우아한테크코스 - 프리코스 2주차 : 자동차 경주
미션은 자동차 경주를 포크하고 클론하는 것으로 시작한다.
기능을 구현하기 전 README.md에 구현할 기능 목록을 정리해 추가한다.
Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 추가한다.
AngularJS Git Commit Message Conventions을 참고해 커밋 메시지를 작성한다.
자세한 과제 진행 방법은 프리코스 진행 가이드 문서를 참고한다.
기능 요구 사항
초간단 자동차 경주 게임을 구현한다.
- 주어진 횟수 동안 n대의 자동차는
전진또는멈출수 있다. - 각 자동차에 이름을 부여할 수 있다. 전진하는 자동차를 출력할 때
자동차 이름을 같이 출력한다. - 자동차 이름은 쉼표
,를 기준으로 구분하며 이름은5자 이하만 가능하다. - 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다.
- 전진하는 조건은
0에서 9 사이에서 무작위 값을 구한 후 무작위 값이4 이상일 경우이다. - 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다.
- 우승자가 여러 명일 경우 쉼표
,를 이용하여 구분한다. - 사용자가 잘못된 값을 입력할 경우
IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다.
입출력 요구 사항
입력
- 경주할 자동차 이름(이름은 쉼표(,) 기준으로 구분)
pobi,woni,jun - 시도할 횟수
5
출력
- 차수별 실행 결과
pobi : -- woni : ---- jun : --- - 단독 우승자 안내 문구
최종 우승자 : pobi - 공동 우승자 안내 문구
최종 우승자 : pobi, jun
실행 결과 예시
경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
pobi,woni,jun
시도할 횟수는 몇 회인가요?
5
실행 결과
pobi : -
woni :
jun : -
pobi : --
woni : -
jun : --
pobi : ---
woni : --
jun : ---
pobi : ----
woni : ---
jun : ----
pobi : -----
woni : ----
jun : -----
최종 우승자 : pobi, jun
프로그래밍 요구 사항 1
- JDK 21 버전에서 실행 가능해야 한다.
- 프로그램 실행의 시작점은 Application의 main()이다.
- build.gradle 파일은 변경할 수 없으며, 제공된 라이브러리 이외의 외부 라이브러리는 사용하지 않는다.
- 프로그램 종료 시 System.exit()를 호출하지 않는다.
- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다.
- 자바 코드 컨벤션을 지키면서 프로그래밍한다.
- 기본적으로 Java Style Guide를 원칙으로 한다.
프로그래밍 요구 사항 2
- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.
- 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
- 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다.
- 3항 연산자를 쓰지 않는다.
- 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라.
- JUnit 5와 AssertJ를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다.
- 테스트 도구 사용법이 익숙하지 않다면 아래 문서를 참고하여 학습한 후 테스트를 구현한다.
- JUnit 5 User Guide
- AssertJ User Guide
- AssertJ Exception Assertions
- Guide to JUnit 5 Parameterized Tests
라이브러리
- camp.nextstep.edu.missionutils에서 제공하는 Randoms 및 Console API를 사용하여 구현해야 한다.
- Random 값 추출은 camp.nextstep.edu.missionutils.Randoms의 pickNumberInRange()를 활용한다.
- 사용자가 입력하는 값은 camp.nextstep.edu.missionutils.Console의 readLine()을 활용한다.
구현 세부사항
자동차 경주 게임을 구현한 Java 애플리케이션
사용자로부터 자동차 이름과 시도 횟수를 입력받아, 각 라운드마다 랜덤 값에 따라 자동차를 전진시키고 최종 우승자를 출력한다.
주요 기능:
- 쉼표
,로 구분된 여러 자동차 이름 입력 (최대 5자) - 사용자 지정 라운드 수만큼 경주 진행
- 랜덤 값(0~9)이 4 이상일 때 자동차 전진
- 매 라운드 종료 시 결과 출력
- 최종 우승자 결정 (공동 우승 지원)
입력 검증:
- 자동차 이름: 빈 값, 단일 공백, 5자 초과 방지
- 시도 횟수: 양의 정수만 허용
명시되지 않은 요구사항에 대한 구현
- 입력값 중 시행 횟수는 양의 정수로 한다.
- 자동차의 이름이 단일 공백 문자
이거나, 빈 값일 경우 에러 처리한다. - 자동차의 이름의 앞 뒤에 공백 문자
가 존재하는 경우 해당 부분을 제거하고 이름으로 설정한다. - 자동차의 수는
int범위(-2,147,483,648 ~ 2,147,483,647)를 넘지 않는다. - 시행 횟수는
int범위(-2,147,483,648 ~ 2,147,483,647)를 넘지 않는다. - 범위를 넘어가는 시행 횟수를 입력할 경우 에러 처리한다.
- 쉼표
,는 자동차의 이름이 될 수 없다. - 자동차가 전진을 할 경우
-를1개 추가한다. (이동 거리는 1씩 증가한다) - 우승자 선정 기준은 가장 전진 횟수가 많은 차량(차량들)으로 한다.
- 우승자가 여러 차량들일 경우, 사용자가 이름 입력을 먼저한 순서로 우승자가 출력된다.
- 중복된 차량의 이름이 있을 경우, 처음 입력한 순서를 가지는 하나의 차량으로 설정한다.
*시행 횟수: 몇 번의 이동을 할 것인지 사용자가 입력한 값
예외 상황
다음의 경우 IllegalArgumentException 발생:
"" // 빈 문자열 입력
-> IllegalArgumentException: [ERROR] 자동차 이름 입력은 빈 값일 수 없습니다.
" " // 공백 문자 입력
-> IllegalArgumentException: [ERROR] 자동차 이름 입력은 공백만으로 구성될 수 없습니다.
"po bi, woni,jun " // 개별 자동차 이름 (내부 공백 포함) 길이 초과 입력
-> IllegalArgumentException: [ERROR] 자동차 이름은 5글자를 초과할 수 없습니다.
"pobi,,jun" // 개별 자동차 이름 빈값 입력
"pobi,"
"pobi,,"
"pobi, ,asd"
-> IllegalArgumentException: [ERROR] 자동차 이름은 빈 값일 수 없습니다.
"0" // 0 입력
"-1" // 공백 문자 입력
-> IllegalArgumentException: [ERROR] 시도 횟수는 양수여야 합니다.
"" // 빈값 입력
-> IllegalArgumentException: [ERROR] 시도 횟수는 빈 값일 수 없습니다.
" " // 올바르지 않은 형식들
"asd"
" 30"
"5 "
"*^&*"
"99999999999" // int 범위를 벗어난 경우
-> IllegalArgumentException: [ERROR] 시도 횟수는 올바른 숫자 형식이어야 합니다.
코드 구조
패키지 구조
└── src
├── main
│ └── java
│ └── racingcar
│ ├── Application.java
│ ├── controller
│ │ └── RacingController.java
│ ├── domain
│ │ ├── RacingCar.java
│ │ └── RacingGame.java
│ ├── exception
│ │ └── ErrorMessage.java
│ └── view
│ ├── InputView.java
│ └── OutputView.java
전체 흐름
Application (main)
↓
RacingController (전체 흐름 제어)
↓
InputView (입력) → RacingController (검증 및 파싱)
↓
RacingGame (게임 로직) → RacingCar (개별 차량)
↓
OutputView (출력)
시행 프로세스
1. 사용자 입력
- InputView.readCarNames(): 자동차 이름 입력
- InputView.readRoundCount(): 시도 횟수 입력
2. 데이터 검증 및 파싱
- RacingController.parseCarNames(): 쉼표 분리, trim, 유효성 검증
- InputView.validateInputRoundCountFormat(): 숫자 포맷 및 양수 검증
3. 게임 초기화
- RacingGame 생성: 입력된 이름으로 RacingCar 인스턴스 생성
4. 라운드 실행 (roundCount만큼 반복)
- RacingGame.run():
* 각 자동차에 대해 랜덤 값(0~9) 생성
* RacingCar.move(): 랜덤 값 >= 4이면 position++
* OutputView.displayOutput(): 현재 위치 출력 (이름 : ---)
5. 결과 산출
- RacingGame.getWinner(): 최대 position을 가진 자동차 찾기
- OutputView.displayResult(): 우승자 출력 (쉼표로 구분)
6. 종료
- Console.close()
주요 컴포넌트
Application
- 프로그램 진입점
- RacingController 실행
RacingController
- MVC 패턴의 Controller 역할
- 입력/출력 View와 게임 로직 연결
- 입력 데이터 검증 및 파싱 책임
- 예외 처리 및 리소스 정리 (Console.close)
InputView
- 사용자 입력 담당
- 시도 횟수 포맷 검증 (숫자 형식, 양수)
- 자동차 이름 검증은 Controller에 위임
OutputView
- 게임 진행 상황 및 결과 출력
- 매 라운드 차량 위치 시각화 (
-사용) - 최종 우승자 출력
RacingGame
- 게임 로직 관리
- RacingCar 인스턴스 리스트 보유
- 한 라운드 실행 (모든 차량 이동 로직 시행)
- 우승자 결정 로직 (최대 position 탐색, 공동 우승 처리)
RacingCar
- 개별 자동차 도메인 모델
- name(이름), position(위치) 상태 관리
- move() 메서드: 전진 조건 판단 (랜덤 값 >= 4)
사용 기술 스택
- Language: Java
- External Libraries:
camp.nextstep.edu.missionutils.Console: 콘솔 입력 유틸리티camp.nextstep.edu.missionutils.Randoms: 랜덤 값 생성 유틸리티
- Design Pattern: MVC (Model-View-Controller)
- Model: RacingCar, RacingGame
- View: InputView, OutputView
- Controller: RacingController
- Java Features:
- Stream API (입력 파싱)
- Exception Handling (IllegalArgumentException)
- Collections (ArrayList, List)
Application.java
package racingcar;
import racingcar.controller.RacingController;
public class Application {
public static void main(String[] args) {
RacingController racingController = new RacingController();
racingController.run();
}
}
RacingController.java
package racingcar.controller;
import camp.nextstep.edu.missionutils.Console;
import racingcar.exception.ErrorMessage;
import racingcar.view.InputView;
import racingcar.view.OutputView;
import racingcar.domain.RacingGame;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class RacingController {
private static final int NAME_LENGTH_THRESHOLD = 5;
private static final String SEPARATOR = ",";
private final InputView inputView;
private final OutputView outputView;
private RacingGame racingGame; // RacingCar 클래스 사용, 1 Round 처리
public RacingController() {
this.inputView = new InputView();
this.outputView = new OutputView();
}
public void run() {
try {
// 검증 및 파싱된 입력값
List<String> parsedCarNamesList = parseCarNames(inputView.readCarNames());
int roundCount = inputView.readRoundCount();
racingGame = new RacingGame(parsedCarNamesList);
loopGameRunner(roundCount);
outputView.displayResult(racingGame.getWinner());
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
throw e;
} finally {
Console.close();
}
}
private static List<String> parseCarNames(String input) {
validateInputCarNameEmptyOrBlank(input);
List<String> carNames = Arrays.stream(input.split(SEPARATOR, -1))
.map(String::trim)
.distinct() // 중복 제거
.collect(Collectors.toList());
validateSplitNamesCheck(carNames);
return carNames;
}
// 기본 상태 입력 문자열 검증
private static void validateInputCarNameEmptyOrBlank(String carNames) {
if (carNames == null || carNames.isEmpty()) {
throw new IllegalArgumentException(ErrorMessage.CAR_NAMES_INPUT_NULL_OR_EMPTY.getMessage());
}
if (carNames.isBlank()) {
throw new IllegalArgumentException(ErrorMessage.CAR_NAMES_INPUT_BLANK.getMessage());
}
}
// 스플릿한 문자열 개별 검증
private static void validateSplitNamesCheck(List<String> carNames) {
for (String carName : carNames) {
if (carName == null || carName.isEmpty()) { // 앞선 trim 과정으로 isBlank() 해당 x
throw new IllegalArgumentException(ErrorMessage.CAR_NAME_NULL_OR_EMPTY.getMessage());
}
if (carName.length() > NAME_LENGTH_THRESHOLD) {
throw new IllegalArgumentException(ErrorMessage.CAR_NAME_TOO_LONG.getMessage());
}
}
}
private void loopGameRunner(int roundCount) {
for (int i = 0; i < roundCount; i++) {
if (i == 0) outputView.printInitOutputMessage();
racingGame.run(); // 전체 차량 이동
}
}
}
RacingGame.java
package racingcar.domain;
import camp.nextstep.edu.missionutils.Randoms;
import racingcar.view.OutputView;
import java.util.ArrayList;
import java.util.List;
public class RacingGame {
private static final int MAXIMUM_RANDOM_NUM = 9;
private final OutputView outputView;
private final List<RacingCar> racingCars;
public RacingGame(List<String> carNames) {
this.outputView = new OutputView();
this.racingCars = new ArrayList<>();
for (String carName : carNames) {
this.racingCars.add(new RacingCar(carName));
}
}
public void run() {
for (RacingCar racingCar : this.racingCars) {
int randomValue = Randoms.pickNumberInRange(0, MAXIMUM_RANDOM_NUM);
racingCar.move(randomValue);
outputView.displayOutput(racingCar.getName(), racingCar.getPosition());
}
outputView.printNewLine();
}
public List<String> getWinner() {
int winnerPosition = this.racingCars.getFirst().getPosition();
// 공동 우승자 고려 List<String>으로 작성
List<String> winnerNames = new ArrayList<>();
winnerNames.add(this.racingCars.getFirst().getName());
for (int i = 1; i < this.racingCars.size(); i++) {
RacingCar racingCar = this.racingCars.get(i);
int tmpPosition = racingCar.getPosition();
if (tmpPosition == winnerPosition) winnerNames.add(racingCar.getName());
if (tmpPosition > winnerPosition) {
winnerPosition = tmpPosition;
winnerNames.clear();
winnerNames.add(racingCar.getName());
}
}
return winnerNames;
}
}
RacingCar.java
package racingcar.domain;
public class RacingCar {
private static final int FORWARD_THRESHOLD = 4;
private final String name;
private int position;
public RacingCar(String name) {
this.name = name;
this.position = 0;
}
public void move(int randomValue) {
if (randomValue >= FORWARD_THRESHOLD) position++;
}
public String getName() {
return this.name;
}
public int getPosition() {
return this.position;
}
}
InputView.java
package racingcar.view;
import camp.nextstep.edu.missionutils.Console;
import racingcar.exception.ErrorMessage;
public class InputView {
private static final String CAR_NAME_PROMPT = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)";
private static final String ROUND_COUNT_PROMPT = "시도할 횟수는 몇 회인가요?";
public String readCarNames() {
System.out.println(CAR_NAME_PROMPT);
return Console.readLine(); // 입력값 검증 -> RacingController
}
public int readRoundCount() {
System.out.println(ROUND_COUNT_PROMPT);
return validateInputRoundCountFormat(Console.readLine());
}
private static int validateInputRoundCountFormat(String roundCount) {
// 기본 입력 포맷 검증 로직 (숫자 포맷 일치 여부, 음이 아닌 정수)
if (roundCount == null || roundCount.trim().isEmpty()) {
throw new IllegalArgumentException(ErrorMessage.ROUND_COUNT_EMPTY.getMessage());
}
try {
int num = Integer.parseInt(roundCount);
checkPositive(num);
return num;
} catch (NumberFormatException e) {
throw new IllegalArgumentException(ErrorMessage.ROUND_COUNT_INVALID_FORMAT.getMessage());
}
}
private static void checkPositive(int roundCount) {
if (roundCount <= 0) throw new IllegalArgumentException(ErrorMessage.ROUND_COUNT_NOT_POSITIVE.getMessage());
}
}
OutputView.java
package racingcar.view;
import java.util.List;
public class OutputView {
private static final char MOVING_LINE = '-';
public void printNewLine() {
System.out.println();
}
public void printInitOutputMessage() {
System.out.println("실행 결과");
}
public void displayOutput(String carName, int carPosition) {
// 매 Round마다 호출될 결과 출력
System.out.println(carName + " : " + String.valueOf(MOVING_LINE).repeat(carPosition));
}
public void displayResult(List<String> winnerNames) {
// 최종 우승자 결과 출력
System.out.print("최종 우승자 : " + String.join(", ", winnerNames));
}
}
ErrorMessage.java
package racingcar.exception;
public enum ErrorMessage {
CAR_NAME_NULL_OR_EMPTY("자동차 이름은 빈 값일 수 없습니다."),
CAR_NAME_TOO_LONG("자동차 이름은 5글자를 초과할 수 없습니다."),
CAR_NAMES_INPUT_NULL_OR_EMPTY("자동차 이름 입력은 빈 값일 수 없습니다."),
CAR_NAMES_INPUT_BLANK("자동차 이름 입력은 공백만으로 구성될 수 없습니다."),
ROUND_COUNT_EMPTY("시도 횟수는 빈 값일 수 없습니다."),
ROUND_COUNT_INVALID_FORMAT("시도 횟수는 올바른 숫자 형식이어야 합니다."),
ROUND_COUNT_NOT_POSITIVE("시도 횟수는 양수여야 합니다.");
private static final String ERROR_PREFIX = "[ERROR] ";
private final String message;
ErrorMessage(String message) {
this.message = message;
}
public String getMessage() {
return ERROR_PREFIX + message;
}
}
회고
2주차 프리코스에선 1주차 종료와 함께 제공된 공통 피드백을 최대한 고려하여 반영하고자 노력했다. 그와 동시에 1주차에서 느꼈던 부족한 점(절차 지향에 가까운 프로그래밍, 유지 보수하기 어려운 코드 및 폴더 구조 etc.)을 개선할 수 있는 기회로 만들고 싶었다. 이에 대한 내용으로 2주차 프리코스를 진행하면 느낀 점과 개발 경험에 대해 회고해보고자 한다.
“오류를 찾을 때 출력 함수 대신 디버거를 사용한다”, 기존 개발을 할 때나 1주차 프리코스 과정 진행 중에도 가장 지키지 못한 내용이었던 것 같다. 처음 사용해보는 인텔리제이 환경 뿐 아니라 기존에 익숙했던 VSCode 환경에서도 오류를 찾을 때, 확인하고자 하는 부분에 출력 함수를 활용해서 디버깅을 하였다. 이번 2주차에서는 이러한 습관을 고치고자 우아한테크 유튜브의 인텔리제이 디버깅 영상을 보고 학습한 후 확인하고자 하는 부분에서 실제 데이터들이 가지는 값을 확인하는 과정을 거치며 코드를 작성하였다. 실제로 Controller에서 입력값 검증을 할 때 단일 자동차 이름 입력 시 검증 과정 중 하나를 실행하지 않아 잘못된 데이터 값이 담기는 것을 바로 확인할 수 있었고 보다 빠른 개발에 도움이 되었다.
또한 1주차와 마찬가지로 자바 코딩 컨벤션을 최대한 지키고자 더욱 노력하였다. 이 과정에서 변수, 메서드, 클래스 명을 작성하는데 공통 피드백 중 하나인 “축약하지 않는다” 부분을 의식해서 작성하였다. 코드의 길이가 길어지는 것보다 해당 네이밍의 의도를 드러내는 것이 추후 리펙토링, 디버깅 과정에서 훨씬 편리함을 가져다 주었다.
이외에도 스트림과 메서드를 활용한 문자열 처리, 리스트 컬렉션으로 데이터 관리 등 처음 써보는 기능들을 학습하고 반영하여 코드의 가독성 측면에서 큰 향상성을 직접 체감하였다.
다음으론 1주차에서 직접 느꼈던 부족한 점을 개선하고자 개발 시 고려하였던 사항들에 대한 내용이다.
1주차 코드에선 하나의 Application.java의 클래스 내 메서드로 모든 기능을 모아서 구현하였다. 기능상으론 문제가 없다 생각하여 그렇게 구현하였으나 다른 참가자들의 코드를 보며 하나의 클래스에 되도록 여러 기능을 담지 않는 점을 확인할 수 있었다. 이후 찾아본 내용으론 Application.java는 프로그램의 진입점으로 애플리케이션을 시작하는 것 하나의 책임을 가지는 것이 추후 유지보수성과 테스트에서의 용이성 때문에 불리하다는 것을 확인하였다. 그래서 자바 패기지의 여러 폴더 구조를 확인해보며 MVC 패턴이 상당 수 사용된다는 것을 알게 되었다. 이를 반영하여 Model(domain) - 핵심 로직 담당 객체, View - 입출력 로직, Controller - View와 domain의 연결 및 전체 플로우 컨트롤 담당 구조로 패키지를 나누어 코드를 작성하였다.
레이어별로 나눈 구조에 맞게 테스트 코드 또한 추가로 작성하였다. 1주차에선 콘솔에 직접 입력하여 표준 예외처리 및 기능 검사를 진행하였으나 테스트 코드 사용법을 공부하고 적용하여 미리 만들어 둔 테스트 코드로 추후 리팩토링 된 코드에 동일 내용을 적용하였을 때 결과를 바탕으로 리펙토링 과정에서 생략된 기능들을 찾을 수 있었다.
추가로 enum 열거형을 사용해 표준 예외 처리 메시지를 모아 관리하는 등, 기존에 알 지 못했던 다양한 방식을 최대한 적용하고자 노력하였다. 이러한 새로 알게된 방식을 적용하는 과정에서 InputView에 View 기능 뿐이 아닌 검증 로직까지 존재해 코드가 무거워 지는 등 확실한 책임 분리가 적용되지 못한게 아쉽게 느껴졌다. 추후 프리코스 주차에선 View에 해당하는 코드는 최대한 입출력과 관련한 기능만 담도록 개선해 나가야겠다. 이번 주차에서 긍정적으로 느낀 점은 README 파일을 먼저 작성한 후 구현 내용을 보고 하나씩 커밋하여 개발한 것이 계획적으로 코드를 작성한 것 같아 조금이나마 나아간 것 같다.
우아한테크코스 - 프리코스 2주차 : 자동차 경주