CORS 에러 해결법 — 원인부터 환경별 설정까지

Access to fetch at 'http://localhost:5000/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.

Postman에서는 200이 잘 떨어지는데 브라우저에서만 이 에러가 뜬다. CORS 에러 해결은 서버에서 한다. 브라우저 보안 정책이 요청을 차단하는 것이고, 서버가 Access-Control-Allow-Origin 헤더를 응답에 실어 보내면 된다.

Express, React, Vite 환경별로 복붙해서 바로 쓸 수 있는 코드를 포함했다.

브라우저가 Cross-Origin 요청을 차단하고 서버 CORS 헤더로 허용하는 과정을 나타낸 다이어그램

이 에러가 뜨는 이유

Origin(출처)은 프로토콜 + 도메인 + 포트의 조합이다. 셋 중 하나라도 다르면 Cross-Origin 요청으로 분류된다.

http://localhost:3000  (React 개발 서버)
http://localhost:5000  (Express API 서버)
→ 포트가 다르므로 Cross-Origin

브라우저는 보안상 다른 출처의 리소스를 함부로 읽지 못하게 하는 동일 출처 정책(SOP)을 적용한다. CORS는 이 정책의 예외를 서버가 명시적으로 허용하는 메커니즘이다. 서버가 응답 헤더에 Access-Control-Allow-Origin을 포함하지 않으면 브라우저가 응답을 차단한다.

중요한 점은 이 차단이 브라우저에서 일어난다는 것이다. 그래서 Postman이나 curl은 이 제한이 없다. 서버는 정상 응답을 보냈지만 브라우저가 스크립트의 접근을 막는 것이다.

CORS 에러 해결의 열쇠는 서버에 있다. 클라이언트 코드를 아무리 수정해도 해결되지 않는다. 서버가 응답 헤더에 허용할 출처를 명시해야 브라우저가 응답 내용을 스크립트에 전달한다.

해결 방법 1: npm cors 패키지 (근본 해결)

가장 빠른 방법이다. Express 기준으로 패키지 하나면 끝난다.

npm install cors
const express = require('express');
const cors = require('cors');
const app = express();

// 개발 환경: 모든 출처 허용
app.use(cors());

// 운영 환경: 특정 도메인만 허용
app.use(cors({
  origin: 'https://myapp.com',
  credentials: true  // 쿠키/인증 헤더 포함 요청 허용
}));

app.use(cors())의 위치가 중요하다. 라우터 등록보다 반드시 먼저 위치해야 한다. 순서가 바뀌면 이미 등록된 라우터에는 CORS 헤더가 붙지 않는다.

// 잘못된 예 — cors보다 라우터가 먼저 등록됨
app.use('/api', router);
app.use(cors()); // 위 라우터에는 적용 안 됨

// 올바른 예
app.use(cors()); // 모든 라우터 선언보다 위에
app.use('/api', router);

해결 방법 2: 개발 환경 프록시 설정 (임시 해결)

서버 코드를 건드릴 수 없거나 개발 중에만 임시로 해결하고 싶을 때 쓴다.

React (CRA)

// package.json
{
  "proxy": "http://localhost:5000"
}

/api/data로 요청하면 CRA 개발 서버가 http://localhost:5000/api/data로 프록싱한다. 브라우저는 같은 출처에서 요청한 것처럼 인식하므로 CORS 에러가 안 뜬다.

Vite

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true
      }
    }
  }
}

주의할 점이 있다. 이 방법은 개발 서버 기능이다. 배포 환경에서는 동작하지 않는다. 로컬에서 해결됐다고 안심했다가 배포 후 동일한 CORS 에러가 다시 뜨는 경우가 정확히 여기서 생긴다. 배포 전에 서버 헤더 설정(해결 방법 1)으로 반드시 전환해야 한다. 서버 배포 전 확인 체크리스트도 함께 확인해두면 좋다.

Preflight(OPTIONS) 요청이 따로 막히는 경우

PUT, DELETE, Content-Type: application/json을 포함한 요청 전에 브라우저는 OPTIONS 요청을 먼저 날린다. 이를 Preflight 요청이라 한다. 서버가 OPTIONS 응답을 제대로 처리하지 않으면 본 요청도 전달되지 않는다.

Spring Boot나 커스텀 인증 필터를 쓰는 환경에서 자주 발생한다. 인증 필터가 CORS 필터보다 먼저 실행되면 OPTIONS 요청이 401을 반환하고, 브라우저는 Preflight 실패로 처리해서 CORS 에러를 띄운다.

Express에서 OPTIONS를 직접 처리해야 할 때:

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  // OPTIONS 요청에는 본 요청 없이 바로 200 반환
  if (req.method === 'OPTIONS') return res.sendStatus(200);
  next();
});

credentials 쓸 때 주의할 점

쿠키나 Authorization 헤더를 포함해서 요청할 때는 클라이언트에 credentials: 'include'를 설정한다. 이 경우 Access-Control-Allow-Origin: *(와일드카드)는 동작하지 않는다. 브라우저 스펙상 허용되지 않는 조합이다.

// 잘못된 예 — credentials와 와일드카드 조합은 에러
app.use(cors({
  origin: '*',
  credentials: true  // 이 조합은 브라우저가 차단
}));

// 올바른 예 — 특정 도메인을 명시
app.use(cors({
  origin: 'https://myapp.com',
  credentials: true
}));

클라이언트 쪽도 맞춰줘야 한다.

// fetch
fetch('http://api.com/data', {
  credentials: 'include'
});

// axios
axios.get('http://api.com/data', {
  withCredentials: true
});

자주 묻는 질문

Q. 서버 코드를 건드릴 수 없을 때는?

개발 환경이라면 프록시 설정(해결 방법 2)을 쓴다. 운영 환경에서 서버 코드 접근이 없다면 Nginx 레이어에서 헤더를 추가하는 방법이 있다.

location /api/ {
  add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
  add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
  add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

  if ($request_method = OPTIONS) {
    return 204;
  }

  proxy_pass http://backend:5000/;
}

Q. Access-Control-Allow-Origin: *으로 전체 허용해도 되나?

개발 환경에서는 괜찮다. 운영 환경에서는 특정 도메인을 명시하는 게 맞다. 와일드카드는 어떤 출처든 브라우저 CORS 보호를 우회할 수 있게 허용하는 것이다. 쿠키 기반 인증을 쓴다면 반드시 특정 도메인으로 제한해야 한다.

Q. Spring/Java 환경에서는?

@CrossOrigin 어노테이션이나 WebMvcConfigurer로 전역 설정한다. Spring Security를 쓴다면 CORS 설정이 Security 필터보다 먼저 실행되도록 CorsConfigurationSource를 Security 설정에 명시해야 한다. Preflight OPTIONS 요청이 인증 없이도 통과할 수 있어야 하기 때문이다.

한줄 정리: CORS 에러는 브라우저가 막는 거고, 서버가 Access-Control-Allow-Origin 헤더를 응답에 포함시키면 해결된다. 개발 환경 프록시로 해결했다면 배포 전에 서버 설정으로 전환하는 걸 잊지 말자.

댓글 남기기