<?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>Batch Operations on jyukki's Blog</title><link>https://jyukki.com/tags/batch-operations/</link><description>Recent content in Batch Operations on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Sat, 04 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/batch-operations/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: 분산 스케줄러 Singleton 실행 보장 플레이북 (Lease·Fencing·Idempotency)</title><link>https://jyukki.com/learning/deep-dive/deep-dive-distributed-scheduler-singleton-playbook/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-distributed-scheduler-singleton-playbook/</guid><description>멀티 인스턴스 환경에서 배치/크론 작업이 중복 실행되는 사고를 줄이기 위해 Lease·Fencing·Idempotency를 함께 설계하는 실무 기준을 숫자와 우선순위 중심으로 정리합니다.</description><content:encoded><![CDATA[<p>쿠버네티스나 오토스케일 환경에서 스케줄러 작업을 운영하다 보면 같은 질문을 반복하게 됩니다.<br>
&ldquo;이 작업, 진짜 한 번만 실행된 게 맞나?&rdquo;</p>
<p>문제는 코드 한 줄이 아니라 실행 환경입니다. 인스턴스가 늘고 줄고, 네트워크가 흔들리고, GC pause가 길어지면 &ldquo;리더 1개&quot;라는 가정이 쉽게 깨집니다. 그 결과는 대부분 비슷합니다. 정산 배치 중복 실행, 중복 알림 발송, 외부 결제 API 이중 호출, 재고 재계산 꼬임처럼 <strong>복구 비용이 큰 운영 사고</strong>로 이어집니다.</p>
<p>이 글은 &ldquo;분산 환경에서 배치를 Singleton으로 안전하게 실행&quot;하는 기준을, 이론보다 운영 관점으로 정리합니다. 핵심은 단순 락 1개가 아니라 **Lease(점유 시간) + Fencing(실행 권한 버전) + Idempotency(효과 중복 방지)**를 함께 설계하는 것입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>분산 스케줄러에서 Singleton 가정이 깨지는 대표 실패 모드(네트워크 분할, 느린 stop-the-world, 시계 오차)를 구조적으로 이해할 수 있습니다.</li>
<li>&ldquo;락을 잡았으니 안전하다&rdquo; 수준을 넘어, <strong>fencing token과 멱등성 계층</strong>을 붙여 실제 중복 효과를 줄이는 방법을 가져갈 수 있습니다.</li>
<li>운영 중 의사결정에 바로 쓰는 기준(lease TTL, 갱신 임계치, 재시도 한도, 수동 개입 조건)을 숫자로 설정할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-왜-singleton-작업이-깨지는가-리더-선출보다-느린-실패-감지">1) 왜 Singleton 작업이 깨지는가: 리더 선출보다 느린 실패 감지</h3>
<p>대부분 팀은 &ldquo;분산 락 = 단일 실행 보장&quot;으로 이해하고 시작합니다. 하지만 실제 사고는 락 알고리즘 자체보다 <strong>실패 감지 지연</strong>에서 발생합니다.</p>
<p>대표 시나리오:</p>
<ol>
<li>인스턴스 A가 락 획득 후 작업 실행</li>
<li>A가 GC pause 또는 네트워크 단절로 heartbeat 중단</li>
<li>TTL 만료 후 인스턴스 B가 락 획득, 같은 작업 실행 시작</li>
<li>A가 복귀해 남은 작업을 계속 수행</li>
</ol>
<p>결과적으로 A와 B가 동시에 같은 외부 자원을 건드릴 수 있습니다. 이 문제는 <a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew/시간 의미론 플레이북</a>에서 다룬 것처럼 &ldquo;시간 기반 제어의 오차&quot;를 전제로 설계해야 줄일 수 있습니다.</p>
<h3 id="2-lease만으로는-부족하다-실행-권한-버전fencing-token이-필요하다">2) Lease만으로는 부족하다: 실행 권한 버전(Fencing Token)이 필요하다</h3>
<p>Lease는 &ldquo;누가 지금 리더인가&quot;를 표현하지만, 다운스트림은 그 정보를 모릅니다. 그래서 오래된 리더가 늦게 도착한 쓰기를 수행해도 막지 못합니다.</p>
<p>이때 필요한 게 fencing token입니다.</p>
<ul>
<li>락 획득 시 단조 증가하는 <code>token</code> 발급</li>
<li>모든 쓰기/명령에 <code>token</code> 포함</li>
<li>다운스트림은 &ldquo;이전 token보다 작은 요청&quot;을 거부</li>
</ul>
<p>즉, 리더가 둘이 되어도 <strong>최신 권한만 효과를 남기게</strong> 만듭니다. 분산 락 자체는 <a href="/learning/deep-dive/deep-dive-distributed-lock/">분산 락 기본 원리</a>를 따르되, 실제 안전성은 fencing 검증 계층에서 확보합니다.</p>
<h3 id="3-singleton의-목표는-실행-1회가-아니라-효과-1회다">3) Singleton의 목표는 &ldquo;실행 1회&quot;가 아니라 &ldquo;효과 1회&quot;다</h3>
<p>운영에서 진짜 중요한 건 실행 횟수가 아니라 결과 중복입니다. 네트워크 재시도와 장애 복구를 고려하면 &ldquo;exactly-once 실행&quot;은 비용이 매우 높고, 현실적으로는 **effectively-once(효과 중복 최소화)**가 더 실용적입니다.</p>
<p>필수 장치:</p>
<ul>
<li>Idempotency key (<code>job_name + business_date + shard</code>)</li>
<li>dedupe window (예: 24~72시간)</li>
<li>side effect 기록 테이블(성공/실패/보상 상태)</li>
</ul>
<p>이 구조는 <a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 설계</a> 및 <a href="/learning/deep-dive/deep-dive-transactional-outbox-cdc/">아웃박스+CDC 패턴</a>과 함께 봐야 운영 복구가 쉬워집니다.</p>
<h3 id="4-재시도-전략이-잘못되면-singleton-보호층이-오히려-무너진다">4) 재시도 전략이 잘못되면 Singleton 보호층이 오히려 무너진다</h3>
<p>장애 직후 재시도를 빠르게 몰아치면 다음 문제가 생깁니다.</p>
<ul>
<li>락 서버/DB에 동시 갱신 부하 집중</li>
<li>같은 키에 경쟁성 재진입 폭증</li>
<li>외부 API rate limit과 연쇄 실패</li>
</ul>
<p>권장 기준(초기값):</p>
<ul>
<li>즉시 재시도 0회, 지수 백오프(1s, 2s, 4s&hellip;) + jitter 20%</li>
<li>작업 단위 최대 재시도 5회</li>
<li>10분 내 실패율 20% 초과 시 자동 중지 + 수동 승인 모드</li>
</ul>
<p>재시도는 <a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff</a>과 묶어서 &ldquo;성공률&quot;이 아니라 &ldquo;시스템 안정성&rdquo; 기준으로 튜닝해야 합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-권장-참조-아키텍처">1) 권장 참조 아키텍처</h3>
<ol>
<li><strong>Scheduler Trigger</strong>: cron 또는 event 기반 트리거 생성</li>
<li><strong>Leader Lease Store</strong>: DB/Redis/etcd에 lease + token 저장</li>
<li><strong>Execution Guard</strong>: 현재 token 검증 후 작업 시작</li>
<li><strong>Idempotency Store</strong>: 작업 효과 중복 체크</li>
<li><strong>Side Effect Executor</strong>: 외부 API/DB 반영</li>
<li><strong>Audit Trail</strong>: token, key, outcome 기록</li>
</ol>
<p>핵심은 &ldquo;락&quot;과 &ldquo;효과 기록&quot;을 분리하는 것입니다. 락은 실행 권한, 멱등성은 결과 보호 역할입니다.</p>
<h3 id="2-최소-데이터-모델-예시">2) 최소 데이터 모델 예시</h3>
<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:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> scheduler_lease (
</span></span><span style="display:flex;"><span>  job_name        <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">100</span>) <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  owner_id        <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">100</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  fencing_token   <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>  lease_until     <span style="color:#ff79c6">TIMESTAMP</span>    <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  updated_at      <span style="color:#ff79c6">TIMESTAMP</span>    <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</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:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> job_effect_log (
</span></span><span style="display:flex;"><span>  idempotency_key <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">160</span>) <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  job_name        <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">100</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  fencing_token   <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>  status          <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">20</span>)  <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>, <span style="color:#6272a4">-- STARTED, SUCCEEDED, FAILED, COMPENSATED
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>  started_at      <span style="color:#ff79c6">TIMESTAMP</span>    <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  finished_at     <span style="color:#ff79c6">TIMESTAMP</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>토큰은 단조 증가해야 하며, 다운스트림 쓰기 경로에서 <code>incoming_token &gt;= last_applied_token</code> 조건을 강제해야 합니다.</p>
<h3 id="3-의사결정-기준숫자조건우선순위">3) 의사결정 기준(숫자·조건·우선순위)</h3>
<p>우선순위는 <strong>데이터 무결성 &gt; 중복 방지 &gt; 처리 지연 최소화</strong> 순으로 두는 것이 안전합니다.</p>
<ul>
<li>Lease TTL: <code>max(작업 p99 실행시간 × 3, 30초)</code></li>
<li>Heartbeat 주기: TTL의 1/3 이하(예: TTL 45초면 10~15초)</li>
<li>스틸(재획득) 허용 조건: <code>현재시각 &gt; lease_until + clock_skew_budget</code></li>
<li>clock_skew_budget: 리전 간 운영이면 200<del>500ms, 단일 존은 50</del>150ms부터 시작</li>
<li>fencing token 역전 감지 시: 즉시 쓰기 거부 + P1 알림</li>
<li>동일 idempotency key 중복 시: 두 번째 실행은 side effect 금지하고 &ldquo;중복 탐지&rdquo; 이벤트만 기록</li>
</ul>
<p>운영 중단 기준 예시:</p>
<ul>
<li>15분 내 <code>duplicate_effect_detected &gt;= 1</code> → 자동 중단 후 수동 승인</li>
<li>10분 이동창 <code>lease_conflict_rate &gt; 3%</code> → 스케줄 간격 증가 또는 shard 분할</li>
<li>외부 API 429 비율 5% 초과 → 배치 동시성 50% 감축</li>
</ul>
<h3 id="4-도입-순서4주">4) 도입 순서(4주)</h3>
<p><strong>1주차: 관측 먼저</strong><br>
기존 배치에 실행 ID, idempotency key, owner 정보를 로그/메트릭으로 추가합니다.</p>
<p><strong>2주차: Lease + Token 적용</strong><br>
락 획득 시 token 발급, 작업 컨텍스트에 token 전파, 다운스트림 검증 로직을 도입합니다.</p>
<p><strong>3주차: Idempotency 계층 적용</strong><br>
효과 로그 테이블과 dedupe window를 붙이고, 중복 실행 시 side effect를 차단합니다.</p>
<p><strong>4주차: 운영 자동화</strong><br>
임계치 기반 자동 중지, 재시도 상한, 알림 룰을 런북으로 고정합니다.</p>
<p>장기 실행/재시작 복구가 중요한 워크플로라면 <a href="/learning/deep-dive/deep-dive-temporal-workflow-orchestration/">Temporal 오케스트레이션</a>을 비교 검토하는 것이 좋습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li>
<p><strong>TTL을 길게 잡으면 중복은 줄지만 장애 복구가 느려진다</strong><br>
TTL 2분이면 안정적이지만 리더 장애 시 2분 가까이 작업이 멈출 수 있습니다.</p>
</li>
<li>
<p><strong>fencing 검증은 다운스트림 협조가 필요하다</strong><br>
모든 저장소/외부 API가 token 비교를 지원하지 않으면 일부 경로는 여전히 취약합니다.</p>
</li>
<li>
<p><strong>멱등성 저장소도 운영 비용이 든다</strong><br>
키 저장량, TTL 정리, 인덱스 관리가 필요하고 고QPS 배치에서는 비용이 작지 않습니다.</p>
</li>
<li>
<p><strong>&ldquo;exactly-once&quot;를 약속하면 복구가 더 어려워질 수 있다</strong><br>
실무에선 정확한 용어를 쓰는 게 중요합니다. 계약 문구는 &ldquo;중복 효과 방지&quot;와 &ldquo;보상 절차&quot;를 함께 명시하세요.</p>
</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 스케줄러 작업마다 idempotency key 규칙이 문서화되어 있다.</li>
<li><input disabled="" type="checkbox"> lease와 fencing token이 분리된 저장 구조로 운영된다.</li>
<li><input disabled="" type="checkbox"> 다운스트림 쓰기 경로가 token 역전 요청을 거부한다.</li>
<li><input disabled="" type="checkbox"> 중복 탐지/lease 충돌/재시도 폭주 알림 임계치가 숫자로 정의되어 있다.</li>
<li><input disabled="" type="checkbox"> 장애 시 자동 중지 후 수동 승인 전환 런북이 존재한다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<ol>
<li>최근 2주 배치 로그에서 <code>job_name + business_date</code> 기준 중복 실행 비율을 계산해 보세요.</li>
<li>현재 TTL/heartbeat 설정으로 &ldquo;GC pause 20초 + 네트워크 단절 15초&rdquo; 시나리오를 시뮬레이션하고, 이중 실행 가능 구간을 추정해 보세요.</li>
<li>가장 위험한 외부 API 1개를 골라 fencing token 또는 idempotency key 검증을 강제했을 때, 실패 복구 시간이 어떻게 변하는지 측정해 보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew를 전제로 한 시간 의미론 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-distributed-lock/">분산 락(Distributed Lock) 실전</a></li>
<li><a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 API 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff 운영 기준</a></li>
<li><a href="/learning/deep-dive/deep-dive-temporal-workflow-orchestration/">Temporal 워크플로 오케스트레이션</a></li>
</ul>
]]></content:encoded></item></channel></rss>