도서/기술

[책] 객체에서 함수로 - 3장 도메인 정의 및 테스트

egg528 2025. 3. 2. 18:05
@Test
fun `List owners can see their lists`() {
    val listName = "shopping"
    startTheApplication("frank", listName, emptyList())

    getToDoList("bob", listName)
}

 2장에서 ToDoList를 HTML 형식으로 반환하는 간단한 WebApplication을 만들었다. ToDoList에 접근하는 동작에는 List의 주인만이 접근할 수 있는 검증 로직이 존재하고 이 내용을 확인하는 간단한 테스트가 위에 작성되어 있다. 이번 장에서는 이 테스트에서 아쉬운 점들을 보완해나가며 더 나은 구조를 만들고 또 DDT에 대해 학습한다.

 

 

 

시나리오 액터 추가하기


interface ScenarioActor {
    val name: String
}

class ToDoListOwner(override val name: String) : ScenarioActor {
		
    fun canSeeTheList(listName: String, items: List<String>) {
        val expectedList = createList(listName, items)
        val list = getToDoList(name, listName)
        expectThat(list).isEqualTo(expectedList)
    }
    
    private fun getToDoList(user: String, listName: String): ToDoList {
        // 테스트에서 액터로 옮겨졌다.
    }
}


// 시나리오 액터 추가 이후 변경된 테스트
@Test
fun `List owners can see their lists`() {
    val listName = "shopping"
    val foodToBuy = listOf("carrots", "apples", "milk")
    val frank = ToDoListOwner("Frank")

    startTheApplication(frank.name, createList(listName, foodToBuy))
    frank.canSeeTheList(listName, foodToBuy)
}

 이전 테스트를 생각해보면 검증하고 싶은 부분은 사용자와 상호작용을 하는 getToDoList이다. 하지만 테스트에는 getToDoList가 사용자의 행동임이 명시되지 않는다. 반면 시나리오 액터를 추가하면 테스트에서 어떤 일이 벌어지고 있는지 조금 더 명확해지는 효과가 있다.

 

 

 

순수 함수로 유지해보기


 추가로 개선할 수 있는 코드를 찾자면 startApplication 메서드를 떠올릴 수 있다. 함수형 프로그램에서 추구하는 순수 함수 관점에서 볼 때 startApplication은 부수 효과에 크게 의존한다. 때문에 테스트 코드에서 사용자의 행동은 startApplication의 부수 효과로 WebApplication이 시작됐을 것이라고 암묵적인 가정을 하게 된다. 이런 암묵적인 가정을 제거하기 위해서는 startApplication에서 App을 반환하고 Actor는 반환된 App을 활용해 행동을 수행해야 한다.

 

 

class ApplicationForAT(val client: HttpHandler, val server: AutoCloseable) {

    override fun getToDoList(user: String, listName: String): ToDoList {

        val response = client(Request(Method.GET, "/todo/$user/$listName"))

        return if (response.status == Status.OK)
            parseResponse(response.bodyString())
        else
            fail(response.toMessage())
    }

    fun runScenario(steps: (ApplicationForAT) -> Unit) {
        server.use {
            steps.onEach { step -> step(this) }
        }
    }
    
    // ... 테스트에 존재하던 여러 private method (parseResponse 등등..)
}


fun startTheApplication(lists: Map<User, List<ToDoList>>): ApplicationForAT {
    val port = 8081 // 메인에서 사용한 포트와 다름
    val server = Zettai(lists).asServer(Jetty(port))
    server.start()

    val client = ClientFilters
        .SetBaseUriFrom(Uri.of("http://localhost:$port/"))
        .then(JettyClient())

    return ApplicationForAT(client, server)
}

 이를 위해 파사드 패턴을 활용한다. 즉, application과 상호작용하는 동작을 단순화한 ApplicationForAT 클래스를 구현하고 이를 통해 Application과 상호작용하도록 만든다.

 

 

@Test
fun `List owners can see their lists`() {
    val app = startTheApplication(lists)
    app.runScenario(
        frank.canSeeTheList("shopping", shoppingItems, it),
        bob.canSeeTheList("gardening", gardenItems, it)
    )
}

 그 결과 startApplication의 부수 효과로 WebApplication이 시작됐을 것이라고 암묵적인 가정은 사라지고 시작된 app과 상호 작용할 수 있는 파사드 객체가 반환된다. 그리고 이 파사드를 활용해 시나리오 액터들은 상호작용을 수행하게 된다.

 

 

 

typealias Step = ApplicationForAT.() -> Unit

fun runScenario(vararg steps: Step) {
    server.use {
        steps.onEach { step -> step(this) }
    }
}

 조금만 더 개선을 해보자면 Kotlin의 Receiver를 활용해 이전 코드의 it을 없앨 수 있다. 위 코드처럼 Step을 ApplicationForAT를 파라미터로 받는 게 아니라 Receiver로 받게 되면 인자로 it을 통해 ApplicationForAT를 명시적으로 넘기지 않아도 canSeeTheList 내부에서 ApplicationForAT를 활용할 수 있다.

 

 

interface Actions {
	fun getToDoList(user: String, listName: String) ToDoList?
}

typealias Step = Actions.() -> Unit


class ApplicationForAT(val client: HttpHandler, val server: AutoCloseable): Actions {
	// 생략
}

@Test
fun `List owners can see their lists`() {
    val app = startTheApplication(lists)
    app.runScenario(
        frank.canSeeTheList("shopping", shoppingItems),
        bob.canSeeTheList("gardening", gardenItems)
    )
}

마지막으로 추상화만 더해주면 테스트 코드와 더불어 구현 코드도 보다 깔끔하게 유지가 가능하다.

 

 

 

인프라에서 도메인 분리하기


 마지막 개선 작업은 순수한 도메인을 분리해내는 작업이다. 이 작업의 목적은 기술적인 요소와 비즈니스적 요소를 분리해 서로 변경의 원인이 되지 않도록 만들기 위함이다. 즉, 기술 관련 코드변경을 위해 비즈니스 코드 변경이 발생하지 않고 또 비즈니스 코드 변경을 위해 기술 관련 변경이 발생하지 않도록 하는 목적이다. 이러한 목적 달성을 위해 포트 앤 어댑터 패턴을 기반으로 한 헥사고날 아키텍처에 대한 이야기도 나오지만 이 얘기를 추가하면 글이 너무 길어질 것 같아 우선은 기술 요소와 비즈니스 요소의 분리 정도로만 정리하고자 한다.

 

 

 책에서는 기술적 요소와 비즈니스 요소를 스포크 허브로 분리한다. 지금까지 만든 로직을 살펴보면 총 4가지 메서드이다. 함수 시그니처만 확인해도 어떤 함수가 허브이고 어떤 함수가 스포크인지 알 수 있다.

 

interface ZettaiHub {
    fun getList(user: User, listName: ListName): ToDoList?
}


class ToDoListHub(val lists: Map<User, List<ToDoList>>) : ZettaiHub {
    override fun getList(user: User, listName: ListName): ToDoList? {
        return lists[user]?.firstOrNull { it.listName == listName }
    }
}


class Zettai(val hub: ZettaiHub): HttpHandler {
    fun fetchListContent(listId: Pair<User, ListName>): ToDoList =
        hub.getList(listId.first, listId.second)
            ?: error("List not found")
            
    // extractListData
    // renderHtml
    // createResponse
}

 이제 기술적인 요소는 Zettai클래스에서 담당하고 비즈니스 요소는 ZettaiHub, 구현체로는 ToDoListHub가 담당하게 된다.

 

 

@Test
fun `List owners can see their lists`() {
    val apps = listOf(
        startTheApplicationDomainOnly(lists),
        startTheApplicationHttp(lists)
    )

    apps.forEach { app ->
        app.runScenario(
            frank.canSeeTheList("shopping", shoppingItems),
            bob.canSeeTheList("gardening", gardenItems)
        )
    }
}

 이렇게 작성된 덕분에 위 테스트 코드처럼 비즈니스 로직만 수행하는 Application과 Http를 활용하는 Application을 만들어 개별적으로 테스트가 가능하다. 이때 두 Application에서 활용하는 Hub(비즈니스 로직)은 동일하다.

 

 지금까지 구현부를 개선함과 동시에 인수 테스트를 개선하는 작업을 진행했다. 저자는 이러한 방식의 DDT를 작성하며 매번 반복적으로 구현해야 하는 부분을 최대한 줄이고자 Pesticide라는 라이브러리를 만들었다고 한다. 이번 장에서 작성된 DDT를 Pesticide를 활용하는 방식으로 변환하는 과정이 있지만 이 내용은 단순히 Pesticide를 활용하는 방법이기 때문에 생략한다.

 

 

 

내 생각


 이번 장을 이해하기까지 많은 시간을 썼다. 아마 생소한 Kotlin 문법과 테스트 구현 방식 때문이 아닐까 싶다. 완성된 테스트 코드를 보면 깔끔함이 느껴지고, 변경된 구조를는 재사용성과 변경에 용이하다는 점에서 개선되었다고 생각한다. 하지만 이와 동시에 구조가 복잡해졌다는 점 또한 사실인 것 같다.