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

Object Pool, 오브젝트 풀, 객체 풀 [디자인패턴](최적화)

게임이 더 좋아 2021. 10. 17. 16:31
반응형
728x170

 

의도는

객체를 매번 할당, 해제하지 않고 고정 크기 풀에 들어있는 객체를 재사용함으로써

메모리 사용 성능을 개선한다.

 

?? 해제하지 않는데.. 어떻게 메모리 사용 성능이 개선된다는거지???

알아보자

 


 

게임에서는 시각적 효과가 필요하다.

플레이어가 마법을 쓰면 빛이 나고

플레이어가 총을 쏘면 총알이 날라간다.

 

그 중에서 빛은 파티클 시스템이 필요하다.

마법을 몇 번 쓰는 것만으로도 수많은 파티클 생성되어야 하기 때문에..

굉장히 빠르게 만들 수 있어야 한다.

더욱 중요한 것은 파티클을 생성, 제거하는 과정에 메모리 단편화가 생겨서 안 된다는 점이다.

 

**메모리 단편화는 경계해야 한다.

단편화란 힙에 사용 가능한 메모리 공간이 크게 뭉쳐 있지 않고 작게 조각나 있는 상태를 말한다.

 

그림을 보자

?? 메모리는 연속적으로 할당되기 때문에.. 저렇게 작은 조각 2개로 나눠져버리면

저 작은 조각 2개도 못쓰게 된다.

사실.. 과장했지만 아무튼 그렇다.

 

 

아무튼 단편화를 막으면서도 메모리 할당 속도 때문에

메모리를 언제 어떻게 관리할 지를 엄청 신경써야 한다.

 

이러한 문제는

게임이 실행될 때 메모리를 미리 크게 잡아놓고 끝날 때까지 들고 있는 것으로 해결할 수 있다.

??? 하지만 이러한 방법도 게임 실행 중에 객체를 생성, 제거해야 하는 시스템 입장에서는 골치가 아프다.

 

이럴 때 쓰는 것이 객체 풀이다.

메모리 관리자 입장에서는 처음에 한 번 메모리를 크게 잡아놓고 

게임이 실행되는 동안 계속 들고 있는 것이다.

객체 풀, 그 안에서 객체를 할당, 해체를 자유롭게 할 수 있다.

 


 

다시 정확하게 정의하자면

재사용 가능한 객체들을 모아놓은 객체 풀, pool 클래스를 정의한다.

여기에 들어가는 객체는

자신이 "사용중"인지 여부를 알 수 있는 방법을 제공해야 한다.

**사용 중이 아니라면 객체 풀에 다시 집어 넣어야 하기 때문

 

해당 풀에 객체를 미리 "사용 안함" 상태로 선언해놓고

필요할 때 객체 풀에 새로운 객체를 달라고 요구하는 것이다.

그렇게 되면 풀에서 객체를 선택하여 "사용 중"상태로 바꾼 뒤 객체를 반환해준다.

 

즉, 이런 식으로 메모리나 다른 자원 할당을 신경쓰지 않고 마음껏 객체를 생성, 삭제할 수 있다.

 


 

그렇다.. 앞서 마법에 썼지만..서도

언제 써야하는가? 를 생각해 볼 필요가 있다.

 

-객체의 빈번한 생성 및 삭제

 

-객체들의 크기가 비슷

 

-객체를 힙에 생성하는데 오래걸리거나 메모리 단편화가 걱정됨.

 

-데이터베이스 연결, 네트워크 연결같이 접근 비용이 비싸면서

재사용 가능한 자원을 객체가 캡슐화하고 있음

 

 


 

 

우선 Garbage Collector에 많은 의존을 한다.

물론 new, delete도 메모리를 관리한다.

하지만 객체 풀을 쓰겠다는 것은

메모리를 처리하는 것에 있어어 내가 더 잘안다라고 선언하는 말이다.

즉, 내가 관리할 때 생기는 문제도 해결할 수 있어야 한다는 말이다.

 


 

객체 풀에서 사용되지 않는 객체가 많을 경우에는 메모리 낭비하는 것과 다를 바가 없다.

즉, 메모리 단편화로 가용 메모리가 줄어드는 것을 걱정을 하면서도

객체 풀에 사용되지 않는 객체가 들어있다면 그것 또한 낭비하는 일이다.

풀의 크기가 너무 커지지 않으면서도 작지도 않아야 하는 것을 사용자가 잘 제어할 수 있어야 한다는 말이다.

 

또한 객체 풀 이상의 객체를 사용할 수 없다는 단점이 있다.

어떤 면에서는 장점이지만 단점이기도 하다.

 

객체 풀 이상의 객체를 사용할 수 없으니 한 번에 파티클 시스템이 많이 불러와지더라도 한계가 있어서

시스템이 메모리를 전부 먹거나 그러한 상황이 오지 않는다.

 

하지만 이는.. 모든 객체가 쓰이고 있을 때에는 다른 이펙트를 터뜨리지도 못한다는 말이 된다.

즉, 객체를 반환받지 못할 때 어떻게 해야하는가? 를 제어해야 한다.

 

사실 풀이 부족하지 않은 크기를 가지고 있는 것이 맞다.

하지만 폭발적으로 필요한 상황이 하나라면 그 하나를 위해서 객체 풀을 늘리는 것은.. 정말 비효율적이다.

하지만 상황에 따라 풀의 크기를 조절함으로써 해결할 수도 있다.

 

또한 위와 마찬가지로 반환하지 않는 것이 그냥 좋을 때도 있다.

시각효과와 같이 많은 것의 객체가 불러와질 때는.. 파티클 시스템 몇 개쯤은 없어도

플레이어의 몰입을 방해하지는 않는다.

 

그렇지 않다면 가장 먼저 생성된 객체나 특정 조건에 있는 객체를 강제로

"사용 안함"으로 강제시켜서 다시 "사용함"을 선언하여 구현한다.

 

마지막으로는 풀의 크기를 늘리기 위해 보조 풀을 만들 수도 있다.

 


 

다만 객체를 위한 메모리 크기는 고정되었다.

이게 무슨말이냐..?

대부분 객체 풀에서 객체를 배열로 관리하도록 구현한다.

풀에 들어가는 객체가 전부 같은 자료형이라면 상관없지만

다른 자료형인 객체나 필드가 추가된 하위 클래스의 인스턴스를 같은 풀에 넣고 싶다면

당연히 배열 한 칸의 크기는 가장 큰 자료형에 맞춰야 한다.

배열 하나에 할당된 메모리보다 더 큰 크기가 오게 된다면 객체의 메모리가 덮여져 버린다.

 

즉, 객체 풀의 객체들이 다양해지면 메모리 낭비가 생길 수 밖에 없다.

다시 말해서 객체 풀 하나로 모든 객체를 다룰 생각은 버리는 것이 좋다.

차라리 객체 풀을 여러 개 만드는 것이 오히려 효율적이다.

 

또한 재사용되는 객체를 저절로 초기화되지 않으므로

사용된 것은 다시 객체 풀에 담을 때에는

초기화하는 작업이 필요하다.

 

마지막으로 객체 풀의 크기와도 관련있는데

사용 중이지 않은 객체도 메모리에 남아 있다.

GC를 지원한다면 객체 풀을 덜 쓰는 편이지만 어쨌든 객체 풀을 구현한다면

GC와 객체 풀의 충돌에 주의해야 한다.

객체 풀에서는 객체가 사용 중이 아니더라도 메모리를 해제하지 않기 때문에

객체가 메모리에 계속 남는다.

이 때 이들 객체가 다른 객체를 참조하고 있다면 GC에서는 참조가 된 객체를 해제할 수 없기 때문에

객체를 다시 객체 풀에 반납할 때 꼭 참조하는 객체를 정리하고 집어넣기를 바란다.

 


 

실제 예제를 통해서 알아보도록 하자.

파티클 시스템에는 여러 물리 효과가 적용된다.

하지만 예제에서는 간단하게 다루도록 하겠다.

 

class Particle
{
public:
  Particle()
  : framesLeft_(0)
  {}

  //해당 객체를 쓸 때 쓴다.
  void init(double x, double y,
            double xVel, double yVel, int lifetime)
  {
    x_ = x; y_ = y;
    xVel_ = xVel; yVel_ = yVel;
    framesLeft_ = lifetime;
  }

  void animate()
  {
    if (!inUse()) return;

    framesLeft_--;
    x_ += xVel_;
    y_ += yVel_;
  }

  bool inUse() const { return framesLeft_ > 0; }

private:
  int framesLeft_;
  double x_, y_;
  double xVel_, yVel_;
};

 

앞서 기본 생성자에서는 파티클을 "사용안 함"으로 초기화 한다.

나중에 init()가 호출되면 파티클이 "사용 중"상태로 바뀐다.

 

파티클은 매 프레임마다 animate() 를 통해서 애니메이션 된다.

**업데이트 된다는 말이다.

 

풀은 어떤 파티클을 재사용할 수 있는지 알기 위해서 파티클 클래스의 inUse()를 호출한다.

파티클은 정해진 시간 동안만 유지된다는 점을 이용해서

별도의 플래그 없이 _frameLeft 변수만으로도 현재 파티클이 사용 중인지 확인 가능하다.

 

객체 풀 클래스도 간단히 만들 수 있다.

class ParticlePool
{
public:
  void create(double x, double y,
              double xVel, double yVel, int lifetime);

  void animate()
  {
    for (int i = 0; i < POOL_SIZE; i++)
    {
      particles_[i].animate();
    }
  }

private:
  static const int POOL_SIZE = 100;
  Particle particles_[POOL_SIZE];
};

 

밖에서는 create 함수로 새로운 파티클을 생성한다.

게임에서는 매 프레임마다 animate를 호출해 파티클을 차례로 업데이트한다.(애니메이션 한다)

particles_는 고정 길이 배열이다.

 

새로운 파티클도 아래와 같이 생성한다.

 

void ParticlePool::create(double x, double y,
                          double xVel, double yVel,
                          int lifetime)
{
  // Find an available particle.
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (!particles_[i].inUse())
    {
      particles_[i].init(x, y, xVel, yVel, lifetime);
      return;
    }
  }
}

 

다시 말해서 사용 가능한 파티클을 찾을 때까지 순회한다.

찾았다면 해당 파티클을 초기화해서 반환받아서 사용한다.

 

 


 

만약 위에서 가용한 파티클을 찾는데.. 왜 전부 뒤지냐고 물어본다면..?

이를 계속 추적해야 한다.

 

즉, 사용 가능한 파티클 객체 포인터를 별도의 리스트에 저장하는 것도 한 방법이다.

이러면 새로 파티클을 생성할 때 포인터 리스트에서 처음 것을 꺼내어

이 포인터가 가리키는 파티클을 사용하면 된다.

 

하지만 이 방법에서는 풀에 들어있는 객체만큼의 포인터가 또 할당되어야 한다.

처음 풀을 생성하면 모든 파티클이 포인터를 해당 리스트에서 가리켜야 한다.

 

 

메모리를 저기다 쓰기 싫다면..

사용하지 않는 파티클 객체의 데이터 일부를 활용하면 된다.??

무슨 말이냐??

 

사용되지 않는 파티클에서 위치나 속도 같은 데이터 대부분은 의미가 없다.

오직 파티클이 죽어 있는지를 알려주는 상태 하나만 사용된다.

 

예제 코드로 치자면 _frameLeft만 있으면 되고, 나머지 데이터는 다른 용도로 사용할 수 있다.

 

Particle 클래스를 고쳐보겠다.

 

class Particle
{
public:
  // ...

  Particle* getNext() const { return state_.next; }
  void setNext(Particle* next) { state_.next = next; }

private:
  int framesLeft_;

  union
  {
    // State when it's in use.
    struct
    {
      double x, y;
      double xVel, yVel;
    } live;

    // State when it's available.
    Particle* next;
  } state_;
};

 

frameLeft_ 를 제외한 모든 멤버 변수를 state_ 공용체의 live 구조체 안으로 옮겼다.

즉, live 구조체에는 파티클이 살아 있는 동안의 상태를 둔다.

 

파티클이 사용 중이 아닐 때에는, 공용체의 다른 변수인 next가 사용된다.

next는 이 파티클 다음에 사용 가능한 파티클 객체를 포인터로 가리킨다.

 

next 포인터를 이용하면 풀에서 사용 가능한 파티클이 묶여 있는 연결 리스트를 만들 수 있다.

이렇게 하면 추가 메모리 없이 죽어 있는 객체의 메모리를 재활용해서

자기 자신을 사용 가능한 파티클 리스트에 등록하게 할 수 있다.

 

이런 것을 빈칸 리스트, free List 기법이라고도 한다.

이를 위해서는 포인터를 초기화하는 것뿐만 아니라 파티클이 생성, 삭제될 때에도 포인터를 관리해야 한다.

당연한 얘기지만 빈칸 리스트의 머리,head (root를 가리키는 포인터) 도 관리해야 한다.

 

class ParticlePool
{
  // ...
private:
  Particle* firstAvailable_;
};

 

처음 생성하면 모든 파티클이 사용가능 상태이므로

빈칸 리스트는 전체 풀을 관통해 연결한다.

초기화는 아래와 같이 한다.

 

ParticlePool::ParticlePool()
{
  // The first one is available.
  firstAvailable_ = &particles_[0];

  // Each particle points to the next.
  for (int i = 0; i < POOL_SIZE - 1; i++)
  {
    particles_[i].setNext(&particles_[i + 1]);
  }

  // The last one terminates the list.
  particles_[POOL_SIZE - 1].setNext(NULL);
}

 

새로운 파티클을 반환받아 쓰고싶다??

바로 받아오자

 

void ParticlePool::create(double x, double y,
                          double xVel, double yVel,
                          int lifetime)
{
  // Make sure the pool isn't full.
  assert(firstAvailable_ != NULL);

  // Remove it from the available list.
  Particle* newParticle = firstAvailable_;
  firstAvailable_ = newParticle->getNext();

  newParticle->init(x, y, xVel, yVel, lifetime);
}

 

파티클이 죽었다?

다시 돌려주자

animate() 반환 값을 불리언으로 바꿔 true를 반환하자

 

bool Particle::animate()
{
  if (!inUse()) return false;

  framesLeft_--;
  x_ += xVel_;
  y_ += yVel_;

  return framesLeft_ == 0;
}

 

죽으면 다시 리스트에 들여보내자

void ParticlePool::animate()
{
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (particles_[i].animate())
    {
      // Add this particle to the front of the list.
      particles_[i].setNext(firstAvailable_);
      firstAvailable_ = &particles_[i];
    }
  }
}

 

 


 

객체 풀에서 몇가지 고려할 점을 짚고 넘어가자

 

1. 풀이 객체와 커플링 되는가?

 

우선 객체 풀을 구현할 때에는 객체가 자신이 풀에 들어 있는지를 할 것인지부터 정해야 한다.

대부분은 그렇지만, 아무 객체나 담을 수 있는 일반적인 풀을 만든다면

이런 것은 고려할 대상 조차 아닐 것이다.

(아무.. 객체니까.. 일일이 다 하는 것은 비효율적이다)

 

-만약 커플링 된다면

풀이 들어가는 객체에 "사용 중" 플래그나 이런 역할을 하는 함수를 추가하면 된다.

객체가 풀을 통해서만 생성할 수 있도록 강제할 수도 있다.

C++에서는 풀 클래스를 객체 클래스의 friend로 만든 뒤 객체 생성자를 private에 두면 된다.

 

class Particle
{
  friend class ParticlePool;

private:
  Particle()
  : inUse_(false)
  {}

  bool inUse_;
};

class ParticlePool
{
  Particle pool_[100];
};

이런 관계는 객체 클래스를 어떻게 사용해야 하는지를 문서화하는 효과가 있다.

 

"사용 중" 플래그가 꼭 필요한 것은 아니다.

다만 객체에 자신이 사용 중인지를 알 수 있는 상태가 이미 있을 때 얘기다.

앞서 구현했던 isUse() 메서드를 제공하여 사용 여부를 조사할 수도 있다.

 

-만약 커플링되지 않는다면?

어떤 객체라도 풀에 넣을 수 있다.

객체와 풀이 디커플링됨으로써 일반적이면서 재사용 가능한 풀 클래스를 만들 수 있다.

"사용 중"인 상태를 객체 외부에서 관리해야 한다.

간단하게 플래그같이 비트를 이용한다.

template <class TObject>
class GenericPool
{
private:
  static const int POOL_SIZE = 100;

  TObject pool_[POOL_SIZE];
  bool    inUse_[POOL_SIZE];
};

 

 

2. 재사용 객체를 초기화할 때 주의할 점이 있는가?

 

우선 기존 객체를 재사용하기 위해서는 먼저 상태를 초기화해야 한다.

이때 객체 초기화를 풀 클래스에서 할 지, 밖에서 할 지를 정해야 한다.

 

-풀 안에서 한다면..?

 

class Particle
{
  // Multiple ways to initialize.
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y, double xVel, double yVel);
};

//풀에서 초기화 하기 위해서 객체 모든 초기화 메서드를 풀에서 부를 수 있게 해야 한다.

class ParticlePool
{
public:
  void create(double x, double y)
  {
    // Forward to Particle...
  }

  void create(double x, double y, double angle)
  {
    // Forward to Particle...
  }

  void create(double x, double y, double xVel, double yVel)
  {
    // Forward to Particle...
  }
};

 

풀은 객체를 완전히 캡슐화할 수 있다.

잘하면 풀 내부에 완전히 숨길 수도 있다.

이러면 밖에서 객체를 아예 참조할 수 없기 때문에 예상치 못하게 재사용되는 것을 막을 수 있다.

 

풀 클래스는 객체가 초기화되는 방법과 결합된다.

풀에 들어가는 객체 중에는 초기화 메서드를 여러 개 지원할 수도 있다.

 

 

-만약 객체를 밖에서 초기화한다??

 

그렇게 되면 풀의 인터페이스는 단순해진다.

풀은 객체 초기화 함수를 전부 제공하지 않아도 새로운 객체에 대한 레퍼런스만 반환하면 된다.

 

class Particle
{
public:
  // Multiple ways to initialize.
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y, double xVel, double yVel);
};

class ParticlePool
{
public:
  Particle* create()
  {
    // Return reference to available particle...
  }
private:
  Particle pool_[100];
};

 

밖에서 반환받은 객체의 초기화 메서드를 바로 호출할 수 있다.

ParticlePool pool;

pool.create()->init(1, 2);
pool.create()->init(1, 2, 0.3);
pool.create()->init(1, 2, 3.3, 4.4);

 

외부 코드에서는 객체 생성이 실패할 때의 처리를 챙겨야 할 수 있다.

풀이 비어있다면 NULL을 반환할 수도 있지만

객체를 초기화하기 전에 NULL검사하는 것이 좋다.

 

Particle* particle = pool.create();
if (particle != NULL) particle->init(1, 2);

 


 

아무튼 재사용 가능한 객체 집합을 관리함으로써

메모리를 절약하면서 구현도 빠르게 할 수 있다.

또한 같은 종류의 객체를 메모리에 함께 모아두면

게임에서 단순히 객체를 순회할 때 CPU 캐시를 이용하는데 도움이 되기도 한다.

 

 

반응형
그리드형