Skip to content

[Step5]자동차 경주(리팩터링) #936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: choi-ys
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/SETP3-README.md → docs/STEP3-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@
* [x] Service 계층에 위치한 난수 생성부 분리
* Service 계층에 위치한 난수 생성부로 인해 해당 계층이 테스트 하기 어려운 문제를 개선하기 위해 난수 생성부를 Service 계층 분리 후, 외부에서 생성된 난수를 각 `Car`객체에 주입하여 해당 계층을 테스트 가능하도록 수정
* 난수 생성부 위치 변경
* AS*IS : `Car` 객체에서 경주 진행 메서드 호출 시, 난수를 생성
* TO*BE : Car 객체 생성 시, 진행 라운드 만큼 난수 생성 후 Car 객체 필드에 주입
* AS-IS : `Car` 객체에서 경주 진행 메서드 호출 시, 난수를 생성
* TO-BE : Car 객체 생성 시, 진행 라운드 만큼 난수 생성 후 Car 객체 필드에 주입
* 각 차량의 자동차 경주 진행 방식 변경
* AS*IS : 각 차량에서 현재 라운드 진행을 위해 난수 생성 및 생성된 난수에 따른 진행거리 누적 여부 판별
* TO*BE : 외부에서 각 차량에 진행 라운드 만큼 아미 생성된 난수를 판별하여 진행 거리 누적 여부만 판별
* AS-IS : 각 차량에서 현재 라운드 진행을 위해 난수 생성 및 생성된 난수에 따른 진행거리 누적 여부 판별
* TO-BE : 외부에서 각 차량에 진행 라운드 만큼 아미 생성된 난수를 판별하여 진행 거리 누적 여부만 판별
* 로직 변경으로 인해 깨지는 TC 수정
* 생성된 난수 목록 일급 컬렉션 정의
* 진행 라운드 만큼 생성된 난수 목록이 주입되는 일급 컬렉션
Expand Down
61 changes: 61 additions & 0 deletions docs/STEP4-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,64 @@
* 우승자 도메인 정의
* 경주 완료 후 자동차 객체 목록을 통해 우승자 객체 생성 및 우승자 목록 추출
* 경주를 완료한 자동차 객체 목록을 통해 우승한 차량의 이름 추출 TC 작성

## 코드 리뷰 피드백 내용 정리
* [x] 별도의 역할 없이 객체를 포장하고 있는 `Cars`일급 컬렉션과 참가한 자동차 목록으로 부터 우승자를 산출하는 `Winners` 일급 컬렉션 역할을 병합

* [x] 각 라운드 진행 방식 변경
* AS-IS : `Car` 객체에 미리 생성된 난수를 판별하여 주행거리를 누적
* TO-BE : 매 라운드 마다 발생된 난수를 판별한 후, 차량 주행거리를 누적
* [x] 추상화를 통해 난수에 대한 의존성으로 테스트 하기 어려운 현상 개선
* [x] 난수 발생 부를 추상화하여 운영 환경에 주입되는 구현체와 테스트 환경에 주입되는 구현체를 별도로 구현한 뒤, 각 실행 환경에 맞는 구현체를 선택하여 주입함으로써 난수로 인해 테스트 하기 어려운 문제 개선
* 추상화된 난수 생성부 주입을 통한 자동차 경주 진행
* [x] 자동차 경주를 진행하는 `RacingService` 객체에 추상화된 난수 생성 부의 구현체를 주입하여 생성한 후, 각 라운드 진행 시 운영 환경에서는 실제 난수 생성 구현체가 동작하고, 테스트 환경에서는 난수 발생 시 의도한 수를 반환하는 구현체가 동작할 수 있도록 수정
* [x] 난수 생성 부 추상화에 따른 `Car`객체에 자동차를 표현하기 어색한 `RandomNumbers` 필드 제거

* 테스트 표현 개선
* [x] 도메인 로직을 활용한 테스트 결과 검증 부를 하드코딩된 값으로 변경
* 테스트 검증 부가 도메인 로직을 참조하고 있음에 따라 로직 변경 시 테스트로서의 역할을 제대로 수행할 수 없는 문제점 개선을 위해 테스트 검증 부를 하드코딩된 값으로 변경
* AS-IS :
```kotlin
given.winnerNames() shouldBe "${두_번째_차량.name}, ${세_번째_차량.name}"
```
* TO-BE :
```kotlin
given.winnerNames() shouldBe "두 번째 차량, 세 번째 차량"
```
* [x] (테스트 픽스처 활용으로 인해 테스트 코드가 결합되는 현상 개선)[https://jojoldu.tistory.com/611]
* 테스트 픽스처로 인해 테스트 코드가 결합되어 테스트 코드 유지보수가 복잡해지는 현상 개선을 위해, 각 테스트에 필요한 fixture 생성부를 private 메서드로 구성

## 2차 코드 리뷰 피드백 내용 정리
* Car 도메인 수정
* [x] `variable`로 선언된 `distance`(누적 주행 거리) 속성의 외부 변경을 제한하기 위해 누락된 `private` 접근 제어자 추가
* [x] 미사용 상수 제거 및 `distance` 속성의 초기값 상수 추가
* [x] 경주가 완료된 차량의 용이한 테스트를 위해 `Car` 도메인의 `distance` 속성의 초기값 설정이 가능하도록 수정
* [x] 우승자 산출 방식 변경 : 해당 차량의 누적 주행거리가 최대값인지를 확인하는 함수 작성
* AS-IS : 우승자 산출을 위해 경주에 기존 `Cars` 객체에서 각 원소(Car)를 순회하며 distance 속성에 접근하여 최대 누적 주행거리를 비교
* TO-BE : `Cars` 객체에서 각 원소(Car)를 순회하며 누적 주행거리가 최대값인 원소를 추출
* [x] 우승자 산출을 위한 `Cars` 도메인 TC의 Car.race() 호출 부 개선
* 우승자 산출 테스트를 위해 Car.race()를 호출하여 누적 주행거리를 설정하는 테스트 방식을 `Car` 도메인의 `distance` 속성 초기값 설정을 통해 제거하여 테스트 간소화
* 경주를 완료한 차량 생성을 위해 더 이상 불필요한 `test fixture` 생성 함수 제거

* Cars 도메인 수정
* [x] private 생성자의 `elements` 프로퍼티를 반환하는 getter 제거하고, 해당 프로퍼티를 public 으로 변경
* [x] ','를 기준으로 우승자 이름을 묶어내는 `Cars` 도메인의 함수를 View 계층으로 이동
* [x] Service 계층에서 `Cars` 도메인의 각 원소(Car)에 접근하여 경주를 진행하는 방식에서 Cars 도메인에 메세지를 보내어 각 원소(Car)의 race() 함수를 호출함으로써 도메인이 메세지를 받아 직접 로직을 처리 하도록 수정

* [x] 명확하지 않은 난수 생성기 인터페이스 이름 변경
* AS-IS : RandomNumber
* TO-BE : NumberGenerator

* [x] 명확하지 않은 Given/When 테스트 표현 개선

* 도메인 계층과 서비스 계층에 구현된 출력 부를 View 계층으로 이동
* AS-IS : `Cars` 도메인에서 직접 각 라운드 결과를 출력하고, `RacingService` 계층에서 우승자를 출력
* TO-BE :
* [x] 각 `Car` 객체가 해당 라운드의 경주를 완료 하면 각 라운드 결과를 기록한 객체(RoundResult)를 반환
* [x] `Cars` 객체는 `Car` 객체로부터 각 라운드 결과 객체를 반환받아 합산한 후, 전체 라운드 결과 객체(RoundResults)를 반환
* [x] `RacingService` 계층은 반환받은 전체 라운드 결과 객체를 출력부에 전달하여 로직과 출력부를 분리
* [x] 각 라운드 결과 객체에 현재 `Car` 객체의 상태값을 저장하기 위한 DeepCopy 함수 구현
* [x] Cars 도메인 계층에서 출력부를 분리함에 따라, 출력부에서 접근하고 있던 `Cars` 도메인의 `elements` 프로퍼티의 접근 제한자를 `private`에서 `public`으로 변경
* 개선 사항 :
* 출력을 위해 public 으로 열려있는 도메인 계층의 프로퍼티의 접근 제한자를 private 으로 변경
* 서비스 계층과 도메인 계층은 자동차 경주만을 위한 코드가 작성되고, 출력 부는 로직이 완료 된 후 반환된 객체를 넘겨 받아 결과를 출력함으로써 출력부에 대한 로직 실행부의 의존성 제거
Comment on lines +62 to +95
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피드백 내용들을 정말 열심히 곱씹으시며 많은 고민을 하신 것들이 잘 느껴지는 대목이네요 😊👍👍

Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@ import step3.racingcar.domain.Cars
import step3.racingcar.domain.PlayInfo
import step3.racingcar.service.RacingCarService
import step3.racingcar.utils.CarGenerator
import step3.racingcar.utils.RandomNumberGenerator.generateRandomNumberToCarByRound
import step3.racingcar.utils.RandomNumberGenerator
import step3.racingcar.view.InputView.Companion.inputJoinerCarsGuideMessagePrinter
import step3.racingcar.view.InputView.Companion.inputRoundCountGuideMessagePrinter
import step3.racingcar.view.ResultView

class RacingCarController {
private val racingCarService: RacingCarService = RacingCarService()
private val racingCarService: RacingCarService = RacingCarService(RandomNumberGenerator())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service 하위의 모든 객체에서 이제 생성되는 숫자에 대해 개발자가 제어할 수 있겠네요 :) 👍👍


fun gameStart() {
val carNames = inputJoinerCarsGuideMessagePrinter()
val totalRound = inputRoundCountGuideMessagePrinter()
val cars = Cars.of(CarGenerator.generate(carNames))
generateRandomNumberToCarByRound(cars, totalRound)
val playInfo = PlayInfo(cars, totalRound)
racingCarService.play(playInfo)
val playResult = racingCarService.play(playInfo)
ResultView.printResult(playResult)
}
}
16 changes: 6 additions & 10 deletions src/main/kotlin/step3/racingcar/domain/Car.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
package step3.racingcar.domain

private const val CAR_ID_DELIMITER = "-"
private const val MOVE_CRITERIA = 4
private const val INITIAL_DISTANCE = 0

class Car(val name: String) {
private var randomNumbers: RandomNumbers = RandomNumbers()
var distance = 0

fun addRandomNumber(randomNumber: Int) = randomNumbers.add(randomNumber)

fun race(currentRoundIndex: Int) {
val randomNumberByCurrentRound: Int = randomNumbers[currentRoundIndex]
if (isMove(randomNumberByCurrentRound)) {
class Car(val name: String, var distance: Int = INITIAL_DISTANCE) {
Comment on lines 5 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여전히 외부에서 distance 값을 변형시킬 수 있습니다 😥
생성자에 반드시 멤버변수를 동시에 선언할 필요는 없답니다 :)

아래 처럼 작성해볼 수 있어요! 참고 정도로만 봐주시면 좋겠습니다 🙂

class Car(
    val name: String,
    initialDistance: Int = 0,
) {
    var distance: Int = initialDistance
        private set

    ...
}

fun race(randomNumber: Int) {
if (isMove(randomNumber)) {
distance++
}
}

fun isMaximumDistance(maxDistance: Int): Boolean = distance == maxDistance
Comment on lines 12 to +13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 함수명은 내용과 사뭇 달라 보입니다.
car의 거리가 파라미터와 같은 지 물어보는 내용이기 때문에,
hasDistance(distance) 정도는 어떨까요?
distance 값 정도를 ==로 비교하는 것은 비교적 간단한 연산이기 때문에, 외부에서 꺼내서 비교해도 괜찮다고 생각해요 :)

private fun isMove(randomNumber: Int): Boolean = randomNumber >= MOVE_CRITERIA
fun copy(): Car = Car(name, distance)
}
24 changes: 21 additions & 3 deletions src/main/kotlin/step3/racingcar/domain/Cars.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
package step3.racingcar.domain

class Cars private constructor(private val elements: List<Car>) {
fun elements(): List<Car> = elements
fun size(): Int = elements.size
operator fun get(index: Int) = elements[index]

operator fun get(index: Int): Car = elements[index]

fun race(numberGenerator: NumberGenerator): RoundResult {
val roundResult = RoundResult()
elements.forEach {
it.race(numberGenerator.value())
roundResult.add(it)
}
return roundResult
}

fun winnerNames(): List<String> {
val maxDistance = findMaxDistance()
return elements
.filter { it.isMaximumDistance(maxDistance) }
.map { it.name }
}

private fun findMaxDistance(): Int = elements.maxOf { it.distance }

companion object {
fun of(elements: List<Car>) = Cars(elements)
fun of(elements: List<Car>): Cars = Cars(elements)
}
}
10 changes: 10 additions & 0 deletions src/main/kotlin/step3/racingcar/domain/NumberGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package step3.racingcar.domain

interface NumberGenerator {
fun value(): Int

Comment on lines +3 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kotlin에서 1개 함수만 있는 인터페이스를 람다로 사용하고 싶을때는,
fun interface 키워드를 사용하면 SAM(Single Abstract Method) 기능을 사용할 수 있어요! :)

companion object {
const val RANGE_START = 1
const val RANGE_END = 9
}
}
7 changes: 0 additions & 7 deletions src/main/kotlin/step3/racingcar/domain/RandomNumbers.kt

This file was deleted.

9 changes: 9 additions & 0 deletions src/main/kotlin/step3/racingcar/domain/RoundResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package step3.racingcar.domain

class RoundResult {
private val elements: MutableList<Car> = mutableListOf()

operator fun get(index: Int): Car = elements[index]
fun add(car: Car) = elements.add(car.copy())
fun size(): Int = elements.size
}
Comment on lines +3 to +9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 라운드 별 결과에서는 직접 Car를 가지지 않고, 라운드에 대한 정보만 가져보는 것은 어떨까요?

12 changes: 12 additions & 0 deletions src/main/kotlin/step3/racingcar/domain/RoundResults.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package step3.racingcar.domain

class RoundResults private constructor(val totalRound: Int, val cars: Cars) {
private val elements: MutableList<RoundResult> = mutableListOf()

operator fun get(index: Int) = elements[index]
fun add(roundResult: RoundResult) = elements.add(roundResult)

companion object {
fun of(playInfo: PlayInfo): RoundResults = RoundResults(playInfo.totalRound, playInfo.cars)
}
}
22 changes: 0 additions & 22 deletions src/main/kotlin/step3/racingcar/domain/Winners.kt

This file was deleted.

28 changes: 16 additions & 12 deletions src/main/kotlin/step3/racingcar/service/RacingCarService.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
package step3.racingcar.service

import step3.racingcar.domain.Car
import step3.racingcar.domain.Cars
import step3.racingcar.domain.NumberGenerator
import step3.racingcar.domain.PlayInfo
import step3.racingcar.domain.Winners
import step3.racingcar.view.ResultView.Companion.printRoundResult
import step3.racingcar.view.ResultView.Companion.printWinner
import step3.racingcar.domain.RoundResult
import step3.racingcar.domain.RoundResults

class RacingCarService {
fun play(playInfo: PlayInfo) {
class RacingCarService(private val numberGenerator: NumberGenerator) {
fun play(playInfo: PlayInfo): RoundResults {
val roundResults = RoundResults.of(playInfo)
repeat(playInfo.totalRound) {
playEachRound(it, playInfo.cars)
val playEachRoundAndReturn = playEachRound(playInfo.cars)
roundResults.add(playEachRoundAndReturn)
}
printWinner(Winners.of(playInfo.cars))
return roundResults
}

private fun playEachRound(currentRoundIndex: Int, cars: Cars) {
cars.elements().forEach {
it.race(currentRoundIndex)
}
printRoundResult(currentRoundIndex, cars)
fun playEachRound(cars: Cars): RoundResult =
cars.race(numberGenerator)

fun playEachRoundByCar(car: Car) {
val randomNumber = numberGenerator.value()
car.race(randomNumber)
}
}
22 changes: 5 additions & 17 deletions src/main/kotlin/step3/racingcar/utils/RandomNumberGenerator.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
package step3.racingcar.utils

import step3.racingcar.domain.Car
import step3.racingcar.domain.Cars
import step3.racingcar.domain.NumberGenerator
import step3.racingcar.domain.NumberGenerator.Companion.RANGE_END
import step3.racingcar.domain.NumberGenerator.Companion.RANGE_START

object RandomNumberGenerator {
private const val RANGE_START = 1
private const val RANGE_END = 9

fun generateRandomNumberToCarByRound(cars: Cars, totalRound: Int) {
cars.elements().forEach {
generateRandomNumberToEachCar(it, totalRound)
}
}
class RandomNumberGenerator : NumberGenerator {
override fun value(): Int = generate()

private fun generate(): Int = (RANGE_START..RANGE_END).random()

private fun generateRandomNumberToEachCar(car: Car, totalRound: Int) {
repeat(totalRound) {
car.addRandomNumber(generate())
}
}
}
30 changes: 22 additions & 8 deletions src/main/kotlin/step3/racingcar/view/ResultView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,37 @@ package step3.racingcar.view

import step3.racingcar.domain.Car
import step3.racingcar.domain.Cars
import step3.racingcar.domain.Winners
import step3.racingcar.domain.RoundResult
import step3.racingcar.domain.RoundResults

class ResultView {
companion object {
private const val SCORE_BAR = "-"
private const val ROUND_COMPLETE_GUIDE_MESSAGE_FORMAT = "%d 라운드가 종료되었습니다."
private const val WINNER_GUIDE_MESSAGE_FORMAT = "%s 가 최종 우승했습니다."
private const val WINNER_NAME_JOINING_SEPARATOR = ", "

fun printRoundResult(currentRoundIndex: Int, cars: Cars) {
roundCompleteGuideMessage(currentRoundIndex)
cars.elements().forEach(::printEachCarRoundResult)
fun printResult(roundResults: RoundResults) {
repeat(roundResults.totalRound) {
roundCompleteGuideMessage(it)
printRoundResult(roundResults[it])
}
printWinner(roundResults.cars)
}

private fun printRoundResult(roundResult: RoundResult) {
repeat(roundResult.size()) {
printEachCarRoundResult(roundResult[it])
}
}

private fun roundCompleteGuideMessage(currentRoundIndex: Int) {
println()
println(ROUND_COMPLETE_GUIDE_MESSAGE_FORMAT.format(currentRoundIndex.plus(1)))
}

private fun printEachCarRoundResult(car: Car) {
private fun printEachCarRoundResult(car: Car) =
println("${car.name} : ${distanceToScore(car.distance)}")
}

private fun distanceToScore(distance: Int): StringBuilder {
val result: StringBuilder = StringBuilder()
Expand All @@ -32,9 +42,13 @@ class ResultView {
return result
}

fun printWinner(winners: Winners) {
private fun printWinner(cars: Cars) {
println()
println(WINNER_GUIDE_MESSAGE_FORMAT.format(winners.names))
val formattedWinnerNames = formatToWinnerNames(cars.winnerNames())
println(WINNER_GUIDE_MESSAGE_FORMAT.format(formattedWinnerNames))
}

private fun formatToWinnerNames(winnerNames: List<String>): String =
winnerNames.joinToString(WINNER_NAME_JOINING_SEPARATOR)
}
}
Loading