diff --git a/README.md b/README.md index 15bb106b5..e772076cb 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ # javascript-lotto-precourse + +## Functional requirements + +### 간단한 로또 발매기를 구현한다. + +- 로또 번호의 숫자 범위는 1~45까지이다. +- 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다. +- 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다. +- 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다. +- 1등: 6개 번호 일치 / 2,000,000,000원 +- 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 +- 3등: 5개 번호 일치 / 1,500,000원 +- 4등: 4개 번호 일치 / 50,000원 +- 5등: 3개 번호 일치 / 5,000원 +- 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +- 로또 1장의 가격은 1,000원이다. +- 당첨 번호와 보너스 번호를 입력받는다. +- 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +### 프로그래밍 요구 사항 + +- 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다. +- 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다. +- else를 지양한다. +- 구현한 기능에 대한 단위 테스트를 작성한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다. +- 단위 테스트 작성이 익숙하지 않다면 LottoTest를 참고하여 학습한 후 테스트를 작성한다 + +--- + +[에러 핸들링](docs/ERRORS.md) +[트러블 슈팅](docs/TROBLE_SHOOTING.md) + +--- + +## 결과 diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..bcab695a1 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -7,12 +7,9 @@ describe("로또 클래스 테스트", () => { }).toThrow("[ERROR]"); }); - // TODO: 테스트가 통과하도록 프로덕션 코드 구현 test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 5]); }).toThrow("[ERROR]"); }); - - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 }); diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 000000000..20265d6e7 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,62 @@ +## 에러 핸들러 + +### 구입 금액 유효성 검사 + +**요구사항** + +- 구입 금액은 1,000원 단위여야 한다. +- 구입 금액은 1,000원 이상이어야 한다. +- 숫자가 아닌 값(문자, 공백 등)이 입력되면 예외 처리한다. + +**구현** + +- 사용자의 원본 입력값( `input` )을 기준으로 검사한다. +- `input.trim() === ""`인지 확인하여, 공백임을 먼저 `[ERROR]` 처리한다. +- `Number(input)`를 사용해 입력값을 숫자로 변환한다. +- `isNaN(number)` 또는 `!Number.isInteger(number)`를 사용해 숫자가 아닌 값, 소수 등을 확인한다. +- `number < 1000`을 사용해 최소 금액 미만을 확인한다. +- `number % 1000 !== 0`을 사용해 1,000원 단위가 아닌 경우를 확인한다. +- 위 조건 중 하나라도 만족하면 `[ERROR]`를 호출한다. + +--- + +### 당첨 번호 유효성 검사 + +**요구사항** + +- 로또 번호는 6개여야 한다. +- 번호는 쉼표(,)를 기준으로 구분한다. +- 각 번호는 1~45 범위의 숫자여야 한다. +- 중복된 숫자가 있으면 안 된다. +- 숫자가 아닌 값(문자, 공백 등)이 포함되면 예외 처리한다. + +**구현** + +- `input.split(",")`을 사용해 입력값을 배열로 분리한다. +- `numbers.length !== 6`인지 확인하여 6개가 아니면 `[ERROR]`를 호출한다. +- `new Set(numbers).size !== 6`인지 확인하여 중복된 번호가 있으면 `[ERROR]`를 호출한다. +- 배열을 순회하며 각 번호( `numStr` )에 대해 `trim()` 후 숫자로 변환(`Number()`)한다. +- `isNaN(number)` 또는 `!Number.isInteger(number)`인지 확인한다. +- `number < 1` 또는 `number > 45`인지 확인하여 1~45 범위를 벗어나는지 확인한다. +- 위 조건 중 하나라도 만족하면 `[ERROR]`를 호출한다. + +--- + +### 보너스 번호 유효성 검사 + +**요구사항** + +- 보너스 번호는 1개여야 한다. +- 번호는 1~45 범위의 숫자여야 한다. +- 숫자가 아닌 값(문자, 공백 등)이 입력되면 예외 처리한다. +- 이미 입력된 당첨 번호와 중복되면 안 된다. + +**구현** + +- 사용자의 원본 입력값( `input` )을 기준으로 검사한다. +- `input.trim() === ""`인지 확인하여, 공백임을 먼저 `[ERROR]` 처리한다. +- `Number(input)`를 사용해 입력값을 숫자로 변환한다. +- `isNaN(number)` 또는 `!Number.isInteger(number)`인지 확인한다. +- `number < 1` 또는 `number > 45`인지 확인하여 1~45 범위를 벗어나는지 확인한다. +- `winningNumbers.includes(number)`를 사용해 당첨 번호와 중복되는지 확인한다. +- 위 조건 중 하나라도 만족하면 `[ERROR]`를 호출한다. diff --git a/docs/TROBLE_SHOOTING.md b/docs/TROBLE_SHOOTING.md new file mode 100644 index 000000000..ba42473ef --- /dev/null +++ b/docs/TROBLE_SHOOTING.md @@ -0,0 +1,28 @@ +### 1. 'else' 지양 및 함수 분리 요구사항에 따른 리팩토링 + +**1. 상황** +당첨 등수(1등~5등, 미당첨)를 판별하는 로직을 구현해야 했다. 6개 일치, 5개+보너스 일치, 5개 일치 등 조건이 복잡하여, 처음에는 `if-else if-else` 구문이 겹겹이 쌓여 15라인을 초과하고 "else를 지양한다"는 요구사항을 위반했다. + +**2. 분석** +"else 지양" 요구사항의 핵심은 "함수(메서드)가 한 가지 일만 하도록" 유도하고, "불필요한 코드의 깊이(indent)를 줄이는 것"에 있다고 판단했다. `else` 구문은 종종 해당 함수가 여러 갈래의 책임을 한 번에 처리하려 할 때 발생한다. `if` 조건에 해당할 경우, 즉시 값을 `return` 시키면 `else`로 묶을 필요가 없어진다고 파악했다. + +**3. 해결** +`WinningLotto` 클래스에 `calculateRank` 메서드를 만들고, **Early Return"** 패턴을 적용했다. + +1. `matchCount`와 `hasBonus`라는 명확한 변수를 먼저 계산했다. +2. 가장 까다로운 조건(1등)부터 `if`문으로 검사하고, 해당하면 즉시 `RANK.FIRST`를 `return` 했다. +3. `else` 없이 다음 `if`문에서 2등을 검사하고 즉시 `return` 했다. +4. 3등, 4등, 5등도 동일하게 처리하고, 모든 `if` 조건을 통과했다면 마지막 줄에서 `return RANK.NONE;`을 실행했다. 결과적으로 `else`를 모두 제거하고 15라인 이내의 간결한 메서드를 완성했다. + +--- + +### 2. 클래스 협력 및 단위 테스트에 따른 책임 분리 + +**1. 상황** +`LottoTest.js` 단위 테스트는 `new Lotto([...])`가 호출되는 시점에 **길이(6개), 중복, 1~45 범위** 검증을 통과하지 못하면 에러를 발생시킬 것을 요구했다. 하지만 초기 구현에서는 이 유효성 검사 로직을 `Lotto` 클래스가 아닌 입력을 받는 `InputManager`나 `Validation` 유틸리티 클래스에 두려고 시도했다. + +**2. 분석** +단위 테스트는 `Lotto` 클래스 자체가 **'스스로 유효성을 보장(self-validating)'**해야 함을 명확히 보여주었다. 만약 `InputManager`가 검증 책임을 모두 갖는다면, `Lotto` 클래스는 `InputManager`가 항상 올바른 값만 넘겨줄 것이라 "신뢰"해야만 하는 **높은 결합도**가 발생한다고 판단했다. `Lotto` 객체는 생성 시점부터 스스로의 유효성을 증명해야 객체의 **응집도**가 높아진다고 결론 내렸다. + +**3. 해결** +`Lotto` 클래스의 `constructor`가 비공개 메서드인 `#validate`를 호출하도록 책임을 위임했다. `#validate` 메서드 내부에 **길이, 중복, 1~45 범위**를 검사하는 모든 로직을 구현했다. 결과적으로 `Lotto` 클래스는 다른 클래스의 구현에 의존하지 않는 '독립적인 객체'가 되었으며, 단위 테스트를 성공적으로 통과시켰다. diff --git a/src/App.js b/src/App.js index 091aa0a5d..31bc96903 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,33 @@ +import InputManager from "./InputManager.js"; +import OutputManager from "./OutputManager.js"; +import LottoMachine from "./LottoMachine.js"; +import { Console } from "@woowacourse/mission-utils"; + class App { - async run() {} + #lottoMachine; + + constructor() { + this.#lottoMachine = new LottoMachine(); + } + + async run() { + const purchaseAmount = await InputManager.getPurchaseAmount(); + const lottos = this.#lottoMachine.issueLottos(purchaseAmount); + OutputManager.printPurchasedLottos(lottos); + + const winningLotto = await InputManager.getWinningLotto(); + const statistics = this.#lottoMachine.calculateStatistics( + lottos, + winningLotto + ); + + const totalReturnRate = this.#lottoMachine.calculateTotalReturnRate( + purchaseAmount, + statistics + ); + + OutputManager.printStatistics(statistics, totalReturnRate); + } } export default App; diff --git a/src/Constants.js b/src/Constants.js new file mode 100644 index 000000000..10a4df1e9 --- /dev/null +++ b/src/Constants.js @@ -0,0 +1,46 @@ +export const LOTTO = Object.freeze({ + MIN_NUMBER: 1, + MAX_NUMBER: 45, + NUMBER_COUNT: 6, + PRICE: 1000, +}); + +export const RANK = Object.freeze({ + FIRST: "FIRST", + SECOND: "SECOND", + THIRD: "THIRD", + FOURTH: "FOURTH", + FIFTH: "FIFTH", + NONE: "NONE", +}); + +export const PRIZE_MONEY = Object.freeze({ + [RANK.FIRST]: 2_000_000_000, + [RANK.SECOND]: 30_000_000, + [RANK.THIRD]: 1_500_000, + [RANK.FOURTH]: 50_000, + [RANK.FIFTH]: 5_000, + [RANK.NONE]: 0, +}); + +export const MESSAGES = Object.freeze({ + INPUT_AMOUNT: "구입금액을 입력해 주세요.\n", + INPUT_WINNING: "\n당첨 번호를 입력해 주세요.\n", + INPUT_BONUS: "\n보너스 번호를 입력해 주세요.\n", + PURCHASE_COUNT: "개를 구매했습니다.", + STATS_HEADER: "\n당첨 통계", + STATS_DIVIDER: "---", + ROI: (rate) => `총 수익률은 ${rate}%입니다.`, +}); + +export const ERROR = Object.freeze({ + PREFIX: "[ERROR]", + AMOUNT_UNIT: `구입 금액은 ${LOTTO.PRICE}원 단위로 입력해야 합니다.`, + AMOUNT_MIN: `구입 금액은 ${LOTTO.PRICE}원 이상이어야 합니다.`, + AMOUNT_NOT_NUMBER: "구입 금액은 유효한 숫자여야 합니다.", + NUMBER_NOT_NUMBER: "로또 번호는 유효한 숫자여야 합니다.", + NUMBER_COUNT: "로또 번호는 6개여야 합니다.", + NUMBER_DUPLICATE: "로또 번호는 중복될 수 없습니다.", + NUMBER_RANGE: `로또 번호는 ${LOTTO.MIN_NUMBER}부터 ${LOTTO.MAX_NUMBER} 사이의 숫자여야 합니다.`, + BONUS_DUPLICATE: "보너스 번호는 당첨 번호와 중복될 수 없습니다.", +}); diff --git a/src/InputManager.js b/src/InputManager.js new file mode 100644 index 000000000..9821def81 --- /dev/null +++ b/src/InputManager.js @@ -0,0 +1,45 @@ +import { Console } from "@woowacourse/mission-utils"; +import Validation from "./Validation.js"; +import WinningLotto from "./WinningLotto.js"; +import { MESSAGES } from "./Constants.js"; + +class InputManager { + static async #retryValidation(asyncInputFunction) { + while (true) { + try { + return await asyncInputFunction(); + } catch (error) { + Console.print(error.message); + } + } + } + + static async getPurchaseAmount() { + return this.#retryValidation(async () => { + const input = await Console.readLineAsync(MESSAGES.INPUT_AMOUNT); + return Validation.validatePurchaseAmount(input); + }); + } + + static async getWinningNumbers() { + return this.#retryValidation(async () => { + const input = await Console.readLineAsync(MESSAGES.INPUT_WINNING); + return Validation.validateWinningNumbers(input); + }); + } + + static async getBonusNumber(winningNumbers) { + return this.#retryValidation(async () => { + const input = await Console.readLineAsync(MESSAGES.INPUT_BONUS); + return Validation.validateBonusNumber(input, winningNumbers); + }); + } + + static async getWinningLotto() { + const winningNumbers = await this.getWinningNumbers(); + const bonusNumber = await this.getBonusNumber(winningNumbers); + return new WinningLotto(winningNumbers, bonusNumber); + } +} + +export default InputManager; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..0788b2385 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -10,9 +10,20 @@ class Lotto { if (numbers.length !== 6) { throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); } + + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error("[ERROR] 로또 번호에 중복된 숫자가 있으면 안 됩니다."); + } } - // TODO: 추가 기능 구현 + getNumbers() { + return this.#numbers.slice().sort((a, b) => a - b); + } + + contains(number) { + return this.#numbers.includes(number); + } } export default Lotto; diff --git a/src/LottoMachine.js b/src/LottoMachine.js new file mode 100644 index 000000000..5a10c7eaf --- /dev/null +++ b/src/LottoMachine.js @@ -0,0 +1,55 @@ +import { Random } from "@woowacourse/mission-utils"; +import Lotto from "./Lotto.js"; +import { LOTTO, RANK, PRIZE_MONEY } from "./Constants.js"; + +class LottoMachine { + issueLottos(purchaseAmount) { + const count = purchaseAmount / LOTTO.PRICE; + const lottos = []; + for (let i = 0; i < count; i++) { + lottos.push(this.#createLotto()); + } + return lottos; + } + + #createLotto() { + const numbers = Random.pickUniqueNumbersInRange( + LOTTO.MIN_NUMBER, + LOTTO.MAX_NUMBER, + LOTTO.NUMBER_COUNT + ); + return new Lotto(numbers); + } + + calculateStatistics(lottos, winningLotto) { + const statistics = { + [RANK.FIRST]: 0, + [RANK.SECOND]: 0, + [RANK.THIRD]: 0, + [RANK.FOURTH]: 0, + [RANK.FIFTH]: 0, + [RANK.NONE]: 0, + }; + + lottos.forEach((lotto) => { + const rank = winningLotto.calculateRank(lotto); + statistics[rank]++; + }); + + return statistics; + } + + calculateTotalReturnRate(purchaseAmount, statistics) { + let totalPrize = 0; + for (const rank in PRIZE_MONEY) { + if (rank !== RANK.NONE) { + totalPrize += statistics[rank] * PRIZE_MONEY[rank]; + } + } + + const rate = (totalPrize / purchaseAmount) * 100; + return rate.toFixed(1); + } +} + +export default LottoMachine; diff --git a/src/OutputManager.js b/src/OutputManager.js new file mode 100644 index 000000000..4fcc09ce3 --- /dev/null +++ b/src/OutputManager.js @@ -0,0 +1,45 @@ +import { Console } from "@woowacourse/mission-utils"; +import { RANK, PRIZE_MONEY, MESSAGES } from "./Constants.js"; + +class OutputManager { + static printPurchasedLottos(lottos) { + Console.print(`\n${lottos.length}${MESSAGES.PURCHASE_COUNT}`); + lottos.forEach((lotto) => { + Console.print(`[${lotto.getNumbers().join(", ")}]`); + }); + } + + static printStatistics(statistics, totalReturnRate) { + Console.print(MESSAGES.STATS_HEADER); + Console.print(MESSAGES.STATS_DIVIDER); + + const rankOrder = [ + RANK.FIFTH, + RANK.FOURTH, + RANK.THIRD, + RANK.SECOND, + RANK.FIRST, + ]; + + rankOrder.forEach((rank) => { + const message = this.#getRankMessage(rank, statistics[rank]); + Console.print(message); + }); + + Console.print(MESSAGES.ROI(totalReturnRate)); + } + + static #getRankMessage(rank, count) { + const prize = PRIZE_MONEY[rank].toLocaleString(); + const messages = { + [RANK.FIFTH]: `3개 일치 (${prize}원) - ${count}개`, + [RANK.FOURTH]: `4개 일치 (${prize}원) - ${count}개`, + [RANK.THIRD]: `5개 일치 (${prize}원) - ${count}개`, + [RANK.SECOND]: `5개 일치, 보너스 볼 일치 (${prize}원) - ${count}개`, + [RANK.FIRST]: `6개 일치 (${prize}원) - ${count}개`, + }; + return messages[rank]; + } +} + +export default OutputManager; diff --git a/src/Validation.js b/src/Validation.js new file mode 100644 index 000000000..25a6c9b6f --- /dev/null +++ b/src/Validation.js @@ -0,0 +1,62 @@ +import { LOTTO, ERROR } from "./Constants.js"; + +class Validation { + static #throwError(message) { + throw new Error(`${ERROR.PREFIX} ${message}`); + } + + static validatePurchaseAmount(input) { + const amount = this.#parseNumber(input, ERROR.AMOUNT_NOT_NUMBER); + if (amount < LOTTO.PRICE) this.#throwError(ERROR.AMOUNT_MIN); + if (amount % LOTTO.PRICE !== 0) this.#throwError(ERROR.AMOUNT_UNIT); + return amount; + } + + static validateWinningNumbers(input) { + const numbers = input.split(",").map((numStr) => { + return this.#parseNumber(numStr.trim(), ERROR.NUMBER_NOT_NUMBER); + }); + this.validateLottoNumbers(numbers); + return numbers; + } + + static validateBonusNumber(input, winningNumbers) { + const number = this.#parseNumber(input, ERROR.NUMBER_NOT_NUMBER); + this.validateNumberRange(number); + this.validateBonusDuplication(number, winningNumbers); + return number; + } + + static validateLottoNumbers(numbers) { + if (numbers.length !== LOTTO.NUMBER_COUNT) { + this.#throwError(ERROR.NUMBER_COUNT); + } + if (new Set(numbers).size !== LOTTO.NUMBER_COUNT) { + this.#throwError(ERROR.NUMBER_DUPLICATE); + } + numbers.forEach(this.validateNumberRange.bind(this)); + } + + static validateNumberRange(number) { + if (number < LOTTO.MIN_NUMBER || number > LOTTO.MAX_NUMBER) { + this.#throwError(ERROR.NUMBER_RANGE); + } + } + + static validateBonusDuplication(bonusNumber, winningNumbers) { + if (winningNumbers.includes(bonusNumber)) { + this.#throwError(ERROR.BONUS_DUPLICATE); + } + } + + static #parseNumber(input, errorMessage) { + if (input.trim() === "") this.#throwError(errorMessage); + const number = Number(input); + if (isNaN(number) || !Number.isInteger(number)) { + this.#throwError(errorMessage); + } + return number; + } +} + +export default Validation; diff --git a/src/WinningLotto.js b/src/WinningLotto.js new file mode 100644 index 000000000..b49deda76 --- /dev/null +++ b/src/WinningLotto.js @@ -0,0 +1,36 @@ +import Lotto from "./Lotto.js"; +import Validation from "./Validation.js"; +import { RANK } from "./Constants.js"; + +class WinningLotto { + #lotto; + #bonusNumber; + + constructor(winningNumbers, bonusNumber) { + this.#lotto = new Lotto(winningNumbers); + Validation.validateBonusDuplication(bonusNumber, winningNumbers); + this.#bonusNumber = bonusNumber; + } + + calculateRank(userLotto) { + const matchCount = this.#countMatches(userLotto); + const hasBonus = userLotto.contains(this.#bonusNumber); + + if (matchCount === 6) return RANK.FIRST; + if (matchCount === 5 && hasBonus) return RANK.SECOND; + if (matchCount === 5) return RANK.THIRD; + if (matchCount === 4) return RANK.FOURTH; + if (matchCount === 3) return RANK.FIFTH; + return RANK.NONE; + } + + #countMatches(userLotto) { + const userNumbers = userLotto.getNumbers(); + const winningNumbers = this.#lotto.getNumbers(); + + return userNumbers.filter((number) => winningNumbers.includes(number)) + .length; + } +} + +export default WinningLotto;