본문 바로가기

진리는어디에/C++

[C++20] 당신이 모듈(module)을 써야만 하는 이유

들어가며

C++의 모듈(module)은 concepts, ranges, coroutine과 더불어 'Big Four'이라고 불려지며 C++20 스펙에 추가된 네 가지 주요 요소 중에 하나다.

모듈은 기존 헤더 파일을 대체할 수 있을 뿐만 아니라, 컴파일 시간 개선, 매크로 격리(macro isolation, 용어가 생소하지만 뒤에 설명이 나온다)등을 제공한다. 간단하게 정리하면 #include 전처리 방식을 대신할 수 있는 새로 도입된 개념으로써 java의 package나 C#의 namespace와 동일한 역할을 한다. 

모듈의 도입으로 C++에 어떠한 변화가 생겼는지 알아 보기 위해 우리는 기존 C++은 어떤 모습이었는지 먼저 살펴 볼 필요가 있다. 기존 C++의 구조를 보기 위해 그 유명한 "Hello World" 프로그램 부터 시작해 보도록 하자.

// helloWorld.cpp

#include <iostream>

int main() {
    std::cout << "Hello World" << std::endl;
}

100 바이트였던 helloworld.cpp를 컴파일하면 130배 사이즈가 증가한 실행 파일을 생성한다.

스크린샷의 숫자 100과 12928은 바이트 수를 나타낸다

과연 내부에서 무슨 짓을 했길래 겨우 100바이트 짜리 소스코드가 만바이트가 넘는 파일이 되었을까? 이를 알아보기 위해 고전적(?)인 C++의 빌드 과정을 살펴 보도록하자.

고전적인 빌드 프로세스

C++의 빌드 과정을 보면 전처리(preprocessing), 컴파일(compileation), 링킹(linking)의 세 단계로 구성된다.

전처리

전처리 프로세스에서는 #include, #define, #ifdef등 과 같은 전처리 지시문을 처리한다. 특히 전처리기는 #include 지시문을 해당 파일의 내용들로 치환하고, #define은 지정된 문자열 그대로를 소스 코드에 적용시킨다. 여기서 #include가 지정된 파일을 그대로 치환한다는 것을 기억하고 있도록 하자.

컴파일

컴파일러는 C++ 소스 코드를 분석하고 어셈블리 코드로 변환한다. 컴파일의 결과로 생성된 파일들을 오브젝트 파일이라고 하며 컴파일 된 코드를 바이너리 형태로 저장한다. 이런 오브젝트 파일들은 나중에 재사용을 위해 라이브러리 형태로 만들어 질수도 있다.

링킹

컴파일러가 생성한 오브젝트 파일들을 입력으로 받는다. 링킹의 결과로 실행 파일이 만들어질 수도 있고 정적 또는 공유 라이브러리가 만들어질 수 도 있다. 링킹 단계에서 정의 되지 않은 기호(함수나 변수를 말한다)에 대한 참조 검사를 수행하며 참조에 대한 기호를 찾을 수 없거나 중복 정의된 기호가 있다면 오류를 발생 시킨다.

고전 빌드 프로세스의 문제점

앞에서 간단히 모듈이 추가 되기 이전의 고전 빌드 프로세스에 대해서 살펴 보았다. 그럼 무엇이 문제였길래 C++20에서는 모듈을 추가하여 해결하려고 했던것일까?

반복적인 헤더파일의 치환(substitution)

앞에서 전처리 과정에서 #include 전처리 지시자는 지정된 파일의 내용으로 치환된다고 이야기 했다. #include의 반복적인 치환이 무엇인지 어떻게 일어나는지 설명하기 위해 앞의 HelloWorld 예제를 수정해 보도록 하겠다.

먼저 프로그램에 hello.cpp와 world.cpp 두 개를 추가했다(물론 각각에 필요한 헤더 파일들도 추가 되었다). hello.cpp는 hello 함수를 제공하고 world.cpp는 world 함수를 제공한다. 프로그램의 실행 결과는 동일하겠지만 프로그램의 구조는 달라졌다.

  • hello.h & hello.cpp
// hello.h

#include <iostream> // <- 주목

void hello();
// hello.cpp

#include "hello.h"

void hello() {
    std::cout << "hello ";
}
  • world.h & world.cpp
// world.h

#include <iostream>  // <- 주목

void world();
// world.cpp

#include "world.h"

void world() {
    std::cout << "world";
}
  • helloWorld.cpp
// helloWorld.cpp

#include <iostream> // <- 주목

#include "hello.h"
#include "world.h"

int main() {
    
    hello(); 
    world(); 
    std::cout << std::endl;
    
}

프로그램을 빌드하고 실행하면 아래와 같이 기대한대로 잘 동작한다.

그런데 무슨 문제가 있을까? 앞에서 #include 전처리기는 각 소스파일 단위로 실행된다고 했다. 그 말은 즉, 모든 cpp에서 include하고 있는 헤더 파일 <iostream>은 hello.cpp, world.cpp, helloWorld.cpp에 각각 별도로 포함된다는 의미다. 결과적으로 각 소스파일들은 50만라인 이상으로 확장되며 각 cpp가 컴파일 될때 같이 컴파일 되며 컴파일 시간을 낭비하고 있다. 하지만 이와는 대조적으로 module은 단 한번만 가져온다.

모듈은 빌드 시간을 극적으로 줄여 준다

어느 정도 규모가 있는 C++ 프로젝트를 경험해본 사람이라면 작업 시간 중 얼마나 많은 시간이 빌드가 완료되길 기다리면 소모 되었는지  뼈져리게 느꼈을 것이다. module을 사용하면 이러한 시간을 대폭 줄일수 있다.

또한, 모듈에 선언 된 매크로, 전처리기 지시문 및 내보내지 않는 이름은 변환 단위(cpp파일)에서는 볼수 없으므로 모듈을 가져오는 변환 단위를 컴파일할 때 영향을 주지 않는다. 헤더 파일에 한 글자 고치고 전체 프로젝트를 다시 빌드해야 했던 경험이 있는 사람이라면 이것이 빌드에 소모 되는 얼마나 많은 시간을 줄여 줄 수 있을지 예상할 수 있으리라 생각한다.

모듈이 얼마나 나은 컴파일 시간을 제공하는 지는 [C++20] 모듈(module) 컴파일 성능 비교에서 확인 할 수 있다.

매크로 격리

앞에서 '매크로 격리'라는 생소한 용어를 사용했다. 이번 섹션에서는 module에서 제공하는 '매크로 격리'가 무엇인지 살펴 보도록 하겠다. 매크로는 C++의 문법, 의미 체계(semantic)를 무시한 단순 텍스트 치환에 불과하다. 여기서 많은 문제가 발생 한다(그래서 C++에서는 상수를 정의할 때 매크로 대신 const를 사용하라고 한다). 예를 들어 매크로를 포함하는 순서에 따라 다른 결과를 만들어 낸다거나, 이미 정의된 매크로로 또는 기존 코드와 충돌할 수 있다.

여러분이 아래의 webcolors.h와 productioninfo.h 를 작성해야 한다고 가정해보자.

// webcolors.h

#define RED   0xFF0000
// productinfo.h

#define RED   0

위 두 헤더 파일을 client.cpp에서 include 한다고 했을때, include 순서에 따라 RED는 서로 다른 값을 가지게 된다. 이러한 방식은 오류가 발생하기 쉽다. 하지만 module은 가져오는 순서에 따라 결과가 달라지지 않는다.

#define에 정의된 값은 include 순서에 따라 결과가 달라지지만
module은 순서와 상관 없이 결과가 동일하다

기호(함수, 변수)의 다중 정의

C++에서 함수 정의는 아래와 같은 규칙을 가지고 있다.

  • 함수는 번역 단위(cpp)에서 둘 이상의 정의를 가질수 없다.
  • 함수는 프로그램에서 둘 이상의 정의를 가질수 없다.
  • 외부 연결이 있는 인라인 함수는 둘 이상의 정의를 가질 수 있지만, 동일한 정의여야만 한다.

아래 예제에서 함수 정의 규칙을 위반하는 프로그램을 링킹하려고 할 때 링커가 어떠한 에러를 발생시키는지 살펴 보도록하자. 예제 코드에는 header.h, 그리고 header.h를 포함하는 header2.h 두 개의 헤더 파일이 있다. main에서 header.h, header2.h를 포함하게 된다면 header.h는 두 번 포함되는 것과 같으므로 func함수는 두번 정의 된다. 이는 '함수는 프로그램에서 둘 이상의 정의를 가질수 없다'는 규칙을 위반하는 것이다.

// header.h

void func() {}
// header2.h

#include "header.h"
// main.cpp

#include "header.h"
#include "header2.h"

int main() {}

위 프로그램을 빌드하면 링커는 func의 정의가 여러번 발견되었다는 에러를 발생 시킨다.

물론 헤더 파일에 #ifndef, #define 또는 비주얼 스튜디오의 #pragma once와 같은 인클루드 가드를 이용해 위와 같은 문제를 해결할 수도 있다.

// header.h

#ifndef FUNC_H
#define FUNC_H

void func(){}

#endif

하지만 module에서는 필요하지 않다.

컴파일 속도 향상

모듈을 사용하면 cpp에 인클루드 되는 헤더 파일에 대한 코드 리플레이싱과 그에 따른 중복된 컴파일 오버헤드를 줄여 컴파일 속도를 극적으로 줄여 준다. 이에 대한 자세한 내용은 [C++20] 모듈(module) 컴파일 성능 비교 포스트에서 다루고 있다.

마치며

포스트를 마치기 전에 지금까지 살펴 보았던 module이 필요한 이유에 대해 요약해보도록 하겠다.

  • 전처리 시 #include와 달리 모듈은 한번만 가져오므로 빌드 시간을 단축할 수 있다(이게 엄청난 장점이다).
  • #define과 달리 모듈은 가지고 오는 순서에 상관 없이 항상 동일한 결과를 가진다.

다음 포스트에서는 모듈을 직접적으로 사용하는 방법에 대해 살펴 보도록하겠다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!