구조는 같고 누가 바꾸느냐가 다르다
비행 슈팅 게임에 특수 능력이 셋 있습니다. 레이더 펄스, 공습, 응급 치료.
public class AbilityRunner : MonoBehaviour
{
public AbilityType type;
public void Use()
{
switch (type)
{
case AbilityType.RadarPulse:
// 주변 적 탐색, 미니맵 갱신, 사운드, 파티클
break;
case AbilityType.AirStrike:
// 좌표 계산, 폭격기 스폰, 딜레이, 사운드, 파티클
break;
case AbilityType.FirstAid:
// 체력 회복, 힐 이펙트, 사운드
break;
}
}
}
프로토타입에서는 아무 문제가 없습니다. 문제는 콘텐츠가 추가될 때입니다.
능력이 셋에서 서른이 되면 이 파일은 수천 줄이 됩니다. 그리고 그때마다 이미 검증이 끝난 파일을 다시 엽니다. 새 능력 하나를 넣다가 기존 능력이 깨지는 사고가 여기서 납니다. 팀으로 일한다면 하나가 더 붙습니다. 셋이 각자 다른 능력을 만들어도 같은 파일을 고치기 때문에 충돌이 계속 납니다.
switch지옥의 진짜 비용은 파일이 길어지는 것이 아니라 고칠 자리가 한 곳에 몰려서 위험이 쌓이는 것입니다.
각 case 를 클래스 하나로
앞 글에서 상태에 했던 것과 똑같이 합니다. 공통으로 하는 일은 베이스에 두고, 달라지는 부분만 자식이 채웁니다.
public abstract class Ability : ScriptableObject
{
[SerializeField] AudioClip sfx;
[SerializeField] ParticleSystem vfxPrefab;
[SerializeField] float cooldown = 1f;
public float Cooldown => cooldown;
public void Use(GameObject user)
{
PlayFeedback(user.transform.position); // 모두가 하는 일
OnUse(user); // 각자 다른 일
}
protected abstract void OnUse(GameObject user);
void PlayFeedback(Vector3 pos)
{
if (sfx != null) AudioSource.PlayClipAtPoint(sfx, pos);
if (vfxPrefab != null) Instantiate(vfxPrefab, pos, Quaternion.identity);
}
}
[CreateAssetMenu(menuName = "Abilities/Air Strike")]
public class AirStrike : Ability
{
[SerializeField] GameObject bomberPrefab;
[SerializeField] int wave = 3;
protected override void OnUse(GameObject user)
{
for (int i = 0; i < wave; i++)
Bomber.Spawn(bomberPrefab, user.transform.position, delay: i * 0.4f);
}
}
쓰는 쪽에는 if 도 switch 도 enum 도 없습니다.
public class AbilityRunner : MonoBehaviour
{
[SerializeField] Ability current; // 인스펙터에서 에셋을 끌어다 놓는다
float nextUsableTime;
public void Equip(Ability ability) => current = ability;
void Update()
{
if (!Input.GetKeyDown(KeyCode.Q)) return;
if (current == null || Time.time < nextUsableTime) return;
current.Use(gameObject);
nextUsableTime = Time.time + current.Cooldown;
}
}
능력이 300개가 되어도 이 파일은 한 줄도 바뀌지 않습니다.
| 새 능력을 추가할 때 | switch 방식 |
전략 방식 |
|---|---|---|
| 고치는 파일 | enum 과 AbilityRunner 둘 |
없음 |
| 새로 만드는 파일 | 없음 | MultiShot.cs 하나 |
| 기존 기능이 깨질 위험 | 있다 | 사실상 없다 |
| 팀 작업 시 충돌 | 잦다 | 파일이 다르니 거의 없다 |
| 그 능력만 시험하기 | Runner 를 통째로 세워야 |
클래스 하나만 |
상태와 무엇이 다른가
여기까지 읽으면서 앞 글이 떠올랐다면 정확합니다. 거의 같은 코드입니다.
public interface IState { void Update(); } // 앞 글
public abstract class Ability { ... } // 이 글
둘 다 행동을 타입 하나로 묶고, 컨텍스트가 그 참조를 들고 있다가, 참조를 바꾸면 행동이 통째로 바뀝니다. 클래스 다이어그램을 그리면 거의 겹칩니다.
갈리는 지점은 딱 하나입니다. 다음에 무엇을 쓸지 누가 정하는가.
전략은 자기가 언제 교체될지 모릅니다. 그냥 시키는 일만 합니다. 상태는 자기가 언제 물러날지 압니다. 조건을 자기 안에 들고 있습니다.
그래서 하나 더 갈립니다. 상태는 서로를 알아야 합니다.
추격 상태가 공격 상태로 넘기려면 그 존재를 알아야 하니까요.
전략끼리는 서로를 전혀 모릅니다. AirStrike 는 FirstAid 가 있는지도 모릅니다.
비슷해 보이는 것 하나를 더 넣어 정리하면 이렇습니다.
| 무엇이 바뀌나 | 누가 정하나 | 구조 | |
|---|---|---|---|
| 전략 | 알고리즘 전체 | 바깥 | 컨텍스트가 들고 있다 |
| 상태 | 알고리즘 + 전이 규칙 | 자기 자신 | 들고 있다 + 서로를 안다 |
| 템플릿 메서드 | 절차의 일부 단계 | 컴파일할 때 정해짐 | 상속으로 자기가 된다 |
위의 Ability 를 보면 사실 셋이 섞여 있습니다.
Use() 가 공통 절차를 정하고 OnUse() 만 자식이 채우는 부분은 템플릿 메서드이고,
AbilityRunner 가 Ability 참조를 들고 있다가 갈아 끼우는 부분이 전략입니다.
실무 코드는 대체로 이렇게 섞여 있고, 그게 잘못된 것도 아닙니다.
패턴 이름을 정확히 맞히는 것은 목적이 아닙니다. “이 행동을 누가 고르는가” 를 코드에서 한 번에 찾을 수 있으면 그걸로 충분합니다.
이미 쓰고 있습니다
URP 를 쓰고 있다면 인스펙터에서 Renderer Feature 를 추가하고 삭제해 봤을 것입니다. SSAO, Screen Space Shadows, Decal 같은 것들입니다.
그게 전략입니다. UniversalRendererData 는 List<ScriptableRendererFeature> 를
들고 있을 뿐이고, 각 피처가 무엇을 하는지는 모릅니다.
그래서 우리가 직접 만든 피처를 상속으로 끼워 넣을 수 있습니다.
여기서 짚을 게 하나 있습니다. 빌트인 파이프라인도 내부적으로는 비슷한 구조였지만 확장점을 밖으로 꺼내 주지 않았습니다. 패턴을 쓰는 것과 확장점을 공개하는 것은 별개의 결정입니다. 전략을 쓴다고 저절로 확장 가능해지지 않습니다. 무엇을 추상 타입으로 공개할지를 정하는 것이 진짜 설계입니다.
인터페이스냐 추상 클래스냐
interface |
abstract class |
|
|---|---|---|
| 공통 구현 공유 | 안 된다 | 된다 |
| 인스펙터에 필드 노출 | 안 된다 | 된다 |
| 여러 개를 동시에 | 된다 | 안 된다 (단일 상속) |
| 테스트에서 가짜 만들기 | 쉽다 | 상대적으로 번거롭다 |
위 예제가 추상 클래스인 이유는 공통으로 할 일(사운드, 파티클, 쿨다운)이
실제로 있고 인스펙터에 필드를 노출해야 하기 때문입니다.
ScriptableRendererFeature 가 추상 클래스인 것도 같은 이유입니다.
공통 구현이 없고 계약만 필요하면 인터페이스가 맞습니다.
둘 다 필요하면 interface IAbility 로 계약을 두고
abstract class AbilityBase : ScriptableObject, IAbility 로 공통 구현을 주면 됩니다.
쓰는 쪽이 인터페이스에만 의존하면 테스트에서 가짜를 꽂기도 쉽습니다.
밟기 쉬운 것 넷
1. ScriptableObject 에 변하는 값을 넣지 않습니다. 에셋은 인스턴스가 하나뿐입니다.
Ability 에 남은 쿨다운을 넣으면 그 능력을 쓰는 모든 적이 같은 쿨다운을 공유합니다.
에디터에서는 플레이를 멈춰도 값이 남습니다.
변하지 않는 설정만 에셋에 두고, 변하는 값은 쓰는 쪽에 둡니다.
위 예제에서 nextUsableTime 이 AbilityRunner 에 있는 것이 그래서입니다.
2. null 검사가 흩어지면 다시 분기 지옥입니다. current == null 을 여기저기서
확인하기 시작하면 없앤 switch 가 다른 모양으로 돌아옵니다.
아무것도 안 하는 전략을 하나 만들어 기본값으로 꽂아 두면 검사가 사라집니다.
public class NoAbility : Ability
{
protected override void OnUse(GameObject user) { }
}
3. 전략마다 필요한 값이 다를 때가 진짜 어려운 지점입니다. 공습은 좌표가, 치료는 대상이 필요합니다. 시그니처를 어떻게 맞출 것인가. 필요한 것을 담은 컨텍스트 객체 하나를 넘기는 것이 가장 흔한 답이고, 그 객체가 계속 부풀면 전략을 잘못 나눈 것일 수 있습니다.
4. 분기가 둘이고 안 늘어날 것 같으면 if 한 줄이 정답입니다.
클래스를 나누는 것 자체가 비용입니다. 파일이 늘고, 찾아야 할 곳이 늘고,
새로 온 사람이 읽어야 할 것이 늘어납니다.
정리
- 전략은
switch의 각case를 클래스로 뜯어낸 것이다. 그게 전부다. - 쓰는 쪽에서 분기가 사라진다. 능력이 300개여도 그 파일은 안 바뀐다.
- 진짜 이득은 코드 길이가 아니라 고칠 자리가 흩어지는 것이다. 회귀와 충돌이 줄어든다.
- 상태와 구조가 거의 같다. 갈리는 것은 다음을 누가 정하는가 하나뿐이다.
- 전략은 밖에서 꽂아 주고 서로를 모른다. 상태는 자기가 정하고 서로를 안다.
- URP 의 Renderer Feature 가 전략이다. 다만 패턴을 쓰는 것과 확장점을 공개하는 것은 다르다.
- 공통 구현과 인스펙터 필드가 있으면 추상 클래스, 계약만 필요하면 인터페이스.
ScriptableObject에는 변하지 않는 것만 넣는다. 에셋은 인스턴스가 하나다.- 분기가 둘이면
if가 맞다. 패턴을 적용하는 것도 비용이다.
전략은 행동을 객체로 만들어 갈아 끼우는 장치입니다. 바꾸는 주체가 바깥이라는 것 하나가 이 패턴의 성격을 거의 다 정합니다.