안녕하세요. 강남언니 안드로이드 앱을 개발하고 있는 Jake입니다.

이번 글에서는 강남언니 제품개발 챕터 내에서 논의되고 있는 TDDDD (Test Driven Domain Driven Design)에 대해 정리해보려 합니다.

TDDDD?

TDDDD를 논하기 위해서는 우선 TDD(Test Driven Development)DDD(Domain Driven Design)에 대한 사전 지식이 요구됩니다. 이에 관련해서는 잘 정리된 글들이 있어 링크를 남깁니다. 물론 해당 이론들에 대해 정확히 파악하기 위해서는 TDD 이론의 창시자인 켄트 벡의 저서DDD 이론의 창시자인 에릭 에반스의 저서를 읽는 것이 좋습니다.

먼저 저희가 말하고 있는 TDDDD 란 무엇일까요?

단순히 생각하면 TDD와 DDD를 합친 것입니다. 요구사항을 검증하는 테스트를 먼저 작성하고 그 후에 테스트를 통과시키기 위한 도메인 로직을 작성하는 것입니다. 굳이 두 가지의 방법론을 합치려고 생각했던 이유는 다음과 같습니다.

어차피 프리젠테이션 로직은 변동성이 크니 지속적인 변화에 맞추어 테스트 코드를 작성하는 것은 비효율적이다. 따라서 변동성이 그나마 적은 도메인만 테스트해도 좋을 것이다.

실제로 제품을 만들다 보면 프리젠테이션 로직은 변화에 민감하다는 것을 깨닫게 됩니다. 기획자나 디자이너 분들의 요청에 따라 뷰들이 달라지기 쉽고 뷰들이 달라지면 그에 맞는 로직을 수정하고 테스트 코드도 다시 짜야하는 문제에 직면하고는 합니다. 하지만 도메인 로직은 프리젠테이션 로직과 분리될 수 있으므로 이러한 변화에서 보다 자유롭습니다. 그래서 저희는 도메인 로직을 프리젠테이션 로직으로부터 분리시키면서 테스트 코드를 먼저 작성하고 후에 도메인 로직을 작성하는 TDDDD를 생각하게 되었습니다.

도메인 로직의 분리

TDDDD를 시작하면서 저희가 가장 처음 시도해보고 싶었던 것은 도메인 로직의 깔끔한 분리였습니다. 도메인 로직은 어떤 도메인이 풀고자 하는 문제를 도메인 모델으로 구현하는 과정에서 도메인 모델이 가지고 있어야 할 순수한 비즈니스 로직이라고 생각했습니다. 따라서 도메인 로직은 플랫폼에 종속되지 않아야 한다고 생각했습니다. 이에 저희는 도메인 로직을 따로 분리하여 안드로이드 디펜던시가 들어가지 않은 순수한 자바 모듈로 만들었습니다. 이에 따라 자연스럽게 프리젠테이션 로직과 도메인 로직, 인프라스트럭처 로직이 모듈로 분리되었고 전체적인 아키텍처는 레이어드 아키텍처로 가져갈 수 있게 되었습니다. 테스트 용이성 또한 자연스럽게 높아지게 되었습니다.

이렇게 레이어를 나누고 보니 각 레이어의 역할이 매우 명확해지는 효과를 확인할 수 있었습니다. 인프라 레이어의 경우 도메인 레이어에서 필요한 데이터를 외부 API를 통해서든 로컬 데이터베이스든 어떤 방법을 이용해서든지 전달해주는 역할만 하면 되고 도메인 레이어는 인프라 레이어에서 받아온 데이터를 가공하여 프리젠테이션 레이어에만 전달해주면 됩니다. 이렇게 역할의 분리가 너무나도 명확하게 이루어질 수 있었습니다. 심지어 사용하는 라이브러리 마저 명확하게 구분이 되니 각 레이어가 맡은 역할을 한 눈에 알아볼 수 있는 효과를 얻을 수 있었습니다.

도메인 레이어

수다방은 강남언니에서 사용자 분들이 자유롭게 성형에 대한 이야기를 나눌 수 있는 커뮤니티 공간입니다.
도메인 레이어를 따로 분리했으니 이제 실제로 도메인이 해결하고자 하는 문제를 도메인 모델로 구현할 단계입니다. 단순하게 인프라 레이어로 부터 수다방 글을 불러오는 로직을 구현해 보기로 했습니다. 우선 수다방 글을 구현하고 있는 도메인 모델인 PostEntity를 정의하고 내부에 PostCategory 등의 밸류 타입을 정의했습니다. PostEntity가 수다방 글에 대한 전체적인 로직의 책임을 가지고 있도록 구현할 것이기 때문에 PostEntity가 수다방 글 애그리거트에 대한 애그리거트 루트가 됩니다. 추후에 좋아요나 번역하기 등의 기능들은 PostEntity를 통해서만 구현될 것입니다.

    data class PostEntity(
      val postId: PostId,
      val writer: Writer,
      val content: String,
      val thumbnail: String,
      val category: PostCategory,
    	// ...
    )

개별적인 애그리거트는 애그리거트 루트인 PostEntity를 통해서 구현될 수 있지만 현재 저희가 다뤄야 할 부분은 PostEntity의 리스트였습니다. 해당 PostEntity의 리스트와 관련 로직을 담고 있는 객체가 필요했습니다. 내부적으로도 그 객체를 어떻게 표현할 지에 대한 많은 논의가 이루어졌습니다.

왼쪽 그림처럼 PostEntity의 리스트를 도메인 레이어에 두고 리스트와 관련 로직을 Facade에 담아서 프리젠테이션 레이어에 제공하자는 의견과 오른쪽 그림처럼 응용(Application) 레이어로 나눠서 Service에 담자는 의견이 있었습니다. 여기서 저희가 논하고 있는 Facede는 CommunityPostList 처럼 PostEntity와 같은 서브 시스템을 두고 서브 시스템의 기능을 고수준의 인터페이스에서 통합하여 활용하는 객체입니다.

최종적으로 선택한 방법은 리스트와 관련 로직을 Facade에 담아서 처리하는 것이었습니다. 응용 레이어에 담을 수도 있겠지만 응용 레이어가 하는 역할에 집중해보기로 했습니다. 응용 레이어는 프리젠테이션 레이어와 도메인 레이어를 연결해주는 역할을 수행해야 합니다. PostEntity의 리스트를 응용 레이어에 담아 사용하다 보면 리스트와 관련된 도메인 로직이 응용 레이어에 노출될 위험도 있고, 미래에 응용 레이어에서 여러 도메인 모델을 가져다 조합하여 사용할 가능성도 있기 때문에 PostEntity의 리스트를 응용 레이어에서 처리하기 보다는 도메인 레이어에 두고 처리하는게 맞다고 생각했습니다.

그래서 저희는 CommunityPostList 라는 Facade를 하나 두고 해당 객체 내부에서 수다방 글 리스트에 대한 로직을 처리하기로 했습니다.

    class CommunityPostList(private val postRepository: PostRepository) {
        private val posts = arrayListOf<PostEntity>()

        fun refresh(communityPostListRequest: CommunityPostListRequest) {
            // ...
        }

    		// ...
    }

읽다 보니 잊어버리셨을 수도 있으시겠지만 저희가 시도했던 것은 TDDDD 입니다. 그래서 바로 로직을 적어볼 수도 있었지만 TDD에 맞도록 Test Case를 먼저 작성한 후에 테스트를 진행해보고 테스트를 통과시키기 위한 도메인 로직을 작성해보았습니다.

TDDDD 구현

현재 도메인 로직은 완전한 자바 모듈로 테스트 용이성이 매우 높습니다. 즐거운 마음으로 테스트 작성을 시작해보았습니다. 처음에는 단순히 JUnit5를 이용해서 유닛 테스트를 작성해보고자 했습니다. 그런데 저희 회사의 훌륭하신 iOS 개발자님께서 BDD (Behaviour Driven Development)로 테스트를 개발하는 것을 엿보게 되었습니다. 여기서 BDD란 시나리오를 기반으로 테스트 케이스를 작성하여 테스트 케이스 자체가 스펙이 되도록 개발하는 방식입니다. 일반적인 유닛 테스트가 아닌 BDD 방식으로 개발을 했을 때, 어떤 함수를 통해 기대되는 결과나 테스트의 의도가 매우 명확하게 보였습니다. 심지어 테스트를 실제로 돌려보았을 때 스펙이 자동으로 예쁘게 정리되어 보이기 때문에 테스트를 잘 작성하게 되면 문서화 단계에서도 많은 도움을 받을 수 있을 것으로 생각했습니다.

Kotest

Kotlintest 였다가 4.0 버전이 릴리즈 되면서 Kotest로 변신한 Kotlintest, Jetbrains에서 제공하는 kotlin.test 패키지와 헷갈릴까봐 이름을 바꿨다고 합니다.
조사를 좀 해보니 Spock이나 Spek과 같이 자바나 코틀린을 위한 BDD 프레임워크가 많이 개발되어 있었습니다. 그 중에서도 저희가 사용해 보기로 한 프레임워크는 Kotest 입니다. Kotest를 선택한 주요 이유는 Kotlin으로 쓰여있다는 점이었습니다. 프로젝트의 코드를 모두 Kotlin으로 옮기고 있는 지금, 순수 Kotlin 라이브러리를 사용해보고 싶었습니다. 참고로 Spock은 Groovy, Spek은 Kotlin, Kotest도 Kotlin으로 작성되었습니다. Spek을 사용하지 않은 이유는 안드로이드 스튜디오에서 따로 Spek 실행 전용 plugin을 설치해주어야 한다는 점이 마음에 들지 않았고 추가적으로 오직 BDD 형태의 테스트만 지원을 하기 때문에 확장성이 부족하다고 느껴서 입니다. Kotest를 사용하게 되면 BDD 형태의 테스트 부터 Property-based Testing, Data-driven Testing 과 같은 테스트도 진행해 볼 수 있습니다.
아래 코드는 Kotest를 이용해 BDD 방식의 테스트 케이스를 작성한 것입니다.

    class CommunityPostListSpec : BehaviorSpec({
        val postRepository = PostListRepositoryTest(CommunityJsonStub.listNormalSize20(), CommunityJsonStub.listHotSize20(), CommunityJsonStub.listNoCommentSize20())
        val communityPostList = CommunityPostList(postRepository)
        val expectedContent = "Expected Content"

        Given("처음 수다방 진입 시 sortQuery 를 CREATE_TIME 으로 가지고 있다.") {
            val pagingModel = PagingModel()
            pagingModel.setSort(SortType.CREATE_TIME.sortQuery)
            When("수다방 화면 진입 시") {
                communityPostList.refresh(CommunityPostListRequest(paging = pagingModel.toMap()))
                Then("첫 번째 아이템의 content 가 $expectedContent") {
                    communityPostList.posts[0].content.shouldBe("$expectedContent")
                }
            }
        }

    		// ...
    })

Test를 위해 Repository를 만들어 두었고 해당 Repository에 미리 Json Stub을 저장해 두었습니다.

테스트를 돌려보면 당연히 구현해 놓은 코드가 없기에 실패합니다. 위의 테스트가 실패한 이유는 사실 단순합니다. communityPostList의 posts가 비어있는 리스트이기 때문입니다.

테스트 성공을 위해 요구사항에 맞도록 도메인 로직을 구현해 줍니다.

    class CommunityPostList(private val postRepository: PostRepository) {
        private val posts = arrayListOf<PostEntity>()

        fun refresh(communityPostListRequest: CommunityPostListRequest) {
            if (communityPostListRequest.paging["sort"] != SortType.CREATE_TIME.sortQuery ||
                communityPostListRequest.paging.getValue("start").toInt() < 0 ||
                !communityPostListRequest.isFilterValid()) return
            postRepository.getPosts(communityPostListRequest, {
                this.posts.clear()
                this.posts.addAll(it)
            }, {
                it.stackTrace
            })
        }

    		// ...
    }

그리고 다시 테스트를 돌려봅니다.

도메인 로직이 PostEntity를 가진 리스트를 제공하기 때문에 테스트가 성공한 모습을 확인할 수 있습니다.

이렇게 도메인 로직을 위한 테스트를 먼저 작성하고 실행시켜 본 후 실패한 테스트 케이스를 기반으로 도메인 로직을 작성하게 되는 과정을 지속적으로 반복하면서 코드를 작성하게 되면 TDDDD는 어느 정도 성공적으로 진행될 수 있을 것으로 보입니다.

후기

결론은 일단

안드로이드에서 TDDDD는 가능은 하다.

입니다.

후에 리팩토링을 하더라도 자신있게 코드를 작성할 수 있도록 테스트를 작성하면서 동시에 프리젠테이션 로직, 도메인 로직, 데이터 로직을 자연스럽고 깔끔하게 분리할 수 있는 경험을 해볼 수 있었습니다. 각각의 로직들이 잘 분리될 수 있기 때문에 유지보수하기가 더 쉬워지는 효과를 볼 수 있었습니다.

하지만 생각했던 TDDDD가 뭔가 마음에 쏙 들게 구현되지는 않은 것 같습니다. 여전히 도메인 로직을 어떻게 더 깔끔하게 분리하고 구현해낼 것인가에 대한 고민이나 도메인 레이어만 테스트를 하는 것이 과연 옳은가 (저는 개인적으로 프리젠테이션 레이어, 인프라 레이어도 가능하면 테스트해야 맞다고 생각합니다), 이렇게 하면 클린 아키텍처와 크게 다른 점이 뭘까 등의 의문점이 많이 남아있기 때문입니다. 이번 포스팅은 구체적인 코드보다는 커다란 방법론에 대해서 논의가 된 것 같습니다. 미래에 더 효율적이고 깔끔하게 DDD를 안드로이드에 적용하는 과정이나 BDD 기반의 TDD를 해보는 과정 등을 연구하여 포스팅 해보도록 하겠습니다.

감사합니다.

Jake
Android Developer
안드로이드 개발자입니다.