-
NextStep Android Architecture with TDD 1기 - 3 MVVM Architecture with Test안드로이드 2021. 9. 18. 18:20반응형
이 글은 NextStep의 안드로이드 아키텍처 with TDD 교육 과정을 듣고 쓴 후기 시리즈이다.
Effective Kotlin 1기 과정을 수강하고 1년 뒤, 이 과정을 수강할 수 있는 좋은 기회가 있어 참여하게 되었다.
이 과정을 수강하면서 배운 내용을 정리하면서 후기를 남기면, 안드로이드 테스트에 대해 관심 있는 다른 사람들에게도 꽤 쓸만한 글이 될거라 생각되어서 글을 쓰기 시작했다.
1편 링크 - NextStep Android Architecture with TDD 1기 - 1 MVC, Domain, Multi Module Project
2편 링크 - NextStep Android Architecture with TDD 1기 - 2 MVP Architecture with Test
3편 링크 - NextStep Android Architecture with TDD 1기 - 3 MVVM Architecture with Test [지금 보고있는 곳]
피드백 강의
지난 미션에 대한 피드백 강의에서 강사님이 Presenter Test가 굉장히 난해하다고 말씀해주셨을 때 격한 공감을 했다.
나만 느낀 난해함이 아니었구나.. 😌
Presenter Test를 진행하다 보면 이상하게 Unit Test보다는 UI Test를 하는 느낌이 많이 들었을 것이라고 말씀해주셨다. 음.. 매우 격한 공감을 했다. 내가 테스트를 짜면서 이게 UI Test인지, Unit Test인지 구분하기가 좀 어려웠었다.
그럴만도 한게, Presenter가 View의 제어까지 하기 때문이고, 해당 View Event가 호출 되었는 지 테스트 해야하기 때문이라고 생각했다.
Presenter가 View에 대한 의존성을 갖고 있기 때문에 발생한 문제이고, 이러한 것들이 MVP 아키텍처의 단점이라고 새삼스럽게 또 체감했다.
MVVM에 대한 강의가 이어지면서, MVVM에는 Binder라는 개념이 숨겨져 있다는 것을 이번에 처음 알게 되었다. Andrdoid의 DataBinding이 그 Binder의 역할을 하고 있기 때문에 몰랐다. 그래서, Binder 레이어가 없는 아키텍처는 MVVM이라고 부를 수 없다고 한다. 즉, DataBinding을 쓰지 않으면 MVVM 이라고 할 수 없다.
Microsoft 가 제시한 첫 MVVM과 Android MVVM이 다르다 라는 것은 알고 있었지만, Binder라는 개념은 처음이다. 역시 배울 게 산더미이다.
1단계 미션
1단계 미션은 기존 프로젝트에 멀티 모듈 프로젝트 두 개를 만드는 것 뿐이었다.
하지만 나는 이해가 잘 되지 않았다.
app -> domain
data -> domain
의 의존 관계에서, app에서 data의 구현체를 어떻게 안쓸 수가 있지? 라는 생각을 했다.
강사님에게 여러번 여쭈어보고 다른 사람들의 코드를 살펴보니, 이제야 무슨 의미인지 알았다. 정말 항상 부족함을 느낀다😂
아무튼, app이 data에 대한 의존성을 갖지 않을 수는 없다. data에서는 domain의 interface등을 구현한 구현체들을 가지고 있고, 해당 interface를 리턴하는 형태의 로직을 갖게 한다.
그리고 app에서는 data의 구현체 객체를 직접 쓰는 것이 아닌, domain의 interface를 쓰지만 그 안의 구현은 모른채 사용한다는 의미였다.
알면 알수록 정말 재미있고 신기한 객체지향의 세계였다. 이걸 이런식으로 나눌 생각을 하다니!
다음에는 이걸 어떻게 DI에 적용할 수 있을 지에 대해서 고민을 좀 해보았지만, 우선은 미션 진행이 먼저라 생각해서 직접 만들어보지는 않았다.
2단계 미션
위에서 멀티 모듈을 분리하고, data와 domain 모듈에 대한 이해도가 부족해서 계속 이해하느라 미션 진행 속도가 매우 더뎠다. 게다가 이것 저것 만들어보고 싶었던 툴들이 있어서 시간은 더 많이 지나갔다..
LifeCycledValue
Activity에서 onDestroy()가 호출되면 같이 null이 되는 필드 값을 구현할 때, 일일이 null 할당하고 체크하는 게 너무 귀찮았는데, ReadOnlyProperty라는 코틀린 API를 이용해서 구현해보았다.
private val memosAdapter: MemosAdapter by lifeCycled { MemosAdapter() }
위 처럼 사용하게 되면, lifecycle state가 Destroyed가 아닐 때, 처음 참조하면 중괄호의 initializer를 통해 생성하고 값을 할당한다. 내부적으로는 nullable 하지만 현재 필드 값은 non-null의 형태이기 때문에 일일이 null check를 하지 않고 사용이 가능하다.
onDestroy()가 호출 되면, 자동으로 내부 값은 null로 할당되며, 이 때에 필드 값을 참조하면 exception이 발생하도록 로직을 짜두었다.
이런걸 만드니 시간이 없어서 미션 진행이 더뎌졌다...ㅋㅋㅋㅋㅋ
Google의 AutoClearedValue와 Kotlin의 lazy 함수에서 영감을 받아 참고해서 만들어보았다.
해당 코드는 아래 링크에서 볼 수 있다.
3단계 미션
3단계 미션은 메모의 수정, 삭제를 할 수 있는 기능을 추가 하는 것이다.
2단계에서 굉장히 많은 시도를 하는 바람에 (Memo Cache, LocalSource 분리, LifeCycledValue, 등등....)
이번 미션에서는 굉장히 빠르게 테스트, 프로덕션 코드를 작성하게 되었다. 역시 아무 것도 없는 곳에서 다 만들어내는 것 보다는 뭔가 추가하는 게 더 시간이 빠르구나.. 라는 것을 느꼈다.
Test Mocking Library
mockk() 라이브러리를 쓰지 않고 습관대로 인터페이스를 직접 구현해서 테스트를 구현했었다. 요 부분에 대해서 mock 라이브러리를 사용하면 좋을 것 같다는 피드백을 받아 모두 mockk()을 사용해서 리팩터링을 마쳤다.
@Test fun `특정 메모를 캐시에서 가져오지 못했다면, 다음 모든 메모는 source에서 가져온다`() { // given var getMemoCallCount = 0 var getAllMemoCallCount = 0 val memoRepository = MemosRepository(object : MemosSource { override fun save(memo: Memo) { /*Nothing*/ } override fun getAllMemos(): List<Memo> { getAllMemoCallCount++ return emptyList() } override fun getMemo(id: String): Memo { getMemoCallCount++ return Memo("title", "content", "1") } }) // when memoRepository.getMemo("1") // then assertThat(getMemoCallCount).isEqualTo(1) // when memoRepository.getAllMemos() // then assertThat(getAllMemoCallCount).isEqualTo(1) }
뭐 대충 위와 같은 테스트 로직이 존재했었는데, 직접 interface의 구현체를 구현한 후, 특정 메서드가 몇 번 호출되었는지 직접 카운팅 하려니 테스트 코드가 매우 장황해지는 것을 볼 수 있다.
class MemosRepositoryTest { lateinit var memosRepository: MemosRepository lateinit var memosLocalSource: MemosSource @BeforeEach fun setUp() { memosLocalSource = mockk(relaxed = true) memosRepository = MemosRepository(memosLocalSource) } @Test fun `특정 메모를 캐시에서 가져오지 못했다면, 다음 모든 메모는 source에서 가져온다`() { // when memosRepository.getMemo("1") // then verify(exactly = 1) { memosLocalSource.getMemo(any()) } // when memosRepository.getAllMemos() // then verify(exactly = 1) { memosLocalSource.getAllMemos() } } }
놀랍게도 방금 전 테스트와 같은 테스트를 진행하는 테스트 코드이다.
매우 코드량이 줄어드는 것을 볼 수 있고, 컴팩트 하게 딱 무엇을 테스트하고 싶은지 알기 좋다.
직접 구현체를 만들어주는 것 보다 mockk()를 사용하는 것이 이렇게나 용이하다.. 이것을 직접 체감했으니, 이제 앞으로는 mocking 라이브러리 없이는 테스트코드를 짤 수 없는 몸이 되어버렸다 😅
4단계 미션
4단계 미션은 data module의 구현체들을 모두 internal로 변경하고, app module에서 data module의 구현체를 직접 사용하지 않게 바꾸는 미션이었다.
1단계 미션에서 이 4단계 요구사항을 먼저 보고 이해가 잘 안되었지만, 이것에 대해서 강사님에게 많이 여쭈어보고, 다른 사람들의 코드를 참고하면서 결국 어떤 의미인지 알게 되었다.
data 모듈에서는 자신의 구현체를 외부에 알리지 않고, domain 모듈의 interface를 리턴하면 된다.
이렇게 도메인을 멀티 모듈로 나누고, 구현체를 외부에 알리지 않는 방법은 완전히 처음이라 헤맸지만, 이젠 완벽하게 파악하고 구현하는데에는 전혀 문제가 없었다.
이런식으로 domain에는 repository의 기능만을 담은 interface만.
data에는 해당 interface를 구현한 구현체가 존재하지만, 외부에는 MemosRepository를 반환하므로써 RealMemosRepository존재를 알리지 않는다.
그렇다면 이 Repository는 어떻게 외부에서 구현체를 받을 수 있는가?
여러가지 방법이 있겠지만, 아래 코드처럼 Injector를 이용해서 외부에서 MemosRepository의 구현체를 주입 받을 수 있게 할 수 있다.
object MemosRepositoryInjector { private var instance: MemosRepository? = null @JvmStatic fun provideMemosRepository(): MemosRepository = synchronized(this) { instance ?: RealMemosRepository(MemosLocalSource()) .also { this.instance = it } } }
이렇게 하면 data module의 구현체는 가리고 domain로직의 Repository만 사용할 수 있게 만들 수 있다.
마무리
멀티 모듈 프로젝트에 대해서 관심을 가지고 있었고, 조금씩 해보다가 이번 기회에 멀티 모듈 프로젝트를 왜 구성하는 게 좋은지, 어떤 식으로 구성하면 좋은지에 대해서 이해할 수 있는 단계였다.
이걸 배우니 최근에 하던 모든 프로젝트를 멀티 모듈로 리팩터링하고 싶어졌지만... 이것 저것 하느라 시간이 부족한 게 너무나 안타까울 뿐이다. 틈틈이 시간이 나는대로 멀티모듈로 구성해야겠다.
그리고, 이것 저것 만들고 싶은게 많아서 캐시며 데이터 소스며 막 구현했다가, 오버엔지니어링 일 수 있다는 피드백을 받았다. 인정한다.. 메모 따위에 캐시를 적용하고 테스트코드를 만들면서도 "아.. 좀 과한 것 같다..." 라는 생각이 들 정도였으니.
그래도 연습하는 과정이니 괜찮다고 보고, 다음 미션에는 군더더기 없이 딱 요구사항에 맞춰서 만들어보려 한다.
이제 다음은 MVVM + Hilt + Remote Source으로 구성된 의존성 주입 편이다.
이 글이 도움이 되셨나요?
말리빈에게 커피를 한 잔 쥐어 주세요! ☕ 커피를 인풋으로 더 좋은 글을 아웃풋으로 쓰려 노력하겠습니다 😊
반응형'안드로이드' 카테고리의 다른 글