diff --git a/README.md b/README.md
index 15bb106b5..22e336b9d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,64 @@
# javascript-lotto-precourse
+
+## 기능 개요
+
+1. 로또 구매
+2. 자동 로또 생성
+3. 로또 추첨
+4. 당첨 결과 및 수익률 출력
+
+## 세부 기능 목록
+
+1. 구매 금액 입력
+ - 빈 문자열 예외
+ - 1000원 단위가 아니면 예외
+
+
+
+2. 구매 금액을 기준으로 로또 번호 생성
+3. 구매한 로또 개수와 생성된 로또 번호 출력
+
+
+
+4. 당첨 번호 입력
+ - 빈 문자열 예외
+ - 번호가 쉼표로 구분되지 않으면 예외
+ - 쉼표로 구분된 번호가 6개가 아니면 예외
+ - 1-45 사이 숫자가 아니면 예외
+ - 중복 숫자가 있으면 예외
+5. 보너스 번호 입력
+ - 빈 문자열 예외
+ - 1-45 사이 숫자가 아니면 예외
+ - 당첨 번호 중 하나라도 중복되면 예외
+
+
+
+6. 랜덤 생성된 로또 번호들마다 당첨 여부 확인
+7. 총 수익률 계산
+8. 당첨 통계(1-5등 개수, 총 수익률) 출력
+
+## 폴더 구조
+
+```bash
+src
+├─ constants
+│ ├─ errorMessages.js
+│ ├─ inputMessages.js
+│ ├─ lotto.js
+│ ├─ rank.js
+│ └─ unit.js
+├─ controller
+│ └─ LottoController.js // 전체 로또 추첨 흐름 제어
+├─ domains
+│ └─ Lotto.js // 단일 로또 상태 관리
+├─ models
+│ ├─ DrawnNumbers.js // 당첨 번호 + 보너스 번호 상태 관리
+│ ├─ LottoDrawer.js // DrawnNumbers -> Lottos 상태 업데이트
+│ └─ Lottos.js // 사용자 로또 번호 상태 관리
+├─ utils
+│ ├─ error.js
+│ └─ Formatter.js // 출력문 형식 관리
+└─ view
+ ├─ InputView.js // 사용자 입력 처리
+ └─ OutputView.js // 출력 처리
+```
diff --git a/__tests__/DrawnNumbersTest.js b/__tests__/DrawnNumbersTest.js
new file mode 100644
index 000000000..aa69cd9bc
--- /dev/null
+++ b/__tests__/DrawnNumbersTest.js
@@ -0,0 +1,101 @@
+import {
+ BONUS_NUMBER_ERROR_MESSAGES,
+ WINNING_NUMBER_ERROR_MESSAGES,
+} from "../src/constants/errorMessages.js";
+import { RANK } from "../src/constants/rank.js";
+import Lotto from "../src/domains/Lotto.js";
+import DrawnNumbers from "../src/models/DrawnNumbers.js";
+
+describe("DrawnNumbers 클래스", () => {
+ describe("생성자 테스트", () => {
+ test("당첨 번호 입력이 없으면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("", "1");
+ }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ });
+
+ test("당첨 번호 입력이 공백이면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers(" ", "1");
+ }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ });
+
+ test("당첨 번호가 쉼표 외 구분자로 분리되어 있으면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1. 2. 3. 4. 5. 6", "1");
+ }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.DELIMITER);
+ });
+
+ test("보너스 번호 입력이 없으면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1, 2, 3, 4, 5, 6", "");
+ }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ });
+
+ test("보너스 번호 입력이 공백이면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1, 2, 3, 4, 5, 6", " ");
+ }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ });
+
+ test("보너스 번호가 1보다 작으면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1, 2, 3, 4, 5, 6", "0");
+ }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.MIN_VALUE);
+ });
+
+ test("보너스 번호가 45보다 크면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1, 2, 3, 4, 5, 6", "46");
+ }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.MAX_VALUE);
+ });
+
+ test("보너스 번호가 이미 당첨 번호에 포함되어 있으면 예외가 발생한다.", () => {
+ expect(() => {
+ new DrawnNumbers("1, 2, 3, 4, 5, 6", "1");
+ }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.UNIQUE);
+ });
+ });
+
+ describe("calculateRank 메서드 테스트", () => {
+ test("당첨 번호와 로또 번호가 6개 모두 일치하는 경우 1등을 반환한다.", () => {
+ const lottoInstance = new Lotto([1, 2, 3, 4, 5, 6]);
+ const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7");
+ expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe(
+ RANK.FIRST
+ );
+ });
+
+ test("당첨 번호와 로또 번호가 5개 일치하고, 로또 번호에 보너스 번호가 포함되어 있는 경우 2등을 반환한다.", () => {
+ const lottoInstance = new Lotto([1, 2, 3, 4, 5, 7]);
+ const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7");
+ expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe(
+ RANK.SECOND
+ );
+ });
+
+ test("당첨 번호와 로또 번호가 5개 일치하는 경우 3등을 반환한다.", () => {
+ const lottoInstance = new Lotto([1, 2, 3, 4, 5, 8]);
+ const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7");
+ expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe(
+ RANK.THIRD
+ );
+ });
+
+ test("당첨 번호와 로또 번호가 4개 일치하는 경우 4등을 반환한다.", () => {
+ const lottoInstance = new Lotto([1, 2, 3, 4, 7, 8]);
+ const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7");
+ expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe(
+ RANK.FOURTH
+ );
+ });
+
+ test("당첨 번호와 로또 번호가 3개 일치하는 경우 5등을 반환한다.", () => {
+ const lottoInstance = new Lotto([1, 2, 3, 7, 8, 9]);
+ const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7");
+ expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe(
+ RANK.FIFTH
+ );
+ });
+ });
+});
diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js
index 409aaf69b..70b5e500b 100644
--- a/__tests__/LottoTest.js
+++ b/__tests__/LottoTest.js
@@ -1,18 +1,62 @@
-import Lotto from "../src/Lotto";
+import { LOTTO_ERROR_MESSAGES } from "../src/constants/errorMessages.js";
+import Lotto from "../src/domains/Lotto.js";
-describe("로또 클래스 테스트", () => {
- test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => {
- expect(() => {
- new Lotto([1, 2, 3, 4, 5, 6, 7]);
- }).toThrow("[ERROR]");
+describe("Lotto 클래스", () => {
+ describe("생성자 테스트", () => {
+ test("로또 번호의 개수가 6개 미만이면 예외가 발생한다.", () => {
+ expect(() => {
+ new Lotto([1, 2, 3, 4, 5]);
+ }).toThrow(LOTTO_ERROR_MESSAGES.LENGTH);
+ });
+
+ test("로또 번호의 개수가 6개를 넘어가면 예외가 발생한다.", () => {
+ expect(() => {
+ new Lotto([1, 2, 3, 4, 5, 6, 7]);
+ }).toThrow(LOTTO_ERROR_MESSAGES.LENGTH);
+ });
+
+ test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => {
+ expect(() => {
+ new Lotto([1, 2, 3, 4, 5, 5]);
+ }).toThrow(LOTTO_ERROR_MESSAGES.UNIQUE);
+ });
+
+ test("로또 번호에 1보다 작은 숫자가 있으면 예외가 발생한다.", () => {
+ expect(() => {
+ new Lotto([0, 2, 3, 4, 5, 6]);
+ }).toThrow(LOTTO_ERROR_MESSAGES.MIN_VALUE);
+ });
+
+ test("로또 번호에 45보다 큰 숫자가 있으면 예외가 발생한다.", () => {
+ expect(() => {
+ new Lotto([1, 2, 3, 4, 5, 46]);
+ }).toThrow(LOTTO_ERROR_MESSAGES.MAX_VALUE);
+ });
});
- // TODO: 테스트가 통과하도록 프로덕션 코드 구현
- test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => {
- expect(() => {
- new Lotto([1, 2, 3, 4, 5, 5]);
- }).toThrow("[ERROR]");
+ describe("compare 메서드 테스트", () => {
+ test("두 로또 인스턴스 사이의 공통 원소 개수를 반환한다.", () => {
+ const lotto1 = new Lotto([1, 3, 5, 7, 9, 11]);
+ const lotto2 = new Lotto([1, 2, 3, 4, 5, 6]);
+ expect(lotto2.compare(lotto1)).toBe(3);
+ });
+
+ test("두 로또 인스턴스 사이에 공통 원소가 없으면 0을 반환한다.", () => {
+ const lotto1 = new Lotto([1, 3, 5, 7, 9, 11]);
+ const lotto2 = new Lotto([2, 4, 6, 8, 10, 12]);
+ expect(lotto2.compare(lotto1)).toBe(0);
+ });
});
- // TODO: 추가 기능 구현에 따른 테스트 코드 작성
+ describe("includes 메서드 테스트", () => {
+ test("로또 인스턴스에 인자값이 포함되어 있으면 true를 반환한다.", () => {
+ const lotto = new Lotto([1, 2, 3, 4, 5, 6]);
+ expect(lotto.includes(1)).toBe(true);
+ });
+
+ test("로또 인스턴스에 인자값이 포함되어 있지 않으면 false를 반환한다.", () => {
+ const lotto = new Lotto([1, 2, 3, 4, 5, 6]);
+ expect(lotto.includes(7)).toBe(false);
+ });
+ });
});
diff --git a/__tests__/LottosTest.js b/__tests__/LottosTest.js
new file mode 100644
index 000000000..a8814701f
--- /dev/null
+++ b/__tests__/LottosTest.js
@@ -0,0 +1,62 @@
+import { RANK, RANK_TO_PRIZE_MAP } from "../src/constants/rank.js";
+import { PURCHASE_UNIT } from "../src/constants/unit.js";
+import Lotto from "../src/domains/Lotto.js";
+import Lottos from "../src/models/Lottos.js";
+
+const PURCHASE_COUNT = 3;
+
+describe("Lottos 클래스", () => {
+ describe("생성자 테스트", () => {
+ test("입력 개수만큼의 lottos 배열을 생성한다.", () => {
+ const lottosInstance = new Lottos(PURCHASE_COUNT);
+ const lottos = lottosInstance.getLottos();
+ expect(lottos).toHaveLength(PURCHASE_COUNT);
+ });
+
+ test("lottos 배열은 Lotto 인스턴스로 구성된다.", () => {
+ const lottosInstance = new Lottos(PURCHASE_COUNT);
+ const lottos = lottosInstance.getLottos();
+ lottos.forEach((lotto) => {
+ expect(lotto).toBeInstanceOf(Lotto);
+ });
+ });
+ });
+
+ describe("win 메서드 테스트", () => {
+ test("순위에 따라 등수 통계를 업데이트한다.", () => {
+ Object.values(RANK).forEach((rank) => {
+ const lottosInstance = new Lottos(PURCHASE_COUNT);
+ lottosInstance.win(rank);
+ const ranks = lottosInstance.getRanks();
+ expect(ranks[rank]).toBe(1);
+ });
+ });
+
+ test("순위에 따라 총 상금을 업데이트한다.", () => {
+ Object.entries(RANK_TO_PRIZE_MAP).forEach(([rank, prize]) => {
+ const lottosInstance = new Lottos(PURCHASE_COUNT);
+ lottosInstance.win(rank);
+ const totalPrize = lottosInstance._getTotalPrize();
+ expect(totalPrize).toBe(prize);
+ });
+ });
+ });
+
+ describe("calculateTotalReturn 메서드 테스트", () => {
+ test("상금을 수익률 형태로 변환해 반환한다.", () => {
+ Object.entries(RANK_TO_PRIZE_MAP).forEach(([rank, prize]) => {
+ const lottosInstance = new Lottos(PURCHASE_COUNT);
+ lottosInstance.win(rank);
+
+ const purchaseAmount = PURCHASE_COUNT * PURCHASE_UNIT;
+ const expectedTotalPrize = prize;
+ const expectedTotalReturn = (
+ expectedTotalPrize / purchaseAmount
+ ).toFixed(1);
+
+ const totalReturn = lottosInstance.calculateTotalReturn();
+ expect(totalReturn).toBe(expectedTotalReturn);
+ });
+ });
+ });
+});
diff --git a/src/App.js b/src/App.js
index 091aa0a5d..68f48ef5a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,5 +1,10 @@
+import LottoController from "./controller/LottoController.js";
+
class App {
- async run() {}
+ async run() {
+ const lottoController = new LottoController();
+ lottoController.run();
+ }
}
export default App;
diff --git a/src/Lotto.js b/src/Lotto.js
deleted file mode 100644
index cb0b1527e..000000000
--- a/src/Lotto.js
+++ /dev/null
@@ -1,18 +0,0 @@
-class Lotto {
- #numbers;
-
- constructor(numbers) {
- this.#validate(numbers);
- this.#numbers = numbers;
- }
-
- #validate(numbers) {
- if (numbers.length !== 6) {
- throw new Error("[ERROR] 로또 번호는 6개여야 합니다.");
- }
- }
-
- // TODO: 추가 기능 구현
-}
-
-export default Lotto;
diff --git a/src/constants/errorMessages.js b/src/constants/errorMessages.js
new file mode 100644
index 000000000..43c10f661
--- /dev/null
+++ b/src/constants/errorMessages.js
@@ -0,0 +1,42 @@
+import { error } from "../utils/error.js";
+import { LOTTO_MAX_VALUE, LOTTO_SIZE } from "./lotto.js";
+import { PURCHASE_UNIT } from "./unit.js";
+
+export const PURCHASE_ERROR_MESSAGES = {
+ NONEMPTY: error("구입 금액을 입력해 주세요."),
+ UNIT: error(`구입 금액은 ${PURCHASE_UNIT}원 단위로 입력해야 합니다.`),
+};
+
+export const LOTTO_ERROR_MESSAGES = {
+ UNIQUE: error("로또 번호는 중복될 수 없습니다."),
+ LENGTH: error(`로또 번호는 ${LOTTO_SIZE}개여야 합니다.`),
+ MIN_VALUE: error(
+ `로또 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.`
+ ),
+ MAX_VALUE: error(
+ `로또 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.`
+ ),
+};
+
+export const WINNING_NUMBER_ERROR_MESSAGES = {
+ NONEMPTY: error("당첨 번호를 입력해 주세요."),
+ DELIMITER: error("당첨 번호는 쉼표로 구분되어야 합니다."),
+};
+
+export const BONUS_NUMBER_ERROR_MESSAGES = {
+ NONEMPTY: error("보너스 번호를 입력해 주세요."),
+ MIN_VALUE: error(
+ `보너스 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.`
+ ),
+ MAX_VALUE: error(
+ `보너스 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.`
+ ),
+ UNIQUE: error("이미 당첨 번호에 포함된 번호입니다."),
+};
+
+export const ERROR_MESSAGES = {
+ PURCHASE: PURCHASE_ERROR_MESSAGES,
+ LOTTO: LOTTO_ERROR_MESSAGES,
+ WINNING_NUMBER: WINNING_NUMBER_ERROR_MESSAGES,
+ BONUS_NUMBER: BONUS_NUMBER_ERROR_MESSAGES,
+};
diff --git a/src/constants/inputMessages.js b/src/constants/inputMessages.js
new file mode 100644
index 000000000..76acccc9b
--- /dev/null
+++ b/src/constants/inputMessages.js
@@ -0,0 +1,5 @@
+export const INPUT_MESSAGES = {
+ PURCHASE_AMOUNT: "구입금액을 입력해 주세요.",
+ WINNING_NUMBER: "당첨 번호를 입력해 주세요.",
+ BONUS_NUMBER: "보너스 번호를 입력해 주세요.",
+};
diff --git a/src/constants/lotto.js b/src/constants/lotto.js
new file mode 100644
index 000000000..e2051631e
--- /dev/null
+++ b/src/constants/lotto.js
@@ -0,0 +1,3 @@
+export const LOTTO_MIN_VALUE = 1;
+export const LOTTO_MAX_VALUE = 45;
+export const LOTTO_SIZE = 6;
diff --git a/src/constants/rank.js b/src/constants/rank.js
new file mode 100644
index 000000000..7239f6164
--- /dev/null
+++ b/src/constants/rank.js
@@ -0,0 +1,23 @@
+export const RANK = {
+ FIRST: "1st",
+ SECOND: "2nd",
+ THIRD: "3rd",
+ FOURTH: "4th",
+ FIFTH: "5th",
+};
+
+export const RANK_TO_PRIZE_MAP = {
+ [RANK.FIRST]: 2000000000,
+ [RANK.SECOND]: 30000000,
+ [RANK.THIRD]: 1500000,
+ [RANK.FOURTH]: 50000,
+ [RANK.FIFTH]: 5000,
+};
+
+export const RANK_TO_MATCH_STRING_MAP = {
+ [RANK.FIRST]: "6개 일치",
+ [RANK.SECOND]: "5개 일치, 보너스 볼 일치",
+ [RANK.THIRD]: "5개 일치",
+ [RANK.FOURTH]: "4개 일치",
+ [RANK.FIFTH]: "3개 일치",
+};
diff --git a/src/constants/unit.js b/src/constants/unit.js
new file mode 100644
index 000000000..4da292a5f
--- /dev/null
+++ b/src/constants/unit.js
@@ -0,0 +1 @@
+export const PURCHASE_UNIT = 1000;
diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js
new file mode 100644
index 000000000..1e65ce2b4
--- /dev/null
+++ b/src/controller/LottoController.js
@@ -0,0 +1,103 @@
+import { PURCHASE_ERROR_MESSAGES } from "../constants/errorMessages.js";
+import { INPUT_MESSAGES } from "../constants/inputMessages.js";
+import { PURCHASE_UNIT } from "../constants/unit.js";
+import LottoDrawer from "../models/LottoDrawer.js";
+import Lottos from "../models/Lottos.js";
+import Formatter from "../utils/Formatter.js";
+import InputView from "../view/InputView.js";
+import OutputView from "../view/OutputView.js";
+
+class LottoController {
+ #inputView;
+ #outputView;
+ #formatter;
+
+ constructor() {
+ this.#inputView = new InputView();
+ this.#outputView = new OutputView();
+ this.#formatter = new Formatter();
+ }
+
+ async run() {
+ const purchaseCount = await this.#readPurchaseCount();
+ const lottos = new Lottos(purchaseCount);
+ this.#printInputResult(purchaseCount, lottos);
+
+ const { winningNumber, bonusNumber } = await this.#readDrawnNumbers();
+ const lottoDrawer = new LottoDrawer(winningNumber, bonusNumber);
+ lottoDrawer.run(lottos);
+
+ this.#printResult(lottos);
+ }
+
+ async #readPurchaseCount() {
+ const purchaseAmount = await this.#inputView.readLineAsync(
+ INPUT_MESSAGES.PURCHASE_AMOUNT
+ );
+ this.#validatePurchaseAmount(purchaseAmount);
+
+ const purchaseCount = purchaseAmount / PURCHASE_UNIT;
+ return purchaseCount;
+ }
+
+ #validatePurchaseAmount(purchaseAmount) {
+ if (purchaseAmount === "") {
+ throw new Error(PURCHASE_ERROR_MESSAGES.NONEMPTY);
+ }
+
+ if (purchaseAmount % PURCHASE_UNIT !== 0) {
+ throw new Error(PURCHASE_ERROR_MESSAGES.UNIT);
+ }
+ }
+
+ async #readDrawnNumbers() {
+ const winningNumber = await this.#inputView.readLineAsync(
+ INPUT_MESSAGES.WINNING_NUMBER
+ );
+ const bonusNumber = await this.#inputView.readLineAsync(
+ INPUT_MESSAGES.BONUS_NUMBER
+ );
+
+ return { winningNumber, bonusNumber };
+ }
+
+ #printInputResult(purchaseCount, lottos) {
+ this.#printPurchaseCount(purchaseCount);
+ this.#printLottos(lottos);
+ }
+
+ #printPurchaseCount(purchaseCount) {
+ const formattedPurchaseCount =
+ this.#formatter.formatPurchaseCount(purchaseCount);
+ this.#outputView.print(formattedPurchaseCount);
+ }
+
+ #printLottos(lottos) {
+ const lottoArray = lottos.getLottos();
+ const formattedLottos = this.#formatter.formatLottos(lottoArray);
+ this.#outputView.print(formattedLottos);
+ }
+
+ #printResult(lottos) {
+ const ranks = lottos.getRanks();
+ const totalReturn = lottos.calculateTotalReturn();
+
+ this.#outputView.print();
+ this.#outputView.print("당첨 통계");
+ this.#outputView.print("---");
+ this.#printRankResult(ranks);
+ this.#printTotalReturn(totalReturn);
+ }
+
+ #printRankResult(ranks) {
+ const formattedRankResult = this.#formatter.formatRankResult(ranks);
+ this.#outputView.print(formattedRankResult);
+ }
+
+ #printTotalReturn(totalReturn) {
+ const formattedTotalReturn = this.#formatter.formatTotalReturn(totalReturn);
+ this.#outputView.print(formattedTotalReturn);
+ }
+}
+
+export default LottoController;
diff --git a/src/domains/Lotto.js b/src/domains/Lotto.js
new file mode 100644
index 000000000..ec474c475
--- /dev/null
+++ b/src/domains/Lotto.js
@@ -0,0 +1,47 @@
+import { LOTTO_ERROR_MESSAGES } from "../constants/errorMessages.js";
+import {
+ LOTTO_MAX_VALUE,
+ LOTTO_MIN_VALUE,
+ LOTTO_SIZE,
+} from "../constants/lotto.js";
+
+class Lotto {
+ #numbers;
+
+ constructor(numbers) {
+ this.#validate(numbers);
+ this.#numbers = numbers;
+ }
+
+ #validate(numbers) {
+ if (numbers.length !== LOTTO_SIZE)
+ throw new Error(LOTTO_ERROR_MESSAGES.LENGTH);
+
+ const numberSet = new Set(numbers);
+ if (numbers.length > numberSet.size)
+ throw new Error(LOTTO_ERROR_MESSAGES.UNIQUE);
+
+ if (numbers.some((num) => num < LOTTO_MIN_VALUE))
+ throw new Error(LOTTO_ERROR_MESSAGES.MIN_VALUE);
+
+ if (numbers.some((num) => num > LOTTO_MAX_VALUE))
+ throw new Error(LOTTO_ERROR_MESSAGES.MAX_VALUE);
+ }
+
+ compare(anotherLotto) {
+ return this.#numbers.reduce(
+ (total, num) => (anotherLotto.includes(num) ? total + 1 : total),
+ 0
+ );
+ }
+
+ includes(num) {
+ return this.#numbers.includes(num);
+ }
+
+ getNumbers() {
+ return this.#numbers;
+ }
+}
+
+export default Lotto;
diff --git a/src/models/DrawnNumbers.js b/src/models/DrawnNumbers.js
new file mode 100644
index 000000000..fb889f22e
--- /dev/null
+++ b/src/models/DrawnNumbers.js
@@ -0,0 +1,71 @@
+import {
+ BONUS_NUMBER_ERROR_MESSAGES,
+ WINNING_NUMBER_ERROR_MESSAGES,
+} from "../constants/errorMessages.js";
+import { LOTTO_MAX_VALUE, LOTTO_MIN_VALUE } from "../constants/lotto.js";
+import { RANK } from "../constants/rank.js";
+import Lotto from "../domains/Lotto.js";
+
+class DrawnNumbers {
+ #winningNumbers;
+ #bonusNumber;
+
+ constructor(numbersString, bonusNumberString) {
+ this.#winningNumbers = this.#createWinningNumbers(numbersString);
+ this.#validateBonusNumber(bonusNumberString);
+ this.#bonusNumber = Number(bonusNumberString);
+ }
+
+ calculateRank(lotto) {
+ const matchCount = lotto.compare(this.#winningNumbers);
+ const hasBonus = lotto.includes(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 undefined;
+ }
+
+ #createWinningNumbers(numbersString) {
+ this.#validateWinningNumbers(numbersString);
+ const winningNumberArray = numbersString.split(",").map(Number);
+ return new Lotto(winningNumberArray);
+ }
+
+ #validateWinningNumbers(numbersString) {
+ const trimmedString = numbersString.trim();
+ if (trimmedString === "") {
+ throw new Error(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ }
+
+ const format = /^\s*\d+(\s*,\s*\d+)*\s*$/;
+ if (!format.test(trimmedString)) {
+ throw new Error(WINNING_NUMBER_ERROR_MESSAGES.DELIMITER);
+ }
+ }
+
+ #validateBonusNumber(bonusNumberString) {
+ const trimmedString = bonusNumberString.trim();
+ if (trimmedString === "") {
+ throw new Error(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY);
+ }
+
+ const bonusNumber = Number(trimmedString);
+
+ if (bonusNumber < LOTTO_MIN_VALUE) {
+ throw new Error(BONUS_NUMBER_ERROR_MESSAGES.MIN_VALUE);
+ }
+
+ if (bonusNumber > LOTTO_MAX_VALUE) {
+ throw new Error(BONUS_NUMBER_ERROR_MESSAGES.MAX_VALUE);
+ }
+
+ if (this.#winningNumbers.includes(bonusNumber)) {
+ throw new Error(BONUS_NUMBER_ERROR_MESSAGES.UNIQUE);
+ }
+ }
+}
+
+export default DrawnNumbers;
diff --git a/src/models/LottoDrawer.js b/src/models/LottoDrawer.js
new file mode 100644
index 000000000..2629f4f5a
--- /dev/null
+++ b/src/models/LottoDrawer.js
@@ -0,0 +1,19 @@
+import DrawnNumbers from "./DrawnNumbers.js";
+
+class LottoDrawer {
+ #drawnNumbers;
+
+ constructor(winningNumbers, bonusNumber) {
+ this.#drawnNumbers = new DrawnNumbers(winningNumbers, bonusNumber);
+ }
+
+ run(lottos) {
+ const lottoArray = lottos.getLottos();
+ lottoArray.forEach((lotto) => {
+ const rank = this.#drawnNumbers.calculateRank(lotto);
+ if (rank) lottos.win(rank);
+ });
+ }
+}
+
+export default LottoDrawer;
diff --git a/src/models/Lottos.js b/src/models/Lottos.js
new file mode 100644
index 000000000..c940abadb
--- /dev/null
+++ b/src/models/Lottos.js
@@ -0,0 +1,62 @@
+import { Random } from "@woowacourse/mission-utils";
+import { RANK, RANK_TO_PRIZE_MAP } from "../constants/rank.js";
+import { PURCHASE_UNIT } from "../constants/unit.js";
+import {
+ LOTTO_MAX_VALUE,
+ LOTTO_MIN_VALUE,
+ LOTTO_SIZE,
+} from "../constants/lotto.js";
+import Lotto from "../domains/Lotto.js";
+
+class Lottos {
+ #lottos;
+ #ranks;
+ #totalPrize;
+
+ constructor(purchaseCount) {
+ this.#lottos = Array.from({ length: purchaseCount }, () =>
+ this.#createLotto()
+ );
+ this.#ranks = this.#createRankCount();
+ this.#totalPrize = 0;
+ }
+
+ #createLotto() {
+ const start = LOTTO_MIN_VALUE;
+ const end = LOTTO_MAX_VALUE;
+ const size = LOTTO_SIZE;
+ const numbers = Random.pickUniqueNumbersInRange(start, end, size);
+ return new Lotto(numbers);
+ }
+
+ #createRankCount() {
+ return Object.values(RANK).reduce((acc, cur) => ({ ...acc, [cur]: 0 }), {});
+ }
+
+ win(rank) {
+ this.#ranks[rank] += 1;
+ this.#totalPrize += RANK_TO_PRIZE_MAP[rank];
+ }
+
+ calculateTotalReturn() {
+ const purchaseCount = this.#lottos.length;
+ const purchaseAmount = purchaseCount * PURCHASE_UNIT;
+
+ const totalReturn = (this.#totalPrize / purchaseAmount).toFixed(1);
+ return totalReturn;
+ }
+
+ getLottos() {
+ return this.#lottos;
+ }
+
+ getRanks() {
+ return this.#ranks;
+ }
+
+ _getTotalPrize() {
+ return this.#totalPrize;
+ }
+}
+
+export default Lottos;
diff --git a/src/utils/Formatter.js b/src/utils/Formatter.js
new file mode 100644
index 000000000..fd8c95edc
--- /dev/null
+++ b/src/utils/Formatter.js
@@ -0,0 +1,38 @@
+import {
+ RANK_TO_MATCH_STRING_MAP,
+ RANK_TO_PRIZE_MAP,
+} from "../constants/rank.js";
+
+class Formatter {
+ formatLottoNumbers(numbers) {
+ return `[${numbers.join(", ")}]`;
+ }
+
+ formatLottos(lottoArray) {
+ const numbersArray = lottoArray.map((lotto) => lotto.getNumbers());
+ const formattedLottos = numbersArray.map((numbers) =>
+ this.formatLottoNumbers(numbers)
+ );
+ return formattedLottos.join("\n");
+ }
+
+ formatPurchaseCount(purchaseCount) {
+ return `${purchaseCount}개를 구매했습니다.`;
+ }
+
+ formatRankResult(ranks) {
+ const formattedRankResult = Object.entries(RANK_TO_PRIZE_MAP).map(
+ ([rank, prize]) =>
+ `${RANK_TO_MATCH_STRING_MAP[rank]} (${prize.toLocaleString()}원) - ${
+ ranks[rank]
+ }개`
+ );
+ return formattedRankResult.join("\n");
+ }
+
+ formatTotalReturn(totalReturn) {
+ return `총 수익률은 ${totalReturn}%입니다.`;
+ }
+}
+
+export default Formatter;
diff --git a/src/utils/error.js b/src/utils/error.js
new file mode 100644
index 000000000..e09f6c3fd
--- /dev/null
+++ b/src/utils/error.js
@@ -0,0 +1 @@
+export const error = (message) => `[ERROR] ${message}`;
diff --git a/src/view/InputView.js b/src/view/InputView.js
new file mode 100644
index 000000000..0ea587994
--- /dev/null
+++ b/src/view/InputView.js
@@ -0,0 +1,9 @@
+import { Console } from "@woowacourse/mission-utils";
+
+class InputView {
+ async readLineAsync(question) {
+ return await Console.readLineAsync(`${question}\n`);
+ }
+}
+
+export default InputView;
diff --git a/src/view/OutputView.js b/src/view/OutputView.js
new file mode 100644
index 000000000..67ecb63d5
--- /dev/null
+++ b/src/view/OutputView.js
@@ -0,0 +1,9 @@
+import { Console } from "@woowacourse/mission-utils";
+
+class OutputView {
+ print(value = "") {
+ Console.print(value);
+ }
+}
+
+export default OutputView;