1.01 ^ 365 = 37.78

[Unity] 유니티 MVC 패턴 적용하기 본문

프로그래밍/Unity

[Unity] 유니티 MVC 패턴 적용하기

Youngchangoon 2021. 4. 19. 22:38

Unity로 게임을 만들다 보면 필연적으로 데이터와 로직이 결합된 상태로 개발하게 된다.
프로젝트가 커질수록 점점 코드는 복잡해지고, 데이터와 로직이 뒤섞여 더 이상 유지보수가 힘든 상황이 이르게 된다.
따라서 데이터(Model)와 로직(Controller)을 분리해주는 패턴으로부터 개발을 시작하는 게 좋다.

MVC 패턴

MVC 패턴은 ModelView, 그리고 둘을 잇는 Controller라는 요소가 들어있는 패턴이다.

Model

  • 게임의 데이터가 되는 요소
  • 로직이 아닌 순수 데이터가 들어가야 함

Controller

  • 게임의 핵심 로직들을 담당한다.
  • Model들을 조작하고 업데이트된 Model들을 View에 통지해준다.

View

  • 게임 내 외적으로 보이는 모든 요소들이다.
  • Controller에서 받은 데이터들을 화면에 출력하는 역할을 한다.

마지막으로 결합도를 줄이기 위해 Model과 View는 Controller의 존재를 몰라야 한다.

Unity에 적용하기

유니티(C#)에 적용하기 위해선 앞서 구현되어야 할 몇 가지 필요 사항이 있다.

  1. Model의 변화를 View가 감지해야 함
  2. View에서 발생되는 Event를 Controller에 전달해야 함.
  3. 각각 Controller들끼리 쉬운 통신이 되어야 함.

위의 조건들을 충족시키는 애셋이 있는데 바로 "Zenject", "UniRx"라는 애셋이다.

Zenject (Extenject)

 

modesttree/Zenject

Dependency Injection Framework for Unity3D . Contribute to modesttree/Zenject development by creating an account on GitHub.

github.com

Zenject는 의존성 주입 (Dependency Injection)이 들어있는 프레임워크다. 프로그램의 초기화 부분에서 미리 어떤 클래스 혹은 인터페이스가 어떤 인스턴스를 받을지 정해놓고, 실행 중에는 서로 다른 구성요소 간 종속성과 통신을 쉽고 느슨하게 관리해준다.

대부분 게임을 만들 때 구성요소 별로 Singleton을 사용하여 관리한다. 그러나 어느 시점에 어떻게 초기화 되는지는 보장할 수 없다. 또한 서비스의 로직이 중간에 바뀔 경우, 수정하기도 매우 번거롭다.
Zenject를 사용하면 초기화 시점이 보장되고, Interface의 할당도 가능하여 쉽게 로직들을 수정할 수 있다. 

Zenject에선 의존성 주입 외에도 몇 가지 기능을 더 지원하는데, 그중 Signal이라는 기능을 사용하면 서로의 클래스를 호출하지 않아도 손쉽게 View와 Controller사이의 통신을 할 수 있다.

UniRx ( Unity + Reactivex )

 

neuecc/UniRx

Reactive Extensions for Unity. Contribute to neuecc/UniRx development by creating an account on GitHub.

github.com

UniRx는 Unity+Rx의 줄임말로, .NET의 Reactive Extensions을 Unity버전으로 다시 구현한 것이다. Rx는 옵저빙이 가능한 데이터 스트림을 생성해 데이터를 조작하는 프로그래밍 패러다임이다. 이렇게 만듬으로 다루기 힘든 비동기 작업들을 손쉽게 처리하거나, 데이터의 값 변화를 쉽게 캐치할 수 있다.

MVC 패턴의 구현중 Model의 값 변화를 캐치하기 위해 UniRx에서 사용한 기능은 ReactiveProperty<T> 클래스다.
데이터 스트림을 만들어 값이 변할 때마다 Controller에서 Subscribe을 해주고, View를 호출해 값을 전달해준다.

이외에도 View단에서 UniRx의 이벤트 스트림을 만들어 더블클릭, 입력 시간 감지등 다양한 처리를 할 수 있다.

간단한 예시 - 큐브 움직이기

프로젝트 생성

우선 Unity 프로젝트를 3D로 생성해본다.
생성 후, 위에선 언급했던 Zenject와 UniRx를 다운로드하고 Scene에는 Cube를 하나 소환한다.

Zenject - SceneContext 및 MonoIntaller 추가

Hierarchy에서 오른쪽 마우스를 누른뒤, Zenject-Scene Context

위 사진과 같이 Scene Context를 생성한다. SceneContext는 이 Scene에 들어올 때 제일 처음 실행되는 진입점이다. 이 부분에 MonoInstaller를 추가해야 한다.

Project뷰에서 오른쪽 마우스를 누르고 Create-Zenject-Mono Installer

Zenject에서 제공하는 Create 메뉴를 이용하여 코드를 생성한다. 

Mono Installers에 추가!

코드를 생성한 뒤, Add Component로 Installer를 추가해주고, Mono Installers에 추가해준다.

Scripting

Model 부분에선 데이터만 들고 있다.

using UniRx;
using UnityEngine;

public class CubeModel
{
    public ReactiveProperty<Vector3> position;

    public CubeModel()
    {
        position = new ReactiveProperty<Vector3>(Vector2.zero);
    }
}

-------

Controller 부분에선 핵심 로직과 Model과 View의 연결을 담당한다.

using UniRx;
using UnityEngine;
using Zenject;

 // Cube Moving 로직 코드
public class CubeMoveController : IInitializable, ITickable
{
    [Inject] private CubeModel _cubeModel;
    [Inject] private CubeView _cubeView;
    [Inject] private SignalBus _signalBus;

    private float _speed = 5f;

    // 초기화 부분
    public void Initialize()
    {
        // Cube Position 데이터 스트림을 View에서 구독한다.
        _cubeModel.position.Subscribe(_cubeView.UpdateMove);
        
        // View에서 넘어오는 Event를 받아서 처리한다.
        _signalBus.Subscribe<CubeResetSignal>(ResetCubePosition);
    }

    // Update Logic
    public void Tick()
    {
        var horizontal = Input.GetAxisRaw("Horizontal");
        var vertical = Input.GetAxisRaw("Vertical");
        var cubePosition = _cubeModel.position.Value;
        
        if (horizontal != 0)
            cubePosition.x += horizontal * _speed * Time.deltaTime;

        if (vertical != 0)
            cubePosition.z += vertical * _speed * Time.deltaTime;

        _cubeModel.position.Value = cubePosition;
    }

    public void ResetCubePosition()
    {
        _cubeModel.position.Value = Vector3.zero;
    }
}

-------

View 부분에선 Controller에서 넘어온 데이터를 받아 처리한다.

using System;
using UnityEngine;
using Zenject;

public class CubeView : MonoBehaviour
{
    [Inject] private SignalBus _signalBus;
    
    public void UpdateMove(Vector3 position)
    {
        transform.position = position;
    }

    // View단에서 넘어가는 Event의 예시를 들기위해 Reset기능을 만들어보았다.
    private void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))
            _signalBus.Fire(new CubeResetSignal());
    }
}

------

View에서 Controller로 넘어가는 Event의 데이터를 담는 Signal이다.

public class CubeResetSignal
{
}

------

마지막으로, 이 모든 클래스의 진입점인 Installer의 코드를 작성한다.
( cubeView는 Scene에서 큐브를 만들고 연결해줘야 한다. )

using UnityEngine;
using Zenject;

public class GameInstaller : MonoInstaller
{
    [SerializeField] private CubeView cubeView;
    
    public override void InstallBindings()
    {
        // Cube모델을 컨테이너에 Single(1개)로 담는다.
        // 기본적으로 다른곳에서 불릴때까지 생성되지 않는다. ( lazy )
        Container.Bind<CubeModel>().AsSingle();

        // CubeMoveController에 구현된 Interface들을 CubeMoveController에 담는다.
        // ---
        // Container.Bind(typeof(IInitializable), typeof(ITickable)).To<CubeMoveController>().AsSingle();
        // 위의 문구를 줄인것이다.
        Container.BindInterfacesAndSelfTo<CubeMoveController>().AsSingle();

        // View
        Container.Bind<CubeView>().FromInstance(cubeView);
        
        // Signals
        Container.DeclareSignal<CubeResetSignal>();
        
        SignalBusInstaller.Install(Container);
    }
}

이 모든 작업이 끝나면 큐브를 자유롭게 이동할 수 있게 된다.

결론

이 패턴의 장점은 데이터와 로직, 그리고 뷰를 나눠서 개발할 수 있다는 장점이 있다.
그러나 단점으로는 패턴에 대한 숙지가 먼저 되어있어야 하고, 고작 큐브 움직이는 코드를 만드는데도 꽤 많은 스크립트 파일이 만들어진다. 

그러나 단점은 유지보수 성적인 측면으로 보았을 때 충분이 보완이 가능하다고 생각한다. 기존 개발 방식에 문제점을 느끼고, 새로운 방식의 개발 방법을 찾는다면 한번 시도해봐도 좋을 것 같다.

---

중간중간 Zenject의 사용방법이나 UniRx의 사용방법은 적지 않았는데, 이 글의 주제와 맞지않고, 너무 길어져서 적지 않았다.