<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Misfire on jyukki's Blog</title><link>https://jyukki.com/tags/misfire/</link><description>Recent content in Misfire on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Sun, 05 Jul 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/misfire/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Scheduler Misfire와 Backfill Control, 놓친 배치와 따라잡기 폭주를 다루는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-scheduler-misfire-backfill-control-playbook/</link><pubDate>Sun, 05 Jul 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-scheduler-misfire-backfill-control-playbook/</guid><description>크론·배치·주기 작업이 지연되거나 누락됐을 때 skip, coalesce, replay, manual backfill 중 무엇을 선택할지 실무 기준과 운영 가드를 정리합니다.</description><content:encoded><![CDATA[<p>스케줄러 장애는 보통 두 가지 모습으로 나타납니다. 하나는 같은 작업이 두 번 도는 중복 실행이고, 다른 하나는 제때 돌지 못한 작업이 나중에 한꺼번에 몰리는 <strong>따라잡기 폭주</strong>입니다. 많은 팀이 첫 번째 문제는 비교적 빨리 인식합니다. 중복 정산, 중복 알림, 중복 외부 API 호출은 결과가 눈에 띄기 때문입니다. 반대로 두 번째 문제는 더 조용합니다. 야간 배치가 3시간 멈췄다가 복구 직후 밀린 작업을 모두 실행하고, 그때 운영 DB와 검색 인덱스, 외부 API가 같이 흔들립니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-distributed-scheduler-singleton-playbook/">분산 스케줄러 Singleton 실행 보장</a>의 다음 단계입니다. &ldquo;한 번만 실행&quot;뿐 아니라 &ldquo;놓친 실행을 어떻게 처리할 것인가&quot;를 다룹니다. 함께 보면 좋은 글은 <a href="/learning/deep-dive/deep-dive-spring-batch-scheduling/">Spring Batch와 스케줄링 기초</a>, <a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency와 Reprocessing</a>, <a href="/learning/deep-dive/deep-dive-cdc-connector-lag-snapshot-recovery-playbook/">CDC Connector Lag와 Snapshot Recovery</a>, <a href="/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/">Workload-aware Queue Partitioning</a>입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>Scheduler misfire를 단순 지연이 아니라 scheduled time, business window, side effect 기준으로 해석할 수 있습니다.</li>
<li>놓친 작업을 skip, coalesce, replay, manual backfill 중 어디로 보낼지 판단하는 기준을 세울 수 있습니다.</li>
<li>Backfill이 온라인 트래픽을 밀어내지 않도록 concurrency, batch size, throttle, pause 조건을 숫자로 잡을 수 있습니다.</li>
<li>배치 실행 이력과 재처리 증거를 남겨 장애 후 &ldquo;무엇이 처리됐고 무엇이 남았는가&quot;를 빠르게 판단할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-misfire는-늦게-시작만-뜻하지-않는다">1) Misfire는 &ldquo;늦게 시작&quot;만 뜻하지 않는다</h3>
<p>스케줄러에서 misfire는 예정된 시각에 작업이 실행되지 못한 상태를 말합니다. 하지만 운영에서는 네 가지를 나눠야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>유형</th>
          <th>설명</th>
          <th>대표 원인</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>delayed start</td>
          <td>예정 시각보다 늦게 시작</td>
          <td>배포, 인스턴스 재시작, 락 경합</td>
      </tr>
      <tr>
          <td>missed run</td>
          <td>해당 window가 아예 실행되지 않음</td>
          <td>scheduler down, cron 설정 오류</td>
      </tr>
      <tr>
          <td>duplicate run</td>
          <td>같은 business window가 두 번 이상 실행</td>
          <td>split brain, 수동 재실행, retry 버그</td>
      </tr>
      <tr>
          <td>late completion</td>
          <td>시작은 했지만 업무 마감 전에 끝나지 않음</td>
          <td>데이터 증가, 외부 API 지연, DB 포화</td>
      </tr>
  </tbody>
</table>
<p>이 네 가지를 같은 &ldquo;배치 실패&quot;로 기록하면 복구가 느려집니다. 예를 들어 매일 02:00에 전날 주문을 정산하는 job이 05:00에 시작했다면 단순 지연일 수 있습니다. 반대로 매시 5분마다 유저 상태를 expire하는 job이 6시간 멈췄다면 72개 window를 어떻게 처리할지 결정해야 합니다. 더 나쁜 경우는 job이 늦게 끝났는데 다음 주기가 시작되어 같은 데이터를 동시에 건드리는 상황입니다.</p>
<p>그래서 중요 job은 최소한 아래 시간을 분리해 기록해야 합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">job_execution</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">job_name</span>: <span style="color:#f1fa8c">&#34;expire-reservations&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">scheduled_at</span>: <span style="color:#f1fa8c">&#34;2026-07-05T02:05:00+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">started_at</span>: <span style="color:#f1fa8c">&#34;2026-07-05T04:10:12+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">completed_at</span>: <span style="color:#f1fa8c">&#34;2026-07-05T04:11:30+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">business_window_start</span>: <span style="color:#f1fa8c">&#34;2026-07-05T02:00:00+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">business_window_end</span>: <span style="color:#f1fa8c">&#34;2026-07-05T02:05:00+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">trigger_reason</span>: <span style="color:#f1fa8c">&#34;catch_up&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">replay_group_id</span>: <span style="color:#f1fa8c">&#34;bf_20260705_0410&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">idempotency_key</span>: <span style="color:#f1fa8c">&#34;expire-reservations:2026-07-05T02:00&#34;</span>
</span></span></code></pre></div><p><code>started_at</code>만 있으면 &ldquo;언제 돌았는가&quot;만 압니다. <code>scheduled_at</code>과 <code>business_window</code>가 있어야 &ldquo;무엇을 처리했는가&quot;를 압니다.</p>
<h3 id="2-놓친-실행은-모두-다시-돌리면-위험하다">2) 놓친 실행은 모두 다시 돌리면 위험하다</h3>
<p>스케줄러가 3시간 멈췄을 때 가장 쉬운 구현은 &ldquo;밀린 실행을 전부 순서대로 실행&quot;입니다. 하지만 모든 job이 replay를 요구하지 않습니다. 최신 상태만 맞추면 되는 작업을 36번 다시 돌리면 불필요한 DB 부하만 만듭니다. 반대로 모든 거래 window를 증거로 남겨야 하는 정산 job을 최신 한 번으로 합치면 데이터 누락이 생깁니다.</p>
<p>의사결정은 업무 의미로 나눕니다.</p>
<table>
  <thead>
      <tr>
          <th>정책</th>
          <th>의미</th>
          <th>적합한 작업</th>
          <th>기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>skip</td>
          <td>오래된 window는 버림</td>
          <td>캐시 warmup, 비핵심 통계 refresh</td>
          <td>최신 실행만 가치 있음</td>
      </tr>
      <tr>
          <td>coalesce</td>
          <td>여러 window를 하나로 합침</td>
          <td>상태 동기화, 검색 색인 보정</td>
          <td>최종 상태가 중요</td>
      </tr>
      <tr>
          <td>replay</td>
          <td>각 window를 순서대로 실행</td>
          <td>정산, 청구, 감사 로그 집계</td>
          <td>window별 증거가 필요</td>
      </tr>
      <tr>
          <td>manual backfill</td>
          <td>자동 실행하지 않고 승인 대기</td>
          <td>대량 마이그레이션, 외부 비용 큰 작업</td>
          <td>비용과 위험이 큼</td>
      </tr>
  </tbody>
</table>
<p>기본값은 replay가 아닙니다. 운영 기준으로는 <strong>skip 또는 coalesce를 먼저 검토하고, 업무 증거가 필요한 경우에만 replay</strong>하는 편이 안전합니다. 특히 외부 API 호출, 이메일/푸시 발송, 결제/정산 반영처럼 side effect가 있는 job은 자동 replay 전에 idempotency와 중복 발송 방지 기준을 확인해야 합니다.</p>
<h3 id="3-backfill은-숨은-production-workload다">3) Backfill은 숨은 production workload다</h3>
<p>Backfill은 과거 누락분을 메우는 작업입니다. 이름은 보조 작업처럼 들리지만 실제로는 운영 DB, replica, 큐, 검색 인덱스, 외부 API를 쓰는 production workload입니다. 온라인 API와 같은 connection pool을 쓰거나 같은 worker pool을 쓰면, &ldquo;복구 작업&quot;이 현재 사용자 경험을 망칠 수 있습니다.</p>
<p>권장 초깃값:</p>
<table>
  <thead>
      <tr>
          <th>항목</th>
          <th>기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>온라인 API p95 악화</td>
          <td>평소 대비 10% 초과 시 backfill pause</td>
      </tr>
      <tr>
          <td>DB CPU 추가 사용률</td>
          <td>20% 이하</td>
      </tr>
      <tr>
          <td>replica lag</td>
          <td>60초 초과 시 pause, 5분 초과 시 중단 후 재계획</td>
      </tr>
      <tr>
          <td>batch size</td>
          <td>row 500~5,000부터 시작, lock wait 보고 조정</td>
      </tr>
      <tr>
          <td>concurrency</td>
          <td>기본 1~2, shard별 최대 4 이하</td>
      </tr>
      <tr>
          <td>외부 API 호출</td>
          <td>provider rate limit의 30% 이하</td>
      </tr>
      <tr>
          <td>catch-up window</td>
          <td>일반 작업 24시간, 정산/감사 작업은 owner 승인</td>
      </tr>
  </tbody>
</table>
<p>이 숫자는 절대값이 아니라 시작점입니다. 중요한 것은 backfill에 별도 예산이 있어야 한다는 점입니다. <a href="/learning/deep-dive/deep-dive-api-resource-budgeting/">API Resource Budgeting</a>처럼 요청에 예산이 필요하듯, 배치와 backfill에도 &ldquo;얼마나 빨리 끝낼 것인가&quot;와 &ldquo;어떤 자원을 얼마나 써도 되는가&quot;가 같이 있어야 합니다.</p>
<h3 id="4-coalesce는-빠르지만-업무-의미를-잃을-수-있다">4) Coalesce는 빠르지만 업무 의미를 잃을 수 있다</h3>
<p>Coalesce는 여러 누락 window를 하나의 실행으로 합치는 방식입니다. 예를 들어 1분마다 캐시를 갱신하는 job이 30분 멈췄다면 30번 실행하는 대신 최신 기준으로 한 번 갱신하면 충분할 수 있습니다. 사용자 상태 만료 job도 <code>WHERE expires_at &lt;= now()</code>처럼 최종 상태를 기준으로 처리하면 window별 실행이 필요하지 않을 수 있습니다.</p>
<p>하지만 coalesce가 항상 안전한 것은 아닙니다. 시간대별 집계, 요금 계산, SLA 측정, 감사 증거처럼 window 자체가 의미를 가지면 합치면 안 됩니다. &ldquo;어차피 최종 합계는 같지 않나&quot;라는 말도 조심해야 합니다. 중간 상태가 알림, 청구, 정산 파일, 외부 webhook을 만든다면 최종 상태만 맞아도 side effect는 달라질 수 있습니다.</p>
<p>간단한 판단 질문:</p>
<ul>
<li>사용자가 특정 시간대 결과를 나중에 확인해야 하는가?</li>
<li>window별 산출물이 외부로 나갔는가?</li>
<li>중간 상태가 청구, 보상, 알림, 권한 변경을 만들 수 있는가?</li>
<li>누락분을 한 번에 처리해도 idempotency key가 유지되는가?</li>
<li>backfill 후 검증을 count/checksum/sample key로 할 수 있는가?</li>
</ul>
<p>위 질문 중 2개 이상이 예라면 coalesce보다 replay 또는 manual backfill을 검토합니다.</p>
<h3 id="5-정확히-한-번보다-효과를-추적할-수-있음이-중요하다">5) &ldquo;정확히 한 번&quot;보다 &ldquo;효과를 추적할 수 있음&quot;이 중요하다</h3>
<p>스케줄러, 큐, 네트워크, DB가 모두 얽힌 환경에서 실행을 정확히 한 번으로 만드는 것은 어렵습니다. 실무 목표는 보통 &ldquo;같은 business key에 대해 같은 side effect가 한 번만 남고, 실패하면 어디서 멈췄는지 알 수 있음&quot;입니다. 즉 <a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 API 설계</a>와 <a href="/learning/deep-dive/deep-dive-upsert-unique-idempotency-write-path-playbook/">UPSERT·UNIQUE 제약·멱등 키</a>의 사고방식을 배치에도 적용해야 합니다.</p>
<p>좋은 idempotency key는 실행 시각이 아니라 업무 창을 기준으로 합니다.</p>
<ul>
<li>나쁨: <code>job_name + started_at</code></li>
<li>좋음: <code>job_name + business_date + shard</code></li>
<li>더 좋음: <code>job_name + business_window + tenant_id + shard + version</code></li>
</ul>
<p>실행이 늦어져도 같은 업무 창을 처리한다면 같은 key를 써야 합니다. 그래야 수동 재실행, 자동 replay, 장애 복구가 같은 dedupe 계층을 통과합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-job-execution-ledger를-먼저-만든다">1) Job Execution Ledger를 먼저 만든다</h3>
<p>스케줄러 안정화의 시작은 라이브러리 교체가 아니라 실행 장부입니다. 최소 필드는 아래처럼 둡니다.</p>
<table>
  <thead>
      <tr>
          <th>필드</th>
          <th>목적</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>job_name</code></td>
          <td>작업 식별</td>
      </tr>
      <tr>
          <td><code>business_window_start/end</code></td>
          <td>처리 대상 시간 범위</td>
      </tr>
      <tr>
          <td><code>scheduled_at</code></td>
          <td>원래 실행 예정 시각</td>
      </tr>
      <tr>
          <td><code>trigger_reason</code></td>
          <td>scheduled, catch_up, manual, retry</td>
      </tr>
      <tr>
          <td><code>status</code></td>
          <td>running, succeeded, failed, skipped, paused</td>
      </tr>
      <tr>
          <td><code>attempt</code></td>
          <td>같은 key의 시도 횟수</td>
      </tr>
      <tr>
          <td><code>processed_count</code></td>
          <td>처리 row/message 수</td>
      </tr>
      <tr>
          <td><code>side_effect_count</code></td>
          <td>실제 외부 효과 수</td>
      </tr>
      <tr>
          <td><code>watermark_before/after</code></td>
          <td>진행 위치</td>
      </tr>
      <tr>
          <td><code>owner</code></td>
          <td>승인·복구 담당</td>
      </tr>
  </tbody>
</table>
<p>이 장부가 없으면 장애 후에 &ldquo;다시 돌려도 되나?&ldquo;를 감으로 판단합니다. 장부가 있으면 누락 window를 조회하고, 같은 window의 성공 여부를 확인하고, replay 후보를 자동 생성할 수 있습니다.</p>
<h3 id="2-job마다-misfire-policy를-명시한다">2) Job마다 misfire policy를 명시한다</h3>
<p>중요 job은 코드에 cron만 두지 말고 운영 정책을 같이 둡니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">job_policy</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">name</span>: <span style="color:#f1fa8c">&#34;daily-settlement&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">schedule</span>: <span style="color:#f1fa8c">&#34;0 2 * * *&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">timezone</span>: <span style="color:#f1fa8c">&#34;Asia/Seoul&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">misfire_policy</span>: <span style="color:#f1fa8c">&#34;manual_backfill&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">max_auto_catch_up</span>: <span style="color:#f1fa8c">&#34;0&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">idempotency_key</span>: <span style="color:#f1fa8c">&#34;job_name + business_date + tenant_id&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">owner</span>: <span style="color:#f1fa8c">&#34;settlement-platform&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">stale_after</span>: <span style="color:#f1fa8c">&#34;6h&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">pause_conditions</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">db_replica_lag_seconds</span>: <span style="color:#bd93f9">60</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">api_p95_regression_percent</span>: <span style="color:#bd93f9">10</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">error_rate_percent</span>: <span style="color:#bd93f9">2</span>
</span></span></code></pre></div><p>정책은 job 성격별로 다릅니다.</p>
<table>
  <thead>
      <tr>
          <th>job</th>
          <th>권장 misfire policy</th>
          <th>이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>캐시 warmup</td>
          <td>skip</td>
          <td>오래된 캐시는 가치가 낮음</td>
      </tr>
      <tr>
          <td>검색 인덱스 보정</td>
          <td>coalesce</td>
          <td>최종 색인 상태가 중요</td>
      </tr>
      <tr>
          <td>예약 만료 worker</td>
          <td>coalesce + idempotent update</td>
          <td>현재 만료 대상 처리면 충분</td>
      </tr>
      <tr>
          <td>시간대별 지표 집계</td>
          <td>replay</td>
          <td>window별 수치가 필요</td>
      </tr>
      <tr>
          <td>정산/청구</td>
          <td>manual backfill</td>
          <td>비용과 감사 책임이 큼</td>
      </tr>
      <tr>
          <td>이메일/푸시 발송</td>
          <td>manual 또는 replay with dedupe</td>
          <td>중복 발송 위험</td>
      </tr>
  </tbody>
</table>
<p>정책이 없으면 라이브러리 기본값이 정책이 됩니다. Quartz, Kubernetes CronJob, Spring Scheduler, 외부 workflow 엔진의 기본 misfire 동작은 제품마다 다릅니다. 팀의 업무 기준을 명시하지 않으면 장애 때 예상과 다르게 따라잡기가 실행될 수 있습니다.</p>
<h3 id="3-backfill은-별도-lane에서-낮은-우선순위로-돌린다">3) Backfill은 별도 lane에서 낮은 우선순위로 돌린다</h3>
<p>Backfill을 온라인 worker와 같은 pool에서 돌리면 복구 작업이 현재 작업을 밀어냅니다. 가능하면 lane을 분리합니다.</p>
<ul>
<li>realtime lane: 현재 이벤트, 사용자-facing 작업</li>
<li>correction lane: 최근 누락 보정, 소량 재처리</li>
<li>bulk backfill lane: 과거 대량 보정, 낮은 우선순위</li>
<li>manual lane: 승인된 고위험 작업</li>
</ul>
<p>큐를 쓰는 경우 <a href="/learning/deep-dive/deep-dive-queue-visibility-timeout-acknack-playbook/">Queue Visibility Timeout·Ack/Nack·DLQ 설계</a>와 연결해 visibility timeout, retry, DLQ 기준을 따로 둡니다. DB cursor batch를 쓰는 경우 <a href="/learning/deep-dive/deep-dive-cursor-pagination-consistency-playbook/">Cursor Pagination Consistency</a>처럼 stable sort와 checkpoint를 같이 설계합니다.</p>
<p>Backfill 실행 중에는 아래 지표를 1~5분 단위로 봅니다.</p>
<ul>
<li><code>backfill_rows_per_sec</code></li>
<li><code>backfill_lag_remaining</code></li>
<li><code>online_api_p95</code></li>
<li><code>db_lock_wait_p95</code></li>
<li><code>replica_lag_seconds</code></li>
<li><code>queue_oldest_age_seconds</code></li>
<li><code>dead_letter_count</code></li>
<li><code>manual_pause_count</code></li>
</ul>
<p>목표는 가장 빨리 끝내는 것이 아니라, 현재 서비스 품질을 지키면서 끝내는 것입니다.</p>
<h3 id="4-자동-catch-up에는-상한을-둔다">4) 자동 catch-up에는 상한을 둔다</h3>
<p>자동 catch-up은 편하지만 상한이 없으면 위험합니다. 예를 들어 1분마다 도는 job이 주말 동안 멈춘 뒤 월요일 아침 2,880개 window를 replay하면, 장애 복구가 아니라 새 장애가 됩니다.</p>
<p>권장 기준:</p>
<ul>
<li>5분 이하 누락: 자동 catch-up 허용</li>
<li>5분~1시간 누락: coalesce 우선, replay는 concurrency 1</li>
<li>1~24시간 누락: owner 알림, backfill plan 생성</li>
<li>24시간 초과 누락: manual approval 전 자동 실행 금지</li>
<li>외부 side effect가 있는 job: 누락 시간과 무관하게 dedupe 검증 후 실행</li>
</ul>
<p>이 기준은 job마다 조정해야 합니다. 핵심은 &ldquo;얼마나 늦었는가&quot;와 &ldquo;무엇을 건드리는가&quot;를 같이 보는 것입니다. 정산 job은 10분 늦어도 수동 승인 대상일 수 있고, 캐시 warmup은 12시간 누락돼도 최신 한 번이면 충분할 수 있습니다.</p>
<h3 id="5-검증은-처리-수가-아니라-결과-차이로-한다">5) 검증은 처리 수가 아니라 결과 차이로 한다</h3>
<p>Backfill 완료 후 &ldquo;100만 row 처리&quot;는 충분한 증거가 아닙니다. 처리 수는 작업량이고, 검증은 결과입니다.</p>
<p>검증 예시:</p>
<table>
  <thead>
      <tr>
          <th>작업</th>
          <th>검증</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>검색 인덱스 재색인</td>
          <td>source count vs index count, sample key 1,000개 비교</td>
      </tr>
      <tr>
          <td>정산 집계</td>
          <td>원천 거래 합계 vs 정산 합계, tenant별 오차 0</td>
      </tr>
      <tr>
          <td>상태 만료</td>
          <td><code>expires_at &lt;= now()</code>인데 active인 row 0건</td>
      </tr>
      <tr>
          <td>알림 발송</td>
          <td>idempotency key별 발송 1회, suppress count 확인</td>
      </tr>
      <tr>
          <td>projection rebuild</td>
          <td>v1/v2 checksum, 불일치율 0.01% 미만</td>
      </tr>
  </tbody>
</table>
<p>검증 기준은 <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation Ledger Pipeline</a>의 관점과 같습니다. 복구 작업은 실행보다 대조가 중요합니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, replay는 정확해 보이지만 비쌉니다. window별 의미가 있는 job에는 필요하지만, 모든 job에 replay를 기본으로 두면 장애 복구 때 부하가 몰립니다. 최신 상태만 중요한 작업은 coalesce가 더 낫습니다.</p>
<p>둘째, skip은 빠르지만 조용한 데이터 품질 저하를 만들 수 있습니다. skip을 허용하는 job에도 &ldquo;무엇을 버렸는지&quot;는 장부에 남겨야 합니다. 그래야 나중에 데이터 공백을 설명할 수 있습니다.</p>
<p>셋째, backfill throttle은 복구 시간을 늘립니다. 하지만 온라인 트래픽이 무너지면 복구 시간보다 장애 영향이 더 커집니다. 고객-facing API와 정산/감사 작업이 충돌하면 우선순위는 보통 <strong>현재 사용자 안정성 &gt; 데이터 손실 방지 &gt; 과거분 처리 속도 &gt; 비용 최적화</strong>입니다.</p>
<p>넷째, 수동 승인은 사람을 병목으로 만들 수 있습니다. 그래서 manual backfill 대상은 적어야 합니다. 외부 비용, 법적 증거, 결제/정산, 대량 권한 변경처럼 실패 비용이 큰 작업에만 둡니다.</p>
<p>다섯째, 스케줄러 제품을 바꿔도 정책 부채는 사라지지 않습니다. Kubernetes CronJob, Quartz, Airflow, Temporal, 자체 DB scheduler 모두 misfire와 catch-up 의미가 다릅니다. 제품 기본값이 아니라 업무별 정책을 먼저 정해야 합니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 중요 job마다 <code>business_window</code>와 <code>scheduled_at</code>을 기록한다.</li>
<li><input disabled="" type="checkbox"> misfire policy가 skip, coalesce, replay, manual backfill 중 하나로 명시돼 있다.</li>
<li><input disabled="" type="checkbox"> 자동 catch-up 가능한 최대 누락 시간과 최대 window 수가 정해져 있다.</li>
<li><input disabled="" type="checkbox"> backfill concurrency, batch size, pause 조건이 운영 지표와 연결돼 있다.</li>
<li><input disabled="" type="checkbox"> idempotency key가 실행 시각이 아니라 업무 key 기준이다.</li>
<li><input disabled="" type="checkbox"> 외부 side effect가 있는 job은 중복 효과 검증을 통과해야 재실행할 수 있다.</li>
<li><input disabled="" type="checkbox"> backfill 완료 후 count, checksum, sample key, residual query 중 최소 2개로 검증한다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 운영 중인 주기 작업 5개를 골라 skip, coalesce, replay, manual backfill 중 하나로 분류해 보세요.</li>
<li>그중 하나에 대해 <code>scheduled_at</code>, <code>business_window</code>, <code>trigger_reason</code>, <code>idempotency_key</code>, <code>processed_count</code>를 포함한 execution ledger 스키마를 작성해 보세요.</li>
<li>&ldquo;스케줄러가 6시간 멈췄다&quot;는 가정으로 자동 catch-up 가능한 window 수, owner 승인 기준, backfill pause 조건을 10줄 runbook으로 정리해 보세요.</li>
<li>Backfill 중 온라인 API p95가 10% 악화됐을 때 pause, throttle, lane 전환, 수동 중단 중 어떤 순서로 대응할지 정해 보세요.</li>
</ol>
<p>Scheduler Misfire와 Backfill Control의 핵심은 놓친 일을 무작정 빨리 따라잡는 것이 아닙니다. 어떤 일은 버려도 되고, 어떤 일은 합쳐도 되며, 어떤 일은 하나씩 증거를 남기며 처리해야 합니다. 좋은 스케줄러 운영은 실행 시각보다 업무 window를 먼저 보고, 복구 속도보다 현재 서비스 안정성과 결과 검증을 우선합니다.</p>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-distributed-scheduler-singleton-playbook/">분산 스케줄러 Singleton 실행 보장</a></li>
<li><a href="/learning/deep-dive/deep-dive-spring-batch-scheduling/">Spring Batch와 스케줄링 기초</a></li>
<li><a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency와 Reprocessing</a></li>
<li><a href="/learning/deep-dive/deep-dive-cdc-connector-lag-snapshot-recovery-playbook/">CDC Connector Lag와 Snapshot Recovery</a></li>
<li><a href="/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/">Workload-aware Queue Partitioning</a></li>
</ul>
]]></content:encoded></item></channel></rss>