Files
llm-wiki/raw/branch-notes/feature-frontend-large-object-transfer-contract.md

373 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-frontend-large-object-transfer-contract
source_type: branch-note
status: raw
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-030
kind: project-work-item
project: ca-skeleton-frontend-operational-contract
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-030
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-028]
imports: [FE-GATE-029@1, FE-OC-006@1, FE-OC-019@1, FE-OC-027@1]
delegates: [DELEG-FE-008@1]
accepts_delegations: []
contract_packet: 1
branch: feature-frontend-large-object-transfer-contract
parent_branch:
related_projects: [ca-skeleton-frontend, ca-skeleton]
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
tags: [branch, ca-skeleton-frontend, transfer, upload, streaming]
created: 2026-07-28
target_merge:
status_label: in-progress
---
# branch: feature-frontend-large-object-transfer-contract
<!-- section-id: branch-parent -->
## 부모 (필수)
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
형제 branch (같은 부모, 이번 확장에서 함께 생성):
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]]
- [[raw/branch-notes/feature-frontend-cache-tier-cross-tab-invalidation-contract]]
- [[raw/branch-notes/feature-frontend-multi-protocol-api-transport-contract]]
- [[raw/branch-notes/feature-frontend-realtime-subscription-lifecycle-contract]]
- [[raw/branch-notes/feature-frontend-background-execution-worker-contract]]
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `2`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: presign 만료·part 재시도·무결성·credential 경계 fixture가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1` | 신규 runtime capability 6종은 FE-REG-CAPABILITY flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `CAP_FE_LARGE_TRANSFER` 를 이 branch 가 소유하고 default OFF 로 유지한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1` | presigned URL 획득은 shared client를 경유하고 실제 byte 전송은 session credential을 첨부하지 않는 transfer adapter가 수행한다 | `UploadTransferPort` adapter 가 credential-less transport 를 사용하도록 강제한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001@1` | 재개 가능 전송은 part size·병렬도·part 재시도 상한을 registry로 고정하고 part 상태를 BlobStorePort에 보존한다 | `TRANSFER_PART_SIZE_BYTES`·`TRANSFER_MAX_PARALLEL_PARTS` 를 소비하고 `UPLOAD_PART_STATE` 에 상태를 쓴다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
| D1 | transfer adapter 는 shared client 의 auth interceptor 체인을 재사용하지 않고 별도 transport 를 갖는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1` | **`UNSUPPORTED_DECISION`** — 조사 후에도 구현 형태를 지지하는 외부 근거 없음. trade-off: 대안인 "interceptor 에 skip 플래그" 는 *기본이 첨부, 예외가 미첨부* 라 플래그를 빠뜨린 새 경로가 곧바로 유출이 된다. 별도 transport 는 *기본이 미첨부* 라 실수의 방향이 안전한 쪽이다. 비용은 timeout·retry 정책이 두 벌로 갈라지는 것 | `proposed` |
| D2 | presigned URL 은 로그·telemetry·에러 객체에 남기지 않는다. `Referer` 경로는 브라우저 기본 정책이 이미 막으므로 **"presigned URL 을 페이지 URL 에 넣지 않는다"** 는 금지 규칙 하나로 대체한다 | `local` | `raw/official-docs/mdn-referrer-policy.md#C1`, `#C2` (기본값이 cross-origin 에 path·query 미전송) | `proposed` |
| D3 | `MediaUrlPolicy` 는 port 가 아니라 `application/policies/` 의 순수 함수다 | `local` | **`UNSUPPORTED_DECISION`** — 조사 후에도 근거 없음. trade-off: URL 파생에 I-O 가 없어 port 로 만들면 test double 만 늘고 composition root 가 커진다. 비용은 서명된 URL 을 요구하는 CDN 을 만나는 순간 순수 함수 가정이 깨져 port 승격 리팩터가 필요해지는 것 | `proposed` |
| D4 | 다운로드 재개는 `Accept-Ranges` 로 지원을 판별하고 `Range` + `If-Range` 로 수행하며, 응답이 `206` 이 아니면 **재개가 아니라 전체 재전송**으로 취급한다. `416` 은 terminal | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C1`, `#C2`, `#C3`, `#C4`, `#C5`, `#C6` | `proposed` |
| D6 | part 재시도는 원본 `Blob`**다시 slice** 해서 새 body 를 만든다. 첫 시도의 body 를 보관했다 재사용하지 않는다 | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C10` (읽힌 스트림은 disturbed 되어 재사용 불가) | `proposed` |
| D7 | 취소는 `AbortController` 로만 하고 `AbortError``REQUEST_ABORTED` 로 매핑한다. 응답 수신 후 body 읽기 중 취소도 같은 kind 다 | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C7`, `#C8` | `proposed` |
| D8 | `TRANSFER_PART_SIZE_BYTES` 는 자유값이 아니라 **부팅 시 하한 검증 대상**이다. 하한은 선택 vendor 가 정하며(`FE-Q-012`), 미확정 동안은 5 MiB 를 잠정 하한으로 강제한다. part size × part 수 상한이 최대 전송 크기이므로 그 값을 계약값으로 노출한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001@1` | `raw/official-docs/aws-s3-multipart-upload-limits.md#C1`(5 MiB~5 GiB), `#C2`(마지막 part 예외), `#C3`(10,000 part 상한) | `proposed` |
| D10 | `UPLOAD_PART_STATE` 유실은 **오류가 아니라 정상 경로**다. 유실을 감지하면 재개 불가로 표시하고 처음부터 전송한다. 이 branch 는 `persist()` 를 요청하지 않는다 | `local` | `raw/official-docs/mdn-storage-quotas-eviction-persistence.md#C1`(origin 전량 eviction), `#C4`·`#C5`(persist 는 브라우저 재량), `#C7`(Safari ITP 7일) | `proposed` |
**deferred (이번 회차 조사 범위 밖)**: D11 — **스트리밍 업로드**(요청 body 를 `ReadableStream` 으로 전달). `mdn-http-range-fetch-transfer.md` §적용 경계 대로 이 페이지는 `duplex` 옵션과 HTTP/2 요구를 다루지 않는다. 현 계약은 part 단위 `Blob` 전송을 전제하며, 스트리밍 업로드가 필요해지면 별도 조사가 선행되어야 한다.
<!-- section-id: declared-overrides -->
### 선언한 예외
해당 없음.
<!-- GENERATED: artifact-imports:start -->
### 가져온 artifact 계약
| Artifact Ref | Owner | Producer | Schema Ref |
|---|---|---|---|
<!-- GENERATED: artifact-imports:end -->
<!-- GENERATED: project-contract-imports:start -->
## 가져온 프로젝트 계약
| Ref | Owner | 요약 | Branch 적용 |
|---|---|---|---|
| `FE-GATE-029@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | presign 만료·part 재시도·무결성·credential 경계 fixture 가 실패하면 merge·release 를 MUST 차단 | 이 branch 가 owner 로서 fixture 와 report 를 산출 |
| `FE-OC-006@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 모든 HTTP는 shared client를 MUST 통과하고 timeout·abort·response parsing을 page에서 구현하면 안 됨 | presign **획득**은 shared client 경유, byte 전송은 예외로 선언 |
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | presigned URL 유출 금지 규칙의 상위 계약 |
| `FE-OC-027@1` | [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] | 파일 선택·다운로드·로컬 바이너리 저장은 등록된 port를 MUST 경유하고, 원시 `File`/`Blob` handle과 object URL 수명은 adapter 경계를 MUST NOT 벗어남 | `DELEG-FE-008` 로 handle 수명을 위임 |
<!-- GENERATED: project-contract-imports:end -->
<!-- GENERATED: received-delegations:start -->
### 수신한 위임
없음. 이 branch 는 `DELEG-FE-008` 의 delegator 다.
<!-- GENERATED: received-delegations:end -->
<!-- GENERATED: flow:start -->
### 가져온 흐름 단계
| Stage Ref | Order | Owner | Input | Action | Output |
|---|---:|---|---|---|---|
<!-- GENERATED: flow:end -->
<!-- section-id: branch-goal -->
## 목표
`UploadTransferPort``StreamingDownloadPort` 를 정의하고, presigned URL 획득과 byte 전송 사이의 credential 경계를 고정한다. 이 계약이 없으면 presigned URL 로 나가는 요청에 session 헤더가 그대로 붙어 제3자 스토리지 도메인에 인증 정보가 전달된다. 재개 가능 전송의 part 상태 소유자도 함께 고정한다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- `UploadTransferPort` — presign 결과 소비, part 분할·병렬·재시도, 진행 스트림, 취소
- `StreamingDownloadPort``ReadableStream` 소비, range/resume, 진행 스트림
- credential-less transfer transport 와 그 경계의 negative fixture
- 무결성 검증(체크섬·크기)과 `TRANSFER_INTEGRITY_MISMATCH`
- `MediaUrlPolicy` — CDN base·허용 transform 기반 URL 파생 (순수 함수)
- §9.6 전송 진행 surface state
- `CAP_FE_LARGE_TRANSFER` capability 행 소유
### 제외 범위
- **use case, domain model, business rule** — port 와 adapter 계약까지만 정의한다
- backend 의 presign 발급 endpoint 설계와 서명 알고리즘
- object storage vendor 선택과 그 제약(part 최소 크기·만료) — `FE-Q-012`
- Image CDN vendor 선택
- `File`/`Blob` handle 과 object URL 수명 — `DELEG-FE-008` 로 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] 에 위임
- 업로드 대상의 도메인 검증 규칙
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| `[[docs/superpowers/specs/2026-07-28-ca-skeleton-frontend-runtime-adapter-features-design]]` §5.1·§5.2·§9.3 | port 분해, `MediaUrlPolicy` 가 port 가 아닌 이유, 전송 surface state |
| [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] | browser 경계에서 credential·URL 노출을 줄이는 상위 관점 |
| [[raw/official-docs/mdn-http-range-fetch-transfer]] | D4 재개 프로토콜 · D6 part 재시도의 body 재생성 · D7 취소의 error 매핑 |
| [[raw/official-docs/aws-s3-multipart-upload-limits]] | D8 `TRANSFER_PART_SIZE_BYTES` 하한 검증과 part 수 상한이 전송 크기를 결정한다는 사실 |
| [[raw/official-docs/mdn-referrer-policy]] | D2 유출 경로 3개의 우선순위 재조정 (`Referer` 는 브라우저 기본값이 이미 방어) |
| [[raw/official-docs/mdn-storage-quotas-eviction-persistence]] | D10 `UPLOAD_PART_STATE` 의 실질 수명 상한 |
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.3·§7.8 | API operation registry 와 auth integration 경계 |
**근거 등급 경계**: 2026-07-28 `/branch-spec` 조사로 **프로토콜 사실**(range 재개, body 1회 소비, abort error, referrer 기본값)과 **vendor 제약의 존재**(S3 part size 하한)는 공식 문서 근거를 확보했다. 그러나 다음은 여전히 미근거다. (1) `FE-D029` 가 정한 *transport 분리 방식 자체* — 유출 방지라는 목표는 자명하나 "interceptor 재사용 금지" 라는 구현 형태를 지지하는 외부 근거는 없다(D1). (2) part size **8 MiB** 와 병렬도 **3** 이라는 구체 값 — `aws-s3-multipart-upload-limits#C1` 은 5 MiB 하한만 말하고 최적값을 말하지 않으며, 애초에 vendor 가 미확정이다(`FE-Q-012`). (3) `MediaUrlPolicy` 를 port 로 만들지 않는다는 D3. 세 항목 모두 아래 표에 라벨로 표시했다.
## TODO
- [ ] `UploadTransferPort`·`StreamingDownloadPort` 인터페이스 확정 — 등급: `planned`
- [ ] credential-less transport 분리와 negative fixture — 등급: `planned`
- [ ] presign 만료 감지와 재획득 1회 경로 — 등급: `planned`
- [ ] part 재시도 상한(≤2)과 상태 보존 — 등급: `planned`
- [ ] 무결성 검증과 불일치 시 재전송 1회 — 등급: `planned`
- [ ] range 기반 다운로드 재개 — 등급: `planned`
- [ ] `MediaUrlPolicy` 순수 함수와 허용 transform 강제 — 등급: `planned`
- [ ] `Accept-Ranges` 판별 후에만 `Range` 를 보내는 경로 (D4) — 등급: `planned`
- [ ] 재개 응답이 `206` 이 아니면 전체 재전송으로 전환 (D4) — 등급: `planned`
- [ ] part 재시도 시 `Blob` 재slice negative fixture (D6) — 등급: `planned`
- [ ] `TRANSFER_PART_SIZE_BYTES` 하한 + part 수 상한 부팅 검증 (D8) — 등급: `planned`
- [ ] part state 유실 시 "재개 불가" 표면화 (D10) — 등급: `planned`
- [ ] 전송 payload 에 presigned URL 문자열 0건을 증명하는 관측 등록 (관심사 7 should-fix) — 등급: `planned`
- [ ] `FE-GATE-029` transfer report 산출 — 등급: `planned`
## 진행 중 메모
`PRESIGN_SAMPLE_UPLOAD``FE-REG-API` 에 등록되지만 실제 byte 전송 대상 URL 은 registry 에 등록하지 않는다. registry 는 우리 backend 의 operation 장부이고, presigned URL 은 제3자 도메인의 일회용 주소이기 때문이다. 이 비대칭이 `FE-OC-006`("모든 HTTP 는 shared client 경유")의 유일한 선언적 예외이며, 예외라는 사실 자체를 §범위에 남긴다.
## 결정 사항
- 2026-07-28: transfer adapter 가 shared client interceptor 를 재사용하지 않음 / 이유: interceptor 체인에 auth 첨부가 있으면 제3자 URL 로 새어나감 / 검토한 대안: interceptor 에 skip 플래그 추가 / 근거: `FE-D029`
- 2026-07-28: `MediaUrlPolicy` 를 port 로 만들지 않음 / 이유: URL 파생에 I-O 가 없어 port 로 만들면 test double 만 늘어남 / 검토한 대안: `MediaUrlPort` / 근거: 설계문서 §5.2
<!-- section-id: decision-evidence -->
## 결정-근거 매핑
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | 별도 transport 로 credential 분리 | 항상. 스토리지가 same-origin proxy 만 제공하면 `FE-D029` 재검토 | 없음 — 조사 후에도 구현 형태의 근거 없음 | `UNSUPPORTED_DECISION` | 두 transport 의 timeout·retry 정책이 갈라진다. 한쪽만 고쳐지는 drift 가 생김 |
| D2 | presigned URL 을 로그·telemetry 에 남기지 않고, `Referer` 는 "페이지 URL 에 넣지 않기" 로 대체 | 항상 | `mdn-referrer-policy.md#C1`(기본값), `#C2`(cross-origin 은 origin 만) | `official-reference`(Referer 경로) + `project-local default`(로그 금지) | 디버깅이 어려워진다. 실패를 operation ID 만으로 추적할 수 있어야 함 |
| D3 | `MediaUrlPolicy` 는 순수 함수 | 항상. CDN 이 서명된 URL 을 요구하면 port 로 승격 재검토 | 없음 — 조사 후에도 근거 없음 | `UNSUPPORTED_DECISION` | 서명 필요 CDN 을 만나면 순수 함수 가정이 깨져 리팩터가 필요 |
| D4 | `Accept-Ranges` 판별 + `Range`/`If-Range`, `206` 아니면 재개 아님 | 서버가 range 를 지원할 때만 재개. 미지원이면 `resumeStrategy` 를 끄고 처음부터 | `mdn-http-range-fetch-transfer.md#C1`~`#C6` | `official-reference` | `If-Range` validator 로 `ETag` 를 쓸지 `Last-Modified` 를 쓸지 미정. vendor 가 무엇을 주는지에 달림(`FE-Q-012`) |
| D6 | part 재시도는 `Blob` 재slice | 항상. 예외 없음 — 프로토콜 제약이다 | `mdn-http-range-fetch-transfer.md#C10` | `official-reference` | 원본 `Blob` 을 재시도 시점까지 살려 둬야 하므로 handle 수명이 `DELEG-FE-008` 과 엮임 |
| D7 | `AbortController` 단일 취소 경로 | 항상 | `mdn-http-range-fetch-transfer.md#C7`, `#C8` | `official-reference` | 이미 전송된 바이트가 서버에서 정리되는지는 vendor 책임이며 프론트가 보장하지 못함 |
| D8 | part size 하한 검증 + 최대 전송 크기 노출 | 항상. vendor 확정 시 하한을 그 값으로 교체 | `aws-s3-multipart-upload-limits.md#C1`, `#C2`, `#C3` | `official-reference`(하한의 존재) + `project-local default`(8 MiB 라는 값) | 8 MiB × 10,000 ≈ 80 GB 라는 상한이 제품 요구를 넘는지 미확인. 이 계산은 우리 도출이지 AWS 서술이 아님 |
| D10 | part state 유실은 정상 경로 | 항상. `persist()` 요청은 하지 않음 | `mdn-storage-quotas-eviction-persistence.md#C1`, `#C4`, `#C5`, `#C7` | `official-reference` | 대용량 전송 중 origin eviction 이 일어나면 사용자는 진행률만 보다가 재개 불가를 통보받는다. UX 문구 미설계 |
<!-- section-id: implementation -->
## 구현 가이드
> 2026-07-28 `/branch-spec` 조사(MDN 2건 + AWS S3 1건)로 **프로토콜에 의존하는 detail** 을 채웠다. vendor 값에 의존하는 detail(`FE-Q-012`)은 여전히 미확정이므로 값이 아니라 **검증 규칙**으로만 적었다. 근거가 원칙만 지지하는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 로 표시했다(CLAUDE.md §15.5 R2).
### 1. credential 경계 — 두 transport 의 분리 형태
> **Trace**: D1(별도 transport) ← 근거 없음(project decision) / D2(URL 미기록) ← `mdn-referrer-policy.md#C1`·`#C2` / 상속 `DEC-…-TRANSFER-CREDENTIAL-001@1`
presign **획득**은 shared client 를 통과한다(`FE-OC-006`). byte **전송**은 shared client 를 쓰지 않는 별도 함수가 수행하며, 그 함수는 auth 를 붙이는 코드에 접근할 수 없어야 한다 — 정책이 아니라 **도달 불가능성**으로 강제한다. 첨부 금지를 런타임 조건문으로 구현하면 조건이 하나 빠지는 순간 유출이므로, transport 자체를 분리해 첨부 코드가 그 경로에 존재하지 않게 한다.
유출 경로는 세 개가 아니라 **두 개**다. `#C1`+`#C2` 대로 브라우저 기본 정책(`strict-origin-when-cross-origin`)이 cross-origin 요청에 path·query 를 이미 보내지 않으므로 `Referer` 는 방어된 상태다. 남은 실질 경로는 (a) 우리 로그·telemetry·에러 객체, (b) presigned URL 이 페이지 URL·`history` 에 들어가는 코드. fixture 밀도를 (a)·(b) 에 몰고, `Referer` 에 대해서는 (b) 금지 규칙 하나만 둔다.
`UNSUPPORTED_IMPL_DECISION` — 분리를 "별도 모듈 + 별도 fetch wrapper" 로 구현하는 것. 근거는 목표(유출 방지)만 지지하고 형태는 지지하지 않는다. trade-off: 대안(interceptor skip 플래그)은 실수의 방향이 유출 쪽이라 배제했다. 비용은 timeout·retry 정책이 두 벌이 되는 것이며, 이를 registry 값 공유로 완화한다.
### 2. 다운로드 재개 프로토콜
> **Trace**: D4 ← `mdn-http-range-fetch-transfer.md#C1`~`#C6` / D7(취소) ← `#C7`·`#C8`
`StreamingDownloadPort` 는 재개를 **낙관적으로 시도하지 않는다**. 순서는 고정이다.
1. 최초 응답의 `Accept-Ranges` 를 기록한다. 헤더가 없거나 `none` 이면 `resumeStrategy` 를 비활성화한다(`#C1`)
2. 재개 시 `Range` 와 함께 **`If-Range`** 를 보낸다. `#C5` 가 요구하는 "원본 미변경 보장" 을 이 헤더가 담당한다
3. 응답 상태를 검사한다. `206` 이면 `Content-Range` 로 위치를 확인하고 이어붙인다(`#C2`). **`200` 이면 재개가 아니라 전체 재전송**이므로 받아 둔 앞부분을 버리고 처음부터 쓴다(`#C4`·`#C6`). `416` 은 재개 위치가 리소스 밖이라는 뜻이므로 terminal 로 보고 `TRANSFER_INTEGRITY_MISMATCH` 로 매핑한다(`#C3`)
3번이 이 절의 핵심이다. `200` 을 성공으로 처리하면 앞부분 + 전체가 이어붙어 **길이가 늘어난 파일**이 만들어지고, 무결성 검사가 없으면 그대로 저장된다.
취소는 `AbortController` 하나로 통일한다. `#C7`(fetch reject)과 `#C8`(body 읽기 중 reject)은 **다른 시점의 같은 오류**이므로 둘 다 `REQUEST_ABORTED` 로 매핑하고 구분하지 않는다.
`UNSUPPORTED_IMPL_DECISION``If-Range` 의 validator 로 `ETag` 를 우선하고 없으면 `Last-Modified` 를 쓰는 것. `#C5`·`#C6` 는 조건부 재개의 필요성만 말하고 validator 선택을 말하지 않는다. trade-off: `ETag` 가 더 정밀하지만 vendor 가 무엇을 주는지 미확정(`FE-Q-012`)이라 둘 다 처리하는 쪽을 택했다.
### 3. part 분할·재시도
> **Trace**: D6(재slice) ← `mdn-http-range-fetch-transfer.md#C10` / D8(하한 검증) ← `aws-s3-multipart-upload-limits.md#C1`~`#C3` / 상속 `DEC-…-RESUMABLE-TRANSFER-001@1`
**재시도는 body 를 재생성한다.** `#C10` 대로 한 번 읽힌 스트림은 disturbed 상태가 되어 누구도 다시 읽지 못하므로, 재시도 시 원본 `Blob` 을 같은 오프셋으로 다시 `slice()` 한다. 첫 시도의 body 객체를 보관했다 재사용하는 구현은 두 번째 시도에서 **빈 본문**을 보내고, 서버는 그것을 성공으로 받는다.
part size 는 값이 아니라 **검증 규칙**으로 고정한다.
| 규칙 | 근거 |
|---|---|
| `TRANSFER_PART_SIZE_BYTES ≥ 하한` 을 부팅 시 검사하고 위반이면 boot fail | `#C1` — 하한 미만은 마지막 part 를 제외한 전 구간에서 거부된다 |
| 마지막 part 만 하한 예외 | `#C2` — "no minimum size limit on the last part" |
| `ceil(총크기 / partSize) ≤ part 수 상한` 을 전송 시작 전 검사 | `#C3` — 상한 초과 분할은 전송 자체가 불가능 |
| 최대 전송 크기 = `partSize × part 수 상한` 을 계약값으로 노출 | 위 두 값의 **우리 도출**이며 AWS 서술이 아님 |
vendor 미확정 동안 하한은 5 MiB, part 수 상한은 10,000 을 잠정값으로 쓴다. 이 두 값의 출처가 S3 라는 사실을 registry 주석에 남겨, vendor 확정 시 무엇을 바꿔야 하는지가 코드에서 보이게 한다.
`UNSUPPORTED_IMPL_DECISION` — part size **8 MiB** 와 병렬도 **3** 이라는 값. `#C1` 은 5 MiB~5 GiB 라는 허용 범위만 말하고 최적값을 말하지 않는다. trade-off: 하한(5 MiB)에 붙이면 part 수가 늘어 상한(10,000)에 빨리 닿고, 크게 잡으면 재시도 1회의 손실이 커진다. 8 MiB 는 그 사이의 임의 지점이며 측정으로 대체되어야 한다.
### 4. part 상태의 수명
> **Trace**: D10 ← `mdn-storage-quotas-eviction-persistence.md#C1`·`#C4`·`#C5`·`#C7`
`UPLOAD_PART_STATE`**사라질 수 있는 값**으로 다룬다. `#C1` 대로 브라우저 eviction 은 origin 전량 삭제라 이 행만 보호할 방법이 없고, `#C4`·`#C5` 대로 `persist()` 는 허가가 브라우저 재량이다. 따라서 adapter 는 재개 전 part 상태의 존재와 정합성을 검사하고, 없으면 **오류가 아니라 "재개 불가"** 로 표면화한 뒤 처음부터 전송한다.
Safari ITP 의 7일 규칙(`#C7`)은 이 계약에서 **구속 조건이 아니다**`FE-REG-STORAGE``UPLOAD_PART_STATE` TTL 이 24시간이라 7일보다 먼저 만료된다. 구속하는 것은 시간이 아니라 저장 압박이며, 그것은 예고 없이 온다.
`persist()` 요청은 이 branch 가 하지 않는다 — 소유자는 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` 이다. 두 branch 가 각자 요청하면 프롬프트가 중복된다.
### 5. 이 branch 가 남기지 않는 것 (R3)
- presign 발급 endpoint 설계·서명 알고리즘·만료 시간 정책 → backend 소유, §범위 제외
- `File`/`Blob` handle 과 object URL 수명 → `DELEG-FE-008` 로 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] 에 위임
- storage vendor 선택 → `FE-Q-012`. 이 절은 vendor 값을 쓰지 않고 **검증 규칙**만 고정했다
- CSP·`Referrer-Policy` 헤더의 실제 설정 → [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 소유(`FE-OC-019`)
<!-- section-id: edge-failure-dependency -->
## 엣지·실패·의존
- **실패·엣지 경로**
- presigned URL 만료 → `PRESIGN_EXPIRED`, 재획득 1회 후 같은 위치에서 재개. 재획득도 실패하면 사용자 주도 retry
- part 재시도 상한 소진 → `UPLOAD_PART_FAILED`, part 상태 보존 후 `transfer-paused`
- 체크섬/크기 불일치 → `TRANSFER_INTEGRITY_MISMATCH`, 해당 part 폐기 후 재전송 1회, 재실패면 terminal
- 다운로드 스트림 중단 → `STREAM_INTERRUPTED`, `resumeStrategy: range` 면 받은 범위부터 재개
- 사용자 취소 → `REQUEST_ABORTED` 재사용, part 상태 폐기 여부를 명시
- `UPLOAD_PART_STATE` 저장 실패 → `BLOB_STORE_UNAVAILABLE`. 이 행은 fallback 이 없으므로 전송을 재개 불가로 표시하고 조용히 memory 로 넘어가지 않음
- **재개 요청에 `200` 응답** → 재개가 아니라 전체 재전송이다(`mdn-http-range-fetch-transfer#C4`·`#C6`). 받아 둔 앞부분을 버리고 처음부터 쓴다. 성공으로 처리하면 길이가 늘어난 파일이 만들어진다 (D4)
- **재개 위치가 리소스 밖** → `416`(`#C3`). 재시도로 회복되지 않으므로 terminal 로 보고 `TRANSFER_INTEGRITY_MISMATCH` 로 매핑한다
- **`Accept-Ranges` 부재·`none`** → 재개 자체가 불가(`#C1`). `resumeStrategy` 를 끄고 중단 시 처음부터 다시 받는다
- **재시도 시 body 재사용** → disturbed 스트림이라 빈 본문이 전송된다(`#C10`). 원본 `Blob` 을 재slice 한다 (D6)
- **part state 유실** → 오류가 아니라 재개 불가로 표면화하고 처음부터 전송 (D10)
- **part size 가 vendor 하한 미만** → 마지막 part 를 제외한 전 구간이 거부된다(`aws-s3-multipart-upload-limits#C1`). 부팅 시 검사해 boot fail (D8)
- **다른 계약 의존**
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D1`·`D7` — object URL·handle 수명(`DELEG-FE-008`)과 `persist()` 요청 소유권. D6 이 재시도까지 원본 `Blob` 을 살려 둬야 하므로 handle 수명 규칙이 이 branch 의 재시도 가능 범위를 정한다
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] — `UPLOAD_PART_STATE``quotaFallback: 없음`·`evictionOrder: null`·TTL 24h. D10 이 이 세 값에 직접 의존한다
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] — shared client 의 timeout·retry·envelope. presign **획득** 경로가 이를 통과하며, D1 이 전송 경로를 여기서 떼어낸다
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] — `Referrer-Policy`·CSP 헤더의 실제 설정(`FE-OC-019`). D2 는 이 branch 가 기본값을 바꾸지 않는다고 전제한다 — `unsafe-url` 로 완화되면 D2 의 전제가 깨진다(`mdn-referrer-policy#C4`)
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] — `PRESIGN_EXPIRED`·`UPLOAD_PART_FAILED`·`TRANSFER_INTEGRITY_MISMATCH`·`STREAM_INTERRUPTED` 의 kind 등록과 retryable 기본값
<!-- section-id: claims-to-verify -->
## 검증해야 할 주장
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| transfer 요청에 session credential 이 붙지 않는다 | interceptor 가 전역이면 조용히 새어나감 | negative fixture — 전송 요청을 가로채 `Authorization`·쿠키 헤더 부재 확인 | `planned` |
| presigned URL 이 telemetry·로그에 남지 않는다 | 에러 객체에 URL 이 딸려오기 쉬움 | grep + fixture — 전송 실패 시 emit 된 event payload 에 URL 문자열 0건 | `planned` |
| part 재시도가 2회를 넘지 않는다 | 상위 재시도와 part 재시도가 곱해질 수 있음 | 결정론 fake clock test — 총 시도 횟수 카운트 | `planned` |
| 만료 후 재획득으로 같은 위치에서 재개된다 | 재개 위치 계산이 vendor 별로 다름 | integration test — 만료 강제 후 이어받기 지점 확인 | `needs-confirmation` |
| part size 8 MiB 가 실제 환경에서 합리적이다 | 측정 없이 정한 초기 default | 벤치마크 — 네트워크 프로파일별 처리량 비교, `FE-D030` revisit trigger 에 연결 | `needs-confirmation` |
| `UPLOAD_PART_STATE` 가 quota 초과 시 memory 로 넘어가지 않는다 | fallback 이 기본 동작으로 새기 쉬움 | negative fixture — quota 초과 강제 후 재개 불가로 표시되는지 | `planned` |
| 재개 요청에 `200` 이 오면 처음부터 다시 쓴다 | 성공 상태 코드라 통과시키기 쉽고, 통과하면 길이가 늘어난 파일이 만들어짐 | negative fixture — `Range` 를 무시하고 `200` + 전체 본문을 주는 서버 stub 에 대해 최종 파일 크기가 원본과 같은지 | `planned` |
| `Accept-Ranges` 부재 시 재개를 시도하지 않는다 | 판별을 건너뛰고 낙관적으로 `Range` 를 보내기 쉬움 | fixture — 헤더 없는 응답 후 중단·재개 시 `Range` 헤더가 나가지 않는지 | `planned` |
| part 재시도가 빈 본문을 보내지 않는다 | `#C10` 의 disturbed 스트림 문제는 첫 시도 성공 시 드러나지 않음 | fixture — 첫 시도 실패 강제 후 두 번째 요청의 `Content-Length` 가 part size 와 같은지 | `planned` |
| part size 하한 위반이 부팅을 막는다 | 검증을 넣지 않으면 런타임에 vendor 거부로만 드러남 | negative fixture — 하한 미만 값으로 boot 시 실패하는지 | `planned` |
| 최대 전송 크기(`partSize × part 수 상한`)가 제품 요구를 넘는다 | 8 MiB × 10,000 ≈ 80 GB 는 **우리 도출**이며 요구를 확인하지 않았음 | 제품 요구 확인 후 registry 값 재계산 | `needs-confirmation` |
| `If-Range` validator 로 무엇을 써야 하는지 | vendor 가 `ETag` 를 주는지 `Last-Modified` 를 주는지 미확정 (`FE-Q-012`) | vendor 확정 후 응답 헤더 실측 | `needs-confirmation` |
| 대용량 전송 중 origin eviction 이 실제로 얼마나 자주 일어나는지 | `#C1` 은 가능성만 말하고 빈도를 말하지 않음. UX 문구 설계가 이 빈도에 달림 | telemetry — `UPLOAD_PART_STATE` 유실로 재개 불가가 된 전송 비율 | `needs-confirmation` |
## Audit & Findings
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약과 조사 결과의 불일치**. hub 소유 항목은 정합 권고만 남긴다(hub §3.3).
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|---|---|---|---|---|---|
| `PART_SIZE_UNVALIDATED` | hub §5.4 `TRANSFER_PART_SIZE_BYTES` / `FE-D030` | part size·병렬도를 registry 로 "고정" 한다고만 적고 **허용 범위**를 말하지 않는다 | `aws-s3-multipart-upload-limits#C1`: part size 는 5 MiB~5 GiB 라는 **vendor 하드 제약**을 받고, `#C3`: part 수 상한 10,000 이 최대 전송 크기를 결정한다. 하한 미만 값은 마지막 part 를 제외한 전 구간에서 거부된다 | env key 정의에 하한 검증과 part 수 상한 검사를 추가할 것. 값 자체는 vendor 확정(`FE-Q-012`) 전까지 잠정 | `open``FE-Q-012` 선행이나, **검증 규칙**은 vendor 무관하므로 먼저 넣을 수 있음 |
| `REFERRER_RISK_MISWEIGHTED` | 이 branch 의 `D2` 초안 | "telemetry·로그·`Referrer` 어디에도" — 세 경로를 같은 무게로 나열했다 | `mdn-referrer-policy#C1`·`#C2`: 기본 정책 `strict-origin-when-cross-origin` 이 cross-origin 요청에 path·query 를 이미 보내지 않는다. 게다가 `Referer` 는 *요청을 유발한 문서*의 URL 이지 요청 대상 URL 이 아니다 | `Referer` 항목을 "presigned URL 을 페이지 URL 에 넣지 않는다" 는 금지 규칙으로 좁히고, fixture 밀도를 로그·telemetry 로 옮길 것 | `resolved` 2026-07-28 — D2 서술과 §구현 가이드 1 에 반영 |
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 ca-tmpl registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java 백엔드 전용. frontend 구현 repo 미식별 | 전 항목 `planned` 유지. 승격은 `FE-Q-001` 이후 | `open` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> 2026-07-28 `/branch-spec` §8b. **agent dispatch 없이 수기 판정**했다(세션 제약). governing doc = [[raw/project-notes/ca-skeleton-frontend-operational-contract]]. 기준은 hub §2.2 의 10개 universal acceptance question.
| # | 관심사 | 판정 | 근거 |
|---:|---|---|---|
| 1 | 문제와 실패 모드가 구체적인가 | covered-here | §목표 — 제3자 도메인으로의 credential 전달. §엣지 12개 경로 |
| 2 | 상속한 결정을 실제로 적용했는가 | covered-here | `TRANSFER-CREDENTIAL-001` → D1·D2, `RESUMABLE-TRANSFER-001` → D8·D10, `CAPABILITY-001``CAP_FE_LARGE_TRANSFER` 소유 |
| 3 | project-wide default 와 limit | covered-here | D4(206 아니면 재개 아님), D6(재slice), D8(하한 검증 + 최대 전송 크기), D10(유실은 정상 경로) |
| 4 | 대안을 검토했는가 | covered-here | §결정 사항 — interceptor skip 플래그 / `MediaUrlPort`. D1·D3 는 trade-off 를 명시 |
| 5 | 금지 구현 | covered-here | §구현 가이드 — 전송 경로에 auth 첨부 코드 도달 금지, body 재사용 금지, URL 을 로그·페이지 URL 에 넣기 금지 |
| 6 | 실패 경로가 error kind 로 매핑되는가 | covered-here | `PRESIGN_EXPIRED`·`UPLOAD_PART_FAILED`·`TRANSFER_INTEGRITY_MISMATCH`·`STREAM_INTERRUPTED`·`REQUEST_ABORTED`·`BLOB_STORE_UNAVAILABLE` |
| 7 | 관측 가능한가 | should-fix | 진행률·재개·part 재시도 telemetry 는 hub §5.8 에 있으나, **URL 미유출을 증명하는** 관측(payload grep gate)이 fixture 로만 있고 registry event 로 등록되지 않았다 |
| 8 | 위임 경계가 명확한가 | covered-here | `DELEG-FE-008`, §구현 가이드 5 의 R3 목록 |
| 9 | 검증 수단이 있는가 | covered-here | §검증해야 할 주장 14행, `FE-GATE-029` fixture + transfer report |
| 10 | 말할 수 있는 범위 | covered-here | 전 항목 `planned``NO_GROUND_TRUTH` |
**판정: Covered (missing 0)** · Should-fix 1건(관심사 7). Blocking 아님.
## 마주친 문제
없음.
## 묶음 (이 branch에서 파생된 자료)
### Sub-branches (세부 작업)
아직 없음.
### 오류 기록 (이 branch 작업 중 발생)
아직 없음.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
아직 없음.
### 강의 (이 작업을 위해 학습한 강의)
아직 없음.
### job-posting tie-ins (이 작업에서 파생된 글감)
아직 없음.
## 관련 일일 노트
- 아직 없음
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: 없음
- `locally-verified` 항목: 없음
- `prod-verified` 항목: 없음
- **추출하지 않을 항목**: 현재 전 항목 `planned`