프론트엔드를 Vercel 프리뷰에 올렸습니다. 화면은 멀쩡히 떴는데, 데이터가 안 옵니다. 콘솔을 열어보니 빨간 CORS 에러. “로컬에선 잘 됐는데?” 싶은, 웹 개발자라면 다 겪는 그 벽입니다.
원인은 프리뷰 배포 URL이 매번 바뀐다는 데 있었습니다. 결론부터: 도메인을 하나하나 나열하지 말고 정규식 한 줄로 푸는 게 답이었어요.
CORS: 브라우저 보안 규칙. A 도메인의 웹페이지가 B 도메인의 API를 부를 때, B가 “A는 허용”이라고 응답 헤더로 명시하지 않으면 브라우저가 그 응답을 막습니다. (서버는 멀쩡히 응답해도 브라우저가 차단)
잠깐, CORS는 왜 있는 건가
고치기 전에 이걸 알아야 삽질이 줄어듭니다. 저도 처음엔 “왜 쓸데없이 막아서 사람 귀찮게 하나” 싶었거든요.
이유가 있습니다. 브라우저는 여러분이 로그인한 상태를 쿠키로 들고 다닙니다. 만약 이 규칙이 없으면, 제가 악성 사이트를 하나 만들어서 여러분을 유인한 뒤 그 페이지에서 몰래 여러분 은행 API를 부를 수 있어요. 여러분 브라우저가 은행 쿠키를 자동으로 실어 보내니까, 은행 입장에선 진짜 여러분의 요청과 구분이 안 됩니다. 그래서 브라우저가 나서서 “이 페이지가 저 API를 불러도 된다고 API 쪽이 명시했나?”를 확인하는 겁니다.
즉 CORS는 API를 보호하는 게 아니라 사용자를 보호합니다. 그리고 확인하는 주체가 서버가 아니라 브라우저라는 게 핵심이에요. 이 사실이 나중에 디버깅할 때 결정적으로 쓰입니다.
증상: 화면은 뜨는데 데이터가 안 온다
전형적인 CORS 에러는 이렇게 생겼습니다(브라우저 콘솔).
Access to fetch at 'https://api.my-backend.fly.dev/analyze'
from origin 'https://pids-git-feat-abc123.vercel.app'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
핵심은 마지막 줄. 백엔드 응답에 “이 출처(origin)를 허용한다”는 헤더(Access-Control-Allow-Origin)가 없다는 뜻입니다.
origin(출처):
https://도메인:포트까지를 묶은 주소의 신원.pids-git-feat-abc123.vercel.app처럼 프리뷰마다 앞부분이 달라지면 다른 출처로 취급됩니다.
여기서 사람을 제일 헷갈리게 하는 지점이 있습니다. 서버는 아무 문제가 없다는 겁니다. 요청은 백엔드까지 잘 갔고, 백엔드는 계산해서 응답까지 정상적으로 돌려줬어요. 그런데 브라우저가 그 응답을 받아 들고 “허용 헤더가 없네” 하며 JavaScript에 넘겨주지 않고 버립니다. 서버 로그에는 200 OK가 찍혀 있는데 화면엔 데이터가 없는 기묘한 상황이 이래서 생깁니다. 서버 로그만 보면 절대 원인을 못 찾아요.
왜 프리뷰에서만 막혔나
로컬(localhost)이나 운영 도메인은 백엔드 허용 목록에 박아뒀습니다. 그런데 Vercel 프리뷰는 배포할 때마다 URL이 새로 생깁니다(브랜치·커밋마다 pids-git-...-팀.vercel.app). 매번 새 도메인이니 허용 목록에 있을 리가 없죠. 그래서 프리뷰에서만 차단된 겁니다.
프리뷰 배포: 운영에 올리기 전, 브랜치/PR마다 임시 URL로 띄워 확인하는 배포. URL이 매번 달라지는 게 특징.
그러니 프리뷰 URL을 허용 목록에 하나씩 추가하는 건 답이 될 수 없습니다. 새 브랜치를 딸 때마다, 커밋을 올릴 때마다 목록을 고치고 백엔드를 재배포해야 하니까요. 문제가 “목록에 하나가 빠졌다”가 아니라 “목록이라는 방식 자체가 안 맞는다” 였던 겁니다.
해결: 도메인 나열 대신 정규식으로 허용
프리뷰 URL은 규칙이 있습니다(pids-로 시작, .vercel.app로 끝남). 그러면 패턴(정규식)으로 한 번에 허용하면 됩니다. FastAPI라면 allow_origin_regex가 그 일을 합니다.
정규식(regex): “이런 모양의 문자열”을 한 줄로 표현하는 패턴. 변하는 부분을 와일드카드로 잡습니다. 수학의 함수처럼, 값 하나가 아니라 “이런 모양이면 전부”를 정의하는 식입니다.
# FastAPI 백엔드 — main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
# 운영·로컬은 고정 목록으로
allow_origins=["https://pids.app", "http://localhost:3000"],
# 프리뷰는 '패턴'으로: pids-...-team.vercel.app 전부 허용
allow_origin_regex=r"https://pids-.*\.vercel\.app",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
패턴을 짤 때 양쪽 끝을 조인 게 중요합니다. pids-로 시작하고 .vercel.app으로 끝나는 것만 통과시켰죠. 귀찮다고 .*\.vercel\.app처럼 넓게 열면 남이 만든 아무 Vercel 앱이나 제 API를 부를 수 있게 됩니다. vercel.app은 누구나 무료로 배포할 수 있는 도메인이니까요. 앞서 말한 “사용자를 보호한다”는 CORS의 목적을 제 손으로 무력화하는 셈입니다. 편의를 위해 규칙을 푸는 건 좋은데, 딱 내 프리뷰만큼만 풀어야 합니다.
내가 실제로 밟은 함정: 정규식에 https://가 빠졌다
여기서 30분을 썼습니다. 정규식을 환경변수(배포 서버의 secret)로 빼뒀는데, 그 값에 https:// 스킴을 빼먹어서 패턴이 실제 출처(https://pids-...)와 안 맞았어요.
빠져나오기 어려웠던 이유가 있습니다. 정규식을 눈으로 보면 pids-.*\.vercel\.app이 멀쩡해 보이거든요. 프리뷰 주소도 딱 그렇게 생겼고요. 그런데 CORS가 비교하는 건 주소가 아니라 출처(origin) 전체이고, 출처에는 https://가 붙어 있습니다. 제가 비교 대상을 잘못 알고 있으니 아무리 들여다봐도 틀린 데가 안 보였던 거예요. 정규식은 맞는 것 같은데 계속 막히면, 스킴(https) 포함 여부부터 의심하세요.
환경변수 / secret: 코드에 직접 안 박고 배포 서버에 따로 저장하는 설정값(키·URL 등). 코드 수정 없이 값만 바꿀 수 있음.
고쳤는지 1초 만에 확인하는 법
브라우저로 매번 배포해 확인하지 말고, curl로 응답 헤더만 까보면 됩니다.
curl -I -H "Origin: https://pids-git-feat-abc123.vercel.app" \
https://api.my-backend.fly.dev/analyze
# 응답에 이 줄이 있으면 성공:
# access-control-allow-origin: https://pids-git-feat-abc123.vercel.app
-H "Origin: ..."로 브라우저인 척 출처를 붙여 보내는 게 요령입니다. 아까 CORS는 브라우저가 검사하는 규칙이라고 했죠. 그래서 curl은 헤더가 없어도 응답을 그냥 받아버립니다 — 검사할 마음이 없으니까요. 대신 우리는 서버가 어떤 헤더를 붙여 보내는지만 확인하면 됩니다. 응답 헤더 access-control-allow-origin이 내 프리뷰 주소를 그대로 되돌려주면(echo) 통과입니다. 안 보이면 아직 안 맞은 거예요. 배포하고 브라우저 열어서 콘솔 보는 것보다 훨씬 빠릅니다.
그래서 뭘 하면 되나
| 신호 | 판단 | 액션 |
|---|---|---|
| 로컬은 되는데 프리뷰만 CORS 차단 | 프리뷰 URL이 허용 목록에 없음 | 🔴 정규식으로 패턴 허용 |
| 정규식 넣었는데도 막힘 | 보통 https:// 스킴 누락 | 🔴 스킴 포함 확인 |
| 고쳤는지 확인 | 브라우저 말고 | 🟢 curl -I -H "Origin: ..."로 ACAO 헤더 확인 |
한 문장: 프리뷰는 URL이 매번 바뀌니 도메인 나열 대신 정규식으로 허용하고, 안 되면 https:// 스킴부터 의심, 확인은 curl로 ACAO 헤더 한 줄.
CORS는 “서버 잘못”처럼 보이지만 사실 브라우저 규칙이라, 고칠 곳은 백엔드의 허용 설정 한 군데입니다. 규칙이 왜 있는지만 알면 에러 메시지가 갑자기 읽히기 시작해요. 이 벽을 또 만나면, 정규식 한 줄과 curl 한 방으로 끝내세요.