https://nodejs.org/ko/docs/guides/dont-block-the-event-loop
이 글이 나오게 된 배경
Express서버를 짜던 중 파일 입출력을 할 일이 생겼는데, 나는 지금까지 그래왔듯 자연스럽게 fs모듈을 import해서 fs.readFileSync를 사용했다.
사실 readFile 메서드도 있지만 이 친구는 비동기로 작동하기 때문에 Promise로 Wrapping하여 사용해야했고, 그게 귀찮다는 이유에서 readFileSync를 지속적으로 남발해왔다.
readFile을 사용하는 경우
const fs = require('fs');
fs.readFile('./file.json', (err, res) => {
console.log(res.toString());
});
콜백으로 파일을 읽어온 후의 명령을 정의해줘야하는데, 나는 콜백함수를 그다지 선호하지 않는다...
콜백함수를 사용하지 않으려면 아래의 두가지 방법이 있다.
1. readFile에서 Promise로 비동기 순서제어
const fs = require('fs');
const getFileData = () => {
return new Promise((resolve) => {
fs.readFile('./file.json', (err, res) => {
resolve(res.toString());
});
});
};
getFileData().then((res) => console.log(res));
2. readFileSync를 사용하여 동기로 구현
const fs = require('fs');
const result = fs.readFileSync('./file.json');
console.log(result.toString());
결과는 같지만 이게 작동하는 방식이 조금씩 다 다르다.
그중에서도 특히 절대 백엔드 서버에서 readFileSync를 사용해서는 안되는 이유를 말해보고자 한다.
Http모듈
나는 백엔드 서버를 express를 활용해서 구성했다.
그리고 express는 nodejs의 http모듈을 활용해서 만들어졌다.
그럼 nodejs의 http모듈을 살펴볼 필요가 있다.
https://nodejs.org/ko/docs/guides/anatomy-of-an-http-transaction
오, 놀랍게도 Server객체는 EventEmitter라고 한다.
이를 통해서 http요청이 오면 이벤트가 발생한다는 사실을 알 수 있다.
그럼 또 event Emitter를 안살펴 볼 수 없다.
Event emitter
https://nodejs.dev/en/learn/the-nodejs-event-emitter/
위 글을 참고하면 이벤트 객체를 이렇게 만들 수 있다.
const EventEmitter = require('events');
const eventEmitter = new EventEmitter();
eventEmitter.on('start', () => {
console.log('started');
});
그리고 .on메서드를 통해서 start라는 이벤트를 등록한다.
등록했으면 이걸 발생시켜야겠지?
eventEmitter.emit('start');
이렇게 하면 이벤트가 발생된다.
첫번째 의문점
그런데, 과연 이게 순차적으로 처리될까?라는 의문이 생겼다.
eventEmitter는 동기로 작동하는가?(https://nodejs.org/api/events.html#asynchronous-vs-synchronous)
그렇다고 한다.(위 링크의 캡처본이다.)
이벤트 콜백함수는 동기로 작동한다.
https://github.com/nodejs/help/issues/3151
위의 링크의 아래 코드를 보면 동기적으로 작동하는 것임을 알 수 있다.
이벤트 이미터와 이벤트루프는 아무 상관이 없다.위 링크를 참고하자.
다시 http모듈로 돌아오면, Server 객체는 EventEmitter이므로, 요청이 들어올때 이벤트를 발생시킨다.
공식문서(https://nodejs.org/ko/docs/guides/anatomy-of-an-http-transaction)를 함께 보자.
request이벤트는 요청이 올때마다 새로운 콜백함수를 실행시킨다.
두번째 의문점
request이벤트나 on메서드가 오버라이딩되어 혹시 특별한 비동기 함수가 된건 아닐까? readFile처럼 비동기함수처럼 작동하는게 아닐까? 라는 의문이 생겼다.
nodeJS레포를 보자.
https://github.com/nodejs/node/blob/main/lib/_http_server.js
function Server(options, requestListener) { //콜백함수로 request Listener를 받는다.
if (!(this instanceof Server)) return new Server(options, requestListener);
...
net.Server.call(
this,
{ allowHalfOpen: true, noDelay: options.noDelay ?? true,
keepAlive: options.keepAlive,
keepAliveInitialDelay: options.keepAliveInitialDelay,
highWaterMark: options.highWaterMark }); //net Function의 Server함수를 실행시킨다.
if (requestListener) { //이벤트 리스너가 있으면
this.on('request', requestListener); //등록한다.
} //동기임을 추측할 수 있다.
this.on('connection', connectionListener);
this.on('listening', setupConnectionsTracking);
this.timeout = 0;
this.maxHeadersCount = null;
this.maxRequestsPerSocket = 0;
this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders);
}
혹시 모르니 net.Server 함수도 확인해보자.
https://github.com/nodejs/node/blob/main/lib/net.js
function Server(options, connectionListener) {
if (!(this instanceof Server))
return new Server(options, connectionListener);
EventEmitter.call(this);
if (typeof options === 'function') {
...
this.on('connection', connectionListener);
} else if (options == null || typeof options === 'object') {
options = { ...options };
if (typeof connectionListener === 'function') {
this.on('connection', connectionListener); //connection 이벤트등록
}
} else {
throw new ERR_INVALID_ARG_TYPE('options', 'Object', options);
}
...
}
ObjectSetPrototypeOf(Server.prototype, EventEmitter.prototype); //프로토타입 설정
ObjectSetPrototypeOf(Server, EventEmitter);
아무리 봐도 on메서드를 오버라이딩하는 코드를 찾아볼 수 없다.
따라서 request 이벤트는 동기로 작동한다.
그러므로 요청1과 요청2가 차례로 들어온경우 이렇게 처리될 것이다.
요청1 | 요청1처리 | 요청1응답 | ||
요청2 | 요청2 대기 | 요청2 처리 | 요청2응답 |
근데 만약에 하나에 요청에 대해 엄청나게 큰 작업을 한다면 어떤 일이 발생하게 될까?
요청1 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1처리 | 요청1응답 |
요청2 | 요청2대기 | 요청2대기 | 요청2대기 | 요청2대기 | 요청2대기 | 요청2대기 | 요청2대기 |
싱글스레드에서 수행되므로 요청에 대한 응답시간이 길어지게 된다.
하나의 요청에 응답시간이 길어진다는것은 다음, 다음, 또 그 다음요청에도 영향을 미치게된다.
만약 동시에 3가지 요청이 들어왔다고 생각해보자.
요청 하나가 들어와서 응답하기까지 3초라고 했을때,
요청1에서는 3초뒤에 응답을 받을것이고
요청2에서는 요청1처리에 걸릴 3초를 기다렸다가 요청2에 대한 3초 처리 후 6초에 응답을 받을 것이다.
마지막 요청은 무려 9초뒤에 받게된다.
근데 이렇게 오래 걸릴만한 작업이 무엇이 있을까?
1. file I/O
2. 네트워크 작업
3. 엄청난 cpu연산...!
자 드디어 Blocking과 Non-blocking이 나올차례다.
그전에 마지막으로 이벤트루프에 대한 오해를 풀고가자.
이벤트 루프에 대한 오해
https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
보통 이벤트루프를 검색하면 이런 그림을 찾아볼 수 있다.
가장 큰 오해가 이벤트루프가 여러개의 스레드에서 실행된다는 오해이다.
하나의 스레드가 js를 실행시키고, 하나의 스레드가 이벤트루프를 돌리는게 아니다. 하나의 스레드가 둘다 한다.
다만 이벤트루프가 libuv에 구현되어있을뿐이다. 실행은 하나의 스레드에서 된다.
Blocking
블로킹은 기다린다. 어떤 일을 할때까지 아무 작업도 할 수 없다.
C언어의 파일 입출력이 대표적인 블로킹 작업이라고 한다.
이벤트 루프로 돌아와보자. js코드 실행과 이벤트루프는 같은 스레드에서 이뤄진다.
하나의 작업이 번갈아가며 수행되는 것이다.
그런데 js실행하는 부분이 끝나지 않는다면?
이벤트루프는 콜스택에 콜백함수를 넣어주지 못할 것이다.
실험
const EventEmitter = require('events');
const event = new EventEmitter();
setTimeout(() => console.log('1초지남'), 1000);
event.on('r', () => {
let sum = 0;
for (let i = 0; i < 1000000000; i++) sum += i;
console.log(sum);
});
for (let i = 0; i < 100; i++) {
console.time()
event.emit('r');
console.timeEnd()
}
이벤트를 동기적으로 100회실행시키는데, 하나의 이벤트는 엄청나게 큰 수를 구하는 작업을 한다.
끝나자마자 다음 이벤트가 수행되므로, 콜스택이 비지않고 이벤트루프는 1초뒤에 console.log를 찍어주지 못할 것이다.
결과
예상대로 timeout에 의한 console이 찍히지 않음을 확인할 수 있다.
readFileSync
드디어 readFileSync이야기를 다시 꺼낼 수 있게됐다. 여기까지 쓰는데 시간이 정말 엄청 많이 들었다.
readFileSync는 위의 예시처럼 극단적으로 cpu를 차지하는 작업은 아니다. 하지만 file I/O는 값비싼 작업 중 하나이다.
readFileSync는 파일작업이 다 될때까지 기다렸다가 코드를 진행시키기때문에 콜스택을 비우지 않는다.
그래서 이벤트 루프가 잠시동안 멈추게되고,
이것이 결과적으로 서버를 느려지게 만들 것이다.
논블로킹
논블로킹은 콜스택을 차지하지 않게 하는 것이다. 어떻게 그게 가능하냐?
다른 사람한테 맡기면된다. nodejs는 libuv라는 라이브러리를 사용한다.
libuv는 기본적으로 4개의 스레드를 가지고 있는데, I/O작업들을 이 스레드 풀에서 진행되게 한다.
이를 통해서 메인스레드의 콜스택을 바로 비워내고, 이벤트 루프가 돌면서 해당 작업이 완료되면 콜스택에 콜백함수를 넣어줄 수 있게 되는 것이다.
그럼 async/await은 blocking인가요?
아니오.
async await은 또다른 async function에서 await을 한다.
즉, 비동기함수의 순서를 제어하는 것이다.
위의 실험 코드를 떠올리며 이번에는 다른 코드를 살펴보자
setInterval(() => console.log(1), 500);
const foo = async () => {
return await new Promise((resolve) => {
setTimeout(() => {
console.log('1초지남');
resolve();
}, 1000);
});
};
foo();
async/await가 blocking을 한다고 가정하면 foo함수가 await하고 있으므로, setInterval에 의한 console은 출력되지 않아야한다.
결과를 보자.
잘 나오는 모습을 확인할 수 있다.
이벤트 루프가 막히지 않는 것이다.
nodejs는 싱글스레드로 돌아가므로 절대 이벤트루프를 막아서는 안된다.