안동민 개발노트

본문 시작

캐시와 CORS

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

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

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


HTTP 캐시가 하는 일

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

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

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

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

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

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


Cache-Control 지시자

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

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

  • 해시 파일명 JS/CSS — Cache-Control 설정: public, max-age=31536000, immutable; 이유: 파일 내용이 바뀌면 URL도 바뀜

  • HTML 문서 — Cache-Control 설정: no-cache; 이유: 저장은 가능하지만 사용 전 재검증

  • 사용자별 API — Cache-Control 설정: private, no-cache; 이유: 브라우저 캐시는 가능, 공유 캐시는 제한

  • 민감 정보 — Cache-Control 설정: no-store; 이유: 저장 자체를 피해야 함

  • CDN 전용 TTL — Cache-Control 설정: s-maxage=300, stale-while-revalidate=30; 이유: 공유 캐시에 별도 TTL과 stale 정책 제공


Fresh Cache와 Conditional Request

저장된 응답의 재사용은 freshness뿐 아니라 재검증 지시자와 stale 허용 정책도 함께 따릅니다. 아래는 캐시 가능한 GET에 대한 대표 경우입니다.

저장된 GET 응답을 다시 쓰는 경우

저장된 GET 응답을 다시 쓰는 경우의 비교 기준과 해석 범위입니다.

저장된 GET 응답을 다시 쓰는 경우
상태·정책요청·응답본문 처리
Fresh, 재검증 의무 없음일치하는 캐시 항목을 직접 사용 가능원본 서버에 요청하지 않고 저장된 본문 사용
no-cache재사용 전에 성공적인 검증 필요저장을 금지하는 no-store와 구분
검증자 일치조건부 GET에 304응답 메타데이터를 갱신하고 저장 본문 사용
표현 변경·검증자 없음보통 200 전체 응답새 본문 수신; 저장 가능 여부 다시 판단
허용된 stale 사용stale-while-revalidate 등 정책의 제한 안에서 사용만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님
Fresh, 재검증 의무 없음
요청·응답: 일치하는 캐시 항목을 직접 사용 가능
본문 처리: 원본 서버에 요청하지 않고 저장된 본문 사용
no-cache
요청·응답: 재사용 전에 성공적인 검증 필요
본문 처리: 저장을 금지하는 no-store와 구분
검증자 일치
요청·응답: 조건부 GET에 304
본문 처리: 응답 메타데이터를 갱신하고 저장 본문 사용
표현 변경·검증자 없음
요청·응답: 보통 200 전체 응답
본문 처리: 새 본문 수신; 저장 가능 여부 다시 판단
허용된 stale 사용
요청·응답: stale-while-revalidate 등 정책의 제한 안에서 사용
본문 처리: 만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님

캐시 키와 Vary가 맞는 저장 응답을 전제로 합니다. 다른 메서드, 오류, 범위 요청의 모든 경우를 나열한 표는 아닙니다.

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

실무 캐시 전략

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

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

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

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


CORS 동작 원리

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

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

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

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

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


Simple Request와 Preflight Request

브라우저는 CORS-safelisted 조건의 요청을 바로 보낼 수 있습니다. preflight는 실제 요청에 사용할 메서드와 헤더의 허용 범위를 확인하는 OPTIONS 요청입니다.

CORS에서 요청 전송과 응답 공개를 나누기

CORS에서 요청 전송과 응답 공개를 나누기의 비교 기준과 해석 범위입니다.

CORS에서 요청 전송과 응답 공개를 나누기
확인 시점브라우저 판단실패·제약
실제 요청 전필요한 메서드·헤더의 preflight 허용 캐시 확인; 없으면 OPTIONSpreflight 실패면 실제 요청을 보내지 않음
실제 요청 응답Allow-Origin, credentials 조건을 확인요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음
헤더 공개기본 공개 헤더와 Expose-Headers를 적용노출 선언만으로 서버가 그 헤더 값을 생성하지 않음
쿠키 동반credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음
실제 요청 전
브라우저 판단: 필요한 메서드·헤더의 preflight 허용 캐시 확인; 없으면 OPTIONS
실패·제약: preflight 실패면 실제 요청을 보내지 않음
실제 요청 응답
브라우저 판단: Allow-Origin, credentials 조건을 확인
실패·제약: 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음
헤더 공개
브라우저 판단: 기본 공개 헤더와 Expose-Headers를 적용
실패·제약: 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음
쿠키 동반
브라우저 판단: credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용
실패·제약: CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음

CORS는 브라우저의 응답 공유 규칙입니다. 서버의 인증·권한 검사와 상태 변경 요청의 CSRF 방어는 별도로 필요합니다.

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


CORS 에러 해결 패턴

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

상황확인할 것
개발 환경 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):
        self.send_header("Vary", "Origin")
        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")
            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")

    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()

이 데모는 GET과 preflight 응답만 구현합니다. Expose-Headers에 적힌 X-Request-Id 값은 실제 응답에 추가하지 않으며, Allow-Credentials는 쿠키 저장·전송이나 인증 성공을 보장하지 않습니다. 클라이언트의 credentials 설정, SameSite와 브라우저 쿠키 정책은 별도로 적용됩니다.

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