2026년 7월 17일 | 개발 깨알 상식_Tips
joined.gz를 풀었더니 Python의 gzip 경로에서는 두 레코드가 나왔고, zlib.decompress(..., wbits=31) 경로에서는 첫 레코드만 나왔다. 예외도 없었고 종료 상태도 정상이었다. 파일 손상이 아니라 gzip member 경계를 하나만 읽은 것이 원인이었다.
gzip member는 헤더·압축 데이터·트레일러를 가진 독립 압축 단위다. RFC 1952의 gzip 파일은 이런 member가 하나 이상 차례로 붙는 구조를 허용한다. 날짜별 로그 조각이나 분산 작업 산출물을 바이트 단위로 이어 붙이면, 겉으로는 파일 하나지만 안에는 여러 member가 남을 수 있다.
Figure 1. 같은 joined.gz에서 왼쪽 경로는 두 member를 모두 순회하지만, 오른쪽 경로는 첫 member 뒤의 바이트를 unused_data로 남긴다.
정상 종료가 전체 레코드 보존을 뜻하지 않았다
두 CSV 조각을 각각 gzip.compress()로 압축한 뒤 결과 바이트를 이어 붙여 재현했다. 첫 조각은 1,alpha, 둘째 조각은 2,beta를 담았다. 압축 파일을 합치는 동작 자체는 gzip 형식에서 금지되지 않는다.
import gzip
part_a = b"id,value\n1,alpha\n"
part_b = b"id,value\n2,beta\n"
joined = gzip.compress(part_a, mtime=0) + gzip.compress(part_b, mtime=0)
with open("joined.gz", "wb") as fp:
fp.write(joined)
이 입력에서 gzip.decompress(joined)는 두 member의 평문을 이어 반환했다. Python 문서도 이 함수가 여러 gzip block을 연결한 multi-member 데이터를 처리할 수 있다고 설명한다. 반면 zlib.decompress(joined, wbits=31)는 첫 member의 평문만 반환했다. 문서가 wbits=31 경로를 데이터가 한 member라고 확실할 때의 선택으로 설명하는 이유가 여기서 드러났다.
첫 member만 읽어도 오류가 나지 않는 이유
첫 member만 놓고 보면 스트림은 완전했다. 헤더가 맞고 압축 데이터와 체크값도 정상이라 저수준 해제기는 성공으로 끝날 수 있다. 뒤에 남은 바이트는 첫 member의 손상이 아니라 그 다음 member다. 그래서 CRC 오류나 truncated stream만 감시하면 이 누락은 잡히지 않는다.
실험에서는 첫 member가 37바이트, 둘째 member가 36바이트였고 전체 파일은 73바이트였다. zlib.decompressobj(wbits=31)로 첫 member를 읽자 평문에는 1,alpha만 들어왔고, unused_data에는 정확히 36바이트가 남았다. 남은 바이트의 시작도 gzip magic인 1f 8b였다.
import zlib
obj = zlib.decompressobj(wbits=31)
first = obj.decompress(joined) + obj.flush()
print(first.decode())
print(len(obj.unused_data)) # 36
print(obj.eof) # True
eof=True와 unused_data 존재는 모순이 아니다. 전자는 현재 member가 완전히 끝났다는 뜻이고, 후자는 그 끝 뒤에 소비하지 않은 바이트가 있다는 뜻이다. 이 둘을 같이 보지 않으면 “정상 EOF”를 “전체 파일 소비 완료”로 잘못 읽게 된다.
일반 로딩에는 multi-member 경로를 고정했다
파일 전체를 메모리에 올려도 되는 크기라면 나는 gzip.decompress()를 쓴다. 스트리밍이 필요하면 gzip.open()이나 gzip.GzipFile처럼 gzip 파일 의미를 아는 인터페이스를 먼저 고른다. 저수준 zlib 경로가 필요한 경우에는 첫 스트림이 끝날 때마다 unused_data로 새 decompress object를 만들어 다음 member를 순회해야 한다.
그 루프를 직접 구현할 때는 남은 바이트가 있다는 이유만으로 무한 반복하지 않는다. 매 반복에서 입력 바이트가 실제로 줄었는지, 새 object가 EOF에 도달했는지, 출력 제한을 넘지 않았는지 확인한다. 마지막에 gzip이 아닌 trailing bytes를 허용할지도 계약으로 정한다. 여러 member 지원과 아무 꼬리 바이트나 무시하는 것은 다른 정책이다.
성능 때문에 zlib.decompress(..., wbits=31)를 고른 코드라면 입력 계약에 “단일 member만 허용”을 명시하고, 제작 단계에서 조각을 단순 연결하지 않게 하는 방법도 있다. 빠른 함수 하나를 유지하려고 multi-member 루프를 숨기는 것보다, 생성기와 소비기의 계약을 한 member로 맞추는 편이 더 단순할 때가 많다.
레코드 수와 소비 바이트를 함께 검증했다
압축 해제 성공만 테스트하면 첫 member 누락은 통과한다. 나는 작은 fixture에 두 member를 의도적으로 넣고, 첫 조각과 둘째 조각의 sentinel 레코드가 모두 나오는지 확인한다. 배치 입력이라면 manifest의 예상 shard 수, 해제 후 레코드 수, 마지막 shard의 식별자를 함께 비교한다.
- 입력 제작 방식이 단일 압축인지, 여러
.gz조각의 바이트 연결인지 기록한다. - 소비 라이브러리가 multi-member gzip을 순회하는지 작은 두-member fixture로 시험한다.
- 저수준 해제기에서는
eof와unused_data를 함께 로그에 남긴다. - 해제 성공과 별도로 예상 레코드 수·마지막 식별자·shard 수를 검증한다.
- trailing bytes 허용 여부와 최대 해제 크기를 입력 계약에 넣는다.
체크섬이 맞아도 소비기가 첫 member만 읽으면 논리적으로는 데이터가 잘린다. 반대로 레코드 수가 맞아도 원본 바이트가 바뀌었다면 다운로드 무결성 문제일 수 있다. 그래서 파일 checksum, gzip member 순회, 레코드 경계를 서로 다른 검증 층으로 둔다.
문제는 압축률이 아니라 경계 계약이었다
이 증상은 “gzip이 여러 파일을 제대로 합치지 못했다”가 아니었다. 형식은 여러 member를 허용했고, 특정 소비 경로가 첫 member까지만 읽었을 뿐이다. 예외가 없다는 점 때문에 데이터 생성 쪽을 먼저 의심했지만, 실제 수정 위치는 로더 선택과 검증 fixture였다.
이후 gzip 산출물을 받을 때 파일 개수만 보지 않는다. 하나의 .gz 안에 몇 개의 압축 단위가 있을 수 있는지, 사용하는 라이브러리가 그 단위를 어디까지 소비하는지 먼저 확인한다. 전체 파일을 읽었다는 근거는 정상 종료가 아니라 남은 입력 0바이트와 기대 레코드 일치로 남기는 편이 안전했다.
'[개발 깨알 상식_Tips]' 카테고리의 다른 글
| CSV 따옴표 안 줄바꿈과 행 수 오판 (0) | 2026.07.21 |
|---|---|
| Docker Compose에서 env_file 값이 빈 문자열로 덮이는 원인 (0) | 2026.07.18 |
| 한글 파일명의 NFC·NFD 불일치 (0) | 2026.07.16 |
| 시간대 없는 datetime과 UTC 값 비교 오류 (0) | 2026.07.15 |
| 경로 오타를 빈 디렉터리로 만든 Docker 바인드 마운트 (0) | 2026.07.14 |