새로운 프론트엔드를 배포하고 콘솔을 열면, 빨간색 CORS 오류가 나타납니다: 요청이 "CORS 정책에 의해 차단되었습니다"라고 알려줍니다. Apidog이나 curl에서는 API가 잘 작동하는데도 불구하고, 브라우저는 JavaScript에 응답을 넘겨주기를 거부합니다. 답답하신가요? 네. 신비롭게 느껴지시나요? 오류의 원인을 알게 되면 더 이상 그렇지 않을 겁니다.
대부분의 튜토리얼이 간과하는 핵심 사실이 있습니다: CORS 오류는 브라우저에 의해 강제되지만, 서버 때문에 발생합니다. 브라우저는 서버가 올바른 Access-Control-Allow-Origin 헤더를 보내지 않았기 때문에 응답을 차단합니다. 따라서 해결책은 거의 항상 프론트엔드 코드 대신 서버 설정에서 찾아야 합니다.
이 가이드에서는 CORS가 무엇을 하는지, 사전 요청(preflight request)이 어떻게 작동하는지, 각 오류에 대한 정확한 해결책과 함께 가장 흔한 6가지 CORS 오류 메시지, 그리고 Express, Spring Boot, Nginx를 위한 작동 설정을 안내합니다. 또한 브라우저 외부에서 디버깅하는 방법도 알아볼 것입니다. 이는 "서버 설정 오류"와 "브라우저 차단"을 가장 빠르게 구별하는 방법입니다.
CORS 오류란 무엇이며 무엇이 아닌가
CORS는 Cross-Origin Resource Sharing(교차 출처 리소스 공유)의 약자입니다. 기본적으로 브라우저는 동일 출처 정책(same-origin policy)을 적용합니다. 즉, https://app.example.com에서 실행되는 JavaScript는 스키마, 호스트 또는 포트가 다르기 때문에 https://api.example.com의 응답을 읽을 수 없습니다. CORS는 서버가 이 규칙을 의도적으로 완화하기 위해 사용하는 메커니즘입니다. 자세한 내용은 MDN CORS 문서에 있으며, 기본 알고리즘은 Fetch 사양에 정의되어 있습니다.
다음 세 가지 사항이 대부분의 혼란을 해소합니다:
- 브라우저가 이를 강제합니다. 오직 브라우저만이 CORS 검사를 적용합니다. 서버 간 호출, curl, 데스크톱 API 클라이언트는 이를 완전히 무시합니다.
- 서버가 이를 구성합니다. 브라우저는 서버가 보내는 응답 헤더를 기반으로 결정합니다. 헤더가 없으면 접근도 없습니다.
- 요청은 대개 여전히 서버에 도달합니다. 간단한 요청의 경우, 서버는 모든 것을 처리하고 응답합니다. 그런 다음 브라우저는 해당 응답을 JavaScript에 넘겨주지 않습니다. CORS는 API를 둘러싼 보안 방벽이 아닙니다. 이는 악성 페이지가 사용자의 쿠키를 사용하여 교차 출처 데이터를 읽는 것을 방지합니다.
따라서 CORS 오류가 발생하면 프론트엔드 해결책을 찾지 마십시오. 오류 메시지를 읽고 서버에서 누락되거나 잘못된 헤더를 수정하십시오.
사전 요청(preflight request)의 구조
특정 교차 출처 요청 전에 브라우저는 정찰병 역할을 하는 OPTIONS 요청을 보냅니다. 이를 사전 요청(preflight)이라고 합니다. 이 요청은 GET, HEAD, POST 외의 메서드를 사용하거나, Authorization과 같은 사용자 정의 헤더를 보내거나, application/json과 같은 Content-Type을 사용할 때 발생합니다.
사전 요청은 다음과 같습니다:
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
브라우저는 묻습니다: "app.example.com의 페이지가 이 헤더들을 사용하여 여기에 POST 요청을 보내려고 합니다. 허용됩니까?" 올바른 서버 응답은 다음과 같습니다:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin
어떤 부분이든 누락되면 브라우저는 실제 요청이 실행되기도 전에 취소합니다. API 엔드포인트는 전혀 실행되지 않고, 로그에는 OPTIONS 요청 외에는 아무것도 표시되지 않으며, 콘솔에는 CORS 오류가 나타납니다. Access-Control-Max-Age는 브라우저에게 이 결정(여기서는 86400초)을 캐시하도록 지시하여, 반복되는 요청이 사전 요청을 건너뛰게 합니다.
이 두 단계의 과정을 명심하십시오. 모든 CORS 디버깅의 절반은 한 가지 질문으로 귀결됩니다: 사전 요청이 실패했는가, 아니면 실제 요청이 실패했는가?
가장 흔한 6가지 CORS 오류와 각 해결 방법
브라우저는 놀랍도록 정확한 CORS 오류 메시지를 작성합니다. 아래 목록에서 자신의 오류를 찾아보십시오.
1. 'Access-Control-Allow-Origin' 헤더가 없습니다.
전형적인 경우입니다. 서버가 CORS 헤더를 전혀 포함하지 않은 응답을 보냈습니다. 브라우저는 평가할 것이 없었으므로 접근을 차단했습니다.
해결 방법: 서버가 특정 요청 출처 또는 공개, 자격 증명 없는 API의 경우 *와 함께 Access-Control-Allow-Origin을 보내도록 구성하십시오:
Access-Control-Allow-Origin: https://app.example.com
한 가지 함정: 성공 응답에는 CORS 헤더가 포함되더라도 오류 응답에는 종종 CORS 헤더가 누락될 수 있습니다. API가 500 오류를 반환하고 미들웨어가 200 응답에만 헤더를 추가한다면, 콘솔에는 실제 서버 오류 대신 CORS 오류가 표시됩니다. 403 Forbidden 및 500 페이지를 포함하여 모든 응답에 CORS 헤더가 첨부되도록 하십시오.
2. 와일드카드 '*'는 자격 증명과 함께 사용할 수 없습니다.
메시지 내용은 다음과 같습니다: "요청의 자격 증명 모드가 'include'일 때 'Access-Control-Allow-Origin' 헤더의 값은 와일드카드 '*'가 될 수 없습니다."
프론트엔드가 credentials: 'include'와 함께 쿠키 또는 인증 헤더를 보내지만, 서버는 Access-Control-Allow-Origin: *로 응답합니다. Fetch 사양은 이 조합을 금지합니다. 와일드카드와 자격 증명이 함께 사용되면 인터넷상의 어떤 사이트든 인증된 응답을 읽을 수 있게 됩니다.
해결 방법: 와일드카드 대신 정확한 출처를 반환하고 자격 증명 헤더를 추가하십시오:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
들어오는 Origin을 허용 목록과 비교하여 유효성을 검사한 후 다시 반환하십시오. 자격 증명이 활성화된 상태에서 임의의 출처를 반영하는 것은 전체 보호 기능을 무력화시킵니다.
3. 사전 요청에 대한 응답이 접근 제어 검사를 통과하지 못합니다.
서버가 OPTIONS 요청을 처리한 적이 없습니다. 경로가 POST만 정의되어 OPTIONS 요청이 404 또는 405를 반환할 수 있습니다. 또는 인증 미들웨어가 사전 요청에 토큰이 없기 때문에 401로 거부했을 수도 있습니다(브라우저는 사전 요청에 자격 증명을 첨부하지 않습니다).
해결 방법: 인증이 실행되기 전에 OPTIONS 요청을 명시적으로 처리하고 전체 CORS 헤더 세트와 함께 2xx 응답을 반환하십시오. 대부분의 프레임워크에서는 CORS 미들웨어를 먼저 마운트하면 해결됩니다. 직접 작성하는 경우:
app.options('/v1/orders', (req, res) => {
res.set({
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type'
});
res.sendStatus(204);
});
4. 헤더 값이 제공된 출처와 일치하지 않습니다.
서버가 Access-Control-Allow-Origin 헤더를 보내지만, 잘못된 출처를 지정합니다. 일반적인 원인으로는 http://localhost:5173에서 테스트 중인데 프로덕션 출처가 하드코딩되어 있는 경우, http와 https 간의 허용 목록 비교 실패, 또는 잘못된 후행 슬래시(https://app.example.com/는 유효한 출처 값이 아님) 등이 있습니다.
해결 방법: 요청의 Origin 헤더를 허용 목록과 정확히 비교하고, 일치하는 항목을 반환하며, 캐시 및 CDN이 한 출처의 헤더를 다른 출처에 제공하지 않도록 Vary: Origin을 보내십시오:
const allowed = ['https://app.example.com', 'http://localhost:5173'];
if (allowed.includes(req.headers.origin)) {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Vary', 'Origin');
}
5. 요청 헤더 필드 또는 메서드가 허용되지 않습니다.
두 가지 관련 메시지: "사전 요청 응답의 Access-Control-Allow-Headers에 의해 요청 헤더 필드 authorization이 허용되지 않습니다." 및 "Access-Control-Allow-Methods에 의해 메서드 PUT이 허용되지 않습니다."
사전 요청은 성공했지만, 응답이 요청에 필요한 것을 포함하지 않았습니다. Authorization 헤더 또는 X-Request-Id를 추가했지만, 서버의 허용 목록에는 언급되지 않았습니다.
해결 방법: 프론트엔드가 보내는 모든 헤더와 메서드를 포함하도록 사전 요청 응답을 확장하십시오:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
여기서 헤더 이름은 대소문자를 구분하지 않습니다. 메서드는 대소문자를 구분하며 대문자로 표기합니다.
6. 사전 요청에는 리디렉션이 허용되지 않습니다.
사전 요청이 301 또는 302를 반환하는 URL에 도달했으며, 브라우저는 사전 요청 중에 리디렉션을 따르는 것을 거부합니다. 일반적인 원인으로는 http URL이 https로 리디렉션되거나, 프레임워크가 "친절하게" 리디렉션하는 누락된 후행 슬래시, 또는 게이트웨이가 /v1/orders를 /v1/orders/로 바운스하는 경우 등이 있습니다.
해결 방법: 프론트엔드를 최종 URL로 직접 지정하십시오. 처음부터 https를 사용하고, 라우터의 후행 슬래시 규칙을 따르며, 엔드포인트가 3xx 대신 2xx로 응답하는지 수동 OPTIONS 호출로 확인하십시오.
서버 설정 예시
다음은 세 가지 일반적인 스택에서 올바른 CORS 설정입니다.
Express
헤더를 직접 작성하는 대신 공식 cors 미들웨어를 사용하십시오:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400
}));
인증 미들웨어보다 먼저 마운트하여 토큰 누락으로 인해 사전 요청이 거부되지 않도록 하십시오. Python 개발자는 Flask 앱에 동일한 헤더 로직을 래핑하는 Flask-CORS 확장에서 동일한 패턴을 얻을 수 있습니다.
Spring Boot
WebMvcConfigurer를 통한 전역 구성:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/v1/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(86400);
}
}
Spring Security를 사용 중이신가요? 보안 필터 체인에서도 .cors(Customizer.withDefaults())를 호출해야 합니다. 그렇지 않으면 보안 계층이 MVC 구성이 보기 전에 사전 요청을 차단할 것입니다. 전체 옵션 세트는 Spring CORS 문서를 참조하십시오.
Nginx
Nginx가 앱 앞에서 요청을 종료할 때, 엣지에서 사전 요청에 응답하십시오:
location /v1/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary "Origin" always;
proxy_pass http://backend;
}
always 플래그가 중요합니다. 이 플래그가 없으면 Nginx는 4xx 및 5xx 응답에서 add_header 지시문을 삭제하여, 실패한 모든 요청에 대해 첫 번째 오류를 다시 발생시킵니다. 그리고 CORS를 담당할 단일 계층을 선택하십시오. Nginx와 앱이 모두 헤더를 추가하면 브라우저는 Access-Control-Allow-Origin: *, *와 같은 중복을 보고 응답을 거부합니다.
Apidog으로 브라우저 외부에서 CORS 디버깅하기
콘솔 오류는 브라우저가 무언가를 차단했음을 알려줍니다. 서버가 무엇을 보냈는지는 알려주지 않습니다. 진실을 확인하는 가장 빠른 방법은 브라우저를 루프에서 제외하는 것입니다.
Apidog은 데스크톱 API 클라이언트이므로, 그 요청은 브라우저의 CORS 검사를 전혀 받지 않습니다. 이를 통해 깨끗한 실험을 할 수 있습니다: 프론트엔드가 보내던 것과 동일한 요청을 Apidog에서 보내보십시오. 거기서 성공한다면 API 로직은 문제가 없고, 문제는 순전히 CORS 헤더 누락입니다. 거기서도 실패한다면, CORS 복장을 한 평범한 API 버그이며, 일반적인 API 테스트 기술이 적용됩니다.
Apidog에서의 CORS 디버깅 세션은 다음과 같습니다:
- 실제 요청을 재현하십시오. 브라우저의 네트워크 탭에서 실패한 요청을 복사하여 Apidog에서 동일한 메서드, 헤더 및 본문으로 다시 만드십시오. 상태와 본문을 확인하십시오. 여기서 500 오류가 발생하면 CORS는 처음부터 문제가 아니었던 것입니다.
- 사전 요청을 수동으로 테스트하십시오. 새 요청을 만들고 메서드를
OPTIONS로 설정한 다음, 브라우저가 보낼 헤더(예:Origin: https://app.example.com,Access-Control-Request-Method: POST,Access-Control-Request-Headers: authorization, content-type)를 추가하십시오. 그리고 요청을 보내십시오. - 응답 헤더를 검사하십시오. 응답 창에서
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers를 찾으십시오. 각 값을 프론트엔드가 필요로 하는 것과 비교하십시오. 누락된 헤더, 잘못된 출처 또는 3xx 상태는 즉시 눈에 띄며, 콘솔을 추측할 필요가 없습니다. - 수정 사항을 확인하십시오. 서버 설정을 변경한 후, 저장된 동일한
OPTIONS요청을 다시 보내고 헤더가 업데이트되는지 확인하십시오. 프론트엔드를 재배포하거나 캐시를 지우는 번거로운 과정이 필요 없습니다.
이 워크플로는 "API 클라이언트에서는 작동하지만 브라우저에서는 실패한다"는 영원한 논쟁을 몇 초 만에 해결해 줍니다. 이는 Postman CORS 테스트 질문 뒤에 숨겨진 동일한 수수께끼입니다. 클라이언트는 CORS를 건너뛰기 때문에 작동합니다. 브라우저는 서버가 마법의 단어를 말하지 않았기 때문에 실패합니다. Apidog을 무료로 다운로드하여 일반 엔드포인트 테스트 옆에 OPTIONS 요청을 저장해 두십시오. 미래의 CORS 문제는 한 번의 클릭으로 해결될 것입니다.
30초 CORS 체크리스트
버그를 보고하기 전에 이 목록을 확인하십시오:
- 실패한 응답에
Access-Control-Allow-Origin이 전혀 포함되어 있습니까? - 그 값은 페이지의 출처(스키마, 호스트, 포트, 후행 슬래시 없음)와 정확히 일치합니까?
- 쿠키 또는 인증을 사용 중이신가요? 특정 출처와
Access-Control-Allow-Credentials: true를 확인하고, 절대*는 사용하지 마십시오. OPTIONS가 요청을 커버하는 메서드와 헤더와 함께 2xx를 반환합니까?- 사전 요청 URL에 리디렉션이 있습니까?
- 오류 응답(401, 403, 500)이 성공 응답과 동일한 CORS 헤더를 가지고 있습니까?
열에 아홉은 이 여섯 가지 항목 중 하나가 답입니다. Apidog에서 수동 OPTIONS 요청으로 이를 확인하고, 서버 설정을 수정한 다음, 다시 개발 작업을 계속하십시오.
자주 묻는 질문
브라우저에서만 CORS 오류가 발생하는 이유는 무엇인가요?
오직 브라우저만이 CORS를 강제하기 때문입니다. 동일 출처 정책은 악성 페이지가 사용자의 인증된 데이터를 읽는 것을 방지하므로, 브라우저는 모든 교차 출처 응답에서 Access-Control-Allow-Origin을 확인합니다. curl, 백엔드 서비스 및 데스크톱 클라이언트에는 이러한 규칙이 없습니다. 브라우저를 제외한 모든 곳에서 요청이 성공한다면, 서버가 CORS 헤더를 누락했거나 잘못 구성한 것이며, API 자체는 정상입니다.
CORS는 Postman 또는 Apidog에 적용되나요?
아니요. Postman과 Apidog은 브라우저 샌드박스 내에서 실행되는 웹 페이지가 아닌 데스크톱 애플리케이션이므로, 그들의 요청은 CORS를 완전히 우회합니다. 이것이 바로 CORS 디버깅에 유용한 이유입니다. 즉, 브라우저의 필터링 없이 서버의 원시 응답 헤더를 보여줍니다. Postman CORS 테스트 혼란은 대개 여기서 시작됩니다. 데스크톱 클라이언트에서 성공적인 요청은 브라우저 동작에 대해 아무것도 증명하지 않지만, 실패하는 계층을 격리하는 데는 도움이 됩니다.
CORS 오류는 보안 기능인가요, 아니면 버그인가요?
기능입니다. CORS 오류는 브라우저가 제 역할을 하고 있다는 것을 의미합니다. 즉, 서버가 허용하지 않는 한 교차 출처 응답 데이터를 스크립트에 노출하는 것을 거부합니다. 플래그나 확장 프로그램을 사용하여 브라우저에서 CORS를 비활성화하는 것은 본인 컴퓨터에서 증상을 숨길 뿐, 모든 사용자는 여전히 동일한 문제에 직면할 것입니다. 대신 서버 헤더를 수정하십시오.
Access-Control-Allow-Origin: *를 어디에나 사용할 수 있나요?
쿠키나 인증이 없는 공개, 읽기 전용 API에만 가능합니다. 자격 증명이 포함될 때는 항상 와일드카드가 거부되며, 이는 웹상의 모든 출처에 데이터가 공개됨을 알리는 것입니다. 인증된 API의 경우, 출처 허용 목록을 유지하고 일치하는 출처를 반환하며, 공유 캐시가 응답을 분리하도록 Vary: Origin을 보내십시오.
