[우아한테크코스] 자동차 경주 회고

김민수 / Minsu Kim·2024년 2월 18일
1

우아한테크코스

목록 보기
1/8
post-thumbnail

⭐️ 들어가기 전

우아한테크코스 레벨1의 첫번째 미션은 자동차 미션이다.

이 미션의 목표는 단위테스트이다. 단위테스트란 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이다.

기능 요구사항은 우아한테크코스 6기 프리코스와 거의 동일하고 자율적으로 기능을 추가할 수 있다.

단위 테스트에 초점을 맞춰 도메인과 그것들의 테스트 설명을 먼저 하고, 그 외의 프로그램 구조적인 부분을 분리해서 회고하려 한다.

⭐️ 도메인

우선 도메인들에 대해 설명하겠다.

🍀 자동차

자동차 경주 게임을 구현할 때 제일 먼저 생각나는 것은 역시 자동차이다.
자동차는 이름과 위치를 가지고, 0~9의 값 중 4가 나오는 경우 움직인다.
단위 테스트를 위해서 이름과 위치, 움직이는 조건을 분리했다.
자동차 이름과 위치는 원시값 포장해서 CarName, Position을 만들었다.
이것의 장점으로 검증 코드를 자동차에서 CarName, Position으로 분리해서 유지보수성이 좋아졌다.

🍀 위치

Position의 경우 value에 대해서 equals, hashCode를 Override하고, Comparable interface를 받아 compareTo를 Override해서 value값으로 위치를 비교할 수 있었다. Car 또한 compareTo를 Override해서 position 값으로 순서를 비교할 수 있게 했다.

CarName과 Position과 같이 원시값 포장한 것들은 Car에서만 사용하기 때문에 car 하위 폴더에 넣어주었다. 내가 생각하기엔 이게 중요한 부분인 것 같다.

🍀 움직이는 조건

자동차는 0~9 중 4가 나왔을 경우 앞으로 간다.
자동차가 특정 조건에 따라 움직인다에 의존하는 것 같아서 MovingStrategy를 만들어서 분리했다.
MovingStrategy는 리턴 타입이 boolean인 move 메서드를 가진다.
특정 조건(0~9 중 4)을 분리하고, 자동차가 앞으로 가거나, 안가거나를 받고 싶었다.
0~9 중 4가 나오는 경우가 아니라 "CarStatus == A 인 경우 앞으로 간다"라는 조건으로 변경될 수 있기 때문에 boolean으로 해줬다.

NumberGenerator라는 인터페이스가 있고, 그것을 구현한 RandomNumberGenerator가 있다.
MovingStrategy를 구현한 DefaultMovingStrategy는 NumberGenerator라는 필드를 가진다. 랜덤 숫자가 얻는 것 또한 갈아끼울 수 있게 작성하고자 했다.

🍀 경주 참여자들

자동차들을 모아놓은 RaceParticipants는 일급 컬렉션이다. 생성자와 getter에서 주소값에 따라 변하는 것을 막아주었다. 이름을 Cars나 RacingCars로 할 수 있었겠지만 의미있는 이름으로 정하고 싶었다.

🍀 경주 결과

마지막으로 RaceResults는 자동차들이 한 번 움직일 때마다 그 위치를 기록하고, 마지막에 우승자를 결정하기 위해서 만들었다. 경주를 기록하고 결과로 주지 않으면 I/O 작업이 너무 빈번하게 일어나기도 하고, 경주 결과를 얻는 것과 경주 결과를 출력하는 것에 의존이 생겨서 분리했다.

⭐️ 도메인 테스트

다음은 도메인의 테스트에 대해서 설명하겠다.
일반적인 예외 테스트는 간단하니 생략한다.
초점을 맞춘 부분은 "특정 조건일 경우 자동차가 간다"와 "모든 자동차들을 움직인다" 의 경우이다.
MovingStrategy의 canMove는 boolean이기 때문에 한 번 밖에 쓸 수 없다.
그래서 다음과 같이 movableList를 만들어서 canMove를 사용할 때마다 리스트의 특정 인덱스의 값을 가져오게 했다.

public class MockMovingStrategy implements MovingStrategy {
    private final List<Boolean> movableList;
    private int currentIndex = 0;

    public MockMovingStrategy(final List<Boolean> movableList) {
        this.movableList = new ArrayList<>(movableList);
    }

    public MockMovingStrategy() {
        this.movableList = new ArrayList<>();
    }

    @Override
    public boolean canMove() {
        if (currentIndex >= movableList.size()) {
            throw new IllegalStateException("더 이상 이동할 수 없습니다.");
        }
        return movableList.get(currentIndex++);
    }

}

움직인다라는 것을 리스트로 만들어서 여러번 움직임을 테스트할 수 있었다.

@Test
    void 자동차_움직임_성공() {
        // given
        final List<Boolean> movableList = List.of(true, false, true, false, true);
        final Car car = new Car("car", new MockMovingStrategy(movableList));

        // when
        for (int i = 0; i < movableList.size(); i++) {
            car.move();
        }

        // then
        assertThat(car.getPosition()).isEqualTo(3);
    }

⭐️ 구조적인 코드

도메인 외적인 프로그램 구조적인 코드에 대해 설명하겠다.

우선 나는 InputView나 OutputView나 움직임 전략, 숫자 생성 전략 등등을 interface로 만들었고, 대부분의 생성자들에서 interface로 받는다.
그래서 AppConfig만으르 조작해서 프로그램을 바꿀 수 있게 의도했다.

public class AppConfig {
    private AppConfig() {
    }

    public static InputView consoleInputView() {
        return new ConsoleInputView();
    }

    public static OutputView consoleOutputView() {
        return new ConsoleOutputView();
    }

    public static NumberGenerator randomNumberGenerator() {
        return new RandomNumberGenerator();
    }

    public static MovingStrategy defaultMovingStrategy() {
        return new DefaultMovingStrategy(randomNumberGenerator());
    }

    public static RacingController racingController() {
        return new RacingController(
                consoleInputView(),
                consoleOutputView(),
                defaultMovingStrategy()
        );
    }
}

예외 처리에 대해서 IllegalArgumentException을 상속받은 BaseException을 만들고 BaseException을 상속받아 예외들을 만들어 사용했다. ErrorMessage들은 enum으로 만들어 관리했다.

package racingcar.exception;

public class BaseException extends IllegalArgumentException {
    private static final String PREFIX = "[ERROR]";

    public BaseException(final String message) {
        super(String.format("%s %s", PREFIX, message));
    }
}
public enum ErrorMessage {
    INPUT_NOT_A_NUMBER("입력 값은 숫자여야 합니다."),
    INVALID_CAR_NAME_LENGTH(String.format("자동차 이름은 %d자 이하여야 합니다.", MAX_NAME_LENGTH)),
    INVALID_RACE_COUNT_RANGE(String.format("시도 횟수는 %d~%d이어야 합니다.", MIN_RACE_COUNT, MAX_RACE_COUNT)),
    DUPLICATE_CAR_NAMES("중복된 자동차 이름이 존재합니다."),
    INVALID_POSITION(String.format("위치는 %d 이상이어야 합니다.", MIN_POSITION)),
    INVALID_CAR_NAME_FORMAT("자동차 이름에 한글, 영어, 숫자만 가능합니다."),
    ;

    private final String message;

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

    public String getMessage() {
        return message;
    }
}

request, response dto도 사용했다.
내가 생각하는 dto의 역할은 controller <-> view나 domain <-> view의 의존 관계를 줄이기 위해서 사용한다고 생각한다.

public record RaceParticipantsRequest(String input) {
    public RaceParticipants toRaceParticipants(final MovingStrategy movingStrategy) {
        final List<Car> cars = InputUtils.splitByComma(input).stream()
                .map(carName -> new Car(carName, movingStrategy))
                .toList();

        return new RaceParticipants(cars);
    }
}
public record RaceWinnersResponse(List<String> raceWinners) {
    public static RaceWinnersResponse from(final List<Car> raceWinners) {
        final List<String> raceWinnersResponse = raceWinners.stream()
                .map(Car::getName)
                .toList();

        return new RaceWinnersResponse(raceWinnersResponse);
    }
}

위와 같이 view가 주고 싶은 코드를 dto에 주고, controller가 받고 싶은 코드를 dto에서 받는다. 또는 controller가 주고 싶은 코드만 dto에 주고 view가 받고 싶은 코드를 dto에서 받는다.

⭐️ 결론

자동차 미션을 진행하면서, 좀 더 객체지향적으로 사고할 수 있었고, 어떻게 분리해야 테스트를 쉽게 할 수 있는지 알 수 있었다.
자동차 코드는 다음에서 확인할 수 있다.

자동차 경주 코드

profile
https://alstn113.tistory.com

0개의 댓글