내가 좋아하지 않는 C/C++의 레거시 by 김민장

C 언어는 컴퓨팅 파워가 매우 척박했던 시절에 만들어진 언어였다. 이후 C99에 이르러 상당히 많은 진화를 하긴 했지만, 워낙 옛날부터 널리 쓰였기에 아무리 발전을 하여도 여전히 적지 않은 레거시(legacy)가 많은 이들에게 고통을 주고 있다. 하위 호환성 유지라는 이유도 있지만 솔직히 “귀찮다. 그냥 냅두자.”와 같은 귀차니즘도 분명 큰 이유다. C의 최대 레거시라면 단연 문자열 형의 부재를 꼽고 싶다. 하지만 C 언어가 나왔을 당시를 생각하면 문자열의 길이를 저장할 공간 조차 배부른 사치였으므로 이해는 한다. 그 다음으로 꼽고 싶은 것은 복잡한 컴파일/빌드 과정과 전처리기이다. 이건 C++에도 고스란히 적용된다. 오늘은 여기에 대한 잡생각.

   1: // Defined getFoo()
   2: #include "foo.h"
   3:  
   4: extern int getMagic();
   5:  
   6: int main(void) {
   7: #ifdef SUPER_MAGIC
   8:   return getMagic();
   9: #else
  10:   return getFoo();
  11: #endif
  12: }

썰렁한 위의 C/C++ 코드를 보고 어떻게 실행될 것인지 예측할 수 있을까?

잠시 딴 소리를 해보자면, C/C++ 언어는 정적 혹은 렉시컬 스코핑(lexical scoping) 언어라서 소스 코드만 보면 이 변수가 어디서 값을 가져 오는지 대부분 알 수 있고 고로 프로그램의 실행 결과도 일반적으로 예측 가능하다. 반대 개념이 동적 스코핑인데, 이를 지원하는 언어는 변수 값의 결정이 실행 시간의 함수 호출 문맥에 달려있다. 그래서 컴파일 시간에는 그 값의 예측이 어렵다. 물론, 지금 이 썰렁한 코드는 정적/동적 스코핑과 아무런 상관이 없다. 그런데 아무리 정적 스코핑인 언어라 하더라도 이 코드만 봐서는 수행을 예측하기 어렵다. 정적 스코핑의 장점을 고스란히 날려버리는 것이다.


코드 해석을 어렵게 하는 레거시 요소를 나열하면 다음과 같다:

(1) 라인 2: “foo.h”가 어디서 오는지 명확히 알 수 없다. 아주 초보자 시절에는 “따옴표는 현재 디렉토리에서 파일을 찾는다”라고 배웠으니 foo.h 파일이 지금 이 소스 파일과 같은 디렉토리에 있을 것이라고 예측할 수 있다. 하지만 현실은 이러하지 않다는 걸 잘 안다. 컴파일러 –I 옵션으로 검색할 #include 디렉토리 목록을 따로 지정할 수 있다. 예를 들어, “foo.h”가 foo라는 디렉토리 밑에 있을 수도 있고 goo라는 디렉토리 밑에 있을 수 있다. 극단적인 예로, “foo.h” 파일이 다른 이름의 디렉토리 밑에 모두 있고, 컴파일러 옵션에서 –I 의 값을 바꿈으로써 다르게 인쿠르드 되는 것도 가능하다. 실제로 이런 경우를 봤다! 더 난해한 경우도 있다. 예를 들어, “foo.h” 파일이 어떤 파서에 의해 빌드시 생성되는 부산물일 수 있다. 그리고 이 파일의 위치는 소스 트리가 아니라 빌드 디렉토리 내에 있을 수 있다. 실제로 이런 경우가 있다. 그러면 아무리 “foo.h”를 소스 트리 내에서 뒤져도 안 나온다.

/src/foo/foo.h
/src/goo/foo.h
/build/generated_code/foo.h

(2) 라인 4: getMagic이 다른 곳에 정의가 되어있다. 링크 시간에 이 정의를 찾아야 하는데 어떤 오브젝트 파일 또는 라이브러리에서 참조해야 하는지 알 수가 없다. 역시 컴파일/링킹 시 옵션에 따라 라이브러리를 바꿔치기 하면 다른 결과가 나올 수 있다. (동적 라이브러리면 더 복잡하겠지만)

(3) 라인 8이 실행될지 라인 10이 실행될지는 SUPER_MAGIC 이라는 #define 값에 의존적이다. 이 소스 코드만 봐서는 SUPER_MAGIC이 정의 되는지 알기 어렵다. 일단, “foo.h”와 “foo.h”가 다시 포함하는 모든 파일을 뒤져야 한다. 있으면 다행인데 없어도 얼마든지 Makefile에서 –D 컴파일러 옵션으로 지정될 수 있다.

이렇게 소스 코드에서 보이지 않는 컴파일링/빌딩 과정에서 코드 해석에 영향을 주는 변수가 많다. 요약하면:

  • -I 로 주어지는 #include 탐색 폴더 목록
  • -D 로 주어지는 각종 #define 값들
  • 링크될 라이브러리

약간 부연 설명하면 이러하다.

-I 옵션은 생각보다 매우 남발된다. 대규모의 프로젝트는 소스 파일이 디렉토리 구조로 이루어져있다. 그런데 많은 곳에서 귀찮으니 각 폴더를 –I 로 해버리고 파일 이름만 #include에 적곤 한다. 이러면 쓰는 사람은 디렉토리 구조를 안 적어도 되는 약간의 편리함이 있다. 또, “../../” 같은 지저분한 상대 경로를 피할 수 있는 장점도 있다. 하지만 처음 접하는 사람에겐 당장 이 헤더 파일이 어디에 있는지부터 찾아야 한다. 당연히 편집기에서 이 파일을 바로 열 수도 없다.

위에서 언급한 –I, –D, 라이브러리 목록은 주로 Makefile에서 정의 된다. 문제는 이 Makefile이 매우 복잡할 수 있다는 것이다.

간단히 하나의 Makefile만 있으면 모르겠는데, Makefile.common 부터 시작해, 복잡한 디렉토리 구조마다 각각 자식 Makefile이 있다. Makefile의 호출 역시 “make FOO=true” 처럼 주어질 수 있다. 그러면 이 FOO 변수에 따라 또 복잡하게 $(CXX_FLAGS) 같은 값이 변경될 수 있다. 그래서 최종적으로 특정 파일이 어떤 컴파일 옵션으로 빌드 되는지 쉽게 알 수 없다. 특히, 남이 만들어 놓은 복잡하고 지저분한 Makefile의 해독은 암호 수준이다. 결국 Makefile을 돌려보면서 무수한 출력 더미 속에서 찾아 내기 일쑤다. 예를 들어, LLVM의 한 소스에 주어지는 컴파일 옵션은 이러하다. (천만 다행으로 Visual Studio에서는 매우 간편히 각 소스 파일이 최종적으로 받는 모든 컴파일 옵션을 볼 수 있다.)

/MP /we"4238" /we"4239" /GS /TP /W3 /wd"4065" /wd"4146" /wd"4180"/wd"4181"
/wd"4351" /wd"4355" /wd"4503" /wd"4551" /wd"4624" /wd"4715" /wd"4800"
/Zc:wchar_t /I"C:/Users/minjang/Downloads/build/lib/Analysis"
/I"C:/Users/minjang/Downloads/llvm-3.2.src/llvm-3.2.src/lib/Analysis"
/I"C:/Users/minjang/Downloads/build/include"
/I"C:/Users/minjang/Downloads/llvm-3.2.src/llvm-3.2.src/include"
/Zi /Gm- /Od /Ob0 /Fd"C:/Users/minjang/Downloads/build/lib/Debug/LLVMAnalysis.pdb"
/fp:precise /D "WIN32" /D "_WINDOWS" /D "_DEBUG" /D "_VARIADIC_MAX=10"
/D "_CRT_SECURE_NO_DEPRECATE" /D "_CRT_SECURE_NO_WARNINGS" /D "_CRT_NONSTDC_NO_DEPRECATE"
/D "_CRT_NONSTDC_NO_WARNINGS" /D "_SCL_SECURE_NO_DEPRECATE"
/D "_SCL_SECURE_NO_WARNINGS" /D "__STDC_CONSTANT_MACROS" /D "__STDC_FORMAT_MACROS"
/D "__STDC_LIMIT_MACROS" /D "_HAS_EXCEPTIONS=0" /D "CMAKE_INTDIR=\"Debug\""
/D "_MBCS" /errorReport:prompt /WX- /Zc:forScope /RTC1 /GR- /Gd /MDd /Fa"Debug"
/nologo /Fo"LLVMAnalysis.dir\Debug\" /Fp"LLVMAnalysis.dir\Debug\LLVMAnalysis.pch"

사실 분석하면 별 거 아니긴 하다. 하지만 이런 미세한 옵션 하나하나가 프로그램 결과에 영향을 줄 수도 있다.

그래도 이건 양반이다. Makefile? 까짓 것 그냥 하나하나 한줄한줄 읽어가며 머릿 속으로 빌드하면 된다. 그런데 안드로이드의 복잡한 빌드 시스템이라면? 실제 이런 걸로 엄청 고생하기도 했다. 안드로이드 빌드 파일(.mk)에서 –D 옵션 설정이 내 예상과 달리 작동해 엄청 고생했다. 더 이상의 자세한 설명은 생략 한다… (CMake도 플랫폼 독립적으로 기술한다는 장점이 있지만 Makefile이 가지는 어려움을 본질적으로 감추지는 못한다고 생각한다.)

 

그래서 해결책은?

이런 문제는 결국 C/C++의 Makefile 기반의 빌드 시스템과 전처리기가 굉장히 낡았기 때문이다. 거기에 익숙한 개발자들은 이런 걸 고치려 하지 않는다. 그렇지만 최대한 이런 어려움을 줄일 수 있는 방법은 분명 있다. 기본 철학은 이러하다.

소스 코드에 최대한 많이 빌딩(컴파일/링킹) 정보를 유추할 수 있도록 하여 소스 하나만 봐도 쉽게 이해할 수 있도록 한다.

(1) –I 는 최소한으로 한다.

-I 를 전혀 쓰지 않으면 언급했듯이 “../../” 같은 상대 경로를 써야 하므로 오히려 지저분해진다. 그래서 대안으로 소스 프로젝트의 루트 위치, 혹은 대표 상위 몇 개만 최소한으로 지정한다. 그리고 헤더 파일은 그 루트를 시작으로 디렉토리 구조를 다 적어가며 쓴다. 좋은 예로 페이스북의 HipHop PHP 컴파일러를 들 수 있다. 소스 파일 하나를 살펴보면:

   1: #include <compiler/construct.h>
   2: #include <compiler/parser/parser.h>
   3: #include <util/util.h>
   4:  
   5: #include <compiler/analysis/file_scope.h>
   6: #include <compiler/analysis/function_scope.h>
   7: #include <compiler/analysis/class_scope.h>
   8: #include <compiler/analysis/analysis_result.h>
   9: #include <compiler/analysis/ast_walker.h>
  10:  
  11: #include <compiler/statement/function_statement.h>
  12:  
  13: #include <compiler/expression/simple_function_call.h>
  14: #include <compiler/expression/simple_variable.h>
  15: #include <compiler/expression/closure_expression.h>
  16: #include <iomanip>
  17:  
  18: using namespace HPHP;
  19:  
  20: ///////////////////////////////////////////////////////////////////////////////
  21:  
  22: Construct::Construct(BlockScopePtr scope, LocationPtr loc)
  23:     : m_blockScope(scope), m_flagsVal(0), m_loc(loc),
  24:       m_containedEffects(0), m_effectsTag(0) {
  25: }

보다시피 상당히 깔끔하다. 실제 소스 폴더 구조는 hphp 라는 루트 폴더 아래에 compiler, utils, hhvm 등이 있다. compiler 아래에는 다시 analysis, statement 등이 있다. #include 만 봐도 바로 위치를 알 수 있고 어떤 목적으로 포함했는지도 쉽게 유추할 수 있다.

부디 복잡한 폴더 구조가 있음에도 -I로 다 때려 넣고 달랑 헤더 파일 이름만 적는 만행은 하지 말자.

 

(2) Makefile에서 정의되는 –D 역시 최소한으로 한다. 아, 그냥 #ifdef 자체를 줄이는 것이 좋다.

C/C++은 컴파일러가 묵시적으로 포함하는 #define 값이 있다. 예를 들어, GCC의 C 컴파일러는 이런 값을 미리 정의하고 있다. 이런 것은 어쩔 수 없다고 치자. 또, 상당히 범용으로 쓰이는 일부 직관적인 #define 값은 소스 파일 외부에서 정의되어도 큰 어려움이 없다. 대표적인 녀석들로 NDEBUG, DEBUG, ASSERT 같은 걸 들 수 있다. 그런데 그 외의 값은 정말 Makefile에서 마구 정의하는 것을 줄이도록 해보자.

보통 Makefile에는 여러 빌드 구성(configuration)이 있고 여기에 따라 –D 값이 바뀌는 것이 일반적이므로 Makefile에서 –D를 하는 것은 사실 자연스럽다. 하지만 최상위 값 하나, 또는 최소한만 –D로 넘어오도록 하고 영향을 받는 나머지 값들은 얼마든지 소스 코드 내에서 정의되도록 할 수 있다. 예를 들어, Makefile에서는 BUILD_FOO, BUILD_GOO 같은 것만 내려 보낸다. 그리고 이 값으로 globalDefines.h 같은 곳에 필요한 값이 정의되도록 한다.

그런데 무엇보다 아예 #define 값에 의존적인 조건부 컴파일 자체를 최소화 해야 한다. 하지만 옛날 코드를 보면 쓰이는 곳이 많다. 또, 코드 크기와 성능 문제로 조건부 컴파일을 어쩔 수 없이 해야 할 때가 여전히 빈번하다.

 

(3) 링크될 정보 역시 소스 파일에 기록하자. 안 되면 주석이라도 달자.

어떤 함수를 외부에서 끌어다 쓸 때 별도의 링크할 라이브러리가 필요하면 어떻게 해서든 그걸 소스 파일에 드러내는 것이 좋다고 생각한다. 사실 이건 빈번한 경우가 아니니 사소하다고 볼 수 있지만 거대한 프로젝트에서 발생되는 외부 심볼 못 찾는 링크 에러는 시간 잡아 먹기 좋은 레거시다. 윈도우에서 코딩할 때는 프로젝트 옵션 (일종의 Makefile)에 같이 링킹할 .lib 목록을 적지 않고 소스 코드에 바로 적는 방법을 썼다:

#pragma comment(lib, “foo.lib”) 

이 방법은 비표준 방법이라 이식성이 없다. MSVC는 #pragma comment 기능으로 링크할 라이브러리를 지정할 수 있다. GCC 대응은 안 찾아봤다.

비슷한 예로 특정 소스 파일에서 링커 최적화 옵션을 조작하고 싶을 때가 있었다. COMDAT folding 이라는 링커 최적화를 반드시 꺼야 할 때가 있었다. 역시 나는 프로젝트 세팅에 기입하지 않고 #pragma comment(linker, …)을 활용하였다.

 

정리

사실 상 모든 큰 프로젝트의 C/C++ 소스 파일은 파일 하나만 봐서는 어떻게 컴파일 되는지, 어떻게 작동되는지 알기 매우 어렵다. 나는 최대한 많은 정보를 소스 파일에 적었으면 한다. 그래서 정말 궁극적으로는 파일 하나만 에디터에 올려도 코드 분석과 컴파일(오브젝트 파일로)이 되는 정도가 되면 좋겠다.

하지만… 이런 일은 10년이 지나도, 20년이 지나도 이뤄지지 않을 것 같다. 세상은 터치에, 레티나에, 이제는 눈동자의 움직임도 인식하는 세상인데, 여전히 이곳은 구닥다리 커맨드 라인 인터페이스가 지배하고 있으니…

p.s. C#이나 다른 고급 언어는 어떠한지는 잘 몰라서 비교 분석을 하지 못한 것이 아쉽다. 그래도 설마 C/C++ 만큼이나 지저분할까.

공유하기 버튼

 
싸이월드 공감트위터페이스북

1 2 3 4 5 6 7 8 9 10 다음