서로 다른 세 가지 문제
“사이트가 느리다”는 말은 보통 다음 셋 중 하나이고, 해결책이 각각 다릅니다.
- 첫 바이트가 늦게 옵니다. 브라우저가 서버의 응답 시작을 기다리고 있습니다. HTML 페이지 자체에 대해서는 압축도 캐시도 이 문제를 해결하지 못합니다.
- 오가는 바이트가 너무 많습니다. 텍스트 파일이 압축 없이 나가서 다운로드가 필요 이상으로 오래 걸립니다.
- 방문할 때마다 파일을 다시 받습니다. 캐시 헤더가 제대로 없으면 재방문자가 같은 CSS와 JavaScript를 매번 새로 받습니다.
무언가를 바꾸기 전에 먼저 재세요. 셋 다 curl로 10초면 확인할 수 있습니다.
첫 바이트 시간 재기
curl -s -o /dev/null -w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} ttfb %{time_starttransfer} total %{time_total}\n' https://example.com/
dns 0.012 connect 0.045 tls 0.112 ttfb 0.874 total 0.921
단위는 초이고, 요청 시작부터 누적된 값입니다. 차이로 읽어야 합니다.
- DNS 조회: 12ms.
- TCP 연결: 45 − 12 = 33ms. 서버까지 대략 한 번 왕복하는 시간입니다.
- TLS 핸드셰이크: 112 − 45 = 67ms.
- 핸드셰이크 뒤 첫 바이트까지 기다린 시간: 874 − 112 = 762ms. 서버가 요청을 처리한 시간(여기에 요청이 오가는 한 번의 왕복이 더해짐)입니다.
이 예에서는 네트워크는 멀쩡하고 서버가 느립니다. 반대로 connect가 300ms라면 서버가 멀거나 경로가 나쁜 것이고, 애플리케이션을 아무리 튜닝해도 해결되지 않습니다. CDN이나 더 가까운 리전이 답입니다.
서너 번 돌려 보세요. 첫 요청은 DNS, 애플리케이션, 데이터베이스의 캐시가 비어 있어서 나머지보다 느린 경우가 많습니다.
대략적인 목표로, web.dev는 대부분의 사이트에 실제 사용자 기준 TTFB 0.8초 이하를 제시합니다. HTTP 점검은 연결 수립을 포함해 DechoNet 서버에서 첫 응답을 받기까지의 시간을 보여 주고, 1초를 넘으면 표시합니다. 한 위치에서 잰 한 번의 표본이므로 대략적인 1차 확인용으로 쓰고, 실제 측정은 사용자가 있는 곳에서 curl로 하세요.
첫 바이트가 느릴 때
리다이렉트 체인. 리다이렉트 하나는 요청 하나를 통째로 더 하는 것입니다. http://example.com → https://example.com → https://www.example.com → https://www.example.com/ko/는 진짜 페이지가 시작되기 전에 왕복을 세 번 더 씁니다. 모든 단계를 이렇게 확인합니다.
curl -sIL http://example.com/ | grep -iE '^(HTTP|location)'
링크, canonical 태그, 첫 리다이렉트를 모두 최종 URL로 바로 보내세요. 어떤 리다이렉트를 쓸지는 301과 302 비교에 정리돼 있습니다.
HTML을 요청마다 새로 만듦. 로그인하지 않은 방문자 한 명을 위해 데이터베이스 쿼리를 수십 번 도는 CMS 페이지가, 첫 바이트가 느린 가장 흔한 원인입니다. 로그아웃 상태 방문자에게는 페이지 전체를 캐시하세요. 캐시 플러그인, nginx fastcgi_cache나 proxy_cache, HTML을 캐시하는 CDN 규칙 같은 방법이 있습니다.
애플리케이션이나 데이터베이스가 느림. nginx가 백엔드를 기다린 시간으로 어느 쪽인지 가릅니다.
log_format timing '$remote_addr "$request" $status rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/timing.log timing;
urt가 rt와 비슷하면 애플리케이션이 느린 것이니 애플리케이션 로그, 슬로 쿼리 로그, 외부 API 호출을 보세요. rt가 urt보다 훨씬 크면 시간이 클라이언트 쪽, 즉 느린 네트워크나 큰 업로드에서 쓰이고 있습니다.
콜드 스타트. 서버리스 함수나 잠들어 있던 무료 요금제 컨테이너는 한동안 조용하다가 들어온 첫 요청에 기동 지연을 더합니다. 느린 요청 하나 뒤에 빠른 요청이 이어지는 모양으로 나타납니다.
CDN 캐시 미스. CDN을 앞에 두었다면 캐시 상태 헤더를 보세요.
curl -sI https://example.com/ | grep -iE '^(cf-cache-status|x-cache|age)'
Cloudflare에서 DYNAMIC은 응답이 캐시 대상이 아니라는 뜻입니다. Cloudflare는 기본적으로 HTML을 캐시하지 않습니다. MISS는 캐시 대상이지만 아직 캐시에 없음, HIT는 캐시에서 나옴이고, age는 캐시에 들어간 지 몇 초 됐는지입니다.
압축
협상 방식
브라우저가 이해하는 인코딩을 나열하고, 서버가 그중 하나를 고릅니다.
요청: Accept-Encoding: gzip, deflate, br, zstd
응답: Content-Encoding: br
Vary: Accept-Encoding
브라우저는 HTTPS에서만 br을 내세웁니다. 최근 Chrome과 Firefox는 zstd도 내세우는데, 지원은 선택 사항입니다. Vary: Accept-Encoding은 캐시에게 압축본과 비압축본을 따로 보관하라고 알려 줍니다. 이것이 없으면 공유 캐시가 압축을 요청하지 않은 클라이언트에게 Brotli 본문을 줄 수 있습니다.
여기서 테스트에 관한 결론이 하나 나옵니다. 서버는 요청이 원할 때만 압축합니다. Accept-Encoding을 보내지 않는 도구나 스크립트는 압축되지 않은 응답을 받고, 브라우저에는 잘 압축해 주는 사이트를 “압축 없음”으로 보고합니다. 설정을 바꾸기 전에 직접 확인하세요.
curl로 확인하기
curl -sI -H 'Accept-Encoding: br' https://example.com/ | grep -iE '^(content-encoding|vary)'
curl -sI -H 'Accept-Encoding: gzip' https://example.com/ | grep -iE '^(content-encoding|vary)'
-I는 HEAD 요청을 보냅니다. 여기서 content-encoding이 안 보이면 결론을 내리기 전에 실제 GET으로 다시 해 보세요. HEAD를 다르게 처리하는 서버가 일부 있습니다.
curl -s -o /dev/null -D - -H 'Accept-Encoding: br' https://example.com/ | grep -i '^content-encoding'
얼마나 줄어드는지 보려면 실제 전송량을 비교합니다. curl은 --compressed를 주지 않으면 압축을 풀지 않으므로, 여기서 size_download는 전송된 크기입니다.
curl -s -o /dev/null -w '%{size_download}\n' https://example.com/assets/app.js
curl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: br' https://example.com/assets/app.js
HTML, CSS 파일, JavaScript 파일, JSON API 응답을 각각 확인하세요. 스택의 서로 다른 부분이 처리하는 경우가 많습니다.
무엇을 압축할까
텍스트를 압축합니다. HTML, CSS, JavaScript, JSON, SVG, XML, 일반 텍스트, 그리고 아직 압축되지 않은 폰트 형식(TTF, OTF)입니다. JPEG, PNG, WebP, AVIF, 동영상, zip 파일, WOFF2 폰트는 이미 압축돼 있어서 CPU만 씁니다. 1KB 정도보다 작은 응답은 얻는 것이 거의 없습니다.
gzip이냐 Brotli냐
둘 다 쓰세요. Brotli는 대개 gzip보다 텍스트를 더 작게 만들고, 현재 모든 브라우저가 HTTPS에서 지원합니다. gzip은 그 밖의 모든 경우를 위한 대비책입니다. 대가는 CPU입니다. 높은 Brotli 레벨은 느리므로 실시간 압축에는 쓰지 마세요. 좋은 방법은 정적 자산을 빌드할 때 최고 레벨로 미리 압축해 두고(app.js 옆에 app.js.br, app.js.gz) 서버가 파일을 고르게 하며, 동적 응답에는 중간 레벨을 쓰는 것입니다.
nginx
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
gzip_static on; # app.js.gz가 있으면 그 파일을 보냄
gzip on이면 text/html은 항상 압축되므로 gzip_types에 넣지 않습니다. gzip_static에는 nginx의 gzip_static 모듈이 필요한데, 대부분의 배포판 패키지에 들어 있습니다. gzip_proxied any는 Via 헤더를 붙이는 CDN이나 로드밸런서 뒤에서 중요합니다. nginx는 기본적으로 이 헤더가 있는 요청에 대한 응답을 압축하지 않습니다.
Brotli는 기본 nginx에 없습니다. ngx_brotli 모듈이 제공하고 일부 배포판이 패키지로 제공합니다. 모듈을 로드하면 지시어가 gzip과 비슷한 모양입니다(brotli on;, brotli_comp_level, brotli_types, brotli_static on;).
Apache
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>
mod_brotli는 Apache 2.4.26부터 함께 배포됩니다. 모듈을 켠 뒤(Debian·Ubuntu는 a2enmod deflate brotli) 위의 curl 명령 두 개로 br 요청과 gzip 요청이 각각 요청한 인코딩을 받는지 확인하세요.
Cloudflare와 다른 CDN
Cloudflare는 압축 가능한 콘텐츠 유형을 방문자의 Accept-Encoding에 따라 gzip이나 Brotli로 직접 압축합니다. 보통 따로 켤 것은 없지만 curl 확인은 여전히 필요합니다. 방문자가 받는 것은 CDN을 거친 응답이므로 CDN을 통해 테스트하세요.
Cache-Control: 정책은 하나가 아니라 둘
캐시 실수는 대부분 모든 파일을 똑같이 다루는 데서 나옵니다. 사이트에는 최소 두 가지 정책이 필요합니다.
이름에 해시가 들어간 파일
빌드 도구는 app.3f9a1c.js 같은 이름을 만듭니다. 내용이 바뀌면 이름이 바뀌므로 예전 URL은 영원히 캐시해도 됩니다.
Cache-Control: public, max-age=31536000, immutable
immutable은 사용자가 새로고침해도 다시 확인하지 말라고 브라우저에 알려 줍니다. Firefox와 Safari가 따릅니다. Chrome은 구현하지 않았지만, 일반 새로고침에서 하위 리소스를 다시 확인하지 않도록 이미 바뀌었기 때문에 결과는 비슷합니다.
HTML
새 해시 파일을 가리키는 것이 HTML이므로, 배포 뒤에 오래된 HTML이 나가서는 안 됩니다.
Cache-Control: no-cache
no-cache는 “캐시하지 말라”가 아닙니다. “보관하되 쓰기 전에 서버에 확인하라”는 뜻입니다. ETag나 Last-Modified 헤더(다음 절)가 있으면 그 확인은 대개 본문 없는 304로 끝나 빠릅니다. 사본을 아예 남기지 말라는 것은 no-store입니다. 계정 화면이나 금융 화면처럼 민감한 개인 정보가 있는 페이지에 쓰세요.
로그인한 사용자마다 내용이 다른 페이지에는 private을 붙여 CDN이나 회사 프록시 같은 공유 캐시가 저장하지 못하게 하세요. Cache-Control: private, no-cache.
그 밖의 파일
/logo.png나 /css/style.css처럼 이름에 해시가 없는 파일은 타협이 필요합니다. 한 시간이나 하루 정도의 적당한 max-age에 검증자(validator)를 함께 씁니다. 더 좋은 방법은 빌드를 거치게 해서 해시 이름을 붙이거나, 버전 쿼리 문자열(style.css?v=42)을 붙이고 배포할 때마다 바꾸는 것입니다.
확인해 볼 만한 실수
- Cache-Control이 아예 없다고 “캐시 안 됨”이 아닙니다. 브라우저는
Last-Modified날짜가 있는 응답에 휴리스틱 유효 기간을 적용하는데, 흔히 파일이 마지막으로 바뀐 뒤 지난 시간의 일부(보통 10%)입니다. 배포 후 일부 방문자가 몇 시간씩 예전 HTML을 보는 이유가 이것입니다. HTML에는 항상 명시적인 정책을 주세요. Expires와Pragma.max-age가Expires보다 우선합니다.Pragma: no-cache는 HTTP/1.0의 요청 헤더라서 응답에 넣어도 현대 캐시에는 쓸모 있는 효과가 없습니다.- 해시 없는 파일에 긴
max-age./js/main.js를 1년 캐시해 두고 내용을 바꾸면, 재방문자는 만료되거나 캐시를 지울 때까지 예전 버전을 씁니다. 되돌릴 방법이 없습니다.
nginx·Apache 예시
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location = /index.html {
add_header Cache-Control "no-cache" always;
}
nginx의 add_header 상속 규칙을 기억하세요. 자체 add_header가 있는 location은 보안 헤더를 포함해 server 블록의 add_header를 모두 잃습니다. 이 location에도 보안 헤더 스니펫을 include하세요. 자세한 내용은 HTTP 보안 헤더 가이드에 있습니다.
<FilesMatch "\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|webp|svg)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
Header set Cache-Control "no-cache"
</FilesMatch>
패턴은 빌드가 해시 파일 이름을 짓는 방식에 맞게 고치세요.
ETag와 Last-Modified
이 둘은 검증자입니다. 서버가 응답과 함께 보내 두면, 다음번에 브라우저가 “이 시점 이후로 바뀌었나?”라고 묻고, 바뀌지 않았으면 서버가 본문 없이 304 Not Modified로 답합니다.
curl -sI https://example.com/ | grep -iE '^(etag|last-modified)'
etag: "65f1c2a0-bd3"
last-modified: Tue, 06 Oct 2026 09:12:00 GMT
curl -sI -H 'If-None-Match: "65f1c2a0-bd3"' https://example.com/ | head -1
HTTP/2 304
항상 200이 돌아온다면 다음을 보세요.
- 로드밸런서 뒤의 여러 서버가 같은 파일에 서로 다른 ETag를 만듭니다. 예를 들어 ETag를 파일 수정 시각으로 만드는데 서버마다 배포가 조금씩 다른 시각에 들어간 경우입니다. 요청이 서버 사이를 오가며 끝내 일치하지 않습니다. ETag를 내용 기반으로 만들거나, ETag를 끄고 시각이 일관된 Last-Modified에 맡기세요.
- 애플리케이션이 페이지를 동적으로 만들면서 검증자를 보내지 않습니다. 많은 프레임워크는 동적 응답에 ETag를 붙이려면 미들웨어나 설정이 필요합니다.
- 약한 ETag(
W/"…")는 괜찮습니다. nginx는 응답을 압축할 때 강한 ETag를 약한 ETag로 바꾸고, 조건부 요청은 그래도 동작합니다.
CDN 계층
CDN은 자기 규칙을 가진 두 번째 캐시를 더합니다. CDN이 캐시하지 못하게 만드는 흔한 원인은 다음과 같습니다.
- 응답의
Set-Cookie. 쿠키를 설정하는 응답은 캐시하지 않는 CDN이 많고, 캐시되기를 바라서도 안 됩니다. 정적 파일이 세션 쿠키를 설정하지 않는지 확인하세요. Vary: Cookie나Vary: User-Agent. 캐시를 거의 요청마다 다른 항목으로 쪼갭니다.- 매번 다른 쿼리 문자열. 추적 파라미터(
?utm_source=…,?fbclid=…)는 CDN이 무시하도록 설정하지 않으면 URL마다 별도의 캐시 항목을 만들 수 있습니다. - 원 서버가 보내는
private이나no-store. 프레임워크가 전역으로 설정해 두는 경우가 많습니다.
s-maxage는 브라우저와 별도로 공유 캐시의 유효 기간을 정합니다. Cache-Control: public, max-age=0, s-maxage=300이면 브라우저는 매번 확인하고 CDN은 5분 동안 HTML을 내줍니다. 배포할 때 퍼지(purge)와 함께 쓰세요. 원 서버에서는 바뀐 내용이 보이는데 CDN을 거치면 안 보인다면 DNS가 아니라 캐시 문제입니다. 둘을 구별하는 방법은 CDN 캐시와 DNS 전파에 있습니다.
작업 순서
- 리다이렉트 체인과 HTML의 첫 바이트부터 고치세요. 페이지 자체가 늦게 시작하면 다른 어떤 것도 도움이 되지 않습니다.
- 텍스트 압축을 켜고,
br요청과gzip요청이 각각 압축된 응답을 받는지 curl로 확인하세요. - 빌드 결과물에 해시 이름과 1년짜리
immutable정책을, HTML에는 검증자와 함께no-cache를 주세요. - HTML과 정적 자산 모두 CDN 캐시 상태를 확인하세요.
- HTTP 점검과 curl 명령을 다시 돌리세요. HTTP 점검은 첫 응답 시간, 최종 응답의
Content-Encoding과Cache-Control을 한 화면에 보여 줍니다. 이 항목들은 참고 사항으로 표시될 뿐 보안 등급에는 들어가지 않습니다.
내 도메인에서 바로 확인
무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.