UniRX 소개
Reactive Extension for Unity 라이브러리 소개
2024.10.25
오늘은 우연히 알게된 Reactive Programming 이라는 것에 대해 알아보고 공유해보고자 한다.
🔷 개론
🔶 Reactive Programming
‘비동기적 데이터 흐름’ 을 처리하는 프로그래밍 기법이라는 뜻으로 모든 처리를 비동기적 데이터 스트림으로 간주,
Observer 디자인패턴을 활용해서 이러한 비동기 이벤트를 처리하는 것이 핵심이다.
In computing, reactive programming is a
declarative programming
paradigm concerned withdata streams and the propagation of change
.변화의 전파와 데이터 흐름과 관련된 선언형 프로그래밍 패러다임 이다.
💠 Data Streams And The Propagation Of Change
좀 더 쉽게 풀어 쓰자면 일련의 흐름을 관찰할 수 있는 (Observable) 형태 로 만들어서 값의 변화 / 이벤트의 발생을 감지하는 것으로
이 값들은 마치 물이 흐르는 것 처럼 스트림 (Stream) 에 비유 할 수 있다.
경우에 따라 이 스트림의 값들을 필터링 하거나 버퍼링, 또는 다른 스트림의 값으로 바꾸는 등의 다양한 연산을 할 수 있다.
이렇게 스트림을 조작해서 원하는 결과가 통지 (Subscribe) 되므로 이 때 최종적으로 필요한 처리를 해줄 수 있게 된다.
💠 Declarative programming
최근 프로그래밍 패러다임은 크게 명령형 프로그래밍, 선언형 프로그래밍으로 구분지을 수 있다.
간단히 말하여, 명령형 프로그램은 알고리즘을 명시하고 목표는 명시하지 않는 데 반해 선언형 프로그램은 목표를 명시하고 알고리즘을 명시하지 않는 것이다.
인터넷에서 적당한 예시를 찾아 공유하고자 한다.
*️⃣ ‘그럼, 자리에 앉는 방법은 누가 알지?’, ‘주소는 아는데, 집에 가는 방법은 누가 알지?’
이에 대한 대답은, 선언형 방식의 접근을 위해서는 명령형 방식으로 ‘어떻게 접근하는가’에 관한 내용이 먼저 ‘추상화’되어있어야 한다는 것이다.
-
식당 직원에게 사용했던 선언형 접근 (
2명 자리 주세요
)에서는, 식당 직원이 ‘테이블에 어떻게 앉는가’에 관한 모든 명령형(절차적) 단계들을 알고 있다는 가정이 뒷받침 되어있다. -
마트에서 친구에게 우리집의 주소를 알려주는 선언형 접근방식에서도, ‘친구가 우리집에 어떻게 올 수 있는가’에 관한 명령적 절차들을 모두 알고있는 GPS같은 것을 가지고 있다는 전제로 한다.
즉, 많은 선언형(Declarative) 접근 방식들의 기반에는 일종의 명령적(Imperative) 추상화가 존재한다.
📚 참고자료 :
*️⃣ 코드로 살펴보자
C#에는 이미 LINQ라고 하는 선언형인 프로그래밍 문법이 있다.
이를 통해 선언형 프로그래밍의 느낌을 잡아보도록 하자.
예를 들어, 어떤 정수 배열에서 짝수만 뽑아내어 오름차순으로 정렬하는 코드를 작성해야 한다고 하자.
명령형 프로그래밍으로는 아래 처럼 작성할 수 있다.
// 샘플 데이터: 정수 배열
int[] numbers = { 5, 8, 3, 12, 9, 4, 7, 10 };
List<int> evenNumbers = new List<int>();
// 명령형 스타일: 짝수 필터링
foreach (var number in numbers)
{
if (number % 2 == 0)
{
evenNumbers.Add(number);
}
}
// 버블 정렬
int count = evenNumbers.Count;
for (int i = 0; i < count - 1; i++)
{
for (int j = 0; j < count - 1 - i; j++)
{
if (list[j] > list[j + 1])
{
(list[j], list[j+1]) = (list[j+1], list[j]);
}
}
}
선언형 프로그래밍은 다음과 같이 간단해진다.
// 샘플 데이터: 정수 배열
int[] numbers = { 5, 8, 3, 12, 9, 4, 7, 10 };
// LINQ를 사용하여 짝수만 필터링하고 정렬
var evenNumbers = numbers
.Where(n => n % 2 == 0) // 짝수만 필터링
.OrderBy(n => n); // 오름차순 정렬
어떻게
선택하고, 어떻게
정렬하는지는 관심 없다. 알아서 잘 했겠지 뭐….
선언형 프로그래밍은 주어진 데이터로 어떤
데이터만 선택 하는지 (짝수만),
그리고 어떤
데이터 순서로 정렬하는지 (내림차순) 만 정의하는 것이다.
전체 코드
namespace MyTempCSharpProject
{
public class Program()
{
public static void Main( string[] args )
{
ImparativeProgramming();
DeclarativeProgramming();
}
public static void ImparativeProgramming()
{
// 샘플 데이터: 정수 배열
int[] numbers = { 5, 8, 3, 12, 9, 4, 7, 10 };
List<int> evenNumbers = new ();
// 명령형 스타일: 짝수 필터링
foreach ( var number in numbers )
{
if ( number % 2 == 0 )
{
evenNumbers.Add(number);
}
}
int count = evenNumbers.Count;
for ( int i = 0 ; i < count - 1 ; i++ )
{
for ( int j = 0 ; j < count - 1 - i ; j++ )
{
if ( evenNumbers[j] > evenNumbers[j + 1] )
{
(evenNumbers[j], evenNumbers[j + 1]) = (evenNumbers[j + 1], evenNumbers[j]);
}
}
}
// 정렬된 결과 출력
Console.WriteLine($"명령형 결과 : {string.Join(", ", evenNumbers)}");
}
public static void DeclarativeProgramming()
{
// 샘플 데이터: 정수 배열
int[] numbers = { 5, 8, 3, 12, 9, 4, 7, 10 };
// LINQ를 사용하여 짝수만 필터링하고 정렬
var evenNumbers = numbers
.Where(n => n % 2 == 0) // 짝수만 필터링
.OrderBy(n => n); // 오름차순 정렬
// 정렬된 결과 출력
Console.WriteLine($"선언형 결과 : {string.Join(", ", evenNumbers)}");
}
}
}
💠 요약하면…?
Rx 프로그래밍
은 Async Event(비동기 이벤트) 와 Observer 디자인패턴의 통지 처리를 이용하여, 미리
이런 조건이 발생하면 이런 처리를 하라는 선언적 지시를 내려놓는 형태가 된다.
Reactive Programming
은 .NET
뿐만 아니라 RxJava, RxJS, Rx++, ReactiveCocoa, RxScala, RxClojure, RxSwift 등등
수많은 언어에서 사용 가능하도록 이미 많은 라이브러리가 제공 되고 있으며 사용자층 또한 매우 빠르게 늘어나고 있다.
( .NET에서는 Microsoft Research가 개발한 RX.NET이 있다.)
정리하면… RX 프로그래밍은
Observer Pattern + Iterator Pattern + Functional Programming 이다.
🔶 그럼 UniRX는 무엇인가?
UniRX는 Reactive Programming을 Unity에서 사용할 수 있도록 한 일본인이 Unity 전용으로 최적화 하여 공개한 라이브러리이다.
Mono에서 RX.NET
을 사용하기엔 .NET의 버전 문제와 라이브러리의 볼륨때문에 사용이 어려웠기에, 이에 맞춰 가공한 것이다.
UniRX는 RX를 Unity에서 구현한 라이브러리인 만큼 더 가벼우며 UGUI, GameObject, Coroutine 등 유니티의 시스템과 매우 강력하고
직관적으로 연동이 되어있어 쉽게 Reactive Programming 의 사용이 가능하며,
Unity AssetStor에서 패키지를 임포트하여 프로젝트에 쉽게 적용이 가능하다.
라이선스는 MIT이며, 소스코드도 Github에 공개하고있다.
🔷 UniRX 적용하기
일단 좋은 말은 많이 주절거려서 좋은건 알겠는데… 그래서 어따씀?
이라는 궁금증이 저절로 떠오를 것이다.
하나의 예시를 들어, UniRX에 대한 감을 익혀보자.
굉장히 간단한 질문이지만, 당신은…
물론 어렵지 않을 수 있다.
마지막 클릭 했을 때 부터 일정 시간 이내라면 더블 클릭?
클릭 횟수 변수와 타이머 변수를 필드에 정의?
Update()
내에 판정 처리를 구현?
예를들면, 다음과 같을 것이다.
using UnityEngine;
using UnityEngine.UI;
public class DoubleClickHandler : MonoBehaviour
{
public Text MyText; //Text GUI
private bool isClicked = false; //첫번째 클릭이 된 상태인가
private float clickTime = 0.0f; //첫번째 클릭 후 흐른 시간
private void Update()
{
if (isClicked == true) //이미 첫번째 클릭이 되었다면
{
clickTime += Time.DeltaTime(); //흐른 시간을 누적 시킨다
}
// 시간이 넘어 버렸을 때
if(clickTime > 0.3f)
{
clickTime = 0.0f;
isClicked = false;
}
// 클릭에 성공했을 때
else if (Inpub.GetMouseButtonDown(0)) //마우스를 클릭 했다면
{
if (isClicked == false) //이번이 첫 클릭이라면
{
isClicked = true;
return;
}
if (clickTime <= 0.3f) //첫 클릭 후 0.3초 이내에 클릭되었다면
{
//더블클릭 성공
gameObject.GetComponent<Text>().text = "Double Clicked! Click Count";
}
}
}
}
이를 UniRX를 사용한다면 아래와 같다.
using UniRx;
using UniRx.Triggers;
using UnityEngine;
using UnityEngine.UI;
public class DoubleClickHandlerRX : MonoBehaviour
{
public Text MyText;
private void Start()
{
//매프레임 마우스 클릭 이벤트를 관찰하는 스트림을 정의 한다.
var clickStream = UpdateAsObservable()
.Where(_ => Input.GetMouseButtonDown(0));
//이 스트림에 0.3초 간 흘러들어오는 (마우스클릭) 이벤트를 모은다.(Buffer)
clickStream.Buffer(clickStream.Throttle(TimeSpan.FromMilliseconds(300)))
.Where(x => x.Count >= 2) //(마우스클릭) 이벤트가 2회 이상 발생한 경우만 필터링
.SubscribeToText(MyText, x => //위 조건을 충족한 경우 GUI의 MyText 컴포넌트에 정보 출력
$"Double Clicked! Click Count", );
}
}
출처: 아마군의 Dev로그:티스토리
다음과 같은 장점이 있는걸로 보인다!
-
코드가 간결해졌다.
-
쓸데없는 Flag 변수나 시간을 저장하는 임시 변수의 선언이 줄어들었다.
-
Update()의 로직을 모두 스트림화하여 Update() 없앴다.
🔷 개념 설명
좀 더 명확한 설명을 위해서, Button을 클릭 할 때, Text에 Click
라고 표시하는 스크립트를 작성해 보도록 하자.
private void start()
{
button.onClick
.AsObservable() // 이벤트를 스트림으로 변경
.Subscribe( _ => // 스트림의 구독
{
text.text = "Clicked";
})
}
🔶 Stream : 이벤트의 흐름
이벤트가 흐르는 파이프 같은 이미지
-
타임라인에 배열되어 있는 이벤트의 시퀀스
-
분기되거나 합쳐지는게 가능하다
-
코드안에서는
IObservable<T>
로 취급된다.
🔶 Message : 스트림에 흐르는 이벤트
메세지는 3종류가 있다.
-
OnNext : 일반적으로 사용되는 메세지 (보통 이것을 사용한다.)
-
OnError : 에러 발생시에 예외를 통지하는 메세지
-
OnCompleted : 스트림이 완료되었음을 통지하는 메세지
🔶 Subscribe : 스트림의 구독
스트림의 말단에서 메세지가 올 때 무엇을 할 것인지를 정의한다.
스트림은 Subscribe된 순간에 생성된다.
-
기본적으로 Subscribe하지 않은 한 스트림은 동작하지 않는다.
-
Subscribe 타이밍에 의해서 결과가 바뀔 수 있다.
-
Subscribe는 오버로드로 여러 개 정의되어 있어서, 용도에 따라 사용해야 한다.
-
OnNext
-
OnNext & OnCompleted
-
OnNext & OnError & OnCompleted
-
-
OnError
,OnCompleted
메세지가 오면Subscribe
는 종료된다.
📌 For UniRX
UniRX에는, UGUI용 Observable과 Subscribe가 준비되어 있다.
button .OnClickAsObservable() .SubscribeToText(text, _ => "clicked");
음… 무슨 말인지 알겠는데, 이렇게까지 배워서 쓸만한진 잘 모르겠다.
더 알아보도록 하자.
🔷 UniRX 예시 1
다음은 3번 클릭을 감지하는 코드이다.
button
.OnClickAsObservable()
.Buffer(3)
.SubscribeToText(text, _ => "clicked");
클릭 횟수를 저장하는 변수를 추가하는대신,
Buffer
라는 메서드로 스트림을 새롭게 가공하는 것으로 쉽게 구현이 가능하다.
🔷 UniRX 예시 2
다음은 버튼이 2개가 있을 때, 버튼이 둘 다 누른 적이 있는 경우를 감지하는 코드이다.
button1
.OnClickAsObservable()
.Zip(button2.OnClickAsObservable(), (b1, b2) => {"Clicked!"})
.First()
.Repeat()
.SubscribeToText(text, _ => "clicked");
zip
메서드는 메세지가 모인 떄에 하나의 이벤트로 취급해서 보낸다.
합쳐진 메세지는 임의로 가공해서 출력이 가능하다.
🔷 오퍼레이터
스트림을 조작할 수 있는 메서드는 아래처럼 엄청 많다.
UniRX에서 지원하는 오퍼레이터들
- Select
- Where
- Skip
- SkipUntil
- SkilWhile
- Take
- TakeUntil
- TakeWhile
- Throttle
- Zip
- Merge
- CombineLatest
- Distinct
- DistinctUntilChange
- Delay
- DelayFrame
- First
- FirstOrDefault
- Last
- LastOfDefault
- ...
이를 통해 실제 게임 개발에서 사용할 수 있는 다양한 사례를 소개한다.
🔶 사례 1. 플레이어가 지면에 떨어지는 순간에 이펙트를 발생
- CharacterController.isGrounded를 매 프레임 체크
- 현재 프레임 값의
isGrounded
를 필드 변수에 저장 - 매 프레임마다,
isGrounded
의 값이 false에서 true로 바뀔 때, 파티클 재생
updateAsObservable()
.Select(_ => characterController.isGrounded)
.DistinctUntilChange()
.Where(x => x)
.Subsribe(_ => particle.Play());
🔶 사례 2. 플레이어가 지면에 떨어지는 순간에 이펙트를 발생 2
하지만 지면이 곡률일 경우, isGround를 매 프레임마다 체크하면 심한 떨림이 발생한다.
이 값의 변화를 UniRX로 정제할 수 있다.
updateAsObservable()
.Select(_ => characterController.isGrounded)
.DistinctUntilChange()
.ThrottleFrame(5)
.Subsribe(_ => particle.Play());
결론
RX 프로그래밍을 사용하면, 다음과 같은 장점을 얻을 수 있다.
- 비동기 데이터 스트림을 중심으로 동작한다.
- 스트림 내의 데이터에 변화가 발생했을 때 반응형으로 기능이 동작하는 방식을 사용한다.
- 시간을 상당히 간단하게 취급할 수 있게 된다.