"배포하면 왜 느려지지?" — Grafana Pyroscope와 OTLP Profiles로 Node.js 프로덕션 FlameGraph를 트레이스에 연결하기
Node.js 백엔드를 운영하다 보면 한 번쯤 이런 상황을 만납니다. Grafana에서 특정 엔드포인트의 P95 레이턴시가 갑자기 솟구친 걸 발견하고 Tempo로 트레이스를 열어보는데, 스팬 타임라인만 봐서는 "이 1.2초가 어디서 왔는지" 도무지 알 수 없는 상황. DB 쿼리도 아니고, 외부 HTTP 호출도 아니고 — Node 프로세스가 뭔가를 열심히 하고 있는데 무엇을 하는지 알 방법이 없는 거죠.
그런 상황에 처하면 보통 로컬에서 --inspect로 Chrome DevTools를 붙이거나 clinic flame을 돌려봅니다. 재현에 성공하면 다행이지만, 프로덕션의 성능 문제는 대부분 로컬이나 스테이징에서 재현이 안 됩니다. 트래픽 패턴이 다르고, 메모리 상태가 다르고, JIT 최적화 상태가 다르기 때문입니다.
Continuous Profiling은 이 문제를 정면으로 해결합니다. 프로덕션에서 CPU 사용량과 콜 스택을 중단 없이 수집해두고, "그 순간" 무슨 일이 있었는지를 사후에 FlameGraph로 재연할 수 있게 해줍니다. Grafana Pyroscope가 그 저장소 역할을 하고, OTLP Profiles가 OTel 에코시스템과 프로파일을 표준화된 방식으로 연결하는 접착제가 됩니다. 이 글에서는 Node.js 환경에 이 스택을 실제로 붙이는 과정과, 트레이스에서 FlameGraph로 원클릭 드릴다운하는 Span Profiles 연동까지 다룹니다.
핵심 개념
Continuous Profiling vs 일회성 프로파일링
처음 "Continuous Profiling"이라는 단어를 들었을 때 흔한 오해는 "그냥 프로파일링 자동화 아냐?"입니다. 하지만 차이는 꽤 근본적입니다.
| 항목 | 일회성 프로파일링 | Continuous Profiling |
|---|---|---|
| 타이밍 | 이슈 발생 시 수동으로 연결 | 상시 수집, 사후 조회 |
| 환경 | 주로 로컬·스테이징 | 프로덕션 직접 |
| 재현성 | 재현 가능한 문제만 분석 가능 | 간헐적·일회성 이슈도 소급 분석 |
| 오버헤드 | 제한 없음 (잠깐만 쓰니까) | CPU 2~3% 미만 설계 (실측 사례) |
| 데이터 보존 | 세션 동안만 | 수주~수개월 보관 가능 |
"이미 지나간 성능 이슈를 과거 프로파일로 소급 분석한다" — 이 한 문장이 Continuous Profiling의 핵심 가치입니다. 장애 후 포스트모템에서 "그때 무슨 함수가 CPU를 잡아먹었는지" FlameGraph로 돌려보는 게 가능해집니다.
FlameGraph 읽는 법
FlameGraph는 X축이 시간이 아니라 **CPU 점유 비율(샘플 수)**를 나타냅니다. 넓은 블록 = 많이 실행됨 = 병목 후보. Y축은 콜 스택 깊이를 나타내며, Pyroscope 기본 뷰는 Icicle 형식으로 위가 호출자(루트), 아래가 피호출자(리프)입니다.
┌─────────────────────────────────────────────────────────────────┐
│ handleRequest (전체의 73%) │
│ ├── parseBody (12%) │ processQuery (58%) │ sendResponse (3%) │
│ │ ├── buildSQL (8%) │
│ │ └── executeQuery (50%) ← 병목 │
└─────────────────────────────────────────────────────────────────┘찾아야 할 것은 가장 넓은 최상단 블록이 아니라, 넓고 납작한 블록 — 자식 함수를 별로 안 부르면서 자기 자신이 넓은 블록입니다. 그게 실제 CPU를 태우는 자리입니다.
OTLP Profiles — OTel의 네 번째 신호
OpenTelemetry는 원래 Traces·Metrics·Logs 세 가지 신호를 표준화했습니다. OTLP Profiles는 2026년 3월 Public Alpha로 진입한 네 번째 신호로, 프로파일 데이터를 OTel 생태계에 통합하는 명세입니다.
핵심 구조는 pprof 포맷을 기반으로 하되 OTel의 Resource·InstrumentationScope 컨텍스트를 추가하고, 트레이스의 Span ID와 직접 연결(link)할 수 있는 필드를 포함합니다. 이 링크가 Span Profiles의 기반입니다 — "이 스팬이 실행되는 동안의 FlameGraph만 보여줘"를 가능하게 합니다.
Node.js 입장에서 주의할 점이 있습니다. Go·Java·Python·.NET SDK는 OTLP Profiles를 이미 안정적으로 출력할 수 있지만, Node.js OTel SDK의 공식 Profiling 지원은 현재 알파 단계입니다(공식 지원 트래커는 opentelemetry-js GitHub 참고). 지금 당장 프로덕션에 쓰려면 Pyroscope 전용 @pyroscope/nodejs SDK가 더 안정적인 선택입니다.
Pyroscope 2.0 아키텍처 — 단일 쓰기 + 스테이트리스 읽기
2026년 4월 GA된 Pyroscope 2.0의 핵심 변화는 아키텍처 단순화입니다.
1.x에서는 쓰기 경로가 복제를 직접 처리하고, 읽기 경로 Querier가 특정 스토리지 복본에 고정되어 있었습니다. 2.0은 복제를 오브젝트 스토리지(S3/GCS/Azure)에게 위임하고 Querier를 완전히 스테이트리스로 만들었습니다. 쿼리 부하가 늘어나면 Querier만 수평 확장하면 되고, 스토리지 비용도 복본 없이 단일 기록으로 줄어들었습니다. Grafana 자체가 이 아키텍처로 19.5PB의 프로파일 데이터를 처리 중입니다(Introducing Pyroscope 2.0).
구현 전에 — SDK 방식과 eBPF 방식 선택하기
이후 모든 단계는 SDK 방식을 기준으로 설명합니다. 환경에 따라 선택이 달라질 수 있으니 먼저 비교해두는 게 좋습니다.
| 항목 | SDK 방식 (@pyroscope/nodejs) |
eBPF 방식 (pyroscope.ebpf) |
|---|---|---|
| 코드 수정 | 필요 | 불필요 |
| Node.js 콜 스택 정확도 | 높음 (JIT 최적화 반영) | V8 JIT 프레임 누락 가능 |
| Span Profiles 지원 | 지원 (PyroscopeSpanProcessor) |
미지원 |
| Linux 커널 요건 | 없음 | 4.8 이상 |
| 컨테이너 권한 | 없음 | SYS_BPF 필요 |
| 전체 시스템 프로파일링 | Node.js 프로세스만 | 시스템 전체 가능 |
Node.js 애플리케이션의 정확한 콜 스택과 Span Profiles가 필요하다면 SDK 방식을 선택하세요. eBPF는 Kubernetes PSS Restricted 정책과 충돌 가능하고, V8 JIT 특성상 스택 프레임이 누락되는 경우가 있어 Node.js 전용 진단에는 한계가 있습니다.
실전 적용
0단계: Pyroscope 서버 실행
이후 예시 코드는 http://pyroscope:4040이 이미 실행 중이라고 가정합니다. 로컬 또는 스테이징에서 빠르게 시작하려면 Docker Compose로 띄워볼 수 있습니다.
# docker-compose.yml
services:
pyroscope:
image: grafana/pyroscope:latest
ports:
- "4040:4040"
volumes:
- pyroscope-data:/var/lib/pyroscope
volumes:
pyroscope-data:프로덕션에서는 오브젝트 스토리지(S3 등)를 백엔드로 연결하는 Helm chart 설정을 Pyroscope 공식 문서에서 확인할 수 있습니다.
1단계: Node.js SDK 설정 — @pyroscope/nodejs
npm install @pyroscope/nodejs애플리케이션 엔트리포인트 최상단에서 초기화합니다. V8 샘플러가 제대로 붙으려면 다른 모듈보다 먼저 로드되어야 합니다.
// profiler.js — ESM (Node 12+, package.json "type": "module") 기준 예시
import Pyroscope from '@pyroscope/nodejs';
Pyroscope.init({
serverAddress: process.env.PYROSCOPE_SERVER_URL || 'http://localhost:4040',
appName: 'my-node-api',
tags: {
env: process.env.NODE_ENV,
version: process.env.APP_VERSION,
region: process.env.AWS_REGION,
},
});
Pyroscope.start();CommonJS 프로젝트라면 import 대신 const Pyroscope = require('@pyroscope/nodejs').default로 대체합니다.
tags는 FlameQL 쿼리로 필터링할 수 있는 레이블입니다. requestId·userId 같은 고카디널리티 값을 태그로 넣으면 스토리지와 쿼리 성능이 급격히 나빠집니다. env, version, region 수준의 저카디널리티 값만 사용하세요.
2단계: Grafana Alloy로 수집 중앙화
SDK가 직접 Pyroscope 서버로 Push하는 것도 가능하지만, Grafana Alloy를 중간에 두면 인증·레이블 보강·라우팅 로직을 애플리케이션 코드 밖에서 관리할 수 있습니다. 멀티 서비스 환경에서 특히 유리합니다.
// alloy.config
pyroscope.receive_http "node_apps" {
http {
listen_address = "0.0.0.0"
listen_port = 9999
}
forward_to = [pyroscope.write.default.receiver]
}
pyroscope.write "default" {
endpoint {
url = "http://pyroscope:4040"
// Grafana Cloud 사용 시 아래 블록을 추가합니다
basic_auth {
username = env("GRAFANA_CLOUD_INSTANCE_ID")
password = env("GRAFANA_CLOUD_API_KEY")
}
}
}그리고 SDK의 serverAddress를 Alloy를 가리키도록 변경합니다.
Pyroscope.init({
serverAddress: 'http://alloy:9999',
appName: 'my-node-api',
// ...
});3단계: Span Profiles — 트레이스에서 FlameGraph로 드릴다운
Span Profiles가 이 스택의 핵심 가치입니다. 스팬 단위 실행 구간의 FlameGraph만 필터링해서 볼 수 있어, 느린 요청의 정확한 코드 경로를 트레이스 UI에서 직접 파고들 수 있습니다.
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
npm install @pyroscope/otel// tracing.js — ESM 기준 예시
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PyroscopeSpanProcessor } from '@pyroscope/otel';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: 'http://tempo:4318/v1/traces',
}),
spanProcessors: [
new PyroscopeSpanProcessor(), // 활성 Span ID를 프로파일 레이블로 주입
],
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();PyroscopeSpanProcessor는 OTel의 현재 활성 Span ID를 프로파일 레이블로 자동 삽입합니다. 이 덕분에 프로파일 데이터에 span_id, trace_id 레이블이 붙고, Grafana Tempo에서 해당 스팬의 "Profiles" 링크를 클릭하면 그 구간의 FlameGraph만 필터링해서 볼 수 있습니다.
엔트리포인트에서 두 파일의 로드 순서가 중요합니다.
// index.js
import './profiler.js'; // 반드시 첫 줄 — V8 샘플러 먼저 연결
import './tracing.js'; // 그 다음 OTel SDK 초기화
import express from 'express';
// ... 이후 나머지 코드실제 드릴다운 흐름은 다음과 같습니다.
4단계: Grafana 데이터소스 연동 설정
Grafana에서 Pyroscope 데이터소스를 추가한 뒤, Tempo 데이터소스 설정에서 "Trace to profiles" 링크를 활성화합니다.
# Grafana 프로비저닝 datasources.yaml 예시
apiVersion: 1
datasources:
- name: Tempo
type: tempo
url: http://tempo:3200
jsonData:
tracesToProfiles:
datasourceUid: pyroscope-uid
profileTypeId: process_cpu:cpu:nanoseconds:cpu:nanoseconds
tags:
- key: service.name
value: appName
customQuery: false
- name: Pyroscope
uid: pyroscope-uid
type: grafana-pyroscope-datasource
url: http://pyroscope:4040profileTypeId는 Pyroscope의 프로파일 타입 식별자입니다. Node.js SDK가 기본으로 수집하는 CPU 프로파일은 process_cpu:cpu:nanoseconds:cpu:nanoseconds 형식입니다. Grafana Explore → Pyroscope 데이터소스에서 드롭다운으로 직접 확인할 수 있습니다.
자주 겪는 문제
설정 후 기대한 대로 동작하지 않을 때 확인할 포인트입니다.
프로파일 데이터가 Pyroscope에 전혀 보이지 않을 때
profiler.js가index.js첫 줄에서 import되고 있는지 확인합니다. SDK 초기화가 다른 모듈 이후에 실행되면 일부 함수가 프로파일링 범위에서 빠집니다.PYROSCOPE_SERVER_URL환경변수 또는serverAddress값이 실제로 접근 가능한 주소인지 확인합니다.Pyroscope.start()가Pyroscope.init()이후에 호출되고 있는지 확인합니다.
Tempo에서 "Profiles" 링크가 표시되지 않을 때
datasources.yaml의tracesToProfiles.datasourceUid가 Pyroscope 데이터소스의 실제uid와 정확히 일치해야 합니다.profileTypeId를 Grafana Explore → Pyroscope 데이터소스 드롭다운에서 실제 수집된 타입으로 맞춰야 합니다. SDK 버전에 따라 형식이 다를 수 있습니다.
FlameGraph에 span_id 레이블이 없을 때
PyroscopeSpanProcessor가 OTel SDK의spanProcessors배열에 등록됐는지 확인합니다.tracing.js가profiler.js이후에 로드됐는지 확인합니다. 순서가 바뀌면 Pyroscope 샘플러가 OTel 컨텍스트를 읽지 못해span_id레이블이 누락됩니다.
장단점 정리
장점
| 항목 | 내용 |
|---|---|
| 낮은 오버헤드 | CPU 오버헤드 2~3% 미만 (출처) — 프로덕션 상시 적용 가능한 수준 |
| 시간 여행 디버깅 | 이미 지나간 성능 이슈를 과거 프로파일로 소급 분석 가능 |
| 트레이스 연동 | Span ID 기반 드릴다운으로 느린 요청의 정확한 코드 경로 확인 |
| Grafana 생태계 통합 | Tempo·Loki·Mimir와 단일 UI에서 연계, 별도 대시보드 구성 불필요 |
| 오픈 표준 | OTLP Profiles로 벤더 종속 없이 데이터 이동 가능 |
| 비용 효율 | Pyroscope 2.0 단일 쓰기 + 스테이트리스 읽기로 스토리지·컴퓨트 비용 절감 |
| Metrics from Profiles | 프로파일 집계를 시계열 메트릭으로 변환해 서비스·버전 간 CPU 비교 가능 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| Node.js OTel SDK 미성숙 | 공식 OTLP Profiles Node.js SDK가 아직 알파. 현재는 @pyroscope/nodejs가 더 안정적 |
| Span Profiles 공식 가이드 부재 | Node.js용 Traces to Profiles 공식 문서가 아직 없음. @pyroscope/otel로 부분 구현 가능 |
| 고카디널리티 레이블 | requestId·userId 등을 태그로 쓰면 스토리지 급증 — 저카디널리티 값만 사용 |
| 스택 심볼리제이션 누락 | eBPF 방식에서 V8 JIT 최적화로 Node.js 프레임 누락 가능 — SDK 방식으로 보완 필요 |
| 보안·PII 위험 | 콜 스택에 함수명·파일 경로 노출 가능. 프로파일 데이터 접근 제어와 전송 TLS 암호화 필수 |
| 컨테이너 권한 문제 | eBPF는 SYS_BPF 권한 필요 — Kubernetes PSS Restricted 정책과 충돌 가능 |
마치며
트레이스는 얼마나 느린지를 알려주고, FlameGraph는 왜 느린지를 알려줍니다. Span Profiles로 이 둘을 연결하면 "느린 스팬 → 해당 구간 CPU 사용 코드 경로"를 클릭 한 번으로 볼 수 있습니다. Continuous Profiling은 이 흐름을 프로덕션에서 상시 가능하게 만들어 줍니다.
Pyroscope 2.0의 단일 쓰기 + 스테이트리스 읽기 아키텍처는 운영 비용 측면에서 이전보다 훨씬 현실적인 선택지가 됐고, OTLP Profiles의 Public Alpha 진입은 장기적으로 벤더 종속 없는 표준 파이프라인으로 이전할 수 있다는 신호이기도 합니다.
처음 시작한다면 이 순서를 권합니다.
@pyroscope/nodejsSDK를 스테이징 환경에 먼저 붙여보세요.init+start두 줄로 FlameGraph가 보이는 경험이 설득력 있고, 오버헤드 실측도 이때 해두면 좋습니다.- Grafana Alloy를 중간에 두고 Tempo 데이터소스와 Traces to Profiles 링크를 설정하면, 트레이스에서 FlameGraph로 드릴다운하는 순간이 이 스택의 전환점이 됩니다.
- Pyroscope 2.0의 Metrics from Profiles 기능으로 배포 전후 CPU 변화를 비교해보면, 성능 개선이 측정 가능한 숫자로 바뀝니다.
참고 자료
- Grafana Pyroscope 공식 문서
- Node.js SDK 설정 가이드 | Grafana Pyroscope
- Span Profiles (Traces to Profiles) 연동 | Grafana Pyroscope
- Traces to Profiles 뷰 | Grafana Pyroscope
- Grafana에서 Traces to Profiles 설정 | Grafana 문서
- Introducing Pyroscope 2.0 | Grafana Labs Blog
- Pyroscope 2.0, Continuous Profiling at Scale | InfoQ (2026.05)
- Pyroscope 2.0 아키텍처 딥다이브 | tldrecap.tech
- OpenTelemetry Profiles Enters Public Alpha (2026.03)
- Profiles 신호 개념 | OpenTelemetry 공식 문서
- OTLP Profiles Protobuf 스펙 | opentelemetry-proto-profile GitHub
- OTel Profiles 사양 | OpenTelemetry
- The State of Profiling | OpenTelemetry Blog (2024)
- OTel Profiles Alpha — Elastic의 기여 | Elastic Observability Labs
- Linking Traces with Continuous Profiling using Pyroscope | Infracloud
- Continuous profiling in production — 실측 사례 | Grafana Labs Blog
- Pyroscope를 활용한 성능 최적화 | Filigran Blog
- Grafana Pyroscope 사용법 | oneuptime.com (2026.02)
- Grafana Alloy로 Tempo·Mimir·Loki·Pyroscope 탐색 | oneuptime.com (2026.02)
- Grafana Pyroscope GitHub 저장소
- OTel JS SDK Profiling 공식 지원 트래커 | opentelemetry-js GitHub
- FlameGraph란 무엇인가 | pyroscope.io
- Node.js 차세대 FlameGraph | Platformatic Blog (2025.09)
- Pyroscope 가격·리뷰 2026 | CubeAPM
- OTel Profiles 보안 — PII 유출·접근 제어 | systemshardening.com