Node.js 인터뷰 질문 45

질문: Node.js에서 클러스터링(Clustering)은 무엇이며, 어떻게 구현하나요? 클러스터링의 장단점과 사용 사례에 대해 설명해주세요.

답변:

Node.js에서 클러스터링은 단일 서버 머신에서 여러 Node.js 프로세스(워커)를 실행하여 다중 코어 시스템의 성능을 극대화하는 기술입니다. Node.js는 기본적으로 단일 스레드로 작동하므로, 클러스터링은 여러 프로세스를 실행하여 다중 코어 시스템의 이점을 활용하는 방법입니다.

클러스터링의 작동 원리

Node.js의 클러스터링은 cluster 모듈을 통해 구현됩니다. 이 모듈은 프로세스 포킹(forking)을 통해 작동합니다:

  1. 마스터 프로세스: 주 프로세스로, 워커 프로세스를 생성하고 관리합니다.
  2. 워커 프로세스: 마스터에 의해 생성된 자식 프로세스로, 실제 애플리케이션 코드를 실행합니다.

클러스터링의 핵심은 이러한 모든 워커 프로세스가 동일한 포트에서 수신할 수 있다는 것입니다. 마스터 프로세스는 연결을 수신하고 이를 라운드 로빈 방식으로 워커 프로세스에 분배합니다.

기본 클러스터링 구현

다음은 Node.js의 cluster 모듈을 사용한 기본적인 클러스터링 구현입니다:

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

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

  // 워커 생성
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 워커 종료 시 로그
  cluster.on("exit", (worker, code, signal) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
    // 종료된 워커 대체를 위한 새 워커 생성
    console.log("새 워커 생성 중...");
    cluster.fork();
  });
} else {
  // 워커는 HTTP 서버를 실행함
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end(`안녕하세요! 워커 ${process.pid}가 응답했습니다\n`);
    })
    .listen(8000);

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

이 코드는 시스템의 CPU 코어 수만큼 워커 프로세스를 생성합니다. 각 워커는 동일한 포트(8000)에서 수신하는 HTTP 서버를 실행합니다. 워커가 종료되면 마스터 프로세스가 새 워커를 생성하여 대체합니다.

PM2를 사용한 클러스터링

PM2는 Node.js 애플리케이션을 위한 프로세스 관리자로, 클러스터링을 좀 더 쉽게 구현할 수 있습니다:

# PM2 설치
npm install pm2 -g

# 클러스터 모드로 애플리케이션 시작 (모든 가용 코어 사용)
pm2 start app.js -i max

# 특정 수의 인스턴스로 시작
pm2 start app.js -i 4

PM2 구성 파일(ecosystem.config.js)을 사용한 예:

module.exports = {
  apps: [
    {
      name: "app",
      script: "./app.js",
      instances: "max",
      exec_mode: "cluster",
      watch: true,
      env: {
        NODE_ENV: "development",
      },
      env_production: {
        NODE_ENV: "production",
      },
    },
  ],
};

Node.js 클러스터링의 장점

  1. 성능 향상: 다중 코어 활용을 통해 처리량이 크게 증가합니다. CPU 코어 수에 비례하여 성능이 거의 선형적으로 증가할 수 있습니다.

  2. 고가용성: 워커 프로세스가 실패하더라도 다른 워커들이 계속 요청을 처리할 수 있습니다. 마스터 프로세스는 실패한 워커를 감지하고 새 워커를 시작할 수 있습니다.

  3. 무중단 서비스: 새 코드 배포 시 워커를 하나씩 재시작하여 애플리케이션 다운타임 없이 업데이트할 수 있습니다(롤링 재시작).

  4. 자원 격리: 각 워커는 독립적인 프로세스이므로, 한 워커의 문제가 다른 워커에 영향을 미치지 않습니다(메모리 누수 등의 문제 격리).

Node.js A클러스터링의 단점

  1. 공유 상태 관리의 어려움: 워커 간에 메모리를 직접 공유할 수 없으므로, 공유 상태를 관리하기 위해 외부 저장소(Redis, 데이터베이스 등)나 IPC(프로세스 간 통신)가 필요합니다.

  2. 메모리 오버헤드: 각 워커는 Node.js의 전체 런타임을 가지므로, 더 많은 메모리를 사용합니다.

  3. 복잡성 증가: 싱글 프로세스 모델에 비해 디버깅과 애플리케이션 설계가 더 복잡해질 수 있습니다.

  4. 로드 밸런싱 제한: 기본 라운드 로빈 방식은 워커 간 부하가 고르지 않을 수 있습니다. 일부 요청은 다른 요청보다 처리 비용이 클 수 있기 때문입니다.

고급 클러스터링 기법

1. 워커 간 통신(IPC)

워커 간 메시지 전달을 통한 통신:

if (cluster.isMaster) {
  const worker = cluster.fork();

  // 워커에게 메시지 보내기
  worker.send({ type: "command", action: "update_cache" });

  // 워커로부터 메시지 받기
  cluster.on("message", (worker, message) => {
    console.log(`마스터가 워커 ${worker.id}로부터 메시지 수신:`, message);
  });
} else {
  // 마스터로부터 메시지 받기
  process.on("message", (message) => {
    console.log("워커가 메시지 수신:", message);

    // 작업 수행 후 응답
    process.send({ type: "response", status: "completed" });
  });
}

2. 제로 다운타임 재시작

코드 업데이트 시 워커를 하나씩 재시작하여 다운타임 없이 서비스를 유지하는 방법:

if (cluster.isMaster) {
  // 워커 포크
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 워커 재시작을 위한 함수
  function restartWorkers() {
    const workers = Object.values(cluster.workers);

    const restartWorker = (i) => {
      if (i >= workers.length) return;

      const worker = workers[i];
      console.log(`워커 ${worker.process.pid} 재시작 중...`);

      // 이 워커가 종료되면 새 워커 시작 및 다음 워커 재시작
      worker.on("exit", () => {
        if (!worker.exitedAfterDisconnect) return;

        const newWorker = cluster.fork();
        newWorker.on("listening", () => {
          restartWorker(i + 1);
        });
      });

      // 워커 연결 끊기 (정상 종료 유도)
      worker.disconnect();
    };

    restartWorker(0);
  }

  // SIGUSR2 신호를 받으면 워커 재시작
  process.on("SIGUSR2", () => {
    console.log("SIGUSR2 신호 수신, 워커 재시작...");
    restartWorkers();
  });
}

3. 상태 공유 - Redis 활용

워커 간 상태 공유를 위한 Redis 사용 예:

const Redis = require("ioredis");
const redis = new Redis();

if (cluster.isMaster) {
  // 워커 포크
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  const app = express();

  // 세션 저장소로 Redis 사용
  const session = require("express-session");
  const RedisStore = require("connect-redis")(session);

  app.use(
    session({
      store: new RedisStore({ client: redis }),
      secret: "your-secret-key",
      resave: false,
      saveUninitialized: false,
    })
  );

  // 캐시 관련 라우트
  app.get("/set-cache", (req, res) => {
    redis.set("cached-key", "cached-value");
    res.send("캐시 설정됨");
  });

  app.get("/get-cache", async (req, res) => {
    const value = await redis.get("cached-key");
    res.send(`캐시된 값: ${value}`);
  });

  app.listen(3000);
}

클러스터링 활용 사례

1. 고부하 API 서버

많은 동시 요청을 처리해야 하는 API 서버는 클러스터링을 통해 처리량을 크게 증가시킬 수 있습니다.

// api-server.js
const cluster = require("cluster");
const express = require("express");
const numCPUs = require("os").cpus().length;

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

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
    cluster.fork(); // 종료된 워커 대체
  });
} else {
  const app = express();

  app.get("/api/data", (req, res) => {
    // 데이터 처리 로직
    res.json({ data: "some data", worker: process.pid });
  });

  app.listen(3000, () => {
    console.log(`워커 ${process.pid}가 포트 3000에서 대기 중`);
  });
}

2. 실시간 데이터 처리

웹소켓이나 실시간 데이터 스트림을 처리하는 애플리케이션:

// realtime-server.js
const cluster = require("cluster");
const http = require("http");
const { Server } = require("socket.io");
const Redis = require("ioredis");
const numCPUs = require("os").cpus().length;

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

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
    cluster.fork();
  });
} else {
  const app = http.createServer();
  const io = new Server(app);

  // Redis 어댑터로 워커 간 소켓 이벤트 공유
  const redisAdapter = require("socket.io-redis");
  io.adapter(redisAdapter({ host: "localhost", port: 6379 }));

  io.on("connection", (socket) => {
    console.log(`워커 ${process.pid}에 클라이언트가 연결됨`);

    socket.on("message", (data) => {
      // 모든 클라이언트에 브로드캐스트
      io.emit("message", {
        ...data,
        workerPid: process.pid,
        timestamp: new Date(),
      });
    });
  });

  app.listen(3000, () => {
    console.log(`워커 ${process.pid}가 포트 3000에서 대기 중`);
  });
}

3. CPU 집약적 작업 처리

데이터 처리나 계산 집약적인 작업을 수행하는 애플리케이션:

// worker-pool.js
const cluster = require("cluster");
const numCPUs = require("os").cpus().length;

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

  const tasks = []; // 처리할 작업 목록
  const results = []; // 처리된 결과 저장

  // 샘플 작업 생성
  for (let i = 0; i < 100; i++) {
    tasks.push(i);
  }

  // 워커 생성
  for (let i = 0; i < numCPUs; i++) {
    const worker = cluster.fork();

    // 워커로부터 결과 수신
    worker.on("message", (message) => {
      if (message.type === "result") {
        results.push(message.data);

        // 아직 처리할 작업이 있으면 워커에게 전달
        if (tasks.length > 0) {
          const task = tasks.shift();
          worker.send({ type: "task", data: task });
        } else if (results.length === 100) {
          // 모든 작업 완료 시 결과 출력 및 종료
          console.log("모든 작업 완료!");
          console.log(`총 결과 수: ${results.length}`);

          // 모든 워커 종료
          for (const id in cluster.workers) {
            cluster.workers[id].kill();
          }
        }
      }
    });

    // 초기 작업 할당
    if (tasks.length > 0) {
      const task = tasks.shift();
      worker.send({ type: "task", data: task });
    }
  }
} else {
  // 작업 수신 및 처리
  process.on("message", (message) => {
    if (message.type === "task") {
      const task = message.data;

      // CPU 집약적 작업 시뮬레이션
      let result = 0;
      for (let i = 0; i < 10000000; i++) {
        result += Math.sqrt(task * i);
      }

      // 결과를 마스터에게 전송
      process.send({
        type: "result",
        data: {
          task: task,
          result: result,
          workerPid: process.pid,
        },
      });
    }
  });

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

모니터링 및 로드 밸런싱 전략

1. 클러스터 모니터링

클러스터 상태를 모니터링하는 예:

if (cluster.isMaster) {
  // 정기적인 상태 보고
  setInterval(() => {
    let stats = {
      total: Object.keys(cluster.workers).length,
      timestamp: new Date(),
      load: process.cpuUsage(),
      memory: process.memoryUsage(),
    };

    console.log("클러스터 상태:", stats);

    // 각 워커에게 상태 요청
    for (const id in cluster.workers) {
      cluster.workers[id].send({ type: "status_query" });
    }
  }, 10000);

  // 워커 상태 수신
  cluster.on("message", (worker, message) => {
    if (message.type === "status_report") {
      console.log(`워커 ${worker.id} 상태:`, message.data);
    }
  });
} else {
  // 마스터로부터 상태 요청 처리
  process.on("message", (message) => {
    if (message.type === "status_query") {
      process.send({
        type: "status_report",
        data: {
          pid: process.pid,
          uptime: process.uptime(),
          memory: process.memoryUsage(),
          load: process.cpuUsage(),
        },
      });
    }
  });
}

2. 워커 상태에 따른 부하 분산

워커의 현재 부하에 따라 작업을 분배하는 예:

if (cluster.isMaster) {
  const workers = [];

  // 워커 생성 및 초기 상태 설정
  for (let i = 0; i < numCPUs; i++) {
    const worker = cluster.fork();
    workers.push({
      id: worker.id,
      process: worker,
      load: 0,
    });
  }

  // 새 작업을 처리할 워커 선택
  function selectWorker() {
    // 가장 부하가 적은 워커 선택
    workers.sort((a, b) => a.load - b.load);
    return workers[0];
  }

  // HTTP 요청 수신 및 워커 할당
  http
    .createServer((req, res) => {
      const worker = selectWorker();
      worker.load++;

      // 워커에게 요청 전달
      worker.process.send({
        type: "request",
        data: {
          url: req.url,
          method: req.method,
          headers: req.headers,
          id: Date.now(),
        },
      });

      // 특정 요청 ID에 대한 응답 추적을 위한 맵
      const responseHandlers = new Map();
      responseHandlers.set(requestId, res);

      // 워커로부터 응답 수신
      worker.process.on("message", (message) => {
        if (message.type === "response" && responseHandlers.has(message.id)) {
          const res = responseHandlers.get(message.id);
          res.writeHead(message.statusCode, message.headers);
          res.end(message.body);

          // 응답 처리 후 워커 부하 감소
          worker.load--;
          responseHandlers.delete(message.id);
        }
      });
    })
    .listen(8000);
} else {
  // 마스터로부터 요청 처리
  process.on("message", (message) => {
    if (message.type === "request") {
      const req = message.data;

      // 요청 처리 로직
      let result = processRequest(req);

      // 응답을 마스터에게 전송
      process.send({
        type: "response",
        id: req.id,
        statusCode: 200,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(result),
      });
    }
  });

  function processRequest(req) {
    // 실제 요청 처리 로직
    return {
      success: true,
      data: `처리됨: ${req.url}`,
      worker: process.pid,
    };
  }
}

프로덕션 환경에서의 고려 사항

  1. 메모리 제한 설정: 각 Node.js 워커의 메모리 제한을 설정하여 메모리 사용량을 제어합니다.

    NODE_OPTIONS="--max-old-space-size=2048" pm2 start app.js -i 4
    
  2. Health Checks: 워커 상태를 정기적으로 확인하고 필요에 따라 재시작합니다.

    // 워커에서 health-check 엔드포인트 제공
    app.get("/health", (req, res) => {
      const stats = {
        uptime: process.uptime(),
        memory: process.memoryUsage(),
        pid: process.pid,
      };
    
      // 메모리 사용량이 임계값을 초과하면 불량 상태 응답
      if (stats.memory.heapUsed > 1.5 * 1024 * 1024 * 1024) {
        // 1.5GB
        return res.status(500).json({ status: "unhealthy", stats });
      }
    
      res.json({ status: "healthy", stats });
    });
    
  3. 로깅 전략: 클러스터링된 환경에서는 로그가 여러 프로세스에서 생성될 수 있으므로, 중앙집중식 로깅이 중요합니다.

    const winston = require("winston");
    const logger = winston.createLogger({
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.json()
      ),
      defaultMeta: { service: "api-service", worker: process.pid },
      transports: [
        new winston.transports.File({ filename: "error.log", level: "error" }),
        new winston.transports.File({ filename: "combined.log" }),
      ],
    });
    
    // 클러스터링된 환경에서 콘솔 출력은 혼란스러울 수 있음
    if (process.env.NODE_ENV !== "production") {
      logger.add(
        new winston.transports.Console({
          format: winston.format.simple(),
        })
      );
    }
    
  4. 무중단 배포: 클러스터링을 활용한 무중단 배포 전략을 구현합니다.

    // PM2 사용 시
    // ecosystem.config.js
    module.exports = {
      apps: [
        {
          name: "app",
          script: "./app.js",
          instances: "max",
          exec_mode: "cluster",
          wait_ready: true, // 워커가 'ready' 이벤트를 보낼 때까지 대기
          listen_timeout: 10000, // 'ready' 이벤트 대기 시간
          kill_timeout: 5000, // SIGINT 후 SIGKILL까지 대기 시간
        },
      ],
    };
    
    // app.js
    if (!cluster.isMaster) {
      const app = express();
      const server = app.listen(3000, () => {
        console.log(`워커 ${process.pid} 준비됨`);
    
        // PM2에게 워커가 준비되었음을 알림
        process.send("ready");
      });
    
      // 정상 종료 처리
      process.on("SIGINT", () => {
        console.log(`워커 ${process.pid} 정상 종료 중...`);
        server.close(() => {
          console.log(`워커 ${process.pid} 종료됨`);
          process.exit(0);
        });
      });
    }
    

요약

Node.js의 클러스터링은 다중 코어 시스템에서 애플리케이션 성능을 최적화하기 위한 필수적인 기술입니다. 주요 포인트는 다음과 같습니다:

  1. 기본 원리: 마스터 프로세스가 여러 워커 프로세스를 생성하고 관리하며, 수신 연결을 이들 간에 분배합니다.

  2. 장점: 성능 향상, 고가용성, 무중단 서비스, 자원 격리.

  3. 단점: 공유 상태 관리의 어려움, 메모리 오버헤드, 개발 복잡성 증가.

  4. 구현 방법: Node.js의 내장 cluster 모듈 또는 PM2와 같은 프로세스 관리자를 사용할 수 있습니다.

  5. 고급 기법: 워커 간 통신(IPC), 제로 다운타임 재시작, Redis를 활용한 상태 공유.

  6. 사용 사례: 고부하 API 서버, 실시간 데이터 처리, CPU 집약적 작업 처리.

  7. 모니터링 및 로드 밸런싱: 워커 상태에 따른 효율적인 부하 분산이 중요합니다.

  8. 프로덕션 고려 사항: 메모리 제한, 상태 점검, 중앙집중식 로깅, 무중단 배포 전략.

클러스터링을 효과적으로 구현하면 Node.js 애플리케이션이 시스템 자원을 최대한 활용하여 더 나은 성능과 안정성을 제공할 수 있습니다.

results matching ""

    No results matching ""