우아한테크코스

블랙잭 상태 패턴 구현 : [우아한테크코스 5기 AN_베르]

베르_최성훈 2023. 4. 10. 23:27

step1 에서 블랙잭 미션을 진행하면서 코드가 점점 복잡해지고 마음에 들지 않았다!

 

step1 미션 PR
https://github.com/woowacourse/kotlin-blackjack/pull/16

 

[베르] 1단계 블랙잭 제출합니다. by SeongHoonC · Pull Request #16 · woowacourse/kotlin-blackjack

안녕하세요! 우테코 5기 베르입니다! 리뷰 잘부탁드립니다!

github.com

 

 

step2 에서 상태 패턴을 알게 되었고 적용해 보았다.

상태 패턴 : 객체지향 관점으로 유한 상태 기계를 구현하는 디자인 패턴

 

그럼 유한 상태 기계는 뭘까? 

 

유한 상태 기계는 유한한 개수의 상태를 가질 수 있는 기계이다.

 

한 번에 오로지 하나의 상태만을 가지게 되며, 어떠한 입력이나 이벤트가 발생하면 현재 상태에서 다음 상태로 변화할 수 있다.

 

전략 패턴과 혼동하는 경우가 있는데 상태 패턴은 인스턴스화 된 후에 상태가 계속 변한다는 점에서 차이가 있다.

 

게임의 몬스터로 예를 들어 쉽게 알아보자. 플레이어가 몬스터를 만났다.

 

이 몬스터는 공격 당하기 전까지 먼저 공격하지 않는다.

1. 첫 번째 상태 -> 공격하지 않음

플레이어가 공격하여 체력이 줄어들면(입력 혹은 이벤트) 몬스터는 공격하기 시작한다.

2. 두 번째 상태 -> 공격

플레이어가 공격하여 체력이 절반 이하로 줄어들면 몬스터는 특수 공격을 사용한다.

3. 세 번째 상태 -> 특수 공격

플레이어가 공격하여 체력이 0이 되면 몬스터는 사망한다.

4. 네 번째 상태 -> 사망

 

상태 패턴을 적용할 때 주의할 점!!

다음 상태를 결정하는데 영향을 미치는 인스턴스 변수의 상태 (위에선 몬스터의 체력) 가 불변이어야 한다는 점이다.

 

보통 이전 상태로부터 생성자를 통해 값을 받아오는데 불변이 아니라면 현재 상태를 신뢰할 수 없게 된다.

 

블랙잭에서는 손에 든 카드 패가 불변이어야 한다.

 

class Hand(val cards: List<Card>) {
    constructor(vararg card: Card) : this(card.toList())

    fun add(card: Card) = Hand(cards + card)

    fun hasAce() = cards.map { it.number }.contains(CardNumber.ACE)

    fun getTotalCards() = cards.sumOf { it.number.value }
}

 

Card List 는 새로운 카드를 받을 때 마다 새로운 Hand 를 생성하므로 불변이다.

 

이제 유한 상태 기계가 무엇인지 이해했다면 블랙잭으로 넘어가보자

 

블랙잭 게임에서 각 플레이어는 여러가지 상태를 가질 수 있다.

 

1. 첫 번째 턴(FirstTurn) : 아직 카드를 한 장만 받은 상태. 무조건 한 장 더 받아야 게임이 시작함.

2. 블랙잭(BlackJack) : 처음 두 장을 받자마자 숫자 합이 21 인 상태. 무조건 승리

3. 힛(Hit) : 카드의 합이 21 미만. 카드를 더 받겠다 선언한 상태.

4. 스테이(Stay) : 카드의 합이 21 미만. 카드를 더 받지 않겠다 선언한 상태.

5. 버스트(Bust) : 카드의 합이 21을 넘어서 무조건 패배

 

첫 상태는 FirstTurn 이며 카드를 받는 입력 혹은 이벤트가 발생할 때마다 상태가 변한다.

 

상태를 이해했다면 상태가 변화하는 경우의 수를 따져보면서 구현해보자.

 

상태패턴 구현의 첫 걸음으로 State interface 부터 작성한다.

 

interface State {
    //카드를 뽑는다.
    fun draw(card: Card): State
    
    //stay 한다.
    fun stay(): State
}

 

다음으로 이 인터페이스를 구현하는 클래스들을 정의한다.

 

FirstTurnHIt 의 중복되는 stay 메서드는 Started 추상 클래스를 상속받도록 하여 불필요한 중복을 제거하였다.

 

abstract class Started(protected val hand: Hand) : State {
    override fun stay(): State {
        return PlayerStay(hand)
    }
}

 

FirstTurn 에서 가능한 경우의 수

1.  카드가 2장이 되지 않으면 다시 FirstTurn 을 반환 ( FirstTurn -> FirstTurn )

 

2. 카드 2장의 합이 21이면 BlackJack을 반환 ( FirstTurn -> BlackJack )

 

3. 카드 2장의 합이 21보다 적으면 Hit 을 반환 ( FirstTurn -> Hit ) 

 

 

FirstTurn 은 다음과 같이 작성할 수 있다.

 

class FirstTurn(hand: Hand) : Started(hand) {
    override fun draw(card: Card): State {
    	val newHand = hand.add(card)
        val score = Score.of(newHand)
        return when {
            hand.value.size == ONE_CARD -> FirstTurn(newHand)
            score.value == BLACK_JACK_NUMBER -> BlackJack(newHand)
            else -> Hit(newHand)
        }
    }

    companion object {
        private const val ONE_CARD = 1
    }
}

 

Hit 에서 가능한 경우의 수

1. 패에 있는 카드와 새로운 카드의 합이 21을 넘지 않고 받지 않겠다 선언. (Hit -> Stay)

 

2. 패에 있는 카드와 새로운 카드의 합이 21을 넘지 않고 더 받겠다 선언 (Hit -> Hit)

 

3. 카드의 합이 21을 넘음. (Hit -> Bust)

 

 

Hit 은 다음과 같이 작성할 수 있다.

 

class Hit(hand: Hand) : Started(hand) {
    override fun draw(card: Card): State {
    	val newHand = hand.add(card)
        val score = Score.of(newHand)
        if (score.value > BLACK_JACK_NUMBER) return Bust(newHand)
        return Hit(newHand)
    }
}

 

 

BlackJack, Stay, Bust 는 종료된 상태이므로 다음과 같은 Finished 추상 클래스를 상속 받는다.

 

abstract class Finished(protected val hand: Hand) : State {
    override fun draw(card: Card): State {
        throw IllegalStateException(CANT_DRAW_ERROR)
    }

    override fun stay(): State {
        throw IllegalStateException(CANT_STAY_ERROR)
    }

    companion object {
        private const val CANT_DRAW_ERROR = "카드를 하나 더 받을 수 없습니다."
        private const val CANT_STAY_ERROR = "이미 종료된 상태입니다."
    }
}
class BlackJack(hand: Hand) : Finished(hand)

class Stay(hand: Hand) : Finished(hand)

class Bust(hand: Hand) : Finished(hand)

 

이제 만들어 놓은 유한한 상태들을 사용해 보자.

 

플레이어는 FirstTurn 으로 시작해서 카드를 받을 때 마다 상태가 변경된다. 

 

혹은 stay 메서드가 실행되면 상태가 바뀐다.

 

class Player(val name: String) {

    var state: State = FirstTurn(Hand())

    fun receiveCard(card: Card) {
        state = state.draw(card)
    }

    fun stay() {
        state = state.stay()
    }
}

 

최상위 도메인 클래스 BlackJackGame 에서 StateStarted 인지 확인하는 것 만으로도 카드를 더 뽑을 수 있는 상태인지 알 수 있다!

 

class BlackJackGame {
	...
    
    fun play(){
	while (player.state is Started) {
    	    handOutCard(player, isContinue)
   	}
    }
    
    private fun handOutCard(player: Player, isContinue: (String) -> Boolean) {
        if (!isContinue(player.name)) {
            player.stay()
            return
        }
        player.receiveCard(deck.getCards(GAME_CARD_COUNT))
    }

}

 

오늘은 상태 패턴을 사용하여 블랙잭의 복잡한 상태를 관리하는 방법을 알아보았다.

 

개인적으로 상태패턴을 적용하면서 너무 재밌었다!

 

상태 패턴을 적용한 Step2 PR

 

https://github.com/woowacourse/kotlin-blackjack/pull/54

 

[베르] 2단계 블랙잭 제출합니다. by SeongHoonC · Pull Request #54 · woowacourse/kotlin-blackjack

먼저 상태패턴 적용 허락해주신 리뷰어님 감사드립니다! 처음 적용해 보는 것이라 어려웠지만 열심히 했습니다! 기존에 있던 파일을 유지한채로 점진적으로 변경하느라 특히 더 힘들었던 것 같

github.com

 

 

 

위에서 테스트 코드에 대해서 얘기하지 않았는데

 

아래와 같이 상태 변화가 예상대로 동작하는지 테스트 코드를 작성해야한다.

 

TDD 로 개발하면 훨씬 쉽고 정확하게 구현할 수 있다.

 

@Test
    fun `PlayerFirstTurn 에서 FirstTurn 된다`() {
        val actual = PlayerFirstTurn(Hand()).draw(Card(CardShape.DIAMOND, CardNumber.KING))

        assertThat(actual is PlayerFirstTurn).isTrue
    }

    @Test
    fun `PlayerFirstTurn 에서 Hit 상태가 된다`() {
        val hand = Hand(Card(CardShape.HEART, CardNumber.TEN))
        val actual = PlayerFirstTurn(hand).draw(Card(CardShape.DIAMOND, CardNumber.KING))

        assertThat(actual is PlayerHit).isTrue
    }

    @Test
    fun `PlayerFirstTurn 에서 BlackJack 상태가 된다`() {
        val hand = Hand(Card(CardShape.HEART, CardNumber.ACE))
        val actual = PlayerFirstTurn(hand).draw(Card(CardShape.DIAMOND, CardNumber.KING))

        assertThat(actual is BlackJack).isTrue
    }