[우테코 6기 - 안드로이드] 프리코스 2주차 자동차 경주 회고
2주차 미션은 자동차 경주이다. 기능에 대한 요구사항은 아래 링크에서 볼 수 있다.
https://github.com/woowacourse-precourse/kotlin-racingcar-6
이전 미션과는 다르게 새로 추가된 요구사항들이 있었다.
요구사항은 아래와 같았다.
- 메서드의 indent depth 를 2까지만 허용한다.
- 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만든다.
- JUnit 5와 AssertJ를 이용하여 본인이 정리한 기능 목록이 정상 동작함을 테스트 코드로 확인한다.
이전 미션에서는 하나의 클래스에 모든 기능을 모두 작성한 후에 클래스를 나누었지만, 이번에는 추가 요구사항이 있는 만큼, 처음부터 전체적인 구조 설계를 먼저 할 필요가 있었다.
그래서 먼저 기능 명세서에 책임(기능)들을 작성하고, 각 책임을 담당할 클래스와 메서드 시그니처를 만든 후, UML 을 그렸다.
그리고 해당 메서드의 구현부 없이 시그니처만 작성하여 코드를 뼈대 코드(skeleton code)를 만들었다.
그렇게 입력, 핵심 도메인, 출력 순으로 기능과 테스트를 구현해나갔다.
입력을 테스트하는 방법
가장 처음 마주친 문제는 입력을 테스트하는 것이었다.
게임 참여자는 Console 창을 통해서 자동차들을 입력해야 한다. 이 때 자동차들은 쉼표로 구분된다.
나는 `cars`를 입력받는 메서드 때, `List<String>` 타입으로 변환하는 것까지의 담당하게 했다.
그래서 입력이 제대로 `List<String>` 으로 변환되는지를 테스트해야 했다. 그런데 테스트를 직접 콘솔창으로 입력해서 진행한다면, 테스트 코드의 의미가 없다.
나는 이에 대해서 default parameter 기능을 사용했다.
코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있다.
class InputCars {
operator fun invoke(str: String = Console.readLine()): List<String> {
return str.split(',').map { it.trim() }
}
}
사용자의 입력 `str` 을 메서드의 파라미터로 하고 있으며, 기본값을 사용자가 입력한 문자열로 하고 있다.
이 메서드를 테스트할 때는 아래처럼 수행하면 된다.
class InputCarsTest {
private lateinit var inputCars: InputCars
private lateinit var input: String
@BeforeEach
fun setUp() {
inputCars = InputCars()
}
@Test
fun `입력 값은 쉼표(,)를 기준으로 리스트로 반환한다`() {
// given
input = "pobi,woni,jun"
// when
val result = inputCars(input)
// then
val inputList = listOf("pobi", "woni", "jun")
assertThat(result).isEqualTo(inputList)
}
@Test
fun `입력 값이 쉼표(,) 앞 뒤로 공백이 있다면 이를 제거하고 리스트로 반환한다`() {
// given
input = "pobi, woni , jun"
// when
val result = inputCars(input)
// then
val inputList = listOf("pobi", "woni", "jun")
assertThat(result).isEqualTo(inputList)
}
}
`inputCars.invoke(input)` 의 형태로 메서드의 파라미터에 값을 넣어주고 있다. 테스트 코드에서는 직접 선언한 문자열 `input` 을 사용자의 입력이라고 취급하고, 테스트를 진행하고 있다.
이렇게 테스트 코드에서는 패러미터를 직접 넣어주고, 프로덕션 코드에서는 패러미터를 따로 넣지 않으므로써, 별다른 코드 수정 없이 하나의 메서드를 프로덕션, 테스트에서 둘다 사용할 수 있었다.
코틀린에서는 디폴트 파라미터 값을 사용하면 자바에서의 너무 많은 오버로딩 메서드의 문제를 어느정도 피할 수 있다. 이에 관련해서는 "Kotlin In Action" 책의 "Chap3 - 2 함수를 호출하기 쉽게 만들기" 에 친절히 설명되어 있다.
Cars 와 Car 클래스
자동차 경주 게임에서는, 자동차가 조건에 맞다면, 앞으로 이동해야 한다. 이 때는 자동차 하나의 이름과 위치를 알아야 한다.
그리고, 한 턴마다 자동차가 이동한 위치를 출력해주어야 한다. 이 때는 모든 자동차의 이름과 위치를 알아야 한다.
즉, 위 책임은 두 개의 클래스로 나뉠 수 있다. `Car` 클래스와 `Cars` 클래스로 나뉘는 것이다. 당연히 `Cars` 는 `Car` 를 합성하는 형태일 것이다. 위에서 작성한 UML 에서도 이를 확인할 수 있다.
전체 시스템을 동작하도록 하기 위해서 하나의 자동차 이름에 대해 유효성 검증을 해야 한다. 이 책임을 수행하는 데 필요한 정보(자동차 이름)를 가장 잘 알고 있는 객체는 `Car` 이다. 자동차가 앞으로 1만큼 나아가는 동작에 대한 책임 또한 자동차의 이름과 위치를 잘 알고 있는 `Car` 이다.
그리고, 모든 자동차 이름에 중복이 없어야 함에 대한 유효성 검증은 모든 자동차에 대해 알고 있는 `Cars` 의 책임이다. 최종 우승자를 판별하는 것 또한 `Cars` 의 책임이다.
이렇게 책임에 대해 생각하고, INFORMATION EXPERT (정보 전문가)패턴을 적용해 가며 객체를 만들었다.
`Car` 클래스
`Car` 클래스의 `Random` 에 관한 테스트?
`Car` 의 책임은 아래와 같다.
- 자동차 이름에 대한 유효성 검증.
- 생성된 랜덤 숫자가 특정 기준(4 이상인지)에 맞는지 확인하고, 맞다면 앞으로 이동.
- 여기서 랜덤 숫자를 생성하는 책임은 `NumberGenerator` 에게 위임한다.
그런데 문제가 있었다. 랜덤 숫자가 4 이상이라면, 앞으로 이동하는 동작을 테스트할 방법이 쉽게 떠오르지 않았다.
랜덤 숫자는 말 그대로 어떤 숫자일지 우리가 예상할 수 없다. 하지만 테스트 코드에서는 우리가 어떤 상황을 직접 만들고, 그 상황에 맞는 결과를 예측해내는 것이다.
즉, 우리는 랜덤 숫자를 직접 지정해주어야 테스트가 가능하다.
나는 `Car` 의 메서드 `moveForward` 의 파라미터에 랜덤 숫자를 만들어내는 책임을 가진 `NumberGenerator` 를 넣었다.
그리고 프로덕션 코드에서는 실제로 랜덤 숫자를 만들어주도록 하고, 테스트 코드에서는 개발자가 직접 숫자를 넣어줄 수 있도록 만들어주었다.
class Car(
val name: String,
var position: Int = 0,
) {
fun moveForward(numberGenerator: NumberGenerator = NumberGenerator()) {
if (isMove(numberGenerator)) {
position++
}
}
private fun isMove(numberGenerator: NumberGenerator): Boolean {
val randomNumber = numberGenerator.generateRandomNumber()
if (randomNumber >= MIN_THRESHOLD) {
return true
}
return false
}
fun validate(){
when{
name.isBlank() -> throw IllegalArgumentException(INVALID_CAR_NAME)
name.length > MAX_CAR_NAME_LENGTH -> throw IllegalArgumentException(TOO_LONG_NAME)
}
}
override fun toString(): String {
return "Car(name='$name', position=$position)"
}
}
`Car` 클래스의 메서드에 대한 간단한 설명이다.
- `moveForward(NumberGenerator)`: 앞으로 이동할 수 있는지 조건을 검사하고, 이동한다.
- `isMove(NumberGenerator)`: 앞으로 이동할 쉬 있는지 `Boolean` 값을 리턴한다.
- `validate`: 자동차의 이름에 대해 유효성 검증을 한다.
`moveForward` 메서드의 패러미터를 보면, 디폴트 패러미터 값으로 `NumberGenerator` 를 사용하고 있다. 이 디폴트 패러미터 값은 위에서 `InputCar` 에 테스트를 작성할 때 사용했었다.
`CarTest` 라는 테스트 코드는 아래와 같다.
class CarTest {
lateinit var car: Car
@Test
fun `Car 이름이 empty 이면 예외를 던진다`() { /* ... */}
@Test
fun `Car 이름이 공백으로만 이루어졌다면 예외를 던진다`() { /* ... */}
@Test
fun `Car 이름이 개행으로만 이루어졌다면 예외를 던진다`() { /* ... */}
@Test
fun `Car 이름이 5자 초과라면 예외를 던진다`() { /* ... */}
@Test
fun `Car 이름이 5자 이하 notBlank 이면 정상 실행된다`() { /* ... */}
@Test
fun `생성한 랜덤 수가 4 이상이면 앞으로 나아간다`() {
// given
car = Car(name = "car", position = 0)
int randomNumber = 4
// when
car.moveForward(NumberGeneratorTest(randomNumber))
// then
Assertions.assertThat(car.position).isEqualTo(1)
}
@Test
fun `생성한 랜덤 수가 3 이하이면 앞으로 나아가지 않는다() {
// given
car = Car(name = "car", position = 0)
int randomNumber = 0
// when
car.moveForward(NumberGeneratorTest(randomNumber))
// then
Assertions.assertThat(car.position).isEqaulTo(0)
}
// 테스트 용도의 클래스
// 리턴하는 RandomNumber 를 개발자가 입력하는 값으로 한다.
class NumberGeneratorTest(private val returnValue: Int) : NumberGenerator() {
override fun generateRandomNumber(): Int {
return returnValue
}
}
}
유효성 검증의 아래에 위치한 테스트 메서드에 집중해서 보자.
`NumberGeneratorTest` 라는 중첩 클래스는 `NumberGenerator` 를 상속받고 있다. 주 생성자로 `Int` 값을 받고 있으며, `generateNumber` 라는 메서드에서 주 생성자의 값을 그대로 리턴하고 있다.
`NumberGenerator` 클래스는 간단한다.
open class NumberGenerator {
open fun generateRandomNumber(): Int = Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE)
}
(참고) 코틀린에서는 자바와 달리, 클래스 내에 클래스를 만들면 기본적으로 중첩 클래스(nested class)이다. 중첩 클래스는 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다.
코틀린 클래스는 자바와 달리, 기본적으로 `final` 이며, 메서드 역시 기본적으로 `final` 이다. 즉, 상속이나 오버라이드할 수 없다는 것이다. 이를 허용하려면 클래스와 메서드 앞에 `open` 이라는 키워드를 붙여주어야 한다.
테스트에서는 `Car` 의 `moveForward` 메서드의 패러미터로 이 `NumberGeneratorTest` 를 사용하고 있다. 즉, `NumberGenerator`로 생성될 수를 개발자가 직접 넣어줄 수 있는 것이다.
이렇게 하면 랜덤 숫자에 대해 앞으로 이동하는 동작을 테스트할 수 있다.
위 `Car` 코드는 몇가지 개선점이 존재한다. 먼저 개선할 점을 알아보자.
`Car` 관련한 개선할 점
`NumberGeneratorTest` 가 추상화에 의존하도록 하자.
현재 설계에서는 `NumberGenerator` 를 `NumberGeneratorTest` 가 직접 상속받고 있습니다.
실제 `generateRandomNumber` 메서드의 구현부를 보면, 전혀 다른 동작을 수행한다는 것을 알 수 있다. 그러므로 굳이 상속을 사용할 이유가 없다.
또한, 상속은 근본적으로 부모 클래스와 자식 클래스가 강하게 결합될 수 밖에 없기 때문에 아래와 같은 문제를 갖는다.
- 부모 클래스와 자식 클래스의 결합도 문제
- 취약한 기반 클래스 문제
- 오버라이딩 오작용 문제
- 부모 클래스와 자식 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경해야 하는 문제.
이에 대한 내용은 "오브젝트" 의 "CHAPTER 10 상속과 코드 재사용" 에서 자세히 볼 수 있다.
물론, 나의 경우에서는 위 문제들이 크게 발생하지는 않지만, 클래스 간의 결합도는 존재한다. 그러므로, 코틀린의 `interface`를 사용하여 추상화에 의존하는 것이 더 좋아 보인다.
즉, 아래와 같이 수정한다.
interface NumberGenerator {
fun generateRandomNumber(): Int
}
class NumberGeneratorImp : NumberGenerator {
override fun generateRandomNumber(): Int = Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE)
}
class NumberGeneratorTest(private val returnValue: Int) : NumberGenerator {
override fun generateRandomNumber(): Int {
return returnValue
}
}
파라미터 값만 바뀌는 테스트 리팩토링
테스트 코드도 코드이므로 리팩토링을 통해 개선해야 한다고 한다. 당연히, 중복 코드를 줄이는 것도 중요한다.
이 때 사용할 수 있는 것이 `@ParameterizedTest`, `@ValueSource`, `@CsvSOurce`, `@MethodSource` 이다.
이 애노테이션들은 모두 JUnit 5 에서 제공하는 기능들이다.
`@ParameterizedTest`
테스트 메서드에 이 애노테이션이 달려있다면, 패러미터를 여러번 던져서 테스트를 수행할 수 있는 메서드가 된다.
또한 각각의 테스트에 해당하는 이름을 붙일 수 있다. `{displayName}_{index}` 와 같이 사용할 수 있다.
`@ValueSource`
`ValueSource` 애노테이션은 JUnit 5 에서 제공되는 `ArgumentsSource` 의 구현 중 하나로, 리터럴 값들의 배열을 테스트 메서드의 인자로 제공하는데 사용된다.
이 애노테이션은 여러 타입의 배열을 지원한다. 지원되는 타입은 `short`, `byte`, ... `char`, `boolean`, `String`, `Class` 등이 있다.
그렇다면 우리는 이 `@ParameterizedTest` 와 `@ValueSource` 를 통해서, 반복되는 테스트 코드를 줄이거나, 한 메서드로 더 많은 값을 동시에 테스트할 수 있다.
아래는 `CarTest` 에 대해 테스트 메서드를 리팩토링한 코드이다.
@ParameterizedTest
@ValueSource(strings = ["", " ", "\n"])
fun `Car 이름 blank 입력 예외 테스트`(input: String) {
car = Car(input)
assertThrows<IllegalArgumentException>(INVALID_CAR_NAME) {
car.validate()
}
}
@ParameterizedTest
@ValueSource(strings = ["abcdef", "123456", "000000"])
fun `Car 이름이 5자 초과라면 예외를 던진다`(input: String) {
car = Car(input)
assertThrows<IllegalArgumentException>(TOO_LONG_NAME) {
car.validate()
}
}
@ParameterizedTest
@ValueSource(strings = ["abcd", "1234", "0000"])
fun `Car 이름이 5자 이하 notBlank 이면 정상 실행된다`(input: String) {
car = Car(input)
assertDoesNotThrow { car.validate() }
}
@ParameterizedTest
@ValueSource(ints = [4, 5, 6])
fun `생성한 랜덤 수가 4 이상이면 앞으로 나아간다`(randomNumber: Int) {
car = Car(name = "car", position = 0)
car.moveForward(NumberGeneratorTest(randomNumber))
Assertions.assertThat(car.position).isEqualTo(1)
}
@ParameterizedTest
@ValueSource(ints = [0, 1, 2, 3])
fun `생성한 랜덤 수가 3 이하이면 앞으로 나아가지 않는다`(randomNumber: Int) {
car = Car(name = "car", position = 0)
car.moveForward(NumberGeneratorTest(randomNumber))
Assertions.assertThat(car.position).isEqualTo(0)
}
이전과 달리 하나의 메서드로 더 많은 값들을 테스트할 수 있는 모습을 볼 수 있다.
`@CsvSource`
이 또한 Junit 5 에서 제공하는 `ArgumentsSource` 의 구현 중 하나이다.
CSV 는 Command Seperated Values 라는 의미이다. 즉, 어떤 구분자(기본값은 ,(콤마))를 기준으로 CSV 를 구분해서 읽는다.
이번에는 입력에 대한 테스트(`InputViewTest`)를 `@ParameterizedTest` 와 `@CsvSource` 를 사용해서 리팩토링한 결과입니다.
class InputViewTest {
private lateinit var inputView: InputView
@BeforeEach
fun setUp() {
inputView = InputView()
}
@DisplayName("inputCar 테스트 - , 를 기준으로 앞뒤 공백을 제거한 값의 리스트로 반환")
@ParameterizedTest
@CsvSource(
"pobi,woni,jun | pobi,woni,jun",
"pobi, woni , jun | pobi,woni,jun", delimiter = '|'
)
fun `입력 값은 쉼표(,)를 기준으로 앞 뒤 공백을 제거한 값의 리스트로 반환한다`(input: String, expected: String) {
val result = inputView.cars(input)
Assertions.assertThat(result).isEqualTo(expected.split(','))
}
/* 다른 테스트들 */
}
위처럼 `@CsvSource` 의 구분자를 직접 지정할 수도 있으며, `@DisplayName` 을 사용해서 테스트의 결과를 원하는 문자열로 볼 수 있습니다.
`@MethodSource`
이 또한 JUnit 5 에서 제공하는 `ArgumentsSource` 의 구현 중 하나이다. 테스트 메서드에 전달될 패러미터를 제공하는 팩토리 메서드를 지정하는데 사용된다.
팩토리 메서드는 스트림 형태의 인자를 생성해야 하며, 각 인자 세트는 테스트 메서드의 인자로 사용된다.
`@MethodSource`를 사용할 때 주의할 점
- 보통 테스트 클래스 내의 팩토리 메서드는 `static` 이다.
- 외부 클래스의 팩토리 메서드는 항상 `static` 이다.
- 팩토리 메서드는 패러미터를 가지면 안되며, 리턴 타입은 스트림 형태의 인자를 생성할 수 있는 형태이다.
즉, 어떤 테스트에서 입력이나 결과가 복잡한 형태일 경우 여러 case 를 한번에 테스트 할 수 있도록 한다.
이 메서드를 사용하는 방법은 `Cars` 에 대해 설명한 후에 다시 설명하겠다.
`Cars` 클래스
`Cars` 의 책임은 아래와 같다.
- 자동차 경주의 결과를 계산해준다.
- 자동차들의 유효성을 검증한다.
코드는 아래와 같다.
class Cars(val carList: List<Car>) {
fun decideWinner(): List<String> {
val maxPosition = carList.maxOf(Car::position)
return carList.filter {
it.position == maxPosition
}.map(Car::name).toList()
}
fun validate() {
validateDuplicate()
carList.forEach {
it.validate()
}
}
internal fun validateDuplicate() {
val set = carList.map(Car::name).toSet()
if (carList.size > set.size) {
throw IllegalArgumentException(DUPLICATED_NAME)
}
}
override fun toString(): String {
return "Cars(carList=$carList)"
}
}
- `decideWinner`: `List<Car>` 중 가장 멀리까지 이동한 차의 위치를 찾고, 해당 `Car` 의 위치를 알려준다.
- `validate`: 자동차들의 이름이 중복인지 검사하고, 각 자동차의 이름이 정상적인지 검사한다.
- `validateDuplicate`: 자동차들의 이름이 중복인지 검사한다. (`validate` 에서 사용되는 메서드)
먼저 위에서 `@MethodSource` 에 대해 설명했으니, 'CarsTest` 에서 `@MethodSource` 를 사용하여 어떻게 테스트를 할 수 있는지부터 보자.
`CarsTest` 에서 `MethodSource` 를 사용하여 테스트
class CarsTest {
@ParameterizedTest
@MethodSource("provideUniqueCars")
fun `중복된 이름이 없으면 테스트를 통과한다`(cars: Cars) {
assertDoesNotThrow { cars.validateDuplicate() }
}
/* 다른 테스트들 */
companion object {
@JvmStatic
fun provideUniqueCars(): Stream<Cars> = Stream.of(
Cars(listOf(Car("pobi"), Car("woni"), Car("jun"))),
Cars(listOf(Car("pobi"), Car("woni"), Car("jun"), Car("sh1m"))),
Cars(listOf(Car("pobi")))
)
/* 다른 팩토리 메서드들 */
}
`@MethodSource` 으로 사용하는 팩토리 메서드는 `static` 이어야 한다고 했다.
`CarsTest` 에서는 `companion object` 를 클래스 내부에 선언해서 팩토리 메서드 `provideUniqueCars` 를 구현했다.
`@JvmStatic` 은 자바에서 `companion object`안의 메서드를 사용하기 위해 필요한 애노테이션이다. 물론 미션에서 "자바에서도 이 시스템의 코드를 사용할 수 있도록 하라" 라는 요구사항은 없었지만, 자바와의 상호 운용성을 위해 추가했다.
자바에서는
`CatTest.Companion.provideUniqueCars();` 형태로 호출할 수 있다.
`@MethodSource` 에 사용되는 팩토리 메서드의 리턴 타입은 스트림 형태의 인자를 생성할 수 있어야 한다고 했다. 그래서 `Stream<Cars>` 타입으로 리턴하고 있다.
테스트 메서드에서는 패러미터로 `Cars` 타입을 받고 있는 것을 확인할 수 있다.
또한 아래처럼 `data class` 를 만들어서 팩토리 메서드를 구현할 수 있다.
class CarsTest {
@ParameterizedTest
@MethodSource("provideTestData")
fun `가장 큰 position 을 가진 car 의 이름(들)을 리스트로 리턴한다`(data: TestData) {
val result = data.cars.decideWinner()
Assertions.assertThat(result).isEqualTo(data.expected)
}
/* 다른 테스트들 */
companion object {
/* 다른 팩토리 메서드들 */
@JvmStatic
fun provideTestData(): Stream<TestData> = Stream.of(
TestData(
Cars(
listOf(
Car("pobi", position = 0),
Car("woni", position = 1),
Car("jun", position = 2)
)
),
listOf("jun")
),
TestData(
Cars(
listOf(
Car("pobi", position = 2),
Car("woni", position = 2),
Car("jun", position = 2)
)
),
listOf("pobi", "woni", "jun")
),
TestData(
Cars(
listOf(
Car("pobi", position = 0),
Car("woni", position = 0),
Car("jun", position = 0)
)
),
listOf("pobi", "woni", "jun")
)
)
}
}
data class TestData(val cars: Cars, val expected: List<String>)
시스템 설계 결과 UML - 1차
이렇게 핵심적인 도메인을 포함한 Model 과 View, Controller 로 나누어 모두 구현한 후 파일을 정리한 결과는 아래와 같다.
이 설계가 최종적인 설계면 좋겠지만, 약간의 문제점들이 발견된다.
먼저 이전 주차 미션의 `View` 의 설계와 이번 주차 미션의`View`의 설계와의 차이부터 설명하겠다.
`View` 의 설계
여러 View 를 `InputView` 와 `OutputView` 로 통합시켰다.
View 에서의 메서드 이름은 프리코스 1주차의 피드백을 반영하여 정했다. 피드백에서는 네이밍을 축약하지 않는 것을 권고한다. 또한, 문맥을 중복하는 이름을 자제하라고 한다. 만약 클래스 이름이 `Order` 라면, `shipOrder` 라고 메서드 이름을 지을 필요가 없다. 짧게 `ship()` 이라고 하면, 클라이언트에서는 `order.ship()` 이라고 호출하며, 간결한 표현이 된다.
이와 마찬가지로, `InputView` 라는 이름에서 입력에 관련한 `View` 라는 문맥을 알려주고 있기 때문에, 메서드 이름을 `inputCars` 라고 하는 것이 아닌, `cars` 라고 두었다.
관련한 내용은 "객체지향 생활 체조 원칙 5: 줄여쓰지 않는다" 에서 확인할 수 있다.
`Car` 와 앞으로 이동하기 위한 조건 설계
이제 1차 설계의 결과물의 문제점을 이야기하겠다.
자동차가 앞으로 나아가기 위한 조건을 만들어주는 역할은 현재 `NumberGenerator` 가 하고 있다. 랜덤 숫자를 만들어서 이 숫자가 어떤 수 이상이라면 앞으로 나아가는 식이다.
그런데 만약 자동차가 앞으로 나아가기 위한 조건이 랜덤 수를 통해 일어나지 않도록 변경된다면???
예를 들어서 사용자가 어떤 문자열을 제한 시간 내에 입력해야 앞으로 나아간다고 해보자. 그렇다면, 애초에, 숫자를 생성할 필요가 없다. 이렇게 앞으로 나아가기 위한 조건이 변경될 수도 있다.
좋은 설계는 변경을 위해 필요한 것이다. 추후, 기능의 요구사항이 변경됨을 대비하여 객체지향적으로 설계를 하는 것이다.
그렇다면, 우리는 이러한 결론을 내릴 수 있다.
"애초에, NumberGenerator 라는 인터페이스 자체가 충분히 추상화되어 있지 않구나. 이 클래스가 너무 구체적인 책임을 가지고 있구나. "
그러므로 우리는 `NumberGenerator` 라는 클래스 대신, `MoveStrategy` 라는 인터페이스와 그 구현 클래스를 만들어서 사용할 수 있다. 그렇게 되면, 우리는 이전과 다르게, 클래스 이름만 보고도, 전체 시스템을 어느정도 예상할 수 있을 것이다.
`Car` 라는 클래스와 `NumberGenerator` 라는 클래스가 있을 때, 시스템 설계에 참여하지 않은 사람은 `NumberGenerator` 가 왜 필요한지 눈치채기 힘들지만, `Car` 클래스와 `MoveStrategy` 클래스가 있다면, 자동차의 이동 전략(정책)을 기준으로 자동차가 움직인다는 것을 비교적 쉽게 눈치챌 수 있을 것이다.
또한 이 경우, `Car` 가 `MoveStrategy` 라는 인터페이스에 의존하고 구현체는 생성자 주입을 통해 결정해주는 것도 바람직해진다.
결론적으로 나는 `Car` 가 `MoveStrategy` 라는 인터페이스를 생성자로 가지고, 해당 `MoveStrategy` 를 구현하는 `UsingRandomNumberMoveStrategy` 를 디폴트 패러미터 값으로 적용한다.
그리고 `CarTest` 시에는, `UsingRandomNumberMoveStrategyTest` 를 `Car` 객체 생성시 생성자로 주입시켜서 동작하도록 하였다.
변경한 코드는 아래와 같다.
class Car(
val name: String,
var position: Int = 0,
private val moveStrategy: MoveStrategy = UsingRandomNumberMoveStrategy()
) {
fun move() {
if (moveStrategy.isMove()) {
position++;
}
}
fun validate() {
when {
name.isBlank() -> throw IllegalArgumentException(INVALID_CAR_NAME)
name.length > MAX_CAR_NAME_LENGTH -> throw IllegalArgumentException(TOO_LONG_NAME)
}
}
override fun toString(): String = "$name$START_LANE${CAR_POSITION_SYMBOL.repeat(position)}"
}
interface MoveStrategy {
fun isMove(): Boolean
}
class UsingRandomNumberMoveStrategy : MoveStrategy {
override fun isMove(): Boolean {
val randomNumber = Randoms.pickNumberInRange(
GameConfig.START_INCLUSIVE,
GameConfig.END_INCLUSIVE
)
if(randomNumber >= MIN_THRESHOLD) {
return true
}
return false
}
}
class CarTest {
private lateinit var car: Car
/* 다른 테스트들 */
@ParameterizedTest
@ValueSource(ints = [4, 5, 6])
fun `생성한 랜덤 수가 4 이상이면 앞으로 나아간다`(randomNumber: Int) {
// given
car = Car(name = "car", position = 0, moveStrategy = UsingRandomNumberMoveStrategyTest(randomNumber))
// when
car.move()
// then
assertThat(car.position).isEqualTo(1)
}
@ParameterizedTest
@ValueSource(ints = [0, 1, 2, 3])
fun `생성한 랜덤 수가 3 이하이면 앞으로 나아가지 않는다`(randomNumber: Int) {
// given
car = Car(name = "car", position = 0, moveStrategy = UsingRandomNumberMoveStrategyTest(randomNumber))
// when
car.move()
// then
assertThat(car.position).isEqualTo(0)
}
class UsingRandomNumberMoveStrategyTest(private val inputValue: Int) : MoveStrategy {
override fun isMove(): Boolean = (inputValue >= MIN_THRESHOLD)
}
}
이렇게 클래스 이름을 충분히 추상화하며 구현이 아닌, 비즈니스에 더 가까워졌다.
`Car` 와 `Cars` 에 대한 의존성 제한
현재 설계에서의 문제점이 더 있다.
현재 `Cars` 뿐 아니라, `Car` 에도 의존하고 있는 클래스들이 있다.
`GameController` 가 `Cars` 와 `Car` 에 의존한다
사용자로부터 자동차들의 이름을 입력받을 때 아래와 같이 `Car` 객체를 직접 생성하면서 `Car` 에 의존한다.
cars = Cars(inputView.cars().map { Car(name = it) })
또한 한 번의 턴(페이즈)마다 자동차의 위치를 출력할 때도 아래와 같이 `Car` 의 메서드를 직접 호출하고 있다.
private fun startTurn(tryCount: Int) {
outputView.resultGuide()
for (i in 1..tryCount) {
cars.carList.forEach {
it.move()
}
outputView.race(cars)
}
}
`GameController` 의 `Car` 에 대한 의존성 제거
`GameController` 의 `Car` 에 대한 의존성은 충분히 제거할 수 있다.
기존 `Cars` 를 아래처럼 변경한다.
// 기존 Cars
class Cars(val carList: List<Car>) {
// ....
}
// 리팩토링한 Cars
class Cars(inputCars: List<String>) {
private var carList: List<Car>
init {
carList = inputCars.map { Car(name = it) }
}
fun move() {
carList.forEach {
it.move()
}
}
// ....
}
생성자를 `List<Car>` 대신, `List<String>` 타입으로 변경하고, `List<Car>` 을 필드로 갖게 한다. . 그리고 `Cars` 의 인스턴스가 생성될 때 `List<Car>` 을 초기화하도록 한다.
또한 `move` 라는 함수를 만든다. `GameController` 에서 직접 `Car`의 `move` 메서드를 호출하지 않고 `Cars` 의 `move` 를 통하도록 만들면 된다.
이렇게 변경하면 `GameController` 는 `Car`의 인스턴스를 직접 생성하거나 `Car` 의 메서드를 직접 호출할 필요가 없어진다.
// 입력받은 자동차들의 문자열을 변환하는 기존 코드
cars = Cars(inputView.cars().map { Car(name = it) })
// 리팩토링 후 코드
cars = Cars(inputCars = inputView.cars())
// 한 턴씩 자동차를 움직이는 기존 코드
private fun startTurn(tryCount: Int) {
outputView.resultGuide()
for (i in 1..tryCount) {
cars.carList.forEach {
it.moveForward()
}
outputView.race(cars)
}
}
// 리팩토링 후 코드
private fun startTurn(tryCount: Int) {
outputView.resultGuide()
for (i in 1..tryCount) {
cars.move()
outputView.race(cars)
}
}
이 외에도 `OutputView` 의 메서드 `race` 의 구현에서도 문제가 있다.
`OutputView` 가 `Cars` 의 `carList` 를 참조한다.
// OutputView 의 메서드
fun race(cars: Cars) {
cars.carList.forEach {
println(it.name + START_LANE + CAR_POSITION_SYMBOL.repeat(it.position))
}
println()
}
`OutputView` 는 MVC 패턴 중 View 에 해당한다. View 에서 Model 에 대한 의존성이 존재한다는 것은 꽤 큰 문제이다. 그런데 `race`메서드 내에서는 `Cars` 의 필드에 직접 접근하고 있었다.
이는 문제가 되며, 위에서 `Cars` 의 생성자와 필드를 수정했다면 `carList` 는 `private` 이 되어, 컴파일 에러가 발생한다.
이는 `Cars` 의 `toStirng` 을 오버라이딩함으로써 해결할 수 있다.
// Cars 에 toString 을 오버라이드
override fun toString(): String =
carList.joinToString("\n") { it.toString() }
// Car 에서 toString 을 오버라이드
override fun toString(): String =
"$name$START_LANE${CAR_POSITION_SYMBOL.repeat(position)}"
위처럼 메서드를 변경하면, 아래처럼 더 이상 `OutputView` 에서 `Cars` 의 필드에 대한 참조를 하지 않아도 된다.
// OutputView 의 기존 코드
fun race(cars: Cars) {
cars.carList.forEach {
println(it.name + START_LANE + CAR_POSITION_SYMBOL.repeat(it.position))
}
println()
}
// 리팩토링 후
fun race(cars: Cars) {
println(cars)
println()
}
이렇게 되면 UML 은 아래처럼 변경된다.
`GameController` 는 `Cars` 에만 의존하며, View 나 Model 에서는 서로에 대한 의존성을 보이지 않는다.
꽤 객체지향적인 설계를 완성한 것 같다.
위 설계 그대로 전혀 문제가 없으면 좋겠지만... 테스트 코드에서 어려움이 생긴다.
프로덕션 코드에서 가시성을 제한함으로써 생기는 테스트의 어려움
그런데 문제가 있다. 위처럼 프로덕션 코드를 변경하면 Cars 의 테스트를 진행하기가 어렵다는 것이다.
기존에는 아래와 같이 테스트했다.
class CarsTest {
/* 다른 테스트들 */
@ParameterizedTest
@MethodSource("provideTestData")
fun `가장 큰 position 을 가진 car 의 이름(들)을 리스트로 리턴한다`(data: TestData) {
data.cars.move()
val result = data.cars.decideWinner()
Assertions.assertThat(result).isEqualTo(data.expected)
}
companion object {
/* ... */
@JvmStatic
fun provideTestData(): Stream<TestData> = Stream.of(
TestData(
Cars(
listOf(
Car("pobi", position = 0),
Car("woni", position = 1),
Car("jun", position = 2)
)
),
listOf("jun")
),
TestData(
Cars(
listOf(
Car("pobi", position = 2),
Car("woni", position = 2),
Car("jun", position = 2)
)
),
listOf("pobi", "woni", "jun")
),
)
}
}
}
data class TestData(val cars: Cars, val expected: List<String>)
`Cars` 가 생성자로 `List<Car>` 를 가져서 가능한 것이었다. 하지만 나는 리팩토링을 진행하면서 생성자로 `List<Car>` 를 가질 필요가 없어 제거했으므로, 위 테스트 코드는 동작하지 않는다.
그렇다면 `Cars` 의 인스턴스를 생성할 때 테스트에서만 사용할 `MoveStrategy` 의 mock 객체가 필요하다. 그렇다면, `Cars` 도 `MoveStrategy` 를 생성자로 가져야 한다.
나의 경우는 아래처럼 해결했다.
`MoveStrategy` 생성자를 더 외부에서 주입한다
먼저 `Car` 의 생성자에서 디폴트 패러미터 값으로 `UsingRandomNumberMoveStrategy` 를 갖던 것에서 데폴트 패러미터 값을 삭제한다.
// 기존
class Car(
val name: String,
var position: Int = 0,
private val moveStrategy: MoveStrategy = UsingRandomNumberMoveStrategy()
) {/*...*/}
// 수정
class Car(
val name: String,
var position: Int = 0,
private val moveStrategy: MoveStrategy
) {/*...*/}
그리고 `Cars` 에서 생성자를 주입해준다. 이 때 디폴트 패러미터 값으로 `UsingRandomNumberMoveStrategy` 을 갖도록 한다.
// 기존
class Cars(inputCars: List<String>) {/*...*/}
// 수정
class Cars(inputCars: List<String>, moveStrategy: MoveStrategy = UsingRandomNumberMoveStrategy()) class Cars(inputCars: List<String>, moveStrategy: MoveStrategy = UsingRandomNumberMoveStrategy()) {/*...*/}
테스트 코드에서는 어떤 자동차가 움직여야 할지 우리가 직접 정할 수 있어야 한다. 그러므로 `MoveStrategy` 의 메서드 `isMove` 가 패러미터로 `Car` 타입을 받을 수 있도록 만든다.
interface MoveStrategy {
fun isMove(car: Car): Boolean
}
class UsingRandomNumberMoveStrategy : MoveStrategy {
override fun isMove(car: Car): Boolean {/*...*/}
}
테스트만을 위해 프로덕션 코드를 수정하는 것은 올바르지 않다. 하지만, 미래에 생길 수 있는 요구사항 변경을 예측하면 위처럼 `isMove` 메서드의 패러미러로 `Car` 을 갖는 것은 바람직해보인다.
더 빨리 이동할 수 있는 자동차가 따로 존재하는 것은 자동차 경주 게임에 흔하게 있는 시스템이기 때문이다.
마지막으로 테스트 코드에서는 아래와 같이 테스트하면 된다.
class CarsTest {
// ...
@ParameterizedTest
@MethodSource("provideCarsAndMockStrategy")
fun `가장 큰 position 을 가진 car 의 이름(들)을 리스트로 리턴한다`(cars: Cars, expected: List<String>) {
// when
cars.move()
val result = cars.decideWinner()
// then
assertThat(result).isEqualTo(expected)
}
companion object {
private val mockMoveStrategy = object : MoveStrategy {
override fun isMove(car: Car): Boolean = car.name.contains("pobi")
}
// ...
@JvmStatic
fun provideCarsAndMockStrategy(): Stream<Arguments> = Stream.of(
Arguments.of(Cars(listOf("pobi", "woni", "jun"), mockMoveStrategy), listOf("pobi")),
Arguments.of(Cars(listOf("pobi1", "pobi2", "jun"), mockMoveStrategy), listOf("pobi1", "pobi2")),
Arguments.of(Cars(listOf("pobi1", "pobi2", "pobi3"), mockMoveStrategy), listOf("pobi1", "pobi2", "pobi3")),
)
}
}
`MoveStrategy` 타입의 `mockMoveStrategy` 라는 익명 객체를 생성했다. 이 때 자동차가 "pobi" 라는 문자열을 포함했을 때 이동하도록 규칙을 만들어주었다.
이렇게 테스트를 수행하면, 성공적으로 테스트가 통과되는 것을 알 수 있다.
최종적인 UML
최종 코드는 아래 레포제토리의 refactorAfterMission 에서 볼 수 있다.
https://github.com/sh1mj1/kotlin-racingcar-6/tree/refactorAfterMission
소감
처음 미션을 받고, 설계한 UML 이 거의 완벽하다고 생각했지만, 미션을 진행하고 이후에 리팩토링하는 과정에서 꽤 구조가 많이 변경되었다. 물론, 설계 시점부터 완벽한 설계를 하는 것은 굉장히 어렵지만, 설계 과정에서 시스템의 책임을 나누고, 여러 객체들에게 책임을 할당하는 작업과 구현을 하면서 캡슐화를 잘 수행하는 것의 중요성을 느꼈다. 책임 주도 설계를 익숙하게 해내고 설계 시점의 구조와 최종 구조가 최대한 비슷하다록 꾸준한 연습도 필요할 것 같다. 프리코스를 진행하면서 읽었던 커뮤니티의 글과 책들이 책임 주도 설계를 하는데 큰 도움이 된 것 같다.
또 기본적인 테스트 코드를 작성하여 작은 기능들에 대해 테스트를 수행해 본 경험이 굉장히 값진 시간이었다.
다음 미션에서는 테스트 코드와 관련된 중요한 개발 방법론인 TDD 에 대해서 공부해보고 가능하다면 프로젝트에 적용해보려고 한다.