포스트

[업무일지] 재기동 직후 놓친 발화를 되살린 Quartz misfire 기본값

배포할 때마다, 스케줄러가 재기동되는 동안 발화 시각을 놓친 잡이 재기동 직후 한 번씩 되살아나 돌았습니다. 원인은 cron 트리거의 misfire 기본 정책이었습니다. 놓친 발화를 “지금 한 번 실행”하도록 되어 있었거든요. 기본값을 DO_NOTHING 으로 바꾸면서 백엔드와 대시보드 폼 두 군데를 같이 고쳤고, simple 트리거는 일부러 두었습니다.

Java 17, Spring Boot 3.3.x, Quartz 2.3 기반 스케줄러 서버입니다. 정해진 시각에 도는 잡을 Quartz 트리거로 관리하고, JobStore 는 DB 를 쓰는 클러스터 모드입니다. 이 글의 클래스명과 잡 이름은 예시로 바꿨습니다.

배포하고 나면 놓친 잡이 한 번씩 되살아났다

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

문제는 배포였습니다. 스케줄러가 재기동되는 동안 발화할 인스턴스가 없으면, 그 사이에 발화 시각이 낀 트리거는 발화를 놓칩니다. 클러스터 모드라도 misfire 판정 기준(65초)보다 오래 비면 마찬가지고요.

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

그런데 재기동하고 나면 놓친 발화가 그냥 넘어가지 않았습니다. 발화를 놓친 잡들이 재기동 직후에 한 번씩 돌았거든요.

대부분의 잡은 이래도 괜찮습니다. 문제는 따로 있었죠. 외부로 요청을 보내거나, 짧은 간격으로 두 번 돌면 부작용이 생기는 잡은 달랐습니다. 예정에 없던 시각에 늦게 한 번 돌고, 간격이 짧은 잡이면 곧이어 정규 발화까지 붙으니 그 부작용이 그대로 났습니다.

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

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

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

저희 등록 코드가 cron 트리거에 넣던 기본값은 MISFIRE_INSTRUCTION_FIRE_ONCE_NOW였습니다. 이름 그대로입니다. 놓친 발화가 몇 번이든 재기동 직후에 한 번 실행합니다. Quartz 자체 기본값인 SMART_POLICY 도 cron 트리거에서는 같은 동작이고요.

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

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

그러니까 이건 버그가 아니었습니다. 기본값이 시킨 대로 정확히 동작한 거죠. 그 기본값이 저희 배포 방식과 안 맞았던 것뿐입니다.

flowchart TD
    A["배포·점검으로 재기동 중<br/>정규 발화 시각 놓침"] --> B{"재기동 후<br/>misfire 정책"}
    B -->|"FIRE_ONCE_NOW<br/>기존 기본값"| C["놓친 발화를<br/>재기동 직후 1회 실행"]
    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에 딱 맞는 값이 없습니다. 억지로 다른 값으로 바꾸면 얻는 건 없이 검증 안 한 동작만 새로 생깁니다.

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

이미 저장된 트리거는 옛 정책을 쓴다

이 기본값은 잡을 새로 등록하거나, 정책이 비어 있는 잡을 다시 저장할 때만 들어갑니다. 이미 등록된 트리거에는 옛 값(Fire once now)이 그대로 남아 있고, 수정 폼도 저장된 값을 불러오기 때문에 기존 잡은 누군가 정책을 직접 바꿔 줘야 새 기본값을 탑니다.

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

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

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