광고
~ / posts / developer

허프만 압축 언제 쓰나, zlib 전략·코드 길이 제한·작은 데이터 손익 계산

허프만만으로는 텍스트가 64~76%까지만 줄고, LZ77을 더해야 24~36%가 돼요. 실제 파일로 잰 zlib 전략·레벨별 크기와 속도, 100바이트 미만이 오히려 커지는 이유, 15비트·11비트 길이 제한의 손실, 직접 구현할 때 점검 순서를 정리했어요.

압축 설정을 고를 때 "허프만이 최적이라니까 그것만 쓰면 되지 않나", "레벨 9가 제일 좋겠지", "작은 JSON도 gzip 하면 줄겠지" 같은 생각이 들기 쉬워요. 결론부터 말하면 허프만은 혼자서는 기호 빈도만 줄이는 마지막 단계라, 실제 압축률은 반복 문자열을 찾는 LZ77 쪽이 대부분 정하고, 100바이트 안팎 데이터는 헤더 때문에 오히려 커질 수 있어요. 직접 허프만을 구현해야 한다면 최적성보다 코드 길이 상한과 비트 순서가 더 자주 문제를 일으켜요.

이 글은 "가장 드문 두 개를 합친다"는 원리 설명은 짧게 줄이고, 그 원리가 설정값과 운영 판단에서 어떤 숫자로 나타나는지를 모았어요. 측정은 Apple M1, 파이썬 3.14.8(내장 zlib 1.2.12, compression.zstd의 zstd 1.5.7)로 직접 했어요(2026-10-30). 재료는 이 사이트 저장소에 실제로 있는 파일(JS 294KB, JSON 150KB, 한국어 마크다운 194KB, PNG 274KB)과 무작위 바이트 300KB예요. 절대 시간은 기기마다 다르니 배수로 읽어 주세요.

광고

허프만 코딩 언제 쓰나, 데이터 모양별 판단표

실무에서 "허프만을 쓸지"는 보통 "어떤 압축기와 설정을 쓸지"의 문제예요. DEFLATE(gzip·zlib·PNG·zip)와 zstd 리터럴 모두 마지막 단계에 허프만을 쓰니, 앞단과 묶어서 고르면 돼요.

데이터 모양고를 것이유(이 글의 측정)
100바이트 미만 메시지압축 안 함, 또는 사전(dictionary) 압축64바이트는 줄지 않고 gzip 은 +18바이트
이미 압축된 바이트(PNG·JPEG·zip·암호문)저장(레벨 0)레벨 6도 99.7%, 시간만 써요
반복 문자열이 많은 텍스트(JSON·JS·HTML·로그)zlib 기본 전략, 또는 zstdLZ77이 압축률의 대부분을 만들어요
반복은 적고 값 분포만 치우친 바이트(필터 거친 이미지 잔차 등)Z_FILTERED·Z_RLE·Z_HUFFMAN_ONLY문자열 탐색 비용을 아껴요
한 기호 확률이 0.9 이상산술 부호화·ANS, 또는 RLE 뒤 허프만허프만은 기호당 최소 1비트
해제기를 극단적으로 단순하게Z_FIXED(고정 허프만)헤더에 표가 없어 해제기가 표를 만들 필요가 없어요
100바이트 미만인지, 이미 압축됐는지, 반복 문자열이 많은지, 한 기호 확률이 0.9를 넘는지, 해제기를 단순하게 둬야 하는지를 차례로 물어 압축 안 함·저장·LZ77+허프만·산술 부호화·고정 코드 중 하나를 고르는 순서도
모두 '아니요'면 zlib 기본값이나 zstd 레벨 3이 무난해요

전략 이름의 뜻은 zlib 공식 매뉴얼의 deflateInit2 설명에 있어요. Z_HUFFMAN_ONLY는 문자열 일치를 쓰지 않고 허프만만 강제하고, Z_RLE은 일치 거리를 1로 제한해 PNG 이미지 데이터에서 Z_HUFFMAN_ONLY만큼 빠르면서 더 잘 줄이도록 설계했다고 적어요. Z_FIXED는 동적 허프만 코드를 막아 단순한 해제기를 쓸 수 있게 해요. 매뉴얼은 전략이 압축률에만 영향을 주고, 잘못 골라도 출력의 정확성에는 영향이 없다고 못 박아요. 그러니 전략은 마음 편히 실험해도 돼요.

허프만만으로 얼마나 줄어드나, 파일 종류별 실측

같은 파일을 허프만만, zlib 레벨 1·6·9, zstd 레벨 3으로 압축해 원본 대비 크기를 쟀어요. 각 방식을 5번 돌려 가장 빠른 값을 적었어요.

파일 종류별 압축 후 크기 (원본 대비, 파이썬 3.14 zlib 1.2.12)
허프만만(Z_HUFFMAN_ONLY)zlib 레벨 6zstd 레벨 3
0255075100JS 294KB · 허프만만(Z_HUFFMAN_ONLY) 71.6%JS 294KB · zlib 레벨 6 24.2%JS 294KB · zstd 레벨 3 25.1%JS 294KBJSON 150KB · 허프만만(Z_HUFFMAN_ONLY) 64.8%JSON 150KB · zlib 레벨 6 26.2%JSON 150KB · zstd 레벨 3 27.4%JSON 150KB한국어 MD 194KB · 허프만만(Z_HUFFMAN_ONLY) 75.6%한국어 MD 194KB · zlib 레벨 6 36.4%한국어 MD 194KB · zstd 레벨 3 38%한국어 MD 194KBPNG 274KB · 허프만만(Z_HUFFMAN_ONLY) 100%PNG 274KB · zlib 레벨 6 99.7%PNG 274KB · zstd 레벨 3 100%PNG 274KB

무작위 300KB는 모든 방식에서 100.0%로 줄지 않았어요.

단위: % · 자료: 직접 측정 (2026-10-30, Apple M1). 원시 DEFLATE(wbits=-15), 헤더 제외
표로 보기
구분허프만만(Z_HUFFMAN_ONLY)zlib 레벨 6zstd 레벨 3
JS 294KB71.624.225.1
JSON 150KB64.826.227.4
한국어 MD 194KB75.636.438
PNG 274KB10099.7100

숫자를 읽는 법은 이래요.

zlib 레벨 1·6·9와 zstd, 시간까지 보면

JS 294KB 기준크기시간
허프만만71.6%9.3ms
zlib 레벨 128.4%8.7ms
zlib 레벨 6 (기본)24.2%28.4ms
zlib 레벨 924.0%57.6ms
zstd 레벨 325.1%3.6ms
zstd 레벨 1920.9%(느림, 생략)

레벨 6에서 9로 올리면 크기는 0.2%p 줄고 시간은 2배가 됐어요. JSON에서도 26.2%→25.6%에 10.3ms→30.0ms였어요. 미리 한 번 압축해 두고 여러 번 내려보내는 정적 파일이면 9가 손해가 아니지만, 요청마다 압축하는 동적 응답이면 6이나 그 아래가 맞아요. 허프만만 쓰는 모드가 레벨 1과 비슷한 시간에 크기는 2.5배라, 텍스트에 Z_HUFFMAN_ONLY를 쓸 이유는 거의 없어요.

작은 데이터 압축 손익, 헤더 비용 계산

허프만을 쓰는 블록은 해제기에게 코드표를 알려 줘야 해요. RFC 1951의 DEFLATE 블록은 세 종류예요.

zlib은 블록을 내보낼 때 세 방식의 크기를 모두 계산해 가장 작은 것을 골라요(zlib trees.c의 _tr_flush_block). 그래서 원시 DEFLATE는 아무리 나빠도 "원본 + 5바이트" 근처에서 멈춰요. 여기에 감싸는 형식이 더해져요. zlib 형식은 앞 2바이트·뒤 Adler-32 4바이트로 6바이트, gzip 은 RFC 1952 기준 앞 10바이트·뒤 CRC-32와 길이 8바이트로 최소 18바이트예요.

JSON 파일의 같은 위치에서 크기만 바꿔 잘라 압축해 봤어요.

JSON 조각 크기별 압축 결과 (바이트, zlib 레벨 6)
원본원시 DEFLATEgzip고정 허프만 강제
02004006008001,0001,20032B · 원본 32바이트32B · 원시 DEFLATE 34바이트32B · gzip 52바이트32B · 고정 허프만 강제 34바이트32B64B · 원본 64바이트64B · 원시 DEFLATE 64바이트64B · gzip 82바이트64B · 고정 허프만 강제 64바이트64B128B · 원본 128바이트128B · 원시 DEFLATE 91바이트128B · gzip 109바이트128B · 고정 허프만 강제 92바이트128B256B · 원본 256바이트256B · 원시 DEFLATE 131바이트256B · gzip 149바이트256B · 고정 허프만 강제 143바이트256B512B · 원본 512바이트512B · 원시 DEFLATE 202바이트512B · gzip 220바이트512B · 고정 허프만 강제 241바이트512B1KB · 원본 1,024바이트1KB · 원시 DEFLATE 339바이트1KB · gzip 357바이트1KB · 고정 허프만 강제 433바이트1KB

1KB 이상에서는 동적 블록이 고정보다 약 22~25% 작았어요.

단위: 바이트 · 자료: 직접 측정 (2026-10-30). zlib 이 고른 블록은 64B까지 고정, 128B부터 동적
표로 보기
구분원본원시 DEFLATEgzip고정 허프만 강제
32B32345234
64B64648264
128B1289110992
256B256131149143
512B512202220241
1KB1,024339357433

이 숫자로 정하면 이래요.

크기를 바이트·KB 단위로 바꿔 가며 볼 때는 데이터 용량 변환기를 쓰면 편해요.

코드 길이 제한 비용, 15비트·11비트를 넘는 경우 직접 계산

교과서 허프만에는 코드 길이 상한이 없지만, 실제 포맷에는 있어요. DEFLATE는 리터럴·거리 코드를 최대 15비트로, RFC 8878(Zstandard)은 허프만 코드를 최대 11비트로 제한해요. RFC 8878은 비트가 많을수록 정확하지만 헤더가 커지고 메모리나 복호화 비용이 늘어난다고 이유를 적어요.

언제 상한을 넘나

트리가 가장 깊어지는 분포는 빈도가 피보나치 수열(1, 1, 2, 3, 5, 8 …)을 따를 때예요. 직접 계산하면 이래요.

극단적인 경우만의 이야기도 아니에요. 이 저장소의 JS 파일 바이트 빈도로 상한 없는 허프만을 만들면 최장 17비트, 한국어 마크다운은 16비트가 나왔어요. 드물게 한두 번 나오는 바이트가 있으면 금방 깊어져요. 그래서 인코더는 상한 처리를 반드시 가져야 해요. zlib은 gen_bitlen에서 넘친 잎을 15로 자르고 짧은 잎 하나를 한 단 내려 길이 분포를 다시 맞추는 빠른 근사를 쓰고, 최적 해를 원하면 package-merge 알고리즘을 써요.

상한이 압축률에 주는 손실

package-merge로 상한마다 최적 길이를 구해 상한 없는 허프만과 총 비트를 비교했어요.

코드 길이 상한별 손실 (상한 없는 허프만 대비 증가율)
JS 294KB한국어 MD 194KB
0246815비트12비트11비트10비트9비트8비트15비트 · JS 294KB 0.001%12비트 · JS 294KB 0.066%11비트 · JS 294KB 0.216%10비트 · JS 294KB 0.728%9비트 · JS 294KB 2.2%8비트 · JS 294KB 7.5%7.515비트 · 한국어 MD 194KB 0.001%12비트 · 한국어 MD 194KB 0.031%11비트 · 한국어 MD 194KB 0.105%10비트 · 한국어 MD 194KB 0.342%9비트 · 한국어 MD 194KB 1.2%8비트 · 한국어 MD 194KB 5.2%5.2

11비트까지는 0.2% 안쪽, 9비트 아래부터 손실이 빠르게 커져요.

단위: % · 자료: 직접 계산 (2026-10-30). 바이트 빈도, package-merge 최적 길이
표로 보기
구분JS 294KB한국어 MD 194KB
15비트0.0010.001
12비트0.0660.031
11비트0.2160.105
10비트0.7280.342
9비트2.21.2
8비트7.55.2

판단은 간단해요. 15비트와 11비트 상한의 압축률 비용은 무시해도 될 수준이에요. 상한을 정하는 진짜 이유는 해제 쪽 메모리와 속도예요.

해제기 조회 표 크기

해제기는 보통 비트 몇 개를 한 번에 읽어 표에서 기호를 찾아요. 한 단계 표로 최장 코드까지 덮으면 항목이 2의 상한제곱 개 필요해요. zlib의 표 항목 하나는 4바이트(op·bits 각 1바이트, val 2바이트)라서 이렇게 계산돼요.

표 구성항목 수메모리(항목 4바이트)
15비트 한 단계 표32,768128KiB
11비트 한 단계 표2,0488KiB
zlib inflate (첫 표 9비트·6비트 + 2단계)최대 1,444약 5.6KiB

zlib의 inftrees.h는 리터럴/길이 852개, 거리 592개, 합 1,444개가 동적 표의 최대 크기라고 적어요. zlib 설계 문서 algorithm.txt는 첫 표를 9비트로 둔 이유를 설명해요. DEFLATE는 수십 KB마다 표를 새로 만드는데, 2¹⁵ 항목 표를 매번 채우면 해제보다 표 채우기에 시간이 더 든다는 거예요. 직접 해제기를 만든다면 "짧은 코드는 한 번에, 긴 코드는 두 번째 표로"가 기본 설계예요.

치우친 분포의 한계, 기호 묶기와 산술 부호화·ANS

허프만은 기호마다 정수 비트를 줘서 기호당 최소 1비트가 들어요. 0과 1 두 기호만 있는 비트열에서 이 바닥이 얼마나 비싼지 계산해 봤어요. 기호 k개를 묶어 2ᵏ개짜리 알파벳으로 허프만을 만드는 흔한 우회법도 같이 넣었어요.

0의 확률엔트로피그대로(k=1)2개 묶음4개 묶음8개 묶음
0.90.469비트1.0000.6450.4930.476 (최장 24비트)
0.990.081비트1.0000.5150.2730.157 (최장 38비트)

단위는 원래 기호 하나당 비트예요. 묶으면 엔트로피에 가까워지지만 두 가지 대가가 있어요. 알파벳이 2⁸=256개로 커져 표가 커지고, 최장 코드가 24~38비트로 길어져 15비트나 11비트 상한에 바로 걸려요. 확률 0.99에서는 8개를 묶어도 엔트로피의 약 2배예요.

그래서 이런 데이터는 다른 길로 가요.

반대로 바이트 분포가 그리 치우치지 않은 일반 텍스트에서는 앞의 측정처럼 허프만이 엔트로피와 0.5%p 안쪽이라, 산술 부호화로 바꿔 얻을 몫이 작아요.

광고
광고

직접 구현할 때 자주 나는 문제와 확인 순서

포맷을 직접 읽고 쓰는 일은 드물지만, 임베디드 해제기·커스텀 바이너리 프로토콜·학습용 구현에서는 생겨요. 증상별로 이 순서로 보세요.

비트 순서, 기호가 하나뿐인 입력, 헤더에 길이를 싣는지, 최대 길이 상한, 크라프트 합, zlib 과 양방향 왕복 시험을 차례로 확인하는 여섯 단계 점검표
1~3은 풀리지 않을 때, 4~5는 가끔 깨질 때, 6은 고친 뒤 검증이에요
  1. 비트 순서. RFC 1951 3.1.1절은 일반 데이터 요소는 바이트의 최하위 비트부터 채우지만, 허프만 코드는 코드의 첫 비트부터 담는다고 적어요. 길이 계산은 맞는데 gzip이 못 푼다면 대개 여기예요. 비트를 손으로 확인할 때는 진법 변환기로 바이트를 2진수로 펼쳐 보면 빨라요.
  2. 기호가 하나뿐인 입력. 합치기가 한 번도 일어나지 않아 길이 0이 나와요. 0으로 채운 버퍼, 같은 문자만 반복되는 로그에서 터져요. 1비트를 강제로 주세요.
  3. 헤더에 무엇을 싣나. 빈도가 같은 노드를 어떤 순서로 합치느냐에 따라 트리 모양이 달라져요. "빈도만 보내고 양쪽에서 트리를 다시 만든다"는 설계는 동점 처리 하나만 달라도 깨져요. 길이를 싣고 정규 코드로 다시 만드세요.
  4. 상한을 넘는 기호. 작은 테스트 입력에서는 절대 안 나와요. 피보나치 빈도로 만든 입력(빈도 합 4,180 이상)을 테스트에 넣어 두세요.
  5. 크라프트 합. 길이 목록에서 2^(-길이)를 모두 더하면 정확히 1이어야 빈틈없는 접두 코드예요. zlib의 inflate_table도 넘치거나(over-subscribed) 모자란(incomplete) 길이 목록을 거절해요. 단, 거리 코드가 하나뿐인 경우처럼 RFC가 허용하는 예외는 따로 처리해요. 해제기가 이 검사를 빼면 손상된 입력에서 표 바깥을 읽어요.
  6. 양방향 왕복 시험. 내 인코더 출력을 zlib으로 풀고, zlib 출력을 내 해제기로 푸는 시험을 무작위 입력 수천 개로 돌려요. 한쪽만 하면 대칭으로 틀린 버그(인코더와 해제기가 똑같이 비트를 뒤집는 등)를 놓쳐요.
import random, zlib

def roundtrip(my_inflate, trials=3000):
    rnd = random.Random(0)
    for t in range(trials):
        n = rnd.choice([0, 1, 2, 17, 300, 5000])
        alphabet = rnd.choice([b"a", b"ab", bytes(range(256))])
        data = bytes(rnd.choice(alphabet) for _ in range(n))
        level = rnd.choice([0, 1, 6, 9])
        strategy = rnd.choice([0, zlib.Z_HUFFMAN_ONLY, zlib.Z_RLE, zlib.Z_FIXED])
        c = zlib.compressobj(level, zlib.DEFLATED, -15, 8, strategy)
        raw = c.compress(data) + c.flush()
        if my_inflate(raw) != data:
            return t, level, strategy, data   # 처음 틀린 입력을 돌려줘요
    return None

입력 길이에 0·1을, 알파벳에 한 글자짜리를, 전략에 Z_FIXED를 섞은 것이 핵심이에요. 저장·고정·동적 세 블록 종류와 기호 하나 경우를 모두 지나가요.

도입 전에 따질 것, 운영에서 압축을 켤 때

광고
광고

자주 묻는 질문

Z_HUFFMAN_ONLY는 언제 쓰나요?

반복 문자열은 거의 없고 값 분포만 치우친 데이터에서 문자열 탐색 시간을 아끼고 싶을 때예요. 일반 텍스트에서는 이번 측정에서 레벨 1과 비슷한 시간에 크기가 2.5배라 쓸 이유가 없어요. 이미지 필터 잔차처럼 짧은 반복이 있으면 Z_RLE도 같이 재 보세요.

작은 JSON 응답도 gzip 하는 게 좋나요?

약 100바이트 미만은 하지 않는 게 나아요. 64바이트 조각이 원시 DEFLATE로도 64바이트, gzip으로는 82바이트가 됐어요. 128바이트부터 줄기 시작하고 1KB면 약 3분의 1이 되니, 최소 압축 크기를 수백 바이트 근처로 두면 돼요.

압축 레벨 9가 항상 좋은가요?

크기 차이는 작고 시간은 커요. JS 294KB에서 레벨 6 대비 0.2%p 줄고 시간은 2배였어요. 한 번 압축해 여러 번 내려보내는 정적 파일에만 쓰고, 요청마다 압축하는 응답에는 6 이하를 쓰세요.

코드 길이를 15비트로 제한하면 압축률이 많이 떨어지나요?

거의 안 떨어져요. 실제 파일 바이트 빈도로 계산하니 15비트 상한의 손실은 0.001%, zstd의 11비트도 0.1~0.2%였어요. 손실은 9비트 아래에서 1~7%로 커져요. 상한은 압축률보다 해제기 표 크기와 속도를 위한 거예요.

허프만 대신 산술 부호화를 써야 하는 경우는 언제인가요?

한 기호 확률이 0.9를 넘는 식으로 분포가 크게 치우칠 때예요. 확률 0.99인 비트열에서 허프만은 기호당 1비트로 엔트로피의 약 12배를 쓰고, 8개씩 묶어도 약 2배예요. 일반 텍스트처럼 분포가 넓으면 허프만도 엔트로피와 0.5%p 안쪽이라 바꿀 이득이 작아요.

직접 만든 DEFLATE 인코더가 gzip으로 풀리지 않아요.

먼저 비트 순서를 보세요. 허프만 코드는 첫 비트부터, 나머지 필드는 최하위 비트부터 담아요. 그다음 기호 하나뿐인 블록의 길이 0, 15비트를 넘는 길이, 크라프트 합이 1이 아닌 길이 목록을 차례로 확인하세요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 허프만 코딩 원리 — 그리디 최적 증명과 DEFLATE 정규 코드에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#허프만 코딩 실무#zlib 압축 전략#Z_HUFFMAN_ONLY#DEFLATE 고정 허프만#코드 길이 제한#작은 데이터 압축#gzip 헤더 크기#엔트로피 코딩 선택
← 이전 글타입스크립트 타입 좁히기 고르는 법, 타입 가드 판단표와 안 될 때 점검다음 글 →rope_theta 값 고르는 법, 목표 길이별 하한과 장문 지속학습 비용
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →