클래스(객체)를 분리하는 것에 대해 조금 더 깊이 고민해 볼 수 있도록 클래스의 구조와 제약사항을 요구사항이 추가됬다.
디자인 패턴을 적용하라는 의미를 내포하고있다.
val number = bridgeNumberGenerator.generate()
함수가 하나의 일만 수행하게, 가독성 있는 코드를 요구하고 있다.
Sealed Class란 제한된 클래스 계층을 나타낸다.
Sealed Class의 서브 클래스는 컴파일 시 모두 알려진다. 알려진 제한된 집합의 유형을 가질 때 유용하다.
즉 sealed 클래스는 상속 받는 자식 클래스의 종류를 제한한다.
컴파일러는 Class는 상속 받을 때 부모 Class가 어떠한 자식에게 Class를 상속했는지 알지 못한다.
Sealed Class는 Enum 클래스와 비슷하지만 다르다
단일 인스턴트로만 존재가능하다 (싱글톤으로 존재)
sealed class는 각각 고유한 상태를 가진 여러 인스턴트를 가질 수 있다.
상속을 지원하여 다양한 동작 구현 가능
sealed interface Error // 같은 패키지내의 값에만 상속 가능
sealed class IOError(): Error // 같은 패키지내에서만 선언 가능
open class CustomError(): Error // 어디서든 선언 가능
enum class State(hour: Int){
SLEEP(8),
WORK(4),
EXCERSIZE(2),
}
val state1 = State.WORK // OK
val state2 = State.WORK(2) // ERROR!
sealed class State{
SLEEP(sleepHour: Int),
WORK(workHour: Int),
EXCERSIZE(excersizeHour: Int, where: String),
}
// 고유 상태를 가진 여러 인스턴트 존재 가능!
val state1 = State.WORK(2)
val state2 = State.WORK(3)
val state5 = State.EXCERSIZE(5,"GYM")
sealed class를 사용하여 얻는 이득 중 포인트는 when
표현을 사용할 때이다. 모든 하위 케이스를 나타내는 것이 가능함으로써 else
브런치를 사용할 필요가 없으며, 예상치 못한 값이 들어올 일도 없다.
when(State) {
is SLEEP -> { // do something! }
is WORK -> { // do something! }
is EXCERSIZE -> { // do something! }
}
다리건너기를 실행할 때 실패, 성공, 완료로 상태를 나누고 각각의 결과마다 위, 아래 라는 상태를 가질 수 있게 하였다.
sealed class BridgeStatus(private val direction: String) {
class FINISH(direction: String) : BridgeStatus(direction)
class SUCCESS(direction: String) : BridgeStatus(direction)
class FAIL(direction: String) : BridgeStatus(direction)
fun getDirection(): String = direction
fun isFinish(action: () -> Unit) {
if (this is FINISH) {
action()
}
}
fun isSuccess(action: () -> Unit) {
if (this is SUCCESS) {
action()
}
}
fun isFail(action: () -> Unit) {
if (this is FAIL) {
action()
}
}
}
해당 객체가 FINISH 타입일 때만 실행되는 isFinish
라는 함수를 이용하여 행동을 정의한다.
private fun onFinish(result: BridgeStatus) {
result.isFinish {
outputView.printEndMessage()
outputView.printMap(bridgeResult)
outputView.printResult(result, bridgeResult.totalCount)
}
}
when브런치를 이용해 모든 케이스를 지정할 수 있다.
when (result) {
is BridgeStatus.FINISH -> addSuccess(result.getDirection())
is BridgeStatus.SUCCESS -> addSuccess(result.getDirection())
is BridgeStatus.FAIL -> addFail(result.getDirection())
}
이는 3주차 때도 경험했던 과제이다.
class BridgeRandomNumberGenerator {
fun generate(): Int {
return Randoms.pickNumberInRange(RANDOM_LOWER_INCLUSIVE, RANDOM_UPPER_INCLUSIVE)
}
}
BridgeRandomNumberGenerator
가 랜덤 값을 다음과 같이 생성하고 다음과 같이 BridgeMaker
가 다리를 만든다고 하자.
class BridgeMaker(private val bridgeNumberGenerator: BridgeNumberGenerator) {
fun makeBridge(size: Int): List<String> {
require(size in BRIDGE_MIN_LENGTH..BRIDGE_MAX_LENGTH) {
ERR_BRIDGE_LENGTH
}
return List(size) {
if (bridgeNumberGenerator.generate() == RANDOM_UPPER_INCLUSIVE) BRIDGE_UPPER_SYMBOL else BRIDGE_LOWER_SYMBOL
}
}
}
만약 makeBridge
를 테스트한다고 한다면 어떻게 할까??
@ParameterizedTest
@ValueSource(strings = ["10", "15"])
@DisplayName("만들어지는 다리가 U 또는 D가 포함된 형태로 반환되는지 테스트한다.")
fun makeRightBridge(size: Int) {
assertSimpleTest {
assertThat(bridgeMaker.makeBridge(size)).hasSize(size)
assertThat(bridgeMaker.makeBridge(size)).containsAnyOf("U", "D")
}
}
작성한다고 작성하였지만 매우 영양가 없는 테스트코드가 작성됬다. ( 물론 필요할 수 있지만) 사이즈, U,D를 모두 포함하는 리스트로 반환되는지에 대한 테스트는 그렇게 유용한 테스트는 아닐 것이다.
하지만 다리가 랜덤으로 만들어 짐으로 이것 이상으로 유용한 테스트코드는 작성할 수 없다.
이제 인터페이스를 추출하여 해당 테스트를 영양가 있게 바꾸어 보자.
기존의 BridgeRandomNumberGenerator
에서 generate()라는 로직을 인터페이스로 추출하여 여러개의 Generator가 존재하게 만들자.
interface BridgeNumberGenerator {
fun generate(): Int
}
class BridgeRandomNumberGenerator : BridgeNumberGenerator {
override fun generate(): Int {
return Randoms.pickNumberInRange(RANDOM_LOWER_INCLUSIVE, RANDOM_UPPER_INCLUSIVE)
}
}
class TestNumberGenerator(numbers: List<Int>) : BridgeNumberGenerator {
private val numbers: MutableList<Int> = numbers.toMutableList()
override fun generate(): Int {
return numbers.removeAt(0)
}
}
두개의 Generator
은 똑같이 Int를 반환하지만 아래의 테스트 전용 제너레이터는 numbers
라는 리스트를 이용해 해당 리스트의 값을 반환한다.
만들어지는 브릿지의 형태를 예측할 수 있게 된 것이다.
private lateinit var bridgeMaker: BridgeMaker
@BeforeEach
fun setUp() {
bridgeMaker = BridgeMaker(TestNumberGenerator(listOf(1, 0, 0, 1, 1)))
}
@Test
@DisplayName("완성된 다리가 예상된 형태와 같은지 테스트한다.")
fun makeBridgeTest() {
assertSimpleTest {
assertThat(bridgeMaker.makeBridge(5)).isEqualTo(listOf("U", "D", "D", "U", "U"))
}
}
브릿지의 형태가 예측이 가능해 지면 테스트 코드도 더욱 더 영양가 있게 만들 수 있다.
TDD기반의 코드작성은 간단하게 테스트 코드를 먼저 작성하고, 그 후 로직을 수행하는 코드를 작성하는 것이다. 이는 입력과 출력을 명확히 정해 놓고 코드를 작성하기 때문에 명확한 코드를 작성할 수 있으며 리팩토링하거나 수정하기 쉽다. 또한 나중에 리팩토링 되어도 해당 테스트를 재사용할 수 있다. 잘 작성된 테스트는 후에도 도움이 될 것 이다.
TDD기반으로 코드를 작성하는 예로 BridgeMap
를 보겠다.
해당 객체는 위쪽 길, 아래 쪽 길을 필드로 가지며 해당 필드에 다리 건너기 성공, 실패, 공백의 결과를 가져야 한다.
해당 필드를 가지고 제대로된 지도를 반환하는 테스트를 먼저 작성한다.
private lateinit var bridgeMap: BridgeMap
@BeforeEach
fun setUp() {
bridgeMap = BridgeMap()
}
@Test
@DisplayName("브릿지맵 (U, U, D) 업데이트가 정상 작동하는지 테스트한다.")
fun updateTest2() {
assertSimpleTest {
bridgeMap.update(BridgeStatus.SUCCESS("U"))
bridgeMap.update(BridgeStatus.SUCCESS("U"))
bridgeMap.update(BridgeStatus.FINISH("D"))
assertThat(bridgeMap.toString()).contains(
"[ O | O | ]",
"[ | | O ]",
)
}
}
@Test
@DisplayName("브릿지맵 (U, D) 업데이트가 정상 작동하는지 테스트한다.")
fun updateTest() {
assertSimpleTest {
bridgeMap.update(BridgeStatus.SUCCESS("U"))
bridgeMap.update(BridgeStatus.FAIL("D"))
assertThat(bridgeMap.toString()).contains(
"[ O | ]",
"[ | X ]",
)
}
}
해당 테스트 코드를 기반으로 BridgeMap객체를 작성한다.
class BridgeMap {
private val upperMap: MutableList<String> = mutableListOf()
private val lowerMap: MutableList<String> = mutableListOf()
override fun toString(): String {
val result = StringBuilder()
buildUpperMap(result)
result.append("\n")
buildLowerMap(result)
return result.toString()
}
private fun addFail(direction: String) {
if (direction == BRIDGE_UPPER_SYMBOL) {
upperMap.add(BRIDGE_FAIL)
lowerMap.add(BRIDGE_BLANK)
return
}
upperMap.add(BRIDGE_BLANK)
lowerMap.add(BRIDGE_FAIL)
}
private fun addSuccess(direction: String) {
if (direction == BRIDGE_UPPER_SYMBOL) {
upperMap.add(BRIDGE_CORRECT)
lowerMap.add(BRIDGE_BLANK)
return
}
upperMap.add(BRIDGE_BLANK)
lowerMap.add(BRIDGE_CORRECT)
}
}
약간 TDD기반 코드작성의 이상향 처럼 글을 썻지만 위에 글을 써놓았듯이 이러한 순서대로 코드를 작성한 것은 아니다.
4주차 과제를 진행하며 구동하는 코드를 먼저 작성하고 이를 리팩토링하여 MVC패턴에 맞게 바꾸었기 때문에 수많은 코드의 수정이 들어 갔고 이에 따른 테스트도 전부 다 바꾸어야하는 문제가 발생하였다.
객체의 도메인로직을 아주 작은 단위부터 구현하며 어플리케이션을 만드는 것은 쉽지 않다. 해당 객체가 어떤 일을 해야할지, 해당 로직에 어떠한 입력값이 들어올지에 대해, 테스트코드를 작성하고 나니 입력과 출력이 바뀌는 문제를 수없이 겪으며 전체적인 구조는 어떻게 구성할지 부터 생각하는 과정이 중요함을 느꼈다.
BridgeGame은 다음과 같은 순서로 진행된다.
여기서 객체는 무엇이 사용되는가??
InputView, OutputView와 같은 UI로직을 수행하는 객체
게임을 전체적으로 통솔하는 BridgeController 객체
다리를 생성하는 BridgeMaker 객체
랜덤 다리번호를 생성하는 BridgeNumberGenerator 객체
생성된 다리를 소유하며 다리의 건너기의 결과를 관리하는 BridgeGame 객체
게임의 결과를 관리하는 BridgeResult 객체
실패, 성공, 완료 라는 항목을 가지고있는 Sealed 객체
객체가 일을 하도록 MVC디자인 패턴에 알맞게 객체를 만들어 보았다.
객체가 어디서 어떤 일을 수행하는지 작성해보았다.
다음의 기능 리스트도 각각의 객체가 자기의 역할만을 수행하고 있는지 확인해 보자.
class Bridge(private val bridge: List<String>) : List<String> by bridge {
init {
require(bridge.all {
it == BRIDGE_UPPER_SYMBOL || it == BRIDGE_LOWER_SYMBOL
}) {
ERR_BRIDGE_SYMBOL
}
}
}
// JAVA
class Car {
private int fuel = 0;
void set (int input) {
feul = input
}
int get() {
return fuel;
}
}
// Kotlin
class Car {
int fuel = 0
}
코틀린은 다음과 같이 setter를 간단하게 private하게 만들 수 있다.
또는 getter 및 setter의 커스텀이 가능하다.
var totalCount: Int = GAME_START_NUMBER
private set
// Get 필요 없음
fun getTotalCount(): Int = totalCount
// Custom
var totalCount: Int = GAME_START_NUMBER
private set(value) {
field = value + 1
}