이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 5월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.
C++/CLI가 어렵다 어렵다 하는데 이 말을 단순히 프로그래밍 측면에서만 바라봐선 안 된다. 기존 코드에 CLR 기능을 넣어 확장하는 일에 주로 쓰는 만큼 프로젝트 구성도 많이 바뀌게 된다. 컴파일 옵션부터 링크 옵션까지 구석구석 파고들어야 비로소 원하는 결과를 얻는다.
C++/CLI를 다루면 난해한 문법에 막힐 때가 많다. C++, C#을 따로 배우는 것보다 C++/CLI를 배우는데 더 시간이 걸렸다. 개인적인 편견일지 모르나 그만큼 언어 습득이 어렵고, 이에 동의하는 개발자도 꽤 있다. 그러나 문법은 MSDN 라이브러리를 뒤적이면 어떻게든 해결이 된다. 오히려 진짜 큰 도전은 프로그래밍 언어가 아니라 프로젝트 구성이다.
대부분의 참고 서적은 C++/CLI의 문법이나 닷넷 프레임워크를 소개하기 바쁘고 우리가 실무에서 겪을 고민은 다루지 않는다. 기존 코드를 확장할 땐 실제로 프로그래밍적인 문제보단 컴파일이나 링크 최적화 때문에 더 고민을 하고 더 고생을 한다. 그럼에도 이런 문제를 다룬 지침서는 본 적이 없다.
앞으로 다룰 내용은 모두 개인적인 경험에 근거한다. 글 쓰는 자신조차 이것이 최적인지 확신하진 못한다. 다만, 하나의 성공 사례로써 그 가치가 있다고 생각한다. 여러분은 이 방법이 최선의 선택이자 바이블이라고 생각하지 말고, 더 나은 방법은 없는지 고민해보길 바란다. 그리고 적절한 대안을 찾아냈다면 모쪼록 다른 사람들도 알 수 있게 어떤 식으로든 공개해줬으면 하는 소박한 바램이 있다.
정적 라이브러리를 동적 라이브러리도 바꾸기
지난 칼럼에서 우리가 한 일을 되짚어보자.
3월에 만든 TimeLib(서브버전 저장소 주소: https://imaso.googlecode.com/svn/trunk/200902-TimeLib)은 크게 세 프로젝트로 구성된다.
-
TimeLib
-
UnitTest++
-
TimeLibTest
TimeLib이 주 프로젝트이고 TimeSpan 등의 네이티브 클래스를 구현하는 주체이다. UnitTest++은 단위 테스트 라이브러리이다. TimeLibTest은 UnitTest++을 활용해 TimeLib이 제공하는 기능을 테스트한다. 이 프로젝트 세 개는 모두 정적 라이브러리(Lib)였고 아직 관리되는 클래스는 없었다. 아니, 사실은 LogManaged란 관리되는 클래스가 있긴 했다. 하지만 어떤 라이브러리도 이 클래스를 실제로 쓰진 않았기 때문에 문제될 게 없었다.
이쯤에서 이 칼럼에서 다루는 시나리오를 떠올려보자. 우리는 CLR 기능이 없는 순수 네이티브 C++ 라이브러리 또는 응용프로그램을 다루는 개발자다. 그런데 어느 날 무슨 이유에선지 기존 코드에 CLR을 붙이기로 결정됐다. C++용 XML 파서의 복잡성 때문에 머리가 깨질 것 같다는 이유일 수도 있고, 아니면 그저 .NET Framework 개발자가 되는 게 일생의 소원이었기 때문일 수도 있다. 이유야 어떻든 C++/CLI란 최신 기술로 기존 네이티브 라이브러리를 닷넷에서 사용 가능하게 포장하기로 한 상황이다.
이 같은 시나리오에 따라 4월 칼럼에선 LogManaged 클래스를 C# 코드에서 호출해봤다. 그 과정은 무척 간단했으니, TimeLib.lib을 TimeLib.dll로 빌드하고, C# 프로젝트에서 TimeLib.dll을 참조했을 뿐이다. 그런 후 C# 소스 파일에 LogManage.Write("does it work?");라고 적으니 콘솔 창에 "does it work?"란 메시지가 떴다.
참 쉽다. 그렇지 않은가?
명석한 여러분은 그렇지 않다는 사실을 눈치챘으리라 믿는다. 문제는 정적 라이브러리(Lib)을 동적 라이브러리(Dll)로 바꾸는 부분이다. TimeLib을 동적 라이브러리로 바꾸는 거야 어렵지 않다. 프로젝트 속성 창에 가서 구성 형식을 동적 라이브러리로 바꾸기만 하면 된다. 그러나 TimeLib을 참조하는 다른 프로젝트는 어떨까? 여기선 TimeLib을 테스트하는 TimeLibTest가 그 당사자가 되는데, 당연한 이야기지만 빌드가 깨진다.
동적 라이브러리는 정적 라이브러리보다 참조하기가 어렵다. 물론 그에 상응하는 장점도 있지만, 레가시 코드의 구성 형식을 바꾼다면 그야말로 재앙이나 다름없다. 동적 라이브러리에 있는 함수나 클래스를 가져다 쓰려면 dllexport라는 방식을 취해야 한다.
#define DllExport __declspec( dllexport ) class DllExport C { int i; virtual int func( void ) { return 1; } };
이런 식으로 dllexport로 지정한 함수나 클래스만 외부에서 접근 가능하다. 이 외에도 몇 가지 제약이 있는데, 소스 코드가 몇 백 줄에 불과하면 해결하기 어렵지 않지만 보통 수만 줄에 달하는 레가시 코드를 모두 이런 식으로 이전하기는 쉽지 않다. 기술적으로 가능하더라도 그만한 시간과 노력을 기울일 이유가 있을까?
만약 여러분의 레가시 코드가 손대기 무서울 정도로 거대하진 않다면 한번 시도해봐도 좋겠다. 이렇게 칼럼을 통해 경험담과 지식을 전달하는 입장이지만 모든 걸 해보진 않았다. 업무에서 다루는 서버 엔진은 상당히 규모가 컸기 때문에 dllexport 방식은 도저히 엄두를 내지 못했다. 만약 dllexport 방식을 취한다면 핵심인 엔진뿐 아니라 그 위에 놓인 데이터 접근 계층과 비즈니스 계층이 모두 대대적인 수술을 받아야 할 상황이었다. 이런 상황에서 여러분이 엔진과 그 위의 계층을 모두 설계한 사람 중 한 명이라면 dllexport 방식에 도전해도 좋을지 모른다. 그러나 그런 입장이 아니었기에 다른 접근 방식을 취했다. 양쪽 모두를 경험한 건 아니니 솔직히 어느 쪽이 더 나은지는 모른다. 기회가 닿아 dllexport 방식을 적용하게 되면 또 자리를 마련해 노하우를 나누기로 하고, 이번 시간엔 또 다른 방식에 대해 알아보기로 하자.
준비 과정
지금부터 실습할 내용은 모두 Google Code를 통해 제공된다. 저장소 주소는 https://imaso.googlecode.com/svn/trunk/200905-TimeLib 이다. 서브버전 클라이언트를 이용해 소스 코드를 내려 받거나 프로젝트 홈페이지로 와서 확인하면 된다. 그런데 200905-TimeLib은 실습 과정이 완결된 최종 소스 코드를 담기 때문에 여러분이 그 과정을 알려면 변경 내역에서 리비전 15번부터 하나씩 살펴보던가 지금부터 설명하는 것을 따라 해야 한다.
우선 https://imaso.googlecode.com/svn/trunk/200903-TimeLib 을 내려 받는다. 여기엔 DLL로 빌드한 TimeLib과 TimeLib을 참조하는 C# 프로젝트 LogManagedTest가 있다. 원래 TimeLibTest와 UnitTest++이 있었지만 TimeLib을 동적 라이브러리로 변환하면서 빌드가 깨졌기 때문에 두 프로젝트를 제거한 바 있다. 그러니까 레가시 라이브러리는 CLR을 지원하도록 바꿨지만 아직 그 라이브러리를 참조하는 프로젝트는 손대지 못한 상황이다.
이제 https://imaso.googlecode.com/svn/trunk/200902-TimeLib 를 내려 받자. 여기엔 UnitTest++과 TimeLibTest가 있다. 두 프로젝트를 아까 받은 200903-TimeLib에 넣으면 준비는 끝난 셈이다. 지금부터는 TimeLib을 참조하는 TimeLibTest 프로젝트가 제대로 빌드되고 작동하도록 고치는 여정을 떠난다.
여정의 시작
우선 TimeLib 솔루션 파일(TimeLib.sln)을 연다. 비주얼 스튜디오의 솔루션 탐색기엔 LogManagedTest와 TimeLib만 있다. 여기에 UnitTest++과 TimeLibTest 프로젝트를 추가하자. 이제 [프로젝트 – 프로젝트 종속성] 메뉴를 선택한다. 그런 후 [그림 1]처럼 설정한다. TimeLibTest 프로젝트를 고르고 이 프로젝트가 TimeLib과 UnitTest++에 종속적이란 걸 명시한다. 이제 TimeLibTest의 속성 페이지로 간다. [C/C++ – 추가 포함 디렉터리]에 "..\TimeLib" 경로를 추가한다. 이로써 추가 포함 디렉터리는 "..\UnitTest++\src"; "..\TimeLib" 가 된다.
꼭 필요한 절차는 아니지만 [TimeLib 속성 페이지 – C/C++ – 전처리기 – 전처리기 정의]에서 _LIB 을 빼면 더 좋다.
모든 게 단위 테스트가 잘 돌던 2월, 3월 무렵으로 돌아갔다. 단지 관리되는 코드가 TimeLib에 추가됐다는 점만 다르다. 이제 [빌드 – 일괄 빌드] 메뉴로 가 Win32 플랫폼에 속하는 모든 빌드를 선택하고 ‘다시 빌드’를 해보자. 어떤가? 혹시 컴파일 오류가 나는가? 만약 다음과 같은 링크 오류만 발생하면 제대로 된 것이다.
LINK : fatal error LNK1181: '..\debug\timelib.lib' 입력 파일을 열 수 없습니다.
다른 오류 없이 여기까지 왔다면 이제 모든 준비가 끝났다. 링크 오류만 해결하면(쉽진 않지만), 우리가 원하던 대로 된다. 기존 라이브러리를 최대한 그대로 두면서 CLR 기능을 추가하기!
문제 해결, 그 첫 번째 시도
이제껏 검토한 문제 때문에 한참 고민한 적이 있다. 시간은 넉넉했지만 며칠 동안 적절한 방안을 찾지 못해 마음이 편치 않았다. 안타깝게도 C++/CLI 관련 책은 미국에도 몇 권 안 되고, 대규모 레가시 코드를 확장해본 경험 많은 개발자도 많지 않았다. 설사 그런 개발자가 많더라도 경험담을 공유하진 않았다. 그러던 중에 “라이브러리 종속성 입력”을 사용하면 문제를 해결할 수 있다는 짧은 글을 MSDN 포럼에서 읽게 됐다. 당시로선 확실한 정보가 아니라도 시도할 가치가 있었다. 달리 방도가 있지도 않았고. 그리하여 끈질기게 삽질을 계속 했다. 이 글에선 그 삽질 과정을 그대로 따라간다. 곧바로 정답을 내기보단 실수를 검토하고 더 나은 방법은 없는지 여러분 스스로 판단해보길 바란다.
포럼에서 힌트를 듣고 라이브러리 종속성 입력을 사용해보기로 했다. 그런데 대체 라이브러리 종속성 입력이란 무엇인가? MSDN의 설명부터 읽어보자.
큰 프로젝트에서는 종속 프로젝트가 lib 파일을 생성할 때 증분 링크가 사용되지 않습니다. .lib 파일을 생성하는 종속 프로젝트가 많이 있으면 응용 프로그램 빌드 시간이 오래 걸릴 수 있습니다. 이 속성이 예로 설정되면 프로젝트 시스템은 종속 프로젝트가 생성하는 .lib의 .obj 파일에 링크하여 증분 링크를 활성화합니다.
말이 복잡한데 결론은 간단하다. 원래는 TimeLib의 obj 파일을 한번 모아서 TimeLib.lib을 생성한다. TimeLibTest를 빌드할 땐 TimeLib.lib를 이용할 뿐 TimeLib의 obj 파일을 다시 링크하진 않는다. 그런데 TimeLibTest에 종속성 입력을 활성화하면, TimeLibTest을 링크할 때 TimeLib의 obj 파일을 또 다시 링크하게 된다. 똑같은 과정을 두 번 반복하는 셈이다. 그래도 어쩌겠는가? 당시로선, 그리고 지금도 더 나은 방법을 찾지 못 했다.
이제 [TimeLibTest 속성 페이지 – 링커 – 일반 – 라이브러리 종속성 입력 사용]을 “예”로 바꾼다. 그러면 [목록 1이던 명령줄이 [목록 2]처럼 바뀐다.
- 목록 1. TimeLibTest의 링크시 명령줄
-
/OUT:"X:\imaso0905-TimeLib\Debug\TimeLibTest.exe" /INCREMENTAL /NOLOGO /MANIFEST /MANIFESTFILE:"Debug\TimeLibTest.exe.intermediate.manifest" /MANIFESTUAC:"level='asInvoker' uiAccess='false'" /DEBUG /PDB:"X:\imaso0905-TimeLib\Debug\TimeLibTest.pdb" /SUBSYSTEM:CONSOLE /DYNAMICBASE /NXCOMPAT /MACHINE:X86 /ERRORREPORT:PROMPT kernel32.lib user32.lib gdi32.lib winspool.lib comdlg32.lib advapi32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib odbc32.lib odbccp32.lib "..\debug\unittest++.lib" "..\debug\timelib.lib"
- 목록 2. 바뀐 명령줄
-
/OUT:"X:\imaso0905-TimeLib\Debug\TimeLibTest.exe" /INCREMENTAL /NOLOGO /MANIFEST /MANIFESTFILE:"Debug\TimeLibTest.exe.intermediate.manifest" /MANIFESTUAC:"level='asInvoker' uiAccess='false'" /DEBUG /PDB:"X:\imaso0905-TimeLib\Debug\TimeLibTest.pdb" /SUBSYSTEM:CONSOLE /DYNAMICBASE /NXCOMPAT /MACHINE:X86 /ERRORREPORT:PROMPT kernel32.lib user32.lib gdi32.lib winspool.lib comdlg32.lib advapi32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib odbc32.lib odbccp32.lib "..\unittest++\obj\unittest++\debug\timehelpers.obj" 중략…… "..\unittest++\obj\unittest++\debug\xmltestreporter.obj" "..\debug\timelib.lib"
이제 다시 빌드를 해보면 짜잔~
LINK : fatal error LNK1181: '..\debug\timelib.lib' 입력 파일을 열 수 없습니다.
이런! 여전하다.
이 문제는 프로젝트 종속성 때문이다. C++ 프로젝트가 다른 C++ 프로젝트를 참조하면 비주얼 스튜디오는 정적 라이브러리 링크를 시도한다. 그래서 DLL로 바꾼 TestLib 프로젝트에서 timelib.lib 파일을 찾는 것이다. 실제로 [목록 2]에는 이상한 점이 있다. TimeLibTest는 분명 TimeLib과 UnitTest++을 모두 참조하는데, [목록 2]에선 UnitTest++의 obj 파일만 보인다. 그리고 맨 끝에 “..\debug\timelib.lib”이란 대목이 있다.
이런 현상을 막는 비효율적인 방법이라면 프로젝트 종속성을 없애는 것이다. 종속성을 없애면 TimeLibTest가 TimeLib보다 먼저 빌드될 가능성도 있지만 이런 일은 막기 쉽다. [프로젝트 – 프로젝트 빌드 순서]에서 조정 가능하기 때문이다.
종속성을 없앤다고 해서 TimeLib의 obj 파일을 곧바로 링크할 리는 없다. 종속성이 없으니 두 프로젝트의 관계를 비주얼 스튜디오가 알 리 없다. 그래서 [TimeLibTest 속성 페이지 – 링커 – 입력 – 추가 종속성]에 “TimeLib\$(ConfigurationName)\*.obj”를 추가한다. 수동으로 obj 파일을 가져다 링크하도록 만들면 문제가 해결된다. 실제로 이쯤에서 TimeLibTest를 빌드하고 돌려보면 다음과 같이 잘 작동한다.
Success: 6 tests passed. Test time: 0.04 seconds. 계속하려면 아무 키나 누르십시오 . . .
참고. 이 처리 방식을 재현해보고 싶다면 소스 코드 저장소에서 리비전 18을 내려 받으면 된다.
더 나은 시도
처음엔 위의 방법에 만족했다. 링크를 한번 더 하니 빌드 속도가 느려지긴 했지만 레가시 코드를 전부 뜯어고치는 것보단 낫지 않은가? 그러나 이 방법에도 문제가 있었으니, 특정 빌드시 미묘한 오류가 발생했다. 안타깝게도 이 문제는 간단한 샘플로는 재현하기 힘들다. 그러나 원인은 분명한데 “TimeLib\$(ConfigurationName)\*.obj”이 골치거리였다. 특정 디렉터리에 있는 Obj 파일을 모두 링크를 했기 때문이다. TimeLib\Debug\ 폴더에 TimeLib의 obj 파일만 있으면 괜찮지만, TimeLib이 참조하는 다른 프로젝트(그럴 정도로 복잡한 구성이라면)의 obj 파일이 들어 있어서 링크가 꼬인 것이다. [목록 2]에서처럼 비주얼 스튜디오가 알아서 필요한 obj 파일을 명시해주면 이런 일이 없는데 첫 번째 방법으론 요원한 일이다.
그런 까닭에 빌드 종속성을 그대로 두면서 문제를 해결해보기로 했다. 그러니까 추가 종속성에 “TimeLib\$(ConfigurationName)\*.obj”를 명시하지 않고 라이브러리 종속성 입력을 사용할 방법이 없는가 고민한 것이다.
다행스럽게 방법은 있었다. 의외로 쉬운 방법이다. 다만, 우리가 여태까지 살펴본 컴파일 방식을 약간 뒤집긴 해야 한다. 더 자세히 들여다 보기 전에 /clr 컴파일 방식을 다시 검토해보자.
C++/CLI 프로젝트를 /clr 컴파일할 때 선택할만한 전략은 크게 세 가지이다.
-
프로젝트 전체에 /clr 옵션을 주고, 모든 cpp 파일을 /clr 컴파일한다. 이것은 비주얼 스튜디오에서 CLR 프로젝트를 생성할 때 쓰는 기본값이다. 순수한 CLR 어셈블리를 만들 때 쓸만한 전략이지만, 네이티브 C++ 기능만 쓰는 코드 파일까지 /clr 컴파일을 하게 된다. 추가적인 성능 상의 하락은 필연적이다. 추천할 수 없는 방법이다.
-
Expert C++/CLI란 책에서 추천하는 방법은 이렇다. 프로젝트 전체 설정에선 /clr을 뺀다. CLR 기능을 쓰는 코드 파일만 따로 옵션을 준다.
-
두 번째 방법과 정반대로 할 수도 있다. 프로젝트 전체 설정에 /clr 옵션을 주고, 네이티브 코드 파일에만 /clr 옵션을 주는 것이다. 물론 이 방법은 기존 네이티브 프로젝트를 CLR 프로젝트로 확장할 땐 상당히 귀찮을 수 있다.
우리는 여태까지 Expert C++/CLI란 책이 제시한 방법을 따랐다. 사실 이 방법이 좋긴 하다. 무엇보다 기존 네이티브 소스 파일의 컴파일 옵션은 그대로 두고, 새로 추가되는 관리되는 소스 파일만 손본다는 점이 마음에 든다. 최소한의 노력으로 기능 확장이라 이상적이지 않은가?
안타까운 일이지만 dllexport가 아닌 obj 파일 링크를 할 땐 세 번째 방식이 더 낫다. TimeLib 프로젝트 설정에 /clr 옵션을 주어야 이를 참조하는 TimeLibTest가 TimeLib이 정적 라이브러리가 아니라는 것을 알기 때문이다. 물론 그 많은 기존 소스 파일의 컴파일 옵션을 바꿔야 하는 문제가 있다. 하지만 소스 파일을 드래그 등으로 한꺼번에 선택해 컴파일 옵션을 단번에 바꾸는 게 가능하므로 큰 문제가 아니다.
이제 여러분이 할 일은 TimeLib 프로젝트에 /clr을 적용하고, 컴파일 옵션을 적절히 고치는 것이다. 컴파일 옵션은 2월 칼럼에 따라 적용하면 된다. 하지만 이를 상세히 설명하기엔 지면이 모자라니 지난 칼럼을 참고하길 바란다.
여기까지 마쳤으면 일단락됐다. 바로 빌드해서 돌려보면 첫 번째 시도 때와 마찬가지로 6개의 단위 테스트가 성공한다.
Success: 6 tests passed. Test time: 0.04 seconds. 계속하려면 아무 키나 누르십시오 . . .
참고. 이 처리 방식을 재현해보고 싶다면 소스 코드 저장소에서 리비전 19을 내려 받으면 된다.
당부의 말
100% 확신하진 못하지만 이렇게 컴파일 및 링크 옵션을 깊숙하게 파고드는 자료는 많지 않을 것이다. 왜 그런지 모르겠는데, 사람들은 이상하게도 프로그래밍 언어의 특징에는 관심을 두지만 의외로 프로젝트 구성엔 소홀한 경향이 있다.
사실 학교 숙제나 소규모 개인 프로젝트에선 빌드 구성이 그리 중요하진 않다. 소스 코드가 많지 않고 컴파일 시간이 짧기 때문이다. C++에 비해 상대적으로 빌드 속도가 빠른 C# 등을 다룰 때도 컴파일 옵션에 주의를 덜 기울인다.
그러나 대규모 소스 코드를 운용해보니 적절한 빌드 구성이 얼마나 큰 역할을 하는지 깨닫게 된다. 우선 빌드 시간이 길면 개발 속도가 지연된다. 프로젝트 하나의 소스 코드를 한 줄 고쳤을 뿐인데 5분을 기다려야 하는 팀과 똑 같은 시간에 프로젝트 대 여섯 개를 빌드하는 팀의 생산성이 같은 순 없다. 단순히 시간의 문제가 아니다. 전자와 같은 팀 환경에선 개발자들이 금세 지치고 컴파일 시간을 핑계 삼아 다른 일에 열중할 때가 많다.
빌드 시간도 중요하지만 빌드를 적절히 구성하면 버그를 조기에 잡을 수도 있다. ‘기본 런타임 검사’나 ‘작은 형식 검사’ 같은 옵션이 그런 예에 속한다. 실제로 이런 옵션을 적용하고 한동안 버그를 상당히 잡아냈다.
생산성을 정말 높이고 싶다면 최소한 팀 구성원 중 한 명은 지속적으로 프로젝트 구성을 검토하고 최적화해야 할 것이다. 다만, 이런 업무는 지루하고 사람을 지치게 만들기 때문에 적절한 보상이 따라야 좋다. 보너스로 멋진 노트북을 사줄 필요야 없겠지만 가끔 떡볶이라도 사주면 좋지 않을까? 아니면 이 업무를 팀 구성원이 번갈아 가면 맡아도 좋겠다.
끝마치는 말
꽤 복잡한 실습이었다. 여기까지 따라온 것만 해도 쉽진 않았으리라 생각한다. 그리고 모든 경우를 다 다루지도 않았다. 하지만 사례 공부로는 나름 의미가 있다고 믿는다. 실무에서 다룬 프로젝트 구성은 이 외에도 몇 가지가 더 있다. 당장 다음 칼럼에 이런 사례를 다룰지는 조금 더 고민해볼 문제이다. 하지만 적절한 시점이다 싶을 때 다른 사례도 다룰 생각이니 느긋하게 기다려주길 바란다.