<?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>Worker Pool on jyukki's Blog</title><link>https://jyukki.com/tags/worker-pool/</link><description>Recent content in Worker Pool on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Sun, 17 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/worker-pool/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Queue Head-of-Line Blocking과 Priority Inversion, 느린 작업이 빠른 작업을 막지 않게 하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-queue-hol-priority-inversion-playbook/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-queue-hol-priority-inversion-playbook/</guid><description>비동기 큐와 워커 풀에서 느린 작업 하나가 전체 처리 지연을 키우는 Head-of-Line Blocking과 Priority Inversion을 분리하고, 큐 격리·우선순위·동시성 예산·관측 지표를 숫자 기준으로 설계하는 방법을 정리합니다.</description><content:encoded><![CDATA[<p>비동기 큐를 도입하면 시스템이 자동으로 안정해진다고 생각하기 쉽습니다. 사용자의 동기 요청을 짧게 끝내고, 무거운 작업은 큐에 넣어 워커가 천천히 처리하면 된다는 그림은 맞습니다. 하지만 운영에서는 여기서 새로운 문제가 생깁니다. <strong>느린 작업 하나가 같은 줄 뒤의 빠른 작업까지 막는 현상</strong>입니다. 요청은 비동기로 바뀌었지만, 큐와 워커가 하나의 긴 줄처럼 설계되어 있으면 지연은 사라지지 않고 위치만 옮겨갑니다.</p>
<p>이 문제는 보통 Head-of-Line Blocking, Priority Inversion, noisy neighbor, worker starvation이라는 이름으로 나타납니다. 메일 발송, 이미지 리사이즈, 정산 집계, 검색 인덱싱, AI 요약, 외부 API 동기화가 모두 같은 큐를 쓰면 하나의 느린 작업군이 전체 backlog를 끌어올릴 수 있습니다. 이 글은 <a href="/learning/deep-dive/deep-dive-thread-pool/">Thread Pool 설계</a>, <a href="/learning/deep-dive/deep-dive-admission-control-concurrency-limits/">Admission Control과 Concurrency Limit</a>, <a href="/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/">Workload-aware Queue Partitioning</a>, <a href="/learning/deep-dive/deep-dive-tail-latency-engineering-playbook/">Tail Latency 엔지니어링</a>과 이어서 보면 좋습니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>큐에서 발생하는 Head-of-Line Blocking과 Priority Inversion을 로그·메트릭으로 구분할 수 있습니다.</li>
<li>단일 FIFO 큐, 우선순위 큐, 워크로드별 큐 분리, tenant별 fair scheduling의 적용 기준을 세울 수 있습니다.</li>
<li>워커 동시성, backlog 상한, 작업 timeout, 재시도 예산을 숫자로 잡아 느린 작업이 전체 시스템을 오염시키지 않게 만들 수 있습니다.</li>
<li>비동기화가 지연을 숨기는 장치가 아니라 운영 가능한 완충 장치가 되도록 체크리스트를 만들 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-head-of-line-blocking은-처리량-부족이-아니라-순서-때문에-생기는-지연이다">1) Head-of-Line Blocking은 &ldquo;처리량 부족&quot;이 아니라 &ldquo;순서 때문에 생기는 지연&quot;이다</h3>
<p>Head-of-Line Blocking은 줄 앞의 작업이 오래 걸려 뒤의 짧은 작업이 같이 늦어지는 현상입니다. 전체 CPU가 여유 있고 워커 수가 남아 보여도, 특정 큐 파티션이나 특정 consumer group 안에서는 순서 때문에 막힐 수 있습니다.</p>
<p>예를 들어 하나의 FIFO 큐에 아래 작업이 섞여 있다고 가정합니다.</p>
<table>
  <thead>
      <tr>
          <th>작업</th>
          <th style="text-align: right">평균 처리시간</th>
          <th style="text-align: right">p95 처리시간</th>
          <th>업무 중요도</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>회원 가입 메일</td>
          <td style="text-align: right">80ms</td>
          <td style="text-align: right">200ms</td>
          <td>높음</td>
      </tr>
      <tr>
          <td>썸네일 생성</td>
          <td style="text-align: right">700ms</td>
          <td style="text-align: right">4s</td>
          <td>중간</td>
      </tr>
      <tr>
          <td>대용량 리포트 생성</td>
          <td style="text-align: right">20s</td>
          <td style="text-align: right">120s</td>
          <td>낮음</td>
      </tr>
      <tr>
          <td>외부 CRM 동기화</td>
          <td style="text-align: right">1s</td>
          <td style="text-align: right">15s</td>
          <td>중간</td>
      </tr>
  </tbody>
</table>
<p>워커가 10개라도 리포트 생성 10건이 먼저 잡히면 가입 메일은 뒤에서 기다립니다. 이때 메트릭을 평균 처리시간만 보면 &ldquo;리포트가 느리다&rdquo; 정도로 보입니다. 하지만 실제 사용자 영향은 가입 직후 메일 지연, 인증 지연, 알림 누락처럼 전혀 다른 곳에서 나타납니다. 그래서 큐 운영에서는 <code>process_time</code>보다 <code>queue_wait_time</code>을 먼저 봐야 합니다. 특히 빠른 작업군의 <code>queue_wait_p95</code>가 평소의 3배 이상 또는 1분 이상 유지되면 Head-of-Line Blocking 후보로 봅니다.</p>
<h3 id="2-priority-inversion은-중요한-일이-낮은-우선순위-자원에-갇히는-문제다">2) Priority Inversion은 중요한 일이 낮은 우선순위 자원에 갇히는 문제다</h3>
<p>Priority Inversion은 중요한 작업이 덜 중요한 작업 때문에 실행 기회를 얻지 못하는 현상입니다. 단순 FIFO에서도 발생하지만, 더 흔한 원인은 &ldquo;우선순위가 있다는 착각&quot;입니다. API는 high priority라고 말하지만 실제로는 같은 DB connection pool, 같은 Redis connection, 같은 worker executor, 같은 rate limit bucket을 공유합니다.</p>
<p>예를 들어 결제 확정 이벤트와 마케팅 세그먼트 갱신 작업이 같은 워커 풀을 쓰면, 세그먼트 갱신이 폭주하는 순간 결제 후속 처리도 밀립니다. 코드에는 <code>priority=HIGH</code>가 붙어 있어도 executor가 하나라면 의미가 없습니다. 우선순위는 큐 라벨이 아니라 <strong>격리된 실행 예산</strong>까지 있어야 동작합니다.</p>
<p>실무에서는 아래 세 조건을 만족해야 우선순위가 실제로 작동한다고 봅니다.</p>
<ol>
<li>high/normal/low 작업이 최소 큐 또는 파티션 단위로 분리되어 있다.</li>
<li>high priority가 사용할 worker slot, DB connection, 외부 API quota가 별도 상한으로 보호되어 있다.</li>
<li>low priority backlog가 증가해도 high priority의 <code>queue_wait_p95</code>가 목표 SLO의 50% 이하로 유지된다.</li>
</ol>
<p>이 조건이 없으면 priority field는 운영 장식에 가깝습니다.</p>
<h3 id="3-큐-분리는-많을수록-좋은-것이-아니라-병목과-slo-단위로-나눠야-한다">3) 큐 분리는 많을수록 좋은 것이 아니라 병목과 SLO 단위로 나눠야 한다</h3>
<p>Head-of-Line Blocking을 막겠다고 모든 작업마다 큐를 만들면 운영 복잡도가 폭발합니다. 큐가 많아지면 모니터링, 재처리, DLQ, 배포 설정, consumer autoscaling 기준도 같이 늘어납니다. 좋은 기준은 &ldquo;작업 이름&quot;이 아니라 <strong>SLO와 병목 자원</strong>입니다.</p>
<p>권장 분류는 아래처럼 시작하면 현실적입니다.</p>
<table>
  <thead>
      <tr>
          <th>분리 기준</th>
          <th>예시</th>
          <th>기본 목표</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>사용자 대기 영향</td>
          <td>가입 메일, 결제 후속 처리</td>
          <td><code>queue_wait_p95 &lt; 5s</code></td>
      </tr>
      <tr>
          <td>외부 API 병목</td>
          <td>CRM, PG, 배송사 연동</td>
          <td>업체별 quota와 timeout 분리</td>
      </tr>
      <tr>
          <td>CPU/메모리 무거움</td>
          <td>이미지 변환, PDF 생성, AI 요약</td>
          <td>worker slot 별도, batch size 제한</td>
      </tr>
      <tr>
          <td>재처리 위험</td>
          <td>정산, 포인트, 쿠폰</td>
          <td>멱등성·감사 로그·수동 replay 우선</td>
      </tr>
      <tr>
          <td>테넌트 공정성</td>
          <td>B2B 고객별 import</td>
          <td>tenant별 concurrency cap</td>
      </tr>
  </tbody>
</table>
<p>처음부터 20개 큐를 만들 필요는 없습니다. 하지만 사용자 체감 작업, 긴 CPU 작업, 외부 API 작업, 재처리 위험 작업은 같은 FIFO에 넣지 않는 편이 안전합니다. 이 기준은 <a href="/learning/deep-dive/deep-dive-priority-load-shedding-bulkhead/">Priority Load Shedding과 Bulkhead</a>의 bulkhead 사고방식과 같습니다. 장애 전파를 막으려면 실행 공간도 나눠야 합니다.</p>
<h3 id="4-재시도는-느린-작업을-더-느리게-만들-수-있다">4) 재시도는 느린 작업을 더 느리게 만들 수 있다</h3>
<p>큐 지연이 커질 때 자동 재시도가 겹치면 상황은 빠르게 나빠집니다. 외부 API가 5초 timeout으로 느려졌는데 모든 작업이 3회 즉시 재시도하면, 실제 점유 시간은 15초 이상으로 늘고 backlog는 더 쌓입니다. 이때 새 작업과 재시도 작업이 같은 큐를 쓰면 정상 요청까지 같이 늦어집니다.</p>
<p>재시도 정책은 아래 원칙을 권장합니다.</p>
<ul>
<li>동기 사용자 영향 작업: 즉시 재시도 1회 이하, 이후 지연 재시도</li>
<li>외부 API 작업: 업체별 circuit breaker와 rate limit bucket 분리</li>
<li>CPU-heavy 작업: 재시도보다 입력 검증과 작업 크기 제한 우선</li>
<li>정산·포인트 작업: 자동 재시도보다 멱등성 키와 reconciliation 우선</li>
<li>같은 payload가 3회 이상 실패하면 DLQ 또는 quarantine으로 이동</li>
</ul>
<p><a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff</a>에서 다룬 원칙은 큐에서도 그대로 적용됩니다. 큐는 재시도를 쉽게 만들지만, 쉬운 재시도는 쉽게 장애를 키웁니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-먼저-메트릭을-작업군-단위로-쪼갠다">1) 먼저 메트릭을 작업군 단위로 쪼갠다</h3>
<p>큐 운영의 출발점은 &ldquo;큐가 몇 건 쌓였나&quot;가 아닙니다. backlog 총량은 거칠게만 의미가 있습니다. 중요한 것은 어떤 작업군이 얼마나 기다리고, 얼마나 오래 실행되고, 실패 후 다시 들어오는지입니다.</p>
<p>최소 메트릭은 아래를 권장합니다.</p>
<ul>
<li><code>queue_wait_ms</code>: enqueue부터 worker start까지 걸린 시간</li>
<li><code>process_ms</code>: worker start부터 ack까지 걸린 시간</li>
<li><code>inflight_count</code>: 현재 실행 중인 작업 수</li>
<li><code>retry_count</code>: payload별 누적 재시도 횟수</li>
<li><code>oldest_message_age</code>: 가장 오래 기다린 메시지 나이</li>
<li><code>dlq_count</code>: 격리된 실패 작업 수</li>
<li><code>worker_busy_ratio</code>: 워커가 실제로 일하는 비율</li>
<li><code>dependency_wait_ms</code>: 외부 API, DB, object storage 대기 시간</li>
</ul>
<p>의사결정 기준은 작업군별로 따로 둡니다. 예를 들어 사용자 체감 작업은 <code>queue_wait_p95 &lt; 5초</code>, 내부 동기화는 <code>&lt; 2분</code>, 대용량 리포트는 <code>&lt; 30분</code>처럼 다르게 잡습니다. 모든 큐에 같은 알람 기준을 붙이면 중요한 알람은 묻히고 덜 중요한 알람은 시끄러워집니다.</p>
<h3 id="2-단일-큐에서-시작했더라도-4개-레인으로-나누는-순간이-온다">2) 단일 큐에서 시작했더라도 4개 레인으로 나누는 순간이 온다</h3>
<p>초기에는 단일 큐가 가장 단순합니다. 작업량이 적고 작업 시간이 비슷하면 단일 FIFO가 오히려 좋습니다. 하지만 아래 조건 중 2개 이상이 1주일에 2회 이상 반복되면 큐 분리를 검토해야 합니다.</p>
<ul>
<li>작업군별 p95 처리시간 차이가 10배 이상이다.</li>
<li>특정 작업군이 전체 backlog의 50% 이상을 10분 이상 차지한다.</li>
<li>빠른 작업의 <code>queue_wait_p95</code>가 목표의 2배를 넘는다.</li>
<li>재시도 작업이 신규 작업 처리량의 20% 이상을 먹는다.</li>
<li>외부 API 장애가 내부 작업 지연으로 전파된다.</li>
</ul>
<p>실무의 첫 분리는 보통 아래 4개 레인이면 충분합니다.</p>
<ol>
<li><code>critical</code>: 결제, 인증, 사용자에게 바로 영향이 가는 작업</li>
<li><code>standard</code>: 일반 알림, 검색 인덱싱, 보통의 후속 처리</li>
<li><code>heavy</code>: 이미지/PDF/AI 요약/대용량 export처럼 긴 작업</li>
<li><code>retry_or_quarantine</code>: 실패 재시도, 수동 확인, 독성 payload 격리</li>
</ol>
<p>각 레인은 worker 수만 나누는 것이 아니라 timeout, retry, batch size, rate limit, 알람 기준까지 따로 가져갑니다. 예를 들어 <code>critical</code>은 worker 30%, DB pool 20%, 외부 API quota 40%를 예약하고, <code>heavy</code>는 worker 20%를 넘지 못하게 할 수 있습니다. 이 숫자는 고정 답이 아니라 출발점입니다. 핵심은 낮은 우선순위가 높은 우선순위의 최소 실행 예산을 침범하지 못하게 하는 것입니다.</p>
<h3 id="3-워커-동시성은-cpu가-아니라-dependency-budget으로-제한한다">3) 워커 동시성은 CPU가 아니라 dependency budget으로 제한한다</h3>
<p>워커 수를 늘리면 처리량이 좋아질 것 같지만, 병목이 DB나 외부 API면 반대로 p99가 커집니다. 큐 워커는 보통 내부 dependency를 호출합니다. 그래서 동시성은 worker host CPU보다 DB pool, 외부 API quota, object storage bandwidth, lock contention을 기준으로 잡아야 합니다.</p>
<p>예시 기준은 이렇습니다.</p>
<ul>
<li>DB를 쓰는 critical worker: 서비스 전체 DB pool의 20~30% 이상을 단일 큐가 쓰지 않게 제한</li>
<li>외부 API worker: 업체 공식 quota의 50~70%를 평시 상한으로 시작</li>
<li>CPU-heavy worker: core 수의 0.5~1.0배 동시성부터 시작, GC/메모리 pressure 관찰</li>
<li>batch worker: batch size를 키우기 전에 <code>process_p95</code>와 lock wait를 먼저 확인</li>
<li>재처리 worker: 신규 작업 처리량의 10~20% 이하로 제한</li>
</ul>
<p>이 기준은 <a href="/learning/deep-dive/deep-dive-capacity-planning-littles-law-saturation/">Capacity Planning과 Little&rsquo;s Law</a>와 연결됩니다. 평균 처리시간이 500ms이고 목표 처리량이 초당 200건이면 필요한 동시성은 단순 계산으로 100입니다. 하지만 p95가 2초이고 dependency가 흔들리면 안전 상한은 더 낮아야 합니다. 운영에서는 평균보다 p95 기준으로 용량을 잡는 편이 장애를 덜 만듭니다.</p>
<h3 id="4-오래-걸리는-작업은-쪼개고-쪼갤-수-없으면-격리한다">4) 오래 걸리는 작업은 쪼개고, 쪼갤 수 없으면 격리한다</h3>
<p>Head-of-Line Blocking의 근본 원인은 작업 크기 편차입니다. 같은 큐에 100ms 작업과 5분 작업이 섞이면 언젠가 문제가 납니다. 가장 좋은 해법은 긴 작업을 작은 단위로 쪼개는 것입니다.</p>
<ul>
<li>10만 건 import → 500~2,000건 chunk로 분할</li>
<li>대용량 리포트 → 준비, 조회, 생성, 업로드, 알림 단계로 분리</li>
<li>이미지 묶음 처리 → 파일 단위 작업으로 분해하고 최종 집계만 별도 처리</li>
<li>AI 요약 → 문서 단위 처리 후 merge job으로 합성</li>
</ul>
<p>쪼개기 어렵다면 격리해야 합니다. 긴 작업 전용 큐, 낮은 concurrency, 더 긴 timeout, 별도 DLQ, 별도 알람을 둡니다. &ldquo;긴 작업도 가끔이니까 괜찮다&quot;는 판단은 피크 시간에 자주 깨집니다. 가끔 발생하지만 10분 이상 실행되는 작업은 별도 레인으로 보내는 편이 낫습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>큐를 나누면 안정성은 좋아지지만 운영 표면이 늘어납니다. 큐마다 consumer 배포, autoscaling, retry, DLQ, dashboard, runbook이 필요합니다. 작은 팀이라면 처음부터 세밀한 fair scheduler를 만들기보다, critical/standard/heavy/retry 4개 레인과 공통 메트릭부터 시작하는 편이 낫습니다.</p>
<p>우선순위 큐도 만능이 아닙니다. high priority가 계속 들어오면 low priority는 영원히 처리되지 않는 starvation이 생깁니다. 그래서 low priority에도 최소 처리 예산을 줘야 합니다. 예를 들어 전체 worker slot의 10%는 low priority에도 보장하고, high priority가 비어 있을 때만 빌려 쓰는 구조가 안전합니다.</p>
<p>작업 순서 보장이 필요한 도메인도 주의해야 합니다. 같은 주문, 같은 계좌, 같은 aggregate에 대한 이벤트는 무작정 병렬화하면 정합성이 깨집니다. 이 경우 전체 FIFO 대신 <strong>key 단위 순서 보장</strong>이 필요합니다. 주문 ID별 파티션은 순서를 지키되, 서로 다른 주문은 병렬 처리하는 방식입니다. 단, 특정 key가 뜨거워지는 hot partition은 별도 대응이 필요합니다.</p>
<p>마지막으로 큐는 사용자 경험을 숨길 수 있습니다. API는 202 Accepted로 빠르게 끝났지만 실제 작업이 30분 밀리면 사용자는 실패로 느낍니다. 비동기 작업에는 operation resource, 상태 조회, 지연 알림, 취소 경로를 붙이는 것이 좋습니다. 관련 내용은 <a href="/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/">Async Request-Reply와 Operation Resource</a>에서 이어서 볼 수 있습니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="운영-체크리스트">운영 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 작업군별 <code>queue_wait_p50/p95/p99</code>와 <code>process_p50/p95/p99</code>를 분리해서 보고 있는가?</li>
<li><input disabled="" type="checkbox"> 빠른 작업과 긴 작업의 p95 처리시간 차이가 10배 이상인데 같은 FIFO를 쓰고 있지 않은가?</li>
<li><input disabled="" type="checkbox"> critical 작업이 사용할 worker slot, DB connection, 외부 API quota가 최소 20~30% 이상 보호되어 있는가?</li>
<li><input disabled="" type="checkbox"> 재시도 작업이 신규 작업과 같은 큐에서 무제한 경쟁하지 않는가?</li>
<li><input disabled="" type="checkbox"> payload별 재시도 3회 이상 실패 시 DLQ 또는 quarantine으로 이동하는가?</li>
<li><input disabled="" type="checkbox"> 오래 걸리는 작업은 chunk 단위로 쪼개거나 heavy 전용 레인으로 격리했는가?</li>
<li><input disabled="" type="checkbox"> oldest message age가 SLO의 2배를 넘으면 자동 알람이 나는가?</li>
<li><input disabled="" type="checkbox"> high priority 폭주 상황에서도 low priority가 최소 10% 이상 처리될 수 있는가?</li>
<li><input disabled="" type="checkbox"> key 단위 순서 보장이 필요한 이벤트와 병렬화 가능한 이벤트를 구분했는가?</li>
<li><input disabled="" type="checkbox"> 큐 지연이 사용자에게 보이는 작업에는 상태 조회와 취소/재시도 안내가 있는가?</li>
</ul>
<h3 id="연습">연습</h3>
<p>현재 운영 중인 큐 하나를 골라 작업군별로 최근 24시간의 <code>queue_wait_p95</code>, <code>process_p95</code>, retry 비율, oldest message age를 적어보세요. 그다음 아래 기준으로 분류합니다.</p>
<ol>
<li>p95 처리시간이 다른 작업군보다 10배 이상 긴가?</li>
<li>backlog의 50% 이상을 특정 작업군이 차지하는 시간이 있는가?</li>
<li>사용자 체감 작업의 대기시간이 내부 배치 때문에 늘어나는가?</li>
<li>재시도 작업이 신규 작업 처리량의 20% 이상을 먹는가?</li>
<li>외부 dependency 장애가 전체 큐 지연으로 전파되는가?</li>
</ol>
<p>3개 이상이 &ldquo;예&quot;라면 단일 큐를 유지하기보다 critical/standard/heavy/retry 레인으로 나누는 설계를 먼저 그려보세요. 목표는 큐를 멋지게 만드는 것이 아니라, <strong>느린 작업의 비용을 느린 작업 안에 가두는 것</strong>입니다.</p>
]]></content:encoded></item><item><title>백엔드 커리큘럼 심화: Workload-Aware Queue Partitioning과 Fair Scheduling으로 느린 작업이 전체 워커를 막지 않게 하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/</guid><description>비동기 작업 큐에서 대형 테넌트, 느린 작업, poison job이 전체 워커를 점유하지 않도록 workload-aware partitioning, fair scheduling, worker pool 격리 기준을 숫자 중심으로 정리합니다.</description><content:encoded><![CDATA[<p>비동기 큐를 붙이면 시스템이 자동으로 안정해질 것처럼 보입니다. 동기 API에서 오래 걸리던 파일 변환, 리포트 생성, 알림 발송, 외부 연동을 큐 뒤로 넘기면 사용자 응답은 빨라지고, 워커가 천천히 처리하면 된다고 생각하기 쉽습니다. 그런데 트래픽이 커지면 큐는 또 다른 병목이 됩니다. 특정 테넌트의 대량 작업이 워커를 독점하고, 처리 시간이 긴 job이 짧은 job을 뒤로 밀고, 실패 job이 계속 재시도되면서 정상 job까지 늦어지는 식입니다.</p>
<p>이 문제는 단순히 워커 수를 늘린다고 해결되지 않습니다. 워커 수를 늘리면 한동안 backlog는 줄 수 있지만, DB 커넥션·외부 API quota·파일 스토리지 I/O 같은 하류 자원도 같이 때리기 때문에 오히려 전체 지연이 커질 수 있습니다. 그래서 큐 운영의 핵심은 &ldquo;얼마나 많이 처리하나&quot;보다 <strong>어떤 작업을 어떤 격리 단위와 순서로 처리하나</strong>입니다. 이 글은 <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-priority-load-shedding-bulkhead/">Priority Load Shedding과 Bulkhead</a>, <a href="/learning/deep-dive/deep-dive-multi-tenant-isolation-playbook/">멀티테넌트 격리 전략</a>, <a href="/learning/deep-dive/deep-dive-admission-control-concurrency-limits/">Admission Control과 Concurrency Limit</a>을 큐 스케줄링 관점으로 묶어 정리합니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>단일 FIFO 큐가 언제 느린 작업과 대형 테넌트에 취약해지는지 설명할 수 있습니다.</li>
<li>workload class, tenant, priority, cost estimate 기준으로 큐를 나누는 실무 기준을 잡을 수 있습니다.</li>
<li>워커 풀, 동시성 상한, retry lane, DLQ를 분리해 정상 작업의 tail latency를 보호하는 방법을 가져갈 수 있습니다.</li>
<li>큐 처리량을 늘리기 전에 봐야 할 숫자 기준과 의사결정 우선순위를 정리할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-단일-fifo-큐는-단순하지만-공정하지-않다">1) 단일 FIFO 큐는 단순하지만 공정하지 않다</h3>
<p>FIFO 큐는 이해하기 쉽습니다. 먼저 들어온 작업을 먼저 처리합니다. 작은 규모에서는 이 기본값이 충분합니다. 문제는 작업 비용이 균일하지 않을 때입니다. 100ms짜리 이메일 발송 job과 3분짜리 CSV 리포트 job이 같은 큐에 있으면, 앞쪽에 대형 job이 몰리는 순간 뒤의 짧은 job까지 같이 늦어집니다. 큐 이론으로 보면 평균 처리시간이 조금만 늘어도 대기시간은 비선형으로 커집니다.</p>
<p>실무에서 단일 큐가 위험해지는 신호는 보통 아래입니다.</p>
<ul>
<li>job 처리시간 p95와 p50 차이가 <strong>10배 이상</strong> 난다.</li>
<li>상위 1~5% 대형 job이 전체 워커 시간의 <strong>40% 이상</strong>을 쓴다.</li>
<li>특정 테넌트가 큐 유입량 또는 처리시간의 <strong>25% 이상</strong>을 차지한다.</li>
<li>retry job이 전체 실행 슬롯의 <strong>10% 이상</strong>을 점유한다.</li>
<li>전체 backlog는 괜찮은데 짧은 job의 end-to-end latency p95가 계속 악화된다.</li>
</ul>
<p>이 조건이 보이면 큐를 &ldquo;하나의 대기열&quot;로 볼 게 아니라, <strong>작업 비용이 다른 여러 흐름을 한 파이프에 섞어 둔 상태</strong>로 봐야 합니다. 이때 워커 수만 늘리는 것은 <a href="/learning/deep-dive/deep-dive-capacity-planning-littles-law-saturation/">용량 계획과 포화도 해석</a> 없이 풀 크기를 올리는 것과 비슷합니다.</p>
<h3 id="2-큐-분할-기준은-기능명이-아니라-비용과-격리-필요성이다">2) 큐 분할 기준은 기능명이 아니라 비용과 격리 필요성이다</h3>
<p>큐를 나눌 때 흔한 실수는 기능별로만 나누는 것입니다. <code>email_queue</code>, <code>report_queue</code>, <code>webhook_queue</code>처럼 이름을 붙이면 보기에는 깔끔하지만, 실제 위험은 기능명보다 비용과 실패 모드에서 갈립니다.</p>
<p>더 실용적인 분류 축은 네 가지입니다.</p>
<ol>
<li><strong>작업 비용</strong>: short, normal, heavy</li>
<li><strong>우선순위</strong>: user-visible, business-critical, best-effort</li>
<li><strong>테넌트/고객 경계</strong>: enterprise, noisy tenant, shared pool</li>
<li><strong>실패 모드</strong>: retryable, poison candidate, manual review</li>
</ol>
<p>예를 들어 리포트 생성 하나도 작게 보면 여러 lane이 필요할 수 있습니다.</p>
<table>
  <thead>
      <tr>
          <th>Lane</th>
          <th>예시</th>
          <th>목표</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>reports-short</code></td>
          <td>최근 7일 소형 CSV</td>
          <td>사용자 대기시간 p95 30초 이하</td>
      </tr>
      <tr>
          <td><code>reports-heavy</code></td>
          <td>1년치 대형 export</td>
          <td>처리량보다 하류 DB 보호 우선</td>
      </tr>
      <tr>
          <td><code>reports-enterprise</code></td>
          <td>계약 SLA가 있는 고객 작업</td>
          <td>테넌트별 quota와 우선순위 보장</td>
      </tr>
      <tr>
          <td><code>reports-retry</code></td>
          <td>외부 API 실패 후 재시도</td>
          <td>정상 신규 작업과 슬롯 분리</td>
      </tr>
  </tbody>
</table>
<p>핵심은 &ldquo;큐가 많으면 복잡하다&quot;가 아니라 <strong>복잡한 workload를 단일 큐에 숨기면 장애 때 더 복잡해진다</strong>는 점입니다. 처음부터 수십 개 큐를 만들 필요는 없지만, 최소한 short/heavy/retry는 분리 후보로 두는 편이 안전합니다.</p>
<h3 id="3-fair-scheduling은-모든-작업을-똑같이-대우하는-것이-아니다">3) Fair Scheduling은 모든 작업을 똑같이 대우하는 것이 아니다</h3>
<p>공정성은 &ldquo;모든 job을 같은 순서로 처리&quot;가 아닙니다. 실무의 공정성은 한 고객이나 한 종류의 작업이 전체 시스템을 독점하지 못하게 하는 것입니다. 특히 SaaS에서는 단일 테넌트가 대량 import를 돌린다고 해서 다른 테넌트의 알림, 결제 후처리, 권한 동기화가 밀리면 안 됩니다.</p>
<p>가장 단순한 출발점은 <strong>tenant-level concurrency cap</strong>입니다.</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-text" data-lang="text"><span style="display:flex;"><span>global_worker_concurrency = 100
</span></span><span style="display:flex;"><span>default_tenant_cap = 10
</span></span><span style="display:flex;"><span>enterprise_tenant_cap = 25
</span></span><span style="display:flex;"><span>heavy_job_global_cap = 15
</span></span><span style="display:flex;"><span>retry_lane_cap = 10
</span></span></code></pre></div><p>이렇게 두면 전체 워커가 100개여도 한 테넌트가 기본적으로 10개 이상을 잡지 못합니다. 엔터프라이즈 고객은 계약 SLA에 맞춰 25까지 허용하되, heavy job 전체는 15개로 제한해 DB를 보호합니다. 이 구조는 <a href="/learning/deep-dive/deep-dive-multi-tenant-isolation-playbook/">멀티테넌트 격리 전략</a>의 rate limit과 같은 철학입니다. 차이는 API 입구가 아니라 비동기 처리면에서 적용한다는 점입니다.</p>
<p>조금 더 발전하면 weighted fair scheduling을 씁니다. 예를 들어 기본 고객 weight 1, 엔터프라이즈 weight 3, 내부 배치 weight 0.5처럼 두고, backlog가 있을 때 weight에 비례해 슬롯을 배분합니다. 단, weight가 높다고 무제한은 아닙니다. 항상 <code>max_concurrency</code>, <code>daily_quota</code>, <code>downstream_budget</code> 세 값으로 상한을 둬야 합니다.</p>
<h3 id="4-retry-lane을-분리하지-않으면-장애가-정상-처리량을-먹는다">4) Retry lane을 분리하지 않으면 장애가 정상 처리량을 먹는다</h3>
<p>큐 장애에서 자주 보이는 패턴은 신규 작업보다 retry가 워커를 점유하는 상황입니다. 외부 API가 30분 동안 느려졌는데 모든 실패 job이 즉시 재시도되면, 정상 신규 job은 retry 폭풍 뒤에 묻힙니다. 이때 retry 횟수를 늘리는 것은 복구가 아니라 부하 증폭입니다.</p>
<p>기본 원칙은 간단합니다.</p>
<ul>
<li>신규 작업 lane과 retry lane을 분리한다.</li>
<li>retry lane은 전체 worker의 **10~20%**부터 시작한다.</li>
<li>같은 error signature가 반복되면 exponential backoff와 jitter를 강제한다.</li>
<li><code>max_attempts</code> 초과 또는 non-retryable 오류는 DLQ로 보낸다.</li>
<li>DLQ 재처리는 별도 manual 또는 controlled replay lane으로만 수행한다.</li>
</ul>
<p>예를 들어 전체 worker가 80개면 retry worker는 처음에 8~12개 정도로 둡니다. 외부 API가 복구되면 천천히 늘릴 수 있지만, 장애 중에는 신규 작업과 핵심 작업 슬롯을 보호해야 합니다. 실패 분류 기준은 <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-timeout-retry-backoff/">Timeout·Retry·Backoff</a>를 같이 맞춰야 합니다.</p>
<h3 id="5-cost-estimate가-없으면-스케줄러는-항상-늦게-배운다">5) Cost estimate가 없으면 스케줄러는 항상 늦게 배운다</h3>
<p>좋은 스케줄링은 작업을 실행하기 전에 대략적인 비용을 알아야 합니다. 완벽할 필요는 없습니다. 소형/중형/대형 정도만 나눠도 효과가 큽니다. 파일 크기, 대상 row 수, 요청 기간, 외부 API 호출 예상 수, tenant tier 같은 값으로 cost class를 계산할 수 있습니다.</p>
<p>예시 기준은 아래처럼 시작할 수 있습니다.</p>
<table>
  <thead>
      <tr>
          <th>Cost class</th>
          <th>조건</th>
          <th>기본 lane</th>
          <th>동시성</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Small</td>
          <td>예상 처리 &lt; 2초, row &lt; 1만</td>
          <td>fast lane</td>
          <td>높게</td>
      </tr>
      <tr>
          <td>Medium</td>
          <td>2초<del>1분, row 1만</del>100만</td>
          <td>normal lane</td>
          <td>보통</td>
      </tr>
      <tr>
          <td>Heavy</td>
          <td>1분 초과 또는 row 100만 이상</td>
          <td>heavy lane</td>
          <td>낮게</td>
      </tr>
      <tr>
          <td>Unknown</td>
          <td>비용 추정 불가</td>
          <td>normal 또는 review</td>
          <td>보수적</td>
      </tr>
  </tbody>
</table>
<p>비용 추정이 틀릴 수 있으므로 실행 후 실제 처리시간을 기록해 다음 스케줄링에 반영합니다. 예를 들어 특정 report type의 p95가 30초를 넘기 시작하면 자동으로 heavy lane으로 강등합니다. 반대로 계속 1초 이내로 끝나면 fast lane으로 승격할 수 있습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-기본-아키텍처-classify--enqueue--schedule--execute--feedback">1) 기본 아키텍처: classify → enqueue → schedule → execute → feedback</h3>
<p>운영 가능한 큐 시스템은 보통 아래 흐름을 갖습니다.</p>
<ol>
<li>API 또는 producer가 작업을 받을 때 <code>tenant_id</code>, <code>job_type</code>, <code>priority</code>, <code>cost_class</code>, <code>idempotency_key</code>를 기록한다.</li>
<li>classifier가 lane을 결정한다. 예: <code>fast</code>, <code>normal</code>, <code>heavy</code>, <code>retry</code>, <code>dlq</code>.</li>
<li>scheduler가 tenant cap, lane cap, downstream budget을 보고 다음 작업을 선택한다.</li>
<li>worker는 실행 중 heartbeat, timeout extension, progress metric을 남긴다.</li>
<li>실행 결과로 실제 duration, downstream call count, failure signature를 기록한다.</li>
<li>피드백 잡이 cost class와 lane 정책을 주기적으로 보정한다.</li>
</ol>
<p>이 구조의 장점은 작업 분류와 실행을 분리한다는 점입니다. producer가 단순히 큐에 넣는 것으로 끝나지 않고, 운영 정책이 작업의 이동 경로를 결정합니다.</p>
<h3 id="2-숫자-기준으로-잡는-초기값">2) 숫자 기준으로 잡는 초기값</h3>
<p>처음부터 복잡한 scheduler를 만들 필요는 없습니다. 작은 팀 기준으로는 아래 숫자부터 시작해도 충분합니다.</p>
<ul>
<li>fast lane 목표: end-to-end latency p95 <strong>30초 이하</strong></li>
<li>normal lane 목표: p95 <strong>5분 이하</strong></li>
<li>heavy lane 목표: 처리 완료율 중심, p95보다 <strong>backlog age p95 1시간 이하</strong></li>
<li>retry lane cap: 전체 worker의 <strong>10~20%</strong></li>
<li>tenant cap: 기본 고객 전체 worker의 <strong>5~10%</strong>, enterprise <strong>15~25%</strong></li>
<li>poison 감지: 같은 job이 <strong>3~5회</strong> 같은 오류로 실패하면 DLQ</li>
<li>하류 보호: DB CPU 70% 또는 외부 API 429 비율 1% 초과 시 heavy/retry lane 자동 감속</li>
</ul>
<p>의사결정 우선순위는 <strong>하류 시스템 생존 &gt; 핵심 작업 지연 보호 &gt; 전체 처리량 &gt; 비용 최적화</strong> 순서가 안전합니다. heavy lane을 빨리 비우겠다고 DB를 포화시키면 결국 fast lane까지 느려집니다.</p>
<h3 id="3-운영-대시보드에-반드시-있어야-할-지표">3) 운영 대시보드에 반드시 있어야 할 지표</h3>
<p>큐 대시보드는 단순 backlog count만 보면 부족합니다. 최소한 아래를 lane·tenant·job_type별로 볼 수 있어야 합니다.</p>
<ul>
<li><code>enqueue_to_start_latency_p50/p95/p99</code></li>
<li><code>run_duration_p50/p95/p99</code></li>
<li><code>backlog_age_p95</code></li>
<li><code>active_workers_by_lane</code></li>
<li><code>retry_attempts_by_error_signature</code></li>
<li><code>tenant_slot_usage</code></li>
<li><code>dlq_inflow_rate</code></li>
<li><code>downstream_budget_usage</code> (DB connection, API quota, storage I/O)</li>
</ul>
<p>특히 <code>backlog count</code>보다 <code>backlog age</code>가 더 중요할 때가 많습니다. 작은 job 10만 건과 heavy job 100건은 count로는 비교가 안 됩니다. 사용자 체감은 &ldquo;내 작업이 몇 번째인가&quot;보다 &ldquo;언제 시작되는가&quot;에 가깝습니다.</p>
<h3 id="4-단계적-도입-순서">4) 단계적 도입 순서</h3>
<p>이미 운영 중인 단일 큐를 한 번에 갈아엎을 필요는 없습니다. 추천 순서는 다음입니다.</p>
<ol>
<li><strong>관측 추가</strong>: job_type, tenant, duration, retry reason을 기록한다.</li>
<li><strong>retry 분리</strong>: 신규 작업과 retry 작업의 worker cap을 분리한다.</li>
<li><strong>heavy 분리</strong>: 처리시간 p95 상위 job을 별도 lane으로 보낸다.</li>
<li><strong>tenant cap 도입</strong>: 단일 테넌트 독점을 막는다.</li>
<li><strong>weighted scheduling</strong>: SLA와 tier에 따라 슬롯을 배분한다.</li>
<li><strong>자동 보정</strong>: 실제 duration을 보고 cost class를 업데이트한다.</li>
</ol>
<p>가장 효과가 큰 첫 조치는 대개 retry lane 분리입니다. 장애 중 정상 작업을 보호하는 효과가 빠르게 보입니다. 그다음은 heavy job 분리, 마지막이 공정 스케줄링 고도화입니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<h3 id="1-큐를-너무-많이-나누면-운영자가-전체-상태를-잃는다">1) 큐를 너무 많이 나누면 운영자가 전체 상태를 잃는다</h3>
<p>분리는 필요하지만 과하면 문제입니다. lane이 30개가 넘어가고 각 lane의 소유자와 SLO가 없으면, 장애 때 어디를 먼저 볼지 모르게 됩니다. 큐를 추가할 때는 반드시 아래를 같이 정해야 합니다.</p>
<ul>
<li>lane owner</li>
<li>latency 또는 backlog age SLO</li>
<li>worker cap</li>
<li>retry/DLQ 정책</li>
<li>감속·중단 조건</li>
</ul>
<p>이 다섯 가지가 없으면 새 큐는 격리가 아니라 숨겨진 부채가 됩니다.</p>
<h3 id="2-우선순위는-비즈니스-합의가-없으면-금방-무너진다">2) 우선순위는 비즈니스 합의가 없으면 금방 무너진다</h3>
<p>모든 팀이 자기 작업을 P0라고 주장하면 scheduler는 아무것도 보호하지 못합니다. P0는 &ldquo;느리면 불편한 작업&quot;이 아니라 &ldquo;늦으면 돈·보안·핵심 신뢰가 깨지는 작업&quot;이어야 합니다. 예를 들어 결제 후처리, 권한 회수, 보안 알림은 P0 후보지만, 내부 통계 리포트는 보통 P2입니다. 이 기준은 <a href="/learning/deep-dive/deep-dive-priority-load-shedding-bulkhead/">Priority Load Shedding과 Bulkhead</a>의 요청 우선순위와 맞춰야 합니다.</p>
<h3 id="3-공정성이-처리량을-일부-희생할-수-있다">3) 공정성이 처리량을 일부 희생할 수 있다</h3>
<p>fair scheduling은 전체 throughput만 보면 손해처럼 보일 때가 있습니다. 큰 job을 몰아서 처리하면 worker utilization은 높아질 수 있습니다. 하지만 사용자 체감과 SLA는 나빠질 수 있습니다. 실무에서는 평균 처리량보다 p95 대기시간, tenant별 지연 편차, 하류 포화 위험을 같이 봐야 합니다. 공정성의 목표는 가장 빠른 배치가 아니라 <strong>예측 가능한 운영</strong>입니다.</p>
<h3 id="4-하류-예산을-보지-않는-scheduler는-위험하다">4) 하류 예산을 보지 않는 scheduler는 위험하다</h3>
<p>큐 안에서는 worker가 남아 보여도 DB, Redis, 외부 API가 이미 포화일 수 있습니다. scheduler는 최소한 하류 상태를 입력으로 받아야 합니다. 예를 들어 DB CPU가 75%를 넘거나 외부 API 429가 늘면 heavy/retry lane을 자동 감속하고 fast lane만 유지하는 정책이 필요합니다. 이 관점은 <a href="/learning/deep-dive/deep-dive-admission-control-concurrency-limits/">Admission Control과 Concurrency Limit</a>의 비동기 버전입니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="운영-체크리스트">운영 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> job payload에 <code>tenant_id</code>, <code>job_type</code>, <code>priority</code>, <code>cost_class</code>, <code>idempotency_key</code>가 포함된다.</li>
<li><input disabled="" type="checkbox"> 신규 작업과 retry 작업이 같은 worker 슬롯을 무제한 공유하지 않는다.</li>
<li><input disabled="" type="checkbox"> 처리시간 p95 상위 job이 fast lane을 막지 않도록 heavy lane 또는 cap이 있다.</li>
<li><input disabled="" type="checkbox"> tenant별 동시성 상한과 계약 tier별 예외 기준이 문서화돼 있다.</li>
<li><input disabled="" type="checkbox"> lane별 SLO가 backlog count가 아니라 latency/backlog age 기준으로 정의돼 있다.</li>
<li><input disabled="" type="checkbox"> DLQ 재처리는 별도 controlled replay 절차를 거친다.</li>
<li><input disabled="" type="checkbox"> DB/API quota 같은 하류 예산이 scheduler 감속 조건에 반영돼 있다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 운영 중인 비동기 작업을 20개 골라 <code>job_type</code>, 평균 처리시간, p95 처리시간, 실패율, 테넌트 편중도를 표로 정리해 보세요.</li>
<li>p95 처리시간이 p50의 10배 이상인 작업을 찾아 <code>fast/normal/heavy</code> 중 어디로 보내야 할지 결정해 보세요.</li>
<li>전체 worker가 60개라고 가정하고, 기본 tenant cap, enterprise cap, retry lane cap, heavy lane cap을 숫자로 정해 보세요.</li>
<li>외부 API 장애로 retry가 30분 동안 늘어나는 상황을 가정하고, 신규 작업 p95를 지키기 위한 감속 규칙을 작성해 보세요.</li>
</ol>
<p>큐는 비동기 처리의 마법 상자가 아닙니다. 큐 안에서도 공정성, 격리, 하류 예산, 재처리 정책이 필요합니다. 단일 FIFO로 시작해도 괜찮지만, workload가 달라지는 순간부터는 큐를 운영 체계로 다뤄야 합니다.</p>
]]></content:encoded></item></channel></rss>