이벤트 큐 (Event Queue)

메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.

 

 

동기

UI의 버튼을 클릭하는 등 프로그램과 상호작용할 때마다 OS는 이벤트를 만들어서 프로그램 쪽으로 전달하고, 프로그램은 이를 받아서 이벤트 핸들러 코드에 전달하여 원하는 행동을 하도록 처리해야 한다.

보통 이런 이벤트들을 받기 위해서 코드 깊숙한 곳 어딘가에 이벤트 루프가 작성되어 돌아간다.

 

while (running) {
    Event event = getNextEvent();
    // .. 이벤트 처리
}

대략적으로 이런식으로 생겼다.

 

getNextEvent를 호출하여 아직 처리되지 않은 사용자 입력을 가져오고 이벤트 핸들러로 보내면 정의된 동작을 수행한다. 이 때, 프로그램이 원할 때 이벤트를 가져오는 것이지 OS가 프로그램의 코드를 바로 호출하는 것이 아니다.

OS는 프로그램이 이벤트를 가져가기 전까지 큐에 보관해둔다.

 

보통 OS의 큐를 이용하기 보다는 게임에 알맞게 자체 제작된 이벤트 큐를 만들어서 중추 통신 시스템으로 활용한다.

게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준의 통신을 하고 싶을 때 이벤트 큐를 사용한다.

 

 

특정한 인게임 이벤트가 발생할 때 팝업 도움말을 보여주는 튜토리얼 시스템을 만든다고 가정해보자.

게임플레이와 전투와 관련된 코드는 이미 충분히 복잡하게 구현되어있기 때문에 튜토리얼 시스템과 관련된 코드를 끼워넣어서 더 복잡하게 만드는 대신 중앙 이벤트 큐를 만들면 어느 시스템에서도 큐에 이벤트를 보낼 수 있게된다.

각 시스템들은 큐의 존재만 알뿐, 그 외 다른 시스템을 모르더라도 어떤 이벤트가 발생한 사실을 전달할 수 있다.

 

class Audio {
public:
    static void playSound(SoundId id, int volume);
};

void Audio::playSound(SoundId id, int volume) {
    ResourceId resource = loadSound(id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, volume);
}

이번에는 위와 같은 오디오 엔진을 간단하게 구현해서 적용해본다고 해보자. 정적 함수이기 때문에 어디서든 호출할 수 있다. 그런데 반복적으로 호출하다보면 몇 프레임씩 멈출 때가 있다. 그 이유를 알아보자.

 

◾ 문제 1: API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 블록한다

playSound 함수는 동기적이라서 내부적으로 호출한 API의 실행이 끝나기 전까지 계속 대기한다.

그 와중에 리소스를 로딩하는 작업까지 해야한다면 더 오래 대기해야 하고 그동안 게임은 멈추게된다.

게다가 몬스터 여러마리가 동시에 피격되기라도 하면 파형이 더해지기 때문에 소리가 매우 커져서 거슬리게 된다.

이런 문제는 전체 사운드 호출을 취합하고 우선순위에 따라 나열한 후 처리해야하지만 동기적으로 처리하기 때문에 해결할 수 없다.

 

◾ 문제 2: 요청을 모아서 처리할 수가 없다

설령 오디오용 스레드를 별도로 만든다 하더라도 문제가 더 심각해질 뿐 전혀 해결되지 않는다.

 

◾ 문제 3: 요청이 원치 않는 스레드에서 처리된다

결론적으로 원인은 playSound 호출 시, 하던 일을 멈추고 사운드를 즉시 처리하라고 해석하는 것에 있다.

 

 

패턴

큐는 요청이나 알림을 들어온 순서대로 저장한다. 알림을 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. 이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링한다.

 

 

언제 쓸 것인가?

단순히 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 관찰자 또는 명령 패턴을 사용하면 된다.

이벤트 큐는 송수신 시점까지 분리하고 싶을 때에만 필요한 것이다.

요청을 처리하는 제어권은 수신측에서 처리하는게 자연스럽고 편하다. 받은 요청을 지연시키거나 모아서 한번에 처리할 수 있고 심지어 요청을 버릴수도 있다. 만약 송신 측에서 피드백을 받아야 한다면 큐를 사용하는 것이 적합하지 않다.

 

 

주의사항

이벤트 큐는 전체 시스템의 맨 위에서 서로 메시지를 주고받게 해주는 통로 역할로 많이 사용된다.

사실상 전역 변수와 같기 때문에 아무리 조심해도 상호 의존성 문제가 생기게 되어있다.

그나마 이벤트 큐 패턴은 중앙 이벤트 큐를 간단한 프로토콜로 깔끔하게 래핑하지만 그래도 결국 전역이기 때문에 그와 관련된 문제가 사라지지 않는다.

 

월드의 상태는 언제든지 변화한다는 것도 이벤트 큐 시점에서 바라보면 문제가 될 수 있다.

주변의 정보를 이용하는 이벤트가 지연 처리된다면 지연되는 그 사이에 정보가 사라질 가능성이 있다.

이벤트가 만들어진 시점의 월드와 처리되는 시점의 월드의 상태가 다를 수 있다는 점을 매우 주의해야한다.

그래서 동기적으로 처리되는 이벤트보다 큐에 들어가는 이벤트에는 데이터가 훨씬 많이 필요하게 된다.

 

피드백 루프에 빠질 수 있다는 점도 주의해야한다.

메시징 시스템이 동기적이면 크래시가 발생하기 때문에 디버깅이 어렵지 않겠지만 비동기로 처리되다 보니 콜스택이 풀려서 쓸데없이 계속 이벤트를 주고받아서 자원을 갉아먹고 있음에도 불구하고 게임은 계속 실행된다.

보통 이런 문제를 피하기 위해서 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는다.

 

 

예제

struct PlayMessage {
    SoundId id;
    int volume;
};

지연처리할 때 필요한 정보를 저장하는 간단한 구조체를 정의했다.

 

class Audio {
public:
    static void init() { numPending_ = 0; }
    static void playSound(SoundId id, int volume);
    static void update();
    
    // ...
    
private:
    static const int MAX_PENDING = 16;
    static PlayMessage pending_[MAX_PENDING];
    static int numPending_;
};

void Audio::playSound(SoundId id, int volume) {
    assert(numPending_ < MAX_PENDING);

    pending_[numPending_].id = id;
    pending_[numPending_].volume = volume;
    numPending_++;
}

void Audio::update() {
    for (int i = 0; i < numPending_; i++)
    {
        ResourceId resource = loadSound(pending_[i].id);
        int channel = findOpenChannel();
        if (channel == -1) return;
        startSound(resource, channel, pending_[i].volume);
    }
    numPending_ = 0;
}

이제 사운드 재생을 즉시 처리하지 않고 큐에 집어넣게 된다.

update 함수는 메인 게임 루프나 오디오 스레드 등 적당한 곳에서 호출해주면 된다.

그런데 위의 방법은 아직 반쪽짜리 큐다. update를 한 번 호출하면 모든 사운드 요청을 다 처리하기 때문에 여전히 동기적이다.

 

◾ 원형 버퍼

 

class Audio {
public:
    static void init() {
        head_ = 0;
        tail_ = 0;
    }
    static void playSound(SoundId id, int volume);
    static void update();

private:
    static int head_;
    static int tail_;
};

void Audio::playSound(SoundId id, int volume) {
    assert(tail_ < MAX_PENDING);

    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_ = (tail_ + 1) % MAX_PENDING;
}

void Audio::update() {
    // 둘이 같으면 보류된 요청이 아무것도 없다는 뜻
    if (head_ == tail_) return;

    ResourceId resource = loadSound(pending_[head_].id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, pending_[head_].volume);

    head_ = (head_ + 1) % MAX_PENDING;
}

인덱스 대신 배열을 쪼개서 head(가장 오래 보류된 메시지)와 tail(새로운 메시지가 들어갈 위치)로 관리한다.

또한 update호출 시 한번에 모든 요청을 처리하는 대신에 한 번에 하나만 처리하도록 변경되었다.

 

 

◾ 요청 취합

원형버퍼를 사용함으로써 큐는 완성되었지만 파형의 합성으로 인해 같은 소리를 동시에 재생하면 커지는 문제부터 해결해보자.

큐를 사용함으로써 대기중인 요청을 확인할 수 있게 되었으므로 버퍼를 탐색하여 같은 요청이 있으면 병합시켜 버리면 된다.

같은 소리를 출력하라는 요청이 있으면 둘 중에 소리가 더 큰 값으로 덮어씌우고 요청을 큐에 넣지 않는다.

요청을 처리할때가 아니라 큐에 넣기 전에 취합이 일어난다는 점에 주의해야한다.

 

대신 호출하는 쪽의 처리 부담이 늘어나는 단점은 있지만 구현이 더 쉽다는 장점이 있다.

만약 큐가 크다면 요청을 처리할 때 취합하는게 나을 수도 있다. 또는 배열대신 해시 테이블 같은것을 사용해서 시간 복잡도를 줄이는 방법도 고려해 볼 수 있다.

 

큐에 메시지가 너무 오래 남아있으면 의도치 않은 동작이 될 수도 있으니 그점도 유의해야한다.

 

 

◾ 멀티스레드

스레드에 코드를 분배하는 방법은 다양하지만 오디오, 렌더링, AI 등 분야 또는 컴포넌트 별로 할당하는 전략을 많이 사용하는 편이다.

이미 코드도 분리되어 있고 큐는 캡슐화 되어있기 때문에 큐를 변경하는 코드인 playSound와 update만 thread-safe 하게 만들기만 하면 된다.

고수준에서만 언급하자면 큐가 동시에 수정되는 것만 막아내면 된다.

playSound는 일부 필드에만 값을 할당할 뿐 작업량이 많지 않기 때문에 mutex를 적용해도 그리 오래 걸리지 않고 update는 조건 변수 같은것으로 대기하게 하면 처리할 요청이 없는 동안 CPU의 낭비를 막을 수 있다.

 

 

디자인 결정

◾ 큐에 무엇을 넣을 것인가?

이벤트와 메시지는 비슷한 개념이라 용어를 혼용하며 사용했지만 실제 개념은 약간 다르다.

 

◾ 이벤트 : 이미 발생한 사건을 표현한다.

큐에 이미 발생한 일이 들어 있기 때문에 송신자는 수신자가 누군지 신경쓰지 않는다. 그래서 다수의 리스너를 지원해야 할 때도 많다.

비동기 관찰자 패턴 같은 방식으로 이벤트에 대해 반응할 수 있다.

 

◾ 메시지 : 나중에 실행했으면 하는 행동을 표현한다.

특정 리스너 하나가 요청을 들어줬으면 해서 메시지를 보내기 때문에 대부분은 리스너가 하나이다.

서비스에 비동기적으로 API을 호출하는 것과 비슷하다.

 

 

◾ 누가 큐를 읽는가?

◾ 싱글캐스트 큐 : 예제의 Audio처럼 큐 자체가 어떤 클래스의 API 일부일 때 적합하다.

 

◾ 브로드캐스트 큐 : 대부분의 이벤트 시스템이 브로드캐스트 큐이다. 리스너가 10개이고 이벤트가 하나 들어오면 리스터 모두가 해당 이벤트를 볼 수 있다.

만약 리스너가 하나도 없다면 이벤트가 버려진다.

또한 이벤트 개수×리스너 개수만큼 이벤트 핸들러가 호출되기 때문에 호출 횟수 조절을 위한 필터링이 필요할 수 있다.

 

◾ 작업 큐 : 브로드캐스트 큐와 마찬가지로 리스너가 여러개지만 큐의 데이터가 하나의 리스너에게만 전달되는 차이가 있다.

스레드 풀에 작업을 분배할 때 일반적으로 사용한다.

 

 

◾ 누가 큐에 값을 넣는가?

◾ 넣는 측이 하나일 때 : 동기형 관찰자 패턴에 가까운 형태이다. 하나의 특정 객체에서만 이벤트를 만들 수 있기 때문에 모든 리스너들은 데이터가 어디서 오는지 안전하게 추측할 수 있다.

 

◾ 넣는 측이 여러개일 때 : 피드백 루프 발생을 주의해야 한다. 또한 이벤트를 누구나 보낼 수 있기 때문에 리스너에서 보낸 쪽에 정보가 필요하다면 송신측의 객체 레퍼런스도 같이 전달해 줄 필요가 있다.

 

 

◾ 큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?

GC를 지원하는 언어에서는 크게 신경쓰지 않아도 되지만 C/C++에서는 객체의 생명을 직접 관리해야한다.

 

◾ 소유권을 전달하는 경우 : 메시지가 큐에 들어가고 나면 소유권은 송신자에서 큐에 넘어간다. 메시지를 처리할때는 수신 측에서 소유권을 가져가고 메시지 해제도 같이 해야한다. 이런 방식으로 동작하는 것이 unique_ptr이다.

 

◾ 소유권을 공유하는 경우 : 메시지를 참조하는 곳이 하나라도 있으면 계속 메모리에 남아있어야 한다. 이런 방식으로 동작하는 것이 shared_ptr이다.

 

◾ 큐가 소유권을 가지는 경우 : 송신 측에서 메시지를 직접 생성하지 않고, 큐에 새로운 메시지를 하나 달라고 요청한다. 큐는 미리 할당해놓은 메시지의 레퍼런스를 반환하고 송신 측에서 값을 채우면 수신측에서는 이 메시지를 참조한다.

말하자면 객체 풀이 큐의 보조 기억장치가 되는 셈이다.

 

 

이벤트 큐는 사실상 관찰자 패턴의 비동기형이다.

컴포넌트 (Component)

한 개체가 여러 분야를 서로 커플링 없이 다룰 수 있게 한다.

 

 

동기

AI, 물리, 렌더링, 사운드 등 서로 분야가 다른 코드끼리는 최대한 서로 모르는 것이 좋다.

이런 것들을 한 클래스 안에(ex:플레이어) 전부 넣으면 코드 길이도 길이지만 커플링이 강력해져서 시간이 갈수록 유지보수가 불가능을 향해 가게된다.

 

if (collidingWithFloor() && (getRenderState() != INVISIBLE)
    playSound(HIT_FLOOR);

이 코드를 이해하고 수정하기 위해서는 물리, 그래픽, 사운드 세가지를 다 알아야만 한다.

이런 코드가 계속 작성된다면 땜빵 코드가 계속 추가되는 악순환을 부른다.

 

해결을 위한 접근 방법은 단순하다. 하나의 거대한 덩어리인 클래스를 분야에 따라 여러 부분으로 나누면 된다.

나누어진 컴포넌트 클래스들은 디커플링 되고 필요에 따라 서로 통신이 필요한 컴포넌트끼리만 결합을 제한시킬 수 있는 장점이 생긴다.

 

상속과 컴포넌트를 비교하기 위해 예를 하나 들어보자.

 

◾ Decoration은 덤불이나 먼지같이 볼 수는 있지만 상호작용은 할 수 없는 객체

◾ Prop은 상자, 바위 나무같이 볼 수 있으면서 상호작용도 할 수 있는 객체

◾ Zone은 보이지 않지만 상호작용 할 수 있는 객체

위의 세가지 경우 처럼 게임 내의 오브젝트가 있다고 하자.

 

상속 구조

컴포넌트를 사용하지 않으면 위와 같은 상속 구조를 가져가야 하는데, Prop이 Zone과 Decoration을 상속 받는 순간 다중 상속 문제가 발생한다. 게다가 구조를 아무리 바꿔봐도 코드를 깔끔하게 재사용 할 수 없다. 최상위 클래스에 모든 기능을 올려두면 해결은 가능하겠지만 파생 클래스에서 쓰지도 않을 기능들이 들어가는 문제가 발생한다.

 

하지만 컴포넌트로 만들면 상속은 전혀 필요없기 때문에 위와 같은 구조에서 자유로워지고 클래스도 GameObject, PhysicsComponent, GraphicComponent 총 3개만 있으면 된다.

 

컴포넌트는 기본적으로 객체를 위한 플러그 앤 플레이라고 볼 수 있다.

객체의 소켓에 재사용 가능한 여러가지 컴포넌트 객체를 꽂아 넣음으로써 복잡하면서 기능이 풍부한 객체를 만들어낼 수 있다.

 

 

패턴

여러 분야를 다루는 하나의 개체가 있다. 분야별로 격리하기 위해, 각각의 코드를 별도의 컴포넌트 클래스에 둔다. 이제 개체 클래스는 단순히 이들 컴포넌트들의 컨테이너 역할만 한다.

 

 

언제 쓸 것인가?

게임 개체를 정의하는 핵심 클래스에서 가장 많이 사용되지만, 다음 조건 중 하나라도 만족하면 다른 분야에서도 유용하게 쓸 수 있다.

 

◾ 한 클래스에서 여러 분야를 건드리고 있어서, 이들을 서로 디커플링하고 싶다.

◾ 클래스가 거대해져서 작업하기가 어렵다.

◾ 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다. 단, 상속으로는 딱 원하는 부분만 골라서 재사용 할 수가 없다.

 

 

주의사항

컴포넌트 패턴을 적용한다면 클래스 하나에 코드를 몰아놨을 때보다 더 복잡해질 가능성도 존재한다.

또한 컴포넌트끼리 통신하기도 더 어렵고, 컴포넌트들을 메모리 어디에 둘지 제어하는 것도 더 복잡해진다.

 

하지만 규모가 커지면 복잡성에서 오는 손해보다 디커플링과 코드 재사용에서 얻는 이득이 더 커질 수 있다.

 

컴포넌트 패턴을 적용하기 전에 아직 존재하지도 않는 문제에 대한 해결책을 오버엔지니어링하려는 것은 아닌지 고민해 볼 필요가 있다.

 

그리고 필연적으로 무언가를 하려면 컴포넌트로부터 얻어서 사용해야 하기 때문에 최소 한 단계 이상을 거쳐야 할 때가 많아져서 성능 하락이 발생할 수도 있다.

 

 

예제

명확한 이해를 위해 더미 코드들을 작성하기 때문에 다른 패턴에 비해 예제 코드가 좀 길다.

 

더보기
class Bjorn {
public:
    Bjorn() : velocity_(0), x_(0), y_(0) {}
    void update(World& world, Graphics& graphics);
    
private:
    static const int WALK_ACCELERATION = 1;
    
    int velocity_;
    int x_, y_;
    
    Volume volume_;
    
    Sprite spriteStand_;
    Sprite spriteWalkLeft_;
    Sprite spriteWalkRight_;
};

// 매 프레임 호출
void Bjorn::update(World&, Graphics& graphics) {
    // 입력에 따라 속도 조절
    switch (Controller::getJoystickDirection()) {
    case DIR_LEFT:
        velocity_ -= WALK_ACCELERATION;
        break;
            
    case DIR_RIGHT:
        velocity_ += WALK_ACCELERATION;
        break;
    }
    
    x_ += velocity_;
    world.resolveCollision(volume_, x_, y_, velocity_);
    
    // 알맞은 스프라이트 그림
    Sprite* sprite = &spriteStand_;
    if (velocity_ < 0) sprite = &spriteWalkLeft_;
    else if (velocity_ > 0 sprite = &spriteWalkRight_;
    graphics.draw(*sprite, x_, y_);
}

우선 컴포넌트 패턴을 적용하지 않은 통짜 클래스이다.

 

더보기
class InputComponent {
public:
    void update(Bjorn& bjorn) {
        switch (Controller::getJoystickDirection()) {
        case DIR_LEFT:
            bjorn.velocity -= WALK_ACCELERATION;
            break;
            
        case DIR_RIGHT:
            bjorn.velocity += WALK_ACCELERATION;
            break;
        }
    }
private:
    static const int WALK_ACCELERATION = 1;
};

class Born {
public:
    int velocity;
    int x, y;
    
    void update(World& world, Graphics& graphics) {
        input_.update(*this);
        
        // 속도에 따라 위치 변경
        x += velocity;
        world.resolveCollision(volume_, x, y, velocity);
        
        // 알맞은 스프라이트 그림
        Sprite* sprite = &spriteStand_;
        if (velocity_ < 0) sprite = &spriteWalkLeft_;
        else if (velocity_ > 0 sprite = &spriteWalkRight_;
        graphics.draw(*sprite, x_, y_);
        
private:
    InputComponent input_;
    
    Volume volume_;
    
    Sprite spriteStand_;
    Sprite spriteWalkLeft_;
    Sprite spriteWalkRight_;
};

입력 부분만 따로 InputComponent 클래스로 위임했다.

Bjorn 클래스는 이제 더이상 Controller를 참조하지 않아도 되기 때문에 커플링이 일부 제거되었다.

고작 이제 시작인데 성과가 눈에 보인다.

 

더보기
class PhysicsComponent {
public:
    void update(Bjorn& bjorn, World& world) {
        bjorn.x += bjorn.velocity;
        world.resolveCollision(volume_, bjorn.x, bjorn.y, bjorn.velocity);
    }
    
private:
    Volume volume_;
};

class GraphicsComponent {
public:
    void update(Bjorn& bjorn, Graphics& graphics) {
        Sprite* sprite = &spriteStand_;
        if (bjorn.velocity < 0) sprite = &spriteWalkLeft_;
        else if (bjorn.velocity > 0) sprite = &spriteWalkRight_;
        graphics.draw(*sprite, bjorn.x, bjorn.y);
    }

private:
    Sprite spriteStand_;
    Sprite spriteWalkLeft_;
    Sprite spriteWalkRight_;
};

남은 기능들도 뽑아내서 위임시켰다.

 

class Bjorn {
public:
    int velocity;
    int x, y;
    
    void update(World& world, Graphics& graphics) {
        input_.update(*this);
        physics_.update(*this, world);
        graphics_.update(*this, graphics);
    }

private:
    InputComponent input_;
    PhysicsComponent physics_;
    GraphicsComponent graphics_;
};

이제 Bjorn 클래스는 자신을 정의하는 컴포넌트 집합을 관리하고 컴포넌트들이 공유하는 상태를 들고있는 역할만 하게 된다.

옮겨지지 않은 일부 멤버 변수는 모든 컴포넌트가 공유해서 사용하기 때문에 어느 컴포넌트에 둘지 애매해지기 때문에 직접 관리한다. 게다가 컴포넌트들끼리 서로 커플링 되지 않고도 쉽게 통신이 가능한 장점이 있다.

 

필요한 경우 컴포넌트 클래스를 추상화 시켜서 인터페이스를 감출 수 있다.

사용 예로는 InputComponent를 추상화 시켜서 플레이어의 입력과 데모 모드에서 자동으로 조작되는 두가지의 클래스로 파생시켜 재정의하면 실제 게임과 데모 모드에서 각자 사용이 가능하도록 할 수 있다.

 

이쯤에서 Bjorn대신 GameObject같은 범용적인 이름으로 교체해도 전혀 문제가 없어진다.

컴포넌트들 역시 추상 클래스로 정의하고 Bjorn전용 컴포넌트로 재정의하여 팩토리 메서드로 생성하면 기존과 동일하게 사용할 수 있으면서 만들어둔 컴포넌트들을 이용해서 온갖 객체들을 찍어낼 수 있게 된다.

 

 

디자인 결정

가장 중요한 사항은 "어떤 컴포넌트 집합이 필요한가?" 이다.

여러가지 고려 사항을 알아보도록 하자.

 

"객체는 컴포넌트를 어떻게 얻는가?"

◾ 객체가 필요한 컴포넌트를 알아서 생성할 때 : 객체는 항상 필요한 컴포넌트만을 가지게 되지만 멤버로써 하드코딩 되므로 객체를 변경하기가 어렵다.

 

◾ 외부 코드에서 컴포넌트를 제공할 때 : 객체가 훨씬 유연해지고 컴포넌트와 좀 더 디커플링 될 수 있다.

 

 

"컴포넌트들끼리는 어떻게 통신할 것인가?"

서로 완전히 격리된채로 동작하는게 이상적이지만 현실적으로는 어렵다.

 

◾ 컨테이너 객체의 상태를 변경하는 방식 : 컴포넌트간의 디커플링 상태가 유지되지만, 컴포넌트들이 공유하는 정보들은 모두 컨테이너 객체에 넣어야 한다. 그리고 암시적으로 통신하다 보니 실행 순서에 의존적이게 된다.

 

◾ 컴포넌트가 서로 참조하는 방식 : 간단하고 빠르지만 두 컴포넌트가 강하게 결합된다. 그나마 서로 통신하는 컴포넌트끼리만 커플링이 형성되기 때문에 컴포넌트 패턴을 적용하기 전보다는 훨씬 낫다.

 

◾ 메시지를 전달하는 방식 : 컨테이너 객체에 간단한 메시징 시스템을 만들고 각 컴포넌트들이 서로에게 정보를 뿌리도록 한다. 컴포넌트가 컨테이너에 메시지를 보내면, 컨테이너는 자신이 소유한 모든 컴포넌트에 메시지를 뿌린다.

처음 메시지를 보낸 컴포넌트도 포함되기 때문에 피드백 루프에 빠지지 않도록 조심해야 한다.

가장 복잡한 대신 하위 컴포넌트들은 디커플링되고 컨테이너 객체가 단순해진다.

 

세 가지 방법중 왕도는 없다. 결국 셋 다 조금씩 쓰게 된다.

메시징은 호출하고 나서 신경 쓰지 않아도 되는 사소한 통신에 사용하기 좋다. 예를 들어 물리 컴포넌트가 어떤 오브젝트와 충돌했다고 메시지를 전파하면 오디오 컴포넌트가 이를 받아서 소리를 출력한다.

 

 

참고로 유니티의 GameObject가 바로 컴포넌트 방식에 맞추어서 설계되었다.

타입 객체 (Type Object)

클래스 하나를 인스턴스별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

 

 

동기

몬스터를 여러 종류 구현해야 한다고 하자.

전형적인 OOP 방식으로 구현하게 된다면 is-a 관계에 따라 Monster라는 상위 클래스를 구현하고 공통 인터페이스는 순수 가상 함수로, 공통 속성은 멤버 변수로써 갖는다. 하위 클래스는 이를 상속받아서 인터페이스를 구현한다. 매우 일반적인 구현이다.

 

문제는 몬스터의 종류만큼 하위 클래스의 개수도 끊임없이 늘어나고 유지보수도 매우 번거로워진다는 점이다.

 

접근 방식을 바꿔서 몬스터마다 종족에 대한 정보를 두어서 상속대신 종족 정보에 접근할 수 있는 Breed 클래스 하나를 새로 만들고 참조하도록 한다. 이러면 상속구조 없이 Monster, Breed 2개의 클래스만으로 해결할 수 있다.

몬스터와 종족을 결합시키기 위해서 모든 Monster 인스턴스는 종족 정보를 가지고 있는 Breed 객체를 참조하여 접근한다.

Breed 클래스는 본질적으로 몬스터의 타입을 정의하고 각각의 종족 객체는 개념적으로 다른 타입을 의미한다.

 

타입 객체 패턴은 코드의 수정 없이도 새로운 타입을 정의할 수 있는 것이 장점이다.

상속으로 만들어지던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮겨놓은 셈이다.

이제는 외부 파일에서 읽은 데이터로 종족 객체를 생성하고나면 데이터만으로 서로 다른 몬스터를 정의할 수 있게 된다.

 

 

패턴

타입 객체 클래스와 타입 사용 객체 클래스를 정의한다.

모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미하고, 타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조한다.

예제를 기준으로 보면 Monster가 타입 사용 객체, Breed가 타입 객체이다.

 

인스턴스별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고, 개념적으로 같은 타입끼리 공유하는 데이터나 동작은 타입 객체에 저장한다.

 

상속 관계가 아님에도 불구하고 마치 상속받는것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.

 

 

언제 쓸 것인가?

◾ 나중에 어떤 타입이 필요할지 알 수 없다.

◾ 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.

 

이 중 하나라도 해당하면 타입 객체 패턴을 사용하기에 적합하다.

 

주의사항

타입 객체 패턴의 핵심은 표현력이 좋더라도 유연하지 않은 코드 대신 표현력이 조금 떨어지더라도 훨씬 유연한 데이터로 타입을 표현하는데에 있다.

유연성을 얻는 대신 코드가 아닌 데이터로 표현하면서 잃는 것도 생긴다.

 

◾ 타입 객체를 직접 관리해야 한다

타입 객체를 생성하고 필요로 하는 몬스터가 있는 한, 메모리에 계속 유지시켜야 한다. 몬스터 인스턴스를 생성할 때, 알맞는 종족 객체 레퍼런스로 초기화 하는것도 직접 해야한다.

 

◾ 타입별로 동작을 표현하기가 더 어렵다

상속의 경우 오버라이딩을 통해 원하는 대로 구현이 가능하다. 하지만 타입 객체 패턴은 종족 객체 변수에 문구를 저장하는 식으로 표현한다.

타입 종속적인 데이터를 정의하는 것은 쉽지만 동작을 정의하기는 어렵다.

이를 극복하기 위해서는 미리 동작 코드를 여러 개 정의해두고 함수 포인터로 가지면 된다.

 

좀 더 나아가서 바이트코드 패턴을 이용하면 동작을 표현하는 객체까지도 만들 수 있다.

 

 

예제

class Breed {
public:
    Breed(int health, const char* attack) : health_(health), attack_(attack) {}
    int getHealth() { return health_; }
    const char* getAttack() { return attack_; }
    
private:
    int health_;
    const char* attack_;
};

class Monster {
public:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    int health_;
    Breed& breed_;
};

기본적인 타입 객체의 구현 방법이다. 상속 없이 몬스터를 정의한다.

그런데 이런식으로 몬스터 객체를 만드는건 OOP답지 않다. 대신, 팩토리 메소드와 유사한 생성자 함수를 통해 클래스가 알아서 인스턴스를 생성하도록 한다.

 

class Breed {
public:
    Monster* newMonster() { return new Monster(*this); }
    // .. 이하 동일
};

class Monster {
    friend class Breed;
    
public:
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    int health_;
    Breed& breed_;
};

Monster의 생성자를 private으로 만든 대신 Breed 클래스를 friend로 지정하여 객체 생성을 위임한다.

 

Monster* monster = new Monster(someBreed); // 기존 방법
Monster* monster = someBreed.newMonster(); // 생성자 함수 이용

겉으로 보기에는 별 차이가 없어보이지만 Monster의 초기화 제어권을 Breed 클래스가 가지고 있기 때문에 Monster 객체를 생성하기 전에 필요한 리소스들을 미리 가져오거나 메모리 풀, 커스텀 힙 등에서 메모리를 가져오는 작업들을 수행할 수 있다.

Breed의 생성자 함수를 통해서만 몬스터 객체를 생성할 수 있기 때문에 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라서 생성되도록 강제할 수 있게 된다.

 

 

상속을 통해서도 데이터를 공유할 수 있는데, 일반적인 상속관계가 아니라 타입 객체끼리 상속할 수 있는 시스템을 직접 구현해서 사용한다.

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : parent_(parent), health_(health), attack_(attack) {}
    int getHealth();
    const char* getAttack();
    
private:
    Breed* parent_;
    inth health_;
    const char* attack_;
};

int Breed::getHealth() {
    // 오버라이딩
    if (health_ != 0 || parent_ == nullptr) return health_;
    
    return parent_->getHealth();
}

const char* Breed::getAttack() {
    // 오버라이딩
    if (attack_ != nullptr || parent_ == nullptr) return attack_;
    
    return parent_->getAttack();
}

상속 문법을 사용하지 않고 상속 구조를 만든다. 대신 속성 값을 반환할때마다 상위 객체들을 재귀적으로 확인하는 과정이 있기 때문에 더 느리다는 단점이 있다.

 

만약 종족의 속성 값이 바뀌지 않는다고 하면 생성 시점에 바로 상속을 적용시켜서 간소화 시킬 수 있다. (카피다운 위임)

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : health_(health), attack_(attack) {
        if (parent != nullptr) {
            if (health == 0) health_ = parent->getHealth();
            if (attack == nullptr) attack_ = parent->getAttack();
        }
    }

    int getHealth() { return health_; }
    int getAttack() { return attack_; }
    // ...
};

생성자에서 상위 속성을 모두 복사했기 때문에 더이상 상위 객체의 포인터(parent_)를 들고있지 않아도 된다.

 

 

디자인 결정

타입 객체의 캡슐화 여부와 생성 방법을 고려해볼 필요가 있다.

캡슐화의 여부는 각자 장단점이 존재하고 만약 캡슐화를 시킨다면 타입 객체 패턴의 복잡성이 다른 코드에는 드러나지 않게 되는 장점이 있다. 또한 같은 타입 객체로부터 동작을 선택적으로 오버라이드 할 수 있어서 코드의 추가가 어렵지 않다.

다만 타입 객체 메서드를 전부 일일이 포워딩 해주어야 하는 번거로운 작업이 발생한다.

 

타입 객체를 노출시키기로 했다면 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있게 되므로 타입 객체의 메서드를 통해서만 새로운 몬스터를 생성시키게 제한시킬 수 있다.

다만 타입 객체가 공개 API의 일부가 되어 포함되기 때문에 복잡성과 유지보수면에서 디메리트가 생긴다.

 

 

타입 객체 패턴에서 객체는 타입 객체와 타입 사용 객체 쌍으로 존재해야만 한다. 그럼 이 둘을 어떻게 생성하고 결합시키는게 좋을까?

 

◾ 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우

두 객체 모두 외부에서 생성하기 때문에 외부 코드에서 메모리 할당을 제어할 수 있다.

 

◾ 타입 객체의 생성자 함수를 호출하는 경우

특정 객체 풀이나 특정 메모리 할당자에서만 생성하도록 제한하고 싶을 때 타입 객체에서 메모리 할당을 제어한다.

 

필요한 상황에 따라 선택하면 될듯하다.

 

타입의 변경과 상속에 관해서도 짚고 갈 사항이 있다.

한번 결정된 타입은 불변한다고 가정했지만 필요하다면 타입을 바꾸는 방법도 존재한다.

타입을 바꿀 수 없도록 설계한다면 코드를 구현하고 이해하는게 더 쉽고 디버깅 하기도 더 쉽다. 하지만 타입을 변경할 수 있도록 한다면 객체 생성 횟수가 줄어들기 때문에 자원의 낭비를 줄일 수 있다는 이점이 있지만 기존에 설정해둔 가정을 깨지 않도록 주의해야한다.

 

상속의 경우는 상속을 하지 않는 경우와 단일 상속, 다중 상속 총 3가지로 나눌 수 있다.

 

◾ 상속 없음

상속을 사용하지 않는다면 매우 단순해지는 대신 중복 작업을 해야 할수도 있다.

 

◾ 단일 상속

단일 상속은 그나마 단순한 편이지만 실제 값이 정의된 타입을 찾을 때까지 상속 구조를 타고 올라가야 하기 때문에 런타임의 자원이 낭비된다.

 

◾ 다중 상속

거의 모든 데이터 중복을 피할 수 있지만 구조가 복잡해진다.

애초에 타입 객체가 아니더라도 일반적인 경우에도 다중 상속을 지양(사실상 금지)하는 만큼 실무보다는 이론에 가까운 내용으로 이해하고 사용 역시 지양하는 편이 좋다.

 

 

타입 객체 패턴은 여러 패턴과 유사한 점들이 있다.

프로토타입 패턴은 같은 문제를 다른 방식으로 접근하고 경량 패턴은 여러 인스턴스가 같은 객체를 공유한다는 점에서 비슷하다.

또한 클래스 자신을 정의하는데에 있어서 일부 내용을 다른 클래스로 위임한다는 면에서는 상태 패턴과 유사점이 있다.

하위 클래스 샌드박스 (Subclass Sandbox)

상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다.

 

 

동기

다양한 초능력을 구현한다고 하자. 그러면 상위 클래스를 만들고 상속받은 클래스들을 정의하는것이 일반적이며 구현을 마치고 나면 수십개가 넘는 초능력이 만들어지게 될 것이다.

그런데 이런식으로 구현하게 되면 중복되는 코드들이 많아지고 게임 내의 거의 모든 코드가 초능력 클래스와 커플링이 생기게 될 가능성이 높다.

 

구조를 조금 바꿔서 초능력 클래스를 구현하는 동료 프로그래머들과 같이 사용할 원시명령 집합을 만들어서 공통적으로 사용하면 중복 코드를 많이 줄일 수 있다. 원시명령 집합들은 코드중복을 최소화하여 하위 클래스들을 구현하기 위해 상위 클래스에 정의한 일종의 도구이기 때문에 가상함수일 필요도 없고 public으로 공개할 필요도 없다. 그래서 일반적으로 protected 비가상함수로 만들게 된다.

그리고 이 원시명령 집합들을 이용해 행동들을 구현할 메서드가 필요할텐데, 이 메서드는 protected 순수 가상 함수로 하위 클래스에서 재정의할 수 있도록 한다.

 

상위 클래스가 제공하는 기능을 최대한 고수준 형태로 만들어서 코드 중복을 최대한 회피하고 추후 하위 클래스에서 중복 코드가 발생하면 해당 코드를 언제든지 상위 클래스로 옮겨서 재사용 할 수 있게 하면 된다.

 

커플링 문제는 상위 클래스 한곳에 몰아넣었기 때문에 하위 클래스는 상위 클래스하고만 커플링될뿐이고 다른 코드와는 커플링 되지 않는다.

 

나중에 게임 시스템이 변경되면 상위 클래스를 수정하는것은 불가피하겠지만 하위 클래스들은 수정할 필요가 없다.

 

 

패턴

상위 클래스는 추상 샌드박스 메서드와 여러 제공 기능을 정의한다. 제공 기능은 protected로 만들어서 하위 클래스에게만 제공하는 것이라는 걸 분명하게 명시한다. 각 하위 클래스들은 제공받은 기능들을 이용해서 샌드박스 메서드를 구현한다.

 

 

언제 쓸 것인가?

패턴이라고 하기도 뭣할정도로 매우 단순하고 일반적이라서 게임이 아니더라도 많이 사용된다.

클래스에 protected 비가상함수가 있다면 샌드박스 메서드 패턴을 사용하고 있을 가능성이 높다.

 

◾ 클래스 하나에 하위 클래스가 많이 있다.

◾ 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.

◾ 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.

◾ 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.

 

위와 같은 경우에 샌드박스 패턴을 사용하면 좋다.

 

 

주의사항

하위 클래스는 상위 클래스를 통해서 게임 코드에 접근하기 때문에 상위 클래스가 하위 클래스들에서 접근해야 하는 모든 시스템과 커플링되고 하위 클래스 역시 상위 클래스와 매우 밀접하게 묶이게 된다.

이런 관계에서는 상위 클래스를 조금만 수정해도 어딘가 깨지기가 쉬워진다. 꺠지기 쉬운 상위 클래스 문제에 빠지게 되는 것이다.

 

그래도 마냥 단점만 있는것은 아닌게 커플링 대부분을 상위 클래스에 떠넘겼기 때문에 하위 클래스들은 다른 코드들과 깔끔하게 디커플링 될 수 있다는 점이다. 동작의 대부분은 하위 클래스에 존재하기 때문에 유지보수가 쉽다.

 

그렇다 하더라도 상위 클래스가 점점 비대해진다면 제공하는 기능의 일부를 별도의 클래스로 분리하여 책임을 나눠갖게 하는 것도 좋다. 이 때 컴포넌트 패턴이 도움이 된다.

 

 

예제

class Superpower {
public:
    virtual ~Superpower() {}
    
protected:
    virtual void activate() = 0;
    void move(double x, double y, double z) { ... }
    void playSound(SoundId sound, double volume) { ... }
    void spawnParticles(ParticleType type, int count) { ... }
};

매우 간단하고 이해하기 쉽다.

샌드박스 메서드는 순수 가상 함수로 만들었기 때문에 어디에 작업을 해야 할 지 분명하게 알 수 있다.

 

class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(PARTICLE_DUST, 10);
        move(0, 0, 20);
    }
};

하위 클래스는 위와 같이 구현하면 된다. 제공받은 비가상함수로 순수 가상 함수를 구현한다. 끝이다.

샌드박스 메서드를 입맛에 맞게 구현하면 된다.

 

 

디자인 결정

어떤 기능을 제공해야 하는지를 따져봐야 한다. 

잠시 극단적인 사례로 따져보자. 기능을 적게 제공하는 방향의 끝에는 상위 클래스에서 제공하는 기능은 하나도 없이 샌드박스 메서드 하나만 있고 기능을 많이 제공하는 방향의 끝에는 하위 클래스가 필요로 하는 모든 기능을 상위 클래스에서 제공한다.

전자는 하위 클래스 샌드박스 패턴이라고 부를수 있는지조차 의문이 드는 상황이고 후자는 상위 클래스의 커플링이 매우 강력해진다.

여기서 절충안을 찾아야 하는데 일반적인 원칙은 아래와 같다.

 

◾ 상위 클래스가 제공하는 기능을 몇 안되는 하위 클래스에서만 사용하면 별 이득이 없다. 상위 클래스의 복잡도가 증가하는 것에 비해 혜택을 받는 하위 클래스가 적기 때문이다.

◾ 외부 시스템의 상태를 변경하는 함수는 상위 클래스의 제공 기능으로 옮겨주는 것이 좋다.

◾ 제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 굳이 기능을 제공할 필요가 없다. 이런 경우라면 그냥 하위 클래스에서 외부 메서드를 호출하는게 더 나을수도 있다.

 

 

하위 클래스 샌드박스 패턴의 매우 큰 단점중 하나는 상위 클래스의 메서드 수가 무수히 많이 증가한다는 점이다.

이 단점은 일부 기능을 보조 클래스에 분산시켜서 해당 객체들을 반환하는 방식으로 우회하면 된다. 그러면 상위 클래스의 메서드 개수를 줄일 수 있고 다른 시스템과의 커플링도 낮출 수 있다.

유지보수가 더 쉬워지는 부수효과도 있다.

 

 

상위 클래스가 필요한 객체를 얻는 방법도 생각해볼 여지가 있다.

예를 들어 파티클을 생성시켜주는 객체가 있다. 이 객체를 이용해서 파티클과 관련된 기능을 구현하고 하위 클래스들에게는 메서드만 제공해주면 되기 때문에 하위 클래스들에 대해서는 캡슐화가 될 필요가 있다.

파티클 객체를 얻는 가장 쉬운 방법은 상위 클래스의 생성자 인수로 받는것이다. 문제는 이 객체를 하위 클래스의 생성자에서 받아서 상위 클래스의 생성자에 전달해주어야 하기 때문에 객체가 노출되어 캡슐화의 의미가 없어진다.

파티클 뿐만 아니라 다른 객체들도 받아야 한다면 하위 클래스 생성자에도 계속 추가해 주어야 하기 때문에 유지보수에도 좋지 않다.

 

Superpower* power = new SkyLaunch();
power->init(particles);

대신 초기화 과정을 2단계로 나누면 생성자에게 모든걸 전달하는 번거로움을 피할 수 있다.

다만 init 호출을 까먹지 말아야 하는 문제가 있다. 이 부분은 객체 생성 과정 전체를 하나의 함수로 캡슐화하면 해결할 수 있다.

덧붙여서 생성자를 private으로 만들고 friend 클래스를 잘 활용하면 하나의 인터페이스로만 객체 생성이 가능하도록 강제할 수 있으므로 초기화 단계를 빼먹을 일이 사라진다.

 

class Superpower {
public:
    static void init(ParticleSystem* particles) {
        particles_ = particles;
    }
    // ...
    
private:
    static ParticleSystem* particles_;
};

혹은 아예 정적 객체로 만들어서 사용하는 방법도 있다. 물론 간단한 만큼 발생할 수 있는 사이드 이펙트들을 유의해야한다.

 

class Superpower {
protected:
    void spawnParticles(ParticleType type, int count) {
        ParticleSystem& particles = Locator::getParticles(); // 서비스 중개자를 통해 접근
        particles.spawn(type, count);
    }
    // ...
};

다른 방법으로는 서비스 중개자 패턴의 도움을 받는 것이다. 2단계 초기화든 정적 객체든 공통점은 해당 객체를 소유한다는 점인데, 필요한 객체를 소유하지 않고 서비스 중개자를 통해 원하는 객체를 직접 가져와서 사용하면 된다.

업데이트 메서드 (Update Method)

컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션한다.

 

 

동기

while (true) {
    for (double x = 0; x < 100; ++x) skeleton.setX(x);
    for (double x = 100; x > 0; --x) skeleton.setX(x);
}

어떤 몬스터를 좌우로 움직이는 코드를 간단하게 작성하면 위와 같다. 문제는 무한루프이기 때문에 시각적으로 볼 수 없다.

 

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// 게임 메인 루프
while (true) {
    if (patrollingLeft) {
        --x;
        if (x == 0) patrollingLeft = false;
    }
    else {
        ++x;
        if (x == 100) patrollingLeft = true;
    }
    skeleton.setX(x);
    // 유저 입력 처리, 렌더링 ...
}

게임 루프 구조로 바꾸면 무한루프에 빠지지 않는다.

그런데 다른 몬스터나 오브젝트가 추가되는 경우 유지보수가 점점 어려워진다.

 

해결책은 의외로 간단하다. 모든 객체들이 자신의 동작을 캡슐화 하면 된다.

이를 위해 추상 메서드인 update를 정의하여 추상 계층을 하나 더해서 객체 컬렉션을 관리한다.

게임 루프는 매 프레임마다 객체 컬렉션을 순회하며 update를 호출하면 끝이다.

 

 

패턴

게임 월드는 객체 컬렉션을 관리하고 각 객체는 1프레임 단위의 동작을 하기 위한 업데이트 메서드를 구현한다.

그리고 매 프레임마다 컬렉션에 들어있는 모든 객체를 업데이트한다.

 

 

언제 쓸 것인가?

게임 루프 패턴 다음으로 매우 중요한 패턴이다.

단순한 애니메이션조차 없는 정적인 게임(보드게임 등)이 아닌 이상에야 움직이는 개체가 많은 게임에서는 업데이트 메서드 패턴이 어떻게든 사용된다.

 

◾ 동시에 동작해야 하는 객체나 시스템이 게임에 많다.

◾ 각 객체의 동작은 다른 객체와 거의 독립적이다.

◾ 객체는 시간의 흐름에 따라 시뮬레이션 되어야 한다.

 

위와 같은 경우라면 업데이트 메서드를 사용할 수 있다.

 

 

주의사항

구현 자체가 단순해서 딱히 조심할건 없지만 알아둬야 할 것들은 있다.

 

매 업데이트마다 기존의 상태를 기반으로 행동하기 때문에 현재 상태를 저장해야 하기 때문에 상태 패턴과 사용하면 좋을 수 있다.

 

또한 객체들은 매 프레임마다 업데이트 되지만 동시에 이루어지지 않는다. 내부적으로는 컬렉션에 저장된 순서대로 업데이트가 이루어진다.

순차적으로 업데이트되면 로직 작업이 편하지만 병렬로 업데이트되면 꼬일 가능성이 생긴다.

 

그리고 업데이트를 실행하는 도중에 컬렉션을 수정하는 것은 조심해야한다.

객체가 새로 생성되는 경우라면 목록 뒤에 추가하면 그만이지만 삭제하는 경우는 큰 문제가 발생할 여지가 있다.

 

삭제로 인해 건너뛰어졌다

객체를 삭제해야 하는 경우라면 인덱스를 업데이트 하거나 해당 객체에 죽었다는 표시를 남겨서 순회도중 해당 객체를 만나면 업데이트를 건너뛰고 순회가 끝나면 다시 순회를 돌며 해당 객체들을 제거하는 방법을 사용하는 것이 좋다.

 

 

예제

class Entity {
// 세부내용 생략
public:
    virtual void update() = 0;
}


class World {
public:
    World() : numEntities_(0) {}
    void gameLoop() {
        // ...
        while (true) {
            for (int i = 0; i < numEntities_; ++i)
                entities_[i]->update();
        }
        // ...
    }

private:
    Entity* entities_[MAX_ENTITIES];
    int numEntities_;
};

업데이트가 필요한 클래스들은 Entity를 상속받아서 각자의 행동을 구현하면 된다.

 

그런데 위의 방식은 고정 시간 간격을 쓰는 경우의 구현이다.

앞서 게임 루프에서 언급한것처럼 가변 시간 간격을 사용하는 게임도 있기 때문에 매개변수로 가변 시간 간격을 받아서 처리하면 어렵지 않게 대응할 수 있다.

 

추상 클래스를 이용한 업데이트 메서드 패턴은 상속의 깊이가 한 단계 증가하는 문제가 있지만 상황에 맞게 사용하면 된다.

물론 차후에 다룰 컴포넌트 패턴을 사용하는 것이 더 좋다.

 

 

디자인 결정

중요한건 update 메서드를 어느 클래스에 두느냐이다.

 

◾ 추상 클래스 상속

예제처럼 추상 클래스를 상속하는 것이 가장 간단하지만 요즘은 지양하는 방법이다. 차후 코드 재사용시 단일 상속으로 인한 문제가 발생하면 해결할 방법이 없다.

 

◾ 컴포넌트 클래스

컴포넌트는 알아서 자기 자신을 업데이트 하기 때문에 고민할 필요가 없다.

 

◾ 위임 클래스

상태 패턴처럼 일부 동작을 다른 객체에 위임하고 포워딩만 해주어서 유연성을 얻는다.

상태가 업데이트 되어야 하는 클래스에는 여전히 update가 존재하지만 오버라이딩 한것이 아니라 일반 멤버 함수이다.

 

 

또한 휴면 상태에 들어간 객체의 처리도 생각해 보아야 한다.

여러 이유로 일시적으로 업데이트를 할 필요가 없는 객체들까지 순회하면서 업데이트를 호출하는 것은 자원의 낭비이다.

이를 해결하는 방법으로는 크게 두가지가 있다.

 

단일 컬렉션으로 모두 관리하는 경우에는 플래그 검사를 하더라도 전체 컬렉션을 순회하기 때문에 시간이 낭비되고, 활성 객체만 관리하는 컬렉션을 추가하여 관리하면 시간의 낭비는 없지만 메모리를 추가로 소모해야 할 뿐더러 두 컬렉션의 동기화를 항상 유지해야 한다.

+ Recent posts