Devin.KR

Node.js · 기본

Node.js로 만드는 작은 API

Node.js 비동기와 이벤트 루프 - Promise async await 실행 순서 체감하기 (Node.js API 2단원)

코드 순서와 실행 순서가 다른 이유, 동기 계산이 서버 전체를 멈추는 이유, Promise.all 과 allSettled, await 누락과 처리되지 않은 거부를 실행 결과로 확인한다.

개발자 · 원고 갱신

이 단원에서 배우는 것

API 서버가 하는 일의 대부분은 계산이 아니라 기다림이다. 파일을 다 읽을 때까지, 데이터베이스가 답할 때까지, 다른 서버가 응답할 때까지 기다린다. Node.js는 스레드 하나로 이 기다림 수천 개를 동시에 굴린다. 그 방법이 이벤트 루프이고, 코드에서는 Promiseasync/await로 드러난다.

  • 코드를 적은 순서와 실행 순서가 달라지는 경우를 직접 출력해 본다.
  • 무거운 동기 계산 하나가 서버 전체를 멈추는 이유를 시간으로 잰다.
  • 기다리는 일 여러 개를 차례로 할 때와 한꺼번에 할 때의 차이, Promise.allPromise.allSettled의 차이를 익힌다.
  • await를 빠뜨렸을 때, 거부된 Promise를 아무도 받지 않았을 때 무슨 일이 생기는지 본다.

문제 상황

회원 정보, 주문 목록, 재고 세 가지를 각각 다른 곳에서 받아 화면 하나를 만드는 API가 있다. 셋 다 200ms씩 걸린다. 그런데 응답이 600ms 넘게 걸린다. 게다가 가끔 관리자가 "통계 다시 계산" 버튼을 누르면 그동안 다른 사용자 요청이 전부 느려진다. 그리고 어느 날 서버가 로그 한 줄만 남기고 조용히 꺼졌다.

세 증상의 원인은 모두 이 단원에 있다. 예제는 ch02 폴더에 저장한다.

완성 코드

1. 실행 순서: order.mjs

// order.mjs — 코드를 적은 순서와 실행되는 순서가 다를 때
console.log('1. 동기 코드 시작');

setTimeout(() => console.log('5. 타이머(0ms) 콜백'), 0);

Promise.resolve().then(() => console.log('3. Promise then 콜백'));

queueMicrotask(() => console.log('4. queueMicrotask 콜백'));

console.log('2. 동기 코드 끝');

2. 이벤트 루프 막기: block.mjs

// block.mjs — 무거운 동기 계산이 타이머를 얼마나 늦추는지 잰다
const start = Date.now();
const around = (ms) => Math.round(ms / 100) * 100;   // 100ms 단위로 반올림해서 본다

setTimeout(() => {
  console.log(`100ms 뒤에 실행하려던 타이머가 약 ${around(Date.now() - start)}ms 뒤에 실행됨`);
}, 100);

// 0.5초 동안 CPU 를 붙잡고 놓아주지 않는 반복문
while (Date.now() - start < 500) {
  // 아무것도 하지 않고 시간만 확인한다
}
console.log(`반복문 끝: 약 ${around(Date.now() - start)}ms`);

3. 차례로와 한꺼번에: parallel.mjs

// parallel.mjs — 기다리는 일 세 개를 차례로 할 때와 한꺼번에 할 때
import { setTimeout as sleep } from 'node:timers/promises';

// 외부 서버에 묻는 일을 흉내 낸다. ms 만큼 기다린 뒤 결과를 준다.
async function ask(name, ms, fail = false) {
  await sleep(ms);
  if (fail) throw new Error(`${name} 응답 없음`);
  return `${name} 결과`;
}

const around = (ms) => Math.round(ms / 100) * 100;

let t = Date.now();
const a = await ask('회원', 200);
const b = await ask('주문', 200);
const c = await ask('재고', 200);
console.log('차례로:', [a, b, c], `약 ${around(Date.now() - t)}ms`);

t = Date.now();
const all = await Promise.all([ask('회원', 200), ask('주문', 200), ask('재고', 200)]);
console.log('한꺼번에:', all, `약 ${around(Date.now() - t)}ms`);

t = Date.now();
try {
  await Promise.all([ask('회원', 200), ask('주문', 100, true), ask('재고', 200)]);
} catch (err) {
  console.log('Promise.all 실패:', err.message, `약 ${around(Date.now() - t)}ms`);
}

const settled = await Promise.allSettled([ask('회원', 100), ask('주문', 100, true)]);
for (const r of settled) {
  console.log('allSettled:', r.status, r.status === 'fulfilled' ? r.value : r.reason.message);
}

4. 흔한 실수 두 가지: forgot-await.mjs, unhandled.mjs

// forgot-await.mjs — await 를 빠뜨렸을 때 생기는 일
import { setTimeout as sleep } from 'node:timers/promises';

async function loadSettings() {
  await sleep(50);
  return { port: 3700 };
}

const wrong = loadSettings();          // await 없음
console.log('await 없이 받은 값:', wrong);
console.log('wrong.port:', wrong.port);

const right = await loadSettings();    // await 있음
console.log('await 로 받은 값:', right);
console.log('right.port:', right.port);
// unhandled.mjs — 아무도 받지 않은 Promise 거부는 프로세스를 끝낸다
async function saveReport() {
  throw new Error('디스크가 가득 찼습니다');
}

saveReport();                // await 도, catch 도 없다
console.log('저장을 요청했습니다');

setTimeout(() => console.log('이 줄은 출력되지 않는다'), 100);

줄별 해설

이벤트 루프를 한 문장으로

Node.js는 "지금 할 동기 코드"를 끝까지 실행한 다음, 대기열에 쌓인 콜백을 하나씩 꺼내 실행하는 일을 반복한다. 대기열은 크게 두 종류다.

  • 마이크로태스크Promisethen/catch, await 다음 줄, queueMicrotask. 지금 코드가 끝나면 곧바로, 전부 실행된다.
  • 매크로태스크 — 타이머(setTimeout), 파일 읽기 완료, 네트워크 도착 같은 바깥 사건. 마이크로태스크를 다 비운 뒤에 하나씩 처리된다.

동기 코드를 끝까지 실행한 뒤 마이크로태스크(Promise then, await 다음 줄)를 모두 비우고, 그다음 타이머나 입출력 완료 같은 매크로태스크를 하나 꺼낸다. 이 과정을 반복한다.

그림 2-1. 이벤트 루프가 코드를 실행하는 순서

두 대기열의 차이
구분들어가는 것실행되는 때
마이크로태스크then · await 다음 줄 · queueMicrotask지금 코드가 끝나면 곧바로 전부
매크로태스크setTimeout · 파일 읽기 완료 · 요청 도착마이크로태스크를 비운 뒤 하나씩

order.mjs에서 setTimeout(..., 0)은 "0ms 뒤"가 아니라 "적어도 0ms 뒤, 타이머 차례가 오면"이다. 그래서 늦게 적은 Promise.then보다도 뒤에 나온다. 번호를 출력 순서대로 붙여 두었으니 실행 결과와 맞춰 보자.

동기 코드는 아무도 끼어들 수 없다

while (Date.now() - start < 500) {
  // 아무것도 하지 않고 시간만 확인한다
}

이 반복문이 도는 0.5초 동안 이벤트 루프는 한 바퀴도 돌지 못한다. 100ms 타이머는 이미 시간이 됐지만 꺼내 줄 사람이 없다. 서버라면 이 0.5초 동안 들어온 모든 사용자의 요청이 기다린다. 큰 JSON을 JSON.parse하거나, 수십만 건 배열을 정렬하거나, 동기 함수 readFileSync로 큰 파일을 읽는 것도 같은 효과를 낸다. 결과 값을 100ms 단위로 반올림한 것은 컴퓨터마다 몇 ms씩 달라지는 값을 원고에서 안정적으로 보여 주기 위해서다.

await를 줄마다 쓰면 차례로 기다린다

const a = await ask('회원', 200);
const b = await ask('주문', 200);
const c = await ask('재고', 200);

await는 "이 Promise가 끝날 때까지 이 함수의 다음 줄로 넘어가지 않는다"는 뜻이다. 세 요청이 서로 상관없는데도 앞 요청이 끝나야 다음 요청을 시작하므로 200 + 200 + 200 = 약 600ms가 된다. 문제 상황의 첫 번째 증상이다.

const all = await Promise.all([ask('회원', 200), ask('주문', 200), ask('재고', 200)]);

함수를 부르는 순간 요청은 시작된다. 세 개를 먼저 다 시작시켜 놓고 Promise.all로 "전부 끝날 때까지" 한 번만 기다리면 약 200ms다. 결과 배열의 순서는 끝난 순서가 아니라 넣은 순서다.

200ms씩 걸리는 일 세 개를 await로 차례로 기다리면 약 600ms, 먼저 모두 시작한 뒤 Promise.all로 한 번에 기다리면 약 200ms가 걸린다.

그림 2-2. 차례로 기다리기와 한꺼번에 기다리기

Promise.all은 하나라도 실패하면 그 즉시 실패한다. 예제에서 주문 쪽이 100ms에 실패하자 나머지를 기다리지 않고 약 100ms에 catch로 넘어갔다. 반면 Promise.allSettled는 실패가 있어도 전부 기다린 뒤 각각의 status(fulfilled/rejected)를 알려 준다. "하나라도 없으면 화면을 못 만든다"면 all, "되는 것만이라도 보여 준다"면 allSettled를 고른다.

Promise를 여러 개 기다리는 함수
함수끝나는 때하나가 실패하면
Promise.all모두 성공했을 때그 즉시 실패
Promise.allSettled모두 끝났을 때실패도 결과 목록에 담는다
Promise.race가장 먼저 끝난 하나먼저 끝난 것이 실패면 실패
Promise.any가장 먼저 성공한 하나모두 실패해야 실패

ES 모듈 최상위 await

parallel.mjs는 함수 밖에서 바로 await를 쓴다. ES 모듈(.mjs)에서만 되는 기능이다. CommonJS에서는 async 함수 안에서만 await를 쓸 수 있다. 짧은 스크립트를 쓸 때 편하다.

await를 빠뜨리면 Promise 상자를 받는다

async 함수는 언제나 Promise를 돌려준다. await 없이 받으면 결과가 아니라 "나중에 결과가 들어올 상자"를 받는다. 상자에는 port가 없으니 undefined다. 오류가 나지 않고 조용히 undefined가 흘러가는 것이 이 실수의 무서운 점이다.

받는 사람 없는 거부는 프로세스를 끝낸다

unhandled.mjssaveReport()는 실패한 Promise를 돌려주지만 아무도 await하거나 .catch하지 않는다. Node.js 15부터는 이런 "처리되지 않은 거부"를 발견하면 오류를 출력하고 프로세스를 종료 코드 1로 끝낸다. 100ms 뒤에 찍으려던 줄은 영영 나오지 않는다. 문제 상황의 "조용히 꺼진 서버"가 이것이다. 서버에서는 이 기본 동작이 오히려 안전하다. 어디서 무엇이 실패했는지 모르는 상태로 계속 돌기보다 멈추고 다시 시작하는 편이 낫다. 11단원에서 이런 오류를 로그로 남기고 끝내는 코드를 넣는다.

실제 실행 결과

$ node order.mjs
1. 동기 코드 시작
2. 동기 코드 끝
3. Promise then 콜백
4. queueMicrotask 콜백
5. 타이머(0ms) 콜백
$ node block.mjs
반복문 끝: 약 500ms
100ms 뒤에 실행하려던 타이머가 약 500ms 뒤에 실행됨

타이머는 100ms로 걸었지만 반복문이 끝난 뒤, 약 500ms에 실행됐다. 동기 코드가 이벤트 루프를 붙잡고 있었기 때문이다.

$ node parallel.mjs
차례로: [ '회원 결과', '주문 결과', '재고 결과' ] 약 600ms
한꺼번에: [ '회원 결과', '주문 결과', '재고 결과' ] 약 200ms
Promise.all 실패: 주문 응답 없음 약 100ms
allSettled: fulfilled 회원 결과
allSettled: rejected 주문 응답 없음
$ node forgot-await.mjs
await 없이 받은 값: Promise { <pending> }
wrong.port: undefined
await 로 받은 값: { port: 3700 }
right.port: 3700
$ node unhandled.mjs
저장을 요청했습니다
file:///home/me/node-book/ch02/unhandled.mjs:3
  throw new Error('디스크가 가득 찼습니다');
        ^

Error: 디스크가 가득 찼습니다
    at saveReport (file:///home/me/node-book/ch02/unhandled.mjs:3:9)
    at file:///home/me/node-book/ch02/unhandled.mjs:6:1
    at ModuleJob.run (node:internal/modules/esm/module_job:447:25)
    at async node:internal/modules/esm/loader:646:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.4.0

종료 코드는 1이다. 저장을 요청했습니다가 먼저 찍힌 것도 눈여겨보자. saveReport() 안의 throw는 즉시 프로그램을 멈추지 않고 Promise를 거부 상태로 만들 뿐이다. 동기 코드가 끝난 뒤 "아무도 이 거부를 받지 않았다"는 것이 확인되는 순간 종료된다.

실무에서 자주 틀리는 것

1. forEach 안의 async

ids.forEach(async (id) => { await save(id); })forEach가 콜백이 돌려준 Promise를 기다리지 않는다. 그래서 반복문 다음 줄이 저장보다 먼저 실행되고, 실패는 처리되지 않은 거부가 된다. 차례로 해야 하면 for (const id of ids) await save(id);, 한꺼번에 해도 되면 await Promise.all(ids.map((id) => save(id)))를 쓴다(연습 문제 3번).

2. 한꺼번에 너무 많이

Promise.all에 요청 1만 개를 넣으면 1만 개가 동시에 출발한다. 상대 서버나 DB 연결 수를 넘기기 쉽다. 수십 개 단위로 나눠 보내는 방법이 필요하다. 이 책의 범위는 아니지만 "동시에 몇 개까지"를 항상 정해 두는 습관을 들이자.

3. "비동기니까 빠르다"는 오해

async를 붙인다고 계산이 다른 스레드로 가지 않는다. async 함수 안의 while 반복도 이벤트 루프를 막는다. Node.js가 동시에 잘하는 것은 기다리기다. 오래 걸리는 계산은 잘게 나누거나 node:worker_threads로 옮겨야 한다.

4. try/catch로 감쌌는데 안 잡힌다

try { saveReport(); } catch {}는 아무것도 잡지 못한다. 실패는 나중에 Promise로 오기 때문이다. try { await saveReport(); } catch {}처럼 awaittry 안에 있어야 잡힌다.

연습 문제

  1. 아래 코드의 출력 순서를 적어라.
    setTimeout(() => console.log('A'), 0);
    (async () => {
      console.log('B');
      await null;
      console.log('C');
    })();
    Promise.resolve().then(() => console.log('D'));
    console.log('E');
  2. parallel.mjs에서 Promise.all 안의 세 호출을 [await ask('회원', 200), await ask('주문', 200), await ask('재고', 200)]처럼 바꾸면 시간은 몇 ms쯤 걸리는가? 이유는?
  3. 아래 코드는 무엇을 먼저 출력하는가? 고쳐서 "저장 1, 저장 2, 저장 3, 끝" 순서로 나오게 하라.
    [1, 2, 3].forEach(async (n) => {
      await sleep(10);
      console.log('저장', n);
    });
    console.log('끝');

정답과 해설

  1. B E C D A. 동기 코드인 B(async 함수는 첫 await 전까지 바로 실행된다)와 E가 먼저 나온다. await null 뒤의 CthenD는 마이크로태스크인데, await가 먼저 대기열에 들어갔으므로 C가 앞선다. 타이머 A가 마지막이다. 검증 스크립트로 같은 순서를 확인했다.
  2. 약 600ms다. 배열을 만드는 동안 각 await가 차례로 끝나기를 기다리므로, Promise.all이 받는 것은 이미 끝난 값 세 개다. 한꺼번에 기다리려면 await 없이 Promise를 만들어 넘겨야 한다. "Promise.all을 썼는데 왜 안 빨라지지?"의 흔한 원인이다.
  3. 이 먼저 나오고 저장 세 줄이 뒤에 나온다. forEach는 콜백의 Promise를 기다리지 않는다. 아래처럼 for...ofawait로 바꾸면 차례대로 저장한 뒤 이 나온다(검증 스크립트로 두 경우 모두 확인). 순서는 상관없고 빨리 끝내고 싶다면 await Promise.all([1, 2, 3].map(async (n) => { ... })) 다음에 을 찍는다.
    for (const n of [1, 2, 3]) {
      await sleep(10);
      console.log('저장', n);
    }
    console.log('끝');

다음 단원에서는 가장 흔한 "기다리는 일"인 파일 읽기와 쓰기를 다룬다. 메모 API가 데이터를 저장할 JSON 파일 다루기가 여기서 시작된다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.