
프런트엔드에서 API를 호출했는데 콘솔에 CORS 오류가 뜬다. 서버 개발자에게 물어보면 "서버에서는 잘 되는데요"라고 한다. 둘 다 맞는 말이다. CORS는 브라우저가 거는 제약이지 서버의 문제가 아니기 때문이다. 이 글은 CORS가 왜 존재하고 오류가 정확히 무엇을 뜻하는지, 그리고 상황별로 어떻게 푸는지를 정리한다.
브라우저만 막는다
먼저 이 사실부터 분명히 해야 한다. curl이나 서버 코드에서 같은 API를 호출하면 아무 문제 없이 된다. CORS는 브라우저가 자바스크립트로 다른 출처에 요청할 때 적용하는 규칙이다.
왜 이런 제약이 있나. 브라우저는 쿠키를 자동으로 붙인다. 만약 제약이 없다면, 악성 사이트가 사용자가 로그인해 둔 은행 사이트에 요청을 보내고 응답을 읽을 수 있다. 쿠키가 자동으로 붙으니 인증도 통과한다. 이걸 막는 것이 동일 출처 정책(Same-Origin Policy)이고, CORS는 그 제약을 서버가 의도적으로 풀어주는 방법이다.
출처(origin)는 스킴 + 호스트 + 포트로 정의된다. 하나라도 다르면 다른 출처다.
https://app.example.com 기준
http://app.example.com 다름 (스킴)
https://api.example.com 다름 (호스트)
https://app.example.com:8443 다름 (포트)
서브도메인이 달라도 다른 출처라는 점이 자주 헷갈린다.
프리플라이트가 붙는 조건
CORS 요청은 두 종류로 나뉜다. 단순 요청은 바로 나가고, 그렇지 않으면 브라우저가 먼저 OPTIONS 요청을 보내 허락을 구한다. 이걸 프리플라이트라고 한다.

GET, HEAD, POST 중 하나이고, 헤더가 몇 가지 기본 항목뿐이며, Content-Type이 text/plain, multipart/form-data, application/x-www-form-urlencoded 중 하나여야 한다.
Content-Type: application/json은 단순 요청이 아니다. 그래서 요즘 대부분의 API 호출은 프리플라이트가 붙는다. Authorization 헤더를 붙여도 마찬가지다.
서버가 응답해야 하는 헤더
서버 쪽에서 필요한 것은 몇 개 되지 않는다.
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
Max-Age는 프리플라이트 결과를 브라우저가 캐시할 시간이다. 이걸 안 주면 요청마다 OPTIONS가 한 번씩 더 나간다. 왕복이 두 배가 되므로 반드시 설정하는 편이 좋다.
쿠키나 인증 정보를 함께 보내려면 양쪽에 설정이 필요하다.
fetch(url, { credentials: "include" })
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
여기서 중요한 제약이 있다. credentials를 쓰면 Allow-Origin에 *를 쓸 수 없다. 정확한 출처를 적어야 한다. 이건 사양상 금지이고, 브라우저가 거부한다.
자주 겪는 함정
와일드카드를 남발하는 것. Access-Control-Allow-Origin: *는 편하지만, 인증이 필요한 API에 쓰면 안 된다. 허용할 출처 목록을 두고 요청의 Origin과 대조해 그 값을 그대로 돌려주는 방식이 맞다. 이때 Vary: Origin 헤더를 함께 줘야 캐시가 출처별로 분리된다.
에러 응답에 CORS 헤더가 빠지는 것. 서버가 500을 낼 때 CORS 헤더를 안 붙이면, 브라우저는 CORS 오류로 표시한다. 개발자는 CORS 설정을 뒤지지만 실제 원인은 서버 에러다. 예외 처리 경로에도 CORS 헤더가 붙는지 확인해야 한다.
리다이렉트. 프리플라이트 응답이 리다이렉트되면 실패한다. OPTIONS 요청이 인증 미들웨어에 걸려 로그인 페이지로 넘어가는 경우가 흔하다. OPTIONS는 인증에서 제외해야 한다.
프록시가 헤더를 지우는 것. nginx나 로드 밸런서가 중간에서 헤더를 제거하거나 덮어쓰는 경우가 있다. 애플리케이션과 프록시 양쪽에서 CORS 헤더를 설정하면 중복되어 오히려 깨진다. 한 곳에서만 설정한다.
개발 환경에서는 프록시가 편하다
로컬 개발에서 프런트엔드가 localhost:3000, API가 localhost:8080이면 출처가 달라 CORS가 걸린다. 서버에 개발용 CORS 설정을 추가하는 방법도 있지만, 개발 서버의 프록시 기능을 쓰는 편이 깔끔하다.
// vite.config.js
export default {
server: {
proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } },
},
};
이러면 브라우저 입장에서는 같은 출처로 요청하는 것이 되어 CORS가 아예 발생하지 않는다. 프로덕션에서도 같은 도메인 아래 경로로 API를 두는 구성이라면 동일하게 문제가 없다.
보안 장치이지 장애물이 아니다
CORS 오류를 만나면 일단 *로 열어서 넘어가고 싶어진다. 그 마음은 이해하지만, CORS는 나를 막으려는 것이 아니라 사용자를 지키려는 것이라는 점을 기억하면 접근이 달라진다.
허용 출처를 정확히 적는 데 드는 비용은 크지 않다. 환경별로 목록을 관리하고, 운영 환경에는 운영 도메인만 넣으면 된다. 반대로 전부 열어두면, 어느 사이트에서든 사용자의 쿠키를 달고 내 API를 호출할 수 있게 된다.
한 가지 덧붙이면, CORS는 응답을 읽는 것을 막지 요청이 서버에 도달하는 것을 막지는 않는다. 단순 요청이라면 서버는 이미 처리한 뒤다. 그래서 상태를 바꾸는 작업을 보호하려면 CORS만으로는 부족하고 CSRF 토큰이나 SameSite 쿠키 속성이 함께 필요하다. 이 둘을 같은 것으로 오해하는 경우가 많은데, 막는 지점이 다르다.
참고 자료
- Cross-Origin Resource Sharing (CORS) — MDN — 프리플라이트 조건, 헤더 전체 목록, 자격 증명 규칙
- Same-origin policy — MDN — 출처의 정의와 정책의 배경
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet — CORS 와 CSRF 의 역할 구분
'IT' 카테고리의 다른 글
| 헬스체크와 무중단 배포, 프로브 제대로 쓰기 (0) | 2026.10.19 |
|---|---|
| 페이지네이션, 오프셋 대신 커서를 쓸 때 (0) | 2026.10.18 |
| JWT와 세션, 무엇을 골라야 하나 (0) | 2026.10.16 |
| 구조화 로깅, 장애 때 찾을 수 있는 로그 남기기 (0) | 2026.10.15 |
| 캐시 전략, 무효화와 스탬피드 대응 (0) | 2026.10.14 |
