본문으로 건너뛰기

안동민 개발노트

본문 시작

캐시와 CORS

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

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

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


HTTP 캐시가 하는 일

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

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

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

HTTP 캐시는 저장, 신선도, 검증, 키로 재사용을 결정한다

캐시는 URL만 보고 쓰는 저장소가 아니라 헤더 규칙을 순서대로 해석하는 재사용 판단기다.

  1. 1. 저장 가능

    저장 가능 Cache-Control과 응답 상태가 저장 가능한지 확인한다.

  2. 2. 신선도

    신선도 max-age, Expires로 fresh/stale을 판단한다.

  3. 3. 검증

    검증 stale이면 ETag나 Last-Modified로 조건부 요청한다.

  4. 4. 캐시 키

    캐시 키 URL과 Vary 헤더가 같은 응답인지 결정한다.

캐시는 크게 브라우저 같은 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는 저장과 재검증의 차이를 나타냅니다.

Cache-Control은 저장 위치와 재사용 조건을 나눈다

`no-cache`는 저장 금지가 아니라 재사용 전 검증이고, 저장 자체를 피하려면 `no-store`를 쓴다.

  1. max-age fresh

    볼 초 단위 시간 해시 파일명 JS/CSS에 긴 TTL

  2. private 브라우저 캐시만 허용 사용자별

    응답

  3. no-cache 저장 가능, 사용 전

    검증 HTML 문서 진입점

  4. no-store 저장 자체 금지 민감

    정보 응답

시나리오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로 본문 없이 응답할 수 있습니다.

조건부 요청은 stale 캐시를 304로 가볍게 검증한다

캐시가 stale이 되면 검증자를 보내고, 바뀌지 않았으면 서버는 본문 없이 304를 돌려준다.

  1. 1. 캐시 보관

    캐시 보관 응답과 ETag/Last-Modified를 저장한다.

  2. 2. stale

    stale max-age가 지나 재사용 전 확인이 필요하다.

  3. 3. 조건부 요청

    조건부 요청 If-None-Match 또는 If-Modified-Since 전송

  4. 4. 304

    304 본문 없이 기존 캐시를 재사용

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

실무 캐시 전략

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

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

캐시 무효화는 URL 버전과 캐시 지시어를 함께 맞춘다

HTML은 새 자산 주소를 가리키고, CDN과 브라우저는 파일명 또는 쿼리 버전이 바뀔 때 새 파일로 인식한다.

  1. 1
    HTML

    새 빌드가 가리키는 자산 URL을 바꾼다.

  2. 2
    Asset

    app.8f3a.js처럼 hash를 포함한다.

  3. 3
    CDN

    hash가 같으면 오래 캐시하고, hash가 바뀌면 새 키가 된다.

  4. 4
    Browser

    저장된 파일과 새 URL을 키로 비교한다.

대상권장 전략위험
HTML짧은 TTL 또는 재검증오래 캐시되면 새 자산 주소를 못 받음
정적 자산파일명 hash + 긴 TTLhash 없이 덮어쓰면 사용자가 구버전 실행
API 응답ETag, Cache-Control, Vary개인화 응답이 공유 캐시에 섞일 수 있음

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

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


CORS 동작 원리

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

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

CORS는 서버 차단이 아니라 브라우저의 응답 공유 판단이다

서버는 응답을 보낼 수 있지만, 브라우저는 CORS 헤더를 확인해 자바스크립트가 응답을 읽어도 되는지 결정한다.

  1. 요청

    브라우저가 Origin과 필요한 경우 preflight 정보를 보낸다.

  2. 서버 응답

    허용 Origin, 메서드, 헤더, 인증정보 정책을 헤더로 돌려준다.

  3. 브라우저 공개

    정책이 맞으면 JavaScript에 응답을 공개하고, 아니면 차단한다.

헤더판단 내용흔한 실수
Access-Control-Allow-Origin어떤 Origin에게 응답을 공개할지credentials와 함께 * 사용
Access-Control-Allow-Credentials쿠키/인증정보 포함 허용 여부SameSite, Secure 설정 누락
Vary: OriginOrigin별 캐시 분리CDN이 다른 Origin 응답을 재사용

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

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

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


Simple Request와 Preflight Request

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

브라우저는 실제 요청 전에 OPTIONS로 허용 범위를 묻는다

단순 요청을 벗어나면 브라우저는 메서드와 헤더가 허용되는지 먼저 확인한다. 허용 응답이 맞아야 실제 요청을 보낸다.

  1. 1 OPTIONS

    OPTIONS Origin Access-Control-Request-Method Access-Control-Request-Headers 실제 요청의 의도를 미리 보낸다.

  2. 2 허용 응답

    허용 응답 Allow-Origin Allow-Methods Allow-Headers Max-Age 허용 범위와 캐시 가능 시간을 돌려준다.

  3. 3 Actual request

    Actual request POST / PUT / DELETE... 검사가 통과하면 실제 요청이 전송된다.

판단 요소왜 필요한가
Origin어느 출처가 API를 읽을 수 있는지 결정한다.
MethodGET은 허용해도 DELETE는 막을 수 있다.
HeadersAuthorization 같은 비단순 헤더를 별도로 허용해야 한다.
Max-Agepreflight 결과를 캐시해 반복 OPTIONS 비용을 줄인다.

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


CORS 에러 해결 패턴

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

CORS 헤더는 브라우저 노출, 인증정보, 캐시 키를 함께 맞춘다

Allow-Origin만 맞추면 끝나지 않는다. 쿠키를 보낼지, JavaScript가 읽을 헤더를 노출할지, CDN이 Origin별로 분리할지도 같이 정해야 한다.

  1. 노출 허용

    Access-Control-Allow-Origin은 응답을 읽을 수 있는 출처를 정한다.

  2. 인증정보

    credentials=true면 * Origin을 쓸 수 없고 쿠키 속성도 맞아야 한다.

  3. 캐시 분리

    Origin별 응답이 다르면 Vary: Origin으로 캐시 키를 분리한다.

헤더역할확인할 점
Allow-Origin응답 공개 대상* 또는 구체 Origin 중 하나로 명확히
Allow-Credentials쿠키/인증정보 포함SameSite=None; Secure와 함께 검토
Expose-HeadersJS가 읽을 수 있는 응답 헤더Location, RateLimit 같은 커스텀 헤더 노출
Max-Agepreflight 결과 캐시긴 캐시는 정책 변경 반영을 늦춘다
상황확인할 것
개발 환경 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로 이어집니다.