CORS 실패 시, 응답 개체의 status는 왜 0일까?

문제를 만나다

클라이언트의 도메인과 서버의 도메인이 서로 다른 경우, 브라우저는 크로스 도메인(Cross Domain) 보안 정책에 따라 요청을 차단한다. 이 상황을 우회하는 몇 가지 해법이 있는데, 스마트에디터 원은 주로 CORS를 이용하고 있다. 최근에 네이버 지식인에 스마트에디터 원을 적용하다가 CORS 정책을 위반하는 상황을 만났는데, 아래의 스크린은 이 상황을 보여준다.

개발하는 도중에 종종 만나는 상황이라 그리 놀랄 일은 아니다. 서버 측에서 응답 헤더의 속성 중, Access-Control-Allow-Origin에 접근을 허용하는 클라이언트의 도메인을 넣어주면 끝이다.

그런데 네트워크 요청에 대한 응답으로 에러를 전달받은 경우에 안내 메시지를 화면에 노출하도록 처리하고 있음에도 아무런 메시지를 노출하지 않는 것이 마음에 걸렸다. 원인을 찾아서 조금 더 상황을 들여다보다가 재미있는 점을 발견했다. 분명 에러 상황인데 응답 상태 코드(Status Code)가 200이네? 물론 Response의 Header에는 Status가 없지만.

더군다나 응답 결과로 만들어진 Response 개체의 status 값은 0이다. 뭘까... 이 부조화한 상황은.

응답 개체의 status가 에러를 표현(401, 404, 500과 같은) 하는 경우에만 대응하도록 코드를 작성하였기에 status가 0인 상황은 제대로 대응하지 못한 셈이다. 조건이야 추가하면 그만인데, 이런 결과가 만들어진 이유가 궁금했고, 밀려오는 호기심을 견디지 못해 스펙을 뒤지기 시작했다.

스펙에서 답을 찾다

브라우저에서 서버로 HTTP 요청을 보낼 때, 우리는 XMLHttpRequest나 Fetch API를 주로 이용한다.

1
2
3
4
/* XHR 개체를 이용하는 과거 방식 */
const request = new XMLHttpRequest();
request.open("GET", "http://www.example.org/example.txt");
request.send();

1
2
3
4
5
6
7
8
/* Fetch API를 이용하는 새로운 방식 */
const myRequest = new Request('http://localhost/flowers.jpg');

fetch(myRequest)
  .then(response => response.blob())
  .then(blob => {
    myImage.src = URL.createObjectURL(blob);
  });

그 어떤 방식을 이용하든, 브라우저는 서버로 요청을 전송할 때 요청의 유형을 판단하여 요청 개체의 mode라는 프로퍼티에 담아 전달한다. Fetch API 스펙은 총 다섯 개의 mode를 정의한다. 다양한 요청을 몇 가지로 유형화하여 이후의 처리 과정을 추상화하기 위해서 만든 설정으로 보인다.

"same-origin"

Used to ensure requests are made to same-origin URLs. Fetch will return a network error if the request is not made to a same-origin URL.

"cors"

Makes the request a CORS request. Fetch will return a network error if the requested resource does not understand the CORS protocol.

"no-cors"

Restricts requests to using CORS-safelisted methods and CORS-safelisted request-headers. Upon success, fetch will return an opaque filtered response.

"navigate"

This is a special mode used only when navigating between documents.

"websocket"

This is a special mode used only when establishing a WebSocket connection. Even though the default request mode is "no-cors", standards are highly discouraged from using it for new features. It is rather unsafe.

https://fetch.spec.whatwg.org/#concept-request-mode

​XHR이든, Fetch API이든, 우리가 흔히 AJAX라고 부르는 요청은, cors를 기본 mode로 갖는다.

이에 반해 이미지나 CSS와 같은 리소스를 가져오는 요청, 즉 클라이언트의 코드 레벨이 아닌 하부 시스템에 의해 네트워크 요청이 만들어지는 경우(link, script, img, audio 등…)에 요청 개체는 no-cors를 기본값으로 갖는다. 다만, crossorigin 속성을 부여하면 mode는 cors가 된다.

1
2
// 아래의 경우는 mode가 cors
<link rel="manifest" href="/app.webmanifest" crossorigin="use-credentials">

이 내용은 MDN에서 확인할 수 있다.

Requests can be initiated in a variety of ways, and the mode for a request depends on the particular means by which it was initiated.

​For example, when a Request object is created using the Request.Request constructor, the value of the mode property for that Request is set to cors

However, for requests created other than by the Request.Request constructor, no-cors is typically used as the mode; for example, for embedded resources where the request is initiated from markup, unless the crossorigin attribute is present, the request is in most cases made using the no-cors mode — that is, for the <link> or <script> elements (except when used with modules), or <img>, <audio>, <video>, <object>, <embed>, or <iframe>elements.

https://developer.mozilla.org/en-US/docs/Web/API/Request/mode#Default_mode

W3C의 Cross-Origin Resource Sharing 스펙은 cors 모드인 요청을 처리하는 과정에서 CORS 확인에 실패하면 network error 처리 프로세스를 따를 것을 요구한다.

Perform a resource sharing check. If it returns fail, apply the network error steps.

https://www.w3.org/TR/cors/#network-error-steps

network error는 또 다른 스펙인 WHATWG의 Fetch Standard에서 정의하고 있는데, type이 error이며, status는 0이고, status message가 빈 문자열을 갖는 응답 개체를 의미한다.

A network error is a response whose status is always 0, status message is always the empty byte sequence, header list is always empty, body is always null, and trailer is always empty.

https://fetch.spec.whatwg.org/#concept-network-error

꽤 길게 적었지만 요약하자면 이렇다.

CORS 실패 → 네트워크 에러 발생 → status가 0

이 내용을 이해하려고 3개의 스펙 문서를 어지럽게 돌아다녀야 하는 게 불만이었지만, XHR → CORS → Fetch로 스펙이 발달해온 과정과 스펙 사이의 의존관계를 생각해보니 조금은 인자해졌다.

XMLHttpRequest
Cross-Origin Resources Shaaring
Fetch Standard