[PonsWarp 개념 교실 10] ZIP64 스트리밍 — LFH·CEN·EOCD64 구조
쉽게 말하면, **ZIP64 스트리밍은 "여러 파일을 한 다운로드로 묶되, 전체를 메모리에 올리지 않고 조각이 도착할 때마다 ZIP 파일을 조금씩 써 내려가는 것"**이다.

멀티파일 전송에서 브라우저는 여러 파일을 하나의 ZIP으로 묶어 다운로드한다. 그런데 수 GB짜리 파일들을 전부 메모리에 모은 뒤 ZIP을 만들면 브라우저가 죽는다. PonsWarp는 수신측에서 스트리밍으로 ZIP을 쓴다.
이게 왜 필요한가 — "전부 모아서 압축"은 불가능하다
파일 전송에서 ZIP을 만드는 가장 단순한 방법은 이렇다.
모든 파일을 메모리에 모음 → ZIP 압축 → 다운로드
이 방식은 두 가지 문제가 있다.
- 메모리 폭발 — 수 GB 파일 여러 개를 모으면 브라우저 탭이 죽는다.
- 지연 — 전송이 끝나야 ZIP을 만들 수 있으므로, 받으면서 동시에 저장할 수 없다.
그래서 PonsWarp는 파일이 도착하는 조각마다 ZIP 바이트를 만들어 디스크에 쓴다. 이게 가능한 이유는 ZIP 형식이 "순차적으로 쓰고, 마지막에 인덱스만 붙이는" 구조이기 때문이다.
ZIP의 구조 — 순차 쓰기 + 뒤늦은 인덱스
ZIP 파일은 크게 세 구역으로 나뉜다.
┌─────────────────────────────┐
│ Local File Header (LFH) │ ← 각 파일의 시작
│ 파일 1 데이터 │
│ Data Descriptor (선택) │
├─────────────────────────────┤
│ Local File Header │
│ 파일 2 데이터 │
│ ... │
├─────────────────────────────┤
│ Central Directory (CEN) │ ← 모든 파일의 인덱스 (마지막에)
│ EOCD64 + Locator + EOCD │ ← 끝 표시
└─────────────────────────────┘
핵심은 Central Directory가 파일 맨 끝에 온다는 것이다. 데이터는 앞에서부터 순차적으로 써 내려가고, 모든 데이터가 끝난 뒤에야 "어떤 파일이 어디에 있는지"를 정리한 인덱스를 끝에 붙이면 된다.
이 덕분에 **"받으면서 앞부분을 쓴다"**가 가능하다. 각 파일의 실제 데이터가 도착하는 대로 LFH + 데이터를 쓰고, 마지막에 CEN과 EOCD64를 붙이면 완성이다.
PonsWarp의 WASM 코어는 이 순서를 그대로 구현한다.
rust// pons-core-wasm/src/zip64/stream.rs begin_file(path, size) // LFH 생성 → 스트림에 기록 process_chunk(data) // 데이터 청크 압축/STORE → 기록 end_file() // Data Descriptor + 엔트리 저장 finalize() // CEN + EOCD64 + Locator + EOCD
시그니처 — ZIP을 알아보는 마법의 숫자
ZIP의 각 구조는 4바이트 시그니처로 시작한다. PonsWarp의 constants.rs가 그 값들을 정의한다.
| 구조 | 시그니처 | 역할 |
|---|---|---|
| Local File Header | 0x04034b50 | 파일 하나의 시작 |
| Data Descriptor | 0x08074b50 | 스트리밍 시 크기를 뒤늦게 기록 |
| Central Directory | 0x02014b50 | 파일 인덱스 |
| EOCD | 0x06054b50 | ZIP 끝 표시 (구식) |
| EOCD64 | 0x06064b50 | ZIP64 끝 표시 |
| EOCD64 Locator | 0x07064b50 | EOCD64 위치 안내 |
ZIP64가 필요한 이유 — 4GB 벽
일반 ZIP은 파일 크기를 32비트(최대 4GB)로만 표현한다. 4GB가 넘는 파일이나 4GB가 넘는 압축 데이터는 표현할 수 없다. ZIP64는 64비트로 크기를 표현하는 확장이다.
- ZIP64 extra field(ID
0x0001)에 실제 크기를 8바이트로 기록 - 크기 필드가
0xFFFFFFFF(마커)면 "실제 값은 extra field에 있다"고 표시 - ZIP64는 버전 4.5 이상이 필요 (
VERSION_NEEDED_ZIP64 = 45)
PonsWarp가 ZIP64를 고른 이유는 이 프로젝트의 목표가 대용량 파일 전송이기 때문이다. 4GB 벽을 처음부터 제거해 두지 않으면 나중에 파일 하나가 4GB를 넘는 순간 다시 설계해야 한다.
STORE vs DEFLATE — 압축의 선택
Zip64Stream은 생성자에서 압축 여부를 고른다.
rust// pons-core-wasm/src/zip64/stream.rs /// compression_level: 0 = STORE (압축 없음), 1-9 = DEFLATE 압축 pub fn new(compression_level: u8) -> Self { let use_compression = compression_level > 0; ... }
- STORE(0): 압축 없이 그대로 저장. 속도가 빠르고 CPU 부담이 없다. 전송 경로에서 실제로 사용하는 모드다.
- DEFLATE(1-9): 압축 수행. 파일이 작아지지만 CPU가 든다.
PonsWarp의 전송 경로가 STORE를 쓰는 이유는 간단하다. 파일을 받는 쪽에서 다시 압축하는 것은 시간 낭비이기 때문이다. 압축이 필요한지 여부는 사용자가 파일을 보낼 때 이미 정해진 일이고, 전송 중에 또 압축할 이유가 없다. (이 선택은 "구현했지만 안 쓴 것들" 편의 LZ4 이야기와도 연결된다.)
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 시그니처 6종 | LFH/DD/CEN/EOCD64/Locator/EOCD | constants.rs:2-7 |
| ZIP64 extra ID | 0x0001 | constants.rs:10 |
| ZIP64 버전 | 45 (4.5 이상 필요) | constants.rs:17-18 |
| 크기 마커 | 0xFFFFFFFF (extra에 실값) | constants.rs:25 |
| 스트리밍 라이터 | begin_file → process_chunk → end_file → finalize | stream.rs:39-73, 103, 161 |
| 구조 빌더 | LFH/DD/CEN/EOCD64/Locator/EOCD 전부 LE | structures.rs:46-196 |
| 압축 선택 | level 0 = STORE, 1-9 = DEFLATE | stream.rs:24-26 |
| DEFLATE 구현 | miniz_oxide 래퍼, Sync flush | deflate.rs:30-107 |
| 전송 경로 사용 | 수신측 Zip64Stream(0) STORE로 패키징 | directFileWriter.ts:1554-1640 |
지금 돌아보는 판단
세 가지를 정리한다.
첫째, "파일을 압축한다"와 "파일을 묶는다"는 다른 일이다. ZIP의 본질은 여러 파일을 하나로 묶는 컨테이너고, 압축은 옵션이다. PonsWarp가 STORE(묶기만)를 쓰는 것은 이 구분을 정확히 이해한 선택이다. 압축이 필요한 파일은 보내는 쪽에서 이미 처리했을 가능성이 높다.
둘째, 스트리밍 가능한 이유는 "인덱스가 마지막에 온다"는 형식 덕분이다. 이 구조를 모르면 "ZIP은 전부 모아야 만든다"고 오해한다. 형식의 순서 설계가 메모리 전략을 결정한다는 좋은 예다.
셋째, ZIP64는 '미래 대비'가 아니라 '필수'였다. 이 프로젝트는 대용량 전송을 목표로 하므로 4GB 벽은 처음부터 없어야 했다. 마커(0xFFFFFFFF) + extra field 조합은 하위 호환성을 유지하면서 확장하는 전형적인 패턴이다.
한 문장 정리
ZIP64 스트리밍은 LFH→데이터→CEN→EOCD64 순서로 조각 단위로 ZIP을 써 내려가며, 4GB 벽을 64비트 extra field(마커 0xFFFFFFFF)로 넘고, PonsWarp는 수신측에서 STORE 모드로 멀티파일을 묶어 메모리 없이 대용량 다운로드를 만든다.
관련 개념: [개념 11 — DEFLATE] (다음 글) · [개념 7 — 22바이트 패킷 헤더] (조각 단위 전송)
다음 글: [개념 11 — DEFLATE: zip64 속의 압축 (miniz_oxide)]