우아한 테크코스 블랙잭 미션 리뷰
도메인 설계와 구현을 페어와 함께 진행했다. Dealer와 Player를 뜻하는 Participant를 구현할 때 인터페이스로 구현할 것인지, 추상 클래스로 구현할 것인지에 대한 고민을 많이 했었던 것 같다.
이번 페어는 피드백을 직접 전달해줬었다. 내가 네비게이터가 되어 말할 때는 적극적으로 했는데 구현하고 있을 때는 수동적이라는 의견이 있었다. 페어에게 직접 피드백을 말하는 것도 상대방을 위한 좋은 방법인 것 같다.
State를 인터페이스로 만들어 개발하는 방법은 너무나 신기했다. 기억해뒀다가 시간 날 때 적용해야겠다.
내가 받았던 피드백
- 프로덕션 코드에 테스트를 위한 코드를 만드는 것을 지양하자
- 도메인 객체에서 View에서 사용할 값을 만드는 것을 지양하자.
filter를 통해 0이 아닌 값만 뽑도록 하는 로직도 “0이 아닌 값들만 보여준다”는 의미이기에 도메인 로직이 아닌 View에서 처리하는 것으로 하자. sum()을 해줄 때reduce()를 통한Integer의sum()이 아니라 의mapToInt()를 통한IntStream의sum()을 사용하도록 하자- Rule 자체를
Enum으로 관리하는 것도 좋은 방법이다. 그리고 중복되는 검증 또한Enum의 순서를 통해 제거해줄 수 있다
배운 내용
객체와 클래스
- 클래스는 객체의 Factory 이며, 객체를 만들고, 추적하고, 적절한 시점에 파괴한다
- 클래스가 객체를 생성할 때 “클래스가 객체를 인스턴스화한다”라고 표현한다
- 클래스를 ‘객체의 능동적인 관리자’로 생각해야 한다
- 클래스는 객체를 보관하고 필요할 때 객체를 꺼낼 수 있고 더 이상 필요하지 않을 때 객체를 반환할 수 있는 저장소로 바라봐야 한다
- 확실하게 이야기하면 객체의 파괴는 GC가 해준다
생성자
- 생성자는 생성할 때 어떤 일을 수행할 수 있게 해서, 객체가 자신의 의무를 수행할 수 있도록 준비시킨다
- 생성할 때 어떤 일을 수행할 수 있게 해주기 때문에 유연성을 제공해준다
- 응집도가 높고 견고한 클래스는 적은 수의 메서드와 많은 수의 생성자가 존재한다
- 생성자의 주된 임무는 제공된 인자를 사용해 캡슐화된 프로퍼티를 초기화하는 것이다
- 유효성 검사를 해줄 수 있다. 다만 매 생성자마다 할 것이 아니라 주 생성자에서 검증을 하도록 만들고 부 생성자에서 this를 통해서 해주는 게 좋다.
상속
- 상속을 완전히 제거하기보다 올바르게 사용하자
- 클래스와 메서드에 final을 붙여 상속을 불가능하게 만들거나 abstract를 붙여 강제 구현하도록 해서 상속과 관련된 문제를 없앨 수 있다
- 상속의 문제는 상위 클래스의 내부 구현이 달라지면 하위 클래스가 오동작할 수 있으며 하위 클래스에서 재정의하더라도 상위 클래스의 로직을 알아야 하기 때문에 어렵고 시간이 더 든다.
- 상속이 적절한 경우는 클래스의 행동을 확장하는 것이 아닌 정제(refine)할 때다. 정제란 부분적으로 불완전한 행동을 완전하게 만드는 것을 의미한다
- B가 정말 A인가라고 할 때만 클래스 A를 상속해야한다.
- 예를 들어, 로또 미션에서 당첨번호가 로또 넘버와 같나?를 보면 보너스 넘버라는 것을 가지고 있기 때문에 다를 수 밖에 없다.
- 즉, 위닝 넘버는 로또 넘버가 될 수 없다! 그래서 상속을 하면 안된다.
- 추상 클래스를 사용할 때가 상속이 필요한 경우다. 추상클래스를 만들어놓고 얘를 만들 때가 필요한 것.
- 일단 인터페이스를 사용하다가 좀 더 정제가 필요하면 추상 클래스를 생각해봐라.
조합
- 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자
- 상속보다는 조합을 사용해야 문제가 적다
인터페이스
- default가 생긴 이유는 이미 작성된 인터페이스에 새로운 메서드를 추가하고 싶기 때문이다
- 예를 들어, List라는 콜렉션이 인터페이스로 되어있는데 여기에 새로운 기능을 추가하면 상당히 편하겠다는 생각이 들었기 때문
- 새로 정의할 수도 있지만 이미 널리 퍼진 코드이기에 쉽게 바꿀 수가 없었다
- 일급컬렉션을 Iterable 인터페이스로 순회할 수 있도록 하면 안된다. 왜냐하면 외부로 개방된 곳에서 코드를 작성할 수 있기에 취약한 구조가 된다.
가변객체
- 상태값을 변경하는 객체
불변객체
- 모든 클래스를 상태를 변경할 수 없는 불변 클래스로 만들면 유지 보수성이 크케 향상된다
- 현대 컴퓨터의 성능을 무시하지 말자. 마음껏 new 하자^^
- immutable이 주는 이점은 메모리 낭비보다 훨씬 크다.(오라클피셜)
블랙잭 공통 피드백
- 자주 사용하는 인스턴스는
IntegerCache처럼 미리 생성해놓고of()를 통해 가져오도록 하자. 즉, 정적 팩토리 메서드를 사용해보자 - 테스트 패키지에 Fixtures라는 클래스를 만들어
public static final PlayingCard CULBS_ACE = PlayingCard.of(~~);처럼 많은 카드들을 쉽게 테스트할 수 있도록 만들어놓자 - 반복되는 조건문을 다형성을 사용해 해결해보자
- 게임 내 규칙을 자바 객체로 추상화하자
- State 인터페이스
- Started, Running, Finished 추상 클래스
- Hit, Bust, Blackjack, Stay 클래스
- 현재 상태에서 다음 상태의 객체를 생성하는 역할을 현재 상태가 담당하도록 한다.
- State는 딜러, 플레이어가 사용하냐의 관점이 아니라 다른 규칙이 생기더라도 받아들일 수 있게, 그 자체 State 로 바라봐야한다.
Score 같은 클래스의 경우 불변 객체로 만들면 좋다
public interface State {
State draw(PlayingCard card); /
State stay();
boolean isFinished(); // 카드를 더 받을 수 있는 상태인지
Cards cards();
double profit(double money);
}
public class Player {
private String name;
private State state; // 이 안에 cards가 들어간다. 딜러나 플레이어가 Cards를 갖고 있는 게 아니라 State를 가질 수 있도록 한다. 카드 한 장을 뽑을 때마다 상태가 변하기 때문에 void가 아닌 State로 반환값을 준다.
}
// 플레이어한테 Cards와 함께 Started를 맨 처음에 준다
public abstract class Started implements State {
protected Cards cards;
public Started(final Cards cards) {
this.cards = cards;
}
@Override
public Cards cards() {
return cards;
}
}
// Finished 일 때는 draw, stay가 모두 불가하다.
public abstract class Finished extends Started {
public Finished(final Cards cards) {
super(cards);
}
@Override
public State draw(final PlayingCard card) {
throw new UnsupportedOperationException();
}
@Override
public State stay() {
throw new UnsupportedOperationException();
}
@Override
public boolean isFinished() {
return true;
}
@Override
public double profit(double money) {
return money * earningRate();
}
public abstract double earningRate();
}
// Running 일 때는 profit이 불가하다
public abstract class Running extends Started {
public Running(final Cards cards) {
super(cards);
}
@Override
public boolean isFinished() {
return false;
}
@Override
public double profit(final double money) {
throw new UnsupportedOperationException();
}
}
ublic class Hit extends Started {
public Hit(Cards cards) {
super(cards);
}
public State draw(PlayingCard card) {
cards.add();
if(cards.isBust()) {
return new Bust(cards);
}
return new Hit(cards);
}
public State stay() {
return new Stay(cards);
}
public boolean isFinished() {
return false;
}
public double profit() {
throw new UnsupportedOperationException();
}
}
public class Stay extends Finished {
public Stay(final Cards cards) {
super(cards);
}
public State draw(PlayingCard card) {
throw new UnspportedOperationException();
}
@Override
public double earningRate() {
return 1;
}
}
public class Blackjack extends Finished {
public Blackjack(final Cards cards) {
super(cards);
}
@Override
public double earningRate() {
return 1.5;
}
}
public class Bust extends Finished {
public State draw(PlayingCard card) {
throw new UnspportedOperationException();
}
public boolean isFinished() {
return true;
}
}
class HitTest {
@Test
void draw() {
final Hit hit = new Hit(new Cards(CLUBS_ACE, CLUBS_TWO));
final State state = hit.draw(CLUBS_TEN); // 이런 식으로 State를 관리한다.
assertThat(state).isInstanceOf(Hit.class);
}
@Test
void bust() {
final Hit hit = new Hit(new Cards(CLUBS_TEN, CLUBS_TWO));
final State state = hit.draw(CLUBS_KING);
assertThat(state).isInstanceOf(Bust.class);
}
}
// 처음 카드를 받을 때
public class stateFactory {
public static State draw(PlayingCard first, PlayingCard second) {
Cards cards = new Cards(first, second);
// 처음 카드를 받을 때 hit 아니면 blackjack 밖에 안 나온다.
if(cards.isBlackjack()) {
return new Blackjack(cards);
}
return new Hit(cards);
}
}