타입 객체 (Type Object)

클래스 하나를 인스턴스별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

 

 

동기

몬스터를 여러 종류 구현해야 한다고 하자.

전형적인 OOP 방식으로 구현하게 된다면 is-a 관계에 따라 Monster라는 상위 클래스를 구현하고 공통 인터페이스는 순수 가상 함수로, 공통 속성은 멤버 변수로써 갖는다. 하위 클래스는 이를 상속받아서 인터페이스를 구현한다. 매우 일반적인 구현이다.

 

문제는 몬스터의 종류만큼 하위 클래스의 개수도 끊임없이 늘어나고 유지보수도 매우 번거로워진다는 점이다.

 

접근 방식을 바꿔서 몬스터마다 종족에 대한 정보를 두어서 상속대신 종족 정보에 접근할 수 있는 Breed 클래스 하나를 새로 만들고 참조하도록 한다. 이러면 상속구조 없이 Monster, Breed 2개의 클래스만으로 해결할 수 있다.

몬스터와 종족을 결합시키기 위해서 모든 Monster 인스턴스는 종족 정보를 가지고 있는 Breed 객체를 참조하여 접근한다.

Breed 클래스는 본질적으로 몬스터의 타입을 정의하고 각각의 종족 객체는 개념적으로 다른 타입을 의미한다.

 

타입 객체 패턴은 코드의 수정 없이도 새로운 타입을 정의할 수 있는 것이 장점이다.

상속으로 만들어지던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮겨놓은 셈이다.

이제는 외부 파일에서 읽은 데이터로 종족 객체를 생성하고나면 데이터만으로 서로 다른 몬스터를 정의할 수 있게 된다.

 

 

패턴

타입 객체 클래스와 타입 사용 객체 클래스를 정의한다.

모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미하고, 타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조한다.

예제를 기준으로 보면 Monster가 타입 사용 객체, Breed가 타입 객체이다.

 

인스턴스별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고, 개념적으로 같은 타입끼리 공유하는 데이터나 동작은 타입 객체에 저장한다.

 

상속 관계가 아님에도 불구하고 마치 상속받는것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.

 

 

언제 쓸 것인가?

◾ 나중에 어떤 타입이 필요할지 알 수 없다.

◾ 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.

 

이 중 하나라도 해당하면 타입 객체 패턴을 사용하기에 적합하다.

 

주의사항

타입 객체 패턴의 핵심은 표현력이 좋더라도 유연하지 않은 코드 대신 표현력이 조금 떨어지더라도 훨씬 유연한 데이터로 타입을 표현하는데에 있다.

유연성을 얻는 대신 코드가 아닌 데이터로 표현하면서 잃는 것도 생긴다.

 

◾ 타입 객체를 직접 관리해야 한다

타입 객체를 생성하고 필요로 하는 몬스터가 있는 한, 메모리에 계속 유지시켜야 한다. 몬스터 인스턴스를 생성할 때, 알맞는 종족 객체 레퍼런스로 초기화 하는것도 직접 해야한다.

 

◾ 타입별로 동작을 표현하기가 더 어렵다

상속의 경우 오버라이딩을 통해 원하는 대로 구현이 가능하다. 하지만 타입 객체 패턴은 종족 객체 변수에 문구를 저장하는 식으로 표현한다.

타입 종속적인 데이터를 정의하는 것은 쉽지만 동작을 정의하기는 어렵다.

이를 극복하기 위해서는 미리 동작 코드를 여러 개 정의해두고 함수 포인터로 가지면 된다.

 

좀 더 나아가서 바이트코드 패턴을 이용하면 동작을 표현하는 객체까지도 만들 수 있다.

 

 

예제

class Breed {
public:
    Breed(int health, const char* attack) : health_(health), attack_(attack) {}
    int getHealth() { return health_; }
    const char* getAttack() { return attack_; }
    
private:
    int health_;
    const char* attack_;
};

class Monster {
public:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    int health_;
    Breed& breed_;
};

기본적인 타입 객체의 구현 방법이다. 상속 없이 몬스터를 정의한다.

그런데 이런식으로 몬스터 객체를 만드는건 OOP답지 않다. 대신, 팩토리 메소드와 유사한 생성자 함수를 통해 클래스가 알아서 인스턴스를 생성하도록 한다.

 

class Breed {
public:
    Monster* newMonster() { return new Monster(*this); }
    // .. 이하 동일
};

class Monster {
    friend class Breed;
    
public:
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    int health_;
    Breed& breed_;
};

Monster의 생성자를 private으로 만든 대신 Breed 클래스를 friend로 지정하여 객체 생성을 위임한다.

 

Monster* monster = new Monster(someBreed); // 기존 방법
Monster* monster = someBreed.newMonster(); // 생성자 함수 이용

겉으로 보기에는 별 차이가 없어보이지만 Monster의 초기화 제어권을 Breed 클래스가 가지고 있기 때문에 Monster 객체를 생성하기 전에 필요한 리소스들을 미리 가져오거나 메모리 풀, 커스텀 힙 등에서 메모리를 가져오는 작업들을 수행할 수 있다.

Breed의 생성자 함수를 통해서만 몬스터 객체를 생성할 수 있기 때문에 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라서 생성되도록 강제할 수 있게 된다.

 

 

상속을 통해서도 데이터를 공유할 수 있는데, 일반적인 상속관계가 아니라 타입 객체끼리 상속할 수 있는 시스템을 직접 구현해서 사용한다.

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : parent_(parent), health_(health), attack_(attack) {}
    int getHealth();
    const char* getAttack();
    
private:
    Breed* parent_;
    inth health_;
    const char* attack_;
};

int Breed::getHealth() {
    // 오버라이딩
    if (health_ != 0 || parent_ == nullptr) return health_;
    
    return parent_->getHealth();
}

const char* Breed::getAttack() {
    // 오버라이딩
    if (attack_ != nullptr || parent_ == nullptr) return attack_;
    
    return parent_->getAttack();
}

상속 문법을 사용하지 않고 상속 구조를 만든다. 대신 속성 값을 반환할때마다 상위 객체들을 재귀적으로 확인하는 과정이 있기 때문에 더 느리다는 단점이 있다.

 

만약 종족의 속성 값이 바뀌지 않는다고 하면 생성 시점에 바로 상속을 적용시켜서 간소화 시킬 수 있다. (카피다운 위임)

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : health_(health), attack_(attack) {
        if (parent != nullptr) {
            if (health == 0) health_ = parent->getHealth();
            if (attack == nullptr) attack_ = parent->getAttack();
        }
    }

    int getHealth() { return health_; }
    int getAttack() { return attack_; }
    // ...
};

생성자에서 상위 속성을 모두 복사했기 때문에 더이상 상위 객체의 포인터(parent_)를 들고있지 않아도 된다.

 

 

디자인 결정

타입 객체의 캡슐화 여부와 생성 방법을 고려해볼 필요가 있다.

캡슐화의 여부는 각자 장단점이 존재하고 만약 캡슐화를 시킨다면 타입 객체 패턴의 복잡성이 다른 코드에는 드러나지 않게 되는 장점이 있다. 또한 같은 타입 객체로부터 동작을 선택적으로 오버라이드 할 수 있어서 코드의 추가가 어렵지 않다.

다만 타입 객체 메서드를 전부 일일이 포워딩 해주어야 하는 번거로운 작업이 발생한다.

 

타입 객체를 노출시키기로 했다면 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있게 되므로 타입 객체의 메서드를 통해서만 새로운 몬스터를 생성시키게 제한시킬 수 있다.

다만 타입 객체가 공개 API의 일부가 되어 포함되기 때문에 복잡성과 유지보수면에서 디메리트가 생긴다.

 

 

타입 객체 패턴에서 객체는 타입 객체와 타입 사용 객체 쌍으로 존재해야만 한다. 그럼 이 둘을 어떻게 생성하고 결합시키는게 좋을까?

 

◾ 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우

두 객체 모두 외부에서 생성하기 때문에 외부 코드에서 메모리 할당을 제어할 수 있다.

 

◾ 타입 객체의 생성자 함수를 호출하는 경우

특정 객체 풀이나 특정 메모리 할당자에서만 생성하도록 제한하고 싶을 때 타입 객체에서 메모리 할당을 제어한다.

 

필요한 상황에 따라 선택하면 될듯하다.

 

타입의 변경과 상속에 관해서도 짚고 갈 사항이 있다.

한번 결정된 타입은 불변한다고 가정했지만 필요하다면 타입을 바꾸는 방법도 존재한다.

타입을 바꿀 수 없도록 설계한다면 코드를 구현하고 이해하는게 더 쉽고 디버깅 하기도 더 쉽다. 하지만 타입을 변경할 수 있도록 한다면 객체 생성 횟수가 줄어들기 때문에 자원의 낭비를 줄일 수 있다는 이점이 있지만 기존에 설정해둔 가정을 깨지 않도록 주의해야한다.

 

상속의 경우는 상속을 하지 않는 경우와 단일 상속, 다중 상속 총 3가지로 나눌 수 있다.

 

◾ 상속 없음

상속을 사용하지 않는다면 매우 단순해지는 대신 중복 작업을 해야 할수도 있다.

 

◾ 단일 상속

단일 상속은 그나마 단순한 편이지만 실제 값이 정의된 타입을 찾을 때까지 상속 구조를 타고 올라가야 하기 때문에 런타임의 자원이 낭비된다.

 

◾ 다중 상속

거의 모든 데이터 중복을 피할 수 있지만 구조가 복잡해진다.

애초에 타입 객체가 아니더라도 일반적인 경우에도 다중 상속을 지양(사실상 금지)하는 만큼 실무보다는 이론에 가까운 내용으로 이해하고 사용 역시 지양하는 편이 좋다.

 

 

타입 객체 패턴은 여러 패턴과 유사한 점들이 있다.

프로토타입 패턴은 같은 문제를 다른 방식으로 접근하고 경량 패턴은 여러 인스턴스가 같은 객체를 공유한다는 점에서 비슷하다.

또한 클래스 자신을 정의하는데에 있어서 일부 내용을 다른 클래스로 위임한다는 면에서는 상태 패턴과 유사점이 있다.

하위 클래스 샌드박스 (Subclass Sandbox)

상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다.

 

 

동기

다양한 초능력을 구현한다고 하자. 그러면 상위 클래스를 만들고 상속받은 클래스들을 정의하는것이 일반적이며 구현을 마치고 나면 수십개가 넘는 초능력이 만들어지게 될 것이다.

그런데 이런식으로 구현하게 되면 중복되는 코드들이 많아지고 게임 내의 거의 모든 코드가 초능력 클래스와 커플링이 생기게 될 가능성이 높다.

 

구조를 조금 바꿔서 초능력 클래스를 구현하는 동료 프로그래머들과 같이 사용할 원시명령 집합을 만들어서 공통적으로 사용하면 중복 코드를 많이 줄일 수 있다. 원시명령 집합들은 코드중복을 최소화하여 하위 클래스들을 구현하기 위해 상위 클래스에 정의한 일종의 도구이기 때문에 가상함수일 필요도 없고 public으로 공개할 필요도 없다. 그래서 일반적으로 protected 비가상함수로 만들게 된다.

그리고 이 원시명령 집합들을 이용해 행동들을 구현할 메서드가 필요할텐데, 이 메서드는 protected 순수 가상 함수로 하위 클래스에서 재정의할 수 있도록 한다.

 

상위 클래스가 제공하는 기능을 최대한 고수준 형태로 만들어서 코드 중복을 최대한 회피하고 추후 하위 클래스에서 중복 코드가 발생하면 해당 코드를 언제든지 상위 클래스로 옮겨서 재사용 할 수 있게 하면 된다.

 

커플링 문제는 상위 클래스 한곳에 몰아넣었기 때문에 하위 클래스는 상위 클래스하고만 커플링될뿐이고 다른 코드와는 커플링 되지 않는다.

 

나중에 게임 시스템이 변경되면 상위 클래스를 수정하는것은 불가피하겠지만 하위 클래스들은 수정할 필요가 없다.

 

 

패턴

상위 클래스는 추상 샌드박스 메서드와 여러 제공 기능을 정의한다. 제공 기능은 protected로 만들어서 하위 클래스에게만 제공하는 것이라는 걸 분명하게 명시한다. 각 하위 클래스들은 제공받은 기능들을 이용해서 샌드박스 메서드를 구현한다.

 

 

언제 쓸 것인가?

패턴이라고 하기도 뭣할정도로 매우 단순하고 일반적이라서 게임이 아니더라도 많이 사용된다.

클래스에 protected 비가상함수가 있다면 샌드박스 메서드 패턴을 사용하고 있을 가능성이 높다.

 

◾ 클래스 하나에 하위 클래스가 많이 있다.

◾ 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.

◾ 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.

◾ 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.

 

위와 같은 경우에 샌드박스 패턴을 사용하면 좋다.

 

 

주의사항

하위 클래스는 상위 클래스를 통해서 게임 코드에 접근하기 때문에 상위 클래스가 하위 클래스들에서 접근해야 하는 모든 시스템과 커플링되고 하위 클래스 역시 상위 클래스와 매우 밀접하게 묶이게 된다.

이런 관계에서는 상위 클래스를 조금만 수정해도 어딘가 깨지기가 쉬워진다. 꺠지기 쉬운 상위 클래스 문제에 빠지게 되는 것이다.

 

그래도 마냥 단점만 있는것은 아닌게 커플링 대부분을 상위 클래스에 떠넘겼기 때문에 하위 클래스들은 다른 코드들과 깔끔하게 디커플링 될 수 있다는 점이다. 동작의 대부분은 하위 클래스에 존재하기 때문에 유지보수가 쉽다.

 

그렇다 하더라도 상위 클래스가 점점 비대해진다면 제공하는 기능의 일부를 별도의 클래스로 분리하여 책임을 나눠갖게 하는 것도 좋다. 이 때 컴포넌트 패턴이 도움이 된다.

 

 

예제

class Superpower {
public:
    virtual ~Superpower() {}
    
protected:
    virtual void activate() = 0;
    void move(double x, double y, double z) { ... }
    void playSound(SoundId sound, double volume) { ... }
    void spawnParticles(ParticleType type, int count) { ... }
};

매우 간단하고 이해하기 쉽다.

샌드박스 메서드는 순수 가상 함수로 만들었기 때문에 어디에 작업을 해야 할 지 분명하게 알 수 있다.

 

class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(PARTICLE_DUST, 10);
        move(0, 0, 20);
    }
};

하위 클래스는 위와 같이 구현하면 된다. 제공받은 비가상함수로 순수 가상 함수를 구현한다. 끝이다.

샌드박스 메서드를 입맛에 맞게 구현하면 된다.

 

 

디자인 결정

어떤 기능을 제공해야 하는지를 따져봐야 한다. 

잠시 극단적인 사례로 따져보자. 기능을 적게 제공하는 방향의 끝에는 상위 클래스에서 제공하는 기능은 하나도 없이 샌드박스 메서드 하나만 있고 기능을 많이 제공하는 방향의 끝에는 하위 클래스가 필요로 하는 모든 기능을 상위 클래스에서 제공한다.

전자는 하위 클래스 샌드박스 패턴이라고 부를수 있는지조차 의문이 드는 상황이고 후자는 상위 클래스의 커플링이 매우 강력해진다.

여기서 절충안을 찾아야 하는데 일반적인 원칙은 아래와 같다.

 

◾ 상위 클래스가 제공하는 기능을 몇 안되는 하위 클래스에서만 사용하면 별 이득이 없다. 상위 클래스의 복잡도가 증가하는 것에 비해 혜택을 받는 하위 클래스가 적기 때문이다.

◾ 외부 시스템의 상태를 변경하는 함수는 상위 클래스의 제공 기능으로 옮겨주는 것이 좋다.

◾ 제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 굳이 기능을 제공할 필요가 없다. 이런 경우라면 그냥 하위 클래스에서 외부 메서드를 호출하는게 더 나을수도 있다.

 

 

하위 클래스 샌드박스 패턴의 매우 큰 단점중 하나는 상위 클래스의 메서드 수가 무수히 많이 증가한다는 점이다.

이 단점은 일부 기능을 보조 클래스에 분산시켜서 해당 객체들을 반환하는 방식으로 우회하면 된다. 그러면 상위 클래스의 메서드 개수를 줄일 수 있고 다른 시스템과의 커플링도 낮출 수 있다.

유지보수가 더 쉬워지는 부수효과도 있다.

 

 

상위 클래스가 필요한 객체를 얻는 방법도 생각해볼 여지가 있다.

예를 들어 파티클을 생성시켜주는 객체가 있다. 이 객체를 이용해서 파티클과 관련된 기능을 구현하고 하위 클래스들에게는 메서드만 제공해주면 되기 때문에 하위 클래스들에 대해서는 캡슐화가 될 필요가 있다.

파티클 객체를 얻는 가장 쉬운 방법은 상위 클래스의 생성자 인수로 받는것이다. 문제는 이 객체를 하위 클래스의 생성자에서 받아서 상위 클래스의 생성자에 전달해주어야 하기 때문에 객체가 노출되어 캡슐화의 의미가 없어진다.

파티클 뿐만 아니라 다른 객체들도 받아야 한다면 하위 클래스 생성자에도 계속 추가해 주어야 하기 때문에 유지보수에도 좋지 않다.

 

Superpower* power = new SkyLaunch();
power->init(particles);

대신 초기화 과정을 2단계로 나누면 생성자에게 모든걸 전달하는 번거로움을 피할 수 있다.

다만 init 호출을 까먹지 말아야 하는 문제가 있다. 이 부분은 객체 생성 과정 전체를 하나의 함수로 캡슐화하면 해결할 수 있다.

덧붙여서 생성자를 private으로 만들고 friend 클래스를 잘 활용하면 하나의 인터페이스로만 객체 생성이 가능하도록 강제할 수 있으므로 초기화 단계를 빼먹을 일이 사라진다.

 

class Superpower {
public:
    static void init(ParticleSystem* particles) {
        particles_ = particles;
    }
    // ...
    
private:
    static ParticleSystem* particles_;
};

혹은 아예 정적 객체로 만들어서 사용하는 방법도 있다. 물론 간단한 만큼 발생할 수 있는 사이드 이펙트들을 유의해야한다.

 

class Superpower {
protected:
    void spawnParticles(ParticleType type, int count) {
        ParticleSystem& particles = Locator::getParticles(); // 서비스 중개자를 통해 접근
        particles.spawn(type, count);
    }
    // ...
};

다른 방법으로는 서비스 중개자 패턴의 도움을 받는 것이다. 2단계 초기화든 정적 객체든 공통점은 해당 객체를 소유한다는 점인데, 필요한 객체를 소유하지 않고 서비스 중개자를 통해 원하는 객체를 직접 가져와서 사용하면 된다.

업데이트 메서드 (Update Method)

컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션한다.

 

 

동기

while (true) {
    for (double x = 0; x < 100; ++x) skeleton.setX(x);
    for (double x = 100; x > 0; --x) skeleton.setX(x);
}

어떤 몬스터를 좌우로 움직이는 코드를 간단하게 작성하면 위와 같다. 문제는 무한루프이기 때문에 시각적으로 볼 수 없다.

 

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// 게임 메인 루프
while (true) {
    if (patrollingLeft) {
        --x;
        if (x == 0) patrollingLeft = false;
    }
    else {
        ++x;
        if (x == 100) patrollingLeft = true;
    }
    skeleton.setX(x);
    // 유저 입력 처리, 렌더링 ...
}

게임 루프 구조로 바꾸면 무한루프에 빠지지 않는다.

그런데 다른 몬스터나 오브젝트가 추가되는 경우 유지보수가 점점 어려워진다.

 

해결책은 의외로 간단하다. 모든 객체들이 자신의 동작을 캡슐화 하면 된다.

이를 위해 추상 메서드인 update를 정의하여 추상 계층을 하나 더해서 객체 컬렉션을 관리한다.

게임 루프는 매 프레임마다 객체 컬렉션을 순회하며 update를 호출하면 끝이다.

 

 

패턴

게임 월드는 객체 컬렉션을 관리하고 각 객체는 1프레임 단위의 동작을 하기 위한 업데이트 메서드를 구현한다.

그리고 매 프레임마다 컬렉션에 들어있는 모든 객체를 업데이트한다.

 

 

언제 쓸 것인가?

게임 루프 패턴 다음으로 매우 중요한 패턴이다.

단순한 애니메이션조차 없는 정적인 게임(보드게임 등)이 아닌 이상에야 움직이는 개체가 많은 게임에서는 업데이트 메서드 패턴이 어떻게든 사용된다.

 

◾ 동시에 동작해야 하는 객체나 시스템이 게임에 많다.

◾ 각 객체의 동작은 다른 객체와 거의 독립적이다.

◾ 객체는 시간의 흐름에 따라 시뮬레이션 되어야 한다.

 

위와 같은 경우라면 업데이트 메서드를 사용할 수 있다.

 

 

주의사항

구현 자체가 단순해서 딱히 조심할건 없지만 알아둬야 할 것들은 있다.

 

매 업데이트마다 기존의 상태를 기반으로 행동하기 때문에 현재 상태를 저장해야 하기 때문에 상태 패턴과 사용하면 좋을 수 있다.

 

또한 객체들은 매 프레임마다 업데이트 되지만 동시에 이루어지지 않는다. 내부적으로는 컬렉션에 저장된 순서대로 업데이트가 이루어진다.

순차적으로 업데이트되면 로직 작업이 편하지만 병렬로 업데이트되면 꼬일 가능성이 생긴다.

 

그리고 업데이트를 실행하는 도중에 컬렉션을 수정하는 것은 조심해야한다.

객체가 새로 생성되는 경우라면 목록 뒤에 추가하면 그만이지만 삭제하는 경우는 큰 문제가 발생할 여지가 있다.

 

삭제로 인해 건너뛰어졌다

객체를 삭제해야 하는 경우라면 인덱스를 업데이트 하거나 해당 객체에 죽었다는 표시를 남겨서 순회도중 해당 객체를 만나면 업데이트를 건너뛰고 순회가 끝나면 다시 순회를 돌며 해당 객체들을 제거하는 방법을 사용하는 것이 좋다.

 

 

예제

class Entity {
// 세부내용 생략
public:
    virtual void update() = 0;
}


class World {
public:
    World() : numEntities_(0) {}
    void gameLoop() {
        // ...
        while (true) {
            for (int i = 0; i < numEntities_; ++i)
                entities_[i]->update();
        }
        // ...
    }

private:
    Entity* entities_[MAX_ENTITIES];
    int numEntities_;
};

업데이트가 필요한 클래스들은 Entity를 상속받아서 각자의 행동을 구현하면 된다.

 

그런데 위의 방식은 고정 시간 간격을 쓰는 경우의 구현이다.

앞서 게임 루프에서 언급한것처럼 가변 시간 간격을 사용하는 게임도 있기 때문에 매개변수로 가변 시간 간격을 받아서 처리하면 어렵지 않게 대응할 수 있다.

 

추상 클래스를 이용한 업데이트 메서드 패턴은 상속의 깊이가 한 단계 증가하는 문제가 있지만 상황에 맞게 사용하면 된다.

물론 차후에 다룰 컴포넌트 패턴을 사용하는 것이 더 좋다.

 

 

디자인 결정

중요한건 update 메서드를 어느 클래스에 두느냐이다.

 

◾ 추상 클래스 상속

예제처럼 추상 클래스를 상속하는 것이 가장 간단하지만 요즘은 지양하는 방법이다. 차후 코드 재사용시 단일 상속으로 인한 문제가 발생하면 해결할 방법이 없다.

 

◾ 컴포넌트 클래스

컴포넌트는 알아서 자기 자신을 업데이트 하기 때문에 고민할 필요가 없다.

 

◾ 위임 클래스

상태 패턴처럼 일부 동작을 다른 객체에 위임하고 포워딩만 해주어서 유연성을 얻는다.

상태가 업데이트 되어야 하는 클래스에는 여전히 update가 존재하지만 오버라이딩 한것이 아니라 일반 멤버 함수이다.

 

 

또한 휴면 상태에 들어간 객체의 처리도 생각해 보아야 한다.

여러 이유로 일시적으로 업데이트를 할 필요가 없는 객체들까지 순회하면서 업데이트를 호출하는 것은 자원의 낭비이다.

이를 해결하는 방법으로는 크게 두가지가 있다.

 

단일 컬렉션으로 모두 관리하는 경우에는 플래그 검사를 하더라도 전체 컬렉션을 순회하기 때문에 시간이 낭비되고, 활성 객체만 관리하는 컬렉션을 추가하여 관리하면 시간의 낭비는 없지만 메모리를 추가로 소모해야 할 뿐더러 두 컬렉션의 동기화를 항상 유지해야 한다.

게임 루프 (Game Loop)

게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다.

 

 

동기

게임 루프 패턴은 이름에서부터 알 수 있듯이 게임이 아닌 분야에서는 그다지 쓰이지 않는다. 사실상 게임을 위해 존재하는 패턴이다. 구현 내용이 다를 수 있지만 거의 모든 게임에서 사용한다.

 

while (true) {
    char* command = readCommand();
    handleCommand(command);
}

대화형 프로그램을 만들다 보면 자연스럽게 위와 같은 형태가 만들어지게 된다.

그런데 게임은 다른 프로그램과는 달리 유저의 입력이 없어도 계속 돌아간다. 루프에서 유저의 입력을 처리하지만 유저의 입력을 마냥 기다리고 있지 않다는 점에서 차이가 있다. 이것이 게임 루프의 첫 번째 핵심이다.

 

while (true) {
    processInput();
    update();
    render();
}

게임 루프의 기본적인 코드는 위와 크게 달라지지 않는다.

 

 

루프가 유저의 입력을 기다리지 않고 계속 돌아간다면 루프가 도는데 시간이 얼마나 걸리는지를 따져봐야 한다.

게임 입장에서는 루프를 도는것이 시간이 흐르는 것과 동일하다. 물론 그동안 현실세계의 시간도 흘러간다.

이를 측정한것이 초당 프레임 수(FPS)이다.

게임 루프가 빠르게 돌면 FPS가 높아져서 부드럽고 빠른 게임 화면을 볼 수 있지만 루프가 느리면 끊기는 화면을 보게된다.

 

별다른 제한을 두지 않으면 루프는 무조건 빠르게 돈다. 그런데 플랫폼마다 성능에 차이가 있기 때문에 루프의 실행 횟수가 일정하지 않은 문제가 발생한다. 플랫폼에 따라 게임의 경험이 달라지는 것이다.

 

어떤 하드웨어에서도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 두 번째 핵심이다.

 

 

패턴

게임 루프는 게임 내내 실행된다. 루프마다 멈춤 없이 유저 입력 처리, 게임 상태 업데이트, 게임 화면 렌더링을 수행하고 시간의 흐름에 따라 게임플레이 속도를 조절한다.

 

 

언제 쓸 것인가?

게임 루프 패턴은 다른 패턴과 다르게 "언제 쓸 것인가" 를 고민할 필요 없이 "무조건" 사용된다.

설령 내가 작성하지 않더라도 어딘가에는 게임 루프가 반드시 존재한다.

 

 

주의사항

게임 루프는 전체 코드 중에서도 가장 핵심이다. 그래서 최적화를 매우 깐깐하게 해야한다.

또한 플랫폼에 따라 해당 플랫폼의 이벤트 루프를 게임 루프로 사용해야 하는 경우도 있다.

 

 

예제

1) 최대한 빨리 달리기

while (true) {
    processInput();
    update();
    render();
}

처음 봤던 코드이다. 이 코드는 실행 속도를 제어할 수 없기 때문에 실행 환경에 따라 실행 속도가 다르다.

 

2) 한숨 돌리기

while (true) {
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start + MS_PER_FRAME - getCurrentTime());
}

한 프레임이 빨리 끝나도 sleep에 의해 일정 시간 대기하게 되므로 실행 속도에 상한선이 생기지만 그보다 느려지는것은 막지 못한다.

 

3) 한 번은 짧게, 한 번은 길게

게임 루프의 문제는 결국 두가지이다.

 

1. 업데이트할 때마다 정해진 만큼 게임 시간이 진행되고,

2. 업데이트하는 데에는 현실 세계의 시간이 어느 정도 걸린다.

 

여기서 2번이 1번보다 오래 걸리면 게임이 느려지게 된다.

게임의 시간을 16ms 진행시키는데 현실 시간의 16ms보다 오래 걸리면 따라갈수가 없다.

그러면 한 번에 게임 시간을 16ms 이상 진행시킨다면 적어도 업데이트 횟수는 따라잡을 수 있다.

 

한 번에 게임 시간을 더 많이 진행시키려면 프레임을 고정적으로 16ms씩 처리하는것이 아니라 프레임 이후 실제 경과시간에 따라 간격을 가변적으로 잡으면 된다.

 

double lastTime = getCurrentTime();
while (true) {
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    
    processInput();
    update(elapsed);
    render();
    
    lastTime = current;
}

매 프레임마다 직전 루프 이후 실제 경과 시간을 계산하고 그 값을 update에 넘겨주면 해당 시간만큼 게임 월드의 상태를 진행시킨다.

 

문제는 가변 시간 간격에 따른 연산 오차이다.

가변 시간 간격을 사용하면 어떤 오브젝트가 이동할 때, 업데이트 횟수에 상관없이 실제 시간동안 같은 거리를 이동하게 된다.

그런데 게임에서는 보통 부동 소수점을 쓰기 때문에 반올림 오차가 발생하기가 매우 쉽고 업데이트 횟수가 많을수록 오차가 더 크게 누적된다.

 

실행 환경에 관계 없이 실행 속도를 동일하게 제어하려고 적용한 가변 시간 간격이 연산 오차로 인해 서로 다른 결과를 발생시킬 수 있는 새로운 문제에 봉착하게 된다.

물리 엔진은 보통 실제 물리 법칙의 근사치를 취하게 되는데, 근사치가 튀는걸 막기 위해 감쇠를 적용하게 된다. 이 감쇠는 시간의 간격에 맞춰서 세심하게 조정해야 하지만 가변 시간 간격을 적용한다면 감쇠 값이 계속 바뀌어서 물리가 불안정해지게 되는 결과를 낳는다.

간단히 말해서 불안정성이 생긴다는 것이다.

 

4) 따라잡기

가변 시간 간격에 영향을 받지 않는 부분 중 하나는 렌더링이다.

모션블러같은 경우를 제외하고는 렌더링은 가변 시간을 고려하지 않고 고려할 필요도 없다.

그래서 물리나 AI 등은 고정 시간 간격을 적용하고 렌더링 같은것은 가변 시간을 적용하여 조금 더 유연하게 만든다.

 

double previous = getCurrentTime();
double lag = 0.0;

while (true) {
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;
    lag += elapsed;
    processInput();
    
    while (lag >= MS_PER_UPDATE) { // 시각적 프레임 레이트가 아님
        update();
        lag -= MS_PER_UPDATE;
    }
    render();
}

주의할 점은 고정 시간 간격을 너무 짧게 잡지 않는것이다. 적어도 update를 실행하는 데 걸리는 시간보다는 간격이 커야한다.

 

5) 중간에 끼는 경우

그런데 만약 렌더링이 두 업데이트 사이에 끼게 된다면 움직임이 튀어보일 수 있다.

다만 이제는 가변 시간 간격을 알기때문에 렌더링 함수의 인수로 (가변 시간 간격 / 업데이트 시간 간격) 을 넘겨주어 값을 보간하여 렌더링한다.

보간의 결과가 틀릴수도 있지만 별로 눈에 띄지도 않고 보간을 하지 않아서 움직임이 튀는 것보다 훨씬 낫다.

 

 

디자인 결정

대부분의 경우 게임 루프를 만들일은 거의 없다.

프레임워크부터 만들어 나간다면 모를까 웹 브라우저는 루프를 따로 만들지 못하도록 막혀있고 엔진을 사용한다면 엔진이 제공하는 게임 루프를 사용할 가능성이 매우 높다.

무엇을 사용하던간에 장단이 존재하지만 게임루프는 어쨌든 반드시 사용한다는 점이 핵심이다.

 

전력 소모에 관해서 잠시 얘기하자면 PC 게임은 성능도 중요하지만 품질 역시 중요하기 때문에 시간이 남으면 FPS나 그래픽 품질을 더 높이는데에 시간을 사용하는 경우가 많아서 전력은 언제나 최대치를 사용하게 된다.

반대로 모바일 게임은 품질보다 성능이 중요하기 때문에 FPS를 제한한다.

 

마지막으로 앞서 언급한 예제들을 정리한다.

 

◾ 동기화 없는 고정 시간 간격 방식

루프를 제어하지 않고 최대한 빠르게 실행한다. 구현이 간단하지만 실행 환경마다 실행 속도가 다르다.

 

◾ 동기화하는 고정 시간 간격 방식

루프의 끝에 지연이나 동기화를 추가하여 너무 빠르게 실행되는 것을 막는다. 전력의 효율이 높지만 게임이 너무 느려질 수도 있다.

 

◾ 가변 시간 간격 방식

환경에 관계 없이 맞춰서 플레이가 가능하다. 하지만 오차로 인해 게임을 불안정하고 비결정적으로 만들어버린다.

 

◾ 업데이트는 고정 시간 간격, 렌더링은 가변 시간 간격

대체로 가장 좋지만 구현이 복잡하다. 특히 업데이트 시간 간격을 잘 조정해야 한다.

이중 버퍼 (Double Buffer)

 

여러 순차 작업의 결과를 한 번에 보여준다.

 

연산 결과를 순차적으로 출력하지 않고 렌더링같이 한번에 출력해야 하는 경우 사용된다.

코드가 프레임 버퍼에 값을 쓰는 동안에 비디오 디스플레이가 프레임 버퍼의 값을 읽어버리면 이미지의 일부만 출력되는 테어링이 발생하게 된다.

 

프레임 버퍼를 두개 준비하여 입력과 출력을 서로 번갈아가며 반복하면 테어링이 발생하지 않게된다.

 

 

패턴

버퍼 클래스가 현재 버퍼와 다음 버퍼, 총 2개의 버퍼를 가진다.

버퍼를 읽을때는 항상 현재 버퍼를 읽고 버퍼에 쓸때는 항상 다음 버퍼에 쓴다. 이를 계속 반복한다.

 

 

언제 쓸 것인가?

  • 순차적으로 변경해야 하는 상태가 있다.
  • 이 상태는 변경 도중에도 접근 가능해야 한다.
  • 외부 코드에서는 작업 중인 상태에 접근할 수 없어야 한다.
  • 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.

 

 

주의사항

버퍼의 교체 연산에는 짧더라도 시간이 걸리기때문에 교체 연산은 반드시 원자적이어야 한다.

대부분은 포인터만 교환하기 때문에 빠르지만 혹시라도 버퍼에 값을 쓰는 것보다 교환이 더 오래걸리면 이중 버퍼는 아무런 도움이 안된다.

 

또한 버퍼를 두개 사용하는 만큼 메모리를 더 사용하므로 메모리가 부족한 기기에서는 사용이 부담스러울 수 있다.

 

 

예제

class Framebuffer {
public:
    Framebuffer() { clear(); }
    void clear() {
        for (int i = 0; i < WIDTH * HEIGHT; ++i) {
            pixels_[i] = WHITE;
        }
    }
    void draw(int x, int y) {
        pixels_[(WIDTH * y) + x] = BLACK;
    }
    const char* getPixels() { return pixels_; }
    
private:
    static const int WIDTH = 160;
    static const int HEIGHT = 120;
    
    char pixels_[WIDTH * HEIGHT];
};


class Scene {
public:
    Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
    void draw() {
        next_->clear();
        next_->draw(1, 1);
        // ...
        next_->draw(4, 3);
        swap();
    }
    Framebuffer& getBuffer() { return *current_; }
    
private:
    void swap() {
        // 버퍼 포인터 교체
        Framebuffer* temp = current_;
        current_ = next_;
        next_ = temp;
    }
    
    Framebuffer buffers_[2];
    Framebuffer* current_;
    Framebuffer* next_;
};

 

그래픽스 외에 물리나 인공지능같이 객체가 서로 상호작용할 때 이중 버퍼를 사용하면 도움이 될 수 있다.

어떤 상태를 변경하는 코드가 일괄적으로 변경하려는 상태를 읽을 때 말이다.

 

 

디자인 결정

버퍼 교체 연산은 원자적으로 이루어져야 한다. 그에 따라서 버퍼 교체 연산 작업중에는 읽거나 쓰는 작업이 불가능해진다.

 

 

버퍼에 남아있는 데이터는 직전 프레임이 아닌 2프레임 직전의 데이터라는것을 이해해야 한다.

일반적으로는 버퍼를 정리하기 때문에 문제가 되지 않지만 모션블러같이 버퍼의 기존 데이터를 섞어서 사용하는 경우가 존재한다.

 

또한 버퍼가 하나의 큰 덩어리(그래픽스)인지, 혹은 객체의 컬렉션 안에 분산(인공지능)되어 있는지에 따라 버퍼링의 성능이 달라진다.

전자의 경우 서로 바꾸기만 하면 되기 때문에 속도가 매우 빠르다. 하지만 후자의 경우 전체 컬렉션을 순회하며 알려주어야 하기 때문에 훨씬 더 느리다. 다만 단순한 경우라면 순회하며 교환하는 대신, 정적 함수의 플래그 변수를 통해 버퍼의 인덱스만 수정하면 최적화가 가능하다.

 

class Actor {
public:
    static void init() { current_ = 0; }
    static void swap() { current_ = next(); }
    
    void slap() { slapped_[next()] = true; }
    bool wasSlapped() { return slapped_[current_]; }
    
private:
    static int current_;
    static int next() { return 1 - current_; }
    
    bool slapped_[2];
};

 

이런 식으로 말이다.

+ Recent posts