[PonsWarp 개념 교실 7] 22바이트 패킷 헤더 — 한 조각이 자신을 설명하는 법
쉽게 말하면, **22바이트 패킷 헤더는 "파일 조각 하나가 스스로를 설명하는 명찰"**이다. 어느 파일의 몇 번째 조각이고, 파일의 어디서부터 시작하며, 데이터가 몇 바이트인지가 이 명찰에 적혀 있다.

이번 글에서는 그 명찰의 각 칸이 왜 그 크기인지, 왜 그 순서인지, 그리고 명찰에 적혀 있지만 **일부러 비워 두는 칸(CRC)**이 있는 이유까지 본다.
이게 왜 필요한가 — 조각은 스스로 위치를 모른다
파일을 192KiB씩 잘라 보내면 수신측에는 "데이터 덩어리"만 도착한다. 이 덩어리만으로는 아무것도 알 수 없다.
- 어느 파일의 일부인가? (멀티파일 전송이면 더 중요)
- 파일의 몇 번째 조각인가? (순서)
- 파일의 어느 위치부터 시작하는가? (offset — 파일 중간에서 시작하는 조각도 있으므로)
- 데이터는 실제로 몇 바이트인가? (마지막 조각은 꽉 차지 않을 수 있으므로)
이 정보를 패킷 앞에 고정 크기로 붙인 것이 헤더다. 수신측은 헤더만 읽으면 조각을 정확한 위치에 붙일 수 있다.
구조 — 22바이트, 5개 필드
PonsWarp의 평문 패킷 헤더는 22바이트로 고정되어 있다. 모든 값은 리틀 엔디언(LE)이다.
오프셋 크기 필드 의미
────── ──── ──────────── ──────────────────────────
0 2 file_index 어느 파일인가 (0 = 첫 파일)
2 4 chunk_index 몇 번째 조각인가 (시퀀스)
6 8 offset 파일의 어느 위치부터인가
14 4 length 데이터 길이 (바이트)
18 4 checksum CRC32 (현재는 0 고정)
────── ──── ──────────── ──────────────────────────
22 HEADER_SIZE [데이터]
각 필드의 크기에는 이유가 있다.
| 필드 | 크기 | 이유 |
|---|---|---|
file_index | 2B (u16) | 최대 65,536개 파일 — 폴더 전송에 충분 |
chunk_index | 4B (u32) | 최대 42억 조각 — 192KiB 기준 약 768TB |
offset | 8B (u64) | 파일 내 절대 위치 — 4GB 이상 파일도 표현 (중요) |
length | 4B (u32) | 조각 데이터 길이 — 192KiB를 담기에 충분 |
checksum | 4B (u32) | 원래는 CRC32 자리 — 현재 0 고정 |
offset이 8바이트인 이유
여기서 가장 중요한 설계 결정이 하나 있다. offset이 **8바이트(u64)**라는 점이다.
조각 번호만으로 위치를 알 수 있는데 왜 파일 내 절대 위치(offset)까지 따로 넣었을까? 두 가지 이유가 있다.
첫째, 4GB 이상 파일을 지원하기 위해서다. ZIP64 편에서 다루겠지만, 이 프로젝트는 대용량 파일 전송을 목표로 한다. chunk_index(4B)는 조각 개수로는 충분하지만, 파일 크기 자체는 u64로 표현해야 4GB를 넘는다.
둘째, 재개(resume)를 단순하게 만든다. 전송이 끊기면 "파일의 123,456,789바이트부터 다시"라고 말할 수 있어야 한다. offset이 있으면 재개 위치 계산이 offset / chunkSize로 단순해진다. 이 커서 계산은 나중에 "resume 커서" 편에서 다룬다.
CRC 필드가 0으로 비워진 사연
헤더에 4바이트 checksum 자리가 있는데, 실제 전송에서는 0으로 채워 보낸다. 코드 주석이 그 이유를 정확히 설명한다.
typescript// PonsWarp/src/utils/plainPacket.ts // CRC32 removed from hot path: reliable SCTP already retransmits, and // AES-GCM (encrypted packets) authenticates ciphertext. Receiver never // verified this field. Keep the 4-byte slot as zero for wire compatibility. view.setUint32(18, 0, true);
두 가지 위임이다.
- 전송 신뢰성은 SCTP가 담당 — WebRTC DataChannel의 ordered 채널이 손실·중복·순서를 이미 처리한다.
- 변조 인증은 AES-GCM 태그가 담당 — 암호화 경로에서는 16바이트 태그가 무결성을 보장한다.
그래서 CRC 계산은 CPU 낭비일 뿐이다. 흥미로운 점은 WASM 코어(packet.rs)는 여전히 CRC32를 계산해 채워 넣는다는 것이다. 즉 브라우저 경로(0 고정)와 WASM 레거시 경로(CRC 계산)가 다르게 동작한다. 이건 "구현했지만 쓰지 않은 것들" 편에서 FEC·LZ4와 함께 다룰 주제다.
EOS — 파일의 끝을 알리는 특수 패킷
전송이 끝나면 헤더만 있는 특수 패킷을 보낸다. file_index가 0xFFFF(65535)인 패킷이 **EOS(End of Stream)**다. 실제 파일 인덱스는 0부터 시작하므로 65535는 절대 사용되지 않아, "이 값이면 끝"이라는 신호로 쓸 수 있다.
typescript// PonsWarp/src/utils/plainPacket.ts export function createEosPacket(): ArrayBuffer { const packet = new ArrayBuffer(HEADER_SIZE); new DataView(packet).setUint16(0, 0xffff, true); return packet; }
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 헤더 크기 | 22B (5개 필드) | constants.ts:46, packet.rs:4 |
| 필드 배치 | file(2)+chunk(4)+offset(8)+len(4)+crc(4) | packet.rs:8-12 |
| 리틀 엔디언 | setUint16(0,0,true) 등 LE 고정 | plainPacket.ts:32-35 |
| CRC 필드 | 0 고정 (SCTP+AEAD 위임) | plainPacket.ts:36-39 |
| CRC 계산 경로 | WASM packet.rs는 여전히 계산해 채움 | packet.rs:33, 47 |
| EOS | file_index=0xFFFF 특수 패킷 | plainPacket.ts:45-48, packet.rs:151-155 |
| 헤더 크기 상수 | HEADER_SIZE=22 | constants.ts:46 |
지금 돌아보는 판단
세 가지가 인상적이다.
첫째, "프로토콜은 미래를 위해 8바이트를 쓴다"는 판단이 좋다. 조각 번호(4B)만으로도 순서는 알 수 있지만, offset(8B)을 넣어 대용량 파일과 재개를 한 번에 해결했다. 헤더 4바이트를 아끼는 것보다 미래의 요구를 막는 것이 낫다.
둘째, CRC 필드를 0으로 두는 건 "게으름"이 아니라 "위임"이다. 하위 계층(SCTP)과 상위 계층(AEAD)이 이미 무결성을 담당하므로 중복 계산을 뺐다. 다만 브라우저 경로와 WASM 경로의 동작이 달라진 점(0 vs 계산)은 와이어 호환성 유지를 위해 필드를 남겨 둔 절충으로 보인다. [INFERENCE] — 코드 주석은 "wire compatibility"라고만 명시한다.
셋째, EOS 같은 "구조적 특수값"은 실용적이다. 별도 상태 머신 없이 "이 값이면 끝"이라는 프로토콜 신호 하나로 완료를 표현한다. 단순함이 예다.
한 문장 정리
22바이트 패킷 헤더는 file_index·chunk_index·offset(8B)·length·checksum(0 고정)으로 조각 하나가 자신의 파일·순서·위치·길이를 설명하게 하고, EOS(0xFFFF)로 끝을 알린다.
관련 개념: [개념 6 — 무결성 3총사] (CRC 위임) · [개념 8 — 청크 크기] (다음 글)
다음 글: [개념 8 — 청크 크기 16~192KiB: 어떻게 정해졌나]