본문 바로가기

캐시와 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 정책 제공

공유 캐시의 s-maxage에는 proxy-revalidate 의미도 포함되므로, stale-while-revalidate 확장과 다른 재검증 지시자의 조합을 선택한 캐시가 어떻게 적용하는지 확인합니다. 위 300초와 30초를 모든 CDN에서 조건 없이 최대 330초 사용한다는 보장으로 읽지 않습니다. 아래 계산 연습은 별도의 stale 허용이 없는 정책을 사용합니다. RFC9111의 확장 지시자 규칙을 함께 확인하세요.


Fresh Cache와 Conditional Request

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

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

예시: 캐시의 나이는 이미 흐르고 있습니다 동일한 키의 저장 가능한 GET 응답에 max-age=60이 있고 현재 나이는 20초라고 합시다. 다른 재검증 의무와 stale 허용 정책은 없습니다. 이 단순 모델의 남은 신선도는 60−20=40초입니다. 그 뒤 나이 70초라면 신선하지 않으므로, ETag가 있을 때 조건부 GET을 보냅니다. 304이면 응답 메타데이터를 반영하고 저장한 본문을 사용하고, 표현이 바뀌어 200 본문이 왔다면 새 본문을 사용합니다. 304에 새 JSON 본문이 있어야만 화면을 만들 수 있는 것은 아닙니다. 수신 시각만 보면 중간 캐시에서 이미 지난 Age를 놓칠 수 있으므로 실제 나이는 Date·Age와 경과 시간을 함께 계산합니다.

정책을 바꾸어 판단하기 같은 저장 응답의 나이가 20초인데 정책이 max-age=60, no-cache라면 서버 확인 없이 바로 재사용할 수 있을까요? 그 조건부 GET이 304와 빈 본문을 반환했을 때 기존 본문도 버려야 할까요? 별도의 stale 허용과 캐시 키 불일치는 없습니다.

신선도와 재검증 의무의 풀이

나이가 60초 미만이어도 no-cache는 재사용 전 성공적인 검증을 요구하므로 바로 재사용하지 않습니다. 304가 같은 저장 표현을 가리키면 메타데이터를 반영하고 그 본문을 재사용합니다. no-store와 혼동해 저장을 금지하거나 304를 빈 새 문서라고 읽지 않습니다. 두 조건부 헤더를 함께 보낸 경우 If-None-Match가 있으면 If-Modified-Since는 무시하는 규칙도 확인합니다. RFC9111의 캐시 재사용·검증을 참고하세요.

실무 캐시 전략

정적 자산은 파일명에 콘텐츠 해시를 넣고 긴 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에서 요청 전송과 응답 공개를 나누기

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는 별도

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


예시: 서버 처리와 응답 공개를 따로 추적하기 다른 출처에 GET과 Authorization을 보내려 하고, 맞는 preflight 허용 캐시는 없다고 합시다. 브라우저는 먼저 OPTIONS로 GET과 Authorization 허용을 확인합니다. 이 확인이 실패하면 실제 GET을 보내지 않습니다. 허용되면 실제 GET을 보내며, 그 응답에도 현재 Origin과 credentials에 맞는 CORS 검사가 필요합니다. 서버가 실제 GET을 처리했다고 해도 응답 헤더가 맞지 않으면 JavaScript에 응답을 공개하지 않습니다. OPTIONS의 허용 결과는 실제 업무의 인증 성공과 다릅니다. 교차 출처 preflight에는 실제 요청에 쓸 쿠키가 동반되지 않으므로, OPTIONS에서 로그인 쿠키만 요구하는 설계는 정상 요청 전에 막힐 수 있습니다. Fetch의 preflight와 credentials를 기준으로 이 단계를 구분합니다.

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와 브라우저 쿠키 정책은 별도로 적용됩니다.

보존 서버 예제로 독립 판단하기 허용된 Origin에서 Content-Type: application/json인 POST를 요청하려 합니다. preflight 허용 캐시는 없고, 위 서버의 코드만 구현돼 있습니다. OPTIONS가 204라는 이유만으로 실제 POST도 처리될까요? GET 응답에서 X-Request-Id를 읽을 수 있게 선언한 것만으로 그 값이 실제로 생기는지도 판단하세요.

허용 범위와 실제 구현의 풀이

원 서버의 Allow-Methods는 GET이므로 POST preflight의 메서드 허용을 만족하지 못합니다. 또한 코드에 do_POST 구현도 없습니다. OPTIONS의 성공 상태만 보고 필요한 메서드·헤더·Origin 조건을 생략할 수 없습니다. Expose-Headers는 실제로 존재하는 응답 헤더의 읽기 허용을 정하는 선언입니다. 이 예제는 X-Request-Id 값을 send_header로 추가하지 않으므로 선언만으로 값이 생성되지 않습니다. Allow-Credentials 역시 세션 유효성이나 쿠키 전송을 단독으로 보장하지 않습니다.

Access-Control-Max-Age의 86400은 preflight 허용 정보의 캐시 수명이며, GET 본문을 86400초 저장하라는 뜻은 아닙니다. 브라우저의 구현 상한도 적용됩니다. HTTP 응답 캐시, preflight 캐시, 쿠키 전송 조건은 서로 다른 상태입니다.

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