카테고리 없음
HTTP 캐싱 (ETag, Cache-Control)
SuldenLion
2026. 3. 1. 10:17
반응형
HTTP 캐싱: ETag, Cache-Control, 그리고 최적화 전략
들어가며
HTTP 캐싱은 웹 성능 최적화의 가장 기본이면서도 강력한 메커니즘입니다. "같은 리소스를 반복해서 다운로드하지 않는다"는 단순한 원칙으로 네트워크 대역폭을 절약하고, 응답 속도를 획기적으로 개선하며, 서버 부하를 줄입니다. Cache-Control, ETag, Last-Modified 등의 HTTP 헤더를 통해 브라우저와 프록시가 언제, 얼마나 오래 캐싱할지 정밀하게 제어할 수 있습니다. Google, Facebook, Amazon 등 모든 대규모 웹사이트가 활용하는 HTTP 캐싱의 모든 것을 깊이 있게 탐구해봅시다.
1. HTTP 캐싱의 본질
1.1 HTTP 캐싱이란?
HTTP 캐싱 = 브라우저/프록시가 리소스를 저장하고 재사용
캐싱 없이:
User → GET /style.css
Server → 200 OK, 500KB CSS
User → GET /style.css (다시 방문)
Server → 200 OK, 500KB CSS (다시 전송!)
User → GET /style.css (새로고침)
Server → 200 OK, 500KB CSS (또 전송!)
문제:
✗ 불필요한 네트워크 사용
✗ 느린 로딩
✗ 서버 부하
HTTP 캐싱:
User → GET /style.css
Server → 200 OK, 500KB CSS
+ Cache-Control: max-age=31536000
User → GET /style.css (다시 방문)
Browser Cache → 200 OK, 500KB (즉시, 네트워크 X)
User → GET /style.css (1년 후)
User → GET /style.css, If-None-Match: "abc123"
Server → 304 Not Modified (응답만, 본문 X)
Browser → 캐시된 파일 사용
장점:
✓ 빠른 응답 (네트워크 생략)
✓ 대역폭 절약
✓ 서버 부하 감소
캐싱 계층:
┌──────────────────────────────────┐
│ Browser Cache (브라우저) │ ← 1차 캐시
│ - Disk Cache │
│ - Memory Cache │
└──────────────┬───────────────────┘
│
↓
┌──────────────────────────────────┐
│ Proxy Cache (프록시/CDN) │ ← 2차 캐시
│ - Shared Cache │
│ - Edge Cache │
└──────────────┬───────────────────┘
│
↓
┌──────────────────────────────────┐
│ Origin Server (원본 서버) │
└──────────────────────────────────┘
캐싱 결정 흐름:
1. 요청 수신
GET /image.jpg
2. 캐시 확인
캐시 있음?
├─ YES → 신선함 확인
│ ├─ Fresh → 캐시 사용 (200 from cache)
│ └─ Stale → 재검증 요청
│ ├─ 304 → 캐시 사용
│ └─ 200 → 새 리소스
└─ NO → 서버 요청 (200 + 캐싱)
3. 응답 전달
주요 개념:
✓ 신선도 (Freshness)
- max-age: 얼마나 오래 신선한가
- Age: 캐시된 지 얼마나 됐는가
✓ 검증 (Validation)
- ETag: 콘텐츠 버전 식별자
- Last-Modified: 마지막 수정 시간
✓ 무효화 (Invalidation)
- Cache-Control: no-cache
- 버전 관리 (v=123)
1.2 왜 HTTP 캐싱인가?
장점:
✓ 극적인 성능 향상
- 첫 로딩: 3초
- 재방문: 0.3초 (10배 빠름)
- 캐시 히트율 90% 가능
✓ 대역폭 절약
- 모바일 데이터 절약
- CDN 비용 절감
- 서버 트래픽 감소
✓ 서버 부하 감소
- CPU 사용량 감소
- DB 쿼리 감소
- 동시 접속 처리 증가
✓ 오프라인 지원
- Service Worker + Cache
- PWA
- 네트워크 없어도 작동
✓ 사용자 경험
- 빠른 로딩
- 부드러운 탐색
- 낮은 이탈률
단점:
✗ 복잡성
- 올바른 헤더 설정
- 캐시 무효화 전략
- 버전 관리
✗ 디버깅
- 캐시 문제 파악 어려움
- "캐시 때문에 안 나와요"
- 브라우저별 차이
✗ 일관성
- 오래된 캐시
- 즉시 업데이트 어려움
효과:
성능:
- 로딩 시간: 50-90% 감소
- 대역폭: 60-95% 절약
- 서버 요청: 70-99% 감소
비용:
- CDN 비용 절감
- 서버 비용 절감
- 인프라 효율 증가
1.3 캐싱 전략 개요
1. 캐싱하지 않음 (No Caching)
Cache-Control: no-store
- 민감한 정보
- 개인정보
- 절대 캐싱 안 함
2. 항상 재검증 (Always Revalidate)
Cache-Control: no-cache
- HTML 파일
- API 응답
- 매번 서버 확인
3. 짧은 캐싱 (Short-Term)
Cache-Control: max-age=300 (5분)
- 뉴스
- 동적 콘텐츠
- 자주 변경
4. 중간 캐싱 (Medium-Term)
Cache-Control: max-age=3600 (1시간)
- 이미지
- CSS (버전 없이)
- 일반 콘텐츠
5. 장기 캐싱 (Long-Term)
Cache-Control: max-age=31536000 (1년)
- 버전화된 파일
- /css/style.v123.css
- /js/app.abc123.js
6. 불변 캐싱 (Immutable)
Cache-Control: max-age=31536000, immutable
- 절대 변경 안 됨
- CDN 최적화
- 재검증 생략
파일 타입별 권장:
┌──────────────┬─────────────────────────────┐
│ 파일 타입 │ 권장 설정 │
├──────────────┼─────────────────────────────┤
│ HTML │ no-cache 또는 max-age=300 │
│ CSS/JS │ max-age=31536000 + 버전 │
│ 이미지 │ max-age=86400 ~ 31536000 │
│ 폰트 │ max-age=31536000, immutable │
│ JSON API │ no-cache 또는 max-age=60 │
│ 비디오 │ max-age=604800 (7일) │
│ PDF │ max-age=3600 ~ 86400 │
└──────────────┴─────────────────────────────┘
2. Cache-Control 헤더
2.1 Cache-Control 기본
http
# 기본 문법
Cache-Control: <directive>, <directive>, ...
# 예시
Cache-Control: max-age=3600, public
Cache-Control: no-cache, no-store, must-revalidate
Cache-Control: max-age=31536000, immutable
주요 디렉티브:
1. max-age=<seconds>
캐시 신선도 기간 (초)
Cache-Control: max-age=3600 # 1시간
Cache-Control: max-age=86400 # 1일
Cache-Control: max-age=31536000 # 1년
2. s-maxage=<seconds>
공유 캐시(CDN/프록시)용 max-age
브라우저는 무시, CDN만 적용
Cache-Control: max-age=300, s-maxage=3600
# 브라우저: 5분, CDN: 1시간
3. no-cache
캐싱 가능, 하지만 매번 재검증
(no-store와 다름!)
Cache-Control: no-cache
# 캐시 O, 사용 전 서버 확인 필요
4. no-store
절대 캐싱 안 함
Cache-Control: no-store
# 디스크에도 메모리에도 저장 X
5. must-revalidate
만료 후 반드시 재검증
Cache-Control: max-age=3600, must-revalidate
# 1시간 후 반드시 서버 확인
6. public
공유 캐시에도 저장 가능
Cache-Control: max-age=3600, public
# CDN/프록시 캐싱 허용
7. private
브라우저만 캐싱 가능
Cache-Control: max-age=3600, private
# CDN/프록시 캐싱 금지
8. immutable
절대 변경 안 됨, 재검증 불필요
Cache-Control: max-age=31536000, immutable
# 새로고침해도 재검증 안 함
9. stale-while-revalidate=<seconds>
만료 후에도 N초간 사용 가능
백그라운드에서 재검증
Cache-Control: max-age=3600, stale-while-revalidate=86400
# 1시간 후 만료, 하지만 1일간 사용 가능 (백그라운드 갱신)
10. stale-if-error=<seconds>
서버 에러 시 만료된 캐시 사용
Cache-Control: max-age=3600, stale-if-error=86400
# 서버 다운 시 1일 된 캐시라도 사용
2.2 Cache-Control 실전 예제
nginx
# Nginx 설정
# HTML - 항상 재검증
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
# CSS/JS (버전 관리 파일) - 1년 불변
location ~* \.(css|js)$ {
add_header Cache-Control "max-age=31536000, immutable";
}
# 이미지 - 1개월
location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
add_header Cache-Control "max-age=2592000, public";
}
# 웹폰트 - 1년 불변
location ~* \.(woff|woff2|ttf|otf)$ {
add_header Cache-Control "max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
# API - 재검증 또는 짧은 캐싱
location /api/ {
add_header Cache-Control "no-cache";
# 또는
# add_header Cache-Control "max-age=60, must-revalidate";
}
# 관리자 페이지 - 캐싱 안 함
location /admin/ {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Apache 설정 (.htaccess)
# HTML
<FilesMatch "\.(html)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
# CSS/JS
<FilesMatch "\.(css|js)$">
Header set Cache-Control "max-age=31536000, immutable"
</FilesMatch>
# 이미지
<FilesMatch "\.(jpg|jpeg|png|gif|ico|svg)$">
Header set Cache-Control "max-age=2592000, public"
</FilesMatch>
# Express.js (Node.js)
const express = require('express');
const app = express();
// 정적 파일 (1년)
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}));
// HTML (재검증)
app.get('/', (req, res) => {
res.set('Cache-Control', 'no-cache, must-revalidate');
res.sendFile('index.html');
});
// API (짧은 캐싱)
app.get('/api/data', (req, res) => {
res.set('Cache-Control', 'max-age=60, must-revalidate');
res.json({ data: 'example' });
});
# Django (Python)
from django.views.decorators.cache import cache_control
# HTML - 재검증
@cache_control(no_cache=True, must_revalidate=True)
def index(request):
return render(request, 'index.html')
# API - 1분
@cache_control(max_age=60, must_revalidate=True)
def api_data(request):
return JsonResponse({'data': 'example'})
# 정적 파일 - 1년 (settings.py)
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
WHITENOISE_MAX_AGE = 31536000
# Flask (Python)
from flask import Flask, make_response
app = Flask(__name__)
@app.route('/')
def index():
response = make_response(render_template('index.html'))
response.headers['Cache-Control'] = 'no-cache, must-revalidate'
return response
@app.route('/api/data')
def api_data():
response = make_response({'data': 'example'})
response.headers['Cache-Control'] = 'max-age=60, must-revalidate'
return response
2.3 Cache-Control 조합 전략
http
# 전략 1: 정적 파일 (변경 안 됨)
Cache-Control: max-age=31536000, immutable
예: /css/style.abc123.css (해시 포함)
효과:
✓ 1년간 캐싱
✓ 재검증 안 함
✓ CDN 최적화
# 전략 2: HTML (자주 변경)
Cache-Control: no-cache, must-revalidate
또는
Cache-Control: max-age=0, must-revalidate
예: /index.html
효과:
✓ 매번 서버 확인
✓ 304 가능 (ETag)
✓ 최신 유지
# 전략 3: API (짧은 캐싱)
Cache-Control: max-age=60, private, must-revalidate
예: /api/user/profile
효과:
✓ 1분간 캐싱
✓ 브라우저만 (private)
✓ 만료 후 재검증
# 전략 4: 공개 이미지 (중간 캐싱)
Cache-Control: max-age=86400, public, stale-while-revalidate=604800
예: /images/logo.png
효과:
✓ 1일 캐싱
✓ CDN 허용
✓ 만료 후 1주일간 사용 (백그라운드 갱신)
# 전략 5: 민감한 정보 (캐싱 안 함)
Cache-Control: no-store, no-cache, must-revalidate, private
Pragma: no-cache
Expires: 0
예: /account/settings
효과:
✓ 절대 캐싱 안 함
✓ 디스크/메모리 저장 X
✓ 레거시 브라우저 대응 (Pragma, Expires)
# 전략 6: 뉴스/피드 (매우 짧은 캐싱)
Cache-Control: max-age=30, must-revalidate, stale-if-error=300
예: /news/latest
효과:
✓ 30초 캐싱
✓ 서버 에러 시 5분 된 캐시 사용
✓ 가용성 향상
# 전략 7: 버전화된 자산 (최대 캐싱)
Cache-Control: max-age=31536000, public, immutable
예: /static/v2.3.1/app.js
효과:
✓ 1년 캐싱
✓ 모든 캐시 허용
✓ 완전 불변
3. ETag (Entity Tag)
3.1 ETag 기본
http
ETag란?
리소스의 버전을 나타내는 식별자
파일이 변경되면 ETag도 변경됨
# 서버 응답
HTTP/1.1 200 OK
ETag: "abc123def456"
Content-Type: text/css
Cache-Control: max-age=3600
body { color: red; }
# 재요청 (조건부)
GET /style.css HTTP/1.1
If-None-Match: "abc123def456"
# 변경 안 됨 → 304
HTTP/1.1 304 Not Modified
ETag: "abc123def456"
(본문 없음, 헤더만)
# 변경됨 → 200
HTTP/1.1 200 OK
ETag: "xyz789ghi012"
Content-Type: text/css
body { color: blue; } # 새 내용
ETag 생성 방법:
1. Strong ETag (기본)
ETag: "abc123"
- 바이트 단위 동일
- 압축 여부 상관없이 동일
2. Weak ETag
ETag: W/"abc123"
- 의미적으로 동일
- 공백, 압축 차이 허용
ETag 생성 알고리즘:
1. 파일 해시 (MD5, SHA)
ETag: "md5_hash"
장점: 정확
단점: CPU 비용
2. 수정 시간 + 크기
ETag: "mtime-size"
장점: 빠름
단점: 부정확 (여러 서버)
3. 버전 번호
ETag: "v123"
장점: 간단
단점: 수동 관리
4. inode-mtime-size (Apache 기본)
ETag: "inode-mtime-size"
문제: 서버마다 inode 다름!
3.2 ETag 구현
nginx
# Nginx - ETag 자동 생성
location / {
etag on; # 기본값
}
# ETag 비활성화 (필요시)
location /dynamic/ {
etag off;
}
# Apache - ETag 설정
# inode 제거 (여러 서버 환경)
FileETag MTime Size
# ETag 비활성화
<FilesMatch "\.(html)$">
Header unset ETag
FileETag None
</FilesMatch>
# Express.js
const express = require('express');
const etag = require('etag');
const fs = require('fs');
app.get('/file', (req, res) => {
const filePath = 'public/file.txt';
const stat = fs.statSync(filePath);
// ETag 생성 (mtime + size)
const fileETag = etag(stat);
// If-None-Match 확인
if (req.headers['if-none-match'] === fileETag) {
res.status(304).end();
return;
}
// 파일 전송
res.set('ETag', fileETag);
res.set('Cache-Control', 'max-age=3600');
res.sendFile(filePath);
});
# Python Flask
from flask import Flask, make_response, request
import hashlib
app = Flask(__name__)
@app.route('/data')
def get_data():
data = get_latest_data()
# ETag 생성 (MD5)
etag = hashlib.md5(str(data).encode()).hexdigest()
# If-None-Match 확인
if request.headers.get('If-None-Match') == f'"{etag}"':
return '', 304
# 응답
response = make_response(data)
response.set_etag(etag)
response.headers['Cache-Control'] = 'max-age=60'
return response
# Django
from django.http import HttpResponse
from django.views.decorators.http import condition
import hashlib
def get_etag(request):
data = get_latest_data()
return hashlib.md5(str(data).encode()).hexdigest()
@condition(etag_func=get_etag)
def data_view(request):
data = get_latest_data()
return HttpResponse(data, content_type='application/json')
# Go (Gin)
package main
import (
"crypto/md5"
"fmt"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/data", func(c *gin.Context) {
data := getLatestData()
// ETag 생성
etag := fmt.Sprintf("%x", md5.Sum([]byte(data)))
// If-None-Match 확인
if c.GetHeader("If-None-Match") == etag {
c.Status(304)
return
}
// 응답
c.Header("ETag", etag)
c.Header("Cache-Control", "max-age=60")
c.String(200, data)
})
r.Run()
}
3.3 ETag vs Last-Modified
http
Last-Modified (날짜 기반):
# 서버 응답
HTTP/1.1 200 OK
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMT
Cache-Control: max-age=3600
# 재요청 (조건부)
GET /file.txt HTTP/1.1
If-Modified-Since: Wed, 15 Jan 2024 10:30:00 GMT
# 변경 안 됨 → 304
HTTP/1.1 304 Not Modified
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMT
# 변경됨 → 200
HTTP/1.1 200 OK
Last-Modified: Wed, 15 Jan 2024 11:45:00 GMT
비교:
┌──────────────────┬──────────┬──────────────┐
│ 항목 │ ETag │Last-Modified │
├──────────────────┼──────────┼──────────────┤
│ 정확도 │ 높음 │ 낮음 │
│ 성능 │ 중간 │ 빠름 │
│ 1초 내 변경 감지 │ 가능 │ 불가능 │
│ 콘텐츠 기반 │ 가능 │ 불가능 │
│ 여러 서버 │ 어려움* │ 가능 │
│ 표준 지원 │ HTTP/1.1 │ HTTP/1.0+ │
└──────────────────┴──────────┴──────────────┘
* inode 포함 시 문제
권장:
둘 다 사용 (최대 호환성):
HTTP/1.1 200 OK
ETag: "abc123"
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMT
Cache-Control: max-age=3600
재요청:
GET /file.txt HTTP/1.1
If-None-Match: "abc123"
If-Modified-Since: Wed, 15 Jan 2024 10:30:00 GMT
우선순위: ETag > Last-Modified
사용 케이스:
ETag 적합:
✓ 동적 콘텐츠 (API)
✓ 1초 내 변경 가능
✓ 콘텐츠 기반 캐싱
✓ 정확도 중요
Last-Modified 적합:
✓ 정적 파일
✓ 파일 시스템 기반
✓ 성능 중요
✓ 단순함
4. 캐시 무효화 전략
4.1 버전 관리 (Cache Busting)
javascript
// 방법 1: 쿼리 문자열
<link rel="stylesheet" href="/css/style.css?v=1.2.3">
<script src="/js/app.js?v=1.2.3"></script>
장점: 간단
단점: 일부 프록시가 쿼리 문자열 무시
// 방법 2: 파일명 (권장)
<link rel="stylesheet" href="/css/style.1.2.3.css">
<script src="/js/app.a8b7c9d.js"></script>
장점: 안정적, CDN 친화적
단점: 빌드 도구 필요
// 방법 3: 경로
<link rel="stylesheet" href="/v1.2.3/css/style.css">
<script src="/v1.2.3/js/app.js"></script>
장점: 버전별 관리
단점: 디렉토리 구조
// Webpack 설정 (해시 기반)
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js'
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
})
]
};
// 빌드 결과:
// app.a8b7c9d45ef1.js
// main.3f2a1b8c.css
// HTML 자동 생성 (HtmlWebpackPlugin)
<!DOCTYPE html>
<html>
<head>
<link href="/css/main.3f2a1b8c.css" rel="stylesheet">
</head>
<body>
<script src="/js/app.a8b7c9d45ef1.js"></script>
</body>
</html>
// Gulp 설정
const gulp = require('gulp');
const rev = require('gulp-rev');
const revRewrite = require('gulp-rev-rewrite');
// 파일 해시 생성
gulp.task('rev-assets', () => {
return gulp.src(['css/**/*.css', 'js/**/*.js'])
.pipe(rev())
.pipe(gulp.dest('dist'))
.pipe(rev.manifest())
.pipe(gulp.dest('dist'));
});
// HTML 업데이트
gulp.task('rev-update-references', () => {
const manifest = gulp.src('dist/rev-manifest.json');
return gulp.src('index.html')
.pipe(revRewrite({ manifest }))
.pipe(gulp.dest('dist'));
});
4.2 캐시 무효화 API
bash
# Cloudflare API
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/css/style.css"]}'
# CloudFront CLI
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/css/*" "/js/*"
# Varnish
curl -X PURGE http://example.com/css/style.css
# 또는 VCL에서
varnishadm "ban req.url ~ /css/"
# Nginx (Purge 모듈)
curl -X PURGE http://example.com/css/style.css
4.3 선택적 캐싱
javascript
// Service Worker - 세밀한 캐싱 제어
// install 이벤트
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/css/style.css',
'/js/app.js',
'/images/logo.png'
]);
})
);
});
// fetch 이벤트 - 캐싱 전략
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// HTML - Network First
if (url.pathname.endsWith('.html')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open('v1').then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
}
// CSS/JS - Cache First
else if (url.pathname.match(/\.(css|js)$/)) {
event.respondWith(
caches.match(event.request)
.then((response) => {
return response || fetch(event.request);
})
);
}
// 이미지 - Stale While Revalidate
else if (url.pathname.match(/\.(jpg|png|gif)$/)) {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
const fetchPromise = fetch(event.request)
.then((networkResponse) => {
caches.open('v1').then((cache) => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
return cachedResponse || fetchPromise;
})
);
}
// 기본 - Network Only
else {
event.respondWith(fetch(event.request));
}
});
// 캐시 업데이트 (새 버전)
self.addEventListener('activate', (event) => {
const cacheWhitelist = ['v2'];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
5. 실전 최적화
5.1 HTML 최적화
html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>캐싱 최적화 예제</title>
<!-- Critical CSS (인라인, 캐싱 안 함) -->
<style>
/* 최소한의 Critical CSS */
body { margin: 0; font-family: sans-serif; }
.header { background: #333; color: white; }
</style>
<!-- 외부 CSS (해시, 1년 캐싱) -->
<link rel="stylesheet" href="/css/main.a8b7c9d.css">
<!-- 프리로드 (중요한 리소스) -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- DNS 프리페치 -->
<link rel="dns-prefetch" href="https://cdn.example.com">
</head>
<body>
<!-- 콘텐츠 -->
<!-- JS (defer, 해시, 1년 캐싱) -->
<script src="/js/app.3f2a1b8.js" defer></script>
</body>
</html>
nginx
# Nginx - HTML 설정
location ~* \.html$ {
# 재검증 (짧은 캐싱 또는 no-cache)
add_header Cache-Control "no-cache, must-revalidate";
# 또는 짧은 캐싱
# add_header Cache-Control "max-age=300, must-revalidate";
# ETag 활성화
etag on;
# Gzip 압축
gzip on;
gzip_types text/html;
}
5.2 정적 자산 최적화
nginx
# Nginx - 정적 파일 설정
# CSS/JS (버전화된 파일)
location ~* \.(css|js)$ {
# 1년 불변 캐싱
add_header Cache-Control "max-age=31536000, public, immutable";
# ETag (선택적)
etag on;
# Gzip 압축
gzip on;
gzip_types text/css application/javascript;
gzip_vary on;
}
# 이미지
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
# 1개월 캐싱
add_header Cache-Control "max-age=2592000, public";
# Vary (WebP 지원)
add_header Vary "Accept";
# 압축 (SVG만)
gzip on;
gzip_types image/svg+xml;
}
# 웹폰트
location ~* \.(woff|woff2|ttf|otf|eot)$ {
# 1년 불변 캐싱
add_header Cache-Control "max-age=31536000, public, immutable";
# CORS (다른 도메인 폰트)
add_header Access-Control-Allow-Origin "*";
}
# 비디오
location ~* \.(mp4|webm|ogg)$ {
# 7일 캐싱
add_header Cache-Control "max-age=604800, public";
# Range 요청 지원
add_header Accept-Ranges "bytes";
}
# Apache (.htaccess)
<IfModule mod_headers.c>
# CSS/JS
<FilesMatch "\.(css|js)$">
Header set Cache-Control "max-age=31536000, public, immutable"
</FilesMatch>
# 이미지
<FilesMatch "\.(jpg|jpeg|png|gif|ico|svg|webp)$">
Header set Cache-Control "max-age=2592000, public"
</FilesMatch>
# 웹폰트
<FilesMatch "\.(woff|woff2|ttf|otf|eot)$">
Header set Cache-Control "max-age=31536000, public, immutable"
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>
5.3 API 최적화
javascript
// Express.js - API 캐싱
const express = require('express');
const app = express();
// 짧은 캐싱 (1분)
app.get('/api/news', (req, res) => {
const news = getLatestNews();
res.set('Cache-Control', 'max-age=60, must-revalidate');
res.set('ETag', generateETag(news));
res.json(news);
});
// 조건부 요청 (ETag)
app.get('/api/user/:id', (req, res) => {
const user = getUser(req.params.id);
const etag = generateETag(user);
// If-None-Match 확인
if (req.headers['if-none-match'] === etag) {
res.status(304).end();
return;
}
res.set('Cache-Control', 'max-age=300, private, must-revalidate');
res.set('ETag', etag);
res.json(user);
});
// 캐싱 안 함 (민감한 데이터)
app.get('/api/account', authenticate, (req, res) => {
const account = getAccountData(req.user.id);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.set('Pragma', 'no-cache');
res.json(account);
});
// Vary 헤더 (Accept-Encoding)
app.get('/api/data', (req, res) => {
const data = getData();
res.set('Cache-Control', 'max-age=120');
res.set('Vary', 'Accept-Encoding');
res.json(data);
});
// GraphQL - 캐싱
const { ApolloServer } = require('apollo-server-express');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart() {
return {
willSendResponse({ response }) {
// 쿼리만 캐싱 (mutation 제외)
if (!response.errors) {
response.http.headers.set(
'Cache-Control',
'max-age=60, public'
);
}
}
};
}
}
]
});
5.4 CDN 최적화
javascript
// Cloudflare Workers - 캐싱 제어
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
// API 요청 캐싱
if (url.pathname.startsWith('/api/')) {
const cacheKey = new Request(url.toString(), request)
const cache = caches.default
// 캐시 확인
let response = await cache.match(cacheKey)
if (!response) {
// 원본 요청
response = await fetch(request)
// 성공 시 캐싱
if (response.status === 200) {
response = new Response(response.body, response)
response.headers.set('Cache-Control', 'max-age=60')
// Edge 캐시 저장
event.waitUntil(cache.put(cacheKey, response.clone()))
}
}
return response
}
// 기본 요청
return fetch(request)
}
// CloudFront - Lambda@Edge 캐싱
exports.handler = async (event) => {
const response = event.Records[0].cf.response
const request = event.Records[0].cf.request
// API 응답에 캐싱 헤더 추가
if (request.uri.startsWith('/api/')) {
response.headers['cache-control'] = [{
key: 'Cache-Control',
value: 'max-age=60, public'
}]
}
// 정적 파일 캐싱 강화
if (request.uri.match(/\.(css|js)$/)) {
response.headers['cache-control'] = [{
key: 'Cache-Control',
value: 'max-age=31536000, public, immutable'
}]
}
return response
}
6. 디버깅 & 모니터링
6.1 Chrome DevTools
javascript
// Chrome DevTools - Network 탭
// 캐시 상태 확인:
// Size 컬럼:
// - "(from disk cache)" : 디스크 캐시
// - "(from memory cache)" : 메모리 캐시
// - "1.2 KB" : 네트워크
// Headers 탭:
// Response Headers:
// Cache-Control: max-age=3600
// ETag: "abc123"
// Age: 245 (캐시된 지 245초)
// Request Headers:
// If-None-Match: "abc123"
// If-Modified-Since: ...
// 캐시 무시 새로고침:
// Ctrl+Shift+R (Windows/Linux)
// Cmd+Shift+R (Mac)
// 캐시 비우기:
// DevTools → Network → Disable cache (체크)
// 또는
// Application → Storage → Clear site data
// Console에서 캐시 확인:
// Service Worker 캐시
caches.keys().then(console.log)
// 특정 캐시 내용
caches.open('v1').then(cache => {
cache.keys().then(console.log)
})
6.2 curl로 헤더 확인
bash
# 헤더만 확인
curl -I https://example.com/style.css
# 출력:
HTTP/2 200
cache-control: max-age=31536000, immutable
etag: "abc123def456"
content-type: text/css
age: 3600
# 캐시된 지 1시간 (Age: 3600)
# 조건부 요청 테스트
curl -I https://example.com/style.css \
-H "If-None-Match: \"abc123def456\""
# 304 응답
HTTP/2 304
cache-control: max-age=31536000, immutable
etag: "abc123def456"
# 전체 요청/응답
curl -v https://example.com/style.css
# 캐시 무시
curl -H "Cache-Control: no-cache" https://example.com/style.css
# 여러 헤더 확인
curl -I https://example.com/style.css \
-H "If-None-Match: \"abc123\"" \
-H "If-Modified-Since: Wed, 15 Jan 2024 10:00:00 GMT"
6.3 성능 측정
javascript
// Performance API
// Navigation Timing
const perfData = performance.getEntriesByType('navigation')[0]
console.log('DNS:', perfData.domainLookupEnd - perfData.domainLookupStart)
console.log('TCP:', perfData.connectEnd - perfData.connectStart)
console.log('Request:', perfData.responseStart - perfData.requestStart)
console.log('Response:', perfData.responseEnd - perfData.responseStart)
console.log('Total:', perfData.loadEventEnd - perfData.fetchStart)
// Resource Timing (캐시 확인)
performance.getEntriesByType('resource').forEach(resource => {
console.log(resource.name)
console.log(' Duration:', resource.duration)
console.log(' Transfer Size:', resource.transferSize)
// transferSize가 0이면 캐시에서 로드
if (resource.transferSize === 0) {
console.log(' ✓ FROM CACHE')
}
})
// Cache API 통계
if ('caches' in window) {
caches.keys().then(cacheNames => {
console.log('Cache Names:', cacheNames)
cacheNames.forEach(cacheName => {
caches.open(cacheName).then(cache => {
cache.keys().then(requests => {
console.log(`${cacheName}: ${requests.length} items`)
})
})
})
})
}
// Lighthouse 점수 (프로그래매틱)
const lighthouse = require('lighthouse')
const chromeLauncher = require('chrome-launcher')
async function runLighthouse(url) {
const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']})
const options = {
logLevel: 'info',
output: 'json',
onlyCategories: ['performance'],
port: chrome.port
}
const runnerResult = await lighthouse(url, options)
console.log('Performance score:', runnerResult.lhr.categories.performance.score * 100)
await chrome.kill()
}
runLighthouse('https://example.com')
7. 실무 체크리스트
HTTP 캐싱 적용 시:
설정
- Cache-Control 헤더 설정
- ETag 또는 Last-Modified
- Vary 헤더 (압축, Accept)
- Expires (레거시 지원)
파일별 전략
- HTML: no-cache 또는 짧은 캐싱
- CSS/JS: 1년 + 버전 관리
- 이미지: 1개월~1년
- 웹폰트: 1년 immutable
- API: 짧은 캐싱 또는 ETag
최적화
- 버전 관리 (해시)
- 압축 (Gzip/Brotli)
- CDN 활용
- Service Worker
검증
- DevTools로 확인
- curl로 헤더 테스트
- 캐시 히트율 모니터링
- Lighthouse 점수
무효화
- 무효화 전략 수립
- 자동화 (CI/CD)
- 긴급 무효화 방법
8. 결론
HTTP 캐싱은 웹 성능의 기본입니다.
핵심 교훈:
- Cache-Control - 캐싱 정책의 핵심
- max-age - 신선도 기간
- immutable - 불변 리소스
- ETag - 버전 식별
- 304 - 대역폭 절약
- 버전 관리 - 무효화 전략
- 압축 - 추가 최적화
- 모니터링 - 지속적 개선
권장 설정:
- HTML: no-cache 또는 max-age=300
- CSS/JS: max-age=31536000, immutable + 해시
- 이미지: max-age=2592000, public
- API: max-age=60, must-revalidate + ETag
HTTP 캐싱은 0원으로 웹 성능을 10배 향상시킬 수 있는 마법입니다. 적절한 Cache-Control과 버전 관리만으로도 극적인 효과를 얻을 수 있습니다!
"Cache Smart. Load Fast. HTTP Caching!"
반응형