소년코딩

컴포넌트 패턴, Component Pattern

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

AI, 물리, 렌더링, 사운드처럼 분야가 다른 코드끼리는 최대한 서로 모르는 게 좋다. 이런 코드를 한 클래스 안에 전부 넣는다면 결과는 뻔하다. 클래스 하나가 5천 줄 넘는 거대한 쓰레기 코드로 뒤덮여버리게 된다. 클래스가 크다는 것은 정말 사소한 걸 바꾸려고 해도 엄청난 작업이 필요할 수 있음을 의미한다. 이런 클래스는 머잖아 기능보다 버그가 더 빨리 늘어나게 된다.

고르디우스의 매듭

코드 길이보다 더 큰 문제가 커플링이다. 여러 게임 시스템이 주인공 캐릭터 클래스 안에서 실타래처럼 얽혀 있다.

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

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

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

매듭 끊기

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

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

느슨한 구조

컴포넌트 클래스들은 디커플링되어 있다. PhysicsComponet와 GraphicsComponent는 Character 클래스 안에 들어 있지만 서로에 대해 알지 못한다. 즉 물리 프로그래머는 그래픽 처리는 신경 쓰지 않고 자기 코드를 수정할 수 있다.

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

다시 합치기

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

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

component

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

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

컴포넌트는 기본적으로 객체를 위한 플러그 앤 플레이라고 볼 수 있다. 개체 소켓에 재사용 가능한 여러 컴포넌트 객체를 꽂아 넣음으로써 복잡하면서도 기능이 풍부한 개체를 만들 수 있다.


패턴

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


언제 쓸 것인가?

  • 한 클래스에서 여러 분야를 건드리고 있어서, 이들을 서로 디커플링하고 싶다.
  • 클래스가 거대해져서 작업하기가 어렵다.
  • 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다. 단 상속으로는 딱 원하는 부분만 골라서 재사용할 수가 없다.

예제 코드

통짜 클래스

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

class Character {
public:
    Character: 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 sprriteWalkLeft_;
    Sprite spriteWalkRight_;
};

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

void Character::update(World& 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_);
}

위 코드는 입력에 따라 주인공을 가속한다. 다음으로 물리 엔진을 통해 주인공의 다음 위치를 구한다. 마지막으로 화면에 캐릭터를 그린다.

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

분야별로 나누기

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

class InputComponent {
public:
    void update(Character& character) {
        switch(Controller::getJoystickDirection()) {
            case DIR_LEFT:
                character.velocity_ -= WALK_ACCELERATION;
                break;
            case DIR_RIGHT:
                character.velocity_ += WALK_ACCELERATION;
                break;
        }
    }

private:
    static const int WALK_ACCELERATION = 1;
};

어려울 거 없다. Character 클래스의 update 메서드에서 앞부분을 InputComponent 클래스로 옮겼다.

class Character {
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 sprriteWalkLeft_;
    Sprite spriteWalkRight_;
};

Character 클래스에 InputComponent 객체가 추가되었다. 이전에는 사용자 입력을 update() 에서 직접 처리했찌만, 지금은 입력 컴포넌트에 위임한다.

input_.update(*this);

이제 시작일 뿐인데도 벌써 Character 클래스가 더 이상 Controller를 참조하지 않도록 커플링을 일부 제거했다.

나머지도 나누기

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

class PhysicsComponent {
public:
    void update(Character& character, World& world) {
        character.x += character.velocity;
        world.resolveCollision(volume_, character.x, character.y, character.velocity);
    }

private:
    Volume volume_;
};

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

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

class GraphicsComponent {
public:
    void update(Character& character, Graphics& graphics) {
        Sprite* sprite = &spriteStand_;

        if(character.velocity < 0) {
            sprite = &spriteWalkLeft_;
        }
        else if(character.velocity > 0) {
            sprite = &spriteWalkRight_;
        }

        graphics.draw(*sprite, character.x, character.y);
    }

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

Character 클래스에서 거의 모든 코드를 뽑아냈다. Character 클래스에는 코드가 거의 남아있지 않다.

class Character {
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_;
    GraphcisComponent graphics_;
};

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

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

오토-캐릭터

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

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

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

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

class PlayerInputComponent : public InputComponent {
public:
    virtual void update(Character& character) {
        switch(Controller::getJoystickDirection()) {
            case DIR_LEFT:
                character.velocity -= WALK_ACCELERATION;
                break;
            case DIR_RIGHT:
                character.velocity += WALK_ACCELERATION;
                break;
        }
    }

private:
    static const int WALK_ACCELERATION = 1;
};

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

class Character {
public:
    int velocity;
    int x, y;

    Character(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_;
};

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

Character* character = new Character(new PlayerInputComponent());

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

그러나 '데모 모드'에서 플레이어가 아무것도 안하고 가만히 앉아 있을 때, 대신 컴퓨터가 자동으로 게임을 플레이 하는등의 기능을 만들 수 있다.

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

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

데모 모드용으로 캐릭터 객체를 생성할 때에는 새로 만든 컴포넌트를 연결한다.

Character* character = new Character(new DemoInputComponent());

More

  • 유니티 프레임워크의 핵심 클래스인 GameObject는 전적으로 컴포넌트 방식으로 맟춰 설계되었다.
  • XNA 게임 프레임워크에는 Game이라는 핵심 클래스가 있는데, 여기에는 GameComponent 객체 컬렉션이 들어 있다.

이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/component.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기