Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
84a584f
feat: 로또 기본 클래스 구조 생성
Kdahyn Mar 29, 2026
57a6996
feat: Lotto 클래스 생성 및 검증 코드 추가
Kdahyn Mar 29, 2026
65c7547
feat: RandomNumberGenerator 구현
Kdahyn Mar 29, 2026
0fcc57b
feat: LottoShop 및 일급 컬렉션 Lottos 구현
Kdahyn Mar 29, 2026
21cbf68
feat: InputView와 OutputView 구현
Kdahyn Mar 29, 2026
c22fb34
feat: LottoController 와 Application 구현
Kdahyn Mar 29, 2026
00d965e
fix: Lottos 방어적 복사 및 메소드 명칭 변겅
Kdahyn Mar 29, 2026
c785cf3
refactor: OutputView가 Lottos에 의존하지 않도록 수정
Kdahyn Mar 29, 2026
2f26698
test: LottoTest 작성
Kdahyn Mar 29, 2026
de6d26e
test: LottosTest, LottoShopTest, TestNumberGenerator 작성
Kdahyn Mar 29, 2026
ddc142a
feat: 지난 주 당첨 번호 입력 기능 추가
Kdahyn Mar 30, 2026
d3f7ba7
feat: 로또 당첨 통계 집계 기능 추가
Kdahyn Mar 30, 2026
4811aef
feat: 로또 당첨 통계 및 수익률 출력 기능 구현
Kdahyn Mar 30, 2026
e2d7f58
refactor: 구입 금액을 PurchaseAmount로 포장
Kdahyn Mar 30, 2026
fffdd21
refactor: 로또 번호를을 LottoNumber로 포장
Kdahyn Mar 30, 2026
47405fe
test: 전반적인 테스트 수정 및 추가
Kdahyn Mar 30, 2026
7017a06
docs: 리드미 작성
Kdahyn Mar 30, 2026
135ce7b
refactor: generator 패키지 생성
Kdahyn Mar 30, 2026
743f4ce
fix: 리드미 수정
Kdahyn Mar 30, 2026
7bbb4df
refactor: 객체 생성 책임을 Application으로 이동
Kdahyn Mar 31, 2026
2f4bb82
refactor: 리스트 방어적 복사 방식 통일
Kdahyn Mar 31, 2026
dc5f949
refactor: Rank 조회가 enum 필드를 활용하도록 개선
Kdahyn Mar 31, 2026
cc52c55
refactor: LOTTE_SIZE를 static으로 변경
Kdahyn Mar 31, 2026
43547a3
test: LottoNumber 경계값 테스트를 파라미터 테스트로 통합
Kdahyn Mar 31, 2026
166ff45
refactor: generator를 호출할때 LottoNumber를 재사용 하도록 수정
Kdahyn Mar 31, 2026
4cc8f1e
refactor: 로또 번호 정렬 책임을 OutputView로 이동
Kdahyn Mar 31, 2026
f4cdd47
refactor: WinningStatistics로 당첨 통계 생성 책임 이동
Kdahyn Mar 31, 2026
a965222
refactor: WinningResult 생성 책임을 WinningStatistics로 이동
Kdahyn Mar 31, 2026
1136f01
refactor: Lottos 값을 List로 전달
Kdahyn Apr 2, 2026
6585e92
refactor: getter 위치 변경
Kdahyn Apr 2, 2026
93c931b
fix: 입력 시 불필요 정렬 제거
Kdahyn Apr 2, 2026
a98be00
test: LottoShopTest 수정
Kdahyn Apr 2, 2026
ec60bb6
refactor: controller에서 DTO를 생성하도록 변경
Kdahyn Apr 5, 2026
2257a30
feat: BonusBall 도입 및 2등 당첨 로직 구현
Kdahyn Apr 5, 2026
50f8cbf
feat: 수동 로또 입력 기능 추가
Kdahyn Apr 6, 2026
5eee051
feat: LottoShop 수동/자동 로또 통합 발급 기능 추가
Kdahyn Apr 6, 2026
6371f02
feat: 수동 로또 구입 수 표시 추가
Kdahyn Apr 6, 2026
0ce58f8
refactor: WinningResult 생성 책임을 Controller에서 WinningResult로 이동
Kdahyn Apr 6, 2026
6270d12
refactor: WinningResult 구조 변경
Kdahyn Apr 6, 2026
97c03ab
feat: manualCount를 ManualLottoCount로 원시값 포장
Kdahyn Apr 6, 2026
2a9c1d1
test: LottoShopTest 수정
Kdahyn Apr 6, 2026
f7bf823
test: ManualLottoCountTest 추가
Kdahyn Apr 6, 2026
76adb1b
docs: README 수정
Kdahyn Apr 6, 2026
07e7ed1
upstream/kdahyn 브랜치 병합
Kdahyn Apr 6, 2026
29b1bc5
refactor: 와일드카드 import 해제
Kdahyn Apr 8, 2026
da199f6
refactor: Lotto 생성 시 번호 정렬
Kdahyn Apr 8, 2026
6e33782
refactor: Rank로 당첨 결과 메시지 책임 이동
Kdahyn Apr 8, 2026
16c27c6
refactor: 검증 메서드를 static으로 변경
Kdahyn Apr 8, 2026
16f706a
refactor: matchCount 필드 제거
Kdahyn Apr 8, 2026
1ae5b41
refactor: WinningStatistics에 EnumMap 적용
Kdahyn Apr 8, 2026
c3bda2c
refactor: Lotto, Lottos의 변환 책임을 각 객체로 이동
Kdahyn Apr 9, 2026
f27e9cc
feat: 입력 검증 실패 시 재시도 로직 추가
Kdahyn Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 49 additions & 20 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
## 프로젝트 개요

로또 구매 및 당첨 통계 계산 프로그램을 구현했습니다.
사용자는 구입 금액과 지난 주 당첨 번호를 입력할 수 있으며, 구입 금액에 해당하는 수만큼 로또를 자동으로 발급받습니다.
발급된 로또와 당첨 번호를 비교하여 당첨 통계를 계산하고, 총 수익률을 출력합니다.
사용자는 구입 금액을 입력하고, 수동으로 구매할 로또 개수와 번호를 입력할 수 있습니다.
남은 수량만큼은 자동으로 발급되며, 지난 주 당첨 번호와 보너스 볼을 입력해 당첨 결과를 계산합니다.
구매한 로또와 당첨 번호를 비교하여 당첨 통계를 출력하고, 총 수익률을 계산합니다.

## 기술 스택

Expand All @@ -26,66 +27,89 @@
- 로또 번호가 6개가 아니면 예외를 발생시킨다.
- 로또 번호가 중복되면 예외를 발생시킨다.
- 당첨 번호와 비교하여 일치 개수를 계산한다.
- 특정 번호를 포함하는지 확인한다.

### Lottos

- 여러 장의 로또를 관리한다.
- 구매한 로또 개수를 반환한다.
- 로또 목록을 출력용 번호 리스트로 변환한다.
- 당첨 번호를 기준으로 당첨 통계를 생성한다.
- 로또 목록을 반환한다.

### PurchaseAmount

- 구입 금액을 관리한다.
- 구입 금액이 1000원 미만이면 예외를 발생시킨다.
- 구입 금액이 1000원 단위가 아니면 예외를 발생시킨다.
- 구입 금액으로 구매 가능한 로또 개수를 계산한다.
- 수동 구매 수를 제외한 자동 구매 개수를 계산한다.

### ManualLottoCount

- 수동 구매 로또 개수를 관리한다.
- 수동 구매 수가 0 미만이면 예외를 발생시킨다.
- 수동 구매 수가 총 구매 가능 로또 수를 초과하면 예외를 발생시킨다.

### LottoShop

- 로또 구매를 담당한다.
- 구입 금액에 해당하는 개수만큼 로또를 생성한다.
- 수동 로또와 자동 로또를 함께 구매한다.
- 자동 로또를 생성하고 수동 로또와 병합한다.

### NumberGenerator

- 로또 번호 생성 역할을 분리하기 위해 `NumberGenerator` 인터페이스를 사용했다.
- `RandomNumberGenerator`는 1부터 45 사이의 숫자 중 6개를 무작위로 생성한다.
- `TestNumberGenerator`는 테스트에서 원하는 번호를 고정으로 생성한다.

### BonusBall

- 보너스 볼 번호를 관리한다.
- 보너스 볼이 없으면 예외를 발생시킨다.

### WinningLotto

- 지난 주 당첨 번호와 보너스 볼을 함께 관리한다.
- 보너스 볼이 당첨 번호와 중복되면 예외를 발생시킨다.
- 구매한 로또의 당첨 등수를 판별한다.

### Rank

- 당첨 등수를 관리한다.
- 일치 개수에 따라 3등, 4등, 5등, 6등을 판별한다.
- 일치 개수와 보너스 볼 일치 여부에 따라 등수를 판별한다.
- 각 등수에 해당하는 당첨 금액을 관리한다.

### WinningStatistics

- 등수별 당첨 개수를 관리한다.
- 등수별 당첨 개수를 증가시킨다.
- 구매한 로또 목록과 당첨 번호를 기준으로 당첨 통계를 생성한다.
- 등수별 당첨 개수를 반환한다.
- 총 당첨금을 계산한다.
- 구입 금액을 기준으로 수익률을 계산한다.

### WinningResult

- 당첨 통계 출력에 필요한 데이터를 관리한다.
- 일치 개수, 당첨 금액, 당첨 개수를 담아 출력 계층으로 전달한다.
- `WinningStatistics`를 출력용 결과 목록으로 변환한다.
- 당첨 메시지와 당첨 개수를 담아 출력 계층으로 전달한다.

### InputView

- 구입 금액을 입력받는다.
- 수동으로 구매할 로또 수를 입력받는다.
- 수동으로 구매할 번호를 입력받는다.
- 지난 주 당첨 번호를 쉼표(,)를 기준으로 구분하여 입력받는다.
- 보너스 볼을 입력받는다.

### OutputView

- 구매 결과 헤더를 출력한다.
- 수동 구매 수와 자동 구매 수를 함께 출력한다.
- 발급된 로또 번호를 출력한다.
- 당첨 통계를 출력한다.
- 총 수익률을 출력한다.

### LottoController

- 로또 게임의 전체 진행을 담당한다.
- 구입 금액 입력, 로또 구매, 당첨 번호 입력, 당첨 통계 계산, 결과 출력을 순서대로 연결한다.

## 테스트

Expand All @@ -100,34 +124,39 @@

### LottoShopTest

- 구입 금액만큼 로또를 구매하는지 테스트한다.
- 구입 금액에 맞게 자동 로또가 생성되는지 테스트한다.
- 수동 로또와 자동 로또가 함께 반환되는지 테스트한다.

### LottoNumberTest

- 로또 번호가 1보다 작으면 예외가 발생하는지 테스트한다.
- 로또 번호가 45보다 크면 예외가 발생하는지 테스트한다.
- 로또 번호가 범위를 벗어나면 예외가 발생하는지 테스트한다.

### PurchaseAmountTest

- 구입 금액이 1000원 미만이면 예외가 발생하는지 테스트한다.
- 구입 금액이 1000원 단위가 아니면 예외가 발생하는지 테스트한다.
- 구입 금액으로 구매 가능한 로또 개수를 계산하는지 테스트한다.

### ManualLottoCountTest

- 수동 구매 수가 0 미만이면 예외가 발생하는지 테스트한다.
- 수동 구매 수가 총 구매 가능 수를 초과하면 예외가 발생하는지 테스트한다.

### WinningStatisticsTest

- 등수를 추가하면 당첨 개수가 증가하는지 테스트한다.
- 당첨 결과별 개수를 집계하는지 테스트한다.
- 총 당첨금을 계산하는지 테스트한다.
- 수익률을 계산하는지 테스트한다.

## 설계 의도

로또 번호와 구입 금액을 각각 `LottoNumber`, `PurchaseAmount` 로 포장했습니다.
이를 통해 로또 번호 범위 검증과 구입 금액 검증 책임을 객체가 관리하도록 했습니다.
로또 번호, 구입 금액, 수동 구매 수를 각각 `LottoNumber`, `PurchaseAmount`, `ManualLottoCount`로 포장했습니다.
이를 통해 원시값이 직접 흩어지지 않도록 하고, 각 값에 대한 검증 책임을 객체가 관리하도록 했습니다.

또한 `List<Lotto>`를 `Lottos`로 감싸는 방식으로 로또 목록에 대한 책임을 분리했습니다.

당첨 결과는 `Rank`와 `WinningStatistics`를 통해 계산하도록 했습니다.
`Rank`는 일치 개수에 따른 등수와 당첨 금액을 관리하고, `WinningStatistics`는 등수별 개수와 총 당첨금, 수익률 계산을 담당하도록 구성했습니다.
당첨 결과는 `WinningLotto`, `Rank`, `WinningStatistics`를 통해 계산하도록 했습니다.
`WinningLotto`는 당첨 번호와 보너스 볼을 함께 관리하고, `Rank`는 일치 개수와 보너스 볼 일치 여부에 따른 등수 및 당첨 금액을 관리합니다.
`WinningStatistics`는 등수별 개수와 총 당첨금, 수익률 계산을 담당하도록 구성했습니다.

입력과 출력은 `InputView`, `OutputView`로 분리했습니다.
또한 출력에 필요한 데이터는 `WinningResult`로 별도 전달하여 뷰가 도메인 객체를 직접 해석하지 않도록 구성했습니다.
또한 출력에 필요한 데이터는 `WinningResult`로 변환하여 뷰가 도메인을 직접 알지 못하도록 구성했습니다.
62 changes: 48 additions & 14 deletions src/main/java/controller/LottoController.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package controller;

import domain.*;
import domain.BonusBall;
import domain.Lotto;
import domain.LottoNumber;
import domain.LottoShop;
import domain.Lottos;
import domain.ManualLottoCount;
import domain.PurchaseAmount;
import domain.WinningLotto;
import domain.WinningStatistics;
import dto.WinningResult;
import view.InputView;
import view.OutputView;

import java.util.List;
import java.util.function.Supplier;

public class LottoController {
private final InputView inputView;
Expand All @@ -18,24 +27,49 @@ public LottoController(InputView inputView, OutputView outputView, LottoShop lot
}

public void run() {
PurchaseAmount purchaseAmount = new PurchaseAmount(inputView.readAmount());
Lottos lottos = lottoShop.purchase(purchaseAmount);
PurchaseAmount purchaseAmount = retryUntilValid(() -> new PurchaseAmount(inputView.readAmount()));
ManualLottoCount manualLottoCount = retryUntilValid(() -> new ManualLottoCount(inputView.readManualCount(), purchaseAmount));
Lottos lottos = retryUntilValid(() -> purchaseLottos(purchaseAmount, manualLottoCount));

outputView.printResultHeader(lottos.size());
outputView.printLottos(lottos.toNumberLists());

List<Integer> winningNumbers = inputView.readWinningNumbers();
Lotto winningLotto = new Lotto(toLottoNumbers(winningNumbers));
printPurchaseResult(lottos, manualLottoCount);

WinningLotto winningLotto = readWinningLotto();
WinningStatistics winningStatistics = WinningStatistics.from(lottos, winningLotto);

outputView.printWinningStatistics(winningStatistics.winningResults());
printWinningResult(winningStatistics, purchaseAmount);
}

private Lottos purchaseLottos(PurchaseAmount purchaseAmount, ManualLottoCount manualLottoCount) {
Lottos manualLottos = Lottos.from(inputView.readManualNumbers(manualLottoCount.count()));
return lottoShop.purchase(purchaseAmount, manualLottos);
}

private void printPurchaseResult(Lottos lottos, ManualLottoCount manualLottoCount) {
int autoCount = lottos.size() - manualLottoCount.count();
outputView.printResultHeader(manualLottoCount.count(), autoCount);
outputView.printLottos(lottos.toNumberLists());
}

private WinningLotto readWinningLotto() {
Lotto winningLotto = retryUntilValid(() -> Lotto.from(inputView.readWinningNumbers()));
return retryUntilValid(() -> {
BonusBall bonusBall = new BonusBall(new LottoNumber(inputView.readBonusBall()));
return new WinningLotto(winningLotto, bonusBall);
});
}

private void printWinningResult(WinningStatistics winningStatistics, PurchaseAmount purchaseAmount) {
outputView.printWinningStatistics(WinningResult.from(winningStatistics));
outputView.printProfitRate(winningStatistics.calculateProfitRate(purchaseAmount));
}

private List<LottoNumber> toLottoNumbers(List<Integer> numbers) {
return numbers.stream()
.map(LottoNumber::new)
.toList();
private <T> T retryUntilValid(Supplier<T> supplier) {
while (true) {
try {
return supplier.get();
} catch (IllegalArgumentException e) {
outputView.printErrorMessage(e.getMessage());
}
}
}
}
13 changes: 13 additions & 0 deletions src/main/java/domain/BonusBall.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package domain;

public record BonusBall(LottoNumber number) {
public BonusBall {
validate(number);
}

private void validate(LottoNumber number) {
Copy link
Copy Markdown

@chemistryx chemistryx Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 검증 메소드의 경우 this 필드에 접근하지 않으므로 static으로 선언해도 무방할 것 같은데 어떻게 생각하시나요?
++ LottoNumber내 선언된 검증 메소드도 동일해요~~

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗, 그렇군요! 말씀해주신 대로 this 필드에 접근하지 않는 검증 메서드들은 static으로 변경했습니다.

if (number == null) {
throw new IllegalArgumentException("보너스 볼은 필수입니다.");
}
}
}
23 changes: 18 additions & 5 deletions src/main/java/domain/Lotto.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package domain;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class Lotto {
Expand All @@ -9,7 +10,13 @@ public class Lotto {

public Lotto(List<LottoNumber> numbers) {
validate(numbers);
this.numbers = new ArrayList<>(numbers);
this.numbers = new ArrayList<>(sortNumbers(numbers));
}

public static Lotto from(List<Integer> numbers) {
return new Lotto(numbers.stream()
.map(LottoNumber::new)
.toList());
}

public int countMatch(Lotto winningLotto) {
Expand All @@ -22,22 +29,28 @@ public List<LottoNumber> getNumbers() {
return List.copyOf(numbers);
}

private boolean contains(LottoNumber lottoNumber) {
public boolean contains(LottoNumber lottoNumber) {
return numbers.contains(lottoNumber);
}

private void validate(List<LottoNumber> numbers) {
private static void validate(List<LottoNumber> numbers) {
validateLottoSize(numbers);
validateDuplicate(numbers);
}

private void validateLottoSize(List<LottoNumber> numbers) {
private List<LottoNumber> sortNumbers(List<LottoNumber> numbers) {
return numbers.stream()
.sorted(Comparator.comparingInt(LottoNumber::number))
.toList();
}

private static void validateLottoSize(List<LottoNumber> numbers) {
if (numbers.size() != LOTTO_SIZE) {
throw new IllegalArgumentException("로또 숫자의 갯수는 6개입니다.");
}
}

private void validateDuplicate(List<LottoNumber> numbers) {
private static void validateDuplicate(List<LottoNumber> numbers) {
long distinctCount = numbers.stream().distinct().count();
if (distinctCount != LOTTO_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/domain/LottoNumber.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ public record LottoNumber(int number) {
validate(number);
}

private void validate(int number) {
private static void validate(int number) {
validateRange(number);
}

private void validateRange(int number) {
private static void validateRange(int number) {
if (number < MIN_NUMBER || number > MAX_NUMBER) {
throw new IllegalArgumentException("로또 번호는 1부터 45 사이여야 합니다.");
}
Expand Down
16 changes: 12 additions & 4 deletions src/main/java/domain/LottoShop.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,23 @@ public LottoShop(NumberGenerator numberGenerator) {
this.numberGenerator = numberGenerator;
}

public Lottos purchase(PurchaseAmount purchaseAmount) {
return new Lottos(createLottos(purchaseAmount));
public Lottos purchase(PurchaseAmount purchaseAmount, Lottos manualLottos) {
int autoLottoCount = purchaseAmount.calculateAutoLottoCount(manualLottos.size());
List<Lotto> autoLottos = createAutoLottos(autoLottoCount);
return new Lottos(mergeLottos(manualLottos.lottoToList(), autoLottos));
}

private List<Lotto> createLottos(PurchaseAmount purchaseAmount) {
private List<Lotto> createAutoLottos(int autoLottoCount) {
List<Lotto> lottos = new ArrayList<>();
for (int i = 0; i < purchaseAmount.calculateLottoCount(); i++) {
for (int i = 0; i < autoLottoCount; i++) {
lottos.add(new Lotto(numberGenerator.generate()));
}
return lottos;
}

private List<Lotto> mergeLottos(List<Lotto> manualLottos, List<Lotto> autoLottos) {
List<Lotto> mergedLottos = new ArrayList<>(manualLottos);
mergedLottos.addAll(autoLottos);
return mergedLottos;
}
}
6 changes: 6 additions & 0 deletions src/main/java/domain/Lottos.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public Lottos(List<Lotto> lottos) {
this.lottos = new ArrayList<>(lottos);
}

public static Lottos from(List<List<Integer>> lottos) {
return new Lottos(lottos.stream()
.map(Lotto::from)
.toList());
}

public int size() {
return lottos.size();
}
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/domain/ManualLottoCount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package domain;

public class ManualLottoCount {
private final int count;

public ManualLottoCount(int count, PurchaseAmount purchaseAmount) {
validate(count, purchaseAmount);
this.count = count;
}

private void validate(int count, PurchaseAmount purchaseAmount) {
if (count < 0) {
throw new IllegalArgumentException("수동 구매 수는 0 이상이어야 합니다.");
}
if (count > purchaseAmount.calculateLottoCount()) {
throw new IllegalArgumentException("수동 로또 수는 총 로또 수를 넘을 수 없습니다.");
}
}

public int count() {
return count;
}
}
Loading