원문: https://www.thisdot.co/blog/deep-dive-into-node-js-with-james-snell
Published May 24, 2022 Updated Feb 13, 2023
이 글은 18개월 전에 작성되었으며 오래된 정보가 포함되어 있을 수 있습니다. 일부 내용은 여전히 유효할 수 있지만 최신 정보는 관련 공식 문서 또는 사용 가능한 리소스를 참조하세요.
Node.js는 전 세계적으로 가장 많이 사용되는 엔진 중 하나로, 독특한 방식으로 코드를 실행합니다. 하지만 우리는 일상 업무에서 사용하는 이러한 기반 기술들이 실제로 어떻게 작동하는지 종종 간과합니다.
이 글에서는 James Snell의 강연을 기준으로 Node.js 내부를 자세히 살펴보고, 몇 가지 개념은 조금 더 확장하여 명확하게 설명해보겠습니다.
먼저, 이벤트 루프와 Node.js의 비동기 모델에 대해 알아보겠습니다. 그 다음, 이벤트 이미터(Emitter)와 그것이 Node.js 내부에서 어떻게 구동되는지 설명하겠습니다. 마지막으로, 이를 바탕으로 Streams가 무엇인지, 그리고 어떻게 작동하는지 이해해 보겠습니다.
이벤트 루프를 탐색하면서 Node.js 내부 여행을 시작하겠습니다. 이벤트 루프는 Node.js에서 이해해야 할 가장 중요한 개념 중 한가지 입니다. 이것은 Node.js의 동기 및 비동기 스케줄링을 담당하는 큰 오케스트레이터 역할을 합니다. 이 섹션에서는 이 스케줄링 메커니즘이 어떻게 작동하는지 설명합니다.
Node.js 프로세스가 시작되면 여러 스레드가 시작됩니다. 기본적으로 메인 프로세스 스레드와 4개의 워커 스레드로 구성된 Libuv 스레드가 작동됩니다. Libuv 스레드는 디스크에서 파일을 읽거나, 일부 암호화를 실행하거나, 소켓에서 데이터를 읽는 등 IO와 같은 무거운 작업을 처리합니다.
이벤트 루프는 메인 스레드 내부에서 실행되는 for/while 루프로 볼 수 있습니다. 루프 프로세스는 C++로 구현되어있습니다. 이벤트 루프가 한 사이클을 완전히 완료하려면 여러 단계를 거쳐야 합니다. 이러한 단계에는 다양한 검사를 수행하고 OS 이벤트를 수신하는 작업이 포함됩니다. 예를 들어, 만료된 타이머가 있는지 확인하여 작업이 실행될 필요가 있는지 검사합니다. 또한, 처리해야 하거나 스케줄링해야 할 대기 중인 IO가 있는지도 확인합니다.
이러한 작업들은 C++로 구현됩니다. 하지만 이벤트 루프가 반복되는 과정에서 발생하는 이벤트는 자바스크립트에서 실행되는 콜백과 관련이 있습니다. 이 콜백은 타이머가 만료되거나 파일의 새로운 청크가 처리될 준비가 되었을 때 실행됩니다.
이벤트 루프 프로세스는 메인 스레드를 점유하고 있습니다. 따라서, 이벤트 루프의 단계 중 하나가 처리될 때마다 해당 단계가 완료될 때까지 이벤트 루프와 메인 스레드가 차단됩니다. 이 말은, 메인 스레드가 이벤트 루프의 작업을 완료하는 동안 Libuv가 여전히 IO 작업을 실행하고 있더라도, 메인 스레드가 차단 해제되고 그 결과를 처리하는 단계에 도달하기 전까지는 IO 작업의 결과에 접근할 수 없다는 것을 의미합니다.
"비동기 자바스크립트라는 것은 존재하지 않습니다." James Snell
하지만 Node.js와 자바스크립트에 비동기가 존재하지 않는다면, 프로미스는 무엇을 위한 것일까요?
프로미스는 처리를 미래로 지연시키는 메커니즘일 뿐입니다. 특정 이벤트가 발생한 후 코드 실행을 예약할 수 있는 것은 프로미스의 강력한 기능입니다. 하지만 이 글의 뒷부분에서 프로미스에 대해 자세히 알아보겠습니다.
이제 이벤트 루프의 큰 그림을 이해했으니, 한 단계 더 깊이 들어가서 이벤트 루프의 각 단계에서 어떤 일이 일어나는지 이해해 보겠습니다.
이벤트 루프의 각 단계가 C++로 실행된다는 것을 배웠는데, 해당 단계에서 일부 자바스크립트를 실행해야 한다면 어떻게 될까요?
C++ 단계 코드는 동기적인 방식의 호출에서 전달된 자바스크립트 코드를 실행하기 위해 V8 API를 사용합니다. 해당 자바스크립트 코드에는 함수를 인자로 받는 process.nextTick(fn)
이라는 이름의 Node.js API를 호출할 수 있습니다.
"nextTick은 실제로 tick과는 관련이 없기 때문에 좋은 이름이 아닙니다." James Snell
process.nextTick(fn)
이 호출되는 경우, 인자로 전달된 함수가 큐에 추가됩니다. 조금 뒤에 함수를 추가할 수 있는 또 다른 큐를 설명해 드리겠습니다. 지금은 설명을 단순하게 하기 위해 nextTick 큐 하나만 있다고 가정해 보겠습니다. 자바스크립트가 실행되고 process.nextTick
메서드를 통해 큐 채우기를 완료하면 제어권이 C++ 수준으로 반환됩니다.
이제 Node.js가 nextTick Queue
에 추가된 각 함수를 실행하여 추가한 순서대로 nextTick 큐를 동기적으로 비워야 할 때입니다. 큐가 비어 있을 때만 이벤트 루프가 다음 단계로 이동하고 동일한 프로세스로 다시 시작할 수 있습니다.
앞서 설명한 모든 내용이 비동기적으로 실행된다는 점을 기억하세요.
"자바스크립트가 실행될 때마다 다른 모든 작업도 동시에 일어나고 있습니다." James Snell
따라서 자바스크립트 성능을 유지하기 위한 핵심은 함수를 작게 유지하고 스케줄링 메커니즘을 사용하여 작업을 지연시키는 것입니다.
그렇다면 스케줄링 메커니즘은 무엇인가요?
스케줄링 메커니즘은 Node.js가 특정 자바스크립트 함수를 미래의 특정 시간에 실행하도록 예약함으로써 비동기성을 시뮬레이션하는 도구입니다. 스케줄링 메커니즘에는 nextTick
큐과 Microtask
큐가 있으며, 이 둘의 차이점은 실행되는 순서입니다. NodeJS는 nextTick
큐가 비워진 후에만 Microtasks
큐를 비우기 시작합니다. 그리고 nextTick
큐는 콜 스택이 비워진 후에 비워집니다.
콜 스택은 LIFO(Last In, First Out. 후입선출) 스택 입니다. 스택에서의 작업은 완전히 동기적이며 가능한 한 빨리 실행되도록 예약됩니다. 앞서 살펴본 v8 API는 C++ 수준에서 전송된 자바스크립트 코드를 스택에 추가하고 제거하면서 실행합니다.
V8이 스택에서 하나의 명령문을 처리할 때 nextTick
큐가 어떻게 채워지는지, 그리고 C++이 자바스크립트 스택을 처리하자마자 nextTick 큐가 어떻게 비워지는지 살펴보았습니다.
마이크로태스크 큐는 V8에서 프로미스의 연속 작업인 then, catch, finally를 처리하는 큐입니다. nextTick
큐가 비워진 직후 즉시 비워집니다.
이것이 어떻게 작동하는지 그림으로 그려보았습니다. 다음은 여러 작업을 실행하는 함수 본문을 나타냅니다.
// 일부 JS 코드
// 블록 A ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
promise.then(fn)
// 블록 B ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
nextTick(fn)
// 블록 C ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
하지만 Node.js가 이 코드를 실행하는 최종 순서는 다음과 같습니다.
// 일부 JS 코드
// 블록 A ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
// 블록 B ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
// 블록 C ...는 var c = a + b, 등과 같은 코드일 수 있습니다.
nextTick(fn)
promise.then(fn)
nextTick이 작업 스택이 비워진 후 처리되고, 마이크로태스크 작업들(프로미스 연속 작업들)이 nextTick 큐가 비워진 후에 지연 실행되는 것을 알 수 있습니다.
하지만 다른 스케줄링 메커니즘도 있습니다.
setImmediate(fn)
는 현재 이벤트 루프 순서가 끝날 때 또는 다음 이벤트 루프 순서가 시작되기 직전에 콜백을 실행하도록 등록합니다."NextTick 작업은 자바스크립트 스택이 비워지자마자 즉시 발생하고 Immediate 함수는 NextTick이 시작되기 직전에 발생하기 때문에 NextTick과 Immediate의 이름은 바뀌어야 합니다."
보완 자료를 찾으려면 Node.js 문서로 이동하세요.
이벤트 이미터는 최초로 만들어진 Node.js API 중 하나입니다. Node.js의 대부분이 이벤트 이미터입니다. 이벤트 이미터는 가장 먼저 로드되며 Node.js 작동 방식의 기본이 됩니다.
다음과 같은 메서드가 있는 객체를 발견하면, 그것은 이벤트 이미터입니다.
on('eventName', fn)
once('eventName', fn)
emit('eventName', arg...)
그러면 이것들은 어떻게 작동할까요?
이벤트 이미터 객체 내부에는 또 다른 객체인 map/look-up 테이블이 있습니다. 각 map 항목 내부에는 이벤트 이름이 키로, 콜백 함수의 배열이 값으로 저장됩니다.
이벤트 이미터 객체의 on
메서드는 첫 번째 인자로 이벤트 이름을 받고, 두 번째 인자로 콜백 함수를 받습니다. 이 메서드가 호출되면, 해당 이벤트 이름에 대응하는 look-up 테이블의 배열에 on
콜백 함수를 추가합니다.
once
메서드는 on
메서드와 비슷하게 동작하지만 한 가지 중요한 차이점이 있습니다. once
콜백 함수를 직접 저장하는 대신, 다른 함수 안에 감싸서 배열에 추가합니다. 래퍼 함수가 실행될 때, 래핑된 콜백 함수를 실행한 후, 배열에서 자신을 제거합니다.
반면에 emit
메서드는 이벤트 이름을 사용하여 해당 배열에 접근합니다. 이 과정에서 배열의 백업 복사본을 만들고, 배열에 있는 각 콜백 함수를 동기적으로 반복하며 실행합니다.
emit은 동기적이라는 점을 강조하는 것이 중요합니다. 배열의 콜백 함수 중 하나라도 실행하는 데 시간이 오래 걸리면 메인 스레드가 차단됩니다. 배열의 모든 함수가 실행될 때까지 Emit
메서드에서 반환값을 받을 수 없습니다.
emit
의 비동기적으로 동작하는 것처럼 보이지만 착시일 뿐입니다. 내부적으로 Node.js는 각 콜백 함수를 nextTick
을 사용하여 호출하므로 함수 실행이 미래로 연기되는 효과가 나타납니다.
이벤트 이미터에 대한 자세한 정보는 Node.js 문서에서 확인할 수 있습니다.
스트림은 Node.js 애플리케이션을 구동하는 기본 개념 중 하나이며 파일 읽기/쓰기, 네트워크 통신 또는 모든 엔드 투 엔드 정보 교환도 효율적으로 처리하는 방법입니다.
스트림은 Node.js만의 고유한 개념이 아닙니다. 수십 년 전에 유닉스 운영 체제에서 도입되었으며, 프로그램들은 파이프 연산자(|)를 통해 스트림을 주고받으며 서로 상호 작용할 수 있습니다.
예를 들어, 기존에는 프로그램에 파일을 읽으라고 지시하면 파일은 처음부터 끝까지 메모리에 읽혀지고 나서 처리됩니다.
스트림을 사용하면 파일을 조각별로 읽어들여 모든 내용을 메모리에 저장하지 않고도 처리할 수 있습니다. Node.js 스트림 모듈은 모든 스트리밍 API가 구축되는 토대를 제공합니다. 모든 스트림은 EventEmitter의 인스턴스입니다.
Node 스트림 유형에는 Readable, Writable, Duplex, Transform 이렇게 네 가지가 있습니다. 모든 스트림은 이벤트 이미터입니다.
Readable
: 데이터를 받을 수 있지만 보낼 수 없는 스트림입니다. Readable Stream에 데이터를 추가하면 소비자가 데이터를 읽기 시작할 때까지 버퍼링됩니다.
Writable
: 데이터를 보낼 수 있지만 받을 수 없는 스트림입니다.
Duplex
: 데이터를 주고받을 수 있는 스트림입니다. 기본적으로 Readable Stream과 Writable Stream의 조합입니다.
Transform
: Duplex와 유사하지만 입력을 변형하여 출력하는 스트림입니다.
Readable Stream은 큐와 highwatermark
를 통해 단순화된 방식으로 작동합니다. highwatermark
는 큐에 들어갈 수 있는 데이터의 양을 제한하지만 Readable Stream은 그 제한을 강제하지 않습니다.
데이터가 큐에 추가될 때마다 스트림은 클라이언트 코드에 피드백을 제공하여 highwatermark
에 도달했는지 여부를 알려주고 책임을 클라이언트로 이전합니다. 클라이언트 코드는 데이터를 계속 추가해서 큐를 오버플로우 시킬지 여부를 결정해야 합니다.
이 피드백은 데이터를 큐에 추가하는 메커니즘인 push
메서드를 통해 수신됩니다. push
메서드가 true를 반환하면 highwatermark
에 도달하지 않았음을 의미하며 계속 추가할 수 있습니다. push
메서드가 false를 반환하면 highwatermark
에 도달했음을 의미하며, 계속 추가할 수는 있지만 더 이상 추가하지 않는 것이 좋습니다.
이벤트
데이터가 큐에 추가되면 Readable Stream 내부에서 몇 가지 이벤트가 발생합니다.
on:readable
은 풀 모델(pull model)의 일부로, 이 스트림이 읽을 수 있는 상태이며 데이터를 읽을 수 있음을 알려줍니다. on:readable
이벤트를 수신 중인 경우, read
메서드를 호출할 수 있습니다. 클라이언트 코드가 read()
메서드를 호출하면 큐에서 데이터 청크를 꺼내고 이를 큐에서 제거합니다. 그런 다음, 클라이언트 코드는 큐에 더 이상 데이터가 없을 때까지 read
를 계속 호출할 수 있습니다. 큐를 비운 후, 클라이언트 코드는 on:readable
이벤트가 다시 발생할 때 데이터를 다시 가져올 수 있습니다.
on:read
이벤트는 푸시 모델의 일부입니다. 이 이벤트에서는 push
메서드를 사용하여 추가되는 모든 새로운 데이터 청크가 해당 리스너에게 동기적으로 전송됩니다. 즉, read
메서드를 호출할 필요가 없다는 뜻입니다. 데이터가 자동으로 도착합니다. 그러나 전송된 데이터는 큐에 저장되지 않는다는 또 다른 중요한 차이점이 있습니다. 큐는 활성된 on:read
이벤트 리스너가 없을 때만 채워집니다. 데이터가 흐르기만 하고 저장되지 않기 때문에 이를 "flow" 모드라고 합니다.
다른 이벤트는 스트림에서 더 이상 소비할 데이터가 없음을 알리는 end
이벤트입니다.
'close'
이벤트는 스트림과 기본 리소스(예: 파일 디스크립터)가 닫힐 때 발생합니다. 이 이벤트는 더 이상 이벤트와 계산이 발생하지 않음을 나타냅니다.
'error'
이벤트는 Readable
각 구현체에 의해 발생될 수 있습니다. 일반적으로, 기본 내부 오류로 인해 기본 스트림이 데이터를 생성할 수 없거나 스트림 구현이 유효하지 않은 데이터 청크를 푸시하려고 할 때 발생할 수 있습니다.
Readable Streams와 해당 이벤트를 이해하는 핵심은 Readable Streams가 단지 이벤트 이미터일 뿐이라는 것입니다. 이벤트 이미터와 마찬가지로 Readable Streams에는 비동기적으로 내장된 것이 없습니다. 어떤 스케줄링 메커니즘도 호출하지 않습니다. 따라서 순전히 동기적으로 작동하며 비동기적인 동작을 얻으려면 클라이언트 코드에서 read()
및 push()
메서드와 같은 다양한 이벤트 API를 호출할 시기를 지연시켜야 합니다. 방법을 알아봅시다!
Readable Stream을 생성할 때는 read
함수를 제공해야 합니다. 스트림은 버퍼가 가득 차지 않는 한 이 read
함수를 반복적으로 호출하지만, 한번 호출한 후에는 다시 호출하기 전에 push를 호출할 때까지 기다립니다. Readable Stream이 read
를 호출한 후에 버퍼가 가득 차지 않은 상태에서 push
를 동기적으로 호출하면 스트림은 push(null)
을 호출하여 스트림의 끝을 표시할 때까지 다시 read
를 동기적으로 호출합니다. 그렇지 않으면 push
호출을 다른 시점으로 지연시켜 스트림 read
호출을 효과적으로 비동기적으로 작동시킬 수 있습니다. 예를 들어, 일부 파일 IO 콜백이 반환될 때까지 기다릴 수 있습니다.
예시
const Stream = require('stream');
// Readable Stream 생성
const readableStream = new Stream.Readable();
// read 메서드 구현
readableStream._read = () => {};
const Stream = require('stream');
// read 메서드가 인라인된 Readable Stream을 생성합니다.
const readableStream = new Stream.Readable({
read() {},
});
// 동기식
readableStream.push('hi!');
readableStream.push('ho!');
// 비동기식
process.nextTick(() => {
readableStream.push('hi!');
readableStream.push('ho!');
})
// 비동기식
somePromise.then(() => {
readableStream.push('hi!');
readableStream.push('ho!');
})
Writable Streams도 비슷하게 작동합니다. Writable Stream을 생성할 때 _write(chunk, encoding, callback)
함수를 제공해야 하며, 외부 write(chunk)
함수도 제공됩니다. 외부 write
함수가 호출되면 내부 _write
함수가 호출됩니다.
내부 _write
함수는 반드시 콜백 인자 함수를 호출해야 합니다. 10개의 청크를 쓴다고 가정해 보겠습니다. 첫 번째 청크가 쓰여질 때, 내부 _write
함수가 콜백 함수를 호출하지 않으면 청크가 Writable Stream 내부 버퍼에 누적되는 일이 발생합니다. 콜백이 호출되면, 다음 청크를 가져와서 쓰고 버퍼를 비우게 됩니다. 즉, _write
함수가 콜백 함수를 동기적으로 호출하면 모든 쓰기 작업이 동기적으로 일어나는 반면, 콜백 호출을 지연시키면 모든 호출이 비동기적으로 이루어지며, 데이터는 highwatermark
에 도달할 때까지 내부 버퍼에 쌓이게 된다는 것을 의미합니다. 그 후에도, 버퍼 큐를 계속 증가시키는 것을 선택할 수 있습니다.
이벤트
Readable Stream과 마찬가지로 Writable Stream도 이벤트 리스너에게 유용한 알림을 제공하는 이벤트 이미터입니다.
on:drain
이벤트는 Writable 버퍼가 비워져 더 많은 쓰기가 가능함을 알립니다. write
함수 호출이 역압력을 나타내는 false
를 반환하면 스트림에 데이터를 다시 쓰기 적절할 때 'drain'
이벤트가 발생합니다.
'close'
이벤트는 스트림과 모든 하위 리소스(예: 파일 디스크립터)가 닫힐 때 발생합니다. 이 이벤트는 더 이상 이벤트가 발생하지 않으며 더 이상의 계산이 발생하지 않음을 나타냅니다.
'error'
이벤트는 데이터를 쓰거나 파이핑하는 동안 오류가 발생하면 이벤트가 발생합니다. 리스너 콜백은 호출될 때 단일 Error
인자를 전달받습니다.
'finish'
이벤트는 stream.end()
메서드가 호출된 후 모든 데이터가 하위 시스템으로 플러시되었을 때 발생합니다.
예시
const Stream = require('stream');
// Writable Stream 생성
const writableStream = new Stream.Writable();
// _write_ 메서드를 동기적으로 구현
writableStream._write = (chunk, encoding, callback) => {
console.log(chunk.toString());
// 콜백은 동기식으로 호출됨
callback();
};
// _write 메서드 구현
writableStream._write = (chunk, encoding, callback) => {
console.log(chunk.toString());
// 콜백은 동기식으로 호출됨
callback();
};
Duplex 스트림은 Readable 및 Writable 인터페이스를 모두 구현하는 스트림입니다. Duplex 스트림은 Readable Stream과 Writable Stream을 모두 포함하는 객체로 볼 수 있습니다. 이 두 개의 내부 객체는 서로 연결되어 있지 않으며 각각 독립적인 버퍼를 가지고 있습니다.
이들은 일부 이벤트를 공유합니다. 예를 들어, close
이벤트가 발생하면 두 스트림이 모두 닫힙니다. 하지만 Readable Stream의 readable
및 read
와 같은 스트림 유형별 이벤트나 Writable Stream의 drain
과 같은 이벤트는 여전히 독립적으로 발생합니다.
하지만 그렇다고 해서 Writable Stream에 데이터를 쓰면 Readable Stream에서 데이터를 사용할 수 있다는 의미하지는 않습니다. 이들은 독립적입니다.
Duplex Stream은 특히 소켓에 유용합니다.
Transform Stream은 입력과 관련된 출력을 가지는 Duplex Stream입니다. Duplex Stream과 달리, Writable Stream에 데이터 청크를 쓰면 그 데이터는 transform
함수 호출을 통해 Readable Stream으로 전달되고, 변환된 데이터를 가지고 readable 쪽에서 push
메서드를 호출합니다. Duplex와 마찬가지로 Readable 측과 Writable 측은 모두 각각의 highwatermark
를 가진 독립적인 버퍼를 가집니다.
예시
const { Transform } = require('stream');
// Transform Stream 생성
const transformStream = new Transform();
// _transform 메서드 구현
transformStream._transform = (chunk, encoding, callback) => {
transformStream.push(chunk.toString().toUpperCase());
callback();
};
Web Streams에서도 Node Streams에서 다뤘던 몇 가지 개념을 볼 수 있습니다. Readable, Writable, Duplex, Transform 스트림이 존재합니다. 하지만 중요한 차이점도 있습니다. Node Streams와 달리 Web Streams는 이벤트 이미터 기반이 아닌 프로미스 기반입니다. 또 다른 큰 차이점은 Node Streams는 동시에 여러 이벤트 리스너를 지원하며, 각 리스너는 데이터의 복사본을 받는 반면, Web Streams는 한 번에 하나의 리스너만 지원하며 이벤트가 없고, 순수하게 프로미스 기반입니다.
Node Streams가 완전히 동기적으로 작동하는 반면, Web Streams는 프로미스 기반이므로 완전히 비동기적으로 작동합니다. 이는 계속 작업이 항상 마이크로태스크 큐로 지연되기 때문에 발생합니다.
Node Streams가 Web Streams보다 훨씬 빠르지만, Web Streams는 Node Streams보다 훨씬 더 이식성이 뛰어납니다. 따라서 둘 중 하나를 선택할 때 이를 염두에 두어야 합니다.
이 작동 방식의 기본 구현을 이해하기 위해 Readable Stream을 자세히 분석해 보겠습니다.
개발자는 새로운 Readable 스트림을 생성할 때 underlayingSource 객체를 생성자에 전달할 수 있습니다. 이 객체 내에 pull(controller)
함수를 정의할 수 있으며, 이 함수는 첫 번째이자 유일한 매개변수로 Controller 객체를 받습니다. 이 함수는 클라이언트 코드가 사용자 정의 데이터 풀링 로직을 정의하여 스트림에 데이터를 추가할 수 있는 함수입니다. pull()
함수의 인자인 Controller 객체에는 enqueue()
함수가 포함되어 있습니다. 이는 Node Readable Stream의 push()
함수와 동일하며, 콜백 함수의 반환 데이터를 Readable Stream에 추가하는 데 사용됩니다.
reader 객체는 Web Readable Stream이 한 번에 하나의 리스너 또는 reader를 강제하는 요소입니다. 이 객체는 스트림의 getReader()
메서드를 통해 액세스할 수 있습니다. reader 객체 내부에는 read()
함수가 있으며, 이 함수는 스트림의 내부 큐에 있는 다음 청크에 접근할 수 있는 프로미스를 반환합니다.
스트림의 클라이언트 코드가 reader 객체에서 read()
를 호출하면 Controller 데이터 큐를 확인하고 데이터가 있는지 확인합니다. 큐에 데이터가 있는 경우, 큐에서 첫 번째 데이터 청크만 큐에서 제거하고 read 프로미스를 해결합니다. 큐에 데이터가 없는 경우, 스트림은 스트림 생성자 인자 객체에 정의된 pull(controller)
함수를 호출합니다. 그러면 pull(controller)
함수가 실행되고 실행의 일환으로 Controller 함수 enqueue()
를 호출하여 데이터를 스트림으로 추가합니다. 데이터를 추가한 후 pull
함수는 추가된 데이터를 사용하여 초기 read()
프로미스를 해결합니다.
pull(controller)
함수는 비동기 함수일 수 있고, 프로미스를 반환할 수도 있습니다. 따라서 한 번 호출되고 그 프로미스가 아직 이행되지 않은 상태에서 클라이언트 코드가 계속 read()
함수를 호출하면, 해당 read 프로미스는 read 큐에 누적됩니다. 이제 데이터 큐와 read 큐가 있으며, 새로운 데이터가 데이터 큐에 추가될 때마다 스트림 로직은 read 큐에 대기 중인 read 프로미스가 있는지 확인합니다. 만약 read 큐가 비어 있지 않으면, 첫 번째 대기 중인 프로미스가 큐에서 제거되고 데이터 큐에 추가된 데이터로 해결됩니다. 이 과정을 read 큐와 데이터 큐가 서로 균형을 맞추려고 하는 과정으로 볼 수 있습니다. 따라서 데이터 큐에 데이터가 있으면 read 큐에 read 프로미스가 축적되지 않습니다. 반대로 read 큐에 해결되지 않은 프로미스가 있으면 데이터 큐에 데이터가 축적되지 않습니다.
예시
const readableStream = new ReadableStream({
start(controller) {
/* … */
},
// 우리의 pull 함수
pull(controller) {
/* 데이터 가져오기 */
controller.enqueue(someData);
/* … */
},
cancel(reason) {
/* … */
},
});
Node.js 내부 메커니즘의 깊은 곳을 탐험하는 걸 두려워하지 않는 호기심 많은 개발자들은 그 깊은 곳에서 엄청난 아름다움을 발견하게 됩니다.
이 글에서는 Node.js 핵심 기여자들이 선택한 몇 가지 솔루션을 탐구하기 시작했을 뿐이며, 많은 솔루션들이 얼마나 우아하고 간단한지 알게 되었습니다.
Node.js와 브라우저 엔진의 내부에 대해 아직 배울 것이 많지만, 이 글과 함께 제공되는 자바스크립트 마라톤이 좋은 출발점이라고 생각합니다.
이 자리에 오신 여러분께 감사드리며, 열정과 창의력을 발휘할 수 있는 도구를 계속 탐구할 수 있는 영감을 얻으셨기를 바랍니다.
번역 감사합니다!
노드 작동방식은 볼때마다 어렵네요 ㅎㅎ..
오타 발견해서 알려드립니다!
underlayingSource -> underlying source