---
title: "구조는 같고 누가 바꾸느냐가 다르다"
description: "전략과 상태는 코드만 보면 거의 구분이 안 됩니다. 갈리는 지점은 하나입니다. 다음에 무엇을 쓸지 밖에서 꽂아 주느냐, 자기가 정하느냐."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-strategy/
---
비행 슈팅 게임에 특수 능력이 셋 있습니다. 레이더 펄스, 공습, 응급 치료.

```csharp
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` 를 클래스 하나로

앞 글에서 상태에 했던 것과 똑같이 합니다. 공통으로 하는 일은 베이스에 두고,
달라지는 부분만 자식이 채웁니다.

```csharp
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);
    }
}
```

```csharp
[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` 도 없습니다.

```csharp
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` 를 통째로 세워야 | 클래스 하나만 |

## 상태와 무엇이 다른가

여기까지 읽으면서 앞 글이 떠올랐다면 정확합니다. **거의 같은 코드**입니다.

```csharp
public interface IState   { void Update(); }   // 앞 글
public abstract class Ability { ... }          // 이 글
```

둘 다 행동을 타입 하나로 묶고, 컨텍스트가 그 참조를 들고 있다가,
참조를 바꾸면 행동이 통째로 바뀝니다. 클래스 다이어그램을 그리면 거의 겹칩니다.

**갈리는 지점은 딱 하나입니다. 다음에 무엇을 쓸지 누가 정하는가.**

<x-compare>
<script type="application/json">
{
  "label": "교체를 결정하는 쪽",
  "tabs": [
    { "label": "상태", "code": "// 추격 상태가 스스로 넘긴다\npublic void Update()\n{\n    if (enemy.Distance < 2f)\n        machine.Change(attackState);\n\n    enemy.MoveTowardPlayer();\n}", "stats": [["다음을 정하는 쪽", "지금 상태 자신"], ["전이 조건이 있는 곳", "상태 안"], ["밖에서 보면", "알아서 굴러간다"]] },
    { "label": "전략", "code": "// 아이템을 먹으면 밖에서 꽂아 준다\nvoid OnItemPickup(Ability ability)\n{\n    runner.Equip(ability);\n}", "stats": [["다음을 정하는 쪽", "바깥의 클라이언트"], ["전이 조건이 있는 곳", "전략 밖"], ["밖에서 보면", "꽂아 준 대로 한다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

전략은 **자기가 언제 교체될지 모릅니다.** 그냥 시키는 일만 합니다.
상태는 **자기가 언제 물러날지 압니다.** 조건을 자기 안에 들고 있습니다.

그래서 하나 더 갈립니다. 상태는 서로를 알아야 합니다.
추격 상태가 공격 상태로 넘기려면 그 존재를 알아야 하니까요.
전략끼리는 서로를 전혀 모릅니다. `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` 가 다른 모양으로 돌아옵니다.
아무것도 안 하는 전략을 하나 만들어 기본값으로 꽂아 두면 검사가 사라집니다.

```csharp
public class NoAbility : Ability
{
    protected override void OnUse(GameObject user) { }
}
```

**3. 전략마다 필요한 값이 다를 때가 진짜 어려운 지점입니다.**
공습은 좌표가, 치료는 대상이 필요합니다. 시그니처를 어떻게 맞출 것인가.
필요한 것을 담은 컨텍스트 객체 하나를 넘기는 것이 가장 흔한 답이고,
그 객체가 계속 부풀면 전략을 잘못 나눈 것일 수 있습니다.

**4. 분기가 둘이고 안 늘어날 것 같으면 `if` 한 줄이 정답입니다.**
클래스를 나누는 것 자체가 비용입니다. 파일이 늘고, 찾아야 할 곳이 늘고,
새로 온 사람이 읽어야 할 것이 늘어납니다.

## 정리

- 전략은 `switch` 의 각 `case` 를 클래스로 뜯어낸 것이다. 그게 전부다.
- 쓰는 쪽에서 분기가 사라진다. 능력이 300개여도 그 파일은 안 바뀐다.
- 진짜 이득은 코드 길이가 아니라 **고칠 자리가 흩어지는 것**이다. 회귀와 충돌이 줄어든다.
- 상태와 구조가 거의 같다. 갈리는 것은 **다음을 누가 정하는가** 하나뿐이다.
- 전략은 밖에서 꽂아 주고 서로를 모른다. 상태는 자기가 정하고 서로를 안다.
- URP 의 Renderer Feature 가 전략이다. 다만 패턴을 쓰는 것과 확장점을 공개하는 것은 다르다.
- 공통 구현과 인스펙터 필드가 있으면 추상 클래스, 계약만 필요하면 인터페이스.
- `ScriptableObject` 에는 변하지 않는 것만 넣는다. 에셋은 인스턴스가 하나다.
- 분기가 둘이면 `if` 가 맞다. 패턴을 적용하는 것도 비용이다.

전략은 행동을 객체로 만들어 갈아 끼우는 장치입니다.
바꾸는 주체가 바깥이라는 것 하나가 이 패턴의 성격을 거의 다 정합니다.