본문 바로가기

캐시와 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에서의 재사용 판단

같은 cache key·Vary에서의 재사용 판단그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.없음필요304200일치 저장 응답GET / key / Vary검증필요?Fresh저장 본문직접 사용조건부GET응답304 / 200?304메타 갱신본문 재사용200새 본문저장 재판정
같은 cache key·Vary에서의 재사용 판단그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.없음필요304200일치 저장 응답GET / key /Vary검증필요?Fresh저장본문직접사용조건부GET응답304 / 200?304메타갱신본문재사용200새 본문저장재판정

그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.

원래 설명과 상세 기준
저장된 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가 맞는 저장 응답을 전제로 합니다. 다른 메서드, 오류, 범위 요청의 모든 경우를 나열한 표는 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 신선한 저장본부터 본문 선택까지 진행합니다.
저장된 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 등 정책의 제한 안에서 사용; 본문 처리: 만료됐다는 이유만으로 항상 동기 재검증하는 것은 아님.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
신선한 저장본

다른 검증 의무가 없는 일치 저장 응답입니다.

  • 캐시 나이·응답 1: max-age60
  • 캐시 나이·응답 2: age20
  • 캐시 나이·응답 3: remaining40s
  • 나이 20초·남은 신선도 40초이면 직접 재사용 가능
나이 70

나이 70초가 되어 기본 신선도가 끝납니다.

  • 캐시 나이·응답 1: age70>60
  • 캐시 나이·응답 2: ETag 있음
  • ETag가 있으므로 조건부 GET; stale 허용 확장은 별도
검증 응답

서버 응답의 304와 200을 구별합니다.

  • 캐시 나이·응답 1: 304 같은 표현
  • 캐시 나이·응답 2: 200 변경 표현
  • 304는 같은 표현 확인이며 새 JSON 본문은 없음
본문 선택

검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다.

  • 캐시 나이·응답 1: 304 메타 갱신+저장 본문
  • 캐시 나이·응답 2: 200 새 본문/저장 재판단
  • no-cache면 나이 20초에도 검증; Date·Age·경과 시간을 함께 계산

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

다른 검증 의무가 없는 일치 저장 응답입니다. 검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP · 캐시 재사용

저장 가능 여부와 재검증 여부를 나누어 판단한다

같은 캐시 키와 Vary에 맞는 GET 응답을 가정합니다. no-cache는 재사용 전에 검증하라는 뜻이며, 저장 자체를 금지하는 no-store와 다릅니다. Freshness와 허용된 stale 정책도 함께 봅니다.

같은 cache key·Vary에서의 재사용 판단

같은 cache key·Vary에서의 재사용 판단그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.없음필요304200일치 저장 응답GET / key / Vary검증필요?Fresh저장 본문직접 사용조건부GET응답304 / 200?304메타 갱신본문 재사용200새 본문저장 재판정
같은 cache key·Vary에서의 재사용 판단그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.없음필요304200일치 저장 응답GET / key /Vary검증필요?Fresh저장본문직접사용조건부GET응답304 / 200?304메타갱신본문재사용200새 본문저장재판정

그림은 ETag 등 검증자가 있는 분기입니다. 검증자가 없으면 일반 GET으로 조회하며 보통 200을 받습니다. 허용된 stale 사용은 해당 정책의 제한 안에서 별도로 허용됩니다. no-store는 저장 금지, no-cache는 재사용 전 검증입니다. 모든 오류·범위 요청을 이 정상 분기로 열거하지 않습니다.

원래 설명과 상세 기준
저장된 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가 맞는 저장 응답을 전제로 합니다. 다른 메서드, 오류, 범위 요청의 모든 경우를 나열한 표는 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 신선한 저장본부터 본문 선택까지 진행합니다.

관계도의 보충 설명

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

다른 검증 의무가 없는 일치 저장 응답입니다.

  • 캐시 나이·응답 1: max-age60
  • 캐시 나이·응답 2: age20
  • 캐시 나이·응답 3: remaining40s
  • 나이 20초·남은 신선도 40초이면 직접 재사용 가능
나이 70

나이 70초가 되어 기본 신선도가 끝납니다.

  • 캐시 나이·응답 1: age70>60
  • 캐시 나이·응답 2: ETag 있음
  • ETag가 있으므로 조건부 GET; stale 허용 확장은 별도
검증 응답

서버 응답의 304와 200을 구별합니다.

  • 캐시 나이·응답 1: 304 같은 표현
  • 캐시 나이·응답 2: 200 변경 표현
  • 304는 같은 표현 확인이며 새 JSON 본문은 없음
본문 선택

검증 결과에 맞게 저장 본문이나 새 본문을 고릅니다.

  • 캐시 나이·응답 1: 304 메타 갱신+저장 본문
  • 캐시 나이·응답 2: 200 새 본문/저장 재판단
  • no-cache면 나이 20초에도 검증; Date·Age·경과 시간을 함께 계산

재검증에는 응답의 버전을 나타내는 검증자(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와 CSSpublic, 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에 그 응답을 공개하지 않을 수 있습니다.

CORS의 요청 전 허용과 응답 공유 분기교차 출처 fetch에서 preflight가 필요하고 허용 캐시가 없으면 OPTIONS로 허용을 확인합니다. 실패 경로는 실제 요청을 보내지 않고 끝납니다. preflight가 필요 없거나 허용 캐시가 있으면 OPTIONS를 건너뛰며, 허용 확인에 성공한 경로도 실제 요청으로 합류합니다. 실제 요청의 서버 처리 뒤에는 별도의 응답 공유 검사가 있습니다. 여기서 실패하면 서버 처리는 이미 일어났어도 JavaScript에는 응답을 공개하지 않습니다. 네트워크 오류가 없는 예시이며 업무 인증과 쿠키 전송은 별도 조건입니다.예아니요아니요예아니요예교차 출처 fetch 요청OPTIONS 필요?OPTIONS 허용 확인허용 조건 충족?실제 요청전송 안 함실제 요청 전송서버 업무 처리응답 공유 허용?JavaScript에응답 공개 안 함JavaScript에 응답 공개
CORS의 요청 전 허용과 응답 공유 분기교차 출처 fetch에서 preflight가 필요하고 허용 캐시가 없으면 OPTIONS로 허용을 확인합니다. 실패 경로는 실제 요청을 보내지 않고 끝납니다. preflight가 필요 없거나 허용 캐시가 있으면 OPTIONS를 건너뛰며, 허용 확인에 성공한 경로도 실제 요청으로 합류합니다. 실제 요청의 서버 처리 뒤에는 별도의 응답 공유 검사가 있습니다. 여기서 실패하면 서버 처리는 이미 일어났어도 JavaScript에는 응답을 공개하지 않습니다. 네트워크 오류가 없는 예시이며 업무 인증과 쿠키 전송은 별도 조건입니다.예아니요아니요예아니요예교차 출처fetch 요청OPTIONS필요?OPTIONS허용 확인허용 조건충족?실제 요청전송 안 함실제 요청 전송서버 업무 처리응답 공유허용?JavaScript에응답 공개 안 함JavaScript에응답 공개

화살표는 브라우저가 진행하는 경로입니다. “OPTIONS 필요?”의 예는 preflight 대상이고 맞는 허용 캐시가 없는 경우입니다. 서버 업무 처리는 실제 요청 단계에서 일어나므로 그 뒤의 응답 공유 실패가 이미 일어난 처리를 되돌리지는 않습니다. 네트워크 오류가 없는 예시이며, 아래 표에서 credentials·쿠키·인증의 남는 조건을 함께 확인합니다.

CORS에서 요청 전송과 응답 공개를 나누기
확인 시점브라우저 판단실패·제약
실제 요청 전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 대상이고 일치하는 허용 캐시가 없습니다.

  • 허용·처리 상태 1: GET+Authorization
  • 허용·처리 상태 2: 허용 캐시 없음
  • GET의 Authorization 헤더 때문에 OPTIONS 필요
허용 확인

OPTIONS 응답의 메서드·헤더 허용을 검사합니다.

  • 허용·처리 상태 1: OPTIONS GET/Authorization
  • 허용·처리 상태 2: 허용이면 진행
  • 실패면 실제 GET 미전송; preflight에 로그인 쿠키는 동반하지 않음
실제 요청

허용된 경로에서 서버가 실제 요청을 처리합니다.

  • 허용·처리 상태 1: GET 전송
  • 허용·처리 상태 2: server 처리
  • GET 송신과 서버 업무 처리; 업무 인증·쿠키 조건은 별도
응답 공유

브라우저가 실제 응답의 공유 조건을 따로 봅니다.

  • 허용·처리 상태 1: 허용→JS 공개
  • 허용·처리 상태 2: 실패→JS 미공개
  • 실패해도 서버 처리를 되돌리지 않음; CSRF·Expose-Headers는 별도

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

preflight 대상이고 일치하는 허용 캐시가 없습니다. 브라우저가 실제 응답의 공유 조건을 따로 봅니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP · CORS

요청을 보내는 판단과 JavaScript에 응답을 공개하는 판단은 다르다

Preflight 대상이면 실제 요청 전에 메서드·헤더 허용을 확인합니다. 실제 요청이 서버에서 처리된 뒤에도 CORS 응답 검사에 실패하면 브라우저는 JavaScript에 그 응답을 공개하지 않을 수 있습니다.

CORS의 요청 전 허용과 응답 공유 분기교차 출처 fetch에서 preflight가 필요하고 허용 캐시가 없으면 OPTIONS로 허용을 확인합니다. 실패 경로는 실제 요청을 보내지 않고 끝납니다. preflight가 필요 없거나 허용 캐시가 있으면 OPTIONS를 건너뛰며, 허용 확인에 성공한 경로도 실제 요청으로 합류합니다. 실제 요청의 서버 처리 뒤에는 별도의 응답 공유 검사가 있습니다. 여기서 실패하면 서버 처리는 이미 일어났어도 JavaScript에는 응답을 공개하지 않습니다. 네트워크 오류가 없는 예시이며 업무 인증과 쿠키 전송은 별도 조건입니다.예아니요아니요예아니요예교차 출처 fetch 요청OPTIONS 필요?OPTIONS 허용 확인허용 조건 충족?실제 요청전송 안 함실제 요청 전송서버 업무 처리응답 공유 허용?JavaScript에응답 공개 안 함JavaScript에 응답 공개
CORS의 요청 전 허용과 응답 공유 분기교차 출처 fetch에서 preflight가 필요하고 허용 캐시가 없으면 OPTIONS로 허용을 확인합니다. 실패 경로는 실제 요청을 보내지 않고 끝납니다. preflight가 필요 없거나 허용 캐시가 있으면 OPTIONS를 건너뛰며, 허용 확인에 성공한 경로도 실제 요청으로 합류합니다. 실제 요청의 서버 처리 뒤에는 별도의 응답 공유 검사가 있습니다. 여기서 실패하면 서버 처리는 이미 일어났어도 JavaScript에는 응답을 공개하지 않습니다. 네트워크 오류가 없는 예시이며 업무 인증과 쿠키 전송은 별도 조건입니다.예아니요아니요예아니요예교차 출처fetch 요청OPTIONS필요?OPTIONS허용 확인허용 조건충족?실제 요청전송 안 함실제 요청 전송서버 업무 처리응답 공유허용?JavaScript에응답 공개 안 함JavaScript에응답 공개

화살표는 브라우저가 진행하는 경로입니다. “OPTIONS 필요?”의 예는 preflight 대상이고 맞는 허용 캐시가 없는 경우입니다. 서버 업무 처리는 실제 요청 단계에서 일어나므로 그 뒤의 응답 공유 실패가 이미 일어난 처리를 되돌리지는 않습니다. 네트워크 오류가 없는 예시이며, 아래 표에서 credentials·쿠키·인증의 남는 조건을 함께 확인합니다.

CORS에서 요청 전송과 응답 공개를 나누기
확인 시점브라우저 판단실패·제약
실제 요청 전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 대상이고 일치하는 허용 캐시가 없습니다.

  • 허용·처리 상태 1: GET+Authorization
  • 허용·처리 상태 2: 허용 캐시 없음
  • GET의 Authorization 헤더 때문에 OPTIONS 필요
허용 확인

OPTIONS 응답의 메서드·헤더 허용을 검사합니다.

  • 허용·처리 상태 1: OPTIONS GET/Authorization
  • 허용·처리 상태 2: 허용이면 진행
  • 실패면 실제 GET 미전송; preflight에 로그인 쿠키는 동반하지 않음
실제 요청

허용된 경로에서 서버가 실제 요청을 처리합니다.

  • 허용·처리 상태 1: GET 전송
  • 허용·처리 상태 2: server 처리
  • GET 송신과 서버 업무 처리; 업무 인증·쿠키 조건은 별도
응답 공유

브라우저가 실제 응답의 공유 조건을 따로 봅니다.

  • 허용·처리 상태 1: 허용→JS 공개
  • 허용·처리 상태 2: 실패→JS 미공개
  • 실패해도 서버 처리를 되돌리지 않음; CSRF·Expose-Headers는 별도

사전 요청이 허락되지 않으면 브라우저는 실제 요청을 보내지 않습니다. 허락되면 실제 요청을 보내고, 그 응답에도 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를 이루는 대칭 키와 공개 키 암호, 해시와 전자 서명이 각각 무엇을 지켜 주는지 살펴봅니다.