코드는 맞는데 왜 브라우저만 에러가 나는 거야
Claude Code로 React 프론트와 FastAPI 백엔드를 각각 만들었다. 프론트는 localhost:3000, 백엔드는 localhost:8000. 연결하려고 이렇게 썼다.
fetch('http://localhost:8000/api/data')
.then(res => res.json())
.then(data => console.log(data))
실행하면 콘솔에 빨간 에러가 뜬다.
Access to fetch at 'http://localhost:8000/api/data' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
코드는 분명히 맞다. FastAPI도 제대로 돌고 있다. curl로 때려보면 응답도 온다. 그런데 브라우저에서만 안 된다.
이게 CORS 에러다.
CORS는 서버 문제가 아니다
먼저 이것부터 짚자.
CORS는 서버 버그가 아니다. 브라우저가 의도적으로 막는 거다.
브라우저에는 "다른 출처의 리소스를 함부로 가져오면 안 된다"는 원칙이 있다. SOP(Same-Origin Policy, 동일 출처 정책)라고 부른다. Chrome이든 Safari든 Firefox든 모든 브라우저가 따르는 표준 규칙이다.
여기서 "출처(Origin)"는 세 가지가 모두 같아야 같은 출처로 본다.
- 프로토콜:
http://vshttps:// - 도메인:
localhostvsapi.myapp.com - 포트:
:3000vs:8000
셋 중 하나라도 다르면 다른 출처다. localhost:3000과 localhost:8000은 포트가 달라서 다른 출처고, http://myapp.com과 https://myapp.com은 프로토콜이 달라서 다른 출처다.
CORS(Cross-Origin Resource Sharing)는 이 SOP를 조건부로 풀어주는 방법이다. 서버가 응답 헤더에 "이 출처의 요청은 허용한다"고 적어 보내면, 브라우저가 그 요청을 통과시킨다.
왜 이런 규칙이 있나
불편하게 느껴지는 건 당연하다. 이유가 있긴 하다.
bank.com에 로그인된 상태를 생각해보자. 쿠키에 인증 정보가 들어 있다. 이 상태에서 악성 사이트 evil.com을 방문했다.
SOP가 없다면, evil.com의 JavaScript가 이런 코드를 실행할 수 있다.
// evil.com에서 몰래 실행되는 코드
fetch('https://bank.com/api/transfer', {
method: 'POST',
body: JSON.stringify({ amount: 1000000, to: 'hacker' }),
credentials: 'include' // bank.com 로그인 쿠키를 같이 보냄
})
브라우저에 bank.com 쿠키가 남아 있으니, 이 요청은 인증된 요청으로 처리된다. 사용자가 모르는 사이에 계좌 이체가 실행된다. CSRF(크로스 사이트 요청 위조) 공격이 이런 원리다.
SOP는 이걸 막는다. evil.com에서 bank.com으로 날리는 요청은 bank.com 서버가 허용 목록에 evil.com을 명시하지 않는 한 브라우저가 응답을 차단한다.
curl은 왜 CORS 에러가 없냐
SOP는 브라우저에만 적용되는 규칙이다. 브라우저가 아닌 클라이언트는 CORS를 신경 쓰지 않는다.
# curl은 잘 된다
curl http://localhost:8000/api/data
# 브라우저 fetch는 CORS 에러가 날 수 있다
fetch('http://localhost:8000/api/data')
curl, Postman, Insomnia, Python requests, Node.js axios 모두 마찬가지다. CORS와 상관없이 응답이 온다.
그래서 "API는 잘 되는데 브라우저에서만 안 돼"라는 상황이 생긴다. 코드가 틀린 게 아니다. 브라우저 보안 정책이 막는 것이고, 해결은 서버 설정에서 해야 한다.
Preflight: 본 요청 전에 먼저 허락을 받는다
CORS 요청 중에 브라우저가 실제 요청을 보내기 전에 "이 요청 해도 돼?"라고 먼저 물어보는 경우가 있다. 이걸 Preflight 요청이라고 한다.
브라우저 개발자 도구의 네트워크 탭을 열면 OPTIONS 메서드로 날아가는 요청이 보인다.
OPTIONS /api/data HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
"나 localhost:3000에서 왔는데, POST로 Content-Type이랑 Authorization 헤더 포함해서 요청해도 돼?" 하고 묻는 거다. 서버가 OK를 보내면 그때 실제 요청을 날린다.
Preflight가 발생하는 조건은 이렇다.
GET,POST,HEAD이외의 메서드 사용 (PUT, DELETE, PATCH 등)Content-Type이application/json인 경우Authorization같은 커스텀 헤더 포함
JSON API를 쓰거나 인증 헤더를 붙이는 순간 Preflight가 발생한다. 바이브 코딩으로 만드는 앱 대부분이 여기 해당한다.
프레임워크별 설정 방법
서버에서 CORS 허용 설정을 추가하면 된다.
FastAPI (Python)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # 프론트 주소
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
배포할 때는 실제 프론트 도메인으로 바꿔야 한다. 개발 주소와 프로덕션 주소를 함께 넣는 게 일반적이다.
allow_origins=[
"http://localhost:3000",
"https://myapp.vercel.app",
"https://myapp.com",
]
Express (Node.js)
const cors = require('cors')
app.use(cors({
origin: ['http://localhost:3000', 'https://myapp.com'],
credentials: true
}))
Next.js (API Routes)
Next.js API Routes는 프론트와 같은 도메인에서 제공되니 기본적으로 CORS가 필요 없다. 외부 클라이언트가 Next.js API를 호출해야 할 경우엔 next.config.js에서 헤더를 직접 설정한다.
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://myapp.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
]
},
}
자주 하는 실수 세 가지
1. * 쓰고 credentials도 넣는다
개발 중에는 *로 모든 출처를 허용하면 편하다. 빠르게 테스트할 때 좋다. 그런데 credentials: true와는 함께 쓸 수 없다. 쿠키나 Authorization 헤더를 같이 보내야 한다면 특정 도메인을 명시해야 한다.
# 이건 서버 에러 난다 (credentials랑 * 조합 불가)
allow_origins=["*"]
allow_credentials=True
# 이렇게 해야 한다
allow_origins=["https://myapp.com"]
allow_credentials=True
프로덕션 API에 *를 그대로 두는 것도 피해야 한다. 어떤 사이트에서든 이 API를 호출할 수 있게 열어두는 셈이다.
2. 로컬에서는 되는데 배포하면 안 된다
allow_origins에 http://localhost:3000만 넣은 채로 배포하면 프로덕션 도메인이 허용 목록에 없어서 에러가 난다. 환경변수로 관리하면 이 실수를 피할 수 있다.
import os
origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
...
)
배포할 때 ALLOWED_ORIGINS=https://myapp.com,https://myapp.vercel.app 식으로 환경변수를 설정해두면 된다.
3. OPTIONS 요청이 404로 떨어진다
Preflight 요청은 OPTIONS 메서드로 들어온다. 서버가 OPTIONS를 처리하지 않으면 404나 405를 돌려보내고 CORS도 실패한다. FastAPI나 Express의 CORS 미들웨어를 제대로 추가하면 자동으로 처리된다. 직접 라우팅을 짤 때는 OPTIONS를 명시적으로 핸들링해야 한다.
CORS를 아예 피하는 방법
프론트와 백엔드를 Next.js 하나로 묶으면 CORS 문제가 처음부터 없다.
https://myapp.com/ → Next.js 프론트엔드
https://myapp.com/api/data → Next.js API Routes (백엔드)
같은 도메인에서 제공되니 SOP 위반 자체가 없다. Claude Code로 빠르게 프로토타입을 만들 때 CORS 설정이 귀찮다면 이 구조가 가장 편하다.
FastAPI나 별도 서버가 꼭 필요하다면 Next.js API Routes를 프록시로 쓰는 방법도 있다. 프론트에서 /api/proxy를 호출하면 Next.js 서버가 내부에서 FastAPI로 요청을 전달한다. 브라우저 입장에서는 같은 도메인에 요청하는 것이라 CORS 없이 동작한다.
CORS 에러를 만났을 때 확인할 것들
- 서버에 CORS 미들웨어를 추가했나? 없으면 여기서부터 시작
allow_origins에 지금 쓰는 프론트 주소가 있나? 로컬, 스테이징, 프로덕션 각각 확인- 쿠키나 Authorization 헤더를 쓰나? 그렇다면
*대신 구체적인 도메인을 명시 - 네트워크 탭에 OPTIONS 요청이 보이나? Preflight가 발생하고 있다면 미들웨어가 이를 처리하는지 확인
CORS는 처음엔 황당하게 느껴지지만, 브라우저가 내 정보를 지키려고 만든 장치다. 다음에 이 에러가 뜨면 "코드가 틀렸나?"가 아니라 "서버 설정을 빠뜨렸나?"부터 확인하면 된다.