Node.js 성능 최적화 | 클러스터링, 캐싱, 프로파일링

Node.js 성능 최적화 | 클러스터링, 캐싱, 프로파일링

이 글의 핵심

Node.js 성능 최적화에 대한 실전 가이드입니다. 클러스터링, 캐싱, 프로파일링 등을 예제와 함께 상세히 설명합니다.

들어가며

성능 최적화의 중요성

Node는 한 프로세스 안에서 이벤트 루프로 I/O를 잘 감당하지만, CPU를 오래 쓰는 작업은 전체를 막을 수 있습니다. 그래서 클러스터·워커 스레드·캐시처럼 “누가 어떤 일을 나눠 갖는지”를 조정합니다. 최적화는 측정 없이 추측하지 말고, 프로파일러로 병목을 본 뒤 한 가지씩 바꾸는 것이 안전합니다.

성능이 중요한 이유:

  • 사용자 경험: 빠른 응답 시간
  • 비용 절감: 서버 리소스 효율화
  • 확장성: 더 많은 사용자 처리
  • SEO: 페이지 속도가 검색 순위에 영향

최적화 원칙:

  1. 측정 먼저: 추측하지 말고 측정
  2. 병목 지점 찾기: 가장 느린 부분 개선
  3. 점진적 개선: 한 번에 하나씩
  4. 트레이드오프 고려: 성능 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);

정리

핵심 요약

  1. 클러스터링: 멀티 코어 활용, PM2 클러스터 모드
  2. 캐싱: Redis, 인메모리 캐시, TTL 설정
  3. 데이터베이스: 인덱스, 쿼리 최적화, 커넥션 풀
  4. 비동기: 병렬 처리, 동시 실행 제한
  5. 메모리: 스트림 사용, LRU 캐시, 메모리 모니터링
  6. 압축: gzip, 이미지 최적화

성능 최적화 우선순위

  1. 데이터베이스 쿼리: 가장 큰 병목
  2. 캐싱: 빠른 효과
  3. 비동기 최적화: 병렬 처리
  4. 압축: 네트워크 비용 절감
  5. 클러스터링: 멀티 코어 활용

측정 도구

도구용도
--profCPU 프로파일링
Chrome DevTools메모리, CPU 분석
clinic.js종합 진단
autocannonHTTP 벤치마크
PM2프로세스 모니터링

다음 단계

  • Node.js 보안 심화
  • Node.js 마이크로서비스

추천 학습 자료

도구:

문서:


관련 글

  • Node.js 시작하기 | 설치, 설정, Hello World
  • Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드
  • Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await