C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
들어가며
이 글은 두 가지 “컴파일 타임” 이야기를 다룹니다. 하나는 런타임에 하던 계산을 constexpr·if constexpr·consteval로 컴파일 타임에 옮겨 실행 비용을 없애는 것이고, 다른 하나는 반대로 빌드 자체에 드는 시간을 줄이는 것입니다. 앞쪽은 빌드 시간을 조금 늘리는 대신 실행 시간을 줄이고, 뒤쪽은 PCH·C++20 모듈·ccache·distcc·Unity 빌드로 같은 결과를 더 빨리 빌드합니다.
출발점은 흔한 패턴입니다. 값이 항상 같은 룩업 테이블(미리 계산해 둔 값 배열, 인덱스로 바로 결과를 찾을 때 사용)을 프로그램이 시작할 때마다 런타임에 채우는 코드입니다.
#include <array>
std::array<int, 256> lookupTable;
void initTable() {
for (int i = 0; i < 256; ++i) {
lookupTable[i] = i * i; // 매번 계산
}
}
int main() {
initTable(); // 프로그램 시작마다 실행
// ...
}
같은 테이블을 constexpr 함수로 만들면 컴파일러가 값을 계산해 바이너리에 상수로 넣습니다.
#include <array>
constexpr std::array<int, 256> makeLookupTable() {
std::array<int, 256> table{};
for (int i = 0; i < 256; ++i) {
table[i] = i * i; // ✅ 컴파일 타임에 계산
}
return table;
}
constexpr auto lookupTable = makeLookupTable();
int main() {
// 테이블이 이미 준비됨 (초기화 비용 0)
}
컴파일러는 상수 평가에 단계 한도를 두고 있어서(Clang의 -fconstexpr-steps, GCC의 -fconstexpr-ops-limit 등), 아주 큰 테이블이나 비효율적인 알고리즘을 컴파일 타임에 돌리면 빌드가 실패하거나 느려질 수 있습니다.
constexpr 변수와 const의 차이
constexpr 변수
constexpr int x = 10; // 컴파일 타임 상수
constexpr int y = x * 2; // 20
// 배열 크기로 사용 가능
int arr[x]; // ✅ OK
// 일반 const는 불가능할 수 있음
const int z = getInput(); // 런타임 값
// int arr2[z]; // ❌ 에러 (컴파일 타임 상수 아님)
const vs constexpr
const int a = 10; // 상수 식으로 초기화했으므로 상수 식에 쓸 수 있음
constexpr int b = 10; // 상수 식이 아니면 컴파일 에러
const int c = getInput(); // ✅ OK (런타임)
// constexpr int d = getInput(); // ❌ 에러
constexpr 함수와 constexpr 클래스
기본 사용법
#include <iostream>
constexpr int square(int x) {
return x * x;
}
int main() {
// 컴파일 타임 계산
constexpr int a = square(5); // 25 (컴파일 타임)
// 런타임 계산도 가능
int input;
std::cin >> input;
int b = square(input); // 런타임
}
재귀 함수
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
int main() {
constexpr int f5 = factorial(5); // 120 (컴파일 타임)
// 배열 크기로 사용
int arr[factorial(4)]; // int arr[24]
}
복잡한 계산
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
constexpr int fib10 = fibonacci(10); // 55 (컴파일 타임)
constexpr 클래스
class Point {
int x, y;
public:
constexpr Point(int x, int y) : x(x), y(y) {}
constexpr int getX() const { return x; }
constexpr int getY() const { return y; }
constexpr int distanceSquared() const {
return x * x + y * y;
}
};
int main() {
constexpr Point p(3, 4);
constexpr int dist = p.distanceSquared(); // 25 (컴파일 타임)
}
if constexpr (C++17)
컴파일 타임 분기
#include <iostream>
template <typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "Integer: " << value << "\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "Float: " << value << "\n";
} else {
std::cout << "Other: " << value << "\n";
}
}
int main() {
process(42); // Integer: 42
process(3.14); // Float: 3.14
process("hello"); // Other: hello
}
일반 if vs if constexpr
template <typename T>
void func(T value) {
// 일반 if: 두 분기 모두 컴파일됨
if (std::is_integral_v<T>) {
value++; // T가 string이면 컴파일 에러
}
// if constexpr: 선택된 분기만 컴파일
if constexpr (std::is_integral_v<T>) {
value++; // T가 string이면 이 코드 무시됨
}
}
재귀 종료
#include <iostream>
template <typename T>
void print(T value) {
std::cout << value << "\n";
}
template <typename T, typename... Args>
void print(T first, Args... rest) {
std::cout << first << " ";
if constexpr (sizeof...(rest) > 0) {
print(rest...); // 재귀
} else {
std::cout << "\n";
}
}
int main() {
print(1, 2, 3, "hello", 4.5);
}
consteval (C++20)
반드시 컴파일 타임
consteval int square(int x) {
return x * x;
}
int main() {
constexpr int a = square(5); // ✅ OK (컴파일 타임)
int input = 10;
// int b = square(input); // ❌ 에러: 런타임 호출 불가
}
constexpr vs consteval
constexpr int func1(int x) {
return x * 2; // 컴파일 타임 또는 런타임
}
consteval int func2(int x) {
return x * 2; // 반드시 컴파일 타임
}
int main() {
constexpr int a = func1(5); // ✅ 컴파일 타임
int input = 10;
int b = func1(input); // ✅ 런타임
constexpr int c = func2(5); // ✅ 컴파일 타임
// int d = func2(input); // ❌ 에러
}
컴파일 타임 해시, 배열 생성, 문자열 처리
패턴 1: 컴파일 타임 해시
constexpr uint32_t hash(const char* str) {
uint32_t hash = 5381;
while (*str) {
hash = ((hash << 5) + hash) + *str++;
}
return hash;
}
int main() {
// switch에서 문자열 비교
constexpr uint32_t cmdOpen = hash("open");
constexpr uint32_t cmdClose = hash("close");
uint32_t cmd = hash(getUserInput());
switch (cmd) {
case cmdOpen:
openFile();
break;
case cmdClose:
closeFile();
break;
}
}
case 라벨에는 상수 식이 필요하므로 constexpr 해시가 이런 용도로 자주 쓰입니다. 다만 해시는 충돌할 수 있어서, 등록하지 않은 다른 문자열이 우연히 cmdOpen과 같은 해시를 가질 수 있습니다. 외부 입력을 처리한다면 case 안에서 원래 문자열과 한 번 더 비교하는 편이 안전합니다. 또 두 명령의 해시가 서로 같으면 case 라벨이 중복되어 컴파일 에러가 나므로, 명령 사이의 충돌은 컴파일 시점에 드러납니다.
패턴 2: 컴파일 타임 배열 생성
#include <iostream>
#include <array>
template <size_t N>
constexpr std::array<int, N> makeSquares() {
std::array<int, N> arr{};
for (size_t i = 0; i < N; ++i) {
arr[i] = i * i;
}
return arr;
}
constexpr auto squares = makeSquares<100>();
int main() {
std::cout << squares[10] << "\n"; // 100 (이미 계산됨)
}
패턴 3: 타입 특성 확인
template <typename T>
constexpr bool isTrivial() {
return std::is_trivially_copyable_v<T> &&
std::is_trivially_destructible_v<T>;
}
template <typename T>
void serialize(const T& value) {
if constexpr (isTrivial<T>()) {
// POD 타입: 바이너리 복사
write(&value, sizeof(T));
} else {
// 복잡한 타입: 직렬화 함수 호출
value.serialize();
}
}
패턴 4: 컴파일 타임 문자열 처리
constexpr size_t stringLength(const char* str) {
size_t len = 0;
while (str[len] != '\0') {
++len;
}
return len;
}
constexpr bool startsWith(const char* str, const char* prefix) {
while (*prefix) {
if (*str++ != *prefix++) {
return false;
}
}
return true;
}
int main() {
constexpr size_t len = stringLength("Hello"); // 5
constexpr bool result = startsWith("Hello", "Hel"); // true
}
패턴 5: 컴파일 타임 팩토리얼 테이블
#include <iostream>
#include <array>
template <size_t N>
constexpr std::array<long long, N> makeFactorials() {
std::array<long long, N> arr{};
arr[0] = 1;
for (size_t i = 1; i < N; ++i) {
arr[i] = arr[i - 1] * i;
}
return arr;
}
constexpr auto factorials = makeFactorials<20>();
int main() {
std::cout << "10! = " << factorials[10] << "\n"; // 즉시 출력
}
빌드 시간 병목 분석
여기부터는 “프로그램이 빨리 도는 것”이 아니라 “빌드가 빨리 끝나는 것”을 다룹니다. 앞 절의 constexpr은 계산을 컴파일러에게 넘기므로 빌드 시간을 오히려 늘리는 쪽이고, 템플릿 메타프로그래밍이 많은 코드베이스라면 그 비용도 측정 대상에 들어갑니다. 어떤 기법을 고르든 먼저 시간이 어디서 쓰이는지 측정하는 것이 출발점입니다.
빌드 파이프라인
flowchart LR
A[".cpp 파일"]
B["#include 펼치기"]
C["전처리"]
D["파싱·템플릿 인스턴스화"]
E["최적화"]
F["코드 생성"]
G["링크"]
A --> B --> C --> D --> E --> F --> G
- 전처리·파싱: 헤더를 많이 include하는 프로젝트에서는 대개 이 구간이 가장 큽니다.
.cpp한 줄짜리 파일도<iostream>이나 Boost 헤더를 include하면 수만 줄을 파싱하게 되고, 이 작업이 TU(번역 단위)마다 반복됩니다. - 템플릿 인스턴스화: 같은
std::vector<Foo>가 수백 개 TU에서 각각 인스턴스화되고, 중복된 결과는 링커가 나중에 버립니다. - 최적화·코드 생성:
-O2/-O3, LTO를 켜면 커집니다. - 링크: 바이너리가 크거나 디버그 정보가 많을수록 커지고, 병렬화가 잘 안 되는 구간이라 증분 빌드에서 체감이 큽니다.
PCH와 모듈은 첫 두 구간을, ccache는 “같은 입력이면 아예 건너뛰기”를, distcc와 -j는 여러 코어·머신으로 나누기를, Unity 빌드는 TU 수 자체를 줄이는 것을 노립니다. 병목이 링크라면 이 중 어느 것도 크게 효과가 없고, lld나 mold 같은 빠른 링커나 디버그 정보 분리가 더 효과적입니다.
헤더 의존성 폭발
flowchart TB
M[main.cpp]
H1[common.h]
H2[utils.h]
H3[config.h]
H4[iostream]
H5[string]
M --> H1
H1 --> H2
H2 --> H3
H1 --> H4
H4 --> H5
main.cpp는 config.h를 직접 include하지 않지만, common.h → utils.h를 거쳐 전이적으로 포함합니다. 그래서 config.h를 고치면 이것을 직접·간접으로 포함하는 모든 TU가 다시 컴파일됩니다. 거의 모든 파일이 include하는 공용 헤더에 자주 바뀌는 상수(빌드 번호, 기능 플래그 등)를 두면 사소한 수정마다 전체 빌드가 되는 이유가 이것입니다. 어떤 헤더가 얼마나 퍼져 있는지는 GCC/Clang의 -H 옵션(포함되는 헤더를 깊이별로 출력)이나 MSVC의 /showIncludes로 확인할 수 있습니다.
측정 방법
# 전체 빌드 시간과 최대 메모리 (GNU time)
/usr/bin/time -v cmake --build build -j8 2>&1 | grep -E "Elapsed|Maximum resident"
# GCC: 한 TU 안에서 단계별 시간
g++ -c -ftime-report -O2 main.cpp 2>&1 | tail -20
# Clang: TU별 타임라인 JSON (chrome://tracing 이나 Perfetto에서 열기)
clang++ -c -ftime-trace -O2 main.cpp
# Ninja: 직전 빌드에서 각 타깃이 걸린 시간이 .ninja_log 에 남음
Clang의 -ftime-trace는 어떤 헤더 파싱과 어떤 템플릿 인스턴스화에 시간이 들었는지 보여 줘서 “무엇을 PCH에 넣을지”, “어떤 헤더를 forward declaration으로 바꿀지”를 정할 근거가 됩니다. MSVC에서는 /Bt+(프론트엔드·백엔드 시간)나 Microsoft의 vcperf(C++ Build Insights)가 같은 역할을 합니다. 개선 전후를 비교할 때는 클린 빌드와, 자주 고치는 파일 하나를 수정한 뒤의 증분 빌드를 따로 재야 합니다. 둘 중 무엇이 빨라지는지가 기법마다 다르기 때문입니다.
PCH(미리 컴파일된 헤더)
PCH란?
자주 쓰는 헤더들을 한 번만 컴파일해 .gch(GCC) 또는 .pch(Clang/MSVC)로 저장하며, 각 TU에서 이 바이너리를 재사용합니다. 효과는 TU마다 반복되던 헤더 파싱이 전체 빌드 시간에서 차지하는 비중에 달려 있어서, <iostream>, Boost, Qt처럼 무거운 헤더를 많은 .cpp가 공통으로 include하는 프로젝트일수록 큽니다. 반대로 PCH에 자주 바뀌는 헤더를 넣으면 그 헤더가 바뀔 때마다 PCH와 모든 TU가 다시 빌드되므로, 거의 바뀌지 않는 외부 헤더만 넣는 것이 원칙입니다.
flowchart LR
subgraph Before[PCH 없음]
A1[main.cpp] --> A2[iostream 파싱]
B1[foo.cpp] --> B2[iostream 파싱]
C1[bar.cpp] --> C2[iostream 파싱]
end
subgraph After[PCH 사용]
D1[pch.h.gch] --> D2[한 번만 생성]
E1[main.cpp] --> E2[PCH 로드]
F1[foo.cpp] --> F2[PCH 로드]
G1[bar.cpp] --> G2[PCH 로드]
end
PCH 헤더 작성
// pch.h - 미리 컴파일할 헤더
#ifndef PCH_H
#define PCH_H
// 자주 쓰는 표준 라이브러리 (변경 거의 없음)
#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <algorithm>
#include <chrono>
// 프로젝트 공통 헤더
#include "config.h"
#include "types.h"
#endif
GCC에서 PCH 생성 및 사용
# 1. PCH 생성
g++ -x c++-header -std=c++20 -O2 -o pch.h.gch pch.h
# 2. 소스 컴파일 시 PCH 사용
g++ -std=c++20 -O2 -include pch.h -c main.cpp -o main.o
CMake에서 PCH 자동화
# CMakeLists.txt - PCH 지원 (CMake 3.16+)
cmake_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
add_executable(app main.cpp foo.cpp bar.cpp)
target_precompile_headers(app PRIVATE
<iostream>
<string>
<vector>
<memory>
"config.h"
)
# 컴파일 옵션이 같은 다른 타깃은 app의 PCH를 재사용할 수 있다
add_executable(app_tests test_main.cpp)
target_precompile_headers(app_tests REUSE_FROM app)
target_precompile_headers를 쓰면 CMake가 PCH를 만들고 각 소스 앞에 강제로 포함시켜 주므로, 소스 파일에 #include "pch.h"를 직접 쓸 필요가 없습니다. REUSE_FROM은 PCH를 실제로 빌드하는 타깃을 가리켜야 하며, 두 타깃의 컴파일 옵션이 다르면 PCH를 재사용할 수 없습니다.
PCH는 만들 때의 컴파일 옵션과 쓸 때의 옵션이 호환되어야 합니다. 특히 PCH보다 먼저 정의된 매크로(-D 옵션 포함)는 PCH를 만들 때와 같아야 하고, 아키텍처·언어 표준 같은 코드 생성 옵션도 맞아야 합니다. 맞지 않을 때 GCC는 에러를 내지 않고 .gch를 조용히 무시한 채 원래 헤더를 파싱하므로, 빌드는 되는데 빨라지지 않는 형태로 나타납니다.
C++20 모듈
모듈 vs 헤더
| 항목 | #include 헤더 | C++20 모듈 |
|---|---|---|
| 파싱 횟수 | TU마다 반복 | 모듈 인터페이스를 한 번 컴파일해 BMI로 재사용 |
| 전이적 노출 | 포함된 모든 것이 노출 | export된 것만 |
| 매크로 | 포함한 쪽으로 새어 나감 | 모듈 밖으로 나가지 않음 |
| 빌드 순서 | 순서 무관 | 모듈 의존성 순서 필요 (빌드 시스템이 스캔) |
템플릿은 모듈에서도 사용하는 TU에서 인스턴스화될 수 있으므로, 모듈이 인스턴스화 비용까지 없애 주는 것은 아닙니다. 모듈이 확실히 줄여 주는 것은 같은 선언을 TU마다 다시 파싱하는 비용과, 매크로·내부 헤더가 새어 나가서 생기는 의존성입니다.
모듈 기본 예제
모듈 인터페이스 파일의 확장자는 컴파일러마다 관례가 달라서 MSVC는 .ixx, Clang은 .cppm을 주로 씁니다. CMake로 빌드하면 FILE_SET CXX_MODULES에 등록하는 것으로 구분하므로 어느 쪽이든 쓸 수 있습니다.
math.cppm (모듈 인터페이스):
// math.cppm - C++20 모듈
module;
#include <cmath> // 모듈 선언 전에만 전통적 #include
export module math;
export double sqrt_safe(double x) {
return x >= 0 ? std::sqrt(x) : 0.0;
}
export template<typename T>
T clamp(T v, T lo, T hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
main.cpp (모듈 사용):
import math; // #include 대신 import
#include <iostream>
int main() {
std::cout << sqrt_safe(2.0) << '\n';
std::cout << clamp(42, 0, 100) << '\n';
return 0;
}
CMake에서 모듈 빌드
# CMakeLists.txt - C++20 모듈 지원
cmake_minimum_required(VERSION 3.28)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 모듈 인터페이스는 FILE_SET CXX_MODULES 로 등록한다
add_library(math)
target_sources(math PUBLIC FILE_SET CXX_MODULES FILES math.cppm)
target_compile_features(math PUBLIC cxx_std_20)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE math) # CMake가 의존성을 스캔해 math BMI를 먼저 만든다
add_library(math MODULE ...)로 쓰는 예제를 가끔 보는데, CMake의 MODULE은 dlopen으로 로드하는 플러그인 라이브러리를 뜻하는 키워드라 C++20 모듈과는 관계가 없습니다. C++20 모듈은 위처럼 FILE_SET CXX_MODULES로 등록해야 CMake가 소스를 스캔해 빌드 순서를 잡습니다.
컴파일러·빌드 도구 지원 현황
CMake 3.28 문서 기준으로 이름 있는 모듈(named module)을 빌드할 수 있는 조합은 다음과 같습니다.
| 항목 | 최소 버전 |
|---|---|
| CMake | 3.28 (FILE_SET CXX_MODULES) |
| 생성기 | Ninja 1.11+, Visual Studio 2022 17.4+ (Makefile 생성기는 미지원) |
| 컴파일러 | MSVC 14.34+ (VS 2022 17.4), Clang 16+, GCC 14+ |
import std; | CMake 3.30+에서 실험적 지원, 표준 라이브러리 구현 쪽 지원도 필요 |
헤더 유닛(import <vector>;)은 CMake가 아직 지원하지 않으므로, 당분간은 표준 헤더는 #include(또는 PCH)로 두고 프로젝트 코드만 모듈로 옮기는 조합이 현실적입니다.
모듈 전환 전략
flowchart LR
A["기존 헤더"]
B{"의존성 적음?"}
C["모듈로 전환"]
D["PCH 우선 적용"]
E["점진적 확대"]
A --> B
B -->|Yes| C
B -->|No| D
C --> E
D --> E
모듈은 한 번에 전체를 바꾸기 어렵습니다. 모듈로 옮긴 코드가 아직 헤더인 코드를 쓰는 것은 쉽지만(global module fragment의 #include), 반대로 헤더 쪽 코드가 모듈을 쓰려면 그 헤더를 포함하는 모든 TU가 모듈을 지원하는 빌드 설정이어야 하기 때문입니다. 그래서 의존 그래프의 아래쪽(다른 것을 거의 include하지 않는 쪽)부터 올라가는 순서가 무난합니다.
- 새로 작성하는 코드부터 모듈로 작성합니다. 기존 코드에 영향이 없습니다.
- 의존성이 적은 유틸리티·공통 타입(문자열 유틸, 에러 타입 등)을 모듈로 옮깁니다. 여러 곳에서 쓰이므로 파싱 절감 효과가 먼저 나타납니다.
- 핵심 라이브러리를 모듈화합니다. 매크로에 의존하는 API가 있으면 이 단계에서 걸립니다. 모듈은 매크로를 export하지 못하므로 매크로는 별도 헤더로 남기거나
constexpr·인라인 함수로 바꿔야 합니다. - 레거시·서드파티 헤더는 모듈로 바꾸지 않고 PCH로 유지합니다.
전환 중에는 같은 선언이 헤더와 모듈 양쪽에서 들어오지 않도록 주의해야 합니다. 한 TU에서 헤더로 include한 클래스와 모듈에서 import한 같은 이름의 클래스가 만나면 재정의 에러나 ODR 문제가 생깁니다. 한 컴포넌트를 모듈로 옮겼으면 그 헤더는 삭제하거나, 내부에서 import만 하는 얇은 호환 헤더로 바꾸는 편이 안전합니다.
ccache로 재빌드 가속
ccache 동작 원리
flowchart TB
subgraph Input[입력]
S[소스 + 컴파일 옵션]
end
subgraph CCache[ccache]
H[해시 계산]
L{캐시\n존재?}
R[캐시에서 복원]
C[실제 컴파일]
S2[캐시 저장]
end
S --> H --> L
L -->|Hit| R
L -->|Miss| C --> S2
R --> O[오브젝트 출력]
C --> O
ccache는 소스(또는 전처리 결과)와 컴파일 옵션, 컴파일러 정보로 해시를 계산해 캐시를 조회합니다. 적중하면 컴파일을 건너뛰고 저장된 오브젝트 파일을 복원하며, 미스면 실제로 컴파일한 뒤 결과를 캐시에 저장합니다.
ccache 설치 및 설정
# Ubuntu/Debian
sudo apt install ccache
# macOS
brew install ccache
# PATH에 ccache 우선 배치
export PATH="/usr/lib/ccache:$PATH"
# 또는
export CC="ccache gcc"
export CXX="ccache g++"
CMake + ccache
# CMakeLists.txt
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
endif()
# 또는 빌드 시
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -B build
cmake --build build -j8
ccache 통계 확인
ccache -s
# 출력 예:
# cache hit (direct) 1234
# cache hit (preprocessed) 56
# cache miss 89
# cache hit rate 93.54 %
위 출력은 형식을 보여 주는 예시입니다. ccache 4.x는 출력 형식이 조금 다르지만 보는 값은 같습니다. 실제로 볼 값은 적중률이 빌드마다 어떻게 변하는지입니다. ccache -z로 통계를 0으로 만든 뒤 한 번 빌드하고 ccache -s를 보면, 그 빌드에서의 적중률만 확인할 수 있습니다.
distcc 분산 컴파일
distcc 개요
ccache가 “이미 한 일을 다시 하지 않기”라면, distcc는 “할 일을 여러 머신에 나누기”입니다. 클라이언트가 로컬에서 전처리를 끝낸 소스를 워커 머신으로 보내고, 워커가 컴파일한 오브젝트 파일을 돌려받습니다. 링크는 로컬에서 합니다.
flowchart TB
S[소스]
P["로컬 전처리"]
D[distcc]
W1["Worker 1"]
W2["Worker 2"]
W3["Worker 3"]
O["오브젝트 → 로컬 링크"]
S --> P
P --> D
D --> W1
D --> W2
D --> W3
W1 --> O
W2 --> O
W3 --> O
전처리가 로컬에서 일어나므로 워커에는 프로젝트 소스나 헤더가 필요 없고, 같은 버전의 컴파일러만 있으면 됩니다. 대신 전처리와 링크는 분산되지 않아서, 헤더가 무거워 전처리 비중이 큰 프로젝트나 링크가 오래 걸리는 프로젝트는 기대만큼 빨라지지 않습니다. 네트워크로 전처리된 소스(대개 원본보다 훨씬 큽니다)를 보내야 하므로 워커와의 네트워크도 빨라야 합니다.
distcc 설정
워커(서버) 쪽 설정입니다.
# distccd 실행 (기본 포트 3632), 허용할 네트워크를 반드시 제한
distccd --daemon --allow 192.168.1.0/24 --listen 0.0.0.0
distccd는 기본적으로 인증이 없고, 허용된 주소에서 오는 요청이면 컴파일러를 실행해 줍니다. 신뢰할 수 있는 사내 네트워크에서만 쓰고 --allow를 좁게 잡아야 하며, 인터넷에 노출하면 안 됩니다. 외부 네트워크를 거쳐야 한다면 SSH 모드(DISTCC_HOSTS="@host")를 씁니다.
클라이언트 쪽 설정입니다.
# 사용할 호스트 목록 (/N 은 그 호스트에 동시에 보낼 작업 수)
export DISTCC_HOSTS="localhost/4 192.168.1.10/8 192.168.1.11/8"
cmake -DCMAKE_C_COMPILER_LAUNCHER=distcc \
-DCMAKE_CXX_COMPILER_LAUNCHER=distcc \
-B build
# -j 는 로컬 코어 수가 아니라 전체 호스트의 작업 수 합에 맞춘다
cmake --build build -j20
-j를 로컬 코어 수로 두면 워커가 놀게 됩니다. 반대로 너무 크게 잡으면 로컬 머신이 전처리와 링크만으로 포화되므로, 호스트별 슬롯 합 정도에서 시작해 조정합니다.
ccache + distcc 조합
두 도구를 같이 쓰면 캐시에 있는 것은 건너뛰고, 캐시 미스만 워커로 보낼 수 있습니다. 이때는 ccache가 앞에 오고, ccache가 실제 컴파일이 필요할 때 distcc를 부르도록 CCACHE_PREFIX를 씁니다.
export CCACHE_PREFIX="distcc" # ccache 미스일 때 'distcc g++ ...'로 실행
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -B build
cmake --build build -j20
런처에 ccache distcc를 나란히 적는 방식보다 이쪽이 ccache 문서에서 권하는 방법입니다. 캐시 키 계산(전처리)은 ccache가 로컬에서 하고, 원격 컴파일만 distcc가 맡는 구조가 됩니다.
distcc는 설정이 단순한 대신 워커 관리가 수동입니다. 팀 규모가 크면 워커를 자동으로 찾고 스케줄링하는 icecream(icecc)이나, 원격 캐시를 제공하는 sccache, 원격 실행을 지원하는 Bazel 같은 도구를 검토할 만합니다.
Unity 빌드
Unity 빌드란?
여러 .cpp를 하나로 합쳐 컴파일 횟수를 줄입니다. 같은 헤더를 TU마다 다시 파싱하던 비용이 합친 묶음당 한 번으로 줄어서 전체 빌드가 빨라지는 경우가 많습니다. 대신 파일 하나만 고쳐도 묶음 전체를 다시 컴파일해야 해 증분 빌드는 느려질 수 있고, 익명 네임스페이스나 static 심볼 이름이 충돌하는 문제가 생길 수 있습니다.
수동 Unity 빌드
// unity.cpp - 여러 cpp를 한 번에 컴파일
#include "file1.cpp"
#include "file2.cpp"
#include "file3.cpp"
#include "file4.cpp"
g++ -O2 unity.cpp -o myapp
CMake Unity 빌드
# CMakeLists.txt
set(CMAKE_UNITY_BUILD ON)
add_executable(app main.cpp foo.cpp bar.cpp baz.cpp)
# → 내부적으로 하나의 unity_*.cpp로 합쳐서 컴파일
파일마다 따로 있던 static 변수·함수와 익명 네임스페이스 안의 이름이 한 TU로 합쳐지면서 충돌할 수 있고, 한 파일에서 #define한 매크로가 뒤에 붙은 파일에까지 영향을 줍니다. 해결 방법은 아래 빌드 에러 절의 Unity Build ODR 위반 항목에서 다룹니다. CMAKE_UNITY_BUILD_BATCH_SIZE(기본 8)로 한 묶음에 들어가는 파일 수를 조절할 수 있는데, 묶음이 클수록 클린 빌드는 빨라지고 증분 빌드는 느려지는 경향이 있습니다.
PCH, constexpr, ccache, 모듈 빌드 에러 해결
문제 1: PCH를 만들었는데 빌드가 빨라지지 않음
PCH를 만들 때와 쓸 때의 옵션이 호환되지 않으면 GCC는 .gch를 조용히 건너뛰고 pch.h를 처음부터 파싱합니다. 에러가 없으니 PCH가 쓰이지 않는다는 사실을 놓치기 쉽습니다. -Winvalid-pch를 켜면 왜 무시했는지 경고로 알려 줍니다. Clang은 이런 경우 “precompiled header was compiled with …” 같은 에러를 냅니다.
# ❌ PCH는 -std=c++17, TU는 -std=c++20 → GCC는 .gch를 무시
g++ -x c++-header -std=c++17 -o pch.h.gch pch.h
g++ -std=c++20 -Winvalid-pch -include pch.h -c main.cpp # 경고로 원인 확인
# ✅ 표준·매크로·코드 생성 옵션을 맞춘다
g++ -x c++-header -std=c++20 -O2 -DNDEBUG -o pch.h.gch pch.h
g++ -std=c++20 -O2 -DNDEBUG -include pch.h -c main.cpp
문제 2: PCH 헤더 앞에 다른 #include가 있음
소스에서 PCH 헤더를 직접 include하는 방식이라면 PCH가 맨 처음에 와야 합니다. GCC는 PCH 앞에 다른 코드가 있으면 PCH를 쓰지 않고, MSVC(/Yu)는 PCH include 줄 앞에 있는 코드를 아예 무시하므로 앞에 둔 include가 사라진 것처럼 동작합니다. CMake의 target_precompile_headers나 GCC의 -include를 쓰면 컴파일러가 PCH를 자동으로 맨 앞에 넣으므로 이 문제가 생기지 않습니다.
// ❌ 잘못된 예
#include "other.h" // PCH보다 먼저!
#include "pch.h"
// ✅ 올바른 예
#include "pch.h"
#include "other.h"
문제 3: constexpr 함수에서 “non-constant expression” 에러
상수 평가 중에 허용되지 않는 연산에 도달하면 에러가 납니다. 비-constexpr static 지역 변수는 C++20까지 constexpr 함수 안에 선언하는 것 자체가 금지였고, C++23부터는 선언은 허용되지만 상수 평가 중에 그 문장에 도달하면 여전히 에러입니다.
// ❌ 잘못된 예
constexpr int bad() {
static int x = 0; // static 금지
return x++;
}
// ❌ 잘못된 예: 상수 평가 중에 throw에 도달하면 에러
// (throw가 있어도 상수 평가에서 그 경로를 타지 않으면 괜찮다)
constexpr int bad2() {
throw std::runtime_error("error");
}
C++14부터는 지역 변수와 루프를 쓸 수 있으므로, 대부분의 계산은 이렇게 다시 쓸 수 있습니다.
// ✅ C++14: 루프, 변수 선언 가능
constexpr int good(int n) {
int result = 0;
for (int i = 0; i < n; ++i) {
result += i;
}
return result;
}
문제 4: ccache 적중이 안 됨
__TIME__이나 __DATE__를 쓰는 파일은 전처리 결과가 시각에 따라 달라지므로 캐시에 적중하지 않습니다(__DATE__는 날짜가 바뀔 때마다). __FILE__이나 디버그 정보에 들어가는 절대 경로도 빌드 디렉터리 위치가 다르면 미스의 원인이 됩니다.
// ❌ ccache 무효화
std::cout << "Built at " << __DATE__ << " " << __TIME__;
빌드 시각은 매번 생성하는 별도 파일 하나로 모아서, 그 파일만 다시 컴파일되게 합니다.
// ✅ 버전 정보를 별도 .cpp에서
// version.cpp - 빌드 스크립트가 생성
const char* build_time = "2026-02-28 12:00:00";
문제 5: 모듈 빌드 순서 오류
모듈 B가 모듈 A를 import하는데 A의 BMI가 아직 만들어지지 않았으면 “module not found” 류의 에러가 납니다.
# ✅ 의존성 순서 보장 (FILE_SET CXX_MODULES 로 등록해야 CMake가 스캔함)
add_library(mod_a)
target_sources(mod_a PUBLIC FILE_SET CXX_MODULES FILES a.mpp)
add_library(mod_b)
target_sources(mod_b PUBLIC FILE_SET CXX_MODULES FILES b.mpp)
target_link_libraries(mod_b PUBLIC mod_a) # mod_a의 BMI가 먼저 만들어짐
Makefile 생성기는 모듈 의존성 스캔을 지원하지 않으므로, 생성기가 Unix Makefiles이면 이 순서 문제가 해결되지 않습니다. -G Ninja로 바꿔야 합니다.
문제 6: Unity 빌드에서 이름 충돌
여러 .cpp를 하나로 합치면 파일마다 따로 있던 static 변수나 익명 네임스페이스 안의 이름이 한 TU 안에서 겹칩니다.
// foo.cpp
static int counter = 0;
// bar.cpp (Unity로 합쳐지면)
static int counter = 0; // ❌ 같은 TU 안의 재정의 → 컴파일 에러
이름이 겹치는 내부 심볼은 파일마다 고유한 이름으로 바꾸거나, 충돌하는 파일을 Unity 묶음에서 제외합니다(set_source_files_properties(bar.cpp PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON)). 익명 네임스페이스도 Unity 묶음 안에서는 하나의 TU로 합쳐지므로, static을 익명 네임스페이스로 바꾸는 것만으로는 같은 이름의 충돌이 해결되지 않는다는 점에 주의해야 합니다.
// ✅ foo.cpp
namespace {
int foo_counter = 0; // 파일별로 이름을 구분
}
문제 7: distcc “compiler not found” 또는 워커에서만 실패
워커에 같은 이름의 컴파일러가 없거나, 버전이 달라 로컬과 다른 결과·에러가 납니다. distcc는 클라이언트가 호출한 컴파일러 이름(g++, g++-13 등)을 워커에서 그대로 실행하기 때문입니다.
워커에 같은 툴체인을 설치하고, DISTCC_VERBOSE=1로 어느 호스트에서 무엇이 실행되는지 확인합니다.
# 클라이언트와 각 워커에서 같은 결과가 나와야 함
which g++ && g++ --version
# 상세 로그
DISTCC_VERBOSE=1 cmake --build build -j20 2>&1 | grep -i distcc | head
버전이 조금만 달라도 컴파일은 성공하는데 워커마다 생성 코드가 미묘하게 달라질 수 있습니다. 이런 버그는 재현이 어렵기 때문에, 워커는 같은 컨테이너 이미지나 같은 패키지 버전으로 고정해 두는 편이 안전합니다.
헤더 정리와 병렬·증분 빌드로 시간 줄이기
팁 1: 헤더 정리
// ❌ 불필요한 포함
#include <iostream> // cout만 쓰는데
#include <vector> // 사용 안 함
// ✅ 필요한 것만
#include <ostream> // std::ostream
#include "my_types.h"
헤더에서 포인터나 참조로만 쓰는 타입은 전방 선언(forward declaration)으로 충분합니다.
// ✅ 헤더에서
class Bar; // forward declaration
void use_bar(Bar& b); // Bar 정의 불필요
// .cpp에서
#include "bar.h"
void use_bar(Bar& b) { /* ... */ }
팁 2: 템플릿 분리
// ❌ 헤더에 모든 구현
template<typename T>
void process(T x) {
// 긴 구현...
}
// ✅ 선언만 헤더, 구현은 .tpp
// util.hpp
template<typename T> void process(T x);
// util.tpp
#include "util.hpp"
template<typename T>
void process(T x) { /* ... */ }
구현을 .tpp로 옮겨도 그 파일을 헤더에서 다시 include하면 파싱 비용은 그대로입니다. 효과를 보려면 쓰는 타입이 정해져 있을 때 한 .cpp에서만 명시적으로 인스턴스화하고, 다른 TU는 선언만 담은 util.hpp를 include하게 해야 합니다. 이 경우 인스턴스화하지 않은 타입으로 호출하면 링크 에러가 나므로, 쓰는 타입이 고정된 템플릿에만 맞는 방법입니다.
// util_instances.cpp
#include "util.tpp"
template void process<int>(int);
template void process<double>(double);
팁 3: 병렬 빌드
# make: -j N (N = 병렬 작업 수)
make -j$(nproc)
# CMake
cmake --build build -- -j8
팁 4: 증분 빌드와 캐시 적중 유지
빌드 디렉터리를 매번 지우지 않고 한 곳에 유지하면 빌드 시스템의 증분 빌드가 그대로 동작합니다. ccache를 여러 체크아웃이나 머신이 공유한다면 base_dir을 소스 루트(또는 그 상위)로 지정하세요. 그 아래의 절대 경로를 상대 경로로 바꿔 해시하므로, 체크아웃 위치가 달라도 캐시를 공유할 수 있습니다. hash_dir = false는 -g 빌드에서 작업 디렉터리를 해시에서 빼는 옵션이라 역할이 다릅니다.
팁 5: CI에서 ccache 활용
# GitHub Actions 예시
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache # ccache 4의 기본 위치(~/.cache/ccache)에 의존하지 않도록 고정
steps:
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.ccache
key: ccache-${{ runner.os }}-${{ github.run_id }}
restore-keys: ccache-${{ runner.os }}-
- run: |
ccache -z
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -B build
cmake --build build -j4
ccache -s # 이번 빌드의 적중률
GitHub Actions 캐시는 한 번 저장하면 같은 키로 덮어쓸 수 없으므로, 키에 run_id를 붙여 매번 새로 저장하고 restore-keys로 가장 최근 캐시를 복원하는 방식이 ccache와 잘 맞습니다.
종합 적용과 효과 측정
종합 적용 예제
지금까지의 기법을 한 프로젝트에 모으면 다음과 같습니다. 순서는 “도입 비용이 낮은 것부터”입니다. 병렬 빌드와 ccache는 코드 변경 없이 켤 수 있고, PCH는 헤더 목록만 정하면 되며, 모듈과 distcc는 가장 나중입니다.
my_project/
├── CMakeLists.txt
├── build.sh
└── src/
├── main.cpp
├── foo.cpp
└── bar.cpp
cmake_minimum_required(VERSION 3.20)
project(MyProject LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
# ccache: 설치되어 있을 때만 사용
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
endif()
add_executable(app src/main.cpp src/foo.cpp src/bar.cpp)
# PCH: 거의 바뀌지 않는 표준·서드파티 헤더만
option(USE_PCH "Use precompiled headers" ON)
if(USE_PCH)
target_precompile_headers(app PRIVATE <iostream> <string> <vector> <memory>)
endif()
#!/bin/bash
# build.sh — 병렬 수준은 CMakeLists.txt가 아니라 빌드 명령에서 정한다
set -e
export CCACHE_DIR="${CCACHE_DIR:-$HOME/.ccache}"
# 분산 빌드를 쓸 때만 (ccache 미스를 distcc로 넘김)
# export CCACHE_PREFIX=distcc DISTCC_HOSTS="localhost/4 192.168.1.10/8"
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
ccache -z >/dev/null 2>&1 || true # 이번 빌드의 적중률만 보기 위해 통계 초기화
cmake --build build --parallel "$(nproc)"
ccache -s 2>/dev/null || true
CMAKE_BUILD_PARALLEL_LEVEL은 cmake --build가 읽는 환경 변수라서 CMakeLists.txt 안에서 set()해도 효과가 없습니다. 병렬 수준은 위처럼 --parallel 옵션이나 환경 변수로 넘깁니다. USE_PCH를 옵션으로 둔 것은, PCH가 헤더 누락을 가리는 경우가 있어서입니다. PCH가 <string>을 미리 넣어 주면 어떤 .cpp가 <string>을 include하지 않았어도 빌드가 되다가, PCH를 끄거나 목록을 바꾸는 순간 깨집니다. CI 잡 하나는 -DUSE_PCH=OFF로 빌드해 두면 이런 숨은 의존성을 일찍 잡을 수 있습니다.
효과를 측정하는 방법
기법별 효과는 프로젝트의 헤더 구조, TU 수, 머신 사양에 따라 크게 달라서 다른 프로젝트의 수치를 그대로 기대하기 어렵습니다. 대신 같은 머신에서 다음 네 가지를 설정별로 재서 표로 남겨 두면 도입 여부를 판단할 근거가 됩니다.
| 측정 항목 | 방법 |
|---|---|
| 클린 빌드 | rm -rf build 후 구성·빌드 전체 시간 (ccache는 ccache -C로 비운 상태와 채운 상태 각각) |
| 증분 빌드 (말단 파일) | .cpp 하나만 수정한 뒤 빌드 |
| 증분 빌드 (공용 헤더) | 많이 include되는 헤더 하나를 수정한 뒤 빌드 |
| CI 빌드 | 캐시를 복원한 상태에서 잡 전체 시간 |
측정은 /usr/bin/time이나 CI 로그의 단계별 시간으로 충분하고, 같은 조건에서 두세 번 반복해 파일 시스템 캐시 영향을 줄이는 것이 좋습니다.
C++ 버전별 constexpr 제약
constexpr 제약 (C++11)
// ❌ C++11: 제약 많음
constexpr int func(int x) {
// 한 줄 return만 가능
return x * 2;
}
constexpr 확장 (C++14)
// ✅ C++14: 루프, 변수 선언 가능
constexpr int func(int x) {
int result = 0;
for (int i = 0; i < x; ++i) {
result += i;
}
return result;
}
constexpr 확장 (C++20)
// ✅ C++20: 상수 평가 안에서의 동적 할당, 가상 함수 호출 가능
constexpr int func() {
std::vector<int> vec = {1, 2, 3};
return vec[0] + vec[1] + vec[2];
}
static_assert(func() == 6);
C++20부터는 상수 평가 중에 new/delete를 쓸 수 있어서 std::vector와 std::string도 constexpr 함수 안에서 쓸 수 있습니다. 단, 상수 평가 안에서 할당한 메모리는 평가가 끝나기 전에 모두 해제되어야 하므로 constexpr std::vector<int> v = {...};처럼 결과 자체를 상수로 남길 수는 없습니다. 표준 라이브러리 구현도 이를 지원해야 해서, 비교적 최근 버전의 GCC·Clang·MSVC가 필요합니다.
런타임 계산과 컴파일 타임 계산 비교
런타임 vs 컴파일 타임
#include <iostream>
#include <chrono>
// 런타임 계산
int runtimeFib(int n) {
if (n <= 1) return n;
return runtimeFib(n - 1) + runtimeFib(n - 2);
}
// 컴파일 타임 계산 (반복문 버전: 상수 평가 단계 한도에 걸리지 않음)
constexpr int compileFib(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) { int t = a + b; a = b; b = t; }
return a;
}
int main() {
// 런타임 계산: 실행할 때마다 비용이 든다
auto start = std::chrono::steady_clock::now();
int r = runtimeFib(30);
auto end = std::chrono::steady_clock::now();
std::cout << r << " took "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " us\n";
// 컴파일 타임 계산: 결과가 바이너리에 상수로 들어가 실행 비용이 없다
constexpr int c = compileFib(30);
std::cout << c << "\n";
}
런타임 쪽 시간은 머신과 최적화 옵션에 따라 달라지므로 직접 재 보는 것이 정확합니다. -O2에서는 컴파일러가 runtimeFib(30)까지 미리 계산해 버릴 수도 있으니, 비교할 때는 인자를 입력으로 받거나 결과를 volatile에 저장해 최적화로 사라지지 않게 해야 합니다. 컴파일 타임 쪽에서 재귀 버전(compileFib(n-1) + compileFib(n-2))을 쓰면 호출 수가 지수적으로 늘어나 Clang의 상수 평가 단계 한도(-fconstexpr-steps)나 GCC의 -fconstexpr-ops-limit에 걸려 빌드가 실패할 수 있습니다. 컴파일 타임 계산도 결국 컴파일러가 실행하는 코드라서, 알고리즘이 비효율적이면 그 비용이 빌드 시간으로 옮겨 올 뿐입니다.