CORS 오설정이 만드는 보안 구멍: Origin 검증 실패 패턴과 프로덕션 방어 설계

CORS 오설정은 조용하게, 치명적으로 침투한다
CORS(Cross-Origin Resource Sharing)는 처음 마주치는 순간 대부분 개발자에게 "브라우저가 왜 요청을 막는 거지?"라는 짜증으로 다가옵니다. 그래서 가장 빠른 해결책인 Access-Control-Allow-Origin: *를 붙이고 문제를 덮어버립니다. 그 순간 브라우저 콘솔의 에러는 사라지지만, 보안 구멍이 열립니다.
우리 팀은 스테이지 서버에서 정확히 이 실수를 저질렀습니다. 빠른 QA를 위해 Access-Control-Allow-Origin: *를 임시로 열었는데, 쿠키 기반 인증을 같이 사용하고 있었기 때문에 Access-Control-Allow-Credentials: true가 충돌하면서 인증이 깨졌습니다. 처음에는 원인을 찾지 못했고, 결국 네트워크 탭을 뜯어보고 나서야 두 헤더의 조합이 명세상 허용되지 않는다는 것을 확인했습니다. 그 사건 이후 우리 팀은 CORS 정책을 "편의 설정"이 아니라 "보안 계약"으로 다루기 시작했습니다.
WHATWG Fetch 명세의 CORS 프로토콜 섹션은 브라우저가 교차 출처 요청을 처리하는 방식을 정의합니다. 이 글은 그 명세에서 출발해, 실제 프로덕션에서 반복되는 오설정 패턴을 분석하고 안전한 방어 설계를 제시합니다.
1. CORS가 브라우저 정책이지 서버 보안이 아닌 이유
Same-Origin Policy(SOP)는 브라우저가 다른 출처의 응답을 읽지 못하도록 막는 정책입니다. https://app.example.com에서 실행된 JavaScript가 https://api.example.com의 응답 본문을 읽으려 하면, 브라우저가 이를 차단합니다. 여기서 핵심은 브라우저가 차단한다는 점입니다. 서버는 요청을 정상적으로 받아 처리하고 응답을 돌려보내지만, 브라우저가 그 응답을 JavaScript에게 전달하지 않습니다.
CORS는 이 제한을 서버가 명시적으로 완화할 수 있도록 하는 메커니즘입니다. 서버가 Access-Control-Allow-Origin 헤더를 응답에 포함시키면, 브라우저는 그 값을 검사해 현재 페이지의 출처와 일치할 때만 응답 내용을 JavaScript에 넘겨줍니다.
이 구조에서 중요한 함의가 있습니다. curl이나 서버 간 통신은 CORS의 영향을 받지 않습니다. 브라우저 밖에서 이루어지는 HTTP 요청에는 SOP도, CORS도 적용되지 않습니다. 따라서 Access-Control-Allow-Origin: *를 설정해도 서버 간 API 공격은 막을 수 없습니다. CORS는 브라우저 사용자의 인증 정보가 악의적인 사이트에 의해 도용되는 것을 방지하는 장치이지, 서버를 보호하는 방화벽이 아닙니다.
MDN의 CORS 문서는 이를 명확히 설명합니다. CORS는 Same-Origin Policy가 허용하는 것보다 더 많은 권한을 서버가 명시적으로 부여하는 방식으로 동작하며, 그 판단의 주체는 항상 브라우저입니다.
이 구분을 이해해야 CORS 오설정이 왜 보안 문제인지 설명할 수 있습니다. 악성 사이트(https://evil.example)에 방문한 사용자가 그 사이트의 JavaScript로 https://api.yourservice.com에 인증 쿠키와 함께 요청을 보낼 때, 서버가 Access-Control-Allow-Origin: https://evil.example을 허용하면 응답 데이터가 공격자의 스크립트에 전달됩니다. 이것이 CORS 기반 Cross-Site Request 데이터 탈취의 기본 구조입니다.
2. 우리 팀이 마주친 오설정 3가지 패턴
실무에서 반복되는 CORS 오설정은 생각보다 단순한 실수에서 비롯됩니다. 세 가지 패턴으로 정리할 수 있습니다.
패턴 1: 와일드카드 + Credentials 조합. 가장 많이 보이는 오설정입니다. Access-Control-Allow-Origin: *와 Access-Control-Allow-Credentials: true를 동시에 설정합니다. WHATWG Fetch 명세는 이 조합을 명시적으로 금지하고, 브라우저는 Credentials를 포함한 요청에서 이 응답을 거부합니다. 문제는 일부 레거시 프레임워크나 잘못 작성된 미들웨어가 이 두 헤더를 독립적으로 관리해, 설정 충돌이 발생해도 런타임 에러 없이 조용히 지나간다는 점입니다.
패턴 2: Origin 에코(echo). 요청의 Origin 헤더 값을 그대로 Access-Control-Allow-Origin에 반사하는 구현입니다. 검증 없이 반사하면 어떤 출처든 허용하는 것과 동일합니다. 화이트리스트를 관리하기 귀찮아서, 또는 "어차피 서버에서 인증을 하니까 괜찮겠지"라는 생각에서 비롯됩니다. PortSwigger의 CORS 취약점 분석에서는 이를 "Reflecting origins in the Access-Control-Allow-Origin header" 패턴으로 분류하고, 대규모 서비스에서도 실제로 발견된 사례를 기술하고 있습니다.
패턴 3: 서브도메인 포함 여부를 정규식으로 잘못 처리. example.com을 허용하려는 의도로 .*example\.com을 사용하면 evil-example.com도 통과합니다. 정규식 앵커(^, $)를 빠뜨리거나, https:// 프로토콜을 고정하지 않으면 비슷한 문제가 발생합니다.
이 세 패턴은 모두 "동작은 하는데 안전하지 않은" 코드를 만들어냅니다. 기능 테스트에서는 통과하지만 보안 관점에서는 실패하는 전형적인 사례입니다.
3. Access-Control-Allow-Origin: * + Access-Control-Allow-Credentials: true 동시 사용의 위험
아래 코드는 실제 취약한 패턴을 재현한 Express 예제입니다. 이 구조가 왜 위험한지 직접 확인하기 위해 작성했습니다.
// 취약한 구현 예시 — 절대 프로덕션에 사용하지 마십시오
import express from 'express';
const app = express();
// 잘못된 CORS 설정: * + credentials 조합
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true'); // 브라우저가 이 응답을 거부함
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
});
// 사용자 계정 정보를 반환하는 엔드포인트
app.get('/api/me', (req, res) => {
// 세션 쿠키로 인증하는 구조라고 가정
const sessionId = req.cookies?.sessionId;
if (!sessionId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
res.json({ userId: 'user_123', email: 'user@example.com', role: 'admin' });
});
app.listen(3000);
이 코드의 문제는 두 가지입니다. 첫째, WHATWG Fetch 명세에 따르면 Access-Control-Allow-Origin이 *일 때 Credentials 포함 요청은 브라우저가 차단합니다. 따라서 쿠키 인증이 작동하지 않아 기능 자체가 깨집니다. 둘째, 만약 서버 구현이 Credentials 없이도 동작하는 구조라면(예: Authorization 헤더 기반), 와일드카드 허용으로 인해 모든 출처의 스크립트가 응답 데이터를 읽을 수 있게 됩니다.
명세가 이 조합을 금지하는 이유는 명확합니다. *는 "모든 출처를 신뢰한다"는 의미이고, Credentials는 "이 요청에 민감한 인증 정보가 포함되어 있다"는 의미입니다. 이 둘의 조합은 논리적으로 충돌합니다. 모든 출처를 신뢰하면서 민감한 인증 정보를 허용하는 것은 보안 계약을 스스로 파기하는 행위입니다.
프로덕션에서 Credentials를 허용하려면 Access-Control-Allow-Origin에 구체적인 출처를 명시해야 합니다.
4. Express·Fastify·Spring별 Origin 화이트리스트 구현 비교
아래는 Express에서 cors 패키지를 사용해 화이트리스트 기반 Origin 검증, null Origin 차단, 서브도메인 정규식 처리를 구현한 예제입니다. 이 코드는 실제 프로덕션 환경에서 사용할 수 있는 형태로 작성했습니다.
import express from 'express';
import cors from 'cors';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://www.example.com',
]);
// 서브도메인 허용이 필요한 경우: 프로토콜과 도메인을 정확히 고정
const SUBDOMAIN_PATTERN = /^https:\/\/[a-z0-9-]+\.example\.com$/;
function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return false;
// null Origin 명시적 차단 (로컬 파일, iframe sandbox, data URI)
if (origin === 'null') return false;
// 정적 화이트리스트 우선 확인
if (ALLOWED_ORIGINS.has(origin)) return true;
// 서브도메인 패턴 검사 (필요한 경우에만 활성화)
if (SUBDOMAIN_PATTERN.test(origin)) return true;
return false;
}
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
if (!origin) {
callback(null, false);
return;
}
if (isOriginAllowed(origin)) {
callback(null, true);
} else {
callback(new Error('CORS: origin not allowed'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
maxAge: 600,
optionsSuccessStatus: 204,
};
const app = express();
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.get('/api/data', (req, res) => {
res.json({ message: 'CORS 화이트리스트 통과' });
});
app.listen(3001);
프레임워크별 구현 방식의 차이와 트레이드오프를 비교하면 다음과 같습니다.
| 프레임워크 | Origin 검증 방식 | Credentials 지원 | 기본 보안 수준 | 유연성 |
|---|---|---|---|---|
Express + cors 패키지 | 콜백 함수로 커스텀 로직 가능 | credentials: true 옵션 | 기본값은 개방적, 직접 설정 필요 | 높음 |
Fastify + @fastify/cors | origin 함수 또는 정규식 배열 | credentials: true 옵션 | Express와 유사 | 높음 |
Spring Boot (@CrossOrigin) | 어노테이션 또는 CorsRegistry | allowCredentials = "true" | 기본값은 제한적 (현재 출처만) | 중간 |
Spring Security cors() | CorsConfigurationSource 빈 등록 | setAllowCredentials(true) | 가장 엄격한 기본값 | 높음 (복잡) |
Nginx add_header | 정적 값 또는 map 블록 | 헤더 직접 추가 | 동적 처리 어려움 | 낮음 |
Spring Boot에서 @CrossOrigin(origins = "*", allowCredentials = "true")를 어노테이션에 함께 쓰면 Spring 자체가 예외를 던져 배포가 실패합니다. 명세를 프레임워크 레벨에서 강제하는 좋은 예입니다. Express는 이런 보호가 없으므로 개발자가 직접 검증 로직을 구현해야 합니다.
인증 세션 관리와 쿠키 보안 설계에 대한 자세한 내용은 세션 쿠키·Refresh Token 보안 설계에서 다루고 있습니다.
5. null Origin의 함정 — 로컬 파일, iframe sandbox, data URI
Origin: null은 브라우저가 특정 조건에서 전송하는 특수한 값입니다. 로컬 파일(file:// 프로토콜), sandbox 속성이 지정된 iframe, data: URI에서 시작된 요청이 여기에 해당합니다. Redirect 체인의 중간에서도 발생할 수 있습니다.
문제는 일부 구현에서 null Origin을 편의상 허용하는 경우입니다. "로컬에서 테스트할 때 편하려고" 또는 "어차피 브라우저 제한이니까 괜찮겠지"라는 생각에서 비롯됩니다.
// 위험한 null Origin 허용 패턴
app.use((req, res, next) => {
const origin = req.headers.origin;
if (!origin || origin === 'null') {
res.setHeader('Access-Control-Allow-Origin', 'null'); // 절대 하지 말 것
}
next();
});
Access-Control-Allow-Origin: null을 설정하면 샌드박스된 iframe에서 실행된 악성 스크립트가 해당 API에 접근할 수 있습니다. 공격자는 공격 대상 사이트에 삽입된 sandboxed iframe 안에서 fetch('https://api.yourservice.com/data', { credentials: 'include' })를 실행해 응답을 읽을 수 있습니다.
OWASP CORS OriginHeaderScrutiny는 Origin 헤더 검증 시 null 값을 반드시 명시적으로 거부하도록 권고합니다. 위에서 작성한 isOriginAllowed 함수에서 if (origin === 'null') return false; 라인이 이 위협을 방어합니다.
로컬 개발 환경에서의 CORS 문제는 다른 방법으로 해결해야 합니다. Vite나 webpack-dev-server의 proxy 설정을 사용하거나, 개발 서버를 실제 도메인과 같은 출처에서 실행하는 것이 올바른 접근입니다. null Origin 허용은 해결책이 아닙니다.
6. Preflight 캐싱과 Access-Control-Max-Age 오설정 타이밍 윈도우
브라우저는 Simple Request가 아닌 교차 출처 요청(커스텀 헤더, Content-Type: application/json, PUT·DELETE 메서드 등)을 보내기 전에 OPTIONS Preflight 요청을 먼저 보냅니다. 서버가 허용하면 실제 요청을 전송합니다.
Access-Control-Max-Age 헤더는 Preflight 결과를 브라우저가 캐시하는 시간(초)을 지정합니다. 이 값을 지나치게 크게 설정하면 두 가지 문제가 발생합니다.
첫째, 정책 변경이 즉시 반영되지 않습니다. CORS 화이트리스트에서 특정 Origin을 제거했더라도, 캐시가 살아있는 동안 해당 Origin의 브라우저는 계속 Preflight 없이 요청을 보낼 수 있습니다. 정확히는 "Preflight 캐시에 허용으로 기록된 상태"로 요청하므로, 실제 요청에서는 서버의 Origin 검증이 동작합니다. 그러나 긴 캐시 시간은 정책 롤백 시 혼란을 초래합니다.
둘째, Chrome 브라우저는 최대 7,200초(2시간)로 캐시 상한을 제한하고, Firefox는 86,400초(24시간)가 상한입니다. 과도한 Max-Age 값은 브라우저마다 다르게 처리되어 예상치 못한 동작을 낳습니다.
실무 권장값은 600초(10분)입니다. 개발·스테이지 환경에서는 0으로 설정해 캐시를 비활성화하고 매번 Preflight를 발생시켜야 정책 변경 테스트가 정확합니다.
타이밍 윈도우 문제도 있습니다. Preflight가 허용된 직후 서버 측 CORS 설정을 변경하면, 짧은 시간 동안 브라우저가 이전 캐시를 보고 허용된 것으로 판단해 요청을 보내고, 서버는 실제 요청에서 거부할 수 있습니다. 이 경우 클라이언트에 CORS 에러가 발생하는데, 원인 파악이 어렵습니다. 정책 변경 시 캐시 시간을 염두에 두고 배포 순서를 설계해야 합니다.
7. 서브도메인 와일드카드(*.example.com)가 명세상 허용되지 않는 이유와 안전한 대안
Access-Control-Allow-Origin: *.example.com은 작동하지 않습니다. WHATWG Fetch 명세는 Access-Control-Allow-Origin 헤더 값으로 정확히 하나의 Origin 문자열 또는 *만 허용합니다. 와일드카드 패턴은 인식하지 않으며, 브라우저는 *.example.com을 리터럴 문자열로 해석합니다. 요청 Origin이 https://sub.example.com이라면 당연히 불일치합니다.
이 제한 때문에 여러 서브도메인을 허용해야 하는 서비스는 서버 측에서 동적 Origin 검증을 구현해야 합니다. 앞서 작성한 isOriginAllowed 함수의 SUBDOMAIN_PATTERN 부분이 이를 담당합니다.
단, 서브도메인을 광범위하게 허용하면 새로운 위험이 생깁니다. *.example.com 패턴을 허용한 상태에서 legacy.example.com이나 test.example.com 같은 서브도메인에 XSS 취약점이 있다면, 그 서브도메인을 통해 메인 API를 공격할 수 있습니다. CSP와 Trusted Types를 활용한 XSS 방어 전략은 브라우저 보안 CSP·Trusted Types와 XSS 방어에서 다루고 있습니다.
서브도메인 와일드카드가 필요한 경우의 안전한 접근:
- 허용할 서브도메인 목록을 명시적으로 관리합니다.
Set이나 데이터베이스에 저장하고, 신규 서브도메인 추가는 코드 리뷰·승인 프로세스를 거칩니다. - 정규식을 사용한다면
^https://[a-z0-9-]+\.example\.com$처럼 프로토콜 고정, 앵커 사용, 허용 문자 제한을 함께 적용합니다. - 와일드카드 허용 범위를 좁힙니다.
cdn.example.com,images.example.com같이 정적 리소스 전용 서브도메인과 API 서브도메인을 분리해 CORS 정책을 독립적으로 관리합니다.
SRI(Subresource Integrity)를 활용한 서브리소스 공급망 보호와의 연계는 Subresource Integrity와 공급망 공격 방어를 참고하십시오.
8. 프로덕션 CORS 정책 설계 체크리스트 — 스테이지별 Origin 분리, CI 린트 통합
CORS 정책은 환경별로 분리해서 관리해야 합니다. 개발·스테이지·프로덕션이 같은 Origin 화이트리스트를 공유하면 스테이지 환경의 느슨한 설정이 프로덕션에 영향을 미칩니다.
환경 변수로 허용 Origin 목록을 관리하는 것이 기본입니다.
// config/cors.config.ts
interface CorsConfig {
allowedOrigins: Set<string>;
maxAge: number;
credentials: boolean;
}
function buildCorsConfig(): CorsConfig {
const env = process.env.NODE_ENV ?? 'development';
const originsByEnv: Record<string, string[]> = {
development: [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
],
staging: [
'https://staging.example.com',
'https://staging-admin.example.com',
],
production: [
'https://app.example.com',
'https://admin.example.com',
'https://www.example.com',
],
};
const allowedOrigins = new Set(originsByEnv[env] ?? []);
return {
allowedOrigins,
maxAge: env === 'development' ? 0 : 600,
credentials: true,
};
}
export const corsConfig = buildCorsConfig();
CI 파이프라인에 CORS 설정 린트를 통합하는 것도 권장합니다. 간단한 접근은 grep 기반으로 Access-Control-Allow-Origin: *와 credentials: true가 동일 파일에 존재하는지 확인하는 스크립트를 추가하는 것입니다. 더 정교한 방법은 ESLint 커스텀 룰을 작성해 cors() 옵션 객체를 정적 분석하는 것입니다.
배포 전 CORS 정책 검토 체크리스트를 팀 내에 공유하고, PR 템플릿에 "CORS 관련 변경이 있는가? 화이트리스트를 검토했는가?"를 포함시키는 것도 효과적입니다.
9. 오설정 탐지: curl·Burp Community로 검증하기, 단위 테스트로 회귀 막기
CORS 설정은 반드시 배포 후 직접 검증해야 합니다. 아래 셸 명령은 Origin 화이트리스트와 Preflight 동작을 빠르게 확인합니다.
# 허용된 Origin 테스트 — 200 응답과 올바른 헤더 확인
curl -s -D - \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-X OPTIONS \
https://api.example.com/api/data | grep -E "access-control|HTTP/"
# 미허용 Origin 테스트 — CORS 헤더가 없어야 함
curl -s -D - \
-H "Origin: https://evil.example.com" \
-X OPTIONS \
https://api.example.com/api/data | grep "access-control-allow-origin"
# null Origin 차단 테스트
curl -s -D - \
-H "Origin: null" \
-X OPTIONS \
https://api.example.com/api/data | grep "access-control-allow-origin"
# Credentials 포함 실제 요청 테스트
curl -s -D - \
-H "Origin: https://app.example.com" \
-H "Cookie: sessionId=test_session_value" \
-X GET \
https://api.example.com/api/me | grep -E "access-control|set-cookie"
수동 검증만으로는 회귀를 막기 어렵습니다. Vitest로 Origin 화이트리스트 로직을 단위 테스트하는 것이 훨씬 안전합니다.
// src/config/cors.config.test.ts
import { describe, it, expect } from 'vitest';
import { isOriginAllowed } from './cors.config';
describe('isOriginAllowed - Origin 화이트리스트 단위 테스트', () => {
describe('허용된 Origin', () => {
it('정확한 프로덕션 Origin을 허용한다', () => {
expect(isOriginAllowed('https://app.example.com')).toBe(true);
expect(isOriginAllowed('https://admin.example.com')).toBe(true);
expect(isOriginAllowed('https://www.example.com')).toBe(true);
});
it('허용된 서브도메인 패턴을 통과시킨다', () => {
expect(isOriginAllowed('https://feature-branch.example.com')).toBe(true);
});
});
describe('차단해야 하는 Origin', () => {
it('null Origin을 거부한다', () => {
expect(isOriginAllowed('null')).toBe(false);
});
it('undefined Origin을 거부한다', () => {
expect(isOriginAllowed(undefined)).toBe(false);
});
it('악의적인 Origin을 거부한다', () => {
expect(isOriginAllowed('https://evil.example.com')).toBe(false);
expect(isOriginAllowed('https://evil-example.com')).toBe(false);
expect(isOriginAllowed('https://notexample.com')).toBe(false);
});
it('example.com을 포함하는 다른 도메인을 거부한다', () => {
expect(isOriginAllowed('https://evil.example.com.attacker.io')).toBe(false);
expect(isOriginAllowed('https://exampleXcom')).toBe(false);
});
it('http(비보안) Origin을 거부한다', () => {
expect(isOriginAllowed('http://app.example.com')).toBe(false);
});
it('포트가 다른 Origin을 거부한다', () => {
expect(isOriginAllowed('https://app.example.com:8080')).toBe(false);
});
});
});
이 테스트는 CI에 포함시켜야 합니다. CORS 화이트리스트를 변경하는 PR은 반드시 테스트를 통해 검증하고, 예상치 못한 Origin을 허용하는 회귀를 자동으로 탐지할 수 있어야 합니다.
10. 결론: CORS는 UX 설정이 아니라 보안 계약이다
CORS 에러는 개발자에게 불편한 장벽처럼 느껴집니다. 그래서 "어떻게 하면 이 에러를 없앨 수 있을까"가 첫 번째 질문이 됩니다. 하지만 CORS가 존재하는 이유는 브라우저가 사용자를 대신해 교차 출처 요청을 통제하기 위해서입니다. 에러를 없애는 올바른 방법은 "이 출처가 정말 신뢰할 수 있는가"를 서버가 명시적으로 판단하는 것입니다.
Access-Control-Allow-Origin: *는 정적 리소스(이미지, 폰트, 공개 CDN 파일)에는 적절한 설정입니다. 하지만 사용자 데이터를 반환하는 API, 인증이 필요한 엔드포인트, Credentials를 다루는 모든 경우에는 구체적인 Origin을 명시해야 합니다.
프로덕션 CORS 보안 체크리스트:
Access-Control-Allow-Origin: *와credentials: true를 절대 함께 사용하지 않는다. 명세 위반이며, 기능과 보안 모두 실패합니다.- Origin 화이트리스트를 환경별로 분리 관리한다. 개발 환경의
localhost허용이 프로덕션 설정에 포함되어서는 안 됩니다. nullOrigin을 명시적으로 차단한다. 허용하면 샌드박스된 iframe 기반 공격에 노출됩니다.- 서브도메인 정규식에 프로토콜 고정과 앵커(
^,$)를 반드시 적용한다. 앵커 누락 하나가evil-example.com통과를 허용합니다. - Origin 화이트리스트 로직을 Vitest 단위 테스트로 검증하고 CI에 포함시킨다. 수동 검증만으로는 회귀를 막을 수 없습니다.
CORS 정책은 한 번 설정하고 잊는 것이 아닙니다. 서비스가 성장하고 도메인이 늘어나면 화이트리스트도 변합니다. 변경할 때마다 "이 Origin이 왜 허용되어야 하는가"를 팀이 함께 검토하는 문화를 만드는 것, 그것이 CORS를 보안 계약으로 다루는 시작점입니다.