C++ 기존 프로젝트를 Module로 전환 | 단계별 마이그레이션 [#24-2]
이 글의 핵심
C++ 기존 프로젝트를 Module로 전환에 대한 실전 가이드입니다. 단계별 마이그레이션 [#24-2] 등을 예제와 함께 설명합니다.
들어가며: “헤더 기반 프로젝트를 모듈로 전환하고 싶어요”
실제 겪는 문제 시나리오
“한 줄만 수정했는데 전체가 다시 컴파일돼요”
헤더 기반 프로젝트에서 utils.h 하나를 수정하면, 그 헤더를 include하는 모든 .cpp 파일이 다시 컴파일됩니다. 프로젝트가 커질수록 빌드 시간이 기하급수적으로 늘어나고, CI/CD 파이프라인에서도 병목이 됩니다.
“include 순서가 바뀌면 링크 에러가 나요”
헤더 의존성이 얽혀 있으면 #include 순서를 바꾸는 것만으로도 ODR 위반이나 재정의 에러가 발생합니다. 순환 include를 피하려면 전방 선언을 난무하게 되고, 코드가 복잡해집니다.
“템플릿 헤더가 너무 커요”
<vector>, <string> 같은 표준 라이브러리를 include하면 수만 줄이 매번 파싱됩니다. 같은 내용이 수십 개의 .cpp에서 반복되므로, 컴파일러 입장에서는 엄청난 낭비입니다.
마이그레이션(기존 코드를 새 방식으로 옮기는 작업)을 점진적으로 진행하면, 한꺼번에 바꿀 필요 없이 한 레이어씩 모듈로 전환할 수 있습니다. 이 글에서는 그 전략과 실전 절차를 단계별로 정리합니다.
이전 글: C++20 모듈 기초 (#24-1)에서 export module, import 문법과 모듈 단위를 다뤘습니다.
목표:
- 한 라이브러리/한 레이어부터 모듈화
- export 할 것과 내부용 구분
- include 와 import 공존 시 순서와 주의사항
- 빌드 시스템 변경 최소화
이 글을 읽으면:
- 기존 헤더를 모듈 인터페이스로 옮기는 절차를 알 수 있습니다.
- 하위 호환(헤더 남기기) 전략을 세울 수 있습니다.
- 실무에서 단계별로 적용할 수 있습니다.
요구 환경: C++20 지원 컴파일러(GCC 11+, Clang 13+, MSVC 2019 16.10+). CMake 3.28+에서 모듈 빌드 지원이 안정적. Linux/macOS/Windows 모두 가능.
목차
- 문제 시나리오: 헤더 기반 프로젝트의 한계
- 추가 문제 시나리오: 실무에서 겪는 구체적 상황
- 단계별 마이그레이션 가이드 (헤더 → 모듈)
- 완전한 C++20 모듈 마이그레이션 예제
- CMake 전체 설정 예제
- Before/After 비교: 빌드 시간
- 모듈 인터페이스 유닛 vs 구현 유닛
- CMake 모듈 설정
- 마이그레이션 시 자주 나는 오류
- 모듈 마이그레이션 베스트 프랙티스
- 프로덕션 패턴
- 성능 비교: 빌드 시간, 바이너리 크기
- 프로덕션 마이그레이션 전략
1. 문제 시나리오: 헤더 기반 프로젝트의 한계
”헤더 기반 프로젝트를 모듈로 전환하고 싶어요”
많은 C++ 프로젝트가 여전히 헤더 + 소스 구조로 되어 있습니다. 이 구조의 한계를 정리하면 아래와 같습니다.
flowchart TB
subgraph header["헤더 기반 빌드"]
H1[utils.h 수정] --> H2[include 1]
H1 --> H3[include 2]
H1 --> H4[include N]
H2 --> H5[전체 재파싱]
H3 --> H5
H4 --> H5
end
subgraph module["모듈 기반 빌드"]
M1[utils 모듈 수정] --> M2[BMI만 재생성]
M2 --> M3[import하는 쪽만 재컴파일]
end
헤더의 비용
| 항목 | 헤더 (#include) | 모듈 (import) |
|---|---|---|
| 파싱 | 매번 텍스트 복붙 후 파싱 | 한 번 파싱 후 BMI 재사용 |
| 의존성 | include 순서에 민감 | import 순서로 명확 |
| 재컴파일 | 헤더 수정 시 모든 include 쪽 재빌드 | 모듈 수정 시 import하는 쪽만 |
| 캡슐화 | private 구현이 새어 나갈 수 있음 | export만 노출 |
전환 전략 개요
- 바닥부터: 다른 코드에 의존하지 않는 유틸/기본 타입을 먼저 모듈로 만듦. 그 다음 그걸 쓰는 상위 레이어를 모듈로.
- 위부터: 새로 추가하는 코드만 모듈로 쓰고, 기존 라이브러리는 include 유지.
한 번에 하나의 “모듈 단위”
- 한 디렉터리/한 라이브러리를 하나의 모듈(또는 몇 개 파티션)로 묶기.
- 그 모듈을 쓰는 쪽은 import 로 전환.
2. 추가 문제 시나리오: 실무에서 겪는 구체적 상황
시나리오 A: “common.h 하나가 50개 파일을 잡아당겨요”
common.h (플랫폼 매크로, 기본 타입 정의)
├── config.h
├── types.h
└── platform.h
└── (50개 .cpp가 직접 또는 간접 include)
문제: common.h에 주석 한 줄만 추가해도 50개 TU가 전부 재컴파일됩니다. CI에서 10분 걸리던 빌드가 15분으로 늘어납니다.
모듈 전환 후: common 모듈로 옮기면 BMI만 재생성되고, import하는 50개 TU는 BMI를 참조만 하므로 파싱 비용이 크게 줄어듭니다.
시나리오 B: “순환 include로 전방 선언 지옥”
a.h ↔ b.h 순환 의존 시 전방 선언으로 풀어야 하는데, 헤더가 늘어날수록 관리가 어려워집니다. 모듈 전환 후: import a; import b;로 의존 방향이 명확해지고, 모듈 단위로 컴파일 순서가 정해져 순환 문제가 사라집니다.
시나리오 C: “외부 라이브러리 헤더가 너무 무거워요”
Boost.Asio, nlohmann/json 같은 헤더 전용 라이브러리는 include할 때마다 수만 줄을 파싱합니다. 모듈 전환 후: 해당 라이브러리가 모듈을 제공하면 import 한 번으로 BMI를 재사용합니다. (Boost, nlohmann 등은 아직 모듈 지원이 제한적이므로, 자체 라이브러리부터 모듈화하는 것이 현실적입니다.)
시나리오 D: “매크로 오염으로 예상치 못한 동작”
#include는 매크로를 그대로 가져와 전역 네임스페이스를 오염시킵니다. 모듈 전환 후: 모듈은 매크로를 export하지 않습니다. 모듈 경계에서 매크로가 차단되므로 import하는 쪽에는 매크로가 전파되지 않습니다.
시나리오 E: “ODR 위반이 릴리스 빌드에서만 터져요”
헤더의 인라인/템플릿 정의가 여러 TU에 복사되면서, 수정 시 ODR 위반이 발생할 수 있습니다. 모듈 전환 후: 모듈은 단일 정의를 가집니다. BMI에 한 번만 저장되고, import하는 쪽은 그 정의를 참조하므로 ODR 위반 가능성이 줄어듭니다.
3. 단계별 마이그레이션 가이드 (헤더 → 모듈)
Step 0: 환경 확인
# GCC 11+ 또는 Clang 13+
g++ --version
clang++ --version
# CMake 3.28+
cmake --version
Step 1: 의존성이 적은 유틸부터 모듈로 작성
Before (헤더):
// mylib/utils.h
#ifndef MYLIB_UTILS_H
#define MYLIB_UTILS_H
int add(int a, int b);
double clamp(double v, double lo, double hi);
#endif
// mylib/utils.cpp
#include "utils.h"
int add(int a, int b) {
return a + b;
}
double clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
After (모듈):
// mylib/utils.cppm (모듈 인터페이스)
export module mylib.utils;
export int add(int a, int b);
export double clamp(double v, double lo, double hi);
// mylib/utils_impl.cpp (구현 유닛)
module mylib.utils;
int add(int a, int b) {
return a + b;
}
double clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
Step 2: 사용하는 쪽을 import로 전환
Before:
// app/main.cpp
#include "mylib/utils.h"
int main() {
int x = add(3, 5);
return 0;
}
After:
// app/main.cpp
import mylib.utils;
int main() {
int x = add(3, 5);
return 0;
}
Step 3: 여러 헤더를 하나의 모듈로
a.h, b.h를 각각 별도 모듈로 두지 않고, mylib 하나로 묶을 때는 파티션을 씁니다.
// mylib.cppm
export module mylib;
export import mylib:internal_a;
export import mylib:internal_b;
// part_a.cppm
module mylib:internal_a;
export class PartA {};
// part_b.cppm
module mylib:internal_b;
export class PartB {};
기존에 #include "mylib/a.h", #include "mylib/b.h" 하던 것을 import mylib; 한 번으로 바꿀 수 있어, 의존 관계가 단순해집니다.
Step 4: export 범위 정하기
- 기존에 “public 헤더”로 제공하던 것만 export.
- 내부용 함수/타입은 export 하지 않음 (모듈 내부 및 구현 파일에서만 사용).
// mylib.cppm
export module mylib;
export int public_api();
// export 없음: 내부 전용
int internal_helper() {
return 42;
}
Step 5: include와 import 공존
- 같은 파일에서 import 를 먼저 쓰고, 그 다음 #include 하는 것이 권장됨.
- 모듈을 import한 뒤에는, 그 모듈이 제공하는 선언을 include 없이 쓸 수 있음.
// 예: 권장 순서
import mylib.utils;
#include <iostream>
#include "legacy_config.h"
int main() {
std::cout << add(1, 2) << "\n";
return 0;
}
4. 완전한 C++20 모듈 마이그레이션 예제
전체 프로젝트 구조 (헤더 → 모듈)
아래는 게임 엔진 유틸 라이브러리를 헤더에서 모듈로 완전 전환한 예제입니다.
Before (헤더 기반):
game_engine/
├── include/
│ ├── math/
│ │ ├── vector3.h
│ │ └── matrix4.h
│ ├── utils/
│ │ ├── logger.h
│ │ └── config.h
│ └── core/
│ └── types.h
├── src/
│ ├── math/
│ │ ├── vector3.cpp
│ │ └── matrix4.cpp
│ ├── utils/
│ │ ├── logger.cpp
│ │ └── config.cpp
│ └── main.cpp
└── CMakeLists.txt
After (모듈 기반):
game_engine/
├── modules/
│ ├── math/
│ │ ├── vector3.cppm # export module engine.math.vector3;
│ │ ├── vector3_impl.cpp
│ │ ├── matrix4.cppm
│ │ └── matrix4_impl.cpp
│ ├── utils/
│ │ ├── logger.cppm
│ │ ├── logger_impl.cpp
│ │ ├── config.cppm
│ │ └── config_impl.cpp
│ └── core/
│ └── types.cppm # 기본 타입만, 구현 유닛 없을 수 있음
├── src/
│ └── main.cpp
└── CMakeLists.txt
math 모듈 완전 예제
vector3.cppm (모듈 인터페이스):
// modules/math/vector3.cppm
export module engine.math.vector3;
export struct Vector3 {
double x, y, z;
Vector3(double x = 0, double y = 0, double z = 0);
Vector3 operator+(const Vector3& other) const;
double length() const;
};
vector3_impl.cpp (구현 유닛):
구현 유닛에서 std::sqrt를 쓰려면 #include <cmath>가 필요합니다. 구현 유닛에서는 #include를 module 선언 직후에 둡니다.
// modules/math/vector3_impl.cpp
module engine.math.vector3;
#include <cmath>
Vector3::Vector3(double x, double y, double z)
: x(x), y(y), z(z) {}
Vector3 Vector3::operator+(const Vector3& other) const {
return Vector3(x + other.x, y + other.y, z + other.z);
}
double Vector3::length() const {
return std::sqrt(x*x + y*y + z*z);
}
utils 모듈 예제
logger.cppm:
// modules/utils/logger.cppm
export module engine.utils.logger;
export void log_info(const char* msg);
export void log_error(const char* msg);
logger_impl.cpp:
// modules/utils/logger_impl.cpp
module engine.utils.logger;
#include <iostream>
void log_info(const char* msg) {
std::cout << "[INFO] " << msg << "\n";
}
void log_error(const char* msg) {
std::cerr << "[ERROR] " << msg << "\n";
}
main.cpp (모듈 사용)
// src/main.cpp
import engine.math.vector3;
import engine.utils.logger;
#include <iostream>
int main() {
Vector3 v(1, 2, 3);
Vector3 w = v + Vector3(0, 1, 0);
log_info("Vector length");
std::cout << "length = " << w.length() << "\n";
return 0;
}
5. CMake 전체 설정 예제
단일 모듈 + 앱 (최소 예제)
# CMakeLists.txt
cmake_minimum_required(VERSION 3.28)
project(GameEngine LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
# math 모듈
add_library(engine.math STATIC
modules/math/vector3.cppm
modules/math/vector3_impl.cpp
)
target_sources(engine.math PUBLIC
FILE_SET CXX_MODULES FILES modules/math/vector3.cppm
)
# utils 모듈
add_library(engine.utils STATIC
modules/utils/logger.cppm
modules/utils/logger_impl.cpp
)
target_sources(engine.utils PUBLIC
FILE_SET CXX_MODULES FILES modules/utils/logger.cppm
)
# 실행 파일
add_executable(game_engine src/main.cpp)
target_link_libraries(game_engine PRIVATE engine.math engine.utils)
engine.math,engine.utils가 먼저 빌드되어 BMI 생성game_engine는 math, utils를 import하므로 마지막에 빌드
멀티 플랫폼 (MSVC .ixx 확장자)
MSVC는 .ixx를 모듈 인터페이스 확장자로 사용합니다. CMake 3.28+는 .cppm을 MSVC에 전달해도 처리합니다 (MSVC 2022 17.4+). 크로스 플랫폼을 위해 조건부로 처리할 수도 있습니다.
빌드 및 실행
# Linux/macOS
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/game_engine
# Windows (Visual Studio)
cmake -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release
.\build\Release\game_engine.exe
6. Before/After 비교: 빌드 시간
테스트 프로젝트 구조
헤더 기반 (Before):
project/
├── include/
│ ├── utils.h
│ └── math.h
├── src/
│ ├── utils.cpp
│ ├── math.cpp
│ └── main.cpp
└── CMakeLists.txt
모듈 기반 (After):
project/
├── include/
│ ├── utils.cppm
│ └── math.cppm
├── src/
│ ├── utils_impl.cpp
│ ├── math_impl.cpp
│ └── main.cpp
└── CMakeLists.txt
빌드 시간 비교 (예시)
| 시나리오 | 헤더 기반 | 모듈 기반 | 개선 |
|---|---|---|---|
| 클린 빌드 (50 TU) | 45초 | 42초 | ~7% |
| 증분 빌드 (1개 파일 수정) | 12초 | 8초 | ~33% |
| 헤더 수정 후 | 35초 (재파싱) | 10초 (BMI만 재생성) | ~71% |
# 증분 빌드 시나리오 (utils.h 수정)
헤더: utils.h를 include하는 30개 .cpp 재컴파일 → 12초
모듈: utils 모듈 BMI 재생성 + import하는 30개 .cpp 재컴파일 → 8초
(BMI는 바이너리 형태라 파싱 비용이 훨씬 적음)
왜 증분 빌드에서 개선이 큰가?
- 헤더: 수정 시 include하는 모든 TU가 전체 헤더를 다시 파싱.
- 모듈: BMI(바이너리 모듈 인터페이스)만 재생성하고, import하는 쪽은 이미 파싱된 정보를 재사용.
7. 모듈 인터페이스 유닛 vs 구현 유닛
모듈 인터페이스 유닛 (Module Interface Unit)
- export module 선언이 있는 파일.
- 이 모듈을 import하는 쪽에 노출되는 선언을 정의.
- 확장자:
.cppm(Clang/GCC),.ixx(MSVC).
// math.cppm (인터페이스 유닛)
export module math;
export int add(int a, int b);
export double pi = 3.14159;
// export 없으면 이 모듈 내부에서만 사용
static int internal_helper() {
return 0;
}
모듈 구현 유닛 (Module Implementation Unit)
- module (export 없음) 선언만 있는 파일.
- 인터페이스에서 export한 선언의 구현을 담당.
- 인터페이스가 바뀌지 않으면, 구현만 수정해도 import하는 쪽은 재컴파일 불필요.
// math_impl.cpp (구현 유닛)
module math;
int add(int a, int b) {
return a + b;
}
인터페이스 vs 구현 분리
flowchart LR
subgraph interface["인터페이스 유닛"]
I1[math.cppm] --> I2[export 선언]
I2 --> I3[BMI 생성]
end
subgraph impl["구현 유닛"]
J1[math_impl.cpp] --> J2[module math;]
J2 --> J3[함수 정의]
end
I3 --> J1
파티션 vs 구현 유닛
| 구분 | 파티션 | 구현 유닛 |
|---|---|---|
| 선언 | module M:part; | module M; |
| 역할 | 모듈을 여러 파일로 나눔 | export 구현만 담당 |
| re-export | export import M:part; | 불가 |
8. CMake 모듈 설정
기본 설정 (CMake 3.28+)
cmake_minimum_required(VERSION 3.28)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
# 모듈 스캔 활성화 (C++20+ 사용 시)
# 모듈을 쓰지 않으면 비활성화: set(CMAKE_CXX_SCAN_FOR_MODULES 0)
모듈 제공 라이브러리
add_library(mylib STATIC
mylib/utils.cppm
mylib/utils_impl.cpp
)
target_sources(mylib PUBLIC
FILE_SET CXX_MODULES FILES mylib/utils.cppm
)
모듈을 사용하는 실행 파일
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)
Ninja / Makefile 생성기
CMake 3.28+에서 C++ 모듈을 지원하는 생성기:
- Ninja (ninja 1.11+)
- Ninja Multi-Config
- Visual Studio 17 2022 (MSVC 14.34+)
- Visual Studio 18 2026
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
컴파일러별 지원
| 컴파일러 | 모듈 스캔 | import std |
|---|---|---|
| GCC 14+ | 지원 | GCC 15+ |
| Clang 16+ | 지원 | Clang 18.1.2+ |
| MSVC 14.34+ | 지원 | MSVC 14.36+ |
모듈 스캔 비활성화
모듈을 아직 쓰지 않는 프로젝트에서 C++20을 쓸 때, 불필요한 스캔을 끄려면:
set(CMAKE_CXX_SCAN_FOR_MODULES 0)
9. 마이그레이션 시 자주 나는 오류
9.1 매크로 관련 문제
증상: 매크로가 모듈에서 안 보임.
원인: 모듈은 매크로를 export할 수 없음.
해결:
// ❌ 모듈에서 안 됨
export module config;
#define MAX_SIZE 1024
// ✅ 방법 1: constexpr로 대체
export module config;
export constexpr int MAX_SIZE = 1024;
// ✅ 방법 2: import 후 별도 include
import mylib;
#include "config_macros.h" // 매크로만
9.2 private 헤더 문제
증상: 내부 구현용 헤더를 모듈로 옮기려다 실패.
원인: private 헤더는 보통 매크로, include 순서, 의존성에 의존.
해결:
- private 헤더는 당분간 #include 유지.
- 공개 API만 모듈로 옮기고, 내부는 include.
// mylib.cppm
export module mylib;
#include "mylib/internal_detail.h" // 내부용, export 안 함
export int public_api();
9.3 import 후 선언을 찾을 수 없음
증상: import mylib; 후 add 등이 정의되지 않았다는 에러.
원인: 모듈이 먼저 빌드되지 않음. CMake에서 모듈 인터페이스 타겟 의존성 미지정.
해결:
# 모듈 B를 import하는 A는 B보다 나중에 빌드되도록
add_library(B STATIC b.cppm)
target_sources(B PUBLIC FILE_SET CXX_MODULES FILES b.cppm)
add_executable(A main.cpp)
target_link_libraries(A PRIVATE B)
9.4 include와 import 순서 에러
증상: 전처리기와 모듈 혼합 시 순서 문제.
해결: 같은 파일에서는 import를 먼저, 그 다음 #include.
// ✅ 올바른 순서
import mylib;
#include <vector>
#include "legacy.h"
9.5 BMI/ifc 파일 경로 문제 (MSVC)
증상: 모듈 인터페이스 산출물 경로 미지정.
해결: /module:export 등 컴파일러 옵션과 출력 디렉터리 설정 확인. CMake 3.28+에서 C++ 모듈 지원 정리됨.
9.6 PRIVATE 모듈을 PUBLIC 모듈에서 import
증상: CMake에서 “PRIVATE 모듈을 PUBLIC 모듈에서 import할 수 없음” 에러.
원인: CMake는 모듈 가시성을 강제. PUBLIC 모듈 인터페이스에서 PRIVATE 모듈을 import하면, 그 모듈을 사용하는 쪽도 transitively import해야 하는데, PRIVATE 모듈은 설치되지 않아서 설치된 모듈 사용이 깨짐.
해결: PRIVATE 모듈은 구현 유닛에서만 import.
// mylib.cppm (PUBLIC)
export module mylib;
export int foo(); // internal_helper는 import 안 함
// mylib_impl.cpp (구현 유닛)
module mylib;
import mylib.internal; // ✅ 구현 유닛에서만
int foo() { return internal_helper(); }
9.7 “redefinition of module” 에러
증상: 같은 모듈을 두 번 정의했다는 링크/컴파일 에러.
원인: export module foo;가 있는 파일이 여러 타겟에 포함되었거나, 같은 모듈 이름을 다른 .cppm에서 사용.
해결: 모듈당 인터페이스 유닛은 하나만. add_library에 .cppm 파일을 한 번만 넣고, FILE_SET CXX_MODULES에도 동일 파일만 지정.
9.8 구현 유닛에서 export 사용
증상: module math; 파일에서 export를 쓰면 에러.
원인: 구현 유닛은 export할 수 없음. export는 인터페이스 유닛 전용.
해결: export할 선언은 모두 .cppm(인터페이스)에 두고, .cpp(구현)에는 정의만.
9.9 표준 라이브러리 import (import std)
증상: import std; 또는 import std.compat; 사용 시 컴파일러가 지원하지 않음.
원인: import std는 C++23 표준이며, GCC 15+, Clang 18.1.2+, MSVC 14.36+에서만 지원.
해결: 구버전 컴파일러에서는 #include <vector> 등 기존 방식을 유지. 모듈 전환 시 표준 라이브러리는 include로 두고, 자체 라이브러리만 모듈화하는 것이 안전합니다.
요약 표
| 증상 | 원인 | 해결 |
|---|---|---|
| import 후 선언을 찾을 수 없음 | 모듈이 먼저 빌드되지 않음 | CMake에서 모듈 인터페이스 타겟 의존성 지정 |
| 매크로가 모듈에서 안 보임 | 모듈은 매크로를 export할 수 없음 | constexpr/인라인 함수로 대체 또는 #include로 매크로만 가져오기 |
| include와 import 순서 에러 | 전처리기와 모듈 혼합 시 순서 문제 | import를 먼저, 그 다음 #include |
| BMI/ifc 파일 경로 문제 (MSVC) | 모듈 인터페이스 산출물 경로 미지정 | /module:export 등 컴파일러 옵션 확인 |
| Private 헤더 의존성 | 내부 구현이 매크로/헤더에 의존 | 당분간 include 유지, 공개 API만 모듈화 |
| redefinition of module | 같은 모듈을 중복 정의 | 모듈당 인터페이스 유닛 하나만 |
| 구현 유닛에서 export | 구현 유닛은 export 불가 | export는 인터페이스 유닛에만 |
10. 모듈 마이그레이션 베스트 프랙티스
1. 모듈 이름은 네임스페이스처럼 계층적으로
// ✅ 좋은 예: 프로젝트.하위.구성요소
export module engine.math.vector3;
export module engine.utils.logger;
// ❌ 피할 것: 단순 이름 (충돌 가능)
export module vector3;
export module logger;
2. export는 최소한으로
- 공개 API만 export. 내부 헬퍼, 디버그용 함수는 export하지 않음.
export { ... }블록으로 한꺼번에 export할 수 있음.
// mylib.cppm
export module mylib;
export {
int public_api();
class PublicClass {};
}
// export 없음: 모듈 내부 전용
namespace detail {
void internal_helper() {}
}
3. 구현 유닛에서 표준 라이브러리 include
구현 유닛에서는 module 선언 직후에 #include를 둡니다.
// impl.cpp
module mylib;
#include <algorithm>
#include <string>
void mylib_function() {
std::string s = "hello";
std::sort(s.begin(), s.end());
}
4. 파티션으로 큰 모듈 분할
한 모듈이 너무 커지면 파티션으로 나눕니다. 파티션은 해당 모듈 내부에서만 import 가능하고, 외부에는 메인 모듈을 통해서만 노출됩니다.
// mylib.cppm
export module mylib;
export import mylib:part_a;
export import mylib:part_b;
// part_a.cppm
module mylib:part_a;
export class PartA {};
// part_b.cppm
module mylib:part_b;
export class PartB {};
5. 헤더와 모듈 공존 시 래퍼 헤더
기존 사용자가 #include "mylib/foo.h"를 쓰고 있다면, 래퍼 헤더로 모듈을 import한 뒤 재export할 수 있습니다. (MSVC 등에서 지원, GCC/Clang은 제한적)
6. 의존성 순서: 바닥부터
의존성 그래프에서 리프(다른 모듈에 의존하지 않는 모듈)부터 순서대로 전환합니다. core → math, utils → app 순서로 진행하면 빌드 순서가 자연스럽게 맞습니다.
7. CI에서 컴파일러 버전 고정
모듈 지원이 컴파일러마다 다르므로, CI에서는 GCC 14+, Clang 18+, MSVC 2022 17.4+ 이상을 명시적으로 지정하는 것이 안전합니다.
11. 프로덕션 패턴
패턴 1: 점진적 전환 (Strangler Fig)
기존 헤더를 한 번에 제거하지 않고, 새 코드만 모듈을 사용합니다. 기존 코드는 include 유지. 두 방식이 같은 라이브러리를 참조할 때는 헤더와 모듈 인터페이스를 동시에 유지하고, export하는 선언과 헤더의 선언이 동일해야 ODR이 유지됩니다.
// 새 코드
import engine.math.vector3;
import engine.utils.logger;
// 기존 코드 (당분간)
#include "engine/math/vector3.h"
#include "engine/utils/logger.h"
패턴 2: 모듈 + PCH 혼용
일부 컴파일러에서는 모듈과 PCH(Precompiled Header)를 함께 쓸 수 있습니다. 자주 쓰는 표준 라이브러리를 PCH로 두고, 자체 모듈은 import로 가져오는 방식입니다.
// pch.cpp
#include <vector>
#include <string>
#include <memory>
// main.cpp
#include "pch.h"
import mylib;
(실제 지원 여부는 컴파일러·버전에 따라 다르므로 문서 확인 필요)
패턴 2: 외부 라이브러리 래퍼 모듈
Boost, nlohmann/json 등 헤더 전용 라이브러리를 래퍼 모듈로 감싸서, 프로젝트 내에서는 import만 쓰게 할 수 있습니다. #include를 여러 TU에서 반복하지 않고, 래퍼 모듈 하나에서만 include합니다.
// wrappers/json.cppm
export module wrappers.json;
#include <nlohmann/json.hpp>
export namespace json = nlohmann;
이렇게 하면 #include <nlohmann/json.hpp>를 여러 TU에서 반복하지 않고, 래퍼 모듈 하나에서만 include합니다.
패턴 3: 테스트 전용 모듈
테스트에서만 필요한 테스트 픽스처 모듈을 두고, 프로덕션 빌드에는 포함하지 않습니다.
add_library(engine.test_helpers STATIC
test/helpers.cppm
)
target_sources(engine.test_helpers PUBLIC
FILE_SET CXX_MODULES FILES test/helpers.cppm
)
target_link_libraries(engine.test_helpers PUBLIC engine.core)
add_executable(engine_tests test/main.cpp)
target_link_libraries(engine_tests PRIVATE engine.math engine.test_helpers)
12. 성능 비교: 빌드 시간, 바이너리 크기
빌드 시간
증분 빌드 (Incremental Build):
- 모듈이 헤더 대비 30~50% 빌드 시간 단축 가능.
- 에러 발생 시 피드백: 모듈 0.85초 vs 헤더 3.11초 (예시).
클린 빌드 (Clean Build):
- 개선이 상대적으로 작음 (병렬화·의존성 체인에 따라 다름).
- BMI 생성 비용이 있어, 처음 빌드 시 오히려 느릴 수 있음.
템플릿이 많은 코드:
- 일부 벤치마크에서
import가#include보다 2~3배 느린 경우도 있음. - 컴파일러 구현·최적화에 따라 달라질 수 있음.
바이너리 크기
- 모듈 전환 시 바이너리 크기는 거의 동일.
- 인라인 함수·템플릿 인스턴스화는 동일하게 적용됨.
- 모듈의 이점은 컴파일 시간·의존성 정리에 있음.
- BMI 파일은 빌드 중간 산출물로, 최종 실행 파일에는 포함되지 않음.
벤치마크 결과 요약 (참고용)
| 항목 | 헤더 | 모듈 | 비고 |
|---|---|---|---|
| 증분 빌드 | 100% | 55~70% | 수정된 TU 수에 따라 다름 |
| 클린 빌드 | 100% | 90~100% | 프로젝트 구조에 따라 다름 |
| 바이너리 크기 | 100% | ~100% | 차이 거의 없음 |
13. 프로덕션 마이그레이션 전략
Phase 1: 준비 (1~2주)
- C++20 활성화 및 컴파일러·CMake 버전 확인
- 모듈 사용하지 않는 기존 코드는
CMAKE_CXX_SCAN_FOR_MODULES 0설정 검토 - 의존성 그래프 파악 (가장 아래 레이어부터 식별)
Phase 2: 파일럿 (2~4주)
- 의존성 없는 유틸 1~2개를 모듈로 전환
- CI/CD에서 빌드 시간·성공 여부 확인
- 팀 내 공유 및 피드백 수집
Phase 3: 점진적 확장 (1~3개월)
- 레이어별로 위에서 사용하는 모듈부터 순차 전환
- include와 import 공존 시 순서·매크로 처리 정책 수립
- 문서화: “이 모듈은 import, 이건 include” 가이드
Phase 4: 안정화
- 새 코드는 기본적으로 모듈 사용
- 레거시 헤더는 필요 시 점진적 제거 또는 래퍼 유지
의존성 순서와 BMI 캐시
- 모듈 A가 모듈 B를 import하면, B가 A보다 먼저 컴파일되어야 함.
- CMake 등에서는 모듈 인터페이스 타겟 간 의존성을 명시해 주면 됨.
- 모듈 컴파일 결과(BMI 등)를 캐시해 두면, 의존하는 소스만 재컴파일할 수 있음. 도구/컴파일러별 옵션 확인 필요.
실무 팁
“C++20 module migration”, “헤더를 모듈로 전환” 등으로 검색할 때 위 표를 먼저 참고하면 원인 좁히기에 도움이 됩니다. MSVC에서는 .ixx 확장자를, Clang/GCC에서는 .cppm 확장자를 사용하는 것이 관례입니다.
체크리스트
- [ ] 환경: C++20, CMake 3.28+, Ninja/VS 2022
- [ ] 모듈 스캔: 필요 시에만 활성화
- [ ] export 범위: 공개 API만 export
- [ ] include/import 순서: import 먼저
- [ ] 매크로: constexpr/인라인 함수로 대체 또는 include 유지
- [ ] CI/CD: 빌드 시간 모니터링
기존 사용자 유지
- 기존에는
#include "mylib/foo.h"로 쓰던 코드를 그대로 두려면:- foo.h를 “모듈을 import한 뒤 재export”하는 래퍼로 만들 수 있음 (도구/컴파일러 지원에 따라 다름).
- 또는 당분간 헤더와 모듈 인터페이스 둘 다 유지하고, 새 코드만 import 하도록 안내.
정리
| 항목 | 내용 |
|---|---|
| 전략 | 바닥부터 한 단위씩 모듈화 |
| 매핑 | 헤더 1개 → 모듈 1개 또는 여러 헤더 → 1 모듈+파티션 |
| export | 공개 API만 export |
| 공존 | import 먼저, include 나중; 기존 헤더 유지 가능 |
| 빌드 | 모듈 의존 순서와 BMI 캐시 고려 |
| CMake | 3.28+, FILE_SET CXX_MODULES, target_link_libraries |
| 성능 | 증분 빌드에서 30~50% 개선 가능 |
| 프로덕션 | 파일럿 → 점진적 확장 → 안정화 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 헤더 기반 C++ 프로젝트를 C++20 모듈로 옮기는 전략, export/import 매핑, 그리고 헤더와의 공존 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
Q. 모듈 전환 시 CMake는 어떻게 바꾸나요?
A. CMake 3.28 이상에서는 CXX_MODULE_STD 등을 설정하고, 모듈 인터페이스 소스(.cppm)를 target_sources(이름 FILE_SET ... TYPE CXX_MODULES)로 등록합니다. 기존 프로젝트는 CMake 공식 C++ 모듈 문서와 모듈 기초(#24-1)를 참고해 단계적으로 적용하면 됩니다.
Q. 헤더와 모듈을 같이 쓰는 코드에서 주의할 점은?
A. 같은 번역 단위에서는 import를 먼저 쓰고, 그 다음 #include를 두는 것이 안전합니다. 매크로나 조건부 컴파일에 의존하는 헤더는 당분간 include로 유지하고, 새로 작성하는 코드만 모듈을 사용하는 식으로 점진적으로 옮기면 됩니다.
Q. 모듈 전환 후 바이너리 크기는 어떻게 되나요?
A. 바이너리 크기는 헤더와 거의 동일합니다. 모듈의 이점은 컴파일 시간 단축과 의존성 정리에 있습니다.
참고 자료
- cppreference - Modules
- CMake C++ Modules 문서
- P1689R5: C++ Module Dependency Discovery — 빌드 시스템용 모듈 의존성 스캔 포맷
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++20 Modules | “#include 지옥” 탈출, import로 컴파일 속도 높이기
- C++ CMake 고급 | 멀티 타겟·외부 라이브러리 관리 (대규모 프로젝트 빌드)
- C++ 컴파일 과정 | “undefined reference” 에러가 나는 이유 (전처리·링킹 4단계)
이 글에서 다루는 키워드 (관련 검색어)
C++20 모듈 마이그레이션, 헤더를 모듈로 전환, export import, 모듈과 헤더 공존, CMake C++ 모듈, 모듈 빌드 오류, 점진적 모듈 전환, 빌드 시간 단축, 모듈 인터페이스 구현 유닛 등으로 검색하시면 이 글이 도움이 됩니다.
한 줄 요약: 한 레이어씩 모듈로 옮기고 include와 import를 함께 쓰면 점진적 전환이 가능합니다. 다음으로 Ranges 기초(#25-1)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #25-1] C++20 Ranges 기초: 범위와 반복자 개선
이전 글: [C++ 실전 가이드 #24-1] C++20 Modules 기초: “#include 지옥” 탈출
관련 글
- C++20 Modules |
- C++20 Coroutine | co_await·co_yield로
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기