포스트

[업무일지] 배포 때마다 밀린 배치를 몰아 실행한 Quartz misfire 기본값

롤링 배포를 할 때마다, 배포 중 발화 시각을 놓친 스케줄이 재기동 직후 밀린 실행을 한꺼번에 몰아 돌렸습니다. 원인은 Quartz의 misfire 기본 정책이었습니다. 놓친 발화를 “지금 한 번 몰아서 실행”하도록 되어 있었거든요. 기본값을 “아무것도 안 함”으로 바꿨고, 그 과정에서 기본값이 숨어 있던 두 층을 함께 고치고 한 축은 일부러 두었습니다.

Java 21, Spring Boot 3.x, Quartz 2.3 기반 스케줄러 서버입니다. 정해진 시각에 도는 잡을 Quartz 트리거로 관리합니다. 이 글의 클래스명과 잡 이름은 예시로 바꿨습니다.

배포하고 나면 놓친 배치가 한꺼번에 돌았다

스케줄러는 정산·회수 같은 잡을 정해진 시각에 발화시킵니다. 평소엔 문제가 없었습니다.

문제는 롤링 배포였습니다. 파드가 순차로 내려갔다 올라오는데, 그 사이에 발화 시각이 낀 트리거는 발화를 놓칩니다.

여기까지는 어쩔 수 없는 일이죠. 배포하는 몇 분 동안 스케줄러가 잠깐 비는 거니까요.

그런데 재기동하고 나면 놓친 발화가 그냥 넘어가지 않았습니다. 재기동 직후에 밀린 실행이 한꺼번에 돌았거든요.

대부분의 잡은 이래도 괜찮습니다. 문제는 따로 있었죠. 외부로 요청을 보내거나 짧은 간격으로 두 번 돌면 부작용이 생기는 잡은, 재기동하자마자 그동안 놓쳤던 발화까지 한꺼번에 몰려 들어오면서 그 부작용이 그대로 몇 배로 커졌습니다. 그게 곤란했습니다.

배포 시각과 겹치기만 하면 이게 반복됐습니다. 점검으로 잠깐 내렸다 올려도 마찬가지였고요.

놓친 발화를 어떻게 할지는 misfire 정책이 정한다

Quartz에는 misfire라는 개념이 있습니다. 트리거가 예정된 발화 시각을 놓쳤을 때, 그 시각에 스케줄러가 꺼져 있었든 실행할 스레드가 없었든 상관없이, 뒤늦게 그 놓친 발화를 어떻게 처리할지 미리 정해두는 정책입니다. 놓친 걸 되살릴지 버릴지의 문제죠.

우리 기본값은 MISFIRE_INSTRUCTION_FIRE_ONCE_NOW였습니다. 이름 그대로입니다. 놓친 발화를 재기동 직후에 한 번 몰아서 실행합니다.

Quartz의 misfire 정책은 몇 가지가 있습니다.

  • Fire once now: 놓친 발화를 지금 한 번 실행한다
  • Do nothing: 놓친 발화는 버리고 다음 정규 스케줄부터 정상 실행한다
  • Smart policy: 트리거 종류에 따라 Quartz가 알아서 고른다

그러니까 우리가 겪은 몰아 실행은 버그가 아니었습니다. 기본값이 시킨 대로 정확히 동작한 거죠. Quartz의 기본 정책이 우리 배포 방식과 안 맞았던 것뿐입니다.

같은 “발화 놓침” 상황에서 두 기본값이 어떻게 갈리는지 보면 이렇습니다.

flowchart TD
    A["롤링 배포·점검 중<br/>정규 발화 시각 놓침"] --> B{"재기동 후<br/>misfire 정책"}
    B -->|"FIRE_ONCE_NOW<br/>기존 기본값"| C["놓친 발화를<br/>재기동 직후 몰아 실행"]
    B -->|"DO_NOTHING<br/>바꾼 기본값"| D["놓친 발화는 버리고<br/>다음 정규 시각에 1회"]
    classDef bad fill:#c62828,color:#fff,stroke:#b71c1c
    classDef fix fill:#2e7d32,color:#fff,stroke:#1b5e20
    class C bad
    class D fix

기본값을 “아무것도 안 함”으로 바꿨다

그래서 cron 트리거의 misfire 기본값을 MISFIRE_INSTRUCTION_DO_NOTHING으로 바꿨습니다.

1
2
3
4
5
6
7
// 잡 등록 시 cron 트리거 생성
trigger = createCronTrigger(CronTriggerRequest.builder()
        .cronExpression(cronExpression)
        .misfireInstruction(resolveMisfireInstruction(
                scheduleJob.getMisfireInstruction(),
                CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING)) // 기존: FIRE_ONCE_NOW
        .build());

여기서 중요한 건 resolveMisfireInstruction이었습니다. 잡마다 misfire 정책을 직접 고를 수 있는데, 그 명시값이 있으면 그대로 쓰고 없을 때만 기본값을 씁니다.

1
2
3
private int resolveMisfireInstruction(Integer misfireInstruction, int defaultInstruction) {
    return misfireInstruction == null ? defaultInstruction : misfireInstruction;
}

그러니까 바꾼 건 “고르지 않았을 때의 기본값” 하나뿐입니다. 몰아 실행이 꼭 필요한 잡은 여전히 Fire once now를 명시하면 됩니다. 안전한 쪽을 기본으로 두고, 위험한 쪽을 명시하게 뒤집은 거죠.

기본값은 두 군데에 숨어 있었다

여기서 한 번 헛짚었습니다. 백엔드만 고치면 되는 줄 알았거든요.

잡을 만드는 경로가 둘이었습니다. 하나는 API로 등록하는 경로입니다. 이건 넘어온 misfire 값이 null이면 방금 고친 백엔드 기본값을 탑니다.

다른 하나는 운영 대시보드 화면에서 사람이 직접 만드는 경로였습니다. 이쪽은 화면 폼의 기본 선택값이 따로 있었습니다. 백엔드 기본값을 바꿔도, 화면에서 만든 잡은 폼이 넣어주는 옛 값(Fire once now)으로 등록됐던 거죠.

그래서 폼도 같이 고쳤습니다. 생성 폼의 초기값을 Do nothing으로 바꾸고, 수정 폼도 값이 비어 있을 때의 fallback을 같은 값으로 맞췄습니다.

1
2
3
4
5
6
7
8
// 생성 폼 초기값
document.getElementById("edit_misfire").value = "2"; // 기존: "1"

// 수정 폼: 저장된 값이 없으면 새 기본값으로
edit_misfire.value =
    data.misfireInstruction === undefined || data.misfireInstruction === ""
        ? "2" // 기존: "1"
        : String(data.misfireInstruction);

템플릿의 <select>도 손봤습니다. JS가 값을 세팅해주니 기능상 안 바꿔도 됩니다. 그래도 Do nothing 옵션을 맨 위로 올리고 selected를 줬습니다. JS가 늦게 돌거나 안 도는 순간에도 정적 HTML의 기본값이 새 정책과 어긋나지 않게요.

simple 트리거는 일부러 그대로 뒀다

바꾸다 보면 대칭을 맞추고 싶어집니다. cron 트리거를 바꿨으니 simple 트리거도 바꿔야 할 것 같았거든요. 그런데 안 바꿨습니다.

이유는 둘입니다. 우선 대시보드에서 만드는 잡은 전부 cron입니다. cron 여부 플래그의 기본값이 참이고 화면에 그걸 끄는 토글이 없어서, simple 트리거 경로는 API로 명시해서 넣을 때만 도달합니다. 콘솔에서 만든 잡엔 쓰이지 않는 경로였던 거죠.

그리고 SimpleTrigger에는 DO_NOTHING에 딱 맞는 값이 없습니다. 억지로 다른 값으로 바꾸면 얻는 건 없이 검증 안 한 동작만 새로 생깁니다.

그래서 검토한 뒤 그대로 뒀습니다. 안 만진 게 아니라, 만지지 않기로 판단한 겁니다. 이 판단은 근거와 함께 리뷰에 남겼습니다. 나중에 누가 “여기는 왜 안 바꿨지” 하고 다시 파는 걸 막으려고요.

맺음말

바꾼 뒤로 배포와 겹친 잡이 몰아 실행되는 일은 사라졌습니다. 놓친 발화는 다음 정규 시각에 한 번 정상적으로 돕니다.

포기한 것도 있습니다. DO NOTHING이 모든 잡에 정답은 아닙니다. 하루에 한 번 도는 정산 집계처럼 “놓쳤으면 반드시 한 번은 돌아야 하는” 잡은 오히려 Fire once now가 맞습니다. 이번 변경이 그런 잡을 자동으로 가려주지는 않습니다. 기본을 안전한 쪽으로 옮겼을 뿐, 잡별로 뭐가 맞는지는 여전히 사람이 판단해서 명시해야 합니다.

남은 한계는 그 명시가 강제되지 않는다는 겁니다. 새 잡을 만들 때 misfire 정책을 안 고르면 조용히 DO NOTHING이 됩니다. 대부분은 그게 안전하지만, catch-up이 꼭 필요한 잡을 만들면서 깜빡하면 놓친 발화가 소리 없이 사라지겠죠. 잡 성격에 따라 정책을 강제로 고르게 할지는 아직 정하지 못했습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.