우아한테크코스 7기

[블랙잭 미션] 상태 패턴(State Pattern) 적용하기

seaniiio 2025. 3. 24. 19:05

수업 시간에, 로직에 분기가 생기면 상태 객체로 나눌 수 있다는 것을 배웠다.

 

간단한 예를 들어보자. 

class Crew {

    private String status;
    
    private int calculateStudyingHour() {
        if (status == "방전") {
            return 1;
        }
        if (status == "평범") {
            return 5;
        }
        if (status == "의지활활") {
            return 10;
        }
        return 0;
    }
}

하루 공부 시간을 구하는 로직인데, if문이 3개 존재하고, 크루의 상태에 따라 달라진다.

 

이렇게 크루의 상태에 따라 다르게 동작하는 로직이 Crew 내부에 있다고 해보자.

  • 하루 공부 시간을 구하는 로직
  • 야근 가능 여부를 구하는 로직

...

 

이 경우마다 똑같이 조건 분기를 하고, 조건에 따라 다른 코드를 적어줘야 한다.

 

이러한 경우 로직이 달라지는 기준인 상태(status)를 객체로 보고, 상태(status)가 달라지는 역할을 직접 수행할 수 있다는 것이 상태 패턴(State Pattern)이다.

상태 패턴을 이용하면, 즉 상태를 객체로 만들면 상태에 따라 적절한 동작을 하는 책임을 상태 객체에 위임할 수 있다.

 

🚀 상태 패턴 적용해보기

크루의 상태가 세 가지 있다고 해보자.

  • 방전 상태
  • 평범 상태
  • 의지 활활 상태

상태에 따라 다르게 동작하는 로직을 정리하자.

  • calculateStudyingHour() - 하루 공부 시간을 구하는 로직
  • canWorkLate() - 야근 가능 여부를 구하는 로직
  • listenPosuta() - 상태가 변하는 로직
  • workHard() - 상태가 변하는 로직

그러면 각 상태 객체가 위의 로직들을 구현하되, 내부 구현이 달라질 것이다. 즉, 모든 상태들이 같은 로직을 구현하지만 내부 구현이 다르기 때문에, 인터페이스로 묶어줄 수 있다.

public interface CrewState {
	
    int calculateStudyingHour();
    boolean canWorkLate();
    CrewState listenPosuta();
    CrewState workLate();
}

상태 인터페이스를 통해 상태에 따라 달라지는 기능을 명세할 수 있다.

 

이제 각 상황에 대한 구현 클래스를 만들어보자.

 

🪫 방전 상태

public class DischargeState implements CrewState {

    @Override
    public int calculateStudyingHour() {
        return 1;
    }

    @Override
    public boolean canWorkLate() {
        return false;
    }

    @Override
    public CrewState listenPosuta() {
        return new StrongWilledState();
    }

    @Override
    public CrewState workHard() {
        throw new IllegalStateException("열심히 할 수 없는 상태입니다.");
    }
}

 

🙂 평범 상태

public class NormalState implements CrewState {
    @Override
    public int calculateStudyingHour() {
        return 5;
    }

    @Override
    public boolean canWorkLate() {
        return true;
    }

    @Override
    public CrewState listenPosuta() {
        return new StrongWilledState();
    }

    @Override
    public CrewState workHard() {
        return new DischargeState();
    }
}

 

🔥 의지 활활 상태

public class StrongWilledState implements CrewState {
    @Override
    public int calculateStudyingHour() {
        return 10;
    }

    @Override
    public boolean canWorkLate() {
        return true;
    }

    @Override
    public CrewState listenPosuta() {
        throw new IllegalStateException("폭풍 코딩하다가 포수타를 놓쳤습니다.");
    }

    @Override
    public CrewState workHard() {
        return new DischargeState();
    }
}

 

각 상태에 따라, 기능이 다르게 동작한다는 것을 알 수 있다. 그리고 이렇게 기능이 다르게 동작하는 것이 객체에 의해 이루어진다.

 

그러면 Crew에서는 이제 분기 처리를 해줄 필요 없이(상태에 따라 다르게 동작한다는 책임을 상태 객체로 위임했기 때문), 원하는 기능을 호출해주기만 하면 된다.

 

public class Crew {

    private CrewState status;

    public Crew() {
        this.status = new NormalState();
    }

    public int calculateStudyingHour() {
        return status.calculateStudyingHour();
    }

    public boolean canWorkLate() {
        return status.canWorkLate();
    }

    public void listenPosuta() {
        status = status.listenPosuta();
    }

    public void workHard() {
        status = status.workHard();
    }
}

 

Crew 클래스의 동작을 상태 객체로 분리했기 때문에, Crew의 로직은 단순해졌다.

🃏 블랙잭 미션에 적용하기

블랙잭에서는 "손패의 상태"에 상태 패턴을 적용할 수 있었다. 상태를 분리하려면, 우선 상태에 따라 달라지는 로직을 정리해보자.

  • 손패의 상태에 따라 addCard()의 동작이 달라질 수 있다.
    • Blackjack, Bust, Stand일 때는 addCard()시 IllegalStateException
    • Hit일 때는 손패에 카드 한 장 추가
  • 손패의 상태에 따라 카드 받기를 멈출 수 있다.(stand)
    • Bust, Stand일 때는 stand() 시 IllegalStateException
    • Hit, Blackjack일 때는 멈출 수 있다.(Stand 객체를 return)
  • 손패의 상태에 따라 승자를 결정할 수 있다.
    • 결과를 계산하는 로직은 특히 복잡했고, 가독성이 좋지 않았다.
// 승패 결정 로직
public static WinningStatus determineWinningStatus(
        final BlackjackWinDeterminable myHand,
        final BlackjackWinDeterminable opponentHand
) {
    if (isBlackjack(myHand)) {
        return determineWinningStatusWhenBlackjack(opponentHand);
    }
    if (isBustOccurred(myHand, opponentHand)) {
        return determineWinningStatusWhenBustOccurred(myHand, opponentHand);
    }
    return determineWinningStatusWhenNormal(myHand, opponentHand);
}

private static WinningStatus determineWinningStatusWhenNormal(BlackjackWinDeterminable myHand, BlackjackWinDeterminable opponentHand) {
    if (myHand.getBlackjackSum() > opponentHand.getBlackjackSum()) {
        return WIN;
    }
    if (myHand.getBlackjackSum() == opponentHand.getBlackjackSum()) {
        return DRAW;
    }
    return LOSE;
}

private static WinningStatus determineWinningStatusWhenBlackjack(BlackjackWinDeterminable opponentHand) {
    if (isBlackjack(opponentHand)) {
        return DRAW;
    }
    return BLACKJACK_WIN;
}

private static boolean isBustOccurred(BlackjackWinDeterminable myHand, BlackjackWinDeterminable opponentHand) {
    return isBust(myHand) || isBust(opponentHand);
}

private static WinningStatus determineWinningStatusWhenBustOccurred(BlackjackWinDeterminable myHand, BlackjackWinDeterminable opponentHand) {
    if (isBust(myHand) && isBust(opponentHand)) {
        return WinningStatus.DRAW;
    }
    if (isBust(myHand)) {
        return WinningStatus.LOSE;
    }
    return WinningStatus.WIN;
}

private static boolean isBust(final BlackjackWinDeterminable cardHand) {
    return cardHand.getBlackjackSum() > BUST_THRESHOLD;
}

private static boolean isBlackjack(final BlackjackWinDeterminable cardHand) {
    return cardHand.getBlackjackSum() == BLACKJACK_SUM && cardHand.getSize() == BLACKJACK_CARD_SIZE;
}

 

블랙잭 미션에서 손패의 상태를 객체로 관리하도록 리팩터링 해보자.

 

가능한 손패의 상태는 아래와 같다

  • Hit(카드를 받을 수 있는 상황)
  • Stand(카드 받기를 멈춘 상황)
  • Bust(합이 21이 넘어 버스트된 상황)
  • Blackjack(합이 21이고 카드가 2장으로 블랙잭인 상황)

여기에 초기 상태 객체인 Start까지 만들어주면, 상태 패턴을 적용할 수 있다.

 

그리고 손패의 상태에 따라 달라지는 로직을 다시 정리해보자.

  • IsBust() - 버스트인지 확인
  • isBlackjack() - 블랙잭인지 확인
  • isFinished() - 카드를 더이상 받을 수 없는지 확인
  • addCard() - 카드를 받을 수 있는 상태면 더 받음
  • stand() - 카드 받기를 멈춤
  • determineWinningStatus(다른 손패) - 승자 결정

그러면 손패 상태 인터페이스에, 위의 기능들을 명세해주면 된다.

public interface BlackjackCardHandState {

    boolean isBlackjack();
    boolean isBust();
    boolean isFinished();
    BlackjackCardHandState addCard(Card card);
    BlackjackCardHandState stand();
    WinningStatus determineWinningStatus(BlackjackCardHandState otherState);
}

 

Blackjack 상태

public class Blackjack {

    private final BlackjackCardHand cardHand;
    
    public Blackjack(final BlackjackCardHand cardHand) {
        this.cardHand = cardHand;
    }
    
    @Override
    public boolean isBlackjack() {
        return false;
    }
    
    @Override
    public boolean isBust() {
        return false;
    }
    
    @Override
    public boolean isFinished() {
        return true;
    }
    
    @Override
    public WinningStatus determineWinningStatus(final BlackjackCardHandState otherState) {
        if (otherState.isBlackjack()) {
            return WinningStatus.DRAW;
        }
        return WinningStatus.WIN;
    }
    
    @Override
    public BlackjackCardHandState addCard(final Card card) {
        throw new IllegalStateException("카드 뽑기가 이미 종료되었습니다.");
    }
    
    @Override
    public BlackjackCardHandState stand() {
        throw new IllegalStateException("이미 stand 상태입니다.");
    }
}

상태 인터페이스를 위와 같이 구현할 수 있을 것이다. Blackjack 상태 객체에는 손패가 블랙잭일때의 동작 적어주면 된다.

 

이런식으로 모든 상태를 구현해주면 된다.

 

가장 복잡했던 승패를 구하는 로직이 각 상태 객체에 분산되면서, 최대 depth가 1로 줄어들었으며 가독성도 좋아졌다.

@Override
public WinningStatus determineWinningStatus(final BlackjackCardHandState otherState) {
    if (otherState.isBlackjack()) {
        return WinningStatus.LOSE;
    }
    if (otherState.isBust() || getBlackjackSum() > otherState.getBlackjackSum()) {
        return WinningStatus.WIN;
    }
    if (getBlackjackSum() < otherState.getBlackjackSum()) {
        return WinningStatus.LOSE;
    }
    return WinningStatus.DRAW;
}

예를 들어 위의 코드는 손패가 Stand 상태일 때 승패 여부를 판단하는 로직이다. 모든 상태 중 가장 복잡한 구현인데도, 기존 코드보다 훨씬 가독성이 좋다. 현재 내 상태는 Stand이기 때문에, 내 상태가 Blackjack이거나 Bust인 경우는 고려하지 않아도 되고, 상대 손패의 상태만 보고 판단하면 되기 때문이다.

 

추가적으로 상태를 크게 두 가지로 분류할 수 있다.

  • Finished - 카드를 더 받을 수 없는 상태
    • Blackjack
    • Bust
    • Stand
  • Running - 카드를 더 받을 수 있는 상태
    • Hit
    • Start

각 상태가 Finished, Running 추상 클래스를 상속받게 하면, Finished, Running 상태별 공통 동작을 묶어줄 수 있다.

// Finished 상태의 공통 로직(Blackjack, Bust, Stand)
@Override
public BlackjackCardHandState addCard(final Card card) {
    throw new IllegalStateException("카드 뽑기가 이미 종료되었습니다.");
}

@Override
public BlackjackCardHandState stand() {
    throw new IllegalStateException("이미 stand 상태입니다.");
}