안녕하세요 ~ 헤나입니다 ! 😄
저번 글은 블랙잭 페어 회고 였습니다.
이번에는 "블랙잭 미션 1단계 & 2단계" 회고를 진행해보려고 합니다.
🟧 View과 Domain 분리
블랙잭 미션을 진행하면서 View와 Domain이 의존되는 부분에 고민이 생겼다.
💁♂️ 리뷰어
블랙잭 게임에서 hit을 하는 부분은 핵심 비즈니스 로직인데요..!컨트롤러의 역할이 맞을까요?? 🤔
Controller와 View 없이도 블랙잭 게임을 할 수 있도록 리팩토링해보면 어떨까요??
💁♂️ 리뷰어
view와의 의존성을 최소화시키는게 왜? 좋은지에 대해서 고민해보셨을까요??
😄 헤나
현재는 메인 로직이 실행되고 View에게 데이터를 건내줍니다.View는 받은 데이터를 가지고 출력해주는 형태입니다.
도메인은 블랙잭 규칙이 바뀌지 않는 이상 그대로 이지만
좀 더 화려한 UI를 위해 출력 포맷을 바꾼다거나
좀 더 편리한 입력을 위해 입력 포맷을 바꿀 수 있습니다.
현재 view를 없앤다고 해도 메인 로직은 정상적으로 돌아가야 합니다.
만약 그렇지 않는다면 출력이 바뀔 때 메인 로직이 바뀔 수 있고
그렇게 된다면 큰 작업으로 이어져 코드 수정에 어려움을 겪을 수 있습니다.
현재 View와 Domain이 의존하는 상황을 그림으로 그리면 다음과 같다.
Controller에서 도메인 Dealer와 Player를 메서드의 지역 변수로 사용하고 있는 상태였다.
코드는 다음과 같다.
public class BlackJackController {
private final Deck deck = new Deck();
public void run() {
// 플레이어를 하나씩 사용한다.
for (Player player : players) {
hitBy(player);
}
}
private void hitBy(final Player player) {
// === View와 Domain이 의존한다. ===
// Player의 isHittable 메서드
while (player.isHittable()
// InputView의 checkPlayerAdditionalHit 메서드
&& InputView.checkPlayerAdditionalHit(player.getName().getValue())) {
// Player의 hit 메서드
player.hit(deck.draw());
OutputView.printParticipantsCards(player);
}
if (player.isHittable()) {
OutputView.printParticipantsCards(player);
}
}
🤔 왜 저런식으로 구현하게 됐을까 ?
블랙잭 미션의 요구사항은 다음과 같았다.
Player가 "hit(히트) 하는지 안하는지"를 계속해서 Console과 통신해서 알아야한다.
즉, InputView를 통해서 입력을 계속해서 받아야 게임이 진행될 수 있다.
이 통신을 보다 편리하게 하려고 했기에 Controller에 Dealer와 Player가 직접적으로 View와 연관이 되어 있었다.
게임에 참여할 사람의 이름을 입력하세요.(쉼표 기준으로 분리)
pobi,jason
pobi의 배팅 금액은?
10000
jason의 배팅 금액은?
20000
딜러와 pobi, jason에게 2장을 나누었습니다.
딜러: 3다이아몬드
pobi카드: 2하트, 8스페이드
jason카드: 7클로버, K스페이드
// === 플레이어는 "히트 하느냐 마느냐"를 콘솔과 통신해서 결정한다. ===
pobi는 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)
y
pobi카드: 2하트, 8스페이드, A클로버
pobi는 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)
n
pobi카드: 2하트, 8스페이드, A클로버
jason은 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)
n
jason카드: 7클로버, K스페이드
그렇다면 현재 계속해서 Player는 Console과 통신하는 부분을 어떻게 분리할 수 있을까 ?
🟢 View와 Domain 분리 (그림)
"Player가 hit(히트)를 진행 하는지 안하는지"에 대한 boolean 형태의 응답을 분리했다.
Controller과 직접적으로 연결되어 있는 Dealer와 Player를 BlackJackManager를 추가해서 의존을 끊어놓았다.
변경 이전 그림
변경 이후 그림
아래과 같이 변화를 주었다.
- BlackJackController에 메인 로직을 두지 않았다.
- BlackJackController는 요청과 응답만을 진행한다.
- BlackJackManager는 메인 로직을 수행한다.
- Console과 계속 통신해야하는 부분을 "함수형 인터페이스"를 통해 분리한다.
🟢 View와 도메인 분리 (코드)
Player는 계속해서 Console과 통신해야 했다.
통신을 한다는 것은 "입력 메서드"를 계속해서 호출해야 한다는 부분이었다.
그래서 "입력 메서드"를 분리하려고 했다.
🤔 입력 메서드를 직접적으로 의존하는 것을 끊을 방법이 있을까 ?
InputView에 있는 메서드를 BlackJackManager에서 사용해야 했다.
메서드를 받아오기 위해서 "함수형 인터페이스"를 이용하기로 했다.
(코드는 아래에 나와 있다.)
현재 각 객체들이 하는 일은 다음과 같다.
- InputView의 checkPlayerAdditionalHit() 메서드를 BlackJackController에서 호출한다.
- BlackJackController는 BlackJackManager의 hitByPlayer() 메서드를 호출한다.
- 이 때 hitByPlayer() 메서드의 파라미터 타입은 Predicate, Consumer이다.
- Predicate : Player(플레이어)가 hit(히트) 하는지 마는지를 결정을 boolean 타입으로 리턴한다.
- Consumer : Player(플레이어)의 현재 카드를 출력하기 위한 메서드이다.
InputView, BlackJackController, BlackJackManager 코드는 다음과 같다.
public class InputView {
// === 플레이어가 카드를 한장 더 받을지 말지 결정한다. ===
public static boolean checkPlayerAdditionalHit(final String playerName) {
// y / n 을 판단하여 boolean 형태로 반환한다.
return playerAdditionalHit.equals(ADDITIONAL_HIT_APPROVE);
}
}
public class BlackJackController {
private final BlackJackManager blackJackManager;
// BlackJackManager에게 플레이어 히트 판단을 넘긴다.
public void inputHitCondition() {
// === InputView의 메서드를 인자로 넘긴다. ===
blackJackManager.hitByPlayer(InputView::checkPlayerAdditionalHit, ...);
}
}
public class BlackJackManager {
public void hitByPlayer(
// 함수형 인터페이스를 매개변수로 둔다.
final Predicate<String> checkHitCondition, // 히트 (y / n)
final Consumer<Player> printPlayerCards // 플래이어 현재 카드 출력
) {
// === 플레이어 리스트를 getter로 가져와서 로직을 돌린다. ===
participants.getPlayers()
.forEach(player -> hitBy(player, checkHitCondition, printPlayerCards));
}
}
이제 아래 그림을 통해서 View와 Domain이 분리되었다는 것을 알 수 있다.
🟧 Getter 이용하기 vs Domain 안까지 들어오기
함수형 인터페이스를 이용해서
View와 Domain의 의존을 끊었다고 해도 다음과 같은 문제가 있었다.
😄 헤나
현재 블랙잭 플레이어들은 BettingPlayers 에 위치해 있습니다.
플레이어들을 getter로 꺼내와서 BlackJackManager에서 콘솔과 통신하며 게임을 진행합니다.
getter로 플레이어들을 BlackJackManager까지 가져오지 않으면 콘솔을 BettingPlayers까지 끌고와야합니다.
저는 콘솔 관련 부분이 도메인 안까지 깊이 들어오는 것이 콘솔과의 의존이 높아진다고 생각해서
getter로 꺼내서 바깥에서 진행하도록 했습니다.
두 방법 다 좋지 않다고 생각이 들지만
현재 콘솔과 계속해서 통신하기 위해서는 어쩔 수 없는 상태라고 느꼈습니다.
무언가 더 나은 방법을 선택해야한다면 도메인 안까지 깊이 들어오게 하지 않고
getter를 사용해서 플레이어 리스트를 주고 로직이 진행되도록 하는게 낫다고 생각합니다.
이후에 변경해야 한다면 콘솔과의 의존을 최소화시키는게 좋다고 생각했기 때문입니다.
이러한 상황에 대해서 어떻게 생각하시는지 궁금합니다.
💁♂️ 리뷰어
저도 두 가지 중에 선택한다면 헤나와 같은 생각입니다 ㅎㅎ
먼저 getter 메서드로 Player(플레이어) 리스트를 가져오는 상황은 다음과 같다.
- Player 리스트는 InputView의 입력을 계속해서 받아야 한다.
- BlackJackManager에서 getter 메서드로 가져온 Player 리스트는 hit 로직을 수행하는데 사용된다.
왜 이 부분에서 getter 메서드를 사용하는 것이 문제라고 생각됐을까 ?
바로 "기능 수행을 위해서 BlackJackParticipants의 정보를 가져오는 것"이 문제라고 생각했다.
이유는 BlackJackParticipants에서 충분히 수행할 수 있는 기능이기 때문이다.
일부로 private으로 감추어둔 정보를 외부로 가져온다는 것은 캡슐화를 깬다고 생각한다.
BlackJackManager 코드는 다음과 같다.
public class BlackJackManager {
private final Deck deck;
// BlackJackParticipants는 플레이어 리스트를 가지고 있다.
private final BlackJackParticipants participants;
// 블랙잭 메인 기능
public void hitByPlayer(final Predicate<String> checkHitCondition, final Consumer<Player> printPlayerCards) {
// === 플레이어 리스트를 getter로 가져와서 로직을 돌린다. ===
participants.getPlayers()
.forEach(player -> hitBy(player, checkHitCondition, printPlayerCards)); }
}
getter 메서드를 호출하면 아래와 같은 상황이 벌어진다.
🤔 그러면 BettingPlayers에서 기능을 수행하도록 하면되지 않을까 ?
사실 해결법은 정말 간단하다.
BlackJackManager가 getter로 가져오는 Player 리스트는 실제로 BettingPlayers에 있는 정보를 가져오고 있다.
그렇다면 BettingPlayers에서 BlackJackManager의 책임을 가져가면 된다.
BettingPlayer가 책임을 가져갈 경우 상황 다음과 같다.
🤔 View와 Domain의 의존을 끊기 위한 함수형 인터페이스가 BettingPlayers까지 넘어오는구나.
이전에 InputView에 있던 checkPlayerAdditionalHit 메서드가 생각나는가 ?
getter 메서드를 없애기 위해서는 BettingPlayers가 checkPlayerAdditionalHit 메서드를 받아서 호출해야 했다.
(기억이 나지 않을거 같아서 가져왔다.)
public class InputView {
// === 플레이어가 카드를 한장 더 받을지 말지 결정한다. ===
public static boolean checkPlayerAdditionalHit(final String playerName) {
// y / n 을 판단하여 boolean 형태로 반환한다.
return playerAdditionalHit.equals(ADDITIONAL_HIT_APPROVE);
}
}
함수형 인터페이스를 매개변수로 갖는 부분은 유연하다 생각한다.
그렇지만 getter 메서드로 Player 리스트를 불러오는 것보다 함수형 인터페이스의 의존이 깊어진다고 생각이 들었다.
확실한 정답은 없는 부분이지만 현재로서는 이거로 만족하고 진행했다.
🟧 가독성은 확실하게 정의하기 어렵다.
💁♂️ 리뷰어
// ParticipantCards 클래스 // 카드 중복 검증을 위해 Set 자료구조를 이용했다. throw new IllegalArgumentException("첫 카드는 두 장이어야 합니다."); } if (new HashSet<>(cards).size() != INITIAL_SIZE) {
자료구조를 사용하는 것도 좋지만, 성능 이슈가 없다면 개인적으로 명확성이 중요하다고 생각하는데요..!
실제로 제가 이 부분 코드를 보면서
1. 클래스 필드의 Cards cards와 헷갈려서 위로 올려서 확인.
2. Card.class의 hashcode가 override 되어있는지 내부 구현을 확인.
3. 테스트 코드로 의도한 대로 동작하는지 확인.
위와 같은 과정을 거쳤습니다 😢
최근 읽고 있는 책에서 공감한 내용을 인용해보면,
---
가독성은 본질적으로 주관적이고, 확실하게 정의하기는 어렵다.
하지만, 가독성의 핵심은 개발자가 코드를 빠르고 정확하게 이해할 수 있도록 하는 것이다.
실제로 이렇게 하려면 다른 사람의 관점에서 보았을 때,
코드가 혼란스럽거나 잘못 해석될 수 있는지를 상상하고 공감해야할 때가 많다.
톰 롱, 좋은 코드 나쁜코드, 123p
---
어떻게 생각하시나요 ??
🤔 Set 자료구조를 이용해서 중복 검증을 하는게 왜 가독성이 안좋다는 걸까 ?
리뷰어의 커멘트를 보고 처음에 와닿지 않았다.
가독성이 좋다고 생각하는 부분이 오히려 알기 어려웠다는 부분이 충격적이었다.
먼저 수정 전 Set 자료구조를 이용한 코드는 아래와 같다.
private void validate(final List<Card> cards) {
if (cards.size() != INITIAL_SIZE) {
throw new IllegalArgumentException("첫 카드는 두 장이어야 합니다.");
}
if (new HashSet<>(cards).size() != INITIAL_SIZE) {
throw new IllegalArgumentException("카드는 중복될 수 없습니다.");
}
}
리뷰어의 따뜻한 배려로 "좋은 코드, 나쁜 코드" 책 내용을 일부 가져와주셔서 그래도 한 번 더 깊게 고민해볼 수 있었다.
먼저 주위 사람들에게 현재 코드의 가독성에 대해서 물어봤다.
돌아오는 대답은 정말 중복을 검증하기 위한 코드인지 확실하게 와닿지는 않는다는 것이었다.
이 문제를 해결하기 위해서는 중복에 대한 검증 로직을 다른 식으로 풀어야 했다.
바로
메서드 구현 코드가 중복을 피하려고 한다는 의미,
메서드 네이밍이 중복을 피하려하고 있다는 의미를 살려서 다음과 같이 수정했다.
- checkAnySameCard : 같은 카드가 있는지 확인하는 메서드
- isSameNextCard : 다음 카드가 동일한지 확인하는 메서드
수정한 코드는 아래와 같다.
private void validate(final List<Card> cards) {
if (cards.size() != INITIAL_SIZE) {
throw new IllegalArgumentException("첫 카드는 두 장이어야 합니다.");
}
if (checkAnySameCard(cards)) {
throw new IllegalArgumentException("카드는 중복될 수 없습니다.");
}
}
private boolean checkAnySameCard(final List<Card> cards) {
final int cardsSize = cards.size();
return IntStream.range(0, cardsSize)
.anyMatch(currentIndex -> isSameNextCard(cards, currentIndex));
}
private boolean isSameNextCard(final List<Card> cards, final int currentIndex) {
final Card currentCard = cards.get(currentIndex);
final int cardsSize = cards.size();
return IntStream.range(currentIndex + 1, cardsSize)
.anyMatch(nextIndex -> currentCard.isSame(cards.get(nextIndex)));
}
🟧 주석을 구분하는 기준
테스트를 작성할 때 MethodSource를 자주 이용했다.
플레이어와 딜러의 승부에서 나올 수 있는 여러 케이스를 주석을 이용해서 표현했다.
static Stream<Arguments> decideBetResultByDummy() {
return Stream.of(
Arguments.arguments(
// 플레이어 패배, 플레이어 딜러 모두 버스트하는 경우
// 플레이어
new Card(CardShape.DIAMOND, CardNumber.NINE),
new Card(CardShape.DIAMOND, CardNumber.QUEEN),
List.of(new Card(CardShape.SPADE, CardNumber.JACK),
new Card(CardShape.HEART, CardNumber.KING)),
// 딜러
new Card(CardShape.HEART, CardNumber.TWO),
new Card(CardShape.HEART, CardNumber.QUEEN),
List.of(new Card(CardShape.CLOVER, CardNumber.JACK),
new Card(CardShape.CLOVER, CardNumber.KING)),
// 플레이어 배팅 이전 금액
10000,
// 플레이어 배팅 이후 수익
-10000
),
...
주석을 사용했을 때 리뷰어님이 아래와 같은 커멘트를 달아주었다.
💁♂️ 리뷰어
주석을 사용할 때와 안할때를 구분하는 기준이 궁금합니다 ㅎㅎ
😄 헤나
주석을 사용하는 이유는 코드만 보고서 이해하기 어려운 부분이기 때문이라고 생각합니다.
단순하게 부가 설명이라고 생각하고 사용했습니다.
현재 같은 경우는 테스트에 이용할 더미 데이터였습니다.
BettingPlayers의 findBettingResultsBy 메서드 테스트를 진행하기 위해서 였습니다.
이 메서드를 다양하게 테스트하기 위해서는 BettingPlayers의 데이터가 다양한 상태일 때를 테스트해야합니다.
그러한 다양한 상태를 만들어주기 위해 아래 같은 데이터가 필요했습니다.
그리고 그 다양한 상태에 대한 데이터를 각각 표현해주기 위해서 주석을 달았습니다.
💁♂️ 리뷰어
주석은 다양한 목적으로 사용되지만, 일반적으로
1. 서술적인 이름으로 잘 작성된 코드는 그 자체로 줄 단위로 무엇을 하는지 설명한다.
2. 그럼에도 주석이 필요하다면 코드가 무엇을 하는지 설명하는 것 보다는,
코드가 "왜" 그 일을 하는지에 대한 이유나 배경을 설명하는 주석이 유용할 때가 많습니다.
3.또, 유용한 주석이라도 지속적으로 코드와 같이 유지보수되어야 합니다.
불필요한 주석문이 남아있는 경우 오히려 없는 것보다 나쁠 수 있습니다. 😢
결론적으로, 주석을 사용하는 것이 필요한 경우에도 이러한 단점들을 고민해보고 적용하시면 도움이 될 것 같아요!! ㅎㅎ
지금까지 주석을 사용한 곳은 테스트 부분이 컸다.
주석을 사용하면 다른 분이 해당 테스트를 볼 때 보다 더 쉽게 이해할 수 있을 거라 판단했다.
물론 지금 더미 데이터에 사용한 주석이 불필요하지는 않다.
그러나 조금 더 생각하고 주석을 사용해야한다고 느꼈다.
이유는 다음과 같다.
- 이미 데이터 이름에서 어떤 상황의 데이터들인지 알 수 있다.
- 주석을 달았을 경우 이후 유지보수에 같이 신경써줘야 한다.
아까 decideBetResultByDummy() 메서드에서 주석을 작성한다면 다음과 같이 수정하려고 한다.
충분히 한 줄으로도 현재 테스트 더미의 의미를 전달할 수 있다 생각하기 때문이다.
// 주석 수정 후
static Stream<Arguments> decideBetResultByDummy() {
return Stream.of(
// 플레이어 딜러 모두 버스트하는 경우
Arguments.arguments(
new Card(CardShape.DIAMOND, CardNumber.NINE),
new Card(CardShape.DIAMOND, CardNumber.QUEEN),
List.of(new Card(CardShape.SPADE, CardNumber.JACK),
new Card(CardShape.HEART, CardNumber.KING)),
new Card(CardShape.HEART, CardNumber.TWO),
new Card(CardShape.HEART, CardNumber.QUEEN),
List.of(new Card(CardShape.CLOVER, CardNumber.JACK),
new Card(CardShape.CLOVER, CardNumber.KING)),
10000,
-10000
),
...
🟧 final 키워드
레벨1 미션을 진행하면서 습관적으로 final 키워드를 달기 시작했다.
💁♂️ 리뷰어
전반적으로 final 키워드가 많아졌네요...!
개인적으로는 좋아하지만 싫어하는 분들도 많아서 장단점과 사용하신 이유를 설명해주실 수 있나요??😄
😄 헤나
1. 사이드 이펙트가 줄어들 수 있습니다.
final 키워드가 없는 변수가 값이 변경 되어 생각지 못한 결과가 나오는 상황을 최소화 시킬 수 있습니다.
2. 명확하다.
final 키워드가 있는 변수라면 그 값은 변하지 않는 다는 것을 알 수 있고
final 키워드가 없는 변수라면 그 값은 로직에 의해서 변할 수 있다는 것을 보다 쉽게 파악할 수 있습니다.
3. 컴파일러 효율
final 키워드가 있는 변수를 넘길 때 해당 변수의 값이 변경 되었는지 안되었는지 컴파일러가 확인할 필요가 없습니다.
💁♂️ 리뷰어
저도 매우 동의하지만 ㅎㅎㅎ
반대로 final이 없어도 위와 같은 장점을 가지도록 구현할 수 있다면
간결하지 못해 가독성을 해치는 코드가 될 수 있다는 의견도 적지 않아요 😢
어떤 것이든 항상 장단점을 고려해서 선택하는 습관을 가지시면,
반대 의견과 토론할 때도 설득할 수 있는 능력(?)이 생길 거라 생각합니다..!
그래서 조금 더 자세히 찾아서 final 키워드 장단점을 정리해봤다.
- [장점] 사이드 이펙트가 줄어들 수 있다.
- final 키워드가 없는 변수일 경우 값이 변경될 수 있다.
- final 키워드가 붙은 변수일 경우 값이 변경될 수 없으므로 생각지 못한 결과를 최소화 시킬 수 있다.
- [장점] 뜻이 명확하다.
- final 키워드가 없는 변수일 경우 상태가 변경될 수 있는 데이터임을 나타낸다.
- final 키워드가 있는 변수일 경우 상태가 변경되지 않는 데이터임을 나타낸다.
- [장점] 컴파일러 효율성
- final 키워드가 있는 변수일 경우 해당 변수를 다른 곳에 넘길 때 컴파일러가 해당 변수의 값이 변경 되었는지 다시 한번 확인하는 작업을 하지 않는다.
- [단점] 가독성 저하
- final 키워드가 모든 곳에 붙지 않아도 명확하게 코드를 작성할 수 있다.
- 때문에 final 키워드가 오히려 가독성을 해칠 수 있다는 단점이 있다.
👊 정리하자면
블랙잭 미션 회고 끝마쳤다.
주로 View와 Domain 의존성 문제에 대해서 생각해볼 수 있었다.
또한 final 키워드, 주석, 가독성과 효율성의 트레이드 오프가 무엇일지 고민해봤다.
이제 현 상황에서 적합한게 무엇일지 내가 선호하는 방식을 알고 설명할 수 있게 됐다. 😄
'👨🚀 우아한테크코스 5기' 카테고리의 다른 글
[20230329] 우아한테크코스 5기 LEVEL 1 - 레벨 인터뷰 회고 (0) | 2023.04.01 |
---|---|
[20230327] 우아한테크코스 5기 LEVEL 1 - 체스 1단계 & 2단계 회고 (0) | 2023.04.01 |
[20230311] 우아한테크코스 5기 LEVEL 1 - 블랙잭 페어 회고 (2) | 2023.03.11 |
[20230225] 우아한 테크코스 5기 LEVEL 1 - 사다리 타기 1단계 & 2단계 회고 (10) | 2023.02.25 |
[20230217] 우아한 테크코스 5기 LEVEL 1 - 사다리 생성 페어 회고 (0) | 2023.02.18 |