<?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>Content Addressing on jyukki's Blog</title><link>https://jyukki.com/tags/content-addressing/</link><description>Recent content in Content Addressing on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Sat, 03 Oct 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/content-addressing/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Content-Addressed Object Integrity, 업로드한 바이트와 공개한 파일을 같게 만드는 법</title><link>https://jyukki.com/learning/deep-dive/2026-10-03-content-addressed-object-integrity-playbook/</link><pubDate>Sat, 03 Oct 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/2026-10-03-content-addressed-object-integrity-playbook/</guid><description>오브젝트 스토리지 업로드에서 이름·ETag·Content-Type만 믿지 않고, 바이트 해시, 임시 객체, 검증 상태, 불변 공개 키를 연결해 전송 오류와 재시도·중복 업로드를 다루는 방법을 정리합니다.</description><content:encoded><![CDATA[<p>오브젝트 스토리지에 <code>invoice.pdf</code>를 올리고 URL을 DB에 넣는 일은 간단해 보입니다. 그러나 모바일 재시도, 프록시, 멀티파트 조립, 중복 완료 이벤트, 백그라운드 변환 워커가 섞이면 &ldquo;업로드가 성공했다&quot;는 상태는 지나치게 약합니다. 같은 파일명 아래에 다른 바이트가 덮일 수 있고, 멀티파트 ETag를 MD5라고 착각해 손상 검증을 건너뛸 수도 있으며, 스캔 전 임시 객체가 CDN 경로에 노출될 수도 있습니다.</p>
<p>이 글의 목표는 모든 파일을 별도 스토리지 시스템으로 옮기는 것이 아닙니다. <strong>공개하는 논리 파일이 어떤 바이트인지, 누가 언제 검증했는지 재현할 수 있게 만드는 것</strong>입니다. <a href="/learning/deep-dive/deep-dive-object-storage-s3/">Object Storage와 S3</a>, <a href="/learning/deep-dive/deep-dive-resumable-multipart-upload-session-playbook/">Resumable Multipart Upload Session</a>, <a href="/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/">Object Upload Quarantine</a>, <a href="/learning/deep-dive/deep-dive-envelope-encryption-pii-field-crypto-playbook/">Envelope Encryption</a>에서 다룬 저장·업로드·보안 경계를 하나의 무결성 계약으로 연결합니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>파일명이나 ETag 대신 digest를 언제 식별자로 써야 하는지 판단할 수 있습니다.</li>
<li>direct upload, multipart, 재시도, 비동기 스캔을 거쳐도 공개 바이트를 바꾸지 않는 상태 전이를 설계할 수 있습니다.</li>
<li>deduplication의 비용 절감과 tenant 간 정보 노출 위험을 분리해서 검토할 수 있습니다.</li>
<li>checksum 오류, 검증 지연, orphan 객체를 수치로 관측하고 배포·장애 기준에 넣을 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-파일명etagcontent-type은-바이트의-신분증이-아니다">1) 파일명·ETag·Content-Type은 바이트의 신분증이 아니다</h3>
<p>사용자가 주는 파일명은 표시용 속성이고, <code>Content-Type</code>과 확장자는 정책 판단의 보조 입력일 뿐입니다. 둘 다 바이트 내용을 증명하지 않습니다. ETag도 객체 저장소와 업로드 방식에 따라 의미가 달라집니다. 단일 업로드에서는 MD5와 비슷하게 보일 수 있어도, multipart ETag는 파트 구성에서 계산된 값이거나 암호화·프록시 설정에 따라 달라질 수 있습니다. 따라서 ETag를 일반적인 SHA-256 checksum처럼 비교하는 설계는 이식성도 안전성도 낮습니다.</p>
<p>content-addressed identity는 저장된 <strong>정확한 바이트열</strong>을 강한 해시로 표현합니다. 예를 들어 <code>sha256:ab12...</code>는 &ldquo;이 이름의 파일&quot;이 아니라 &ldquo;이 바이트의 파일&quot;을 뜻합니다. 같은 digest가 두 번 나오면 전송 결과가 같다는 검증 근거가 생기고, digest가 다르면 같은 <code>invoice.pdf</code>라도 다른 객체입니다. 다만 해시는 암호화·권한을 대신하지 않습니다. 해시를 알고 있다는 사실만으로 다른 tenant의 객체 존재 여부를 추측하거나 접근할 수 없도록, 공개 권한과 조회 API는 논리 객체 ID·tenant ID 기준으로 계속 검사해야 합니다.</p>
<h3 id="2-업로드-완료와-검증-완료는-다른-상태다">2) 업로드 완료와 검증 완료는 다른 상태다</h3>
<p>클라이언트가 <code>PUT</code> 응답을 받았다고 해서 서비스가 그 객체를 읽거나 공개해도 된다는 뜻은 아닙니다. 네트워크 재시도로 두 개의 temp object가 남을 수 있고, 업로드 완료 이벤트는 적어도 한 번 이상 전달될 수 있으며, multipart complete 직후에만 서버가 최종 길이와 checksum을 안정적으로 읽을 수 있는 제공자도 있습니다. 그러므로 <code>uploaded=true</code> 하나로 상태를 합치면 재처리할수록 판단이 흐려집니다.</p>
<p>권장 상태는 아래처럼 역할을 나누는 것입니다.</p>
<table>
  <thead>
      <tr>
          <th>상태</th>
          <th>의미</th>
          <th>공개 가능 여부</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>initiated</code></td>
          <td>크기·정책이 있는 업로드 의도를 만들었다</td>
          <td>불가</td>
      </tr>
      <tr>
          <td><code>received</code></td>
          <td>임시 key에 바이트가 도착했다</td>
          <td>불가</td>
      </tr>
      <tr>
          <td><code>verified</code></td>
          <td>크기와 강한 checksum이 기대값 또는 서버 계산값과 일치한다</td>
          <td>불가</td>
      </tr>
      <tr>
          <td><code>approved</code></td>
          <td>악성코드·콘텐츠·권한 정책을 통과했다</td>
          <td>manifest를 통해 가능</td>
      </tr>
      <tr>
          <td><code>rejected</code> / <code>expired</code></td>
          <td>불일치·정책 위반·시간 초과다</td>
          <td>불가</td>
      </tr>
  </tbody>
</table>
<p><code>verified</code>와 <code>approved</code>를 분리하는 이유는 무결성과 안전성이 다르기 때문입니다. SHA-256이 맞는 PDF도 악성일 수 있고, 스캔을 통과한 파일도 중간에 다른 객체로 바뀌면 안 됩니다. 이 분리는 <a href="/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/">비동기 요청-응답 Operation Resource</a>의 장기 작업 상태 모델과도 잘 맞습니다.</p>
<h3 id="3-물리-객체와-논리-파일을-분리해야-재시도가-멱등해진다">3) 물리 객체와 논리 파일을 분리해야 재시도가 멱등해진다</h3>
<p><code>uploads/{userId}/invoice.pdf</code>처럼 mutable key를 최종 URL로 쓰면 새 업로드가 과거 영수증을 덮는지, 같은 요청 재시도가 같은 결과인지 알기 어렵습니다. 대신 <code>uploads/{uploadId}</code>에는 임시 바이트만 두고, 검증 뒤 <code>objects/sha256/ab/ab12...</code>처럼 불변 physical key를 만들거나 공급자의 immutable version ID를 저장합니다. 애플리케이션 DB의 <code>document</code>는 그 digest와 검증 상태를 참조하는 논리 레코드가 됩니다.</p>
<p>이 구조에서 <code>CompleteUpload(uploadId)</code>는 여러 번 호출돼도 같은 digest와 같은 논리 객체를 반환해야 합니다. 다른 바이트가 도착했다면 성공을 덮어쓰지 말고 <code>checksum_mismatch</code>로 닫아야 합니다. 동일 digest를 저장 비용 절감을 위해 재사용할 수는 있지만, tenant별 object reference와 암호화·보존·삭제 정책은 합치지 않는 것이 보수적입니다. 전역 dedup은 &ldquo;이 파일이 이미 존재한다&quot;는 존재 여부를 side channel로 만들 수 있고, tenant별 암호화 키를 쓰면 ciphertext digest도 달라질 수 있습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-업로드-의도부터-공개-manifest까지-네-단계로-만든다">1) 업로드 의도부터 공개 manifest까지 네 단계로 만든다</h3>
<p>첫 단계에서 서버는 <code>uploadId</code>, tenant, 허용 MIME 계열, 최소·최대 크기, 만료 시각, 선택적인 client SHA-256을 기록하고 임시 key용 presigned URL만 발급합니다. 결제 영수증처럼 최대 크기가 명확한 도메인은 예를 들어 <strong>10 MiB 이하</strong>를 먼저 거절해 불필요한 전송과 스캔 비용을 막을 수 있습니다. 사용자 생성 동영상처럼 큰 파일은 별도 정책·multipart session으로 분리합니다. 클라이언트 hash는 유용한 힌트지만, 신뢰 경계 밖 입력이므로 그것만으로 승인하면 안 됩니다.</p>
<p>둘째, complete API는 제공자 메타데이터에서 실제 크기와 strong checksum을 읽고, 필요하면 격리 워커가 스트리밍 SHA-256을 계산합니다. 작은 문서처럼 <strong>128 MiB 이하</strong>이고 비용이 허용되면 서버 재계산이 가장 이해하기 쉬운 출발점입니다. 더 큰 multipart 객체는 저장소가 제공하는 SHA-256 checksum을 complete 단계에 강제하고, 파트 목록·최종 길이·checksum 알고리즘을 함께 보관합니다. 어느 경우든 ETag 비교만으로 <code>verified</code>를 만들지 않는 것이 원칙입니다.</p>
<p>셋째, 검증된 객체를 quarantine 경로에서 스캔·콘텐츠 정책·메타데이터 추출로 보냅니다. 스캐너는 <code>uploadId</code>가 아니라 이미 고정된 digest를 입력으로 받아야 재시도 때 다른 바이트를 검사하지 않습니다. 마지막으로 승인 워커가 <code>(tenant_id, logical_object_id, digest)</code>를 포함한 manifest를 트랜잭션으로 확정합니다. 읽기 API는 이 manifest가 <code>approved</code>인 경우에만 short-lived download URL을 발급합니다. CDN origin에 temp prefix를 직접 열어 두면 이 설계가 무너집니다.</p>
<h3 id="2-검증-기준은-오류율과-시간-제한으로-고정한다">2) 검증 기준은 오류율과 시간 제한으로 고정한다</h3>
<p>초기 운영 기준으로는 다음 정도가 실용적입니다. 제품 위험도와 객체 크기 분포를 본 뒤 조정해야 하며, 모든 서비스에 그대로 적용할 값은 아닙니다.</p>
<table>
  <thead>
      <tr>
          <th>지표·조건</th>
          <th>출발 기준</th>
          <th>조치</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>checksum mismatch</td>
          <td>0건이 정상</td>
          <td>즉시 공개 중단, client·proxy·multipart 경로 분리 조사</td>
      </tr>
      <tr>
          <td><code>received → verified</code> p95</td>
          <td>문서 5분, 대용량 별도 SLA</td>
          <td>지연 시 worker queue와 storage head 실패율 확인</td>
      </tr>
      <tr>
          <td><code>verified → approved</code> p95</td>
          <td>일반 파일 15분</td>
          <td>초과 객체는 알림 후 사용자에게 pending 표시</td>
      </tr>
      <tr>
          <td>temp object TTL</td>
          <td>24시간부터 시작</td>
          <td>만료 전에 complete되지 않으면 abort·삭제 큐로 이동</td>
      </tr>
      <tr>
          <td>orphan bytes / accepted bytes</td>
          <td>1% 미만</td>
          <td>refcount·이벤트 중복·cleanup 실패를 점검</td>
      </tr>
  </tbody>
</table>
<p>hash mismatch를 &ldquo;가끔 나는 재시도 오류&quot;로 허용하지 마세요. checksum mismatch가 생겼다면 해당 logical object는 새 upload intent로 다시 시작해야 합니다. 반면 스캐너 지연은 대상 파일을 계속 비공개 상태로 보관하면서 재시도할 수 있습니다. 실패 종류마다 되돌리는 범위가 다르므로, 하나의 <code>upload_failed</code> 카운터로 합치지 않는 편이 좋습니다.</p>
<h3 id="3-삭제와-변환도-원본-digest를-보존한-채-다룬다">3) 삭제와 변환도 원본 digest를 보존한 채 다룬다</h3>
<p>이미지 썸네일, PDF 미리보기, OCR 텍스트처럼 파생 결과가 생기면 원본과 결과물의 관계를 <code>(source_digest, transform_version, output_digest)</code>로 기록합니다. 그러면 변환 라이브러리를 바꾼 뒤 어떤 결과만 재생성할지 알 수 있고, 같은 원본에서 나온 오래된 미리보기를 무작정 덮어쓰지 않아도 됩니다. 파생 객체의 공개 여부도 원본의 tenant 권한과 삭제 hold를 상속해야 합니다.</p>
<p>삭제 요청에서는 논리 reference를 먼저 끊고, 물리 객체는 보존 기간과 참조 수를 확인한 뒤 비동기로 정리합니다. <code>refcount=0</code>은 유용한 최적화 힌트이지만 이벤트 중복과 재처리로 음수가 될 수 있으므로, 정기 reconciliation에서 manifest를 기준으로 재계산할 수 있어야 합니다. 법적 보존이나 backup 삭제가 필요한 데이터는 <a href="/learning/deep-dive/deep-dive-data-retention-deletion-architecture/">데이터 보존·삭제 아키텍처</a>의 별도 절차를 따릅니다. digest를 쓴다고 보존 의무가 사라지지는 않습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>서버 재해싱은 가장 강한 검증이지만 비용과 지연을 늘린다.</strong> 객체 크기·위험도별로 provider checksum 검증과 서버 재계산을 나누되, 어떤 경로든 강한 checksum 증거를 남겨야 한다.</li>
<li><strong>전역 dedup은 저장비를 줄이지만 존재 여부와 삭제 연동을 복잡하게 만든다.</strong> 민감 tenant·고객별 키·보존 정책이 다르면 tenant 내부 dedup부터 시작하는 편이 안전하다.</li>
<li><strong>불변 key는 정정 기능을 없애는 것이 아니다.</strong> 논리 manifest를 새 digest로 바꾸고 이전 version을 보존·폐기하는 방식으로 수정 이력을 다룬다.</li>
<li><strong>checksum은 악성 파일을 찾아 주지 않는다.</strong> 무결성 검증 후에도 quarantine, malware scan, content policy, 다운로드 권한 검사가 필요하다.</li>
<li><strong>해시 알고리즘 변경도 데이터 마이그레이션이다.</strong> 새 알고리즘을 추가할 때는 알고리즘 식별자와 digest를 함께 저장하고, 과거 객체를 한 번에 재해싱하지 않는다.</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="운영-체크리스트">운영 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> upload intent에 tenant, 크기 제한, 만료 시각, 허용 정책, idempotency key가 있다.</li>
<li><input disabled="" type="checkbox"> multipart ETag를 보편적 MD5 또는 SHA-256 무결성 증명으로 취급하지 않는다.</li>
<li><input disabled="" type="checkbox"> complete 전에 실제 길이와 strong checksum을 확인하고 검증 근거를 저장한다.</li>
<li><input disabled="" type="checkbox"> temp object, verified object, 공개 manifest, CDN origin이 분리돼 있다.</li>
<li><input disabled="" type="checkbox"> digest가 같아도 tenant 권한·암호화 키·보존 정책을 자동 공유하지 않는다.</li>
<li><input disabled="" type="checkbox"> checksum mismatch, 검증·스캔 지연, orphan 비율, refcount 이상을 관측한다.</li>
<li><input disabled="" type="checkbox"> logical delete와 physical cleanup을 분리하고 reconciliation 경로가 있다.</li>
</ul>
<h3 id="연습">연습</h3>
<p>현재 서비스의 파일 한 종류를 고르고 <code>uploadId</code>, 실제 크기, checksum 알고리즘·값, quarantine key, final digest, 논리 객체 ID, 승인 시각을 어느 테이블 또는 이벤트에 남길지 그려 보세요. 이어서 complete API를 두 번 호출하고, 첫 호출 뒤 다른 바이트가 같은 임시 key에 도착했다고 가정합니다. 어느 상태 전이가 거절돼야 하는지와 사용자에게 어떤 재시도 안내를 보여 줄지를 정리하면, 저장 API가 아니라 무결성 계약을 설계하고 있는지 확인할 수 있습니다.</p>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-object-storage-s3/">Object Storage와 S3</a></li>
<li><a href="/learning/deep-dive/deep-dive-resumable-multipart-upload-session-playbook/">Resumable Multipart Upload Session</a></li>
<li><a href="/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/">Object Upload Quarantine과 비동기 스캔</a></li>
<li><a href="/learning/deep-dive/deep-dive-envelope-encryption-pii-field-crypto-playbook/">Envelope Encryption과 PII Field Crypto</a></li>
<li><a href="/learning/deep-dive/deep-dive-data-retention-deletion-architecture/">데이터 보존·삭제 아키텍처</a></li>
</ul>
]]></content:encoded></item></channel></rss>