컴포넌트 (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가 바로 컴포넌트 방식에 맞추어서 설계되었다.

+ Recent posts