CORS 제대로 이해하기 — 삽질 없이 설정하는 법

게시일: 2025년 11월 11일 · 13분 읽기

CORS 에러가 반복되는 이유

많은 개발자들이 CORS 에러에 시달린다. 해결책을 구글링해서 Access-Control-Allow-Origin: *을 쓰거나, 프록시를 만든다. 하지만 근본 원인을 모르면 계속 같은 문제에 걸린다. CORS(Cross-Origin Resource Sharing)는 보안 정책이자 동시에 웹 개발의 필수 요소다. 경험상 원리만 이해하면 대부분의 CORS 문제는 5분 만에 해결된다.

Same-Origin Policy — 왜 이 정책이 필요한가?

브라우저에는 Same-Origin Policy가 있다. 한 origin에서 로드된 웹 페이지는 다른 origin의 리소스에 접근할 수 없다는 정책이다. Origin은 protocol + domain + port의 조합이다. https://example.com과 https://api.example.com은 다른 origin이다(subdomain이 다르므로). https://example.com:3000과 https://example.com은 다른 origin이다(port가 다르므로).

이 정책이 없으면 악의적인 웹사이트가 사용자의 은행 계좌에 접근할 수 있다. 만약 사용자가 은행 계좌에 로그인한 상태라면, 그 세션 쿠키를 이용해 계좌 이체 요청을 보낼 수 있다. 따라서 같은 origin의 요청만 가능하게 제한하는 것이 필수다.

Preflight Request — 브라우저가 자동으로 하는 보안 체크

특정 요청(복잡한 요청)에는 preflight 요청이 앞선다. 예를 들어 POST로 JSON을 보낼 때:

// 브라우저가 자동으로 먼저 이 요청을 보냄
OPTIONS /api/data HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

// 서버의 응답이 OK면 실제 요청을 보냄
POST /api/data HTTP/1.1
Content-Type: application/json
Origin: https://example.com
Body: {data...}

서버가 preflight 요청에 제대로 응답하지 않으면 CORS 에러가 난다.

Access-Control 헤더 — 어떤 origin을 허용할 것인가?

서버는 다음 헤더로 CORS를 제어한다:

// 어떤 origin이 접근 가능한가?
Access-Control-Allow-Origin: https://example.com
// 또는 모든 origin 허용 (권장하지 않음)
Access-Control-Allow-Origin: *

// 어떤 HTTP 메서드를 허용할 것인가?
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

// 어떤 헤더를 허용할 것인가?
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header

// 브라우저가 응답 헤더에 접근할 수 있나?
Access-Control-Expose-Headers: X-Total-Count, X-Page-Count

// 인증 정보(쿠키, Authorization 헤더)를 포함할 것인가?
Access-Control-Allow-Credentials: true

// Preflight 요청을 캐시할 시간 (초)
Access-Control-Max-Age: 3600

Credentials 모드 — 쿠키를 보낼 것인가?

CORS 요청에서 인증 정보를 포함할지 결정하는 중요한 설정이다:

// 클라이언트 측 (JavaScript)
fetch('/api/data', {
  method: 'POST',
  credentials: 'include',  // 쿠키 포함
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({...})
})

// 이 경우 서버의 응답에 반드시 포함되어야 함:
// Access-Control-Allow-Credentials: true
// (Access-Control-Allow-Origin: * 는 불가능, 명시적 origin 필요)

credentials: 'include'를 쓰면서 Allow-Origin: *를 쓰면 CORS 에러가 난다. 이 조합은 불가능하다.

실제 설정 예제

Express에서:

const cors = require('cors');

// 단순: 모든 origin 허용 (개발용)
app.use(cors());

// 실무: 특정 origin만 허용
const allowedOrigins = ['https://example.com', 'https://app.example.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (allowedOrigins.includes(origin) || !origin) {  // !origin은 curl 요청
      callback(null, true);
    } else {
      callback(new Error('CORS not allowed'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 3600
}));

// Preflight 요청 처리 (cors 미들웨어가 자동 처리)
app.options('/api/:path', cors());

또는 FastAPI에서:

from fastapi.middleware.cors import CORSMiddleware

allowed_origins = [
    'https://example.com',
    'https://app.example.com',
    'http://localhost:3000'
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,
    allow_methods=['GET', 'POST', 'PUT', 'DELETE'],
    allow_headers=['Content-Type', 'Authorization'],
    max_age=3600
)

개발용 프록시 — 로컬에서 CORS 우회하기

로컬 개발할 때 백엔드 API가 다른 포트에 있으면 CORS 문제가 난다. 프록시를 사용해서 우회할 수 있다:

// Next.js: next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:8000/api/:path*'
      }
    ]
  }
}

// 클라이언트 코드는 /api를 그대로 사용
fetch('/api/data')  // http://localhost:3000/api/data로 요청되지만
                     // 서버에서 http://localhost:8000/api/data로 프록시됨

또는 Create React App: .env에 PROXY 설정:

REACT_APP_API_URL=http://localhost:8000

CORS와 보안

CORS는 단순히 개발자의 불편함을 위한 정책이 아니다. 보안이 핵심이다. 따라서:

결론

CORS 에러는 보안 정책 때문이고, 이 정책은 필요하다. 원리만 이해하면 설정은 매우 간단하다. 앞으로 CORS 에러를 만나면 먼저 origin이 다른지 확인하고, preflight 요청이 제대로 응답하는지 브라우저의 Network 탭에서 확인하자.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.