Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[로또] 정남진 미션 제출합니다. #106

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7057a26
docs: README 작성
ErroredPasta Oct 30, 2024
63fb5f3
feat: 사용자로 부터 금액 입력 받기
ErroredPasta Oct 31, 2024
9f24f32
docs: 누락된 요구 사항 추가
ErroredPasta Nov 1, 2024
f2293a8
feat: 올바른 결과값이 나올 때까지 반복해서 호출하는 util 함수 구현
ErroredPasta Nov 2, 2024
f3bc6ee
fix: NoSuchElementException의 경우 예외는 처리하지 않도록 수정
ErroredPasta Nov 3, 2024
81bd6aa
refactor: 에러 메시지 출력 함수에서 Throwable을 받도록 수정
ErroredPasta Nov 3, 2024
2a7f6a8
fix: 에러 메세지를 stdout으로 출력하도록 변경
ErroredPasta Nov 3, 2024
dadc342
docs: 로또 생성에서 출력에 관한 내용 및 예외 사항 추가
ErroredPasta Nov 3, 2024
54d1611
feat: 입력받은 금액에 해당하는 만큼 로또를 생성 후 출력
ErroredPasta Nov 4, 2024
b4ed4c7
refactor: Lotto와 LottoFactory의 패키지 위치 변경
ErroredPasta Nov 4, 2024
19d2412
feat: 당첨 번호 입력을 받도록 구현
ErroredPasta Nov 4, 2024
a1b9671
docs: 보너스 번호에 관한 예외 사항 추가
ErroredPasta Nov 4, 2024
428fedc
feat: 보너스 당첨 번호를 입력받도록 구현
ErroredPasta Nov 4, 2024
cdfffd2
refactor: Ui를 OutputView와 InputView로 나눔
ErroredPasta Nov 4, 2024
5f53524
refactor: 프로그램의 흐름을 전반적으로 관리할 LottoApp 생성
ErroredPasta Nov 4, 2024
a425938
feat: 로또 결과를 계산하여 출력
ErroredPasta Nov 4, 2024
4c840f2
feat: 수익률 출력
ErroredPasta Nov 4, 2024
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
122 changes: 122 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,123 @@
# kotlin-lotto-precourse

## 프로젝트 개요

이 프로젝트는 간단한 로또 발매기를 구현한 것입니다. 사용자가 입력한 금액에 따라 로또 번호를 발행하고, 당첨 번호를 입력받아 당첨 결과와 수익률을 계산합니다.

---

## 기능 요구 사항

1. **금액 입력**
- 금액을 정수형으로 입력하지 않으면 예외 발생
- 금액이 1000원으로 나누어떨어지지 않으면 예외 발생
- 금액이 0 이하일 경우 예외 발생
2. **금액에 따른 로또 번호 생성 및 출력**
- 금액을 1000원으로 나눈만큼 랜덤으로 로또 번호를 생성
- 몇개의 로또를 구매했는지와 각각의 로또 번호를 오름차순으로 정렬하여 출력
- 하나의 로또 번호는 중복 없이 랜덤으로 6개 생성
- 유효한 로또 번호는 1부터 45 사이의 숫자
- 유효 범위 외의 숫자가 존재하거나 6개가 아닌 경우 예외 발생
3. **로또 당첨 번호 입력**
- 로또 당첨 번호는 쉼표(,)로 구분된 6개의 숫자
- 위의 양식을 지키지 않는 경우 예외 발생
- 당첨 번호 중 1부터 45 사이의 숫자가 아닌 것이 있으면 예외 발생
4. **보너스 번호 입력**
- 당첨 번호와 마찬가지로 1부터 45 사이의 숫자가 아닌 것이 있으면 예외 발생
- 보너스 번호와 당첨 번호가 중복이 될 경우 예외 발생
5. **결과 출력**
- 생성된 로또와 당첨 번호로 당첨 결과 출력
6. **수익률 출력**
- `당첨 금액 / 로또 구매 금액 * 100`으로 수익률을 계산하여 출력
- 수익률은 소수점 둘째 자리에서 반올림
7. **예외 처리**
- 사용자가 잘못된 값을 입력시 알맞은 예외를 발생시키고 에러 메시지 출력 후 입력을 다시 받음
- 에러 메시지는 "[ERROR]"로 시작

---

## 입출력 요구 사항

### 입력

- **구입 금액** : 1,000원 단위로 입력, 1,000으로 나누어 떨어지지 않는 경우 예외 처리
```plaintext
14000
```

- **당첨 번호** : 쉼표(,)로 구분된 6개의 숫자 입력

```plaintext
1,2,3,4,5,6
```

- **보너스 번호** : 하나의 숫자 입력

```plaintext
7
```

### 출력

- **발행 로또 번호** : 구매한 로또 수량과 각 번호를 오름차순으로 출력

```plaintext
8개를 구매했습니다.
[8, 21, 23, 41, 42, 43]
[3, 5, 11, 16, 32, 38]
[7, 11, 16, 35, 36, 44]
[1, 8, 11, 31, 41, 42]
[13, 14, 16, 38, 42, 45]
[7, 11, 30, 40, 42, 43]
[2, 13, 22, 32, 38, 45]
[1, 3, 5, 14, 22, 45]
```

- **당첨 내역 및 수익률**: 각 당첨 등수별 일치 개수와 수익률 출력

```plaintext
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.
```

- 에러 메시지: 유효하지 않은 값 입력 시 에러 메시지 출력

```plaintext
[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.
```

### 실행 예시

```plaintext
구입금액을 입력해 주세요.
8000

8개를 구매했습니다.
[8, 21, 23, 41, 42, 43]
[3, 5, 11, 16, 32, 38]
[7, 11, 16, 35, 36, 44]
[1, 8, 11, 31, 41, 42]
[13, 14, 16, 38, 42, 45]
[7, 11, 30, 40, 42, 43]
[2, 13, 22, 32, 38, 45]
[1, 3, 5, 14, 22, 45]

당첨 번호를 입력해 주세요.
1,2,3,4,5,6

보너스 번호를 입력해 주세요.
7

당첨 통계
---
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.
```
10 changes: 9 additions & 1 deletion src/main/kotlin/lotto/Application.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
package lotto

import lotto.data.random.pickLottoNumbers
import lotto.domain.model.factory.LottoFactory
import lotto.ui.Ui

fun main() {
// TODO: 프로그램 구현
val ui = Ui()
val lottoFactory = LottoFactory(lottoNumberGenerator = ::pickLottoNumbers)
val app = LottoApp(inputView = ui, outputView = ui, lottoFactory = lottoFactory)

app.run()
}
9 changes: 0 additions & 9 deletions src/main/kotlin/lotto/Lotto.kt

This file was deleted.

41 changes: 41 additions & 0 deletions src/main/kotlin/lotto/LottoApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package lotto

import lotto.domain.model.Lotto
import lotto.domain.model.LottoWinPlace
import lotto.domain.model.factory.LottoFactory
import lotto.ui.InputView
import lotto.ui.OutputView
import lotto.util.keepCallingForSuccessResult

class LottoApp(
private val outputView: OutputView,
private val inputView: InputView,
private val lottoFactory: LottoFactory
) {
fun run() {
val budget = keepCallingWithDefaultOnFailure(inputView::requestBudget)

val amount = budget / Lotto.LOTTO_PRICE
outputView.displayAmount(amount)

val lottoes = lottoFactory.createLottoes(amount)
outputView.displayLottoes(lottoes)

val winningNumbers = keepCallingWithDefaultOnFailure(inputView::requestWinningNumbers)

Choose a reason for hiding this comment

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

이 부분에서 :: 로 참조를 사용하신거같은데 왜 참조로 사용하셨는지 알 수 있을까요?

Copy link
Author

Choose a reason for hiding this comment

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

습관적으로 참조를 사용했네요.
Jetpack Compose에서 람다를 생성해서 넘겨주게 되면 recomposition이 필요하지 않은 상황에서도 발생하는 경우가 있어서 습관적으로 Compose에서 사용할 때 처럼 사용했습니다. trailing lambda를 이용하여 호출하여도 괜찮을 거 같습니다.

val bonusWinningNumber = keepCallingWithDefaultOnFailure { inputView.requestBonusWinningNumber(winningNumbers) }

val lottoWinPlaces = lottoes.calculateAllLottoWinPlaces(winningNumbers, bonusWinningNumber)
outputView.displayLottoResults(lottoWinPlaces, budget)
}

private fun List<Lotto>.calculateAllLottoWinPlaces(
winningNumbers: List<Int>,
bonusWinningNumber: Int
): Map<LottoWinPlace, Int> =
mapNotNull { it.calculateWinPlace(winningNumbers, bonusWinningNumber) }.groupingBy { it }.eachCount()

private fun <R> keepCallingWithDefaultOnFailure(actionToCall: () -> Result<R>): R = keepCallingForSuccessResult(
onFailure = outputView::displayExceptionMessage,
actionToCall = actionToCall,
)
}
10 changes: 10 additions & 0 deletions src/main/kotlin/lotto/data/random/LottoNumberGeneratorImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package lotto.data.random

import camp.nextstep.edu.missionutils.Randoms
import lotto.domain.model.Lotto

fun pickLottoNumbers(): List<Int> = Randoms.pickUniqueNumbersInRange(
Lotto.VALID_LOTTO_NUMBER_RANGE.first,
Lotto.VALID_LOTTO_NUMBER_RANGE.last,
Lotto.VALID_LOTTO_NUMBER_LENGTH
)

Choose a reason for hiding this comment

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

data 레이어는 원격 및 로컬에 접근, 관리,처리등을 하는 레이어로 알고 있는데,
로컬이나 원격 데이터가 없는데 데이터 레이어에 해당 구현을 하신 이유가 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

While the UI layer contains UI-related state and UI logic, the data layer contains application data and business logic. The business logic is what gives value to your app—it's made of real-world business rules that determine how application data must be created, stored, and changed.
https://developer.android.com/topic/architecture/data-layer

데이터 레이어의 경우 원격 및 로컬에서 데이터를 가져오는 것 뿐만이 아니라 애플리케이션에서 사용될 데이터에 관해서 담당하는 레이어입니다.
로또 앱의 경우 랜덤한 데이터를 가져와서 로또 생성에 사용하게 되는데 이에 가장 적합한 레이어는 데이터 레이어라 생각하여 데이터 레이어에서 구현하였습니다.

Choose a reason for hiding this comment

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

아 그렇군요 애플리케이션에서 담당할 데이터도 포함되는군요!
몰랐던 사실을 많이 알게 되는거 같습니다!

11 changes: 11 additions & 0 deletions src/main/kotlin/lotto/domain/CalculateProfit.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package lotto.domain

import lotto.domain.model.LottoWinPlace

private const val PERCENT = 100

fun calculateProfitRate(budget: Int, lottoWinPlaces: Map<LottoWinPlace, Int>): Float {
val totalPrize = lottoWinPlaces.map { (lottoWinPlace, count) -> lottoWinPlace.prize * count }.sum().toFloat()

Choose a reason for hiding this comment

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

sum을 사용한 시점에서 큰 값이 될 것 같습니다!
toFloat()은 소수 계산 때문에만 사용하신 걸까요?

Copy link
Author

Choose a reason for hiding this comment

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

네 소수 계산 때문에 사용했습니다. 그리고 말씀하신 것 처럼 큰 값이 되어 overflow가 발생할 여지에 대해서는 제가 마지막 날에 급하게 구현하느라 놓친점 같네요.

return (totalPrize / budget) * PERCENT
}

18 changes: 18 additions & 0 deletions src/main/kotlin/lotto/domain/exception/ExceptionMessages.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package lotto.domain.exception

import lotto.domain.model.Lotto
import lotto.domain.model.Lotto.Companion.VALID_LOTTO_NUMBER_LENGTH
import lotto.domain.model.Lotto.Companion.VALID_LOTTO_NUMBER_RANGE

object ExceptionMessages {

Choose a reason for hiding this comment

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

이 object를 domain 패키지에 넣어둔 이유가 궁금하네요!
뭔가 common이나 util의 성격이 강하다는 생각이 들기도 해서요..!

Copy link
Author

Choose a reason for hiding this comment

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

네 말씀하신 것 처럼 common의 성격이 더 강한 것 같습니다.
원래는 해당 object는 에러 메시지를 담고 있어 성격상 ui 패키지에 넣을까 생각하다가 그렇게 되면 domain에서 ui로 의존성이 생길까 하다가 domain에 넣었던 것 같네요.

const val DEFAULT_EXCEPTION_MESSAGE = "오류가 발생 했습니다. 다시 입력해 주세요."
const val BUDGET_NOT_DIVISIBLE_BY_LOTTO_PRICE =
"금액이 로또 금액(${Lotto.LOTTO_PRICE})으로 나누어 떨어지도록 입력해주세요."
const val BUDGET_NEEDS_TO_BE_BIGGER_THAN_ZERO = "금액은 0보다 커야합니다."
const val INVALID_NUMBER_FORMAT = "올바른 숫자 형식을 입력해 주세요."
const val INVALID_LOTTO_NUMBER_LENGTH = "로또 번호는 ${VALID_LOTTO_NUMBER_LENGTH}개여야 합니다."
val NUMBER_OUT_OF_VALID_LOTTO_RANGE_EXISTS =
"로또 번호는 ${VALID_LOTTO_NUMBER_RANGE.first}와 ${VALID_LOTTO_NUMBER_RANGE.last} 사이의 값이어야 합니다."
const val DUPLICATE_NUMBER_EXISTS = "로또 번호에 중복이 없어야 합니다."
const val DUPLICATE_BONUS_WINNING_NUMBER = "보너스 당첨 번호는 당첨 번호와 중복이 될 수 없습니다."
}
29 changes: 29 additions & 0 deletions src/main/kotlin/lotto/domain/model/Lotto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package lotto.domain.model

import lotto.domain.exception.ExceptionMessages

class Lotto(private val numbers: List<Int>) {
init {
require(numbers.size == VALID_LOTTO_NUMBER_LENGTH) { ExceptionMessages.INVALID_LOTTO_NUMBER_LENGTH }
require(numbers.distinct().size == numbers.size) { ExceptionMessages.DUPLICATE_NUMBER_EXISTS }
require(numbers.all { number -> number in VALID_LOTTO_NUMBER_RANGE }) {
ExceptionMessages.NUMBER_OUT_OF_VALID_LOTTO_RANGE_EXISTS
}
}

fun calculateWinPlace(winningNumbers: List<Int>, bonusWinningNumber: Int): LottoWinPlace? {
val matchedNumbers = getMatchedNumbers(winningNumbers)
val isBonusWinningNumberMatched = bonusWinningNumber in numbers
return LottoWinPlace.findLottoWinPlace(matchedNumbers.size, isBonusWinningNumberMatched)
}

private fun getMatchedNumbers(winningNumbers: List<Int>) = numbers.toSet().intersect(winningNumbers.toSet())

override fun toString(): String = numbers.sorted().toString()

companion object {
const val LOTTO_PRICE = 1_000
const val VALID_LOTTO_NUMBER_LENGTH = 6
val VALID_LOTTO_NUMBER_RANGE = 1..45
}
}
50 changes: 50 additions & 0 deletions src/main/kotlin/lotto/domain/model/LottoWinPlace.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package lotto.domain.model

enum class LottoWinPlace(
val prize: Int,
val requiredMatchedNumberLength: Int,
val shouldBonusWinningBeNumberMatched: Boolean,
) {
FIRST_PLACE(
prize = 2_000_000_000,
requiredMatchedNumberLength = 6,
shouldBonusWinningBeNumberMatched = false
),
SECOND_PLACE(
prize = 30_000_000,
requiredMatchedNumberLength = 5,
shouldBonusWinningBeNumberMatched = true
),
THIRD_PLACE(
prize = 1_500_000,
requiredMatchedNumberLength = 5,
shouldBonusWinningBeNumberMatched = false
),
FOURTH_PLACE(
prize = 50_000,
requiredMatchedNumberLength = 4,
shouldBonusWinningBeNumberMatched = false
),
FIFTH_PLACE(
prize = 5_000,
requiredMatchedNumberLength = 3,
shouldBonusWinningBeNumberMatched = false
);

companion object {
fun findLottoWinPlace(
matchedNumberLength: Int,
isBonusWinningNumberMatched: Boolean,
): LottoWinPlace? {
var candidates = entries.filter { it.requiredMatchedNumberLength == matchedNumberLength }

if (candidates.hasMoreThanOneElement()) {
candidates = candidates.filter { it.shouldBonusWinningBeNumberMatched == isBonusWinningNumberMatched }
}

return candidates.firstOrNull()
}

private fun List<LottoWinPlace>.hasMoreThanOneElement(): Boolean = size > 1
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/lotto/domain/model/factory/LottoFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package lotto.domain.model.factory

import lotto.domain.model.Lotto
import lotto.domain.random.LottoNumberGenerator

class LottoFactory(
private val lottoNumberGenerator: LottoNumberGenerator
) {
fun createLottoes(amount: Int): List<Lotto> = List(amount) {
val lottoNumbers = lottoNumberGenerator.pickLottoNumbers()
Lotto(lottoNumbers)
}
}
5 changes: 5 additions & 0 deletions src/main/kotlin/lotto/domain/random/LottoNumberGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package lotto.domain.random

fun interface LottoNumberGenerator {
fun pickLottoNumbers(): List<Int>
}

Choose a reason for hiding this comment

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

impl이랑 다른 레이어에 있는 이유가 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

랜덤값을 이용하여 로또를 생성하는 것은 로또앱의 비즈니스 로직으로 domain 레이어에 속하지만 랜덤값을 어떻게 가져오는지에 대해서 세부사항에 대해서는 감추어서 랜덤값 라이브러리 변경에 대해서 다른 코드에 영향을 미치지 않게 하기 위해서입니다.

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

위 글의 Crossing boundaries 부분을 읽어보시면 될 거 같습니다.

Choose a reason for hiding this comment

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

아 이해 했습니다 생각보다 개념이 헷갈리는 부분이 많네요..

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package lotto.domain.validation

import lotto.domain.exception.ExceptionMessages
import lotto.domain.model.Lotto

fun validateBonusWinningNumber(bonusWinningNumber: Int, winningNumbers: List<Int>) {
require(bonusWinningNumber in Lotto.VALID_LOTTO_NUMBER_RANGE) {
ExceptionMessages.NUMBER_OUT_OF_VALID_LOTTO_RANGE_EXISTS
}
require(bonusWinningNumber !in winningNumbers) { ExceptionMessages.DUPLICATE_BONUS_WINNING_NUMBER }
}
9 changes: 9 additions & 0 deletions src/main/kotlin/lotto/domain/validation/ValidateBudget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package lotto.domain.validation

import lotto.domain.model.Lotto.Companion.LOTTO_PRICE
import lotto.domain.exception.ExceptionMessages

fun validateBudget(budget: Int) {
require(budget > 0) { ExceptionMessages.BUDGET_NEEDS_TO_BE_BIGGER_THAN_ZERO }
require(budget % LOTTO_PRICE == 0) { ExceptionMessages.BUDGET_NOT_DIVISIBLE_BY_LOTTO_PRICE }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package lotto.domain.validation

import lotto.domain.model.Lotto

fun validateWinningNumbers(winningNumbers: List<Int>) {
// 로또 생성으로 당첨 번호 검증
Lotto(winningNumbers)
}
7 changes: 7 additions & 0 deletions src/main/kotlin/lotto/ui/InputView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package lotto.ui

interface InputView {
fun requestBudget(): Result<Int>
fun requestWinningNumbers(): Result<List<Int>>
fun requestBonusWinningNumber(winningNumbers: List<Int>): Result<Int>
}
11 changes: 11 additions & 0 deletions src/main/kotlin/lotto/ui/OutputView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package lotto.ui

import lotto.domain.model.Lotto
import lotto.domain.model.LottoWinPlace

interface OutputView {
fun displayExceptionMessage(exception: Throwable)
fun displayAmount(amount: Int)
fun displayLottoes(lottoes: List<Lotto>)
fun displayLottoResults(lottoWinPlaces: Map<LottoWinPlace, Int>, budget: Int)
}
6 changes: 6 additions & 0 deletions src/main/kotlin/lotto/ui/Ui.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lotto.ui

import lotto.ui.console.ConsoleInputView
import lotto.ui.console.ConsoleOutputView

class Ui : OutputView by ConsoleOutputView(), InputView by ConsoleInputView()

Choose a reason for hiding this comment

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

혹시 이 클래스의 역할은 무엇인가요?

Copy link
Author

Choose a reason for hiding this comment

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

원래 해당 클래스에서 입출력을 모두 처리하다가 클래스가 커지는 것 같아서 InputView, OutputView로 나누었습니다. 기존의 main 함수 코드에 영향을 주지 않기 위해서 해당 클래스에 delegation을 이용해서 각각의 인터페이스를 구현하도록 했습니다.
그런데 나중에 프로그램 전반을 나타낼 LottoApp을 생성하게 되면서 해당 클래스가 쓸모가 없어져서 삭제해도 될거 같네요.

Loading