우아한 테크 코스/프리코스

[우테코 6기 - 안드로이드] 프리코스 3주차 로또 회고와 냄새나는 내 코드

sh1mj1 2023. 11. 28. 11:04

3주차 미션은 로또 게임이다. 요구사항들은 아래 링크에서 확인할 수 있다.

https://github.com/woowacourse-precourse/kotlin-lotto-6

 

GitHub - woowacourse-precourse/kotlin-lotto-6: 로또 미션을 진행하는 저장소

로또 미션을 진행하는 저장소. Contribute to woowacourse-precourse/kotlin-lotto-6 development by creating an account on GitHub.

github.com

 

 

이번 미션에서는 아래와 같은 요구사항이 추가되었다.

  • 함수 또는 메서드의 길이가 15라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다.
  • `else` 를 지양한다.
    • `if` 조건절에서 값을 `return` 하는 방식으로 구현하면 `else` 를 사용하지 않아도 된다.
    • 때로는 `if/else`, `when` 문을 사용하는 것이 더 깔끔해보일 수 있다. 어느 경우에 쓰는 것이 적절한지 고민하자.
  • `Enum` 클래스를 적용해 프로그래밍을 구현한다.
  • 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
    • 핵심 로직을 구현하는 코드와 UI 를 담당하는 로직을 분리해 구현한다.

 

그 외에 `Lotto` 라는 클래스가 제공되었고, 이 클래스의 필드를 변경하지 않아야 했다.

class Lotto(private val numbers: List<Int>) {
    init {
        require(numbers.size == 6)
    }

    // TODO: 추가 기능 구현
}

 

이전 주차 미션 자동차 경주 게임을 진행했던 것처럼 기능 목록을 먼저 적고, 간단한 UML 을 그렸다. 그 UML 의 내용대로, 뼈대 코드를 모두 만들었다. UML 을 그릴 때부터 왼쪽에는 View, 가운데에 Controller, 오른쪽에는 Model 을 그렸기 때문에 UI 로직과 다른 도메인 로직을 구분하기가 쉬웠다.

 

코딩 전, 가장 처음 설계

 

 

TDD(Test-Driven-Development)

TDD 사이클

간단한 뼈대 코드를 만든 후, TDD 에 대해서도 공부해보았다. TDD 는 Test-Driven-Development, 테스트 주도 개발이다. 말 그대로 개발을 테스트가 주도하는 개발 방법론이다.

프리코스 커뮤니티에서 이 TDD 에 대해서 1주차부터 많은 이야기가 오갔다.  하지만 나는 TDD 는 커녕 test code 를 작성하는 것에도 익숙하지 않았다. 하지만 TDD 에 대해 공부를 하고, 이 미션을 TDD 를 적용해서 구현을 해나간다면 익숙치 않은 만큼 더 많은 것들을 익힐 수 있을 것이라고 생각했다.

 

TDD 는 작은 단위의 테스트 케이스를 작성하고, 이를 통과하는 코드를 추가하는 단계를 반복하여 구현하는 것이다. 구현보다 테스트 코드를 먼저 작성하기 때문에, "실패하는 테스트를 먼저 작성하고, 구현한다" 라고도 한다.

TDD 는 짧은 개발 주기의 반복에 의존하는 개발 프로세스이고, 그 때문에 애자일 방법론과도 접점이 있다.

 

TDD 는 TDD 사이클에서 볼 수 있는 것처럼 이 과정을 통해 만들면 된다.

  1. 실패하는 테스트 코드 작성
  2. 실패하는 테스트를 통과할 정도의 최소 프로덕션 코드를 작성
  3. 테스트와 프로덕션 코드를 리팩토링

TDD 도입의 장점

  1. 높은 안정성
    • 테스트를 먼저 작성한 후, 비즈니스 로직을 작성하므로 테스트되지 않는 코드가 없어진다. 
      즉, 시스템의 모든 코드가 테스트되어 버그 발생의 가능성이 줄어든다.
  2. 재설계 시간의 단축
    • 테스트부터 수행해야 하므로, 기능을 최대한 작게 나누게 된다. 결국, 시스템의 디자인이 'Simple' 해지고, 그렇다면 재설계가 간단해진다.
  3. 디버깅 시간의 단축
    • 잘 짜여진 테스트라면, 프로그램에 변경이 생길 때 이슈가 발생하는 것을 쉽게 알아챌 수 있다. 
      또, 변경 작업의 두려움이 줄어들어서 개발 과정의 유연성이 높아진다.
  4. 구현보다 인터페이스에 먼저 집중하게 한다
    • 인터페이스는 "무엇을 하는지"를 나타내는 것이고, 구현은 "어떻게 하는지"를 나타내는 것이다. 테스트를 먼저 작성하면 "무엇을 하는지"에 먼저 집중할 수 밖에 없다.
  5. 좋은 문서로서 작용
    • 테스트 코드는 시스템의 사용 설명서 혹은 API 문서로서 동작할 수 있다.

 

TDD 도입의 단점

  1. 생산성의 저하
    • 처음부터 테스트 코드와 프로덕션 코드를 작성해야 하기 때문에 거의 2배의 코드를 작성하고, 중간중간 테스트를 하면서 고쳐나가야 한다. 물론 시간이 2배가 들지는 않는다. 어느정도 숙련된 개발자의 입장에서 10~30% 정도가 든다고 한다.
  2. 숙련도 이슈
    • 테스트를 먼저 작성하는 작업 프로세스 자체에 익숙하지 않은 사람에게는 작업 시간이 훨씬 더 많이 늘어나고, 품질도 오히려 떨어질 수 있다. 이는 사실, TDD 방법론 자체의 문제라기 보다는 TDD 에 익숙하지 않거나, 잘못 사용하는 개발자들의 문제이다.
  3. 초기 설계에 얽매이게 된다.
    • 프로젝트를 진행하면서 첫 설계 시 예측하지 못한 상황을 마주칠 수 있다. 전체적인 구조를 수정하자니 애매하고, 또 기능 구현을 위해 너무나 더러운 코드를 만들게 되는 것도 애매하다. (물론, TDD 원칙에 따르면, 테스트 코드 작성 이후 기능 구현에서는 구현에만 집중해야 하지만, 그럼에도 불구하고 너무 더러운 코드..)
    • 이 경우는 도메인에 대한 지식과 이해가 부족할수록 자주 일어난다.

 

미션 구현에서의 고난

위에서 TDD 도입의 단점 중, "초기 설계에 얽매이게 된다"  , "도메인에 대한 지식과 이해가 부족할수록 자주 일어난다" 라고 했다.

나는 미션 구현 중에서 도메인을 제대로 이해하지 못해서 꽤 오랫동안 해멨다.

 

미션에서 로또를 구현할 때는 우리가 직접 로또 당첨번호를 입력한다. 그리고 우리가 구매한 로또 번호는 항상 랜덤으로 돌아간다. 그렇게 우리가 직접 입력한 로또 번호와, 우리가 구매한 로또 번호를 비교해서 당첨 여부를 판단하는 것이다. 로또 당첨번호를 사용자가 이미 알고 있는 독특한 구조이다.

 

그런데 우리가 실생활에서 로또를 살 때는 1 부터 45 까지의 숫자 중, 6개의 숫자를 직접 뽑을 수 있고, 혹은 자동으로 뽑게끔 할 수 있다.(흔히 자동으로 돌린다고 함)

그래서 로또 미션을 구현할 때 사용자가 콘솔로 입력하는 6개의 숫자가 당연히 사용자가 구매하는 로또의 번호라고 착각했다.

그리고 랜덤으로 생성되는 로또 번호들이 당첨번호라고 생각했다. 여러 장의 로또를 구매하는 경우는 여러 주의 걸쳐서 로또를 한 장씩 구매하며, 매주 똑같은 번호의 로또를 구매한 것이라고 생각했다.  

또 보너스 번호는 로또를 구매한 사람이 선택하는 번호라고 생각했다.

 

실제로 구현해야 하는 것과 내가 착각한 구현

 

그래서 설계 후 테스트와 프로덕션 코드를 작성하는데 조금 헤매었다. 구현을 시작하고, 미션 제출이 얼마 남지 않은 시점이 되서야 실제로 구현해야 하는 기능을 제대로 이해했다. 

 

위 그림에서처럼, 처음에는 보너스 번호도 내가 로또를 구매할 때 선택하는 것이라고 생각했다. 그래서 `Lotto` 클래스는 생성자로, 구매한 로또 번호 리스트(`numbers :List<Int>`) 와 보너스 번호 (`bonusNumber: Int`) 를 가져야 한다고 생각했다. `Lotto` 의 인스턴스를 생성할 때 이를 결정할 수 있기 때문에 이것이 당연한 것이라고 생각했다.

그런데 제공된 `Lotto` 클래스는 그렇지 않다. 또, `Lotto` 클래스의 필드를 변경하면 안된다는 요구사항도 있었기 때문에 어떻게 구현해야 할지 고민하는데 시간이 꽤 들었다.

 

그런데 사실 요구하는 기능 자체가 조금 이상하긴 하다.

실행 결과 예시는 아래와 같다.

구입금액을 입력해 주세요.
8000

8개를 구매했습니다.
[8, 21, 23, 41, 42, 43] 
[3, 5, 11, 16, 32, 38] 
[7, 11, 16, 35, 36, 44] 
[1, 8, 11, 31, 41, 42] 
[13, 14, 16, 38, 42, 45] 
[7, 11, 30, 40, 42, 43] 
[2, 13, 22, 32, 38, 45] 
[1, 3, 5, 14, 22, 45]

당첨 번호를 입력해 주세요.
1,2,3,4,5,6

보너스 번호를 입력해 주세요.
7

당첨 통계
---
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.

정상적인 로또 기능을 하려면,  `8개를 구매했습니다.`  라는 프롬프트가 출력되고 나서, 구매한 로또 번호들이 바로 출력되면 안되고, 당첨 번호와 보너스 번호를 입력한 후에 출력되어야 하지 않나...? 싶다.

내가 기능 요구사항을 헷갈렸던 것이 당연한 부분도 조금 있었던 것 같다...

 

요구사항을 너무 늦게 이해했다.

처음 설계할 때는 `Lottoes` 라는 클래스가 `Lotto` 를 합성하도록 만들었다.

도메인을 제대로 이해하지 못한 것 때문에 삽질을 오래하여 미션 구현에 충분히 고민해볼 시간이 부족할 수 밖에 없었다. 그래서인지 다른 클래스를 만들 생각을 하지 못했고, 결국, 첫 설계에 얽매이게 되었다.

아래는 제출한 최종 설계의 UML 이다.

제출한 최종 설계의 UML

 

`Lottoes` 클래스에 너무 많은 필드가 있는 모습을 볼 수 있다.

 

먼저 `Lottoes` 클래스를 보자.

class Lottoes(private val paymentAmount: Int) {

    var userNumbers: Set<Int> = setOf()
    var bonusNumber: Int = 0
    var lottoes: MutableList<Lotto> = mutableListOf()

    val lottoTicketCount: Int = paymentAmount / 1000
    private var lottoGenerator: LottoGenerator = LottoGenerator()
    private var lottoesResult: MutableMap<WinningRank, Int> =
        WinningRank.values().associateWith { 0 }.toMutableMap()

    init {
        for (i in 1..lottoTicketCount) {
            lottoes.add(lottoGenerator.generateLotto())
        }
    }


    fun calculateLottoesResult(): Map<WinningRank, Int> {
        lottoes.forEach {
            val numMatchCount = it.calculateMatchingCount(userNumbers)
            val bonusNumberMatch = it.containBonusNumber(bonusNumber)
            val result = it.calculateLottoRank(numMatchCount, bonusNumberMatch)

            lottoesResult[result] = (lottoesResult[result] ?: 0) + 1
        }
        return lottoesResult
    }

    fun getProfitRate(): Double {
        val profitAmount = calculateTotalProfit()
        val profit = Profit(profitAmount)
        return profit.calculateProfitRate(paymentAmount)
    }

    internal fun calculateTotalProfit(): Int =
        lottoesResult.entries.sumOf { (rank, count) ->
            rank.prize * count
        }

}

 

각 필드를 만든 나의 의도는 아래와 같았다.

  • `payment: Int`: 로또를 구입할 때 지불하는 금액
  • `userNumbers: Set<Int>`: 로또 당첨 번호(사용자가 입력)
  • `bonusNumber: Int`: 로또의 보너스 번호
  • `lottoes: MutableList<Lotto>`: 구입한 로또 번호 리스트
  • `lottoTicketCount: Int`: 구입한 로또의 수
  • `lottoGenerator: LottoGenerator`: 구입한 로또의 번호를 생성해주는 인스턴스
  • `lottoResult: MutableMap<WinningRank, Int>: 로또의 결과

코드에 문제점들이 많이 보인다.

 

캡슐화가 제대로 되지 않았다

`userNumbers` 와 `bonusNumber`, `lottoes`, `lottoTicketCount` 의 가시성은 `public` 인 것을 볼 수 있다. 이 변수들은 Controller 에서 `getter` 와 `setter` 의 형태로 직접 참조하고 있다. 이 설계는 객체들을 캡슐화하지 못하고 있어, 객체지향적인 설계가 아니다. 이전 미션들에서는 비교적 잘 지켰던 "묻지 말고 시켜라` 원칙을 굉장히 많이 위반하고 있다.

 

변수의 네이밍이 적절하지 않는다

`userNumbers` 라는 변수명부터 적절한 네이밍이 아닌 것으로 보인다. 이것은 처음 설계시 요구사항을 제대로 이해하지 못한 흔적이다. 사용자가 입력하는 번호이지만, 로또 당첨 번호이므로 `winningNumbers` 와 같은 네이밍이 적절해 보인다.

 

필드의 수가 너무 많다

필드의 수가 많은 것은 객체의 복잡도를 높이고, 버그 발생 가능성을 높일 수 있다. 필드에 중복이 있거나 불필요한 필드가 없는지 확인해서 필드의 수를 최소화해야 한다. 

이는 위에서 언급한 "묻지 말고 시켜라` 원칙을 지키면서 해결되는 부분도 있을 것이다.

 

 

리팩토링을 해보자

위의 설계를 천천히 고쳐보자

`Lottoes` 의 필드 제거

`userNumbers`와 `bonusNumber` 필드

먼저 `Lottoes` 에서 `userNumbers` 와 `bonusNumber` 필드를 제거할 수 있다.

`calculateLottoesResult` 메서드의 패러미터로 `userNumbers` 와 `bonusNumber` 를 넣어서 해결할 수 있다.

class Lottoes(private val paymentAmount: Int) {
    var lottoes: MutableList<Lotto> = mutableListOf()

    val lottoTicketCount: Int = paymentAmount / 1000
    private var lottoGenerator: LottoGenerator = LottoGenerator()
    private var lottoesResult: MutableMap<WinningRank, Int> =
        WinningRank.values().associateWith { 0 }.toMutableMap()

    // ...
    
    fun calculateLottoesResult(
        winningNumbers: Set<Int>,
        bonusNumber: Int
    ): Map<WinningRank, Int> {
        lottoes.forEach {
            val numMatchCount = it.calculateMatchingCount(winningNumbers)
            val bonusNumberMatch = it.containBonusNumber(bonusNumber)
            val result = it.calculateLottoRank(numMatchCount, bonusNumberMatch)

            lottoesResult[result] = (lottoesResult[result] ?: 0) + 1
        }
        return lottoesResult
    }
    
    fun getProfitRate(): Double {
        val profitAmount = calculateTotalProfit()
        val profit = Profit(profitAmount)
        return profit.calculateProfitRate(paymentAmount)
    }

    // ...
}

 

이렇게 변경한 후, `GameController` 에서 입력받은 것들을 `calculateLottoesResult` 의 패러미터로 넣어주면 될 것이다.

 

`lottoTicketCount` 필드

이제 `lottoTicketCount` 를 제거해보자.

현재 `GameController` 가 `lottoTicketCount` 를 직접 참조하고 있다. 사용자(고객)이 지불한 금액을 통해 구매한 로또의 수량을 출력해야 하기 때문이다.

 

`lottoTicketCount` 라는 필드를 단순히 제거한 뒤, 컨트롤러에서 지불 금액을 로또 가격으로 직접 나눠서 로또 수량을 구할 수도 있지만, 이보다 다른 객체가 이 책임을 수행하는 것이 더 바람직해보인다.

나는 `LottoSeller`  라는 객체를 만드는 것이 좋을 것이라고 생각한다.

 

그리고 `Lottoes` 는 `paymentAmount` 가 아닌, `lottoTicketCount` 를 생성자로 받으면 될 것 같다. 그런데 `Lottoes` 의 `getProfitRate` 메서드가 `paymentAmount` 를 필요로 한다는 문제가 있다. 

나는 `ProfitCalculator` 라는 `lottoesResult: Map<WinningRank, Int>, payment: Int`를 생성자로 가지는 클래스를 도입하고 이 클래스가 수익과 관련된 것들을 계산하도록 만들었다.

 

결론적으로 아래와 같이 수정했다.

class Lottoes(lottoTicketCount: Int) {
    private var lottoGenerator: LottoGenerator = LottoGenerator()
    
    private var lottoes: MutableList<Lotto> = mutableListOf()
    private var lottoesResult: MutableMap<WinningRank, Int> =
        WinningRank.values().associateWith { 0 }.toMutableMap()

    init {
        for (i in 1..lottoTicketCount) {
            lottoes.add(lottoGenerator.generateLotto())
        }
    }

    fun calculatedLottoesResult(
        winningNumbers: Set<Int>,
        bonusNumber: Int
    ): Map<WinningRank, Int> {
        lottoes.forEach {
            val numMatchCount = it.calculateMatchingCount(winningNumbers)
            val bonusNumberMatch = it.containBonusNumber(bonusNumber)
            val result = it.calculateLottoRank(numMatchCount, bonusNumberMatch)

            lottoesResult[result] = (lottoesResult[result] ?: 0) + 1
        }
        return lottoesResult
    }

    override fun toString(): String {
        return lottoes.joinToString("\n") {
            it.toString()
        }
    }
}
class ProfitCalculator(private val lottoesResult: Map<WinningRank, Int>, private val payment: Int) {

    fun getProfitRate(): Double {
        val totalProfit = calculateTotalProfit()
        return calculateProfitRate(totalProfit)
    }

    private fun calculateTotalProfit(): Int =
        lottoesResult.entries.sumOf { (rank, count) ->
            rank.prize * count
        }

    private fun calculateProfitRate(totalProfit: Int): Double {
        var profitRate = totalProfit.toDouble() / payment.toDouble() * 100.0
        profitRate = round(profitRate * 10) / 10
        return profitRate
    }
}

 

그리고 Controller 에서 이 두 클래스의 인스턴스를 생성해서 사용하면 될 것 같다.

 

`lottoes` 필드

이제 `lottoes` 를 제거해보자. `lottoes` 는 `GameController` 에서 모든 로또의 번호를 출력해야 하므로, 직접 참조하고 있다. 필드를 묻도록 하지 말고 메서드를 통해 리턴받도록 하자.

나는 `toString` 메서드를 오버라이드하는 것으로 이를 해결했다.

// Lottoes 의 toString 메서드
override fun toString(): String {
    return lottoes.joinToString("\n") {
        it.toString()
    }
}

// Lotto 의 toString 메서드
override fun toString(): String = numbers.toString()

이렇게 되면, `lottoes` 필드의 가시성도 `private` 으로 제한할 수 있다.

 

그런데 아직 문제가 발생한다. 바로 테스트 코드에서의 문제이다.

원래의 `LottoesTest` 의 테스트 메서드를 살펴 보자.

@Test
fun `구매한 모든 로또의 당첨 결과를 Map 의 형태로 리턴한다`() {
    // given
    val lottoes = Lottoes(6000)
    val winningNumbers = setOf(1, 2, 3, 4, 5, 6)
    val bonusNumber = 7
    lottoes.lottoes = mutableListOf(
        Lotto(listOf(1, 2, 3, 4, 5, 6)),
        Lotto(listOf(1, 2, 3, 4, 5, 7)),
        Lotto(listOf(1, 2, 3, 11, 12, 14)),
        Lotto(listOf(11, 22, 33, 2, 1, 3)),
        Lotto(listOf(11, 22, 33, 17, 27, 37)),
        Lotto(listOf(11, 22, 33, 8, 27, 9))
    )

    // when
    val result = lottoes.calculateLottoesResult(winningNumbers, bonusNumber)
    expectedLottoes = mapOf(
        WinningRank.FIRST to 1,
        WinningRank.SECOND to 1,
        WinningRank.THIRD to 0,
        WinningRank.FOURTH to 0,
        WinningRank.FIFTH to 2,
        WinningRank.FAILURE to 2
    )

    // then
    assertThat(result).isEqualTo(expectedLottoes)
}

프로덕션 코드에선 `lottoes` 를 랜덤 번호들로 생성해준다. 테스트를 하기 위해서는 이 랜덤 번호들을 개발자가 직접 넣어주어야 한다. 

 

이 경우는 `LottoGenerator` 의 mock 객체를 만들어서 우리가 직접 조작할 수 있도록 만들어야 할 것 같다.

현재 `Lottoes` 를 보자.

class Lottoes(lottoTicketCount: Int) {
    private var lottoGenerator: LottoGenerator = LottoGenerator()

    var lottoes: MutableList<Lotto> = mutableListOf()
    private var lottoesResult: MutableMap<WinningRank, Int> =
        WinningRank.values().associateWith { 0 }.toMutableMap()

    init {
        for (i in 1..lottoTicketCount) {
            lottoes.add(lottoGenerator.generateLotto())
        }
    }
    // ...
}

 

`init` 의 바디에서 `LottoGenerator` 가 로또들을 만들어서 리스트에 넣어주고 있다. 현재 구현에서는 어떻게 mock 객체를 만들어도, 랜덤 번호를 직접 조작할 수 없다. 즉, 구현을 바꾸어야 한다.

 

`LottoGenerator`를 생성자로 만들어서 테스트 코드에서는 `mockLottoGenerator` 를 만들어서 사용할 수 있도록 해야 한다.

 

`LottoGenerator` 의 상위에 인터페이스를 만들어서 사용하자.

interface LottoesGenerator {
    fun generateLottoes(): List<Lotto>
}

class LottoesGeneratorImp(private val lottoTicketCount: Int): LottoesGenerator {
    override fun generateLottoes(): List<Lotto> {
        val lottoes = mutableListOf<Lotto>()
        for (i in 0 until lottoTicketCount) {
            lottoes.add(
                Lotto(pickUniqueNumbersInRange(START_NUMBER, END_NUMBER, LOTTO_COUNTS).sorted().toList())
            )
        }
        return lottoes
    }
}

 

이제 ` LottoesGenerator` 는 `Lotto` 가 아닌 `List<Lotto>` 를 리턴하고 있다. 

`LottoGeneratorImp` 는 프로덕션에서 쓰이는 구현체이다.

class Lottoes(
    lottoTicketCount: Int,
    lottoGenerator: LottoesGenerator = LottoesGeneratorImp(lottoTicketCount)
) {
    private val lottoes: List<Lotto> = lottoGenerator.generateLottoes()
    private val lottoesResult: MutableMap<WinningRank, Int> =
        WinningRank.values().associateWith { 0 }.toMutableMap()
        
    // ....
}

 

테스트할 때는 `mockLottoGenerator` 라는 익명 객체를 만들어서 사용하면 된다. 바로 아래에서 `LottoesTest` 코드를 볼 수 있다.

class LottoesTest {
    @Test
    fun `구매한 모든 로또의 당첨 결과를 Map 의 형태로 리턴한다`() {
        // given
        val mockLottoesGenerator = object : LottoesGenerator {
            override fun generateLottoes(): List<Lotto> =
                listOf(
                    Lotto(listOf(1, 2, 3, 4, 5, 6)),
                    Lotto(listOf(1, 2, 3, 4, 5, 7)),
                    Lotto(listOf(1, 2, 3, 11, 12, 14)),
                    Lotto(listOf(11, 22, 33, 2, 1, 3)),
                    Lotto(listOf(11, 22, 33, 17, 27, 37)),
                    Lotto(listOf(11, 22, 33, 8, 27, 9))
                )
        }
        val lottoes = Lottoes(6, mockLottoesGenerator)
        val winningNumbers = setOf(1, 2, 3, 4, 5, 6)
        val bonusNumber = 7

        // when
        val result = lottoes.calculatedLottoesResult(winningNumbers, bonusNumber)
        val expectedLottoes = mapOf(
            WinningRank.FIRST to 1,
            WinningRank.SECOND to 1,
            WinningRank.THIRD to 0,
            WinningRank.FOURTH to 0,
            WinningRank.FIFTH to 2,
            WinningRank.FAILURE to 2
        )

        // then
        assertThat(result).isEqualTo(expectedLottoes)
    }
}

 

이렇게 된다면, `Lottoes` 의 필드를 완전히 숨길 수 있게 되었다!!

 

여기까지 리팩토링했을 때의 구조는 아래와 같다.

 

 

아래 링크의  `refatorAfterMission` 브랜치에서 코드를 볼 수 있다.

만약 코드를 본다면 이 코드가 좋은 코드가 아님을 참고하면 좋겠다. 아직 고쳐야 할 부분들이 굉장히 많다..

https://github.com/sh1mj1/kotlin-lotto-6/tree/refactorAfterMission

 

GitHub - sh1mj1/kotlin-lotto-6: 로또 미션을 진행하는 저장소

로또 미션을 진행하는 저장소. Contribute to sh1mj1/kotlin-lotto-6 development by creating an account on GitHub.

github.com

 

이외에 아쉬운 점들

당첨 번호도 `Lotto` 객체로 사용해야 했다

현재 내가 설계한 시스템에서는 내가 입력한 로또 번호(당첨 번호)는 `Set<Int>` 형태이다. 이 번호는 따로 클래스를 생성해서 사용하고 있지 않는다.

그런데 `Lotto` 라는 인스턴스를 만들어서 사용하는 것이 더 바람직한 것으로 보인다. 

 

먼저 랜덤으로 생성되는 로또 번호는 `Lotto` 에서 관리하고 있다. 이는 6자리 1~45 까지의 숫자 리스트(`List<Int>`)를  주 프로퍼티로 갖고 있다.  그런데 당첨 번호 또한 이러한 프로퍼티를 가지고 있다. 또한 두 경우에서 유효성 검증을 해야하는 항목들이 같다.

 

미션에서 제공된 `Lotto` 클래스의 처음 모습은 아래와 같았다.

class Lotto(private val numbers: List<Int>) {
    init {
        require(numbers.size == 6)
    }

    // TODO: 추가 기능 구현
}

 

여기서는 `Lotto` 가 초기화될 때, `numbers` 의 크기에 대한 유효성을 검증하고 있다. 

즉, 시스템에서 중요한 모델 클래스의 인스턴스를 생성할 때 유효성 검증을 수행하고 있다. 사용자 입력 번호(당첨 번호)를 단순 `Set<Int>` 타입으로 만드는 게 아닌, `Lotto` 클래스를 사용하여 인스턴스를 만들어서 사용한다면, `Lotto` 에서 사용하는 유효성 검증 로직을 그대로 사용할 수 있다. 하지만, 내 코드에서는 `InputValidator` 라는 클래스에서 유효성 검증을 중복해서 하고 있다....

 

 

현재 진행하고 있는 프리코스 스터디에서 유효성 검증을 어떻게 해야 하는지에 대해 아래와 같은 결론이 나왔었다.

  • 단순 형식값(빈 값을 입력하지는 않는지, 올바른 타입을 입력했는지)에 대해서는 View 혹은 Controller 에서 유효성 검증을 한다.
  • 그 외에 시스템에서 제공하는 핵심 도메인과 관련이 깊고 정책에 따라 변경되기 좋은 것들은 Model 에서 유효성 검증을 한다.

위 글로만 보면 정확히 어떤 이야기인지 이해하기 어려울 것이다. 로또 당첨 번호를 입력할 때의 유효성 검증을 예시로 들어서 설명하면 아래와 같다.

  • 로또 당첨 번호를 사용자가 입력한다. 입력하는 것은 숫자들이므로, `Int` 타입의 숫자가 입력되어야 한다. 또한 콤마(`,`) 로 숫자들을 구분하여 입력해야 한다. 콤마를 사용해야 한다는 것은, 시스템이 MVC 패턴으로 설계되었을 때 View 와 관련이 깊은 정책이다. 이럴 때 `Int` 타입으로 입력되는지, `,` 로 숫자들이 구분되어 있는지와 같은 유효성 검증은 View 에서 하는 것이 바람직하다.
  • 실제 로또 객체를 생성할 때 추가로 해당 클래스의 초기화(`init`) 블록에서 유효성 검증을 수행한다. 예를 들어 "로또 번호의 숫자들을 6개여야 함", "로또 번호는 중복되면 안 됨", "각 번호는 1 ~ 45 사이의 숫자여야 함" 은 전체 시스템의 핵심 정책과 관련이 깊고,핵심 정책의 변경에 따라 같이 변경될 수 있다.

 

소감

이번 미션은 1주차 숫자 야구, 2주차 자동차 경주 미션에서의 난이도보다 크게 어려워진 느낌이 들었다.

그렇게 느낀 이유는 먼저, 시스템의 기능과 요구사항에 대해 제대로 이해하지 못한 이유가 크다. 좋은 개발을 위해서라면, 먼저 요구사항에 대해서 제대로 이해하고 있어야 한다. 이것이 제일 중요하다. 시스템을 개발하는데 어떤 것을 개발해야 하는지 제대로 이해하지 못한다면, 개발의 결과가 나오는 것 자체가 불가능하다. 재사용성, 유지보수성,, 등 이런 것들도 중요하지만, 가장 중요한 것은 요구사항대로 돌아가는 시스템을 만드는 것이다.

 

미션에서 `Lotto` 클래스를 미리 제공한 덕분에 핵심 비즈니스와 관련된 유효성 검증 정책단순 형식값에 대한 유효성 검증 정책에 대해서 고민할 수 있었다.

어떠한 도메인 모델에 대한 정책과 유효성 검증은 해당 클래스에서 관리하는 것이 재사용성과 유지보수성이 높아질 것이라는 생각이 들었다. 내가 `InputValidator` 에서만 모든 유효성 검증을 수행하도록 설계했던 것은, 이 클래스가 너무 무거워지고, 시스템이 커짐에 따라 메서드도 너무 많아져서 코드를 보기도 어려워질 것이다...

 

객체지향적으로 설계하는 것은 참 어렵다고 생각을 했지만, 그에 대해서 공부나 새로운 시도가 부족했던 것 같다. 또한, 테스트를 하고 싶은 작은 메서드가 private 으로 선언되어 있어 더 작은 단위의 테스트들을 하지 못한 경우가 종종 있었다. (나는 미션 이후 코드부터, 리팩토링한 코드마저도, 스터디를 하면서 정말 품질이 낮은 코드를 만들어 내고 있었구나..)

이런 어려움을 일급 컬렉션 등과 같은 개념들을 더 공부하고 재구현을 해가며 줄일 생각이다. 

 

냄새 나는 내 코드.... 출처:&nbsp;https://engineering.99x.io/natural-odor-of-a-developer-f7f7fbb838e3

 

 

TDD 관련 내용 출처

https://inpa.tistory.com/entry/QA-%F0%9F%93%9A-TDD-%EB%B0%A9%EB%B2%95%EB%A1%A0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C#tdd_%EA%B0%9C%EB%B0%9C%EC%A3%BC%EA%B8%B0

https://velog.io/@hanblueblue/spring-boot-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0-2.-TDD