Node.js 인터뷰 질문 76

질문: Node.js가 단일 스레드 언어임에도 불구하고 어떻게 높은 동시성을 처리할 수 있는지 설명해주세요.

답변:

Node.js는 단일 스레드 이벤트 루프 모델을 기반으로 하지만, 비동기 I/O와 다양한 메커니즘을 통해 높은 동시성을 달성할 수 있습니다. 이제 Node.js가 단일 스레드 모델에서 어떻게 효율적으로 동시성을 처리하는지 살펴보겠습니다.

1. 이벤트 루프와 비동기 I/O

Node.js의 핵심은 이벤트 루프와 비동기 I/O 모델입니다. 이 모델은 다음과 같이 작동합니다:

// 비동기 파일 읽기 예시
const fs = require("fs");

console.log("시작");

// 비동기 작업 예약
fs.readFile("example.txt", "utf8", (err, data) => {
  if (err) {
    console.error("오류:", err);
    return;
  }
  console.log("파일 내용:", data);
});

console.log("종료");

// 출력 순서:
// 시작
// 종료
// 파일 내용: ...

비동기 작업이 발생하면 Node.js는 작업을 libuv 라이브러리를 통해 운영 체제의 커널이나 스레드 풀에 위임합니다. 이후 작업이 완료되면 콜백 함수가 이벤트 루프의 태스크 큐에 추가되어 실행됩니다.

2. libuv와 스레드 풀

Node.js는 내부적으로 libuv 라이브러리를 사용하여 비동기 I/O 작업을 처리합니다. libuv는 운영 체제의 비동기 메커니즘을 활용하며, 필요한 경우 스레드 풀을 사용합니다.

// 파일 시스템 작업은 내부적으로 스레드 풀을 사용
const fs = require("fs");
const crypto = require("crypto");

// 스레드 풀 크기 설정 (기본값은 4)
process.env.UV_THREADPOOL_SIZE = 8;

// CPU 집약적인 작업 (스레드 풀 활용)
function calculateHash() {
  const start = Date.now();
  // 암호화 연산은 CPU 집약적이라 스레드 풀에서 실행됨
  crypto.pbkdf2("비밀번호", "salt", 100000, 512, "sha512", () => {
    console.log(`해시 계산 완료: ${Date.now() - start}ms`);
  });
}

// 여러 해시 계산 병렬 실행
for (let i = 0; i < 8; i++) {
  calculateHash();
}

3. 클러스터 모듈

Node.js는 단일 스레드지만, 클러스터 모듈을 사용하여 여러 프로세스를 생성하고 로드 밸런싱할 수 있습니다:

const cluster = require("cluster");
const http = require("http");
const numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  console.log(`마스터 프로세스 ${process.pid} 실행 중`);

  // CPU 코어 수만큼 워커 생성
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
    // 워커가 종료되면 새 워커 시작
    cluster.fork();
  });
} else {
  // 워커 프로세스는 HTTP 서버 생성 및 요청 처리
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end(`워커 ${process.pid}가 응답함\n`);
    })
    .listen(8000);

  console.log(`워커 ${process.pid} 시작됨`);
}

4. Worker Threads 모듈

Node.js 10부터 도입된 Worker Threads 모듈을 사용하면 동일한 프로세스 내에서 병렬 JavaScript 실행이 가능합니다:

const { Worker, isMainThread, parentPort } = require("worker_threads");

if (isMainThread) {
  // 메인 스레드 코드
  console.log("메인 스레드 실행");

  const worker = new Worker(__filename);

  worker.on("message", (msg) => {
    console.log(`워커로부터 메시지: ${msg}`);
  });

  worker.postMessage("메인에서 워커로 메시지 전송");
} else {
  // 워커 스레드 코드
  console.log("워커 스레드 실행");

  // 메인 스레드로부터 메시지 수신
  parentPort.on("message", (msg) => {
    console.log(`메인으로부터 메시지: ${msg}`);

    // CPU 집약적인 작업 수행
    const result = fibonacci(42);

    // 결과를 메인 스레드로 반환
    parentPort.postMessage(`Fibonacci(42) = ${result}`);
  });
}

// CPU 집약적인 작업 예시
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

5. 비차단 코드 작성의 중요성

Node.js에서 동시성을 최대화하려면 차단(blocking) 코드를 피하는 것이 중요합니다:

// 나쁜 예: 차단 코드
function blockingOperation() {
  const start = Date.now();
  // CPU를 점유하는 무거운 연산
  while (Date.now() - start < 5000) {
    // 5초 동안 CPU를 차단
  }
  return "complete";
}

// 좋은 예: 비차단 코드
function nonBlockingOperation() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("complete");
    }, 5000);
  });
}

// 비차단 코드 사용 예시
async function handleRequest() {
  console.log("요청 시작");
  const result = await nonBlockingOperation();
  console.log("결과:", result);
  console.log("요청 종료");
}

6. Node.js 이벤트 루프 페이즈

이벤트 루프는 다음 페이즈로 구성됩니다:

  1. timers: setTimeout(), setInterval() 콜백 실행
  2. pending callbacks: 일부 시스템 작업 콜백(TCP 오류 등) 실행
  3. idle, prepare: 내부용
  4. poll: I/O 콜백 실행 및 새 I/O 이벤트 감시
  5. check: setImmediate() 콜백 실행
  6. close callbacks: socket.on('close', ...) 등 close 이벤트 콜백 실행
// 이벤트 루프 페이즈 실행 순서 예시
console.log("스크립트 시작");

// timer 페이즈에서 실행
setTimeout(() => {
  console.log("setTimeout 실행");
}, 0);

// check 페이즈에서 실행
setImmediate(() => {
  console.log("setImmediate 실행");
});

// poll 페이즈 이후 실행될 I/O 작업
fs.readFile(__filename, () => {
  // I/O 완료 후 타이머 등록
  setTimeout(() => {
    console.log("I/O 내부 setTimeout");
  }, 0);

  // I/O 완료 후 immediate 등록
  setImmediate(() => {
    console.log("I/O 내부 setImmediate");
  });

  // 현재 이벤트 루프 반복 마지막에 실행
  process.nextTick(() => {
    console.log("I/O 내부 nextTick");
  });
});

// 현재 페이즈 완료 직후 실행
process.nextTick(() => {
  console.log("nextTick 실행");
});

console.log("스크립트 종료");

요약

Node.js가 단일 스레드임에도 높은 동시성을 달성하는 방법:

  1. 이벤트 루프와 비동기 I/O: 비차단 작업 처리를 위한 핵심 아키텍처
  2. libuv와 스레드 풀: 운영 체제의 비동기 메커니즘 및 내부 스레드 풀 활용
  3. 클러스터 모듈: 여러 Node.js 프로세스를 사용한 멀티코어 활용
  4. Worker Threads: CPU 집약적 작업을 위한 멀티스레딩 지원
  5. 비차단 코드 작성: 이벤트 루프 차단을 방지하는 코딩 패턴
  6. 이벤트 루프 이해: 효율적인 작업 스케줄링을 위한 이벤트 루프 메커니즘

이러한 메커니즘을 통해 Node.js는 단일 스레드 모델을 유지하면서도 효율적인 리소스 사용과 높은 동시성을 제공합니다. 대부분의 웹 애플리케이션은 I/O 바운드 작업이 많기 때문에 이 모델은 특히 웹 서버와 API 서버에 효과적입니다.

results matching ""

    No results matching ""