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

KG·2021년 6월 27일
0

모던JS

목록 보기
40/47
post-thumbnail

Intro

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

fetch

자바스크립트를 사용해서 네트워크 요청을 보내고 새로운 정보를 받아오는 작업을 할 수 있다. 네트워크 요청은 주로 다음과 같이 다양한 상황에서 일어날 수 있다.

  • 주문 전송
  • 사용자 정보 읽기
  • 서버에서 최신 정보 가져오기
  • 기타 등등

그런데 이러한 모든 작업을 별도의 페이지 새로고침 없이 현재 페이지에서 수행할 수 있다. 가령 서버로부터 정보를 가져와 테이블을 만들어주는 버튼이 있다고 가정해보자. 이런 버튼을 누를때마다 페이지가 새로고침 된다면 유저 경험에 안 좋은 영향을 끼칠 수 있을 것이다. 그러나 AJAX 기술을 활용하면 데이터를 받아오고, 이 데이터를 기반으로 테이블을 다시 그려주는 작업만 수행하여 현재 페이지를 떠나지 않고도 최신 정보를 반영할 수 있다. 이러한 흐름에서 더 나아가 발전한 형태의 형태가 SPA(Single Page Application)이라고 볼 수 있다. 오늘날 웹 프론트엔드 프레임워크/라이브러리 대부분 최종 빌드의 형태로 SPA를 지원한다. 관련해서 더 궁금하다면 클라이언트 사이드 렌더링 vs 서버 사이드 렌더링 포스트를 읽어보는 것을 추천한다.

이를 흔히 비동기 네트워크 통신으로 이야기 하기도 하는데 정확히는 AJAX(Asynchronous Javascript And XML)라는 용어를 사용한다. AJAX는 서버에서 추가 정보를 비동기적으로 가져올 수 있게 해주는 포괄적인 기술을 나타내는 용어이다. AJAX는 아주 오래전부터 있었던 기술이기 때문에 용어에 XML이 포함되어 있다. 보통 JQuery에서 ajax로 비동기 네트워크 요청을 다룰 수 있기 때문에 AJAX라고 하면 JQuery의 그것을 떠올리는 경우가 많다.

그러나 엄연히 AJAX 용어 자체는 기술을 의미하는 상위 개념의 용어이다. 다만 JQuery는 이를 보다 사용하기 쉽게 라이브러리 자체적으로 구현한 함수를 ajax라고 표현한 것이다.

자바스크립트에서는 XMLThhpRequest(XHR) 객체를 사용해서 이러한 비동기 네트워크 통신이 가능했다. 그러나 해당 객체를 사용한 통신은 사용하기 복잡하고 가독성이 좋지 않다는 단점이 있었다. 때문에 조금 더 모던하고 다재다능한 fetch 메서드가 ES6에서 도입되었다. 몇몇 구식 브라우저는 이를 지원하지 않지만 관련 폴리필이 잘 마련되어 있고, 대부분의 모던 브라우저는 이를 지원하고 있다.

fetch() 는 비동기 통신을 하기 때문에 관련 네트워크 요청은 이전 이벤트 루프 챕터에서 살펴본 웹 브라우저의 네트워크 API가 도맡아 처리한다. 또한 요청이 반환하는 값은 보통 Promise이기 때문에 이들은 마이크로태스크 큐에 적재되어 작업이 처리된다.

보통 프론트엔드 프레임워크에서는 fetch 말고도 axios라는 비동기 네트워크 요청 라이브러리 역시 많이 사용하고 있다. 대부분 fetch와 사용방법이 유사하며, fetch 메서드에서 지원하는 기능 이상을 가지고 있기 때문에 안정적인 프레임워크에서는 axios를 사용하는 것이 더 편리한 경우가 많다.

1) 문법

fetch() 기본 문법은 다음과 같다.

let promise = fetch(url, [options]);
  • url : 접근하고자 하는 url
  • options : 선택 매개변수, methodheader 등을 지정할 수 있음

만약 options에 아무것도 넘기지 않으면 GET 메서드로 진행되어 url로 부터 컨텐츠가 다운로드 된다. 이때 fetch()가 호출되면 브라우저는 네트워크 요청을 해당 url로 보내고 프라미스를 반환한다. 반환되는 프라미스를 사용해서 다음 작업을 수행할 수 있다.

보통 요청에 대한 응답은 대개 두 단계를 거쳐 진행된다.

먼저 서버에서 응답 헤더를 받자마자 fetch 호출 시 반환받은 Promise 객체가 내장 클래스인 Response 인스턴스와 함께 이행(fullfilled) 상태가 된다. 해당 단계는 아직 본문(body)가 도착하기 전이라 원하는 정보에 접근이 불가하다. 그러나 개발자는 응답 헤더를 보고 요청이 성공적으로 처리되었는지 아닌지를 확인할 수 있다.

예를 들어 네트워크 문제로 인한 장애, 또는 존재하지 않는 사이트에 접근하려는 경우처럼 HTTP 요청을 보낼 수 없는 상태에서 프라미스는 거부 상태가 될 것이다. HTTP 상태는 응답 프로퍼티를 사용해 확인할 수 있다.

  • status : HTTP 상태 코드 (eg. 200, 404, 500 ...)
  • ok : Boolean 값으로 HTTP 상태 코드 값이 200-299 사이일 경우에 true
let response = await fetch(url);

if (response.ok) {
  let json = response.json();
} else {
  alert("HTTP-Error : " + response.status);

두 번째 단계에서는 추가 메서드를 사용해 응답 본문을 받을 수 있다. 이 단계에서 원하는 정보에 접근해 파싱을 하는 등의 작업을 수행할 수 있다. 위에서 첫 단계에서 프라미스로 반환받은 response 객체에는 또 다시 프라미스를 기반으로 하는 다양한 메서드가 있다.

  • response.text() : 응답을 읽고 텍스트 형태로 반환
  • response.json() : 응답을 읽고 JSON 형태로 반환
  • response.formData() : 응답을 읽고 FormData 형태로 반환
  • response.blob() : 응답을 읽고 Blob 타입으로 반환
  • response.arrayBuffer() : 응답을 읽고 ArrayBuffer 형태로 반환
  • 이 외에도 response.body가 있는데 이는 메서드는 아니고 자체로 ReadableStream 객체이다. 이를 이용하면 응답 본문을 청크 단위로 일부씩 읽을 수 있다.

FormData에 대한 자세한 내용은 다음 챕터에서 바로 다루어 보도록 하자. Blob은 타입이 있는 바이너리 데이터를 일컫는데, 보통 이미지·사운드·비디오와 같은 멀티미디어 데이터를 다룰 때 사용한다. ArrayBuffer는 메모리를 수동으로 관리하고자 할 때 사용할 수 있는데 바이너리 데이터를 로우 레벨 형식으로 표현한 것이다. 이 둘에 대해서는 다른 챕터에서 더 자세히 살펴보도록 하자.

다른 챕터에서도 살펴보았지만, 추가 메서드를 사용해 깃허브로부터 커밋 내역에 접근하는 코드를 한 번 살펴보자.

/* async/await을 사용한 프라미스 접근 */
let url = 'https://api.github.com/repos/javascript-tutorial/ko.javascript.info/commits';
let response = await fetch(url);

// 깃허브 경로에 대한 응답을 JSON 형태로 반환
let commits = await response.json();

console.log(commits[0].author.login);

/* then을 사용한 프라미스 접근 */
fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits')
  .then(response => response.json())
  .then(commits => console.log(commits[0].author.login));

응답을 텍스트 형태로 받아보려면 response.text()를 사용할 수 있다. 내용은 동일하지만 문자열 형태로 데이터가 반환되는 것을 확인할 수 있다.

let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

// 깃허브 경로에 대한 응답을 text 형태로 반환
let text = await response.text();

console.log(text);

이번에는 Blob 데이터를 다루는 예시를 살펴보자. fetch 명세서 사이트 상단 우측에 있는 로고 이미지(바이너리 데이터)를 가져오는 예시이다.

let response = await fetch('/article/fetch/logo-fetch.svg');

// 응답을 Blob 객체 형태로 반환
let blob = await response.blob();

// img 요소를 생성하고 document에 추가
let img = document.createElement('img');
img.style = 'position: fixed; top: 10px; left: 10px; width: 100px';
document.body.append(img);

// Blob 객체를 이미지로 변환 후 경로 지정
img.src = URL.createObjectURL(blob);

// 3초간 출력 후 제거
setTimeout(() => {
  img.remove();
  URL.revokeObjectURL(img.src);
}, 3000);

이때 본문을 읽을 때 사용되는 메서드는 한 시점에서 딱 하나만 사용할 수 있다. response.text()를 사용해 응답을 얻었다면 본문의 컨텐츠는 모두 처리가 된 상태이기 때문에, 추가적으로 response.json()과 같은 다른 메서드를 통해 작업을 진행할 수 없다.

let text = await response.text();	// 정상 반환
let json = await response.json();	// 반환 실패

2) 응답 헤더

응답 헤더는 response.headers에 맵(Map)과 유사한 형태로 저장된다. 유사한 형태일 뿐 맵은 아니지만 맵과 유사한 메서드를 지원하기 때문에 맵 처럼 헤더의 일부만 추출하거, 헤더 전체를 for ... of 로 순회할 수 있다.

let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

// 헤더 일부를 추출
console.log(response.headers.get('Content-Type'));	// application/json; charset=utf-8

for (let [key, value] of response.headers) {
  console.log(`${key} = ${value}`);
}

3) 요청 헤더

fetch()를 호출할 때 추가적으로 headers 옵션을 사용하면 요청을 보낼 때 헤더를 설정할 수 있다. HTTP 요청 헤더에서 지원하는 다양한 헤더값을 직접 설정하고 요청을 보낼 수 있다.

let response = fetch(protectedURL, {
  headers: {
    Authentication: 'secret',
  }
});

이때 headers를 사용해 설정할 수 없는 헤더도 존재한다. 예를 들어 Sec-로 시작하는 헤더이름은 fetch와 같은 API를 사용할 때 안전하게 새로운 헤더를 생성할 수 있도록 예약되어 있는 키워드기에 개발자가 설정할 수 없다. 이러한 제약은 HTTP를 목적에 맞고 안전하게 사용할 수 있도록 하기 위해 만들어졌다. 금지 목록에 있는 헤더는 오직 브라우저만이 전적으로 권한을 가지고 있다. 금지된 헤더 목록은 다음과 같다. 전체 목록은 다음 링크에서 확인할 수 있다.

  • Accept-Charset, Accept-Encoding
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Connection
  • Content-Length
  • Cookie, Cookie2
  • Host
  • Keep-Alive
  • Origin
  • Referer
  • Proxy-*
  • Sec-*
  • ...

4) POST 요청

GET 이외의 요청을 보내려면 추가 옵션을 기입해야 한다.

  • method : HTTP 메서드 (POST 외에도 PUT, DELETE 등...)
  • body : 요청 본문으로 POST일 때 사용하며 다음 중 하나
    • 문자열 : 순수 문자열 또는 JSON 문자열
    • FormData객체 : form/multipart 형태로 데이터 전송
    • Blob이나 BufferSource : 바이너리 데이터 전송
    • URLSearchParams : x-www-form-urlencoded 형태로 전송 (요즘엔 잘 사용하지 않는다. 관련 예시는 해당 포스트에서 좀 더 자세히 확인할 수 있다)

대부분의 body는 오늘날 JSON 형태로 실어 보내는 것을 선호한다. 다음은 자바스크립트로 user 객체를 만들고, 이를 본문에 실어 서버로 전송하는 예시이다.

let user = {
  name: 'Jonh',
  surname: 'Smith',
}

let response = await fetch('/article/fetch/post/user', {
  method: 'POST',
  headers: {
    'Content-Type' : 'application/json; charset=utf-8',
  },
  body: JSON.stringify(user)
});

POST 요청을 보낼 때 주의할 점은 요청 본문이 문자열이면 Content-Type 헤더가 text/plain; charset=UTF-8로 기본 설정된다는 점이다. 하지만 위 예시에서는 JSON을 전송하고 있기 때문에 headers에 제대로 된 Content-Typeapplication/json을 명시해주어야 한다.

앞서 언급한 axios에서는 이러한 부분과 관련해 편의성이 많이 개선되었다. 때문에 axios에서는 별도로 application/json을 명시하지 않더라도 JSON 타입의 데이터를 전송할 수 있다.

잘 사용하는 경우는 아니지만 Blob 객체를 이용해 바이너리 데이터를 전송하는 예시를 살펴보자. canvas를 이용해 사용자가 그린 그림을 전송 버튼을 통해 서버로 전송하는 코드이다.

canvasElem.onmousemove = function (e) {
  // 마우스 움직임에 따라 캔버스 위에서 그림을 그리는 핸들러
  let ctx = canvasElem.getContext('2d');
  ctx.lineTo(e.clientX, e.clientY);
  ctx.stroke();
};

async function submit () {
  // 캔버스에 그려진 SVG를 png 이미지 파일 형식의 바이너리 데이터로 전환
  let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
  let response = await fetch('/article/fetch/post/image', {
    method: 'POST',
    body: blob,
  });
  
  // 전송이 잘 되었다는 응답이 오고 이미지 사이즈가 얼럿창에 출력
  let result = await response.json();
  alert(result.message);
}

이번엔 JSON 문자열때와 달리 따로 Content-Type을 명시하지 않았다는 점에 주의하자. Blob 객체는 내장 타입을 갖기 때문에 특별히 Content-Type을 설정하지 않아도 상관없다. 위에서는 이미지를 전송하기 때문에 toBlob 메서드에 의해 image/png가 자동으로 설정되었다. 이렇게 Blob 객체의 경우 해당 객체의 타입이 Content-Type 헤더의 값이 된다.

async/await을 사용하지 않고 프라미스 체이닝을 이용해 작성한 코드는 다음과 같다.

function submit() {
  canvasElem.toBlob(function(blob) {
    fetch('/article/fetch/post/image', {
      method: 'POST',
      body: blob,
    }).then(response => response.json())
      .then(result => alert(JSON.stringify(result, null, 2)))
  }, 'image/png');
}

이 외에도 fetch 에서 다룰 수 있는 다양한 옵션과 유스케이스는 다음 챕터에서 살펴보도록 하자.

FormData

HTML의 기본 요소 중에는 form이 있다. 해당 요소는 submit 이벤트를 통해 데이터를 서버로 전송하는 역할을 담당한다. 우리는 폼과 폼 조작 챕터에서 이미 AJAX 통신 없이도 서버로 데이터를 전송하는 방법을 살펴본 바 있다. 이때는 form의 속성 중 action에 지정된 경로로 데이터가 전송된다. 이처럼 보통의 경우엔 AJAX 통신을 사용해 폼 전송을 하는 일이 거의 없다. 오늘날엔 주로 JSON 형태로 데이터를 전송하기 때문이다.

그러나 이미지를 업로드 하는 경우에는 폼 전송을 고려해볼 수 있다. 물론 위에서 살펴 보았듯이 이미지는 base64 또는 이진 데이터 형식으로도 서버에 전송할 수 있다. 그러나 폼 요소중에 <input type="file">과 같이 관련 기능을 브라우저 자체적으로 지원하고 있기 때문에, 폼 전송을 이용하는 것도 좋은 대안이 될 수 있다.

자바스크립트의 FormData 객체를 이용하면 폼 전송을 제어할 수 있다. 해당 객체는 당연히 HTML의 form 데이터를 담게 된다. 다음과 같이 생성자를 통해 해당 객체를 만들어 줄 수 있다.

let formData = new FormData([form]);

생성자에 인수로 form 요소가 전달되면 알아서 폼 요소의 필드값을 모두 캡처하게 된다. fetch 메서드를 설명할 때 보았듯이 bodyFormData를 실어 보낼 수가 있는데, 이때 헤더의 Content-Typemultipart/form-data로 인코딩되어 전달된다. 이때 전송되는 데이터를 받아보는 서버 입장에서는 AJAX 통신으로 fetch를 이용해 FormData를 보내더라도, 일반 폼으로 전송되는 데이터와 별다른 구분을 두지 않는다.

1) 간단한 폼 전송 예제

fetch를 사용해서 폼을 전송하는 코드를 살펴보자. HTML에서는 submit 타입을 통해 해당 이벤트가 일어날 때 서버로 데이터가 전송되고 페이지가 새로고침 되지만, fetch를 사용해서 보낼 때는 이러한 기본 동작을 방지하기 위해 e.preventDefault()를 호출한다. 그리고 전송은 오롯이 fetch 메서드가 담당하게 된다.

<form id="formElem">
  <input type="text" name="name" value="John">
  <input type="text" name="surname" value="Smith">
  <input type="submit">
</form>

<script>
  formElem.onsubmit = async (e) => {
    e.preventDefault();
  
    let response = await fetch('/article/formdata/post/user', {
      method: 'POST',
      body: new FormData(formElem),
    });
  
    let result = await response.json();
  
    alert(result.message);
</script>

2) FormData 메서드

위 예시처럼 만약 <form> 요소가 존재하는 경우 생성자를 통해 관련 필드 정보를 간단하게 받아올 수 있지만, 별도의 메서드를 통해서도 필드값을 추가 및 수정이 가능하다.

  • formData.append(name, value) : 폼 필드를 주어진 namevalue 쌍을 추가
  • formData.append(name, blob, fileName) : 폼 필드가 <input type="file">인 것과 동일하게 주어진 nameblob 쌍을 추가. fileName은 사용자의 파일 시스템에 있는 파일 자체의 이름을 의미
  • formData.delete(name) : 주어진 name에 해당하는 필드 삭제
  • formData.get(name) : 주어진 name에 해당하는 필드 값 읽기
  • formData.has(name) : 주어진 name을 가진 필드가 있다면 true, 없다면 false

form은 문법적으로 동일한 name으로 여러개의 필드를 가지고 있더라도 상관이 없는데, formData.append() 메서드를 통해 같은 키(= name)를 가진 값을 여러 개 넣을 수 있다. 마치 배열에 추가하듯 값은 덮어씌워지지 않고 계속해서 추가된다.

메서드 중에는 formData.set 도 있는데 대부분의 동작이 append와 동일하지만 유일한 차이점이 있다. 바로 set 메서드는 기존의 값을 덮어쓴다는 점이다. 따라서 어떤 name을 가진 필드가 유일한 값을 가지는 경우가 보장된다면 set 메서드를 써도 무방하다. 그 외 기능과 사용법은 모두 append 메서드와 동일하다.

  • formData.set(name, value)
  • formData.set(name, blob, fileName)

이때 formData에 추가하는 값은 모두 문자열로 자동 변환되어 삽입된다. 다만 객체는 무시되므로 주의해야 한다.

formData는 키-값의 쌍을 가진 구조를 띄고 있기 때문에 for...of를 사용해 순회가 가능하다.

let formData = new FormData();
formData.append('key1', 'value1');
formData.append('key2', 'value2');

for(let [name, value] of formData) {
  console.log(`${name} = ${value}`);
}

3) 파일과 함께 폼 전송

앞서 설명했듯이 보통 AJAX 통신을 사용해 폼 데이터를 전송하는 경우는 많지 않다. 그러나 이미지와 같은 파일 타입을 전송하는 경우는 폼 전송을 고려해봄직 하다. 폼 전송은 항상 Content-Type: multipart/form-data로 인코딩이 이뤄지는데, 해당 타입은 파일 전송도 허용한다. 만약 HTML 코드 자체적으로 <input type="file">이 선언되어 있다면 이 역시 생성자를 통해 자동으로 캡쳐되어 fetch 메서드를 통해 서버로 전송이 가능하다.

<form id="formElem">
  <input type="text" name="firstName" value="John">
  Picture: <input type="file" name="picture" accept="image/*">
  <input type="submit">
</form>

<script>
  formElem.onsubmit = async (e) => {
    e.preventDafault();
  
    let response = await fetch('/article/formdata/post/user-avater', {
      method: 'POST',
      body: new FormData(formElem),
    });
  
    let result = await response.json();
  
    alert(result.message);
  };
</script>

4) Blob을 이용한 폼 전송

fetch 메서드의 사용법을 다루면서 이미 Blob 데이터를 활용해 전송하는 경우를 살펴보았다. 캔버스에 유저가 그린 그림을 Blob 형식으로 변경하고 이 자체를 전송하는 방식이었다. 이처럼 fetchBlbo 객체 자체를 서버에 전송할 수 있지만 이를 폼 데이터에 담아서 보내는 것도 가능하다.

보통 폼에 이미지와 같은 파일을 담아 보내면 관련 필드와 함께 name과 같은 추가적인 메타데이터에 접근할 수 있기 때문에 보다 편리하다. 또한 서버의 입장에서도 원시 데이터(raw data)인 이진 데이터의 형태보다 폼 데이터 타입인 multipart/form-data를 해석하는 것이 용이하다. 위에서 살펴본 Blob 객체 자체를 전송하는 fetch 메서드를 FormData 객체로 바꾸어 구현해보자.

<body style="margin:0">
  <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>

  <input type="button" value="Submit" onclick="submit()">
  
  <script>
    canvasElem.onmousemove = function (e) {
      let ctx = canvasElem.getContext('2d');
      ctx.lineTo(e.clientX, e.clientY);
      ctx.stroke();
    };
    
    async function submit() {
      let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
    
      let formData = new FormData();
      formData.append('firstName', 'John');
      formData.append('image', imageBlob, 'image.png');
    
      let response = await fetch('/article/formdata/post/image-form', {
        method: 'POST',
        body: formData,
      });
    
      let result = await response.json();
      alert(result.message);
  </script>
  
</body>

변환된 Blob 객체를 FormData에 추가하는 부분을 유의하도록 하자. 3번째 인수는 유저 파일 시스템에 존재하는 imageBlob의 파일 이름을 지정하고 있다. 이처럼 append 메서드를 통해 직접적인 조작도 가능하지만, 만약 HTML 문서 내부에 <input type="file">과 같은 필드가 선언되어 있다면 이러한 동작이 내부적으로 자동 변환된다는 점을 잘 알아두자.

fetch: Download progress

fetch 메서드는 다운로드 진행과정을 추적할 수 있다. 그렇지만 동시에 유의해야 할 점은 업로드 진행과정을 추적하는 것은 아직 불가능 하다는 점이다. 만약 업로드 진행과정 또한 추적하고 싶다면 XMLHttpRequest 객체를 이용해야 한다. 관련해서는 추후 챕터에서 다뤄보도록 하자.

앞서 response.body를 살펴볼 때 이는 메서드가 아닌 ReadableStream 객체라는 것을 언급했었다. 해당 객체는 청크 단위로 도착하는대로 본문을 반환하는 역할을 수행할 수 있다. 청크란 쉽게 말해 어떤 내용의 일부분이라고 볼 수 있다. 즉 실시간 처리와 같이 다운받는 과정이 계속 전달되어 온다고 볼 수 있다.

때문에 response.text() 또는 response.json()와 같은 메서드와는 달리 response.body는 읽기 프로세스에 대해 전적인 권한을 가지고 있다. 그리고 개발자는 어느 시점에서든 얼마나 다운로드가 진행되었는지 카운트가 가능하다.

다음 코드를 통해 전반적인 response.body의 작동 과정을 들여다보자.

// ReadableStream 객체의 내장 메서드 getReader() 호출
const reader = response.body.getReader();

// 무한 반복문을 돌면서 reader 객체로부터 계속 내용을 읽어옴
while(true) {
  // 마지막 청크가 도착할 때 done = true
  // value는 청크 바이트의 Uint8Array 형태
  const { done, value } = await reader.read();
  
  if (done) {
    break;
  }
  
  console.log(`수신 : ${value.length} bytes`);
}

Uint8Array 타입은 나중에 자세히 다룰 예정이다. 간단하게만 짚고 넘어가면 ArrayBuffer 객체의 일종으로 원시(raw) 이진 데이터에 액세스하기 위한 메커니즘을 제공해 배열 최적화를 수행하는데 기여한다.

await reader.read() 의 호출 결과는 두개의 프로퍼티를 가지고 있는 객체를 반환하는데, 각각의 프로퍼티는 위 코드에서 쓰이고 있는 것과 같다.

  • done : 읽기가 끝난 경우 true, 아니면 false
  • value : 바이트를 나타내는 형식화 배열(Uint8Array)

ReadableStream 객체는 Stream API에 명시되어 있는데, 해당 API는 명세에 따르면 비동기 반복자가 가능하다. 따라서 무한 반복문 말고 for await ... of 반복문을 사용할 수 있다. 그렇지만 이는 아직 모든 브라우저에서 지원되지 않기에 보통 while을 이용한 무한 루프를 사용한다.

조금 더 복잡한 예시를 살펴보며 다운로드 진행과정을 추적하는 내부 매커니즘을 파악해보자.

// Step 1 : fetch 응답 수신 후 reader 객체 생성
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100');

const reader = response.body.getReader();

// Step 2 : 헤더정보를 통해 전체 크기 구하기
const contentLength = +response.header.get('Content-Length');

// Step 3 : 청크 단위로 데이터 읽기
let receivedLength = 0;
let chunks = [];

while(true) {
  const { done, value } = await reader.read();
  
  if (done) {
    break;
  }
  
  chunks.push(value);
  receivedLength += value.length;
  
  console.log(`수신: ${receivedLength} of ${contentLength}`);
}

// Step 4 : chunks 배열을 단일 Uint8Array 형태로 변환
let chunksAll = new Uint8Array(receivedLength);
let position = 0;

for(let chunk of chunks) {
  chunksAll.set(chunk, position);
  position += chunk.length;
}

// Step 5 : 문자열로 디코딩
let result = new TextDecoder('utf-8').decode(chunksAll);

// Step 6 : 결과 출력
let commits = JSON.parse(result);
console.log(commits[0].author.login);

각 과정을 Step 으로 구분하고 있다. 각 스텝 별로 하나씩 살펴보도록 하자.

  1. fetch 메서드를 호출하고 응답을 받는다. 그리고 response.body.getReader()를 호출해서 Stream API를 처리할 수 있는 reader 객체를 생성한다.

  2. 데이터를 읽기에 앞서 Content-Length 헤더를 통해 응답 전체의 크기를 미리 구한다. 이는 CORS 이슈로 접근이 불가할 수도 있는데 보통의 경우 문제없이 크기를 구할 수 있다.

  3. 데이터를 await reader.read()를 통해 순차적으로 읽는다. 응답 청크를 chunks 배열에 차례대로 삽입한다. 일단 응답을 한 번 소비하고 나면 response.json()과 같은 다른 메서드를 사용할 수 없기 때문에 해당 배열을 가지고 원하는 응답 본문에 접근할 것이다.

  4. 모든 데이터를 읽으면 그 값을 가지고 있는 chunks 배열이 만들어진다. 이 배열을 다시 Uint8Array 형식화 배열로 변환한다. 우리가 필요한 것은 단일화 된 데이터 형태인데, 안타깝게도 chunks 배열을 단번에 Uint8Array로 변환하기 위한 단일 메서드는 없다. 따라서 반복문을 통해 순회하며 변환작업을 수행하자.

  5. chunksAll 변수에는 변환이 완료된 결과가 들어있다. 이는 아직 문자열이 아닌 바이트 배열이기 때문에 이를 다시 문자열로 변환해주어야 한다. 이는 TextDecoder를 통해 수행할 수 있다.

  6. 최종적으로 TextDecoder를 통해 변환된 결과를 다시 JSON.parse() 메서드를 통해 JSON으로 변환하면 원하는 응답 본문에 commits[0].author.login과 같이 접근할 수 있다.

fetch: Abort

앞서 설명한 것과 같이 fetch의 반환 형태는 Promise 객체이다. 자바스크립트는 일반적으로 진행되고 있는 프라미스를 멈출 수 있는 컨셉을 제안하지 않는다. 그렇지만 실무의 영역에서는 진행중인 프라미스 객체를 중단해야 하는 경우가 종종 생기곤 한다.

예를 들어 다량의 URL에서 여러 정보를 fetch를 통해 가져온다고 가정해보자. 그런데 이때 만약 유저가 도중에 페이지를 떠나버린다면 해당 결과를 받아볼 필요가 없다. 그렇지만 이미 유저에 의해 요청된 fetch는 프라미스 객체를 반환하고 일련의 작업을 수행하고 있을 수 있다. 이 같은 경우 쓸 데 없는 요청이 서버에 전달되기 때문에 효율성 측면에서 좋지 않다. 때문에 만약 유저의 특정 행동으로 인해 더 이상 프라미스의 진행이 지속될 필요가 없다면 중단하는 기능이 필요하다.

자바스크립트에서는 이러한 목적의 특별한 내장 객체인 AbortController를 제공한다. 해당 객체를 이용하면 단순히 fetch 뿐만 아니라 다른 비동기 작업 역시 도중에 중단이 가능하다.

1) AbortController 객체

AbortController 객체를 만드는 법은 간단하다.

let controller = new AbortController();

만들어진 controller 객체는 매우 단순한 객체이다. 매우 단순하기 때문에 딱 두 개의 값을 가진다.

  • abort() 라는 단 하나의 내장 메서드를 가지고 있다.
  • signal 이라는 단 하나의 프로퍼티를 가지고 있다.

내장 메서드인 abort()가 호출되면 다음의 과정이 수행된다.

  • controller.signalabort 이벤트를 발생시킨다.
  • 그 이후 controller.signal.aborted 프로퍼티의 값이 true가 된다.

일반적으로 이러한 절차는 다음과 같이 두 부분으로 나눌 수 있다.

  • 취소 가능한 작업을 수행하는 쪽에서 controller.signal에 이벤트 리스너를 설정
  • 취소가 되는 쪽에서는 필요시 controller.abort()를 호출

이 컨셉만 가지고 fetch는 아직 사용하지 않은 채 AbortController 객체를 활용한 작업 중단 과정을 살펴보자.

let controller = new AbortController();
let signal = controller.signal;

// 취소 가능한 작업을 수행하는 쪽에서
// signal 객체를 얻고 abort 이벤트가 발생할 때 
// 수행할 작업을 처리할 이벤트를 등록
signal.addEventListener('abort', () => alert('abort'));

// 취소하는 쪽에서 abort() 메서드 호출
controller.abort();

// abort() 메서드가 호출된 후 해당 값은 true가 됨
console.log(signal.aborted);

위 코드에서 알 수 있듯이 AbortController는 단지 abort() 메서드가 호출되었을 때 abort 이벤트를 전달하기 위한 수단일 뿐이다. 때문에 굳이 AbortController 객체를 사용하지 않고서도 동일한 종류의 이벤트 등록을 통해 해당 작업을 수행할 수 있다. 그러나 fetch 메서드를 사용함에 있어 AbortController 객체를 이용하면 보다 편하게 관련 작업 처리가 가능하다.

2) fetch 메서드와 AbortController

fetch 메서드를 도중에 중단하고 싶다면 AbortController 객체의 signal 프로퍼티를 fetch 메서드의 옵션으로 전달하면 된다.

let controller = new AbortController();

fetch(url, {
  signal: controller.signal
});

이렇게 옵션으로 signal을 지정해주면 fetch 메서드는 스스로가 어떻게 AbortController 객체와 함께 작업을 수행해야 하는지 파악한다. 즉 fetch 메서드 내부에서 abort 이벤트에 대한 발생 여부를 signal 에서 지속적으로 체크한다. 때문에 controller.abort()를 호출하게 되면 fetchsignal로 부터 요청을 중단할 것을 받고 요청을 중단하게 된다.

fetch가 중단되게 되면 프라미스는 AbortError 라는 에러 객체로 거부(reject) 처리를 수행한다. 때문에 우리는 try...catch 블록을 사용해서 해당 에러를 감지하고 처리할 수 있다.

let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('/article/fetch-abort/demo/hang', {
    signal: controller.signal,
  });
} catch(err) {
  if (err.name === 'AbortError') {
    alert('Aborted!');
  } else {
    throw err;
  }
}

3) 확장가능한 AbortController

AbortController 객체는 확장성이 뛰어나다. 해당 객체를 이용하면 다량의 fetch에서 발생하는 요청 역시 단번에 모두 중단시킬 수 있다. 앞서 비동기 처리에서 Promise.all 메서드를 통해 동시에 여러 개의 비동기 요청을 발생시키는 예시를 살펴보았다. 해당 메서드를 사용할 때도 AbortController 객체를 사용해서 모든 요청을 즉시 중단시켜 보자.

// 병렬적으로 fetch 하기 위한 url 경로 배열
let urls = [ ... ];

let controller = new AbortController();

// 각각의 fetch 응답 프라미스를 갖고 있는 배열
let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal,
}));

// 모든 프라미스를 동시 요청
let results = await Promise.all(fetchJobs);

위와 같은 코드가 있을 때 코드 내 어디에서라도 controller.abort() 메서드를 호출하게 되면 모든 fetch 요청이 중단된다. 이는 우리가 단 하나의 AbortController 객체를 사용해 signal을 등록했기 때문이다.

앞서 언급했던 것과 같이 fetch 메서드가 아니더라도 다른 비동기 작업 역시 AbortController 객체를 이용해 도중에 중단시킬 수 있다.

let urls = [ ... ];

let controller = new AbortController();

// 또 다른 비동기 작업 정의
// addEventListener를 통해 abort 이벤트 핸들러 등록
let ourJob = new Promise((resolve, reject) => {
  ...
  controller.signal.addEventListener('abort', reject);
});

let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal,
}));

let results = await Promise.all([...fetchJobs, ourJob]);

// 코드 내 어디에서라도 controller.abort() 메서드가 호출되면
// 모든 fetch 요청과 ourJob은 중단되게 됨

References

  1. https://ko.javascript.info/network
  2. https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
  3. https://fetch.spec.whatwg.org/#forbidden-header-name
profile
개발잘하고싶다

0개의 댓글