373 lines
35 KiB
Markdown
373 lines
35 KiB
Markdown
---
|
||
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`
|