C++20 Modules: import·export 문법, 컴파일러 지원 현황, 빌드 시간

들어가며: 헤더의 컴파일 비용

큰 헤더 하나를 include하면 그 헤더가 또 수십 개의 헤더를 include하고, 같은 내용이 수많은 .cpp에서 반복 파싱됩니다. C++20 모듈은 인터페이스를 한 번 컴파일해 그 결과(BMI)를 재사용하는 단위라서, 이 반복 파싱 비용을 줄여 줍니다. 또 export로 “이 모듈이 공개하는 것”을 명시하므로 구현 디테일과 매크로가 바깥으로 새어 나가지 않고, 의존 관계도 import 문만 보면 파악할 수 있습니다.

이 글에서는 export module과 export로 공개 인터페이스를 정의하고, import로 다른 모듈을 가져오고, 파티션으로 큰 모듈을 나누고, 컴파일러별로 빌드하는 방법을 다룹니다.

요구 환경: 이름 있는 모듈을 실무 수준으로 쓰려면 MSVC(Visual Studio 2022 17.4 이상), Clang 16 이상, GCC 14 이상과 CMake 3.28 이상(Ninja 또는 Visual Studio 생성기)이 필요합니다. 더 오래된 버전도 실험적 지원이 있지만 버그가 많습니다.


모듈: 헤더 대신 컴파일된 인터페이스를 가져오기

헤더(#include)는 전처리기가 파일 내용을 그대로 복사해 넣는 방식이라, 같은 헤더를 포함하는 모든 .cpp가 그 내용을 매번 처음부터 파싱합니다. 모듈은 인터페이스 파일을 한 번 컴파일해 BMI로 저장하고, import하는 쪽은 그 결과를 읽어 옵니다.

flowchart LR
  subgraph header["#include 헤더"]
    H1[.cpp 1] --> H2[매번 파싱]
    H3[.cpp 2] --> H2
    H2 --> H4[중복 파싱]
  end
  subgraph module[import 모듈]
    M1[.cpp 1] --> M2[BMI 읽기]
    M3[.cpp 2] --> M2
    M2 --> M4[한 번 컴파일한 결과 재사용]
  end
항목헤더 (#include)모듈 (import)
동작텍스트 복사컴파일된 인터페이스(BMI) 로드
중복포함하는 .cpp마다 파싱인터페이스를 한 번 컴파일
매크로포함한 쪽으로 새어 나감모듈 밖으로 나가지 않음
공개 범위헤더에 있는 것 전부export한 것만

기본 형태

// mylib.cppm (MSVC는 보통 .ixx)
module;

// 전역 모듈 fragment: 기존 헤더는 여기서 #include
#include <vector>

export module mylib;

export int add(int a, int b) {
    return a + b;
}

export class Widget {
public:
    void draw();
};

int internalHelper() { return 0; }  // export하지 않음 → 모듈 내부 전용

module; 다음부터 export module mylib; 전까지가 전역 모듈 fragment로, 모듈화되지 않은 기존 헤더를 include하는 자리입니다. export module mylib;로 이 파일이 mylib 모듈의 인터페이스임을 선언하고, export를 붙인 add와 Widget만 이 모듈을 import하는 쪽에서 쓸 수 있습니다. internalHelper처럼 export하지 않은 선언은 모듈 내부에서만 보입니다.


include가 만드는 컴파일 비용

50개의 .cpp가 각각 <vector>, <string>, "common_utils.h"를 include하고, common_utils.h가 다시 10개의 헤더를 include한다고 해 봅시다.

flowchart TD
    subgraph cpp["50개 .cpp 파일"]
        C1[main.cpp]
        C2[parser.cpp]
        C3[renderer.cpp]
        C50[...]
    end
    subgraph headers[공통 헤더]
        H1[common_utils.h]
        H2[vector]
        H3[string]
        H4[algorithm]
    end
    C1 --> H1
    C2 --> H1
    C3 --> H1
    C50 --> H1
    H1 --> H2
    H1 --> H3
    H1 --> H4

컴파일러는 같은 헤더 내용을 .cpp 수만큼, 여기서는 50번 파싱합니다. 표준 헤더 하나가 전처리 후 수만 줄이 되는 경우도 흔하므로, 이 비용은 .cpp 수에 비례해 그대로 커집니다. 모듈로 바꾸면 인터페이스는 한 번 컴파일되고, 50개 .cpp는 BMI를 읽기만 합니다.

헤더 방식에는 그 밖의 비용도 있습니다. #define min(a,b) ... 같은 매크로가 헤더에 있으면 그 헤더를 포함한 모든 파일에서 min이 치환되어 std::min과 충돌하는데, 모듈은 매크로를 export하지 않으므로 이런 오염이 생기지 않습니다. 또 헤더에 private 멤버나 inline 구현이 있으면 그 헤더를 쓰는 모든 코드가 그 디테일에 의존하게 됩니다.

반대로 모듈이 해결하지 못하는 것도 있습니다. 템플릿 인스턴스화는 여전히 템플릿을 사용하는 번역 단위마다 일어나므로, 템플릿이 무거운 코드에서는 파싱 시간만 줄고 인스턴스화 시간은 남습니다. 또 모듈 인터페이스를 수정하면 그 모듈을 import하는 모든 파일을 다시 컴파일해야 한다는 점은 헤더와 같습니다. 차이는 구현을 별도의 구현 단위로 분리해 두었을 때, 구현만 바뀌면 import하는 쪽을 다시 컴파일하지 않아도 된다는 점입니다.


export module, export 블록, 템플릿 export

export module math;

export int add(int a, int b) {
    return a + b;
}

export constexpr double pi = 3.141592653589793;

// export 없음 → 이 모듈 내부에서만 사용
static int internalHelper() {
    return 0;
}

모듈에서는 export 목록이 곧 API 계약이 됩니다. 헤더처럼 “선언된 것은 전부 보이는” 구조가 아니므로, 외부에 필요한 최소 집합만 export하는 습관이 중요합니다.

여러 선언을 한꺼번에 export하려면 export { ... } 블록을 씁니다.

export module utils;

export {
    void foo();
    class Bar {};
}

템플릿도 export할 수 있으며, 정의가 모듈 인터페이스 안에 있으면 import하는 쪽에서 인스턴스화할 수 있습니다.

// container.cppm
module;
#include <utility>
export module container;

export template<typename T>
class Box {
    T value;
public:
    explicit Box(T v) : value(std::move(v)) {}
    const T& get() const { return value; }
};

템플릿 정의는 인스턴스화하는 쪽에서 보여야 하므로, 헤더 시절처럼 정의를 인터페이스 단위에 둡니다. 구현 단위(module container;)에만 정의하면 import하는 쪽에서 인스턴스화할 수 없습니다.


예제: 단일 모듈, 인터페이스·구현 분리, 기존 헤더 사용

예제 1: 단일 파일 모듈

// math.cppm
export module math;

export int add(int a, int b) { return a + b; }
export int multiply(int a, int b) { return a * b; }
export constexpr double PI = 3.141592653589793;

namespace detail {
    int square(int x) { return x * x; }  // export 없음
}
// main.cpp
#include <iostream>
import math;

int main() {
    std::cout << add(3, 5) << "\n";       // 8
    std::cout << multiply(4, 7) << "\n";  // 28
    std::cout << PI << "\n";              // 3.14159
}

예제 2: 인터페이스와 구현 분리

// geometry.cppm — 인터페이스 단위
export module geometry;

export struct Point {
    double x, y;
};

export double distance(const Point& a, const Point& b);
export Point midpoint(const Point& a, const Point& b);
// geometry_impl.cpp — 구현 단위
module;
#include <cmath>
module geometry;

double distance(const Point& a, const Point& b) {
    double dx = a.x - b.x;
    double dy = a.y - b.y;
    return std::sqrt(dx * dx + dy * dy);
}

Point midpoint(const Point& a, const Point& b) {
    return Point{(a.x + b.x) / 2, (a.y + b.y) / 2};
}

구현 단위는 export 없이 module geometry;로 시작하며, 인터페이스 단위의 선언을 자동으로 볼 수 있습니다. 표준 헤더가 필요하면 구현 단위에서도 module; 전역 모듈 fragment에서 include해야 합니다. module geometry; 뒤에 #include <cmath>를 쓰면 표준 라이브러리 선언이 geometry 모듈에 소속되어 버려, 다른 곳의 같은 선언과 충돌하는 에러가 납니다.

// main.cpp
import geometry;

int main() {
    Point p1{0, 0}, p2{3, 4};
    double d = distance(p1, p2);  // 5.0
    Point m = midpoint(p1, p2);   // {1.5, 2}
}

예제 3: 전역 모듈 fragment로 기존 헤더 사용

// string_utils.cppm
module;
#include <algorithm>
#include <cctype>
#include <string>
export module string_utils;

export std::string to_upper(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c) { return static_cast<char>(std::toupper(c)); });
    return s;
}

export std::string trim(const std::string& s) {
    auto start = s.find_first_not_of(" \t\n\r");
    if (start == std::string::npos) return "";
    auto end = s.find_last_not_of(" \t\n\r");
    return s.substr(start, end - start + 1);
}

std::toupper에 char를 그대로 넘기면 음수 값(한글 등 비ASCII 바이트)에서 미정의 동작이 되므로 unsigned char로 받습니다.


파티션으로 큰 모듈 나누기

한 모듈을 여러 파일로 나누되 바깥에는 하나의 모듈로 보이게 하려면 파티션을 씁니다. 사용자는 import network; 한 번으로 모든 공개 파티션의 export를 쓸 수 있고, 파티션 자체는 모듈 바깥에서 직접 import할 수 없습니다.

flowchart TB
    subgraph net[network 모듈]
        M1["network.cppm (주 인터페이스)"]
        P1["network:tcp"]
        P2["network:udp"]
        P3["network:http"]
    end
    M1 --> P1
    M1 --> P2
    M1 --> P3
    P3 --> P1
    User[main.cpp] -->|import network| M1
// network.cppm — 주 인터페이스: 파티션을 다시 export
export module network;

export import :tcp;
export import :udp;
export import :http;
// network_tcp.cppm — 인터페이스 파티션
module;
#include <cstddef>
export module network:tcp;

export class TcpSocket {
public:
    void connect(const char* host, int port);
    void send(const void* data, std::size_t len);
    std::size_t receive(void* buf, std::size_t len);
};
// network_udp.cppm
module;
#include <cstddef>
export module network:udp;

export class UdpSocket {
public:
    void bind(int port);
    void sendTo(const void* data, std::size_t len, const char* addr, int port);
};
// network_http.cppm — 다른 파티션을 사용하는 파티션
module;
#include <string>
export module network:http;

import :tcp;

export class HttpClient {
public:
    std::string get(const char* url);
};
// main.cpp
import network;

int main() {
    TcpSocket tcp;
    tcp.connect("localhost", 8080);
    UdpSocket udp;
    udp.bind(9000);
    HttpClient http;
    auto response = http.get("https://example.com");
}

export가 들어 있는 파티션은 반드시 export module network:tcp;처럼 인터페이스 파티션으로 선언해야 하고, 주 인터페이스가 export import :tcp;로 다시 내보내야 바깥에서 보입니다. module network:tcp;처럼 export 없이 선언한 것은 구현 파티션이라 안에 export 선언을 둘 수 없습니다. 같은 모듈 안에서는 import :tcp;처럼 모듈 이름 없이 파티션 이름만 씁니다.

내부 전용 파티션과 구현 단위

// internal.cppm — 구현 파티션 (export 없음)
module network:internal;

void internalHelper() { /* 구현 디테일 */ }
// network_tcp_impl.cpp — TcpSocket 구현
module network;   // 파티션이 아니라 모듈의 구현 단위
import :internal;

void TcpSocket::connect(const char* host, int port) {
    internalHelper();
}

구현 파티션은 주 인터페이스가 다시 export하지 않으므로 바깥에 노출되지 않습니다. 파티션에서 선언한 클래스의 멤버 함수 구현은 module network; 구현 단위에 둡니다. 구현 파일에 module network:tcp;를 다시 쓰면 같은 이름의 파티션이 두 개가 되어 에러입니다.

규칙설명
인터페이스 파티션export module 모듈:파티션;, 주 인터페이스가 export import :파티션;해야 함
구현 파티션module 모듈:파티션;, export 불가, 모듈 내부에서만 import
이름파티션 이름은 모듈 안에서 유일해야 함
순환모듈 간·파티션 간 모두 순환 import 불가

GCC, Clang, MSVC, CMake에서 모듈 빌드하기

모듈은 import하는 쪽을 컴파일하기 전에 BMI가 있어야 하므로, 빌드 순서가 의존 관계를 따라야 합니다. 손으로 빌드할 때의 형태는 다음과 같습니다.

# GCC 14+ (GCC 15부터는 -fmodules, 이전 버전은 -fmodules-ts)
g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o   # gcm.cache/math.gcm 생성
g++ -std=c++20 -fmodules-ts main.cpp math.o -o app

# 파티션: 의존 없는 파티션부터
g++ -std=c++20 -fmodules-ts -c network_tcp.cppm network_udp.cppm
g++ -std=c++20 -fmodules-ts -c network_http.cppm
g++ -std=c++20 -fmodules-ts -c network.cppm
# Clang 16+: BMI(.pcm)를 먼저 만들고, 사용하는 쪽에 경로를 알려 줌
clang++ -std=c++20 --precompile math.cppm -o math.pcm
clang++ -std=c++20 -c math.pcm -o math.o
clang++ -std=c++20 -fmodule-file=math=math.pcm main.cpp math.o -o app
:: MSVC: .ixx는 모듈 인터페이스로 처리됨 (.cppm 등은 /interface 지정)
cl /std:c++20 /EHsc /c math.ixx
cl /std:c++20 /EHsc main.cpp math.obj

실제 프로젝트에서는 의존성 스캔과 순서를 빌드 시스템에 맡기는 것이 현실적입니다. CMake 3.28 이상에서는 모듈 인터페이스와 파티션 파일을 CXX_MODULES 파일 세트에 넣고, 구현 단위는 일반 소스로 둡니다.

cmake_minimum_required(VERSION 3.28)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

add_library(network)
target_sources(network
  PUBLIC
    FILE_SET CXX_MODULES FILES
      network.cppm
      network_tcp.cppm
      network_udp.cppm
      network_http.cppm
      internal.cppm
  PRIVATE
    network_tcp_impl.cpp
)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE network)
cmake -B build -G Ninja && cmake --build build

Makefile 생성기는 모듈 의존성 스캔을 지원하지 않으므로 Ninja(1.11 이상)나 Visual Studio 생성기를 씁니다. add_library(... MODULE ...)의 MODULE은 런타임에 로드하는 플러그인 공유 라이브러리를 뜻하는 키워드로, C++20 모듈과는 관계가 없습니다.


자주 만나는 오류

”module not found” 또는 BMI를 찾지 못함

import하는 파일을 컴파일하는 시점에 해당 모듈의 BMI가 아직 없거나, 컴파일러가 BMI 위치를 모를 때 납니다. 손으로 빌드한다면 인터페이스를 먼저 컴파일하고, Clang은 -fmodule-file=이름=경로 또는 -fprebuilt-module-path=디렉터리로 위치를 알려 줍니다. BMI는 같은 컴파일러·같은 옵션(표준 버전, 매크로 정의 등)으로 만든 것만 쓸 수 있으므로, 옵션이 다른 타깃 사이에서 BMI를 공유하면 이 에러나 불일치 에러가 납니다.

전역 모듈 fragment에서 export 사용

// ❌ module; 와 export module 사이에는 전처리 지시문만 둘 수 있음
module;
export int foo() { return 0; }
#include <vector>
export module mylib;

// ✅
module;
#include <vector>
export module mylib;
export int foo() { return 0; }

export하지 않은 심볼 사용

// math.cppm
export module math;
int internalAdd(int a, int b) { return a + b; }  // export 없음

// main.cpp
import math;
int x = internalAdd(1, 2);  // ❌ 이 이름은 보이지 않음

순환 import

모듈 A가 B를 import하고 B가 A를 import하면 어느 쪽 BMI도 먼저 만들 수 없어 빌드가 불가능합니다. 같은 모듈의 파티션 사이에서도 순환은 허용되지 않습니다. 공통 선언을 별도 모듈이나 파티션으로 추출해 의존 방향을 한쪽으로 만듭니다.

구현 단위에서 export 사용

// ❌ geometry_impl.cpp — 구현 단위
module geometry;
export double distance(const Point& a, const Point& b) { /* ... */ }

// ✅ export는 인터페이스 단위에서만
export module geometry;
export double distance(const Point& a, const Point& b);

매크로와 import 순서

모듈은 import하는 파일의 매크로에 영향을 받지 않고, 모듈 안의 매크로도 바깥으로 나오지 않습니다. 따라서 #include와 import의 순서는 모듈 내용에 영향을 주지 않습니다. 다만 import하는 파일 자신의 코드는 그 파일에서 include한 헤더의 매크로에 여전히 영향을 받습니다. 예를 들어 <windows.h>의 min/max 매크로가 std::min 호출을 망가뜨리는 문제는 순서와 무관하며, #define NOMINMAX를 <windows.h> include보다 앞에 두거나 (std::min)(a, b)처럼 괄호로 감싸 피합니다.

확장자와 모듈 인식

GCC는 확장자와 상관없이 -fmodules-ts(GCC 15부터 -fmodules)가 켜져 있으면 모듈 선언을 인식합니다. Clang은 .cppm을 모듈 인터페이스로 인식하고, 다른 확장자라면 -x c++-module을 지정해야 합니다. MSVC는 .ixx를 인터페이스로 처리하고, 그 밖의 확장자는 /interface 또는 /internalPartition을 지정합니다. CMake로 빌드하면 파일 세트에 넣은 것만으로 처리됩니다.


모듈 설계 원칙

외부에 필요한 API만 export하고, 내부 헬퍼와 구현 디테일은 export하지 않습니다.

module;
#include <string>
export module config;

export struct Config {
    int timeout;
    std::string host;
};
export Config loadConfig(const std::string& path);

namespace detail {
    std::string parseEnv(const std::string& key);  // 내부 전용
}

하나의 모듈은 하나의 관심사를 담당하는 것이 좋습니다. 수학, 문자열, 날짜, JSON을 모두 담은 utils 모듈은 하나만 바뀌어도 이를 import하는 모든 코드가 다시 컴파일됩니다.

프로젝트가 커지면 myproject.math.algebra처럼 점으로 이어진 이름을 쓰게 되는데, 모듈 이름의 점은 이름의 일부일 뿐 계층 관계를 만들지 않습니다. import myproject.math;를 해도 myproject.math.algebra가 자동으로 딸려 오지 않으므로, 함께 노출하려면 export import myproject.math.algebra;를 명시해야 합니다. 한 모듈의 내부를 나누는 것이 목적이라면 점 이름 대신 파티션(myproject.math:algebra)을 씁니다. 파티션은 바깥에서 따로 import할 수 없어 구현 경계가 지켜집니다.

export module myproject.math;
export import myproject.math.algebra;   // 함께 노출하려면 명시
export import myproject.math.geometry;

전역 모듈 fragment에는 꼭 필요한 #include만 넣고, 표준 라이브러리 모듈을 쓸 수 있는 환경이라면 import std;로 대체합니다.

module;
#include <windows.h>        // 매크로·플랫폼 API가 필요한 헤더
#include "legacy_header.h"  // 아직 모듈화되지 않은 레거시
export module mylib;
import std;                 // C++23 표준 라이브러리 모듈 (지원 환경에서)

어떤 프로젝트에서 효과가 큰가

프로젝트 특성모듈 전환 효과이유
.cpp가 적고 헤더가 가벼움작음원래 헤더 파싱 비용이 크지 않음
많은 .cpp가 같은 대형 헤더를 포함큼파일마다 다시 파싱하던 비용이 BMI 생성 한 번으로 바뀜
템플릿이 많은 헤더 라이브러리파싱 비용은 줄지만 인스턴스화 비용은 남음인스턴스화는 사용하는 쪽에서 여전히 일어남
모듈 간 의존 체인이 긺작거나 역효과앞 모듈의 BMI가 나올 때까지 뒤 모듈을 컴파일할 수 없어 병렬 빌드가 막힘

실제 감소 폭은 프로젝트 구조, 헤더 크기, 컴파일러 버전에 따라 크게 달라지므로, 일부 타깃만 먼저 전환해 클린·증분 빌드 시간을 직접 비교해 보는 것이 가장 확실합니다.


점진적 마이그레이션과 실무 패턴

기존 헤더를 한 번에 모듈로 바꾸기보다 새 코드부터 모듈로 작성합니다. 한 파일에서 기존 헤더와 새 모듈을 함께 쓸 수 있습니다.

// legacy_code.cpp
#include "old_header.h"
import new_module;

void process() {
    oldFunction();  // 헤더 기반
    newFunction();  // 모듈 기반
}

기존 헤더/소스 쌍 하나를 모듈로 옮기면 include 가드와 #include "math.h"가 사라지고, 인터페이스 파일의 export가 공개 범위를 대신합니다.

// Before: math.h
#ifndef MATH_H
#define MATH_H
namespace math { int add(int a, int b); }
#endif

// After: math.cppm
export module math;
export namespace math {
    int add(int a, int b) { return a + b; }
}

옮길 때 같은 기능을 헤더와 모듈 양쪽에 동시에 두면 안 됩니다. 전환 기간에 math.h를 include하는 코드와 import math;하는 코드가 섞여 있으면 같은 이름의 정의가 두 번 생겨 ODR 위반이나 중복 정의 오류가 납니다. 파일 단위로 옮기되, 옮긴 헤더는 지우거나 모듈 쪽을 감싸는 얇은 호환 계층으로만 남깁니다.

모듈 안에서도 PIMPL을 쓰면 구현 세부(멤버 구성)가 바뀌어도 클래스 레이아웃과 인터페이스가 바뀌지 않아 import하는 쪽 재컴파일을 줄일 수 있습니다.

// widget.cppm
module;
#include <memory>
export module widget;

export class Widget {
public:
    Widget();
    ~Widget();
    void draw();
private:
    struct Impl;
    std::unique_ptr<Impl> pimpl;
};
// widget_impl.cpp
module;
#include <memory>  // 인터페이스의 전역 모듈 fragment는 이름으로 보이지 않으므로 다시 include
module widget;

struct Widget::Impl {
    int state = 0;
};

Widget::Widget() : pimpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // Impl이 완전한 타입인 곳에서 정의
void Widget::draw() { /* ... */ }

아직 모듈로 바꿀 수 없는 헤더는 헤더 유닛으로 컴파일해 import "legacy_utils.h"; 형태로 쓸 수 있습니다. MSVC 지원이 가장 성숙하고, GCC와 Clang도 지원하지만 빌드 시스템 연동이 아직 까다롭습니다. CMake는 3.28 기준으로 헤더 유닛을 지원하지 않습니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 모듈로 바꾸면 헤더에서 쓰던 매크로는 어떻게 되나요?

A. 이름 있는 모듈은 매크로를 export하지 않으므로, import한 쪽에서는 모듈 안에서 정의한 #define을 볼 수 없습니다. 이것이 min/max 같은 매크로 오염을 막아 주는 장점이지만, 설정 매크로에 의존하던 코드는 constexpr 변수나 inline 함수로 바꾸거나 해당 매크로만 담은 헤더를 계속 #include해야 합니다. 헤더 유닛(import "config.h";)은 예외적으로 매크로를 내보냅니다.