Recording/우아한테크코스 5기 Pre-course

[우테코 5기] <3주차> 'java-lotto' 회고

LEFT 2022. 12. 14. 14:54

<우아한 테크코스 5기 프리코스 진행사항 - 3주차>

3주차 과제도 2주차 과제와 마찬가지로 하나의 주제를 가지고 기능별로 구현을 하는 알고리즘 문제였다.

요구사항은 크게 3가지로 나뉘어져있었다. 

1) 기능 요구 사항

2) 프로그래밍 요구 사항

3) 과제 진행 요구 사항

<백엔드 - Java> 기준으로 과제를 수행하였다.


<로또>

  • 기능 요구 사항

- 로또 게임 기능을 구현해야 한다. 로또 게임은 아래와 같은 규칙으로 진행된다.

- 로또 번호의 숫자 범위는 1~45까지이다.
- 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다.
- 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다.
- 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다.
    - 1등: 6개 번호 일치 / 2,000,000,000원
    - 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
    - 3등: 5개 번호 일치 / 1,500,000원
    - 4등: 4개 번호 일치 / 50,000원
    - 5등: 3개 번호 일치 / 5,000원

  • 입출력 요구 사항

  • 프로그래밍 요구 사항

  • 구현과정

요구사항을 살펴보니 "indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현 (2까지만 허용)" 이 있었다.

함수를 분리하여 구현하는 것이 중요해보였고,

또 다른 요구사항인 "함수가 한가지 일만 하도록 최대한 작게 만들기" 또한 함수 분리 구현이 필수적인 것 같았다.

2주차와 마찬가지로 JUnit 5와 AssertJ 를 이용해 각 기능별로 단위 테스트를 진행해야했다.

또 어려웠던 점은 함수의 길이가 15라인이상 넘어가지 않도록 구현해야했는데

클래스의 분리를 더 세분화시켜야할 것 같았다.

요구사항에서 제공된 lotto 클래스의 기본구조는 2주차에서의 Init_game 클래스와 비슷해보였다. 

IllegalArgumentException을 발생시키는 부분이나, number.size가 넘치지 않도록 조건문을 달아주는 것 또한

숫자 야구 게임과 유사한 부분이 있었다. 

이번 프로젝트에서는 클래스를 더 세분화해야하기때문에 lotto 패키지 안에

다른 세부 패키지로 나누어서 관리해야할 것 같았다.

lotto 패키지 안에 기능을 구현할 domain과 예외처리를 할 exception, 화면에 보여지는 view 패키지로 나눌 수 있었다.

Application과 Gameplay는 lotto 패키지 내에서 다른 클래스들을 참조하여 실행할 수 있도록 설계하였다.


<Application.java>

package lotto;

public class Application {
    public static void main(String[] args) {
        // TODO: 프로그램 구현
        final Gameplay gameplay = Gameplay.create();
        gameplay.run();
    }
}

- Application.java에서 Gameplay를 진행하고 gameplay 클래스 안에서 게임이 실제 시작되는 로직을 구현하였다.

<Gameplay.java>

package lotto;

import lotto.domain.lotto.LottoFactory;
import lotto.domain.lotto.LottoGroup;
import lotto.domain.lotto.LottoMoney;
import lotto.domain.lotto.WinningLotto;
import lotto.domain.result.Result;
import lotto.domain.result.TicketCount;
import lotto.view.InputView;
import lotto.view.ResultView;

public class Gameplay {
    private Gameplay(){

    }

    public static Gameplay create() {
        return new Gameplay();
    }

    private static float getProfit(final float nowMoney, final float pastMoney) {
        return nowMoney / pastMoney;
    }

    public void run() {
        final LottoMoney lottoMoney = makeLottoMoney();
        final TicketCount count = makeLottoTicketCount(lottoMoney);
        final LottoGroup lottoTickets = makeLottos(count);
        ResultView.printLottoTickets(count, lottoTickets);

        final WinningLotto winningLotto = makeWinNums();

        final Result result = Result.of(lottoTickets, winningLotto);
        end(result, lottoMoney);
    }

    private LottoMoney makeLottoMoney() {
        return LottoMoney.from(
                InputView.inputMoney());
    }

    private TicketCount makeLottoTicketCount(final LottoMoney money) {
        return TicketCount.of(money.toLottoCount(),
                InputView.inputManualTicketCount());
    }

    private LottoGroup makeLottos(final TicketCount count) {
        return LottoFactory.createLottos(count,
                InputView.inputManualNums(count.ofManual()));
    }

    private WinningLotto makeWinNums() {
        return LottoFactory.createWinNums(InputView.inputWinLottoNums(), InputView.inputBonusNumber());
    }

    private void end(final Result result, final LottoMoney lottoMoney) {
        ResultView.printLottosResult(result);
        ResultView.printProfit(getProfit((float) result.getPrize(), (float) lottoMoney.get()));
    }
}

생성자를 만들고, create()를 통해 Gameplay를 재호출하도록 기본 골격을 만든다.

run()부분이 중요했는데, 로또금액인 lottoMoney와 TicketCount, LottoGroup 등을 참조하였다.

로또에 당첨되는 부분 또한 result를 통해 구현하였다.

makeLottoMoney()에서는 View 패키지의 InputView에서 참조할 수 있었다.

run부분에서는 전체적으로 생성과 게임을 진행시키는 Initialize 부분을 만들어냈다.


<domain> - <generator>

<NumsGenerator.java>

package lotto.domain.generator;

import lotto.domain.lotto.LottoNumber;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class NumsGenerator {
    private static final int START_INDEX = 0;
    private static final int END_INDEX = 6;

    public static List<LottoNumber> RandomNumber(){
        final List<LottoNumber> lottoNumbers = new ArrayList<>(LottoNumber.getCache().values());

        Collections.shuffle(lottoNumbers);
        return lottoNumbers.subList(START_INDEX, END_INDEX);
    }

    public static List<LottoNumber> generate(final List<Integer> lottoNums){
        return lottoNums.stream()
                .map(LottoNumber::getInstance)
                .sorted()
                .collect(Collectors.toList());
    }
}

- 시작 인덱스와 끝 인덱스를 final로 설정하고, static final 처럼 변하지 않는 전역변수를 설정할때는

대문자로 변수를 만드는 것이 좋다는 것을 새롭게 배울 수 있었다. (우테코 프리코스 깃허브 커뮤니티)

RandomNumber메서드로 로또 번호를 생성하고 Collections 클래스의 shuffle 메서드로 이 번호들을 무작위로 섞는다.

generator 메서드에서는 stream()을 이용해 기능을 세부적으로 접근하여 리스트로 만든다. 


<domain> - <lotto>

<Lotto.java>

package lotto.domain.lotto;

import lotto.exception.lotto.LottoNumDuplicatedException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

public class Lotto {
    private static final int SIZE = 6;
    final List<LottoNumber> numbers;

    public Lotto(List<LottoNumber> numbers) {
        validate(numbers);
        Collections.sort(numbers);
        this.numbers = List.copyOf(numbers);
    }

    private static void validate(List<LottoNumber> numbers) {
        Set<LottoNumber> setNumbers = new HashSet<>(numbers);

        if (numbers.size() != SIZE) {
            throw new IllegalArgumentException();
        }

        if (setNumbers.size() != numbers.size()){
            throw new LottoNumDuplicatedException(numbers);
        }
    }

    // TODO: 추가 기능 구현
    public static Lotto from(final List<LottoNumber> numbers){
        return new Lotto(numbers);
    }

    public int countSameNum(final WinningLotto winningLotto){
        return (int) numbers.stream()
                .filter(winningLotto::contains)
                .count();
    }
    public boolean contains(final LottoNumber lottoNumber) {
        return numbers.contains(lottoNumber);
    }
    public List<LottoNumber> get() {
        return List.copyOf(numbers);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Lotto lotto1 = (Lotto) o;
        return Objects.equals(numbers, lotto1.numbers);
    }

    @Override
    public int hashCode() {
        return Objects.hash(numbers);
    }

    @Override
    public String toString() {
        return "Lotto{" +
                "lotto=" + numbers +
                '}';
    }
}

- 로또의 속성을 관리하는 부분이며 6개의 번호를 받는 부분과 정렬하는 부분을 구현하였다.

로또 클래스에서는 6개의 번호 규격을 넘어갈 경우 IllegalArgumentException을 발생시키도록 하였다.

setNumbers를 HashSet으로 만들어 해시의 중복제거 특징을 활용하여 LottoNumDuplicatedException 예외처리 시

중복을 검사할 수 있도록 하였다.

countSameNum메서드로 winningLotto (맞춘개수)를 증가시킨다.

<lotto> - <lotto> - <LottoFactory.java> 에서는

보너스를 처리하고, 자동적으로 로또번호를 생성하도록 하였다.

<lotto> - <lotto> - <LottoGroup.java> 에서는

NullException을 발생시키는 부분, Lotto.java와 같이 equals를 처리하는 부분을 작성하였다.

<lotto> - <lotto> - <LottoMoney.java> 에서는

LottoMoneyLessException : 금액 예외처리와

LottoMoneyDivideException : Lotto 번호 나누는 예외처리를 구현하였다. 

<lotto> - <lotto> - <LottoNumber.java> 에서는

로또 번호의 범위를 지정하였다.

private static final int MIN = 1;
private static final int MAX = 45;

compareTo 를 통해 각 클래스에서 지정된 object와 기준 값을 비교할 수 있도록 하였다.

@Override
public int compareTo(LottoNumber o) {
    return java.lang.Integer.compare(value, o.get());
}

<lotto> - <lotto>

<WinningLotto.java>

package lotto.domain.lotto;

import lotto.exception.lotto.BonusNumDuplicatedException;

public class WinningLotto {
    private final Lotto lotto;
    private final LottoNumber bonusNumber;

    private WinningLotto(final Lotto lotto, final LottoNumber bonusNumber) {
        validate(lotto, bonusNumber);
        this.lotto = lotto;
        this.bonusNumber = bonusNumber;
    }

    public static WinningLotto of(final Lotto lotto, final LottoNumber bonusNumber) {
        return new WinningLotto(lotto, bonusNumber);
    }

    private static void validate(final Lotto lotto, final LottoNumber number) {
        if (lotto.contains(number)) {
            throw new BonusNumDuplicatedException(number.get());
        }
    }

    public boolean contains(final LottoNumber number) {
        return lotto.contains(number);
    }

    public LottoNumber getBonusNumber() {
        return bonusNumber;
    }
}

- 보너스 숫자를 관리하는 부분과 번호를 맞췄을때 다른 클래스를 참조할 수 있도록 다리 기능의 코드를 구현하였다.


<lotto> - <result>

<Rank.java>

package lotto.domain.result;

import java.util.Arrays;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.stream.Collectors;

public enum Rank {
    NONE(0, 0,
            (matchCount, isBonus) -> matchCount < 3),
    FIFTH(5000, 3,
            (matchCount, isBonus) -> matchCount == 3),
    FOURTH(50000, 4,
            (matchCount, isBonus) -> matchCount == 4),
    THIRD(1500000, 5,
            (matchCount, isBonus) -> matchCount == 5 && !isBonus),
    SECOND(30000000, 5,
            (matchCount, isBonus) -> matchCount == 5 && isBonus),
    FIRST(2000000000, 6,
            (matchCount, isBonus) -> matchCount == 6);

    private final int prize;
    private final int matchCount;
    private final BiPredicate<Integer, Boolean> condition;

    Rank(final int prize, final int matchCount, BiPredicate<Integer, Boolean> condition) {
        this.prize = prize;
        this.matchCount = matchCount;
        this.condition = condition;
    }

    public static Rank of(final int matchCount, final boolean isBonus) {
        return Arrays.stream(Rank.values())
                .filter(rank -> rank.condition.test(matchCount, isBonus))
                .findAny()
                .orElse(NONE);
    }

    public static List<Rank> getWithoutDefault() {
        return Arrays.stream(Rank.values())
                .filter(rank -> !rank.equals(NONE))
                .collect(Collectors.toList());
    }

    public int getPrize() {
        return prize;
    }

    public int getMatchCount() {
        return matchCount;
    }
}

- 아무것도 못맞췄을때와 어느정도 맞추었을때의 상금의 크기를 지정하였다. 

5위부터 금액이 매겨져 1위에서는 20억을 prize로 지정하였다.

<lotto> - <result>

<Result.java>

package lotto.domain.result;

import lotto.domain.lotto.Lotto;
import lotto.domain.lotto.LottoGroup;
import lotto.domain.lotto.WinningLotto;
import java.util.LinkedHashMap;

public class Result {
    private final LinkedHashMap<Rank, Integer> value = new LinkedHashMap<>();

    private Result(final LottoGroup lottos, final WinningLotto winningLotto) {
        for (Lotto lotto : lottos.get()) {
            Rank rank = Rank.of(lotto.countSameNum(winningLotto), lotto.contains(winningLotto.getBonusNumber()));
            add(rank);
        }
    }

    public static Result of(final LottoGroup lottos, final WinningLotto winningLotto) {
        return new Result(lottos, winningLotto);
    }

    private void add(final Rank rank) {
        value.merge(rank, 1, Integer::sum);
    }

    public long getPrize() {
        long prize = 0;
        for (Rank rank : value.keySet()) {
            prize += (long) rank.getPrize() * value.get(rank);
        }
        return prize;
    }

    public int getRankCount(final Rank rank) {
        return value.getOrDefault(rank, 0);
    }
}

Result에서는 전체적인 결과를 만들어내기 위해 rank를 merge하는 부분과

getPrize()로 상금을 계산하는 부분을 구현하였다.

<lotto> - <result> - <TicektCount.java> 에서는

로또를 몇장 사는지 받아와서 자동적으로 로또번호를 생성하는 다리 역할을 하도록 구현하였다.


<lotto> - <exception> 에서 위에 각 구현하였던 클래스 별로 발생할 수 있는 예외들을 작성해서 처리하도록 하였다.

크게 중복, 잘못된 패턴, 규격에 맞지않는 사이즈 관련 예외처리 등이 있으며, 

TicketCount를 처리하는 부분에서는 로또 장 수가 입력되지 않거나 너무 많게 입력되었을 경우의 처리를 담당하였다.


<lotto> - <view>

<InputView.java>

package lotto.view;

import lotto.exception.lotto.LottoNumWrongPatternException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Scanner;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class InputView {
    private static final String MONEY_INPUT_MESSAGE = "구입금액을 입력해 주세요.";
    private static final String LOTTO_NUMBER_INPUT_MESSAGE = System.lineSeparator() + "지난 주 당첨 번호를 입력해 주세요.";
    private static final String BONUS_INPUT_MESSAGE = "보너스 볼을 입력해 주세요.";
    private static final String MANUAL_TICKET_COUNT_MESSAGE = System.lineSeparator() + "수동으로 구매할 로또 수를 입력해 주세요.";
    private static final String MANUAL_TICKET_NUMBS_GROUP_MESSAGE = System.lineSeparator() + "수동으로 구매할 번호를 입력해 주세요.";

    private static final String REGEX = ",";
    private static final Pattern PATTERN = Pattern.compile("^[\\d]+,[\\d]+,[\\d]+,[\\d]+,[\\d]+,[\\d]+$");
    private static final Scanner SCANNER = new Scanner(System.in);

    public static int inputMoney() {
        System.out.println(MONEY_INPUT_MESSAGE);
        return inputNum();
    }

    public static int inputManualTicketCount() {
        System.out.println(MANUAL_TICKET_COUNT_MESSAGE);
        return inputNum();
    }

    public static int inputBonusNumber() {
        System.out.println(BONUS_INPUT_MESSAGE);
        return inputNum();
    }

    public static List<Integer> inputWinLottoNums() {
        System.out.println(LOTTO_NUMBER_INPUT_MESSAGE);
        return inputLottoNums();
    }

    public static List<List<Integer>> inputManualNums(final int repeatCount) {
        System.out.println(MANUAL_TICKET_NUMBS_GROUP_MESSAGE);
        List<List<Integer>> numsGroup = new ArrayList<>();
        for (int i = 0; i < repeatCount; i++) {
            numsGroup.add(inputLottoNums());
        }
        return numsGroup;
    }

    private static int inputNum() {
        int number = SCANNER.nextInt();
        SCANNER.nextLine();
        return number;
    }

    private static List<Integer> inputLottoNums() {
        final String lottoNumbers = SCANNER.nextLine();
        validateLottoNums(lottoNumbers);
        return separateNumbers(lottoNumbers);
    }

    private static List<Integer> separateNumbers(final String numbersText) {
        return Arrays.stream(numbersText.split(REGEX))
                .map(Integer::parseInt)
                .collect(Collectors.toList());
    }

    private static void validateLottoNums(final String rawLottoNumbers) {
        if (!PATTERN.matcher(rawLottoNumbers).matches()) {
            throw new LottoNumWrongPatternException(rawLottoNumbers);
        }
    }
}

- 전체적으로 게임 UI를 출력할 수 있도록 하였고, 입력을 받는 부분을 InputView.java에서 처리할 수 있도록 하였다.

새롭게 공부한 사실은

System.out.println( 문자열변수 );
>>

private static final String MONEY_INPUT_MESSAGE = "구입금액을 입력해 주세요.";

처럼 위에서 전역변수로 지정해주고, 그 문자열 변수에 따라 각 클래스에 넣어 처리하도록 하는 부분이

클래스 세분화에 있어서 중요한 부분이라고 생각하였다.

<lotto> - <view>

<ResultView.java>

package lotto.view;

import lotto.domain.lotto.Lotto;
import lotto.domain.lotto.LottoGroup;
import lotto.domain.lotto.LottoNumber;
import lotto.domain.result.Rank;
import lotto.domain.result.Result;
import lotto.domain.result.TicketCount;

public class ResultView {
    private static final String BUY_MESSAGE =
            System.lineSeparator() + "%d개를 구매했습니다." + System.lineSeparator();
    private static final String LOTTO_PREFIX = "[";
    private static final String LOTTO_ENDFIX = "]";
    private static final String SEPARATOR = ", ";
    private static final int DELETE_IDX = 2;
    private static final String RESULT_START_MESSAGE =
            System.lineSeparator() + "당첨 통계" + System.lineSeparator() + "---";
    private static final String RESULT_RANK_MESSAGE = "%d개 일치%s(%d원)- %d개" + System.lineSeparator();
    private static final String SAME_BONUS_MESSAGE = ", 보너스 볼 일치";
    private static final String PROFIT_MESSAGE =
            "총 수익률은 %.2f입니다." + System.lineSeparator();
    private static final String NO_MESSAGE = " 아니";

    public static void printLottoTickets(final TicketCount count, final LottoGroup lottoTickets) {
        System.out.printf(BUY_MESSAGE, count.ofManual(), count.ofAuto());
        for (Lotto lotto : lottoTickets.get()) {
            printLottoNumbers(lotto);
        }
    }

    private static void printLottoNumbers(final Lotto lotto) {
        StringBuilder result = new StringBuilder(LOTTO_PREFIX);
        for (LottoNumber lottoNumber : lotto.get()) {
            result.append(lottoNumber.get()).append(SEPARATOR);
        }
        result.delete(result.length() - DELETE_IDX, result.length()).append(LOTTO_ENDFIX);
        System.out.println(result);
    }

    public static void printLottosResult(final Result result) {
        System.out.println(RESULT_START_MESSAGE);
        for (Rank rank : Rank.getWithoutDefault()) {
            System.out.printf(RESULT_RANK_MESSAGE,
                    rank.getMatchCount(), printIfSecond(rank),
                    rank.getPrize(), result.getRankCount(rank));
        }
    }

    private static String printIfSecond(final Rank rank) {
        if (rank.equals(Rank.SECOND)) {
            return SAME_BONUS_MESSAGE;
        }
        return " ";
    }

    public static void printProfit(final float profit) {
        System.out.printf(PROFIT_MESSAGE,
                profit, printIfLoss(profit));
    }

    private static Object printIfLoss(final float profit) {
        if (profit >= 1) {
            return NO_MESSAGE;
        }
        return "";
    }
}

- 입력된 부분을 처리된 후 출력을 규격에 맞게 할 수 있는 부분과

전체적인 프로그램 진행상황을 화면에 보여질 수 있도록 하였다.

또한 최종적인 결과 출력과 입력했을 때마다 나올 수 있는 부분들을 구현해낼 수 있었다.


제출 결과로 모든 예제를 통과할 수 없었다. 확실히 주마다 난이도도 올라가고, 추가되는 요구사항도 많아

클래스를 한번 구현한 후에도 코드 리팩토링을 통해 계속 수정하는 부분이 많았다. 

이번에도 예제 테스트를 통과할 수 있도록 많은 리팩토링을 거쳤지만 과제 제출기간 내에 완벽히 구현해내지 못해서 

아쉬운 결과였다.

마지막 주차를 남겨두고 있는 가운데 다음 과제를 위해 더 공부하며 코드적으로 보강해야겠다고 생각했다.