기록공간

13장. 디커플링 - 컴포넌트 패턴 (Component Pattern) 본문

Game Design Pattern, Logic

13장. 디커플링 - 컴포넌트 패턴 (Component Pattern)

입코딩 2020. 8. 11. 22:03
반응형
한 개체가 여러 분야를 서로 커플링 없이 다룰 수 있게 한다.

 

플랫포머 게임을 만든다고 해보자. 슈퍼 마리오 게임을 만든다고 하자. 그러면 마리오를 대표하는 클래스를 하나 만들어서 여기에 주인공이 게임에서 하는 모든 것들을 넣는 게 당연하다.

 

주인공을 조장해야 하니 컨트롤러 입력 값을 읽어 행동으로 바꿔야 한다. 지형이나 발판(플랫폼) 같은 레벨과도 상호작용할 수 있도록 물리 및 충돌 처리도 필요하다. 주인공이 화면에 나와야 하니 애니메이션과 렌더링도 넣어야 한다. 당연한 소리처럼 들린다.

 

점점 엉망진창이 되어간다는 느낌이 든다. 분야가 다른 코드는 서로 격리해야 한다고 배웠을 것이다. 워드 프로세서를 예로 든다면 인쇄 코드가 문서를 열고 저장하는 코드에 영향을 받으면 안 된다. 게임은 사무용 소프트웨어와 차이가 있지만, 이런 규칙은 똑같이 적용된다.

 

AI, 물리, 렌더링, 사운드처럼 분야가 다른 코드끼리는 서로 모르는 것이 좋다. 이런 코드들을 한 클래스 안에 전부 욱여넣으면, 결과는 뻔할 것이다. 클래스 하나가 5천 줄이 넘는 거대한 쓰레기 코드로 뒤덮여, 누구도 건들 수 없는 코드가 될 가능성이 높다. 직접 구현한 본인은 건드릴 수 있을지 몰라도 다른 사람들에게는 지옥이나 마찬가지 일 것이다. 이런 코드는 머지않아 기능보다 버그가 더 빠르게 늘어날 것이다.

 

고르디우스의 매듭

가장 큰 문제는 커플링 문제이다. 여러 게임 시스템이 주인공 클래스 안에 실타레마냥 얽혀 있다. 

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

이 코드는 문제없이 고치려면 물리(collidingWithFloor), 그래픽(getRenderState), 사운드(playSound)를 전부 알아야 한다. 

 

커플링과 코드 길이 문제는 서로 악영향을 미친다. 한 클래스가 너무 많은 분야를 건드리다 보니 모든 프로그래머가 그 클래스를 작업해야 하는데, 클래스가 너무 크다 보니 작업하기가 굉장히 어렵다. 이런 상황이 심해지면 프로그래머들이 뒤죽박죽이 된 플레이어 클래스를 손대기 싫어서 다른 곳이 땜빵 코드를 넣기 시작할 것이다.

 

매듭 끊기

이 문제는 알렉산더 대왕이 고르디우스의 매듭을 칼로 끊었던 것처럼 풀 수 있다. 한 덩어리였던 클래스를 분야에 따라 여러 부분으로 나누면 된다. 예를 들어 사용자 입력에 관련된 코드는 InputComponent 클래스로 옮겨둔 뒤에, 플레이어 클래스가 InputComponent 인스턴스를 갖게 한다. 나머지 분야에 대해서도 이런 작업을 반복한다. 

 

이러고 나면 컴포넌트를 묶는 얇은 껍데기 코드 외에는 플레이어 클래스에 남는 게 거의 없게 된다. 클래스 코드 크기 문제는 클래스를 여러 작은 클래스로 나누는 것만으로 해결했고, 소득은 이뿐만이 아니다.

 

열린 구조

컴포넌트 클래스들은 디커플링되어 있다. PhysicsComponent와 GraphicComponent는 플레이어 클래스 안에 들어 있지만 서로에 대해 알지 못한다. 즉, 물리 프로그래머는 화면 처리는 신경 쓰지 않고 자기 코드를 수정할 수 있다. 반대도 마찬가지이다.

 

현실적으로는 컴포넌트끼리 상호작용이 필요할 수도 있다. 예를 들어 AI 컴포넌트는 플레이어가 가려는 곳을 물리 컴포넌트를 통해서 알아내야 할 수도 있다. 다만 모든 코드를 한 곳에 섞어놓지 않았기 때문에 서로 통신이 필요한 컴포넌트만으로 결합을 제한할 수 있다.

 

다시 합치기

컴포넌트 패턴의 다른 특징은 이렇게 만든 컴포넌트를 재사용할 수 있다는 점이다. 플레이어 외에 게임에 다른 객체들을 생각해보자. 데커레이션(decoration)은 덤불이나 먼지같이 볼 수는 있으니 상호작용은 할 수 없는 객체다. 프랍(Prop)은 상자, 바위, 나무같이 볼 수 있으면서 상호작용도 할 수 있는 객체다. 존(Zone)은 데커레이션과 반대로 보이지는 않지만 상호작용은 할 수 있는 객체다. 예를 들어 플레이어가 특정 영역에 들어올 때 컷신을 틀고 싶다면 존을 써먹을 수 있다.

 

컴포넌트를 쓰지 않는다면 이들 클래스를 어떻게 상속해야 할까? 먼저 이렇게 할 듯하다.

 

GameObject 클래스에는 위치나 방향 같은 기본 데이터를 둔다. Zone은 GameObject를 상속받은 뒤에 충돌 검사를 추가한다. Prop은 충돌 검사 기능을 재사용하기 위해 Zone을 상속 받는다. 하지만 Prop이 렌더링 코드를 재사용하기 위해 Decoration 클래스를 상속하려는 순간 '죽음의 다이아몬드'라고 불리는 다중 상속 문제를 피할 수 없다.

 

뒤집어서 Prop이 Decoration을 상속받게 만들어봐야 충돌 처리 코드 중복은 피할 수 없다. 어떻게 해도 다중 상속 없이는 충돌 처리 코드와 렌더링 코드를 깔끔하게 재사용할 수는 없다. 아니면 모든 기능을 GameObject 클래스에 올려놔야 하는데, 그러면 Zone에는 필요 없는 렌더링 데이터가 들어가고 Decoration에는 쓰지 않는 물리 기능이 들어가게 된다.

 

이제 컴포넌트로 만들어 보겠다. 상속은 전혀 필요가 없다. GameObject 클래스 하나와 PhysicsComponen, GraphicsComponent 클래스 두 개만 있으면 된다. 데커레이션은 GraphicsComponent는 있고 PhysicsComponent는 없는 GameObject다. 반대로 존에는 PhysicsComponent가 있고 GraphicsComponent는 없다. 프랍에는 둘 다 있다. 여기에는 코드 중복도 다중 상속도 없다. 클래스 개수도 네 개에서 세 개로 줄었다. 

 

패턴

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

 

예제 코드

통짜 클래스

컴포넌트 패턴을 어떻게 적용할지를 더 명확하게 알 수 있도록, 먼저 컴포넌트 패턴을 아직 적용하지 않아 모든 기능이 통짜 클래스에 다 들어 있는 플레이어 클래스부터 보자.

class Player
{
public:
    Player() : 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_;
};

Player 클래스의 update 메서드는 매 프레임마다 호출된다.

void Player::update(World& world, Graphics& graphics) 
{
    // 입력에 따라 주인공의 속도를 조절한다.
    switch(Controller::getJoystixkDirection()) 
    {
    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_);
}

이 코드는 조이스틱 입력에 따라 주인공을 가속한다. 다음으로 물리 엔진을 통해 주인공의 다음 위치를 구한다. 마지막으로 화면에 주인공인 플레이어를 그린다.

 

구현은 굉장히 간단하다. 그럼에도 코드를 보면 update 함수 하나를 여러 분야의 프로그래머가 작업해야 하고 코드가 더러워지기 시작했다는 것을 알 수 있다. 이런 코드가 몇천 줄이 넘어가면 얼마나 괴로울지 짐작이 갈 것이다.

 

분야별로 나누기

먼저 분야 하나를 정해서 관련 코드를 플레이어에서 별도의 컴포넌트 클래스로 옮긴다. 가장 먼저 처리되는 입력 분야부터 시작한다. 플레이어 클래스가 처음하는 일은 사용자 입력에 따라 주인공의 속도를 조절하는 처리다. 그에 해당하는 로직을 별개의 클래스로 옮겨보자.

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

플레이어 클래스의 update 메서드에서 앞부분을 InputComponent 클래스로 옮겼다. 플레이어 클래스는 다음과 같이 바뀐다.

class Player
{
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 객체가 추가되었다. 이전에는 사용자 입력을 update()에서 직접 처리했지만, 지금은 입력 컴포넌트에 위임한다.

input_.update(*this);

플레이어 클래스가 더 이상 Controller를 참조하지 않도록 커플링을 일부 제거했다.

 

나머지도 나누기

이제 남아 있는 물리 코드와 그래픽스 코드도 같은 식으로 복사 & 붙여넣기를 한다. PhysicsComponent부터 보자.

class PhysicsComponent
{
public:
    void update(Player& player, World& world)
    {
        player.x += player.velocity;
        world.resolveCollision(volume_, player.x, player.y, player.velocity);
    }
    
private:
    Volume volume_;
};

물리 코드를 옮기고 보니 물리 데이터도 같이 옮겨졌다. 이제 Volume 객체는 Player가 아닌 PhysicsComponent에서 관리한다.

 

마지막으로 렌더링 코드를 옮긴다.

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

Player 클래스에서 거의 모든 코드를 뽑아냈다.

class Player
{
public:
    int velocity;
    int x, y;
    
    void update(Player& player, Graphics& graphics)
    {
        input_.update(*this);
        physics_.update(*this, world);
        graphics_.update(*this, graphics);
    }
    
private:
    InputComponent input_;
    PhysicsComponent physics_;
    GraphicsComponent graphics_;
};

이렇게 바뀐 Player 클래스는 두 가지 역할을 한다. 먼저 자신을 정의하는 컴포넌트 집합을 관리하고 컴포넌트들이 공유하는 상태를 들고 있는 역할이다. 위치와 속도 값을 player 클래스에 남겨놓은 이유는 두 가지이다. 먼저, 이들 상태는 '전 분야'에서 사용된다. 컴포넌트로 옮기고 싶어도 거의 모든 컴포넌트에서 이 값을 사용하다 보니 어느 컴포넌트에 둘지 애매하다.

 

그보다 더 중요한 이유는 이렇게 하면 컴포넌트들이 서로 커플링되지 않고도 쉽게 통신할 수 있기 때문이다. 이를 어떻게 활용할 수 있는지 살펴보자.

 

오토-플레이어

동작 코드를 별도의 컴포넌트 클래스로 옮겨놓기는 했지만 아직 추상화하지는 않았다. Player 클래스는 자신의 동작을 어떤 구체 클래스에서 정의하는지를 정확하게 알고 있다. 이걸 바꿔보겠다.

 

사용자 입력 처리 컴포넌트를 인터페이스 뒤로 숨기려고 한다. InputComponent를 다음과 같이 추상 상위 클래스로 바꿔보자.

class InputComponent
{
public:
    virtual ~InputComponent();
    virtual void update(Player& player) = 0;
};

사용자 입력을 처리하던 코드는 InputComponent 인터페이스를 구현하는 클래스로 끌어내린다.

class PlayerInputComponent : public InputComponent
{
public:
    virtaul void update(Player& player)
    {
       switch(Controller::getJoystickDirection())
        {
        case DIR_LEFT:
            player.velocity -= WALK_ACCELERATION;
            break;
        case DIR_RIGHT:
            player.velocity += WALK_ACCELERATION;
            break;
        }
    }
    
private:
    static const int WALK_ACCELERATION = 1;
};

Player 클래스는 InputComponent 구체 클래스의 인스턴스가 아닌 인터페이스의 포인터를 들고 있게 바꾼다.

class Player
{
public:
    int velocity;
    int x, y;
    
    Player(InputComponent* input) : input_(input) {}
    
    void update(World& world, Graphics& graphics)
    {
        input_.update(*this);
        physics_.update(*this, world);
        graphics_.update(*this, graphics);
    }
    
private:
    InputComponent input_;
    PhysicsComponent physics_;
    GraphicsComponent graphics_;
};

이제는 Player 객체를 생성할 때, Player가 사용할 입력 컴포넌트를 다음과 같이 전달할 수 있다.

Player* player = new Player(new PlayerInputComponent());

어떤 클래스라도 InputComponent 추상 인터페이스만 구현하면 입력 컴포넌트가 될 수 있다. update()는 가상 메서드로 바뀌면서 속도는 조금 느려졌다. 대신 무엇을 얻을 수 있을까?

 

대부분의 콘솔 게임에서는 '데모 모드'를 지원해야 한다. 플레이어가 주 메뉴에서 아무것도 하지 않을 때, 대신 컴퓨터가 자동으로 게임을 플레이하는 모드이다. 화면에 주 메뉴만 계속 나오는 것을 막을 수 있고, 매장에서 게임을 근사하게 보여줄 수 있다.

 

입력 컴포넌트 클래스를 인터페이스 밑에 숨긴 덕분에 이런 걸 만들 수 있게 되었다. PlayerInputComponent는 실제로 게임을 플레이할 때 사용하는 클래스이니, 다른 클래스를 만들어보자.

class DemoInputComponent : public InputComponent
{
public:
    virtual void update(Player& player)
    {
        // AI가 알아서 Player를 조정한다...
    }
};

데모용 플레이어 객체를 생성할 때는 새로 만든 컴포넌트를 연결한다.

Player* player = new Player(new DemoInputComponent());

단순히 컴포넌트를 교체했을 뿐인데 완벽하게 돌아가는 데모 모드용 인공지능 플레이어를 만들 수 있었다. 나머지 코드는 그대로 재사용하면 된다. 

 

꼭 플레이어일 필요는 없잖아?

이제 Player 클래스는 컴포넌트 묶음일 뿐 딱히 플레이어에 관련된 코드가 남아 있지 않다. 그보다는 게임에서 모든 객체가 기본으로 사용하는 게임 객체(GameObject) 클래스로 바꾸는 게 더 좋을 것 같다. 원하는 컴포넌트만 선택하면 온갖 객체를 만들 수 있다.

 

나머지 물리와 그래픽스 컴포넌트도 입력에서 그랬던 것처럼 인터페이스와 구현부를 나눠보자.

class PhysicsComponent
{
public:
    virtual ~PhysicsComponent() {}
    virtual void update(GameObject& obj, World& world) = 0;
};

class GraphicsComponent
{
public:
    virtual ~GraphicsComponent() {}
    virtual void update(GameObject& obj, Graphics& graphics) = 0;
};

Player 클래스는 이름을 범용적인 GameObject로 바꾸고 내부적으로 다음과 같은 인터페이스들을 사용하게 한다. 

class GameObject
{
public:
    int velocity;
    int x, y;
    
    GameObject(InputComponent* input,
               PhysicsComponent* physics,
               GraphicsComponent* graphics)
       : input_(input),
         physics_(physics),
         graphics_(graphics) 
         {}
         
    void update(World& world, Graphics& graphics)
    {
        input_->update(*this);
        physics_->update(*this, world);
        graphics_->update(*this, graphics);
    }
    
private:
    InputComponent* input_;
    PhysicsComponent* physics_;
    GraphicsComponent* graphics_;
};

기존 구체 클래스 역시 이름을 바꾸고 인터페이스를 구현하도록 한다.

class PlayerPhysicsComponent : public PhysicsComponent
{
public:
    virtual void update(GameObject& obj, World& world)
    {
       // 물리 코드...
    }
};
class PlayerGraphicsComponent : public GraphicsComponent
{
public:
    virtual void update(GameObject& obj, Graphics& graphics)
    {
        // 그래픽스 코드...
    }
};

플레이어만을 위한 별도 클래스 없이도 플레이어의 원래 기능을 그대로 유지하는 객체를 다음과 같이 만들 수 있다.

GameObject* createPlayer()
{
    return new GameObject(
        new PlayerInputComponent(),
        new PlayerPhysicsComponent(),
        new PlayerGraphicsComponent());
}

다른 컴포넌트를 조합한 GameObject를 생성하는 함수를 정의하면, 게임에 필요한 온갖 객체를 만들 수 있다.

 

디자인 결정

컴포넌트 패턴에서 가장 중요한 질문은 '어떤 컴포넌트 집합이 필요한가?'이다. 대답은 만들고 있는 게임 장르와 필요에 따라 다르다. 게임 코드가 크고 복잡할수록 컴포넌트를 더 세분화해야 할 것이다.

 

그밖에 고려해야 할 것들이 있다.

 

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

통짜 클래스를 몇 개의 컴포넌트로 나눴다면, 누가 이들 컴포넌트를 하나로 합쳐줄지를 정해야 한다. 

 

| 객체가 필요한 컴포넌트를 알아서 생성할 때 |

  • 객체는 항상 필요한 컴포넌트를 가지게 된다. 컨테이너 객체에서 모든 걸 다 처리하기 때문에 실수로 잘못된 컴포넌트를 객체 연결하거나 컴포넌트 연결을 잊어버려서 게임이 깨질 일이 있다.

  • 객체를 변경하기가 어렵다. 컴포넌트 패턴의 강점 중 하나는 컴포넌트 재조합만으로 새로운 종류의 객체를 만들 수 있다는 점이다. 어떤 컴포넌트를 사용할지를 하드 코딩해놓으면, 이런 유연성을 잃게 된다.

| 외부 코드에서 컴포넌트를 제공할 때 |

  • 객체가 훨씬 유연해진다. 컴포넌트만 바꿔도 전혀 다르게 동작하는 객체를 만들 수 있다. 극단적으로 유연하게 만든다면, 객체는 일반적인 컴포넌트 컨테이너가 되어 다른 용도로도 계속해서 재사용할 수 있다.

  • 객체를 구체 컴포넌트 자료형으로 디커플링 할 수 있다. 밖에서 컴포넌트를 전달할 수 있다면, 인터페이스를 상속받은 컴포넌트 객체를 전달할 가능성이 높다. 객체는 컴포넌트의 인터페이스만 알지, 이게 어떤 구체 클래스인지는 모르기 때문에 구조를 캡슐화하기 더 좋다.

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

컴포넌트들이 서로 완전히 격리된 채로 동작하는 게 이상적이겠지만, 실제로 그렇게 만들기는 어렵다. 이들 컴포넌트들은 한 객체를 이루는 부분이기 때문에 조정, 즉 서로 간의 통신이 필요하다.

 

컴포넌트는 몇 가지 방식으로 서로 통신할 수 있다. 이들 방식은 다른 설계 '대안'과는 달리 서로 배타적이지 않다. 보통은 여러 방식을 동시에 지원해야 할 것이다.

 

| 컨테이너 객체의 상태를 변경하는 방식 |

  • 컴포넌트들은 서로 디커플링 상태를 유지한다. InputComponent에서 플레이어 속도를 변경하고, PhysicsComponent에서는 그 값을 사용하면, 이 두 컴포넌트를 서로 몰라도 된다.

  • 컴포넌트들이 공유하는 정보를 컨테이너 객체에 전부 넣어야 한다. 어떤 상태는 일부 컴포넌트에서만 사용하기도 한다. 예를 들어 애니메이션 컴포넌트와 렌더링 컴포넌트는 객체의 그래픽 관련 상태를 공유해야 하는데, 이런 상태를 다른 모든 컴포넌트가 접근할 수 있는 컨테이너 객체로 올려놓으면 객체 클래스가 지저분해질 수 있다. (보이지 않는 객체의 경우 렌더링 컴포넌트 관련 데이터가 메모리 낭비일 뿐이다)

  • 컴포넌트 끼리 암시적으로 통신하다 보니 컴포넌트 실행 순서에 의존하게 된다. 예제 코드에서 사용자 입력은 속도를 바꾸고, 이 값은 물리 코드에서 보고 위치를 결정한다. 마지막으로 렌더링 코드에서는 위치 값을 보고 플레이어를 올바른 위치에 그린다. 코드를 여러 컴포넌트로 나눈 뒤에도 실행 순서는 바뀌지 않도록 주의해야 한다. 그렇지 않으면 찾기 어려운 버그가 생길 수 있다. 그래픽스 컴포넌트를 먼저 업데이트하면 플레이어를 이번 프레임이 아닌 이전 프레임 위치에 잘못 그리게 된다. 컴포넌트 개수와 코드가 많을수록 이런 버그는 피하기 어렵다. 

| 컴포넌트가 서로 참조하는 방식 |

서로 통신해야 하는 컴포넌트들이 컨테이너 객체를 통하지 않고 직접 참조하게 만들자는 뜻이다. 플레이어가 점프를 할 수 있게 만든다고 해보자. 그래픽스 코드는 점프 스프라이트를 그려야 할지 여부를 판단해야 한다. 이를 위해 주인공이 땅에 서 있는지를 물리 엔진에 물어봐야 한다. 그래픽스 컴포넌트가 물리 컴포넌트를 직접 알고 있다면 이를 쉽게 해결할 수 있다.

class PlayerGraphicsComponent
{
public:
    PlayerGraphicsComponent(PlayerPhysicsComponent* physics)
    : physics_(physics) {}
    
    void Update(GameObject& obj, Graphics& graphics)
    {
        Sprite* sprite;
        if(!physics_->isOnGround())
        {
            sprite = &spriteJump_;
        }
        else
        {
            // 나머지 그래픽스 코드....
        }
        graphics.draw(*sprite, obj.x, obj.y);
    }
    
private:
    PlayerPhysicsComponent* physics_;
    
    Sprite spriteStand_;
    Sprite spriteWalkLeft_;
    Sprite spriteWalkRight_;
    Sprite spriteJump_;
};

플레이어의 GraphicsComponent를 생성할 때 이에 맞는 PhysicsComponent를 생성자 인수에 레퍼런스로 제공한다. 

  • 간단하고 빠르다. 한 객체가 다른 객체 메서드를 직접 호출해 통신한다. 컴포넌트는 자기가 참조하는 컴포넌트에서 제공하는 어떤 메서드라도 아무런 제한 없이 호출할 수 있다.

  • 두 컴포넌트가 강하게 결합된다. 아무런 제한이 없다 보니 생기는 단점이다. 처음 형태로 조금 되돌아간 셈이다. 다만 통신하는 컴포넌트끼리만 커플링이 생기기 때문에 통짜 클래스 형태일 때보다는 낫다.

| 메시지를 전달하는 방식 |

가장 복잡한 대안이다. 컨테이너 객체에 간단한 메시징 시스템을 만든 뒤에, 각 컴포넌트들이 서로에게 정보를 뿌리게 할 수 있다. 

 

구현을 보자. 먼저 모든 컴포넌트가 상속받아야 하는 기본 인터페이스인 Compoenent를 정의한다.

class Component
{
public:
    virtual ~Component() {}
    virtual void receive(int message) = 0;
};

인터페이스에는 들어오는 메시지를 들을 수 있도록 컴포넌트 클래스가 구현해야 하는 receive 메서드 하나만 들어 있다. 지금은 int만으로 메시지를 구별하지만, 제대로 구현하려면 데이터를 추가로 보낼 수 있어야 할 것이다.

 

컨테이너 객체에는 메시지를 보내는 메서드를 추가한다.

class ContainerObject
{
public:
    void send(int message)
    {
        for(int i = 0; i < MAX_COMPONENTS; ++i)
        {
            if(components_[i] != NULL) 
            {
                components_[i]->receive(message);
            }
        }
    }
    
private:
    static const int MAX_COMPONENTS = 10;
    Components* components_[MAX_COMPONENTS];
};

컴포넌트가 컨테이너에 메시지를 보내면, 컨테이너는 자기에게 있는 모든 컴포넌트에 이를 전파한다. 이러면 다음과 같은 결과를 얻는다.

  • 하위 컴포넌트들은 디커플링된다. 상태 공유 방식에서처럼 상위 컨테이너 객체를 통해서 통신하기 때문에, 컴포넌트들은 메시지 값과 커플링 될 뿐 컴포넌트끼리는 디커플링 상태를 유지한다.

  • 컨테이너 객체는 단순하다. 상태 공유 방식에서는 컨테이너 객체가 데이터를 소유하고 컴포넌트들이 이를 어떻게 사용하는지를 알아야 했지만, 여기에서는 메시지만 무작정 전달하면 된다. 특정 분야에 한정된 정보를 컨테이너 객체에 노출하지 않고도 컴포넌트끼리 주고받을 수 있다.

세 가지 통신 방식 중 정답은 따로 없다. 하다 보면 셋 다 조금씩 쓰게 된다. 상태 공유 방식은 위치나 크기 같이 모든 객체에서 당연히 있을 거라고 생각하는 기본적인 정보를 공유하기에 좋다.

 

어떤 분야는 서로 별개이나 꽤나 가깝게 연관될 수 있다. 애니메이션과 렌더링, 사용자 입력과 AI, 물리와 충돌 같은게 그러하다. 이런 쌍이 서로 다른 컴포넌트로 존재한다면 각자 자신의 짝꿍 컴포넌트를 직접 알게 하는 게 작업하기에 가장 편할 수 있다.

 

메시징은 호출하고 나서 신경 안 써도 되는 '사소한' 통신에 쓰기 좋다. 예를 들어 물리 컴포넌트에서 객체가 무엇인가 충돌했다고 전파하면 오디오 컴포넌트가 이를 받아 소리를 내는 식이다. 

 

먼저 쉬운 것부터 해본 다음 필요하면 다른 통신 방법을 추가하자.


자료의 출처는 http://gameprogrammingpatterns.com/component.html 입니다.

반응형
Comments