캐시와 CORS
Cache-Control과 조건부 요청으로 HTTP 캐시를 설계하고 동일 출처 정책·프리플라이트·CORS 헤더를 진단합니다.
HTTP의 마지막 주제로, 성능을 책임지는 캐시(Cache)와 브라우저 보안을 책임지는 CORS(Cross-Origin Resource Sharing)를 다룹니다.
둘 다 헤더 몇 줄처럼 보이지만, 실제로는 브라우저, CDN, 프록시, 서버가 함께 해석하는 규칙입니다.
HTTP 캐시가 하는 일
HTTP 캐시는 이전 응답을 저장해두었다가 같은 요청에 재사용합니다.
캐시가 잘 동작하면 지연 시간과 네트워크 사용량이 줄고, 원본 서버 부하도 낮아집니다.
단, 캐시는 “아무 응답이나 저장하는 저장소”가 아니라 저장 가능 여부, 신선도(freshness), 검증(validation), 캐시 키를 기준으로 판단합니다.
캐시는 URL만 보고 쓰는 저장소가 아니라 헤더 규칙을 순서대로 해석하는 재사용 판단기다.
- 1. 저장 가능
저장 가능 Cache-Control과 응답 상태가 저장 가능한지 확인한다.
- 2. 신선도
신선도 max-age, Expires로 fresh/stale을 판단한다.
- 3. 검증
검증 stale이면 ETag나 Last-Modified로 조건부 요청한다.
- 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는 신선한 시간, private과 public은 저장 위치, no-cache와 no-store는 저장과 재검증의 차이를 나타냅니다.
`no-cache`는 저장 금지가 아니라 재사용 전 검증이고, 저장 자체를 피하려면 `no-store`를 쓴다.
- max-age fresh
볼 초 단위 시간 해시 파일명 JS/CSS에 긴 TTL
- private 브라우저 캐시만 허용 사용자별
응답
- no-cache 저장 가능, 사용 전
검증 HTML 문서 진입점
- no-store 저장 자체 금지 민감
정보 응답
| 시나리오 | Cache-Control 설정 | 이유 |
|---|---|---|
| 해시 파일명 JS/CSS | public, max-age=31536000, immutable | 파일 내용이 바뀌면 URL도 바뀜 |
| HTML 문서 | no-cache | 저장은 가능하지만 사용 전 재검증 |
| 사용자별 API | private, no-cache | 브라우저 캐시는 가능, 공유 캐시는 제한 |
| 민감 정보 | no-store | 저장 자체를 피해야 함 |
| CDN 전용 TTL | s-maxage=300, stale-while-revalidate=30 | 공유 캐시에 별도 TTL과 stale 정책 제공 |
no-cache는 “캐시하지 말라”가 아니라 “재사용 전에 원본 서버에 검증하라”에 가깝습니다.
이때 ETag나 Last-Modified 같은 검증자가 없으면 보통 304 대신 200 전체 응답을 다시 받게 됩니다.
HTTP 캐시에 저장하지 않아야 하는 응답에는 no-store를 써야 합니다.
Fresh Cache와 Conditional Request
캐시된 응답이 아직 fresh라면 브라우저나 CDN은 원본 서버에 가지 않고 응답을 재사용할 수 있습니다.
stale 상태가 되면 검증자가 있을 때 조건부 요청을 보내고, 서버는 내용이 바뀌지 않았으면 304 Not Modified로 본문 없이 응답할 수 있습니다.
캐시가 stale이 되면 검증자를 보내고, 바뀌지 않았으면 서버는 본문 없이 304를 돌려준다.
- 1. 캐시 보관
캐시 보관 응답과 ETag/Last-Modified를 저장한다.
- 2. stale
stale max-age가 지나 재사용 전 확인이 필요하다.
- 3. 조건부 요청
조건부 요청 If-None-Match 또는 If-Modified-Since 전송
- 4. 304
304 본문 없이 기존 캐시를 재사용
| 검증자 | 요청 헤더 | 서버 판단 기준 | 특징 |
|---|---|---|---|
ETag | If-None-Match | 리소스 버전 식별자 비교 | strong/weak validator가 있고 우선순위가 높음 |
Last-Modified | If-Modified-Since | 마지막 수정 시각 비교 | 구현이 쉽지만 초 단위·시계 오차 영향 |
| 둘 다 있음 | 둘 다 전송될 수 있음 | 일반적으로 ETag 검증이 우선 | 서버 구현과 HTTP 규칙을 함께 확인 |
실무 캐시 전략
정적 자산은 파일명에 콘텐츠 해시를 넣고 긴 TTL을 주는 전략이 가장 안정적입니다.
반대로 HTML은 최신 자산 URL을 알려주는 진입점이므로 짧게 캐시하거나 매번 재검증하도록 두는 편이 안전합니다.
HTML은 새 자산 주소를 가리키고, CDN과 브라우저는 파일명 또는 쿼리 버전이 바뀔 때 새 파일로 인식한다.
- 1HTML
새 빌드가 가리키는 자산 URL을 바꾼다.
- 2Asset
app.8f3a.js처럼 hash를 포함한다.
- 3CDN
hash가 같으면 오래 캐시하고, hash가 바뀌면 새 키가 된다.
- 4Browser
저장된 파일과 새 URL을 키로 비교한다.
| 대상 | 권장 전략 | 위험 |
|---|---|---|
| HTML | 짧은 TTL 또는 재검증 | 오래 캐시되면 새 자산 주소를 못 받음 |
| 정적 자산 | 파일명 hash + 긴 TTL | hash 없이 덮어쓰면 사용자가 구버전 실행 |
| API 응답 | ETag, Cache-Control, Vary | 개인화 응답이 공유 캐시에 섞일 수 있음 |
이 전략의 핵심은 “변경 가능성”을 URL에 반영하는 것입니다.
/app.7f3a9c.js처럼 내용이 바뀌면 URL이 바뀌는 파일은 오래 캐시해도 되고, /index.html처럼 같은 URL에서 내용이 바뀌는 문서는 검증 중심으로 둡니다.
CORS 동작 원리
브라우저의 동일 출처 정책(Same-Origin Policy)은 한 출처의 스크립트가 다른 출처의 응답을 마음대로 읽지 못하게 막습니다.
여기서 출처(origin)는 scheme, host, port 조합입니다.
서버는 응답을 보낼 수 있지만, 브라우저는 CORS 헤더를 확인해 자바스크립트가 응답을 읽어도 되는지 결정한다.
- 요청
브라우저가 Origin과 필요한 경우 preflight 정보를 보낸다.
- 서버 응답
허용 Origin, 메서드, 헤더, 인증정보 정책을 헤더로 돌려준다.
- 브라우저 공개
정책이 맞으면 JavaScript에 응답을 공개하고, 아니면 차단한다.
| 헤더 | 판단 내용 | 흔한 실수 |
|---|---|---|
| Access-Control-Allow-Origin | 어떤 Origin에게 응답을 공개할지 | credentials와 함께 * 사용 |
| Access-Control-Allow-Credentials | 쿠키/인증정보 포함 허용 여부 | SameSite, Secure 설정 누락 |
| Vary: Origin | Origin별 캐시 분리 | CDN이 다른 Origin 응답을 재사용 |
CORS는 서버가 Access-Control-* 응답 헤더로 “이 출처에는 응답을 공유해도 된다”고 알려주는 프로토콜입니다.
중요한 점은 CORS가 서버 간 요청을 막는 기능이 아니라, 브라우저가 JavaScript에 응답을 노출할지 결정하는 규칙이라는 점입니다.
요청 자체가 전송될 수 있으므로 CSRF 방어는 별도 정책으로 설계해야 합니다.
Simple Request와 Preflight Request
브라우저는 CORS-safelisted 조건에 들어오는 요청은 바로 보내고, 그 조건을 벗어난 요청은 먼저 OPTIONS preflight로 서버의 허용 여부를 확인합니다.
단순 요청을 벗어나면 브라우저는 메서드와 헤더가 허용되는지 먼저 확인한다. 허용 응답이 맞아야 실제 요청을 보낸다.
- 1 OPTIONS
OPTIONS Origin Access-Control-Request-Method Access-Control-Request-Headers 실제 요청의 의도를 미리 보낸다.
- 2 허용 응답
허용 응답 Allow-Origin Allow-Methods Allow-Headers Max-Age 허용 범위와 캐시 가능 시간을 돌려준다.
- 3 Actual request
Actual request POST / PUT / DELETE... 검사가 통과하면 실제 요청이 전송된다.
| 판단 요소 | 왜 필요한가 |
|---|---|
| Origin | 어느 출처가 API를 읽을 수 있는지 결정한다. |
| Method | GET은 허용해도 DELETE는 막을 수 있다. |
| Headers | Authorization 같은 비단순 헤더를 별도로 허용해야 한다. |
| Max-Age | preflight 결과를 캐시해 반복 OPTIONS 비용을 줄인다. |
예를 들어 GET 요청이라도 Authorization 헤더를 붙이면 preflight 대상이 될 수 있고, POST라도 Content-Type: application/json이면 safelisted content type이 아니므로 preflight가 필요합니다.
CORS 에러 해결 패턴
CORS 에러는 프론트엔드 코드에서 우회하는 문제가 아니라, 서버가 어떤 출처와 어떤 메서드·헤더·credentials를 허용할지 명확히 응답해야 해결됩니다.
Allow-Origin만 맞추면 끝나지 않는다. 쿠키를 보낼지, JavaScript가 읽을 헤더를 노출할지, CDN이 Origin별로 분리할지도 같이 정해야 한다.
- 노출 허용
Access-Control-Allow-Origin은 응답을 읽을 수 있는 출처를 정한다.
- 인증정보
credentials=true면 * Origin을 쓸 수 없고 쿠키 속성도 맞아야 한다.
- 캐시 분리
Origin별 응답이 다르면 Vary: Origin으로 캐시 키를 분리한다.
| 헤더 | 역할 | 확인할 점 |
|---|---|---|
| Allow-Origin | 응답 공개 대상 | * 또는 구체 Origin 중 하나로 명확히 |
| Allow-Credentials | 쿠키/인증정보 포함 | SameSite=None; Secure와 함께 검토 |
| Expose-Headers | JS가 읽을 수 있는 응답 헤더 | Location, RateLimit 같은 커스텀 헤더 노출 |
| Max-Age | preflight 결과 캐시 | 긴 캐시는 정책 변경 반영을 늦춘다 |
| 상황 | 확인할 것 |
|---|---|
| 개발 환경 CORS | dev server proxy로 같은 출처처럼 중계할 수 있음 |
| 운영 환경 CORS | 서버가 정확한 Access-Control-Allow-Origin 반환 |
| 쿠키 포함 요청 | Allow-Credentials: true와 특정 Origin 필요 |
| 와일드카드 Origin | credentials 요청에는 * 사용 불가 |
| 커스텀 응답 헤더 읽기 | Access-Control-Expose-Headers에 이름 추가 |
| preflight 반복 | Access-Control-Max-Age를 쓰되 브라우저 한계 고려 |
| Origin 반사 | 허용 목록 검증과 Vary: Origin 함께 고려 |
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로 이어집니다.