[우테코/5기/안드로이드] 4주차 프리코스 톺아보기

Falco·2022년 11월 29일
0

우테코 프리코스

목록 보기
4/5
post-thumbnail

우테코에서 요구하는 것

1. 클래스(객체)를 분리하는 연습

2. 리팩터링 연습

클래스(객체)를 분리하는 것에 대해 조금 더 깊이 고민해 볼 수 있도록 클래스의 구조와 제약사항을 요구사항이 추가됬다.

  • BridgeGame 클래스에서 InputView, OutputView 를 사용하지 않는다.

디자인 패턴을 적용하라는 의미를 내포하고있다.

  • 다리 칸을 생성하기 위한 Random 값은 아래와 같이 추출한다.
val number = bridgeNumberGenerator.generate()
  • 함수(또는 메서드)의 길이가 10라인을 넘어가지 않도록 구현한다.
  • 메서드의 파라미터 개수는 최대 3개까지만 허용한다.

함수가 하나의 일만 수행하게, 가독성 있는 코드를 요구하고 있다.

What I Learn?

Sealed Class란?

kotlinlang.org - sealed class

Sealed Class란 제한된 클래스 계층을 나타낸다.
Sealed Class의 서브 클래스는 컴파일 시 모두 알려진다. 알려진 제한된 집합의 유형을 가질 때 유용하다.
즉 sealed 클래스는 상속 받는 자식 클래스의 종류를 제한한다.

컴파일러는 Class는 상속 받을 때 부모 Class가 어떠한 자식에게 Class를 상속했는지 알지 못한다.

Sealed Class는 Enum 클래스와 비슷하지만 다르다

enum 클래스

단일 인스턴트로만 존재가능하다 (싱글톤으로 존재)

sealed 클래스

sealed class는 각각 고유한 상태를 가진 여러 인스턴트를 가질 수 있다.
상속을 지원하여 다양한 동작 구현 가능

  • sealed 클래스의 서브 클래스, 인터페이스는 반드시 같은 패키지 내에 선언되어야 한다
sealed interface Error // 같은 패키지내의 값에만 상속 가능

sealed class IOError(): Error // 같은 패키지내에서만 선언 가능
open class CustomError(): Error // 어디서든 선언 가능
  • sealed 클래스는 abstract 클래스이며 abstract 멤버를 가질 수 없다.
  • sealed 클래스의 생성자는 private, protected 의 visibility를 가진다.
  • 하위 클래스는 class, data class, object class로 정의할 수 있다,
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! }
}

활용

다리건너기를 실행할 때 실패, 성공, 완료로 상태를 나누고 각각의 결과마다 위, 아래 라는 상태를 가질 수 있게 하였다.

  • SUCCESS(Up), FAIL(Down)
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())
}

Interface를 분리하여 테스트하기 좋은 메서드로 만들기

이는 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기반의 코드작성은 간단하게 테스트 코드를 먼저 작성하고, 그 후 로직을 수행하는 코드를 작성하는 것이다. 이는 입력과 출력을 명확히 정해 놓고 코드를 작성하기 때문에 명확한 코드를 작성할 수 있으며 리팩토링하거나 수정하기 쉽다. 또한 나중에 리팩토링 되어도 해당 테스트를 재사용할 수 있다. 잘 작성된 테스트는 후에도 도움이 될 것 이다.

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은 다음과 같은 순서로 진행된다.

  1. 다리 사이즈를 입력받아 다리를 생성한다.
  2. 생성된 다리와 사용자의 입력을 이용하여 다리를 건넌다.
  3. 잘못된 다리를 건너면 실패, 잘 건너면 성공, 다 건너면 완료라는 결과를 이용 게임을 진행, 중단한다.

여기서 객체는 무엇이 사용되는가??

  • InputView, OutputView와 같은 UI로직을 수행하는 객체

  • 게임을 전체적으로 통솔하는 BridgeController 객체

  • 다리를 생성하는 BridgeMaker 객체

  • 랜덤 다리번호를 생성하는 BridgeNumberGenerator 객체

  • 생성된 다리를 소유하며 다리의 건너기의 결과를 관리하는 BridgeGame 객체

  • 게임의 결과를 관리하는 BridgeResult 객체

  • 실패, 성공, 완료 라는 항목을 가지고있는 Sealed 객체

객체가 일을 하도록 MVC디자인 패턴에 알맞게 객체를 만들어 보았다.

객체가 어디서 어떤 일을 수행하는지 작성해보았다.
다음의 기능 리스트도 각각의 객체가 자기의 역할만을 수행하고 있는지 확인해 보자.

1. 다리 사이즈를 입력받아 다리를 생성한다.

  1. Controller에서 View에서 사이즈를 입력받고 BridgeGame에 넘긴다.
  2. BridgeGame에서는 BridgeMaker에게 해당 사이즈를 전달한다.
  3. BridgeMaker는 해당 사이즈만큼의 브릿지 번호를 생성한다.
  4. BridgeGame은 해당 브릿지 번호를 이용하여 Bridge를 만든다.

2. 생성된 다리와 사용자의 입력을 이용하여 다리를 건넌다.

  • 생성된 다리는 BridgeGame의 Bridge 객체이다.
  1. Controller에서 View에게 다리를 어디로 건널지(direction) 입력받는다.
  2. Contorller가 BridgeGame에게 해당 값을 전달한다.
  3. BridgeGame에서 해당 direction을 건널 수 있는지 Bridge에게 물어본다.
  4. BridgeGame이 다리를 건넌 결과 값(Sealed Class)을 반환한다.

3. 잘못된 다리를 건너면 실패, 잘 건너면 성공, 다 건너면 완료라는 결과를

  1. Controller에서 해당 결과 값을 이용하여 BridgeResult를 업데이트한다.
    • 성공 : 2번으로 돌아가 다시 다리를 건넌다.
    • 실패 : View가 실패 메시지를 출력 하고, 재시작 유무를 선택한다.
      재시작한다면 BridgeResult를 초기화한다.
    • 완료 : View가 완료 메시지 및 결과를 출력한다.

의문 및 생각해야 할 점 🤔

  1. BridgeResult는 Controller에서 관리되어야 하는가?
    BridgeResult도 BridgeGame의 일부이며 BridgeGame에서 결과 값을 반환할 때에 BridgeResult를 업데이트해 주어도 되지 않는가?
  2. 객체가 본인이 수행할 일을 수행하고 있는가?
    다리 사이즈를 입력받거나 direction(방향)을 입력받을 때 이를 View에서 예외를 처리할 수 있지만, 이러한 예외처리를 View 객체가 수행해야 하는가 아니면 Model에서 수행해야 하는가 예외처리는 누구의 몫인가?
    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
       		 }
    	 }
    }
  3. Private으로 선언된 객체의 필드에 대한 get, set에 대해
    kotlin은 객체의 필드에 대해 자바와 달리 get, set을 자동으로 구현해 준다.
// 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
        }
profile
강단있는 개발자가 되기위하여

0개의 댓글