CORS 완전 정복 — 동일 출처 정책과 교차 출처 요청의 원리
프론트엔드에서 API를 호출했는데, 코드에는 문제가 없는 것 같은데 브라우저 콘솔에 빨간 에러가 뜹니다. "Access to fetch at ... has been blocked by CORS policy." 대체 이 에러는 왜 나오는 걸까요?
Same-Origin Policy — 동일 출처 정책
같은 출처(Origin)에서 온 리소스만 접근을 허용하는 브라우저의 보안 정책 입니다.
여기서 "출처"는 세 가지 요소로 결정됩니다.
| 요소 | 예시 |
|---|---|
| 프로토콜(Scheme) | https:// |
| 호스트(Host) | api.example.com |
| 포트(Port) | :443 |
셋 중 하나라도 다르면 다른 출처(Cross-Origin) 로 판단합니다.
✅ 같은 출처
https://example.com/page1
https://example.com/page2/detail
❌ 다른 출처
https://example.com vs http://example.com ← 프로토콜 다름
https://example.com vs https://api.example.com ← 호스트 다름
https://example.com vs https://example.com:8080 ← 포트 다름
주의할 점 하나 — 경로(path)는 출처 판단에 포함되지 않습니다. /api/users와 /api/posts는 같은 출처입니다.
왜 제한하는가 — SOP가 필요한 이유
"그냥 다 허용하면 편하지 않나요?"라고 생각할 수 있는데, SOP가 없으면 아래 같은 공격이 너무 쉬워집니다.
CSRF(Cross-Site Request Forgery)
1. 사용자가 bank.com에 로그인한 상태 (세션 쿠키 보유)
2. 악성 사이트 evil.com 방문
3. evil.com의 스크립트가 bank.com/transfer?to=hacker&amount=1000000 호출
4. 쿠키가 자동 전송되어 → 실제로 송금이 실행됨
데이터 탈취
1. 사용자가 mail.com에 로그인한 상태
2. evil.com의 스크립트가 mail.com/inbox를 fetch로 호출
3. 응답에서 메일 내용을 읽어서 → 공격자 서버로 전송
SOP가 이런 교차 출처 요청의 ** 응답 읽기를 차단 **하여 데이터 탈취를 막습니다.
정확히 말하면, SOP는 요청 자체를 막는 게 아니라 ** 응답을 읽지 못하게** 합니다. 요청은 서버에 도달할 수 있지만 브라우저가 응답을 스크립트에 전달하지 않습니다.
CORS의 등장 배경
SOP는 보안에 필수적이지만, 현대 웹 개발에서는 교차 출처 요청이 꼭 필요한 상황이 많습니다.
- 프론트엔드(
localhost:3000)에서 백엔드(localhost:8080)로 API 호출 - CDN에서 폰트나 이미지 로드
- 외부 서비스의 REST API 사용 (결제, 지도, 날씨 등)
이런 정당한 교차 출처 요청까지 모두 막으면 웹 개발이 불가능해집니다. 그래서 ** 서버가 명시적으로 허용한 출처에 한해 교차 출처 요청을 승인하는 메커니즘 **이 필요했고, 그것이 CORS(Cross-Origin Resource Sharing) 입니다.
핵심 원리는 간단합니다.
브라우저: "이 요청은 다른 출처에서 왔는데, 허용해도 되나요?"
서버: "네, 이 출처는 괜찮습니다." (응답 헤더로 알려줌)
브라우저: "서버가 허용했으니 응답을 스크립트에 전달합니다."
CORS는 서버가 응답 헤더로 허용을 표현하는 방식 이기 때문에, CORS 에러의 해결은 항상 서버 쪽에서 해야 합니다.
단순 요청(Simple Request)
모든 교차 출처 요청이 복잡한 절차를 거치는 것은 아닙니다. 아래 조건을 모두 충족하면 Preflight 없이 바로 본 요청을 보냅니다.
단순 요청의 조건
- ** 메서드 **:
GET,POST,HEAD중 하나 - ** 헤더 **: 아래 허용 헤더만 사용
AcceptAccept-LanguageContent-LanguageContent-Type(아래 세 가지만)
- Content-Type:
text/plain,multipart/form-data,application/x-www-form-urlencoded중 하나
단순 요청 흐름
클라이언트 서버
| |
|--- GET /api/data ----------------→|
| Origin: https://frontend.com |
| |
|←-- 200 OK ----------------------- |
| Access-Control-Allow-Origin: |
| https://frontend.com |
// 단순 요청 예시 — Preflight 없이 바로 전송
fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => console.log(data));
브라우저는 응답의 Access-Control-Allow-Origin 헤더를 확인하고, 요청한 출처가 허용 목록에 있으면 응답을 스크립트에 전달합니다.
Preflight 요청
단순 요청 조건에 해당하지 않으면, 브라우저는 ** 실제 요청 전에 OPTIONS 메서드로 "사전 점검" 요청 **을 보냅니다. 이것이 Preflight 요청입니다.
Preflight가 발생하는 대표적인 경우
PUT,DELETE,PATCH메서드 사용Content-Type: application/json(REST API에서 가장 흔함)- 커스텀 헤더 사용 (
Authorization,X-Custom-Header등)
실무에서 REST API를 만들면 JSON을 주고받으니까, ** 사실상 대부분의 API 호출은 Preflight가 발생한다 **고 보면 됩니다.
Preflight 흐름
클라이언트 서버
| |
|--- OPTIONS /api/users ----------------→ | ← 사전 점검(Preflight)
| Origin: https://frontend.com |
| Access-Control-Request-Method: POST |
| Access-Control-Request-Headers: |
| Content-Type, Authorization |
| |
|←-- 204 No Content -------------------- | ← 서버가 허용 여부 응답
| Access-Control-Allow-Origin: |
| https://frontend.com |
| Access-Control-Allow-Methods: |
| GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers: |
| Content-Type, Authorization |
| Access-Control-Max-Age: 86400 |
| |
|--- POST /api/users ------------------→ | ← 실제 요청
| Origin: https://frontend.com |
| Content-Type: application/json |
| Authorization: Bearer xxx |
| |
|←-- 201 Created ----------------------- | ← 실제 응답
| Access-Control-Allow-Origin: |
| https://frontend.com |
Preflight 요청에 포함되는 두 가지 핵심 헤더입니다.
| 요청 헤더 | 의미 |
|---|---|
Access-Control-Request-Method | 실제 요청에서 사용할 HTTP 메서드 |
Access-Control-Request-Headers | 실제 요청에서 사용할 커스텀 헤더 목록 |
CORS 응답 헤더 총정리
서버가 CORS를 허용하기 위해 사용하는 응답 헤더들입니다.
| 헤더 | 설명 | 예시 |
|---|---|---|
Access-Control-Allow-Origin | 허용할 출처 | https://frontend.com 또는 * |
Access-Control-Allow-Methods | 허용할 HTTP 메서드 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 허용할 요청 헤더 | Content-Type, Authorization |
Access-Control-Allow-Credentials | 인증 정보 포함 허용 | true |
Access-Control-Max-Age | Preflight 결과 캐시 시간(초) | 86400 (24시간) |
Access-Control-Expose-Headers | JS에서 읽을 수 있는 응답 헤더 | X-Total-Count |
Access-Control-Max-Age는 성능에 직접적인 영향을 줍니다. 값을 설정하면 같은 요청에 대해 지정한 시간 동안 Preflight를 다시 보내지 않습니다.
Spring Boot에서의 CORS 설정 예시
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://frontend.com") // 허용할 출처
.allowedMethods("GET", "POST", "PUT", "DELETE") // 허용 메서드
.allowedHeaders("Content-Type", "Authorization") // 허용 헤더
.allowCredentials(true) // 인증 정보 허용
.maxAge(86400); // Preflight 캐시 24시간
}
}
Express에서의 CORS 설정 예시
const cors = require('cors');
app.use(cors({
origin: 'https://frontend.com', // 허용할 출처
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 허용 메서드
allowedHeaders: ['Content-Type', 'Authorization'], // 허용 헤더
credentials: true, // 인증 정보 허용
maxAge: 86400 // Preflight 캐시 24시간
}));
인증 정보 포함 요청 (Credentialed Request)
쿠키, Authorization 헤더, TLS 인증서 같은 ** 인증 정보를 교차 출처 요청에 포함 **시키려면 추가 설정이 필요합니다.
클라이언트 측 설정
// fetch API — credentials 옵션 사용
fetch('https://api.example.com/user', {
method: 'GET',
credentials: 'include' // 쿠키를 함께 전송
});
// XMLHttpRequest — withCredentials 사용
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/user');
xhr.withCredentials = true; // 쿠키를 함께 전송
xhr.send();
// axios — withCredentials 사용
axios.get('https://api.example.com/user', {
withCredentials: true // 쿠키를 함께 전송
});
서버 측 필수 조건
인증 정보 포함 요청에서 서버는 ** 반드시 세 가지 규칙 **을 지켜야 합니다.
❌ 와일드카드 불가 — 정확한 출처 명시 필수
Access-Control-Allow-Origin: * ← 거부됨!
Access-Control-Allow-Origin: https://frontend.com ← 허용
❌ 메서드/헤더에도 와일드카드 불가 (일부 브라우저)
Access-Control-Allow-Methods: * ← 거부될 수 있음
Access-Control-Allow-Methods: GET, POST ← 명시
✅ Credentials 헤더 필수
Access-Control-Allow-Credentials: true
이 규칙을 어기면 브라우저가 응답을 차단하고 CORS 에러를 출력합니다. 공부하다 보니 이 부분에서 가장 많이 막혔는데, 와일드카드와 credentials는 함께 쓸 수 없다 는 것만 기억하면 절반은 해결됩니다.
CORS 에러 해결법
1. 서버에서 CORS 헤더 설정 (근본적 해결)
가장 올바른 방법입니다. 서버에서 적절한 Access-Control-Allow-* 헤더를 설정합니다.
// Node.js Express — 미들웨어로 직접 설정
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://frontend.com');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Preflight 요청(OPTIONS)에 대한 빠른 응답
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
2. 프록시 서버 활용
CORS는 브라우저의 정책 이므로, 서버 간 통신에는 적용되지 않습니다. 이 특성을 이용합니다.
기존 (CORS 에러 발생):
브라우저 → 외부 API 서버 (교차 출처 → 차단)
프록시 사용 (CORS 회피):
브라우저 → 내 서버(같은 출처) → 외부 API 서버 (서버 간 통신 → 차단 없음)
3. 개발 환경 프록시 (Vite / Webpack)
로컬 개발 시 가장 많이 사용하는 방법입니다. 개발 서버가 프록시 역할을 합니다.
// Vite — vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080', // 백엔드 서버 주소
changeOrigin: true, // Origin 헤더를 target으로 변경
rewrite: (path) => path.replace(/^\/api/, '') // 경로 재작성
}
}
}
});
// Webpack — webpack.config.js (CRA의 경우 setupProxy.js)
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
};
// Next.js — next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*', // 프론트 경로
destination: 'http://localhost:8080/:path*' // 백엔드 서버
}
];
}
};
개발 환경 프록시를 설정하면 프론트엔드 코드에서 /api/users로 요청하면 개발 서버가 http://localhost:8080/users로 대신 전달합니다. 브라우저 입장에서는 같은 출처 요청이므로 CORS 에러가 발생하지 않습니다.
개발 환경 프록시는 프로덕션에서는 동작하지 않습니다. 배포 시에는 서버에서 CORS 헤더를 설정하거나, Nginx 같은 리버스 프록시를 사용해야 합니다.
자주 하는 실수 모음
실수 1: 와일드카드 + credentials
// 클라이언트
fetch('https://api.example.com/user', {
credentials: 'include'
});
// 서버 — 이렇게 하면 에러 발생!
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Credentials', 'true');
**해결 **: 와일드카드 대신 정확한 출처를 명시합니다.
// 여러 출처를 허용해야 할 때 — 동적으로 설정
const allowedOrigins = ['https://frontend.com', 'https://admin.com'];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
}
res.header('Access-Control-Allow-Credentials', 'true');
next();
});
실수 2: Content-Type 누락으로 Preflight 발생
// Content-Type을 지정하지 않으면 기본값이 text/plain
// JSON을 보내면서 Content-Type을 빠뜨리면 서버가 파싱 실패
fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify({ name: '홍길동' })
// Content-Type 누락! → 서버에서 JSON으로 파싱 못 함
});
// 올바른 요청
fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // 이 헤더 때문에 Preflight 발생
},
body: JSON.stringify({ name: '홍길동' })
});
Content-Type: application/json을 추가하면 단순 요청 조건에서 벗어나 Preflight가 발생합니다. 서버에서 Access-Control-Allow-Headers에 Content-Type을 포함해야 합니다.
실수 3: Preflight에 대한 OPTIONS 핸들링 누락
// Express — POST 라우트만 정의하면 OPTIONS 요청에 404 반환
app.post('/api/users', (req, res) => {
// ... 사용자 생성 로직
});
// cors 미들웨어를 사용하면 자동 처리
// 또는 직접 OPTIONS 핸들링
app.options('/api/users', (req, res) => {
res.header('Access-Control-Allow-Origin', 'https://frontend.com');
res.header('Access-Control-Allow-Methods', 'POST');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.sendStatus(204);
});
실수 4: mode: 'no-cors' 오해
// no-cors는 CORS 에러를 없애주는 게 아닙니다!
fetch('https://api.example.com/data', {
mode: 'no-cors' // 에러는 안 나지만...
});
// 응답 타입이 'opaque'가 되어 → 응답 본문을 읽을 수 없음
// 즉, 데이터를 가져오는 용도로는 쓸 수 없습니다
no-cors는 응답을 읽을 필요 없는 경우(이미지 프리로드, 비콘 전송 등)에만 쓰입니다.
CORS 디버깅 체크리스트
CORS 에러를 만났을 때 순서대로 확인하면 대부분 해결됩니다.
- ** 브라우저 개발자 도구 → Network 탭 **에서 요청/응답 헤더 확인
- OPTIONS 요청 이 있는지 확인 (Preflight 발생 여부)
- 서버 응답에
Access-Control-Allow-Origin헤더가 있는지 확인 - 요청 출처와
Allow-Origin값이 ** 정확히 일치 **하는지 확인 credentials: 'include'사용 중이라면 ** 와일드카드(*) 미사용** 확인- Preflight라면
Allow-Methods와Allow-Headers에 필요한 값이 있는지 확인 - 서버가 **OPTIONS 메서드에 정상 응답 **(200 또는 204)하는지 확인
정리
| 구분 | 핵심 |
|---|---|
| Same-Origin Policy | 브라우저가 다른 출처의 응답 읽기를 차단하는 보안 정책 |
| 출처(Origin) | 프로토콜 + 호스트 + 포트 (경로는 미포함) |
| CORS | 서버가 응답 헤더로 교차 출처 허용을 명시하는 메커니즘 |
| 단순 요청 | GET/POST/HEAD + 제한된 헤더 → Preflight 없음 |
| Preflight | OPTIONS 메서드로 사전 점검 → 허용 시 본 요청 전송 |
| credentials | include 사용 시 와일드카드(*) 불가, 정확한 출처 필수 |
| 해결법 | 서버 헤더 설정이 근본, 개발 중에는 프록시 활용 |
CORS를 처음 만나면 프론트엔드 문제라고 생각하기 쉬운데, CORS는 브라우저의 정책이고 해결은 서버에서 한다 는 점을 기억해두면 좋겠습니다. 에러 메시지를 잘 읽어보면 어떤 헤더가 빠졌는지 친절하게 알려주니까, 당황하지 말고 하나씩 체크해보면 됩니다.