아니, 싱글 스레드로 어떻게 서버를 돌려?

아니, 싱글 스레드로 어떻게 서버를 돌려?

Node.js를 처음 접하셨을 때, 이런 궁금증이 들진 않으셨나요? ‘싱글 스레드라는데, 과연 여러 요청을 동시에 잘 처리할 수 있을까? 혹시 성능에 문제는 없을까?’ 하고 말이죠.

사실 제가 그랬습니다..

자바스크립트가 한 번에 하나의 작업만 처리한다면, 과연 수많은 사용자가 동시에 접속하는 서버 역할을 제대로 해낼 수 있을지 처음엔 이해하기 어려웠거든요.

그런데 Node.js의 내부 동작 방식을 조금만 자세히 들여다보면, 이 궁금증에 대한 답을 꽤 명쾌하게 찾을 수 있습니다. 핵심은 Node.js 자체는 싱글 스레드로 동작하지만, 시간이 오래 걸리는 작업, 특히 I/O(입출력) 작업들은 다른 방식으로 처리한다는 점입니다. 그리고 이 이야기를 할 때 빠지지 않고 등장하는 이름이 바로 libuv입니다.

자바스크립트 코드와 이벤트 루프, 그리고 libuv

우리가 작성하는 자바스크립트 코드는 Node.js 환경에서 돌아갑니다. 그리고 이 자바스크립트에는 원래 '이벤트 루프'라는 개념이 없습니다. 우리가 브라우저에서 setTimeout이나 addEventListener 같은 비동기 함수를 쓸 수 있는 건 브라우저가 이벤트 루프와 관련 API를 제공하기 때문이죠. Node.js 환경에서는 libuv라는 C++ 라이브러리가 바로 이 이벤트 루프를 비롯한 여러 핵심 기능을 제공합니다.

libuv는 Node.js의 심장과도 같은 존재입니다. 이벤트 루프뿐만 아니라, 운영체제(OS) 수준의 비동기 기능들, 예를 들어 파일 시스템 접근, 네트워크 요청 처리, 타이머 등을 Node.js가 사용할 수 있도록 다리 역할을 해줍니다. 간단히 말해, libuv는 자바스크립트 코드와 운영체제의 비동기 처리 능력 사이를 연결해주는 통역사라고 생각할 수 있습니다.

libuv는 멀티 스레드 라이브러리인가요?

여기서 많은 분들이 "아하, 그럼 libuv가 멀티 스레드로 돌아가서 Node.js가 빠른 거구나!" 하고 생각하실 수 있습니다. 부분적으로는 맞는 이야기입니다. libuv는 내부적으로 **스레드풀(Thread Pool)**을 가지고 있습니다. 기본적으로 4개의 스레드로 시작하고, 환경 변수 UV_THREADPOOL_SIZE를 통해 이 개수를 최대 1024개(버전에 따라 다를 수 있음, 과거에는 128개)까지 조절할 수 있습니다.

하지만 중요한 점은, 이 스레드풀이 모든 종류의 비동기 작업에 사용되는 것은 아니라는 것입니다. libuv의 스레드풀은 주로 다음과 같은 작업들을 처리하는 데 사용됩니다.

  • 파일 시스템 작업 (예: fs.readFile(), fs.writeFile())
  • 몇몇 암호화 관련 함수 (예: crypto.pbkdf2(), crypto.randomBytes())
  • 몇몇 DNS 관련 함수 (예: dns.lookup())
  • 사용자가 직접 만든 C++ 애드온에서 스레드풀을 사용하는 경우

예를 들어, 아래 코드는 암호화 작업을 스레드풀에서 실행하는 간단한 예시입니다.

import { pbkdf2 } from 'crypto'
import { performance } from 'perf_hooks'

const ITERATIONS = 4 // 스레드풀 기본 개수와 동일하게 설정
const startTime = performance.now()

console.log(`Starting ${ITERATIONS} crypto operations...`)

for (let i = 0; i < ITERATIONS; i++) {
  pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, derivedKey) => {
    if (err) throw err
    // console.log(`Operation ${i + 1} done. Key length: ${derivedKey.length}`);
    // 모든 작업 완료 시 시간 측정 (실제로는 콜백 카운팅 등으로 정확히 측정해야 함)
    if (i === ITERATIONS - 1) {
      // 이 방식은 마지막 콜백이 가장 늦게 끝난다는 보장이 없으므로 정확하지 않음
      // 실제로는 Promise.all 등을 사용하거나 카운터를 두어야 함
      // 여기서는 설명을 위해 단순화
    }
  })
}

// 이 예제에서 시간 측정 로직은 각 콜백 내부 또는 Promise.all 등을 사용해야 정확합니다.
// 아래는 단순 참고용이며, 실제 실행 순서에 따라 다르게 나올 수 있습니다.
// setTimeout(() => {
//   const endTime = performance.now();
//   console.log(`All crypto operations finished (approximately) in ${(endTime - startTime).toFixed(2)} ms`);
// }, 2000); // 예상 완료 시간보다 길게 설정

// 더 나은 측정 방식 (Promise 활용)
const promises = []
console.time('crypto_all')
for (let i = 0; i < ITERATIONS; i++) {
  promises.push(
    new Promise((resolve, reject) => {
      pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, derivedKey) => {
        if (err) reject(err)
        console.log(`Crypto task ${i + 1} done.`)
        resolve(derivedKey)
      })
    })
  )
}

Promise.all(promises)
  .then(() => {
    console.timeEnd('crypto_all')
  })
  .catch((err) => {
    console.error('Error during crypto operations:', err)
    console.timeEnd('crypto_all')
  })

만약 UV_THREADPOOL_SIZE가 4로 설정되어 있다면, 위 코드에서 4개의 pbkdf2 호출은 스레드풀의 스레드를 하나씩 할당받아 거의 동시에 처리될 수 있습니다. 다섯 번째 호출부터는 앞선 작업 중 하나가 끝날 때까지 대기열에서 기다리게 됩니다.

잠깐, 그럼 네트워크 요청은 스레드풀을 안 쓴다고요?

네, 맞습니다. 여기가 많은 분들이 의외라고 생각하는 지점일 수 있습니다. 우리가 흔히 만드는 웹 서버에서의 HTTP 요청 처리 같은 네트워크 I/O 작업은 libuv의 스레드풀을 직접 사용하지 않습니다.

대신, Node.js는 운영체제가 제공하는 논블로킹(Non-blocking) 소켓 API를 활용합니다. 예를 들어 리눅스에서는 epoll, macOS에서는 kqueue, 윈도우에서는 IOCP (I/O Completion Ports) 같은 것들이죠.

간단한 HTTP 서버를 생각해봅시다.

import http from 'http';

const server = http.createServer((req, res) => {
  // 복잡한 계산이나 블로킹 I/O가 없다고 가정
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello, World!
');
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});

이 서버로 요청이 들어오면, libuv는 운영체제에게 "이 네트워크 연결에서 뭔가 이벤트가 발생하면 알려줘"라고 등록만 해둡니다. 그리고는 다른 작업을 하러 가죠 (정확히는 이벤트 루프가 계속 돌면서 다른 이벤트가 있는지 확인합니다). 실제 데이터 수신이나 송신 같은 작업은 운영체제 커널 수준에서 비동기적으로 처리됩니다. 작업이 완료되거나 새로운 데이터가 도착하면, 커널이 libuv에게 신호를 보냅니다. 그러면 libuv는 이 이벤트를 받아 Node.js의 이벤트 루프에 전달하고, 이벤트 루프는 해당 요청에 연결된 자바스크립트 콜백 함수(위 예제에서는 (req, res) => { ... } 부분)를 실행하도록 스케줄링합니다.

이 전체 과정에서 libuv의 스레드풀에 있는 스레드들은 사용되지 않습니다. 이것이 Node.js가 싱글 스레드임에도 불구하고 수많은 네트워크 요청을 효율적으로 처리할 수 있는 비결 중 하나입니다.

그럼 libuv는 네트워크 I/O에서 정확히 뭘 하는 걸까요?

libuv가 네트워크 요청을 스레드풀에서 직접 "처리"하지 않는다면, 도대체 무슨 역할을 하는 걸까요? libuv는 일종의 이벤트 감시자(watcher)이자 중재자 역할을 합니다.

  1. Node.js 코드에서 네트워크 요청 (http.createServer, socket.connect 등)이 시작되면, libuv는 운영체제에 해당 소켓을 논블로킹 모드로 설정하고, 이 소켓에서 발생하는 이벤트(데이터 수신, 연결 종료 등)를 감시하도록 요청합니다.
  2. libuv는 이벤트 루프를 통해 이러한 요청들이 완료되었는지 또는 새로운 이벤트가 발생했는지 주기적으로 확인합니다. (정확히는 OS가 이벤트 발생 시 알려줍니다.)
  3. 운영체제로부터 이벤트 발생 알림을 받으면, libuv는 해당 이벤트와 연결된 자바스크립트 콜백 함수를 이벤트 큐에 넣습니다.
  4. 자바스크립트의 메인 스레드(이벤트 루프)는 현재 실행 중인 코드가 없을 때 이 큐를 확인하고, 큐에 있는 콜백 함수를 하나씩 꺼내어 실행합니다.

즉, libuv는 네트워크 I/O를 직접 수행하는 대신, 운영체제의 비동기 기능을 활용하여 이벤트가 발생했음을 감지하고, 이를 자바스크립트 세상으로 전달해주는 다리 역할을 하는 것입니다.

반면, 파일 I/O나 몇몇 CPU 집약적인 작업(앞서 언급된 암호화 등)처럼 운영체제가 효율적인 논블로킹 방식을 기본으로 제공하지 않거나, 작업 자체가 본질적으로 블로킹될 수밖에 없는 경우에는 libuv가 자신의 스레드풀을 활용하여 해당 작업을 백그라운드에서 처리하고, 완료되면 그 결과를 이벤트 루프에 알려줍니다.

정리하며: Node.js 성능, 제대로 이해하고 활용하기

자, 그럼 지금까지의 내용을 바탕으로 Node.js의 동작 방식과 성능에 대해 실무적인 관점에서 몇 가지 정리해볼까요?

  1. Node.js는 싱글 스레드로 동작합니다. 우리가 작성하는 자바스크립트 코드는 하나의 메인 스레드에서 순차적으로 실행됩니다.
  2. 하지만 모든 작업이 이 싱글 스레드를 막는 것은 아닙니다.
    • 네트워크 I/O (HTTP 요청, DB 쿼리 등)는 대부분 운영체제의 논블로킹 기능을 통해 처리됩니다. libuv는 이 과정을 중재하며, 스레드풀을 사용하지 않습니다. 덕분에 수많은 동시 연결을 효율적으로 관리할 수 있습니다.
    • 파일 I/O, 일부 암호화, DNS 조회 등 특정 작업들은 libuv의 스레드풀에서 비동기적으로 처리됩니다. 이 스레드풀의 기본 크기는 작지만(보통 4개), UV_THREADPOOL_SIZE 환경 변수로 조절할 수 있습니다.
  3. CPU를 많이 사용하는 작업은 여전히 메인 스레드를 블로킹합니다. 복잡한 계산, 큰 JSON 객체 파싱/직렬화 등은 메인 스레드에서 직접 실행되므로, 이런 작업이 길어지면 전체 애플리케이션의 반응성이 떨어질 수 있습니다.
    • 이런 경우에는 worker_threads 모듈을 사용하여 별도의 스레드에서 CPU 집약적인 작업을 처리하거나, 해당 기능을 마이크로서비스로 분리하는 것을 고려해야 합니다.
  4. UV_THREADPOOL_SIZE를 조절하는 것이 항상 능사는 아닙니다. 만약 애플리케이션의 병목 지점이 파일 I/O나 스레드풀을 사용하는 다른 작업에 있다면 이 값을 늘리는 것이 도움이 될 수 있습니다. 하지만 네트워크 처리량이 문제라면 스레드풀 크기보다는 코드의 비동기 처리 방식, 외부 시스템(DB 등)의 응답 속도, 또는 인스턴스 확장 등을 먼저 살펴보는 것이 좋습니다.

Node.js가 싱글 스레드라는 말은 맞지만, 그것이 곧 성능이 낮다는 의미는 아닙니다. 오히려 네트워크 중심의 I/O 바운드(I/O-bound) 애플리케이션에서는 이벤트 기반의 논블로킹 처리 방식이 매우 효율적일 수 있습니다. Node.js가 내부적으로 어떻게 동작하는지, 특히 libuv와 이벤트 루프가 어떻게 상호작용하는지 이해한다면, 우리 애플리케이션의 성능을 제대로 파악하고 최적화하는 데 큰 도움이 될 것입니다.

이 글이 Node.js의 동작 원리를 이해하는 데 조금이나마 보탬이 되었으면 좋겠습니다.