Unity VContainer·Zenject로 UI와 코어 로직 의존성 분리하는 법
Unity에서 VContainer 또는 Zenject를 활용해 UI가 게임 규칙을 직접 호출하는 결합을 끊고 Presenter와 코어 서비스로 의존성을 분리하는 실전 구조를 코드와 등록 규칙으로 설명합니다.

게임 UI가 GameManager.Instance, PlayerPrefs, SceneManager를 직접 참조하면 화면 변경이 곧 코어 로직 변경으로 이어진다. VContainer와 Zenject 같은 DI 컨테이너는 객체 생성과 연결을 Composition Root로 모아 UI, Presenter, 코어 서비스의 책임을 분리한다. 핵심은 UI가 게임 규칙을 모르고 이벤트와 표시만 담당하도록 만드는 것이다.
Unity UI와 코어 로직이 강하게 결합되는 이유
Unity 프로젝트에서는 버튼 클릭 콜백에서 싱글턴을 호출하는 코드가 빠르게 늘어난다.
public void OnStartButtonClicked()
{
GameManager.Instance.StartNewGame();
SceneManager.LoadScene("Game");
}
이 코드는 처음에는 간단하지만 다음 문제가 생긴다.
| 문제 | 원인 | 결과 |
|---|---|---|
| UI 테스트가 어려움 | MonoBehaviour, 싱글턴, 씬 전환이 한 메서드에 섞임 | 게임 규칙만 분리해 검증할 수 없음 |
| 화면 교체 비용 증가 | UI가 GameManager의 구체 구현을 앎 | uGUI에서 UI Toolkit으로 옮길 때 로직도 수정 |
| 초기화 순서 오류 | 싱글턴의 생성 시점이 암묵적임 | 씬 진입 시 null 또는 상태 누수 발생 |
| 의존성 추적 어려움 | 참조가 코드 곳곳에 흩어짐 | 어떤 화면이 어떤 서비스를 쓰는지 파악하기 어려움 |
의존성 주입은 이 문제를 해결하기 위해 객체가 직접 협력 대상을 생성하거나 전역에서 찾지 않게 한다. 대신 LifetimeScope 또는 Installer가 실행 시점에 필요한 객체를 조립한다.
flowchart LR
View[MainMenuView\n입력과 표시] -->|이벤트| Presenter[MainMenuPresenter\n화면 흐름]
Presenter -->|인터페이스 호출| Session[GameSession\n게임 규칙]
Presenter -->|인터페이스 호출| Navigator[SceneNavigator\n씬 전환]
Scope[LifetimeScope / Installer] -.객체 생성 및 연결.-> View
Scope -.객체 생성 및 연결.-> Presenter
Scope -.객체 생성 및 연결.-> Session
Scope -.객체 생성 및 연결.-> Navigator

VContainer로 UI와 코어 로직을 어떻게 분리할까?
다음 예제는 Unity uGUI의 시작 메뉴를 기준으로 한다. GameSession은 Unity API를 참조하지 않는 순수 C# 클래스이고 MainMenuView는 버튼 이벤트만 외부에 알린다. 두 객체를 연결하고 씬 전환을 결정하는 책임은 MainMenuPresenter에 둔다.
1. 코어 로직을 Unity API 없이 작성한다
코어 로직은 MonoBehaviour, Button, SceneManager를 알 필요가 없다. 이 경계를 지키면 Edit Mode 테스트에서도 동일한 클래스를 만들 수 있다.
public sealed class GameSession
{
public bool IsRunning { get; private set; }
public void StartNewGame()
{
IsRunning = true;
}
}
public interface ISceneNavigator
{
void LoadGame();
}
ISceneNavigator는 씬 전환이라는 Unity 의존성을 Presenter 밖으로 한 번 더 분리한다. 단, 구현체가 하나이고 테스트 대역도 필요 없다면 인터페이스를 기계적으로 늘릴 필요는 없다. 여기서는 코어가 UnityEngine.SceneManagement.SceneManager를 참조하지 않도록 하는 경계 역할이 분명하다.
2. View는 입력과 표시만 노출한다
View는 버튼 클릭을 처리하되 게임 시작 여부를 판단하지 않는다.
using System;
using UnityEngine;
using UnityEngine.UI;
public sealed class MainMenuView : MonoBehaviour
{
[SerializeField] private Button startButton;
public event Action StartClicked;
private void OnEnable()
{
startButton.onClick.AddListener(NotifyStartClicked);
}
private void OnDisable()
{
startButton.onClick.RemoveListener(NotifyStartClicked);
}
private void NotifyStartClicked()
{
StartClicked?.Invoke();
}
}
이 구조에서 MainMenuView는 GameSession의 존재를 모른다. 버튼을 UI Toolkit의 Button으로 교체해도 이벤트를 전달하는 View 구현만 바꾸면 된다.
3. Presenter에서 화면 이벤트와 유스케이스를 연결한다
Presenter는 UI 이벤트를 구독하고 코어 서비스와 내비게이터를 호출한다.
using System;
using VContainer.Unity;
public sealed class MainMenuPresenter : IStartable, IDisposable
{
private readonly MainMenuView view;
private readonly GameSession session;
private readonly ISceneNavigator navigator;
public MainMenuPresenter(
MainMenuView view,
GameSession session,
ISceneNavigator navigator)
{
this.view = view;
this.session = session;
this.navigator = navigator;
}
public void Start()
{
view.StartClicked += OnStartClicked;
}
public void Dispose()
{
view.StartClicked -= OnStartClicked;
}
private void OnStartClicked()
{
session.StartNewGame();
navigator.LoadGame();
}
}
이제 시작 버튼의 처리 흐름은 Presenter에 모인다. 동일한 GameSession을 다른 화면에서도 사용해야 한다면 화면마다 싱글턴을 만들지 말고 서비스의 수명을 컨테이너 등록으로 결정한다.
4. LifetimeScope를 Composition Root로 사용한다
VContainer에서는 LifetimeScope.Configure에 등록을 모은다. 이곳이 객체 생성 규칙을 아는 유일한 장소가 된다.
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;
public sealed class MainMenuScope : LifetimeScope
{
[SerializeField] private MainMenuView mainMenuView;
protected override void Configure(IContainerBuilder builder)
{
builder.Register<GameSession>(Lifetime.Scoped);
builder.Register<ISceneNavigator, SceneNavigator>(Lifetime.Scoped);
builder.RegisterComponent(mainMenuView);
builder.RegisterEntryPoint<MainMenuPresenter>();
}
}
public sealed class SceneNavigator : ISceneNavigator
{
public void LoadGame()
{
SceneManager.LoadScene("Game");
}
}
Lifetime.Scoped는 현재 LifetimeScope가 살아 있는 동안 같은 인스턴스를 제공한다. 메뉴 씬 전용 상태라면 적합하다. 여러 씬을 넘어서 유지할 세션이라면 부모 LifetimeScope에 등록하거나 부트스트랩 범위에서 Lifetime.Singleton을 선택한다. 반대로 Lifetime.Transient는 요청할 때마다 새 객체를 만들므로 상태를 공유해야 하는 서비스에는 맞지 않는다.
VContainer와 Zenject는 무엇이 다르고 무엇이 같은가?
두 도구 모두 생성자 주입과 수명 관리로 결합도를 낮춘다. 프로젝트에 이미 Zenject가 있다면 같은 구조를 유지한 채 Installer만 사용하면 된다. 새 구조를 도입할 때는 팀의 기존 기반과 패키지 유지 정책을 먼저 따른다.
| 항목 | VContainer | Zenject |
|---|---|---|
| 등록 중심 객체 | LifetimeScope와 IContainerBuilder | MonoInstaller와 DiContainer |
| Presenter 시작 | RegisterEntryPoint<T>() | BindInterfacesAndSelfTo<T>().AsSingle() |
| 컴포넌트 등록 | RegisterComponent() | Bind<T>().FromInstance() |
| 핵심 설계 원칙 | 생성자 주입과 명시적 수명 | 생성자 주입과 명시적 수명 |
Zenject의 같은 등록은 다음처럼 표현할 수 있다.
using Zenject;
public sealed class MainMenuInstaller : MonoInstaller
{
[SerializeField] private MainMenuView mainMenuView;
public override void InstallBindings()
{
Container.Bind<GameSession>().AsSingle();
Container.Bind<ISceneNavigator>().To<SceneNavigator>().AsSingle();
Container.Bind<MainMenuView>().FromInstance(mainMenuView);
Container.BindInterfacesAndSelfTo<MainMenuPresenter>().AsSingle();
}
}
VContainer와 Zenject를 한 씬에 함께 사용해 같은 객체 그래프를 구성하는 것은 피하는 편이 좋다. 컨테이너가 둘이면 생성 주체와 수명 경계도 둘이 되어 의존성 분리의 이점이 줄어든다.
DI 수명은 어떻게 결정해야 할까?
수명 선택은 객체가 누구와 상태를 공유해야 하는지로 결정한다.
| 대상 | 권장 수명 | 이유 |
|---|---|---|
| 버튼, 패널, View | 씬의 MonoBehaviour 인스턴스 | 씬 계층과 함께 생성·파괴 |
| Presenter | Scoped | 해당 화면의 이벤트 구독 수명과 일치 |
| 매치 진행 상태 | 매치 단위 Scoped | 새 매치에서 깨끗하게 초기화 |
| 저장소 접근, 설정 읽기 | 앱 루트 Singleton | 여러 씬에서 같은 구현과 설정을 공유 |
| 일회성 명령 객체 | Transient | 상태 공유가 필요 없음 |
가장 흔한 실수는 모든 것을 Singleton으로 등록하는 것이다. 전역 수명은 편하지만 씬을 다시 열어도 상태와 이벤트 구독이 남을 수 있다. 상태가 필요한 기간과 LifetimeScope의 기간을 맞추는 것이 안전하다.
UI 의존성 분리를 도입하는 순서
기존 프로젝트를 한 번에 전환할 필요는 없다. 새 화면이나 변경 빈도가 높은 화면부터 다음 순서로 옮기면 된다.
- UI 콜백에서 직접 호출하는 싱글턴과 Unity API를 찾는다.
- 게임 규칙을 순수 C# 서비스 또는 유스케이스 메서드로 옮긴다.
- View에는 이벤트와 표시 메서드만 남기고 Presenter가 이를 연결한다.
LifetimeScope또는Installer에 객체 생성과 수명을 등록한다.- 씬 재진입 시 이벤트가 중복 구독되지 않는지 확인한다.
특히 AddListener, C# event, UniRx 구독처럼 해제가 필요한 연결은 Presenter의 Dispose 또는 View의 OnDisable에서 반드시 해제한다. DI 컨테이너는 객체 생성을 정리해 주지만 임의로 추가한 이벤트 구독의 의미까지 대신 판단해 주지는 않는다.
자주 묻는 질문 (FAQ)
DI를 쓰면 작은 Unity 프로젝트에도 도움이 되나?
도움이 된다. 다만 모든 클래스를 등록할 필요는 없다. UI가 싱글턴과 게임 규칙을 직접 호출하기 시작하는 지점부터 Presenter와 핵심 서비스만 분리해도 효과가 크다.
Presenter와 ViewModel을 반드시 함께 써야 하나?
아니다. uGUI의 버튼과 텍스트를 연결하는 수준에서는 Presenter 하나면 충분하다. 양방향 데이터 바인딩이나 복잡한 화면 상태가 실제로 필요할 때 ViewModel을 추가한다.
코어 로직에 인터페이스를 모두 만들어야 하나?
아니다. 테스트 대역이 필요하거나 Unity·네트워크·저장소처럼 경계를 넘는 의존성에 우선 적용한다. 같은 계층의 단순 도메인 클래스까지 인터페이스로 감싸면 탐색 비용만 늘어난다.
정리
VContainer와 Zenject의 목적은 DI 문법 자체가 아니라 의존성의 방향을 바로잡는 데 있다. View는 입력과 표시를 Presenter는 화면 흐름을 코어 서비스는 게임 규칙을 맡긴다. 객체 생성과 수명 관리는 LifetimeScope 또는 Installer 한곳에 모으면 UI 교체, 테스트, 씬 재진입 대응이 훨씬 단순해진다.



