web-performance infrastructure

HTTP 압축 — gzip, Brotli 압축 방식

gzip과 Brotli의 설계 차이와 브라우저 지원 현황을 정리하고, 이 사이트의 빌드 산출물로 압축률과 압축 속도를 직접 측정합니다. 페이지마다 압축 방식이 달랐던 실제 사례를 추적하며 CDN 캐시와 압축 설정의 관계를 다룹니다.

compression brotli gzip cdn performance cloudfront

들어가며

개발자 도구 네트워크 탭을 무심코 보다가 이상한 걸 발견했다. HTML 문서의 Content-Encodinggzip이었다.

이 사이트는 S3 + CloudFront로 배포되어 있고 CloudFront는 Brotli를 지원한다. 당연히 Brotli로 오고 있을 줄 알았는데 아니었다.

원인을 찾는 데 생각보다 오래 걸렸다. 처음엔 압축 설정이 잘못된 줄 알았고, 그 다음엔 배포 파이프라인을 의심했다. 둘 다 아니었다. 한 달 전에 캐시된 응답이 그대로 남아 있었던 것이었다.

그 과정에서 압축 방식들을 다시 들여다보게 됐고, 이왕 하는 김에 이 사이트의 빌드 결과물로 직접 측정도 해봤다. 이 글은 그 기록이다.

CDN 캐싱 자체의 동작 원리는 CDN 캐싱 완벽 가이드에서 다뤘다. 이 글은 그중 압축에 초점을 맞춘다.

왜 압축하는가

결국 네트워크가 CPU보다 훨씬 느리다는 비대칭성 때문이다.

HTML, CSS, JS, JSON은 전부 텍스트고, 텍스트에는 반복이 지독하게 많다. function, return, <div class=, 들여쓰기 공백, 똑같은 클래스명이 계속 나온다.

내 빌드 결과물로 재봤더니 2,134.9 KB짜리 텍스트가 gzip으로 509.0 KB까지 줄었다. 76% 감소다. 압축에 드는 CPU 시간은 파일당 밀리초 단위인데, 1.6 MB를 덜 보내서 아끼는 시간은 네트워크에 따라 수 초다. 안 할 이유가 없는 거래다.

그래서 전송 압축은 HTTP 초기부터 표준에 들어 있었다. Content-EncodingHTTP/1.0 스펙(RFC 1945, 1996) 10.3절에 이미 정의되어 있다.

압축은 어떻게 협상되는가

압축은 서버가 마음대로 하는 게 아니다. 클라이언트가 먼저 자신이 풀 수 있는 방식을 알려주는 구조다.

요청:
  GET /articles/ HTTP/2
  Accept-Encoding: gzip, deflate, br, zstd

응답:
  HTTP/2 200
  Content-Encoding: br
  Vary: Accept-Encoding

세 헤더의 역할이 각각 다르다.

헤더방향역할
Accept-Encoding요청클라이언트가 지원하는 압축 방식 목록
Content-Encoding응답서버가 실제로 사용한 압축 방식
Vary: Accept-Encoding응답캐시에게 “인코딩별로 따로 저장하라”고 지시

이 중 Vary가 까다롭다. 일반적인 HTTP 캐시에서 이게 빠지면 Brotli로 압축된 응답이 gzip만 지원하는 클라이언트에게 그대로 전달될 수 있다. 클라이언트는 압축을 풀지 못하고 깨진 응답을 받는다.

다만 CDN마다 동작이 다르다. CloudFront는 압축을 활성화하면 Accept-Encoding을 캐시 키에 자동으로 포함한다. 오리진이 Vary를 보내지 않아도 인코딩별로 분리된다.

그래서 “Vary가 없으면 무조건 깨진다”는 아니다. HTTP 캐시 일반론과 특정 CDN의 동작이 다른 지점이고, 나도 이번에 문서를 다시 읽어보고서야 정확히 알았다.

품질 값(q-value)

Accept-Encoding에는 우선순위를 지정할 수 있다.

Accept-Encoding: br;q=1.0, gzip;q=0.8, *;q=0.1

q 값이 높을수록 선호한다는 뜻이다. 다만 실제 브라우저는 대부분 q 없이 단순 나열만 하고, 서버가 자체 우선순위로 선택하는 경우가 많다.

gzip과 Brotli는 무엇이 다른가

공통 기반 — LZ77과 엔트로피 부호화

gzip과 Brotli는 서로 다른 압축 포맷이지만, 두 가지 기법을 공통으로 사용한다.

LZ77 계열 참조: 앞에서 이미 나온 문자열이 또 나오면, 그 내용 대신 “N바이트 뒤로 가서 M바이트 복사”라는 포인터로 대체한다. 반복을 제거하는 단계다.

원본:   <div class="card"><div class="card">
        └────── 18바이트 ──────┘└─ 반복 ─┘

압축 후: <div class="card">[18바이트 전으로, 18바이트 복사]

허프만 부호화: 자주 나오는 심볼에 짧은 비트를, 드문 심볼에 긴 비트를 할당한다. 빈도 편향을 이용하는 단계다.

여기서 하나 짚고 넘어간다. 나는 오랫동안 “Brotli도 DEFLATE에서 출발한 것”이라고 알고 있었는데, 이번에 RFC를 찾아보니 아니었다.

헷갈린 이유는 RFC 7932가 DEFLATE 명세의 표기법과 용어를 그대로 빌려 쓰기 때문이었다(“uses the notations and terminology introduced in the DEFLATE format specification”). 문서 서술 방식을 빌린 것이지 알고리즘을 물려받은 게 아니다.

gzip (1992) — 사실상의 기본값

gzip은 DEFLATE에 헤더와 CRC 체크섬을 씌운 컨테이너 포맷이다. 압축 알고리즘 자체는 DEFLATE와 같다.

아직도 쓰는 이유는 지원 범위가 가장 넓어서다. 압축과 해제가 모두 빠르고 메모리도 적게 쓴다. 30년 넘게 검증됐으니 폴백으로 이만한 게 없다.

한계는 슬라이딩 윈도우가 32KB로 고정이라는 점이다. 그보다 멀리 떨어진 반복은 못 찾는다. 내 아티클 HTML이 대부분 50~100KB니까, 문서 앞뒤에 같은 패턴이 있어도 놓치고 있다는 뜻이다.

Content-Encoding: deflate는 안 쓰는 게 낫다. 스펙상 zlib 래퍼여야 하는데 일부 서버가 raw DEFLATE를 보내면서 구현이 갈렸다. 역사적 지뢰다.

Brotli (2015) — 웹 텍스트에 특화

Brotli는 Google이 만들었다. 원래는 WOFF2 폰트 압축용으로 개발했는데, 결과가 좋아서 HTTP 전송용으로 확장했다. (WOFF2 서브셋 아티클에서 폰트 쪽 Brotli를 다룬 적이 있다.)

gzip 대비 세 가지가 다르다.

1. 슬라이딩 윈도우가 최대 16MB — gzip의 512배다. 멀리 떨어진 반복도 잡는다.

2. 내장 사전(static dictionary) — 웹에서 흔히 쓰이는 문자열 약 13,000개를 사전에 미리 넣어놨다. <!DOCTYPE html>, </div>, function, charset=utf-8 같은 것들이다. 파일에 처음 등장하는 순간에도 이미 사전에 있으니 짧은 참조로 대체된다. 웹 텍스트 특화가 여기서 나온다.

3. 컨텍스트 모델링 — 직전 바이트에 따라 다음 바이트의 확률 모델을 바꾼다. RFC 7932는 리터럴에 대해 LSB6, MSB6, UTF8, Signed 네 가지 컨텍스트 모드를 정의한다.

압축 레벨이 핵심이다

Brotli에는 0~11 레벨(quality)이 있는데, 레벨에 따라 성격이 꽤 달라진다. 나는 이번에 측정해보기 전까지 이 차이를 제대로 몰랐다.

레벨용도특징
1~4런타임 압축gzip과 비슷한 속도
5~6스트리밍압축률과 속도의 균형
11빌드 타임 사전 압축최대 압축률, 대신 매우 느림

이 구분은 내가 임의로 나눈 게 아니다. Node.js zlib 문서에 이렇게 적혀 있다.

Quality 6 is appropriate for streaming; quality 11 is intended for offline/build-time compression.

quality 6은 스트리밍용, quality 11은 오프라인·빌드 타임용. “매우 느리다”가 얼마나 느린지는 뒤에서 직접 재봤다.

실측 — 이 사이트의 빌드 산출물로 측정

“Brotli가 gzip보다 15~25% 낫다”는 말은 많이 봤지만, 전부 남의 데이터였다. 내 사이트에서는 실제로 몇 %인지 궁금해서 직접 재봤다.

처음엔 brotli CLI를 설치하려다가, Node.js 내장 zlib으로 충분하다는 걸 알고 그대로 썼다. 별도 패키지 없이 gzip과 Brotli를 모두 다룰 수 있다.

import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
import { readFileSync } from 'node:fs';

const buf = readFileSync('dist/articles/react-suspense-deep-dive/index.html');

const gzipped = gzipSync(buf, { level: 9 }).length;

const brotlied = brotliCompressSync(buf, {
  params: {
    [constants.BROTLI_PARAM_QUALITY]: 11,
    [constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
  },
}).length;

BROTLI_PARAM_SIZE_HINT는 문서상 “expected input size”를 지정하는 값이다. 기본값은 0(크기 미상)이고, 넣지 않아도 동작한다. 파일을 통째로 읽어서 크기를 이미 알고 있으니 그냥 넣어줬다.

전체 합계

dist/ 하위의 텍스트 파일 82개(HTML, CSS, JS, SVG, XML, TXT)를 대상으로 측정했다.

방식크기원본 대비gzip 대비
원본2,134.9 KB
gzip-9509.0 KB-76.2%
Brotli-5474.0 KB-77.8%-6.9%
Brotli-11427.4 KB-80.0%-16.0%

Brotli-11이 gzip 대비 16% 작았다. 흔히 얘기되는 15~25% 범위의 아래쪽에 걸친다.

확장자별 결과

확장자개수원본gzip-9Brotli-11gzip 대비
.html451,899.0 KB422.7 KB351.1 KB-16.9%
.js2127.5 KB49.5 KB44.3 KB-10.5%
.svg3161.3 KB28.3 KB24.6 KB-13.1%
.css138.7 KB7.5 KB6.5 KB-13.8%
.xml28.4 KB1.0 KB0.8 KB-18.4%

HTML에서 효과가 가장 컸다. Brotli의 내장 사전이 HTML 태그와 속성으로 채워져 있으니 납득이 간다. JS가 -10.5%로 낮은 건 의외였는데, 번들러가 minify하면서 이미 반복을 상당히 걷어냈기 때문인 듯하다.

개별 파일로 보면 편차가 더 크다.

파일원본gzip-9Brotli-11차이
articles/index.html67.5 KB9.6 KB7.7 KB-19.5%
react-suspense-deep-dive/index.html102.5 KB14.6 KB11.8 KB-19.3%
javascript-promise-deep-dive/index.html92.4 KB15.1 KB12.3 KB-18.6%
CareerTimeline...js112.5 KB44.4 KB39.7 KB-10.6%

아티클 목록 페이지가 -19.5%로 가장 좋았다. 카드 마크업이 계속 반복되는 구조라 Brotli의 큰 윈도우가 제 역할을 한 것 같다.

압축 레벨과 속도의 트레이드오프

측정하면서 가장 인상 깊었던 부분이다. 102.5 KB짜리 HTML 하나를 레벨별로 재봤다.

방식크기압축 시간
gzip-914.6 KB1.8 ms
Brotli-414.8 KB0.9 ms
Brotli-513.4 KB1.2 ms
Brotli-613.3 KB1.1 ms
Brotli-912.5 KB4.0 ms
Brotli-1111.8 KB106.8 ms

두 가지가 눈에 들어왔다.

첫째, Brotli-6이 gzip-9보다 작으면서 압축 시간도 짧았다. 13.3 KB / 1.1 ms 대 14.6 KB / 1.8 ms.

처음엔 “그럼 런타임에서 gzip 쓸 이유가 없네”라고 적으려다 말았다. 단일 파일을 한 번 잰 값이고, 비교 대상도 하필 gzip level 9였다. 서버 CPU, 파일 크기, gzip 레벨, 동시 요청 수에 따라 결과는 얼마든지 달라진다. 이 숫자는 내 환경에서 나온 참고값 정도로 보는 게 맞겠다.

둘째, Brotli-11의 압축 비용이 생각보다 훨씬 컸다. Brotli-6 대비 1.5 KB를 더 줄이자고 시간을 약 97배 쓴다. 요청마다 압축하는 상황이라면 감당하기 어려운 수치다.

어쨌든 방향은 앞에서 인용한 Node.js 문서와 같았다.

런타임 압축 (요청마다)  → Brotli 4~6 (quality 6이 스트리밍용 권장값)
빌드 타임 사전 압축      → Brotli 11 (한 번만 하면 되므로)

이미 압축된 포맷은 어떨까

“이미 압축된 이미지는 재압축하면 오히려 커진다”는 말을 그동안 믿고 있었다. 이왕 재는 김에 확인해봤다.

파일원본gzip-9변화
configdeck_1.png346.4 KB258.3 KB-25.4%
configdeck_3.png838.2 KB729.8 KB-12.9%
configdeck_1.webp92.6 KB83.6 KB-9.8%
configdeck_2.webp153.2 KB145.2 KB-5.2%

커지기는커녕 오히려 줄었다. PNG는 25%까지 빠졌다.

처음엔 이유를 이렇게 적었다. “PNG는 내부적으로 DEFLATE를 쓰는데, 이미지 생성 도구가 최대 압축을 안 해서 gzip이 더 짜낸 것이다.” 그럴듯했지만 이것도 틀린 설명이었다.

PNG가 내부 이미지 데이터에 DEFLATE를 쓰는 것은 맞다. 하지만 HTTP gzip이 PNG를 감쌀 때는 픽셀 데이터를 다시 압축하는 게 아니라 이미 완성된 PNG 파일의 바이트 전체를 통째로 gzip으로 감싼다. 두 레이어가 다르니 “생성기가 압축을 덜 했다”와 직접 연결되지 않는다.

메타데이터나 청크 구조에 반복 패턴이 있었을 수도 있는데, 정확한 원인은 해당 PNG를 따로 뜯어봐야 알 수 있다. 여기서 확실한 건 “재봤더니 이런 값이 나왔다”까지다.

그래서 이미지도 압축해야 하나 싶었지만, 결론은 그대로 “안 한다”였다. 다만 이유가 내가 알던 것과 달랐다.

무엇을 제외할지는 포맷별로 다르다

나도 “이미지·폰트·동영상은 압축 안 한다”고 뭉뚱그려 알고 있었는데, CloudFront가 압축 대상으로 지정한 Content-Type 목록을 보니 포맷마다 갈렸다.

포맷압축 대상 여부이유
SVG (image/svg+xml)압축함텍스트 기반이라 효과가 크다
TTF, OTF (font/ttf, font/otf)압축함자체 압축이 없는 폰트 포맷
WOFF2제외이미 Brotli로 압축된 포맷
PNG, JPEG, WebP, AVIF제외이미 압축된 래스터 이미지
MP4 등 동영상제외이미 압축된 코덱

내 측정에서도 SVG 31개가 gzip 대비 13.1% 더 줄었다. SVG를 이미지라고 뭉뚱그려 뺐다면 그만큼 손해였다.

브라우저 지원 현황 (2026년 기준)

측정을 끝내고 나니 “그럼 뭘 켜두면 되나”가 궁금해져서 지원 현황을 다시 확인했다.

방식지원 범위판단
gzip모든 주요 브라우저폴백으로 항상 유지
BrotliChrome 50+, Firefox 44+, Safari 11+ (2017~)기본값으로 사용 가능
zstdChrome 123+, Firefox 126+, Safari 26.3+브라우저는 준비됨, CDN이 관건
deflate지원되나 구현이 갈림사용하지 않음

Brotli는 이미 안전한 선택이었다. 2017년부터 주요 브라우저가 지원했으니 9년치가 쌓였다. gzip 폴백만 있으면 고민할 게 없었다.

zstd는 어디까지 왔나

Zstandard는 Meta가 2016년에 만든 알고리즘이다. Brotli가 “웹 텍스트를 최대한 작게”라면, zstd는 “비슷한 압축률을 훨씬 빠르게”에 가깝다. 특히 해제 속도가 빠르다.

브라우저 지원은 다음과 같이 갖춰졌다.

브라우저지원 시작
Chrome123부터 (2024)
Firefox126부터 (2024)
Safari26.3부터

Safari도 26.3에서 정식 지원이 들어왔다. 다만 macOS는 Tahoe 26.3 이상이어야 하고, 그 이전 macOS의 Safari 26.3에서는 동작하지 않는다는 단서가 붙는다.

브라우저 쪽은 이제 거의 갖춰진 셈이다. 그런데도 내가 이번에 zstd를 후보에서 뺀 이유는 두 가지였다.

첫째, CDN이 안 받아준다. CloudFront의 자동 압축은 gzip과 Brotli를 중심으로 동작한다. 브라우저가 Accept-Encoding: zstd를 보내도 받아줄 곳이 없으면 소용이 없다.

둘째, 정적 파일에서는 Brotli-11이 더 작다. zstd의 강점은 압축 속도인데, 빌드 때 한 번만 압축하는 정적 파일에서는 속도가 병목이 아니다.

zstd가 빛을 보는 건 요청마다 압축해야 하는 동적 API 응답이나 서버 간 통신 쪽이다. 정적 사이트라면 당분간 Brotli로 충분하겠다는 게 지금 판단이다.

프레임워크에서의 압축 설정

이 부분을 찾아보면서 하마터면 잘못 정리할 뻔했다. Astro도 Next.js도 기본 설정에서는 .gz, .br 같은 사전 압축 산출물을 빌드 때 만들지 않는다. 압축은 대개 배포처(CDN, Nginx)가 응답 시점에 처리한다.

여기까지만 쓰면 “Next.js는 압축을 안 한다”로 읽히는데, 그건 아니다. 빌드 산출물 생성런타임 압축은 별개다. Next.js는 next start로 서버를 띄우면 기본적으로 gzip 압축을 한다.

사전 압축 산출물 생성  → 빌드 시 .gz/.br 파일을 만드는 것 (기본 비활성)
런타임 압축            → 요청이 올 때 서버가 압축하는 것 (Next.js는 gzip 기본 활성)

빌드 타임 사전 압축이 의미 있는 경우는 자체 서버로 호스팅할 때로 한정된다.

Astro

검색해보면 @astrojs/compress를 안내하는 글이 많이 나온다. 나도 그걸 먼저 시도했다가 막혔는데, 이 패키지는 npm에서 삭제된 상태였다.

$ npm view @astrojs/compress
npm error 404 Not Found - GET https://registry.npmjs.org/@astrojs/compress

오래된 글들이 아직 이걸 안내하고 있어서 한참 헤맸다. 지금 쓸 수 있는 선택지는 이 정도다.

패키지지원 방식
astro-compressorgzip + Brotli
astro-squeezegzip + Brotli (Rust 구현)
vite-plugin-compressiongzip + Brotli (Vite 레이어)

astro-compressor 적용 예시다. integrations 배열의 맨 마지막에 와야 한다. 다른 통합이 파일 생성을 끝낸 뒤에 압축해야 하기 때문이다.

import { defineConfig } from 'astro/config';
import compressor from 'astro-compressor';

export default defineConfig({
  integrations: [
    mdx(),
    sitemap(),
    compressor({ gzip: true, brotli: true }), // 반드시 마지막
  ],
});

빌드 결과로 index.html 옆에 index.html.gz, index.html.br이 생긴다. 다만 서버가 이 파일을 찾아 서빙하도록 설정해야 의미가 있다.

# Nginx 설정 예시 (해당 모듈이 포함된 빌드에서)
gzip_static on;
brotli_static on;

그런데 이 두 줄이 아무 Nginx에서나 되는 건 아니다.

패키지 매니저로 설치했다면 배포판마다 포함 여부가 달라서, nginx -V로 먼저 확인해봐야 한다.

Next.js

Next.js에는 compress 옵션이 있는데, 이게 다루는 건 gzip 런타임 압축이다. next start로 띄우면 별도 설정 없이 gzip이 걸린다. 대신 Brotli를 고르는 옵션은 없다.

// next.config.js
module.exports = {
  compress: true, // 기본값. next start의 gzip 압축
  // compress: false,  // Nginx/CDN이 압축을 맡을 때
};

헷갈리기 쉬운데 compress: false는 압축을 포기하는 설정이 아니라 압축 책임을 앞단으로 넘기는 설정이다. Nginx나 CDN이 Brotli로 처리하게 할 때 이중 압축을 피하려고 끈다.

Brotli를 쓰려면 우회해야 한다.

방법설명
CDN에 위임Vercel, CloudFront 등이 자동 처리 (가장 일반적)
Nginx 앞단compress: false로 끄고 Nginx가 Brotli 처리
사전 압축next-pre-compression 등 서드파티
커스텀 서버shrink-ray-current 같은 미들웨어

Next.js가 Brotli 옵션을 두지 않은 것은 “압축은 인프라 레이어에서 처리하라”는 방향으로 읽힌다. 실제 배포 환경 대부분이 CDN이나 리버스 프록시를 앞에 두는 것을 생각하면 납득할 만한 선택이다.

S3 + CloudFront에서 사전 압축은 유효한가

측정 결과를 보고 나니 욕심이 났다. Brotli-11이 확실히 더 작으니 빌드 때 미리 압축해서 올리면 되지 않을까 싶었다.

결론부터 쓰면 적용하지 않았다. 알아볼수록 손이 많이 가는 데 비해 남는 게 적었다.

왜 까다로운가

static 압축 모듈이 갖춰진 Nginx라면 brotli_static on; 정도로 끝난다. 요청의 Accept-Encoding을 보고 .br 파일이 있으면 그걸 내준다.

S3에는 그런 기능이 없다. S3는 Accept-Encoding을 해석하지 않는다. 단순 객체 스토리지라 요청 헤더에 따라 다른 파일을 골라줄 수가 없다.

억지로 하려면 이런 과정을 거쳐야 했다.

1. 빌드 시 index.html.br 생성
2. S3에 업로드하면서 Content-Encoding: br 메타데이터 수동 지정
3. 그런데 이러면 gzip만 지원하는 클라이언트가 깨진다
4. 해결하려면 CloudFront Function으로
   Accept-Encoding을 보고 오리진 경로를 바꾸는 로직 작성
5. 캐시 키에 Accept-Encoding 포함 설정

CloudFront Function을 하나 더 관리해야 하고, 잘못 짜면 디바이스 캐시 오염 사례처럼 캐시 키 설계 실수로 이어질 수 있다.

실익은 얼마인가

CloudFront의 자동 압축은 엣지에서 실시간으로 돌아가니 최대 레벨은 아닐 것이다. 다만 AWS는 자기가 쓰는 Brotli quality level을 공개하지 않는다. 아래 숫자는 내 실측값을 근거로 한 가정일 뿐, 실제 값은 다를 수 있다.

Brotli-6 수준이라 가정할 경우: 474 KB 부근
Brotli-11 (사전 압축):        427 KB
차이:                         약 47 KB (약 10%)

여기서 한 번 크게 헷갈렸다. 처음엔 “이 47 KB 차이는 캐시 미스일 때만 발생하니까 캐시 히트율이 높으면 의미 없다”고 결론 내렸는데, 완전히 틀린 얘기였다.

CloudFront 문서를 다시 읽어보니 이렇게 적혀 있었다.

If the object can be compressed, CloudFront compresses it, sends it to the viewer, and then adds it to the cache. If there are subsequent viewer requests for the same object, CloudFront returns the first cached version.

CloudFront는 최초에 압축한 결과를 캐싱해두고 이후 요청에는 그 압축본을 그대로 내준다. 474 KB짜리가 캐싱됐으면 캐시 히트마다 474 KB가 나간다. 사전 압축으로 427 KB를 올려뒀으면 매번 427 KB가 나간다. 전송량 차이는 캐시 히트든 미스든 매 응답마다 그대로 발생한다.

내가 뒤섞은 건 전송량과 압축 비용이었다. 캐시가 좌우하는 건 후자다.

구분캐시 미스캐시 히트
오리진 요청발생없음
압축 연산엣지에서 수행없음 (캐시된 결과 재사용)
사용자 전송량압축본 크기동일

그래서 왜 안 했느냐. 전송량 이득이 상시 발생하는 건 맞지만 그게 **약 10%**이고, 대가로 CloudFront Function을 하나 더 운영해야 한다. 개인 포트폴리오 트래픽에서는 운영 복잡도가 더 크다고 봤다. 잘못 짠 캐시 키가 어떤 사고로 이어지는지는 디바이스 캐시 오염 사례에서 이미 크게 데였다.

Nginx나 Caddy로 직접 호스팅한다면 얘기가 다르다. 앞서 말한 static 압축 모듈만 있으면 설정 몇 줄이니, 같은 10%를 훨씬 싸게 가져갈 수 있다.

추적 — 왜 페이지마다 압축 방식이 달랐나

이제 처음 발견했던 문제로 돌아간다.

1차 확인 — 헤더 하나만 보고 결론 내리다

일단 홈 화면에 요청을 던져봤다.

$ curl -sI -H 'Accept-Encoding: br, gzip, zstd' https://sungyujang.com/

HTTP/2 200
server: AmazonS3
content-encoding: gzip
vary: Accept-Encoding

br을 요청했는데 gzip이 왔다. 그리고 server: AmazonS3가 눈에 띄길래 “CloudFront를 안 타고 S3 응답이 그대로 나오는구나”라고 생각했다.

이 판단부터 틀렸다. Server 헤더는 오리진이 보낸 값이 그대로 전달되는 경우가 많다. 이 헤더만으로는 CloudFront 경유 여부를 알 수 없다. 그건 x-cachex-amz-cf-id를 봐야 한다.

여기에 더해 홈 화면 응답 하나 보고 “사이트 전체가 gzip이구나”라고 단정한 게 두 번째 실수였다. 이 두 가지 오판 때문에 한동안 엉뚱한 데를 팠다.

2차 확인 — 자산별로 나눠보기

HTML만 보고 판단한 게 걸려서 CSS와 JS도 따로 찍어봤다.

$ curl -sI -H 'Accept-Encoding: br, gzip' \
    https://sungyujang.com/_astro/_slug_.DkHviMbP.css

HTTP/2 200
content-type: text/css
content-encoding: br
x-cache: Hit from cloudfront

CSS는 Brotli로 오고 있었다. 게다가 이번엔 x-cache: Hit from cloudfront가 찍혔다. 1차 확인 때는 없던 헤더다.

여기서 가설이 바뀌었다. 압축 설정이 잘못된 게 아니라 캐시 상태에 따라 결과가 갈리는 것 같았다.

3차 확인 — 경로별로 훑어보기

그래서 여러 경로를 한꺼번에 돌려봤다. 결정적인 단서는 엉뚱하게도 age 헤더에서 나왔다.

for P in "/" "/articles/" "/about" "/work"; do
  curl -sI -H 'Accept-Encoding: gzip, deflate, br' "https://sungyujang.com$P" \
    | grep -iE 'content-encoding|age'
done
경로Content-Encodingage환산
/br104약 2분
/articles/br1,620,124약 19일
/aboutgzip2,716,08731일
/workgzip2,714,58831일

패턴이 눈에 들어왔다. age가 31일인 경로만 gzip이었다.

나는 Age를 그냥 “캐시에 저장되고 나서 흐른 시간”으로 알고 있었는데, RFC 9111의 정의는 조금 달랐다.

The “Age” response header field conveys the sender’s estimate of the time since the response was generated or successfully validated at the origin server.

오리진에서 응답이 생성되거나 검증된 이후 흐른 시간에 대한, 캐시가 계산한 추정값이다. 여러 캐시를 거치면 값이 누적된다. “이 응답이 얼마나 묵은 것인가”를 가늠하는 데는 충분하지만 “엣지에 저장된 시각”과 똑같은 개념은 아니다.

평소엔 거들떠보지도 않던 헤더인데, 이번엔 이게 실마리가 됐다.

원인

시간축으로 정리하면 이렇다.

31일 전  마지막 master 배포
         → CloudFront 캐시 무효화 실행
         → 재캐싱 시점에 Brotli 압축이 비활성 상태
         → gzip으로 압축된 객체가 캐시에 저장됨

그 이후  CloudFront 캐시 정책에서 Brotli 압축 활성화
         → 하지만 이미 캐시된 객체에는 소급 적용되지 않음
         → s-maxage=31536000 (1년)이라 만료되지도 않음

현재     /about, /work는 여전히 31일 전 gzip 객체를 서빙
         자주 갱신된 /는 새 설정으로 Brotli 서빙

AWS 캐시 정책에는 Gzip 압축 지원Brotli 압축 지원이 별도 설정으로 있다. API 기준으로는 EnableAcceptEncodingGzipEnableAcceptEncodingBrotli다. Brotli만 꺼져 있으면 Accept-Encoding: br을 보내도 gzip이 돌아온다. 기억을 더듬어보니 언젠가 콘솔에서 이 Brotli 항목을 켰던 것 같은데, 그게 마지막 배포 이후였던 모양이다.

찜찜해서 CloudFront 문서를 찾아보니 아예 명시되어 있었다.

When you configure CloudFront to compress objects, CloudFront doesn’t compress objects that are already cached in edge locations. (…) If you want CloudFront to compress objects that are already cached in edge locations, you need to invalidate those objects.

압축 설정을 켜도 이미 엣지에 캐시된 객체는 다시 압축되지 않고, 적용하려면 무효화해야 한다. 설정을 켠 시점과 캐시에 저장된 시점이 어긋나면서 설정은 최신인데 응답은 과거인 상태가 만들어진 것이었다.

해결

무효화 한 번으로 끝났다.

aws cloudfront create-invalidation \
  --distribution-id <배포ID> \
  --paths "/*"

돌리고 나니 모든 경로가 Brotli로 응답했다. 원인을 찾는 데는 오래 걸렸는데 해결은 명령어 한 줄이었다. 참고로 무효화는 월 1,000경로까지 무료다.

배포 파이프라인은 문제가 아니었다

사실 원인을 찾는 중간에 “배포할 때 무효화를 안 걸어놔서 옛 객체가 남은 거 아닐까” 하고 워크플로를 열어봤다. 이미 들어가 있었다.

# .github/workflows/deploy.yml
- name: Invalidate CloudFront cache
  run: |
    aws cloudfront create-invalidation \
      --distribution-id EXXXXXXXXXXXX \
      --paths "/*"

파이프라인은 멀쩡했다. 진짜 문제는 배포와 배포 사이에 CDN 설정을 바꿨다는 것이었다. 배포 시점에만 캐시가 갱신되는 구조라서, 콘솔에서 만진 설정은 다음 배포까지 반영되지 않는다.

HTML에 s-maxage=31536000이 걸려 있는 것도 처음엔 위험해 보였는데, 배포마다 /* 무효화가 도니 실제로는 문제없이 동작하고 있었다. 다만 무효화에 기대는 구조라는 건 기억해둘 만하다.

교훈

1. 응답 하나로 결론을 내렸다

홈 화면 하나 보고 “사이트 전체가 gzip”이라고 단정했다. 자산별로, 경로별로 나눠 보니 전혀 다른 그림이었다. CDN이 끼면 같은 사이트라도 경로마다 캐시 상태가 제각각이라는 걸 머리로는 알면서도 손이 먼저 결론을 내버렸다.

Server: AmazonS3를 보고 “CloudFront를 안 탔구나”라고 넘겨짚은 것도 같은 종류의 실수였다. 헤더 하나에 너무 많은 의미를 실었다.

2. Age를 진작 봤어야 했다

Content-Encoding이 예상과 다를 때, 그게 설정 탓인지 캐시 탓인지 가르는 데 Age가 꽤 쓸모 있었다. 다음부터는 이 세 개를 같이 찍어볼 생각이다.

curl -sI -H 'Accept-Encoding: br, gzip' https://example.com/ \
  | grep -iE 'content-encoding|age|x-cache'

물론 이건 확정 규칙이 아니라 감을 잡는 용도다. 이번에 확신이 섰던 건 Age 값 하나 때문이 아니라 31일이라는 값이 여러 경로에서 똑같이 나왔고, 그게 마지막 배포 시점과 맞아떨어졌기 때문이었다. 단서 하나로는 부족하고 여러 개가 같은 곳을 가리켜야 한다는 걸 다시 확인했다.

3. 설정만 바꾸고 무효화를 안 했다

이번 사건의 핵심이 여기에 있다. 배포 파이프라인에 무효화가 잘 걸려 있어도, 배포 없이 CDN 설정만 만지면 기존 캐시는 그대로 남는다. 콘솔에서 캐시 정책을 켜놓고 “됐겠지” 하고 넘어간 게 화근이었다.

파이프라인도 정상이고 설정도 정상인데 둘의 적용 시점이 어긋나서 한 달을 모르고 지냈다. 앞으로 콘솔에서 CDN 설정을 건드리면 그 자리에서 무효화까지 돌려야겠다.

정리

다음에 확인할 것들

이번에 헤매면서 정리해둔 목록이다.

핵심 정리

개념설명
Accept-Encoding클라이언트가 지원하는 압축 방식 (요청)
Content-Encoding서버가 실제 사용한 압축 방식 (응답)
Vary: Accept-Encoding캐시를 인코딩별로 분리하라는 지시
Age오리진 생성·검증 이후 경과 시간에 대한 캐시의 추정값 (초)
gzipDEFLATE 기반, 32KB 윈도우, 지원 범위가 가장 넓음
Brotli독립 포맷(RFC 7932), 16MB 윈도우 + 웹 내장 사전
Brotli 레벨6은 스트리밍용, 11은 오프라인·빌드 타임용 (Node.js 공식 권고)
zstd브라우저 지원은 갖춰졌으나 CDN·서버 지원 범위가 아직 제약

내 사이트 기준으로 Brotli-11은 gzip-9 대비 전체 16%, HTML만 보면 17% 작았다. 체크박스 하나 켜서 얻는 것치고는 나쁘지 않다.

다만 이번에 배운 건 설정을 켰다고 바로 적용되는 게 아니라는 점이었다. CDN이 끼면 설정과 캐시가 따로 논다. 결국 curl -I 한 번 찍어보는 게 가장 확실했는데, 그걸 한 달 동안 안 해봤다는 게 이 글의 진짜 교훈인 것 같다.

참고 자료

명세 (RFC)

공식 문서

압축 알고리즘 구현체

브라우저 지원

CDN 및 프레임워크

관련 아티클