
CORS: 프론트엔드 개발자의 영원한 숙적
빨간색 에러 메시지를 보고 당황하셨나요? 브라우저가 당신을 괴롭히는 게 아니라 보호하고 있는 겁니다.

빨간색 에러 메시지를 보고 당황하셨나요? 브라우저가 당신을 괴롭히는 게 아니라 보호하고 있는 겁니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

첫 프론트엔드 프로젝트였다. 백엔드 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시간 동안 헤맸다."그게 뭔데요?"
결국 시니어 개발자에게 도움을 요청했다. 시니어는 백엔드 코드를 열어보더니 딱 한 줄을 추가했다:
// Spring Boot
@CrossOrigin(origins = "http://localhost:3000")
에러가 사라졌다.
나는 멍하니 화면을 보다가 물었다.
"이게 뭐예요?"
시니어:
"CORS 설정이요. 브라우저는 보안 때문에 다른 Origin에서 오는 요청을 기본적으로 막아요. 서버에서 '이 Origin은 허용한다'고 명시해줘야 합니다."
나:
"그럼 Postman은 왜 돼요?"
시니어:
"Postman은 브라우저가 아니니까요. 브라우저만 이 규칙을 따릅니다."
그 순간 정리해본다면, CORS는 "백엔드 문제"도 "프론트엔드 문제"도 아니었다. 브라우저 보안 정책이었던 것이다.
처음 CORS를 마주했을 때 이해가 안 갔던 부분들:
무엇보다 "왜 이렇게 복잡하게 만들었지? 그냥 요청 보내면 안 되나?"가 이해가 안 갔다.
시니어가 예시를 들어줬다. 이 예시를 듣고 나서야 나는 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였던 것이다.
웹 생태계의 기본 원칙은 SOP (Same-Origin Policy)다. "같은 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 다름 (서브도메인) |
// 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다.
하지만 요즘 세상에 API 없이 어떻게 개발하나? 프론트엔드(localhost:3000)와 백엔드(localhost:8080)가 포트부터 다르다. 마이크로서비스 아키텍처에서는 도메인도 다르다.
그래서 나온 게 CORS다. 일종의 비자(Visa) 발급 시스템이다. "다른 나라(Origin)에서 왔지만, 비자가 있으면 통과시켜주겠다"는 개념이다.
CORS가 작동하는 방식을 정리해본다. 모든 요청에 대해 비자 검사(Preflight)를 하는 건 아니다.
다음 조건을 만족하면 예비 검사 없이 바로 본 요청을 보낸다:
GET, HEAD, POST 중 하나Content-Type이 다음 중 하나:
text/plainmultipart/form-dataapplication/x-www-form-urlencoded주의: JSON(application/json)은 여기에 포함 안 된다! 그래서 대부분의 API 요청은 Preflight를 거친다.
위 조건을 만족하지 못하면(예: PUT이거나 Authorization 헤더가 있거나 Content-Type: application/json이거나), 브라우저는 꼼꼼하게 정찰병(OPTIONS)을 먼저 보낸다.
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에서 왔는데요,"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에서 오는 요청 허락합니다."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 에러
이 흐름을 이해했을 때 "아, 브라우저가 사용자 대신 먼저 확인하고 안전하면 요청을 보내는구나"라는 게 와닿았다.
내가 실제로 쓴 해결책들을 정리해본다.
서버에서 "이 Origin은 허용한다"고 명시적으로 선언하는 방법이다.
컨트롤러 레벨:
@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);
}
}
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
브라우저를 속이는 방법이다. "어? 같은 Origin이네?" 하고 착각하게 만든다.
{
"proxy": "http://localhost:8080"
}
이제 코드에서:
// Before (CORS 에러)
fetch("http://localhost:8080/api/users")
// After (프록시 사용)
fetch("/api/users") // 자동으로 localhost:8080으로 프록시
브라우저 입장에선:
http://localhost:3000http://localhost:3000/api/users"어? 같은 Origin이네?" → 통과
하지만 실제론:localhost:8080으로 요청 전달// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
];
},
};
주의: 이건 개발 환경에서만 작동한다. 배포하면 프록시 서버가 없으므로 다시 CORS 에러가 터진다. 나는 이걸 몰라서 로컬에서는 되는데 Vercel에 배포하니까 안 돼서 한참 헤맸다.
// 절대 프로덕션에 쓰지 마세요!
fetch("https://cors-anywhere.herokuapp.com/http://api.example.com/data")
CORS Anywhere가 중간에서 헤더를 추가해준다. 하지만 보안상 위험하고 느리다. 테스트할 때만 쓰자.
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
쿠키를 포함한 요청을 보냈다:
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"
)
매 요청마다 OPTIONS가 날아가서 느렸다. Network 탭을 열어보니:
OPTIONS /api/users (200ms)POST /api/users (200ms)"왜 요청 한 번 보내는 데 두 배나 걸리지?"
해결:registry.addMapping("/api/**")
.maxAge(3600); // 1시간 캐싱
이제 브라우저가 1시간 동안 Preflight를 캐싱한다. 첫 요청만 OPTIONS를 보내고, 그 다음부터는 바로 본 요청을 보낸다.
"분명 설정했는데 왜 안 되죠?"
내가 겪은 가장 흔한 원인 3가지를 정리해본다.
이전에 실패한 Preflight 응답(403)이 브라우저에 캐시되어 있을 수 있다.
해결: 개발자 도구 → Network → Disable cache 체크 → 새로고침
CORS 미들웨어보다 앞단(예: 인증 필터)에서 500 에러나 401 에러가 터지면, CORS 헤더가 붙지 않고 그냥 에러 페이지만 내려온다.
브라우저는 "CORS 헤더 없네?" 하고 CORS 에러를 띄운다.
진짜 원인은 서버 로그를 봐야 안다.백엔드는 허용했는데, 앞에 있는 CDN이나 리버스 프록시가 OPTIONS 메서드를 차단하거나 헤더를 날려버리는 경우가 있다.
CloudFront 설정에서 OPTIONS 메서드와 CORS 헤더를 허용해야 한다.
반은 맞고 반은 틀리다.
CORS는 브라우저 사용자를 보호하는 기술이지, 서버(API)를 보호하는 기술이 아니다.
curl이나 Python 스크립트로 요청을 보낸다. 이때는 CORS 검사를 아예 안 한다.CORS는 "브라우저에서 악의적인 사이트가 사용자의 쿠키를 훔쳐 쓰는 걸 막는다"는 역할만 한다.
localhost:3000 허용https://my-app.vercel.app)Access-Control-Allow-Origin: * 사용 금지credentials: true 쓸 경우 명시적 Origin 필수maxAge)CORS 에러는 짜증 난다. 처음 봤을 때는 "왜 이렇게 불편하게 만들었어?"라고 생각했다.
하지만 지금은 받아들였다. CORS는 사용자를 지키는 고마운 방패다.
만약 CORS가 없었다면?
"보안 해제 플러그인" 같은 걸 깔고 개발하면, 배포했을 때 지옥을 맛본다.
CORS 에러를 만났을 때:
* 남발하지 않기결국 이거였다: CORS는 적이 아니라 친구다.