Node.js 성능 비결 파헤치기 - libuv 라이브러리와 함께 알아보는 논블로킹 IO와 이벤트 루프 완벽 정복!

안녕하세요?

오늘 공부할 주제를 살펴보기 전에 우리가 프로그래머로서 컴퓨터의 속성에 대해 이미 알고 있는 걸 다시 짚어보면 아래와 같습니다.

  1. 컴퓨터 작업은 크게 CPU 연산IO(입출력) 작업으로 나뉩니다.
  2. CPU 연산 속도는 IO 작업 속도보다 훨씬 빠릅니다.
  3. CPU 바운드 작업은 연산량이 너무 많아 CPU가 병목 지점이 되는 경우고, IO 바운드 작업은 디스크나 네트워크 등 IO 장치가 느려서 CPU가 기다리는 시간이 길어지는 경우입니다.
  4. 가장 큰 문제! IO 바운드 작업 시, CPU는 IO가 끝나기를 기다리며 아까운 시간을 낭비하게 됩니다.

바로 이 'CPU의 낭비되는 기다림' 문제를 해결하는 것이 오늘 이야기의 핵심입니다.

어떻게 하면 IO 작업을 기다리는 동안에도 CPU가 멈추지 않고 다른 유용한 일을 할 수 있을까요?

그 해답인 논블로킹(Non-Blocking) IO비동기(Asynchronous) 프로그래밍, 그리고 이를 기가 막히게 구현해낸 노드제이에스(Node.js) 의 내부 동작 원리(이벤트 루프, libuv)까지 오늘 알아보겠습니다.

1. 문제점 - 답답한 기다림, 블로킹(Blocking) IO 방식

먼저 전통적인 방식, 즉 블로킹 IO가 왜 비효율적인지 확실히 짚고 넘어가겠습니다.

블로킹 방식은 아주 직관적입니다. 프로그램이 IO 작업(예: 파일 읽기, 네트워크 요청 보내기)을 시키면, 그 작업이 완전히 끝날 때까지 프로그램의 다음 코드 실행을 멈추고 기다리는 방식입니다.

마치 식당에서 웨이터에게 주문을 했는데, 그 웨이터가 주방 앞에서 음식이 나올 때까지 아무것도 안 하고 꼼짝 않고 기다리는 것과 같습니다.

그동안 다른 테이블 손님이 불러도 못 가고, 빈 그릇을 치우지도 못합니다. 오직 주문한 음식이 나와야만 다음 행동을 할 수 있습니다.

코드로 간단히 개념을 표현하면 이렇습니다. (실제 코드는 아니지만 이해를 돕기 위한 예시입니다.)

// 블로킹 방식의 개념적 예시
결과1 = 파일_읽기('파일A'); // 파일A 읽기가 끝날 때까지 여기서 멈춤!
처리하기(결과1);

결과2 = 네트워크_요청('주소B'); // 주소B 응답이 올 때까지 여기서 멈춤!
처리하기(결과2);

console.log('모든 작업 완료!'); // 앞의 작업들이 다 끝나야 실행됨

혼자서 순차적으로 일을 처리할 때는 문제가 없어 보일 수 있습니다.

하지만 만약 동시에 여러 손님(요청)을 받아야 하는 웹 서버 같은 경우는 어떨까요?

한 명의 요청(파일 읽기나 데이터베이스 조회 등)을 처리하는 동안 다른 모든 요청은 하염없이 기다려야만 합니다.

이를 해결하려면 손님(요청)마다 웨이터(스레드)를 한 명씩 붙여줘야 하는데, 요청이 많아지면 스레드 수도 그만큼 늘어나야 하고 이는 엄청난 메모리 자원 낭비와 관리 부담으로 이어집니다.

결론적으로, 블로킹 IO 방식은 IO 작업 대기 시간 동안 CPU 자원을 낭비하고, 많은 동시 요청을 효율적으로 처리하기 어렵다는 치명적인 단점이 있습니다.

2. 해결책 - 기다리지 않고 일한다! 논블로킹(Non-Blocking) IO와 비동기(Asynchronous) 프로그래밍

이 답답한 블로킹 방식의 문제를 해결하기 위해 등장한 것이 바로 논블로킹 IO입니다.

논블로킹 방식은 IO 작업을 요청한 후, 그 작업이 완료되었는지 여부와 상관없이 즉시 다음 코드로 실행 흐름을 넘기는 방식입니다.

"일단 요청했으니, 난 내 할 일 계속할게. 작업 끝나면 나중에 알려줘!" 라고 말하는 것과 같습니다.

앞서 식당 비유를 다시 가져와 볼까요? 똑똑한 웨이터는 손님의 주문을 받아서 주방에 전달(IO 요청)한 뒤, 음식이 나오기를 기다리지 않습니다.

대신 다른 테이블에 가서 주문을 받거나(다른 IO 요청 처리), 테이블을 정리하는 등(다른 작업 수행) 다른 일을 하다가, 주방에서 음식이 다 되었다고 알려주면(IO 완료 알림) 그때 음식을 가져다줍니다.

훨씬 효율적이죠!

이 논블로킹 IO 방식을 기반으로 프로그램을 작성하는 방식을 비동기 프로그래밍이라고 부릅니다.

"나중에 알려줘"라는 약속을 코드로 구현하는 방법에는 여러 가지가 있지만, 가장 대표적인 것이 콜백(Callback) 함수, 프로미스(Promise), 그리고 최신 자바스크립트(JavaScript)의 async/await 문법입니다.

자, 이제 실제 노드제이에스(Node.js) 코드를 통해 블로킹과 논블로킹(비동기) 방식의 차이를 눈으로 확인해 볼까요?

파일을 읽는 간단한 예제입니다.

[예제 1: 블로킹 방식으로 파일 읽기 - fs.readFileSync]

const fs = require('fs'); // Node.js의 파일 시스템 모듈

console.log('파일 읽기 시작 (동기)');

try {
  // readFileSync는 파일 읽기가 끝날 때까지 여기서 실행을 멈춥니다. (블로킹)
  const data = fs.readFileSync('./myFile.txt', 'utf8');
  console.log('파일 내용:', data);
} catch (err) {
  console.error('파일 읽기 오류:', err);
}

console.log('파일 읽기 완료 (동기)');
console.log('다음 작업 진행');

[예제 2: 논블로킹(비동기) 방식으로 파일 읽기 - fs.readFile]

const fs = require('fs');

console.log('파일 읽기 시작 (비동기)');

// readFile은 파일 읽기를 '시작'만 시키고 바로 다음 코드로 넘어갑니다. (논블로킹)
// 파일 읽기가 완료되면, 세 번째 인자로 전달된 콜백 함수가 실행됩니다.
fs.readFile('./myFile.txt', 'utf8', (err, data) => {
  // 이 부분은 파일 읽기가 완료된 '미래 시점'에 실행됩니다.
  if (err) {
    console.error('파일 읽기 오류:', err);
    return;
  }
  console.log('파일 내용:', data);
  console.log('--- 비동기 파일 읽기 콜백 완료 ---');
});

console.log('파일 읽기 요청 완료 (비동기)'); // readFile 함수 호출 직후 바로 실행됩니다.
console.log('다음 작업 진행');

두 코드의 실행 결과를 비교하면 어떻게 나올까요? (myFile.txt에는 "Hello Node.js!"라는 내용이 있다고 가정합니다.)

[예제 1 실행 결과 예상]

파일 읽기 시작 (동기)
파일 내용: Hello Node.js!
파일 읽기 완료 (동기)
다음 작업 진행

[예제 2 실행 결과 예상]

파일 읽기 시작 (비동기)
파일 읽기 요청 완료 (비동기)
다음 작업 진행
파일 내용: Hello Node.js!
--- 비동기 파일 읽기 콜백 완료 ---

결과가 확연히 다르죠?

  • 블로킹 방식(readFileSync) 은 파일 읽기가 끝날 때까지 console.log('파일 내용:', data); 다음 줄로 넘어가지 못했습니다. 모든 것이 순서대로 실행됩니다.
  • 논블로킹 방식(readFile)readFile 함수를 호출하자마자 바로 다음 줄인 console.log('파일 읽기 요청 완료 (비동기)');console.log('다음 작업 진행'); 이 실행되었습니다. 파일 읽기 자체는 백그라운드에서 진행되고, 완료된 후에야 콜백 함수 안의 코드가 실행되어 파일 내용이 출력된 것을 볼 수 있습니다.

이것이 바로 논블로킹 IO와 비동기 프로그래밍의 힘입니다!

IO 작업을 기다리는 동안에도 프로그램의 다른 부분을 계속 실행하여 효율성을 극대화하는 것이죠.

특히 수많은 동시 요청을 처리해야 하는 웹 서버 같은 IO 바운드 환경에서 이 방식은 엄청난 성능 향상을 가져옵니다.

3. Node.js의 심장 - 마법 같은 이벤트 루프(Event Loop)

"아니, 자바스크립트(JavaScript)는 원래 싱글 스레드(Single Thread)라면서요?

어떻게 동시에 여러 작업을 처리하는 거죠?" 라는 의문이 드는 것이 당연합니다.

맞습니다. 노드제이에스(Node.js)의 메인 실행 흐름은 기본적으로 하나의 스레드에서 이루어집니다.

그런데도 여러 요청을 동시에 처리하는 것처럼 보이는 비결, 그것이 바로 이벤트 루프(Event Loop) 입니다.

이벤트 루프는 노드제이에스(Node.js) 애플리케이션이 시작될 때 함께 실행되어, 프로그램이 종료될 때까지 끊임없이 특정 작업들을 확인하고 처리하는 역할을 합니다.

마치 아주 눈치 빠르고 부지런한 매니저 같다고 할 수 있습니다.

이벤트 루프가 주로 하는 일과 관련된 핵심 요소들을 알아볼까요?

  • 호출 스택 (Call Stack): 현재 실행 중인 함수의 목록을 관리하는 곳입니다. 자바스크립트 함수가 호출되면 스택에 쌓이고(push), 함수 실행이 끝나면 스택에서 빠집니다(pop). 일반적인 동기 코드(계산 등)는 여기서 실행됩니다.
  • 백그라운드 (Background / Web APIs / C++ APIs): 시간이 오래 걸리는 작업(타이머 설정, 파일 IO 요청, 네트워크 요청 등)은 노드제이에스(Node.js)나 브라우저가 제공하는 별도의 영역(또는 내부 C++ 모듈)으로 보내져 처리됩니다. 메인 스레드는 이 작업들이 완료되기를 기다리지 않습니다.
  • 콜백 큐 (Callback Queue / Task Queue): 백그라운드에서 처리된 비동기 작업들의 완료 결과(콜백 함수) 가 등록되는 대기 장소입니다. 여러 종류의 큐가 있지만(마이크로태스크 큐, 매크로태스크 큐 등), 일단은 '완료된 작업의 콜백 함수들이 줄 서 있는 곳' 정도로 이해하면 충분합니다.
  • 이벤트 루프 (Event Loop): 바로 이 녀석이 핵심입니다! 이벤트 루프는 다음과 같은 일을 끊임없이 반복합니다.
    1. 호출 스택(Call Stack)을 확인합니다. 스택이 비어 있나요? (즉, 현재 실행 중인 동기 코드가 없나요?)
    2. 호출 스택이 비어 있다면, 콜백 큐(Callback Queue)를 확인합니다. 큐에 대기 중인 콜백 함수가 있나요?
    3. 콜백 큐에 함수가 있다면, 그중 하나를 꺼내서 호출 스택으로 옮깁니다(push).
    4. 호출 스택으로 옮겨진 콜백 함수가 실행됩니다. 함수 실행이 끝나면 스택에서 빠집니다(pop).
    5. 다시 1번부터 반복합니다.

이 과정을 그림으로 상상해 보세요.

  1. fs.readFile 같은 비동기 함수가 호출 스택에서 실행됩니다.
  2. fs.readFile은 실제 파일 읽기 작업을 백그라운드로 보냅니다. 그리고 자신은 바로 호출 스택에서 빠져나옵니다. (논블로킹!)
  3. 호출 스택에는 fs.readFile 다음에 있던 console.log('파일 읽기 요청 완료...') 등이 들어와 실행되고 빠져나갑니다.
  4. 그동안 백그라운드에서는 파일 읽기가 진행됩니다.
  5. 파일 읽기가 완료되면, fs.readFile에 전달했던 콜백 함수가 콜백 큐로 들어갑니다.
  6. 이벤트 루프는 끊임없이 호출 스택과 콜백 큐를 감시합니다.
  7. 호출 스택이 비어있는 시점에, 이벤트 루프는 콜백 큐에 있던 콜백 함수를 발견하고 호출 스택으로 옮깁니다.
  8. 드디어 콜백 함수가 실행되어 파일 내용을 출력합니다.

이러한 매커니즘 덕분에 노드제이에스(Node.js)는 단일 스레드임에도 불구하고 IO 작업을 기다리는 동안 멈추지 않고 다른 요청이나 작업을 처리할 수 있는 것입니다.

이것이 바로 노드제이에스(Node.js)가 IO 바운드 작업 처리에 매우 효율적인 이유입니다!

4. 보이지 않는 조력자 - 리부브(libuv) 라이브러리와 스레드 풀 예제

그런데 노드제이에스(Node.js)가 이런 복잡한 비동기 IO 처리를 처음부터 끝까지 혼자 다 만들었을까요?

아닙니다. 노드제이에스(Node.js)의 강력한 비동기 기능 뒤에는 리부브(libuv) 라는 든든한 조력자가 있습니다.

리부브(libuv)는 C 언어로 작성된 저수준(low-level) 라이브러리로, 노드제이에스(Node.js)에게 운영체제(OS) 종류(윈도우, 맥OS, 리눅스 등)에 상관없이 일관된 방식으로 비동기 IO 기능을 제공하는 핵심적인 역할을 합니다.

이벤트 루프 자체도 리부브(libuv)의 기능 중 하나입니다.

앞서 잠깐 언급했듯이, 모든 IO 작업이 OS 레벨에서 논블로킹을 지원하는 것은 아닙니다.

몇몇 파일 시스템 작업이나, 또는 CPU 연산 자체가 매우 많이 필요한 작업(예: 암호화, 압축)을 메인 스레드에서 직접 처리하면 결국 이벤트 루프가 해당 작업 시간 동안 멈춰버리는 '블로킹' 현상이 발생합니다.

이 문제를 해결하기 위해 리부브(libuv)는 내부적으로 작은 규모의 스레드 풀(Thread Pool) 을 가지고 있습니다(기본 4개).

OS 수준에서 논블로킹을 지원하지 않는 IO 작업이나 시간이 오래 걸리는 특정 작업들은 메인 이벤트 루프 스레드가 아닌, 이 별도의 스레드 풀에 있는 워커 스레드(Worker Thread)에게 위임됩니다.

워커 스레드가 일을 처리하는 동안, 메인 스레드는 멈추지 않고 다른 요청을 계속 처리할 수 있습니다.

작업이 완료되면 워커 스레드는 그 결과를 이벤트 루프에게 다시 알려주고, 이벤트 루프는 해당 콜백을 적절한 시점에 실행합니다.

[예제 3: libuv 스레드 풀 동작 확인하기 (파일 I/O + 암호화)]

이번 예제에서는 리부브(libuv)의 스레드 풀이 실제로 어떻게 사용되는지 보여주는 코드를 살펴보겠습니다.

비동기 파일 읽기(fs.readFile)와 상대적으로 CPU 연산이 많이 필요한 암호화 함수(crypto.pbkdf2)를 함께 사용해 보겠습니다.

setTimeout도 사용하여 이벤트 루프의 다른 작업과의 상호작용도 관찰해 볼까요?

const fs = require('fs');
const crypto = require('crypto');
const os = require('os');

// libuv의 기본 스레드 풀 크기를 확인하고 변경할 수 있습니다. (기본값은 보통 4)
// process.env.UV_THREADPOOL_SIZE = os.cpus().length; // 예: CPU 코어 수만큼 설정
console.log(`libuv 스레드 풀 크기: ${process.env.UV_THREADPOOL_SIZE || 4}`);

const startTime = Date.now();

// 타이머 설정 (10ms 뒤 실행 예약)
setTimeout(() => {
  const delay = Date.now() - startTime;
  console.log(`타이머 1 (10ms) 실행 완료: ${delay}ms 경과`);
}, 10);

// 비동기 파일 읽기 (myFile.txt가 존재한다고 가정)
// 이 작업은 libuv에 의해 처리되며, 경우에 따라 스레드 풀을 사용할 수 있습니다.
fs.readFile('./myFile.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('파일 읽기 오류:', err);
    return;
  }
  const delay = Date.now() - startTime;
  console.log(`파일 읽기 1 완료: ${delay}ms 경과`);
});

// CPU 집약적인 암호화 작업 (PBKDF2) - 비동기 방식
// 이 함수는 연산량이 많아 이벤트 루프를 차단할 수 있으므로,
// Node.js는 libuv의 스레드 풀을 사용하여 이 작업을 백그라운드에서 실행합니다.
crypto.pbkdf2('비밀번호', '솔트값', 100000, 64, 'sha512', (err, derivedKey) => {
  if (err) throw err;
  const delay = Date.now() - startTime;
  console.log(`암호화 1 (PBKDF2) 완료: ${delay}ms 경과`);
});

// 스레드 풀을 더 많이 사용하도록 암호화 작업을 몇 번 더 호출해 봅니다.
// 기본 스레드 풀 크기가 4라면, 이 작업들은 여러 스레드에 분산되어 처리될 겁니다.
for (let i = 2; i <= 5; i++) {
  crypto.pbkdf2('비밀번호', '솔트값', 100000, 64, 'sha512', (err, derivedKey) => {
    if (err) throw err;
    const delay = Date.now() - startTime;
    console.log(`암호화 ${i} (PBKDF2) 완료: ${delay}ms 경과`);
  });
}

// 동기 코드
console.log('동기 코드 실행 완료 (비동기 작업 요청 보냄)');

[예제 3 실행 결과 예상 (주의: 순서는 실행 환경에 따라 다를 수 있습니다!)]

libuv 스레드 풀 크기: 4 // 또는 설정된 값
동기 코드 실행 완료 (비동기 작업 요청 보냄)
타이머 1 (10ms) 실행 완료: 12ms 경과 // 10ms 보다 약간 늦게 실행될 수 있음
파일 읽기 1 완료: 85ms 경과       // 파일 크기와 디스크 속도에 따라 다름
암호화 3 (PBKDF2) 완료: 210ms 경과 // 스레드 풀에서 먼저 끝난 순서대로
암호화 2 (PBKDF2) 완료: 215ms 경과
암호화 5 (PBKDF2) 완료: 220ms 경과
암호화 1 (PBKDF2) 완료: 225ms 경과
암호화 4 (PBKDF2) 완료: 230ms 경과 // 마지막 암호화 완료

[결과 해석 및 libuv의 역할]

  1. 동기 코드 우선 실행: 가장 먼저 console.log('동기 코드 실행 완료...')가 출력됩니다. 이는 비동기 함수들이 호출되자마자 실행 흐름이 바로 다음으로 넘어간다는 것을 보여줍니다.
  2. 비동기 작업 요청: setTimeout, fs.readFile, crypto.pbkdf2 호출 시, 실제 작업은 노드제이에스(Node.js) 내부(백그라운드)로 보내집니다. pbkdf2나 특정 상황의 readFile 같은 작업은 리부브(libuv)에 의해 스레드 풀로 보내집니다.
  3. 타이머 실행: setTimeout으로 예약된 콜백은 지정된 시간(10ms)이 지나면 콜백 큐(타이머 큐)에 들어갑니다. 이벤트 루프는 다른 작업들을 처리하다가 적절한 시점에 이 콜백을 실행합니다. (정확히 10ms가 아닐 수 있습니다!)
  4. 스레드 풀 작업 완료: 스레드 풀에서 실행된 파일 읽기나 암호화 작업들은 완료되는 대로 해당 콜백 함수를 콜백 큐(IO 큐 또는 다른 큐)에 넣습니다. 어떤 작업이 먼저 끝날지는 예측하기 어렵습니다. 파일 읽기가 빠를 수도, 암호화 중 일부가 먼저 끝날 수도 있습니다. 위 예시에서는 파일 읽기가 암호화보다 먼저 끝났지만, 환경에 따라 다를 수 있습니다.
  5. 콜백 실행 순서: 이벤트 루프는 정해진 규칙(페이즈 순서, 큐 우선순위 등)에 따라 콜백 큐들을 확인하고, 큐에 있는 콜백 함수들을 하나씩 호출 스택으로 가져와 실행합니다. 따라서 최종 출력 순서는 작업 완료 순서 및 이벤트 루프의 스케줄링에 따라 유동적입니다. 중요한 것은, 스레드 풀에서 아무리 오래 걸리는 작업이 실행되더라도 메인 이벤트 루프는 멈추지 않고 타이머 같은 다른 작업들을 계속 처리할 수 있다는 점입니다.

이처럼 리부브(libuv)는 보이지 않는 곳에서 이벤트 루프를 돌리고, OS별 비동기 API 차이를 흡수하며, 필요시 스레드 풀을 활용하여 메인 스레드가 절대 멈추지 않도록(non-blocking) 보장합니다.

이것이 바로 노드제이에스(Node.js)가 싱글 스레드 기반임에도 불구하고 높은 동시성과 반응성을 유지할 수 있는 비결입니다.

5. Node.js, IO 바운드 작업의 강자!

자, 이렇게 해서 우리는 블로킹 IO의 문제점에서 시작하여 논블로킹 IO와 비동기 프로그래밍의 개념, 그리고 노드제이에스(Node.js)가 이를 이벤트 루프와 리부브(libuv)를 통해 어떻게 구현하는지까지 깊이 있게 살펴봤습니다.

핵심을 다시 정리하면, 노드제이에스(Node.js)는 이벤트 기반의 논블로킹 IO 모델을 사용하여 싱글 스레드 환경에서도 IO 작업을 기다리는 동안 CPU 자원을 낭비하지 않고 다른 요청들을 효율적으로 처리할 수 있다는 것입니다.

이 덕분에 웹 서버, API 서버, 실시간 채팅 애플리케이션 등 동시에 많은 IO 요청을 처리해야 하는 IO 바운드 환경에서 특히 강력한 성능과 자원 효율성을 보여줍니다.

적은 서버 자원으로도 많은 사용자를 감당할 수 있게 되는 것이죠.

물론 노드제이에스(Node.js)가 만능은 아닙니다.

앞서 살펴봤듯이, 순수하게 CPU 연산이 매우 중요한 CPU 바운드 작업에서는 전통적인 멀티스레딩 모델을 사용하는 다른 언어/플랫폼(Java, C++, Go 등)이 더 유리할 수 있습니다.

(물론 노드제이에스(Node.js)에도 워커 스레드(Worker Threads) 기능이 추가되어 CPU 바운드 작업 처리 능력이 향상되긴 했습니다.)

하지만 오늘 배운 내용을 통해 왜 노드제이에스(Node.js)가 특정 분야에서 큰 인기를 얻고 있는지, 그리고 그 내부에서는 어떤 놀라운 일들이 벌어지고 있는지 조금이나마 이해하는 계기가 되었기를 바랍니다.

그럼.