카테고리 없음

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 캐싱은 웹 성능의 기본입니다.

핵심 교훈:

  1. Cache-Control - 캐싱 정책의 핵심
  2. max-age - 신선도 기간
  3. immutable - 불변 리소스
  4. ETag - 버전 식별
  5. 304 - 대역폭 절약
  6. 버전 관리 - 무효화 전략
  7. 압축 - 추가 최적화
  8. 모니터링 - 지속적 개선

권장 설정:

  • 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!"

반응형