소년코딩

Sandbox Pattern, 샌드박스 패턴

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

슈퍼히어로 게임에서는 수십 개가 넘는 다양한 초능력을 선택할 수 있어야 한다.

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

초능력이 풍부한 게임을 제공하려면 Superpower를 상속받은 초능력 클래스에서 사운드, 시각 이펙트, AI와의 상호작용, 다른 게임 개체의 생성과 파괴, 물리 작용 같은 모든 일을 할 수 있어야 한다. 초능력 클래스는 온갖 코드를 건드리게 된다.

이런 식으로 초능력 클래스를 구현하기 시작하면 다음과 같은 문제점이 생긴다.

  • 중복 코드가 많아진다. 초능력은 다양하겠지만 여러 부분이 겹칠 가능성이 높다. 냉동 광선, 열 광선 모두 같은 코드다.

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

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

  • 모든 초틍력 클래스가 지켜야 할 불변식을 정의하기 어렵다. 사운드를 항상 큐를 통해 우선순위를 맞춘다고 할 떄, 수백개가 넘는 초능력 클래스가 사운드 엔진에 직접 접근한다면 이를 강제하기가 쉽지 않다.

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

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

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

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

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

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

즉, Superpower 클래스에 시간과 정성을 쏟으면 하위 클래스 모두가 그 혜택을 받을 수 있다.


패턴

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


언제 쓸 것인가?

클래스에 protected인 비-가상 함수가 있다면 샌드박스 패턴을 쓰고 있을 가능성이 높다.

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

예제 코드

굉장히 간단한 패턴이기 때문에 예제 코드도 간단하다.

Superpower 상위 클래스부터 보자.

class Supert {
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 메서드를 구현할 때 호출한다. move() 는 물리 코드를, playsound() 는 오디오 엔진 함수를 호출하는 식이다. Superpower 클래스에서만 다른 시스템에 접근하기 때문에 Superpower 안에 모든 커플링을 캡슐화 할 수 있다.

이제 방사능 거미를 꺼내 초능력을 부여해보자.

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

모든 초능력 클래스 코드가 단순히 사운드, 파이클 이펙트, 모션 조합만으로 되어 이다면 하위 클래스 샌드박스 패턴을 쓸 필요가 없다. 대신, 초능력 클래스에서 정해진 동작만 하도록 activate()를 구현해놓고, 초능력별로 다른 사운드 ID, 파티클 타입, 움직임을 사용하게 만들면 된다. 하지만 이런 건 모든 초능력이 본질적으로 동작은 같으면서 데이터가 다를 때만 가능하다. 코드를 좀 더 정교하게 만들어보자.

class Superpower {
protected:
    double getHeroX() { /* 코드... */ }
    double getHeroY() { /* 코드... */ }
    double getHeroZ() { /* 코드... */ }
    // More...
}

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

class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        if(getHeroZ() == 0) {
            // 땅이라면 공중으로 뛴다.
            playSound(SOUND_SPROING, 1.0f);
            spawnParticles(PARTICLE_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(PARTICLE_SPARKLES, 1);
            move(0, 0, -getHeroZ());
        }
    }
};

어떤 상태에 대해 접근할 수 있게 만들었기 때문에 샌드박스 메서드에서 실제적이고 흥미로운 제어 흐름을 만들 수 있게 되었다.


디자인

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

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

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

class Superpower {
protected:
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }

    // More...
};

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

class SoundPlayer {
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }
};

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

class Superpower {
protected:
    SoundPlayer& getSoundPlayer() {
        return soundPlayer_;
    }

    // More...

private:
    SoundPlayer soundPlayer_;
};

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

  • 상위 클래스이 메서드 개수를 줄일 수 있다.
  • 보조 클래스에 있는 코드가 유지보수하기 더 쉬운편이다.
  • 상위 클래스와 다른 시스템과의 커플링을 낮출 수 있다.

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

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

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

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

class Superpower {
public:
    Superpower(ParticleSystem* particles) : particles_(particles) {}
    // 샌드박스 메서드와 그 외 다른 기능들...

private:
    ParticleSystem* particles_;
};

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

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

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

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

| 2단계 초기화 |

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

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

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

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

Superpower* createSkyLaunch(ParticleSystem* 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 클래스)에서 가져온다.


More

  • 업데이트 메서드 패턴에서 업데이트 메서드는 흔히 샌드박스 메서드이기도 하다.
  • 이와 상반된 패턴이 GoF의 템플릿 메서드 패턴이다.
  • 이 패턴을 GoF의 파사드 패턴의 일종으로 볼 수도 있다.

이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/subclass-sandbox.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기