본문으로 건너뛰기

안동민 개발노트

본문 시작

캐시와 CORS

Cache-Control과 조건부 요청으로 HTTP 캐시를 설계하고 동일 출처 정책·프리플라이트·CORS 헤더를 진단합니다.

HTTP의 마지막 주제로, 성능을 책임지는 캐시(Cache)와 브라우저 보안을 책임지는 CORS(Cross-Origin Resource Sharing)를 다룹니다.

둘 다 헤더 몇 줄처럼 보이지만, 실제로는 브라우저, CDN, 프록시, 서버가 함께 해석하는 규칙입니다.


HTTP 캐시가 하는 일

HTTP 캐시는 이전 응답을 저장해두었다가 같은 요청에 재사용합니다.

캐시가 잘 동작하면 지연 시간과 네트워크 사용량이 줄고, 원본 서버 부하도 낮아집니다.

단, 캐시는 “아무 응답이나 저장하는 저장소”가 아니라 저장 가능 여부, 신선도(freshness), 검증(validation), 캐시 키를 기준으로 판단합니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/1.html

캐시는 크게 브라우저 같은 private cache와 CDN·프록시 같은 shared cache로 나눌 수 있습니다.

같은 URL이라도 Vary 헤더가 있으면 요청 헤더 일부가 캐시 키에 포함됩니다.

예를 들어 Vary: Accept-Encoding은 gzip/br 응답을 구분하고, 동적으로 CORS Origin을 반사하는 응답은 보통 Vary: Origin을 함께 고려해야 합니다.


Cache-Control 지시자

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

max-age는 신선한 시간, privatepublic은 저장 위치, no-cacheno-store는 저장과 재검증의 차이를 나타냅니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/2.html
시나리오Cache-Control 설정이유
해시 파일명 JS/CSSpublic, max-age=31536000, immutable파일 내용이 바뀌면 URL도 바뀜
HTML 문서no-cache저장은 가능하지만 사용 전 재검증
사용자별 APIprivate, no-cache브라우저 캐시는 가능, 공유 캐시는 제한
민감 정보no-store저장 자체를 피해야 함
CDN 전용 TTLs-maxage=300, stale-while-revalidate=30공유 캐시에 별도 TTL과 stale 정책 제공

no-cache는 “캐시하지 말라”가 아니라 “재사용 전에 원본 서버에 검증하라”에 가깝습니다.

이때 ETagLast-Modified 같은 검증자가 없으면 보통 304 대신 200 전체 응답을 다시 받게 됩니다.

HTTP 캐시에 저장하지 않아야 하는 응답에는 no-store를 써야 합니다.


Fresh Cache와 Conditional Request

캐시된 응답이 아직 fresh라면 브라우저나 CDN은 원본 서버에 가지 않고 응답을 재사용할 수 있습니다.

stale 상태가 되면 검증자가 있을 때 조건부 요청을 보내고, 서버는 내용이 바뀌지 않았으면 304 Not Modified로 본문 없이 응답할 수 있습니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/4.html
검증자요청 헤더서버 판단 기준특징
ETagIf-None-Match리소스 버전 식별자 비교strong/weak validator가 있고 우선순위가 높음
Last-ModifiedIf-Modified-Since마지막 수정 시각 비교구현이 쉽지만 초 단위·시계 오차 영향
둘 다 있음둘 다 전송될 수 있음일반적으로 ETag 검증이 우선서버 구현과 HTTP 규칙을 함께 확인

실무 캐시 전략

정적 자산은 파일명에 콘텐츠 해시를 넣고 긴 TTL을 주는 전략이 가장 안정적입니다.

반대로 HTML은 최신 자산 URL을 알려주는 진입점이므로 짧게 캐시하거나 매번 재검증하도록 두는 편이 안전합니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/5.html

이 전략의 핵심은 “변경 가능성”을 URL에 반영하는 것입니다.

/app.7f3a9c.js처럼 내용이 바뀌면 URL이 바뀌는 파일은 오래 캐시해도 되고, /index.html처럼 같은 URL에서 내용이 바뀌는 문서는 검증 중심으로 둡니다.


CORS 동작 원리

브라우저의 동일 출처 정책(Same-Origin Policy)은 한 출처의 스크립트가 다른 출처의 응답을 마음대로 읽지 못하게 막습니다.

여기서 출처(origin)는 scheme, host, port 조합입니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/6.html

CORS는 서버가 Access-Control-* 응답 헤더로 “이 출처에는 응답을 공유해도 된다”고 알려주는 프로토콜입니다.

중요한 점은 CORS가 서버 간 요청을 막는 기능이 아니라, 브라우저가 JavaScript에 응답을 노출할지 결정하는 규칙이라는 점입니다.

요청 자체가 전송될 수 있으므로 CSRF 방어는 별도 정책으로 설계해야 합니다.


Simple Request와 Preflight Request

브라우저는 CORS-safelisted 조건에 들어오는 요청은 바로 보내고, 그 조건을 벗어난 요청은 먼저 OPTIONS preflight로 서버의 허용 여부를 확인합니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/8.html

예를 들어 GET 요청이라도 Authorization 헤더를 붙이면 preflight 대상이 될 수 있고, POST라도 Content-Type: application/json이면 safelisted content type이 아니므로 preflight가 필요합니다.


CORS 에러 해결 패턴

CORS 에러는 프론트엔드 코드에서 우회하는 문제가 아니라, 서버가 어떤 출처와 어떤 메서드·헤더·credentials를 허용할지 명확히 응답해야 해결됩니다.

HTML 다이어그램: /docs/network/ch9/ch9-4/9.html
상황확인할 것
개발 환경 CORSdev server proxy로 같은 출처처럼 중계할 수 있음
운영 환경 CORS서버가 정확한 Access-Control-Allow-Origin 반환
쿠키 포함 요청Allow-Credentials: true와 특정 Origin 필요
와일드카드 Origincredentials 요청에는 * 사용 불가
커스텀 응답 헤더 읽기Access-Control-Expose-Headers에 이름 추가
preflight 반복Access-Control-Max-Age를 쓰되 브라우저 한계 고려
Origin 반사허용 목록 검증과 Vary: Origin 함께 고려
cors_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class CORSHandler(BaseHTTPRequestHandler):
    """허용 목록 기반 CORS 응답 예시"""

    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-Credentials", "true")
            self.send_header("Access-Control-Allow-Methods", "GET, POST")
            self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
            self.send_header("Access-Control-Expose-Headers", "X-Request-Id")
            self.send_header("Access-Control-Max-Age", "86400")
            self.send_header("Vary", "Origin")

    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 success", "path": self.path}
        self.wfile.write(json.dumps(data).encode())

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

다음 장에서는 HTTP 통신의 보안을 담당하는 HTTPS와 TLS로 이어집니다.