Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

---

## 결과
3 changes: 0 additions & 3 deletions __tests__/LottoTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ describe("로또 클래스 테스트", () => {
}).toThrow("[ERROR]");
});

// TODO: 테스트가 통과하도록 프로덕션 코드 구현
test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => {
expect(() => {
new Lotto([1, 2, 3, 4, 5, 5]);
}).toThrow("[ERROR]");
});

// TODO: 추가 기능 구현에 따른 테스트 코드 작성
});
62 changes: 62 additions & 0 deletions docs/ERRORS.md
Original file line number Diff line number Diff line change
@@ -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]`를 호출한다.
28 changes: 28 additions & 0 deletions docs/TROBLE_SHOOTING.md
Original file line number Diff line number Diff line change
@@ -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` 클래스는 다른 클래스의 구현에 의존하지 않는 '독립적인 객체'가 되었으며, 단위 테스트를 성공적으로 통과시켰다.
30 changes: 29 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -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;
46 changes: 46 additions & 0 deletions src/Constants.js
Original file line number Diff line number Diff line change
@@ -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: "보너스 번호는 당첨 번호와 중복될 수 없습니다.",
});
45 changes: 45 additions & 0 deletions src/InputManager.js
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 12 additions & 1 deletion src/Lotto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
55 changes: 55 additions & 0 deletions src/LottoMachine.js
Original file line number Diff line number Diff line change
@@ -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;
45 changes: 45 additions & 0 deletions src/OutputManager.js
Original file line number Diff line number Diff line change
@@ -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;
Loading