리뷰어: 로운
이 둘의 기능적인 차이점은 '추가 카드를 뽑는 방법'과 '처음에 카드를 몇 장 보여주는지'에 있다. 하지만 이외의 기능들에서는 공통적이기 때문에 이 둘을 User라고 하는 추상클래스로 추상화하여 구현하였다.
그런데 View에서 딜러와 일반 Player들을 구분해주다보니 Users라고 하는 클래스를 만들고 여기에 Players
와 Dealer
를 두게 되었고, 각각을 호출하게 되어 결국 추상화의 이점을 활용하지 못하고 있는 것 아닌가 하는 생각이 들었다.
public void setInitCardsPerPlayer(Deck deck) {
players.drawInitCards(deck);
dealer.drawInitCards(deck);
}
DTO는 단순하게 이야기해서 Controller와 View 사이에서 데이터를 운반하는 역할을 한다고 생각한다.
하지만 뷰에서 모든 처리를 해주려고 하다보니 OutputView
에서 구현한 calculateDealerYield
와 같이 너무 뷰의 책임이 커진 것이 아닌가 하는 고민이 들었다.
그래도 View의 출력 조건이 변경되었을 때 View에서만 영향이 있도록 하는 것이 적절해보여 View쪽에서 데이터를 가공해주도록 하였다.
하지만 여기에 더불어서 아직 UserDto
의 getInitCardsInfo()
와 같은 부분이나, getCardsInfo()
부분에서 뷰를 위한 데이터 가공을 해주고 있는게 아닌가? 하는 생각도 들었다.
(페어와 논의하였을 때 Card를 그냥 넘겨주는 것은 DTO를 만드는 의미가 없어지며 CardDto를 만드는 것은 오버엔지니어링이 아니냐 하는 고민을 하였고, 문자열로 만들어서 반환하는 것으로 결정하여 DTO에서 가공해주도록 하였다.)
User라는 추상클래스를 사용하며 추상클래스의 이점을 활용하고 있다고 생각하는데요. Users라는 일급객체를 만들며 안에서 User가 아닌 각 클래스를 명시해 주는 것에서 아쉬움이 남았던거 같아요.
추상클래스를 사용하는 이유를 찾아보면 좋을거 같고, Users 내에서도 분리없이 하려면 우선 딜러인지 아닌지 물어볼 수 있어야할 거 같아요. 조금 더 방법을 생각해보시고 생각이 안나면 제가 생각한 방법을 말씀드릴게요ㅎㅎ
view의 책임은 무엇일까요?? 실제 앱이나 웹에서도 어떻게 보여줄지 서버에서 고민을 할까요? view를 위한 데이터를 가공하는 이유가 무엇인가요?? 이 데이터를 가공한 dto가 view에서만 쓰일까요?? dto는 값을 전달하는 것이기 때문에 어떠한 형태로 넘겨줄지는 개발자들이 정하면 된다고 생각하는데요. 다만 이 값들이 어떠한 정보인지 명확하게 알 수 있게 표현해야한다고 생각해요. 이러한 의미에서 cardDto가 필요하다고 생각이 들면 만드는 것도 좋다고 생각합니다~ dto를 사용하는 이유와 사용방법에 대해서 알아보면 좋을거 같아요. 알아보고 알려주세요ㅎㅎ
추상클래스인 User
에 isDealer()
, isPlayer()
두가지 메소드를 둔다. 이를 통해 딜러인지 아닌지를 물어볼 수 있도록 하였다. 이렇게 하고나니 의도한 대로 List로 관리할 수 있어 Users레벨까지 User 추상화의 이점을 활용할 수 있게 된다고 생각하였다.
실제 앱이나 웹 서비스에서 '서버'는 데이터를 전달하는데에만 집중하는게 맞다고 생각된다. 즉, "어떻게" 보여줄지에 대한 것은 프런트쪽에서 처리를 해주는 것이 적절하다고 생각한다. 그리고 스스로 정의하고 있는 DTO는 말 그대로 "데이터 전송 객체"이며 view와 domain의 의존성을 끊어내기위한 도구라고 생각한다. 또한 User 라는 객체에 패스워드 등 전달되기 민감한 데이터 들이 있을 수 있기 때문에 도메인을 바로 뷰로 전달하지 않고, DTO로 필요한 데이터만을 감싸서 전송할 수 있도록 하기 위해 DTO를 사용한다.
리뷰어님의 답변을 받고 또 질문에 답하기 위해서 고민하는 시간을 가지고 나니, 적어도 view에 대한 변경이 DTO에 까지 영향이 가면 안되는거 아닌가? 하는 생각을 하였고, String.join(", ", ....); 과 같이 문자열을 조인하는 부분에서 분리자가 ", " 가 아니라 "와" 와 같이 view단에 요구사항 변경이 들어오면 이것이 DTO에 까지 영향이 가는 것은 적절하지 않다고 생각하게 되었고, 이를 코드에 반영하였다.
from
보다는 of
가 더 적절한 네이밍이다.Q. 전략 패턴을 적용해보면 어떨까요? 참고 자료
A. 처음에 위 질문을 받고 든 생각은 왜?
였다.
내가 찾은 답은 아래와 같다.
DeckGenerateStrategy
인터페이스와 이를 구현하는ShuffledDeckGenerateStrategy
를 두어 덱 생성에 대한 책임을 해당 전략으로 분리할 수 있다.
만약에 순서대로 정리된 Deck을 사용해야 한다고 하면 NonShuffled 덱 전략을 사용하기 용이해진다.
다음으로는 Deck에서는 덱 생성에 대해서는 관심이 없고,덱에서 카드를 한 장씩 반환해준다.
라고 하는 책임에만 전념할 수 있으므로객체가 갖는 책임의 크기는 작게
라고 하는 내용에도 부합하게 된다.
이와 더불어 리뷰어가 왜 '전략 패턴' 사용을 권유하였는지 그 이유에 대해서 궁금해졌다.
나는 이전까지 요구사항과 같이 눈에 보이는 곳에서 '수동, 자동'과 같이 전략을 사용해야겠다. 를 느낄 때 사용하였었다. 그렇다면 리뷰어는 어떤 부분에서 '전략 패턴' 사용을 고민하게 되었던 것일까? 리뷰어님의 답변은 다음과 같다.
저도 어떨 때 적용하면 좋겠다라고 생각이 딱 들지는 않는데요;; 😅
기본적으로 생각이 드는 것은 random 한 것에 대한 로직이 들어간다면 테스트를 할때 필요하기 때문에 생각을 하게 되는 것 같고요.
(블랙잭의 경우에는 deck에서 card를 한장 줬을때 실제로 내가 원했던 위치에 있는 카드가 user에게 갔는가를 테스트)
이외에는 비슷한 기능을 하는 것들이 더 생기는 상황이 발생하면 그때 적용을 하게 되는거 같아요.
이거는 저희 팀의 예를 들 수 밖에 없을거 같은데요.
저희가 배달대행사에 주문을 전달을 하는데 이 전달하는 방식이 배달대행사마다 다른데요. 실제로 주문을 전달한다는 같게되죠.
이럴때 처음에는 하나의 배달대행사만 적용하면서 전략패턴을 생각하지 않다가 다른 배달대행사들을 추가로 구현하게 되면서 적용을 고려하게 됐던거 같습니다~ 비슷한 도메인을 해봤거나 경험이 많이 쌓이게 되면 처음부터 고려를 할 수도 있겠죠??
아마 pg사나 은행쪽 로직(결제, 뱅킹)도 비슷하지 않을까 생각이 드네요~
enhanced-for문 보다는 stream을 사용하자.
List<User> players = new ArrayList<>();
for (User user : users) {
checkPlayer(players, user);
}
return Collections.unmodifiableList(players);
return users.stream()
.filter(user -> !user.isDealer(user))
.collect(Collectors.toUnmodifiableList());
메소드의 순서를 정하기 어려울 때 혹은 메소드간 순서에 대한 가독성이 없을 때는, 다음과 같은 규칙을 따라서 작성하도록 노력해보자.
public class ClassName {
// private static final
// 상수
// 클래스 변수
// 인스턴스 변수
// 생성자
// 팩토리 메서드
// 일반 메서드
// getter, setter
// equals, hashCode, toString
}
instanceof
의 사용을 지양하자. 참고 자료
public boolean isPlayer() {
if (this instanceof Player) {
...
}
}
예를 들어 위와 같은 코드의 경우, User
추상 클래스에 public abstract boolean isDealer();
와 같은 추상메소드를 두고, 각각의 Dealer와 Player 구현체에서 그에 맞게끔 재정의 해줌으로써 instanceof
의 사용을 피할 수 있다.
그렇다면 왜 instanceof
를 사용하면 지양해야 할까?
가장 먼저 캡슐화
에 대해서 고민해볼 수 있다.
캡슐화란 객체가 가진 상태나 행위를 감추고 숨기는 것을 의미하는데, instanceof
를 사용하는 것은 각 객체가 무엇인지에 대한 정보를 불필요한 외부에 노출하게 되는 것이다. 즉, 캡슐화가 깨지게 되는 것이다.
다음으로는 OCP 원칙을 지키지 못하기 때문이다. 만약 User를 상속하는 Concrete Class가 더 늘어난다고 생각해보자. 다형성을 이용하면 새로운 클래스를 구현하기만 하면 되는데, instanceof를 사용하게 되는 경우 기존 코드에 대한 변경을 유발하게 된다.
마지막은 SRP을 위배하게 된다. 각 타입에게 책임을 부여하면 되는 일을 하나의 메소드에서 모든 책임을 지려고 하기 때문에 instanceof로 특정 타입임을 알아내고 특정 코드의 실행을 요청하게 되는 것이다.
하지만 instanceof 사용을 하지 않고는 구현이 어렵다고 느껴질 때가 있다. 이럴 때는 다형성
을 고려해보자.
instanceof
사용을 하지 않고 '다형성'을 통해서 원하는 로직을 충분히 표현해 낼 수 있기 때문이다.
메소드 참조를 사용할 수 있으면 사용하자.
return cards.stream()
.anyMatch(card -> card.isAce());
위의 코드 보다는 아래 코드가 보다 간결하다. 더군다나 card
라고 하는 변수명에 대한 고려를 하는 비용도 제거할 수 있다.
return cards.stream()
.anyMatch(Card::isAce);
테스트코드에서 여러개의 코드(Card, player, deck 등)가 중복되는 경우 테스트에서 공통으로 사용할 수 있는 객체를 만들어서 필요한 객체를 반환하는 메소드를 제공하여 여러 테스트에서 사용하면 중복을 줄일 수 있다.
private Cards createCards(List<Card> initCards) {
Cards cards = new Cards();
for (Card initCard : initCards) {
cards.add(initCard);
}
return cards;
}
이에 대한 코멘트: 제가 활용하는 개인적인 방법은 TestUtils와 같은 객체를 만들어 이 메서드를 그쪽으로 옮겨서 필요한 테스트 클래스들에서 사용하도록 하고 있어요~ 참고만 하시면 될 거 같아요~
Consumer 사용
뷰에서 입력받은 'Y' or 'N'에 따라서 추가적으로 카드를 draw할 수 있어야 한다는 요구사항이 존재한다. 즉, 도메인이 뷰쪽의 결과를 도메인에 전달해야하므로 도메인에서 뷰로의 의존이 존재하게 된다는 것인데, 이를 Consumer라는 함수형 인터페이스를 활용하여 끊어낼 수 있다.
Consumer에 대해서 간단히 설명하면, void accept(T t)
추상 메소드를 가지는 함수형 인터페이스로 "객체 T를 받아서 이를 소비한다." 라는 의미를 가진다.
이를 활용하여 블랙잭 게임의 진행 역할
을 담당하는 BlackJack
이라는 도메인을 사용하는 Application 코드에서 다음과 같이 구현하게 되면 BlackJack 도메인에서 View에 대한 의존을 끊어낼 수 있게 된다.
private static void drawAdditionalCard(BlackJack blackJack) {
Consumer<User> consumerPlayer = user -> drawCardPerPlayer(blackJack, user);
Consumer<User> consumerDealer = user -> drawDealerCard(blackJack, user);
blackJack.drawAdditionalCard(consumerPlayer, consumerDealer);
}
리뷰어: 로운
지난 1단계에서 consumer를 사용하였다. 그 이유는 inputView와 도메인 로직이 얽혀있기 때문에 이를 풀어내기 위한 방법 중 하나로서 사용한 것이었다. 하지만 consumer를 남용하는 것은 코드의 해석을 어렵게 할 수 있고, 객체의 책임과 역할에 맞지 않을 수 있다. 따라서 도메인과 로직이 얽혀있지 않은 outputView 쪽은 다음과 같이 불필요한 consumer 사용 대신 BlackJack으로 부터 결과를 가져오도록 하여 책임과 역할을 명확히 할 수 있다.
private static void printFinalResult(BlackJack blackJack) {
Consumer<User> consumer = user -> outputView.printWithScore(UserDto.from(user), user.getScore());
private static void printFinalResult(BlackJack blackJack) {
Map<UserDto, Integer> result = blackJack.getResultCardInfo();
outputView.printWithScore(result);
Map<String, Integer> revenue = blackJack.calculateRevenueAllUser();
outputView.printRevenue(revenue);
}
Fixtures 적용하기
테스트 픽스처(test fixture)란 테스트를 반복적으로 수행할 수 있게 도와주고 매번 동일한 결과를 얻을 수 있게 도와주는 '기반이 되는 상태나 환경'을 의미한다. 여러 테스트에서 공용으로 사용할 수 있는 테스트 픽스처는 테스트의 인스턴스 변수 혹은 별도의 클래스에 모아 본다.
다음과 같이 테스트 코드에서 중복적으로 사용되는 Card 객체에 대해서 static하게 미리 생성하여 상수로써 관리하고 필요한 곳에서 사용할 수 있도록 하였다.
public class Fixtures {
public static final Card SPADE_ACE = new Card(SPADE, ACE);
public static final Card SPADE_EIGHT = new Card(SPADE, EIGHT);
public static final Card CLOVER_FIVE = new Card(CLOVER, FIVE);
public static final Card HEART_KING = new Card(HEART, KING);
...
}
Stream 사용과 Function.identity()
Map<Name, BettingMoney> playerInfo = new HashMap<>();
for (Name playerName : playerNames) {
BettingMoney bettingMoney = createBettingMoney(playerName);
playerInfo.put(playerName, bettingMoney);
}
return playerInfo;
위의 코드를 다음과 같이 간략화 할 수 있으며 훨씬 가독성 있는 코드가 된다.
return playerNames.stream()
.collect(Collectors.toMap(Function.identity(), Application::createBettingMoney));
이 때 사용한 Function.identity()
는 "인자로 넘어온 타입을 받아서 그대로 반환" 이라는 의미의 메소드로 위의 코드를 해석해보면 playerNames에 대해서 스트림을 생성하고, Map으로써 컬렉션을 만들어 반환하는데 이 때 키는 인자로 넘어온 타입 그대로인 "playerNames" 의 타입인 Name이 되고, value는 createBettingMoney() 메소드의 결과가 된다.
로직에 순서
가 존재하는 경우, 이를 메소드 분리등을 통하며 명확히 해주자.
예를 들어 다음의 코드는 BlackJack에서 추가 카드를 드로우 하는 메소드인데, "플레이어 이후에 딜러" 라고 하는 순서
에 대한 조건이 포함되어 있다. 하지만 다음의 코드를 보고는 코드를 처음보는 사람 입장에서 위와 같은 조건을 인지하기 어렵다.
public void drawAdditionalCard(Consumer<User> consumerPlayer, Consumer<User> consumerDealer) {
users.drawAdditionalCard(consumerPlayer, consumerDealer);
}
따라서 다음과 같이 순서를 명확히 해주는 것이 보다 좋은 코드이다.
public void drawAdditionalCard(Consumer<User> consumerPlayer, Consumer<User> consumerDealer) {
users.drawPlayerAdditionalCard(consumerPlayer);
users.drawDealerAdditionalCard(consumerDealer);
}
Q. players와 dealer를 구분해서 호출하는 경우가 users 전체를 도는 로직보다 많다면 내부에서 dealer와 players를 분리해서 관리해도 좋을 거 같아요.
return을 할 때 User가 아닌 각각의 클래스로 return 하는 이유가 있을까요??
// 이전 코드
public List<User> getPlayers() {
// 변경된 코드
public List<Player> getPlayers() {
A. 저는 딜러는 BettingMoney를 가질 필요가 없다고 판단하였습니다. (실제로 현재 요구사항에서는 플레이어들의 수익의 반대(ex. 플레이어들 총 수익: 15000 이면 딜러는 -15000)입니다.) 때문에 Player들에만 BettingMoney에 대한 필드를 추가해주었습니다.
그러다보니 Result의 calculateRevenue()
메소드에서는 player의 getRevenue() 메소드 호출이 필요하게 되었고, 그에 따라서 Users 에서 List<Player>
를 명시적으로 반환해줄 필요가 있게 되었습니다. 그렇지 않으면 calculateRevenue() 에서 다운캐스팅
을 해주어야했습니다.
저는 calculateRevenue() 에서 다운캐스팅을 하는 것보다 메소드 명에도 명시되어 있다시피 "getPlayers()" 메소드에서 Player로 다운캐스팅하는 것이 보다 나은 것 같아 현재와 같이 구현하였습니다...
(결국 다운캐스팅 해준다는 점에서는 동일)
이전에는 List 하나로 관리하고 싶어 이와 같이 구현해보았는데, 말씀해주신 것과 같이 users 전체를 도는 로직이 있다보니 dealer와 players를 분리해서 관리하는게 더 좋아보입니다.
따라서 이전 설계대로 한 번 players와 dealer를 분리해서 관리해보도록 수정해보겠습니다.
Q. player만의 메소드가 생겼다고 했는데, 추상클래스를 쓸 때 고려해야하는 부분이 어떤 부분이 있는지 고민해보시면 좋을거 같아요
A. 저는 우선 User와 Player, Dealer를 상속을 통해 구현한 이유는 다음과 같습니다..!
("개발자가 반드시 정복해야할 객체지향과 디자인패턴" 스터디를 하며 정리한 상속의 개념입니다.)
"상속이란 "코드의 재활용"을 위한 즉 "재사용"을 위한 개념은 아니다. 상속은 "연관된 일련의 클래스들에 공통적인 규약을 정의"하기 위해 사용하는 것이고 재사용의 관점보다는 "기능의 확장(extends)의 관점"이다.
따라서 명확한 IS-A 관계이고, 상위 클래스의 기능을 확장해 나가는 경우 사용하면 좋다."
이러한 점에서 User는 "연관된 일련의 클래스들에 공통적인 규약을 정의" 한다는 내용에 부합하고있다고 생각하고, Player만의 메소드가 생긴 부분은 "기능의 확장"의 관점으로 볼 수 있다고 생각합니다. 또 다른 부분들도 고민을해보았는데 LSP 원칙도 잘 지키고 있고..저는 Player에 getBettingMoney()와 같이 Player만의 메소드가 생긴 것에 문제가 없는 것 같은데요...혹시 "추상클래스"를 쓸 때 고려할 수 있는 부분이 어떤 부분이 있을지 힌트를 주실 수 있을까요..ㅠㅠㅠ
이전에 다운캐스팅 사용과 같은 문제를 직면하면서 User에 getMoney()라는 추상메소드를 두고 Dealer에서는 0을 반환하도록 구현을 할까 하다가도 "Dealer에서 0을 반환"이라는 메소드(기능)는 제대로된 구현이 아니라는 생각에 도입하지 않았습니다. 이번에 수정한 코드에서 또한 딜러에서 BettingMoney 대신 null을 반환한다고 해도 적절하지 않은 "기능 구현"이라는 생각도 들어 시도하지 않았습니다..🥲
이에 대한 리뷰어님의 답변: 캐스팅을 하는 부분이 생기면 IS-A가 맞는지 생각해보게되는거 같은데요.
추상클래스를 쓰다보면 상속받는 클래스들에서 각자만 사용하는 메서드가 생기고 역할이 커지게 되는 경우들이 있는데요.
이때는 정말 추상클래스와 같은 클래스인가? 역할과 책임이 같은가? 라는 생각을 하면서 클래스를 분리하는 것도 고려를 하게 되는거 같아요.
이런 부분을 한번 생각해보시라고 코멘트 달았던 것이었습니다.
문제가 있어서는 아니였어요!
아래 의견은 개인적인 생각입니다. 반영은 안하셔도 돼요.
말씀하신대로 User에 추상메서드를 두는 것은 둘 모두에서 사용하지 않기 때문에 맞지 않다고 생각하는데요.
다른 관점으로 money를 player가 가지고 있어야 하나라고 생각할 수도 있을 것 같습니다~
users.getPlayers()
.stream()
.forEach(player -> result.put(UserDto.from(player), player.getScore()));
위와 같은 경우에는 forEach 보다는 for문이 더 명확하다.Stream의 의도
를 벗어나게 된다. 원래 로직을 수행하는 역할은 Stream을 반환하는 중간 연산이 해야하는 일인데, 최종연산인 forEach에서 수행하는 것은 적절하지 않다.forEach 연산은 최종 연산 중 기능이 가장 적고 가장 '덜' 스트림 답기 때문에, forEach 연산은 스트림 계산 결과를 보고할 때(주로 print 기능)만 사용하고 계산하는 데는 쓰지 말자.