프론트엔드(localhost:3000)에서 백엔드(localhost:8080)로 API를 호출했더니 브라우저가 요청을 차단합니다. 서버는 정상인데 왜 그럴까요?

브라우저에는 동일 출처 정책(Same-Origin Policy) 이라는 보안 규칙이 있습니다. 프로토콜, 도메인, 포트 중 하나라도 다르면 "다른 출처"로 간주하고 응답 읽기를 차단합니다. CORS(Cross-Origin Resource Sharing) 는 서버가 "이 출처에서 오는 요청은 허용한다"고 명시적으로 알려주는 HTTP 헤더 기반 메커니즘입니다.

CORS(Cross-Origin Resource Sharing)란

CORS 는 브라우저의 동일 출처 정책에 의해 차단되는 교차 출처 요청을, 서버가 응답 헤더를 통해 선택적으로 허용할 수 있게 해주는 메커니즘입니다.

(참고) 출처(Origin)는 프로토콜 + 도메인 + 포트 조합을 의미합니다.

  • http://localhost:3000http://localhost:8080은 포트가 다르므로 서로 다른 출처입니다.
  • http://example.comhttps://example.com은 프로토콜이 다르므로 다른 출처로 취급됩니다.

브라우저의 CORS 요청 흐름

cors-options-request-diagram

브라우저가 교차 출처로 HTTP 요청을 보낼 때, 해당 요청이 ** 단순 요청(Simple Request)** 조건을 만족하는지에 따라 동작이 달라집니다.

단순 요청(Simple Request) 조건

구분조건
** 메서드**GET, HEAD, POST 중 하나
** 요청 헤더**브라우저가 기본으로 붙이는 단순 헤더만 포함, 커스텀 헤더 없음
** 본문 타입**POST인 경우 Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나

이 조건을 모두 만족하면 브라우저는 프리플라이트 없이 바로 요청을 보냅니다. 조건을 만족하지 않으면 ** 프리플라이트(OPTIONS) 요청 **을 먼저 보냅니다.

프리플라이트(OPTIONS)란

단순 요청 조건을 만족하지 않는 교차 출처 요청에 대해, 브라우저가 ** 실제 요청을 보내기 전에 "이 요청을 보내도 되는지" 서버에 물어보는 OPTIONS 요청 **입니다.

예를 들어, Content-Type: application/json으로 POST 요청을 보내면 단순 요청 조건에 해당하지 않기 때문에 프리플라이트가 발생합니다.

프리플라이트 요청/응답에서 주로 사용하는 헤더는 다음과 같습니다.

종류헤더 이름의미
** 요청**Origin요청을 보내려는 페이지의 출처
** 요청**Access-Control-Request-Method실제로 사용할 HTTP 메서드
** 요청**Access-Control-Request-Headers실제 요청에 포함할 커스텀 헤더 목록
** 응답**Access-Control-Allow-Origin허용하는 Origin
** 응답**Access-Control-Allow-Methods허용하는 메서드
** 응답**Access-Control-Allow-Headers허용하는 요청 헤더

브라우저는 이 OPTIONS 응답을 확인한 뒤, 조건이 맞으면 실제 요청을 전송합니다.


스프링에서 CORS를 설정하는 세 가지 방법

1. @CrossOrigin — 컨트롤러/메서드 단위

특정 엔드포인트에만 예외적으로 CORS를 허용할 때 사용합니다.

JAVA
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public User createUser(@RequestBody CreateUserRequest request) {
        // ...
    }
}

설정이 컨트롤러 곳곳에 흩어지기 때문에, 전역 정책보다는 예외적인 경우에만 사용하는 것이 좋습니다.

2. WebMvcConfigurer — MVC 전역 설정

여러 컨트롤러에 공통 CORS 정책을 적용할 때 가장 관리하기 쉬운 방법입니다.

JAVA
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

이 설정은 DispatcherServlet 이후 HandlerMapping 단계에서 적용됩니다.

3. CorsFilter — 서블릿 필터 레벨

서블릿 필터 체인에서 CORS를 처리하는 방식입니다. Spring MVC 외 다른 서블릿까지 CORS를 적용해야 할 때 사용합니다.

JAVA
@Configuration
public class CorsFilterConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://localhost:3000");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

스프링 시큐리티의 CORS 처리

스프링 시큐리티를 사용한다면 http.cors() 설정이 ** 거의 필수 **입니다. 시큐리티 필터가 MVC 핸들러보다 먼저 실행되기 때문에, @CrossOrigin이나 WebMvcConfigurer만으로는 프리플라이트 요청이 시큐리티 필터에서 차단될 수 있습니다.

JAVA
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .build();
}

http.cors()를 설정하면 시큐리티가 내부적으로 CorsFilter를 SecurityFilterChain에 등록하여, 시큐리티 필터 단계에서 프리플라이트와 실제 요청 모두 CORS 헤더를 처리합니다.


세 가지 레이어의 차이

spring-cors-layers-architecture

레이어적용 지점범위설정 방식
Servlet-level서블릿 필터 체인 (DispatcherServlet 이전)모든 요청@Bean CorsFilter
Security-levelSecurityFilterChain 내부Security 관리 하 모든 요청http.cors().configurationSource(...)
MVC-levelHandlerMapping (DispatcherServlet 이후)Spring MVC 핸들러만WebMvcConfigurer 또는 @CrossOrigin

CORS 에러가 발생했을 때, ** 어느 레이어에서 막힌 것인지 **를 파악하는 것이 디버깅의 핵심입니다.

  • ** 프리플라이트(OPTIONS)가 컨트롤러까지 안 온다면** → Servlet/Security 필터 체인에서 차단 → CorsFilter 또는 http.cors() 설정 확인
  • ** 프리플라이트는 통과하는데 실제 요청에서 에러가 난다면** → MVC 핸들러 매핑 단계 문제 → WebMvcConfigurer 또는 @CrossOrigin 설정 확인
  • @CrossOrigin을 여러 곳에 붙였는데 동작이 들쭉날쭉하다면 → 로컬 설정과 전역 설정 충돌 → 전역 설정 하나로 통일

주의할 점

allowedOrigins("*")와 allowCredentials(true)는 동시에 쓸 수 없다

CORS 스펙상 Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true는 함께 사용할 수 없습니다. 쿠키를 보내야 한다면 allowedOriginPatterns를 사용하거나, 구체적인 출처를 명시해야 합니다.

Spring Security CORS와 Spring MVC CORS를 이중으로 설정하면 혼란

@CrossOriginhttp.cors()를 동시에 설정하면 어떤 설정이 적용되는지 파악하기 어렵습니다. Spring Security를 사용한다면 http.cors()에서 통합 관리하는 것이 명확합니다.


정리

항목설명
CORS브라우저의 동일 출처 정책을 서버가 응답 헤더로 완화하는 메커니즘
프리플라이트단순 요청 조건 미충족 시 브라우저가 먼저 보내는 OPTIONS 요청
스프링 시큐리티 필수http.cors() 없으면 프리플라이트가 시큐리티 필터에서 차단
와일드카드 제약allowedOrigins("*")allowCredentials(true) 동시 사용 불가
디버깅 핵심CORS 에러 시 Servlet → Security → MVC 순서로 레이어 확인
댓글 로딩 중...