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 애플리케이션의 확장성을 개선하는 주요 전략:
수직적 확장
- CPU 및 메모리 최적화
- 이벤트 루프 블로킹 방지
- 메모리 누수 관리
- 데이터베이스 쿼리 최적화
수평적 확장
- Cluster 모듈 활용
- PM2 클러스터 모드
- 로드 밸런싱
마이크로서비스 아키텍처
- 서비스 분리
- 서비스 간 통신 (REST, 메시지 큐)
캐싱 전략
- 인메모리 캐싱
- Redis를 활용한 분산 캐싱
데이터베이스 확장성
- 데이터베이스 샤딩
- 읽기/쓰기 분리
- 인덱싱 최적화
분산 처리
- 작업 큐 활용
- 서비스 워커 구현
이러한 전략을 적절히 조합하여 구현하면 Node.js 애플리케이션의 확장성을 크게 향상시킬 수 있습니다. 애플리케이션의 특성과 요구사항에 따라 가장 적합한 전략을 선택하는 것이 중요합니다.