Node.js 인터뷰 질문 64
질문: Node.js 애플리케이션의 성능을 최적화하는 방법에는 어떤 것들이 있나요?
답변:
Node.js 애플리케이션의 성능 최적화는 확장성 있는 애플리케이션을 구축하는 데 매우 중요합니다. 다양한 레벨에서 성능을 개선할 수 있는 방법을 살펴보겠습니다.
1. 코드 최적화
1.1 비동기 프로그래밍 활용
Node.js의 주요 장점 중 하나는 비동기 이벤트 기반 아키텍처입니다. 이를 최대한 활용해야 합니다.
비효율적인 코드:
const fs = require("fs");
// 동기식 파일 읽기 - 이벤트 루프 차단
function readFileSync(filename) {
const content = fs.readFileSync(filename, "utf8");
return content;
}
// 순차적 처리로 비효율적
function processFiles(files) {
const results = [];
for (const file of files) {
const content = readFileSync(file);
results.push(content);
}
return results;
}
최적화된 코드:
const fs = require("fs").promises;
// 비동기식 파일 읽기
async function readFile(filename) {
return await fs.readFile(filename, "utf8");
}
// Promise.all을 사용한 병렬 처리
async function processFiles(files) {
const promises = files.map((file) => readFile(file));
return await Promise.all(promises);
}
1.2 메모리 관리 최적화
// 메모리 누수를 일으킬 수 있는 코드
const cache = {};
function fetchDataWithLeak(key) {
if (!cache[key]) {
// 캐시 항목에 대한 참조가 유지되어 메모리 누수 가능성
cache[key] = fetchDataFromDatabase(key);
}
return cache[key];
}
// 최적화된 메모리 관리
const NodeCache = require("node-cache");
const dataCache = new NodeCache({ stdTTL: 600, checkperiod: 120 });
function fetchDataOptimized(key) {
let data = dataCache.get(key);
if (data === undefined) {
data = fetchDataFromDatabase(key);
dataCache.set(key, data);
}
return data;
}
1.3 V8 엔진 최적화 활용
// 비효율적인 객체 접근
function processUserData(users) {
const result = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
if (user.active === true) {
result.push({
name: user.name,
email: user.email,
lastLogin: user.lastLogin,
});
}
}
return result;
}
// 객체 구조의 일관성 유지로 V8 최적화 활용
function processUserDataOptimized(users) {
// 같은 구조의 객체를 사용하여 V8 히든 클래스 최적화 활용
return users
.filter((user) => user.active === true)
.map((user) => ({
name: user.name,
email: user.email,
lastLogin: user.lastLogin,
}));
}
2. 데이터베이스 최적화
2.1 효율적인 쿼리 작성 및 인덱싱
// 데이터베이스 쿼리 최적화 (MongoDB 예제)
async function findUsersByStatus(status) {
// 인덱스가 있는 필드 사용
const users = await User.find({ status })
.select("name email lastLogin") // 필요한 필드만 선택
.lean() // 몽구스 문서 대신 일반 JS 객체 반환
.limit(100);
return users;
}
// 데이터베이스 설정 (인덱스 생성)
const UserSchema = new mongoose.Schema({
name: String,
email: String,
status: String,
lastLogin: Date,
// ... 기타 필드
});
// 자주 쿼리하는 필드에 인덱스 추가
UserSchema.index({ status: 1 });
UserSchema.index({ email: 1 }, { unique: true });
2.2 연결 풀링 사용
// MySQL 연결 풀링 예제
const mysql = require("mysql2");
const pool = mysql.createPool({
host: "localhost",
user: "user",
password: "password",
database: "mydb",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
// 프로미스 래퍼 사용
const promisePool = pool.promise();
async function getUserById(id) {
const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [
id,
]);
return rows[0];
}
2.3 배치 작업 및 트랜잭션 활용
// 배치 삽입 (MongoDB 예제)
async function insertManyUsers(users) {
// 개별 삽입보다 배치 삽입이 훨씬 효율적
return await User.insertMany(users);
}
// 트랜잭션 사용 (MySQL 예제)
async function transferFunds(fromAccountId, toAccountId, amount) {
const connection = await promisePool.getConnection();
try {
await connection.beginTransaction();
// 출금 계좌에서 금액 차감
await connection.query(
"UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
[amount, fromAccountId, amount]
);
const [result] = await connection.query(
"SELECT ROW_COUNT() as affectedRows"
);
if (result[0].affectedRows === 0) {
throw new Error("잔액 부족 또는 계좌를 찾을 수 없음");
}
// 입금 계좌에 금액 추가
await connection.query(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
[amount, toAccountId]
);
await connection.commit();
return true;
} catch (err) {
await connection.rollback();
throw err;
} finally {
connection.release();
}
}
3. 캐싱 구현
3.1 메모리 내 캐싱
const NodeCache = require("node-cache");
const cache = new NodeCache({ stdTTL: 300 }); // 5분 TTL
async function getUserWithCache(id) {
const cacheKey = `user:${id}`;
// 캐시에서 사용자 조회
const cachedUser = cache.get(cacheKey);
if (cachedUser) {
return cachedUser;
}
// 캐시에 없으면 데이터베이스에서 조회
const user = await User.findById(id);
// 캐시에 저장
if (user) {
cache.set(cacheKey, user);
}
return user;
}
3.2 Redis 캐싱
const redis = require("redis");
const { promisify } = require("util");
const client = redis.createClient();
// Redis 메서드를 프로미스화
const getAsync = promisify(client.get).bind(client);
const setexAsync = promisify(client.setex).bind(client);
async function getProductWithRedisCache(id) {
const cacheKey = `product:${id}`;
try {
// Redis에서 데이터 조회
const cachedData = await getAsync(cacheKey);
if (cachedData) {
return JSON.parse(cachedData);
}
// 캐시에 없으면 데이터베이스에서 조회
const product = await Product.findById(id);
// Redis에 저장 (60초 TTL)
if (product) {
await setexAsync(cacheKey, 60, JSON.stringify(product));
}
return product;
} catch (err) {
console.error("Redis 캐싱 오류:", err);
// 오류 발생 시 DB에서 직접 조회
return await Product.findById(id);
}
}
3.3 데이터베이스 쿼리 결과 캐싱
// Mongoose에서 쿼리 캐싱
const mongoose = require("mongoose");
const cachegoose = require("cachegoose");
cachegoose(mongoose, {
engine: "redis",
port: 6379,
host: "localhost",
});
async function getPopularProducts() {
// 쿼리 결과를 10분 동안 캐싱
const products = await Product.find({ featured: true })
.sort("-views")
.limit(10)
.cache(600)
.exec();
return products;
}
4. HTTP 최적화
4.1 압축 사용
const express = require("express");
const compression = require("compression");
const app = express();
// 응답 압축 활성화
app.use(
compression({
// 1KB 이상 크기의 응답만 압축
threshold: 1024,
// 이미지, 비디오 등 이미 압축된 형식은 제외
filter: (req, res) => {
const contentType = res.getHeader("Content-Type");
return !/image|video|audio/.test(contentType);
},
})
);
4.2 적절한 HTTP 캐싱 설정
// 정적 자산 캐싱
app.use(
"/static",
express.static("public", {
maxAge: "1d", // 1일 동안 클라이언트 캐싱
etag: true,
lastModified: true,
})
);
// API 응답 캐싱 헤더 설정
function setCacheHeaders(req, res, next) {
// 공개 콘텐츠에 대한 캐싱 설정
res.set("Cache-Control", "public, max-age=300"); // 5분
next();
}
app.get("/api/products", setCacheHeaders, getProducts);
4.3 HTTP/2 사용
const http2 = require("http2");
const fs = require("fs");
const express = require("express");
const app = express();
// Express 애플리케이션 설정
// ...
// HTTP/2 서버 생성
const server = http2.createSecureServer(
{
key: fs.readFileSync("server.key"),
cert: fs.readFileSync("server.crt"),
},
app
);
server.listen(443, () => {
console.log("HTTP/2 서버가 포트 443에서 실행 중입니다");
});
5. 클러스터링 및 로드 밸런싱
5.1 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} 시작됨`);
}
5.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", // 1GB 메모리 사용 시 재시작
env: {
NODE_ENV: "development",
},
env_production: {
NODE_ENV: "production",
},
},
],
};
6. 스트리밍 및 버퍼링
6.1 파일 스트리밍
const fs = require("fs");
const zlib = require("zlib");
const http = require("http");
// 비효율적인 방법: 전체 파일을 메모리에 로드
http
.createServer((req, res) => {
if (req.url === "/download") {
fs.readFile("large-file.txt", (err, data) => {
if (err) throw err;
res.end(data);
});
}
})
.listen(3000);
// 스트리밍을 사용한 효율적인 방법
http
.createServer((req, res) => {
if (req.url === "/download-stream") {
// 압축 및 스트리밍
res.setHeader("Content-Encoding", "gzip");
fs.createReadStream("large-file.txt").pipe(zlib.createGzip()).pipe(res);
}
})
.listen(3001);
6.2 데이터베이스 스트리밍
// MongoDB 커서 스트리밍
function streamLargeCollection(req, res) {
const cursor = Product.find().cursor();
res.setHeader("Content-Type", "application/json");
res.write("[");
let isFirst = true;
cursor.on("data", (doc) => {
if (!isFirst) {
res.write(",");
} else {
isFirst = false;
}
res.write(JSON.stringify(doc));
});
cursor.on("end", () => {
res.write("]");
res.end();
});
cursor.on("error", (err) => {
res.status(500).json({ error: err.message });
});
}
7. 프로파일링 및 모니터링
7.1 프로파일링 도구 사용
// NODE_ENV=production node --prof app.js
// 실행 후 생성된 isolate-xxxxx-vvvvv-mmmmm.log 파일 분석:
// node --prof-process isolate-xxxxx-vvvvv-mmmmm.log > processed.txt
// 메모리 사용량 스냅샷 생성
const heapdump = require("heapdump");
// 10초마다 스냅샷 생성
setInterval(() => {
const filename = `heapdump-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error(err);
else console.log(`Heap snapshot written to ${filename}`);
});
}, 10000);
7.2 APM 도구 통합
// New Relic APM 통합
const newrelic = require("newrelic");
const express = require("express");
const app = express();
app.get("/api/users", async (req, res) => {
// 사용자 정의 트랜잭션 세그먼트 생성
newrelic
.startSegment("fetch-users-db", true, async () => {
const users = await User.find();
return users;
})
.then((users) => {
res.json(users);
})
.catch((err) => {
newrelic.noticeError(err);
res.status(500).json({ error: err.message });
});
});
8. 외부 서비스 통합 최적화
8.1 연결 풀링 및 재사용
const axios = require("axios");
const https = require("https");
// 지속적인 연결을 위한 에이전트 설정
const agent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000,
freeSocketTimeout: 30000,
});
// Axios 인스턴스 생성과 설정
const api = axios.create({
baseURL: "https://api.example.com",
httpsAgent: agent,
timeout: 5000,
});
// 재사용 가능한 API 클라이언트
async function fetchUserData(userId) {
try {
const response = await api.get(`/users/${userId}`);
return response.data;
} catch (err) {
console.error("API 요청 실패:", err);
throw err;
}
}
8.2 서킷 브레이커 패턴 구현
const CircuitBreaker = require("opossum");
// API 호출 함수
function callExternalAPI() {
return axios.get("https://api.example.com/data");
}
// 서킷 브레이커 구성
const breaker = new CircuitBreaker(callExternalAPI, {
timeout: 3000, // 3초 타임아웃
errorThresholdPercentage: 50, // 50% 실패율에서 서킷 오픈
resetTimeout: 30000, // 30초 후 반개방 상태로 전환
});
// 폴백 처리
breaker.fallback(() => {
return { data: { fallback: true, message: "서비스 일시적으로 사용 불가" } };
});
// API 요청 처리
app.get("/api/external-data", async (req, res) => {
try {
const result = await breaker.fire();
res.json(result.data);
} catch (err) {
res.status(500).json({ error: "외부 API 호출 실패" });
}
});
// 이벤트 처리
breaker.on("open", () => console.log("서킷 브레이커 열림"));
breaker.on("close", () => console.log("서킷 브레이커 닫힘"));
breaker.on("halfOpen", () => console.log("서킷 브레이커 반개방 상태"));
9. 작업 큐 활용
9.1 배경 작업 처리
const Queue = require("bull");
const nodemailer = require("nodemailer");
// 이메일 전송 큐 생성
const emailQueue = new Queue("email-queue", {
redis: {
host: "localhost",
port: 6379,
},
});
// 이메일 전송 작업 생산자
function sendWelcomeEmail(user) {
emailQueue.add(
{
to: user.email,
subject: "환영합니다!",
template: "welcome",
context: {
name: user.name,
},
},
{
attempts: 3, // 재시도 횟수
backoff: {
type: "exponential",
delay: 5000, // 초기 지연 시간 (5초)
},
}
);
}
// 이메일 전송 작업 소비자
emailQueue.process(async (job) => {
const { to, subject, template, context } = job.data;
const transporter = nodemailer.createTransport({
// 이메일 서버 설정
});
// 이메일 전송
return transporter.sendMail({
from: "no-reply@example.com",
to,
subject,
template,
context,
});
});
// 오류 처리
emailQueue.on("failed", (job, err) => {
console.error(`작업 ${job.id} 실패:`, err);
});
9.2 작업 스케줄링
const Queue = require("bull");
// 정기 작업 큐 생성
const scheduledTasksQueue = new Queue("scheduled-tasks", {
redis: { host: "localhost", port: 6379 },
});
// 매일 자정에 실행되는 정리 작업 스케줄링
scheduledTasksQueue.add(
"cleanup-expired-data",
{},
{
repeat: {
cron: "0 0 * * *", // 매일 자정
},
}
);
// 주기적으로 실행되는 메트릭 업데이트
scheduledTasksQueue.add(
"update-metrics",
{},
{
repeat: {
every: 60 * 60 * 1000, // 1시간마다
},
}
);
// 작업 처리
scheduledTasksQueue.process("cleanup-expired-data", async () => {
console.log("만료된 데이터 정리 중...");
// 오래된 레코드 삭제 로직
await ExpiredData.deleteMany({ expiresAt: { $lt: new Date() } });
});
scheduledTasksQueue.process("update-metrics", async () => {
console.log("메트릭 업데이트 중...");
// 애플리케이션 메트릭 계산 및 저장
});
10. 네이티브 모듈 및 작업자 스레드
10.1 CPU 집약적 작업에 네이티브 모듈 사용
// 네이티브 애드온 사용 예시
const nativeModule = require("./build/Release/native_module");
app.get("/process-image", (req, res) => {
const inputPath = req.query.path;
try {
// 네이티브 C++ 모듈에서 이미지 처리
const result = nativeModule.processImage(inputPath);
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
10.2 작업자 스레드 사용
const { Worker } = require("worker_threads");
// CPU 집약적 작업을 별도 스레드에서 처리
function runHeavyTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./workers/heavy-task.js", {
workerData: data,
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) {
reject(new Error(`워커가 종료 코드 ${code}와 함께 종료됨`));
}
});
});
}
// API 엔드포인트
app.post("/process-data", async (req, res) => {
try {
const result = await runHeavyTask(req.body);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// heavy-task.js (워커 파일)
const { workerData, parentPort } = require("worker_threads");
// CPU 집약적인 작업 수행
function performHeavyComputation(data) {
// 복잡한 계산 수행...
return { result: data.value * 2 };
}
// 계산 결과를 메인 스레드로 전송
const result = performHeavyComputation(workerData);
parentPort.postMessage(result);
요약
Node.js 애플리케이션의 성능을 최적화하기 위한 주요 방법은 다음과 같습니다:
코드 최적화: 비동기 프로그래밍 활용, 메모리 관리 개선, V8 엔진 최적화 활용
데이터베이스 최적화: 효율적인 쿼리 작성, 인덱싱, 연결 풀링, 배치 작업 사용
캐싱 구현: 메모리 내 캐싱, Redis 활용, 쿼리 결과 캐싱
HTTP 최적화: 응답 압축, 적절한 HTTP 캐싱 설정, HTTP/2 사용
클러스터링 및 로드 밸런싱: Node.js 클러스터 모듈 또는 PM2 활용
스트리밍 및 버퍼링: 파일 및 데이터베이스 스트리밍 활용
프로파일링 및 모니터링: 성능 병목 현상 식별 및 해결, APM 도구 통합
외부 서비스 통합 최적화: 연결 풀링, 서킷 브레이커 패턴 구현
작업 큐 활용: 백그라운드 작업 및 작업 스케줄링
네이티브 모듈 및 작업자 스레드: CPU 집약적 작업 처리
이러한 최적화 방법을 적용할 때는 항상 실제 측정을 통해 성능 향상을 확인하고, 특정 애플리케이션에 맞게 조정하는 것이 중요합니다. 모든 최적화는 복잡성을 증가시킬 수 있으므로, 각 기술의 장단점을 고려하여 필요한 곳에 적절히 적용해야 합니다.