목적은 상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의하는 것이다.
알아보자
아이들은 누구나 슈퍼히어로가 되고 싶어 하지만, 지구에는 우주 광선이 공급 부족 상태다.
이런 아이들이 슈퍼히어로를 가장 그럴싸하게 체험할 수 있는 수단이 바로 게임이다.
기획자는 게임으로 뭐든 만들 수 있다고 생각하는 사람들이기 때문에
우리가 만들 슈퍼히어로 게임에서는 수십 가지가 넘는 초능력을 선택할 수 있다.
먼저 Superpower라는 상위 클래스를 만든 후에 초능력별로 이를 상속받는 클래스를 정의하려한다.
기획서를 나눠서 받은 프로그래머들이 구현을 마치고 나면
수십 개가 넘는 초능력 클래스가 만들어져 있을 것이다.
어떠한 초능력이라도 다 쓸 수 있는 풍부한 게임 월드를 제공하고 싶다.
이를 위해서는 SuperPower 클래스를 상속받은 초능력 클래스에서
사운드, 시각효과, AI와의 상호작용, 다른 게임 개체의 생성과 파괴, 물리작용 등등
모든 일을 할 수 있어야 한다.
결국 이러한 Superpower 클래스는 온갖 코드를 건드리게 된다.
이런 식으로 구현한다면 문제가 생기는데
- 중복코드가 많아짐
초능력이 다양할지 몰라도 겹치는 부분도 다양하게 나타날 것이다.
많은 초능력이 시각 이펙트와 사운드가 동일하고
프로그래머들간의 특별한 협의 없이 구현한다면 중복해서 구현하는 경우가 다수가 될 것이다.
-거의 모든 게임 코드가 이 클래스와 커플링됨
렌더러를 여러 계층으로 깔끔하게 나눠놓고, 밖에서는 그 중 한 계층으로만 접근하는 방식을 쓴다고 하면
초능력 클래스를 구현한 후 렌더러 계층에 중구난방식으로 접근해놓을 가능성이 높아진다.
-외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다.
여러 초능력 클래스가 게임 내 다양한 코드와 커플링되다 보니 이런 코드가 변경될 때 초능력 클래스에도 영향을 미친다. 그래픽, 오디오, UI 프로그래머들은 대부분 게임플레이 프로그래머 역할까지 맡는 것을 원치 않기 때문에 이러면 안된다.
-모든 Superpower 클래스가 지켜야 할 불변식(invariant)를 정의하기 어렵다.
초능력 클래스가 재생하는 모든 사운드를 항상 큐를 통해 적절히 우선순위를 맞추고 싶다면
수백 개가 넘는 초능력 클래스가 사운드 엔진에 직접 접근하게 해서 구현하는 것은 어렵다.
다시 말해서
초능력 클래스는 구현하는 게임플레이 프로그래머가 사용할 원시명령 집합을 제공하는 것이 좋다.
사운드를 출력한다면 playSound() 함수를, 파티클 효과에서는 spawnParticles()를 말이다.
초능력을 구현하는 데 필요한 모든 기능을 원시명령이 제공하기 때문에
초능력 클래스가 이런 저런 헤더를 include하거나, 다른 코드를 찾지 않아도 된다.
이를 위해 원시명령을 Superpower의 Protected 메서드로 만들어
하위 클래스에게만 접근이 가능하도록 구현한다.
원시명령을 protected로 만드는 이유는 이들 함수가 하위 클래스용이라는 것을 알려주기 위해서다.
가지고 놀 수 있는 원시명령을 준비하면 이를 사용할 공간을 제공해야 한다.
이를 위해 하위 클래스가 구현해야 하는 샌드박스 메서드를 순수 가상 메서드로 만들어 protected에 둔다.
이제 새로운 초능력을 만들고 싶다면 3가지 단계를 거치면 된다.
1. Superpower를 상속받는 새로운 클래스를 생성한다.
2. 샌드박스 메서드인 activate()를 오버라이드한다.
3. Superpower 클래스가 제공하는 protected 메서드를 호출하여 activate()를 구현한다.
이렇게 상위 클래스가 제공하는 기능을 최대한 고수준 형태를 만듦으로써 중복 코드 문제를 해결할 수 있다.
여러 초능력 클래스에서 중복되는 코드가 있다면, 언제든지 Superpower 클래스로 옮겨서 하위 클래스에서 재사용할 수 있게 한다.
커플링 문제는 커플링을 한곳으로 몰아서 해결했다.
Superpower 클래스는 여러 게임 시스템과 커플링된다.
하지만 수많은 하위 클래스는 상위 클래스와만 커플링될 뿐 다른 코드와는 커플링되지 않는다.
게임 시스템이 변경될 때 Superpower 클래스를 고치는 건 피할 수 없다해도
나머지 많은 하위 클래스는 손대지 않는다.
하위 클래스 샌드박스 패턴을 쓰면 클래스 상속 구조가 얇게 퍼진다.
많은 클래스가 Superpower를 상위 클래스로 두기 때문에 코드 입장에서는 전략적 요충지를 확보할 수 있다.
즉, Superpower 클래스에 시간과 정성을 쏟으면 하위 클래스 모두가 그 혜택을 받을 수 있다.
상위 클래스는 추상 샌드박스 메서드와 여러 제공기능을 정의한다.
제공 기능은 protected로 만들어져 하위 클래스용이라는 걸 분명히 한다.
각 하위 클래스는 제공 기능을 이용해 샌드박스 메서드를 구현한다.
하위 클래스 샌드박스 패턴은 굉장히 단순하고 일반적이라
게임이 아닌 곳에서도 알게 모르게 많이 사용하고 있다.
클래스에 protected인 비-가상 함수가 있다면 이 패턴을 쓰고 있을 가능성이 높다.
하위 클래스 샌드박스 패턴은 이럴 때 쓴다.
-클래스 하나에 하위 클래스가 많이 있다.
-상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.
-하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.
-하위 클래스들의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.
요즘은 "상속"을 나쁘게 생각하기도 하는데
그 이유 중 하나는 상위 클래스에 코드가 계속 쌓이는 경향이 있어서 그렇다.
특히 하위 클래스 샌드박스 패턴에서는 그럴 여지가 많다.
하위 클래스는 상위 클래스를 통해서 나머지 게임 코드에 접근하기 때문에
상위 클래스가 하위 클래스에서 접근해야 하는 모든 시스템과 커플링된다.
하위 클래스 역시 상위 클래스와 밀접하게 묶이게 된다.
이런 거미줄 같은 커플링 관계에서는 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽다.
소위 "깨지기 쉬운 상위 클래스" 문제에 빠지게 된다.
반대로 좋은 점은 커플링 대부분이 상위 클래스에 몰려 있기 때문에
하위 클래스를 나머지 코드와 깔끔하게 분리할 수 있다는 것이다.
이상적이라면 동작 대부분이 하위 클래스에 있을 것이다.
즉, 많은 코드가 격리되어 있어 유지보수하기 쉽다.
그럼에도, 상위 클래스 코드가 거대한 스파게티 덩어리가 된다면
제공 기능 일부를 별도 클래스로 뽑아내 책임을 나눠갖게 할 수도 있다.
지금까지 말로만 설명했지만 예제로 알아보자
한 번 Superpower 클래스를 보자
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// Code here...
}
void playSound(SoundId sound, double volume)
{
// Code here...
}
void spawnParticles(ParticleType type, int count)
{
// Code here...
}
};
activate()는 샌드박스 메서드다.
순수 가상 함수로 만들었기 때문에 하위 클래스가 반드시 오버라이드해야 한다.
덕분에 초능력 클래스를 구현하려는 개발자는 어디에 작업을 해야 할지를 분명히 알 수 있다.
나머지 protected 메서드인 move, playSound, spawnParticles는 제공 기능이다.
하위 클래스에서 activate 메서드를 구현할 때 호출한다.
예제에서는 제공 기능을 따로 구현하지 않지만, 실제 프로젝트에서는 여기에 진짜 코드가 들어간다.
Superpower 클래스는 제공 기능을 통해서 게임 내 다른 시스템에 접근한다.
move()는 물리 코드를, playSound()는 오디오 엔진 함수를 호출하는 식이다.
Superpower 클래스에서만 다른 시스템에 접근하기 때문에
Superpower 안에 모든 커플링을 캡슐화할 수 있다.
그럼 방사는 거미를 꺼내 초능력을 부여해보자.
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// Spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
점프 능력은 소리와 함께 바닥에 흙먼지를 남긴 채 슈퍼히어로를 하늘 높이 뛰어오르게 한다.
모든 초능력 클래스 코드가 단순히 사운드, 파티클 이펙트, 모션 조합만으로 되어 있다면 하위 클래스 샌드박스 패턴을 쓸 필요가 없다.
대신, 초능력 클래스에서는 정해진 동작만 하도록 activate()를 구현해놓고
초능력별로 다른 사운드 ID, 파티클 타입, 움직임을 사용하게 만들면 된다.
하지만 이런 건 모든 초능력이 본질적으로 동작은 같으면서 데이터가 다를 때만 가능하다.
조금 다듬어보자
class Superpower
{
protected:
double getHeroX()
{
// Code here...
}
double getHeroY()
{
// Code here...
}
double getHeroZ()
{
// Code here...
}
// Existing stuff...
};
히어로 위치를 얻을 수 있는 메서드를 몇 개 추가했다.
이제 SkyLaunch 클래스에서 이들 메서드를 사용할 수 있다.
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// On the ground, so spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
else if (getHeroZ() < 10.0f)
{
// Near the ground, so do a double jump.
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() + 20);
}
else
{
// Way up in the air, so do a dive attack.
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
어떤 상태에 대해 접근할 수 있게 만들었기 때문에
샌드박스 메서드에서 실제적이고 흥미로운 제어 흐름을 만들 수 있게 되었다.
예제에는 간단한 if 문 몇 개만 사용했지만,
샌드박스 메서드에는 아무코드나 넣을 수 있기 때문에 마음대로 구현할 수 있다.
위에서 본 것과 같이 하위 클래스 샌드박스 패턴은 유연한 것으로 보인다.
이렇게 유연하다면 어떻게 구현해야 하는가? 에 대한 선택의 폭이 넓다는 것이다.
그렇다면 어떻게 구현해야 하는가?
즉, 어떠한 기능을 제공하게끔 해야 하는가? 에 대한 질문에 도달한다.
** 어떠한 선택을 하더라도 샌드박스 패턴을 쓰는 이유가 무엇이냐는 말이다.
가장 중요한 질문으로
이 질문에 대한 답을 어떻게 하느냐에 따라 패턴의 느낌이나 사용성이 달라진다.
기능을 제공하는 정도에 따라 스펙트럼을 나눈다고 하자.
기능을 적게 제공하게끔 구현한 클래스에서는
상위 클래스에 제공 기능은 전혀 없고 샌드박스 메서드 달랑 하나만 들어있다.
하위 클래스에서는 상위 클래스가 아닌 외부 시스템을 직접 호출해야 한다.
.. 그렇다. 앞서 설명한 샌드박스 패턴이 맞는지 의문이다.
반대로 하위 클래스가 필요로 하는 모든 기능을 상위 클래스에서 제공하는 클래스에서는
하위 클래스와 상위 클래스에서만 커플링이 발생할 뿐
외부 시스템에는 접근하지 않는다.
흠.. 즉, 기능을 제공할수록
상위 클래스와 의 커플링은 올라가고
외부시스템과의 커플링은 내려간다.
즉.. 중간의 어느정도 타협이 필요할 것이다.
그렇다면 어느 정도가 적당한가? 라는 질문이 나온다.
우선 몇 가지 생각해보자
-제공 기능을 몇 안되는 하위 클래스에서만 사용한다면 별 이득이 없다.
모든 하위 클래스가 영향을 받는 상위 클래스의 복잡도만 증가할 뿐 그 혜택은 하위 클래스 몇 개만 받기 때문이다.
-다른 시스템의 함수를 호출할 때에도 그 함수가 상태를 변경하지 않는다면 크게 문제가 되진 않는다.
커플링이야 생기겠지만 게임 내에서 다른 걸 망가뜨리지는 않아서.. "안전한" 커플링이라고도 한다.
-제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 있어봐야 좋을 것이 없다.
그럴 때는 하위 클래스에서 외부 메서드를 직접 호출하는 것이 더 나을 수 있다.
위와 같은 것을 생각하면서
사용자가 기능과 외부 시스템과의 커플링을 적절히 조절할 필요가 있다.
그 다음에 생각해볼 문제는
메서드를 직접 제공할 것인가..? 그렇지 않으면 객체를 이용해서 제공할 것인가?
의 문제다.
하위 클래스 샌드박스 패턴의 골칫거리 중 하나는
상위 클래스의 메서드 수가 징그럽게도 많아지는 것이다.
이들 메서드 일부를 다른 클래스로 옮기면 이런 문제를 완화할 수 있다.
상위 클래스의 제공 기능에서는 이들 객체를 반환하기만 하면 된다.
예를 들어서 초능력을 쓸 때 사운드를 내기 위해
Superpower 클래스에 메서드를 직접 추가할 수 있다.
class Superpower
{
protected:
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
// Sandbox method and other operations...
};
하지만 이미 Superpower 클래스가 충분히 복잡하다면
메서드를 또 이렇게 추가하고 싶지는 않다.
다만 사운드 기능을 제공하는 SoundPlayer 클래스를 만들 수도 있다.
class SoundPlayer
{
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
};
다음으로 Superpower 클래스가 이 객체에 접근하는 것이다.
class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{
return soundPlayer_;
}
// Sandbox method and other operations...
private:
SoundPlayer soundPlayer_;
};
이런 식으로 제공 기능을 따로 다른 보조 클래스에 옮겨놓을 수 있다.
이렇게 구혀나면 이점이 몇 가지 있다.
1. 상위 클래스의 복잡도를 줄일 수 있음.
2. 보조 클래스에 있는 코드가 유지보수가 더 쉬움(커플링이 적음)
3. 상위 클래스와 다른 시스템 간의 커플링도 낮출 수 있음.
뭐 샌드박스 패턴은 솔직히 알게 모르게 쓰고 있다.
상위 클래스에 몰아 넣는.. 그건 왠지 모르지만 편해서 쓰고 있긴 하다.
하지만 디자인 패턴을 공부하는 이유는
왠지 모르지만 쓰고 있는 것들에 대한 공부이므로
이번에도 잘 알아갑니다~
'Game Development, 게임개발 > 디자인패턴' 카테고리의 다른 글
Service Locator, 서비스 중개자 [디자인패턴](디커플링) (0) | 2021.10.20 |
---|---|
Object Pool, 오브젝트 풀, 객체 풀 [디자인패턴](최적화) (0) | 2021.10.17 |
Bytecode, 바이트코드 [디자인패턴](행동) (0) | 2021.10.12 |
Update Method, 업데이트 메서드 ** [디자인패턴] (0) | 2021.10.09 |
Game Loop Pattern, 게임 루프 패턴 ** [디자인패턴] (0) | 2021.10.07 |