🔖Contents
🎯 학습 목표
1. CORS가 왜 로컬 환경에서 발생하는지와, 이것이 서버 문제가 아니라 브라우저 보안 정책이라는 점을 이해한다.
2. Preflight(OPTIONS) 요청이 언제 발생하며, Spring Security 환경에서 이것이 차단되면 왜 CORS 에러로 이어지는지 설명할 수 있다.
3. Spring Security에서 CORS를 직접 설정해야 하는 이유와, WebMvcConfigurer만으로는 해결되지 않는 구조적 원인을 이해한다.
로컬 개발을 하다 보면 어느 순간 갑자기 프론트엔드 콘솔에 빨간 글씨가 쏟아지고, 서버는 멀쩡히 돌아가는 것처럼 보이는데 브라우저만 혼자 “안 돼”라고 말하는 상황을 만나게 됩니다. 저는 이번에 로컬 환경에서 프론트엔드에서 API를 호출하는 순간 CORS 문제가 발생했고, 그 문제를 Spring Security 설정으로 해결했습니다. 이 글은 그때의 흐름을 최대한 풀어서, “왜 이런 일이 벌어졌는지”, “어디에서 막혔는지”, “Spring Security에서 왜 CORS 설정이 핵심이었는지”를 깊게 설명하는 기록입니다.
1. CORS 문제란 무엇인가
CORS(Cross-Origin Resource Sharing)는 브라우저가 “다른 출처(origin)”로 요청을 보낼 때 기본적으로 막아두는 보안 정책(SOP, Same-Origin Policy)을 “서버가 허용해줄 경우만” 예외로 풀어주는 규칙입니다. 여기서 출처(origin)는 보통 아래 3개가 모두 같아야 “같은 출처”예요.
- 스킴: http, https
- 호스트: example.com
- 포트: 3000, 8080 등
예: http://localhost:3000 → http://localhost:8080 요청은 포트가 달라서 다른 출처입니다.
2. 대표 증상 (프론트는 실패, 서버는 성공처럼 보일 때가 많음)
CORS는 브라우저가 막는 것이라서, 서버 로그에는 “요청이 안 들어온 것처럼 보이거나” 혹은 “OPTIONS만 들어온 것처럼 보이는” 경우가 흔합니다. 크롬 콘솔에서 흔히 보이는 메시지:

- No 'Access-Control-Allow-Origin' header is present...
- CORS policy: The request client is not a secure context...
- Response to preflight request doesn't pass access control check
- The value of the 'Access-Control-Allow-Origin' header in the response must not be '*' when the request's credentials mode is 'include'
로컬에서 프론트가 http://localhost:3000이고 백엔드가 http://localhost:8080이면, 둘은 호스트는 같아 보여도 포트가 다르기 때문에 다른 출처가 됩니다. 브라우저는 기본적으로 이 “다른 출처” 요청을 허용하지 않고, 서버가 “이 출처는 허용해도 돼”라고 명시적으로 알려주는 경우에만 요청을 통과시킵니다. 그 “명시적으로 알려주는” 방법이 CORS 헤더입니다.여기서 중요한 포인트가 하나 있습니다. CORS는 서버가 막는 게 아니라 브라우저가 막습니다.
그래서 지금처럼 로컬에서 이런 상황이 꽤 자주 나옵니다.
- 서버 로그: “요청이 안 들어온 것 같은데?”
- 프론트 콘솔: CORS policy... blocked
실제로는 브라우저가 서버 응답을 받아놓고도 “CORS 기준을 통과 못했으니 프론트 코드에는 전달하지 않을게”라고 막아버리는 경우가 많습니다. 제가 겪었던 케이스를 포함해서, CORS는 보통 두 가지 형태로 나타납니다.
2.1 “Access-Control-Allow-Origin이 없다”
대표적인 콘솔 메시지는 이런 느낌입니다.
- No 'Access-Control-Allow-Origin' header is present on the requested resource
이건 말 그대로 서버 응답에 Access-Control-Allow-Origin 헤더가 없어서, 브라우저가 차단한 상황입니다. 즉, 서버가 “어떤 origin을 허용한다”는 정보를 내려주지 않았습니다.
2.2 “preflight가 실패했다”
이게 로컬에서 특히 더 자주 터집니다.
- Response to preflight request doesn't pass access control check
브라우저는 어떤 요청에 대해서는 실제 요청을 보내기 전에 OPTIONS 요청을 먼저 보냅니다. 이걸 Preflight(사전 검사) 라고 부릅니다. 그리고 이 preflight가 실패하면, 브라우저는 본 요청(예: POST/PUT/DELETE)을 아예 보내지 않습니다. “서버에 최종 요청을 보내도 되는지”를 미리 물어보고, 허용이 안 되면 시도조차 하지 않는 겁니다.
2.3 Preflight(OPTIONS)는 왜 뜨는가
로컬에서 CORS가 갑자기 터졌다면, 저는 제일 먼저 “preflight가 떴는지”부터 의심하는 편이 좋다고 생각합니다. 이유는 간단합니다. 많은 경우 “내가 헤더를 하나 추가했을 뿐인데” preflight가 생기고, 그 preflight가 Security에서 막히면서 CORS 문제가 폭발합니다.preflight가 생기는 대표 조건은 다음과 같습니다.
- Authorization 헤더를 붙였다 (JWT 등)
- 커스텀 헤더를 붙였다 (예: X-User-Role, X-User-Id)
- PUT/PATCH/DELETE 같은 메서드를 쓴다
- 브라우저가 “단순 요청(simple request)”이 아니라고 판단한다
(대부분의 JSON API 호출은 이쪽으로 걸리는 경우가 많습니다)
여기서 중요한 건, preflight는 OPTIONS 요청이라는 점입니다. 즉, 브라우저가 먼저 이렇게 묻습니다.
“내가 http://localhost:3000에서 http://localhost:8080로 POST 요청 보내려고 하는데,
Authorization 헤더도 포함할 거야. 이거 허용돼?”
그리고 서버는 OPTIONS 요청에 대해 아래 같은 정보를 정상적으로 돌려줘야 합니다.
- Access-Control-Allow-Origin: http://localhost:3000
- Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
- Access-Control-Allow-Headers: Authorization, Content-Type, ...
이게 안 내려오거나, OPTIONS 자체가 401/403 같은 응답으로 떨어지면 preflight는 실패하고, 브라우저는 본 요청을 보내지 않습니다.
3. 내가 Spring Security 설정으로 해결해야 했던 이유
저는 결국 이 문제를 Spring Security 설정으로 해결했습니다. 이 흐름이 중요한 이유는, 많은 프로젝트에서 CORS 설정을 어디선가 해뒀는데도 계속 터지는 케이스가 대부분 Security 필터 체인 때문입니다. 한 문장으로 요약하면 이렇습니다.
“CORS 헤더를 내려주기 전에 Security가 요청을 먼저 막아버리면, 브라우저는 CORS 실패로 인식한다.”
특히 preflight OPTIONS 요청은 “인증 전 요청”의 성격이 강합니다. 브라우저가 “진짜 요청 보내기 전에 물어보는” 단계라서, 여기서 OPTIONS를 인증 대상으로 취급해버리면 이런 일이 일어납니다.
- OPTIONS 요청이 Security에서 401/403으로 떨어짐
- CORS 헤더가 내려올 기회가 없음
- 브라우저는 “preflight 실패”라고 판단하고 본 요청을 차단
즉, 서버 입장에서는 “인증 안 됐으니 차단”이 논리적으로 맞아 보이지만, 브라우저 흐름에서는 그 단계에서 막히면 CORS가 해결될 수가 없습니다. 그래서 Spring Security가 들어간 프로젝트에서는 CORS 문제가 터질 때 다음을 거의 확실하게 확인해야 합니다.
- Security에서 CORS를 활성화했는가?
- OPTIONS 요청을 허용했는가?
- 허용 Origin / Methods / Headers가 실제 프론트 요청과 일치하는가?
3.1 CORS 허용 목록
CORS 해결을 하다 보면 흔히 “그냥 다 허용하면 되지 않나?”라는 유혹이 생깁니다. 실제로 * 같은 와일드카드를 써버리면 되는 경우도 있지만, 인증이 붙기 시작하면 함정이 있습니다. 예를 들어 쿠키 기반이거나 credentials: include로 인증 정보를 포함하는 요청에서는 이런 규칙이 있습니다.
- Access-Control-Allow-Origin: * 불가능
- 허용 Origin을 구체적으로 지정해야 함(예: http://localhost:3000)
JWT를 Authorization 헤더로 보내는 경우는 쿠키만큼 민감하진 않더라도, 보통 실무에서는 “구체적 Origin 허용”이 안전하고 디버깅도 쉽습니다. 그래서 해결의 방향은 결국 다음 둘 중 하나로 좁혀졌니다. Spring Security 필터 체인에서 CORS 처리를 올바르게 활성화하고 OPTIONS(preflight)를 막지 않도록 허용 규칙을 잡는 것입다.
3.2 진짜 범인은 Preflight(OPTIONS)
로컬에서 CORS가 갑자기 터질 때 대부분의 원인은 preflight(사전 검사)입니다. 브라우저는 어떤 요청이 “단순 요청(simple request)”이 아니라고 판단하면 실제 요청 전에 OPTIONS 요청을 먼저 보냅니다. 이게 preflight입니다. preflight가 생기는 대표 조건은 다음과 같습니다.
- Authorization 헤더를 붙인다 (JWT)
- 커스텀 헤더를 붙인다
- PUT/PATCH/DELETE 같은 메서드를 쓴다
- JSON 기반 호출(특히 다양한 헤더가 섞이면 거의 발동)
즉, 브라우저는 먼저 이렇게 묻습니다.
“내가 이 origin에서 저 origin으로 요청하려고 하는데, Authorization 헤더도 쓰고, POST도 할 건데, 허용돼?”
서버가 이 OPTIONS 요청에 대해 아래를 제대로 응답해줘야만 브라우저가 본 요청을 보냅니다.
- Access-Control-Allow-Origin
- Access-Control-Allow-Methods
- Access-Control-Allow-Headers
- (인증정보 포함 시) Access-Control-Allow-Credentials
그리고 여기서 문제가 터지기 쉬운 곳이 바로 Spring Security입니다.
3.3 “WebMvcConfigurer로 CORS 설정했는데도” 안 되는 이유
당시 저도 처음에는 이렇게 생각했습니다.
“CORS는 WebMvcConfigurer에서 전역 설정하면 끝 아닌가?”
그래서 아래처럼 CorsConfig를 만들 수 있습니다. (제가 실제로 작성한 형태)
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:3000",
"https://wego.monster",
"https://api.wego.monster",
"https://local.wego.monster",
"https://local.wego.monster:3000"
)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}
그런데 Spring Security가 존재하는 프로젝트에서는 이게 “항상” 먹히지 않습니다.
이유는 단순합니다. Security 필터 체인이 MVC보다 앞에서 요청을 잡아버릴 수 있습니다.
브라우저가 보낸 OPTIONS(preflight) 요청이 Security에서 인증/인가 규칙에 걸려서 401/403으로 떨어져 버리면, MVC 레벨의 CORS 설정(WebMvcConfigurer)이 동작할 기회 자체가 없어집니다. 그러면 브라우저는 이렇게 판단합니다.
- “preflight가 실패했다”
- “본 요청은 보내지겠다”
- “CORS policy에 의해 차단되었다”
그래서 Postman으로는 잘 되는데, 브라우저에서만 안 되는 장면이 만들어집니다. Postman은 preflight 자체가 없거든요(브라우저 보안 정책을 따르지 않음). 결론적으로 Security를 쓰는 서비스에서 CORS 문제를 “확실하게” 해결하려면, Security가 CORS를 처리하도록 설정을 올리는 게 정석입니다.
4. 해결 방법: SecurityFilterChain에서 cors() 설정
제가 최종적으로 적용한 핵심은 이 라인입니다.
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
즉, CORS 설정을 MVC 레벨이 아니라 SecurityFilterChain 레벨에서 확실히 태워서,
- preflight(OPTIONS) 요청도
- Security 필터 체인을 통과하는 과정에서
- 올바른 CORS 헤더를 붙여 응답하게 만든 겁니다.
그리고 저는 OPTIONS 요청을 명시적으로 permitAll 처리했습니다.
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
이게 생각보다 중요합니다. preflight는 “사전 검사”라서, 여기서 인증이 필요하다고 판단하면 브라우저는 본 요청을 아예 안 보냅니다. 즉, OPTIONS는 인증 논리로 막는 대상이 아니라 “통과시켜야 하는 교통정리 대상”에 가깝습니다.
4.1 Security 설정 코드
당시 제가 적용한 SecurityConfig에는 중요한 요소가 몇 가지 있습니다.
1) Security에서 CORS를 처리하게 만든다
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
이렇게 하면 CorsConfigurationSource가 Security 내부에서 사용됩니다.
결과적으로 preflight 응답에도 CORS 헤더가 안정적으로 들어갑니다.
2) OPTIONS 전체 허용: preflight가 막히면 게임 끝
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
이 라인이 없으면, 어떤 엔드포인트는 preflight 단계에서 401/403이 되어버리고, 프론트는 “CORS 실패”로 보게 됩니다.
3) Stateless + JWT 필터: 인증은 JWT로만
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
JWT 기반이면 보통 이렇게 가고, 이때도 마찬가지로 preflight가 JWT 필터에 의해 “인증 실패”로 처리되면 문제가 생길 수 있는데, OPTIONS permitAll이 그걸 방지해줍니다.
4) allowCredentials(true)를 쓸 때 Origin은 반드시 구체적으로
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("http://localhost:3000", ...));
allowCredentials(true)는 쿠키/인증정보 포함 요청을 허용하겠다는 의미로 해석될 수 있고(브라우저 정책상), 이 경우 Access-Control-Allow-Origin: *가 불가능합니다. 그래서 “명시적 origin 허용”이 필요합니다.
4.2 그런데 왜 두 개 설정을 같이 쓰면 안 되는 이유
여기서 핵심 결론입니다. WebMvcConfigurer 기반 CORS 설정과 SecurityFilterChain 기반 CORS 설정을 동시에 올려두면 “둘 다 적용될 것 같지만”, 실무에서는 오히려 혼란의 원인이 됩니다. 왜냐하면 “CORS 처리가 어디서 끝나는지”가 일관되지 않기 때문입니다
- 어떤 요청은 Security에서 CORS 처리로 끝나고
- 어떤 요청은 MVC에서 처리되고
- preflight는 Security에서 막히거나/통과하거나
- 응답 헤더가 중복되거나(브라우저가 싫어함)
- 환경별로 동작이 달라 보이기도 합니다
특히 Security가 있는 환경에서는 “CORS의 최종 결정권”을 Security 쪽으로 몰아주는 것이 안정적입니다.
즉, Security를 쓴다면 CORS도 Security에서 끝내는 것으로 정하자고 생각했습니다.

결론은 Security 설정 하나로 통일입니다. 제가 이 상황에서 가장 추천하는 방식은 이겁니다.
- CorsConfig(WebMvcConfigurer)는 제거하거나 비활성화
- SecurityConfig에서 http.cors(...) + CorsConfigurationSource로 통일
왜냐하면 브라우저가 겪는 CORS 문제의 절반 이상은 “preflight가 Security에서 막힘”이기 때문이고,
이건 MVC 설정만으로는 확실히 해결하기 어렵기 때문입니다.
'💭Retrospective' 카테고리의 다른 글
| 대격변 AI 시대에서 신입 개발자의 공부는 무엇일까? (0) | 2026.02.24 |
|---|---|
| [인프런] 3월 무한 작심삼일 챌린지 - 공부한 만큼 상금이 커져요! (0) | 2026.02.23 |
| 모임 참가 신청 API: 모임 최대 참가자 수 동시성 문제 해결하기 (0) | 2026.01.10 |
| 이미지 로딩 속도와 크기 70% 단축: WebP 변환부터 Redis 프리업로드로 URL을 보증하기 (0) | 2026.01.10 |
| 서평단: 그림으로 이해하는 도커와 쿠버네티스 (0) | 2026.01.09 |