오늘은 우연히 알게된 Reactive Programming 이라는 것에 대해 알아보고 공유해보고자 한다.




🔷 개론

🔶 Reactive Programming

‘비동기적 데이터 흐름’ 을 처리하는 프로그래밍 기법이라는 뜻으로 모든 처리를 비동기적 데이터 스트림으로 간주,

Observer 디자인패턴을 활용해서 이러한 비동기 이벤트를 처리하는 것이 핵심이다.

In computing, reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change.

변화의 전파와 데이터 흐름과 관련된 선언형 프로그래밍 패러다임 이다.


💠 Data Streams And The Propagation Of Change


좀 더 쉽게 풀어 쓰자면 일련의 흐름을 관찰할 수 있는 (Observable) 형태 로 만들어서 값의 변화 / 이벤트의 발생을 감지하는 것으로


이 값들은 마치 물이 흐르는 것 처럼 스트림 (Stream) 에 비유 할 수 있다.


경우에 따라 이 스트림의 값들을 필터링 하거나 버퍼링, 또는 다른 스트림의 값으로 바꾸는 등의 다양한 연산을 할 수 있다.


이렇게 스트림을 조작해서 원하는 결과가 통지 (Subscribe) 되므로 이 때 최종적으로 필요한 처리를 해줄 수 있게 된다.


Rx 프로그래밍의 기본적인 형태

💠 Declarative programming

최근 프로그래밍 패러다임은 크게 명령형 프로그래밍, 선언형 프로그래밍으로 구분지을 수 있다.

명령형 프로그래밍이란?

프로그래밍의 데이터와 상태를 변경시키는 구문의 관점에서 연산을 설명하는 프로그래밍 패러다임의 일종이다. 데이터와 상태를 어떻게 바꿀지 정의하는 프로그래밍 방법이다.
...
어떻게 계산을 할지보다는 무엇이 계산될 것인지를 정의한다는 생각으로 작성된다.
선언형 프로그래밍이란?

프로그램이 어떤 방법으로 해야 하는지를 나타내기보다 무엇을 할 것인지를 설명하는 경우에 선언형이라고 한다.

간단히 말하여, 명령형 프로그램은 알고리즘을 명시하고 목표는 명시하지 않는 데 반해 선언형 프로그램은 목표를 명시하고 알고리즘을 명시하지 않는 것이다.


인터넷에서 적당한 예시를 찾아 공유하고자 한다.


상황 1. 당신은 여자친구와 근사한 데이트를 하기로 결정했습니다. 식당에 도착하여 프론트데스크에 가서 다음과 같이 말하였습니다.
명령형 접근법 (HOW)
저기 "Gone Fishin"이라고 적힌 표지판 아래에 있는 테이블이 비어있네요. 우리는 저기로 걸어가서 저 테이블에 앉도록 하겠습니다.
→ 명령형 방식은 내가 실제로 자리에 '어떻게' 앉을지에 관심이 있다. 이를 위해 나는 내가 어떻게 테이블을 잡아서 자리에 앉을지에 관해 필요한 단계들을 하나하나 나열해야한다.
선언형 접근법 (WHAT)

2명 자리 주세요.
→ 반면, 선언형 방식은 오로지 내가 '무엇을' 원하는지에 관심이 있다.


상황 2. 친구가 우리집에 집들이를 오기 위해 마트에서 선물을 구매하였습니다. 친구는 지금 마트바로 옆에 있고, 우리집에 어떻게 오는지 저에게 전화로 물어봅니다.
명령형 접근법 (HOW)
주차장 북구 출구로 나와서 좌회전을 하고, 12번가 출구에 도착할 때까지 XX 도로를 타고 와. 거기서 지하철 역에 가는 것처럼 출구에서 우회전을 해. 그리고 거기서 직진하다가 첫번째 신호등에서 우회전을 해. 그 다음에 나오는 신호등을 통과한 후에 좌회전을 하면 돼. 우리집은 골목에서 세번째 집이야
선언형 접근법 (WHAT)

우리집 주소는 AAA동 BBB로 CCC-DDD 야.


*️⃣ ‘그럼, 자리에 앉는 방법은 누가 알지?’, ‘주소는 아는데, 집에 가는 방법은 누가 알지?’

이에 대한 대답은, 선언형 방식의 접근을 위해서는 명령형 방식으로 ‘어떻게 접근하는가’에 관한 내용이 먼저 ‘추상화’되어있어야 한다는 것이다.

  • 식당 직원에게 사용했던 선언형 접근 (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 의 사용이 가능하며,

AssetsStor에 공개된 UniRX

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>로 취급된다.

RX의 스트림


🔶 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라는 메서드로 스트림을 새롭게 가공하는 것으로 쉽게 구현이 가능하다.

Buffer 메서드



🔷 UniRX 예시 2

다음은 버튼이 2개가 있을 때, 버튼이 둘 다 누른 적이 있는 경우를 감지하는 코드이다.

button1
  .OnClickAsObservable()
  .Zip(button2.OnClickAsObservable(), (b1, b2) => {"Clicked!"})
  .First()
  .Repeat()
  .SubscribeToText(text, _ => "clicked");

zip 메서드는 메세지가 모인 떄에 하나의 이벤트로 취급해서 보낸다.

합쳐진 메세지는 임의로 가공해서 출력이 가능하다.

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());
DistinctUntilChange


🔶 사례 2. 플레이어가 지면에 떨어지는 순간에 이펙트를 발생 2

하지만 지면이 곡률일 경우, isGround를 매 프레임마다 체크하면 심한 떨림이 발생한다.

이 값의 변화를 UniRX로 정제할 수 있다.

심한 떨림
updateAsObservable()
    .Select(_ => characterController.isGrounded)
    .DistinctUntilChange()
    .ThrottleFrame(5)
    .Subsribe(_ => particle.Play());
심한 떨림





결론

RX 프로그래밍을 사용하면, 다음과 같은 장점을 얻을 수 있다.

  • 비동기 데이터 스트림을 중심으로 동작한다.
  • 스트림 내의 데이터에 변화가 발생했을 때 반응형으로 기능이 동작하는 방식을 사용한다.
  • 시간을 상당히 간단하게 취급할 수 있게 된다.