이번 포스팅은 이전기수 프리코스 3주차 문제였던 로또를 구현하는 과정을 작성하고 회고해보는 시간을 가져보도록 하자!
추가적으로 입출력 요구사항, 프로그래밍, 과제 진행 요구사항이 있다.
저번 포스팅과 다르게 이번 포스팅에서는 MVC 패턴을 기준으로 기능 명세서를 작성하였다. 다만, 초반 설계에서 벗어나는 부분이 생기면 새로운 목표를 정의하고 다시 적어나갔다. 처음 설계에서 벗어나는 일이 있어도 큰 틀에서는 벗어나지 않도록 명세서를 작성했다.
MVC 기준으로 명세서를 작성하니 확실히 저번 포스팅보다 정리가 더욱 잘되고 틀이 잡힌다는 느낌을 받았다. 약간 고민은 어디까지 틀을 잡고 어디까지 디테일을 잡는가인 것 같다.
사실 초반 아키텍쳐는 Model, View, Controller 였지만 역시 Model에서 수행되는 비즈니스 로직이 단일 책임을 지니지 않는 것 같아 Service 계층까지 확대하였다.
MVC 패턴을 기반으로 Model,Service,Controller,View로 구분하였다. App의 경우 해당 애플리케이션이 돌아갈 main 함수가 있는 곳 이다.
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 클래스가 주어지고 해당 클래스에 살을 붙이는 형식으로 구현하는 것이 요구사항 중 하나였다.
원래는 제공되는 라이브러리를 사용해야하지만 해당 포스팅은 자바 라이브러리 내의 메서드를 이용해 랜덤한 번호를 생성하였다.
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 코드도 작성해주었다.
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을 더해 자신의 총 수익률을 계산하였다.
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를 기준으로 조금 큰 틀을 잡고 기능을 설계하다보니 해당 기능을 만들때 이것도 있어야 겠는데? 라는 생각을 많이 했던 것 같다.
내가 작성한 코드가 성능은 어떠할지도 많이 궁금하다. 이런 부분은 역시 코드리뷰를 받으면서 개선점을 찾아가는 것이 정말 좋다고 생각한다. 테스트 코드를 작성할때도 어디까지 검증해야할까 고민을 많이 했던 것 같다. 로또는 랜덤한 로직이다 보니 어떻게 테스트를 작성할지도 많이 고민했다.
확실한 건, 숫자야구보다 기능적으로 신경 쓸 부분이 더 많았고 이전 포스팅에 비해 조금 더 설계하고 테스트하고 구현하는 실력이 비약적으로나마 향상된 것 같다 라는 생각을 했다 🥹