본문으로 건너뛰기
Previous
Next
C++ 프로파일링 | '어디가 느린지 모르겠어요' perf·gprof로 병목 찾기

C++ 프로파일링 | '어디가 느린지 모르겠어요' perf·gprof로 병목 찾기

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와 함수별 시간 확인

도구 비교표

도구플랫폼방식오버헤드프로덕션
perfLinux샘플링낮음 (~5%)
gprofLinux/Mac인스트루멘테이션중간 (~10%)
ValgrindLinux/Mac시뮬레이션매우 높음 (10~50x)
VS ProfilerWindows샘플링낮음

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, 병목 지점, 성능 측정, 최적화 등으로 검색하시면 이 글이 도움이 됩니다.

정리

도구플랫폼용도
perfLinuxCPU 프로파일링
gprofLinux/Mac함수별 시간
ValgrindLinux/Mac메모리, 캐시
VS ProfilerWindowsCPU, 메모리
std::chrono모든 플랫폼수동 측정

핵심 원칙:

  1. 추측 금지, 측정 필수
  2. 병목 80%부터 최적화
  3. 최적화 전후 비교
  4. 작은 개선보다 큰 병목
  5. 프로파일러 활용

초보자를 위한 체크리스트

  • 최적화 전·후 같은 입력으로 시간을 재었는가?
  • 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++ 프로파일링 | ‘어디가 느린지 모르겠어요’ 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로 병목 찾기」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 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 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.