[Java] 우테코 프리코스 2주차 - 자동차 경주 게임

10 분 소요

2주차 시작

2주차 과제가 또 시작됐다.
저번주에는 많이도 헤메고 힘들었지만, 그렇게 힘들었던 만큼 2주차 과제는 훨씬 수월하게 할 수 있을 것 같다!

이번에는 1주차 과제가 끝났고 공부했던 내용을 좀 더 적용해서 더 나은 코드를 작성 해보려고 한다.

그래서 코딩을 시작하기 전 규칙을 몇가지 세웠다.

  1. 한개 이상의 stream 사용하기
    for문의 도배를 막아주고 훨씬 가독성 있는 코드를 쓸 수 있도록 도와준다.
  2. enum 타입으로 상수 작성하기
    상수를 단순히 final로 선언했는데, final은 enum 보다 안전하고, 또 클래스로 취급받기 떄문에 더욱 유용하게 사용할 수 있다.
  3. TDD 적용해보기
  4. 프로그램 구현 시간 재보기
    최종 코딩 테스트를 위해서…

기능 구현 목록 작성

사실 저번주에 의외로 시간이 많이 들고 막막했던게 이 부분이었는데, 확실히 두번째라 큰 부담없이 슥슥 작성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
* 경주할 자동차들의 이름을 입력받는다.
    * "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)" 메시지를 출력한다.
    * 자동차의 이름들을 쉼표로 분리해서 받는다.
    * 만약 자동차의 이름이 5글자가 넘으면 예외를 발생시키고 다시 값을 받는다.
        * 예외상황 시 [Error]로 시작하는 해당 상황에 맞는 에러 문구를 출력해야 한다.
        
* 시도할 횟수를 입력받는다.
    * "시도할 회수는 몇회인가요?" 메시지를 출력한다.
    * 숫자외에 다른 문자열이 들어오면 예외를 발생시키도 다시 값을 받는다.
        * 예외상황 시 [Error]로 시작하는 해당 상황에 맞는 에러 문구를 출력해야 한다.
        
* 자동차는 입력받은 횟수만큼 차례로 난수를 생성한다.
    * 생성한 난수가 4 이상일 경우 전진, 미만일 경우 멈춰있는다.
    
* 매 회차마다 자동차는 자기가 간 거리만큼을 '-'로 표시해서 사용자에게 출력해준다.

* 모든 회차가 종료되면 가장 먼 거리를 이동한 우승자를 출력한다.
    * 우승자는 여러명일 수 있다.

기능 구현을 목록을 작성하다보니, 컨벤션에도 제약사항이 늘어나고 Car라는 객체에도 제약이 생기는 등 제약사항은 늘었지만,
기능 자체는 구현이 숫자야구보다 쉬울 것 같다는 생각이 들었다.

이번에는 꼭 기능 구현대로 커밋을 좀 더 꼼꼼히 해보자는 생각을 했다!!

경주할 자동차들의 이름을 입력받는다.

테스트 코드 작성

첫번째 기능은 5글자 넘어가는 예외만 잘 처리해주는지 확인하면 될 것 같았다.

그래서 간단하게 아래와 같이 테스트 코드를 작성해보았다.

1
2
3
4
5
6
7
 @Test
 void 자동차_이름에_대한_예외_출력테스트() {
        assertThatThrownBy(() -> {
            cars.addCar("pobi,minjaea");
        })
                .isInstanceOf(IllegalArgumentException.class);
    }

“경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)” 메시지를 출력한다.

출력이니까 OutputView를 만들어서 추가해주었다.
중요한 점은 시스템 메시지인 상수를 enum 타입을 선언해보는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public enum SystemMessage {
    SET_CAR_NAME_MESSAGE("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)");

    private final String message;

    SystemMessage(String message) {
        this.message = message;
    }

    public String print() {
        return message;
    }
}

이렇게 enum을 이용해서 SystemMessage라는 클래스를 만들어서 상수를 선언해주었다.

메시지 출력 메소드는 사용자가 보는 화면이므로 View 패키지의 OutputView에 작성해주었다.

1
2
3
4
5
public class OutputView {
    public static void printSetCarNameMessage(){
        System.out.println(SystemMessage.SET_CAR_NAME_MESSAGE.print());
    }
}

자동차의 이름들을 쉼표로 분리해서 받는다.

먼저 InputView에 자동차 이름을 문자열로 받도록 해주었다.

1
2
3
4
5
public class InputView {
    public String getCarName(){
        return Console.readLine();
    }
}

이렇게 하고보니까 쉼표를 기준으로 분리한 문자열로 각각의 Car 객체를 만들어주어야 하는데,
Cars 라는 Car들이 모인 집단을 하나의 객체로 새로 만들어서 Car에서는 각각의 이름의 유효성 검사를 하는게 맞을 것 같았다.

라고 생각했는데, Car에다가 유효성 검사를 추가하려 했더니 이런 규칙이 있었다…

Car 기본 생성자를 추가할 수 없다.

1
2
3
    public Car(String name) {
        this.name = name;
    }

이 생성자를 변경 할 수가 없었다. 내가 생각하기에는 차의 이름의 유효성인데 차에서 하는게 맞지않을까?? 생각했지만,
규칙을 지키기 위해서 Cars에서 유효성 검사를 해서 예외를 발생시키기로 했다.

1
2
3
4
5
6
7
8
9
public class Cars {
    CarList carList = new CarList();

    public void addCar(String carNames){
        for (String carName : carNames.split(",")) {
            carList.add(carName);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CarList {
    List<Car> carList = new ArrayList<Car>();

    public void add(String carName) throws IllegalArgumentException {
        carNameValidation(carName);
        carList.add(new Car(carName));
    }

    public void carNameValidation(String carName) throws IllegalArgumentException {
        isNull(carName);
        isOverSizeCarName(carName);
    }

    public static void isOverSizeCarName(String carName) throws IllegalArgumentException {
        if (carName.length() > Constant.CAR_NAME_MAX_SIZE.value()) {
            throw new IllegalArgumentException();
        }
    }

    public static void isNull(String carName) throws IllegalArgumentException {
        if (carName.isEmpty()){
            throw new IllegalArgumentException();
        }
    }
}

Car객체를 담는 CarList를 일급 컬렉션으로 따로 만들어주었다. 이 CarList 자료형은 자동차의 이름이 5글자 이상이거나, 자동차의 이름을
입력하지 않으면 예외를 출력한다.

이렇게 CarList로 일급컬렉션을 만들고나니, Car 생성자에 왜 추가를 하지 말라고 했는지 이해가 갔다. 결국 Cars에 이름 개수만큼의 Car를 만들으려면
이를 담는 리스트가 필요할텐데, Car에서 이 이름을 유효성 검사하는 것보다 여기서 이름에 대한 유효성을 아예 검사하고 추가하는 자료형을 따로 만들어주는게 더 맞는 것 같았다.

예외상황 시 [Error]로 시작하는 해당 상황에 맞는 에러 문구를 출력하고 다시 값을 받는다.

CarList 객체에서 throws한 IllegalArgumentException 예외를 catch해서 처리 해주어야 한다.
에러 메시지도 출력해주어야 했는데, 이 메시지를 OutputView에 따로 넣어서 출력하게 되면, Model이 View에 의존하는 코드가 나오게 되서 Exception 자체에 메시지를 주고 이를 출력하도록 했다.

먼저 ErrorMessage 라는 Enum을 하나 만들어주고,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package racingcar.constant;

public enum ErrorMessage {
    CAR_NAME_OVER_SIZE_ERROR("[ERROR] 자동차 이름은 5글자를 넘을 수 없습니다."),
    CAR_NAME_NULL_ERROR("[ERROR] 자동차의 이름을 입력하지 않았습니다.");

    private final String message;

    ErrorMessage(String message) {
        this.message = message;
    }

    public String print() {
        return message;
    }
}

그리고 CarList에 있는 유효성을 검사하는 아래 코드에서,

1
2
3
4
5
6
7
8
9
10
11
public static void isOverSizeCarName(String carName) throws IllegalArgumentException {
        if (carName.length() > Constant.CAR_NAME_MAX_SIZE.value()) {
            throw new IllegalArgumentException(ErrorMessage.CAR_NAME_OVER_SIZE_ERROR.print());
        }
    }

    public static void isNull(String carName) throws IllegalArgumentException {
        if (carName.isEmpty()){
            throw new IllegalArgumentException(ErrorMessage.CAR_NAME_NULL_ERROR.print());
        }
    }

이렇게 예외를 검사하는 두 메서드가 만드는 예외에 메시지를 넣어주고 이걸 getMessage()를 사용해서 출력해주기로 했다.

그래서 여기까지 완성한 차들의 입력을 받고 처리하는 기능까지를 CarRacingGame이라는 Controller를 만들어서 구현해주기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CarRacingGame {
    public void start() {
        Cars cars = new Cars();
        setCarsName(cars);
    }

    public void setCarsName(Cars cars){
        try{
            cars.addCar(InputView.printSetCarsNameMessage());
        }catch (IllegalArgumentException e){
            OutputView.printErrorMessage(e);
            setCarsName(cars);
        }
    }
}

setCarsName은 예외를 발생하면 에러 메시지를 출력하고 다시 입력을 받는다.

이 메서드를 구현하면서 While을 이용하는 방식과 재귀함수를 이용하는 방식 두가지를 놓고 고민했는데,

While의 경우는 구간만 반복하지만, 재귀 호출의 경우는 메서드를 새로 호출하고 해당 부분의 처리가 완료되면 남은 부분들을 처리하기 떄문에 성능면에서는 While이 낫다.

하지만 이 부분이 그렇게 성능면에서 중요한 메서드가 아닐뿐더러, 두 코드를 놓고 봤을 때 재귀함수를 쓰는게 가독성에서 압도적으로 높았다.

따라서 클린 코드를 작성하는게 가장 중요한 과제인만큼 이 부분에서는 재귀함수를 사용하는게 맞다는 결론을 내렸다.

시도할 횟수를 입력받는다.

테스트 코드

시도할 횟수의 유효성을 만족 못할시 IllegalArgumentException 예외를 제대로 발생시키는지만 확인하면 될 것같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class TryNumberTest {

    private TryNumber tryNumber;

    @BeforeEach
    void SetUp(){
        tryNumber = new TryNumber();
    }

    @Test
    void 시도할_횟수_유효성_테스트() {
        assertThatThrownBy(() -> {
            tryNumber.getTryNumber("as");
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

TryNumber로 따로 시도 횟수를 클래스로 만들어 관리할 예정이기 때문에 위와 같이 테스트 코드를 작성하였다.

시도할 회수는 몇회인가요?” 메시지를 출력한다.

1
2
3
4
5
6
7
8
9
10
11
public class InputView {
    public static String printSetCarsNameMessage() {
        System.out.println(SystemMessage.GET_CAR_NAME_MESSAGE.print());
        return Console.readLine();
    }

    public static String printGetTryNumberMessage() {
        System.out.println(SystemMessage.GET_TRY_NUMBER_MESSAGE.print());
        return Console.readLine();
    }
}

마찬가지로 InputView에 메시지 출력과 함께 입력을 받도록 구현하였다.

이제 TryNumber라는 클래스를 따로 만들어서 Cars의 클래스 변수로 사용할 수 있도록 해주자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TryNumber {
    private String tryNumber;

    public String getTryNumber() {
        return tryNumber;
    }

    public void setTryNumber(String tryNumber) throws IllegalArgumentException {
        tryNumberValidation(tryNumber);
        this.tryNumber = tryNumber;
    }

    public void tryNumberValidation(String tryNumber) {
        isDigitString(tryNumber);
        isNull(tryNumber);
    }

    public void isDigitString(String tryNumber) throws IllegalArgumentException {
        for (int i = 0; i < tryNumber.length(); i++) {
            isDigitChar(tryNumber.charAt(i));
        }
    }

    public void isDigitChar(char tryNum) throws IllegalArgumentException {
        if (!Character.isDigit(tryNum)) {
            throw new IllegalArgumentException(ErrorMessage.TRY_NUM_NOT_DIGIT_ERROR.print());
        }
    }

    public static void isNull(String tryNumber) throws IllegalArgumentException {
        if (tryNumber.isEmpty()) {
            throw new IllegalArgumentException(ErrorMessage.TRY_NUM_NULL_ERROR.print());
        }
    }
}

TryNumber는 들어온 문자열이 숫자가 아닌 문자가 섞여있거나 빈칸이라면 예외를 출력한다.

테스트 케이스를 모두 만족하는걸 확인했으니, 기능적으로 작동하도록 CarRacingGame 컨트롤러에 기능을 추가해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CarRacingGame {
    public void start() {
        Cars cars = new Cars();

        setCarsName(cars);
        setTryNumber(cars);
    }

    public void setCarsName(Cars cars) {
        try {
            cars.addCar(InputView.printSetCarsNameMessage());
        } catch (IllegalArgumentException e) {
            OutputView.printErrorMessage(e);
            setCarsName(cars);
        }
    }

    public void setTryNumber(Cars cars) {
        try {
            cars.setTryNumber(InputView.printSetTryNumberMessage());
        } catch (IllegalArgumentException e) {
            OutputView.printErrorMessage(e);
            setTryNumber(cars);
        }
    }
}

setCarsName과 마찬가지로 재귀함수로 예외가 발생하면 다시 입력을 받도록 해주었다.

여기까지 구현 후 테스트 코드 역시 통과를 완료했다.

그럼 이제 입력값들을 받고 유효성 검사를 완료하도록 했으니, 본격적으로 게임내의 기능을 구현해보자.

자동차는 난수를 생성한다.

생성한 난수가 4 이상일 경우 전진, 미만일 경우 멈춘다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void SelectMoveOrStop() {
    if (generateRandomNumber() > 3){
        Move();
    }@!!
}

public int generateRandomNumber() {
    return Randoms.pickNumberInRange(Constant.RANDOM_NUMBER_MIN_SIZE.value(),
            Constant.RANDOM_NUMBER_MAX_SIZE.value());
}

public void Move(){
    this.position++;
}

Car 클래스에 난수를 뽑아 전진할지 정지해있을지 정하는 메서드들을 만들어주었다.

매 회차마다 자동차는 난수를 생성하고, 그 결과에 따라 자기가 간 거리만큼을 ‘-‘로 표시해서 사용자에게 출력해준다.

1
2
3
4
5
 public static void printCarPosition(int carPosition) {
        for (int i = 0; i < carPosition; i++) {
            System.out.print("-");
        }
    }

carPosition의 개수만큼 “-“를 출력하는 메서드를 OutputView에 만들어주었다.

이제 CarRacingGame 컨트롤러로 가서, 모델과 뷰를 이어주었다.

1
2
3
4
5
6
7
8
9
 public void printCarsPosition(Cars cars) {
        for (Car car : cars.getCarList()) {
            car.SelectMoveOrStop();
            OutputView.printCarName(car.getName());
            OutputView.printCarPosition(car.getPosition());
            OutputView.printSpace();
        }
        OutputView.printSpace();
    }

먼저 printCarsPosition을 통해서 carList에 있는 차들의 이름과 포지션 만큼의 “-“ 기호를 출력하고,

1
2
3
4
5
public void printAllTry(Cars cars) {
        for (int i = 0; cars.getTryNumber() > i; i++) {
            printCarsPosition(cars);
        }
    }

printAllTry 메서드를 통해 모든 시도를 출력하도록 했다.

우승자를 출력한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 public int getCarsPosition(){
        List<Integer> carPositionList = new ArrayList<Integer>();
        for (Car car : carList.getCarList()){
            carPositionList.add(car.getPosition());
        }
        return Collections.max(carPositionList);
    }

    public String getWinner(int maxPosition){
        List<String> winnerList = new ArrayList<String>();

        for (Car car : carList.getCarList()){
            if (maxPosition == car.getPosition()){
                winnerList.add(car.getName());
            }
        }
       return String.join(", ",winnerList);
    }

Cars 클래스에 모든 차들의 Position을 가져오고, 그 포지션을 바탕으로 maxPosition을 구한 뒤,
이 maxPosition과 같은 포지션 값을 갖는 Car들의 이름을 문자열로 반환해 준다.

이렇게 구현한 메서드들을 CarRacingGame 클래스에서 View와 이어주면 모든 구현이 끝이난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void printCarsPosition(Cars cars) {
        for (Car car : cars.getCarList()) {
            car.SelectMoveOrStop();
            OutputView.printCarName(car.getName());
            OutputView.printCarPosition(car.getPosition());
            OutputView.printSpace();
        }
        OutputView.printSpace();
    }

    private void printWinner(Cars cars) {
        OutputView.printWinner(getWinner(cars));
    }

    private String getWinner(Cars cars) {
        return cars.getWinner(cars.getCarsPosition());
    }

2주차 후기

확실히 1주차보다 개념이 많이 정리되어서 그런지 코드를 빠르게 써내려가는 내 모습이 만족스러웠다!
최종 코딩테스트 대비를 위해서 시간도 재고 풀었는데 5시간 반 정도만에 모든 코드를 구현하고 리팩토링을 마칠 수 있었다.

물론 최종 코딩테스트는 2주차 과제보다 난이도가 더 높겠지만, 남은 시간동안 열심히 한다면
충분히 5시간안에 구현을 모두 완료할 수 있을것 같다는 자신감이 생겼다.

하지만 역시 이번 과제를 하면서 몇가지 의문점과 함께 아쉬운점이 몇가지 있는데, 우선 1주차 과제 후 상속과 스트림을 공부해서
꼭 실제 과제에 적용시켜보자고 생각했는데, 딱히 상속이나 스트림을 사용할만한 부분이 없어 사용하지 못했다.
내가 실력이 부족해서 위의 두 기능을 사용하면 더 깔끔해질 코드인데 발견을 못한건지, 애초에 기능 자체가 상속과 스트림을 사용하기 애매한
가능인지는 모르겠지만 배운 걸 바로 꼭 써보고 싶은 마음이었는데 아쉬웠다.

또, 새로 공부한 enum을 적용해서 상수값들을 관리하는 클래스를 만들어봤는데, 상수를 과연 이렇게 모아놓는 것이 효율적인지, 각 클래스에서 사용할때마다
위에 선언해주는게 효율적인지에 대한 고민이 생겼다. 처음에는 단순하게 클래스로 따로 모아서 이를 관리하면, 어떤 메시지를 바꾸고 싶을때 클래스의 위치를 찾을 필요없이 바로 내가 만들어준
SystemMessage 클래스에서 수정하면 되고, 모든 상수값을 한 클래스에서 관리하기 떄문에 같은 상수를 사용하는 클래스가 있다면 중복 코드 없이 클래스에서 가져다 쓸 수 있어
당연히 클래스로 따로 모아서 관리 하는게 효율적이라고 생각했는데, 다른 사람들이 클래스내에 상수를 선언하는걸 많이 보기도 했고,
사용하는 클래스 자체에서 관리하게 되면, 클래스 기능을 읽다가 바로 상수를 확인 할 수 있으니 유지보수가 더 편한건가? 라는 의문이 들어서 많은 고민을 했다.

그래도 내가 판단하기에는 전자의 장점이 더 많다고 생각해서 우선은 enum으로 상수들을 선언해서 constant 패키지에서 관리 하는걸로 제출하긴 했다. 물론 내가 아직 아는게 적어 후자가 더 많은 장점을 가질 수도 있지만, 우선 지금까지의 내가 공부한 내용으로 판단한 답을 내보기로 했다.

2주차는 생각보다 빠르게 과제가 끝나서, 3주차 과제 전에 비슷한 다른 프로젝트를 하나 더 구현해보고 리팩토링 해볼 생각이다.
다른 시험과 다르게 코딩테스트가 너무너무 기다려진다. 최종 코딩 테스트는 제발 오프라인에서 했으면… 거기서 코딩테스트를 보면 뭔가 동기부여가 확실히 되서
집에서 할때보다 한 두배는 더 열정적으로 불타오를 것 같은 느낌이 든다.

남은 기간도 화이팅 또 화이팅!

카테고리:

업데이트:

댓글남기기