서비스
교육 철학

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

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

12.1 의도

상위 클래스가 제공하는 기능을 이용해 하위 클래스에서 행동을 정의합니다.

12.2 동기

누구나 어린 시절 슈퍼히어로가 되고 싶어 하지만 지구에는 우주 광선이 충분하지 않습니다. 아이들이 이를 가장 그럴듯하게 체험하는 수단은 게임입니다. 기획자는 게임이라면 무엇이든 만들 수 있다고 생각하므로, 우리가 만들 슈퍼히어로 게임에도 수십 가지의 초능력이 있어야 합니다.
먼저 Superpower라는 상위 클래스를 만들고 각 초능력이 이를 상속하게 하겠습니다. 프로그래머들이 기획서를 나누어 구현하고 나면 수십 개의 초능력 클래스가 생길 것입니다.
어릴 때 꿈꾼 어떤 초능력이든 쓸 수 있는 풍부한 월드를 만들려면, 각 초능력에서 사운드와 시각 효과, AI와의 상호작용, 다른 개체의 생성과 파괴, 물리 작용 등을 모두 다룰 수 있어야 합니다. 결국 초능력 클래스는 온갖 코드를 건드리게 됩니다.
원주 12-1: 이렇게 하위 클래스가 많아진다면 수많은 초능력을 하드코딩하기보다 데이터 기반 구현이 더 좋을 때가 많습니다. 타입 객체(13장), 바이트코드(11장), 인터프리터 같은 패턴도 참고하세요.
개발팀이 이런 방식으로 구현하면 어떤 일이 생길까요?
•
중복 코드가 많아집니다. 초능력이 달라도 겹치는 부분이 많습니다. 시각 효과와 사운드는 같은 방식으로 출력하는 경우가 많습니다. 냉동 광선, 열 광선, 심지어 머스터드 광선도 구현해 보면 비슷할 것입니다. 조율 없이 만들면 여러 사람이 같은 일을 반복하며 중복 코드를 쌓게 됩니다.
•
거의 모든 게임 코드가 초능력과 결합합니다. 자세히 살펴보지 않으면 초능력과 직접 연결하려고 하지 않았던 하부 시스템(subsystem)까지 호출하게 됩니다. 렌더러를 여러 계층으로 나누고 외부에서는 한 계층만 사용하게 설계했더라도, 초능력 구현이 끝나면 모든 계층을 중구난방으로 접근할 수 있습니다.
•
외부 시스템을 변경하면 초능력이 깨질 수 있습니다. 여러 초능력이 여러 시스템에 연결되어 있기 때문입니다. 그래픽, 오디오, UI 담당자 대부분은 게임플레이 프로그래머의 역할까지 떠맡고 싶지는 않습니다.
•
모든 초능력에 공통 불변식(invariant)을 적용하기 어렵습니다. 모든 사운드를 큐로 보내 적절한 우선순위로 재생하고 싶더라도, 수백 개 클래스가 사운드 엔진을 직접 호출한다면 강제하기 어렵습니다.
초능력을 구현하는 프로그래머에게 원시명령 집합을 제공하면 좋겠습니다. 소리를 내려면 playSound(), 파티클을 보여주려면 spawnParticles()를 호출합니다. 필요한 기능이 원시명령에 모두 있으므로 각 클래스가 여러 헤더를 포함하거나 다른 코드를 찾아 헤매지 않아도 됩니다.
이를 Superpower의 protected 메서드로 만들면 모든 하위 클래스가 쉽게 접근할 수 있습니다. protected이며 일반적으로 비가상 함수인 이유는 하위 클래스 전용 기능임을 분명하게 하기 위해서입니다.
가지고 놀 원시명령을 마련했으니 이를 사용할 공간도 필요합니다. 하위 클래스가 구현해야 할 샌드박스 메서드를 protected 순수 가상 함수로 둡니다. 새 초능력을 만드는 과정은 다음과 같습니다.
1.
Superpower를 상속하는 클래스를 만듭니다.
2.
샌드박스 메서드 activate()를 재정의합니다.
3.
상위 클래스의 protected 메서드를 조합해 activate()를 구현합니다.
상위 클래스의 기능을 최대한 고수준으로 제공하면 중복 코드를 줄일 수 있습니다. 여러 초능력에서 겹치는 코드는 언제든 Superpower로 옮겨 재사용할 수 있습니다.
결합 문제는 결합을 한곳에 모아 해결합니다. Superpower는 여러 게임 시스템과 결합하지만, 수많은 하위 클래스는 상위 클래스에만 의존합니다. 게임 시스템이 바뀌면 Superpower 수정은 피하지 못하더라도 나머지 많은 클래스는 건드리지 않아도 됩니다.
이 패턴은 얕고 넓은 상속 구조를 만듭니다. 많은 클래스가 같은 Superpower를 부모로 삼으므로 전략적인 요충지가 생깁니다. 이 한 클래스에 시간과 정성을 들이면 모든 하위 클래스가 혜택을 받습니다.
원주 12-2 · 옮긴이: 리팩터링에서는 이를 풀업(Pull Up)이라고 부릅니다.
원주 12-3: 상속을 비판하는 목소리가 커진 이유 중 하나는 상위·하위 클래스의 결합이 매우 강하기 때문입니다. 그래도 깊은 구조보다는 얕고 넓은 구조가 다루기 낫습니다.
💡
게임에서 활용하기: 점프와 광선은 서로 다른 행동이지만 ‘소리 재생’, ‘파티클 생성’, ‘이동’을 함께 사용합니다. 능력마다 오디오 엔진의 세부 API를 호출하면 엔진 변경 시 모든 능력을 수정해야 합니다. 공통 기능 뒤에 그 의존성을 모으면 능력 코드는 행동을 조합하는 작은 언어처럼 읽힙니다. 여기서 ‘샌드박스’는 설계상의 접근 경계를 뜻하며, 보안 격리를 자동 제공한다는 뜻은 아닙니다.
보충 도해. 하위 클래스는 상위 클래스의 공통 기능을 사용하고, 외부 시스템 접근은 그 안에 모읍니다

12.3 패턴

상위 클래스는 추상 샌드박스 메서드와 여러 제공 기능(provided operation)을 정의합니다. 제공 기능을 protected로 두어 하위 클래스용임을 분명히 합니다. 각 하위 클래스는 이 기능들을 이용해 샌드박스 메서드를 구현합니다.

12.4 언제 쓸 것인가?

단순하고 일반적인 패턴이라 게임 밖에서도 의식하지 못한 채 자주 사용합니다. 클래스에 protected 비가상 메서드가 있다면 이 패턴일 가능성이 높습니다. 특히 다음 상황에 좋습니다.
•
하나의 상위 클래스에 하위 클래스가 많습니다.
•
상위 클래스가 하위 클래스에 필요한 기능을 모두 제공할 수 있습니다.
•
하위 클래스의 행동이 많이 겹쳐 쉽게 공유하고 싶습니다.
•
하위 클래스끼리의 결합과, 하위 클래스가 나머지 코드와 맺는 결합을 줄이고 싶습니다.

12.5 주의사항

상속을 좋지 않게 보는 프로그래머가 많습니다. 상위 클래스에 코드가 계속 쌓이는 경향도 이유 중 하나입니다. 하위 클래스 샌드박스는 특히 그렇게 되기 쉽습니다.
하위 클래스가 상위 클래스를 통해 게임 전체에 접근하므로, 상위 클래스는 필요한 모든 시스템과 결합합니다. 하위 클래스도 상위 클래스와 밀접합니다. 이런 거미줄 같은 관계에서는 상위 클래스를 조금 바꿔도 어딘가가 깨지기 쉽습니다. 이른바 깨지기 쉬운 상위 클래스(fragile base class) 문제입니다.
반대로 결합이 상위 클래스에 모여 있으므로 하위 클래스는 나머지 코드와 깔끔하게 분리됩니다. 이상적인 경우 행동의 대부분은 하위 클래스에 있으므로 많은 코드가 격리되어 유지보수하기 쉽습니다.
그럼에도 상위 클래스가 거대한 스파게티 덩어리가 된다면 제공 기능 일부를 다른 클래스로 옮겨 책임을 나눌 수 있습니다. 컴포넌트 패턴이 도움이 됩니다.
원주 12-4 · 옮긴이: 『게임 엔진 아키텍처』(에이콘, 2013)에서는 상위 클래스로 코드가 몰리는 것을 ‘버블업 효과’라고 부릅니다.

12.6 예제 코드

패턴이 단순하므로 예제도 단순합니다. 그렇다고 유용하지 않다는 뜻은 아닙니다. 중요한 것은 의도이지 구현의 복잡함이 아닙니다. 먼저 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) { // 코드... } };
C++
복사
activate()는 샌드박스 메서드입니다. 순수 가상이므로 구체적인 하위 클래스는 반드시 구현해야 합니다. 초능력 개발자가 작업할 위치가 분명합니다.
나머지 protected 메서드인 move(), playSound(), spawnParticles()는 제공 기능입니다. 하위 클래스의 activate()가 호출합니다.
예제에는 구현을 생략했지만 실제 프로젝트에서는 여기에 코드가 들어갑니다. move()는 물리 코드를, playSound()는 오디오 엔진을 호출합니다. 다른 시스템을 오직 Superpower에서 접근하므로 그 결합을 이 클래스 안에 캡슐화할 수 있습니다.
이제 방사능 거미를 꺼내 초능력을 부여해 보겠습니다.
class SkyLaunch : public Superpower { protected: virtual void activate() { // 하늘로 뛰어오릅니다. playSound(SOUND_SPROING, 1.0f); spawnParticles(PARTICLE_DUST, 10); move(0, 0, 20); } };
C++
복사
점프 능력은 소리를 내고 바닥에 흙먼지를 남기면서 영웅을 하늘 높이 올립니다.
모든 초능력이 소리, 파티클, 이동의 단순한 조합뿐이라면 이 패턴까지 필요하지는 않습니다. 하나의 정해진 activate()를 두고 초능력별로 사운드 ID, 파티클 종류, 이동 데이터만 다르게 하면 됩니다. 그러나 이는 본질적인 동작은 같고 데이터만 다를 때 가능합니다. 조금 더 정교하게 만들어 보겠습니다.
원주 12-5: 점프가 대단한 초능력은 아닐지 몰라도 예제를 최대한 간단하게 만들고 싶었습니다.
class Superpower { protected: double getHeroX() { /* 코드... */ } double getHeroY() { /* 코드... */ } double getHeroZ() { /* 코드... */ } // 나머지 코드... };
C++
복사
영웅의 위치를 읽는 메서드를 추가했습니다. 이제 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()); } } };
C++
복사
상태를 읽을 수 있으므로 샌드박스 메서드에 실제적이고 흥미로운 제어 흐름이 생겼습니다. 예제는 간단한 if문만 사용했지만, 메서드 안에는 어떤 코드든 넣어 자유롭게 구현할 수 있습니다.
원주 12-6: 앞에서는 데이터 주도 방식을 권했지만 기능이 복잡해서 데이터 정의가 코드 구현보다 어렵다면 적합하지 않을 수 있습니다.
💡
원본 코드의 좌표 부호 주의: 책의 이중 점프 줄은 getHeroZ() - 20입니다. 원문을 그대로 남겼지만, 다른 두 분기처럼 move()가 상대 이동이고 +z가 위라면 이 값은 아래로 움직여 ‘이중 점프’ 주석과 맞지 않습니다. 높이 20으로 올리려는 의도라면 20 - getHeroZ()가 맞는지 확인해야 합니다. 실제 게임에서는 좌표가 정확히 0인지 비교하기보다 물리 시스템의 접지 상태를 사용하기도 합니다.

12.7 디자인 결정

이 패턴은 상당히 ‘부드러운’ 패턴입니다. 기본 아이디어를 제공하되 세부 구현을 강하게 정하지 않으므로 적용할 때마다 흥미로운 선택이 남습니다.
어떤 기능을 제공해야 하는가?
가장 중요한 질문입니다. 답에 따라 느낌과 사용성이 달라집니다. 제공 범위를 스펙트럼으로 생각해 보겠습니다. 한쪽 끝에는 제공 기능 없이 샌드박스 메서드 하나만 있는 상위 클래스가 있습니다. 하위 클래스는 외부 시스템을 모두 직접 호출합니다. 이를 샌드박스 패턴이라고 할 수 있을지조차 의문입니다.
반대쪽 끝에서는 필요한 모든 기능을 상위 클래스가 제공합니다. 하위 클래스는 부모에만 의존하고 다른 외부 시스템에는 전혀 접근하지 않습니다.
그 사이에서는 일부 기능은 상위 클래스가, 나머지는 직접 접근한 외부 시스템이 제공합니다. 제공 기능이 많으면 하위 클래스와 외부 시스템의 결합은 줄고 부모와의 결합은 늘어납니다. 결합을 상위 클래스로 옮기는 셈입니다.
여러 하위 클래스가 같은 외부 시스템에 의존한다면 이를 한곳에 모을 수 있다는 장점이 있습니다. 하지만 그만큼 상위 클래스는 커지고 유지보수하기 어려워집니다. 적당한 범위를 정할 때 다음 원칙을 참고하세요.
•
소수의 하위 클래스만 쓸 기능은 이득이 작습니다. 모두가 영향을 받는 부모의 복잡성은 늘지만 혜택은 소수만 받습니다. 일관성도 의미는 있으나, 특수한 하위 클래스가 직접 접근하는 편이 더 단순하고 명확할 수 있습니다.
•
상태를 바꾸지 않는 외부 함수 호출은 비교적 문제가 적습니다. 결합은 생겨도 다른 것을 망가뜨리지 않는다는 의미에서 ‘안전한’ 결합입니다. 반대로 외부 상태를 변경하는 호출은 더 강한 결합이라는 점을 의식해야 합니다. 눈에 잘 보이는 부모의 제공 기능으로 옮기는 편이 나을 수 있습니다.
•
단순 전달만 하는 제공 기능의 이득은 작습니다. 이런 경우 외부 메서드를 바로 호출하는 편이 더 깔끔할 수 있습니다. 다만 단순 전달에도 상태를 숨기는 장점은 있습니다.
예를 들어 다음 기능을 생각해 보겠습니다.
void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); }
C++
복사
단지 Superpower의 멤버 soundEngine_에 호출을 전달하지만, 하위 클래스가 그 객체에 함부로 접근하지 못하도록 캡슐화합니다.
원주 12-7: 모든 기능을 부모가 제공한다면, 하위 클래스 소스 파일의 #include는 부모 헤더 하나만 있어도 됩니다.
원주 12-8: 읽기도 항상 안전하지는 않습니다. 멀티스레드에서는 수정 중인 값을 읽어 깨진 데이터를 얻을 수 있습니다. 온라인 게임처럼 상태가 엄격히 결정적이어야 할 때도 까다롭습니다. 동기화되지 않은 상태를 읽으면 비결정적 버그가 생길 수 있습니다. 옮긴이는 NPC가 스폰될 때 모델보다 소리가 먼저 나오거나, 애니메이션보다 시각 효과가 먼저 보이는 상황 등을 예로 들었습니다.
메서드를 직접 제공할까, 객체로 묶어 제공할까?
상위 클래스의 메서드 수가 지나치게 늘어나는 것이 골칫거리입니다. 일부를 다른 클래스로 옮기고 상위 클래스는 그 객체를 반환하게 하면 완화할 수 있습니다.
예를 들어 사운드용 메서드를 Superpower에 직접 추가할 수 있습니다.
class Superpower { protected: void playSound(SoundId sound, double volume) { /* 코드... */ } void stopSound(SoundId sound) { /* 코드... */ } void setVolume(SoundId sound) { /* 코드... */ } // 샌드박스 메서드와 다른 기능들... };
C++
복사
하지만 이미 크고 복잡하다면 더 늘리고 싶지 않을 것입니다. 대신 사운드를 제공하는 SoundPlayer를 만듭니다.
class SoundPlayer { void playSound(SoundId sound, double volume) { /* 코드... */ } void stopSound(SoundId sound) { /* 코드... */ } void setVolume(SoundId sound) { /* 코드... */ } };
C++
복사
그다음 상위 클래스가 SoundPlayer에 접근하는 기능을 제공합니다.
class Superpower { protected: SoundPlayer& getSoundPlayer() { return soundPlayer_; } // 샌드박스 메서드와 다른 기능들... private: SoundPlayer soundPlayer_; };
C++
복사
이처럼 보조 클래스로 옮기면 다음 이점이 있습니다.
•
상위 클래스의 메서드 수가 줄어듭니다. 예제는 세 메서드를 게터 하나로 줄였습니다.
•
보조 클래스는 유지보수하기 더 쉽습니다. 핵심 상위 클래스에는 의존하는 코드가 많아 신중하게 수정해야 합니다. 일부 기능을 결합이 적은 보조 클래스로 옮기면 기존 기능을 망가뜨리지 않고 고치기 쉽습니다.
•
상위 클래스와 다른 시스템의 결합이 줄어듭니다. playSound()를 직접 두면 부모는 SoundId와 오디오 구현에 의존합니다. 이를 옮기면 사운드 의존성을 SoundPlayer에 모을 수 있습니다.
💡
C++ 접근 지정자 보충: PDF의 SoundPlayer 예제에는 public:이 빠져 있습니다. class의 기본 접근은 private이므로 실제로 getSoundPlayer().playSound(...)를 호출하려면 공개할 메서드 앞에 public:을 추가해야 합니다. setVolume(SoundId)도 인터페이스 구조만 보여주는 예이므로 실제 볼륨 값 전달 방식은 구현 때 정해야 합니다. 위 한 줄짜리 빈 본문은 닫는 중괄호가 주석에 들어가지 않도록 블록 주석으로 표기했습니다.
필요한 객체를 상위 클래스가 어떻게 얻는가?
상위 클래스에는 하위 클래스에서 숨기고 싶은 상태가 있을 수 있습니다. 예를 들어 spawnParticles()를 구현하는 데 파티클 시스템이 필요하다면 어디서 얻을까요?
상위 클래스 생성자로 받기:
가장 간단한 방법은 생성자 인수입니다.
class Superpower { public: Superpower(ParticleSystem* particles) : particles_(particles) {} // 샌드박스 메서드와 다른 기능들... private: ParticleSystem* particles_; };
C++
복사
모든 인스턴스가 생성 시 파티클 시스템을 받도록 강제할 수 있습니다. 하지만 하위 클래스도 보겠습니다.
class SkyLaunch : public Superpower { public: SkyLaunch(ParticleSystem* particles) : Superpower(particles) {} };
C++
복사
모든 하위 생성자가 파티클 시스템을 받아 부모에게 전달해야 합니다. 숨기고 싶던 부모의 상태가 원치 않게 모두 노출됩니다. 부모에 새 상태를 추가할 때마다 하위 생성자도 전부 바꿔야 하므로 유지보수에도 좋지 않습니다.
2단계 초기화:
생성자를 매개변수 없이 호출한 다음 부모의 메서드로 필요한 데이터를 제공하면 전달의 번거로움을 피할 수 있습니다.
Superpower* power = new SkyLaunch(); power->init(particles);
C++
복사
SkyLaunch 생성자는 부모의 비공개 멤버와 결합하지 않습니다. 단, init() 호출을 잊으면 객체가 불완전한 상태여서 제대로 동작하지 않습니다.
전체 생성 과정을 하나의 함수에 캡슐화하면 해결할 수 있습니다.
Superpower* createSkyLaunch(ParticleSystem* particles) { Superpower* power = new SkyLaunch(); power->init(particles); return power; }
C++
복사
원주 12-9: 생성자를 private로 두고 friend를 적절히 사용하면 createSkyLaunch()를 통해서만 생성하도록 보장할 수 있어 초기화 누락을 막습니다.
정적 객체로 만들기:
앞에서는 인스턴스마다 파티클 시스템을 초기화했습니다. 각자가 다른 시스템을 필요로 한다면 타당합니다. 하지만 파티클 시스템이 싱글턴이라 모두 같은 상태를 사용한다면 어떨까요?
부모의 private 정적 멤버로 둘 수 있습니다. 초기화는 여전히 필요하지만 인스턴스마다가 아니라 초능력 클래스에서 한 번만 하면 됩니다.
class Superpower { public: static void init(ParticleSystem* particles) { particles_ = particles; } // 샌드박스 메서드와 다른 기능들... private: static ParticleSystem* particles_; };
C++
복사
init()과 particles_ 모두 정적입니다. Superpower::init()을 한 번 호출해 두면 모든 초능력이 같은 시스템에 접근합니다. 그 뒤에는 하위 클래스 생성자만 호출하면 됩니다.
인스턴스마다 파티클 시스템 포인터를 따로 저장하지 않으므로 메모리도 줄어듭니다.
원주 12-10: 많은 객체가 같은 상태를 공유하므로 싱글턴의 여러 문제가 따라옵니다. private로 캡슐화하여 전역적으로 보이지 않는 것은 좋지만, 여러 초능력이 같은 객체를 건드리므로 코드를 이해하기는 더 어려워집니다.
서비스 중개자 이용하기:
앞의 방법은 외부 코드가 필요한 객체를 미리 넣어 주도록 초기화 부담을 넘깁니다. 부모가 직접 객체를 구하면 스스로 초기화할 수 있습니다. 서비스 중개자 패턴이 한 방법입니다.
class Superpower { protected: void spawnParticles(ParticleType type, int count) { ParticleSystem& particles = Locator::getParticles(); particles.spawn(type, count); } // 샌드박스 메서드와 다른 기능들... };
C++
복사
이제 spawnParticles()는 외부에서 전달받지 않고 Locator에서 직접 파티클 시스템을 얻습니다.
💡
선택의 기준: 생성자 전달은 의존성을 분명하게 하지만 하위 생성자까지 인수가 퍼집니다. 2단계 초기화는 생성 과정을 한곳에 묶어 누락을 막는 것이 중요합니다. 정적 멤버와 서비스 중개자는 사용이 간단해도 공유 상태와 초기화 순서를 신경 써야 합니다. 같은 게임의 테스트 월드 두 개가 서로 다른 파티클 시스템을 써야 한다면 인스턴스별 전달 방식이 더 자연스러울 수 있습니다.

12.8 관련 자료

•
업데이트 메서드 패턴에서 update()는 샌드박스 메서드이기도 한 경우가 많습니다.
•
GoF의 템플릿 메서드는 반대 방향의 패턴입니다. 둘 다 원시명령을 조합합니다. 샌드박스는 하위 클래스가 큰 동작을 구현하고 부모가 원시명령을 제공합니다. 템플릿 메서드는 상위 클래스가 큰 동작을 구현하고 하위 클래스가 원시명령을 구현합니다.
•
파사드 패턴의 일종으로 볼 수도 있습니다. 파사드는 여러 시스템을 단순한 API 뒤에 숨깁니다. 여기의 부모 클래스도 전체 게임 코드를 하위 클래스로부터 숨기는 파사드 역할을 합니다.
원주 12-11 · 옮긴이: 『GoF의 디자인 패턴』에서는 발음에 따라 ‘퍼사드 패턴’으로 표기했습니다(245쪽).