Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,101 @@
# java-racingcar-precourse

우아한테크코스 8기 프리코스 2주차 괴제 내용입니다.

## 프로젝트 설명

쉼표로 구분된 자동차 이름을 입력받아 경주를 준비, 시도할 횟수를 입력받아 입력된 횟수만큼 경주를 실행한다.
각 자동차는 0-9 사이의 무작위 값을 부여받아 무작위 값이 4 이상일 경우 해당 자동차는 1칸 전진한다.
각 라운드별 실행 결과를 실시간으로 출력한다.
모든 경주가 완료된 후, 가장 멀리 이동한 자동차를 최종 우승자로 선정하여 출력한다.

## 기능 목록

1. 입력 기능
- [x] 자동차 이름을 쉼표 기준으로 입력받기
- [x] 경주 횟수 입력받기

2. 예외 처리

- [x] 자동차 이름이 5자를 초과하는 경우
- [x] 자동차 이름이 null, 빈 문자열이거나 공백만으로 이루어진 경우
- [x] 중복된 자동차 이름을 입력한 경우
- [x] 경주 횟수에 정수가 아닌 값을 입력한 경우
- [x] 경주 횟수에 자연수가 아닌 값을 입력한 경우

3. 메인 로직

- [x] 입력받은 자동차 이름을 쉼표 기준으로 나누어 자동차 객체 목록 생성
- [x] 무작위 값이 4 이상인 경우 자동차를 전진시키기
- [x] 입력된 횟수만큼 자동차 경주를 실행하기
- [x] 최종 우승자 결정하기(가장 먼 위치의 자동차를 찾아내기)

4. 출력 기능

- [x] 횟수별 자동차의 이름과 이동한 거리를 출력하기
- [x] 최종 우승자 출력하기(공동 우승 시 쉼표로 구분)

## 사용 기술

- Language: Java 21
- Build Tool: Gradle
- Test: JUnit 5, AssertJ
- Library: camp.nextstep.edu.missionutils (Console, Randoms)

## 아키텍처 및 설계 원칙

본 프로젝트는 객체 지향 설계 원칙을 준수하도록 개발되었습니다.

### 1. MVC 패턴 (Model-View-Controller)
Controller
- RacingGameController: View와 Model을 중재하며 게임의 전체 흐름(입력 ➔ 실행 ➔ 결과)을 제어

Model
- Car: name과 position을 가지며 position을 1 증가시키는 move() 메서드를 갖는다
- RacingGame: List<Car>를 관리하며 findWinners()와 같은 핵심 로직을 수행
- CarFactory: 이름 문자열을 받아 List<Car> 생성하는 Factory 객체
- Validator: 이름 및 횟수 검증 로직을 static 메서드로 제공하는 유틸리티 객체

View
- InputView: 사용자의 콘솔 입력을 담당
- OutputView: 콘솔 출력을 담당

### 2. TDD (Test-Driven Development)
Model 계층의 모든 비즈니스 로직과 예외 상황에 대해 JUnit 5와 AssertJ를 사용하여 단위 테스트를 우선 작성하고 이를 통과하는 코드를 구현했다.

### 3. 의존성 주입 (Dependency Injection)
Application(main)이 InputView, OutputView, CarFactory 등 필요한 객체를 생성하여 RacingGameController의 생성자로 주입한다.
- Controller가 View의 구체적인 구현이 아닌 인스턴스에 의존하게 된다.
- Controller와 View 간의 결합도가 낮아지며 Controller의 테스트가 용이하다.

### 4. 단일 책임 원칙 (SRP)

각 객체가 하나의 책임만 갖도록 분리했습니다.
CarFactory는 '생성', Validator는 '검증', RacingGame은 '게임 로직', Controller는 '흐름 제어'의 책임을 각각 담당합니다.

## 실행 방법

1. Application.main() 메서드를 실행
2. "경주할 자동차 이름을 입력하세요..."에 따라 이름을 입력한다.(이름은 쉼표로 구분한다)
3. "시도할 횟수는 몇 회인가요?"에 따라 횟수를 입력한다.
4. 실행 결과를 확인한다.

## 프로젝트 구조

```
├── main
│ └── java
│ └── racingcar
│ ├── controller
│ │ └── RacingGameController.java # 게임의 전체 흐름(입력 ➔ 실행 ➔ 결과)을 제어
│ ├── model
│ │ ├── Car.java # 자동차 name, position, move() 포함
│ │ ├── CarFactory.java # List<Car>을 생성
│ │ ├── RacingGame.java # findWinners()와 같은 핵심 로직을 수행
│ │ └── Validator.java # 이름 및 횟수 검증
│ ├── view
│ │ ├── InputView.java
│ │ └── OutputView.java
│ └── Application.java
└── test
```
16 changes: 14 additions & 2 deletions src/main/java/racingcar/Application.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
package racingcar;

import racingcar.controller.RacingGameController;
import racingcar.model.CarFactory;
import racingcar.view.InputView;
import racingcar.view.OutputView;

public class Application {
public static void main(String[] args) {
// TODO: 프로그램 구현
InputView inputView = new InputView();
OutputView outputView = new OutputView();
CarFactory carFactory = new CarFactory();

RacingGameController racingGameController = new RacingGameController(
inputView, outputView, carFactory
);
racingGameController.run();
}
}
}
55 changes: 55 additions & 0 deletions src/main/java/racingcar/controller/RacingGameController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package racingcar.controller;

import racingcar.model.Car;
import racingcar.model.CarFactory;
import racingcar.model.RacingGame;
import racingcar.view.InputView;
import racingcar.view.OutputView;

import java.util.List;

public class RacingGameController {
private final InputView inputView;
private final OutputView outputView;
private final CarFactory carFactory;

public RacingGameController(InputView inputView, OutputView outputView, CarFactory carFactory) {
this.inputView = inputView;
this.outputView = outputView;
this.carFactory = carFactory;
}

public void run() {
// 1. 준비
List<Car> cars = setupCars();

Choose a reason for hiding this comment

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

inputView를 바로 사용하지 않고 이렇게 함수로 한번 더 포장해서 사용했을때의 장점이 궁금합니다

int tryCount = setupTryCount();
RacingGame game = new RacingGame(cars);
// 2. 실행
playRacingGame(game, tryCount);
// 3. 결과
showGameResult(game);
}

private List<Car> setupCars() {
String carNameInput = inputView.readCarNames();
return carFactory.createCarList(carNameInput);
}
Comment on lines +33 to +36
Copy link
Author

@dohyunk58 dohyunk58 Oct 29, 2025

Choose a reason for hiding this comment

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

입력받은 문자열 자체의 검증 없이 split을 하려고 시도합니다.

  • 입력값이 null
  • 입력값이 빈 문자열
    인 경우 예외 처리하도록 수정해야 합니다

Ref. #923


private int setupTryCount() {
String tryCountInput = inputView.readTryCount();
return Integer.parseInt(tryCountInput);
}

private void playRacingGame(RacingGame game, int tryCount) {

Choose a reason for hiding this comment

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

Controller에 전체 게임 라운드를 반복하는 로직을 직접 구현하는 것보다 RacingGame에서 동작하는 것이 역할이 더 잘 분리될거 같다고 생각하는데 어떻게 생각하시나요?

Copy link
Author

Choose a reason for hiding this comment

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

맞습니다. 저도 Controller가 반복을 수행하지 않는 것이 이상적이라고 생각합니다.
그래서 대안을 생각해보던 중 현재 코드 설계에서는 Controller에서 View를 호출하기 때문에 만약 루프가 RacingGame 클래스로 이동하면 반복마다 출력하는 경우에 View를 호출하기가 까다로워지는 문제를 발견했습니다.
구조를 수정한다고 생각해보면 게임 결과를 저장하고 출력하면 해결할 수 있을 것 같습니다. 피드백 감사합니다!

outputView.printResultHeader();
for (int i = 0; i < tryCount; i++) {
game.runOneRound();
outputView.printCurrentStatus(game.getCarList());
}
}

private void showGameResult(RacingGame game) {
List<String> winners = game.findWinners();
outputView.printWinner(winners);
}
}
24 changes: 24 additions & 0 deletions src/main/java/racingcar/model/Car.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package racingcar.model;

public class Car {
private final String name;
private int position;
private static final int INCREASE_POSITION = 1;

public Car(String name) {
this.name = name;
Validator.validateName(name);
}

public int getPosition() {
return this.position;
}

public String getName() {
return this.name;
}

public void move() {
this.position += INCREASE_POSITION;
}
}
22 changes: 22 additions & 0 deletions src/main/java/racingcar/model/CarFactory.java
Copy link
Author

Choose a reason for hiding this comment

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

변수 이름에 타입을 적지 않아야 합니다. nameList -> names 등

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package racingcar.model;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class CarFactory {
private static final String DELIMITER = ",";

public List<Car> createCarList(String carNameInput) {
String[] names = carNameInput.split(DELIMITER);
List<String> nameList = Arrays.asList(names);

Validator.validateDuplicateNames(nameList);

List<Car> carList = new ArrayList<>();
for (String name : names) {
carList.add(new Car(name));
}
return carList;
}
}
45 changes: 45 additions & 0 deletions src/main/java/racingcar/model/RacingGame.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package racingcar.model;

import java.util.ArrayList;
import java.util.List;
import static camp.nextstep.edu.missionutils.Randoms.pickNumberInRange;

public class RacingGame {
private final List<Car> carList;
private static final int MIN_INCREASE_CONDITION = 4;

public RacingGame(List<Car> carList) {
this.carList = carList;
}

public List<String> findWinners() {
int maxPosition = findMaxPosition();
List<String> winners = new ArrayList<>();

for(Car car : carList) {
if(car.getPosition() == maxPosition) {
winners.add(car.getName());
}
}
return winners;
}

private int findMaxPosition() {
int maxPosition = 0;
for (Car car : carList) {
maxPosition = Math.max(maxPosition, car.getPosition());
}
return maxPosition;
}

public void runOneRound() {
for(Car car : carList) {
int randomNumber = pickNumberInRange(0,9);
if(randomNumber >= MIN_INCREASE_CONDITION) car.move();
}
}

public List<Car> getCarList() {
return carList;
}
Comment on lines +42 to +44
Copy link
Author

Choose a reason for hiding this comment

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

getter로 참조 가능한 객체를 반환하는 경우 수정 가능성이 있으므로 깊은 복사를 통해 수정을 방지해야 합니다.

Ref. https://velog.io/@backfox/getter-%EC%93%B0%EC%A7%80-%EB%A7%90%EB%9D%BC%EA%B3%A0%EB%A7%8C-%ED%95%98%EA%B3%A0-%EA%B0%80%EB%B2%84%EB%A6%AC%EB%A9%B4-%EC%96%B4%EB%96%A1%ED%95%B4%EC%9A%94

}
34 changes: 34 additions & 0 deletions src/main/java/racingcar/model/Validator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package racingcar.model;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Validator {
private static final int MAX_NAME_LENGTH = 5;

public static void validateName(String name) {
if(name == null) throw new IllegalArgumentException("이름은 null일 수 없습니다");

if(name.isBlank()) throw new IllegalArgumentException("이름이 비어있습니다");

if(name.length() > MAX_NAME_LENGTH) throw new IllegalArgumentException("이름은 5자 이하만 가능합니다");
}

public static void validateTryCount(String tryCount) {
try {
int count = Integer.parseInt(tryCount);
if(count < 1) throw new IllegalArgumentException("입력값이 자연수가 아닙니다");
} catch (NumberFormatException e) {
throw new IllegalArgumentException("입력값이 정수가 아닙니다");
}
}

public static void validateDuplicateNames(List<String> names) {
Set<String> uniqueNames = new HashSet<>(names);

if (uniqueNames.size() != names.size()) {

Choose a reason for hiding this comment

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

이름이 중복되는 경우에 대해서는 생각을 안해봤는데 이런 예외가 있을 수도 있겠군요!

throw new IllegalArgumentException("중복된 자동차 이름이 있습니다.");
}
}
}
21 changes: 21 additions & 0 deletions src/main/java/racingcar/view/InputView.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package racingcar.view;

import racingcar.model.Validator;

import static camp.nextstep.edu.missionutils.Console.readLine;

public class InputView {
public String readCarNames() {
System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)");

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.

맞습니다. @sun007021 님의 PR에서 확인한 점으로 입력값 자체가 null이거나 공백인 경우 예외처리하는 기능이 필요합니다.

return readLine();
}

public String readTryCount() {
System.out.println("시도할 횟수는 몇 회인가요?");

String inputTryCount = readLine();
Validator.validateTryCount(inputTryCount);

return inputTryCount;
}
}
24 changes: 24 additions & 0 deletions src/main/java/racingcar/view/OutputView.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package racingcar.view;

import racingcar.model.Car;

import java.util.List;

public class OutputView {
public void printResultHeader() {
System.out.println("\n실행 결과");
}

public void printCurrentStatus(List<Car> CarList) {
for (Car car : CarList) {
System.out.println(car.getName()+ " : " + "-".repeat(car.getPosition()));
}
System.out.println();
}

public void printWinner(List<String> winners) {
System.out.print("최종 우승자 : ");
String result = String.join(", ", winners);
System.out.print(result);
}
}
27 changes: 27 additions & 0 deletions src/test/java/racingcar/model/CarFactoryTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package racingcar.model;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

public class CarFactoryTest {

@DisplayName("쉼표로 구분된 문자열로 Car 리스트를 생성한다")
@Test
void createCarsFromNames() {
CarFactory carFactory = new CarFactory();

String carNameInput = "pobi,woni,jun";

List<Car> cars = carFactory.createCarList(carNameInput);

assertThat(cars).hasSize(3);

assertThat(cars)
.extracting(Car::getName)
.containsExactly("pobi", "woni", "jun");
}
}
Loading