본문으로 건너뛰기 CORS 완벽 가이드 | 프리플라이트·자격 증명·동일 출처·프로덕션 패턴

CORS 완벽 가이드 | 프리플라이트·자격 증명·동일 출처·프로덕션 패턴

CORS 완벽 가이드 | 프리플라이트·자격 증명·동일 출처·프로덕션 패턴

이 글의 핵심

CORS는 브라우저가 다른 출처(origin)로의 읽기·자바스크립트 접근을 허용할지 결정하는 메커니즘입니다. 프리플라이트, 헤더 우선순위, credentials, 동일 출처 정책, 운영 환경 패턴까지 내부 동작을 이해하면 장애 대응이 빨라집니다.

들어가며

CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 서버가 “이 출처의 스크립트가 응답 본문을 읽어도 된다”고 브라우저에 알려 주는 HTTP 헤더 규약입니다. 서버가 허용하지 않으면, 브라우저는 네트워크 요청 자체를 막는 것이 아니라, 교차 출처 응답을 자바스크립트에 노출하지 않습니다(콘솔에는 CORS 정책 위반으로 표시).

이 글에서는 Express 설정 예제뿐 아니라 프리플라이트 알고리즘, 응답 헤더 해석 순서, credentials의 함의, 동일 출처 정책(SOP)과의 관계, 프로덕션에서 쓰는 패턴까지 정리합니다.

Origin이란

출처는 스킴 + 호스트 + 포트의 조합입니다.

https://example.com:443/api
└── https://example.com:443  ← origin (443은 생략 표기가 일반적)

다음은 서로 다른 출처로 취급됩니다.

  • http://localhost:3000http://localhost:8000 (포트 다름)
  • https://app.example.comhttps://api.example.com (호스트 다름)
  • http://example.comhttps://example.com (스킴 다름)

1. 동일 출처 정책(SOP)과 CORS의 관계

1.1 브라우저가 막는 것

동일 출처 정책은 기본적으로 한 문서의 스크립트가 다른 출처의 리소스에 마음대로 접근하지 못하게 합니다. 대표적으로:

  • DOM: 다른 창/iframe의 문서에 대한 접근 제한
  • Fetch/XHR: 교차 출처 응답 본문을 기본적으로 스크립트에 넘기지 않음(읽기 차단)

CORS는 이 제한을 서버가 명시적으로 완화할 때 사용됩니다. 즉 “SOP의 예외를 서버가 허가한다”는 계층입니다.

1.2 CORS가 적용되지 않는 경우

  • 서버↔서버(Node·curl·백엔드 간 gRPC/HTTP): 브라우저가 아니므로 CORS 검사 없음
  • 동일 출처 요청: CORS 헤더 없이도 응답 읽기 가능
  • 단순 GET으로 이미지·스크립트 태그 로드 등: “읽기”의 의미가 Fetch API와 다름(스크립트가 픽셀 데이터를 읽는 등은 별도 제약)

실무에서 “API는 되는데 브라우저만 안 된다”는 말은 대부분 CORS 또는 쿠키/SameSite 이슈입니다.


2. 프리플라이트(preflight) 메커니즘

교차 출처 요청이 “단순 요청(simple request)”이 아니면, 브라우저는 먼저 OPTIONS 프리플라이트를 보냅니다. 서버가 허용 범위를 응답하면 그다음 실제 메서드(POST, PUT 등)를 전송합니다.

2.1 단순 요청의 조건(개략)

다음을 모두 만족하면 프리플라이트 없이 바로 본 요청이 갑니다(메서드·헤더·Content-Type 제한).

  • 메서드: GET, HEAD, POST 중 하나
  • 헤더: Accept, Accept-Language, Content-Language, Content-Type허용 목록
  • Content-Type이 단순 타입: application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나

JSON 본문(application/json)은 단순 요청이 아니므로 일반적으로 프리플라이트가 발생합니다.

2.2 프리플라이트 요청에 실리는 정보

브라우저가 보내는 OPTIONS에는 대략 다음이 포함됩니다.

  • Origin: 요청 페이지의 출처
  • Access-Control-Request-Method: 실제로 보내려는 메서드(예: PUT)
  • Access-Control-Request-Headers: 실제로 보내려는 “비단순” 헤더 목록(예: authorization, x-request-id)

서버는 프리플라이트 응답으로 허용 범위를 실행하여줍니다.

2.3 프리플라이트 응답에 필요한 헤더

  • Access-Control-Allow-Origin: 허용 출처(또는 * — 단, credentials 조합 시 제한)
  • Access-Control-Allow-Methods: 허용 HTTP 메서드
  • Access-Control-Allow-Headers: 허용 요청 헤더
  • Access-Control-Max-Age: 프리플라이트 결과 캐시 시간(초). 반복 OPTIONS 부하 감소

프리플라이트가 성공해야 브라우저가 실제 요청을 이어갑니다. 프리플라이트가 실패하면 본 요청은 보내지 않거나, 보내도 응답을 스크립트에 공개하지 않습니다.

2.4 운영 시 자주 나는 실수

  • 라우터에 OPTIONS가 404: 프리플라이트 실패. 프레임워크 CORS 미들웨어가 전역으로 OPTIONS를 처리하는지 확인합니다.
  • 역프록시가 OPTIONS를 앱 전에 삼킴: Nginx·API Gateway에서 OPTIONS를 빠르게 204로 끝내되, 동일한 CORS 헤더를 맞춰야 합니다.
  • 와일드카드 메서드/헤더: 가능하면 명시적 목록을 권장합니다(공격 면·디버깅 모두 유리).

3. CORS 응답 헤더의 해석과 “우선순위”

스펙은 “여러 개의 충돌하는 Access-Control-Allow-Origin을 보내지 말라”는 쪽입니다. 실무에서는 하나의 일관된 응답을 만들어야 합니다.

3.1 실제 요청 응답에서 중요한 헤더

헤더역할
Access-Control-Allow-Origin어떤 출처의 스크립트가 응답을 읽을 수 있는지
Access-Control-Allow-Credentials쿠키·인증 정보 노출 허용 시 true
Access-Control-Expose-HeadersJS가 response.headers로 읽을 수 있게 추가로 노출할 응답 헤더
Vary캐시/CDN이 출처별로 다른 응답을 갖도록 할 때 Origin 포함

3.2 Vary: Origin이 필요한 이유

Access-Control-Allow-Origin요청의 Origin을 그대로 반사(reflect)하는 패턴(허용 목록에 있을 때만)을 쓰면, 응답 내용이 출처마다 달라질 수 있습니다. 공유 캐시가 잘못된 CORS 헤더를 다른 사용자에게 주지 않도록 Vary: Origin을 함께 쓰는 것이 안전합니다.

3.3 여러 번 설정되면?

미들웨어·역프록시·WAF가 각각 CORS 헤더를 붙이면 중복·불일치가 생깁니다. 디버깅 순서는 다음이 좋습니다.

  1. 브라우저 개발자 도구 Network에서 최종 응답 헤더 확인
  2. 역프록시 한 단계씩 제거하며 한 곳에서만 CORS를 설정
  3. OPTIONS와 본 요청 둘 다 동일 정책인지 확인

4. Credentials 모드의 함의

fetch(..., { credentials: 'include' }) 또는 XMLHttpRequest.withCredentials = true일 때 쿠키·Authorization·TLS 클라이언트 인증서 등이 교차 출처로 실릴 수 있는 조건이 붙습니다.

4.1 서버 조건

  • Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Origin반드시 와일드카드 *가 아닌, 요청 Origin과 맞는 구체 값
  • (필요 시) Access-Control-Allow-HeadersAuthorization 등 명시

4.2 클라이언트 조건

  • credentials: 'include' (기본값 same-origin인 경우가 많아 명시 누락이 흔한 원인)

4.3 쿠키와의 연동

쿠키가 교차 출처로 실리려면 CORS뿐 아니라 쿠키 속성도 맞아야 합니다.

  • SameSite=None이면 보통 Secure 필수(HTTPS)
  • Domain·Path가 API 도메인과 맞는지

“CORS만 맞췄는데 쿠키가 안 간다”SameSite·도메인·Secure 점검이 필요합니다.


5. Express에서의 기본 설정

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

const app = express();

// 개발 편의용 — 프로덕션에서는 출처 제한 권장
app.use(cors());

app.get('/api/users', (req, res) => {
  res.json({ users: ['Alice', 'Bob'] });
});

app.listen(8000);

5.1 출처 허용 목록

const allowedOrigins = [
  'http://localhost:3000',
  'https://example.com',
  'https://www.example.com',
];

app.use(cors({
  origin: (origin, callback) => {
    // Origin 없음: 같은 출처·일부 비브라우저 클라이언트
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
}));

5.2 메서드·헤더·노출 헤더·프리플라이트 캐시

app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
  credentials: true,
  maxAge: 86400,
}));

5.3 수동 OPTIONS (특정 경로만 열 때)

app.options('/api/users', cors());
app.put('/api/users/:id', cors(), (req, res) => {
  /* ... */
});

6. 흔한 오류와 대응

6.1 Access-Control-Allow-Origin 없음

CORS 미들웨어 미적용 또는 역프록시가 헤더를 제거한 경우입니다.

6.2 credentials: trueorigin: '*' 동시 사용

브라우저가 허용하지 않습니다. 허용 목록 + 반사 패턴을 씁니다.

6.3 메서드·헤더 누락

프리플라이트에서 Access-Control-Request-Method / Request-Headers와 서버의 Allow-Methods / Allow-Headers가 맞지 않으면 실패합니다.

6.4 커스텀 응답 헤더를 JS에서 못 읽음

기본적으로 일부 안전한 응답 헤더만 노출됩니다. 나머지는 Access-Control-Expose-Headers에 나열합니다.


7. 프로덕션 CORS 패턴

7.1 단일 API 도메인 + 명시적 허용 목록

가장 흔한 패턴입니다. 스테이징·프로덕션 도메인을 환경 변수로 나눕니다.

const allowed = (process.env.CORS_ORIGINS || '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean);

app.use(cors({
  origin: (origin, cb) => {
    if (!origin) return cb(null, true);
    cb(null, allowed.includes(origin));
  },
  credentials: true,
  maxAge: 86400,
}));

7.2 BFF(Backend for Frontend)와 동일 출처

브라우저 → 같은 출처의 Next/Astro API 라우트 → 내부망 API 호출이면 브라우저 관점에서는 동일 출처라 CORS 부담이 줄어듭니다. 대신 BFF 인증·쿠키 모델을 일관되게 설계해야 합니다.

7.3 API 게이트웨이·CDN에서 CORS 일원화

Cloudflare Workers, AWS API Gateway, Kong 등에서 CORS를 고정하면 애플리케이션 코드 중복을 줄일 수 있습니다. 이때 앱과 게이트웨이 이중 헤더에 주의합니다.

7.4 공개 읽기 전용 API

진짜로 누구나 읽어도 되는 공개 데이터라면 Access-Control-Allow-Origin: *가 가능하지만, 쿠키·세션 기반 인증과는 함께 쓸 수 없습니다.

7.5 관측 가능성

Origin 차단 시 구조화된 로그(출처, 경로, 프리플라이트 여부)를 남기면 운영 대응이 빨라집니다. 다만 개인정보가 될 수 있는 값은 마스킹 정책을 따릅니다.


8. curl로 프리플라이트 시뮬레이션

curl -i -X OPTIONS "http://localhost:8000/api/users" \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

응답에 Access-Control-Allow-Origin, Allow-Methods, Allow-Headers가 기대와 일치하는지 확인합니다.


요약

  • SOP가 기본 차단, CORS가 서버 허가 하에 교차 출처 읽기를 허용합니다.
  • 단순 요청이 아니면 OPTIONS 프리플라이트가 먼저 갑니다. OPTIONS 처리와 헤더 일치가 핵심입니다.
  • 헤더는 한 곳에서 일관되게 — 중복·충돌을 피하며, 반사 모델이면 Vary: Origin을 고려합니다.
  • credentials: trueAllow-Origin: *와 호환되지 않으며, 쿠키는 SameSite·Secure·도메인까지 봐야 합니다.
  • 프로덕션에서는 허용 목록·환경 분리·게이트웨이 일원화·BFF 같은 패턴으로 복잡도와 사고 면을 줄입니다.

참고 자료


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. CORS의 내부 동작: 프리플라이트 OPTIONS, 응답 헤더 해석, credentials와 쿠키, 동일 출처 정책, Express·프로덕션 허용 목록 패턴까지 전문가 수준으로 정리합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

CORS, 보안, HTTP, 브라우저, Express, Node.js, 웹보안 등으로 검색하시면 이 글이 도움이 됩니다.