[우아한테크 도전기] 이전 기수 프리코스 3주차_로또 연습

Dev_ch·2023년 9월 9일
0

우아한테크 도전기

목록 보기
33/51
post-thumbnail

이번 포스팅은 이전기수 프리코스 3주차 문제였던 로또를 구현하는 과정을 작성하고 회고해보는 시간을 가져보도록 하자!

🚀 문제

추가적으로 입출력 요구사항, 프로그래밍, 과제 진행 요구사항이 있다.

📝 풀이 및 회고

저번 포스팅과 다르게 이번 포스팅에서는 MVC 패턴을 기준으로 기능 명세서를 작성하였다. 다만, 초반 설계에서 벗어나는 부분이 생기면 새로운 목표를 정의하고 다시 적어나갔다. 처음 설계에서 벗어나는 일이 있어도 큰 틀에서는 벗어나지 않도록 명세서를 작성했다.

MVC 기준으로 명세서를 작성하니 확실히 저번 포스팅보다 정리가 더욱 잘되고 틀이 잡힌다는 느낌을 받았다. 약간 고민은 어디까지 틀을 잡고 어디까지 디테일을 잡는가인 것 같다.

사실 초반 아키텍쳐는 Model, View, Controller 였지만 역시 Model에서 수행되는 비즈니스 로직이 단일 책임을 지니지 않는 것 같아 Service 계층까지 확대하였다.

📂 패키지 구조

MVC 패턴을 기반으로 Model,Service,Controller,View로 구분하였다. App의 경우 해당 애플리케이션이 돌아갈 main 함수가 있는 곳 이다.

Model

public class Lotto {

    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        validate(numbers);
        this.numbers = numbers;
    }

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

    public static List<Lotto> createLottoList(Integer price) {
        price /= 1000;

        List<Lotto> LottoList = new ArrayList<>();
        while (price --> 0) {
            LottoList.add(createLotto());
        }

        return LottoList;
    }

    public static Lotto createLotto() {
        List<Integer> settingNumbers = new ArrayList<>();
        for (int i = LOTTO_MIN; i <= LOTTO_MAX; i++) {
            settingNumbers.add(i);
        }
        Collections.shuffle(settingNumbers);

        List<Integer> randNumbers = settingNumbers.subList(0, LOTTO_COUNT);
        randNumbers.sort(Comparator.naturalOrder());

        return new Lotto(randNumbers);
    }

    private void validate(List<Integer> numbers) {
        if (numbers.size() != 6) {
            throw new IllegalArgumentException();
        }
    }
}

Lotto에서 사용되는 상수들은 LottoConstants 라는 상수 클래스로 따로 관리해주었다. Lotto 클래스는 오로지 Lotto 객체를 정의하고 생성하는 책임만을 갖게하다보니 Service 계층을 하나 더 만들어주게 되었다. 특히 이번 미션은 Lotto 클래스가 주어지고 해당 클래스에 살을 붙이는 형식으로 구현하는 것이 요구사항 중 하나였다.

원래는 제공되는 라이브러리를 사용해야하지만 해당 포스팅은 자바 라이브러리 내의 메서드를 이용해 랜덤한 번호를 생성하였다.

View

public class LottoView {
    private final Scanner sc;

    public LottoView(InputStream in) {
        this.sc = new Scanner(in);
    }

    // View

    // 로또 구입금액 입력
    public Integer inputPurchasePrice() {
        System.out.println("구입금액을 입력해 주세요.");
        int price = Integer.parseInt(sc.nextLine());
        validateInputPurchasePrice(price);
        return price;
    }

    // 로또 구매 결과 출력
    public void purchaseResult(List<Lotto> lottoList) {
        System.out.printf("%s개를 구매했습니다.\n", lottoList.size());
        for (Lotto lotto : lottoList) {
            System.out.println(lotto.getNumbers());
        }
    }

    // 로또 당첨 번호 입력
    public Set<Integer> inputWinningNumbers() {
        System.out.println("당첨 번호를 입력해 주세요.");

        String numbers = sc.nextLine();
        Set<Integer> numberList = getIntegerNumbers(numbers);

        validateInputWinningNumbers(numberList);
        return numberList;
    }

    // 로또 보너스 번호 입력
    public Integer inputBonusNumber() {
        System.out.println("보너스 번호를 입력해 주세요.");
        int inputNumber = Integer.parseInt(sc.nextLine());
        checkLottoNumberRange(inputNumber);
        return inputNumber;
    }

    // 로또 결과 출력
    public void lottoResult(Map<Integer, Integer> result, Double rate) {
        NumberFormat nf = createNumberFormat();

        System.out.println("당첨 통계");
        System.out.println("---");
        System.out.printf("3개 일치 (5,000원) - %s개\n", result.getOrDefault(3, 0));
        System.out.printf("4개 일치 (50,000원) - %s개\n", result.getOrDefault(4, 0));
        System.out.printf("5개 일치 (1,500,000원) - %s개\n", result.getOrDefault(5, 0));
        System.out.printf("5개 일치, 보너스 볼 일치 (30,000,000원) - %s개\n", result.getOrDefault(0, 0));
        System.out.printf("6개 일치 (2,000,000,000원) - %s개\n", result.getOrDefault(6, 0));
        System.out.println("총 수익률은 " + nf.format(rate) + "%입니다.");
    }

    private static NumberFormat createNumberFormat() {
        NumberFormat nf = NumberFormat.getInstance();
        nf.setMinimumFractionDigits(1);
        nf.setMaximumFractionDigits(1);
        return nf;
    }

    public Set<Integer> getIntegerNumbers(String numbers) {
        return Arrays.stream(numbers.split(","))
                .map(Integer::valueOf)
                .collect(Collectors.toSet());
    }

    // Validate

    public void validateInputWinningNumbers(Set<Integer> inputNumbers) {
        if (inputNumbers.size() != 6) {
            throw new IllegalArgumentException("[ERROR] 당첨 번호를 총 6개 입력해야 합니다.");
        }

        for (Integer inputNumber : inputNumbers) {
            checkLottoNumberRange(inputNumber);
        }
    }

    public void checkLottoNumberRange(Integer inputNumber) {
        if (inputNumber < 1 || inputNumber > 45) {
            throw new IllegalArgumentException("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.");
        }
    }

    public void validateInputPurchasePrice(int price) {
        if (price % 1000 != 0) {
            throw new IllegalArgumentException("[ERROR] 구매 금액은 1000 단위로만 가능합니다.");
        }
    }
}

View는 이전포스팅과 마찬가지로 클라이언트간의 입/출력을 담당하게 구성해주었다. 입/출력을 제외한 기능이 들어가는 것을 최소화하였고 입력을 가공하거나, 출력을 가공하는 간단한 기능정도만 추가해주었다. 메서드 중 getIntegerNumbers()의 경우 String으로 입력받은 로또 번호를 stream을 통해 set으로 변경해주었다.

마찬가지로 수익률 출력시 NumberFormat을 이용해 요구사항과 같은 출력을 맞춰주었다. 또한 입력을 담당하는 클래스인 만큼 validate 코드도 작성해주었다.

Service

public class LottoService {

    public static final int MATCH5_BONUS = 0;

    // 로또 결과 계산
    public Map<Integer, Integer> verifyLottoResult(
            List<Lotto> lottoList, Set<Integer> winningNumbers, Integer bonusNumber
    ) {
        Map<Integer, Integer> resultMap = new HashMap<>();

        for (Lotto lotto : lottoList) {
            int count = checkHitCount(winningNumbers, lotto);
            updateResultMapWithHitCount(bonusNumber, resultMap, lotto, count);
        }

        return resultMap;
    }

    private static void updateResultMapWithHitCount(Integer bonusNumber, Map<Integer, Integer> resultMap, Lotto lotto, int count) {
        if (count == 5 && lotto.getNumbers().contains(bonusNumber)) {
            resultMap.put(MATCH5_BONUS, resultMap.getOrDefault(MATCH5_BONUS, 0) + 1);
        } else if (count >= 3) {
            resultMap.put(count, resultMap.getOrDefault(count, 0) + 1);
        }
    }

    private static int checkHitCount(Set<Integer> winningNumbers, Lotto lotto) {
        return (int) lotto.getNumbers().stream()
                .filter(winningNumbers::contains)
                .count();
    }

    // 로또 수익률 계산
    public Double calculateProfitRate(Map<Integer, Integer> result, Integer buy) {
        int sum = 0;

        for (Integer key : result.keySet()) {
            sum += LottoMatchUtils.calculatePrize(key) * result.get(key);
        }

        double profitRate = ((double) (sum - buy) / buy) * 100;

        // 소수점 둘째 자리에서 반올림
        profitRate = Math.round(profitRate * 10) / 10.0;

        return 100 + profitRate;
    }
}

원래 Model에 수행하던 비즈니스 코드를 Service 계층으로 리팩터링해주었다. 각각 로또 결과를 계산하고 로또 수익률을 계산하는 메서드인데 verifyLottoResult()의 경우 단일 책임을 유지하기 위해 메서드 안에서 수행하는 다른 로직들을 메서드 단위로 한번 더 분리해주었다.

checkHitCount()를 통해 자신이 맞춘 숫자 개수를 확인하고 updateResultMapWithHitCount() 를 통해 자신이 맞춘 개수에 비례하여 로또에 당첨될 때 마다 등수와 당첨횟수를 Map으로 등록시킨다.

로또 수익률 계산의 경우

이렇게 계산을 해주었는데, 요구사항에서의 출력이 음수로 나타내는 것을 제한하여 100을 더해 자신의 총 수익률을 계산하였다.

Controller

public class LottoController {
    private final LottoService lottoService;
    private final LottoView lottoView;

    public LottoController(LottoService lottoService, LottoView lottoView) {
        this.lottoService = lottoService;
        this.lottoView = lottoView;
    }

    public void run() {
        // 구매금액 입력
        Integer price = lottoView.inputPurchasePrice();
        System.out.println();

        // 구매한 개수 및 로또번호 리스트 출력
        List<Lotto> lottoList = Lotto.createLottoList(price);
        lottoView.purchaseResult(lottoList);
        System.out.println();

        // 로또 당첨 번호 입력
        Set<Integer> winningNumbers = lottoView.inputWinningNumbers();
        System.out.println();

        // 로또 보너스 번호 입력
        Integer bonusNumber = lottoView.inputBonusNumber();
        System.out.println();

        // 로또 당첨 확인 및 수익률 계산
        Map<Integer, Integer> result = lottoService.verifyLottoResult(lottoList, winningNumbers, bonusNumber);
        Double rate = lottoService.calculateProfitRate(result, price);

        // 로또 당첨 통계 출력
        lottoView.lottoResult(result, rate);
    }
}

Controller는 Service와 View을 의존성주입을 통해 구현하였다. run() 메서드를 통해 로또를 시작하여 각각의 로직들을 수행한다. 각 View와 Service가 무엇을 하고 있는지 판단하기 위해 주석을 이용하여 어떤 책임을 지고 어떤 기능을 수행하는지 명시해주었다.


아쉬운점

초반 기능 명세서를 작성했을때와 다르게 디테일이 많이 붙은 것 같았다. 초반 설계를 더 세부화되게 설계하면 좋지 않을까 싶었다. MVC를 기준으로 조금 큰 틀을 잡고 기능을 설계하다보니 해당 기능을 만들때 이것도 있어야 겠는데? 라는 생각을 많이 했던 것 같다.

내가 작성한 코드가 성능은 어떠할지도 많이 궁금하다. 이런 부분은 역시 코드리뷰를 받으면서 개선점을 찾아가는 것이 정말 좋다고 생각한다. 테스트 코드를 작성할때도 어디까지 검증해야할까 고민을 많이 했던 것 같다. 로또는 랜덤한 로직이다 보니 어떻게 테스트를 작성할지도 많이 고민했다.

확실한 건, 숫자야구보다 기능적으로 신경 쓸 부분이 더 많았고 이전 포스팅에 비해 조금 더 설계하고 테스트하고 구현하는 실력이 비약적으로나마 향상된 것 같다 라는 생각을 했다 🥹

profile
내가 몰입하는 과정을 담은 곳

0개의 댓글