CORS 에러는 왜 나나 — 서버는 200인데 브라우저가 막는 이유

@JavaPark · 2026년 8월 17일 · 14 min read

CORS 에러 커버 — 서버는 200을 응답했는데 브라우저가 차단하는 구조
CORS 에러 커버 — 서버는 200을 응답했는데 브라우저가 차단하는 구조

안녕하세요, 자바파커입니다.

콘솔에 이게 뜹니다. Access to fetch at 'https://api.example.com/users' from origin 'https://app.example.com' has been blocked by CORS policy

그런데 네트워크 탭을 보면 응답은 200입니다. Postman으로 쏴 보면 잘 됩니다.

결론부터 말씀드리면 — CORS 에러는 서버가 요청을 거부한 게 아닙니다. 서버는 정상적으로 응답했고, 브라우저가 그 응답을 자바스크립트에 넘겨주지 않은 것입니다. 이 한 줄을 이해하면 나머지가 전부 따라옵니다.


막는 건 서버가 아니라 브라우저다

흐름을 순서대로 보면 이렇습니다.

순서 일어나는 일
1 브라우저가 서버에 요청을 보냄
2 서버가 정상 처리하고 200 OK 응답
3 브라우저가 응답 헤더를 검사
4 허용 표시가 없으면 응답을 버리고 JS에는 에러를 던짐

요청은 이미 서버까지 갔고, 서버는 이미 처리했습니다. 그래서 POST /orders 였다면 주문은 생성돼 있을 수 있습니다. CORS는 요청을 막는 게 아니라 응답을 못 읽게 하는 것입니다.

여기서 두 가지 흔한 현상이 설명됩니다.

  • Postman·curl은 왜 되나 — CORS는 브라우저가 지키는 규칙입니다. Postman은 브라우저가 아니라서 검사 자체를 안 합니다
  • 네트워크 탭에 200이 찍히는데 왜 에러인가 — 네트워크 탭은 실제 통신을 보여주고, 콘솔 에러는 브라우저가 그 뒤에 내린 판정입니다

왜 막나 — 동일 출처 정책

브라우저에는 동일 출처 정책(Same-Origin Policy) 이 기본으로 켜져 있습니다. 다른 출처의 응답은 스크립트가 읽을 수 없다는 규칙입니다.

출처(Origin) = 프로토콜 + 호스트 + 포트, 세 개가 전부 같아야 같은 출처입니다.

기준이 https://app.example.com 일 때:

상대 URL 같은 출처? 이유
https://app.example.com/api 경로만 다름 — 출처에 경로는 안 들어감
http://app.example.com 프로토콜이 다름
https://api.example.com 호스트가 다름 (서브도메인도 남)
https://app.example.com:8443 포트가 다름

서브도메인도 다른 출처입니다. 실무에서 app.api. 로 나눠 놓고 CORS에 걸리는 게 이 이유입니다.

이 규칙이 없으면 무슨 일이 생기나

악성 사이트에 접속만 해도, 그 페이지의 스크립트가 여러분이 로그인해 둔 은행 사이트의 API를 여러분의 쿠키를 실어서 호출하고 응답까지 읽어갈 수 있습니다.

동일 출처 정책은 그걸 막는 기본 빗장이고, CORS는 그 빗장을 서버가 의도적으로 여는 방법입니다. 순서를 정확히 하면 —

브라우저는 기본이 차단이다. CORS는 보안을 추가하는 게 아니라 차단을 완화하는 규약이다.


서버가 여는 방법 — 응답 헤더

서버가 응답에 이 헤더를 붙이면 브라우저가 통과시킵니다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

브라우저는 요청의 Origin 헤더와 이 값을 비교해서, 맞으면 응답을 스크립트에 넘깁니다. 없으면 응답을 버립니다.

핵심은 이걸 붙이는 주체가 서버라는 점입니다. 프론트엔드 코드로는 고칠 수 없습니다.


Preflight — 요청이 왜 두 번 가나

네트워크 탭에 OPTIONS 요청이 하나 더 찍혀서 당황하신 적 있을 겁니다. 이게 사전 요청(preflight) 입니다.

브라우저는 "부작용이 있을 수 있는" 요청을 보내기 전에, 먼저 물어봅니다.

OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

서버가 허용하면 이렇게 답합니다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600

그다음에야 진짜 POST /users 가 나갑니다. 허용 안 되면 본 요청은 아예 보내지 않습니다.

언제 preflight가 붙나

아래 조건을 전부 만족하면 단순 요청이라 preflight 없이 바로 갑니다. 하나라도 벗어나면 붙습니다.

항목 단순 요청 조건
메서드 GET · HEAD · POST
Content-Type text/plain · multipart/form-data · application/x-www-form-urlencoded
커스텀 헤더 없음

Content-Type: application/json 하나만 붙어도 preflight가 붙습니다. 요즘 API는 사실상 전부 걸린다고 보시면 됩니다. Authorization 헤더도 마찬가지입니다.

Access-Control-Max-Age를 주면 브라우저가 그 시간만큼 preflight 결과를 캐시합니다. 안 주면 요청마다 왕복이 두 번이라 그대로 지연이 됩니다.


실무에서 반복해서 걸리는 5가지

여기가 이 글의 핵심입니다. 원리를 알아도 아래에서 막힙니다.

1. 인증 필터가 OPTIONS를 401로 막는다

가장 흔합니다. preflight의 OPTIONS 요청에는 토큰이 실려 있지 않습니다. 그런데 인증 필터가 모든 요청을 검사하면 401을 던지고, 브라우저는 "preflight 실패"로 판단해 본 요청을 아예 안 보냅니다.

증상이 고약합니다 — 콘솔에는 CORS 에러가 뜨는데 진짜 원인은 인증 설정입니다. OPTIONS는 인증에서 제외하세요.

2. 와일드카드와 쿠키를 같이 못 쓴다

fetch(url, { credentials: "include" }) // 쿠키를 실어 보냄

이 경우 서버가 Access-Control-Allow-Origin: * 을 주면 브라우저가 거부합니다. 스펙상 금지입니다.

Access-Control-Allow-Origin: https://app.example.com   ← 구체적인 출처
Access-Control-Allow-Credentials: true

출처를 명시하고 Allow-Credentials: true 를 같이 줘야 합니다. 그래서 쿠키를 쓰는 서비스는 허용 출처 목록을 관리해야 합니다.

3. 에러 응답에 CORS 헤더가 안 붙는다

서버가 500을 던질 때 예외 핸들러가 CORS 헤더를 안 붙이는 경우가 많습니다. 그러면 브라우저에는 500이 아니라 CORS 에러로 보입니다.

원인이 통째로 가려지는 케이스입니다. 진짜 문제는 서버 오류인데 CORS 설정만 계속 만지게 됩니다. 헤더는 에러 응답에도 붙어야 합니다.

4. 커스텀 헤더를 Allow-Headers에 안 넣는다

Authorization, X-Request-Id 같은 헤더를 보내려면 서버가 Access-Control-Allow-Headers그 이름을 명시해야 합니다. 요청에는 있는데 허용 목록에 없으면 preflight에서 막힙니다.

반대로 응답 헤더를 JS에서 읽으려면 Access-Control-Expose-Headers가 필요합니다. 페이징 정보를 커스텀 헤더로 내려주는데 프론트에서 undefined가 나온다면 이겁니다.

5. 게이트웨이와 애플리케이션이 헤더를 중복으로 붙인다

Nginx·API 게이트웨이에서 CORS를 설정해 놓고 애플리케이션에서도 설정하면, Access-Control-Allow-Origin두 번 붙습니다. 브라우저는 값이 여러 개인 걸 허용하지 않아서 그대로 실패합니다.

한 군데에서만 처리하세요. 보통은 게이트웨이가 낫습니다 — 서비스마다 중복 설정할 필요가 없어집니다. 게이트웨이가 앞단에서 어떤 일을 처리하는지는 API 게이트웨이가 하는 일에 정리해 뒀습니다.


프론트엔드에서는 고칠 수 없다

허용 헤더를 붙이는 건 서버이므로, 프론트 코드로는 해결이 안 됩니다. 상황별로 이렇게 갑니다.

상황 방법
로컬 개발 dev server proxy (Vite server.proxy, Next.js rewrites)
운영 서버 또는 게이트웨이에서 허용 출처 설정
서버를 못 건드림 우리 쪽 백엔드에 중계 엔드포인트를 둠

크롬을 --disable-web-security 로 띄우는 방법은 쓰지 마세요. 당장은 되지만 문제를 미루는 것이고, 그 상태로 개발하면 운영에서 그대로 터집니다.

Spring 설정 예시

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")  // * 아님 — 쿠키 쓰면 필수
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("Authorization", "Content-Type")
                .exposedHeaders("X-Total-Count")
                .allowCredentials(true)
                .maxAge(3600);                              // preflight 캐시
    }
}

Spring Security를 쓰신다면 시큐리티 필터 체인에도 CORS를 등록해야 합니다. WebMvcConfigurer만 설정하면 필터 단계에서 먼저 막혀서 앞의 1번 증상이 그대로 납니다.


자주 묻는 것

Q. CORS는 보안 기능인가요?

정확히는 보안을 완화하는 규약입니다. 브라우저의 기본값이 차단이고, CORS는 서버가 예외를 선언하는 방법입니다. 그래서 CORS를 열어 뒀다고 API가 안전해지는 게 아니고, 닫아 뒀다고 안전해지는 것도 아닙니다. 인가는 서버에서 따로 해야 합니다 — CORS는 브라우저 밖의 호출(스크립트, 서버 간 통신)을 전혀 막지 못합니다.

Q. 그냥 * 로 다 열면 안 되나요?

공개 API라면 괜찮습니다. 다만 쿠키·세션을 쓰는 API에는 스펙상 불가능하고, 그 경우 * 는 "아무 사이트나 사용자의 인증 정보로 내 API를 호출해도 된다"는 뜻이 되므로 열면 안 됩니다.

Q. preflight를 없앨 수 있나요?

Content-Type을 단순 요청 범위로 낮추면 사라지지만, JSON API에서는 현실적이지 않습니다. 대신 Access-Control-Max-Age로 캐시하는 게 정석입니다. 브라우저별 상한이 있어 무한정 늘어나진 않지만, 요청마다 왕복하는 것보다는 훨씬 낫습니다.


정리

  • CORS 에러는 서버가 거부한 게 아니라 브라우저가 응답을 안 넘겨준 것입니다
  • 요청은 서버까지 갔고 처리도 됐습니다 — Postman이 되는 이유가 이것입니다
  • 브라우저 기본값은 차단이고, CORS는 서버가 여는 방법입니다
  • Content-Type: application/json 하나만으로도 preflight가 붙습니다
  • 제일 흔한 함정은 인증 필터가 OPTIONS를 막는 것 — 콘솔에는 CORS 에러로 보입니다
  • 에러 응답에도 CORS 헤더를 붙여야 진짜 원인이 드러납니다

CORS 때문에 오래 헤매신 적 있으신가요? 어떤 경우였는지 댓글로 남겨 주시면 사례를 모아 정리해 보겠습니다.

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다