Game Development, 게임개발/디자인패턴

Component, 컴포넌트 [디자인패턴](디커플링)**

게임이 더 좋아 2021. 10. 26. 21:21
반응형
728x170

 

이 패턴을 사용하는 의도는

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

 

완전 많이 쓰이는 패턴이다.

이것만큼은 잘 해보자.

 


 

플랫포머 게임을 만든다고 해보자.

플레이어 한 명을 정했다면

클래스를 하나 만들어서 주인공이 게임에서 하는 모든 것들을 넣는 것이 당연해 보인다.

 

플레이어를 조정해야 하니 컨트롤러 입력 값을 읽어 행동으로 바꿔야 한다.

지형이나 플랫폼 같은 레벨과도 상호작용할 수 있도록 물리 및 충돌 처리도 필요하다.

주인공이 화면에 나와야 하니 애니메이션과 렌더링도 넣자. 

 

소프트웨어 구조 입문 수업에서 분야가 다른 코드는 서로 격리해야 한다고 배웠다.

** 클래스는 한 번에 하나의 역할만 하게끔 만드는 것이 OOP의 원칙이다.

 

예를 들어서 워드 프로세서라면 인쇄 코드가 문서를 열고 저장하는 코드에 영향을 받으면 안 된다.

게임은 사무용 소프트웨어와는 다르지만, 이런 규칙은 똑같이 적용된다.

 

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

이런 코드를 한 클래스 안에 전부 욱여넣는다면 결과는 뻔하다.

클래스 하나가 몇 천줄 넘는 거대한 스파게티 코드가 될 것이다.

 

이런 코드에 적응하는 일부 프로그래머는 직업 안정성을 확보할 수 있을지 몰라도

나머지 사람들에게는 지옥이나 다를 바 없다.

클래스가 크다는 것은 정말 사소한 걸 바꾸려고 해도 엄청난 작업이 필요할 수 있음을 의미한다.

** 나라도 그렇다. 

 


 

코드 길이만 길면 그나마 낫다.

더 큰 문제가 커플링, Coupling 이다.

만약 여러 게임 시스템이 플레이어 클래스 안에서 스파게티처럼 얽혀있다.

 

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

 

이 코드를 문제없이 고치려면

물리(colliding With Floor), 그래픽( get Render State), 사운드(play Sound)를 전부 알아야 한다.

 

커플링과 코드 길이 문제는 서로 악영향을 미친다.

한 클래스가 너무 많은 분야를 건드리다 보니

모든 프로그래머가 그 클래스를 작업해야 하는데, 클래스가 너무 크다 보니 작업하기가 굉장히 어렵다.

이런 상황이 심해지면 프로그래머들이 뒤죽박죽이 된 코드를 손대기 싫어지고

싫어지면 다른 곳에 그냥 임시 코드를 만들어 현재 주어진 작업에만 해결되는 다른 쓰레기 코드를 만들게 된다.

 

 

그렇게 생긴 매듭은 끊어버려야 한다.

한 덩어리였던 부분들을 나누면 된다.

 

예를 들어서 사용자 입력을 받는 부분을 InputComponent 클래스로 옮겨서 매듭을 푼다.

이러고 나면 컴포넌트들을 묶는 얇은 껍데기만 본 클래스에 남은 것들은 거의 없을 것이다.

클래스 코드 크기 문제는 클래스를 여러 작은 클래스로 나누는 것만으로 해결된다.

 

컴포넌트 클래스들은 디커플링되어 있다.

예를 들어 Physics와 Graphics는 클래스 안에 들어 있지만 서로에 대해 알지 못한다.

즉, 서로 관련되어 있지는 않다.

 

현실적으로는 컴포넌트끼리 상호작용이 필요할 수도 있다. 

예를 들어 AI 컴포넌트는 플레이어의 가려는 곳을 물리 컴포넌트를 통해서 알아내야 할 수도 있다.

다만 모든 코드를 한 곳에 배치하지 않았기 떄문에 서로 통신이 필요한 컴포넌트만으로 결합을 제한할 수 있다.

 

컴포넌트 패턴의 다른 특징은 이렇게 만든 컴포넌트를 재사용할 수 있다는 점이다.

이제부터는 플레이어 게임에 필요한 다른 객체들을 생각해보자.

 

Decoration, 데코는 덤불이나 먼지 같이 볼 수는 있지만 상호작용은 할 수 없는 객체다.

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

Zone, 존은 Deco와는 반대로 보이지는 않지만 상호작용은 할 수 있는 객체다.

 

 

만약 컴포넌트를 쓰지 않는다면 이들 클래스를 어떻게 상속해야 할까?

 

 

GameObject 클래스에는 위치나 방향 같은 기본 데이터를 둔다.

Zone은 GameObject을 상속받은 뒤에 충돌 검사를 추가한다.

Decoration도 GameObject를 상속받은 뒤 렌더링 기능을 추가한다.

Prop은 충돌 검사 기능을 재사용하기 위해 Zone을 상속받는다.

하지만 Prop이 렌더링 코드를 재사용하기 위해 Decoration 클래스를 상속하려는 순간

"죽음의 다이아몬드"라고 불리는 다중 상속 문제를 피할 수 없다.

 

뒤집어서 Prop이 Decoration을 상속 받게 만들어봐야 충돌 처리 코드 중복은 피할 수 없다.

어떻게 해도 다중 상속 없이는 충돌 처리 코드와 렌더링 코드를 깔끔하게 재사용할 수 없다.

아니면 모든 기능을 GameObject 클래스에 올려놔야 하는데

그러면 Zone에는 필요 없는 렌더링 데이터가 들어가고 Decoration에는 쓰지 않는 물리 기능이 들어가게 된다.

 

이제 컴포넌트로 만들어보자.

상속은 전혀 필요가 없다.

GameObject 클래스 하나와 Physics, Graphics 2개 클래스만 있으면 된다.

Decoration은 Graphics에 있고 Physics는 없는 GameObject다.

반대로 Zone은 Graphics가 없고 Physics가 있는 GameObject다.

Prop에는 둘다 있다.

 

여기에는 코드 중복도, 다중 상속도, 클래스 개수도 4개에서 3개로 줄어들었다.

 

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

개체 소켓에 재사용 가능한 여러 컴포넌트 객체를 꽂아 넣음으로써

복잡하면서 기능이 풍부한 개체를 만들 수 있다.

 


 

여러 분야를 다루는 하나의 개체가 있다.

분야별로 격리하기 위해, 각각의 코드를 별도의 컴포넌트 클래스에 둔다.

이제 개체 클래스는 단순히 컴포넌트들의 컨테이너 역할만 하면 된다.

 

실제 기능은 컴포넌트에 있다고 봐도 무방하다.

 

그렇다면 이렇게 유용한 컴포넌트 패턴을 언제 쓸까??

 

컴포넌트는 게임 개체를 정의하는 핵심 클래스에서 가장 많이 사용되지만

다음 조건 중 하나라도 만족한다면 다른 분야에서도 유용하게 쓸 수 있다.

 

-한 클래스에서 여러 분야를 건드리고 있어서 디커플링하고 싶다거나

-클래스가 너무 거대해졌다거나

-여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다거나.

(상속으로는 부분적으로 재사용할 수 없기 때문이다)

 


 

이렇게 좋은 컴포넌트 패턴도 몇가지 주의사항이 있다.

 

컴포넌트 패턴을 적용했을 때 클래스 하나에 코드를 모아놨을 때 보다

더 복잡해질 수 있다.

한 무리의 객체를 생성하고 초기화하고 알맞게 묶어주는 것이 객체를 만드는 것이기 때문이다.

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

 

코드 베이스 규모가 크면 이런 복잡성에서 오는 손해보다

디커플링과 컴포넌트를 통한 코드 재사용에서 얻는 이득이 더 크다.

하지만 컴포넌트 패턴을 적용하기 전에 아직 있지도 않은 문제에 대한 "해결책"을

오버엔지니어링하는 것은 아닌지 주의하자.

 

또한 컴포넌트 패턴은 무엇이든지 하려면 한 단계를 거쳐야 할 때가 많다.

무슨 일이든 컨테이너 객체를 통해서 원하는 컴포넌트를 얻고나서 무엇인가 액션을 취하는 것이다.

이런 메커니즘은 성능이 민감한 내부 루프 코드에서는 성능이 떨어질 수도 있다.

 


 

이제 예를 들어서 알아보자

패턴을 핵심만 파헤쳐보자.

컴포넌트 패턴은 짧게 설명하기는 어렵지만 천천히 보자.

 

우선 아래 예시는 컴포넌트 패턴을 적용하기 전의 플레이어 클래스다.

얼마나 많은 기능이 들어있는지 보자

 

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

 

update 메서드는 매 프레임마다 호출된다.

 

void Bjorn::update(World& world, Graphics& graphics)
{
  // Apply user input to hero's velocity.
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;

    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }

  // Modify position by velocity.
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);

  // Draw the appropriate sprite.
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0)
  {
    sprite = &spriteWalkLeft_;
  }
  else if (velocity_ > 0)
  {
    sprite = &spriteWalkRight_;
  }

  graphics.draw(*sprite, x_, y_);
}

 

위 코드는 조이스틱 입력에 따라 플레이어를 가속하고

물리 엔진을 통해 주인공의 다음 위치를 그린 후 렌더링한다.

 

구현은 굉장히 심플하다.

중력 x, 애니메이션 x, 그저 캐릭터의 움직임만 표현했다.

그럼에도 update함수는 여러 프로그램의 작성으로 돌아가야한다는 생각을 해보니

엄청나게 더러워질 것이라는 것이 생각이 든다.

 

먼저 분야 하나를 정해서 관련 코드를

플레이어에서 컴포넌트 클래스로 옮긴다.

 

가장 먼저 처리되는 입력 분야부터 시작해보자.

사용자의 입력에 따라 속도를 조절하는 처리를 별개의 클래스에 옮겨보자.

 

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;
};

 

어려울 거 없다. 

update 메서드에서 앞부분을 InputComponent 클래스로 옮겼다.

기존 클래스도 바뀐다.

 

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

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);

    // Modify position by velocity.
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);

    // Draw the appropriate sprite.
    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를 참조하지 않도록 커플링을 일부 제거했다.

디커플링이 되어 버렸다.

 

이런 식으로 물리코드와 그래픽 코드도 분리시키면 된다.

 

 

먼저 물리에 대한 코드

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

 

우선 짧아졌다.

이렇게 바뀐 클래스는 2가지 역할을 한다.

 

먼저 자신을 정의하는 컴포넌트 집합을 관리하고 컴포넌트들이 공유하는 상태를 들고 있는다.

예를 들어 위치나 속도와 같은 값을 말한다. ( 거의 모든 컴포넌트들이 사용하기 때문이다)

그렇기 때문에 굳이 저런 필드들을 특정 컴포넌트들에 넣어서 보관하기는 그렇다.

그렇게 함으로 인해 컴포넌트들이 커플링 되지 않고 쉽게 통신할 수 있기 때문이다.

 

 


 

동작 코드를 별도의 컴포넌트 클래스로 옮겼지만 아직 추상화하지는 않았다.

플레이어 클래스는 자신의 동작을 어떤 구체 클래스에서 정의하는지 알고 있다.

 

예를 들어 사용자 입력 처리 컴포넌트를 인터페이스 뒤로 숨기려고 한다.

InputComponent을 다음과 같이 추상 상위 클래스로 바꿔보자.

 

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

 

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

 

class PlayerInputComponent : public InputComponent
{
public:
  virtual 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;
};

 

플레이어 클래스는 InputComponent 구체 클래스의 인스턴스가 아닌 인터페이스의 포인터를 들고 있게 한다.

 

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

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

 

이젠 플레이어 객체를 생성할 때 

플레이어가 사용할 입력 컴포넌트를 전달하는 것이다.

 

Bjorn* bjorn = new Bjorn(new PlayerInputComponent());

 

어떤 클래스라도 InputComponent 추상 인터페이스만 구현하면 입력 컴포넌트가 되는 것이다.

update()는 가상 메서드로 바뀌면서 속도는 조금 느려졌다.

 

이로 얻을 수 있는 것은 무엇일까?

대부분 콘솔게임에서는 "데모 모드"를 지원하는데

이는 사용자 입력이 없을 때, 컴퓨터가 자동을 게임을 플레이해주는 모드다.

오락실 다녀보면 무슨 말인지 알 것이다.

 

아무튼 입력 컴포넌트 클래스를 인터페이스를 밑에 숨긴 덕분에 이런 것을 만들 수 있게 되었다.

PlayerInputComponent는 실제로 게임을 플레이할 때 사용하는 클래스이니

다른 클래스를 살펴보자

 

class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // AI to automatically control Bjorn...
  }
};

 

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

 

Bjorn* bjorn = new Bjorn(new DemoInputComponent());

 

단순히 컴포넌트만 교체했지만 완벽하게 다른 객체를 만들어버렸다.

그냥 재사용하면 여러 가지를 만들 수 있다니 놀랍다.

 


 

플레이어 클래스는 그저 컴포넌트 묶음이 된 것이지

플레이어의 자체에 무엇이 있는 것은 아니다.

 

게임에서는 모든 객체가 기본으로 사용하는 게임 객체

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;
};

 

플레이어 클래스의 이름을 범용적으로 쓰일 수 있게 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 BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // Physics code...
  }
};

class BjornGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphics& graphics)
  {
    // Graphics code...
  }
};

 

플레이어를 위한 별도 클래스 없이도 플레이어 역할을 하는 게임오브젝트를 만들 수 있다.

컴포넌트 패턴은 이렇게 사용한다.

 

GameObject* createBjorn()
{
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}

 

반응형
그리드형