이전에 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 등의 객체지향적인 설계가 자연스럽게 녹아드는 부분도 매우 좋은 방법이라고 느껴진다.