의존성 주입으로 결합도 낮은 게임플레이 코드 만들기

의존성 주입의 핵심 원리와 게임 코드에서의 적용 방법을 C# 예제로 정리합니다. 직접 생성과 전역 접근을 줄여 테스트와 교체가 쉬운 구조를 만들어 봅니다.

✓ 복사 완료!
챕터시스템 아키텍처 & 패턴
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
의존성 주입으로 결합도 낮은 게임플레이 코드 만들기

왜 게임 코드가 쉽게 얽히는가

플레이어가 적을 공격할 때 피해 계산, 사운드 재생, 이펙트 생성, 업적 기록까지 한 컴포넌트가 직접 처리하기 시작하면 기능은 빠르게 완성됩니다. 문제는 각 기능이 구체적인 구현을 직접 알고 있다는 점입니다.

예를 들어 PlayerCombat이 내부에서 new DamageCalculator()를 호출하고 싱글턴 오디오 매니저와 이펙트 매니저에 접근한다고 가정해 보겠습니다. 피해 공식만 바꾸고 싶어도 전투 컴포넌트를 수정해야 하며 자동 테스트에서는 실제 오디오와 이펙트 시스템까지 함께 준비해야 할 수 있습니다.

의존성 주입(Dependency Injection, DI)은 객체가 필요한 협력자를 직접 만들거나 전역에서 찾아오지 않고 외부에서 전달받도록 만드는 설계 방식입니다. 핵심은 프레임워크 도입이 아니라 의존성의 생성 책임을 사용하는 코드 밖으로 옮기는 데 있습니다.

flowchart LR
    A[게임 시작 지점<br/>Composition Root] --> B[PlayerCombat]
    A --> C[DamageCalculator]
    A --> D[AudioService]
    A --> E[EffectService]
    C --> B
    D --> B
    E --> B

위 구조에서 게임 시작 지점은 어떤 구현을 조합할지 결정하고 PlayerCombat은 전달받은 기능의 인터페이스만 사용합니다. 이를 조합 루트(Composition Root)라고 부릅니다.

직접 생성이 만드는 결합

다음 코드는 동작하지만 PlayerCombat이 구체 클래스에 강하게 묶여 있습니다.

public sealed class PlayerCombat
{
    public void Attack(Enemy enemy)
    {
        var calculator = new DamageCalculator();
        int damage = calculator.Calculate(10, enemy.Defense);

        enemy.TakeDamage(damage);
        AudioManager.Instance.Play("hit");
        EffectManager.Instance.SpawnHitEffect(enemy.transform.position);
    }
}

이 클래스는 피해 계산기, 오디오 시스템, 이펙트 시스템의 생성 방식과 사용 방식을 모두 알아야 합니다. 대미지 계산을 난수 기반으로 바꾸거나 사운드 출력을 끈 테스트를 작성하려면 불필요한 수정이 늘어납니다.

여기서 중요한 것은 의존성이 있다는 사실 자체가 문제가 아니라는 점입니다. 전투 기능에는 피해 계산과 피드백 기능이 필요합니다. 문제는 필요한 대상이 어떤 구체 구현인지까지 전투 기능이 결정하는 것입니다.

인터페이스로 필요한 역할을 드러내기

먼저 호출하는 쪽이 필요로 하는 최소 역할을 인터페이스로 정의합니다. 지나치게 큰 공용 인터페이스 대신 용도에 맞는 작은 인터페이스를 두는 편이 안전합니다.

public interface IDamageCalculator
{
    int Calculate(int attack, int defense);
}

public interface ICombatAudio
{
    void PlayHit();
}

public interface IHitEffect
{
    void Spawn(Vector3 position);
}

구현체는 기존 기능을 감싸거나 새로 만들 수 있습니다.

public sealed class DamageCalculator : IDamageCalculator
{
    public int Calculate(int attack, int defense)
    {
        return Math.Max(1, attack - defense);
    }
}

public sealed class UnityCombatAudio : ICombatAudio
{
    public void PlayHit()
    {
        AudioManager.Instance.Play("hit");
    }
}

UnityCombatAudio처럼 기존 싱글턴을 감싸는 방법은 전역 접근을 한 번에 제거하기 어려운 프로젝트에서 유용합니다. 전투 로직은 더 이상 싱글턴을 알 필요가 없고 전역 접근은 경계 역할을 하는 클래스 한 곳에 남습니다.

생성자 주입으로 전투 로직 분리하기

가장 단순하고 명확한 방법은 생성자 주입입니다. 객체가 정상적으로 동작하는 데 반드시 필요한 의존성은 생성자로 받습니다.

public sealed class PlayerCombat
{
    private readonly IDamageCalculator damageCalculator;
    private readonly ICombatAudio combatAudio;
    private readonly IHitEffect hitEffect;

    public PlayerCombat(
        IDamageCalculator damageCalculator,
        ICombatAudio combatAudio,
        IHitEffect hitEffect)
    {
        this.damageCalculator = damageCalculator;
        this.combatAudio = combatAudio;
        this.hitEffect = hitEffect;
    }

    public void Attack(Enemy enemy, int attackPower, Vector3 hitPosition)
    {
        int damage = damageCalculator.Calculate(attackPower, enemy.Defense);
        enemy.TakeDamage(damage);

        combatAudio.PlayHit();
        hitEffect.Spawn(hitPosition);
    }
}

이제 PlayerCombat은 역할에만 의존합니다. 실제 게임에서는 Unity 구현체를 전달하고 테스트에서는 가짜 구현체를 전달할 수 있습니다.

public sealed class TestDamageCalculator : IDamageCalculator
{
    public int Calculate(int attack, int defense)
    {
        return 999;
    }
}

public sealed class SilentCombatAudio : ICombatAudio
{
    public void PlayHit() { }
}

이 방식은 테스트만을 위한 기법이 아닙니다. 플랫폼별 오디오 구현, 접근성 옵션에 따른 피드백 처리, 네트워크 모드별 피해 계산처럼 구현을 교체해야 하는 지점에서도 효과가 있습니다.

Unity에서 조합 루트 두기

Unity의 MonoBehaviour는 Unity가 생성하므로 일반 C# 객체처럼 생성자 주입을 적용하기 어렵습니다. 대신 MonoBehaviour는 조립과 Unity 이벤트 연결에 집중하고 게임 규칙은 일반 C# 클래스로 분리하는 방식이 실용적입니다.

public sealed class PlayerCombatPresenter : MonoBehaviour
{
    [SerializeField] private AudioSource hitAudio;
    [SerializeField] private HitEffectSpawner hitEffectSpawner;

    private PlayerCombat combat;

    private void Awake()
    {
        var calculator = new DamageCalculator();
        var audio = new AudioSourceCombatAudio(hitAudio);
        var effect = new UnityHitEffect(hitEffectSpawner);

        combat = new PlayerCombat(calculator, audio, effect);
    }

    public void OnAttack(Enemy enemy)
    {
        combat.Attack(enemy, attackPower: 10, transform.position);
    }
}

Awake는 이 예제의 조합 루트입니다. 인스펙터에서 연결해야 하는 Unity 객체는 MonoBehaviour가 받고 도메인 로직에는 Unity 생명 주기와 컴포넌트 참조가 스며들지 않도록 막습니다.

Unity 인스펙터 참조를 조합 루트에서 일반 C# 전투 로직으로 전달하는 구조

프로젝트 규모가 커지면 Zenject나 VContainer 같은 DI 컨테이너를 고려할 수 있습니다. 다만 컨테이너는 등록과 수명 관리를 돕는 도구일 뿐입니다. 의존성을 명시적으로 드러내고 적절한 경계에서 조립하는 설계가 먼저입니다.

주입 방식 선택 기준

생성자 주입은 필수 의존성에 가장 잘 맞습니다. 생성 직후부터 유효한 상태를 보장하고 누락된 의존성을 빠르게 발견할 수 있기 때문입니다.

선택적 기능이나 런타임 교체가 필요한 경우에는 메서드 주입을 사용할 수 있습니다. 예를 들어 컷신 시작 시에만 카메라 연출기를 전달하는 경우가 이에 해당합니다. 반면 필드 주입은 의존성이 숨겨지기 쉬워 초기화 순서 문제를 만들 수 있으므로 일반 C# 로직에서는 신중하게 사용해야 합니다.

과도한 추상화를 피하는 법

DI를 적용한다고 모든 클래스에 인터페이스가 필요한 것은 아닙니다. 바뀔 가능성이 없고 외부 효과도 없는 작은 값 객체나 계산 객체는 구체 클래스로 사용해도 충분합니다.

다음 질문에 하나라도 해당하면 추상화와 주입을 검토할 만합니다.

  • 플랫폼, 환경, 게임 모드에 따라 구현이 달라지는가?
  • 파일, 네트워크, 시간, 난수처럼 테스트에서 통제하기 어려운 외부 효과를 다루는가?
  • 현재 구현을 대체해야 할 가능성이 있는가?
  • 하나의 클래스가 생성과 사용을 동시에 책임지고 있는가?

반대로 구현이 하나뿐이며 교체 이유도 없고 복잡성을 줄이는 데 도움이 되지 않는다면 인터페이스를 추가하지 않는 편이 낫습니다. 결합도를 낮추는 목적은 클래스 수를 늘리는 것이 아니라 변경의 범위를 제한하는 데 있습니다.

적용 순서

기존 프로젝트를 한 번에 DI 구조로 바꿀 필요는 없습니다. 우선 테스트가 어렵거나 변경이 잦은 한 기능을 고릅니다. 그 기능이 직접 생성하거나 싱글턴에서 가져오는 대상을 찾고 호출에 필요한 최소 인터페이스를 정의한 뒤 생성자 인자로 옮깁니다. 마지막으로 씬 초기화 코드나 부트스트랩 코드에서 실제 구현을 조합합니다.

이 과정을 반복하면 게임 규칙은 Unity API와 전역 서비스에서 점차 분리됩니다. 결과적으로 기능 교체, 자동 테스트, 멀티플레이 전환 같은 변화가 전체 프로젝트로 번지는 일을 줄일 수 있습니다.