3주차 – 로또

Github – 로또 프로젝트

학습 목표

  • 관련 함수를 묶어 클래스를 만들고, 객체들이 협력하여 하나의 큰 기능을 수행하도록 한다.
  • 클래스와 함수에 대한 단위 테스트를 통해 의도한 대로 정확하게 작동하는 영역을 확보한다.
  • [[우테코 7기 프리코스] 2주차 피드백](https://velog.io/@grayson1999/%EC%9A%B0%ED%85%8C%EC%BD%94-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-2%EC%A3%BC%EC%B0%A8-%ED%94%BC%EB%93%9C%EB%B0%B1)을 최대한 반영한다.

미션 설명

해당 미션은 로또 발매기를 코드로 구현하는 것이다. 사용자는 구입할 로또의 금액을 입력하고, 이에 따라 로또 번호가 발행된다. 로또 번호는 1부터 45까지의 범위에서 중복되지 않는 6개의 숫자로 구성된다. 또한, 당첨 번호와 보너스 번호를 입력받아 로또의 당첨 내역을 확인할 수 있다. 사용자가 입력한 구입 금액이 1,000원 단위로 나누어 떨어지지 않을 경우, 프로그램은 예외를 발생시켜 재입력을 요구한다.

게임이 끝난 후, 로또의 당첨 통계를 출력하며, 각 등수에 따른 당첨 내역을 확인할 수 있다. 1등부터 5등까지의 당첨 기준과 금액이 정해져 있으며, 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 수익률을 계산하여 출력한다. 만약 잘못된 값이 입력될 경우에는 IllegalArgumentException이 발생하여 애플리케이션이 종료된다.

입출력 예

구입금액을 입력해 주세요.
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%입니다.

아키텍처

├─main
│  └─java
│      └─lotto
│              Application.java
│              ErrorMessages.java
│              Lotto.java
│              LottoController.java
│              LottoPurchase.java
│              WinningLotto.java
│
└─test
    └─java
        └─lotto
                ApplicationTest.java
                LottoControllerTest.java
                LottoPurchaseTest.java
                LottoTest.java
                WinningLottoTest.java

요구 사항 분석

과제 진행 요구 사항

  1. 기능을 구현하기 전 README.md에 구현할 기능 목록을 정리한다.
  2. Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 추가한다.

프로그래밍 요구 사항

  1. JDK 21 버전을 사용한다.
  2. build.gradle 파일 변경은 불가하다.
  3. 제공된 라이브러리 이외의 외부 라이브러리 사용은 금지된다.

기능 요구 사항

  1. 로또 번호의 숫자 범위는 1~45이다.
  2. 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다.
  3. 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다.
  4. 당첨 기준과 금액은 아래와 같다:

1등: 6개 번호 일치 / 2,000,000,000원
2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
3등: 5개 번호 일치 / 1,500,000원
4등: 4개 번호 일치 / 50,000원
5등: 3개 일치 / 5,000원

시퀀스

  • 로또 구매:
    • 사용자가 구입할 금액을 입력한다.
    • 구입 금액에 대한 검증 로직이 포함된다.
  • 로또 발행:
    • 로또 1장의 가격은 1,000원이며, 입력한 금액을 1,000원으로 나눈 만큼의 로또를 발행한다.
  • 당첨 번호 추첨:
    • 중복되지 않는 6개의 당첨 번호와 1개의 보너스 번호를 입력받는다.
  • 당첨 내역 확인:
    • 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 각 등수에 따른 당첨 여부를 확인한다.
    • 당첨 통계 및 수익률을 계산하여 출력한다.

기능 목록

로또

  1. 로또 번호

6개의 숫자를 가짐.
오름차순으로 정렬됨.

  1. 정상 로또 확인

중복을 허용하지 않음.
6개의 숫자로 이루어져 있어야 함.
범위는 1~45이어야 함.
null값 또는 빈값이 입력되면 안 됨.

당첨 로또

  1. 로또 번호

6개의 숫자를 가짐.
오름차순으로 정렬됨.

  1. 보너스 번호

1개의 숫자를 가짐.

  1. 정상 로또 확인

로또 번호와 중복을 허용하지 않음.
범위는 1~45이어야 함.

로또 구입

  1. 구입 금액 입력

구입 금액을 입력 받음.
숫자가 입력되어야 함.
입력 값에 오류가 있을 시 재입력을 받음.

  1. 당첨 로또 번호 입력

6개의 로또 번호를 쉼표로 구분하여 입력 받음.
6개 입력 값 모두 숫자여야 함.
쉼표로 구분했을 때 정확히 6개여야 함.
입력 값에 오류가 있을 시 재입력을 받음.

  1. 보너스 번호 입력

이전 로또 번호와 중복될 수 없음.
1~45 범위의 숫자여야 함.
입력 값에 오류가 있을 시 재입력을 받음.

  1. 구입 금액 입력 검증

1000원 단위로 나누어져야 하며, 아닐 경우 예외 처리함.
최소 구입 금액은 1000원임.
입력 값에 오류가 있을 시 재입력을 받음.

로또 컨트롤러

  1. 로또 발행

구입한 만큼 로또를 발행함.
로또는 랜덤한 6자리 수로 추첨함.
6개의 숫자를 가짐.
중복된 숫자를 허용하지 않음.
1~45 범위의 숫자를 가짐.
발행한 로또의 수량 및 번호를 출력함.

  1. 로또 결과 확인

입력된 로또를 기준으로 모든 로또의 당첨 내역을 확인함.
당첨 통계를 출력함.
수익률을 확인함.
수익률은 소수점 둘째 자리에서 반올림함.

로또 클래스 구현

Lotto 클래스는 로또 번호를 관리하고 유효성을 검증하는 역할을 담당한다.

package lotto;

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

import static lotto.ErrorMessages.*;

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        if (numbers == null) {
            throw new IllegalArgumentException(ERROR_LOTTO

_NUMBER_NULL);
        }
        validate(numbers);
        numbers = sortNumber(numbers);
        this.numbers = numbers;
    }

    private void validate(List<Integer> numbers) {
        if (numbers.isEmpty()) {
            throw new IllegalArgumentException(ERROR_LOTTO_NUMBER_EMPTY);
        }
        if (numbers.size() != 6) {
            throw new IllegalArgumentException(ERROR_LOTTO_NUMBER_SIZE);
        }
        if (!validateRange(numbers)) {
            throw new IllegalArgumentException(ERROR_LOTTO_NUMBER_RANGE);
        }
        if (!validateDuplicate(numbers)) {
            throw new IllegalArgumentException(ERROR_LOTTO_NUMBER_DUPLICATE);
        }
    }

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

    boolean validateRange(List<Integer> numbers) {
        for (int number : numbers) {
            if (number < 1 || number > 45) {
                return false;
            }
        }
        return true;
    }

    boolean validateDuplicate(List<Integer> numbers) {
        return numbers.size() == new HashSet<>(numbers).size();
    }

    List<Integer> sortNumber(List<Integer> numbers) {
        return numbers.stream().sorted().toList();
    }

    public void printLotto() {
        System.out.println(numbers.toString());
    }
}

당첨 로또 클래스

WinningLotto 클래스는 기본 Lotto 클래스를 상속받아 당첨 번호와 보너스 번호를 관리하는 역할을 한다.

package lotto;

import java.util.List;

import static lotto.ErrorMessages.ERROR_BONUS_NUMBER_DUPLICATE;
import static lotto.ErrorMessages.ERROR_LOTTO_NUMBER_RANGE;

public class WinningLotto extends Lotto {
    private final int bonusNumber;

    public WinningLotto(List<Integer> numbers, int bonusNumber) {
        super(numbers);
        validateBonusNumber(numbers, bonusNumber);
        this.bonusNumber = bonusNumber;
    }

    private void validateBonusNumber(List<Integer> numbers, int bonusNumber) {
        if (numbers.contains(bonusNumber)) {
            throw new IllegalArgumentException(ERROR_BONUS_NUMBER_DUPLICATE);
        }
        if (bonusNumber < 1 || bonusNumber > 45) {
            throw new IllegalArgumentException(ERROR_LOTTO_NUMBER_RANGE);
        }
    }

    public int getBonusNumber() {
        return bonusNumber;
    }
}

로또 구입 클래스

package lotto;

import camp.nextstep.edu.missionutils.Console;

import java.util.Arrays;
import java.util.List;

import static lotto.ErrorMessages.*;

public class LottoPurchase {
    private int purchaseAmount;
    private int purchaseCount;
    private WinningLotto winningLotto;

    public void inputAmount() {
        System.out.println("구입금액을 입력해주세요");
        String userInputAmount = Console.readLine();
        inputAmount(userInputAmount);
    }

    public void inputAmount(String userInputAmount) {
        if (!userInputAmount.matches("\\d+")) {
            throw new IllegalArgumentException(ERROR_NOT_NUMBER);
        }
        int inputAmount = Integer.parseInt(userInputAmount);

        validateInputAmount(inputAmount);
        purchaseAmount = inputAmount;
        purchaseCount = inputAmount / 1000;
    }

    private void validateInputAmount(int inputAmount) {
        if (inputAmount % 1000 != 0) {
            throw new IllegalArgumentException(ERROR_NOT_1000_MULTIPLE);
        }
        if (inputAmount < 1000) {
            throw new IllegalArgumentException(ERROR_MIN_PURCHASE_AMOUNT);
        }
    }

    public void inputLottoNumber() {
        System.out.println("당첨 번호를 입력해 주세요.");
        String userInputLottoNumber = Console.readLine();
        System.out.println("보너스 번호를 입력해 주세요.");
        String userInputBonusNumber = Console.readLine();
        inputLottoNumber(userInputLottoNumber, userInputBonusNumber);
    }

    public void inputLottoNumber(String userInputLottoNumber, String userInputBonusNumber) {
        if (!userInputLottoNumber.matches("^\\d+(?:,\\d+)*quot;)) {
            throw new IllegalArgumentException(ERROR_INVALID_LOTTO_FORMAT);
        }
        if (!userInputBonusNumber.matches("\\d+")) {
            throw new IllegalArgumentException(ERROR_INVALID_BONUS_NUMBER);
        }
        winningLotto = new WinningLotto(splitLottoNumber(userInputLottoNumber), Integer.parseInt(userInputBonusNumber));
    }

    private List<Integer> splitLottoNumber(String userInputLottoNumber) {
        return Arrays.stream(userInputLottoNumber.split(",")).map(Integer::parseInt).toList();
    }

    public int getPurchaseAmount() {
        return purchaseAmount;
    }

    public WinningLotto getWinningLotto() {
        return winningLotto;
    }

    public int getPurchaseCount() {
        return purchaseCount;
    }
}

로또 컨트롤러

LottoController 클래스는 로또 게임의 주요 로직을 처리하는 클래스이다.

package lotto;

import camp.nextstep.edu.missionutils.Randoms;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;

import static java.lang.Math.round;

public class LottoController {
    private List<Lotto> lottos;
    private int purchaseCount;
    private WinningLotto winningLotto;
    private LottoPurchase lottoPurchase;
    private Map<Integer, Integer> winningList;
    private double winningRate;

    public LottoController() {
        lottos = new ArrayList<>();
        lottoPurchase = new LottoPurchase();
        winningList = new HashMap<>();
        initialWinningList();
    }

    public LottoController(String Test) {
        lottos = new ArrayList<>();
        initialWinningList();
    }

    public void getStart() {
        while (true) {
            try {
                lottoPurchase.inputAmount();
                issueLotto(lottoPurchase.getPurchaseCount());
                printIssuedLottos();
                break;
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }

        while (true) {
            try {
                lottoPurchase.inputLottoNumber();
                updateWinningList(lottoPurchase.getWinningLotto());
                printWinningReport();
                break;
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }

        calculateWinningRate();
        printWinningRage();
    }

    public void issueLotto(int purchaseCount) {
        this.purchaseCount = purchaseCount;

        for (int i = 0; i < purchaseCount; i++) {
            List<Integer> numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
            lottos.add(new Lotto(numbers));
        }
    }

    public void printIssuedLottos() {
        System.out.printf("%d개를 구매했습니다.\n", purchaseCount);
        for (Lotto lotto : lottos) {
            lotto.printLotto();
        }
    }

    private void initialWinningList() {
        for (int i = 0; i < 6; i++) {
            winningList.put(i, 0);
        }
    }

    public void updateWinningList(WinningLotto winningLotto) {
        this.winningLotto = winningLotto;
        for (Lotto lotto : lottos) {
            int lottoRank = getRanking(lotto);
            winningList.put(lottoRank, winningList.getOrDefault(lottoRank, 0) + 1);
        }
    }

    public int getMatchCount(Lotto lotto) {
        int matchCount = 0;
        for (Integer number : lotto.getNumbers()) {
            if (winningLotto.getNumbers().contains(number)) {
                matchCount++;
            }
        }
        return matchCount;
    }

    public int getRanking(Lotto lotto) {
        int matchCount = getMatchCount(lotto);
        return findRanking(lotto, matchCount);
    }

    public int findRanking(Lotto lotto, int matchCount) {
        if (matchCount == 6) {
            return 1; // 1등
        } else if (matchCount == 5) {
            if (lotto.getNumbers().contains(winningLotto.getBonusNumber())) {
                return 2; // 2등
            }
            return 3; // 3등
        } else if (matchCount == 4) {
            return 4; // 4등
        } else if (matchCount == 3) {
            return 5; // 5등
        }
        return 0; // 미당첨
    }

    public void printWinningReport() {
        System.out.println("당첨 통계");
        System.out.println("---");
        List<Integer> winningListKeys = winningList.keySet().stream().sorted(Comparator.reverseOrder()).toList();
        for (int key : winningListKeys) {
            if (key == 5) {
                System.out.printf("3개 일치 (5,000원) - %d개\n", winningList.get(key));
            } else if (key == 4) {
                System.out.printf("4개 일치 (50,000원) - %d개\n", winningList.get(key));
            } else if (key == 3) {
                System.out.printf("5개 일치 (1,500,000원) - %d개\n", winningList.get(key));
            } else if (key == 2) {
                System.out.printf("5개 일치, 보너스 볼 일치 (30,000,000원) - %d개\n", winningList.get(key));
            } else if (key == 1) {
                System.out.printf("6개 일치 (

2,000,000,000원) - %d개\n", winningList.get(key));
            }
        }
    }

    public double calculateWinningRate() {
        double totalWinningPrice = 0;

        for (int key : winningList.keySet()) {
            if (key == 1) {
                totalWinningPrice += 2000000000 * winningList.get(key);
            } else if (key == 2) {
                totalWinningPrice += 30000000 * winningList.get(key);
            } else if (key == 3) {
                totalWinningPrice += 1500000 * winningList.get(key);
            } else if (key == 4) {
                totalWinningPrice += 50000 * winningList.get(key);
            } else if (key == 5) {
                totalWinningPrice += 5000 * winningList.get(key);
            }
        }

        int totalPurchaseAmount = purchaseCount * 1000;

        if (totalPurchaseAmount == 0) {
            return 0;
        }

        double rate = Math.round(totalWinningPrice / totalPurchaseAmount * 1000) / 10.0;

        this.winningRate = rate;
        return this.winningRate;
    }

    public void printWinningRage() {
        System.out.println("총 수익률은 " + String.format("%.1f", winningRate) + "%입니다."); // 소수점 한 자리까지 포맷팅
    }

    public List<Lotto> getLottos() {
        return lottos;
    }

    public Map<Integer, Integer> getWinningList() {
        return winningList;
    }
}

회고

이번 프로젝트는 졸업 작품 전과 발표 준비가 겹쳐 시간이 많이 부족했다. 구현할 시간이 하루밖에 없었고, 학습 목표에 따라 작은 단위에 대한 테스트를 먼저 작성한 후 개발하는 방법을 사용했다. 이 과정에서 작은 단위에 대한 테스트를 우선 작성하여 의도한 대로 정확하게 작동하는 영역을 확보하는 것이 중요하다는 점과 기능 목록을 먼저 작성하는 것이 왜 필요한지를 깨달았다.

프로젝트 초기에 사용자가 로또를 구매한 금액에 따라 입력을 받고, 당첨 번호를 랜덤 함수에서 추출하여 결과를 비교하는 방식으로 이해했다. 그러나 이는 잘못된 이해였다. 실제로는 사용자가 입력한 금액에 맞춰 로또를 랜덤으로 발행하고, 사용자가 당첨 번호를 입력해야 했다.

이런 혼란 속에서 몇 분간 뇌정지가 왔다. 그 후, 내가 구현한 클래스를 점검하면서 로또 클래스와 보너스 번호를 포함한 발행 로또 클래스를 구현했음을 확인하고, 로또 구입 클래스를 추가했다. 만약 클래스를 분리하지 않았다면 처음부터 다시 작성해야 했을 것이다. 그러나 클래스를 분리해두었기 때문에 한눈에 파악할 수 있었고, 클래스와 메서드 이름을 변경하는 정도로 실수를 해결할 수 있었다. 이번 경험을 통해 클래스화를 해야 하는 이유와 메서드를 작은 단위로 설정해야 하는 이유, 그리고 기능 목록을 먼저 작성해야 하는 이유를 알게 되었다.

기존에 작성한 테스트에서 변경된 기능에 대한 테스트를 수정한 후, 테스트가 문제 없이 통과하는 것을 바로 확인할 수 있었다. 의도하지 않았지만, 오늘의 주제를 한 번에 파악할 수 있는 시간이 되었다.

위로 스크롤