<?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>Operation Resource on jyukki's Blog</title><link>https://jyukki.com/tags/operation-resource/</link><description>Recent content in Operation Resource on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Thu, 30 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/operation-resource/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Async Request-Reply와 Operation Resource 운영 플레이북 (202 Accepted·Polling·Webhook)</title><link>https://jyukki.com/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/</guid><description>처리 시간이 길거나 외부 부작용이 있는 작업을 동기 API로 억지로 처리하지 않고, 202 Accepted와 operation resource로 분리해 안정적으로 운영하는 기준을 정리합니다.</description><content:encoded><![CDATA[<p>사용자 프로필 저장처럼 100~300ms 안에 끝나는 작업은 동기 API가 자연스럽습니다. 문제는 모든 작업을 그 방식으로 밀어붙일 때 생깁니다. 보고서 생성, 대용량 업로드 후 변환, 외부 결제 승인, 여러 하위 시스템을 거치는 provisioning은 서버가 &ldquo;요청은 받았지만 아직 끝나지 않았다&quot;는 상태를 먼저 모델링해야 안전합니다. 이걸 하지 않으면 타임아웃, 중복 클릭, 재시도 폭주, 상태 불일치가 한 번에 붙습니다.</p>
<p>그래서 실무에서는 긴 작업을 단순히 &ldquo;백그라운드로 돌린다&quot;보다, <strong>클라이언트가 추적 가능한 operation resource를 함께 설계</strong>하는 쪽이 낫습니다. 이 글은 <code>202 Accepted + Location + operation status endpoint</code> 패턴을 언제 쓰고, polling과 webhook를 어떻게 섞고, 실패와 재시도를 어떤 숫자로 관리할지 정리한 운영 플레이북입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>어떤 작업을 동기 API로 두고, 어떤 작업을 async request-reply로 분리해야 하는지 <strong>판단 기준</strong>을 잡을 수 있습니다.</li>
<li><code>202 Accepted</code>를 반환할 때 operation resource에 어떤 필드를 두어야 하는지 알 수 있습니다.</li>
<li>polling, webhook, SSE를 섞을 때 무엇을 우선하고 어디서 비용이 커지는지 이해할 수 있습니다.</li>
<li>재시도, 멱등성, 상태 전이, 보관 기간 같은 <strong>실무 숫자 기준</strong>을 바로 가져갈 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-긴-작업을-동기-응답으로-유지하면-성능-문제가-아니라-제어-문제로-번진다">1) 긴 작업을 동기 응답으로 유지하면 성능 문제가 아니라 제어 문제로 번진다</h3>
<p>동기 API가 위험해지는 기준은 단순 평균 응답시간이 아닙니다. 보통 아래 셋 중 둘 이상이면 async 전환을 먼저 검토하는 편이 안전합니다.</p>
<ol>
<li><strong>p95 처리시간이 2초를 넘는다</strong></li>
<li><strong>외부 시스템 2개 이상과 상태를 맞춰야 한다</strong></li>
<li><strong>실패 시 사용자가 같은 요청을 다시 눌러 중복 부작용이 생길 수 있다</strong></li>
</ol>
<p>예를 들어 결제 후 영수증 발행, 파일 바이러스 검사, 권한 프로비저닝은 한 번의 HTTP 연결 안에서 끝내려 할수록 문제가 커집니다. 클라이언트는 5초 안에 응답을 원하지만 서버는 20초짜리 작업을 붙잡고 있고, 사용자는 새로고침이나 재시도를 눌러 같은 작업을 또 만듭니다. 그 순간 핵심 이슈는 느림이 아니라 <strong>중복 실행과 상태 추적 부재</strong>입니다.</p>
<p>이 패턴은 <a href="/learning/deep-dive/deep-dive-rest-api-design/">REST API 설계 원칙</a>, <a href="/learning/deep-dive/deep-dive-end-to-end-deadline-cancellation-playbook/">종단간 Deadline Budget과 Cancellation</a>, <a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 설계</a>를 같이 봐야 더 선명해집니다. 긴 작업을 동기로 유지하면 deadline은 짧아지고 실제 작업은 뒤에 남으며, 사용자는 같은 요청을 다시 던지기 쉽습니다.</p>
<h3 id="2-async-request-reply의-본체는-큐가-아니라-operation-resource다">2) Async Request-Reply의 본체는 큐가 아니라 operation resource다</h3>
<p>많은 팀이 비동기 API를 도입하면서 큐만 붙이고 클라이언트 추적 모델은 비워 둡니다. 그러면 서버 내부는 비동기화됐지만 클라이언트 입장에서는 여전히 &ldquo;언제 끝나는지 모르는 요청&quot;이 됩니다. 그래서 핵심은 메시지 큐보다 <strong>operation resource</strong>입니다.</p>
<p>권장 흐름은 아래와 같습니다.</p>
<ol>
<li>클라이언트가 <code>POST /exports</code> 또는 <code>POST /provisioning-jobs</code> 호출</li>
<li>서버는 멱등성 키를 확인하고 작업 수락 여부 판단</li>
<li>즉시 <code>202 Accepted</code> 반환</li>
<li><code>Location: /operations/{operationId}</code> 또는 본문에 operation URI 제공</li>
<li>클라이언트는 <code>GET /operations/{id}</code>로 상태 조회</li>
<li>완료 시 <code>result_uri</code>, 실패 시 <code>error_code</code>, 취소 시 <code>canceled_at</code> 제공</li>
</ol>
<p>operation resource에 최소한 아래 필드는 있어야 운영이 쉽습니다.</p>
<ul>
<li><code>operation_id</code></li>
<li><code>status</code> (<code>accepted</code>, <code>running</code>, <code>succeeded</code>, <code>failed</code>, <code>canceled</code>, <code>expired</code>)</li>
<li><code>submitted_at</code>, <code>started_at</code>, <code>finished_at</code></li>
<li><code>request_id</code>, <code>idempotency_key</code></li>
<li><code>progress_percent</code> 또는 <code>current_step</code></li>
<li><code>result_uri</code> 또는 <code>error</code></li>
<li><code>retryable</code> 여부</li>
<li><code>expires_at</code></li>
</ul>
<p>핵심은 &ldquo;작업이 큐에 들어갔다&quot;가 아니라, <strong>클라이언트와 운영자가 같은 상태 객체를 본다</strong>는 점입니다. 이 모델이 없으면 장애 때 support 팀은 DB와 큐를 뒤져야 하고, 사용자는 버튼을 다시 누르게 됩니다.</p>
<h3 id="3-polling-webhook-sse는-대체재가-아니라-대상별-조합이다">3) Polling, Webhook, SSE는 대체재가 아니라 대상별 조합이다</h3>
<p>실무에서 자주 나오는 오해가 &ldquo;polling은 구식이고 webhook가 정답&quot;이라는 생각입니다. 실제로는 소비자 유형에 따라 다릅니다.</p>
<ul>
<li><strong>브라우저/모바일 최종 사용자</strong>: polling 또는 SSE가 단순하고 안정적</li>
<li><strong>B2B 파트너 시스템</strong>: webhook가 효율적이지만 서명·재시도·DLQ가 필수</li>
<li><strong>사내 어드민 대시보드</strong>: polling으로 시작하고 필요 시 SSE 추가</li>
</ul>
<p>처음 기준을 잡을 때는 아래처럼 보면 편합니다.</p>
<ul>
<li>완료 시간이 <strong>10초 이하</strong>면 1~2초 간격 polling으로 충분한 경우가 많음</li>
<li>평균 완료 시간이 <strong>수분 단위</strong>면 polling 간격을 5~15초로 늘리거나 webhook 병행</li>
<li>클라이언트 수가 많아 동시 polling 요청이 작업 수보다 <strong>10배 이상</strong> 커지면 SSE/webhook 검토</li>
<li>파트너 시스템에 외부 효과가 있으면 webhook를 쓰되 <a href="/learning/deep-dive/deep-dive-webhook-delivery-reliability-playbook/">Webhook Delivery Reliability 플레이북</a>의 서명·재시도 기준을 같이 붙임</li>
</ul>
<p>즉 설계 질문은 &ldquo;무조건 실시간인가&quot;가 아니라, <strong>누가 상태를 소비하고 어떤 실패를 감당할 수 있는가</strong>입니다. 사용자 화면 한두 개 때문에 webhook 인프라를 먼저 키우는 건 과할 수 있고, 반대로 외부 파트너 통합에 polling만 강제하면 지연과 비용이 빠르게 커집니다. 실시간성이 정말 중요하면 <a href="/learning/deep-dive/deep-dive-websocket-sse-patterns/">WebSocket과 SSE 패턴</a>을 같이 검토하면 됩니다.</p>
<h3 id="4-큐를-넣는-순간-ack-visibility-retry-정책이-api-품질에-직접-연결된다">4) 큐를 넣는 순간 ack, visibility, retry 정책이 API 품질에 직접 연결된다</h3>
<p>async request-reply는 HTTP 레이어에서 끝나지 않습니다. 실제 품질은 작업 큐와 워커 정책에서 결정됩니다. 예를 들어 operation status는 <code>running</code>인데 워커가 죽어서 메시지는 다시 보이지 않는 상태면, 클라이언트는 영원히 끝나지 않는 작업을 보게 됩니다.</p>
<p>그래서 큐 정책은 operation 상태와 같이 설계해야 합니다.</p>
<ul>
<li>visibility timeout은 <strong>평균 처리시간의 2~3배</strong>에서 시작</li>
<li>최대 재시도 횟수는 <strong>3~5회</strong> 범위에서 작업 성격별 분리</li>
<li>1회 처리 시간이 5분을 넘는 작업은 heartbeat 또는 step checkpoint 필요</li>
<li><code>failed</code>와 <code>retrying</code>을 같은 상태로 뭉개지 말 것</li>
<li>최종 실패는 DLQ로 격리하고 operation에는 <code>retryable=false</code>를 명시</li>
</ul>
<p>이 부분은 <a href="/learning/deep-dive/deep-dive-queue-visibility-timeout-acknack-playbook/">Queue Visibility Timeout / Ack-Nack 플레이북</a>과 <a href="/learning/deep-dive/deep-dive-transactional-outbox-cdc/">Transactional Outbox + CDC</a>를 함께 보는 편이 좋습니다. API 계약과 워커 계약이 따로 놀면 &ldquo;202는 잘 나가는데 실제 완료율은 낮은&rdquo; 이상한 시스템이 됩니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-동기-vs-비동기-의사결정-기준">1) 동기 vs 비동기 의사결정 기준</h3>
<p>제가 실무에서 먼저 보는 기준은 아래 순서입니다.</p>
<ol>
<li><strong>사용자 체감 목표</strong>
<ul>
<li>p95 응답 목표가 1초 내외인데 작업 자체가 3초 이상이면 비동기 우선</li>
</ul>
</li>
<li><strong>외부 부작용 크기</strong>
<ul>
<li>이메일, 결제, 권한 부여, 파트너 API 호출처럼 중복 실행이 비싸면 비동기 + 멱등성 필수</li>
</ul>
</li>
<li><strong>상태 추적 필요성</strong>
<ul>
<li>사용자가 &ldquo;요청이 접수됐는지, 진행 중인지, 실패했는지&quot;를 봐야 하면 operation resource 필요</li>
</ul>
</li>
<li><strong>트래픽 형태</strong>
<ul>
<li>순간 피크에서 긴 작업이 thread/connection을 오래 점유하면 비동기 우선</li>
</ul>
</li>
</ol>
<p>빠른 출발 기준은 아래 정도가 현실적입니다.</p>
<ul>
<li>동기 유지: p95 <strong>1.5초 이하</strong>, 외부 의존성 <strong>1개 이하</strong>, 중복 부작용 낮음</li>
<li>경계 구간: p95 <strong>1.5~3초</strong>, 팬아웃 <strong>2~3개</strong>, 실패 시 재시도 가능성 높음</li>
<li>비동기 전환: p95 <strong>3초 초과</strong> 또는 외부 부작용 큼 또는 완료 콜백/상태 추적 필요</li>
</ul>
<h3 id="2-api-계약-예시">2) API 계약 예시</h3>
<p><code>POST /reports</code></p>
<ul>
<li>요청 성공 수락 시 <code>202 Accepted</code></li>
<li>응답 헤더 <code>Location: /operations/op_123</code></li>
<li>응답 본문에는 <code>operation_id</code>, <code>status</code>, <code>poll_after_seconds</code> 포함</li>
</ul>
<p><code>GET /operations/op_123</code></p>
<ul>
<li><code>accepted</code>: 아직 큐 대기 중</li>
<li><code>running</code>: 실제 처리 중</li>
<li><code>succeeded</code>: <code>result_uri</code> 포함</li>
<li><code>failed</code>: <code>error_code</code>, <code>retryable</code>, <code>failed_reason</code> 포함</li>
<li><code>expired</code>: 조회 가능 기간 종료</li>
</ul>
<p>추천 시작값:</p>
<ul>
<li><code>poll_after_seconds</code>: 기본 <strong>2초</strong>, 장기 작업은 <strong>5초</strong></li>
<li>operation 조회 가능 기간: 성공 후 <strong>24시간</strong>, 실패 후 <strong>7일</strong></li>
<li>멱등성 키 보존 기간: <strong>24~72시간</strong></li>
<li>progress 갱신 최소 간격: <strong>5초</strong> 또는 step 완료 시점</li>
</ul>
<h3 id="3-운영-지표와-알람-기준">3) 운영 지표와 알람 기준</h3>
<p>비동기 API는 접수량보다 <strong>완료 품질</strong>을 봐야 합니다. 시작 지표는 아래면 충분합니다.</p>
<ul>
<li><code>operation_accept_to_start_p95</code>가 <strong>30초 초과</strong>하면 큐 적체 점검</li>
<li><code>operation_running_time_p95</code>가 기준선 대비 <strong>50% 이상 상승</strong>하면 워커/외부 의존성 확인</li>
<li><code>operation_stuck_ratio</code>가 <strong>1% 초과</strong>하면 heartbeat 또는 timeout 누락 조사</li>
<li><code>duplicate_operation_ratio</code>가 <strong>0.3% 초과</strong>하면 idempotency key 정책 재점검</li>
<li><code>poll_requests_per_completed_operation</code>이 <strong>20 초과</strong>면 polling 간격 또는 push 방식 개선 검토</li>
</ul>
<p>중요한 우선순위는 보통 <strong>중복 부작용 방지 &gt; 완료율 &gt; 실시간성 &gt; 구현 단순성</strong> 순입니다. 완료 알림이 3초 늦는 것보다 같은 결제가 두 번 처리되는 쪽이 훨씬 비쌉니다.</p>
<h3 id="4-도입-순서">4) 도입 순서</h3>
<p><strong>1단계, 상태 모델 먼저</strong></p>
<ul>
<li>큐 도입보다 operation status 스키마와 상태 전이표를 확정합니다.</li>
</ul>
<p><strong>2단계, 멱등성 추가</strong></p>
<ul>
<li><code>POST</code> 요청에 idempotency key를 받아 같은 작업 중복 생성을 막습니다.</li>
</ul>
<p><strong>3단계, 워커/큐 정책 연결</strong></p>
<ul>
<li>재시도, visibility timeout, DLQ 조건을 operation 상태와 매핑합니다.</li>
</ul>
<p><strong>4단계, 알림 채널 확장</strong></p>
<ul>
<li>polling으로 시작하고, 실제 병목이 보일 때 webhook 또는 SSE를 추가합니다.</li>
</ul>
<p>이 순서가 좋은 이유는 운영 문제 대부분이 &ldquo;전달 채널 부재&quot;보다 &ldquo;상태 계약 부재&quot;에서 나오기 때문입니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li>
<p><strong>비동기화가 항상 더 싸진 않다</strong><br>
큐, 워커, 상태 저장소, 알림 채널이 추가되므로 시스템 구성은 분명 복잡해집니다. 처리 시간이 800ms인 작업까지 전부 operation resource로 빼면 오히려 과설계가 됩니다.</p>
</li>
<li>
<p><strong>진행률 숫자는 거짓말이 되기 쉽다</strong><br>
<code>progress_percent</code>를 억지로 넣으면 의미 없는 10%, 60%, 90%만 늘어날 수 있습니다. 단계 기반 작업이면 <code>current_step</code>이 더 정직할 때가 많습니다.</p>
</li>
<li>
<p><strong>실패를 숨기면 support 비용이 커진다</strong><br>
&ldquo;백그라운드에서 처리 중&quot;만 보여주고 실제 실패 이유를 감추면 사용자와 운영자 모두 재시도를 남발하게 됩니다. <code>retryable</code> 여부와 오류 코드는 최대한 명시하는 편이 낫습니다.</p>
</li>
<li>
<p><strong>polling 비용은 완료 시간 분포와 함께 봐야 한다</strong><br>
평균 2분짜리 작업을 1초마다 polling하면 완료 1건당 상태 조회가 120번입니다. 작업 수가 하루 10만 건이면 상태 조회만으로도 별도 비용이 됩니다.</p>
</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> p95 3초 초과 또는 외부 부작용 큰 작업을 동기 API로 억지 유지하고 있지 않다.</li>
<li><input disabled="" type="checkbox"> <code>202 Accepted</code> 응답에 operation URI 또는 식별자가 포함된다.</li>
<li><input disabled="" type="checkbox"> operation resource에 상태, 결과, 오류, 만료 시각이 명시된다.</li>
<li><input disabled="" type="checkbox"> idempotency key와 중복 작업 정책이 문서화돼 있다.</li>
<li><input disabled="" type="checkbox"> polling 간격, 조회 보관 기간, DLQ 조건이 숫자로 정리돼 있다.</li>
<li><input disabled="" type="checkbox"> 큐 재시도 상태와 operation 상태 전이가 서로 어긋나지 않는다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<ol>
<li>현재 서비스에서 p95 2초를 넘는 API 3개를 골라, 동기 유지 이유와 async 전환 시 장단점을 표로 적어 보세요.</li>
<li>하나의 긴 작업 API를 골라 <code>accepted → running → succeeded/failed/canceled/expired</code> 상태 전이표를 작성해 보세요.</li>
<li>완료까지 평균 45초 걸리는 작업을 가정하고, polling 2초와 5초의 상태 조회 비용 차이를 계산해 보세요.</li>
<li>같은 요청이 3번 중복 제출될 때 멱등성 키가 없으면 어떤 외부 부작용이 발생하는지 시나리오로 적어 보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-rest-api-design/">REST API 설계 원칙</a></li>
<li><a href="/learning/deep-dive/deep-dive-end-to-end-deadline-cancellation-playbook/">종단간 Deadline Budget과 Cancellation</a></li>
<li><a href="/learning/deep-dive/deep-dive-idempotency/">멱등성 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-webhook-delivery-reliability-playbook/">Webhook Delivery Reliability 플레이북</a></li>
<li><a href="/learning/deep-dive/deep-dive-queue-visibility-timeout-acknack-playbook/">Queue Visibility Timeout / Ack-Nack 플레이북</a></li>
<li><a href="/learning/deep-dive/deep-dive-transactional-outbox-cdc/">Transactional Outbox + CDC</a></li>
<li><a href="/learning/deep-dive/deep-dive-websocket-sse-patterns/">WebSocket과 SSE 패턴</a></li>
</ul>
]]></content:encoded></item></channel></rss>