객체 풀 (Object Pool)

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

 

 

동기

파티클 시스템을 통해 파티클을 생성, 제거하는 과정에서 메모리 단편화가 생기면 안된다.

메모리 단편화가 빈번하게 발생하지 않더라도 메모리 단편화를 계속 방치하면 지속적으로 힙에 구멍을 내기 때문에 시간이 누적되면 결국 게임이 뻗어버린다.

 

 

패턴

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

 

새로운 객체가 필요하면 풀에 요청한다. 풀은 사용 가능한 객체를 찾아서 사용 중으로 초기화한 뒤 반환한다. 객체를 더 이상 사용하지 않는다면 사용 안 함 상태로 되돌리고 이런 식으로 메모리나 다른 자원 할당을 신경 쓰지 않고 마음껏 객체를 생성, 삭제할 수 있다.

 

 

언제 쓸 것인가?

보통 오브젝트나 파티클같이 시각적으로 보이는 것에 많이 사용된다.

 

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

◾ 객체들의 크기가 비슷하다.

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

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

 

위와 같은 경우에 사용할 수 있다.

 

 

주의사항

보통 new, delete로 메모리를 관리한다. GC가 지원되는 언어는 GC로 관리한다.

객체 풀을 만들 때, 얼마나 사용될지 예측하여 크기를 조절해야 한다. 너무 작으면 크래시가 나고 너무 크면 메모리 낭비와 다를 바가 없다.

 

한 번에 사용 가능한 객체의 개수가 정해져 있기 때문에 장점일수도 있고 단점일수도 있다.

어찌 되었건 간에 객체 풀의 모든 객체가 사용 중이어서 재사용 할 객체를 반환받지 못해서 오버런이 발생할 때를 대비해 두어야 한다.

 

아예 풀이 부족하지 않도록 최악의 상황을 고려해서 풀을 크게 잡거나 객체 미생성, 기존 객체를 강제로 제거, 풀의 크기를 늘리는 등 해결 방법은 여러 가지가 있으며 각자 장단점이 존재한다.

 

 

대부분의 객체 풀은 배열로 구현한다.

풀에 들어가는 객체가 전부 같은 타입이면 상관없지만 크기가 다양한 서로 다른 타입이면 메모리를 낭비하게 된다.

이런 경우는 객체의 크기별로 풀을 나누는 게 좋다.

 

 

재사용되는 객체는 저절로 초기화되지 않기 때문에 재사용시에는 주의해서 반드시 객체를 완전히 초기화해야 한다.

 

 

예제

class Particle {
public:
    Particle() : frameLeft_(0) {}
    void init(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
    bool inUse() const { return frameLeft_ > 0; }
    
private:
    int frameLeft_;
    double x_, y_;
    double xVel_, yVel_;
};

void Particle::init(double x, double y, double xVel, double yVel, int lifetime) {
    x_ = x;
    y_ = y;
    xVel_ = xVel;
    yVel_ = yVel;
    frameLeft_ = lifetime;
}

파티클은 최초 생성 시 사용 안 함 상태로 초기화되고 나중에 init이 호출되면 파티클이 사용 중 상태로 바뀐다.

 

void Particle::animate() { // 업데이트 메서드 패턴 사용
    if (!inUse()) return;
    
    frameLeft_--;
    x_ += xVel_;
    y_ += yVel_;
}

매 프레임마다 animate를 호출하며 재사용 가능한 파티클을 확인하여 사용한다.

 

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

객체 풀 클래스는 꽤 단순하다.

매 프레임마다 animate를 호출해서 풀에 들어 있는 파티클을 순차적으로 애니메이션한다.

새로운 파티클을 생성하는 것도 어렵지 않다. 사용 중이지 않은 파티클을 찾아서 초기화하여 재사용 준비만 시켜주면 된다.

파티클들은 생명 주기가 끝나면 스스로 비활성화된다.

 

다 좋아보이지만 사용 가능한 객체를 찾기 위해 매 프레임마다 전체 컬렉션을 순회하는 문제가 있다.

 

 

◾ 빈칸 리스트

사용 가능한 파티클을 찾느라 전체 컬렉션을 순회하며 낭비하고 싶지 않으면 사용 가능한 파티클을 계속 추적해야 한다. 사용 가능한 파티클 포인터를 별도의 리스트에 저장하는 것도 한 방법이지만 풀에 들어 있는 객체 개수만큼의 포인터가 들어 있는 리스트를 따로 관리해야 한다.

 

리스트를 따로 관리하는 대신 사용하지 않는 파티클 객체의 데이터 일부를 활용할 수 있다.

 

class Particle {
public:
    Particle* getNext() const { return state_.next; }
    void setNext(Particle* next) {
        state_.next = next;
    }
    
private:
    int frameLeft_;
    
    union {
        struct {
            double x, y;
            double xVel, yVel;
        } live;
        
        Particle* next;
    } state_;
};

별도로 관리되어야 하는 생명주기를 제외한 모든 멤버 변수를 공용체 내부의 구조체 안으로 옮긴다.

파티클이 활성화 된 동안에는 구조체의 데이터를 사용하고 비활성화 되었을 때는 구조체의 데이터가 필요 없기 때문에 포인터로 사용 가능한 다음 파티클을 가리키면 추가 메모리 낭비 없이 리스트를 만들 수 있다.

이를 빈칸 리스트 기법이라고 부른다.

 

빈칸 리스트 기법을 적용하기 위해 코드를 일부 수정해야 한다.

 

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

ParticlePool::ParticlePool() {
    firstAvailable_ = &particles_[0];
    
    for (int i = 0; i < POOL_SIZE - 1; ++i)
        particles_[i].setNext(&particles_[i+1]);
    
    particles_[POOL_SIZE-1].setNext(nullptr);
}

풀을 최초 생성시에는 모든 파티클이 사용 가능하기 때문에 배열 전체를 관통하여 리스트를 만든다.

 

void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) {
    assert(firstAvailable_ != nullptr);
    
    Particle* newParticle = firstAvailable_;
    firstAvailable_ = newParticle->getNext();
    newParticle->init(x, y, xVel, yVel, lifetime);
}

새로운 파티클 생성시에는 첫 번째로 사용 가능한 파티클의 포인터를 바로 얻어오면 된다.

 

bool Particle::animate() {
    if (!inUse()) return false;
    
    frameLeft_--;
    x_ += xVel_;
    y_ += yVel_;
    
    return frameLeft_ == 0;
}

파티클의 생명주기를 알 수 있도록 반환 값을 bool로 바꾼다.

 

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

추가 메모리 낭비 없이 상수 시간에 생성, 삭제가 가능한 아름다운 객체 풀이 완성됐다.

 

 

디자인 결정

풀과 객체의 커플링 여부와 재사용 될 객체를 초기화 할때 주의할 점이 있다.

 

객체가 풀과 커플링된다면 더 간단하게 구현할 수 있고 객체가 풀을 통해서만 생성될 수 있도록 강제할 수 있다.

커플링되지 않는다면 어떤 객체라도 풀에 넣을 수 있지만 사용 여부를 객체의 외부에서 관리해야 한다.

 

재사용될 객체를 풀 안에서 초기화한다면 풀은 객체를 완전히 캡슐화 시킬 수 있고 풀 클래스는 객체가 초기화 되는 방법과 결합된다.

객체를 풀 밖에서 초기화한다면 풀의 인터페이스는 단순해진다. 하지만 객체 생성이 실패할 때의 예외 처리를 구현해 두어야 할 수 있다.

 

 

객체 풀 패턴은 경량 패턴과 비슷한 점이 많다. 둘 다 재사용 가능한 객체 집합을 관리하지만 재사용의 의미에 차이가 있다.

+ Recent posts