기록공간

11장. 행동 패턴 - 하위 클래스 샌드박스 본문

Game Design Pattern, Logic

11장. 행동 패턴 - 하위 클래스 샌드박스

입코딩 2020. 5. 17. 18:27
반응형
"상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다"

 

아이들을 누구나 슈퍼히어로가 되고 싶어 하지만, 안타깝지만 현실적으로 봤을 때 실현 불가능하다. 이런 아이들이 슈퍼히어로를 가장 그럴싸하게 체험할 수 있는 수단이 바로 게임이다. 기획자는 게임으로 뭐든 만들 수 있다고 생각하는 사람들이기 때문에, 슈퍼히어로 게임에서는 수십 개가 넘는 다양한 초능력을 선택할 수 있어야 한다. 

 

먼저 Superpower라는 상위 클래스를 만든 후에 초능력별로 이를 상속받는 클래스를 정의하려고 한다. 기획서를 나눠서 받은 프로그래머들이 구현을 마치고 나면 수십 개가 넘는 초능력 클래스가 만들어져 있을 것이다. 

 

유저가 어릴 때 꿈꿔왔던 어떤 초능력이라도 모두 쓸 수 있는 풍부한 게임 월드를 제공하고 싶다. 이를 위해서는 Superpower를 상속받은 초능력 클래스에서 사운드, 이펙트, AI와의 상호작용, 다른 게임 객체의 생성과 파괴, 물리 작용 등과 같은 모든 일을 할 수 있어야 한다. 그렇게 되면 온갖 코드를 건드려야 한다.

 

이런 식으로 개발팀이 초능력 클래스를 구현하기 시작하면 이런 일들이 벌어지게 될 것이다.

 

  • 중복 코드가 많아진다. 초능력은 다양하겠지만 여러 부분이 겹칠 가능성이 높다. 많은 초능력이 이펙트와 사운드를 같은 방식으로 출력한다. 만약 여러 광선을 만든다고 했을 때 만들어놓고 보면 서로가 비슷할 것이다. 

  • 거의 모든 게임 코드가 초능력 클래스와 커플링 된다. 초능력 클래스와 직접 엮일 의도가 전혀 없었던 하부 시스템을 바로 호출하도록 코드를 짤 것이다. 

  • 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다. 여러 초능력 클래스가 게임 내 다양한 코드와 커플링 되다 보니 이런 코드가 변경될 때 초능력 클래스에도 영향을 미친다. 

초능력 클래스를 구현하는 게임 플레이 프로그래머가 사용할 원시 명령 집합을 제공하는 게 좋겠다. 사운드를 출력하고 싶다면 playSound 함수를, 파티클을 보여주고 싶다면 spawnParticles 함수를 호출하면 된다. 초능력을 구현하는 데 필요한 모든 기능을 원시 명령이 제공하기 때문에 초능력 클래스가 이런저런 헤더를 include 하거나, 다른 코드를 찾아 헤매지 않아도 된다. 

 

이를 위해 원시명령을 Superpower의 protected 메서드로 만들어 모든 하위 초능력 클래스에서 쉽게 접근할 수 있게 한다. 원시 명령을 protected로 만드는 이유는 이들 함수가 하위 클래스용이라는 걸 알려주기 위해서다.

 

가지고 놀 수 있는 원시 명령을 준비하고 나면, 이를 사용할 공간을 제공해야 한다. 이를 위해 하위 클래스가 구현해야 하는 샌드박스 메서드를 순수 가상 메서드로 만들어 protected에 둔다. 이제 새로운 초능력 클래스를 구현하려면 다음과 같이 한다.

 

  1. Superpower를 상속받는 새로운 클래스를 만든다.

  2. 샌드박스 메서드인 activate()를 오버라이드 한다.

  3. Superpower 클래스가 제공하는 protected 메서드를 호출하여 activate()를 구현한다.

이렇게 상위 클래스가 제공하는 기능을 최대한 고수준 형태로 만듦으로써 중복 코드 문제를 해결할 수 있다. 여러 초능력 클래스에서 중복되는 코드가 있다면, 언제든지 Superpower 클래스로 옮겨서 하위 클래스에서 재사용할 수 있게 할 수 있다.

 

커플링 문제는 커플링을 한 곳으로 몰아서 해결했다. Superpower 클래스는 여러 게임 시스템과 커플링 된다. 하지만 수많은 하위 클래스는 상위 클래스와만 커플링 될 뿐 다른 코드와는 커플링 되지 않는다. 게임 시스템이 변경될 때 Superpower 클래스를 고치는 건 피할 수 없다 해도 나머지 많은 하위 클래스는 손대지 않아도 된다. 

 

하위 클래스 샌드박스 패턴을 쓰면 클래스 상속 구조가 얇게 퍼진다. 많은 클래스가 Superpower를 상위 클래스로 두기 때문에 코드 입장에서는 전략적 요충지를 확보할 수 있다. 즉 Superpower 클래스에 시간과 정성을 쏟으면 하위 클래스 모두가 그 혜택을 받을 수 있다.

패턴

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

 

언제 쓸 것인가?

하위 클래스 샌드박스 패턴은 굉장히 단순하고 일박적이라 게임이 아닌 곳에서도 알게 모르게 많이 사용되고 있다. 클래스에 protected인 비-가상 함수가 있다면 이 패턴을 쓰고 있을 가능성이 높다. 하위 클래스 샌드박스 패턴은 이럴 때 좋다.

 

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

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

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

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

주의사항

요즘은 여러 프로그래머 집단에서 '상속'을 나쁘게 생각한다. 그 이유 중 하나는 상위 클래스에 코드가 계속 쌓이는 경향이 있어서다. 특히 하위 클래스 샌드박스 패턴에서는 그럴 여지가 많다.

 

하위 클래스는 상위 클래스를 통해서 나머지 게임 코드에 접근하기 때문에 상위 클래스가 하위 클래스에서 접근해야 하는 모든 시스템과 커플링된다. 하위 클래스 역시 상위 클래스와 밀접하게 묶이게 된다. 이런 관계에서는 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽다. 소위 '깨지기 쉬운 상위 클래스' 문제에 빠지게 된다.

 

반대로 좋은 점은 커플링 대부분이 상위 클래스에 몰려 있기 때문에 하위 클래스를 나머지 코드와 깔끔하게 분리할 수 있다는 것이다. 이상적이라면 동작 대부분이 하위 클래스에 있을 것이다. 즉, 많은 코드가 격리되어 있어 유지 보수하기 쉽다.

 

그럼에도, 상위 클래스 코드가 거대한 스파게티 덩어리가 되어간다면 제공 기능 일부를 별도 클래스로 뽑아내 책임을 나눠 갖게 할 수도 있다. 이때는 컴포넌트 패턴이 도움이 될 것이다.

 

예제 코드

굉장히 간단한 패턴이기 때문에 예제 코드도 간단한 편이다. 패턴은 의도가 중요하지, 구현의 복잡성이 중요한 것은 아니다.

 

Superpower 상위 클래스부터 보자.

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) 
    {
        // 코드...
    }
};

activate()는 샌드박스 메서드이다. 순수 가상 함수로 만들었기 때문에 하위 클래스가 반드시 오버라이드 해야 한다. 덕분에 초능력 클래스를 구현하려는 개발자는 어디에 작업을 해야 할지를 분명하게 알 수 있다.

 

나머지 protected 메서드인 move, playSound, spawnParticles는 제공 기능이다. 하위 클래스에서 activate 메서드를 구현할 때 호출한다. 

 

여기서는 제공 기능을 따로 구현하지 않지만, 실제 프로젝트에서는 여기에 진짜 코드가 들어간다. Superpower 클래스는 제공 기능을 통해서 게임 내 다른 시스템에 접근한다. move()는 물리 코드를, playSound()는 오디오 엔진 함수를 호출하는 식이다. Superpower 클래스에서만 다른 시스템에 접근하기 때문에 Superpower안에 모든 커플링을 캡슐화할 수 있다.

 

예를 들어 히어로에게 초능력을 부여한다고 해보자.

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

 

점프 능력은 소리와 함께 바닥에 흙먼지를 남긴 채 슈퍼히어로를 하늘 높이 뛰어오르게 한다. 모든 초능력 클래스 코드가 단순히 사운드, 파티클 이펙트, 모션 조합만으로 되어 있다면 하위 클래스 샌드박스 패턴을 쓸 필요가 없다. 대신, 초능력 클래스에서는 정해진 동작만 하도록 activate()를 구현해놓고, 초능력별로 다른 사운드 ID, 파티클 타입, 움직임을 사용하게 만들면 된다. 하지만 이런 건 모든 초능력이 본질적으로 동작은 같으면서 데이터가 다를 때만 가능하다. 코드를 좀 더 정교하게 만들어보자.

class Superpower
{
protected:
    double getHeroX() { // 코드... }
    double getHeroY() { // 코드... }
    double getHeroZ() { // 코드... }
    // 나머지 코드...
};

히어로 위치를 얻을 수 있는 메서드를 몇 개 추가했다. 이제 SkyLaunch 클래스에서 이들 메서드를 사용할 수 있다.

class SkyLaunch : public Superpower
{
protected:
    virtual void activate()
    {
        if(getHeroZ() == 0) 
        {
            // 땅이라면 공중으로 뛴다.
            playSound(SOUND_SPROING, 1.f);
            spawnParticles(PARTICLE_DUST, 10);
            move(0, 0, 20);
        }
        else if(getHeroZ() < 10.f)
        {
            // 거의 땅에 도착했다면 이중 점프를 한다.
            playSound(SOUND_SWOOP, 1.f);
            move(0, 0, getHeroZ() - 20);
        }
        else
        {
            // 공중에 높이 떠 있다면 내려찍기 공격을 한다.
            playSound(SOUND_DIVE, 0.7f);
            spwanParticles(PARTICLE_SPARKLES, 1);
            move(0, 0, -getHeroZ());
        }
    }
};

어떤 상태에 대해 접근할 수 있게 만들었기 때문에 샌드박스 메서드에서 실제적이고 흥미로운 제어 흐름을 만들 수 있게 되었다. 예제에는 간단한 if문 몇 개만 사용했지만, 샌드박스 메서드에는 아무 코드나 넣을 수 있기 때문에 마음대로 구현할 수 있다.

 

디자인 결정

지금까지 본 것처럼 하위 클래스 샌드박스 패턴은 상당히 부드러운 패턴이다. 이런 패턴은 기본 아이디어는 제공하되 구체적인 부분은 많이 언급하지 않는다. 즉, 패턴을 적용할 때마다 흥미로운 선택 거리가 있다는 뜻이다. 어떤 결정을 내려야 하는지 보자.

 

어떤 기능을 제공해야 하는가?

가장 중요한 질문이다. 이걸 어떻게 하느냐에 따라 패턴의 느낌이나 사용성이 전혀 달라진다. 기능을 제공하는 정도에 따라 스펙트럼을 나눈다고 해보자. 기능을 적게 제공하는 방향 맨 끝에서는 상위 클래스에 제공 기능은 전혀 없고 샌드박스 메서드 하나만 달랑 들어 있다. 하위 클래스에서는 상위 클래스가 아닌 외부 시스템을 직접 호출해야 한다. 이걸 하위 클래스 샌드박스 패턴이라고 부를 수 있는지조차 의문이다.

 

하위 클래스가 필요로 하는 모든 기능을 상위 클래스에서 제공한다. 하위 클래스는 상위 클래스와만 커플링 될 뿐, 그 외 외부 시스템에는 전혀 접근하지 않는다.

 

제공 기능이 많을수록 하위 클래스는 외부 시스템과 적게 커플링 되겠지만 상위 클래스와는 더 많이 커플링 된다. 하위 클래스에 있던 커플링을 상위 클래스로 옮겨놓는 셈이다.

 

많은 하위 클래스가 일부 외부 시스템과 커플링 되어 있다면, 커플링을 상위 클래스의 제공 기능으로 옮김으로써 커플링을 상위 클래스 한 곳에 모아둘 수 있다는 장점이 있다. 하지만 그럴수록 상위 클래스는 커지고 유지 보수하기 어려워진다.

 

그렇다면 어느 정도가 적당할까? 일반적인 원칙은 다음과 같다.

 

  • 제공 기능을 몇 안 되는 하위 클래스에서만 사용한다면 별 이득이 없다. 모든 하위 클래스가 영향을 받는 상위 클래스의 복잡도는 증가하는 반면, 혜택을 받는 클래스는 몇 안되기 때문이다.

  • 다른 시스템의 함수를 호출할 때에도 그 함수가 상태를 변경하지 않는다면 크게 문제가 되지 않는다. 커플링은 생기겠지만 게임 내에서 다른 걸 망가뜨리지 않는다는 점에서 안전한 커플링이다.

  • 제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 있어봐야 좋을 게 없다. 그럴 때는 하위 클래스에서 외부 메서드를 직접 호출하는 게 더 깔끔할 수도 있다.

메서드를 직접 제공할 것인가? 이를 담고 있는 객체를 통해서 제공할 것인가?

하위 클래스 샌드박스 패턴의 골칫거리 중 하나는 상위 클래스 메서드 수가 끔찍하게 늘어난다는 점이다. 이들 메서드 일부를 다른 클래스로 옮기면 이런 문제를 완화할 수 있다. 상위 클래스의 제공 기능에서는 이들 객체를 반환하기만 하면 된다.

 

예를 들어 초능력을 쓸 때 사운드를 내기 위한 Superpower 클래스에 메서드를 직접 추가할 수 있다.

class Superpower
{
protected:
    void playSound(SoundId sound, double volume) { // 코드... }
    void stopSound(SoundId sound) { // 코드... }
    void setVolume(SoundId sound) { // 코드... }
    
    // 샌드박스 메서드와 그 외 다른 기능들....
};

하지만 Superpower 클래스가 이미 크고 복잡하다면 메서드를 이렇게 추가하고 싶진 않을 것이다. 대신 사운드 기능을 제공하는 SoundPlayer 클래스를 만들자.

class SoundPlayer
{
protected:
    void playSound(SoundId sound, double volume) { // 코드... }
    void stopSound(SoundId sound) { // 코드... }
    void setVolume(SoundId sound) { // 코드... }
    
    // 샌드박스 메서드와 그 외 다른 기능들....
};

다음으로 Superpower 클래스가 SoundPlayer 객체에 접근할 수 있게 한다.

class Superpower
{
protected:
    SoundPlayer& getSoundPlayer() { return soundPlayer_; }
    
    // 샌드박스 메서드와 그 외 다른 기능들...
    
private:
    SoundPlayer soundPlayer_;
};

이런 식으로 제공 기능을 보조 클래스로 옮기면 다음과 같은 이점이 있다.

 

  • 상위 클래스의 메서드 개수를 줄일 수 있다

  • 보조 클래스에 있는 코드가 유지 보수하기 더 쉬운 편이다. 상위 클래스가 자기를 의존하는 코드가 많다 보니 아무리 조심해도 변경하기가 쉽지 않다. 상위 클래스의 기능 일부를 커플링이 적은 보조 클래스로 옮기면 기능을 망치지 않고도 쉽게 고칠 수 있다.

  • 상위 클래스와 다른 시스템과의 커플링을 낮출 수 있다. 위처럼 SoundPlayer 클래스로 사운드 기능들을 옮기면 사운드와 관련된 모든 의존 관계를 SoundPlayer 클래스 하나에 전부 캡슐화할 수 있다.

상위 클래스는 필요한 객체를 어떻게 얻는가?

상위 클래스 멤버 변수 중에는 캡슐화하고 하위 클래스로부터 숨기고 싶은 데이터가 있을 수 있다. 처음 본 예제에서 Superpower 클래스의 제공 기능 중에 spawnParticles()가 있었다. 이 함수를 구현하기 위해서 파티클 시스템 객체가 필요하다면 어떻게 얻을 수 있을까?

 

| 상위 클래스를 생성자로 받기 |

상위 클래스의 생성자 인수로 받으면 가장 간단하다.

class Superpower
{
public:
    Superpower(ParticlesSystem* particles) : particles_(particles) {}
    // 그 외 기능들...
    
private:
    ParticleSystem* particles_;
};

이제 모든 초능력 클래스는 생성될 때 파티클 시스템 객체를 참조하도록 강제할 수 있다, 하지만 하위 클래스를 생각해보자.

class SkyLaunch : public Superpower
{
public:
    SkyLaunch(ParticlesSystem* particles) : Superpower(particles) {}
};

이런 문제가 있다. 모든 하위 클래스 생성자는 파티클 시스템을 인수로 받아서 상위 클래스 생성자에 전달해야 한다. 원치 않게 모든 하위 클래스에 상위 클래스의 상태가 노출된다. 

 

상위 클래스에 다른 상태를 추가하면 하위 클래스 생성자도 해당 상태를 전달하도록 전부 바꿔야 하기 때문에 유지 보수하기에도 좋지 않다.

 

| 2단계 초기화 |

초기화를 2단계로 나누면 생성자로 모든 상태를 전달하는 번거로움을 피할 수 있다. 생성자는 매개변수를 받지 않고 그냥 객체를 생성한다. 그 후에 상위 클래스 메서드를 따로 실행해 필요한 데이터를 제공한다.

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

SkyLaunch 클래스 생성자에는 인수가 없기 때문에 Superpower 클래스가 private으로 숨겨놓은 멤버 변수와 전혀 커플링 되지 않는다. 단, 까먹지 말고 init()를 호출해야 한다는 문제가 있다. 이걸 빼먹으면 초능력 인스턴스의 상태가 완전치 않아 제대로 작동하지 않을 것이다.

 

이런 문제는 객체 생성 과정 전체를 한 함수로 캡슐화하면 해결할 수 있다.

Superpower* createSkyLaunch(ParticlesSystem* particles) 
{
    Superpower* power = new SkyLaunch();
    power->init(particles);
    return power;
}

 

| 정적 객체로 만들기 |

앞에서는 초능력 인스턴스 별로 파티클 시스템 초기화를 했다. 모든 초능력 인스턴스가 별도의 파티클 객체를 필요로 한다면 말이 된다. 하지만 파티클 시스템이 싱글턴이라면 어차피 모든 초능력 인스턴스가 같은 상태를 공유할 거싱다.

 

이럴 때는 상태를 상위 클래스의 private 정적 멤버 변수로 만들 수 있다. 여전히 초기화는 필요하지만 인스턴스마다 하지 않고 초능력 클래스에서 한 번만 초기화하면 된다.

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

private:
    static ParticleSystem* particles_;
};

여기에서 init()와 particles_는 모두 정적이다. Superpower::init()를 미리 한 번 호출해놓으면 모든 초능력 인스턴스에서 같은 파티클 시스템에 접근할 수 있다. 하위 클래스 생성자만 호출하면 Superpower 인스턴스를 그냥 만들 수 있다. 

 

particles_가 정적 변수이기 때문에 초능력 인스턴스 별로 파티클 객체를 따로 저장하지 않아 메모리 사용량을 줄일 수 있다는 것도 장점이다.

 

|서비스 중개자를 이용하기|

앞에서는 상위 클래스가 필요로 하는 객체를 먼저 넣어주는 작업을 밖에서 잊지 말고 해줘야 했다. 즉, 초기화 부담을 외부 코드에 넘기고 있다. 만약 상위 클래스가 원하는 객체를 직접 가져올 수 있다면 스스로 초기화할 수 있다. 이런 방법 중 하나가 서비스 중개자 패턴이다.

class Superpower
{
protected:
    void spawnParticles(ParticleType type, int count)
    {
        ParticleSystem& particles = Locator::getParticles();
        particles.spawn(type, count);
    }
    // 그 외...
};

여기서 spawnParticles()는 필요로 하는 파티클 시스템 객체를 외부 코드에서 전달받지 않고 직접 서비스 중개자(Locator)에서 가져온다.


자료의 출처는 http://gameprogrammingpatterns.com/subclass-sandbox.html 입니다.

반응형
Comments