CORS 에러, 프리플라이트, Access-Control-Allow-Origin | 로컬은 되는데 배포하면 막히면?
CORS 에러는 코드가 틀려서가 아니라 서버가 응답에 허용 헤더를 안 붙여서 브라우저가 읽기를 막는 것이다. 프로토콜·도메인·포트가 하나라도 다르면 교차 출처가 되고, JSON 본문이나 Authorization 헤더가 붙으면 OPTIONS 프리플라이트가 먼저 날아간다. Access-Control-Allow-Origin·Methods·Headers, 쿠키를 쓸 때 와일드카드 금지와 정확한 출처 반사, Vary Origin, Next.js rewrites 회피까지. 개발자, Next.js, 프론트엔드, 백엔드, API, 보안 기준. 2026년 8월 MDN·Fetch 표준.
CORS 에러는 코드가 틀려서가 아니라, 서버가 브라우저에게 "이 응답을 다른 출처에서 읽어도 된다"고 허락하는 헤더를 안 붙여서 생깁니다. 그래서 프런트만 고쳐서는 대부분 안 풀립니다.
로컬에서는 멀쩡하던 요청이 배포만 하면 콘솔에 빨간 CORS 메시지를 띄우는 일, 한 번쯤 겪으셨을 겁니다. 도메인이 같을 땐 문제가 없다가 프런트와 API가 다른 주소로 갈라지는 순간 브라우저가 막아서죠. 여기에 쿠키까지 얹으면 규칙이 또 한 겹 더 붙습니다.
아래에서는 CORS 에러가 왜 나는지, 언제 프리플라이트 요청이 먼저 날아가는지, 서버가 돌려줘야 하는 응답 헤더가 무엇인지, 그리고 쿠키를 보낼 때 왜 와일드카드가 막히는지를 순서대로 풀어 봅니다. 한국에서 프런트와 API를 다른 도메인으로 나눠 혼자 운영하시는 분이라면 특히 자주 만나는 상황이죠. 같은 에러 앞에서 어디를 고쳐야 할지 막막하셨다면 도움이 되실 거예요. 근거는 MDN과 WHATWG Fetch 표준에 뒀습니다.
CORS 에러는 왜 로컬에선 안 뜨나
CORS는 브라우저가 다른 출처의 응답을 함부로 읽지 못하게 막는 보안 규칙입니다. 여기서 출처(origin)는 프로토콜, 도메인, 포트 세 가지가 모두 같아야 같은 출처로 칩니다. 하나라도 다르면 교차 출처가 되고, 그때부터 서버의 허락이 필요합니다.
로컬에서 에러가 안 뜨는 이유가 여기 있습니다. 개발 중에는 프런트와 API가 같은 localhost에 있거나 프록시로 한 주소처럼 묶여서 교차 출처가 아니었던 겁니다. 그런데 배포하면 프런트는 app.example.com, API는 api.example.com처럼 갈라지면서 처음으로 교차 출처가 되고, 서버가 허락 헤더를 안 붙였다면 그때 막힙니다.
한 가지 더 짚을 점은, 서버는 요청을 실제로 처리하고 응답을 정상적으로 보냈다는 것입니다. 막는 주체는 서버가 아니라 브라우저예요. 그래서 서버 로그에는 200이 찍히는데 화면에서는 데이터를 못 받는 상황이 자주 생깁니다.
먼저 확인할 것 | CORS 에러 메시지에는 항상 막힌 출처와 빠진 헤더 이름이 함께 적혀 있습니다. 콘솔의 빨간 줄을 끝까지 읽으면 대부분 "무슨 헤더가 없다"고 알려 줍니다. 추측보다 그 문장을 먼저 읽는 편이 빠릅니다.
같은 localhost에선 안 보이던 CORS 에러가 도메인이 갈라지면 드러난다
프리플라이트 요청은 언제 먼저 날아가나
단순 요청이 아니면 브라우저는 실제 요청을 보내기 전에 OPTIONS 메서드로 "이 요청을 보내도 되냐"고 서버에 먼저 물어봅니다. 이걸 프리플라이트라고 합니다. 서버가 여기서 허락해야만 진짜 요청이 나갑니다.
MDN 기준으로, GET과 HEAD가 아니거나 POST라도 본문 타입이 폼 계열이 아닌 경우, 또는 Authorization·Content-Type: application/json 같은 커스텀 헤더가 붙으면 프리플라이트가 먼저 뜹니다. 요즘 API는 JSON 본문에 토큰 헤더를 얹는 경우가 많아서, 사실상 대부분의 실무 요청이 프리플라이트 대상입니다.
여기서 흔히 놓치는 실수가 있습니다. 서버가 POST 라우트만 만들고 OPTIONS는 처리하지 않으면, 프리플라이트가 404나 405로 떨어지면서 진짜 요청은 시작도 못 합니다. 그래서 "POST를 만들었는데 왜 안 되지" 싶을 때는 OPTIONS 응답부터 확인해야 합니다.
요청 상황
프리플라이트
이유
GET 단순 조회
없음
단순 요청 조건 충족
JSON 본문 POST
있음
Content-Type이 폼 계열 아님
Authorization 헤더 첨부
있음
안전 목록 밖 헤더
PUT · DELETE
있음
부수 효과 있는 메서드
서버가 돌려줘야 하는 응답 헤더
CORS는 결국 서버 응답 헤더로 풀립니다. 핵심은 Access-Control-Allow-Origin이고, 프리플라이트가 뜨는 요청이면 허용 메서드와 허용 헤더도 함께 돌려줘야 합니다. 이 세 가지가 요청 내용과 맞아떨어져야 브라우저가 통과시킵니다.
정리하면, 브라우저는 실제 요청에 담길 메서드를 Access-Control-Request-Method로, 커스텀 헤더를 Access-Control-Request-Headers로 프리플라이트에 실어 보냅니다. 서버는 그에 대응해 Access-Control-Allow-Methods와 Access-Control-Allow-Headers로 허용 목록을 답해야 하죠. 프리플라이트를 매번 반복하기 싫다면 Access-Control-Max-Age로 캐시 시간을 줄 수 있습니다.
응답 헤더
역할
예시 값
Access-Control-Allow-Origin
읽기를 허용할 출처
https://app.example.com
Access-Control-Allow-Methods
허용할 메서드 목록
GET, POST, PUT, DELETE
Access-Control-Allow-Headers
허용할 요청 헤더
Content-Type, Authorization
Access-Control-Max-Age
프리플라이트 캐시 초
86400
Next.js API 라우트라면 응답에 이 헤더를 붙이고 OPTIONS에는 본문 없이 204를 돌려주면 됩니다. 아래는 App Router 라우트 핸들러 예시입니다.
가장 헷갈리는 지점이 쿠키나 인증 헤더를 함께 보내는 경우입니다. 클라이언트가 fetch(..., { credentials: 'include' })로 쿠키를 실으면, 서버는 Access-Control-Allow-Credentials: true를 돌려줘야 합니다. 그런데 이때 Access-Control-Allow-Origin을 *로 두면 브라우저가 응답을 막아 버립니다.
WHATWG Fetch 표준은 자격 증명이 붙은 요청에서 와일드카드 *를 금지합니다. 아무 출처나 인증된 응답을 읽게 두면 쿠키가 새기 때문이죠. 그래서 쿠키를 쓸 때는 서버가 요청의 Origin을 허용 목록과 대조한 뒤, 통과하면 그 출처 문자열을 그대로 되돌려줘야 합니다. MDN도 자격 증명 요청에는 반드시 명시적 출처를 지정하라고 못 박습니다.
같은 규칙이 다른 헤더에도 적용됩니다. 자격 증명이 켜지면 Access-Control-Allow-Headers: *도 와일드카드로 안 먹혀서 Authorization 같은 헤더를 이름으로 나열해야 합니다. 그리고 출처를 요청마다 반사해서 돌려줄 때는 Vary: Origin을 붙여야 캐시가 엉뚱한 출처의 응답을 섞지 않습니다.
무검증 반사는 와일드카드보다 위험 | 요청 출처를 검증 없이 그대로 되돌리면 아무 사이트나 인증된 응답을 읽을 수 있게 됩니다. 편하다고 모든 Origin을 반사하지 말고, 반드시 허용 목록을 통과한 출처만 돌려주세요.
Next.js에서 실제로 고치는 순서
에러가 떴을 때 순서를 정해 두면 헤매지 않습니다. 먼저 콘솔 메시지에서 어떤 헤더가 빠졌는지 읽고, 네트워크 탭에서 OPTIONS 응답의 상태 코드와 헤더를 확인합니다. 여기서 프리플라이트가 실패했는지, 아니면 실제 요청 단계에서 막혔는지가 갈립니다.
같은 프로젝트 안에서 프런트와 API가 함께 배포되는 구조라면, 사실 CORS 자체가 필요 없는 경우가 많습니다. Next.js에서 화면과 API 라우트가 같은 도메인에 있으면 교차 출처가 아니니까요. 굳이 다른 도메인의 API를 부르는 대신, next.config.js의 rewrites로 /api를 뒤쪽 서버로 프록시하면 브라우저 입장에서는 같은 출처가 되어 CORS를 피할 수 있습니다.
정말로 다른 도메인의 API를 브라우저가 직접 불러야 할 때만 앞에서 정리한 응답 헤더를 서버에 붙입니다. 서버 컴포넌트나 라우트 핸들러에서 서버끼리 부르는 요청은 브라우저를 거치지 않으므로 CORS 규칙을 받지 않는다는 점도 기억해 두면 좋습니다. 보안 헤더나 쿠키 속성이 얽힌 경우는 쿠키 SameSite 글과 보안 헤더 글을 함께 보시면 됩니다.
대부분은 서버에서 고쳐야 합니다. CORS는 서버가 응답에 허용 헤더를 붙였는지로 결정되기 때문이죠. 프런트에서 할 수 있는 것은 같은 도메인으로 요청을 묶거나, Next.js의 rewrites로 프록시해 교차 출처 자체를 피하는 정도입니다.
프리플라이트 요청은 왜 두 번 날아가나요?
단순 요청 조건을 벗어나면 브라우저가 실제 요청 전에 OPTIONS로 허락을 먼저 묻기 때문입니다. JSON 본문이나 Authorization 헤더가 붙으면 여기에 해당합니다. Access-Control-Max-Age로 캐시 시간을 주면 반복 프리플라이트를 줄일 수 있습니다.
Access-Control-Allow-Origin을 *로 두면 안 되나요?
쿠키나 인증 헤더가 없는 공개 API라면 *가 간단하고 캐시에도 유리합니다. 다만 자격 증명이 붙은 요청에는 표준이 *를 금지하므로, 허용 목록을 통과한 정확한 출처를 되돌려줘야 합니다.
서버 로그에는 200인데 화면에서 데이터를 못 받아요.
전형적인 CORS 차단입니다. 서버는 요청을 처리해 응답까지 보냈지만, 허용 헤더가 없어서 브라우저가 응답 읽기를 막은 것이죠. 콘솔의 CORS 메시지와 네트워크 탭의 응답 헤더를 확인하면 원인이 드러납니다.
서버에서 다른 서버 API를 부를 때도 CORS가 걸리나요?
아닙니다. CORS는 브라우저가 적용하는 규칙이라 서버끼리 주고받는 요청에는 걸리지 않습니다. Next.js 서버 컴포넌트나 라우트 핸들러에서 외부 API를 부르면 브라우저를 거치지 않아 CORS 에러가 나지 않습니다.
Vary: Origin은 왜 붙이나요?
출처를 요청마다 반사해서 돌려줄 때, 캐시가 한 출처용 응답을 다른 출처에 그대로 내주는 것을 막기 위해서입니다. Vary: Origin이 있으면 중간 캐시가 출처별로 응답을 구분해 저장합니다.
CORS 에러는 결국 서버가 브라우저에게 허락 헤더를 붙였는지의 문제입니다. 프리플라이트가 뜨는 요청인지 먼저 가르고, 쿠키를 쓰면 와일드카드를 버리고 정확한 출처를 반사하면 대부분 풀립니다. 같은 도메인으로 묶을 수 있다면 아예 피하는 편이 제일 깔끔하죠. 관련 글: 쿠키 SameSite, 보안 헤더, 프록시와 서버 액션.