이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 2월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.
C++/CLI은 C#, Visual Basic .NET과 더불어 마이크로소프트 사가 역량을 가장 많이 기울이는 닷넷 프로그래밍 언어 중 하나다. MSDN 라이브러리의 모든 예제는 C#, VB.NET 그리고 C++/CLI 세 가지 버전이 함께 제공된다. 그만큼 중요하지만 정작 이 독특한 프로그래밍 언어의 용도나 올바른 쓰임새에 대해 자세히 설명한 책이나 자료는 많지 않다. 적어도 국내엔 C++/CLI를 다룬 책이 한 권도 없다. 우리는 이 칼럼을 통해 C++/CLI의 기초와 응용을 익힐 것이다.
지난 1월에 어떤 이야기를 했던가? 먼저 닷넷 프레임워크에서 많이 쓰이지만 그게 그것 같아 헷갈리는 CTS, CLS 같은 용어를 정리했다. 이런 용어는 C++/CLI 관련 자료에 특히 자주 등장하기 때문에 “아, CLS는 공통 언어의 표준 말하는 거지?” 정도는 이해해야 한다.
용어를 정리하고 C++/CLI의 용도를 이야기했는데 다시 강조하지만 순수하게 CLR(공통 언어 런타임)의 기능만 필요하고 네이티브 코드는 쓰지 않아도 된다면 C++/CLI가 아닌 C#을 새로 익히는 편이 생산성이 훨씬 좋다.
마지막으로 CLI의 기본 타입을 정리했으며 기본 타입은 아니지만 만만치 않게 자주 쓰는 DateTime(시각), TimeSpan(시간)을 중점적으로 다뤘다. 특히 TimeSpan은 그에 대응하는 네이티브 코드를 작성했는데 이는 두 가지 측면에서 중요하다.
우선 정수 변수에 Tick 값을 저장해선 버그가 양산된다. 64비트 정수에 보통 Tick 값을 저장하는데 변수 이름을 잘 짓지 않으면 Tick 값이 시각을 나타내는지 시간을 나타내는지 모른다. 이런 경우 2009년 12월 1일과 2000년 1월 15일을 서로 더하는 의도가 불분명한 결과가 튀어나오기 십상이다. 이에 반해 시각과 시간 타입을 따로 정의하고 유효한 연산만 허락하면 이런 문제는 모두 사라지고 시간 연산이 간단해진다.
또한 CLI가 제공하는 DateTime
, TimeSpan
과 동일한 기능과 인터페이스를 제공한다는 측면에서 편리해진다. 모든 사람이 네이티브 C++, C++/CLI, C#으로 이어지는 삼단 콤보를 완벽하게 익히진 못한다. 여러 사람이 함께 하나의 결과물을 만들어내야 하는 현대 소프트웨어 개발 과정을 생각하면, 다른 개발자가 이해하고 따라하기 쉬운 코드를 작성해야 한다.
자, 꽤 서론이 길었다. 이제 오늘 다룰 주제가 무엇인지 알아보자. 원래 지면이 모자라 다루지 못한 DateTime을 논의하려 했으나, 두 시간 연속으로 네이티브 코드만 만지작거리면 C++/CLI 칼럼답지 못하단 생각이 들었다. 그래서 지난번에 작성한 네이티브 TimeSpan 클래스와 관리되는 TimeSpan 클래스를 상호 변환하는 코드를 짜고 그에 대한 단위테스트 코드를 작성해보겠다.
작업 환경
실제 코드를 작성하기 전에 우리의 작업환경부터 알아보자. 거두절미하고 Visual Studio 2008을 기준으로 칼럼을 쓴다. 아직 베타인 Visual Studio 2010은 논외로 하더라도 C++/CLI를 처음 지원한 Visual Studio 2005가 아닌 최신 버전을 쓰는 데는 그만한 이유가 있다. 개발자는 항상 최신기술에 익숙해야 한다는 그런 이유가 아니라 아주 현실적인 이유가 말이다. C++/CLI는 C#과 함께 가장 빨리 변하는 분야다. 그나마 C#은 역사가 길어 안정적이지만 C++/CLI는 Visual Studio 2003 때 발표한 Managed C++이 실패하고 Visual Studio 2005 때 C++/CLI가 새로 나왔으니 역사가 매우 짧다. 그래서 C#에 비해 버그가 많으며 기능 면에서 개선의 여지가 많다. 그러니 항상 최신 버전을 쓰는 편이 좋다. 실제로 Visual Studio 2008에는 marshal_as라고 하는 데이터 변환 기능이 기본 제공되는데 이전 버전에선 개발자가 일일이 만들어 쓰던 기능이다. 오늘 예제에서도 marshal_as를 다루게 된다.
예제 라이브러리 만들기
먼저 지난 번 코드를 담을 Win32 프로젝트를 만든다. 이때 핵심 키워드는 Win32다. 이 칼럼이 비록 C++/CLI를 다루긴 하지만 CLR 프로젝트를 만들 일은 아마 없을 리라 생각한다. 지금은 약간 의아하겠지만 그만한 이유가 있고 곧 알게 될 것이다.
Win32 프로젝트를 만들 때 응용 프로그램이나 DLL이 아닌 정적 라이브러리를 선택한다. 이때 라이브러리의 이름은 TimeLib으로 정한다. 물론 마음대로 해도 좋지만 칼럼에선 이 이름을 쓰기로 한다. 그리고 지난 번에 만든 FTimeSpan을 붙여 넣는다. 이때 클래스 이름을 NTimeSpan으로 고쳐 쓰자. 네이티브의 N자를 앞에 붙여서 TimeSpan과 구분할 생각이다. 지면이 남는 상황은 아니니 NTimeSpan의 코드를 또 붙여 넣진 않겠다. 지난 칼럼을 참고하던가 소스 코드를 웹에서 다운로드 받길 바란다.
UnitTest++ 추가하기
이제 단위테스트 라이브러리 UnitTest++를 솔루션에 추가하자. UnitTest++을 구성하는 방법은 2008년 7월 칼럼(지속적인 통합)에서 다뤘다. 웹에도 자료가 있으니 참고하자. 어디까지나 C++/CLI 칼럼이므로 단위테스트 라이브러리의 사용법을 깊게 다루진 않고 기본적인 내용만 다루겠다.
UnitTest++ 프로젝트를 넣은 후 TimeLib의 테스트 코드를 추가할 새 프로젝트 TimeLibTest를 만들자. 물론 이 프로젝트는 콘솔 응용 프로그램이어야 한다. 그리고 프로젝트 종속성 메뉴를 열어 TestLib과 UnitTest++에 대한 의존성을 추가한다.
여기까지 했으면 콘솔 응용 프로그램의 진입점 main 함수만 채워 넣으면 된다. 2008년 7월 칼럼에서 다룬 내용인데, UnitTest++이 버전 1.4로 업데이트되면서 이전 칼럼에서 쓰던 코드 일부가 작동하지 않게 됐다. 새 버전에 맞춰 고친 코드는 함수 RunTests이며 웹에 올린 예제 소스 코드에 반영했다.
- [목록 1] UnitTest++ 1.4용 RunTests 함수
-
int RunTests(UnitTest::TestReporter& reporter) { // 버전 1.4 이전 // return UnitTest::RunAllTests(reporter, UnitTest::Test::GetTestList(), NULL, 0); // 버전 1.4 TestRunner runner(reporter); return runner.RunTestsIf(Test::GetTestList(), NULL, True(), 0); }
CLR용 미리 컴파일된 헤더 만들기
stdafx_clr.h 추가하기
Visual Studio는 보통 미리 컴파일된 헤더란 기능을 쓰도록 프로젝트를 구성한다(미리 컴파일된 헤더에 대해선 “미리 컴파일된 헤더란?” 글을 참고하자). 보통 stdafx.h 란 파일이 미리 컴파일된 헤더가 된다. 그런데 C++/CLI 프로젝트에선 미리 컴파일된 헤더를 하나 더 만들어 쓴다. 하나는 네이티브 소스 코드용이고 다른 하나는 관리되는 소스 코드용이다. 여기선 stdafx_clr.h 헤더와 그에 상응하는 stdafx_clr.cpp를 추가한다. 그리고 stdafx.h와 stdafx_clr.h에 공통적으로 들어갈 헤더 파일을 포함하는 stdafx_common.h도 넣는다.
이때 세 개의 헤더 파일은 목록 2와 목록 3와 같다.
- [목록 2] stdafx.h 과 stdafx_clr.h
-
// stdafx.h #pragma once #include "stdafx_common.h" // stdafx.cpp #include "stdafx.h" // stdafx_clr.cpp #include "stdafx_clr.h"
- [목록 3] stdafx_common.h
-
#pragma once // 원래stdafx.h에있던코드 #include "targetver.h" #define WIN32_LEAN_AND_MEAN // 거의사용되지않는내용은Windows 헤더에서제외합니다. // 여기서부터사용자가추가한코드
stdafx_clr.cpp 의 속성 바꾸기
솔루션 탐색기에서 stdafx_clr.cpp 파일을 선택한다. 그런 후 컨텍스트 메뉴를 열어 속성을 선택한다. 속성 페이지 상단의 구성에서 “모든 구성”을 선택하고 플랫폼에서 “Win32”를 고른다. 물론 해당 프로젝트가 Win32와 x64를 모두 지원한다면 “모든 플랫폼”을 골라야 할 것이다. 일단 이 예제에선 “Win32”로 충분하다.
미리 컴파일된 헤더
이제 [구성 속성 – C/C++ – 미리 컴파일된 헤더]를 선택하자. 그리고 해당 값을 그림 3처럼 설정하면 된다. “미리 컴파일된 헤더 만들기/사용”은 “미리 컴파일된 헤더 만들기(/Yc)”, “파일에서 PCH 만들기/사용”은 “StdAfx_clr.h”, 그리고 “미리 컴파일된 헤더 파일”은 “$(IntDir)\$(TargetName)_clr.pch”로 설정한다.
일반
[구성 속성 – C/C++ – 일반]은 그림 4와 같이 설정한다. 디버깅 정보 형식은 “프로그램 데이터베이스(/Zi)”를 고른다. CLR 코드는 “편집하며 계속하기를 위한 프로그램 데이터베이스”를 지원하지 않는다. 물론 프로그램 데이터베이스(PDB) 자체를 안 쓰는 옵션도 선택 가능하다. 하지만 프로그램 데이터베이스 없이는 문제 발생시 디버깅이 너무 어렵기 때문에 릴리즈할 때도 PDB는 만든 편이 좋다. 이에 대해선 Debugging Applications for Microsoft .NET and Microsoft Windows 같은 책을 참고하면 좋다.디버깅 정보 형식을 바꿨으면 제일 중요한 “공용 언어 런타임 지원을 사용하여 컴파일” 옵션을 “공용 언어 런타임 지원(/clr)”로 선택한다. 이제 눈치챘겠지만 /clr 옵션은 소스 파일 별로 줄 수 있다. TestLib 프로젝트를 만들 때 만약 WIN32 프로젝트가 아닌 CLR 프로젝트를 골랐다면 /clr 옵션이 기본값으로 모든 소스 파일에 적용됐을 것이다. 그런데 왜 이렇게 안 했을까? 이에 대해선 “필요할 때만 /clr 옵션을 주자”에서 논의한다.
코드 생성
이제 길고 긴 컴파일 옵션 바꾸기의 끝이다. [구성 속성 – C/C++ – 코드 생성]을 그림 5처럼 구성한다. 일반적인 네이티브 코드에선 DEBUG 빌드시 /Gm 옵션을 주는데 CLR 코드는 “최소 다시 빌드 가능(/Gm)”을 지원하지 않는다.
네이티브 코드는 보통 “C++ 예외 처리 가능”의 값이 “예(/EHsc)”이다. 하지만 관리되는 코드에선 “예, SHE 예외 있음(/EHa)”를 선택한다.
또한 디버그 빌드시 “기본 런타임 검사”의 값이 “모두(/RTC1, /RTCsu와 동일)”일 텐데 이를 “기본값”으로 바꾼다. 기본값은 기본 런타임 검사를 하지 않는 것이다.
마지막으로 런타임 라이브러리를 선택한다. 관리되는 코드는 반드시 “다중 스레드 DLL(/Md)”나 “다중 스레드 디버그 DLL(/MDd)”를 선택해야 한다. 각각은 순서대로 디버그 빌드와 릴리즈 빌드용이다.
미리 컴파일된 헤더란?
MSDN 라이브러리의 설명을 빌리자면 다음과 같다.
Microsoft C 및 C++ 컴파일러에서는 인라인 코드를 포함하는 C 또는 C++ 코드를 미리 컴파일하기 위한 옵션을 제공합니다. 이 성능 향상 기능을 사용하면 안정적인 코드 본문을 컴파일하고, 컴파일된 상태의 코드를 파일에 저장하고, 후속 컴파일 타임에 아직 개발 단계에 있는 코드와 미리 컴파일된 코드를 결합할 수 있습니다. 안정적인 코드는 다시 컴파일하지 않아도 되므로 각 후속 컴파일의 속도가 더 빨라집니다.
미리 컴파일된 헤더의 최대 장점은 속도다. 소스 코드가 많은 대규모 프로젝트에서 미리 컴파일된 헤더를 쓰는 것만으로 빌드 시간을 엄청나게 단축할 수 있다. 특히 STL을 많이 쓴다면 그 효과가 더하다.
다만 주의할 점이 있다. 미리 컴파일된 헤더에 포함된 헤더 파일은 #include로 명시하지 않아도 자동으로 포함된다. 그래서 자주 쓰지도 않는 헤더 파일조차 미리 컴파일된 헤더에 추가하는 개발자가 의외로 많다.
그러나 이보다 심각한 문제가 있다. 설계 문제를 미리 컴파일된 헤더로 가리려는 경우가 있다. 소스 코드끼리 순환 참조를 하는 경우가 그 예인데, 전방 선언을 도입한다던가 설계의 결함을 검토해봐야 할 상황이지만, 실력이 미숙하다던가 단순히 귀찮아서 문제의 소스 코드를 미리 컴파일된 헤더에 넣어버리는 일이 비일비재하다. 미리 컴파일된 헤더를 사용하면 어쨌거나 빌드는 되기 때문이다. 하지만 이런 대처는 눈 가리고 아웅에 불과하다. 설계 결함을 해결한 게 아니기 때문에 코드의 확장성이 떨어지고 소스 코드를 변경하기 힘들어진다. 자신은 운이 좋더라도 그 코드를 유지 보수하는 누군가는 그 문제 탓에 전임자의 욕을 하는 상황이 벌어진다.
미리 컴파일된 헤더는 순전히 그 본래 목적, 컴파일 속도를 높이는 데만 써야 한다.
필요할 때만 /clr 옵션을 주자
/clr 옵션을 주는 방법은 크게 두 가지이다. /clr을 기본값으로 삼고 네이티브 소스 파일만 /clr 없이 따로 설정하던가, 반대로 “공통 언어 런타임 지원 안 함”을 기본값으로 삼고 관리되는 소스 파일에만 /clr 옵션을 주는 것이다. 물론 우리는 후자를 선택했다.
두 번째 접근법을 선택한 이유가 있다. 개발자가 여럿 있을 때 /clr 옵션을 충분히 숙지한 사람이 몇이나 있을까? 누군가 무심코 네이티브 소스 파일의 옵션을 바꾸는 걸 잊을지 모른다. /clr 옵션은 네이티브 코드와 관리되는 코드를 모두 컴파일할 수 있다. 그러니 옵션을 안 바꿨더라도 티가 나지 않는다. 단지 눈치 못 채는 사이에 컴파일 시간이 길어지고 런타임 성능이 떨어질 뿐이다. 반면 후자의 접근법을 선택하면 이런 일이 없다. /clr 옵션이 없으면 관리되는 코드는 컴파일 되지 않는다. 그러니 개발자가 깜박하더라도 곧 그 사실을 깨닫게 된다.
만약 첫 번째 접근법을 선택하고 시간이 흐르면 설계가 엉망이 된다. 그저 컴파일이 되기 때문에 네이티브 코드와 관리되는 코드를 명확히 구분하지 않게 된다. 그러다 보면 코드가 뒤엉켜서 분리해내기 어려워지고 후에 성능 문제가 불거지더라도 대책이 없게 된다.
방금 /clr 옵션을 주면 컴파일 시간이 길어지고 런타임 성능이 떨어진다고 했다. 그러나 문제는 거기서 그치지 않는다. /clr 옵션이 있느냐 없느냐에 따라 전역 변수나 정적 변수의 초기화 순서가 달라진다. 네이티브 소스 파일(/clr 없음)에 정의된 전역 및 정적 변수는 항상 관리되는 소스 파일(/clr 있음)에 정의된 전역 및 정적 변수보다 먼저 불린다. 그러니 컴파일 옵션을 바꿀 때는 변수의 초기화 순서에 주의를 기울여야 한다. 개인적인 경험은 없지만 Expert C++/CLI라는 초기 C++/CLI 명저에는 ATL을 쓸 때 주의하라는 조언이 있다. 전역 _Module이나 _AtlModule 변수는 반드시 네이티브 코드로 컴파일해야 초기화 문제를 피할 수 있다고 한다.
관리되는 소스 코드 추가하기
CLR용 미리 컴파일된 헤더와 관련된 중요한 사항은 모두 점검했다. 이제 실제 소스 코드를 추가할 차례다. 우리는 전 시간에 만든 NTimeSpan과 닷넷 프레임워크의 System::TimeSpan을 상호 변환하는 코드를 작성하기로 했다. 일반적으로 이런 변환을 마샬링(Marshaling)이라 부른다. 그러니 새로 추가하는 코드의 이름을 MarshalAs.h, MarshalAs.cpp 라 이름 붙이겠다.
파일을 추가했으면 앞서 stdafx_clr.cpp에서 그랬던 것처럼 컴파일 구성을 바꾼다. 이번엔 전처럼 거창한 설명이 필요 없다. Stdafx_clr.cpp와 똑같이 구성하면 된다. 다만, [구성 속성 – C/C++ – 미리 컴파일된 헤더 – 미리 컴파일된 헤더 만들기/사용] 값을 “미리 컴파일된 헤더 만들기(/Yc)”가 아닌 “미리 컴파일된 헤더 사용(/Yu)”로 바꾸면 된다. 그뿐이다.
마샬링에 대해
C++/CLI에는 마샬링을 담당하는 각종 클래스와 함수가 있다. 이런 기능은 주로 System::Runtime::InteropServices 네임스페이스나 msclr::interop 네임스페이스 안에 있다. 전자는 C++/CLI, C#, Visual Basic .NET에서 모두 쓸 수 있고, 후자는 C++/CLI에서만 사용 가능하다. 실상 후자는 전자의 기능을 C++에서 쓰기 편하게 정리한 것인데 Visual Studio 2008부터 도입됐다. Visual Studio 2005를 쓸 땐 현재의 msclr::interop 네임스페이스에 포함된 기능을 개발자가 일일이 구현해 썼다.
NDateTime용 마샬링 코드
C++/CLI에서 쓰는 마샬링 코드는 기본 지침이 있다. MSDN 라이브러리의 방법: 마샬링 라이브러리 확장을 참고하자. 목록 4의 코드도 이러한 지침을 따른 코드인데 지면이 모자라 주석을 제거했다.
- [목록 4] Marshal.h
-
#pragma once #include <msclr/marshal.h> #include "TimeSpan.h" namespace msclr { namespace interop { using namespace System; template <> inline TimeSpan marshal_as<TimeSpan, NTimeSpan>(const NTimeSpan& nativeObj) { return TimeSpan(nativeObj.Ticks()); } template<> ref class context_node<NTimeSpan*, TimeSpan> : public context_node_base { private: NTimeSpan* toPtr; marshal_context context; public: context_node(NTimeSpan*& toObject, TimeSpan fromObject) { toPtr = NULL; toPtr = new NTimeSpan(fromObject.Ticks); toObject = toPtr; } ~context_node() { this->!context_node(); } protected: !context_node() { if (toPtr != NULL) { delete toPtr; toPtr = NULL; } } }; } }
이 예제 코드가 MSDN 라이브러리의 예제와 다른 점은 단 하나뿐이다. MSDN의 예제에선 관리되는 클래스를 매개변수나 반환 값에 쓸 때 참조로 주고 받았다(예. ManagedEmp^). 그러나 이 예제에선 참조 키워드 ^를 뺐다. CLI에는 값 타입과 참조 타입이 있다. 값 타입엔 int, long을 비롯한 기본형과 DateTime, TimeSpan이 들어간다. 이러한 값 타입은 참조가 아닌 복사를 통해 값을 전달하기 때문에 참조 키워드 ^를 뺀 것이다. 위의 코드를 살짝 바꿔 참조로 값을 넘기고 반환하게 하더라도 컴파일이 될 가능성이 높다. 그래서 실수해놓고 모르는 경우가 많은데 미묘한 버그가 날지 모르니 주의하자.
행여나 의심 많은 독자가 있을지 몰라 TimeSpan의 일부 코드를 Reflector로 역어셈블리한 코드를 목록 5에 싣는다. 목록 5는 C++/CLI로 변환된 코드인데 TimeSpan을 참조로 넘기지 않는다.
- [목록 5] TimeSpan의 Equals 메서드
-
public: virtual bool Equals(TimeSpan obj) { return (this->_ticks == obj->_ticks); }
자, 다 됐다. 코드 컴파일하고 단위테스트를 돌려보자.
앗! 그러고 보니 단위테스트 코드가 빠졌다. 실은 지면이 부족한 탓에 이번에 다루지 않기로 했다. 하지만 예제 소스 코드엔 테스트가 포함됐으니 반드시 다운로드 받아 돌려보자. 그리 정교하게 작성한 코드는 아니지만 C++/CLI를 공부하는 용도론 충분하리라 믿는다.
C++은 익히기 어려운 언어라 한다. 포인터 사용을 가장 큰 문제로 꼽는 사람도 있는데 내 견해는 다르다. C++은 코드라는 논리적 구성체의 역할뿐만 아니라 물리적 구조 역시 중요하다. 예를 들어 헤더 파일과 소스 파일을 분리하고 헤더 파일을 포함시킬 때(#include) 순환 참조가 일어나지 않게 조심해야 한다. 그밖에 컴파일 옵션도 너무 복잡하다. 이런 문제는 C++/CLI로 와서 더 심해졌다. 앞서 살펴봤듯 C++/CLI가 도입되면서 컴파일 옵션을 구성하기도 한결 어려워졌다. 그러나 어쩌랴 달리 방법이 없는 것을. 열심히 공부하고 대처하는 수밖에 없다.
끝마치는 말
C++은 익히기 어려운 언어라 한다. 포인터 사용을 가장 큰 문제로 꼽는 사람도 있는데 내 견해는 다르다. C++은 코드라는 논리적 구성체의 역할뿐만 아니라 물리적 구조 역시 중요하다. 예를 들어 헤더 파일과 소스 파일을 분리하고 헤더 파일을 포함시킬 때(#include) 순환 참조가 일어나지 않게 조심해야 한다. 그밖에 컴파일 옵션도 너무 복잡하다. 이런 문제는 C++/CLI로 와서 더 심해졌다. 앞서 살펴봤듯 C++/CLI가 도입되면서 컴파일 옵션을 구성하기도 한결 어려워졌다. 그러나 어쩌랴 달리 방법이 없는 것을. 열심히 공부하고 대처하는 수밖에 없다.