C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]

💡 초보자를 위한 한 줄: 항상 같은 상수·테이블이면 constexpr로 컴파일 타임에 박아 두며, 타입마다 분기가 필요하면 if constexpr. 빌드가 느리면 PCH·ccache·모듈부터 손대는 편이 ROI가 좋습니다. 15-2 캐시 친화 코드와 세트로 보면 좋습니다.

들어가며: 매번 같은 계산을 반복한다

룩업 테이블(미리 계산해 둔 값 배열—인덱스로 바로 결과를 찾을 때 사용)을 런타임(실행 중)에 초기화하고 있었습니다. 하지만 값은 항상 같았습니다.
constexpr과 if constexpr을 쓰면 “항상 같은 값”이나 “타입에 따라 다른 분기”를 컴파일 타임에 처리해서, 런타임 비용을 없애고 코드 경로를 단순하게 유지할 수 있습니다. 다만 컴파일 시간이 늘어나므로, 큰 메타프로그래밍은 모듈화해서 필요한 부분만 켜 두는 것이 실무에서 유리합니다.

헤더 하나 고쳐도 10분 걸리는 빌드

빌드가 10분 이상 걸려요

헤더 하나 수정하면 전체 리빌드가 돼서, 작은 수정에도 긴 대기 시간이 발생합니다. PCH·모듈·ccache로 해결할 수 있습니다.

템플릿 헤더가 너무 커서 컴파일이 느려요

<iostream>, <vector> 등이 거의 모든 TU에 포함되어 매번 파싱됩니다. PCH로 한 번만 컴파일해 재사용하면 됩니다.

CI에서 매번 클린 빌드

PR마다 15분 이상 빌드, 캐시 없음. ccache·병렬 빌드로 빌드 시간을 크게 줄일 수 있습니다.

switch에서 문자열 비교

런타임에 strcmp 반복 호출 대신, constexpr 해시로 컴파일 타임에 상수로 변환해 O(1) 비교가 가능합니다.

타입에 따라 다른 직렬화

POD 타입은 바이너리 복사, 복잡한 타입은 직렬화 함수 호출. if constexpr로 컴파일 타임에 분기하면 됩니다.

문제의 코드:

std::array<int, 256> lookupTable;
void initTable() {
    for (int i = 0; i < 256; ++i) {
        lookupTable[i] = i * i;  // 매번 계산
    }
}
int main() {
    initTable();  // 프로그램 시작마다 실행
    // ...
}

constexpr로 해결:

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)
}

주의사항: 컴파일 타임 계산 한도(constexpr 단계 제한)에 걸리면 빌드가 실패하므로, 거대한 메타프로그래밍은 단계적으로 나눕니다. 이 글을 읽으면:

  • constexpr로 컴파일 타임 계산을 할 수 있습니다.
  • if constexpr로 조건 분기를 최적화할 수 있습니다.
  • 템플릿 메타프로그래밍의 기초를 이해할 수 있습니다.
  • 빌드 시간이 어디서 새는지 측정하고, PCH·모듈·ccache·distcc·Unity 빌드로 단축할 수 있습니다.
  • 자주 발생하는 에러와 프로덕션 패턴을 알 수 있습니다.

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 클래스

기본 사용법

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)

컴파일 타임 분기

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이면 이 코드 무시됨
    }
}

재귀 종료

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;
    }
}

패턴 2: 컴파일 타임 배열 생성

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: 컴파일 타임 팩토리얼 테이블

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_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
# PCH 타겟
add_library(pch_header INTERFACE)
target_precompile_headers(pch_header INTERFACE
    <iostream>
    <string>
    <vector>
    <memory>
    "config.h"
)
add_executable(app main.cpp foo.cpp bar.cpp)
target_link_libraries(app PRIVATE pch_header)
target_precompile_headers(app REUSE_FROM pch_header)

주의: PCH 생성 시 -O2, -DDEBUG 등이 TU와 완전히 동일해야 합니다. PCH 헤더는 파일 최상단에 포함해야 합니다.


C++20 모듈

모듈 vs 헤더

항목#include 헤더C++20 모듈
파싱 횟수TU마다 반복모듈 인터페이스를 한 번 컴파일해 BMI로 재사용
전이적 노출포함된 모든 것이 노출export된 것만
매크로포함한 쪽으로 새어 나감모듈 밖으로 나가지 않음
빌드 순서순서 무관모듈 의존성 순서 필요 (빌드 시스템이 스캔)

템플릿은 모듈에서도 사용하는 TU에서 인스턴스화될 수 있으므로, 모듈이 인스턴스화 비용까지 없애 주는 것은 아닙니다. 모듈이 확실히 줄여 주는 것은 같은 선언을 TU마다 다시 파싱하는 비용과 매크로·내부 헤더가 새어 나가서 생기는 의존성입니다.

모듈 기본 예제

math.mpp (모듈 구현):

// math.mpp - 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.mpp)
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)을 빌드할 수 있는 조합은 다음과 같습니다.

항목최소 버전
CMake3.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하지 않는 쪽)부터 올라가는 순서가 무난합니다.

  1. 새로 작성하는 코드부터 모듈로 작성합니다. 기존 코드에 영향이 없습니다.
  2. 의존성이 적은 유틸리티·공통 타입(문자열 유틸, 에러 타입 등)을 모듈로 옮깁니다. 여러 곳에서 쓰이므로 파싱 절감 효과가 먼저 나타납니다.
  3. 핵심 라이브러리를 모듈화합니다. 매크로에 의존하는 API가 있으면 이 단계에서 걸립니다. 모듈은 매크로를 export하지 못하므로 매크로는 별도 헤더로 남기거나 constexpr·인라인 함수로 바꿔야 합니다.
  4. 레거시·서드파티 헤더는 모듈로 바꾸지 않고 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

소스+옵션 해시로 캐시 조회 → Hit이면 컴파일 생략, Miss면 컴파일 후 저장.

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 %

위 출력은 형식을 보여 주는 예시입니다. 실제로 볼 값은 hit rate가 빌드마다 어떻게 변하는지입니다. 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 “file not found” 또는 “invalid precompiled header”

원인: PCH 생성 옵션과 TU 컴파일 옵션이 다름.

# ❌ 잘못된 예: PCH는 -O0, TU는 -O2
g++ -x c++-header -O0 -o pch.h.gch pch.h
g++ -O2 -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 "other.h"   // PCH보다 먼저!
#include "pch.h"
// ✅ 올바른 예
#include "pch.h"
#include "other.h"

문제 3: constexpr 함수에서 “non-constant expression” 에러

원인: constexpr 함수 내에서 허용되지 않는 연산 사용.

// ❌ 잘못된 예
constexpr int bad() {
    static int x = 0;  // static 금지
    return x++;
}
// ❌ 잘못된 예: 상수 평가 중에 throw에 도달하면 에러
// (throw가 있어도 상수 평가에서 그 경로를 타지 않으면 괜찮다)
constexpr int bad2() {
    throw std::runtime_error("error");
}

해결:

// ✅ C++14: 루프, 변수 선언 가능
constexpr int good(int n) {
    int result = 0;
    for (int i = 0; i < n; ++i) {
        result += i;
    }
    return result;
}

문제 4: ccache Hit이 안 됨

원인: __FILE__, __DATE__, __TIME__ 등이 소스에 포함되어 매번 다른 입력으로 인식.

// ❌ 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가 아직 빌드되지 않음.

# ✅ 의존성 순서 보장 (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 Build에서 ODR 위반

원인: 여러 cpp를 합치면서 static 변수나 익명 네임스페이스가 중복.

// ❌ foo.cpp
static int counter = 0;
// ❌ bar.cpp (Unity로 합쳐지면)
static int counter = 0;  // ODR 위반!

해결: 이름이 겹치는 내부 심볼은 파일마다 고유한 이름으로 바꾸거나, 충돌하는 파일을 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) { /* ... */ }

필요한 타입만 .cpp에서 명시적 인스턴스화:

// 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: 증분 빌드 최대화

  • 빌드 디렉토리 고정: build/ 한 곳에 유지해 ccache Hit 극대화
  • 경로 정규화: ccache base_dir을 소스 루트로 지정하면 그 아래 절대 경로를 상대 경로로 바꿔 해시하므로, 빌드 디렉터리 위치가 다른 머신끼리도 캐시를 공유할 수 있습니다. (hash_dir = false는 -g 빌드에서 작업 디렉터리를 해시에서 빼는 옵션이라 역할이 다릅니다.)
  • 타임스탬프 분리: 소스는 Git, 빌드 아티팩트는 별도 저장

팁 5: CI에서 ccache 활용

# GitHub Actions 예시
- uses: actions/cache@v4
  with:
    path: ~/.ccache
    key: ccache-${{ runner.os }}-${{ hashFiles('**/CMakeLists.txt', '**/*.cpp') }}
    restore-keys: ccache-${{ runner.os }}-
- run: |
    ccache -s  # 캐시 통계
    cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -B build
    cmake --build build -j4

계층별 빌드 전략과 효과 측정

패턴 1: 계층별 빌드 전략

flowchart TB
    subgraph Layer1["1층: 안정 라이브러리"]
        L1[표준 라이브러리]
        L2[서드파티]
    end
    subgraph Layer2["2층: 프로젝트 코어"]
        M1[모듈/PCH]
    end
    subgraph Layer3["3층: 애플리케이션"]
        A1[앱 코드]
    end
    L1 --> L2 --> M1 --> A1
  • 1층: PCH 또는 모듈로 한 번만 빌드
  • 2층: 변경 적은 코어를 모듈화
  • 3층: 자주 바뀌는 앱 코드는 ccache·병렬에 의존

패턴 2: 빌드 프로파일

# CMakePresets.cmake 또는 프로파일
set(CMAKE_BUILD_TYPE "Release")
set(USE_PCH ON)
set(USE_CCACHE ON)
set(CMAKE_CXX_COMPILER_LAUNCHER "ccache")

패턴 3: 종합 적용 예제

지금까지의 기법을 한 프로젝트에 모으면 다음과 같습니다. 순서는 “도입 비용이 낮은 것부터”입니다. 병렬 빌드와 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 로그의 단계별 시간으로 충분하고, 같은 조건에서 두세 번 반복해 파일 시스템 캐시 영향을 줄이는 것이 좋습니다.

빌드 가속 적용 체크리스트

  • -j$(nproc) 또는 CMAKE_BUILD_PARALLEL_LEVEL 설정
  • ccache 설치 및 CMAKE_CXX_COMPILER_LAUNCHER=ccache 적용
  • PCH에 자주 쓰는 표준·프로젝트 헤더만 포함 (변경 잦은 헤더 제외)
  • PCH 생성 옵션과 TU 컴파일 옵션 일치 확인
  • CI에서 ccache 캐시 디렉토리 유지 (actions/cache 등)
  • distcc 사용 시 워커에 동일 컴파일러·버전 설치, --allow로 네트워크 제한
  • 모듈 도입 시 CMake 3.28+, Ninja 생성기, GCC 14+/Clang 16+/MSVC 17.4+ 확인
  • 빌드 시간 로깅으로 개선 효과 측정

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];
}

런타임 계산과 컴파일 타임 계산 비교

런타임 vs 컴파일 타임

// 런타임 계산
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에 걸려 빌드가 실패할 수 있습니다. 컴파일 타임 계산도 결국 컴파일러가 실행하는 코드라서, 알고리즘이 비효율적이면 그 비용이 빌드 시간으로 옮겨 올 뿐입니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. constexpr 함수인데 런타임에 계산되는 이유는 무엇인가요?

A. constexpr 함수는 컴파일 타임에 계산될 수 있다는 뜻일 뿐, 반드시 컴파일 타임에 실행된다는 보장은 아닙니다. 인자가 상수 표현식이 아니거나 결과를 일반 변수에 대입하면 평범한 런타임 함수처럼 실행됩니다. 컴파일 타임 계산을 강제하려면 결과를 constexpr 변수에 받거나, C++20의 consteval로 선언해 런타임 호출 자체를 컴파일 에러로 만듭니다.