이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 4월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.
C++/CLI는 기존 네이티브 C++ 코드에 CLR의 막강한 기능을 넣는 훌륭한 수단이다. 네이티브 C++ 코드를 관리되는 클래스로 감싸 C#에서 쓸 수 있게 API를 제공하면 된다. 생각만큼 쉽지 않고 의외의 곳에서 튀어나오는 컴파일러 오류가 앞길을 가로막기도 한다. 하지만 몇 가지 패턴만 익히면 그 다음부턴 넓은 평야에 깔아놓은 고속도로를 달리듯 쭉쭉 코드를 뽑아내는 것도 가능하다.
지난 3개월에 걸쳐 우리는 무엇을 했는가? C++/CLI에서 자주 쓰는 용어부터 시작해서 C++/CLI 프로젝트를 구성하고 빌드하는 법을 알아봤다. 그리고 그 과정에서 발생할법한 작고 큰 문제를 몇 가지 알아봤다. 이를 통해 C++/CLI가 결코 만만하지 않은, 배우기 어렵고 손이 많이 가는 프로그래밍 언어라는 점도 깨닫게 됐다. 열 가지가 넘는 소스 파일의 빌드 옵션을 매번 바꿔야 하고, 난해하기 짝이 없는 문법도 익혀야 한다. 하지만 C++/CLI가 꼭 필요한 현장 개발자라면 피해갈 길이 없으니 공부하고 노력하는 수밖에.
오늘은 C++/CLI 코드와 C# 코드를 섞어 쓰는 법을 살펴보려 한다. 이전 칼럼에선 BCL(기본 클래스 라이브러리)에 포함된 System.TimeSpan을 그대로 모방해 네이티브용 NTimeSpan을 구현했다. 그리고 네이티브 NTimeSpan과 관리되는 TimeSpan을 상호변환하는 마샬링 코드도 구현했다. 이러한 작업은 대개 관리되는 기능을 네이티브 코드에서 활용하기 위함인데 이번엔 반대로 네이티브에서 구현한 기능을 관리되는 코드에서 활용하는 방안을 알아볼 생각이다.
준비물
지난번에 만든 TimeLib 솔루션을 이용해 실습할 계획이다. 특별히 TimeSpan이나 DateTime과 관련된 기능을 구현하진 않기 때문에 기존 산출물을 굳이 활용할 이유는 없다. 새로운 프로젝트를 구성해도 괜찮을 것이다. 하지만 2월에 알아봤듯이 C++/CLI 프로젝트를 올바르게 구성하는 일은 무척 번거롭기 때문에 일전에 만든 프로젝트를 활용하려 한다. 2월 칼럼을 읽지 못한 독자를 위해 또다시 빌드 옵션을 설명할 여력이 없기 때문이다.
우선 http://github.com/andromedarabbit/imaso/source/checkout의 설명에 따라 소스 코드를 내려 받자. 서브버전 클라이언트가 없다면 http://github.com/andromedarabbit/imaso/downloads/list에서 압축한 소스코드(Timelib.zip)을 받아도 좋다.
이번 칼럼의 예제는 2월에 올린 소스 코드를 고쳐가며 기능을 넣는다. 하지만 하나하나 따라할 시간이 없고 최종 결과물만 보고 싶다면 이번 4월 소스 코드를 내려 받으면 된다.
네이티브 로그 클래스
TimeLib.sln 파일을 열자. 그리고 Log.h 파일을 추가하자.
이름에서 유추하겠지만 첫 예제는 로그를 남기는 클래스가 그 대상이다. 아마도 대부분의 응용프로그램에 로그 기능이 있을 것이고 되려 이 기능이 없는 응용프로그램을 찾기가 훨씬 어려우리라 생각한다. 그런 만큼 네이티브로 작성한 기존 코드에 CLR 기능을 넣어서 확장시킨다는 이 칼럼의 시나리오에 맞는 사례이다.
그럼 시나리오에 맞춰 네이티브 로그 클래스를 작성하자. 어차피 예제니까 그리 복잡한 기능을 넣을 이유는 없고 그저 문자열을 받아서 표준출력장치, 그러니까 콘솔 화면에 해당 문자열을 뿌리기만 하면 된다.
- [목록 1] 네이티브 로그 클래스
-
#pragma once class Log { private: explicit Log(); explicit Log(const Log&); Log& operator = (const Log&); public: static void Write(const std::wstring& msg) { std::wcout << msg << std::endl; } };
- [목록 2] 기본 생성자를 구현한 경우
-
explicit Log() { // 뭔가 초기화를 한다. }
이렇게 기본 생성자를 제공하지 않을 때는 보통 해당 클래스의 인스턴스 생성을 막고 싶기 때문이다. 알다시피 C++ 컴파일러는 프로그래머가 어떠한 생성자도 정의하지 않으면 기본 생성자를 자동 생성한다. 그러므로 생성자를 아예 없애고 싶으면 이처럼 기본 생성자를 명시적으로 private 처리해야 한다. 이렇게 인스턴스가 존재 안 하는 클래스는 소위 static 클래스를 만들 때 사용한다. static 클래스는 C# 2.0부터 제공되는 특수 클래스인데 이에 대해선 조금 있다 알아보자.
복사 생성자와 = 연산자의 역할은 사실상 같다. static 클래스로만 쓸 테니 Log 인스턴스를 만들지도 복사하지도 않는다는 사실을 명확히 한다. 이렇게 명시적으로 막아야 C++ 컴파일러가 쓸모 없는 코드를 생성하지 않는다. Effective C++이란 책에선 복사 생성자와 = 연산자를 되도록 명시적으로 막는 걸 권하는데 그 이유까지 자세히 설명하진 않겠다. 인터넷에도 관련 자료가 많으니 말이다.
여기까지 했으면 이전에 잘 쓰던 네이티브 코드가 있는 상황까지 재현한 셈이다. C++/CLI를 도입하여 기존 코드에 CLR의 힘을 불어넣기로 결정한 후라면 아마도 C#이나 Visual Basic .NET에서 Log::Write( ) 메서드를 호출하고 싶을 것이다. 이제부터 그렇게 하는 방법을 알아보자.
관리되는 로그 클래스
Log 클래스에 대응하는 관리되는 클래스를 구현할 차례니 우선 LogManaged.h 파일을 TimeLib 프로젝트에 추가하자. 파일을 추가했으면 한번쯤 스스로 생각해보자. 어떻게 구현해야 할까? MSDN을 뒤져 관리되는 클래스의 예제를 찾아본다. 아마 C++/CLI란 키워드로 검색하면 되리라. 그리고 C#에서 Write( ) 메서드를 호출해야 하니 std::wstring 이 아닌 System::String 인스턴스를 매개변수로 받아야 하리라. 이렇게 받은 매개변수를 다시 std::wstring으로 변환한 후 Log::Write( ) 메서드를 호출하면 되지 않을까?
이렇게 한번쯤 고민해보고 머리 속에 그려낸 코드이든 실제로 작성한 코드이든 뭔가 만들어졌다면 다음 구현과 비교해보자. 비슷할 수도 그렇지 않을 수도 있다. 스스로 고안한 코드가 더 나을 수도 그렇지 않을 수도 있다. 어찌되었건 프로그래밍 언어는 실습을 통해 배우는 게 최고다! 눈으로만 따라잡으려 애쓰지 않았으면 한다.
- [목록 3] LogManaged
-
#pragma once #include <msclr/marshal_cppstd.h> #include "Log.h" public ref class LogManaged abstract sealed { public: static void Write(System::String^ msg) { std::wstring nativeMsg = msclr::interop::marshal_as<std::wstring>(msg); Log::Write(nativeMsg); } };
이제부터 목록 3의 코드를 하나씩 짚어나가자. 짧은 코드지만 생각보다 초심자가 알아야 할 내용이 많다. 우선 네이티브 클래스와 달리 클래스에 접근 한정자 public이 붙었다. C# 등을 다뤄봤다면 이 키워드가 뜻하는 바를 알리라 생각한다. 클래스의 접근 한정자는 세 가지다. public, internal, private. public은 외부 어셈블리에서 해당 클래스를 사용해도 된다는 뜻이다. 반면 internal은 외부 어셈블리에서 쓰진 못하지만 같은 어셈블리 내의 다른 클래스가 해당 클래스를 사용하는 건 허용한다는 뜻이다. 마지막으로 private은 같은 어셈블리 안이라 하더라도 다른 클래스의 접근은 아예 거부한다는 의미다. 짐작했겠지만 private 클래스는 쓰는 경우가 극히 드물다. 우리는 C# 등으로 작성한 외부 코드에서 로그를 남기길 원하니 LogManaged 클래스는 public 한정자를 가진다.
ref 키워드는 이 코드가 관리되는 클래스임을 명시한다.
클래스 이름 뒤에 붙는 abstract sealed 키워드는 실은 하나가 아니고, abstract 키워드와 sealed 키워드의 조합이다. abstract 키워드는 LogManaged 클래스가 추상 클래스임을 뜻하는데 다시 말해 인스턴스 생성이 안 되고, 인스턴스를 생성하려면 파생 클래스를 정의해야 한다. 뒤이은 sealed 키워드는 LogManaged 클래스가 상속을 허용하지 않는다는 뜻이다. 각각의 키워드가 의미하는 바는 알았고 이제 두 키워드를 조합하면 어떤 뜻이 될지 생각해보자. 인스턴스 생성이 안 되고 파생 클래스도 정의 못한다. 다시 말해 static 클래스란 뜻이다. static 클래스는 모든 멤버가 static으로 선언된, 또는 선언되어야 하는 클래스다. C#에선 편의상 static이란 키워드를 제공하지만 C++/CLI에선 종전처럼, 그러니까 .NET Framework 1.1 때처럼 abstract sealed 키워드 조합을 이용해야 한다.
이제 LogManaged의 하나뿐인 메서드 Write( )를 보자. 우선 static 키워드가 눈에 띈다. 네이티브 C++에도 있는 거라 생소하진 않다. 그런데 이 키워드를 제거하고 인스턴스 메서드로 변환하면 어떻게 될까? 다음과 같은 컴파일 오류가 난다.
error C4693: ‘LogManaged’: 봉인 추상 클래스는 인스턴스 멤버 ‘Write’을(를) 포함할 수 없습니다.
관리되는 코드에서 static 클래스는 멤버 메서드를 가지지 못한다. 반면 네이티브 Log::Write( )는 멤버 메서드로 바꾸더라도 오류가 나지 않는다. 네이티브 C++ 컴파일러는 정적 클래스란 개념을 직접 지원하진 않는 탓이다. 어쨌거나 Log와 LogManaged는 정적 클래스의 전형적인 설계 패턴을 보여준다. 네이티브 C++ 키워드와 그에 대응하는 관리되는 C++ 키워드를 알아두면 좋지만 거기서 그치지 않고 대응하는 디자인 패턴도 익혀야 상호운영 코드를 작성하기 쉽다. C++/CLI의 제일 큰 용도가 상호운영성을 제공하는 것이라는 점을 생각할 때 이는 매우 중요하다.
LogManaged:Write( ) 내부에선 관리되는 문자열을 std::wstring 으로 변환하고 Log::Write( )를 호출한다. 지난 칼럼 때 이러한 상호변환 코드를 손수 작성하는 방법을 알아봤는데 여기선 마이크로소프트 사가 기본 제공하는 변환 코드를 사용했다. MS 사는 BSTR, System::String^, CComBSTR, wstring, string, const char*, const wchar_t* 같은 다양한 종류의 문자열을 변환할 수 있도록 마샬링 라이브러리를 미리 만들어놓았다. 문제는 문자열의 종류에 따라 참조해야 하는 헤더 파일이 다르다는 것이다. 잘 모르겠으면 Overview of Marshaling in C++라는 MSDN 라이브러리 문서를 참고하면 된다. 참고로 marshal_as에는 문자열 변환과 더불어 HANDLE과 System::Intptr의 상호변환 기능도 들어있다.
마지막으로 사소한 부분이지만 혼란을 줄지 모르는 부분은 짚고 넘어가자. 알다시피 std::wstring은 std::string의 유니코드 버전이다. 그런데 std::wstring은 C++ 표준 라이브러리에 포함된 문자열 클래스이고 #include <string>을 추가해야 올바로 컴파일된다. 목록 3에는 #include <string>이 빠졌는데 대신 미리 컴파일된 헤더에 추가했다. 이 문자열 라이브러리는 네이티브 코드와 관리되는 코드 양쪽에서 다 쓰기 때문에 stdafx_common.h에 넣었다. 지난 칼럼에서 stdafx_common.h는 stdafx.h(네이티브용 미리 컴파일된 헤더)와 stdafx_clr.h(관리되는 코드용 미리 컴파일된 헤더) 양쪽에서 참조함을 보인 바 있다.
- [목록 4] stdafx_common.h
-
#pragma once // 원래stdafx.h에있던코드 #include "targetver.h" #define WIN32_LEAN_AND_MEAN // 거의사용되지않는내용은Windows 헤더에서제외합니다. // 여기서부터사용자가추가한코드 #include <string> #include <iostream>
Lib을 DLL로
이제 우리가 만든 LogManaged::Write( ) 함수를 C# 코드에서 불러볼 것이다. 그러나 그 전에 알아둬야 할 중요한 사실이 있다. 다른 어셈블리 파일에서 C++/CLI로 작성한 관리되는 코드를 참조하려면 반드시 그 대상이 되는 프로젝트가 DLL이어야 한다(참고로 참조 대상인 프로젝트가 C#으로 작성된 거라면 exe 파일이어도 된다).
이것은 기존 네이티브 코드를 확장하려는 프로그래머와 팀에게 커다란 도전이다. 내부 조직에서 쓰기엔 정적 라이브러리가 더 편하다. 아마도 동적 라이브러리, 그러니까 DLL은 꼭 필요한 곳에만 쓰고 대부분은 정적 라이브러리로 만들어 쓰리라 생각한다. 그런데 갑자기 정적 라이브러리를 동적 라이브러리로 바꾸라니! 그렇게 바꾼 프로젝트는 문제 없이 빌드되더라도 그걸 참조하는 다른 프로젝트가 골치 아플 것이다.
예제 TimeLib 솔루션도 마찬가지다. TimeLib 프로젝트를 참조하던 TimeLibTest 프로젝트를 어찌해야 하나? 이 문제는 지면이 부족하고 복잡하므로 다음 기회로 미루자. 일단 이 실습에선 TimeLibTest 프로젝트와 UnitTest 프로젝트를 제거하여 빌드 오류가 나지 않도록 임시 처리한다. 그러고 나서 [TimeLib 속성 페이지 – 구성 속성 – 일반 – 구성 형식]의 값을 정적 라이브러리에서 ‘동적 라이브러리’로 바꾼다.
준비는 이만하면 됐다. 이제 C# 프로젝트를 추가하여 하던 일을 끝내보자.
.cpp 파일은 필수
앞서 한 가지 언급하지 않은 부분이 있다. LogManaged.h 파일을 프로젝트에 추가한 후 LogManaged.cpp를 추가하는 대목이 빠졌다. 아마 여러분이 프로젝트 사이트에서 4월 소스 코드를 내려 받으면 LogManaged.cpp 파일도 있을 텐데 목록 5와 같을 것이다.
- [목록 5] LogManaged.cpp
-
#include "stdafx_clr.h" #include "logmanaged.h"
사실상 헤더 파일을 참조하는 것 외엔 하는 일이 없는데 이 참조가 생각보다 중요하다. 이 코드가 왜 중요한지 알아보기 전에 LogManaged를 테스트할 C# 콘솔 프로젝트를 TimeLib 솔루션에 추가하자. 이는 기존 네이티브 코드를 관리되는 클래스로 감싼 후 해당 기능을 C#에서 활용하는 시나리오에 해당한다.
- [목록 6] LogManagedTest의 Main( )
-
namespace LogManagedTest { class Program { static void Main(string[] args) { LogManaged.Write("does it work?"); } } }
이 코드를 실행하면 어떨까? 예상했던 대로 ‘does it work?’란 문자열이 콘솔 화면에 출력된다. 여기까진 정상이다. 이번엔 살짝 다른 시도를 해보자. TimeLib 프로젝트에 있는 LogManaged.cpp 파일을 제거한다. 그러고 나서 TimeLib 프로젝트를 ‘다시 빌드’한다. 아마 별 문제 없이 빌드가 끝날 것이다. 이번엔 TimeLib을 참조하는 LogManagedTest를 빌드한다. 그러면 다음과 같은 컴파일 오류가 난다.
오류 CS0103: ‘LogManaged’ 이름이 현재 컨텍스트에 없습니다.
왜 이런 오류가 나는지 확실히 알려면 reflector로 TimeLib.dll의 메타데이터를 살펴보면 된다. 그림 1이 그 결과인데 어디에도 LogManaged 클래스가 없다. 도대체 어떻게 된 걸까?
LogManaged.h에 필요한 클래스를 정의하긴 했지만 문제는 어느 소스 파일도 이 헤더 파일을 참조하지 않는다는 점이다. 이런 상황에선 LogManaged.h 에 정의된 코드가 오브젝트 파일(.obj)로 컴파일되지 않고, 그러므로 링크가 끝난 최종 산출물인 dll에도 해당 코드가 빠진다.
매우 사소한 문제이고, 처음처럼 헤더 파일을 참조하는 소스 파일을 추가하면 간단히 해결된다. 설사 실수했더라도 이번처럼 컴파일 오류가 나기 때문에 조기에 실수를 깨닫는다. 문제는 리플렉션(System.Reflection) 기능을 이용해 래퍼 클래스를 동적으로 생성할 때다. 코드를 런타임에 생성하기 때문에 컴파일타임에 문제를 잡아내지 못한다. 행여나 서비스를 내놓고 나서 사고가 나지 않도록 평소에 소스 파일을 추가하는 습관을 들이자.
혼합 프로젝트를 참조할 때
C++/CLI의 컴파일 옵션은 크게 네 가지다. 그 중 주목할 옵션은 /clr, /clr:pure, /clr:safe인데 각각의 용도에 대해선 나중에 다룰 기회가 있을 것이다. 여기선 하나만 알아두자. 기존 네이티브 코드를 확장하는 시나리오에선 대부분 /clr 옵션을 쓰게 된다. 우리의 실습 예제도 마찬가지다. 그런데 /clr로 컴파일한 산출물을 C# 프로젝트에서 활용할 땐 한가지 제약이 따른다. 그 제약이 무엇인지 알아보기 전에 우선 LogManagedTest 프로젝트를 빌드해본다.
앞서 LogManagedTest 프로젝트의 Main( ) 함수가 곧바로 실행되는 것처럼 적었지만 실은 그렇지 않다. 십중팔구 목록 7과 같은 오류가 발생할 것이다.
- [목록 7] 런타임 오류
-
처리되지 않은 예외: System.BadImageFormatException: 파일이나 어셈블리 'TimeLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' 또는 여기에 종속되어 있는 파일이나 어셈블리 중 하나를 로드할 수 없습니다. 프로그램을 잘못된 형식으로 로드 하려고 했습니다. 파일 이름: 'TimeLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' 위치: LogManagedTest.Program.Main(String[] args)
이 문제의 원인은 이렇다. /clr 컴파일한 Dll 파일은 플랫폼 의존성이 있다. 그에 반해 일반적인 C# 프로젝트는 플랫폼으로 ‘ANY CPU’를 선택한다. 만약 C++ 프로젝트로 구성된 솔루션에 C# 프로젝트를 추가하면 솔루션의 기본 플랫폼 값은 ‘Mixed Platforms’가 된다. 그런데 이 플랫폼 구성을 보면 LogManagedTest의 플랫폼이 ‘ANY CPU’로 되어 있다(그림 2).
이 문제를 해결하려면 Mixed Platforms일 때 LogManagedTest의 플랫폼을 x86으로 바꾸면 된다. 그림 3처럼 말이다. 이렇게 하면 아까처럼 ‘does it work?’ 가 표시되고 사람 신경을 건드리는 오류 메시지는 사라진다.
한글 출력이 안 될 때
목록 6의 ‘does it work?’를 ‘잘 될까?’로 바꿔보고 실행해보자. 아마 콘솔 화면에 아무것도 나오지 않을 것이다. 고백하건대 한글이 안 나와서 잠시 당황했는데, 이는 마샬링 라이브러리의 문제가 아니다. 아마 숙련된 Windows 프로그래머라면 뭐가 문제인지 금방 떠올릴 텐데 표준 출력시 로케일 설정을 빼먹은 탓에 발생한 문제다. 이 문제를 해결하려면 목록 8의 코드를 어딘가 넣으면 된다. 표준 출력을 사용할 때마다 실행할 필요는 없고, 표준 출력을 사용하기 전에 딱 한번만 돌리면 된다. 곧잘 잊어먹는 이슈라 다시 상기하는 차원에서 정리해봤다.
- [목록 8] 표준 출력에 한글을 내보내기
-
std::wcout.imbue(std::locale("korean"));
끝마치는 말
마침내 C++ 코드와 C# 코드를 연결해보았다. 거창한 프로그램이 아니고 그저 흔해 빠진 ‘Hello World’ 수준에 불과하지만 세 가지 언어(네이티브 C++, 관리되는 C++, C#)를 묶다 보니 의외로 신경 쓸 곳이 많았다. 하지만 경험을 쌓을수록 문제에 대처하는 능력도 향상될 테니 걱정하지 않아도 된다.
good