5 분 소요

현재 코드에서는, 단기예보조회를 일정 시간마다 자동으로 호출 & 알람 재시도 로직을 자동으로 호출하기 위해 Scheduling을 사용해야 한다. 그러나 pod가 여러대일 경우, 각 pod마다 똑같은 작업인 scheduling을 모두 수행하게 되면 성능이 나빠질 수 있다.

따라서 같은 작업을 여러번 도는 것을 막아야 하는데, 이를 막기 위한 방법을 정리해보고 왜 shedlock을 선택했는지 기록하고자 한다.

  • lock의 종류 : ShedLock, Redis 분산락, k8s cronjob, DB status column

1. ShedLock

기본 컨셉은, DB row 하나를 두고 먼저 잡는 프로세스가 성공, 나머지는 모두 실패의 개념이다.

예를 들어, pod 1과 pod 2가 동시에 @Scheduled 붙은 메서드에 접근하여 실행을 시도한다고 해보자. 이때, 둘다 shedlock table에 접근하여, 같은 name을 가진 row에 INSERT/UPDATE를 시도한다. 둘 중 하나의 pod만 성공하며, 해당 pod만 작업을 실행한다.

DB를 이용한 매우 간단하며 직관적인 lock 방식이다. 이를 구현하기 위해서는 lock의 이름, lock을 잡은 시간, lock을 언제까지 잡을건지, lock은 누가 잡았는지를 유지해야 한다.

따라서, 아래와 같이 먼저 gradle에 의존성을 추가해주고,

implementation("net.javacrumbs.shedlock:shedlock-spring:5.13.0") // shedlock & spring framework 연동
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.13.0") // lock 저장소로 RDB 사용 가능하게 함

DB 테이블을 아래처럼 추가해준다.

CREATE TABLE IF NOT EXISTS shedlock
(
    name VARCHAR(64) PRIMARY KEY NOT NULL,
    locked_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    lock_until TIMESTAMP(6) NOT NULL,
    locked_by VARCHAR(256) NOT NULL
);

또한 shedlock을 적용할 config 파일도 만들어준다.

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S") // ShedLock 기능을 활성화하겠다.
class ShedLockConfig {
  @Bean
  fun lockProvide(
    dataSource: DataSource
  ): LockProvider = JdbcTemplateLockProvider (
    // implementation
  )
}

(내부구현에서 .usingDbTime()을 사용하였는데, 이는 각 서버의 시스템 시간이 미묘하게 달라 락 관련 이슈가 발생할 수 있는 가능성을 막기 위해 DB 시간을 기준으로 통일해준 것이다. 그래서 JdbcTemplateLockProvider의 여러 종류의 생성자 중 기본 생성자를 사용하지 않았다)

이후 @Scheduled 붙은 함수에 @SchedulerLock을 아래처럼

@SchedulerLock(
  name = "저는락입니다.",
  lockAtMostFor = "PT10S", // 최대 락 유지시간
  lockAtLeastFor = "PT1S" // 최소 락 유지시간
)

붙이면 된다.

  • 동작 : @Scheduled 메서드가 각기 다른 pod에서 동시에 실행 -> shedlock 로직이 추가된 proxy 객체가 요청을 가로채어(HTTP 요청이 아니라, 메서드 호출을 의미) 등록했던 LockProvider에게 lock 잡으라고 요청 -> JdbcTemplateLockProvider가 내부적으로 DB 연산을 이용하여 lock을 시도 -> 하나의 pod만 성공
    과 같은 내부 흐름을 따른다.

2. Redis 분산락

Redis를 이용한 락 방식이며, 크게 2가지 방식인 SpinLock(직접 구현 방식)과 라이브러리 활용(Redisson 등)이 있다.

  • SpinLock : Redis의 SET 명령어를 이용한다. 구체적으로는 SET key value NX PX timeout을 사용하여(NX = Not eXists : 해당 key가 존재하지 않을 경우 명령어 수행, PX timeout : 만료시간(ms)) 락을 직접 구현한다. 명령어를 해석해보면, key가 존재하지 않는 경우(= lock 잡은 프로세스가 없을 경우) 해당 key-value를 저장하여, timeout 이후 key를 삭제한다. key가 이미 존재한다면 아무 작업도 하지 않고 nil을 반환한다.

근데 이런 방식으로 락 유무를 확인하려면 저 명령어를 계속 보내야 한다. 따라서 Redis에 부하가 심하게 걸린다.

  • Redisson : 특정 서버가 락을 획득하면, Redis의 특정 채널을 구독하는 방식이 이루어지며, 해당 서버가 다시 락을 해제할 때 해당 채널에 메시지를 전송하여 락이 풀림을 알리는 방식이다. pub/sub는 구체적으로 대기중인 스레드를 깨우는 방식으로 구현된다.
    (좀더 공부해보니, 락보다 작업시간이 길면 특별한 백그라운드 스레드를 실행해서 락 시간을 갱신하는 매커니즘이 존재하고, 내부적으로 락 생성과 유효시간 설정 두 작업의 원자성을 보장하기 위해 Lua Script를 사용한다고 함)

해당 방법은 Redis를 사용하는 만큼, 굉장히 빠르다(인메모리). 또한 DB에 부하가 없다. 따라서, 락을 굉장히 자주 잡아야 하는 상황에 유리하다.
그러나, Redis 장애가 발생하면 락을 사용하지 못해 전체 서비스에 영향을 줄 수 있다는 단점이 있다.

3. k8s Cronjob

쿠버네티스에서 스케줄링을 위해 제공하는 방법이다. 기본 실행주기가 분 단위이고, linux의 crontab 문법을 기반으로 한다(관련 내용은 서비스 배포하며 좀 더 자세히 다뤄볼 예정).
CronJob 내부 컨트롤러가 주기적으로 클러스터를 훑어보며 실행할 작업을 찾고, 찾으면 Pod를 새로 생성하여 작업을 수행한다.

여기서 컨트롤러가 일정 주기마다 훑어본다고 해서, 초단위 제어가 가능하다고 바로 생각하면 안된다. 컨트롤러가 10초마다 확인하는 이유는 분 단위 실행주기에서 해당 시각이 되었을때 작업을 최대한 빨리 제시간에 시작하기 위해서이기 때문이다.

4. DB status column

정식 명칭은 아니고, lock 도입을 하며 내가 대략적으로 생각해본 방법이다.
현재 알람 삽입 재시도 로직은 alarm outbox DB table을 조회하고 있는데, 해당 상황에서 Query를

UPDATE alarm_outbox
SET status = '처리중'
WHERE '대략 올바른 조건'
LIMIT 1;

이런 식으로 DB atomic update 때와 비슷한 쿼리를 작성한다면 오직 하나의 pod만 작업을 수행할 수 있을 것이다.
장점은 추가 인프라 없이 기존 DB column을 이용하여 해결할 수 있는 것인데.. 문제는 status를 변경한 후 서버가 다운되면 추가 복구로직이 없는 이상 이를 복구할 방법이 없으며, 추가적인 관리 로직을 또 구현해야한다.

가장 큰 예상문제는 서버의 성능 저하이다. 해당 로직은 모든 pod가 많은 데이터를 담은 DB table에 동시에 접근하여 한번이 아닌 주기적으로 UPDATE 쿼리를 날리므로 성능이 저하될 것이다.
반면 ShedLock의 경우, shedlock DB table은 row 수가 매우 적으며(상대적으로), 따라서 위 방법에 비해서 훨씬 성능에 영향을 덜 받는다.

그리고 이 경우 서비스 로직과 락 로직이 비즈니스 계층에서 섞여버리는 그런 현상이 발생하기 때문에 서비스 설계상 둘을 분리하여, 락 로직을 분리 가능하고 정기적으로 수행할 수 있는 shedlock이 이러한 측면에서도 더욱 효과적이라고 판단했다.

또한, 이미 이 프로젝트에서 MySQL을 사용중이며, 단기예보조회 정보 가져오기는 주기적인 작업, k8s에서 멀티 pod 환경을 고려하고 있다.
Scheduler의 실행 주기를 각각 3시간/10초로 설정한 만큼(기상청 단기예보조회 정보는 실제로 3시간마다 발표됨, 알림 삽입 재시도 로직은 10초로 설정함), 1초에 수십 수백번의 락을 잡아야 하는 환경도 아니므로 초고성능을 목표로 하기보다는 pod의 scheduler 중복 실행을 막는게 운영상 최우선 목적이다.

따라서, 매초 수십 수백번 락을 잡는 상황도 아니며 단순히 몇초 단위로 재시도만 하면 되기 때문에 Redis 분산락의 성능 향상 이점은 고려하지 않았고, 더군다나 우리 서비스는 알림 큐로 redis를 사용중이므로 redis 장애 발생 시 서비스 전체가 멈춰버릴 수 있고, redis를 알림 큐로 씀과 동시에 lock 용으로 같이 쓰게되면 알림 큐로서의 의미가 퇴색될 수 있다고 판단했다(역할 혼용). 그래서 Redis 분산락은 사용하지 않았다.

또한 cronjob은 다 좋은데, 기본 단위가 분단위라 초단위 알람을 고려하는 우리 서비스에서는 부적절하다고 판단했고, 만약 분 단위로 알람 삽입 재시도를 수행했는데 실패한다면 또 분 단위로 기다려야 하므로 알람이 늦게 전송될 수도 있어 우리 서비스처럼 초/분단위 알람이 중요한 경우에는 부적절하다고 판단하여 고려하지 않았다.

ShedLock의 경우는 무엇보다 이미 mysql을 사용하고 있고 구현이 간단하여 우리 서비스 성격과 잘 맞으며, 오직 한번의 실행만 보장한다. 또한 @SchedulerLock을 통해 락 최대 유효기간을 지정함으로써, 서비스 장애가 발생 시 락을 영원히 잡고 있는 문제 또한 해결할 수 있기에 shedlock 방식을 채택했다.

이렇게 scheduling과 lock에 대해 정리해보고 적용해보았는데, 추가적인 기능을 위해 단순히 또 다른 기술 스택을 도입하기보다 이미 주어진 개발 환경을 최대한 효율적으로 활용하여, 얼마나 우리 서비스 입맛에 맞게 적용해야 하는지가 서버 설계의 중요한 부분임을 알게 되었다.

또한 cronjob과 같은 인프라 쪽에서 지원하는 스케줄링과 애플리케이션 레벨에서 제어하는 스케줄링 로직을 비교하면서 서버가 어디서부터 어디까지 lock을 보장해야 하는가 또한 전체적으로 이해한 것 같다. 좋은 경험이었다.

카테고리:

업데이트: