icon

안동민 개발노트

9장 : HTTP

캐시와 CORS


HTTP의 마지막 주제로, 성능과 보안의 핵심인 캐시(Cache)CORS(Cross-Origin Resource Sharing)를 다루겠습니다. 둘 다 실무에서 왜 안 되지?라는 상황을 자주 만들어내는 주제입니다.


HTTP 캐시 전략

HTTP 캐시는 이전에 가져온 리소스를 저장해두고 재사용함으로써, 불필요한 네트워크 요청을 줄이는 메커니즘입니다.

캐시가 없다면?
매 페이지 방문마다
  style.css   (50KB)  → 다운로드
  app.js      (200KB) → 다운로드
  logo.png    (30KB)  → 다운로드
  font.woff2  (100KB) → 다운로드
  총: 380KB × 매 요청

캐시 활용
  첫 방문: 380KB 다운로드 + 캐시 저장
  재방문: 0KB! (캐시에서 로드)
  → 로딩 속도 극적 개선

Cache-Control 지시자

Cache-Control 헤더가 캐시 정책의 핵심입니다.

Cache-Control 지시자 분류
저장 관련
  public    → CDN, 프록시 등 공유 캐시에 저장 가능
  private   → 브라우저에만 저장 (기본값)
  no-store  → 캐시에 아예 저장 금지 (은행 거래, 개인정보)

유효성 관련
  max-age=3600    → 3600초(1시간) 동안 캐시 유효
  s-maxage=3600   → CDN용 max-age (CDN에만 적용)
  no-cache        → 캐시 저장하되, 매번 서버에 유효성 확인
                    (이름이 오해의 소지 → "항상 재검증" 의미)
  must-revalidate → max-age 경과 후 반드시 서버 재검증

불변
  immutable  → 리소스 절대 안 변함, 새로고침해도 재검증 안 함
시나리오Cache-Control 설정이유
해시 파일명 JS/CSSmax-age=31536000, immutable파일명에 해시 → 변경 시 새 URL
API 응답 (공개)max-age=60, public1분 캐시, CDN에서도 캐시
사용자별 APImax-age=0, privateCDN 캐시 X, 브라우저에만
은행 거래 내역no-store민감 정보, 절대 캐시 금지
HTML 문서no-cache매번 최신 확인, 캐시 저장은 허용

강력한 캐시 vs 조건부 캐시

두 가지 캐시 전략
강력한 캐시 (Strong Cache)
  Browser
  ┌──────────┐
  │ Cache ✓  │
  └──────────┘
  → max-age 내에는 서버 요청 안 보냄
  → 서버 부하 0, 속도 최대

  적합: app.a1b2c3.js, style.d4e5f6.css
        (파일명에 해시 → 변경 시 URL 자체가 바뀜)

조건부 캐시 (Conditional Cache)
  Browser → Server
  "If-None-Match: abc"
  Server → Browser
  "304 Not Modified" (본문 없음)
  → 서버 부하 ↓ (검증만), 대역폭 절약 (본문 생략)

  적합: index.html, API 응답
ETag와 Last-Modified 비교
ETag (Entity Tag)
  서버 응답:  ETag: "abc123"
  재요청:     If-None-Match: "abc123"
  서버 판단:  현재 ETag와 비교
              같으면 → 304 (본문 생략)
              다르면 → 200 + 새 데이터

Last-Modified
  서버 응답:  Last-Modified: Wed, 15 Nov 2023 10:00:00 GMT
  재요청:     If-Modified-Since: Wed, 15 Nov 2023 10:00:00 GMT
  서버 판단:  마지막 수정 시각 비교
              이후 변경 없으면 → 304
              변경 있으면 → 200 + 새 데이터

ETag가 더 정확
  1초 내 여러 번 변경 시 Last-Modified는 구분 못함
  ETag는 내용 기반이라 정밀
실무 캐시 전략: 해시 기반 캐시 무효화
빌드 시
  app.js    → app.a1b2c3.js    (내용 해시 포함)
  style.css → style.d4e5f6.css

index.html
  <script src="/app.a1b2c3.js"></script>
  <link href="/style.d4e5f6.css">

캐시 설정
  *.html     → Cache-Control: no-cache  (매번 최신 확인)
  *.js|*.css → Cache-Control: max-age=31536000, immutable
                (1년 캐시, 절대 변경 안 됨)

코드 수정 시
  app.js 변경 → app.x7y8z9.js (새 해시 → 새 URL!)
  index.html 업데이트 → 새 파일 참조
  기존 a1b2c3 파일은 이미 캐시된 사용자들이 사용
  새 HTML 받으면 자동으로 새 JS 다운로드

CORS 동작 원리

브라우저에서 http://localhost:3000에서 실행 중인 프론트엔드가 http://api.example.com의 API를 호출하면, 브라우저 콘솔에 빨간 에러가 나타납니다.

동일 출처 정책 (Same-Origin Policy)
출처(Origin) = 프로토콜 + 호스트 + 포트

http://localhost:3000  vs  http://api.example.com
│          │      │          │        │
프로토콜 호스트  포트       프로토콜 호스트
같음     다름!   다름!      → 다른 출처!

같은 출처 예시
  http://example.com/page1
  http://example.com/page2     ← 같은 출처 ✓ (경로만 다름)

다른 출처 예시
  http://example.com  vs https://example.com   ← 프로토콜 다름
  http://example.com  vs http://api.example.com ← 호스트 다름
  http://example.com  vs http://example.com:8080 ← 포트 다름

동일 출처 정책은 보안을 위한 것입니다. 악의적인 사이트가 사용자의 은행 사이트에 API 요청을 보내 정보를 탈취하는 것을 방지합니다. CORS는 이 제한을 안전하게 완화하는 메커니즘입니다.


Simple Request vs Preflight Request

단순 요청 (Simple Request)
조건: GET/HEAD/POST + 기본 헤더만 사용

Browser                                 Server
  │                                       │
  │── GET /api/data ─────────────────────→│
  │   Origin: http://localhost:3000       │
  │                                       │
  │←─ 200 OK ─────────────────────────────│
  │   Access-Control-Allow-Origin: *      │
  │   {"data": "..."}                     │
  │                                       │
  Browser: "Allow-Origin에 내 출처가 포함?" → ✓ 허용
프리플라이트 요청 (Preflight Request)
조건: 커스텀 헤더(Authorization 등) 또는 PUT/DELETE 사용

Browser                                 Server
  │                                       │
  │── OPTIONS /api/users ────────────────→│  ← 프리플라이트
  │   Origin: http://localhost:3000       │
  │   Access-Control-Request-Method: PUT  │
  │   Access-Control-Request-Headers:     │
  │     Authorization, Content-Type       │
  │                                       │
  │←─ 204 No Content ─────────────────────│
  │   Access-Control-Allow-Origin:        │
  │     http://localhost:3000             │
  │   Access-Control-Allow-Methods:       │
  │     GET, POST, PUT, DELETE            │
  │   Access-Control-Allow-Headers:       │
  │     Authorization, Content-Type       │
  │   Access-Control-Max-Age: 86400       │  ← 24시간 캐시
  │                                       │
  │── PUT /api/users/123 ────────────────→│  ← 실제 요청
  │   Origin: http://localhost:3000       │
  │   Authorization: Bearer eyJ...        │
  │                                       │
  │←─ 200 OK ─────────────────────────────│
  │   Access-Control-Allow-Origin:        │
  │     http://localhost:3000             │

CORS 에러 해결 패턴

CORS 에러를 해결하는 올바른 방법은 서버 측에서 CORS 헤더를 설정하는 것입니다.

CORS 핵심 응답 헤더
필수:
  Access-Control-Allow-Origin: http://localhost:3000
  (또는 * → 단, 인증 사용 시 * 불가!)

선택:
  Access-Control-Allow-Methods: GET, POST, PUT, DELETE
  Access-Control-Allow-Headers: Content-Type, Authorization
  Access-Control-Allow-Credentials: true  ← 쿠키 포함 시
  Access-Control-Max-Age: 86400          ← 프리플라이트 캐시
  Access-Control-Expose-Headers: X-Total-Count ← 커스텀 헤더 노출
상황해결 방법
개발 환경 CORS프론트엔드 devServer proxy 설정
운영 환경 CORS서버에서 Allow-Origin 설정
쿠키 포함 요청Allow-Credentials: true + 특정 Origin (not *)
커스텀 헤더 읽기Expose-Headers에 헤더명 추가
프리플라이트 줄이기Max-Age 길게 (86400) 설정
CORS는 브라우저 보안이다
중요: CORS는 브라우저의 보안 메커니즘!

Postman → Server: CORS 없음 → 성공!
curl → Server:    CORS 없음 → 성공!
Server → Server:  CORS 없음 → 성공!
Browser → Server: CORS 적용 → 헤더 없으면 차단!

"Postman에서는 되는데 브라우저에서 안 돼요"
→ CORS는 브라우저만 적용하기 때문

개발 중 우회
  1. 프론트엔드 프록시 (Next.js rewrites, Vite proxy)
     → 같은 출처에서 요청하므로 CORS 미적용
  2. 서버 CORS 설정 (권장)
  3. 크롬 확장 프로그램 (비권장, 개발용만)
cors_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class CORSHandler(BaseHTTPRequestHandler):
    """CORS 헤더를 설정하는 간단한 API 서버"""
    
    ALLOWED_ORIGINS = ["http://localhost:3000", "http://localhost:5173"]
    
    def set_cors_headers(self):
        origin = self.headers.get("Origin", "")
        if origin in self.ALLOWED_ORIGINS:
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
            self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
            self.send_header("Access-Control-Allow-Credentials", "true")
            self.send_header("Access-Control-Max-Age", "86400")
    
    def do_OPTIONS(self):
        """프리플라이트 요청 처리"""
        self.send_response(204)
        self.set_cors_headers()
        self.end_headers()
    
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.set_cors_headers()
        self.end_headers()
        
        data = {"message": "CORS 성공!", "path": self.path}
        self.wfile.write(json.dumps(data).encode())

# HTTPServer(("localhost", 8080), CORSHandler).serve_forever()

다음 장에서는 HTTP 통신의 보안을 담당하는 HTTPS와 TLS를 살펴보겠습니다.

목차