트로스트 앱 아키텍처 목적의 7할은 단위 테스트입니다.
왜 아키텍처는 굳이 레이어를 나누고 복잡하게 연결할까요?
여러 이유가 있지만 저는 가장 큰 이유 중 하나로 단위 테스트라고 봅니다. 각 레이어는 단위 테스트가 독립적으로 쉽게 가능하고, 이렇게 쌓인 테스트 코드는 제품의 안정성에 직결됩니다.
경험상 이 테스트 코드를 얼마나 잘 작성하고 관리하냐에 따라 제품의 안정성과 생산성은 엄청나게 달라집니다.
트로스트 앱 아키텍처를 설계하고 적용할 때부터 코드 작성에 있어 테스트 코드 작성은 필수였습니다.
아무리 아키텍처를 잘 설계했더라도 테스트 코드가 없다면 철저하게 분리한 레이어의 구분은 모호해질 수밖에 없고 점점 아키텍처의 힘을 잃어갈 수밖에 없습니다. 그만큼 강조하는 것이 이 테스트 코드입니다.
구현된 모든 뷰모델, 인터렉터, 레파지토리는 각각 자신의 테스트 코드가 있어야 합니다. 테스트 코드는 두 플랫폼을 통일시키지 않고 각 플랫폼이 각자의 방식으로 작성하되 BDD의 맞게 Given, When, Then 형식을 지키고 있습니다. 또한 뷰를 제외한 나머지 레이어는 플랫폼 의존성이 완전히 제거되어 있는 언어 레벨의 단위 테스트이기 때문에 수행 속도가 빠르고 CI에서 수행 가능합니다.
입사할 때만 해도 막연하게 테스트 코드를 작성해야 한다고만 알고 있었지 어떻게 작성해야 하는지는 몰라 어려움을 호소하던 주니어 개발자들도 이제는 제가 시키지 않아도 자발적으로, 주도적으로 테스트 코드를 작성하고 지속적으로 풀 리퀘스트를 올리고 함께 코드 리뷰를 하고 있습니다. 주니어 개발자들이 본인이 개발한 피처의 테스트 커버리지가 올라가는 수치를 자랑하는 것은 그들의 큰 성취감이고 성장의 원동력입니다. 그만큼 이 아키텍처에서 중요하게 생각하는 것이 단위 테스트 코드이고, 주니어 개발자들도 명확히 이해하고 있습니다.
뷰모델을 테스트하는 방법은 간단합니다. 뷰모델의 디펜던시에 Mock으로 만든 객체를 주입하여 뷰모델을 생성한 뒤 Output을 TestObserver로 구독하여 바인딩합니다(Given). 준비가 끝나면 각 Input별로 이벤트를 보내고(When) Output을 관찰합니다(Then). 정상적인 데이터 플로우를 보내는 Mock인 경우에는 에러가 없어야 하고 비정상적인 데이터 플로우를 보내는 Mock은 올바른 에러 핸들링이 동작하는지 확인합니다.
즉 Input 하나당 최소 2개에서 n개의 테스트 케이스가 만들어집니다.
안드로이드의 단위 테스트를 먼저 설명하겠습니다. 안드로이드는 Mockito를 이용하여 Mock을 만들어 테스트합니다. 아래에 실제로 쓰고 있는 테스트 코드 일부를 보여드리겠습니다.
뷰모델을 테스트함이 목적이므로 뷰모델은 실제 객체를 사용합니다만 디펜던시로 제공받는 객체들은 Mock을 주입합니다.
감정 기록의 경우 init이 들어오면 API를 통해 서버로부터 감정상태, 이미지 URL 등의 데이터를 받아서 뷰에게 그리라고 전달할 것입니다. 만일 에러가 발생하면 error메시지를 뷰에게 전달할 것입니다.
API처리를 담당하는 인터렉터를 Mock으로 주입하고, 뷰모델로부터 API요청을 받으면 미리 만들어둔 Mock ResponseData를 뷰모델에게 전달하도록 구현해 둡니다. 뷰모델 입장에서는 이것이 실제 데이터인지 알 필요도 없고 알 수도 없습니다. 그저 전달받은 데이터를 가지고 가공하고 처리할 뿐이죠.
우리는 이 Mock데이터를 뷰모델에서 잘 처리하는지 Output을 통해 관찰하기만 하면 됩니다.
iOS도 방식은 완전히 동일하지만 코드는 다릅니다. Mockito 같은 Mocking 라이브러리로 괜찮은 것을 아직 찾지 못해 현재는 코드로 직접 주입하고 있습니다. XCTest 대신 Quick/Nimble 라이브러리를 사용하고 있습니다. 나머지 내용은 위에서 설명한 내용과 완전히 동일하여 코드 일부를 공유드리고 넘어가겠습니다.
인터렉터는 레파지토리로부터 받은 데이터를 알맞게 가공하는지 테스트합니다. 하위 레이어인 레파지토리를 Mock으로 주입받습니다. 레파지토리로부터 받은 데이터를 매핑이나 조합 등의 가공이 들어간다면 정상적으로 됐는지 테스트합니다. 만약 API의 경우 에러 핸들링이 필요한 응답이라면 에러로 처리되는지, 응답의 statusCode에 따라 다르게 핸들링해야 하는 경우라면 잘 처리하는지 테스트합니다.
이 부분은 특별히 다룰 내용이 없어서 간단한 코드를 예시로 들고 넘어가겠습니다.
레파지토리는 API, Socket 라이브러리를 테스트하는 코드가 존재합니다. 또는 플랫폼의 DB나 임시 저장소 등의 인터페이스에 Mocking을 통해 테스트합니다.
현재 트로스트에 이 레이어의 테스트 코드는 아직 작성하지 못했습니다. 레파지토리를 구현하는 라이브러리를 교체할 예정이라 이 작업 후에 작성하려고 합니다. 작성하게 되면 추가하도록 하겠습니다.
이 아키텍처는 최고인가요?
결론만 이야기하면 절대 아닙니다. 개인적으로 아키텍처는 최선이 있을 수는 있지만 최고는 있을 수 없다고 생각합니다. 앱이 동작하는 방식이나 화면이 구성되는 방식, 개발자의 수에 따라 최선의 아키텍처는 모두 달라집니다. 최선이라는 것은 항상 상대적입니다. 현재 시점에 최선일지언정 미래의 환경에서는 전혀 그렇지 못할 수 있습니다.
현재 트로스트는 제한된 리소스를 최대한 효율적으로 사용하고 리소스의 효율을 극한으로 끌어올려야 하기 때문에 이 아키텍처가 최선이란 것입니다. 바꿔 이야기하면 단점도 충분히 많은 아키텍처란 이야기입니다.
이 아키텍처는 안드로이드 개발자 관점에서 치명적인 단점이 있습니다. 구글에서 유도하는 안드로이드 앱 개발 가이드 방향을 따르지 않고 있는 것이 가장 큰 문제입니다.
안드로이드는 JetPack을 통해 AAC 등 앱 개발에 쓸 수 있는 많은 기능을 제공하고 있고 Kotlin 언어도 코루틴 등 많은 기능을 추가, 제공하고 있습니다. 하지만 iOS와 코드를 맞추기 위해 뷰와 관련된 경우를 제외하고는 모두 포기하였습니다. 이는 솔직히 안드로이드 개발자 커리어 관점에서 치명적입니다.
DI 라이브러리를 사용하지 않는 점 역시 단점입니다. 처음에 설계할 때는 저에게 익숙한 DI 라이브러리를 사용하려 했습니다. 안드로이드는 Dagger(Hilt)를, Swift는 Swinject를 사용할 계획이었습니다. 하지만 주니어 개발자들이 DI를 이해하는 것 자체보다 라이브러리 사용법에만 너무 집중하고 또 두 플랫폼의 코드가 많이 달라지는 문제가 있었습니다. 그래서 라이브러리를 사용하지 않는 것으로 결정하였습니다. 멤버 객체의 주입은 사용하지 않고 생성자를 통해서만 주입받을 수 있게 설계하고 이를 개발자가 직접 구현하게 만들었습니다. 주입되는 객체의 생명주기 역시 개발자가 직접 핸들링해야 합니다.
이렇게 직접 코드를 구현하게 하면서 DI에 대한 이해는 명확할 수 있으나 라이브러리 사용법은 따로 공부해야 하죠.
라이브러리 사용법만 알고 이걸 왜 써야 하는지, 어떤 기능을 하는지, 그리고 어떻게 동작하는지 정확히 모르는 개발자가 많습니다. 이렇게 이 방법의 사용법에만 너무 몰입하게 되면 한 가지 방법만 추구하고 사용할 수 있는 개발자가 되기 쉽기 때문에 멀리서 바라보고 본질을 이해한 뒤에 다양한 라이브러리를 직접 사용해볼 수 있게 가이드하고 있습니다. 그 경험을 토대로 라이브러리의 장점과 단점을 파악하여 필요할 때 적절히 선택할 수 있게 되죠.
그렇기에 주니어 개발자들에게 항상 코드가 목적이 아닌 아키텍처 설계와 리액티브 프로그래밍에 대한 이해, 그리고 의존성 역전을 통한 자연스러운 의존성 주입을 강조하고 있습니다.
MVVM을 명확히 이해한다면 AAC의 ViewModel을 충분히 이해하고 사용할 수 있습니다.
우리 아키텍처에서 Rx를 통한 뷰/뷰모델 간의 바인딩을 명확히 이해한다면 LiveData 역시 쉽게 이해할 수 있습니다.
Rx를 통해 데이터를 비동기로 처리함과 스레드 컨트롤을 이해한다면 코루틴을 이해할 기초가 생기게 되고 Observable을 이해하면 코루틴의 Flow 역시 쉽게 이해할 수 있습니다.
의존성을 어떻게 구성할지 알아야 Dagger를 사용하더라도 컴포넌트와 모듈을 설계하고 프로바이더를 이용하여 주입할 객체를 설정할 수 있습니다.
결국 라이브러리나 언어의 기능을 사용하지 않더라도 이것이 무엇인지 정확히 이해하고 있어야 하는 것이 더 중요하다고 봅니다.
그렇기에 이 아키텍처의 단점들은 아키텍처 자체의 단점이지 트로스트 앱 주니어 개발자들에게는 오히려 장점이 됩니다. 단점을 통해 오히려 더 큰 그림을 이해할 수 있는 기회가 되기 때문이죠.
트로스트 앱 아키텍처는 멈춰있지 않습니다.
개발자가 충분히 많아진다는 등 환경이 변화하면 트로스트 앱 아키텍처 역시 각각 플랫폼에 최적화된 코드로 변경될 수 있고 또 변경되어야 합니다. 그때는 지금처럼 두 플랫폼의 코드를 하나로 맞추려는 게 오히려 오버헤드가 되겠죠.
게다가 제품의 규모가 커지면 모듈화도 되고 모듈별로 독립적인 개발이 되어야 합니다. 지금 트로스트는 현재 상황에 가장 효율적인 아키텍처를 만들어 쓰고 있을 뿐입니다. 안드로이드 아키텍처는 충분히 리소스가 확보된 어느 시점에 AAC와 코루틴을 적극적으로 사용하고 DI 라이브러리로 객체 주입뿐 아니라 생명주기까지 관리하도록 변화할 것입니다. iOS는 지금도 안드로이드에 비해 비교적 자유롭지만 추후 SwiftUI에 최적화된 아키텍처로 변화할 수도 있죠.
이 아키텍처를 설계하고 적용하는 시기부터 지금까지는 이전에 있던 레거시를 모두 걷어내고 한 번에 아키텍처를 옮겨갈 수 있는 시간이 허락되지 않았습니다. 그렇기에 우선은 레거시 위에 새 아키텍처를 올리고 새로운 기능들을 새 아키텍처로 개발하고 이전 레거시 코드들을 새 아키텍처로 옮기고 있습니다. 그렇다 보니 초기 설계한 프로토타입과 달라지고 또 기존 레거시와 맞추는 과정에서 변형되거나 도저히 답이 안 나오는 한계를 만나기도 했습니다.
이 과정에서 이미 상당 부분이 개선되고 변경되었습니다. 그만큼 유연하게 설계하여 앞으로도 얼마든지 변경될 수 있고 그 가능성은 항상 열려있습니다. 아키텍처의 본질적인 목적과 그것을 위한 원칙만 지켜진다면 그 어떠한 변화도 환영입니다.
주니어 개발자들도 아키텍처를 맹목적으로 따르지 않고 본인의 생각을 적극적으로 이야기하며 아키텍처와 함께 성장하고 있습니다. 그리고 스스로 성장했음을 그 누구들보다 크게 느끼고 있습니다.
그렇기에 트로스트 앱 아키텍처는 멈춰있지 않을 것입니다.
이상으로 현재 트로스트 앱에서 사용하고 있는 아키텍처를 간략히 소개해 보았습니다.
안드로이드와 iOS를 사실상 하나의 코드로 개발한다는 것이 상당히 힘든 일임은 분명하지만 그만큼 또 도전해볼 만한 즐거운 일이었습니다.
비록 아키텍처라는 큰 뼈대를 구축했지만 트로스트는 지금도 개선하고 고쳐야 할 것이 산더미입니다.
큰 뼈대가 준비되었으니 이제 살과 근육을 붙이고 디테일을 살리고 그 위에 멋진 옷도 입혀야겠죠?
다시 말해 아직도 트로스트에는 새로 만들어내고 쌓아나가야 할게 엄청나게 많이 남아있다는 이야기입니다.
물론 이전에 남아있던 언터쳐블 레거시들(e.g. 상담방)도 새 아키텍처로 옮겨야 하구요.
빠르게 성장하는 스타트업에서 주도적이고 에너지 넘치는 구성원들과 같은 방향을 바라보고 달려나가고 때로는 투닥거리며 멋진 프로덕트를 만들고 키우면서 프로덕트와 내가 함께 성장한다는 것은 분명 정말 힘들지만 아주 재밌는 일입니다. (물론 보상도 따라와야죠! 보고 계시죠 동현님?)
혹시나 이 포스트를 읽으면서 재밌을 것 같다는 두근거림이 조금이라도 느껴지셨다면,
그리고 트로스트를 만드는 휴마트컴퍼니에 대해 궁금한 부분이 생기셨다면
아래 링크를 통해 저희를 알아보실 수 있습니다.
프로덕트를 통해 도전해보고 시도하는 재미를 느끼고 싶으시다면 언제든지 환영입니다.
긴 글 읽어주셔서 감사합니다.
또 흥미로운 내용의 포스트로 찾아뵙도록 하겠습니다.