💡 Intro
💰 로또 미션
TDD
로또 미션을 진행하면서 새롭게 추가된 방법은 TDD다. TDD가 뭐냐고? 나도 잘 모른다. 누구세요?
Test Driven Development(테스트 주도 개발)는 실패하는 단위 테스트를 작성하고 그 단위 테스트를 통과하도록 코드를 구현하고, 이 코드를 리팩토링하는 방식으로 개발하는 방법이다. TDD는 단순한 설계를 장려하고 자신감을 불어 넣어 준다는데 단순한 설계 장려는 납득할 수 있지만 자신감을 불어 넣는 것에는 공감되지 않는다.🤔
TDD의 개발 사이클은 다음과 같다.
- RED : (설계가 되어 있다는 가정 하에) 실패하는 작은 테스트 케이스를 작성한다. 컴파일이 되도록 필요한 클래스나 프로퍼티는 만들어준다.
- GREEN : 최대한 빨리 테스트가 통과되게끔 만든다. 이를 위해서는 어떤 죄악을 저질러도 좋다. 여기서 죄악은 "이렇게 해도되나?"하는 생각들을 포함한다.
- BLUE : 구현한 코드를 리팩토링한다.
GREEN 단계에서 코드를 리팩토링할(코드를 추가할) 근거를 계속 찾아나간다. 같은 기능에 대해 여러 테스트 코드를 작성하면서 A테스트 코드에서는 통과하지만 B테스트 코드에서는 통과하지 않는 것과 같이 논리적으로 맞지 않는 부분들을 고쳐나간다.
일급 컬렉션
또 새로운 요구사항은 일급 컬렉션의 사용이다. 일급 컬렉션이 뭔데?
Collection을 Wrapping하면서, 그 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라고 한다.
래핑함으로써 내가 느낀 이점은 다음과 같다.
- 비즈니스에 종속적인 자료구조
- 상태와 행위를 한 곳에서 관리
1. 비즈니스에 종속적인 자료구조
로또 한 장을 발급하기 위해서는 6개의 번호가 필요하고, 이 6개의 번호는 중복되지 않아야 한다는 조건이 있다. 보통 이런 조건을 확인하기 위해서는 다음과 같이 작성하지 않을까한다.
fun main() {
val lottos = listOf(1, 2, 3, 4, 5, 6)
validateSize(lottos)
validateDuplicate(lottos)
}
fun validateSize(numbers: List<Int>): Boolean = numbers.size == 6
fun validateDuplicate(numbers: List<Int>): Boolean = numbers.toSet().size == 6
로또의 크기를 비교하고, 중복된 숫자가 있는지를 검증하는 로직을 작성해야한다. 만약 다른 클래스에서도 로또를 사용하고 싶다면?? 그 파일에서도 validateSize()
, validateDuplicate()
을 똑같이 진행해야 할 것이다. 즉, 같은 코드가 여러 곳에 존재하는 문제가 발생한다. 이를 일급 컬렉션을 통해서 해결할 수 있다.
package domain
class Lotto(val numbers: Set<Int>) {
init {
require(numbers.size == 6) { "로또 번호는 6개여야 합니다." }
}
}
Set은 중복을 지워주기 때문에 파라미터로 들어온 numbers의 크기가 6이 아니면 중복된 번호가 있거나 6개보다 적은 숫자를 넣었다고 생각할 수 있다. 즉 Lotto를 생성하는 시점에 중복없이 6개의 번호가 잘 들어왔는지를 검사하는 것이고, Lotto가 잘 생성되었다면 중복되지 않은 6개의 숫자가 들어있다는 것을 믿을 수 있다.
이렇게 컬렉션을 래핑해서 도메인 로직에 해당하는 자료구조를 새로 만들어 사용할 수 있게 되는 것이다.
2. 상태와 행위를 한 곳에서 처리
위에서 작성한 Lotto클래스는 이미 6개의 로또 번호를 가지고 있다.
class Lotto(val numbers: Set<LottoNumber>) {
init {
require(numbers.size == 6) { "로또 번호는 6개여야 합니다." }
}
fun contains(number: LottoNumber): Boolean = numbers.contains(number)
}
로또에서 어떤 번호를 가지고 있는지 확인하기 위해서는 외부에서 numbers에 접근하여 확인해도 된다. 하지만 단순히 번호를 확인하는 것이 아니고 다른 로또와 같은 숫자가 몇개 있는지 확인하고 싶다면? 아마 이런 내용들을 외부에 작성하게 되면 컨트롤러의 크기가 커질 것이다.
이 때도 우리는 일급 컬렉션을 활용할 수 있다. (일급 컬렉션이 아니어도 되지만) 일급 컬렉션에 메시지를 던져 원하는 결과를 반환받을 수 있도록 contains()
함수를 만들어두면 외부에서는 Lotto.contains(1)
만을 사용하면 결과를 확인할 수 있다.
이렇게 상태와 행위를 한 곳에서 함께 처리하게 되면 중복되는 코드를 줄일 수 있고 관리를 좀 더 효율적으로 할 수 있다.
❗ 페어 프로그래밍
이번에도 역시 페어로 미션을 진행했고, TDD 요구사항이 추가된만큼 기능 목록을 최대한 자세하게 작성하기로 했다. 아무래도 기능 목록이 자세할수록 테스트하려는 기능을 쪼갤 수 있고, 쉽지 않을까 생각했기 때문이다. 기능 목록은 잘 작성한 것 같은데 함수명, 클래스명을 짓는 능력이 부족해서 이 부분에서 굉장한 어려움을 느꼈던 것 같다.. 이름을 보고 어떤 역할과 일을 하는지 바로 알 수 있게 짓고 싶은데 그게 참 쉽지 않은 것 같다.
TDD를 하면서 느낀 것은 설계가 굉장히 중요하다는 것이다. 기능들이 세분화되어 있고 무엇을 원하는지 명확할수록 테스트를 작성하기 수월해지고, 코드의 수정이나 커밋 메시지를 작성하기도 쉬워진다.
@MethodSource
를 사용했고, 내가 생성한 객체를 넘겨주는 방식으로 테스트를 진행했다. 다만 좋은 방법이고 새로 공부하게 된 개념이기 때문에 무분별하게 사용하기는 했던 것 같다는 느낌이 들긴했지만 일단 그렇게 작성했다.🦾 Step1 & 리팩토링
LottoNumber 클래스 생성
로또에서 사용하는 숫자는 결국 Int 타입이다. 로또 번호는 1부터 45까지의 숫자만 가능하다는 특성을 가지고 있기 때문에 로또 번호를 사용하는 곳에서는 항상 유효성 검증을 해줘야한다. 로또 번호 클래스를 만들어 검증 로직을 그 안으로 옮기는 것이 좋을 것 같다는 리뷰를 받았다.
val lotto = Lotto(...)
val number = lotto.numbers.first()
foo(number)
fun foo(number: Int) {
// number가 로또 번호인가? 그럼 또 검증해야되나?
}
이것을 적용하기 위해 아래와 같은 로또 번호라는 클래스를 만들어서 로또 번호가 1부터 45사이 값인지 검증하도록 변경했다.
class LottoNumber(private val number: Int) {
init {
require(number in 1..45) { "로또 번호는 1이상 45이하여야 합니다." }
}
}
테스트코드 수정
@ParameterizedTest(name = "{0}개의 로또를 발급한다.")
@ValueSource(ints = [2, 3, 4])
fun `입력받은 개수만큼 로또를 발급한다`(count: Int) {
val generator = TestNumberGenerator()
val lottoSeller = LottoSeller(generator)
val ticket = lottoSeller.sellLottos(count)
assertThat(ticket.lottos.map { lotto -> lotto.numbers }).isEqualTo(
generator.pattern.subList(0, count)
)
}
inner class TestNumberGenerator : RandomGenerator {
val pattern = listOf(
setOf(1, 2, 3, 4, 5, 6),
setOf(9, 8, 7, 6, 5, 4),
setOf(45, 30, 27, 1, 2, 7),
setOf(5, 8, 9, 2, 10, 17)
)
private var i = 0
override fun generate(): Set<Int> {
return pattern[i++]
}
}
위 코드는 테스트에 주어진 로또 번호들이 아래에 위치하고 있어서 pattern이 무슨 값인지 일일히 확인해야 하는 번거로움이 있다는 리뷰를 받았다. 테스트는 항상 직관적으로 드러나게 작성하는 것이 좋다. 이를 적용하기 위해 2, 3, 4장의 로또를 발급하는 테스트 코드를 각각 작성해주었고 변경하고나니 훨씬 가독성이 좋고 어떤 것을 테스트하는지 명확하게 알 수 있어서 굳이 위와 같은 방법을 사용하지 않아도 되겠다는 느낌이 들었다.
class RankTest {
@ParameterizedTest(name = "{0}개 맞고 보너스볼 매치 {1}일 경우 {2}")
@MethodSource("countOfMatchAndMatchBonusProvider")
fun `당첨 번호와 보너스 볼 매치 여부로 당첨 등수 확인`(countOfMatch: Int, matchBonus: Boolean, expected: Rank) {
val result = Rank.valueOf(countOfMatch, matchBonus)
assertThat(result).isEqualTo(expected)
}
companion object {
@JvmStatic
fun countOfMatchAndMatchBonusProvider(): Stream<Arguments> {
return Stream.of(
Arguments.arguments(6, false, Rank.FIRST),
Arguments.arguments(5, true, Rank.SECOND),
Arguments.arguments(5, false, Rank.THIRD),
Arguments.arguments(4, false, Rank.FOURTH),
Arguments.arguments(3, false, Rank.FIFTH),
Arguments.arguments(1, false, Rank.MISS),
Arguments.arguments(0, false, Rank.MISS),
Arguments.arguments(0, true, Rank.MISS)
)
}
}
}
위 테스트코드 역시 @MethodSource를 이용하여 객체를 테스트 파라미터로 주입하는 것은 좋은 방법이지만 테스트 환경이 반복해서 사용되지 않고 가독성이 떨어지기 때문에 given, when, then을 사용하여 명시하는 것이 더 좋은 표현일 때가 많다고 한다.
테스트 코드의 목표는 어떻게 테스트 코드가 보다 읽기 쉽고 이해가 잘 되는가 이다.
UI를 사용하기 위한 형태의 이름
val profit = lottoStatistics.calculateProfitToString(result)
LottoStatistics 클래스에서 calculateProfitToString()
함수를 통해 수익률을 문자열로 반환하도록 구현했다. 하지만 LottoStatistics클래스는 도메인 모델이기 때문에 UI를 사용하기 위한 형태로 이름이 작성되면 안된다는 피드백을 받았다. 전혀 생각하지 못했던 부분이었기 때문에 짚어주시지 않았다면 계속 모르지 않았을까 싶다.
자료형에 알맞는 변수명
override fun printResult(statisticsResult: Map<Rank, Int>, profit: String)
위 코드는 리뷰를 받기 전 코드다. statisticsResult는 Map 형태지만 statisticsResult라는 변수명을 가지고는 자료형의 의미를 알기가 쉽지 않다. Map은 key를 통해 value값을 가져올 수 있다는 특징을 가지고 있기 때문에 winningCountBy로 수정했고 앞으로도 Map자료형에 대해서는 xxBy로 사용할 것 같다. 꽤나 괜찮은 변수명인 것 같다.
return when
companion object {
fun valueOf(countOfMatch: Int, matchBonus: Boolean): Rank {
var result = values().find { it.countOfMatch == countOfMatch } ?: MISS
when (result) {
SECOND -> result = if (matchBonus) SECOND else THIRD
else -> {}
}
return result
}
}
Enum 클래스인 Rank에서 valueOf()
함수를 작성할 때 위 코드처럼 작성했었다. 그런데 이 코드가 아래처럼 간단해질 수가 있더라. 아직 코틀린 문법에 익숙해지지 않은 것 같다.
companion object {
fun valueOf(countOfMatch: Int, matchBonus: Boolean): Rank {
return when (val result = values().find { it.countOfMatch == countOfMatch } ?: MISS) {
SECOND -> if (matchBonus) SECOND else THIRD
else -> result
}
}
}
when이 아니어도 return 뒤에 runCatching이나 다양한 것들을 쓸 수 있다. 이런 것들도 하나씩 잘 모아서 지식 블록을 쌓아가야겠다.
private 함수 테스트
기능을 작성하면 그 기능이 잘 동작하는지 테스트해봐야 한다. 하지만 모든 함수가 public으로 작성되지 않으며 private함수는 테스트 코드에서 접근할 수 없기 때문에 함수 그 자체를 테스트 할 수 없다.
val method = lottoStatistics.javaClass.declaredMethods.find {
it.name == "getCountOfMatch"
}
method?.isAccessible = true
어떻게 어떻게 찾아서 이런식으로 강제로 private함수에 접근할 수 있도록 해서 테스트할 수는 있지만 특정 private함수를 테스트 하는 것이 아닌 기능을 테스트해야 한다고 리뷰를 달아주셨다.
private으로 선언된 함수들은 결국 클래스 내부에서만 사용된다는 뜻이고 이 말은 또 public함수에서 private함수를 사용한다는 말이된다. 종국적으로는 private을 사용하는 기능(public 함수)을 테스트 해야 한다.
클래스
클래스를 객체의 능동적인 관리자로 생각해야 한다. 클래스는 객체를 보관하고 필요할 때 객체를 꺼낼 수 있고 필요하지 않을 때에는 객체를 반환할 수 있는 저장소로 바라봐야 한다.
로또 번호(LottoNumber)라는 개념은 아무리 많아야 최대 45개로 이루어져 있다. 그렇다면 로또 번호(LottoNumber)를 만들 때 매번 새로운 인스턴스를 만들 필요없이 미리 인스턴스화 해 놓은 뒤 필요할 때마다 가져오도록 만들면 더 효율적으로 코드를 작성할 수 있다. 즉 LottoNumber를 1부터 45까지의 LottoNumber를 보관하고 있는 저장소로 볼 수 있다는 것이다.
class LottoNumber private constructor(private val value: Int) {
companion object {
private const val MINIMUM_NUMBER = 1
private const val MAXIMUM_NUMBER = 45
private val NUMBERS: Map<Int, LottoNumber> = (MINIMUM_NUMBER..MAXIMUM_NUMBER).associateWith(::LottoNumber)
fun from(value: Int): LottoNumber {
return NUMBERS[value] ?: throw IllegalArgumentException()
}
}
}
🦿 Step2 & 리팩토링
의존성 주입 방법
interface LottoMachine {
fun create(count: Int): List<Lotto>
}
이렇게 LottoMachine 인터페이스를 만들고 자동으로 랜덤 로또를 생성하는 것은 RandomLottoMachine클래스를 통해, 수동 로또를 생성하는 것은 ManualLottoMachine클래스를 통해 할 수 있도록 구현하려고 했지만 쉽게 방법을 찾을 수 없었다.
class LottoSeller {
// 더이상 로또머신이 랜덤인지 뭔지 알 필요가 없어짐
fun sellTicket(count: Int, lottoMachine: LottoMachine): Ticket {
return Ticket(lottoMachine.create(count))
}
}
즉, 함수 파라미터 주입으로 LottoSeller가 사용하는 LottoMachine을 동적으로 변경하고, 결과적으로 LottoSeller는 어떤 LottoMachine인지 관심이 없어지게 되면서 create() 하나로 다른 동작을 할 수 있게 되는 것이다.
결국 어떤 기계를 이용해서 만들지는 사용하는 곳에서 결정을 할 수 있게 된다.
val lottoSeller: LottoSeller
val manualTicket = lottoSellet.sellTicket(ManualLottoMachine)
val autoTicket = lottoSeller.sellTicket(RandomLottoMachine)
interface LottoMachine {
fun create(count: Int): List<Lotto>
}
class RandomLottoMachine : LottoMachine {
override fun create(count: Int): List<Lotto> {
val result = mutableListOf<Lotto>()
repeat(count) { result.add(Lotto(LottoNumber.all().shuffled().take(6).toSet())) }
return result
}
}
class ManualLottoMachine(private val lottoNumbers: List<Set<Int>>) : LottoMachine {
override fun create(count: Int): List<Lotto> {
return lottoNumbers.map { numbers ->
Lotto(numbers.map { number -> LottoNumber.from(number) }.toSet())
}
}
}
1. ManualLotto가 InputView의 의존성을 가진다.
- 이 경우 일관성이 다소 떨어지는 대신, ManualLotto가 스스로 동작한다고 볼 수 있다.
2. ManualLotto가 가져올 로또 번호를 다시 한 번 협력을 통해 가져온다.
- 이 경우, 위임을 사용하거나 또 다른 인터페이스를 사용하거나 등 여러가지 방법이 있고 아래 코드가 있다 정도로 보면된다.
// 인터페이스 사용
class ManualLottoMachine(
private val getLottoNumbers: () -> List<LottoNumber>
): LottoMachine {
fun create(...) { return getLottoNumbers() }
}
// Controller
// 방법 A
val lottoMachine: LottoMachine = ManualLottoMachine { getManualLottoNumbers() }
private fun getManualLottoNumbers(numbers: List<Set<Int>>): List<Lotto> {
return numbers.map { numbers ->
Lotto(numbers.map { number -> LottoNumber.from(number) }.toSet())
}
}
// 방법 B
val manualLottoMachine = object: LottoMachine {
override fun create(...) { return getManualLottoNumbers() }
}
방법A는 ManualLottoMachine이라는 이름의 구현체를 만든 후에 그것을 생성해서 사용하는 방식이고,
방법B는 구현체를 직접 만들지 않고 인터페이스를 익명으로 구현하는 방식이다.
A와 B는 인터페이스를 구현하고 생성하여 사용할 것이냐, 익명으로 사용할 것이냐의 차이인 것 같다.
회고
분명히 프리코스 기간에 했던 미션이었는데 아예 다른 미션인 느낌이었다. 후... 리뷰를 받고 코드를 리팩토링하면서 점점 효율적이고 코틀린스러운 코드로 바뀌어 가는 것 같아서 너무 재밌고 즐겁다.😊 실제 현업에 계신 리뷰어님이 모든 커밋을 세세하게 들여다보시면서 개선이 필요한 부분을 직접 말해준다는 것 그리고 같은 내용에 대해 이야기를 나눌 수 있다는 것 이런 경험을 할 수 있다는 게 우테코만의 가장 큰 매력인 것 같다. 코드를 수정하는 과정에서 레거시 코드가 조금씩 남는데 이 부분은 필히 개선해야 할 부분인 것 같다.
[크롱] 1단계 로또 제출합니다. by krrong · Pull Request #19 · woowacourse/kotlin-lotto
프리코스 기간에 했던 미션인데도 더욱 어렵게 느껴졌던 것 같습니다! 많이 배우는 것 같아서 좋네요 😊 말씀드릴 것과 궁금한 부분이 생겨서 남기려고 합니다. 답변 해주시면 더 고민해볼 수
github.com
로또 step2 PR
[크롱] 2단계 로또 제출합니다. by krrong · Pull Request #41 · woowacourse/kotlin-lotto
안녕하세요! 1단계 미션을 진행하면서 결과를 출력하는 부분을 진행하지 않았는데 이를 확인하지 못하였습니다. 이 부분을 추가하고, 1단계 머지해주시면서 남겨주셨던 코멘트 반영, 수업에서
github.com