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

이 글의 핵심

Node.js 서버가 느려질 때 원인은 CPU 코어를 하나만 쓰는 구조, 반복되는 DB 조회, 이벤트 루프를 막는 동기 작업, 누적되는 메모리 누수처럼 여러 갈래입니다. 추측 대신 프로파일러와 벤치마크로 병목을 먼저 확인하고, 원인별로 클러스터링·캐싱·쿼리 최적화 중 무엇을 적용할지 판단하는 순서를 제시합니다.

들어가며

성능 최적화의 중요성

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

최적화 원칙:

  1. 측정 먼저: 추측하지 말고 측정
  2. 병목 지점 찾기: 가장 느린 부분 개선
  3. 점진적 개선: 한 번에 하나씩
  4. 트레이드오프 고려: 성능 vs 가독성

클러스터링 (Clustering)

Cluster 모듈

// cluster.js
const cluster = require('cluster');
const http = require('http');
const os = require('os');
const numCPUs = os.cpus().length;
if (cluster.isPrimary) {  // Node 16 미만에서는 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 시작됨

Node.js의 자바스크립트 코드는 프로세스당 하나의 스레드(이벤트 루프)에서 실행되므로, 8코어 서버에서 node app.js 하나만 띄우면 CPU 한 개만 바쁘고 나머지 일곱 개는 놀게 됩니다. cluster 모듈은 같은 스크립트를 워커 프로세스로 여러 개 띄우고, 마스터가 3000번 포트의 연결을 받아 워커들에게 나눠 줍니다(Linux 기본값은 라운드 로빈). 워커는 메모리를 공유하지 않는 별도 프로세스라는 점이 가장 중요합니다. 앞에서 본 Map 기반 인메모리 캐시나 세션, 웹소켓 연결 목록은 워커마다 따로 존재하므로, 요청이 어느 워커로 가느냐에 따라 로그인이 풀린 것처럼 보이거나 캐시 적중률이 워커 수만큼 떨어집니다. 여러 워커가 공유해야 하는 상태는 Redis 같은 외부 저장소로 옮겨야 합니다.

exit 이벤트에서 무조건 fork()하는 코드는 편하지만, 시작하자마자 죽는 버그(잘못된 환경 변수 등)가 있으면 워커가 끝없이 죽고 다시 뜨는 루프에 빠져 CPU와 로그를 가득 채웁니다. 실무에서는 짧은 시간 안에 재시작이 반복되면 백오프를 두거나 멈추는 처리를 추가하고, 보통은 이런 관리를 직접 짜기보다 아래의 PM2나 컨테이너 오케스트레이터에 맡깁니다. Kubernetes나 ECS에서 운영한다면 한 컨테이너 안에서 클러스터를 돌리기보다 컨테이너를 코어 수만큼 복제하는 쪽이 일반적인 선택입니다.

PM2 클러스터 모드

# CPU 코어 수만큼 프로세스 생성
pm2 start app.js -i max
# 특정 개수
pm2 start app.js -i 4
# 무중단 재시작
pm2 reload app

PM2의 클러스터 모드는 내부적으로 같은 cluster 모듈을 쓰면서 재시작, 로그 수집, 모니터링(pm2 monit)을 대신 해 줍니다. pm2 reload는 워커를 하나씩 교체하므로 배포 중에도 요청을 계속 받을 수 있지만, 이것이 제대로 동작하려면 앱이 SIGINT를 받았을 때 진행 중인 요청을 마무리하고 DB 연결을 닫은 뒤 종료하는 graceful shutdown을 구현해야 합니다. 그렇지 않으면 PM2가 기본 대기 시간(kill_timeout, 1.6초) 뒤에 프로세스를 강제로 죽여, 처리 중이던 요청이 끊깁니다. -i max는 CPU 코어 수만큼 워커를 만드는데, 같은 서버에 DB나 Redis도 함께 돌고 있다면 코어를 모두 Node에 주는 것이 오히려 느릴 수 있으니 여유를 두는 것이 좋습니다.


캐싱 (Caching)

인메모리 캐싱

// 간단한 캐시
const cache = new Map();
async 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 = await fetchFn();  // async 함수의 결과를 기다려서 저장
    
    cache.set(key, {
        data,
        expiresAt: Date.now() + ttl
    });
    
    return data;
}
// 사용
app.get('/api/users', async (req, res) => {
    const users = await getCachedData('users', () => User.find(), 60000);  // 1분 캐시
    
    res.json({ users });
});

이 예제의 이전 버전은 getCachedData가 동기 함수인데 fetchFn으로 async 함수를 넘겨서, 캐시에 사용자 목록이 아니라 Promise 객체가 저장되고 res.json({ users })가 {"users":{}}를 응답하는 버그가 있었습니다. 에러는 나지 않고 빈 객체만 나오기 때문에 찾기 어려운 유형이라, 캐시 헬퍼를 만들 때는 반환 타입이 Promise인지 값인지를 가장 먼저 확인해야 합니다.

프로세스 메모리에 두는 캐시는 네트워크 왕복이 없어 가장 빠르지만 한계도 분명합니다. 앞에서 본 것처럼 클러스터의 워커마다 따로 존재하고, 프로세스가 재시작되면 사라지며, 만료된 항목을 지우는 코드가 없어 키 종류가 많으면 메모리가 계속 늘어납니다(아래 “메모리 누수 방지” 참고). 또 캐시가 비어 있는 순간 요청 100개가 동시에 들어오면 100개 모두 User.find()를 실행하는 캐시 스탬피드가 생깁니다. 이를 막으려면 결과 대신 진행 중인 Promise를 캐시에 넣어 두고 같은 키의 요청들이 그 Promise를 함께 기다리게 하는 방법을 씁니다. 사용자별 데이터가 아니라 설정값이나 코드 테이블처럼 작고 모두가 공유하는 데이터에 가장 잘 맞습니다.

Redis 캐싱

npm install redis
// cache.js
const redis = require('redis');
// node-redis v4+: host/port 대신 url(또는 socket 옵션)으로 지정
const client = redis.createClient({
    url: 'redis://localhost:6379'
});
client.on('error', (err) => {
    console.error('Redis 에러:', err);
});
client.connect().catch(console.error);
// 캐시 설정
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 };

node-redis v4부터 API가 Promise 기반으로 바뀌면서 connect()를 명시적으로 호출해야 하고, 연결 옵션도 host/port 대신 url이나 socket: { host, port }로 지정합니다. 예전 v3 방식으로 { host, port }를 넘기면 에러 없이 무시되고 기본값(localhost:6379)으로 접속하기 때문에, 로컬에서는 잘 되다가 Redis가 다른 호스트에 있는 운영 환경에서만 ECONNREFUSED가 나는 혼란스러운 상황이 생깁니다. connect()가 끝나기 전에 명령을 보내면 ClientClosedError: The client is closed가 나므로, 서버 시작 전에 연결을 기다리는 것이 안전합니다.

JSON.stringify로 저장하면 Date는 문자열로, Mongoose 문서는 순수 객체로 바뀌어 돌아온다는 점도 기억해야 합니다. 캐시에서 꺼낸 user.createdAt에 getTime()을 호출하면 TypeError가 나는 식입니다. 그리고 Redis가 잠시 내려가면 getCache가 예외를 던져 API 전체가 실패하게 되는데, 캐시는 “있으면 빠르고 없어도 동작해야 하는” 계층이므로 캐시 조회 실패 시에는 로그만 남기고 DB로 넘어가도록 try/catch로 감싸는 편이 좋습니다.

// 사용
// 변수 선언 및 초기화
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 });
});

캐시를 “수정 후 삭제”하는 이 방식(cache-aside)은 가장 단순하고 흔하지만, 동시성에서 틈이 있습니다. 요청 A가 캐시 미스로 DB에서 옛 값을 읽은 직후, 요청 B가 값을 수정하고 캐시를 지우고, 그 뒤에 A가 옛 값을 캐시에 쓰면 TTL이 끝날 때까지 옛 데이터가 남습니다. 드물지만 트래픽이 많으면 실제로 일어나는 경쟁 조건이라, TTL을 무한으로 두지 않는 것이 최소한의 안전장치입니다. 또 user:${id} 하나만 지우므로, 이 사용자가 포함된 목록(/api/users 응답 캐시)은 그대로 옛 값을 보여 줍니다. 캐시 키 설계와 무효화 범위를 함께 정해 두지 않으면 “수정했는데 목록에는 반영이 안 된다”는 버그 보고가 반복됩니다. 캐시 삭제가 실패하는 경우를 대비해 DB 수정과 캐시 삭제 순서, 실패 시 재시도도 고려해야 합니다.


데이터베이스 최적화

인덱스

// 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);

인덱스가 없으면 MongoDB는 조건에 맞는 문서를 찾으려고 컬렉션 전체를 훑는 COLLSCAN을 합니다. 문서가 수천 개일 때는 티가 나지 않다가 수백만 개가 되면 쿼리 하나에 몇 초씩 걸리는데, User.find({ email }).explain('executionStats')로 실행 계획을 보면 totalDocsExamined가 반환 문서 수보다 훨씬 큰지로 인덱스 사용 여부를 확인할 수 있습니다. 복합 인덱스 { name: 1, age: -1 }은 앞쪽 필드부터 쓰일 수 있어서 name으로만 검색할 때는 쓰이지만 age로만 검색할 때는 쓰이지 않습니다. 인덱스는 조회를 빠르게 하는 대신 쓰기마다 갱신 비용과 메모리를 쓰므로, 모든 필드에 거는 것이 아니라 실제로 자주 쓰는 조회 조건에 맞춰 만듭니다. Mongoose는 개발 편의를 위해 앱 시작 시 스키마의 인덱스를 자동 생성(autoIndex)하는데, 운영 환경의 큰 컬렉션에서 이 작업이 부하를 일으킬 수 있어 운영에서는 끄고 마이그레이션으로 관리하는 경우가 많습니다.

쿼리 최적화

// ❌ 느린 쿼리
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();  // 더 빠름

N+1 문제는 게시글 N개를 가져온 뒤 작성자를 한 명씩 조회해 쿼리가 N+1번 나가는 패턴입니다. 게시글이 100개면 DB 왕복이 101번이 되고, 왕복 한 번에 1ms만 걸려도 100ms가 추가됩니다. populate는 작성자 ID를 모아 $in 쿼리 한 번으로 가져오므로 쿼리가 2번으로 줄어듭니다. lean()은 결과를 Mongoose 문서 객체(getter/setter, save() 같은 메서드, 변경 추적 포함)가 아닌 순수 자바스크립트 객체로 돌려주어 메모리와 변환 비용을 크게 줄입니다. 대신 가상 속성(virtuals)과 스키마의 getter가 적용되지 않고 doc.save()를 쓸 수 없으므로, 읽기 전용 API 응답에 쓰는 것이 적합합니다.

커넥션 풀

// MongoDB
mongoose.connect('mongodb://localhost:27017/mydb', {
    maxPoolSize: 10,  // 최대 연결 수
    minPoolSize: 2
});
// PostgreSQL
const pool = new Pool({
    max: 20,
    min: 5,
    idleTimeoutMillis: 30000
});

커넥션 풀 크기는 클수록 좋은 것이 아닙니다. DB 쪽에서 연결 하나하나가 메모리와 프로세스(PostgreSQL은 연결당 백엔드 프로세스)를 차지하고, 동시에 실행되는 쿼리가 CPU 코어 수를 크게 넘으면 오히려 전체 처리량이 떨어집니다. 특히 클러스터와 함께 쓸 때 계산을 빠뜨리기 쉬운데, 워커 8개가 각자 max: 20 풀을 만들면 서버 한 대가 최대 160개 연결을 열고, 서버가 세 대면 480개가 되어 PostgreSQL 기본 max_connections(100)를 훌쩍 넘습니다. 이때 증상은 sorry, too many clients already 에러로 나타납니다. 워커 수 × 풀 크기 × 서버 수가 DB 한도 안에 들어오도록 정하거나, PgBouncer 같은 연결 풀러를 앞에 둡니다.


비동기 최적화

병렬 처리

// ❌ 순차 실행 (느림)
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)

await를 연달아 쓰면 앞의 작업이 끝나야 다음 작업을 시작하므로, 서로 의존하지 않는 조회라면 Promise.all로 동시에 시작하는 것만으로 응답 시간이 가장 느린 작업 하나 수준으로 줄어듭니다. 다만 Promise.all은 하나라도 실패하면 즉시 reject되고 나머지 결과는 버리므로, 일부 실패를 허용해야 한다면 Promise.allSettled를 씁니다. 또 병렬로 보낸 쿼리들은 결국 같은 DB와 같은 커넥션 풀을 나눠 쓰므로, 요청 하나가 쿼리 수십 개를 동시에 보내면 풀이 고갈되어 다른 요청들이 연결을 기다리게 됩니다. 병렬화는 대기 시간을 줄여 주지만 DB가 해야 할 일의 총량을 줄이지는 않는다는 점을 기억해 두세요.

동시 실행 제한

// 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;
}

파일 1만 개를 Promise.all(files.map(processFile))로 한꺼번에 처리하면 파일 디스크립터가 바닥나 EMFILE: too many open files가 나거나, 외부 API에 요청을 동시에 1만 개 보내 rate limit(429)에 걸립니다. p-limit은 동시에 실행되는 작업 수를 제한하면서도 전체 결과는 Promise.all처럼 순서대로 모아 줍니다. 참고로 p-limit은 v4부터 ES 모듈 전용 패키지라서 위처럼 require로 불러오면 ERR_REQUIRE_ESM 에러가 납니다. CommonJS 프로젝트라면 v3을 고정해서 쓰거나, 프로젝트를 ES 모듈로 전환하고 import pLimit from 'p-limit'으로 불러와야 합니다(Node.js 22 이후 버전은 require로 ES 모듈을 불러오는 기능도 지원합니다).


메모리 관리

메모리 사용량 확인

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분마다

네 값의 의미를 구분해 두면 메모리 문제의 종류를 좁힐 수 있습니다. heapUsed는 자바스크립트 객체가 실제로 쓰는 V8 힙이고, heapTotal은 V8이 확보해 둔 힙 전체, external은 V8 바깥에서 관리되는 메모리(Buffer, 일부 네이티브 모듈)이며, rss는 이 모두를 포함해 운영체제가 보는 프로세스 전체 메모리입니다. heapUsed가 GC 이후에도 계속 우상향한다면 자바스크립트 객체 누수이고, heapUsed는 평평한데 rss만 계속 오른다면 Buffer나 네이티브 모듈 쪽을 의심합니다. V8 힙에는 기본 상한이 있어서 이를 넘으면 FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory로 프로세스가 죽는데, --max-old-space-size로 상한을 올리는 것은 진짜 누수라면 죽는 시점을 늦출 뿐입니다. 컨테이너에서 실행한다면 이 상한을 컨테이너 메모리 제한보다 작게 두어야 OOM Killer가 먼저 프로세스를 죽이는 상황을 피할 수 있습니다.

메모리 누수 방지

// ❌ 메모리 누수
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 캐시 사용 (lru-cache v7+: named export, maxAge → ttl)
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({
    max: 500,  // 최대 500개
    ttl: 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);
});

첫 번째 코드는 요청마다 다른 id가 들어오는 한 Map이 끝없이 커지는 전형적인 누수입니다. 개발 환경에서는 id 종류가 적어서 드러나지 않다가, 운영에서 며칠이 지나 메모리 그래프가 계단식으로 오르고 결국 heap out of memory로 재시작되는 형태로 나타납니다. LRU 캐시는 최대 개수를 넘으면 가장 오래 사용되지 않은 항목부터 버려서 메모리 상한을 보장합니다. lru-cache 패키지는 버전에 따라 API가 크게 바뀌었는데, v7부터 maxAge가 ttl로 바뀌었고 최신 버전은 const { LRUCache } = require('lru-cache')처럼 이름 있는 export를 씁니다. 오래된 예제 코드를 그대로 쓰면 LRU is not a constructor 에러가 나거나, 옵션 이름이 달라 TTL이 조용히 적용되지 않습니다. 개수 대신 메모리 크기로 제한하려면 maxSize와 sizeCalculation 옵션을 사용합니다.

스트림 사용

// ❌ 전체 파일을 메모리에 로드
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);
});

readFile은 파일 전체를 Buffer로 메모리에 올린 뒤 보내므로, 500MB 파일을 동시에 10명이 받으면 5GB가 필요합니다. 스트림은 파일을 작은 조각(기본 64KB)으로 읽어 보내고, 클라이언트가 느리면 읽기를 잠시 멈추는 배압(backpressure) 을 pipe가 자동으로 처리해 메모리 사용량을 일정하게 유지합니다. 한 가지 주의할 점은 pipe가 에러를 전파하지 않는다는 것입니다. 파일이 없으면 읽기 스트림에서 ENOENT 에러가 나는데 처리하지 않으면 프로세스가 Unhandled 'error' event로 종료될 수 있고, 클라이언트가 다운로드 중 연결을 끊으면 읽기 스트림이 닫히지 않고 남을 수 있습니다. const { pipeline } = require('stream/promises')의 await pipeline(stream, res)를 쓰면 어느 쪽에서 에러가 나든 양쪽 스트림을 모두 정리해 주므로 더 안전합니다. Express라면 res.download()나 res.sendFile()이 이런 처리와 Content-Type, 범위 요청까지 알아서 해 줍니다.


프로파일링

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 접속 후 프로파일링.

DevTools를 연결하면 Performance 탭에서 CPU 프로파일을, Memory 탭에서 힙 스냅샷을 찍을 수 있습니다. 메모리 누수를 찾을 때는 스냅샷을 세 번 찍는 방법이 흔히 쓰입니다. 서버를 띄우고 한 번, 의심되는 API에 요청을 수천 번 보낸 뒤 한 번, 다시 같은 만큼 보낸 뒤 한 번 찍고, 두 번째와 세 번째 스냅샷을 “Comparison” 보기로 비교해 계속 늘어나는 객체 종류를 찾습니다. 그 객체의 “Retainers”를 따라가면 누가 그 객체를 붙잡고 있는지(대개 전역 Map, 이벤트 리스너, 클로저) 알 수 있습니다. --inspect는 디버그 포트를 열기 때문에 운영 서버에서 0.0.0.0으로 열어 두면 원격 코드 실행이 가능해지므로, 반드시 127.0.0.1에만 바인드하고 SSH 터널로 접속해야 합니다.

clinic.js

npm install -g clinic
# CPU 프로파일
clinic doctor -- node app.js
# 이벤트 루프 지연
clinic bubbleprof -- node app.js
# 메모리 누수
clinic heapprofiler -- node app.js

clinic doctor는 CPU 사용률, 이벤트 루프 지연, 메모리, 활성 핸들 수를 함께 기록한 뒤 “이벤트 루프가 막힌 것 같다”, “I/O 대기가 문제로 보인다”처럼 문제의 종류를 먼저 추정해 줍니다. 그 결과에 따라 CPU 문제라면 clinic flame(플레임 그래프), 비동기 흐름 문제라면 bubbleprof, 메모리라면 heapprofiler로 좁혀 들어가는 것이 이 도구의 사용 순서입니다. 도구를 실행한 상태에서 autocannon으로 부하를 주어야 의미 있는 데이터가 모이며, 한가한 서버를 프로파일링하면 아무것도 보이지 않습니다.


벤치마킹

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();

벤치마크 결과를 볼 때는 평균 지연 시간보다 p99 지연 시간(result.latency.p99)이 더 중요합니다. 평균 20ms인 서버라도 100번 중 한 번이 2초 걸린다면 사용자 체감은 크게 나빠지고, 이벤트 루프가 가끔 막히는 문제는 평균에는 거의 드러나지 않고 p99에만 나타납니다. 측정할 때는 벤치마크 도구와 서버를 같은 머신에서 돌리지 않는 것이 좋습니다. 부하 생성기 자체가 CPU를 나눠 쓰기 때문에 서버 성능이 실제보다 낮게 나옵니다. 또 -c 10처럼 동시 연결 수가 적으면 서버의 한계가 아니라 네트워크 왕복 시간을 재는 셈이므로, 연결 수를 늘려 가며 처리량이 더 이상 오르지 않는 지점과 그때의 지연 시간을 함께 기록하는 것이 서버 용량을 파악하는 방법입니다. 캐시가 있는 API라면 같은 URL만 반복 요청하면 캐시 적중 성능만 재게 된다는 점도 주의하세요.


실전 최적화 예제

예제 1: API 응답 캐싱

const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient();
client.connect().catch(console.error);  // CommonJS에서는 최상위 await 불가
// 캐시 미들웨어
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) => {
                if (res.statusCode === 200) {
                    client.setEx(key, ttl, JSON.stringify(data)).catch(() => {});
                }
                return originalJson(data);
            };
            
            next();
        } catch (err) {
            next();
        }
    };
}
// 사용
app.get('/api/users', cacheMiddleware(60), async (req, res) => {
    const users = await User.find();
    res.json({ users });
});

res.json을 가로채 응답 본문을 캐시에 쓰는 방식은 라우트 코드를 건드리지 않고 캐시를 붙일 수 있어 편리합니다. 이전 버전의 코드는 상태 코드를 확인하지 않아서, DB 장애 때 나간 500 에러 응답까지 1시간 동안 캐시되어 장애가 복구된 뒤에도 계속 에러를 내보내는 문제가 있었기에 200 응답만 저장하도록 고쳤습니다. 캐시 키로 req.originalUrl을 쓰므로 ?page=1과 ?page=2는 다른 키가 되지만, 사용자마다 다른 응답을 주는 API(로그인 사용자의 “내 정보”)에 이 미들웨어를 붙이면 다른 사용자의 데이터가 캐시에서 나가는 심각한 사고가 납니다. 인증이 필요한 응답은 캐시 키에 사용자 ID를 넣거나 아예 이 방식으로 캐시하지 않아야 합니다. 또 원래 예제처럼 모듈 최상위에서 await client.connect()를 쓰면 CommonJS 파일에서는 SyntaxError: await is only valid in async functions가 나므로, ES 모듈로 작성하거나 서버 시작 함수 안에서 연결을 기다려야 합니다.

예제 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);
}

최적화 버전에는 쿼리 수를 줄이는 것 외에도 두 가지가 더 들어 있습니다. .select()로 필요한 필드만 가져와 네트워크로 전송되는 데이터와 역직렬화 비용을 줄이고, .limit(20)으로 한 번에 가져오는 문서 수를 제한합니다. 실무에서 가장 흔한 성능 문제 중 하나가 목록 API에 페이지네이션이 없어 데이터가 늘어날수록 응답이 점점 느려지는 것이라, limit과 함께 skip 또는 마지막 항목의 _id를 기준으로 다음 페이지를 가져오는 커서 방식 페이지네이션을 적용하는 것이 좋습니다. 큰 skip 값은 건너뛸 문서를 모두 읽어야 해서 뒤 페이지로 갈수록 느려지므로, 데이터가 많다면 커서 방식이 유리합니다.

예제 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 });
});

sharp는 C로 작성된 libvips를 사용하므로 순수 자바스크립트 이미지 라이브러리보다 훨씬 빠르고, 이미지 처리가 libuv 스레드 풀에서 실행되어 이벤트 루프를 직접 막지 않습니다. 다만 스레드 풀의 기본 크기는 4라서(UV_THREADPOOL_SIZE), 큰 이미지 업로드가 몰리면 같은 스레드 풀을 쓰는 파일 I/O와 crypto, DNS 조회까지 줄을 서게 됩니다. 업로드가 많은 서비스라면 이미지 변환을 요청 처리 경로에서 떼어 내 큐와 별도 워커로 처리하는 편이 안정적입니다. 이 예제는 원본 파일을 uploads/에 그대로 남기므로 처리 후 삭제해야 디스크가 차지 않으며, multer에 limits: { fileSize }를 지정하지 않으면 거대한 파일 업로드로 디스크를 채우는 공격에도 취약합니다.


압축

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% 감소

위 수치는 텍스트 데이터에서 흔히 보는 범위이며, 이미 압축된 JPEG·PNG·동영상은 다시 압축해도 거의 줄지 않고 CPU만 씁니다. compression 미들웨어의 기본 필터는 Content-Type을 보고 압축할 만한 응답만 고르고, 기본적으로 1KB보다 작은 응답은 압축하지 않습니다. 압축은 CPU를 써서 네트워크를 아끼는 트레이드오프라, 단일 스레드인 Node.js 프로세스에서 큰 응답을 매번 압축하면 그만큼 다른 요청을 처리할 시간이 줄어듭니다. 운영 환경에서는 Nginx나 CDN 같은 앞단에서 압축을 맡기고 Node.js에서는 끄는 구성이 일반적이며, 정적 파일은 빌드할 때 미리 .gz/.br 파일을 만들어 두면 요청마다 압축할 필요가 없습니다.


자주 발생하는 문제

문제 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);

첫 번째 코드가 돌아가는 몇 초 동안은 서버의 다른 모든 요청이 멈춥니다. 헬스 체크 요청도 응답하지 못하므로, 로드 밸런서가 이 서버를 죽은 것으로 판단해 트래픽에서 빼 버리는 연쇄 장애로 이어지기도 합니다. worker_threads는 별도 스레드에 V8 인스턴스를 하나 더 띄워 계산을 넘기므로 메인 이벤트 루프는 계속 다른 요청을 처리할 수 있습니다.

다만 위 예제처럼 요청마다 새 Worker를 만드는 것은 워커 생성에 수십 밀리초와 수 MB의 메모리가 들어서, 동시 요청이 몰리면 스레드가 수백 개 만들어져 서버가 버티지 못합니다. 실무에서는 piscina 같은 워커 풀 라이브러리로 코어 수 정도의 워커를 미리 만들어 두고 작업을 큐에 넣는 방식을 씁니다. 워커와 메인 스레드 사이의 데이터는 복사되어 전달되므로 큰 객체를 주고받으면 직렬화 비용이 들고, 큰 바이너리 데이터라면 ArrayBuffer를 전송 목록에 넣어 복사 없이 넘길 수 있습니다. 참고로 0부터 10억까지의 합은 약 5×10¹⁷로, 자바스크립트 Number가 정확히 표현할 수 있는 정수 범위(2⁵³ ≈ 9×10¹⁵)를 넘어서 이 예제의 결과는 정확하지 않습니다. 정확한 값이 필요하다면 BigInt를 써야 합니다.

문제 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);
});

첫 번째 코드는 요청이 올 때마다 리스너를 하나씩 추가하기만 해서, 리스너 함수와 그 클로저가 붙잡고 있는 res 객체가 모두 메모리에 남습니다. 리스너가 11개를 넘으면 Node.js가 MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added라는 경고를 출력하므로, 이 경고는 무시하지 말고 누수의 신호로 받아들여야 합니다. setMaxListeners로 경고 한도를 올려서 경고를 없애는 것은 증상만 가리는 것입니다.

수정한 코드는 한 번 실행된 뒤 자신을 제거하므로 emitter.once('data', ...)로 더 간단히 쓸 수 있습니다. 하지만 'data' 이벤트가 끝내 발생하지 않으면 리스너가 여전히 남아 있고, 클라이언트는 응답을 영원히 기다립니다. 실제 코드에서는 타임아웃을 두어 일정 시간이 지나면 리스너를 제거하고 에러 응답을 보내거나, 클라이언트가 연결을 끊었을 때(req.on('close', ...)) 리스너를 정리해야 합니다. Node.js 15 이상에서는 events.once(emitter, 'data', { signal })과 AbortSignal.timeout()을 조합하면 이 처리를 Promise 기반으로 짧게 쓸 수 있습니다.


운영에서 먼저 드러나는 것들: 느린 요청, 잡히지 않는 에러, Keep-Alive

느린 요청을 로그로 남기기

프로파일러를 붙이기 전에, 어떤 경로가 실제로 느린지부터 알아야 합니다. res.on('finish')는 응답이 커널로 넘어간 뒤 호출되므로 미들웨어를 맨 앞에 두면 라우트·DB·직렬화 시간이 모두 포함됩니다. 클라이언트가 응답을 받기 전에 연결을 끊으면 finish 대신 close만 발생하니, 끊긴 요청까지 보고 싶다면 close도 함께 들어야 합니다.

// 응답 시간 측정
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.originalUrl} - ${duration}ms`);
        }
    });
    
    next();
});

async 에러를 전역 핸들러로 넘기기

// ❌ 라우트마다 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' });
});

예전에는 try-catch가 V8의 최적화를 막는다는 이야기가 있었지만, V8의 TurboFan 컴파일러(Node.js 8 이후)부터는 try-catch가 있는 함수도 정상적으로 최적화되므로 성능 차이는 사실상 없습니다. asyncHandler를 쓰는 진짜 이유는 Express 4가 async 함수의 reject를 잡지 못하기 때문입니다. 라우트에서 await한 쿼리가 실패하면 Express 4는 그 에러를 모른 채 요청을 방치하고, 클라이언트는 타임아웃까지 기다리며, 콘솔에는 UnhandledPromiseRejection만 남습니다. asyncHandler는 reject를 next(err)로 넘겨 전역 에러 핸들러가 처리하게 만들고, 라우트마다 같은 catch 블록을 반복하지 않게 해 줍니다. Express 5부터는 라우트 핸들러가 반환한 Promise의 reject를 기본으로 next에 넘겨 주므로 이 래퍼가 필요 없습니다. 전역 에러 핸들러에서 err.message를 그대로 클라이언트에 내보내지 않고 일반적인 메시지만 보내는 것도 내부 정보 노출을 막는 좋은 습관입니다.

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);

Keep-Alive는 요청이 끝난 뒤에도 TCP 연결을 열어 두어 다음 요청이 연결 수립(TCP·TLS 핸드셰이크)을 생략하게 합니다. Node.js의 기본 keepAliveTimeout은 5초인데, AWS ALB처럼 앞단 로드 밸런서의 유휴 타임아웃이 60초라면 문제가 생깁니다. 로드 밸런서는 연결이 아직 살아 있다고 보고 요청을 보냈는데 Node.js는 5초 뒤 이미 그 연결을 닫아 버려서, 드물게 502 Bad Gateway가 나는 원인을 찾기 어려운 장애가 됩니다. 그래서 Node.js의 keepAliveTimeout을 로드 밸런서의 유휴 타임아웃(60초)보다 길게 두는 것이 위 설정의 목적이고, headersTimeout은 keepAliveTimeout보다 조금 더 길게 두어야 헤더를 받는 도중 연결이 끊기는 경쟁 조건을 피할 수 있습니다. 반대로 Node.js가 다른 서비스를 호출하는 쪽이라면 http.Agent({ keepAlive: true })로 나가는 연결을 재사용해야 하는데, Node.js 19 이전 버전은 기본 전역 에이전트가 Keep-Alive를 쓰지 않아 외부 API 호출마다 새 연결을 맺었습니다.


어디부터 손댈지

측정 결과에 따라 달라지지만, 웹 API에서는 대체로 데이터베이스 쿼리(인덱스 누락, N+1 쿼리)가 가장 먼저 병목으로 드러나고, 그다음이 캐시로 없앨 수 있는 반복 계산과 외부 호출입니다. 클러스터링은 코어를 더 쓰게 해 줄 뿐 요청 하나의 지연을 줄이지 못하므로, 느린 요청이 문제라면 순서상 마지막입니다. 반대로 CPU 사용률이 한 코어에서만 100%에 붙어 있고 나머지가 놀고 있다면 그때가 클러스터링이나 worker_threads를 볼 시점입니다.

도구쓰는 때
node --cpu-prof / --prof어떤 함수가 CPU를 쓰는지 볼 때
Chrome DevTools (--inspect)힙 스냅샷을 비교해 누수를 찾을 때
clinic.js이벤트 루프 지연·I/O 대기 중 무엇이 문제인지 모를 때
autocannon수정 전후 처리량과 지연 분포를 비교할 때

참고 자료: Node.js 프로파일링 가이드, clinic.js, autocannon


자주 묻는 질문 (FAQ)

Q. CPU를 많이 쓰는 API 하나 때문에 서버 전체 응답이 느려지는 이유는 무엇인가요?

A. Node.js는 자바스크립트를 하나의 이벤트 루프 스레드에서 실행하므로, 큰 반복문 같은 동기 계산이 도는 동안 다른 요청은 모두 대기합니다. 이런 작업은 worker_threads로 별도 스레드에 넘기거나 별도 워커 프로세스로 분리해야 합니다. 클러스터링으로 프로세스 수를 늘리면 여러 코어를 쓸 수는 있지만, 각 프로세스 안에서 이벤트 루프가 막히는 문제 자체를 해결하지는 못합니다.


같이 보면 좋은 글