[우아한테크코스 6기] 블랙잭 미션 회고

,

우여곡절 끝에 블랙잭 미션 2단계가 머지됐다🎉 2단계를 진행할 때, 책임 분배가 잘못되었음을 깨닫고 다시 엎는 일이 두 번이나(…) 있었다. 테스트 못지않게 단단한 설계가 선행되어야 한다는 것을 알게 되었던 미션이었다. 이번 글에서는 내가 고민했던 부분을 위주로 작성하고자 한다.

코드에 의도를 담자

매번 의식하려고 노력하지만 잘 되지 않는 부분이다. 이번에는 코드 한 줄 쓸 때마다 내가 어떤 생각을 가지고 타이핑했는지 돌아보았다. 왜 private으로 닫았는지, 왜 package-private으로 열었는지와 같은 생각, 메서드 이름을 어떻게 하면 더 좋을지, 도메인 지식을 담아낼 수 있을지와 같은 질문들을 스스로 대답하는 데에 시간을 보냈다.

☝🏻 접근제한자를 사용해 보자

지금까지 코드를 작성하면서 아래와 같이 메서드 이름만 달랑 써 본 적이 있을까? 자바를 경험한 지 오래되지 않아서 그런지 메서드의 앞에 public, private, protected까지는 써 보았지만 단순히 열고 닫는 용도로만 사용했다. 그냥 다들 열고 닫으니까, 숨길 것은 숨기라고 하니까 기계적으로 작성했다.

public class Hand {
    private final List<Card> cards;
    
    Hand hand(List<Card> cards) { // <- package-private
        this.cards = cards;
    }
}

public은 ‘나는 열려 있으니 누구나 사용할 수 있어요‘라는 의도가 담겨 있다. 이 메서드를 가지고 있는 객체를 다른 객체에 넘기는 것은 위험할 수 있다. 잠재적으로 건네받은 객체의 메서드를 호출할 수 있고, 이에 따른 부작용side-effect를 고려해야 한다.

앞에 아무것도 붙지 않은 package-private 메서드는 같은 패키지에서만 사용할 수 있다. 이 특성을 통해서 ‘다른 사람들은 쓰지 말고, 나와 상호작용할 가까운 클래스에게만 열어 둘게요‘라는 의도를 담는다. 많지는 않지만 테스트에서 적절하게 상태를 조작해야 하는 경우 (좋은 방법이라고 생각하지는 않는다), 혹은 바깥에서 불리지 않아 나를 부를 사람이 누구인지를 명확하게 하고 싶은 경우에 이를 사용하면 좋겠다.

✌🏻 잘 읽히는 코드를 작성하자

코드는 문학이라는 말을 듣고 고개를 끄덕인 적이 있다. 내 코드를 모든 사람이 만족할 상황은 일어나기 어렵겠지만, 열 명 중 여덟 명 정도에게서 인정받을 정도의 코드를 짜면 충분하다는 말이다. 그러기 위해서는 코드가 우선 잘 읽혀야 한다고 생각한다. 읽기 어려운 코드는 평가하기도 어렵다.

메서드 이름과 파라미터 이름을 이어서 읽을 때 자연스럽도록 이름을 지으려고 노력했다. 덱을 구성하기 위해 모양과 수의 모든 조합을 만드는 아래와 같은 코드를 생각해 보자.

private Deck createFullDeck() {
    List<Card> cards = new ArrayList<>();
    for (Shape shape : Shape.values()) {
        for (Number number : Number.values()) {
            cards.add(Card.of(shape, number));
        }
    }
    return new Deck(cards);
}

사실 이 코드도 충분히 가독성 있다고 생각한다(…) 하지만 미션에서는 들여쓰기 제한 조건(1)을 지켜야 한다. 내부 for문을 메서드로 뽑거나, 전체 반복 로직을 stream을 통해 연산하도록 해야 조건을 만족하면서 코드를 쓸 수 있다.

Arrays.stream(Shape.values())
        .flatMap(shape -> Arrays.stream(Number.values())
                .map(number -> Card.of(shape, number)))
        .toList();

하지만 stream을 쓰면 굉장히 보기 어렵다. 2중 for문은 바로 어떤 카드들이 만들어지는지 보이는 반면에, streamflatMap부터 내부에서 새로 stream을 생성하는 것이 가독성을 해치는 느낌이 든다. 결국 내부 for문을 메서드로 뽑고, 메서드의 이름을 createNumberCardsOf(Shape shape)와 같이 작성했다. 파라미터의 타입까지 읽으니 “Shape을 가진 Number 카드를 만들어 줄게!” 라는 의도가 명확하게 드러나게 되었다.

🤟🏻 코드를 짜기 전에 도메인 공부를 하자

생각보다 이름에 의미를 담고 싶지만 적절한 단어나 문장이 떠오르지 않아 어려울 때가 많다. 가령, 에이스 카드가 11점으로 계산될 수 있는 손패인 경우에는 11점으로 계산하는 게 유리하다. 이를 계산하는 메서드는 어떤 이름을 가져야 하고, 조건문 내부는 어떻게 적어둬야 할까?

public Score addAceScoreOnNotBust() {
    if (value + ADDITIONAL_ACE_SCORE <= BLACKJACK_SCORE) {
        return this.add(Score.of(ADDITIONAL_ACE_SCORE));
    }
    return this;
}

매직 넘버를 상수도 빼 두었고, 잘 읽히는 것 같아서 만족했었던 코드이다. 메서드 이름도 “버스트가 아니면 추가 에이스 점수를 얻을 수 있다”는 것을 명시했다. 조금 더 개선할 수 있을까? 우선 조건문 안을 직접 계산해가면서 이해하는 인지적 부담이 있다. 이를 메서드로 꺼내서 문장처럼 읽히게 하면 좋겠다.

public Score addAceScoreOnNotBust() {
    if (isNotBustOnAdditionalScore()) {
        return this.add(Score.of(ADDITIONAL_ACE_SCORE));
    }
    return this;
}

오? 보기 좋은 코드가 되었다. if부터 내부 조건까지 한 번에 읽히니 보다 쉽게 이해할 수 있었다! 하지만 여기에 도메인 지식을 조금 더 넣으면 훨씬 간결한 코드가 된다.

카드 한 장을 뽑으니 A를 계산하는 방법이 달라진다. Soft/Hard hand

블랙잭에서는 현재 손패에서 A(에이스)를 11점으로 올릴 수 있는 상황의 손패를 Soft Hand, 그렇지 않은 경우를 Hard Hand라고 한다. 이를 활용하면 OnNotBust와 같은 장황한 상황 설명이 필요하지 않게 된다.

public Score addAceScoreOnSoftHand() {
    if (isSoftHandScore()) {
        return this.add(Score.of(ADDITIONAL_ACE_SCORE));
    }
    return this;
}

책임을 생각하자

1단계는 페어와 함께 고민한 시간이 많았었고, 나름의 근거가 있어서 금방 머지되었지만, 2단계는 아니었다. 이번 미션에 새로 생긴 제약 조건이 있었는데, 인스턴스 필드를 두 개 이상 가지지 않도록 설계하는 것이다. 2단계 미션은 돈이라는 개념이 추가됐다. 배팅하는 금액에 따라 수익을 계산해야 했다. 플레이어가 돈을 가지는 게 당연했지만, 지금 플레이어는 이름과 손패를 이미 가지고 있다. 추가적인 추상화나 클래스로 빼내는 게 필요해 보였다. 역할과 책임에 대한 생각을 다시 할 때이다.

🤔 상태를 추상화해 볼까?

필드를 어떻게 줄일까 고민하다가, 플레이어의 상태를 하나의 클래스로 추상화하자는 생각이 들었다. 각 상태는 다른 상태로 전이할 수 있고, 마치 오토마타의 DFA처럼 상태 전이 그림을 그릴 수 있었다.

뭔가 갈피가 잡히는 것 같았고, 이를 토대로 구현했다. 각 상태는 플레이어를 대표했고, 카드를 뽑으면서 스스로 다음 상태로 전이됐다. 플레이어는 이름과 상태를 가지게 되었고, 요구사항을 만족하는 코드를 완성했다. 하지만 이 때부터 페어가 정말 필요하다는 생각을 가지게 되었는데…

😱 책임이 많다, 너무 많다

손패를 상태로 밀어넣다 보니, 상태(게임에서 플레이어가 어떤 상황인지)가 상태를 가지는stateful(?) 일이 생겼다. 나아가 주도적으로 자신의 상태를 확인해 다른 상태와의 승부 결과를 얻기도 했다. 벌써 적은 책임만 두 가지인데, 적어둔 것 말고도 더 많은 일을 하고 있었다.

리뷰어분이 먼저 이야기해주셔서 나의 코드를 돌아볼 수 있게 되었다. 누구의 책임인가. 카드를 뽑는 건 플레이어가 해야 할 것 같다. 상태에서 카드를 뽑으면 플레이어의 의미가 퇴색된다. 상태가 모든 일을 다 떠맡게 되었다. 결국 모든 것은 원점으로 돌아갔고, 2단계를 처음부터 차곡차곡 쌓기 위해 설계를 다시 해보기로 했다. 페어가 그리웠다 😱

🤪 왜 상태를 도입하려고 했을까

나는 두 플레이어의 승부 결과를 만드는 과정에서의 if-else 분기가 가지는 인지적 부담이 매우 높다고 생각했다. 내가 블랙잭이면… 내가 버스트라면… 내가 스탠드라면… 여러 가지 경우의 수 때문에 만약-그게 아니라면 구조가 계속해서 생겼고, 인덴트 제한도 있었기 때문에 early return을 사용하는 식이었다.

public static MatchResult chooseWinner(int playerScore, int dealerScore) {
	if (isPlayerWinningCondition(playerScore, dealerScore)) {
		return PLAYER_WIN;
	}
	if (isDealerWinningCondition(playerScore, dealerScore)) {
		return DEALER_WIN;
	}
	return TIE;
}

private static boolean isPlayerWinningCondition(int playerScore, int dealerScore) {
	if (isBurst(playerScore)) {
		return false;
	}
	return isBurst(dealerScore) || playerScore > dealerScore;
}
// 추가 딜러 승리 조건, 버스트 여부 메서드 ...

2단계에서는 블랙잭 여부도 수익에 연결되었기에, 플레이어가 어떤 방식으로 이겼는지도 고려대상에 포함되었다. 위에서 아래로 if-else를 뿌리고 싶지 않았다. 상태 전이를 사용하지 않고 어떻게 플레이어간의 승패 정보를 알아낼 수 있을지 고민을 많이 했다.

🥳 종료 상태를 정의하자

딜러, 플레이어 모두 더 이상 카드를 뽑지 않는 순간이 존재한다. 버스트 되었거나bust, 뽑지 않기로 결정했거나stand, 블랙잭인 경우blackjack이다. 플레이어와 딜러의 각 상태를 조합하면 $3 \times 3 = 9$가지 경우의 수가 나온다. 이 중에서도 이기고 지는 것을 조금 간략하게 하면, 다음과 같이 정리할 수 있다.

P BlackJack
	D Bust:      Participant win (ParticipantBlackJack)
	D stand:     Participant win (ParticipantBlackJack)
	D BlackJack: Draw (Tie)
P Bust → Dealer win (ParticipantBust)
P Stand
	D Bust :     Participant win (DealerBust)
	D Stand:     Compare score
	D BlackJack: Dealer win (DealerBlackJack)

각 상태를 정의하는 것은 간단한 and/or 연산으로 가능하다. 예를 들어, ParticipantBust 상태는 플레이어가 지고, participant.isBust()인 경우이다. ParticipantBlackJackWin 상태는 플레이어가 이기고, participant.isBlackJack() && dealer.isNotBlackJack()인 경우이다.

각 상태를 인터페이스로 감싸고, 플레이어와 딜러가 주어졌을 때, 두 명의 결과 상태가 현재 클래스인가? 를 확인하는 함수를 사용하면, 모든 상태를 돌면서 해당 상태를 찾아낼 수 있다.

private static final List<ResultState> states = List.of(
        new ParticipantBlackJack(),
        new DealerBlackJack(),
        new ParticipantBust(),
        new DealerBust(),
        new ParticipantWinByScore(),
        new DealerWinByScore()
);

public static MatchResult judge(Player participant, Player dealer) {
    return states.stream()
            .filter(state -> state.isCapableWith(participant, dealer))
            .findFirst()
            .orElse(new Tie())
            .getMatchResult(); // <- 누가 이겼는지 각 State에서 정의한다.
}

이 결과를 알아내는 건 Referee라는 클래스가 담당한다. 플레이어는 다시 카드를 뽑는 일을 하고, 심판은 두 딜러와 플레이어를 데려다가 결과를 알아내는 책임을 맡았다. 훨씬 읽기 좋은 코드가 된 것 같아 좋았다 🤩

가독성과 책임에 대한 좋은 이야기를 들어서 뿌듯하다 😀

TDD를 통해 위에서 아래로 쪼개나가면서 진행했는데, 지금까지의 미션은 설계와 구현을 동시에 하고 있었다고 생각한다. 조금만 더 도메인 로직이 복잡해지니 리팩터링과 테스트 작성에 어려움을 겪었다. 탄탄한 설계를 기반으로, TDD를 통해 도메인 지식을 구체화하는 방법의 갈피를 잡은 듯했다. 두 개는 별개이니 둘 다 시간을 투자해야 한다.

다음 미션은 체스인데, 무려 4단계까지 있어 겁부터 난다 😱 이번에는 엎는 일 없이, 설계를 통해서 점진적인 발전이 가능하도록 코드를 작성해보려고 한다. 다음 페어와 많은 이야기를 설계에 투자해야겠다 👻

Categories