프로젝트가 끝나갈 무렵 프로젝트 배포 단계에서 예상치 못한 CORS 에러를 만났다. 로컬에서는 잘 돌아갔었는데 배포 환경에서는 작동하지 않았다. 단순히 설정을 빼먹어서 생긴 문제였지만, 이번 기회에 CORS가 정확히 무엇이고 왜 필요한지 제대로 이해하고 정리해보려고 한다.

CORS란?
CORS는 Cross-Origin Resource Sharing의 약자로 교차 출처 리소스 공유를 의미한다.

웹 브라우저는 보안상의 이유로 다른 출처(Origin)의 리소스를 요청하는 것을 기본적으로 차단한다. 이를 동일 출처 정책(Same-Origin Policy)이라고 한다.
하지만 현대 웹 개발에서는 프론트엔드와 백엔드가 다른 서버에서 동작하는 경우가 많다. React는 localhost:3000에서, Spring Boot는 localhost:8080에서 실행되는 것처럼 말이다. 이때 CORS 설정이 없으면 브라우저가 요청을 차단한다.
출처(Origin)란?
출처는 프로토콜 + 도메인 + 포트로 구성된다.

이 세 가지 중 하나라도 다르면 다른 출처로 간주된다.
왜 Same-Origin Policy가 필요한가요?
보안 때문이다. 만약 이 정책이 없다면 악의적인 사이트가 사용자의 정보를 몰래 빼갈 수 있다.

공격 시나리오 예시
- 사용자가 은행 사이트(bank.com)에 로그인
- 쿠키에 인증 정보 저장됨
- 악의적인 사이트(evil.com) 방문
- evil.com의 JavaScript가 bank.com으로 요청 시도
- 브라우저가 자동으로 쿠키 포함하여 요청
만약 Same-Origin Policy가 없다면 4번 단계에서 사용자 모르게 은행 정보가 유출될 수 있다.
CORS는 어떻게 동작하나요?
CORS는 서버가 "이 출처는 허용할게요"라고 브라우저에게 알려주는 메커니즘이다.

1. 단순 요청 (Simple Request)

GET, POST 같은 단순한 요청은 바로 보내고, 서버의 응답 헤더를 확인한다.
클라이언트 요청:
Origin: http://localhost:3000
서버 응답:
Access-Control-Allow-Origin: http://localhost:3000
2. 사전 요청 (Preflight Request)

PUT, DELETE 같은 요청이나 커스텀 헤더를 사용하면 사전 요청을 먼저 보낸다.
이 과정에서 서버가 허용하지 않으면 실제 요청은 보내지지 않는다.
프로젝트에서 발생한 문제
우리 프로젝트는 React(프론트엔드)와 Spring Boot(백엔드)로 구성되어 있었다.
로컬 환경
// CORS 설정
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://localhost:3000"); // 프론트엔드 주소
// 생략
}
로컬에서는 SecurityConfig에 localhost:3000을 허용 출처로 설정해뒀기 때문에 문제없이 작동했다.
문제는 배포 환경의 URL을 SecurityConfig에 추가하지 않았다는 것이다. 당연히 CORS 에러가 발생했다.
Access to XMLHttpRequest at 'https://api.our-project.com/api/users'
from origin 'https://our-project.vercel.app' has been blocked by
CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Preflight 요청 확인
팀 미팅 때 강사님께서 피드백을 주셨다.
"Preflight 요청 처리는 확인해보셨나요?"
설정한 기억이 없는데 왜 잘 돌아가지? 라는 의문이 들었다.
신기하게도 우리 프로젝트에서는 OPTIONS 요청이 정상적으로 처리되고 있었다. 이유는 이미 작성해둔 CORS 설정 때문이었다.
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
Spring Security는 .cors() 설정이 있으면 CORS preflight 요청을 자동으로 처리한다. CorsConfigurationSource에서 addAllowedMethod("*")로 설정했기 때문에 OPTIONS를 포함한 모든 HTTP 메서드가 허용되고, Spring Security 내부적으로 CORS 관련 요청을 필터 체인보다 먼저 처리하도록 구현되어 있다.
하지만 명시적으로 설정하는 것이 좋을 것 같아 추가 설정을 했다.
1. SecurityConfig에 CORS 설정 추가
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Preflight 명시적 허용
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 출처
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000", // 로컬
"https://배포서버주소" // 배포 서버 - 이걸 빼먹었었다!
));
// 생략
}
}
2. Preflight 요청 명시적 허용
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
이 한 줄을 추가해서 OPTIONS 요청을 명시적으로 허용했다.
CORS 설정 시 주의사항
1. allowedOrigins에 와일드카드(*) 사용 금지
// 절대 이렇게 하지 말 것!
configuration.setAllowedOrigins(Arrays.asList("*"));
모든 출처를 허용하면 보안에 매우 취약하다. 반드시 명시적으로 허용할 출처만 나열해야 한다.
2. allowCredentials와 allowedOrigins
allowCredentials(true)를 설정하면 allowedOrigins에 와일드카드를 사용할 수 없다.
// 에러 발생!
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowCredentials(true);
// 올바른 방법
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowCredentials(true);
3. 환경별로 다른 출처 관리
개발, 스테이징, 프로덕션 환경마다 URL이 다를 수 있다. application.yml로 관리하면 편하다고 한다.
# application.yml
cors:
allowed-origins:
- http://localhost:3000
- https://our-project-dev.vercel.app
- https://our-project.vercel.app
@Value("${cors.allowed-origins}")
private List<String> allowedOrigins;
configuration.setAllowedOrigins(allowedOrigins);
마치며
처음에는 단순히 "CORS 에러 = 설정 빼먹음"으로만 생각했다. 하지만 제대로 공부해보니 CORS는 웹 보안을 위한 중요한 요소이고, Same-Origin Policy라는 브라우저 보안 정책의 예외를 허용하는 방식이라는 것을 알게 되었다.
배포 서버 URL을 SecurityConfig에 추가하지 않아서 생긴 사소한 실수였지만, 덕분에 CORS의 동작 원리와 Preflight 요청, 그리고 올바른 설정 방법까지 확실하게 학습할 수 있었다.
앞으로는 프로젝트 시작 단계부터 환경별 CORS 설정을 체계적으로 관리해야겠다는 교훈을 얻었다. 그리고 에러가 발생했을 때 단순히 "해결"에만 그치지 않고, "왜 그런지" 이해하려는 자세의 중요성도 다시 한번 느꼈다.
후기
6개월간의 부트캠프 여정이 막을 내렸다. TIL 챌린지 포스팅도 이 글이 마지막이다. 챌린지 덕분에 부트캠프에서 배운것들을 다시 돌아보는 시간을 가질 수 있었고, 프로젝트 하느라 바쁜 와중에도 끝까지 해냈다는게 뿌듯하다. 이렇게 긴 시간동안 무언가를 꾸준히 해본적이 없었던것 같은데 이번 챌린지를 통해 자신감을 얻을 수 있었다. TIL 포스팅 챌린지는 여기까지지만 이 흐름을 이어서 포스팅을 이어가보려 한다.
'Archive > Java 풀스택 아카데미' 카테고리의 다른 글
| [TIL] 22. 12월 Axios Interceptor (문제해결) (1) | 2025.12.16 |
|---|---|
| [TIL] 21. 12월 S3란? (1) | 2025.12.08 |
| [TIL] 20. 12월 Redis란? (0) | 2025.12.02 |
| [TIL] 19. 11월 JPA란 (0) | 2025.11.25 |
| [TIL] 18. 11월 JWT (0) | 2025.11.18 |