Unity VContainer·Zenject로 UI View와 Controller를 완전 분리하는 방법

Unity VContainer·Zenject로 UI View와 Controller를 완전 분리하는 방법

Unity에서 VContainer·Zenject 의존성 주입으로 UI View의 버튼·텍스트 참조와 Controller의 게임 로직을 분리한다. 테스트 가능한 구조, 프리팹 등록, 이벤트 구독 해제까지 C# 예제로 정리한다.

UI View와 Controller를 분리하려면 View는 Unity UI 컴포넌트와 사용자 입력만 노출하고 Controller는 View 인터페이스를 통해 상태를 갱신해야 한다. VContainer와 Zenject 같은 DI 컨테이너는 생성자 주입으로 두 객체를 조립하므로 View가 Controller를 직접 찾거나 생성하지 않게 만든다.

Zenject(젠젝트)는 Unity(유니티) 엔진 전용 의존성 주입(Dependency Injection, DI) 프레임워크입니다.

이 구조의 핵심은 View → 이벤트 발행, Controller → 상태 처리 및 View 갱신이라는 단방향 책임 분리다. MonoBehaviour를 모두 제거하는 것이 목적이 아니라 Unity API 의존성을 화면 계층에 가두는 것이 목적이다.

Unity UI에서 View와 Controller가 강하게 결합되는 이유는 무엇인가?

가장 흔한 구현은 버튼 이벤트 안에 게임 규칙과 UI 갱신을 함께 작성하는 방식이다.

public sealed class ShopPanel : MonoBehaviour
{
    [SerializeField] private Button buyButton;
    [SerializeField] private TextMeshProUGUI goldText;

    private int gold = 100;

    private void Awake()
    {
        buyButton.onClick.AddListener(() =>
        {
            gold -= 10;
            goldText.text = gold.ToString();
            // 인벤토리 추가, 사운드 재생, 서버 요청 등이 계속 붙는다.
        });
    }
}

이 코드는 작은 화면에서는 빠르지만 UI 프리팹이 다음 책임을 동시에 가진다.

책임문제
Unity UI 참조 보관프리팹 없이는 테스트하기 어렵다.
클릭 입력 처리입력 흐름을 다른 UI나 키보드 입력으로 바꾸기 어렵다.
구매 규칙 실행가격, 재화, 인벤토리 정책이 화면에 섞인다.
화면 갱신비즈니스 로직 변경이 프리팹 수정으로 이어질 수 있다.

특히 FindObjectOfType, 싱글턴, GetComponent 호출로 Controller나 서비스를 찾기 시작하면 의존 관계가 코드에 숨는다. 어느 객체가 무엇을 필요로 하는지 생성자만 봐서는 알 수 없고 씬 초기화 순서에도 민감해진다.

VContainer·Zenject DI 구조는 어떻게 설계할까?

권장 경계는 다음과 같다.

flowchart LR
    Player[플레이어 입력] --> View[ShopView<br/>MonoBehaviour]
    View -->|BuyRequested 이벤트| Controller[ShopController]
    Controller --> Service[ShopService]
    Service --> Model[PlayerWallet]
    Controller -->|SetGold / SetBuyEnabled| View

ShopViewButton, TextMeshProUGUI 같은 Unity 컴포넌트를 보유한다. ShopControllerIShopView, IShopService만 알고 구체적인 프리팹이나 MonoBehaviour를 모른다. 따라서 Controller는 EditMode 테스트에서 일반 C# 객체로 생성할 수 있다.

Unity UI View와 Controller가 DI 컨테이너를 통해 조립되고 이벤트와 화면 갱신이 분리되는 구조

역할을 먼저 인터페이스로 고정한다

using System;

public interface IShopView
{
    event Action BuyRequested;

    void SetGold(int gold);
    void SetBuyEnabled(bool enabled);
}

public interface IShopService
{
    bool TryBuy(int price);
    int CurrentGold { get; }
}

인터페이스는 View가 Controller의 메서드를 호출하게 만들기 위한 장치가 아니다. Controller가 필요한 화면 기능만 선언하는 계약이다. 예를 들어 구매 결과를 표시할 필요가 없다면 ShowPurchaseResult를 미리 추가하지 않는다.

Step 1. View는 Unity 컴포넌트와 입력 전달만 담당한다

using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public sealed class ShopView : MonoBehaviour, IShopView
{
    [SerializeField] private Button buyButton;
    [SerializeField] private TextMeshProUGUI goldText;

    public event Action BuyRequested;

    private void Awake()
    {
        buyButton.onClick.AddListener(HandleBuyClicked);
    }

    private void OnDestroy()
    {
        buyButton.onClick.RemoveListener(HandleBuyClicked);
    }

    public void SetGold(int gold)
    {
        goldText.text = gold.ToString();
    }

    public void SetBuyEnabled(bool enabled)
    {
        buyButton.interactable = enabled;
    }

    private void HandleBuyClicked()
    {
        BuyRequested?.Invoke();
    }
}

View에 재화 차감, 인벤토리 변경, 네트워크 요청을 넣지 않는다. 반대로 Controller가 TextMeshProUGUIButton을 직접 받는 것도 피한다. UI 툴킷 교체나 프리팹 구조 변경의 영향 범위를 View로 제한할 수 있기 때문이다.

Step 2. Controller는 생성자 주입으로 의존성을 명시한다

using System;

public sealed class ShopController : IDisposable
{
    private const int ItemPrice = 10;

    private readonly IShopView view;
    private readonly IShopService shopService;

    public ShopController(IShopView view, IShopService shopService)
    {
        this.view = view;
        this.shopService = shopService;

        view.BuyRequested += HandleBuyRequested;
        Refresh();
    }

    public void Dispose()
    {
        view.BuyRequested -= HandleBuyRequested;
    }

    private void HandleBuyRequested()
    {
        shopService.TryBuy(ItemPrice);
        Refresh();
    }

    private void Refresh()
    {
        int gold = shopService.CurrentGold;
        view.SetGold(gold);
        view.SetBuyEnabled(gold >= ItemPrice);
    }
}

IDisposable 구현은 선택이 아니라 이벤트 구독이 있는 Controller의 종료 계약이다. 화면을 닫은 뒤에도 View 이벤트가 남으면 중복 처리나 파괴된 객체 접근 문제가 발생한다. 컨테이너의 수명 범위에서 Dispose가 호출되도록 등록해야 한다.

Step 3. VContainer와 Zenject에서 수명 범위를 등록한다

화면이 씬에 하나만 있고 씬과 함께 사라진다면 Scoped 또는 Zenject의 AsSingle을 씬 컨텍스트에서 사용한다. 팝업을 열 때마다 프리팹을 생성한다면 팝업 단위 LifetimeScope 또는 Factory가 더 적절하다.

상황VContainer 예시Zenject 예시주의점
씬에 고정된 HUDRegisterComponentInHierarchy + ScopedFromComponentInHierarchy + AsSingle씬 전환 시 함께 폐기한다.
팝업 프리팹팝업 LifetimeScope 또는 FactoryFromComponentInNewPrefab인스턴스별 Controller가 필요하다.
저장소·API 클라이언트SingletonAsSingleUI 객체를 싱글턴으로 두지 않는다.

VContainer 등록 예시

using VContainer;
using VContainer.Unity;

public sealed class ShopLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<ShopView>()
            .As<IShopView>();

        builder.Register<ShopService>(Lifetime.Scoped)
            .As<IShopService>();

        builder.RegisterEntryPoint<ShopController>(Lifetime.Scoped);
    }
}

RegisterEntryPoint<ShopController>는 VContainer가 Controller 생명주기를 관리하도록 한다. IStartable이 필요하지 않은 경우에도 등록과 폐기를 컨테이너에 맡길 수 있으며 IDisposable은 스코프 종료 시 처리된다.

Zenject 등록 예시

using Zenject;

public sealed class ShopInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<IShopView>()
            .To<ShopView>()
            .FromComponentInHierarchy()
            .AsSingle();

        Container.Bind<IShopService>()
            .To<ShopService>()
            .AsSingle();

        Container.BindInterfacesTo<ShopController>()
            .AsSingle()
            .NonLazy();
    }
}

Zenject에서 BindInterfacesTo<ShopController>()를 사용하면 IDisposable 인터페이스도 컨테이너가 인식한다. NonLazy()는 첫 UI 입력 전 Controller를 생성해 초기 화면 상태를 즉시 반영하려는 의도다.

VContainer와 Zenject 중 무엇을 선택해야 할까?

두 라이브러리 모두 생성자 주입, 인스톨러 또는 스코프 기반 등록, 수명 관리라는 핵심 목적은 같다. 이미 Zenject 기반 프로젝트라면 UI만 VContainer로 바꾸기보다 기존 컨테이너 규칙을 유지하는 편이 비용이 적다.

기준VContainerZenject
프로젝트 적용Unity용 경량 DI 컨테이너로 구성하기 좋다.기존 Unity 프로젝트와 커뮤니티 예제가 많은 편이다.
등록 방식LifetimeScope 중심으로 범위를 구성한다.MonoInstaller, SceneContext, ProjectContext 구성이 중심이다.
마이그레이션 판단신규 프로젝트 또는 VContainer 생태계 사용 시 적합하다.프로젝트가 이미 Zenject 계약에 의존한다면 유지가 안전하다.

중요한 것은 컨테이너 선택보다 의존성 방향이다. ShopViewShopController를 직접 주입받아 메서드를 호출하면 DI를 쓰더라도 분리 효과가 줄어든다. View는 이벤트를 노출하고 Controller가 구독하는 방향을 유지하는 편이 화면 재사용과 테스트에 유리하다.

Controller는 어떻게 테스트할 수 있는가?

Controller가 Unity API에 의존하지 않으면 가짜 View와 Service로 동작을 검증할 수 있다.

using System;
using NUnit.Framework;

public sealed class ShopControllerTests
{
    [Test]
    public void BuyRequested_UpdatesGoldAndButtonState()
    {
        var view = new FakeShopView();
        var service = new FakeShopService(initialGold: 10);
        using var controller = new ShopController(view, service);

        view.RaiseBuyRequested();

        Assert.That(view.DisplayedGold, Is.EqualTo(0));
        Assert.That(view.IsBuyEnabled, Is.False);
    }

    private sealed class FakeShopView : IShopView
    {
        public event Action BuyRequested;
        public int DisplayedGold { get; private set; }
        public bool IsBuyEnabled { get; private set; }

        public void SetGold(int gold) => DisplayedGold = gold;
        public void SetBuyEnabled(bool enabled) => IsBuyEnabled = enabled;
        public void RaiseBuyRequested() => BuyRequested?.Invoke();
    }
}

이 테스트는 버튼 클릭 자체가 아니라 구매 요청 이후 화면 상태가 올바른지를 확인한다. GameObject, 씬 로딩, 프리팹 없이 실행할 수 있으므로 UI 규칙이 늘어날수록 효과가 커진다.

자주 묻는 질문 (FAQ)

View 인터페이스가 너무 많아지면 어떻게 해야 하나?

화면 단위로 하나의 인터페이스를 우선 유지하고 여러 Controller가 같은 화면을 독립적으로 제어해야 할 때만 기능 단위 인터페이스로 분리한다. 처음부터 모든 UI 요소를 인터페이스로 쪼개면 오히려 추적 비용이 커진다.

Controller도 MonoBehaviour로 만들어도 되나?

가능하지만 Update, 코루틴, Inspector 참조가 필요하지 않다면 일반 C# 클래스로 두는 편이 테스트와 수명 관리에 유리하다. Unity 생명주기가 필요한 코드는 View나 별도 Presenter·Adapter에 한정한다.

이벤트 대신 Controller 메서드를 View에서 직접 호출하면 안 되나?

작은 화면에서는 동작하지만 View가 Controller라는 구체적 구현을 알아야 한다. BuyRequested 같은 의미 기반 이벤트를 사용하면 입력원이 버튼에서 키보드·터치·튜토리얼 자동 진행으로 바뀌어도 Controller 계약을 유지할 수 있다.

정리

VContainer와 Zenject는 UI 구조를 자동으로 개선하는 도구가 아니다. MonoBehaviour인 View에는 Unity UI 접근과 입력 전달만 남기고 일반 C# Controller에는 규칙과 화면 상태 계산을 둔 뒤 생성자 주입으로 조립할 때 효과가 생긴다.

이후 새 화면을 만들 때는 다음 세 가지만 점검하면 된다.

  1. View가 게임 규칙이나 서비스 호출을 직접 수행하지 않는가?
  2. Controller가 Button, TMP_Text, GameObject 같은 Unity UI 타입을 참조하지 않는가?
  3. 이벤트 구독과 해제가 컨테이너 수명 범위 안에서 보장되는가?

이 기준을 지키면 프리팹 수정, UI 테스트, 팝업 재사용이 서로 얽히는 문제를 크게 줄일 수 있다.

#Unity#VContainer#Zenject#의존성 주입#UI 아키텍처#C##MVP 패턴

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs