디자인패턴

하위 클래스 샌드박스 패턴

띠애모 2024. 5. 5. 20:33

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

 

캐릭터 스킬 클래스를 만든다고 하자.  게임에는 여러 캐릭터들이 있고 그 캐릭터 클래스들의 스킬들을 재각각 개발한다면 수십개의 클래스가 만들어질것이고 관리하기에 까다롭고 여러 중복코드가 있게 되기 마련이다. 스킬클래스에서 공통적으로 사용 할 수 있는 부분들을 상위 클래스에서 추상클래스로 다뤄사용한다면 코드의 중복성도 줄어들고 코드의 결합도 또한 낮아지게 된다. 또 유지보수의 용이성도 있다. 이러한 방법중 하위 클래스 샌드박스 패턴이 있다.

 

예를들어 초능력 클래스를 만드는데 디자인 패턴을 고려하지 않고 무분별하게 클래스를 만든다면 다음과 같은 문제점이 생긴다.

  • 중복 코드가 많아진다. 초능력은 다양하겠지만 여러 부분이 겹칠 가능성이 높다. 냉동 광선, 열 광선 모두 같은 코드다.
  • 거의 모든 게임 코드가 초능력 클래스와 커플링 된다. 초능력 클래스와 직접 엮일 의도가 전혀 없었던 하부시스템을 바로 호출하도록 코드를 짤 수도있다.
  • 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다. 여러 초능력 클래스가 게임 내 다양한 코드와 커플링되다 보니 이런 코드가 변경될 때 초능력 클래스에도 영향을 미친다.
  • 모든 초틍력 클래스가 지켜야 할 불변식을 정의하기 어렵다. 사운드를 항상 큐를 통해 우선순위를 맞춘다고 할 떄, 수백개가 넘는 초능력 클래스가 사운드 엔진에 직접 접근한다면 이를 강제하기가 쉽지 않다.

 

초능력 클래스를 구현한다고 하자.

능력을 사용할 때 사운드를 출력하고 싶다면 PlaySound를 파티클을 재생하고 싶다면 spawnParticle 함수를 호출하면 된다.

 

이를 위해 하위 클래스가 구현해야 하는 샌드박스 메서드를 순수 가상 메서드로 만들어 protected에 둔다. 이제 새로운 초능력 클래스를 구현하려면 다음과 같이 한다.

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

하위 클래스 샌드박스 패턴은 다음과 같은 특징들이 있을 때 사용하기 좋다.

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

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

 

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

 

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

 

 

다음은 하위 클래스 샌드박스 패턴을 사용하는 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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) 
    {
        // 코드...
    }
};
cs

 

activate()는 샌드박스 메서드다. 순수 가상 함수로 만들었기 때문에 하위 클래스가 반드시 오버라이드해야 한다.

나머지 protected 메서드인 move, playSound, spawnParticles는 제공 기능이다. 하위 클래스에서 activate 메서드를 구현할 때 호출한다. move() 는 물리 코드를, playsound() 는 오디오 엔진 함수를 호출하는 식이다. Superpower 클래스에서만 다른 시스템에 접근하기 때문에 Superpower 안에 모든 커플링을 캡슐화 할 수 있다.

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

 

1
2
3
4
5
6
7
8
9
class SkyLaunch : public Superpower {
protected
    virtual void activate() {
        // 하늘로 뛰어오른다.
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(PARTICLE_DUST, 10);
        move(0020);
    }
};
cs

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

1
2
3
4
5
6
7
class Superpower {
protected:
    double getHeroX() { /* 코드... */ }
    double getHeroY() { /* 코드... */ }
    double getHeroZ() { /* 코드... */ }
    // More...
}
cs

 

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        if(getHeroZ() == 0) {
            // 땅이라면 공중으로 뛴다.
            playSound(SOUND_SPROING, 1.0f);
            spawnParticles(PARTICLE_DUST, 10);
            move(0020);
        }
        else if(getHeroZ() < 10.0f) {
            // 거의 땅에 도착했다면 이중 점프를 한다.
            playSound(SOUND_SWOOP, 1.0f);
            move(00, getHeroZ() - 20);
        }
        else {
            // 공중에 높이 떠 있다면 내려찍기 공격을 한다.
            playSound(Sound_DIVE, 0.7f);
            spawnParticles(PARTICLE_SPARKLES, 1);
            move(00-getHeroZ());
        }
    }
};
cs

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

 

디자인

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

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

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

 

1
2
3
4
5
6
7
8
class Superpower {
protected:
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }
 
    // More...
};
cs

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

 

 

1
2
3
4
5
class SoundPlayer {
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }
};
cs

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

 

 

1
2
3
4
5
6
7
8
9
10
11
class Superpower {
protected:
    SoundPlayer& getSoundPlayer() {
        return soundPlayer_;
    }
 
    // More...
 
private:
    SoundPlayer soundPlayer_;
};
cs

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

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

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

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

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

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

 

1
2
3
4
5
6
7
8
class Superpower {
public:
    Superpower(ParticleSystem* particles) : particles_(particles) {}
    // 샌드박스 메서드와 그 외 다른 기능들...
 
private:
    ParticleSystem* particles_;
};
cs

 

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

 

1
2
3
4
class SkyLaunch : public Superpower {
public:
    SkyLaunch(ParticleSystem* particles) : Superpower(particles) {}
};
cs

 

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

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

| 2단계 초기화 |

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

 

 

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

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

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

 

 

1
2
3
4
5
Superpower* createSkyLaunch(ParticleSystem* particles) {
    Superpower* power = new SkyLaunch();
    power->init(particles);
    return power;
}
cs

 

 

 

| 정적 객체로 만들기 |

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

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

1
2
3
4
5
Superpower* createSkyLaunch(ParticleSystem* particles) {
    Superpower* power = new SkyLaunch();
    power->init(particles);
    return power;
}
cs

 

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

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

 

 

 

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

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

 

1
2
3
4
5
6
7
8
9
class Superpower {
protected:
    void spawnParticles(ParticleType type, int count) {
        ParticleSystem& particles = Locator::getParticles();
        particles.spawn(type, count);
    }
 
    // 샌드박스 메서드와 그 외 다른 기능들...
};
cs

 

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


More

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