Node Js는 I/O 작업을 Non-Blocking 형태로 처리한다. 때문에 순차적 코드 작성에 익숙한 프로그래머들이 Node Js의 I/O 처리에서 어려움을 겪는 경우가 흔하게 발생한다. 이번 포스팅에서는 Non-Blocking을 다루면서 실수하기 쉬운 포인트들을 정리해보자.
다음과 같은 코드를 보자
let printNum = (number, delaySec) => {
setTimeout(() => console.log(number), delaySec); // i/o 작업을 대신한다.
};
let logPrintNum = (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
printNum
함수는 delaySec
초 뒤에 number
를 console에 출력하는 간단한 함수이다. logPrintNum
은 printNum
함수를 실행하기 전 후에 logging을 추가적으로 출력하는 함수이다. 위 코드를 실행한 결과는 아래와 같다.
Enter logPrintNum 1
Exit logPrintNum 1
1
출력결과는 코드의 실행순서와 일치하지 않는다. 이 부분이 Non-Blocking I/O가 어렵게 느껴지는 가장 근본적인 이유이다. 왜 그런지 간략히 살펴보자. 위 코드의 대략적인 흐름은 다음과 같다.
setTimeOut의 인자로 넘겨진 콜백함수는 곧바로 실행되지 않고, 2-2번에서 요청한 타이머 이벤트가 완료된 뒤에 Task Queue
에 삽입한다. 이후 Call Stack
이 비었을 때 Event Loop
가 하나씩 Task Queue
에서 꺼내어 콜백함수가 실행된다.
이러한 비동기적 동작방식때문에 0초에 딜레이를 줬음에도 불구하고 코드 순서와 다르게 결과가 출력된다.
ES7에서는 비동기적 제어흐름을 동기적으로 제어 할 수 있도록 async
, await
키워드를 지원한다.
async, await
를 이해하기 위해서는promise
에 대한 개념이 선행되어야 하는데, 이 부분은 이번 포스팅에서 설명하지 않는다.
async
키워드는 함수 앞에 붙는 키워드로, 해당 함수가 비동기 함수임을 의미한다. async
함수는 항상 Promise
를 리턴한다.
await
키워드는 async
함수 내부에서만 사용 할 수 있으며, Promise가 resolved 또는 reject될 때까지 기다린다. 이를 통해 비동기적 흐름을 동기적인 흐름으로 제어할 수 있다.
위 코드를 async,await 키워드를 이용해 코드의 순서와 출력이 일치하도록 고쳐보자
let printNum = async (number, delaySec) => {
await setTimeout(() => console.log(number), delaySec); // i/o 작업을 대신한다.
};
let logPrintNum = async (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
await printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
위 코드는 과연 우리가 의도한대로 코드의 순서와 일치하는 결과가 출력이 될까?
Enter logPrintNum 1
Exit logPrintNum 1
1
여전히 코드의 흐름과 맞지 않는 결과가 출력 됬다. 이유는 setTimeOut이 Promise를 리턴하고 하고 있지 않기 때문이다. Await은 오직 Promise에 대해서만 유효하다. 우리가 의도한대로 순서대로 결과를 출력하기 위해서 아래와 같이 수정해보자.
let printNum = (number, delaySec) => {
return new Promise((resolve) =>
setTimeout(() => {
console.log(number);
resolve();
}, delaySec));
};
let logPrintNum = async (number, delaySec) => {
console.log(`Enter logPrintNum ${number}`);
await printNum(number, delaySec);
console.log(`Exit logPrintNum ${number}`);
};
logPrintNum(1, 0);
Promise는 생성자로 콜백함수를 받게되는데, 해당 콜백함수의 첫번째 인자는 resolve()이다. resolve()를 호출함으로서 해당 Promise가 이행되었음을 알려준다.
실행결과는 다음과 같다.
Enter logPrintNum 1
1
Exit logPrintNum 1
let printNum = (number, delaySec) => {
return new Promise((resolve) =>
setTimeout(() => {
console.log(number);
resolve();
}, delaySec));
};
let inner = async (number) => {
console.log(`Enter Inner ${number}`);
await printNum(number);
console.log(`Exit Inner ${number}`);
};
let outer = (numbers) => {
numbers.map(async printNum => {
console.log(`Enter Outer ${printNum}`);
await inner(printNum);
console.log(`Exit Outer ${printNum}`);
});
};
let data = [1,2];
outer(data);
위 코드의 실행결과는 다음과 같다.
Enter Outer 1
Enter Inner 1
Enter Outer 2
Enter Inner 2
1
Exit Inner 1
Exit Outer 1
2
Exit Inner 2
Exit Outer 2
실행결과를 볼면 1에 대한 처리가 다 끝나지 않았음에도 2에 대한 처리가 시작되는 걸 볼 수 있다. 이는 map은 await를 기다리지 않기 때문이다. map은 파라미터로 받은 함수를 요소에 따라 순회하면서 실행하는데, 이때 await에 대해서 block되지 않고 다음 요소에 대한 수행을 실행한다.
forEacth도 마찬가지다.
이외에도 또 한가지 조심해야 될점은 map의 파라미터로 async 함수를 넣어주게 되면, Promise 객체가 리턴된다.