목차
- 처음 시도: 와일드카드와 함께 모든 게 부서졌다
- 두 번째 시도: 헤더는 넣었는데 OPTIONS가 404
- 세 번째 시도: Express 환경에서 다시 만난 같은 문제
- 세 환경 비교: 어디서 막는 게 맞는가
- 출처 검증을 정규식으로 풀 때 빠지기 쉬운 함정
- 자주 만나는 증상과 1차 진단
- 프론트에서 보던 것과 백엔드에서 보는 것
- 그래서 언제 어떻게 쓸 것인가
Access to fetch at 'https://api.example.com/v1/users'
from origin 'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
즉, 이 메시지를 처음 본 게 프론트 개발자였을 때다. CORS 오류 해결이라고 검색하면 늘 같은 답이 나왔다. "백엔드한테 헤더 추가해달라고 하세요." 그땐 그 한 줄이 답이라고 믿었다. 백엔드로 전환한 지 2년이 지난 지금 보니, 그 한 줄 뒤에 숨은 의사결정이 훨씬 더 무거웠다.
이 글은 프론트→백엔드 전환자의 시점에서 CORS 오류 해결 과정을 정리한 기록이다. 왜 안 됐는지, 어디서 헤맸는지, 그리고 Nginx·FastAPI·Express 세 환경에서 각각 어떻게 풀어야 안전한지까지 다룬다.
처음 시도: 와일드카드와 함께 모든 게 부서졌다
백엔드로 옮긴 첫 달, 가장 처음 받은 티켓 중 하나가 "프론트에서 API 호출이 안 됩니다"였다. 콘솔에 찍힌 빨간 글씨를 보고 반사적으로 했던 작업이 이거였다.
# FastAPI 초기 버전 - 절대 따라하지 말 것
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
이렇게 넣고 배포했다. 로컬에선 잘 됐다. 스테이징에서도 됐다. 그런데 운영에서 로그인 한 사용자만 401이 떨어졌다. 다시 콘솔을 봤더니 이번엔 다른 에러가 떠 있었다.
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.
따라서, 그때 처음 알았다. allow_credentials=True와 allow_origins=["*"]는 함께 쓸 수 없다는 사실을. 브라우저 명세(Fetch Standard, 3.2.5절)에 명시되어 있는 동작이다. 쿠키나 인증 헤더를 실어 보낼 거면, 출처는 반드시 명시적으로 지정해야 한다.
프론트일 때는 credentials: 'include'를 그냥 옵션처럼 썼다. 백엔드에서 보니 이건 옵션이 아니라 계약이었다. 와일드카드와 함께 쓰는 순간 브라우저가 응답 자체를 막아버린다. 서버는 200을 줬지만, 자바스크립트는 그 응답을 절대 못 본다.
두 번째 시도: 헤더는 넣었는데 OPTIONS가 404
예를 들어, 와일드카드를 지우고 명시적으로 도메인을 박았다.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
물론, 이번엔 진짜 될 거라고 생각했다. 그런데 PUT 요청만 골라서 안 됐다. GET은 통과, POST는 통과, PUT만 404. 네트워크 탭을 열어봤더니 PUT 전에 OPTIONS 요청이 먼저 나가고 있었다. 그 OPTIONS가 404로 떨어지면서 본 요청은 아예 발사되지도 않았다.
여기서 한참 헤맸다. 원인은 Nginx 앞단에 있었다. 우리 인프라는 Nginx가 리버스 프록시로 앞에 있고, 뒤에 FastAPI 컨테이너가 붙는 구조였다. Nginx의 location 블록에 if ($request_method = OPTIONS) 분기가 있었는데, 거기서 즉시 응답을 만들어 돌려주고 있었다. 문제는 그 응답의 헤더가 불완전했다는 점이다.
프리플라이트가 뭘 요구하는지 정확히 알아야 한다
또한, CORS의 프리플라이트(Preflight)는 단순 요청이 아닌 모든 요청 앞에 자동으로 붙는 검문이다. 브라우저가 "이 요청을 정말 보내도 됩니까?"를 OPTIONS 메서드로 먼저 물어본다. 단순 요청의 조건은 좁다. 메서드는 GET/HEAD/POST 중 하나, Content-Type은 application/x-www-form-urlencoded / multipart/form-data / text/plain 중 하나, 커스텀 헤더 없음. 셋 다 만족해야 단순 요청이고, 하나라도 어긋나면 프리플라이트가 발사된다.
Authorization 헤더를 붙이거나 Content-Type: application/json을 쓰는 순간 거의 모든 API 요청이 프리플라이트 대상이 된다. 즉, 실무에서 만나는 거의 모든 요청이 OPTIONS 검문을 먼저 받는다는 뜻이다.
Nginx에서 프리플라이트를 어떻게 처리할까
결국, 선택지는 두 가지였다. (1) Nginx에서 OPTIONS를 가로채서 직접 응답한다. (2) OPTIONS도 그냥 백엔드로 흘려보내고 백엔드(FastAPI)가 응답하게 둔다.
처음엔 (1)을 택했다. 백엔드까지 요청이 가지 않으니 응답이 빠르고, 백엔드 로그도 깨끗해진다. 다만 문제는 Nginx 설정이 헤더 한 두 개 빠지면 바로 깨진다는 점이었다. 결국 운영에서는 (2)로 굳혔다. 백엔드 미들웨어 하나만 신경 쓰면 되니까. (개인적으로 환경마다 OPTIONS 응답이 달라지는 상황보다는 한 군데로 책임을 모으는 게 디버깅하기 편했다.)
# /etc/nginx/conf.d/api.conf - 운영 적용 버전
location /v1/ {
proxy_pass http://fastapi_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OPTIONS도 그대로 백엔드로. CORS 헤더는 FastAPI가 책임진다.
proxy_pass_request_headers on;
}
또한, 이렇게 하니 PUT 404가 해결됐다. 네트워크 탭에서 OPTIONS → 204, 본 요청 → 200 순서로 깔끔하게 떨어졌다.
세 번째 시도: Express 환경에서 다시 만난 같은 문제
그러나, 다른 프로젝트에선 Node.js로 짜인 게이트웨이 앞에서 같은 증상이 발생했다. 이번엔 cors 미들웨어 자체는 깔려 있었는데 설정이 어설펐다.
// 잘못된 Express 설정
const cors = require('cors');
app.use(cors()); // 와일드카드 디폴트
app.use('/api', apiRouter);
cors()를 옵션 없이 부르면 Access-Control-Allow-Origin: *이 박힌다. 인증 쿠키를 함께 보내야 하는 API에선 앞서 본 그 에러가 다시 떨어진다. 명시적으로 출처를 지정해야 한다.
// 운영 적용 버전 - express 4.18, cors 2.8.5 기준
const cors = require('cors');
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
app.use(cors({
origin: (origin, callback) => {
// 서버-서버 호출이나 curl처럼 Origin이 없는 경우
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error(`Origin ${origin} not allowed`));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
maxAge: 86400, // 프리플라이트 결과 24시간 캐시
}));
maxAge는 의외로 자주 빼먹는 값이다. 이걸 안 넣으면 브라우저는 매 요청마다 OPTIONS를 새로 던진다. 86400(24시간)으로 잡아두면 같은 출처/메서드 조합에 대해서는 24시간 동안 프리플라이트를 건너뛴다. 체감상 API 응답 횟수가 절반 가까이 줄어드는 효과가 있다. 단, 브라우저별 상한이 다르다. Chromium 계열은 2시간(7200초), Firefox는 24시간이 상한이다(출처: MDN Access-Control-Max-Age 문서, 2026년 5월 확인 기준). 86400을 넣어도 Chrome은 7200으로 강제 클램핑한다.
세 환경 비교: 어디서 막는 게 맞는가
반면, 세 환경을 모두 거치면서 만든 판단 기준이다.
| 환경 | CORS 처리 위치 | 장점 | 약점 |
|---|---|---|---|
| Nginx | 리버스 프록시 | 빠른 응답, 백엔드 부담 감소 | 헤더 누락 시 깨짐, 설정 분산 |
| FastAPI | 애플리케이션 | 한 곳에 집중, 환경별 동적 설정 가능 | OPTIONS도 워커 부담 |
| Express | 애플리케이션 | npm 생태계, 동적 origin 검증 쉬움 | 미들웨어 순서에 민감 |
서비스가 단일 백엔드 + 단일 프록시면 백엔드(FastAPI/Express) 한 군데에 책임을 모으는 게 낫다. 마이크로서비스가 5개 이상이고 모두 같은 CORS 정책을 따른다면 그땐 Nginx나 API 게이트웨이로 끌어올리는 게 유리하다. 정책 변경 시 한 군데만 고치면 되니까.
출처 검증을 정규식으로 풀 때 빠지기 쉬운 함정
그런데, 서브도메인이 많은 환경에서는 정규식으로 출처를 검증하고 싶어진다. 이게 함정이다.
# 의도: app.example.com 하위 서브도메인 전체 허용
import re
ALLOWED_PATTERN = re.compile(r"^https://.*\.example\.com$")
이 패턴은 https://evil.com.example.com도 통과시킨다. 공격자가 evil.com.example.com이라는 서브도메인을 운영 중인 도메인 어딘가에 만들 수만 있다면 그대로 뚫린다. 점(.)이 정규식에서 임의 문자라는 사실을 깜빡한 결과다.
# 수정: 점을 이스케이프하고 앵커 명확히
ALLOWED_PATTERN = re.compile(r"^https://([a-z0-9-]+\.)?example\.com$")
그런데, 이 정도면 app.example.com, admin.example.com, example.com은 통과하고 evil-example.com이나 example.com.attacker.com은 막힌다. 이런 정규식 검증은 단위 테스트로 케이스를 박아두는 게 안전하다. 출처 검증 한 줄이 잘못되면 CSRF 방어막이 통째로 무너진다.
자주 만나는 증상과 1차 진단
증상만으로 원인을 좁히는 표다. 이건 콘솔에 뜬 메시지를 그대로 검색하는 것보다 훨씬 빠르다.
| 증상 | 가장 흔한 원인 | 1차 확인 |
|---|---|---|
No 'Access-Control-Allow-Origin' |
서버에서 CORS 헤더 자체가 안 나감 | OPTIONS 응답 헤더 직접 curl로 확인 |
wildcard '*' when credentials mode is 'include' |
*와 credentials: true 동시 사용 |
출처를 명시적으로 지정 |
| 프리플라이트만 404 | 프록시 단에서 OPTIONS 라우팅 누락 | Nginx location이 OPTIONS 받는지 확인 |
Authorization 헤더가 막힘 |
Access-Control-Allow-Headers에 미포함 |
허용 헤더 목록에 추가 |
| 매 요청마다 OPTIONS가 새로 나감 | Access-Control-Max-Age 미설정 |
86400 추가 |
| 어떤 브라우저만 안 됨 | 캐시된 프리플라이트 차이 | 시크릿 창으로 재현 후 캐시 지우기 |
curl로 OPTIONS를 직접 던지면 브라우저를 거치지 않고 서버 응답만 확인할 수 있다.
curl -i -X OPTIONS https://api.example.com/v1/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization,content-type"
게다가, 응답 헤더에 Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers가 모두 찍혀야 정상이다. 하나라도 빠져 있으면 그게 범인이다.
프론트에서 보던 것과 백엔드에서 보는 것
한편, 전환 2년차 시점에서 가장 크게 바뀐 인식은 이거다. 프론트에서 CORS는 "막혔다"는 결과만 보였다. 백엔드에서 CORS는 "왜 막아야 하는가"의 문제였다.
그 외에도, 브라우저는 사용자 보호를 위해 출처가 다른 응답을 자바스크립트에게 노출하지 않는다. 이게 동일 출처 정책(Same-Origin Policy)이고, CORS는 그 정책에 구멍을 내는 합의된 절차다. "통과시켜달라"가 아니라 "이 출처는 신뢰한다고 명시적으로 선언한다"가 본질이다. 와일드카드로 다 열어두는 건 그 신뢰 선언을 무책임하게 양보하는 짓이다. (백엔드 와서 보안팀이 와일드카드 코드에 왜 그렇게 예민한지 그제야 이해됐다.)
또 하나 의외였던 건, CORS는 보안 기능이 아니라는 사실이었다. 정확히는 서버를 보호하는 기능이 아니다. 서버는 OPTIONS와 본 요청을 다 받는다. 응답도 다 돌려준다. 브라우저가 응답을 자바스크립트에 넘겨주지 않을 뿐이다. curl이나 백엔드 간 통신은 CORS와 무관하게 다 통한다. 그래서 CORS만 믿고 API 인증을 느슨하게 짜면 안 된다. 인증·인가는 별개의 레이어다.
그래서 언제 어떻게 쓸 것인가
판단 기준 세 가지로 정리한다.
결국, 서비스가 단일 도메인 + 단일 백엔드라면, FastAPI든 Express든 애플리케이션 미들웨어에서 처리하라. 출처는 환경변수로 빼두고 운영/스테이징/개발을 분리한다. allow_origins=["*"]는 절대 쓰지 마라. 인증이 붙는 순간 깨진다.
마이크로서비스 5개 이상이라면, Nginx 혹은 API 게이트웨이(Kong, AWS API Gateway 등) 한 곳으로 끌어올려라. 정책 변경 비용이 서비스 수에 비례해서 폭증하는 걸 막을 수 있다.
실제로, 서브도메인이 많고 출처가 동적이라면, 콜백 형태로 검증 로직을 짜되 정규식의 점(.)을 반드시 이스케이프하라. 단위 테스트로 화이트리스트 통과/거부 케이스를 박아둬라. 이게 빠진 출처 검증은 출처 검증이 아니다.
지금 당장 실행할 액션은 세 가지다. (1) 운영 서버에 curl로 OPTIONS를 던져서 응답 헤더 세 개(Origin, Methods, Headers)가 모두 찍히는지 확인한다. (2) allow_credentials=True인 코드베이스에서 allow_origins=["*"]이 남아있는지 grep으로 훑는다. (3) Access-Control-Max-Age가 비어있다면 86400을 박는다. 세 가지가 끝나면 콘솔의 빨간 글씨는 한동안 안 본다.
이처럼, 참고: MDN — CORS (2026년 5월 확인), Fetch Standard — CORS protocol (Living Standard 기준), FastAPI CORSMiddleware 공식 문서 (FastAPI 0.110+ 기준), expressjs/cors GitHub (v2.8.5 기준).
관련 글
- SQL Injection 방지 실전 가이드: Parameterized Query와 ORM 패턴 – 코드 리뷰에서 f-string으로 조립된 SQL 쿼리를 본 적이 있다. SQL Injection 방지 실전을 parameterized qu…
- Let’s Encrypt Nginx HTTPS 설정 완전 가이드: Certbot·Docker·Reverse Proxy 자동 갱신 – Let’s Encrypt를 Nginx에 붙일 때 발급 도구·인증 방식·배포 구조에 따라 갱신 안정성이 크게 갈린다. 실무에서 자주 마주치는…
- Refresh Token Rotation 구현: 3개월간 부순 것과 고친 것 – reuse_detected 알람이 87건 한꺼번에 떴다. 사용자는 강제 로그아웃됐고 CS는 불탔다. Refresh Token Rotatio…