Unity에서 게임을 개발하다 보면 상태 변화에 따라 UI를 갱신해야 하는 상황이 계속 발생한다.
플레이어의 체력이 변경되면 HP Bar를 변경해야 하고, 골드를 획득하면 골드 UI를 갱신해야 한다. 설정값이 변경되면 Toggle이나 Slider의 상태도 변경해야 한다.
일반적으로는 이벤트를 만들어 처리한다.
public event Action<int> OnHealthChanged;
그리고 값이 변경될 때마다 이벤트를 호출한다.
health = value;
OnHealthChanged?.Invoke(health);
UI에서는 이벤트를 구독한다.
player.OnHealthChanged += UpdateHealthUI;
작은 프로젝트에서는 문제가 없다.
하지만 프로젝트가 커지면 상태, 이벤트, 이벤트 호출, 구독 해제, UI 갱신 코드가 여러 곳으로 분산되기 시작한다.
Reactive Programming은 이러한 문제를 데이터의 흐름으로 표현한다.
R3는 이러한 Reactive Programming을 C#에서 사용할 수 있도록 만든 라이브러리이며, Unity에서도 상태 관리와 UI 바인딩에 활용할 수 있다.
R3란?
R3는 Reactive Extensions를 기반으로 한 Reactive Programming 라이브러리다.
핵심 개념은 다음과 같다.
Observable
Subscribe
Operator
Subject
ReactiveProperty
Dispose
이 중 Unity에서 UI를 연결할 때 특히 중요한 것은 ReactiveProperty와 Subscribe다.
기본적인 구조는 다음과 같다.
Model
↓
ReactiveProperty
↓
Subscribe
↓
UI
예를 들어 플레이어의 체력을 ReactiveProperty<int>로 관리할 수 있다.
public ReactiveProperty<int> Health { get; }
= new(100);
UI에서는 이를 구독한다.
player.Health
.Subscribe(value =>
{
hpText.text = value.ToString();
});
이제 플레이어의 체력이 변경되면 UI도 변경된다.
player.Health.Value = 80;
player.Health.Value = 50;
player.Health.Value = 10;
UI는 별도의 Update() 없이 상태 변화를 받을 수 있다.
ReactiveProperty
R3에서 UI 바인딩을 이해하려면 먼저 ReactiveProperty를 이해해야 한다.
일반적인 변수는 단순히 값을 저장한다.
public int Health;
값을 변경해도 다른 객체는 값이 변경됐다는 사실을 알 수 없다.
Health = 50;
UI가 이를 알고 싶다면 직접 확인해야 한다.
void Update()
{
hpText.text = Health.ToString();
}
하지만 이 방식은 매 프레임마다 값을 확인한다.
ReactiveProperty를 사용하면 상태 변경 자체를 이벤트 스트림으로 만들 수 있다.
public ReactiveProperty<int> Health { get; }
= new(100);
값을 변경한다.
Health.Value = 50;
그러면 이 값을 구독하고 있는 객체에게 변경 사실이 전달된다.
Health
.Subscribe(value =>
{
hpText.text = value.ToString();
});
구조는 다음과 같다.
Health.Value 변경
↓
ReactiveProperty
↓
Subscribe
↓
UI 갱신
이것이 R3를 이용한 가장 기본적인 UI 바인딩이다.
Unity UI와 R3 연결하기
Unity UI에서는 TextMeshProUGUI, Slider, Image, Toggle, Button 등을 사용할 수 있다.
예를 들어 체력을 UI Text에 표시한다고 해보자.
using TMPro;
using UnityEngine;
using R3;
public class Player : MonoBehaviour
{
public ReactiveProperty<int> Health { get; }
= new(100);
}
UI에서는 다음과 같이 구독한다.
using TMPro;
using UnityEngine;
using R3;
public class PlayerUI : MonoBehaviour
{
[SerializeField]
private TMP_Text healthText;
[SerializeField]
private Player player;
private void Start()
{
player.Health
.Subscribe(health =>
{
healthText.text = $"HP : {health}";
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
}
}
이제 Player의 상태와 UI가 분리된다.
Player
└─ Health
PlayerUI
└─ healthText
Player는 UI를 알 필요가 없다.
반대로 UI는 Player의 내부 구현을 직접 조작하지 않고 Health의 변화를 관찰한다.
Slider와 ReactiveProperty 연결하기
HP Bar 같은 UI에는 Slider가 자주 사용된다.
[SerializeField]
private Slider hpSlider;
체력이 0~100이라고 한다면 다음과 같이 연결할 수 있다.
player.Health
.Subscribe(health =>
{
hpSlider.value = health;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
그러면
player.Health.Value = 75;
를 실행했을 때 Slider도 75로 변경된다.
Player.Health
↓
ReactiveProperty
↓
Subscribe
↓
Slider.value
UI가 게임 상태를 매 프레임 확인하지 않는다는 것이 중요하다.
상태가 변경됐을 때만 UI를 갱신한다.
Slider의 최대값까지 연결하기
체력의 최대값이 항상 100인 것이 아니라면 Slider.maxValue도 상태와 연결할 수 있다.
[SerializeField]
private Slider hpSlider;
private void Start()
{
hpSlider.maxValue = player.MaxHealth;
player.Health
.Subscribe(health =>
{
hpSlider.value = health;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
}
게임에서 최대 체력이 변경될 가능성이 있다면 MaxHealth 역시 ReactiveProperty로 관리할 수 있다.
public ReactiveProperty<int> MaxHealth { get; }
= new(100);
public ReactiveProperty<int> Health { get; }
= new(100);
그리고 두 상태를 조합해서 UI를 구성할 수 있다.
UI에 표시할 값 변환하기
R3의 장점은 Observable에 Operator를 적용할 수 있다는 것이다.
예를 들어 체력 값을 그대로 출력하는 대신 문자열로 변환할 수 있다.
player.Health
.Select(health => $"HP : {health}")
.Subscribe(text =>
{
healthText.text = text;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
데이터의 흐름은 다음과 같다.
100
↓
Select
↓
"HP : 100"
↓
Subscribe
↓
TMP_Text
UI에서 필요한 형태로 데이터를 변환한 다음 전달할 수 있다.
조건에 따라 UI 변경하기
Where를 사용하면 특정 조건을 만족하는 값만 처리할 수 있다.
예를 들어 체력이 20 이하가 되었을 때 경고 UI를 활성화한다고 해보자.
player.Health
.Where(health => health <= 20)
.Subscribe(_ =>
{
warningObject.SetActive(true);
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
이제 체력이 20 이하가 되는 경우에만 코드가 실행된다.
다만 여기서 한 가지 주의해야 한다.
체력이 다시 20보다 높아졌을 때 경고 UI를 끄고 싶다면 단순히 Where 하나만 사용하는 것으로는 충분하지 않다.
player.Health
.Subscribe(health =>
{
warningObject.SetActive(health <= 20);
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
이런 식으로 상태 전체를 구독하는 것이 더 적절하다.
즉, Reactive Programming을 사용한다고 해서 모든 것을 복잡한 Operator 체인으로 만들 필요는 없다.
UI Button과 R3
Button도 Reactive 방식으로 처리할 수 있다.
일반적인 Unity 방식은 다음과 같다.
button.onClick.AddListener(OnClick);
R3에서는 Unity의 Button 이벤트를 Observable 형태로 연결할 수 있다.
Unity용 R3 패키지에서 제공하는 확장 기능을 사용하면 Button의 클릭 이벤트를 Observable처럼 처리할 수 있다.
예를 들어 다음과 같은 형태다.
button.OnClickAsObservable()
.Subscribe(_ =>
{
StartGame();
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
그러면 Button의 클릭이 하나의 이벤트 스트림이 된다.
Button Click
↓
Observable
↓
Subscribe
↓
StartGame()
이 방식의 장점은 다른 Observable과 조합할 수 있다는 것이다.
예를 들어 버튼 클릭 이후 특정 조건을 검사하거나 다른 이벤트와 결합할 수 있다.
UI Toggle과 R3
Toggle도 비슷하다.
Toggle
↓
Observable
↓
Subscribe
↓
게임 설정 변경
예를 들어 사운드 설정을 Toggle과 연결할 수 있다.
toggle.OnValueChangedAsObservable()
.Subscribe(isOn =>
{
audioManager.SetMute(!isOn);
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
Toggle의 값이 변경될 때마다 isOn을 전달받는다.
이렇게 하면 UI에서 발생한 입력을 게임 시스템으로 전달할 수 있다.
UI 입력도 상태로 관리하기
여기서 ReactiveProperty의 진짜 장점이 나온다.
UI에서 입력을 받는 것과 게임 상태를 변경하는 것을 직접 연결하는 대신 UI 입력 → 상태 → 다른 UI라는 구조를 만들 수 있다.
예를 들어 볼륨 설정이 있다고 하자.
public ReactiveProperty<float> Volume { get; }
= new(1.0f);
Slider의 입력을 상태에 반영한다.
volumeSlider
.OnValueChangedAsObservable()
.Subscribe(value =>
{
settings.Volume.Value = value;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
그리고 다른 시스템에서는 Volume을 구독한다.
settings.Volume
.Subscribe(value =>
{
audioSource.volume = value;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
구조는 다음과 같다.
Slider
↓
UI Input
↓
Volume.Value
↓
ReactiveProperty
↓
Audio System
이렇게 하면 Slider가 AudioSource를 직접 제어하지 않는다.
UI는 설정값을 변경하고, Audio 시스템은 설정값의 변화를 관찰한다.
양방향 바인딩
여기서 흔히 말하는 Two-Way Binding이 나온다.
예를 들어 다음과 같은 구조다.
┌──────────────┐
│ ReactiveProperty │
└──────────────┘
↑ ↓
│ │
Input Output
│ │
↓ ↓
┌────────────────┐
│ UI │
└────────────────┘
Slider의 값을 변경하면 Model이 변경된다.
Slider
↓
Volume.Value
반대로 게임 코드에서 Volume을 변경하면 Slider도 변경된다.
Volume.Value
↓
Slider
예를 들어 다음과 같이 구현할 수 있다.
settings.Volume
.Subscribe(value =>
{
volumeSlider.SetValueWithoutNotify(value);
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
volumeSlider
.OnValueChangedAsObservable()
.Subscribe(value =>
{
settings.Volume.Value = value;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
여기서 SetValueWithoutNotify가 중요하다.
Model에서 Slider를 변경했을 때 Slider의 이벤트까지 다시 발생시키면 다음과 같은 순환이 발생할 수 있다.
Volume 변경
↓
Slider 변경
↓
Slider 이벤트
↓
Volume 변경
↓
Slider 변경
↓
...
따라서 코드에 따라 이벤트를 발생시키지 않고 UI만 갱신해야 하는 경우가 있다.
이것이 양방향 바인딩에서 자주 발생하는 문제다.
BindableReactiveProperty는 무엇인가?
R3를 사용하다 보면 BindableReactiveProperty라는 이름을 접할 수 있다.
이것은 일반적인 ReactiveProperty와 달리 UI 바인딩을 보다 직접적으로 지원하기 위한 개념이다.
하지만 여기서 중요한 것은 R3의 ReactiveProperty 자체가 WPF 전용이라는 것이 아니다.
R3의 ReactiveProperty는 기본적으로 UI 프레임워크에 종속된 개념이 아니다.
Unity에서도 사용할 수 있다.
ReactiveProperty<int>
를 게임 상태로 사용하고,
Subscribe(...)
를 이용해 Unity UI와 연결하면 된다.
즉,
ReactiveProperty
↓
UI Framework에 맞는 Binding
이라는 구조로 이해하는 것이 정확하다.
Unity에서는 반드시 BindableReactiveProperty를 사용해야만 UI 바인딩을 할 수 있는 것은 아니다.
오히려 Unity 프로젝트에서는 ReactiveProperty + Subscribe 방식으로 직접 UI를 연결하는 것이 단순하고 명확한 경우가 많다.
UI 바인딩을 별도의 View에서 관리하기
프로젝트 규모가 커지면 UI 코드와 게임 로직을 분리하는 것이 좋다.
예를 들어 다음과 같이 구성할 수 있다.
PlayerModel
├─ Health
├─ Gold
└─ Level
PlayerView
├─ HP Text
├─ HP Slider
├─ Gold Text
└─ Level Text
Model:
public class PlayerModel
{
public ReactiveProperty<int> Health { get; }
= new(100);
public ReactiveProperty<int> Gold { get; }
= new(0);
public ReactiveProperty<int> Level { get; }
= new(1);
}
View:
public class PlayerView : MonoBehaviour
{
[SerializeField]
private TMP_Text hpText;
[SerializeField]
private TMP_Text goldText;
[SerializeField]
private TMP_Text levelText;
private PlayerModel player;
public void Bind(PlayerModel player)
{
this.player = player;
player.Health
.Subscribe(value =>
{
hpText.text = $"HP : {value}";
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
player.Gold
.Subscribe(value =>
{
goldText.text = $"Gold : {value}";
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
player.Level
.Subscribe(value =>
{
levelText.text = $"Lv. {value}";
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
}
}
이렇게 하면 Model은 Unity UI를 전혀 알지 않는다.
View가 Model의 상태를 구독하면서 UI를 갱신한다.
ViewModel을 사용하는 구조
더 복잡한 프로젝트에서는 View와 Model 사이에 ViewModel을 둘 수도 있다.
Model
↓
ViewModel
↓
View
예를 들어 Model에는 숫자 형태의 HP가 있고 ViewModel에서 UI에 필요한 문자열로 변환할 수 있다.
public class PlayerViewModel
{
public Observable<string> HealthText { get; }
public PlayerViewModel(PlayerModel model)
{
HealthText = model.Health
.Select(x => $"HP : {x}");
}
}
View에서는 ViewModel을 구독한다.
viewModel.HealthText
.Subscribe(text =>
{
hpText.text = text;
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
이렇게 하면 View는 게임 데이터를 어떻게 계산하는지 알 필요가 없다.
Model
↓
원본 데이터
↓
ViewModel
↓
UI용 데이터
↓
View
게임 프로젝트에서 MVVM이나 유사한 구조를 구현할 때 R3가 상당히 유용하게 사용될 수 있는 이유다.
R3에서 Dispose가 중요한 이유
Reactive Programming에서 가장 중요한 문제 중 하나가 구독의 수명이다.
다음 코드가 있다고 하자.
player.Health
.Subscribe(value =>
{
hpText.text = value.ToString();
});
UI가 삭제됐는데 구독이 계속 유지되는 구조라면 문제가 발생할 수 있다.
Unity에서는 GameObject의 생명주기와 Subscription의 생명주기를 연결하는 것이 중요하다.
대표적으로 다음과 같이 처리할 수 있다.
player.Health
.Subscribe(value =>
{
hpText.text = value.ToString();
})
.RegisterTo(this.GetCancellationTokenOnDestroy());
이렇게 하면 PlayerView가 파괴될 때 해당 구독도 정리된다.
Reactive Programming을 사용할 때는 단순히 Subscribe하는 것만큼 언제 구독이 끝나는지를 생각해야 한다.
R3를 사용한다고 모든 것을 Reactive로 만들 필요는 없다
R3를 도입했다고 해서 모든 데이터를 ReactiveProperty로 만들 필요는 없다.
예를 들어 다음과 같은 코드는 굳이 Reactive할 이유가 없다.
private int damage = 10;
private int CalculateDamage()
{
return damage * 2;
}
그냥 일반적인 C# 코드로 작성하면 된다.
반면 다음과 같은 구조는 Reactive Programming과 잘 맞는다.
플레이어 HP 변경
↓
HP UI 변경
↓
HP 0
↓
사망 처리
↓
애니메이션
↓
사운드
↓
퀘스트 처리
↓
보상 처리
하나의 상태 변화가 여러 시스템으로 전달되는 구조라면 Observable이 강력해진다.
R3와 UI 바인딩의 핵심
Unity에서 R3를 이용한 UI 바인딩은 복잡하게 생각할 필요가 없다.
가장 기본적인 구조는 이것이다.
Model
↓
ReactiveProperty
↓
Subscribe
↓
Unity UI
UI 입력까지 포함하면 다음과 같다.
┌─────────────────┐
│ ReactiveProperty│
└─────────────────┘
↑ ↓
│ │
UI Input UI Output
│ │
↓ ↓
┌─────────────────┐
│ Unity UI │
└─────────────────┘
그리고 실제 프로젝트에서는 여기에 ViewModel, Operator, Subject, Dispose 등을 추가하면서 구조를 확장할 수 있다.
결국 R3의 핵심은 UI를 자동으로 연결해주는 마법 같은 기능이 아니다.
상태와 이벤트를 Observable이라는 데이터 흐름으로 표현하고, UI는 그 흐름을 구독하도록 만드는 것이다.
이 구조를 이해하면 ReactiveProperty, Subject, Subscribe, Where, Select, Dispose 같은 R3 API가 각각 왜 존재하는지도 자연스럽게 이해할 수 있다.
Unity에서 R3를 제대로 활용하려면 단순히 기존 이벤트 코드를 R3 문법으로 바꾸는 것보다, 게임 상태와 UI 사이의 의존성을 어떻게 끊고 데이터의 흐름을 어떻게 설계할 것인가를 먼저 생각하는 것이 중요하다.