이번 미션에서는 앞에서 배워온 단위 테스트, MVC 패턴을 모두 적용하여 로또 미션을 구현해보는 문제였다. 또, 읽기 좋고, 예측 가능하고, 실수를 방지하는 코드를 작성하는 법을 배웠다. 굵직한 부분을 어느정도 배웠으니 디테일을 챙기며 코드를 리팩터링하는 단계인 것 같다. 하지만 아직 제대로 객체지향적인 코드도 잘 모르겠는데 디테일을 신경쓰려니 너무 힘들었다. 하려고 노력은 했으나... 결과물은 별로인듯 하다.

로또 수동 구매, 보너스 넘버 등 신경 써줄 부분이 너무 많았다. 특히 일급 컬렉션을 활용하라는 요구사항이 있었는데 잘 알지 못해서 일단 구현해보았다. 구조를 잘못 짠건지 너무 복잡하고 어려운 부분이 많았다. 다른 사람들 코드도 구경을 해보았는데 잘 이해가 안가기도 하고, 비슷하게 복잡해서 큰 도움은 되지 않았다. 더 간단하게 구성하는 방법이 있을지...
일단 전체 코드를 MVC 패턴 구조에 맞게 view, model, controller로 소개해보겠다.
import java.util.Arrays;
import java.util.List;
import java.util.Scanner;
public class InputView {
private static final Scanner in = new Scanner(System.in);
public int inputLottoAmount() {
System.out.println("구입금액을 입력해 주세요.");
int amount;
try{
amount = Integer.parseInt(in.nextLine());
if(amount < 1000) throw new RuntimeException("1000원 이상 구매하여야 합니다.");
if(amount % 1000 != 0) throw new RuntimeException("1000원 단위로 구매하여야 합니다.");
}
catch (NumberFormatException e) {
throw new RuntimeException("잘못된 값을 입력하였습니다.");
}
return amount;
}
public int manualLottoAmount(int lottoAmount) {
System.out.println("수동으로 구매할 로또 수를 입력해 주세요.");
int amount;
try{
amount = Integer.parseInt(in.nextLine());
if(amount > lottoAmount) throw new RuntimeException("총 구매할 로또 수 이하로 구매하여야 합니다.");
if(amount <= 0) throw new RuntimeException("1개 이상 구매하여야 합니다.");
}
catch (NumberFormatException e){
throw new RuntimeException("잘못된 값을 입력하였습니다.");
}
return amount;
}
public List<Integer> inputManualLottoNums() {
return validateLottoNumber(in.nextLine());
}
public List<Integer> intputWinningNums() {
System.out.println("지난 주 당첨 번호를 입력해 주세요.");
return validateLottoNumber(in.nextLine());
}
public int inputBonusBall() {
System.out.println("보너스 볼을 입력해 주세요.");
int bonusBall;
try{
bonusBall = Integer.parseInt(in.nextLine());
if(bonusBall < 0 || bonusBall > 45) throw new RuntimeException("로또 번호는 1이상 45이하여야 합니다.");
}
catch (NumberFormatException e){
throw new RuntimeException("잘못된 값을 입력하였습니다.");
}
return bonusBall;
}
private List<Integer> validateLottoNumber(String winningNums) {
List<Integer> winningNumbers;
try{
winningNumbers = Arrays.stream(winningNums.split(","))
.map(String::strip)
.map(Integer::parseInt)
.toList();
}
catch (NumberFormatException e){
throw new RuntimeException("잘못된 로또 번호 입니다.");
}
return winningNumbers;
}
}
로또 구매 금액, 수동 로또 구매 개수, 당첨 번호, 보너스 볼 번호 등을 입력 받는다.
저번 피드백에서 예외 발생을 inputView에서 관리해야하는가?를 코멘트 받았는데 이번에도 input에서 관리하는 것이 맞다 생각하여 이곳에서 진행했다. 자세한 내용은 회고 2를 참고하면 된다.
import java.util.Comparator;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
public class OutputView {
public void printLottos(List<Lotto> lotto, int manualAmount, int autoLottoCount){
System.out.printf("수동으로 %d장, 자동으로 %d장을 구매했습니다.\n", manualAmount, autoLottoCount);
lotto.forEach(System.out::println);
}
public void printWinningStatistics(Map<Rank, Long> winningLottos) {
System.out.println("당첨 통계\n---------");
sortWinningLottos(winningLottos).forEach(this::printRankInfo);
}
private EnumMap<Rank, Long> sortWinningLottos(Map<Rank, Long> winningLottos) {
EnumMap<Rank, Long> sortedMap = new EnumMap<>(Rank.class);
winningLottos.entrySet().stream()
.filter(entry -> entry.getKey() != Rank.UNRANK)
.forEach(entry -> sortedMap.put(entry.getKey(), entry.getValue()));
return sortedMap;
}
private void printRankInfo(Rank rank, Long count) {
System.out.printf("%d개 일치%s (%d원) - %d개\n",
rank.getMatch(),
rank.hasBonus() ? " + 보너스 볼" : "",
rank.getReward(),
count
);
}
public void printProfitRate(double profitRate){
System.out.printf("총 수익률은 %.2f 입니다.\n", profitRate);
}
public void printInputManualLottoMessage(){
System.out.println("수동으로 구매할 번호를 입력해 주세요.");
}
}
통계, 수익률, 그외 시스템 메시지 등을 출력한다.
import java.util.List;
public class Lotto {
private final List<Integer> numbers;
private static final int MIN_LOTTO_NUMBER = 1;
private static final int MAX_LOTTO_NUMBER = 45;
public Lotto(List<Integer> numbers){
this.numbers = numbers;
}
public List<Integer> getNumbers() {
return numbers;
}
public static void validate(List<Integer> lottoNumbers) {
if (lottoNumbers.size() != 6) {
throw new RuntimeException("로또 번호는 6개여야 합니다.");
}
if (lottoNumbers.stream().anyMatch(n -> n < MIN_LOTTO_NUMBER || n > MAX_LOTTO_NUMBER)) {
throw new RuntimeException("로또 번호는 1이상 45이하여야 합니다.");
}
}
@Override
public String toString(){
return numbers.toString();
}
}
Lotto 클래스이다. 로또 번호와 최소 로또 번호, 최대 로또 번호를 멤버변수로 가지고 있다. 로또 번호가 6개가 아니거나 1이상 45이하의 수가 아닌 번호가 들어온다면 예외를 일으킨다. toString의 경우 나중에 로또 번호를 출력하기 위해(outputView에서) override(재정의)했다.
import java.util.*;
public class LottoMarket {
private List<Integer> winningNumbers;
private final List<Lotto> lottos;
private final List<Lotto> manualLottos;
public LottoMarket() {
this.lottos = new ArrayList<>();
this.manualLottos = new ArrayList<>();
}
public void setWinningNumbers(List<Integer> winningNumbers) {
Lotto.validate(winningNumbers);
this.winningNumbers = winningNumbers;
}
public void randomLotto() {
List<Integer> numbers = new Random().ints(1, 46)
.distinct()
.limit(6)
.sorted()
.boxed()
.toList();
lottos.add(new Lotto(numbers));
}
public void manualLotto(List<Integer> manualLottoNums) {
Lotto.validate(manualLottoNums);
manualLottos.add(new Lotto(manualLottoNums));
}
public List<Integer> getWinningNumbers() {
return Collections.unmodifiableList(winningNumbers);
}
public List<Lotto> getManualLottos() {
return Collections.unmodifiableList(manualLottos);
}
public List<Lotto> getLottos() {
return Collections.unmodifiableList(lottos);
}
public List<Lotto> getAllLottos() {
List<Lotto> allLottos = new ArrayList<>(manualLottos);
allLottos.addAll(lottos);
return Collections.unmodifiableList(allLottos);
}
}
Lotto를 총괄하는 클래스이다. 자동 로또, 수동 로또, 당첨 번호를 가지고 있다.
자동, 수동 로또를 설정하는 메소드도 포함되어 있다.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Objects;
public class Statics {
public Map<Rank, Long> calcWinningLottos(List<Lotto> lottos, List<Integer> winningNumbers, int bonusBall) {
Map<Rank, Long> winningLottos = initWinningLottos();
Map<Rank, Long> result = countWinningRanks(lottos, winningNumbers, bonusBall);
winningLottos.putAll(result);
return winningLottos;
}
private Map<Rank, Long> initWinningLottos() {
Map<Rank, Long> map = new HashMap<>();
for (Rank rank : Rank.values()) map.put(rank, 0L);
return map;
}
private Map<Rank, Long> countWinningRanks(List<Lotto> lottos, List<Integer> winningNumbers, int bonusBall) {
return lottos.stream().filter(Objects::nonNull)
.map(lotto -> Rank.getRank(countMatchingNumbers(lotto, winningNumbers), lotto.getNumbers().contains(bonusBall)))
.filter(rank -> rank != Rank.UNRANK)
.collect(Collectors.groupingBy(rank -> rank, Collectors.counting()));
}
private int countMatchingNumbers(Lotto lotto, List<Integer> winningNumbers) {
return (int) lotto.getNumbers().stream().filter(winningNumbers::contains).count();
}
public double calcProfitRate(Map<Rank, Long> winningLottos, int lottoAmount) {
long totalEarnings = winningLottos.entrySet().stream()
.mapToLong(entry -> entry.getKey().getReward() * entry.getValue())
.sum();
return ((double) totalEarnings / lottoAmount);
}
}
Statics라는 네이밍에 맞게 수익률을 계산하기 위한 메소드들을 한번에 모아놓았다.
근데 생각해보니 객체지향설계란 데이터를 중심으로 해야하는데 이 클래스는 함수 중심이 되어버렸다. 이거 때문에 구조가 꼬인 것 같기도 하다.
import java.util.List;
import java.util.Map;
public class Controller {
private final LottoMarket market;
private final Statics statics;
private final InputView inputView;
private final OutputView outputView;
public Controller(LottoMarket market, Statics statics, InputView inputView, OutputView outputView) {
this.market = market;
this.statics = statics;
this.inputView = inputView;
this.outputView = outputView;
}
public void run(){
int lottoAmount = inputView.inputLottoAmount();
int purchasableLotto = lottoAmount / 1000;
int manualAmount = inputView.manualLottoAmount(purchasableLotto);
int autoLottoCount = setLotto(manualAmount, purchasableLotto);
startLotto(manualAmount, autoLottoCount, lottoAmount);
}
private int setLotto(int manualAmount, int purchasableLotto) {
outputView.printInputManualLottoMessage();
for (int i = 0; i < manualAmount; ++i) {
market.manualLotto(inputView.inputManualLottoNums());
}
int autoLottoCount = purchasableLotto - manualAmount;
for (int i = 0; i < autoLottoCount; ++i) {
market.randomLotto();
}
return autoLottoCount;
}
private void startLotto(int manualAmount, int autoLottoCount, int lottoAmount){
outputView.printLottos(market.getAllLottos(), manualAmount, autoLottoCount);
var winningLottos = setWinningLottoNumber();
outputView.printWinningStatistics(winningLottos);
double profitRate = statics.calcProfitRate(winningLottos, lottoAmount);
outputView.printProfitRate(profitRate);
}
private Map<Rank, Long> setWinningLottoNumber(){
List<Integer> winningNumbers = inputView.intputWinningNums();
int bonusBall = inputView.inputBonusBall();
market.setWinningNumbers(winningNumbers);
return statics.calcWinningLottos(market.getAllLottos(), market.getWinningNumbers(), bonusBall);
}
}
각각 클래스에서 만든 메소드들을 통해 실행하는 클래스인 controller이다. 이번에는 양이 너무 많아서 두개의 메소드로 분할되어있다.(원래는 하나였는데 피드백 받아 고쳤다.)
import java.util.Arrays;
public enum Rank {
FIRST(6, false, 2_000_000_000),
SECOND(5, true, 30_000_000),
THIRD(5, false, 1_500_000),
FOURTH(4, false, 50_000),
FIFTH(3, false, 5_000),
UNRANK(0, false, 0);
private final int match;
private final boolean bonus;
private final int reward;
Rank(int match, boolean bonus, int reward) {
this.match = match;
this.bonus = bonus;
this.reward = reward;
}
public static Rank getRank(int matchCount, boolean hasBonus) {
return Arrays.stream(values())
.filter(rank -> rank.match == matchCount && rank.bonus == hasBonus)
.findFirst()
.orElse(UNRANK);
}
public int getReward() {
return reward;
}
public int getMatch() {
return match;
}
public boolean hasBonus() {
return bonus;
}
}
당첨 번호 개수를 통해 랭크를 설정 해놓은 열거형이다. false, true는 보너스 번호 당첨 여부이다.
public class Main {
public static void main(String[] args) {
LottoMarket market = new LottoMarket();
Statics statics = new Statics();
InputView inputView = new InputView();
OutputView outputView = new OutputView();
Controller controller = new Controller(market, statics, inputView, outputView);
controller.run();
}
}
main을 통해 검증한다.
try{
amount = Integer.parseInt(in.nextLine());
if(amount > lottoAmount) throw new RuntimeException("총 구매할 로또 수 이하로 구매하여야 합니다.");
if(amount <= 0) throw new RuntimeException("1개 이상 구매하여야 합니다.");
}
catch (NumberFormatException e){
throw new RuntimeException("잘못된 값을 입력하였습니다.");
}
현재 inputView 코드에서는 try-catch를 통해 입력의 유효성을 검증하고 있다. 하지만 lottoAmount, manualLottoAmount, bonusNumber 등 모두 try-catch를 사용하고 있어 이 부분을 모듈화 해보자라는 피드백이 들어왔다.
하지만 어떻게 모듈화 할지 몰라 일단은 pass를 한 부분이다.
보너스 볼을 객체화 해서 하는 방법도 나쁘지는 않지만, 클래스가 너무 늘어날것 같아 하지는 않았다. 그러나 요구사항대로 하려면 클래스를 만들고 생성자에서 유효성을 검증하는 편이 좋아 보인다. try-cathc도 줄일 수 있다.
위와 마찬가지로 너무 클래스가 많아질 것 같아 반영하지는 않았지만, 괜찮은 방법이라 생각한다. 유효성 검증도 input에서 하지 않고, 클래스 생성자에서 유효성 검사를 할 수 있어 try-catch를 줄일 수 있다.
enumMap
원래 OutputView에서는 sortWinningNums 메소드에서 map을 ordianry라는 고유값을 통해 정렬을 하였다. (ex. 값이 추가 되거나 삭제될 때 고유값이 바뀌어 정렬이 잘못 될 수 있다.) 하지만 고유값을 통해 정렬하면 위험성이 있을 수 있다. 라는 피드백이 들어왔다. 하지만 enumMap을 통해 enum의 정의된 순서를 가지고 정렬할 수 있다.
파일 맨 끝에 뉴라인을 추가하자
코드 마지막 줄에 뉴라인을 추가하지 않으면 문제가 생길수 있다 한다. 아마 운영체제마자 다른 것 같다.
다른 사람의 코드는 그 사람에게 저작권이 있어 갖고 올 수는 없지만... 다들 코드를 잘 짜셨다. 확실히 일급 컬렉션 요구사항 때문에 클래스를 엄청나게 책임 분리를 한 것 같다. 내가 분리할 때는 너무 더러워 보였는데 어째서 남이 했을 때는 이리도 깨끗해 보이는건가..... 주요 로직 자체는 비슷해보여서 따로 리뷰는 안할 것 같다. 하지만 여러가지 클래스로 책임 분리하는 것... 나쁘지 않아 보인다. 일단 가독성이 쏘쏘하다. 현재 내 코드는 좀 조잡해보여서 객체지향적인 설계를 더 배워야 할 것 같아...
아직도 모자란 부분이 많다... 일단 클래스 구조는 고사하고 로직 구현때문에 힘듬이 많다. 이렇게 하면 되겠지? 생각하고 막상 구현하려니 아는게 없어서 항상 찾아본다. 아직까지 자바가 익숙하지 않나보다. 그외에도 객체지향적인 설계는 어떻게 해야하는가를 끊임없이 고민해봐야할 것 같다. 책임 분리와 객체(데이터) 중심 설계는 대체 뭘까... 어렵다 어려워.