본문으로 건너뛰기 C++ 성능 최적화 | '10배 빠르게' 실전 기법 | 핵심 개념과 실전 활용

C++ 성능 최적화 | '10배 빠르게' 실전 기법 | 핵심 개념과 실전 활용

C++ 성능 최적화 | '10배 빠르게' 실전 기법 | 핵심 개념과 실전 활용

이 글의 핵심

C++ 성능 최적화: "10배 빠르게" 실전 기법. 불필요한 복사 제거·메모리 할당 최적화.

1. 불필요한 복사 제거

C++ 성능 최적화에서 가장 먼저, 그리고 가장 큰 효과를 내는 것은 새로운 알고리즘을 도입하는 것이 아니라 코드 전반에 흩어진 불필요한 복사를 걷어내는 일입니다. 복사 비용은 데이터가 커질수록 선형으로 늘어나는데, 이런 복사가 함수 호출마다 반복되면 프로그램 전체 성능에 누적되어 큰 영향을 미칩니다.

값 전달 vs 참조 전달

함수 매개변수를 값으로 받으면 호출할 때마다 인자 전체가 복사되므로, vectorstring처럼 힙 메모리를 가진 타입을 값으로 넘기는 것은 특히 비용이 큽니다. 함수가 인자를 읽기만 한다면 const 참조로 받아 복사 자체를 없애고, 함수 내부에서 인자를 수정해야 한다면 일반 참조로 받아 호출자가 원본을 직접 갱신하도록 하는 것이 원치 않는 복사를 피하는 기본 원칙입니다.

// ❌ 느림 (복사 발생)
void process(vector<int> data) {
    // ...
}

// ✅ 빠름 (참조)
void process(const vector<int>& data) {
    // ...
}

// ✅ 수정 필요 시
void process(vector<int>& data) {
    // ...
}

Move 시맨틱스

값 전달을 완전히 피할 수 없는 상황, 특히 함수가 지역 변수를 결과로 반환하거나 더 이상 쓰지 않을 객체를 다른 곳에 넘길 때는 이동 의미론을 활용해 복사 비용을 없앨 수 있습니다. C++11부터는 함수가 지역 변수를 반환할 때 컴파일러가 반환값 최적화나 암묵적 이동을 자동으로 적용해 주므로 대부분 별도 조치가 필요 없지만, v1처럼 이미 존재하는 변수를 더 이상 사용하지 않을 것이 확실하다면 std::move로 명시적으로 이동을 유도해 포인터만 옮기고 실제 데이터 복제는 피할 수 있습니다.

// ❌ 복사
vector<int> createLargeVector() {
    vector<int> v(1000000);
    return v;  // 복사 발생 가능
}

// ✅ move
vector<int> result = createLargeVector();  // move됨 (C++11)

// 명시적 move
vector<int> v1 = {1, 2, 3};
vector<int> v2 = move(v1);  // v1은 비워짐

2. 메모리 할당 최적화

동적 메모리 할당은 시스템 콜과 힙 관리 오버헤드를 동반하는 상대적으로 무거운 연산이므로, 할당 횟수 자체를 줄이는 것이 성능 개선에 직결됩니다. 아래 두 기법은 할당 빈도를 줄이는 대표적인 접근법으로, 각각 컨테이너 재할당과 객체 생성/소멸 비용을 겨냥합니다.

reserve로 재할당 방지

std::vector는 용량이 부족해질 때마다 더 큰 메모리를 할당하고 기존 원소를 옮긴 뒤 이전 메모리를 해제하는 과정을 반복하는데, 최종적으로 담길 원소 수를 미리 알 수 있다면 이 반복적인 재할당 자체가 낭비입니다. 반복문을 시작하기 전에 reserve()로 예상 크기만큼 미리 공간을 확보해 두면, 이후의 push_back 호출들은 재할당 없이 바로 끝나므로 전체 시간 복잡도는 같아도 실제 실행 시간은 눈에 띄게 줄어듭니다.

// ❌ 여러 번 재할당
vector<int> v;
for (int i = 0; i < 1000; i++) {
    v.push_back(i);  // 재할당 여러 번
}

// ✅ 한 번에 할당
vector<int> v;
v.reserve(1000);  // 미리 할당
for (int i = 0; i < 1000; i++) {
    v.push_back(i);
}

객체 풀

같은 타입의 객체를 빈번하게 생성하고 소멸시키는 코드에서는 매번 new/delete를 호출하는 대신 이미 만들어 둔 객체를 재사용하는 객체 풀(object pool) 패턴이 효과적입니다. 아래 ObjectPool 클래스는 다 쓴 객체를 release()로 반환받아 내부 벡터에 보관해 두었다가, acquire()가 호출되면 새로 생성하는 대신 풀에 남아 있는 객체를 재사용하는 방식으로 동작합니다. 이 패턴은 게임 엔진의 파티클이나 네트워크 서버의 연결 객체처럼 생성·소멸이 매우 빈번하고 수명이 짧은 객체를 다룰 때, 반복적인 힙 할당 비용을 크게 줄여줍니다.

// 실행 예제
template <typename T>
class ObjectPool {
private:
    vector<unique_ptr<T>> pool;
    
public:
    T* acquire() {
        if (pool.empty()) {
            return new T();
        }
        T* obj = pool.back().release();
        pool.pop_back();
        return obj;
    }
    
    void release(T* obj) {
        pool.push_back(unique_ptr<T>(obj));
    }
};

3. 캐시 친화적 코드

현대 CPU에서 메인 메모리 접근은 CPU 캐시 접근보다 수십 배 느리기 때문에, 데이터를 캐시에 최대한 오래 머물도록 배치하고 접근하는 것이 알고리즘의 이론적 복잡도 못지않게 실질적인 성능에 큰 영향을 미칩니다. 아래 두 기법은 구조체 설계와 배열 순회 순서라는, 겉보기에는 사소하지만 실제로는 캐시 효율을 좌우하는 두 가지 지점을 다룹니다.

데이터 지역성

CPU는 메모리를 한 바이트씩이 아니라 캐시 라인(보통 64바이트) 단위로 읽어오므로, 함께 자주 쓰이는 데이터를 서로 가깝게 배치하면 한 번의 캐시 라인 로드로 여러 필드를 동시에 캐시에 올릴 수 있습니다. 아래 Bad 구조체처럼 자주 함께 쓰이는 idvalue 사이에 불필요한 패딩이 끼어 있으면 두 필드가 서로 다른 캐시 라인에 걸쳐 있게 되어 캐시 미스가 늘어나지만, Good 구조체처럼 관련 필드를 가까이 배치하면 하나의 캐시 라인 로드만으로 두 값을 모두 얻을 수 있습니다.

// ❌ 캐시 미스 많음
struct Bad {
    int id;
    char padding[60];  // 캐시 라인 낭비
    int value;
};

// ✅ 캐시 친화적
struct Good {
    int id;
    int value;
    // 관련 데이터를 가까이
};

배열 순회 최적화

C++의 2차원 배열은 행 우선(row-major) 순서로 메모리에 저장되므로, matrix[i][j]에서 j를 먼저 1씩 증가시키며 접근하면 실제 메모리 주소도 순차적으로 이어져 있어 캐시가 미리 읽어온 데이터를 그대로 활용할 수 있습니다. 반대로 아래 첫 번째 루프처럼 바깥쪽에서 j를, 안쪽에서 i를 증가시키면 매번 다른 행으로 건너뛰는 접근이 되어 캐시 라인을 거의 재사용하지 못하고 캐시 미스가 빈번하게 발생합니다. 이 둘은 계산 결과는 완전히 동일하지만, 단순히 반복문의 중첩 순서만 바꿔도 대형 행렬을 다룰 때 실행 시간이 몇 배씩 차이 날 수 있습니다.

int matrix[1000][1000];

// ❌ 느림 (캐시 미스)
for (int j = 0; j < 1000; j++) {
    for (int i = 0; i < 1000; i++) {
        matrix[i][j] = 0;
    }
}

// ✅ 빠름 (순차 접근)
for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        matrix[i][j] = 0;
    }
}

4. 컴파일러 최적화

코드를 직접 손대지 않고도 컴파일러에게 최적화 힌트를 주거나 적절한 빌드 옵션을 선택하는 것만으로도 상당한 성능 이득을 얻을 수 있습니다. 아래는 함수 호출 오버헤드를 줄이는 인라인 함수와, 컴파일러의 최적화 수준을 결정하는 컴파일 옵션을 다룹니다.

인라인 함수

함수를 호출할 때마다 스택 프레임을 만들고 인자를 복사하고 반환 주소를 저장하는 오버헤드가 발생하는데, add처럼 본문이 매우 짧은 함수에서는 이 호출 오버헤드가 실제 연산 비용보다 클 수도 있습니다. inline 키워드는 컴파일러에게 함수 호출 대신 호출 지점에 함수 본문을 직접 삽입하도록 권장하는 힌트를 주며, constexpr은 여기서 한 걸음 더 나아가 인자가 컴파일 타임 상수라면 아예 실행 시점 계산 자체를 없애고 결과를 컴파일 타임에 미리 확정해 버립니다. 다만 최신 컴파일러는 inline 키워드가 없어도 최적화 빌드에서는 짧은 함수를 스스로 판단해 인라인화하는 경우가 많아, inline은 힌트일 뿐 강제는 아니라는 점을 이해하고 있어야 합니다.

// ❌ 함수 호출 오버헤드
int add(int a, int b) {
    return a + b;
}

// ✅ 인라인
inline int add(int a, int b) {
    return a + b;
}

// ✅ constexpr (컴파일 타임)
constexpr int add(int a, int b) {
    return a + b;
}

컴파일 옵션

GCC와 Clang은 -O 뒤에 붙는 숫자로 최적화 수준을 단계별로 조절할 수 있으며, 숫자가 높아질수록 더 공격적인 최적화(함수 인라인, 루프 언롤링, 자동 벡터화 등)를 적용하지만 그만큼 컴파일 시간도 늘어납니다. 실무에서는 안정성과 성능의 균형이 좋은 -O2를 배포 빌드의 기본값으로 삼는 경우가 많고, 수치 계산처럼 반복문이 매우 뜨거운 코드에서는 -O3로 추가 이득을 볼 수 있는지 벤치마크로 확인해 볼 가치가 있습니다. -march=native는 빌드하는 CPU에 특화된 명령어를 사용하도록 하지만 그 결과물은 다른 세대의 CPU에서 실행되지 않을 수 있으므로 배포용 바이너리보다는 특정 하드웨어에서만 돌아가는 내부 도구에 적합합니다.

# 최적화 레벨
g++ -O0  # 최적화 없음
g++ -O1  # 기본 최적화
g++ -O2  # 권장
g++ -O3  # 공격적 최적화

# 추가 옵션
g++ -O3 -march=native  # CPU 특화
g++ -O3 -flto  # Link Time Optimization

실전 예시

지금까지 다룬 최적화 원칙들이 실제로 얼마나 큰 성능 차이를 만드는지, 직접 시간을 측정해서 비교해 보면 훨씬 체감이 잘 됩니다. 아래 세 예시는 각각 문자열 연결, 반복 계산, SIMD 벡터화라는 서로 다른 영역에서 최적화 전후의 실행 시간을 chrono로 직접 비교합니다.

예시 1: 문자열 연결 최적화

string+=로 반복해서 이어붙이면 내부 버퍼가 부족해질 때마다 재할당이 발생할 수 있어, 반복 횟수가 늘어날수록 누적 비용이 커집니다. 아래 코드는 이 방식(concat1)과 ostringstream으로 조각을 모았다가 한 번에 문자열로 변환하는 방식(concat2)의 실행 시간을 직접 측정해 비교하는데, ostringstream은 내부적으로 버퍼를 더 효율적으로 관리하기 때문에 반복 횟수가 많아질수록 그 차이가 두드러집니다.

#include <iostream>
#include <string>
#include <sstream>
#include <chrono>
using namespace std;

// ❌ 느림
string concat1(int n) {
    string result;
    for (int i = 0; i < n; i++) {
        result += to_string(i);  // 매번 재할당
    }
    return result;
}

// ✅ 빠름
string concat2(int n) {
    ostringstream oss;
    for (int i = 0; i < n; i++) {
        oss << i;
    }
    return oss.str();
}

int main() {
    auto start = chrono::high_resolution_clock::now();
    concat1(10000);
    auto end = chrono::high_resolution_clock::now();
    cout << "concat1: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
    
    start = chrono::high_resolution_clock::now();
    concat2(10000);
    end = chrono::high_resolution_clock::now();
    cout << "concat2: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
}

설명: ostringstream이 string += 보다 훨씬 빠릅니다.

예시 2: 룩업 테이블

sin() 같은 삼각함수는 내부적으로 여러 단계의 부동소수점 연산을 거쳐 계산되므로, 같은 값을 반복해서 요청할 때마다 매번 이 계산을 새로 수행하는 것은 낭비일 수 있습니다. 아래 FastSin 클래스는 가능한 입력값에 대한 결과를 생성자에서 미리 계산해 테이블에 저장해 두고, 이후 get() 호출 시에는 삼각함수 계산 대신 단순한 배열 인덱싱만으로 값을 즉시 반환합니다. 이 룩업 테이블 기법은 입력 범위가 제한적이고 같은 값이 반복해서 요청되는 상황(그래픽 렌더링의 각도 계산 등)에서 계산 비용을 메모리 접근 비용으로 바꿔치기하는 전형적인 시간-공간 트레이드오프입니다.

#include <iostream>
#include <cmath>
#include <chrono>
using namespace std;

// ❌ 느림 (매번 계산)
double slow(int x) {
    return sin(x * 0.01);
}

// ✅ 빠름 (미리 계산)
class FastSin {
private:
    static constexpr int SIZE = 360;
    double table[SIZE];
    
public:
    FastSin() {
        for (int i = 0; i < SIZE; i++) {
            table[i] = sin(i * 0.01);
        }
    }
    
    double get(int x) {
        return table[x % SIZE];
    }
};

int main() {
    FastSin fastSin;
    
    auto start = chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; i++) {
        slow(i);
    }
    auto end = chrono::high_resolution_clock::now();
    cout << "slow: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
    
    start = chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; i++) {
        fastSin.get(i);
    }
    end = chrono::high_resolution_clock::now();
    cout << "fast: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
}

설명: 반복적인 계산은 룩업 테이블로 대체할 수 있습니다.

예시 3: SIMD 최적화

배열 전체에 같은 연산을 반복 적용하는 코드는 SIMD(Single Instruction Multiple Data) 명령으로 여러 원소를 한 번에 처리해 큰 성능 이득을 얻을 수 있습니다. 아래 add_scalar는 한 번에 원소 하나씩 더하는 평범한 루프인 반면, add_simd는 AVX 인트린식을 사용해 __m256 레지스터에 float 8개를 한꺼번에 담아 _mm256_add_ps 한 번으로 8개의 덧셈을 동시에 처리합니다. 이런 명시적 벡터화는 이미지 처리나 수치 시뮬레이션처럼 배열 전체에 동일한 연산을 대량으로 반복하는 코드에서 이론적으로 최대 8배(AVX 기준)에 가까운 처리량 향상을 가져올 수 있습니다.

#include <immintrin.h>  // AVX
#include <iostream>
using namespace std;

// ❌ 일반 루프
void add_scalar(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

// ✅ SIMD (8개씩 처리)
void add_simd(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i += 8) {
        __m256 va = _mm256_loadu_ps(&a[i]);
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_storeu_ps(&c[i], vc);
    }
}

설명: SIMD를 사용하면 여러 데이터를 동시에 처리할 수 있습니다.

프로파일링 도구

지금까지 다룬 최적화 기법들을 어디에 적용할지 결정하려면, 추측이 아니라 실제 측정 데이터에 근거해야 합니다. 아래 세 가지 도구는 각각 다른 방식으로 프로그램의 병목 지점을 찾아내며, 상황에 따라 적절히 선택해서 사용할 수 있습니다.

1. gprof

gprof는 컴파일 시 -pg 플래그로 계측 코드를 삽입한 뒤 프로그램을 한 번 실행해 함수별 호출 횟수와 소요 시간을 통계로 뽑아주는 고전적인 프로파일러입니다. 설치와 사용법이 간단해 빠르게 대략적인 병목 함수를 찾고 싶을 때 좋은 출발점이 되지만, 계측 방식 특성상 측정 자체가 실행 속도에 어느 정도 영향을 준다는 점을 감안해서 결과를 해석해야 합니다.

# 컴파일
g++ -pg program.cpp -o program

# 실행
./program

# 프로파일 확인
gprof program gmon.out > analysis.txt

2. Valgrind (Callgrind)

Valgrind의 Callgrind 도구는 프로그램을 가상 머신 위에서 실행하며 모든 함수 호출과 명령어를 정밀하게 추적해, gprof보다 훨씬 정확하고 실행할 때마다 일관된 결정론적인 결과를 제공합니다. 다만 명령어 하나하나를 가상으로 실행하다 보니 실제 실행 속도보다 수십 배 느려지므로, 정확도가 중요한 상세 분석 단계에서 사용하고 kcachegrind 같은 GUI 도구로 결과를 시각화하면 함수 간 호출 관계를 한눈에 파악할 수 있습니다.

# 프로파일링
valgrind --tool=callgrind ./program

# 결과 확인
kcachegrind callgrind.out.*

3. perf (Linux)

perf는 리눅스 커널에 내장된 프로파일러로, 프로그램을 다시 컴파일하지 않고도 이미 빌드된 바이너리를 샘플링 방식으로 분석할 수 있어 실제 운영 환경에 가까운 조건에서 병목을 진단하기에 적합합니다. perf record로 실행 중인 프로그램을 주기적으로 샘플링하고 perf report로 그 결과를 확인하면, 함수별 시간 비중뿐 아니라 캐시 미스나 분기 예측 실패 같은 하드웨어 수준의 정보까지 함께 살펴볼 수 있습니다.

# 프로파일링
perf record ./program

# 결과 확인
perf report

최적화 체크리스트

지금까지 다룬 개별 기법들을 실제 프로젝트에 적용할 때는 순서가 중요합니다. 아래 체크리스트는 효과가 큰 순서대로(알고리즘 → 메모리 → 컴파일러 → 캐시 → 병렬화) 정리한 것으로, 상위 항목에서 얻을 수 있는 개선 폭이 하위 항목보다 대체로 훨씬 크므로 이 순서를 참고해 우선순위를 정하는 것이 좋습니다.

1. 알고리즘 최적화

  • 시간복잡도 확인 (O(n²) → O(n log n))
  • 불필요한 중복 계산 제거
  • 적절한 자료구조 선택

2. 메모리 최적화

  • reserve() 사용
  • 불필요한 복사 제거
  • move 시맨틱스 활용

3. 컴파일러 최적화

  • -O2 또는 -O3 사용
  • inline/constexpr 활용
  • LTO 고려

4. 캐시 최적화

  • 데이터 지역성 개선
  • 순차 접근 패턴
  • 구조체 패딩 최소화

5. 병렬화

  • 멀티스레딩 고려
  • SIMD 활용
  • GPU 가속 (CUDA, OpenCL)

자주 발생하는 실수

성능 최적화를 시도하는 과정에서 오히려 역효과를 내는 경우도 적지 않습니다. 아래 세 가지는 실무에서 특히 자주 반복되는 최적화 관련 실수입니다.

실수 1: 조기 최적화

측정 없이 “이렇게 하면 더 빠를 것 같다”는 직감만으로 코드를 비트 연산 같은 저수준 트릭으로 바꾸는 것은 대부분 코드 가독성만 해치고 실질적인 성능 이득은 거의 없습니다. 아래 예시에서 (a << 1) + (b >> 2)처럼 비트 시프트로 곱셈과 나눗셈을 흉내 낸 코드는 a * 2 + b / 4라는 명확한 산술 표현과 사실상 동일한 기계어 코드로 컴파일되는 경우가 많은데, 이는 현대 컴파일러가 이런 단순한 패턴을 이미 스스로 최적화하기 때문입니다. 그래서 컴파일러가 대신 처리해 줄 수 있는 최적화는 코드를 억지로 비틀어 표현하기보다, 읽기 쉬운 코드로 작성하고 컴파일러를 신뢰하는 편이 대체로 더 나은 결과를 냅니다.

// ❌ 읽기 어려운 코드
int x = (a << 1) + (b >> 2);  // 최적화 시도

// ✅ 명확한 코드 (컴파일러가 최적화)
int x = a * 2 + b / 4;

실수 2: 프로파일링 없이 최적화

개발자의 직관은 프로그램의 실제 병목 위치를 맞히지 못하는 경우가 생각보다 많은데, 이는 캐시 동작이나 컴파일러 최적화처럼 눈에 보이지 않는 요인들이 실제 성능에 크게 관여하기 때문입니다. 그래서 “여기가 느릴 것 같다”는 추측만으로 최적화를 시작하기보다, 항상 아래와 같은 측정-최적화-재측정 순환을 거쳐 실제 데이터에 근거해서 노력을 투입할 지점을 정해야 합니다.

1. 프로파일링으로 병목 찾기
2. 병목 부분만 최적화
3. 다시 프로파일링으로 확인

실수 3: 마이크로 최적화에 집착

반복문 안의 사소한 연산 하나를 몇 나노초 줄이는 데 며칠을 쏟는 것보다, 알고리즘의 시간 복잡도 자체를 O(n²)에서 O(n log n)으로 바꾸는 것이 압도적으로 더 큰 성능 개선을 가져오는 경우가 대부분입니다. 아래 우선순위처럼 알고리즘과 자료구조 선택이 코드 수준의 미시적 최적화보다 항상 먼저 검토되어야 하며, 이 순서를 뒤집어 마이크로 최적화에 먼저 매달리면 투입한 노력 대비 얻는 성능 이득이 매우 작을 수 있습니다.

알고리즘 개선 > 자료구조 선택 > 코드 최적화

FAQ

Q1: 언제 최적화해야 하나요?

A:

  1. 프로파일링으로 병목 확인
  2. 병목이 실제 문제인지 확인
  3. 최적화 후 측정

Q2: 가장 효과적인 최적화는?

A: 알고리즘 개선이 가장 효과적입니다. O(n²) → O(n log n)

Q3: 컴파일러 최적화를 믿어도 되나요?

A: 네, 대부분의 경우 컴파일러가 더 잘 최적화합니다.

Q4: 성능 vs 가독성?

A: 가독성을 우선하되, 병목 부분만 최적화하세요.

Q5: 프로파일링 도구 추천은?

A:

  • Linux: perf, Valgrind
  • Windows: Visual Studio Profiler
  • 크로스 플랫폼: Tracy Profiler

Q6: 최적화 학습 리소스는?

A:

  • “Optimized C++” by Kurt Guntheroth
  • CppCon 발표 영상
  • Compiler Explorer (godbolt.org)

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, 성능최적화, 최적화, performance, 속도 등으로 검색하시면 이 글이 도움이 됩니다.