C++ 프로그램 느림 원인 찾기 | 프로파일링으로 병목 5분 만에 찾는 법
이 글의 핵심
C++ 프로그램 느림 원인 찾기에 대한 실전 가이드입니다. 프로파일링으로 병목 5분 만에 찾는 법 등을 예제와 함께 설명합니다.
들어가며: “코드는 맞는데 왜 이렇게 느리죠?"
"같은 알고리즘인데 Python보다 느려요”
C++로 작성한 프로그램이 예상보다 느릴 때, 원인을 찾기 어렵습니다. “알고리즘은 O(n)인데 왜 느릴까?”, “멀티스레드로 바꿨는데 오히려 느려졌어요”, “최적화 플래그를 켰는데도 개선이 없어요” 같은 상황에서 프로파일링(Profiling—프로그램 실행 중 함수별 시간·메모리 사용량을 측정하는 기법)이 필요합니다.
이 글에서 다루는 것:
- 성능 저하의 7가지 주요 원인
- 프로파일러 사용법 (perf, gprof, VTune, Visual Studio)
- 병목 찾는 5단계 프로세스
- 자주 나오는 성능 문제 패턴 10가지
- 실전 최적화 사례 (10배 속도 향상)
- 프로파일링 결과 읽는 법
목차
- 성능 저하의 7가지 주요 원인
- 프로파일러 선택 가이드
- perf로 병목 찾기 (Linux)
- Visual Studio Profiler (Windows)
- 자주 나오는 성능 문제 패턴 10가지
- 실전 최적화 사례
- 정리
1. 성능 저하의 7가지 주요 원인
원인 1: 잘못된 알고리즘 선택
// ❌ O(n²) - 100만 건이면 1조 번 비교
std::vector<int> data(1000000);
for (size_t i = 0; i < data.size(); ++i) {
for (size_t j = 0; j < data.size(); ++j) {
if (data[i] == data[j] && i != j) {
// 중복 찾기
}
}
}
// ✅ O(n) - 100만 번
std::unordered_set<int> seen;
for (int x : data) {
if (seen.count(x)) {
// 중복 발견
}
seen.insert(x);
}
영향: 알고리즘 선택이 잘못되면 1000배 이상 느려질 수 있습니다.
원인 2: 불필요한 복사
// ❌ 매번 복사 (1GB 데이터면 1GB 복사)
void process(std::vector<int> data) { // 값 복사
// ...
}
std::vector<int> big_data(1000000);
process(big_data); // 4MB 복사
// ✅ 참조 사용
void process(const std::vector<int>& data) { // 참조 (8바이트)
// ...
}
영향: 대량 데이터 복사는 수십~수백 ms 소요.
원인 3: 메모리 할당 과다
// ❌ 루프마다 할당/해제
for (int i = 0; i < 1000000; ++i) {
std::vector<int> temp(100); // 매번 힙 할당
// ...
}
// ✅ 루프 밖에서 한 번만 할당
std::vector<int> temp;
temp.reserve(100);
for (int i = 0; i < 1000000; ++i) {
temp.clear();
// ...
}
영향: malloc/free는 생각보다 비쌉니다 (수십 ns).
원인 4: 캐시 미스
// ❌ 캐시 비효율 (열 우선 순회)
int matrix[1000][1000];
for (int col = 0; col < 1000; ++col) {
for (int row = 0; row < 1000; ++row) {
sum += matrix[row][col]; // 캐시 미스 다발
}
}
// ✅ 캐시 효율 (행 우선 순회)
for (int row = 0; row < 1000; ++row) {
for (int col = 0; col < 1000; ++col) {
sum += matrix[row][col]; // 연속 접근
}
}
영향: 캐시 미스는 100배 느립니다 (L1: 1ns, 메모리: 100ns).
원인 5: 분기 예측 실패
// ❌ 랜덤 분기 (예측 불가)
std::vector<int> data = generateRandomData();
for (int x : data) {
if (x > 50) { // 랜덤하게 true/false
// ...
}
}
// ✅ 정렬 후 분기 (예측 가능)
std::sort(data.begin(), data.end());
for (int x : data) {
if (x > 50) { // 처음에는 false, 나중에는 true
// ...
}
}
영향: 분기 예측 실패는 10~20 사이클 손실.
원인 6: 가상 함수 오버헤드
// ❌ 가상 함수 호출 (간접 호출)
class Base {
public:
virtual void process() = 0;
};
std::vector<std::unique_ptr<Base>> objects;
for (auto& obj : objects) {
obj->process(); // 가상 함수 호출 (vtable 조회)
}
// ✅ 타입별로 분리 (직접 호출)
std::vector<TypeA> type_a_objects;
std::vector<TypeB> type_b_objects;
for (auto& obj : type_a_objects) {
obj.process(); // 직접 호출 (인라인 가능)
}
영향: 가상 함수는 인라인 최적화 불가, 분기 예측 어려움.
원인 7: 문자열 연결 비효율
// ❌ 매번 재할당
std::string result;
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i) + ","; // 매번 재할당
}
// ✅ reserve로 재할당 방지
std::string result;
result.reserve(100000); // 미리 공간 확보
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i) + ",";
}
// ✅ stringstream 사용
std::ostringstream oss;
for (int i = 0; i < 10000; ++i) {
oss << i << ",";
}
std::string result = oss.str();
2. 프로파일러 선택 가이드
플랫폼별 권장 도구
| 플랫폼 | 권장 도구 | 특징 |
|---|---|---|
| Linux | perf | 재컴파일 불필요, 하드웨어 카운터 지원 |
| macOS | Instruments | Xcode 통합, GUI 친화적 |
| Windows | Visual Studio Profiler | IDE 통합, 사용 쉬움 |
| 크로스 플랫폼 | Valgrind (callgrind) | 느리지만 정확함 |
| 고급 | Intel VTune | 최고 성능, 유료 |
도구별 비교
| 도구 | 속도 오버헤드 | 재컴파일 | 하드웨어 카운터 | 난이도 |
|---|---|---|---|---|
| perf | 낮음 (5~10%) | 불필요 | 지원 | 중간 |
| gprof | 중간 (20~30%) | 필요 (-pg) | 미지원 | 쉬움 |
| Valgrind | 높음 (10~50배) | 불필요 | 미지원 | 쉬움 |
| VTune | 낮음 (5%) | 불필요 | 지원 | 어려움 |
3. perf로 병목 찾기 (Linux)
설치
# Ubuntu/Debian
sudo apt install linux-tools-common linux-tools-generic
# Fedora/RHEL
sudo dnf install perf
기본 사용법
# 1. 프로그램 실행 중 프로파일링
perf record -g ./myapp
# 2. 결과 확인
perf report
# 3. 함수별 시간 확인
perf report --stdio
출력 예시
# Overhead Command Shared Object Symbol
# ........ ....... ................. .............................
#
45.23% myapp myapp [.] processData
23.45% myapp myapp [.] calculateSum
12.34% myapp libc-2.31.so [.] malloc
8.90% myapp myapp [.] sortArray
5.67% myapp libstdc++.so.6 [.] std::vector::push_back
해석:
- processData가 전체 시간의 45%를 차지 → 최우선 최적화 대상
- malloc이 12% → 메모리 할당이 병목
- sortArray가 8% → 알고리즘 개선 고려
하드웨어 카운터 측정
# 캐시 미스 측정
perf stat -e cache-misses,cache-references ./myapp
# 분기 예측 실패 측정
perf stat -e branch-misses,branches ./myapp
# 전체 통계
perf stat ./myapp
출력 예시:
Performance counter stats for './myapp':
1,234,567 cache-misses # 12.34% of all cache refs
10,000,000 cache-references
234,567 branch-misses # 2.35% of all branches
10,000,000 branches
2.345678 seconds time elapsed
해석:
- cache-misses 12%: 캐시 효율이 낮음 → 데이터 레이아웃 개선 필요
- branch-misses 2.35%: 분기 예측 실패 → 정렬 또는 분기 제거 고려
Flame Graph 생성
# FlameGraph 도구 설치
git clone https://github.com/brendangregg/FlameGraph
cd FlameGraph
# 프로파일링 + Flame Graph 생성
perf record -g ./myapp
perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > flame.svg
# 브라우저로 열기
firefox flame.svg
4. Visual Studio Profiler (Windows)
사용법
1. 디버그 → 성능 프로파일러 (Alt+F2)
2. "CPU 사용량" 체크
3. "시작" 클릭
4. 프로그램 실행 후 종료
5. 함수별 시간 확인
핫 패스 (Hot Path) 확인
함수 전체 % 자체 %
processData 45.2% 12.3%
├─ calculateSum 23.4% 23.4%
└─ sortArray 8.9% 8.9%
malloc 12.3% 12.3%
해석:
- 전체 %: 이 함수와 하위 함수의 총 시간
- 자체 %: 이 함수 자체의 시간 (하위 함수 제외)
최적화 우선순위: 자체 %가 높은 함수부터 최적화.
5. 자주 나오는 성능 문제 패턴 10가지
패턴 1: 불필요한 복사 (값 전달)
// ❌ 느린 코드
void process(std::vector<int> data) { // 값 복사
for (int x : data) {
// ...
}
}
// 프로파일러: process 함수에서 복사 생성자가 20% 차지
// ✅ 빠른 코드
void process(const std::vector<int>& data) { // 참조
for (int x : data) {
// ...
}
}
개선: 10~100배 빠름 (데이터 크기에 비례).
패턴 2: 루프 안에서 벡터 재할당
// ❌ 느린 코드
for (int i = 0; i < 1000000; ++i) {
std::vector<int> temp;
for (int j = 0; j < 100; ++j) {
temp.push_back(j); // 재할당 발생
}
}
// 프로파일러: malloc/free가 상위 차지
// ✅ 빠른 코드
std::vector<int> temp;
temp.reserve(100); // 미리 할당
for (int i = 0; i < 1000000; ++i) {
temp.clear();
for (int j = 0; j < 100; ++j) {
temp.push_back(j); // 재할당 없음
}
}
개선: 5~10배 빠름.
패턴 3: 문자열 연결 비효율
// ❌ 느린 코드
std::string result;
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i); // 매번 재할당
}
// ✅ 빠른 코드
std::string result;
result.reserve(100000); // 미리 할당
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i);
}
// ✅ 더 빠른 코드
std::ostringstream oss;
for (int i = 0; i < 10000; ++i) {
oss << i;
}
std::string result = oss.str();
개선: 10~20배 빠름.
패턴 4: map 대신 unordered_map
// ❌ 느린 코드 (O(log n) 조회)
std::map<int, std::string> cache;
for (int i = 0; i < 1000000; ++i) {
cache[i] = "value";
}
for (int i = 0; i < 1000000; ++i) {
auto it = cache.find(i); // O(log n)
}
// ✅ 빠른 코드 (O(1) 조회)
std::unordered_map<int, std::string> cache;
for (int i = 0; i < 1000000; ++i) {
cache[i] = "value";
}
for (int i = 0; i < 1000000; ++i) {
auto it = cache.find(i); // O(1) 평균
}
개선: 5~10배 빠름 (조회 많을 때).
패턴 5: 캐시 비효율적 자료구조
// ❌ 느린 코드 (AoS - Array of Structures)
struct Particle {
float x, y, z; // 위치
float vx, vy, vz; // 속도
float r, g, b, a; // 색상
};
std::vector<Particle> particles(1000000);
// 위치만 갱신 (색상은 안 쓰는데 캐시에 올라옴)
for (auto& p : particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
}
// ✅ 빠른 코드 (SoA - Structure of Arrays)
struct Particles {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
std::vector<float> r, g, b, a;
};
Particles particles;
particles.x.resize(1000000);
// ...
for (size_t i = 0; i < particles.x.size(); ++i) {
particles.x[i] += particles.vx[i] * dt;
particles.y[i] += particles.vy[i] * dt;
particles.z[i] += particles.vz[i] * dt;
}
개선: 2~3배 빠름 (캐시 효율).
패턴 6: 가상 함수 호출 과다
// ❌ 느린 코드
class Animal {
public:
virtual void makeSound() = 0;
};
std::vector<std::unique_ptr<Animal>> animals;
for (int i = 0; i < 1000000; ++i) {
for (auto& animal : animals) {
animal->makeSound(); // 가상 함수 호출
}
}
// ✅ 빠른 코드 (타입별 분리)
std::vector<Dog> dogs;
std::vector<Cat> cats;
for (int i = 0; i < 1000000; ++i) {
for (auto& dog : dogs) {
dog.makeSound(); // 직접 호출 (인라인 가능)
}
for (auto& cat : cats) {
cat.makeSound();
}
}
개선: 2~5배 빠름 (인라인 최적화).
패턴 7: 불필요한 std::endl
// ❌ 느린 코드
for (int i = 0; i < 1000000; ++i) {
std::cout << i << std::endl; // 매번 flush
}
// ✅ 빠른 코드
for (int i = 0; i < 1000000; ++i) {
std::cout << i << '\n'; // flush 안 함
}
개선: 10~100배 빠름 (I/O 버퍼링).
패턴 8: 정규표현식 매번 컴파일
// ❌ 느린 코드
for (const auto& line : lines) {
std::regex pattern(R"(\d+)"); // 매번 컴파일
if (std::regex_search(line, pattern)) {
// ...
}
}
// ✅ 빠른 코드
std::regex pattern(R"(\d+)"); // 한 번만 컴파일
for (const auto& line : lines) {
if (std::regex_search(line, pattern)) {
// ...
}
}
개선: 100배 이상 빠름.
패턴 9: 멀티스레드 락 경합
// ❌ 느린 코드
std::mutex mtx;
std::vector<int> shared_data;
void worker() {
for (int i = 0; i < 1000000; ++i) {
std::lock_guard lock(mtx); // 매번 락
shared_data.push_back(i);
}
}
// 4스레드 실행 시 락 경합으로 느림
// ✅ 빠른 코드 (로컬 버퍼)
std::mutex mtx;
std::vector<int> shared_data;
void worker() {
std::vector<int> local_buffer;
local_buffer.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
local_buffer.push_back(i); // 락 없이
}
std::lock_guard lock(mtx); // 한 번만 락
shared_data.insert(shared_data.end(),
local_buffer.begin(),
local_buffer.end());
}
개선: 10~50배 빠름 (락 횟수 감소).
패턴 10: 불필요한 동적 할당
// ❌ 느린 코드
std::vector<std::unique_ptr<int>> data;
for (int i = 0; i < 1000000; ++i) {
data.push_back(std::make_unique<int>(i)); // 100만 번 할당
}
// ✅ 빠른 코드
std::vector<int> data;
data.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
data.push_back(i); // 값 저장 (재할당 없음)
}
개선: 5~10배 빠름 (할당 오버헤드 제거).
6. 실전 최적화 사례
사례 1: JSON 파싱 최적화 (10배 개선)
Before:
// ❌ 느린 코드
std::string parseJson(const std::string& json) {
std::string result;
for (char c : json) {
result += c; // 매번 재할당
if (c == '{') {
result += '\n';
}
}
return result;
}
// 프로파일러: string::operator+= 가 80% 차지
After:
// ✅ 빠른 코드
std::string parseJson(const std::string& json) {
std::string result;
result.reserve(json.size() * 2); // 미리 할당
for (char c : json) {
result += c;
if (c == '{') {
result += '\n';
}
}
return result;
}
개선: 10배 빠름 (재할당 제거).
사례 2: 데이터베이스 쿼리 최적화 (100배 개선)
Before:
// ❌ 느린 코드 (N+1 쿼리)
std::vector<User> users = db.query("SELECT * FROM users");
for (const auto& user : users) {
auto orders = db.query("SELECT * FROM orders WHERE user_id = " +
std::to_string(user.id)); // 100만 번 쿼리
// ...
}
// 프로파일러: db.query가 99% 차지
After:
// ✅ 빠른 코드 (JOIN 한 번)
auto results = db.query(
"SELECT u.*, o.* FROM users u "
"LEFT JOIN orders o ON u.id = o.user_id"
);
// 결과를 메모리에서 그룹화
std::unordered_map<int, std::vector<Order>> user_orders;
for (const auto& row : results) {
user_orders[row.user_id].push_back(row.order);
}
개선: 100배 빠름 (네트워크 왕복 제거).
사례 3: 이미지 처리 최적화 (5배 개선)
Before:
// ❌ 느린 코드 (픽셀마다 함수 호출)
void applyFilter(Image& img) {
for (int y = 0; y < img.height; ++y) {
for (int x = 0; x < img.width; ++x) {
Color c = img.getPixel(x, y); // 가상 함수 호출
c = processColor(c);
img.setPixel(x, y, c); // 가상 함수 호출
}
}
}
// 프로파일러: getPixel/setPixel이 60% 차지
After:
// ✅ 빠른 코드 (직접 메모리 접근)
void applyFilter(Image& img) {
Color* pixels = img.getPixelData(); // 직접 포인터
size_t total = img.width * img.height;
for (size_t i = 0; i < total; ++i) {
pixels[i] = processColor(pixels[i]);
}
}
개선: 5배 빠름 (함수 호출 오버헤드 제거).
병목 찾는 5단계 프로세스
1단계: 측정 (Measure)
# 전체 실행 시간 측정
time ./myapp
# 프로파일러 실행
perf record -g ./myapp
2단계: 분석 (Analyze)
# 함수별 시간 확인
perf report
# 상위 5개 함수 찾기
perf report --stdio | head -20
질문:
- 어떤 함수가 시간을 가장 많이 쓰는가?
- 예상과 일치하는가?
- 최적화 가능한가?
3단계: 가설 (Hypothesize)
"processData 함수가 45%를 차지한다"
→ 가설: 내부에서 불필요한 복사가 일어나는가?
→ 가설: 알고리즘이 비효율적인가?
→ 가설: 캐시 미스가 많은가?
4단계: 최적화 (Optimize)
// 가설 검증: 복사 제거
// Before
void processData(std::vector<int> data) { ... }
// After
void processData(const std::vector<int>& data) { ... }
5단계: 재측정 (Re-measure)
# 최적화 후 다시 측정
perf record -g ./myapp_optimized
# 개선 확인
perf diff perf.data.old perf.data
반복: 병목이 사라질 때까지 2~5단계 반복.
프로파일링 결과 읽는 법
Flat Profile vs Call Graph
Flat Profile (함수별 시간):
% cumulative self self total
time seconds seconds calls ms/call ms/call name
45.23 1.23 1.23 1000000 0.00 0.00 processData
23.45 1.87 0.64 500000 0.00 0.00 calculateSum
12.34 2.20 0.33 1 330.00 330.00 malloc
Call Graph (호출 관계):
index % time self children called name
[1] 68.5 1.23 0.64 1000000 processData [1]
0.64 0.00 500000/500000 calculateSum [2]
-----------------------------------------------
[2] 23.5 0.64 0.00 500000 calculateSum [2]
0.64 0.00 1000000/1000000 processData [1]
해석:
- self: 함수 자체의 시간
- children: 하위 함수의 시간
- called: 호출 횟수
핫스팟 (Hotspot) 찾기
규칙: 자체 시간이 5% 이상인 함수를 최적화 대상으로 선정.
45% processData ← 최우선 최적화
23% calculateSum ← 두 번째 최적화
12% malloc ← 메모리 할당 줄이기
8% sortArray ← 알고리즘 개선
5% other ← 무시 가능
정리
성능 저하 원인 체크리스트
- 알고리즘이 최적인가? (O(n²) → O(n log n) → O(n))
- 불필요한 복사가 있는가? (값 전달 → const 참조)
- 메모리 할당이 과다한가? (reserve, 재사용)
- 캐시 효율이 낮은가? (AoS → SoA, 순차 접근)
- 가상 함수 호출이 많은가? (타입별 분리)
- 락 경합이 있는가? (로컬 버퍼, lock-free)
- 문자열 연결이 비효율적인가? (reserve, stringstream)
프로파일링 도구 선택
| 상황 | 권장 도구 |
|---|---|
| Linux 개발 | perf |
| Windows 개발 | Visual Studio Profiler |
| macOS 개발 | Instruments |
| 크로스 플랫폼 | Valgrind (callgrind) |
| 고급 분석 | Intel VTune |
최적화 우선순위
- 알고리즘 개선 (가장 큰 영향)
- 불필요한 복사 제거 (쉽고 효과적)
- 메모리 할당 줄이기 (reserve, 재사용)
- 캐시 최적화 (데이터 레이아웃)
- 컴파일러 최적화 (-O3, -march=native)
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 프로파일링 완벽 가이드 | perf·gprof·VTune 실전
- C++ 성능 최적화 | 알고리즘·메모리·캐시 개선 패턴
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상
- C++ 벤치마킹 | 정확한 성능 측정 방법
마치며
“프로그램이 느리다”는 막연한 문제를 프로파일러로 구체적인 병목으로 바꿀 수 있습니다.
핵심 원칙:
- 추측하지 말고 측정하세요 (프로파일러 사용)
- 상위 5% 함수만 최적화하세요 (80/20 법칙)
- 알고리즘을 먼저 개선하세요 (O(n²) → O(n log n))
- 최적화 전후를 벤치마크하세요 (실제 개선 확인)
프로파일러 없이 최적화하는 것은 어둠 속에서 길 찾기와 같습니다. 이 가이드를 참고해 병목을 빠르게 찾고, 10배 빠른 프로그램을 만들어 보세요.
다음 단계: 병목을 찾았다면, C++ 캐시 최적화 가이드와 C++ SIMD 최적화로 더 깊이 최적화할 수 있습니다.
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |