[WarpInfra] 폴더가 단일 스트림으로 줄줄이 나가던 버그
폴더 전송은 겉보기엔 단순하다. "파일을 묶어서 보내면 된다." 그 한 문장이 통하는 범위는 몇십 MB 수준까지다. 그 이상이 되는 순간 zip으로 묶는 행위 자체가 메모리와 디스크와 타이밍이 한꺼번에 어긋나는 지점이 된다. 이 편은 폴더 전송이 zip 패키징 실패로 단일 스트림 연속 송신이라는 이상한 모양으로 무너졌던 사건, 그리고 그것이 스트리밍 파이프라인으로 바뀌기까지의 커밋을 읽는다.
이 사건을 시리즈 안에 두면 위치도 의미가 있다. 앞 편에서 네이티브 QUIC로 수송체를 바꿨다면, 이 편은 수송체 위에 실리는 짐의 형태 — 파일을 어떤 단위로 묶고 어떤 순서로 흘려보낼 것인가 — 를 다룬다. 수송체를 아무리 좋게 바꿔도 짐을 싸는 방식이 틀리면 배달은 틀린다. 전송 시스템은 이렇게 층층이 쌓이고, 각 층은 자기 층의 실패 양식을 갖고 있다.
버그의 모양
138a5ab 커밋 메시지가 사건을 그대로 말해준다.
"다중파일전송 에러 문제 수정 (zip패키징이 안되고 단일스트림으로 연속적으로 송신)"
폴더를 zip 하나로 패키징하려던 경로가 실패하면서, 파일들이 한 스트림에 이어 붙어 전송됐다. 받는 쪽은 어디서 한 파일이 끝나고 다음이 시작하는지 알 수 없었다. 이런 부류의 버그가 특히 나쁜 이유는 실패의 모양이 있지 않다는 것이다. 전송은 "성공"한 것처럼 보이고, 바이트는 흘렀고, 진행률도 올랐다. 깨진 것은 전송이 아니라 전송된 데이터의 의미 구조다. 수신 쪽에서 조립이 실패해야 비로소 문제를 인지한다 — 발신 쪽에서 이미 한참 전에 깨져 있었는데도.
이런 실패 형태는 원인 분석을 특히 어렵게 한다. "zip 패키징 실패"라는 근본 원인과 "수신 쪽 조립 실패"라는 증상 사이에 단일 스트림이라는 이상한 중간 상태가 끼어 있어서, 증상만 보고 조립 로직을 의심하게 되기 쉽다. 실제로도 그렇게 원인을 잘못 짚는 시간이 들었을 것이다 — 커밋 메시지가 원인과 증상을 한 줄에 같이 써놓은 건, 그 구분이 당시에도 쉽지 않았다는 뜻이다.
이 버그에서 꺼낼 교훈은 "zip을 조심하라"가 아니다. 전송 시스템에서 데이터의 의미 구조는 전송 계층과 분리돼 검증돼야 한다는 것이다. 단일 스트림 사건에서 전송 계층은 자기 임무(바이트 이동)를 수행했고 깨진 건 파일 경계라는 의미 구조였다. 만약 조각 단위 무결성 검증이 발신 단계부터 붙어 있었다면 — 파일마다 해시가 매겨지고 각 파일이 독립 조각으로 매핑돼 있었다면 — 연속 송신은 애초에 성립하지 않았을 것이다. 왜냐하면 "두 번째 파일의 조각"이 정의되지 않은 상태가 되고, 그런 조각은 검증 단계에서 즉시 거부되기 때문이다. 의미 구조를 조각 지도에 박아두는 것은 성능 최적화가 아니라 오류 조기 폭발 장치다.
해결 방향
| 커밋 | 내용 |
|---|---|
138a5ab | zip 패키징 실패 → 연속 송신 버그 수정 |
c6f0928 | 고성능 스트리밍 전송 아키텍처 구현 |
8cf631d | 폴더/다중 파일 전송 시 zip 스트리밍 개선 |
커밋의 순서와 성격을 보면, 138a5ab가 즉상 치료라면 c6f0928과 8cf631d는 근본 처방이다. 즉상 치료는 "zip이 실패해도 파일별로 정상 전송되게" 수준이었을 것이고, 근본 처방은 "애초에 폴더를 스트림으로 취급하자"는 구조 변경이다. 이 둘의 구분이 중요한 이유는, 즉상 치료만 남았을 때 같은 부류의 버그(묶기-보내기 경계의 실패)가 다른 옷을 입고 재발하기 때문이다. 단일 스트림 사건은 zip에 한정된 문제가 아니라 "전체를 한 덩어리로 다루는 모든 방식"의 공통 약점이었고, 스트리밍 전환은 그 부류 전체를 제거한 선택이다.
핵심은 "전체를 메모리에 모은 뒤 zip"이 아니라 스트림으로 zip을 쓰면서 즉시 전송하는 파이프라인이다. 전체 파일 크기가 몇 GB여도 메모리는 청크 크기만큼만 쓴다. 이 한 문장에 세 가지의 이전 상태가 묻혀 있다. "전체를 메모리에 모은다"는 폴더 크기만큼 메모리를 요구했고, "묶은 뒤에 보낸다"는 첫 바이트 전송이 패키징 완료까지 지연됐고, "zip 하나"는 패키징 실패 시 전송 전체가 실패했다는 것. 스트리밍 파이프라인은 이 세 가지를 동시에 고친다 — 메모리 상수, 첫 바이트 지연 제거, 부분 실패의 격리.
세 가지 중 세 번째가 구조적으로 가장 중요하다. 메모리와 지연은 정량적 개선이지만, 부분 실패의 격리는 실패의 성격 자체를 바꾼다. 모으기 방식에서 패키징이 실패하면 그동안 기다린 시간 전체가 날아가고 재시도도 처음부터다. 스트리밍 방식에서는 실패 지점까지의 진행이 보존되고, 재시도는 실패 지점부터다. 대용량일수록 이 차이가 기하급수적으로 커진다 — 10GB 폴더에서 90% 지점 실패를 생각해 보면 된다. "재개"라는 기능이 Phase A 목록에 독립 항목으로 있는 것도, 이 실패 성격의 변화가 전제되기 때문이다.
c6f0928의 "고성능 스트리밍 전송 아키텍처"가 이 편과 QUIC 편에 걸쳐 등장하는 이유도 여기 있다. 스트리밍 아키텍처는 전송 수송체(QUIC)와 패키징(zip) 양쪽에 걸린 공통의 요구다. 청크가 생산되는 즉시 소비되는 파이프라인이 있어야 zip도 밀리고 QUIC도 밀린다.
설계 원칙
이 사건 이후 고정된 원칙 세 가지다.
- 폴더 전송은 "하나의 전송 단위"로 묶되, 바이트는 청크 단위로 흘려보낸다.
- 패키징 실패 시 "모두 멈춤"이 아니라 복구 가능한 스트림 상태로 처리한다.
- 버그 수정은 기능 추가와 분리해 커밋 단위를 작게 유지한다.
첫 번째가 이 편의 본체다. 두 번째는 첫 번째의 귀결로, 스트림으로 처리하면 어디까지 진행됐는지가 명시적이 되니 실패 지점부터 재개하는 것이 자연스러워진다. "모두 멈춤" 방식에서 재개란 처음부터 다시였다. 재개가 "처음부터"가 아니라 "터진 지점부터"가 되려면, 스트림 상태가 재개 지점을 정확히 기술할 수 있어야 한다. 조각 단위 진행 상황이 그 역할을 한다 — 어느 조각까지 확정됐는지가 곧 재개 지점이다. resume 기능(S2 전체에 걸친 주제)과 zip 스트리밍이 만나는 접점이 바로 여기다.
세 번째는 커밋 관행 얘기처럼 보이지만 사실 검증의 문제다. 버그 수정과 리팩토링이 한 커밋에 섞이면, 버그가 고쳐졌는지 리팩토링이 고장을 만들지 않았는지를 따로 증명할 수 없게 된다. 138a5ab이 다른 기능 없이 버그 하나만 담은 것은 이 규율의 실천이다.
스트리밍 파이프라인이 바꾼 감시점
파이프라인이 "모아서 보내기"에서 "흘려보내기"로 바뀌면 관리해야 할 지점도 달라진다. 모으기 방식의 감시점은 두 개였다 — 패키징 완료와 전송 완료. 둘 다 이분법이어서 관리가 단순했다. 스트리밍 방식에서는 생산과 소비가 계속 어긋날 수 있으니, 어긋남의 크기(버퍼에 쌓인 청크량)가 새로운 감시 대상이 된다. 생산이 소비를 오래 앞서면 메모리가 늘고, 소비가 생산을 앞서면 전송이 뚝뚝 끊긴다. backpressure — 소비 속도가 생산 속도를 통제하는 구조 — 가 이 편 이후 전송 설계의 기본 어휘가 된 이유다.
이 변화의 실용적 효과 하나 더. 스트리밍에서는 첫 바이트가 패키징 완료를 기다리지 않는다. 사용자 관점에서 "전송 시작"까지의 대기 시간이 폴더 크기와 무관해진다. GB 단위 폴더에서 이 차이는 기능이라기보다 제품의 신뢰감이다. 눌렀는데 아무 일도 안 일어나는 몇 초가 제품 전체의 평판을 깎아먹는다.
왜 처음부터 스트리밍이 아니었나
되돌아보면 "zip을 통째로 만든 뒤 전송"이 처음에 선택된 이유도 이해할 만하다. 단순하다. zip 라이브러리에 파일 목록을 주고 결과 파일 하나를 받으면 끝이다. 실패 처리도 단순하다 — 됐거나 안 됐거나. 스트리밍 zip은 그보다 훨씬 복잡하다. 생성 속도와 소비 속도의 불일치, 부분 생성 상태의 표현, 실패 시의 정리가 전부 우리 코드의 책임이 된다.
즉 이 사건은 "단순한 방식이 먼저였고, 단순함이 실제 부하에서 깨지면서 복잡한 방식으로 갈아탄" 표준적인 진화다. 이런 진화를 피하는 방법은 처음부터 복잡한 방식을 택하는 게 아니라, 단순한 방식의 한계가 드러나는 지점(여기서는 폴더 크기)을 미리 알고 있어서 그 지점을 통과하기 전에 전환 준비를 하는 것이다. RC1 게이트의 20GB 기준이 "이런 지점들을 통과해야 완료다"라는 선언인 셈이다.
남은 구멍
zip 스트리밍은 로컬/LAN에서 검증됐지만, 대용량(수십 GB) 폴더의 장기 실행 안정성은 RC1 게이트(20GB 실측)에 걸려 있다. 이 편의 파이프라인은 그 게이트를 통과해야 할 대상이다. 몇 시간 동안 흐르는 스트림이 메모리를 유출하지 않는지, 생성-소비 속도 차가 길게 누적되지 않는지 — 이런 질문들은 짧은 LAN 테스트로는 답이 안 나온다. 장기 실행 안정성은 시간이라는 변수가 필요한 테스트고, 그래서 "20GB 실측"이 증거 요건으로 박혀 있는 것이다.
특히 폴더 케이스는 단일 파일보다 질문이 많다. 파일이 수백 개면 스트림 상태 표현도 수백 개의 파일 경계를 다뤄야 하고, 폴더 안에서 파일 하나가 전송 도중 바뀌면(배포 중 빌드가 다시 돈다든가) 어떤 정책으로 처리할지도 정해져야 한다. 이런 엣지들은 지금 당장 답을 박을 문제가 아니라, 20GB 실측을 설계할 때 같이 상정해야 할 시나리오 목록이다. 버그를 고치는 것과 그 버그 부류가 다시 나오지 않게 하는 것은 다른 작업이고, 후자는 게이트에서 한다.
이 시리즈
- 이전: WASM을 버리고 네이티브 QUIC로
- 다음: 희소한 조각부터, 여러 피어에게 동시에
- 목차: PonsWarp 개발 회고