로또 게임
3주차의 미션은 '로또' 게임 프로그램을 구현하는 것이다.
이번에 추가된 학습 목표는 아래와 같다.
- 클래스(객체)를 분리하는 연습
- 도메인 로직에 대한 단위 테스트를 작성하는 연습
2주차까지 했던 설계 방식으로 해도 기능 구현에는 큰 무리가 없었다. 작성한 코드 역시 직관적이고 간결하다고 생각했다.
그러나 여전히 테스트 코드는 어떤 위주로 작성해야 하고, TDD에 대한 감을 잡지 못한 것 같았다.
지금까지 했던 설계로는 캡슐화나 의존성으로 인해 테스트 코드를 작성하면서 구현 코드를 변경했다.
테스트 코드에 기능을 맞추는 꼴이 되어가는 것 같아 찝찝했다.
또한, 과연 내가 했던 설계가 보기에는 직관적이어도 유지보수에는 용이할까라는 의문도 들었다.
다른 사람들이 왜 MVC 패턴이나 일급 컬렉션을 사용하는지도 궁금하였다.
시간을 많이 투자해야겠지만 조금 더 명확한 차이를 체감하고 싶어 공부 목적으로 이전에 했던 방식에서 리팩토링을 하려고 한다.
학습 목표와 더불어 MVC 패턴, 일급 컬렉션, TDD 등을 적용해보려고 한다.
문제점
이전 방식으로 개발을 완료하고 작성한 코드를 분석해봤다.
먼저, 객체의 역할과 계층이 명확히 분리되어 있지 않아 도메인 로직이 중복되는 문제가 있었다.
여기서 이 게임의 가장 중요한 모델인 'Lotto'에 관련된 도메인 로직은 아래와 같다.
(로또 생성은 전달받은 로또 번호 6개가 예외를 발생하지 않는다면 로또 생성이 성공했다는 의미이다)
✅ 6개의 번호를 가진 로또를 생성한다.
✅ (E) 번호의 개수가 6개가 아닐 경우 예외를 발생시킨다.
✅ (E) 번호의 범위가 1~45의 범위가 아닐 경우 예외를 발생시킨다.
✅ (E) 중복된 번호가 있을 경우 예외를 발생시킨다.
이 때 나는 User 객체에서 사용자에게 입력값을 받아 위의 예외 발생 로직에 대해 유효성 검사를 같이 진행했다.
그러나 과제 요구 사항의 Lotto 일급 컬렉션을 사용하는 부분에서도 같은 로직이 포함되어야 했다.
class User {
private var _winningNumbers = mutableListOf<Int>()
val winningNumbers get() = _winningNumbers.sorted().toList()
private fun setWinningNumbers(inputs: List<String>) {
val trimmedInputs = inputs.map { it.trim() }
trimmedInputs.forEach {
require(isValidInputNumber(it)) { INVALID_NUMBER_ERROR_MESSAGE }
}
val numbers = trimmedInputs.map { it.toInt() }
require(isValidWinningNumberCount(numbers)) { INVALID_WINNING_NUMBER_COUNT_ERROR_MESSAGE }
require(isValidRangeNumbers(numbers)) { INVALID_RANGE_NUMBER_ERROR_MESSAGE }
require(isValidDistinctNumber(numbers)) { INVALID_DISTINCT_NUMBER_ERROR_MESSAGE }
_winningNumbers.addAll(numbers)
}
...
}
class Lotto(private val numbers: List<Int>) {
init {
require(isValidNumberCount()) { NUMBER_COUNT_ERROR_MESSAGE }
require(isValidNumberRange()) { NUMBER_RANGE_ERROR_MESSAGE }
require(isValidDistinctNumber()) { DISTINCT_NUMBER_ERROR_MESSAGE }
}
...
}
위의 코드에서 User와 Lotto에서 로또 번호가 유효한 지 검사하는 도메인 로직을 중복하고 있다.
또한, 당첨 번호를 생성하는 기능에 대해서 아래처럼 예외 발생에 대해 단위 테스트를 하고 싶었지만
@Test
fun `입력값이 올바르게 금액으로 변환되었을 때 True`() {
val input = "1000"
val expectedAmount = 1000
user.setAmountFromInput(input)
val result = user.amount == expectedAmount
assertThat(result).isTrue()
}
setAmountFromInput 메서드가 private 이기 때문에 테스트 코드에서 호출이 불가했다.
테스트를 위해 캡슐화를 깨서라도 public으로 선언하는 것이 옳은 방향일까?
전체적으로 코드를 살펴보면 객체 안에 데이터를 처리하는 로직과 입력, 출력하는 로직 등이 섞여 있어
객체의 책임과 역할을 정확히 가늠하기 어려웠으며, 가시성으로 인해 테스트 코드 작성에 제한이 생겨 로직을 검증하는 데 어려움을 겪었다.
그러나 요구사항으로 제시된 Lotto 일급 컬렉션과 LottoTest를 분석하며 도메인 로직과 테스트에 대한 방향을 찾을 수 있었다.
학습 목표로 제시된 도메인 로직에 대한 단위 테스트를 이같은 방식으로 진행하면 되겠다 싶었다.
또한, 객체들의 역할이 불분명한 문제는 MVC 패턴을 적용해 역할에 따른 계층을 분리하고,
계층의 분명한 역할에 따라 객체들의 책임을 분리해주기로 했다.
모델
주어진 Lotto 일급컬렉션을 통해 어떤 방향으로 모델을 분리하고 정의해야 할 지 감이 왔다.
1차로 기능 구현을 하며 등장한 중요 데이터들에 대해 재설계를 하며 모델에 대해 정의하고,
각 모델이 수행해야 할 도메인 로직들을 정의하며 구현 기능 목록을 아래와 같이 새로 작성했다.
Lotto
✅ 6개의 번호를 가진 로또를 생성한다.
✅ (E) 번호의 개수가 6개가 아닐 경우 예외를 발생시킨다.
✅ (E) 번호의 범위가 1~45의 범위가 아닐 경우 예외를 발생시킨다.
✅ (E) 중복된 번호가 있을 경우 예외를 발생시킨다.
Bonus
✅ 1개의 보너스 번호를 생성한다.
✅ (E) 번호의 범위가 1~45의 범위가 아닐 경우 예외를 발생시킨다.
✅ (E) 당첨 번호와 중복되었을 경우 예외를 발생시킨다.
Purchase
✅ 구매 금액을 생성한다.
✅ (E) 유효하지 않은 금액 단위에 대해 예외를 발생시킨다.
- 1,000원 단위
Publisher
✅ 구입하려는 로또 개수만큼 로또를 발행한다.
WinningRank
✅ 당첨 번호 개수와 보너스 번호에 해당하는 등수를 찾는다.
- 1등: 6개 번호 일치 / 2,000,000,000원
- 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
- 3등: 5개 번호 일치 / 1,500,000원
- 4등: 4개 번호 일치 / 50,000원
- 5등: 3개 번호 일치 / 5,000원
WinningRecord
✅ 당첨 내역을 기록한다.
✅ 구매 로또와 당첨 로또를 비교해 일치하는 번호 개수를 계산한다.
✅ 구매 로또와 보너스 번호를 비교한다.
ProfitRate
✅ 총 당첨 금액을 계산한다.
✅ 수익률을 계산한다.
- 소수점 둘째자리에서 반올림
이렇게 프로그램에 필요한 데이터 모델들을 하나씩 정의하니까 데이터가 올바르게 생성되는지, 예외를 발생하는지
단위 테스트를 하기가 훨씬 수월해졌다.
예를 들어, 아래 정의된 클래스처럼 보너스 번호를 상태로 가진 Bonus 모델 객체에 대해서 단위 테스트를 작성한다면
class Bonus(private val number: Int) {
init {
require(isValidNumberRange()) { NUMBER_RANGE_ERROR_MESSAGE }
}
fun getNumber() = number
private fun isValidNumberRange() = number in Lotto.MIN_NUMBER..Lotto.MAX_NUMBER
private fun isValidDistinctNumber(winningNumbers: List<Int>) = number !in winningNumbers
companion object {
const val NUMBER_RANGE_ERROR_MESSAGE = "보너스 번호는 ${Lotto.MIN_NUMBER}에서 ${Lotto.MAX_NUMBER}사이여야 합니다."
const val DISTINCT_NUMBER_WITH_WINNING_LOTTO_ERROR_MESSAGE = "보너스 번호가 당첨 번호와 중복됩니다."
}
}
Bonus 객체가 생성될 때 생성자로 받은 number값이 유효하지 않다면 init 블럭 안에서 예외 발생 로직을 작성해두면 된다.
그리고 예외가 발생하는지 테스트하려면 아래와 같이 작성해줬다.
▷ "(E) 번호의 범위가 1~45의 범위가 아닐 경우 예외를 발생시킨다."에 대한 도메인 로직 단위 테스트
@ParameterizedTest
@ValueSource(ints = [0, 46])
fun `범위를 벗어난 보너스 번호인 경우 예외가 발생한다`(number: Int) {
//when
val exception = assertThrows<IllegalArgumentException> { Bonus(number) }
//then
assertThat(exception.message).isEqualTo(Bonus.NUMBER_RANGE_ERROR_MESSAGE)
}
즉, Bonus 객체를 생성하는 것만으로 보너스 번호가 유효한지 테스트할 수 있다.
이런식으로 모델을 정의하는 것만으로 가지고 있는 상태나 행위 등 도메인 로직에 대한 단위 테스트가 용이해짐을 체감할 수 있었다.
TDD
프리코스를 하며 TDD를 어떻게 적용해봐야 할 지 계속 고민했었다.
TDD 이론을 보며 가장 궁금했던 점은 어떻게 테스트 코드를 먼저 작성하는가였다.
객체의 프로퍼티와 메서드들도 정의하지 않았는데 어떻게 테스트 코드안에서 객체에 접근하는 건가 싶었다.
그러다 내 나름대로 어떻게 하는지 방법을 찾아 아래와 같이 적용해봤다.
먼저, TDD 사이클을 보면
- Red : 실패하는 테스트를 구현한다.
- Green : 테스트가 성공하도록 프로덕션 코드를 구현한다.
- Blue : 프로덕션 코드와 테스트 코드를 리팩토링한다.
이런 순서로 개발을 진행한다고 나와 있어 아래의 기능 목록에 대해 이 방법대로 테스트와 기능 구현을 진행해봤다.
Bonus
✅ (E) 번호의 범위가 1~45의 범위가 아닐 경우 예외를 발생시킨다.
Red : 실패하는 테스트를 구현한다.
이 단계에서 실패하는 테스트 코드를 작성하기 전에 테스트에 필요한 최소한의 클래스 정의를 해놓는다.
class Bonus(private val number: Int) {
init {
}
fun getNumber(): Int = number
companion object {
const val INVALID_NUMBER_RANGE_ERROR_MESSAGE =
"보너스 번호는 ${Lotto.MIN_NUMBER}에서 ${Lotto.MAX_NUMBER} 사이여야 합니다."
}
}
그런 다음 실패하는 테스트 코드를 작성한다.
@Test
fun `범위를 벗어난 보너스 번호인 경우 예외가 발생한다`() {
//given
val number = 46
//when
val exception = assertThrows<IllegalArgumentException> { Bonus(number) }
//then
assertThat(exception.message).isEqualTo(Bonus.INVALID_NUMBER_RANGE_ERROR_MESSAGE)
}
이 테스트 코드로 Bonus 객체를 생성하여도 init 블럭 안에 예외를 발생시키는 코드가 없기 때문에 이 테스트 코드는 실패한다.
Green : 테스트가 성공하도록 프로덕션 코드를 구현한다.
테스트 코드가 성공하도록 이제 예외를 발생시키는 코드를 작성한다.
class Bonus(private val number: Int) {
init {
require(isValidNumberRange(number)) { INVALID_NUMBER_RANGE_ERROR_MESSAGE }
}
fun getNumber() = number
private fun isValidNumberRange(number: Int) = number in Lotto.MIN_NUMBER..Lotto.MAX_NUMBER
companion object {
const val INVALID_NUMBER_RANGE_ERROR_MESSAGE =
"보너스 번호는 ${Lotto.MIN_NUMBER}에서 ${Lotto.MAX_NUMBER} 사이여야 합니다."
}
}
예외 발생 기능을 구현한 후 실패했던 테스트 코드를 실행시켜 보면 테스트가 성공으로 바뀌어져있다.
Blue : 프로덕션 코드와 테스트 코드를 리팩토링한다.
보너스 번호는 1~45의 범위 안의 정수인데 테스트 코드에서는 범위 오른쪽 외부의 정수만 테스트하고 있다.
이를 하나의 테스트 코드로 범위 양쪽 외부의 정수에 대해서 케이스별로 테스트할 수 있게끔 테스트 코드를 리팩토링한다.
@ParameterizedTest
@ValueSource(ints = [0, 46])
fun `범위를 벗어난 보너스 번호인 경우 예외가 발생한다`(number: Int) {
//when
val exception = assertThrows<IllegalArgumentException> { Bonus(number) }
//then
assertThat(exception.message).isEqualTo(Bonus.INVALID_NUMBER_RANGE_ERROR_MESSAGE)
}
이처럼 작은 단위의 테스트부터 테스트 코드가 주도하는 기능 구현을 적용해봤다.
이 방법이 맞는지는 좀 더 확인해봐야겠지만 TDD에 대해 어렴풋이 알 것 같았다.
이런 방식으로 단위 테스트를 하다 보니 기능 구현에서 실수한 부분도 빠르게 찾을 수 있었고,
객체의 메서드가 어떤 행위를 하고 원하는 데이터를 도출하는지를 요구사항에 맞춰 사전에 명확하게 정할 수 있었다.
그러면서 설계의 오류를 수정하고, 리팩토링을 해도 테스트가 무사히 통과하는지 빠르게 확인만 하면 되니,
이렇게 코드의 품질이 향상되는 것을 체감할 수 있었다.
MVC 패턴
이번 주차 학습 목표 중 하나는 클래스(객체)를 분리하는 연습이다.
클래스(분리)하는 것이 MVC 패턴을 적용하라는 말은 아니지만 1차 기능 구현을 완료하고 나서
작성한 코드를 봤을 때 정의한 클래스의 수는 적었지만 한 클래스에 100줄이 넘는 코드가 담겨 있었다.
예를 들어, 이전에 했던 방식대로 User라는 객체에서 사용자에게 입력을 받고, 입력값 유효성을 검사하고,
모델 데이터에 대한 상태를 포함하고 있었다.
이처럼 하나의 객체에서 입력에 대한 로직, 데이터를 처리하는 로직 등 여러 책임이 혼합되며 코드의 복잡성이 높았다.
따라서 구현한 객체들의 책임과 역할을 좀 더 명확히 구분할 필요가 있다고 생각했고,
학습 목표에 부합하다는 생각이 들어 MVC 패턴을 학습하고 적용해보기로 했다.
MVC 패턴과 프로그램의 동작 방식을 아래와 같이 정리했다.
위와 같은 설계대로 Model - View - Controller 로 계층을 분리하여 각 계층에서 해야 할 역할을 분명히 하였다.
User에서 수행하던 입력에 대한 책임을 InputView에게 부여하고,
LottoGame에서 결과를 출력하던 책임을 OutputView에게 부여했다.
또한 프로그램에 필요한 핵심 데이터와 비즈니스 로직을 처리하는 도메인 모델 객체들을 Model 계층으로 배정했다.
이런식으로 프로그램을 설계하고 리팩토링을 하면서 느낀 점은 하나의 클래스(객체)에 대해서
도메인 로직에 대한 단위 테스트가 용이해졌으며, 해당 객체의 역할을 분명히 알 수 있었다.
또한, 파일의 개수는 많아졌지만 코드의 가독성과 확장성은 더욱 증가한 것 같다.
GitHub - minuxx/kotlin-lotto-6: 로또 미션을 진행하는 저장소
로또 미션을 진행하는 저장소. Contribute to minuxx/kotlin-lotto-6 development by creating an account on GitHub.
github.com
'과제 > 우테코 6기 프리코스' 카테고리의 다른 글
우테코 6기 프리코스 안드로이드 - 2주차 미션 (0) | 2023.11.01 |
---|---|
우테코 6기 프리코스 안드로이드 - 1주차 미션 (0) | 2023.10.25 |