CORS 심화 — 브라우저의 보안 정책과 스프링의 대응 방식
프론트엔드와 백엔드를 분리해서 개발하는데, API 호출이 차단됩니다. 서버 로그에는 아무 에러도 없는데 브라우저 콘솔에만 CORS 에러가 뜹니다. 왜 그럴까요?
CORS 에러가 서버가 아닌 브라우저에서만 발생하는 이유는, CORS가 브라우저의 보안 정책이기 때문입니다. 서버는 정상 응답을 보내지만, 브라우저가 CORS 헤더를 확인하고 JavaScript에서 응답 읽기를 차단합니다.
SOP (Same-Origin Policy)
Origin이란
https://example.com:443/api/users
└─프로토콜─┘ └──호스트──┘└포트┘
Origin 은 프로토콜 + 호스트 + 포트의 조합입니다. 이 세 가지가 모두 같아야 Same-Origin입니다.
| URL A | URL B | Same-Origin? | 이유 |
|---|---|---|---|
| http://localhost:3000 | http://localhost:8080 | X | 포트 다름 |
| https://api.example.com | https://example.com | X | 호스트 다름 |
| http://example.com | https://example.com | X | 프로토콜 다름 |
| https://example.com/a | https://example.com/b | O | 경로만 다름 |
SOP의 목적
SOP는 악의적인 사이트가 다른 사이트의 데이터에 접근하는 것을 방지합니다. SOP가 없다면 evil.com에서 실행되는 JavaScript가 bank.com의 API를 호출해 사용자 계좌 정보를 탈취할 수 있습니다.
그런데 프론트엔드와 백엔드를 분리하는 현대 웹 개발에서는, 정당한 교차 출처 요청도 차단됩니다. CORS는 서버가 "이 출처에서의 요청은 허용한다"고 명시적으로 선언할 수 있게 해주는 메커니즘입니다.
CORS 동작 원리
단순 요청 (Simple Request)
다음 조건을 ** 모두** 만족하면 Preflight 없이 바로 요청합니다.
- 메서드: GET, HEAD, POST
- Content-Type: text/plain, multipart/form-data, application/x-www-form-urlencoded
- 커스텀 헤더 없음
Preflight 요청
단순 요청 조건을 만족하지 않으면 브라우저가 OPTIONS 메서드 로 사전 확인 요청을 보냅니다. Content-Type: application/json이나 Authorization 헤더가 포함되는 대부분의 API 호출에서 Preflight가 발생합니다.
1. 브라우저 → 서버: OPTIONS /api/users (Preflight)
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
2. 서버 → 브라우저: 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
3. 브라우저 → 서버: POST /api/users (실제 요청)
4. 서버 → 브라우저: 200 OK + 데이터
Spring Security CORS 설정
CorsConfigurationSource (권장)
Spring Security를 사용한다면 CorsConfigurationSource 빈으로 전역 CORS를 설정하는 것이 가장 명확합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.build();
}
CorsConfigurationSource에서 허용할 Origin, 메서드, 헤더, 인증 정보 포함 여부를 설정합니다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://myapp.com"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
노출할 응답 헤더와 Preflight 캐시 시간을 설정한 뒤 모든 경로에 적용합니다.
configuration.setExposedHeaders(List.of("Authorization", "X-Custom-Header"));
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@CrossOrigin (컨트롤러 단위)
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class MemberController {
@CrossOrigin(origins = "https://admin.myapp.com")
@GetMapping("/api/admin/members")
public List<MemberDto> getMembers() { ... }
}
Spring Security를 사용할 때는 @CrossOrigin만으로는 부족합니다. Security 필터가 먼저 실행되므로, @CrossOrigin과 별개로 ** 반드시 cors() 설정이 필요 **합니다.
인증과 CORS 조합
credentials 포함 요청
쿠키나 Authorization 헤더를 포함하는 요청에서는 CORS 설정에 특별한 제약이 있습니다.
fetch('http://localhost:8080/api/me', {
method: 'GET',
credentials: 'include',
headers: { 'Authorization': 'Bearer eyJ...' }
});
이때 서버에서 allowedOrigins("*")를 사용하면 에러가 발생합니다. 구체적인 Origin을 명시하거나 allowedOriginPatterns를 사용해야 합니다.
// allowedOrigins("*") + allowCredentials(true)는 동시 사용 불가
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
// 또는 패턴 사용
configuration.setAllowedOriginPatterns(List.of("http://localhost:*"));
configuration.setAllowCredentials(true);
이 제약이 존재하는 이유는 보안 때문입니다. 모든 Origin을 허용하면서 인증 정보까지 포함시키면, 임의의 사이트에서 인증된 요청을 보낼 수 있게 됩니다.
Preflight 캐시
configuration.setMaxAge(3600L); // 1시간 동안 Preflight 결과 캐시
maxAge를 설정하면 브라우저가 Preflight 응답을 캐시하여 동일한 요청에 대해 반복적인 OPTIONS 요청을 생략합니다.
주의할 점
프리플라이트 캐시가 오래되면 설정 변경이 반영되지 않는다
Access-Control-Max-Age로 프리플라이트 응답이 캐시되면, 서버에서 CORS 설정을 바꿔도 브라우저에 즉시 반영되지 않습니다. 개발 중에는 maxAge를 0으로 설정하거나, 브라우저 캐시를 수동으로 비워야 합니다.
프록시로 CORS를 우회하면 프로덕션에서 문제가 된다
개발 환경에서 webpack-dev-server의 proxy 설정으로 CORS를 우회하면 편하지만, 프로덕션에서는 실제 CORS 설정이 필요합니다. 개발 초기부터 올바른 CORS 설정을 해두면 배포 시 의외의 에러를 방지할 수 있습니다.
"No 'Access-Control-Allow-Origin' header" 에러의 세 가지 원인
| 증상 | 원인 | 해결 |
|---|---|---|
| OPTIONS에서 401/403 | Security 필터가 Preflight 차단 | http.cors() 설정 추가 |
| 응답에 CORS 헤더 없음 | CORS 설정 자체가 누락 | CorsConfigurationSource 빈 등록 |
| credentials + 와일드카드 | allowedOrigins("*") + allowCredentials(true) | 구체적 Origin 명시 |
정리
| 항목 | 설명 |
|---|---|
| SOP | 프로토콜 + 호스트 + 포트가 다르면 교차 출처로 간주하는 브라우저 정책 |
| CORS | 서버가 허용하는 Origin, 메서드, 헤더를 응답 헤더로 알려주는 메커니즘 |
| 권장 설정 | Spring Security 사용 시 CorsConfigurationSource 빈으로 전역 설정 |
| credentials 제약 | allowCredentials(true)와 allowedOrigins("*") 동시 사용 불가 |
| Preflight 캐시 | maxAge로 반복 OPTIONS 요청 생략 가능, 개발 시 0으로 설정 |