Visual Studio C++ 빌드가 느릴 때: PCH·/MP 병렬 컴파일·증분 링크·Unity Build 설정
이 글의 핵심
빌드가 느려지는 주된 원인은 같은 헤더를 번역 단위마다 반복해서 파싱하는 데 있습니다. PCH에 어떤 헤더를 넣어야 효과가 나는지, Unity Build가 이름 충돌을 일으키는 경우, 증분 링크를 켰을 때 주의할 점을 짚어 프로젝트 규모에 맞게 기법을 조합하는 방법을 알려 줍니다.
들어가며
Visual Studio에서 C++ 프로젝트가 커지면 빌드 시간이 눈에 띄게 늘어납니다. 원인은 대부분 같은 헤더를 번역 단위(.cpp)마다 처음부터 다시 파싱하는 데 있습니다. 헤더 하나를 100개 파일이 include하면 그 헤더와 그 헤더가 끌어오는 모든 헤더를 100번 처리하므로, 전체 비용은 대략 “번역 단위 수 × 번역 단위당 include되는 코드 양”에 비례합니다. 템플릿이 많은 헤더(Boost, Qt, Eigen 등)는 이 곱의 뒤쪽 항을 크게 만듭니다.
이 글은 Visual Studio 2019/2022(Windows) 기준으로, 이 비용을 줄이는 방법을 효과가 큰 순서대로 설명합니다. 어느 기법이 얼마나 빨라지는지는 프로젝트 구조에 따라 크게 달라지므로, 마지막 절의 측정 방법으로 직접 확인하면서 하나씩 적용하는 것을 권장합니다.
프리컴파일 헤더(PCH) 사용
PCH란?
프리컴파일 헤더(Precompiled Header, PCH)는 자주 쓰이지만 거의 바뀌지 않는 헤더(<vector>, <string>, <boost/asio.hpp> 등)를 한 번 컴파일해 컴파일러 내부 상태를 .pch 파일로 저장해 두고, 각 .cpp가 그 상태를 불러와 이어서 컴파일하게 하는 기법입니다. 100개 파일이 같은 무거운 헤더를 include한다면, PCH 없이는 그 헤더를 100번 파싱하지만 PCH를 쓰면 한 번만 파싱합니다.
PCH에는 거의 변경되지 않는 헤더만 넣어야 합니다. 자주 수정하는 프로젝트 헤더를 넣으면, 그 헤더를 고칠 때마다 PCH를 다시 만들고 PCH를 쓰는 모든 파일이 다시 컴파일되어 오히려 증분 빌드가 느려집니다.
Visual Studio에서 설정
- pch.h 파일을 만듭니다.
// pch.h
#ifndef PCH_H
#define PCH_H
// 자주 쓰는 표준 라이브러리
#include <iostream>
#include <vector>
#include <string>
#include <memory>
#include <algorithm>
// 외부 라이브러리
#include <boost/asio.hpp>
#include <fmt/core.h>
#endif
- pch.cpp 파일을 만듭니다.
// pch.cpp
#include "pch.h"
- 프로젝트 속성을 설정합니다.
- pch.cpp: 속성 → C/C++ → 프리컴파일 헤더 → 프리컴파일 헤더 만들기(/Yc)
- 나머지 모든 .cpp: 속성 → C/C++ → 프리컴파일 헤더 → 프리컴파일 헤더 사용(/Yu), 프리컴파일 헤더 파일:
pch.h
- 각 .cpp 파일 맨 위에서 include합니다.
#include "pch.h" // 반드시 첫 줄
#include "myheader.h"
// ...
/Yu를 쓰면 컴파일러는 #include "pch.h" 줄 앞의 내용을 모두 무시합니다. 그 앞에 다른 include나 매크로 정의를 두면 조용히 사라지므로 반드시 첫 줄에 둡니다. 소스마다 include를 넣기 번거롭다면 C/C++ → 고급 → 강제 포함 파일(/FI)에 pch.h를 지정할 수도 있습니다.
CMake를 쓴다면 3.16부터 제공되는 target_precompile_headers가 위 설정을 대신해 줍니다.
target_precompile_headers(myapp PRIVATE
<vector>
<string>
<memory>
<boost/asio.hpp>
)
병렬 빌드 활성화 (/MP)
/MP 옵션
MSBuild는 기본으로 여러 프로젝트를 동시에 빌드하지만, 한 프로젝트 안의 .cpp 파일들은 /MP 없이는 하나씩 순서대로 컴파일합니다. /MP를 켜면 컴파일러가 여러 프로세스를 띄워 같은 프로젝트의 파일들을 병렬로 컴파일합니다. 프로젝트가 몇 개 안 되고 각 프로젝트에 파일이 많은 솔루션에서 효과가 큽니다.
코어 수만큼 선형으로 빨라지지는 않습니다. 링크 단계는 병렬화되지 않고, 유난히 오래 걸리는 파일 하나가 끝날 때까지 다음 단계로 넘어가지 못하며, 동시에 뜬 컴파일러 프로세스마다 메모리를 크게 쓰기 때문입니다. 템플릿이 무거운 코드에서 RAM이 부족해지면 디스크 스와핑으로 오히려 느려지므로, 그럴 때는 /MP4처럼 프로세스 수를 제한합니다.
설정 방법
프로젝트 속성에서 C/C++ → 일반 → 다중 프로세서 컴파일 → 예(/MP)로 바꿉니다. 솔루션 전체에 일괄 적용하려면 솔루션 폴더에 Directory.Build.props를 두는 방법이 편합니다.
<Project>
<ItemDefinitionGroup>
<ClCompile>
<MultiProcessorCompilation>true</MultiProcessorCompilation>
</ClCompile>
</ItemDefinitionGroup>
</Project>
명령줄에서 프로젝트 단위 병렬 빌드를 켜려면 msbuild MySolution.sln /m을 씁니다.
CMake:
if(MSVC)
add_compile_options(/MP)
endif()
CMake에서 Ninja 생성기를 쓰면 Ninja가 파일 단위로 병렬 실행하므로 /MP가 필요 없습니다. /MP는 Visual Studio 생성기를 쓸 때 의미가 있습니다.
증분 링크 활성화 (/INCREMENTAL)
증분 링크란?
증분 링크(Incremental Linking)는 실행 파일을 처음부터 다시 만드는 대신, 이전 결과물에 바뀐 함수만 덮어써 패치하는 방식입니다. 이를 위해 링커는 함수 사이에 여유 공간을 두고, 함수 호출을 점프 테이블(incremental linking thunk)을 거치게 만듭니다. 그래서 링크는 빨라지지만 실행 파일이 커지고 약간 느려지므로 디버그 빌드 전용입니다.
설정 방법
Visual Studio의 디버그 구성은 기본으로 증분 링크가 켜져 있습니다. 꺼져 있다면 링커 → 일반 → 증분 링크 사용 → 예(/INCREMENTAL)로 바꿉니다.
CMake:
if(MSVC)
set(CMAKE_EXE_LINKER_FLAGS_DEBUG "${CMAKE_EXE_LINKER_FLAGS_DEBUG} /INCREMENTAL")
endif()
주의사항
/OPT:REF,/OPT:ICF,/LTCG가 켜져 있으면 링커는 증분 링크를 끄고 LNK4075 경고만 남깁니다. “증분 링크를 켰는데 링크가 여전히 느리다”면 빌드 출력에서 이 경고부터 찾아봅니다. 디버그 구성에서는 링커 → 최적화의 참조를/OPT:NOREF, COMDAT 정리를/OPT:NOICF로 둡니다.- 릴리스 빌드에서는 크기와 성능을 위해 증분 링크를 끄고 전체 링크를 합니다.
- 드물게
.ilk파일이 꼬여 이해하기 어려운 링크 에러가 나기도 합니다. 이때는 Clean 후 다시 빌드합니다.
전방 선언으로 include 줄이기
문제 상황
// MyClass.h
#include "HeavyClass.h" // HeavyClass 정의 전체를 포함
class MyClass {
HeavyClass* member; // 포인터만 쓰는데 전체 정의가 필요 없음
};
HeavyClass.h가 무겁고 MyClass.h를 include하는 파일이 많으면, 그 모든 파일이 HeavyClass.h까지 파싱합니다. 더 큰 문제는 HeavyClass.h가 바뀔 때마다 이 파일들이 전부 다시 컴파일된다는 점입니다.
해결법: 전방 선언
// MyClass.h
class HeavyClass; // 전방 선언
class MyClass {
public:
void doSomething();
private:
HeavyClass* member; // 포인터·참조는 전방 선언만으로 충분
};
실제 정의는 구현 파일에서만 include합니다.
// MyClass.cpp
#include "MyClass.h"
#include "HeavyClass.h" // 구현부에서만 include
void MyClass::doSomething() {
member->someMethod(); // 여기서는 정의가 필요
}
포인터와 참조, 함수 선언의 매개변수·반환 타입은 전방 선언만으로 충분합니다. 반면 값 멤버, 상속, sizeof, 멤버 접근에는 완전한 정의가 필요합니다. std::unique_ptr<HeavyClass> 멤버는 전방 선언으로 쓸 수 있지만, 소멸자를 헤더에서 = default로 두면 그 자리에서 HeavyClass의 정의가 필요해 컴파일 에러가 나므로 소멸자를 .cpp에서 정의해야 합니다.
Unity Build (Jumbo Build)
Unity Build란?
여러 .cpp 파일을 하나의 .cpp에서 #include해 한 번에 컴파일하는 기법입니다. 같은 헤더를 묶음당 한 번만 파싱하므로 전체 빌드가 빨라집니다.
CMake에서 설정
set(CMAKE_UNITY_BUILD ON) # 이후 만드는 타깃에 적용
add_executable(myapp main.cpp utils.cpp math.cpp)
# 또는 타깃별: set_target_properties(myapp PROPERTIES UNITY_BUILD ON)
CMake 3.16부터 지원되며, 기본으로 8개 파일씩 묶습니다(UNITY_BUILD_BATCH_SIZE).
주의사항
- 이름 충돌: 여러 파일이 하나의 번역 단위가 되므로, 각 파일에서
static이나 익명 네임스페이스로 숨겨 둔 같은 이름의 함수·변수가 서로 충돌합니다. 파일 끝에서 정리하지 않은 매크로도 뒤 파일로 새어 나갑니다. - 숨은 의존성: 어떤 파일이 필요한 헤더를 include하지 않았는데, 같은 묶음의 앞 파일이 include해 준 덕분에 컴파일되는 경우가 생깁니다. 묶음 구성이 바뀌거나 Unity Build를 끄는 순간 깨지므로, CI에서 가끔 Unity Build 없이도 빌드해 보는 것이 좋습니다.
- 증분 빌드 효과 감소: 한 파일만 수정해도 그 파일이 속한 묶음 전체가 다시 컴파일됩니다.
그래서 Unity Build는 CI나 전체 빌드에서 쓰고, 파일 하나를 고치며 반복 빌드하는 개발 환경에서는 끄는 구성이 흔합니다. 충돌을 일으키는 파일은 SKIP_UNITY_BUILD_INCLUSION 소스 속성으로 묶음에서 뺄 수 있습니다.
불필요한 헤더 제거
#include <iostream>
#include <vector>
#include <map>
#include <algorithm>
#include <boost/asio.hpp>
// ... 실제로는 iostream만 씀
복사해 온 코드나 리팩터링 뒤에 남은 include는 아무 일도 하지 않으면서 파싱 비용만 늘립니다. 특히 헤더 파일에 남은 불필요한 include는 그 헤더를 쓰는 모든 파일로 비용이 퍼집니다.
정리하는 방법은 다음과 같습니다.
- Visual Studio 2022 17.8 이상에는 사용하지 않는 include를 흐리게 표시하고 제거를 제안하는 Include Cleanup 기능이 내장되어 있습니다(도구 → 옵션 → 텍스트 편집기 → C/C++ → 코드 정리).
- Include-what-you-use(IWYU)는 Clang 기반 도구로, 각 파일이 실제로 쓰는 심볼 기준으로 추가·삭제할 include를 알려 줍니다. MSVC 전용 코드에서는 정확도가 떨어질 수 있습니다.
# Ubuntu
sudo apt install iwyu
include-what-you-use -Xiwyu --no_fwd_decls main.cpp
- 도구가 없다면 include를 하나씩 주석 처리하며 컴파일되는지 확인합니다. 다만 다른 헤더를 통해 간접적으로 들어오는 덕분에 컴파일되는 경우도 있으므로, 실제로 쓰는 심볼의 헤더는 직접 include해 두는 것이 원칙입니다.
C++20 모듈 사용
C++20 모듈이란?
#include는 헤더 내용을 그 자리에 텍스트로 붙여 넣는 전처리기 지시문입니다. include guard는 같은 번역 단위 안에서의 중복 정의를 막을 뿐, 다른 번역 단위에서는 같은 헤더를 매번 처음부터 파싱합니다.
C++20 모듈은 모듈 인터페이스를 한 번 컴파일해 그 결과를 바이너리 인터페이스 파일(MSVC는 .ifc)로 저장하고, import하는 쪽은 소스 텍스트 대신 이 파일을 읽어 들입니다. 텍스트 파싱과 템플릿 정의 분석을 반복하지 않으므로 PCH와 비슷한 효과를 내면서, PCH와 달리 매크로가 새어 나가지 않고 import 순서에 영향을 받지 않습니다.
기존 방식(헤더):
// math.h
int add(int a, int b);
// main.cpp
#include "math.h" // 번역 단위마다 파싱
모듈 방식:
// math.ixx (모듈 인터페이스)
export module math;
export int add(int a, int b) {
return a + b;
}
// main.cpp
import math; // 컴파일된 모듈 인터페이스를 읽음
int main() {
int result = add(3, 5);
return result == 8 ? 0 : 1;
}
Visual Studio에서 설정
- C/C++ → 언어 → C++ 언어 표준 → ISO C++20 표준(/std:c++20)
- C/C++ → 일반 → 모듈 종속성에 대한 소스 검사(Scan Sources for Module Dependencies) → 예
CMake (3.28 이상, Ninja 1.11 이상 또는 Visual Studio 17.4 이상 생성기):
cmake_minimum_required(VERSION 3.28)
project(myapp CXX)
set(CMAKE_CXX_STANDARD 20)
add_executable(myapp main.cpp)
target_sources(myapp PRIVATE
FILE_SET CXX_MODULES FILES math.ixx
)
모듈 인터페이스 파일은 FILE_SET CXX_MODULES로만 등록하고 add_executable의 일반 소스 목록에 중복으로 넣지 않습니다.
모듈은 새로 작성하는 코드에서는 깔끔하지만, 외부 라이브러리 대부분이 아직 모듈을 제공하지 않고 IntelliSense와 다른 도구의 지원도 고르지 않습니다. 당장 빌드 시간을 줄이는 목적이라면 PCH가 더 현실적이고, 모듈은 프로젝트 내부 라이브러리부터 점진적으로 옮겨 보는 편이 안전합니다. 자세한 문법은 C++20 모듈 글을 참고하세요.
ccache로 컴파일 캐시
ccache란?
ccache는 컴파일러 앞에서 동작하는 캐시입니다. 컴파일 옵션, 컴파일러 버전, 전처리된 소스(또는 소스와 include된 파일들)의 해시를 키로 삼아, 같은 입력이 다시 들어오면 컴파일러를 실행하지 않고 저장해 둔 오브젝트 파일을 돌려줍니다.
브랜치를 오가며 빌드할 때, Clean 뒤에 다시 빌드할 때, 공유 캐시를 쓰는 CI에서 효과가 큽니다. 반대로 매번 내용이 바뀌는 파일에는 효과가 없습니다. 기본 캐시 크기는 5GB이고 ccache -M 10G로 바꿀 수 있습니다.
설치
# Linux
sudo apt install ccache
# macOS
brew install ccache
Windows는 ccache 릴리스 페이지에서 받습니다. ccache는 4.6 무렵부터 MSVC(cl.exe)를 지원합니다.
CMake에서 설정
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
message(STATUS "ccache enabled")
endif()
CMAKE_<LANG>_COMPILER_LAUNCHER는 Ninja와 Makefile 생성기에서 동작합니다. Visual Studio 솔루션(.sln)을 직접 쓰는 경우에는 이 방식이 적용되지 않으므로, Visual Studio에서 ccache를 쓰려면 CMake 프로젝트를 Ninja 생성기로 여는 구성(Visual Studio의 “폴더 열기”·CMake 프리셋 기본값)이 가장 간단합니다.
MSVC와 함께 쓸 때 놓치기 쉬운 조건이 있습니다. /Zi나 /ZI는 여러 오브젝트가 하나의 공유 PDB 파일에 디버그 정보를 쓰는 방식이라 ccache가 결과를 캐시할 수 없습니다. 디버그 정보 형식을 /Z7로 바꿔야 캐시가 적중합니다(CMake 3.25 이상에서는 CMAKE_MSVC_DEBUG_INFORMATION_FORMAT을 Embedded로 설정). 이 설정을 빠뜨리면 ccache를 붙였는데도 캐시 적중률이 0에 가깝게 나옵니다. ccache -s로 적중 통계를 확인합니다.
디버그 정보 형식 (/Zi vs /Z7)
/Zi(그리고 새 프로젝트 디버그 구성의 기본값인 /ZI, 편집하며 계속하기 지원)는 디버그 정보를 별도의 PDB 파일에 씁니다. 여러 컴파일러 프로세스가 같은 PDB에 쓰므로 mspdbsrv.exe를 통해 접근을 직렬화하고, 병렬 컴파일 시 이 부분에서 경합이 생길 수 있습니다.
/Z7은 디버그 정보를 각 오브젝트 파일(.obj) 안에 넣고, 링커가 최종 PDB를 만듭니다. 컴파일 단계의 PDB 경합이 없고 ccache·분산 빌드와 잘 맞지만, 오브젝트 파일이 커지고 링커가 타입 정보를 병합하는 비용이 늘어납니다. 편집하며 계속하기(Edit and Continue)는 쓸 수 없습니다.
if(MSVC)
# CMake 3.25+ (정책 CMP0141 NEW)
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$<CONFIG:Debug,RelWithDebInfo>:Embedded>")
endif()
프로젝트 속성에서는 C/C++ → 일반 → 디버그 정보 형식 → C7 호환(/Z7)입니다. 편집하며 계속하기를 자주 쓰는 개인 개발 환경이라면 /ZI를, 병렬·캐시·분산 빌드 비중이 크다면 /Z7을 고르는 식으로 정합니다.
저장 장치와 안티바이러스 설정
빌드는 작은 파일을 엄청나게 많이 읽고 씁니다. 소스와 빌드 출력이 HDD에 있다면 SSD로 옮기는 것만으로도 체감이 큽니다.
Windows Defender 같은 실시간 검사도 컴파일러가 만드는 수많은 파일을 하나하나 검사하면서 빌드를 느리게 합니다. 빌드 출력 폴더를 검사 예외에 추가하는 것이 일반적인데, 예외 폴더는 악성 코드 검사도 빠지므로 범위를 빌드 출력처럼 좁게 잡는 편이 안전합니다. Windows 11에서는 개발용으로 설계된 Dev Drive(ReFS 기반 볼륨)를 만들고 그 위에 소스와 빌드 폴더를 두면, Defender가 성능 모드(비동기 검사)로 동작해 예외를 따로 두지 않고도 검사 비용을 줄일 수 있습니다.
템플릿 인스턴스화 줄이기
같은 템플릿 특수화(예: HeavyClass<int>)를 여러 .cpp에서 쓰면, 각 번역 단위가 그 코드를 따로 인스턴스화하고 링커가 중복을 버립니다. extern template으로 “이 특수화는 다른 곳에서 인스턴스화된다”고 선언하면 각 파일의 인스턴스화 작업을 건너뜁니다.
// heavy_template.h
template <typename T>
class HeavyClass { /* ... */ };
extern template class HeavyClass<int>; // 다른 번역 단위에서 인스턴스화하지 않음
// heavy_template.cpp
#include "heavy_template.h"
template class HeavyClass<int>; // 명시적 인스턴스화는 여기 한 곳에서만
extern template 선언을 헤더에 두면 include하는 모든 파일에 자동으로 적용됩니다. 인라인 함수는 최적화를 위해 여전히 인스턴스화될 수 있으므로 효과는 클래스 구성에 따라 다릅니다.
컴파일 시간 측정
어느 기법이 효과가 있는지는 측정해 봐야 압니다. 먼저 Clean 후 전체 빌드 시간과, 자주 고치는 파일 하나를 수정한 뒤의 증분 빌드 시간을 기록해 두고, 기법을 하나씩 적용하며 비교합니다.
그림: 실측 결과(clang++ 21, 8코어 맥북, -O0, 한 번씩 잰 값) — Visual Studio/MSVC가 아니라 같은 원리를 clang으로 잰 것이다. 공통 STL 헤더를 쓰는 40개 .cpp는 순차 컴파일에 약 23초, PCH를 쓰면 PCH 생성 0.5초 + 약 10초로 절반 이하가 됐다. 8개 프로세스로 병렬 컴파일하면 약 3.43.8배 빨라졌고(코어 수 8배에는 못 미침), 8개씩 묶은 Unity 빌드는 순차에서도 약 3초로 78배 빨랐다. extern template은 같은 템플릿을 쓰는 30개 번역 단위에서 약 1517% 줄였으나, 측정마다 12초씩 편차가 있어 클래스 안·밖 정의의 차이는 이 측정만으로는 말할 수 없다. (환경: macOS 27.0.1 · clang++ 21 (MSVC 아님) · -O0 · 8코어 맥북)
- Visual Studio 2022 17.8 이상: 빌드 → Build Insights 실행으로 파일별 컴파일 시간, 가장 비싼 헤더, 템플릿 인스턴스화 비용을 볼 수 있습니다. 이전 버전은 별도 도구인
vcperf로 같은 정보를 수집합니다. - 프로젝트 단위 시간: 도구 → 옵션 → 프로젝트 및 솔루션 → VC++ 프로젝트 설정 → 빌드 타이밍을 켜면 빌드 출력에 단계별 시간이 찍힙니다.
- CMake + Ninja: 빌드 폴더의
.ninja_log에 명령별 시작·종료 시각이 기록됩니다. ninjatracing 같은 스크립트로 크롬 트레이스 형식으로 바꿔 볼 수 있습니다. - Clang(clang-cl 포함):
-ftime-trace로 파일별 JSON 트레이스를 만들고 ClangBuildAnalyzer로 집계합니다.
clang++ -ftime-trace -c main.cpp
ClangBuildAnalyzer --all . output.bin
ClangBuildAnalyzer --analyze output.bin
측정해 보면 대개 몇 개의 헤더가 전체 시간의 큰 부분을 차지합니다. 그 헤더를 PCH로 옮기거나, 헤더에서 빼고 전방 선언으로 바꾸는 것이 가장 효과가 큰 출발점입니다.