본문 바로가기
우아한테크코스/Level 1

[우아한테크코스 5주차] 블랙잭의 위험성에 대하여 🃏

by kriorsen 2024. 3. 16.

📌  블랙잭은 도박 아닌가요?


우리가 블랙잭을 멀리해야 하는 이유는 블랙잭이 카지노에서 행해지는 도박성 카드 게임이라서가 아니다. 그냥 구현이 너무 어렵기 때문이다. 스탠드(Stand), 힛(Hit), 핸드(Hand), 버스트(Bust), 블랙잭(Blackjack) 처음 듣는 용어들 투성이에 정신이 혼미했다. 대부분의 크루들이 나와 같이 블랙잭에 대해 생소한 듯 보였으나, 내 페어는 게임의 룰에 대해 매우 잘 알고 있었다. (캠퍼스에 트럼프 카드도 들고 왔었는데 사용감을 봐서는 돌잡이 때 카드를 잡은 게 아닐까 하는 합리적 의심이...) 아마 도박광 페어가 없었다면 지금쯤 미션 못 끝내서 퇴소당하고 엉엉 울고 있었을지도 🫨 

 

📌  [1단계] - 블랙잭 게임 실행


페어 프로그래밍 절망 같은 희망 편 👀

나는 이전 미션까지는 페어와 생각의 방향이 일치하는 경우가 많았다. 사소한 컨벤션이나 네이밍과 같은 것에서는 다른 부분이 있어도 요구 사항 분석이나 도메인 설계에 있어서는 의견 충돌이 없는 편이었다. 그런데 이번에는 요구 사항 분석에서부터 페어와 의견이 맞지 않았다.

딜러는 처음에 받은 2장의 합계가 16이하이면 반드시 1장의 카드를 추가로 받아야 하고, 17점 이상이면 추가로 받을 수 없다.

 

나는 저 요구 사항을 읽고 딜러의 카드 합계가 16 이하이면 딱 한 장의 카드만 받는 방식이 맞다고 생각했지만, 페어는 딜러가 16점을 넘을 때까지 카드를 계속 추가로 받는 방식을 제안했다. 실제 블랙잭에서는 후자의 방식으로 게임이 진행되지만, 이건 실제 블랙잭이 아니고 미션에서 언급된 것은 한 장이기 때문에 요구 사항의 범위를 벗어나는 것이라고 생각했다.

 

간극이 좁혀지지 않자 다른 크루들의 의견을 물어보기도 했는데, 여전히 나는 실제 블랙잭 룰과 미션으로 구현할 블랙잭 게임의 룰이 일치할 필요는 없다는 입장이었다. 결국 우리는 최후의 수단으로 코치님을 찾아갔다. 나는 정말 소심한 편이라서 코치룸에 들어가기 전에도 코치님이 너무 바쁠 때 찾아와서 화를 내시진 않을까? 하는 걱정을 한 바가지 하고 있었는데, 페어가 너무 아무렇지 않게 들어가서 나도 그냥 어버버 하며 뒤꽁무니를 쫓았다.

 

코치님은 우리의 얘기를 차근차근 들어주신 후, 실제 블랙잭의 룰을 의도하고 미션을 낸 것이라고 알려주셨고, 우리는 출제자의 의도대로 구현하기로 결론을 냈다. 내 의견이 반영되지 않았지만 처음 코치님께 질문을 해보기도 했고, 앞으로 진행할 미션들에서 어떤 방향으로 요구 사항을 분석하고 이해해야 할지에 대한 기준이 잡혀서 좋은 경험이었다고 생각한다.

 

플레이어와 딜러의 중복 코드 제거 😣

이번 미션이 유독 어려웠던 이유는 블랙잭의 복잡한 룰 때문도 있지만, 추가된 요구 사항도 한몫을 했다고 생각한다.

3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
딜러와 플레이어에서 발생하는 중복 코드를 제거해야 한다.

 

인스턴스 변수를 두 개 이하로 쓰면서 딜러와 플레이어에서 발생하는 중복 코드를 제거해야 했다. 나와 페어는 다음과 같은 세 가지 옵션 중에 무엇을 택할지 고민했다.

  1. 인터페이스를 활용하여 구현한다.
  2. 공통된 추상 클래스를 상속하여 구현한다.
  3. 조합(Composition)을 사용하여 구현한다.

일단 중복 코드를 제거해야 하는데 인스턴스 변수를 가질 수 없는 인터페이스는 적합하지 않다고 판단해서 해당 선택지를 제외했다. 나는 추상 클래스를 통해 중복 코드를 제거하는 쪽으로 기울었고, 페어는 조합을 사용하고 싶어 했다. 생각이 잘 정리되지 않은 상태에서 계속 토론을 이어가는 것은 시간 낭비라고 느껴서, 첫날은 고민이 많이 필요하지 않은 InputView에 대한 구현을 하고, 다음날 다시 플레이어와 딜러의 설계에 대해 이야기를 나누기로 했다. 나는 그날 활동이 끝나고 집에 가는 길에도 계속 조합과 추상 클래스를 검색해 보며 각각의 장단점은 무엇이 있는지, 어떻게 나의 페어를 설득하면 좋을지 고민했다. 

 

Participant 추상 클래스를 사용한다면?

추상 클래스를 활용한다면 플레이어와 딜러가 완전히 동일하게 수행하는 역할들을 부모인 Participant에 모아서 관리할 수 있다. 그리고 플레이어와 딜러가 수행하는 공통된 작업은 다음과 같은 것들이 있다:

  • 현재 자신의 Bust 여부를 판단한다.
  • 현재 자신의 Blackjack 여부를 판단한다.
  • Hand에 카드를 한 장 추가한다.
package blackjack.domain.participant;

public abstract class Participant {
    private final Name name;
    private final Hand hand;

    public Participant(Name name, HandGenerator handGenerator) {
        this.name = name;
        this.hand = handGenerator.generate();
    }

    public boolean isBust() {
        return hand.isBust();
    }

    public boolean isBlackjack() {
        return hand.isBlackjack();
    }

    public void addCard(Deck deck) {
        Card card = deck.drawCard();
        hand.addCard(card);
    }
}

 

상위 클래스인 Participant를 상속받은 딜러와 플레이어는 공통된 로직을 제외한 나머지 로직, 즉 자신만 수행하는 기능들에 더욱 집중할 수 있다.

 

package blackjack.domain.participant;

public class Player extends Participant {
    public Player(Name name, HandGenerator handGenerator) {
        super(name, handGenerator);
    }
    
    // 플레이어만 수행하는 기능들
}

package blackjack.domain.participant;

public class Dealer extends Participant {
    public Dealer(HandGenerator handGenerator) {
        super(new Name(DEALER_NAME), handGenerator);
    }
    
    // 딜러만 수행하는 기능들
}

 

 

다음과 같이 플레이어와 딜러가 서로 다르게 동작하는 부분 또한 Participant에 명시하여 특정 메서드의 구현을 강제할 수 있다. 

package blackjack.domain.participant;

public abstract class Participant {
    // 기존 코드들
    
    public abstract List<Card> getInitialOpenedCards();
    public abstract boolean canHit();
}

 

 

추상 클래스 사용 시 장점

  • 공통된 필드와 메서드에 대한 코드 중복 감소
  • 공통된 기능 혹은 필드를 확장하거나 수정해야 할 때, 기반 클래스만 수정하면 되므로 유지보수가 용이
  • 딜러와 플레이어가 반드시 구현해야 하는 추상 메서드 명시 가능

 

상속이 아닌 조합(Composition)을 사용한다면?

내가 떠올린 조합의 방식은 세 가지가 있었다. 우선 첫 번째는 딜러와 플레이어가 각각 Name과 Hand 필드를 가지는 방식이다.

public class Dealer {
    private final Name name;
    private final Hand hand;

    public Dealer(HandGenerator handGenerator) {
        this.name = new Name(DEALER_NAME);
        this.hand = handGenerator.generate();
    }

    public boolean isBust() {
        return hand.isBust();
    }

    public boolean isBlackjack() {
        return hand.isBlackjack();
    }

    public void addCard(Deck deck) {
        Card card = deck.drawCard();
        hand.addCard(card);
    }
    
    // 딜러만 수행하는 기능들
}
public class Player {
    private final Name name;
    private final Hand hand;

    public Player(String name, HandGenerator handGenerator) {
        this.name = new Name(name);
        this.hand = handGenerator.generate();
    }

    public boolean isBust() {
        return hand.isBust();
    }

    public boolean isBlackjack() {
        return hand.isBlackjack();
    }

    public void addCard(Deck deck) {
        Card card = deck.drawCard();
        hand.addCard(card);
    }

    // 플레이어만 수행하는 기능들
}

 

추상 클래스를 사용하는 경우에 비해 중복 코드가 늘어난 것을 확인할 수 있다. 하지만 Participant라는 상위 클래스로 묶여 있지 않기 때문에 결합도가 낮은 것이 장점이다.

 

두 번째 조합 방법은 플레이어와 딜러가 Participant를 필드로 가지는 것이다.

public class Dealer {
    private final Participant participant;

    public Dealer(HandGenerator handGenerator) {
        this.participant = new Participant(new Name(DEALER_NAME), handGenerator);
    }

    public boolean isBust() {
        return participant.isBust();
    }

    public boolean isBlackjack() {
        return participant.isBlackjack();
    }

    public void addCard(Deck deck) {
        participant.addCard(deck);
    }

    // 딜러만 수행하는 기능들
}
public class Player {
    private final Participant participant;

    public Player(String name, HandGenerator handGenerator) {
        this.participant = new Participant(new Name(name), handGenerator);
    }

    public boolean isBust() {
        return participant.isBust();
    }

    public boolean isBlackjack() {
        return participant.isBlackjack();
    }

    public void addCard(Deck deck) {
        participant.addCard(deck);
    }

    // 플레이어만 수행하는 기능들
}

 

현재는 Participant에서 복잡한 로직을 수행하지 않고 있기에 첫 번째 방식과 큰 차이가 없어 보이지만, addCard와 같은 메서드에서 코드가 훨씬 간결해진 것을 볼 수 있다. 여전히 Bust와 Blackjack 여부를 판단하는 부분에서는 중복되는 코드가 존재하지만, 상속을 사용하는 경우에 비해 Participant의 변경이 플레이어와 딜러에게 미치는 영향이 적어 더 유연한 설계가 가능하다. 

 

마지막으로는 딜러가 플레이어를 인스턴스 변수로 가지는 경우에 대해서도 고려했다. 하지만 딜러에게만 필요한 기능을 구현하기 위해 플레이어가 자신의 요구 사항에는 맞지 않는 public 메서드를 추가해야 하는 상황이 발생할 여지가 있어서 좋지 않다고 판단했다.

 

조합(Composition) 사용 시 장점

  • 클래스 간 결합도가 낮아져 코드의 유연성이 증가
  • 시스템의 다른 부분에 미치는 영향 없이 특정 기능을 변경하거나 확장할 수 있음(Participant의 변경이 미치는 영향을 최소화)

혼자 미션을 진행했다면 조합에 대한 고민을 아예 하지 않고 바로 추상 클래스를 선택했을 것 같다. 그러나 페어가 제시한 의견에 대해서도 충분히 고민하고 내 의견과 비교해 보면서 뜻밖에 수확으로 조합의 장점에 대해서도 많이 배우게 되었다. 지금 현재 요구 사항에서는 추상 클래스를 사용하는 편이 코드 중복을 가장 많이 줄일 수 있다. 하지만 플레이어와 딜러에게 요구되는 기능들이 더 많아지고 서로 다르게 동작하는 메서드가 늘어난다면, 조합을 사용하는 것이 더 유리하다고 생각한다. 결과적으로 이번 미션에서는 추상 클래스를 적용했지만, 조합을 사용할 수 있는 적절한 상황이 온다면 이번에 배운 지식들을 잘 활용해서 코드를 작성해 보고 싶다.

 

뷰의 책임을 도메인에게 넘겨도 될까? 🤔

사실 나는 미션을 제출할 때까지만 해도 이 부분에 대해 크게 고민하지 않았다. 그런데 다른 크루들의 PR을 확인했을 때, 생각보다 카드 출력을 위한 문자열들을 뷰에 따로 정의해 놓은 경우가 많아서 리뷰어분께 다음과 같은 질문을 남겼다.

지금 코드에는 Number와 Shape에 출력용 문자열을 저장하고 있는데, 이건 view의 책임을 model에게 전가한 경우에 해당될까요? 저는 특히 값을 많이 가지고 있는 Number의 경우 view가 이에 대한 문자열을 저장하고 변환하는 역할을 수행하는 것이 도메인에 대한 너무 많은 이해를 요구하여 둘 사이 결합도를 높인다고 생각하는데, 리뷰어님의 의견은 어떤지 궁금합니다

 

package blackjack.domain.card;

public enum Number {
    ACE(1, "A"),
    TWO(2, "2"),
    THREE(3, "3"),
    FOUR(4, "4"),
    FIVE(5, "5"),
    SIX(6, "6"),
    SEVEN(7, "7"),
    EIGHT(8, "8"),
    NINE(9, "9"),
    TEN(10, "10"),
    JACK(10, "J"),
    QUEEN(10, "Q"),
    KING(10, "K");

    private final int score;
    private final String symbol;

    Number(int score, String symbol) {
        this.score = score;
        this.symbol = symbol;
    }

    public int getScore() {
        return score;
    }

    public String getSymbol() {
        return symbol;
    }
}

 

나의 코드는 위에서 볼 수 있듯이 도메인 영역에서 출력용 문자열을 모두 저장하고 있는 상태였고, 출력에 대한 요구 사항이 변경된다면 도메인 영역의 코드를 수정해야 하는 방식이었다. 솔직히 지금 방식이 정답이라고 말할 수는 없지만, 뷰에서 변환 로직을 수행하는 것보다는 장점이 더 많다고 생각했다. 

  1. 심볼과 점수가 한 곳에 정의되어 있어 데이터의 일관성을 유지하기 쉽다.
  2. 숫자가 추가되어도 도메인 enum 하나만 수정하면 되므로 유지 보수에 유리하다.
  3. 출력용 문자열로 변환하는 과정이 생략되어 더욱 간결한 코드를 작성할 수 있다.

내 코드의 정당성을 뒷받침할 근거들을 가지고 있었음에도, 다른 크루들이 원칙을 지키며 뷰와 도메인을 완전히 분리하는 것을 보고 '내가 틀린 건 아닐까?' 하는 조바심이 들었다. 그리고 돌아온 리뷰어의 피드백이 나에게 정말 큰 도움이 되었다.

 

그건 바로"모든 원칙을 언제나 반드시 적용해야 하는 것은 아니다."라는 내용이었다. 도메인에 출력용 문자열을 두었을 때와 출력용 문자열을 분리하는 방식을 사용했을 때를 비교해서, 현재에 더 맞는 방식을 사용한다면 그게 더 좋은 방향이라는 말씀을 해주셨다. 어떻게 보면 정말 당연한 일인데, 도메인에 문자열을 두었을 때 얻을 수 있는 장점이 더 많다고 판단했음에도 원칙이라는 단어에 얽매여서 내 코드에 대한 자신감을 가지지 못했던 것 같다.

 

프로그래밍에 정답은 없으니 상황에 따라 유연하게 사고하고, 매 순간 하는 판단이 정말 합리적인지 스스로 점검해 보기 위해 노력하려고 한다. 그리고 스스로 생각했을 때 논리적인 근거가 있는 선택을 했다면, 다른 사람들에게 당당하게 이유를 설명하고, 상대가 더 좋은 방식을 제시한다면 적극적으로 수용하면서 앞으로 더욱 성장하고 싶다.

 

📌  [2단계] - 블랙잭 베팅


2단계에서는 베팅 금액을 입력받고 딜러와 겨뤘을 때 각 플레이어의 승패에 따라 수익률이 결정되는 방식이었다. 기존 승무패만 있던 1단계와 달리 2단계에서는 일반 승무패를 제외하고 블랙잭으로 이기는 케이스가 하나 더 추가되어서 금액을 모두 잃는 경우, 베팅 금액을 그대로 돌려받는 경우, 베팅한 금액만큼 얻는 경우, 베팅 금액의 1.5배를 얻는 네 가지로 분류를 해야 했다.

 

승패 판정 구현 시 요구 사항의 변경 가능성에 대한 많은 고민을 했다. 딜러만 블랙잭일 경우 플레이어가 1.5배로 잃는다는 룰이 추가되거나, 핸드가 777로 뽑혔을 때는 블랙잭과 동일하게 취급한다던가 하는 변경 사항이 생긴다면 어떨까 하는 가정을 했다. 단순히 승무패만 있는 경우에도 조건문이 복잡하게 얽혀서 잘못된 코드를 작성하기 쉬웠는데, 케이스가 더 늘어날 가능성이 있다면 차라리 각각의 경우를 완전히 분리하는 것이 더 안정적인 설계라고 생각하여 PlayerResultMatcher라는 인터페이스를 도입했다.

package blackjack.domain.result.matcher;

import blackjack.domain.participant.Dealer;
import blackjack.domain.participant.Player;

@FunctionalInterface
public interface PlayerResultMatcher {
    boolean isResultMatched(Player player, Dealer dealer);
}

 

그리고 PlayerResultMatcher를 구현하는 네 개의 클래스를 추가했다.

public class PlayerWinMatcher implements PlayerResultMatcher {
    @Override
    public boolean isResultMatched(Player player, Dealer dealer) {
        return dealer.isBust() || !player.isBlackjack() && isPlayerWinning(player, dealer);
    }

    private boolean isPlayerWinning(Player player, Dealer dealer) {
        return !player.isBust() && player.getScore() > dealer.getScore();
    }
}

public class PlayerLoseMatcher implements PlayerResultMatcher {
    @Override
    public boolean isResultMatched(Player player, Dealer dealer) {
        boolean isDealerBust = dealer.isBust();
        if (player.isBust() && !isDealerBust) {
            return true;
        }
        return (!isDealerBust && player.getScore() < dealer.getScore()) || (dealer.isBlackjack() && !player.isBlackjack());
    }
}

public class PlayerDrawMatcher implements PlayerResultMatcher {
    @Override
    public boolean isResultMatched(Player player, Dealer dealer) {
        return !dealer.isBust() && areSameScore(player, dealer) && areSameNaturalBlackjack(player, dealer);
    }

    private static boolean areSameScore(Player player, Dealer dealer) {
        return player.getScore() == dealer.getScore();
    }

    private boolean areSameNaturalBlackjack(Player player, Dealer dealer) {
        return player.isBlackjack() == dealer.isBlackjack();
    }
}

public class PlayerBlackjackWinMatcher implements PlayerResultMatcher {
    @Override
    public boolean isResultMatched(Player player, Dealer dealer) {
        return player.isBlackjack() && !dealer.isBlackjack();
    }
}

 

위 구현체들을 하나로 묶기 위해 결과를 저장하고 있는 HandResult에 구현체의 인스턴스를 추가하였고 결과 판정을 담당하는 Referee 클래스에서 HandResult의 values를 순회하며 매치되는 결과를 반환하는 방식으로 코드가 돌아간다.

package blackjack.domain.result;

import blackjack.domain.participant.Dealer;
import blackjack.domain.participant.Player;
import blackjack.domain.result.matcher.*;

public enum HandResult {
    BLACKJACK_WIN(1.5, new PlayerBlackjackWinMatcher()),
    WIN(1, new PlayerWinMatcher()),
    LOSE(-1, new PlayerLoseMatcher()),
    DRAW(0, new PlayerDrawMatcher());

    private final double profitRate;
    private final PlayerResultMatcher playerResultMatcher;

    HandResult(double profitRate, PlayerResultMatcher playerResultMatcher) {
        this.profitRate = profitRate;
        this.playerResultMatcher = playerResultMatcher;
    }

    public boolean match(Player player, Dealer dealer) {
        return playerResultMatcher.isResultMatched(player, dealer);
    }

    public double getProfitRate() {
        return profitRate;
    }
}

 

그리고 리뷰어께서 하나하나 부분들을 떼어놓고 보면 괜찮은 코드라고 생각하지만, 과한 분리로 인해 전체를 파악하기 어려워진 것 같다는 피드백을 주셨다. 만약 나의 코드를 기반으로 다른 크루가 블랙잭 3단계를 구현해야 한다고 가정했을 때, 결과 판정에 관여하는 클래스 간의 관계를 파악해야 하고, PlayerResultMatcher를 구현하는 모든 클래스를 하나하나 확인해야 한다. 파악이 어려운 코드는 결국 나만 아는 코드, 나만 수정할 수 있는 코드가 될 수 있다는 코멘트를 받고 머리를 한 대 맞은 것만 같았다.

 

나는 프로그래밍을 할 때 요구 사항의 변경과 기능의 확장 가능성에 대한 고려는 많이 하지만, 정작 다른 사람이 나의 코드를 읽고 흐름을 이해하여 작업하는 경우에 대해서는 한 번도 생각해 보지 않았다. 설계가 조금 복잡하더라도 확장에 열려 있는 코드가 무조건적으로 좋은 코드라고 여긴 탓에, 요구 사항의 복잡도를 넘어선 지나친 분리를 하고 있다는 자각을 전혀 못하고 있었다.

 

그래서 내린 결론은 일단 단순하게 작성하고 분명히 확장성이 필요할 때 리팩터링을 하는 연습을 하자는 것이다. 결국 예측했던 변경 사항들이 발생되지 않는다면 복잡한 설계는 그저 이해하기 어려운 코드에 불과하기 때문이다. 어쩌면 나는 리팩터링에 대한 거부감으로 인해, 처음부터 완벽한 설계를 해서, 변화하는 요구 사항에 대응하기 쉬운 코드를 작성하려고 했던 것 같다. 물론 너무 단순하게 코드를 작성하는 것도 변경이 크게 일어나야 하는 상황이 자주 생기므로, 기존의 내 설계 방식에서 리팩터링에 대한 가능성을 조금 더 열어두고, 현재 주어진 요구 사항을 만족하는 것에 초점을 둔 적절한 설계를 하도록 노력할 것이다.