이전에 Zenject를 활용한 의존성 주입을 공부한 적이 있었다.

이번에 좀 더 복잡하고 실용성을 강화한 시나리오로 예제를 만들어보면서 공부를 해볼것이다.

 

  • IPlayerStatsService: 플레이어의 체력, 마나, 경험치, 스텟 관리
  • IInventoryService: 플레이어 인벤토리 관리(아이템 추가/삭제/현재 장착 무기)
  • IWeapon: 플레이어가 사용하는 무기(근접무기나 원거리무기)
  • IUIManager: UI에 플레이어 정보(HP, 장비 아이템)를 표시
  • ILoggerService: 게임 내 로깅(디버그 / 파일 저장용)

Zenject를 사용해서 이 서비스들을 Installer에서 한번에 바인딩하고, PlayerController가 이 서비스들을 주입받아 동작한다. 또한, IInventoryService에 대해 실제 인벤토리 서비스와 테스트용 Mock 인벤토리 서비스를 스위칭할 수 있도록 하여 테스트나 개발 편의성을 고려한다. 이 과정을 통해 DI의 장점(테스트 용이성, 유지보수성 향상, 확장성)을 극대화한다.

 

인터페이스 및 구현

public interface IPlayerStatsService
{
    int Health { get; set; }
    int Mana { get; set; }
    int Experience { get; set; }
    void IncreaseExperience(int amount);
    void TakeDamage(int damage);
}

 

using UnityEngine;

public class PlayerStatsService : IPlayerStatsService
{
    public int Health { get; set; } = 100;
    public int Mana { get; set; } = 50;
    public int Experience { get; set; } = 0;

    public void IncreaseExperience(int amount)
    {
        Experience += amount;
        Debug.Log($"경험치 증가: 현재 경험치 {Experience}");
    }

    public void TakeDamage(int damage)
    {
        Health = Mathf.Max(0, Health - damage);
        Debug.Log($"플레이어 피해: 현재 체력 {Health}");
    }
}

 

using System.Collections.Generic;

public interface IInventoryService
{
    // 인벤토리에 있는 아이템 목록 반환
    List<string> GetItems();

    // 아이템 획득
    void AddItem(string itemName);

    // 아이템 사용(무기 장착 등)
    void UseItem(string itemName);

    // 현재 장착 무기 반환
    IWeapon GetEquippedWeapon();
}

 

using System.Collections.Generic;
using UnityEngine;

public class InventoryService : IInventoryService
{
    private List<string> _items = new List<string>() { "Sword", "HealthPotion" };
    private IWeapon _equippedWeapon;

    // DI를 통해 무기 팩토리나 풀링 시스템을 주입받을 수도 있지만 여기서는 간단히 처리
    public List<string> GetItems()
    {
        return _items;
    }

    public void AddItem(string itemName)
    {
        _items.Add(itemName);
        Debug.Log($"아이템 추가: {itemName}");
    }

    public void UseItem(string itemName)
    {
        // 예: "Gun"이라는 아이템을 사용하면 Gun 무기를 장착
        if (itemName == "Sword")
        {
            _equippedWeapon = new Sword();
            Debug.Log("검 장착 완료");
        }
        else if (itemName == "Gun")
        {
            _equippedWeapon = new Gun();
            Debug.Log("총 장착 완료");
        }
        else if (itemName == "HealthPotion")
        {
            // 실제로는 PlayerStatsService를 주입받아 회복 처리하는 식으로 확장 가능
            Debug.Log("체력 물약 사용: 체력 회복 로직 추가 가능");
        }
    }

    public IWeapon GetEquippedWeapon()
    {
        return _equippedWeapon;
    }
}

 

using System.Collections.Generic;
using UnityEngine;

public class MockInventoryService : IInventoryService
{
    // 테스트 시나리오: 초기 아이템이 없고, 기본 검 장착
    private IWeapon _equippedWeapon = new Sword();

    public List<string> GetItems()
    {
        // 아이템이 없는 상황을 가정
        return new List<string>();
    }

    public void AddItem(string itemName)
    {
        // 테스트 상황에서는 AddItem이 호출되면 디버그 출력만
        Debug.Log($"[Mock] 아이템 추가 요청: {itemName} (실제 추가 없음)");
    }

    public void UseItem(string itemName)
    {
        // 테스트 상황에서는 UseItem이 호출되면 디버그 출력만
        Debug.Log($"[Mock] 아이템 사용 요청: {itemName} (장착 무기 변경 없음, 기본 검 유지)");
    }

    public IWeapon GetEquippedWeapon()
    {
        return _equippedWeapon;
    }
}

 

public interface IUIManager
{
    void UpdateHealthDisplay(int health);
    void UpdateManaDisplay(int mana);
    void UpdateEquippedWeaponDisplay(string weaponName);
}

 

using UnityEngine;

public class UIManager : IUIManager
{
    public void UpdateHealthDisplay(int health)
    {
        Debug.Log($"UI 갱신: 체력 {health}");
    }

    public void UpdateManaDisplay(int mana)
    {
        Debug.Log($"UI 갱신: 마나 {mana}");
    }

    public void UpdateEquippedWeaponDisplay(string weaponName)
    {
        Debug.Log($"UI 갱신: 현재 무기 {weaponName}");
    }
}

 

public interface ILoggerService
{
    void Log(string message);
    void Error(string message);
}

 

using UnityEngine;

public class LoggerService : ILoggerService
{
    public void Log(string message)
    {
        Debug.Log($"[Logger]: {message}");
    }

    public void Error(string message)
    {
        Debug.LogError($"[Logger Error]: {message}");
    }
}

 

public interface IWeapon
{
    void Attack();
    string GetWeaponName();
}

 

using UnityEngine;

public class Sword : IWeapon
{
    public void Attack()
    {
        Debug.Log("검 공격! 근접 범위 공격 수행");
    }

    public string GetWeaponName()
    {
        return "Sword";
    }
}

 

using UnityEngine;

public class Gun : IWeapon
{
    public void Attack()
    {
        Debug.Log("총 공격! 원거리 공격 수행");
    }

    public string GetWeaponName()
    {
        return "Gun";
    }
}

 

PlayerController(DI를 통해 다양한 시스템 접근)

using UnityEngine;
using Zenject;

// PlayerController는 다양한 서비스를 주입받아 게임플레이 로직을 수행
public class PlayerController : MonoBehaviour
{
    private IPlayerStatsService _playerStatsService;
    private IInventoryService _inventoryService;
    private IUIManager _uiManager;
    private ILoggerService _logger;

    // 생성자 주입을 통해 모든 의존성 주입
    [Inject]
    public void Construct(
        IPlayerStatsService playerStatsService,
        IInventoryService inventoryService,
        IUIManager uiManager,
        ILoggerService logger)
    {
        _playerStatsService = playerStatsService;
        _inventoryService = inventoryService;
        _uiManager = uiManager;
        _logger = logger;
    }

    private void Start()
    {
        // 시작 시 현재 무기 UI 갱신
        var weapon = _inventoryService.GetEquippedWeapon();
        if (weapon != null)
        {
            _uiManager.UpdateEquippedWeaponDisplay(weapon.GetWeaponName());
        }
        else
        {
            _uiManager.UpdateEquippedWeaponDisplay("None");
        }

        // 시작 시 플레이어 HP/Mana UI 갱신
        _uiManager.UpdateHealthDisplay(_playerStatsService.Health);
        _uiManager.UpdateManaDisplay(_playerStatsService.Mana);
    }

    private void Update()
    {
        // 마우스 좌클릭으로 공격
        if (Input.GetMouseButtonDown(0))
        {
            IWeapon weapon = _inventoryService.GetEquippedWeapon();
            if (weapon != null)
            {
                weapon.Attack();
                _logger.Log("플레이어가 공격을 수행했습니다.");
            }
            else
            {
                _logger.Error("무기가 없습니다! 무기를 장착하세요.");
            }
        }

        // 키보드 입력으로 아이템 사용 (예: 1키 = Sword, 2키 = Gun, 3키 = HealthPotion)
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            _inventoryService.UseItem("Sword");
            UpdateWeaponUI();
        }
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            _inventoryService.AddItem("Gun");
            _inventoryService.UseItem("Gun");
            UpdateWeaponUI();
        }
        if (Input.GetKeyDown(KeyCode.Alpha3))
        {
            _inventoryService.UseItem("HealthPotion");
            // 체력 회복 로직이 추가되면 여기서 _uiManager.UpdateHealthDisplay() 호출
        }

        // 체력 감소 테스트 (H키)
        if (Input.GetKeyDown(KeyCode.H))
        {
            _playerStatsService.TakeDamage(10);
            _uiManager.UpdateHealthDisplay(_playerStatsService.Health);
        }
    }

    private void UpdateWeaponUI()
    {
        var weapon = _inventoryService.GetEquippedWeapon();
        var weaponName = weapon != null ? weapon.GetWeaponName() : "None";
        _uiManager.UpdateEquippedWeaponDisplay(weaponName);
    }
}

 

using Zenject;
using UnityEngine;

// Inspector에 옵션을 제공하여 실제 인벤토리 서비스 또는 Mock 인벤토리 서비스 선택 가능
public class GameInstaller : MonoInstaller
{
    [SerializeField] private bool useMockInventory = false;

    public override void InstallBindings()
    {
        // PlayerStatsService 바인딩 (Singleton으로 관리)
        Container.Bind<IPlayerStatsService>().To<PlayerStatsService>().AsSingle();

        // InventoryService 또는 MockInventoryService 바인딩
        if (useMockInventory)
        {
            Container.Bind<IInventoryService>().To<MockInventoryService>().AsSingle();
        }
        else
        {
            Container.Bind<IInventoryService>().To<InventoryService>().AsSingle();
        }

        // UI Manager 바인딩
        Container.Bind<IUIManager>().To<UIManager>().AsSingle();

        // Logger Service 바인딩
        Container.Bind<ILoggerService>().To<LoggerService>().AsSingle();

        // PlayerController 주입
        // PlayerController는 씬 상에 존재하는 MonoBehaviour이므로 Zenject가 SceneContext를 통해 자동 Inject
        // 별도 바인딩 불필요하지만, 필요하다면 아래와 같이 명시적 바인딩 가능:
        // Container.Bind<PlayerController>().FromComponentInHierarchy().AsSingle();
    }
}

 

요약 및 디자인 패턴 분석

  • 의존성 주입 (Dependency Injection):
    • PlayerController는 명시적으로 무기, UI, 통계 서비스 등을 new 하지 않으며, 외부에서 주입받는다.
    • Installer(GameInstaller)를 통해 런타임에 어떤 구현체를 주입할지 결정하므로, 요구사항 변화(예: 실제 인벤토리 대신 Mock 사용)에도 코드 변경 없이 설정만 바꿔 대응 가능.
  • 인터페이스 기반 설계:
    • 각 서비스는 인터페이스를 통해 정의되고, 다양한 구현체(InventoryService, MockInventoryService)로 교체 가능.
    • OCP(개방-폐쇄 원칙)에 따라 기존 코드를 수정하지 않고 새로운 서비스나 무기 타입을 추가할 수 있다.
  • 테스트 용이성:
    • MockInventoryService를 바인딩함으로써 게임플레이 로직 테스트 시 아이템, 무기 로직을 단순화할 수 있다.
    • Logger, UI, Stats 등도 Mock 구현체를 만들어 단위테스트 용이.
  • 유연한 확장:
    • 새로운 무기 추가 시 IWeapon 구현체만 만들면 PlayerController는 코드 변경 없이 자동 사용 가능.
    • UI 시스템 교체 시 IUIManager 구현체만 교체하면 된다.
  • 실용성:
    • 실제 개발에서 매우 흔히 쓰이는 플레이어 스탯 관리, 인벤토리 관리, UI 갱신, 로깅 등의 기능을 모두 DI로 관리해 큰 프로젝트에서도 모듈 간 결합도를 낮추고 유지보수성을 높인다.

후기

 

확실히 배우는 과정에선 여전히 어떻게 어디부터 적용해야할지 크게 감히 잡히진 않지만

이렇게 배우다 보면 언젠가 나도 모르게 천천히 스며들고 있는걸 느낀다.

마침 진행중인 팀 프로젝트가 꽤 복잡한 시스템이 많아서 너무 좋은 시스템이라는게 느껴지고 있기때문에 재밌다..!

이런 구조를 통해 OCP, DIP 등의 객체지향적인 설계가 자연스럽게 녹아드는 부분도 매우 좋은 방법이라고 느껴진다.