<?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>Async API on jyukki's Blog</title><link>https://jyukki.com/tags/async-api/</link><description>Recent content in Async API on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Wed, 27 May 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/async-api/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Bulk Import Job, 대량 업로드를 안전하게 처리하는 운영 설계</title><link>https://jyukki.com/learning/deep-dive/deep-dive-bulk-import-job-row-error-playbook/</link><pubDate>Wed, 27 May 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-bulk-import-job-row-error-playbook/</guid><description>CSV·엑셀·JSONL 같은 대량 업로드를 동기 API로 처리하지 않고, import job·row error·멱등성·부분 성공·재처리 기준으로 운영하는 방법을 정리합니다.</description><content:encoded><![CDATA[<p>관리자 화면에서 &ldquo;CSV 업로드&rdquo; 버튼 하나를 붙이는 일은 쉬워 보입니다. 파일을 받고, 파싱하고, DB에 넣으면 끝처럼 느껴집니다. 하지만 실제 운영에서는 이 기능이 자주 장애의 출발점이 됩니다. 10행짜리 테스트 파일은 잘 들어가지만, 고객이 20만 행짜리 파일을 올리면 요청 timeout이 나고, 절반은 저장됐는데 절반은 실패하고, 사용자는 같은 파일을 다시 올립니다. 그 결과 중복 데이터, 누락 데이터, 실패 원인 불명, DB 부하 급증이 한꺼번에 옵니다.</p>
<p>그래서 대량 업로드는 파일 처리 기능이 아니라 <strong>상태를 가진 데이터 변경 파이프라인</strong>으로 봐야 합니다. 이 글은 CSV·엑셀·JSONL import를 예로 들지만, 파트너 정산 파일, 상품 카탈로그 동기화, 사용자 일괄 초대, 쿠폰 발급, 권한 대량 변경에도 같은 기준을 적용할 수 있습니다. 기본 흐름은 <a href="/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/">Async Request-Reply Operation Resource</a>, <a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency/Reprocessing</a>, <a href="/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/">Object Upload Quarantine</a>, <a href="/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/">Workload-aware Queue</a>와 이어집니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>대량 import를 동기 API가 아니라 비동기 job으로 설계해야 하는 이유를 설명할 수 있습니다.</li>
<li>dry-run, validation, canary apply, full apply를 어떤 순서로 나눌지 기준을 잡을 수 있습니다.</li>
<li>row error, 부분 성공, 멱등성, 재업로드, 재처리 정책을 숫자로 정의할 수 있습니다.</li>
<li>운영자가 &ldquo;파일 다시 주세요&quot;가 아니라 error report와 replay plan으로 대응할 수 있게 만드는 체크리스트를 얻습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-bulk-import는-http-요청이-아니라-job이다">1) Bulk import는 HTTP 요청이 아니라 job이다</h3>
<p>대량 import를 <code>POST /admin/products/import</code> 하나로 처리하면 곧 한계가 옵니다. 브라우저와 API gateway에는 timeout이 있고, 서버 thread는 오래 점유되며, DB transaction은 커지고, 실패 응답 하나로는 어떤 행이 왜 실패했는지 설명할 수 없습니다. 더 나쁜 상황은 클라이언트가 timeout을 실패로 보고 같은 파일을 다시 올리는 것입니다. 서버는 이미 일부 row를 반영했는데 사용자는 실패했다고 믿을 수 있습니다.</p>
<p>기본 API 계약은 아래처럼 분리하는 편이 안전합니다.</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>POST /imports
</span></span><span style="display:flex;"><span>  -&gt; 202 Accepted
</span></span><span style="display:flex;"><span>  -&gt; operation_url: /imports/{job_id}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>GET /imports/{job_id}
</span></span><span style="display:flex;"><span>  -&gt; status, progress, counters, error_report_url
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>POST /imports/{job_id}/apply
</span></span><span style="display:flex;"><span>  -&gt; 검증 완료 job만 실제 반영
</span></span></code></pre></div><p>상태는 최소 <code>uploaded</code>, <code>validating</code>, <code>validation_failed</code>, <code>ready_to_apply</code>, <code>applying</code>, <code>partially_applied</code>, <code>applied</code>, <code>failed</code>, <code>canceled</code> 정도로 나눕니다. 처음부터 복잡해 보이지만, 상태를 나누지 않으면 결국 로그와 DB row를 사람이 뒤져 상태를 추정하게 됩니다. 상태 모델은 곧 운영 UX입니다.</p>
<h3 id="2-업로드-성공과-적용-성공을-분리한다">2) 업로드 성공과 적용 성공을 분리한다</h3>
<p>파일이 object storage에 올라갔다고 데이터가 반영 가능한 것은 아닙니다. 파일에는 악성 매크로, 잘못된 MIME, 깨진 인코딩, 중복 헤더, 허용되지 않은 컬럼, 너무 큰 셀, 개인정보가 섞일 수 있습니다. 그래서 업로드 단계는 &ldquo;받았다&quot;까지만 의미하고, import 단계는 별도 검증을 거쳐야 합니다.</p>
<p>권장 단계는 아래입니다.</p>
<ol>
<li><strong>Upload</strong>: presigned URL 또는 서버 업로드로 파일 수신</li>
<li><strong>Quarantine</strong>: 파일 크기, 확장자, MIME, signature, malware scan 확인</li>
<li><strong>Parse</strong>: 인코딩, 헤더, 행 수, 컬럼 타입 검증</li>
<li><strong>Validate</strong>: 도메인 규칙, FK 존재, 권한, 중복, 제한량 검증</li>
<li><strong>Dry-run report</strong>: 반영 전 예상 성공/실패 요약 제공</li>
<li><strong>Apply</strong>: 승인 또는 자동 정책에 따라 실제 쓰기</li>
<li><strong>Reconcile</strong>: 카운터, 샘플, 불일치율, 감사 로그 확인</li>
</ol>
<p>출발 숫자는 보수적으로 잡습니다. 예를 들어 일반 관리자 import는 파일 크기 50MB, row 수 10만, validation p95 2분, apply batch size 500~2,000 rows, 단일 job DB CPU 추가 사용률 15%p 이하부터 시작할 수 있습니다. 이 숫자는 정답이 아니라 안전한 초깃값입니다. 정산·권한·재고처럼 부작용이 큰 도메인은 더 낮게 시작합니다.</p>
<h3 id="3-row-error는-사람이-읽을-문장이-아니라-계약이다">3) row error는 사람이 읽을 문장이 아니라 계약이다</h3>
<p>대량 import에서 가장 중요한 산출물은 성공 메시지가 아니라 실패 리포트입니다. &ldquo;1234행 처리 실패&quot;만 주면 운영자는 원본 파일을 다시 열고, 개발자는 로그를 뒤집니다. 좋은 row error는 재현 가능하고, 사용자가 고칠 수 있고, 시스템이 집계할 수 있어야 합니다.</p>
<p>권장 구조는 아래와 같습니다.</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-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;job_id&#34;</span>: <span style="color:#f1fa8c">&#34;imp_20260527_001&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;row_number&#34;</span>: <span style="color:#bd93f9">1842</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;row_key&#34;</span>: <span style="color:#f1fa8c">&#34;sku:ABC-0192&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;status&#34;</span>: <span style="color:#f1fa8c">&#34;rejected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;error_code&#34;</span>: <span style="color:#f1fa8c">&#34;PRICE_NEGATIVE&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;message&#34;</span>: <span style="color:#f1fa8c">&#34;price must be greater than or equal to 0&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;field&#34;</span>: <span style="color:#f1fa8c">&#34;price&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;severity&#34;</span>: <span style="color:#f1fa8c">&#34;error&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;raw_value_hash&#34;</span>: <span style="color:#f1fa8c">&#34;sha256:...&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;suggested_action&#34;</span>: <span style="color:#f1fa8c">&#34;set a non-negative price and re-upload&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>여기서 <code>message</code>는 바뀔 수 있지만 <code>error_code</code>는 API 계약입니다. error code가 있어야 상위 10개 실패 원인, 배포 후 error spike, 특정 고객 파일 품질을 집계할 수 있습니다. 원본 값은 민감정보일 수 있으므로 기본은 hash 또는 redacted value를 저장합니다. 원문이 필요하면 짧은 보존 기간과 감사 로그를 둡니다. 이 기준은 <a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log</a>와도 맞닿아 있습니다.</p>
<h3 id="4-부분-성공-정책을-먼저-정해야-한다">4) 부분 성공 정책을 먼저 정해야 한다</h3>
<p>대량 import는 all-or-nothing과 partial success 사이에서 결정해야 합니다. 둘 중 하나가 항상 옳지는 않습니다.</p>
<table>
  <thead>
      <tr>
          <th>도메인</th>
          <th>권장 정책</th>
          <th>이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>상품 설명, 태그, 이미지 메타데이터</td>
          <td>부분 성공 허용</td>
          <td>row 간 독립성이 높고 재수정 비용이 낮음</td>
      </tr>
      <tr>
          <td>사용자 초대</td>
          <td>부분 성공 허용 가능</td>
          <td>이미 존재하는 이메일은 skip하고 신규만 처리 가능</td>
      </tr>
      <tr>
          <td>쿠폰 대량 발급</td>
          <td>제한적 부분 성공</td>
          <td>중복 발급과 한도 초과를 강하게 막아야 함</td>
      </tr>
      <tr>
          <td>권한 일괄 변경</td>
          <td>기본은 dry-run 후 승인</td>
          <td>잘못 적용하면 보안 사고</td>
      </tr>
      <tr>
          <td>정산/포인트/재고</td>
          <td>all-or-nothing 또는 ledger 기반</td>
          <td>부분 반영이 금전·수량 불일치로 이어짐</td>
      </tr>
  </tbody>
</table>
<p>부분 성공을 허용한다면 실패 허용률을 숫자로 둡니다. 예를 들어 상품 import는 row rejection rate 5% 이하면 적용 가능, 5~20%는 사용자 승인 필요, 20% 초과는 자동 중단으로 둘 수 있습니다. 권한 변경은 실패율보다 실패 유형이 중요합니다. <code>USER_NOT_FOUND</code> 1건은 수정 가능한 오류지만, <code>FORBIDDEN_ROLE_ESCALATION</code> 1건은 전체 job 중단 신호일 수 있습니다.</p>
<h3 id="5-멱등성은-파일-단위와-row-단위로-나눠야-한다">5) 멱등성은 파일 단위와 row 단위로 나눠야 한다</h3>
<p>사용자는 실패했다고 느끼면 같은 파일을 다시 올립니다. 운영자도 &ldquo;다시 실행&rdquo; 버튼을 누릅니다. 그래서 import는 재실행을 정상 시나리오로 봐야 합니다.</p>
<p>멱등성 키는 최소 두 층이 필요합니다.</p>
<ul>
<li><strong>file fingerprint</strong>: normalized header + content hash + tenant_id + import_type</li>
<li><strong>row effect key</strong>: tenant_id + import_type + business_key + operation_version</li>
</ul>
<p>file fingerprint는 같은 파일의 중복 등록을 감지합니다. row effect key는 같은 비즈니스 효과가 두 번 적용되는 것을 막습니다. 예를 들어 상품 가격 import라면 <code>tenant:store-1:price:sku-ABC:v20260527</code> 같은 키를 둘 수 있습니다. 단순히 row number를 키로 쓰면 사용자가 파일 정렬을 바꾸는 순간 중복 방지가 깨집니다. 반대로 business key만 쓰면 정상적인 두 번째 가격 변경까지 막을 수 있으므로 operation version이나 effective date를 함께 둡니다.</p>
<p>이 관점은 <a href="/learning/deep-dive/deep-dive-upsert-unique-idempotency-write-path-playbook/">UPSERT·UNIQUE·멱등 키</a>와 같습니다. DB unique constraint는 중복 row를 막아주지만, &ldquo;같은 업무 효과를 다시 냈는가&quot;까지 자동으로 판단해 주지는 않습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-import-job-테이블을-먼저-설계한다">1) import job 테이블을 먼저 설계한다</h3>
<p>최소 스키마는 아래 정도로 시작할 수 있습니다.</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:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> import_jobs (
</span></span><span style="display:flex;"><span>  id <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  tenant_id <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  import_type <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">64</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">32</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  file_fingerprint <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">128</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  uploaded_by <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  total_rows <span style="color:#8be9fd;font-style:italic">INTEGER</span> <span style="color:#ff79c6">DEFAULT</span> <span style="color:#bd93f9">0</span>,
</span></span><span style="display:flex;"><span>  valid_rows <span style="color:#8be9fd;font-style:italic">INTEGER</span> <span style="color:#ff79c6">DEFAULT</span> <span style="color:#bd93f9">0</span>,
</span></span><span style="display:flex;"><span>  rejected_rows <span style="color:#8be9fd;font-style:italic">INTEGER</span> <span style="color:#ff79c6">DEFAULT</span> <span style="color:#bd93f9">0</span>,
</span></span><span style="display:flex;"><span>  applied_rows <span style="color:#8be9fd;font-style:italic">INTEGER</span> <span style="color:#ff79c6">DEFAULT</span> <span style="color:#bd93f9">0</span>,
</span></span><span style="display:flex;"><span>  failed_rows <span style="color:#8be9fd;font-style:italic">INTEGER</span> <span style="color:#ff79c6">DEFAULT</span> <span style="color:#bd93f9">0</span>,
</span></span><span style="display:flex;"><span>  error_report_ref <span style="color:#8be9fd;font-style:italic">VARCHAR</span>(<span style="color:#bd93f9">256</span>),
</span></span><span style="display:flex;"><span>  created_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>  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></code></pre></div><p>row 결과는 별도 테이블이나 object storage report로 분리합니다. 모든 row 결과를 DB에 영구 보관하면 비용이 커질 수 있습니다. 보통 최근 7~30일은 조회 가능하게 두고, 장기 보관은 압축된 report artifact로 넘기는 방식이 낫습니다. 단, 정산·권한처럼 감사가 필요한 import는 보관 기간을 길게 잡고 접근 권한을 제한합니다.</p>
<h3 id="2-검증과-적용을-다른-worker-pool로-분리한다">2) 검증과 적용을 다른 worker pool로 분리한다</h3>
<p>validation은 CPU와 DB read를 많이 씁니다. apply는 DB write와 downstream side effect를 만듭니다. 둘을 같은 worker pool에 넣으면 검증 job 폭주가 실제 반영 작업을 막거나, 반대로 apply가 validation 대기열을 밀어냅니다.</p>
<p>권장 분리:</p>
<ul>
<li><code>import-parse</code>: 파일 읽기, 인코딩, 헤더 검증</li>
<li><code>import-validate</code>: 도메인 read 검증, FK 확인, 중복 검사</li>
<li><code>import-apply-low-risk</code>: 상품/태그 같은 낮은 위험 변경</li>
<li><code>import-apply-high-risk</code>: 권한/정산/재고 같은 승인 기반 변경</li>
<li><code>import-report</code>: error report 생성과 알림</li>
</ul>
<p>운영 기준은 <a href="/learning/deep-dive/deep-dive-workload-aware-queue-partitioning-fair-scheduling/">Workload-aware Queue</a>처럼 잡습니다. 대형 job이 짧은 job을 막지 않도록 tenant별 동시 실행 1<del>2개, 전체 apply worker는 DB write capacity의 10</del>20% 이하부터 시작합니다. 온라인 트래픽 p95가 20% 이상 악화되면 import apply를 자동 throttle하는 게 안전합니다.</p>
<h3 id="3-dry-run을-기본값으로-둔다">3) dry-run을 기본값으로 둔다</h3>
<p>관리자 기능은 &ldquo;업로드하면 바로 반영&quot;이 편해 보이지만, 실무에서는 dry-run이 더 빠릅니다. 잘못된 파일을 반영하고 되돌리는 시간보다, 반영 전에 오류를 보여주는 시간이 훨씬 싸기 때문입니다.</p>
<p>dry-run report에는 최소 아래가 필요합니다.</p>
<ul>
<li>전체 row 수, 적용 가능 row 수, 거부 row 수</li>
<li>error code별 top 10</li>
<li>샘플 row error 20개</li>
<li>예상 신규/수정/skip/delete 카운터</li>
<li>영향 범위: tenant, resource type, effective date</li>
<li>apply 예상 시간과 부하 등급</li>
<li>high-risk 경고: 권한 상승, 금액 변경, 재고 음수 가능성</li>
</ul>
<p>자동 apply 기준은 좁게 둡니다. 예를 들어 <code>rejected_rows = 0</code>, <code>estimated_apply_rows &lt;= 10,000</code>, <code>high_risk_error = 0</code>, <code>tenant_import_concurrency = 0</code>, <code>DB CPU &lt; 60%</code>일 때만 자동 적용합니다. 그 외에는 사람이 report를 보고 승인하게 합니다.</p>
<h3 id="4-replay와-cancel을-제품-기능으로-만든다">4) replay와 cancel을 제품 기능으로 만든다</h3>
<p>import job은 실패합니다. 그래서 처음부터 다시 실행, 이어 실행, 취소를 API와 UI에 넣어야 합니다.</p>
<ul>
<li><code>cancel</code>: 아직 apply되지 않은 job은 즉시 취소, apply 중이면 batch 경계에서 중지</li>
<li><code>retry validation</code>: 파일은 그대로 두고 검증 로직 또는 참조 데이터 변경 후 재검증</li>
<li><code>retry failed rows</code>: 실패 row만 새 job으로 재시도</li>
<li><code>replay apply</code>: idempotency ledger 확인 후 적용 누락분만 재실행</li>
</ul>
<p>고위험 replay에는 <a href="/learning/deep-dive/deep-dive-poison-message-quarantine-safe-replay-playbook/">Poison Message Quarantine/Safe Replay</a>와 같은 근거 패킷이 필요합니다. root cause, 수정 증거, 영향 범위, replay 대상 row 수, 중단 조건, rollback/compensation 경로를 승인 전에 보여줘야 합니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, job 모델은 초기 구현량이 늘어납니다. 상태 테이블, worker, report, UI, 알림이 필요합니다. 하지만 동기 API로 시작해 장애가 난 뒤 job 모델로 옮기면 데이터 정리 비용이 더 큽니다. row 수가 1,000을 넘거나 처리 시간이 5초를 넘을 수 있으면 처음부터 job으로 두는 편이 낫습니다.</p>
<p>둘째, 부분 성공은 사용자 편의와 데이터 일관성 사이의 타협입니다. 부분 성공을 허용하면 업무가 앞으로 나아가지만, 누락 row를 나중에 별도로 관리해야 합니다. all-or-nothing은 일관성은 좋지만 작은 오류 하나가 전체 업무를 막습니다. 판단 우선순위는 <strong>금전/권한/재고 정합성 &gt; 고객 영향 범위 &gt; 운영 재처리 비용 &gt; 사용자 편의</strong> 순서가 안전합니다.</p>
<p>셋째, error report에 원문 데이터를 과하게 남기면 보안 문제가 됩니다. 이메일, 전화번호, 주소, 계좌, 외부 식별자는 기본 마스킹하고, 원문 파일 접근은 만료와 감사 로그가 있는 경로로 제한합니다.</p>
<p>넷째, import worker는 온라인 서비스와 자원을 공유합니다. 밤에만 돌리는 배치라고 방심하면 안 됩니다. 야간 배치가 DB vacuum, index build, backup, analytics query와 겹치면 낮보다 더 위험할 수 있습니다. import에는 rate limit, tenant quota, pause switch가 필요합니다.</p>
<h2 id="운영-지표와-알람-기준">운영 지표와 알람 기준</h2>
<p>대량 import 기능은 &ldquo;성공했는가&quot;만 보면 늦습니다. 운영자는 import가 온라인 서비스에 어떤 압력을 주는지, 사용자 파일 품질이 나빠지는지, 재처리가 반복되는지까지 봐야 합니다. 최소 대시보드는 job 단위 지표와 시스템 단위 지표를 나눕니다.</p>
<p>job 단위 지표:</p>
<ul>
<li><code>total_rows</code>, <code>valid_rows</code>, <code>rejected_rows</code>, <code>applied_rows</code>, <code>skipped_rows</code></li>
<li>validation latency p50/p95, apply latency p50/p95</li>
<li>row rejection rate, retry count, replay count</li>
<li>error code top 10과 신규 error code 발생 여부</li>
<li>dry-run 이후 apply까지 걸린 시간</li>
<li>cancel 또는 manual approval 비율</li>
</ul>
<p>시스템 단위 지표:</p>
<ul>
<li>import queue depth와 oldest job age</li>
<li>worker pool별 처리량과 실패율</li>
<li>DB write QPS, lock wait, replication lag, online API p95</li>
<li>tenant별 동시 실행 수와 quota 초과 횟수</li>
<li>report artifact 생성 실패율과 다운로드 실패율</li>
</ul>
<p>알람은 단순 실패 건수보다 사용자 영향과 재처리 위험에 맞춥니다. 예를 들어 <code>ready_to_apply</code> 상태의 oldest job age가 30분을 넘으면 운영 지연이고, <code>applying</code> 상태에서 10분 이상 progress가 변하지 않으면 worker stuck 가능성이 있습니다. row rejection rate가 평소 2%에서 25%로 튀면 배포로 validator가 바뀌었거나 고객 파일 생성기가 깨졌을 수 있습니다. 온라인 API p95가 기준선 대비 20% 이상 악화되면 import apply를 자동 pause하고, 이미 시작한 job은 batch 경계에서 멈춥니다.</p>
<p>여기서 중요한 점은 import 지표가 제품 지표와 연결되어야 한다는 것입니다. &ldquo;10만 row 중 9만 row 성공&quot;은 좋아 보이지만, 실패한 1만 row가 특정 고객의 전체 상품이거나 금액 필드라면 사고입니다. 그래서 error report에는 <code>tenant_id</code>, <code>import_type</code>, <code>business_key</code>, <code>effective_date</code>, <code>risk_class</code>를 함께 남기고, 알람도 위험 등급별로 다르게 둡니다.</p>
<h2 id="설계-리뷰-질문">설계 리뷰 질문</h2>
<p>설계 리뷰에서는 구현 방식보다 실패 경로를 먼저 묻는 편이 좋습니다. 아래 질문에 답하지 못하면 아직 운영 가능한 import가 아닙니다.</p>
<ul>
<li>사용자가 같은 파일을 세 번 올리면 job과 row effect는 각각 몇 번 생기나요?</li>
<li>API timeout 이후 실제로는 apply가 계속 진행 중일 때 UI는 어떤 상태를 보여주나요?</li>
<li>100만 row 중 83만 row가 성공하고 17만 row가 실패하면 rollback, 재처리, 승인 중 무엇을 하나요?</li>
<li>validator 배포 버그로 정상 row가 대량 reject되면 기존 report를 어떻게 무효화하나요?</li>
<li>apply 중 DB lock wait가 급증하면 어떤 지표가 pause를 트리거하나요?</li>
<li>실패 report에 개인정보 원문이 들어갔을 때 누가, 언제, 어떤 절차로 삭제하고 회전하나요?</li>
<li>import job이 downstream 이벤트를 발행한다면 replay 시 이벤트 중복은 어디서 막나요?</li>
</ul>
<p>답은 문서로 남깁니다. 특히 rollback 방법은 &ldquo;DB 백업에서 복구&quot;처럼 큰 문장으로 쓰면 실전에서 쓸 수 없습니다. 낮은 위험 import라면 <code>row effect ledger 기준으로 inverse update 생성</code>, 높은 위험 import라면 <code>apply 전 승인 + batch별 compensation script + 샘플 검증</code>처럼 실제 실행 단위로 쪼개야 합니다. replay와 보상 트랜잭션은 <a href="/learning/deep-dive/deep-dive-batch-idempotency-reprocessing/">Batch Idempotency/Reprocessing</a>의 체크포인트 사고방식과 같이 봅니다.</p>
<h2 id="장애-대응-흐름">장애 대응 흐름</h2>
<p>장애가 났을 때의 기본 순서는 멈춤, 범위 산정, 원인 분리, 재처리 판단입니다.</p>
<ol>
<li><strong>Stop</strong>: import apply queue를 pause하고 새 apply 요청을 막습니다. upload와 validation은 도메인에 따라 유지할 수 있지만, 원인이 validator라면 validation도 멈춥니다.</li>
<li><strong>Scope</strong>: affected job id, tenant, import type, applied row count, downstream event count를 뽑습니다.</li>
<li><strong>Classify</strong>: 파싱 오류, 검증 오류, 적용 오류, 중복 적용, downstream side effect 중 어디인지 나눕니다.</li>
<li><strong>Contain</strong>: 중복 적용이면 idempotency ledger와 unique constraint를 확인하고, 누락 적용이면 checkpoint 이후 row만 분리합니다.</li>
<li><strong>Recover</strong>: failed rows retry, compensation job, manual correction, all-or-nothing rollback 중 하나를 선택합니다.</li>
<li><strong>Verify</strong>: import counter와 실제 business table count를 맞추고, 샘플 row 20개 이상을 원본 파일과 대조합니다.</li>
</ol>
<p>운영 보고에는 <code>몇 건 실패</code>보다 <code>어떤 업무 효과가 잘못됐는가</code>를 먼저 씁니다. 예를 들어 &ldquo;상품 import 3개 job에서 12,430 row가 reject됨&quot;보다 &ldquo;store-17의 신규 가격 8,912건이 적용되지 않았고 기존 가격은 유지됨&quot;이 더 중요합니다. 이 차이가 있어야 고객 안내, 재처리, 보상 여부를 빠르게 정할 수 있습니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> import API가 <code>202 Accepted</code>와 <code>job_id</code>를 반환한다.</li>
<li><input disabled="" type="checkbox"> 업로드 완료, 검증 완료, 적용 완료 상태가 분리되어 있다.</li>
<li><input disabled="" type="checkbox"> file fingerprint로 같은 파일 재업로드를 감지한다.</li>
<li><input disabled="" type="checkbox"> row-level error code와 error report를 제공한다.</li>
<li><input disabled="" type="checkbox"> 부분 성공 허용률과 자동 중단 기준이 숫자로 정의되어 있다.</li>
<li><input disabled="" type="checkbox"> apply 단계가 batch size, throttle, tenant concurrency를 가진다.</li>
<li><input disabled="" type="checkbox"> replay 전에 idempotency ledger 또는 effect key를 확인한다.</li>
<li><input disabled="" type="checkbox"> 고위험 import는 dry-run report와 사람 승인을 요구한다.</li>
</ul>
<p>연습 과제는 하나면 충분합니다. 현재 서비스에서 &ldquo;엑셀 업로드&quot;로 처리하는 기능 하나를 고르고, <code>job status</code>, <code>row error code</code>, <code>partial success policy</code>, <code>idempotency key</code>, <code>apply throttle</code> 다섯 항목을 한 페이지로 적어 보세요. 숫자는 반드시 넣습니다. 예를 들어 <code>row rejection rate 5% 초과 시 승인 필요</code>, <code>batch size 1,000</code>, <code>tenant당 apply 동시 실행 1개</code>, <code>error report 30일 보관</code>처럼 시작하면 설계가 훨씬 현실적으로 바뀝니다.</p>
<p>정리하면 Bulk Import Job의 핵심은 파일을 빨리 읽는 것이 아닙니다. 사용자가 같은 파일을 다시 올리고, 일부 row가 실패하고, 운영자가 재실행 버튼을 누르고, 시스템이 바쁜 시간에 apply가 밀리는 상황을 전부 정상 경로로 받아들이는 것입니다. 이 전제를 코드와 API 계약에 넣으면 대량 업로드는 위험한 관리자 기능이 아니라 통제 가능한 데이터 파이프라인이 됩니다.</p>
]]></content:encoded></item></channel></rss>