CORS 오류를 빠르게 진단하고 해결하는 방법
새로운 프론트엔드를 배포하고 콘솔을 열면 빨간색 CORS 오류가 나타납니다. 요청이 “CORS 정책에 의해 차단되었습니다”라고 알려주죠. [Apidog]이나 curl에서는 API가 정상적으로 작동하는데도 브라우저는 JavaScript에 응답을 넘겨주지 않습니다. 하지만 원리를 알면 이 문제는 더 이상 신비롭지 않습니다.
대부분의 튜토리얼이 놓치는 핵심은 다음과 같습니다. CORS 오류는 브라우저가 강제하지만, 원인은 서버에 있습니다. 서버가 올바른 Access-Control-Allow-Origin 헤더를 보내지 않으면 브라우저가 응답을 차단합니다. 따라서 해결책은 대개 프론트엔드 코드가 아니라 서버 설정에서 찾아야 합니다.
이 글에서는 다음 내용을 다룹니다.
- CORS의 작동 방식
- 사전 요청(preflight request)의 구조
- 가장 흔한 CORS 오류 6가지와 해결 방법
- Express, Spring Boot, Nginx 설정
- 브라우저 외부에서 CORS를 디버깅하는 방법
CORS 오류란 무엇인가
CORS는 Cross-Origin Resource Sharing(교차 출처 리소스 공유)의 약자입니다. 브라우저는 기본적으로 동일 출처 정책(same-origin policy)을 적용합니다. 예를 들어 https://app.example.com에서 실행되는 JavaScript는 스키마, 호스트 또는 포트가 다르기 때문에 https://api.example.com의 응답을 읽을 수 없습니다.
CORS는 서버가 이 제한을 의도적으로 완화하는 메커니즘입니다. 자세한 내용은 MDN CORS 문서와 Fetch 사양에서 확인할 수 있습니다.
핵심은 세 가지입니다.
- 브라우저가 강제합니다. 서버 간 호출, curl, 데스크톱 API 클라이언트는 CORS 검사를 적용하지 않습니다.
- 서버가 구성합니다. 브라우저는 서버가 보내는 응답 헤더를 기준으로 접근을 허용하거나 차단합니다.
- 요청은 서버에 도달할 수 있습니다. 간단한 요청의 경우 서버가 요청을 처리하고 응답까지 보낼 수 있지만, 브라우저가 그 응답을 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를 디버깅할 때는 먼저 다음을 구분하십시오.
- 사전 요청이 실패했는가?
- 실제 요청이 실패했는가?
가장 흔한 CORS 오류 6가지
1. Access-Control-Allow-Origin 헤더가 없습니다
서버가 CORS 헤더 없이 응답한 경우입니다. 브라우저는 허용 여부를 판단할 정보가 없으므로 응답 접근을 차단합니다.
해결 방법: 서버가 특정 출처 또는 공개 API에 대해 Access-Control-Allow-Origin을 보내도록 설정합니다.
Access-Control-Allow-Origin: https://app.example.com
공개 API이고 자격 증명을 사용하지 않는 경우에만 *를 사용할 수 있습니다.
또 다른 함정은 성공 응답에는 헤더가 있지만 오류 응답에는 헤더가 없는 경우입니다. 예를 들어 API가 500 오류를 반환했는데 미들웨어가 200 응답에만 헤더를 추가하면, 콘솔에는 실제 서버 오류 대신 CORS 오류가 표시됩니다.
403 Forbidden과 500 응답을 포함한 모든 응답에 CORS 헤더가 추가되는지 확인하십시오.
2. 와일드카드 *는 자격 증명과 함께 사용할 수 없습니다
다음과 같은 오류 메시지가 표시될 수 있습니다.
요청의 자격 증명 모드가
include일 때Access-Control-Allow-Origin헤더의 값은 와일드카드*가 될 수 없습니다.
프론트엔드가 credentials: 'include'와 함께 쿠키 또는 인증 정보를 보내지만, 서버가 Access-Control-Allow-Origin: *로 응답하는 상황입니다. 이 조합은 허용되지 않습니다.
해결 방법: 정확한 출처와 자격 증명 헤더를 함께 반환하십시오.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
들어온 Origin을 허용 목록과 비교한 뒤 일치하는 값만 반환해야 합니다. 자격 증명이 활성화된 상태에서 임의의 출처를 그대로 반영하면 CORS 보호가 무력화됩니다.
3. 사전 요청 응답이 접근 제어 검사를 통과하지 못합니다
서버가 OPTIONS 요청을 처리하지 않는 경우입니다. POST 라우트만 정의되어 OPTIONS 요청이 404 또는 405를 반환할 수 있습니다. 인증 미들웨어가 토큰이 없는 사전 요청을 401로 거부하는 경우도 있습니다. 브라우저는 일반적으로 사전 요청에 자격 증명을 첨부하지 않습니다.
해결 방법: 인증 미들웨어보다 먼저 OPTIONS 요청을 처리하고, 전체 CORS 헤더와 함께 2xx 응답을 반환하십시오.
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);
});
대부분의 프레임워크에서는 CORS 미들웨어를 인증 미들웨어보다 먼저 등록하면 해결됩니다.
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 응답을 반환하면 브라우저가 리디렉션을 따르지 않을 수 있습니다.
일반적인 원인은 다음과 같습니다.
-
httpURL이https로 리디렉션됨 - 누락된 후행 슬래시를 프레임워크가 자동으로 추가함
- 게이트웨이가
/v1/orders를/v1/orders/로 변경함
해결 방법:
- 프론트엔드에서 최종 URL을 직접 사용합니다.
- 처음부터
https를 사용합니다. - 라우터의 후행 슬래시 규칙을 따릅니다.
- 수동
OPTIONS호출로 엔드포인트가 3xx가 아닌 2xx를 반환하는지 확인합니다.
서버 설정 예시
다음은 Express, Spring Boot, Nginx에서 사용할 수 있는 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 문제가 발생합니다.
또한 CORS를 담당할 계층을 하나로 정하십시오. Nginx와 애플리케이션이 모두 헤더를 추가하면 Access-Control-Allow-Origin: *, *처럼 중복된 헤더가 생성되어 브라우저가 응답을 거부할 수 있습니다.
Apidog으로 브라우저 외부에서 CORS 디버깅하기
콘솔 오류는 브라우저가 무언가를 차단했다는 사실만 알려줍니다. 서버가 실제로 어떤 응답을 보냈는지는 알기 어렵습니다. 가장 빠른 방법은 브라우저를 디버깅 과정에서 제외하는 것입니다.
Apidog은 데스크톱 API 클라이언트이므로 브라우저의 CORS 검사를 받지 않습니다. 프론트엔드와 동일한 요청을 Apidog에서 보내면 API 로직과 CORS 설정을 분리해 확인할 수 있습니다.
- Apidog에서는 성공하지만 브라우저에서는 실패한다면 CORS 헤더 문제일 가능성이 높습니다.
- Apidog에서도 실패한다면 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를 건너뛰지만 브라우저는 서버가 필요한 헤더를 보내지 않으면 응답을 차단합니다.
일반 엔드포인트 테스트와 함께 OPTIONS 요청을 저장해 두면, 이후 CORS 문제도 한 번의 클릭으로 점검할 수 있습니다. Apidog을 무료로 다운로드해 보십시오.
30초 CORS 체크리스트
버그를 보고하거나 프론트엔드를 수정하기 전에 다음을 확인하십시오.
- 실패한 응답에
Access-Control-Allow-Origin이 포함되어 있습니까? - 값이 페이지의 출처와 정확히 일치합니까?
- 스키마, 호스트, 포트가 모두 같은가요? 후행 슬래시는 제거했나요?
- 쿠키나 인증을 사용한다면 특정 출처와
Access-Control-Allow-Credentials: true를 사용하고 있습니까? -
OPTIONS가 필요한 메서드와 헤더를 허용하며 2xx를 반환합니까? - 사전 요청 URL에 리디렉션이 있습니까?
- 다시 개발을 진행하십시오.
자주 묻는 질문
브라우저에서만 CORS 오류가 발생하는 이유는 무엇인가요?
브라우저만 CORS를 강제하기 때문입니다. 동일 출처 정책은 악성 페이지가 사용자의 인증된 데이터를 읽지 못하도록 하며, 브라우저는 교차 출처 응답에서 Access-Control-Allow-Origin을 확인합니다.
curl, 백엔드 서비스, 데스크톱 클라이언트에는 이 규칙이 없습니다. 브라우저를 제외한 모든 환경에서 요청이 성공한다면 서버의 CORS 헤더가 누락되었거나 잘못 구성되었을 가능성이 높습니다.
CORS는 Postman 또는 Apidog에 적용되나요?
아니요. Postman과 Apidog은 브라우저 샌드박스에서 실행되는 웹 페이지가 아니라 데스크톱 애플리케이션이므로 CORS를 적용받지 않습니다. 따라서 브라우저 필터링 없이 서버의 원시 응답 헤더를 확인할 수 있습니다.
Postman CORS 테스트에서 혼란이 생기는 이유도 여기에 있습니다. 데스크톱 클라이언트에서 성공했다고 해서 브라우저에서도 성공한다는 뜻은 아니지만, 문제가 발생한 계층을 분리하는 데는 유용합니다.
CORS 오류는 보안 기능인가요, 아니면 버그인가요?
보안 기능입니다. CORS 오류는 브라우저가 서버가 허용하지 않은 교차 출처 응답을 스크립트에 노출하지 않고 있다는 의미입니다.
브라우저 플래그나 확장 프로그램으로 CORS를 비활성화하면 자신의 컴퓨터에서만 증상을 숨길 수 있습니다. 다른 사용자는 여전히 같은 문제를 겪으므로 서버 헤더를 수정해야 합니다.
Access-Control-Allow-Origin: *를 어디에나 사용할 수 있나요?
쿠키나 인증 정보를 사용하지 않는 공개 읽기 전용 API에서만 적절합니다. 자격 증명이 포함된 요청에서는 와일드카드가 거부됩니다.
인증된 API는 출처 허용 목록을 유지하고, 일치하는 출처만 반환해야 합니다. 또한 공유 캐시가 응답을 잘못 재사용하지 않도록 Vary: Origin을 추가하십시오.
Top comments (0)