C++ 프로파일링 | '어디가 느린지 모르겠어요' perf·gprof로 병목 찾기
이 글의 핵심
C++ 프로파일링의 C++, 프로파일링, "어디가, 들어가며: "어디가 느린지 모르겠어요"를 실전 예제와 함께 상세히 설명합니다.
💡 초보자를 위한 한 줄: 추측으로 최적화하지 말고
perf·프로파일러·chrono로 어디가 뜨거운지부터 잡습니다. Release 빌드에 가깝게 측정하고, 한 번에 한 가지 변경만 비교하면 원인 추적이 쉬워집니다. 14-1 이동 의미론 다음 흐름에 맞습니다.
들어가며: “어디가 느린지 모르겠어요”
실무에서 겪는 문제 시나리오
터미널에서 다음 명령어를 실행합니다.
실제 겪는 상황:
- "이 함수가 느릴 것 같아서" 3일 최적화했는데, 실제 병목은 파일 I/O였음
- perf report를 봐도 심볼이 ???로 나와서 분석 불가
- gprof로 프로파일했는데 gmon.out이 생성되지 않음
- Valgrind를 돌리니 30배 느려져서 실용성이 없다고 느낌
- API 서버가 CPU 100%인데 어느 핸들러가 문제인지 모름
- 24시간 실행 후 메모리가 2GB→8GB로 증가 (누수 의심)
- O(n) 알고리즘인데 n이 커지면 선형보다 훨씬 느림 (캐시 의심)
이런 상황에서 추측 대신 측정이 핵심입니다. 프로파일러로 병목을 찾고, 화염 그래프로 시각화하고, 상위 20% 함수부터 최적화하면 시간 대비 효과가 큽니다.
추측으로 최적화하다가 시간만 낭비했다
프로그램이 느려서 추측으로 최적화를 시도했습니다. 하지만 실제 병목(bottleneck—전체 성능을 제한하는 가장 느린 지점)은 다른 곳이었습니다.
잘못된 접근:
processData 함수의 구현 예제입니다.
// "이 함수가 느릴 것 같아서" 최적화
void processData(std::vector<int>& data) {
// 복잡한 최적화 시도...
}
// 실제로는 이 함수가 병목이었음
void loadData() {
// 파일 I/O가 느림
}
프로파일링 후:
processData: 전체 시간의 5%loadData: 전체 시간의 80% ← 진짜 병목
교훈:
- 추측하지 말고 측정하라
- 프로파일러로 병목 찾기
- 가장 느린 부분부터 최적화
프로파일링(실행 중 어느 함수가 시간·메모리를 얼마나 쓰는지 측정하는 것) 없이 “이 부분이 느릴 것 같다”고 손대면, 실제로는 I/O나 다른 모듈이 병목인 경우가 많습니다. CPU 샘플링(perf 등)이나 인스트루멘테이션으로 “어느 함수가 시간을 많이 쓰는지”를 먼저 확인한 뒤, 상위 몇 %부터 최적화하는 것이 시간 대비 효과가 큽니다.
프로파일링의 전체 흐름
다음은 mermaid 예제 코드입니다.
flowchart TD
A[프로그램 느림] --> B[측정 없이 추측]
B --> C{병목 맞추기}
C -->|실패| D[시간 낭비]
A --> E[프로파일링 실행]
E --> F[병목 지점 확인]
F --> G[상위 20% 구간 최적화]
G --> H[재측정]
H --> I{목표 달성?}
I -->|아니오| E
I -->|예| J[성능 개선 완료]
이 글을 읽으면:
- 프로파일링 도구를 사용할 수 있습니다.
- 병목 지점을 정확히 찾을 수 있습니다.
- 성능을 정량적으로 측정할 수 있습니다.
- 실전에서 효과적으로 최적화할 수 있습니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
1. 프로파일링이란
성능 측정의 필요성
"추측하지 말고 측정하라"
- 직관은 자주 틀림
- 병목은 예상 밖의 곳에 있음
- 측정 없는 최적화는 시간 낭비
프로파일링 종류
1. CPU 프로파일링
- 어떤 함수가 CPU를 많이 쓰는지
- 함수 호출 횟수와 시간
2. 메모리 프로파일링
- 메모리 사용량
- 할당/해제 횟수
- 메모리 누수
3. 캐시 프로파일링
- 캐시 미스 횟수
- 메모리 접근 패턴
프로파일링 종류별 비교
다음은 mermaid 예제 코드입니다.
flowchart LR
subgraph CPU[CPU 프로파일링]
C1[perf]
C2[gprof]
C3[VS Profiler]
end
subgraph MEM[메모리 프로파일링]
M1[Valgrind Memcheck]
M2[AddressSanitizer]
end
subgraph CACHE[캐시 프로파일링]
K1[Valgrind Cachegrind]
K2[perf stat]
end
2. 시간 측정 기초
std::chrono로 측정
C++11부터 제공하는 std::chrono로 구간 시간을 잴 수 있습니다. high_resolution_clock::now()로 시작·종료 시점을 받고, end - start로 duration을 구한 뒤 duration_cast<std::chrono::milliseconds>로 원하는 단위로 변환합니다. 이렇게 하면 특정 함수나 블록이 실제로 몇 ms 걸리는지 숫자로 확인할 수 있어, “느리다”는 감이 아니라 데이터로 병목를 찾을 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o profile_time profile_time.cpp && ./profile_time
#include <chrono>
#include <iostream>
void slowFunction() {
// 무거운 작업...
}
int main() {
auto start = std::chrono::high_resolution_clock::now();
slowFunction();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "Time: " << duration.count() << " ms\n";
return 0;
}
실행 결과: Time: N ms 형태로 출력됩니다 (환경에 따라 N 값은 다름).
코드 상세 설명:
high_resolution_clock: 시스템에서 사용 가능한 가장 정밀한 시계now(): 현재 시점을time_point로 반환duration_cast: 나노초 단위 duration을 밀리초로 변환count(): 해당 단위의 정수 값 반환
측정 헬퍼 클래스
생성자에서 시각을 기록하고 소멸자에서 경과 시간을 출력하는 RAII 스타일 타이머입니다. { Timer t("slowFunction"); slowFunction(); }처럼 스코프를 두면, 블록을 빠져나갈 때 자동으로 소멸자가 호출되어 해당 구간 시간이 출력됩니다. 예외가 나거나 early return이 있어도 소멸자는 호출되므로, 수동으로 end 시간을 찍는 것보다 누락이 적습니다.
class Timer {
std::chrono::high_resolution_clock::time_point start;
const char* name;
public:
Timer(const char* n) : name(n) {
start = std::chrono::high_resolution_clock::now();
}
~Timer() {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << name << ": " << duration.count() << " us\n";
}
};
void processData() {
Timer timer("processData");
// 작업...
} // 소멸자에서 자동 출력
주의점: Timer 객체를 스코프 밖으로 두면 측정 구간이 의도와 다를 수 있습니다. { }로 블록을 명확히 구분하세요.
여러 구간 측정
class Profiler {
std::map<std::string, long long> timings;
std::chrono::high_resolution_clock::time_point start;
public:
void startTimer() {
start = std::chrono::high_resolution_clock::now();
}
void record(const std::string& name) {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
timings[name] += duration.count();
start = end;
}
void report() {
for (const auto& [name, time] : timings) {
std::cout << name << ": " << time << " us\n";
}
}
};
int main() {
Profiler prof;
prof.startTimer();
loadData();
prof.record("loadData");
processData();
prof.record("processData");
saveData();
prof.record("saveData");
prof.report();
}
활용: record() 호출 시점마다 “이전 구간”의 누적 시간이 기록됩니다. start = end로 다음 구간 시작을 갱신하므로, 여러 번 반복하면 전체 합계를 볼 수 있습니다.
3. 프로파일링 도구
도구 선택 가이드
다음은 mermaid 예제 코드입니다.
flowchart TD
A[프로파일링 필요] --> B{플랫폼?}
B -->|Linux| C[perf]
B -->|Linux/Mac| D[gprof]
B -->|Linux/Mac| E[Valgrind]
B -->|Windows| F[VS Profiler]
C --> G[CPU 샘플링]
D --> H[인스트루멘테이션]
E --> I[메모리/캐시]
F --> C
perf (Linux)
Linux의 표준 프로파일링 도구입니다. 샘플링 방식으로 CPU가 주기적으로 실행 중인 함수를 기록해, 오버헤드가 적어 프로덕션 환경에서도 사용 가능합니다.
터미널에서 다음 명령어를 실행합니다.
# 프로그램 실행하며 프로파일링
perf record ./myapp
# 결과 보기
perf report
# 함수별 시간
perf stat ./myapp
출력 예시:
50.23% myapp [.] processData
30.45% myapp [.] loadFile
15.32% myapp [.] parseJson
perf report 상세 사용법:
터미널에서 다음 명령어를 실행합니다.
# 호출 그래프 포함 (함수 호출 관계)
perf record -g ./myapp
# 결과를 텍스트로 출력
perf report --stdio
# 특정 함수만 필터
perf report --symbol-filter=processData
perf stat 출력 해석:
터미널에서 다음 명령어를 실행합니다.
Performance counter stats for './myapp':
1,234.56 msec task-clock
42 context-switches
0 cpu-migrations
128 page-faults
3,456,789,012 cycles
2,345,678,901 instructions
task-clock: CPU 사용 시간 (ms)context-switches: 컨텍스트 스위칭 횟수page-faults: 페이지 폴트 횟수cycles: CPU 사이클instructions: 실행된 명령어 수
IPC (Instructions Per Cycle): instructions / cycles가 1에 가까우면 CPU가 효율적으로 동작합니다. 0.5 이하면 메모리 대기나 분기 예측 실패 등이 의심됩니다.
gprof (GNU Profiler)
컴파일 시 -pg 플래그로 코드에 프로파일링 코드를 삽입합니다. 실행 시 gmon.out 파일이 생성되고, 이를 분석해 함수별 호출 횟수와 시간을 보여줍니다.
터미널에서 다음 명령어를 실행합니다.
# -pg 플래그로 컴파일
g++ -pg -O2 main.cpp -o myapp
# 실행 (gmon.out 생성)
./myapp
# 프로파일 보기
gprof myapp gmon.out
gprof 출력 예시:
터미널에서 다음 명령어를 실행합니다.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
80.0 0.80 0.80 1 800.00 800.00 loadFile
15.0 0.95 0.15 100 1.50 1.50 processData
5.0 1.00 0.05 1 50.00 50.00 saveResult
주의: -pg와 -O2를 함께 쓰면 인라인 최적화로 인해 일부 함수가 합쳐져 보일 수 있습니다. 정확한 호출 관계를 보려면 -O0 또는 -O1로 테스트할 수 있습니다.
Valgrind Callgrind
시뮬레이션 방식으로 CPU 명령어를 단계별 실행합니다. 정확한 호출 관계와 캐시 정보를 얻을 수 있지만, 10~50배 느려지므로 짧은 실행에만 적합합니다.
터미널에서 다음 명령어를 실행합니다.
# 프로파일링 실행
valgrind --tool=callgrind ./myapp
# 결과 분석
callgrind_annotate callgrind.out.12345
# GUI 도구
kcachegrind callgrind.out.12345
Callgrind 옵션:
# 캐시 시뮬레이션 포함
valgrind --tool=callgrind --cache-sim=yes ./myapp
# 특정 함수만 프로파일
valgrind --tool=callgrind --toggle-collect=processData ./myapp
Visual Studio Profiler
터미널에서 다음 명령어를 실행합니다.
1. 메뉴: Debug → Performance Profiler
2. CPU Usage 선택
3. Start 클릭
4. 프로그램 실행
5. Hot Path와 함수별 시간 확인
도구 비교표
| 도구 | 플랫폼 | 방식 | 오버헤드 | 프로덕션 |
|---|---|---|---|---|
| perf | Linux | 샘플링 | 낮음 (~5%) | ✅ |
| gprof | Linux/Mac | 인스트루멘테이션 | 중간 (~10%) | △ |
| Valgrind | Linux/Mac | 시뮬레이션 | 매우 높음 (10~50x) | ❌ |
| VS Profiler | Windows | 샘플링 | 낮음 | ✅ |
Flame Graph로 시각화하기
Flame Graph는 프로파일 결과를 가로 막대 그래프로 보여줍니다. 호출 스택이 아래에서 위로 쌓이고, 너비가 CPU 사용 비율을 나타냅니다. 한눈에 병목 함수를 찾을 수 있습니다.
# perf 데이터로 Flame Graph 생성
perf record -F 99 -g ./myapp
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
Flame Graph 읽는 법:
- 가로: 함수가 CPU 사용 시간 중 차지하는 비율
- 세로: 호출 스택 (아래가 호출자, 위가 피호출자)
- 넓은 막대: 가장 많은 시간을 쓰는 호출 경로
예: main → loadFile → readBuffer → memcpy
↑ 이 경로가 가장 넓으면 memcpy가 병목
화염 그래프 완전한 생성 예제
터미널에서 다음 명령어를 실행합니다.
# 1. FlameGraph 스크립트 설치 (한 번만)
git clone --depth 1 https://github.com/brendangregg/FlameGraph
export PATH="$PATH:$(pwd)/FlameGraph"
# 2. perf로 호출 스택 수집 (DWARF로 정확한 스택)
perf record -F 99 -g --call-graph dwarf,8192 ./myapp
# 3. SVG 화염 그래프 생성
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
# 4. 브라우저에서 열기
open flamegraph.svg # macOS
# xdg-open flamegraph.svg # Linux
화염 그래프에서 자주 보는 패턴:
| 패턴 | 의미 | 대응 |
|---|---|---|
memcpy가 넓음 | 메모리 복사 병목 | 버퍼 풀, zero-copy |
malloc/free가 넓음 | 할당/해제 비용 | 객체 풀, arena |
std::sort가 넓음 | 정렬 비용 | 정렬 제거, 부분 정렬 |
pthread_mutex_lock | 락 대기 | 락 최소화, lock-free |
4. 완전한 프로파일링 예제
프로파일링 대상 프로그램
// profile_target.cpp - perf, gprof로 분석할 대상
#include <vector>
#include <algorithm>
#include <random>
#include <chrono>
#include <iostream>
// 의도적으로 비효율적인 함수: 캐시 미스 유발
void processDataCacheUnfriendly(std::vector<int>& data) {
const size_t stride = 16;
for (size_t i = 0; i < data.size(); i += stride) {
data[i] = data[i] * 2 + 1;
}
}
// 캐시 친화적: 연속 접근
void processDataCacheFriendly(std::vector<int>& data) {
for (size_t i = 0; i < data.size(); ++i) {
data[i] = data[i] * 2 + 1;
}
}
// 병목 후보: O(n log n) 정렬
void sortData(std::vector<int>& data) {
std::sort(data.begin(), data.end());
}
// 병목 후보: 난수 생성
void fillRandom(std::vector<int>& data) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(1, 1000000);
for (auto& v : data) {
v = dis(gen);
}
}
int main() {
const size_t N = 10'000'000;
std::vector<int> data(N);
fillRandom(data);
sortData(data);
processDataCacheUnfriendly(data);
processDataCacheFriendly(data);
return 0;
}
perf로 완전한 프로파일링 예제
터미널에서 다음 명령어를 실행합니다.
# 1. 디버그 심볼 포함하여 컴파일
g++ -std=c++17 -O2 -g -o profile_target profile_target.cpp
# 2. perf로 호출 그래프 수집 (화염 그래프용)
perf record -F 99 -g --call-graph dwarf,8192 ./profile_target
# 3. perf report로 결과 확인
perf report --stdio
# 4. perf stat로 CPU/캐시 통계
perf stat -e cycles,instructions,cache-references,cache-misses ./profile_target
perf report —stdio 출력 예시:
터미널에서 다음 명령어를 실행합니다.
# Samples: 1,234 of event 'cpu-clock'
# Event count (approx.): 12340000000
#
# Overhead Command Shared Object Symbol
# ......... .............. ................. .......................
# 45.23% profile_target profile_target [.] sortData
# 28.10% profile_target profile_target [.] fillRandom
# 12.30% profile_target profile_target [.] processDataCacheUnfriendly
# 10.00% profile_target profile_target [.] processDataCacheFriendly
핫스팟 해석: sortData가 45%로 가장 큰 병목 → 정렬 알고리즘 개선 또는 정렬 제거 검토.
gprof로 완전한 프로파일링 예제
터미널에서 다음 명령어를 실행합니다.
# 1. -pg 옵션으로 컴파일
g++ -std=c++17 -O2 -pg -g -o profile_target_gprof profile_target.cpp
# 2. 실행 (gmon.out 자동 생성)
./profile_target_gprof
# 3. 플랫 프로파일 (함수별 시간 비율)
gprof -p profile_target_gprof gmon.out
# 4. 호출 그래프만 보기
gprof -q profile_target_gprof gmon.out
# 5. 전체 리포트를 파일로 저장
gprof profile_target_gprof gmon.out > gprof_report.txt
gprof 출력 해석:
터미널에서 다음 명령어를 실행합니다.
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
45.23 2.15 2.15 1 2150.00 2150.00 sortData
28.10 3.48 1.33 1 1330.00 1330.00 fillRandom
12.30 4.07 0.59 1 590.00 590.00 processDataCacheUnfriendly
10.00 4.54 0.48 1 480.00 480.00 processDataCacheFriendly
핵심 컬럼: % time(전체 대비 비율), self seconds(해당 함수 자체 시간), calls(호출 횟수).
핫스팟 식별 워크플로우
다음은 mermaid 예제 코드입니다.
flowchart TD
A[프로그램 실행] --> B[perf record -g]
B --> C[perf report]
C --> D{상위 3개 함수 확인}
D --> E[넓은 막대 = 병목]
E --> F[Timer로 세부 측정]
F --> G[최적화 대상 구간 확정]
G --> H[최적화 후 재측정]
5. 병목 지점 분석
핫스팟 찾기
loadFile 함수의 구현 예제입니다.
// 프로파일 결과:
// 80% - loadFile() ← 병목!
// 15% - processData()
// 5% - saveResult()
// loadFile() 최적화에 집중
void loadFile(const std::string& path) {
Timer timer("loadFile");
// 세부 측정
{
Timer t("open");
file.open(path);
}
{
Timer t("read");
// 읽기.... ← 여기가 느림!
}
{
Timer t("parse");
// 파싱...
}
}
호출 횟수 확인
class CallCounter {
static std::map<std::string, int> counts;
std::string name;
public:
CallCounter(const char* n) : name(n) {
counts[name]++;
}
static void report() {
for (const auto& [name, count] : counts) {
std::cout << name << ": " << count << " calls\n";
}
}
};
std::map<std::string, int> CallCounter::counts;
void expensiveFunction() {
CallCounter counter("expensiveFunction");
// ...
}
int main() {
// ...
CallCounter::report();
}
파레토 원칙 (80/20 법칙)
전체 실행 시간의 80% ← 상위 20% 함수만 최적화
나머지 20% ← 나머지 80% 함수는 건드리지 않아도 됨
실전: 프로파일 결과에서 상위 20% 함수만 골라 최적화하면, 전체 성능의 대부분을 개선할 수 있습니다.
6. 실전 최적화 프로세스
1단계: 측정
// 현재 성능 측정
void benchmark() {
Timer timer("Total");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
int main() {
benchmark(); // 기준선 확립
}
2단계: 프로파일링
# CPU 프로파일
perf record -g ./myapp
# 결과 확인
perf report
3단계: 병목 최적화
// 병목: 벡터 재할당
void processData() {
std::vector<int> data; // ❌ 재할당 반복
for (int i = 0; i < 100000; ++i) {
data.push_back(i);
}
}
// 최적화
void processDataOptimized() {
std::vector<int> data;
data.reserve(100000); // ✅ 미리 할당
for (int i = 0; i < 100000; ++i) {
data.push_back(i);
}
}
4단계: 재측정
main 함수의 구현 예제입니다.
int main() {
{
Timer t("Before");
processData(); // 50ms
}
{
Timer t("After");
processDataOptimized(); // 10ms (5배 빠름)
}
}
5단계: 반복
측정 → 프로파일 → 최적화 → 재측정 → 반복
실전 예시: JSON 파서 최적화
시나리오: 대용량 JSON 파일 파싱이 10초 걸립니다. 어디가 느린지 모릅니다.
1단계: 프로파일링:
perf record -g ./json_parser large_file.json
perf report
결과: parseJson() 80%, loadFile() 15%, validate() 5%
2단계: 파싱 세부 분석:
parseJson 함수의 구현 예제입니다.
void parseJson(const std::string& content) {
{
Timer t("tokenize");
auto tokens = tokenize(content); // 60% 여기!
}
{
Timer t("build_tree");
buildTree(tokens); // 20%
}
{
Timer t("validate");
validate();
}
}
3단계: tokenize 최적화 (정규식 → 수동 파싱):
// ❌ 느림: 정규식 매칭
std::regex number_regex(R"(\d+)");
for (auto token : tokens) {
std::regex_match(token, number_regex);
}
// ✅ 빠름: 수동 파싱
std::vector<int> parseNumbers(const std::string& s) {
std::vector<int> result;
size_t i = 0;
while (i < s.size()) {
if (std::isdigit(s[i])) {
int num = 0;
while (i < s.size() && std::isdigit(s[i])) {
num = num * 10 + (s[i++] - '0');
}
result.push_back(num);
} else {
++i;
}
}
return result;
}
4단계: 재측정 → 10초 → 3초로 개선
벤치마크 시 주의사항
1. 워밍업: 첫 실행은 캐시 미스·JIT 등으로 느릴 수 있습니다.
// 워밍업 후 측정
for (int i = 0; i < 3; ++i) {
processData(); // 캐시 워밍업
}
{
Timer t("benchmark");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
2. 여러 번 실행해 평균: 한 번의 결과는 노이즈에 영향받습니다.
C/C++ 예제 코드입니다.
std::vector<long long> times;
for (int run = 0; run < 10; ++run) {
auto start = std::chrono::high_resolution_clock::now();
processData();
auto end = std::chrono::high_resolution_clock::now();
times.push_back(std::chrono::duration_cast<std::chrono::microseconds>(end - start).count());
}
// 중앙값 또는 평균 사용
3. 컴파일 최적화: -O2 또는 -O3로 릴리즈 빌드 후 측정해야 실제 성능을 반영합니다.
# 프로덕션과 동일한 최적화로 벤치마크
g++ -O2 -DNDEBUG main.cpp -o myapp
메모리 프로파일링 (Valgrind Memcheck)
CPU뿐 아니라 메모리 누수나 잘못된 접근도 프로파일링할 수 있습니다.
터미널에서 다음 명령어를 실행합니다.
# 메모리 누수 검사
valgrind --leak-check=full ./myapp
# 출력 예시:
# ==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 2
# ==12345== at 0x4C2AB80: malloc
# ==12345== by 0x400567: loadData() (main.cpp:42)
AddressSanitizer (ASan)는 컴파일 시 삽입되는 방식으로, Valgrind보다 빠릅니다.
# -fsanitize=address로 컴파일
g++ -g -O1 -fsanitize=address -fno-omit-frame-pointer main.cpp -o myapp
./myapp # 힙 버퍼 오버플로우 등 즉시 감지
7. 자주 발생하는 문제
문제 1: perf 권한 오류
증상:
Permission denied (raw syscall access denied)
원인: perf는 커널 레벨 이벤트를 사용하므로 root 권한 또는 perf_event_paranoid 설정이 필요합니다.
해결법:
# 임시로 paranoid 값 낮추기 (재부팅 시 초기화)
sudo sysctl -w kernel.perf_event_paranoid=-1
# 권한 설정으로 재실행
sudo perf record ./myapp
문제 2: gprof에서 gmon.out이 생성되지 않음
원인: -pg 플래그 없이 컴파일했거나, 정상 종료가 아닌 경우(예: Ctrl+C, abort()).
해결법:
# -pg 플래그 확인
g++ -pg -O2 main.cpp -o myapp
# 정상 종료되도록 main에서 return 0
exit(0); // 또는 정상 종료 경로
문제 3: Valgrind가 너무 느림
원인: 시뮬레이션 방식이라 10~50배 느립니다.
해결법:
# 입력 데이터를 작게 줄여서 테스트
./myapp < small_input.txt
# 또는 perf로 대체 (CPU 프로파일링만 필요할 때)
perf record ./myapp
문제 4: 프로파일 결과에 함수명이 없음 (???)
원인: 디버그 심볼이 없거나 릴리즈 빌드에서 strip된 경우.
해결법:
# -g 옵션으로 디버그 심볼 포함
g++ -g -O2 main.cpp -o myapp
# strip하지 않기
# g++ -g -O2 main.cpp -o myapp # -s 옵션 제거
문제 5: 컴파일 최적화로 인라인된 함수
증상: 프로파일에서 main만 보이고 세부 함수가 보이지 않음.
원인: -O2/-O3에서 인라인 최적화로 인해 함수가 합쳐짐.
해결법:
# 프로파일링용 빌드는 -O1 또는 -O0
g++ -g -O1 -pg main.cpp -o myapp
# 또는 __attribute__((noinline))로 특정 함수 보호
void __attribute__((noinline)) criticalFunction() { ....}
7. 체크리스트
프로파일링 전 체크리스트
-
-g플래그로 디버그 심볼 포함 - 최적화 수준 결정 (
-O1권장, 정확한 호출 관계 필요 시-O0) - perf 사용 시
perf_event_paranoid설정 확인 - gprof 사용 시
-pg플래그 추가 - Valgrind 사용 시 입력 데이터 크기 축소
프로파일링 후 체크리스트
- 상위 20% 함수 식별
- 병목 구간 세부 측정 (Timer 등)
- 최적화 전 기준선 기록
- 최적화 후 재측정
- 회귀 테스트 (기능 정상 동작 확인)
최적화 원칙 체크리스트
- 추측하지 말고 측정
- 병목 80%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 컴파일러 최적화 | PGO·LTO로 “느린 프로그램” 성능 30% 향상시키기
이 글에서 다루는 키워드 (관련 검색어)
C++ 프로파일링, perf gprof, Valgrind, 병목 지점, 성능 측정, 최적화 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 도구 | 플랫폼 | 용도 |
|---|---|---|
| perf | Linux | CPU 프로파일링 |
| gprof | Linux/Mac | 함수별 시간 |
| Valgrind | Linux/Mac | 메모리, 캐시 |
| VS Profiler | Windows | CPU, 메모리 |
| std::chrono | 모든 플랫폼 | 수동 측정 |
핵심 원칙:
- 추측 금지, 측정 필수
- 병목 80%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
초보자를 위한 체크리스트
- 최적화 전·후 같은 입력으로 시간을 재었는가?
- Debug만 보고 병목을 단정하지 않았는가?
- 화염 그래프에서 위쪽(호출이 쌓인 곳)부터 의심 구간을 좁혔는가?
💡 초보자 팁: 본문 체크리스트·5. 병목 지점 분석·7. 자주 발생하는 문제를 함께 보세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 프로파일링 도구(perf, gprof, Valgrind)로 병목 지점을 찾고, 성능을 측정하며, 실전에서 최적화 포인트를 발견하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. perf와 gprof 중 어떤 것을 써야 하나요?
A. Linux에서는 perf를 우선 추천합니다. 샘플링 방식이라 오버헤드가 적고, 프로덕션 환경에서도 사용 가능합니다. gprof는 -pg 플래그로 코드를 수정해야 하므로, 빌드 설정이 다를 수 있습니다. 정확한 호출 그래프가 필요하면 Valgrind Callgrind를 고려하세요.
Q. 프로파일링이 프로그램을 느리게 만들지 않나요?
A. perf는 샘플링 방식이라 오버헤드가 5% 내외로 낮습니다. gprof는 인스트루멘테이션이라 1020% 정도 느려질 수 있지만, 병목 찾기에는 충분합니다. Valgrind는 시뮬레이션이라 1050배 느려지므로, 짧은 실행에만 사용하세요.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: chrono나 프로파일러로 병목 구간을 찾은 뒤 최적화할 수 있습니다. 다음으로 캐시 친화적 코드(#15-2)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #15-2] 캐시 친화적 코드: 메모리 접근 패턴 최적화
이전 글: [C++ 실전 가이드 #14-2] Perfect Forwarding과 std::forward
관련 글
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 프로그램 느림 원인 찾기 | 프로파일링으로 병목 5분 만에 찾는 법
- C++ 고급 프로파일링 완벽 가이드 | perf·gprof
- C++ STL 알고리즘 기초 | sort·find·count·transform·accumulate 가이드
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 프로파일링 | ‘어디가 느린지 모르겠어요’ perf·gprof로 병목 찾기」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 프로파일링 | ‘어디가 느린지 모르겠어요’ perf·gprof로 병목 찾기」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.