본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
자바스크립트를 사용해서 네트워크 요청을 보내고 새로운 정보를 받아오는 작업을 할 수 있다. 네트워크 요청은 주로 다음과 같이 다양한 상황에서 일어날 수 있다.
그런데 이러한 모든 작업을 별도의 페이지 새로고침 없이 현재 페이지에서 수행할 수 있다. 가령 서버로부터 정보를 가져와 테이블을 만들어주는 버튼이 있다고 가정해보자. 이런 버튼을 누를때마다 페이지가 새로고침 된다면 유저 경험에 안 좋은 영향을 끼칠 수 있을 것이다. 그러나 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
를 사용하는 것이 더 편리한 경우가 많다.
fetch()
기본 문법은 다음과 같다.
let promise = fetch(url, [options]);
url
: 접근하고자 하는 url
options
: 선택 매개변수, method
나 header
등을 지정할 수 있음만약 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(); // 반환 실패
응답 헤더는 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}`);
}
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-*
...
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-Type
인 application/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
에서 다룰 수 있는 다양한 옵션과 유스케이스는 다음 챕터에서 살펴보도록 하자.
HTML의 기본 요소 중에는 form
이 있다. 해당 요소는 submit
이벤트를 통해 데이터를 서버로 전송하는 역할을 담당한다. 우리는 폼과 폼 조작 챕터에서 이미 AJAX
통신 없이도 서버로 데이터를 전송하는 방법을 살펴본 바 있다. 이때는 form
의 속성 중 action
에 지정된 경로로 데이터가 전송된다. 이처럼 보통의 경우엔 AJAX
통신을 사용해 폼 전송을 하는 일이 거의 없다. 오늘날엔 주로 JSON
형태로 데이터를 전송하기 때문이다.
그러나 이미지를 업로드 하는 경우에는 폼 전송을 고려해볼 수 있다. 물론 위에서 살펴 보았듯이 이미지는 base64
또는 이진 데이터 형식으로도 서버에 전송할 수 있다. 그러나 폼 요소중에 <input type="file">
과 같이 관련 기능을 브라우저 자체적으로 지원하고 있기 때문에, 폼 전송을 이용하는 것도 좋은 대안이 될 수 있다.
자바스크립트의 FormData
객체를 이용하면 폼 전송을 제어할 수 있다. 해당 객체는 당연히 HTML의 form
데이터를 담게 된다. 다음과 같이 생성자를 통해 해당 객체를 만들어 줄 수 있다.
let formData = new FormData([form]);
생성자에 인수로 form
요소가 전달되면 알아서 폼 요소의 필드값을 모두 캡처하게 된다. fetch
메서드를 설명할 때 보았듯이 body
에 FormData
를 실어 보낼 수가 있는데, 이때 헤더의 Content-Type
은 multipart/form-data
로 인코딩되어 전달된다. 이때 전송되는 데이터를 받아보는 서버 입장에서는 AJAX
통신으로 fetch
를 이용해 FormData
를 보내더라도, 일반 폼으로 전송되는 데이터와 별다른 구분을 두지 않는다.
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>
위 예시처럼 만약 <form>
요소가 존재하는 경우 생성자를 통해 관련 필드 정보를 간단하게 받아올 수 있지만, 별도의 메서드를 통해서도 필드값을 추가 및 수정이 가능하다.
formData.append(name, value)
: 폼 필드를 주어진 name
과 value
쌍을 추가formData.append(name, blob, fileName)
: 폼 필드가 <input type="file">
인 것과 동일하게 주어진 name
과 blob
쌍을 추가. 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}`);
}
앞서 설명했듯이 보통 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>
fetch
메서드의 사용법을 다루면서 이미 Blob
데이터를 활용해 전송하는 경우를 살펴보았다. 캔버스에 유저가 그린 그림을 Blob
형식으로 변경하고 이 자체를 전송하는 방식이었다. 이처럼 fetch
는 Blbo
객체 자체를 서버에 전송할 수 있지만 이를 폼 데이터에 담아서 보내는 것도 가능하다.
보통 폼에 이미지와 같은 파일을 담아 보내면 관련 필드와 함께 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
메서드는 다운로드 진행과정을 추적할 수 있다. 그렇지만 동시에 유의해야 할 점은 업로드 진행과정을 추적하는 것은 아직 불가능 하다는 점이다. 만약 업로드 진행과정 또한 추적하고 싶다면 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
으로 구분하고 있다. 각 스텝 별로 하나씩 살펴보도록 하자.
fetch
메서드를 호출하고 응답을 받는다. 그리고 response.body.getReader()
를 호출해서 Stream API
를 처리할 수 있는 reader
객체를 생성한다.
데이터를 읽기에 앞서 Content-Length
헤더를 통해 응답 전체의 크기를 미리 구한다. 이는 CORS
이슈로 접근이 불가할 수도 있는데 보통의 경우 문제없이 크기를 구할 수 있다.
데이터를 await reader.read()
를 통해 순차적으로 읽는다. 응답 청크를 chunks
배열에 차례대로 삽입한다. 일단 응답을 한 번 소비하고 나면 response.json()
과 같은 다른 메서드를 사용할 수 없기 때문에 해당 배열을 가지고 원하는 응답 본문에 접근할 것이다.
모든 데이터를 읽으면 그 값을 가지고 있는 chunks
배열이 만들어진다. 이 배열을 다시 Uint8Array
형식화 배열로 변환한다. 우리가 필요한 것은 단일화 된 데이터 형태인데, 안타깝게도 chunks
배열을 단번에 Uint8Array
로 변환하기 위한 단일 메서드는 없다. 따라서 반복문을 통해 순회하며 변환작업을 수행하자.
chunksAll
변수에는 변환이 완료된 결과가 들어있다. 이는 아직 문자열이 아닌 바이트 배열이기 때문에 이를 다시 문자열로 변환해주어야 한다. 이는 TextDecoder
를 통해 수행할 수 있다.
최종적으로 TextDecoder
를 통해 변환된 결과를 다시 JSON.parse()
메서드를 통해 JSON
으로 변환하면 원하는 응답 본문에 commits[0].author.login
과 같이 접근할 수 있다.
앞서 설명한 것과 같이 fetch
의 반환 형태는 Promise
객체이다. 자바스크립트는 일반적으로 진행되고 있는 프라미스를 멈출 수 있는 컨셉을 제안하지 않는다. 그렇지만 실무의 영역에서는 진행중인 프라미스 객체를 중단해야 하는 경우가 종종 생기곤 한다.
예를 들어 다량의 URL에서 여러 정보를 fetch
를 통해 가져온다고 가정해보자. 그런데 이때 만약 유저가 도중에 페이지를 떠나버린다면 해당 결과를 받아볼 필요가 없다. 그렇지만 이미 유저에 의해 요청된 fetch
는 프라미스 객체를 반환하고 일련의 작업을 수행하고 있을 수 있다. 이 같은 경우 쓸 데 없는 요청이 서버에 전달되기 때문에 효율성 측면에서 좋지 않다. 때문에 만약 유저의 특정 행동으로 인해 더 이상 프라미스의 진행이 지속될 필요가 없다면 중단하는 기능이 필요하다.
자바스크립트에서는 이러한 목적의 특별한 내장 객체인 AbortController
를 제공한다. 해당 객체를 이용하면 단순히 fetch
뿐만 아니라 다른 비동기 작업 역시 도중에 중단이 가능하다.
AbortController
객체를 만드는 법은 간단하다.
let controller = new AbortController();
만들어진 controller
객체는 매우 단순한 객체이다. 매우 단순하기 때문에 딱 두 개의 값을 가진다.
abort()
라는 단 하나의 내장 메서드를 가지고 있다.signal
이라는 단 하나의 프로퍼티를 가지고 있다.내장 메서드인 abort()
가 호출되면 다음의 과정이 수행된다.
controller.signal
은 abort
이벤트를 발생시킨다.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
객체를 이용하면 보다 편하게 관련 작업 처리가 가능하다.
fetch
메서드를 도중에 중단하고 싶다면 AbortController
객체의 signal
프로퍼티를 fetch
메서드의 옵션으로 전달하면 된다.
let controller = new AbortController();
fetch(url, {
signal: controller.signal
});
이렇게 옵션으로 signal
을 지정해주면 fetch
메서드는 스스로가 어떻게 AbortController
객체와 함께 작업을 수행해야 하는지 파악한다. 즉 fetch
메서드 내부에서 abort
이벤트에 대한 발생 여부를 signal
에서 지속적으로 체크한다. 때문에 controller.abort()
를 호출하게 되면 fetch
는 signal
로 부터 요청을 중단할 것을 받고 요청을 중단하게 된다.
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;
}
}
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은 중단되게 됨