Loading
2022. 2. 25. 21:56 - lazykuna

게임에서의 소프트웨어 디자인 패턴

왜 하필 게임?

게임만큼 다양한 자료구조를 사용하기 좋은 분야가 많지 않을 것 같다. 많은 데이터를 실시간으로 처리해야 하는 게임 특성상 효율적인 자료 처리를 위해 다양한 디자인 패턴들이 사용되는 것을 많이 봤다.

그래서 근래 "게임 프로그래밍 패턴"이라는 책을 읽으며, 보다 구체적으로 실제 게임 프로그래밍상에서 패턴들이 어떻게 사용되는지 공부해보앗다. 그리고 놀랍게도 이 책에서 다룬 내용들을 오픈소스화 된 게임 Stepmania의 소스코드에서 많이 확인할 수 있었다.

이런 사례들을 가지고, 나 스스로도 정리해보는 시간을 가져볼 참에 내용을 작성하게 되었다.

최근 유행하는 분산 시스템과는 맥락을 다르게 하는 디자인 패턴들이 많은데, 게임 자체가 Monolithic 시스템에 가깝고 이에 최적화된 설계를 하기 때문이다. 아마 큰 틀에서의 설계보다는 세부적인 low-level 코드레벨 단에서의 설계에 가깝다고 봐야 할 것 같다. 물론, 디자인의 중점이 다르다 뿐이지, 어떤 분야를 하든간에 다 알아두면 뼈가 되고 살이 되는 내용들이다.

디자인 패턴 도입의 필요성과 현실 타협

  • 우리 모두는 깨끗한 코드를 원하고, 깨끗한 코드의 정의는 "고치려고 하는 로직에 필요한 부분이 모두 제공되어 있는 마치 5성급 호텔 같은" 느낌
  • 즉 코드 유지보수를 위해 깨끗한 코드가 필요하고, 코드 유지보수를 하려면 기존 코드에 대한 이해가 필요함. 기존 코드 이해를 빠르게 하기 위한 방법으로, "디커플링"이라는 대안이 주로 사용됨.
  • 하지만 어떤 방식이든 구조화가 잘 된 코드를 만들고 유지하는데는 큰 비용이 든다. 이용되지 않을 부분에 대한 개선에 대해서는 과감히 포기할 수도 있어야 한다.
  • 그리고 유연한 구조를 도입하는 것은, 성능에 대해서 어느정도 타협해야 할 여지가 생긴다는 말과도 동일하다. 성능과 코드구조는 등가교환이다. 코드구조 먼저 개선하고, 성능을 나중에 챙기는 방식으로 접근할 수도 있을 것이다.

디자인 패턴 다시 보기

GoF는 프로그래밍 패턴에 큰 영향을 끼쳤는데, 그 중 일부 패턴은 너무 많이 쓰여 오용/남용되고 있고 어떤 패턴은 과소평가 되곤 했다. 이들에 대해서 다시 짚고 넘어갈 필요가 있다.

명령 (Command)

요청 자체를 캡슐화하는 것이다. 해당 패턴의 매력은 어떤 상황에 대한 행동을 ifelseendif 지옥으로 만드는 대신, 상황 객체로부터 행동 객체를 받아와서 이를 수행하는 형태로 만들 수 있다는 점에 있다. 객체 메서드만을 이용하기 때문에 if 지옥에서 자유롭다.

후에 Undo/Redo를 구현하는 데에도 쉽게 쓸 수 있다.

함수형 프로그래밍 패턴으로도 짤 수 있다는 것이 매우 흥미로운 점이다. 이를테면 아래 방식처럼 짤 수가 있을 듯...

function makeMoveUnitCommand(unit, x, y) {
    var xBefore, yBefore;
    return {
        execute: function () {
            xBefore = unit.x();
            yBefore = unit.y();
            unit.moveTo(x, y);
        }
        undo: function() { ... }
    };
}

다만 C/C++의 경우에는 클로저 기능의 미성숙함으로 인해, 클래스를 이용하여 짠다.

경량 패턴

enum에 대한 행위를 객체화시킬 때 사용한다. 중복되는 객체를 여러개 만들지 않고, enum을 대표하는 constant 객체를 만들어서 이를 여기저기서 사용하는 게 포인트...

나 스스로도 불필요한 if 구문을 많이 제거할 수 있기 때문에 유용하게 쓰는 패턴이기도 하다.

관찰자 패턴

  • 해당 패턴이 유명하다는 것은 알고 있었는데, 어떠한 형태로 언제 사용되는지 잘 와닿지 않았다.
  • 디커플링을 통한 의존성 감소가 이를 통해 얻을 수 있는 강력한 효과이다. 관찰자 패턴을 통해 이벤트 발생을 직접 호출하지 않아도 되므로 디커플링이 가능해진다.
  • 유명한 MVC 모델도 관찰자 패턴 기반이다! 보통 View가 Model의 Observer이 되니까...

생각해보니 이벤트 핸들러를 사용하는 부분에서 . stepmania 같은 경우는 아예 모든 Actor들이 아예 Observer들로서 사용되고 있다. 게임 내 Actor을 생성할 때 핸들러를 지정할 수 있는데, 이 핸들러들은 Event manager(MessageManager)에 의해서 등록되고 호출된다.

// 객체에 이벤트 스크립트가 달려 있어, 스스로가 이벤트 핸들러를 등록하고 있다
void Actor::AddCommand( const std::string &sCmdName, apActorCommands apac, bool warn )
{
    ...

    if( GetMessageNameFromCommandName(sCmdName, sMessage) )
    {
        SubscribeToMessage( sMessage );
        command_name= sMessage;    // sCmdName w/o "Message" at the end
    }
    else
    {
        command_name= sCmdName;
    }

    ...
}

// 객체가 지워질 때 스스로 이벤트 Detach 시킨다
Actor::~Actor()
{
    StopTweening();
    UnsubscribeAll();
    ...
}

// 이벤트 매니저의 경우, 이벤트 종류에 따른 observer list가 존재한다.
void MessageManager::Subscribe( IMessageSubscriber* pSubscriber, const std::string& sMessage )
{
    LockMut(g_Mutex);

    SubscribersSet& subs = g_MessageToSubscribers[sMessage];
#ifdef DEBUG
    SubscribersSet::iterator iter = subs.find(pSubscriber);
    ASSERT_M( iter == subs.end(), fmt::sprintf("already subscribed to '%s'",sMessage.c_str()) );
#endif
    subs.insert( pSubscriber );
}


// 필요하다면 메시지 자체의 로깅을 할 수도 있을 것이다. Event sourcing에 쓰기 좋은 모양새가 될 것이다.
...
    void SetLogging(bool set) { m_Logging= set; }
    bool m_Logging;
...

// 등등 ...

이를 통해 다양한 Actor, 혹은 Kbd/Mouse event와 다른 Actor이 서로 얽히지 않도록 구현할 수 있게 되었다. 서로 객체간 호출해야 하는 메서드를 직접적으로 부르지 않기 때문이다.

유의할점은 Observer이 상위 계층을 건드리지 않도록 해야 한다는 것. (계층적 구조를 염두에 두어야 함)

그리고 메인스레드 + 비동기 상황에서는 적합하지 않을 수 있다. 게임 프로그래밍에서 제일 큰 딜레마중 하나! 이 경우에는 이벤트 큐를 쓰는 것을 고민해 볼 수 있다.

최근에는 클래스(객체)로서 관찰자 객체를 구현하기보다 함수 형태로 만들어 등록하는 것이 보다 일반적이라고 함. 추후에는 데이터 바인딩 형태의 모습이 될 것이라고 한다.

프로토타입

나 같은 경우는 자주 사용할 일은 없었지만 때로는 객체를 새로 만드는 것보다는 프로토타입을 만들고, 이를 clone하여 구현하는 것이 더 깔끔한 구조를 만들 수 있다.

자바스크립트 언어가 이 프로토타입 철학을 철저하게 잘 이용한 것으로 잘 알려진 언어이다. 하지만 실제 개발에 사용하는 것은 어렵기 때문에, 교묘하게 프로토타입 특성을 드러나지 않도록 하여 다른 언어들처럼 클래스를 사용하는 것처럼 느끼게 해줬다. 이를테면 clone 이 없고, prototype이 암시적으로 설정되고...

보다 실용적인 접근으로는, 데이터 모델링 시스템에서 사용할 수 있을 것이다. 이 경우 join이나 간접 포인터의 개념에 보다 가까운 느낌으로 사용된다.

싱글턴 (Singleton)

싱글턴 패턴은 잘 알려져 있다시피 오직 한개의 클래스 인스턴스만 가지고 있는 것이 보장된다면 언제든지 사용할 수 있는 패턴이다.

하지만 무엇보다도 싱글턴 패턴은 남용해서 좋을 것이 없다. 쉽게 사용할 수 있는 전역변수로 인해서 scope이 뒤죽박죽이 되고, 인스턴스가 기하급수적으로 무거워지고, 결국 손댈 수 없는 코드가 되어가는 것은 매우 흔하게 볼 수 있는 일이다.

싱글턴 패턴과 멀티스레드

싱글턴 패턴을 사용하기 전에, 멀티스레드 경합에 대해서 인지하고 있어야 한다.

싱글턴 객체에 처음 접근할 때 객체가 생성되는 것이 일반적인 모습이다. 하지만, 두 스레드가 동시에 싱글턴 객체에 접근한다면? 동시에 객체가 생성되는 문제가 일어날 수 있다. 전형적인 멀티스레드 경합의 모습을 보여준다.

이를 방지하기 위해서 아래와 같은 디자인을 지키는 것이 국룰이 되어 있다.

/// static을 이용
/// 최신 c++11 컴파일러에서는 static local variables는 단 한번만 초기화되는 것이 보장되어 있다.
///
/// 생성자는 private에 위치해야 한다. 외부에서 임의로 instance 생성할 수 있는 여지를 방지한다.
class FileSystem {
    public:
    static FileSystem& instance() {
        static FileSystem *fsys = new FileSystem();
        return *fsys;
    }

    private:
    FileSystem() {}

싱글턴을 사용하는 이유

싱글턴을 사용하는 이유는 여러 가지가 있을 것이다.

사용하지 않는다면 인스턴스 생성하지 않는다

런타임에 초기화 된다 (lazy-initialize)

이 말은 전역 변수와는 다르게 초기화 시점을 어느 정도 조정할 수 있다는 뜻이다. 설계가 충분히 괜찮다면, 효율성과 안전성을 모두 챙길 수 있다.

상속이 가능하다

테스트 하는 입장에서 강력하다. (Mocking)

싱글턴의 문제

위에서 이야기했듯이 주로 전역 변수에서 파생되는 문제가 대다수다. 싱글턴이 전역 변수라는 것을 망각하면 안 된다. 세부적으로는 코드의 이해를 저해시키고, 코드 커플링을 복잡하게 하고, 멀티스레딩 시 문제 발생 소지가 높아진다.

그리고 위에서 이야기된 게으른 초기화가 역으로 비수를 꽃기도 한다. 제어할 수 없는 초기화이기 때문에, 변형이 필요할 수도 있다.

싱글턴의 대안

  • 필요하다면 인스턴스 개수를 체크해서 2개 이상 만들지 못하도록 할 수 있다.
  • "하위 클래스 샌드박스 패턴" 과 같은 식으로 공용 상태를 전역으로 만드는 것을 피할 수 있다.
  • "서비스 중개자 패턴"을 통해 보다 성숙한 싱글턴 구조를 사용하는 방식을 쓴다.

Stepmania 게임에서는 아래와 같이 싱글턴 패턴을 사용하고 있다.

// MessageManager.cpp
MessageManager*    MESSAGEMAN = nullptr;    // global and accessible from anywhere in our program

// StepMania.cpp

int sm_main(int, char**)
{
    ...

    // Set up the messaging system early to have well defined code.
    MESSAGEMAN    = new MessageManager;
}

사실 모범적인 싱글턴 패턴이라고 볼 수는 없고 오히려 가장 원시적인 형태이다. 왜냐하면 싱글턴을 안전하게 쓸 수 있도록 하는 안전 장치가 하나도 안 되어 있는데, 동시성 대응에서도 나쁘고, 생성자의 encapsulation이 되지 않아 아무데서나 싱글턴을 생성할 수 있기 때문이다. 어떻게 보면 서비스 중개자 패턴에 가까운 형태라고 볼 수도 있다. 서비스명을 단순히 전역 포인터 변수로 설정해놔서 그렇지...

나도 지금까지 싱글턴이라는 이름으로 전역 상태를 남용해 놓은 적이 꽤 있어서, 이번 글은 읽으면서 많은 생각을 하게 되었다. 정답은 없지만, 한 가지 확실한 것은 싱글턴 패턴을 피할 수 있으면 최대한 피하는 게 맞다.

상태

게임만큼 상태가 복잡할 수 있는 프로그램이 그렇게 많지는 않을 것 같다. 이들을 naive하게 잘못 구현하다가는, 온갖 자질구레한 변수들과 함께하는 if구문 지옥에 빠져버릴 수 있다.

컴퓨터공학 전공이라면 으레 FSM(Finite State Machine)을 들어 보았을 것이다. 그렇다, FSM이 우리를 구원할 것이다. 직접 가능한 상태 경우의 수를 그려보고, 상태들에 대해 열거형을 정의하고, 이를 구현함으로서 안전하고 명료한 코드를 짤 수 있다.

필요하다면 "동적 디스패치"를 통해 상태별 행동을 정의할 수 있다. 이게 흔히 이야기하는 정석적인 상태 패턴의 모습인데, 간단한 경우라면 굳이 객체화 할 필요는 없고 로직만으로 해결 가능할지도 모른다...

더 복잡한 경우라면, 더 복잡한 FSM의 도입도 가능하다. 이를 테면 병행 상태 기계계층형 상태 기계푸시다운 오토마타 라던가 ... 점프 도중 총을 못 쏘게 하거나, 무장한 상태에서 내려찍기를 못한다거나 하는 복잡한 상황을 다루기 위한 방법으로서 적합한 대안이 될 수 있다.

간략하게 줄여놓았지만 게임 개발쪽에서는 정말 유용하고 많이 쓰이는 패턴이다. 한번쯤 잘 알아둘 필요가 있지 않을까?

아쉽게도 Stepmania에서는 해당 패턴에 대해 눈에 띄는 구현부를 딱히 찾지는 못했다.

순서 패턴

여기에서는 컴퓨터 그래픽스 엔진에 대한 내용을 순서 패턴으로 풀어나가는 내용이 주가 된다. 흔히 쓰는 이중 버퍼링, 게임루프, 업데이트 메서드를 통해 객체들의 순차적인 처리를 어떤 방식으로 할 것인가에 대한 이야기를 한다.

이중 버퍼링의 경우 수많은 게임 엔진들에서 쓰고 있는 테크닉이다. 객체의 update 전과 후 상태를 분리하여 두는 것으로서, 으레 OpenGL이나 D3DswapBuffer 관련 코드들을 보다보면 아주 쉽게 확인할 수 있는 내용이다.

게임 메인루프는 진정한 "게임 패턴"에 해당된다. 사실 어떤 프로그램이든 (batch 형태가 아닌 이상) 그 본질에는 이벤트 루프가 있기 때문에 비슷한 모습을 띄긴 하지만... 실시간으로 처리를 해야 하는 게임 특성상 그 느낌이 더 크게 다가오는 게 있다. 그 모습은 보통 아래와 같은 구조를 띈다.

while (isRunning()) {
    processInput();
    update();
    render();
}

여기 책에서 중시하는 것이 시간에 대한 민감도라는 것이었다. 보통 이 부분 개발할 때 update(delta) 식으로 흘러간 delta 시간에 대한 처리를 수행하는 게 정석이라고 생각했는데, 부동소수점의 오차값을 고려하지 못했다. 그것보단 아래 방식처럼...

while (isRunning()) {
    processInput();
    lag += time_delta();
    while (lag >= MS_PER_UPDATE) {
        update();
        lag -= MS_PER_UPDATE;
    }
    render();
}

단순 update()를 여러 번 수행하는 것이 정답이 될 수 있다는 건 꽤 신박한 방안이었다.

Unity의 Monobehavior Lifecycle에서 Game Loop에 대해서 상세 설계 및 설명이 있다.

참고로 stepmania에서는 "가변 시간 간격 방식"을 사용하고 있다. 아무래도 프로그램 설계 자체가 오래된 점이 있어서 어쩔 수 없나 보다 ^^;

// stepmania - time-delta based update
void GameLoop::RunGameLoop()
{
    ...

    // Update
    float fDeltaTime = g_GameplayTimer.GetDeltaTime();

    ...

    SOUNDMAN->Update();
    SOUND->Update( fDeltaTime );
    TEXTUREMAN->Update( fDeltaTime );
    GAMESTATE->Update( fDeltaTime );
    SCREENMAN->Update( fDeltaTime );
    MEMCARDMAN->Update();
    NSMAN->Update( fDeltaTime );

    ...
}

업데이트 메서드의 경우는 Stepmania의 Actor 클래스에 해당된다. 장황하게 풀어 쓰였는데, 그냥 children에 대해서 순차적으로 도는 전형적인 순서 패턴이라 크게 중요한 내용은 없을 듯.

행동 패턴

게임 스크립트

요즘의 게임들은 모든 행위들이 하드코딩되어 있지 않고, 외부 스크립트를 수행시키는 형태로 만들어져 있다. 필요하다면 모딩(modding)도 가능하다.데이터의 실행이라고 볼 수 있다.

이러한 것들을 구현함에 있어 인터프리터 구조, 그리고 바이트코드 형태의 이점에 대해서 간단하게 다룬다. 물론 게임은 성능이 중요하기 때문에, 절대적으로 locality 감안해서 바이트코드를 선택하는 쪽이 낫다고 한다.

물론 이런 인터프리터/바이트코드 VM을 직접 만들 필요는 없다. stepmania 같은 경우에는 lua interpreter/engine을 내장하여 runtime에서 수행할 수 있도록 하였다. 이런 구조 덕분에 유저가 자유롭게 modding 하는것도 가능하고, 기묘한 플레이도 할 수 있게 되었다. beatoraja의 경우 theme에 ES6 엔진을 일부 빌려 쓰고 있다.

stepmania에서 재미있는 부분이, 바이트코드가 수행되는 시점이 게임 메인 루프가 되는 것을 최대한 지향하고 있다. 이를 통해 성능상의 문제를 최대한 피하고 있는데, 이를테면 OnLoad시에 Animation 정보를 Compile 하도록 되어 있고, OnRender과 같이 매 렌더링마다 돌아가는 스크립트는 겅의 없는 구조로 되어 있다.

아무튼, 있는 걸 잘 가져다 쓰는 것도 중요한 일이라고 생각한다.

Facade, 하위 클래스 샌드박스, 타입 객체

위 패턴들은 모두 행동을 효율적으로 표현하기 위한 코드 디자인이다.

  • 하위 클래스 샌드박스를 통해 중복되는 행동을 효율적으로 구현할 수 있다. 상속 대신 컴포넌트로 표현함으로서 디커플링과 동시에 파사드(Facade) 형태의 패턴으로 만들어 쓸 수도 있다.
  • 타입 객체의 경우, 매 객체를 코드로 만드는 것이 아니라 데이터로부터 객체를 만드는 방법을 제시한다.

결국은, 하드코딩보다 데이터로 표현하는 것이 효율적인 경우에는, 데이터의 실행화를 고려해 볼 수 있다는 점이 핵심인 것 같다.

디커플링 패턴

코드간의 커플링(의존성)을 낮춰주는 패턴이다. 리팩토링에서 으레 만나는 패턴 중 하나.

왜 의존성을 낮추는 게 중요한지는 많이들 알고 있을 것이다. 여러 모듈의 코드가 서로 얽히고 설킬수록 코드의 이해가 어려워지고, 이는 효율성 저하 및 버그를 유발한다. 그리고 이후의 리팩토링 또한 엄청난 투자를 해야 한다. 그리고 디커플링을 통해 분리된 모델은 이후 재사용을 유도할 수 있다.

하지만 언제나 그렇듯이 과도한 엔지니어링을 하지 않도록 유의할 필요도 있다. 이는 생산성의 낭비이고 성능에도 악영향을 끼칠 수 있다.

코드 수준에서의 디커플링은 비교적 간단한 편이다. 으레 해오던 feature extraction 을 적절한 단위로 수행하는 것으로 할 수 있다. 필요하다면 로직을 객체로 분리하는 컴포넌트 형태로 분리할 수도 있다.

객체간 통신의 경우 다소 복잡할 수 있다.

  1. 객체를 직접 참조하게 하거나
  2. 메시지 기반 통신을 사용하는 방법이 있다.

전자는 커플링의 문제, 후자는 추가적인 오버로드가 있다는 것이고, 정답은 없다.

메시지 기반 통신을 위해 이벤트 큐를 만드는 방법 또한 디커플링을 위한 수단이다. 근데 꼭 이벤트 큐일 것은 아니고, 관찰자 패턴도 괜찮은 방법이라고 생각한다.

  • 비동기 처리: 비단 디커플링을 넘어서, 다소 수행시간이 걸릴 수 있는 작업을 비동기로 처리하기 위해서 사용할 수도 있다. 관찰자 패턴과 유사하지만 가장 큰 차이점을 만드는 게 바로 이것이라고 생각한다.
  • 피드백 루프 주의: 보통 이벤트를 처리하는 쪽에서 이벤트를 생성하지 않는 방향으로 구현해야 한다.

서비스 중개자를 통해서 특정 객체를 간접적으로 가져오는 구현 방법 또한 디커플링 구현 방법 중 하나로 소개하고 있다.

방식은 다양하지만, 직접적인 참조는 커플링을 유발하기 때문에, 간접적으로 객체를 참조하는 방법에 대해서 설명하는 것이 공통적인 내용이다.

stepmania 같은 경우 이 부분에 대해 눈에 띄는 작업 내역은 없었다. 각 screen마다 sound / graphic 등을 직접 모두 호출하고 처리하고 있는 형태였기 때문에... 😂

최적화 패턴

최적화는 게임에서 참 중요한 분야 중 하나이다. 유저가 바로 체감할 수 있기에 더더욱 그렇다고 생각한다. 그래서 이 부분에 대한 유용한 패턴을 또 따로 묶어서 설명해주고 있다.

데이터 지역성은, 연산의 효율성 이전에 데이터 배치의 효율성이 얼마나 중요한지를 설명해주고 있다. 이 부분은 컴퓨터공학도라면 아마 어느 정도는 다들 알고 있겠지만, 왜 중요한지에 대한 이유가 꽤 인상깊어서 적어둔다.

지금껏 우리는 속아왔다. 매년 증가하는 CPU 속도 그래프는 무어의 법칙을 단순한 관찰의 결과가 아닌 절대불변의 법칙인 양 보여줬다. ... (중략) ... 하지만 무어의 법칙이 말하지 않은 것이 있다. 분명 이전보다 데이터 연산은 훨씬 빨라졌지만, 데이터를 가져오는 건 그다지 빨라지지 않았다.

이를 위한 방법으로 활성화된 객체 리스트 별도 관리, 중요 변수들 직접 접근하도록 설계하기, 다형성 줄이기 등의 테크닉을 제시한다. 근데 이 부분은 비단 게임이 아니더라도, 시스템 프로그래밍에서도 많이 고민하여 설계하는 부분이긴 하다

이외에도 더티 플래그, 객체 풀과 같은 방식으로 효율적인 메모리 사용, 불필요한/중복 연산을 최대한 줄이는 방법이 있다.

이외에도, 책에서 소개하지는 않았지만 GPU call을 줄이는 것도 상당히 중요하다고 생각한다. 주로 이를 위한 방법으로 Batch processing을 사용한다. 관련 API를 이용한 설계를 할 수 있으면 좋다고 생각한다. 관련 링크: OpenGL Batch Rendering

참조한 내용