[우테코 6기 - 안드로이드] 프리코스 1주차 숫자야구 회고와 오브젝트
23년 10월 19일 (목)부터 시작한 우아한 테크 코스의 프리코스가 4주가 지나고, 11월 15일(수) 에 끝이 났다.
저번 기수인 5기 때는 프리코스를 3주동안 3개의 미션을 진행했다고 했지만, 이번 6기에는 4주동안 4개의 미션을 진행했다.
현재 이 글을 쓰고 있는 시점은 우테코 프리코스가 모두 종료한 시점이다. 미션이 모두 종료했으니 이제 1주차 미션부터 회고록을 작성하면서 배운 내용과 경험, 감정들을 정리해보고자 한다.
우테코에 참여하게 된 계기는 학교 동기의 친구의 추천이었다. 우테코는 객체지향의 정수를 배울 수 있으며 프리코스 때부터 관련 설계에 대해 많은 것을 배울 수 있었다 했다. 그래서 그 친구가 추천한 책인 '오브젝트 코드로 이해하는 객체지향 설계' 를 읽으면서 프리코스를 진행했다.
"오브젝트" 에서 말하는 객체지향 설계
오브젝트의 앞 부분에서는 아래처럼 설명하고 있다.
- 객체지향 시스템은 자율적인 객체들의 공동체이다.
- 한 객체가 다른 객체에게 메시지를 전송하여 협력한다.
- 각 객체는 협력에 참여하기 위해 특정 책임(객체가 수행하는 행동)을 가진다.
- 응집도가 높고, 결합도가 낮은 프로그램으로 만들어야 한다.
- 객체는 자신의 데이터를 스스로 처리하는 자율적인 존재이며, 내부의 상태를 캡슐화하고, 오직 메시지를 통해서만 다른 객체와 상호작용해야 한다.
즉, 객체지향 시스템에서 가장 중요한 것은 객체들의 협력과 각 객체들이 가지는 책임이다. 각 객체들에게 올바른 책임을 부여하면서 시스템을 설계하는 것을 책임 주도 설계(Responsibility-Driven Design, RDD) 라고 한다.
그렇다면, 당연히 "어떻게 자율적인 객체를 만들 수 있는가?", 즉, "특정 책임을 과연 어떤 객체에게 할당하는 게 자율적인 객체를 만드는 것인가?" 가 중요할 수 밖에 없다.
이에 대해서는 여러 원칙과 패턴을 제공하고 있다.
- INFORMATION EXPERT(정보 전문가) 패턴: 어떤 책임을 수행하는데 필요한 정보를 가장 잘 아는 전문가에게 책임을 할당하라.
- CREATOR 패턴: 객체를 생성할 책임을 어떤 객체에게 할당할지 안내해주는 패턴.
- LOW COUPLING 패턴: 같은 기능을 하는 설계가 여러 개 보인다면, 설계의 전체적인 결합도가 낮게 유지되도록 할당하라.
- HIGH COHENSION 패턴: 같은 기능을 하는 설계가 여러 개 보인다면, 응집도가 높은 객체를 갖도록 할당하
- 디미터 법칙: 객체 내부에 강하게 결합되지 않도록 협력 경로를 제한하라.
- 묻지 말고 시켜라 법칙: 객체의 상태에 관해 묻지 말고, 원하는 것을 시켜라
- .. 등
절차지향적인 첫번째 구현 코드
나는 이전에 어떤 시스템을 설계하는 시점에서부터 책임 주도 설계를 했던 경험이 없다. 그렇기 때문에 처음부터 객체지향적인 설계를 하는 것은꽤 어려울 것이라고 생각했다. 실제로 "오브젝트" 책에서도 책임 주도 설계에 익숙해지기 위해서는 많은 노력과 시간이 필요하다고 한다. 그래서 책의 Chap.5 에서는 리팩토링(Refactoring) 이라는 기법을 소개하고 있다.
리팩토링은 최대한 빨리 목적한 기능을 동작하도록 코딩한 후, 시스템의 기능은 수정하지 않고서 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮추는 등 내부 구조를 변경시키는 방법이다. 그래서 나는 이러한 방법으로 과제를 수행하기로 결정했다.
그래서 처음 작성한 코드는 아래와 같다.
package baseball
import camp.nextstep.edu.missionutils.Console.readLine
import camp.nextstep.edu.missionutils.Randoms.pickNumberInRange
fun main() {
println("숫자 야구 게임을 시작합니다.")
// A. 컴퓨터는 사용자가 맞힐 정답을 생성한다.
val answer = mutableListOf<Int>()
while (answer.size < 3) {
val randomNum = pickNumberInRange(1, 9)
if (!answer.contains(randomNum)) {
answer.add(randomNum)
}
}
println(answer)
while (true) {
var ballCnt = 0
var strikeCnt = 0
// B: 사용자의 수를 입력받는다.
print("숫자를 입력해주세요 : ")
val input = readLine()
// B-2 유효성 검증
if (input.length != 3) {
throw IllegalArgumentException("입력한 수가 3자리가 아닙니다.")
}
if (input.contains("""[^123456789]""".toRegex())) {
throw IllegalArgumentException("유효하지 않은 형식입니다.")
}
val numSet = input.toCharArray().toSet()
if (numSet.size < 3) {
throw IllegalArgumentException("중복된 수를 가집니다.")
}
// C: 정답과 사용자의 입력을 비교해서 결과를 리턴받는다.
for (i in 0..2) {
if (answer.contains(input[i] - '0')) {
ballCnt++
}
if (input[i] - '0' == answer[i]) {
strikeCnt++
}
}
ballCnt -= strikeCnt
// C-2 모두 맞히지 못했을 경우
if (ballCnt == 0 && strikeCnt == 0) {
println("낫싱")
} else if (strikeCnt == 0) {
println("${ballCnt}볼")
} else if (ballCnt == 0) {
println("${strikeCnt}스트라이크")
} else {
println("${ballCnt}볼 ${strikeCnt}스트라이크")
}
// C-1 모두 맞힌 경우
if (strikeCnt == 3) {
println("""3개의 숫자를 모두 맞히셨습니다! 게임 종료
|게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
""".trimMargin())
when (readLine().toIntOrNull()) {
1 -> {
answer.clear()
while (answer.size < 3) {
val randomNum = pickNumberInRange(1, 9)
if (!answer.contains(randomNum)) {
answer.add(randomNum)
}
}
println(answer)
continue
}
2 -> break
else -> throw IllegalArgumentException("유효하지 않은 형식입니다.")
}
}
}
}
위처럼 `Application.kt` 파일에 모든 코드를 작성했다.
프리코스 1주차의 미션에서는 기능을 구현하기 전에 기능 목록을 만들고, 기능 단위로 커밋하는 방식으로 진행하라는 요구사항이 있었다. 비록 위 코드가 어떠한 메서드로도 나뉘어져 있지 않지만, 구현을 진행하기 전에, 전체 시스템의 기능을 어떠한 책임으로 나뉘어질 수 있는지 생각하고, 기능 목록을 작성했다.
그리고 기능 목록을 보면서 한 기능씩 코드를 작성했기 때문에 조금이나마 책임과 협력에 대해서 생각을 할 수는 있었다.
첫번째로 리팩토링한 결과
이후, 위에서 작성한 코드를 리팩토링을 시작했다.
1차 리팩토링의 결과 코드는 여기에서 볼 수 있다.
책임을 바탕으로 생각한 후, 크게 아래처럼 나누었다.
- `AnswerGenerator`: 정답을 생성해주는 책임.
- `NumberValidator`: 입력한 세자리 수를 검증해주는 책임.
- `NumberComparator`: 입력과 정답을 비교해서 볼과 스트라이크를 결정해주는 책임.
- `GameRestartHandler`: 입력한 커맨드에 따라 재시작 여부를 판단해주는 책임.
- `GameController`: 게임을 시작하는 역할.
결론적으로 아래와 같이 설계가 변경되었다.
하지만 위 코드도 관점에 따라 책임이 덜 분리되었다고도 할 수 있다.
게임은
- 게임 시작 안내문을 출력
- 사용자가 맞추어야 하는 정답을 생성
- 사용자가 숫자를 입력
- 사용자의 입력의 유효성을 검증
- 입력과 정답이 비교해서 정답을 판단
- 입력에 대해 볼, 스트라이크를 출력
- 게임이 종료되면,
- 관련 안내문 출력
- 사용자가 재시작 여부를 입력
- 재시작, 혹은 종료를 수행. 관련 안내문 출력
그런데 위의 코드에서는 기본적으로 사용자 입출력을 처리하는 책임이 숫자야구 게임의 핵심 비즈니스 로직과 섞여 있다.
class GameController {
private val answerGenerator = AnswerGenerator()
private val numberValidator = NumberValidator()
private val numberComparator = NumberComparator()
private val gameRestartHandler = GameRestartHandler()
fun startGame() {
println("숫자 야구 게임을 시작합니다.")
do {
startTurn()
printEndMsg()
} while (gameRestartHandler.restart(readLine()))
}
private fun startTurn() {
val answer = answerGenerator.generated() // A. 컴퓨터는 사용자가 맞힐 정답을 생성한다.
println(answer)
while (true) {
print("숫자를 입력해주세요 : ")
val input = readLine() // B: 사용자의 수를 입력받는다.
numberValidator.validate(input) // B-2 유효성 검증
val result = numberComparator.compare(input, answer) // C: 정답과 사용자의 입력을 비교해서 결과를 리턴받는다.
println(result)
if (result.strike == DIGIT_NUMBER) {
break
}
}
}
private fun printEndMsg() {
println(
"""${DIGIT_NUMBER}개의 숫자를 모두 맞히셨습니다! 게임 종료
|게임을 새로 시작하려면 ${RESTART_CMD}, 종료하려면 ${EXIT_CMD}를 입력하세요.
""".trimMargin()
)
}
}
물론, 이 정도면 충분히 분리되어 있다고 할 수도 있다. 다른 클래스에서는 입출력에 대해서 전혀 알지 못하고 있고, 단지 `GameController` 에서 입출력을 담당하고, 각자의 책임을 가지는 객체들을 사용하고 있기 때문이다.
하지만 입출력만을 담당하는 객체가 따로 없기 때문에, 이에 대해서만 변경하고 싶다면 `GameController` 의 코드를 직접 수정해야 한다.
만약, 게임을 처음 시작햇을 때 `"숫자 야구 게임을 시작합니다"` 라는 안내문을 출력하는 것에서, `"숫자 야구 START!"` 라는 안내문으로 변경이 된다면, 직접 `GameController` 의 `startGame()` 메서드 안에 작성되어 있는 코드를 수정해야 한다.
이는 충분히 객체지향적인 코드를 작성하지 않았기 때문에 생기는 나쁜 유지보수성의 결과이다.
물론, 이 정도의 작은 시스템에서는 큰 문제가 되지는 않는다.
두번째로 리팩토링 - MVC 패턴
사실, 1차 리팩토링 결과를 보기 전에는 입출력에 대해 분리할 생각을 하지 못했었다. 그런데 프리코스 커뮤니티에서 MVC 패턴에 대한 이야기를 보니, MVC 패턴으로 리팰토링 해야 겠다고 생각했다.
이전에 안드로이드 앱 프로젝트를 하면서, MVC 패턴으로 만들어도 보고, 안드로이드 진영에서 자주 사용하는 패턴인 MVC, MVP, MVVM 을 따로 공부하고, 사용도 해보았다. 하지만, 프리코스 미션을 MVC 패턴으로 만드려고 하니, 갈피를 잡기 어려웠다.
그래서 먼저 안드로이드 진영에 국한되지 않는 일반적인 MVC 패턴에 대해 다시 공부해 보았습니다.
https://sh1mj1-log.tistory.com/181
결과적으로 MVC 패턴으로 리팩토링한 결과는 아래와 같습니다.
관련 코드는 아래에서 볼 수 있습니다.
https://github.com/sh1mj1/kotlin-baseball-6/tree/sh1mj1
정말 객체지향적으로 리팩토링되었나?
위의 형태로까지 리팩토링한 후, 미션을 제출했지만, 나의 코드가 정말 객체지향적으로 설계되었는지를 고민해보았습니다.
하지만, 제 생각에는 그렇지 않은 것 같습니다.
캡슐화 문제
Computer 클래스와 추측에 의한 설계 전략(Design-by Guessing Strategy)
아래 코드는 `GameController` 에서의 코드이다.
class GameController {
private val numberValidator = NumberValidator()
private val computer = Computer()
private val inputNumberView = InputNumberView()
private val inputCmdView = InputCmdView()
private val outputGuideView = OutputGuideView()
private val outputResultView = OutputResultView()
fun startGame() {
outputGuideView.showStartMsg()
do {
startTurn()
outputGuideView.showEndMsg()
} while (inputCmdView())
}
private fun startTurn() {
computer.generateAnswer() // A. 컴퓨터는 사용자가 맞힐 정답을 생성한다.
while (true) {
computer.input = inputNumberView.inputNumber() // B: 사용자의 수를 입력받는다.
numberValidator.validate(computer.input) // B-2 유효성 검증
val result = computer.getResult() // C: 정답과 사용자의 입력을 비교해서 결과를 리턴받는다.
outputResultView.showResult(result) // C-2 결과 출력
if (result.strike == DIGIT_NUMBER) {
break
}
}
}
}
여기서 아래 코드에 집중해보자.
computer.input = inputNumberView.inputNumber() // B: 사용자의 수를 입력받는다.
numberValidator.validate(computer.input) // B-2 유효성 검증
여기서 `input` 은 사용자의 입력을 받아서 저장하고, 해당 입력의 유효성을 검증하고 있다.
`Computer` 클래스의 코드는 아래와 같다.
class Computer {
var input: String = ""
private var answer: List<Int> = emptyList()
private val numberComparator = NumberComparator()
fun generateAnswer() {
val answer = mutableListOf<Int>()
while (answer.size < DIGIT_NUMBER) {
val randomNum = Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE)
if (!answer.contains(randomNum)) {
answer.add(randomNum)
}
}
this.answer = answer.toList()
}
fun getResult(): BallAndStrike {
return numberComparator.compare(input, answer)
}
}
그리고 `NumberValidator` 클래스는
class NumberValidator {
fun validate(input: String) {
validateLength(input)
validateFormat(input)
validateDuplication(input)
}
private fun validateLength(input: String) {
if (input.length != DIGIT_NUMBER) {
throw IllegalArgumentException(WRONG_DIGIT_COUNT)
}
}
private fun validateFormat(input: String) {
if (input.contains("""[^$START_INCLUSIVE -$END_INCLUSIVE]""".toRegex())) {
throw IllegalArgumentException(INVALID_FORMAT)
}
}
private fun validateDuplication(input: String) {
if (input.toCharArray().toSet().size < DIGIT_NUMBER) {
throw IllegalArgumentException(DUPLICATE_INPUT)
}
}
}
객체지향 설계를 잘 알고 있는 사람이라면, 단번에 문제가 무엇인지를 알 수 있을 것이다.
`Computer` 클래스에서는`getResult()` 라는 메서드 내에서만 `input` 이라는 변수를 사용하고 있다.
그러므로, 굳이 `GameController` 에서 `input` 이라는 값을 직접 수정(`set`) 하지 않아도 된다.
"오브젝트" 에서는 이렇게 설계 시 협력이라는 문맥을 고려하지 않고, 캡슐화를 위반하는 과도한 접근자/수정자 (`getter`/`setter`) 를 가지게 되는 경우를 "추측에 의한 설계 전략(Design-by Guessing Strategy)" 라고 한다.
개발자가 객체들의 협력에 대해 정확히 파악하지 못해서, 객체가 다양한 상황에서 사용될 수 있을 것이라는 추측을 가지게 되고, 내부 상태를 드러내는 메서드를 무분별하게 추가하게 되는 것이다.
그러므로 코드를 아래처럼 수정하면 해결된다.
수정 후 `GameController`
package baseball.controller
import baseball.config.GameNumberConfig.DIGIT_NUMBER
import baseball.model.Computer
import baseball.service.NumberValidator
import baseball.view.InputCmdView
import baseball.view.InputNumberView
import baseball.view.OutputGuideView
import baseball.view.OutputResultView
class GameController {
// NumberValidator 를 여기서 생성하지 않는다.
// model & view
// ...
fun startGame() {
/* ... */
}
private fun startTurn() {
computer.generateAnswer() // A. 컴퓨터는 사용자가 맞힐 정답을 생성한다.
while (true) {
val result = computer.getResult(inputNumberView.inputNumber()); // 사용자의 수를 가지고, 결과를 얻는다.
outputResultView.showResult(result) // C-2 결과 출력
if (result.strike == DIGIT_NUMBER) {
break
}
}
}
}
`GameController` 는 따로 유효성을 검증하는 동작을 수행하지 않는다. 단지, `computer` 에게 메시지를 보내서 정답을 생성해 달라는 요청을 할 뿐이다.
그렇다면 `Computer` 는 어떻게 변경되어야 할까?
class Computer {
// ...
private val numberValidator = NumberValidator() // cnrkehla.
fun generateAnswer() {
/* ... */
}
fun getResult(input: String = ""): BallAndStrike {
numberValidator.validate(input)
return numberComparator.compare(input, answer)
}
}
여기서 `Computer` 는 결과를 생성하는 동작(`getResult`)을 구현하고 있다.
이 때 사용자의 입력에 대한 유효성을 검증하고 나서, `NumberComparator` 에게 메시지를 보내서 입력과 정답을 비교해서 결과를 알려달라는 요청을 한다.
그리고 `NumberComparator` 가 결과를 생성해주고 있다.
우리의 직관으로 생각해보면, 사용자 입력의 유효성을 검증해주는 역할과 입력과 정답을 비교해서 결과를 알려주는 역할을 `Computer` 가 수행하는 것이 합리적이라고 생각이 된다. 즉, 책임이 적절히 할당된 것 같다.
이렇게 변경한 결과 UML 은 아래처럼 변경된다.
`NumberComparator` 와 `BallAndStrike` 의 문제
하지만 또 다른 문제가 하나 더 있다.
바로 `NumberComparator` 와 `BallAndStrike` 의 문제이다.
먼저 코드로 보자.
`NumberComparator`
class NumberComparator {
fun compare(input: String, answer: List<Int>): BallAndStrike {
val ballAndStrike = BallAndStrike()
for (i in 0 until GameNumberConfig.DIGIT_NUMBER) {
if (answer.contains(input[i] - '0')) {
ballAndStrike.ball++
}
if (input[i] - '0' == answer[i]) {
ballAndStrike.strike++
}
}
ballAndStrike.ball -= ballAndStrike.strike
return ballAndStrike
}
}
`BallAndStrike`
data class BallAndStrike(
var ball: Int = 0,
var strike: Int = 0,
) {
override fun toString(): String = when {
ball == 0 && strike == 0 -> NOTING
strike == 0 -> "$ball" + BALL
ball == 0 -> "$strike" + STRIKE
else -> "$ball$BALL $strike$STRIKE"
}
}
언뜻 보면, 문제가 없어보이지만, `BallAndStrike` 의 프로퍼티 `ball` 과 `strike` 가 가시성이 `public` 인 것이 문제가 된다.
이렇게 되면, 어떠한 클래스도 프로퍼티에 접근할 수 있게 된다.
이 문제는 입력과 정답을 비교하는 책임을 다르게 변경한다면, 해결될 수 있다. 입력과 정답을 비교해서 `ball` 과 `strike` 의 상태를 변경해서 `BallAndStrike` 의 형태로 리턴하는 책임을 `NumberComparator` 가 아니라, `BallAndStrike` 자체가 수행하도록 변경해봅시다.
`변경된 BallAndStrike`
data class BallAndStrike(
private var ball: Int = 0,
private var strike: Int = 0,
) {
fun getResult(input: String, answer: List<Int>): BallAndStrike {
for (i in 0 until DIGIT_NUMBER) {
if (answer.contains(input[i] - '0')) {
ball++
}
if (input[i] - '0' == answer[i]) {
strike++;
}
}
ball -= strike;
return this
}
fun isSuccess() = strike == DIGIT_NUMBER
override fun toString(): String = when {
ball == 0 && strike == 0 -> NOTING
strike == 0 -> "$ball" + BALL
ball == 0 -> "$strike" + STRIKE
else -> "$ball$BALL $strike$STRIKE"
}
}
`ball` 과 `strike` 를 프로퍼티로 가지는 `BallAndStrike` 가 직접 `input` 과 `answer` 를 패러미터로 받아서, 결과를 생성해내고 있다.
그리고 현재 3 스트라이크가 되어서 정답을 맞추는 경우도 함수 `isSuccess` 라는 함수를 통해 결과를 외부에 알려주도록 하고 있다.
이렇게 하면, 프로퍼티를 외부로부터 완전히 캡슐화할 수 있다. (물론, 클래스 이름부터 `BallAndStrike` 이기 때문에 외부에 완전히 정보를 캡슐화하지는 못한다. 클래스 이름만으로, 어떤 프로퍼티를 가지고 있는지 알수 있기 때문이다.)
그리고 이렇게 된다면 굳이 `NumberComparator` 가 존재하지 않아도 된다.
그러므로 `Computer` 클래스가 아래와 같이 변경될 수 있다.
class Computer {
private var answer: List<Int> = emptyList()
private val numberValidator = NumberValidator()
fun generateAnswer() {
/* ... */
}
fun getResult(input: String = ""): BallAndStrike {
numberValidator.validate(input)
val ballAndStrike = BallAndStrike()
// return numberComparator.compare(input, answer) 이 코드에서 아래로 변경됨.
return ballAndStrike.getResult(input, answer)
}
}
또 `GameController` 클래스는 아래처럼 변할 것이다.
class GameController {
// model & view
// ...
fun startGame() { /* ... */ }
private fun startTurn() {
computer.generateAnswer() // A. 컴퓨터는 사용자가 맞힐 정답을 생성한다.
while (true) {
val result = computer.getResult(inputNumberView.inputNumber()) // 사용자의 수를 가지고, 결과를 얻는다.
outputResultView.showResult(result) // C-2 결과 출력
if (result.isSuccess()) {
break
}
}
}
}
이렇게 되면 UML 은 아래처럼 변한다.
이제 `GameController` 는 오직 `Computer` 에만 의존성을 갖게 되었다.
성능 관련 이슈
현재 코드는 구조적으로 꽤 객체지향적인 설계라고 생각된다.
코드 리뷰를 받으면서 성능 상의 문제가 있다는 것을 찾아냈다.
볼, 스트라이크를 계산해주는 코드의 성능 이슈
아래 코드는 원래는 `NumberComparator` 에 있던 로직, 현재는 `BallAndStrike` 에 있는 로직이다.
fun getResult(input: String, answer: List<Int>): BallAndStrike {
for (i in 0 until DIGIT_NUMBER) {
if (answer.contains(input[i] - '0')) {
ball++
}
if (input[i] - '0' == answer[i]) {
strike++
}
}
ball -= strike
return this
}
반복문 안에 두 개의 `if` 문이 들어있다. 여기서 `ball` 의 개수와 `strike` 의 개수를 모두 계산하고 있다. 먼저 `ball` 에 관련한 연산에서는 O(n) 의 시간 복잡도를 갖고, `strike` 에 관련한 연산에서는 `O(1)` 의 시간 복잡도를 갖는다.
그런데 만약 아래처럼 `strike` 에 관련한 연산을 먼저한다고 하면 어떨까?
fun getResult(input: String, answer: List<Int>): BallAndStrike {
for (i in 0 until DIGIT_NUMBER) {
if (input[i] - '0' == answer[i]) {
strike++
continue
}
if (answer.contains(input[i] - '0')) {
ball++
}
}
return this
}
변경한 코드에서는 먼저 `strike` 에 관련한 O(1)의 연산을 먼저 수행한다. 첫번째 자리 수부터 정답과 비교해서 만약 같다면 `strike` 를 1 증가시키고, `continue` 를 통해 다음 자리수에 대해서 검사하고 있다.
만약 첫번째 `if` 문의 조건을 만족하지 않는다면, `ball` 에 관련한 O(n) 의 연산을 수행한다. 정답에 현재 입력 자리수의 수가 있다면, `ball` 을 1 증가시키고 있따.
이렇게 변경한다면, 만약 현재 자리수가 `strike` 라면, O(n) 에 해당하는 `ball` 연산을 하지 않는다.
이로써 성능 상으로도 이득을 볼 수 있는 것이다.
정답을 생성해내는 코드의 Indent 이슈
메서드는 대부분, 가독성을 위해서 Indent 를 줄이는 것이 좋은 선택이다.
`Computer` 의 `generateAnswer` 라는 메서드에서는 사용자가 맞추어야 하는 정답을 생성하고 있는데 현재 Indent 가 2 이다.
fun generateAnswer() {
val answer = mutableListOf<Int>()
while (answer.size < DIGIT_NUMBER) {
val randomNum = Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE)
if (!answer.contains(randomNum)) {
answer.add(randomNum)
}
}
this.answer = answer.toList()
}
정상적으로 작동하지만, Indent 를 더 줄일 수 있는 방법이 존재한다.
바로 `LinkedHashSet` 을 이용하는 방법이다.
fun generateAnswer() {
val answer = LinkedHashSet<Int>()
while (answer.size < DIGIT_NUMBER) {
val randomNum = Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE)
answer.add(randomNum)
}
this.answer = answer.toList()
}
코드를 위처럼 변경하면, Indent 를 1로 줄이면서 동작의 성능은 거의 비슷하게 유지할 수 있다.
물론 나도 처음에 `Set` 을 사용하는 방법을 떠올렸다. 나는 `HashSet` 을 사용하여 구현했었다. 하지만 그렇게 구현했을 때 숫자 야구의 숫자가 항상 오름차순으로만 정렬되는 문제가 있었다.
HashSet 과 LinkedHashSet
Kotlin 의 `Set`, `MutableSet` 은 `LinkedHashSet` 을 사용하며, 이것을 삽입된 `element` 의 순서를 보장한다.
`LinkedHashSet`은 `Hash table` 과 `Linked List` 를 이용해서 구현되어 있다.
반면에 Kotlin 의 `HashSet` 은 `element` 의 순서를 보장하지 않으며, `Hash Table` 로 구현되어 있다.
따라서 `LinkedHashSet` 을 `toList` 하면, 결과 리스트에는 원래 `LinkedHashSet` 의 원래 순서대로, 즉, 삽입한 순서대로 `element` 가 담긴다.
하지만 `HashSet` 을 `toList` 하면, 특정 순서를 보장하지 않는다. 즉, `element` 의 순서가 어떻게 될지 알 수 없다는 것이다.
그런데 내가 `HashSet` 으로 구현했을 때는 항상 숫자가 오름차순으로 정렬되었다.
`HashSet` 은 순서를 보장하지 않지만, `HashSet<Int>` 처럼 원시타입으로 `HashSet` 을 만든 경우는 오름차순으로 정렬하여, 일관된 순서를 보장하도록 한다.
실제로 실험을 해본 결과, `Int` 이나, `Char`, `String` 을 `element`의 타입으로 가지는 경우 오름차순으로 정렬되고, 직접 만든 `data class` 를 `element` 타입으로 가지는 경우, 그렇지 않은 것을 확인할 수 있었다.
fun main(args: Array<String>) {
val hashSet = HashSet<TwoInt>()
hashSet.add(TwoInt(1, 9))
hashSet.add(TwoInt(3, 8))
hashSet.add(TwoInt(7, 2))
println("hashSet: $hashSet")
val list = hashSet.toList()
println("리스트로 변환: $list")
}
data class TwoInt(val o1: Int, val o2: Int)
/* 결과:
hashSet: [TwoInt(o1=3, o2=8), TwoInt(o1=1, o2=9), TwoInt(o1=7, o2=2)]
리스트로 변환: [TwoInt(o1=3, o2=8), TwoInt(o1=1, o2=9), TwoInt(o1=7, o2=2)]
*/
소감(미션 진행 시점에서 느꼈던 것)
객체지향 이라는 것에 대해 깊게 배우고, 또 다른 사람들과 오랜 시간동안 성장하고 싶다는 생각이 계속해서 들어왔는데 프리코스 1주차 미션에서부터 벌써 많은 것들을 공부하고, 고민하게 되어서 너무 보람찬 시간이었다.
그리고 이번 미션을 통해서 객체지향에 대해 어느정도 감이 잡히기 시작한 것 같다. 그래서 다음 미션에서는 한 소스 코드에 모든 기능을 넣어서 시스템을 완성한 후에 리팩터링하는 방식이 아닌, 처음부터 '책임 주도 설계' 라는 것을 해보려고 한다.
다음 미션을 받고, 빨리 진행해보고 싶었다.
마지막 버전의 전체 코드는 아래 링크의 `refactorAfterMission` 브랜치에서 볼 수 있다.
https://github.com/sh1mj1/kotlin-baseball-6/tree/refactorAfterMission