C++ Profiling | '성능 프로파일링' 가이드
이 글의 핵심
C++ Profiling - "성능 프로파일링" 가이드. C++ Profiling의 프로파일링이란?, 기본 시간 측정, 실전 예시를 실전 코드와 함께 설명합니다.
프로파일링이란?
프로파일링은 프로그램이 실제로 어디에서 시간을 쓰고 있는지 정량적으로 측정해 병목 지점을 찾아내는 과정입니다. 개발자의 직관은 종종 틀리기 마련이라, “이 부분이 느릴 것 같다”는 추측만으로 최적화를 시작하면 정작 진짜 병목은 그대로 둔 채 별로 중요하지 않은 코드만 다듬는 경우가 많습니다. 아래 예시처럼 process() 함수가 step1, step2, step3을 순서대로 호출하는 코드만 봐서는 어느 단계가 느린지 전혀 알 수 없지만, 실제로 측정해 보면 step2 하나가 전체 실행 시간의 90%를 차지하는 식의 결과가 나오는 경우가 흔합니다. 이렇게 측정을 통해 병목의 실제 위치를 확인한 뒤에야 최적화 노력을 어디에 집중해야 할지 근거를 가지고 판단할 수 있습니다.
// 측정 전: 어디가 느린지 모름
void process() {
step1();
step2();
step3();
}
// 측정 후: step2가 90% 시간 소요
기본 시간 측정
가장 손쉽게 시작할 수 있는 프로파일링 방법은 표준 라이브러리의 <chrono>를 이용해 특정 구간의 실행 시간을 직접 재는 것입니다. high_resolution_clock::now()로 측정 시작 시점과 종료 시점을 각각 기록한 뒤 두 시점의 차이를 duration_cast로 원하는 단위(밀리초, 마이크로초 등)로 변환하면, 별도의 외부 도구 설치 없이도 코드 어디서나 즉시 시간을 측정할 수 있습니다. 이 방법은 전용 프로파일러만큼 세밀한 호출 스택 정보를 주지는 못하지만, 특정 함수나 구간이 대략 얼마나 걸리는지 빠르게 확인하고 싶을 때 가장 간단하고 즉각적인 수단입니다.
#include <chrono>
#include <iostream>
void measureTime() {
auto start = std::chrono::high_resolution_clock::now();
// 측정할 코드
for (int i = 0; i < 1000000; i++) {
// 작업
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "시간: " << duration.count() << "ms" << std::endl;
}
실전 예시
chrono로 매번 시작/종료 시점을 직접 기록하는 것도 번거롭고, 때로는 코드 안에 심어둔 수동 측정만으로는 부족해 전용 프로파일링 도구가 필요합니다. 아래 예시들은 RAII를 활용한 자동 타이머부터, 리눅스와 맥 환경에서 널리 쓰이는 외부 프로파일링 도구들의 기본 사용법을 순서대로 다룹니다.
예시 1: 함수 프로파일링
매번 시작/종료 시각을 직접 기록하는 대신, RAII 패턴을 활용하면 측정 코드를 훨씬 깔끔하게 만들 수 있습니다. 아래 Timer 클래스는 생성자에서 시작 시각을 기록하고, 소멸자에서 종료 시각을 계산해 자동으로 결과를 출력하도록 설계되어 있어, 함수 맨 앞에 Timer t("함수이름"); 한 줄만 추가하면 해당 함수가 끝나는 시점(정상 종료든 예외로 인한 종료든)에 자동으로 소요 시간이 측정됩니다. 이 패턴은 앞서 다룬 예외 안전성의 RAII 원리를 성능 측정에 응용한 것으로, 여러 함수에 동일한 측정 로직을 반복해서 작성하지 않아도 된다는 것이 큰 장점입니다.
#include <chrono>
#include <iostream>
class Timer {
std::chrono::time_point<std::chrono::high_resolution_clock> start;
std::string name;
public:
Timer(const std::string& 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() << "μs" << std::endl;
}
};
void slowFunction() {
Timer t("slowFunction");
// 작업
}
void fastFunction() {
Timer t("fastFunction");
// 작업
}
예시 2: gprof 사용
gprof는 GCC 계열 컴파일러와 함께 오래전부터 쓰여온 고전적인 프로파일러로, 함수 호출 빈도와 각 함수에 소요된 시간을 통계로 보여줍니다. 사용법은 컴파일 시 -pg 플래그를 추가해 프로파일링용 계측 코드를 심어 넣은 뒤, 프로그램을 한 번 실행해 실행 통계가 담긴 gmon.out 파일을 생성하고, 마지막으로 gprof 명령으로 이 파일을 분석해 사람이 읽을 수 있는 리포트로 변환하는 세 단계로 이루어집니다. gprof는 설치가 간단하고 별도 권한 없이 사용할 수 있다는 장점이 있지만, 계측 방식(instrumentation) 특성상 측정 자체가 실행 속도에 어느 정도 오버헤드를 더한다는 점을 감안해서 결과를 해석해야 합니다.
# 컴파일
g++ -pg program.cpp -o program
# 실행
./program
# 프로파일 생성
gprof program gprof.out > analysis.txt
예시 3: perf 사용
perf는 리눅스 커널에 내장된 성능 분석 도구로, gprof와 달리 프로그램을 다시 컴파일할 필요 없이 이미 빌드된 바이너리를 그대로 샘플링 방식으로 분석할 수 있습니다. perf record는 실행 중인 프로그램을 주기적으로 샘플링해 어느 함수, 심지어 어느 소스 코드 라인에서 시간이 가장 많이 쓰이는지 기록하고, perf report로 그 결과를 함수별 비율로 확인할 수 있습니다. perf stat은 한 걸음 더 나아가 CPU 사이클 수, 캐시 미스율, 분기 예측 실패율 같은 하드웨어 성능 카운터까지 보여주므로, 단순히 “어디가 느린가”를 넘어 “왜 느린가”까지 파고들고 싶을 때 특히 유용합니다.
# 프로파일링
perf record ./program
# 결과 확인
perf report
# CPU 이벤트
perf stat ./program
예시 4: Valgrind Callgrind
Valgrind의 Callgrind 도구는 프로그램을 가상 머신 위에서 실행하며 모든 함수 호출과 명령어 실행을 정밀하게 추적하는 방식으로 동작합니다. 이 덕분에 perf의 샘플링 방식보다 훨씬 정확하고 결정론적인(deterministic) 결과를 얻을 수 있어, 실행할 때마다 같은 결과가 나오는 재현성이 중요한 상황에 적합합니다. 다만 명령어 하나하나를 가상으로 실행하기 때문에 실제 실행 속도보다 수십 배 느려지는 것이 단점이며, kcachegrind 같은 별도 GUI 도구로 결과를 시각화하면 함수 간 호출 관계와 시간 비중을 그래프로 훨씬 직관적으로 파악할 수 있습니다.
# 프로파일링
valgrind --tool=callgrind ./program
# 결과 확인
kcachegrind callgrind.out.*
병목 지점 찾기
외부 도구를 쓰기 어려운 환경이거나, 프로덕션 코드 안에 가벼운 자체 계측을 심고 싶을 때는 직접 프로파일러 클래스를 만들어 쓰는 방법도 있습니다. 아래 Profiler 클래스는 이름별로 호출 횟수와 누적 시간을 std::map에 기록해 두었다가, report()에서 각 구간의 평균 소요 시간과 호출 횟수를 함께 출력하는 구조입니다. 이런 방식은 외부 도구처럼 상세한 호출 스택까지는 보여주지 못하지만, 특정 함수나 API 엔드포인트처럼 “이름표를 붙일 수 있는” 코드 구간을 지속적으로 모니터링하고 싶을 때 배포 환경에도 가볍게 적용할 수 있다는 장점이 있습니다.
#include <map>
#include <chrono>
class Profiler {
struct Entry {
size_t count = 0;
long long totalTime = 0;
};
std::map<std::string, Entry> entries;
public:
void start(const std::string& name) {
// 시작 시간 기록
}
void end(const std::string& name) {
// 종료 시간 기록
}
void report() {
for (const auto& [name, entry] : entries) {
std::cout << name << ": "
<< entry.totalTime / entry.count << "μs"
<< " (" << entry.count << " calls)" << std::endl;
}
}
};
자주 발생하는 문제
프로파일링은 도구를 아는 것만큼이나 측정 방법 자체를 올바르게 설계하는 것이 중요합니다. 측정 방식이 잘못되면 엉뚱한 결론에 도달할 수 있으므로, 아래 네 가지 함정을 반드시 염두에 두어야 합니다.
문제 1: 측정 오버헤드
측정 자체가 성능에 영향을 준다는 사실은 프로파일링에서 가장 흔히 간과되는 부분입니다. 아래 잘못된 예시처럼 100만 번 반복되는 루프 안에서 매번 high_resolution_clock::now()를 두 번씩 호출하면, 시계를 읽는 연산 자체의 비용이 측정 대상인 doWork()의 실제 비용보다 오히려 커질 수 있어 결과가 심각하게 왜곡됩니다. 이를 피하려면 반복문 전체를 하나의 측정 구간으로 감싸 시작과 끝에서만 시각을 기록하고, 전체 시간을 반복 횟수로 나눠 평균을 구하는 방식으로 측정 오버헤드 자체를 최소화해야 합니다.
// ❌ 너무 자주 측정
for (int i = 0; i < 1000000; i++) {
auto start = std::chrono::high_resolution_clock::now();
doWork();
auto end = std::chrono::high_resolution_clock::now();
}
// ✅ 전체 측정
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; i++) {
doWork();
}
auto end = std::chrono::high_resolution_clock::now();
문제 2: 최적화 비활성화
디버그 빌드(-O0, 최적화 없음)로 프로파일링하면 실제 사용자가 마주할 릴리스 빌드와는 전혀 다른 성능 특성이 나타납니다. 디버그 빌드에서는 함수 인라인, 루프 언롤링, 레지스터 할당 최적화가 모두 꺼져 있어 실제보다 훨씬 느리게 측정될 뿐 아니라, 최적화가 적용되면 통째로 사라질 코드가 병목처럼 보이는 등 병목 지점 자체가 다르게 나타날 수 있습니다. 그래서 프로파일링은 항상 배포 환경과 동일한 최적화 플래그(-O2 또는 -O3)로 빌드하되, 함수 이름과 소스 라인 정보를 프로파일러가 읽을 수 있도록 디버그 심볼(-g)만 추가로 유지하는 것이 정확한 결과를 얻는 방법입니다.
// 디버그 빌드로 프로파일링 (느림)
g++ -g program.cpp
// ✅ 릴리스 빌드
g++ -O2 -g program.cpp
문제 3: 캐시 효과
프로그램을 처음 실행할 때는 CPU 캐시나 OS의 파일 시스템 캐시가 아직 비어 있어, 이후 반복 실행보다 눈에 띄게 느린 경우가 많습니다. 데이터를 처음 메모리에 로드하거나 디스크에서 파일을 읽어들이는 과정에서 캐시 미스가 집중적으로 발생하기 때문인데, 이 첫 실행 결과만 보고 성능을 판단하면 실제 운영 환경에서 반복적으로 실행될 때의 진짜 성능과는 동떨어진 수치를 얻게 됩니다. 그래서 신뢰할 수 있는 측정을 위해서는 캐시가 데워진(warm) 상태를 만들기 위해 몇 차례 워밍업 실행을 거친 뒤, 그 이후의 실행들을 여러 번 반복해 평균이나 중앙값을 취하는 방식이 일반적입니다.
// 첫 실행은 느림 (캐시 미스)
// 이후 실행은 빠름 (캐시 히트)
// ✅ 여러 번 실행 후 평균
문제 4: 조기 최적화
측정 없이 “느릴 것 같다”는 직감만으로 코드를 미리 복잡하게 최적화하는 조기 최적화(premature optimization)는 도널드 커누스가 “모든 악의 근원”이라고 부를 만큼 흔하면서도 해로운 습관입니다. 실제로 측정해 보면 전체 실행 시간의 대부분은 코드 전체에서 극히 일부 지점에 집중되어 있는 경우가 많은데, 그 지점을 모른 채 여기저기를 미리 손대면 코드만 복잡해질 뿐 성능은 거의 개선되지 않는 경우가 대부분입니다. 그래서 올바른 순서는 항상 먼저 프로파일링으로 병목을 측정하고, 실제로 시간이 많이 걸리는 지점을 확인한 뒤, 그 부분에만 집중해서 최적화를 적용하는 것입니다.
// ❌ 측정 전 최적화
void func() {
// 복잡한 최적화
}
// ✅ 측정 후 최적화
// 1. 프로파일링
// 2. 병목 지점 확인
// 3. 해당 부분만 최적화
프로파일링 도구
지금까지 다룬 도구들을 플랫폼별로 정리하면, 리눅스에서는 gprof와 perf, Valgrind가 표준적인 선택지이고, 맥에서는 Xcode에 포함된 Instruments의 Time Profiler가 GUI 기반으로 직관적인 분석을 제공하며, 윈도우에서는 Visual Studio에 내장된 프로파일러가 비슷한 역할을 합니다. 어떤 도구를 선택하든 공통적으로 중요한 것은 “가벼운 측정이 먼저, 무거운 분석은 그다음”이라는 순서로, chrono를 이용한 간단한 시간 측정으로 대략적인 병목 함수를 먼저 좁힌 뒤, 그 함수 내부를 더 자세히 들여다볼 때만 perf나 Callgrind 같은 무거운 도구를 투입하는 것이 효율적입니다.
# gprof
g++ -pg program.cpp
./a.out
gprof a.out gmon.out
# perf (Linux)
perf record ./program
perf report
# Valgrind Callgrind
valgrind --tool=callgrind ./program
# Instruments (Mac)
instruments -t "Time Profiler" ./program
# Visual Studio Profiler (Windows)
FAQ
Q1: 프로파일링은 언제?
A:
- 성능 문제 발생
- 최적화 전
- 정기적 모니터링
Q2: 어떤 도구?
A:
- gprof: 기본
- perf: Linux 상세
- Valgrind: 정확
- Instruments: Mac
Q3: 측정 단위는?
A:
- 마이크로초 (μs)
- 밀리초 (ms)
- CPU 사이클
Q4: 최적화 순서는?
A:
- 측정
- 병목 찾기
- 최적화
- 재측정
Q5: 프로덕션 프로파일링?
A:
- 샘플링 프로파일러
- 낮은 오버헤드
- 통계 수집
Q6: 프로파일링 학습 리소스는?
A:
- “Optimized C++”
- perf 문서
- Valgrind 문서
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Exception Performance | “예외 성능” 가이드
- C++ Cache Optimization | “캐시 최적화” 가이드
- C++ 프로파일링 | “어디가 느린지 모르겠어요” perf·gprof로 병목 찾기
관련 글
- C++ Cache Optimization
- C++ Exception Performance
- C++ Algorithm Sort
- C++ Benchmarking
- C++ Branch Prediction
이 글에서 다루는 키워드 (관련 검색어)
C++, profiling, performance, optimization, 프로파일링 등으로 검색하시면 이 글이 도움이 됩니다.