비동기 데이터는 어떻게 소비될까?
우리는 일반적으로 서버의 데이터를 다음과 같이 가져온다.
const response = await fetch('/api/users');
const data = await response.json();
이 코드만 보면 데이터 요청 과정은 단순해 보인다.
요청 → 응답 → 데이터 사용
하지만 실제로는 몇 가지 질문이 생긴다.
fetch가 끝났다는 것은 데이터도 전부 받은 것일까?
데이터가 아주 크다면 전부 받을 때까지 기다려야 할까?
데이터가 조금씩 도착한다면 도착한 데이터부터 처리할 수 있을까?
더 이상 필요하지 않은 요청은 어떻게 중단할까?
여기서 소비한다는 것은 데이터를 읽어서 계산하거나 화면에 표시하는 등 실제로 사용하는 것을 말한다.
이 글에서는 서버 응답을 예로 들어 전체 본문을 읽은 뒤 결과를 사용하는 방법과 도착한 부분부터 읽어 사용하는 방법을 비교한다. 그리고 더 이상 필요하지 않은 요청이나 본문 읽기를 중단하는 방법까지 살펴본다.
다룰 범위는 다음 네 가지다.
| 개념 | 데이터 소비에서 확인할 내용 |
|---|---|
fetch | 응답을 얻는 시점과 본문을 읽는 시점의 차이 |
ReadableStream | 도착한 데이터를 chunk 단위로 읽는 방법 |
| Async Iterable | 다음 값을 기다리며 반복해서 소비하는 방법 |
AbortSignal | 필요 없어진 요청이나 본문 읽기에 중단을 전달하는 방법 |
Promise와 async/await의 기본 사용법을 알고 있다는 전제로 설명한다. fetch와 ReadableStream은 실행 환경이 제공하는 Web API이고, await와 for await...of는 그 API가 제공하는 비동기 결과를 기다리고 소비할 때 사용하는 JavaScript 문법이다.
1. fetch()가 끝나면 데이터를 모두 받은 걸까?
먼저 일반적으로 사용하는 fetch를 살펴보자.
const response = await fetch('/api/users');
fetch()는 Promise<Response>를 반환한다.
여기서 중요한 점은 await fetch()가 끝났다고 해서 응답 본문을 전부 읽은 것은 아니라는 것이다.
브라우저가 서버로부터 응답의 상태 코드와 헤더를 받으면 fetch()의 Promise는 Response 객체로 이행될 수 있으며, 이 시점에는 응답 본문이 아직 전부 도착하지 않았을 수도 있다.
그래서 다음 두 await은 기다리는 대상이 다르다.
const response = await fetch('/api/users');
const data = await response.json();
첫 번째 await
await fetch() → Response를 기다림
두 번째 await
await response.json() → Response Body를 끝까지 읽기 → JSON 파싱 결과 기다리기
response.json()은 응답 스트림을 끝까지 읽은 뒤 JSON을 파싱한다.
핵심
fetch 완료 ≠ 응답 Body 전체 소비 완료
2. 우리가 평소 사용하는 response.json()
일반적인 JSON API 응답은 다음과 같이 사용한다.
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('요청에 실패했습니다.');
}
const data = await response.json();
json() 외에도 Response는 응답 본문을 여러 형태로 읽을 수 있는 API를 제공한다.
| 메서드 | 읽어서 얻는 결과 |
|---|---|
json() | JSON을 파싱한 자바스크립트 값 |
text() | 문자열 |
blob() | 이미지·파일 등을 다루는 Blob |
arrayBuffer() | 바이너리 데이터를 다루는 ArrayBuffer |
위 메서드 중 필요한 결과 형태에 맞는 하나를 선택한다. 같은 응답 본문은 한 번 읽으면 다시 읽을 수 없다. 예를 들어 response.json()으로 읽은 뒤 같은 응답에 response.text()를 호출하면 실패한다. 읽어서 변수에 저장한 결과는 계속 사용할 수 있다.
예를 들어 JSON 응답이라면:
이 방식은 일반적인 API 응답을 처리하기에 편리하다.
하지만 중요한 특징이 하나 있다.
응답 본문을 끝까지 읽어야 결과를 사용할 수 있다는 것이다.
const data = await response.json();
// 여기부터 전체 JSON을 사용할 수 있음
console.log(data);
작은 API 응답에서는 문제가 되지 않지만, 데이터가 매우 크거나 데이터를 점진적으로 보여줘야 하는 경우에는 다른 방식이 필요할 수 있다.
3. Response.body와 ReadableStream
Response는 서버 응답을 다룰 수 있게 해주는 객체다. 응답의 상태와 헤더를 확인할 수 있고, body를 통해 실제 응답 내용을 읽을 수 있다.
const response = await fetch('/api/users');
response.status; // 응답 상태: 예를 들어 200
response.headers; // 응답 정보: 예를 들어 데이터 형식
response.body; // 실제 응답 내용을 읽는 통로
서버가 {"name":"민수"}라는 JSON을 보냈다고 생각해보자. response.body에는 파싱된 { name: "민수" } 객체가 들어 있는 것이 아니다. 그 내용을 바이트 데이터로 읽을 수 있는 ReadableStream이 들어 있다.
ReadableStream은 데이터를 차례로 읽을 수 있는 통로를 제공하는 객체라고 이해하면 된다.
Response
├─ status → 응답 상태 코드
├─ headers → 데이터 형식 등의 응답 정보
└─ body → 실제 내용을 읽는 통로: ReadableStream
fetch()로 얻은 response.body는 ReadableStream<Uint8Array> 또는 null이다. 여기서 Uint8Array는 읽어서 얻는 바이트 데이터의 형태다. 본문이 없는 응답에서는 body가 null일 수 있으므로 직접 읽기 전에 확인해야 한다.
본문을 읽는 두 가지 방법
① json()에 읽기를 맡기기
const data = await response.json();
console.log(data.name); // "민수"
json()이 본문을 끝까지 읽고 JSON을 파싱해준다. 소비하는 쪽에서는 완성된 자바스크립트 값을 받아 사용하면 된다.
② 스트림에서 직접 조금씩 읽기
if (response.body) {
const reader = response.body.getReader();
const { value, done } = await reader.read();
// value: 이번에 읽은 바이트 데이터
// done: 읽기가 끝났는지 여부
}
getReader()는 데이터를 읽을 도구를 얻는 것이고, read()는 다음 데이터 조각을 요청하는 것이다. 이렇게 한 번에 읽는 조각을 chunk라고 한다. 받은 chunk는 바이트 데이터이므로 텍스트나 JSON으로 사용하려면 추가 처리가 필요하다. 끝까지 반복해서 읽는 방법은 다음 절에서 살펴본다.
위 두 예제는 같은 본문을 읽는 서로 다른 선택지다. json()으로 본문을 모두 읽은 뒤 두 번째 예제를 이어서 실행하는 것은 아니다.
스트림으로 읽으면 무엇이 달라질까?
응답 본문은 전송 과정에서 나뉘어 도착할 수 있다. ReadableStream을 직접 읽으면 본문 전체가 도착하기 전에도 읽을 수 있게 된 부분부터 처리할 수 있다.
서버가 내용을 생성하면서 조금씩 보내는 경우도 있고, 한 번에 응답한 내용이 전송 과정에서 나뉘어 도착하는 경우도 있다. 서버가 보낸 단위와 브라우저에서 읽는 chunk의 경계가 반드시 일치하는 것은 아니다.
전체를 읽은 뒤 사용
Chunk 1 → Chunk 2 → Chunk 3 → 본문 읽기 완료 → 결과 사용
부분부터 사용
Chunk 1 → 처리
Chunk 2 → 처리
Chunk 3 → 처리
즉, response.body는 실제 내용을 읽는 통로이고, ReadableStream은 그 통로를 제공하는 객체의 종류다. 이 통로를 직접 읽으면 큰 파일이나 스트리밍 응답을 점진적으로 처리할 수 있다.
4. ReadableStream의 데이터를 읽어보기
가장 기본적인 방법은 getReader()를 이용하는 것이다. 아래처럼 await를 사용하는 코드 조각은 async 함수 내부나 최상위 await를 지원하는 모듈에서 실행한다고 가정한다.
const response = await fetch('/api/stream');
if (!response.body) {
throw new Error('읽을 응답 본문이 없습니다.');
}
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
여기서 중요한 부분은 다음 코드다.
await reader.read();
read()는 읽기 결과를 담은 Promise를 반환한다. await로 기다리면 준비된 chunk를 얻거나 스트림이 끝났음을 확인할 수 있다. 읽는 중 오류가 발생하면 Promise는 reject된다.
결과는 다음과 같은 형태다.
{
value,
done
}
value는 현재 읽은 데이터이고,
value → 이번에 읽은 chunk
done은 스트림이 종료되었는지를 나타낸다.
done: false → 이번 읽기에서 chunk를 얻음
done: true → 스트림 종료
마지막 chunk를 읽을 때도 done은 false다. 이후 다시 읽어 더 이상 읽을 chunk가 없고 스트림이 닫혔음을 확인하면 done: true가 반환된다. 따라서 done: false가 다음 chunk의 존재를 보장하지는 않는다.
텍스트 데이터라면?
fetch의 응답 스트림은 기본적으로 바이트 데이터를 전달한다.
텍스트로 사용하고 싶다면 TextDecoder로 변환한다. 아래는 새 응답의 본문을 텍스트로 읽는 예제다.
const response = await fetch('/api/stream');
if (!response.body) {
throw new Error('읽을 응답 본문이 없습니다.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, {
stream: true,
});
console.log(text);
}
// 정상 종료 후 디코더에 남은 바이트를 최종 처리한다.
const remainingText = decoder.decode();
if (remainingText) console.log(remainingText);
한 문자를 표현하는 바이트가 여러 chunk에 나뉘어 도착할 수 있다. stream: true는 미완성 바이트를 보관했다가 다음 chunk와 함께 처리하도록 한다. 마지막 decoder.decode()는 입력이 끝났음을 알리고, 남은 불완전한 바이트가 있다면 기본 설정에서는 대체 문자(�)로 처리한다.
즉, 바이트로 받은 chunk를 텍스트로 사용하려면 디코딩 과정이 필요하다.
디코딩한 문자열을 화면에 추가하는 것이 텍스트 스트림을 소비하는 한 가지 예다.
5. 그런데 이 코드 어디서 많이 본 것 같지 않을까?
앞의 코드를 다시 보면 다음과 같다.
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
결국 우리가 하는 일은
다음 데이터가 언제 올지는 모르지만, 데이터가 도착할 때마다 하나씩 처리하는 것
이다.
이처럼 다음 값을 기다리고 처리하는 반복을 JavaScript 문법으로 표현할 수 있다. 그 연결을 이해하기 위해 Async Iterable을 살펴보자.
6. Iterable과 Async Iterable
앞에서는 reader.read()를 반복해서 호출하며 응답 본문을 읽었다. 소비하는 쪽에서 필요한 동작은 **“다음 값을 받아 사용하고, 끝날 때까지 반복하기”**다. JavaScript에는 이런 반복을 공통 문법으로 표현하기 위한 규칙이 있다.
Iterable: 값을 하나씩 꺼내 사용하기
Iterable은 값을 하나씩 꺼내 순회할 수 있도록 정해진 규칙을 제공하는 객체다. 배열이 대표적인 예이고, for...of로 순회할 수 있다.
예를 들어 이미 얻은 사용자 목록에서 이름을 하나씩 출력한다면:
const users = [{ name: '민수' }, { name: '지수' }];
for (const user of users) {
console.log(user.name);
}
소비하는 쪽에서는 다음 값을 꺼내 사용하는 일을 반복한다. 배열뿐 아니라 문자열이나 Set도 Iterable이므로 같은 for...of 문법으로 순회할 수 있다.
여기서 중요한 점은 데이터 종류마다 반복문을 새로 만드는 대신, 같은 순회 규칙을 따르는 객체를 공통 문법으로 사용할 수 있다는 것이다.
Async Iterable: 다음 값을 기다리며 사용하기
응답 스트림에서는 다음 chunk가 아직 도착하지 않았을 수 있다. 따라서 다음 값을 요청했을 때 그 읽기 결과를 기다릴 수 있어야 한다.
Async Iterable은 다음 읽기 결과를 Promise로 받아 순회할 수 있도록 정해진 규칙을 제공하는 객체다. 소비하는 쪽에서는 for await...of로 값을 기다리며 하나씩 사용한다.
// response.body가 존재하는 응답을 얻었다고 가정한다.
for await (const chunk of response.body) {
console.log(chunk);
}
이 반복문은 다음 chunk를 기다리고, 받은 chunk를 처리한 뒤 다음 읽기로 넘어간다. 스트림이 정상적으로 끝나면 반복도 끝난다. 읽기에 실패하면 오류가 전달된다.
즉, Iterable과 Async Iterable 모두 여러 값을 순회하기 위한 규칙이다. 차이는 다음 읽기 결과를 바로 받는지, Promise로 받아 기다릴 수 있는지에 있다. Async Iterable이라도 값이 이미 준비되어 있을 수 있다.
어떤 경우에 사용하면 될까?
| 상황 | 데이터를 사용하는 방법 |
|---|---|
| 사용자 배열의 항목을 하나씩 사용 | Iterable인 배열을 for...of로 순회 |
| 응답 본문 전체를 읽고 JSON 결과를 사용 | await response.json()으로 결과를 받은 뒤 사용 |
| 응답 스트림의 chunk를 도착하는 대로 사용 | Async Iterable인 스트림을 for await...of로 순회 |
예를 들어 서버에서 받은 사용자 목록도 다음처럼 두 단계로 사용할 수 있다.
const response = await fetch('/api/users');
const users = await response.json(); // 본문 전체를 읽고 배열을 얻는다고 가정
for (const user of users) {
console.log(user.name); // 이미 얻은 배열을 순회
}
처음에 비동기로 가져온 데이터라도, 읽기가 끝나 배열을 얻었다면 일반적인 for...of로 순회하면 된다. 다음 값을 얻는 과정에서도 비동기 결과를 기다리며 반복해야 할 때 Async Iterable이 도움이 된다.
단, 대상이 Async Iterable 규칙을 제공해야 한다. for await...of를 쓴다고 임의의 데이터를 자동으로 스트리밍 응답으로 바꾸는 것은 아니다.
순회 중 **“다음 값을 달라”**고 요청하는 메서드가 next()다. 이 요청을 처리하는 객체를 Iterator라고 한다. Async Iterator는 읽기 결과를 Promise로 전달한다.
const { value, done } = await asyncIterator.next();
// value: 이번에 얻은 값
// done: 순회가 끝났는지 여부
Async Iterable은 [Symbol.asyncIterator]()로 Async Iterator를 제공한다. for await...of는 이 객체를 얻고 next() 호출, 기다림, 종료 확인을 반복하는 과정을 대신한다. 앞에서 직접 작성했던 await reader.read() 반복문과 비슷한 목적이다.
따라서 여기서 기억할 것은 Async Iterable은 비동기적으로 값을 순회할 수 있게 하는 규칙이고, for await...of는 그 값을 소비하는 문법이라는 점이다. 다음 절에서 ReadableStream에 적용한 전체 예제를 살펴본다.
for await...of는 async 함수 내부나 최상위 await를 지원하는 모듈에서 사용할 수 있다.
7. ReadableStream과 Async Iterable
여기서 ReadableStream과 Async Iterable이 연결된다.
ReadableStream은 [Symbol.asyncIterator]()를 제공하므로 Async Iterable로 사용할 수 있다. 그래서 for await...of로 응답 본문의 chunk를 하나씩 읽을 수 있다.
const response = await fetch('/api/stream');
if (!response.body) {
throw new Error('읽을 응답 본문이 없습니다.');
}
for await (const chunk of response.body) {
console.log(chunk);
}
reader.read()는 스트림의 Reader API이고, iterator.next()는 JavaScript의 순회 규칙이다. 서로 다른 메서드지만, 둘 다 Promise로 { value, done } 형태의 읽기 결과를 전달한다.
reader.read()를 직접 반복하는 것보다
다음 데이터 기다리기 → 도착하면 처리하기 → 다음 데이터 기다리기
라는 목적을 코드에 더 직접적으로 표현할 수 있다.
여기서 ReadableStream은 읽을 데이터를 제공하고, Async Iterable은 다음 값을 얻는 규칙을 제공한다. for await...of는 그 규칙에 따라 chunk를 반복해서 읽는다.
Async Iteration은 chunk를 하나씩 받아 소비하는 방법이다. 앞의 reader.read() 반복문과 이 예제는 같은 응답 본문을 읽는 두 가지 선택지다.
8. AbortController와 AbortSignal
더 이상 데이터가 필요하지 않다면?
데이터를 소비하다 보면 끝까지 읽을 필요가 없어지는 경우도 있다. 이때는 진행 중인 요청이나 본문 읽기를 중단할 수 있다.
예를 들어 검색창에서 사용자가 빠르게 입력한다고 생각해보자.
r
re
rea
reac
react
입력할 때마다 요청하면 여러 요청이 동시에 진행될 수 있다.
"rea"
───────────────→ response
"react"
──────→ response
사용자가 이미 "react"를 입력했다면, 이전 검색어인 "rea"의 응답은 필요하지 않을 수 있다. 이때 AbortController로 중단을 요청하고, AbortSignal을 통해 진행 중인 fetch에 전달할 수 있다.
중단을 요청하고 전달하는 방법
기본적인 사용법은 다음과 같다.
const controller = new AbortController();
const response = await fetch('/api/users', {
signal: controller.signal,
});
요청이 진행되는 동안 사용자 취소 이벤트 등에서 다음을 호출하면 중단할 수 있다. 위의 await fetch()가 끝난 뒤라도 본문을 읽는 중이면 그 읽기를 중단할 수 있다.
controller.abort();
두 객체는 다음처럼 역할을 나눈다.
| 객체 | 역할 |
|---|---|
AbortController | abort()를 호출해 중단을 발생시킴 |
AbortSignal | controller.signal로 얻으며, fetch에 중단 상태를 전달 |
검색 요청에 적용한다면
새로운 검색이 시작될 때 이전 요청을 중단하고, 새 요청에는 새로운 Controller의 Signal을 전달한다.
let controller: AbortController | null = null;
async function search(keyword: string) {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{
signal: controller.signal,
},
);
if (!response.ok) {
throw new Error('검색 요청에 실패했습니다.');
}
// 본문을 읽는 중 발생한 오류도 아래 catch에서 처리한다.
return await response.json();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
// 새 검색으로 취소된 요청은 결과를 반환하지 않는다.
return undefined;
}
throw error;
}
}
abort()는 진행 중인 fetch()나 응답 본문 읽기를 AbortError로 실패시킬 수 있다. 위 예제는 의도한 취소일 때 undefined를 반환하므로, 호출부에서는 이 경우 검색 결과를 갱신하지 않아야 한다. 그 밖의 오류는 호출부에서 처리하도록 다시 던진다.
검색어는 encodeURIComponent()로 인코딩해 &, # 등이 쿼리 구분자나 URL fragment로 해석되지 않도록 한다.
단, abort()를 서버에서 이미 실행된 모든 작업을 되돌리는 기능으로 이해하면 안 된다.
클라이언트 측에서는 해당 요청이나 응답 소비를 중단할 수 있지만, 서버 내부 작업까지 실제로 중단되는지는 서버 구현에 따라 달라진다.
9. 지금까지의 흐름 연결하기
응답을 얻고 본문을 읽는 방법을 살펴봤다. 이제 소비 방식을 선택하는 기준은 데이터를 언제, 어떤 단위로 사용해야 하는가다.
| 필요한 동작 | 소비 방법 | 결과를 사용하는 시점 |
|---|---|---|
| 완성된 JSON으로 목록을 표시하거나 계산 | await response.json() | 본문 전체를 읽고 파싱한 뒤 |
| 생성되는 텍스트를 화면에 조금씩 추가 | 스트림을 읽고 텍스트로 디코딩 | 사용할 수 있는 텍스트가 준비될 때마다 |
| 큰 데이터를 나누어 처리 | 스트림에서 chunk를 읽고 데이터 형식에 맞게 가공 | 처리 가능한 단위가 모일 때마다 |
응답 본문을 읽기
├─ 전체 결과가 필요함 → 끝까지 읽고 변환 → 결과 사용
└─ 부분부터 사용 가능 → 다음 chunk 기다리기 → 가공·사용 → 반복
두 방식 모두 비동기 결과를 기다린다. 차이는 전체 본문을 읽은 결과를 사용할지, 읽는 도중에도 일부 데이터를 사용할지에 있다. response.json()도 내부적으로 본문 스트림을 읽지만, 호출자에게는 완성된 결과를 제공한다.
for await...of는 두 번째 방식에서 반복 읽기를 표현하는 수단이다. 취소는 소비 방식과 별도로, 해당 요청이나 본문 읽기가 더 이상 필요하지 않을 때 선택한다.
10. 실제 UI에서는 어떻게 사용할 수 있을까?
예를 들어 서버에서 텍스트가 조금씩 전달되는 상황을 생각해보자.
Server → "안" → "녕하세요" → " 무엇을" → " 도와드릴까요?"
전체 데이터를 기다리는 방식이라면:
Request → 응답 본문 전체 읽기 → UI 업데이트
스트림을 소비한다면:
Request → "안" → UI 업데이트
→ "녕하세요" → UI 업데이트
→ " 무엇을" → UI 업데이트
처럼 응답을 받는 동안 점진적으로 화면에 보여주는 것도 가능하다.
위 그림은 개념적인 예시다. 실제 chunk는 문자나 메시지의 경계와 일치하지 않을 수 있으므로, 텍스트는 디코딩한 결과를 이어 붙여 사용한다. JSON도 chunk 하나가 완성된 JSON이라는 보장이 없으므로 각 chunk를 바로 JSON.parse()할 수는 없다.
화면에 표시하는 경우에는 다음처럼 읽기 결과를 실제 사용으로 연결한다.
chunk 읽기 → 텍스트 디코딩 → 표시할 내용에 추가 → 화면 업데이트
이처럼 읽은 데이터를 사용할 수 있는 형태로 바꾸어 화면에 반영하는 것이 비동기 데이터를 소비하는 구체적인 예다.
정리
서버 응답을 소비하는 방식은 응답 본문 전체가 준비된 뒤 사용해도 되는지, 아니면 도착한 데이터부터 바로 사용해야 하는지에 따라 달라진다.
본문 전체를 읽은 뒤 사용하기
일반적인 JSON API처럼 완성된 데이터가 있어야 화면을 그리거나 계산할 수 있다면 response.json()을 사용한다.
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('요청에 실패했습니다.');
}
const users = await response.json();
console.log(users); // 본문을 모두 읽고 JSON 파싱이 끝난 뒤 사용 가능
await fetch()가 끝나면 상태와 헤더를 확인할 수 있는 Response를 얻는다. 이 시점에 본문 전체까지 다 읽었다는 뜻은 아니다. await response.json()이 본문을 끝까지 읽고 JSON으로 변환할 때까지 기다린 뒤, 완성된 값을 돌려준다.
도착한 데이터부터 읽어 사용하기
서버가 텍스트를 생성하는 동안 화면에 조금씩 보여주거나, 큰 데이터를 나누어 처리하려면 response.body를 직접 읽을 수 있다. body는 바이트 chunk를 제공하는 ReadableStream이다.
const response = await fetch('/api/stream');
if (!response.ok) {
throw new Error('요청에 실패했습니다.');
}
if (!response.body) {
throw new Error('읽을 응답 본문이 없습니다.');
}
const decoder = new TextDecoder();
for await (const chunk of response.body) {
const text = decoder.decode(chunk, { stream: true });
// 이 chunk에서 디코딩된 텍스트를 이어 붙이거나 화면에 반영한다.
console.log(text);
}
// 마지막 chunk까지 처리한 뒤 디코더에 남은 바이트를 마무리한다.
const remainingText = decoder.decode();
if (remainingText) console.log(remainingText);
for await...of는 다음 chunk를 기다리고, 받은 값을 처리한 뒤, 스트림이 끝날 때까지 반복한다. 한 번에 읽은 chunk가 완성된 문자나 JSON 한 건이라는 보장은 없으므로, 필요한 형식에 맞춰 텍스트를 이어 붙이거나 구분 규칙을 적용해야 한다.
두 예제 모두 fetch()로 응답을 얻은 뒤 본문을 소비한다. 차이는 결과를 사용하는 시점이다. json()은 본문 전체를 읽고 완성된 값을 제공하며, 스트림을 직접 읽으면 처리할 수 있는 부분부터 사용할 수 있다. 일반적인 JSON API는 json()이 간단하고, 점진적인 처리가 필요한 경우에 스트림을 선택한다.
필요 없어진 요청은 중단하기
사용자가 새 검색어를 입력해 이전 응답이 필요 없어지면 AbortController로 중단할 수 있다. Controller의 signal을 fetch()에 전달하고, 새 검색이 시작될 때 abort()를 호출한다. 중단되면 fetch() 또는 본문 읽기에서 AbortError가 발생할 수 있으므로 이를 취소 상황으로 처리한다. 클라이언트의 중단이 서버 내부 작업까지 되돌리는 것은 아니다.
| 개념 | 이 흐름에서 하는 일 |
|---|---|
fetch() | 요청을 보내고 응답의 Response를 얻음 |
Response | 상태·헤더와 본문을 읽을 수 있는 통로를 제공 |
response.json() | 본문 전체를 읽고 JSON 값으로 변환 |
ReadableStream | 응답 본문을 바이트 chunk 단위로 읽게 함 |
Async Iterable / for await...of | 다음 chunk를 기다리며 반복 소비하게 함 |
AbortController / AbortSignal | 필요 없어진 요청이나 본문 읽기에 중단을 전달 |
따라서 소비 방식은 완성된 결과가 필요한지, 도착하는 부분부터 처리할 수 있는지를 기준으로 선택한다.
질문
Q1. await fetch()가 완료되면 응답 데이터도 모두 받은 걸까?
아니다.
fetch()의 Promise는 응답의 상태와 헤더를 바탕으로 Response를 만들 수 있게 되면 이행될 수 있다.
실제 응답 body를 끝까지 읽는 과정은 response.json(), response.text() 등을 호출했을 때 별도로 이루어진다.
Q2. response.json()과 response.body를 직접 읽는 방식의 차이는 무엇일까?
response.json()은 응답 body를 끝까지 읽은 뒤 JSON을 파싱해서 결과를 반환한다.
반면 response.body의 ReadableStream을 직접 사용하면 전체 데이터가 도착하기 전에도 chunk 단위로 데이터를 처리할 수 있다.
Q3. 모든 응답 본문을 직접 chunk 단위로 읽는 것이 좋을까?
그렇지 않다.
일반적인 크기의 JSON API라면:
await response.json();
처럼 사용하는 것이 훨씬 단순하다.
본문을 직접 chunk 단위로 읽는 방식은 큰 데이터나 스트리밍 응답처럼 점진적인 처리가 실제로 필요한 경우에 선택하는 것이 적절하다. json()을 사용하는 경우에도 내부에서는 본문 스트림을 읽는다.
Q4. ReadableStream과 Async Iterable은 같은 개념일까?
아니다.
ReadableStream은 데이터가 흐르는 구조이고,
Async Iterable은 비동기적으로 도착하는 데이터를 순차적으로 소비하기 위한 인터페이스다.
ReadableStream의 데이터를 Async Iteration 방식으로 소비할 수 있다는 관계다. Async Iteration은 chunk를 받은 뒤 거치는 단계가 아니라 chunk를 반복해서 읽는 방법이다.
Q5. for...of와 for await...of의 차이는 무엇일까?
for...of는 일반적인 Iterable을 순회한다.
for (const value of values) {}
for await...of는 Async Iterator의 next()가 반환하는 Promise를 기다린 뒤 값을 사용한다. 일반 Iterable도 순회할 수 있다.
for await (const value of asyncValues) {}
Q6. AbortController와 AbortSignal의 역할은 어떻게 다를까?
AbortController가 중단을 발생시키고,
controller.abort();
AbortSignal은 중단 상태를 비동기 작업에 전달한다.
fetch(url, {
signal: controller.signal,
});
Q7. abort()를 호출하면 서버에서 실행 중인 작업도 중단될까?
반드시 그런 것은 아니다.
클라이언트에서는 요청이나 응답 소비를 중단할 수 있지만, 서버 내부의 비즈니스 로직까지 중단되는지는 서버의 구현에 따라 달라질 수 있다.
Q8. 스트리밍을 활용하는 대표적인 경우는 무엇일까?
예를 들어:
- AI 채팅처럼 응답 텍스트를 실시간으로 보여주는 UI
- 큰 파일을 점진적으로 처리하는 경우
- 서버에서 계속 전달되는 데이터를 순차적으로 처리하는 UI
등을 생각할 수 있다.
Q9. 비동기 데이터를 소비하는 두 방식은 무엇을 기준으로 선택할까?
완성된 결과가 있어야 사용할 수 있다면 전체를 읽고, 일부 데이터만으로도 의미 있는 처리가 가능하다면 도착한 부분부터 읽어 사용한다. 예를 들어 일반적인 JSON 목록은 전체를 파싱한 뒤 사용하고, 생성 중인 텍스트는 디코딩한 부분부터 화면에 추가할 수 있다.
보충 그림
바이트를 텍스트로 바꾸는 과정
텍스트 스트림을 다룰 때 참고할 수 있는 디코딩 과정이다.
Iterable과 Async Iterable의 순회 규칙
각 객체에서 얻은 Iterator가 next()의 결과를 어떤 형태로 전달하는지 비교한 그림이다.