서비스가 많아지면서 MSA구조로 서비스를 확장하게 되었고, 자연스럽게 Spring Cloud Gateway를 도입하게 되었다. Gateway가 모든 요청을 가장 먼저 받아 각 서비스로 전달하는 구조로 바뀌면서, 기존 단일 서버 환경에서는 발생하지 않았던 CORS 에러에 직면하게 되었다. 이 글에서는 해당 문제를 겪으면서 어떤 방식으로 접근했고, 실제 원인은 무엇이었으며, 어떻게 해결했는지를 정리해보려 한다.
CORS 에러란?
CORS(Cross-Origin Resource Sharing)는 웹 브라우저가 다른 출처(origin)의 서버에 요청을 보낼 때 발생할 수 있는 보안 정책이다.
예를 들어, 프론트엔드가 http://localhost:3000에서 실행되고 있고, API 서버가 http://localhost:8080에 있다면, 이는 서로 다른 origin이므로 브라우저는 보안상 제한을 걸게 된다. 이때 서버에서 "이 출처(origin)는 허용해도 돼"라는 응답(CORS 허용 헤더)을 보내주지 않으면, 브라우저는 요청을 보내지 못하고 CORS 에러를 발생시킨다. 즉, 요청이 틀린 건 아니지만, 서버가 이를 허용한다는 명시적인 응답을 해주지 않아서 문제가 발생하는 것이다.
개발 환경에선 프론트엔드와 백엔드가 서로 다른 포트나 도메인에서 실행되는 경우가 대부분이다. 따라서 웹 개발을 한다면 CORS 설정은 거의 필수적이다.
CORS 설정 시 주요 요소들
그렇다면 어떤 것들을 설정해줘야 하나?!
CORS를 허용하려면 서버에서 응답 헤더를 통해 "어떤 요청을 허용할지" 명시해야 한다. 대표적으로 아래 항목들을 설정하게 된다.
allowedOrigins / allowedOriginPatterns | 어떤 출처(origin)를 허용할지 지정 (ex. "*", "http://localhost:3000", "www.실제dns.com") |
allowedMethods | 허용할 HTTP 메서드 지정 (ex. GET, POST, PUT, DELETE, OPTIONS 등) |
allowedHeaders | 요청 시 허용할 헤더 지정 (ex. "Authorization", "Content-Type" 등) |
allowCredentials | 쿠키, 인증 정보 등 자격 증명 포함 여부 설정 (true/false) |
maxAge | Preflight 요청의 캐시 시간 (초 단위) |
기존 구조 - 각 서비스에서 개별적으로 CORS 설정
기존에는 모든 서비스가 단일 서버 구조였기 때문에, 아래와 같이 각각의 서비스 모듈에서 개별적으로 CORS 설정을 해두었다.
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ehc -> ehc.authenticationEntryPoint(new JwtAuthenticationEntryPoint()))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)
.build();
}
@Bean
protected CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", getDefaultCorsConfiguration());
return source;
}
private CorsConfiguration getDefaultCorsConfiguration() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
return configuration;
}
}
이 구조에서는 별 문제가 없었다. 하지만 Gateway가 들어오면서 상황이 바뀌었다.
구조 변경 - Gateway 도입 후 발생한 CORS 에러
MSA 구조로 전환하면서 모든 요청을 Gateway에서 먼저 받도록 변경했는데, 그 뒤로 프론트엔드에서 요청을 보낼 때 갑자기 CORS 에러가 발생하기 시작했다.
1) Gateway에 글로벌 CORS 설정 추가
처음엔 Gateway에서 CORS 처리를 제대로 안 해준다고 생각해서 아래와 같은 설정을 Gateway 모듈 application.yml에 추가했다.
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders: "*"
allowCredentials: true
이 설정은 Spring Cloud Gateway가 자체적으로 CORS를 처리하도록 하는 방식이다.
2) WebFilter로 CORS 수동 처리
@Bean
public WebFilter corsFilter() {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (request.getMethod() == HttpMethod.OPTIONS) {
response.getHeaders().add("Access-Control-Allow-Origin", "*");
response.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.getHeaders().add("Access-Control-Allow-Headers", "*");
response.setStatusCode(HttpStatus.OK);
return response.setComplete();
}
response.getHeaders().add("Access-Control-Allow-Origin", "*");
return chain.filter(exchange);
};
}
Gateway가 WebFlux 기반이다 보니, 직접 WebFilter를 만들어 처리하기도 했다. 이 방식은 모든 요청을 직접 필터링해서 응답 헤더를 조작하는 방법이다. 더욱 커스텀하게 제어할 수 있지만, Spring Cloud Gateway의 globalcors 기능과 중복되면 충돌이 발생할 수도 있다.
하지만 두 가지를 번갈아가면서 진행해도 다 되지 않았다. 그런데 아이러니하게도 Gateway에서 CORS 설정을 아예 제거했더니 오히려 잘 작동했다.
CORS 설정 없어도 돌아갈 수 있는 원인은?
알고 보니, Spring Cloud Gateway는 내부적으로 CorsWebFilter를 사용해 Preflight 요청(OPTIONS)에 대해 자동 응답을 할 수 있도록 설계되어 있다고 한다. 즉, 별도로 설정하지 않아도 allowedOrigins="*" 같은 기본 처리가 되어 있는 것이다.
이때까지만 해도 이러한 이유로 CORS 설정 없이도 알아서 에러가 해결되는 줄 알았다.. 하지만 GET 요청만 됐고, POST 요청이나 이 외의 요청들은 다시 CORS 에러가 발생했다..
진짜 원인은?
더 분석해보니, 진짜 문제는 하위 서비스들의 SecurityConfig에서 CORS 설정을 또 하고 있었다는 것이었다.
- Gateway가 이미 모든 요청을 받고 CORS 처리를 하고 있음
- 그런데 서비스 모듈에서도 또 CORS 필터를 추가함
- 특히 POST, OPTIONS 요청에서 Preflight가 두 번 이상 필터를 거치면서 충돌이 발생한다.
결과적으로 하위 서비스에서의 중복 설정이 문제의 원인이었다.
1) Gateway만 CORS 처리하도록 설정
- WebFilter 제거
- spring.cloud.gateway.globalcors 설정만 유지
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins:
- "http://localhost:3000"
- "http://localhost:5173"
- "https://www.naver.com"
allow-credentials: true
allowedHeaders:
- '*'
allowedMethods:
- PUT
- GET
- POST
- DELETE
- OPTIONS
사실 spring.cloud.gateway.globalcors 설정을 제거하고, WebFilter만 단독으로 사용해도 CORS 처리는 가능하다. 나는 별도로 복잡한 커스터마이징 없이, 간단하게 yml 설정만으로 해결했다.
2) 하위 서비스에서 CORS 설정 제거
- 각각의 서비스들에 있는 SecurityConfig에서 cors() 설정과 CorsConfigurationSource 관련 코드 제거
→ CORS는 Gateway만 맡고, 서비스는 신경 쓰지 않도록 통일한다.
생각해 보면 당연한 게 CORS는 브라우저가 "첫 요청을 보내기 전에" 서버에 미리 확인하는 보안 절차다. 즉, CORS 처리는 "요청의 진입점"에서 한 번만 정확하게 이뤄져야 한다. Spring Cloud Gateway는 모든 외부 요청을 가장 먼저 받는 관문이기 때문에, CORS는 Gateway에서만 한 번만 처리해 주는 게 가장 안전하고 명확한 방법이다. 각 서비스들은 Gateway를 통해서만 요청을 받기 때문에,
브라우저가 직접 각 서비스에 요청을 보내는 일이 없다.
→ 즉, 각 서비스들은 CORS와 무관한 내부 호출 대상이므로, CORS 에러가 발생할 수조차 없다.
따라서 하위 서비스에서는 CORS 설정을 아예 제거하고, Gateway에서만 CORS를 통일해서 관리하는 것이 MSA 구조에서 가장 적절한 알맞은 방식이다.
추가 상식 - allowCredentials(true)와 allowedOrigins("*") 조합, 정말 안 되는 걸까?
allowedOrigins: "*"
allowCredentials: true
위 설정은 CORS 관련 설정 중 가장 많이 보는 조합 중 하나이다. "Cannot use wildcard '*' when allowCredentials is set to true" 실제로 이번 문제를 해결하는 과정에서 이 설정이 틀려서 발생하는 오류인가 찾아보며 알게 된 사실이다. 혹여나 이 설정이 문제인 건가 해서 결국은 아래처럼 특정 origin을 명시적으로 열어주는 방식으로 바꿨다
allowedOrigins:
- "http://localhost:3000"
- "http://localhost:5173"
- "https://example.com"
allowCredentials: true
꼭 안 되는 건 아니다?
Spring 공식 이슈 #26111 (댓글 링크)에 따르면, Spring 5.3.2부터는 allowedOriginPatterns가 도입되면서 *도 조건부 허용 가능하다고 명시되어 있다. 댓글을 요약하자면 "allowedOriginPatterns를 사용하면 allowedOrigins("*")와 비슷하게 작동하지만, "*"를 써도 allowCredentials(true)와 함께 사용할 수 있는 방식이다."
- allowedOrigins("*") + allowCredentials(true) → ❌ 불가능
- allowedOriginPatterns("*") + allowCredentials(true) → ✅ 가능 (Spring 5.3.2 이상)
물론, 보안상 주의는 필요하다!!!
allowCredentials(true)는 쿠키, 세션, Authorization 헤더 같은 민감한 정보까지 포함해서 브라우저가 요청을 보내겠다는 뜻이다. 이걸 "*"와 함께 허용하면 "누구나 인증 정보를 가져갈 수 있는 상태"가 되기 때문에 보안상 좋을 것 같진 않다.
그래서 공식 스펙(CORS 명세서)에서도 "*" + allowCredentials(true) 조합은 원칙적으로 금지돼 있다. Spring 5.3.2 이후에는 이를 개발자가 선택적으로 열 수 있도록 풀어준 것뿐이기 때문에, 실서비스에서는 가능하면 "*" 대신 명확한 origin 목록을 명시하는 게 안전한 방식이다. 하지만 그래도 allowedOriginPatterns을 활용하면 저게 문법적으로 틀린 건 아니기 때문에 적어둔다!
마무리하며
이번 경험을 통해 MSA 구조에서 Gateway를 도입하면서 발생할 수 있는 CORS 에러에 대해 다뤄볼 수 있어 좋은 경험이었다.
또한 오류를 해결하면서 allowedOrigins("*") + allowCredentials(true)와 같은 흔히 쓰는 조합이 실제로는 CORS 스펙에 위배되는 조합이라는 것도, 그 과정에서 Spring 5.3.2 이상에서는 allowedOriginPatterns("*")를 통해 이를 우회할 수 있다는 것도 새롭게 알게 되었다. 더불어 무작정 오류를 고치기보단 ‘왜 그런지’ 찾아보는 습관의 중요성도 다시 느끼게 된다..ㅎㅎ
추가로 이번 글과는 조금 벗어난 이야기지만, 이 문제를 해결하는 과정에서 매번 배포하고, 프론트엔드 개발자에게 테스트를 요청할 수는 없었기에 결국 Cursor를 활용해 React 프론트도 직접 구성해 보는 좋은 경험이 되었다. 로컬 환경에서 Gateway와 서비스 서버, 프론트를 동시에 띄우고, 내 로컬 서버로 직접 요청을 보내며 매번 바로바로 CORS 동작 여부를 확인할 수 있어 훨씬 효율적으로 문제를 해결할 수 있었다. 참고로, Cursor로 직접 프론트를 개발한 경험이 궁금하다면 이 글을 참고해보는 것도 좋다!
이번 포스팅이 나처럼 MSA 전환 과정에서 CORS로 고생하는 누군가에게 조금이나마 도움이 되길 바란다?!
'Spring' 카테고리의 다른 글
[Spring] Tech Spec 작성부터 리팩토링 과정 (3) | 2025.06.29 |
---|---|
[Spring] 험난한 리팩토링과 구조 개선의 과정 회고 (3) | 2025.06.05 |
[Spring] SpringBoot Cloud Gateway에서 Swagger 활용 (1) | 2025.04.25 |
[Spring/DB] 실제 운영 서버에서 ddl-auto update의 문제점과 Flyway 도입 (ddl-auto, Flyway) (0) | 2025.02.28 |
[Spring/Test] 분산락을 통한 동시성 제어 - Troubleshooting (4) | 2024.12.08 |