Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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
133 changes: 133 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 로또 게임

## 프로젝트 개요

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

## 기술 스택

- Java
- Gradle
- JUnit5
- AssertJ

## 구현 기능

### LottoNumber

- 로또 번호 하나를 관리한다.
- 로또 번호가 1보다 작거나 45보다 크면 예외를 발생시킨다.

### Lotto

- 로또 한 장의 번호 6개를 관리한다.
- 로또 번호가 6개가 아니면 예외를 발생시킨다.
- 로또 번호가 중복되면 예외를 발생시킨다.
- 당첨 번호와 비교하여 일치 개수를 계산한다.

### Lottos

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

### PurchaseAmount

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

### LottoShop

- 로또 구매를 담당한다.
- 구입 금액에 해당하는 개수만큼 로또를 생성한다.

### NumberGenerator

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

### Rank

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

### WinningStatistics

- 등수별 당첨 개수를 관리한다.
- 등수별 당첨 개수를 증가시킨다.
- 총 당첨금을 계산한다.
- 구입 금액을 기준으로 수익률을 계산한다.

### WinningResult

- 당첨 통계 출력에 필요한 데이터를 관리한다.
- 일치 개수, 당첨 금액, 당첨 개수를 담아 출력 계층으로 전달한다.

### InputView

- 구입 금액을 입력받는다.
- 지난 주 당첨 번호를 쉼표(,)를 기준으로 구분하여 입력받는다.

### OutputView

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

### LottoController

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

## 테스트

### LottoTest

- 로또 번호의 개수가 6개가 아니면 예외가 발생하는지 테스트한다.
- 로또 번호가 중복되면 예외가 발생하는지 테스트한다.

### LottosTest

- 로또 목록을 출력용 번호 리스트로 변환하는지 테스트한다.

### LottoShopTest

- 구입 금액만큼 로또를 구매하는지 테스트한다.

### LottoNumberTest

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

### PurchaseAmountTest

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

### WinningStatisticsTest

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

## 설계 의도

로또 번호와 구입 금액을 각각 `LottoNumber`, `PurchaseAmount` 로 포장했습니다.
이를 통해 로또 번호 범위 검증과 구입 금액 검증 책임을 객체가 관리하도록 했습니다.

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

당첨 결과는 `Rank`와 `WinningStatistics`를 통해 계산하도록 했습니다.
`Rank`는 일치 개수에 따른 등수와 당첨 금액을 관리하고, `WinningStatistics`는 등수별 개수와 총 당첨금, 수익률 계산을 담당하도록 구성했습니다.

입력과 출력은 `InputView`, `OutputView`로 분리했습니다.
또한 출력에 필요한 데이터는 `WinningResult`로 별도 전달하여 뷰가 도메인 객체를 직접 해석하지 않도록 구성했습니다.
16 changes: 16 additions & 0 deletions src/main/java/Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import controller.LottoController;
import domain.LottoShop;
import generator.RandomNumberGenerator;
import view.InputView;
import view.OutputView;

public class Application {
public static void main(String[] args) {
InputView inputView = new InputView();
OutputView outputView = new OutputView();
LottoShop lottoShop = new LottoShop(new RandomNumberGenerator());

LottoController lottoController = new LottoController(inputView, outputView, lottoShop);
lottoController.run();
}
}
41 changes: 41 additions & 0 deletions src/main/java/controller/LottoController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package controller;

import domain.*;
import view.InputView;
import view.OutputView;

import java.util.List;

public class LottoController {
private final InputView inputView;
private final OutputView outputView;
private final LottoShop lottoShop;

public LottoController(InputView inputView, OutputView outputView, LottoShop lottoShop) {
this.inputView = inputView;
this.outputView = outputView;
this.lottoShop = lottoShop;
}

public void run() {
PurchaseAmount purchaseAmount = new PurchaseAmount(inputView.readAmount());
Lottos lottos = lottoShop.purchase(purchaseAmount);

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

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

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

outputView.printWinningStatistics(winningStatistics.winningResults());
outputView.printProfitRate(winningStatistics.calculateProfitRate(purchaseAmount));
}

private List<LottoNumber> toLottoNumbers(List<Integer> numbers) {
return numbers.stream()
.map(LottoNumber::new)
.toList();
}
}
46 changes: 46 additions & 0 deletions src/main/java/domain/Lotto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package domain;

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

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

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

public int countMatch(Lotto winningLotto) {
return (int) numbers.stream()
.filter(winningLotto::contains)
.count();
}

public List<LottoNumber> getNumbers() {
return List.copyOf(numbers);
}

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

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

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

private void validateDuplicate(List<LottoNumber> numbers) {
long distinctCount = numbers.stream().distinct().count();
if (distinctCount != LOTTO_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
}
}
}
20 changes: 20 additions & 0 deletions src/main/java/domain/LottoNumber.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package domain;

public record LottoNumber(int number) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LottoNumber 를 record class 방식으로 작성해주셨어요 이를 선택하신 이유가 있으실까요?

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.

LottoNumber는 변경하지 않는 객체라고 판단해 record를 사용했습니다. 또한 record를 사용하면 단순 접근자 같은 반복되는 코드를 줄일 수 있다고 생각했습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

대현님만의 record 사용 이유를 설명해주셔서 좋네요~

맞습니다, record에서는 다음과 같은 특징이 있지요:

  • 모든 필드에 자동으로 private final 선언
  • getter 자동 생성

그러면 이 특징을 그대로 class로 옮겨보면 이런 모습이 될 텐데요:

public final class LottoNumber {
    private static final int MIN_NUMBER = 1;
    private static final int MAX_NUMBER = 45;
    private final int number;

    public LottoNumber(int number) {
        validate(number);
        this.number = number;
    }

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

    private void validateRange(int number) {
        if (number < MIN_NUMBER || number > MAX_NUMBER) {
            throw new IllegalArgumentException("로또 번호는 1부터 45 사이여야 합니다.");
        }
    }

    public int number() {
        return number;
    }
}

이렇게 하면 대현님이 작성하진 로직이 이전과 동일하게 동작할까요?

Copy link
Copy Markdown
Author

@Kdahyn Kdahyn Apr 2, 2026

Choose a reason for hiding this comment

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

처음에는 동일하게 작동하지 않을까? 라고 생각했는데 실제로 넣어보니 테스트 코드에서 오류가 발생했네요.
그 이유를 확인해보니 record는 equals()hashCode()를 자동으로 제공해서 값 기반 비교가 가능하다는 차이가 있었습니다. 이번 기회에 자바가 값을 어떻게 비교하는지도 알게 됐습니다!

private static final int MIN_NUMBER = 1;
private static final int MAX_NUMBER = 45;

public LottoNumber {
validate(number);
}

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

private void validateRange(int number) {
if (number < MIN_NUMBER || number > MAX_NUMBER) {
throw new IllegalArgumentException("로또 번호는 1부터 45 사이여야 합니다.");
}
}
}
26 changes: 26 additions & 0 deletions src/main/java/domain/LottoShop.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package domain;

import generator.NumberGenerator;

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

public class LottoShop {
private final NumberGenerator numberGenerator;

public LottoShop(NumberGenerator numberGenerator) {
this.numberGenerator = numberGenerator;
}

public Lottos purchase(PurchaseAmount purchaseAmount) {
return new Lottos(createLottos(purchaseAmount));
}

private List<Lotto> createLottos(PurchaseAmount purchaseAmount) {
List<Lotto> lottos = new ArrayList<>();
for (int i = 0; i < purchaseAmount.calculateLottoCount(); i++) {
lottos.add(new Lotto(numberGenerator.generate()));
}
return lottos;
}
}
32 changes: 32 additions & 0 deletions src/main/java/domain/Lottos.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package domain;

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

public class Lottos {
private final List<Lotto> lottos;

public Lottos(List<Lotto> lottos) {
this.lottos = new ArrayList<>(lottos);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

코드를 보니 방어적 복사 방식을 두 가지 사용하고 계시네요! 여기서는 new ArrayList<>()를, Lotto 클래스에서는 Collections.unmodifiableList()를 쓰셨는데, 각각의 차이와 그렇게 선택하신 이유가 있을까요?

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.

시간을 나눠서 코드를 짜다보니 제각각으로 복사를 진행했네요...😶 둘의 차이를 명확히 모르던 터라 이번 기회에 new ArrayList<>()는 별도의 새 리스트를 만드는 방법이고 Collections.unmodifiableList()는 읽기전용으로 보이게만 한 방법이라는 점을 알게됐어요!
new ArrayList<>() 가 더 적절하다 생각하여 new ArrayList<>()로 통일했습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

new ArrayList<>() 방식의 방어적 복사를 사용해서 외부와의 참조를 끊어내셨군요 👍

}

public int size() {
return lottos.size();
}

public List<List<Integer>> toNumberLists() {
return lottos.stream()
.map(this::toNumbers)
.toList();
}

public List<Lotto> lottoToList() {
return new ArrayList<>(lottos);
}

private List<Integer> toNumbers(Lotto lotto) {
return lotto.getNumbers().stream()
.map(LottoNumber::number)
.toList();
}
}
22 changes: 22 additions & 0 deletions src/main/java/domain/PurchaseAmount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package domain;

public record PurchaseAmount(int amount) {
private static final int LOTTO_PRICE = 1000;

public PurchaseAmount {
validateAmount(amount);
}

private void validateAmount(int amount) {
if (amount < LOTTO_PRICE) {
throw new IllegalArgumentException("구입 금액은 1000원 이상이어야 합니다.");
}
if (amount % LOTTO_PRICE != 0) {
throw new IllegalArgumentException("구입 금액은 1000원 단위여야 합니다.");
}
}

public int calculateLottoCount() {
return amount / LOTTO_PRICE;
}
}
38 changes: 38 additions & 0 deletions src/main/java/domain/Rank.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package domain;

import java.util.Arrays;

public enum Rank {
THREE_MATCH(3, 5000),
FOUR_MATCH(4, 50000),
FIVE_MATCH(5, 1500000),
SIX_MATCH(6, 2000000000),
MISS(0, 0);

private final int matchCount;
private final int prizeMoney;

Rank(int matchCount, int prizeMoney) {
this.matchCount = matchCount;
this.prizeMoney = prizeMoney;
}

public static Rank from(int matchCount) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

matchCount를 필드로 잘 설계해두셨네요! 이미 각 Rank가 matchCount를 알고 있으니, from() 메서드에서도 이 필드를 활용해볼 수 있지 않을까요? 지금처럼 if 분기로 하나씩 비교하면 Rank가 늘어날 때마다 분기문도 함께 늘어나서 관리가 어려워질 수 있을 것 같아서요!

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.

이제 보니 if 분기로 나누는 것이 꽤나 비효율적이네요. stream으로 Rank를 순회해 matchCount에 해당하는 Rank를 찾는 방식으로 수정했습니다.

return Arrays.stream(values())
.filter(rank -> rank.matchCount == matchCount)
.findFirst()
.orElse(MISS);
}

public boolean isWinning() {
return this != MISS;
}

public int getPrizeMoney() {
return prizeMoney;
}

public int getMatchCount() {
return matchCount;
}
}
Loading