UniRx: 유니티에서의 반응형 프로그래밍

2025. 4. 8. 21:07[Unity] Game Programing

TIL - UniRx: 유니티 반응형 프로그래밍 학습

UniRx: 유니티에서의 반응형 프로그래밍

작성일:

1. UniRx란?

UniRx는 이벤트 기반 프로그래밍을 함수형 프로그래밍과 결합하여 비동기 처리와 이벤트 스트림을 보다 쉽게 다룰 수 있게 해준다.

UniRx의 핵심 아이디어

이벤트 스트림을 데이터 컬렉션처럼 다룰 수 있습니다.

🔄

비동기 처리

🔗

이벤트 연결

🧩

컴포지션

2. 기본 개념

2.1 Observable

UniRx의 핵심은 IObservable<T> 인터페이스다. 이는 시간에 따라 발생하는 이벤트 스트림을 나타낸다. 데이터를 발행(Emit)하는 생산자 역할을 한다.

ObservableExample.cs
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에서 발행된 데이터를 소비하는 구독자 역할을 한다.

ObserverExample.cs
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의 역할을 동시에 수행할 수 있는 객체다. 이벤트를 발행하고 구독할 수 있다.

SubjectExample.cs
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 패턴에서 데이터 바인딩을 구현할 때 유용하다.

PlayerHealth.cs
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로 변환하여 사용할 수 있다.

PlayerController.cs
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와 유사한 다양한 연산자를 제공하여 이벤트 스트림을 변환, 필터링, 결합할 수 있다.

RaycastExample.cs
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 타이머 구현

CountdownTimer.cs
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 입력 처리

InputHandler.cs
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 이벤트 처리

UIController.cs
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는 유니티에서 반응형 프로그래밍을 구현하는 강력한 도구다. 이벤트 처리, 데이터 바인딩, 비동기 처리 등 다양한 상황에서 유용하게 활용할 수 있다.

하지만 메모리 관리와 오버헤드에 주의해야 하며, 간단한 로직에는 적합하지 않다.