자바스크립트가 싱글 스레드인 사실은 프론트엔드 개발자라면 모두가 알고 있는 사실이다.
그럼에도 멀티스레드처럼 작동할 수 있는 이유는 Event loop와 Web API의 존재로 이벤트를 병렬적으로 처리할 수 있기 때문이라고 배웠다.
NodeJS도 자바스크립트이기 때문에 어느 정도 같지만, Nodejs만이 갖는 lifecycle이 존재하며 이벤트 루프를 제외하고 병렬적으로 작동할 수 있는 부분이 추가적으로 존재한다.
Nodejs의 lifecycle을 통해 이밴트를 어떻게 처리하는지 짚어보고 그 뒤 데이터스트리밍을 통해 '가만히 앉아 기다리지 않는' Nodejs를 알아보자.
먼저 Nodejs는 당연히 자바스크립트이기에 위에서부터 아래로 작성된 전체 코드를 분석(parse)하고 코드에 있는 변수와 함수를 등록(변수 객체화)한다.
그 뒤 중요한 것은 일반 자바스크립트는 함수의 실행문을 만나 함수 실행 컨텍스트를 생성, 실행, 종료하면서 콜 스텍이 비워진 뒤 전역 컨텍스트가 종료되면서 생명주기가 끝나지만, Nodejs는 그 뒤 프로그램이 종료되지 않고 이벤트를 기다린다.
Nodejs가 관리하는 이벤트 루프는, 작업이 남아 있는 한 계속해서 작동하는 루프 프로세스로 이벤트 리스너가 존재하는 한 계속 작동하기 때문이다.
이를 Event Driven Architecture라 한다.
이벤트 루프는 빨리 끝낼 수 있는 코드를 포함한 콜백만을 다루며 파일 시스템 연산 등의 오래 걸리는 연산은 워커풀에 보내버린다.
무거운 작업을 담당하는 워커풀은 자바스크립트 코드로부터 완전히 분리되어
다른 여러 스레드(OS의 thread)에서 작동할 수 있으며, 앱을 실행하는 운영 체제와 깊은 연관이 있다.
파일과 관련된 작업을 할 때는 워커풀의 워커가 코드, http-request, event loop와 분리된 상태에서 작업을 수행한다.
작업을 마친 뒤 워커풀은 nodejs에 완료되었다는 신호를 보내고, 실행 종료 콜백이 이벤트 루프로 돌아가 실행된다.
워커풀은 마치 setTimeout, setInterval의 타이머가 종료된 뒤 내부 콜백함수가 이벤트 루프로 인하여 실행되는 것과 같은 것이다.
setTimeout, setInterval, 파일 연산 등은 blocking / long-taking operation이다.
이벤트 루프는 loop, 즉 반복이며 반복하는 과정에 순서가 있다.
Timers - Pending Callbacks - Poll - Check - close Callbacks - exit 로 이어지며 반복한다.
먼저 Timers에서 setTimeout, setInterval의 타이머를 확인하고, 실행해야 하는 callback을 실행한다.
이벤트 루프는 콜백을 모두 처리하게되면 Poll 단계에 진입하기 이전에 새로운 I/O 이벤트를 찾는다.
I/O는 Input/Output을 의미하며 파일연산,네트워크 연산등이 포함된다.
최대한 해당 이벤트의 콜백을 빨리 실행하도록 하기 위한 목적이며, 콜백이 너무 많이 밀려있다면 루프를 이어가는 대신 남은 콜백을 다음 루프에서 실행하도록 미룬다.
이 과정이 pending callbacks이다
또한 이벤트 루프는 멍청하게 계속 돌고만 있지는 않는다.
지난 루프에서 확인된 Timers(setTimeout, setInterval)의 timer가 종료되었다면, 해당 콜백을 실행하기 위해 다시 돌아가 해당 콜백을 실행시킨다.
즉 반복을 이어가지 않고 다시 전 단계로 돌아갈 수도 있다는 말이다.
Check 단계에서는 setImmediate라는 메서드의 사용 여부를 확인하는데 현재 주기가 끝나거나 적어도 현재 루프에 열린 콜백을 처리한 후에 일어나게 된다.
그 뒤 Close Callbacks를 실행하는 단계에 진입한다.
네트워크 연결, 파일 디스크립터, 서버 등과 같이 자원을 사용하는 NodeJs는 이러한 자원이 어플리케이션에서 더 이상 필요하지 않을 때 종료(close)하게 되는데 이 단계에서 해당 자원의 종료 여부를 감지하고, 종료 이벤트(close event)와 연결된 콜백 함수를 실행한다.
루프의 모든 과정을 수행한 뒤 Nodejs는 내부적으로 열린 이벤트 리스너를 추적하며 references를 숫자로 센다 refs == 0
새로운 콜백이 등록되거나 새로운 이벤트 리스너가 등록될 때마다 1씩 늘어난다.
특히 서버 환경에서 createServer를 통해 서버를 생성하고 listen을 통해 요청을 듣는 이벤트들은 기본적으로 절대 끝나지 않는 이벤트이기 때문에 refs는 보통 1을 유지한다.
const server = http.createServer((req, res) => {
console.log('waiting event ....')
})
위 createServer에서 콜백함수로 받는 이벤트 리스너를 제거하지 않았기에 서버는 종료되지 않고 계속 작동한다.
말 그대로 데이터의 흐름이다. 한 지점에서 다른 지점으로 이동하는 데이터의 흐름이 곧 데이터 스트림이다.
다양한 곳에서 데이터 스트림 개념이 사용되겠지만, http 통신의 과정에서 데이터 스트림을 이해해보자.
요청(Request) 데이터 처리: 클라이언트가 HTTP 요청을 서버로 보낼 때, 요청에 표현된 리소스는 Node.js의 Buffer로 수신된다.
버퍼를 통해 전체 데이터를 수신하지 않고, 일정량 이상의 데이터가 쌓인다면, 일부분부터 데이터를 처리하여 원하는 형식으로 파싱하거나 저장한다.
응답(Response) 데이터 처리: Res에 데이터를 보내야 할 때에도 Buffer로 생성하고 필요에 따라서 Buffer를 사용하여 데이터를 조작하거나 인코딩할 수 있다.
파일 업로드: HTTP 통신을 통해 클라이언트가 서버로 파일을 업로드할 때, 업로드된 파일은 Node.js의 Buffer로 처리될 수 있다.
데이터 스트리밍: 데이터를 스트리밍으로 서버에서 클라이언트로 전송할 때 Buffer를 사용하여 데이터를 조각조각으로 전송할 수 있다.
Node.js의 Buffer는 원시적인 형태의 바이너리 데이터를 다루는데 용이하며, 특히 네트워크 통신이나 파일 처리와 같이 바이너리 데이터를 다뤄야 하는 상황에서 유용하게 사용된다.
Buffer를 활용하여 데이터를 효과적으로 처리하고 변환하는 것은 Node.js에서 HTTP 통신을 구현하는데 중요한 역할을 한다.
버퍼라는 개념이 자주 등장하는 것 같다.
그렇다면 버퍼는 무엇일까?
Buffer는 임시 데이터 저장 공간이다.
클라이언트와 서버가 데이터를 주고 받을 때, 만약 서버에서 데이터를 처리하는 시간보다 클라이언트에서 서버로 보내는 데이터의 도착시간이 더 빠르다면, 초과된 데이터는 계속해서 쌓여 어디에선가 처리되기를 기다리고 있어야한다.
또한 반대로, 서버에서 데이터를 처리하는 시간이 클라이언트에서 서버로 보내는 데이터의 도착 시간보다 더 빠르다면, 먼저 도착한 데이터는 처리되기 전에 어느정도의 데이터량이 쌓일때까지 기다려야한다.
두가지 모두 데이터가 쌓인다는 점이 공통점인데, 이때 사용하는 임시 공간이 바로 버퍼다.
데이터가 어느정도 쌓일때까지 기다려야 하는 이유
1. 데이터의 완전성: 데이터가 아직 모두 도착하지 않은 상태에서 처리를 시작하면, 처리해야 할 데이터가 완전하지 않을 수 있다. 예를 들어, 큰 파일을 전송하는 경우 파일의 일부만 도착한 상태에서 처리를 시작하면, 조각마다 올바르지 않은 결과가 나올 수 있기 때문이다.
2. 불완전한 작업: 데이터는 곧 정보다. 데이터의 양이 부족하면 작업을 수행하기에 충분한 정보를 얻기 어려우며 효율성도 떨어진다
Buffer를 이해하기 위한 비유
스트리밍과 버퍼의 과정을 버스정류장에 빗대어 설명할 수 있습니다.
어떤 버스정류장에서는, 어느정도 이상의 승객이 모이지 않거나, 출발시간 전일때에는 출발하지 않습니다.
그리고, 승객들은 버스정류장에 도착하는 속도도 도착하는 시간도 각각 다릅니다.
승객 자기자신도 버스도 그 어느 누구도 버스정류장에 승객이 도착하는걸 마음대로 제어할 수 없습니다. (승객이 버스정류장에 도착하는 것도 결국에는 승객 스스로 제어하는게 아니라 환경적 요인에 달려있다는 말입니다.)
어쨌든 일찍 도착한 승객은 버스가 출발하기 전까지는 버스정류장에서 기다려야 합니다. 반대로 승객이 도착했을때, 버스가 이미 문을닫고 출발하기 직전상태 이거나 버스가 이미 출발한 상황일때에는 다음 버스를 기다려야 합니다.
어떤 경우든지, 승객이 기다리는 위치가 있습니다. 바로 버스정류장입니다. 바로 그게 Node.js에서의 Buffer입니다! Node.js는 데이터가 도착하는 시간이나 전송되는 속도를 제어할 수는 없습니다. Node.js가 결정할 수 있는건 언제 데이터를 내보내느냐 입니다. 버스를 언제 출발시킬 수 있는 제어권이 있는 것과 동일합니다. 아직 데이터를 내보낼 때가 아니면, Node.js는 데이터들을 일종의 대기영역인 RAM에 작은 영역인 buffer에 데이터를 넣어놓습니다.
일상생활에서 버퍼작동을 볼 수 있는 예로는 온라인 영상을 시청할 때 입니다. 유튜브를 보는 순간을 상상해보세요. 우리의 인터넷 연결상태가 매우 좋을때에는 영상 스트리밍이 끝날때까지 버퍼를 채우고 데이터가 처리될 수 있게 빠르게 내보내고, 다시 버퍼를 채우고 빠르게 내보내고를 반복합니다.
그러나 인터넷 연결상태가 좋지 못할때에는 첫번째 데이터셋을 처리하고 나서, 영상플레이어는 로딩 아이콘을 띠우면서 ‘buffering’이라는 텍스트를 보여줄 것입니다. 이것은 데이터가 더 모이고 도착할때 까지 기다린다는 의미입니다. 만약에 버퍼가 채워지고 데이터가 처리되면, 영상이 다시 보여지게 될 것입니다. 영상을 보여주는 동안에도, 계속해서 다음 데이터가 도착하고, 버퍼에 채워질 것입니다.
이것이 바로 버퍼입니다.
참조: https://tk-one.github.io/2018/08/28/nodejs-buffer/
버퍼는 바이너리 데이터 스트림을 직접 다루기 위해 Node.js의 API에 추가되었다.
버퍼를 사용함으로써 바이너리 데이터를 일정한 크기로 저장할 수 있다.
버퍼를 생성하는 방법은
1. 버퍼의 길이를 임의로 바이트를 설정하여 생성하는 방법
2. 데이터를 입력함과 동시에 버퍼를 생성하는 방법이 있다.
임의로 바이트를 설정하여 생성된 버퍼는 이후 크기를 변경할 수 없으며
데이터 입력과 동시에 버퍼를 생성하면 데이터의 크기만큼의 버퍼가 할당된다.
바이너리 데이터 (binary data)
컴퓨터는 모든 데이터를 이진수로 저장하고 표현하는데, 이진수는 단순히 0과 1의 연속이다.
10011010
위 예시의 이진수에 1 또는 0으로 되어있는 자리를 비트(bit)라고하는데, bit는 Binary digIT의 약어이다.
버퍼는 임의로 바이트를 설정하여 생성하였기 때문에 이후 크기를 변경할 수 없다.
const data = 'bring on data, buffer!'
const buffer = Buffer.alloc(22);
console.log(buffer);
// <Buffer 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>
const assignDataOnCreatedBuffer = buffer.write(data,'utf8');
console.log(buffer)
// <Buffer 62 72 69 6e 67 20 6f 6e 20 6d 65 2c 20 62 75 66 66 65 72 21>
console.log(`size : ${bufferOnData} byte, content : ${buffer.toString()}`);
// size : 22 byte, content : bring on data, buffer!
const buffer = Buffer.from('create buffer with data','utf8');
console.log(buffer); // <Buffer 63 72 65 61 74 65 20 62 75 66 66 65 72 20 77 69 74 68 20 64 61 74 61>
console.log(buffer.toString()); // create buffer with data
console.log(buffer.byteLength); // 23
차이점은 생성된 버퍼에 데이터를 할당하는 방법에는 alloc메서드를, 데이터 입력과 버퍼 생성을 동시에 진행할 때에는 from 메서드를 사용한다.
const server = http.createServer((req, res) => {
const url = req.url;
const method = req.method;
if(url === '/') {
res.write('<html>');
res.write('<head><title>Enter Message</title></head>');
res.write('<body><form action="/message" method="POST"><input type="text" name="message" /><button type="submit">Send</button></form></body>');
res.write('</html>');
return res.end();
}
if(url === '/message' && method === 'POST') {
const body = [];
req.on('data', (chunk) => {
console.log(chunk); // chunk로 분할된 데이터(Buffer)를 받는다
body.push(chunk);
}) // 데이터 이벤트가 발생할 때 버퍼가 도움을 준다.
req.on('end', () => {
const parsedBody = Buffer.concat(body).toString(); // 담은 buffer들을 합친다.
const message = parsedBody.split('=')[1]; // message=hello
fs.writeFileSync('message.txt', message); // blocking-method
})
res.statusCode = 302;
res.setHeader('Location', '/');
return res.end();
}
});
참조 :
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick