Post

Quartz misfire는 스케줄러 문제가 아니라 워커 기아 문제일 수 있다

Quartz misfire를 단순 스케줄러 설정 문제로 보면 원인을 놓치기 쉽다. 실제로는 다른 잡의 retry 폭주가 worker를 잠식한 사례를 통해 진단 순서와 구조적 대응을 정리한 글이다.

이번 사고에서 먼저 보인 건 AirQualityTasklet이 제시간에 돌지 않았다는 사실이었다.
매시 정해진 시점에 실행돼야 하는 잡이 비었고, 결과 데이터도 들어오지 않았다.

처음엔 Quartz 설정 문제처럼 보였다.
그런데 로그를 따라가 보니 더 이상한 패턴이 있었다. AirQualityTasklet 쪽 로그는 거의 없는데, 같은 시간대에 FeelsLikeService 쪽 로그만 비정상적으로 많이 쌓이고 있었다.

실제 원인은 Quartz 안이 아니라 바깥에 있었다.
외부 API 호출이 잘못 분류된 retry를 타면서 FeelsLike 계열 작업이 worker를 잠식했고, 그 결과 AirQuality 트리거가 발화 시점에 실행 자원을 받지 못해 misfire가 난 것이었다.

이 글에서 말하고 싶은 핵심은 단순하다.

Quartz misfire는 스케줄러 문제가 아니라, 종종 스케줄러 바깥의 워커 기아 문제다.

그래서 misfire를 볼 때는 Quartz 설정부터 만지기 전에, 누가 worker를 먹고 있는지부터 봐야 한다.

misfire가 무서운 이유는 잡이 실패한 게 아니라 시작조차 못 했을 수 있기 때문이다

스케줄러 장애를 볼 때 많은 팀이 먼저 찾는 건 예외 stack trace다.
그런데 misfire는 그 기대를 자주 배신한다.

Quartz에서 특정 잡이 misfire 되는 상황은 보통 이런 느낌으로 나타난다.

  • 잡이 돌아야 하는 시각에 결과 데이터가 안 들어온다
  • 해당 잡의 에러 로그가 없다
  • 심지어 잡 클래스명도 거의 안 보인다

이건 “잡이 실행됐다가 실패했다”가 아니라, 트리거는 있었지만 실행 기회를 못 받은 상태일 수 있다.

그래서 misfire를 디버깅할 때는 해당 잡 코드부터 보는 습관이 오히려 방해가 된다.
문제는 잡 내부 로직이 아니라, 그 잡이 실행되기 전에 worker를 받았는지 여부일 수 있기 때문이다.

처음엔 Quartz 설정 문제처럼 보인다

이런 상황에서 보통 떠오르는 가설은 비슷하다.

  • Quartz thread pool 설정이 너무 작은가
  • misfire 정책이 잘못된 건가
  • trigger 등록이 꼬였나
  • 특정 프로필에서 Quartz auto-startup이 이상하게 동작하나

이 가설들은 틀린 방향은 아니다.
실제로 Quartz 자동설정이나 프로필별 비활성화 설정이 원인이 되는 경우도 있다.

하지만 이번 문제는 그쪽이 아니었다.
트리거는 존재했고, Quartz도 살아 있었고, 스케줄러 자체가 멈춘 것도 아니었다.
문제는 같은 스케줄러 안의 다른 작업들이 worker를 먼저 다 차지하고 있었다는 점이었다.

즉 “왜 이 잡이 안 돌았지?”보다 더 먼저 물어야 할 질문은 이거다.

지금 누가 다른 잡의 실행 기회를 빼앗고 있지?

결정적인 단서는 Quartz misfire 마커였다

이럴 때 가장 먼저 찾아야 하는 건 해당 잡 이름보다 Quartz 자체가 남기는 misfire 마커다.

대표적으로 이런 로그가 반복된다.

1
o.s.s.quartz.LocalDataSourceJobStore: Handling N trigger(s) that missed their scheduled fire-time

이 로그가 매 분 반복된다면, Quartz는 이미 “트리거는 있었는데 제때 실행을 못 시켰다”고 말하고 있는 셈이다.

이 시점에서 중요한 건 두 가지다.

  1. 이 misfire가 특정 시점에만 있었는가
  2. 같은 시간대에 어떤 logger가 폭주하고 있었는가

즉 misfire는 원인 로그가 아니라, 워커 부족의 결과 로그로 보는 편이 맞다.

로그를 보기 전에는 원인이 안 보인다

이번 문제를 푸는 데 실제로 도움이 된 건 CloudWatch 로그를 이벤트 단위로 분해해 보는 작업이었다.
큰 로그 응답을 그대로 눈으로 읽는 건 거의 의미가 없다.
분당 이벤트 수, logger 분포, 특정 키워드 유무를 먼저 뽑아야 한다.

내가 먼저 본 건 세 가지였다.

1. 분당 이벤트 수

폭주가 있었는지 확인하려면 시간대별 이벤트 밀도를 먼저 봐야 한다.

1
2
jq -r '.events[] | (.timestamp + 9*3600*1000)/1000 | strftime("%H:%M")' \
  | sort | uniq -c

여기서 분당 수백~수천 건이 보이면 이미 정상 상태는 아니다.
이 정도면 “잡 하나가 좀 오래 걸린다” 수준이 아니라, 뭔가 반복적으로 계속 실패하고 있다는 신호다.

2. logger 분포

다음은 누가 그 이벤트를 만들고 있는지다.

1
2
3
jq -r '.events[].message' \
  | grep -oE 'c\.[a-z.]+\.[A-Z][A-Za-z]+' \
  | sort | uniq -c | sort -rn | head

상위 logger 1~2개가 거의 모든 이벤트를 차지하면, 그게 사실상 범인이다.
이 단계에서 “문제가 된 잡”이 아니라 “문제를 퍼뜨린 잡”이 보이기 시작한다.

3. misfire 된 잡 키워드 매치

정상 실행이면 해당 잡의 클래스명이나 tasklet 이름이 로그에 남아야 한다.
그런데 0건이라면 그건 잡 내부 실패가 아니라, 애초에 시작을 못 했을 가능성이 높다.

예를 들어 이번엔 이런 식이었다.

  • AirQualityTasklet 검색 결과: 0건
  • FeelsLikeService 검색 결과: 수백 건

이 패턴이 보이면, “왜 AirQualityTasklet이 실패했지?”가 아니라
“왜 FeelsLike 쪽이 worker를 계속 먹고 있지?”로 질문이 바뀌어야 한다.

진짜 원인은 Quartz가 아니라 외부 API retry 폭주였다

로그를 따라가 보면 결국 구조는 단순했다.

  1. 특정 외부 API 클라이언트가 비정상 응답을 계속 받는다
  2. 애플리케이션은 그걸 일반 예외로 본다
  3. 공통 retry 로직이 같은 요청을 계속 반복한다
  4. 그 호출이 같은 dispatcher 또는 같은 스케줄러 worker 자원을 계속 점유한다
  5. 다른 트리거가 제시간에 발화돼도 worker를 받지 못한다
  6. Quartz는 그 결과를 misfire로 기록한다

즉 misfire는 1차 원인이 아니라 2차 증상이었다.

이 점이 중요하다.
misfire 로그만 보고 Quartz thread pool 크기부터 늘리면, 일시적으로는 덜 터질 수 있다.
하지만 retry 폭주가 계속 살아 있으면 같은 문제가 더 큰 자원 위에서 다시 반복된다.

이번 사고에서 Quartz는 잘못 동작한 게 아니라, 오히려 현재 상태를 정직하게 드러내고 있었다.

  • “나는 트리거를 받았다”
  • “그런데 제시간에 실행할 worker를 못 구했다”
  • “그래서 misfire를 남긴다”

문제는 그 worker를 누가 가져가고 있었는지였다.

이런 경우 즉시 대응은 Quartz 튜닝보다 retry 경계 재설정이다

이 상황에서 가장 먼저 할 일은 thread 수를 늘리는 게 아니라, 폭주를 멈추는 것이다.

즉시 대응 관점에서는 보통 이 순서가 맞다.

  • 폭주를 만드는 잡의 retry 정책 확인
  • 명백한 호출 계약 위반이나 명시적 fault를 NonRetryable로 분리
  • max attempts 축소
  • status code나 본문 fault 패턴을 먼저 체크해서 재시도 대상에서 제외

이 단계가 끝나야 worker가 다시 풀린다.
Quartz 쪽 증상도 그제서야 같이 잦아든다.

반대로 retry 경계는 그대로 둔 채 Quartz 설정만 만지면, 겉으로는 잠깐 나아져도 다시 터진다.

구조적으로는 잡별 격리와 동시성 제한이 필요하다

운영 사고를 한 번 겪고 나면 “다음에도 같은 종류의 폭주가 오면 어떡하지?”가 남는다.
이 질문에는 구조적인 답이 필요하다.

대표적으로는 이런 방향이 있다.

1. 잡별 dispatcher 분리

하나의 외부 API 잡이 폭주해도 다른 잡이 같은 자원을 공유하지 않게 만드는 방식이다.
모든 작업이 같은 Dispatchers.IO.limitedParallelism(N)이나 같은 worker pool에 묶여 있으면, 한쪽 문제는 반드시 다른 쪽으로 번진다.

2. 동시 호출 제한

retry가 붙은 외부 호출은 특히 병렬도가 높아질수록 사고 반경이 커진다.
동시성 제한을 두면 실패가 있어도 시스템 전체로 번지는 속도를 늦출 수 있다.

3. 서킷 브레이커 또는 실패 차단 장치

같은 종류의 fault가 연속으로 나올 때는 “계속 다시 해보는 것”보다 일정 기간 차단하는 쪽이 더 안전할 때가 많다.
특히 제공자 쪽 장애나 인증 오류는 재시도보다 차단이 낫다.

핵심은 이거다.

스케줄러 안정성은 Quartz 설정만으로 보장되지 않는다.
잡들이 공유하는 자원 모델까지 같이 설계해야 한다.

misfire를 볼 때 먼저 던질 질문

Quartz misfire를 보면 보통 “스케줄러가 문제인가?”부터 묻게 된다.
하지만 운영 기준으로는 질문 순서를 바꾸는 편이 낫다.

내가 지금 먼저 던지는 질문은 이렇다.

  • misfire 마커 로그가 반복되는가
  • 같은 시간대 분당 이벤트 수가 비정상적으로 치솟았는가
  • 어떤 logger가 대부분의 이벤트를 차지하는가
  • misfire 된 잡은 실제로 시작 로그조차 없는가
  • 그 시간대에 retry 폭주 가능성이 있는 외부 호출이 있었는가

이 다섯 가지를 보면, misfire를 Quartz 내부 문제로 볼지, worker starvation 문제로 볼지 방향이 빨리 잡힌다.

마무리

Quartz misfire는 보기보다 오해하기 쉬운 증상이다.
잡이 실패한 것처럼 보이지만, 실제로는 시작도 못 했을 수 있다.

그래서 misfire를 볼 때는 스케줄러 설정부터 만지기 전에, 누가 worker를 먼저 다 써버리고 있는지부터 봐야 한다.
특히 외부 API retry 폭주가 있는 시스템이라면, misfire는 종종 가장 바깥으로 드러난 2차 증상일 뿐이다.

이번 사고를 지나고 나서 남은 기준은 분명했다.

  • misfire는 Quartz 로그로 확인한다
  • 원인은 로그 분포와 폭주량으로 찾는다
  • 해결은 Quartz 튜닝보다 retry 경계와 자원 격리에서 시작한다

Quartz misfire를 스케줄러 문제로만 보면 원인을 놓친다.
종종 그건 “다른 잡이 이미 시스템을 굶기고 있다”는 운영 신호다.

This post is licensed under CC BY 4.0 by the author.