Velog
Node.js 애플리케이션 프로파일링 (심화)
Node.js 의 V8 내장 프로파일러는 `--prof` + `--prof-process` 두 단계로 충분하다. 핵심은 "무엇이 CPU 를 쓰는가" 가 아니라 "무엇이 동기인가" 를 찾아내는 것.
핵심 요약
Node 내장 프로파일러 사용 흐름은 다음과 같다.
# 1) 틱 로그 수집
NODE_ENV=production node --prof app.js
# 2) 부하 테스트(ab, k6 등)
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=..."
# 3) 사람이 읽을 수 있는 보고서로 변환
node --prof-process isolate-*.log > processed.txt
출력 보고서는 다음 섹션을 본다.
| 섹션 | 무엇을 보여주나 | 핵심 신호 |
|---|---|---|
[Shared libraries] | OS/런타임 native 코드 비중 | 비정상적으로 높으면 syscall(IO) 병목 |
[JavaScript] | 사용자 JS 함수 비중 | *Sync 류·정규식이 상위면 동기 블록 |
[C++] | V8 내부·crypto 등 | 암호화/압축 라이브러리 핫스팟 |
[Bottom up] | 함수 → 부모 | 누가 호출했는가 를 거꾸로 추적 |
예시: Express /auth 에서 pbkdf2Sync 가 JS 섹션 1위면 → pbkdf2 (콜백) 또는 pbkdf2 + util.promisify 로 분리해 이벤트 루프 해제.
Node.js 프로파일링은 "이벤트 루프를 막는 코드를 찾는다" 가 본질이다. V8 프로파일러는 일정 간격마다 콜 스택을 샘플링해 틱 로그 를 남기고, --prof-process 가 이를 상위 함수 표 + 라이브러리/JS/C++ 분류표 로 가공한다. 그래서 면접에서도 "느린 함수 찾는다" 가 아니라 "동기 CPU 작업을 비동기로 분리한다" 라는 결론까지 도달해야 한다.
Node 서버는 한 스레드에서 모든 요청을 돌리기 때문에 한 핸들러의 동기 CPU 작업 이 그 자체로 SLA 위반이다. pbkdf2Sync, JSON.stringify(거대한 객체), 정규식 백트래킹 같은 함수가 그렇다. 프로파일링 결과 해석은 "누가 비싼가" 가 아니라 "누가 동기인가" 를 묻는 도구로 봐야 한다.
현실에서 이 분석이 필요한 상황:
- p99 가 갑자기 뛰는 요청
- CPU 100% 인데 RPS 가 안 오르는 서버
- async/await 으로 짰는데도 처리량이 안 늘 때
이런 상황에서 "어떤 함수에서 막히는지" 를 logical step 으로 잘라 설명할 수 있어야 시니어 면접에서 통한다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
`--prof` 의 결과는 "무엇이 동기인가" 를 묻는 도구
내장 프로파일러는 일정 간격(기본 1ms)마다 현재 콜 스택을 샘플링한다. 그래서 샘플 비중 ≈ 그 함수가 메인 스레드를 점유한 시간이다. Node 의 핵심 통증은 동기 CPU 작업이 이벤트 루프를 막는 것 이므로, 비중 1위 함수가 *Sync 라면 거의 확실히 범인이다.
[JavaScript]:
ticks total nonlib name
1.6% ... pbkdf2Sync <-- 동기 CPU
0.4% ... JSON.stringify
샘플 비중을 "그 함수가 느리다" 로만 해석하는 것. 핵심은 "이게 동기적으로 메인 스레드를 잡고 있다" 라는 사실이다.
"Bottom up" 으로 호출 경로를 거꾸로 추적
보고서 하단의 Bottom up (heavy) profile 은 자식 → 부모 순서로 출력한다. 같은 함수가 여러 부모에서 불릴 때 어느 핸들러에서 들어왔는가 를 빠르게 찾는 유일한 방법.
[Bottom up (heavy) profile]:
ticks parent name
9 55.0% pbkdf2Sync
9 100.0% Module._compile -> /auth
이걸 보면 "pbkdf2Sync 의 부하가 100% /auth 핸들러에서 발생" 임을 단번에 알 수 있다.
상위 함수 목록만 보고 끝내는 것. 같은 함수가 여러 진입점을 통해 불리면 어디를 고쳐야 하는지 가 안 보인다.
동기 작업을 "실제로" 분리하는 두 가지 패턴
프로파일링이 "여기 동기다" 라고 알려주면 다음 둘 중 하나로 분리한다.
// (1) 비동기 콜백 버전 사용
import { pbkdf2 } from 'crypto';
import { promisify } from 'util';
const pbkdf2Async = promisify(pbkdf2);
await pbkdf2Async(password, salt, 10000, 512, 'sha512');
// (2) Worker 풀로 위임 (CPU bound)
import { Worker } from 'worker_threads';
// 또는 piscina, workerpool 등의 풀 라이브러리
IO 바운드면 (1) — 콜백/Promise 가 충분. CPU 바운드면 (2) — Worker 가 필요. pbkdf2 는 둘 다 가능하지만, 키 길이/반복 수가 크면 Worker 가 우세.
*Sync 를 *Async 콜백으로만 바꾸면 다 풀린다고 착각. 동일 스레드 라 CPU 100% 부하면 동시 요청은 여전히 줄 서 있다. CPU bound 는 Worker 필수.
읽는 순서
- 1이론
샘플링 프로파일러의 원리(
tick= 콜 스택 스냅샷)와--prof-process의 출력 섹션 4가지(Shared libraries / JavaScript / C++ / Bottom up)를 정리한다. - 2구현
Express +
pbkdf2Sync핸들러를 만들고ab -c 20으로 부하를 준 뒤--prof로그를 떠서, 어떤 함수가 1위인지 직접 확인한다. 그리고pbkdf2(콜백) 또는 Worker 로 옮겨 처리량을 비교한다. - 3실무
현재 서비스의 가장 느린 엔드포인트 1개에 대해 부하 테스트 환경에서 프로파일을 떠보고, 동기 CPU 작업/IO 대기/GC 비중 중 어느 항목이 1위인지 분류한다.
- 4설명
동료에게 "왜
pbkdf2Sync가 한 줄짜리인데 서버 전체 처리량을 죽이는지" 를 5분 안에 이벤트 루프 그림 없이 말로 설명한다.
면접 연결 질문
[감점 답변] "가장 느린 함수를 찾는다". [좋은 답변] (1) node --prof 로 부하 시간 동안 틱 수집 → (2) --prof-process 로 보고서 → (3) JavaScript 섹션 상위 + Bottom up 두 곳을 본다. 상위가 *Sync/정규식/큰 JSON 류라면 동기 CPU 차단 이고, 부모 트레이스로 어떤 핸들러 인지 좁힌다. (4) 그 핸들러를 promisify 또는 Worker 로 분리. "느린 함수" 가 아니라 "동기 함수" 가 핵심.
[감점 답변] "비동기로 바꿨는데 왜 안 되지?". [좋은 답변] pbkdf2 는 내부적으로 libuv 스레드 풀(기본 4) 위에서 돈다. 동시에 5개 이상 들어오면 큐잉되어 결국 CPU 가 직렬화된다. 풀 크기를 UV_THREADPOOL_SIZE 로 늘리거나, 진짜 CPU 바운드라면 Worker pool 로 옮기고 코어 수만큼 분산한다. 즉 "비동기 = 무료" 가 아니라 어떤 자원 위에서 비동기인지를 본다.
[감점 답변] "성능에 영향이 적으니 그냥 켜자". [좋은 답변] 항상 켜는 건 권장하지 않는다. 디스크/IO 비용과 1~3% 오버헤드, 그리고 개인정보가 함수명/스택에 노출 가능성. 대신 (1) 부하 테스트 환경에서 상시, (2) 운영에서는 inspector + Chrome DevTools, 또는 Continuous Profiler (Pyroscope/Datadog) 처럼 샘플링 기반 도구를 쓴다.
자기 점검
[C++] 비중이 높으면 무조건 native 라이브러리 문제라고 단정. 실제로는 V8 내부 GC 나 crypto 처럼 사용자 JS 가 유발한 경우가 많다.
비중이 높으면 무조건 CPU 라고 보는 것. libuv/시스템 호출 비중 이 높으면 IO 대기일 수 있다.