Node.js 성능 최적화 | 클러스터링, 캐싱, 프로파일링
이 글의 핵심
Node.js 성능 최적화에 대한 실전 가이드입니다. 클러스터링, 캐싱, 프로파일링 등을 예제와 함께 상세히 설명합니다.
들어가며
성능 최적화의 중요성
Node는 한 프로세스 안에서 이벤트 루프로 I/O를 잘 감당하지만, CPU를 오래 쓰는 작업은 전체를 막을 수 있습니다. 그래서 클러스터·워커 스레드·캐시처럼 “누가 어떤 일을 나눠 갖는지”를 조정합니다. 최적화는 측정 없이 추측하지 말고, 프로파일러로 병목을 본 뒤 한 가지씩 바꾸는 것이 안전합니다.
성능이 중요한 이유:
- ✅ 사용자 경험: 빠른 응답 시간
- ✅ 비용 절감: 서버 리소스 효율화
- ✅ 확장성: 더 많은 사용자 처리
- ✅ SEO: 페이지 속도가 검색 순위에 영향
최적화 원칙:
- 측정 먼저: 추측하지 말고 측정
- 병목 지점 찾기: 가장 느린 부분 개선
- 점진적 개선: 한 번에 하나씩
- 트레이드오프 고려: 성능 vs 가독성
1. 클러스터링 (Clustering)
Cluster 모듈
// cluster.js
const cluster = require('cluster');
const http = require('http');
const os = require('os');
const numCPUs = 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 {
// 워커 프로세스에서 서버 실행
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end(`워커 ${process.pid}가 처리함\n`);
});
server.listen(3000, () => {
console.log(`워커 ${process.pid} 시작됨`);
});
}
실행:
node cluster.js
# 마스터 프로세스 12345 실행 중
# 워커 12346 시작됨
# 워커 12347 시작됨
# 워커 12348 시작됨
# 워커 12349 시작됨
PM2 클러스터 모드
# CPU 코어 수만큼 프로세스 생성
pm2 start app.js -i max
# 특정 개수
pm2 start app.js -i 4
# 무중단 재시작
pm2 reload app
2. 캐싱 (Caching)
인메모리 캐싱
// 간단한 캐시
const cache = new Map();
function getCachedData(key, fetchFn, ttl = 60000) {
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
console.log('캐시 히트');
return cached.data;
}
console.log('캐시 미스');
const data = fetchFn();
cache.set(key, {
data,
expiresAt: Date.now() + ttl
});
return data;
}
// 사용
app.get('/api/users', async (req, res) => {
const users = getCachedData('users', async () => {
return await User.find();
}, 60000); // 1분 캐시
res.json({ users });
});
Redis 캐싱
npm install redis
// cache.js
const redis = require('redis');
const client = redis.createClient({
host: 'localhost',
port: 6379
});
client.on('error', (err) => {
console.error('Redis 에러:', err);
});
client.connect();
// 캐시 설정
async function setCache(key, value, ttl = 3600) {
await client.setEx(key, ttl, JSON.stringify(value));
}
// 캐시 조회
async function getCache(key) {
const value = await client.get(key);
return value ? JSON.parse(value) : null;
}
// 캐시 삭제
async function deleteCache(key) {
await client.del(key);
}
module.exports = { setCache, getCache, deleteCache };
// 사용
const { getCache, setCache } = require('./cache');
app.get('/api/users/:id', async (req, res) => {
const { id } = req.params;
const cacheKey = `user:${id}`;
// 캐시 확인
let user = await getCache(cacheKey);
if (user) {
console.log('캐시 히트');
return res.json({ user, cached: true });
}
// 데이터베이스 조회
user = await User.findById(id);
if (!user) {
return res.status(404).json({ error: '사용자 없음' });
}
// 캐시 저장 (1시간)
await setCache(cacheKey, user, 3600);
res.json({ user, cached: false });
});
캐시 무효화
app.put('/api/users/:id', async (req, res) => {
const { id } = req.params;
const user = await User.findByIdAndUpdate(id, req.body, { new: true });
// 캐시 무효화
await deleteCache(`user:${id}`);
res.json({ user });
});
3. 데이터베이스 최적화
인덱스
// MongoDB
userSchema.index({ email: 1 }); // 단일 인덱스
userSchema.index({ name: 1, age: -1 }); // 복합 인덱스
userSchema.index({ email: 1 }, { unique: true });
// 인덱스 확인
const indexes = await User.collection.getIndexes();
console.log(indexes);
쿼리 최적화
// ❌ 느린 쿼리
const posts = await Post.find();
for (const post of posts) {
const author = await User.findById(post.author); // N+1 문제
}
// ✅ Populate 사용
const posts = await Post.find().populate('author');
// ✅ 필요한 필드만 선택
const posts = await Post.find()
.select('title content author')
.populate('author', 'name email');
// ✅ Lean (Mongoose 객체 → Plain Object)
const posts = await Post.find().lean(); // 더 빠름
커넥션 풀
// MongoDB
mongoose.connect('mongodb://localhost:27017/mydb', {
maxPoolSize: 10, // 최대 연결 수
minPoolSize: 2
});
// PostgreSQL
const pool = new Pool({
max: 20,
min: 5,
idleTimeoutMillis: 30000
});
4. 비동기 최적화
병렬 처리
// ❌ 순차 실행 (느림)
async function sequential() {
const users = await User.find();
const posts = await Post.find();
const comments = await Comment.find();
return { users, posts, comments };
}
// 총 시간: T1 + T2 + T3
// ✅ 병렬 실행 (빠름)
async function parallel() {
const [users, posts, comments] = await Promise.all([
User.find(),
Post.find(),
Comment.find()
]);
return { users, posts, comments };
}
// 총 시간: max(T1, T2, T3)
동시 실행 제한
// p-limit 사용
const pLimit = require('p-limit');
async function processFiles(files) {
const limit = pLimit(5); // 최대 5개 동시 실행
const results = await Promise.all(
files.map(file => limit(() => processFile(file)))
);
return results;
}
5. 메모리 관리
메모리 사용량 확인
function logMemoryUsage() {
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)} MB`, // 총 메모리
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, // 할당된 힙
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`, // 사용 중인 힙
external: `${Math.round(used.external / 1024 / 1024)} MB` // C++ 객체
});
}
setInterval(logMemoryUsage, 60000); // 1분마다
메모리 누수 방지
// ❌ 메모리 누수
const cache = new Map();
app.get('/api/data/:id', async (req, res) => {
const data = await fetchData(req.params.id);
cache.set(req.params.id, data); // 계속 쌓임!
res.json(data);
});
// ✅ LRU 캐시 사용
const LRU = require('lru-cache');
const cache = new LRU({
max: 500, // 최대 500개
maxAge: 1000 * 60 * 60 // 1시간
});
app.get('/api/data/:id', async (req, res) => {
let data = cache.get(req.params.id);
if (!data) {
data = await fetchData(req.params.id);
cache.set(req.params.id, data);
}
res.json(data);
});
스트림 사용
// ❌ 전체 파일을 메모리에 로드
app.get('/download', async (req, res) => {
const data = await fs.promises.readFile('large-file.pdf');
res.send(data);
});
// ✅ 스트림 사용
app.get('/download', (req, res) => {
const stream = fs.createReadStream('large-file.pdf');
stream.pipe(res);
});
6. 프로파일링
Node.js 내장 프로파일러
# CPU 프로파일
node --prof app.js
# 프로파일 분석
node --prof-process isolate-0x*.log > processed.txt
Chrome DevTools
# 디버그 모드로 실행
node --inspect app.js
# 또는 중단점과 함께
node --inspect-brk app.js
브라우저에서 chrome://inspect 접속 후 프로파일링.
clinic.js
npm install -g clinic
# CPU 프로파일
clinic doctor -- node app.js
# 이벤트 루프 지연
clinic bubbleprof -- node app.js
# 메모리 누수
clinic heapprofiler -- node app.js
7. 벤치마킹
Apache Bench
# 설치
sudo apt install apache2-utils
# 테스트
ab -n 1000 -c 10 http://localhost:3000/
# -n: 총 요청 수
# -c: 동시 연결 수
autocannon
npm install -g autocannon
# 테스트
autocannon -c 10 -d 10 http://localhost:3000/
# -c: 동시 연결 수
# -d: 지속 시간 (초)
벤치마크 코드
// benchmark.js
const autocannon = require('autocannon');
async function runBenchmark() {
const result = await autocannon({
url: 'http://localhost:3000',
connections: 10,
duration: 10,
pipelining: 1
});
console.log('요청/초:', result.requests.mean);
console.log('지연시간:', result.latency.mean, 'ms');
console.log('처리량:', result.throughput.mean, 'bytes/sec');
}
runBenchmark();
8. 실전 최적화 예제
예제 1: API 응답 캐싱
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient();
await client.connect();
// 캐시 미들웨어
function cacheMiddleware(ttl = 3600) {
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
try {
const cached = await client.get(key);
if (cached) {
console.log('캐시 히트');
return res.json(JSON.parse(cached));
}
// 원래 res.json을 래핑
const originalJson = res.json.bind(res);
res.json = (data) => {
client.setEx(key, ttl, JSON.stringify(data));
return originalJson(data);
};
next();
} catch (err) {
next();
}
};
}
// 사용
app.get('/api/users', cacheMiddleware(60), async (req, res) => {
const users = await User.find();
res.json({ users });
});
예제 2: 데이터베이스 쿼리 최적화
// ❌ 비효율적
async function getPostsWithAuthors() {
const posts = await Post.find();
for (const post of posts) {
post.author = await User.findById(post.author); // N+1
}
return posts;
}
// ✅ 최적화
async function getPostsWithAuthorsOptimized() {
return await Post.find()
.populate('author', 'name email')
.select('title content author createdAt')
.lean() // Plain Object로 변환 (빠름)
.limit(20);
}
예제 3: 이미지 최적화
npm install sharp
const sharp = require('sharp');
const fs = require('fs');
async function optimizeImage(inputPath, outputPath) {
await sharp(inputPath)
.resize(800, 600, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 80 })
.toFile(outputPath);
const inputSize = fs.statSync(inputPath).size;
const outputSize = fs.statSync(outputPath).size;
console.log(`압축률: ${((1 - outputSize / inputSize) * 100).toFixed(2)}%`);
}
// Express에서 사용
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('image'), async (req, res) => {
const inputPath = req.file.path;
const outputPath = `optimized/${req.file.filename}.jpg`;
await optimizeImage(inputPath, outputPath);
res.json({ path: outputPath });
});
9. 압축
gzip 압축
npm install compression
const compression = require('compression');
// 모든 응답 압축
app.use(compression());
// 조건부 압축
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6 // 압축 레벨 (0-9)
}));
효과:
- HTML: 70-90% 감소
- JSON: 60-80% 감소
- CSS/JS: 50-70% 감소
10. 자주 발생하는 문제
문제 1: 이벤트 루프 블로킹
// ❌ CPU 집약적 작업 (블로킹)
app.get('/heavy', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
res.json({ sum });
});
// ✅ Worker Threads 사용
const { Worker } = require('worker_threads');
app.get('/heavy', (req, res) => {
const worker = new Worker('./heavy-task.js');
worker.on('message', (result) => {
res.json({ sum: result });
});
worker.on('error', (err) => {
res.status(500).json({ error: err.message });
});
});
// heavy-task.js
const { parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
parentPort.postMessage(sum);
문제 2: 메모리 누수
원인: 전역 변수, 이벤트 리스너 미제거, 캐시 무한 증가
// ❌ 메모리 누수
const EventEmitter = require('events');
const emitter = new EventEmitter();
app.get('/api/data', (req, res) => {
emitter.on('data', (data) => { // 리스너가 계속 쌓임!
res.json(data);
});
});
// ✅ 리스너 제거
app.get('/api/data', (req, res) => {
const handler = (data) => {
res.json(data);
emitter.off('data', handler); // 제거
};
emitter.on('data', handler);
});
11. 실전 팁
성능 모니터링
// 응답 시간 측정
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`느린 요청: ${req.method} ${req.url} - ${duration}ms`);
}
});
next();
});
에러 처리 최적화
// ❌ 동기 에러 처리 (try-catch 오버헤드)
app.get('/api/users', async (req, res) => {
try {
const users = await User.find();
res.json({ users });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ✅ 에러 핸들러로 위임
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json({ users });
}));
// 전역 에러 핸들러
app.use((err, req, res, next) => {
logger.error(err.message, { stack: err.stack });
res.status(500).json({ error: 'Internal Server Error' });
});
Keep-Alive
const http = require('http');
const server = http.createServer((req, res) => {
res.end('Hello');
});
// Keep-Alive 설정
server.keepAliveTimeout = 65000; // 65초
server.headersTimeout = 66000; // 66초
server.listen(3000);
정리
핵심 요약
- 클러스터링: 멀티 코어 활용, PM2 클러스터 모드
- 캐싱: Redis, 인메모리 캐시, TTL 설정
- 데이터베이스: 인덱스, 쿼리 최적화, 커넥션 풀
- 비동기: 병렬 처리, 동시 실행 제한
- 메모리: 스트림 사용, LRU 캐시, 메모리 모니터링
- 압축: gzip, 이미지 최적화
성능 최적화 우선순위
- 데이터베이스 쿼리: 가장 큰 병목
- 캐싱: 빠른 효과
- 비동기 최적화: 병렬 처리
- 압축: 네트워크 비용 절감
- 클러스터링: 멀티 코어 활용
측정 도구
| 도구 | 용도 |
|---|---|
--prof | CPU 프로파일링 |
| Chrome DevTools | 메모리, CPU 분석 |
| clinic.js | 종합 진단 |
| autocannon | HTTP 벤치마크 |
| PM2 | 프로세스 모니터링 |
다음 단계
- Node.js 보안 심화
- Node.js 마이크로서비스
추천 학습 자료
도구:
문서:
관련 글
- Node.js 시작하기 | 설치, 설정, Hello World
- Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드
- Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await