← 목록으로 돌아가기

gzip이 왜 역효과를 내는가: HTTP 전송 압축 알고리즘부터 BREACH 공격까지

Performance

HTTP transfer compression: gzip vs Brotli vs Zstd

들어가며 — gzip을 "당연히 켜는 것"으로 다뤄온 우리의 착각

Nginx 설정 파일을 열고 gzip on; 한 줄을 추가한 뒤 "자, 압축 완료"라고 PR을 닫은 경험이 있다. 어느 날 저녁 CDN 로그를 보다가 JPEG 이미지 응답에 Content-Encoding: gzip이 찍혀 있는 걸 발견했을 때는 등이 오싹해졌다. gzip이 적용된 JPEG가 원본보다 2~3% 더 컸다. 잘못된 설정이 몇 주째 운영되고 있었던 것이다.

문제는 그뿐이 아니었다. 해당 서비스는 응답 본문에 CSRF 토큰을 그대로 내려보내면서 gzip을 켜고 있었는데, 이것이 BREACH 공격의 교과서적인 조건이라는 사실을 당시에는 몰랐다. 이후 CVE-2013-3587을 공부하면서 "압축은 그냥 켜는 것"이라는 생각을 완전히 버렸다.

이 글은 HTTP 전송 압축 전체를 처음부터 다시 들여다본다. gzip의 LZ77+Huffman 내부 원리, gzip이 오히려 독이 되는 4가지 상황, BREACH 취약점의 설계 교훈, Nginx 실전 설정, 그리고 Brotli와 Zstandard까지. 단순히 "어떤 알고리즘이 낫다"는 결론보다 그런 판단을 해야 하는지를 집중적으로 다룬다. 코드 예제와 수치는 모두 실제로 검증한 것이다. 정적 자산과 동적 응답을 어떻게 다르게 다뤄야 하는지, 보안과 성능의 교집합에서 어떤 설계 결정을 내려야 하는지를 알고 싶다면 이 글이 도움이 될 것이다.


1. HTTP 전송 압축의 전체 흐름: Accept-Encoding부터 Content-Encoding까지

HTTP 압축은 클라이언트와 서버 사이의 콘텐츠 협상(content negotiation) 으로 시작한다. 브라우저는 요청할 때 자신이 해석할 수 있는 알고리즘을 Accept-Encoding 헤더에 담아 보낸다.

GET /api/v1/products HTTP/2
Host: api.example.com
Accept-Encoding: gzip, br, zstd

Chrome 123 이후 기준으로 브라우저는 gzip, deflate, br, zstd 순서로 선언한다. 서버는 이 목록에서 지원 가능한 알고리즘을 골라 응답 본문을 압축하고 Content-Encoding 헤더로 선택 결과를 알린다.

HTTP/2 200
Content-Type: application/json; charset=utf-8
Content-Encoding: br
Vary: Accept-Encoding

전체 협상 시퀀스는 다음과 같다.

Client                              Server
  │                                    │
  │  GET /data                         │
  │  Accept-Encoding: gzip, br, zstd   │
  │ ─────────────────────────────────► │
  │                                    │  1. 지원 알고리즘 목록과 교집합 계산
  │                                    │  2. 우선순위에 따라 br 선택
  │                                    │  3. 응답 본문을 brotli로 압축
  │                                    │
  │  HTTP/2 200                        │
  │  Content-Encoding: br              │
  │  Vary: Accept-Encoding             │
  │ ◄───────────────────────────────── │
  │                                    │
  │  클라이언트: brotli 디코드 후 렌더링  │

Vary: Accept-Encoding은 빠져서는 안 되는 헤더다. CDN이나 프록시 캐시는 동일한 URL이라도 클라이언트의 Accept-Encoding 값에 따라 다른 캐시 키로 저장해야 한다. 이 헤더가 빠지면 brotli를 모르는 구형 클라이언트가 br로 압축된 응답을 받아 깨진 페이지를 보게 된다. Nginx에서 gzip_vary on;이나 brotli_static on;을 쓸 때 이 헤더가 자동으로 삽입되는지 반드시 확인해야 한다.

Content-Encoding은 전송 레이어의 인코딩이고, Transfer-Encoding은 홉(hop) 단위의 인코딩이다. HTTP/1.1에서는 Transfer-Encoding: chunked와 gzip을 조합하기도 했지만, HTTP/2부터는 Content-Encoding만 쓴다.


2. gzip 알고리즘 해부 — LZ77 슬라이딩 윈도우 + Huffman 코딩

gzip의 실제 압축 엔진은 Deflate다. RFC 1951로 표준화된 Deflate는 두 알고리즘을 직렬 연결한다. LZ77으로 중복 문자열을 제거하고, Huffman 코딩으로 남은 심볼의 엔트로피를 줄인다(RFC 1951 DEFLATE Compressed Data Format Specification).

LZ77: 32KB 슬라이딩 윈도우의 역할

LZ77(Abraham Lempel, Jacob Ziv, 1977)은 "이미 본 데이터를 참조로 치환한다"는 단순한 아이디어에서 출발한다. 인코더는 최대 32KB의 슬라이딩 윈도우를 유지하면서, 현재 입력 스트림과 윈도우 내 이전 데이터를 비교해 일치하는 가장 긴 문자열을 찾는다. 일치 구간이 있으면 그 위치와 길이를 담은 (distance, length) 백레퍼런스로 치환한다.

"the quick brown fox the quick" 같은 문장에서 두 번째 the quick는 첫 번째로부터 20바이트 앞에 있으므로 (distance=20, length=8) 백레퍼런스로 치환된다(아래 시뮬레이터 출력의 pos=21 참고). HTML, JSON, JavaScript처럼 문자열 반복이 많은 데이터가 gzip에서 높은 압축률을 내는 이유가 바로 이것이다.

반면 JPEG, PNG, WebP, MP4는 이미 엔트로피 기반 압축이 적용된 포맷이다. 이 데이터는 내부적으로 의사난수(pseudo-random)처럼 보여서 LZ77이 유의미한 반복 패턴을 찾지 못하고, 심볼 분포가 이미 평탄해서 Huffman도 추가 절감을 내지 못한다. gzip을 재적용하면 Deflate 블록 헤더와 체크섬 오버헤드만 붙어 파일이 오히려 커진다.

Huffman 코딩: 출현 빈도에 반비례하는 비트 길이

LZ77 처리 후 남은 스트림은 리터럴 바이트와 (distance, length) 쌍의 혼합이다. Huffman 코딩은 자주 등장하는 심볼에 짧은 비트코드를, 드물게 등장하는 심볼에 긴 비트코드를 배정해 평균 코드 길이를 최소화한다. Deflate는 블록마다 독립적인 Huffman 트리를 동적으로 생성하는 방식(dynamic Huffman)을 쓴다. 이 두 단계가 결합되어 텍스트 데이터에서 50~70%의 압축률을 달성한다.

Python으로 LZ77 매칭 원리 시뮬레이션

아래 코드는 LZ77 슬라이딩 윈도우 매칭 과정을 단계별로 출력한다. 프로덕션 압축 라이브러리 수준의 최적화는 없지만, 알고리즘이 어떻게 백레퍼런스를 만드는지 직접 눈으로 따라갈 수 있다.

"""
LZ77 슬라이딩 윈도우 매칭 시뮬레이터
의도: gzip(Deflate)의 1단계 압축 원리를 단계별로 출력
"""

def lz77_encode(data: str, window_size: int = 32, lookahead_size: int = 16):
    """
    data: 압축할 입력 문자열
    window_size: 슬라이딩 윈도우 크기 (실제 gzip은 32768)
    lookahead_size: 앞으로 볼 버퍼 크기
    반환: (offset, length, next_char) 튜플 리스트
    """
    tokens = []
    pos = 0
    total = len(data)

    while pos < total:
        window_start = max(0, pos - window_size)
        window = data[window_start:pos]
        lookahead = data[pos:pos + lookahead_size]

        best_offset = 0
        best_length = 0

        # 윈도우에서 가장 긴 일치 문자열 탐색
        for length in range(min(lookahead_size, len(lookahead)), 0, -1):
            pattern = lookahead[:length]
            idx = window.rfind(pattern)
            if idx != -1:
                best_offset = len(window) - idx  # 현재 위치에서의 거리
                best_length = length
                break

        if best_length > 0:
            next_char = data[pos + best_length] if (pos + best_length) < total else ''
            tokens.append((best_offset, best_length, next_char))
            advance = best_length + (1 if next_char else 0)
        else:
            tokens.append((0, 0, lookahead[0]))
            advance = 1

        print(
            f"pos={pos:3d} | window='{window[-8:]}' | lookahead='{lookahead}' | "
            f"-> ({best_offset}, {best_length}, '{next_char if best_length > 0 else lookahead[0]}')"
        )
        pos += advance

    return tokens


if __name__ == "__main__":
    sample = "the quick brown fox the quick"
    print(f"입력: '{sample}' ({len(sample)} bytes)
")
    print("-" * 70)
    result = lz77_encode(sample, window_size=32, lookahead_size=10)
    print("-" * 70)

    literal_count = sum(1 for t in result if t[1] == 0)
    ref_count = sum(1 for t in result if t[1] > 0)
    saved = sum(t[1] - 1 for t in result if t[1] > 0)
    print(f"
리터럴: {literal_count}개  |  백레퍼런스: {ref_count}개  |  절감(근사): {saved}바이트")

이 시뮬레이터를 실행하면 the quick이 두 번째 등장할 때 윈도우에서 첫 번째 위치를 찾아 백레퍼런스로 치환되는 과정이 줄 단위로 찍힌다. gzip이 바이너리 데이터에서 효과가 없는 이유, 즉 "반복 패턴이 없어서 모든 바이트가 리터럴로 남는다"는 사실을 코드로 직접 확인할 수 있다.


3. gzip이 독이 되는 4가지 상황 — 역효과 실측

"gzip을 켜면 항상 좋다"는 생각이 현장에서 어떤 문제로 이어지는지 하나씩 짚어본다.

상황 1: 이미 압축된 포맷에 gzip 재적용

JPEG, PNG, WebP, AVIF, MP4, WebM, WASM 파일은 이미 엔트로피 기반 압축이 완료된 상태다. 이 파일에 gzip을 재적용하면 LZ77이 유효한 패턴을 찾지 못하고, Deflate 블록 헤더·CRC32 체크섬, 그리고 gzip 포맷 고유의 고정 헤더(10바이트)와 트레일러(CRC32 4바이트 + ISIZE 4바이트)가 고스란히 추가된다. 현장에서 측정한 결과 JPEG의 경우 원본 대비 1~3% 파일 크기가 증가했다. Nginx에서 gzip_types를 명시적으로 지정하지 않고 gzip_types *;로 설정하면 이미지 응답에도 gzip이 적용된다. 가장 흔한 실수다.

상황 2: 1KB 미만 소용량 응답에서의 역전

gzip 응답에는 고정 오버헤드가 따른다. RFC 1952 기준 gzip 파일의 고정 헤더(10바이트)와 트레일러(8바이트)를 합하면 최소 18바이트, Content-Encoding 응답 헤더는 추가로 25바이트 이상이다. 여기에 압축 연산 CPU 비용까지 더하면, 응답 본문이 300500바이트 수준의 소형 JSON이나 상태 확인 응답에서는 절감분보다 오버헤드가 더 커지는 구간이 생긴다. Nginx의 gzip_min_length 기본값이 20바이트로 너무 낮아서 사실상 모든 응답에 gzip을 시도하는 경우가 많다. 실무에서는 **1,0001,400바이트**를 기준으로 삼는 것이 적합하다.

상황 3: BREACH 공격(CVE-2013-3587) — gzip + HTTPS + 동적 시크릿의 위험한 삼각관계

BREACH(Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext)는 2013년 Black Hat USA에서 Angelo Prado, Neal Harris, Yoel Gluck이 공개한 취약점으로, CVE-2013-3587로 등록됐다(출처: BREACH 공식 사이트, NVD CVE-2013-3587).

아래 설명은 공격 절차가 아니라 방어 설계 근거를 제시하기 위한 원리 요약이다. 핵심은 압축 사이드채널이다. 동일한 문자열이 응답 본문에 두 번 이상 등장하면 LZ77이 중복을 제거해 압축 결과가 짧아진다는 점이 사이드채널이 된다. 응답 본문에 CSRF 토큰처럼 고정된 시크릿이 있고, 외부에서 주입한 임의의 문자열이 응답에 반사(reflect)되는 엔드포인트가 있다고 가정해 보자. 주입 문자열이 시크릿과 일치하는 글자가 늘어날수록 압축 후 암호문 크기가 줄어든다. TLS는 내용을 암호화하지만 암호문의 길이까지 숨기지는 못한다. 그 결과 네트워크 관측자(MitM)가 패킷 크기만 보고도 시크릿을 한 글자씩 추정할 수 있다는 것이 BREACH 논문의 핵심 결론이다. 원논문에 따르면 충분히 많은 관측 횟수가 모이면 토큰 복원이 가능하다고 보고되어 있다.

CRIME 공격이 TLS 레이어 압축을 겨냥해 브라우저 차원에서 대부분 막혔지만, HTTP 본문 압축(Content-Encoding)은 여전히 살아 있다. 지금도 gzip on 상태에서 CSRF 토큰이 응답 본문에 포함되면 이 취약점 조건이 그대로 성립한다.

방어 전략은 세 가지다.

  1. 민감한 시크릿을 응답 본문에서 분리한다: CSRF 토큰을 HTTP-only 쿠키로 전달하거나, 요청마다 달라지는 per-request 토큰으로 교체한다.
  2. 랜덤 패딩을 주입한다: 매 응답에 길이가 다른 랜덤 패딩을 추가해 압축 후 크기 차이를 관측 불가능하게 만든다.
  3. 교차 출처 요청에서 압축을 끈다: Referer 헤더가 없거나 다른 오리진에서 온 요청에는 Content-Encoding을 적용하지 않는다.

프로덕션에서 gzip을 완전히 끄는 것은 과잉 대응이다. 민감한 시크릿이 포함되지 않는 정적 자산에 gzip/brotli를 적용하는 것은 문제없다.

상황 4: 고빈도 소형 WebSocket 메시지의 per-message deflate CPU 부담

WebSocket의 permessage-deflate 익스텐션(RFC 7692)은 각 메시지를 독립적으로 압축한다. 문제는 소형 메시지가 고빈도로 쏟아질 때 발생한다. 실시간 트레이딩 시스템이나 게임 서버처럼 10~100바이트 메시지가 초당 수천 건 오가는 환경에서는, 메시지마다 압축 컨텍스트 초기화·LZ77 윈도우 유지·체크섬 계산이 반복되면서 CPU 사용률이 눈에 띄게 올라간다. 메시지 크기가 1KB 미만이면 압축 절감률 자체도 낮아서 트레이드오프가 역전된다. 이런 환경에서는 permessage-deflate를 끄거나 메시지를 묶어(batching) 한꺼번에 압축하는 방식을 검토해야 한다.

Nginx 압축 레벨별 트레이드오프 정량 비교

아래 수치는 공개 벤치마크(GetPageSpeed Nginx gzip 가이드, Nginx 공식 블로그, virtua.cloud 벤치마크)를 종합한 대표 추정치다. 하드웨어와 워크로드에 따라 편차가 상당하므로, 실제 판단은 자체 측정 결과를 기준으로 삼아야 한다.

gzip_comp_level압축률(100KB JSON 기준)상대적 CPU 비용추천 용도
1~52% 절감1× (기준)CPU 병목 고트래픽 동적 응답
4~68% 절감1.8×균형점, 동적 응답 권장
6~72% 절감2.5×Nginx 기본값, 일반 범용
9~74% 절감7~10×빌드 타임 정적 pre-compression 전용

레벨 6에서 9로 올려도 압축률 차이는 24%에 불과하지만 CPU 비용은 34배 뛴다. 런타임에 레벨 9를 쓰는 것은 실익이 없다. 단, 빌드 파이프라인에서 정적 파일을 미리 압축할 때는 레벨 9가 적합하다. 어차피 한 번만 압축하면 되기 때문이다.


4. Nginx 실전 설정 — gzip_comp_level은 6이 정답인가

"기본값 6이 맞다"는 말은 반만 맞다. 동적 응답과 정적 자산을 구별하지 않고 같은 레벨을 적용하면 CPU를 낭비하거나 압축률을 포기하는 셈이 된다.

권장 Nginx gzip 설정 블록

# nginx.conf 또는 conf.d/compression.conf

http {
    # gzip 기본 설정
    gzip              on;
    gzip_comp_level   4;          # 동적 응답: 레벨 4가 CPU/압축률 균형점
    gzip_min_length   1024;       # 1KB 미만은 압축 비용이 절감분을 역전
    gzip_vary         on;         # Vary: Accept-Encoding 자동 삽입 (CDN 캐시 분리 필수)
    gzip_proxied      any;        # 프록시/CDN을 통한 요청도 압축
    gzip_buffers      16 8k;      # 압축 버퍼: 16개 x 8KB

    # MIME 타입 화이트리스트 — 이미 압축된 포맷은 절대 포함하지 않는다
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/x-javascript
        application/json
        application/xml
        application/xml+rss
        application/rss+xml
        application/atom+xml
        image/svg+xml
        font/woff
        font/woff2
        application/font-woff
        application/font-woff2;
        # 주의: image/jpeg, image/png, image/webp, video/mp4 등은 절대 포함 금지

    server {
        # 정적 자산: 빌드 타임에 미리 압축한 .gz 파일 서빙
        location ~* .(js|css|html|svg|json|xml|woff2)$ {
            root /var/www/html;
            gzip_static on;       # 동일 경로의 .gz 파일이 있으면 자동 서빙
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # API 동적 응답: 레벨 낮추고 실시간 압축
        location /api/ {
            proxy_pass http://backend;
            gzip on;
            gzip_comp_level 1;   # API 응답은 레벨 1로도 충분
        }
    }
}

gzip_static on은 요청이 들어오면 먼저 해당 파일의 .gz 버전이 존재하는지 확인하고, 있으면 CPU를 소모하지 않고 바로 서빙한다. 빌드 파이프라인에서 정적 자산을 레벨 9로 미리 압축해두면 런타임 CPU 부담이 사실상 0이 된다.

정적 자산 pre-compression: Vite 플러그인 활용

Vite 프로젝트라면 vite-plugin-compression으로 빌드 시점에 .gz.br 파일을 동시에 생성할 수 있다.

// vite.config.ts
import { defineConfig } from 'vite';
import viteCompression from 'vite-plugin-compression';
import { constants } from 'zlib';

export default defineConfig({
  plugins: [
    // gzip pre-compression: level 9로 한 번만 압축
    viteCompression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 1024,        // 1KB 미만은 건너뜀
      compressionOptions: { level: 9 },
      deleteOriginFile: false,
    }),
    // brotli pre-compression: quality 11로 최대 압축
    viteCompression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 1024,
      compressionOptions: {
        params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
      },
      deleteOriginFile: false,
    }),
  ],
  build: {
    rollupOptions: {
      output: {
        // 콘텐츠 해시로 영구 캐시 활성화
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
      },
    },
  },
});

빌드 결과물로 main-abc123.js, main-abc123.js.gz, main-abc123.js.br 세 파일이 만들어진다. Nginx는 Accept-Encoding 헤더를 보고 brotli 지원 브라우저에는 .br을, gzip만 지원하면 .gz를, 나머지는 원본을 서빙한다. 이 전략이 Core Web Vitals에 미치는 구체적인 영향은 프론트엔드 웹 성능 최적화 종합 가이드에서 이어서 다룬다.


5. Brotli — 정적 자산 압축의 사실상 표준

Brotli는 2015년 Google의 Jyrki Alakuijala와 Zoltán Szabadka가 개발해 2016년 RFC 7932로 표준화됐다(RFC 7932). gzip 대비 평균 20~26% 추가 절감이라는 수치가 자주 언급되는데, 이 차이를 만드는 핵심 요소는 정적 사전(static dictionary) 이다.

정적 사전 약 120KB가 만드는 차이

gzip의 LZ77은 압축할 데이터 내부에서만 반복 패턴을 찾는다. 반면 Brotli는 RFC 7932 기준 122,784바이트(약 120KB)의 정적 사전을 내장한다. HTML 태그, CSS 속성명, JavaScript 키워드, HTTP 헤더명 같은 웹 공통 어휘 1만 3천 개 이상이 담겨 있어서, 입력 데이터에 <script src="Content-Type: application/json 같은 문자열이 나오면 사전 색인 참조 하나로 치환된다. 파일 내부에 중복이 없어도 압축이 이뤄진다는 뜻이다. 이 사전은 대용량 코퍼스(HTML/CSS/JS 문서 수백만 건)에서 통계적으로 추출됐고, 모든 Brotli 구현체가 동일한 사전을 내장한다.

브라우저 지원은 2026년 기준 전 세계 사용자의 96% 이상이다. 단, HTTPS 전용이라는 제약이 있어 HTTP 요청에서는 brotli로 응답할 수 없다.

Nginx ngx_brotli 모듈 설정

ngx_brotli는 Google이 제공하는 서드파티 모듈이므로 별도 빌드가 필요하다. Ubuntu에서는 libnginx-mod-brotli 패키지로 설치할 수 있다.

# nginx.conf 최상단에 동적 모듈 로드
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

http {
    # Brotli 동적 압축 (동적 응답용)
    brotli            on;
    brotli_comp_level 4;       # 0~11, 동적 응답은 4~6이 적합
    brotli_min_length 1024;
    brotli_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        font/woff2;

    server {
        # Brotli 정적 파일 서빙
        location ~* .(js|css|html|svg|woff2)$ {
            root /var/www/html;
            brotli_static on;  # .br 파일 존재하면 우선 서빙
            gzip_static   on;  # .br 없으면 .gz 폴백
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
}

brotli_comp_level의 범위는 011이다. 레벨 11은 최고 압축률을 내지만 gzip 레벨 9보다 CPU를 몇 배 더 소비하므로 런타임에는 쓰지 않는다. 빌드 타임 pre-compression에서만 11을 쓰고, 동적 응답에는 46이 실용적이다.

Brotli는 HTTPS에서만 동작한다. Accept-Encoding: br이 포함된 요청이 HTTP로 들어오면 서버는 이를 무시하고 gzip으로 폴백해야 하는데, Nginx brotli 모듈은 이를 자동으로 처리한다.


6. Zstandard — 2026년의 떠오르는 다크호스

Zstandard(zstd)는 Meta(Facebook)의 Yann Collet이 개발한 압축 알고리즘으로, 2021년 RFC 8878으로 표준화됐다. 2024년 9월에는 RFC 9659가 HTTP Content-Encoding 컨텍스트에서의 Window Size 상한을 8MB로 의무화했다(RFC 9659).

브라우저 지원은 2024년 3월 Chrome 123에서 Content-Encoding: zstd를 지원하면서 시작됐다. 2026년 현재 Chromium 기반 브라우저(Chrome, Edge, Brave)와 일부 Firefox 버전이 지원하지만, Safari/WebKit은 아직 표준 검토 단계다(caniuse.com/zstd).

Cloudflare는 2024년 10월 전체 플랜에 zstd를 개방했다. Cloudflare 공식 블로그(2024년 10월 게시, "New standards"편) 에 따르면 자사 24시간 트래픽 실측에서 zstd의 평균 압축비는 2.86:1로 gzip의 2.56:1보다 높았고, 압축 속도는 0.848ms(gzip 0.872ms, brotli 1.544ms)였다고 보고했다(출처: Cloudflare 블로그 — New standards, 접속 시점에 따라 수치가 갱신될 수 있다). 현재 Cloudflare의 알고리즘 우선순위는 zstd → br → gzip → 비압축 순이라고 같은 글에서 밝히고 있다.

zstd의 강점은 동적 응답에서의 균형에 있다. Brotli는 한 번 압축하고 수천 번 서빙하는 정적 자산 시나리오에서 빛난다. 반면 API 응답, SSR HTML, 스트리밍처럼 캐시되지 않고 요청마다 새로 압축해야 하는 워크로드에서는 brotli의 CPU 비용이 부담이 된다. zstd는 brotli와 비슷한 압축률을 42% 빠른 속도로 달성해 동적 압축 경쟁에서 유리하다.

Nginx의 zstd 지원은 2026년 현재 공식 내장 모듈이 없어 서드파티 nginx-zstd 모듈로 사용한다. 생태계 성숙도가 brotli보다 낮은 만큼, 엔터프라이즈 환경에서는 Cloudflare나 AWS CloudFront 등 엣지 레이어에서 zstd를 적용하는 방식을 먼저 검토하는 것이 현실적이다.

RSC 기반 아키텍처에서 동적 응답 압축이 스트리밍 렌더링에 어떤 영향을 주는지는 RSC 네트워크 최적화에서 이어서 다룬다.


7. 세 알고리즘 정량 비교 — 동일 페이로드 100KB JSON 기준

아래 표는 Cloudflare 2024년 10월 블로그 실측 보고(Cloudflare 블로그 — New standards), Google 공개 Brotli 벤치마크, Squash Compression Benchmark, PeaZip fast compression benchmark(PeaZip)를 종합한 대표 추정치다. 인프라 환경과 콘텐츠 종류에 따라 편차가 크므로, 도입 전 자체 벤치마크 결과를 기준으로 삼아야 한다.

알고리즘압축률 (100KB JSON)압축 속도해제 속도브라우저 지원적합한 용도
gzip (레벨 6)~72% 절감빠름 (0.87ms/req 추정)매우 빠름전체 100%범용 폴백, 레거시 지원
Brotli (레벨 4, 동적)~80% 절감보통 (1.54ms/req 추정)빠름~96% (HTTPS 전용)정적 자산 pre-compression
Brotli (레벨 11, 정적)~84% 절감매우 느림 (빌드타임 전용)빠름~96% (HTTPS 전용)빌드 타임 정적 파일
zstd (레벨 3)~75% 절감가장 빠름 (0.85ms/req 추정)가장 빠름~70% (Safari 미지원)동적 응답, 실시간 스트림

알고리즘 선택 의사결정 트리

응답 유형 분류
      │
      ├─ 정적 자산 (JS/CSS/HTML/SVG/WOFF2)
      │        │
      │        ├─ 빌드 파이프라인에서 pre-compress 가능?
      │        │        └─ YES → brotli(q=11) + gzip(lv=9) 동시 생성
      │        │                  Nginx: brotli_static + gzip_static
      │        └─ NO (런타임 압축만 가능)
      │                  └─ brotli(lv=4~6) + gzip(lv=4) 폴백
      │
      ├─ 동적 API 응답 (JSON/XML, 매 요청 다름)
      │        │
      │        ├─ CDN 엣지에서 zstd 지원 가능?
      │        │        └─ YES → zstd 우선, br 폴백, gzip 최종 폴백
      │        └─ NO
      │                  └─ gzip(lv=1~4) 동적 압축
      │                     크기 < 1KB면 압축 스킵
      │
      └─ 이미지/바이너리/WASM
               └─ 압축 절대 금지 (MIME 화이트리스트에서 제외)

이 트리를 따르면 JPEG에 gzip이 적용되는 사고와, API 응답에 레벨 9를 걸어 CPU를 낭비하는 실수를 함께 막을 수 있다.


8. 결론: "압축은 켜고 끄는 것이 아니라 설계하는 것"

처음 Nginx에서 gzip on;을 켰던 날로부터 꽤 오랜 시간이 지났다. 그 사이에 BREACH를 공부했고, JPEG에 gzip이 걸리는 걸 목격했으며, brotli pre-compression이 Core Web Vitals 점수를 실제로 끌어올리는 것도 확인했다. 현재 우리 서비스에서 적용 중인 결정을 체크리스트로 정리한다.

실무 의사결정 체크리스트

  1. MIME 타입 화이트리스트를 명시적으로 관리한다. gzip_types *brotli_types *는 절대 쓰지 않는다. JPEG, PNG, WebP, AVIF, MP4, WebM, WASM은 화이트리스트에서 제외한다. 지금 바로 Nginx 설정에서 gzip_types를 확인하라.

  2. 정적 자산과 동적 응답의 전략을 분리한다. 정적 자산은 빌드 타임 brotli(quality 11) + gzip(level 9) pre-compression을 Vite/Webpack 플러그인으로 자동화한다. brotli_static on; gzip_static on;으로 런타임 CPU 소비를 제거한다. 동적 API 응답은 레벨을 낮춰(gzip 14, brotli 46) 압축하거나, 크기가 1KB 미만이면 건너뛴다(gzip_min_length 1024).

  3. BREACH 조건을 점검한다. 동적 HTML 응답 본문에 CSRF 토큰, 세션 토큰 같은 고정 시크릿이 포함되는지 확인한다. 포함된다면 세 가지 중 하나를 선택한다: HTTP-only 쿠키로 분리, per-request 갱신 토큰으로 교체, 해당 엔드포인트에서만 압축 비활성화.

  4. 압축 레벨은 워크로드에 맞게 선택한다. "레벨 6이 항상 옳다"는 규칙은 없다. CPU 병목이 있는 고트래픽 동적 응답에는 1~4가 맞고, pre-compression에는 9(gzip) / 11(brotli)이 맞다. 변경 전후를 wrkab로 측정해 수치로 근거를 만든다.

  5. Vary: Accept-Encoding 헤더 삽입을 검증한다. CDN 앞단에 Nginx를 뒀다면, CDN이 Vary 헤더를 올바르게 전파하는지 확인한다. CloudFront, Cloudflare 모두 기본 설정에서 Vary: Accept-Encoding을 캐시 키에 포함하지 않는 경우가 있다. 설정 없이 brotli와 gzip 버전이 같은 키로 캐싱되면 클라이언트에게 잘못된 인코딩이 서빙된다.

  6. zstd 도입은 Safari 지원 타임라인을 지켜보며 결정한다. 2026년 현재 Safari가 zstd를 지원하지 않아 전체 브라우저 지원률은 약 70% 수준이다. CDN 엣지(Cloudflare, Fastly)에서는 지금도 의미가 있지만, Nginx 오리진에서 직접 서빙할 때는 반드시 gzip 폴백 체인을 구성해야 한다.


압축은 "켜면 좋고 끄면 나쁜" 이진 스위치가 아니다. 어떤 콘텐츠를 어떤 알고리즘으로, 어느 레벨에서, 어떤 보안 조건 아래서 적용할 것인가를 설계하는 문제다. gzip을 켰다가 JPEG가 더 커진 날의 그 오싹함이, 이 글을 읽는 누군가에게는 일어나지 않기를 바란다.