CORS.. 웹개발을 하시다보면, 한번쯤은 마주해본 이슈라고 생각합니다.

처음 마주했을 때, 정말 어떤 개념인지 (Spring에서) 어떻게 해결해야 할지 헤맸던 경험이 있어,

제가 학습하고 이해한 CORS에 대해 설명해보고자 합니다.


CORS란?

CORS란 “Cross-Origin Resource Sharing”의 약자로, 웹 애플리케이션에서 다른 도메인 간에 데이터를 공유할 수 있도록 하는 보안 기술을 뜻합니다.

한번에 와닿지 않는 개념이기 때문에, 이어지는 개념들을 접하고 나서 다시 이해해보도록 합시다!

SOP란?

CORS의 반대되는 개념으로 SOP가 있습니다. “Same-Origin Policy”의 약자로, 웹 브라우저가 동일 출처에서 로드된 문서 간에만 상호 작용을 허용하는 보안 정책을 말합니다.

그럼에도 아직 이해가 한번에 되지는 않으리라고 생각합니다.

CORS와 SOP 모두 공통적으로 포함하는 O(Origin)이란 무엇일까요?

Origin

Origin프로토콜, 호스트, 포트로 구성된 URL의 조합을 말합니다. 아래의 간단한 예시로 확인해 봅시다.

2.png

프로토콜(https), 호스트(example.com), 포트(8080)의 조합으로 위의 Origin이 구성되었습니다.

그렇다면 아래의 두 Origin은 포트가 상이하기 때문에 다른 Origin이 되는 것입니다.

3.png

그리고 아래의 두 Origin은 같은 Origin으로 취급됩니다!

4.png

Origin은 “프로토콜, 호스트, 포트”의 조합으로 구성되기 때문에, 이 뒤에 붙는 Path와 Query String이 다르더라도 Origin의 판별에는 영향을 미치지 않습니다.

CORS

이제 다시 SOP와 CORS로 돌아와보면, 둘 다 브라우저 보안의 한 종류로 크롬과 같은 뭽 브라우저에서 설정한 보안 정책입니다.

그래서 SOP는 웹 브라우저가 동일한 Origin 사이에서의 리소스 공유만을 허용하는 보안 정책이고, CORS는 다른 Origin 사이에서의 리소스 공유를 제어하는 메커니즘을 의미합니다.

그렇기 때문에 엄밀히 말하면 CORS는 에러가 아니라 보안 메커니즘이고, CORS 보안 메커니즘에 특정 Origin을 허용하도록 설정해주지 않은 것이 우리가 접해온 CORS error입니다.

추가로, 이런 CORS error는 프론트의 요청 방식에 따라 발생할 수도 있고 발생하지 않을 수도 있습니다.

html의 태그 중에 img, video, script, link 등 태그들이 있는데, 이러한 태그들은 기본적으로 Cross-Origin 정책을 지원합니다. 그렇기 때문에 코드의 href나 src에 설정된 다른 사이트, 즉 다른 Origin의 리소스에 접근하는 것이 가능한 것입니다.

그러나 XMLHttpRequest, Fetch API 스크립트와 같은 프론트 요청에서는 기본적으로 Same-Origin 정책을 따릅니다.

그렇기 때문에 동일한 기능이라고 하더라도, 태그의 src 요청으로 가져오느냐, 자바스크립트 ajax 요청으로 가져오느냐에 따라 CORS 이슈가 발생하는 것입니다.

CORS의 필요성

그렇다면 이러한 CORS는 무슨 필요성이 있길래 이런 혼돈을 우리에게 주는 걸까요?

5.jpeg

이미지 출처: https://blog.postman.com/what-is-cors/

보안 상의 이유로, 브라우저는 다른 도메인에서 자원을 로드하는 것을 차단합니다. 이로 인해 악의적인 사이트에서 사용자 정보에 접근하는 것을 방지할 수 있습니다.

사실 출처가 다른 두 어플리케이션이 자유롭게 소통하는 환경은 꽤 위험한 환경이라고 합니다. 만약 CORS와 같은 제약이 없다면 악의적인 유저가 CSRFXSS 등의 방법을 이용해서 개인정보를 가로챌 수 있다고 합니다. 그래서 필요한 경우에만 다른 Origin 간의 소통(자원 공유)을 허용해주어야 하는 것입니다.

CORS의 동작 원리

위에서 다룬 필요성에 따라 서버에서 설정해준 대로, 브라우저가 Origin을 비교하고 차단 여부에 대한 판단을 하는 메커니즘이 CORS입니다.

6.jpeg

이미지 출처: https://www.linkedin.com/pulse/understanding-cross-origin-resource-sharing-cors-ishan-girdhar-oscp

위 도식은 CORS에 따라 요청과 응답을 받는 과정을 간략히 표현한 것입니다. 더 상세하게 이제 CORS의 동작 원리를 살펴보겠습니다.

7.svg

이미지 출처: https://ko.wikipedia.org/wiki/교차_출처_리소스_공유

여기서 주목해서 살펴볼 부분은 CORS의 일반적인 조건(여기서는 초록색 부분)을 충족하지 못했을 때, 보내는 OPTIONS 메서드(빨간색 부분)입니다.

이전의 도식을 빌려보면, 실제 요청을 처리하기 전에 Preflight Request를 통해서 CORS에 관한 정보를 확인하는 과정이 OPTIONS 메서드로 처리되는 것입니다.

  • (OPTIONS 메서드에 익숙하시지 않은 분들을 위해서, OPTIONS 메서드에 대해 간략하게 짚고 넘어가겠습니다. OPTIONS 메서드는 HTTP의 여러 메서드 중 하나로, target resource 혹은 서버와 통신하기 위한 통신 옵션을 확인할 때 사용되는 메서드입니다. 즉 OPTIONS 메서드를 통해, Target으로 하는 서버나 리소스가 어떤 method, header, content type을 지원하는지 알 수 있는 것입니다. CORS와 관련된 헤더를 확인하는 것도 OPTIONS 메서드를 통해 하게 됩니다.)

다시 돌아와서 이러한 OPTIONS 메서드에서 응답으로 적절한 Access-Control-header를 받으면 실제 요청한 API 콜을 수행할 수 있는 것이고 그렇지 않으면 CORS error를 반환하는 것입니다.

CORS와 관련된 response header

위의 OPTIONS 메서드의 응답으로 받는, Access-Control header에서 CORS와 관련된 응답 헤더로는 다음과 같은 것들이 있습니다.

8.png

첫번째 Origin 헤더는 특정 Origin에서의 요청을 허용하는 서버의 목록을 나타냅니다.

두번째 Credentials 헤더는 실제 요청 시에 인증 헤더를 포함하여 요청할 수 있는지를 나타냅니다. true면 인증 정보를 포함한 요청을 허용하고, false면 허용하지 않습니다.

세번째 Expose Headers는 브라우저가 접근할 수 있는 정의 헤더를 나타냅니다. 노출하고자 하는 헤더 목록들이 여기 담깁니다.

네번째 Max-Age 헤더는 사전 요청의 결과를 캐시할 시간을 나타냅니다. 이 시간 동안은 사전 요청을 반복해서 보내지 않습니다.

이후 헤더들은 각각 실제 요청에 허용되는 HTTP Methods와 Headers들의 목록을 담습니다.


Spring에서의 CORS Error 해결

이제 최종적으로 이러한 CORS 이슈에 직면했을 때 백엔드(Spring)측에서 해결하는 방법에 대해 알아보겠습니다.

앞에서 살펴본 CORS의 동작 원리에 따르면, Access-Control-header에 적절한 설정을 해주면 되는 것이 기본 원리입니다.

이 포스팅에서는 Spring에서 CORS 설정을 해줄 수 있는 3가지 방식에 대해 다루고자 합니다.

1. @CrossOrigin 어노테이션

먼저, 가장 간단한 방법으로 @CrossOrigin 어노테이션이 있습니다. 이 @CrossOrigin 어노테이션은 메소드별로 각각 달아줄 수도 있고, 클래스 단위로도 달아줄 수 있습니다.

9.png

공식 문서에서 제공하는 @CrossOrigin 어노테이션의 옵션들입니다.

위에서 봤던 CORS와 관련된 응답 헤더(Access-Control header)로 조회하는, 백엔드측 CORS 세팅을 해줄 수 있는 것입니다.

2. @Configuration으로 전역적인 CORS 설정 (+ Spring Security)

다음으로, 클래스 레벨을 넘어서 전역적으로 CORS를 설정해주고 싶으면 WebMvcConfigurer를 상속받아 @Configuration으로 구현하는 방식이 있습니다.

아래 코드에서는 WebConfigurer을 상속받고, addCorsMappings() 메서드를 오버라이딩하여 필요한 세팅을 추가해줬습니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000", "https://f1-orury-client.vercel.app/", "https://orury.com")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

현재 제가 참여하고 있는 프로젝트에서는 Spring Security를 활용하고 있기 때문에, Spring Security에서 @Configuration으로 구현하는 코드도 공유하겠습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource()))
                .build();
    }
    
		// CORS 설정
    CorsConfigurationSource corsConfigurationSource() {
        return request -> {
            CorsConfiguration config = new CorsConfiguration();
            config.setAllowedHeaders(Collections.singletonList("*"));
            config.setAllowedMethods(Collections.singletonList("*"));
            config.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000", "https://f1-orury-client.vercel.app/", "https://orury.com")); // 허용할 origin
            config.setAllowCredentials(true);
            return config;
        };
    }
}

WebConfig를 따로 두지 않고, SecurityFilterChain을 구성할 때 HttpSecurity의 cors()를 이용하여 CORS 설정을 해줬습니다. 아래코드의 filterChain()에는 프로젝트의 csrf, JwtTokenFilter 추가 등 다른 Security를 위한 코드들(생략)이 추가되기에, 따로 corsConfigurationSource()으로 구현했습니다.

3. CorsFilter 커스텀을 통한 전역적인 CORS 설정

CorsFilter를 상속하지 않고 Filter를 상속하여 doFilter() 메서드를 오버라이딩하는 방식도 있습니다만, Spring의 CORS 관련 기능을 더 잘 활용하도록 구현해놓은 CorsFilter를 상속받는 방식을 소개하도록 하겠습니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustomCorsFilter extends CorsFilter {

    public CustomCorsFilter() {
        super(configurationSource());
    }

    private static UrlBasedCorsConfigurationSource configurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedHeaders(Collections.singletonList("*"));
        config.setAllowedMethods(Collections.singletonList("*"));
        config.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000", "https://f1-orury-client.vercel.app/", "https://orury.com"));
        config.setAllowCredentials(true);

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

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if ("OPTIONS".equals(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            super.doFilterInternal(request, response, filterChain);
        }
    }
}

위 코드에서는 configurationSource()라는 static 메서드를 통해 CorsConfiguration을 작성해놓고, CustomCorsFilter의 생성자의 super(configurationSource());를 통해 CorsFilter의 configSource를 설정해주고 있습니다.

그리고 doFilterInternel() 메서드를 통해 OPTIONS 메서드에 대해서는 성공적인 응답(200)을 반환하도록 하고, 다른 HTTP 메서드들에 대해서는 super.doFilterInternal()을 통해 CorsFilter의 doFilterInternal()을 수행하도록 하고 있습니다.

아래의 코드(CorsFilter의 doFilterInternal 메서드)를 보면, CustomCorsFilter의 생성자에서 설정해준 this.configSource을 바탕으로 CorsFilter가 동작하는 것을 확인할 수 있습니다.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

	CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
	boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
	if (!isValid || CorsUtils.isPreFlightRequest(request)) {
		return;
	}
	filterChain.doFilter(request, response);
}