선생님, 개발을 잘하고 싶어요.

[RxJava] RxJava를 왜 배워야할까? 무슨 문제를 해결할까? 본문

강좌

[RxJava] RxJava를 왜 배워야할까? 무슨 문제를 해결할까?

알고싶은 승민 2022. 5. 15. 14:25

도입

우리는 어떤 기술을 사용하기 위해서 배울 때, 왜 이 기술을 사용해야 하는지 알아야 한다.
그러려면 우선 이 기술이 해결한다고 주장하는 문제를 이해할 필요가 있다. (이 과정에서 주장하는 문제에 공감할 수 있는가가 배움의 동기가 된다.)
문제에 공감했으면 해결하기 위해서 기술이 제안하는 방법을 이해해야 한다. (풍부한 실제 사례를 들어서 비교 분석해야 한다, 이때 실제로 문제를 겪은 경험이 있다면 도움이 된다.)
더 나아가서 다른 방법으로는 이 문제를 해결할 수 있는지 고민해보아야 한다.

 

추상화

이름으로 User를 찾고 그 User의 정보를 출력하는 프로그램을 만든다고 생각하자.

 

 

 

 

main 함수는 이름에 해당하는 User를 찾기 위해서 파일 시스템을 조회해야 한다는 걸 알고 명시하고 있다.
이러한 지식을 안다는 것이 의미하는 건 무엇일까? 바로 User를 찾기 위한 세부사항이 변경될 때 main도 바뀌어야 함을 의미한다.
가령 User를 저장한 파일 이름이 변경된다면 main 코드가 변경되어야 할 것이다. ("usersdb.txt" -> "newusersdb.txt")
가령 name을 나타내는 데이터가 row 두 번째 위치라면 main 코드가 변경되어야 할 것이다. (row[0] == name -> row[1] == name)
우리는 이를 해결하기 위해서 이름을 기반으로 User를 받아오는 함수를 만들어 빼낼 수 있을 것이다.

 

 

 

 

이제 아까와 같이 파일 이름이 변경된다거나 name을 나타내는 데이터의 row상 순서가 변경될 때 main에는 어떤 일이 일어날까?
아무런 변화가 없다. 그런 변경 사항은 getUser 함수에서 처리가 되어야 한다.
어떻게 이런 일이 가능할까? 바로 getUser라는 함수가 제공하는 추상화 때문이다.
getUser는 함수 사용자에게 다음과 같은 정보만 알려준다. "네가 나한테 이름만 알려주면 User를 반환해줄게"
main은 이러한 사실만 알고 코드를 작성했다. 즉 main은 User를 받아오기 위한 지식을 알지 못한다.
이를 나는 main이 getUser의 추상화에 의존해서 작성되었다고 한다.

이렇게 모른다는 것에서 오는 장점은 생각보다 강력하다.
예를 들어보자. 이번에는 파일 시스템을 통해서 user를 받아오는 게 아니라
메모리에 저장된 user를 받아온다고 해보자.

 

 

 

 

User를 파일시스템을 통해 받아오든 메모리에 저장된 값을 받아오든 main은 똑같다.
이런 일이 가능한 이유는 main이 애초에 User를 파일시스템을 통해 받아온다는 걸 몰랐기 때문이다.
main이 아는 것이라고는 "이름을 주면 User를 돌려줄게"라는 지식뿐이었기 때문이다.
User를 어떻게 찾아오는가라는 구체적인 사항은 getUser 내부에서 격리될 수 있었다.


단 이 이야기는 동기적 코드를 작성할 때만 맞는 말이 된다.
이번에는 User를 받아오는데 매우 오랜 시간이 걸린다고 생각해보자.

현대 GUI 애플리케이션에서는 사용자의 터치 이벤트에 반응하고 UI를 그려주기 위해서 MainThread 하나를 강제하고 있다.
이러한 MainThread는 오랜 시간 블락이 걸리면 안 되는데, 휴대폰으로 앱 화면을 터치했는데 아무런 반응이 없다면 안되기 때문이다.

이러한 상황에서 User를 받아오는데 오랜 시간이 걸린다고 MainThread를 멈추고 기다릴 수는 없는바
User를 받아오는 작업을 별도의 WorkerThread에 넘기고, WorkerThread에서 시간을 들여 User를 검색한 이후에 실행되는 callback을 등록하는 방법을 사용할 수 있다.
우리는 이러한 상황을 비동기적으로 동작하는 코드라고 말한다.

 

 

이렇게 User를 받아오는 동작이 비동기적으로 동작해야 하는 경우 main 코드가 달라진다.
즉 기존 main 코드는 함수가 동기적으로 동작한다는 사실을 알고 있었다는 뜻이다.
즉 getUser 함수에서 동기와 비동기는 추상화되어있지 않다.
getUser를 호출하는 main입장에서 동기와 비동기를 알고 있어야 한다는 말이다.

이를 해결하기 위해서 무조건 callback을 쓰는 방법을 떠올릴 수 있다.
이러면 main에선 동기를 표현하는 코드나 비동기를 표현하는 코드가 같아질 것이다.

 

 

모두 callback을 사용하면 동기와 비동기를 추상화할 수 있음을 
하지만 callback으로는 해결하기 어려운 다양한 문제가 있다.
그중 하나는 동시성 문제다.
두 사용자가 같은 hobby를 가졌는지 판단하려면 어떻게 만들면 될까?

 

 

  1. 공통 변수를 접근하게 되면서 타이밍 문제가 발생할 수 있다
  2. user1이 로드되었을 때, user2가 로드된 상태라면 비교한다.
  3. 혹은 이런식으로 순차적으로 처리할 수 있지만, 병렬적으로 실행되도 괜찮은 동작을 순차적으로 만들어 비효율적이다.

발행 구독 모델의 추상화 구현체로써 RxJava

callback은 push model의 일종이다.
getUser에 함께 전달한 onUser는 main 코드에서 실행이 좌우되지 않는다.
getUser에서 적절한 처리를 한 이후 User 객체를 만들고 준비가 됐을 때 callback의 인자로 전달하는 것이다.
이런 동작은 main 입장에서는 임의의 시간에 User 객체를 push 받는 것과 유사하다.


callback이 동기냐 비동기냐를 추상화시킬 수 있던 이유는 바로 이 push model 덕분이다.
하지만 위에서 살펴봤듯 callback을 정밀하게 사용하기란 정말 어려운 일이다.


따라서 우리는 더 쉽게 push model을 구현할 수 있는 모델이 필요하다.


push model을 구성하기 위해서 Observer Pattern을 활용할 수 있다.
Observer Pattern은 간단히 말하면 발행 구독 모델인데
main이 getUser에게 callback을 등록하는 과정을 구독이라고 생각할 수 있고
getUser가 User를 찾은 이후 callback을 실행하며 인자로 User를 전달하는 과정을 발행으로 생각할 수 있다.


RxJava에서는 이를 달성하기 위해서 Single<User>라는 타입으로 이러한 발행 구독 모델을 추상화해놓았다.


Single<User> 타입은 "언젠가 User 하나를 발행할 거야"라는 추상화다.
Single<User>가 제공하는 subscirbe 인터페이스를 통해서 "User가 발행되면 그 User를 받아서 이 함수를 실행켜줘" 라는 의미로 구독을 시작할 수 있다.


이러한 Single<User> 타입을 활용해서 다시 작성해보자.

 

 

  1. Single<User> 타입을 만들기 위해서 fromCallable 이라는 생성 함수를 사용했다. 인자로 받은 { server.getUser(name) } 의 반환을 발행하게 된다.
  2. RxJava의 타입에는 다양한 연산자들이 존재하는데 subscirbeOn 연산자를 활용하면 손쉽게 실행될 스레드를 지정할 수 있다. server.getUser(name)이 MainThread에서 실행되면 안됐다는 사실이 기억나는가? 이 함수를 사용해서 간단히 IOThread로 스레드 변경을 처리할 수 있다.
  3. Single<User> 타입이 발행하는 User를 push 받기 위해서는 subscribe (RxKotlin - subscribeBy)를 통해서 구독해야한다.
  4. onSuccess 인자를 통해서 성공적으로 발행된 User 객체를 받아서 처리하는 callback을 등록할 수 있다.
  5. 기존 getUserAsync를 보면 알 수 있지만 해당 함수 내부에서 에러가 발생한 경우 처리가 미흡함을 눈치챌 수 있다. RxJava는 Single<User>.subscribe 가 제공하는 onError 인자를 통해서 발생한 에러에 대한 적절한 동작을 손쉽게 제공할 수 있다.

 

파일 시스템을 뒤져서 User를 받아오는 케이스도 Single<User> 타입으로 제공할 수 있다.
이 코드를 보면 알 수 있듯 파일 시스템 접근도 손쉽게 IO Thread에서 처리하도록 제공할 수 있었다.

 

 

 

메모리에 저장된 User를 받아오는 경우도 마찬가지다.
subscribeOn을 생략했는데 메모리를 통해서 받아오므로 굳이 IO Thread에서 동작할 이유가 없기 때문이다.

단순히 더미 데이터를 반환하고 싶을 수도 있다.
이런 경우에는 just라는 생성 연산자를 사용하면 된다.

 

 

위 케이스 모두 main 코드는 변경될 필요가 없다는 점에 주목하자.
동기와 비동기가 완전히 추상화되었고 추가적으로 예외 처리와 스레드 지정 또한 추상화되었음을 알 수 있다.


동시성 문제를 해결하기 위해서 도 이러한 push model 개념을 도입할 수 있다.
User 두 명을 비교했던 문제가 기억나는가?
User를 받아오는 각 작업이 비동기적이라 이 둘을 비교할 때 발생하는 미묘한 문제들이 있었다.


이를 해결하기 위해서 가장 쉽게 떠오르는 방법은 두 User 모두 준비가 되었을 때 Pair<User, User>를 push 받는 것이다.
기존 getUserAsync처럼 callback을 등록하려면 getTwoUserAsync(callback: (Pair<User, User>) -> Unit) 같은 새로운 함수를 설계해야 할 것이다.


RxJava에서 처럼 발행 구독 모델을 사용한다면 zip 연산을 활용해서 쉽게 코드를 재활용할 수 있다.

 

 

 

  1. User 하나를 발행하는 Single<User> 타입을 두개 준비했다.
  2. Single.zip 연산을 사용하면 인자로 들어오는 Single이 모두 발행 완료 했을 때, 그 결과를 Pair로 묶어서 Single<Pair<User, User>> 타입을 만들어낸다.
  3. Single<Pair<User, User>> 타입에 대한 구독을 등록한다.
  4. 당연히 인자로 들어오는 타입은 User 페어이고 이를 통해서 비교를 수행한다.

 

결론

이번 포스팅을 통해서 함수가 제공하는 추상화에 대해서 논의해보았고 이러한 추상화된 함수를 사용하는 코드에서 얻을 수 있는 장점을 알아보았다.

일반적인 함수로는 동기, 비동기 레벨의 추상화를 달성하기 어렵다는 사실을 살펴보았고 이를 해결하기 위해서 push model을 이용할 수 있음을 알 수 있었다.

이러한 push model의 구현체로 RxJava를 선택할 수 있었고 RxJava가 제공하는 대표적인 Single<T> 타입을 알아보았다. 이 타입은 "내가 언젠가 한번 T 타입 객체를 발행할 게"를 추상화 했다는 사실을 알 수 있었고 이러한 추상화를 통해서 Single<T>를 사용하는 코드가 동기와 비동기를 효과적으로 추상화 하는 사실을 알 수 있었다.

RxJava는 이러한 push model이 가지는 복잡한 문제들 동시성 문제나 멀티 스레딩 문제를 해결하기 위한 인터페이스를 제공한다는 사실을 알 수 있었고 Single.zip이나 subscribeOn과 같은 연산자를 살펴보았다.

 

참고자료

- https://youtu.be/_t06LRX0DVReactive Extensions

- https://reactivex.io/

Comments