프롤로그 - "분명 Postman에서는 되는데..."
첫 프론트엔드 프로젝트였다. 백엔드 API를 Postman으로 테스트했을 때는 완벽하게 작동했다. 데이터도 잘 왔고, 200 OK도 떴고, JSON 응답도 깔끔했다. "좋아, 이제 React에서 똑같이 요청만 보내면 되겠군" 하고 생각했다.
React에서 fetch()로 똑같이 요청했다:
fetch("http://localhost:8080/api/users")
.then(res => res.json())
.then(data => console.log(data));
결과는? 개발자 도구에 빨간색 에러가 도배됐다:
Access to fetch at 'http://localhost:8080/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
"아니, 왜? Postman에서는 되는데?"
나는 백엔드 개발자에게 슬랙을 보냈다.
"API 호출하니까 CORS 에러 나는데요, 백엔드에서 뭔가 막아놓으신 거 아니에요?"
백엔드 개발자의 답장:
"API는 정상입니다. Postman에서 테스트했고 잘 됩니다. 제 쪽 문제 아니에요."
프론트엔드 커뮤니티에 질문을 올렸다:
"CORS 에러요? 백엔드가 헤더 안 넣어준 거 아니에요? 백엔드한테 Access-Control-Allow-Origin 헤더 추가하라고 하세요."
백엔드한테 다시 슬랙:
"헤더 추가해주세요."
백엔드:
"무슨 헤더요?"
나:
"Access-Control-Allow-Origin이요."
백엔드:
"그게 뭔데요?"
누구 잘못인지 몰라서 3시간 동안 헤맸다.
왜 공부하게 되었나 - 시니어의 5분짜리 해결책
결국 시니어 개발자에게 도움을 요청했다. 시니어는 백엔드 코드를 열어보더니 딱 한 줄을 추가했다:
// Spring Boot
@CrossOrigin(origins = "http://localhost:3000")
에러가 사라졌다.
나는 멍하니 화면을 보다가 물었다.
"이게 뭐예요?"
시니어:
"CORS 설정이요. 브라우저는 보안 때문에 다른 Origin에서 오는 요청을 기본적으로 막아요. 서버에서 '이 Origin은 허용한다'고 명시해줘야 합니다."
나:
"그럼 Postman은 왜 돼요?"
시니어:
"Postman은 브라우저가 아니니까요. 브라우저만 이 규칙을 따릅니다."
그 순간 정리해본다면, CORS는 "백엔드 문제"도 "프론트엔드 문제"도 아니었다. 브라우저 보안 정책이었던 것이다.
처음엔 뭐가 이해가 안 갔나
처음 CORS를 마주했을 때 이해가 안 갔던 부분들:
- Same-Origin이 뭐지? (같은 출신? 뭔 소리야?)
- 왜 브라우저는 막는데 Postman은 안 막지? (둘 다 HTTP 요청 아닌가?)
- OPTIONS 메서드는 또 뭐야? (POST 하나만 보냈는데 왜 요청이 두 번 날아가지?)
- "No 'Access-Control-Allow-Origin' header"가 뭔데? (이게 없으면 왜 막혀?)
무엇보다 "왜 이렇게 복잡하게 만들었지? 그냥 요청 보내면 안 되나?"가 이해가 안 갔다.
깨달음의 순간 - "악의적인 사이트가 내 쿠키를 훔쳐 쓴다고?"
시니어가 예시를 들어줬다. 이 예시를 듣고 나서야 나는 CORS가 왜 존재하는지 이해했다.
"자, 여러분이
mybank.com(은행 사이트)에 로그인했다고 칩시다. 브라우저에는 로그인 쿠키(session=abc123)가 저장돼 있죠.그 상태에서
hacker.com(해커가 만든 사이트)을 방문했습니다. 이 사이트에는 이런 JavaScript 코드가 숨어 있어요:fetch('https://mybank.com/api/transfer', { method: 'POST', credentials: 'include', // 쿠키 포함 body: JSON.stringify({ to: 'hacker', amount: 10000 }) });여러분이
hacker.com을 방문했을 때 이 코드가 실행됩니다. 브라우저는 자동으로mybank.com쿠키를 함께 보냅니다.CORS가 없으면? 은행 서버는 '오, 이 요청은 유효한 쿠키가 있네? 송금 처리!' 하고 여러분의 돈을 해커 계좌로 송금합니다."
"아, 브라우저가 나를 보호하는구나!"
이 예시를 듣고 나서야 받아들였다. CORS는 귀찮은 장애물이 아니라 나를 지키는 방패였다. 브라우저가 "야, 이 사이트가 저 사이트한테 요청 보내려고 하는데, 저 사이트가 허락했어?"라고 확인하는 과정이 CORS였던 것이다.
1. SOP (Same-Origin Policy): "같은 동네 사람만 믿는다"
웹 생태계의 기본 원칙은 SOP (Same-Origin Policy)다. "같은 Origin끼리만 자유롭게 소통할 수 있다"는 규칙이다.
Origin이란?
https://example.com:443/api/users?id=1
└─────┬─────┘ └┬┘ └─┬──┘ └──┬───┘ └┬┘
Scheme Port Host Path Query
Origin = Scheme + Host + Port
https://example.com:443
이 3가지 중 하나라도 다르면 다른 Origin이다.
예시로 이해했다
| URL | Origin | 같은 Origin? |
|---|---|---|
https://example.com/api | https://example.com:443 | ✅ 같음 (포트 443은 기본값) |
https://example.com:8080/api | https://example.com:8080 | ❌ 포트 다름 |
http://example.com/api | http://example.com:80 | ❌ Scheme 다름 (http vs https) |
https://api.example.com/ | https://api.example.com:443 | ❌ Host 다름 (서브도메인) |
SOP 규칙
// https://example.com에서 실행
fetch('https://example.com/api/users') // ✅ OK (Same Origin)
fetch('https://api.example.com/users') // ❌ Blocked (Different Host)
fetch('http://example.com/users') // ❌ Blocked (Different Scheme)
다른 Origin으로의 요청은 기본적으로 차단된다.
이 규칙을 처음 받아들였을 때 "아니, 요즘 세상에 API 호출도 못 하게 하면 어떻게 개발해?"라고 생각했다. 그래서 나온 게 CORS다.
2. CORS (Cross-Origin Resource Sharing): "여권 있으면 통과"
하지만 요즘 세상에 API 없이 어떻게 개발하나? 프론트엔드(localhost:3000)와 백엔드(localhost:8080)가 포트부터 다르다. 마이크로서비스 아키텍처에서는 도메인도 다르다.
그래서 나온 게 CORS다. 일종의 비자(Visa) 발급 시스템이다. "다른 나라(Origin)에서 왔지만, 비자가 있으면 통과시켜주겠다"는 개념이다.
동작 원리: Simple Request vs Preflight
CORS가 작동하는 방식을 정리해본다. 모든 요청에 대해 비자 검사(Preflight)를 하는 건 아니다.
1. 단순 요청 (Simple Request) - "그냥 보내도 됨"
다음 조건을 만족하면 예비 검사 없이 바로 본 요청을 보낸다:
- Method:
GET,HEAD,POST중 하나 - Header:
Content-Type이 다음 중 하나:text/plainmultipart/form-dataapplication/x-www-form-urlencoded
주의: JSON(application/json)은 여기에 포함 안 된다! 그래서 대부분의 API 요청은 Preflight를 거친다.
2. 예비 요청 (Preflight Request) - "먼저 물어보고 보내"
위 조건을 만족하지 못하면(예: PUT이거나 Authorization 헤더가 있거나 Content-Type: application/json이거나), 브라우저는 꼼꼼하게 정찰병(OPTIONS)을 먼저 보낸다.
실제 HTTP 흐름으로 이해했다
Step 1: Preflight Request (브라우저 → 서버)
OPTIONS /api/users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
브라우저가 묻는다:
- "저
localhost:3000에서 왔는데요," - "POST 메서드로 요청 보내도 돼요?"
- "Content-Type 헤더 넣어도 돼요?"
Step 2: Preflight Response (서버 → 브라우저)
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
서버가 답한다:
- "오케이!
localhost:3000에서 오는 요청 허락합니다." - "GET, POST, PUT 메서드 쓸 수 있어요."
- "Content-Type 헤더 넣어도 돼요."
- "이 허가증은 24시간(86400초) 유효합니다. 오늘은 다시 안 물어봐도 돼요."
Step 3: Actual Request (브라우저 → 서버)
POST /api/users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Content-Type: application/json
{"name": "John"}
브라우저가 말한다:
- "허가 받았으니 본 요청 보냅니다!"
만약 서버가 허락 안 하면?
HTTP/1.1 403 Forbidden
(Access-Control-Allow-Origin 헤더 없음)
브라우저: "허가 안 받았으니 본 요청 안 보냅니다." → CORS 에러
이 흐름을 이해했을 때 "아, 브라우저가 사용자 대신 먼저 확인하고 안전하면 요청을 보내는구나"라는 게 와닿았다.
3. 적용 - 해결 방법 세 가지
내가 실제로 쓴 해결책들을 정리해본다.
방법 1 - 백엔드에서 CORS 허용 (정석)
서버에서 "이 Origin은 허용한다"고 명시적으로 선언하는 방법이다.
Spring Boot
컨트롤러 레벨:
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
@GetMapping("/api/users")
public List<User> getUsers() {
return userService.findAll();
}
}
전역 설정 (권장):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
NestJS
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
Express.js
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
방법 2 - 프록시 서버 (개발 환경 꼼수)
브라우저를 속이는 방법이다. "어? 같은 Origin이네?" 하고 착각하게 만든다.
React (package.json)
{
"proxy": "http://localhost:8080"
}
이제 코드에서:
// Before (CORS 에러)
fetch("http://localhost:8080/api/users")
// After (프록시 사용)
fetch("/api/users") // 자동으로 localhost:8080으로 프록시
브라우저 입장에선:
- Origin:
http://localhost:3000 - Request URL:
http://localhost:3000/api/users
"어? 같은 Origin이네?" → 통과
하지만 실제론:
- React Dev Server가
localhost:8080으로 요청 전달 - 서버끼리 통신이라 CORS 검사 없음
Next.js
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
];
},
};
주의: 이건 개발 환경에서만 작동한다. 배포하면 프록시 서버가 없으므로 다시 CORS 에러가 터진다. 나는 이걸 몰라서 로컬에서는 되는데 Vercel에 배포하니까 안 돼서 한참 헤맸다.
방법 3 - CORS Anywhere (임시 테스트용)
// 절대 프로덕션에 쓰지 마세요!
fetch("https://cors-anywhere.herokuapp.com/http://api.example.com/data")
CORS Anywhere가 중간에서 헤더를 추가해준다. 하지만 보안상 위험하고 느리다. 테스트할 때만 쓰자.
4. 제가 겪은 실제 삽질 (따라 하지 마세요)
삽질 1 - Access-Control-Allow-Origin: * 남발
처음엔 귀찮아서 이렇게 했다:
@CrossOrigin(origins = "*") // ❌ 모든 Origin 허용
시니어가 코드 리뷰에서 지적했다:
"이거 프로덕션에 배포하면 안 돼요. 해커한테 문 열어주는 겁니다.
hacker.com에서도 우리 API 호출할 수 있어요."
결국 이거였다: *는 "아무나 와도 돼"라는 뜻이다. 개발 환경에서는 편할지 몰라도, 프로덕션에서는 절대 금물이다.
해결:
@CrossOrigin(origins = {
"https://my-app.vercel.app", // 프로덕션
"http://localhost:3000" // 개발
})
환경 변수로 분리:
@CrossOrigin(origins = "${app.cors.allowed-origins}")
# application.properties
app.cors.allowed-origins=https://my-app.vercel.app,http://localhost:3000
삽질 2 - Credentials 문제
쿠키를 포함한 요청을 보냈다:
fetch("http://localhost:8080/api/users", {
credentials: "include" // 쿠키 포함
})
백엔드:
@CrossOrigin(origins = "*") // ❌ 에러 발생
에러:
The value of 'Access-Control-Allow-Origin' must not be '*'
when the request's credentials mode is 'include'
와닿았다: 쿠키를 보낼 때는 *를 쓸 수 없다. 명시적으로 Origin을 지정해야 한다.
해결:
@CrossOrigin(
origins = "http://localhost:3000", // 명시적 Origin
allowCredentials = "true"
)
삽질 3 - Preflight 캐싱 안 됨
매 요청마다 OPTIONS가 날아가서 느렸다. Network 탭을 열어보니:
OPTIONS /api/users(200ms)POST /api/users(200ms)
총 400ms
"왜 요청 한 번 보내는 데 두 배나 걸리지?"
해결:
registry.addMapping("/api/**")
.maxAge(3600); // 1시간 캐싱
이제 브라우저가 1시간 동안 Preflight를 캐싱한다. 첫 요청만 OPTIONS를 보내고, 그 다음부터는 바로 본 요청을 보낸다.
5. 문제 해결 가이드 (Troubleshooting)
"분명 설정했는데 왜 안 되죠?"
내가 겪은 가장 흔한 원인 3가지를 정리해본다.
1. 브라우저 캐시
이전에 실패한 Preflight 응답(403)이 브라우저에 캐시되어 있을 수 있다.
해결: 개발자 도구 → Network → Disable cache 체크 → 새로고침
2. 서버 에러(500)
CORS 미들웨어보다 앞단(예: 인증 필터)에서 500 에러나 401 에러가 터지면, CORS 헤더가 붙지 않고 그냥 에러 페이지만 내려온다.
브라우저는 "CORS 헤더 없네?" 하고 CORS 에러를 띄운다.
진짜 원인은 서버 로그를 봐야 안다.
3. CloudFront/Nginx
백엔드는 허용했는데, 앞에 있는 CDN이나 리버스 프록시가 OPTIONS 메서드를 차단하거나 헤더를 날려버리는 경우가 있다.
CloudFront 설정에서 OPTIONS 메서드와 CORS 헤더를 허용해야 한다.
6. 오해와 진실 - CORS는 보안 기술인가?
반은 맞고 반은 틀리다.
CORS는 브라우저 사용자를 보호하는 기술이지, 서버(API)를 보호하는 기술이 아니다.
- 해커는 브라우저를 안 쓰고
curl이나 Python 스크립트로 요청을 보낸다. 이때는 CORS 검사를 아예 안 한다. - 그래서 "CORS를 켰으니 우리 API는 안전해"라고 생각하면 큰 오산이다.
- API 보안은 인증(JWT/OAuth)과 방화벽으로 따로 챙겨야 한다.
CORS는 "브라우저에서 악의적인 사이트가 사용자의 쿠키를 훔쳐 쓰는 걸 막는다"는 역할만 한다.
7. 정리 - CORS 체크리스트
개발 환경
- 백엔드:
localhost:3000허용 - 프론트엔드: 프록시 설정 (선택)
프로덕션 환경
- 백엔드: 실제 도메인만 허용 (예:
https://my-app.vercel.app) -
Access-Control-Allow-Origin: *사용 금지 -
credentials: true쓸 경우 명시적 Origin 필수 - Preflight 캐싱 설정 (
maxAge)
마치며 - "고마운 방패"
CORS 에러는 짜증 난다. 처음 봤을 때는 "왜 이렇게 불편하게 만들었어?"라고 생각했다.
하지만 지금은 받아들였다. CORS는 사용자를 지키는 고마운 방패다.
만약 CORS가 없었다면?
- 해커 사이트가 내 은행 계좌를 털 수 있다.
- 악의적인 광고가 내 개인정보를 훔쳐갈 수 있다.
- 내가 방문한 사이트가 내 쿠키로 다른 사이트에 요청을 보낼 수 있다.
"보안 해제 플러그인" 같은 걸 깔고 개발하면, 배포했을 때 지옥을 맛본다.
CORS 에러를 만났을 때:
- 백엔드에서 허용 설정 (정석)
- 개발 환경이면 프록시 사용 (꼼수)
- 절대
*남발하지 않기
결국 이거였다: CORS는 적이 아니라 친구다.