실무나 사이드프로젝트를 하면 항상 만나게 되는 에러가 있는데 바로 CORS이다.
자주 보는 CORS 에러지만 우리가 제대로 이해하고 해결하고 있는지는 곰곰히 생각해보자.
프로젝트를 진행하면서 CORS 에러를 만나면 "Access-Control-Allow-Origin: *" 을 추가하고 넘어가곤 했다.
모놀리식에서는 그걸로 충분할 수 있지만, MSA 환경에서는 이 접근이 보안 취약점이 되거나 운영상 큰 장애 요인이 될 수 있다.
이번 글에서 CORS의 동작 원리를 정확히 이해하고, MSA에서 CORS를 어느 계층에서 어떻게 관리할 수 있는지 설계 관점까지 알아보고자 한다.
1. Same-Origin Policy — 왜 브라우저는 Cross-Origin 요청을 막을까?
1. 브라우저를 악용하는 공격
하나의 시나리오를 봐보자.
우리가 https://my-bank.com에 로그인한 상태에서, 다른 탭으로 https://evil-site.com에 접속했다. 이 악성 사이트의 JavaScript가 다음 코드를 실행한다.
fetch("https://my-bank.com/api/transfer?to=hacker&amount=1000000");
브라우저는 https://my-bank.com으로 요청을 보낼 때, 요청을 시작한 주체와 무관하게 해당 도메인의 쿠키를 자동으로 포함한다.
서버 입장에서는 정상 로그인된 사용자의 요청과 악성 요청을 구분할 수가 없는것이다.
이것이 바로 CSRF(Cross-Site Request Forgery) 공격이다.
Same-Origin Policy는 이러한 위협을 막기 위해 브라우저에 내장된 보안 정책이다.
핵심은 다른 Origin에서 온 스크립트가 응답 데이터를 읽는 것을 차단하는 것이다. 해커가 네트워크를 가로채는 것을 막는 것(이건 TLS/HTTPS의 역할이다)이 아니라, 사용자의 브라우저를 대리인으로 악용되는걸 막는다.
2. Origin의 정확한 정의
여기서 말하는 Origin은 세 가지 요소의 조합으로 결정된다.
Scheme(프로토콜) + Host(도메인) + Port
예를 들어 https://api.example.com:8443이라면, Scheme는 https, Host는 api.example.com, Port는 8443이다.
세 요소 중 하나라도 다르면 Cross-Origin이다.
| 비교 | 결과 | 이유 |
| https://example.com vs https://example.com/api/users | Same-Origin | Path는 Origin 판단에 포함되지 않음 |
| https://example.com vs http://example.com | Cross-Origin | Scheme이 다름 |
| https://example.com vs https://api.example.com | Cross-Origin | Host가 다름 (서브도메인도 다른 Host) |
| https://example.com vs https://example.com:8443 | Cross-Origin | Port가 다름 |
3. Same-Origin Policy가 차단하는 것
많은 사람들이 오해하는 부분이 있는데, Same-Origin Policy는 요청 자체를 막지는 않는다.
Cross-Origin 요청은 실제로 서버에 도달하고, 서버는 처리하고 응답도 보낸다. 브라우저가 차단하는 것은 JavaScript가 그 응답을 읽는 행위다.
이 구분이 왜 중요한지는 바로 다음에 나오는 Preflight 메커니즘에서 명확해진다.
2. CORS 동작 방식 — 브라우저와 서버의 협의 프로토콜
CORS(Cross-Origin Resource Sharing)는 Same-Origin Policy의 제한을 통제된 방식으로 완화하는 메커니즘이다.
브라우저가 서버에게 "이 Cross-Origin 요청을 허용해도 될까?"를 묻고, 서버가 HTTP 헤더로 응답하는 구조다.
CORS 요청은 세 가지로 분류된다.
1. Simple Request (단순 요청)
브라우저가 특정 조건을 만족하는 요청을 "단순 요청"으로 분류하고, Preflight 없이 바로 서버에 보내는 방식이다. 조건은 다음과 같다.
- HTTP 메서드가 GET, HEAD, POST 중 하나
- 헤더가 Accept, Accept-Language, Content-Language, Content-Type 등 기본 헤더만 포함
- Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나
근데 의문이 드는 부분이 있다. GET, HEAD는 일리있는데 POST가 preflight 없이 가도되는 단순요청인게 이상하다.
분명히 CORS는 프론트나 서버에서 막지 않고 상대 서버까지 요청이 수행되어서 응답이 돌아오고, 브라우저에서 응답을 보여줄지 말지 차단하는 것이라고 했다.
그렇다면 POST의 경우 데이터의 생성/수정/삭제 등 조작이 가능한 Header이므로 미리 요청이 가기전에 막는 Preflight를 적용해야할거 같은데 왜 Simple Request에 포함되도록 설계했을까?
사실 이 조건이 이렇게 설계된 이유는 역사적 호환성때문이다. CORS 명세가 만들어지기 이전에도 브라우저는 이미 Cross-Origin 요청을 보낼 수 있었고 보내고 있었다.
- HTML <form> 태그의 submit → POST 가능
- Content-Type은 application/x-www-form-urlencoded이나 multipart/form-data
- <img>, <script>, <link> 태그 → GET 요청
이 요청들은 CORS 이전에도 자유롭게 Cross-Origin으로 날아가고 있었다.
이걸 갑자기 막으면 기존 웹 생태계가 깨지므로, CORS 명세는 "기존에 이미 가능했던 요청은 그대로 허용하고, CORS 이전에는 불가능했던 새로운 종류의 요청만 Preflight으로 사전 검증하자"는 방향으로 설계되었다.
Simple Request의 흐름
- 브라우저가 바로 요청을 보냄 (Origin 헤더 포함)
- 서버가 응답에 Access-Control-Allow-Origin 헤더를 포함
- 브라우저가 응답의 Allow-Origin과 요청의 Origin을 비교
- 일치하면 JavaScript에게 응답을 전달, 불일치하면 차단
2. Preflight Request (사전 요청)
Simple Request 조건을 만족하지 않는 요청은 Preflight 대상이 된다.
우리가 REST API에서 흔히 쓰는 Content-Type: application/json이나 Authorization 헤더는 Simple Request 조건에 포함되지 않으므로, 사실상 대부분의 API 호출은 Preflight 대상이다.

Preflight이 필요한 이유는 앞서 설명한 Same-Origin Policy의 특성과 관련된다.
Same-Origin Policy는 "응답 읽기"를 차단할 뿐, 요청은 서버에 도달한다. DELETE나 PUT처럼 서버 상태를 변경하는 요청은 도달하는 것 자체가 위험하다. 그래서 브라우저는 본 요청을 보내기 전에 서버에게 먼저 허락을 구한다.
Step 1 — 브라우저가 OPTIONS 메서드로 사전 질의
- "내 Origin은 ~이야. 헤더는 ~이야. DELETE 명령을 해도 될까?" 라고 미리 묻는다.
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://frontend.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
Step 2 — 서버가 허용 여부를 헤더로 응답
- "Ok. 요청해도돼. 우리가 허용하는 도메인은 ~고, ~ 명령만 허용하고, ~ 헤더타입만 허용해. 3600초 동안은 또 묻지마." 라고 답한다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
- Access-Control-Max-Age: 3600은 "이 허락은 3600초 동안 유효하니까 그동안은 Preflight을 다시 보내지 않아도 된다"는 뜻이다.
Step 3 — 허락을 확인한 후 본 요청 전송
- 브라우저가 Preflight 응답을 확인하고, 허용된 경우에만 실제 DELETE /api/users 요청을 보낸다.
3. Credentialed Request (인증 포함 요청)
쿠키나 Authorization 헤더 같은 인증 정보를 포함하는 Cross-Origin 요청은 추가적인 제약이 적용된다.
기본적으로 Cross-Origin fetch는 쿠키를 보내지 않는다. 보내려면 양쪽 모두의 명시적 동의가 필요하다.
- 클라이언트 — credentials: "include" 설정
- 서버 — Access-Control-Allow-Credentials: true 응답
그리고 이때 핵심 제약이 붙는다.
Access-Control-Allow-Origin에 와일드카드(*)를 사용할 수 없고, 정확한 Origin을 명시해야 한다.
- 이유는 단순하다.
- Allow-Origin: * + Allow-Credentials: true가 조합되면, 인터넷의 어떤 사이트든 사용자의 인증 정보를 포함한 요청을 보내고 응답까지 읽을 수 있게 된다. 앞서 설명한 은행 시나리오에서 잔액 조회, 개인정보 열람까지 모두 가능해지는 것이다.
4. 세 분류의 관계
참고로 이 세 분류(Simple Request, Preflight Request, Credentialed Request)는 서로 배타적이지 않다는 것이다.
Credentialed Request는 별도의 카테고리가 아니라, Simple이든 Preflight이든 그 위에 얹어지는 추가 제약이다.
예를 들어 Authorization 헤더 + Content-Type: application/json을 포함해서 credentials: "include"로 요청하면, Preflight 조건도 충족하고 Credentialed 조건도 충족한다.
-> 그러면 이제 Preflight 요청도 하면서 + 쿠키도 보내게 되고, 서버는 Access Control-Allow-Origin에 명시적인 값을 채워야 하는것이다.
3. MSA에서 CORS가 복잡해지는 이유
모놀리식 아키텍처에서는 프론트엔드와 백엔드가 같은 Origin에서 서빙되거나, CORS 설정을 한 곳에서 관리하면 끝이다. 그러나 MSA에서는 구조 자체가 CORS 관리를 복잡하게 만든다.
프론트엔드: https://app.example.com
유저 서비스: https://user-api.example.com
주문 서비스: https://order-api.example.com
결제 서비스: https://payment-api.example.com
1. CORS는 브라우저 → 서버 구간에서만 적용된다
여기서 반드시 짚고 넘어가야 할 개념이 있다. 마이크로서비스 간 통신에는 CORS가 적용되지 않는다. 주문 서비스가 유저 서비스를 RestTemplate이나 WebClient로 호출하는 건 서버 대 서버 통신이다. 브라우저가 없으므로 Same-Origin Policy 자체가 개입하지 않는다.
CORS가 문제가 되는 구간은 오직 브라우저(프론트엔드) → 마이크로서비스 요청뿐이다.
2. 개별 마이크로서비스에서 CORS를 관리하면 생기는 문제
각 마이크로서비스가 개별적으로 CORS를 설정하면 다음과 같은 문제가 발생한다.
- 설정 중복 — 모든 서비스에 동일한 프론트엔드 Origin을 등록해야 한다. 서비스가 50개라면 50곳 전부에!
- 변경 전파의 어려움 — 프론트엔드 도메인이 변경되거나 새 클라이언트(관리자 페이지 등)가 추가되면 모든 서비스를 수정·배포해야 한다.
- 설정 불일치 위험 — 서비스 A는 Allow-Methods에 DELETE를 넣었는데 서비스 B는 빠뜨리는 식으로, CORS 정책이 서비스마다 미묘하게 달라질 수 있다.
이 문제의 본질은 횡단 관심사를 각 서비스에 분산시킨 것이다. 인증, 로깅, 호출량 제어 등을 각 서비스에 개별 구현하면 안 되듯이, CORS도 마찬가지다.
4. MSA에서 CORS를 처리하는 4가지 전략
1. API Gateway에서 처리
API Gateway는 North - South를 담당하는 영역으로, MSA에서 모든 외부 트래픽이 통과하는 단일 진입점(Single Entry Point)이다. North-South 트래픽의 연결점으로서, 브라우저와의 통신이 반드시 거치는 지점이므로 CORS 처리에 가장 자연스러운 위치다.
Spring Cloud Gateway 설정 예시
application.yml 선언적 설정
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "https://app.example.com"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders:
- Authorization
- Content-Type
allowCredentials: true
maxAge: 3600
Java Config 방식
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("https://app.example.com");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
- allowCredentials: true를 설정했기 때문에 allowedOrigins에 [*]를 사용할 수 없다.
- 위에서 다룬 Credentialed Request의 제약이 그대로 적용되는 것이다.
게이트웨이를 통한 설정 장점
- CORS 설정을 단일 지점에서 관리하므로 중복과 불일치가 없다.
- 코드 레벨에서 동적이고 세밀한 정책 관리가 가능하다.
- 단위 테스트를 통한 검증이 가능하다.
- 프론트엔드 도메인 변경 시 한 곳만 수정하면 된다.
주의점 — CORS 헤더 중복
API Gateway에서 CORS를 처리하기로 했으면, 개별 마이크로서비스에서는 CORS 설정을 반드시 제거해야 한다.
Gateway에 CORS 헤더가 있고, 뒤쪽 마이크로서비스에도 @CrossOrigin 같은 설정이 남아있으면 응답에 동일한 CORS 헤더가 두 번 포함된다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: https://app.example.com
브라우저는 Access-Control-Allow-Origin 헤더가 두 개 이상이면, 값이 동일하더라도 CORS 에러로 처리한다. 서버 로그에서는 정상으로 보이는데 브라우저에서 CORS 에러가 발생하는 이 상황은 디버깅이 매우 까다롭다.
즉, 원칙은 단순하다. CORS 처리 계층을 딱 하나로 정하고, 나머지에서는 전부 제거하자.
2. 리버스 프록시(Nginx)에서 처리
실무에서 API Gateway 앞에 Nginx 같은 리버스 프록시를 두는 구조가 흔하다.
Nginx는 L7(Application Layer) 리버스 프록시로서 HTTP 헤더를 읽고 조작할 수 있으므로, CORS 처리가 가능하다.
Nginx CORS 설정 예시
server {
listen 443 ssl;
server_name api.example.com;
location / {
# Preflight 요청은 즉시 응답
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' 3600;
return 204;
}
# 본 요청에 CORS 헤더 부착
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://api-gateway;
}
}
- Preflight인 OPTIONS 요청을 Nginx에서 즉시 204로 응답하고, 본 요청만 Gateway로 넘기는 구조다.
Nginx를 통한 설정 장점
- Preflight 응답이 Gateway까지 도달하지 않아 응답 속도가 빠르다.
- API Gateway의 불필요한 Preflight 처리 부하를 제거할 수 있다.
한계점
- Nginx 설정은 기본적으로 정적 텍스트 파일이므로, 서비스별로 다른 CORS 정책을 적용하기가 까다롭다.
Access-Control-Allow-Origin은 하나의 Origin만 넣을 수 있어서, 여러 Origin을 허용하려면 $http_origin을 검사하는 if 분기를 직접 작성해야 한다. 근데 텍스트 파일 베이스이므로 문법 오류가 나기 쉽상이다. - 코드 레벨의 정합성 검증이 어렵고, 테스트 코드를 작성하기도 제한적이다.
- 설정 변경 시 Nginx reload가 필요하다.
Nginx vs API Gateway 비교
| Nginx | API Gateway | |
| Preflight 응답 속도 | 빠름 (앞단에서 즉시 응답) | 상대적으로 느림 |
| Gateway 부하 | Preflight 부하 없음 | Preflight도 처리해야함 |
| 세밀한 정책 관리 | 어려움 (정적 설정) | 용이함 (코드 레벨 제어) |
| 테스트·검증 | 제한적 | 단위 테스트 가능 |
| 설정 변경 시 | Nginx reload 필요 | 애플리케이션 재배포 |
즉, CORS 정책이 단순하고 전체 API에 동일하게 적용된다면 Nginx에서 처리하는 게 효율적이고, 정책이 복잡하거나 동적이면 API Gateway에서 처리하는 게 관리에 유리하다.
3. BFF(Backend For Frontend) 패턴으로 CORS 제거
앞의 두 방식이 "CORS를 어디서 처리할까"였다면, BFF는 발상 자체를 바꿔서 CORS가 발생하지 않게 만드는 접근이다.
브라우저 (https://app.example.com)
↓ Same-Origin → CORS 발생 안 함
BFF 서버 (https://app.example.com)
↓ 서버 대 서버 → CORS 무관
마이크로서비스들 (내부 네트워크)
BFF 서버가 프론트엔드 정적 파일도 서빙하고 API 요청도 받아서 뒤쪽 마이크로서비스로 프록시한다.
브라우저 입장에서 모든 요청이 https://app.example.com으로 가니까 Cross-Origin이 성립하지 않고, BFF에서 마이크로서비스로의 호출은 서버 대 서버 통신이라 CORS가 적용되지 않는다.
BFF 패턴 장점
BFF는 단순히 CORS를 없애는 것 이상의 가치를 제공한다.
- 응답 조합(Aggregation) — 하나의 화면에 유저 정보 + 주문 내역 + 결제 상태가 필요하면, BFF가 세개의 서비스를 호출해서 하나의 응답으로 합쳐준다.
- 클라이언트별 최적화 — 웹용 BFF, 모바일용 BFF를 분리해서 각 클라이언트에 최적화된 API를 제공할 수 있다.
한계
- 관리 포인트 증가 — 프론트엔드 클라이언트(웹, 모바일, 어드민 등)마다 별도의 BFF가 필요할 수 있다. 각 BFF를 별도로 개발, 배포, 모니터링해야 한다.
- 네트워크 홉 추가 — 모든 요청이 BFF를 거치므로 네트워크 홉이 하나 더 생긴다. 이는 지연 시간 증가와 네트워크 유실 가능성 증가로 이어질 수 있다.
- 병목 위험 — BFF에 모든 트래픽이 집중되므로, BFF 자체의 장애가 해당 클라이언트의 전체 서비스 장애로 이어진다.
4. 전략 조합
이 네 가지 전략은 반드시 하나만 선택해야 하는 것이 아니다. 실무에서는 상황에 맞게 조합하는 것이 효과적이다.
Nginx + API Gateway 조합
가장 흔한 조합이다. 역할을 명확히 분리하는 것이 핵심이다.
- Nginx — Preflight(OPTIONS)에 대해 즉시 204 응답. 본 요청은 CORS 헤더 없이 Gateway로 프록시.
- API Gateway — 본 요청의 응답에 CORS 헤더 부착. 서비스별 세밀한 정책 관리.
이렇게 하면 Preflight은 Nginx에서 빠르게 처리되면서도 복잡한 정책은 API Gateway에서 코드 레벨에서 관리할 수 있고, 어떤 응답에도 CORS 헤더가 정확히 한 번만 붙는다.
API Gateway + BFF 조합
트래픽이 집중되는 주력 클라이언트는 BFF로 CORS 자체를 제거하고, 나머지 클라이언트는 API Gateway에서 일괄적으로 CORS를 처리하는 하이브리드 전략이다. 모든 브라우저 별 클라이언트의 CORS 관리는 Gateway에 맡기고 핵심 진입점은 BFF를 통해 CORS를 없애고 성능을 관리한다.
Nginx + API Gateway + BFF 조합
대규모 서비스에서 사용하는 구조다.
브라우저 (웹)
↓ Same-Origin
BFF (https://app.example.com) ─── 정적 파일 서빙 + API 프록시
↓ 서버 대 서버
Nginx (L7 로드밸런서, SSL 종료)
↓
API Gateway (인증, 라우팅, 레이트리밋)
↓
마이크로서비스들
이 구조에서 CORS는 BFF 덕분에 아예 발생하지 않는다.
Nginx와 API Gateway는 CORS가 아닌 다른 횡단 관심사(SSL 종료, 로드밸런싱, 인증, 라우팅)에 집중한다. 복잡성은 높지만, 각 계층이 명확한 책임을 가지므로 대규모 환경에서는 오히려 체계적인 관리가 가능하다.
아키텍처 다이어그램

5. 정리
1. CORS 메커니즘 요약
| 조건 | 브라우저 동작 | |
| Simple Request | CORS 이전에 이미 가능했던 요청 형태 (특정 메서드, 헤더, Content-Type) |
Preflight 없이 바로 전송. 응답의 Allow-Origin 확인 후 차단 여부 결정 |
| Preflight Request | Simple Request 조건 불충족 (application/json, Authorization 헤더, DELETE/PUT 등) |
본 요청 전 OPTIONS로 사전 질의. 허락받은 후에만 본 요청 전송 |
| Credentialed Request | 쿠키, Authorization 등 인증 정보 포함 시 | 클라이언트·서버 양쪽 명시적 동의 필요. Allow-Origin에 와일드카드 사용 불가 |
- Credentialed Request는 별도 카테고리가 아니라, Simple/Preflight 위에 얹어지는 추가 제약이다.
2. MSA CORS 처리 전략 비교
전략 CORS 처리 위치 핵심 장점 핵심 한계
| 전략 | CORS 처리 위치 | 핵심 장점 | 핵심 한계 |
| 개별 마이크로서비스 | 각 서비스 | 서비스 자율성 | 설정 중복, 불일치 위험, 변경 전파 어려움 |
| API Gateway | Gateway 단일 지점 | 일원화된 관리, 세밀한 정책, 테스트 가능 | Preflight도 Gateway까지 도달 |
| 리버스 프록시 (Nginx) | 인프라 앞단 | Preflight 빠른 응답, Gateway 부하 감소 | 동적 정책 어려움, 정적 설정 한계 |
| BFF 패턴 | CORS 자체 불필요 | CORS 문제 원천 제거 | 관리 포인트 증가, 네트워크 홉 추가 |
3. 실무 설계 원칙
- CORS 처리 계층은 반드시 하나로 통일하자. 여러 계층에서 CORS 헤더를 붙이면 헤더 중복으로 오히려 CORS 에러가 발생한다.
- CORS는 브라우저 ↔ 서버 구간에서만 적용된다. 서비스 간(East-West) 통신에는 CORS가 무관하다.
- Credentialed Request를 사용할 경우 와일드카드(*)를 쓸 수 없다. 허용할 Origin을 명시적으로 지정해야 한다.
- 전략은 조합할 수 있다. Nginx에서 Preflight를 빠르게 처리하고, API Gateway에서 세밀한 정책을 관리하는 식의 역할 분담이 효과적이다.
'기술 학습' 카테고리의 다른 글
| TCP/IP 체크섬(Checksum) 내부 동작 원리 (0) | 2026.03.23 |
|---|---|
| Java 기반 동기/비동기, 블로킹/논블로킹 정리 (0) | 2026.03.23 |
| ANSI Isolation Level vs MySQL Isolation Level: 같은 이름, 다른 보장 (0) | 2026.03.15 |
| MSA에서 ACID의 의미 (0) | 2026.03.10 |
| 객체지향 설계 원칙 - SOLID (0) | 2026.03.08 |