본문으로 건너뛰기
뒤로가기

[Unity] Project Ellie: Event Channel로 입력과 UI 연결하기

TL;DRProject Ellie에서는 오브젝트가 서로의 메서드를 직접 호출하는 대신, 씬이 소유한 Event Channel을 통해 메시지를 전달했습니다. 플레이어의 인벤토리 입력이 Ticket, UIChannel, UIPayload를 거쳐 UI에 도착하는 흐름을 중심으로 설계 이유를 정리합니다.

Table of contents

Open Table of contents

들어가며

Project Ellie는 개발자 4명과 기획자 3명이 약 2개월 동안 제작한 Unity 3D 팀 프로젝트입니다. 기능이 늘어나면서 플레이어, UI, 몬스터, 보스가 서로를 직접 참조하는 경우도 함께 늘어났습니다. 한 기능을 빠르게 연결하기에는 편했지만, 호출하는 쪽이 상대 오브젝트의 위치와 구체적인 메서드까지 알아야 했습니다.

이 문제를 줄이기 위해 제가 PR #21에서 BaseCenter, BaseEventChannel, Ticket, TicketMachine으로 구성된 기본 채널 구조를 도입했습니다. 이후 팀 개발 과정에서 채널의 메시지를 여러 구독자에게 전달하는 흐름이 확장됐고, 인벤토리와 대화, 몬스터, 보스 같은 실제 기능에 적용됐습니다.

이 글에서 다루는 내용:

사전 지식: Unity MonoBehaviour, C# delegate와 이벤트 구독에 대한 기본 이해를 전제로 합니다.


1. 씬은 채널을 소유하고 오브젝트는 티켓을 가집니다

채널 구조에서 가장 먼저 정한 기준은 메시지 경로의 소유자였습니다. 게임 전체에 하나의 전역 이벤트 버스를 두는 대신, 각 씬의 BaseCenter가 그 씬에서 사용하는 채널을 만들고 보관하도록 구성했습니다.

BaseCenterChannelType 목록을 읽어 UI, Monster, Boss 같은 목적별 채널을 생성합니다. 씬에 배치된 오브젝트는 자신에게 필요한 ChannelTypeTicketTicketMachine에 추가합니다. 초기화 시점에 BaseCenter가 각 Ticket을 같은 타입의 채널과 연결합니다.

[Scene]
BaseCenter
  `- ChannelType -> BaseEventChannel

[GameObject]
TicketMachine
  `- ChannelType -> Ticket

연결: Ticket(UI) <-> UIChannel

Project Ellie 이벤트 채널 클래스 구조

이 구조에서는 플레이어가 인벤토리 UI 오브젝트를 찾아 메서드를 호출하지 않습니다. 플레이어는 UI 채널에 UIPayload를 보내고, UI 이벤트를 구독한 인벤토리가 필요한 메시지만 처리합니다.

ChannelType으로 경로를 나눈 이유는 모든 메시지가 하나의 버스를 통과할 때 생기는 모호함을 줄이기 위해서였습니다. 송신자는 구체적인 수신자를 몰라도 되지만, 메시지가 전투용인지 UI용인지는 명확하게 지정합니다.


2. Ticket이 송신과 구독을 같은 경로로 묶습니다

최종 채널 구조에서 Ticket은 오브젝트와 채널 사이의 연결점입니다. 오브젝트가 메시지를 보낼 때는 Ticket.Publish()가 채널의 ReceiveMessage()를 호출합니다. 채널은 payload를 확인한 뒤 Publish()하고, 연결된 Ticket이 자신의 observer에게 알립니다.

Sender
  -> TicketMachine.SendMessage(type, payload)
  -> Ticket.Publish(payload)
  -> BaseEventChannel.ReceiveMessage(payload)
  -> BaseEventChannel.Publish(payload)
  -> Ticket observer
  -> Receiver callback

TicketMachine은 오브젝트가 보유한 티켓을 ChannelType별로 관리합니다. 송신 코드는 채널 타입과 payload만 넘기므로, 어느 UI가 메시지를 받는지 알 필요가 없습니다.

public void SendMessage(ChannelType type, IBaseEventPayload payload)
{
    if (tickets.TryGetValue(type, out var ticket))
        ticket.Publish(payload);
}

public void RegisterObserver(
    ChannelType type,
    Action<IBaseEventPayload> observer)
{
    Components.Ticket.RegisterObserver(tickets[type], observer);
}TicketMachine.cs

구독을 추가할 때는 같은 callback을 먼저 제거한 뒤 다시 추가했습니다. 초기화 코드가 중복 호출되더라도 같은 observer가 여러 번 등록되는 상황을 막기 위한 선택입니다.

public static void RegisterObserver(
    Ticket ticket,
    Action<IBaseEventPayload> observer)
{
    ticket.channelNotifyAction -= observer;
    ticket.channelNotifyAction += observer;
}Ticket.cs

씬 시작 시 만들어진 티켓뿐 아니라 런타임에 추가된 티켓도 같은 채널에 연결할 수 있도록 BaseCenter.OnAddTicket() 경로를 두었습니다. 생성 시점이 다른 UI와 게임 오브젝트도 하나의 연결 규칙을 사용할 수 있게 하기 위한 구성입니다.


3. 인벤토리 입력은 UIPayload로 전달합니다

인벤토리를 여는 흐름은 채널 구조의 역할을 가장 간단하게 보여줍니다. 플레이어가 I 키를 누르면 PlayerInventory는 인벤토리 오브젝트의 ToggleInventory()를 직접 호출하지 않고, 동작을 표현하는 UIPayload를 만듭니다.

if (Input.GetKeyDown(KeyCode.I))
{
    ticketMachine.SendMessage(
        ChannelType.UI,
        MakeInventoryOpenPayload());

    OnInventoryToggle();
}

private UIPayload MakeInventoryOpenPayload()
{
    return new UIPayload
    {
        uiType = UIType.Notify,
        actionType = ActionType.ToggleInventory,
    };
}PlayerInventory.cs

여기서 ChannelType.UI는 메시지가 이동할 경로를, ActionType.ToggleInventory는 수신자가 수행할 동작을 나타냅니다. UIChannelUIPayload인지 확인한 후 구독자에게 다시 전달합니다.

인벤토리는 초기화할 때 UI 티켓을 추가하고 OnNotifyAction()을 observer로 등록합니다. 메시지를 받으면 actionType을 확인해 자신이 담당하는 동작만 실행합니다.

private void InitTicketMachine()
{
    ticketMachine = gameObject.GetOrAddComponent<TicketMachine>();
    ticketMachine.AddTickets(ChannelType.UI);
    ticketMachine.RegisterObserver(ChannelType.UI, OnNotifyAction);

    TicketManager.Instance.Ticket(ticketMachine);
}

private void OnNotifyAction(IBaseEventPayload payload)
{
    if (payload is not UIPayload uiPayload)
        return;

    if (uiPayload.actionType == ActionType.ToggleInventory)
    {
        ToggleInventory();
        return;
    }
}Inventory.cs

전체 흐름을 한 줄로 정리하면 다음과 같습니다.

I 키 입력 -> UIPayload(ToggleInventory) -> UIChannel -> Inventory.ToggleInventory()

플레이어는 입력과 플레이 상태를 담당하고, 인벤토리는 화면 표시와 슬롯 상태를 담당합니다. 두 시스템 사이에는 구체적인 UI 메서드 대신 payload 계약만 남습니다.

Project Ellie 인벤토리 UI


4. 모든 연결을 이벤트로 바꾸지는 않았습니다

이벤트 채널은 서로 다른 시스템이 상태 변화를 전달할 때 사용했습니다. 반대로 한 오브젝트가 명확하게 소유하는 컴포넌트나 즉시 결과가 필요한 로컬 호출은 직접 참조가 더 단순했습니다.

인벤토리를 여는 경우에도 화면 열기 요청은 UIChannel로 보내지만, 플레이어의 공격 가능 여부와 커서 상태는 PlayerInventory가 바로 갱신합니다. 메시지 전달과 플레이어 입력 상태를 각각의 책임 안에서 처리한 것입니다.

또한 UIPayload 하나에 모든 UI 동작을 계속 추가하면 payload와 ActionType이 커질 수 있습니다. 당시에는 2개월 팀 프로젝트에서 채널을 지나치게 세분화하는 것보다, UI, Monster, Boss처럼 기능 영역을 구분하고 payload의 동작 값을 명시하는 편이 팀원이 따라가기 쉬웠습니다.

이 선택으로 새 UI 기능을 연결할 때 송신자가 수신 오브젝트의 씬 경로나 구체적인 컴포넌트를 찾는 대신 다음 세 가지만 정하면 됐습니다.

  1. 어떤 ChannelType으로 보낼지 결정합니다.
  2. payload에 필요한 동작과 데이터를 담습니다.
  3. 수신자가 해당 동작을 처리하도록 observer를 등록합니다.

정리하며

Project Ellie의 이벤트 채널은 복잡한 프레임워크를 만드는 것보다, 팀원이 기능을 연결할 때 지켜야 할 경로를 통일하는 데 목적이 있었습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[Unity] Project Ellie: UGUI 바인딩과 인벤토리 슬롯 교환
다음 글
[WinAPI] 땅따먹기: 경로 병합과 점령 영역 선택