카테고리 없음

[우아한테크코스 4기] 프리코스 2주차 미션

daram 2021. 12. 3. 13:12

우아한 테크코스 4기 2주차 미션과제는 '자동차 경주 게임'을 구현하는 것이었습니다.

 

README 작성


이전 프로젝트에서는 마지막에 가서야 기능별로 README 구현을 하였다면, 이번에는 프로젝트 시작하자 마자 기능별로 정리를 하는데 성공을 하였다고 생각합니다.

허나 뭔가 완벽하지는 않다고 생각하여 다른 사람들은 어떻게 README를 구현하였는지 확인을 하고 가장 깔끔하다고 판단한 포맷을 기반으로 하여 재구성 하였습니다.

 

최종 README는 아래와 같습니다.

## 🚀 기능 요구사항

초간단 자동차 경주 게임을 구현한다.

### 입력
1.  자둥차 이름 입력
    - 기능 설명
        - 사용자로 부터 자동차 이름을 입력 받음.
        - 자동차의 이름은 ","를 기준으로 구분.
    - 예외 처리    
        - 자동차 이름이 `5자 초과`인 경우.
        - 자동차 이름이 `생략`이 되어있을 경우.
    - 예외 처리 방법
        - 예외 상황 시 에러 문구를 출력
        - 에러 문구는 `[ERROR]` 로 시작
        - `자동차 이름`을 재 입력 받는다.
    

2. 이동 횟수 입력
    - 기능 설명
        - 사용자로 부터 이동할 횟수를 입력 받음.
    - 예외 처리
        - 이동 횟수가 `0 또는 음수`인 경우.
        - `숫자가 아닌 값`인 경우.
    - 예외 처리 방법
        - 예외 상황 시 에러 문구를 출력
        - 에러 문구는 `[ERROR]` 로 시작
        - `이동 횟수`를 재 입력 받는다.

### 동작
1. 자동차의 이동.
    - 기능 설명
        - 주어진 횟수에 따라 자동차는 이동이 가능하다.
        - 랜덤 값을 이용하여 이동을 한다 (랜덤 값의 범위는 1 ~ 9 사이)      
        - 정지 : 랜덤 메서드를 이용하여 얻은 값이 1 ~ 3 일 경우
        - 이동 : 랜덤 메서드를 이용하여 얻은 값이 4 ~ 9 일 경우


2. 자동차의 현재 위치 출력
    - 기능 설명
        - 각 차수별 실행 결과를 출력한다.
        - 차수별 실행 결과는 `(자동차 이름) : (현재 위치)` 포맷으로 출력한다.
        - 현재 위치는 `하이픈 (-)`으로 표시한다.
  
### 출력
5. 우승자 정보 출력
    - 기능 설명
        - 마지막 턴이 끝난 후 우승자를 출력한다.
        - 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다.

 

많은 시간을 투자한 부분


1. 예외 처리 후 재 입력을 받기

 

사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

 

위 내용을 어떻게 구현해야 가장 안전하면서 깔끔하게 구현할 수 있을까 생각을 많이 하였습니다.

사실 위 내용을 확인하고 구현하면서 들었던 생각은 'InputView에서 자동차 이름을 받는 시점에서 확인을 하고 잘못된 값일 경우 재 입력을 받는 것이 구현하기에는 가장 편할텐데' 라는 생각이 들었습니다.

 

그러나 코드 재활용성을 생각하였을 때, 자동차 이름을 사용하는 시점에 자동차 이름의 적합성을 확인하는 로직이 존재하지 않는다면 문제가 생길 수 있기 때문에 객체가 생성되는 시점에 자동차 이름을 확인하는 로직을 넣기로 마음 먹었습니다.

 

기능 구현을 막 완성했을 당시에 Car 클래스의 모습입니다.

상위 클래스에서 try catch 를 이용하여 예외 처리를 진행하였습니다.

InputView에서 input에 적합성을 판별하는 것 보다는 객체가 사용하는 시점에 적합성을 판별하는 로직을 넣어 둠으로써 최소한의 안전 장치를 마련했다고 생각을 합니다.

package racingcar.domain;

import java.util.Objects;

public class Car {

    private static final String CARS_NAME_EXCEPTION_MESSAGE = "[ERROR]: 자동차의 이름은 1글자 이상 5글자 이하로 지정하여야 합니다.";
    private static final Integer NAME_LENGTH_THRETHOLD = 5;

    private final String name;
    private int position = 0;

    public Car(String name) throws IllegalArgumentException {
        validCarName(name);
        this.name = name;
    }

    private void validCarName(String name) throws IllegalArgumentException {
        emptyNullCheck(name);
        validLength(name);
    }

    private void emptyNullCheck(String name) {
        if (Objects.isNull(name) || name.isEmpty()) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

    private void validLength(String name) {
        if (name.length() > NAME_LENGTH_THRETHOLD) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

    public void move(MoveStrategy moveStrategy) {
        if (moveStrategy.move()) {
            position++;
        }
    }

    public int position() {
        return position;
    }

    public String name() {
        return name;
    }
}

 

2.  원시값 포장


Car.java

 

아래 Car 클래스를 다시 살펴보도록 하겠습니다.

자동차에 관련된 클래스 임에도 불구하고 자동차 이름에 관한 내용이 너무 많습니다.

원시값을 포장해주면서, 해당 내용에 대한 메서드를 옮겨 보았습니다.

 

< 변경 전 Car 클래스>

package racingcar.domain;

import java.util.Objects;

public class Car {

    private static final String CARS_NAME_EXCEPTION_MESSAGE = "[ERROR]: 자동차의 이름은 1글자 이상 5글자 이하로 지정하여야 합니다.";
    private static final Integer NAME_LENGTH_THRETHOLD = 5;

    private final String name;
    private int position = 0;

    public Car(String name) throws IllegalArgumentException {
        validCarName(name);
        this.name = name;
    }

    private void validCarName(String name) throws IllegalArgumentException {
        emptyNullCheck(name);
        validLength(name);
    }

    private void emptyNullCheck(String name) {
        if (Objects.isNull(name) || name.isEmpty()) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

    private void validLength(String name) {
        if (name.length() > NAME_LENGTH_THRETHOLD) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

    public void move(MoveStrategy moveStrategy) {
        if (moveStrategy.move()) {
            position++;
        }
    }

    public int position() {
        return position;
    }

    public String name() {
        return name;
    }
}

 

<변경 후 Car 클래스>

package racingcar.domain;

public class Car {

    private final CarName carName;
    private int position = 0;

    public Car(String name) throws IllegalArgumentException {

        this.carName = new CarName(name);
    }

    public void move(MoveStrategy moveStrategy) {
        if (moveStrategy.move()) {
            position++;
        }
    }

    public int position() {
        return position;
    }

    public String name() {
        return carName.name();
    }
}

carName 에 대한 상수와 메서드가 없어지니 코드의 가독성도 좋아지고, 한 결 깔끔해진 것 같습니다.

carName의 클래스는 아래와 같습니다.

 

<원시값을 포장한 CarName 클래스>

package racingcar.domain;

import java.util.Objects;

public class CarName {
    private static final String CARS_NAME_EXCEPTION_MESSAGE = "[ERROR]: 자동차의 이름은 1글자 이상 5글자 이하로 지정하여야 합니다.";
    private static final Integer NAME_LENGTH_THRETHOLD = 5;

    private final String name;

    public CarName(String name) {
        validCarName(name);
        this.name = name;
    }

    private void validCarName(String name) throws IllegalArgumentException {
        emptyNullCheck(name);
        validLength(name);
    }

    private void emptyNullCheck(String name) {
        if (Objects.isNull(name) || name.isEmpty()) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

    private void validLength(String name) {
        if (name.length() > NAME_LENGTH_THRETHOLD) {
            throw new IllegalArgumentException(CARS_NAME_EXCEPTION_MESSAGE);
        }
    }

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

같은 방법으로 position도 원시값을 포장해 주었습니다.

 

3. 테스트하기 쉬운 코드로 변경


Car.java

 

이번 코스에서 테스트하기 어려운 부분은 랜덤 값을 받고, 그 값을 이용하여 테스트를 진행하는 것입니다.

package racingcar.domain;

import camp.nextstep.edu.missionutils.Randoms;

public class Car {

    private static final Integer MINIMUM_RANDOM_NUMBER = 1;
    private static final Integer MAXIMUM_RANDOM_NUMBER = 9;
    private static final Integer MOVING_THRETHOLD = 4;

    public void move() {
        int randomNumber = Randoms.pickNumberInRange(MINIMUM_RANDOM_NUMBER, MAXIMUM_RANDOM_NUMBER);
        if (randomNumber >= MOVING_THRETHOLD) {
            position++;
        }
    }
}

 

move 메서드를 살펴보면, 랜덤 값이 기준치를 넘어서게 되면 이동을 하는 형태로 코드가 작성되어 있습니다.

현재 상태에서는 랜덤 값을 지정할 수 없기 때문에 테스트를 하기 매우 불편한 상황입니다.

 

테스트 할 수 있는 코드로 변경하기 위한 2가지 방법을 살펴보았습니다.

1. 랜덤 값을 리턴 받을 수 있는 새로운 메서드 생성

 

랜덤값을 린턴 받을 수 있는 randomNumber 함수를 새로 생성합니다.

기능은 똑같지만 randomNumber 라는 새로운 메서드를 이용하여 랜덤값을 통제할 수 있습니다.

public void move() {
    if (randomNumber() >= MOVING_THRETHOLD) {
        position++;
    }
}

protected int randomNumber() {
    return Randoms.pickNumberInRange(MINIMUM_RANDOM_NUMBER, MAXIMUM_RANDOM_NUMBER);
}

 

테스트는 아래와 같이 메서드 오버라이드를 이용하여 진행할 수 있습니다.

@DisplayName("현재 position이 0인 자동차가 한 칸 이동할 경우, 다음 위치 확인에 관한 테스트")
@Test
void carPositionTest() {
    Car car = new Car("jay") {
        @Override
        protected int randomNumber() {
            return 4;
        }
    };
    car.move();
    Assertions.assertThat(car.position()).isEqualTo(1);
}

 

메서드 오버라이드를 하는 방법 보다 조금 더 간편한 방법이 없을까?

메서드 오버라이드를 하지 않고 테스트 코드를 작성하기 위하여 랜덤 값을 인자로 받는 형태로 코드를 수정하였습니다.

 

2. 랜덤 값을 인자로 받기

 

랜덤 값을 인자로 받게 되면 테스트 코드를 훨씬 쉽게 작성할 수 있습니다.

우선 아래와 같이 랜덤 값을 인자로 받도록 코드를 수정하였습니다.

public void move(Integer randomNumber) {
    if (randomNumber >= MOVING_THRETHOLD) {
        position++;
    }
}

 

인자를 받게 move 메서드의 형태를 변경하였더니 훨씬 깔끔하고 보기 좋은 형태로 테스트 코드를 완성할 수 있게 되었습니다.

class CarTest {

    @DisplayName("현재 position이 0인 자동차가 한 칸 이동할 경우, 다음 위치 확인에 관한 테스트")
    @Test
    void carPositionTest() {
        Car car = new Car("jay");
        car.move(4);
        Assertions.assertThat(car.position()).isEqualTo(1);
    }
}

 

현재는 랜덤값이 4이상 일때만 자동차가 한 칸 앞으로 가는 형태입니다.

시간이 지나서, 이동 유무 조건이 변경될 경우를 대비하기 위해서는 어떻게 해야 할까요?

변경 가능한 부분을 인터페이스로 분리 하는 방법이 있습니다.

 

인터페이스로 분리를 진행해 보겠습니다.

 

package racingcar.domain;

public interface MoveStrategy {

    boolean move();
}

 

인터페이스를 구현해 보겠습니다.

랜덤 값을 이용하여 움직임 유무를 확인하는 방법이니 클래스 명은 RandomMoveStrategy 라고 하겠습니다.

 

package racingcar.domain;

import camp.nextstep.edu.missionutils.Randoms;

public class RandomMoveStrategy implements MoveStrategy {

    private static final Integer MINIMUM_RANDOM_NUMBER = 1;
    private static final Integer MAXIMUM_RANDOM_NUMBER = 9;
    private static Integer MOVING_THRETHOLD = 4;

    @Override
    public boolean move() {
        return randomNumber() >= MOVING_THRETHOLD;
    }

    private int randomNumber() {
        return Randoms.pickNumberInRange(MINIMUM_RANDOM_NUMBER, MAXIMUM_RANDOM_NUMBER);
    }
}

 

move 메서드에서 MoveStrategy를 이용하도록 변경해 보았습니다.

 

package racingcar.domain;

import java.util.Objects;

public class Car {

    private int position = 0;

    public void move(MoveStrategy moveStrategy) {
        if (moveStrategy.move()) {
            position++;
        }
    }
}

테스트 코드는 아래와 같이 변경할 수 있습니다.

class CarTest {

    @DisplayName("현재 position이 0인 자동차가 한 칸 이동할 경우, 다음 위치 확인에 관한 테스트")
    @Test
    void carPositionTest() {
        Car car = new Car("jay");
        car.move(new RandomMoveStrategy() {
            @Override
            public boolean move() {
                return true;
            }
        });
        Assertions.assertThat(car.position()).isEqualTo(1);
    }
}

 

람다를 이용하여 테스트 코드를 깔끔하게 변경해 보았습니다.

package racingcar.domain;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class CarTest {

    @DisplayName("현재 position이 0인 자동차가 한 칸 이동할 경우, 다음 위치 확인에 관한 테스트")
    @Test
    void carPositionTest() {
        Car car = new Car("jay");
        car.move(() -> true);
        Assertions.assertThat(car.position()).isEqualTo(1);
    }
}

 

이제 자동차가 움직이는 로직이 변경하더라도 RandomMoveStrategy 만 변경하면 되기 때문에 코드 재활용성이 높아졌다고 할 수 있습니다.

추가적으로 테스트 코드도 처음보다 훨씬 깔끔해 진 것도 확인할 수 있습니다.

움직임 전략에 관련된 모든 상수들이 RandomMoveStrategy 안에 들어감으로 인하여 다른 클래스들이 더 깔끔해진 것 같아 기분이 좋습니다.

 

4. DTO 사용


Cars 객체 내부 컬렉션을 받아와 순회하며 자동차의 현재 위치와, 우승자 정보를 출력하였습니다.만약 OutputView에서 move 메서드를 건드리게 된다면 결과값이 변경되는 위험이 있기에 이를 방지할 수 있는 방법을 찾아보았습니다.

 

View가 Cars의 내부 리스트에 직접적으로 방식을 변경할 방법이 있을까? 라는 질문에 대답하기 위하여 찾아보던 중 DTO 라는 개념을 알게 되었습니다.

도움이 많이 되었던 블로그를 아래 Reference에 적어 놓도록 하겠습니다.

 

 

DTO는 Data Transfer Object의 약자로, 계층 간 데이터 교환 역할을 한다. DB에서 꺼낸 데이터를 저장하는 Entity를 가지고 만드는 일종의 Wrapper라고 볼 수 있는데, Entity를 Controller 같은 클라이언트단과 직접 마주하는 계층에 직접 전달하는 대신 DTO를 사용해 데이터를 교환한다.
DTO는 그저 계층간 데이터 교환이 이루어 질 수 있도록 하는 객체이기 때문에, 특별한 로직을 가지지 않는 순수한 데이터 객체여야 한다. 또한 DB에서 꺼낸 값을 DTO에서 임의로 조작할 필요가 없기 때문에 DTO에는 Setter를 만들 필요가 없고 생성자에서 값을 할당한다. 개인적으로는 생성자 또한 사용하지 않고 Entity처럼 Builder 패턴을 통해 값을 할당하는 것이 가장 좋은 것 같다.

 

getter의 기능만 가지고 있으며, OutputView 에서 위험한 경우를 모두 방지할 수 있는 새로운 Dto 객체를 생성하였습니다.

package racingcar.domain;

public class CarDto {

    private final String name;
    private final int position;

    public CarDto(String name, int position) {
        this.name = name;
        this.position = position;
    }

    public static CarDto form(Car car) {
        return new CarDto(car.name(), car.position());
    }

    public String name() {
        return name;
    }

    public int position() {
        return position;
    }
}

 

Cars.java

 

기존에 List<Car>을 return 받아 OutputView 에서 결과값을 출력하던 형태에서 List<CarDto> 를 return 받아 OutputView 에서 결과값을 출력하는 형태로 변경되었습니다.

public List<Car> cars() {
    return cars;
}

public List<CarDto> carsDto() {
    return cars.stream()
        .map(car -> CarDto.form(car))
        .collect(Collectors.toList());
}

CarDto 객체는 position과 name 메서드 확인하는 기능만 존재하기 때문에, 객체 내에 저장된 정보가 변경되는 일은 없을 것으로 예상됩니다.

 

느낀점


요구사항 구현을 완료하는 것은 어렵지 않았지만, 깔끔하고 이해하기 쉬운 코드를 작성하기란 쉽지 않았습니다.

 

이번 프로젝트에서 고민을 많이 하였던 것 중 하나는 input이 적절하지 않을 때 에러 메세지를 보내고, 재입력을 받는 부분이었습니다.

위에서 말했던 것과 동일하게 InputView에서 모든 것을 확인하고, 적절하지 않을 경우 재입력을 받게 되면 훨씬 편하게 코드를 작성할 수 있지만 값을 사용하는 객체에서 값을 확인하여야 한다는 신념(?) 때문에 코드가 조금 복잡해 진 부분이 있습니다.  그러나 객체가 만들어지는 시점에서 사용하는 입력값을 확인함으로써 오류가 발생할 수 있는 확률을 줄일 수 있었다고 생각합니다.

 

가장 만족스러웠던 부분은 DTO라는 개념을 알게된 것입니다. view에서 domain의 메서드를 건드려서 불상사가 일어나는 일은 미연에 방지시킬 수 있는 방법을 알게 되어 이번 프로젝트에서 적용을 한 것은 큰 성과가 아닐까 싶습니다.

 

이전 프로젝트에서 보다 README를 처음부터 기능별로 조금 더 잘 나눠서 작성한 것도 뿌듯합니다.

 

이번 프로젝트는 정말 생각할 부분이 많은 프로젝트 였는 것 같습니다.

단순히 구현뿐만이 아니라 어떤 객체가 어떤 위치에 존재해야 더 최적화를 할 수 있는지에 대한 생각을 많이 하게 만드는 프로젝트 였던 것 같습니다. 

모든 경기 결과를 저장한 객체를 만든다면 어떻게 구상하는 것이 좋을까에 대한 질문도 스스로에게 해보았습니다.

Car 객체에서 저장하는 것이 좋을지, 경기 결과 객체는 OutputView와 가장 많은 상호작용을 할 것이기 때문에 Car 외부에서 독립적인 객체를 만들어 사용하는 것이 좋을 것인지 많은 생각이 드는 부분이었습니다.

 

이런 의견을 나누면서 페어 프로그래밍을 진행해 보면 얼마나 재미있을까 라는 생각이 듭니다.

 

하루 하루 많은 것을 배우고 있습니다. 내일도 오늘보다 더 많은 것을 배울 수 있게 노력하도록 하겠습니다.

 

 

Reference

 


https://velog.io/@ohzzi/Entity-DAO-DTO가-무엇이며-왜-사용할까