코드 최적화 기법
컴파일러·알고리즘·캐시·메모리·병렬화 관점의 최적화를 적용하고 재측정으로 실제 성능 향상을 검증합니다.
병목 지점을 찾았다면, 해당 코드를 어떻게 개선하여 성능을 향상시킬 수 있을까요?
이번 장에서는 C++ 프로그램의 성능을 최적화하기 위한 코드 수준 기법들을 정리합니다.
여기서 다룰 기법들은 특정 도메인에 국한되지 않는 일반적인 원칙과 패턴입니다.
실제 적용 시에는 항상 프로파일링을 통해 효과를 검증해야 함을 다시 한번 강조합니다.
먼저 아래 요약도는 이 절에서 다룰 최적화 기법을 측정, 병목 분류, 적용 기준의 순서로 정리합니다.
최적화는 정확성을 유지하며 측정 루프를 반복한다
정확성 확인과 성능 기준을 잡은 뒤 한 변경을 검증하고 회귀면 되돌리는 반복 절차이다.
C++ · MEASURE AND VERIFY
정확성을 유지하며 성능 변경을 검증한다
기능 정답과 허용 오차를 정한 뒤 같은 작업을 측정한다. 빠르게 보이는 변경도 정답·편차·복잡도 비용을 함께 평가한다.
정답 기준 고정
출력 · 경계값 · 허용 오차
기준 성능
같은 workload 반복 분포
병목 분류
CPU · 캐시 · 할당 · 대기
한 변경
원인에 맞는 기법 하나
재검증
정답 · 성능 · 유지 여부
측정 편차보다 작거나 특정 환경에만 보이는 개선은 추가 근거를 확인한다. 성능이 늘어도 정답이 바뀌면 원인을 수정하거나 변경을 되돌린다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 측정 편차보다 작거나 특정 환경에만 보이는 개선은 추가 근거를 확인한다. 성능이 좋아져도 정답이 바뀌면 원인을 수정하거나 변경을 되돌린다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 계약과 기준 정확성 확인과 성능 기준을 먼저 준비한다. |
|
|
| 단일 변경 검증 한 가지 최적화를 동일 조건에서 측정한다. |
|
|
| 결정과 반복 회귀가 있으면 되돌리고 다음 가설로 간다. |
|
|
최적화는 정확성을 유지하며 측정 루프를 반복한다
정확성 확인과 성능 기준을 잡은 뒤 한 변경을 검증하고 회귀면 되돌리는 반복 절차이다.
C++ · MEASURE AND VERIFY
정확성을 유지하며 성능 변경을 검증한다
기능 정답과 허용 오차를 정한 뒤 같은 작업을 측정한다. 빠르게 보이는 변경도 정답·편차·복잡도 비용을 함께 평가한다.
정답 기준 고정
출력 · 경계값 · 허용 오차
기준 성능
같은 workload 반복 분포
병목 분류
CPU · 캐시 · 할당 · 대기
한 변경
원인에 맞는 기법 하나
재검증
정답 · 성능 · 유지 여부
측정 편차보다 작거나 특정 환경에만 보이는 개선은 추가 근거를 확인한다. 성능이 늘어도 정답이 바뀌면 원인을 수정하거나 변경을 되돌린다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 측정 편차보다 작거나 특정 환경에만 보이는 개선은 추가 근거를 확인한다. 성능이 좋아져도 정답이 바뀌면 원인을 수정하거나 변경을 되돌린다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 계약과 기준 정확성 확인과 성능 기준을 먼저 준비한다. |
|
|
| 단일 변경 검증 한 가지 최적화를 동일 조건에서 측정한다. |
|
|
| 결정과 반복 회귀가 있으면 되돌리고 다음 가설로 간다. |
|
|
최적화는 한 번에 끝나는 작업이 아니라, 측정 결과를 보고 병목 유형을 분류한 뒤 적절한 기법을 적용하고 다시 측정하는 반복 과정입니다.
다음 그림은 그 루프를 프로파일링 결과에서 재측정까지의 의사결정 흐름으로 보여줍니다.
C++ · OPTIMIZATION CHOICE
측정한 병목에 가까운 첫 변경을 고른다
한꺼번에 모든 기법을 적용하지 않고 가장 큰 비용의 원인 가설을 검증한다. 아래 후보의 효과는 같은 workload로 다시 측정한다.
계산 비용 → 알고리즘과 중복
반복 규모에 따른 복잡도와 같은 계산이 반복되는지 확인한다.
본문은 정렬 알고리즘과 루프 불변 계산을 비교한다. 계산 이동 전 부작용·예외·N=0의 호출 조건도 확인한다.
캐시 미스 → 배치와 순회
연속 배치·순차 접근·작업 데이터 크기와 순회 순서를 검토한다.
vector/list 이름만으로 우열을 정하지 않는다. 실제 접근 패턴과 위치 탐색 비용까지 측정한다.
할당 비용 → reserve와 재사용
불필요한 재할당·임시 객체·작은 객체의 반복 할당을 줄인다.
data.reserve(expected_count);풀이나 이동의 비용은 수명·타입·allocator 조건과 함께 확인한다. RAII는 해제 책임을 유지하는 계약이다.
대기 비용 → 범위와 묶음 처리
잠금 구간을 줄이고 I/O를 버퍼링하거나 묶어 처리할 후보를 고른다.
잠금 밖으로 옮겨도 공유 상태의 불변식을 지켜야 한다. 병렬화는 동기화와 스케줄링 비용까지 재측정한다.
컴파일러 옵션·자료구조·pool·병렬화는 이름만으로 우열이 정해지지 않는다. 한 변경마다 출력과 측정 분포를 함께 비교한다.
컴파일러 최적화 활용
가장 쉽고 기본적인 최적화는 컴파일러의 최적화 기능을 적극적으로 활용하는 것입니다.
현대 C++ 컴파일러(GCC, Clang, MSVC)는 매우 정교한 최적화 기술을 내장하고 있습니다.
그래서 개발자가 명시적으로 최적화하지 않아도 상당한 성능 향상을 가져다줍니다.
-
최적화 플래그
-O2,-O3(GCC/Clang): 최적화 수준을 지정합니다.-O2는 대부분의 경우 좋은 균형을 제공하며,-O3는 더 공격적인 최적화를 시도하지만, 컴파일 시간이 길어지고 때로는 디버깅을 어렵게 만들 수 있습니다./O2,/Ox(MSVC): MSVC의 최적화 플래그입니다.- LTO (Link Time Optimization) / WPO (Whole Program Optimization): 프로그램 전체를 대상으로 최적화를 수행하여, 개별 소스 파일 컴파일 시에는 불가능했던 최적화를 가능하게 합니다. (예: GCC/Clang의
-flto, MSVC의/GL)
-
인라인화 (Inlining)
- 컴파일러는 작은 함수를 호출하는 대신, 호출 지점에 함수의 본문을 직접 삽입(인라인화)하여 함수 호출 오버헤드를 줄일 수 있습니다.
- 실제 호출의 인라인화 여부는 컴파일러가 결정합니다. 그러나
inline에는 여러 번역 단위에 정의를 둘 때 따르는 언어 규칙도 있으므로, 호출을 치환하지 않아도 그 규칙은 유지됩니다. 헤더 정의와 조건은 인라인 함수에서 확인합니다. - 표준 C++11에는 [[noinline]] 속성이 없습니다. GCC의 [[gnu::noinline]] 등 컴파일러별 확장을 사용한다면 해당 도구 문서와 지원 범위를 확인합니다.
-
Dead Code Elimination (불필요 코드 제거)
- 컴파일러는 실행되지 않거나 결과에 영향을 미치지 않는 코드를 자동으로 제거합니다.
-
Loop Unrolling (루프 언롤링)
- 루프 반복 횟수를 줄이기 위해 루프 본문을 여러 번 복제합니다. 루프 제어 변수의 오버헤드를 줄일 수 있습니다.
-
Vectorization (벡터화 / SIMD)
- 컴파일러는 SIMD(Single Instruction Multiple Data) 명령어를 사용하여 여러 데이터에 대해 단일 명령어를 동시에 실행하도록 코드를 변환할 수 있습니다. 이는 특히 벡터/행렬 연산에서 큰 성능 향상을 가져옵니다.
중요: 디버깅 빌드(-O0 / /Od)는 최적화를 거의 수행하지 않으므로, 성능 측정은 항상 최적화가 적용된 릴리스 빌드에서 수행해야 합니다.
알고리즘과 자료구조 선택
아무리 코드를 잘 작성해도, 비효율적인 알고리즘이나 자료구조를 사용한다면 성능 한계에 부딪힐 수밖에 없습니다.
이는 가장 중요하고 기본적인 최적화 원칙입니다.
-
시간 복잡도 (Time Complexity)
- 알고리즘 대신 또는 알고리즘을 사용하면 N이 커질수록 실행 시간 차이가 크게 벌어집니다.
- 예: 정렬 시 버블 정렬 () 대신
std::sort(대표 구현은 인트로 정렬이며 비교 횟수는 보장) 사용.
-
공간 복잡도 (Space Complexity)
- 메모리 사용량을 줄이는 것은 캐시 효율성에도 영향을 미쳐 성능에 간접적인 영향을 미칩니다.
-
자료구조 선택
std::vectorvsstd::listvsstd::setvsstd::unordered_map등: 각 자료구조는 접근, 삽입, 삭제, 검색 등의 연산에 대한 시간 복잡도와 메모리 사용량 특성이 다릅니다.- 예: 삽입·삭제할 위치의 반복자를 이미 갖고 있다면
std::list의 한 원소 삽입·삭제 비용은 상수 시간입니다. 먼저 위치를 찾는 순회 비용은 별도입니다. 키 검색에는std::unordered_map(해시맵)의 평균 비용을 검토하되 충돌·해시 비용과 최악 조건을 확인합니다. 캐시 친화적인 순차 접근이 중요하면std::vector의 연속 배치를 후보로 측정합니다.
캐시 효율성 (Cache Efficiency)
캐시 최적화는 단순히 자료구조 이름을 고르는 문제가 아니라, 메모리를 어떤 순서로 읽고 쓰는지 확인하는 문제입니다.
연속 배치와 포인터 추적이 실제 순회 비용에 주는 차이를 측정합니다.
현대 CPU는 메인 메모리보다 훨씬 빠른 캐시 메모리(L1, L2, L3)를 사용하여 데이터 접근 속도를 높입니다.
캐시 친화적인(cache-friendly) 코드를 작성하는 것은 매우 중요합니다.
-
데이터 지역성 (Locality of Reference)
- 공간 지역성 (Spatial Locality): 참조된 데이터 주변의 데이터도 곧 참조될 가능성이 높습니다. 연속된 메모리 공간에 데이터를 배치하고 순차적으로 접근하면 캐시 히트(cache hit)율이 높아집니다.
std::vector가std::list보다 일반적으로 빠른 이유 중 하나입니다. - 시간 지역성 (Temporal Locality): 최근에 참조된 데이터는 곧 다시 참조될 가능성이 높습니다. 자주 사용하는 데이터를 캐시 내에 유지하도록 합니다.
- 공간 지역성 (Spatial Locality): 참조된 데이터 주변의 데이터도 곧 참조될 가능성이 높습니다. 연속된 메모리 공간에 데이터를 배치하고 순차적으로 접근하면 캐시 히트(cache hit)율이 높아집니다.
-
데이터 구조 패딩 (Padding)
- 구조체 멤버 변수의 순서를 조정하거나, 필요에 따라 패딩을 추가하여 캐시 라인(cache line)의 효율적인 사용을 유도할 수 있습니다. (고급 기법)
-
false sharing방지- 멀티스레드 환경에서, 서로 다른 스레드의 독립적인 데이터가 같은 캐시 라인에 있고 적어도 하나의 스레드가 쓰면 불필요한 캐시 무효화·전달로 성능이 저하될 수 있습니다. 같은 라인을 읽는 것만으로 이러한 쓰기 경합을 단정하지 않습니다.
std::hardware_destructive_interference_size(C++17)를 이용하여 패딩을 추가하거나 데이터를 재배치하여 이를 방지할 수 있습니다.
- 멀티스레드 환경에서, 서로 다른 스레드의 독립적인 데이터가 같은 캐시 라인에 있고 적어도 하나의 스레드가 쓰면 불필요한 캐시 무효화·전달로 성능이 저하될 수 있습니다. 같은 라인을 읽는 것만으로 이러한 쓰기 경합을 단정하지 않습니다.
불필요한 연산 제거 및 단순화
-
루프 불변 코드 이동 (Loop Invariant Code Motion)
- 루프 내에서 매번 동일한 값을 계산하는 코드가 있다면, 루프 밖으로 빼내어 한 번만 계산하도록 합니다. 컴파일러가 자동으로 수행하기도 하지만, 명시적으로 작성하는 것이 좋습니다.
// before for (int i = 0; i < N; ++i) { result[i] = i * func(param); // func는 부작용·관측 가능한 예외가 없고 param도 변하지 않는다고 가정 } // after // N=0일 때 불필요한 호출도 생기므로 적용 전 호출 조건을 확인 const int fixed_val = func(param); for (int i = 0; i < N; ++i) { result[i] = i * fixed_val; }
예시
앞의 루프 불변 계산 발췌를 시험하려면 N·param·result·func의 정의가 필요합니다. 다음은 반환 배열의 값만을 기능 계약으로 삼는 완전한 프로그램입니다. calls는 호출 수를 보기 위해 추가한 계측이며, 이 수가 같아야 한다는 요구는 없습니다. 계측 증가가 업무상 필수 부작용이라면 이 변환은 허용되지 않습니다.
#include <iostream>
#include <vector>
int measured_value(int param, int& calls) {
++calls; // 이 예제에서만 관찰하는 호출 수
return param * 2;
}
std::vector<int> before(int n, int param, int& calls) {
std::vector<int> result(n);
for (int i = 0; i < n; ++i) result[i] = i * measured_value(param, calls);
return result;
}
std::vector<int> after(int n, int param, int& calls) {
std::vector<int> result(n);
if (n > 0) { // 원래 호출이 없던 n=0을 보존
const int fixed = measured_value(param, calls);
for (int i = 0; i < n; ++i) result[i] = i * fixed;
}
return result;
}
int main() {
int a_calls = 0, b_calls = 0;
const auto a = before(3, 5, a_calls);
const auto b = after(3, 5, b_calls);
std::cout << std::boolalpha << (a == b) << " : " << a_calls << " / " << b_calls << '\n';
for (int x : b) std::cout << x << ' ';
std::cout << '\n';
a_calls = b_calls = 0;
before(0, 5, a_calls);
after(0, 5, b_calls);
std::cout << a_calls << " / " << b_calls << '\n';
}출력은 첫 줄 true : 3 / 1, 둘째 줄 0 10 20, 셋째 줄 0 / 0입니다. 반환값이 일정하고 조건 범위에서 정수 오버플로가 없으므로 배열은 같고, 계산 호출은 세 번에서 한 번으로 줄어듭니다. 이 호출 수를 실제 실행 시간의 3배 차이로 바꾸어 읽으면 안 됩니다. 컴파일러가 이미 같은 계산을 제거할 수도 있고 전체 프로그램에 다른 비용도 있기 때문입니다.
독립 확인: n=4·param=2의 두 배열과 호출 수를 구하세요. 다음으로 measured_value가 호출마다 반드시 저장해야 하는 감사 기록을 남기는 업무 함수라면, 같은 배열이 나온다는 이유만으로 루프 밖으로 옮길 수 있는지 판단하세요.
조건별 판단 확인
배열은 둘 다 0 4 8 12, 호출 수는 4회와 1회입니다. 감사 기록이 필수 기능이면 기록이 세 번 사라지므로 계약이 달라져 허용되지 않습니다. n=0에서도 기록을 새로 남기지 않도록 호출 조건을 보존해야 합니다.
-
지연 초기화 (Lazy Initialization)
- 객체나 자원이 실제로 필요할 때까지 초기화를 지연합니다. 항상 사용되지 않는 자원을 미리 할당하거나 계산하는 것을 방지합니다.
-
불필요한 함수 호출/객체 생성 줄이기
- 특히 루프 내에서 과도한 함수 호출이나 임시 객체 생성을 피합니다.
- RVO/NRVO (Return Value Optimization / Named Return Value Optimization): C++ 컴파일러가 반환값을 복사하지 않고 바로 목적지에 생성하도록 최적화합니다. C++17부터는 보장되는 경우가 많습니다.
-
const및noexcept활용const는 그 접근 경로에서 허용되는 변경을 제한합니다. 다른 별칭이나 mutable 멤버까지 모두 불변이라는 뜻은 아니며, 선언만으로 성능 개선이 보장되지는 않습니다.noexcept는 예외가 함수 밖으로 전파되지 않는 계약이며, 구현의 예외 처리 비용이나 컨테이너의 이동 선택에 영향을 줄 수 있습니다. 특히 이동 생성자/대입 연산자에noexcept를 사용하는 이유를 확인하되 실제 계약을 지켜야 합니다. 밖으로 나갈 예외가 발생하면 정상 복구 대신 종료하므로 속도만을 위해 붙이지 않습니다.
메모리 관리 최적화
-
동적 할당 최소화
new/delete또는malloc/free는 할당자 관리 비용이 있으며 필요에 따라 시스템 호출도 발생합니다. 매번 커널을 호출하는 것은 아닙니다.- 빈번한 작은 객체 할당을 피하고, 가능하면 스택에 할당하거나 큰 청크(chunk)로 미리 할당하여 재사용하는 메모리 풀(Memory Pool) 기법을 고려합니다.
std::vector와 같이 연속적인 메모리 할당을 사용하는 컨테이너는 동적 할당의 오버헤드를 줄이는 데 효과적입니다. 미리reserve()를 사용하여 재할당을 피하는 것도 중요합니다.
-
이동 시맨틱 (
std::move) 활용- 값비싼 객체의 복사 대신 이동 연산을 선택할 수 있도록
std::move를 사용합니다. 이 표현 자체가 자원을 옮기는 것은 아니며, 선택된 생성자·대입 연산자의 구현이 실제 작업을 합니다. - std::unique_ptr는 move-only이고, std::vector는 원소 타입의 요구 조건에 따라 복사도 가능합니다. 이동 비용은 타입과 allocator 조건에 따라 확인합니다.
- 값비싼 객체의 복사 대신 이동 연산을 선택할 수 있도록
병렬 처리 (Parallel Processing)
멀티코어 CPU의 시대에 병렬 처리는 성능 최적화의 핵심입니다.
-
멀티스레딩 (Multithreading)
- CPU 집약적인 작업을 여러 스레드로 분할하여 동시에 실행합니다. (15장 참조)
std::thread,std::async등을 활용합니다.- 경쟁 조건, 데드락 등의 문제에 대한 동기화가 필수적입니다 (
std::mutex,std::condition_variable).
-
OpenMP / TBB (Threading Building Blocks) / C++ Concurrency TS
- 더 복잡하고 세밀한 병렬 처리를 위해 OpenMP (컴파일러 지시어 기반 병렬화), Intel TBB (템플릿 라이브러리), 또는 C++ 표준의 동시성 TS(Technical Specification)를 사용할 수 있습니다.
기타 고려 사항
-
정밀한 부동소수점 연산 vs 빠른 연산
- 부동소수점 연산은 성능에 큰 영향을 미칠 수 있습니다.
float와double은 필요한 표현 범위·정밀도와 실제 대상 하드웨어의 비용으로 고릅니다.float대신double을 쓴다는 이유만으로 더 빨라지지는 않습니다. 컴파일러 플래그로 연산 계약을 바꾸는 경우도 있습니다 (예: GCC/Clang의-ffast-math). GCC의 이 옵션은 유한 값 등의 가정과 수학 규칙을 바꾸므로 오차만이 아니라 NaN·무한대 등 필요한 동작까지 검증해야 합니다. GCC 최적화 옵션의 해당 옵션 범위를 확인합니다.
- 부동소수점 연산은 성능에 큰 영향을 미칠 수 있습니다.
-
시스템 호출 (System Calls) 최소화
- 운영체제 커널 모드와의 전환은 오버헤드가 발생합니다. 파일 I/O, 네트워크 통신 등 시스템 호출을 하는 작업의 횟수를 최소화하고 데이터를 버퍼링하여 한 번에 처리하는 것이 효율적입니다.
-
로그 및 디버깅 코드 제거
- 릴리스 빌드에서는 불필요한 로그 출력이나 디버깅을 위한 어설션(assertion) 등을 컴파일하지 않도록 매크로(
NDEBUG등)를 사용합니다.
- 릴리스 빌드에서는 불필요한 로그 출력이나 디버깅을 위한 어설션(assertion) 등을 컴파일하지 않도록 매크로(
예시
측정 조건이 같은 가상 직렬 작업이 100 ms이고, 그중 선택한 함수가 40 ms를 차지한다고 합시다. 다른 60 ms가 그대로인 채 그 함수만 20 ms로 줄었다면 전체는 80 ms, 개선은 20%입니다. 한 함수가 두 배 빨라졌다고 전체가 두 배 빨라지는 것은 아닙니다. 독립 확인: 같은 함수만 10 ms로 줄었다면 전체 시간과 개선율은 얼마인가요?
전체 비용 계산 확인
남은 60 ms+함수 10 ms=70 ms이고 개선율은 (100−70)/100=30%입니다. 함수 비용이 0에 가까워져도 나머지 60 ms가 그대로이면 이 직렬 모형의 개선에는 한계가 있습니다. 실제 병렬 작업·I/O·캐시 변화는 이 단순 합산 가정 밖이므로 재측정합니다.
최적화 기법은 병목의 원인에 맞게 고를 때 효과가 큽니다.
앞의 병목별 선택 흐름에 따라 개선 후보를 하나씩 검토합니다.
최적화는 빠르게 고치는 일이 아니라 정확성 기준을 지킨 채 병목별 변경과 재측정을 짝지어 진행해야 합니다.
컴파일러 옵션, 자료구조, 캐시 배치, 병렬화는 같은 입력의 정확성과 측정 분포로 판단합니다.