컴포넌트 디자인 패턴은 게임 개발에서 굉장히 중요하게 사용되는 디자인 패턴이다.

물론, 다른 분야에서도 자주 사용되겠지만 언리얼 엔진, 유니티 등의 상용엔진에서 핵심적으로 사용되는 패턴이기 때문에, 반드시 알아두어야 한다.

 

컴포넌트 패턴이란, 코드의 재활용성을 높여줌과 동시에 커플링 문제를 해결하기 위한 디자인 패턴이다.

 

일반적으로 우리가 어떠한 기능을 구현하고자 하면, 해당 기능을 객체 내부에 정의하게 된다.

예를 들어, 플레이어가 이동하는 기능을 구현한다고 해보자.

class Player
{
public:
    void Move();
};

 

위 코드와 같이, 단순하게 객체 내부에 Move라는 함수를 선언한 뒤, 해당 함수를 필요할 때 호출해주면 된다.

하지만, 이동기능을 플레이어 뿐만이 아니라, 몬스터도 사용하고 싶다면?

 

일반적으로는 아래와 같이 상속을 활용하여 구현할 것이다.

class CharacterBase
{
    virtual void Move() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
};

class Monster : public CharacterBase
{
public:
    void Move() override;
};

 

이번엔, 공격 기능을 추가하고 싶다고 해보자.

아래와 같이 CharacterBase에 공격 함수를 추가하게 될 것이다.

class CharacterBase
{
    virtual void Move() {}
    virtual void Attack() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class Monster : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

 

이번엔, 몬스터가 종류가 2가지라고 생각해보자.

일반적으로는 두 몬스터 모두 아래와 같이 CharacterBase를 상속받게 될 것이다.

 

class CharacterBase
{
    virtual void Move() {}
    virtual void Attack() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class MonsterA : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class MonsterB : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

 

여기서 몬스터 A가 인간형 몬스터, 몬스터 B는 고정형 몬스터라고 가정해보자.

몬스터 A는 이동에 관한 함수가 필요하지만 몬스터 B는 자리에 고정되어 움직이지 않는 타입이기 때문에 Move함수가 전혀 필요하지 않다.

 

하지만, 몬스터 B도 Attack 함수를 사용하기 위해선 CharacterBase를 상속받아야만 한다.

상속의 문제점은 여기서 발생한다.

 

해당 클래스를 상속받는 하위 클래스들이 정확히 어떤 함수들을 공통으로 가져야할지 정확히 예측할 수 없기 때문에, 특정 클래스에선 사용하지 않는 함수들이 발생할 수 밖에 없다.

 

또한, 지금은 CharacterBase 클래스에 Attack 과 Move만 있지만, 사운드 처리 기능이나 충돌 처리 등의 기능을 추가하게 되면 하나의 클래스가 너무 많은 기능와 책임을 가지게 되면서 유지보수가 굉장히 힘들고 복잡해진다. 

 

그렇다고, 공격에 관한 클래스와 이동에 관한 클래스를 따로 만들어서 다중 상속을 하게 되면 다이아몬드 문제가 발생할 가능성이 생겨버린다.

 

또한, 상속이라는 것은 두 클래스간의 결합도를 매우 높히기 때문에 부모 클래스에서 특정 로직을 수정했다가 자식 클래스를 몽땅 수정해야 하는 경우가 발생할 수도 있다.

 

이런 문제를 해결하기 위해 사용되는 패턴이 컴포넌트 패턴이다.

 

컴포넌트 패턴

아래 코드를 보자.

class MovementComponent
{
public:
    void Move() {}
    void Jump() {}
};

class AttackComponent
{
public:
    void BasicAttack() {}
    void ChargedAttack() {}
};

class Player
{
public:
    Player()
    {
        if (MyMovementComponent == nullptr)
        {
            MyMovementComponent = new MovementComponent();
        }

        if (MyAttackComponent == nullptr)
        {
            MyAttackComponent = new AttackComponent();
        }
    }

    ~Player()
    {
        if (MyMovementComponent != nullptr)
        {
            delete MyMovementComponent;
        }

        if (MyAttackComponent != nullptr)
        {
            delete MyAttackComponent;
        }
    }

    void Move()
    {
        if(MyMovementComponent != nullptr)
        {
            MyMovementComponent->Move();
        }
    }

    void BasicAttack()
    {
        if (MyAttackComponent != nullptr)
        {
            MyAttackComponent->BasicAttack();
        }
    }

private:
    MovementComponent* MyMovementComponent = nullptr;
    AttackComponent* MyAttackComponent = nullptr;
};

 

먼저, MovementComponent를 만들어서 그 안에 Move와 Jump함수를 선언해놓았다.

다음은 AttackComponent를 만들어서 그 안에 BasicAttack과 ChargedAttack을 선언해놓았다.

 

Player내부에선, 생성자에서 MovementComponent와 AttackComponent를 생성해주고 있으며, 해당 기능이 필요할 때 컴포넌트의 함수를 호출하여 기능을 사용하고 있다.

 

이 것이 컴포넌트 패턴이다. 내가 공격이 필요하다면 AttackComponent를 생성해서 사용하면 되고, 이동이 필요하지 않다면 MovementComponent를 안만들면 그만이다.

 

이렇게 컴포넌트 패턴으로 구현하게 되면, 상속관계에서 발생했던 문제들이 전혀 발생하지 않는다.

 

객체 내부에선 컴포넌트의 함수를 호출해서 사용하기만 하면 되기 때문에, 두 클래스 간의 결합도가 상속할때보다 훨씬 낮아진다. 또한, 컴포넌트끼리도 서로 모르는 상태로 기능을 구현할 수 있기 때문에, 컴포넌트 사이의 결합도 또한 매우 낮다.

 

컴포넌트 별로 특정 기능을 담당하여 구현하기 때문에, 필요한 컴포넌트만 생성해서 사용한다면 객체에서 필요없는 기능을 보유하는 일이 현저하게 줄어든다. 

 

기능을 수정할 때에도, 컴포넌트 클래스 내부의 코드만 수정하면 되기 때문에 유지보수 측면에서도 매우 유용하다.

 

언리얼 엔진에서는 렌더링과 관련된 부분인 SkeletalMeshComponent도 있고, 이동과 관련된CharacterMovementComponent도 있고, AI의 인지기능과 관련된 AIPerceptionComponent도 있다. 기능이 필요할 때마다, 해당 컴포넌트를 생성해서 사용하면 된다.

 

상속과 컴포넌트는 Is - A관계와 Has - A 관계라고도 부른다.

 

Is - A , Has - A

 

Goblin이라는 클래스가 Monster라는 클래스를 상속받았다고 해보자.

이 관계에서 Goblin은 곧 Monster라고도 할 수 있다.

Goblin is a monster. 라고 표현할 수 있기 때문에, 상속관계는 Is - A 관계라고도 부른다.

 

Player가 MovementComponent를 생성했다고 해보자.

이 컴포넌트를 사용하기 위해 Player는 컴포넌트를 멤버변수에 저장하여 사용할 것이다.

 

즉, Player는 MovementComponent를 보유하고 있는것이다.

Player has a MovementComponent.라고 표현할 수 있기 때문에 컴포넌트 구조는 Has - A 관계라고도 부른다.

 

상속과 컴포넌트는 각각의 장단점이 존재한다.

 

상속의 장단점

장점

  • 객체지향의 특징인 다형성을 구현할 수 있다.
  • 객체의 속성을 확실하게 표현할 수 있다. 

단점

  • 클래스 간의 결합도가 높아진다.
  • 부모 클래스의 코드를 수정할 때, 자식 클래스의 코드를 몽땅 수정해야 하는 경우가 발생할 수도 있다.
  • 부모 클래스에 결함이 있을 때, 이는 자식 클래스에도 결함으로 전파된다.

컴포넌트의 장단점

장점

  • 클래스간의 결합이 느슨하다.
  • 객체에서 필요없는 기능을 보유하게 되는 경우가 상대적으로 적다. 
  • 컴포넌트 끼리 서로 알 필요가 없다. (디커플링)
  • 상속에 비해, 캡슐화를 더욱 견고하게 할 수 있다.

단점

  • 컴포넌트 간의 상호작용을 구현하기가 까다롭다.
  • 사용하기에 따라, 오히려 코드가 복잡해지고 유지보수를 어려워지게 만들 수 있다.

 

컴포넌트 패턴도 상속도 잘 사용하면 너무나 좋은 것들이지만, 사실 잘 쓰기가 정말 까다로운 것 같다.

컴포넌트 패턴도 언뜻 보기엔 좋아보여도, 막상 구현하려고 하면 정말 고려해야 하는 것들이 너무너무 많다.

상용엔진을 사용하게 되면, 자주 접하게 되는 만큼 조금이라도 알고 사용하도록 하자.

 

+ Recent posts