이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 11월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.
한 해가 끝나간다. C++/CLI 강좌도 그에 따라 마무리를 해야 할 시점에 이르렀다. 화창하고 시원한 가을다운 날씨도 다음 칼럼을 쓸 무렵엔 거센 바람에 옷을 여미는 언제나 같은 겨울 날씨가 되어 있을 듯 하다. 그러나 겨울은 겨울 나름의 즐거움이 있는 법, 일단은 이 가을의 아름다움과 카페의 분주함을 즐기련다.
지난 시간에 글을 매듭 지으며 언급했듯 원래는 C++/CLI 로 강좌로 시작했으나 몇 달째 C# 소스코드만 다루게 됐다. C++/CLI란 언어의 특징을 배우고 해당 언어를 능수능란하게 다루길 원했던 독자에겐 아쉬운 일이리라 생각한다. 그러나 여러 차례 언급했듯 C++/CLI는 관리되는 환경과 기존 네이티브 C++ 환경을 잇는 다리에 불과하다. 그 복잡함을 이해는 하되 몰입할 필요는 없다. 실무에선 C++/CLI 프로그래밍을 할 일이 생각처럼 많지 않다. C++ 코드의 래퍼를 작성할 때만 C++/CLI가 필요한데 그나마 그런 작업을 반복하다 보면 패턴이 생긴다. 그 후론 패턴을 참고해서 코드를 작성하거나 더 나아가 스크립트로 도우미 프로그램을 작성해 자동으로 코드를 생성해내게 된다. 여러분이 집중할 영역은 되려 특정 패턴에 따라 스크립트가 실행되도록 하는 엔진 개발이다. 이것이 이 칼럼에서 집중적으로 다루는 바이다.
시작하기에 앞서
이 글의 모든 예제 소스코드는 웹에서 제공한다. 구글 코드에서 호스팅 받는데 서브버전(Subversion) 클라이언트가 있으면 자유롭게 다운로드 받고 변경 내역을 검토할 수 있다. 질문도 여기서 받는다. 물론 마이크로소프트웨어 홈페이지에서도 된다.
이벤트형 스크립트 호출
서론이 길었다. 이번 칼럼에서 스크립트 엔진의 핵심 부분은 전부 다룰 생각이다. 그러고 나서 12월 칼럼을 통해 실무에서 겪은 이야기나 개선 가능한 부분을 집중적으로 다루었으면 하는 바람이다. 글쓰기란 게 뜻대로 되진 않지만 일단 달려보자.
지난 칼럼에선 이벤트형 스크립트를 호출하는 기능을 구현했다. ScriptManager는 스크립트를 등록하고 규칙에 따라 호출하는 역할을 맡는데, 이 클래스에 이와 관련된 기능이 주로 들어갔다. 기억을 되살리는 차원에서 잠시 단위테스트 코드를 살펴보자(목록 1).
- [목록 1] 이벤트형 스크립트를 호출하는 단위테스트
-
[Test] public void InvokeEventTest() { string asmDir = AppConfiguration.TestAssembly2Dir; var count = AppDomain.CurrentDomain.GetAssemblies().Count(); int newCount = 0; using (var scriptManger = new ScriptManager(asmDir)) { scriptManger.Initialize(); newCount = AppDomain.CurrentDomain.GetAssemblies().Count(); Assert.GreaterOrEqual(newCount, count); var eventArgs = new ChatMsgArgs("채팅 메시지"); scriptManger.InvokeEvent(eventArgs); } Assert.AreEqual(newCount, AppDomain.CurrentDomain.GetAssemblies().Count()); }
대부분의 코드는 스크립트 어셈블리를 동적으로 적재하고 해제하는 일을 맡는다. 실제로 채팅 이벤트를 보내는 부분은 두 줄에 불과하다. 채팅 이벤트 인스턴스를 생성해(ChatMsgArgs) ScriptManager에게 처리해달라고 요청하면 끝이다.
C++/CLI를 활용해 네이티브 환경과 관리되는 환경을 엮어본 적이 있다면 이 정도로 충분히 이해하겠지만, 그렇지 않은 경우엔 “대체 이 코드를 어떻게 써먹으란 걸까?”란 의문이 들지 모른다. 실제로 처음 이러한 코드를 팀 구성원들에게 설명할 때 그런 질문이 터져 나왔었다. 그래서 C++/CLI 및 네이티브 C++ 측 코드는 어떤 행태가 될지 예를 살펴보기로 한다.
- [목록 2] 채팅 이벤트를 호출하는 실제 코드 예제
-
// ScriptInvokeManaged.cpp : C++/CLI 컴파일 // ScriptEngine.ScriptManager의래퍼클래스 ref class ScriptInvokerManaged { static void InvokeEvent(ScriptEventArgs^ eventArgs) { try { // 스레드별로 ScriptManager 인스턴스를생성했다가 // GetTLSScriptManagerInstance() 메서드로 그 인스턴스를 받는다고 가정한다. ScriptManager^ manager = GetTLSScriptManagerInstance(); return manager->InvokeEvent(eventArgs); } catch(System::Exception^ ex) { // 예외처리하기 } } } // ScriptInvoker.h : 네이티브 C++ 헤더 // 네이티브측에서 관리되는 스크립트를 호출할 때 쓰는 인터페이스 클래스. // ScriptInvokerManaged를 네이티브측에서 호출할 때 쓴다. // 이 헤더에는 관리되는 구성요소를 두지 않는다. class ScriptInvoker { public: static void OnChatReqReceived(const wstring& msg); }; // ScriptInvoker.cpp : C++/CLI 컴파일 inline void ScriptInvoker::OnChatReqReceived(wstring& msg) { // 네이티브 문자열을 관리되는 문자열로 변환한다. System::String^ managedMsg = msclr::interop::marshal_as(msg); auto_handle<ChatMsgArgs> args = gcnew ChatMsgArgs(managedMsg); ScriptInvokerManaged::InvokeEvent(args.get()); }; // Session.cpp : 네이티브 C++ 컴파일 void Session::Chat(const wstring& msg) { ScriptInvoker::OnChatReqReceived(msg); // 중략: 채팅 메시지를 처리하는 원래 네이티브 소스코드 // 동일한 채팅 채널에 있는 사용자들에게 채팅 메시지를 전달한다. }
이 예제에서 주목할 부분만 살펴보기로 한다. 우선 GetTLSScriptManagerInstance() 메서드이다. 실제 구현부는 보이지 않지만 기본적인 개념은 파이썬이나 루아 같은 스크립트 언어와 동일하다. 일반적으로 스크립트 엔진의 인스턴스는 스레드마다 생성한다. 그리하여 스레드 간의 동기화 문제로 병목현상이 생기지 않도록 방지한다. 물론 스레드 간에 데이터를 공유해야 하는 상황에선 스레드마다 스크립트 엔진 인스턴스를 둔다고 해도 동기화 문제는 여전할 수밖에 없다. 그러나 게임 서버를 설계할 땐 대개 하나의 요청은 하나의 스레드에서 전부 처리하고 데이터를 공유하는 일이 최대한 없도록 한다. 그러므로 스레드별로 스크립트 엔진 인스턴스를 두면 성능상의 이점이 상당하다.
한데 스크립트 엔진 인스턴스를 TLS(스레드 로컬 저장소)마다 둘 땐 다소 주의할 점이 있다. 이 점은 뒤에서 따로 다루기로 한다.
다음으로 주목할 부분은 ScriptInvoker의 헤더 파일과 소스 파일이다. 헤더 파일은 네이티브 C++인 반면 소스코드는 관리되는 C++, 그러니까 C++/CLI로 컴파일한다. 이렇게 생각하면 된다. ScriptInvoker는 관리되는 코드인 ScriptEngine의 기능을 활용하기 때문에 C++/CLI로 컴파일해야 한다. 반면 ScriptInvoker를 쓰는 쪽은 네이티브 C++쪽이다. 네이티브 C++은 관리되는 코드의 문법 요소, 예를 들면 ref, ^, 관리되는 클래스에 대한 참조 등을 전혀 이해하지 못한다. 따라서 ScriptInvoker에 관리되는 문법 요소가 없도록 해야 한다. 네이티브 코드를 관리되는 코드로 변환하는 부분(예, msclr::interop::marshal_as)은 모두 CPP, 즉 소스코드에 있어야 한다.
관리되는 코드와 네이티브 코드를 엮는 부분은 항상 이런 식으로 작성한다. 헤더는 네이티브 구성요소만 두고 소스파일에서 네이티브 코드와 관리되는 코드를 상호변환하는 것이다. 두 환경을 엮는 것이야 말로 C++/CLI의 역할이므로 이 패턴은 잘 알아두면 좋다.
CLR 환경에서 TLS 공간은 몇 개나 있을까?
CLR 환경(C#, C++/CLI 등)에는 Thread Local Storage가 몇 개나 있을까? 스레드 갯수만큼 있을까? 대부분의 경우엔 스레드 갯수만큼 있다. 하지만 항상 그렇지는 않다. CLR 환경에선 정확히 ‘스레드 갯수 x 응용프로그램 도메인 갯수’ 만큼 TLS가 있다. 이 점을 간과했다가 하마터면 피를 볼 뻔 한 적이 있다.
이제부터 여러분이 읽을 글은 이러한 과정에서 얻은 교훈을 블로그에 정리해둔 것이다.
애플리케이션 초기화 때 스레드마다 스크립트 엔진 인스턴스를 하나씩 붙인다(이유는 앞서 언급했다). 그런데 문제는 각 AppDomain마다 TLS 를 따로 갖는다는 것이다. 스크립트는 별도의 AppDomain을 쓰는데, 이 스크립트가 API를 호출할 때 기본 도메인이 아닌 두 번째 도메인 컨텍스트 안에 있게 된다. 당연히 두 번째 도메인 컨텍스트에는 스크립트 엔진 인스턴스가 없다. 왜? 응용 도메인마다 TLS가 다르고, 기본 AppDomain에만 스크립트 엔진을 붙였으니까.기본 응용프로그램 도메인에서 스레드마다 스크립트 엔진 인스턴스를 만들어놨다. 그리고 스크립트는 플러그인 응용프로그램 도메인에서 실행된다. 문제는 스크립트 안에서, 즉 플러그인 응용프로그램 도메인에서 스크립트 엔진 인스턴스에 접근할 때 벌어진다. 응용프로그램 도메인마다 TLS가 따로 있고, 기본 응용프로그램 도메인에만 인스턴스를 만들어놨으니, 스크립트에서 스크립트 엔진에 접근하려 하면 널 참조 예외가 발생한다. 이 문제를 가장 손쉽게 해결하는 방법은 역시 ‘스레드 갯수 x 응용프로그램 도메인 갯수’만큼 스크립트 엔진 인스턴스를 만드는 것이지만, 메모리 사용량도 많아지고 원래 의도한 바도 아니기에 다른 해결책을 모색했다.
그래서 찾아낸 해결책이 다음과 같다.
ref class ScriptEngineManaged sealed { public: [System::ThreadStatic] static ScriptEngine^ Instance = nullptr; } class ScriptEngineWrapper { public: explicit ScriptEngineWrapper(ScriptEngine^ scriptManager) : Instance(scriptManager) { } gcroot<ScriptEngine^> Instance; }; __declspec(selectany) ThreadLocal<ScriptEngineWrapper> ScriptEngine(NULL);간단히 설명하자면, ThreadLocalStorage를 닷넷 기능을 써서 할당 받는 대신, C++쪽에서 할당 받아 쓰는 방식으로 문제를 해결했다. CRT(네이티브 C++) 환경에선 정확히 스레드 갯수만큼 TLS가 있으므로 위와 같은 문제는 발생하지 않는다. 만약 정황을 설명하지 않고 위의 두 코드만 주면 완전히 동일한 코드라 판단할 테지만, 이렇게 미묘한 차이가 있다. 스크립트 엔진 만들면서 C++/CLI를 극한까지 다뤄보는 느낌이다.
여기까지가 일전에 블로그에 써 둔 글이다. 그런데 이 글에선 ThreadLocal 클래스에 대한 설명이 없다. 하지만 우리는 이미 이 클래스가 필요한 이유와 구현 방식을 3월 칼럼에서 다뤘다. 여기선 그 필요성만 간단히 다시 설명하고 넘어간다. 보통 Visual C++에선 TLS에 인스턴스를 생성할 때 __declspec(thread) 키워드를 이용한다. 그러나 C++/CLI 컴파일시엔 이 키워드가 제대로 작동하지 않는다. 불행히 컴파일 오류가 나지 않기 때문에 응용프로그램을 실행하고 나서야 뭔가 잘못됐음을 깨닫게 된다. 그런 탓에 위와 같이 ThreadLocal 클래스를 구현해 __declspec(thread) 대신 사용해야 한다.
메서드형 스크립트 호출
이벤트형 스크립트에 대해선 이제 알만큼 알게 됐으리라 믿는다. 그리 어려운 곳은 없었다고 믿지만 혹시라도 “왜 이렇게 해야 하지? 더 좋은 방법이 있는데,”라거나 “저자가 설명을 너무 못하는데 이해가 안돼”라는 생각이 들면 칼럼 도입부에 적은 곳으로 연락주길 바란다.
어쨌거나 이제 이벤트형 스크립트는 눈 감고도 구현 가능하다고 믿고 메서드형 스크립트를 지원하기로 한다. 기본적으로 메서드형 스크립트나 이벤트형 스크립트나 큰 차이가 없다. 특히 엔진 측에선 두 가지 스크립트의 차이가 드러나는 부분이 거의 없다. C++/CLI 또는 C++ 측의 호출부가 어디냐란 차이만 두드러지게 드러날 뿐 기술적으론 구현상의 차이점이 없다고 봐도 좋다.
그럼 ScriptManager가 어떤 인터페이스를 노출해야 할 지부터 생각해보자. 종전의 이벤트형 스크립트의 단위테스트를 복사해 붙여 넣고 살짝 코드를 고치면 우리가 원하는 코드가 나온다.
- [목록 3] 메서드형 이벤트의 단위테스트
-
[Test] public void InvokeMethodTest() { string asmDir = AppConfiguration.TestAssemblyDir; var count = AppDomain.CurrentDomain.GetAssemblies().Count(); int newCount = 0; using (var scriptManger = new ScriptManager(asmDir)) { scriptManger.Initialize(); newCount = AppDomain.CurrentDomain.GetAssemblies().Count(); Assert.GreaterOrEqual(newCount, count); scriptManger.InvokeMethod("Help", “scriptlist); } Assert.AreEqual(newCount, AppDomain.CurrentDomain.GetAssemblies().Count()); }
InvokeMethod의 첫 번째 인자인 문자열 “Help”은 호출할 스크립트의 이름으로써 다음과 같이 애트리뷰트에 기술한다(목표 4).
- [목록 4] 메서드형 스크립트 Help
-
[MethodScript("Help", "테스트용 스크립트!")] public class HelpMethodScript { public void Execute(string option) { Console.WriteLine("테스트 메시지: " + option); } }
당장은 Help 메서드가 어떤 일을 하든 우리의 관심사가 아니므로 간단히 콘솔에 메시지를 찍는 형태로 간단하게 놔두었다. 이벤트형 스크립트와 마찬가지로 스크립트 정보가 애트리뷰트(이 경우엔 MethodScriptAttribute)에 들어가므로, 이 애트리뷰트가 붙은 클래스를 찾아 메서드형 스크립트로 등록해두면 된다. 이렇게 메서드형 스크립트를 검색해서 등록하는 코드는 ScriptManager.RegisterScripts( ) 메서드에 들어간다. 기존 코드는 이벤트형 스크립트를 등록하는 기능만 있고 메서드형 스크립트는 무시했는데 목록 5에 그 뼈대가 있다.
- [목록 5] ScriptManager.RegisterScripts( )의 뼈대
-
private void RegisterScripts() { foreach(var asm in AppDomain.CurrentDomain.GetAssemblies()) { // 중략 foreach (Type t in asm.GetExportedTypes()) { // 중략 if (Attribute.IsDefined(t, typeof(EventScriptAttribute)) == true) { // 중략 } if (Attribute.IsDefined(t, typeof(MethodScriptAttribute)) == true) { // TODO: 나중에 구현하자 } } } }
우리가 할 일은 할일(TODO)로 남겨둔 블록 안을 채워 넣는 일뿐이며 사실상 EventScriptAttribute를 처리하는 코드와 동일하다. 그렇다면 EventScriptAttribute를 처리하는 부분의 소스 코드를 가져다 MethodScriptAttribute를 처리하게 바꾼 코드를 살펴보자(목록 6).
- [목록 6] 메서드형 스크립트 등록하기
-
// ScriptEngine 클래스의 멤버변수 private readonly Dictionary<string, MethodScriptInvoker> _methodInvokers; // ScriptEngine.RegisterScripts( ) 의 구현 중 일부 if (Attribute.IsDefined(t, typeof(MethodScriptAttribute)) == true) { object[] methodAttrs = t.GetCustomAttributes(typeof(MethodScriptAttribute), false); foreach (MethodScriptAttribute methodAttr in methodAttrs) { Debug.Assert(methodAttr != null); var methodName = methodAttr.Name; MethodInfo methodInfo = t.GetMethod("Execute", BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public); Debug.Assert(methodInfo != null); var scriptMethod = new ScriptMethodInfo(t, methodInfo); MethodScriptInvoker invoker = null; if (_methodInvokers.TryGetValue(methodName, out invoker)) { throw new ApplicationException("Two different method scripts with the same name found: " + methodName); } invoker = new MethodScriptInvoker(methodName, scriptMethod); _methodInvokers.Add(methodName, invoker); } }
메서드형 스크립트를 등록하는 일이나 이벤트형 스크립트를 등록하는 일이나 큰 차이는 없다. Event란 5자짜리 단어를 Method란 6자짜리 단어로 치환하면 거의 모든 일이 끝난다. 하지만 이 둘 사이엔 결정적인 차이가 있으니 그 점만은 조심해야 한다. 이벤트 하나엔 이벤트형 스크립트가 0개 이상 있을 수 있다. 채팅 이벤트를 처리하는 스크립트는 아예 없을 수도 아니면 수백 개가 있을 수도 있다. 반면 메서드형 스크립트는 고유성을 갖는다. 달리 말하자면 똑 같은 이름을 가진 메서드형 스크립트는 있어선 안 된다. 예를 들어 “스크립트 목록”을 반환하는 관리자 스크립트를 이미 등록했는데 다른 곳에서 똑 같은 이름을 가진 메서드형 스크립트가 발견되어선 안 된다. 굳이 프로그래밍 언어의 관점에서 말하자면 우리의 스크립트 언어는 메서드 오버로딩을 지원하지 않는다. 이 요구사항에 대해선 두세 차례 언급한 바가 있다. 물론 여러분의 요구나 생각에 따라선 요구사항을 바꾸어도 되지만 적어도 이 칼럼에선 이러한 조건을 따르기로 한다.
위의 소스코드에서 실제로 메서드형 스크립트의 정보를 저장했다가 호출하는 기능은 MethodScriptInvoker에 들어간다. 이 클래스도 지난 달에 구현한 EventScriptInvoker를 살짝 고치면 금방 완성되는데 그렇게 구현한 코드가 목록 7에 있다.
- [목록 7] MethodScriptInvoker
-
internal class MethodScriptInvoker { private readonly string _scriptName; private readonly ScriptMethodInfo _scriptMethod; public MethodScriptInvoker(string scriptName, ScriptMethodInfo scriptMethod) { _scriptName = scriptName; _scriptMethod = scriptMethod; } public string ScriptName { get { return _scriptName; } } public ScriptMethodInfo Method { get { return _scriptMethod; } } public void Invoke(params object[] args) { Object scriptObj = null; try { if (_scriptMethod.ScriptMethod.IsStatic == false) { scriptObj = Activator.CreateInstance(_scriptMethod.ClassType, false); } _scriptMethod.ScriptMethod.Invoke(scriptObj, args); } finally { var disposableInterface = scriptObj as IDisposable; if (disposableInterface != null) disposableInterface.Dispose(); } } }
앞서 살펴본 바와 같이 MethodScriptInvoker도 하나의 요구사항만 제외하면 EventScriptInvoker와 동일하다. EventScriptInvoker는 해당 이벤트에 속하는 여러 개의 이벤트형 스크립트를 등록 받는다. 그러고 나서 루프를 돌며 이벤트형 스크립트를 하나씩 실행한다. 이벤트 하나에 핸들러가 여러 개일 수 있기 때문이다. 반면 메서드형 스크립트는 유일성이 특징이다. 때문에 특정 스크립트 이름에 해당하는 메서드형 스크립트는 하나뿐이다. 그런 까닭에 ScriptMethodInfo의 인스턴스를 하나만 둔다.
또한 이벤트형 스크립트는 호출시(Invoke) ScriptArgs 클래스로 필요한 인자를 받는다. 그에 비해 메서드형 스크립트는 스크립트마다 인자를 달리해 받는 게 가능하다. 예를 들어, “Print”란 스크립트가 있다면 문자열을 매개변수로 받을 것이다. 그에 비해 “ScriptList”란 스크립트는 인자를 전혀 받지 않을지 모른다. 이렇게 스크립트마다 매개변수의 개수가 다른 경우까지 처리하기 위해 우리는 가변인자(params object[])를 이용한다. 이렇게 하면 인자의 종류나 개수에 상관없이 처리가 가능하지만 호출하는 측에서 해당 스크립트가 어떤 인자를 어떤 순서대로 받는지 정확히 알아야 한다.
마지막으로 목록 3에서 봤던 InvokeMethod 메서드의 구현을 살펴보자. InvokeMethod는 외부에서 메서드형 스크립트를 호출할 때 쓰는 인터페이스이며 목록 8과 같이 구현한다.
- [목록 8] ScriptEngine.InvokeMethod
-
public void InvokeMethod(string methodName, params object[] args) { Debug.Assert( string.IsNullOrEmpty(methodName) == false ); MethodScriptInvoker invoker; if (_methodInvokers.TryGetValue(methodName, out invoker) == false) { var msg = string.Format("{0}인 이름을 가진 메서드형 스크립트는 없습니다.", methodName); throw new ApplicationException(msg); } invoker.Invoke(args); }
InvokeMethod의 구현 역시 InvokeEvent와 다른 점이 거의 없다. 매개변수로 이벤트 인자(EventArgs) 대신 메서드 이름과 가변 인자를 넘겨받는 점이 다를 뿐이다. 그 외에는 Event란 단어를 Method란 단어로 치환했다고 봐도 무방할 정도이다.
끝마치는 말
이로써 스크립트 엔진의 기본적인 형태는 완성됐다. 조금 더 엔진다운 모습을 보이려면 CLI 스펙에 맞춰 스크립트 언어와 컴파일러를 따로 구현하거나 C# 대신 IronPython이나 IronRuby 같은 언어로 서버측 스크립트 코드를 작성하면 된다. 물론 전자는 상당한 노력이 필요한 탓에 후자를 추천하는 바이다. 한편으론 IronPython 등보다 C# 측이 성능이 더 낫다라는 게 대체적인 평가이므로 프로그래머가 서버측 스크립트를 작성한다면 C#을 선택하는 것도 좋다. 대체로 서버측 스크립트는 클라이언트측 스크립트와 달리 데이터 동기화 등의 문제가 있기 때문에 기획자가 짜기 쉬운 형태가 되기 쉽지 않다. API를 쓰기 쉬운 형태로 만드는 등의 노력이 수반되어야 한다.
이러한 문제 등은 마지막 칼럼에서 자세히 논의해보자. 글쓴이조차 경험이 부족하지만 아는 만큼이라도 이야기해볼 수는 있을 것이다.