Velog
Node.js 애플리케이션 프로파일링
Node.js CPU 사용률이 100%로 튈 때, --prof·Chrome DevTools·Inspector API·perf + FlameGraph 네 가지 도구로 핫스팟을 찾는 방법. CPU bound vs I/O bound 구분이 출발점.
핵심 요약
Fastify /register 엔드포인트에서 crypto.pbkdf2Sync 동기 호출이 이벤트 루프를 막는 시나리오로, 4가지 프로파일링 도구를 단계적으로 사용한다. (1) node --prof 가 만드는 V8 로그 → --prof-process 로 사람이 읽을 수 있는 텍스트 변환, (2) node --inspect + Chrome DevTools 로 Performance 탭 FlameChart, (3) node:inspector/promises API 로 SIGUSR1/SIGUSR2 시그널 기반 프로파일링, (4) Linux perf record + Brendan Gregg FlameGraph 로 SVG 시각화. 모든 결과가 "pbkdf2Sync 가 CPU 시간 99%"로 수렴한다.
Node.js 성능 디버깅의 핵심은 "CPU bound인가 I/O bound인가" 를 먼저 가르는 것이다. CPU bound면 단일 스레드 이벤트 루프가 막혀서 다른 요청이 줄줄이 대기한다. 프로파일링은 "어느 함수가 이벤트 루프를 잡고 있는가"를 시각적으로 답해주는 도구다.
면접에서 "Node.js 성능을 어떻게 보냐"고 물었을 때 "CPU 모니터링한다"는 답은 약하다. 샘플링 프로파일러가 어떻게 동작하는지, 콜 스택을 bottom-up 으로 읽는 법, FlameGraph 의 가로/세로 의미를 설명할 수 있어야 한다. 이 글은 도구 4가지를 동일한 데모(pbkdf2Sync 핫스팟)로 비교해 학습 곡선을 짧게 만들어 준다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
CPU bound vs I/O bound 구분이 출발점
I/O bound (네트워크·디스크·DB) 는 비동기로 위임되어 이벤트 루프를 막지 않지만, CPU bound (해시·이미지·암호화·복잡 연산) 는 직접 이벤트 루프 위에서 돈다. CPU 100% 가 곧 "내 자바스크립트 코드가 막고 있다"는 신호.
CPU 사용률 모니터링만 보고 끝내기. 어떤 함수인지까지 가야 진짜 원인을 안다.
`--prof` 와 `--prof-process` 의 흐름
node --prof index.js 는 V8 샘플링 프로파일러를 켠다. 종료 후 isolate-0x...-v8.log 파일이 생성되며, node --prof-process <log> 로 사람이 읽을 수 있는 형태로 변환한다. [Summary] / [JavaScript] / [Bottom up] 세 섹션을 차례로 본다.
isolate-*.log 를 그대로 열어 "읽을 수 없다"고 포기하기. 반드시 --prof-process 로 후처리해야 의미가 보인다.
Chrome DevTools 로 빠르게 시각화
node --inspect index.js 후 chrome://inspect → Performance 탭. FlameChart 의 가로축은 시간, 세로축은 호출 깊이. "Bottom-Up" 탭으로 보면 함수별 누적 시간 순으로 정렬되어 핫스팟을 즉시 찾는다.
FlameChart의 "가장 넓은 박스"가 항상 문제라고 단정. 실제로는 호출 깊이와 누적 시간을 함께 봐야 정확하다.
Inspector API + 시그널 기반 프로파일링
프로덕션 프로세스를 재시작하지 않고 SIGUSR1/SIGUSR2 로 프로파일을 시작/중단. 결과는 .cpuprofile 로 떨어져 Chrome "Load profile"로 읽을 수 있다.
import * as inspector from 'node:inspector/promises';
const session = new inspector.Session();
session.connect();
await session.post('Profiler.enable');
await session.post('Profiler.start');
// ... 부하 ...
const { profile } = await session.post('Profiler.stop');
await fs.writeFile('./profile.cpuprofile', JSON.stringify(profile));
프로덕션에서 --inspect 를 켠 채 두는 것. 디버깅 포트 노출은 보안 사고의 시작이 된다 — 시그널 방식이 안전한 이유.
`perf` + FlameGraph 로 시스템 레벨까지
node --perf-basic-prof index.js & 로 컴파일된 함수 이름 매핑을 만들고, sudo perf record -F 99 -p <pid> -g. 결과를 perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg 로 시각화하면 JS + V8 + libc + 커널 까지 한 그림에 보인다.
FlameGraph 가로폭을 "실행 시간"으로 오해. 실제로는 "샘플 채취 횟수"이고 결국 비슷하지만, 셈플레이트에 따라 해석이 달라진다.
읽는 순서
- 1이론
이벤트 루프 페이즈, 샘플링 vs 인스트루멘테이션 프로파일링, FlameGraph 의 축 의미를 정리.
- 2구현
글의 데모 저장소를 클론해 (1)
--prof, (2) Chrome DevTools, (3) Inspector API + 시그널, (4)perf+ FlameGraph 네 도구를 모두 돌려보고 결과를 비교. - 3실무
사내 서비스에
perf_hooks.monitorEventLoopDelay모니터링을 붙이고, p99 지연이 50ms 이상일 때 Inspector API 로 자동 프로파일을 찍는 운영 루프를 만든다. - 4설명
"
pbkdf2Sync가 99% CPU" 같은 결과를 동료에게 5분 안에 설명할 수 있도록 FlameGraph 캡처 + 콜 스택 해석을 정리.
면접 연결 질문
[감점 답변] "console.log 로 시간 찍어본다". [좋은 답변] (1) CPU vs I/O 구분: CPU 100% 면 동기 작업 의심, 낮으면 외부 의존성/락 의심. (2) 샘플링 프로파일링: --prof 또는 --inspect 로 핫 함수 찾기. (3) 이벤트 루프 지연 측정: perf_hooks.monitorEventLoopDelay. (4) GC 압력: --trace-gc. 단계별 도구를 결합해 답하면 깊이가 보인다.
[감점 답변] "하나는 가볍고 하나는 무겁다". [좋은 답변] 샘플링(V8 --prof, perf)은 일정 주기로 콜 스택을 캡처해 통계적 추정 → 오버헤드 작고 프로덕션에서도 가능. 인스트루멘테이션(console.time, APM 트레이스)은 모든 호출을 기록 → 정확하지만 오버헤드 큼. 어떤 도구가 어떤 상황에 맞는지 트레이드오프로 답한다.
[감점 답변] "비동기로 바꾸면 된다". [좋은 답변] 동기 함수는 호출 동안 다른 요청 모두 블로킹 → 처리량 급락. 해결: (1) crypto.pbkdf2(콜백/Promise) 또는 argon2/bcrypt async 버전, (2) Worker Threads 로 CPU 작업 격리, (3) 너무 무거우면 별도 워커 큐(BullMQ 등)로 분리. "왜 비동기로 바꿔야 하는가"를 이벤트 루프 단일 스레드 사실과 연결해 설명.
자기 점검
"가장 위 = 가장 비싼"이라는 오해. 위쪽은 호출 깊이, 폭이 비용을 나타낸다.
"읽기 전용"이라고 안전하다고 생각. 디버거 프로토콜은 임의 코드 실행이 가능해 노출 자체가 사고.