이번 2주차 미션에서는 MVC패턴에 대해 학습할 수 있는 좋은 기회였다. 첫번째 회고에서는 MVC 패턴을 배우지 않는 줄 알았는데 리팩터링 개념으로 들어가 있었다. 처음 적용해보는 것이라 솔직히 어려웠다. 그래도 어느정도 쉽게 이해는 가능했다.
뭔가... 백엔드 개발자에 한 발자국 가까워진 느낌? 근데 1주차 하면서도 느꼈지만 기본 틀만 주어진 채로 독학하는 경우가 많다. 솔직히 우테코 안해고 했다면 정말 많이 헤매고 재미를 느끼지 못했을지도... 그래도 재밌다.

일단 자동자 경주 게임 자체는 쉽게 만들 수 있다. 하지만 depth를 1이상 작성할 수 없다는 점을 고려하여 모듈화를 해야한다. 그러나 모듈화를 하지 않고 쉽게 진행할 수 있는 법이 있다. 바로 stream 라이브러리이다.
public List<String> findWinners() {
int maxPosition = racingCars.stream()
.mapToInt(RacingCar::getForwardCount)
.max()
.orElse(0);
List<String> winners = new ArrayList<>();
for (RacingCar car : racingCars) {
checkWinners(car, maxPosition, winners);
}
return winners;
}
만약 stream 라이브러리를 활용하지 않는다면 for문을 통한 순회 + if문을 통한 maxPosition에 해당하는지 확인해주어야 한다. 그러나 stream라이브러리르 활용함으로써 간단하게 작성할 수 있다.
MVC패턴에 대한 학습이 있었다. Model(domain), View, Controller를 통해 책임과 역할을 분리하는 방법이다. 서로 의존하지 않기 때문에 한 부분을 수정해도 다른 부분을 수정하지 않아도 되는 이점이 있다고 한다. Controller는 정말 게임 컨트롤러처럼 Model, View를 통해 전체 코드를 총괄하는 느낌인 것 같다. 학습은 어려웠지만 구분을 해주니 코드도 깔끔해지고, 가독성도 좋아졌다.
나 같은 경우에는 다음과 같이 Model, View, Controller를 분리하였다.
원래는 View 하나였는데 피드백을 통해 input, output으로 구분하였다. 확실히 가독성이 더 좋아진다.
각각 코드는 다음과 같다.
public class RacingCar {
private final String carName;
private int forwardCount = 0;
RacingCar(String carName){
if(carName == null || carName.isEmpty()) throw new RuntimeException("제대로된 자동차 이름을 입력해주세요.");
if(carName.length() > 5) throw new RuntimeException("자동차 이름은 5자 이하로 설정해주세요.");
this.carName = carName;
}
public String getCarName(){
return carName;
}
public void move(int randomNumber){
if(randomNumber >= 4) ++forwardCount;
}
public int getForwardCount(){
return forwardCount;
}
}
레이싱 자동차 객체를 담당한다. 멤버변수로는 이름과 전진 횟수가 있다. 생성자를 통해 이름 규칙 예외를 확인한다. move 메소드를 통해 전진 할 수 있다.
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class RacingGame {
private final List<RacingCar> racingCars = new ArrayList<>();
private final Random random;
private int runCount;
public RacingGame(Random random){
this.random = random;
}
public void initializeGame(String[] carNames, int runCount) {
this.runCount = runCount;
for (String name : carNames) {
racingCars.add(new RacingCar(name));
}
}
public void playRound() {
for (RacingCar car : racingCars) {
car.move(random.nextInt(10));
}
}
public int getRunCount() {
return runCount;
}
public List<RacingCar> getRacingCars() {
return racingCars;
}
public List<String> findWinners() {
int maxPosition = racingCars.stream()
.mapToInt(RacingCar::getForwardCount)
.max()
.orElse(0);
List<String> winners = new ArrayList<>();
for (RacingCar car : racingCars) {
if (checkWinner(car, maxPosition)) {
winners.add(car.getCarName());
}
}
return winners;
}
private boolean checkWinner(RacingCar car, int maxPosition){
return car.getForwardCount() == maxPosition;
}
}
우승한 차를 찾거나 차를 전진하도록 하는 메소드들이 담겨있다.
import java.util.Scanner;
public class InputView {
static Scanner in = new Scanner(System.in);
public String inputCarName(){
System.out.println("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).");
String carName = in.nextLine();
if(carName == null || carName.isEmpty()) throw new RuntimeException("자동차 이름을 제대로 입력해주세요.");
return carName;
}
public int inputRunCount(){
System.out.println("시도할 횟수는 몇 회인가요?");
int runCount;
try{
runCount = in.nextInt();
runCountMinusCheck(runCount);
}
catch (NumberFormatException e) {
throw new RuntimeException("정수만 가능합니다.");
}
return runCount;
}
private void runCountMinusCheck(int runCount){
if(runCount <= 0) throw new RuntimeException("실행횟수가 0이하 입니다.");
}
}
import java.util.List;
public class OutputView {
public void printGameStart() {
System.out.println("\n실행 결과");
}
public void printRound(List<RacingCar> racingCars) {
for (RacingCar car : racingCars) {
System.out.println(car.getCarName() + " : " + "-".repeat(car.getForwardCount()));
}
System.out.println();
}
public void printWinner(List<String> winnerCars){
System.out.printf("최종 우승자 : %s%n", String.join(", ", winnerCars));
}
}
public class GameController {
private final RacingGame game;
private final InputView inputView;
private final OutputView outputView;
public GameController(RacingGame game, InputView inputView, OutputView outputView){
this.game = game;
this.inputView = inputView;
this.outputView = outputView;
}
public void startGame() {
String[] carNames = inputView.inputCarName().split(",");
int runCount = inputView.inputRunCount();
game.initializeGame(carNames, runCount);
outputView.printGameStart();
for (int i = 0; i < game.getRunCount(); ++i) {
game.playRound();
outputView.printRound(game.getRacingCars());
}
outputView.printWinner(game.findWinners());
}
}
각 model, view, controller에 따라 피드백을 정리해보았다.
public void initializeGame(String[] carNames, int runCount) {
this.runCount = runCount;
for (String name : carNames) {
racingCars.add(new RacingCar(name));
}
}
다만 나는 이 자체로 괜찮다고 생각하여 피드백을 반영하지는 않았다. setCarNames, setRunCount 두 메소드로 분리하기를 추천하셨지만 두개를 만들 까닭은 딱히 없다고 생각했다.
List<String> winners = new ArrayList<>();
for (RacingCar car : racingCars) {
checkWinners(car, maxPosition, winners);
}
return winners;
}
private void checkWinners(RacingCar car, int maxPosition, List<String> winners){
if (car.getForwardCount() == maxPosition) {
winners.add(car.getCarName());
}
}
다만 이 형태는 winners 라는 리스트가 checkWinners가 호출되면서 값이 바뀌는 형태로 check라는 메소드 이름에 걸맞지 않게 값을 변경하고 있다는 리뷰를 받았다. depth에만 신경써서 아예 생각지 못한 부분이었다.
리뷰어님은 다음과 같은 코드로 변경하기를 추천하셨다.
List<String> winners = new ArrayList<>();
for (RacingCar car : racingCars) {
if (checkWinner(car, maxPosition)) {
winners.add(car.getCarName);
}
}
return winners;
}
private boolean checkWinner(RacingCar car, int maxPosition){
return car.getForwardCount() == maxPosition;
}
이러면 depth는 1 초과지만 메소드 이름에는 걸맞게 된다. depth 1 맞추는게 진짜 극악이었다.
input과 output을 분리하라.
앞에도 작성했듯이 input과 output을 분리하지 않고 통틀어 하였는데 분리해보라는 피드백이 들어왔다. 확실히 분리하니 가독성이 좋아졌다.
유효성 검증은 어디까지?
input으로 들어오는 값이 예외 처리해야하는 값에 해당하면 예외를 일으켜야 한다. 하지만 이 예외는 어디서 일으켜야하는가? 예외를 어디서 검증하는 것이 좋을까? Model? View? 이에 대해 내 생각은 어떤지 물어보셨다.
나는 이렇게 답변했다.
저는 유효성 검사는 view 에서 하는게 맞다고 생각했습니다. 일단 요구사항에는 없었지만 유효하지 못한 값을 입력 받았을 때 유효한 값을 받을 때까지 input을 받는다고 가정하면 그 과정은 view에서 처리하는 것이 맞다고 생각하여 그렇게 진행했습니다! 하지만 지금 다시 생각해보면 model중 RacingCar 클래스의 멤버변수로 runcount가 있으므로 model에서 처리하는 것도 맞다고 생각합니다.
이것 말고도 input에서 예외가 일어났다면 더 빨리 프로그램에 예외를 전달할 수 있기 때문이라고 생각했다. 물론 model 쪽에서도 검증이 가능하겠지만... 그러면 뭔가 애매한 느낌? 어찌됐든 그렇게 생각했다.
문자열 결합 비용 줄이기
원래는 덧셈을 활용하여 문장을 출력했다.
System.out.println(car.getCarName() + " : " + "-".repeat(car.getForwardCount()));
작성할 때도 생각했지만 너무 비효율적이 아닌가 생각했다. 자바프로그래밍 수업에서도 배웠지만 2개 이상의 문자를 결합할 때 덧셈을 활용하는 것은 굉장히 비효율적이라고 한다. 하지만 방법이 없어 가만히 두었는데 리뷰어님께서 지적해주셨다. string.foramt을 활용하면 문자열 결합 비용을 줄일 수 있다.
그래서 다음과 같은 형태로 코드를 바꾸었다.
for (RacingCar car : racingCars) {
System.out.printf("%s : %s%n", car.getCarName(), "-".repeat(car.getForwardCount()));
}
패키지를 통해 view, model, controller를 분리해보자.
자바가 패키지를 통해 관리하는 것은 알고 있었지만 실제로 적용할 생각은 못했다. 괜히 적용했다가 고장날까봐... 그래서 이번 피드백에는 적용하지 못했다. 다음 미션부터 적용하려 한다.
단위 테스트
mock을 활용한 랜덤 테스트
현재 RacingCar가 움직일 수 있는 방법은 랜덤 값이 4이상일 때 뿐이다. 랜덤 값을 통해 결과가 바뀔 수 있는 코드는 단위 테스를 하기 어렵다. 그렇기 때문에 등장한 것이 mock을 활용한 단위 테스트이다. Mock은 가짜를 의미하는데 가짜 값을 설정하여 랜덤 값 대신 mock에서 설정한 값을 전달한다는 의미이다. 이렇게 랜덤 대신 설정 값을 전달하여 단위 테스트를 원하는 대로 진행할 수 있다.
이번 미션에서는 MVC패턴 학습 및 랜덤 값에 대한 단위 테스트를 배울 수 있었다. MVC패턴은 처음 배우는 것이지만 직관적이기 때문에 잘 이해가 됐다. 하지만 단위 테스트가 너무 이해가 안가고 어려워서 힘들었다. 어떻게 테스트를 작성해야 할지도 잘 생각이 안나서 더 어려웠던 것 같다. 앞으로는 테스트 작성 방법에도 더 힘을 써야할 것 같다. 너무 어렵다...