블랙잭 게임 분석 - 1

이전 글에서 블랙잭 게임을 다시 시작하고자 했다.
이번 프로젝트는 우테코 2기 프리코스에서 진행된 프로젝트이다.
전체 코드를 확인 가능하다.
이 코드는 kouz95 님의 코드를 기반으로 하고 있습니다.

이번엔 패키지 구조와 각 클래스의 구성 하나하나 뜯어보려고 한다.
양이 후덜덜 하겠지.. ㅠ?

요구 사항 분석

패키지 분석

  • domain
    • card
      • Card.class
      • CardRepository.class
      • Cards.class
      • Deck.class
      • Symbol.enum
      • Type.enum
    • controller
      • blackjackController.class
    • user
      • strategy.draw
        • DealerDrawStrategy.class
        • PlayerDrawStrategy.class
        • DrawStrategy.interface
      • Dealer.class
      • Player.class
      • User.interface
      • Users.class
    • BettingMoney.class
    • PlayerIntentionType.enum
    • PlayerMoneys.class
    • ResultType.enum
    • ScoreType.enum
  • view
    • InputView
    • OutputView

ㅗㅜㅑ...
이것이야 말로 단일 책임의 끝판왕;;
이제까지 내가한 프로젝트를 바이트 단위로 쪼개버리고 싶다.

나같은 모지리는 클래스 하나하나에 들어있는 숨결까지 온전히 느껴야 한다..
스압 가보즈아.

분석

Card.class

public class Card {
    private final Symbol symbol;
    private final Type type;

    public Card(Symbol symbol, Type type) {
        this.symbol = symbol;
        this.type = type;
    }

    public Symbol getSymbol() {
        return symbol;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Card card = (Card) o;
        return symbol == card.symbol && type == card.type;
    }

    @Override
    public int hashCode() {
        return Objects.hash(symbol, type);
    }

    @Override
    public String toString() {
        return symbol.getName() + type.getName();
    }
}

Enum으로 선언된 Symbol과 Type으로 이루어진 블랙잭 게임의 핵심 객체 카드이다.

별다르게 볼것은 없지만 equlas()와 hashCode가 오버라이딩 되어있는 것이 인상적이다. oop를 이렇게 극한까지 따르시다니

Symbol.enum

public enum Symbol {
    ACE(1, "A", score -> score <= 11, () -> 10),
    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 name;
    private final Predicate<Integer> promotionJudge;
    private final Supplier<Integer> bonusScore;

    Symbol(int score, String name) {
        this(score, name, i -> false, () -> 0);
    }

    Symbol(int score, String name, Predicate<Integer> promotionJudge, Supplier<Integer> bonusScore) {
        this.score = score;
        this.name = name;
        this.promotionJudge = promotionJudge;
        this.bonusScore = bonusScore;
    }

    public int getPoint() {
        return score;
    }

    public String getName() {
        return name;
    }

    public boolean isPromotable(int score) {
        return promotionJudge.test(score);
    }

    public int getBonusPoint() {
        return bonusScore.get();
    }
}

일단 눈여겨 봐야 할것은 Predicate와 Supplier의 사용이지 않을까 싶다.
앞의 최근 게시물은 모두 여기를 위한 빌드업..!

일단 생성자를 보자

생성자는 두가지가 있다

Symbol(int score, String name)
Symbol(int score, String name, Predicate<Integer> promotionJudge, Supplier<Integer> bonusScore)

이넘의 값을 보면 ACE의 경우 Predicate와 Supplier가 있고 나머지 속성에는 없다.

ACE를 먼저 분석해보자
ACE(1, "A", score -> score <= 11, () -> 10)
두번째 속성인 Predicate는 매개변수를 일정 조건을 통해 검사하여 논리 값을 반환한다.
Integer의 매개변수인 score를 검사해 score <= 11 의 결과를 반환할 것이다.

세번째 속성인 Supplier는 요청 시 함수의 특정 값을 반환하는 제공자이다.
Supplier.get()이 호출되면 () -> 10을 통해 10을 반환할 것이다.

그렇다면 이 두 속성이 없는 값들의 경우

Symbol(int score, String name) {
    this(score, name, i -> false, () -> 0);
}

이 생성자를 타게 되고,
Predicate의 경우 매개 변수 i가 들어오면 상수 false를 반환하는 람다식을 인스턴스로 갖게된다.
Supplier의 경우도 마찬가지로 호출 시 0을 반환하는 인스턴스를 갖는 Enum값이 된다.

CardRepository.class

public class CardRepository {
    private static final List<Card> cards;

    static {
        cards = Arrays.stream(Symbol.values())
                .flatMap(CardRepository::mapToCardByType)
                .collect(Collectors.toList());
    }

    private static Stream<Card> mapToCardByType(Symbol symbol) {
        return Arrays.stream(Type.values())
                .map(type -> new Card(symbol, type));
    }

    public static List<Card> toList() {
        return Collections.unmodifiableList(cards);
    }
}

일단 Repository라 함은..
모델에서 데이터베이스에 접근하는 메소드를 사용하기 위한 인터페이스인데..
이 자바 프로젝트에서는 Repository가 어떻게 쓰였는지 살펴봐야 역할을 제대로 알 수 있을것 같다.

일단 클래스 초기화 블럭에서 Card의 일급 컬렉션을 정의해주고 있다.
이전 블랙잭 게임에서 고민했던 enum의 2중 포문 문장을 메서드로 분리해서 깔끔하게 1 depth로 처리한 모습이 인상적이다.

메서드를 살펴보면 바깥쪽 루프는 flatMap이고 안쪽 루프는 map을 사용했다.

  • map : 단일 스트림 안의 요소를 원하는 특정 형태로 변환할 수 있다.
  • flatMap : 모든 원소를 단일 원소 스트림으로 반환할 수 있다.

map() 및 flatMap() 메소드와 둘 다 중간 스트림 조작이며 다른 스트림을 메소드 출력으로 리턴한다.
map()과 flatMap() 의 주요 차이점은 두 메소드의 리턴 유형이다.


map()객체 스트림이있을 때 연산 을 사용할 수 있으며 스트림의 각 요소에 대해 고유 한 값을 가져와야한다.
일대일 입출력 요소 사이의 매핑.
예를 들어, 직원 스트림에서 모든 직원의 생년월일 을 찾는 프로그램을 작성할 수 있다 .
flatMap()의 경우, 각 입력 요소 / 스트림에 대해 일대 다 매핑이 생성되어 먼저 여러 값을 얻은 다음 모든 입력 스트림의 값을 단일 출력 스트림으로 병합한다.
예를 들어, 텍스트 파일의 모든 줄에서 모든 지구 단어 를 찾기위한 프로그램을 작성할 수 있다.
Java Stream map(), flatMap() 차이

일단 여기까지 봤을때,
Repository의 역할은 카드 객체들을 일급 컬렉션으로 래핑하고 생성해 실제적인 데이터로 만드는 과정을 담당하는 것 같다.

Cards.class

public class Cards {
    private static final int INITIAL_CARDS_SIZE = 2;

    private final List<Card> cards;

    public Cards(List<Card> cards) {
        this.cards = cards;
    }

    public void add(Card card) {
        cards.add(card);
    }

    public boolean isInitialSize() {
        return cards.size() == INITIAL_CARDS_SIZE;
    }

    public boolean isNotInitialSize() {
        return cards.size() != INITIAL_CARDS_SIZE;
    }

    public int getPoint() {
        int point = cards.stream()
                .map(Card::getSymbol)
                .mapToInt(Symbol::getPoint)
                .sum();

        int bonusPoint = cards.stream()
                .map(Card::getSymbol)
                .filter(symbol -> symbol.isPromotable(point))
                .mapToInt(Symbol::getBonusPoint)
                .findFirst()
                .orElse(0);

        int resultPoint = point + bonusPoint;
        return ScoreType.of(resultPoint).getScore(resultPoint);
    }

    public List<Card> toList() {
        return Collections.unmodifiableList(cards);
    }
}

이 객체를 Card의 일급 컬렉션으로 오해할 수도 있지만 이 객체의 역할은 딜러와 플레이어들의 핸드를 담당하는 작은 CardList이다.
핸드의 점수 계산과 장 수 카운팅을 담당하는데 인상적인 부분은
isInitialSize(), isNotInitialSize() 메서드 였다.
한가지 메서드를 그냥 ! 부정 해버리면 되는데 가독성을 위해 따로 뺀거 같은데,
이게 좋은지 생각 해봐야겠다.

Deck.class

public class Deck {
    private final Stack<Card> deck;

    private Deck(Stack<Card> deck) {
        this.deck = deck;
    }

    public static Deck of(List<Card> cards) {
        Stack<Card> deck = new Stack<>();
        cards.forEach(deck::push);
        Collections.shuffle(deck);
        return new Deck(deck);
    }

    public Card pop() {
        return deck.pop();
    }
}

게임을 진행하게 될 카드의 덱이다.
일급 컬렉션인 Cards에서 연산을 위해 Stack으로 바꾸어 준 것이다.
간단하지만 깔끔한 코드가 인상적이었고, 프로젝트 종종 of() 메서드가 나오는데 어떤 의미로 of라는 네이밍을 한건지 궁금하다.
구성하다 이런 의미인가?..

User.interface

public abstract class User {
    private static final int BLACKJACK_SCORE = 21;
    private static final int INITIAL_HANDS_SIZE = 2;

    protected Cards hands;
    protected DrawStrategy drawStrategy;

    protected User() {
        this.hands = new Cards(new ArrayList<>());
    }

    public void proceedInitialPhase(Deck deck) {
        for (int i = 0; i < INITIAL_HANDS_SIZE; i++) {
            hands.add(deck.pop());
        }
    }

    public boolean canDrawMore() {
        return drawStrategy.canDraw(hands.getPoint());
    }

    public void receive(Card card) {
        hands.add(card);
    }

    public boolean isNotBlackJack() {
        return hands.isNotInitialSize() || hands.getPoint() != BLACKJACK_SCORE;
    }

    public int getScoreMinusBy(User compared) {
        return hands.getPoint() - compared.hands.getPoint();
    }

    public Cards openAllCards() {
        return hands;
    }

    public abstract Cards openInitialCards();

    @Override
    public abstract String toString();
}

User의 근간을 이루는 인터페이스.
어떤 메서드가 있는지 가볍게 보고 넘어가면 될것이다.
인상적인 것은 protected 접근 제어자 인데, 난 실제로 처음 접한다.

  • protected : 같은 패키지 내에서, 다른 패키지의 자손 클래스에서 접근 가능

Dealer.class

public class Dealer extends User{
    private static final int FIRST_CARD_INDEX = 0;

    public Dealer() {
        drawStrategy = new DealerDrawStrategy();
    }

    @Override
    public Cards openInitialCards() {
        return new Cards(Collections.singletonList(hands.toList().get(FIRST_CARD_INDEX)));
    }

    @Override
    public String toString() {
        return "딜러";
    }
}

User를 상속받는 Dealer이다.
굉장히 간결한데 Dealer가 가지고 있는 책임이라곤, 요구사항의 한장의 카드를 공개하는 것과 toString 뿐이다.

Player.class

public class Player extends User{

    private final String name;

    public Player(String name) {
        if (name.isEmpty()) {
            throw new IllegalArgumentException("이름이 빈 문자열입니다.");
        }
        this.name = name;
        drawStrategy = new PlayerDrawStrategy();
    }

    public Player(String name, List<Card> cards) {
        this(name);
        hands = new Cards(cards);
    }

    @Override
    public Cards openInitialCards() {
        return hands;
    }

    @Override
    public String toString() {
        return name;
    }
}

Dealer와 큰 차이는 없다.

Users.class

public class Users implements Iterable<User> {
    private static final int MAX_PLAYERS_COUNT = 8;
    private static final int MIN_PLAYERS_COUNT = 2;
    private static final String SPLIT_DELIMITER = ",";

    private final List<User> users;

    private Users(List<User> players, User dealer) {
        players.add(dealer);
        if (players.size() > MAX_PLAYERS_COUNT) {
            throw new IllegalArgumentException("블랙잭의 최대 인원은 8명입니다.");
        }
        if (players.size() < MIN_PLAYERS_COUNT) {
            throw new IllegalArgumentException("블랙잭의 최소 인원은 2명입니다.");
        }
        this.users = players;
    }

    public static Users of(List<User> players, User dealer) {
        return new Users(players, dealer);
    }

    public static Users of(String playerNames, User dealer) {
        return of(Arrays.stream(playerNames.split(SPLIT_DELIMITER))
                .map(String::trim)
                .map(Player::new)
                .collect(Collectors.toList()), dealer);
    }

    public List<Player> getPlayers() {
        return users.stream()
                .filter(user -> user.getClass() == Player.class)
                .map(user -> (Player) user)
                .collect(Collectors.toList());
    }

    @Override
    public Iterator<User> iterator() {
        return users.iterator();
    }
}

User를 상속 받은 객체들의 관리를 위한 일급 컬렉션 객체이다.
특이한 점은Iterable<User>를 구현하고 있다는 것

Iterable 인터페이스는 최상위 인터페이스로 Collection 인터페이스에서 상속하고 있다.
내부를 살펴보면 디폴트 메소드인 forEach 메소드와 spliterator 메소드를 가지고 있고 추상 메소드로 iterator 메소드를 가지고 있다.
Spliterator 인터페이스는 기존 존재했던 반복자인 Iterator 와 비슷하지만
자바 8에서 추가된 병렬 작업에 특화된 인터페이스이다.


Iterable를 통해 forEach문을 사용한다는 것은 stream의 생성비용 없이 반복문을 사용할 수 있다는 것이다.


Iterable 을 구현한 클래스에서는
반드시 iterator() 메소드를 override해야 한다.
Collection인터페이스에서는 Iterable 을 상속하고 있기 때문에 Collection 인터페이스를 상속하는 인터페이스에서도 Iterable을 상속하고 있고 List, Queue, Set 인터페이스 등을 구현하는 ArrayList, LinkedList 클래스 등에서는 Iterable의 Iterator 메소드를 override하고 있다.
때문에 우리가 Collection을 쓰면서 for-each-loop를 사용할 수 있는 것이다.


Iterable을 구현하지도 않은 객체에서 iterator() 메소드를 사용해서 반복하는 for-each loop를 어떻게 사용할 수 있을까?
결론은 for-each loop를 일반 for문으로 컴파일러가 번역해준다.
Iterator 인터페이스와 Iterable 인터페이스

좋은 웹페이지 즐겨찾기