step1 에서 블랙잭 미션을 진행하면서 코드가 점점 복잡해지고 마음에 들지 않았다!
step1 미션 PR
https://github.com/woowacourse/kotlin-blackjack/pull/16
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
}
다음으로 이 인터페이스를 구현하는 클래스들을 정의한다.
FirstTurn 과 HIt 의 중복되는 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 에서 State 가 Started 인지 확인하는 것 만으로도 카드를 더 뽑을 수 있는 상태인지 알 수 있다!
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
위에서 테스트 코드에 대해서 얘기하지 않았는데
아래와 같이 상태 변화가 예상대로 동작하는지 테스트 코드를 작성해야한다.
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
}
'우아한테크코스' 카테고리의 다른 글
Presenter 테스트 작성하기 (안드로이드 MVP) : [우아한테크코스 5기 AN_베르] (0) | 2023.06.23 |
---|---|
오목 데코레이터 패턴 구현 : [우아한테크코스 5기 AN_베르] (0) | 2023.04.12 |
로또 미션 피드백 : [우아한테크코스 5기 AN_베르] (0) | 2023.02.27 |
TDD : [우아한테크코스 5기 AN_베르] (0) | 2023.02.15 |
Lv1 자동차 경주 미션 피드백 2 : [우아한테크코스 5기 AN_베르] (0) | 2023.02.14 |