우테코 8기 프리코스 3주차 회고

박병욱·2025년 11월 3일

우아한테크코스

목록 보기
3/9
post-thumbnail

🎰 로또

3주차는 간단한 로또 발매기 프로그램을 구현하는 과제가 주어졌다. 먼저 주어진 기능 요구 사항을 나름대로 요약해봤다.

<기능 요구 사항 정리>

  • 로또를 발행한다.
    • 구매 금액만큼의 로또를 구매한다. (로또의 단위는 1,000원)
    • 1 ~ 45 사이의 숫자들 중 중복되지 않는 숫자 6개를 생성한다.
  • 당첨 번호를 뽑는다.
    • 중복되지 않도록 1 ~ 45 사이의 숫자들 중 6개를 입력한다.
    • 6개의 숫자와 중복되지 않도록 보너스 번호를 입력한다.
  • 당첨 결과를 발표한다.
    • 당첨 기준을 토대로 당첨 내역과 수익률을 출력한다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시키고, "[ERROR]" 로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

 

입출력 요구 사항

입력

  • 로또 구입 금액을 입력 받는다. 구입 금액은 1,000원 단위로 입력 받으며 1,000원으로 나누어 떨어지지 않는 경우 예외 처리한다.
14000
  • 당첨 번호를 입력 받는다. 번호는 쉼표(,)를 기준으로 구분한다.
1,2,3,4,5,6
  • 보너스 번호를 입력 받는다.
7

출력

  • 발행한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다.
8개를 구매했습니다.

[8, 21, 23, 41, 42, 43]
[3, 5, 11, 16, 32, 38]
[7, 11, 16, 35, 36, 44]
[1, 8, 11, 31, 41, 42]
[13, 14, 16, 38, 42, 45]
[7, 11, 30, 40, 42, 43]
[2, 13, 22, 32, 38, 45]
[1, 3, 5, 14, 22, 45]
  • 당첨 내역을 출력한다.
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
  • 수익률은 소수점 둘째 자리에서 반올림한다. (ex. 100.0%, 51.5%, 1,000,000.0%)
총 수익률은 62.5%입니다.
  • 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]" 로 시작해야 한다.
[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.

실행 결과 예시

구입금액을 입력해 주세요.
8000

8개를 구매했습니다.
[8, 21, 23, 41, 42, 43]
[3, 5, 11, 16, 32, 38]
[7, 11, 16, 35, 36, 44]
[1, 8, 11, 31, 41, 42]
[13, 14, 16, 38, 42, 45]
[7, 11, 30, 40, 42, 43]
[2, 13, 22, 32, 38, 45]
[1, 3, 5, 14, 22, 45]

당첨 번호를 입력해 주세요.
1,2,3,4,5,6

보너스 번호를 입력해 주세요.
7

당첨 통계
---
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.

📃 구현 계획

저번 과제에서 각 객체에 대한 책임을 철저하게 분리하지 못해 중복되는 로직도 있었고, 특정 객체가 처리해야 하는 작업을 엉뚱한 객체가 처리하기도 했다. 또한, 서비스 로직을 설계하고 구현할 때도 명확한 기준 없이 느낌대로 흘러 갔던 것 같아 많은 아쉬움이 남았다. 이번에는 특히 각 객체의 책임을 명확하게 설계하고, 서비스 로직에 대한 역할과 기준에 대해 많은 시간을 할애했다. 물론, 처음부터 완벽한 설계를 했다기 보다는 구현을 해 가면서 추가된 부분들도 많았다. 중간 중간 추가하면서 최종적으로 정리된 구현할 기능 목록은 아래와 같다.

 

Model(Lotto)

  • 로또 한 장을 생성한다.
    • 로또 번호를 오름차순으로 정렬한다.
    • 번호가 6개가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 범위 내의 숫자가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 중복된 숫자가 있으면 IllegalArgumentException 예외가 발생한다.

 

Model(Purchase)

  • 금액만큼 구입 가능한 로또 매수를 계산한다.
    • 구매 금액이 양수가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 구매 금액이 1,000원 단위가 아닐 경우, IllegalArgumentException 예외가 발생한다.

 

Model(LottoGenerator)

  • 중복되지 않는 난수 리스트를 생성한다.
    • 난수 생성 전략이 달라질 수 있으므로 추상화 한다.

 

Model(Parser)

  • 입력된 당첨 번호를 구분자를 기준으로 분리한다.
    • 구분자가 변경될 수 있으므로 추상화 한다.
    • 지정된 구분자를 기준으로 당첨 번호를 분리하는 구현체를 추가한다.
    • 구분자 사이에 공백이 존재하면 IllegalArgumentException 예외가 발생한다.

 

Model(Rank)

  • 당첨 가능한 등수와 당첨 금액을 저장한다.
    • 번호 일치 여부에 따른 등수를 검증한다.

 

Model(WinningNumbers)

  • 당첨 번호 및 보너스 번호를 저장한다.
    • 당첨 번호가 양수가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 당첨 번호에 범위를 벗어난 숫자가 있다면 IllegalArgumentException 예외가 발생한다.
    • 당첨 번호의 개수가 6개가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 당첨 번호에 중복된 숫자가 있다면 IllegalArgumentException 예외가 발생한다.
    • 보너스 번호가 양수가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 보너스 번호에 범위를 벗어난 숫자가 있다면 IllegalArgumentException 예외가 발생한다.
    • 보너스 번호가 당첨 번호와 중복된다면 IllegalArgumentException 예외가 발생한다.

 

Model(Result)

  • 당첨 결과를 집계하고, 수익률을 계산한다.
    • 당첨된 등수를 각각 카운트하고 검증한다.
    • 당첨된 등수에 해당하는 총 상금을 바탕으로 수익률을 계산하고 검증한다.

 

Util(LottoNumbersValidator)

  • 로또 번호에 대한 공통 검증을 수행한다.
    • 로또 번호의 개수는 정해진 개수가 아니라면 IllegalArgumentException 예외가 발생한다.
    • 로또 번호 중 범위를 벗어난 번호가 있다면 IllegalArgumentException 예외가 발생한다.
    • 로또 번호 중 중복된 번호들이 있다면 IllegalArgumentException 예외가 발생한다.

 

Util(LottoRules)

  • 공통으로 사용될 로또 규칙들을 정의한다.
    • 로또 번호 범위의 최솟값을 정의한다.
    • 로또 번호 범위의 최댓값을 정의한다.
    • 로또 번호의 개수를 정의한다.
    • 로또 가격 단위를 정의한다.

 

Service(LottoService)

  • 로또 번호와 당첨 번호를 바탕으로 당첨 여부를 판단한다.
    • 구매 금액 만큼의 로또를 생성한다.
    • 로또 번호와 당첨 번호로 당첨 여부를 판단한다.
    • 당첨된 항목에 해당하는 등수와 개수 정보를 반환한다.

 

View(InputView)

  • 사용자의 입력을 받는다.
    • 구매 금액을 입력받는다.
    • 구매 금액이 공백이면 IllegalArgumentException 예외가 발생한다.
    • 구매 금액이 숫자가 아니라면 NumberFormatException 예외가 발생한다.
    • 당첨 번호를 입력받는다.
    • 보너스 번호를 입력받는다.
    • 당첨 번호와 보너스 번호가 공백이면 IllegalArgumentException 예외가 발생한다.
    • 당첨 번호와 보너스 번호가 숫자가 아니라면 NumberFormatException 예외가 발생한다.

 

View(OutputView)

  • 결과 데이터를 전달 받아 출력한다.
    • 몇 장의 로또를 구매했는지 출력한다.
    • 각 로또의 번호들을 출력한다.
    • 당첨 통계 및 수익률을 출력한다.

 

Controller(LottoController)

  • 서비스 로직으로부터 데이터 및 결과를 반환 받아 뷰로 출력하도록 한다.
    • 입력으로부터 구매 금액을 전달 받고 서비스 로직으로 로또 생성을 요청한다.
    • 입력으로부터 당첨 번호와 보너스 번호를 전달 받고 서비스 로직으로 당첨 여부 판단을 요청한다.
    • 위 과정으로부터 반환 받은 데이터를 뷰에게 전달한다.

🔥 집중한 부분

2주차 과제에서 가장 아쉬웠던 점은 테스트 방법론에 대한 지식이 전무했다는 점이었다. 일단 테스트를 작성하기 전에 왜 테스트 코드를 작성하는 데 시간을 투자해야 하는지를 생각해봤다. 내가 생각하기에 테스트 코드를 작성한다는 것은 위에서 구현할 기능 목록을 작성한 것과 비슷한 맥락인 것 같다. 변경이나 오류가 발생했을 때도, 길을 잃지 않고 정확히 문제가 발생한 지점을 찾을 수 있다. 그리고 2주차 피드백에서 핵심 기능부터 작게 테스트를 작성하라는 내용이 있었는데, 이러한 부분들을 비춰 봤을 때 핵심 기능을 작게 테스트하려고 노력하면 자연스럽게 한 가지 일만 담당하는 함수나 메서드를 생산할 수 있게 되는 것 같다. 무엇보다 실무에서는 여러 사람들과 협업을 할 것이다. 작업이 잘 진행되었는지, 프로그램에 오류가 없는지 확인하기 위해 프로그램을 실행시키는 것보다는, 잘 짜여진 테스트 코드를 사용하는 것이 훨씬 일관성 있고, 모두가 이견 없이 프로그램을 신뢰하고 작업할 수 있을 것 같다는 생각을 했다.

따라서 이번 과제에서는 테스트 주도 개발(TDD) 방법론을 도입해서 프로그램이 마땅히 수행해야 할 로직이나 처리되어야 할 예외들을 생각나는 대로 작성했다. 그리고 작성한 테스트를 통과시킬 수 있는 코드를 작성하고, 향후 추가적인 테스트를 추가하거나 구현한 최소한의 코드를 효율적으로 리팩토링 하려는 노력을 했다. 이러한 작업들을 즉각적으로 반영해 살아 있는 문서가 될 수 있도록 기능 목록을 업데이트했다.

추가로 신경 쓴 부분은 서비스 로직에 대한 역할과 기준이었다. 2주차 과제에서 작성한 서비스 코드에는 나만의 원칙이 녹아져 있지 않았다. 솔직히 말하자면, 서비스 코드를 따로 분리하는 이유와 그 안에 어떤 로직들이 존재해야 하는지 잘 알지 못했다. 이번 로또에서는 여러 모델들이 서로 상호작용 해야 하는 상황이나, 실제 연산이 수행해야 할 로직들을 서비스 코드에 작성했다. 이렇게 처리하니 각 모델들이 적절히 캡슐화 되어 있는지 다시 한번 확인할 수 있었고, 로또를 생성하고 당첨 번호를 바탕으로 당첨 여부를 평가한다는 핵심 로직을 서비스 코드에서 한 눈에 파악할 수 있었다. 잘 설계된 모델들로 프로그램의 핵심 로직을 구현하고, 컨트롤러에서 서비스를 주입 받아 뷰로 결과를 출력하는 깔끔한 흐름을 가져가기 위해 많은 시간을 할애했다.

마지막으로, 디테일한 부분에도 신경을 썼다. 저번 과제에서는 캡슐화된 메서드를 상단에 두고, 외부에서의 접근이 필요한 공개 메서드는 하단에 두는 등, 코딩 컨벤션을 지키지 않았다. 이번에는 필드, 생성자, 필요하다면 정적 팩토리 메서드, public 메서드, private 메서드 순으로 엄격하게 작성하려고 노력했다. 추가로, 커밋 메시지에도 더욱 신경 썼다. 구체적으로 어떤 파일에 대해 작업한 건지 명시하고, 문서를 수정할 경우에도 저번 과제처럼 그냥 깃허브에서 자체적으로 업데이트하는 것이 아니라 테스트를 진행하고, 코드를 구현한 후 모든 작업이 완료될 때마다 문서를 docs(readme) 형식으로 내가 정확히 어떤 작업을 마쳤는지 커밋 메시지를 작성하도록 했다.


🖋 테스트 코드 작성

먼저 구현해야 할 객체의 기능에 대한 테스트 코드를 작성했다. 물론 아래 코드들은 최종 커밋된 테스트 코드라서 최초로 작성했을 당시와는 모습이 좀 다르지만, 핵심 기능에 대한 테스트는 크게 변함이 없다.

🍔 Test(Lotto)

class LottoTest {

    @DisplayName("로또 번호가 오름차순으로 정렬되어 있는지 검증한다.")
    @Test
    void 로또_번호가_오름차순으로_정렬되어_있는지_검증한다() {
        LottoGenerator generator = new RandomLottoGenerator();
        Lotto lotto = Lotto.of(generator.generate());
        assertThat(lotto.getLottoNumbers()).isSorted();
    }
}

로또 한 장에 대한 테스트 코드다. 추후 서술하겠지만, 수행해야 할 다른 검증도 많이 존재한다. 그 많은 검증들에 대한 테스트 코드도 LottoTest에 작성해 놓았지만, 그 검증들이 로또 한 장(Lotto) 모델에만 국한되어 있지 않고, 향후 당첨 번호(WinningNumbers)에서도 동일하게 적용되기 때문에 로또 번호에 대한 공통 검증 로직을 따로 LottoNumbersValidator 클래스로 리팩토링 해줬다. 따라서 그에 대한 테스트 코드는 LottoNumbersValidatorTest에 작성했기 때문에 LottoTest에는 오름차순으로 정렬이 되어 있는지만 확인한 것이다.

 

🍟 Test(Purchase)

class PurchaseTest {

    @DisplayName("구매 금액이 양수가 아니면 예외가 발생한다.")
    @Test
    void 구매_금액이_양수가_아니면_예외가_발생한다() {
        assertThatThrownBy(() ->
            List.of(Purchase.of(-1000), Purchase.of(0))
        ).isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("구매 금액 단위가 천원이 아니면 예외가 발생한다.")
    @Test
    void 구매_금액_단위가_천원이_아니면_예외가_발생한다() {
        assertThatThrownBy(() ->
            List.of(Purchase.of(2025), Purchase.of(1225))
        ).isInstanceOf(IllegalArgumentException.class);
    }
}

구매에 대한 테스트 코드를 구현할 기능 목록을 토대로 작성했다.

 

🌭 Test(Rank)

class RankTest {

    @Test
    void 번호_6개가_일치할_경우_1등인지_검증한다() {
        assertThat(Rank.of(6, false)).isEqualTo(Rank.FIRST);
    }

    @Test
    void 번호_5개가_일치하고_보너스_번호도_일치하는_경우_2등인지_검증한다() {
        assertThat(Rank.of(5, true)).isEqualTo(Rank.SECOND);
    }

    @Test
    void 번호_5개가_일치할_경우_3등인지_검증한다() {
        assertThat(Rank.of(5, false)).isEqualTo(Rank.THIRD);
    }

    @Test
    void 번호_4개가_일치할_경우_4등인지_검증한다() {
        assertThat(Rank.of(4, false)).isEqualTo(Rank.FOURTH);
    }

    @Test
    void 번호_3개가_일치할_경우_5등인지_검증한다() {
        assertThat(Rank.of(3, false)).isEqualTo(Rank.FIFTH);
    }

    @Test
    void 그_외의_경우를_검증한다() {
		    assertThat(Rank.of(2, false)).isEqualTo(Rank.OTHERS);
        assertThat(Rank.of(1, false)).isEqualTo(Rank.OTHERS);
        assertThat(Rank.of(0, false)).isEqualTo(Rank.OTHERS);
    }
}

과제의 프로그래밍 요구 사항에 Enum을 적용하라는 내용이 있었다. 바로 당첨에 대한 내용에 적용하면 될 것 같다는 느낌을 받았다. 그걸 염두에 두고, 테스트 코드도 일치하는 번호의 개수를 바탕으로 알맞은 당첨 등수가 반환되는지 확인하는 테스트 코드를 작성했다.

 

🥞 Test(Result)

class ResultTest {

    @DisplayName("당첨되지 않은 등수는 개수가 0인지 검증한다.")
    @Test
    void 당첨되지_않은_등수는_개수가_0인지_검증한다() {
        Map<Rank, Integer> counts = new EnumMap<>(Rank.class);
        counts.put(Rank.SECOND, 1);
        counts.put(Rank.THIRD, 1);
        counts.put(Rank.FOURTH, 1);
        counts.put(Rank.FIFTH, 1);

        Result result = Result.of(counts);
        assertThat(result.count(Rank.FIRST)).isEqualTo(0);
    }

    @DisplayName("총 상금이 정확히 합산되는지 검증한다.")
    @Test
    void 총_상금이_정확히_합산되는지_검증한다() {
        Map<Rank, Integer> counts = new EnumMap<>(Rank.class);
        counts.put(Rank.FIRST, 1);
        counts.put(Rank.SECOND, 1);
        counts.put(Rank.THIRD, 1);
        counts.put(Rank.FOURTH, 1);
        counts.put(Rank.FIFTH, 1);

        Result result = Result.of(counts);
        assertThat(result.totalPrize()).isEqualTo(2_000_000_000L + 30_000_000L + 1_500_000L + 50_000L + 5_000L);
    }

    @DisplayName("수익률은 소수점 한 자리로 반올림해 백분율로 반환한다.")
    @Test
    void 수익률을_소수점_한_자리로_반올림해_백분율로_반환한다() {
        Map<Rank, Integer> counts = new EnumMap<>(Rank.class);
        counts.put(Rank.FIFTH, 1);

        Result result = Result.of(counts);
        assertThat(result.yieldRate(8_000)).isEqualTo("62.5%");
    }

}

이번엔 당첨 결과에 대한 테스트 코드다. 가장 핵심적으로 수행해야 할 테스트는 각 당첨 등수의 상금을 합산하는 것과 그에 대한 구매 금액 대비 수익률을 소수점 한 자리로 반올림해서 계산하는 것이었다.

 

🧀 Test(WinningNumbers)

class WinningNumbersTest {

    @DisplayName("보너스 번호가 양수가 아니라면 예외가 발생한다.")
    @Test
    void 보너스_번호가_양수가_아니라면_예외가_발생한다() {
        assertThatThrownBy(() -> WinningNumbers.of(List.of(1, 2, 3, 4, 5, 6), -10))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("보너스 번호에 범위를 벗어난 숫자가 있다면 예외가 발생한다.")
    @Test
    void 보너스_번호에_범위를_벗어난_숫자가_있다면_예외가_발생한다() {
        assertThatThrownBy(() -> WinningNumbers.of(List.of(1, 2, 3, 4, 5, 6), 50))
                .isInstanceOf(IllegalArgumentException.class);
    }

		@DisplayName("당첨 번호와 보너스 번호가 중복된다면 예외가 발생한다.")
    @Test
    void 당첨_번호와_보너스_번호가_중복된다면_예외가_발생한다() {
        assertThatThrownBy(() -> WinningNumbers.of(List.of(1, 2, 3, 4, 5, 6), 6))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

Lotto와 마찬가지로, 초기에는 번호에 대한 테스트 코드도 존재했지만, 추후 공통으로 처리할 로또 번호에 대한 검증 코드가 당첨 번호에서도 사용될 것이기 때문에 그와 관련된 테스트만 제외하고 보너스 번호에 대한 테스트 코드만 남겨두었다.

 

🥗 Test(CommaParser)

class CommaParserTest {

    @DisplayName("쉼표를 기준으로 숫자를 분리하는지 검증한다.")
    @Test
    void 쉼표를_기준으로_숫자를_분리하는지_검증한다() {
        String input = "1, 2, 3, 4, 5, 6";
        Parser commaParser = new CommaParser();
        assertThat(commaParser.parse(input)).containsExactly(1, 2, 3, 4, 5, 6);
    }

    @DisplayName("쉼표 사이에 공백이 있으면 예외가 발생한다.")
    @Test
    void 쉼표_사이에_공백이_있으면_예외가_발생한다() {
        Parser commaParser = new CommaParser();
        assertThatThrownBy(() -> commaParser.parse(("1, ,2, 3, 4, 5")))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

다른 구분자를 사용해서 당첨 번호를 파싱해야 하는 상황이 생길 수도 있다는 생각을 해서 Parser를 인터페이스로 구현할 계획이었다. 현재 과제에서는 쉼표를 기준으로 당첨 번호를 파싱하고 있기 때문에 해당 구현체 모델에 대한 테스트 코드를 추가했다.

 

🥪 Test(LottoNumbersValidator)

class LottoNumbersValidatorTest {

    @DisplayName("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.")
    @Test
    void 로또_번호의_개수가_6개가_넘어가면_예외가_발생한다() {
        assertThatThrownBy(() -> Lotto.of(List.of(1, 2, 3, 4, 5, 6, 7)))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("로또 번호의 개수가 6개보다 적으면 예외가 발생한다.")
    @Test
    void 로또_번호의_개수가_6개보다_적으면_예외가_발생한다() {
        assertThatThrownBy(() -> Lotto.of(List.of(1, 2, 3, 4, 5)))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("로또 번호가 범위 내의 값이 아니라면 예외가 발생한다.")
    @Test
    void 로또_번호가_범위_내의_값이_아니라면_예외가_발생한다() {
        assertThatThrownBy(() -> Lotto.of(List.of(-1, 0, 5, 47, 97, 101)))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.")
    @Test
    void 로또_번호에_중복된_숫자가_있으면_예외가_발생한다() {
        assertThatThrownBy(() -> Lotto.of(List.of(1, 2, 3, 4, 5, 5)))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

로또 번호의 공통 규칙에 대한 테스트 코드다. 공통 규칙을 하나의 유틸 클래스로 설계하고 LottoWinningNumbers에 적용하도록 초기 설계를 수정했다. 위는 공통 규칙에 대한 테스트 코드다.

 

🍖 Test(RandomLottoGenerator)

class RandomLottoGeneratorTest {

    LottoGenerator generator = new RandomLottoGenerator();
    List<Integer> generatedNumbers = generator.generate();

    @DisplayName("범위 내에서 숫자가 생성되는지 검증한다.")
    @Test
    void 범위_내에서_숫자가_생성되는지_검증한다() {
        for (int i = 0; i < 1000; i++) {
            for (Integer generatedNumber : generatedNumbers) {
                assertThat(generatedNumber).isBetween(1, 45);
            }
        }
    }

    @DisplayName("설정한 개수만큼 난수가 생성되는지 검증한다.")
    @Test
    void 설정한_개수만큼_난수가_생성되는지_검증한다() {
        for (int i = 0; i < 1000; i++) {
            assertThat(generatedNumbers).hasSize(6);
        }
    }

    @DisplayName("생성된 난수들이 중복되는지 검증한다.")
    @Test
    void 생성된_난수들이_중복되는지_검증한다() {
        for (int i = 0; i < 1000; i++) {
            assertThat(generatedNumbers).doesNotHaveDuplicates();
        }
    }
}

마지막으로, 난수를 생성하는 책임을 가진 모델도 필요하기 때문에 그에 대한 테스트 코드를 작성했다. 다만, 과제에서 주어진 라이브러리를 이용할 것이기 때문에 간단한 경계값에 대한 테스트와 정상적으로 동작하는지에 대한 테스트만 작성해줬다.

초기 테스트와는 다소 차이가 있지만, 이렇게 각 객체가 가져야 할 핵심 기능에 대한 테스트 코드를 먼저 작성한 후에 테스트를 통과시킬 수 있는 코드를 작성했다. 그리고 추가로 수행해야 할 테스트가 생기면 추가하고, 또 그에 맞게 구현 코드를 효율적으로 리팩토링 했다. 이런 과정을 계속 반복하면서 기능이 어느 정도 완성되었다고 판단이 되었을 때, 해당 내용을 문서에 반영하는 식으로 작업을 진행했다.


🎫 Model(Lotto)

“로또 한 장” 에 대한 데이터와 규칙을 정의하기 위해 주어진 Lotto 클래스를 다듬었다.

package lotto.domain.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import lotto.domain.utils.LottoNumbersValidator;

public class Lotto {

    private final List<Integer> numbers;

    private Lotto(List<Integer> numbers) {
        LottoNumbersValidator.validateNumbers(numbers);
        this.numbers = Collections.unmodifiableList(toSortedAscending(numbers));
    }

    public static Lotto of(List<Integer> numbers) {
        return new Lotto(numbers);
    }

    public List<Integer> getLottoNumbers() {
        return numbers;
    }

    private static List<Integer> toSortedAscending(List<Integer> numbers) {
        List<Integer> copiedList = new ArrayList<>(numbers);
        Collections.sort(copiedList);
        return copiedList;
    }
}

먼저 기능 요구 사항에서 언급되었듯이 로또를 발행할 때 정해진 숫자를 뽑아서 보관할 numbers 필드를 사용하고, 생성자에서 로또 번호를 검증하고 오름차순으로 생성하도록 처리했다. 추가로, 검증 로직을 캡슐화 하고, 주어진 numbers를 바탕으로 Lotto라는 인스턴스를 생성한다는 의미가 잘 드러나도록 정적 팩토리 메서드를 추가했다.

 

↗ 번호들을 오름차순으로 정렬: toSortedAscending()

앞으로 구현할 로또 난수 생성기의 camp.nextstep.edu.missionutils.Randoms의 pickUniqueNumbersInRange() 메서드가 불변 리스트를 반환하다보니, 그냥 생성자에서 Collections.sort(numbers)처럼 정렬하려고 하면, UnsupportedOperationException 예외가 발생했다. 따라서 어쩔 수 없이 numbersArrayList로 받아 안전하게 복사하고 정렬을 수행하고 나서 리스트를 반환 받도록 처리했다.

private static List<Integer> toSortedAscending(List<Integer> numbers) {
    List<Integer> copiedList = new ArrayList<>(numbers);
    Collections.sort(copiedList);
    return copiedList;
}

🏷 Model(Purchase)

로또를 “구매” 하는 행위에 대한 부분도 데이터와 규칙을 가지는 것이 적절하다고 판단해서 별도로 Purchase 클래스에 설계했다.

package lotto.domain.model;

import static lotto.domain.utils.LottoRules.paymentUnit;

public class Purchase {

    private final int payment;

    private Purchase(int payment) {
        validatePaymentIsPositive(payment);
        validatePaymentIsMultipleOfPrice(payment);
        this.payment = payment;
    }

    public static Purchase of(int payment) {
        return new Purchase(payment);
    }

    public int getPayment() {
        return payment;
    }

    public int getPurchasedLottoCount() {
        return payment / paymentUnit();
    }

    private void validatePaymentIsPositive(int payment) {
        if (payment <= 0) {
            throw new IllegalArgumentException("[ERROR] 구매 금액은 반드시 양수여야 합니다.");
        }
    }

    private void validatePaymentIsMultipleOfPrice(int payment) {
        if (payment % paymentUnit() != 0) {
            throw new IllegalArgumentException(String.format("[ERROR] 구매 금액은 %d원 단위여야 합니다.", paymentUnit()));
        }
    }
}

구매 금액을 보관할 payment 필드를 추가하고, 정해진 로또 가격에 맞게 로또를 발행하도록 getPurchasedLottoCount() 메서드를 추가해줬다. 참고로, 로또 프로그램 전역에서 사용될 공통 상수들은 LottoRules라는 별도의 클래스에 정의해뒀다.


🥇 Model(Rank)

정해진 등수를 저장하기 위해 자바의 Enum을 사용해서 구현했다. 각 등수는 일치하는 번호의 개수(matchedNumberCount), 보너스 번호를 포함하고 있는지 여부(containsBonusNumber), 해당되는 상금(prize)로 구성했다.

package lotto.domain.model;

import java.util.Arrays;

public enum Rank {
    FIRST(6, false, 2_000_000_000L),
    SECOND(5, true, 30_000_000L),
    THIRD(5, false, 1_500_000L),
    FOURTH(4, false, 50_000L),
    FIFTH(3, false, 5_000L),
    OTHERS(0, false, 0L);

    private final int matchedNumberCount;
    private final boolean containsBonusNumber;
    private final long prize;

    Rank(int matchedNumberCount, boolean containsBonusNumber, long prize) {
        this.matchedNumberCount = matchedNumberCount;
        this.containsBonusNumber = containsBonusNumber;
        this.prize = prize;
    }
    
    public static Rank of(int matchedNumberCount, boolean containsBonusNumber) {
        return Arrays.stream(values())
                .filter(rank -> rank.matchedNumberCount == matchedNumberCount)
                .filter(rank -> rank.matchedNumberCount != 5 || rank.containsBonusNumber == containsBonusNumber)
                .findFirst()
                .orElse(OTHERS);
    }

    public int getMatchedNumberCount() {
        return matchedNumberCount;
    }

    public long getPrize() {
        return prize;
    }
}

보다시피 기능 요구 사항에서 주어진 당첨 등수와 그에 해당하지 않는 경우는 모두 OTHERS로 처리되도록 했다. 외부에서 일치하는 번호의 개수와 보너스 번호 포함 여부를 바탕으로 설정된 당첨 등수를 반환 받을 수 있다.

 

일치하는 번호의 개수와 보너스 번호 포함 여부를 바탕으로 등수 반환: of()

public static Rank of(int matchedNumberCount, boolean containsBonusNumber) {
    return Arrays.stream(values())
            .filter(rank -> rank.matchedNumberCount == matchedNumberCount)
            .filter(rank -> rank.matchedNumberCount != 5 || rank.containsBonusNumber == containsBonusNumber)
            .findFirst()
            .orElse(OTHERS);
}

기존에는 조건문으로 모든 항목에 대해 분기 처리를 해서 코드가 장황했는데, 스트림을 도입하여 일치하는 번호의 개수로 먼저 필터링 하고, 그 후 일치하는 번호가 5개인 경우에만 보너스 번호 포함하는지 필터링 하도록 했다.


🎉 Model(Result)

등수를 반환 받아, 각각의 등수가 몇 번 당첨되었는지 확인하는 Result 클래스를 설계했다. 원래는 서비스 코드에서 직접 당첨된 등수를 카운트하고 수익률을 계산하는 로직을 작성했었는데 코드가 너무 길어져서 가독성이 떨어졌다. 따라서 당첨 결과를 바탕으로 수익률을 계산하는 책임을 맡는 Result 객체를 별도로 만드는 것이 적절하다고 생각했다.

package lotto.domain.model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.EnumMap;
import java.util.Map;

public class Result {

    private final Map<Rank, Integer> counts;

    private Result(Map<Rank, Integer> counts) {
        this.counts = new EnumMap<>(Rank.class);
        for (Rank rank : Rank.values()) {
            this.counts.put(rank, counts.getOrDefault(rank, 0));
        }
    }

    public static Result of(Map<Rank, Integer> counts) {
        return new Result(counts);
    }

    public Long totalPrize() {
        long sum = 0L;
        for (Map.Entry<Rank, Integer> e : counts.entrySet()) {
            sum += e.getKey().getPrize() * e.getValue();
        }

        return sum;
    }

    public String yieldRate(int payment) {
        BigDecimal rate = BigDecimal.valueOf(totalPrize())
                .divide(BigDecimal.valueOf(payment), 3, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100));

        return rate.setScale(1, RoundingMode.HALF_UP) + "%";
    }

    public int count(Rank rank) {
        return counts.getOrDefault(rank, 0);
    }
}

먼저 각 등수에 따른 당첨 횟수를 저장하기 위해 Map을 이용해 counts라는 필드를 추가했다. 추가로 생성자의 내부 로직을 숨긴 채, 파라미터로 전달 받은 각 등수에 대한 당첨 횟수를 초기화 하기 위해 정적 팩토리 메서드를 도입했다.

 

💵 총 상금 합산: totalPrize()

public Long totalPrize() {
    long sum = 0L;
    for (Map.Entry<Rank, Integer> e : counts.entrySet()) {
        sum += e.getKey().getPrize() * e.getValue();
    }

    return sum;
}

모든 등수를 순회하면서 해당하는 등수에 책정된 상금을 당첨 횟수만큼 곱해서 최종 결과에 합산하는 방식으로 총 상금을 반환하도록 처리했다.

 

🧮 수익률 계산: yieldRate()

public String yieldRate(int payment) {
    BigDecimal rate = BigDecimal.valueOf(totalPrize())
            .divide(BigDecimal.valueOf(payment), 3, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(100));

    return rate.setScale(1, RoundingMode.HALF_UP) + "%";
}

그냥 long 타입을 String 클래스의 메서드로 반올림 처리를 하려고 했는데, 부동 소수의 오차로 인해 계속해서 테스트가 실패했다. 그래서 BigDecimal을 이용해서 정확한 10진 연산과 명시적 반올림 후, 반올림 할 자릿수를 제어해주도록 처리했다.


⛳ Model(WinningNumbers)

당첨 번호에 대한 데이터와 규칙을 정의하는 WinningNumbers 클래스다. 기존에는 BonusNumber를 별도로 설계했는데, “당첨” 번호라는 도메인을 비추어 봤을 때, 보너스 번호도 결국 당첨을 판단하는데 사용되는 당첨 번호라고 생각해서 WinningNumbers 모델에 통합해줬다.

package lotto.domain.model;

import static lotto.domain.utils.LottoRules.maxNumber;
import static lotto.domain.utils.LottoRules.minNumber;

import java.util.List;
import lotto.domain.utils.LottoNumbersValidator;

public class WinningNumbers {

    private final List<Integer> winningNumbers;
    private final int bonusNumber;

    private WinningNumbers(List<Integer> winningNumbers, int bonusNumber) {
        this.winningNumbers = winningNumbers;
        this.bonusNumber = bonusNumber;
        LottoNumbersValidator.validateNumbers(winningNumbers);
        validateBonusNumberInRange(bonusNumber);
        validateDuplicateBonusNumber(bonusNumber);
    }

    public static WinningNumbers of(List<Integer> winningNumbers, int bonusNumber) {
        return new WinningNumbers(winningNumbers, bonusNumber);
    }

    public List<Integer> getWinningNumbers() {
        return winningNumbers;
    }

    public int getBonusNumber() {
        return bonusNumber;
    }

    private void validateBonusNumberInRange(int bonusNumber) {
        if (bonusNumber < minNumber() || bonusNumber > maxNumber()) {
            throw new IllegalArgumentException("[ERROR] 보너스 번호가 범위를 벗어났습니다.");
        }
    }

    private void validateDuplicateBonusNumber(int bonusNumber) {
        if (winningNumbers.contains(bonusNumber)) {
            throw new IllegalArgumentException("[ERROR] 보너스 번호가 당첨 번호와 중복됩니다.");
        }
    }
}

당첨 번호와 보너스 번호를 입력 받아서 WinningNumbers의 정적 팩토리 메서드로 당첨 번호 인스턴스를 초기화 해주도록 처리했다.


🗡 Model(Parser)

지금 과제에서는 WinningNumbers에서 쉼표를 기준으로 입력 받은 당첨 번호를 파싱해도 충분하다고 생각하지만, 향후 다른 구분자를 이용해서 당첨 번호를 파싱하는 요구 사항이 생길 수도 있다는 생각을 해서 Parser로 추상화 했다.

package lotto.domain.parser;

import java.util.List;

public interface Parser {
    List<Integer> parse(String input);
}

 

위 인터페이스에 대한 구현체로 이번 과제에서 사용될 쉼표를 기준으로 문자열을 파싱하는 CommaParser 구현체를 추가했다.

package lotto.domain.parser;

import java.util.ArrayList;
import java.util.List;

public class CommaParser implements Parser {

    private static final String DELIMITER = ",";

    @Override
    public List<Integer> parse(String input) {
        List<Integer> result = new ArrayList<>();
        for (String s : input.split(DELIMITER)) {
            String trim = s.trim();
            if (trim.isEmpty()) {
                throw new IllegalArgumentException("[ERROR] 쉼표 사이에 공백은 허용되지 않습니다.");
            }

            result.add(Integer.parseInt(trim));
        }

        return result;
    }
}

🤖 Model(LottoGenerator)

로또 번호 생성기도 Parser와 마찬가지로, 지금이야 난수들을 생성하지만, 향후 요구 사항이 바뀌어서 다른 기준으로 숫자들을 생성한다든지, 아예 다른 자료로 당첨을 판단하게 될지 모르기 때문에 LottoGenerator로 추상화 했다.

package lotto.domain.generator;

import java.util.List;

public interface LottoGenerator {
    List<Integer> generate();
}

 

주어진 프로그래밍 요구 사항에서 camp.nextstep.edu.missionutils.Randoms의 pickUniqueNumbersInRange()를 사용하라고 했기 때문에 로또 번호를 생성하는 구현체에 해당 메서드로 숫자들을 생성하도록 처리했다.

package lotto.domain.generator;

import camp.nextstep.edu.missionutils.Randoms;
import java.util.List;

public class RandomLottoGenerator implements LottoGenerator {

    @Override
    public List<Integer> generate() {
        return Randoms.pickUniqueNumbersInRange(1, 45, 6);
    }
}

🎯 Service(LottoService)

지금까지 설계한 모델들을 바탕으로 로또 발매기의 핵심적인 기능을 구현하기 위해 서비스 로직을 추가했다.

package lotto.domain.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lotto.domain.generator.LottoGenerator;
import lotto.domain.model.Lotto;
import lotto.domain.model.Purchase;
import lotto.domain.model.Rank;
import lotto.domain.model.Result;
import lotto.domain.model.WinningNumbers;

public class LottoService {

    private final LottoGenerator lottoGenerator;

    public LottoService(LottoGenerator lottoGenerator) {
        this.lottoGenerator = lottoGenerator;
    }

    public List<Lotto> initLottoList(Purchase payment) {
        List<Lotto> lottoList = new ArrayList<>();
        for (int i = 0; i < payment.getPurchasedLottoCount(); i++) {
            lottoList.add(Lotto.of(lottoGenerator.generate()));
        }

        return lottoList;
    }

    public Result evaluate(List<Lotto> lottoList, WinningNumbers winningNumbers) {
        Map<Rank, Integer> counts = new HashMap<>();
        for (Lotto lotto : lottoList) {
            Rank rank = Rank.of(countMatches(lotto, winningNumbers), containsBonusNumber(lotto, winningNumbers));
            counts.put(rank, counts.getOrDefault(rank, 0) + 1);
        }

        return Result.of(counts);
    }

    private int countMatches(Lotto lotto, WinningNumbers winningNumbers) {
        int count = 0;
        for (Integer number : lotto.getLottoNumbers()) {
            if (winningNumbers.getWinningNumbers().contains(number)) {
                count++;
            }
            if (number.equals(winningNumbers.getBonusNumber())) {
                count++;
            }
        }

        return count;
    }

    private boolean containsBonusNumber(Lotto lotto, WinningNumbers winningNumbers) {
        boolean containsBonusNumber = false;
        for (Integer number : lotto.getLottoNumbers()) {
            if (number.equals(winningNumbers.getBonusNumber())) {
                containsBonusNumber = true;
            }
        }

        return containsBonusNumber;
    }
}

먼저 서비스는 로또 번호 생성기에 얽매이지 않도록 인터페이스에 의존시키고 구현체를 외부에서 주입 받도록 설계했다. 프로그램의 핵심 로직은 명확하다. “로또를 발매하는 것”, “당첨 번호를 바탕으로 당첨 여부를 판단하는 것”, 이 2가지로 압축할 수 있다.

 

🍀 구매 금액만큼의 로또를 발매: initLottoList()

public List<Lotto> initLottoList(Purchase payment) {
    List<Lotto> lottoList = new ArrayList<>();
    for (int i = 0; i < payment.getPurchasedLottoCount(); i++) {
        lottoList.add(Lotto.of(lottoGenerator.generate()));
    }

    return lottoList;
}

구매 금액(payment)를 파라미터로 전달 받아서, 그 금액에 해당하는 만큼 로또를 발행해서 리스트로 반환하도록 간단하게 처리했다.

 

🔢 일치하는 번호의 개수 세기: countMatches()

private int countMatches(Lotto lotto, WinningNumbers winningNumbers) {
    int count = 0;
    for (Integer number : lotto.getLottoNumbers()) {
        if (winningNumbers.getWinningNumbers().contains(number)) {
            count++;
        }
        if (number.equals(winningNumbers.getBonusNumber())) {
            count++;
        }
    }

    return count;
}

로또 번호와 당첨 번호를 비교해서 일치하는 숫자의 개수를 증가시키도록 했다. 여기서 보너스 번호도 일치하게 되면 count를 증가 시켜주도록 하는 것이 바람직한데, 아래 containsBonusNumber() 메서드에서 추가로 보너스 번호의 포함 유무를 판단하고 있다. 보너스 번호가 양쪽 메서드에서 처리되고 있는 것과 같기 때문에 향후 개선이 필요해 보였다.

 

⁉ 보너스 번호가 포함되어 있는지 확인: containsBonusNumber()

private boolean containsBonusNumber(Lotto lotto, WinningNumbers winningNumbers) {
    boolean containsBonusNumber = false;
    for (Integer number : lotto.getLottoNumbers()) {
        if (number.equals(winningNumbers.getBonusNumber())) {
            containsBonusNumber = true;
        }
    }

    return containsBonusNumber;
}

로또 번호를 순회하면서 만약 보너스 번호와 일치한다면 true를 반환하도록 처리했다.

 

🤔 당첨 번호를 바탕으로 당첨 여부 판단: evaluate()

public Result evaluate(List<Lotto> lottoList, WinningNumbers winningNumbers) {
    Map<Rank, Integer> counts = new HashMap<>();
    for (Lotto lotto : lottoList) {
        Rank rank = Rank.of(countMatches(lotto, winningNumbers), containsBonusNumber(lotto, winningNumbers));
        counts.put(rank, counts.getOrDefault(rank, 0) + 1);
    }

    return Result.of(counts);
}

서비스 로직 중에서도 가장 중요한 부분이다. 위에 정의해 놓은 countMatches() 메서드와 containsBonusNumber() 메서드로 반환 받은 일치하는 번호 개수와 보너스 번호 포함 여부를 Rank 클래스의 정적 팩토리 메서드의 인자로 넘긴다. 그럼 해당하는 등수와 그에 해당하는 당첨 횟수를 counts라는 Map에 저장한다. 모든 당첨 여부가 판단됐다면 최종 카운트된 countsResult의 정적 팩토리 메서드의 인자로 넘기고 반환한다.

결국 최종 카운트된 counts를 받아 Result에서 총 상금을 뽑아내고, 수익률 계산하도록 처리한 것이다.


📥 View(InputView)

입력을 처리하는 InputView다.

package lotto.view;

import camp.nextstep.edu.missionutils.Console;
import java.util.List;
import lotto.domain.parser.CommaParser;
import lotto.domain.parser.Parser;

public class InputView {

    public static final String REGEX = "^[0-9]*$";

    public String inputPayment() {
        System.out.println("구입금액을 입력해 주세요.");
        String input = Console.readLine();
        validatePaymentIsNumber(input);
        validatePaymentIsEmpty(input);

        return input;
    }

    public String inputWinningNumbers() {
        System.out.println("당첨 번호를 입력해 주세요.");
        String input = Console.readLine();
        Parser parser = new CommaParser();
        List<Integer> parsedNumber = parser.parse(input);
        validateWinningNumbersAreEmpty(input);
        for (Integer number : parsedNumber) {
            validateWinningNumbersAreNumber(String.valueOf(number));
        }

        return input;
    }

    public String inputBonusNumber() {
        System.out.println();
        System.out.println("보너스 번호를 입력해 주세요.");
        String input = Console.readLine();
        validateBonusNumberIsEmpty(input);
        validateBonusNumberIsNumber(input);

        return input;
    }

    private void validatePaymentIsEmpty(String input) {
        if (input.trim().isEmpty()) {
            throw new IllegalArgumentException("[ERROR] 구매 금액이 비어 있습니다.");
        }
    }

    private void validatePaymentIsNumber(String input) {
        if (!input.matches(REGEX)) {
            throw new NumberFormatException("[ERROR] 구입 금액은 숫자만 입력 가능합니다.");
        }
    }

    private void validateWinningNumbersAreEmpty(String input) {
        if (input.trim().isEmpty()) {
            throw new IllegalArgumentException("[ERROR] 당첨 번호가 비어 있습니다.");
        }
    }

    private void validateWinningNumbersAreNumber(String input) {
        if (!input.matches(REGEX)) {
            throw new NumberFormatException("[ERROR] 당첨 번호는 숫자만 입력 가능합니다.");
        }
    }

    private void validateBonusNumberIsEmpty(String input) {
        if (input.trim().isEmpty()) {
            throw new IllegalArgumentException("[ERROR] 보너스 번호가 비어 있습니다.");
        }
    }

    private void validateBonusNumberIsNumber(String input) {
        if (!input.matches(REGEX)) {
            throw new NumberFormatException("[ERROR] 보너스 번호는 숫자만 입력 가능합니다.");
        }
    }
}

“구매 금액”“당첨 번호”, “보너스 번호” 를 입력 받는 메서드들을 추가하고, 공백을 인식한다든지, 숫자 형식만 입력 가능하다든지, 도메인 규칙과는 크게 관련이 없는 순수 입력에 대한 예외 처리는 뷰에서 처리하도록 했다.


📤 View(OutputView)

컨트롤러로부터 모델의 데이터를 전달 받아 출력하는 OutputView다.

package lotto.view;

import java.util.List;
import lotto.domain.model.Lotto;
import lotto.domain.model.Purchase;
import lotto.domain.model.Rank;
import lotto.domain.model.Result;

public class OutputView {

    public void printPurchased(List<Lotto> lottoList, Purchase purchase) {
        System.out.println();
        System.out.printf("%d개를 구매했습니다.\n", purchase.getPurchasedLottoCount());
        for (Lotto lotto : lottoList) {
            System.out.println(lotto.getLottoNumbers());
        }
        System.out.println();
    }

    public void printResult(Result result, int payment) {
        System.out.println();
        System.out.println("당첨 통계");
        System.out.println("---");
        System.out.printf("%d개 일치 (%s원) - %d개\n", Rank.FIFTH.getMatchedNumberCount(), getFormat(Rank.FIFTH),
                result.count(Rank.FIFTH));
        System.out.printf("%d개 일치 (%s원) - %d개\n", Rank.FOURTH.getMatchedNumberCount(), getFormat(Rank.FOURTH),
                result.count(Rank.FOURTH));
        System.out.printf("%d개 일치 (%s원) - %d개\n", Rank.THIRD.getMatchedNumberCount(), getFormat(Rank.THIRD),
                result.count(Rank.THIRD));
        System.out.printf("%d개 일치, 보너스 볼 일치 (%s원) - %d개\n", Rank.SECOND.getMatchedNumberCount(), getFormat(Rank.SECOND),
                result.count(Rank.SECOND));
        System.out.printf("%d개 일치 (%s원) - %d개\n", Rank.FIRST.getMatchedNumberCount(), getFormat(Rank.FIRST),
                result.count(Rank.FIRST));
        System.out.printf("총 수익률은 %s입니다.\n", result.yieldRate(payment));
    }

    public void printError(String message) {
        System.out.println(message);
    }

    private static String getFormat(Rank rank) {
        return String.format("%,d", rank.getPrize());
    }
}

출력 뷰를 작업하면서 printResult()의 경우, 하드 코딩을 했다는 느낌이 강하게 들었다. 하지만 5개가 일치하고 “보너스 볼 일치” 라는 추가 안내가 있기도 하고, 상수로 처리하기에도 어차피 각각의 경우에 대해 다 뽑아내야 해서 의미가 없어 보였다. 이 부분에 대해서는 코드 리뷰를 받거나 스터디에서 다른 팀원들의 의견을 들어보는 등 여러 방법을 모색할 계획이다.


🔁 Controller(LottoController)

서비스를 주입 받아서 애플리케이션을 정상적인 흐름으로 실행하도록 LottoController를 추가했다.

package lotto.controller;

import java.util.List;
import lotto.domain.model.Lotto;
import lotto.domain.model.Purchase;
import lotto.domain.model.Result;
import lotto.domain.model.WinningNumbers;
import lotto.domain.parser.CommaParser;
import lotto.domain.parser.Parser;
import lotto.domain.service.LottoService;
import lotto.view.InputView;
import lotto.view.OutputView;

public class LottoController {

    private final InputView inputView;
    private final OutputView outputView;
    private final LottoService lottoService;

    public LottoController(InputView inputView, OutputView outputView, LottoService lottoService) {
        this.inputView = inputView;
        this.outputView = outputView;
        this.lottoService = lottoService;
    }

    public void run() {
        Purchase purchase = readPurchase();
        List<Lotto> lottoList = lottoService.initLottoList(purchase);
        outputView.printPurchased(lottoList, purchase);

        WinningNumbers winningNumbers = readWinningNumbers();
        Result result = lottoService.evaluate(lottoList, winningNumbers);
        outputView.printResult(result, purchase.getPayment());
    }

    private Purchase readPurchase() {
        while (true) {
            try {
                String payment = inputView.inputPayment();
                return Purchase.of(Integer.parseInt(payment.trim()));
            } catch (IllegalArgumentException e) {
                outputView.printError(e.getMessage());
            }
        }
    }

    private WinningNumbers readWinningNumbers() {
        Parser parser = new CommaParser();
        while (true) {
            try {
                String winningNumbers = inputView.inputWinningNumbers();
                String bonusNumber = inputView.inputBonusNumber();
                return WinningNumbers.of(parser.parse(winningNumbers), Integer.parseInt(bonusNumber.trim()));
            } catch (IllegalArgumentException e) {
                outputView.printError(e.getMessage());
            }
        }
    }
}

보다시피 간단한 컨트롤러지만 주어진 요구 사항 중에 오류가 발생했을 때 그 부분부터 다시 입력을 받으라는 내용이 있었다. 따라서 사용자의 입력이 필요한 구매 금액이나 당첨 번호 등은 정상적으로 입력을 받을 때까지 실행하기 위해 while 문을 이용해서 입력을 받도록 했다.


⚖ Util(LottoNumbersValidator)

로또 번호와 당첨 번호가 공통적으로 가지는 규칙들이 있었다. 번호는 정해진 개수의 숫자를 가지고 있어야 한다든지, 정해진 범위 내의 숫자를 뽑는다든지, 숫자들은 중복될 수 없다든지, 이러한 공통 규칙을 각 클래스에 중복 작성하기 보다는 아래와 같이 클래스로 뽑아내는 것이 바람직하다고 생각했다.

package lotto.domain.utils;

import static lotto.domain.utils.LottoRules.lottoSize;
import static lotto.domain.utils.LottoRules.maxNumber;
import static lotto.domain.utils.LottoRules.minNumber;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

public final class LottoNumbersValidator {

    private LottoNumbersValidator() {
    }

    public static void validateNumbers(List<Integer> numbers) {
        validateNumberCount(numbers);
        validateNumberInRange(numbers);
        validateDuplicateNumbers(numbers);
    }

    private static void validateNumberCount(List<Integer> numbers) {
        if (numbers.size() != lottoSize()) {
            throw new IllegalArgumentException(String.format("[ERROR] 로또 번호는 %d개 입니다.", lottoSize()));
        }
    }

    private static void validateNumberInRange(List<Integer> numbers) {
        for (Integer number : numbers) {
            if (number < minNumber() || number > maxNumber()) {
                throw new IllegalArgumentException(
                        String.format("[ERROR] 로또 번호는 %d부터 %d 사이의 숫자여야 합니다.", minNumber(), maxNumber()));
            }
        }
    }

    private static void validateDuplicateNumbers(List<Integer> numbers) {
        Set<Integer> set = new HashSet<>();
        for (Integer number : numbers) {
            if (!set.add(number)) {
                throw new IllegalArgumentException("[ERROR] 로또 번호는 중복될 수 없습니다.");
            }
        }
    }
}

그래서 아까 LottoWinningNumbers를 보면 알 수 있듯이, 생성자에서 LottoNumbersValidatorvalidateNumbers() 메서드를 생성자에서 실행시켜 검증했던 것이다.


📄 Util(LottoRules)

마찬가지로, 여러 파일에서 공통적으로 사용되는 규칙과 관련된 데이터들이 있었다. 그 값들을 일일이 하드 코딩 한다면, 시간도 오래 걸릴 뿐더러 의미 전달이 제대로 되지 않을 수 있기 때문에 별도의 클래스에 담아두도록 했다.

package lotto.domain.utils;

public final class LottoRules {

    private LottoRules() {
    }

    private static final int MINIMUM_NUMBER = 1;
    private static final int MAXIMUM_NUMBER = 45;
    private static final int LOTTO_SIZE = 6;
    private static final int PAYMENT_UNIT = 1000;

    public static int minNumber() {
        return MINIMUM_NUMBER;
    }

    public static int maxNumber() {
        return MAXIMUM_NUMBER;
    }

    public static int lottoSize() {
        return LOTTO_SIZE;
    }

    public static int paymentUnit() {
        return PAYMENT_UNIT;
    }
}

😅 아쉬웠던 점 & 느낀 점

이번 과제를 통해 테스트의 중요성을 인지하고, 테스트 주도 개발 방법론을 도입해서 작성하려는 노력을 많이 했다. 물론 처음부터 완벽히 작성할 수는 없으니 이 정도만 해도 뿌듯하다는 생각도 있지만, 한편으로는 온전히 혼자 힘으로 테스트 코드를 작성하다 보니, 테스트 코드의 의도는 맞을 수 있지만 과연 효율적으로 작성한 것인지 의문이었다. 테스트 작성법에 대한 학습을 하고, 다른 팀원들과 테스트 코드 작성법에 대한 다양한 의견을 공유하면 좋을 것 같다.

그리고 예상치도 못 하게 자바의 Enum을 사용하는데 큰 어려움을 겪었다. 평소 크게 생각하지 않고, 항목이 명백하게 나뉘어져 있어야 하는 데이터들을 Enum으로 작성했는데, 각 등수에 해당하는 일치하는 번호 개수, 보너스 번호가 포함되어야 하는 정보를 추가로 전달하려고 했을 때, 어떻게 해야 할지 감이 오지 않아 한동안 정체되어 있었다. 결국 Enum도 다른 클래스와 다르지 않기 때문에 상수에 여러 값을 넣으려면 당연히 그 값을 어디로 받는지를 생성자로 정의해줘야 한다는 것을 깨닫고 작업을 계속 이어갈 수 있었다. 기본적인 부분에서 뒤통수를 맞은 것 같아 많은 반성을 하게 된 것 같다. 프로그래밍 언어에 대한 기본적인 학습을 병행할 수 있도록 노력해야겠다.

해결할 수 있을 것 같은데 결국 명쾌하게 해결하지 못 한 부분은, 보너스 번호에 대한 처리 부분이었다. 분명 보너스 번호도 당첨 번호이기 때문에 번호 일치 횟수를 증가시키고, 보너스 번호가 포함되어 있다는 불리언 값을 참으로 변경해줘야 하는 작업이 동시에 필요했다. 하지만, 각 메서드는 한 가지 일만 담당하도록 설계해야 한다는 점에서 countMatches() 메서드와 containsBonusNumber() 메서드로 나눌 수 밖에 없었고, 보너스 번호의 코드가 중복된다는 느낌을 지울 수 없었다. 추가로, 요구 사항대로 에러 문구에 “[ERROR]” 가 포함되도록 예외 처리 메시지를 작성했는데, 이 또한 중복되었다는 느낌을 지울 수가 없었다. 그렇다고 아예 클래스로 빼서 “[ERROR]” 문구만 정의해서 다른 파일들에서 끌어다 사용하는 것도 비효율적이라고 생각했다. 이런 부분들에 대해 여러 팀원들과 의견을 나누고 개선할 수 있었으면 좋겠다.

 

🎉 제출 결과

0개의 댓글