하위 클래스 샌드박스 (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단계 초기화든 정적 객체든 공통점은 해당 객체를 소유한다는 점인데, 필요한 객체를 소유하지 않고 서비스 중개자를 통해 원하는 객체를 직접 가져와서 사용하면 된다.

+ Recent posts