[모던JS: 심화] 네트워크 요청 (2)

KG·2021년 6월 30일
1

모던JS

목록 보기
41/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

fetch와 Cross-Origin 요청

문서와 리소스 로딩 챕터에서 간단하게 CORS 정책에 대해 이야기 한 적이 있다. 이는 브라우저를 사용하는 사용자를 위해 설정된 보안 정책으로 어떤 도메인에서 다른 도메인으로 네트워크 요청을 보낼 때 발생하는 권한 관련 이슈이다. 해당 챕터에서는 fetch 메서드를 사용할 때 발생할 수 있는 CORS 이슈에 대해 조금 더 자세히 살펴보고 관련 권한을 획득하여 정상적인 요청을 주고 받을 수 있는 방법에 대해 알아보자.

try {
  await fetch('http://example.com');
} catch(err) {
  alert(err); // TypeError: Failed to fetch
}

fetch로 요청을 보내게 될 사이트가 현재 접속 사이트와 다른 경우엔 요청이 실패할 수 있다. 이는 오리진(Origin)이 다를때 발생하는 이슈인데, 오리진은 도메인/프로토콜/포트 세 가지에 의해 결정되는 일련의 주소를 말한다. 서로 다른 오리진끼리 요청을 전송하는 것을 Cross-Origin Request라고 부르는데, 해당 요청을 정상적으로 전송하기 위해서는 리모트 오리진에서 특별한 헤더를 사전에 전송해 주어야 한다. 이러한 정책을 CORS(Cross-Origin Resource Sharing, 크로스 오리진 리소스 공유)라고 부른다.

1) CORS 등장 배경

CORS는 앞서 이야기한 바와 같이 브라우저를 사용하는 유저를 보호하기 위해 고안된 정책이다. 과거 수 년 동안, 한 사이트의 스크립트에서 다른 사이트에 있는 콘텐츠에 접근할 수 없다는 제약이 존재했다. 이와 같은 제약은 당연히 인터넷 보안을 위한 근간이었는데, 이를 통해 악의를 가진 해커가 만든 웹 사이트에서 다른 도메인으로 함부로 접근하는 행위 등을 차단할 수 있었다. 때문에 브라우저 사용자는 비교적 안전하게 인터넷을 사용할 수 있었다.

그러나 이 당시 자바스크립트는 네트워크 요청을 보낼 수 있을 만한 메서드를 지원하지 않았다. 당시에는 자바스크립트가 그저 웹 페이지를 조금 더 동적으로 꾸밀 수 있는 토이 랭귀지 수준이었기 때문이다.

하지만 많은 웹 개발자들이 강력한 기능을 원하기 시작하며, 이러한 제약을 피해 다른 웹 사이트에 요청을 보내기 위한 여러 트릭을 만들기 시작했다.

폼 사용하기

가장 대표적인 트릭 중 하나는 <form> 요소를 사용하는 것이었다. <form> 요소 안에 <iframe>을 넣어 폼 전송을 했는데, 이를 통해 현재 사이트에 남아있으면서 네트워크 요청을 보내는 작업이 가능했다. 아래의 코드는 폼 요소를 통해 요청을 보내고 그 응답을 현재 페이지에 있는 <iframe>에 받아오도록 하는 예시이다.

<!-- 폼 target -->
<iframe  name="ifame"></iframe>

<!-- 자바스크립트를 사용해 폼을 동적 생성하고 전송 -->
<form target="iframe" method="POST" action="http://another.com">
  ...
</form>

이 당시엔 네트워크 관련 메서드가 없었지만, 폼은 어디서든 데이터를 보낼 수 있다는 특징을 이용해 폼으로 다른 사이트에 GET, POST 요청을 전송할 수 있었다. 하지만 다른 사이트에서 <iframe>에 있는 콘텐츠를 읽는 것은 금지되었기 때문에 응답을 읽는 것은 불가능했다.

그러나 개발자들은 iframe과 페이지 양쪽에 특별한 스크립트를 심어 이런 제약 또한 우회할 수 있는 트릭을 만들었다. 이를 통해 오리진이 서로 다른 사이트 간에도 제약 없이 양방향 통신이 가능하도록 구현할 수 있었다. 어떤 식으로 우회하는 코드를 구현했는지는 따로 소개하지 않겠다.

스크립트 사용하기

script 태그를 사용하는 것 역시 제약을 피하기 위한 일종의 트릭으로 사용할 수 있었다. script 태그의 src 속성값에는 도메인 제약이 없기 때문이다. 이는 script 태그가 src 경로에 있는 대상을 호출한 결과를 포함시키는 것이 아니라 그저 실행시키는 역할이기 때문에 도메인 제약이 적용되지 않는다. 이 특징을 이용해 어디서든 스크립트를 실행할 수 있다. 이처럼 HTML 태그 중 몇몇은 외부 리소스에 접근하더라도 CORS가 적용되지 않는 것들이 있다. 대표적으로 scriptimg 태그가 그러하다.

script 태그를 사용해 <script src="http://another.com"> 형태로 다른 오리진에 데이터를 요청하게 되면 JSONP(JSON with Padding)이라고 불리는 프로토콜을 사용해 데이터를 가져오게 된다.

이 과정을 단계별로 살펴보면 다음과 같다. 먼저 날씨 정보가 저장되어 있는 http://another.com에서 데이터를 가져와야 한다고 가정해보자.

  1. 먼저 서버에서 받아온 데이터를 소비하는 전역 함수 gotWeather를 선언
// JSONP 프로토콜에서는 서버에서 내려준 콜백함수가
// HTML에서 미리 정의되어 있어야 함
function gotWeather({ temperature, humidity }) {
  alert(`temp: ${temperature}, hum: ${humidity}`);
}
  1. src="http://another.com/weather.json?callback=gotWeather"을 속성으로 갖는 script 태그를 만들고, 1번에서 만든 함수를 callback 매개변수로 지정
let script = document.createElement('script');
script.src = `http://another.com/weather.json?callback=gotWeather`;
document.body.append(script);
  1. 리모트 서버 another.com은 클라이언트에서 필요한 날씨 데이터와 함께 gotWeather(...)를 호출하는 스크립트를 동적 생성
// 서버에서 전달하는 데이터
// 콜백함수에 감싸진 형태의 JSON 데이터
// 이 형식을 곧 JSONP라고 일컫음
gotWeather({
  temperature: 26,
  humidity: 78,
});

JSONPCORS가 활성화 되기 이전 데이터 요청 방법으로 서버에서 JSON 형식의 데이터를 전달할 때 콜백함수로 감싸서 전달하는 형식이다. 이는 script 태그가 도메인 제약이 적용되지 않기에 외부 도메인에 요청을 보낼 수 있고 이에 대한 결과값을 저장하기 위해 고안된 방법이다. CORS 정책이 고안된 후에는 보안 상의 이유로 오늘날엔 거의 사용되지 않는 기술이기 때문에 더 상세하게 다루지는 않으려 한다.

이런 꼼수를 쓰면 보안 규칙을 위배하지 않으면서 양방향 데이터 전송이 가능했다. 양쪽에서 인지하고 동의한 상태라면 해킹으로 볼 수도 없다. 아직도 오래된 브라우저나 서비스에서는 이러한 방식을 사용해서 통신을 주고 받는 경우도 있다.

그러던 와중에 브라우저측 자바스크립트에 네트워크 관련 메서드가 추가되면서 CORS 정책 역시 추후에 만들어졌다. 초기에는 네트워크 메서드를 사용해서 크로스 오리진 요청이 불가했는데, 긴 논의 끝 이를 허용하기로 하되 대신 서버에서 명시적으로 이를 허가했다고 알려주는 특별한 헤더를 전달받은 경우에만 가능하도록 제약을 만들었고, 이를 CORS 정책이라 부른다.

2) 안전한 요청

크로스 오리진 요청은 크게 두 가지 종류로 구분된다.

  1. 안전한 요청 (safe request)
  2. 그 외의 요청 (모두 unsafe request로 간주됨)

안전한 요청의 경우엔 그 외의 요청 대비 만들기 간단하다. 다음 두 가지 조건을 모두 충족하는 경우 안전한 요청으로 분류한다.

  1. 안전한 메서드 (safe method) : GET/POST/HEAD
  2. 안전한 헤더(safe header) : 다음 목록에 속하는 헤더
    • Accept : 요청 전송 시 기대하는 타입의 데이터
    • Accept-Language : 요청 전송 시 기대하는 언어
    • Content-Language : 사용자 자체의 언어를 뜻함. 요청이나 응답이 무슨 언어인지와는 관련 없음
    • Content-Type의 값이 application/x-www-form-urlencoded / multipart/form-data / text/plain에 속하는 경우

위 두 조건을 만족하지 않는 모든 요청은 안전하지 않은 요청(unsafe request)으로 간주된다. 즉 PUT 메서드를 사용한다거나 헤더에 API-Key가 명시되어 있다면 모두 안전하지 않은 요청으로 분류된다.

안전한 요청과 그렇지 않은 요청의 근본적인 차이는 특별한 방법을 사용하지 않고 form이나 script를 사용해 요청을 만들 수 있는지 아닌지르 구분한다. 이러한 태그를 사용해 요청을 전송하는 행위는 아주 오래전부터 존재했기에, 오래된 웹서버라도 안전한 요청은 당연히 처리할 수 있다.

만약 표준이 아닌 헤더가 들어있거나 안전하지 않은 메서드(PUT, PATCH, DELETE 등)를 사용한 요청은 안전한 요청이 될 수 없다. 왜냐하면 아주 오래 전에는 자바스크립트를사용해 이런 요청을 보내는 것이 불가능했기 때문이다. 따라서 오래된 서버 입장에서는 이러한 요청이 웹 페이지에서는 불가하기 때문에, 무언가 특별한 다른 곳에서 왔을거라고 생각하고 작업을 처리했다.

하지만 시간이 지나고 네트워크 관련 메서드가 지원되면서 개발자가 자바스크립트로 안전하지 않은 요청을 보낼 수 있게 되자, 브라우저는 안전하지 않은 요청을 서버에 전송하기 전에 preflight 요청을 먼저 전송하고, 서버가 크로스 오리진 요청을 받을 준비가 되어있는지 사전에 체크하는 방식을 사용하게 되었다.

이때 서버에서 크로스 오리진 요청은 허용하지 않는다는 정보를 담은 헤더가 응답으로 오게되면, 안전하지 않은 요청은 서버로 전송되지 않는다. 이것이 CORS 정책이 등장하기 까지의 배경과 역사이다. 이젠 CORS 정책을 준수하며 데이터를 주고 받기 위해 어떤 작업이 필요한지 살펴보도록 하자.

3) CORS와 안전한 요청

크로스 오리진 요청을 보낼 경우 브라우저는 항상 Origin이라는 헤더를 요청에 추가한다. 만약 https://javascript.info/page에서 https://anywhere.com/request으로 요청을 보낸다고 가정해보자. 둘은 서로 다른 도메인이기 때문에 크로스 오리진 요청에 해당한다. 따라서 헤더는 다음과 같은 형태를 띈다.

GET /request
Host: anywhere.com
Origin: https://javascript.ino
...

위와 같이 Origin 헤더 정보에는 요청이 이뤄지는 페이지 경로(.../page)가 아닌 오리진(도메인/프로토콜/포트) 정보만 담긴다.

서버는 요청 헤더에 있는 Origin을 검사하고, 요청을 받아들이기로 합의된 상태라면 특별한 헤더인 Access-Control-Allow-Origin을 응답에 추가해 반환한다. 해당 헤더에는 허가된 오리진에 대한 정보나, 모든 오리진에 대해 허용하는 경우 *가 명시되어 있다. 해당 헤더에 허가된 오리진 또는 *가 들어있다면 응답은 정상적으로 반환되고 그렇지 않은 경우에는 응답이 실패한다.

브라우저는 이 과정에서 중재 역할을 한다.

  1. 브라우저는 크로스 오리진 요청 시 Origin에 값이 제대로 설정, 전송되었는지 확인
  2. 브라우저는 서버로부터 받은 응답에 Access-Origin-Allow-Origin이 있는지를 확인하고, 관련 오리진 정보가 명시되어 있는지 체크. 명시되어 있다면 자바스크립트를 사용해 응답에 정상접근이 가능하고, 아닌 경우 에러 발생

서버에서 크로스 오리진 요청을 허용한 경우 preflight 요청에 대한 응답은 다음과 같은 형태를 띈다.

200 OK
Content-Type: text/html; charset=UTF-8
Access-Control-Allow-Origin: https://javascript.info

4) 응답 헤더

크로스 오리진 요청이 이뤄진 경우, 자바스크립트는 기본적으로 안전한 응답 헤더로 분류되는 헤더에만 접속할 수 있다. 안전한 응답 헤더는 다음과 같다.

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

일부 HTTP 응답 헤더는 HTTP/1.0 프로토콜에서 사용되는 경우로 오늘날 HTTP/3.0이 표준으로 채택된 기점으로 더 이상 사용되지 않는 목록이 있을 수 있다.

앞서 fetch 메서드를 사용할 때 다운로드 진행 상황을 추적할 때 Content-Length 헤더를 이용하는 경우를 살펴보았다. Content-Length는 응답 본문 크기 정보를 담고 있는 헤더로 안전한 응답 헤더 목록에 속하지 않는다. 때문에 이에 접근하는데 특별한 권한이 필요하기에, 만약 문제가 발생한다면 관련 권한 획득 이슈일 확률이 높다.

자바스크립트를 사용해 안전하지 않은 응답 헤더에 접근하려면 서버에서 Access-Control-Expose-Headers 라는 헤더를 보내주어야 한다. 해당 헤더에는 자바스크립트 접근을 허용하는 안전하지 않은 헤더 목록이 담겨있고, 여러 개의 헤더를 콤마로 구분해 명시할 수 있다.

200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 12345
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Expose-Headers: Content-Length, API-Key

응답 헤더 Access-Control-Expose-Headers 목록에 각각 Content-LengthAPI-Key가 명시되어 있기 때문에 자바스크립트로 해당 헤더에 대한 접근이 가능하다.

5) 안전하지 않은 요청

요즘엔 RESTFul API 라는 이름 하에 GET, POST 메서드 외에도 PATCH, PUT, DELETE와 같은 메서드를 용도에 맞게 사용해 요청을 전송할 수 있다. 그런데 해당 메서드들은 비교적 최근에 생긴 것으로, 과거에는 웹 페이지에서 GETPOST 메서드만 사용해 요청을 보낼 수 있었다. 때문에 오래된 웹 서버의 경우엔 아직 새로운 메서드를 다룰 수 없는 경우도 종종 있다. 때문에 이런 서버들은 GET/POST 이외의 요청이 들어오는 경우 브라우저에서 보낸 요청이 아니라고 판단하고 접근 권한을 따로 확인한다.

이런 혼란을 피하기 위해 브라우저는 안전하지 않은 요청이 이뤄지는 경우, 서버에 바로 요청을 보내지 않고 preflight 요청이라는 사전 요청을 서버에 미리 보내고 권한 여부를 체크한다. 이때 preflight 요청을 보낼 때 사용하는 메서드가 OPTIONS이다. 해당 메서드는 서버에서 지원하는 메서드를 확인할 때 보내는 요청인데, 이를 통해 서버가 어떤 parameters를 포함한 요청을 보내도 되는지에 대한 응답을 줄 수 있는지 확인할 수 있다.

preflight 요청은 OPTIONS 메서드와 함께 두 헤더가 함께 들어가며, 이때 본문은 비어있는 형태이다.

  • Access-Control-Request-Method 헤더 : 안전하지 않은 요청에서 사용하는 메서드 정보가 담겨있다.
  • Access-Control-Request-Headers 헤더 : 안전하지 않은 요청에서 사용하는 헤더 목록이 담겨있다.

안전하지 않은 요청을 허용하기로 협의했다면 서버는 본문이 비어있고 상태 코드가 200인 응답을 다음과 같은 헤더와 함께 브라우저에 반환한다.

  • Access-Control-Allow-Origin : *(모든 오리진)이나 요청을 보낸 오리진
  • Access-Control-Allow_Methods : 허용된 메서드 정보
  • Access-Control-Allow-Headers : 허용된 헤더 목록
  • Access-Control-Max-Age : 퍼미션 체크 여부를 몇 초간 캐싱할 지 명시. 퍼미션 정보가 캐싱된 경우 브라우저는 일정 기간 동안 preflight 요청을 생략하고 안전하지 않은 요청을 전송 가능

이 일련의 과정을 그림으로 나타내면 아래와 같다.

실제로 안전하지 않은 크로스 오리진 요청을 보내는 코드를 단계별로 나누어 살펴보자. 아래 예시에서는 PATCH 메서드를 사용했는데, 이는 주로 데이터를 갱신할 때 사용하는 메서드이다. 각 메서드에 대한 자세한 설명은 추후 다른 포스트에서 소개하겠다.

let response = await fetch('https://site.com/service.json', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'API-Key': 'secret',
  }
});

일단 위 요청이 안전한 요청이 아닌 이유는 세 가지가 있다.

  • PATCH 메서드 사용
  • Content-Typeapplication/json에 해당
  • 비표준 헤더 API-Key 사용

1단계: preflight 요청

본 요청을 보내기 전에 브라우저는 자체적으로 다음과 같은 preflight 요청을 서버에 전송한다.

OPTIONS /scrvice.json
Host: site.com
Origin: https://javascript.info
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type, API-Key

2단계: preflight 응답

서버는 상태코드 200과 함께 다음과 같은 헤더를 담은 응답을 보낸다.

  • Access-Control-Allow-Methods: PATCH
  • Access-Control-Allow-Headers: Content-Type, API-Key

위 응답이 오지 않는다면 더 이상 서버로 다음 요청을 보낼 수가 없다. 때문에 본 요청에서는 에러가 발생할 것이다. 웹 애플리케이션의 규모가 커짐에 따라 미래에 PATCH 메서드외에 다른 메서드를 사용해 요청을 전송하는 경우가 생길 수 있다. 이를 대비하기 위해선 Access-Control-Allow-MethodsAccess-Control-Allow-Headers에 원하는 메서드와 헤더를 추가하면 된다.

200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400

위와 같이 서버에서 prefilght 응답이 오게 되면 브라우저는 PATCH 메서드가 허용되었는지 확인하고 관련 헤더 역시 허용되었는지 확인 후 본 요청을 서버에 전송하게 된다.

참고로 Access-Control-Max-Age 헤더가 응답으로 오는 경우 prefilght 허용 여부가 헤더와 함께 캐싱되기 때문에, 브라우저는 헤더 값에 명시한 초 동안 새로운 preflight 요청을 보내지 않는다. 이는 HTTP 통신 자체가 기본적으로 stateless 방식이기 때문에 이전 요청을 기억하지 않아 발생하는 이슈이다. 매번 새로운 preflight 요청을 보내는 것은 보안적으로 강력하지만, 자원을 사용하는 입장에서는 비효율적이므로 별도의 값을 캐싱해두어 해당 요청을 생략할 수 있다.

3단계: 실제요청

preflight 요청이 성공적으로 이루어진 후에야 브라우저는 본 요청을 서버로 전송한다. 이후 프로세스는 안전한 요청이 이루어질 때와 모두 동일하다. 본 요청은 크로스 오리진 요청이기 때문에 Origin 헤더가 붙는다.

PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.info

4단계: 실제응답

서버에선 본 요청에 대한 응답으로 Access-Control-Allow-Origin 헤더를 반드시 붙여주어야 한다. 이는 prefilght 요청이 성공했더라도 항상 동반되어야 하는 작업이다. 앞서 말했다싶이 HTTP 통신은 stateless하기 때문에 이전 정보를 기억하지 못하기 때문이다.

Access-Contorl-Allow-Origin: https://javascript.info

이 모든 과정이 끝나야 자바스크립트를 사용해 실제 응답에 접근할 수 있다.

preflight 요청은 무대 밖에서 일어나기 때문에 자바스크립트를 사용해 중간에 개입할 수 없는 영역이다. 따라서 자바스크립트는 본 요청에 대한 응답을 받아올 때에만 어떤 작업을 수행할 수 있다. 서버에서 크로스 오리진 요청을 허용하지 않는다면 에러가 발생한다.

위 일련의 과정은 순수 자바스크립트 메서드만 이용했을 때 서버에서 같이 처리해주어야 하는 작업이다. 오늘날 fetch 메서드 대신 조금 더 편의성과 기능적인 부분이 강화된 axios 라이브러리를 사용하는 것처럼, 서버쪽에서도 cors와 같은 라이브러리를 사용해서 일일이 헤더를 설정하지 않고도 CORS 관련 설정을 수행할 수 있다.

6) 자격 증명

자바스크립트로 크로스 오리진 요청을 보내는 경우, 기본적으로 쿠키나 HTTP 인증 같은 자격 증명(credential)이 함께 전송되지는 않는다. HTTP 요청의 경우 대개 쿠키가 함께 전송되는데, 자바스크립트를 사용해 만든 크로스 오리진 요청은 예외에 해당한다. 쿠키와 인증 정보가 무엇인지는 다음 챕터에서 자세히 다루도록 하자.

따라서 fetch('http://another.com')과 같은 요청에는 anther.com과 관련된 쿠키 정보는 함께 서버로 전송되지 않는다. 이러한 예외가 생긴 이유는 자격 증명과 함께 전송되는 요청은 보안과 매우 밀접한 관련을 맺고 있기 때문이다. 크로스 오리진 요청 시 자격 증명을 자동으로 보내게 된다면, 사용자의 동의 없이 자바스크립트를 사용해 민감한 정보에 접근할 수 있게 된다.

하지만 그럼에도 불구하고, 서버에서 이러한 자격 증명 정보를 받아야 할 때가 있다. 대표적으로 유저의 로그인 정보를 받아, 자동 로그인 등과 같은 기능을 구현하려는 경우가 있다. 이런 경우에 서버에서 이를 허용하고자 한다면, 자격 증명이 담긴 헤더를 명시적으로 허용하겠다는 별도의 세팅을 해주어야 한다. 만약 fetch 메서드에 자격 증명 정보를 함께 전송하고자 한다면, credentials: "include" 옵션을 추가하면 된다.

fetch('http://another.com', {
  credentials: 'include',
});

이렇게 추가적인 옵션을 명시하면 fetch로 서버에 요청을 보낼 때 antoher.com에 대응하는 쿠키 정보 역시 같이 전송되게 된다. 자격 증명 정보가 담긴 요청을 서버에서 받아들이기로 동의했다면, 서버는 응답에 Access-Control-Allow-Origin 헤더와 함께 Access-Control-Allow-Credentials: true 헤더를 추가해서 보내게 된다.

200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Credentials: true

자격 증명이 함께 전송되는 요청을 보낼 땐 Access-Control-Allow-Origin*을 쓸 수 없다. 정확한 오리진 정보가 명시되어야만 자격 증명이 정상적으로 오고 갈 수 있다. 이러한 제약이 있기 때문에 어떤 오리진에서 관련 요청이 왔는지에 대한 정보를 서버가 신뢰할 수 있기 때문이다.

자격 증명을 허용하는 옵션은 네크워크 메서드에 따라 조금씩 다를 수 있다. 하지만 보통 credential 이란 키워드가 포함되어 있고 그 목적은 모두 동일하다.

Origin 헤더는 조금 더 강력한 신뢰성을 가진 헤더 정보이다. 비슷한 정보를 가진 헤더로 Referer가 있는데, 해당 헤더는 보통 Origin 보다 조금 더 자세한 정보를 담고 있다. Referer는 주로 네트워크 요청을 처음 시작한 페이지의 전체 URL 정보를 담기 때문이다. 그렇지만 Referer는 여러 이유로 생략이 될 수 있는 헤더이다. 일단 명세서에 따르면 HTTP 헤더에서 선택 사항에 해당하는 값이고, fetch 메서드에서는 별도로 Referer를 막을 수 있는 옵션을 제공한다. 또한 보안 수준이 높은 프로토콜에서 낮은 프로토콜을 사용해 접근하는 경우(HTTPS ➡ HTTP)엔 Referer가 없다. 이처럼 Referer는 신뢰할 수 없는 정보를 담을 수 있는 가능성이 있기 때문에 HTTP 헤더 명세서에는 Origin이 추가되었고, 이 값을 활용해 크로스 오리진 요청을 수행한다.

fetch API

앞서 fetch 메서드에 대해 살펴보았다. 하지만 그 외에도 fetch 메서드에 명시할 수 있는 옵션은 여러가지가 있다. 해당 옵션들은 사실 잘 사용되지 않는 경우가 대부분이지만, 나중을 위해 지원하는 옵션들을 간단하게라도 훑고 넘어가보도록 하자.

fetch 메서드에서 사용할 수 있는 옵션에는 다음과 같은 것들이 있다

let response = fetch(url, {
  method: 'GET',
  headers: {
    'Content-Type': 'text/plain; charset=UTF-8'
  },
  body: undefined,
  referrer: 'about:client',
  referrerPolicy: 'no-referrer-when-downgrade',
  mode: 'cors',
  credentials: 'same-origin',
  cache: 'default',
  redirect: 'follow',
  integrity: '',
  keepalive: false,
  signal: undefined,
  window: window
});

앞서 method, headers, body 그리고 siganl에 대해서는 충분히 살펴보았다. 그 외 각각의 옵션에 대해 하나씩 살펴보도록 하자.

1) referrer, referrerPolicy

해당 옵션은 fetch 메서드가 HTTP Referer 헤더를 설정하는 방법을 제어한다. 일반적으로 해당 헤더는 자동으로 설정 되며 요청이 처음 만들어진 페이지의 경로를 포함한다. 대부분의 경우 해당 정보는 중요하게 취급되지 않는다. 이를 대신해서 Origin 헤더에 더 신뢰할 수 있는 도메인 정보가 들어가기 때문이다. 보안을 위해서라면 해당 옵션을 사용하지 않는 것이 때때로 더 유용할 수도 있다.

재미있는 점은 HTTP 헤더의 이름은 Referer이다. 원래 영단어도 referrer이 맞고 fetch 메서드의 옵션도 referrer로 명시하고 있는데, 헤더가 Referer인 이유는 단순 오타로 이미 돌이키기엔 하위 호환성이 깨질 것을 우려하여 계속 Referer 헤더로 유지하고 있다.

Referer 요청 헤더는 현재 요청된 페이지의 링크 이전 웹 페이지 주소를 포함하는 용도로 활용이 가능하다. 해당 헤더를 통해 사람들이 어디로부터 와서 방문 중인지 분석이 가능하다. referrer 옵션을 사용하면 현재 오리진 내에서 Referer를 설정하거나 제거할 수 있다.

만약 Referer를 설정하지 않으려면 빈 문자열을 보내면 된다.

fetch('/page', {
  referrer: ""	// no Referer header
});

현재 오리진 내에서 또 다른 경로를 지정해서 Referer 헤더를 설정할 수 있다.

fetch('/page', {
  referrer: 'https://javascript.info/anotherpage'
});

referrerPolicy 옵션은 Referer 헤더를 위한 일반적인 규칙을 설정할 수 있다. 요청은 다음과 같이 3가지 타입으로 구분할 수 있다.

  • 동일 오리진에 대한 요청
  • 다른 오리진에 대한 요청
  • 보안 수준이 높은 프로토콜에서 낮은 수준의 프로토콜로 요청 (HTTPSHTTP)

정확한 Referer 값을 설정할 수 있는 referrer 옵션과 달리 referrerPolicy 옵션은 브라우저에게 각 요청 타입에 대한 일반적인 규칙을 알려준다. 규칙 유형은 다음과 같은 것들이 있다.

  • no-referrer-when-downgrade : 대개 기본값이며 항상 완전한 Referer이 전송된다. 다만 HTTPS 프로토콜에서 HTTP 프로토콜로의 전송은 예외이다.
  • no-referrer : Referer를 어떠한 경우에도 전송하지 않는다.
  • origin : Referer에 있는 오리진만 전송하고 전체 경로는 전송하지 않는다. 예를 들어 http://site.com/path인 경우엔 http://site.com만 전송된다.
  • origin-when-cross-origin : 동일 오리진일 경우 Referer 전체가 전송된다. 그러나 크로스 오리진 요청일 경우엔 오리진만 전송된다.
  • same-origin : 동일 오리진이라면 완전한 Referer를 전송한다. 그러나 크로스 오리진 요청이라면 no-referrer과 동일하다.
  • strict-origin : 오리진만 전송하고 만약 고수준 보안에서 저수준 보안으로의 요청이라면 Referer 자체를 전송하지 않는다.
  • strict-origin-when-cross-origin : 동일 오리진이라면 완전한 Referer을 전송하지만 크로스 오리진 요청에서는 오직 오리진만 전송한다. 또한 고수준 보안에서 저수준 보안 프로토콜로의 요청이면 아무것도 전송하지 않는다.
  • unsafe-url : 항상 완전한 Referer을 전송한다. 이는 HTTPS ➡ HTTP 요청일 때도 마찬가지이다.

referrerPolicy의 기본값은 브라우저별로 조금씩 다르다. 또한 동일 브라우저여도 버전별로 다른 경우가 있다. 예를 들어 크롬의 경우 85버전 이후로 기본값은 strict-origin-when-cross-origin으로 변경되었다.

referrerPolicy에 대한 값을 테이블로 나타내면 아래와 같다.

ValueTo same OriginTo Anther OriginHTTPS ➡ HTTP
no-referrer---
no-referrer-when-downgradefullfull-
originoriginoriginorigin
origin-when-cross-originfulloriginorigin
same-originfull--
strict-originoriginorigin-
strict-origin-when-cross-originfullorigin-
unsafe-urlfullfullfull

Referer 헤더에 너무 많은 정보가 들어있다면 보안상의 이유로 좋지 않다. 해당 정보를 이용해 어느 사이트에서 누가 방문한건지에 대한 중요 정보를 누군가 파악하고 이를 악용할 수 있기 때문이다. 따라서 보통 권고되는 값은 strict-origin-when-cross-origin이다.

Referer 관련 정책은 fetch 메서드에만 한정된 것이 아니다. Referrer-Policy HTTP 헤더를 사용하는 전체 페이지 또는 <a rel="norefferer">과 같이 HTML 태그에도 적용되는 정책이다.

2) mode

mode 옵션은 경우에 따라 크로스 오리진 요청을 방지하는 보호 기능 역할을 한다.

  • cors : 기본값으로 크로스 오리진 요청을 항상 허용한다.
  • same-origin : 크로스 오리진 요청은 금지된다.
  • no-cors : 아주 단순한 크로스 오리진 요청만을 허용한다.

해당 옵션은 서드 파티 라이브러리로부터 fetch 메서드에 명시할 URL을 가져오는 경우 유용할 수 있다.

3) credentials

credentials 옵션은 fetch 메서드가 쿠키나 HTTP 인증과 같은 자격 증명 정보를 요청에 포함해 전송할 수 있는지 없는지에 대한 여부를 결정한다.

  • same-origin : 기본값으로 크로스 오리진 요청에는 자격 증명 정보를 전송하지 않는다.
  • include : 항상 자격 증명 정보를 같이 전송한다. 이때 서버로부터 Accept-Control-Allow-Credentials 헤더를 응답 받아야 한다.
  • omit : 어떠한 경우에도 보내지 않는다.

4) cache

기본적으로 fetch 요청은 표준 HTTP-캐싱을 사용한다. 즉 일반적인 HTTP 요청과 마찬가지로 Expires, Cache-Control과 같은 헤더를 준수하며 If-Modified-Since와 같은 값을 전송할 수 있다.

cahe 옵션을 사용하면 HTTP 캐시를 무시하거나 용도를 쓰임에 맞게끔 미세하게 조정할 수 있다.

  • default : fetch 메서드는 표준 HTTP-캐싱 규칙과 헤더를 준수
  • no-store : HTTP 캐시를 완전히 무시. 헤더를 If-Modified-Since, If-None-Match, If-Unmodified-Since, If-Match, If-Range와 같은 조건부 헤더로 설정했을때 기본값
  • reload : HTTP 캐시로 부터 결과를 가져오지 않지만 응답값으로 캐시를 갱신
  • no-cache : 만약 캐시된 응답이 있다면 조건부 요청을 아니라면 일반 요청 생성
  • force-cache : 최근 캐시된 정보가 아니더라도 HTTP 캐시로 부터 응답을 사용. 만약 캐시된 응답이 HTTP 캐시에 없다면 일반적인 HTTP 요청처럼 작동
  • only-if-cached : force-cache와 동일하나 캐시된 응답이 없다면 에러 발생

5) redirect

요청 이후 리다이렉트와 관련된 옵션을 설정할 수 있다.

  • manual : 리다이렉트를 허용하지 않음
  • error : 리다이렉트 응답을 에러로 처리
  • follow : 리다이렉트 응답을 허용

6) Integrity

Integrity 옵션은 응답이 미리 알려진 체크섬과 일치하는지에 대한 여부를 확인할 수 있다. 여기서 체크섬은 SHA-256, SHA-384, SHA-512와 같은 해시 알고리즘으로 브라우저별로 조금씩 다를 수 있다. 만약 fetch 메서드를 사용해 파일을 다운로드 할 때 SHA-256 알고리즘에 abcdef와 같은 체크섬을 사용하는 것을 알고있다면 다음과 같이 일치여부를 확인해볼 수 있다.

fetch('http://site.com/file', {
  integrity: 'sha256-abcdef'
});

일치하는 경우 정상적으로 요청이 전송되어 응답을 받아볼 수 있지만, 불일치의 경우엔 에러가 발생한다.

7) keepalive

keepalive 옵션은 요청이 해당 요청을 생성한 웹 페이지보다 더 오래 생존할 수 있음을 명시한다.

예를 들어 현재 방문자를 통해 사용자 경험을 분석하고 개선하려고 한다. 이때 방문자가 해당 페이지를 떠나는 경우 우리는 마우스 클릭과 같은 통계 정보를 수집할 수 있을 것이다. 사용자가 페이지를 떠나면 window.onload를 통해 이를 캐치하고 관련 정보를 서버에 전송해야 한다.

이때 일반적으로는 웹 페이지를 떠나는 경우 관련 네트워크 요청은 자동으로 모두 중단된다. 하지만 우리가 필요한 정보는 유저가 웹 페이지를 떠날 때 그 정보를 서버에 전송하는 것이므로 요청이 중단되지 않고 정상적으로 계속 수행되어야 한다. 이럴때 keepalive 옵션을 true로 설정하면 백그라운드에서 계속 네트워크 요청이 돌아가도록 할 수 있다.

window.onunload = function() {
  fetch('/analytics', {
    method: 'POST',
    body: "statistics",
    keepalive: true
  });
};

물론 여기에는 몇 가지 한계가 존재한다.

  • 요청을 통해 전송 가능한 데이터 크기는 최대 64KB이다. 때문에 사용자가 페이지를 떠날 때 한번에 관련 데이터를 보내려면 크기제한으로 요청이 실패할 수 있다. 그러한 이유로 사용자 데이터가 너무 커지지 않도록 중간중간 정기적으로 패킷 단위로 서버에 전송할 필요가 있다.

  • 해당 옵션은 모든 keepalive를 사용하는 네트워크 요청에 공동 적용된다. 즉 병렬적으로 네트워크 요청을 사용하는 경우, 해당 요청들이 보내는 데이터의 총합 역시 64KB를 넘을 수 없다.

  • 문서가 unload 되는 경우엔 당연히 요청에 대한 응답을 처리할 수 없다. 보통 통계 자료를 전송하는 경우엔 문제가 되지 않는 경우가 많다. 일반적으로 이에 대한 응답은 보통 빈 응답을 보내는 경우가 많기 때문이다.

URL Objects

자바스크립트는 URL 내장 클래스를 제공한다. 이를 이용해 보다 편리하게 URL을 만들고 파싱할 수 있다. URL 클래스 자체는 네트워크 메서드와 관련된 기능을 지원하지 않는다. 따라서 문자열을 사용해서 URL 경로를 작성하는 것과 기능적으로 아주 큰 차이가 있지는 않다. 그러나 URL 클래스를 사용하면 특정 상황에서 도움을 얻을 수 있다.

1) URL 생성

URL 생성자를 이용해 URL 객체를 생성하는 문법은 다음과 같다.

let url = new URL(url, [base]);
  • url : 전체 URL 경로 또는 일부 경로 (일부 경로는 base가 명시된 경우, 이를 기준으로 전체 경로가 생성)
  • base : 생략가능하며, 기준이 될 URL 경로를 지정 가능

예를 들어 다음 두 코드는 서로 같은 URL 객체를 생성한다.

let url1 = new URL('https://javascript.info/profile/admin');
let url2 = new URL('/profile/admin', 'https://javascript.info');

또는 생성된 URL 객체를 베이스로 삼아 또 다른 URL 객체를 생성할 수 있다.

let url = new URL('https://javascript.info/profile/admin');
let newURL = new URL('tester', url);

console.log(newURL);	// https://javascript.info/profile/tester

그 외 URL 객체가 지원하는 추가적은 프로퍼티가 있다.

  • href : 전체 URL 경로로 url.toString()과 동일
  • protocol : 통신 프로토콜로 :까지 포함한 문자열
  • host : 포트넘버까지 포함한 호스트 주소 (포트가 명시되지 않은 경우 hostname만 출력)
  • pathname : 추가 경로
  • search : 파라미터 문자열로 ?으로 시작하는 범위 포함
  • hash : #으로 시작하는 해시값 문자열
  • ...

만약 HTTP Authentication 정보가 있는 경우 userpassword 프로퍼티를 사용할 수도 있다. (eg. http://login:password@site.com)

let url = new URL('https://javascript.info/url');

console.log(url.protocol);	// https:
console.log(url.host);		// javascript.info
console.log(url.pathname);	// /url

이렇게 만들어진 URL 객체는 네트워크 요청 메서드에 전달하는 문자열 경로대신 사용할 수 있다. 대부분 URL 문자열이 사용되는 모든 곳에서 URL 객체를 대신 사용할 수 있다. 이는 URL 객체가 알아서 전체 경로를 담은 문자열로 변환되기 때문이다.

2) SearchParams

URL 클래스를 이용하면 보다 편리하게 세부 항목별로 접근이 가능함을 알아보았다. 이 자체도 꽤나 좋은 기능이지만, 그렇다고 해서 문자열로 경로를 적는 방식에 비해 압도적으로 편리하다고 생각이 들지 않을 수 있다. 그러나 URL 객체를 이용해서 queryparams를 경로에 추가할 때 보다 편리한 변환 기능을 제공해준다.

검색 params를 URL 경로에 지정하여 URL 객체를 만들고 싶다고 가정해보자. 예를 들면 https://google.com/search?query=Javascript와 같은 URL을 만들고 싶다. 당연히 아래와 같이 전체 경로를 적어줄 수 있다

let url = new URL(`https://google.com/search?query=Javascript`);

이때 파라미터로 지정되는 글자에 스페이스가 있다거나, 아니면 비-라틴어 계열 특수문자가 포함되어 있는 경우엔 정상적으로 브라우저가 이를 이해할 수 없기 때문에 인코딩되어야 한다. 이때 url.searchParams 객체에서 지원하는 여러 메서드를 통해 변환 작업을 위임할 수 있다.

  • append(name, vlaue) : name - value 쌍으로 파라미터 추가
  • delete(name) : name을 가진 파라미터 제거
  • get(name) : name에 해당하는 파라미터 읽기
  • getAll(name) name을 가진 모든 파라미터 읽기 (?user=John&user=Peter)
  • has(name) : name에 해당하는 파라미터가 있는지 검사
  • set(name, value) : 파라미터를 name - value 쌍으로 설정/교체
  • sort() : name에 따른 파라미터 정렬
  • ...

url.searchParamsMap과 유사한 이터러블 객체이기 때문에 for...of를 통해 순회도 가능하다.

let url = new URL('https://google.com/search');

// 특수문자 느낌표(!)와 공백 사용 -> 아래처럼 변환
url.searchParams.set('q', 'test me!');
console.log(url); 
// https://google.com/search?q=test+me%21

// 특수문자 콜론(:) 사용 -> 아래처럼 변환
url.searchParams.set('tbs', 'qdr:y');
console.log(url); 
// https://google.com/search?q=test+me%21&tbs=qdr%3Ay

for(let [name, value] of url.searchParams) {
  console.log(`${name}=${value}`);
  // q=test me!, tbs=qdr:y
}

3) 인코딩

위에서 살펴본 것과 같이 URL 객체에서 지원하는 프로퍼티를 통해 경로 인코딩을 자동으로 적용할 수 있다. RFC3986 명세에는 URL에서 허용하는 문자와 허용하지 않는 문자 목록이 명시되어 있다. 허용되지 않는 문자는 반드시 인코딩이 수행되어야 한다. 이를 일일이 변환하려고 한다면 매우 번거로운 작업이 될 것이다. url.searchParams 객체를 이용하면 이를 자동으로 변환할 수 있기 때문에 매우 편리하다.

// 키릴 문자를 사용하는 경우 자동 인코딩 적용
let url = new URL('https://ru.wikipedia.org/wiki/Тест');

url.searchParams.set('key', 'ъ');
alert(url); //https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%81%D1%82?key=%D1%8A

이때 URL 객체가 등장하기 전에는 모든 URL 경로는 문자열로 표현했다. 그런데 브라우저가 인식할 수 있는 URL 경로는 한정되어 있고, 이를 벗어난 경우 인코딩이 일어나야 하는데 이는 사람이 일일이 적용하기 매우 힘들다. 오늘날엔 URL 객체를 통해 이 작업을 쉽사리 해결할 수 있지만 과거에는 다음과 같은 별도의 메서드를 사용했다.

  • encodeURI : URL 전체를 인코딩
  • decodeURI : 인코딩 된 URL을 다시 디코딩
  • encodeURIComponent : search parameters, hash와 같은 URL 일부만 인코딩
  • decodeURIComponent : 일부 인코딩 된 URL을 다시 디코딩

이때 encodeURIencodeURIComponent 사이에는 어떤 차이가 있을까?

encodeURI는 명세에 허용되지 않은 문자에 대해서만 인코딩을 적용한다. 그러나 encodeURIComponent는 허용되지 않은 문자는 물론 #, $, &, +, ,, :와 같은 특수기호 역시 인코딩을 진행한다는 차이가 있다.

let url1 = encodeURI('http://site.com/привет');
alert(url1); 
// http://site.com/%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82

let url2 = encodeURIComponent('http://site.com/привет')
alert(url2);
// http%3A%2F%2Fsite.com%2F%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82

encodeURIComponent은 만약 파라미터에 값으로 지정되는 값이 특수문자를 포함하고 있는 경우에 유용하다. 예를 들어 ?music=Rock&Roll 처럼 파라미터가 지정된 경우 Rock&Roll 자체가 하나의 값이 되어야 한다. 그러나 &기호는 search parameter 끼리 구분하는 기호로 사용되기 때문에 단순히 금지된 문자만 인코딩하여 진행한다면 music=Rock으로 인식될 수 있다. 때문에 &까지 인코딩하여 하나의 덩어리로 만들면 정확하게 값을 추출할 수 있다.

다만 해당 메서드들은 예전에 만들어졌고 최신 명세를 반영하지 못하는 경우가 있으므로 가급적 URL 객체를 사용해 인코딩을 진행하는 것을 추천한다. 예를 들어 IPv6 버전의 경로는 해당 메서드들이 만들어 졌을때 명세에 추가되지 않았기 때문에 제대로 된 인코딩을 진행하지 못한다.

XMLHttpRequest

XMLHttpRequest는 앞에서 살짝 언급이 된 바 있다. fetch 메서드가 ES6에서 소개되기 전에는 XMLHttpRequest 객체를 이용해서 AJAX 통신을 수행했다.

XML 이라는 이름을 가지고 있지만, XML 타입의 데이터 뿐만 아니라 어떤 데이터도 전송이 가능하다. 즉 XMLHttpRequest을 통해서도 파일을 다운로드/업로드 할 수 있으며 관련 진행 상황 역시 추적할 수 있다.

오늘날엔 fetch 메서드로 인해 더 이상 XMLHttpRequest는 잘 사용하지 않는다. 만약 XMLHttpRequest를 사용한다면 옛날 소스코드일 확률이 높다. 그럼에도 불구하고 모던 웹 개발에서 XMLHttpRequest를 사용한다면 크게 다음 3가지 이유를 꼽을 수 있다.

  1. 역사적인 이유 : 하위 호환성을 위해 어쩔 수 없이 사용하는 경우
  2. 구식 브라우저를 지원해야 하는 경우 : 일부 구식 브라우저는 fetch를 지원하지 않기 때문
  3. 아직 fetch가 지원하지 않는 아주 특수한 기능을 사용해야 하는 경우

XMLHttpRequest는 이제 더 이상 잘 사용되지 않지만 간단하게 어떤 식으로 사용했는지를 살펴보고, 3번 관점에서 어떤 특수 기능이 있는지를 알아보도록 하자.

1) 기본 사용법

XMLHttpRequestfetch 메서드와는 달리 사용방법이 조금 복잡하다. 일단 XMLHttpRequest는 비동기와 동기 방식을 모두 지원하는데, 먼저 비동기 방식부터 알아보도록 하자.

  1. 가장 먼저 XMLHttpRequest 객체를 생성해야 한다.
let xhr = new XMLHttpRequest();
  1. XMLHttpRequest 객체를 생성한 후 초기화를 수행한다. open() 메서드를 사용해서 관련 옵션을 설정한다. 이름은 open 이지만 이는 아직 요청을 보내는 단계가 아니라는 점에 주의해야 한다.
xhr.open(method, URL, [async, user, password]);
  • method : HTTP 메소드로 보통 GET/POST
  • URL : URL 경로로 URL 객체도 사용 가능
  • async : false인 경우 동기, 기본값은 true
  • user, password : HTTP auth가 필요한 경우 로그인 관련 정보 (잘 사용하지 않음)
  1. 요청을 전송한다. send() 메서드를 이용한다. POST 메서드인 경우 별도로 body를 지정해 전송할 수 있다.
xhr.send([body]);
  1. xhr 이벤트를 사용해 응답을 수신한다. 다음과 같이 3가지의 이벤트를 자주 사용한다.
  • load : 요청이 완료되어 응답이 완전히 다운로드 된 경우 발생 (400이나 500과 같은 통신 에러가 나도 발생)
  • error : 요청이 정상적으로 처리되지 않은 경우 발생. 네트워크 에러나 유효하지 않은 URL인 경우
  • progress : 응답이 다운로드 되는 와중에 주기적으로 발생
xhr.onload = function() {
  console.log(`Loaded: ${xhr.status} ${xhr.response}`);
}

xhr.onerror = function() {
  console.log(`Network error`);
}

xhr.onprogress = function(evnet) {
  // event.loaded : 얼마나 다운로드 받았는지 (실시간 변경)
  // event.lengthComputable : 서버에서 Content-Length 헤더를 보낸 경우 true
  // event.total : lengthComputable이 true인 경우 응답의 총 크기
  console.log(`Received ${event.loaded} of ${event.total}`);
}

이처럼 이벤트 핸들러 방식으로 네트워크 요청을 다루고 있는 것을 볼 수 있다. fetch 메서드에 비해 관련 처리 작업이 조금 불편한 것을 체감할 수 있다. 다음은 실제로 어떤 경로에 요청을 보내고 이에 대한 응답을 받아 처리하는 과정을 살펴보자.

let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/example/load');

xhr.send();

xhr.onload = function() {
  if(xhr.status !== 200) {
    alert(`Error ${xhr.status}: ${xhr.statusText}`); 
    // e.g. 404: Not Found
  } else {
    alert(`Done, got ${xhr.response.length} bytes`);
  }
}

xhr.onprogress = function(event) {
  if (event.lengthComputable) {
    alert(`Received ${event.loaded} of ${event.total} bytes`);
  } else {
    alert(`Received ${event.loaded} bytes`); 
    // no Content-Length
  }
}

xhr.onerror = function() {
  alert("Request failed");
}

이때 서버로부터 응답을 받으면 xhr 객체에서 다음과 같은 프로퍼티를 사용해 접근할 수 있다.

  • status : HTTP 통신 상태코드. 특이하게 0을 출력할 때가 있는데 이는 HTTP 통신 관련 에러가 아닌 경우로 중간에 중단된 경우 등을 의미

  • statusText : HTTP 통신 상태 메시지로 코드가 200인 경우 OK, 404인 경우 Not Found, 403인 경우 Forbidden 등의 문자열

  • response : 서버로부터 받은 응답 (오래된 스크립트의 경우 responseText를 사용하기도 함)

또 별도로 타임아웃 프로퍼티를 설정해 줄 수 있다. 주어진 시간 내에 요청이 성공하지 못하면 timeout 이벤트가 발생한다.

xhr.timeout = 10000; // 10초 (10000ms)

2) Response 타입

xhr.responseType 프로퍼티를 사용해 응답 포맷을 설정할 수 있다. 이는 fetch 메서드에서 Content-Type을 지정하는 것과 동일하다.

  • "" : 기본값으로 문자열 형식
  • "text" : 문자열 형식
  • "arraybuffer" : ArrayBuffer 형식
  • "blob" : Blob 형식
  • "document" : XML document 형식
  • "json" : JSON 형식이고 자동으로 파싱됨
let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/example/json');

xhr.responseType = 'json';

xhr.send();

xhr.onload = function () {
  let responseObj = xhr.response;
  alert(responseObj.message);
}

3) Ready states

XMLHttpRequest 객체는 진행 상황에 따라 상태가 변하는데, 이 상태값은 xhr.readyState 프로퍼티로 접근할 수 있다. 명세에 따른 모든 상태값은 다음과 같다.

UNSENT = 0; // 초기 상태값
OPENED = 1; // open 호출 시 상태값
HEADERS_RECEIVED = 2; // 응답헤더를 받았을 때 상태값
LOADING = 3; // 응답을 다운로드 하는 과정의 상태값
DONE = 4; // 요청이 완료된 경우 상태값

XMLHttpRequest 객체를 통한 네트워크 요청은 따라서 0 ➡ 1 ➡ 2 ➡ 3 ➡ 3 ➡ ... ➡ 3 ➡ 4 순서로 진행된다. 이는 readystatechange 이벤트로 추적할 수 있다.

xhr.onreadystatechange = function () {
  if (xhr.readyState === 3) {
    // loading
  }
  if (xhr.readyState === 4) {
    // request finished
  }
};

XMLHttpRequest 객체를 통해 네트워크 요청을 다룰 때 readystatechange 이벤트 리스너를 통해 관리하는 것은 매우 오래된 코드에서 볼 수 있다. 이는 이전에는 load/error/progress와 같은 이벤트가 고안되지 않았기 때문에 이 같은 방식을 사용해 처리했던 것인데, 오늘날 XMLHttpRequest 객체를 사용한다면 load/error/progress 이벤트를 통해 관련 처리를 하는 것이 더 좋다.

4) 요청 중단

fetch 메서드의 경우엔 별도로 AbortController 객체를 통해 요청을 중단할 수 있었지만, XMLHttpRequest의 경우엔 자체 메서드를 지원한다.

xhr.abort();

해당 메서드를 호출하면 abort 이벤트를 발생시킴과 동시에 xhr.status의 값은 0이 된다.

5) 동기 방식 요청

open 메서드에서 async 옵션을 false로 지정하면 동기식 네트워크 요청을 수행한다. 동기식으로 요청을 하게 되면 요청을 보내고 응답을 받아올 때까지 모든 자바스크립트 실행이 블록킹된다.

let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/hello.txt', false);

try {
  xhr.send();
  if (xhr.status != 200) {
    alert(`Error ${xhr.status}: ${xhr.statusText}`);
  } else {
    alert(xhr.response);
  }
} catch(err) { // instead of onerror
  alert("Request failed");
}

만약 응답 본문의 크기가 크다면 이를 완전히 다운로드하기 까지 브라우저의 동작이 멈출 수도 있다. 예를 들어 몇몇 브라우저는 스크롤 기능이나 클릭 기능이 마비될 수 있다. 때문에 사실상 동기 방식 네트워크 요청은 오늘날 거의 사용하지 않는 방식이다.

6) HTTP 헤더

XMLHttpRequest 객체는 커스텀 헤더를 전송하거나 응답으로부터 헤더 정보를 읽는 것을 모두 지원한다. HTTP 헤더를 위한 메서드는 다음과 같이 3가지가 있다.

setRequestHeader(name, value)

요청 헤더를 주어진 name과 상응하는 value로 설정

xhr.setRequestHeader('Content-Type', 'application/json');

이때 오직 브라우저가 관리하는 헤더는 설정할 수 없다. 또한 setRequestHeader 메서드로 설정된 헤더는 다시 되돌릴 수 없다. 헤더가 한 번 설정되고 나면 이를 지우거나 덮어씌우는 것이 불가하다.

xhr.setRequestHeader('X-Auth', '123');
xhr.setRequestHeader('X-Auth', '456');

// 이 경우 `X-Auth`는 456으로 덮어씌워 지는 것이 아니라
// X-Auth: 123, 456 으로 계속 값이 추가된다.

getResponseHeader(name)

name에 해당하는 응답 헤더를 반환

xhr.getResponseHeader('Content-Type');

getAllResponseHeaders()

Set-CookieSet-Cookie2를 제외한 모든 응답 헤더를 반환

xhr.getAllResponseHeaders();

// Cache-Control: max-age=31536000
// Content-Length: 4260
// Content-Type: image/png
// Date: Sat, 08 Sep 2012 16:53:16 GMT

이때 반환되는 헤더들은 모두 "\r\n" 이스케이프 문자를 통해 구분된다. 따라서 이를 이용해 다음과 같이 키-밸류 쌍으로 헤더를 구조화할 수 있다.

let headers = xhr.getAllResponseHeaders()
	.split('\r\n')
	.reduce((result, current) => {
      let [name, value] = current.split(':');
      result[name] = value;
      return result;
    }, {});

console.log(headers['Content-Type']);
           

7) POST, FormData

fetch 메서드를 통해 POST 요청 때 FormData를 보낼 수 있었던 것 처럼 XMLHttpRequest 객체를 통해서도 FormData 전송이 가능하다.

<form name="person">
  <input name="name" value="John">
  <input name="surname" value="Smith">
</form>

<script>
  let formData = new FormData(document.forms.person);

  formData.append("middle", "Lee");

  let xhr = new XMLHttpRequest();
  xhr.open("POST", "/article/xmlhttprequest/post/user");
  xhr.send(formData);

  xhr.onload = () => alert(xhr.response);
</script>

폼 전송을 통해 요청을 보내는 경우엔 multipart/form-data 타입으로 인코딩이 이루어진다.

FormData 형식 외에도 fetch에서 보낼 수 있었던 대부분의 데이터 형식 역시 모두 요청이 가능하다. JSON 형식의 데이터를 전송하는 경우는 다음과 같다.

let xhr = new XMLHttpRequest();

let json = JSON.stringify({
  name: "John",
  surname: "Smith"
});

xhr.open("POST", '/submit')
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');

xhr.send(json);

8) 업로드 진행절차

fetch 메서드의 경우 다운로드 절차를 추적하는 것은 가능했지만, 업로드의 경우는 추적할 수 없는 한계가 있었다. 이때 XMLHttpRequest에서는 업로드를 추적하는 것이 가능하다고 했었는데 해당 기능을 알아보자.

POST 메서드로 서버에 요청을 보내는 경우엔 어떤 데이터를 서버에 업로드 하는 소요가 발생하고 만약 이를 추적하고 싶다면 fetch 대신 XMLHttpRequest 객체를 이용해야 한다. 위에서 살펴본 xhr.onprogress 이벤트 핸들러도 마찬가지로 오직 다운로드 관련 진행 상황만 추적이 가능하다. 업로드 절차를 추적하기 위해서는 별도의 객체를 사용해야 한다.

XMLHttpRequest는 따로 uplaod 객체 프로퍼티를 지원한다. 해당 객체는 다시 여러 이벤트를 지원하는데 이를 사용해서 업로드 상황을 추적할 수 있다.

xhr.uploadxhr에서 발생하는 이벤트와 유사한 이벤트를 가지고 있다.

  • loadStart : 업로드 시작 시 발생
  • progress : 업로드가 진행되고 있는 도중 주기적으로 발생
  • abort : 업로드가 중단되면 발생
  • error : HTTP 통신 외적인 오류 시 발생
  • load : 업로드가 성공적으로 완료되었을 시 발생
  • timeout : 설정한 업로드 시간이 초과되었을 시 발생
  • loadend : 업로드가 성공이든 에러이든 상관없이 종료되면 발생
xhr.uplaod.onprogress = function(event) {
  alert(`Uploaded: ${event.loaded} of ${event.total} bytes`);
}

xhr.upload.onload = function() {
  alert(`Upload finished successfully`);
}

xhr.upload.onerror = function() {
  alert(`Error during the upload: ${xhr.status}`);
}

역시 실제 경로에 요청을 보내고, 이에 대한 업로드 과정을 추적하는 코드를 살펴보자.

<input type="file" onchange="upload(this.files[0])">

<script>
function upload(file) {
  let xhr = new XMLHttpRequest();

  xhr.upload.onprogress = function(event) {
    console.log(`Uploaded ${event.loaded} of ${event.total}`);
  };

  xhr.onloadend = function() {
    if (xhr.status == 200) {
      console.log("success");
    } else {
      console.log("error " + this.status);
    }
  };

  xhr.open("POST", "/article/xmlhttprequest/post/upload");
  xhr.send(file);
}
</script>

9) 크로스 오리진 요청

XMLHttpRequestfetch 이전에 만들어진 네트워크 관련 객체이지만 크로스 오리진 이슈는 그보다 훨씬 이전에 있었기 때문에 동일하게 크로스 오리진 요청이 가능하다. 관련 정책은 모두 fetch 에서 다룬 내용과 동일하다.

XMLHttpRequest 역시 기본적으로는 자격 증명 정보를 전송하지 않기 때문에, 해당 정보를 같이 서버에 전송하기 위해서는 xhr.withCredentialstrue로 설정해주어야 한다.

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

xhr.open('POST', 'http://anywhere.com/request');
...

error, abort, timeout, load 이벤트들은 서로 상호 배타적이다. 때문에 한 번에 하나의 이벤트만 발생할 수 있다.

References

  1. https://ko.javascript.info/network
  2. https://simsimjae.medium.com/cors%EC%99%80-jsonp%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-aa3ec0456e97
  3. https://yceffort.kr/2020/09/referer-and-referrer-policy#referrer-policy-%EC%84%A4%EC%A0%95%ED%95%98%EB%8A%94-%EC%98%AC%EB%B0%94%EB%A5%B8-%EB%B0%A9%EB%B2%95
profile
개발잘하고싶다

0개의 댓글