콘텐츠로 건너뛰기

CORS – 웹 브라우저와 웹 어플리케이션의 관계에 대해서

CORS (Cross-Origin Resource Sharing) 관련하여 많은 문제를 겪어보았고 또 해결해보았지만 실제로 그것이 어떤 것이고 왜 이런 것이 생겨났는지 제대로 알고 있지 못한다는 생각이 들어서 한 번 정리를 해보려고 한다.

CORS는 정책이라고 볼 수 있다. 브라우저에서 로드하는 Javascript 코드가 서버에게 요청을 보내 데이터를 받아오고자할 때 해당 서버가 만약 다른 곳에 있다면 브라우저는 받아온 데이터를 차단한다.

위 문장이 CORS에 대해서 간단하게 잘 설명해주는 것 같지만 조금 모호한 부분이 있다. 서버가 다른 곳에 있다는 것은 어떤 의미인가? 브라우저가 받아온 데이터를 차단한다는 것은 어떤 것인가?

일단 명심해야할 것은 같은 곳인지 다른 곳인지를 판단하는 주체는 ‘웹 브라우저’이다. 브라우저는 현재 자신이 로딩한 웹 어플리케이션의 Origin과 해당 웹 어플리케이션이 요청을 날리고 있는 서버의 Origin을 비교하고 동일하다면 요청의 결과인 응답값을 웹 어플리케이션이 사용하도록 해준다.

Origin의 정의는 MDN web docsRFC6454에서 찾아볼 수 있다. Origin은 sheme (protocol), host (domain) 그리고 port로 구성된다. 세 가지 요소가 모두 동일할 때 두 Origin을 같다고 할 수 있다. 두 Origin을 비교하는 예시는 링크의 문서에서 자세히 살펴볼 수 있다.

CORS 정책은 왜 도입되었나?

CORS가 생기기 이전에 same-origin policy가 존재했고 이것이 생겨난 이유를 살펴보면 CORS가 어떻게 생겨났는지에 대해서 더욱 자세히 이해할 수 있을 것이다. 기본적으로 웹 브라우저는 access token이나 cookie 등과 같이 유저와 관련된 데이터를 저장하는데 이러한 정보가 중간자에 의해 노출, 외부로 전송이 되거나 조작될 수 있다면 보안적으로 심각한 문제가 될 것이다. (XSS, CSRF)

이러한 문제를 해결하기 위해 same-origin policy가 등장하였고 이는 웹 어플리케이션이 자신과 동일한 Origin을 가진 서버로부터만 데이터를 요청할 수 있고 받을 수 있도록 강제하는 정책이다. 만약 다른 Origin으로 웹 어플리케이션이 요청을 보낸다면 웹 브라우저가 요청을 살펴보고 취소시킨다.

그런데 조금만 상상해보면 same-origin policy는 적용하기에 상당히 까다로운 정책이다. 그래서 이를 보완하고자 클라이언트가 안전하게 다른 Origin으로부터 리소스를 얻을 수 있도록 허용하는 정책을 만들었고 이것이 “Cross-Origin Resource Sharing” (CORS)이다.

CORS는 어떻게 동작하는가?

웹 어플리케이션이 서버에 요청을 보내면 서버는 자신의 리소스를 어떤 Origin이 사용할 수 있는지를 명시하여 요청한 리소스와 함께 응답값을 보낸다. 이 때 웹 어플리케이션이 응답값을 받아보기 전에 브라우저는 서버에서 보낸 ‘리소스를 사용할 수 있는 Origin’과 자신이 로딩한 웹 어플리케이션의 Origin을 비교한다. 두 Origin이 동일한 경우에만 응답값을 웹 어플리케이션에 돌려준다. 이 때 서버에서 리소스를 사용할 수 있는 Origin을 명시하기 수단으로 HTTP headers에 값을 담아 보낸다. 우리가 많이 보았던 Access-Control-Allow-Origin와 같은 것이다.

Origin을 검증하는 것과 더불어 Method를 Access-Control-Allow-Methods와 같은 HTTP headers로 검증하는 것도 경험한 적이 있을 것이다. 이는 PUT, PATCH와 같은 Method를 통해 서버 측에 side-effects를 만들어낼 수 있기 때문인데 Origin을 검증하는 것뿐만아니라 Method를 검사하는 장치를 하나 더 만들어 놓은 것이다.

이뿐만아니라 CORS는 HTTP 요청에 담긴 여러 메타데이터를 통해 검증하는 수단을 만들어 놓았다. 하지만 이는 디테일한 부분이니 넘어가도록 하겠다.

Preflight Request

이제 CORS가 어떻게 적용되는지 웹 어플리케이션, 웹 브라우저, 그리고 외부 서버 셋 간의 인터렉션을 통해서 살펴보자.

기본적으로 웹 어플리케이션이 외부 서버에 보낸 요청을 보내기 전에 웹 브라우저는 Preflight request를 외부 서버에 전송한다. MDN 문서에서는 Simple Request라는 방식도 소개되고 있는데 이는 웹 어플리케이션의 요청이 특정한 조건을 갖추었을 경우 사용하게 되는 방식이다.

여기서는 일반적으로 많이 알려져있는 Preflight request에 대해서 좀 더 살펴보겠다. Preflight request는 OPTIONS Method를 가지고 내부적으로 Origin과 User-Agent와같은 HTTP Headers들을 붙여서 보낸다.

CORS how preflight request works
CORS: how preflight request works

위의 다이어그램에서 첫 번째로 이루어지는 Client와 Server 사이에서의 요청과 응답이 Preflight request/response이다.

Preflight request를 서버에 보냈을 때 성공적으로 응답이 왔고 서버에서 Access-Control-Allow-OriginAccess-Control-Allow-Methods 를 키로 하는 헤더가 넘어왔다. 이것으로 자신이 어떤 Origin과 Method를 허용하는지를 웹 브라우저에게 알려주게 되는 것이다. 하나 짚고 넘어갈 것은 OPTIONS Method를 통한 HTTP 통신은 안전한 것으로 알려져있다. 여기서 안전하다는 것은 서버의 상태를 바꾸지 않는다는 것을 뜻한다. OPTIONS Method는 본래 요청 대상의 정보를 얻기 위해서 사용된다.

Chromium

std::unique_ptr<ResourceRequest> CreatePreflightRequest(
    const ResourceRequest& request,
    ...) {
  std::unique_ptr<ResourceRequest> preflight_request =
      std::make_unique<ResourceRequest>();
  ...
  preflight_request->url = request.url;
  preflight_request->method = net::HttpRequestHeaders::kOptionsMethod;
  preflight_request->headers.SetHeader(net::HttpRequestHeaders::kAccept,
                                       kDefaultAcceptHeaderValue);
  ...
  preflight_request->headers.SetHeader(
      header_names::kAccessControlRequestMethod, request.method);
  ...
  preflight_request->headers.SetHeader(
      net::HttpRequestHeaders::kOrigin,
      (tainted ? url::Origin() : *request.request_initiator).Serialize());
  ...
  std::string user_agent;
  if (request.headers.GetHeader(net::HttpRequestHeaders::kUserAgent,
                                &user_agent)) {
    preflight_request->headers.SetHeader(net::HttpRequestHeaders::kUserAgent,
                                         user_agent);
  }
  return preflight_request;
}

Chromium에서 Preflight request를 만드는 부분이다. 불필요한 부분은 생략하고 가져왔다. 코드를 보면 알 수 있다시피 웹 어플리케이션에서 보낸 요청으로부터 Preflight request를 만드는데 Method는 OPTIONS로 그리고 Origin, Access-Control-Request-XXX , User-Agent 와같은 헤더를 넣는 것을 확인할 수 있다.

Chromium CorsURLLoader component relationship

조금 더 코드를 살펴보면 재미있는 부분들이 많다. Chromium은 CORS 정책을 CorsURLLoader 를 통해서 사용할 수 있도록 하였다. 그리고 CorsURLLoaderPreflightController 를 이용하여 Preflight request를 보내고 응답값에 대해 핸들링한다. PreflightController 는 내부적으로 PreflightLoader 를 통해 실제로 Preflight 요청을 날리게 된다.

우선 책임 분리 측면에서 Preflight와 관련된 것들은 PreflightController에 모두 위임했다고 생각해볼 수 있을 것이고. 컨트롤러를 통해 PreflightLoader를 생성하고 삭제하고 그리고 실제로 Preflight request 요청을 수행하도록 한다. 컨트롤러를 둔 의도에 대해서는 정확히 파악하지 못했다. PreflightController가 어떻게 사용되는지를 좀 더 살펴보면 알 수 있을 것이다.

void CorsURLLoader::StartRequest() {
  ...
  preflight_controller_->PerformPreflightCheck(
      base::BindOnce(&CorsURLLoader::StartNetworkRequest,
                     weak_factory_.GetWeakPtr()),
      request_,
      PreflightController::WithTrustedHeaderClient(
          options_ & mojom::kURLLoadOptionUseHeaderClient),
      tainted_, net::NetworkTrafficAnnotationTag(traffic_annotation_),
      network_loader_factory_, process_id_, isolation_info_);
}

CorsURLLoader가 웹 어플리케이션의 네트워크 요청을 받아서 수행하는 코드로 추정이 되는데 우선 PerformPreflghtCheck 를 통해서 서버로부터 헤더값을 받아오고 CORS 정책을 만족한다면 callback 함수로 &CorsURLLoader::StartNetworkRequest 를 호출하는데 이제 실제로 웹 어플리케이션의 요청을 외부 서버로 전달하게 된다.

웹 브라우저와 웹 어플리케이션의 네트워크 측면에서의 관계

한 단계 더 가보자. 마지막으로 내가 궁금했던 것은 이것이다. 우리가 흔히 웹 어플리케이션을 만들게되면 axios와 같은 라이브러리를 사용하여 외부 서버와 네트워크 통신을 하게되는데 이 라이브러리와 웹 브라우저의 관계가 도통 어떻게 엮여있는지가 궁금했다.

이 때까지 살펴보았던 것은 axios를 통해 HTTP 요청을 날리면 이것이 바로 외부 서버로 전달이 되는 것이 아니라 사실은 웹 브라우저를 통해서 외부로 전달되는 사실이다.

function getDefaultAdapter() {
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}

핵심은 여기이다. axios가 웹 어플리케이션에서 사용될 경우 네트워크 통신을 할 때 XMLHttpRequest 를 사용한다. 처음 javascript를 이용하여 HTTP 요청을 외부로 보낼 때 XMLHttpRequest 를 사용한 기억이 있는데 사실 이것이 엄청난 역할을 하고 있었다.

XMLHttpRequest는 웹 브라우저 단에서 제공해주는 API이고 이는 초창기에 AJAX (Asynchronous Javascript and XML) 프로그래밍을 하기 위한 핵심 기술이었다. 지금은 시간이 지나면서 axios와 같이 웹 어플리케이션을 개발하는 사용자들이 XMLHttpRequest를 편하게 사용할 수 있도록 wrapper를 만들어서 제공하고 있다.

여기까지 살펴보면서 확인할 수 있는 것은 React과 같은 프레임워크를 이용해서 만든 웹 어플리케이션에서 외부 서버와 네트워크 통신을 한다고 하면 사실은 모든 통신은 웹 브라우저에서 제공해주는 API를 이용하여 이루어졌던 것이다.

Web browser architecture

‘A Reference Architecture for Web Browsers’에서 소개되고 있는 웹 브라우저 아키텍쳐이다. 여기서 Javascript Interpreter와 Networking 컴포넌트를 보고 전체적인 그림이 그려졌다. 기본적으로 웹 브라우저는 웹 어플리케이션에서 사용되고 있는 Javascript를 이해하고 그것을 화면에 그려준다.

그 중 웹 어플리케이션 코드에는 new XMLHttpRequest()와 같은 코드도 있을 것이고 이런 Javascript 코드는 브라우저 단에서 파싱된 후에 Networking 컴포넌트를 이용하여 외부 서버로 요청을 날릴 것이다.

XMLHttpRequest와 관련된 Javascript코드는 브라우저 단에서 해석이 되기 때문에 네트워크 요청을 보내는 것 앞, 뒤로 CORS와 같은 보안 정책이나 캐싱 적용 혹은 커넥션 관리를 할 수 있다는 것이 이제는 이해가 된다. 또한 이러한 디테일들은 브라우저의 코드에 따라서 결정이 되기 때문에 어떤 브라우저가 속도가 빠르고 어떤 브라우저는 보안에 취약하다는 것 또한 이해가 된다.

Chromium은 Blink라는 Rendering Engine을 사용한다. 그리고 Chromium의 /third_party/blink/renderer/core/xmlhttprequest/에서 친숙한 클래스를 찾아볼 수 있다.

class XMLHttpRequest final : public XMLHttpRequestEventTarget,
                             private ThreadableLoaderClient,
                             public DocumentParserClient,
                             public ActiveScriptWrappable<XMLHttpRequest>,
                             public PausableObject {
 public:
  static XMLHttpRequest* Create(ScriptState*);
  static XMLHttpRequest* Create(ExecutionContext*);
  ~XMLHttpRequest() override;
  // These exact numeric values are important because JS expects them.
  enum State {
    kUnsent = 0,
    kO
    kHeadersReceived = 2,
    kLoading = 3,
    kDone = 4
  };
  
  void open(const AtomicString& method, const String& url, ExceptionState&);
  void send(
      const ArrayBufferOrArrayBufferViewOrBlobOrDocumentOrStringOrFormDataOrURLSearchParams&,
      ExceptionState&);
}

XMLHttpRequst 클래스가 정의되어있다. open()send() 와 같은 함수는 Javascript의 XMLHttpRequest의 의 함수 시그니처와 유사하고 XMLHttpRequest* Create(ScriptState*) 와 같은 메소드는 읽어들인 Javascript를 이용하여 XMLHttpRequest 객체를 어떻게 만들어낼지 정할 수 있는 factory 메소드처럼 보인다.

마치며

처음엔 CORS에 대해서 살펴보았다. 그것이 어떤 것이고 왜 생겨났는지에 대해서 살펴보았다. 기본적으로 다른 Origin의 서버로부터 리소스를 함부로 받아오거나 조작하는 일을 막기 위해서 브라우저 단에서 그것을 제어한다. 웹 어플리케이션에서 외부 서버로 전송하는 네트워크 요청은 사실은 웹 브라우저를 통해서 전달되는데 그것의 연결고리가 되는 것이 XMLHttpRequest 이다. XMLHttpRequest를 하나의 추상화된 객체 혹은 API로 볼 수 있는데, 웹 어플리케이션은 이를 이용하여 커넥션 관리나 보안 등 low-level에서 일어나는 복잡한 일들을 신경쓰지 않고 편하게 비동기 네트워크 통신을 할 수 있다.

Leave a comment