1주차 미션 1~5까지 모두 완료했다. 미션을 진행하면서 새롭게 배운 바와 느낀 점을 회고해본다.

코드 리뷰를 해주신 자바지기님께 리뷰 내용을 올려도 되는지 여쭤봤는데, 부담없이 공유해도 된다 해주셔서 코드 리뷰받은 내용도 같이 정리해봤다. ☺️

1. 문자열 계산기

  • 미션 요구사항

    • 사용자가 입력한 문자열 값에 따라 사칙 연산을 수행할 수 있는 계산기를 구현한다
    • 문자열 계산기는 사칙 연산의 계산 우선순위가 아닌 입력 값에 따라 계산 순서가 결정된다.
    • ex) "2 + 3 * 4 / 2" → 2 + 3 * 4 / 2 실행 결과인 10을 출력

code style check는 사람이 하지 말자

새로운 미션을 시작할 때마다 ktlint 설정을 하자. 관련하여 블로그에 따로 정리했다. (링크)

Gradle Kotlin DSL을 사용해보자

Groovy 대신 Kotlin DSL을 사용하면 IDE로부터 코드 자동완성, 오류 코드 강조 등의 지원을 받을 수 있다.

assertEquals 대신 assertThat

assertEquals()보다 assertThat().{값을 검증하는 메서드}가 인자의 의미를 파악하기 용이하다. 관련하여 블로그에 따로 정리했다. (링크)

// assertEquals를 사용했을 때
assertEquals(8.5, Calculator.execute(ExpressionParser.parse(" 3 + 4 / 2 + 5 ")))
assertEquals(listOf("-3.5", "+", "2.2"), ExpressionParser.parse("-3.5 + 2.2"))

// assertThat을 사용했을 때
assertThat(Calculator.execute(ExpressionParser.parse(" 3 + 4 / 2 + 5 "))).isEqualTo(8.5)
assertThat(ExpressionParser.parse("-3.5 + 2.2")).isEqualTo(listOf("-3.5", "+", "2.2"))

테스트 메서드 이름에 test라는 키워드를 쓰지 말자

@Test라는 어노테이션으로 해당 메서드가 이미 test라는 사실이 이미 드러나고 있다. testXXX() 형식의 이름은 중복에 해당될 수 있다.

2. 자동차 경주 게임

  • 미션 요구사항

    • 초간단 자동차 경주 게임을 구현한다.

  • 프로그래밍 요구사항

    • 모든 로직에 단위 테스트를 구현한다. 단, UI 로직은 제외

    • indent depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다.

    • 함수(또는 메소드)의 길이가 15라인을 넘어가지 않도록 구현한다.

    • 기능을 구현하기 전에 README.md 파일에 구현할 기능 목록을 정리해 추가한다.

    • git의 commit 단위는 앞 단계에서 README.md 파일에 정리한 기능 목록 단위로 추가한다.

    • 테스트 가능한 부분과 테스트하기 힘든 부분을 분리해 테스트 가능한 부분에 대해서만 단위 테스트를 진행한다.

    • 핵심 비지니스 로직을 가지는 객체를 domain 패키지, UI 관련한 객체를 view 패키지에 구현한다.

하나의 메서드는 한 가지 일만 해야 한다, 객체에 메시지를 보내자

문제의 코드 👀

findWinners()는 게임 참가자 중에서 우승자를 찾는 일을 한다.

data class Participants(
    private val carNames: String,
    private val movingStrategy: MovingStrategy
) {
    // ...

    fun findWinner(): List<Car> {
        val maxPosition = participants.maxBy { it.position }?.position ?: return emptyList()
        return participants.filter { it.position == maxPosition }
    }

    // ...
}
  • 이 함수는 우승자를 찾는 것 외에도, 가장 긴 이동 거리(maxPosition)를 찾는 일을 추가로 하고 있다. 하나의 메서드가 하나의 일만 잘하도록 분리하자.
  • getter, setter의 사용은 캡슐화를 실현하기 위해 지양하는 것이 좋다. Car를 비교하는 로직은 Participants가 아니라, Car가 직접 수행해야 한다. 상태를 가지는 객체의 데이터를 직접 꺼내는 대신 객체에 메세지를 보내자.

개선 방안 🧐

data class Car(
    private val id: Int,
    private val name: String,
    private val movingStrategy: MovingStrategy
) : Comparable<Car> {
    var position = 0
        private set

    // ...

    fun isWinner(maxPosition: Int): Boolean = position >= maxPosition

    override fun compareTo(other: Car): Int = position.compareTo(other.position)

    // ...
}

먼저 position을 기준으로 Car 객체를 비교할 수 있도록 Car를 Comparable의 구현체로 만들었다.

data class Participants(
    private val carNames: String,
    private val movingStrategy: MovingStrategy
) {
    // ...

    fun findWinner(): List<Car> = participants.filter { it.isWinner(findMaxPosition()) }

    private fun findMaxPosition(): Int = participants.max()?.position ?: 0

    // ...
}

Car가 우승이 가능한지 판단하는 일은 Car에게 맡기자. 객체에게 메세지를 보내는 방식을 사용하니 아래와 같은 이점이 생겼다.

  • 우승자를 찾는 로직을 다른 곳에서 재사용할 수 있다.
  • it.position == maxPosition보다 isWinner()가 읽는 사람 입장에서 코드의 의미가 더욱 와 닿는다.

핵심 로직과 View 관련 로직은 완전히 분리하자

문제의 코드 👀

class RacingGame(
    private val carCount: Int,
    private val tryCount: Int,
    private val movingStrategy: MovingStrategy
) {
    private val participants = Participants(carCount, movingStrategy)

    fun start() {
        for (i in 0 until tryCount) {
            participants.processRound() // 게임 라운드 진행(핵심 로직)
            ResultView.printParticipantsPath(participants) // 콘솔창에 진행 결과 출력(UI 로직)
        }
    }
}

하나의 객체에 이 두 가지 로직이 같이 구현될 경우 SRP(단일 책임 원칙)을 어기게 된다.

  • 클래스는 단 한 개의 책임을 가져야 한다.
  • 클래스가 변경되는 이유는 단 한 개여야 한다.

이 경우 다음과 같은 문제가 생긴다.

  • 핵심 로직을 구현한 코드를 재사용하기가 어렵다.
    • 만약 콘솔창 대신 GUI를 쓰도록 요구사항이 수정된다면 지금과 같은 코드로는 골치아파질 것이다.
  • View에 종속되는 부분이 있으면 테스트가 어렵다.

개선 방안 🧐

콘솔창 입/출력과 관련된 함수는 오로지 main() 안에서만 호출 가능하도록 수정하였다.

fun main() {
    // ...
    while (gameHost.isProgress()) {
        val movingStatus = gameHost.startRound()
        ResultView.printMovingStatus(movingStatus)
    }

    // ...
}

그러면 핵심 로직에 해당되는 RacingGame은 View의 존재를 전혀 모르게 된다.

class RacingGame(
    private val carNames: String,
    private var tryCount: Int,
    private val movingStrategy: MovingStrategy
) {
    private val participants = Participants(carNames, movingStrategy)

    fun startRound(): String {
        if (isProgress()) {
            participants.processRound()
            tryCount--
        }

        return participants.getMovingStatus()
    }

    fun isProgress() = tryCount > 0
}

TODO 리스트를 먼저 작성하자

TDD를 시작하기 위해서는 테스트할 기능 목록이 필요하다. TODO 리스트에 테스트할 것을 적는 것부터 시작하자. 처음부터 너무 완벽하게 적으려 할 필요는 없다.

테스트할 대상을 찾기
  1. 기능 요구 사항을 분석해서 객체를 추출한다.
  2. View, DB 등과의 의존 관계없이 핵심 도메인 영역을 집중적으로 설계한다.
  3. 일차적으로 도메인 로직을 테스트하는 것에 집중해야 한다.

테스트가 어려운 Random

자동차 경주 미션에서 자동차의 움직임 여부는 Random 값에 의해 결정된다. 결과를 예측할 수 없는 로직이 있다면 어떻게 테스트를 해야 하나?라는 의문이 필연적으로 생기게 된다. 지금 프로그램은 Main → RacingGame → Participants → Car 순으로 의존 관계가 있기 때문에, Car의 테스트가 불가능하다면 나머지도 줄줄이 테스트할 수 없는 불확실한 코드가 되어버린다.

 

나 같은 경우 약간의 꼼수를 써서, 자동차의 전진 여부를 결정하는 로직은 Strategy 패턴으로 따로 분리했다. 그리고 무조건 차를 움직이게 만드는 FairMovingStrategy라는 테스트용 로직을 따로 만들었다.

object FairMovingStrategy : MovingStrategy {
    override val isMovable = true
}

어제자 강의의 전체 피드백 시간에 random, shuffle, System.now()처럼 테스트가 어려운 로직에 대응하는 방법을 들었다. 불확실성을 줄이기 위한 방법은 확실한 것과, 불확실한 것을 나누는 것이다. 테스트가 어려운 코드의 의존 관계를 객체 그래프의 상위로 이동시켜야 한다. 즉, Car에서 Random과 관련된 로직을 완전히 걷어내야 한다.

class Car(id: Int, name: String, position: Int = 0) {
    var position = position
        private set

    // ...
}

이런 식으로 position을 포함해서 객체에 필요한 모든 프로퍼티를 외부에서 주입받을 수 있게 만들자. position이 바깥에서 어떻게 지지고 볶아서 만들어진 건지 Car는 관심이 없다. 그러면 상태 값에 변화를 주는 로직(move)을 선행할 필요 없이 Car를 테스트할 수 있게 된다.

 


이번 주 미션 소스코드

 

zion830/kotlin-racingcar

Contribute to zion830/kotlin-racingcar development by creating an account on GitHub.

github.com

2주차 부터는 본격적으로 TDD 적용이 시작된다. 구현 → 테스트 작성이 아니라 테스트 작성 → 구현 순으로 코드를 짠다는 게 어떤 건지 아직은 정확히 감이 안 온다. 이 부분은 다음 미션을 하면서 차근차근 적응해봐야겠다.