자동차 경주 게임 - 자바

Kim Dong Kyun·2023년 9월 1일
1

1. StringCalculator

기능 요구사항

  1. 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환 (예: “” => 0, "1,2" => 3, "1,2,3" => 6, “1,2:3” => 6)
  1. 앞의 기본 구분자(쉼표, 콜론)외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 “//”와 “\n” 사이에 위치하는 문자를 커스텀 구분자로 사용한다.
  • 예를 들어 “//;\n1;2;3”과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
  1. 문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.

테스트 요구사항 및 내가 추가한 테스트들

class StringCalculatorTest {
    @Test
    public void splitAndSum_null_또는_빈문자() {
        int result = StringAddCalculator.splitAndSum(null);
        assertThat(result).isEqualTo(0);
        result = StringAddCalculator.splitAndSum("");
        assertThat(result).isEqualTo(0);
    }

    @Test
    public void splitAndSum_숫자하나() throws Exception {
        int result = StringAddCalculator.splitAndSum("1");
        assertThat(result).isEqualTo(1);
    }

    @Test
    public void splitAndSum_쉼표구분자() throws Exception {
        int result = StringAddCalculator.splitAndSum("1,2");
        assertThat(result).isEqualTo(3);
    }

    @Test
    public void splitAndSum_쉼표_또는_콜론_구분자() throws Exception {
        int result = StringAddCalculator.splitAndSum("1,2:3");
        assertThat(result).isEqualTo(6);
    }

    @Test
    public void splitAndSum_custom_구분자() throws Exception {
        int result = StringAddCalculator.splitAndSum("//;\n1;2;3");
        assertThat(result).isEqualTo(6);
    }

    @Test
    public void splitAndSum_negative() throws Exception {
        assertThatThrownBy(() -> StringAddCalculator.splitAndSum("-1,2,3"))
                .isInstanceOf(RuntimeException.class);
    }
    
    @Test // 그냥 contains("-") 로 완전히 막을수도 있음.
    public void splitAndSum_커스텀마이너스_핸들(){
        int result = StringAddCalculator.splitAndSum("//-\n1-2-3");
        assertThat(result).isEqualTo(6);
    }
}

중요한 점은 이 테스트로부터 시작해서

  1. 컴파일을 잡고 (이 때 실제 클래스를 만들고, 매서드를 만들기 "시작한다")

  2. 테스트를 성공시키고 (성공을 위해서 다른 테스트를 끼어넣을 수도 있다)

  3. 필요하다면 추가적인 테스트를 진행한다

구현한 클래스

public class StringAddCalculator {

    public static int splitAndSum(String input) {
        if (isNullOrEmpty(input)) return 0;
        if (containsNegativeNumbers(input)) throw new RuntimeException();
        String[] tokens = splitInput(input);
        return calculateSum(tokens);
    }

    private static String[] splitInput(String input) {
        Matcher matcher = Pattern.compile("//(.)\n(.*)").matcher(input);
        if (matcher.find()) {
            String customDelimiter = matcher.group(1);
            return matcher.group(2).split(Pattern.quote(customDelimiter));
        }
        return splitByCommaOrColon(input);
    }

    private static String[] splitByCommaOrColon(String input) {
        return input.split(",|:");
    }

    private static int calculateSum(String[] tokens) {
        return Arrays.stream(tokens).mapToInt(Integer::parseInt).sum();
    }

    private static boolean isNullOrEmpty(String input) {
        return input == null || input.trim().isEmpty();
    }

    private static boolean containsNegativeNumbers(String input) {
        return input.contains("-") && Arrays.stream(splitInput(input)).anyMatch(s -> Integer.parseInt(s) < 0);
    }
}

2. 자동차 경주 게임

요구사항

1.각 자동차에 이름을 부여할 수 있다. 자동차 이름은 5자를 초과할 수 없다.

2.전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.

3.자동차 이름은 쉼표(,)를 기준으로 구분한다.

4.전진하는 조건은 0에서 9 사이에서 random 값을 구한 후 random 값이 4이상일 경우이다.

5.자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한명 이상일 수 있다.

다이어그램

위와 같은 구조를 채택한 이유?

  1. 테스트부터 시작했기 때문에 가장 최우선적인 조건은 테스트 용이성이었다.

  2. 그에 따라 작은 매서드들이 모여있는 도메인의 필요성을 느꼈다.

  3. 따라서 InputView (입력받는 부분), OutputView(출력 해주는 부분), Car(자동차 게임의 핵심 도메인), Cars(일급 콜렉션 - 불변객체)

  4. 책임을 명확하게 분리하고, 의존 관계는 최대한 없애기 위해 노력했다

그러나, 사용자의 편의를 위해 하나의 매서드로 게임을 진행하길 원했으므로 Controller 는 facade로 활용하여 InputView, OutputView 등을 의존주입 받아 작동한다.


테스트 게임 결과

  1. 한개의 자동차가 우승하는 경우

  2. N개의 자동차가 우승하는 경우


코드 및 내 생각 정리

1. Car

public class Car {
    private final String name;
    private int location = 1;
    public Car(String name) {
        if (name.length() > 5) throw new RuntimeException();
        this.name = name;
    }

    public int getLocation(){
        return this.location;
    }

    public void move(){
        Random random = new Random();
        if (random.nextInt(10) >= 4) this.location++;
    }

    public String getName() {
        return name;
    }
}
  • 생성자에서 제약 조건을 걸어주고 있다. 이게 적절한지는 모르겠다

  • 이전에는 게임의 기능을 생각해서 try-catch 구문으로 막았으나, 이게 더 깔끔한 구조라고 생각했다. 이 객체는 생성되는 순간 이름을 입력받기 때문에, 그 순간에 이름에 대한 validation 이 적용되어야 한다.

    @Test
    void carName_failureTest(){
        String name = "more than five words";
        assertThatThrownBy(() -> new Car(name));
    }
  • 위와 같은 테스트로 확인이 가능하다. RuntimeException 을 던지는 부분은...그냥 귀찮아서 얘로 정했다

  • getter 매서드(행위) 로 상태(location, name)을 반환한다. 일급 컬렉션에서 이 객체에게 직접 물어보기 위한 매서드이다. 상태는 private로 닫혀있다.

  • 핵심 로직은 move() 인데, 테스트를 어떻게 해야하는지 모르겠다...

이 Car 객체의 군집인 Cars 일급 컬렉션을 사용해서, 이 Car들의 핵심 로직인 move를 호출한다.


2. Cars

public class Cars {
    private final List<Car> cars;

    public Cars(String[] nameArr){
        this.cars = Arrays.stream(nameArr).map(Car::new).collect(Collectors.toList());
    }

    public void move(){
        cars.forEach(Car::move);
    }

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

    public List<String> getWinnerNames() {
        int maxLocation = cars.stream()
                .mapToInt(Car::getLocation)
                .max()
                .orElse(0);

        return cars.stream()
                .filter(car -> car.getLocation() == maxLocation) // primitive type 의 filter 는 stream 오버헤드 발생
                .map(Car::getName)
                .collect(Collectors.toList());
    }
}
  • 일급 컬렉션의 장점 (이름을 가지며, 불변하는(세터가 없음), 도메인의 응집버전) 을 생각하며 만들었다.

  • 게임에서는 여러 대의 자동차가 동일한 동작 (50%확률로 움직이기) 를 하고 있으므로, 일급 컬렉션 측에서 해당 객체들에게 메시지를 보내는 것이 좋다고 생각했다.

  • 조금 아쉬운것은, stream() 에서 프리미티브 타입을 사용 한 것이다. 래퍼 타입을 사용할까? 하다가 그냥 만든대로 두었다.

  • stream()은 primitive Type 의 경우 wrapper Class 로 변환하여 사용한다. 이 연산으로 인해 오버헤드가 발생하므로, primitive type은 자제해야 함

Car 를 거느리는 일급 컬렉션. 거느리는 car 들에게 일괄적으로 메시지를 보낼 수 있다.

  • 그리고 위 특성을 생각해서 Observer 패턴 등도 고려했으나, 오버 디자인이라고 생각했다.

3. CarController

public class CarController {
    private final InputView inputView;
    private final OutputView outputView;

    public CarController(InputView inputView, OutputView outputView) { // 둘은 완전히 책임이 다르므로 의존주입 받기
        this.inputView = inputView;
        this.outputView = outputView;
    }

    public void game() throws IOException {
        String[] carNames = this.getCarNames();
        Cars cars = new Cars(carNames);
        outputView.requestTrials();
        int trial = Integer.parseInt(inputView.getInput());
        this.getResult(cars, trial);
    }

    private void getResult(Cars cars, int trial) {
        outputView.printResult(cars); // 최초 상태의 표현
        for (int i = 0; i < trial; i++) {
            cars.move();
            outputView.printResult(cars);
        }
        outputView.printWinner(cars);
    }

    private String[] getCarNames() throws IOException {
        outputView.requestNames();
        String input = inputView.getInput();
        return inputView.getCarNames(input);
    }
}
  • 사실상 제일 궁금한 녀석

  • 사용자에게는 하나의 인터페이스(game()) 만 제공 해 주기 위해서 위와 같이 작성했으나, 이게 맞을지 감이 오진 않는다.

  • inputView, outputView 는 확실히 관심사가 다르다고 생각했다.

  • 따라서 인펏뷰나 아웃풋뷰의 내부의 변경이 있어도 큰 상관 없이 사용할 수 있도록 생성자를 통해 주입받는 방식을 사용했다.

사용자에게 game() 인터페이스를 제공해주는 컨트롤러


입출력

public class InputView {
    public String[] getCars(String s) {
        return s.split(",");
    }

    public String getInput() throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        return br.readLine();
    }
}
  • 버퍼드리더를 통해 인펏을 받는다.

  • 입력값을 쪼개서 String[] 배열로 리턴해주는 역할도 담당한다.

  • 추가적으로 위에서 생성자에 validation 을 거는 것이 아니라, 이 인펏라인에서 생성의 제한을 거는 방식도 가능하다.

그러나 나는 해당 방식이 도메인의 관심사를 응집시키지 않고, 오히려 흩어버린다고 생각해서 여기서는 순수 입출력만 도맡는다.

public class OutputView {
    public void requestNames(){
        System.out.println("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).");
    }

    public void requestTrials(){
        System.out.println("시도할 회수는 몇회인가요?");
    }

    public void printResult(Cars cars){
        List<Car> carList = cars.getCars();
        for (Car car : carList) {
            System.out.println(printCarNameWithLocation(car));
        }
        System.out.println();
    }

    public void printWinner(Cars cars){
        List<String> winnerNames = cars.getWinnerNames();
        if (winnerNames.size() == 1){
            System.out.println(winnerNames.get(0) + "가 최종 우승했습니다.");
        }
        if (winnerNames.size() > 1){
            String sb = String.join(", ", winnerNames) +
                    "가 최종 우승했습니다.";
            System.out.println(sb);
        }
    }

    private String printCarNameWithLocation(Car car) {
        return car.getName() + " : " + printAsMinus(car.getLocation());
    }

    private String printAsMinus(int location){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < location; i++) {
            sb.append("-");
        }
        return sb.toString();
    }
}
  • 사용자에게 메시지를 뿌리는 녀석

  • 이녀석은...필연적으로 패러미터에 Cars를 받아야 한다 (결과값을 얻기 위해)

  • Cars 객체에서 get 메시지를 통해 보내는 값들을 화면에 뿌려준다.


이상한 부분, 아주 작은 것이라도 좋으니 피드백 부탁드려요!

ex) 변수명, 매서드명이 별로에요!

질문 리스트

  1. Controller 는 Facade 로 의도하고 사용했는데, Car, Cars, InputView, OutputView 와 적든 많든 의존관계에 놓여있다. 이게 맞는 구현인지 궁금함

  2. Input단에서 사용자의 입력값이 적절한지 걸러 받기 vs Car 객체 스스로가 생성 될 때 맞는 입력만을 걸러 받기

  • 혹은 둘 다?

0개의 댓글