우테코 8기 프리코스 2주차 회고

박병욱·2025년 10월 27일

우아한테크코스

목록 보기
2/9
post-thumbnail

🏎️ 자동차 경주

2주차에는 자동차 경주 문제가 주어졌다. 이번에도 전체 프로그램 구조를 파악하고 주어진 기능 요구사항을 다시 정리하면서 과제를 시작했다.

<기능 요구사항 정리>

  • 자동차 이름을 입력받는다.
    • 이름은 쉼표(,)로 구분하며, 이름의 길이는 5자 이하로 제한한다.
  • 몇번을 시도할건지 입력한다.
  • 자동차(들)는 전진하거나 정지한다.
    • 0 ~ 9까지의 난수 중 4 이상일 경우에만 전진한다. (전진할 경우, "-" 표시)
    • 매 시도마다 각 자동차의 진행 상태를 출력한다.
  • 우승자를 선정한다.
    • 우승자가 2명 이상일 경우, 쉼표(,)로 구분한다.
  • 사용자가 잘못된 값을 입력할 경우 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

 

📃 구현 계획

이번 과제에서도 MVC 패턴을 적용했다. 1주차에서 회고했던 내용을 참고해서 모델을 세부적으로 분리하고, 현재 과제에서 설정된 자동차가 0부터 9 사이의 난수 중 4 이상인 경우만 전진한다는 조건은 언제나 변경될 수 있다는 생각을 해서 이동 전략을 추상화해야겠다는 생각이 들었다.

 

Model(Car): 이름을 부여받고, 전진하는 책임을 맡는다.

  • 자동차는 이름과 위치를 입력받는다.
  • 자동차는 이동 전략을 주입 받아 기준에 부합하면 자동차를 전진시킨다. (move() 메서드)

 

Model(MoveStrategy): 특정 조건에 따라 자동차의 전진 혹은 정지 여부를 판단하는 책임을 맡는다. 향후 다양한 이동 전략이 추가될 수도 있기 때문에 인터페이스로 추상화하는 것이 적절하다고 생각했다.

  • 자동차 전진 조건 정의(MoveStrategy 인터페이스)
  • 현재 과제에서 주어진 조건 0 ~ 9까지의 난수 중 4 이상이 나올 경우에만 전진하는 구현체를 구현한다. (BasicMoveStrategy 구현체)
    • 기준값(요구 사항에서는 4) 이상의 수가 나올 경우, 전진 여부로 true를 반환한다.

 

Model(NumberGenerator): 요구 사항에 맞는 범위 안에서 난수를 생성하는 책임을 맡는다.

  • camp.nextstep.edu.missionutils에서 제공하는 Randoms API를 사용해서 난수를 생성한다.

 

Model(Winner): 최종 우승자를 선정하는 책임을 맡는다.

  • 우승자(들)를 선정한다. (selectWinners() 메서드)

 

Service(GameService): 도메인을 생성하고, 전략을 연결하는 책임을 맡는다.

  • 자동차 이름 입력 처리
    • 입력 문자열을 쉼표(,)로 분리한다.
    • 각 문자열의 공백만인 항목은 제거한다.
    • 중복 이름이 있으면 예외를 발생시킨다.
  • 시도 횟수 입력 처리
    • 뷰로부터 입력받은 시도 횟수의 공백을 제거한다.
    • 입력받은 시도 횟수를 정수형으로 파싱한다.
    • 정수형으로 파싱할 수 없는 타입일 경우, 예외를 발생시킨다.
    • 양수가 아닌 값을 입력 받았을 경우, 예외를 발생시킨다.
  • 도메인 객체 생성
    • 검증받은 자동차 이름들을 가지고 Car 인스턴스들이 담긴 리스트를 생성하고 반환한다.

 

View(InputView): 자동차의 이름과 시도 횟수를 사용자로부터 입력받는 책임을 맡는다.

  • 자동차 이름을 입력 받는다. (inputCarNames() 메서드)
  • 시도할 횟수를 입력 받는다. (inputRounds() 메서드)

 

View(OutputView): 시도 횟수마다 진행 상태를 출력하고, 우승자를 출력하는 책임을 맡는다.

  • 자동차 진행 상태를 출력한다. (printProgress() 메서드)
  • 우승자를 출력한다. (printWinners() 메서드)

 

Controller(GameController)View로부터 입력받은 데이터를 각 Model들과 Service로 전달 후 결과를 반환받는다. 반환받은 결과를 OutputView로 전달하는 책임을 맡는다.

  • 자동차 이름을 InputView로부터 입력 받는다.
  • 시도할 횟수를 InputView로부터 입력 받는다.
  • GameService로 입력된 자동차 이름 문자열을 전달하고, 최종 Car 리스트를 반환받는다.
  • 시도 횟수동안 반환 받은 진행 상태를 OutputView로 전달하고, 진행 상태를 출력한다.
  • 반환 받은 우승자(들)를 OutputView로 전달하고, 최종 우승자(들)를 출력한다.

🔥 집중한 부분

이번 주차에서는 1주차 공통 피드백과 더불어 추가적인 프로그래밍 요구 사항이 존재했다. 먼저 1주차 공통 피드백에서 내가 1주차 때 지키지 못한 부분 한 가지가 바로 눈에 띄었다. 바로 “오류를 찾을 때 출력 함수 대신 디버거를 사용” 해야 한다는 것이다. 그동안 오류가 발생하거나 원하는 데이터가 출력되지 않았을 때 항상 출력 함수를 사용해서 일일이 확인했었다. 이번에 디버거를 사용하면서 데이터나 주입 정보, 컬렉션 내부까지 한 눈에 관찰할 수 있는 점이 아주 좋았다. 추가로, 원한다면 내가 디버깅하고 싶은 부분까지만 브레이크 포인트를 걸어 예외가 발생한 부분이나 내가 작업하고 싶은 단락에만 집중할 수 있어 매우 만족스러웠다.

그리고 이번 주차에서 추가된 프로그래밍 요구 사항에도 흥미로운 항목들이 있었다. 그 중에 “함수나 메서드는 한 가지 일만 담당하도록 최대한 작게 설계” 해야 한다는 것과 “테스트 도구를 이용해서 프로그램이 정상적으로 동작하는지 확인” 하는 것이 눈에 띄었고, 이번 과제에서 아주 많은 시간을 투자한 부분이다.

🤔 왜 함수나 메서드는 최대한 작게 설계해야 하는거지?

함수나 메서드는 왜 한 가지일만 담당하도록 설계해야 한다는 추가 요구사항을 보고는 약간 의문이 들었다. 한 가지 일만 담당하도록 메서드를 작성하려고 하면 한 클래스 내부에 메서드가 지나치게 많아지는 거 아닌가? 하지만 알다시피 모든 프로그램들은 한번 완성하고 그 상태로 끝나지 않는다. 요구사항이나 설계의 변화가 있을 수 있기 때문에 다시 수정해야 할 텐데, 여기서 함수나 메서드가 너무 많은 작업을 포함하고 있다면 변화가 있는 부분만 콕 집어서 고치기 어렵다. 최악의 경우, 아예 코드를 다 갈아엎을 수도 있다.


🚘 Model(Car)

먼저 “자동차 1대” 에 대한 데이터와 규칙을 정의하기 위해 Car 클래스를 만들었다.

package racingcar.model;

public class Car {

    private final String name;
    private int position;
    private final MoveStrategy moveStrategy;

    public Car(String name, int position, MoveStrategy moveStrategy) {
        validateCarHasNoName(name);
        validateCarHasLongName(name);
        validateCarHasSpecialCharacter(name);
        this.name = name;
        this.position = position;
        this.moveStrategy = moveStrategy;
    }

    private void validateCarHasNoName(String name) {
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("자동차 이름은 공백을 허용하지 않습니다.");
        }
    }

    private void validateCarHasLongName(String name) {
        if (name.length() > 5) {
            throw new IllegalArgumentException("자동차 이름은 5자 이하만 가능합니다.");
        }
    }

    private void validateCarHasSpecialCharacter(String name) {
        if (!name.matches("^[a-zA-Z0-9가-힣]*$")) {
            throw new IllegalArgumentException("자동차 이름에 특수문자는 허용되지 않습니다.");
        }
    }

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

    public String getName() {
        return name;
    }

    public int getPosition() {
        return position;
    }
}

기본적으로 Car 클래스는 자동차의 이름(name)과 전진 위치(position)를 가지고 있고, 각각의 자동차가 게임의 공통 규칙(이동 전략)을 가지고 행동해야 하기 때문에 외부에서 주입받기 위해 이동 전략(moveStrategy)도 추가해줬다. 여기서 고민했던 부분은, 분명 MVC 패턴에서의 모델은 본인과 관련된 로직을 제외한 그 어떤 외부 코드가 있어서는 안 된다고 했었는데, 과연 “외부로부터 주입받을 이동 전략을 필드로 두어도 괜찮은가” 였다. 결론은, 현재 구현체가 아닌 “이동 전략” 이라는 인터페이스에 의존하고 있기 때문에 DIP 원칙에 위배되지 않는다.

 

➡️ 이동 전략에 따라 자동차를 전진: move()

원래는 외부에서 자동차 클래스의 move()를 호출해서 자동차를 프로그램 흐름과 상관없이 전진시킬 수 있는 위험이 있다고 생각해서 내부적으로 캡슐화하려고 했었다. 하지만, 향후 자동차 클래스는 외부로부터 이동 전략 구현체를 주입받기 때문에, 그 이동 전략의 조건에 부합할 경우에만 자동차를 전진시킨다면 외부에서 move() 메서드에 접근할 수 있도록 public으로 열어놔도 괜찮을 것 같다는 생각을 했다.

public void move() {
		// 주입받을 이동 전략의 조건에 부합한다면
    if (moveStrategy.isMove()) {
		    // 전진 위치의 값을 1만큼 증가
        position++;
    }
}

 

📝 이름에 대한 검증 로직

가장 고민을 많이 한 부분이기도 하다. MVC 패턴에 따르면 각 모델은 본인이 가지고 있는 데이터에 대한 검증 규칙을 포함하고 있어야 한다. 함수나 메서드는 하나의 일만 담당하도록 최대한 작게 설계하라는 요구사항에 따라 하나의 항목만 검증하도록 메서드들을 설계했다.

🍩 이름이 없을 경우: validateCarHasNoName()

private void validateCarHasNoName(String name) {
    if (name == null || name.trim().isEmpty()) {
        throw new IllegalArgumentException("자동차 이름은 공백을 허용하지 않습니다.");
    }
}

 

🥩 이름이 5자를 초과할 경우: validateCarHasLongName()

private void validateCarHasLongName(String name) {
    if (name.length() > 5) {
        throw new IllegalArgumentException("자동차 이름은 5자 이하만 가능합니다.");
    }
}

 

🧀 이름에 특수문자가 포함될 경우: validateCarHasSpecialCharacter()

private void validateCarHasSpecialCharacter(String name) {
    if (!name.matches("^[a-zA-Z0-9가-힣]*$")) {
        throw new IllegalArgumentException("자동차 이름에 특수문자는 허용되지 않습니다.");
    }
}

이렇게 각 검증 항목에 대한 메서드를 일일이 만들고, Car 생성자 내부에서 검증을 수행하도록 처리했다.

 

🤔 생성자가 너무 무겁지 않나?

근데 여기서 든 의문점은 지금처럼 검증 메서드가 적을 경우에는 문제가 없는 것처럼 보이지만, 만약 추가적인 검증 로직을 추가해야 한다는 요구사항이 발생할 경우, 그만큼의 검증 메서드를 일일이 만들고 생성자에 메서드를 싹 다 추가하게 되면, 생성자 코드가 너무 길어져서 가독성이 떨어질 것 같다는 느낌을 받았다. 그렇다고, 검증 메서드들을 public으로 열어 놓고, 외부에서 Car 인스턴스를 생성할 때마다 검증을 수행하는 것은 더 말이 안 된다고 생각했다. 왜냐하면 외부에서 검증한다는 말 자체가 그 외부 클래스와 Car 클래스의 결합도가 높아진다는 뜻이고, 개발자가 실수로 검증 메서드를 추가하지 않으면 불완전한 객체 인스턴스가 프로그램 안에 떠돌아다닐 위험이 있기 때문이다.

결론은 정적 팩토리 메서드를 사용하는 것이었다. 정적 팩토리 메서드를 사용하면 생성 경로를 하나로 모으기 때문에 검증이 누락되는 것을 방지하고, 의미 있는 이름을 부여하고, 캐싱을 비롯한 하위 타입을 반환하는 등 유연성까지 제공해 생성자보다 더 안전하고 확장 가능하게 만들 수 있다. 다음 과제에서는 검증 로직이 많아질 경우, 정적 팩토리 메서드를 도입해봐야겠다는 생각을 했다.


⚔️ Model(MoveStrategy)

이번 과제에서 이동 전략을 추상화하는 계획은 간단하다. 그냥 전진 여부를 판단하는 역할만 추상화해줬다.

package racingcar.model;

public interface MoveStrategy {
    boolean isMove();
}

물론 인터페이스가 메서드 하나만 가지고 있어서 굳이 만들어야 하나 싶지만, 현재 Car 모델이 이동 전략을 주입받아서 생성되어야 하기 때문에 인터페이스에 의존해야 한다. 그리고 나중에 다양한 이동 전략들에 대한 요구사항이 발생할 수 있기 때문에 이동 전략을 추상화하는 것이 적절하다고 생각했다.

 

그리고 곧바로 이번 과제에서 사용될 이동 전략 구현체를 만들어줬다.

📑  이동 전략 구현체: BasicMoveStrategy

package racingcar.model;

public class BasicMoveStrategy implements MoveStrategy {

    private static final int MIN_NUMBER = 0;
    private static final int MAX_NUMBER = 9;
    private static final int REFERENCE_VALUE = 4;

    private final NumberGenerator numberGenerator;

    public BasicMoveStrategy(NumberGenerator numberGenerator) {
        this.numberGenerator = numberGenerator;
    }

    @Override
    public boolean isMove() {
        return numberGenerator.generateNumber(MIN_NUMBER, MAX_NUMBER) >= REFERENCE_VALUE;
    }
}

게임에 대한 규칙을 BasicMoveStrategy만 알고 있으면, 다른 객체들은 각자 본인의 행동만 수행하면 되기 때문에 난수 생성 범위와 기준값을 내부로 캡슐화 해줬다. 그리고 이동 전략 구현체는 생성된 난수를 바탕으로 전진 여부를 판단해야 하기 때문에 외부에서 난수 생성기(numberGenerator) 주입 받도록 설계했다.

 

⚖️ 전진 판단 여부: isMove()

주입 받은 난수 생성기(numberGenerator)에게 과제에서 설정된 난수 범위 값을 파라미터로 넘기도록 했다. 그 범위 값을 바탕으로 난수 생성기가 난수를 생성하면, 그 난수를 바탕으로 설정된 기준값(REFERENCE_VALUE)보다 크거나 같은지 판단해서 boolean 타입으로 반환하도록 처리했다.

@Override
public boolean isMove() {
    return numberGenerator.generateNumber(MIN_NUMBER, MAX_NUMBER) >= REFERENCE_VALUE;
}

🤖 Model(NumberGenerator)

이동 전략에 주입될 난수 생성기다. 이동 전략에서 파라미터로 전달한 난수 범위 값 내에서 camp.nextstep.edu.missionutils에서 제공하는 Randoms API를 사용해 난수를 생성해서 반환하도록 설계했다.

package racingcar.model;

import camp.nextstep.edu.missionutils.Randoms;

public class NumberGenerator {

    public int generateNumber(int min, int max) {
        return Randoms.pickNumberInRange(min, max);
    }
}

🏆 Model(Winner)

우승자(들)에 대한 데이터와 규칙을 정의하기 위해 Winner 클래스를 만들었다.

package racingcar.model;

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

public class Winner {

    private final List<String> winners = new ArrayList<>();
    
    private void validateAllCarsMoved(int maxPosition) {
        if (maxPosition == 0) {
            throw new IllegalStateException("어떤 자동차도 전진하지 않으면, 우승자를 선정할 수 없습니다. 게임을 다시 진행해주세요.");
        }
    }

    private int findLargestPosition(List<Car> cars) {
        int max = 0;
        for (Car car : cars) {
            if (car.getPosition() > max) {
                max = car.getPosition();
            }
        }

        validateAllCarsMoved(max);
        return max;
    }

    public List<String> getWinners(List<Car> cars) {
        for (Car car : cars) {
            if (car.getPosition() == findLargestPosition(cars)) {
                winners.add(car.getName());
            }
        }

        return winners;
    }
}

Winner 모델에서도 마찬가지로, 최대한 메서드가 한 가지 일만 담당하도록 설계했다.

 

❌ 모든 자동차가 전진하지 않았는지 검증: validateAllCarsMoved()

프로그램은 “자동차 경주” 다. 만약 모든 자동차가 움직이지 않는다면, 이걸 경주라고 할 수 있을까? 당연히 우승자를 선정하지 않는 것이 프로그램 흐름 상 적절하다고 생각해서 해당 규칙을 추가했다.

private void validateAllCarsMoved(int maxPosition) {
    if (maxPosition == 0) {
        throw new IllegalStateException("어떤 자동차도 전진하지 않으면, 우승자를 선정할 수 없습니다. 게임을 다시 진행해주세요.");
    }
}

따라서 위치의 최댓값을 구하는 메서드(findLargestPosition())에서 최댓값이 0이라는 결과를 반환했을 때, 전진한 자동차가 단 1대도 없었다는 뜻이기 때문에 IllegalStateException 예외를 터뜨리도록 처리했다.

 

🔍 최대 위치값 찾기: findLargestPosition()

자동차들이 가지고 있는 전진 위치값 중에서 가장 큰 값을 뽑아낸다.

private int findLargestPosition(List<Car> cars) {
    int max = 0;
    for (Car car : cars) {
        if (car.getPosition() > max) {
            max = car.getPosition();
        }
    }

		validateAllCarsMoved(max);
    return max;
}

자동차 리스트를 순회하면서 가장 큰 전진 위치값을 갱신시키고, 그 최댓값을 반환하도록 했다. 추가로, 반환된 최댓값을 전달 받아 모든 자동차가 전진하지 않았을 경우에 우승자를 선정하지 않는다는 규칙을 반영하기 위해 validateAllCarsMoved() 메서드로 검증해주도록 처리했다.

 

👑 우승자 리스트 반환: getWinners()

그리고 뽑아낸 전진 위치값을 바탕으로 그 전진 위치값과 일치하는 값을 가진 자동차들의 이름을 winners 리스트에 추가함으로써 최종 우승자 리스트를 반환하도록 했다.

public List<String> getWinners(List<Car> cars) {
    for (Car car : cars) {
        if (car.getPosition() == findLargestPosition(cars)) {
            winners.add(car.getName());
        }
    }

    return winners;
}

🏭 Service(GameService)

현재 프로그램은 자동차 1대만 가지고 게임을 진행하는 것이 아니라, 여러 대가 게임을 진행하는 것이다. 결국 한 줄로 한번에 입력받은 자동차들의 이름을 파싱하고, 담을 리스트가 필요했다. 또, 입력된 시도 횟수만큼 게임을 진행해야 했기 때문에 그에 대한 로직들은 서비스에 작성하는 것이 적절하다고 생각했다.

package racingcar.service;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import racingcar.model.Car;
import racingcar.model.MoveStrategy;

public class GameService {

    private final MoveStrategy moveStrategy;

    public GameService(MoveStrategy moveStrategy) {
        this.moveStrategy = moveStrategy;
    }

    private List<String> parseCarNames(String input) {
        List<String> carNames = new ArrayList<>();
        for (String s : input.split(",")) {
            String name = s.trim();
            if (!name.isEmpty()) {
                carNames.add(name);
            }
        }

        return carNames;
    }

    private List<String> validateDuplicateCarName(String input) {
        Set<String> set = new LinkedHashSet<>();
        for (String name : parseCarNames(input)) {
            if (!set.add(name)) {
                throw new IllegalArgumentException("차 이름이 중복되었습니다.");
            }
        }

        return new ArrayList<>(set);
    }

    private int validateRoundIsInteger(String input) {
        String trim = input.trim();
        if (!trim.matches("[0-9]+$")) {
            throw new NumberFormatException("시도 횟수는 숫자만 입력 가능합니다.");
        }

        return Integer.parseInt(trim);
    }

    public int validateRoundIsPositive(String input) {
        int round = validateRoundIsInteger(input);
        if (round <= 0) {
            throw new IllegalArgumentException("시도 횟수는 양수만 입력 가능합니다.");
        }

        return round;
    }

    public List<Car> initCarList(String input) {
        List<String> nameList = validateDuplicateCarName(input);
        List<Car> carList = new ArrayList<>();

        for (String name : nameList) {
            carList.add(new Car(name, 0, moveStrategy));
        }

        return carList;
    }
}

 

🔬 입력 받은 자동차 이름 파싱: parseCarNames()

먼저 한 줄로 입력 받은 자동차 이름들을 쉼표(”,”)를 기준으로 하나씩 뽑아내는 일을 담당하는 parseCarNames() 메서드를 만들었다.

private List<String> parseCarNames(String input) {
    List<String> carNames = new ArrayList<>();
    for (String s : input.split(",")) {
        String name = s.trim();
        if (!name.isEmpty()) {
            carNames.add(name);
        }
    }

    return carNames;
}

보다시피 쉼표 기준으로 자동차 이름을 파싱한다. 추가로, 쉼표 전후로 자동차 이름에 공백이 포함되었을 경우, 논리적으로는 같은 이름이지만 다른 이름으로 인식될 우려가 있기 때문에 공백을 제거하고 최종적으로 자동차 이름들이 담긴 carNames 리스트를 반환하도록 했다.

 

🎭 자동차 이름 중복 여부 판단: validateDuplicateCarName()

자동차들이 담긴 리스트(carNames)를 전달 받아서 이름이 중복됐는지 판단하는 메서드도 별도로 추가했다.

private List<String> validateDuplicateCarName(String input) {
    Set<String> set = new LinkedHashSet<>();
    for (String name : parseCarNames(input)) {
        if (!set.add(name)) {
            throw new IllegalArgumentException("차 이름이 중복되었습니다.");
        }
    }

    return new ArrayList<>(set);
}

추가적으로, 1주차 피드백에 컬렉션 프레임워크(List, Map, Set 등)를 적극 활용하라는 내용이 있었다. 이름을 중복되었는지 확인하기 위해 일일이 리스트를 순회하면서 확인할 수도 있겠지만, 데이터의 중복을 허용하지 않는 Set 자료구조를 활용한다면 손쉽게 중복 여부를 체크할 수 있다고 생각했다. 여기서 이름의 순서도 보장하기 위해 LinkedHashSet을 사용했다. 자동차 이름을 차례로 LinkedHashSet에 집어 넣으면서 자료구조 내부에 해당 이름이 존재한다면 예외를 터뜨리도록 처리했다. 마지막으로 모든 순회를 마치고 LinkedHashSet이 복사된 리스트를 반환하도록 했다.

 

🔢 시도 횟수가 숫자인지 여부 판단: validateRoundIsInteger()

일단 정수형이 아닌 값이 입력 되었을 경우를 먼저 검증하도록 했다.

private int validateRoundIsInteger(String input) {
    String trim = input.trim();
    if (!trim.matches("[0-9]+$")) {
        throw new NumberFormatException("시도 횟수는 숫자만 입력 가능합니다.");
    }

    return Integer.parseInt(trim);
}

먼저 입력된 시도 횟수를 파라미터로 전달 받아서 혹시나 있을 공백을 제거한 후, 정규식을 이용해서 숫자가 아닌 값이 입력 되었을 경우, NumberFormatException 예외를 터뜨리도록 처리했다. 그리고 정규식을 모두 통과했다면 시도 횟수를 정수형으로 변환한 값을 반환한다.

 

➕ 시도 횟수가 양수인지 판단: validateRoundIsPositive()

숫자임이 검증된 시도 횟수를 바탕으로 만일 0이나 음수일 경우에도 IllegalArgumentException 예외를 발생시키도록 별도로 메서드를 만들었다.

public int validateRoundIsPositive(String input) {
    int round = validateRoundIsInteger(input);
    if (round <= 0) {
        throw new IllegalArgumentException("시도 횟수는 양수만 입력 가능합니다.");
    }

    return round;
}

 

🌿 자동차 리스트 생성 및 반환: initCarList()

마지막으로, 검증이 끝난 자동차 이름들이 담겨 있는 리스트를 바탕으로 실제 Car 인스턴스를 생성해서 carList에 넣어주도록 했다.

public List<Car> initCarList(String input) {
    List<String> nameList = validateDuplicateCarName(input);
    List<Car> carList = new ArrayList<>();

    for (String name : nameList) {
        carList.add(new Car(name, 0, moveStrategy));
    }

    return carList;
}

 

🕷 디버거 사용

원래 initCarList() 메서드를 대략 아래와 같이 작성했었다.

package racingcar.service; 

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

import racingcar.model.BasicMoveStrategy; 
import racingcar.model.Car; 

public class GameService { 
	public List<Car> initCarList(String input) { 
		String[] split = input.split(","); 
		List<Car> carList = new ArrayList<>(); 
		
		for (String s : split) { 
			if (s.length() > 5) { 
				throw new IllegalArgumentException("자동차 이름은 5자 이하만 가능합니다."); 
			} 
			
			carList.add(new Car(s, 0, new BasicMoveStrategy())); 
		} 
			
		return carList; 
	} 
}

서비스 로직을 모두 구성하고, 확인을 위해 디버깅을 하다가 BasicMoveStrategyNumberGenerator 인스턴스의 참조값들이 각 자동차들마다 모두 다른 것을 확인했다.

다시 보니, Car 생성자를 통해 자동차 인스턴스를 생성할 때 계속해서 new BasicMoveStrategy()로 새로운 이동 전략 구현체 인스턴스를 생성해서 주입하고 있는 어처구니 없는 실수를 하고 있었다. 이 BasicMoveStrategyNumberGenerator 인스턴스도 의존하고 있으니 NumberGenerator의 참조값도 당연히 달랐던 것이다. 따라서 아래와 같이 실제 실행 코드에서 이동 전략 구현체를 딱 한 번만 생성자로 주입 받도록 처리했다.

private final MoveStrategy moveStrategy;

public GameService(MoveStrategy moveStrategy) {
    this.moveStrategy = moveStrategy;
}

이후 다시 디버깅을 해보니 원래 의도대로 모든 자동차 인스턴스들이 같은 이동 전략과 난수 생성기를 사용하는 것을 확인할 수 있었다. 앞으로는 틈틈이 디버깅을 수행해서 예상치 못한 오류를 방지하도록 해야겠다.


⛺ View(InputView & OutputView)

그 다음으로는 입력 뷰와 출력 뷰를 구현했다. 사용자의 입력을 받아야 하는 부분은 자동차의 이름(inputCarNames())과 시도할 횟수(inputRounds())이므로 각각을 메서드로 분리해주었다. 사용자에게 보여줄 출력은 시도 횟수만큼 게임이 진행되는 동안의 진행 상황(printProgress())과 최종 우승자(printWinners())이므로 이 또한 각각의 메서드로 분리해서 한 가지의 일만 담당하도록 설계했다.

📥 입력 뷰: InputView

package racingcar.view;

import camp.nextstep.edu.missionutils.Console;

public class InputView {

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

    public String inputRounds() {
        System.out.println("시도할 횟수는 몇 회인가요?");
        return Console.readLine();
    }
}

 

📤 출력 뷰: OutputView

package racingcar.view;

import java.util.List;
import racingcar.model.Car;

public class OutputView {

    public void printProgress(Car car) {
        System.out.println(car.getName() + " : " + "-".repeat(car.getPosition()));
    }

    public void printWinners(List<String> winners) {
        System.out.println("최종 우승자 : " + String.join(", ", winners));
    }
}

🕹 Controller(GameController)

컨트롤러의 역할을 단순하다.

package racingcar.controller;

import java.util.List;
import racingcar.model.Car;
import racingcar.model.Winner;
import racingcar.service.GameService;
import racingcar.view.InputView;
import racingcar.view.OutputView;

public class GameController {

    private final InputView inputView;
    private final OutputView outputView;
    private final GameService gameService;
    private final Winner winner = new Winner();

    public GameController(InputView inputView, OutputView outputView, GameService gameService) {
        this.inputView = inputView;
        this.outputView = outputView;
        this.gameService = gameService;
    }

    public void run() {
        List<Car> cars = gameService.initCarList(inputView.inputCarNames());
        int rounds = gameService.validateRoundIsPositive(inputView.inputRounds());

        System.out.println("실행 결과");
        for (int i = 0; i < rounds; i++) {
            for (Car car : cars) {
                car.move();
                outputView.printProgress(car);
            }

            System.out.println();
        }

        outputView.printWinners(winner.getWinners(cars));
    }
}

코드를 보다시피, gameServiceinitCarList() 메서드를 호출해서 사용자가 입력한 자동차 이름들을 바탕으로 자동차 리스트를 생성 및 초기화하고, 사용자로부터 입력 받은 시도 횟수도 validateRoundIsPositive() 메서드를 호출해서 검증까지 완료된 데이터를 사용한다. 시도 횟수만큼 라운드를 진행하면서 이동 전략이 주입된 Carmove() 메서드를 호출하면서 전진시키고, outputView로부터 진행 상황도 출력해주도록 하고 있다. 게임이 모두 종료되었을 때, 최종적으로 전진 위치의 최댓값을 기준으로 우승자를 선정하고, WinnergetWinners() 메서드를 호출하고, outputViewprintWinners() 메서드로 우승자들을 출력 형식에 맞게 출력하도록 했다.


📜 테스트 코드 작성

이번 과제에서 가장 고민을 많이 하고 신경을 많이 쓴 부분이다. 1주차 과제에서는 코드를 모두 구현하고 프로그램이 다 동작하는 걸 확인하고 억지로 끼워 맞추듯이 테스트를 몇 가지만 작성했었다. 하지만 이번 2주차에서는 아예 요구사항으로 테스트 도구를 사용하라는 내용이 추가된 만큼, 테스트에 대한 학습에 신경썼다.

먼저 테스트 코드를 왜 작성해야 하는가에 대한 내 생각은 “프로그램이 주어진 요구사항에 대해 정확히 동작하는지” 확인하기 위해서라고 생각했다. 그럼 어떤 부분을 테스트 코드로 작성해야 하는지를 생각했을 때, 가장 먼저 떠오른 것은 “각 모델에서 정의한 규칙” 이었다. 요구사항에서 주어진 규칙이라든지, 프로그램이 실행될 때 마땅히 수행되어야 하는 규칙들 말이다. 아래는 각 모델에 대해 Junit5 테스트 프레임워크로 작성한 테스트 코드들이다.

package racingcar.model;

import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;

class CarTest {

    @Test
    void 이름_공백_제한() {
        assertSimpleTest(() ->
                assertThatThrownBy(() -> new Car(" ", 0, () -> false))
                        .isInstanceOf(IllegalArgumentException.class)
        );
    }

    @Test
    void 이름_길이_제한() {
        assertSimpleTest(() ->
                assertThatThrownBy(() -> new Car("이름이6자리", 0, () -> false))
                        .isInstanceOf(IllegalArgumentException.class)
        );
    }

    @Test
    void 이름_특수문자_허용_불가() {
        assertSimpleTest(() -> {
            assertThatThrownBy(() -> new Car("박병욱!", 0, () -> false))
                    .isInstanceOf(IllegalArgumentException.class);
        });
    }
}

이름_길이_제한()과 같은 요구사항으로 주어진 규칙에 대해서는 생각하기 쉬웠지만, 프로그램의 의도와는 거리가 먼 규칙들에 대해 생각할 때는 어려움이 있었다. 지금이야 그나마 쉽게 생각할 수 있는 사용자가 공백을 입력하지 말아야 하거나(이름_공백_제한()), 이름에 엉뚱한 특수문자가 들어가지 말아야 한다(이름_특수문자_허용_불가())는 규칙 정도만 생각할 수 있었다.

 

따라서 프로그램을 구현하는 시점에 모든 경우에 대해 테스트 코드를 작성하는 것은 불가능하다는 생각이 들었다. 다른 모델에 대한 테스트 코드도 마찬가지다.

package racingcar.model;

import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.List;
import org.junit.jupiter.api.Test;

class WinnerTest {

    @Test
    void 우승자_없음() {
        assertSimpleTest(() -> {
            Winner winner = new Winner();
            List<Car> cars = List.of(
                    new Car("자동차1", 0, () -> false),
                    new Car("자동차2", 0, () -> false),
                    new Car("자동차3", 0, () -> false)
            );

            assertThatThrownBy(() -> winner.getWinners(cars))
                    .isInstanceOf(IllegalStateException.class);
        });
    }

    @Test
    void 단일_우승() {
        assertSimpleTest(() -> {
            Winner winner = new Winner();
            List<Car> cars = List.of(
                    new Car("자동차1", 1, () -> false),
                    new Car("자동차2", 2, () -> false),
                    new Car("자동차3", 3, () -> false)
            );
            List<String> winners = winner.getWinners(cars);
            assertThat(winners).containsExactly("자동차3");
        });
    }

    @Test
    void 공동_우승() {
        assertSimpleTest(() -> {
            Winner winner = new Winner();
            List<Car> cars = List.of(
                    new Car("자동차1", 1, () -> false),
                    new Car("자동차2", 2, () -> false),
                    new Car("자동차3", 2, () -> false)
            );
            List<String> winners = winner.getWinners(cars);
            assertThat(winners).containsExactlyInAnyOrder("자동차2", "자동차3");
        });
    }
}

우승자를 선정하는 Winner 모델에서도 만약 어떤 데이터들이 들어왔을 때, 내가 의도한 대로 프로그램이 동작하는지 확인하는 단일_우승(), 공동_우승() 메서드와 “자동차 경주” 라는 도메인에 비춰봤을 때, 모든 자동차가 전진하지 못 했을 때 우승자를 선정하는 것은 프로그램의 의도를 벗어났다고 생각해서 관련 규칙에 대한 테스트(우승자_없음())도 추가했다.

 

그리고 NumberGenerator에 대한 테스트 코드를 작성하려고 할 때, 난관에 봉착하고 말았다. 바로 “랜덤에 대한 테스트는 어떻게 하느냐” 였다. 정확히 말하자면, “테스트를 굳이 해야 하느냐” 였다. 프로그래밍 요구사항에서 주어진 camp.nextstep.edu.missionutils 라이브러리를 사용했는데, 이것에 대한 테스트 코드를 작성한다고 하면, 대단한 개발자 형님들이 고생해서 만들어 놓으신 라이브러리를 왜 사용했는지 의문이 들었다. 그래서 아래와 같이 테스트 코드를 작성했다.

package racingcar.model;

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

import org.junit.jupiter.api.Test;

class NumberGeneratorTest {

    @Test
    void 범위_내_숫자_생성() {
        NumberGenerator gen = new NumberGenerator();
        for (int i = 0; i < 1000; i++) {
            int n = gen.generateNumber(0, 9);
            assertThat(n).isBetween(0, 9);
        }
    }

    @Test
    void 잘못된_범위_예외() {
        NumberGenerator gen = new NumberGenerator();
        assertThatThrownBy(() -> gen.generateNumber(10, 1))
                .isInstanceOf(IllegalArgumentException.class);
    }

}

당연히 “난수 그 자체” 를 테스트하려고 한다면, 상황에 따라 테스트 결과가 달라지기 때문에 테스트 코드를 작성하는 의미가 없을 것이다. 그럼 내가 테스트해야 할 것은, 주어진 요구사항에 맞는 범위 내에서 난수가 적절하게 생성되는지(범위_내_숫자_생성())와 범위가 잘못 전달됐을 때 예외가 잘 발생하는지(잘못된_범위_예외())에 대한 테스트라고 생각했다. 단위 테스트의 목적은 “내 코드가 약속한 동작을 항상 수행하는지” 를 확인하는 것이라는 점을 꼭 기억하도록 해야겠다.

 

그 다음은 이동 전략에 대한 테스트다.

package racingcar.model;

import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

class BasicMoveStrategyTest {
    static class FixedNumberGenerator extends NumberGenerator {
        private final int fixedNumber;
        int min;
        int max;

        FixedNumberGenerator(int fixedNumber) {
            this.fixedNumber = fixedNumber;
        }

        @Override
        public int generateNumber(int min, int max) {
            this.min = min;
            this.max = max;
            return fixedNumber;
        }
    }

    @Test
    void 기준값_미만_전진_불가() {
        assertSimpleTest(() -> {
            FixedNumberGenerator generator = new FixedNumberGenerator(1);
            BasicMoveStrategy moveStrategy = new BasicMoveStrategy(generator);

            assertThat(moveStrategy.isMove()).isFalse();
            assertThat(generator.min).isEqualTo(0);
            assertThat(generator.max).isEqualTo(9);
        });
    }

    @Test
    void 기준값_이상_전진() {
        assertSimpleTest(() -> {
            FixedNumberGenerator generator = new FixedNumberGenerator(7);
            BasicMoveStrategy moveStrategy = new BasicMoveStrategy(generator);

            assertThat(moveStrategy.isMove()).isTrue();
            assertThat(generator.min).isEqualTo(0);
            assertThat(generator.max).isEqualTo(9);
        });
    }
}

이동 전략에 대해 테스트 코드를 작성할 때, 테스트 편의를 위해 기존에 사용하던 NumberGenerator가 아닌, 고정된 값을 반환하는 FixedNumberGenerator를 이동 전략에 주입해주었다. 그리고 요구사항에서 주어진 4라는 기준값을 기준으로 자동차 인스턴스가 전진하는지, 그렇지 않은지에 대한 테스트하도록 처리했다.

 

마지막으로 GameService에 대한 테스트 코드다.

package racingcar.service;

import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;

class GameServiceTest {

    @Test
    void 자동차_이름_중복() {
        assertSimpleTest(() -> {
            GameService gameService = new GameService(() -> false);
            assertThatThrownBy(() -> gameService.initCarList("pobi,woni,pobi"))
                    .isInstanceOf(IllegalArgumentException.class);
        });
    }

    @Test
    void 공백_포함_중복() {
        assertSimpleTest(() -> {
            GameService gameService = new GameService(() -> false);
            assertThatThrownBy(() -> gameService.initCarList("pobi, pobi"))
                    .isInstanceOf(IllegalArgumentException.class);
        });
    }

    @Test
    void 시도횟수_숫자() {
        assertSimpleTest(() -> {
            GameService gameService = new GameService(() -> false);
            assertThatThrownBy(() -> gameService.validateRoundIsPositive("rockernun"))
                    .isInstanceOf(NumberFormatException.class);
            assertThatThrownBy(() -> gameService.validateRoundIsPositive("a1b2c3"))
                    .isInstanceOf(NumberFormatException.class);
            assertThatThrownBy(() -> gameService.validateRoundIsPositive(" "))
                    .isInstanceOf(NumberFormatException.class);
        });
    }

    @Test
    void 시도횟수_양수() {
        GameService gameService = new GameService(() -> false);
        assertSimpleTest(() -> {
            assertThatThrownBy(() -> gameService.validateRoundIsPositive("0"))
                    .isInstanceOf(IllegalArgumentException.class);
            assertThatThrownBy(() -> gameService.validateRoundIsPositive("-1"))
                    .isInstanceOf(IllegalArgumentException.class);
        });
    }

}

기존 GameService에서 예외를 처리하던 로직에 대한 테스트를 그대로 작성해줬다. 물론 내가 생각하지 못한 예외들도 무수히 많을 것이다. 프로그램 목적에 맞기 초기 테스트를 작성하고, 구현 도중에 예외나 버그가 발생하면 그때마다 테스트 케이스를 수정하거나 추가하는 방식으로 단계적 리팩토링 해 나가면 될 것 같다.


😅 아쉬웠던 점 & 느낀 점

일단 가장 아쉬웠던 점은 테스트 방법론에 대한 지식이 전무했다는 것이다. 전체 프로그램을 객체지향적으로 바라보고, 구현하는 데만 집중한 것 같다. 어찌됐든, 프로그램의 존재 이유는 수많은 요구사항을 받아 들여 문제를 해결하기 위함인데, 프로그램이 의도한 대로 정상적으로 동작하는 것을 확인하고 검증하는 테스트 작업에 대해서는 너무 소홀했던 나 자신에 대해 많은 반성을 했다. 껍데기만 그럴싸하고 오류만 펑펑 터지는 프로그램을 사용자들에게 제공하려고 했다는 점이 지금 생각해도 정말 아찔하다.

다음 과제부터는 그 유명한 테스트 주도 개발(Test Driven Development) 방법론을 도입해보면 좋을 것 같다는 생각을 했다. 전체 구조를 설계하고, 무엇을 테스트 해야 할지 미리 정의해 놓고, 발생하는 예외나 버그들에 대해 다시 테스트를 수정하거나 추가하면서 진행하는 것이다. 최종적으로 그 테스트를 통과한 코드만 실제 코드로 사용하도록 하자. 물론 한 번에 적용하는 것은 어렵겠지만, 최대한 테스트를 통해 프로그램이 목적에 맞게 잘 동작하도록 신경쓰는 차원에서도 시도해 보는 것 자체가 의미가 있을 것 같다.

그리고 각 객체에 대한 책임을 철저하게 분리하는 능력도 아직 부족하다고 느꼈다. 책임이 제대로 분리되지 않았다 보니, 그 객체가 수행해야 할 기능이 아님에도 관련 로직을 작성하고, 예외 처리를 엉뚱한 곳에서 하는 등 많은 실수를 범했다. 이제 과제 난이도가 계속해서 높아질 텐데, 그만큼 전체 프로그램 구조를 객체지향적으로 분석하는데 더 많은 시간을 투자해야겠다고 생각했다. 꼭 기억하자. 설계만 잘 한다면 코드 작성하는 건 생각만큼 어렵지 않을 것이다.

 

🎉 제출 결과

0개의 댓글