본문 바로가기

강의/TDD, Clean Code with Java 12기

로또 게임 - 강의 정리

AS-IS

  • 학습 테스트 - JUnit 사용법 및 단위 테스트 연습
  • 단위 테스트 - 내가 구현한 코드에 대한 단위 테스트
  • TDD - TDD 사이클을 맛보기 단계

아무런 설계가 없는 상태에서 TDD를 구현하기가 어렵다.

대상이 되는 클래스가 너무 많은 책임을 지니고 있기 때문에 TDD로 구현하기 어렵다.

클래스를 어떻게 작게 나눌까를 고민하다 보면, 객체 설계에 대한 힌트를 얻는 경우가 많다.

 

TO-BE

  • TDD - TDD 사이클이 익숙해질 때까지 같은 미션으로 반복 연습

TDD로 Lotto 구현하기

기능 요구사항
  • 로또 구입 금액을 입력하면 구입 금액에 해당하는 로또를 발급해야 한다.
  • 로또 1장의 가격은 1000원이다.

 

시작하기
  • 요구사항 분석을 통한 기능 목록 작성
  • 객체 설계를 통해 어느 부분부터 구현을 시작할 것인지 결정

 

기능 목록
  • 구매한 Lotto의 매수 구하기
    • 1000 -> 1
    • 1500 -> 1
    • 500 -> error
  • 한장의 Lotto 생성
    • 정상적인 당첨번호 입력
    • 유효하지 않은 당첨번호
  • 한장의 Lotto에 대한 당첨 결과 구하기
  • n장의 Lotto에 대한 당첨 결과 구하기
  • Lotto 결과에 따른 수익률 구하기

 

TDD로 구현할 기능 찾기

구현 중간 부분을 자르는 연습을 해야 한다.

즉, 프로그램이 실형되는 특정 시점의 상태 값으로 시작한다는 것을 의미한다.

 

큰 프로그램을 작은 단위로 쪼개야 한다. 그래야 객체 설계도 나오고 테스트할 부분도 보이게 된다.

프로그램이 실행이 되다가 특정 상태가 되는데 그 특정 상태값을 가지고 다시 테스트 코드를 만들 수 있어야 한다.

 

자동차 경주게임을 예시로 들어보자 - 우승자 구하기

자동차 경주 게임을 완료한 시점의 자동차 상태 값을 테스트 코드에서 변경(또는 결정) 할 수 있음을 의미한다.

특정 시점의 상태를 분리할 수 있는 그 역량이 중요하다.

레이싱이 끝났을 때의 자동차 목록을 가지고 있다면 그 목록 데이터를 가지고 우승자를 구할 수 있다.

아래의 예를 살펴보자.

public class WinnersTest {
    @Test
    void winners() {
        List<Car> cars = Arrays.asList(
                new Car("pobi", 4),
                new Car("crong", 3),
                new Car("honux", 4),
                new Car("jk", 2));
        
        List<Car> winners = Winners.findWinners(cars);
        assertThat(winners).containsExactly( new Car("pobi", 4),
                new Car("honux", 4))
    }
}

이렇게 winner 구하는 방법을 테스트로 구현할 수 있도록 해야한다는 것이다.

어려긴 하지만 이것이 설계에 대한 핵심중 하나라고 볼 수 있다.

 

가장 작은 도메인에 대한 기준은 상황에 따라 달라진다고 생각한다.

도메인에 대한 지식이 깊어질 수록 더 좋은 객체설계가 된다.

 

객체에 대한 유효성 검증은 객체내에 존재하도록 한다.

모든 사용자가 안심하고 사용할 수 있도록 한다.

 

로또에서 TDD로 구현할 기능 찾기
  • 로또 구매 금액을 전달하면 구매할 수 있는 로또의 장수를 반환한다.
  • 구매한 로또의 장 수 만큼 자동 구매할 경우 자동 로또 생성해 반환한다.
  • 구매한 한장의 로또 번호와 당첨 번호를 넣으면 당첨 결과를 반환한다.
  • 구매한 전체 로또의 당첨 결과를 입력하면 당첨금 총액을 반환한다.
  • 당첨 금액과 구매 금액을 넣으면 수익률을 반환한다.

 

한장의 Lotto에 대한 당첨 결과 구하기

오늘 강의는

객체 설계 경험이 부족한 주니어 개발자이거나,

객체 설계가 부족한 레거시 코드가 존재하는 상황을 가정한 내용이다.

 

시작하기
  • 객체 설계를 어떻게 해야할지 모르겠다면 시작은 클래스 메소드 구현으로 시작한 후 지속적인 리팩토링
  • 리팩토링 할 때는 객체지향 생활체조 원칙, 클린코드 원칙을 참고해 리팩토링

아래와 같이 리팩토링이 필요한 코드가 존재한다.

현재는 절차 지향적인 프로그래밍이다.

public class LottoGame {
    public static int match(List<Integer> userLotto, List<Integer> winningLotto, int bonusNo) {
        // 값들이 유효한지에 대해서 아래와 같은 유효성 검증도 필요하다.
        // userLotto 6개의 값, int 값이 1 ~ 45
        // winningLotto 6개의 값, int 값이 1 ~ 45
        // bonusNo 1 ~ 45, winningLotto에 포함되지 않은 값
        int matchCount = match(userLotto, winningLotto);
        if (matchCount == 6) {
            return 1;
        }
        boolean matchBonus = userLotto.contains(bonusNo);
        if (matchCount == 5 && matchBonus) {
            return 2;
        }
        if (matchCount > 2) {
            return 6 - matchCount + 2;
        }
        return 0;
    }

    private static int match(List<Integer> userLotto, List<Integer> winningLotto) {
        int count = 0;
        for (Integer lottoNumber : userLotto) {
            if (winningLotto.contatins(lottoNumber)) {
                count += 1;
            }
        }
        return count;
    }
}

아래의 테스트 코드가 존재한다.

public class LottoGameTest {
    private List<Integer> winningLotto;
    private int bonusNo;
    
    @BeforeEach
    void setUp() {
        winningLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        bonusNo = 7;
    }
    
    @Test
    public void match_1등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEquaalTo(1);
    }
}

 

이런 코드를 어떻게 수정할 것인가??

어디서 부터 어떻게 수정을 시작할 것인가?

 

리팩토링을 할 때 잘 모르겠으면 클래스 분리를 하는 것 부터 시작해본다.

메소드 파라미터로 전달된 인자들을 모두 클래스로 분리를 한다.

원시값을 포장하라 -> 클래스를 나누기 위한 방법이다.

 

테스트 코드 생성

public class LottoTest {
    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6)));
    }
}

테스트 코드를 성공시키기 위한 도메인 클래스 생성

public class Lotto {
    private final List<Integer> lottoNos;
    public Lotto(List<Integer> lottoNos) {
        this.lottoNos = lottoNos;
    }
    // equals 메서드 오버라이드
}

로또번호는 6개의 번호로 이루어져 있다.

라는 것에 대한 테스트 케이스 추가 (invalid)

public class LottoTest {
    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6)));
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

테스트를 성공시키기 위해 다시 해당 Lotto 클래스 다시 수정

public class Lotto {
    private final List<Integer> lottoNos;
    public Lotto(List<Integer> lottoNos) {
        if (lottoNos.size() != 6) {
            throw new IllegalArgumentException();
        }
        this.lottoNos = lottoNos;
    }
    // equals 메서드 오버라이드
}

추가적인 유효성 테스트도 진행해보자

public class LottoTest {
    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6)));
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 5));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(0, 2, 3, 4, 5, 6));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 46));
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

위 테스트를 성공시키기 위한 코드는 따로 작성하지 않도록 하겠다.

 

match에 대한 테스트가 새로 생성되었다.

public class LottoTest {
    @Test
    void match() {
        Lotto userLotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        int result = userLotto.match(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(result).isEqualTo(6);
    }

    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6)));
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 5));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(0, 2, 3, 4, 5, 6));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 46));
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

 

LottoGame에 있던 match 기능을 Lotto 클래스로 옮겨주도록 하자.

public class Lotto {
    private final List<Integer> lottoNos;
    public Lotto(List<Integer> lottoNos) {
        if (lottoNos.size() != 6) {
            throw new IllegalArgumentException();
        }
        this.lottoNos = lottoNos;
    }
    
    public int match(List<Integer> other) {
        int matchCount = 0;
        for (Integer lottoNo : lottoNos) {
            if (other.contains(lottoNos)) {
                matchCount += 1;
            }
        }
        return matchCount;
    }
    
    // equals 메서드 오버라이드    
}

matchBonus 번호 존재 여부에 대한 테스트를 추가한다.

public class LottoTest {
    @Test
    void matchbonus() {
        Lotto userLotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        boolean result = userLotto.matchBonusNo(6);
        assertThat(result).isTrue();
    }

    @Test
    void match() {
        Lotto userLotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        int result = userLotto.match(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(result).isEqualTo(6);
    }

    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6)));
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 5));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(0, 2, 3, 4, 5, 6));
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 46));
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

해당 기능을 다시 구현한다.

public class Lotto {
    private final List<Integer> lottoNos;
    public Lotto(List<Integer> lottoNos) {
        if (lottoNos.size() != 6) {
            throw new IllegalArgumentException();
        }
        this.lottoNos = lottoNos;
    }

    public int match(List<Integer> other) {
        int matchCount = 0;
        for (Integer lottoNo : lottoNos) {
            if (other.contains(lottoNos)) {
                matchCount += 1;
            }
        }
        return matchCount;
    }

    public boolean matchBonusNo(int bonusNo) {
        return lottoNos.contains(bonusNo);
    }

    // equals 메서드 오버라이드
}

 

위와 같이 적용을 하다보면 LottoGame에 존재하는 많은 메서드가 이동하게 된다.

일급 콜렉션을 활용, 원시값을 포장하는 것을 적용하시면 클래스 분리하는데 도움을 받을 수 있다는 것을 잊지말자.

'강의 > TDD, Clean Code with Java 12기' 카테고리의 다른 글

사다리타기 교육  (0) 2021.09.10
엘레강트 오브젝트  (0) 2021.09.07
자동차 게임 프로젝트 - 강의 정리  (0) 2021.09.03
옵셔널  (0) 2021.08.25
스트림 실습  (0) 2021.08.25