본문 바로가기
Spring Framework/스프링

스프링 CORS 해결 방법 + CORS 동작 방식과 의미

by 스코리아 2024. 5. 31.

안녕하세요, 스코리아입니다.

오늘은 CORS가 무엇이고 어떻게 동작하는지 알아본 뒤, 스프링 서버에서 발생하는 CORS 문제의 해결 방안에 대해서 소개해드리겠습니다.

CORS

 

지긋지긋한 CORS 오류

Frontend(ex: React)와 Backend(ex: Spring) 서버를 분리하여 작업하다 보면, 한 번쯤은 마주쳤을 'No Access-Control-Allow-Origin' CORS 문제입니다. 처음 CORS 문제를 발견하였을 때, 해결하기 위해 정말 다양하고 기가 막힌(?) 방법을 시도하였던 기억이 있습니다. 

 

CORS 에러는 Frontend와 Backend 서버의 Origin(도메인)이 달라 발생합니다. 아래에서 자세히 살펴보겠습니다.

브라우저 Console에 찍힌 Access-Control-Allow-Origin CORS 에러
CORS 문제를 해결하기 위한 Github commit 기록

 

CORS란 무엇인가?

CORS란, Cross-Origin Resource Sharing의 줄임말로, 다른 Origin(출처) 간의 Resource(자원)을 공유하는 정책입니다. 여기서 Origin은 요청이 시작되는 서버의 위치를 나타냅니다. 즉, 요청 서버의 URL 주소라고 생각하시면 됩니다.

 

결론적으로, CORS 문제는 소통하려는 서버끼리 Origin이 달라 발생합니다.

1. Origin 구분하기

Origin 구분 방법

 

보통 URL은 Protocol(http, https), Host(domain), Port, Query String, Fragment로 이루어져 있습니다.

이 중에서 Protocol, Host, Port 중 하나라도 같지 않는 게 있다면, CORS 에러가 발생하게 됩니다. (Query String과 Fragment는 달라도 무방)

 

아래에 예시를 살펴보세요.

http://jwt.abz.kr/test <-> https://jwt.abz.kr/test : CORS 에러 발생함 (Protocol이 다르므로)
https://jwt.abz.kr/test1 <-> https://jwt.abz.kr/test2 : CORS 에러 발생하지 않음 (Protocol, Host, Port이 모두 같으므로)

 

2. SOP(Same-Origin Policy)

SOP는 2011년 RFC 6454에서 처음으로 발견된 보안 정책으로, 동일한 출처의 Origin만 리소스를 공유하게끔 하는 정책입니다.

 

과거에는 보안을 위해서 엄격하게 같은 Origin끼리만 소통하도록 허용하였으나, 요즘에는 다른 Origin끼리 소통해야 하는 경우가 많아져, SOP의 예외 조항인 CORS 정책이 생겨나게 되었습니다.

 

3. Access-Control-Allow-Origin

CORS 문제를 해결하기 위해서는 Access-Control-Allow-Origin 설정이 필히 필요합니다.

 

여기서 Access-Control-Allow-Origin란, 리소스 접근을 허용할 Origin List를 뜻합니다. 즉, Access-Control-Allow-Origin에 담겨 있는 List와 리소스에 접근한 Origin을 비교하여, 일치하는 경우가 없으면 CORS 에러가 발생하고, 일치하는 경우가 있으면 발생하지 않게 됩니다. 화이트리스트 설정과 비슷하다고 생각하시면 되겠습니다.

 

클라이언트가 다른 Origin을 요청할 때, 브라우저가 요청 Header에 'Origin: https://jwt.abz.kr'와 같이, Origin 필드를 함께 보내게 되면, 서버는 응답 헤더에 Acess-Control-Allow-Origin을 담아서 보내주고, 응답을 받은 브라우저는 Access-Control-Allow-Origin 헤더 안에 있는 List에 자신이 보냈던 Origin이 들어있는지 확인하게 됩니다. 만약 들어있지 않다면, CORS 정책을 위반하게 됩니다.

 

CORS가 동작하는 방식에는 크게 Preflight Request, Simple Request이 있습니다. 하나씩 살펴보겠습니다.

 

(1) Preflight Request

Preflight Request에서는 브라우저가 요청을 보낼 때 한 번에 보내지 않고 예비 요청(OPTIONS 메소드)과 본 요청으로 나누어서 브라우저 스스로 이 요청이 안전한지 확인하는 과정을 거치게 됩니다.

 

CORS - Preflight Request

 

브라우저가 서버에게 예비 요청을 보내면, 서버가 브라우저에게 예비 요청의 응답으로 준 Access-Control-Allow-Origin 값에서 Origin값이 있는지 확인하여 CORS를 위반했는지 확인하게 됩니다.

만약 허용되지 않은 Origin이거나 예비 요청(Preflight Request)이 성공하지 못하면 CORS 에러가 발생하고, 성공한다면 본 요청을 받아서 실제 서버 리소스를 받아오게 됩니다.

 

(2) Simple Request

Simple Request에서는 예비 요청을 보내지 않고, 서버에게 바로 본 요청을 보낸 후, 응답 헤더의 Access-Control-Allow-Origin 값과 Origin값을 비교하여 CORS를 위반했는지 확인하게 됩니다.

 

CORS - Simple Request

 

하지만, Simple Request는 아래의 특정한 조건을 만족해야지만 사용이 가능합니다.

1. Method: GET, HEAD, POST만 허용
2. Header: Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width만 허용
3. Content-Type: application/x-www-form-urlencoded, multipart/form-data, text/plain만 허용

 

이러한 제약조건으로 인해 대부분은 Simple Request보다 Preflight Request를 더 많이 사용하게 됩니다.

특히 API 서버는 JSON으로 데이터를 주고받기 때문에 application/json이라는 Content-Type을 사용하게 되는데, 이는 Simple Request의 조건에 위반되므로, Preflight Request를 필히 사용하게 됩니다.

 

 

스프링에서 CORS 설정 방법

먼저 필자는 Frontend(React)와 Backend(Spring) 서버가 분리되어 있는 상태입니다. Frontend 서버의 주소는 'https://jwt.abz.kr'이고, Backend 서버의 주소는 'https://api.jwt.abz.kr'로, Protocol(https)과 Port(443)는 동일하지만 Host 주소가 다르므로 Origin이 다르다고 볼 수 있겠습니다.

CORS error + preflight requested

 

Origin이 다르므로, 위 사진과 같이 CORS 에러가 발생합니다. 위에서 말씀드렸다시피, CORS 문제를 해결하기 위해서는 Access-Control-Allow-Origin 헤더의 List에 허용할 Origin(Frontend URL 주소)를 추가해주어야 합니다. 

 

그럼 이제 스프링에서 CORS 문제를 해결해 보겠습니다.

저는 스프링스프링 시큐리티 3.1.0 버전(최신 버전)을 사용했습니다. 3.0 이전 버전과는 세팅이 다를 수 있으니 참고 바랍니다.

 

[Kotlin] SecurityConfig 파일

@Configuration
@EnableWebSecurity
class SecurityConfig() {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .formLogin { it.disable() }
            .httpBasic { it.disable() }
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .cors { it.configurationSource(corsConfigurationSource()) } // CORS 관련 설정
            .authorizeHttpRequests {
                it
                    .requestMatchers("/api/member/find/**", "/api/member/signup/**", "/api/member/login/oauth2", "/api/member/login", "/api/member/token/refresh/issue")
                    .anonymous()
                    .requestMatchers("/api/member/**").hasRole("MEMBER")
                    .anyRequest().permitAll()
            }
            .build()
    }

    // CORS 관련 설정
    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val config = CorsConfiguration()
        config.allowedOrigins = listOf("https://jwt.abz.kr") // frontend url
        config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
        config.allowedHeaders = listOf("*")
        config.allowCredentials = true
        config.maxAge = 3600L

        val source = UrlBasedCorsConfigurationSource()
        source.registerCorsConfiguration("/**", config)
        return source
    }
}

 

[Java] SecurityConfig 파일

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 관련 설정
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/api/member/find/**", "/api/member/signup/**", "/api/member/login/oauth2", "/api/member/login", "/api/member/token/refresh/issue")
                        .anonymous()
                        .requestMatchers("/api/member/**").hasRole("MEMBER")
                        .anyRequest().permitAll())
                .build();
    }

    // CORS 관련 설정
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList("https://jwt.abz.kr")); // frontend url
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

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

 

corsConfigurationSource 메소드 안의 내용을 살펴보겠습니다.

1. Allowed Origins : 어떤 도메인에서 오는 요청을 허용할 것인지 (List)
2. Allowed Methods :허용할 HTTP 메소드 (List)
3. Allowed Headers: 허용할 HTTP 헤더 (List)
4. Allow Credentials: 인증 정보(쿠키 등)를 포함한 요청을 허용할 것인지 (Boolean)
5. Max Age: Preflight Request 결과의 최대 유효시간

 

이러한 설정을 포함한 CorsConfiguration 객체는 UrlBasedCorsConfigurationSource 객체에 Bean으로 등록되어 전체 애플리케이션에 대한 CORS 정책으로 사용됩니다.

 

또한 '.cors(cors -> cors.configurationSource(corsConfigurationSource()))' 코드는 corsConfigurationSource 메소드를 통해 정의된 CORS 설정이 HttpSecurity 객체에 연결하게 해 줍니다.

 

CORS 문제 해결 완료

 

이렇게 간단하게 코드 몇 줄을 추가함으로써, 스프링 CORS 오류를 해결할 수 있었습니다.

앞단에 Cloudfront와 같은 CDN이 있을 경우, CORS 문제가 완벽하게 해결되지 않을 수 있습니다. 이와 관련한 문제와 해결방안에 대한 경험은 다음 포스팅 때 적어보겠습니다.


 

이상으로 CORS의 의미, 원리, 동작 방식에 대해서 알아보았고 스프링에서 CORS를 해결할 수 있는 자바/코틀린 코드를 소개드렸습니다.

읽어주셔서, 감사합니다.