CORS 허용 좀 해주세요...☆

Jihoon Oh·2022년 10월 25일
1

이 글은 우아한테크코스 학습로그 공유 사이트 Prolog에 업로드한 글을 재구성한 글입니다.

프론트엔드와 협업하게 되면서 생기는 가장 큰 차이점은 바로 프론트엔드와 백엔드가 각각 따로 서버를 띄운다 라는 것입니다. 이렇게 서버를 각각 띄우게 되면서 가장 간과하고 넘어갈 수 있는 문제가 바로 CORS 문제입니다. 아마 별다른 설정을 하고 백엔드 서버를 배포한다면, 프론트엔드 팀원으로부터 이런 연락을 받게 될 지도 모릅니다.

"오찌... CORS 허용 좀 해주세요...☆"

CORS란 무엇인가?

CORS는 Cross-Origin Resource Sharing의 약자로, 교차 출처 자원 공유 라고 번역할 수 있습니다. MDN에서는 CORS에 대해 이렇게 설명하고 있다.

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행합니다.

쉽게 설명하자면 CORS란 도메인이 다른 서버끼리 리소스를 주고 받는 정책이라고 생각하면 됩니다. 이게 왜 문제가 되냐면, 기본적으로 웹 브라우저의 기본 정책은 Same-Origin으로, origin이 다른 서버와의 리소스 공유를 허용하고 있지 않기 때문입니다.

여기서 origin이란 scheme(일반적으로 프로토콜), host(도메인), port를 모두 포함하는 것으로, 셋 중 하나라도 일치하지 않는다면 다른 origin으로 판단합니다. 예를 들어, 한 컴퓨터에서 React 서버(3000 포트)와 Springboot(8080 포트) 서버를 모두 띄워서 서로 리소스를 주고 받으려 한다면 포트가 다르기 때문에 origin이 달라 CORS 위반 문제가 발생하고, 개발자 도구 창에서는 시뻘건 CORS 위반 에러 메시지를 볼 수 있게 됩니다.

그래서 서버에서는 "어? 요청이 왔네?" 하고 200번대 OK 상태 코드를 응답하고 리소스를 정상적으로 보내더라도, 응답을 받은 뒤 브라우저가 판단하기에 "아 이거 같은 origin이 아니네."하고 판단해서 에러를 띄워버립니다. 결론적으로, 서버에서는 CORS 위반을 확인할 수 없습니다.

CORS는 어떻게 작동하는가?

Preflight Request

기본적으로 브라우저는 HTTP 요청을 보낼 때, 사전에 OPTIONS 메서드를 통한 HTTP 요청을 보내서 요청을 보내기 안전한지 확인해야 합니다. 미리 전송(preflight) 한다고 해서 이를 프리플라이트 요청(preflight request)이라고 하는데요, 이 때 요청하려는 메서드 정보를 Access-Control-Request-Method 헤더에, 요청에 담길 헤더 정보를 Access-Control-Request-Headers 헤더에 담습니다.

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

이런 식으로. 그러면 서버는 응답으로 어떤 것을 허용하는지에 대한 정보를 담아서 돌려줍니다.

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

이런 식으로 Access-Control-Allow-Origin에는 허용된 origin 정보를, Access-Control-Allow-Methods에는 허용하는 메서드를, Access-Control-Allow-Header에는 사용 가능한 헤더 목록을, Access-Control-Max-Age에는 현재의 preflight request를 브라우저가 캐싱 가능한 최대 시간을 담아서 제공합니다.

Simple Request

하지만 이런 preflight request를 보내지 않는 경우도 있습니다. 우선 브라우저를 쓰지 않으면 보내지 않는데요, 앞서 말했듯이 origin이 다른지 판단하는 것은 브라우저 스펙이기 때문입니다. 그래서 Postman과 같은 기능을 사용하면 CORS 문제가 발생하지 않습는다. 하지만 브라우저에서도 preflight request를 생략하는 경우가 있는데, 이를 simple request라고 합니다. simple request는 아무 때나 보낼 수 있는 것은 아니고 다음의 세 가지 조건을 만족해야 합니다.

  • 본 요청 메서드가 GET, HEAD, POST 중 하나일 것
  • 클라이언트에서 자동으로 넣어주는 헤더와 Fetch 표준 정책에서 정의한 CORS-safelisted request header라는 헤더 목록에 들어 있는 헤더 외에 다른 헤더를 수동으로 넣어주지 않았을 것
    • CORS-safelist request Header
      • Accept
      • Accept-Language
      • Content-Language
      • Content-Type
  • Content-Type의 경우 다음의 값들만 있을 것
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

이 경우에는 preflight 요청을 보내지 않고 서버가 본 요청에 대한 응답 헤더에 CORS 관련된 헤더를 보내서 브라우저가 이를 검사하는 형태로 CORS 정책 위반 여부를 검사합니다.

Credentialed Request

이외에 헤더에 인증과 관련된 정보를 담아서 보내는 credentialed request라는 경우도 있는데, 이 경우에는 credentials 옵션을 사용하며 CORS 정책 위반 여부를 검사하는 규칙에 몇 가지 규칙이 더 들어가게 됩니다.

자바스크립트의 fetch API를 사용하거나 Axios, Ajax 등을 사용할 때 서버로 쿠키를 함께 전송해야 하는 경우가 있는데요, 요청에 쿠키가 담기게 되면 Credentialed Request 허용이 되어 있어야 합니다. 이 때는 서버 쪽에서 응답 헤더에 Access-Control-Allow-Credentials: true를 보내주지 않는다면 브라우저에서 응답을 받는 것을 거부하게 됩니다.

여기서 주의할 점이 있습니다. Credentialed Request의 경우, Access-Control-Allow-Origin 헤더 값이 와일드카드여서는 안됩니다. 대신 https://foo.com과 같이 구체적인 origin을 지정해주어야 합니다.

서버에서 CORS 허용해주기

CORS에 대해서 알아봤으니 이제 서버에서 CORS 문제를 해결할 수 있도록 설정해봅시다. 사실 간단한데요, WebMvcConfigurer를 상속받은 @Configuration 빈을 만들어 주면 됩니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    public static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH";

    @Override
    public void addCorsMappings(final CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedMethods(ALLOWED_METHOD_NAMES.split(","))
                .exposedHeaders(HttpHeaders.LOCATION);
    }
}

WebMvcConfigurer에는 addCorsMappings라는 메서드가 존재하는데, 이 메서드로 CORS 정책을 허용해 줄 url을 지정해줄 수 있습니다. 위 코드는 우아한테크코스 레벨 2 장바구니 미션의 레거시 코드에 포함되어 있는 코드인데요, 하나씩 뜯어보면 addCorsMappings메서드를 오버라이딩 한 뒤, registryaddMapping으로 CORS를 허용할 메서드를 지정해 줍니다. 이후 allowedMethods로 허용하는 메서드를, exposedHeader로 서버에서 반환해 줄 헤더를 지정합니다.

여기서 "어? 허용하는 origin 값은 왜 안 주지?" 라는 생각이 들어야 합니다. 허용하는 origin을 지정하는 allowedOrigins 메서드와 allowedOriginPatterns도 존재하지만, 이 메서드들을 호출하지 않는다면 기본 값으로 "*"를 지정해서 모든 origin에 대해 CORS를 허용합니다.

public class CorsRegistry {

    private final List<CorsRegistration> registrations = new ArrayList<>();

    public CorsRegistration addMapping(String pathPattern) {
        CorsRegistration registration = new CorsRegistration(pathPattern);
        this.registrations.add(registration);
        return registration;
    }
    ...
}

public class CorsRegistration {

    public CorsRegistration(String pathPattern) {
        this.pathPattern = pathPattern;
        this.config = new CorsConfiguration().applyPermitDefaultValues();
    }
    ...
}

public class CorsConfiguration {
    ...
    public CorsConfiguration applyPermitDefaultValues() {
        if (this.allowedOrigins == null && this.allowedOriginPatterns == null) {
            this.allowedOrigins = DEFAULT_PERMIT_ALL;
        }
        ...
        return this;
    }
    ...
}

CorsRegistry -> CorsRegistration -> CorsConfiguration 순으로 들어가 보면, allowedOrigins가 지정되지 않으면 DEFAULT_PERMIT_ALL로 지정하기 때문에 모든 origin에 대해서 허용하도록 설정하는 것을 볼 수 있습니다.

그런데 Credentialed-Request를 허용해 주어야 한다면 특정한 origin들을 지정해주어야겠죠? 추가적으로 .allowCredentials(true) 메서드를 호출하여 Credential 옵션을 허용해주어야 합니다.

주의: 인증이 필요한 URL에 preflight 요청

이 경우에 주의해야 하는 부분이 있습니다. 예를 들어 api/products URL에 interceptor를 통해 Authorization 헤더의 Jwt 토큰 값을 확인하는 인가 로직이 들어있다고 하겠습니다. 만약 클라이언트에서 보내는 본 요청의 Content-Typeapplication/json 이라면 simple request의 조건을 만족하지 못하기 때문에 preflight request를 보낼 것입니다. 그리고 서버는 preflight request에 대해서도 Authorization 헤더를 검사할 것입니다.

하지만 preflight request는 본 요청과는 다르게 Authorization 헤더에 토큰 정보를 담지 못합니다. 본 요청에 어떤 헤더가 들어갈 지를 Access-Control-Request-Headers헤더에 담을 뿐입니다. 따라서 서버는 인증이 필요한 URL에 인증되지 않은 클라이언트가 요청을 보낸다고 판단하게 되어 정상적으로 요청을 처리할 수 없게 되고, 클라이언트에서는 preflight request가 실패했으니 본 요청을 보내지 않게 됩니다. 따라서 preflight request의 HTTP 메서드인 OPTIONS에 대한 추가적인 처리가 필요합니다.

라이브러리를 사용하는 등 다양한 방법이 있지만, 가장 간단하게는 만들어준 interceptor로 가서 preHandle 메서드에 OPTIONS의 경우 바로 true를 반환하도록 하는 로직을 추가하면 됩니다.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    if (HttpMethod.OPTIONS.matches(request.getMethod()) {
        return true;
    }
    ...
}

참고 자료

교차 출처 리소스 공유 (CORS)
[WEB] 📚 CORS 개념 💯 완벽 정리 & 해결 방법 👏
CORS, CORS Error 간단히 알아보기

profile
Backend Developeer

0개의 댓글