꾸준하게, 차근차근

[Spring] 스케줄러에서 ShedLock 실행 누락, ThreadPoolTaskScheduler, 그리고 millisecond의 함정 본문

Spring

[Spring] 스케줄러에서 ShedLock 실행 누락, ThreadPoolTaskScheduler, 그리고 millisecond의 함정

jn4624 2025. 6. 17. 23:30
반응형

최근 도메인 서비스를 개발하면서 데이터 처리, 재처리 등을 위한 스케줄러를 구현하게 되었다.

스케줄러는 이전 직장에서도 자주 구현해왔던지라 익숙했지만, 다중 인스턴스 환경에서의 구현은 처음이고 ThreadPoolTaskScheduler, ShedLock 활용도 처음이라 의도한대로 동작하는지 출근하면 로그를 모니터링하는 습관이 생길 정도였다.

 

하지만 왜 슬픈 예감은 틀린 적이 없는지... 🥲

특정 주기의 스케줄러 로그가 누락되는 현상을 두 눈으로 확인한 후에 내 머릿속은 바빠지기 시작했다.

왜 1개의 로그가 안찍혔지? 왜 2개만 로그가 찍힌거지? 내가 잘못 본건가? 😳😳

 

1. 시스템 구성 및 문제 상황

먼저 시스템의 구성을 간단히 소개하자면,

  • ThreadPoolTaskScheduler를 활용해 스케줄러를 관리했고,
  • 스케줄러 작업마다 @SchedulerLock(ShedLock, DB 기반 분산 락)을 적용했다.
  • 1분마다 실행되어야 하는 스케줄러가 3개, 3초마다 실행되는 스케줄러가 2개 있었다.
  • ThreadPoolTaskScheduler의 pool size는 4로 설정되어 있었다(사내 템플릿 프로젝트의 기본 설정).

문제는, 1분마다 실행되어야 하는 스케줄러 3개가 문제였다.

스케줄러가 "매번 정확히 3개가 동시에 실행되어야 하는데, 어떤 시점에는 2개만 실행되고 어떤 시점에는 1개만 실행되는 등 실행 누락이 반복적으로 나타나는 것"이었다.

 

로그로 보면 아래와 같은 상황이 자주 보였다.

00:15:00.057 scheduler-1 실행
00:15:00.058 scheduler-2 실행
00:15:00.064 scheduler-3 실행

00:16:00.079 scheduler-2 실행
00:16:00.079 scheduler-3 실행
// scheduler-1 누락

00:22:00.035 scheduler-1 실행
00:22:00.035 scheduler-3 실행
00:22:00.035 scheduler-2 실행

00:23:00.055 scheduler-1 실행
// scheduler-3 누락
// scheduler-2 누락

 

즉, 3개의 스케줄러가 1분마다 반드시 실행되어야 하는데, 간헐적으로 한두개가 누락되는 현상이 계속 반복되었다. 😭😭

 

2. 첫 번째 시도: ThreadPoolTaskScheduler의 pool size 증가

처음에는 단순히 ThreadPoolTaskScheduler의 pool size가 부족해서 동시에 여러 스케줄러가 실행될 때 스레드 할당이 안되어 누락되는건 아닐까 의심했다.

 

pool size가 4로 설정되어 있었고, 1분마다 실행되는 스케줄러가 3개, 3초마다 실행되는 스케줄러가 2개이니 최대 5개가 동시에 겹치는 경우 pool size가 부족할 수도 있다고 판단했다.

 

그래서 pool size를 5로 늘려서 배포해봤지만, 문제는 여전히 해결되지 않았다.

로그 패턴이 그대로 반복되었고, 여전히 스케줄러가 누락되는 일이 사라지지 않았다.

 

이 과정에서

  • ThreadPoolTaskScheduler는 pool size만큼의 스레드를 미리 생성해두고,
  • 동시에 여러 예약 작업을 실행할 때 각 작업에 빈 스레드를 할당해준다.
  • pool size가 넉넉하면, 단순 스레드 경쟁 때문에 스케줄러가 누락되는 일은 없다.

실제로 작업 자체가 아주 짧게 끝나고 있어서 pool size 부족 문제가 아니라는 점을 다시 한 번 확인하게 되었다. 😅

 

3. 원인 분석: ShedLock(@SchedulerLock) 설정과 실행 타이밍

ThreadPoolTaskScheduler의 설정에는 문제가 없다는 결론에 도달하면서 자연스럽게 @SchedulerLock(ShedLock)의 설정을 다시 들여다보게 되었다.

 

@SchedulerLock의 기본 원리

  • 각 스케줄러마다 name을 지정해서 DB에 락을 건다.
  • 락을 잡은 인스턴스만 해당 작업을 수행하고, 락이 풀릴 때까지는 같은 name의 다른 작업(심지어 같은 인스턴스라도)은 실행되지 않는다.

lockAtLeastFor가 핵심

문제 상황에서는 각 스케줄러의 @SchedulerLock에 lockAtLeastFor = "PT1M", lockAtMostFor = "PT2M"을 설정했었다.

이 설정의 의미는

  • 작업이 아무리 빨리 끝나도 락은 최소 1분 동안 유지된다는 것이다.
  • 즉, 스케줄러가 1분마다 실행된다고 하더라도, 락이 1분간 유지되고 있는 동안에는 ShedLock이 락을 잡지 못해 작업이 스킵(누락)된다.

 

4. 실행 누락의 진짜 원인: 밀리초(millisecond) 오차와 락 해제 타이밍

그런데, 실제 로그를 보면 "정확히 1분이 지난 시점"에 다음 작업이 실행을 시도하는데도 락을 못 잡는 경우가 있다.

 

예를 들어,

  • 00:15:00.057에 작업이 실행되고 락이 걸림 (lock_until = 00:16:00.057)
  • 00:16:00.079에 다음 작업이 락을 잡으려고 시도 -> 분명 1분이 지난 시점인데, 락을 못 잡고 실행이 누락된다.

이유는 무엇일까?

실제 락 만료 타이밍과 트리거 오차

  • 락 해제 타이밍(lock_until)과, 실제 @Scheduled가 트리거되는 타이밍 사이에는 서버/DB 시스템간 millisecond 단위의 clock drift(시계 오차), 또는 DB write/read 지연, JVM GC/스레드 스케줄링 오차 등 다양한 현실적 요소로 인해 정확히 1분이 보장되지 않는다.
  • 예를 들어 DB lock_until 값이 00:16:00.057인데, 애플리케이션의 시스템 시계가 DB보다 10~20ms 느리거나, DB와 네트워크 전송/처리 지연 등으로 실제 lock_until보다 미묘하게 오차가 발생할 수 있다.
  • 이럴 경우, 00:16:00.079에 락을 잡으러 왔지만 DB에서는 lock_until보다 아직 current_time이 작거나 같다고 판단, 락 획득에 실패하게 된다.

ShedLock의 락 체크 로직

  • ShedLock은 락을 잡으려 할 때 현재 시간(current_time)이 lock_until보다 "확실히 큰 경우"에만 락을 획득한다.
  • 1ms라도 current_time이 lock_until보다 작거나 같으면 락을 잡지 못하고 바로 실행이 누락된다.

이렇게 아주 미세한 차이(0.01초, 0.02초)가 실제 스케줄러의 실행 누락으로 이어질 수 있다.

 

실제로 ShedLock 테이블의 갱신되는 정보를 확인하기 위해 새로고침을 여러번 해본 결과 누락된 스케줄러의 시간만 갱신이 되지 않고 있었다.

 

5. 최종 해결: lockAtLeastFor 설정값 조정

문제의 진짜 원인을 파악한 뒤에는 해결은 명확했다.

  • lockAtLeastFor 설정값을 스케줄러 주기(1분)보다 더 짧게, 스케줄러 데이터 처리 예상 시간을 고려하여 PT2S로 조정하여 락이 항상 다음 실행 트리거 전에 미리 해제될 수 있도록 했다.
  • 실제로 lockAtLeastFor를 2초로 조정한 뒤에는 스케줄러가 빠짐없이 1분마다 모두 정상적으로 실행되는 것을 로그로 확인할 수 있었다. 😮‍💨

lockAtLeastFor는 "실제 작업 소요 시간 + 1~2초 여유" 정도로만 설정하고, lockAtMostFor는 장애 복구를 고려해 넉넉하게 설정하는 것이 효율적인 듯하다....

 

6. 이번 삽질을 통해 배운 점을 정리해보자!

  • ThreadPoolTaskScheduler의 pool size는 단순히 스레드 경쟁 문제만을 해결할 뿐, lockAtLeastFor 등 스케줄러 락 설정 이슈까지 해결해주지 않는다. 🥲
  • @SchedulerLock의 lockAtLeastFor 설정값이 실행 주기와 같거나 더 길면 DB/서버의 millisecond 단위 오차, 네트워크 지연 등 여러 현실적 변수로 인해 스케줄러 실행이 주기적으로 누락될 수 있다. 🥲🥲
  • 정확히 "1분" 같은 딱 맞는 수치는 항상 위험하며, lockAtLeastFor를 스케줄러 주기보다 몇 초 이상 충분히 작게 잡는 것이 안전하다. 🥲🥲🥲
  • ShedLock/분산 락 기반 스케줄러를 사용할 때는 시스템간 시계 동기화(NTP 등), DB/서버 간 시간차, 네트워크 상황 등 현실적인 인프라 환경을 반드시 고려해야 한다. 🥲🥲🥲🥲

 

사실 설계와 개발도 아직 경험이 많이 부족하지만, 이런 수치를 결정하는 것에 대한 경험은 전무하다 보니 이런 결과를 초래하게 된 것 같다.

그래도 이런 시행착오를 겪으며 성장해나가리라 믿는다!!!!!!! 👊

그럼 오늘의 삽질 일기는 여기서 이만 🙇🙇🙇🙇

 

END.

반응형