Blog / Dev / Unity SOLID 원칙 적용 — VContainer, R3, M...
Dev

Unity SOLID 원칙 적용 — VContainer, R3, MessagePipe, UniTask

Unity에서 SOLID 5원칙을 지키는 법을 정리한다. MonoBehaviour God 클래스 문제를 VContainer DI, R3, MessagePipe, UniTask로 분리하고 의존성 주입과 결합도 낮추기를 실제 코드로 보여준다.

왜 Unity에서 SOLID가 어려운가

Unity는 컴포넌트 지향이라 OOP 친화적으로 보이지만, 실제로 짜다 보면 한 MonoBehaviour가 입력, 상태, 렌더링, 사운드, UI까지 전부 떠안는 “God 컴포넌트”가 되기 쉽다. FindObjectOfType, GetComponent, static Instance 세 가지가 의존성을 숨기고, 인스펙터에서 끌어다 꽂는 직렬화 필드는 결합도를 시각적으로만 가린다.

SOLID는 이걸 막기 위한 지침이지만, 원칙만 외워서는 코드가 바뀌지 않는다. 각 원칙을 강제(혹은 권장)하는 도구가 같이 있어야 한다. 이 글에서는 SOLID 5원칙을 Unity 맥락에서 다시 풀고, 각 원칙에 잘 맞는 프레임워크를 짝지어 본다. 결론부터 말하면 추천 스택은 다음이다.

도구 역할 출처
VContainer DI 컨테이너 hadashiA
R3 Reactive Extensions (UniRx 후속) Cysharp
MessagePipe Pub/Sub 메시징 Cysharp
UniTask 알로케이션 없는 async/await Cysharp

Zenject(Extenject)도 살아 있지만, 신규 프로젝트라면 가벼움과 IL2CPP 호환성에서 VContainer가 우위다. UniRx는 더 이상 신규 기능이 들어오지 않고, 같은 저자가 만든 R3로 흐름이 옮겨갔다.


S - Single Responsibility Principle

클래스는 하나의 변경 이유만 가져야 한다.

Unity에서의 안티패턴

// PlayerController.cs - 흔한 God MonoBehaviour
public class PlayerController : MonoBehaviour
{
    [SerializeField] private float _moveSpeed;
    [SerializeField] private AudioSource _footstep;
    [SerializeField] private Slider _hpBar;
    [SerializeField] private ParticleSystem _hitFx;

    private int _hp = 100;

    void Update()
    {
        var input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        transform.Translate(input * _moveSpeed * Time.deltaTime);
        if (input.sqrMagnitude > 0.1f) _footstep.Play();
    }

    public void TakeDamage(int dmg)
    {
        _hp -= dmg;
        _hpBar.value = _hp / 100f;
        _hitFx.Play();
        if (_hp <= 0) GameObject.Destroy(gameObject);
    }
}

입력, 이동, 사운드, HP, UI, VFX, 사망 처리가 한 클래스에 다 있다. HP 표시 위치만 바뀌어도 PlayerController를 건드려야 한다.

분리 후

// 각자 한 가지만 책임진다
public class PlayerInput : MonoBehaviour { public Vector3 MoveAxis { get; private set; } /* ... */ }
public class PlayerMover : MonoBehaviour { public void Move(Vector3 dir) { /* ... */ } }
public class PlayerHealth : MonoBehaviour
{
    public int Current { get; private set; } = 100;
    public event Action<int> OnChanged;
    public void TakeDamage(int dmg) { Current -= dmg; OnChanged?.Invoke(Current); }
}
public class PlayerHpView : MonoBehaviour { /* PlayerHealth.OnChanged 구독해서 슬라이더만 갱신 */ }

여기서 R3가 들어오면 event ActionObservable로 바꿀 수 있다. View는 Model을 직접 알 필요 없이 스트림만 구독한다.

// R3 적용
public class PlayerHealth
{
    private readonly ReactiveProperty<int> _current = new(100);
    public ReadOnlyReactiveProperty<int> Current => _current;
    public void TakeDamage(int dmg) => _current.Value -= dmg;
}

public class PlayerHpView : MonoBehaviour
{
    [SerializeField] private Slider _slider;

    public void Bind(PlayerHealth health)
    {
        health.Current
            .Subscribe(hp => _slider.value = hp / 100f)
            .AddTo(this); // GameObject 파괴 시 자동 Dispose
    }
}

AddTo(this)는 R3.Unity의 확장으로, GameObject가 파괴되면 자동으로 구독을 해제한다. 메모리 누수와 NRE 위험을 한 줄로 막아준다.


O - Open/Closed Principle

확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

Unity에서의 적용

새 무기를 추가할 때 WeaponManagerswitch (weaponType)을 매번 수정해야 한다면 OCP 위반이다. ScriptableObject 기반 전략 패턴이 정석이다.

public abstract class WeaponSO : ScriptableObject
{
    public abstract void Fire(Transform muzzle);
}

[CreateAssetMenu(menuName = "Weapon/Pistol")]
public class PistolSO : WeaponSO
{
    public override void Fire(Transform muzzle) { /* ... */ }
}

[CreateAssetMenu(menuName = "Weapon/Shotgun")]
public class ShotgunSO : WeaponSO
{
    public override void Fire(Transform muzzle) { /* ... */ }
}

public class WeaponHolder : MonoBehaviour
{
    [SerializeField] private WeaponSO _equipped;
    public void Trigger() => _equipped.Fire(transform);
}

새 무기는 새 SO를 만들면 끝. WeaponHolder는 한 글자도 안 바뀐다.

여기에 R3를 곁들이면 무기 교체 자체를 스트림으로 만들 수 있다.

public class WeaponHolder : MonoBehaviour
{
    public ReactiveProperty<WeaponSO> Equipped { get; } = new();

    void Start()
    {
        Equipped
            .Where(w => w != null)
            .Subscribe(w => Debug.Log($"무기 교체: {w.name}"))
            .AddTo(this);
    }
}

UI, 사운드, 애니메이터 어느 쪽이든 Equipped만 구독하면 된다. 새 시스템(예: 무기 교체 시 튜토리얼 표시)을 추가해도 WeaponHolder는 닫혀 있다.


L - Liskov Substitution Principle

하위 타입은 상위 타입을 대체할 수 있어야 한다.

Unity에서 LSP를 깨는 가장 흔한 경우는 깊은 MonoBehaviour 상속 트리다. EnemyBaseRangedEnemyMagicRangedEnemy 식으로 내려가다 보면, 상위 클래스의 Attack()을 하위에서 throw new NotSupportedException()으로 막아버리는 일이 생긴다.

해결은 상속이 아닌 컴포지션이다. “공격할 수 있는 것”은 베이스 클래스가 아니라 인터페이스로 표현한다.

public interface IDamageable { void TakeDamage(int dmg); }
public interface IAttacker { void Attack(IDamageable target); }
public interface IMovable { void MoveTo(Vector3 pos); }

public class Goblin : MonoBehaviour, IDamageable, IAttacker, IMovable { /* ... */ }
public class Turret : MonoBehaviour, IAttacker { /* 움직이지 않는다 */ }
public class Crate : MonoBehaviour, IDamageable { /* 공격받기만 한다 */ }

Turret이 EnemyBase를 상속받지 않으니 MoveTo를 억지로 구현할 필요가 없다. 각 기능을 가진 객체가 그 기능의 인터페이스만 구현한다. 이게 다음 원칙(ISP)으로 자연스럽게 이어진다.


I - Interface Segregation Principle

클라이언트는 사용하지 않는 메서드에 의존하지 않아야 한다.

위에서 본 IDamageable, IAttacker, IMovable이 ISP의 예시 그대로다. 거대한 IEntity 하나에 모든 기능을 욱여넣는 대신, 작은 인터페이스를 여러 개 두는 게 정답이다.

ISP의 진가는 메시징 시스템에서 드러난다. MessagePipe가 여기에 잘 맞는다.

// 메시지 타입 자체가 ISP의 단위
public readonly struct PlayerDamagedMessage
{
    public readonly int Amount;
    public PlayerDamagedMessage(int amount) { Amount = amount; }
}

public readonly struct EnemyKilledMessage
{
    public readonly int Score;
    public EnemyKilledMessage(int score) { Score = score; }
}
public class ScoreSystem : IInitializable, IDisposable
{
    private readonly ISubscriber<EnemyKilledMessage> _subscriber;
    private IDisposable _disposable;

    public ScoreSystem(ISubscriber<EnemyKilledMessage> subscriber)
    {
        _subscriber = subscriber;
    }

    public void Initialize()
    {
        _disposable = _subscriber.Subscribe(msg => AddScore(msg.Score));
    }

    private void AddScore(int s) { /* ... */ }
    public void Dispose() => _disposable?.Dispose();
}

ScoreSystemEnemyKilledMessage만 구독한다. PlayerDamagedMessage를 알 필요도, 의존할 필요도 없다. 이벤트 버스가 하나의 거대한 GameEvents 정적 클래스였다면, 점수 시스템도 데미지 이벤트 정의에 묶여버린다.


D - Dependency Inversion Principle

고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상에 의존해야 한다.

DIP는 SOLID의 마지막이지만, 다른 네 원칙을 가능하게 만드는 토대다. 그리고 Unity에서 DIP를 가장 잘 강제하는 도구가 VContainer다.

VContainer 기본 구조

// 1) 추상
public interface IAudioService
{
    void Play(string clipId);
}

// 2) 구현 (저수준)
public class FmodAudioService : IAudioService
{
    public void Play(string clipId) { /* FMOD 호출 */ }
}

// 3) 사용 (고수준)
public class PlayerHealth
{
    private readonly IAudioService _audio;
    public PlayerHealth(IAudioService audio) { _audio = audio; }
    public void TakeDamage(int dmg) { _audio.Play("hit"); }
}

// 4) LifetimeScope에서 등록
public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<IAudioService, FmodAudioService>(Lifetime.Singleton);
        builder.Register<PlayerHealth>(Lifetime.Scoped);
    }
}

PlayerHealth는 FMOD를 모른다. IAudioService만 안다. 테스트할 때는 MockAudioService를 등록하면 끝. FindObjectOfTypeSingleton.Instance도 없다.

MonoBehaviour 주입

VContainer는 MonoBehaviour에도 생성자 대신 메서드 주입을 지원한다.

public class HudPresenter : MonoBehaviour
{
    private PlayerHealth _health;

    [Inject]
    public void Construct(PlayerHealth health)
    {
        _health = health;
    }
}

씬에 미리 배치된 컴포넌트는 builder.RegisterComponentInHierarchy<HudPresenter>()로 등록한다. 런타임에 생성되는 프리팹은 builder.RegisterComponentInNewPrefab(prefab, Lifetime.Scoped)을 쓴다.

Zenject와의 차이

Zenject도 똑같은 일을 하지만 메서드 호출이 많고 리플렉션 비용도 크다. VContainer는 코드 생성으로 IL2CPP에서도 빠르다. 측정치로는 Zenject 대비 5~10배 정도 빠른 것으로 알려져 있다. 신규 프로젝트라면 VContainer를 권한다.


UniTask: SOLID와 직접 관련은 없지만 필수

SOLID 원칙 자체는 아니지만, 위 코드 중 비동기가 들어가는 모든 곳에서 UniTask가 필요하다. Unity의 Task는 GC 알로케이션이 크고 SynchronizationContext가 무거워서, 게임 루프에 그대로 쓰면 프레임 드랍이 생긴다.

public interface IEnemySpawner
{
    UniTask SpawnWaveAsync(int count, CancellationToken ct);
}

public class EnemySpawner : IEnemySpawner
{
    public async UniTask SpawnWaveAsync(int count, CancellationToken ct)
    {
        for (int i = 0; i < count; i++)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(0.5f), cancellationToken: ct);
            // 스폰 로직
        }
    }
}

VContainer로 IEnemySpawner를 주입받으면, 테스트에서는 await UniTask.CompletedTask만 반환하는 Mock을 끼워 넣을 수 있다. DIP + 비동기가 자연스럽게 결합된다.

R3와도 잘 섞인다. R3의 ObservableToUniTask()로, UniTask는 ToObservable()로 양방향 변환이 가능하다.


추천 스택을 한 화면에

// 입력 (R3 + UniTask)
public class PlayerInput : MonoBehaviour
{
    public Observable<Vector2> Move { get; private set; }
}

// 도메인 (POCO, MonoBehaviour 아님 - 테스트 가능)
public class PlayerHealth
{
    private readonly IAudioService _audio;
    private readonly IPublisher<PlayerDamagedMessage> _publisher;
    private readonly ReactiveProperty<int> _hp = new(100);

    public ReadOnlyReactiveProperty<int> Hp => _hp;

    public PlayerHealth(IAudioService audio, IPublisher<PlayerDamagedMessage> publisher)
    {
        _audio = audio;
        _publisher = publisher;
    }

    public void TakeDamage(int dmg)
    {
        _hp.Value -= dmg;
        _audio.Play("hit");
        _publisher.Publish(new PlayerDamagedMessage(dmg));
    }
}

// 뷰 (구독만)
public class HpBarView : MonoBehaviour
{
    [SerializeField] private Slider _slider;

    [Inject]
    public void Construct(PlayerHealth health)
    {
        health.Hp.Subscribe(v => _slider.value = v / 100f).AddTo(this);
    }
}

// 컴포지션 루트
public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        var options = builder.RegisterMessagePipe();
        builder.RegisterMessageBroker<PlayerDamagedMessage>(options);

        builder.Register<IAudioService, FmodAudioService>(Lifetime.Singleton);
        builder.Register<PlayerHealth>(Lifetime.Scoped);
        builder.RegisterComponentInHierarchy<HpBarView>();
    }
}

각 클래스가 자기 책임만 가지고(SRP), 새 뷰가 추가되어도 도메인은 닫혀 있고(OCP), 각 인터페이스는 작고(ISP), 도메인은 구체 구현이 아닌 추상에 의존한다(DIP). LSP는 인터페이스 기반 컴포지션이 자연스럽게 보장한다.


정리

SOLID Unity의 함정 대응 도구
SRP God MonoBehaviour R3 (스트림으로 책임 분리)
OCP switch-case 분기 추가 ScriptableObject + R3
LSP 깊은 MonoBehaviour 상속 인터페이스 컴포지션
ISP 거대한 GameEvents 정적 클래스 MessagePipe (메시지 단위 분리)
DIP FindObjectOfType, Singleton.Instance VContainer

도구를 도입하기 전에 먼저 점검할 것은 “이 클래스가 바뀌는 이유가 몇 개냐”는 질문이다. 이유가 하나가 될 때까지 쪼개고, 그다음 R3로 결합을 풀고, VContainer로 주입을 정리하면 SOLID는 따라온다. 반대로 도구만 먼저 넣으면 [Inject]만 잔뜩 붙은 God 클래스가 또 만들어진다.

게임 코드가 늘어날수록 가장 먼저 무너지는 건 아키텍처가 아니라 변경 비용이다. SOLID는 변경 비용을 일정하게 유지하기 위한 규칙이고, VContainer/R3/MessagePipe/UniTask는 그 규칙을 매일의 코드에 강제하는 가드레일이다.

God MonoBehaviour를 R3 스트림으로 잘라내고, VContainer로 의존성을 뒤집고, MessagePipe로 메시지를 분리하고, UniTask로 비동기를 묶는다 – Unity에서 SOLID를 실제로 굴리는 한 줄 요약이다.

댓글

블로그 목록으로
ACHIEVEMENT UNLOCKED
LOADING...
SCORE 000000
HITS
0