언리얼 팀프로젝트 개발기 3 - UI를 위한 MVC 패턴의 기본 구조
Project Priest의 UI 브랜치에서 MVC 패턴의 역할을 정리하고, Model·View 인터페이스와 Delegate 기반 Controller의 기본 구조를 만든 과정을 기록했습니다.
UI를 위한 MVC 패턴의 기본 구조
UI의 기반이 되는 MVC 패턴
UI 작업 브랜치인 features/NC2-6_CombatUI에서는 화면과 게임 데이터의 역할을 분리하기 위해 MVC 패턴의 기본 구조를 만들었다. 체력이나 탄약을 표시하는 Widget이 데이터 변경과 게임 규칙까지 모두 처리하면, UI를 수정할 때 전투 코드까지 함께 살펴봐야 한다. 그래서 각 역할이 어떤 정보를 주고받을지 먼저 정리했다.
Model, View, Controller의 역할
MVC는 Model, View, Controller로 책임을 나누는 설계 패턴이다.
| 구성 요소 | 책임 | 이번 UI에 적용할 역할 |
|---|---|---|
| Model | 데이터와 상태를 관리하고 변경을 알린다. | 캐릭터의 체력 등 게임 상태와 변경 알림 |
| View | 데이터를 화면에 표시하고 사용자 입력을 전달한다. | HUD의 표시와 버튼 이벤트 전달 |
| Controller | 입력과 상태 변경을 받아 필요한 처리를 연결한다. | 캐릭터 상태 변경을 해석해 HUD 갱신으로 연결 |
예를 들어 캐릭터가 피해를 받으면 게임 로직에서 체력이 바뀐다. Model은 체력이 변경되었다는 사실을 알리고, Controller는 이를 받아 View에 변경된 상태를 반영하도록 연결한다. View는 전달받은 값을 체력바나 텍스트로 표현한다. 반대로 버튼 입력은 View에서 이벤트로 전달하고, Controller가 해당 입력에 맞는 동작을 처리하도록 구성할 수 있다.
이렇게 책임을 나누면 체력 계산 방식과 체력바의 표현 방식을 따로 수정하기 쉬워진다. 팀원 간에도 데이터 변경 알림과 화면 갱신의 연결 지점을 기준으로 작업을 나눌 수 있다. 여기서 Controller는 MVC의 역할 이름이며, 언리얼의 APlayerController를 반드시 뜻하는 것은 아니다. 이번 기본 Controller는 UObject를 상속한 UMvcControl로 작성했다.
*그림 1. 이번 프로젝트에서 연결하려는 MVC 구조. Model은 상태 변경을 알리고, Controller는 변경 내용을 화면 갱신으로 연결한다. View의 입력도 Controller로 전달하며, Controller는 입력에 따라 Model의 상태 변경을 요청한다.*
그림에서 파란색 Model은 데이터, 초록색 View는 화면을 담당한다. 가운데 보라색 Controller가 두 역할 사이의 처리를 연결하므로, Model이 구체적인 체력바 Widget을 직접 조작할 필요가 없어진다. Controller에서 Model로 향하는 아래쪽 화살표는 상태 변경 요청을 뜻한다. Controller가 Model의 변경 함수를 호출하면 Model이 자신의 규칙에 따라 데이터를 수정하고, 위쪽 화살표를 통해 변경 결과를 다시 알리는 흐름이다. 화살표는 객체의 상속 관계가 아니라 알림과 처리 요청의 방향을 뜻한다.
인터페이스로 Model과 View의 연결 규칙 정하기
캐릭터와 UI Widget은 원래 하는 일이 다르다. 캐릭터는 게임 속에서 움직이고 체력 같은 상태를 가지며, Widget은 체력바나 버튼을 화면에 보여준다. MVC를 적용한다고 해서 이 클래스들을 처음부터 새로 만들 필요는 없다. 기존 역할을 유지하면서 MVC에 필요한 기능을 추가하면 된다.
이를 위해 인터페이스를 사용했다. 여기서 인터페이스는 “이 역할을 맡으려면 다음 함수들을 제공해야 한다”는 약속이다. 함수가 실제로 어떻게 동작할지는 인터페이스를 사용하는 각 클래스에서 구현한다.
이번에는 두 가지 약속을 만들었다.
IMvcModel: “내 데이터가 바뀌면 알려줄 수 있다.”IMvcView: “사용자가 UI를 조작하면 알려줄 수 있다.”
Controller는 이 약속을 기준으로 Model과 View에 알림을 받을 수 있도록 등록한다. 캐릭터가 어떻게 움직이는지, Widget이 어떤 모양인지는 알림을 연결하는 공통 코드에서 다룰 필요가 없다. 다만 체력을 읽어 체력바에 반영하는 구체적인 처리는 파생 Controller에서 연결해야 한다.
기존 클래스를 그대로 사용하려면
캐릭터는 이미 ACharacter를 상속하고, UI Widget은 이미 UUserWidget을 상속한다. 이 상속 관계는 그대로 두고 옆에 MVC 인터페이스를 추가한다.
// 캐릭터 기능 + Model의 약속
class AExampleCharacter : public ACharacter, public IMvcModel
{
// 데이터 변경을 알리는 함수 등을 구현
};
// Widget 기능 + View의 약속
class UExampleWidget : public UUserWidget, public IMvcView
{
// UI 입력을 알리는 함수 등을 구현
};
위 코드는 상속 관계만 보여주는 예시다. 캐릭터는 계속 캐릭터로 동작하면서 Model 역할도 맡고, Widget은 계속 화면을 표시하면서 View 역할도 맡는다.
이 방식이 필요한 이유는 언리얼의 상속 제한과도 관련이 있다. 하나의 클래스가 여러 UObject 기반 클래스를 동시에 상속할 수는 없다. 예를 들어 ACharacter를 상속한 클래스에 또 다른 UObject 기반 Model 클래스를 부모로 추가하는 방식은 사용할 수 없다.
대신 IMvcModel처럼 언리얼 인터페이스의 I 타입은 함께 상속할 수 있다. 즉, 기존 부모 클래스의 기능은 유지하고 MVC에 필요한 약속만 추가하는 것이다. C++의 모든 다중 상속이 금지된다는 의미는 아니다.
이름 앞에 붙는 U와 I도 역할이 다르다. UMvcModel과 UMvcView는 언리얼이 인터페이스를 인식하도록 등록하는 쪽이고, IMvcModel과 IMvcView는 실제 클래스가 구현해야 할 함수들을 선언하는 쪽이다.
Model은 무엇을 약속할까?
IMvcModel에는 알림을 받는 Controller를 등록하고 해제하는 함수, 데이터 변경을 알리는 함수가 있다.
// 이 Model의 변경 알림을 받을 Controller 등록
virtual FDelegateHandle AddListener(UMvcControl* Control) = 0;
// 등록했던 알림 연결 해제
virtual void RemoveListener(FDelegateHandle Handle) = 0;
// 어떤 데이터가 바뀌었는지 알림
virtual void InvokePropertyChanged(uint8 PropertyName) = 0;
예를 들어 체력이 바뀌었다면 InvokePropertyChanged()로 “체력 항목이 바뀌었다”고 알리도록 구현한다. 여기서 PropertyName은 새 체력 값 자체가 아니라 어떤 항목이 바뀌었는지를 구분하는 번호다. 체력과 탄약에 어떤 번호를 사용할지는 실제 Model을 구현할 때 정해야 한다.
AddListener()가 반환하는 FDelegateHandle은 등록한 연결을 나중에 찾기 위한 표식이다. 더 이상 알림이 필요하지 않으면 이 값을 RemoveListener()에 전달해 연결을 해제한다.
View는 무엇을 약속할까?
IMvcView도 알림 연결을 등록하고 해제하는 함수를 제공한다. Model과 다른 점은 데이터 변경 대신 사용자가 UI에서 어떤 동작을 했는지 전달한다는 것이다.
virtual void InvokeViewEvent(
EViewEventType EventName,
UEventParameterBase* Parameter) = 0;
EventName은 “버튼을 눌렀다”와 같은 동작의 종류이고, Parameter는 그 동작에 필요한 추가 정보다. 예를 들어 버튼 클릭이라면 어떤 버튼을 눌렀는지 함께 전달할 수 있다.
현재는 버튼 클릭을 나타내는 ButtonClicked와 버튼 정보를 담는 UButtonClickedEvent의 기본 형태를 준비했다. 이후 실제 버튼과 처리할 동작을 연결하면, Widget은 입력을 알리고 Controller는 그 입력에 맞는 처리를 맡게 된다.
Delegate로 변경과 입력 전달하기
MvcEvents.h에는 Model 변경과 View 입력을 전달하는 Multicast Delegate를 선언했다. Model 변경 알림에는 Model과 속성 식별 값을, View 이벤트에는 View와 이벤트 종류, 매개변수 객체를 전달한다.
UMvcControl은 SetModel()과 SetView()에서 각 대상의 리스너로 등록되고, 반환받은 FDelegateHandle을 저장한다. 전달된 알림은 파생 Controller의 HandleModelChanged()와 HandleViewEvent()에서 처리하도록 나눴다. 종료 시 리스너를 해제하는 코드도 BeginDestroy()에 마련했다.
*그림 2. 위쪽은 상태 변경이 화면에 반영되는 흐름이고, 아래쪽은 UI 입력이 게임 동작으로 전달되는 흐름이다. 최종 연결 후의 동작을 설명하는 개념 예시다.*
체력이 100에서 80으로 줄었다면 Model이 변경된 속성을 알리고, Controller의 HandleModelChanged()에서 해당 변경을 처리해 View의 체력바와 텍스트에 반영하도록 연결한다. 체력을 변경하는 게임 로직과 변경된 체력을 그리는 UI의 책임을 나누는 것이다.
버튼 입력은 View에서 시작한다. InvokeViewEvent()로 이벤트 종류와 매개변수를 전달하면 Controller의 HandleViewEvent()가 이를 해석하고 필요한 게임 동작을 요청하도록 확장한다. 모든 버튼이 Model을 바꾸는 것은 아니며, 입력에 따라 화면 전환 같은 처리를 연결할 수도 있다. 현재는 이 흐름의 기본 통로를 마련했으며, 그림의 실제 HUD 갱신과 입력 처리는 후속 구현 범위다.
현재 구현 범위
현재는 MVC의 공통 인터페이스와 이벤트 전달 방식, 기본 Controller를 마련한 단계다. 캐릭터 상태를 HUD에 연결할 UMvcCharacterStatController도 추가했으며, HandleModelChanged()에서 APlayerCharacter와 UPriestHUDWidget을 가져오는 부분을 작성 중이다.
아직 이 Controller의 실제 HUD 갱신과 View 이벤트 처리는 완성되지 않았다. 다음에는 캐릭터의 속성 변경 알림을 화면 갱신에 연결하고, 초기 상태 표시와 리스너 수명 관리까지 확인해야 한다. 이번 작업에서는 이후 체력과 탄약 등 개별 UI 기능을 붙일 때 사용할 공통 구조를 먼저 만들었다.