적용 환경: 모든 모던 브라우저 (Fetch API, XMLHttpRequest 기반 요청)
프론트엔드 개발을 하다 보면 한 번쯤은 브라우저 콘솔에 찍힌 빨간 에러 메시지를 마주하게 됩니다.
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
같은 API를 Postman이나 cURL로 호출하면 문제없이 응답이 돌아오는데, 브라우저에서만 차단됩니다. 더 당혹스러운 것은 DevTools의 Network 탭을 열어보면, 요청이 실제로 전송되기도 전에 OPTIONS라는 알 수 없는 메서드의 요청이 먼저 나가 있다는 점입니다.
이 현상을 이해하려면 브라우저가 교차 출처 요청을 어떻게 제어하는지, 그리고 왜 특정 요청 앞에 Preflight라 불리는 사전 확인 절차를 거치는지를 알아야 합니다. 이 문서에서는 Same-Origin Policy라는 근본 정책부터 시작하여, CORS 메커니즘의 전체 흐름과 Preflight 요청의 동작 원리를 상세히 다룹니다.
Same-Origin Policy: 왜 브라우저는 요청을 차단하는가
웹 보안의 출발점은 Same-Origin Policy(동일 출처 정책)입니다. "출처"(origin)란 URL의 프로토콜(scheme) + 호스트 + 포트 조합을 말하며, 이 세 요소가 모두 일치해야 같은 출처로 간주됩니다.
| Origin A | Origin B | 동일 출처? | 이유 |
|---|---|---|---|
https://example.com | https://example.com/api/users | O | 경로(path)는 출처에 포함되지 않음 |
https://example.com | http://example.com | X | 프로토콜이 다름 (HTTPS vs HTTP) |
https://example.com | https://example.com:8080 | X | 포트가 다름 (443 vs 8080) |
https://example.com | https://api.example.com | X | 호스트가 다름 (서브도메인도 별개) |
SOP가 존재하는 이유는 명확합니다. 사용자가 은행 사이트(bank.com)에 로그인한 상태에서 악성 사이트(evil.com)를 방문했다고 가정하면, SOP가 없다면 evil.com의 JavaScript가 bank.com에 요청을 보내 사용자의 세션 쿠키와 함께 계좌 정보를 가져올 수 있습니다. SOP는 이처럼 다른 출처의 응답을 JavaScript가 읽는 것을 차단하여 사용자의 데이터를 보호합니다.
여기서 한 가지 중요한 사실이 있습니다. SOP는 요청의 전송을 막는 것이 아니라 응답의 읽기를 막습니다. 브라우저는 교차 출처 요청을 보내고, 서버도 이를 처리하여 응답을 반환하지만, 브라우저가 그 응답을 JavaScript에 노출하지 않는 것입니다. 이 구분은 CORS 에러를 디버깅할 때 매우 중요한데, 서버 로그에는 요청이 정상적으로 기록되어 있는데 클라이언트에서만 에러가 발생하는 상황이 바로 이 메커니즘 때문입니다.
SOP는 브라우저만의 제한
Same-Origin Policy는 브라우저에서만 적용됩니다. cURL, Postman, 서버 간 통신에서는 SOP가 존재하지 않으므로, 같은 API 호출이 브라우저 외부에서는 아무 문제 없이 동작합니다. "브라우저에서만 안 되는" 이유가 바로 이것입니다.
CORS의 기본 메커니즘
현대 웹 애플리케이션은 프론트엔드와 API 서버가 다른 출처에 있는 경우가 대부분입니다. localhost:3000에서 개발하면서 api.example.com을 호출하거나, CDN에서 폰트와 이미지를 불러오는 것은 일상적인 패턴이므로, SOP를 완전히 적용하면 현실적인 웹 개발이 불가능해집니다. 이 문제를 해결하기 위해 등장한 것이 CORS(Cross-Origin Resource Sharing)입니다.
CORS는 서버가 특정 출처의 접근을 허용하겠다고 명시적으로 선언하는 메커니즘입니다. 브라우저가 교차 출처 요청을 보낼 때, 서버가 응답 헤더를 통해 "이 출처에서 오는 요청은 허용한다"고 알려주면, 브라우저가 JavaScript에 응답을 노출하는 방식입니다.
브라우저가 Origin 헤더를 자동 추가
JavaScript 코드가 fetch()나 XMLHttpRequest로 교차 출처 요청을 보내면, 브라우저는 자동으로 Origin 헤더를 추가합니다. 이 헤더는 JavaScript에서 수정하거나 제거할 수 없으며, 브라우저가 직접 관리합니다.
서버가 CORS 응답 헤더를 반환
요청을 받은 서버는 Access-Control-Allow-Origin 헤더를 포함하여 응답합니다. 이 헤더에 요청 출처가 포함되어 있으면 접근이 허용된다는 의미입니다.
브라우저가 헤더를 검증하고 응답을 노출 또는 차단
브라우저는 응답의 Access-Control-Allow-Origin 값과 요청의 Origin을 비교합니다. 일치하면 JavaScript가 응답을 읽을 수 있고, 일치하지 않거나 헤더가 없으면 응답을 차단하고 콘솔에 CORS 에러를 출력합니다.
이 흐름을 HTTP 헤더 수준에서 보면 다음과 같습니다.
# 요청 (브라우저가 Origin을 자동 추가)
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
# 응답 (서버가 허용할 출처를 명시)
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json
{"message": "success"}여기서 핵심은 CORS가 서버 측 설정이라는 점입니다. 프론트엔드 코드에서 CORS 에러를 "고칠" 수 있는 방법은 없으며, 서버가 적절한 응답 헤더를 반환하도록 설정해야 합니다. "CORS를 프론트엔드에서 해결해야 한다"는 것은 가장 흔한 오해 중 하나입니다.
Simple Request vs Preflight Request
모든 교차 출처 요청이 동일하게 처리되는 것은 아닙니다. 브라우저는 요청의 특성에 따라 두 가지 경로로 분기합니다. 하나는 곧바로 요청을 보내는 Simple Request, 다른 하나는 사전 확인 절차를 거치는 Preflight Request입니다.
Simple Request로 분류되려면 다음 조건을 모두 충족해야 합니다.
| 조건 | 허용되는 값 |
|---|---|
| HTTP 메서드 | GET, HEAD, POST만 허용 |
| 요청 헤더 | CORS-safelisted 헤더만 허용 (Accept, Accept-Language, Content-Language, Content-Type, Range) |
| Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain만 허용 |
조건 하나라도 벗어나면 Preflight가 발생합니다
실무에서 대부분의 API 호출은 Simple Request 조건을 충족하지 못합니다. Content-Type: application/json을 사용하거나, Authorization 헤더를 포함하거나, PUT/DELETE/PATCH 메서드를 사용하는 순간 Preflight가 트리거됩니다. 즉, 거의 모든 REST API 호출에는 Preflight가 동반된다고 보는 것이 현실적입니다.
두 경로의 차이를 HTTP 수준에서 비교하면 다음과 같습니다.
# 조건 충족: GET + 커스텀 헤더 없음
# → 브라우저가 바로 요청을 전송
GET /api/public/notices HTTP/1.1
Host: api.example.com
Origin: https://myapp.comSimple Request는 HTML <form>이 전통적으로 보낼 수 있는 요청의 범위와 대체로 일치합니다. 브라우저 입장에서는 <form> 태그로도 가능한 요청이므로 별도의 사전 확인 없이 전송해도 기존 웹의 보안 모델을 위반하지 않습니다. 반면 JavaScript로만 가능한 요청(커스텀 헤더, JSON 바디, PUT/DELETE 등)은 서버가 이를 의도적으로 허용했는지 먼저 확인해야 하므로, Preflight 절차를 거치게 됩니다.
Preflight 요청의 동작 흐름
Preflight는 브라우저가 실제 요청을 보내기 전에, OPTIONS 메서드로 서버에 "이런 요청을 보내도 되는지" 먼저 확인하는 절차입니다. JavaScript 코드에서는 이 과정이 완전히 투명하게 처리되며, 개발자가 직접 OPTIONS 요청을 보내는 것이 아닙니다. DevTools의 Network 탭에서만 확인할 수 있습니다.
브라우저가 Preflight 필요성을 판단
fetch()나 XMLHttpRequest 호출을 가로챈 브라우저가 요청의 메서드, 헤더, Content-Type을 검사합니다. Simple Request 조건을 충족하지 못하면 Preflight 절차를 시작합니다.
OPTIONS 요청 전송
브라우저가 자동으로 OPTIONS 요청을 전송합니다. 이 요청에는 실제로 보내려는 메서드와 헤더 정보가 포함됩니다.
서버가 허용 정보를 응답
서버는 허용하는 메서드, 헤더, 출처를 응답 헤더에 담아 반환합니다. 응답 본문은 브라우저가 무시하므로 비어 있어도 무방합니다.
브라우저가 실제 요청을 전송하거나 차단
Preflight 응답이 실제 요청의 메서드와 헤더를 허용하고 있다면 실제 요청을 전송합니다. 허용하지 않으면 실제 요청은 전송되지 않고 CORS 에러가 발생합니다.
전체 과정을 HTTP 헤더 수준에서 보겠습니다. POST 요청에 Content-Type: application/json과 Authorization 헤더를 포함하는 경우입니다.
# 1단계: Preflight 요청 (브라우저가 자동으로 전송)
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationAccess-Control-Request-Method는 실제 요청에서 사용할 메서드를, Access-Control-Request-Headers는 포함할 커스텀 헤더를 서버에 미리 알려주는 역할입니다. 이를 받은 서버는 허용 여부를 응답합니다.
# 2단계: Preflight 응답 (서버가 허용 정보를 반환)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400Preflight가 성공하면 비로소 실제 요청이 전송됩니다.
# 3단계: 실제 요청
POST /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
{"name": "Kim", "email": "kim@example.com"}
# 4단계: 실제 응답
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json
{"id": 1, "name": "Kim"}Preflight가 실패하면(서버가 적절한 CORS 헤더를 반환하지 않으면) 실제 요청은 아예 전송되지 않습니다. 이는 SOP의 "요청은 전송되지만 응답이 차단된다"는 동작과 다른 점입니다. Preflight의 존재 이유가 바로 여기에 있는데, 서버의 데이터를 변경할 수 있는 요청(PUT, DELETE 등)이 허가 없이 실행되는 것을 사전에 차단하기 위함입니다.
CORS 응답 헤더 상세
서버가 CORS를 설정할 때 사용하는 응답 헤더는 여섯 가지입니다. 각 헤더의 역할과 주의사항을 살펴봅니다.
가장 기본이 되는 헤더로, 접근을 허용할 출처를 지정합니다. 값은 세 가지 형태 중 하나입니다.
- 특정 출처:
Access-Control-Allow-Origin: https://myapp.com - 와일드카드:
Access-Control-Allow-Origin: *(모든 출처 허용, 단 인증 정보와 함께 사용 불가) - 동적 반영: 요청의
Origin헤더 값을 읽어서 허용 목록에 있으면 그대로 반환
동적 반영 패턴을 사용할 때는 반드시 Vary: Origin 헤더를 함께 설정해야 합니다. 이 헤더가 없으면 CDN이나 브라우저 캐시가 특정 출처용 응답을 다른 출처의 요청에 반환하는 문제가 발생할 수 있습니다.
# 동적 반영 시 반드시 Vary: Origin 포함
Access-Control-Allow-Origin: https://myapp.com
Vary: Origin인증 정보와 CORS
교차 출처 요청에서 인증을 처리하는 방식은 크게 두 가지로 나뉘며, CORS 관점에서 이 둘은 전혀 다른 메커니즘으로 동작합니다. 혼동하기 쉬운 부분이므로 명확히 구분할 필요가 있습니다.
토큰 기반 인증 (Authorization 헤더)
JWT 같은 토큰을 Authorization 헤더에 직접 설정하는 방식은 CORS에서 말하는 "인증 정보(credentials)"에 해당하지 않습니다. JavaScript가 직접 추가하는 요청 헤더일 뿐이므로, credentials: 'include'나 Access-Control-Allow-Credentials: true 없이도 동작합니다. 다만 Authorization은 CORS-safelisted 헤더가 아니므로 Preflight가 트리거되며, 서버의 Preflight 응답에서 이 헤더를 허용해야 합니다.
// credentials 옵션 없이 Authorization 헤더만 설정
fetch('https://api.example.com/user', {
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9...',
'Content-Type': 'application/json'
}
});이 요청에 대해 서버가 Preflight 응답에서 반환해야 하는 CORS 헤더는 다음과 같습니다.
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Headers: Authorization, Content-TypePreflight 처리만 올바르게 되어 있으면 토큰 기반 인증은 일반적인 CORS 설정만으로 충분하며, 뒤에서 설명할 credentialed 모드는 필요하지 않습니다.
쿠키 기반 인증 (Credentialed Requests)
쿠키, TLS 클라이언트 인증서, 브라우저 관리 HTTP 인증처럼 브라우저가 자동으로 첨부하는 인증 정보를 교차 출처 요청에 포함하려면, 클라이언트와 서버 양쪽에서 명시적으로 허용해야 합니다. 이것이 Fetch 표준에서 정의하는 "credentialed request"이며, 세 가지 조건이 동시에 충족되어야 동작합니다.
Credentialed Request의 3중 조건
- 클라이언트:
fetch(url, { credentials: 'include' })또는xhr.withCredentials = true - 서버: 응답에
Access-Control-Allow-Credentials: true포함 - 서버:
Access-Control-Allow-Origin에 와일드카드(*)가 아닌 명시적 출처 지정
이 세 조건 중 하나라도 누락되면 브라우저는 응답을 차단합니다. Credentialed 모드에서는 와일드카드(*) 제한이 Access-Control-Allow-Origin뿐 아니라 Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Expose-Headers에도 동일하게 적용되므로, 네 개의 헤더 모두 값을 명시적으로 나열해야 합니다.
클라이언트 측에서 쿠키 전송을 요청하는 코드는 다음과 같습니다.
// Fetch API
fetch('https://api.example.com/user', {
credentials: 'include'
});
// Axios
axios.get('https://api.example.com/user', {
withCredentials: true
});서버는 다음과 같이 응답해야 합니다.
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Vary: Origincredentials 옵션에 따른 쿠키 전송 동작은 다음과 같습니다.
credentials 값 | 같은 출처 쿠키 | 교차 출처 쿠키 |
|---|---|---|
omit | 전송 안 함 | 전송 안 함 |
same-origin (기본값) | 전송 | 전송 안 함 |
include | 전송 | 전송 (서버 허용 시) |
한편 쿠키의 SameSite 속성도 교차 출처 전송에 영향을 미칩니다. 교차 출처로 쿠키를 전송하려면 SameSite=None; Secure가 설정되어 있어야 하며, HTTPS 환경에서만 동작합니다. 모던 브라우저는 SameSite를 지정하지 않은 쿠키를 Lax로 처리하므로, 별도 설정 없이는 교차 출처 쿠키 전송이 차단됩니다.
흔한 CORS 에러와 원인
CORS가 차단되면 JavaScript의 fetch()는 네트워크 에러와 동일한 TypeError를 반환하며, 응답 내용이나 CORS 헤더를 프로그래밍적으로 확인할 수 없습니다. 이는 보안상의 이유로 의도된 설계입니다. 다만 브라우저의 콘솔 메시지는 상세한 원인을 포함하고 있어 디버깅에 활용할 수 있습니다. 자주 마주치는 에러 메시지별 원인과 해결 방향을 정리합니다.
원인: 서버 응답에 Access-Control-Allow-Origin 헤더가 포함되지 않았습니다. 가장 흔한 CORS 에러이며, 서버에 CORS 설정이 아예 되어 있지 않거나, OPTIONS 라우트가 처리되지 않는 경우에 발생합니다.
확인 포인트:
- 서버의 CORS 미들웨어가 활성화되어 있는지 확인
- OPTIONS 메서드에 대한 라우트 핸들러가 존재하는지 확인
- API 게이트웨이나 리버스 프록시가 CORS 헤더를 제거하고 있지 않은지 확인
CORS 에러를 디버깅할 때 가장 먼저 해야 할 일은 DevTools Network 탭에서 OPTIONS 요청을 찾는 것입니다. Preflight가 존재한다면 그 요청의 상태 코드와 응답 헤더를 확인하고, Preflight 없이 실제 요청만 있다면 그 응답의 CORS 헤더를 확인합니다. 서버가 200 OK를 반환했는데도 CORS 에러가 발생했다면, 서버는 정상 동작했지만 응답 헤더에 CORS 설정이 누락된 것입니다.
Preflight 캐싱과 성능 최적화
Preflight 요청은 실제 요청 전에 별도의 HTTP 왕복(round trip)을 추가합니다. 네트워크 지연이 100ms인 환경에서 매 API 호출마다 Preflight가 발생하면, 체감 응답 시간이 두 배로 늘어나는 것과 다름없습니다. 이 비용을 줄이는 방법은 크게 두 가지입니다.
Preflight 캐싱
Access-Control-Max-Age 헤더를 설정하면, 브라우저가 Preflight 결과를 캐싱하여 동일한 조건의 요청에 대해 OPTIONS 호출을 생략합니다. Fetch 표준에 따르면 캐시 키는 (출처, URL, credentials 모드, 메서드, 요청 헤더 이름) 조합입니다. 즉, 같은 URL이라도 요청에 포함하는 헤더가 달라지면 별도의 Preflight가 발생합니다.
# Preflight 응답에서 캐시 시간 설정 (24시간)
Access-Control-Max-Age: 86400다만 브라우저마다 이 값의 상한이 정해져 있어, 서버에서 아무리 큰 값을 설정해도 브라우저가 인정하는 최대치를 넘을 수 없습니다.
| 브라우저 | Access-Control-Max-Age 상한 |
|---|---|
| Chrome (Chromium v76+) | 7200초 (2시간) |
| Firefox | 86400초 (24시간) |
| Safari | 300초 (5분) |
Max-Age를 설정하지 않으면?
Access-Control-Max-Age를 생략하면 브라우저의 기본 캐시 시간이 적용되며, 이는 보통 5초 정도입니다. 즉 5초마다 같은 Preflight가 반복될 수 있으므로, CORS를 설정할 때 이 헤더를 함께 지정하는 것이 바람직합니다.
같은 출처 프록시 패턴
CORS 자체를 우회하는 가장 효과적인 방법은, 교차 출처 요청을 같은 출처 요청으로 만드는 것입니다. 프론트엔드 서버에 프록시를 두어, 브라우저는 같은 출처의 프록시에 요청하고, 프록시가 외부 API를 대신 호출하는 구조입니다.
브라우저 → /api/data (같은 출처, CORS 없음)
→ 프록시 서버가 https://api.example.com/data 호출 (서버 간 통신, SOP 없음)
Next.js의 API Routes나 Route Handlers, nginx의 proxy_pass 등이 이 패턴을 지원합니다. Vite의 server.proxy도 같은 원리지만 개발 서버 전용이므로, 배포 시에는 nginx 등 별도 프록시 설정이 필요합니다. 이 방식은 Preflight 왕복을 완전히 제거할 뿐 아니라, API 키 같은 민감 정보를 서버에 보관할 수 있다는 보안상의 이점도 있습니다.
정리
핵심 정리
-
Same-Origin Policy는 다른 출처의 응답을 JavaScript가 읽지 못하도록 차단하는 브라우저의 보안 정책이며, CORS는 서버가 이를 선택적으로 완화하는 메커니즘입니다. 요청의 전송이 아닌 응답의 읽기가 차단된다는 점이 핵심입니다.
-
Simple Request(GET/HEAD/POST + 제한된 헤더와 Content-Type)는 Preflight 없이 바로 전송되지만, 그 외의 요청에는 브라우저가 자동으로 OPTIONS 메서드의 Preflight 요청을 먼저 전송하여 서버의 허용 여부를 확인합니다. 실무에서 대부분의 API 호출은
application/json이나Authorization헤더 때문에 Preflight를 트리거합니다. -
교차 출처 인증은 방식에 따라 CORS 설정이 다릅니다. 토큰 기반 인증(
Authorization헤더)은 Preflight에서 해당 헤더를 허용하는 것만으로 충분하지만, 쿠키 기반 인증은 클라이언트의credentials: 'include', 서버의Access-Control-Allow-Credentials: true, 그리고 명시적 출처 지정(와일드카드 불가)이라는 세 가지 조건을 모두 충족해야 합니다. -
Access-Control-Max-Age로 Preflight 결과를 캐싱하면 반복적인 OPTIONS 왕복을 줄일 수 있으며, 같은 출처의 프록시를 활용하면 CORS와 Preflight를 근본적으로 우회할 수 있습니다.