2025. 4. 8. 21:07ㆍ[Unity] Game Programing
UniRx: 유니티에서의 반응형 프로그래밍
작성일:
1. UniRx란?
UniRx는 이벤트 기반 프로그래밍을 함수형 프로그래밍과 결합하여 비동기 처리와 이벤트 스트림을 보다 쉽게 다룰 수 있게 해준다.
UniRx의 핵심 아이디어
이벤트 스트림을 데이터 컬렉션처럼 다룰 수 있습니다.
비동기 처리
이벤트 연결
컴포지션
2. 기본 개념
2.1 Observable
UniRx의 핵심은 IObservable<T> 인터페이스다. 이는 시간에 따라 발생하는 이벤트 스트림을 나타낸다. 데이터를 발행(Emit)하는 생산자 역할을 한다.
using System;
using UnityEngine;
using UniRx;
public class ObservableExample : MonoBehaviour
{
void Start()
{
// 기본적인 Observable 생성
var observable = Observable.Timer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1))
.Select(x => x.ToString())
.Take(10)
.Subscribe(
x => Debug.Log("값: " + x),
ex => Debug.LogError("에러: " + ex.Message),
() => Debug.Log("완료")
);
}
}
▶ 콘솔 출력:
값: 0
값: 1
값: 2
...
값: 9
완료
2.2 Observer
IObserver<T>는 Observable에서 발행된 데이터를 소비하는 구독자 역할을 한다.
using System;
using UnityEngine;
using UniRx;
public class ObserverExample : MonoBehaviour
{
void Start()
{
// Observable 생성
var clickStream = Observable.EveryUpdate()
.Where(_ => Input.GetMouseButtonDown(0));
// Observer 구독 예제
clickStream.Subscribe(
_ => Debug.Log("클릭 감지됨"), // OnNext
ex => Debug.LogError("에러 발생: " + ex), // OnError
() => Debug.Log("스트림 종료") // OnCompleted
).AddTo(this); // 컴포넌트 파괴시 자동 구독 해제
}
}
▶ 콘솔 출력 (사용자가 화면을 3번 클릭한 경우):
클릭 감지됨
클릭 감지됨
클릭 감지됨
* 컴포넌트가 파괴될 때까지 계속 동작합니다.
2.3 Subject
Subject는 Observable과 Observer의 역할을 동시에 수행할 수 있는 객체다. 이벤트를 발행하고 구독할 수 있다.
using System;
using UnityEngine;
using UniRx;
public class SubjectExample : MonoBehaviour
{
// Subject 정의
private Subject<string> messageSubject = new Subject<string>();
void Start()
{
// 구독
messageSubject.Subscribe(message => {
Debug.Log("메시지 수신: " + message);
}).AddTo(this);
}
void Update()
{
// 스페이스바 누르면 메시지 발행
if (Input.GetKeyDown(KeyCode.Space))
{
messageSubject.OnNext("스페이스바 누름 - " + DateTime.Now.ToString("HH:mm:ss"));
}
// Escape 키 누르면 Subject 종료
if (Input.GetKeyDown(KeyCode.Escape))
{
messageSubject.OnCompleted();
Debug.Log("Subject 스트림 종료됨");
}
}
void OnDestroy()
{
// 컴포넌트 파괴 시 Subject 정리
messageSubject.Dispose();
}
}
▶ 콘솔 출력 (Space 키를 여러 번 누른 후 ESC 키를 누른 경우):
메시지 수신: 스페이스바 누름 - 13:45:22
메시지 수신: 스페이스바 누름 - 13:45:24
메시지 수신: 스페이스바 누름 - 13:45:26
Subject 스트림 종료됨
3. UniRx의 주요 기능
3.1 ReactiveProperty
ReactiveProperty<T>는 값이 변경될 때마다 이벤트를 발행하는 프로퍼티다. MVVM 패턴에서 데이터 바인딩을 구현할 때 유용하다.
using System;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
public class PlayerHealth : MonoBehaviour
{
// ReactiveProperty로 체력 관리
private ReactiveProperty<int> health = new ReactiveProperty<int>(100);
[SerializeField] private Slider healthBar;
[SerializeField] private Text healthText;
void Start()
{
// 체력 변화를 UI에 바인딩
health.Subscribe(newHealth => {
// 슬라이더 업데이트
healthBar.value = newHealth / 100f;
// 텍스트 업데이트
healthText.text = $"체력: {newHealth}/100";
// 체력이 0이 되면 게임오버 처리
if (newHealth <= 0)
{
GameOver();
}
}).AddTo(this);
}
// 대미지 처리 메서드
public void TakeDamage(int amount)
{
// ReactiveProperty 값 변경
health.Value -= amount;
}
// 회복 처리 메서드
public void Heal(int amount)
{
health.Value = Mathf.Min(health.Value + amount, 100);
}
private void GameOver()
{
Debug.Log("게임 오버!");
// 게임 오버 처리 로직
}
}
▶ 결과:
1. 게임 시작 시 - UI에 "체력: 100/100" 표시, 슬라이더 최대값
2. TakeDamage(30) 호출 시 - UI가 "체력: 70/100"으로 자동 업데이트, 슬라이더 70%
3. Heal(20) 호출 시 - UI가 "체력: 90/100"으로 자동 업데이트, 슬라이더 90%
4. TakeDamage(100) 호출 시 - 체력이 0이 되면서 UI가 "체력: 0/100"으로 업데이트, 콘솔에 "게임 오버!" 메시지 출력
3.2 Observable Triggers
유니티 이벤트(Update, OnTriggerEnter 등)를 Observable로 변환하여 사용할 수 있다.
using System;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
public class PlayerController : MonoBehaviour
{
[SerializeField] private float jumpForce = 5f;
[SerializeField] private float moveSpeed = 3f;
private Rigidbody rb;
void Start()
{
rb = GetComponent();
// Update를 Observable로 변환하여 이동 처리
this.UpdateAsObservable()
.Subscribe(_ => {
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(h, 0f, v) * moveSpeed * Time.deltaTime;
transform.Translate(movement);
})
.AddTo(this);
// 점프 처리
this.UpdateAsObservable()
.Where(_ => Input.GetKeyDown(KeyCode.Space) && IsGrounded())
.Subscribe(_ => {
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log("점프!");
})
.AddTo(this);
// 충돌 이벤트 처리
this.OnTriggerEnterAsObservable()
.Where(col => col.CompareTag("Enemy"))
.Subscribe(enemy => {
Debug.Log("적과 충돌: " + enemy.gameObject.name);
GetComponent().TakeDamage(10);
})
.AddTo(this);
// 아이템 획득 처리
this.OnTriggerEnterAsObservable()
.Where(col => col.CompareTag("Item"))
.Subscribe(item => {
Debug.Log("아이템 획득: " + item.gameObject.name);
Destroy(item.gameObject);
})
.AddTo(this);
}
private bool IsGrounded()
{
// 바닥 체크 로직
return Physics.Raycast(transform.position, Vector3.down, 1.1f);
}
}
▶ 실행 결과 (게임 플레이 시나리오):
1. 방향키로 이동 - 캐릭터가 부드럽게 이동함
2. 스페이스바로 점프 시:
점프!
3. 적 오브젝트와 충돌 시:
적과 충돌: Enemy1
--- 플레이어 체력이 10 감소 ---
4. 아이템과 충돌 시:
아이템 획득: HealthPotion
--- 아이템이 사라짐 ---
3.3 Observable Operators
UniRx는 LINQ와 유사한 다양한 연산자를 제공하여 이벤트 스트림을 변환, 필터링, 결합할 수 있다.
using System;
using System.Collections;
using UnityEngine;
using UniRx;
public class RaycastExample : MonoBehaviour
{
[SerializeField] private LayerMask clickableLayer;
[SerializeField] private Material highlightMaterial;
[SerializeField] private Material defaultMaterial;
private GameObject currentSelection;
void Start()
{
// 마우스 클릭 시 레이캐스트로 오브젝트 선택 로직
Observable.EveryUpdate()
.Where(_ => Input.GetMouseButtonDown(0))
.Select(_ => {
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
bool didHit = Physics.Raycast(ray, out hit, 100f, clickableLayer);
return new { didHit, hit };
})
.Where(result => result.didHit)
.Select(result => result.hit.collider.gameObject)
.Subscribe(clickedObject => {
// 이전 선택 해제
if (currentSelection != null)
{
ResetSelection();
}
// 새 오브젝트 선택
currentSelection = clickedObject;
MeshRenderer renderer = currentSelection.GetComponent();
if (renderer != null)
{
renderer.material = highlightMaterial;
}
Debug.Log("선택된 오브젝트: " + clickedObject.name);
})
.AddTo(this);
// ESC 키로 선택 해제
Observable.EveryUpdate()
.Where(_ => Input.GetKeyDown(KeyCode.Escape) && currentSelection != null)
.Subscribe(_ => ResetSelection())
.AddTo(this);
}
private void ResetSelection()
{
MeshRenderer renderer = currentSelection.GetComponent();
if (renderer != null)
{
renderer.material = defaultMaterial;
}
currentSelection = null;
}
}
▶ 실행 결과 (상호작용 시나리오):
1. Cube 오브젝트 클릭 시:
선택된 오브젝트: Cube
--- Cube의 재질이 highlight 재질로 변경됨 ---
2. Sphere 오브젝트 클릭 시:
선택된 오브젝트: Sphere
--- Cube의 재질이 원래대로 돌아감 ---
--- Sphere의 재질이 highlight 재질로 변경됨 ---
3. ESC 키 누를 시:
--- Sphere의 재질이 원래대로 돌아감 ---
--- 선택된 오브젝트가 없음 ---
4. 실용적인 예제
4.1 타이머 구현
using System;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
public class CountdownTimer : MonoBehaviour
{
[SerializeField] private Text timerText;
[SerializeField] private float countdownTime = 5f;
private ReactiveProperty<float> timeLeft = new ReactiveProperty<float>();
void Start()
{
// 타이머 텍스트에 바인딩
timeLeft
.Subscribe(time => {
timerText.text = string.Format("{0:0.0}초", time);
if (time <= 0)
{
timerText.text = "시간 종료!";
}
})
.AddTo(this);
// 게임 시작 버튼을 눌렀을 때 타이머 시작
StartTimer();
}
public void StartTimer()
{
timeLeft.Value = countdownTime;
Observable.Timer(TimeSpan.Zero, TimeSpan.FromSeconds(0.1f))
.TakeWhile(_ => timeLeft.Value > 0)
.Subscribe(
_ => {
timeLeft.Value -= 0.1f;
},
() => { },
() => {
Debug.Log("카운트다운 완료!");
OnTimerCompleted();
}
)
.AddTo(this);
}
private void OnTimerCompleted()
{
// 타이머 종료 후 실행할 로직
Debug.Log("타이머 종료 후 로직 실행");
}
}
▶ 실행 결과 (시간 경과에 따른 UI 변화):
5.0초 → UI 텍스트: "5.0초"
4.0초 → UI 텍스트: "4.0초"
3.0초 → UI 텍스트: "3.0초"
2.0초 → UI 텍스트: "2.0초"
1.0초 → UI 텍스트: "1.0초"
0.0초 → UI 텍스트: "시간 종료!"
▶ 콘솔 출력:
카운트다운 완료!
타이머 종료 후 로직 실행
* 실제로는 0.1초 단위로 감소하지만, 여기서는 주요 시점만 표시했습니다.
4.2 입력 처리
using System;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
public class InputHandler : MonoBehaviour
{
void Start()
{
// 마우스 클릭 이벤트 처리
this.UpdateAsObservable()
.Where(_ => Input.GetMouseButtonDown(0))
.Subscribe(_ => {
Debug.Log("마우스 클릭 감지됨");
// 클릭 처리 로직
})
.AddTo(this);
// 키보드 입력 이벤트 처리
this.UpdateAsObservable()
.Where(_ => Input.GetKeyDown(KeyCode.Space))
.Subscribe(_ => {
Debug.Log("스페이스바 누름");
// 스페이스바 처리 로직
})
.AddTo(this);
}
}
▶ 콘솔 출력 (사용자 입력 시):
마우스 클릭 감지됨
스페이스바 누름
마우스 클릭 감지됨
* 각 입력이 발생할 때마다 메시지가 출력됩니다.
4.3 UI 이벤트 처리
using System;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
public class UIController : MonoBehaviour
{
[SerializeField] private Button attackButton;
[SerializeField] private Button healButton;
[SerializeField] private Button quitButton;
[SerializeField] private Toggle autoAttackToggle;
[SerializeField] private Slider volumeSlider;
[SerializeField] private PlayerHealth playerHealth;
[SerializeField] private EnemyController enemyController;
[SerializeField] private AudioSource bgmAudioSource;
private Subject<Unit> autoAttackStream = new Subject<Unit>();
private IDisposable autoAttackSubscription;
void Start()
{
// 공격 버튼 클릭 이벤트 처리
attackButton.OnClickAsObservable()
.ThrottleFirst(TimeSpan.FromSeconds(0.5)) // 더블 클릭 방지
.Subscribe(_ => {
Debug.Log("공격 버튼 클릭!");
enemyController.TakeDamage(10);
})
.AddTo(this);
// 회복 버튼 클릭 이벤트 처리
healButton.OnClickAsObservable()
.ThrottleFirst(TimeSpan.FromSeconds(1.0)) // 연타 방지
.Subscribe(_ => {
Debug.Log("회복 버튼 클릭!");
playerHealth.Heal(20);
})
.AddTo(this);
// 종료 버튼 클릭 이벤트 처리
quitButton.OnClickAsObservable()
.Subscribe(_ => {
Debug.Log("게임 종료!");
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
})
.AddTo(this);
// 자동 공격 토글 상태 변화 처리
autoAttackToggle.OnValueChangedAsObservable()
.Subscribe(isOn => {
Debug.Log("자동 공격 " + (isOn ? "활성화" : "비활성화"));
if (isOn)
{
// 자동 공격 시작
autoAttackSubscription = Observable.Interval(TimeSpan.FromSeconds(1))
.Subscribe(_ => {
Debug.Log("자동 공격!");
enemyController.TakeDamage(5);
})
.AddTo(this);
}
else
{
// 자동 공격 중지
autoAttackSubscription?.Dispose();
}
})
.AddTo(this);
// 볼륨 슬라이더 값 변화 처리
volumeSlider.OnValueChangedAsObservable()
.Subscribe(volume => {
Debug.Log("볼륨 변경: " + volume);
bgmAudioSource.volume = volume;
})
.AddTo(this);
}
}
// 관련 클래스들
public class EnemyController : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private ReactiveProperty<int> health = new ReactiveProperty<int>();
[SerializeField] private Slider healthBar;
void Start()
{
health.Value = maxHealth;
// 체력 변화를 UI에 바인딩
health
.Subscribe(currentHealth => {
healthBar.value = (float)currentHealth / maxHealth;
if (currentHealth <= 0)
{
Die();
}
})
.AddTo(this);
}
public void TakeDamage(int amount)
{
health.Value -= amount;
}
private void Die()
{
Debug.Log("적 사망!");
gameObject.SetActive(false);
}
}
▶ 콘솔 출력 (사용자 인터랙션 시나리오):
1. 공격 버튼 클릭 시:
공격 버튼 클릭!
--- 적의 체력 바가 10% 감소 ---
2. 회복 버튼 클릭 시:
회복 버튼 클릭!
--- 플레이어 체력 바가 20% 증가 ---
3. 자동 공격 토글 활성화 시:
자동 공격 활성화
자동 공격!
--- 적의 체력 바가 5% 감소 ---
자동 공격!
--- 적의 체력 바가 5% 감소 ---
자동 공격!
--- 적의 체력 바가 5% 감소 ---
4. 자동 공격 토글 비활성화 시:
자동 공격 비활성화
5. 볼륨 슬라이더 조절 시:
볼륨 변경: 0.75
--- 배경 음악 볼륨 조절됨 ---
6. 적의 체력이 0이 되었을 때:
적 사망!
--- 적 오브젝트가 화면에서 사라짐 ---
5. 사용 시 주의사항
메모리 관리
구독(Subscription)은 명시적으로 해제하거나 AddTo() 메서드를 통해 GameObject의 수명과 연결해야 한다. 그렇지 않으면 메모리 누수가 발생할 수 있다.
using System;
using UnityEngine;
using UniRx;
public class MemoryManagementExample : MonoBehaviour
{
private IDisposable subscription;
void Start()
{
// 방법 1: 변수에 저장 후 명시적 해제
subscription = Observable.Interval(TimeSpan.FromSeconds(1))
.Subscribe(_ => Debug.Log("매 초마다 실행"));
// 방법 2: 컴포넌트 수명과 연결
Observable.Timer(TimeSpan.FromSeconds(5))
.Subscribe(_ => Debug.Log("5초 후 실행"))
.AddTo(this); // GameObject가 파괴될 때 자동으로 구독 해제
// 방법 3: CompositeDisposable 사용
var disposables = new CompositeDisposable();
Observable.EveryUpdate()
.Where(_ => Input.GetKeyDown(KeyCode.A))
.Subscribe(_ => Debug.Log("A 키 누름"))
.AddTo(disposables);
Observable.EveryUpdate()
.Where(_ => Input.GetKeyDown(KeyCode.B))
.Subscribe(_ => Debug.Log("B 키 누름"))
.AddTo(disposables);
// 필요할 때 모든 구독 일괄 해제
// disposables.Dispose();
}
void OnDestroy()
{
// 수동으로 구독 해제
subscription?.Dispose();
}
}
▶ 올바른 메모리 관리 결과:
매 초마다 실행
매 초마다 실행
매 초마다 실행
매 초마다 실행
5초 후 실행
A 키 누름
B 키 누름
* 게임 오브젝트가 파괴되면 모든 구독이 해제되어 메모리 누수가 발생하지 않습니다.
* 반면, 제대로 구독을 해제하지 않으면 오브젝트가 파괴된 후에도 메모리에 남아 로그가 계속 출력되는 문제가 발생합니다.
오버헤드
UniRx를 사용하면 코드가 더 복잡해지고 오버헤드가 발생할 수 있다. 특히 이벤트 처리나 데이터 바인딩과 같은 작업에서는 오버헤드가 더 크다.
따라서 간단한 로직이나 성능에 민감한 부분에서는 UniRx를 사용하지 않는 것이 좋다.
6. 결론
UniRx는 유니티에서 반응형 프로그래밍을 구현하는 강력한 도구다. 이벤트 처리, 데이터 바인딩, 비동기 처리 등 다양한 상황에서 유용하게 활용할 수 있다.
하지만 메모리 관리와 오버헤드에 주의해야 하며, 간단한 로직에는 적합하지 않다.
'[Unity] Game Programing' 카테고리의 다른 글
| TIL : 유니티 게임 시스템 구현 (1) | 2025.04.29 |
|---|---|
| TIL: AlivePlayer 시스템 심층 분석 (상태 머신, 데이터 관리, 컴포넌트 구조) (1) | 2025.04.28 |
| 캐릭터 개발 TIL (Today I Learned) (0) | 2025.04.25 |
| Today I Learned - Unity에서 Google Sheets 사용법 정리 (0) | 2025.04.24 |
| UniTask (0) | 2025.04.21 |