C++20 모듈을 사용자(라이브러리 유지보수자/프로젝트 모범 사례 관리자) 관점에서 활용하기 위한 실전 팁과, 기존 헤더 기반 프로젝트에 모듈 인터페이스를 제공하는 방법 및 모듈 네이티브 코드 구성 원칙을 정리한다.
URL: https://chuanqixu9.github.io/c++/2025/12/30/C++20-Modules-Best-Practices.en.html
이 글은 중국어로 작성되었고 LLM에 의해 번역되었습니다. 표현이 부자연스럽다고 느껴지면 언제든 연락 주세요.
2025년 현재 C++20 모듈에 대한 글, 발표, 토론은 드물지 않습니다. 하지만 대부분은 툴체인 논의나 불평에 집중되어 있고, 사용자 관점에서 이 주제를 다루는 글은 상대적으로 적습니다. 한편으로 툴체인은 C++20 모듈의 보급에 분명 중요하고 근본적입니다. 다른 한편으로 언어 기능의 관점에서 보면, 코루틴, 콘셉트, 리플렉션, 컨트랙트 같은 기능들과 비교했을 때 C++20 모듈은 꽤 단순하다고 말해도 과장이 아닙니다. 그렇다 하더라도, C++20 모듈이 언어 차원에서 단순하다고 해서 실전 경험을 공유하는 일이 가치가 없지는 않습니다.
제목의 “사용자 관점”이란 어떤 컴파일러나 빌드 시스템을 선택할지, 컴파일러가 모듈을 어떻게 구현하는지, 빌드 시스템과 어떻게 상호작용하는지, 컴파일러별 동작 차이 같은 것에 대해 걱정하지 않는다는 뜻입니다. 대신 C++ 라이브러리 유지보수자나 C++ 프로젝트의 모범 사례를 관리하는 사람의 관점에서, 언어 수준에서 C++20 모듈을 어떻게 사용할지를 다룹니다. “사용자 관점”을 강조하는 이유는, 툴체인이 모두 준비되었다고 주장하려는 게 아니라(물론 저는 Linux에서 Clang으로 C++20 모듈이 사용 가능하다고 여전히 믿습니다), 제가 가치 있다고 느끼지만 널리 논의되지는 않은 몇 가지를 정리하고 싶기 때문입니다.
이 논의는 두 부분으로 나눌 수 있습니다. 첫째는 기존의 헤더 기반 프로젝트에 대해(여전히 헤더로 개발하면서) C++20 모듈 래퍼를 어떻게 제공할지, 둘째는 코드를 “모듈 네이티브(Modules Native)” 방식으로 어떻게 구성할지입니다. “모듈 네이티브”란 마치 C++에 처음부터 모듈이 존재했던 것처럼 코드를 작성하는 것을 의미합니다.
이 블로그 글의 각 섹션은 가능한 한 자체 완결적으로 작성하려 했기 때문에, 독자는 관심 없는 내용을 건너뛰어도 됩니다. 예를 들어 C++20 모듈로 새 프로젝트를 시작하고 싶다면 실제로 가장 단순한 “모듈 네이티브” 섹션만 읽어도 됩니다. 또는 여러분과 사용자 모두 ABI에 관심이 없다면 ABI 관련 섹션은 건너뛰어도 됩니다.
실천 사항을 소개하기 전에, 뒤에서 다룰 다양한 접근법을 이해하기 위한 맥락으로 C++20 모듈의 장점을 먼저 정리해 보겠습니다. C++20 모듈의 주요 설계 목표는 다음과 같습니다.
첫 두 목표(더 빠른 컴파일과 ODR 위반 방지)는, C++20 모듈이 각 선언에 대해 단일 “소유(owning) 번역 단위(Translation Unit, TU)”를 제공할 수 있다는 점으로 달성됩니다.
일부는 C++20 모듈이 표준화된 PCH(Precompiled Headers) 또는 표준화된 Clang 헤더 모듈(Clang Header Modules)에 불과하다고 주장하기도 합니다. 이는 틀렸습니다. PCH나 Clang 헤더 모듈은 서로 다른 TU들 사이에서 반복되는 전처리와 파싱을 피함으로써 컴파일 시간을 줄입니다.
C++20 모듈은 한 단계 더 나아가, 컴파일러 백엔드에서 동일한 선언을 반복적으로 최적화하고 컴파일하는 일을 방지합니다. 많은 프로젝트에서 백엔드 최적화와 코드 생성이 긴 컴파일 시간의 주된 원인입니다.
예를 들어:
cpp// a.h inline void func_a() { ... }
이 방식은 a.h를 포함하고 func_a()를 참조하는 모든 TU가 func_a()에 대해 최적화와 코드 생성을 수행하게 만듭니다.
모듈을 사용하면:
cppexport module a; export int func_a() { ... }
몇 개의 TU가 func_a()를 import해서 사용하든, 각 TU는 자신의 컴파일 과정에서 func_a()를 반복 최적화/코드 생성하지 않습니다. 이것이 C++20 모듈이 PCH나 Clang 헤더 모듈보다 컴파일 속도를 더 크게 향상시킬 수 있는 핵심 이유입니다.
전역 함수보다 더 흔한 경우는 클래스 내부(in-class) inline 함수입니다:
cppclass A { public: void a() { ... } };
C++20 표준은 이름 있는 모듈(named module) 안의 클래스 내부에 정의된 멤버 함수가 더 이상 암시적으로 inline이 아니라고 규정합니다. 즉, A::a()가 이름 있는 모듈 안에 있을 때 그 정의는 해당 이름 있는 모듈에 대응하는 오브젝트 파일에만 놓여야 하며, 여러 소비자(consumer)에 의해 반복적으로 최적화/컴파일되지 않습니다.
명시적인 함수 정의뿐 아니라 vtable, 디버그 정보 같은 다른 정보도 같은 원칙을 따라야 합니다. 즉, 관련 정의가 있는 이름 있는 모듈에서만 생성되어야 하며, 모든 소비자에서 중복 생성되어 시간과 공간을 낭비하지 않아야 합니다. 실제로 C++20 모듈을 사용하면 컴파일 시간이 줄어들 뿐만 아니라, 빌드 산출물 크기를 줄이는 데에도 큰 도움이 된다는 것을 확인했습니다.
ODR(One Definition Rule, 단일 정의 규칙)은 프로그램의 모든 엔티티가 정확히 하나의 정의를 가져야 한다고 말합니다. 어떤 엔티티에 서로 다른 여러 정의가 존재하면 그 프로그램은 ODR을 위반하며 ill-formed입니다.
현실에서는, 어떤 엔티티가 강한 심볼(strong symbol)을 가진 여러 정의를 가지면 링커가 multiple definition 에러를 보고합니다. 모든 정의가 약한 심볼(weak symbol)이라면 링커는 임의로 하나(대개 처음 만난 것)를 선택합니다. (강한 심볼 하나와 약한 심볼 여러 개가 섞인 경우는 보통 의도된 것이므로 여기서는 무시합니다.) 링커 에러가 나는 전자의 경우가 런타임 미정의 동작보다 훨씬 안전하고 견고합니다.
헤더 파일 메커니즘은 많은 TU가 공유하지만 헤더 자체는 TU가 아니라는 특성 때문에, 헤더 안의 대부분 심볼이 약한 심볼이 되도록 설계되는 경향이 있고, 이는 ODR 위반 위험을 크게 만듭니다. 큰 프로젝트에서 여러 이유로 동일한 서드파티 라이브러리의 서로 다른 버전을 함께 포함하게 되면, ODR 위반의 잠재적 함정에 빠질 수 있습니다.
C++20 모듈은 “모든 엔티티는 유일한 소유 TU를 가진다”는 원칙에 기반하기 때문에, 모든 엔티티에 대해 강한 심볼을 제공하여 이런 유형의 ODR 위반을 자연스럽게 방지합니다.
게다가 C++20 모듈은 이름 있는 모듈 안의 각 엔티티에 모듈 이름과 관련된 접미사를 추가하는 고유한 네임 맹글링 메커니즘을 도입합니다. 이는 서로 다른 라이브러리 개발자 간의 우발적 이름 충돌을 방지하는 데 도움이 됩니다. 예를 들어:
cppexport module M; namespace NS { export int foo(); }
디맹글링하면 NS::foo()의 링키지 이름은 NS::foo@M()처럼 보이며, 다른 모듈의 foo()와 충돌할 확률을 더 낮춥니다.
모듈 이름 충돌에 대해서도, C++20 모듈은 각 모듈 유닛이 내부 상태를 설정하기 위한 강한 심볼의 모듈 초기화자(module initializer)를(초기화할 것이 없더라도) 생성하도록 요구합니다. 이는 프로그램이 같은 이름의 모듈 유닛을 갖는 상황을 방지하는 데 도움이 됩니다.
Clang은 모듈 인터페이스 파일에 .cppm 접미사를 권장합니다. MSVC는 .ixx를 권장합니다. GCC는 특별한 선호가 없습니다.
대부분의 사용자는 빌드 시스템을 통해 컴파일러를 사용하며, 빌드 시스템은 보통 어떤 파일이 모듈 인터페이스인지 아닌지 알고 있습니다. 그래서 많은 사용자는 이 문제가 중요하지 않고 어떤 확장자든 상관없다고 느낍니다.
그럼에도 저는 모듈 인터페이스 파일에 .cppm 또는 .ccm 같은 접미사를 사용할 것을 권합니다. 이유는 하나는 도구 친화성이 높고, 다른 하나는 가독성을 높이기 때문입니다.
LOC(라인 수) 카운터나 통계 도구는 확장자 기준으로 그룹핑할 수 있으면 더 직관적인 통계를 제공할 수 있습니다. clangd처럼 더 복잡한 도구도 모든 모듈 인터페이스가 .cppm으로 끝난다고 가정할 수 있다면 처리 속도가 빨라질 수 있습니다. 예를 들어 CLion은 .cppm으로 끝나는 파일만 모듈 인터페이스 파일이라고 가정합니다. 또, Are We Modules Yet의 유지보수자로서, 모든 모듈 인터페이스 파일이 .cppm을 사용한다고 가정할 수 있다면 훨씬 단순해집니다.
코드 가독성 측면에서도 .cppm 같은 특수 확장자는 도움이 됩니다. 예를 들어:
text. ├── network.cpp └── network.cppm
이 구조를 보면 network.cppm이 인터페이스를 선언하고 network.cpp가 구현을 제공한다는 것을 쉽게 이해할 수 있습니다. 또한 clangd 같은 도구는 “Switch Between Source/Header”(아마 “Impl/Interface”로 이름을 바꿔야 할 듯합니다) 같은 기능을 제공하여 network.cppm과 network.cpp 사이를 빠르게 오갈 수 있습니다.
.ixx에 대해서는, 전처리된 파일처럼 읽히고 .cppm만큼 보기 좋지 않다고 생각합니다.
따라서 모듈 인터페이스 파일 접미사로 .cppm 또는 .ccm을 권장합니다.
(더 간결한 버전은 https://clang.llvm.org/docs/StandardCPlusPlusModules.html#transitioning-to-modules 에서 볼 수 있습니다)
간단한 프로젝트를 예로 들어봅시다:
cpp// header.h #pragma once #include <cstdint> namespace example { class C { public: std::size_t inline_get() { return 42; } std::size_t get(); }; }
cpp// src.cpp #include "header.h" std::size_t example::C::get() { return 43 + inline_get(); }
ABI 호환성을 위해, 이 프로젝트가 다음과 같은 익스포트 심볼을 갖는 libexample.so를 배포한다고 가정해봅시다:
text$nm -ACD libexample.so libexample.so: w __cxa_finalize libexample.so: w __gmon_start__ libexample.so: w _ITM_deregisterTMCloneTable libexample.so: w _ITM_registerTMCloneTable libexample.so:0000000000001130 W example::C::inline_get() libexample.so:0000000000001110 T example::C::get()
(W는 example::C::inline_get()이 약한 심볼임을 뜻하고, T는 example::C::get()이 강한 심볼임을 뜻합니다.)
헤더 전용(header-only) 라이브러리 작성자나 바이너리 없이 소스 코드만 배포하는 사람에게는 이 사례조차 다소 복잡해 보일 수 있습니다. 하지만 이 단순한 사례를 이해하면, 더 단순한 시나리오에 대해 모듈 인터페이스를 래핑하는 것은 어렵지 않습니다.
export using 스타일export using 스타일은 헤더 파일에 대해 C++20 모듈 인터페이스를 제공하는 가장 단순한 방법입니다. libc++, libstdc++, MSVC STL이 사용하는 방식이며, 현재 C++20 모듈을 지원하는 대부분의 라이브러리도 이 접근을 사용합니다.
방법은 다음과 같습니다:
cpp// example.cppm module; #include "header.h" export module example; namespace example { export using example::C; }
프로젝트의 모든 헤더를 전역 모듈 프래그먼트(global module fragment)에 #include하고, 모듈 영역(module purview)에서 export using 구문으로 공개할 선언을 내보냅니다.
이 접근의 가장 큰 장점은 단순함과 비침투성(non-intrusive)입니다.
비침투적이기 때문에, 모듈을 지원하지 않는 서드파티 라이브러리를 import해서 사용하고 싶다면 우리 프로젝트에서 그 라이브러리를 위한 래퍼를 만들어 줄 수도 있습니다.
주요 단점은 모듈 래퍼와 원래 헤더의 구현이 서로 다른 파일에 있다는 점입니다. 유지보수자가 헤더에서 익스포트되는 인터페이스를 추가/삭제/수정했는데 example.cppm을 업데이트하는 것을 잊으면 깨질 수 있습니다. 이 문제는 스크립트나 테스트로 완화하거나 해결할 수 있습니다. 예: https://github.com/llvm/llvm-project/blob/main/libcxx/utils/generate_libcxx_cppm_in.py
import 사용하기프로젝트에서 사용하는 서드파티 라이브러리가 C++20 모듈을 지원한다면, 모듈 인터페이스에서는 #include 대신 import를 사용해야 합니다. 이는 사용자 컴파일 속도를 개선하는 데 도움이 됩니다. Clang 컴파일러의 현재 구현 제약 때문에, import가 존재할 때 #include를 피하면 더 큰 컴파일 속도 향상을 얻을 수 있습니다.
표준 라이브러리를 가장 흔한 서드파티 라이브러리로 볼 수 있습니다. 위 예제의 경우 header.h를 다음처럼 바꿀 수 있습니다:
cpp// header.h #pragma once #ifdef USE_STD_MODULE_IN_HEADER import std; #else #include <cstdint> #endif namespace example { class C { public: std::size_t inline_get() { return 42; } std::size_t get(); }; }
그리고 example.cppm은 다음처럼 바꿉니다:
cpp// example.cppm module; #define USE_STD_MODULE_IN_HEADER #include "header.h" export module example; namespace example { export using example::C; }
이렇게 하면 사용자가 더 큰 컴파일 성능 향상을 누릴 수 있습니다.
export using 스타일의 ABI프로젝트가 바이너리를 배포한다면 example.cppm도 그 바이너리로 컴파일해야 합니다. 그러면 libexample.so의 익스포트 심볼은 다음과 같아집니다:
text$llvm-nm -ACD libexample.so libexample.so: w _ITM_deregisterTMCloneTable libexample.so: w _ITM_registerTMCloneTable libexample.so: 0000000000001050 T initializer for module example libexample.so: 0000000000001140 W example::C::inline_get() libexample.so: 0000000000001120 T example::C::get() libexample.so: w __cxa_finalize@GLIBC_2.2.5 libexample.so: w __gmon_start__
(C++20 모듈 관련 심볼을 디맹글링하지 못하는 오래된 nm이 있기 때문에 nm 대신 llvm-nm을 사용합니다.)
이전 버전과 비교하면 initializer for module example 심볼이 추가되었습니다.
마찬가지로, 프로젝트가 바이너리를 배포하지 않고 소스 파일만 포함하더라도, 빌드 스크립트는 example.cppm 같은 모듈 인터페이스를 소스들과 같은 라이브러리 파일에 컴파일해 넣어야 합니다. 논리적으로 이런 파일들은 모두 프로젝트 인터페이스의 일부입니다.
헤더 전용 라이브러리의 경우, 사용자를 위한 가장 편한 옵션은 example.cppm 같은 모듈 인터페이스를 라이브러리에(바이너리를 실제로 배포하지 않더라도) 추가해 주는 것입니다.
하지만 예전처럼 example.cppm 소스만 배포하면, 사용자가 해당 오브젝트 파일을 직접 처리해야 합니다. 사용자가 최종 실행 파일만 만드는 엔드 유저라면 문제는 비교적 단순합니다(그냥 example.cppm을 오브젝트로 컴파일해 링크하면 됩니다). 하지만 사용자가 또 다른 다운스트림 사용자를 가진 라이브러리 작성자라면, 최선의 접근은 example.cppm을 오브젝트로 컴파일하지 않고 이 작업을 최종 바이너리 사용자에게 미루는 것일 수 있습니다. 핵심은 example.cppm이 처음부터 어떤 라이브러리에 속하지 않으면 진정한 바이너리 수준의 소유자가 없게 되고, 최종 실행 파일 사용자가 처리해 주기를 기대할 수밖에 없다는 점입니다.
export extern "C++" 스타일다음처럼 매크로로 헤더 파일 내부의 모든 #include를 제어할 수 있습니다:
cpp// header.h #pragma once #ifndef IN_MODULE_WRAPPER #include <cstdint> #endif #ifdef IN_MODULE_WRAPPER #define EXPORT export #else #define EXPORT #endif namespace example { EXPORT class C { public: std::size_t inline_get() { return 42; } std::size_t get(); }; }
그다음 example.cppm에서 다음처럼 모듈 인터페이스로 감쌉니다:
cpp// example.cppm module; #include <cstdint> // 일반적으로 전역 모듈 프래그먼트에 모든 서드파티 헤더를 include 한다. export module example; #define IN_MODULE_WRAPPER extern "C++" { #include "header.h" }
모든 서드파티 라이브러리가 모듈을 제공한다면, 더 나아가 이렇게 할 수도 있습니다:
cpp// example.cppm export module example; import std; // 그리고 다른 서드파티 모듈이 있다면 import #define IN_MODULE_WRAPPER extern "C++" { #include "header.h" }
이 접근은 초기 설정 이후에는 example.cppm에서 향후 유지보수는 파일 수준에서만 필요합니다. 각 선언의 가시성은 선언 위치에서 EXPORT 매크로로 제어하므로 export using 스타일에 비해 유지보수성이 크게 향상됩니다.
example.cppm의 extern "C++"는 매우 중요합니다. 이는 라이브러리의 ABI를 보존합니다. extern "C++"를 제거하면 더 공격적인 ABI-파괴 스타일이 됩니다. 따라서 export extern "C++" 스타일은 이후 더 급진적인 변경을 위한 준비 단계로 볼 수 있습니다.
또한 export using 스타일과 비교해, export extern "C++" 스타일은 헤더에 inline 전역 함수나 inline 전역 변수(특히 동적 초기화를 포함하는 것)가 있을 때, inline을 선택적으로 적용함으로써 더 나은 컴파일 성능을 얻을 수 있습니다.
예를 들어 헤더가 다음과 같다고 합시다:
cpp// header.h #pragma once #include <cstdint> namespace example { inline int func() { return 43; } inline int init() { return 43; } inline int var = init(); }
이를 다음처럼 리팩터링할 수 있습니다:
cpp// header.h #pragma once #ifndef IN_MODULE_WRAPPER #include <cstdint> #endif #ifdef IN_MODULE_WRAPPER #define EXPORT export #else #define EXPORT #endif #ifndef IN_MODULE_WRAPPER #define INLINE inline #else #define INLINE #endif namespace example { INLINE int func() { return 43; } INLINE int init() { return 43; } INLINE int var = init(); }
(example.cppm 구현은 변경되지 않습니다. 이것은 export extern "C++" 스타일의 유지보수성의 또 다른 예입니다.)
이 경우 example.cppm의 소비자는 func를 재컴파일하지 않습니다.
주의할 점: 이 리팩터링은 ABI를 변경하여 약한 심볼을 강한 심볼로 바꿉니다. ODR 위반이 없는 잘 정의된 프로젝트에서는 괜찮습니다. 하지만 해당 심볼과 관련된 기존 ODR 위반이 있었다면, 이 변경이 기존 동작을 바꿀 수 있습니다. 이를 걱정한다면 header.h를 이렇게 바꿀 수 있습니다:
cpp// header.h #pragma once #ifndef IN_MODULE_WRAPPER #include <cstdint> #endif #ifdef IN_MODULE_WRAPPER #define EXPORT export #else #define EXPORT #endif #ifndef IN_MODULE_WRAPPER #define INLINE inline #else #define INLINE __attribute__((weak)) #endif namespace example { INLINE int func() { return 43; } INLINE int init() { return 43; } INLINE int var = init(); }
이제 모듈 내부에서도 example::func, example::init, example::var는 약한 심볼로 남습니다. 이는 기존 ODR 위반을 촉발할 가능성을 줄이지만, 프로젝트에 이미 이 심볼들에 대한 ODR 위반이 있다면, 이 변경은 촉발 확률만 낮출 뿐 여전히 프로그램 동작을 바꿀 수 있다는 점을 강조해야 합니다.
inline 링키지를 무시하는 컴파일러들이 주제와 관련하여 컴파일러 처리에 대해 이야기하고 싶습니다. 구현 작업 중 몇몇 사람은 컴파일러가 모듈 유닛에서 inline 키워드를 무시하고, 현재의 inline 링키지 대신 강한 심볼 또는 대응하는 약한 심볼을 직접 생성해야 한다고 제안했습니다. 그들은 이것이 모듈에서 C++ 초기 설계 문제(제 이해로는 많은 ODR 이슈가 헤더 파일의 원래 설계에서 기인함)를 피하는 데 도움이 될 것이라고 주장했습니다. 하지만 저는 호환성이 매우 중요하다고 생각하며, 가능한 한 선택권은 사용자에게 맡겨져야 한다고 봅니다.
export extern "C++" 스타일의 ABIABI 관점에서, 앞서 설명한 inline 수정이 없다면 export extern "C++" 스타일의 ABI는 export using 스타일과 완전히 동일해야 합니다.
export extern "C++" 스타일 예제에서 example.cppm의 extern "C++"를 제거하면 ABI-파괴 스타일의 모듈 인터페이스가 됩니다:
cpp// example.cppm export module example; import std; // 그리고 다른 서드파티 모듈이 있다면 import #define IN_MODULE_WRAPPER #include "header.h"
extern "C++"를 제거하면 header.h의 선언들이 이제 example 모듈에 속하게 되며, 이는 이전의 래퍼 접근과 근본적으로 다릅니다. example 모듈 내의 header.h 선언은 더 이상 src.cpp의 정의를 재사용할 수 없고, 새 정의를 제공해야 합니다.
cpp// src.cpp #ifndef IN_MODULE_IMPL #include "header.h" #endif std::size_t example::C::get() { return 43 + inline_get(); }
cpp// src.module.cpp module example; #define IN_MODULE_IMPL #include "src.cpp"
이제 src.cpp, src.module.cpp, example.cppm의 오브젝트 파일을 libexample.so로 링크하면, 익스포트 심볼은 다음과 같습니다:
text$llvm-nm -ACD libexample.so libexample.so: w _ITM_deregisterTMCloneTable libexample.so: w _ITM_registerTMCloneTable libexample.so: 0000000000001060 T initializer for module example libexample.so: 0000000000001150 W example::C::inline_get() libexample.so: 0000000000001130 T example::C::get() libexample.so: 0000000000001180 T example::C@example::inline_get() libexample.so: 0000000000001160 T example::C@example::get() libexample.so: w __cxa_finalize@GLIBC_2.2.5 libexample.so: w __gmon_start__
libexample.so에 example::C::inline_get()과 example::C@example::inline_get(), 그리고 example::C::get()과 example::C@example::get()처럼 두 세트의 ABI가 들어 있음을 볼 수 있습니다. 이는 “듀얼 ABI 모드(Dual ABI Mode)”라고 부를 수 있으며, 유명한 GCC 5의 libstdc++ C++11 듀얼 ABI와 유사합니다.
라이브러리는 두 ABI 모두와 호환되지만, 사용자 관점에서 모듈 기반 ABI를 선택하면 그들의 ABI도 깨집니다. 예를 들어 사용자의 코드가 다음을 포함한다면:
cpp#include "header.h" namespace user { void user_def(example::C& c) { } }
사용자의 libuser.so의 익스포트 심볼은 다음처럼 보일 수 있습니다:
text$llvm-nm -ACD libuser.so libuser.so: w _ITM_deregisterTMCloneTable libuser.so: w _ITM_registerTMCloneTable libuser.so: 0000000000001100 T user::user_def(example::C&) libuser.so: w __cxa_finalize@GLIBC_2.2.5 libuser.so: w __gmon_start__
하지만 사용자가 ABI-파괴 스타일 모듈로 전환하면:
cppimport example; namespace user { void user_def(example::C& c) { } }
ABI는 다음처럼 바뀝니다:
text$llvm-nm -ACD libuser.so libuser.so: w _ITM_deregisterTMCloneTable libuser.so: w _ITM_registerTMCloneTable libuser.so: 0000000000001100 T user::user_def(example::C@example&) libuser.so: w __cxa_finalize@GLIBC_2.2.5 libuser.so: w __gmon_start__
보듯이 user_def에 대해 생성되는 심볼(디맹글링 후)이 user::user_def(example::C&)에서 user::user_def(example::C@example&)로 바뀝니다. 이 현상 또한 GCC 5의 libstdc++ C++11 ABI 브레이크와 동일합니다. 하지만 이 ABI 브레이크는 사용자에 의해 제어되므로, 여러분이 죄책감을 가질 필요는 없습니다.
export extern "C++" 스타일과 비교하면 ABI-파괴 스타일은 ABI 변경 외에도 컴파일러에게 더 효율적인 코드를 생성하게 합니다. 예를 들어 앞서 언급했듯이, 이름 있는 모듈에서는 클래스 내부 inline 함수가 더 이상 암시적으로 inline이 아닙니다. 예제에서:
cpp// header.h #pragma once #include <cstdint> namespace example { class C { public: std::size_t inline_get() { return 42; } std::size_t get(); }; }
듀얼 ABI 모드에서 생성되는 심볼은:
text$llvm-nm -ACD libexample.so ... libexample.so: 0000000000001150 W example::C::inline_get() ... libexample.so: 0000000000001180 T example::C@example::inline_get() ...
여기서 전통 ABI에서는 C::inline_get이 약한 심볼이지만, 모듈 ABI에서는 강한 심볼 T example::C@example::inline_get()이 됩니다. 헤더의 inline 엔티티를 매크로로 제어하는 기법은 여기에서도 유용합니다.
ABI-파괴 스타일의 또 다른 장점은 사용자가 무심코 #include와 import를 섞어 쓰기 더 어려워진다는 점입니다. 예를 들어 사용자가 실수로 다음처럼 쓰면:
cpp#include "header.h" import example; namespace user { void user_def(example::C& c) { } }
컴파일러가 자동으로 에러를 냅니다:
text$clang++ -std=c++23 -fPIC user.cpp -c -o user.o -fprebuilt-module-path=. In file included from user.cpp:1: ./header.h:16:17: error: 'example::C' has different definitions in different modules; first difference is defined here found method 'inline_get' with body 16 | std::size_t inline_get() { return 42; } | ~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~ ./header.h:16:17: note: but in 'example' found method 'inline_get' with different body 16 | std::size_t inline_get() { return 42; } | ~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~ 1 error generated.
이는 사용자에게 더 나은 관행을 안내하는 데도 도움이 됩니다.
먼저, 라이브러리에 향후 근본적인 ABI-파괴 변경을 예상하는지(특히 모듈 관련 ABI 변경을 도입할 계획인지)에 달려 있습니다. 그렇고, 여러분이 모든 구현 파일에 대해 모듈 관련 지원을 제공할 의사가 있다면, ABI-파괴 스타일이 최선의 선택이라고 봅니다. 물론 ABI에 전혀 신경 쓰지 않는다면, 모든 구현 파일에 대해 모듈 버전을 지원할 수 있다는 전제하에 ABI-파괴 스타일이 역시 최선입니다.
둘째, ABI를 신경 쓰거나 라이브러리의 모든 구현 파일에 대해 모듈 버전을 제공하고 싶지 않다면, 심볼 가시성을 어떻게 제어하고 싶은지에 따라 export using 스타일과 export extern "C++" 스타일 중에서 선택할 수 있습니다.
마지막으로, 어떤 방법을 선택하든, 모든 헤더에서(표준 라이브러리를 포함한) 서드파티 라이브러리의 #include를 #ifdef 매크로로 제어하는 것이 바람직합니다. 이는 많은 시간을 절약할 수 있습니다.
앞서 언급했듯이, 모든 모듈 유닛은 오브젝트 파일에 최소 하나의 초기화자(initializer)를 생성합니다. 따라서 이 오브젝트 파일을 어디에 둘지 결정해야 합니다. 이미 바이너리를 배포하는 라이브러리/프로젝트라면 가장 단순한 해결책은 모듈 유닛의 오브젝트 파일을 바이너리에 직접 포함하는 것입니다. 바이너리를 배포하지 않는다면, 빌드 스크립트가 이 오브젝트 파일을 어떻게 빌드하고 어떤 라이브러리에 넣을지 지정해야 합니다. 사용자는 그 빌드 스크립트를 따라 빌드/링크할 수 있습니다.
헤더 전용 프로젝트가 C++20 모듈로 전환한 뒤 어떤 형태가 될지에 대해 논의가 있었습니다. “interface-only” 모듈이라는 개념까지 제안되기도 했습니다. 하지만 저는 이것이 과도하게 복잡하다고 느낍니다. C++20 이름 있는 모듈 유닛은 본질적으로 단지 import 가능한 번역 단위일 뿐입니다. 바이너리 수준에서 C++20 이름 있는 모듈 유닛과 일반 TU 사이에는 차이가 없습니다. 즉, 원래 헤더 전용이던 프로젝트가 C++20 이름 있는 모듈을 도입했다면, 그 프로젝트 자체가 해당 모듈 유닛의 바이너리 수준 소유자가 되어야 합니다.
예를 들어 async_simple은 전형적인 헤더 전용 라이브러리입니다. 또한 C++20 모듈 인터페이스(async_simple.cppm)를 제공합니다. 하지만 사용자가 async_simple이 제공하는 모듈을 사용하고 싶다면 ASYNC_SIMPLE_BUILD_MODULES CMake 옵션을 켜서 소스에서 C++20 모듈 버전의 libasync_simple을 빌드해야 합니다:
cmakemessage(STATUS "Fetching async_simple") # Download and integrate async_simple FetchContent_Declare( async_simple GIT_REPOSITORY https://github.com/alibaba/async_simple.git GIT_TAG f376f197e54d4921a7f0d8e40ad303e41018f7c2 ) set(ASYNC_SIMPLE_ENABLE_TESTS OFF CACHE INTERNAL "") set(ASYNC_SIMPLE_DISABLE_AIO ON CACHE INTERNAL "") set(ASYNC_SIMPLE_BUILD_DEMO_EXAMPLE OFF CACHE INTERNAL "") set(ASYNC_SIMPLE_ENABLE_ASAN OFF CACHE INTERNAL "") set(ASYNC_SIMPLE_BUILD_MODULES ON CACHE INTERNAL "") FetchContent_MakeAvailable(async_simple)
(출처: https://github.com/ChuanqiXu9/socks_server/blob/main/CMakeLists.txt)
즉, 헤더 전용 라이브러리의 경우 호환성을 위해 CMake 옵션으로 모듈을 opt-in으로 만들 수 있어, 기본값으로는 헤더 전용 상태를 유지할 수 있습니다. 하지만 사용자가 C++20 모듈이 필요하다면, 소스에서 해당 라이브러리 버전을 빌드할 수 있어야 합니다.
배경 정보는 여기에서 볼 수 있습니다: https://clang.llvm.org/docs/StandardCPlusPlusModules.html#background-and-terminology
다음과 같은 구조를 생각해봅시다:
text. ├── common.h ├── network.h └── util.h
이를 모듈로 변환할 때 common 모듈, network 모듈, util 모듈을 만들면 안 됩니다. 그러면 모듈이 3개가 되며, 이런 이름은 충돌하기 쉽습니다.
대신 프로젝트에 대해 오직 하나의 모듈만 도입해야 합니다. 이를 example이라고 부르겠습니다. 그리고 common.h, network.h, util.h을 각각 example:common, example:network, example:util이라는 이름의 모듈 파티션 유닛으로 리팩터링해야 합니다.
이 접근은 두 가지 주요 장점이 있습니다:
전방 선언 문제는(툴체인 이슈를 제외하면) 온라인에서 모듈 관련 언어 수준 주제 중 가장 많이 논의된 것 중 하나입니다. 예: C++ Modules and Circular References. 하지만 프로젝트/라이브러리 전체를 하나의 모듈로 취급하면 이 문제는 사라집니다. 이는 논리적으로도 타당합니다. 여러분의 라이브러리는 자체적으로 결속된 하나의 모듈이기 때문입니다.
심볼 가시성에 대해서는, 모듈 이전에는 바이너리를 배포할 때 기본적으로 모든 심볼을 숨기기 위해 -fvisibility=hidden -fvisibility-inlines-hidden를 쓰고, 익스포트할 선언만 __attribute__((visibility("default")))로 표시하는 것이 흔했습니다. 예:
cppvoid __attribute__((visibility("default"))) Exported() { // ... }
모듈 도입과 함께 이 두 개념을 연결할 수 있습니다. 예를 들어, Clang의 이 이슈는 export된 심볼을 기본적으로 hidden으로 두지 않게 하는 컴파일러 옵션을 요청합니다. 하지만 그런 옵션이 없더라도, 사용자 관점에서 다음 같은 매크로를 제공하는 것은 합리적이고 자연스럽습니다:
cpp#define EXPORT export __attribute__((visibility("default"))) EXPORT void Exported() {}
이를 할 수 있는 전제는 라이브러리 전체를 하나의 모듈로 취급한다는 것입니다. 이 맥락에서 export는 “라이브러리 밖에서 보인다”는 의미가 됩니다. 만약 각 헤더 파일마다 별도 모듈을 선언한다면, 그 모듈에서의 export는 “다른 파일에서 보인다”는 의미가 되어 버리고, export는 라이브러리 수준의 가시성이라는 의미를 잃게 됩니다.
라이브러리에서 오직 하나의 모듈만 사용하면, 많은 모듈 인터페이스 유닛이 생기기 쉽습니다. 만약 모듈 구현 유닛(module implementation unit)으로 각 인터페이스를 구현한다면, 예를 들어:
cpp// network.cpp module example; // 암시적으로 `example`을 import 한다 // network 인터페이스 정의...
cpp// common.cpp module example; // 암시적으로 `example`을 import 한다 // common 인터페이스 정의...
cpp// util.cpp module example; // 암시적으로 `example`을 import 한다 // util 인터페이스 정의...
모든 *.cpp 파일이 이제 example 모듈의 주(primary) 인터페이스에 의존합니다. 보통 주 모듈 인터페이스는 모듈의 모든 인터페이스에 의존합니다. 예:
cppexport module example; export import :network; export import :common; export import :util;
표준도 이를 언급합니다:
[module.unit]p3: 모듈 인터페이스 유닛인 어떤 모듈의 모든 모듈 파티션은 주(primary) 모듈 인터페이스 유닛에 의해 직접 또는 간접적으로 export되어야 한다. 이 규칙 위반에 대해 진단은 요구되지 않는다.
여기서 문제는, network.cppm 같은 인터페이스 파티션을 수정하면 의존성 체인 때문에 모든 *.cpp 파일(예의 common.cpp, util.cpp 포함)이 재컴파일된다는 것입니다. 이는 용납할 수 없습니다. 프로젝트에서 인터페이스/구현 파일 수가 늘어날수록 이 문제는 실제로 매우 심각해집니다.
이를 해결하는 가장 단순한 방법은 구현 파일에 모듈 구현 파티션 유닛(module implementation partition unit)을 사용하는 것입니다. 예:
cpp// network.cpp module example:network.impl; // network 인터페이스 정의...
cpp// common.cpp module example:common.impl; // common 인터페이스 정의...
cpp// util.cpp module example:util.impl; // util 인터페이스 정의...
(같은 모듈에서 파티션 이름이 중복될 수는 없습니다.)
이렇게 하면 모듈 버전의 파일 수준 의존성은 최소한 헤더 파일 버전에 비해 퇴보하지 않습니다.
다만 실제로는 CMake 사용자에게 작은 이슈가 있습니다. 현재 CMake는 모든 모듈 구현 파티션 유닛을 CXX_MODULES FILES에 나열할 것을 요구하며, 그 결과 각각에 대해 BMI를 생성하게 됩니다. 이는 시간 낭비입니다. 예에서 network.cpp, common.cpp, util.cpp는 어떤 다른 유닛에서도 import하지 않도록 설계되었고, 프로그래머가 이를 보장하고 의도합니다. 하지만 CMake 아래에서는 이런 모듈 구현 파티션 유닛도 모두 BMI가 생성되어 추가 오버헤드가 생깁니다. 이 문제는 [C++20 Modules] We should allow implementation partition unit to not be in CXX_MODULES FILES에서 논의 중입니다. 비슷한 상황을 겪는 분이 있다면 해당 이슈에 댓글을 달아 주세요.
이 관행은 이전 관행(구현 파일에 모듈 구현 파티션 유닛을 사용)에서 자연스럽게 확장됩니다.
유닛 테스트에서는 종종 export되지 않은 내부 API를 테스트할 필요가 있습니다. 이 가시성 문제는 모듈 구현 파티션 유닛 안에서 유닛 테스트를 작성함으로써 해결할 수 있습니다. 같은 모듈 내에서는 모듈 수준 선언이 모두 보이기 때문입니다.
또 하나의 작은 포인트: 모듈 구현 파티션 유닛에서 main 함수를 작성할 때는 extern "C++"를 추가해야 합니다. ISO 표준은 이전에 main이 어떤 이름 있는 모듈에도 속하면 안 된다고 말하며 이를 금지했지만, 위원회가 나중에 이를 수정했고 이제는 extern "C++"를 추가하면 됩니다. 제 지식으로는 컴파일러들의 이전 동작은 이미 기대와 같았기 때문에 실질적 영향은 거의 없습니다. 최신 컴파일러는 main이 이름 있는 모듈 안에 있는데 extern "C++" 블록 안에 있지 않으면 경고를 낼 수 있습니다.
모듈 구현 파티션 유닛이 혼란스러운 점 중 하나는, 이것 또한 import 가능하다는 것입니다. 처음 접했을 때 저는 모듈 구현 파티션 유닛과 모듈 인터페이스 파티션 유닛의 차이를 이해하기 어려웠습니다.
처음에는 모듈 구현 파티션 유닛이 전통적인 헤더 파일의 detail 네임스페이스에 대응한다고 생각했습니다. 하지만 지금은 그것이 틀렸다는 것을 알았습니다. 전통적 헤더의 detail 네임스페이스는 사실 모듈 인터페이스 파티션 유닛의 non-export 부분입니다.
cpp// detail.h namespace detail { ... }
cpp// detail.cppm export module example:detail; // 여기엔 export가 없다 namespace detail { // ... }
모듈 구현 파티션 유닛은 구현 파일로서의 용도 외에도, import 가능할 때는 프로젝트에서 “공개되지 않는 헤더”에 더 가까운 역할을 합니다.
Clang을 예로 들어봅시다(Clang/LLVM은 단순한 컴파일러가 아니라 라이브러리 그 자체이기도 합니다):
textclang ├── ... ├── include ├── lib └── ...
include 디렉터리에는 공개되는 헤더가 있고, lib는 주로 구현 파일을 담습니다. 하지만 lib에도 헤더가 있습니다. 예:
textclang/lib/Serialization/ ├── ... ├── ASTReaderInternals.h ├── MultiOnDiskHashTable.h ├── TemplateArgumentHasher.h └── ...
여기의 ASTReaderInternals.h, MultiOnDiskHashTable.h, TemplateArgumentHasher.h 같은 헤더는 Serialization 라이브러리 컴포넌트 내부에서만 사용됩니다. Clang 라이브러리 사용자에게는 보이지 않습니다. 이런 파일들은 모듈 구현 파티션 유닛으로 변환하기에 이상적인 후보입니다.
다른(다소 동어반복적인) 방식으로 말하면: 모듈 구현 파티션 유닛을 사용하는 원칙은, 라이브러리 인터페이스의 일부가 아닌 모든 파일은 모듈 구현 파티션 유닛이어야 한다는 것입니다. 라이브러리 인터페이스는 모듈 인터페이스 유닛(주 모듈 인터페이스 유닛과 모듈 인터페이스 파티션 유닛)과 헤더 파일(매크로를 노출해야 한다면)로 구성되어야 합니다. 그 외 모든 것은 모듈 구현 파티션 유닛이어야 합니다.
import하지 말라주 모듈 인터페이스 유닛과 모듈 인터페이스 파티션 유닛을 포함하는 “모듈 인터페이스” 안에서 모듈 구현 파티션 유닛을 import하지 마세요. 예:
cpp// impl.cppm module example:impl;
cpp// interface.cppm export module example:interface; import :impl;
이 파일을 컴파일하면 다음 경고가 발생합니다:
textinterface.cppm:2:1: warning: importing an implementation partition unit in a module interface is not recommended. Names from example:impl may not be reachable [-Wimport-implementation-partition-unit-in-interface-unit] 2 | import :impl; | ^ 1 warning generated.
이 관행에는 두 가지 이유가 있습니다:
[module.reach]p2는 다음과 같이 말합니다:
반드시 도달 가능한(necessarily reachable) 모든 번역 단위는 도달 가능하다. 프로그램 내의 어떤 지점이 인터페이스 의존성을 갖는 추가 번역 단위는 도달 가능하다고 간주될 수 있지만, 어느 것들이 어떤 상황에서 도달 가능한지는 미지정(unspecified)이다.
“반드시 도달 가능”은 [module.reach]p1에서 정의합니다:
번역 단위 U가 지점 P에서 반드시 도달 가능하다는 것은, U가 번역 단위 P를 포함하는 번역 단위가 인터페이스 의존성을 가지는 모듈 인터페이스 유닛이거나, 또는 P를 포함하는 번역 단위가 P 이전에 U를 import하는 경우이다.
즉 “반드시 도달 가능”이란 직접 import한 TU 또는 인터페이스 의존성이 있는 TU를 의미합니다. “인터페이스 의존성”은 [module.import]p10에서 정의됩니다:
어떤 번역 단위가 번역 단위 U를 import하는 선언을 포함하거나, 또는 U에 인터페이스 의존성을 가진 번역 단위에 인터페이스 의존성을 가지면, 그 번역 단위는 U에 인터페이스 의존성을 가진다.
이는 인터페이스 의존성이란 import된 모듈 인터페이스 유닛 및 그로부터 재귀적으로 import되는 모듈 인터페이스 유닛을 의미함을 뜻합니다.
요약하면, 직접 import하지 않은 모듈 구현 파티션 유닛은 도달 가능하다고 보장되지 않습니다. 표준 용어로는 “도달 가능하다고 간주될 수도 있지만, 어떤 것들이 어떤 상황에서 도달 가능한지는 미지정”입니다. 이는 실무에서 매우 혼란스럽습니다. Clang에 대해 이와 관련된 버그 리포트가 여러 개 있었지만 모두 “invalid”로 닫혔습니다. 이런 혼란을 피하기 위해 모듈 인터페이스에서 모듈 구현 파티션 유닛을 import하지 말 것을 권합니다.
모든 헤더 파일이나 import 가능한 모듈 유닛이 인터페이스인 것은 아닙니다. 라이브러리 인터페이스의 경계는 프로그래머가 신중히 설계해야 합니다. 하지만 대규모 프로젝트에서는 많은 사람이 협업하면서 인터페이스 경계가 잘 유지되지 않는 경우가 많습니다. 많은 헤더가 생각 없이 include 폴더에 들어가고 점점 사실상 인터페이스의 일부가 되어 버릴 수 있습니다. 모듈 도입으로 우리는 새로운 도구를 갖게 됩니다. 코드를 작성할 때, 현재 TU가 모듈 인터페이스 유닛인지 모듈 구현 파티션 유닛인지 명확히 볼 수 있으므로, 지금 작성하는 파일이 프로젝트 인터페이스의 일부인지 판단하는 데 도움이 됩니다. 이는 가독성에 큰 도움입니다.
모듈 구현 파티션 유닛을 사용하는 원칙은 다음과 같습니다: 라이브러리 인터페이스의 일부가 아닌 어떤 파일이든 모듈 구현 파티션 유닛이어야 합니다. 이 구현 파티션 유닛이 다른 모듈 유닛에서 import될 수 있다면 .cppm(또는 .ccm) 확장자를 사용하세요. 그렇지 않다면 .cpp 또는 .cc 확장자를 가진 구현 파일로 사용하세요.
모듈 인터페이스에서 모듈 구현 파티션 유닛을 import하지 마세요.
모듈 구현 파티션 유닛을 구현 파일로 사용할 때도 CMake는 여전히 BMI를 생성할 수 있어 추가 오버헤드를 유발합니다. 이는 https://gitlab.kitware.com/cmake/cmake/-/issues/27048 에서 논의될 수 있습니다.
그렇다면 모듈 구현 유닛(module implementation unit)은 어떨까요? 구현 파일로 모듈 구현 파티션 유닛을 사용하는 방법을 알게 된 뒤, 저는 어떤 큰 모듈에서도 모듈 구현 유닛을 사용하지 말 것을 권합니다. 모듈 구현 유닛은 한 줄의 import를 줄여주는 달지 않은(sweet하지 않은) 문법 설탕(syntactic sugar)일 뿐이라고 느낍니다. 하지만 그로 인해 생기는 의존성은 너무 거칠고(coarse-grained) 큽니다.
static을 적극 사용하라예를 들어:
cppexport module a; struct A {};
이 TU에서 모듈 a는 아무것도 export하지 않습니다. 그렇다면 컴파일러가 A의 선언을 최적화로 제거할 수 있을까요? 아닙니다. 이 non-export 선언은 외부에는 보이지 않지만, 같은 모듈 내의 다른 모듈 유닛에는 보입니다. 따라서 컴파일러는 여전히 struct A에 대한 전체 정보를 BMI에 기록해야 합니다.
실무에서는 프로그래머가 이를 잊을 수 있습니다. 모듈이 export 키워드를 도입했더라도, 현재 TU 안에서만 보이는 엔티티에는 익명 네임스페이스 또는 static 지정자를 적극적으로 사용해야 합니다. 이는 최종 바이너리의 심볼 수를 줄일 수 있고 BMI 크기도 줄일 수 있습니다.
(BMI에 관해서, 이론적으로 컴파일러는 주 모듈 인터페이스에 대해 모듈 내부용 BMI와 외부용 BMI를 두 세트로 생성할 수도 있습니다. 하지만 이는 컴파일러와 빌드 시스템의 협업이 필요하며 현재로서는 매우 어려워 보입니다. 또한 이 글은 사용자 관점에 초점을 맞추므로 자세히 다루지 않겠습니다.)
비록 먼 미래일 수 있지만, 모듈 래퍼를 제공하던 라이브러리가 언젠가 래퍼 제공을 넘어서 모듈 네이티브로 개발하고 싶어질 때를 상상할 수 있습니다. 그 시점에는 몇 가지 선택지가 있습니다:
첫 번째 옵션은 수동으로 하든 도구(예: clang-modules-converter)로 하든, “모듈 네이티브” 방식으로 코드를 작성하는 법을 이해하고 나면 비교적 직관적입니다.
두 번째 옵션을 위해서는, 먼저 기존 모듈 래퍼를 현재 모듈의 파티션으로 이름을 바꿀 수 있습니다. 예:
cppexport module example:header_interfaces; import std; // 다른 서드파티 모듈이 있다면 import #define IN_MODULE_WRAPPER extern "C++" { #include "header.h" }
그 뒤에는 다른 파티션에서 일반적인 모듈 코드를 작성하면 됩니다. 기존 헤더의 인터페이스를 사용할 필요가 있으면 import :header_interfaces를 하면 됩니다. 마지막으로 주 모듈 인터페이스에서 header_interfaces를 export합니다:
cppexport module example; export import :header_interfaces; export import :...; // 다른 파티션들
이렇게 하면 원래 헤더 파일을 유지하면서도 C++20 모듈로 개발할 수 있습니다.
2024년 말부터 2025년 초까지, 저는 700만 라인(7M LoC) 규모의 대형 C++ 프로젝트를 모듈 네이티브로 전환하는 데 두 달이 넘는 시간을 썼습니다. 시작하기 전에는 대부분의 시간을 컴파일러 버그를 고치는 데 쓸 것이라 예상했습니다. 실제로는 컴파일러 이슈에는 2주 정도만 썼고, 대부분의 시간은 전환 이후의 런타임 문제를 조사하는 데 들었습니다. 이는 예상 밖이었습니다. 저는 모듈 전환에서 대부분의 문제는 컴파일 시간에 나타나고, 컴파일만 되면 동작할 것이라 생각했습니다. 하지만 그렇지 않았습니다. 대규모 C++ 프로젝트에서는 ODR 위반이 흔하며, 종종 “그냥 동작합니다.” 모듈 전환은 이런 잠재 이슈를 많이 촉발할 수 있습니다. 근본 원인은 사실 단순하지만, 디버깅 과정은 골치 아픕니다. 그러나 다른 관점에서 보면, 모듈 전환 과정은 많은 기술 부채를 드러내므로 프로젝트의 안정성을 진정으로 개선하는 데 도움이 됩니다.
이름 있는 모듈이 non-inline 함수 정의를 export하지 않는 현재 관행이 성능 최적화에 해롭다고 말하는 사람도 많습니다. 이 동작은 현재 C++ 표준 위원회가 주로 ABI 안정성을 보장하기 위해 권고하는 것입니다.
ABI 안정성을 잠시 제쳐두면, 실무에서 이 접근을 ThinLTO와 결합했을 때 관측 가능한 성능 저하는 없었습니다(오히려 여러 프로젝트에서 약간의 성능 향상을 관측했습니다). 대신 컴파일 속도는 빨라집니다. 만약 컴파일러가 최적화 과정에서 import 가능한 모든 함수를 무작정 가져온다면, 각 TU의 최적화 복잡도는 O(N)에서 O(N^2)(여기서 N은 TU당 평균 함수 수)로 증가해 용납할 수 없게 됩니다.
전반적으로 C++20 모듈은 다른 주요 C++ 기능과 비교할 때 언어 기능 관점에서 꽤 단순합니다. 이 글을 되돌아보면, 내용의 대부분은 헤더 파일과의 호환성을 유지하면서 C++20 모듈 지원을 제공하는 방법과 ABI 관련 주제에 관한 것입니다. ABI에 신경 쓰지 않거나, 새로 시작하는 모듈 네이티브 프로젝트를 충분히 공격적으로 작성한다면, 언어 수준에서 마주칠 문제는 상대적으로 적을 것입니다.