빌드노트
curl로 저장한 본문이 깨질 때: UTF-8보다 gzip부터 확인한다
curl로 저장한 gzip 응답을 UTF-8 오류로 오해하지 않도록 기본 요청·--compressed·--raw의 실제 저장 바이트를 비교한다. Accept-Encoding 헤더만 추가했을 때의 차이와 자동 해제 뒤에도 남는 응답 헤더를 확인하고, 텍스트 저장과 기존 파일 복원 방법을 설명한다.
curl로 받은 한글 본문을 파일로 열었는데 글자 대신 제어 문자와 알아볼 수 없는 기호가 보인다면, 응답의 Content-Encoding부터 확인한다. Content-Encoding: gzip인 본문을 압축된 상태로 저장했을 수 있다. gzip을 해제하기 전에 UTF-8로 읽으면, 올바른 한글 응답도 문자 디코딩에 실패한다. 텍스트를 저장하려면 curl의 --compressed로 자동 해제를 켜는 방법이 있다.
Content-Type: text/plain; charset=utf-8과 Content-Encoding: gzip은 함께 올 수 있다. 앞의 헤더는 압축을 푼 데이터가 어떤 텍스트인지를, 뒤의 헤더는 그 데이터에 어떤 콘텐츠 압축이 적용됐는지를 설명한다. 따라서 읽는 순서는 gzip 해제 → UTF-8 문자 디코딩이다. RFC 9110의 표현 데이터 모델 (새 탭에서 열림)은 이 두 층의 순서를 정의한다.
같은 GET 요청의 헤더와 본문을 나눠 저장한다
다음 명령은 자신의 응답이 gzip인지, 파일에 압축 바이트가 남았는지 확인한다. Windows의 PowerShell 7과 curl.exe를 사용한다. 새 빈 진단 폴더에서 $url을 증상이 나는 자신의 테스트 서버 GET 주소로 바꿔 실행한다. 예시의 /message는 자신의 실제 경로로 교체한다.
$ErrorActionPreference = 'Stop'
$url = 'http://127.0.0.1:8080/message'
curl.exe --disable -sS -v -D default.headers -o default.body "$url"
if ($LASTEXITCODE -ne 0) { throw "curl exit $LASTEXITCODE" }
Get-Content -LiteralPath default.headers
$b = [IO.File]::ReadAllBytes((Join-Path $PWD 'default.body'))
[BitConverter]::ToString($b, 0, [Math]::Min(12, $b.Length))
--disable을 첫 옵션으로 두어 개인 curl 설정 파일의 영향을 제외했다. -D는 응답 헤더를, -o는 본문을 따로 저장한다. -v 출력에서는 >로 시작하는 요청 헤더를 읽을 수 있다. 본문을 > 리다이렉션 대신 -o로 저장하면 이 비교에서 파일 쓰기를 curl에 맡길 수 있다. curl 옵션 문서 (새 탭에서 열림)
별도로 만든 loopback 서버에 안녕하세요, gzip 본문입니다.와 LF 줄바꿈을 UTF-8로 인코딩한 뒤 gzip으로 압축해 넣었다. 이 서버의 /gzip은 요청 헤더와 관계없이 같은 gzip 본문을 반환한다. 위 명령을 그 주소로 실행했을 때 나온 헤더 중 필요한 부분과 파일 앞 12바이트는 다음과 같았다.
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Encoding: gzip
Content-Length: 62
1F-8B-08-00-00-00-00-00-02-0A-01-27
파일은 62바이트였고, 서버가 만든 gzip 바이트와 완전히 같았다. Python으로 이 파일을 엄격한 UTF-8 디코딩에 넣자 byte 0x8b in position 1: invalid start byte 오류가 났다. 같은 파일을 gzip 해제한 뒤 디코딩하면 원래 한글이 나왔다. 이 비교에서 실패한 것은 한글을 UTF-8로 만드는 과정이 아니라, 압축 바이트를 바로 글자로 읽은 단계였다. 앞부분의 모양만 추측한 것이 아니라 실제 압축 해제 결과까지 대조했다.
자신의 응답이 404나 로그인 페이지라면 먼저 요청 주소와 실제 응답 내용을 확인한다. curl이 전송 오류로 끝났다면 남은 파일을 정상 본문으로 판단하지 않는다. 62바이트나 위의 12바이트 전체가 나와야 하는 것은 아니다. 이 수치는 이 합성 본문의 결과다.
--compressed는 요청과 해제를 함께 바꾼다
같은 URL에 자동 해제를 켜서 요청하고, 저장한 본문을 UTF-8로 읽어 본다.
curl.exe --disable --compressed -sS -v -D compressed.headers -o compressed.body "$url"
if ($LASTEXITCODE -ne 0) { throw "curl exit $LASTEXITCODE" }
Get-Content -LiteralPath compressed.body -Encoding utf8
이 실험의 출력은 다음 한 줄이었다.
안녕하세요, gzip 본문입니다.
기본 요청, --compressed, --raw를 같은 gzip 응답에 비교한 결과는 다음과 같다. 요청 헤더는 서버가 실제로 받은 값이다.
- 기본 요청:
Accept-Encoding없음. 저장 파일 62바이트. gzip 상태였고 직접 UTF-8 디코딩은 실패했다. --compressed:Accept-Encoding: deflate, gzip. 저장 파일 39바이트. 압축 전 UTF-8 바이트와 완전히 같았다.--raw:Accept-Encoding없음. 저장 파일 62바이트. 기본 요청에서 저장한 gzip 파일과 같았다.
curl의 --compressed 설명 (새 탭에서 열림)은 지원하는 압축을 요청하면서 응답을 자동 해제한다고 명시한다. 요청하는 알고리즘 목록은 curl 빌드에 따라 다르므로 다른 환경에서 br이나 zstd가 함께 보여도 이상하지 않다. curl.exe --version으로 실행 중인 빌드도 확인한다. 서버가 압축을 선택할 의무는 없으며, 별도 무압축 비교 응답은 이 옵션을 켜도 원래 39바이트로 저장됐다.
Accept-Encoding을 보내지 않았다고 반드시 무압축이 오는 것도 아니다. RFC 9110의 Accept-Encoding 규칙 (새 탭에서 열림)은 이 헤더가 없는 요청에서도 콘텐츠 압축을 허용한다. 따라서 기본 curl 요청이라는 이유만으로 gzip 응답을 잘못된 서버 동작으로 단정할 수 없다.
한편 -H 'Accept-Encoding: gzip'만 추가하는 것과 --compressed는 다르다. 헤더만 넣은 실험에서는 서버가 gzip 요청을 받았지만 파일은 여전히 62바이트였다. 여기에 --compressed도 지정하자 같은 요청 헤더로 39바이트의 텍스트가 저장됐다. libcurl의 자동 해제 문서 (새 탭에서 열림)도 헤더를 직접 추가하는 것만으로는 자동 해제가 켜지지 않는다고 설명한다.
--raw를 텍스트 복구 옵션으로 선택해서는 이 문제가 해결되지 않는다. 공식 설명 (새 탭에서 열림)에서 이 옵션은 콘텐츠 인코딩과 전송 인코딩의 HTTP 디코딩을 끈다. 이번에는 --compressed --raw를 함께 넣어도 gzip 62바이트가 저장됐다. 이 서버는 Content-Length를 쓰므로 기본 저장과 --raw가 같았으며, chunked 전송에서도 두 결과가 항상 같다는 뜻은 아니다.
해제된 파일에도 gzip 헤더 기록은 남는다
compressed.headers에는 여전히 Content-Encoding: gzip과 Content-Length: 62가 있었다. 반면 compressed.body는 이미 해제된 39바이트였다. curl 문서 (새 탭에서 열림)는 자동 해제 후에도 저장한 응답 헤더를 수정하지 않는다고 설명한다.
따라서 저장한 헤더만 보고 파일을 다시 gzip 해제하면 안 된다. 이 실험에서 이미 풀린 파일을 다시 해제하자 BadGzipFile: Not a gzipped file 오류가 났다. 파일을 저장한 명령과 실제 바이트를 함께 확인해야 한다. 헤더의 62와 파일의 39가 다르다는 사실도 여기서는 전송 중 잘림의 증거가 아니다. 서버가 보낸 압축 본문의 길이와 curl이 풀어서 저장한 길이를 서로 비교한 결과다.
이미 저장된 파일을 복원해야 한다면, 같은 응답의 Content-Encoding: gzip과 파일의 gzip 형식을 확인한 뒤 별도 파일로 해제할 수 있다. 다음은 Python 3가 설치된 환경에서 위의 default.body를 그대로 두고 복원본을 만드는 명령이다.
python -c "import gzip,pathlib; p=pathlib.Path('default.body'); pathlib.Path('recovered.txt').write_bytes(gzip.decompress(p.read_bytes()))"
if ($LASTEXITCODE -ne 0) { throw 'gzip 해제가 실패했습니다.' }
Get-Content -LiteralPath recovered.txt -Encoding utf8
실행 결과는 앞의 한글 한 줄과 같았고, 복원 파일은 압축 전 39바이트와 일치했다. 해제 자체가 실패하면 파일이 이미 풀렸는지, gzip이 맞는지, 다운로드가 완전한지부터 확인한다. 정상 해제 후에도 글자가 깨진다면 그때는 charset과 실제 텍스트 바이트의 일치를 별도로 조사한다. 헤더가 UTF-8이라고 쓰였다는 사실만으로 실제 데이터까지 UTF-8임이 보장되지는 않는다.
이번 관찰은 Windows 11, PowerShell 7.6.6, curl 8.21.0과 zlib 1.3.2, Python 3.12.10에서 수행한 합성 HTTP/1.1 응답 비교다. gzip 응답과 무압축 응답을 대조했고 실습 서버는 종료했다. 프록시의 압축 변환, chunked 전송, Brotli·Zstandard, 손상된 gzip 응답은 실행하지 않았다. 실제 응답을 진단할 때도 문자 인코딩을 바꾸기 전에 콘텐츠 압축과 저장 명령의 자동 해제 여부를 먼저 맞춘다.