더티 플래그 (Dirty Flag)

불필요한 작업을 피하기 위해 실제로 필요할 때까지 그 일을 미룬다.

쉽게 말해서 지연 평가(Lazy Evaluation)의 일종이다.

 

 

동기

월드에 존재하는 오브젝트들을 렌더링 하려면 장면 그래프라는 거대한 자료구조에 저장해야 한다. 렌더링 코드는 이 장면 그래프를 이용해서 어떤 것들을 렌더링 해야하는지를 결정하기 때문이다.

매우 간단하게 만든다면 객체 리스트 하나만 존재하면 되지만 장면 그래프는 거의 계층형으로 이루어져 있기 때문에 적합한 자료구조가 아니다.

 

계층형이라 함은 쉽게 말해서 트랜스폼의 부모 자식 관계이다. 부모의 트랜스폼이 변하면 자식의 트랜스폼도 바뀐다.

그리고 렌더링을 하려면 로컬 트랜스폼이 아닌 월드 트랜스폼을 알아야만 렌더링 할 수 있다.

로컬 트랜스폼을 월드 트랜스폼으로 변환하는 것은 어렵지 않다. 문제는 이 연산을 모든 객체에 대해서 매 프레임 수행하면 성능 하락은 피할 수 없다.

특히 지형같은 경우는 전혀 움직이지 않는데 이것까지 매번 월드 트랜스폼으로 변환하는 연산을 수행하는 것은 자원의 낭비다.

 

 

객체가 움직이지 않는 경우를 대비하기 위해 월드 트랜스폼 값을 캐싱하면 최적화가 좀 더 이루어진다.

 

부모 트랜스폼이 변화하면 자식 트랜스폼도 같이 바뀐다

하지만 최상위 부모의 트랜스폼이 변화하면 계층을 따라 내려오면서 재귀적으로 연산이 이루어지는데, 그림으로 보이다시피 중복된 연산이 많이 발생한다. 객체는 고작 4개가 움직였을 뿐이지만 중복된 연산으로 인해 10회의 연산량이 발생한다.

캐싱으로는 이 문제를 해결할 수 없다.

 

대신 로컬 트랜스폼과 월드 트랜스폼 연산을 분리시키고 연산을 뒤로 미룸으로써 연산량을 줄이는 방법을 선택한다.

이를 위해서 장면 그래프에 들어가는 객체에 플래그를 추가한다.

로컬 트랜스폼의 값이 바뀌면 플래그를 켜고 객체의 월드 트랜스폼 값이 필요할 때에는 플래그를 검사한다. 플래그가 켜져있으면 월드 트랜스폼을 계산한 뒤에 플래그를 끈다.

 

이제 와서 얘기하는 것이지만 패턴 이름이 더티 플래그인 이유는 "더 이상 맞지 않음" 을 나타내는 플래그를 더티라고 부르기 때문에 더티 플래그이다.

 

월드 트랜스폼으로 변환하는 연산을 렌더링 할 때로 미뤄서 연산량을 4회로 줄였다.

움직이지 않는 객체는 계산을 하지 않고 렌더링 전에 제거될 객체는 월드 트랜스폼 변환 연산을 하지 않아도 된다.

 

 

패턴

계속해서 변경되는 기본 값이 있다. 파생 값은 기본 값에 비싼 작업을 거쳐야 얻을 수 있다. 더티 플래그는 파생 값이 참조하는 기본 값의 변경 여부를 추적한다. 즉, 더티 플래그는 기본 값이 변경되면 켜진다. 파생 값을 써야 할 떄 더티 플래그가 켜져 있다면 다시 계산한 뒤에 플래그를 끈다. 플래그가 꺼져 있다면 이전에 캐시해놓은 파생 값을 그대로 사용한다.

 

 

언제 쓸 것인가?

마찬가지로 코드가 복잡해지는 것을 감수할 정도로 성능 문제가 심할 때에만 적용해야 한다.

계산과 동기화라는 두 종류의 작업에 사용되고 두 작업 모두 기본 값으로부터 파생 값을 얻는 게 오래 걸리거나 비용이 크다는 문제가 있다.

 

파생 값이 사용되는 횟수보다 기본 값이 더 자주 변경되어야 하고 점진적으로 업데이트 하기가 어려운 경우에 적용하기 좋고 그게 아니라면 별로 도움이 되지 않는다.

 

 

주의사항

계산을 오래 지연시키려면 비용이 든다. GC를 정리하는것도 일종의 지연인데 GC 정리를 너무 빨리하는것도 성능의 하락이 생기지만 너무 늦게 정리하는것도 비용이 크게 발생된다.

그리고 지연 도중에 뭔가 잘못되었을 경우 작업이 전부 날아갈 수도 있다는 문제도 있다.

 

상태가 변할 때마다 플래그를 일일이 켜줘야 하는 번거로움도 있다. 한군데라도 놓치면 무효화된 파생 값을 사용해서 잡기 어려운 버그가 발생할 수도 있다.

 

또한 더티 플래그가 꺼져 있으면 캐싱된 값을 그대로 사용해야 하기 때문에 이전 파생값을 캐싱해둬야 한다.

 

다른 최적화 방법들과 마찬가지로 더티 플래그 패턴 역시 속도를 위해 메모리를 희생한다.

 

 

예제

class Transform {
public:
    static Transform origin();
    Transform combine(Transform& other);
};

combine은 상위 노드를 따라서 로컬 트랜스폼을 전부 결합하여 월드 트랜스폼으로 변환한 값을 반환한다.

origin은 아무 변화가 없는 단위행렬로 나타낸 기본 트랜스폼을 반환한다. 

 

class GraphNode {
public:
    GraphNode(Mesh* mesh) : mesh_(mesh), local(Transform::origin()) {}
    
private:
    Transform local_;
    Mesh* mesh_;
    GraphNode* children_[MAX_CHILDREN];
    int numChildren_;
};

더티 플래그 패턴을 적용하기 전의 클래스 구조이다.

 

GraphNode* graph_ = new GraphNode(nullptr); // 하위 노드를 루트에 추가

void renderMesh(Mesh* mesh, Transform transform); // 메시 렌더링

 

void GraphNode::render(Transform parentWorld) {
    Transform world = local_.combine(parentWorld);
    if (mesh_) renderMesh(mesh_, world);
    
    for (int i = 0; i < numChildren_; ++i)
        children_[i]->render(world);
}

/* == */

graph_->render(Tranform::origin()); // 루트 노드부터 순회하며 렌더링

더티 플래그를 사용하지 않고 단순히 장면 그래프를 루트 노드부터 순회하며 렌더링한다.

 

 

class GraphNode {
public:
    GraphNode(Mesh* mesh) : mesh_(mesh), local(Transform::origin()), dirty_(true) {}
    void setTransform(Transform local) {
        local_ = local;
        dirty_ = true;
    }
    // ...
    
private:
    Transform world_;
    bool dirty_;
    // ...
};

로컬 트랜스폼이 변하면 더티 플래그를 켜준다.

 

void GraphNode::render(Transform parentWorld, bool dirty) {
    dirty |= dirty_; // 상위 노드의 플래그 중 하나라도 켜져있으면 켜진다
    
    if (dirty) [
        world_ = local_.combine(parentWorld);
        dirty_ = false;
    }
    
    if (mesh_) renderMesh(mesh_, world_);
    
    for (int i = 0; i < numChildren_; i++)
        children_[i]->render(world_, dirty);
}

재귀구조 대신 더티 플래그를 매개변수로 사용해서 연산 비용을 줄인다.

더티 플래그가 켜진 경우에만 월드 트랜스폼을 계산하고 더티 플래그를 꺼준다.

 

 

디자인 결정

더티 플래그를 끄는 시점도 상황에 따라서 결정할 수 있다.

 

◾ 결과가 필요할 때 : 결과 값이 필요 없다면 아예 계산하지 않을 수 있지만 계산 시간이 오래 걸린다면 프리징이 생길 수 있다.

 

◾ 미리 정해놓은 지점에서 할 때 : 지연 작업 처리가 플레이 경험에 영향을 미치지 않지만, 처리 시점을 제어할 수 없다.

 

◾ 백그라운드로 처리할 때 : 타이머 핸들로 정해진 시간마다 변경사항을 처리한다. 작업의 처리 빈도를 조절할 수 있지만 필요 없는 작업을 더 많이 할 수 있고 비동기 작업을 지원해야 한다.

데이터 지역성 (Data Locality)

CPU 캐시를 최대한 활용할 수 있도록 데이터를 배치해 메모리 접근 속도를 높인다.

 

 

동기

하드웨어 발전을 따라서 데이터의 연산 속도는 빨라졌지만 데이터를 가져오는 속도는 크게 달라지지 않았다.

CPU는 램에서 데이터를 1바이트 가져오는 데에만 몇 백 CPU 사이클 정도를 소모한다. 매 번 이렇게 사이클을 소모하면 사실상 거의 대부분의 시간을 데이터를 기다리는 대기시간일텐데, 실제로는 그렇게 동작하지 않는다.

그 이유는 참조 지역성에 기반을 둔다.

 

데이터를 1바이트라도 가져오게 된다면 인접한 연속된 메모리를 선택하여 캐시에 복사한다.

추후 필요한 데이터가 캐시 라인 안에 들어있다면 CPU는 대기하지 않고 데이터를 바로 가져온다.

 

필요한 데이터가 없어서 캐시 미스가 발생하면 그때는 CPU가 멈춰서 데이터를 기다리며 사이클을 낭비한다.

그래서 자료구조를 최대한 잘 만들어서 캐시 미스가 발생하는 것을 최대한 피해야 한다.

 

 

패턴

CPU는 메모리 접근 속도를 높이기 위한 캐시를 여러 개 둔다. 캐시를 사용하면 최근에 접근한 메모리 근처에 있는 메모리를 훨씬 빠르게 접근할 수 있다. 데이터 지역성을 높일수록, 즉 데이터를 처리하는 순서대로 연속된 메모리에 둘수록 캐시를 통해서 성능을 향상할 수 있다.

 

 

언제 쓸 것인가?

다른 최적화 기법과 마찬가지로, 성능 문제가 있을 때 써야 한다. 필요없는 곳에 최적화를 해봤자 코드만 복잡해지고 유연성이 떨어질 뿐이다.

특히나 데이터 지역성 패턴은 성능 문제가 캐시 미스 때문인지를 확인해봐야 한다. 다른 이유 때문에 도입하는 것이라면 전혀 도움이 안 된다.

 

처음부터 캐시 최적화를 하겠다고 많은 시간을 낭비할 필요는 없지만 자료구조를 캐시하기 좋게 만들려고 노력할 필요는 있다.

 

 

주의사항

C++에서는 인터페이스를 사용하려면 포인터나 레퍼런스를 통해 객체에 접근해야 한다. 그런데 포인터를 쓰게 되면 캐시 미스가 발생하게 된다.

그래서 데이터 지역성 패턴을 적용하려면 추상화를 일부 희생해야한다.

 

 

예제

..
private:
    AIComponent* ai_;
    PhysicsComponent* physics_;
    RenderComponent* render_;
    
/* --- */

while (!gameOver) {
    for (int i = 0; i < numEntities; ++i)
        entities[i]->ai()->update();
    
    for (int i = 0; i < numEntities; ++i)
        entities[i]->physics()->update();
        
    for (int i = 0; i < numEntities; ++i)
        entities[i]->render()->update();
}

컴포넌트 패턴을 통해 나누어진 게임 개체들을 게임 루프 안에서 호출한다면 위와 같은 코드가 될 것이다.

월드에 있는 모든 개체를 거대한 포인터 배열 하나로 관리하는 것이다.

 

동작 자체는 별 문제가 없어보이지만 캐시 적중의 측면에서 접근하면 난장판 이라는 것을 알 수 있다.

게임 개체가 배열에 포인터로 저장되어 있기 때문에 배열 값에 접근할 때마다 포인터를 따라가며 캐시 미스가 계속 발생하게 된다.

 

..
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES];
    
/* --- */

while (!gameOver) {
    for (int i = 0; i < numEntities; ++i)
        aiComponents[i].update();
    
    for (int i = 0; i < numEntities; ++i)
        physicsComponents[i].update();
        
    for (int i = 0; i < numEntities; ++i)
        renderComponents[i].update();
}

각 개체 안에 포인터로 들고있는 대신 외부의 동적 배열로 끌어내려서 포인터가 아닌 객체에 바로 접근하여 캐시 적중률을 높인다.

 

작업 전과 후의 속도 차이는 실행 환경마다 다르겠지만 책 기준으로는 50배가 차이 난다.

캡슐화도 많이 깨먹지 않아서 나쁘지 않다. 게임 루프에서 컴포넌트를 직접 업데이트 하기는 하지만 이전 코드도 어차피 함수 호출로 컴포넌트에 직접 접근하고 있었다. 단순히 컴포넌트가 사용되는 방식만 바뀌었을 뿐이다.

 

본래 게임 개체(GameEntity) 안에 있던 컴포넌트를 게임 루프로 끌어내렸기 때문에 게임 개체 클래스는 딱히 들고 있을만한 컴포넌트는 없지만 그렇다고 게임 개체 클래스를 없앨 필요는 없다.

대신 자기가 본래 소유했어야 할 컴포넌트에 대한 포인터를 가지도록 만들면 된다.

 

 

이번에는 파티클 시스템을 예로 들어보자.

 

class Particle {
public:
    void update() { /* ... */ }
};


class ParticleSystem {
public:
    ParticleSystem() : numParticles_(0) {}
    void update() {
        for (int i = 0; i < numParticles_; ++i)
            particles_[i].update();
    }
    
private:
    static const int MAX_PARTICLES = 100000;
    int numParticles_;
    Particle particles_[MAX_PARTICLES];
};

파티클을 객체 풀에 관리하는 파티클 시스템 클래스이다.

항상 객체 풀에 들어있는 모든 파티클을 사용하는 것은 아니기 때문에 루프를 전부 순회하며 처리할 필요는 없다.

대신 플래그 변수를 둬서 활성화된 파티클만 골라서 처리시키면 간단하게 해결 될 것이다.

 

하지만 이것 역시 캐시 적중에 관한 측면에서 보면 같이 읽어들인 인접한 파티클 데이터들이 활성화된 상태가 아니라면 무용지물이 된다.

게다가 활성화된 파티클이 적을수록 메모리를 더 자주 건너뛰게 되어서 이 방법 역시 캐시 미스가 더 자주 발생한다.

 

이번에는 활성화된 파티클들만 앞으로 모아서 처리하면 캐시 적중률이 높아진다.

정렬이라는 단어를 사용할 수 있겠지만 사실 매 프레임마다 배열의 모든 요소들을 정렬할 필요는 없고 활성 파티클만 앞으로 모아두면 된다.

 

// 비활성 -> 활성
void ParticleSystem::activeParticle(int index) {
    assert(index >= numActive_);
    
    swap(particles_[numActive_], particles_[index]);
    numActive_++;
}

// 활성 -> 비활성
void ParticleSystem::deactiveParticle(int index) {
    assert(index < numActive_);
    
    numActive_--;
    swap(particles_[numActive_], particles_[index]);
}

성능적으로는 만족스러울지 모르지만 객체지향성을 어느 정도 포기해야 한다.

덧붙여서 Particle과 ParticleSystem 클래스가 서로 강하게 커플링 되어있지만 어차피 하나의 개념이 두개의 클래스로 나누어진 것 뿐이기 때문에 크게 상관이 없다.

 

 

마지막으로 호출 횟수에 따라 구분해보자.

 

예를 들어 AI 컴포넌트는 현재 재생중인 애니메이션, 이동 중인 목표 지점, 스탯 등이 들어있고 매 프레임마다 값을 확인하고 변경한다.

그런데 죽을 때 어떤 아이템을 떨어트릴지에 대한 데이터는 죽는 순간 딱 한번만 사용되기 때문에 굳이 위와 같은 컴포넌트에서 멤버로 같이 관리하여 컴포넌트 크기를 늘릴 필요는 없다.

 

class AIComponent {
public:
    // methods
    
private:
    Animation* animation_;
    double energy_;
    Vector goalPos_;
    LootDrop* loot_; // 사용 빈도가 적은 데이터들
};

class LootDrop {
private:
    friend class AIComponent;
    LootType drop_;
    int minDrops_;
    int maxDrops_;
    double chanceOfDrop_;
};

이런 데이터들을 따로 분리시켜서 포인터로 들고있게 하면 컴포넌트의 크기를 줄일 수 있기 때문에 캐시 라인에 컴포넌트를 더 올려서 캐시 적중률을 높일 수 있다.

 

 

디자인 결정

결론만 먼저 내리자면 이 패턴은 데이터를 메모리에 어떻게 배치하느냐에 따라 성능이 크게 영향을 받는다는 사고 방식을 갖게 하는 것이 목적이다. 구현 방법에 왕도는 없고 다양하게 구현할 수 있다.

 

그런데 데이터 지역성을 적용한다면 다형성은 어떻게 처리할 것인가에 생각해 볼 필요가 있다.

가장 쉬운 방법으로는 상속을 받지 않는것이다. 가장 안전하고 쉽고 빠르지만 상속을 받지 않기때문에 코드가 유연하지 않게 된다.

두 번째 방법으로는 타입별로 배열을 준비해서 각기 다른 배열에 넣고 관리하는 것이다. 대신 이 경우는 커플링이 생기고 확장성이 좋지 않아진다.

마지막으로 데이터 지역성을 적용하지 않고 하나의 컬렉션에 포인터를 모아놓는 방법이 있다. 캐시를 신경쓰지 않는다면 보통 이게 자연스러운 방식이다.

서비스 중개자 (Service Locator)

서비스를 구현한 구체 클래스는 숨긴 채로 어디에서나 서비스에 접근할 수 있게 한다.

 

 

동기

메모리 할당이나 로그, 난수 생성 등을 사용하지 않는 게임코드는 찾아보기 어려울 정도로 정말 많이 사용된다.

이런 시스템은 게임 전체에서 사용 가능해야 하는 일종의 서비스라고 볼 수 있다.

 

바로 직전에 만들어봤던 오디오 시스템만 해도 여러 게임 시스템과 연결되어 있었다.

 

AudioSystem::playSound(VERY_LOUD_BANG); // 정적 클래스
AudioSystem::instance()->playSound(VERY_LOUD_BANG); // 싱글턴

둘 중 무엇이 되었던 간에 오디오 시스템을 사용하는 클래스는 강한 커플링도 함께 생긴다.

물론 사운드를 출력하려면 어느 정도의 커플링을 피할 수는 없지만 오디오를 구현한 구체 클래스를 바로 접근할 수 있게 하는 것은 별로 옳지 않다.

 

전화번호부에 비유를 해보자. 누군가의 연락처를 알고 싶다면 전화번호부에서 이름으로 찾으면 된다. 만약 이사를 가게된다면 전화국을 통해 전화번호부를 업데이트하면 다른 사람들이 새로운 주소를 찾아볼 수 있다.

진짜 주소가 아닌 대리 주소를 적어도 상관이 없다. 그저 정보를 찾는 쪽(호출하는 쪽)에서 전화번호부를 통해 나를 찾게 함으로써 찾을 방법은 한곳에서 편하게 관리할 수 있다.

 

이것이 서비스 중개자 패턴의 핵심이다. 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다.

 

 

패턴

서비스는 여러 기능을 추상 인터페이스로 정의한다. 구체 서비스 제공자는 이런 서비스 인터페이스를 상속받아 구현한다. 이와 별도인 서비스 중개자는 서비스 제공자의 실제 타입과 이를 등록하는 과정은 숨긴채 적절한 서비스 제공자를 찾아 서비스에 대한 접근을 제공한다.

 

 

언제 쓸 것인가?

전역적으로 접근하는것은 싱글턴에서도 언급했듯이 문제가 생기기 쉽기 때문에 절제해서 사용하는 게 좋다.

만약 접근해야 할 객체가 있다면 전역 메커니즘을 먼저 떠올리는 대신 필요한 객체를 인수로 넘겨줄 수 없는지부터 생각해 볼 필요가 있다.

다만 직접 객체를 넘기는 방식이 불필요하거나 오히려 코드를 읽기 어렵게 만드는 경우도 있다.

예를 들어 로그나 메모리 관리 같은 시스템이 모듈의 공개 API에 포함되어 있으면 안된다.

렌더링 함수의 매개변수에는 렌더링에 관련된 것만 있어야지 로그 같은 게 섞여 있을 필요가 없을 뿐더러 있어서도 안된다.

 

게임 내에서 오디오나 디스플레이 시스템은 단 하나만이 존재하는데 이걸 굳이 인수로 넘겨주는것은 쓸데없이 복잡성만 늘리는 셈이 된다.

 

서비스 중개자 패턴은 사실상 싱글턴 패턴이지만 좀 더 유연하고 더 설정하기 좋은 싱글턴 패턴이다.

물론 근간은 싱글턴이기 때문에 잘못 사용하면 싱글턴의 단점이 그대로 나타난다.

 

 

주의사항

두 코드가 커플링되는 의존성을 런타임 시점까지 미루는 부분이 가장 어렵다.

유연성을 얻는 대신 코드만 봐서는 어떤 의존성을 사용하는지 알기 어렵다는 비용이 발생한다.

 

싱글턴이나 정적 클래스와 달리 서비스 객체를 등록해야만 사용할 수 있기 때문에 필요한 객체가 없을 때를 대비해야 한다.

 

서비스 중개자는 전역에서 접근 가능하기 때문에 각 서비스들은 어느 환경에서나 문제 없이 동작해야만 한다.

특정한 곳에서만 사용하고 특정할 때에는 사용하면 안 된다면 정확히 정해진 곳에서만 실행되는 걸 보장할 수 없기 때문에 서비스로는 적합하지 않다.

 

만약 어떤 클래스가 특정 상황에서만 실행되어야 한다면 서비스 중개자 패턴은 적용하지 않는 것이 안전하다.

 

 

예제

class Audio {
public:
    virtual ~Audio() {}
    virtual void playSound(int soundID) = 0;
    virtual void stopSound(int soundID) = 0;
    virtual void stopAllSounds() = 0;
};

이벤트 큐에서 다뤘던 오디오 시스템을 서비스 중개자를 통해서 다른 코드에 오디오 시스템을 제공하도록 변경한다.

우선 인터페이스를 정의한다.

 

class ConsoleAudio : public Audio {
public:
    virtual void playSound(int soundID) override { ... }
    virtual void stopSound(int soundID) override { ... }
    virtual void stopAllSounds() override { ... }
};

인터페이스 만으로는 아무것도 할 수 없기 때문에 구체 클래스를 구현한다.

 

 

우선 단순한 중개자부터 시작하자.

 

class Locator {
public:
    static Audio* getAudio() { return service_; }
    static void provide(Audio* service) { service_ = service; }
    
private:
    static Audio* service_;
};

정적 함수인 getAudio가 중개 역할을 하게 된다.

 

Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);

Audio 클래스가 제공하는 인터페이스를 통해 서비스 인스턴스를 반환받는다.

 

ConsoleAudio* audio = new ConsoleAudio();
Locator::provice(audio);

중개자가 서비스를 등록하는 방법이다. 매우 단순하다. getAudio를 호출하기 전에 먼저 서비스 제공자를 외부 코드에서 등록해주면 된다. 

 

playSound를 호출하는 쪽에서는 추상 클래스의 인터페이스만 알 뿐 구체 클래스에 대해서는 전혀 모른다는 것이 핵심이다.

중개자인 Locator 클래스 역시 서비스 제공자의 구체 클래스와는 커플링되지 않는다.

어떤 구체 클래스가 사용될지는 서비스를 제공하는 초기화 코드에서만 알아낼 수 있다.

 

재미있는점은 상위 추상 클래스인 Audio 자신은 서비스 중개자를 통해 여기저기에 접근된다는 사실을 모른다.

Audio 인터페이스만 놓고 보면 일반적인 상위 추상 클래스이므로 꼭 서비스 중개자 패턴용으로 만들지 않은 기존의 클래스에도 서비스 중개자 패턴을 적용할 수 있다.

 

 

구현과 적용 자체는 어렵지 않고 단순하지만 서비스 제공자가 서비스를 등록하기 전에 서비스를 사용하려고 접근하면 nullptr을 반환하기 때문에 호출하는 쪽에서 nullptr 검사를 하지 않으면 크래시되는 문제가 있다.

 

호출하는 쪽에서 nullptr 검사를 일일이 수행하는 대신 아예 null객체를 반환하도록 하면 훨씬 깔끔해진다.

서비스 제공자를 찾지 못하거나 만들지 못해서 nullptr을 반환해야 할 때, 대신 같은 인터페이스를 구현한 널 객체를 생성해서 반환해준다.

 

class NullAudio : public Audio {
public:
    virtual void playSound(int soundID) override { /* 아무것도 하지 않는다 */ }
    virtual void stopSound(int soundID) override { /* 아무것도 하지 않는다 */ }
    virtual void stopAllSounds() override { /* 아무것도 하지 않는다 */ }
};

널 객체 자체는 아무런 기능이 구현되어 있지 않지만 받는 쪽에서는 실제 객체를 받은 것처럼 안전하게 작업을 진행할 수 있다.

 

class Locator {
public:
    static void initialize() {
        service_ = &nullService_;
    }
    static Audio& getAudio() { return *service_; }
    static void provide(Audio* service) {
        if (service == nullptr)
            service_ = &nullService_;
        else
            service_ = service;
    }

private:
    static Audio* service_;
    static NullAudio nullService_;
};

이제 Locator는 언제나 유효한 객체를 반환한다는 점이 보장되기 때문에 호출하는 쪽에서는 서비스가 준비되었는지를 신경쓰거나 nullptr 검사를 할 필요가 전혀 없이 그냥 호출만 하면 된다.

 

덧붙여서 널 객체는 의도적으로 특정 서비스를 찾지 못하게 하고 싶을때도 사용할 수 있다.

어떤 시스템을 잠시 못쓰게 하고 싶다면 그냥 서비스를 등록하지 않으면 된다. 그러면 알아서 널 객체를 반환해준다.

 

 

게임을 개발하다 보면, 어떤 일이 벌어지는지 알기 위해서 원하는 이벤트에 간단하게 로그를 설치해야 할 때가 있다.

이럴 때는 보통 로그를 생성하는 함수를 이곳저곳에 집어넣게 되는데, 시간이 지나면 로그가 너무 많아지는 문제가 생긴다.

AI 프로그래머는 사운드 출력에 관심이 없고, 사운드 개발자는 AI의 상태 변화에 관심이 없는 등 필요 없는 로그가 있음에도 일괄적으로 기록되기 때문에 원하는 로그를 찾기 위해서 다른 사람들이 기록한 로그까지 같이 뒤져봐야 한다.

 

조건적으로 로그를 남기고 싶은 시스템이 서비스로 노출되어 있다면, 데코레이터 패턴을 적용시켜서 원하는 로그만 온오프하거나 최종 빌드시에 로그를 전부 제거시킬수도 있다.

 

class LoggedAudio : public Audio {
public:
    LoggedAudio(Audio& wrapped) : wrapped_(wrapped) {}
    virtual void playSound(int soundID) {
        log("사운드 출력");
        wrapped_.playSound(soundID);
    }
    virtual void stopSound(int soundID) {
        log("사운드 중지");
        wrapped_>stopSound(soundID);
    }
    virtual void stopAllSounds() {
        log("모든 사운드 중지");
        wrapped_.stopAllSounds();
    }

private:
    void log(const char* message) { /* 로그를 남기는 코드 */ }
    Audio& wrapped_;
};

다른 오디오 서비스 제공자를 래핑하면서 동일한 인터페이스를 상속받는다.

기능 요청을 받으면 로그를 기록하고 실제 기능 요청은 래핑한 서비스에 전달한다.

 

void enableAudioLogging() {
    // 기존 서비스 decorate
    Audio* service = new LoggedAudio(Locator::getAudio());
    Locator::provide(service);
}

로그를 키고싶으면 위의 함수를 호출해주면 되고 비활성화 할때는 로그 기능이 빠진 서비스 제공자를 등록하면 된다.

만약 널객체에 데코레이터 패턴을 적용한다면 기능은 빠지고 로그만 출력시킬 수도 있다.

 

 

디자인 결정

◾ 서비스는 어떻게 등록되는가?

◾ 외부 코드에서 등록

예제에서 본 방식이 이에 해당하고 가장 일반적으로 사용하는 방법이다.

빠르고 간단하며 서비스 제공자를 어떻게 만들지 제어할 수 있고 게임 실행 도중에도 서비스를 교체할 수 있다.

하지만 서비스 중개자가 외부 코드에 의존한다는 단점이 있다.

 

◾ 컴파일할 때 바인딩

class Locator {
public:
    static Audio& getAudio() { return service_; }

private:
#if DEBUG
    static DebugAudio service_;
#else
    static ReleaseAudio service_;
#endif
};

전처리기 매크로를 이용해서 컴파일 타임에 등록한다.

컴파일 타임에 작업이 이루어지기 때문에 매우 빠르고 서비스가 항상 사용 가능하다는 장점이 있지만 서비스를 쉽게 변경할 수 없다. 만약 변경하려면 컴파일을 다시 해야한다.

 

◾ 런타임에 설정 값 읽기

보통 서비스 제공자 설정 파일을 읽어서 리플렉션으로 원하는 서비스 제공자를 런타임에 생성한다.

여러 장점이 있지만 복잡하고 서비스 등록 시간이 걸린다는 단점이 있다.

 

 

◾ 서비스를 못 찾으면 어떻게 할 것인가?

◾ 사용자가 알아서 처리하게 한다

가장 간단한 방법이다. 못찾으면 알아서 해결하라고 nullptr을 반환하고 책임을 떠넘기면 된다.

실패 처리를 사용자 쪽에서 정할 수 있다는 장점은 있지만 매번 실패처리를 해야하는 번거로움이 생긴다.

 

◾ 게임을 멈춘다

서비스를 못찾으면 단언문을 넣어서 게임을 중단시켜버린다.

문제를 해결해주지는 않지만 누구에게 문제가 있는지 분명하게 보여준다.

서비스 중개자쪽에서 처리하기 때문에 사용자 측에서 실패처리를 하지 않아도 되지만 찾지 못하면 게임이 얄짤없이 종료된다.

 

◾ 널 서비스를 반환한다

실패처리를 사용자가 하지 않아도 되고 게임이 멈추지도 않는다. 

게임이 멈추지 않는다는 점은 장점임과 동시에 단점이 될 수도 있는데, 서비스를 찾지 못했는데도 동작한다면 디버깅하기가 어려워진다.

 

널 서비스를 반환하는 형태가 가장 좋아보이지만 의외로 단언문을 걸어서 게임을 중단시키는 경우가 가장 많이 사용된다.

게임이 출시될쯤이면 이미 테스트를 많이 통과했을 것이고 게임이 실행될 환경도 어느정도 구체화되기 때문에 서비스를 찾지 못할 가능성은 매우 적다.

 

 

◾ 서비스의 범위는 어떻게 잡을 것인가?

여태까지는 전역적인 접근을 허용했고 이게 일반적이지만 특정 클래스 및 하위 클래스에만 접근을 제한시킬수도 있다.

 

전역에서 접근이 가능하다면 전체 코드에서 같은 서비스를 사용하도록 할 수 있지만 언제 어디에서 서비스가 사용되는지를 제어할 수 없다.

 

특정 클래스에만 접근을 제한시킨다면 커플링을 제어할 수 있지만 중복 작업을 해야 할 수도 있다.

각자의 서비스에 참조해야 하기 때문에 코드 중복을 피할 수 없다.

하위 클래스 샌드박스 패턴을 적용시켜봤자 결국 최상위 클래스와 커플링이 생긴다.

 

서비스가 특정 분야에 한정되어 있다면 특정 클래스로 접근 범위를 좁히는 편이 좋다.

이벤트 큐 (Event Queue)

메시지나 이벤트를 보내는 시점과 처리하는 시점을 디커플링한다.

 

 

동기

UI의 버튼을 클릭하는 등 프로그램과 상호작용할 때마다 OS는 이벤트를 만들어서 프로그램 쪽으로 전달하고, 프로그램은 이를 받아서 이벤트 핸들러 코드에 전달하여 원하는 행동을 하도록 처리해야 한다.

보통 이런 이벤트들을 받기 위해서 코드 깊숙한 곳 어딘가에 이벤트 루프가 작성되어 돌아간다.

 

while (running) {
    Event event = getNextEvent();
    // .. 이벤트 처리
}

대략적으로 이런식으로 생겼다.

 

getNextEvent를 호출하여 아직 처리되지 않은 사용자 입력을 가져오고 이벤트 핸들러로 보내면 정의된 동작을 수행한다. 이 때, 프로그램이 원할 때 이벤트를 가져오는 것이지 OS가 프로그램의 코드를 바로 호출하는 것이 아니다.

OS는 프로그램이 이벤트를 가져가기 전까지 큐에 보관해둔다.

 

보통 OS의 큐를 이용하기 보다는 게임에 알맞게 자체 제작된 이벤트 큐를 만들어서 중추 통신 시스템으로 활용한다.

게임 시스템들이 디커플링 상태를 유지한 채로 서로 고수준의 통신을 하고 싶을 때 이벤트 큐를 사용한다.

 

 

특정한 인게임 이벤트가 발생할 때 팝업 도움말을 보여주는 튜토리얼 시스템을 만든다고 가정해보자.

게임플레이와 전투와 관련된 코드는 이미 충분히 복잡하게 구현되어있기 때문에 튜토리얼 시스템과 관련된 코드를 끼워넣어서 더 복잡하게 만드는 대신 중앙 이벤트 큐를 만들면 어느 시스템에서도 큐에 이벤트를 보낼 수 있게된다.

각 시스템들은 큐의 존재만 알뿐, 그 외 다른 시스템을 모르더라도 어떤 이벤트가 발생한 사실을 전달할 수 있다.

 

class Audio {
public:
    static void playSound(SoundId id, int volume);
};

void Audio::playSound(SoundId id, int volume) {
    ResourceId resource = loadSound(id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, volume);
}

이번에는 위와 같은 오디오 엔진을 간단하게 구현해서 적용해본다고 해보자. 정적 함수이기 때문에 어디서든 호출할 수 있다. 그런데 반복적으로 호출하다보면 몇 프레임씩 멈출 때가 있다. 그 이유를 알아보자.

 

◾ 문제 1: API는 오디오 엔진이 요청을 완전히 처리할 때까지 호출자를 블록한다

playSound 함수는 동기적이라서 내부적으로 호출한 API의 실행이 끝나기 전까지 계속 대기한다.

그 와중에 리소스를 로딩하는 작업까지 해야한다면 더 오래 대기해야 하고 그동안 게임은 멈추게된다.

게다가 몬스터 여러마리가 동시에 피격되기라도 하면 파형이 더해지기 때문에 소리가 매우 커져서 거슬리게 된다.

이런 문제는 전체 사운드 호출을 취합하고 우선순위에 따라 나열한 후 처리해야하지만 동기적으로 처리하기 때문에 해결할 수 없다.

 

◾ 문제 2: 요청을 모아서 처리할 수가 없다

설령 오디오용 스레드를 별도로 만든다 하더라도 문제가 더 심각해질 뿐 전혀 해결되지 않는다.

 

◾ 문제 3: 요청이 원치 않는 스레드에서 처리된다

결론적으로 원인은 playSound 호출 시, 하던 일을 멈추고 사운드를 즉시 처리하라고 해석하는 것에 있다.

 

 

패턴

큐는 요청이나 알림을 들어온 순서대로 저장한다. 알림을 보내는 곳에서는 요청을 큐에 넣은 뒤에 결과를 기다리지 않고 리턴한다. 요청을 처리하는 곳에서는 큐에 들어 있는 요청을 나중에 처리한다. 요청은 그곳에서 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다. 이를 통해 요청을 보내는 쪽과 받는 쪽을 코드뿐만 아니라 시간 측면에서도 디커플링한다.

 

 

언제 쓸 것인가?

단순히 메시지를 보내는 곳과 받는 곳을 분리하고 싶을 뿐이라면 관찰자 또는 명령 패턴을 사용하면 된다.

이벤트 큐는 송수신 시점까지 분리하고 싶을 때에만 필요한 것이다.

요청을 처리하는 제어권은 수신측에서 처리하는게 자연스럽고 편하다. 받은 요청을 지연시키거나 모아서 한번에 처리할 수 있고 심지어 요청을 버릴수도 있다. 만약 송신 측에서 피드백을 받아야 한다면 큐를 사용하는 것이 적합하지 않다.

 

 

주의사항

이벤트 큐는 전체 시스템의 맨 위에서 서로 메시지를 주고받게 해주는 통로 역할로 많이 사용된다.

사실상 전역 변수와 같기 때문에 아무리 조심해도 상호 의존성 문제가 생기게 되어있다.

그나마 이벤트 큐 패턴은 중앙 이벤트 큐를 간단한 프로토콜로 깔끔하게 래핑하지만 그래도 결국 전역이기 때문에 그와 관련된 문제가 사라지지 않는다.

 

월드의 상태는 언제든지 변화한다는 것도 이벤트 큐 시점에서 바라보면 문제가 될 수 있다.

주변의 정보를 이용하는 이벤트가 지연 처리된다면 지연되는 그 사이에 정보가 사라질 가능성이 있다.

이벤트가 만들어진 시점의 월드와 처리되는 시점의 월드의 상태가 다를 수 있다는 점을 매우 주의해야한다.

그래서 동기적으로 처리되는 이벤트보다 큐에 들어가는 이벤트에는 데이터가 훨씬 많이 필요하게 된다.

 

피드백 루프에 빠질 수 있다는 점도 주의해야한다.

메시징 시스템이 동기적이면 크래시가 발생하기 때문에 디버깅이 어렵지 않겠지만 비동기로 처리되다 보니 콜스택이 풀려서 쓸데없이 계속 이벤트를 주고받아서 자원을 갉아먹고 있음에도 불구하고 게임은 계속 실행된다.

보통 이런 문제를 피하기 위해서 이벤트를 처리하는 코드 내에서는 이벤트를 보내지 않는다.

 

 

예제

struct PlayMessage {
    SoundId id;
    int volume;
};

지연처리할 때 필요한 정보를 저장하는 간단한 구조체를 정의했다.

 

class Audio {
public:
    static void init() { numPending_ = 0; }
    static void playSound(SoundId id, int volume);
    static void update();
    
    // ...
    
private:
    static const int MAX_PENDING = 16;
    static PlayMessage pending_[MAX_PENDING];
    static int numPending_;
};

void Audio::playSound(SoundId id, int volume) {
    assert(numPending_ < MAX_PENDING);

    pending_[numPending_].id = id;
    pending_[numPending_].volume = volume;
    numPending_++;
}

void Audio::update() {
    for (int i = 0; i < numPending_; i++)
    {
        ResourceId resource = loadSound(pending_[i].id);
        int channel = findOpenChannel();
        if (channel == -1) return;
        startSound(resource, channel, pending_[i].volume);
    }
    numPending_ = 0;
}

이제 사운드 재생을 즉시 처리하지 않고 큐에 집어넣게 된다.

update 함수는 메인 게임 루프나 오디오 스레드 등 적당한 곳에서 호출해주면 된다.

그런데 위의 방법은 아직 반쪽짜리 큐다. update를 한 번 호출하면 모든 사운드 요청을 다 처리하기 때문에 여전히 동기적이다.

 

◾ 원형 버퍼

 

class Audio {
public:
    static void init() {
        head_ = 0;
        tail_ = 0;
    }
    static void playSound(SoundId id, int volume);
    static void update();

private:
    static int head_;
    static int tail_;
};

void Audio::playSound(SoundId id, int volume) {
    assert(tail_ < MAX_PENDING);

    pending_[tail_].id = id;
    pending_[tail_].volume = volume;
    tail_ = (tail_ + 1) % MAX_PENDING;
}

void Audio::update() {
    // 둘이 같으면 보류된 요청이 아무것도 없다는 뜻
    if (head_ == tail_) return;

    ResourceId resource = loadSound(pending_[head_].id);
    int channel = findOpenChannel();
    if (channel == -1) return;
    startSound(resource, channel, pending_[head_].volume);

    head_ = (head_ + 1) % MAX_PENDING;
}

인덱스 대신 배열을 쪼개서 head(가장 오래 보류된 메시지)와 tail(새로운 메시지가 들어갈 위치)로 관리한다.

또한 update호출 시 한번에 모든 요청을 처리하는 대신에 한 번에 하나만 처리하도록 변경되었다.

 

 

◾ 요청 취합

원형버퍼를 사용함으로써 큐는 완성되었지만 파형의 합성으로 인해 같은 소리를 동시에 재생하면 커지는 문제부터 해결해보자.

큐를 사용함으로써 대기중인 요청을 확인할 수 있게 되었으므로 버퍼를 탐색하여 같은 요청이 있으면 병합시켜 버리면 된다.

같은 소리를 출력하라는 요청이 있으면 둘 중에 소리가 더 큰 값으로 덮어씌우고 요청을 큐에 넣지 않는다.

요청을 처리할때가 아니라 큐에 넣기 전에 취합이 일어난다는 점에 주의해야한다.

 

대신 호출하는 쪽의 처리 부담이 늘어나는 단점은 있지만 구현이 더 쉽다는 장점이 있다.

만약 큐가 크다면 요청을 처리할 때 취합하는게 나을 수도 있다. 또는 배열대신 해시 테이블 같은것을 사용해서 시간 복잡도를 줄이는 방법도 고려해 볼 수 있다.

 

큐에 메시지가 너무 오래 남아있으면 의도치 않은 동작이 될 수도 있으니 그점도 유의해야한다.

 

 

◾ 멀티스레드

스레드에 코드를 분배하는 방법은 다양하지만 오디오, 렌더링, AI 등 분야 또는 컴포넌트 별로 할당하는 전략을 많이 사용하는 편이다.

이미 코드도 분리되어 있고 큐는 캡슐화 되어있기 때문에 큐를 변경하는 코드인 playSound와 update만 thread-safe 하게 만들기만 하면 된다.

고수준에서만 언급하자면 큐가 동시에 수정되는 것만 막아내면 된다.

playSound는 일부 필드에만 값을 할당할 뿐 작업량이 많지 않기 때문에 mutex를 적용해도 그리 오래 걸리지 않고 update는 조건 변수 같은것으로 대기하게 하면 처리할 요청이 없는 동안 CPU의 낭비를 막을 수 있다.

 

 

디자인 결정

◾ 큐에 무엇을 넣을 것인가?

이벤트와 메시지는 비슷한 개념이라 용어를 혼용하며 사용했지만 실제 개념은 약간 다르다.

 

◾ 이벤트 : 이미 발생한 사건을 표현한다.

큐에 이미 발생한 일이 들어 있기 때문에 송신자는 수신자가 누군지 신경쓰지 않는다. 그래서 다수의 리스너를 지원해야 할 때도 많다.

비동기 관찰자 패턴 같은 방식으로 이벤트에 대해 반응할 수 있다.

 

◾ 메시지 : 나중에 실행했으면 하는 행동을 표현한다.

특정 리스너 하나가 요청을 들어줬으면 해서 메시지를 보내기 때문에 대부분은 리스너가 하나이다.

서비스에 비동기적으로 API을 호출하는 것과 비슷하다.

 

 

◾ 누가 큐를 읽는가?

◾ 싱글캐스트 큐 : 예제의 Audio처럼 큐 자체가 어떤 클래스의 API 일부일 때 적합하다.

 

◾ 브로드캐스트 큐 : 대부분의 이벤트 시스템이 브로드캐스트 큐이다. 리스너가 10개이고 이벤트가 하나 들어오면 리스터 모두가 해당 이벤트를 볼 수 있다.

만약 리스너가 하나도 없다면 이벤트가 버려진다.

또한 이벤트 개수×리스너 개수만큼 이벤트 핸들러가 호출되기 때문에 호출 횟수 조절을 위한 필터링이 필요할 수 있다.

 

◾ 작업 큐 : 브로드캐스트 큐와 마찬가지로 리스너가 여러개지만 큐의 데이터가 하나의 리스너에게만 전달되는 차이가 있다.

스레드 풀에 작업을 분배할 때 일반적으로 사용한다.

 

 

◾ 누가 큐에 값을 넣는가?

◾ 넣는 측이 하나일 때 : 동기형 관찰자 패턴에 가까운 형태이다. 하나의 특정 객체에서만 이벤트를 만들 수 있기 때문에 모든 리스너들은 데이터가 어디서 오는지 안전하게 추측할 수 있다.

 

◾ 넣는 측이 여러개일 때 : 피드백 루프 발생을 주의해야 한다. 또한 이벤트를 누구나 보낼 수 있기 때문에 리스너에서 보낸 쪽에 정보가 필요하다면 송신측의 객체 레퍼런스도 같이 전달해 줄 필요가 있다.

 

 

◾ 큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?

GC를 지원하는 언어에서는 크게 신경쓰지 않아도 되지만 C/C++에서는 객체의 생명을 직접 관리해야한다.

 

◾ 소유권을 전달하는 경우 : 메시지가 큐에 들어가고 나면 소유권은 송신자에서 큐에 넘어간다. 메시지를 처리할때는 수신 측에서 소유권을 가져가고 메시지 해제도 같이 해야한다. 이런 방식으로 동작하는 것이 unique_ptr이다.

 

◾ 소유권을 공유하는 경우 : 메시지를 참조하는 곳이 하나라도 있으면 계속 메모리에 남아있어야 한다. 이런 방식으로 동작하는 것이 shared_ptr이다.

 

◾ 큐가 소유권을 가지는 경우 : 송신 측에서 메시지를 직접 생성하지 않고, 큐에 새로운 메시지를 하나 달라고 요청한다. 큐는 미리 할당해놓은 메시지의 레퍼런스를 반환하고 송신 측에서 값을 채우면 수신측에서는 이 메시지를 참조한다.

말하자면 객체 풀이 큐의 보조 기억장치가 되는 셈이다.

 

 

이벤트 큐는 사실상 관찰자 패턴의 비동기형이다.

컴포넌트 (Component)

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

 

 

동기

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

이런 것들을 한 클래스 안에(ex:플레이어) 전부 넣으면 코드 길이도 길이지만 커플링이 강력해져서 시간이 갈수록 유지보수가 불가능을 향해 가게된다.

 

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

이 코드를 이해하고 수정하기 위해서는 물리, 그래픽, 사운드 세가지를 다 알아야만 한다.

이런 코드가 계속 작성된다면 땜빵 코드가 계속 추가되는 악순환을 부른다.

 

해결을 위한 접근 방법은 단순하다. 하나의 거대한 덩어리인 클래스를 분야에 따라 여러 부분으로 나누면 된다.

나누어진 컴포넌트 클래스들은 디커플링 되고 필요에 따라 서로 통신이 필요한 컴포넌트끼리만 결합을 제한시킬 수 있는 장점이 생긴다.

 

상속과 컴포넌트를 비교하기 위해 예를 하나 들어보자.

 

◾ Decoration은 덤불이나 먼지같이 볼 수는 있지만 상호작용은 할 수 없는 객체

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

◾ Zone은 보이지 않지만 상호작용 할 수 있는 객체

위의 세가지 경우 처럼 게임 내의 오브젝트가 있다고 하자.

 

상속 구조

컴포넌트를 사용하지 않으면 위와 같은 상속 구조를 가져가야 하는데, Prop이 Zone과 Decoration을 상속 받는 순간 다중 상속 문제가 발생한다. 게다가 구조를 아무리 바꿔봐도 코드를 깔끔하게 재사용 할 수 없다. 최상위 클래스에 모든 기능을 올려두면 해결은 가능하겠지만 파생 클래스에서 쓰지도 않을 기능들이 들어가는 문제가 발생한다.

 

하지만 컴포넌트로 만들면 상속은 전혀 필요없기 때문에 위와 같은 구조에서 자유로워지고 클래스도 GameObject, PhysicsComponent, GraphicComponent 총 3개만 있으면 된다.

 

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

객체의 소켓에 재사용 가능한 여러가지 컴포넌트 객체를 꽂아 넣음으로써 복잡하면서 기능이 풍부한 객체를 만들어낼 수 있다.

 

 

패턴

여러 분야를 다루는 하나의 개체가 있다. 분야별로 격리하기 위해, 각각의 코드를 별도의 컴포넌트 클래스에 둔다. 이제 개체 클래스는 단순히 이들 컴포넌트들의 컨테이너 역할만 한다.

 

 

언제 쓸 것인가?

게임 개체를 정의하는 핵심 클래스에서 가장 많이 사용되지만, 다음 조건 중 하나라도 만족하면 다른 분야에서도 유용하게 쓸 수 있다.

 

◾ 한 클래스에서 여러 분야를 건드리고 있어서, 이들을 서로 디커플링하고 싶다.

◾ 클래스가 거대해져서 작업하기가 어렵다.

◾ 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다. 단, 상속으로는 딱 원하는 부분만 골라서 재사용 할 수가 없다.

 

 

주의사항

컴포넌트 패턴을 적용한다면 클래스 하나에 코드를 몰아놨을 때보다 더 복잡해질 가능성도 존재한다.

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

 

하지만 규모가 커지면 복잡성에서 오는 손해보다 디커플링과 코드 재사용에서 얻는 이득이 더 커질 수 있다.

 

컴포넌트 패턴을 적용하기 전에 아직 존재하지도 않는 문제에 대한 해결책을 오버엔지니어링하려는 것은 아닌지 고민해 볼 필요가 있다.

 

그리고 필연적으로 무언가를 하려면 컴포넌트로부터 얻어서 사용해야 하기 때문에 최소 한 단계 이상을 거쳐야 할 때가 많아져서 성능 하락이 발생할 수도 있다.

 

 

예제

명확한 이해를 위해 더미 코드들을 작성하기 때문에 다른 패턴에 비해 예제 코드가 좀 길다.

 

더보기
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_;
};

// 매 프레임 호출
void Bjorn::update(World&, Graphics& graphics) {
    // 입력에 따라 속도 조절
    switch (Controller::getJoystickDirection()) {
    case DIR_LEFT:
        velocity_ -= WALK_ACCELERATION;
        break;
            
    case DIR_RIGHT:
        velocity_ += WALK_ACCELERATION;
        break;
    }
    
    x_ += velocity_;
    world.resolveCollision(volume_, x_, y_, velocity_);
    
    // 알맞은 스프라이트 그림
    Sprite* sprite = &spriteStand_;
    if (velocity_ < 0) sprite = &spriteWalkLeft_;
    else if (velocity_ > 0 sprite = &spriteWalkRight_;
    graphics.draw(*sprite, x_, y_);
}

우선 컴포넌트 패턴을 적용하지 않은 통짜 클래스이다.

 

더보기
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;
};

class Born {
public:
    int velocity;
    int x, y;
    
    void update(World& world, Graphics& graphics) {
        input_.update(*this);
        
        // 속도에 따라 위치 변경
        x += velocity;
        world.resolveCollision(volume_, x, y, velocity);
        
        // 알맞은 스프라이트 그림
        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 클래스로 위임했다.

Bjorn 클래스는 이제 더이상 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_;
};

이제 Bjorn 클래스는 자신을 정의하는 컴포넌트 집합을 관리하고 컴포넌트들이 공유하는 상태를 들고있는 역할만 하게 된다.

옮겨지지 않은 일부 멤버 변수는 모든 컴포넌트가 공유해서 사용하기 때문에 어느 컴포넌트에 둘지 애매해지기 때문에 직접 관리한다. 게다가 컴포넌트들끼리 서로 커플링 되지 않고도 쉽게 통신이 가능한 장점이 있다.

 

필요한 경우 컴포넌트 클래스를 추상화 시켜서 인터페이스를 감출 수 있다.

사용 예로는 InputComponent를 추상화 시켜서 플레이어의 입력과 데모 모드에서 자동으로 조작되는 두가지의 클래스로 파생시켜 재정의하면 실제 게임과 데모 모드에서 각자 사용이 가능하도록 할 수 있다.

 

이쯤에서 Bjorn대신 GameObject같은 범용적인 이름으로 교체해도 전혀 문제가 없어진다.

컴포넌트들 역시 추상 클래스로 정의하고 Bjorn전용 컴포넌트로 재정의하여 팩토리 메서드로 생성하면 기존과 동일하게 사용할 수 있으면서 만들어둔 컴포넌트들을 이용해서 온갖 객체들을 찍어낼 수 있게 된다.

 

 

디자인 결정

가장 중요한 사항은 "어떤 컴포넌트 집합이 필요한가?" 이다.

여러가지 고려 사항을 알아보도록 하자.

 

"객체는 컴포넌트를 어떻게 얻는가?"

◾ 객체가 필요한 컴포넌트를 알아서 생성할 때 : 객체는 항상 필요한 컴포넌트만을 가지게 되지만 멤버로써 하드코딩 되므로 객체를 변경하기가 어렵다.

 

◾ 외부 코드에서 컴포넌트를 제공할 때 : 객체가 훨씬 유연해지고 컴포넌트와 좀 더 디커플링 될 수 있다.

 

 

"컴포넌트들끼리는 어떻게 통신할 것인가?"

서로 완전히 격리된채로 동작하는게 이상적이지만 현실적으로는 어렵다.

 

◾ 컨테이너 객체의 상태를 변경하는 방식 : 컴포넌트간의 디커플링 상태가 유지되지만, 컴포넌트들이 공유하는 정보들은 모두 컨테이너 객체에 넣어야 한다. 그리고 암시적으로 통신하다 보니 실행 순서에 의존적이게 된다.

 

◾ 컴포넌트가 서로 참조하는 방식 : 간단하고 빠르지만 두 컴포넌트가 강하게 결합된다. 그나마 서로 통신하는 컴포넌트끼리만 커플링이 형성되기 때문에 컴포넌트 패턴을 적용하기 전보다는 훨씬 낫다.

 

◾ 메시지를 전달하는 방식 : 컨테이너 객체에 간단한 메시징 시스템을 만들고 각 컴포넌트들이 서로에게 정보를 뿌리도록 한다. 컴포넌트가 컨테이너에 메시지를 보내면, 컨테이너는 자신이 소유한 모든 컴포넌트에 메시지를 뿌린다.

처음 메시지를 보낸 컴포넌트도 포함되기 때문에 피드백 루프에 빠지지 않도록 조심해야 한다.

가장 복잡한 대신 하위 컴포넌트들은 디커플링되고 컨테이너 객체가 단순해진다.

 

세 가지 방법중 왕도는 없다. 결국 셋 다 조금씩 쓰게 된다.

메시징은 호출하고 나서 신경 쓰지 않아도 되는 사소한 통신에 사용하기 좋다. 예를 들어 물리 컴포넌트가 어떤 오브젝트와 충돌했다고 메시지를 전파하면 오디오 컴포넌트가 이를 받아서 소리를 출력한다.

 

 

참고로 유니티의 GameObject가 바로 컴포넌트 방식에 맞추어서 설계되었다.

+ Recent posts