brunch

You can make anything
by writing

C.S.Lewis

by 최우람 Jun 10. 2022

트로스트 앱 아키텍처 이야기 -2-


트로스트 앱 아키텍처는 The Clean Architecture 구조를 적용한 MVVM로 설계하였습니다.

위 클린 아키텍처의 그림은 다들 익숙하실 것이기에 굳이 설명하진 않겠습니다.

트로스트의 앱 아키텍처는 이 클린 아키텍처 구조로 설계되었습니다.


점선을 기준으로 좌측은 플랫폼 의존이 있는 영역, 우측은 순수 로직 영역입니다.

트로스트 앱 아키텍처는 역할에 맞게 View, ViewModel, Interactor, Repository 4개의 레이어로 나누어집니다. 이는 The Clean Architecture의 그림에서 각각 UI, Presenter, Use Cases, Entities와 매핑됩니다.

철저하게 플랫폼 의존성을 가진 레이어와 그렇지 않은 레이어를 물리적으로 분리하였고, 플랫폼 의존성이 없는 레이어의 단위 테스트는 모두 언어 레벨의 테스트로 구현되어 빠르게 수행합니다.


각 레이어는 인터페이스와 Rx로 연결되어 있습니다. 하위 레이어로는 인터페이스를 통해 연결되어있고 Rx를 이용하여 레이어 간 데이터를 전달하고 필요시 가공합니다. 이렇게 의존성이 역전되어 있기에 아래 레이어는 역할에 따라 자유롭게 교체될 수 있고 이로 인해 상위 레이어가 수정되어야 할 필요가 없습니다.

또한 의존성이 역전되어있는만큼 의존이 필요한 객체는 외부로부터 생성 시 주입받게 되어 있습니다.

(나중에 기술하지만 DI 라이브러리는 사용하지 않고 직접 구현하도록 유도하였습니다.)

객체 생성 시 의존성이 필요한 객체를 주입합니다.

 

이렇게 의존성을 역전시킨 구조는 테스트에서 더 강력한 힘을 발휘하는데 레이어 단위 테스트를 할 때에는 단순히 하위 레이어에 Mock을 주입하고 테스트 코드를 작성하면 됩니다. 테스트 타깃 레이어는 Mock에서 제공하는 데이터를 정상적으로 가공 및 전달하는지, 에러를 내보내는 Mock일 경우 정상적으로 에러 핸들링을 하는지만 테스트하면 됩니다.


그리고 이 모든 내용은 안드로이드와 iOS가 완전히 동일한 구조로, 각자의 언어로 구현되어 있습니다. 그렇기에 서로의 코드를 보고 옮기는 것이 가능합니다.


그럼 각 레이어에 대해 (가능하다면)코드를 곁들여 설명하겠습니다.




뷰 레이어는 UI, 즉 앱에서의 화면을 담당합니다. iOS의 경우 대체로 UIViewController, UIView로 되어있고 안드로이드는 Activity, (Dialog)Fragment, View로 구현되어 있습니다.

화면을 그리고 하위 뷰들로부터 이벤트를 받아서 뷰모델에게 전달하고 뷰모델로부터 받은 데이터로 화면을 갱신하는 역할을 합니다.

뷰 레이어는 각각 플랫폼에 맞는 방식으로 구현됩니다. 공통적인 부분으로는 기본 뷰들로부터 받는 이벤트를 Rx로 포팅하여 뷰모델에 바인딩합니다. (Android : RxBinding, iOS : RxCocoa)


실제 코드에서 구현부만 제거한 코드입니다. 뷰 레이어는 뷰모델과 바인딩되는 부분을 제외하고 각각 플랫폼에 맞게 작성됩니다.

굳이 코드에서 차이점을 꼽자면 iOS는 클로저에서 [weak self] 처리를 위해 withUnretained를 사용하고 있다는 부분과 안드로이드에서 UI처리 시 메인 스레드를 사용하게 하는 부분에 차이가 있습니다.

iOS는 RxCocoa의 Relay와 Signal/Driver를 사용하기에 뷰에서 스레드 핸들링을 따로 하지 않습니다. 안드로이드는 뷰모델로부터 데이터를 받아서 뷰들을 컨트롤하는 경우에는 반드시 메인 스레드를 사용하도록 하고 있습니다.

이 부분을 제외하면 양쪽 코드를 읽는데 문제가 없을 것입니다.




뷰모델


뷰모델은 Input, Output, Dependecy로 구성되어 있습니다. Input은 뷰모델로 들어오는 데이터 스트림, Output은 뷰모델에서 나가는 데이터 스트림, Dependency는 뷰모델을 구성, 사용하기 위해 주입받아야 할 데이터들입니다.

뷰모델은 생성자에서 dependency를 반드시 주입받도록 되어있습니다. 그리고 Input, Output을 바인딩합니다. 뷰모델에서는 Subject를 사용하여 Input, Output을 바인딩합니다.

Input으로 제공할 때에는 이벤트 발행이 가능하도록 Observer 타입으로, Output으로 제공할 때에는 수신만 할 수 있게 Observable로 제공합니다.

iOS도 거의 같지만 RxCocoa에서 제공하는 기능을 사용하여 Subject 대신 Relay를 사용하고 Ouput에서는 Signal, Driver를 사용한다는 부분만 다릅니다.


실제 사용중인 뷰모델의 선언부입니다.


뷰모델은 자신의 뷰와 바인딩되어 뷰로부터 이벤트를 받고(Input), 뷰에게 무엇을 그리라고 명령합니다(Output). 또한 뷰모델이 동작하기 위해 필요한 데이터나 객체들을 Dependency를 통해 주입받습니다. 이 3개가 뷰모델을 구성하는 필수 요소가 됩니다.


뷰모델은 뷰나 다른 뷰모델, 플랫폼의 SDK들(인터페이스를 통해)이 이벤트를 뷰모델에게 보내 주거나 또는 결과를 받기 위해 Input, Output을 제공합니다. 즉, 자신의 뷰와 소통하기 위해, 다른 뷰의 뷰모델과 소통하기 위해, 또는 플랫폼의 여러 컴포넌트와 소통하기 위한 채널입니다.

외부에는 이 두 채널만 공개되어 있고 이들을 바인딩 함으로 원하는 동작을 처리할 수 있습니다.


안드로이드, iOS 모두 뷰로부터 이벤트를 받아서 처리를 시작하고 인터렉터로부터 데이터를 받아 뷰로 명령하는 뷰모델의 코드는 완전히 똑같을 수 있습니다. 플랫폼의 의존성이 제거된 순수 로직 영역이기 때문이죠. 그렇기 때문에 이 부분을 서로 베낄 수 있기에 마치 2명이 있는 것처럼 개발할 수 있는 것입니다.


뷰모델의 실제 사용 예로 트로스트의 감정기록 기능을 보여드리겠습니다.

현재 초기 기능만 적용된 상태고 고도화 진행중이 기능입니다.

감정기록 기능의 간단한 플로우와 뷰모델 코드를 보여드리겠습니다.

감정기록 초기 기능입니다.


실제 감정 기록 기능에 사용되는 뷰모델의 코드입니다.


뷰모델은 위와 같이 주로 자신의 뷰와 바인딩되지만 필요할 경우 다른 뷰모델과도 바인딩되기도 합니다. 한 화면에 여러 뷰가 보이고 서로 인터렉션이 일어난다거나, 어떤 화면에서의 변경사항이 다른 화면에도 공유되어야 하는 경우가 그렇죠.

이 경우를 트로스트의 사운드 플레이어로 예를 들어 설명해 보겠습니다.


트로스트 사운드 중 ASMR 기능

트로스트 사운드 플레이어에는 조작패널이 있습니다. 조작패널은 사운드의 볼륨이나 목소리 등 상태를 보여주고 조작할 수 있습니다. 조작패널의 뷰모델은 볼륨이나 목소리 등의 변경되는 사항을 사운드 플레이어 뷰모델에게 실시간으로 전달해야 합니다.



붉은색 화살표는 전달, 파란색 선은 바인딩을 의미합니다.

우선 이전 화면인 사운드 플레이어 뷰모델이 다음 화면인 패널의 뷰모델을 생성하고 디펜던시를 주입해 줍니다. 이때 패널의 뷰모델과 자신을 바인딩합니다. 아주 간단하게 플레이어 뷰모델에서 패널 뷰모델에서 필요한 Output을 단지 Subscribe 하면 됩니다. 지금 이것으로 다음 화면의 이벤트를 지금 뷰모델이 받을 준비는 끝났습니다.


이제 생성된 뷰모델을 다음 화면인 패널 뷰에게 주입해주기만 하면 됩니다. 하지만 뷰모델이 직접 다음 화면의 뷰를 생성하고 관리할 수 없습니다. 뷰모델은 플랫폼과의 종속성이 끊어져 있기 때문입니다. 그렇기에 플레이어 뷰모델은 생성 및 바인딩까지 완료한 패널 뷰모델을 자신의 뷰에게 전달합니다. 뷰는 온전히 플랫폼에 종속되기 때문에 뷰를 생성하고 관리할 수 있습니다.


뷰는 이제 각 플랫폼의 방식대로 다음 화면 뷰를 생성하고 push, present, addView, startActivity 등등 원하는 대로 띄웁니다. 이때 패널 뷰에는 플레이어 뷰모델로부터 받은 바인딩까지 끝난 패널 뷰모델을 주입합니다. 이렇게 어떤 플랫폼인지 상관없이 플레이어 뷰모델은 패널 뷰모델로부터 필요한 이벤트를 전달받을 수 있고 이 이벤트로 자신의 뷰를 원하는 대로 그릴 수 있습니다.



인터렉터


인터렉터는 비즈니스 로직을 담당합니다. 레파짓으로부터 raw 한 데이터를 받아 뷰모델에서 사용 가능하게 가공하여 제공합니다. 그렇기에 인터렉터는 생성 시 레파짓을 주입받습니다.

인터렉터 기준 상위 레이어로 데이터를 보낼 때 Rx를 사용하기에 데이터를 가공할 때 Rx의 데이터 가공 연산자를 사용합니다.



레파지토리


레파지토리는 API, Socket같이 서버와 데이터를 처리하는 리모트와 SharedPreference/UserDefault, DB/CoreData 같은 디바이스의 저장소를 담당하는 로컬로 나누어집니다. 각각 플랫폼의 라이브러리를 인터페이스와 Rx를 이용하여 인터렉터에서 사용 가능하게 구현하고 있습니다.


현시점에서의 인터렉터와 레파지토리의 코드는 예시로 보일 수 있게 코드 정리를 하고 나니 내용이 너무 없어서 생략하였습니다. 추후 공개하기 알맞게 정리되면 추가하도록 하겠습니다.





간략하게 트로스트 앱 아키텍처에 대해 설명하였습니다. MVVM에서 흔히 보이는 기술과 구조이고 Rx를 사용하였기에 익숙한 모양일 것이라 생각됩니다. 트로스트는 다만, 이 흔한 구조에서 철저하게 의존성을 지키면서 두 서로 다른 플랫폼이 함께 개발할 수 있는 방향에 집중하였습니다.


이렇게 아키텍처를 설계한 큰 이유 중에 하나인 테스트에 대한 설명이 남아있습니다. 또 이러한 아키텍처를 사용함에 있어서 장점도 있지만 분명 단점도 있습니다. 그리고 앞으로 해결해 나갈 문제들도 있죠.

다음 글에서는 이 내용들에 대해 다루어 보겠습니다.


[다음 글 보기]



브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari