Node.js 인터뷰 질문 69

질문: Node.js 애플리케이션의 확장성을 개선하는 방법에 대해 설명해주세요.

답변:

Node.js는 비동기 이벤트 기반 아키텍처로 인해 단일 서버에서도 높은 동시성을 처리할 수 있지만, 시스템 부하가 증가함에 따라 확장성 문제가 발생할 수 있습니다. 여기서는 Node.js 애플리케이션의 확장성을 개선하는 다양한 전략에 대해 알아보겠습니다.

1. 수직적 확장 (Vertical Scaling)

1.1 CPU 및 메모리 최적화

// 서버의 이벤트 루프 블로킹 최소화
// 나쁜 예: 무거운 동기 작업
function calculateFibonacciSync(n) {
  if (n <= 1) return n;
  return calculateFibonacciSync(n - 1) + calculateFibonacciSync(n - 2);
}

// 좋은 예: 작업 분리 및 비동기 처리
function calculateFibonacci(n) {
  return new Promise((resolve) => {
    setImmediate(() => {
      let a = 0,
        b = 1,
        temp;
      for (let i = 0; i < n; i++) {
        temp = a + b;
        a = b;
        b = temp;
      }
      resolve(a);
    });
  });
}

1.2 메모리 누수 방지

// 메모리 누수를 일으키는 코드
const cache = {};

function getUserData(userId) {
  if (!cache[userId]) {
    cache[userId] = fetchUserDataFromDB(userId);
  }
  return cache[userId];
}
// 문제: 캐시가 계속 커져 메모리 부족 발생 가능

// 개선된 코드: TTL 적용
const NodeCache = require("node-cache");
const userCache = new NodeCache({ stdTTL: 600 }); // 10분 만료

function getUserData(userId) {
  let userData = userCache.get(userId);
  if (!userData) {
    userData = fetchUserDataFromDB(userId);
    userCache.set(userId, userData);
  }
  return userData;
}

1.3 데이터베이스 쿼리 최적화

// 비효율적인 쿼리
async function getAllUsers() {
  // 모든 필드 선택, 제한 없음
  return await User.find({});
}

// 최적화된 쿼리
async function getActiveUsers(page = 1, limit = 20) {
  // 필요한 필드만 선택, 페이지네이션 적용
  return await User.find({ active: true })
    .select("name email lastLogin")
    .skip((page - 1) * limit)
    .limit(limit)
    .lean(); // 일반 JS 객체 반환
}

2. 수평적 확장 (Horizontal Scaling)

2.1 Cluster 모듈 활용

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} 종료`);
    console.log("새 워커 시작");
    cluster.fork();
  });
} else {
  // 워커 프로세스: HTTP 서버 시작
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end("Hello World\n");
    })
    .listen(8000);

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

2.2 PM2를 사용한 클러스터 관리

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "app",
      script: "./app.js",
      instances: "max", // CPU 코어 수에 맞게 설정
      exec_mode: "cluster", // 클러스터 모드
      watch: true,
      max_memory_restart: "1G",
      env: {
        NODE_ENV: "production",
      },
    },
  ],
};

2.3 로드 밸런싱

// 간단한 로드 밸런서 구현 예시
const http = require("http");
const httpProxy = require("http-proxy");

// 백엔드 서버 리스트
const servers = [
  { host: "localhost", port: 3001 },
  { host: "localhost", port: 3002 },
  { host: "localhost", port: 3003 },
];

let currentServer = 0;

// 라운드 로빈 방식으로 서버 선택
function getNextServer() {
  const server = servers[currentServer];
  currentServer = (currentServer + 1) % servers.length;
  return server;
}

// 프록시 생성
const proxy = httpProxy.createProxyServer();

// 로드 밸런서 서버
const server = http.createServer((req, res) => {
  const target = getNextServer();

  proxy.web(
    req,
    res,
    {
      target: `http://${target.host}:${target.port}`,
    },
    (err) => {
      console.error("프록시 오류:", err);
      res.writeHead(500);
      res.end("서버 오류");
    }
  );
});

server.listen(80, () => {
  console.log("로드 밸런서가 포트 80에서 실행 중");
});

3. 마이크로서비스 아키텍처

3.1 서비스 분리

// 모놀리식 vs 마이크로서비스 구조
// 모놀리식: 하나의 애플리케이션에 모든 기능 구현
// 마이크로서비스: 기능별로 독립적인 서비스 구현

// 사용자 서비스 예시 (user-service)
const express = require("express");
const app = express();

app.use(express.json());

// 사용자 관련 API 엔드포인트
app.get("/users", async (req, res) => {
  // 사용자 목록 조회 로직
});

app.get("/users/:id", async (req, res) => {
  // 특정 사용자 조회 로직
});

app.post("/users", async (req, res) => {
  // 사용자 생성 로직
});

app.listen(3001, () => {
  console.log("사용자 서비스가 포트 3001에서 실행 중");
});

// 주문 서비스 예시 (order-service)
// 별도 프로세스로 실행
const express = require("express");
const app = express();

app.use(express.json());

// 주문 관련 API 엔드포인트
app.get("/orders", async (req, res) => {
  // 주문 목록 조회 로직
});

app.post("/orders", async (req, res) => {
  // 주문 생성 로직 (사용자 서비스 API 호출 포함)
});

app.listen(3002, () => {
  console.log("주문 서비스가 포트 3002에서 실행 중");
});

3.2 서비스 간 통신

// REST API를 통한 동기식 통신
const axios = require("axios");

async function getUserDetails(userId) {
  try {
    const response = await axios.get(
      `http://user-service:3001/users/${userId}`
    );
    return response.data;
  } catch (error) {
    console.error("사용자 정보 조회 실패:", error);
    throw error;
  }
}

// 메시지 큐를 통한 비동기식 통신
const amqp = require("amqplib");

async function publishOrderCreatedEvent(order) {
  const connection = await amqp.connect("amqp://rabbitmq:5672");
  const channel = await connection.createChannel();

  const queue = "order_created";
  await channel.assertQueue(queue, { durable: true });

  channel.sendToQueue(queue, Buffer.from(JSON.stringify(order)), {
    persistent: true,
  });

  console.log(`주문 생성 이벤트 발행: ${order.id}`);

  setTimeout(() => {
    connection.close();
  }, 500);
}

4. 캐싱 전략

4.1 인메모리 캐싱

const NodeCache = require("node-cache");
const cache = new NodeCache({ stdTTL: 300 }); // 5분 TTL

async function getProductWithCache(productId) {
  const cacheKey = `product:${productId}`;

  // 캐시에서 조회
  const cachedProduct = cache.get(cacheKey);
  if (cachedProduct) {
    console.log("캐시 히트:", cacheKey);
    return cachedProduct;
  }

  // 캐시 미스: DB에서 조회
  console.log("캐시 미스:", cacheKey);
  const product = await Product.findById(productId);

  // 캐시에 저장
  if (product) {
    cache.set(cacheKey, product);
  }

  return product;
}

4.2 Redis 활용

const redis = require("redis");
const { promisify } = require("util");

// Redis 클라이언트 설정
const client = redis.createClient({
  host: process.env.REDIS_HOST || "localhost",
  port: process.env.REDIS_PORT || 6379,
});

// 프로미스 래핑
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
const expireAsync = promisify(client.expire).bind(client);

async function getCachedData(key, fetchFunction, ttl = 300) {
  try {
    // Redis에서 조회
    const cachedData = await getAsync(key);

    if (cachedData) {
      return JSON.parse(cachedData);
    }

    // 캐시 미스: 데이터 조회
    const freshData = await fetchFunction();

    // Redis에 저장
    await setAsync(key, JSON.stringify(freshData));
    await expireAsync(key, ttl);

    return freshData;
  } catch (error) {
    console.error("캐시 조회/저장 실패:", error);

    // 오류 발생 시 원본 함수 결과 반환
    return await fetchFunction();
  }
}

// 사용 예시
async function getUserProfile(userId) {
  return getCachedData(
    `user:${userId}:profile`,
    () => User.findById(userId).populate("preferences"),
    600 // 10분 TTL
  );
}

5. 데이터베이스 확장성

5.1 데이터베이스 샤딩

// 사용자 ID에 따른 데이터베이스 샤딩 예시
const mongoose = require("mongoose");

// 샤드 연결 배열
const shardConnections = [
  mongoose.createConnection("mongodb://shard1:27017/users"),
  mongoose.createConnection("mongodb://shard2:27017/users"),
  mongoose.createConnection("mongodb://shard3:27017/users"),
];

// 샤드 선택 함수
function getShardConnection(userId) {
  // 간단한 해싱: userId를 샤드 수로 나눈 나머지 사용
  const shardIndex = parseInt(userId, 16) % shardConnections.length;
  return shardConnections[shardIndex];
}

// 사용자 조회 함수
async function getUserByIdSharded(userId) {
  const connection = getShardConnection(userId);

  // 연결에서 User 모델 가져오기
  const User = connection.model("User", userSchema);

  return await User.findById(userId);
}

5.2 읽기/쓰기 분리

const mongoose = require("mongoose");

// 쓰기 연결 (마스터)
const writeConnection = mongoose.createConnection(
  process.env.MONGODB_MASTER_URI
);

// 읽기 연결 (레플리카)
const readConnection = mongoose.createConnection(
  process.env.MONGODB_REPLICA_URI
);

// 모델 정의
const userSchemaDefinition = {
  name: String,
  email: String,
  password: String,
  // ...
};

// 각 연결에 대한 모델 생성
const UserWrite = writeConnection.model("User", userSchemaDefinition);
const UserRead = readConnection.model("User", userSchemaDefinition);

// 쓰기 작업은 마스터에서 수행
async function createUser(userData) {
  const user = new UserWrite(userData);
  return await user.save();
}

// 읽기 작업은 레플리카에서 수행
async function findUsers(query) {
  return await UserRead.find(query);
}

6. 분산 처리

6.1 작업 큐

// Bull을 사용한 작업 큐 구현
const Queue = require("bull");

// 이미지 처리 큐 생성
const imageProcessingQueue = new Queue("image-processing", {
  redis: {
    host: process.env.REDIS_HOST || "localhost",
    port: process.env.REDIS_PORT || 6379,
  },
});

// 작업 추가
async function addImageProcessingJob(imageData) {
  return await imageProcessingQueue.add(
    {
      imageId: imageData.id,
      operations: imageData.operations,
    },
    {
      priority: imageData.priority || 1,
      attempts: 3,
      backoff: {
        type: "exponential",
        delay: 1000,
      },
    }
  );
}

// 작업 처리기 설정
imageProcessingQueue.process(async (job) => {
  console.log(`이미지 처리 작업 시작: ${job.id}`);
  const { imageId, operations } = job.data;

  // 이미지 처리 로직
  const image = await Image.findById(imageId);

  for (const operation of operations) {
    await processImage(image, operation);
    // 현재 진행 상황 업데이트
    job.progress((operations.indexOf(operation) / operations.length) * 100);
  }

  console.log(`이미지 처리 작업 완료: ${job.id}`);
  return { imageId, status: "completed" };
});

// 이벤트 처리
imageProcessingQueue.on("completed", (job, result) => {
  console.log(`작업 ${job.id} 완료:`, result);
});

imageProcessingQueue.on("failed", (job, err) => {
  console.error(`작업 ${job.id} 실패:`, err);
});

6.2 서비스 워커

// 파일 처리 워커 예시
const { parentPort, workerData } = require("worker_threads");
const fs = require("fs").promises;
const path = require("path");

async function processFile(filePath) {
  try {
    console.log(`파일 처리 시작: ${filePath}`);

    // 파일 읽기
    const content = await fs.readFile(filePath, "utf8");

    // 처리 로직 (예: 단어 수 세기)
    const wordCount = content.split(/\s+/).length;

    // 결과 반환
    return {
      filePath,
      wordCount,
      status: "success",
    };
  } catch (error) {
    return {
      filePath,
      error: error.message,
      status: "error",
    };
  }
}

// 워커 시작
processFile(workerData.filePath)
  .then((result) => {
    // 결과를 부모 프로세스로 전송
    parentPort.postMessage(result);
  })
  .catch((err) => {
    // 오류 발생 시 전송
    parentPort.postMessage({
      filePath: workerData.filePath,
      error: err.message,
      status: "error",
    });
  });

// 메인 스크립트에서 워커 사용 예시
const { Worker } = require("worker_threads");

function processFileInWorker(filePath) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./fileWorker.js", {
      workerData: { filePath },
    });

    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) {
        reject(new Error(`워커 스레드 종료 코드: ${code}`));
      }
    });
  });
}

요약

Node.js 애플리케이션의 확장성을 개선하는 주요 전략:

  1. 수직적 확장

    • CPU 및 메모리 최적화
    • 이벤트 루프 블로킹 방지
    • 메모리 누수 관리
    • 데이터베이스 쿼리 최적화
  2. 수평적 확장

    • Cluster 모듈 활용
    • PM2 클러스터 모드
    • 로드 밸런싱
  3. 마이크로서비스 아키텍처

    • 서비스 분리
    • 서비스 간 통신 (REST, 메시지 큐)
  4. 캐싱 전략

    • 인메모리 캐싱
    • Redis를 활용한 분산 캐싱
  5. 데이터베이스 확장성

    • 데이터베이스 샤딩
    • 읽기/쓰기 분리
    • 인덱싱 최적화
  6. 분산 처리

    • 작업 큐 활용
    • 서비스 워커 구현

이러한 전략을 적절히 조합하여 구현하면 Node.js 애플리케이션의 확장성을 크게 향상시킬 수 있습니다. 애플리케이션의 특성과 요구사항에 따라 가장 적합한 전략을 선택하는 것이 중요합니다.

results matching ""

    No results matching ""