우테코 프리코스 [자동차 경주 게임] 구현 후기 (2)

Jii·2023년 9월 5일
0

우테코

목록 보기
2/5

지금까지 총 4번의 구현을 했는데, 3번째랑 4번째는 결이 많이 비슷해 4번째 코드를 바탕으로 설명을 작성하겠습니다!

구조

먼저 구조는 이렇게 잡았습니다

다른 분들 구조 잡은 거 보면 domain폴더 등 있던데, 이것은 mvc패턴에 대해 좀 더 공부하고 그 때 개선해 보도록 하겠습니다..!

먼저 저는 흉내만 내보았는데,
controller에서는 요청만 처리하고 핵심 로직은 service계층으로 넘겨주었습니다.
그리고 이에 대한 응답은 view 에서 처리해주었습니다.
또 default 클래스인 Car의 일급컬렉션 Cars를 만들어주어 이 두 개의 클래스를 모델로 하였구요.

io는 입출력 관리하는 곳으로 사용자 값을 읽어오는 Reader클래스와 출력을 담당하는 Printer 클래스로 나뉘는데요, 지금 생각해보니 굳이 io 폴더 또 만들지 말고 그냥 view 폴더 내에서 처리했어도 될 것 같다는 생각이 드네요..?

마지막으로 utils 폴더에는 여러 상수와 랜덤하게 숫자 만들어주는 클래스, position만큼 "-"로 변환해주는 클래스를 넣어주었습니다.

배열 VS 리스트

사용자가 입력한 이름들을 배열에 담을지 리스트에 담을지 많이 고민하였는데, 찾아보니 대부분의 상황에서 웬만하면 리스트를 사용하는 것을 권장하길래 그 이유에 대해 간단히 서술해보도록 하겠습니다.

  • 배열: 한번 크기를 지정하면 변경이 불가하므로 고정된 크기를 가짐
    - 정적 할당
    • 메모리 상에 데이터 연속적으로 존재
      - 처음 배열의 크기를 5로 지정한다면 데이터가 다 채워지든 덜 채워지든 배열의 크기는 항상 5로 동일
    • 메모리 낭비 발생 가능 (배열 크기는 정해져 있지만 그 크기만큼 데이터를 다 채우지 않았을 경우)
      => 데이터 갯수가 확실히 정해져 있으며, 접근이 빈번할 경우 사용

  • 리스트: 빈틈없는 데이터 적재가 가능하므로 크기가 가변적이며, 동적 배열이라 볼 수 있음
    - 동적 할당
    • 데이터들이 순차적으로 구성된 집합이지만, 데이터가 메모리 상에 연속적으로 있진 않음(랜덤 주소)
    • 처음 배열의 크기를 5로 지정해도, 원소가 그 이상으로 많아져도 자동으로 사이즈를 키워 관리해줌
      => 데이터 갯수 정해지지 않았을 때 사용하면 좋음

일급콜렉션

위 이유 뿐만 아니라 일급 콜렉션 사용을 위해 저는 Car클래스에 대한 일급콜렉션 Cars를 만들어 멤버변수를 오직 Car list만 넣어주었습니다.

public class Cars {

    List<Car> carList;

    public Cars(List<String> carListByString) {
        List<Car> carList = new ArrayList<>();
        validateCarsIfNameIsDupulicate(carListByString);
        for (String car : carListByString) {
            carList.add(new Car(car));
        }
        this.carList = carList;
    }

    public static void validateCarsIfNameIsDupulicate(List<String> carListByString) {
        if (carListByString.size() != carListByString.stream().distinct().count()) {
            throw new IllegalArgumentException(MessageConsts.NAME_DUPLICATE_EXCEPTION);
        }
    }

    public List<Car> getCarList() {
        return carList;
    }

    public void moveCars(RandomNumberGenerator randomNumberGenerator) {
        for (Car car : carList) {
            car.moveCar(randomNumberGenerator);
        }
        System.out.println();
    }

    public int getMaxPosition() {
        int maxPosition = 0;
        for (Car car : carList) {
            maxPosition = Math.max(car.getLastPosition(), maxPosition);
        }
        return maxPosition;
    }

    public String getWinner() {
        ArrayList<String> winnerList = new ArrayList<>();
        int maxPosition = getMaxPosition();
        for (Car car : carList) {
            if (car.getLastPosition() == maxPosition) {
                winnerList.add(car.getName());
            }
        }
        return String.join(", ", winnerList);
    }
}

일급콜렉션 사용으로 인한 장점은! 전 게시물에서도 언급하였긴 하지만 다시한 번 간단히 설명하고 넘어가도록 하겠습니다.

  1. Cars외부에서 car리스트에 접근 시 add, remove 등 list의 모든 기능을 보여주는 것이 아닌,
    사용자가 Cars클래스 내부에서 만든 메서드들만 사용할 수 있게끔 해줍니다.
    여기서는 car list로 활용할 수 있는 것은 getCarList, moveCars, getMaxPosition, getWinner 이죠.
    이로 인해 다른 개발자들이 car 리스트를 멋대로 수정할 수 없게 되는 것입니다!

  2. 리스트를 생성하는 생성자에서 검증로직 관리해줌으로 인해,
    다른 개발자들이 이 리스트가 검증로직이 필요한지 몰라도 충분히 문제없이 사용이 가능합니다.
    다른 개발자들은 그저 검증되어 생성된 리스트만을 사용하면 되기 때문입니다.

잘못된 값 처리: 재귀 VS while문

이번 구현 조건 중에서 - 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다. 라는 조건이 있었습니다.

그리고 이에 대한 대중적인 구현 방법을 2가지를 소개해보고자 합니다.

  1. while문
    다음은 사용자로부터 시도할 횟수를 입력받는 메서드입니다. true 상태인 무한 반복 구간에서 맞는 값을 입력했을 시에만 return을 통해 빠져나올 수 있게 됩니다.
    public int inputTryTimes() {
        while (true) {
            try {
                printer.printTryTimesMsg();
                return reader.readTryTimes();
            } catch (IllegalArgumentException e) {
                System.out.println(MessageConst.INPUT_TRYTIMES_EXCEPTION_MSG);
            }
        }
    }
  2. 재귀문
    다음은 사용자로부터 자동차 이름들을 입력받는 메서드입니다.
    public Cars inputNames() {
        try {
            printer.printInputNamesMsg();
            List<String> carNames = reader.readNames();
            return new Cars(carNames);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
            return inputNames();
        }
    }
    예외 발생 시 자기 자신의 메서드를 다시 호출함으로써 예외발생한 그 부분부터 입력을 다시 받는 것을 볼 수 있습니다.

찾아본 결과 !

while문이 재귀보다는 성능이 더욱 좋다고 합니다.

하지만 while문이 재귀보다는 가독성 측면에서 좋지 않죠.

따라서 우리 프로그램은 매우 간단하기 때문에 성능부분에서 별 차이 없을 거 같아서 가독성이 나은 재귀문을 사용하는 게 더 좋을 거라 판단했습니다.

StringBuilder

이전 코드에서는 StringBuilder의 존재를 모르고 "-"연결을 매번 for문을 통해 아래와 같이 이어주었습니다.

public String moveResult() { 
        String dash = "";
        for (int i = 0; i < position; i++) {
            dash += "-";
        }
        return dash;
    }

이로 인한 문제점은 바로
String 객체는 한번 생성되면 할당된 메모리 공간이 변하지 않기 때문에
+연산자를 사용하였더라도 매번 새로운 String 객체를 생성한다는 것입니다.
이로 인해 성능이 매우 안좋아지게 됩니다.

기존에 생성된 String 클래스 객체 문자열에 다른 문자열을 붙이는 것이 아니라, 새로운 String 객체를 만든 후 새 String 객체에 연결된 문자열을 새로 저장하고 그 객체를 참조하도록 하기 때문입니다.

String 객체는 이러한 이유로 문자열 연산이 많은 경우 성능이 좋지 않습니다.

이러한 이유로 위의 단점을 상쇄시켜줄 StringBuilder를 추천합니다.!!!

문자열을 추가할 때마다 매번 새로운 객체를 생성하는 것이 아닌, toString을 통해 String객체로 변환할 때 한번 객체가 생성되므로 성능이 훨씬 좋아지게 됩니다.

public static String positionToDash(int position) {
        StringBuilder moving = new StringBuilder();
        for (int i = 0; i < position; i++) {
            moving.append("-");
        }
        return moving.toString();
    }

상수 클래스

저는 나중의 유지 보수를 위해 문자 및 숫자 상수 클래스 파일을 따로 만들어 빼두었습니다.

public class MessageConsts {
  public static final String INPUT_CAR_NAME_MSG = "경주 할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)";
  public static final String INPUT_TRY_TIMES_MSG = "시도할 회수는 몇회인가요?";
  public static final String GAME_RESULT_MSG = "실행 결과";
  public static final String WINNER_MSG = "최종 우승자 : ";
  public static final String NAME_LENGTH_EXCEPTION = "[ERROR] - 이름은 " + NumberConsts.NAME_LIMIT + "자 이하로 입력해주세요.";
  public static final String NAME_DUPLICATE_EXCEPTION = "[ERROR] - 중복된 이름입니다.";
  public static final String TRY_TIMES_TYPE_EXCEPTION = "[ERROR] - 시도 횟수는 숫자여야 합니다.";

}
public class NumberConsts {
    public static int START_INCLUSIVE = 0;
    public static int END_INCLUSIVE = 9;
    public static int NAME_LIMIT = 5;
}

따라서 이 변수들을 여러 곳에서 사용하는 경우, 그 곳 모두 변경하는 것이 아니라 상수 클래스만 변경하면 되기 때문에, 변경 구간이 확 줄어들어 유지보수에 편리하게 됩니다.

코드 흐름

  1. 처음 Application에서는 필요한 객체들을 생성해주고 controller클래스의 start메서드를 실행해주었습니다.
ublic class Application {
    public static void main(String[] args) {
        Printer printer = new Printer();
        Reader reader = new Reader();
        CarService carService = new CarService(new RandomNumberGenerator());
        GameView gameView = new GameView();
        GameController gameController = new GameController(printer, reader, carService, gameView);
        gameController.start();
    }
}
  1. GameController 에서는 car이름들과 시도 횟수를 입력받은 뒤 carService 를 호출하여 핵심 로직을 처리한 후
    gameView를 통해 결과를 출력해주었습니다.
public class GameController {
    Printer printer;
    Reader reader;
    CarService carService;
    GameView gameView;

    public GameController(Printer printer, Reader reader, CarService carService, GameView gameView) {
        this.printer = printer;
        this.reader = reader;
        this.carService = carService;
        this.gameView = gameView;
    }

    public void start() {
        Cars cars = inputNames();
        int tryTimes = inputTryTimes();
        carService.execute(cars, tryTimes);
        gameView.renderAllResult(tryTimes, cars);
    }

    public Cars inputNames() {
        try {
            printer.printNameMsg();
            List<String> carList = reader.readNames();
            return new Cars(carList);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
            return inputNames();
        }
    }

    public int inputTryTimes() {
        try {
            printer.printTryTimesMsg();
            return reader.readTryTimes();
        } catch (IllegalArgumentException e) {
            System.out.println(MessageConsts.TRY_TIMES_TYPE_EXCEPTION);
            return inputTryTimes();
        }
    }
}
  1. CarService 에서는 차들을 움직이는 핵심로직을 수행하였습니다.
public class CarService {
    private final RandomNumberGenerator randomNumberGenerator;

    public CarService(RandomNumberGenerator randomNumberGenerator) {
        this.randomNumberGenerator = randomNumberGenerator;
    }

    public void execute(Cars cars, int tryTimes) {
        for (int i = 0; i < tryTimes; i++) {
            cars.moveCars(randomNumberGenerator);
        }
    }
}

모델(Cars, car) 관련한 코드는 깃허브에서 확인 바랍니다!
https://github.com/Jiihyun/java-racingcar-practice

  1. 마지막으로 GameView클래스에서는 오직 view와 관련된 로직(출력)만 처리하려고 많이 애썼습니다..
public class GameView {
    public void renderAllResult(int tryTimes, Cars cars) {
        System.out.println(MessageConsts.GAME_RESULT_MSG);
        List<Car> carList = cars.getCarList();
        for (int i = 0; i < tryTimes; i++) {
            renderEachPersonResult(carList, i);
        }
        System.out.print(MessageConsts.WINNER_MSG);
        System.out.println(cars.getWinner());
    }

    public void renderEachPersonResult(List<Car> carList, int index) {
        for (Car car : carList) {
            System.out.println(car.getName() + " : " + StringUtils.positionToDash(car.getPosition(index)));
        }
        System.out.println();
    }
}

이상 자동차 구현을 마무리하겠습니다!!

이렇게 4번 정도 구현하고 나서야,,그나마 맘에 드는?? 다음에도 어느정도 구조를 비슷하게 잡아야겠다고 생각이 드는 코드를 짜게 되었습니다.

역시 반복이 짱이네요,,,,

다음에는 깃허브에서 다른 분들 구조 및 코드 읽어보고 좀 더 리팩토링을 시도해보도록 하겠습니다!

읽어주셔서 감사합니다 :)

profile
Empower Yourself

0개의 댓글