<?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>Job Processing on jyukki's Blog</title><link>https://jyukki.com/tags/job-processing/</link><description>Recent content in Job Processing on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Mon, 27 Jul 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/job-processing/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Job Result Ledger, 재시작 가능한 워커를 운영 가능한 작업으로 만드는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-job-result-ledger-restartable-worker-playbook/</link><pubDate>Mon, 27 Jul 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-job-result-ledger-restartable-worker-playbook/</guid><description>비동기 job과 worker를 단순 retry 코드가 아니라 결과 원장, checkpoint, lease, evidence, 재처리 기준을 가진 운영 단위로 설계하는 방법을 정리합니다.</description><content:encoded><![CDATA[<p>비동기 job은 처음에는 간단해 보입니다. 요청을 받으면 <code>PENDING</code> row를 만들고, worker가 집어 가서 처리한 뒤 <code>SUCCESS</code>나 <code>FAILED</code>로 바꾸면 됩니다. 하지만 운영에 들어가면 질문이 달라집니다. worker가 중간에 죽었을 때 어디서 다시 시작할 수 있는지, 외부 API 호출이 timeout 났지만 실제로는 성공했을 가능성이 있는지, 10만 건 중 7만 건까지 반영된 뒤 검증 로직이 바뀌면 남은 3만 건만 처리해도 되는지, 같은 job을 사람이 다시 눌렀을 때 중복 효과가 생기지 않는지를 답해야 합니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency/Reprocessing</a>, <a href="/learning/deep-dive/deep-dive-queue-visibility-timeout-acknack-playbook/">Queue Visibility Timeout/Ack/Nack/DLQ</a>, <a href="/learning/deep-dive/deep-dive-bulk-import-job-row-error-playbook/">Bulk Import Job</a>, <a href="/learning/deep-dive/deep-dive-operational-state-machine-design/">Operational State Machine</a>과 이어집니다. 핵심은 worker를 더 많이 띄우는 것이 아니라, job의 결과와 부작용을 <strong>재시작 가능한 원장</strong>으로 남기는 것입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>장기 실행 job을 <code>status</code> 컬럼 하나로 관리할 때 생기는 운영 공백을 이해합니다.</li>
<li>Job Result Ledger에 어떤 필드를 저장해야 재시작, 재처리, 감사, 보상이 가능해지는지 정리합니다.</li>
<li>checkpoint, lease, heartbeat, side effect evidence를 분리해 worker stuck과 partial success를 판단할 수 있습니다.</li>
<li>실무에서 적용할 숫자 기준, 경보 기준, 재처리 승인 체크리스트를 가져갑니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-job-status는-현재-상태이고-result-ledger는-확정된-효과다">1) job status는 현재 상태이고, result ledger는 확정된 효과다</h3>
<p><code>jobs.status = SUCCESS</code>는 결과를 요약합니다. 하지만 운영자가 실제로 필요한 것은 요약보다 근거입니다. 어떤 row가 처리되었고, 어떤 row가 reject 되었고, 어떤 외부 호출은 성공 증거가 있으며, 어떤 호출은 timeout으로 결과가 불명확한지 알아야 합니다. 이 정보가 없으면 실패한 job을 다시 실행할 때 전체를 되돌리거나, 반대로 같은 효과를 두 번 만들 위험을 감수해야 합니다.</p>
<p>Job Result Ledger는 job의 각 step 또는 대상 단위 결과를 누적하는 테이블입니다.</p>
<table>
  <thead>
      <tr>
          <th>필드</th>
          <th>의미</th>
          <th>예시</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>job_id</code></td>
          <td>전체 작업 식별자</td>
          <td><code>price-import-20260727-01</code></td>
      </tr>
      <tr>
          <td><code>target_key</code></td>
          <td>처리 대상</td>
          <td><code>sku:KR-8812</code></td>
      </tr>
      <tr>
          <td><code>step</code></td>
          <td>검증, 적용, 외부 전송, 확인</td>
          <td><code>apply_price</code></td>
      </tr>
      <tr>
          <td><code>attempt</code></td>
          <td>시도 번호</td>
          <td><code>3</code></td>
      </tr>
      <tr>
          <td><code>idempotency_key</code></td>
          <td>중복 효과 방지 키</td>
          <td><code>price:sku:KR-8812:v7</code></td>
      </tr>
      <tr>
          <td><code>input_hash</code></td>
          <td>같은 키에 다른 요청이 들어왔는지 확인</td>
          <td><code>sha256:...</code></td>
      </tr>
      <tr>
          <td><code>result_state</code></td>
          <td>성공, 거부, 보류, 불명확</td>
          <td><code>CONFIRMED</code></td>
      </tr>
      <tr>
          <td><code>effect_ref</code></td>
          <td>DB row version, provider id, event id</td>
          <td><code>price_history:91321</code></td>
      </tr>
      <tr>
          <td><code>evidence_ref</code></td>
          <td>로그, 응답, 영수증, trace</td>
          <td><code>trace:abc123</code></td>
      </tr>
  </tbody>
</table>
<p>중요한 점은 ledger가 로그 덤프가 아니라는 것입니다. 로그는 검색용이고, ledger는 재실행 판단용입니다. 같은 job을 다시 시작할 때 worker는 ledger를 먼저 보고 이미 확정된 target은 건너뛰고, 불명확한 target은 확인 또는 격리로 보냅니다.</p>
<h3 id="2-재시작-가능하다는-말은-같은-코드를-다시-돌린다는-뜻이-아니다">2) 재시작 가능하다는 말은 같은 코드를 다시 돌린다는 뜻이 아니다</h3>
<p>실무에서 &ldquo;재시작 가능&quot;을 단순히 while loop와 retry로 이해하면 위험합니다. 진짜 재시작 가능성은 아래 네 가지를 만족해야 합니다.</p>
<ol>
<li>이미 확정된 효과를 다시 만들지 않는다.</li>
<li>실패한 대상만 다시 시도할 수 있다.</li>
<li>결과가 불명확한 대상은 자동 재시도 전에 확인 경로로 보낸다.</li>
<li>작업 코드가 바뀌어도 이전 checkpoint와 ledger를 해석할 수 있다.</li>
</ol>
<p>예를 들어 결제 정산 보정 job이 외부 정산 API를 호출하다가 timeout 됐다고 합시다. 이때 HTTP client는 실패를 받았지만 정산 시스템은 요청을 처리했을 수 있습니다. 무조건 retry하면 중복 정산이 생깁니다. 반대로 실패로 확정하면 실제 처리된 금액과 내부 상태가 갈라질 수 있습니다. 이 구간은 <code>FAILED</code>가 아니라 <code>AMBIGUOUS_EFFECT</code> 같은 상태로 분리하고, provider 조회나 수동 확인으로 닫아야 합니다.</p>
<p>의사결정 우선순위는 <strong>금전/권한/고객 데이터 정합성 &gt; 중복 외부 전송 방지 &gt; 빠른 완료 &gt; worker 처리량</strong>입니다. 처리량을 이유로 불명확한 효과를 자동 retry하는 것은 대부분 좋지 않습니다.</p>
<h3 id="3-checkpoint는-progress-bar가-아니라-재처리-경계다">3) checkpoint는 progress bar가 아니라 재처리 경계다</h3>
<p>checkpoint를 &ldquo;몇 퍼센트 진행&rdquo; 표시로만 쓰면 재처리에는 약합니다. 운영 가능한 checkpoint는 다시 시작할 수 있는 경계여야 합니다. cursor 기반 처리라면 정렬 기준과 마지막 처리 키가 안정적이어야 하고, 파일 처리라면 row number와 파일 checksum이 같이 있어야 하며, 이벤트 처리라면 topic, partition, offset, schema version이 같이 있어야 합니다.</p>
<p>초기 기준:</p>
<table>
  <thead>
      <tr>
          <th>작업 유형</th>
          <th>checkpoint 단위</th>
          <th>권장 저장 주기</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSV/Excel import</td>
          <td>file checksum + row number</td>
          <td>100~1,000 row마다</td>
      </tr>
      <tr>
          <td>DB backfill</td>
          <td>stable cursor + batch id</td>
          <td>batch commit마다</td>
      </tr>
      <tr>
          <td>외부 API sync</td>
          <td>provider cursor + local target key</td>
          <td>page 또는 target마다</td>
      </tr>
      <tr>
          <td>이벤트 replay</td>
          <td>topic/partition/offset + event id</td>
          <td>메시지 또는 작은 batch마다</td>
      </tr>
      <tr>
          <td>대량 알림</td>
          <td>campaign id + recipient id</td>
          <td>recipient 또는 shard마다</td>
      </tr>
  </tbody>
</table>
<p>checkpoint는 너무 자주 저장하면 DB write가 늘고, 너무 드물면 재처리 범위가 커집니다. 시작값은 &ldquo;worker가 죽었을 때 다시 처리해도 되는 최대 손실 시간&quot;으로 잡습니다. 예를 들어 10분짜리 job에서 30초 이상 되돌아가면 외부 API quota가 아깝다면 30초 이내 checkpoint를 둡니다. 반대로 내부 읽기 전용 backfill이면 5분 단위도 충분할 수 있습니다.</p>
<h3 id="4-lease와-heartbeat는-worker-생존-증거이지-결과-증거가-아니다">4) lease와 heartbeat는 worker 생존 증거이지 결과 증거가 아니다</h3>
<p>분산 worker 환경에서는 같은 job을 두 worker가 동시에 처리하지 않도록 lease를 둡니다. 하지만 lease를 잡았다고 결과가 안전해지는 것은 아닙니다. lease는 &ldquo;지금 누가 작업 권한을 갖는가&quot;를 말하고, result ledger는 &ldquo;무엇이 실제로 확정됐는가&quot;를 말합니다. 둘은 역할이 다릅니다.</p>
<p>stuck job 판정도 단순히 heartbeat 하나로 끝내면 안 됩니다.</p>
<ul>
<li>heartbeat가 2회 이상 누락됨</li>
<li>progress checkpoint가 10분 이상 변하지 않음</li>
<li>같은 target에서 attempt가 3회 이상 반복됨</li>
<li>downstream 5xx나 429가 5분 동안 10% 초과</li>
<li>worker process는 살아 있지만 ledger write가 없음</li>
</ul>
<p>이 신호가 같이 나타나면 worker를 죽이고 lease를 훔치는 것보다 먼저 target 상태를 봐야 합니다. 외부 side effect 직후에 worker가 멈췄다면 자동 steal이 중복 효과를 만들 수 있습니다. 이 구간에서는 <code>needs_reconciliation</code> queue로 보내는 편이 안전합니다.</p>
<h3 id="5-실패를-하나로-묶으면-운영자가-잘못-재시도한다">5) 실패를 하나로 묶으면 운영자가 잘못 재시도한다</h3>
<p><code>FAILED</code> 하나로 모든 실패를 표현하면 재처리 버튼이 위험해집니다. 실패 유형별로 다음 행동이 달라야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>실패 유형</th>
          <th>예시</th>
          <th>기본 행동</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>TRANSIENT</code></td>
          <td>네트워크, 5xx, 일시 429</td>
          <td>제한된 retry</td>
      </tr>
      <tr>
          <td><code>VALIDATION_REJECTED</code></td>
          <td>입력 형식 오류, 비즈니스 규칙 위반</td>
          <td>자동 retry 금지</td>
      </tr>
      <tr>
          <td><code>CONFLICT</code></td>
          <td>같은 키 다른 payload, 버전 불일치</td>
          <td>최신 상태 확인 후 결정</td>
      </tr>
      <tr>
          <td><code>AMBIGUOUS_EFFECT</code></td>
          <td>외부 timeout 후 결과 불명확</td>
          <td>provider 조회 또는 수동 확인</td>
      </tr>
      <tr>
          <td><code>POLICY_BLOCKED</code></td>
          <td>승인 없음, scope 부족</td>
          <td>승인 또는 작업 취소</td>
      </tr>
      <tr>
          <td><code>BUG_SUSPECTED</code></td>
          <td>같은 지점 반복 실패</td>
          <td>배포/코드 수정 전 replay 금지</td>
      </tr>
  </tbody>
</table>
<p>retry 횟수보다 실패 분류가 먼저입니다. 잘못 분류된 실패는 retry budget을 낭비하는 정도가 아니라 데이터 오염을 만들 수 있습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-최소-스키마부터-만든다">1) 최소 스키마부터 만든다</h3>
<p>처음부터 workflow engine을 도입할 필요는 없습니다. 하지만 long-running 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-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- job header: 운영자가 보는 작업 단위
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>job_operation (
</span></span><span style="display:flex;"><span>  job_id <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">primary</span> <span style="color:#ff79c6">key</span>,
</span></span><span style="display:flex;"><span>  job_type <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  status <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  requested_by <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  created_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  started_at timestamptz,
</span></span><span style="display:flex;"><span>  completed_at timestamptz,
</span></span><span style="display:flex;"><span>  risk_level <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  stop_condition <span style="color:#8be9fd;font-style:italic">text</span>,
</span></span><span style="display:flex;"><span>  approval_ref <span style="color:#8be9fd;font-style:italic">text</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- progress: 재시작 경계
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>job_checkpoint (
</span></span><span style="display:flex;"><span>  job_id <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  shard_key <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  checkpoint_token <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  processed_count <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  updated_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">primary</span> <span style="color:#ff79c6">key</span> (job_id, shard_key)
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- result ledger: 대상별 확정 효과
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>job_result_ledger (
</span></span><span style="display:flex;"><span>  job_id <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  target_key <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  step <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  attempt <span style="color:#8be9fd;font-style:italic">int</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  idempotency_key <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  input_hash <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  result_state <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  effect_ref <span style="color:#8be9fd;font-style:italic">text</span>,
</span></span><span style="display:flex;"><span>  evidence_ref <span style="color:#8be9fd;font-style:italic">text</span>,
</span></span><span style="display:flex;"><span>  updated_at timestamptz <span style="color:#ff79c6">not</span> <span style="color:#ff79c6">null</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">primary</span> <span style="color:#ff79c6">key</span> (job_id, target_key, step)
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>이 구조는 단순합니다. 하지만 운영자가 &ldquo;어디까지 됐고, 무엇을 다시 해도 되는가&quot;를 묻는 순간 효과가 큽니다.</p>
<h3 id="2-job-생성-기준을-숫자로-정한다">2) job 생성 기준을 숫자로 정한다</h3>
<p>모든 작업을 job으로 만들 필요는 없습니다. 동기 API가 더 단순한 경우도 많습니다. 대신 아래 조건 중 하나라도 맞으면 job 모델을 검토합니다.</p>
<ul>
<li>p95 처리 시간이 5초를 넘을 가능성이 있다.</li>
<li>처리 대상이 1,000건 이상이거나 파일 크기가 10MB 이상이다.</li>
<li>외부 API, 이메일, 알림, 결제, 권한 변경 같은 side effect가 있다.</li>
<li>사용자가 진행 상태를 확인해야 한다.</li>
<li>중간 실패 후 일부만 재처리해야 한다.</li>
<li>운영자가 pause, resume, cancel, replay를 해야 한다.</li>
</ul>
<p>이 조건은 <a href="/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/">Async Request-Reply Operation Resource</a>와도 이어집니다. 사용자 요청이 길어질수록 API 응답은 처리 결과가 아니라 operation resource를 반환하는 편이 낫습니다.</p>
<h3 id="3-재처리-버튼에는-preflight를-붙인다">3) 재처리 버튼에는 preflight를 붙인다</h3>
<p>운영 UI에 &ldquo;Retry&rdquo; 버튼만 있으면 언젠가 사고가 납니다. 재처리 전에는 최소한 아래 preflight를 보여줘야 합니다.</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">replay_preflight</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">job_id</span>: <span style="color:#f1fa8c">&#34;price-import-20260727-01&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">failed_targets</span>: <span style="color:#bd93f9">381</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">confirmed_targets</span>: <span style="color:#bd93f9">98214</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">ambiguous_effect_targets</span>: <span style="color:#bd93f9">7</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">validation_rejected_targets</span>: <span style="color:#bd93f9">128</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">last_checkpoint_age</span>: <span style="color:#f1fa8c">&#34;3m&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">downstream_error_rate_5m</span>: <span style="color:#f1fa8c">&#34;0.4%&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">code_version_changed</span>: <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">requires_approval</span>: <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">stop_if</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;ambiguous_effect_targets &gt; 0&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;downstream_error_rate_5m &gt; 5%&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;replay_reject_rate &gt; 10%&#34;</span>
</span></span></code></pre></div><p>권장 기준은 명확합니다. <code>AMBIGUOUS_EFFECT</code>가 1건이라도 있으면 자동 전체 replay를 막습니다. 금전, 권한, 고객 알림, 외부 전송이 포함되면 승인자와 보상 경로를 요구합니다. 같은 실패가 3회 반복되면 retry가 아니라 code/config fix 대기열로 보냅니다.</p>
<h3 id="4-알람은-실패-건수보다-stuck과-불명확-효과를-본다">4) 알람은 실패 건수보다 stuck과 불명확 효과를 본다</h3>
<p>좋은 알람은 운영자가 행동할 수 있게 해야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>신호</th>
          <th style="text-align: right">초기 임계치</th>
          <th>행동</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>oldest_running_job_age</code></td>
          <td style="text-align: right">예상 p95의 3배</td>
          <td>stuck 조사</td>
      </tr>
      <tr>
          <td><code>checkpoint_stale_age</code></td>
          <td style="text-align: right">10분</td>
          <td>worker/DB/downstream 확인</td>
      </tr>
      <tr>
          <td><code>ambiguous_effect_count</code></td>
          <td style="text-align: right">1건 이상</td>
          <td>자동 replay 중지</td>
      </tr>
      <tr>
          <td><code>retry_exhausted_count</code></td>
          <td style="text-align: right">10건 또는 1%</td>
          <td>실패 유형 재분류</td>
      </tr>
      <tr>
          <td><code>ledger_write_error_rate</code></td>
          <td style="text-align: right">0.1%</td>
          <td>worker 처리 중단 검토</td>
      </tr>
      <tr>
          <td><code>manual_intervention_rate</code></td>
          <td style="text-align: right">1주 5% 초과</td>
          <td>workflow 설계 재검토</td>
      </tr>
  </tbody>
</table>
<p>실패 건수만 보면 입력 품질이 나쁜 정상 reject와 시스템 장애를 구분하지 못합니다. 특히 <code>ambiguous_effect_count</code>는 낮은 숫자여도 중요합니다. 한 건의 불명확 결제, 한 건의 잘못된 권한 변경은 1,000건의 형식 오류보다 위험할 수 있습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, ledger는 저장 비용과 설계 비용을 늘립니다. 대상 단위로 결과를 남기면 테이블이 커지고, 인덱스와 보존 정책이 필요합니다. 하지만 side effect가 있는 job에서 ledger가 없으면 장애 후 사람 시간이 더 비쌉니다. 보존 기간은 job 유형별로 나눕니다. 단순 import reject는 30~90일, 정산/권한/감사 job은 1년 이상 또는 규정 기준을 따릅니다.</p>
<p>둘째, 너무 세밀한 checkpoint는 오히려 병목이 됩니다. row마다 checkpoint를 저장하면 DB write가 job 자체보다 비싸질 수 있습니다. 내부 DB 변경만 있는 낮은 위험 batch는 batch 단위 checkpoint가 충분합니다. 외부 side effect가 있는 step만 대상 단위 ledger를 강하게 두는 식으로 차등 적용합니다.</p>
<p>셋째, 재시작 가능성을 과신하면 설계가 느슨해집니다. &ldquo;다시 돌리면 된다&quot;는 말은 입력과 코드가 같고, idempotency key가 안정적이고, 외부 효과가 확인 가능할 때만 맞습니다. job code version이 바뀌면 예전 ledger 해석이 깨질 수 있으므로 job마다 worker version과 schema version을 남깁니다.</p>
<p>넷째, compensation은 rollback보다 어렵습니다. 이미 고객에게 알림이 갔거나 외부 시스템에 전파된 결과는 DB row를 되돌린다고 끝나지 않습니다. 고위험 job은 실행 전에 보상 경로를 적어야 하고, 보상 자체도 별도 job result ledger를 가져야 합니다.</p>
<p>마지막으로, 모든 job을 완전 자동화하려고 하지 않는 편이 좋습니다. 자동 retry는 transient 오류에만 좁게 열고, business conflict와 ambiguous effect는 사람 판단을 거치는 것이 안전합니다. 우선순위는 <strong>불명확 효과 격리 &gt; 중복 방지 &gt; 부분 재처리 &gt; 처리량 개선</strong>입니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 5초 이상 또는 1,000건 이상 처리하는 작업은 job operation으로 모델링한다.</li>
<li><input disabled="" type="checkbox"> job status와 target별 result ledger를 분리한다.</li>
<li><input disabled="" type="checkbox"> 외부 side effect에는 idempotency key, input hash, effect ref가 있다.</li>
<li><input disabled="" type="checkbox"> checkpoint는 progress 표시가 아니라 재시작 가능한 경계로 설계했다.</li>
<li><input disabled="" type="checkbox"> <code>FAILED</code>를 transient, validation, conflict, ambiguous, policy, bug suspected로 분류한다.</li>
<li><input disabled="" type="checkbox"> <code>AMBIGUOUS_EFFECT</code>가 있으면 자동 replay를 막는다.</li>
<li><input disabled="" type="checkbox"> 재처리 버튼에는 affected count, last checkpoint, code version, stop condition이 보인다.</li>
<li><input disabled="" type="checkbox"> ledger 보존 기간과 개인정보 마스킹 기준이 job 유형별로 정해져 있다.</li>
</ul>
<h3 id="연습">연습</h3>
<p>현재 서비스의 long-running 작업 하나를 고르세요. 대량 업로드, 월간 정산, 알림 발송, 검색 인덱스 재생성, 데이터 보정 중 무엇이든 됩니다. 그 작업을 <code>job_operation</code>, <code>job_checkpoint</code>, <code>job_result_ledger</code> 세 구조로 나눠 적어 봅니다. 이어서 실패 5가지를 <code>TRANSIENT</code>, <code>VALIDATION_REJECTED</code>, <code>CONFLICT</code>, <code>AMBIGUOUS_EFFECT</code>, <code>BUG_SUSPECTED</code>로 분류하고, 각 실패에 대해 자동 retry, 수동 확인, replay 금지 중 하나를 선택합니다. 표를 만든 뒤 &ldquo;worker가 정확히 어느 줄에서 죽어도 중복 효과 없이 다시 시작할 수 있는가&quot;를 마지막 질문으로 검증하면 됩니다.</p>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency/Reprocessing</a></li>
<li><a href="/learning/deep-dive/deep-dive-queue-visibility-timeout-acknack-playbook/">Queue Visibility Timeout/Ack/Nack/DLQ</a></li>
<li><a href="/learning/deep-dive/deep-dive-bulk-import-job-row-error-playbook/">Bulk Import Job</a></li>
<li><a href="/learning/deep-dive/deep-dive-operational-state-machine-design/">Operational State Machine</a></li>
</ul>
]]></content:encoded></item></channel></rss>