Vercel 프리뷰에서 API가 CORS로 막힐 때 — URL이 매번 바뀌는 문제 한 방에

프론트엔드를 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 한 방으로 끝내세요.

댓글 남기기