Published on

CORS 에러 원인과 서버 설정으로 확실히 해결하는 방법

Authors

서론

프론트엔드에서 API를 붙이다 보면 콘솔에 빨간 글씨로 뜨는 단골 에러가 있습니다. Access to fetch at ... from origin ... has been blocked by CORS policy.

처음엔 “서버가 죽었나?”, “프록시가 꼬였나?” 싶지만, 정작 서버 로그는 200을 찍거나 아예 요청이 도달하지 않는 경우도 많습니다. CORS는 단순히 “다른 도메인이라서 막힘”이 아니라, 브라우저의 보안 모델서버가 내려주는 응답 헤더가 맞물려서 생기는 문제입니다.

이 글에서는 CORS가 왜 생기는지, 어떤 경우에 프리플라이트(OPTIONS)가 발생하는지, 그리고 Node/Express, Nginx, Spring, Django 등에서 실제로 통하는 서버 설정 해결책을 코드와 함께 정리합니다.

CORS는 무엇이고 왜 브라우저만 신경 쓸까

CORS(Cross-Origin Resource Sharing)는 “교차 출처 리소스 공유”입니다. 핵심은 다음 한 줄입니다.

  • 브라우저는 기본적으로 다른 Origin(출처)의 리소스 요청을 제한한다.

여기서 Origin은 보통 아래 3가지 조합입니다.

  • 프로토콜(https/http)
  • 호스트(example.com)
  • 포트(443/3000 등)

예:

  • https://app.example.comhttps://api.example.com 요청은 Cross-Origin
  • http://localhost:5173http://localhost:8080 요청도 Cross-Origin

중요한 포인트:

  • CORS는 브라우저에서만 강제됩니다.
  • 서버-서버 통신(cURL, Postman, 백엔드 간 호출)은 CORS에 막히지 않습니다.

그래서 “Postman에서는 되는데 브라우저에서만 안 돼요”가 CORS의 전형적인 증상입니다.

CORS 에러의 진짜 원인 6가지

1) 서버가 Access-Control-Allow-Origin을 안 내려줌

브라우저는 응답에 아래 헤더가 없으면 차단합니다.

  • Access-Control-Allow-Origin: https://app.example.com

또는 개발 단계에서 임시로:

  • Access-Control-Allow-Origin: *

단, *인증 정보 포함 요청(쿠키/Authorization 등)과 함께 쓰면 안 됩니다(아래 3번 참고).

2) Origin이 정확히 매칭되지 않음 (http/https, 포트 포함)

서버가 https://app.example.com만 허용해 놓고, 실제 요청은 http://app.example.com이면 실패합니다.

로컬에서 흔한 케이스:

  • 허용: http://localhost:3000
  • 실제: http://localhost:5173 (Vite)

Origin은 문자열로 “정확히” 비교되는 경우가 많아, 포트까지 포함해서 맞춰야 합니다.

3) credentials 요청인데 Allow-Origin에 *를 사용함

다음 조건이면 브라우저는 credentials 요청으로 취급합니다.

  • fetch(url, { credentials: 'include' })
  • axios에서 withCredentials: true
  • 또는 Authorization 헤더를 붙이는 패턴(서버 설정에 따라 프리플라이트 유발)

이때 서버가 아래처럼 응답하면 실패합니다.

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Credentials: true

이 조합은 스펙상 불가입니다. credentials를 허용하려면:

  • Access-Control-Allow-Origin: https://app.example.com (구체적 Origin)
  • Access-Control-Allow-Credentials: true

4) 프리플라이트(OPTIONS)를 서버가 처리하지 못함

브라우저는 “위험할 수 있는 요청”을 보내기 전에 OPTIONS 요청으로 사전 확인합니다.

프리플라이트가 발생하는 대표 조건:

  • 메서드가 GET/HEAD/POST가 아닌 경우(PUT, PATCH, DELETE)
  • Content-Type: application/json (일반적으로 프리플라이트 유발)
  • 커스텀 헤더(예: Authorization, X-Requested-With) 포함

서버가 OPTIONS에 404/405를 내거나 CORS 헤더 없이 응답하면 브라우저는 본 요청을 보내지 않습니다.

5) 리다이렉트(301/302) 경유로 CORS가 깨짐

예를 들어 API가:

  • http://api.example.comhttps://api.example.com로 301 리다이렉트

이 과정에서 프리플라이트/본 요청의 CORS 헤더가 누락되거나, 브라우저가 리다이렉트를 CORS 정책상 제한하는 케이스가 있습니다. API는 가급적 최종 URL로 직접 호출되게 구성하세요.

6) CDN/프록시(Nginx, CloudFront)가 헤더를 덮어씀

애플리케이션 서버에서는 CORS 헤더를 잘 내려주는데, 앞단 프록시가:

  • 헤더를 제거하거나
  • OPTIONS를 다른 업스트림으로 보내거나
  • 캐시된 응답을 CORS 헤더 없이 반환

이러면 브라우저에서는 여전히 CORS 에러가 납니다.

빠르게 진단하는 방법 (브라우저에서 무엇을 봐야 하나)

1) Network 탭에서 OPTIONS 요청을 먼저 찾기

크롬 개발자 도구에서 Network를 열고, 실패한 요청을 클릭해 다음을 봅니다.

  • Request Headers → Origin
  • Response Headers → Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Allow-Credentials
  • Status code가 200/204인지, 404/405인지

DOM/네트워크 분석이 익숙하지 않다면 개발자 도구 사용 흐름을 먼저 잡는 게 좋습니다.

2) “CORS 에러”와 “서버 에러”를 분리해서 생각하기

CORS는 브라우저가 차단하는 것이므로, 콘솔에 CORS가 떠도 서버는 500을 냈을 수 있고, 반대로 서버는 200인데도 브라우저가 차단할 수 있습니다.

API가 실제로 응답하는지 확인하려면 상태 코드 점검이 도움이 됩니다.

3) curl로 CORS 헤더 확인하기

브라우저 대신 curl로 “서버가 어떤 CORS 헤더를 주는지” 확인해보면 원인이 빨리 좁혀집니다.

curl -i https://api.example.com/v1/users \
  -H "Origin: https://app.example.com"

프리플라이트 확인:

curl -i -X OPTIONS https://api.example.com/v1/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization"

해결책의 핵심: 서버가 내려줘야 할 CORS 헤더 세트

기본적으로 다음을 맞추는 게 출발점입니다.

  • Access-Control-Allow-Origin: 허용할 Origin
  • Access-Control-Allow-Methods: 허용할 메서드
  • Access-Control-Allow-Headers: 허용할 헤더
  • Access-Control-Allow-Credentials: credentials 허용 여부
  • (프리플라이트 최적화) Access-Control-Max-Age

프리플라이트(OPTIONS)는 보통 204 No Content로 빠르게 응답하는 패턴이 많습니다.

서버/프레임워크별 설정 예제

Node.js Express cors 미들웨어

가장 안전한 실무 패턴은 “허용 Origin 리스트 기반”입니다.

import express from 'express';
import cors from 'cors';

const app = express();

const allowlist = [
  'http://localhost:5173',
  'https://app.example.com'
];

app.use(cors({
  origin: (origin, cb) => {
    // same-origin 또는 서버-서버 호출처럼 Origin이 없는 경우도 있어 허용 처리
    if (!origin) return cb(null, true);
    if (allowlist.includes(origin)) return cb(null, true);
    return cb(new Error('Not allowed by CORS'));
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400
}));

app.options('*', cors()); // 프리플라이트 명시 처리

app.get('/health', (req, res) => res.json({ ok: true }));

app.listen(8080);

트러블슈팅:

  • credentials: true인데 origin: '*'를 쓰면 실패합니다.
  • 프리플라이트가 404/405면 app.options('*', cors()) 또는 라우터 앞단에서 OPTIONS 처리 여부를 확인하세요.

Nginx에서 CORS 헤더 추가 (프록시 레이어)

애플리케이션 서버를 건드리기 어렵거나, 정적 파일/API를 Nginx가 직접 서빙할 때 유용합니다.

location /api/ {
    proxy_pass http://backend:8080/;

    # 허용 Origin을 동적으로 반사(reflect)하는 방식은 편하지만 보안에 주의
    # 운영에서는 map으로 allowlist를 구성하는 것을 권장
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
    add_header 'Access-Control-Max-Age' 86400 always;

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

Best Practice:

  • $http_origin을 그대로 반사하면 사실상 “모든 Origin 허용”이 됩니다. 운영에서는 map으로 허용 목록을 만들고, 매칭될 때만 헤더를 내려주세요.
  • add_header ... always;를 붙여야 4xx/5xx 응답에도 헤더가 붙어 디버깅이 쉬워집니다.

Spring Boot (Spring MVC) 전역 CORS 설정

컨트롤러마다 @CrossOrigin을 붙이는 방식은 규모가 커지면 관리가 어렵습니다. 전역 설정을 권장합니다.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173", "https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(86400);
    }
}

트러블슈팅:

  • Spring Security를 쓰면 MVC의 CORS 설정이 무시되는 경우가 있습니다. 이때는 Security 설정에서 http.cors() 활성화 및 CorsConfigurationSource를 추가해야 합니다.

Django (django-cors-headers) 설정

Django는 라이브러리를 쓰는 게 일반적입니다.

pip install django-cors-headers

settings.py:

INSTALLED_APPS = [
    # ...
    'corsheaders',
    # ...
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    # ...
]

CORS_ALLOWED_ORIGINS = [
    'http://localhost:5173',
    'https://app.example.com',
]

CORS_ALLOW_CREDENTIALS = True

CORS_ALLOW_HEADERS = [
    'content-type',
    'authorization',
]

주의:

  • 미들웨어 순서가 중요합니다. CorsMiddleware가 상단에 있어야 헤더가 정상적으로 붙습니다.

프론트엔드에서 자주 하는 실수와 해결

fetch/axios에서 credentials를 켰는데 서버는 쿠키를 안 받는 경우

프론트:

fetch('https://api.example.com/api/me', {
  credentials: 'include'
});

서버는:

  • Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Origin: https://app.example.com

을 내려야 하고, 쿠키 기반이면 추가로:

  • 쿠키에 SameSite=None; Secure가 필요할 수 있습니다(특히 크로스 사이트 쿠키).

개발 환경에서 Vite/Next 프록시로 우회하는 방법(임시)

CORS는 “브라우저가 다른 Origin으로 요청할 때” 발생하므로, 개발 서버에서 프록시로 같은 Origin처럼 보이게 만들면 CORS를 피할 수 있습니다.

Vite 예:

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

이 방식은 개발 생산성은 좋지만, 운영에서 해결책이 되진 않습니다. 운영은 서버 CORS 정책을 명확히 설정해야 합니다.

실무 Best Practice 체크리스트

  • 허용 Origin은 *보다 allowlist를 사용한다.
  • credentials(쿠키/세션)가 필요하면 Allow-Origin은 반드시 구체적인 Origin으로.
  • OPTIONS 프리플라이트는 204로 빠르게 처리하고, Access-Control-Max-Age로 캐시한다.
  • 프록시/로드밸런서/CDN이 CORS 헤더를 덮어쓰는지 확인한다.
  • 로컬/스테이징/운영 환경별 Origin이 다르므로 환경 변수로 관리한다.
  • “브라우저에서만 실패”하면 CORS를 의심하고, Network 탭에서 OPTIONS부터 확인한다.

결론

CORS 에러는 프론트 문제가 아니라, 브라우저 보안 정책 + 서버의 CORS 응답 헤더 + 프리플라이트 처리가 어긋날 때 발생합니다. 해결의 핵심은 (1) 정확한 Origin 매칭, (2) credentials 사용 시 * 금지, (3) OPTIONS 요청을 정상 처리하는 것입니다.

Express/Nginx/Spring/Django 등 어떤 스택이든 원리는 동일하니, 먼저 Network 탭에서 OPTIONS와 응답 헤더를 확인하고, 그 다음 서버 설정을 allowlist 기반으로 정리하면 재발을 크게 줄일 수 있습니다.