본문 바로가기

Library/C/C++

컴파일 종속성 문제를 가볍게 생각하지 말라

어느 정도 실력 있는 C/C++ 프로그래머라도, 경험해보지 않았다면 주의를 기울이지 않는 것이 컴파일 과정에서의 종속성 문제이다. 30초 이내로 클린 빌드할 수 있는 프로젝트라면, 자료형의 중복 정의를 피하는 것으로도 충분하다. 그러나, 프로젝트를 빌드하는데 걸리는 시간이 긴 편에 속한다면, 정말로 뻔히 보이는 버그조차도 컴파일 시간 때문에 수정하기 어렵게 된다. 즉, 빌드 시간이 길어지면 자신이 무엇을 고치고 무엇을 확인해봐야 하는지 자주 잊어버린다. 놀랍지만 이것은 사실이다! 여기에, 잊어버린 디버깅 상황을 생각해내기 위해 코드를 수정하고 다시 빌드를 시작했을 때 또 엄청난 시간이 소모된다면, 앞서의 상황은 완전히 머리 속에서 사라진다. 간단한 버그조차도 이런 문제 때문에 제대로 잡지 못한다면, 이보다 더 복잡한 디버깅은 아예 엄두도 낼 수 없다. 이렇게 된다면, 잘 돌아가는 프로그램을 만드는 것은 꿈 속에서나 이루어질 일이다.

그렇다면, 이런 끔찍한 상황을 유발하게 하는 컴파일 종속성 문제란 무엇일까? 그것은, 자료형을 정의한 헤더 파일이 지나치게 다른 구현 파일과 연관되어 있는 상황을 말한다. 즉, make 유틸리티와 같은 빌드 자동화 툴들은 타임스탬프를 기준으로 생성해야 할 목적 파일들의 종속성 관계를 생성한다. 예를 들어, a라는 헤더 파일이 foo, bar라는 구현 파일에 모두 포함되었을 때, a를 수정하는 것은 foo, bar를 모두 새로 컴파일하게 된다. 만약, foo를 생성하는 구현 파일만 a에서 선언하고 있는 자료형을 실제로 참조하고, bar는 실제 자료형을 참조할 필요가 없어도 a를 포함하고 있다고 하자. 그렇다면, 링크 과정에서 bar는 다시 재컴파일될 필요없이 그냥 링크만으로 충분하더라도, 다시 컴파일, 링크 과정을 거치게 된다. 만약, 프로젝트 전체에 걸쳐 이런 종속성 문제가 존재한다면, 심각한 경우 헤더 파일, 구현 파일 하나만 수정하는 것으로도 줄줄이 재컴파일, 링크가 일어나게 된다.

이런 컴파일 종속성 문제를 피해가는 디자인적인 방법으로는, pImpl 관용구로 더 잘 알려진 bridge 패턴이 있다. 즉, 실제 구현부를 따로 만들고, 인터페이스는 이 구현에 대한 포인터를 통해 접근하는 것이다. 이렇게 되면, 이 코드를 사용하는 클라이언트는 구현이 바뀌더라도 구현에 대한 헤더 파일이 바뀐 것이고, 구현에 대한 포인터를 포함하는 인터페이스는 변하지 않는다. 따라서, 이 인터페이스를 정의한 헤더를 참조하는 구현 파일들을 재컴파일할 필요는 없어진다. 함수를 호출하면서 포인터를 한번 거쳐서 호출하는 것에 대해 오버헤드를 걱정할지도 모르지만, 극히 짧은 시간에 엄청난 횟수로 호출되어야 하는 함수가 아니라면, 한번 더 메모리를 참조하는 것이 프로그램의 성능을 좌지우지하지 않는다. 물론, 이런 오버헤드조차 아쉬운 경우가 있지만, 대부분의 경우 큰 문제가 아니다.

그러나, 이 방법은 패턴에 대한 이해와 프로그램의 전체 설계를 볼 수 있는 안목을 지녀야 적용할 수 있다. 최소한 이런 구현에 대한 경험이 필수적이다. 따라서, 컴파일 종속성 문제를 처음 접하는 사람이라면, 단지 선언만 필요한 경우와, 컴파일러가 실제 코드를 생성하는데 자료형의 크기와 같은, 명확한 자료형이 필요한 경우를 구별할 수 있는 안목을 키우는 것이 먼저다. 다음을 보자.


#include <fstream>

class Tester
{
public:
    void SeqOpen(std::ifstream &in);
};


위와 같은 클래스를 정의했다고 하자. 이것을 test.h라는 헤더 파일로 정의했다면, 이 파일에는 과연 #incldue <fstream>이 필요한가? 그렇지 않다. 만약, 선언만 있는 헤더 파일이라면 fstream을 포함할 필요는 없으며, SeqOpen()을 구현하는 파일에만 fstream을 포함하면 된다. fstream과 같은 표준 라이브러리는 사실 재컴파일할 필요가 없기 때문에 컴파일 종속 문제를 체감하기 어렵지만, 개발자가 생성한, 컴파일이 필요한 파일들은 이 문제를 조심해야 한다. fstream 대신, Foo라는 클래스가 선언에 필요하다고 하자. 그렇다면, Foo가 선언된 헤더 파일을 포함해야 하는가? 만약, 이 헤더 파일이 선언으로만 이루어져 있다면 굳이 Foo 클래스 전체 선언을 가져올 필요가 없다. 다음과 같은 클래스 전방 선언만 해주면 된다.


class Foo;

class Tester
{
public:
    void SeqOpen(Foo &foo);
};


그리고, Tester를 구현하는 파일에만 Foo 클래스를 포함하는 헤더 파일을 포함해주면 된다. 즉, 헤더 파일은 단지 선언만 제공하기 때문에, 전체 선언이 필요하지 않다. 그러나, 컴파일러가 실제 오브젝트를 생성하는 시점에서는 Foo의 구체적인 자료형을 알아야 하기 때문에, 완전한 Foo 클래스의 전체 선언이 필요하다 . 위와 같이 구성되어 있다면, Foo 클래스를 수정했더라도 Tester 클래스를 선언한 헤더 파일은 아무런 변화가 없으며, 이것은 Tester를 포함하는 다른 구현 파일들을 다시 컴파일할 필요가 없다는 것을 뜻한다. 이런 구성은 프로젝트의 전체적인 빌드 시간을 크게 줄일 수 있다.
 
한 가지 주의점은, 이 헤더 파일이 클라이언트에게 직접적으로 노출되어야 한다면, 클래스 전방 선언으로 생략된 자료형이 클라이언트가 직접 사용해야 하는지 여부이다. 만약 클라이언트가 생략된 구체적인 자료형을 직접 사용해야 한다면, 이것은 잘못된 구조일 수도 있다. 그런 일이 발생하지 않도록 프로그램 디자인을 수정하거나, 헤더 파일들의 역할을 좀 더 명확하게 구별하는 것이 필요하다.

pImpl 관용구는 이보다는 좀 더 세련된 디자인 기법이지만, 근본적인 발상은 위와 같다. 소스 코드를 수정하고, 수정된 부분에 대해서만 최소한으로 컴파일이 이루어지도록 코드를 작성하는 버릇을 들인다면, 언젠가는 크게 도움이 될 때가 있을 것이다.