[🚀 사이클1 - 미션 (블랙잭 게임 실행)] 코코 미션 제출합니다.#1004
[🚀 사이클1 - 미션 (블랙잭 게임 실행)] 코코 미션 제출합니다.#1004yejinkimis wants to merge 63 commits intowoowacourse:yejinkimisfrom
Conversation
NewWisdom
left a comment
There was a problem hiding this comment.
코코 안녕하세요~! 이번 미션 리뷰어로 함께하게 된 아마찌라고 합니다 🙂
미션 아주 깔끔하게 잘 구현해주셨네요!
몇가지 사소한 코멘트와 질문에 대한 답변 남겨놓았는데 한번 확인해보시고 코코의 의견대로 반영해주시면 좋을 것 같습니다 :)
궁금하신 사항은 언제든지 코멘트로 편히 남겨주세요!
이번 미션 끝까지 화이팅입니다👏
| ); | ||
| } | ||
| private List<String> inputGamblersInfo() { | ||
| String name = inputView.askGamblerNames().name(); |
There was a problem hiding this comment.
inputView 에 있는 모든 메서드들이 static 메서드인 것 같은데,
그렇다면 InputView 를 인스턴스로 선언해서 사용하지 않아도 되겠네요.
| String name = inputView.askGamblerNames().name(); | |
| String name = InputView.askGamblerNames().name(); |
There was a problem hiding this comment.
- InputView안에서 static메소드를 없애고 controller에서 InputView를 인스턴스로 선언해서 사용하기
- InputView안에서 stati메소드를 유지하고 controller에서 InputView를 인스턴스로 선언해 사용하지 않기
저는 InputView안에 있는 메소드들이 한 번씩만 사용되기 때문에 1번이 더 좋을 것 같은데 어떤 게 더 좋을까요?
|
|
||
| private List<Card> cards; | ||
|
|
||
| public GameCards(int amount) { |
There was a problem hiding this comment.
amount 값은 항상 1로 들어오는 것 같은데, 현재 필요한 이유가 있을까요~?
- 카드는 항상 숫자/모양 조합으로 고정
There was a problem hiding this comment.
한 벌의 카드덱이 아니라 여러 벌의 카드 덱을 사용하는 카드게임을 생성하는 경우를 생각해서 만드려는 덱의 벌 개수를 가지는 amount변수를 사용했습니다.
지금 다시 보니까 카드 벌 수를 조정해서 카드를 생성한다면 상수 모음 클래스 constants에 이미 존재하는 DEFAULT_CARD_SET를 사용하면 되네요!
|
|
||
| public class Hand { | ||
|
|
||
| private List<Card> cards; |
There was a problem hiding this comment.
넵!! final로 쓰는게 맞네요!! 감사합니다 !
| public boolean isEqualName(String name) { | ||
| if(this.name.isEqualName(name)) return true; | ||
| return false; | ||
| } |
There was a problem hiding this comment.
| public boolean isEqualName(String name) { | |
| if(this.name.isEqualName(name)) return true; | |
| return false; | |
| } | |
| public boolean isEqualName(String name) { | |
| return this.name.isEqualName(name); | |
| } |
로 바로 사용 가능해보이네요 :)
| private void validateNameIsNotNullAndIsNotBlank(String agreement) { | ||
| if (agreement == null || agreement.isBlank()) { | ||
| throw new IllegalArgumentException("y/n 입력은 null 또는 공백이면 안됩니다"); | ||
| } |
There was a problem hiding this comment.
현재 agreement는 단일 문자열이고, 'y/n'이라는 특정 형식을 충족해야만 의미가 있는 값이기 때문에 해당 형식에 대한 검증을 InputView에서 수행하는 방향으로 수정하려 합니다..!!
그리고 현재 DTO에서 진행 중인 null 및 빈 값(isBlank) 검증 또한 '올바른 입력인가'를 판단하는 동일한 성격의 검증이라고 생각합니다. 따라서 이들을 각각 나누기보다는 InputView에서 한꺼번에 검증하여 잘못된 입력에 대해 즉각적인 피드백을 주고 DTO는 완성된 데이터만 전달받도록 역할을 분리해 보려 하는데, 이런 방향이 괜찮을까요?
| } | ||
|
|
||
| private void validateNameRangeTwoToTen(String name) { | ||
| if (name.length() < 2 || name.length() > 10) { |
| # java-blackjack | ||
|
|
||
| 블랙잭 미션 저장소 | ||
| ## 블랙잭 미션 기능 명세 |
There was a problem hiding this comment.
- Shuffle / Random 로직의 테스트 방법
좋은 고민 해주셨네요 👍
넵 output 을 예측할 수 없는 값들은 테스트를 하기 어려운 구조라, 보통은 랜검 생성 로직을 도메인 로직에서 분리하여, 필요시 주입받는 형태를 많이 사용하게 되는 것 같은데요,
예를 들어 Random이나 ShuffleStrategy와 같은 객체를 의존성으로 두고, 실제 코드에서는 랜덤 구현을 사용하고 테스트에서는 결과가 고정된 구현을 주입하는 방식을 사용해볼 수 있을 것 같습니다!
또 말씀 주신 것 처럼 mockito로 반환값을 고정하는 방식은 가능하긴 하지만, 실제 상태가 아닌 결과값만 바꾸는 테스트가 되기 때문에 적절한 테스트인지에 대해서는 의문이 들긴 하는 것 같아요.
현재처럼 Dealer가 GameCards를 직접 생성하지 않도록 구조를 변경하고, 테스트에서는 필요한 카드 상태를 직접 구성할 수 있도록 만든 방식은 테스트하기 더 용이한 방향으로 잘 설계해주신 것 같네요 👍
- Getter 사용과 출력 데이터 전달 방식
출력을 위한 getter는 괜찮습니다! 객체지향 생활체조의 "getter 사용 금지"는 도메인 로직에서의 getter 남용을 경계하는 것이라보아요 ㅎㅎ
- 입력/출력과 도메인 객체의 책임 분리
코코가 생각하는 Controller 와 Gamblers 의 역할은 무엇인가요~?
저는 Controller 는 입출력의 중계 역락, Gamblers 가 참가자 도메인으로 보이는데, 이렇가면 입출력을 중계해주는 역할은 controller 에서 가지고 있는 것이 괜찮은 구조로 보이는 것 같아요.
도메인 객체는 view 에 대해 알고 있지 않는 것이 좋겠죠~?
- 점수 계산 로직 분리 기준
점수 계산 로직을 어디에 두는 것이 좋을지 고민해보신 과정이 인상적이네요 👍
말씀해주신 것처럼 Score 객체를 별도로 두는 방식도 가능하지만, 현재 카드와 점수는 밀접한 관계에 있기 때문에 카드를 쥐고 있는 Hand 에서 점수를 계산한다 라는 책임이 자연스러운 것으로 보여요.
일반적으로 객체를 분리할지 여부는 해당 로직이 그 도메인의 핵심 로직인지, 혹은 독립적인 개념으로 다뤄질 필요가 있는지를 기준으로 고민해볼 수 있을 것 같은데요,
예를 들어 점수 계산 로직이 매우 복잡해지거나, 여러 객체에서 공통적으로 사용되거나, 점수 자체가 별도의 의미 있는 값 객체로 다뤄져야 하는 상황이라면 Score와 같은 객체로 분리하는 것도 고려해볼 수 있을 것 같습니다.
- 카드 분배 구조 설계
말씀 주신 것처럼 딜러가 모든 게임을 진행하는 구조에서는 참여자인 딜러에게 게임 진행 책임까지 함께 부여되면서 객체의 책임이 다소 비대해질 수 있을 것 같네요.
그런 점에서 Game 객체를 별도로 두고 게임 진행에 대한 책임을 위임한 설계는 좋은 선택으로 보여요 👍
현재 구조에서는 Game이 dealer, gamblers, gameCards를 관리하면서 게임 흐름을 조율하는 역할을 담당하고 있고, Dealer와 Gambler는 카드를 보유하고 자신의 상태를 관리하는 책임에 집중하고 있는 것으로 보이는데요. 이렇게 역할을 분리한 점이 객체 책임 관점에서 자연스럽게 느껴지는 것 같아요 ㅎㅎ
현실 세계에서는 딜러가 실제로 게임을 진행하는 역할을 하기도 하지만, 객체 설계에서는 도메인 개념과 객체 책임을 반드시 동일하게 가져갈 필요는 없기 때문에, 게임 진행을 담당하는 별도의 Game 객체를 두는 것도 충분히 합리적인 설계라고 생각합니다 :)
- Controller / Service 분리 기준
넵 저는 게임 진행의 로직이 game 객체에 있고, controller 는 적절하게 흐름 제어를 하고 있어 지금 단계에서 불필요하게 service 계층을 두지 않아도 괜찮아보여요.
일반적으로 Service 계층은 여러 도메인 객체를 조합해서 하나의 유스케이스를 수행하거나, 트랜잭션 관리 / 외부 시스템 연동 / 복잡한 비즈니스 흐름을 조율해야 하는 경우에 두는 경우가 많은 것 같은데요, 지금 단계에서는 각 도메인 객체가 스스로 책임을 수행하는 정도로 구현 가능 하니 계층을 단순히 유지하는 것이 좋아보여요 :)
- TDD 관련 질문
이 부분은 테스트 쪽 코멘트로 남길게요~!
There was a problem hiding this comment.
정성스런 답변 정말 감사합니다...!🙇🏻♀️
덕분에 궁금증이 많이 해소되었어요!! 🥹
입력 검증이 너무 헷갈려서 입력 검증을 유형별로 어디서 하면 좋을 지 찾아보니까 리뷰어님이 말씀해주신 것처럼 Domain은 비즈니스 핵심 규칙 등만 검증하고 view나 Dto에서 검증된 깨끗한 값만 controller가 Domain으로 보내는 게 좋은 것 같습니다!!
| // then | ||
| Assertions.assertThat(cardsInfo.containsAll(Arrays.asList("K다이아몬드", "2하트", "3클로버"))); |
There was a problem hiding this comment.
containsAll()은 boolean을 반환하는데, assertThat()에 직접 넣으면 항상 true가 되어, 실제로는 아무것도 검증하지 않는 테스트가 되네요!
list 는 아래와 같이 테스트 가능해보여요 :)
| // then | |
| Assertions.assertThat(cardsInfo.containsAll(Arrays.asList("K다이아몬드", "2하트", "3클로버"))); | |
| // then | |
| assertThat(cardsInfo) | |
| .hasSize(3) | |
| .contains("K다이아몬드", "2하트", "3클로버"); |
There was a problem hiding this comment.
엇 그러네요!! 감사합니다!!
아니면 cardsInfo는 hand.addCard(new Card(String score, String kind))가 순서대로 리스트에 추가되기 때문에
List<String> comparisonValue = List.of("K다이아몬드", "2하트", "3클로버");와 같이 비교 리스트를 만들어서
Assertions.assertThat(cardsInfo).isEqualTo(comparisonValue);이런식으로 비교하는것도 괜찮을 것 같은데 어떨까요?
| public int calculateScore() { | ||
| int sum = calculateBaseScore(); | ||
| int aceCount = countAce(); | ||
|
|
||
| while (sum > BLACK_JACK && aceCount > 0) { | ||
| sum -= (ACE_HIGH_SCORE - ACE_LOW_SCORE); | ||
| aceCount--; | ||
| } | ||
|
|
||
| return sum; | ||
| } |
There was a problem hiding this comment.
해당 로직은 이 게임에서 점수를 계산하는 중요한 로직으로 보이는데 관련한 테스트를 작성해 보는 것은 어떨까요~?
| void 딜러_카드_합_16_이하_테스트() { | ||
| //given | ||
| Dealer dealer = new Dealer("딜러"); | ||
|
|
||
| //when | ||
| dealer.addCard(new Card("A", "하트")); | ||
|
|
||
| //then | ||
| Assertions.assertThat(dealer.isTotalScore16OrLess()).isEqualTo(true); | ||
| } |
There was a problem hiding this comment.
버그가 자주 발생할 수 있는 경계값(16, 17 값)에 대한 테스트가 작성되면 좋을 것 같아요.
There was a problem hiding this comment.
또한 좋은 테스트는 테스트명만으로도 도메인 정책을 설명하는 역할을 할 수 있다고 생각하는데요,
블랙잭에서 중요한 로직이라고 판단되는 부분들을 테스트로 작성하고, 테스트 이름만 보아도 어떤 상황에서 어떤 결과를 검증하는지 이해할 수 있도록 작성해보는 것도 좋은 연습이 될 것 같네요~!
|
지금은 테스트를 작성할 때 @DisplayName()에 쓸 설명과, 테스트 함수 이름을 한글로 작성하고 있는데요, 가끔은 영어로 작성하는 것이 의미가 더 함축적으로 잘 전달되는 경우가 많아서 영어로 쓰는 게 좋을지 한글로 쓰는 게 좋을 지 고민이 됩니다.... 그리고 커밋 메시지도 현재는 한글로 작성하긴 하나, 영어로 작성할 때 의미가 더 함축적으로 잘 전달되는 경우가 많아서 커밋 메시지도 영어로 작성하는 게 좋을 지 고민입니다. 현업에서는 이 경우 한글 VS 영어 어떤 언어를 사용해서 작성하는 지 궁금합니다! |

체크 리스트
test를 실행했을 때, 모든 테스트가 정상적으로 통과했나요?어떤 부분에 집중하여 리뷰해야 할까요?
1. Shuffle / Random 로직의 테스트 방법
DealerTest - 딜러 카드 합이 16 이하인지 검증하는 테스트
해당 테스트는 Dealer가 가진 카드의 합이
를 반환하는 isTotalScore16OrLess() 메서드를 검증하는 테스트입니다.
현재는 Dealer가 GameCards를 직접 소유하지 않도록 구조를 변경하여, 테스트에서 필요한 카드를 직접 추가하는 방식으로 상태를 제어하고 있습니다.
그러나 구현 초반에는 Dealer가 GameCards를 직접 가지고 있었고, GameCards 생성 시 바로 shuffle()이 수행되었기 때문에 테스트 시 어떤 카드가 나올지 제어하기 어려웠습니다.(경계값 테스트 불가능)
이 문제를 해결하기 위해 처음에는 Mockito를 사용해서 메서드 반환값을 고정하는 방식도 시도했습니다.
다만 이 방식은 실제 카드 상태를 바꾸는 것이 아니라 최종 합계 반환값만 바꾸는 방식이기 때문에,
isTotalScore16OrLess()메서드를 검증하기엔 어려웠습니다.현재는 dealer가 GameCards를 직접 소유하지 않도록 구조가 변경되면서 테스트 문제는 해결되었지만, shuffle/random이 포함된 로직은 일반적으로 어떤 방식으로 테스트 가능성을 확보하는지 궁금해졌습니다.
질문
2. Getter 사용과 출력 데이터 전달 방식
게임 시작 시 참가자들의 초기 카드 정보를 출력하는 기능을 구현하면서 객체지향 생활체조 원칙에 따라 getter 사용을 최대한 피하려고 했습니다. 그래서 객체 자체를 반환하기보다는 필요한 정보만 가공해서 반환하는 방식으로 구현했습니다.
예를 들어 Game에서는 다음과 같은 메서드를 추가했습니다.
Map<String, List<String>>형태로 반환하지만 이후 게임 결과 출력 기능이 추가되면서 이 세 가지 정보를 함께 전달해야 하는 상황이 생겼습니다.
이 경우 기존 방식처럼 필요한 정보만 반환하려면
그래서 구현 과정에서 다음과 같은 고민이 들었습니다.
getter를 사용하면 구현은 단순해지지만 객체지향 생활체조 원칙과 거리가 생기는 것 같고
getter를 피하려다 보니 반환 메서드가 계속 늘어나는 구조가 되는 것 같습니다.
질문
이와 같은 경우 출력 데이터를 전달할 때 getter를 사용하는 방식이 괜찮은 선택인지 궁금합니다.
3. 입력/출력과 도메인 객체의 책임 분리
게임 진행 중 다음과 같은 흐름이 있습니다.
현재 구조에서는
이때 다음 두 가지 방식이 가능할 것 같습니다.
Gamblers 객체가 참가자에게 hit/stand 여부를 묻는 로직까지 담당 (도메인에서 입출력 담당)
Gamblers는 참가자 정보만 관리하고 Controller에서 입력/출력을 처리 (Controller에서 getGamblers()를 통해 List에 직접 접근)
질문
입력/출력과 도메인 로직을 분리할 때 어떤 방식이 더 적절한 설계인지 궁금합니다.
4. 점수 계산 로직 분리 기준
처음에는 점수 계산을 위해 Score 객체를 따로 만들었습니다.
하지만 구현을 진행하면서
두 곳에서 비슷한 역할을 수행하는 것처럼 느껴졌습니다.
그래서 Score 객체를 제거하고 Hand 객체에 calculateScore() 메서드를 두어 점수를 계산하도록 변경했습니다.
다만 ACE 처리 로직 등 점수 계산이 비교적 중요한 로직이다 보니 별도의 객체로 분리하는 것이 맞는지 고민이 됩니다.
질문
점수 계산 로직은
별도의 객체로 분리하는 것이 좋은지
Hand 객체 내부 책임으로 두는 것이 적절한지
궁금합니다.
5. 카드 분배 구조 설계
처음에는 딜러가 GameCards를 가지고 있고, 딜러가 자신과 참가자들에게 카드를 나눠주는 구조로 설계했습니다.
하지만 이 경우 딜러 → 딜러, 딜러 → Gambler 형태로 메서드 호출이 이루어지면서 구조가 어색하게 느껴졌습니다.
또한 Gambler에게 카드를 나눠주기 위해 Gamblers 객체를 통해 반복문을 돌면서 dealer.drawCard(gambler) 형태로 호출하는 구조도 자연스럽지 않다고 느꼈습니다. (gamblers가 dealer를 계속 의존)
그래서 현재는 Game 객체가 GameCards를 가지고 있고, 필요한 경우 gameCards.drawCard()로 카드를 뽑은 뒤 dealer.addCard(card), gambler.addCard(card) 형태로 구조를 변경했습니다.
현재 Game 객체는
3개의 인스턴스 변수를 가지고 있습니다.
질문
이와 같은 구조가 객체 책임 관점에서 괜찮은 설계인지 궁금합니다.
6. Controller / Service 분리 기준
현재 구현에서는 로직이 비교적 단순하다고 판단하여 Service 계층 없이 Controller에서 흐름을 처리했습니다.
실무에서는 어떤 기준으로 Controller와 Service를 분리하는지 궁금합니다.
7. TDD 관련 질문
궁금합니다.