Gzip
Base64로 쓴 gzip, zlib, 원시 deflate를 붙여넣어 내용을 읽거나(어느 것인지는 바이트가 알려 줍니다), 텍스트를 셋 중 하나로 압축합니다. 브라우저에서 실행됩니다.
[
{
"name": "서연",
"city": "서울"
},
{
"name": "민준",
"city": "부산"
},
{
"name": "지우",
"city": "인천"
},
{
"name": "도윤",
"city": "대구"
},
{
"name": "하은",
"city": "광주"
},
{
"name": "시우",
"city": "대전"
},
{
"name": "서윤",
"city": "수원"
},
{
"name": "예준",
"city": "제주"
}
]gzip으로 읽었습니다.
- 압축 전 바이트 수
- 418
- 압축 후 바이트 수
- 170
- 변화
- -59.3%
- Base64 문자 수
- 228
세 래퍼에 담긴 하나의 압축, 그리고 deflate라는 단어
gzip, zlib, 원시 deflate는 DEFLATE라는 하나의 압축을 세 가지 방식으로 감싼 것입니다. DEFLATE 자체는 RFC 1951이 정의합니다. 바이트를 블록으로 나누고 각 블록을 부호로 쓰는데, 부호 하나하나는 바이트 하나나, 그보다 앞에 나온 바이트 열을 복사한 것을 나타냅니다. gzip의 래퍼는 RFC 1952가 정의하며, 앞에는 최소 10바이트의 헤더를, 뒤에는 8바이트, 곧 원래 바이트의 CRC-32와 그 길이를 둡니다. zlib의 래퍼는 RFC 1950이 정의하며, 앞에 2바이트를, 뒤에 Adler-32를 둡니다. 그리고 원시 deflate는 래퍼가 전혀 없는 압축 그 자체입니다. 안에 든 압축 데이터는 셋 모두 같을 수 있으므로, 차이는 오로지 래퍼에 있습니다.
이름이 엉키는 곳은 deflate라는 단어입니다. HTTP에서 Content-Encoding: deflate는 zlib의 래퍼를 뜻하는데, RFC 9110은 그래도 이 이름으로 원시 deflate를 보내는 서버가 있다고 적고 있습니다. 브라우저 자체 압축기는 HTTP를 따라, zlib의 래퍼를 deflate라는 이름으로 부르고, 원시 deflate를 deflate-raw라는 이름으로 부릅니다. .NET의 DeflateStream과 PHP의 gzdeflate는 원시 deflate를 쓰고, PHP의 gzcompress와 Python의 zlib.compress는 zlib의 래퍼를 씁니다. 그러니 deflate라는 레이블이 붙은 바이트는 둘 중 어느 것일 수도 있고, 어느 쪽인지는 바이트만이 말해 줄 수 있습니다.
그래서 페이지는 압축을 풀 때 래퍼를 바이트에서 읽어 내고, 어느 래퍼로 읽었는지를 나온 것 아래에 밝힙니다. ‘gzip으로 읽었습니다’, ‘zlib으로 읽었습니다’, ‘원시 deflate로 읽었습니다’ 가운데 하나입니다. 이를 정하는 것은 바이트입니다. gzip은 언제나 1f 8b로 시작하고, zlib의 첫 2바이트는 둘을 합쳐 하나의 수로 읽으면 31의 배수이며, 원시 deflate를 그 둘 중 어느 쪽으로 시작하는 인코더도 없습니다. 이름이 .gz인데 zlib이 든 파일은 zlib으로 읽히고, 이름과 바이트가 어긋난다는 알림이 붙습니다. 셋 중 어느 것도 아닌 바이트는 압축을 풀 것이 없어 거부됩니다. 압축할 때는 여러분이 래퍼를 고르며, 다른 것을 고르기 전까지는 gzip입니다.
페이로드는 어디서 오는가, 그리고 H4sI와 eJ는 무엇인가
압축된 바이트가 개발자에게 오는 모양은 몇 가지입니다. Content-Encoding이 gzip이나 deflate를 가리키는 HTTP 응답의 본문으로, 파일로, 또는 Base64나 16진수로 쓴 텍스트로 옵니다. 모든 gzip 스트림은 같은 3바이트인 1f 8b 08로 시작합니다 — 앞의 2바이트는 서명이고, 세 번째 바이트는 압축 방식의 번호, 곧 DEFLATE를 나타내는 번호입니다 — 그리고 3바이트는 정확히 Base64의 4문자이므로, Base64로 쓴 gzip은 언제나 H4sI로 시작합니다. CloudWatch Logs 구독이 Lambda 함수나 Kinesis 스트림에 건네는 각 레코드의 데이터가 바로 이것이고, Helm이 릴리스를 저장하는 방식도 이것입니다. 릴리스의 JSON을 gzip으로 압축한 다음 Base64로 쓰는 것입니다. Helm이 릴리스를 Kubernetes의 Secret에 보관한다면, Secret에서 바로 읽은 것은 Base64가 두 겹입니다. Secret은 자기 데이터를 따로 Base64로 담기 때문입니다. 그 바깥쪽 Base64는 ‘Base64’ 도구가 디코딩하며, 그러면 이 페이지가 읽는 H4sI…가 나옵니다.
zlib의 헤더 2바이트는 압축 방식과 창 크기, 그리고 쓰일 때의 수준을 나타냅니다. 그래서 zlib의 기본 창 크기라면 그 Base64는 네 가지 중 하나로 시작합니다. 기본 수준에서는 eJ이며, 이는 Python의 zlib.compress가 따로 요청받지 않는 한 쓰는 것입니다. 그보다 낮은 수준에서는 eA나 eF, 그보다 높은 수준에서는 eN입니다. 원시 deflate에는 서명이 전혀 없고, 그 Base64는 첫 블록이 우연히 어떻게 시작하느냐에 따라 시작합니다.
‘압축 데이터 표기’는 페이지가 그 바이트를 텍스트로 어떻게 읽고 쓰는지를 정합니다. 페이지가 열릴 때의 ‘Base64’, 또는 ‘16진수’입니다. 고른 표기는 두 방향 모두에 적용되며, 페이지가 여러분 대신 그것을 바꾸는 일은 없습니다. 16진수 숫자는 모두 Base64의 문자이기도 해서, 1f8b0800은 어느 쪽으로든 텍스트이고 각각에서 서로 다른 바이트가 되기 때문입니다. Base64는 표준 알파벳이든 URL 안전 알파벳이든, 패딩이 있든 없든, 공백과 줄바꿈이 끼어 있어도 읽히고, 쓸 때는 패딩을 붙여 씁니다. ‘URL 안전’ 스위치가 켜져 있으면 패딩 없이 URL 안전 알파벳으로 씁니다. 16진수는 두 자리씩 공백으로 띄워 쓰고, 읽을 때는 띄어 썼든 붙여 썼든, 앞에 0x가 있든, 16진수 덤프든 읽습니다. 그리고 0x는 T-SQL이 이진 값을 쓰는 방식으로, SQL Server의 COMPRESS()가 돌려주는 gzip도 그렇게 쓰입니다. 16진수를 Base64로 읽다가 실패했거나, Base64를 16진수로 읽다가 16진수에 없는 문자에서 거부되었는데, 어느 경우든 텍스트 전체가 다른 쪽으로 읽힌다면, 알림이 그렇다고 알려 주고 그 옆에 전환 버튼이 나옵니다. 그 버튼을 누르기 전에는 아무것도 바뀌지 않습니다.
스트림 읽기: 멤버, 그 뒤의 바이트, 너무 이른 끝
RFC 1952에 따르면 gzip 파일은 멤버의 연속입니다. 멤버란 저마다 자기 헤더와 트레일러를 가진 완전한 gzip 스트림 하나를 말하며, 그것이 차례로 이어집니다. cat a.gz b.gz가 그런 파일을 만들고, gzip -c file >> archive.gz로 기존 파일에 덧붙여도 마찬가지인데, 이것은 GNU gzip 매뉴얼 자체의 예시입니다. gunzip은 모든 멤버를 읽고 그 내용을 이어 붙입니다. 이 페이지도 같은 방식으로 읽습니다. 멤버를 하나하나 읽고 확인하며, 그 내용을 이어 붙여 보여 주고, 몇 개를 읽었는지 알림이 알려 줍니다. gzip을 읽는 도구가 모두 이렇게 하지는 않습니다. 브라우저 자체 압축 해제기가 따르는 표준은 gzip 스트림에 멤버를 하나만 허용하고 두 번째 멤버를 오류로 봅니다. 그리고 Python의 zlib.decompress는 첫 번째 멤버 뒤에서 아무 말 없이 멈춥니다.
끝 뒤에 있으면서 어떤 멤버도 시작하지 않는 바이트는, 페이지가 거부하지 않고 건너뜁니다. 그 앞의 모든 것이 완전하고 확인을 마쳤기 때문입니다. 그리고 알림이 그 바이트가 몇 개인지, 어느 오프셋에서 시작하는지, 전부 0인지를 알려 줍니다. 거기 있는 0은 패딩입니다 — GNU gzip 매뉴얼은 테이프에서 블록 끝까지 채우려고 쓴 것으로 이를 다룹니다 — 그리고 gunzip은 이를 조용히 건너뜁니다. 다른 바이트는 끝의 쓰레기 데이터를 무시했다는 경고와 함께 건너뛰는데, Python의 gzip.decompress는 그런 파일을 거부합니다. zlib 스트림의 Adler-32 뒤에서도, 원시 스트림의 마지막 블록 뒤에서도 마찬가지입니다. 다만 원시 deflate에는 확인할 체크섬이 없습니다.
붙여넣은 것이 잘렸거나 내려받다가 중간에 멈춘 경우처럼 끝에 이르기 전에 끝나 버린 스트림은 닿는 데까지 보여 주며, 체크섬은 확인하지 않았다는 알림이 붙습니다. 손상된 스트림은 거부하며, 아무것도 보여 주지 않습니다. 이는 gunzip보다 엄격합니다. gunzip은 잘못을 찾아내는 체크섬에 이르기 전에 그때까지 압축을 푼 것을 써 내기 때문입니다. 하지만 비트 하나가 바뀌어도 무언가가 그것을 드러내기까지 한동안은 아무 일 없이 디코딩될 수 있으므로, 손상이 드러나기 전에 나온 것도 이미 틀렸을 수 있습니다. 위치도 알려 주지 않습니다. 디코더가 손상을 알아차리는 곳은 손상이 있는 곳이 아니기 때문입니다. 그리고 미리 정한 사전을 요구하는 zlib 스트림은 그것만을 위한 문장으로 거부합니다. 그 스트림은 압축기가 미리 받아 둔 바이트를 식별할 뿐 그 바이트를 담고 있지는 않아서, 그것을 읽을 수단이 없기 때문입니다.
나온 것은 ‘바이트 표기’에서 고른 대로, UTF-8 ‘텍스트’나 ‘16진수’로 씁니다. 그것은 흔히 JSON이며, ‘JSON 포맷터’가 이를 정리하고 검증하면서 깨지는 줄과 열을 짚어 줍니다. gzip으로 압축한 이미지나 아카이브는 텍스트가 아니므로, ‘텍스트’일 때는 UTF-8이 아닌 바이트 시퀀스가 하나하나 U+FFFD로 나타나고, 그런 시퀀스의 수를 세는 알림이 바이트를 16진수로 보여 주는 버튼과 함께 나옵니다. 어느 쪽이든 ‘내려받기’는 바이트 그 자체를 저장합니다. 결과 아래의 한 줄은 첫 번째 멤버의 헤더에 저장된 것, 곧 이름과 UTC 시각과 주석을 보여 주며, 저장된 이름이 ‘내려받기’가 저장하는 파일의 이름이 되는 일은 없습니다. 이 페이지가 한 번에 압축을 푸는 최대치를 넘는 출력은 거기서 멈추고, 그 크기를 밝히는 알림이 붙습니다. 그리고 gzip의 트레일러가 그보다 큰 길이를 적고 있으면, 작업이 진행되는 동안 페이지가 그렇다고 알려 줍니다.
작은 입력이 커지는 이유, 그리고 Base64가 더하는 것
압축은 반복을 찾아내서 이득을 보는데, 짧은 텍스트에는 반복이 거의 없습니다. 반면 래퍼는 저마다 자체 바이트가 듭니다. gzip의 헤더와 트레일러는 헤더에 더 저장하는 것이 없을 때 합쳐서 18바이트, zlib은 6바이트이고, 원시 deflate에는 그런 것이 없습니다. 다만 DEFLATE는 블록의 경계를 표시하는 데 몇 비트를 씁니다. 그래서 13바이트인 Hello, world!는 gzip으로는 33바이트, zlib으로는 21바이트, 원시 deflate로는 15바이트가 됩니다. 결과 아래의 크기 줄은 ‘압축 전 바이트 수’와 ‘압축 후 바이트 수’, 그리고 그 사이의 ‘변화’를 보여 주는데, 이 gzip이라면 +153.8%입니다. 그리고 결과가 커지면 알림이 그 이유를 알려 줍니다. JSON이나 로그처럼 반복이 많은 텍스트는 그 비용을 몇 배로 되찾습니다. 페이지가 열릴 때의 예제인 작은 JSON은 원래 크기의 절반도 안 되게 줄어듭니다.
텍스트로 쓰면 바이트는 다시 더 늘어납니다. Base64는 3바이트마다 4문자를 써서 3분의 1이 늘어나며 — ‘Base64’ 도구의 가이드가 설명하는 대로입니다 — 그래서 gzip의 그 33바이트는 44문자가 됩니다. 16진수는 바이트 하나에 두 자리를 쓰고, 페이지는 그것을 두 자리씩 공백으로 띄워 쓰므로, 같은 33바이트가 98문자가 됩니다. 크기 줄은 이 문자 수도 ‘Base64 문자 수’나 ‘16진수 문자 수’로 세며, ‘데이터 용량 변환’은 그 수 가운데 무엇이든 KB나 KiB로 바꿔 줍니다.
압축: 수준 선택 없음, gzip -c와는 다른 바이트, 그리고 Brotli 없음
압축은 브라우저 자신의 일로, Compression 표준이 정의하는 CompressionStream을 통해 이루어집니다. 그 표준은 압축 수준을 제공하지 않으므로 이 페이지도 제공하지 않습니다. 여러분이 얻는 것은 브라우저의 기본값입니다. gzip -9는 조금 더 작게 나올 수 있지만, 크게 차이 나는 일은 드뭅니다.
둘 다 압축을 풀면 같은 텍스트가 되지만, 바이트도 gzip -c가 쓰는 것과 같지 않습니다. 먼저 헤더가 다릅니다. GNU gzip은 -n을 지정받지 않는 한 파일의 이름과 시각을 저장하고, 파이프에서 온 입력이면 시각을 0으로 씁니다. 헤더의 한 바이트에는 -9나 -1을 썼다는 표시를 기록합니다. 그리고 RFC 1952가 운영체제에 배정한 10번째 바이트에는, Linux에서는 Unix의 번호인 03을 씁니다. 브라우저는 바이트만 건네받으므로, 여러분 파일의 이름도 시각도 결과와 함께 나갈 수 없습니다. Chromium은 이름을 저장하지 않고 시각을 0으로 쓰는데 — 이는 gzip -n이 고르는 것과 같습니다 — 10번째 바이트에는 Linux에서 03을 쓰며, Windows의 Chrome은 거기에 0a를 씁니다. 다음으로 압축 데이터가 다릅니다. GNU gzip의 압축기는 브라우저의 것과 다르므로, 긴 텍스트에서는 같은 수준에서도 둘이 서로 다른 바이트를 고릅니다. 그러니 gzip -c와 일치하지 않는다는 것은 그것만으로는 아무 의미가 없습니다. 중요한 것은 둘 다 압축을 풀면 같은 바이트가 된다는 점입니다.
Brotli는 지금으로서는 여기 없습니다. Compression 표준은 이를 명시하지만 아직 모든 브라우저가 그것을 쓰지는 못하며, 브라우저가 해 줄 때만 페이지가 제공할 수 있는 선택지는 브라우저마다 다른 페이지를 만들고 말 것입니다. zstd는 그 표준에 아예 없습니다. 이 페이지가 읽고 쓰는 것은 세 래퍼에 담긴 DEFLATE이며, 그 밖에는 아무것도 없습니다.
파일이 들어오고 파일이 나가며, 업로드되는 것은 없다
어느 방향이든 텍스트 상자 대신 파일을 쓸 수 있습니다. 파일을 선택하거나 상자에 끌어다 놓으세요. 파일의 바이트는 ‘압축 데이터 표기’도 ‘바이트 표기’도 거치지 않고 있는 그대로 읽힙니다. 텍스트 상자에는 한도가 있지만 여기서 파일에는 한도가 없습니다. 다만 아주 큰 파일에는 브라우저가 감당하지 못할 수도 있다는 경고가 나옵니다. 압축을 풀 때 ‘내려받기’는 gunzip처럼 결과에 이름을 붙입니다. 파일 이름에서 .gz를 떼고, .tgz는 .tar로 바꾸며, .zz나 .deflate도 뗍니다. 이 둘은 이 페이지가 다른 두 래퍼에 붙이는 확장자입니다. 그 밖의 이름과 붙여넣은 것은 모두 bytes.bin으로 나옵니다. 압축할 때는 파일 이름에 .gz, .zz, .deflate 가운데 하나를 붙이고 — .zz는 pigz가 zlib에 붙이는 이름입니다 — 입력한 텍스트라면 text라는 단어에 붙입니다.
‘입력으로 사용’은 결과를 상자로 옮기고 방향을 뒤집으므로, 왕복이 클릭 한 번으로 끝납니다. 파일에서 온 결과나 상자에 담기에 너무 큰 결과는 파일로 옮겨집니다. 어느 방향이든 파일은 이 탭 안에서 읽히고, 작업은 페이지가 여러분 자신의 기기에서 시작하는 Web Worker에서 돌아갑니다. 그러기 위해 페이로드나 파일이 업로드되는 일은 없습니다.
gunzip과 Python으로 같은 일 하기
명령줄에서는 base64 -d가 페이로드를 바이트로 되돌린 뒤, gunzip과 zcat이 이 페이지처럼 모든 멤버까지 포함해 gzip을 읽습니다. 다만 둘 다 zlib이나 원시 deflate는 읽지 않고, gzip이 아니라며 거부합니다. gzip -k는 파일을 압축하고 원본을 그 옆에 남깁니다. Python에서는 gzip.decompress가 gzip의 래퍼와 그 안의 모든 멤버를 읽고, zlib.decompress는 wbits로 어느 래퍼인지 지정받아 세 래퍼 가운데 무엇이든 읽습니다:
# 페이로드: 디코딩한 다음 압축 풀기
printf 'H4sIAAAAAAAAA/NIzcnJ11Eozy/KSVEEAObG5usNAAAA' | base64 -d | gunzip # Hello, world!
# 파일: 원본을 남긴 채 압축한 다음 다시 출력하기
printf 'Hello, world!' > hello.txt
gzip -k hello.txt
zcat hello.txt.gz # Hello, world!
# 멤버 두 개: 두 번째를 첫 번째 뒤에 덧붙이고 하나로 다시 읽기
printf 'Hello, ' | gzip > two.gz
printf 'world!' | gzip >> two.gz
zcat two.gz # Hello, world!
# 같은 일을 스크립트로: 모든 멤버를 읽은 다음 래퍼를 하나씩
import base64, gzip, zlib
two = open('two.gz', 'rb').read()
zl = base64.b64decode('eJzzSM3JyddRKM8vyklRBAAgXgSK')
raw = base64.b64decode('80jNycnXUSjPL8pJUQQA')
gzip.decompress(two) # b'Hello, world!'
zlib.decompress(two, wbits=31) # b'Hello, '
zlib.decompress(zl, wbits=15) # b'Hello, world!'
zlib.decompress(raw, wbits=-15) # b'Hello, world!'
zlib.decompress(zl, wbits=47) # b'Hello, world!'wbits 값 31은 gzip의 래퍼를 요청하고, 기본값인 15는 zlib의 래퍼를, -15는 원시 deflate를 요청합니다. 각각에 들어 있는 15는 가장 큰 창 크기를 뜻하며, 47은 gzip과 zlib의 래퍼 가운데 바이트가 시작하는 쪽을 받아들입니다. 하지만 zlib.decompress는 멤버를 하나만 읽고 나머지는 아무 말 없이 버리므로, 위의 두 멤버에서 b'Hello, '만 돌려줍니다. gzip.decompress는 그 모두를 읽습니다. Python은 이 페이지보다 두 가지 점에서 더 엄격하기도 합니다. gzip.decompress는 끝 뒤에 있는 0이 아닌 바이트를 거부하고, 두 함수 모두 일찍 끝나는 스트림을 거부합니다. 이 페이지라면 나온 것을 보여 주는 경우입니다.
자주 묻는 질문
- 압축한 결과가 왜 입력한 것보다 큰가요?
- 래퍼마다 자체 바이트가 들고, 짧은 텍스트에는 압축으로 덜어 낼 것이 거의 없기 때문입니다. gzip은 18바이트, zlib은 6바이트를 더하고, 거기에 DEFLATE가 블록의 경계를 표시하므로, 긴 JSON은 줄어드는데 몇 단어는 오히려 커집니다. 페이지는 이를 알림으로 알려 줍니다. 셋 가운데 가장 작은 것은 원시 deflate입니다. Base64로 쓰면 어떤 결과든 다시 3분의 1만큼 길어집니다.
- 출력이 왜 gzip -c와 일치하지 않나요?
- 그렇게 된다고 약속하는 것은 아무것도 없습니다. gzip 헤더에는 이름, 시각, 수준의 표시, 운영체제를 나타내는 바이트가 들어갈 수 있는데, GNU gzip과 브라우저는 이를 서로 다르게 채웁니다. 게다가 둘은 서로 다른 압축기이므로, 조금 긴 텍스트에서는 헤더와 트레일러 사이의 데이터까지 달라질 수 있습니다. 한 텍스트의 gzip 스트림 두 개는 각각 압축을 풀어 그 텍스트가 된다면 둘 다 옳습니다. 그러니 압축을 푼 결과를 비교하세요. ‘입력으로 사용’을 누르면 이 페이지의 결과가 곧바로 되돌려집니다.
- 페이로드 맨 앞의 H4sI는 무엇을 뜻하나요?
- 그 페이로드가 Base64로 쓴 gzip이라는 뜻입니다. 모든 gzip 스트림은 1f 8b 08이라는 바이트로 시작하며, 이 3바이트가 Base64로는 H4sI입니다. 있는 그대로 여기에 붙여넣으세요. eJ로 시작하는 페이로드는 기본 수준의 zlib일 가능성이 가장 높고, 둘 중 어느 것으로도 시작하지 않는 것은 원시 deflate이거나 아예 압축되지 않은 것일 수 있는데, 어느 쪽인지는 페이지가 알려 줍니다.
- gzip은 암호화인가요?
- 아니요. 압축에는 키가 없으므로, 바이트를 가진 사람은 누구나 여기서든 gunzip으로든 압축을 풀 수 있고, 모든 바이트가 그대로 돌아옵니다. gzip으로 압축한 페이로드 속의 비밀번호는 그대로 적어 둔 비밀번호만큼 드러나 있습니다.
- 제 페이로드나 파일이 어딘가로 올라가나요?
- 아니요. 붙여넣은 페이로드도, 선택하거나 끌어다 놓은 파일도 모두 이 탭 안에서 읽히며, 거기서 페이지가 시작하는 Web Worker가 압축과 압축 해제를 하고, ‘내려받기’도 바로 그 자리에서 결과로 파일을 만듭니다. 어느 것도 이 사이트나 다른 누구에게도 전송되지 않습니다.