Async/Await 주의해서 다루기!

litien·2020년 4월 15일
3

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에 출력하는 간단한 함수이다. logPrintNumprintNum 함수를 실행하기 전 후에 logging을 추가적으로 출력하는 함수이다. 위 코드를 실행한 결과는 아래와 같다.


Enter logPrintNum 1
Exit  logPrintNum 1
1

출력결과는 코드의 실행순서와 일치하지 않는다. 이 부분이 Non-Blocking I/O가 어렵게 느껴지는 가장 근본적인 이유이다. 왜 그런지 간략히 살펴보자. 위 코드의 대략적인 흐름은 다음과 같다.

  1. logPrintNum의 첫번째 console.log가 실행되어 Enter logPrintNum 1가 출력된다.
  2. 다음라인인 printNum이 호출된다.
    2-1. printNumb의 setTimeOut이 호출된다.
    2-2. setTimeOut은 타이머 이벤트를 브라우저 또는 Node API에 요청한다.
  3. logPrintNum의 마지막 라인 console.log가 실행되고 Exit logPrintNum 1가 출력된다.

setTimeOut의 인자로 넘겨진 콜백함수는 곧바로 실행되지 않고, 2-2번에서 요청한 타이머 이벤트가 완료된 뒤에 Task Queue에 삽입한다. 이후 Call Stack이 비었을 때 Event Loop가 하나씩 Task Queue에서 꺼내어 콜백함수가 실행된다.

이러한 비동기적 동작방식때문에 0초에 딜레이를 줬음에도 불구하고 코드 순서와 다르게 결과가 출력된다.

Async, Await

ES7에서는 비동기적 제어흐름을 동기적으로 제어 할 수 있도록 async, await 키워드를 지원한다.

async, await를 이해하기 위해서는 promise에 대한 개념이 선행되어야 하는데, 이 부분은 이번 포스팅에서 설명하지 않는다.

async 키워드는 함수 앞에 붙는 키워드로, 해당 함수가 비동기 함수임을 의미한다. async함수는 항상 Promise를 리턴한다.

await 키워드는 async 함수 내부에서만 사용 할 수 있으며, Promise가 resolved 또는 reject될 때까지 기다린다. 이를 통해 비동기적 흐름을 동기적인 흐름으로 제어할 수 있다.

Await은 Promise에 대해서만 유효하다!

위 코드를 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

map에서의 async/await

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 객체가 리턴된다.

Reference

자바스크립트와 이벤트 루프
[javascript] Promise, async, await

profile
어려운 문제를 함께 풀어가는 것을 좋아합니다.

0개의 댓글