
자바스크립트에서 비동기를 배울 때 우리는 대개 Promise와 async, await부터 익힌다. 이 조합은 한 번의 결과를 기다리는 문제에는 매우 강력하다. 그러나 데이터가 한 번에 끝나지 않고 계속 흘러들어오는 상황에서는 이야기가 달라진다. 네트워크 응답이 길게 이어지거나, 큰 파일을 점진적으로 처리해야 하거나, 생산자와 소비자의 속도가 다를 때는 단순한 await response.text() 같은 모델만으로는 부족하다. Web Streams API는 바로 이 지점을 다루기 위해 만들어진 표준이다.
이 API를 제대로 배우려면 먼저 관점을 바꿔야 한다. 스트림은 "결과 하나를 기다리는 모델"이 아니라 "값의 흐름을 시간에 걸쳐 조율하는 모델"이다. 따라서 핵심은 메서드 이름을 외우는 데 있지 않다. 누가 데이터를 만들고, 누가 소비하고, 어느 시점에 더 가져오며, 언제 멈추고, 누가 종료를 선언하는지를 이해해야 한다.
왜 스트림이 필요한가
가장 흔한 오해는 스트림을 단지 "큰 파일 처리용 API" 정도로 보는 것이다. 물론 큰 데이터 처리도 중요한 사용처이지만, 본질은 크기가 아니라 전달 방식에 있다. 예를 들어 서버가 50MB짜리 JSON 파일을 보낸다고 해보자. 이 데이터를 문자열 전체로 받은 뒤 파싱하면, 브라우저는 응답 전체가 도착할 때까지 기다려야 하고 그동안 메모리에도 거대한 버퍼를 유지해야 한다. 반면 스트림으로 다루면 일부 청크가 도착하는 즉시 처리할 수 있다. 사용자는 더 빨리 의미 있는 결과를 보고, 런타임은 메모리를 덜 잡아먹는다.
또 다른 이유는 속도 차이다. 데이터 생산자가 빠르고 소비자가 느릴 수 있고, 반대로 생산자가 느리고 소비자가 빠를 수도 있다. 전통적인 배열이나 문자열 모델은 이미 완성된 데이터를 대상으로 동작하므로 이 속도 차이를 구조적으로 표현하지 못한다. 그러나 스트림은 데이터를 여러 청크로 쪼개고, 그 청크가 필요한 만큼만 이동하도록 설계되었다. 따라서 읽는 쪽이 느리면 쓰는 쪽이 속도를 조절해야 한다는 사실, 곧 백프레셔(backpressure)라는 개념이 자연스럽게 등장한다.
결국 스트림은 성능 최적화용 장식이 아니라, 비동기 데이터 흐름을 정직하게 모델링하는 방식이다. 값이 아직 다 오지 않았다는 사실, 일부만 처리되었다는 사실, 더 이상 필요 없어서 중단해야 한다는 사실을 모두 API 차원에서 표현한다. 이 점이 Promise와 Web Streams API를 가르는 가장 큰 차이이다.
조금 더 직설적으로 말하면, Promise는 "언젠가 하나의 값으로 수렴할 계산"을 잘 표현하고, 스트림은 "시간에 따라 계속 도착하는 값의 연속"을 잘 표현한다. 이 차이는 사소한 표현 차이가 아니다. 전자의 세계에서는 완료가 핵심 사건이지만, 후자의 세계에서는 진행 중이라는 상태 자체가 중요한 사건이 된다. 스트림을 배운다는 것은 바로 이 전환을 받아들이는 일이다. 값을 받았는가만 묻는 대신, 지금 몇 번째 청크까지 왔는가, 더 받을 여지가 있는가, 여기서 중단해도 되는가를 질문하는 습관으로 넘어가야 한다.
Web Streams API의 세 가지 축
Web Streams API의 표면은 크게 세 가지 축으로 구성된다. ReadableStream은 읽을 수 있는 데이터 소스이다. WritableStream은 데이터를 받아들이는 목적지이다. TransformStream은 입력 스트림을 받아 다른 출력 스트림으로 바꾸는 중간 변환기이다. 이 셋만 머릿속에 잡혀 있으면 이후의 모든 세부 규칙은 이 큰 구조 안에서 이해할 수 있다.
ReadableStream은 생산자 쪽 관점에 가깝다. 여기서 생산자라는 표현은 꼭 사용자가 직접 만든 객체만을 의미하지 않는다.fetch()의 응답 본문도 생산자이고, 브라우저의 압축 해제 파이프라인도 생산자이며, 파일 시스템에서 읽어 온 바이트들도 생산자가 될 수 있다. 중요한 점은 소비자가 필요할 때 청크를 공급할 수 있어야 한다는 점이다.WritableStream은 그 반대편이다. 이쪽은 데이터를 받아서 어딘가에 기록한다. 메모리 버퍼에 저장할 수도 있고, 네트워크 소켓으로 보낼 수도 있고, 압축기나 암호화기 같은 후속 단계로 넘길 수도 있다. 소비자와 목적지를 분리해서 생각해야 하는 이유는, 스트림 세계에서 "읽는 것"과 "처리한 뒤 어디에 쓸 것인가"가 다른 책임이기 때문이다.TransformStream은 두 축 사이에 놓인다. 입력을 받되 단순히 모으기만 하지 않고, 이를 다른 형태의 청크로 바꾸어 다시 내보낸다. 텍스트를 줄 단위로 자르거나, NDJSON을 객체 스트림으로 바꾸거나, 바이트를 디코딩해 문자열로 바꾸는 작업이 모두 여기에 속한다. 실무에서 대부분의 스트리밍 코드는 사실 읽기 자체보다 이 변환 단계를 어떻게 설계하느냐에 더 많은 시간을 쓴다.
이 세 축을 머릿속에 두면 스트림 설계의 책임 분리도 훨씬 선명해진다. ReadableStream은 어디서 값을 가져오는가를 설명하고, WritableStream은 그 값을 어디에 반영하는가를 설명하며, TransformStream은 그 사이에서 어떤 의미를 만들어내는가를 설명한다. 좋은 스트림 코드는 보통 이 세 질문이 섞이지 않는다. 반대로 좋지 않은 코드는 읽기, 파싱, 저장, 에러 처리, 종료 처리가 한 루프 안에 뒤엉켜 있다. 1편에서 이 구조를 먼저 고정하는 이유가 여기에 있다. 표면 API를 배우는 동시에 책임 경계도 함께 배워야 이후의 심화 개념이 자연스럽게 붙는다.
ReadableStream은 무엇을 약속하는가
ReadableStream을 이해할 때 가장 먼저 버려야 할 오해는, 이것이 "배열을 비동기로 감싼 것"이라는 생각이다. 배열은 이미 모든 원소를 갖고 있고, 인덱스로 접근할 수 있다. 반면 ReadableStream은 아직 오지 않은 데이터를 미래에 걸쳐 제공한다. 따라서 이 객체의 핵심은 데이터 자체보다도 생산 시점과 소비 시점을 분리하는 계약에 있다.
표준 생성자는 다음과 같은 모양을 갖는다.
const stream = new ReadableStream<string>({
start(controller) {
controller.enqueue('hello');
controller.enqueue('world');
controller.close();
},
});여기서 controller.enqueue()는 청크를 큐에 넣고, controller.close()는 더 이상 청크가 없음을 선언한다. 중요한 것은 close()가 즉시 모든 소비를 끝낸다는 뜻은 아니라는 점이다. 이미 큐에 들어간 청크는 계속 읽을 수 있고, 그 청크가 모두 소비된 뒤에야 읽기 쪽은 종료를 관찰한다. 이 규칙 덕분에 생산자는 "더 이상 새로 만들지 않겠다"는 의도를 밝히고, 소비자는 이미 준비된 데이터를 끝까지 처리할 수 있다.
이 미묘한 차이를 이해하지 못하면 스트림 종료를 너무 기계적으로 해석하게 된다. 많은 초심자는 close()를 호출한 순간 모든 것이 즉시 끝난다고 생각한다. 하지만 표준이 이렇게 설계된 이유는, 생산과 소비가 시간적으로 완전히 일치하지 않기 때문이다. 이미 큐에 들어간 데이터까지 날려버린다면 생산자는 정상 종료를 해도 소비자 입장에서는 데이터 손실을 겪게 된다. 따라서 close()는 "새 공급 중단"이지 "기존 데이터 폐기"가 아니다. 이 구분은 이후 cancel()과 비교할 때 특히 중요해진다.
조금 더 실전적인 예시는 pull()이 등장할 때 보인다.
function createCounterStream(limit: number) {
let current = 0;
return new ReadableStream<number>({
pull(controller) {
if (current >= limit) {
controller.close();
return;
}
controller.enqueue(current);
current += 1;
},
});
}이 예제의 핵심은 생산이 "미리 전부 일어나지 않는다"는 점이다. 소비자가 읽어갈 여지가 있을 때만 pull()이 호출되고, 그때 하나씩 만들어 넣는다. 이 차이를 이해하지 못하면 나중에 백프레셔나 desiredSize를 배울 때 계속 헷갈리게 된다. 스트림은 기본적으로 "필요할 때 더 달라"는 pull 기반 모델을 품고 있다.
실무 감각으로 바꿔 말하면, pull()은 생산자에게 허용된 진입 지점이라고 볼 수 있다. 어디서든 데이터를 밀어 넣을 수 있다면 생산자 쪽 코드는 단순해질지 몰라도, 시스템 전체는 금방 제어 불가능해진다. 반대로 pull()처럼 소비 여지를 기준으로 생산을 허가하면, 업스트림은 다운스트림의 속도를 존중하게 된다. 이 생각이 바로 스트림을 "잘 쓰는 법"과 "겉보기만 스트림인 코드"를 나누는 기준이 된다.
start, pull, cancel의 역할
ReadableStream 생성자에서 가장 중요한 세 가지 훅은 start, pull, cancel이다. 이 셋은 겉보기에는 단순하지만, 역할을 섞어 쓰면 곧바로 꼬인다.
start(controller)는 스트림이 처음 만들어질 때 호출된다. 여기서는 초기 자원 준비가 주된 역할이다. 예를 들어 외부 연결을 열거나, 이벤트 리스너를 붙이거나, 즉시 넣을 수 있는 초기 청크를 enqueue할 수 있다. 하지만start()안에서 무한히 데이터를 밀어 넣는 것은 좋은 설계가 아니다. 그렇게 하면 소비자 속도와 무관하게 생산자가 독주하게 되어 스트림의 조율 모델을 스스로 깨뜨리기 때문이다.pull(controller)는 읽는 쪽이 더 받을 여지가 있을 때 호출된다. 바로 이 지점이 생산 속도와 소비 속도를 연결하는 고리이다. 지연 생산, 페이징, 청크 단위 파일 읽기, 외부 소스로부터 다음 데이터 요청 같은 작업은 보통 여기서 수행한다.pull()을 제대로 이해하면 "스트림은 비동기 반복자와 뭐가 다른가"라는 질문에도 답할 수 있다. 스트림은 단순히next()를 비동기로 호출하는 인터페이스를 넘어서, 내부 큐와 압력 조절까지 함께 표준화한다.cancel(reason)은 읽는 쪽이 더 이상 데이터를 원하지 않을 때 호출된다. 예를 들어 소비자가 중간에 충분한 정보를 얻어서 나머지를 버리고 싶을 수 있다. 이때 생산자는 단순히 읽기를 멈춘 것으로 생각하면 안 된다. 외부 자원을 붙잡고 있었다면 해제해야 하고, 타이머나 소켓 같은 비동기 작업도 중단해야 한다.cancel은 읽기 중단 알림이지, 자동 정리 마법이 아니다.
이 지점은 스트림을 처음 쓸 때 가장 자주 놓치는 수명 주기 문제와 연결된다. 우리는 보통 생성 경로에는 신경을 많이 쓰지만, 종료 경로는 예외 상황 정도로 취급하는 습관이 있다. 그러나 스트림에서는 오히려 종료 경로를 먼저 설계하는 편이 안전하다. 네트워크 요청이 오래 지속되거나, 이벤트 소스를 구독하거나, 파일 핸들을 들고 있는 스트림이라면 "언제 끝나는가"보다 "누가 중단을 선언하면 어떻게 정리되는가"가 더 중요할 수 있다.
function createIntervalStream() {
let timer: ReturnType<typeof setInterval> | null = null;
return new ReadableStream<number>({
start(controller) {
let count = 0;
timer = setInterval(() => {
controller.enqueue(count);
count += 1;
}, 1000);
},
cancel() {
if (timer !== null) {
clearInterval(timer);
}
},
});
}이 예제는 단순하지만 중요한 메시지를 담고 있다. 소비자가 떠났는데 생산자가 계속 일하면, 스트림은 겉보기에만 비동기적일 뿐 실은 자원 누수의 통로가 된다. 따라서 스트림을 만들 때는 생성만큼이나 종료 경로를 먼저 설계해야 한다.
읽기는 어떻게 이루어지는가
ReadableStream을 소비하는 가장 직접적인 방법은 getReader()를 통해 reader를 얻고 read()를 반복 호출하는 것이다.
async function consume(stream: ReadableStream<string>) {
const reader = stream.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
console.log('chunk:', value);
}
} finally {
reader.releaseLock();
}
}여기서 done은 더 이상 읽을 데이터가 없다는 신호이다. 이 값을 배열의 길이와 비슷하게 생각하면 곤란하다. 배열은 고정된 크기를 가지지만, 스트림은 종료가 선언되기 전까지 길이를 모른다. 따라서 소비자는 항상 done을 통해 종결 시점을 관찰해야 한다.
이 차이는 코드를 작성하는 방식에도 영향을 준다. 배열을 다룰 때는 보통 시작 전에 전체 크기나 종료 조건을 알고 들어간다. 하지만 스트림은 보통 "끝날 때까지 읽는다"는 형태로 소비한다. 즉, 스트림 소비 코드는 인덱스 기반 반복보다 상태 기반 반복에 가깝다. 이 패턴을 어색하게 느낀다면 그것은 아직 사고방식이 컬렉션 중심에 머물러 있다는 뜻일 수 있다. 스트림에서는 전체 데이터보다 현재 청크와 종료 신호가 더 중요하다.
또 하나의 핵심은 releaseLock()이다. ReadableStream은 한 시점에 하나의 reader만 독점적으로 연결될 수 있다. 이 사실은 처음에는 불편해 보이지만, 사실상 매우 중요한 안전장치이다. 두 소비자가 동시에 같은 스트림을 읽으려 하면 청크 분배 규칙이 애매해지고, 누가 어느 청크를 소비했는지 추적하기 어려워진다. 스트림은 이 혼란을 피하기 위해 잠금(lock)이라는 개념을 도입한다. reader를 얻는 순간 스트림은 잠긴다는 사실을 꼭 기억하기 바란다.
비동기 반복과 스트림 소비
현대 자바스크립트에서는 비동기 반복자를 통해 스트림을 더 자연스럽게 소비할 수도 있다.
async function consumeWithIterator(stream: ReadableStream<string>) {
for await (const chunk of stream) {
console.log(chunk);
}
}이 문법은 편리하지만, 편하다고 해서 의미론이 사라지는 것은 아니다. 내부적으로는 여전히 reader와 잠금, 종료 규칙이 작동한다. 따라서 이 문법을 쓰는 경우에도 스트림은 한 번에 하나의 소비자만 가지며, 반복이 끝나면 스트림 상태도 그에 맞게 전이된다. 즉, for await...of는 더 짧은 문법이지 다른 모델이 아니다.
이 점은 학습 순서와도 연결된다. 많은 글이 for await...of부터 보여주기 때문에 스트림이 마치 비동기 iterable의 문법적 별칭처럼 보인다. 그러나 실제로는 순서가 반대이다. 먼저 스트림이 내부 큐와 잠금, 종료 의미론을 가진 객체라는 사실을 이해해야 하고, 그다음에야 이 객체가 비동기 반복이라는 편리한 소비 표면도 제공한다고 보는 편이 정확하다. 편의 문법을 먼저 배우면 나중에 복잡한 상황에서 reader API로 내려왔을 때 갑자기 세계가 달라진 것처럼 느껴진다.
실무에서는 이 문법이 특히 fetch() 응답이나 텍스트 디코딩 후의 라인 스트림을 다룰 때 읽기 좋다. 다만 세밀한 제어가 필요할 때는 다시 reader API로 내려가야 한다. 예를 들어 부분 읽기 후 조기 종료, releaseLock() 시점 통제, BYOB reader 사용 같은 경우에는 직접 reader를 잡는 편이 더 명확하다.
WritableStream은 무엇을 약속하는가
읽기 모델을 이해했다면 이제 반대편인 WritableStream을 볼 차례이다. 여기서 중요한 질문은 "어떻게 쓸 것인가"보다 "언제 써도 안전한가"이다. 스트림 목적지가 항상 즉시 데이터를 받을 수 있는 것은 아니기 때문이다. 디스크가 느릴 수도 있고, 네트워크 송신 버퍼가 꽉 찰 수도 있고, 중간 변환기가 아직 이전 청크를 처리 중일 수도 있다. WritableStream은 바로 이 불확실성을 표준화한다.
기본 생성자는 다음과 같이 생긴다.
const writable = new WritableStream<string>({
write(chunk) {
console.log('write:', chunk);
},
close() {
console.log('closed');
},
abort(reason) {
console.error('aborted:', reason);
},
});write(chunk)는 청크를 받아 처리한다. 이 메서드는 비동기일 수 있으며 Promise를 반환해도 된다. 바로 이 점이 중요하다. 쓰기 대상이 느리다면 write()가 해결될 때까지 다음 단계는 기다려야 한다. 그렇지 않으면 메모리 버퍼만 끝없이 쌓이고 흐름 제어는 무너진다.
close()는 더 이상 청크가 오지 않는 정상 종료를 의미한다. 반면 abort(reason)는 실패 또는 강제 중단에 가깝다. 둘 다 쓰기 종료라는 점에서는 비슷해 보이지만 의미는 전혀 다르다. close()는 이미 들어온 데이터를 정상적으로 마무리한 뒤 닫는 경로이고, abort()는 현재 처리 중인 일까지 폐기할 수 있는 비정상 경로이다. 이 구분을 흐리게 이해하면 이후 pipeTo()의 전파 규칙을 배울 때 반드시 혼란이 생긴다.
특히 저장 계층이나 네트워크 송신과 연결되는 순간 이 차이는 더욱 무거워진다. 로그 파일을 닫는 것과, 오류 때문에 더 이상 신뢰할 수 없는 상태로 중단하는 것은 관찰 결과가 같아 보여도 운영 의미가 다르다. 전자는 "정상적으로 다 썼다"는 기록을 남길 수 있지만, 후자는 "어디까지 반영되었는지 보장할 수 없다"는 경고를 남겨야 한다. 스트림은 이런 차이를 메서드 이름 수준에서 드러내어, 호출부가 종료 이유를 명시적으로 선택하도록 강제한다.
writer와 쓰기 순서
WritableStream을 사용할 때는 getWriter()로 writer를 얻는다.
async function writeChunks(writable: WritableStream<string>) {
const writer = writable.getWriter();
try {
await writer.write('a');
await writer.write('b');
await writer.write('c');
await writer.close();
} finally {
writer.releaseLock();
}
}이 코드에서 await를 생략하고 연속으로 write()만 호출할 수도 있지만, 그렇게 하면 목적지의 수용 속도를 무시하기 쉽다. writer는 이 문제를 막기 위해 ready라는 신호를 제공한다. 자세한 백프레셔 메커니즘은 나중에 따로 다루겠지만, 지금 단계에서는 쓰기 쪽 역시 읽기 쪽과 마찬가지로 속도 조율을 내장하고 있고, writer는 그 압력을 관찰하는 창이라는 점을 기억하기 바란다.
초기 학습에서는 이 부분이 잘 체감되지 않을 수 있다. 메모리 버퍼에 쓰는 간단한 예제에서는 write()가 거의 즉시 끝나기 때문이다. 그러나 실제 목적지가 느린 저장소라면 이야기가 완전히 달라진다. 예를 들어 압축, 암호화, 파일 기록, 네트워크 업로드가 연결되면 각 청크는 단순 함수 호출이 아니라 실제 작업 단위를 의미하게 된다. 이때 await writer.write()는 스타일 차원이 아니라 시스템이 감당 가능한 속도를 존중하는 제어 구문이 된다.
또한 writer 역시 스트림을 잠근다. 즉, 여러 코드 경로가 동시에 같은 WritableStream에 제멋대로 쓰지 못하도록 한다. 겉보기에는 불편하지만, 이는 순서를 보장하기 위한 중요한 설계이다. 쓰기 순서가 섞이면 로그 스트림, 네트워크 프로토콜, 파일 저장처럼 순서 자체가 의미를 갖는 작업에서 즉시 문제가 발생한다.
TransformStream은 왜 별도 개념인가
처음에는 ReadableStream으로 읽어서 WritableStream에 직접 쓰면 되지 굳이 TransformStream이 왜 필요한가 싶을 수 있다. 그러나 변환 단계를 별도 객체로 분리하면 책임이 훨씬 명확해진다. 입력을 읽는 코드와 출력을 쓰는 코드를 억지로 섞지 않고, "입력 청크를 받아 출력 청크로 바꾸는 일"만 집중해서 모델링할 수 있기 때문이다.
const upperCaseTransform = new TransformStream<string, string>({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});이 객체는 readable과 writable을 동시에 가진다. 한쪽으로 청크를 쓰면, 변환 로직을 거쳐 다른 쪽에서 읽을 수 있다. 이 구조는 스트림 설계를 파이프라인 사고로 바꿔준다. 데이터를 읽는다 → 변환한다 → 다시 쓴다라는 흐름을 단일 루프 하나로 퉁치는 대신, 각 단계를 독립적인 스트림 계약으로 나누는 것이다.
바로 이 지점에서 스트림 설계가 함수형 파이프라인과도 닮아 보이기 시작한다. 각 단계는 입력과 출력의 계약만 알면 되고, 앞뒤 단계의 세부 구현을 몰라도 된다. 덕분에 변환기는 재사용 가능해지고, 테스트도 훨씬 쉬워진다. 예를 들어 대문자 변환기를 네트워크 응답에도 붙일 수 있고 파일 입력에도 붙일 수 있다. 좋은 TransformStream은 특정 데이터 소스에 종속되지 않고, 청크 의미만 다룬다.
실무에서 이 분리는 매우 중요하다. 줄 단위 파서, 압축기, 디코더, 토큰화기, 필터, 샘플러 같은 구성 요소를 모두 TransformStream으로 만들 수 있기 때문이다. 이렇게 되면 애플리케이션은 거대한 스트리밍 함수 하나가 아니라, 작은 파이프 조각들을 이어 붙이는 구조가 된다.
파이프라인 사고의 출발점
스트림의 진짜 힘은 결국 파이프라인에 있다. pipeThrough()는 현재 스트림을 TransformStream에 통과시키고, pipeTo()는 결과를 WritableStream으로 흘려보낸다.
async function runPipeline(source: ReadableStream<string>) {
const upperCaseTransform = new TransformStream<string, string>({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
const sink = new WritableStream<string>({
write(chunk) {
console.log('result:', chunk);
},
});
await source.pipeThrough(upperCaseTransform).pipeTo(sink);
}이 코드는 읽는 쪽과 쓰는 쪽이 직접 서로를 모른다. 중간 변환기는 입력과 출력 계약만 안다. 종료와 에러, 취소는 체인을 따라 전파된다. 그리고 가장 중요한 사실 하나가 숨어 있다. 다운스트림이 느리면 업스트림도 속도를 조절해야 한다. 이 점이 바로 백프레셔이고, 스트림을 단순한 비동기 반복과 구분하는 핵심이다.
스트림은 혼자 존재할 때보다 연결될 때 의미가 커진다. 단일 ReadableStream 예제는 입문용으로는 좋지만, 실제 실무 가치는 파이프라인에서 드러난다. 여러 단계를 어떻게 분해하고, 종료와 압력을 어떻게 체인 전체에 걸쳐 다룰지를 고민하는 순간 비로소 Web Streams API의 설계 철학이 보인다.
fetch와 스트림의 연결
Web Streams API를 배워야 하는 가장 현실적인 이유는 fetch()가 이미 이 모델 위에 올라가 있기 때문이다. Response.body는 ReadableStream이다. 즉, 우리는 이미 표준 스트림을 매일 쓰고 있는데, 그 의미론을 모른 채 편의 메서드로만 소비하고 있는 셈이다.
async function readResponse(url: string) {
const response = await fetch(url);
if (!response.body) {
throw new Error('응답 본문이 없습니다.');
}
const reader = response.body.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
console.log('bytes:', value);
}
} finally {
reader.releaseLock();
}
}이 코드를 이해하면 response.text()나 response.json()이 내부에서 무엇을 하는지도 보인다. 그 메서드들은 스트림 전체를 끝까지 소비한 뒤 한 번에 결과를 만들어 주는 편의 계층일 뿐이다. 따라서 응답을 점진적으로 처리하거나, 중간에 중단하거나, 직접 청크 경계를 다루고 싶다면 결국 Web Streams API로 내려와야 한다.
자주 생기는 첫 번째 오해들
기초 단계에서 가장 흔한 오해는 네 가지이다. 첫째, 스트림을 단순한 비동기 배열로 보는 것이다. 그러나 스트림은 길이와 종료 시점, 속도 조절까지 포함한 모델이다. 둘째, close()와 cancel()을 같은 뜻으로 보는 것이다. 전자는 생산자가 정상 종료를 선언하는 경로이고, 후자는 소비자가 더 이상 원하지 않음을 알리는 경로이다. 셋째, WritableStream을 단순한 콜백 집합으로 보는 것이다. 하지만 실제 본질은 쓰기 순서와 수용 능력 조율에 있다. 넷째, TransformStream을 편의 도구 정도로 보는 것이다. 그러나 실제 실전 설계에서는 변환 단계의 분리가 전체 파이프라인의 복잡도를 결정한다.
이 오해들을 초기에 바로잡아 두어야 이후의 세부 개념이 자연스럽게 이어진다. 스트림은 "읽고 쓰는 API"이면서 동시에 "시간에 걸친 흐름 제어 모델"이기 때문이다.
마무리
이 글에서 꼭 남겨야 할 한 문장은 이것이다. Web Streams API의 본질은 데이터를 한 번에 들고 다니는 것이 아니라, 시간이 흐르면서 생산자와 소비자를 조율하는 데 있다. ReadableStream은 읽기 가능한 소스를, WritableStream은 쓰기 가능한 목적지를, TransformStream은 그 사이의 변환을 모델링한다. 그리고 start, pull, cancel, write, close, abort 같은 메서드는 단순한 훅이 아니라 각 단계의 생명주기와 책임 경계를 드러내는 계약이다.
다음 글에서는 바로 그 계약이 실제로 어떻게 작동하는지, 즉 잠금, 종료 전파, 에러 전파, 취소, 백프레셔를 중심으로 스트림의 의미론을 더 깊게 파고들겠다. 1편이 표면을 익히는 단계였다면, 2편은 왜 스트림 코드가 멈추고 꼬이고 안정되기도 하는지를 이해하는 단계가 될 것이다.
더 읽어보기
2026.03.13
Streams API 3. 바이트 스트림과 실전 파이프라인
앞선 두 글에서 우리는 Web Streams API의 표면과 의미론을 정리했다. 이제 남은 질문은 하나이다. 이 지식을 실제 어디에 써먹을 것인가. 이 질문에 답하는 순간 스트림 학습은 비로소 완성된다. 표준을 읽고 메서드를 아는 것만으로는 충분하지 않다. fetch() 응답 본문을 어…
2026.03.13
Streams API 2. 상태와 백프레셔의 의미론
스트림을 처음 배울 때는 읽고 쓰는 예제가 꽤 단순해 보인다. getReader()로 읽고, getWriter()로 쓰고, pipeTo()로 연결하면 끝나는 것처럼 느껴진다. 실제로 짧은 데모는 이 정도만 알아도 돌아간다. 그러나 실무에서 스트림 코드를 망가뜨리는 원인은 거의 언제나 더…
2026.03.04
JavaScript를 위한 더 나은 Streams API가 필요하다
이 포스트는 node.js의 코어 컨트리뷰트이며 Cloudflare Workers 팀 소속 개발자 James M Snell이 cloudflare 블로그에 올린 We deserve a better streams API for JavaScript 게시글을 번역한 것이다. 번역하는 과정에서…
2026.02.22
View Transition API
웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...