Node.js 이벤트 루프의 장단점: 많은 I/O를 처리하면서도 한 요청에 막히는 이유
author
postNo
status
thumbnail
description
category
tags
createdAt
updatedAt
Node.js의 장점으로 많은 I/O 요청을 동시에 처리할 수 있다는 설명을 자주 본다. 그런데 한 요청이 오래 걸리면 다른 요청도 막힌다는 설명을 함께 접하면 의문이 생긴다. 여러 요청을 처리한다면서 왜 하나의 요청에 영향을 받을까?
이 내용을 이해하기 위해서는 요청이 끝날 때까지 걸린 시간과 JavaScript가 실행 스레드를 차지한 시간을 나눠 봐야 한다. Node.js에서 데이터베이스의 응답을 기다리는 250ms와 CPU로 계산하는 250ms는 다른 요청에 전혀 다른 영향을 준다.
I/O는 데이터를 읽고 쓰는 입출력 작업으로 DB 조회, 외부 API 호출, 파일 읽기 등이 여기에 해당한다.
이 글에서는 서버 요청을 기준으로 이벤트 루프의 동작과 장단점을 살펴본다. 이벤트 루프의 기본 동작은 JavaScript Event Loop에서 다뤘다. 이번 글에서는 어떤 작업에서 이점이 생기고 어디서 병목이 생기는지 살펴보려 한다.

1. 오래 걸리는 요청은 모두 다른 요청을 막을까?

먼저 두 상황을 비교해 보자.
  • 요청 A가 비동기 DB 조회를 시작하고 결과를 250ms 동안 기다린다.
  • 요청 A가 큰 데이터를 계산하느라 JavaScript를 250ms 동안 연속으로 실행한다.
첫 번째 상황에서는 A가 기다리는 동안 이벤트 루프가 요청 B의 코드를 실행할 수 있다. await는 해당 비동기 함수의 진행을 잠시 멈추지만 서버 전체를 기다리게 하지는 않는다.
두 번째 상황에서는 같은 이벤트 루프에서 실행할 B의 코드도 기다려야 한다. A의 JavaScript가 실행을 마치거나 제어권을 돌려주기 전에는 다른 요청의 JavaScript가 끼어들 수 없기 때문이다.
 
I/O를 기다리는 동안에는 다른 요청을 처리할 수 있지만 긴 JavaScript 실행 중에는 같은 이벤트 루프의 다른 요청도 기다린다.
I/O를 기다리는 동안에는 다른 요청을 처리할 수 있지만 긴 JavaScript 실행 중에는 같은 이벤트 루프의 다른 요청도 기다린다.
 
여기서 Node.js의 단점을 바로 알 수 있다.
하나의 요청이 이벤트 루프를 오래 점유하면, 같은 이벤트 루프에서 처리할 다른 요청도 지연된다.
두 번째 상황에서는 애플리케이션의 후속 처리가 지연된다. 운영체제의 네트워크 처리나 다른 프로세스까지 모두 멈춘다는 뜻은 아니다. 별도 프로세스로 실행 중인 서버 인스턴스도 자신의 이벤트 루프로 계속 일할 수 있다.
 

2. 하나의 이벤트 루프로 어떻게 여러 I/O를 처리할까?

동시성은 여러 작업의 진행 기간이 겹치는 것이다. 병렬성은 여러 작업이 실제로 같은 순간에 실행되는 것이다. Node.js의 메인 JavaScript는 보통 하나의 이벤트 루프 스레드에서 실행되지만 여러 네트워크 요청을 진행 중인 상태로 유지할 수 있다.
웹 서버가 DB를 조회하는 흐름을 단순화하면 다음과 같다.
  1. 요청을 받은 JavaScript가 입력을 확인하고 DB 조회를 시작한다.
  1. 네트워크 응답을 기다리는 동안 현재 함수가 제어권을 돌려준다.
  1. 이벤트 루프는 실행할 수 있는 다른 요청과 콜백을 처리한다.
  1. DB 응답을 처리할 준비가 되면 관련 후속 코드가 실행될 차례를 얻는다.
  1. JavaScript가 결과를 가공하고 응답을 만든다.
 
Node.js는 이벤트 루프와 운영체제의 I/O 기능을 연결하는 라이브러리인 libuv를 사용한다. libuv는 I/O 종류에 따라 서로 다른 경로로 작업을 처리한다.
일반적인 네트워크 소켓은 운영체제의 비동기·논블로킹 I/O 기능을 이용한다. 호출한 스레드를 작업이 끝날 때까지 붙잡아 두지 않고 나중에 처리할 준비가 됐는지 확인하는 방식이다.
 
이벤트 루프가 네트워크 I/O와 libuv 스레드 풀 작업을 시작하고 처리 준비가 된 후속 JavaScript를 다시 실행하는 흐름.
이벤트 루프가 네트워크 I/O와 libuv 스레드 풀 작업을 시작하고 처리 준비가 된 후속 JavaScript를 다시 실행하는 흐름.
 
그래서 Node.js는 스레드가 하나다라는 말만으로 내부를 설명하기는 어렵다. JavaScript 실행 스레드 외에도 I/O와 런타임 내부 작업을 위한 스레드가 있다. 다만 일반적인 요청 핸들러의 JavaScript를 이 스레드들이 자동으로 나눠 실행해 주지는 않는다.
또한 모든 후속 작업이 하나의 큐에서 들어온 순서대로 실행된다고 생각하면 실행 순서를 잘못 예상하기 쉽다. Node.js에는 타이머, I/O, setImmediate 등을 처리하는 단계가 있고 Promise의 후속 처리는 마이크로태스크로 실행된다. (JavaScript Event Loop에서도 다뤘다)
 

3. 어떤 장점이 있을까?

I/O를 기다리는 동안 다른 요청을 처리한다

DB 조회, 외부 API 호출, 소켓 통신처럼 기다리는 비중이 큰 작업에서 이점이 생긴다. 하나의 요청이 응답을 기다리는 동안 다른 요청을 시작하거나 이미 도착한 결과를 처리할 수 있다.
웹 API나 WebSocket 서버를 떠올리면 이해하기 쉽다. 연결이 많아도 모든 연결이 계속 CPU를 사용하는 것은 아니다. 잠시 메시지를 기다리는 연결 때문에 실행 스레드를 하나씩 붙잡아 둘 필요가 없다.
다만 유지할 수 있는 연결 수는 메모리, 소켓 한도, 연결 풀, 상대 서버의 처리 용량에 제한받는다. 많은 I/O를 진행할 수 있다는 말이 무한히 처리할 수 있다는 뜻은 아니다.
 

요청마다 전용 스레드를 두는 비용을 줄일 수 있다

요청마다 운영체제 스레드 하나를 점유하는 처리 모델과 비교하면 대기 중인 요청 수만큼 스레드를 늘리지 않아도 된다. 스레드 스택에 쓰는 메모리와 스레드 전환 비용을 줄일 수 있다.
 

독립적인 I/O를 겹쳐 응답 시간을 줄인다

사용자 정보와 서비스 설정을 각각 조회해야 한다고 하자. 두 조회가 서로의 결과를 필요로 하지 않는다면 함께 시작할 수 있다.
각각 100ms와 150ms가 걸린다는 가정에서 순서대로 기다리면 약 250ms가 필요하다. 함께 시작하면 다른 비용을 제외했을 때 더 긴 쪽인 약 150ms에 가까워진다. 각 I/O에 걸리는 시간은 그대로지만 대기 시간을 겹쳐 전체 응답 시간을 줄인다.
 
독립적인 두 I/O를 순차 시작하면 대기 시간이 더해지고 함께 시작하면 대기 시간이 겹친다. 수치는 설명용 예시다.
독립적인 두 I/O를 순차 시작하면 대기 시간이 더해지고 함께 시작하면 대기 시간이 겹친다. 수치는 설명용 예시다.
 
Promise.all은 이런 독립 작업의 결과를 함께 기다릴 때 사용할 수 있다. 하지만 작업 사이에 의존성이 있다면 순서를 지켜야 한다. 사용자 ID를 알아야 주문을 조회할 수 있는 상황에서는 두 번째 조회를 먼저 시작할 수 없다.
 

스트림과 함께 쓰면 큰 데이터를 나눠 처리하기 좋다

큰 응답을 전부 메모리에 담은 뒤 보내는 대신, 준비된 데이터부터 작은 단위로 전달할 수 있다. Node.js의 스트림은 이런 흐름을 구성하는 도구다.
여기에는 백프레셔가 함께 필요하다. 데이터를 받는 쪽이 느리면 보내는 쪽도 잠시 생산을 늦추는 방식이다. 이를 지켜야 메모리에 아직 보내지 못한 데이터가 끝없이 쌓이는 상황을 줄일 수 있다. 스트림을 사용했다는 사실만으로 메모리 사용량이 자동으로 제한되지는 않는다.
 
빠른 생산자가 느린 소비자에게 계속 보내면 버퍼가 늘어나고 백프레셔를 적용하면 생산을 잠시 멈춰 소비 속도에 맞춘다.
빠른 생산자가 느린 소비자에게 계속 보내면 버퍼가 늘어나고 백프레셔를 적용하면 생산을 잠시 멈춰 소비 속도에 맞춘다.
직접 쓰기 흐름을 제어한다면 writable.write()가 false를 반환할 때 추가 쓰기를 멈추고 drain을 기다려야 한다. 여러 스트림을 연결할 때는 pipeline 같은 API를 검토할 수 있다.
 

4. 어떤 단점과 제약이 있을까?

긴 실행 하나가 다른 요청의 응답 시간까지 늘린다

이벤트 루프를 공유하는 만큼 한 요청의 긴 실행이 다른 요청으로 영향을 퍼뜨릴 수 있다. 반복 계산만 문제가 되는 것은 아니다.
  • 큰 JSON의 JSON.parse, JSON.stringify
  • 큰 배열의 정렬과 집계
  • 특정 입력에서 실행 시간이 급증하는 정규식
  • 요청 처리 중 사용하는 동기 파일 I/O
파일을 읽는 시간이 길면 CPU 사용률이 높지 않아도 이벤트 루프는 다른 일을 못 할 수 있다. 반대로 비동기 파일 읽기를 사용했더라도 읽은 내용을 한 번에 파싱하는 단계에서 오래 점유할 수 있다.
 
이 때문에 무거운 API 하나가 가벼운 조회나 헬스 체크를 함께 늦출 수 있다. 평균 응답 시간은 괜찮아도 일부 요청이 크게 지연되는지 봐야 한다. 예를 들어 p99는 요청의 99%가 그 값 이하에 완료됐다는 뜻으로, 느린 쪽의 응답 시간을 살피는 지표다.
 

비동기 호출도 스레드 풀이나 연결 풀에서 밀릴 수 있다

이벤트 루프가 비어 있어도 다른 곳에서 기다릴 수 있다. 대표적인 예가 libuv 스레드 풀이다.
파일 시스템의 비동기 API, dns.lookup, 일부 crypto·zlib 작업은 제한된 스레드 풀을 공유한다. 오래 걸리는 작업이 풀을 차지하면 같은 풀을 사용하는 다른 작업도 늦어질 수 있다. 모든 네트워크 I/O가 이 풀을 거치는 것은 아니다.
DB 연결 풀도 별개로 봐야 한다. 쿼리 요청을 많이 시작해도 사용 가능한 DB 연결이 없으면 애플리케이션 안에서 차례를 기다린다. 비동기로 호출해도 사용 가능한 DB 연결이 부족하면 기다려야 한다.
 

동시 요청을 늘릴수록 메모리와 상대 시스템의 부담도 커진다

비동기 코드는 여러 작업을 쉽게 시작할 수 있다. 그래서 동시성 제한을 빠뜨리기도 쉽다.
예를 들어 API 요청 하나가 외부 서비스를 20번 호출한다고 하자. 이런 요청 100개가 겹치면 최대 2,000개의 외부 작업을 진행하려 할 수 있다. 실제 실행 수는 연결 풀 등에 막힐 수 있지만 그 앞의 대기 요청과 Promise, 응답 버퍼는 남는다.
서버가 일을 더 많이 시작해도 DB나 외부 API의 처리량이 늘어나는 것은 아니다. 오히려 대기 시간, 타임아웃, 재시도가 함께 늘어날 수 있다. 들어오는 양이 처리하는 양을 계속 넘으면 대기열만으로는 해결할 수 없다.
 
크기가 제한된 대기열에서 동시 실행 한도만큼 작업을 꺼내고 하나가 끝나면 다음 작업을 시작한다.
크기가 제한된 대기열에서 동시 실행 한도만큼 작업을 꺼내고 하나가 끝나면 다음 작업을 시작한다.
그래서 동시에 실행할 작업 수와 대기시킬 작업 수를 따로 제한해야 한다. 요청 하나당 호출 수를 제한하는 것과 서버 전체의 호출 수를 제한하는 것도 다르다. 한도를 넘을 때 거절할지, 지연시킬지, 별도 작업 큐로 넘길지까지 정해야 한다. (이쯤 되면 많은 고민이 필요하다)
 

async와 await는 CPU 작업을 다른 스레드로 보내지 않는다

async 함수도 실행되기 시작하면 일반적인 JavaScript 코드다. 내부에서 큰 계산을 수행하면 그 계산은 현재 스레드를 사용한다.
await 뒤에 있는 코드도 재개된 뒤에는 같은 방식으로 실행된다. Promise로 감싸거나 async를 붙이는 것만으로 무거운 계산이 이벤트 루프 밖으로 옮겨지지는 않는다.
이 구분은 CPU 코어를 활용할 때도 중요하다. 코어가 여러 개라는 이유만으로 하나의 이벤트 루프가 실행하는 JavaScript가 자동으로 모든 코어에 분산되지는 않는다.
 

Promise.all로 함수 5개를 호출하면 어떨까?

그렇다면 I/O가 없는 async 함수 5개를 Promise.all로 묶으면 어떨까? 하나가 5초 걸릴 때 나머지 4개도 기다려야 하는지는 그 5초를 어떻게 보내느냐에 달려 있다.
반복문으로 5초를 소비하는 경우
다음 코드는 ES 모듈 기준이다. slow는 5초 동안 반복문을 실행하고 나머지 함수는 로그만 남긴다.
import { performance } from 'node:perf_hooks';

async function slow() {
  const end = performance.now() + 5000;
  while (performance.now() < end) {}
  console.log('slow 완료');
}

async function fast(id) {
  console.log('fast ' + id + ' 완료');
}

await Promise.all([
  slow(),
  fast(1),
  fast(2),
  fast(3),
  fast(4),
]);

console.log('전체 완료');
처음 약 5초 동안 출력이 없다가 다음 순서로 출력된다.
slow 완료
fast 1 완료
fast 2 완료
fast 3 완료
fast 4 완료
전체 완료
이 예제에서 나머지 함수들은 5초 동안 시작조차 하지 못한다. Promise.all에 전달할 배열을 만드는 과정에서 slow()를 먼저 호출하기 때문이다. slow가 반복문을 끝내고 반환해야 다음 원소인 fast(1)을 호출할 수 있다.
async 함수도 첫 await로 제어권을 돌려주기 전까지는 동기적으로 실행된다. 이 slow에는 await가 없으므로 함수 본문 전체가 한 번에 실행된다. 이미 다른 비동기 작업이 진행 중이더라도 같은 이벤트 루프에서 실행할 후속 JavaScript는 이 반복문이 끝날 때까지 기다려야 한다.
타이머로 5초를 기다리는 경우
같은 코드에서 slow만 다음처럼 바꿔 보자.
async function slow() {
  await new Promise(resolve => setTimeout(resolve, 5000));
  console.log('slow 완료');
}
이번에는 fast 1부터 fast 4까지 바로 출력된다. slow 완료와 전체 완료는 약 5초 뒤에 출력된다.
fast 1 완료
fast 2 완료
fast 3 완료
fast 4 완료
slow 완료
전체 완료
slow가 타이머를 등록한 뒤 await에서 제어권을 돌려주기 때문에 다음 함수들을 호출할 수 있다. I/O가 없는 타이머 대기도 이렇게 이벤트 루프를 다른 작업에 내줄 수 있다.
두 경우 모두 전체 완료는 약 5초 뒤에 나온다. 하지만 첫 번째는 나머지 함수의 실행 자체가 밀렸고 두 번째는 나머지 함수가 먼저 끝난 상태에서 slow의 완료를 기다렸다.
Promise.all은 전달받은 Promise의 결과를 모아 기다린다. 모든 Promise가 성공하면 완료되지만 동기 계산을 여러 스레드로 나눠 실행하지는 않는다. 따라서 await Promise.all(...) 다음 코드가 늦게 실행된다는 사실만으로 다른 함수들도 그동안 멈춰 있었다고 판단해서는 안 된다.
 

한 스레드라도 await 사이에서는 데이터가 바뀐다

하나의 JavaScript 실행 구간 안에서는 같은 이벤트 루프의 다른 요청이 중간에 끼어들지 않는다. 하지만 await로 기다리는 동안에는 다른 요청이 실행될 수 있다.
재고가 1개인 상황을 생각해 보자. 요청 A가 재고 1을 읽고 외부 응답을 기다리는 사이에, 요청 B도 재고 1을 읽을 수 있다. 두 요청이 각자 확보했다고 판단한 뒤 재고를 0으로 저장하면 실행 스레드가 하나여도 중복 처리가 생긴다.
 
요청 A가 재고를 읽고 await로 기다리는 사이 요청 B도 같은 재고를 읽는다. 두 요청 모두 성공했다고 판단하는 경쟁 조건의 예.
요청 A가 재고를 읽고 await로 기다리는 사이 요청 B도 같은 재고를 읽는다. 두 요청 모두 성공했다고 판단하는 경쟁 조건의 예.
이것은 CPU 명령이 같은 순간에 실행돼야만 생기는 문제가 아니다. 읽기와 쓰기 사이에 다른 작업이 들어올 수 있기 때문에 생긴다. 공유 데이터를 보호하려면 조건부 갱신, 적절한 트랜잭션과 잠금, 유일성 제약 등으로 확인과 변경을 함께 보장해야 한다. 트랜잭션을 선언하는 것만으로 모든 경쟁 조건이 사라지는 것도 아니다.
 

5. 단점을 줄이려면 작업을 어떻게 나눠야 할까?

짧게 나눌 수 있는 계산은 실행 기회를 양보한다

큰 반복 작업을 작은 묶음으로 나누고 묶음 사이에 이벤트 루프가 I/O를 처리할 기회를 줄 수 있다. 이때 await Promise.resolve()를 계속 반복하는 방식은 충분하지 않을 수 있다.
Promise의 후속 처리는 마이크로태스크다. 마이크로태스크를 계속 추가하면 I/O 단계로 진행하는 시점이 밀릴 수 있다. process.nextTick을 재귀적으로 계속 예약하는 경우에도 비슷한 문제가 생긴다.
 
Promise 마이크로태스크를 계속 이어 가면 I/O가 기다릴 수 있다. 작은 계산 사이에 setImmediate로 양보하면 이벤트 루프에 처리 기회를 준다.
Promise 마이크로태스크를 계속 이어 가면 I/O가 기다릴 수 있다. 작은 계산 사이에 setImmediate로 양보하면 이벤트 루프에 처리 기회를 준다.
Node.js에서는 다음처럼 setImmediate의 Promise 버전을 사용할 수 있다. 아래 size는 설명용이며 적절한 크기는 각 계산에 걸리는 시간으로 판단해야 한다.
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function sumInChunks(values, size = 1000) {
  if (!Number.isInteger(size) || size < 1) {
    throw new RangeError('size must be a positive integer');
  }

  let total = 0;
  for (let offset = 0; offset < values.length; offset += size) {
    const end = Math.min(offset + size, values.length);

    for (let i = offset; i < end; i++) {
      total += values[i];
    }

    if (end < values.length) {
      await yieldToLoop();
    }
  }
  return total;
}

console.log(await sumInChunks([1, 2, 3, 4], 2)); // 10
이 방식은 다른 작업의 응답성을 지키기 위한 것이다. 전체 계산량이 줄어들지는 않고 스케줄링 비용이 더해질 수 있다. 또한 한 번의 계산 자체가 오래 걸린다면 바깥 반복만 나눠서는 충분하지 않다.
 

무거운 JavaScript 연산은 워커를 검토한다

CPU를 오래 사용하는 JavaScript는 worker_threads로 분리할 수 있다. 워커는 별도의 JavaScript 실행 스레드를 사용하므로 메인 이벤트 루프가 계산을 직접 떠안는 시간을 줄일 수 있다. 여러 코어를 활용할 여지도 생긴다.
다만 작업마다 워커를 새로 만들면 생성 비용이 커질 수 있다. 보통은 제한된 수의 워커를 재사용하는 풀을 검토한다. 워커에 데이터를 보내는 복사·전달 비용, 워커별 메모리, 실패 처리도 함께 고려해야 한다.
 
메인 이벤트 루프가 CPU 작업을 제한된 워커 풀에 전달하고 결과를 받아 응답을 처리하는 구성.
메인 이벤트 루프가 CPU 작업을 제한된 워커 풀에 전달하고 결과를 받아 응답을 처리하는 구성.
여기서 말하는 워커 풀은 앞서 본 libuv 스레드 풀과 다르다. libuv 풀은 특정 네이티브 API가 내부적으로 사용하는 풀이고 worker_threads 풀은 애플리케이션이 JavaScript 작업을 나눠 실행하기 위해 구성하는 것이다.
이미 효율적으로 비동기 처리되는 네트워크 I/O를 워커로 옮긴다고 이점이 생기는 것은 아니다. CPU 계산 비중과 전달할 데이터 크기를 먼저 확인하는 편이 좋다.
 

6. 어떤 상황에서 쓰기 좋고, 무엇을 측정해야 할까?

장단점을 요청의 성격과 연결하면 선택 기준이 조금 더 분명해진다.
요청의 성격
기대할 수 있는 점
함께 준비할 것
DB·외부 API 대기가 많고 JS 처리가 짧다
I/O 대기 시간을 겹쳐 처리하기 좋다.
동시성 제한, 연결 풀, 타임아웃
많은 연결이 메시지를 기다린다
연결마다 전용 실행 스레드를 두지 않아도 된다.
연결 수·버퍼·메모리 한도
큰 데이터를 읽고 전달한다
스트림으로 데이터를 나눠 전달할 수 있다.
백프레셔와 입력 크기 제한
JavaScript 계산이 요청 시간의 큰 비중을 차지한다
I/O 동시성만으로 해결할 범위가 작다.
계산 개선, 워커, 프로세스 분리 검토
짧은 응답 지연을 엄격히 지켜야 한다
평균 처리량만으로 적합성을 판단하기 어렵다.
긴 실행 제거와 부하 상황의 p99 검증
응답이 느려졌다면 CPU 사용률만으로 원인을 판단해서는 안 된다. 다음 지표를 함께 봐야 한다.
  • 요청별 응답 시간과 처리량: 평균뿐 아니라 p95·p99와 오류율을 확인한다.
  • 이벤트 루프 지연: monitorEventLoopDelay로 루프가 실행 기회를 얼마나 늦게 얻는지 본다. 반환되는 히스토그램의 시간 단위는 나노초이므로 밀리초로 표시할 때 1e6으로 나눈다.
  • 이벤트 루프 사용률: eventLoopUtilization은 루프의 활성·유휴 시간 관점의 지표다. CPU 사용률과 동일한 값으로 해석하지 않는다.
  • 풀과 대기열: DB 연결을 얻기까지의 시간, 진행 중인 작업 수, 대기열 길이와 외부 호출 시간을 함께 본다.
  • 메모리: JavaScript 객체를 담는 힙, Buffer 등 외부 메모리, 프로세스 전체 메모리를 본다. 사용하지 않는 객체를 정리하는 GC가 실행 지연에 미치는 영향도 함께 확인한다.
이벤트 루프 측정 API의 의미와 단위는 Node.js 성능 측정 문서에서 확인할 수 있다.
 

정리

Node.js의 비동기 I/O는 기다리는 동안 다른 요청을 진행할 수 있게 한다. 기다림이 많고 한 번의 JavaScript 실행이 짧은 서버에서 이점이 크다.
그 대신 같은 이벤트 루프를 공유하는 요청들은 긴 실행 하나의 영향을 함께 받는다. 비동기 코드를 쓴다고 CPU 계산, 연결 풀의 한도, 메모리 사용량, 데이터 충돌이 사라지지는 않는다.
나는 Node.js를 선택할 때 “동시에 몇 개를 시작할 수 있나”와 “각 작업이 실행 스레드를 얼마나 오래 차지하나”를 함께 봐야 한다고 생각한다. 여기에 동시에 실행할 작업 수와 대기시킬 작업 수의 한도를 정하고 긴 계산을 분리하면 장점을 살리면서 지연이 퍼지는 범위를 줄일 수 있다.