티스토리 뷰

Web

CORS(Cross-Origin Resourse Sharing)

nooblette 2023. 7. 24. 22:12
반응형

목차

    최근 평소 관심있는 기술들을 사용하면서 원하는 서비스를 개발하고 싶다는 생각에 사이드 프로젝트를 진행하는 인데, 화면 개발을 진행하면서 드디어 .. CORS 문제에 직면하였다.

    웹 개발자치고는 비교적 늦게 경험한 편이라고 생각해서 한편으로 부끄럽기도하지만, 워낙 웹 개발을 처음 시작할때 흔하게 만나면서도 중요한 이슈라는 생각이 들어 개념도 익힐 겸 단순히 해결만 하는게 아니라 짚고 넘어가보았다.

    배경

    Fail Books top-rated API call:  AxiosError {message: 'Network Error', name: 'AxiosError', code: 'ERR_NETWORK', config: {…}, request: XMLHttpRequest, …}
    index.js:1210
    arg1:
    AxiosError {message: 'Network Error', name: 'AxiosError', code: 'ERR_NETWORK', config: {…}, request: XMLHttpRequest, …}
    console.<computed> @ /booklog-react-app/node_modules/react-error-overlay/lib/index.js:1210

    처음 에러를 만나고 에러 페이지를 뱉는 리액트 창을 봤을때는 막막하기만 했다. 에러로그를 보더라도 CORS와 관련된 내용은 없고, 단순 API 호출시 발생한 Network Error라는 말만 있어 원인 파악이 힘든 상황이였다.

     

    일반적으로 Network Error는 다음과 같은 원인으로 발생하게 된다.

    1. 네트워크 연결이 끊어진 경우

    2. 잘못된 엔드포인트 요청 또는 설정

    3. Cross-Origin Resource Sharing (CORS) 문제

     

    API 서버는 잘 돌아가고 있고 해당 요청에 대해 응답까지 정상 실행되었으며, 동일한 엔드포인트로 요청했을때 postman으로도 정상 응답을 뱉고있었다.

    리액트 코드도 api 요청/응답과 처리 로직을 추가한 부분을 제외하면 크게 변경한 부분이 없었으니 구글링과 챗지피티의 도움 결과 CORS 에러이겠거니 추측만했다.

     

    원인

    크롬 개발자 도구를 열고 Network 탭을 들어가보니 간단하게 원인을 파악할 수 있었다.

    위 캡쳐화면에서 빨간색으로 칠해진 탭을 더블클릭하니 요청 경로과 결과를 볼 수 있었고, 역시나 api는 이상없이 200 코드를 뱉고 있었음을 확인했다. 원인을 확실히 파악했으니 간단하게 CORS에 대한 개념, 발생 원인, 해결 방법등을 파악했다.


    SOP(Same-Origin Policy)

    웹에서 리소스 요청은 두가지 정책이 존재하는데, 하나는 SOP이며 다른 하나는 CORS에다.

    SOP는 Same-Origin Policy의 약자인데, 이름에서도 알 수 있듯이 동일한 출처에 대해서만 리소스 공유가 가능한 정책이다.

    하지만 (당장 나와 같은 경우만 하더라도) 무작정 다른 출처로부터 리소스 교환을 막아버린다면 그 웹사이트에서 제공할 수 있는 기능은 매우 한정될 것이다. 웹에서 다른 출처로부터 리소스(API, 이미지, 영상 등)를 가져오는 것은 굉장히 흔한 일이므로 무작정 출처를 막아버리는 것은 큰 제약이 된다.

     

    따라서, 이와 같은 제약을 극복할 수 있는 몇가지 예외 조건에 대해서만 서로 다른 출처로부터 리소스 교환을 허용하는 정책이 CORS이다.


    CORS(Cross-Origin Resource Sharing)

    위키백과에서는 다음과 같이 나와있다.

     

    교차 출처 리소스 공유(Cross-origin resource sharing, CORS), 

     

    교차 출처 자원 공유는 웹 페이지 상의 제한된 리소스를 최초 자원이 서비스된 도메인 밖의 다른 도메인으로부터 요청할 수 있게 허용하는 구조이다.[1] 웹페이지는 교차 출처 이미지, 스타일시트, 스크립트, iframe, 동영상을 자유로이 임베드할 수 있다.[2]

    다만 특정 교차 도메인 간(cross-domain) 요청, 특히 Ajax 요청은 동일-출처 보안 정책에 의해 기본적으로 금지된다.

    CORS는 교차 출처 요청을 허용하는 것이 안전한지 아닌지를 판별하기 위해 브라우저와 서버가 상호 통신하는 하나의 방법을 정의한다.[3] 순수하게 동일한 출처 요청보다 더 많은 자유와 기능을 허용하지만 단순히 모든 교차 출처 요청을 허용하는 것보다 더 안전하다. CORS의 사양은 원래 W3C 권고안으로 배포되었으나[4] 해당 문서는 쓸모 없어진(obsolete) 상태이다.[5] 현재 CORS를 재정의하면서 활발히 유지보수된 사양은 WHATWG의 Fetch Living Standard이다.[6]


     

    앞서 말했듯이, 기존 웹 브라우저에서는 정책적으로 보안상 서로 다른 출처(Cross-origin) 간에 리소스를 요청하고 가져올 수 없다.

     

    여기서 출처란 도메인을 의미하며, URL에서 Protocol + Host + Post 3가지가 같으면 동일한 출처라고 판단한다.

    https://beomy.github.io/tech/browser/cors/

    웹 브라우저에서 보안을 위해 동일한 도메인으로만 리소스 교환이 가능한데, 당장 나와 같이 웹 페이지와 api 서버를 분리해서 개발하게되면 서로 다른 도메인으로부터 리소스를 가져올 수 밖에 없고, 그냥 가져올 경우 정책에 위반되기때문에 이와 같은 에러가 발생하는 것이다.

     

    하지만 웹 개발을 하다보면 다른 도메인으로 AJAX 요청, 폰트/이미지/CSS 등 다양한 리소스를 가져와야하는 경우 발생하게 된다.

    이처럼 리소스를 교환해야하는 경우가 항상 발생할 수 밖에 없고 이러한 단점을 보완하기 위한 보안 메커니즘이 CORS(다른 도메인간 리소스 공유)이다.


     

    다른 도메인간 리소스 교환의 위험성

    교차 도메인간 리소스 교환은 웹 개발자에게 골치아픈 상황을 만들어버린다.

    그럼에도 불구하고 굳이 동일 출처 / 다른 출처를 구분하고, 다른 출처로부터 리소스를 가져오는 것을 막는 이유는 당연하게도 보안 때문이다.

     

    만일 웹브라우저가 이러한 보안 정책 없이 어떤 출처에 있는 리소스든간에 교환을 할 수 있다면, 아래 그림과 같은 상황이 발생하게 된다. 

     

    evilc.om 이라는 사이트에 해커가 악의적으로 bank.com/account 라는 URL로 요청을 하는 <script>를 심어버린다면(JSONP), 그리고 만일 이 요청이 접속한 사람의 은행계좌를 해킹하는 요청이라면, 사용자는 사이트 접속만 했을 뿐인데 자신의 계좌를 잃게된다.

     

    당장 브라우저 개발자 도구만 열어도 DOM, HTML 구조, 사용하는 API와 서버, 리소스의 출처 등 모든 정보를 알 수 있다.

    이런 상황에서 다른 출처와 리소스 교환에 대해 아무런 제약이 없다면 위와 같은 예시에서 볼 수 있듯이, 해커가 악의적인 스크립트만 심어두면 너무나 쉽게도 매우 위험한 상황에 놓이게 된다.

     

    JSONP

    SOP를 우회하여 JSON 형태로 다른 출처로부터 요청을 받는 방식이며, 위에서 말한 <script> 태그가 JSONP를 사용하는 방식이다.

    // 함수이름을 넣어 요청
    const script = document.createElement("script");
    script.src = "ingg.com/test.json?callback=parseResponse";
    
    // 서버에서는 함수이름을 넣고 매개변수로 데이터를 넣어서 반환
    parseResponse({
      id: "123",
      name: "ingg",
    });
    
    function parseResponse(data) {
      // ...
    }

    다른 출처로부터 데이터를 주고받기 위해 CORS가 나오기 전까지 사용하던 방식이며, 앞서 말했듯이 보안상의 이슈로 현재는 거의 사용하지 않는 방법이다.


    CORS 동작 원리

    다시 문제 발생 원인이였던 CORS에 대해 살펴보자면, CORS 동작원리는 크게 세가지 방식이 있다.

     

    Preflight Request

    웹 개발시에 가장 흔하게 발생하는 요청이다. 이 요청시에 브라우저는 브라우저 스스로 이 요청이 안전한지 파악하기 위한 예비 요청(Preflight)본 요청으로 나누어서 서버에 전송한다.

    브라우저가 서버로 에비 요청을 보내고 응답을 받는 과정에서, 서버는 브라우저에게 어떤 것을 허용하고 어떤 것을 금지하는지 정보를 응답 헤더에 담아서 보내준다. 이후 브라우저는 자신이 보낸 예비요청과 서버가 보낸 응답을 비교하여 안전하다고 판단되면 본 요청을 보내게 된다. 이때 요청 헤더와 응답 헤더에는 다음과 같은 값을 포함한다.

     

    요청 헤더

    - origin : 어디에서 요청을 보냈는지 알려주기 위한 정보

    - access-control-reqeust-method : 실제 요청이 보낼 http 메소드

    - access-control-request-headers : 실제 요청에 포함된 header

     

    응답 헤더

    - access-control-allow-origin : 서버가 허용하는 출처

    - access-control-allow-methods : 서버가 허용하는 http 메소드

    - access-control-allow-headers : 서버가 허용하는 header 리스트

    - access-control-max-age : 프리플라이트 요청의 응답을 캐시에 저장하는 시간


    Simple Request

    Preflight Request과 다르게 단 한 번의 요청만을 전송한다.

    하지만 이때 다음과 같은 조건을 만족해야한다.

    - 메소드는 GET, HEAD, POST 중 하나여야 한다.

    - User Agent가 자동으로 설정한 헤더를 제외하면, 아래 헤더만 사용 가능

        - Accept

        - Accept-Language

        - Content-Language

    - 다음 값을 사용하는 Content-Type

        - application/x-www-form-urlencoded

        - multipart/form-data

        - text/plain

        - DRP

        - Downlink

        - Save-Data

        - Viewport-width

        - Width

    위 조건을 만족할때에만 안전한 요청으로 취급되어 Simple Reqeust를 보낼 수 있다.

     

    위 조건에 나타난 헤더들은 정말 기본적인 헤더이고, 실제 웹 에서는 이 헤더외의 추가적은 헤더들을 쓰지 않는 경우가 매우 드물다. 당장 사용자 인증을 위한 Authorization 헤더와 데이터 교환을 위한 application/json 컨텐츠 타입또한 포함되지 않으므로 조건을 만족하기에 상당히 까다롭다.
    (출처 : https://evan-moon.github.io/2020/05/21/about-cors/)

    Credentialed Request

    위 두 요청과 비교했을때 보안을 중시하는 요청이다. 요청시에 쿠키, 인증 헤더, TLS 클라이언트 인증서 등을 함께 요청한다.

    기본적으로 별도의 옵션 없이 CORS는 브라우저 쿠키, 인증 정보 등을 허용하지 않지만, 요청에 인증을 포함하는 credentials 옵션을 설정한다면 요청에 인증과 관련한 정보를 주고받을 수 있다.

     

    credentials 옵션 값은 다음과 같다. 

    - same-origin(default) : 같은 출처간 요청에만 인증 정보를 담을 수 있다.

    - include : 모든 요청에 인증 정보를 담을 수 있다.

    - omit : 모든 요청에 인증 정보를 담지 않는다.

     

    이 요청을 사용할 경우(include : true로 설정할 경우), 다른 요청과 달리 CORS 정책에 의해 Access-Control-Allow-Origin 헤더에 와일드카드(*, asterisk)를 설정할 수 없다. 즉, 헤더에 설정된 origin 목록을 직접 지정해주어야한다.


    해결 방안

    CORS를 허용해주기 위해서는 화면단과 서버단 둘 다 설정이 필요하다.

    (나 같은 경우 화면은 React, 서버는 Java + SpringBoot로 구현하였다.)

     

    화면(React)

    http-proxy-middleware를 설치하고 setUpProxy.js 파일을 생성하여 관리해준다.

     

    • 설치
    npm install http-proxy-middleware
    • setUpProxy.js 
    const { createProxyMiddleware } = require('http-proxy-middleware');
    
    module.exports = function (app) {
      app.use(
        '/v1',
        createProxyMiddleware({
          target: 'http://localhost:8080',  // API 서버 주소
          changeOrigin: true,
        })
      );
    };
    • setUpProxy.js(요청 서버가 여러개라면 다음과 같이 아래에 추가로 작성해서 관리해주면 된다)
    const { createProxyMiddleware } = require('http-proxy-middleware');
    
    module.exports = function(app){
      app.use(
        createProxyMiddleware('/nooblette', {
          target: 'https://nooblette.com',
          pathRewrite: {
            '^/nooblette':''
          },
          changeOrigin: true
        })
      )
      
      app.use(
        createProxyMiddleware('/다른context', {
          target: 'https://다른호스트',
          pathRewrite: {
            '^/지우려는패스':''
          },
          changeOrigin: true
        })
      )
      
      ...
    };
    export function test() {
      // nooblette로 시작하는 url을 자동으로 인식하여 프록시 처리해준다.
      // 이때 /nooblette 패스는 pathRewrite에서 설정한 것처럼 제거가 가능하다.
      axios.get('nooblette/search?id='123')
           .then(data => {
             console.log(data.data)
           })
    }

     

    API 서버 (Java + Springboot)

    WebConfig.java를 추가하고 아래와 같이 작성해주었다.

    (디렉토리 위치는 Contoller 패키지와 동일한 레벨로 지정하였다.)

    • WebConfig.java
    package com.booklog.book.config;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    	@Override
    	public void addCorsMappings(CorsRegistry registry) {
    		registry.addMapping("/v1/books/**") // v1/books/ 경로에 대해서 CORS 설정
    			.allowedOrigins("http://localhost:3000") // 리액트 애플리케이션의 도메인을 설정
    			.allowedMethods("GET", "POST", "PUT", "DELETE")
    			.allowCredentials(true);
    
    		registry.addMapping("/v1/reviews/**") // v1/reviews/ 경로에 대해서 CORS 설정
    			.allowedOrigins("http://localhost:3000") // 리액트 애플리케이션의 도메인을 설정
    			.allowedMethods("GET", "POST", "PUT", "DELETE")
    			.allowCredentials(true);
    	}
    }

    결과

    위와 같은 과정을 거치고나서 서로 다른 출처에 있는 API 서버로부터 정상적으로 리소스를 주고받는 것을 확인할 수 있었다.


    참고한 곳

    반응형
    Comments