본문 바로가기
우아한테크코스/레벨 1 - Java

[JAVA] 상태 패턴(State Pattern) with BlackJack

by shyun00 2024. 3. 19.

블랙잭 게임을 구현하다보면 현재 내 상태에 따라 수익을 계산하는 방법이 결정된다.(블랙잭으로 승리하면 베팅 금액의 1.5배 받기 등)

그래서 승패를 결정하거나 수익을 결정하는 과정에서 if()절을 사용해서 조건을 확인하는 메서드가 많아졌다.

이 때 사용할 수 있는 패턴 중 하나가 '상태 패턴'이다. 처음 이 방식을 알았을 때의 신선한 충격이란🤔

그렇다면 상태 패턴이 무엇인지, 상태 패턴을 어떻게 적용할 수 있는지 알아보자.

상태 패턴(State Pattern)

객체가 상태에 따라 행위를 다르게 할 때, 상태를 객체화하여 행동을 변경(지정)할 수 있도록 하는 행동 디자인 패턴이다.

구현 방식을 간단히 설명하면 아래와 같다.

  • 상태를 인터페이스로 캡슐화 한다.
  • 세부 상태는 클래스로 표현한다.
  • 상태별 동작은 클래스 내 메서드에서 정의한다.
  • 클래스 교체를 통해 상태의 변화를 나타낸다.

❯ 상태 패턴 적용 전 기존 코드 살펴보기

이번 미션이었던 블랙잭 게임을 통해 상태 패턴을 적용해보자.

우선 그 전에, 승패 결과와 그에 따른 수익 계산은 enum으로 구현해두었고, 이 부분은 상태 패턴 적용 후에도 동일하게 사용되었다.

(승패 결과에 따라 수익 계산식이 결정되므로 해당 로직을 enum에서 관리해주어도 괜찮겠다고 생각했다.

< 승패 결과를 나타내는 Result enum 클래스 >

import blackjack.vo.Money;
import java.util.function.IntUnaryOperator;

public enum Result {
    WIN_BY_BLACKJACK(money -> (int) (money * 1.5)),
    WIN(money -> money),
    PUSH(money -> 0),
    LOSE(money -> -money);

    private final IntUnaryOperator operator;

    Result(IntUnaryOperator operator) {
        this.operator = operator;
    }

    public Money calculateProfit(Money money) {
        return new Money(operator.applyAsInt(money.value()));
    }
}

<기존 - 딜러 카드와 자신의 카드 상태를 확인해 승패를 판단하는 플레이어 로직>

* 글 마지막에 이 코드가 어떻게 변경되었는지 적혀있다.

도메인의 역할을 생각해봤을때, 플레이어의 승패 결과는 플레이어 스스로 판단해야 한다고 생각해서 플레이어쪽에 승패를 판단하는 로직을 두었다. (반대로 딜러의 결과는 플레이어 결과를 가지고 딜러 스스로 판단할 수 있도록 구현했다.)

public class Player extends Participant {
    private final String name;

    ...

    public Result getResult(Cards otherCards) {
        if (cards.isBust()) {
            return Result.LOSE;
        }
        if (otherCards.isBust()) {
            return Result.WIN;
        }
        return compareScore(otherCards);
    }

    // 1단계에서는 '블랙잭'상태를 별도로 구분하지 않았으므로 해당 로직은 제외되어있다.
    private Result compareScore(Cards otherCards) {
        int calculatedScore = cards.getScore();
        int otherScore = otherCards.getScore();

        if (calculatedScore > otherScore) {
            return Result.WIN;
        }
        if (calculatedScore < otherScore) {
            return Result.LOSE;
        }
        return Result.PUSH;
    }
}

위 코드를 보면 여러 if절을 통해 조건을 검증하고있다. 여기에 2단계 미션이었던 '블랙잭으로 승리'라는 조건을 넣게 되면 해당 조건을 검증하는 if절이 또 추가된다. (그런데 여기서 하나 짚고 갈 부분은, if절이 있다고 무조건 나쁘다는 뜻은 아니다. 간단한 검증 혹은 관리가 쉬운 검증은 바로 수행하는게 더 경제적일 수 있다.)

위 코드를 자세히 보면 내가 Bust 상태이면 무조건 진다. 반대로 상대방이 Bust이면 나는 이긴다. 이렇게 '상태'에 따라 결과가 결정된다.

이 부분을 상태 패턴을 통해 구현할 수 있다.

상태패턴 적용해보기 in BlackJack

블랙잭에서 상태는 아래와 같이 구분할 수 있다.

**① 게임을 시작한 상태(InitialState)**

**② 카드를 계속해서 받을 수 있는 상태(Hit)**

**③ BlackJack 조건을 만족한 상태(BlackJack)**

**④ Bust되어 게임이 종료된 상태(Bust)**

**⑤ 카드를 더 받지 않겠다고 Stand한 상태(Stand)**

한 단계식 구현해보자. (해당 코드는 간단한 설명을 위해 일부 생략하였다.)


1. 상태를 인터페이스로 캡슐화한다.

먼저, 모든 상태들이 공통적으로 가져야할 행동에 대해 정의한다.

(어떤 행동을 해야할지를 나타낸다. 어떤 행동을 '어떻게'할지 나타내는것이 아니다.)

< State 인터페이스 >

상태에 따라 달라질 수 있는 동작들을 정의한다.

상태에 따라 카드를 추가로 받거나(draw), 초기 두장의 카드를 받거나( drawCards), 턴을 종료(stand)할 수 있다.

그리고 상대방의 상태와 나의 상태를 비교하여 승패를 판단할 수 있다.

public interface State {
    State draw(Card card);

    State drawCards(List<Card> cardsToAdd);

    State stand();

    Result determineResult(State otherState);
}

2. 세부 상태는 클래스로 표현한다 / 상태별 동작은 클래스 내 메서드에서 정의한다 / 클래스 교체를 통해 상태의 변화를 나타낸다.

각각의 상태를 클래스로 구현한다. 이 때 상태별로 다르게 수행하는 동작을 메서드 구현을 통해 나타낼 수 있다.

만약 동작에 따라 상태가 바뀌어야하는경우 상태 클래스를 교체해준다(draw, drawCards, stand의 리턴값이 State인 이유).

< InitialState >

게임을 막 시작한 상태를 의미한다. 초기 카드 2장을 받을 수 있다. 그러나 추가 카드를 받거나, 턴을 종료하거나 승패를 판단할수는 없다.

대신 초기 카드 2장은 받을 수 있으며 초기 카드가 블랙잭 조건을 만족하면 BlackJack상태를 리턴하고 블랙잭 조건이 아닌 경우 추가 카드를 받을 수 있는 Hit 상태를 리턴한다.

public class InitialState implements State {
    private final Cards cards;

    public InitialState(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        throw new UnsupportedOperationException("게임 시작 전에는 카드를 뽑을 수 없습니다.");
    }

    @Override
    public State drawCards(List<Card> cardToAdd) {
        Cards newCards = new Cards(cardToAdd);
        if (newCards.isBlackJack()) {
            return new BlackJack(newCards);
        }
        return new Hit(newCards);
    }

    @Override
    public State stand() {
        throw new UnsupportedOperationException("게임 시작 전에는 stand할 수 없습니다.");
    }

    @Override
    public Result determineResult(State otherState) {
        throw new UnsupportedOperationException("게임 종료 전에는 승패를 계산할 수 없습니다.");
    }
}

초기 카드를 두장 받은 상태 이후로, 게임 규칙인 21점을 넘지 않는 상태이면서 카드를 추가로 받을 수 있는 상태를 의미한다.

public class Hit {
    private final Cards cards;

    public Hit(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        Cards newCards = new Cards(cards().getCards());
        newCards.add(card);
        if (newCards.isBust()) {
            return new Bust(newCards);
        }
        return new Hit(newCards);
    }

    @Override
    public State drawCards(List<Card> cardsToAdd) {
        throw new UnsupportedOperationException("게임 시작시에만 카드를 여러장 받을 수 있습니다.");
    }

    @Override
    public State stand() {
        return new Stand(cards);
    }

    @Override
    public Result determineResult(State otherState) {
        throw new UnsupportedOperationException("게임 종료 전에는 승패를 계산할 수 없습니다.");
    }
}

블랙잭 상태이면 사실상 게임이 종료된 상태로(굳이 게임 내 최고 상태인 블랙잭 상태인데 카드를 받거나 추가조작을 할 필요가 없다!)

카드를 받거나/받지 않거나를 결정할 필요가 없으며 승패 결과만 결정해주면 된다.

상대방 역시 블랙잭이면 무승부이고 나만 블랙잭이면 블랙잭으로 승리하게된다.

public class BlackJack implements State{
    private final Cards cards;

    public BlackJack(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        throw new UnsupportedOperationException("게임이 종료된 카드는 카드를 발급받을 수 없습니다.");
    }

    @Override
    public State drawCards(List<Card> cardsToAdd) {
        throw new UnsupportedOperationException("게임 시작시에만 카드를 여러장 받을 수 있습니다.");
    }

    @Override
    public State stand() {
        throw new UnsupportedOperationException("게임이 종료된 카드는 stand할 수 없습니다.");
    }

    @Override
    public Result determineResult(State otherState) {
        if (otherState.isBlackJack()) {
            return Result.PUSH;
        }
        return Result.WIN_BY_BLACKJACK;
    }
}

기준 점수인 21점을 넘어 Bust된 상태이다. 카드를 추가로 받을 수 없으며 상대방의 결과와 상관 없이 항상 진다.

public class Bust {
    private final Cards cards;

    public Bust(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        throw new UnsupportedOperationException("게임이 종료된 카드는 카드를 발급받을 수 없습니다.");
    }

    @Override
    public State drawCards(List<Card> cardsToAdd) {
        throw new UnsupportedOperationException("게임 시작시에만 카드를 여러장 받을 수 있습니다.");
    }

    @Override
    public State stand() {
        throw new UnsupportedOperationException("게임이 종료된 카드는 stand할 수 없습니다.");
    }

    @Override
    public Result determineResult(State otherState) {
        return Result.LOSE;
    }
}

기준 점수는 넘지 않았지만 카드를 더이상 받지 않겠다고 Stand한 상태이다.

public class Stand extends Finished {
    private final Cards cards;

    public Stand(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        throw new UnsupportedOperationException("게임이 종료된 카드는 카드를 발급받을 수 없습니다.");
    }

    @Override
    public State drawCards(List<Card> cardsToAdd) {
        throw new UnsupportedOperationException("게임 시작시에만 카드를 여러장 받을 수 있습니다.");
    }

    @Override
    public State stand() {
        throw new UnsupportedOperationException("게임이 종료된 카드는 stand할 수 없습니다.");
    }

    @Override
    public Result determineResult(State otherState) {
        int myScore = getScore();
        int otherScore = otherState.getScore();
        if (otherState.isBust() || myScore > otherScore) {
            return Result.WIN;
        }
        if (otherState.isBlackJack() || myScore < otherScore) {
            return Result.LOSE;
        }
        return Result.PUSH;
    }
}

이제 각 클래스를 생성하는 긴 여정이 끝났다. 이렇게 생성된 상태들을 기존의 코드에 반영해주면 상태 패턴 적용이 종료된다.


그런데 위 코드들을 보면 약간 불편한 구석이 있다.🤔

그렇다. 상태에 따라 '중복되는' 코드가 너무나도 많다. 모든 상태가 동일하게 Cards를 필드로 갖고 있다.

또한 게임이 종료된 블랙잭, Bust, Stand는 추가 카드들을 뽑지 못하고 stand()를 수행할 수 없다. 이 코드가 각 클래스마다 동일하게 반복되고있다. 반대로 게임이 종료되지 않으면 승패를 결정할 수 없는것 또한 InitialState, Hit 클래스에서 반복되고있다.

이렇게 중복되는 코드를 State를 구현한 '추상 클래스'를 통해 정리할 수 있다.

상태를 크게 두가지로 나누면 게임을 진행중인 상태 / 게임이 종료된 상태로 구분된다.

이렇게 두개의 추상 클래스를 정의하고 세부 상태가 추상 클래스를 구현하도록 하였다.

게임이 진행중인 상태들을 나타낸다. 게임이 진행중이므로 승패를 판단할 수 없다.

InProgress이면 동일하게 동작해야하는 부분만 구현하고 나머지는 구현하는 클래스에서 정의하도록 했다.

public abstract class InProgress implements State {
    private final Cards cards;

    public InProgress(Cards cards) {
        this.cards = cards;
    }

    @Override
    public Result determineResult(State otherState) {
        throw new UnsupportedOperationException("게임 종료 전에는 승패를 계산할 수 없습니다.");
    }
}

게임이 종료된 상태를 나타낸다. 게임이 종료되었으므로 카드를 뽑거나 stand하지 못한다.

승패를 결정하는 로직은 상태에 따라 달라지므로 각 구현 클래스에서 정의하도록 하였다.

public abstract class Finished implements State {
    private final Cards cards;

    public Finished(Cards cards) {
        this.cards = cards;
    }

    @Override
    public State draw(Card card) {
        throw new UnsupportedOperationException("게임이 종료된 카드는 카드를 발급받을 수 없습니다.");
    }

    @Override
    public State drawCards(List<Card> cardsToAdd) {
        throw new UnsupportedOperationException("게임 시작시에만 카드를 여러장 받을 수 있습니다.");
    }

    @Override
    public State stand() {
        throw new UnsupportedOperationException("게임이 종료된 카드는 stand할 수 없습니다.");
    }
}

위 두 추상클래스를 적용한 세부 State들을 적용 전(왼쪽) / 적용 후(오른쪽) 비교를 통해 나타내면 아래와 같다.

자세한 코드는 이미지를 눌러서 확인할 수 있고, 중복 코드가 제거되어 전체적으로 간결해진것을 알 수 있다.

InProgress 구현 클래스
Finished 구현 클래스

클래스간의 관계는 아래와 같다.(코드에서 설명하지 않은 메서드도 포함되어있다.)

마지막으로, 상태 패턴을 적용해 플레이어가 결과를 찾아오는 로직은 아래와 같이 한줄로 정리되었다.

위 <기존 코드> 에서 if절들을 통해 계산해오던것에 비하면 간결해진것을 알 수 있다.

플레이어에서 직접 결과를 비교하는것이 아니라 상태를 통해 결과를 도출하고 있다.

public class Player extends Participant {
    ...

    public Result evaluateResult(State otherState) {
        return getState().determineResult(otherState);
    }
}

❯ 상태패턴을 직접 적용해보고 느낀점

- 현재 상태를 어떤 상태 클래스를 가지고 있는지를 통해 직관적으로 표현할 수 있어서 좋았다.

- 상태에 따라 수행할 수 있는 동작을 정의할 수 있어서 편리했다. 기존 코드는 조건문을 작성하고 해당 조건문에 맞는 동작을 기재해주었다. 그런데 상태 패턴을 활용하면 상태별 동작을 해당 클래스에서 정의하고, 메서드를 호출하는쪽에서는 State.해당메서드()만 해주면 되므로 세부적인 동작을 알 필요가 없어서 편리했다.

- 혹시 상태가 추가된다면 클래스만 추가하면 된다. 블랙잭 조건이 추가된다면 도메인에서 조건을 추가해주어야한다. 상태 패턴을 사용하게 되면 기존 코드의 변경 없이 상태만 추가해주면 된다.

- 플레이어에서 조건문을 제거할 수 있다.(역할이 분리되었다) 플레이어에서 하나씩 검증하던 로직을 상태가 일하는 형태로 변경할 수 있었다.

- 반면, 관리 포인트가 늘어났다. 상태별로 클래스가 존재하므로 상태가 많아지면 관리해야하는 포인트도 늘어난다.

- 조건문으로 20줄이면 해결되던 코드가 상태 패턴을 적용하면서 100줄이 넘는 코드가 생성되었다. 상태 패턴이 무조건 답은 아니다.

❯ 어떤 상황에서 사용하면 좋을까

  • 상태에 따라 다르게 행동하는 객체가 있을 때, 상태가 많을 때, 상태별 코드가 자주 변경될 때
    상태가 많지 않거나 상태의 동작이 고정되어 있다면 상태 패턴을 적용하는것이 오히려 관리 포인트만 증가시키는 일이 될 수 있다.
  • 상태, 값에 따라 행동 방식이 변경되는 거대한 조건문을 가진 클래스가 있을 때
    마치 블랙잭에서 if() if() if()와 같이 거대한 if절을 가진 메서드가 있다면 상태 패턴을 고려해봐도 좋을것같다.

디자인 패턴을 처음으로 '인지한 상태'로 적용해봤다. 이론으로만 배웠던걸 적용해보니 처음에는 구조를 생각하기가 쉽지는 않았다.

(심지어 이론은 학습했던 내용이라는것도 지금 블로그 글을 쓰면서 깨달았다..!🤦🏻‍♀️)

앞으로도 과정을 들으면서 새로운 개념이 있으면 내 코드에 직접 적용해보고 적용했을때의 장단점, 나만의 가치관을 확립해가야겠다는 생각이 들었다.

학습하면서 작성한 글이라 잘못된 부분이 있을수도 있다. 글 관련 내용이나 코드 관련 피드백이 있다면 언제든지 환영입니다.😉

참고자료

Refactoring.guru/상태 패턴