diff --git a/README.md b/README.md
index e078fd41..e05a8a25 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,255 @@
-# javascript-racingcar-precourse
+# 자동차 경주 게임
+
+> SOLID 원칙과 MVC 패턴을 적용한 객체지향 자동차 경주 게임
+
+## 목차
+
+- [프로젝트 개요](#프로젝트-개요)
+- [시스템 아키텍처](#시스템-아키텍처)
+- [주요 기능](#주요-기능)
+- [예외 처리](#예외-처리)
+- [리팩토링 이력](#리팩토링-이력)
+- [최종 결과](#최종-결과)
+
+## 프로젝트 개요
+
+자동차 경주 게임은 객체지향 설계 원칙을 준수하여 구현된 콘솔 게임입니다. MVC 패턴을 적용하여 관심사를 분리하고, 단일 책임 원칙을 준수하여 유지보수성을 높였습니다.
+
+### 핵심 원칙
+- **SOLID 원칙**: 각 클래스는 단일 책임만 수행
+- **MVC 패턴**: Model-View-Controller 완전 분리
+- **Indent Depth**: 최대 2 (요구사항 준수)
+
+## 시스템 아키텍처
+
+### 파일 구조
+
+```
+src/
+├── constants/ # 상수 및 에러 메시지
+│ ├── constants.js
+│ └── ErrorMessage.js
+├── models/ # 도메인 모델
+│ └── Car.js
+├── validators/ # 검증 로직
+│ └── Validator.js
+├── views/ # UI 출력
+│ └── GameView.js
+├── controllers/ # 게임 컨트롤러
+│ └── GameController.js
+├── App.js # 메인 앱
+└── index.js # 진입점
+```
+
+### 시스템 실행 흐름
+
+
+
+### 계층 구조
+
+
+
+## 주요 기능
+
+### 입력 및 검증
+- **자동차 이름**: 쉼표로 구분, 최대 5자, 공백 불가
+- **이동 횟수**: 양의 정수만 입력 가능
+
+### 게임 로직
+- **전진 조건**: 랜덤값(0~9)이 4 이상일 때 전진
+- **실시간 결과**: 각 라운드마다 자동차 위치 출력
+- **우승자 판정**: 최대 전진 거리를 가진 자동차(들) 선정
+
+### 게임 흐름
+1. 자동차 이름 입력 및 검증
+2. 이동 횟수 입력 및 검증
+3. 자동차 객체 생성
+4. 라운드별 게임 진행 및 결과 출력
+5. 최종 우승자 판정 및 출력
+
+## 예외 처리
+
+### 검증 규칙
+
+| 분류 | 규칙 | 잘못된 예시 |
+|------|------|------------|
+| **자동차 이름** | 빈 문자열, 5자 초과, 공백 포함 불가 | `""`, `javaji`, `car 1` |
+| **이동 횟수** | 빈 문자열, 음수, 소수, 공백 포함 불가 | `""`, `0`, `-5`, `3.5`, ` 5 ` |
+
+### 검증 로직
+
+```javascript
+// 자동차 이름 검증 (세분화된 에러 메시지)
+Validator.validateCarNames(carNames);
+// 체크 항목:
+// - 빈 문자열 입력
+// - 5자 초과
+// - 공백 포함 (앞/뒤/중간)
+// - 빈 이름 포함 (쉼표로 구분된 빈 값)
+
+// 이동 횟수 검증 (세분화된 에러 메시지)
+Validator.validateMovementCount(movementCountInput);
+// 체크 항목:
+// - 빈 문자열
+// - 음수
+// - 0
+// - 소수점
+// - 공백 포함
+// - 특수문자
+```
+
+## 리팩토링 이력
+
+### 1. 코드 분리 (178줄 → 230줄)
+
+**문제점**
+- 모든 로직이 `App.js`에 집중
+- 단일 책임 원칙 위배
+- 코드 재사용성 및 테스트 어려움
+
+**해결방안**
+- 8개 파일로 분리
+- App.js: 178줄 → 35줄 (80% 감소)
+- 각 모듈의 책임 명확화
+
+**결과**
+```javascript
+// Before: 178줄
+App.js (모든 로직 포함)
+
+// After: 35줄
+App.js (GameController만 호출)
+GameController.js (게임 흐름 제어)
+GameView.js (UI 출력)
+Validator.js (검증 로직)
+Car.js (자동차 모델)
+```
+
+### 2. 에러 메시지 세분화 및 중앙 관리
+
+**문제점**
+- 에러 메시지 여러 곳에 하드코딩
+- 일반적인 에러 메시지로 구체적인 문제 파악 어려움
+
+**해결방안**
+```javascript
+// ErrorMessage.js
+export class ErrorMessage {
+ // 자동차 이름 관련 (4가지)
+ static EMPTY_CAR_NAMES = "[ERROR] 자동차 이름을 입력해주세요.";
+ static INVALID_CAR_NAME_LENGTH = "[ERROR] 자동차 이름은 5자 이하만 가능합니다.";
+ static INVALID_CAR_NAME_SPACE = "[ERROR] 자동차 이름에 공백이 포함될 수 없습니다.";
+ static EMPTY_CAR_NAME_IN_LIST = "[ERROR] 빈 자동차 이름이 포함되어 있습니다.";
+
+ // 이동 횟수 관련 (5가지)
+ static EMPTY_MOVEMENT_COUNT = "[ERROR] 시도할 횟수를 입력해주세요.";
+ static INVALID_MOVEMENT_COUNT_NEGATIVE = "[ERROR] 시도 횟수는 1 이상이어야 합니다.";
+ static INVALID_MOVEMENT_COUNT_ZERO = "[ERROR] 시도 횟수는 0보다 커야 합니다.";
+ static INVALID_MOVEMENT_COUNT_DECIMAL = "[ERROR] 시도 횟수는 정수여야 합니다.";
+ static INVALID_MOVEMENT_COUNT_SPACE = "[ERROR] 시도 횟수에 공백이 포함될 수 없습니다.";
+ static INVALID_NUMBER_FORMAT = "[ERROR] 숫자만 입력해주세요.";
+}
+```
+
+**장점**
+- 메시지 변경 시 한 파일만 수정
+- IDE 자동완성 지원
+- 구체적인 에러 원인 파악 용이
+- 사용자에게 명확한 피드백 제공
+
+### 3. MissionUtils API 수정
+
+**에러**
+```
+Error: arguments must be 1
+ at MissionUtils.Console.readLineAsync
+```
+
+**원인**
+- `readLineAsync()` 인자 중복 전달
+
+**해결**
+```javascript
+// Before
+const input = await GameView.readLine(message);
+GameView.print(message); // 중복 출력
+
+// After
+static async readLine(query) {
+ MissionUtils.Console.print(query);
+ return await MissionUtils.Console.readLineAsync("> ");
+}
+```
+
+### 4. 랜덤값 범위 오류 수정
+
+**원인**
+- 요구사항: 0~9 랜덤값, 4 이상일 때 전진
+- 잘못된 구현: 4~9만 선택
+
+**해결**
+```javascript
+// Before (잘못됨)
+const randomNumber = Random.pickNumberInRange(4, 9);
+
+// After (올바름)
+const randomNumber = Random.pickNumberInRange(0, 9);
+return randomNumber >= 4;
+```
+
+### 5. 검증 로직 통합 및 변수명 개선
+
+**문제점**
+- 검증 로직 여러 곳에 산재
+- 중복 코드 존재
+- 변수명이 애매함 (`name`, `input`, `number`)
+
+**해결방안**
+- `Validator` 클래스로 통합
+- 변수명 명확화:
+ - `name` → `carName`
+ - `trimmedName` → `trimmedCarName`
+ - `input` → `movementCountInput`
+ - `trimmedInput` → `trimmedMovementCount`
+ - `number` → `movementCount`
+- JSDoc 주석 추가
+
+**장점**
+- 변수 의미가 한눈에 파악 가능
+- 검증 로직이 명확하게 이해됨
+- 코드 가독성 향상
+
+## 최종 결과
+
+### 코드 메트릭
+
+| 항목 | 값 |
+|------|-----|
+| 총 라인 수 | 297줄 (8개 파일) |
+| App.js 라인 수 | 14줄 (최소화) |
+| 평균 파일 크기 | ~37줄 |
+| Indent Depth | 최대 2 |
+| 테스트 통과율 | 16/16 (100%) |
+
+### 디자인 패턴
+
+- **Repository Pattern**: `ErrorMessage.js`, `constants.js` - 데이터 중앙 관리
+- **Strategy Pattern**: `Validator.js` - 검증 전략 캡슐화
+- **MVC Pattern**: Model(Car) - View(GameView) - Controller(GameController)
+- **Facade Pattern**: `App.js` - 복잡한 시스템 단순화
+
+### 테스트 결과
+
+**기능 테스트 (4개)**
+- 단일 라운드에서 단독 우승자 결정
+- 여러 라운드 경주 진행
+- 공동 우승자 결정
+- 3대의 자동차가 다른 거리 이동
+
+**예외 처리 테스트 (12개)**
+- 자동차 이름 검증 (5개): 5자 초과, 빈 문자열, 공백 포함, 앞뒤 공백, 빈 이름 포함
+- 이동 횟수 검증 (7개): 빈 문자열, 숫자 아님, 음수, 0, 소수, 공백 포함, 특수문자
+
+**BDD 패턴 적용**
+- Given-When-Then 주석으로 테스트 구조 명확화
+- 모든 테스트 케이스에 일관된 구조 적용
diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js
index 0260e7e8..1fce7309 100644
--- a/__tests__/ApplicationTest.js
+++ b/__tests__/ApplicationTest.js
@@ -1,6 +1,11 @@
import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";
+import { Car } from "../src/models/Car.js";
+/**
+ * 사용자 입력 모킹 함수
+ * @param {string[]} inputs - 입력할 값들의 배열
+ */
const mockQuestions = (inputs) => {
MissionUtils.Console.readLineAsync = jest.fn();
@@ -10,6 +15,10 @@ const mockQuestions = (inputs) => {
});
};
+/**
+ * 랜덤값 모킹 함수
+ * @param {number[]} numbers - 반환할 랜덤값들의 배열
+ */
const mockRandoms = (numbers) => {
MissionUtils.Random.pickNumberInRange = jest.fn();
@@ -18,6 +27,10 @@ const mockRandoms = (numbers) => {
}, MissionUtils.Random.pickNumberInRange);
};
+/**
+ * 출력 로그를 추적하는 스파이 객체 생성
+ * @returns {jest.SpyInstance} 콘솔 출력 스파이
+ */
const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, "print");
logSpy.mockClear();
@@ -25,36 +38,338 @@ const getLogSpy = () => {
};
describe("자동차 경주", () => {
- test("기능 테스트", async () => {
- // given
- const MOVING_FORWARD = 4;
- const STOP = 3;
- const inputs = ["pobi,woni", "1"];
- const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"];
- const logSpy = getLogSpy();
+ describe("기능 테스트", () => {
+ test("단일 라운드에서 단독 우승자 결정", async () => {
+ // given: 전진하는 자동차와 멈추는 자동차 설정
+ const MOVING_FORWARD = 4;
+ const STOP = 3;
+ const inputs = ["pobi,woni", "1"];
+ const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"];
+ const logSpy = getLogSpy();
- mockQuestions(inputs);
- mockRandoms([MOVING_FORWARD, STOP]);
+ mockQuestions(inputs);
+ mockRandoms([MOVING_FORWARD, STOP]);
- // when
- const app = new App();
- await app.run();
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
- // then
- logs.forEach((log) => {
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ // then: 예상된 출력이 정확히 호출되었는지 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
+ });
+
+ test("여러 라운드 경주 진행", async () => {
+ // given: 3라운드 진행 설정
+ const MOVING_FORWARD = 4;
+ const STOP = 3;
+ const inputs = ["pobi,jun", "3"];
+ const logs = [
+ "pobi : -", // 라운드 1: pobi 전진
+ "jun : ", // 라운드 1: jun 멈춤
+ "pobi : -", // 라운드 2: pobi 전진 (누적 2)
+ "jun : ", // 라운드 2: jun 멈춤 (누적 0)
+ "pobi : -", // 라운드 3: pobi 전진 (누적 3)
+ "jun : ", // 라운드 3: jun 멈춤 (누적 0)
+ "최종 우승자 : pobi"
+ ];
+ const logSpy = getLogSpy();
+
+ mockQuestions(inputs);
+ // pobi는 항상 전진, jun은 항상 멈춤
+ mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, STOP, MOVING_FORWARD, STOP]);
+
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
+
+ // then: 각 라운드별 결과와 최종 우승자 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
+ });
+
+ test("공동 우승자 결정", async () => {
+ // given: 두 자동차가 같은 거리만큼 전진
+ const MOVING_FORWARD = 4;
+ const inputs = ["pobi,jun", "2"];
+ const logs = [
+ "pobi : -",
+ "jun : -",
+ "pobi : --",
+ "jun : --",
+ "최종 우승자 : pobi, jun"
+ ];
+ const logSpy = getLogSpy();
+
+ mockQuestions(inputs);
+ // 두 자동차 모두 항상 전진
+ mockRandoms([MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD]);
+
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
+
+ // then: 공동 우승자가 쉼표로 구분되어 출력되는지 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
+ });
+
+ test("3대의 자동차가 다른 거리 이동", async () => {
+ // given: 3대의 자동차 경주
+ const MOVING_FORWARD = 4;
+ const STOP = 3;
+ const inputs = ["pobi,woni,jun", "3"];
+ const logs = [
+ // 라운드 1: pobi 전진, woni 멈춤, jun 전진 → pobi:1, woni:0, jun:1
+ "pobi : -",
+ "woni : ",
+ "jun : -",
+ // 라운드 2: pobi 멈춤, woni 전진, jun 전진 → pobi:1, woni:1, jun:2
+ "pobi : -",
+ "woni : -",
+ "jun : --",
+ // 라운드 3: pobi 전진, woni 전진, jun 전진 → pobi:2, woni:2, jun:3
+ "pobi : --",
+ "woni : --",
+ "jun : ---",
+ "최종 우승자 : jun"
+ ];
+ const logSpy = getLogSpy();
+
+ mockQuestions(inputs);
+ // 라운드 1: pobi 전진(4), woni 멈춤(3), jun 전진(4)
+ // 라운드 2: pobi 멈춤(3), woni 전진(4), jun 전진(4)
+ // 라운드 3: pobi 전진(4), woni 전진(4), jun 전진(4)
+ mockRandoms([
+ MOVING_FORWARD, STOP, MOVING_FORWARD,
+ STOP, MOVING_FORWARD, MOVING_FORWARD,
+ MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD
+ ]);
+
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
+
+ // then: 각 자동차의 위치와 공동 우승자 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
+ });
+
+ test("중복된 자동차 이름이 있는 경우 구분하여 처리", async () => {
+ // given: 중복된 이름이 있는 자동차들
+ Car.resetIdCounter(); // ID 카운터 리셋
+ const MOVING_FORWARD = 4;
+ const STOP = 3;
+ const inputs = ["pobi,pobi,jun", "2"];
+ const logs = [
+ "pobi#1 : -", // 첫 번째 pobi 전진
+ "pobi#2 : ", // 두 번째 pobi 멈춤
+ "jun : -", // jun 전진
+ "pobi#1 : -", // 첫 번째 pobi 전진 (누적 1)
+ "pobi#2 : ", // 두 번째 pobi 멈춤 (누적 0)
+ "jun : -", // jun 전진 (누적 1)
+ "최종 우승자 : pobi#1, jun" // 공동 우승
+ ];
+ const logSpy = getLogSpy();
+
+ mockQuestions(inputs);
+ // 첫 번째 pobi는 전진, 두 번째 pobi는 멈춤, jun은 전진
+ mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, MOVING_FORWARD, STOP, MOVING_FORWARD]);
+
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
+
+ // then: 중복 이름이 ID로 구분되어 출력되는지 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
+ });
+
+ test("중복된 자동차 이름이 있는 경우 구분하여 처리", async () => {
+ // given: 중복된 이름이 있는 자동차들
+ Car.resetIdCounter(); // ID 카운터 리셋
+ const MOVING_FORWARD = 4;
+ const STOP = 3;
+ const inputs = ["pobi,pobi,jun", "2"];
+ const logs = [
+ "pobi#1 : -", // 첫 번째 pobi 전진
+ "pobi#2 : ", // 두 번째 pobi 멈춤
+ "jun : -", // jun 전진
+ "pobi#1 : -", // 첫 번째 pobi 전진 (누적 1)
+ "pobi#2 : ", // 두 번째 pobi 멈춤 (누적 0)
+ "jun : -", // jun 전진 (누적 1)
+ "최종 우승자 : pobi#1, jun" // 공동 우승
+ ];
+ const logSpy = getLogSpy();
+
+ mockQuestions(inputs);
+ // 첫 번째 pobi는 전진, 두 번째 pobi는 멈춤, jun은 전진
+ mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, MOVING_FORWARD, STOP, MOVING_FORWARD]);
+
+ // when: 게임 실행
+ const app = new App();
+ await app.run();
+
+ // then: 중복 이름이 ID로 구분되어 출력되는지 확인
+ logs.forEach((log) => {
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
+ });
});
});
- test("예외 테스트", async () => {
- // given
- const inputs = ["pobi,javaji"];
- mockQuestions(inputs);
+ describe("예외 처리 테스트", () => {
+ describe("자동차 이름 검증", () => {
+ test("자동차 이름이 5자 초과인 경우", async () => {
+ // given: 6자 이름 입력
+ const inputs = ["pobi,javaji"];
+ mockQuestions(inputs);
- // when
- const app = new App();
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름은 5자 이하만 가능합니다.");
+ });
- // then
- await expect(app.run()).rejects.toThrow("[ERROR]");
+ test("빈 문자열 입력", async () => {
+ // given: 빈 문자열 입력
+ const inputs = [""];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름을 입력해주세요.");
+ });
+
+ test("자동차 이름에 공백이 포함된 경우", async () => {
+ // given: 공백이 포함된 이름 입력
+ const inputs = ["po bi"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름에 공백이 포함될 수 없습니다.");
+ });
+
+ test("자동차 이름 앞뒤에 공백이 있는 경우", async () => {
+ // given: 앞뒤 공백이 포함된 이름 입력
+ const inputs = [" pobi "];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름에 공백이 포함될 수 없습니다.");
+ });
+
+ test("빈 이름이 포함된 경우", async () => {
+ // given: 쉼표로 구분된 빈 값 입력
+ const inputs = ["pobi,,jun"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 빈 자동차 이름이 포함되어 있습니다.");
+ });
+
+ });
+
+ describe("이동 횟수 검증", () => {
+ test("빈 문자열 입력", async () => {
+ // given: 자동차 이름은 정상, 이동 횟수는 빈 문자열
+ const inputs = ["pobi,jun", ""];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 시도할 횟수를 입력해주세요.");
+ });
+
+ test("숫자가 아닌 값 입력", async () => {
+ // given: 숫자가 아닌 문자 입력
+ const inputs = ["pobi,jun", "abc"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 숫자만 입력해주세요.");
+ });
+
+ test("음수 입력", async () => {
+ // given: 음수 입력
+ const inputs = ["pobi,jun", "-5"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 1 이상이어야 합니다.");
+ });
+
+ test("0 입력", async () => {
+ // given: 0 입력
+ const inputs = ["pobi,jun", "0"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 0보다 커야 합니다.");
+ });
+
+ test("소수점 입력", async () => {
+ // given: 소수 입력
+ const inputs = ["pobi,jun", "3.5"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 정수여야 합니다.");
+ });
+
+ test("이동 횟수에 공백 포함", async () => {
+ // given: 공백이 포함된 숫자 입력
+ const inputs = ["pobi,jun", " 5 "];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수에 공백이 포함될 수 없습니다.");
+ });
+
+ test("특수문자 입력", async () => {
+ // given: 특수문자 입력
+ const inputs = ["pobi,jun", "@"];
+ mockQuestions(inputs);
+
+ // when: 앱 실행
+ const app = new App();
+
+ // then: 에러 발생 확인
+ await expect(app.run()).rejects.toThrow("[ERROR] 숫자만 입력해주세요.");
+ });
+ });
});
});
diff --git a/docs/architecture-diagram.mmd b/docs/architecture-diagram.mmd
new file mode 100644
index 00000000..820f5763
--- /dev/null
+++ b/docs/architecture-diagram.mmd
@@ -0,0 +1,34 @@
+graph TB
+ subgraph "메인 계층"
+ App["App
메인 앱"]
+ Index["index.js
진입점"]
+ end
+
+ subgraph "컨트롤러 계층"
+ GC["GameController
게임 흐름 제어"]
+ end
+
+ subgraph "도메인 계층"
+ Car["Car
자동차 모델"]
+ end
+
+ subgraph "뷰 계층"
+ GV["GameView
UI 출력"]
+ end
+
+ subgraph "유틸리티 계층"
+ Validator["Validator
검증 로직"]
+ Constants["constants.js
상수 정의"]
+ ErrorMsg["ErrorMessage
에러 메시지"]
+ end
+
+ Index --> App
+ App --> GC
+ GC --> GV
+ GC --> Validator
+ GC --> Car
+ GV --> Validator
+ Validator --> Constants
+ Validator --> ErrorMsg
+ Car --> Constants
+
diff --git a/docs/architecture-diagram.png b/docs/architecture-diagram.png
new file mode 100644
index 00000000..b8b72a9c
Binary files /dev/null and b/docs/architecture-diagram.png differ
diff --git a/docs/sequence-diagram.mmd b/docs/sequence-diagram.mmd
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/sequence-diagram.png b/docs/sequence-diagram.png
new file mode 100644
index 00000000..07ea2168
Binary files /dev/null and b/docs/sequence-diagram.png differ
diff --git a/src/App.js b/src/App.js
index 091aa0a5..85c49154 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,5 +1,14 @@
+import { GameController } from "./controllers/GameController.js";
+
+/**
+ * 메인 애플리케이션
+ * 애플리케이션 진입점 역할
+ */
class App {
- async run() {}
+ async run() {
+ const controller = new GameController();
+ await controller.run();
+ }
}
export default App;
diff --git a/src/constants/ErrorMessage.js b/src/constants/ErrorMessage.js
new file mode 100644
index 00000000..7644dce5
--- /dev/null
+++ b/src/constants/ErrorMessage.js
@@ -0,0 +1,19 @@
+// 에러 메시지 관리
+export class ErrorMessage {
+ // 자동차 이름 관련 에러
+ static EMPTY_CAR_NAMES = "[ERROR] 자동차 이름을 입력해주세요.";
+ static INVALID_CAR_NAME_LENGTH = "[ERROR] 자동차 이름은 5자 이하만 가능합니다.";
+ static INVALID_CAR_NAME_SPACE = "[ERROR] 자동차 이름에 공백이 포함될 수 없습니다.";
+ static EMPTY_CAR_NAME_IN_LIST = "[ERROR] 빈 자동차 이름이 포함되어 있습니다.";
+ static DUPLICATE_CAR_NAME = "[ERROR] 중복된 자동차 이름이 있습니다.";
+
+ // 이동 횟수 관련 에러
+ static EMPTY_MOVEMENT_COUNT = "[ERROR] 시도할 횟수를 입력해주세요.";
+ static INVALID_NUMBER_FORMAT = "[ERROR] 숫자만 입력해주세요.";
+ static INVALID_MOVEMENT_COUNT_NEGATIVE = "[ERROR] 시도 횟수는 1 이상이어야 합니다.";
+ static INVALID_MOVEMENT_COUNT_ZERO = "[ERROR] 시도 횟수는 0보다 커야 합니다.";
+ static INVALID_MOVEMENT_COUNT_DECIMAL = "[ERROR] 시도 횟수는 정수여야 합니다.";
+ static INVALID_MOVEMENT_COUNT_SPACE = "[ERROR] 시도 횟수에 공백이 포함될 수 없습니다.";
+}
+
+
diff --git a/src/constants/constants.js b/src/constants/constants.js
new file mode 100644
index 00000000..19cfcfb1
--- /dev/null
+++ b/src/constants/constants.js
@@ -0,0 +1,10 @@
+// 상수 정의
+export const MIN_ADVANCE_NUMBER = 4;
+export const MAX_ADVANCE_NUMBER = 9;
+export const MIN_MOVEMENT_COUNT = 1;
+export const MAX_CAR_NAME_LENGTH = 5;
+export const SEPARATOR = ",";
+
+
+
+
diff --git a/src/controllers/GameController.js b/src/controllers/GameController.js
new file mode 100644
index 00000000..5d8ed332
--- /dev/null
+++ b/src/controllers/GameController.js
@@ -0,0 +1,89 @@
+import { Car } from "../models/Car.js";
+import { GameView } from "../views/GameView.js";
+import { Validator } from "../validators/Validator.js";
+import { SEPARATOR } from "../constants/constants.js";
+
+/**
+ * 게임 컨트롤러 (MVC - Controller)
+ * 전체 게임 흐름을 관리하고 조율하는 역할
+ */
+export class GameController {
+ async getCarNamesInput() {
+ const message = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)";
+ const input = await GameView.readLine(message);
+ return input;
+ }
+
+ async getMovementCountInput() {
+ const message = "시도할 횟수는 몇 회인가요?";
+ const input = await GameView.readLine(message);
+ return input;
+ }
+
+ parseCarNames(input) {
+ const carNames = input.split(SEPARATOR);
+ return carNames;
+ }
+
+ createCars(carNames) {
+ const cars = carNames.map((name) => new Car(name));
+ return cars;
+ }
+
+ playRound(cars) {
+ for (const car of cars) {
+ car.move();
+ }
+ GameView.printRoundResult(cars);
+ }
+
+ playGame(cars, movementCount) {
+ GameView.printGameHeader();
+
+ for (let i = 0; i < movementCount; i++) {
+ this.playRound(cars);
+ }
+ }
+
+ /**
+ * 우승자 찾기
+ * 최대 position을 가진 모든 자동차 반환 (공동 우승 가능)
+ */
+ findWinners(cars) {
+ let maxPosition = 0;
+
+ for (const car of cars) {
+ if (car.position > maxPosition) {
+ maxPosition = car.position;
+ }
+ }
+
+ const winners = cars.filter((car) => car.position === maxPosition);
+ return winners;
+ }
+
+ /**
+ * 전체 게임 실행 흐름 관리
+ */
+ async run() {
+ // 자동차 이름 입력 및 검증
+ const carNamesInput = await this.getCarNamesInput();
+ const carNames = this.parseCarNames(carNamesInput);
+ Validator.validateCarNames(carNames);
+
+ // 이동 횟수 입력 및 검증
+ const movementCountInput = await this.getMovementCountInput();
+ const movementCount = Validator.validateMovementCount(movementCountInput);
+
+ // 자동차 생성
+ const cars = this.createCars(carNames);
+
+ // 경주 진행
+ this.playGame(cars, movementCount);
+
+ // 우승자 판정 및 출력
+ const winners = this.findWinners(cars);
+ GameView.printWinners(winners, cars);
+ }
+}
+
diff --git a/src/models/Car.js b/src/models/Car.js
new file mode 100644
index 00000000..b672455f
--- /dev/null
+++ b/src/models/Car.js
@@ -0,0 +1,59 @@
+import { MissionUtils } from "@woowacourse/mission-utils";
+import { MIN_ADVANCE_NUMBER, MAX_ADVANCE_NUMBER } from "../constants/constants.js";
+
+/**
+ * 자동차 모델 (MVC - Model)
+ */
+export class Car {
+ static idCounter = 0;
+
+ constructor(name) {
+ this.name = name;
+ this.position = 0;
+ this.id = ++Car.idCounter; // 고유 ID 할당
+ }
+
+ /**
+ * ID 카운터 리셋 (테스트용)
+ */
+ static resetIdCounter() {
+ Car.idCounter = 0;
+ }
+
+ /**
+ * 전진 여부 판단
+ * 0~9 랜덤값 중 4 이상이면 전진
+ */
+ shouldAdvance() {
+ const randomNumber = MissionUtils.Random.pickNumberInRange(0, MAX_ADVANCE_NUMBER);
+ return randomNumber >= MIN_ADVANCE_NUMBER;
+ }
+
+ move() {
+ if (this.shouldAdvance()) {
+ this.position += 1;
+ }
+ }
+
+ /**
+ * 고유 식별자 반환 (이름이 중복되어도 구분 가능)
+ * @returns {string} 고유 식별자
+ */
+ getUniqueIdentifier() {
+ return `${this.name}#${this.id}`;
+ }
+
+ /**
+ * 디스플레이용 이름 반환 (중복 시 ID 포함)
+ * @param {Car[]} allCars - 모든 자동차 배열
+ * @returns {string} 디스플레이용 이름
+ */
+ getDisplayName(allCars = []) {
+ const duplicateNames = allCars.filter(car => car.name === this.name);
+ if (duplicateNames.length > 1) {
+ return this.getUniqueIdentifier();
+ }
+ return this.name;
+ }
+}
+
diff --git a/src/validators/Validator.js b/src/validators/Validator.js
new file mode 100644
index 00000000..0122e1d8
--- /dev/null
+++ b/src/validators/Validator.js
@@ -0,0 +1,96 @@
+import { ErrorMessage } from "../constants/ErrorMessage.js";
+import { MAX_CAR_NAME_LENGTH, MIN_MOVEMENT_COUNT } from "../constants/constants.js";
+
+/**
+ * 검증 로직 담당
+ */
+export class Validator {
+ /**
+ * 자동차 이름 배열 전체를 검증
+ * @param {string[]} carNames - 검증할 자동차 이름 배열
+ */
+ static validateCarNames(carNames) {
+ if (carNames.length === 0) {
+ throw new Error(ErrorMessage.EMPTY_CAR_NAMES);
+ }
+
+ // 모든 이름이 빈 문자열인지 확인
+ const hasNonEmptyName = carNames.some(name => name.trim().length > 0);
+ if (!hasNonEmptyName) {
+ throw new Error(ErrorMessage.EMPTY_CAR_NAMES);
+ }
+
+ // 중복 이름은 허용하되, 나중에 구분할 수 있도록 처리
+
+ for (const carName of carNames) {
+ const trimmedCarName = carName.trim();
+
+ // 빈 이름이 포함된 경우 (쉼표로 구분된 빈 값)
+ if (trimmedCarName.length === 0 && carName.length > 0) {
+ throw new Error(ErrorMessage.EMPTY_CAR_NAME_IN_LIST);
+ }
+
+ // 빈 문자열 자체
+ if (carName.length === 0) {
+ throw new Error(ErrorMessage.EMPTY_CAR_NAME_IN_LIST);
+ }
+
+ // 앞뒤 공백이 있는 경우
+ if (trimmedCarName !== carName) {
+ throw new Error(ErrorMessage.INVALID_CAR_NAME_SPACE);
+ }
+
+ // 이름 중간에 공백이 있는 경우
+ if (carName.includes(' ')) {
+ throw new Error(ErrorMessage.INVALID_CAR_NAME_SPACE);
+ }
+
+ // 이름 길이가 5자를 초과하는 경우
+ if (carName.length > MAX_CAR_NAME_LENGTH) {
+ throw new Error(ErrorMessage.INVALID_CAR_NAME_LENGTH);
+ }
+ }
+ }
+
+ /**
+ * 이동 횟수 입력을 검증하고 숫자로 변환하여 반환
+ * @param {string} movementCountInput - 검증할 이동 횟수 입력값
+ * @returns {number} 검증된 이동 횟수
+ */
+ static validateMovementCount(movementCountInput) {
+ const trimmedMovementCount = movementCountInput.trim();
+
+ if (trimmedMovementCount.length === 0) {
+ throw new Error(ErrorMessage.EMPTY_MOVEMENT_COUNT);
+ }
+
+ if (trimmedMovementCount !== movementCountInput) {
+ throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_SPACE);
+ }
+
+ if (isNaN(Number(trimmedMovementCount))) {
+ throw new Error(ErrorMessage.INVALID_NUMBER_FORMAT);
+ }
+
+ const movementCount = Number(trimmedMovementCount);
+
+ if (!Number.isInteger(movementCount)) {
+ throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_DECIMAL);
+ }
+
+ if (movementCount === 0) {
+ throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_ZERO);
+ }
+
+ if (movementCount < 0) {
+ throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_NEGATIVE);
+ }
+
+ if (movementCount < MIN_MOVEMENT_COUNT) {
+ throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_NEGATIVE);
+ }
+
+ return movementCount;
+ }
+}
+
diff --git a/src/views/GameView.js b/src/views/GameView.js
new file mode 100644
index 00000000..b6733706
--- /dev/null
+++ b/src/views/GameView.js
@@ -0,0 +1,43 @@
+import { MissionUtils } from "@woowacourse/mission-utils";
+
+/**
+ * 출력 관련 (MVC - View)
+ * 모든 메서드가 static으로 View는 상태를 가지지 않음
+ */
+export class GameView {
+ static print(message) {
+ MissionUtils.Console.print(message);
+ }
+
+ static async readLine(query) {
+ MissionUtils.Console.print(query);
+ const input = await MissionUtils.Console.readLineAsync("> ");
+ return input;
+ }
+
+ static printCarStatus(car, allCars = []) {
+ const dashes = "-".repeat(car.position);
+ const displayName = car.getDisplayName(allCars);
+ const status = `${displayName} : ${dashes}`;
+ GameView.print(status);
+ }
+
+ static printRoundResult(cars) {
+ for (const car of cars) {
+ GameView.printCarStatus(car, cars);
+ }
+ GameView.print("");
+ }
+
+ static printGameHeader() {
+ GameView.print("");
+ GameView.print("실행 결과");
+ }
+
+ static printWinners(winners, allCars = []) {
+ const winnerNames = winners.map((winner) => winner.getDisplayName(allCars));
+ const winnerNamesString = winnerNames.join(", ");
+ GameView.print(`최종 우승자 : ${winnerNamesString}`);
+ }
+}
+