단방향 데이터 플로우(Unidirectial Data Flow, UDF) iOS 앱 아키텍처로 복잡한 상태 관리하기

이동영
How we build MyRealTrip
9 min readJul 9, 2020

시작하며

마이리얼트립이 서비스를 시작한 이후 꽤 시간이 흘렀습니다. 그동안 많은 기능이 추가되었고, 다양한 요구사항 변경이 있었습니다. 마이리얼트립 iOS 개발자들은 대처하기 쉬운 견고한 구조를 만들기 위해 지속적으로 개선해왔습니다. 최근까지 마이리얼트립 iOS 앱은 MVVM으로 개발해왔습니다.

MVVM도 완벽하게 만족스러운 것은 아닙니다. 어떤 이유일까요?

단순한 상태를 가진 화면에서의 MVVM

기존 MVVM에서의 흐름은 View가 Input을 스트림(Observable)에 넣으면ViewModel에서 데이터를 가공한 후 Output의 스트림(Observable)으로 View로 전달한 뒤 View는 가공된 값으로 화면에 그려지는 형태였습니다.

Output의 각각의 상태는 독립적인 스트림을 가지게 구현했습니다.

그 결과 이렇게 아름다운 Input과 Output을 볼 수 있었습니다.

MVVM 아키텍처에서 Input을 Output으로 가공해서 반환하는 방식은 이때까지는 명확해 보였습니다.

복잡한 상태를 가진 화면에서의 MVVM

복잡한 상태를 가지는 화면에서는 Output이 이전에 나온 Output들에 의존성을 가지는 경우를 자주 볼 수 있습니다. 그리고 이런 I/O는 위의 흐름처럼 아름답지 않습니다. 이전에 스트림으로 흘려보낸 Output을 저장하지 않아서 이전의 Output 가져와야 하거나 특정 상태를 저장하기 위한 인스턴스 프로퍼티들을 추가하거나 추가적인 RxOperator (combineLatest, withLastestFrom) 들이 필요하게 됩니다.

그림처럼 하나의 Output을 위해서는 내부 상태와 이전에 흘려보낸 Output의 상태요소들과 Input들이 결합 되어야만 합니다. 이 과정에서 코드의 복잡도가 증가하고 가독성이 떨어집니다.

지저분하게 엉켜있는 I/O들을 어떻게 풀까요?

상태를 한 곳에서 관리하기

입력 스트림을 하나로 결합하고 출력 스트림을 하나로 결합해서 모든 상태를 한 곳에서 관리하면 우리는 여기저기 엉켜있는 I/O들에서 벗어날 수 있습니다.

합쳐진 Input 요소들과 Output 요소들

그러기 위해선 두 가지의 요소가 필요합니다.

Action - 상태를 바꾸는 명령
State - 화면에 렌더링 될 속성들을 가지는 불변 객체

상태요소들은 State한 곳에 관리되고 ViewModel은 마지막에 방출한 StatecurrentState라는 프로퍼티로 가지고 있습니다. 그래서 수많은 Operator로 결합하는 일을 하지 않아도 됩니다. 그저 currentStateAction 만나서 newState을 방출할 뿐입니다.

하지만 문제가 존재합니다. 비동기적인 Action들은 느리게 끝날 수도 있습니다.

Action이 일어난 시점의 currentStateAction으로 인한 요청 결과가currentState와 달라질 수 있습니다. currentState는 데이터를 요청한 시점이 아닌 데이터 응답을 받은 시점의 값 이어야 합니다. 그렇다면 Action 을 비동기로 처리하는 메소드에서 currentStateAction을 동시에 인자로 받을 수는 없습니다.

위와 같은 방법도 고려해볼 수 있었습니다. 이 방법의 문제점은 reduce함수를 테스트하기 어렵다는 것이었습니다. 위의 코드는currentStatereduce함수를 순수하지 못하게 만들어 테스트를 어렵게 합니다. 테스트를 명확히 하기 위해서는 newState생성 직전의 currentState를 추적해야 합니다. 그래서 newState를 생성하는 함수를 순수하게 만들어 로직들의 Testability을 높이는 방법을 고려해봤습니다.

테스트 없는 코드 수정 후 빌드하는 우리의 모습

상태를 순수하게 관리하여 Testability 높이기

상태를 수정하는 함수를 순수하게 만들기 위해 하나의 요소를 추가했습니다. 모든 상태의 변경이 언제나 순수하게 동작할 수는 없습니다. 실제 서비스하는 앱들은 화면에 그릴 State와 유저의 Action외에도 서버, DB로부터 오는 데이터들이 필요합니다. 그리고 이런 데이터들로 인해State와 유저의 Action외에 의존성이 생기게 됩니다. 의존성을 없앨 수는 없지만 상태를 순수한 부분과 분리해내는SideEffect개념을 적용해 보겠습니다.

마이리얼트립 앱의 검색 화면을 예로 들어보겠습니다.

(왼쪽) 검색어 입력 중 자동 문구 완성 화면, (중간)검색 요청 후 로딩 화면 (Skeleton View), (오른쪽)검색 결과 상품 표시 화면

이 화면들을 위에서 언급한 Action, SideEffect, State 라는 개념을 이용하여 코드로 작성해 봤습니다.

Action은 사용자로부터 발생하거나 SideEffect의 결과로 발생할 수 있습니다.

화면에 표현될 상태들을 가지는 State 가 있습니다.

Action의 발생에 따른 처리는 아래와 같이 이루어집니다. ActionState를 변경하며 SideEffect를 필요에 따라 발생시킵니다. Action + currentState를 조합하는 상태의 수정은 언제나 지연 없이 발생하므로 순차적으로 동작할 수 있습니다. 이를 처리하는 스트림은 하나입니다. 그리고 View가 그리는 State는 언제나 하나뿐입니다. 멀티스레딩을 함으로써 생기는 문제들을 해결할 수 있습니다.

예) Action.tapSearchButtonState.onProgress = true 으로써 로딩 중임을 표현합니다. 그래서 Skeleton view를 활성화하고 실제 요청을 하는 SideEffect.requestSearchResult(for: query) 을 발생시킵니다.

SideEffect는 API 요청, DB에서 데이터를 가져오는 작업(대부분 비동기 작업)등 시간이 걸리는 작업을 정의합니다.

SideEffect은 처리 후 가져온 데이터로 다시 상태를 변경하기 위한 Action을 만들어냅니다.

예) SideEffect.requestSearch 에서 데이터를 가져오는 시간이 걸리는 작업 을 수행한 후 결과 데이터를 Action.fetchSearchResult 매핑하여 반환합니다.

Action은 다시 State를 변경합니다.

예) Action.fetchSearchResultState.searchResults 를 설정해 화면에 검색 결과를 보여줌과 동시에 State.onProgress = false 하여 Skeleton View를 비활성화시킵니다.

이 전체적인 흐름은 아래의 그림과 같이 진행됩니다.

이 그림처럼 Action은 새로운 State를 방출하며, 필요에 따라 SideEffect을 발생시킵니다. 그 후SideEffect이 처리되면 다시 Action이 발생하고 다시 새로운 State가 생성됩니다.

이 과정을 거치게 되면 테스트할 요소들은 2가지입니다.

  • currentState + Action 은 우리가 의도한 newState를 만들어내고 있는가?
  • Action에서 우리가 의도한 SideEffect를 발생시키고 있는가?

테스트와 마찬가지로 개발자가 코드에서 버그를 찾아야 할 때 추적하는 흐름도 동일합니다. 데이터의 흐름을 따라가게 되면 비교적 손쉽게 버그를 발견하고 수정할 수 있습니다.

UDF에서의 다른 골칫거리들

물론 UDF에서도 우리를 힘들게하는 요소들이 있습니다.

상태 요소들을 모을 때 생기는 골칫거리는 하나의 상태 요소 변경에 전체 상태를 다시 렌더링하게 된다는 것입니다.

View는 State의 요소를 화면에 표시될 UI 컴포넌트들과 바인딩합니다.

Action을 처리한 다음 새로운 State는 스트림으로 흘러나옵니다. 그 후 State의 요소와 바인딩 된 모든 UI 컴포넌트가 업데이트됩니다. 그러나 우리는 업데이트된 값만을 렌더링하기를 원합니다.

여기서 distinctUntilChanged() 연산자는 큰 힘이 됩니다. 아래의 방식으로 해결해 보았습니다. (다만 Equatable 을 꼭 구현해야 하는 부담도 있기는 합니다.)

이 구조체는 업데이트의 여부를 확인하기 위한 version 프로퍼티를 두고 값의 변경 여부를 판단합니다. 그래서 값의 동등 여부가 아니라 새로 값이 할당되었는지의 여부로 판단하고 업데이트합니다. 또 하나의 문제는 일부 경우에는 화면의 상태로 저장하지 않고 단발성으로 처리해야 하는 경우도 존재한다는 것입니다.

예를 들어 Toast(메시지를 표시하고 시간이 지난 뒤 사라지는 UI 컴포넌트)로 알릴 오류 내용, 다음 화면에 대한 URI 정보 한번만 View에 전달되고 사라져야 하는 상태값입니다. 이런 단발성 처리를 어떻게 해야 할지 또한 큰 고민거리입니다. 단발성 처리를 하는 방법은 다음에 다룰 기회가 생긴다면 한번 다뤄 보겠습니다.

마지막으로

Redux , MVI는 잘 알려진 단방향 데이터 플로우 아키텍처입니다. 데이터 플로우 아키텍처에 개념을 좀 더 자세히 알고 싶으신 분들은 링크된 글들을 참고하시면 이해에 큰 도움이 될 것 같습니다.

ReSwift, ReactorKit도 알려진 오픈 소스 단방향 아키텍처입니다. 실제 그 개념을 코드에 어떻게 녹여내고 있는지에 대한 글들을 참고해도 좋을 것 같습니다.

이 글에는 담겨 있지 않지만, 마이리얼트립의 안드로이드팀은 일찍이 Box라는 아키텍처로 마이리얼트립을 개발하고 있습니다. 그리고 개발과정에서 Box를 더욱 발전 시켜 나가고 있습니다.

다음 글에서는 조금 더 완성된 내용으로 마이리얼트립 iOS 앱의 아키텍처, 단발성 이벤트 및 테스트 코드를 처리하는 방법도 함께 다뤄 보겠습니다.

팀과 회사에 대해 궁금하신 분들은 아래의 페이지를 방문해주세요.

https://career.myrealtrip.com/

--

--