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

2024. 6. 27. 14:12Game Develop/Design Pattern

하위 클래스 샌드박스 패턴은 상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정희하는 것이다.

 

슈퍼히어로 게임을 만든다고 과정해보자. 유저의 다양한 경험을 위해 우리는 수십 개가 넘는 초능력을 구현해야한다

 

먼저 Superpower라는 상위 클래스를 만든 후 초능력별로 이를 상속받는 클래스를 정의해보자. 이렇게 하면 수십 개가 넘는 초능력 클래스가 만들어질 것이다. 또한 더욱더 풍부한 경험을 위해 Superpower를 상속받은 초능력 클래스에서 사운드, 시각 이펙트, AI와의 상호작용, 다른 게임 개체의 생성과 파괴, 물리 작용 같은 모든 일을 할 수 있어야 한다.

 

만약 이런 식으로 초능력 클래스를 구현하면 어떻게 될까?

  • 중복 코드가 많아진다. 초능력은 다양하겠지만 여러 부분이 겹칠 가능성이 높다. 즉 중복 코드가 다수 만들어져 헛고생할 확률이 높아진다.
  • 거의 모든 게임 코드가 초능력 클래스와 커플링된다. 하부시스템을 바로 호출하도록 코드를 짤 확률이 높다. 예를 들어 렌더러를 여러 계층으로 깔끔하게 나눠 놓고 정작 초능력 구현 부분에서 여러 렌더러 계층에 중구난방으로 접근 해놨을 가능성이 높아진다.
  • 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다. 많은 클래스들과 커플링 되어있기 때문에 수정이 굉장히 어렵다. 다른 부서의 코드와 충돌 및 혼란을 야기할 수 있다.
  • 모든 초능력 클래스가 지켜야할 불변식을 정의하기 어렵다. 초능력 클래스가 재생하는 모든 사운드를 항상 큐를 통해 적절히 우선순위를 맞추고 싶다고 가정하자. 수백 개가 넘는 초능력 클래스가 사운드 엔진에 접근하면 이를 강제하기 어렵다.

따라서 초능력 클래스를 구현하는 게임플레이 프로그래머가 사용할 원시 명령 집합을 제공해야한다. 사운드를 제공할 playSound나 파티클을 제공할 spawnParticles 함수 등과 같이 말이다. 초능력을 구현하는 데 필요한 모든 기능을 원시명령이 제공하기 대문에 초능력 클래스가 이런저런 헤더를 include하거나, 다른 코드를 찾아 헤매지 않아도 된다.

 

이를 위해선 protected 메서드로 만들어 모든 하위 초능력 크래스에서 쉽게 접근할 수 있게 해야하며, 이를 사용할 공간 또한 제공해야 한다.

하위 클래스가 구현해야 하는 샌드박스 메서드를 순수 가상 메서드로 만들어 protected에 둔다.

  1. Superpower를 상속받는 새로운 클래스를 만든다.
  2. 샌드박스 메서드인 activate()를 오버라이드한다.
  3. Superpower 클래스가 제공하는 protected 메서드를 호출하여 activate()를 구현한다.

이렇게 상위 클래스가 제공하는 기능을 고수준 형태로 만듦으로써 중복 코드 문제를 해결할 수 있다. 이렇게 하면 중복 코드를 Superpower 클래스로 옮겨서 하위 클래스에서 재사용할 수 있게 할 수 있다. 커플링 문제를 한곳으로 전부 몰아서 해결하는 식이다. 이렇게 하면 게임 시스템이 변경될 때 Superpower 클래스를 고치는 건 피할 수 없다 해도 나머지 많은 하위 클래스는 손대지 않아도 된다.

 

요약하자면 코드 입장에서는 전략적 요충지를 확보할 수 있으며 Superpower 클래스에 시간과 정성을 쏟으면 하위 클래스 모두가 그 혜택을 받을 수 있다.

 

 

앞서 말했듯이 상위 클래스는 추상 샌드박스 메서드와 여러 제공 기능을 정의한다. 제공 기능은 protected로 만들어져 하위 클래스용이라는 걸 분명히 한다. 이 패턴은 굉장히 단순하고 일반적이라 게임이 아닌 곳에서도 많이 쓰인다.

 

다음은 하위 클래스 샌드박스 패턴을 사용하기 적합한 경우다.

  • 클래스 하나에 하위 크래스가 많이 있다.
  • 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.
  • 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.
  • 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.

 

이렇게 단순하고 유용한 패턴이지만 단점이 없을 수는 없다. 대표적으로 '깨지기 쉬운 상위 클래스(fragile base class)' 문제로 상위 클래스가 하위 클래스에서 접근해야 하는 모든 시스템과 커플링 될 수 밖에 없기 때문에 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽기 때문이다. 뭐 좋게 말하자면 커플링 대부분이 상위 클래스에 몰려있기 때문에 하위 클래스를 나머지 코드와 완벽하게 분리할 수 있다는 소리이기도 하다. 즉 많은 코드가 격리되어 있어서 유지보수 하기 쉽다.

 

 

이제 코드를 한 번 쭉 살펴 보자.

SuperPower 클래스다

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

위 코드에서 activate() 메서드가 샌드박스 메서드다. 순수 가상 함수로 만들었기 때문에 하위 클래스가 반드시 오버라이드 해야한다. 나머지 메서드들은 하위 클래스에서 activate 메서드를 구현할 때 호출하면 된다. 이 메서드들에서 실제로 물리 코드, 오디오 엔진 함수를 호출하여 다른 시스템에 접근함으로써 모든 커플링을 캡슐화할 수 있다.

 

자 이제 초능력 클래스를 구현해보자.

class SkyLaunch : public Superpower{
protected:
    virtual void activate(){
        // 하늘로 튀어오른다.
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(ARPTICLE_DUST, 10);
        move(0, 0, 20);
    }
};

SkyLaunch 능력은 소리와 함께 바닥에 흙먼지를 일으키며 하늘 높이 뛰어오르게 한다. 사실 이렇게 만들어서 될 것 같으면 굳이 하위 클래스 샌드박스 패턴을 쓸 필요는 없다. 이 코드는 모든 초능력이 동작은 같으면서 사운드나 이펙트만 다를 경우에만 유효하다. 좀 더 만져보자.

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

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

class SkyLaunch : public Superpower{
protected:
    virtual void activate(){
        if(getHeroZ() == 0){
            // 하늘로 튀어오른다.
    	    playSound(SOUND_SPROING, 1.0f);
    	    spawnParticles(ARPTICLE_DUST, 10);
    	    move(0, 0, 20);
        }
        else if(getHeroZ() < 10.0f){
            // 거의 땅에 도착했다면 이중 점프를 한다.
            playSound(SOUND_SWOOP, 1.0f);
    	    move(0, 0, getHeroZ() - 20);
        }
        else{
            // 공중에 높이 떠있다면 내려찍기 공격을 한다
        	playSound(SOUND_DIVE, 0.7f);
    	    spawnParticles(ARPTICLE_SPARKLES, 1);
    	    move(0, 0, -getHeroZ());
        }
    }
};

위에는 if문 몇 개만 사용했지만 아무 코드나 다 넣을 수 있기 때문에 훨씬 정교하게 개발할 수 있다.

 

정리를 해보자면 하위 클래스 샌드박스 패턴의 핵심은 하위 클래스에 있던 커플링을 상위 클래스로 옮겨놓음이다.

 

  • 제공 기능을 몇 안되는 하위 클래스에서만 사용한다면 별 이득이 없다. 하위 클래스가 많으면 많을 수록 편리할 수 있다
  • 다른 시스템의 함수를 호출할 때에도 그 함수가 상태를 변경하지만 않는다면 큰 문제는 없다. 하지만 상태를 변경하는 함수를 호출해야 한다면 더 밀접한 결합도를 가지기 때문에 상위 클래스로 옮기는 편이 나을 수도 있다.
  • 제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 있어봐야 좋을 게 없다. 이럴 땐 직접 하위 클래스에서 외부 메서드를 호출하는게 더 깔끔하다.

 

하위 클래스 샌드박스 패턴의 가장 큰 골칫거리 중 하나는 상위 클래스에서 정의할 메서드가 끔찍하게 늘어난다는 점이다.

이를 완화시키기 위해 우리는 다른 클래스로 옮기는 방법이 있다. 예를 들어 우리가 위에서 만들었던 Superpower 클래스의 playSound() 메서드 외에 다른 사운드 메서드를 추가하고 싶다고 해보자.

class SuperPower{
protected:
    void playSound(SuondId sound, double volume){ // 코드... }
    void stopSound(SuondId sound){ // 코드... }
    void setVolume(SuondId sound){ // 코드... }
};

Superpower 클래스에 이런식으로 계속 메서드를 추가하다가는 이 클래스는 점점 크고 복잡해져 가독성이 상당히 떨어질 뿐더러 각종 오류에 휩싸이기 쉬워진다. 대신 사운드 기능을 제공하는 SoundPlayer 클래스를 만들자.

class SoundPlayer{
protected:
    void playSound(SuondId sound, double volume){ // 코드... }
    void stopSound(SuondId sound){ // 코드... }
    void setVolume(SuondId sound){ // 코드... }
};

 

 

이렇게 해주고 단순히 Superpower 클래스가 SoundPlayer 클래스에 접근만 하게 해주면 끝이다.

class SuperPower{
protected:
    SoundPlayer& getSoundPlayer(){
        return soundPlayer_;
    }
    
private:
    SoundPlayer soundPLayer_;
};

 

위와 같이 제공 기능을 보조 클래스로 옮겨놓으면 다음과 같은 이점이 있다.

  • 상위 클래스의 메서드 개수를 줄일 수 있다. 3개에서 클래스 호출 하나로 줄였다.
  • 보조 클래스에 있는 코드가 유지보수하기 더 쉽다. 쉽게 말해서 필기구를 모아놓은 상자에 볼펜이 들어갈 자리를 하나 만들어 놓고 거기에 볼펜만 채운 꼴이다.
  • 상위 클래스와 다른 시스템과의 커플링을 낮출 수 있다. 게임 엔진의 오디오 코드를 SuperPower 클래스가 아닌 SoundPlayer 클래스에 커플링이 일어나게 함으로써 소리에 관련된 코드를 클래스 하나에서 전부 관리할 수 있다.