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

[2026] 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가 기대와 일치하는지 확인합니다.


내부 동작과 핵심 메커니즘

이 글의 주제는 「[2026] CORS 완벽 가이드 | 프리플라이트·자격 증명·동일 출처·프로덕션 패턴」입니다. 앞선 튜토리얼을 구현·런타임 관점에서 다시 압축합니다. 요청 경로와 상태 전이를 기준으로 “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)·동시성이 어디서 터지는가”를 한 장면으로 그리면 장애 분석이 빨라집니다.

처리 파이프라인(개념도)

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]

경계에서의 지연·실패(시퀀스 관점)

sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(프로세스·런타임·게이트웨이)
  participant D as 의존성(외부 API·DB·큐)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)

알고리즘·프로토콜·리소스 관점 체크포인트

  • 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(버퍼 경계, 프로토콜 상태, 트랜잭션 격리, 파일 디스크립터 상한)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 동일 입력에 동일 출력이 보장되는 순수 층과, 시간·네트워크·스레드 스케줄에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합, GC·할당, 캐시 미스처럼 누적 비용을 의심 목록에 넣습니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때(소켓 버퍼, 큐 깊이, 스트림) 어디서 어떤 신호로 속도를 줄일지 정의합니다.

프로덕션 운영 패턴

실서비스에서는 기능과 함께 관측·배포·보안·비용·규제가 동시에 요구됩니다.

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율/지연 분위수(p95/p99), 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시 계층·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션 호환성·플래그가 문서화되어 있는가
용량피크 트래픽·디스크·파일 디스크립터·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 가능한 한 프로덕션에 가깝게 맞추는 것이 재현율을 높입니다.


확장 예시: 엔드투엔드 미니 시나리오

「[2026] CORS 완벽 가이드 | 프리플라이트·자격 증명·동일 출처·프로덕션 패턴」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값이 기대 범위인지 본다.

의사코드 스케치(프레임워크 무관)

handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)        // 경계에서 거절
  authorize(validated, ctx)                  // 권한·테넌트
  result = domainCore(validated)             // 순수에 가까운 규칙
  persistOrEmit(result, idempotentKey)       // I/O: 멱등·재시도 정책
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성 불안정, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정이 로컬과 다름프로필·시크릿·기본값, 지역 리전단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

요약

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

참고 자료