💡 Intro
우테코를 시작한지 2주 정도 됐고 처음으로 글을 쓰는 것 같다. 하루에 왕복 네 시간씩 걸리는 통학을 하다보니 6시 이후로 공부하기도 공부의 흔적을 남기기도 쉽지 않다. 2주 됐는데 2달 된 것 같은 이 느낌. 짧은 시간이긴 하지만 배운 것도, 느낀 것도 많다. 시간 날때마다 작성해서 남겨봐야겠다.
🚗 자동차 경주 미션
가장 먼저 진행한 미션은 자동차 경주 미션이다. 프리코스를 진행하면서 들어갔던 스터디에서 프리코스가 끝난 이후에도 이전 미션들을 찾아보면서 준비했었는데 자동차 경주 미션도 그 중 하나였다. 그래서 요구사항이나 클래스를 구분하는 것이 굉장히 익숙했지만 그럼에도 불구하고 많은 피드백을 받았다.
우테코를 진행하면서 이전과 가장 달라진 점은 구현한 기능마다 단위 테스트를 한다는 것이다. 자동차가 전진하는 기능의 요구사항은 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우다. 무작위 값이라는 조건이 들어가게 되면 결과가 어떤 값이 나올지 모르기 때문에 테스트가 굉장히 어려워지는 문제가 발생한다. 그래서 페어와 생각했던 것은 NumberGenerator를 interface로 만들어두고 단위 테스트를 작성할 때는 테스트 파일 내에 inner class로 TestNumberGenerator를 만들어 무작위 값이 아닌 내가 넣어준 값을 반환하도록 하자는 것이었다.(아크&&뽀또 짱)
실제 구현 코드에서는 NumberGenerator를 상속받은 RandomNumberGenerator를 사용하고 이 클래스에서는 실제로 무작위 값을 뱉어내도록 했다.
또 의도한건 아니었지만 최대한 domain을 나누려고 하다보니 MVC 패턴이 지켜졌던 것 같다. View 패키지의 객체가 domain 패키지 객체에 의존할 수 있지만, domain 패키지의 객체는 View 패키지 객체에 의존하지 않도록 구현하려고 노력했다. 또 만들었던 기능에 대해서 단위 테스트를 작성하고 정상적으로 동작하는지 확인하려고 했다.
그래서 우리가 목표로 했던 것은 다음과 같다.
1. 기능 목록을 상세히 작성할 것
2. 기능에 대한 단위 테스트가 존재할 것
3. domain로직과 비즈니스로직을 나눌 것
🦾 1차 코드 리뷰 & 리팩토링
1단계 미션을 완성하고 1차 코드리뷰를 요청했다. 원래 페어 프로그래밍이라 2명이 원칙이지만 전체 인원이 홀수인 관계로 3명이 페어인 조가 있었고 내가 그랬다. 나를 담당해주셨던 리뷰어님은 7개 정도의 코멘트를 남겨주셨지만 페어들의 리뷰어님은 더 다양한 코멘트들을 달아주셨고, 우리 세 명은 마음이 잘 맞기도 했고 더 다양한 의견을 수렴하고 적용해보자는 의미에서 2단계 미션을 진행했다.(사실 셋 다 페어로 진행하는게 디폴트인줄 알았다.)
함수의 네이밍
사용자로부터 자동차의 이름과 시도할 횟수를 입력받는 역할을 하는 클래스를 InputView로 지었는데 그러고 나니 함수명을 짓는데 어떻게 지어야 할 지 고민이 되었다. 자동차의 이름을 입력받는 역할을 하는 함수의 이름을 inputCarNames()로 지었는데 그러면 inputView.inputCarNames() 와 같은 방식으로 사용해야 하기 때문에 가독성 측면에서 그다지 좋지 않다고 생각했다.
부끄럽지만 리뷰어님께 이 부분을 여쭤보았고 약간의 팁을 주셨다.
보통 도메인에 어울리는 네이밍으로 짓는게 가장 좋다.
https://grep.app과 같은 사이트를 이용하여 어떤 네이밍이 자주 쓰이는지 확인하는 것도 좋다.
입력을 받는 함수의 경우에는 아래와 같은 네이밍을 사용하기도 한다.
- awaitXxx() : 쓰레드가 block되기 때문에 가장 잘 어울린다고 생각됨.
- getXxx()
- inputXxx()
- receiveXxx()
String Template
private fun printCarState(carName: String, carLocation: Int) {
println(carName + " : " + "-".repeat(carLocation))
}
자동차의 이름과 위치를 출력하는 함수를 위와 같이 작성했었는데, String Template 기능을 활용해보라는 코멘트가 있었다. String Template 이란 문자열 안에서 외부에 있는 변수를 가져올 수 있는 방법이다.
문자열 안에서 '$'와 변수명을 연결하여 사용하면 '+'를 사용하지 않고도 간단하게 문자열을 합칠 수 있다.
private fun printCarState(name: String, location: Int) {
println("$name : ${"-".repeat(location)}")
}
그래서 위의 코드처럼 리팩토링을 진행했다.
String Template에 대해 조금 더 알아보자면
val name = "krrong"
println("${name}안녕")
// krrong안녕
변수명을 {}안에 넣어서 사용할 수도 있고, 심지어는 {}안에 식(함수)을 넣을 수 있다.
lazy() vs lateinit var
class GameController {
private val input by lazy { InputView() }
private val output by lazy { OutputView() }
private lateinit var racingManager: RacingManager
우리는 controller에서 위와 같이 InputView와 OutputView는 lazy로, RacingManager는 lateinit var로 생성하여 사용했다. 이렇게 만든 이유는 GameController를 생성할 때 RacingManager는 외부에서 주입받아 사용하지만 InputView와 OutputView는 내부에서 생성하여 사용하기 때문이었다.
또 둘의 차이를 명확하게 몰랐던 이유도 있었던 것 같다. 그래서 둘의 차이를 알아보자면 다음과 같다.
- lazy
- 값을 변경할 수 없다. (val 을 사용한다.)
- 초기화 이후 읽기 전용으로 사용할 때 사용한다.
- 옆에 적어주는 초기화 블록으로만 초기화를 진행할 수 있다.
- thread safe (처음 사용하는 thread에서 초기화를 진행하면 이후에 사용하는 thread에서 동일한 값을 가져와 사용할 수 있다.
- lateinit var
- 값을 변경할 수 있다. (var 을 사용한다.)
- 초기화 이후 값을 변경할 가능성이 있을 때 사용한다.
- 초기화하지 않고 값에 접근하려고 하면 UnInitializedPropertyAccessException 이 발생할 수 있다.
- backing field가 필요할 경우 사용할 수 있다. (는데 이 부분은 아직 이해하지 못했다.)
상수선언
게임을 진행하면서 UI에 보여주는 상수는 다양하다. 어떤 값을 요청하는지 알려주는 문구, 에러가 발생했을 때 띄워줄 문구 등이 그에 속한다. 우리는 이러한 모든 상수들을 resources/String.kt 에 모아두고 필요한 곳에서 사용하는 방식으로 구현했다.
하지만 이런 방법보다 여러 곳에서 쓰이는 상수가 아니면 연관이 있는 클래스의 companion object에 선언하는 것이 좋다고 한다. 만약 파일의 규모가 커지면 사용하는 상수 값들도 많아질테고, 그 모든 상수 값들이 하나의 파일에 몰려있다면 그 파일에 대한 의존성도 커져서 그러는 것이 아닐까하고 생각해본다.
리뷰어님이 남겨주신 말로는
상수는 한 곳에 모으기 보다는 연관이 있는 파일에 같이 두는게 좋다. 여러 곳에서 쓰이는 경우가 아니면 해당 클래스의 companion object에 선언해보자.
🦿 2차 코드 리뷰 & 리팩토링
위의 내용 외에도 테스트 코드를 포함한 여러 부분을 리팩토링 했고, step1을 머지 해주셨다. 자연스럽게 step2 미션을 진행하게 됐고 step2 브랜치를 새로 만들어 이번에는 각자 리팩토링을 진행했다.
Controller에서 UI에 대한 의존성 분리
리뷰어님의 말에 따르면 다음과 같다.
Controller에서 View를 생성한다는 것은 View에 의존한다는 것과 같다. 더 엄격하게 UI에 대한 의존성을 분리하기 위해서는 View를 Interface로 분리하고 구현체를 생성자로 주입받는 것이 더 좋다. 그렇게 해야 UI와 관련된 의존성이 분리되기 때문에 Controller에 대한 단위 테스트가 가능해진다.
위에서도 언급한 것 같지만 우리는 UI를 InputView, OutputView 클래스 로 만들었고, 이를 controller에서 생성하여 사용하도록 했다.
Controller에서 UI에 대한 의존성을 분리하기 위해 InputViewInterface, OutputViewInterface를 만들고 InputView, OutputView 클래스는 각각을 구현하도록 했다. 또, controller를 생성할 때 외부에서 InputViewInterface, OutputViewInterface를 주입받도록 변경했다. 즉, controller를 생성할 때 RacingManager, InputViewInterface, OutputViewInterface 세 개를 주입받도록 변경했다는 것이다. 그러면 ControllerTest 에서 controller를 생성할 때 TestNumberGenerator, TestInputView, TestOutputView 를 새로 생성하여 넣어줄 수 있고, 사용자에게 입력 받는 것이 아닌 내가 확인하고자 하는 값들을 넣어 테스트할 수 있게 되는 것이다.
- GameController.kt
class GameController(
private val raceManager: RaceManager,
private val inputInterface: InputInterface,
private val outputInterface: OutputInterface
)
controller를 생성할 때 외부에서 InputViewInterface, OutputViewInterface를 주입받도록 변경
- GameControllerTest.kt
package racingcar.controller
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import racingcar.domain.NumberGenerator
import racingcar.domain.RaceManager
import racingcar.racingcar.domain.RaceResultDto
import racingcar.racingcar.view.InputInterface
import racingcar.racingcar.view.OutputInterface
class GameControllerTest {
@Test
fun `runGame 함수를 통한 최종 우승자 정상 출력 확인`() {
val gameController =
GameController(RaceManager(TestNumberGenerator(mutableListOf(1, 2, 4))),
TestInputViewInterface(),
TestOutputViewInterface())
gameController.runGame()
}
class TestNumberGenerator(private val numbers: MutableList<Int>) : NumberGenerator {
override fun generateNumber(minNumber: Int, maxNumber: Int): Int {
return numbers.removeAt(0)
}
}
class TestInputViewInterface : InputInterface {
override fun inputCarNames(): List<String> {
return listOf("test1", "test2", "test3")
}
override fun inputRacingCount(): Int {
return 1
}
}
class TestOutputViewInterface(
val onPrintWinner: (List<String>) -> Unit
) : OutputInterface {
override fun printRaceResult(raceResultDto: RaceResultDto) {
}
override fun printCarsState(names: List<String>, locations: List<Int>) {
}
override fun printCarState(name: String, location: Int) {
}
override fun printWinner(names: List<String>) {
assertThat(names).isEqualTo(listOf("test3"))
}
}
}
TestNumberGenerator, TestInputView, TestOutputView 를 새로 생성하여 넣어주는 방법 사용
사용자에게 입력 받는 것이 아닌 내가 확인하고자 하는 값들을 넣어 테스트 가능
고차함수
위에 있는 테스트 코드를 보면 assert문이 테스트 본문에 존재하는 것이 아니라 다른 영역에 존재한다. 이 부분을 리뷰어님도 말씀해주셨고, 이것은 고차함수를 넘기는 방식을 통해 개선할 수 있다고 코멘트를 달아주셨다.
수정한 부분의 중요한 코드만 남겨보면 다음과 같다.
class GameControllerTest {
@Test
fun `runGame 함수를 통한 최종 우승자 정상 출력 확인`() {
// given
var actual: List<String> = emptyList()
val gameController =
GameController(
RaceManager(TestNumberGenerator(mutableListOf(1, 2, 4))),
TestInputViewInterface(),
TestOutputViewInterface { actual = it }
)
// when
gameController.runGame()
// then
assertThat(actual).containsExactly("test3")
}
class TestOutputViewInterface(
val onPrintWinner: (List<String>) -> Unit
) : OutputInterface {
override fun printWinner(names: List<String>) {
onPrintWinner(names)
}
}
}
TestOutputViewInterface는 생성할 때 함수 하나를 인자로 받는다. 이 함수는 List<String>을 인자로 받는다.
GameController를 생성할 때 { actual = it } 을 넣어줌으로써 onPrintWinner가 하는 일은 { actual = it }이 되는 것이다. 그러면 it에는 names가 들어가고 printWinner함수를 통해 출력하는게 아닌 actual에 it(names)을 넣어줄 수 있는 것이다.
회고
프리코스와 최종 코딩테스트를 진행하고 한 달이 넘는 시간동안 코틀린을 공부하지 않았기 때문에 내가 원하는 기능을 마음대로 구현하기가 어려웠고, 코틀린에서 제공하는 좋은 함수들을 사용하지 못했다. 하지만 페어였던 아크와 뽀또가 다양한 함수를 사용하는 방법, 좀 더 코틀린스럽게 코드를 작성하려고 노력해서 페어들한테서도 많이 배울 수 있었다.
구현하면서 당연하다고 생각했던 것이나 의문점이 드는 부분이 많았다. 이 부분에 대해 리뷰어님이 꼼꼼하게 커밋을 확인해주시고 본인의 생각을 달아주시면서 더 좋은 방향성과 공부방법을 알게 되었다. 페어들과 같은 코드를 가지고 PR을 날렸지만 다른 리뷰어님의 리뷰를 함께 보면서 생각의 깊이가 깊어지고 시야가 넓어질 수 있겠다는 생각이 들었다. 코드리뷰의 문화는 정말 좋은 것 같다..!
생각지도 못한 MVC 패턴에 대해서도 학습할 수 있었고, 컨트롤러로부터 UI에 대한 의존성을 더 엄격하게 분리하는 방법, 테스트 코드 작성요령을 배워서 너무 재밌었다.
함수명, 변수명을 잘 지어야 하는 이유에 대해서 몸소 느낄 수 있었다. (하지만 이름 짓는건 여전히 어렵다.)
자동차 경주 1단계 PR
자동차 경주 2단계 PR