반응형

2단계 로또


디미터 법칙

기존에는 통계내는 역할이 LottoTickets에 있었다. LottoTicket은 티켓을 생성하고
구매 금액을 검증하고, (통계)수익률과 순위당 맞은 개수를 구하고 있었다.

하지만 생각하다 보니 여기서 통계를 구하는 것이 맞을까? 게임을 하지도 않았는데?
라는 생각이 들었다. 그래서 게임을 시작하는 LottoGame으로 역할을 옮기게 되었다.

또 의문이 들었다. 여기서 통계를 구하는 것이 맞을까? 게임만 실행하면 되는데?
라는 생각이 들었다. 그래서 개발자가 반드시 정복해야 할 객체지향과 디자인 패턴 책에서 본
디미터 법칙을 적용해보기로 했다. 애초에 디미터 법칙을 어기고 의존 객체의 내부 구조를 알아야 하는
코드였기 때문이다. 적용을 하다 보니 결국 아래와 같이 WinningStatistics 클래스가 도출 되었다.
도출되면서 느낀건데 코드를 계속 변경해도 변경되는 코드에 의존하는 객체들에는 디미터 법칙
점차 적용되면서 캡슐화가 강화되니 전혀 영향이 없는 점을 발견하게 되었다.

하지만 이 구조도 결국 리팩토링 하면서 조금 수정이 되었지만 고민하는 과정에서 이렇게 그려보게 되었다.

Set 활용하여 필요없는 validation 없애기

로또 번호는 순서가 필요없고, 중복된 숫자는 들어갈 수 없다.
원래 알고 있었지만 로또 티켓을 검증하는 LottoTicket 클래스에서 List 형태
로또 번호들을 받아서 처리했기 때문에 중복 번호 검증이 있었다.

원인은 애초에 번호 생성 시(AutomaticLottoNumber 클래스) List를 반환 했기 때문이다.
그래서 애초에 생성 시 HashSet을 사용하여 LottoTicekt 클래스에서 검증 로직을 제거할 수 있었다.

코드를 살펴보자.

리팩토링 전

보면 AutomaticLottoNumber에서 번호를 List로 생성하여 반환한다.
이 후 LottoTicket이 이를 받아 Set을 사용하여 중복을 제거(validateDuplicate())하고 크기가 LOTTO_NUMBERS_SIZE가 아니면 예외 처리를 했다.

하지만 과연 이 검증 코드가 필요한가?

// LottoTicket 클래스
private void validateDuplicate(List<LottoNumber> lottoNumbers) {
    Set<LottoNumber> nonDuplicateNumbers = new HashSet<>(lottoNumbers);

    if (nonDuplicateNumbers.size() != LOTTO_NUMBERS_SIZE) {
        throw new IllegalArgumentException("로또 번호들은 중복될 수 없습니다.");
    }
}

// AutomaticLottoNumber
public static List<LottoNumber> createNumbers() {
    Collections.shuffle(numbers);

    List<LottoNumber> lottoNumbers = new ArrayList<>();
    for (int i = 0; i < LOTTO_NUMBERS_SIZE; i++) {
        lottoNumbers.add(numbers.get(i));
    }

    return lottoNumbers;
}

리팩토링 후

아래와 같이 Set으로 변경을 하고, iterator를 사용하여 반환을 하는 로직으로 변경하고,
LottoTicket에서는 중복 검증 로직을 제거할 수 있었다.

// AutomaticLottoNumber
public static Set<LottoNumber> createNumbers() {
    Collections.shuffle(numbers);

    Set<LottoNumber> lottoNumbers = new HashSet<>();
    Iterator<LottoNumber> iterator = numbers.iterator();
    while (lottoNumbers.size() < LottoTicket.LOTTO_NUMBERS_SIZE) {
        lottoNumbers.add(iterator.next());
    }

    return lottoNumbers;
}

물론 연관 코드를 모두 수정해야 했지만, 테스트 코드가 잘 작성되어 있어 어렵지 않았다.

의미있는 테스트와 간결한 테스트에 대한 고민

예시로 아래와 같은 코드에 대해서 테스테 고민을 했다.

public class Ticket {

    private static final int TICKET_SIZE = 6;

    private final Set<LottoNumber> ticket;

    private Ticket(Set<LottoNumber> ticket) {
        validate(ticket);
        this.ticket = ticket;
    }

    public static Ticket of(List<Integer> numbers) {
        Set<LottoNumber> ticket = new HashSet<>();
        for (Integer number : numbers) {
            ticket.add(LottoNumber.of(number));
        }
        return new Ticket(ticket);
    }
        ....eqauls(), hashCode()....
}

1. 테스트 전용 생성자를 만들어 테스트를 하는 것

이렇게 할 경우 테스트 데이터 생성이 매우 번거로웠다. 하지만 장점으로는 굳이 getter가 필요없다는 점이었다.
getter가 있으면 디미터를 어길 가능성이 있기 때문에 사용하지 않으려고 했다.
아래가 테스트 전용 생성자인데 일부러 default로 막았다.

    Ticket(Set<LottoNumber> ticket) {
        validate(ticket);
        this.ticket = ticket;
    }

하지만 테스트 데이터 생성이 아래와 같이 불편했다.

        @DisplayName("로또 번호가 6개이면 티켓을 생성한다.")
    @Test
    void Should_Success_When_SixNumbers() {
        // given
        List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
        Set<LottoNumber> expectedNumbers = new HashSet<>();
        expectedNumbers.add(LottoNumber.of(1));
        expectedNumbers.add(LottoNumber.of(2));
        expectedNumbers.add(LottoNumber.of(3));
        expectedNumbers.add(LottoNumber.of(4));
        expectedNumbers.add(LottoNumber.of(5));
        expectedNumbers.add(LottoNumber.of(6));

        Ticket expectedTicket = new Ticket(expectedNumbers);

        // when
        Ticket ticket = Ticket.of(numbers);

        // then
        assertThat(ticket).isEqualTo(expectedTicket);
    }

2. getter를 사용하여 테스트 하기.

1번과 비슷하지만 단지 객체 동등성 검증이 아닌 getter로 해당 데이터만 검증하는 방법이다.
음.. 차라리 거의 비슷하니 getter를 열지 않고 1번을 사용하는게 더 좋다고 생각한다.
굳이 getter를 열어서 디미터를 어길 수 있는 것을 허용하지 않는 것이 좋다고 생각하기 때문이다.

publicSet<LottoNumber> get() {
    return Collections.unmodifiableSet(ticket);
}

테스트 코드를 보면 거의 1번과 비슷하다.

        @DisplayName("로또 번호가 6개이면 티켓을 생성한다.")
    @Test
    void Should_Success_When_SixNumbers() {
        // given
        List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
        Set<LottoNumber> expectedNumbers = new HashSet<>();
        expectedNumbers.add(LottoNumber.of(1));
        expectedNumbers.add(LottoNumber.of(2));
        expectedNumbers.add(LottoNumber.of(3));
        expectedNumbers.add(LottoNumber.of(4));
        expectedNumbers.add(LottoNumber.of(5));
        expectedNumbers.add(LottoNumber.of(6));

        // when
        Ticket ticket = Ticket.of(numbers);

        // then
        assertThat(ticket.get()).isEqualTo(expectedNumbers);
    }

3. getter를 사용하지 않기 위해 객체 동등성 검증을 해야하는 걸까?

위에 1번은 객체 동등성 검증, 2번은 getter검증을 하였다. 케바케이긴 하지만 될 수 있다면 동등성 검증이 더 좋은 방법이라고 생각한다.
객체 자체의 값이 모두 같아야 하는데 getter만 검증을 했을 객체의 다른 상태는 검증이 안될 수도 있기 때문이다.
즉, getter를 사용하지 않기 위해서 객체 동등성 검증을 해야하는 것은 아니라고 생각한다.

4. 의미있는 테스트인가 고민하기

애초에 이런 고민이 생긴 이유가 꼭 있어야 하는 테스트인가에 대한 고민없이 무작정 테스트를 작성하려고 했기 때문이었다.
테스트가 많다고 좋은 것도 아니며, 상황에 따라 다른 것이기 때문에 테스트가 필요 없다면 굳이 만들 필요가 없다고 생각한다.
즉, 테스트가 어렵거나 불편 하다면 세 가지를 생각해 볼 수 있을 것 같다.

  • 구조를 개선한다.
  • 구조 문제가 아니라면 필요없는 테스트일 수도 있다.
  • 데이터 생성이 번거롭다면 데이터 생성 메서드를 만들면 된다.(이펙티브 자바에서 그렇게 하더라)

일급 컬렉션 사용하기

일급 컬렉션을 사용하면 좋은 점이 4가지 있다고 한다. 내가 온전하게 느낀 것은 세 가지이다.
아주 좋은 참고 자료가 여기 있습니다.

이 부분은 1단계 자동차 미션을 하면서 해봤던 부분인데 깜빡한다. 일급 컬렉션이라는 말을 이 과정하면서 처음 들어보게 되었는데
굉장한 무기이다.

  • 종속적인 자료구조
    • 검증 로직을 해당 객체 생성 시 무조건 검증하는 자료구조로 비지니스 로직을 잘 모르는 개발자도 실수할 여지를 줄일 수 있다.
  • 상태와 행위를 한 곳에서 관리
    • 외부에서 어떤 기능을 하기위해 특정 메서드에만 의존하고 외부에서 get하여 직접 로직을 짜서 사용하지 않게 할 수 있다.
      외부에서 직접 로직을 작성하게 되면 여기저기 코드가 달라지게 되는 현상이 발생하여 버그 발생률이 높아진다.
  • 불변
    • setter와 같은 메서드로 여기저기서 값을 변경하게 되면 정말 코드를 건들기가 무서워서 "이거 만지면 다 엎어야 돼요"라는 말이 자연스레
      나오게 된다. 이런 사이드 이펙트를 방지하기 위해 불변 객체는 정말 필수이다.
반응형
복사했습니다!