[C++20] Module 연구

MIN·2025년 4월 28일

CPP20

목록 보기
2/8

모듈은 기존 헤더 파일의 단점을 보완하고, 컴파일 속도 개선, 중복 포함 방지, 인터페이스와 구현의 명확한 분리를 가능하게 한다.
이제는 #include 대신 import를 사용함으로써, 필요한 기능만 가져오고 의존성도 보다 명확하게 관리할 수 있다.
그렇다면 C++ 모듈은 기존 헤더 파일 방식과 어떤 점에서 구체적으로 다른지, 그리고 실제로 어떻게 사용하는지 살펴보자.

Module

번역 단위간에 선언과 정의를 공유하는 언어 기능

모듈에 대한 더 자세한 정의가 궁금하다면 관련 문서를 참고해보는 것도 좋다.
모듈은 import하는 순서에 영향을 받지 않으며, 기존 헤더 파일처럼 매번 컴파일하지 않고 한 번만 컴파일하면 된다.
이로 인해 전체 컴파일 시간이 크게 단축되는 효과를 기대할 수 있다.

또 다른 장점으로는, 모듈 내부에서 정의된 매크로가 외부의 매크로 정의에 영향을 받지 않는다는 점이 있다. 이는 코드의 독립성과 예측 가능성을 높여주는 요소다.

이제 본격적으로, 모듈을 실제로 어떻게 사용하는지 살펴보자.

모듈 인터페이스 파일

모듈에서 제공하는 기능에 대한 인터페이스를 정의

필자가 현재 사용 중인 Visual Studio 2022에서는 모듈을 만들면 확장자가 .ixx로 지정된다.
단, 모듈의 확장자는 컴파일러마다 다를 수 있으니 이 점은 참고하자.

모듈에서는 외부에 공개할 대상을 export 키워드로 명시해야 한다.
export가 붙은 요소는 모듈을 import한 클라이언트 코드에서 사용할 수 있지만,
만약 export를 지정하지 않았다면 해당 요소는 모듈 내부에서만 사용 가능하다.

이렇게 export된 모든 선언들을 통틀어 모듈 인터페이스(module interface)라고 부른다.

다음은 person이라는 모듈을 정의한 예시이다.

// person.ixx
export module person;			// 명명 모듈 선언문

export import <iostream>;		// 외부 모듈을 가져오는 선언
import <string>;				// 비공개 import (클라이언트에 노출되지 않음)
export using namespace std;

export namespace Human			// export된 네임스페이스
{
	class Person
	{
	public:
		Person(string firstName, string lastName);
		~Person();
		const string& getFirstName() const;
		const string& getlastName() const;

	private:
		string m_firstName;
		string m_lastName;
	};
}

위 코드에서 export module person;으로 모듈 이름을 선언하고,
Person 클래스와 Human 네임스페이스에 export를 지정함으로써 클라이언트 코드에서 해당 타입들을 사용할 수 있도록 공개하고 있다.

이제 모듈을 main.cpp에서 실제로 사용하는 방법을 살펴보자.

// Main.cpp
import person;

int main()
{
	Human::Person* ps = new Human::Person("VE", "LOG");
	cout << ps->getFirstName() << ps->getlastName() << endl;	// 결과 : VELOG
    
    return 0;
}

위 코드는 person 모듈을 import한 뒤, Person 클래스를 활용하는 예제다.
Person 클래스와 Human 네임스페이스는 export 키워드로 외부에 공개되어 있기 때문에, main 함수에서도 문제없이 사용할 수 있다.

글로벌 모듈 프래그먼트 (Global Module Fragment)

person.ixx 파일을 유심히 본 독자라면, import 선언문뿐 아니라 #include 대신 import를 사용하고 있다는 점에 주목했을 것이다.
하지만 C++에서는 모든 헤더가 import 가능한 것은 아니다. 특히 C 헤더나 전통적인 C++ 헤더는 #include로 사용하는 것이 일반적이다.

이럴 때는 #include를 모듈 상단에 있는 글로벌 모듈 프래그먼트(Global Module Fragment)에 모아두면 된다.

module;					// 글로벌 모듈 프래그먼트
//#include <iostream>
//#include <vector>		// 이곳에 include을 모아서 작성
//#include <string>

export module person;

export import <iostream>;
import <string>;
export using namespace std;

export namespace Human
{
	class Person
	{
	public:
		Person(string firstName, string lastName);
		~Person();
		const string& getFirstName() const;
		const string& getlastName() const;

	private:
		string m_firstName;
		string m_lastName;
	};
}

module; 다음에 나오는 이 부분이 바로 글로벌 모듈 프래그먼트다.
여기에는 #include 지시문을 적어 전통적인 헤더 파일을 포함할 수 있으며, 이 코드는 모듈의 나머지 부분보다 먼저 컴파일된다.

C++ 모듈에서는 export 키워드를 사용해 다양한 요소들을 외부로 공개할 수 있다. 클래스 정의, 함수 프로토타입, 클래스 열거 타입, using 선언문, 디렉티브, 네임스페이스 등등이 있다. 요약하자면, 헤더 파일에서 일반적으로 공개하던 요소들은 대부분 모듈에서도 export로 처리할 수 있다는 점이다.

다음으로는, 모듈 구현 파일(implementation unit)을 어떻게 작성하는지 살펴보자.

export module Math;

// 1) 함수 앞에다가 export
export int Add(int a, int b)
{
	return a + b;
}

// 2) export 블럭으로 애들을 따로 묶어주는
export
{
	void TestExport()
	{

	}

	int TestExport2()
	{
		return 1;
	}

	float TestExport3()
	{
		return 1.5f;
	}
}

// 3) namespace를 지정
export namespace test
{
	int TestExport4()
	{
		return 4;
	}
}

모듈 구현 파일

C++에서는 오래전부터 헤더 파일(.h)구현 파일(.cpp)을 나누어 프로그램을 구성해 왔다.
모듈도 이와 유사한 구조를 갖는다. 일반적으로 모듈 인터페이스 파일(.ixx) 하나와 하나 이상의 구현 파일(.cpp)로 구성된다.

앞서 작성한 person.ixx 모듈 인터페이스에 해당하는 구현 파일 예시는 다음과 같다.

// person.cpp
module person;

Human::Person::Person(string firstName, string lastName)
	: m_firstName{ move(firstName)}, m_lastName{ move(lastName)}
{
	cout << "Person 생성자 호출" << endl;
}

Human::Person::~Person()
{
	cout << "Person 소멸자 호출" << endl;
}

const string& Human::Person::getFirstName() const
{
	return m_firstName;
}

const string& Human::Person::getlastName() const
{
	return m_lastName;
}

구현 파일에서는 import person;이 아닌 module person;을 사용한다.
그 이유는 모듈 구현 파일은 해당 모듈의 일부로 컴파일되기 때문에, import가 아닌 module 키워드로 선언해야 한다.

또한 위 예제에서는 string 헤더를 따로 import하지 않았는데도 문제없이 작동한다.
그 이유는 가시성(visibility)도달성(reachability)이라는 개념 때문이다.

  • string은 인터페이스 파일 내부에서 이미 import되었으므로, 구현 파일은 그 기능에 접근할 수 있음 (도달성 있음).

  • 그러나 string 자체는 보이지 않는다 (가시성 없음). 컴파일러는 이를 인지하고 문제없이 처리할 수 있음

단, 컴파일러가 이를 해결하지 못해 오류가 발생할 경우, 구현 파일에서 명시적으로 import <string>;을 추가해주면 된다.

정리하자면,

  • 모듈 인터페이스 파일(.ixx)에는 클래스 정의, 함수 선언(프로토타입), 공개할 인터페이스 등이 포함

  • 구현 파일(.cpp)에는 해당 기능들의 실제 구현을 작성

이러한 구조로 코드를 분리하면, 구현 코드만 수정되었을 경우에도 모듈 인터페이스를 사용하는 다른 코드들은 재컴파일 없이 그대로 유지될 수 있다.
이는 빌드 시간 단축과 유지보수에 매우 유리한 구조다.

서브모듈

C++ 표준에는 서브 모듈에 대한 설명이 따로 없지만 모듈 이름에 점(.)을 사용하는 것이 허용된다.
이것을 이용해서 개발자가 원하는 계층 구조를 만들 수 있다.

person 모듈에서 person.Arm 이라는 새로운 모듈을 만들어서 사용해 보자.

// person.Arm.ixx
export module Person.Arm;

import <string>;			// 만약 서브모듈에서 export한다면 person.ixx에는 
							// string을 import을 할 필요가 없다.
using namespace std;		// using도 마찬가지이다.

export namespace Human
{
	class Arm
	{
	public:
		Arm(string name);
		~Arm();

	private:
		string _name;
	};
}
// person.ixx
export module person;

import Person.Arm;			// Arm 서브모듈을 import하고 export하기
export import <iostream>;
import <string>;			
export using namespace std;

export namespace Human
{
	class Person
	{
	public:
		Person(string firstName, string lastName);
		~Person();
		const string& getFirstName() const;
		const string& getlastName() const;

	private:
		string m_firstName;
		string m_lastName;
	};
}

이런 식으로 두 개의 모듈을 만들고 person 모듈에 import 하여 사용할 수 있게 만들 수 있다.
물론 서브모듈에 있는 클래스의 메서드 구현을 모듈 구현 파일에서 작성하는 것도 당연히 가능하다.

이처럼 여러 개의 모듈 파일을 계층적으로 나누고 필요한 모듈만 선택적으로 import하는 구조는 유지보수성과 빌드 효율성을 크게 향상시킨다.

// person.cpp
module person;

Human::Person::Person(string firstName, string lastName)
	: m_firstName{ move(firstName)}, m_lastName{ move(lastName)}
{
	cout << "Person 생성자 호출" << endl;
}

Human::Person::~Person()
{
	cout << "Person 소멸자 호출" << endl;
}

Human::Arm::Arm(string name)
	: _name(move(name))
{
	cout << "Arm 생성자 호출" << endl;
}

Human::Arm::~Arm()
{
	cout << "Arm 소멸자 호출" << endl;
}

필자가 서브모듈을 처음 접했을 때, 이름에 "서브(sub)"가 들어가다 보니 자연스럽게 부모-자식 클래스 관계, 즉 상속 관계로 착각했던 기억이 있다.
그래서 Person과 Arm처럼 상속관계에 있는 것처럼 보이는 예시를 만들었지만, 지금 돌이켜보면 적절한 예시는 아니었다.

실제로 서브모듈은 상속과는 아무 관련이 없고,
"하위에 속한 개념" 혹은 "일부 기능을 별도의 단위로 나눈 구성 요소"라고 보는 것이 더 정확하다.

예를 들어, 어떤 DataModel이라는 모듈이 있다면, 그 안에는 Address, PhoneNumber, UserInfo 등 다양한 구성 요소가 있을 수 있다.
이러한 각 구성 요소가 점점 복잡해지고 커진다면, 이를 각각의 서브모듈로 나누는 것이 코드 관리와 빌드 측면에서 훨씬 효율적이다.

서브모듈은 전체 모듈을 가져오는 것이 아니라, 필요한 일부분만 가져와 사용하는 방식이다.

모듈 파티션

서브모듈과 비교했을 때, 상속 관계에 더 가까운 개념은 오히려 모듈 파티션이다.
서브모듈이 외부에 공개되는 구조를 계층적으로 나누는 방식이라면,
모듈 파티션은 모듈 내부에서만 보이는 구조를 정의하는 데 사용된다.

즉, 서브모듈은 "공개된 서브 기능"이고, 파티션은 "내부의 구조적 분할"에 가깝다.

모듈 파티션은 다음과 같은 방식으로 정의하고 사용한다.

// 기본 모듈 인터페이스 파일
export module Leg;

import :R_Foot;		// 모듈 파티션을 선언할 때는 :모듈 이름 이런식으로 작성
import :L_Foot;		// ...
import <vector>;
using namespace std;

export namespace Human
{
	class Leg : public RFoot	// ??? 구조적으로는 어색할 수 있음
	{
	public:
		Leg();
		~Leg();

	private:
		vector<vector<int>> _size;
	};
}
// 모듈 인터페이스 파티션 파일
// R_Foot.ixx
export module Leg:R_Foot;

export namespace Human
{
	class RFoot
	{
	public:
		RFoot() {}
		~RFoot() {}

	private:
		int _rsize;
	};
}

모듈 파티션은 모듈 내부 구조를 나누기 위한 기능으로, 외부에서는 직접 import할 수 없다.
사용하려면 기본 모듈 인터페이스 파일에서 import :파티션이름 형식으로 불러와야 하며,
사용자는 전체 모듈을 import 모듈이름으로 가져와야 파티션 기능을 사용할 수 있다.

즉, 파티션은 모듈 내부 전용 구성 요소로, 외부에 공개되지 않고 내부 구현 정리에 활용된다.
서브모듈이 외부 확장에 초점을 둔다면, 파티션은 내부 설계 개선에 중점을 둔 기능이다.

예제처럼 import Leg:R_Foot;처럼 파티션을 개별적으로 불러오고 싶을 수도 있지만, 그런 시도는 애초에 불가능하다고 생각하는 게 좋다.
그 이유는 파티션 파일 :R_Foot에는 import Leg 선언이 없기 때문이다.
만약 이를 억지로 추가한다고 해도, 곧바로 순환 의존성 문제가 발생하여 구조가 꼬이게 된다.

참고로, 위 예제에는 필자의 설계 실수가 포함되어 있다.
컴파일 자체에는 문제가 없지만, 구조적으로는 어색함이 분명히 존재한다.

원래는 Leg이 Foot을 포함하는 개념이라고 생각했기 때문에, Leg을 부모, Foot을 자식 클래스처럼 구성하려 했다.
하지만 모듈을 실제로 구성하고 사용해 보니, Leg 쪽에서 Foot을 import해야만 사용하는 구조가 되어버렸다.
이 상황에서는 설계상 Foot이 오히려 상위에 있는 것처럼 동작하게 되어, 처음 의도했던 상하 관계와 어긋나게 된다.

물론 복잡한 방식으로 어떻게든 구현은 가능하겠지만, 차라리 의존 구조 자체를 바꾸는 것이 훨씬 단순하고 직관적이라는 판단이 들었고, 이 부분은 필자 스스로 설계 실수였음을 인정한다. ㅠㅠ

마무리

필자는 C++ 모듈이 앞으로 매우 중요한 개념이 될 것이라고 확신한다.
특히 게임 서버 개발과 같이 대규모 코드베이스를 다루는 환경에서는, 모듈이 가져다줄 이점이 상당하다고 느낀다.

현재 개발 중인 네트워크 라이브러리에도 모듈 개념을 적용해보는 중이며,
아직 완성 단계는 아니지만 어느 정도 정리가 되면 관련 내용을 블로그에 따로 정리해 올릴 예정이다.

모듈을 활용하면 메모리 사용량을 줄일 수 있을 뿐만 아니라,
무엇보다도 컴파일 시간을 크게 단축할 수 있다.
필자도 과거에 10~20분씩 걸리는 컴파일 시간 때문에 개발 흐름이 끊기고, 집중력이 떨어지는 경험을 여러 번 했다.
그래서 모듈이라는 개념이 더욱 절실하게 느껴졌고,
앞으로 컴파일 병목을 줄이기 위한 중요한 도구로 적극 활용해보고자 한다.

이번 블로그 글은 여기서 마무리하겠다.
혹시 내용 중에 개념적으로 잘못된 부분이 있다면, 댓글로 알려주시면 감사하겠다.

지금까지 읽어주셔서 감사합니다.
다음 글에서는 보다 가치 있고, 실용적인 주제로 다시 찾아뵙겠습니다!

참고자료

전문가를 위한 C++(개정5판) P590~P604
Inflearn [Rookiss][C++20 훑어보기]
cppreference.com
Modules

profile
게임서버개발자(진)

1개의 댓글

comment-user-thumbnail
2026년 3월 5일

좋은 글 감사합니다

답글 달기