C++/CLI 강좌: 스크립트 엔진 개발하기 1편

  • Post author:
  • Post category:칼럼
  • Post comments:2 Comments
  • Post last modified:February 8, 2020

이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 6월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.

C++/CLI가 처음 등장했을 때, 그러니까 Managed C++이란 이름이었을 때가 생각난다. C#의 편리함을 즐기다 C++의 세계로 막 다시 들어온 시기였다. C++의 불편함을 어떻게 견뎌내는 걸까? 어떻게 하면 C++을 벗어날까? 이런 고민을 했다. 이런 건 단순히 능력이 부족한 신입 개발자의 투덜거림에 불과할까?

최재훈 | SK 아이미디어의 게임 서버 팀에서 일한다. 요즘은 스크립트 엔진을 개발하는 데 전념하며, 새로운 도전을 즐긴다. 직업 외적인 측면에선 배철수의 음악 캠프를 15년째 즐겨 듣고, U2가 최고의 밴드라 생각한다.

오늘은 C++/CLI의 문법이 어떻고 빌드 옵션이 어때야 하는지 같은 이야기를 잠깐 미루려 한다. 그 전에 다분히 개인적인 경험담을 나눌 생각이다. 하기야 언제는 안 그랬냐는 독자가 있을지 모른단 생각이 문득 들긴 하지만.

몇 주 전에 경력 개발을 어떻게 할지 고민하다 새삼스런 사실을 깨달았다. 어느새 현업 6년 차 개발자가 됐다는 사실을 말이다. 벌써 그렇게 됐나? 산업기능요원을 시작할 무렵 아무것도 알지 못했다. 대학에서 그리 뛰어난 학생이 아니었고 항상 “왜 이렇게 됐나?” 번민만 하다 도피 겸 일을 시작했다. 그런 탓에 일이 쉽지 않았다. 이 C++이란 녀석은 괴팍해서 처음 만나는 사람에게 살갑게 굴지 않는다. 언어 자체도 절차적 프로그래밍, 객체지향적 프로그래밍, 제네릭 프로그래밍이 혼재되어 있고, 단순히 문법만 알아선 안 되고 소스 파일의 물리적 구조, 컴파일러의 작동 방식까지 알아야 한다.

그러던 중 XML 웹 서비스를 개발할 일이 생겼고 몇 개월은 C# 개발자가 됐다. 그런데 이 새로운 언어가 너무 마음에 들었다. 메모리를 알아서 관리해주고 free, delete가 필요 없었다(IDisposable 패턴은 있지만). 기본 라이브러리도 풍성해 날짜, XML, 데이터베이스 등 필요한 자원이 다 있었다. 그래서 C++로 돌아갈 시기가 되자 우울했다. 그 모든 걸 버리고 어떻게 사나? 심지어는 나중에 C++을 다루는 회사는 가지 말자는 어이없는 생각까지 했더랬다.

그러다가 결국 C++로 게임을 개발하는 회사에 민간인으로써 첫 경력을 쌓게 된 자리를 마련하고 말았다. 약 3년간 경험을 쌓은 시점이라 프로그래밍 언어가 제약사항이 되던 신입 때와는 달랐지만 돌이켜보면 웃기는 일이다. 아차! 너무 개인적인 이야기로 흘러간다. 이제 곧 왜 이런 이야기를 하는지 알게 될 테니 잠깐만 참아주길 바란다.

이렇게 입사를 하고 C++ 책을 여러 권 탐독하고 현업에 적용하면서 일에 적응해 갔다. 그러던 어느 날 스크립트 엔진을 개발해보란 이야기가 나왔다. 게임 개발에 대해 아무것도 아는 바가 없었지만 이야기를 꺼낸 당사자가 사전에 생각해 둔 바가 있어 감을 잡을 수 있었다. 이쯤에서 짐작하겠지만 바로 C++/CLI를 이용한 스크립트 엔진의 개발이 이때 시작되었다. 그리고 이제부터 이 시스템의 어떤 고민을 했는지, 어떻게 문제를 해결했는지 사례 분석을 할 것이다. 이 과정에서 문법이나 버그 등의 관련 이슈도 좀더 심도 있게 다루기로 한다.

스크립트 엔진?

스크립트 엔진. 대체 스크립트 엔진이란 무엇일까? 게임 개발을 해 본 적이 없던 당시의 나와 독자 중 상당수는 그런 의문을 품을 만 하다. 이 사례 연구를 제대로 수행하고 이해하려면 우선 이 질문부터 답해야 한다.

게임 분야엔 Game Programming Gems란 유명한 책이 있다. 최근에 번역된 6권을 보면 스크립트를 활용한 기법이 많이 소개됐다. 전에는 스크립트란 분류가 따로 없었는데 최근 추세를 반영한 결과이다.

게임은 보통 당대의 가장 최신 기술을 활용한다. 특히 그래픽이 날이 갈수록 좋아지는 탓에 성능 최적화가 항상 고민거리다. 그래서 성능이 좋지만 객체지향도 지원하는 C++을 주로 사용한다. 하지만 C++은 기능확장하기가 어렵다. 특히 Java, C#, Python 등이 제공하는 Reflection 기능이 없어 문제다. 실행 시간에 타입 정보를 검색하고 필요한 객체를 생성하는 기능이 기본 지원되지 않는다. 그래서 플러그인 또는 스크립트를 넣으려면 스스로 구성 요소를 개발해야 한다. 여러 사례를 보면 DLL을 이용한 동적 적재 기능, 전문 프로그래머가 아닌 기획자 또는 일반인이 쓸 만큼 쉬운 언어를 위한 파서 등이 필요하다. 세상에 불가능한 일이 없다지만 자원과 시간이 한정된 실무에서 이 모든 걸 지원하기란 어렵다. 그래서 새로운 접근 방법이 필요하다.

그래서 게임 업계에선 스크립트 언어와 구성요소를 직접 개발하기 보단 파이썬이나 루아 같은 스크립트를 이용한다. C++과 연동하는 기능이 기본 제공되는 프로그래밍 환경이라 컴파일러 이론을 직접 구현할 필요가 없다. 단지 정해진 규칙에 따라 데이터만 주고 받으면 된다.

보통 C++ 측에서 게임 데이터를 조작하는 API를 제공하고, 네이티브 API를 사용하게 루아 또는 파이썬 래퍼 API를 구현한다. 물론 스크립트 수준에서 생성된 데이터를 네이티브 코드로 전달하는 기능도 대부분의 경우엔 필수적이다. 파이썬은 그 유명한 시뮬레이션 게임 문명(Civilization)에 쓰였다. 덕분에 사용자들이 확장팩을 따로 만들어 공유하기도 한다. 루아는 일년에 매출이 1조가 넘는다는 MMORPG 월드 오브 워크래프트에 쓰여 다양한 사용자 그래픽 인터페이스가 등장했다.

[목록 1] 월드 오브 워크래프트의 스크립트 예제
# 출처: http://www.wowace.com/projects/kg-panels/pages/sample-scripts/
local pmems = GetNumPartyMembers()
 local rmems = GetNumRaidMembers()
 if (pmems < 1 and rmems < 1) or (pmems > 0 and pmems < 6 and rmems < 6) then
    self:Hide()
 else
    self:Show()
 end
			
[목록 2] 문명 4의 파이썬 스크립트 예제
# 출처: http://civ4.wikidot.com/guide-to-python-in-civ:iteration-looping
# NewObj: Active player
Player = Player.PyPlayer(GC.getActivePlayer().getID())
CyPlayer = CyGlobalContext().getPlayer(GC.getActivePlayer().getID())
# NewObj: Determine player's list of cities
CityList = Player.getCityList()

# NewObj: Loop through each of the active player's cities
for City in CityList:
        # Blah...
			

목록 1은 월드 오브 워크래프트의 스크립트 사용 예제이다. GetNumPartyMembers, GetNumRaidMembers 등의 API 함수를 호출하여 게임 내 정보를 얻는다. 목록 2는 문명의 파이썬 스크립트 예제인데 게임 플레이어와 게임 내 도시 정보를 얻는다. 양쪽 모두 C++과 달리 변수 타입을 선언할 필요도 세그멘테이션 폴트를 걱정할 필요도 없다.

스크립트 엔진을 도입하는 이유

혹 “외부 사용자가 우리 코드를 쓸 일이 없다. 그러니 스크립트가 필요 없다”라고 생각할지 모른다. 그러나 꼭 그렇지는 않다. 초창기엔 모든 응용프로그램을 C++로 작성했다. 당시엔 그리 힘들다고 생각하지 않았다. 익숙해진 상태니까 말이다. 하지만 나중에 C#이나 Visual Basic .NET으로 기능을 확장하는 단계로 넘어가자 내 자신이 개발한 시스템인데도 불구하고 놀라운 생산성 향상에 감탄하게 됐다. 그렇다면 구체적으로 어떤 부분이 개선됐을까?

우선 빌드 속도를 고려해야 한다. 일반적으로 C++은 빌드 속도가 느리다. 모든 정보를 컴파일 시간에 생성하기 때문이다. 반면 파이썬이나 루아는 런타임에 파일을 컴파일하고 타입 정보를 확인한다(미리 컴파일하는 방법으로 응용프로그램의 초기 반응속도나 성능을 높이기도 한다). C#이나 Java는 그 중간 정도에 위치한다. 무슨 말인가 하면 소스 코드를 컴파일하긴 하지만 C++처럼 기계어 수준까지 만들어놓진 않는다. 상당량의 정보는 실행시간에 생성한다. 그런 까닭에 루아, 파이썬, C#은 빌드 속도가 빠르다. 특히 최적화 옵션을 적용한 C++ 코드와 C# 코드를 비교하면 그 정도가 너무나 심각하다. 비슷한 규모의 프로젝트라도 빌드 시간이 10배씩 차이가 난다.

오래 전엔 몇 십만 줄짜리 프로젝트를 다루지 않았다. 그래서 빌드 속도가 왜 중요한지 체감하지 못했다. 만약 여러분도 그렇다면 축복일지도 모른다. 고민거리가 하나 줄었으니 말이다. 그러나 게임 개발 분야라면 아마 비슷한 고민을 항상 하리라. 소스 코드를 한 줄 고쳤을 뿐인데 10분이나 빌드해야 한다면? 직접 목격한 바, 절대 좋지 않다. xkcd라는 미국 웹 만화에 이런 게 있다. 개발자 두 명이 바퀴 달린 의자 위에 앉아 칼 싸움을 한다. 그 모습을 본 관리자가 소리친다. “일 안 해?” “컴파일 중이예요!”. 이런 식의 만화였다.

실제로도 빌드 속도에 따라 생산성의 차이가 크다. 대체로 빌드 시간이 오래 걸릴수록 팀의 사기가 전체적으로 낮다는 사실을 깨달았다. 여러분이 이런 상ghkd에 놓였다면 1차적으론 리팩터링을 해야 한다. 특히 파일 간의 의존성을 줄여서 코드 한 줄을 변경할 때마다 다시 빌드하는 일이 없게 한다. 2차적으론 스크립트 엔진을 도입하면 좋다. C++로 이뤄진 핵심 코드를 변경하지 않는 이상 빌드가 오래 걸리지 않을 것이다.

스크립트 엔진이 꼭 게임에만 쓰일 이유가 없다. 잠깐 쓰고 말 구성요소가 아니라면 스트립트 엔진과 그 API를 구성해 놓으면 얼마 안 있어 고생한 값어치를 할 것이다.

C++/CLI를 이용한 스크립트 엔진 개발

앞서 문명 IV와 월드 오브 워크래프트의 사례를 살펴봤다. 이런 성공에 힘입어 최근에 나온 Game Programming Gems 6권에는 루아 스크립트를 활용한 기법이 많이 소개됐다. 그러나 안타깝게도 대부분의 기술이 클라이언트용으로 개발됐다. 일반적으로 클라이언트는 동기화 문제가 심각하지 않다. 멀티스레딩 프로그램일지라도 사용자 인터페이스를 다루는 스레드를 따로 둔다면 이 기능만큼은 동기화 문제를 고려하지 않고 스크립트를 만들 수 있다. 사실 서버도 스레드 별로 기능이 완전히 독립적이고 이 독립된 기능을 서로 엮을 필요가 없다면 마찬가지다. 그러나 현실에서 그러긴 힘들다.

서버 프로그래밍에서 가장 중요한 성능도 문제가 된다. 클라이언트 측 스크립트는 게임 플레이어에게만 부담이지만 서버는 다중 사용자가 이용하는 스크립트를 모두 감당해야 한다. 그래서 크라이언트보다 성능 문제에 민감하게 된다.

내게 일을 맡긴 당사자는 이 점을 고려한 후 C++/CLI를 도입하자는 생각을 하게 됐다. CLR을 활용하면 몇 가지 장점이 있다.

  1. 스크립트 언어나 컴파일러 등 프로그래밍 환경을 구축하지 않아도 된다. 이는 루아나 파이썬과 마찬가지다.

  2. 취향이나 요구사항에 맞는 프로그래밍 언어를 선택할 수 있다. CLR 환경은 C++/CLI, C#, Visual Basic .NET, IronPython, IronRuby 등 다양한 프로그래밍 환경을 제공하고 상호운영이 가능하다. 파이썬이 익숙한 사람은 IronPython을 루비가이 익숙한 사람은 IronPython을 쓰면 된다.

  3. C#과 같은 프로그래밍 언어를 선택한다면 C++보다 약간 못한 최상의 성능을 얻는다. C# 2.0부터 크게 성능이 개선되어 대다수의 벤치마크에서 C++ 바로 밑을 차지한다. 생산성 향상을 이루고 이 정도의 희생만 감수하면 된다면 엄청난 일이다!

  4. 루아나 파이썬과 마찬가지로 메모리 관리를 런타임 시스템이 알아서 처리한다. RAII(Resource Acquisition Is Initialization,, 자원 획득이 초기화이다), 스마트 포인터 등의 개념을 모르는 개발자가 많다고 한탄이지만, 솔직히 왜 아직도 모든 개발자가 이런 개념을 알아야 하는지 의문이다. 예전과 달리 어셈블리를 몰라도 밥벌이가 가능한 시절이 되지 않았는가? 그리고 누구나 이런 문제를 잘 다룬다면 이 모든 걸 잘하는 여러분의 입지가 좀더 약해지지 않겠는가? 이렇게 생각하면 마음이 편하다.

  5. .NET Framework나 오픈 소스 라이브러리가 제공하는 풍부한 기능을 맘껏 활용할 수 있다. 예를 들어 XML 파싱을 생각해보자. 윈도우쪽에선 Tinyxml이나 MSXML 같은 라이브러리를 많이 이용한다. 그러나 C++쪽 라이브러리 상당수는 사용하기 불편하고 손볼 곳이 많다. 만약 XML 설정파일을 스크립트에서 읽어서 네이티브 C++ 라이브러리 쪽으로 넘기는 게 가능하다면 이런 불편을 감내하지 않아도 된다.

특히 5번의 경우가 생산성과 직결된 부분이다. 이런 고민을 하는 사람이 많고 그래서 C#처럼 깔끔한 XML 코드를 주제로 글 쓰는 사람도 있다. 이 글에서 제시한 C++ 코드와 C# 코드를 비교한 부분을 살펴보면 뭘 말하려는지 알 것이다.

[목록 3] C++에서 XML 다루기
#include "stdafx.h"
#include <stdio.h>
#include <windows.h>
#import <msxml3.dll> raw_interfaces_only

// Macro that calls a COM method returning HRESULT value:
#define HRCALL(a, errmsg) \
do { \
    hr = (a); \
    if (FAILED(hr)) { \
        dprintf( "%s:%d  HRCALL Failed: %s\n  0x%.8x = %s\n", \
                __FILE__, __LINE__, errmsg, hr, #a ); \
        goto clean; \
    } \
} while (0)

// Helper function that put output in stdout and debug window
// in Visual Studio:
void dprintf( char * format, ...)
{
    static char buf[1024];
    va_list args;
    va_start( args, format );
    vsprintf_s( buf, format, args );
    va_end( args);
    OutputDebugStringA( buf);
    printf("%s", buf);
}

// Helper function to create a DOM instance:
IXMLDOMDocument * DomFromCOM()
{
   HRESULT hr;
   IXMLDOMDocument *pxmldoc = NULL;

   HRCALL( CoCreateInstance(__uuidof(MSXML2::DOMDocument30),
                  NULL,
                  CLSCTX_INPROC_SERVER,
                  __uuidof(IXMLDOMDocument),
                  (void**)&pxmldoc),
                  "Create a new DOMDocument");

    HRCALL( pxmldoc->put_async(VARIANT_FALSE),
            "should never fail");
    HRCALL( pxmldoc->put_validateOnParse(VARIANT_FALSE),
            "should never fail");
    HRCALL( pxmldoc->put_resolveExternals(VARIANT_FALSE),
            "should never fail");

    return pxmldoc;
clean:
    if (pxmldoc)
    {
        pxmldoc->Release();
    }
    return NULL;
}

int _tmain(int argc, _TCHAR* argv[])
{
    IXMLDOMDocument *pXMLDom=NULL;
    IXMLDOMParseError *pXMLErr=NULL;
    BSTR bstr = NULL;
    VARIANT_BOOL status;
    VARIANT var;
    HRESULT hr;

    CoInitialize(NULL);

    pXMLDom = DomFromCOM();
    if (!pXMLDom) goto clean;

    VariantInit(&var);
    V_BSTR(&var) = SysAllocString(L"stocks.xml");
    V_VT(&var) = VT_BSTR;
    HRCALL(pXMLDom->load(var, &status), "");

    if (status!=VARIANT_TRUE) {
        HRCALL(pXMLDom->get_parseError(&pXMLErr),"");
        HRCALL(pXMLErr->get_reason(&bstr),"");
        dprintf("Failed to load DOM from stocks.xml. %S\n",
                    bstr);
        goto clean;
    }
    HRCALL(pXMLDom->get_xml(&bstr), "");
    dprintf("XML DOM loaded from stocks.xml:\n%S\n",bstr);

clean:
    if (bstr) SysFreeString(bstr);
    if (&var) VariantClear(&var);
    if (pXMLErr) pXMLErr->Release();
    if (pXMLDom) pXMLDom->Release();

    CoUninitialize();
    return 0;
}
			
[목록 4] C#에서 XML 다루기
using System;
using System.Xml;

static void Main(string[] args)
{
	try
	{
		XmlDocument xmlDoc = new XmlDocument();

		xmlDoc.Load("stocks.xml");
		Console.WriteLine("Loaded stocks.xml");
	}
	catch (Exception e)
	{
		Console.WriteLine(e.Message);
	}
}
			

반면 단점도 있다. 무엇보다 메모리 관리 방식을 바꾸기 힘들다. 런타임 시스템이 메모리를 관리하는데 이 방식을 구미에 맞게 개조하기 어렵다. 가능하더라도 한계가 있다. 좀더 구체적으로 말해 문제가 되는 부분은 메모리 회수 시점이다. 똑같이 메모리를 자동관리하더라도 루아나 파이썬은 그때그때 메모리를 회수한다. 일종의 스마트 포인터를 떠올리면 된다. 반면 CLR은 물리 메모리가 부족해진 시점에 한꺼번에 메모리를 회수한다.

후자는 장단점이 있다. 힙 구현이 사실상 스택과 같기 때문에 할당이 매우 빠르다. 하지만 때가 되면 모든 스레드를 정지시키고 메모리를 회수한다. 게임 서버를 예로 든다면 이때 소위 말하는 렉이 발생한다. 렉 때문에 소중한 캐릭터가 죽은 경험을 해봤다면 걱정이 될 것이다. 그러나 월드 오브 워크래프트 같은 세계적인 게임도 한번씩 이런 현상을 겪는다. 얼마나 자주 이런 일이 벌어지는지, 한번 벌어지면 얼마나 오래 지속되는지가 문제다. 이 문제에 대한 명확한 답은 아직 없다. CLR을 이용한 스크립트 엔진 자체가 아직 보기 드물기 때문이다. 업계에서 많이 쓰는 루아조차도 엄밀한 비교 연구는 전무하다. 확실한 점은 물리 메모리가 충분하다면 렉의 주기도 길어질 거란 사실이다.

또 다른 단점은 기술적인 면보단 고객 서비스란 측면이 강하다. CLR을 활용하려면 모노든 .NET Framework를 설치해야 한다. 꽤 용량도 커서 배포하기도 힘들고 설치 시간도 길다. 그런 까닭에 클라이언트측 스크립트 엔진보단 서버측 스크립트 엔진 개발을 개발할 때 더 적합하다.

스크립트 엔진의 요구사항

다양한 프로그래밍 언어의 지원

앞서 짧게 언급한 바 있듯이 사람마다 선호하는 프로그래밍 언어가 다르다. 그러니 IronPython, IronRuby, C# 등 중에서 원하는 걸 골라 쓰면 좋을 것이다. 물론 이러한 기능을 구현하는데 엄청난 자원이 소모된다면 안 하는 편이 나을지 모른다. 하지만 걱정하지 않아도 좋다. 앞으로 알아보겠지만 별로 고생할 일 없다. 싱겁다 싶을 정도로 간단한 일이니 한번 도전해보자.

동적 적재 지원

동적 적재라 하면 실행시간에 어떤 기능을 추가하거나 갱신하는 일을 뜻한다. 플러그인이 동적 적재의 사례라 할 수 있다. 플러그인을 설치하고 활성화 버튼을 누르면 기능이 작동되는 경우라면 말이다. 그러나 플러그인이 꼭 동적 적재를 뜻하지는 않는다. 때로는 플러그인 설치 뒤에 응용프로그램을 다시 시작하라는 메시지가 뜬다. 정적 적재만 지원하는 경우이다.

CLR에서 신규 기능을 동적으로 추가하는 일은 쉽다. 자세히 설명하기엔 지면이 부족하니 나중으로 미루겠지만 정말이지 쉽다. 다만 조건이 있다. 동적으로 새 기능을 추가할 때만 쉽다. 만약 기존 기능을 실행 시간에 갱신하거나 삭제해야 한다면? 안타깝지만 문제의 난이도가 두세 배로 는다. 이는 응용프로그램 도메인(AppDomain)과 관련이 있다. 메모리에 올린 코드를 깔끔하게 삭제하고 갱신하려면 응용프로그램 도메인 별로 코드를 나누어 관리해야 한다. 자세한 이야기는 역시 뒤로 미룬다.

끝마치는 말

이번 칼럼에선 C++/CLI를 이용한 스크립트 엔진의 가능성과 장단점, 그리고 요구사항을 알아봤다. 실제 게임 스크립트의 예제를 본 것 빼곤 프로그래밍적 측면을 거의 다루지 못해 아쉽다. 하지만 이로써 실제 사례를 구현하려면 필요한 기초지식은 거의 다 습득했다. 이제부턴 실제로 이런 시스템을 구현해보고 어떤 결과가 나오는지 확인하는 일만 남았다. 그 과정에서 C++/CLI를 제대로 이해하는 기회가 될 것이다.

Kubernetes, DevSecOps, AWS, 클라우드 보안, 클라우드 비용관리, SaaS 의 활용과 내재화 등 소프트웨어 개발 전반에 도움이 필요하다면 도움을 요청하세요. 지인이라면 가볍게 도와드리겠습니다. 전문적인 도움이 필요하다면 저의 현업에 방해가 되지 않는 선에서 협의가능합니다.
follow me
  • 싸이월드 법인가 뭔가 화제였는데 이런 게 훨씬 현실적인 접근이다 https://t.co/fSB9LiMYzO
    1 day ago
  • 시장을 좋게 보는 사람을 좋게 볼 근거를 찾고 그렇지 않은 사람은 나쁘게 볼 근거만 열심히 찾네. 그 반대로 해야 얻는 게 있을텐데
    1 day ago
  • 일본이 liberal country 라는 말이 마음에 걸리네 https://t.co/aLteP9gEE8
    2 days ago
Buy me a coffeeBuy me a coffee
×
Kubernetes, DevSecOps, AWS, 클라우드 보안, 클라우드 비용관리, SaaS 의 활용과 내재화 등 소프트웨어 개발 전반에 도움이 필요하다면 도움을 요청하세요. 지인이라면 가볍게 도와드리겠습니다. 전문적인 도움이 필요하다면 저의 현업에 방해가 되지 않는 선에서 협의가능합니다.
Latest Posts