캐시와 CORS
응답을 저장해 다시 쓰는 HTTP 캐시가 신선도와 검증자로 재사용을 판단하는 과정을 코드로 따라가고, 자원의 성격에 맞는 Cache-Control 전략을 정리합니다. 동일 출처 정책과 CORS의 단순 요청, 사전 요청, 자격 증명 규칙을 구별하고, CORS 오류를 서버 쪽에서 바로잡는 방법을 봅니다.
HTTP 장의 마지막으로 두 가지 규칙을 다룹니다. 하나는 응답을 저장해 두었다가 다시 써서 웹을 빠르게 하는 캐시(cache)이고, 다른 하나는 다른 출처의 응답을 스크립트가 읽어도 되는지 정하는 CORS(cross-origin resource sharing)입니다. 둘 다 헤더 몇 줄로 보이지만 브라우저와 CDN, 프록시, 서버가 함께 해석하는 규칙이어서, 한 줄을 잘못 적으면 사용자에게 예전 화면이 보이거나 다른 사용자의 정보가 캐시되거나 API 호출이 막힙니다.
캐시가 하는 일
HTTP 캐시(RFC 9111)는 받은 응답을 저장해 두었다가 같은 요청이 오면 서버에 묻지 않고 다시 씁니다. 캐시가 잘 동작하면 응답이 빨라지고 네트워크 사용량과 서버의 부하가 줄어듭니다. 캐시에는 한 사용자의 브라우저 안에 있는 사설 캐시(private cache)와, CDN이나 프록시처럼 여러 사용자가 함께 쓰는 공유 캐시(shared cache)가 있습니다. 공유 캐시에 한 사용자의 응답이 저장되면 다른 사용자에게 갈 수 있으므로, 사용자마다 다른 응답에는 특히 주의해야 합니다.
캐시는 저장해 둔 응답을 찾을 때 메서드와 URL을 열쇠(cache key)로 씁니다. 같은 URL이라도 요청 헤더에 따라 응답이 달라진다면, 서버는 9장 2편에서 본 Vary 헤더로 어느 요청 헤더가 응답을 바꾸는지 알려 열쇠에 포함시키게 합니다. 압축 방식에 따라 다른 응답을 주면 Vary: Accept-Encoding, 요청의 출처에 따라 CORS 헤더가 달라지면 Vary: Origin을 보냅니다.
Cache-Control 지시자
캐시의 동작은 응답의 Cache-Control 헤더가 정합니다.
| 지시자 | 뜻 |
|---|---|
max-age=60 | 받은 뒤 60초 동안은 서버에 묻지 않고 다시 써도 된다(신선하다) |
s-maxage=300 | 공유 캐시에만 적용되는 신선한 시간. 공유 캐시에서는 max-age보다 우선한다 |
no-cache | 저장해도 되지만, 다시 쓰기 전에 매번 서버에 확인해야 한다 |
no-store | 저장 자체를 하지 않는다 |
private | 사설 캐시에만 저장한다. 공유 캐시는 저장하지 않는다 |
public | 보통은 저장하지 않는 응답(예: 인증이 필요한 응답)도 공유 캐시가 저장해도 된다 |
immutable | 신선한 동안에는 내용이 절대 바뀌지 않으므로, 새로 고침을 해도 확인하지 않아도 된다 |
must-revalidate | 신선도가 끝난 뒤에는 반드시 확인한 다음에 쓴다 |
stale-while-revalidate=30 | 신선도가 끝난 뒤 30초 동안은 예전 응답을 먼저 주고 뒤에서 확인한다 |
가장 자주 헷갈리는 것이 no-cache와 no-store입니다. no-cache는 이름과 달리 캐시를 금지하지 않고, 쓰기 전에 확인하라는 뜻입니다. 확인 결과 바뀌지 않았다면 저장해 둔 본문을 그대로 쓰므로, 본문을 다시 받지 않아 여전히 빠릅니다. 저장 자체를 막고 싶은 민감한 응답에는 no-store를 써야 합니다.
신선도와 재검증
캐시는 저장한 응답이 아직 신선한지(fresh)를 먼저 따집니다. 응답의 나이가 max-age보다 작으면 그대로 쓰고, 신선도가 끝났으면 서버에 아직 같은지 확인하는데, 이를 재검증(revalidation)이라고 합니다.
저장된 GET 응답을 다시 쓰는 경우
다른 검증 의무가 없는 일치 저장 응답입니다. 검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다. 각 단계의 값과 전환 이유를 비교합니다.
HTTP · 캐시 재사용
저장 가능 여부와 재검증 여부를 나누어 판단한다
같은 캐시 키와 Vary에 맞는 GET 응답을 가정합니다. no-cache는 재사용 전에 검증하라는 뜻이며, 저장 자체를 금지하는 no-store와 다릅니다. Freshness와 허용된 stale 정책도 함께 봅니다.
같은 cache key·Vary에서의 재사용 판단
그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.
원래 설명과 상세 기준
| 상태·정책 | 요청·응답 | 본문 처리 |
|---|---|---|
| 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가 맞는 저장 응답을 전제로 합니다. 다른 메서드, 오류, 범위 요청의 모든 경우를 나열한 표는 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 신선한 저장본부터 본문 선택까지 진행합니다.
| 상태·정책 | 요청·응답 | 본문 처리 |
|---|---|---|
| Fresh / 재검증 의무 없음 | 일치하는 캐시 항목을 직접 사용 가능 | 원본 서버에 요청하지 않고 저장된 본문 사용 |
| no-cache | 재사용 전에 성공적인 검증 필요 | 저장을 금지하는 no-store와 구분 |
| 검증자 일치 | 조건부 GET에 304 | 응답 메타데이터를 갱신하고 저장 본문 사용 |
| 표현 변경·검증자 없음 | 보통 200 전체 응답 | 새 본문 수신; 저장 가능 여부 다시 판단 |
| 허용된 stale 사용 | stale-while-revalidate 등 정책의 제한 안에서 사용 | 만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | Fresh / 재검증 의무 없음 — 요청·응답: 일치하는 캐시 항목을 직접 사용 가능; 본문 처리: 원본 서버에 요청하지 않고 저장된 본문 사용. no-cache — 요청·응답: 재사용 전에 성공적인 검증 필요; 본문 처리: 저장을 금지하는 no-store와 구분. 검증자 일치 — 요청·응답: 조건부 GET에 304; 본문 처리: 응답 메타데이터를 갱신하고 저장 본문 사용. 표현 변경·검증자 없음 — 요청·응답: 보통 200 전체 응답; 본문 처리: 새 본문 수신; 저장 가능 여부 다시 판단. 허용된 stale 사용 — 요청·응답: stale-while-revalidate 등 정책의 제한 안에서 사용; 본문 처리: 만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 신선한 저장본 다른 검증 의무가 없는 일치 저장 응답입니다. |
|
|
| 나이 70 나이 70초가 되어 기본 신선도가 끝납니다. |
|
|
| 검증 응답 서버 응답의 304와 200을 구별합니다. |
|
|
| 본문 선택 검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다. |
|
|
저장된 GET 응답을 다시 쓰는 경우
다른 검증 의무가 없는 일치 저장 응답입니다. 검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다. 각 단계의 값과 전환 이유를 비교합니다.
HTTP · 캐시 재사용
저장 가능 여부와 재검증 여부를 나누어 판단한다
같은 캐시 키와 Vary에 맞는 GET 응답을 가정합니다. no-cache는 재사용 전에 검증하라는 뜻이며, 저장 자체를 금지하는 no-store와 다릅니다. Freshness와 허용된 stale 정책도 함께 봅니다.
같은 cache key·Vary에서의 재사용 판단
그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.
원래 설명과 상세 기준
| 상태·정책 | 요청·응답 | 본문 처리 |
|---|---|---|
| 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가 맞는 저장 응답을 전제로 합니다. 다른 메서드, 오류, 범위 요청의 모든 경우를 나열한 표는 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 신선한 저장본부터 본문 선택까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | Fresh / 재검증 의무 없음 — 요청·응답: 일치하는 캐시 항목을 직접 사용 가능; 본문 처리: 원본 서버에 요청하지 않고 저장된 본문 사용. no-cache — 요청·응답: 재사용 전에 성공적인 검증 필요; 본문 처리: 저장을 금지하는 no-store와 구분. 검증자 일치 — 요청·응답: 조건부 GET에 304; 본문 처리: 응답 메타데이터를 갱신하고 저장 본문 사용. 표현 변경·검증자 없음 — 요청·응답: 보통 200 전체 응답; 본문 처리: 새 본문 수신; 저장 가능 여부 다시 판단. 허용된 stale 사용 — 요청·응답: stale-while-revalidate 등 정책의 제한 안에서 사용; 본문 처리: 만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 신선한 저장본 다른 검증 의무가 없는 일치 저장 응답입니다. |
|
|
| 나이 70 나이 70초가 되어 기본 신선도가 끝납니다. |
|
|
| 검증 응답 서버 응답의 304와 200을 구별합니다. |
|
|
| 본문 선택 검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다. |
|
|
재검증에는 응답의 버전을 나타내는 검증자(validator)를 씁니다. 서버가 처음 응답에 ETag: "v1"을 붙여 보냈다면, 캐시는 신선도가 끝난 뒤 If-None-Match: "v1"을 붙인 조건부 요청(conditional request)을 보냅니다. 서버는 지금의 버전이 같으면 본문 없이 304 Not Modified를, 바뀌었으면 새 본문과 함께 200을 돌려줍니다. 다음 코드는 이 흐름을 흉내 냅니다.
def server(headers, version, body):
etag = f'"v{version}"'
if headers.get("If-None-Match") == etag:
return 304, etag, b"" # 바뀌지 않았다: 본문 없이 답한다
return 200, etag, body
def use_cache(entry, age, max_age, version, body):
if age < max_age:
return f"캐시 그대로, 남은 신선도 {max_age - age}초", entry["body"]
status, etag, new_body = server({"If-None-Match": entry["etag"]}, version, body)
if status == 304:
return "304: 가진 본문을 다시 쓴다", entry["body"]
entry.update(etag=etag, body=new_body)
return "200: 새 본문으로 바꾼다", new_body
entry = {"etag": '"v1"', "body": b"price=100"}
print(use_cache(entry, age=20, max_age=60, version=1, body=b"price=100"))
print(use_cache(entry, age=70, max_age=60, version=1, body=b"price=100"))
print(use_cache(entry, age=70, max_age=60, version=2, body=b"price=120"))('캐시 그대로, 남은 신선도 40초', b'price=100')
('304: 가진 본문을 다시 쓴다', b'price=100')
('200: 새 본문으로 바꾼다', b'price=120')나이가 20초일 때는 서버에 묻지도 않습니다. 70초가 되어 신선도가 끝나면 조건부 요청을 보내는데, 서버의 버전이 그대로라면 304가 오고 캐시는 저장해 둔 본문을 씁니다. 304는 빈 문서가 아니라 "가진 것을 그대로 쓰라"는 뜻이며, 캐시는 이때 응답의 신선도를 다시 시작합니다. 서버의 값이 바뀌었다면 200과 함께 새 본문이 옵니다.
검증자에는 ETag 말고 Last-Modified도 있습니다. 캐시는 그 값을 If-Modified-Since에 넣어 보내고, 서버는 그 시각 이후에 바뀌었는지로 판단합니다. 구현은 쉽지만 초 단위라서 1초 안에 두 번 바뀌면 구별하지 못하므로, 둘 다 보냈다면 서버는 If-None-Match를 먼저 보고 If-Modified-Since는 무시합니다(RFC 9110 §13.2.2).
응답의 나이는 받은 뒤 흐른 시간만이 아닙니다. CDN 같은 앞의 캐시에서 이미 머문 시간이 Age 헤더로 따라오므로, Age: 15인 응답을 받고 5초가 지났다면 나이는 20초입니다. 받은 시각만으로 계산하면 앞의 캐시에서 보낸 시간을 놓쳐 신선도를 길게 잡게 됩니다.
더 깊이: Cache-Control이 없는 응답도 캐시된다
응답에 Cache-Control도 Expires도 없으면 캐시하지 않을 것 같지만, RFC 9111은 이때 캐시가 다른 헤더로 그럴듯한 만료 시각을 짐작하는 휴리스틱 신선도(heuristic freshness)를 쓸 수 있게 합니다. 짐작은 명시한 만료 시각이 없을 때만 쓸 수 있고, 200, 204, 301, 404처럼 휴리스틱으로 캐시할 수 있다고 정해 둔 상태 코드의 응답이나, public 지시처럼 캐시해도 된다고 명시한 응답에만 씁니다. RFC는 구체적인 계산법을 정하지 않지만, Last-Modified가 있다면 그 시각부터 흐른 시간의 일부, 흔히 10% 정도를 넘지 않게 잡으라고 권합니다.
그래서 열흘 전에 마지막으로 바뀐 파일을 아무 캐시 지시 없이 내보내면, 캐시는 이 응답을 하루쯤 신선하다고 보고 서버에 묻지 않을 수 있습니다. 배포한 뒤에도 일부 사용자가 예전 파일을 받는다면 이 경우를 의심해 볼 만합니다. 예전 HTTP/1.1 명세(RFC 2616)는 쿼리가 붙은 URL에 휴리스틱 신선도를 쓰지 못하게 했지만 실제로는 널리 구현되지 않았으므로, RFC 9111은 캐시를 막고 싶은 서버라면 Cache-Control: no-cache 같은 지시를 명시적으로 보내라고 권합니다.
자원에 맞는 캐시 전략
캐시 전략의 핵심은 "내용이 바뀔 수 있는가"를 URL에 반영하는 것입니다.
| 자원 | Cache-Control | 이유 |
|---|---|---|
| 파일 이름에 내용의 해시가 든 JS와 CSS | public, max-age=31536000, immutable | 내용이 바뀌면 파일 이름이 바뀌므로 같은 URL은 영원히 같다 |
| HTML 문서 | no-cache | 최신 자원의 이름을 알려 주는 입구이므로 매번 확인한다 |
| 사용자마다 다른 API 응답 | private, no-cache | 브라우저에는 저장해도 되지만 공유 캐시에는 두지 않는다 |
| 민감한 정보 | no-store | 저장 자체를 막는다 |
| CDN에서만 잠시 캐시할 응답 | s-maxage=300, stale-while-revalidate=30 | 공유 캐시에만 5분을 주고, 만료 직후에는 예전 응답을 먼저 준다 |
/app.7f3a9c.js처럼 빌드할 때 내용의 해시를 파일 이름에 넣으면, 파일을 1년 동안 캐시해도 배포한 새 코드가 새 이름으로 바로 쓰입니다. 대신 그 이름을 알려 주는 HTML은 매번 확인해야, 사용자가 예전 HTML에 적힌 예전 파일을 계속 받지 않습니다. 공유 캐시의 s-maxage에는 신선도가 끝나면 반드시 확인하라는 의미도 함께 들어 있으므로, stale-while-revalidate와 함께 쓸 때 CDN이 이를 어떻게 적용하는지는 그 CDN의 문서로 확인합니다.
동일 출처 정책과 CORS
브라우저는 한 출처(origin)의 스크립트가 다른 출처의 응답을 마음대로 읽지 못하게 막는데, 이를 동일 출처 정책(same-origin policy)이라고 합니다. 이 정책이 없다면 내가 연 아무 사이트의 스크립트가 로그인된 은행 사이트에 요청을 보내 그 응답을 읽어 갈 수 있습니다. 출처는 스킴, 호스트, 포트의 세 가지가 모두 같아야 같은 출처입니다.
from urllib.parse import urlsplit
def origin(url):
p = urlsplit(url)
port = p.port or {"http": 80, "https": 443}[p.scheme]
return (p.scheme, p.hostname, port)
page = "https://app.example.com/dashboard"
for target in ["https://app.example.com/api/me", "https://api.example.com/me",
"http://app.example.com/api/me", "https://app.example.com:8443/api/me"]:
print(target, "같은 출처" if origin(target) == origin(page) else "다른 출처")https://app.example.com/api/me 같은 출처
https://api.example.com/me 다른 출처
http://app.example.com/api/me 다른 출처
https://app.example.com:8443/api/me 다른 출처같은 회사의 api.example.com도 호스트가 다르므로 다른 출처이고, 스킴이나 포트만 달라도 다른 출처입니다. 쿠키의 "같은 사이트"보다 훨씬 엄격한 기준입니다. CORS(Fetch 표준)는 서버가 Access-Control-Allow-Origin 같은 응답 헤더로 "이 출처의 스크립트에는 응답을 보여 줘도 된다"고 허락하는 방법입니다. CORS는 서버를 보호하는 장치가 아니라 브라우저가 스크립트에 응답을 보여 줄지 정하는 규칙이라는 점을 기억해야 합니다. curl이나 다른 서버에서 보낸 요청에는 CORS가 적용되지 않습니다.
단순 요청과 사전 요청
브라우저는 다른 출처로 가는 요청 가운데 예전부터 양식 제출로 보낼 수 있던 모양의 요청은 바로 보냅니다. 메서드가 GET, HEAD, POST 가운데 하나이고, 정해진 몇몇 헤더만 쓰며, Content-Type이 양식이나 일반 텍스트인 요청이 그렇습니다. 그 밖의 요청은 실제 요청을 보내기 전에 OPTIONS 메서드로 "이 메서드와 헤더로 보내도 되는가"를 먼저 묻는데, 이를 사전 요청(preflight request)이라고 합니다.
SAFE_METHODS = {"GET", "HEAD", "POST"}
SAFE_TYPES = {"application/x-www-form-urlencoded", "multipart/form-data", "text/plain"}
SAFE_HEADERS = {"accept", "accept-language", "content-language", "content-type"}
def needs_preflight(method, headers):
if method not in SAFE_METHODS:
return True
for name, value in headers.items():
if name.lower() not in SAFE_HEADERS:
return True # 정해진 헤더가 아니면 미리 묻는다
if name.lower() == "content-type" and value.split(";")[0].strip().lower() not in SAFE_TYPES:
return True # JSON 같은 형식도 미리 묻는다
return False
for method, headers in [("GET", {}), ("GET", {"Authorization": "Bearer abc"}),
("POST", {"Content-Type": "text/plain"}),
("POST", {"Content-Type": "application/json"}), ("DELETE", {})]:
print(method, headers, "-> 사전 요청" if needs_preflight(method, headers) else "-> 바로 보냄")GET {} -> 바로 보냄
GET {'Authorization': 'Bearer abc'} -> 사전 요청
POST {'Content-Type': 'text/plain'} -> 바로 보냄
POST {'Content-Type': 'application/json'} -> 사전 요청
DELETE {} -> 사전 요청GET이라도 Authorization 헤더를 붙이면 사전 요청이 필요하고, POST라도 JSON을 보내면 사전 요청이 필요합니다. 실제 규칙은 헤더 값이 128바이트를 넘지 않는지, 쓸 수 있는 글자인지까지 따지므로 이 코드보다 엄격한 부분이 있고, 반대로 Range: bytes=0-499처럼 바이트 범위 하나만 적은 Range 헤더는 이 코드와 달리 사전 요청 없이 보낼 수 있습니다.
CORS에서 요청 전송과 응답 공개를 나누기
preflight 대상이고 일치하는 허용 캐시가 없습니다. 브라우저가 실제 응답의 공유 조건을 따로 봅니다. 각 단계의 값과 전환 이유를 비교합니다.
HTTP · CORS
요청을 보내는 판단과 JavaScript에 응답을 공개하는 판단은 다르다
Preflight 대상이면 실제 요청 전에 메서드·헤더 허용을 확인합니다. 실제 요청이 서버에서 처리된 뒤에도 CORS 응답 검사에 실패하면 브라우저는 JavaScript에 그 응답을 공개하지 않을 수 있습니다.
화살표는 브라우저가 진행하는 경로입니다. “OPTIONS 필요?”의 예는 preflight 대상이고 맞는 허용 캐시가 없는 경우입니다. 서버 업무 처리는 실제 요청 단계에서 일어나므로 그 뒤의 응답 공유 실패가 이미 일어난 처리를 되돌리지는 않습니다. 네트워크 오류가 없는 예시이며, 아래 표에서 credentials·쿠키·인증의 남는 조건을 함께 확인합니다.
| 확인 시점 | 브라우저 판단 | 실패·제약 |
|---|---|---|
| 실제 요청 전 | preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인 | preflight 실패면 실제 요청을 보내지 않음 |
| 실제 요청 응답 | Allow-Origin, credentials 조건을 확인 | 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음 |
| 헤더 공개 | 기본 공개 헤더와 Expose-Headers를 적용 | 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음 |
| 쿠키 동반 | credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용 | CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음 |
- 실제 요청 전
- 브라우저 판단: preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인실패·제약: preflight 실패면 실제 요청을 보내지 않음
- 실제 요청 응답
- 브라우저 판단: Allow-Origin, credentials 조건을 확인실패·제약: 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음
- 헤더 공개
- 브라우저 판단: 기본 공개 헤더와 Expose-Headers를 적용실패·제약: 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음
- 쿠키 동반
- 브라우저 판단: credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용실패·제약: CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음
CORS는 브라우저의 응답 공유 규칙입니다. 서버의 인증·권한 검사와 상태 변경 요청의 CSRF 방어는 별도로 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 교차 출처 판단부터 응답 공유까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | 실제 요청 전 — 브라우저 판단: preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인; 실패·제약: preflight 실패면 실제 요청을 보내지 않음. 실제 요청 응답 — 브라우저 판단: Allow-Origin, credentials 조건을 확인; 실패·제약: 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음. 헤더 공개 — 브라우저 판단: 기본 공개 헤더와 Expose-Headers를 적용; 실패·제약: 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음. 쿠키 동반 — 브라우저 판단: credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용; 실패·제약: CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 교차 출처 판단 preflight 대상이고 일치하는 허용 캐시가 없습니다. |
|
|
| 허용 확인 OPTIONS 응답의 메서드·헤더 허용을 검사합니다. |
|
|
| 실제 요청 허용된 경로에서 서버가 실제 요청을 처리합니다. |
|
|
| 응답 공유 브라우저가 실제 응답의 공유 조건을 따로 봅니다. |
|
|
CORS에서 요청 전송과 응답 공개를 나누기
preflight 대상이고 일치하는 허용 캐시가 없습니다. 브라우저가 실제 응답의 공유 조건을 따로 봅니다. 각 단계의 값과 전환 이유를 비교합니다.
HTTP · CORS
요청을 보내는 판단과 JavaScript에 응답을 공개하는 판단은 다르다
Preflight 대상이면 실제 요청 전에 메서드·헤더 허용을 확인합니다. 실제 요청이 서버에서 처리된 뒤에도 CORS 응답 검사에 실패하면 브라우저는 JavaScript에 그 응답을 공개하지 않을 수 있습니다.
화살표는 브라우저가 진행하는 경로입니다. “OPTIONS 필요?”의 예는 preflight 대상이고 맞는 허용 캐시가 없는 경우입니다. 서버 업무 처리는 실제 요청 단계에서 일어나므로 그 뒤의 응답 공유 실패가 이미 일어난 처리를 되돌리지는 않습니다. 네트워크 오류가 없는 예시이며, 아래 표에서 credentials·쿠키·인증의 남는 조건을 함께 확인합니다.
| 확인 시점 | 브라우저 판단 | 실패·제약 |
|---|---|---|
| 실제 요청 전 | preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인 | preflight 실패면 실제 요청을 보내지 않음 |
| 실제 요청 응답 | Allow-Origin, credentials 조건을 확인 | 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음 |
| 헤더 공개 | 기본 공개 헤더와 Expose-Headers를 적용 | 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음 |
| 쿠키 동반 | credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용 | CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음 |
- 실제 요청 전
- 브라우저 판단: preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인실패·제약: preflight 실패면 실제 요청을 보내지 않음
- 실제 요청 응답
- 브라우저 판단: Allow-Origin, credentials 조건을 확인실패·제약: 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음
- 헤더 공개
- 브라우저 판단: 기본 공개 헤더와 Expose-Headers를 적용실패·제약: 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음
- 쿠키 동반
- 브라우저 판단: credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용실패·제약: CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음
CORS는 브라우저의 응답 공유 규칙입니다. 서버의 인증·권한 검사와 상태 변경 요청의 CSRF 방어는 별도로 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 교차 출처 판단부터 응답 공유까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | 실제 요청 전 — 브라우저 판단: preflight 대상이면 허용 캐시 확인; 없으면 OPTIONS로 메서드·헤더 허용 확인; 실패·제약: preflight 실패면 실제 요청을 보내지 않음. 실제 요청 응답 — 브라우저 판단: Allow-Origin, credentials 조건을 확인; 실패·제약: 요청을 처리한 서버 응답도 JavaScript에 공개되지 않을 수 있음. 헤더 공개 — 브라우저 판단: 기본 공개 헤더와 Expose-Headers를 적용; 실패·제약: 노출 선언만으로 서버가 그 헤더 값을 생성하지 않음. 쿠키 동반 — 브라우저 판단: credentials 설정과 쿠키 범위·SameSite·브라우저 정책 적용; 실패·제약: CORS 허용만으로 쿠키 전송이나 로그인 성공을 보장하지 않음. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 교차 출처 판단 preflight 대상이고 일치하는 허용 캐시가 없습니다. |
|
|
| 허용 확인 OPTIONS 응답의 메서드·헤더 허용을 검사합니다. |
|
|
| 실제 요청 허용된 경로에서 서버가 실제 요청을 처리합니다. |
|
|
| 응답 공유 브라우저가 실제 응답의 공유 조건을 따로 봅니다. |
|
|
사전 요청이 허락되지 않으면 브라우저는 실제 요청을 보내지 않습니다. 허락되면 실제 요청을 보내고, 그 응답에도 CORS 헤더가 맞는지 다시 확인한 뒤에야 스크립트에 보여 줍니다. 여기서 중요한 것은 실제 요청이 서버에 도착해 처리된 뒤에 응답을 보여 줄지가 정해진다는 점입니다. 응답의 CORS 헤더가 맞지 않아 스크립트가 오류를 받았더라도, 서버는 이미 그 요청을 처리했을 수 있습니다. 바로 보내는 단순 요청도 마찬가지이므로, 상태를 바꾸는 요청을 다른 출처에서 막는 일은 CORS가 아니라 9장 3편의 CSRF 방어가 맡아야 합니다. 또 사전 요청에는 쿠키가 실리지 않으므로, OPTIONS 요청에까지 로그인을 요구하는 서버는 정상적인 요청도 막아 버립니다.
서버가 보내는 CORS 헤더
CORS 오류를 고치는 곳은 프론트엔드의 코드가 아니라 서버의 응답 헤더입니다. 브라우저가 응답을 스크립트에 보여 줄지 판단할 때 보는 것은 서버가 보낸 헤더뿐이기 때문입니다. 아래 cors_headers는 요청의 Origin을 허용 목록과 비교해 맞을 때만 CORS 헤더를 붙이고, 사전 요청이면 허락할 메서드와 헤더, 그 결과를 캐시할 시간을 더합니다. 마지막 두 줄은 허용된 출처의 사전 요청과 허용되지 않은 출처의 요청에 돌려줄 헤더를 비교합니다.
ALLOWED = {"https://app.example.com"}
def cors_headers(origin, preflight=False):
headers = {"Vary": "Origin"} # 출처마다 응답이 다르므로 캐시에 알린다
if origin in ALLOWED: # 허용 목록에 있는 출처만 그대로 돌려준다
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
if preflight:
headers["Access-Control-Allow-Methods"] = "GET, POST"
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
headers["Access-Control-Max-Age"] = "600"
return headers
print(cors_headers("https://app.example.com", preflight=True))
print(cors_headers("https://evil.example.net")){'Vary': 'Origin', 'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '600'}
{'Vary': 'Origin'}허용 목록에 없는 출처에는 CORS 헤더를 붙이지 않으므로, 브라우저가 그 출처의 스크립트에 응답을 보여 주지 않습니다. 각 헤더가 하는 일과 자주 나는 오류는 다음과 같습니다.
| 상황 | 확인할 것 |
|---|---|
| 출처를 허락하지 않았다는 오류 | Access-Control-Allow-Origin이 요청의 Origin과 정확히 같은가 |
| 쿠키를 함께 보내는 요청 | Access-Control-Allow-Credentials: true이고, 출처를 *가 아닌 정확한 값으로 적었는가 |
| 사전 요청의 실패 | Allow-Methods와 Allow-Headers에 실제 요청의 메서드와 헤더가 들어 있는가 |
| 응답 헤더를 스크립트에서 읽지 못함 | 기본으로 공개되는 헤더가 아니라면 Access-Control-Expose-Headers에 이름을 적었는가 |
| 사전 요청이 매번 반복됨 | Access-Control-Max-Age를 적었는가. 브라우저마다 상한이 있어 더 길게 적어도 그만큼만 기억한다 |
| 출처를 그대로 돌려주는 서버 | 허용 목록으로 확인한 뒤 돌려주는가, Vary: Origin을 보내는가 |
요청의 Origin을 확인하지 않고 그대로 Access-Control-Allow-Origin에 돌려주면서 자격 증명까지 허락하면, 아무 사이트의 스크립트나 로그인한 사용자의 응답을 읽어 갈 수 있습니다. CORS를 끄는 것과 같은 결과이므로 반드시 허용 목록으로 확인해야 합니다. 개발할 때는 프론트엔드 개발 서버의 프록시로 API를 같은 출처처럼 중계하면 CORS 없이 개발할 수 있지만, 운영 환경의 설정은 따로 맞춰야 합니다.
정리
- HTTP 캐시는 메서드와 URL,
Vary가 가리키는 요청 헤더를 열쇠로 응답을 저장하며, 사용자마다 다른 응답은 공유 캐시에 두지 않아야 합니다. no-cache는 쓰기 전에 확인하라는 뜻이고no-store는 저장하지 말라는 뜻입니다.- 신선도가 끝난 응답은
ETag나Last-Modified로 조건부 요청을 보내 확인하고, 304를 받으면 저장한 본문을 다시 씁니다. 나이에는 앞의 캐시에서 머문Age도 포함합니다. - 내용의 해시를 이름에 넣은 정적 파일은 오래 캐시하고, 그 이름을 알려 주는 HTML은 매번 확인합니다.
- 출처는 스킴, 호스트, 포트가 모두 같아야 같으며, CORS는 서버가 다른 출처의 스크립트에 응답을 보여 줘도 된다고 허락하는 브라우저의 규칙입니다.
- 사전 요청이 허락되지 않으면 실제 요청을 보내지 않지만, 보낸 요청은 응답을 보여 주지 않더라도 이미 처리되었을 수 있으므로 CSRF 방어는 따로 둡니다.
연습 문제
1. no-cache와 304
저장해 둔 응답의 나이가 20초이고 정책이 max-age=60, no-cache라면, 캐시는 서버에 묻지 않고 바로 다시 쓸 수 있나요? 조건부 요청에 304와 빈 본문이 왔다면 저장해 둔 본문을 버려야 하나요?
해설 보기
나이가 60초보다 작더라도 no-cache는 쓰기 전에 반드시 서버에 확인하라는 뜻이므로 바로 쓸 수 없고, 조건부 요청을 보내야 합니다. 304는 저장한 표현이 지금도 같다는 뜻이므로 본문을 버리지 않고 그대로 쓰며, 304에 함께 온 헤더로 저장한 응답의 정보를 갱신합니다. no-cache를 no-store로 착각해 저장 자체를 하지 않거나, 304의 빈 본문을 새 빈 문서로 읽으면 안 됩니다.
2. 사전 요청이 막히면
https://app.example.com의 스크립트가 다른 출처의 API로 Content-Type: application/json인 POST를 보내려 합니다. 서버의 사전 요청 응답이 Access-Control-Allow-Methods: GET만 담고 있다면 어떻게 되나요? 같은 요청을 curl로 보내면 어떻게 되나요?
해설 보기
JSON을 보내는 POST는 사전 요청이 필요하고, 사전 요청의 응답이 POST를 허락하지 않았으므로 브라우저는 실제 POST를 보내지 않고 스크립트에 CORS 오류를 줍니다. 서버는 OPTIONS 요청만 받았을 뿐 POST를 처리하지 않습니다. 같은 요청을 curl로 보내면 CORS는 브라우저의 규칙이므로 아무것도 확인하지 않고 POST를 바로 보내며, 서버는 그 요청을 처리합니다. 그래서 CORS 설정을 서버의 접근 제어로 여기면 안 되고, 인증과 권한 검사는 서버가 따로 해야 합니다.
3. 출처를 그대로 돌려주는 서버
어느 API 서버가 모든 요청의 Origin 헤더 값을 그대로 Access-Control-Allow-Origin에 넣고, Access-Control-Allow-Credentials: true도 함께 보냅니다. 로그인한 사용자가 공격자의 사이트를 열었을 때 어떤 일이 생길 수 있나요? 이 서버가 Vary: Origin을 보내지 않고 CDN 뒤에 있다면 생길 수 있는 문제도 생각해 보세요.
해설 보기
공격자의 사이트에서 이 API로 자격 증명을 포함한 요청을 보내면, 서버가 공격자의 출처를 그대로 허락하므로 브라우저는 로그인한 사용자의 응답을 공격자의 스크립트에 보여 줍니다. 개인정보나 토큰이 담긴 응답을 읽어 갈 수 있으므로, 출처는 허용 목록으로 확인한 뒤에만 돌려줘야 합니다. Vary: Origin이 없다면 CDN은 출처와 상관없이 같은 열쇠로 응답을 저장하므로, 한 출처를 위해 만든 CORS 헤더가 다른 출처의 요청에 돌아갑니다. 허락해야 할 출처가 거부되거나, 거부해야 할 출처가 허락되는 응답을 받을 수 있습니다.
다음 장 암호화의 기초에서는 HTTPS를 이루는 대칭 키와 공개 키 암호, 해시와 전자 서명이 각각 무엇을 지켜 주는지 살펴봅니다.