객체를 매번 할당, 해제하지 않고 고정 크기 풀에 들어 있는 객체를 재사용함으로써 메모리 사용 성능을 개선한다.

 

19.2 동기

새로운 객체를 생성하고 제거하는 과정에서 발생하는 메모리 단편화 문제를 해결하기 위함이다.

 

19.3 패턴

재사용 가능한 객체들을 모아놓은 객체 풀 클래스를 정의한다. 여기에 들어가는 객체는 현재 자신이 '사용 중'인지 여부를 알 수 있는 방법을 제공해야 한다. 풀은 초기화될 때 사용할 객체들을 미리 생성하고, 이들 객체를 '사용 안함' 상태로 초기화 한다.

새로운 객체가 필요하면 풀에 요청한다. 풀은 사용 가능한 객체를 찾아 '사용 중'으로 초기화한 뒤 반환한다.

 

19.4 언제 쓸 것인가?

객체 풀 패턴은 게임 개체나 시각적 효과같이 눈으로 볼 수 있는 것에 많이 사용된다.

다음과 같을때 사용하면 좋다.

ㄴ 객체를 빈번하게 생성/삭제해야 한다.

ㄴ 객체들의 크기가 비슷해야 한다.

ㄴ 객체를 힙에 생성하기가 느리거나 메모리 단편화가 우려된다.

ㄴ 데이터베이스 연결이나 네트워크 연결같이 접근 비용이 비싸면서 재사용 가능한 자원을 객체가 캡슐화하고 있다.

 

19.5 주의사항

객체 풀에서 사용되지 않는 객체는 메모리 낭비와 다를 바 없다.

객체 풀은 필요에 따라 크기를 조절해야 한다. 크기가 너무 작으면 새로운 객체를 추가할 수 없는 문제가 생기고 

너무 크면 메모리 낭비가 된다.

 

한 번에 사용 가능한 객체 수가 정해져 있다.

객체 풀 패턴을 사용하여 메모리를 객체 종류별로 별개의 풀로 나눠놓으면 한번에 이펙트가 많이 터진다고 해도 파티클 시스템이 메모리를 전부 먹는다거나 메모리가 부족해 새로운 객체를 생성하지 못하는 막을 수 있다는 장점이 있다.

 

객체 풀의 모든 객체가 사용 중이어서 재사용할 객체를 반환받지 못할 경우 해결책

ㄴ 최악의 상황에 맞춰서 객체 풀의 크기를 크게 유지하는 방법 - 메모리 낭비의 문제가 있음

ㄴ 그냥 객체를 생성하지 않는다.

ㄴ 기존 객체를 강제로 제거한다.

ㄴ 풀의 크기를 늘린다.

 

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

풀에 들어가는 객체가 전부 같은 자료형이라면 상관이 없지만 다른 자료형인 객체나 하위 클래스의 인스턴스를 같은 풀에 넣고 싶다면 풀의 배열 한 칸 크기를 가장 큰 자료형에 맞춰야 한다.

ㄴ 객체 크기별로 풀을 나누는 게 좋다.

 

재사용되는 객체는 저절로 초기화되지 않는다.

객체 풀은 메모리 관리자를 통하지 않고 객체를 재사용하기 때문에 초기화되지 않은 변수나 이미 해제된 메모리를 사용하는 골치 아픈 버그를 주의해야 한다.

새로 할당된 메모리를 초기화하거나 삭제된 메모리를 구별할 수 있도록 특수한 값을 덮어써주어 관리해주는 방법을 사용할 수 있다.

 

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

가비지 컬렉션을 지원하는 시스템에서는 GC가 메모리 단편화를 알아서 처리하기 때문에 객체 풀을 덜 쓰는 편이다. 그래도 모바일같이 CPU가 느리고 단순한 GC만 지원하는 곳에서는 객체 풀로 메모리 할당, 해제 비용을 줄이는게 의미가 있다.

 

GC와 객체 풀을 같이 사용한다면 충돌에 주의해야 한다. GC는 다른 곳을 참조하는 객체는 회수하지 않기 때문에

풀에 있는 객체를 더 이상 사용하지 않을 때 객체에서 다른 객체를 참조하는 부분을 전부 정리해야 한다.

 

19.6 예제 코드

class Particle {
public:
    Particle() : framesLeft_(0) {} // 남은 프레임수 0 (파티클 사용 안함 상태로 초기화한다는 뜻)
    void init(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
    bool inUse() const { return framesLeft_ > 0; }
   
private:
    int framesLeft_;
    double x_, y_;
    double xVel_, yVel_;
};

// init이 호출되면 파티클이 사용 중 상태로 바뀐다.
void Particle::init(double x, double y, double xVel, double yVel, int lifetime) { 
    x_ = x;
    y_ = y;
    xVel_ = xVel;
    yVel_ = yVel;
    framesLeft_ = lifetime;
}

void Particle::animate() {
    if (!inUse()) return; // 남은 
    
    framesLeft_--;
    x_ += xVel_;
    y_ += yVel_;
}

class ParticlePool {
public:
    void create(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
private:
    static const int POOL_SIZE = 100;
    Particle particles_[POOL_SIZE];
}

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

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

위의 예제에서는 ParticlePool에 Particle 객체 배열을 미리 만들어 놓고 (비활성화 상태) ParticlePool의 create 함수에서

배열을 순회하면서 비활성화 상태인 객체를 임의로 하나 찾아 활성화 상태로 바꾼다.(init 함수 호출, 새로운 위치 좌표 변화 값들과 생명주기를 인수로 주고)

파티클 객체 마다 자신의 남은 프레임 수 (생명 주기)를 알고 있기 때문에 update함수( = animate함수) 에서 매 프레임마다 이 값을 감소시켜 시간이 지나면 스스로 비활성화 될 수 있도록 한다.

 

빈칸 리스트 기법을 활용하기

위의 예제에서는 비활성화된 객체를 찾기 위해 배열을 순회했는데, 사용 가능한 파티클 객체 포인트를 별도의 리스트에 저장하는 것도 방법이다. 하지만 이 방법에서는 풀에 들어 있는 객체만큼의 포인터가 들어 있는 리스트를 따로 관리해야 한다. 처음 풀을 생성하면 모든 파티클이 사용 안 함 상태이기 때문에 별도의 리스트에서도 풀에 들어 있는 모든 객체를 포인터로 가리켜야 한다. (추가 메모리가 필요하다)

class Particle{
    // 원래 있던 코드들 생략
public:
    Particle* getNext() const { return state_.next; }
    void setNext(Particle* next) {
        state_.next = next;
    }
    
private:
    int framesLeft_;
    
    union {
        struct {
            double x, y;
            double xVel, yVel;
        }live; // 파티클이 살아 있는 동안에는 live에 파티클의 상태를 둔다.
        
        Particle* next; // 파티클이 죽어 있으면 next에 사용 가능한 파티클 객체를 포인터로 가리킨다.
    } state_;
}

class ParticlePool {
    // 원래 있던 코드들 생략
private:
    Particle* firstAvailable_; // 빈칸 리스트의 head
};

Particle 구조체에서 framesLeft_를 제외한 멤버 변수들을 state_ 공용체의 live 구조체 안으로 옮겼다.

공용체를 사용했기 때문에 추가 메모리 없이 자기 자신을 사용 가능한 파티클 메모리에 등록하게 할 수 있다.

ParticlePool::ParticlePool() {
    // 처음에는 모든 파티클이 비활성화 상태이므로 헤더에는 처음 파티클을 저장한다.
    firstAvailable_ = &aprticles_[0];
    
    // 모든 파티클은 다음 파티클을 가리킨다.
    for (int i = 0; i < POOL_SIZE - 1; i++) {
        particle_[i].setNext(&particles_[i + 1]);
    }
    
    // 마지막 파티클에서 리스트를 종료한다.
    particles_[POOL_SIZE - 1].setNext(NULL);
}

void ParticlePool::create(double x, double y, double xVel, double yVel, int liftime) {
    assert(firstAvailable_ != NULL);
    
    // 얻은 파티클을 빈칸 목록에서 제거한다.
    Particle* newParticle = firstAvailable_; 
    firstAvailable_ = newParticle->getNext(); // 사용 가능한 파티클 객체의 포인터를 옮긴다.
    newParticle->init(x, y, xVel, yVel, lifetime); // 비어 있는 파티클을 init 시킨다.
}

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()) {
            // 방금 죽은 파티클을 빈칸 리스트 앞에 추가한다. (새로 활성화되는 파티클이 먼저 재사용된다.)
            particles_[i].setNext(firstAvailable_);
            firstAvailable_ = &particles_[i];
        }
    }
}

빈칸 리스트 기법을 위해서 ParticlePool에서는 빈칸 리스트의 head를 관리해야 하며 파티클이 생성, 삭제될 때 head가 가리키는 포인터도 바꿔줘야 한다.

 

19.7 디자인 결정

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

객체가 풀과 커플링된다면 (예제의 Particle과 ParticlePool의 방식)

ㄴ 간단하게 구현할 수 있다. 풀에 들어가는 객체에 '사용 중' 플래그나 이런 역할을 하는 함수를 추가하면 된다.

ㄴ 객체가 풀을 통해서만 생성할 수 있도록 강제할 수 있다. 풀 클래스를 객체 클래스의 friend로 만든 뒤 객체 생성자를 private에 두면 된다.

 

객체가 풀과 커플링되지 않는다면

ㄴ 어떤 객체라도 풀에 넣을 수 있다. 객체와 풀을 디커플링함으로써, 재사용 가능한 풀 클래스를 구현할 수 있다.

ㄴ 가장 간단한 방법은 비트 필드를 따로 두는 것이다.

template <class TObject> // 일반적이면서 재사용 가능한 풀 클래스 템플릿
class GenericPool {
private:
    static const int POOL_SIZE = 100;
    
    TObject pool_[POOL_SIZE];
    bool inUse_[POOL_SIZE]; // 각 객체의 활성화 상태를 알기 위해 bool 배열을 pool에서 관리한다.
};

재사용되는 객체를 초기화할 때 어떤 점을 주의해야 하는가?

객체를 풀 안에서 초기화한다면 (예제의 방법)

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

ㄴ 풀 클래스는 객체가 초기화 하는 방법과 결합된다. (객체 초기화 메서드가 여러 개라면 객체 풀에도 포워딩 메서드가 같은 수만큼 있어야 한다.)

 

객체를 밖에서 초기화한다면

ㄴ 풀의 인터페이스는 단순해진다. (객체 초기화 메서드가 하나만 있어도 된다.)

ㄴ 이 방법의 경우 풀은 새로운 객체에 대한 레퍼런스만 반환하는데, 사용 가능한 객체가 없어서 NULL값을 반환한다면 외부 코드에서 이 객체에 대한 NULL 검사가 필요할 수 있다.

 

+ Recent posts