우테코

[우테코] 프리코스 2주차 자동차 경주 회고

wldud8297 2025. 10. 27. 03:38

0. 과제 설명

8기의 2주차 문제는 자동차 경주였다.

자동차 이름과 경주 횟수를 입력했을 때, 게임 기록과 우승자를 출력해야한다.


지난주 피드백

 

1. 깃 커밋
2. 디버깅 사용하기
[우테코 2주차] 디버깅으로 입출력 예외 분석하기로 디버깅 적용 ⭕️
[우테코 2주차] 개발 과정에서 디버깅 적용해보기로 디버깅 적용 ⭕️

 

 

코드 리뷰 피드백
1. 자바 코드 컨벤션
- 연산자 양쪽에 공백 사용
- 불필요한 빈칸 띄어쓰기 없애기
(Google Java Style Guide 4.6.2 Horizontal whitespace 참고)
→ 반영 ⭕️
2. validator에서 이름 관례
- check는 단지 상태를 확인하는 용도로 사용
- void 메서드의 경우 validate를 붙여서 사용
- 유효 여부만 판단할 경우 is를 붙여서 사용
→ 반영 ⭕️
3. 도메인 검증
- 도메인 관련 책임은 도메인에서 지기
→ 코드 중복을 고려하여, 그대로 validator에서 책임지기로 함 (현행 유지)
4. 입출력 책임을 View로 분리
→ 반영 ⭕️

1.  나에게 적용해보기

다른 사람들의 코드를 보니, 전체 테스트가 아니라, 계층별로 나눠서 테스트를 한것이 인상적이었다. 그래서 나도 이번에는 전체 테스트 대신 기능별로 테스트를 해보려고 한다.

오늘의 키워드는 "기능별 테스트"이다 ⭐️

 

추가로 이 과정에서 공통 피드백으로 주어진 디버깅을 활용해보겠다.


2. 개발 시작

2-0. 리드미 작성

 

우선 문제에서 주어진대로 리드미를 작성하였다.

 

 

기본적인 틀을 이정도로 잡고, 개발 과정에서 추가로 리드미를 업데이트 하도록 하겠다.


파일 구조

지난주와 달라진 점이 있다.

이번주는 입출력의 양이 많아 Controller 계층에서 사용자 입출력 관한 기능들을 View로 분리하였다. 또한 Service 계층에서 DTO를 도입하여, Controller로 전달할 데이터를 변환하는 기능을 분리하였다.

 

Controller(Controller, Message, View)
Service(Service, Dto)
Domain
Exception(ErrorMessage, Validator)
  • View에서는 입출력 기능, controller에서 받은 값을 출력 형식으로 포맷팅하는 역할을 수행한다.
  • Controller에서는 view를 호출하며, 사용자 입력값을 Service로 넘길 적절한 데이터 형식으로 파싱한다.
  • Dto는 Service에서 Controller로 넘기기 위한 데이터 전달용 클래스로 사용하였다.
  • Service에서는 도메인을 이용해 비즈니스 로직을 수행하고, 결과를 DTO로 변환하여 Controller에 전달한다.

입출력을 view로 분리함으로써 역할 경계를 분명히 해야하므로,
아래의 기준을 짚고 넘어가겠다.

View : 사용자에게 보여지는 걸 책임지고 포맷팅 (출력 형식 맞추기)
Controller : 서비스에 넘겨질 데이터를 책임지고 파싱 (서비스 형식에 맞추기)

 


나는 Controller-Service-Domain의 계층 구조를 사용한다.
따라서, 각 클래스에는 여러 역할이 섞여있을 수 있다.

이 계층 구조에서 단일한 책임을 가진 클래스로 분리한 것이 View, Validator, Message, ErrorMessage, Dto 이다.

 

 

View : 입출력 기능 (출력값 파싱, 입력값 파싱 X)
Validator : 검증 기능
Message : 출력 메세지 폼
ErrorMessage : 에러 메세지 폼
Dto : Service → Controller로 결과 전달용 클래스


2-1. Message 작성

1) 출력 메세지 폼 작성
2) 에러 메세지 폼 작성

 

지난주와 동일하기 때문에 생략하도록 하겠다.

 

Message와 ErrorMessage를 테스트 하는 것은 사실상 사용자 입출력 기능 테스트와 연관이 있으므로, Controller 계층에서 테스트 하도록 하겠다.


2-2. Controller 작성

1) 자동차 이름 문자열을 구분자 기준으로 쪼개는 기능 추가
2) 사용자 입출력 기능 추가(View로 분리)
3) View 호출 - 입출력 관련 기능
4) Service 호출 - 비즈니스 로직 기능

 

Controller에서는 View와 Service를 내부 속성으로 두고, 외부에서 의존성을 주입해주었다.

// RacingController.java
public class RacingController {
    private View view;
    private RacingService racingService;

    public RacingController(View view, RacingService racingService){
        this.view = view;
        this.racingService = racingService;
    }
}

사용자 입출력 기능 추가 (View로 분리)

 

원래는 입출력 관해서는 Controller에서 모두 작성하였다. 하지만 코드가 많아질 수록 controller안에 모든 코드를 넣으니 가독성이 떨어졌다. 따라서, 입출력에 관한 코드만 View 클래스로 분리하였다.

 

Console.readLine()으로 입력을 받고, 검증기(Validator)를 호출하여 값이 안전한지 확인한다.

 

// controller/View.java
// 입력
public String getInputMessage(){
    String inputMessage = Console.readLine();
    return inputMessage;
}

// null이 아닌지 확인
public String getNotNullInputMessage(){
    try{
        String inputMessage = getInputMessage();
        Validator.validateNotEmpty(inputMessage);
        return inputMessage;
    } catch(NoSuchElementException e){
        throw new IllegalArgumentException(ErrorMessage.INPUT_NULL_ERROR.getMessage());
    }
}

// 사용자에게 자동차 이름 입력 요청
public String requestCarName(){
    printMessage(Message.REQUEST_RACING_CAR_NAME);
    String inputCarName = getNotNullInputMessage();
    Validator.validateNotStartByComma(inputCarName);
    Validator.validateNotEndByComma(inputCarName);
    return inputCarName;
}

 

또한 출력 결과를 포맷팅하는 역할도 수행한다.

// controller/View.java
public void printMessage(String message){
    System.out.println(message);
}

public void printResult(List<Map<String, Integer>> gameHistory, String finalWinner){
    printMessage(Message.PRINT_RESULT_START);
    for (Map<String, Integer> history : gameHistory){
        printOneRoundCarPosition(history);
    }
    printMessage("");
    printMessage(String.format(Message.PRINT_FINAL_WINNER,finalWinner));
}

 

입출력 관련 기능을 View로 분리한 덕분에 Controller 코드가 간단해지고, 흐름을 이해하기 수월해졌다.

 


자동차 이름 문자열을 구분자 기준으로 쪼개는 기능

 

이 기능은 Service에서 할지, Controller에서 할지 고민했으나, 결국 Controller에서 수행하기로 하였다.

 

왜냐하면 Service의 그 어떤 메소드도 자동차 문자열을 필요로하는 로직이 없기 때문이다. Service 내부 로직을 돌리는데 필요한 것은 자동차 문자열이 아니라 자동차 리스트이다. 그렇다면 계층을 넘어갈 때, 미리 안전한 값을 넘겨주는 것이 좋다고 생각했다.

 

또한 Service에서 문자열을 split한다면 "자동차 이름은 중복될 수 없다"라는 검증을 나중으로 미뤄야하는데, 안전하지 않은 값은 Controller 계층에서 미리 쳐내는게 낫다고 생각해서, Controller에서 수행하였다.

 

// controller/RacingController.java
public List<String> splitCarName(String carNameString){
    List<String> carNameList = List.of(carNameString.split(","));
    Validator.validateCarNameIsUnique(carNameList);
    return carNameList;
}

2-3 Controller Test 작성

입출력이 올바른지 바로 테스트해보도록 하겠다.

 

1) NsTest 상속

우테코에서 제공해준 테스트는 NsTest를 사용한다.

 

 

NsTest 내부에 이미 콘솔을 캡쳐하는 기능이 포함되어있기 때문에, 이걸 그대로 사용하면 System.setOut()이나 ByteArrayOutputStream과 같은 함수를 따로 만들 필요가 없다.

 

 

Nstest를 사용하기 위해 extends 한다.

class RacingControllerTest  extends NsTest {

 

 

2) 실행 코드 작성

 

controller는 입출력과 관련있으니, 콘솔을 사용한다. 이때, 전체(Main) 실행이 아닌, controller만 실행하도록 코드를 작성한다.

 

아래는 Service까지 마치고 최종 버전의 ControllerTest 파일이다.
초기에는 View와 Service가 없어서 위와 같이 Controller만 선언되었다

 

 

오직 입출력만을 위한 테스트 코드를 작성한다.

// RacingControllerTest.java
package racingcar;
// (생략)
public class RacingControllerTest  extends NsTest {

    @Test
    void doRacingGame_사용자_입출력_테스트() {
        assertSimpleTest(
                () -> {
                    // given & when
                    run("pobi,woni", "1"); // 입력값 (자동차 이름, 시도 횟수)

                    // then
                    assertThat(output())
                            .contains("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)")
                            .contains("시도할 횟수는 몇 회인가요?")
                            .contains("실행 결과")
                            .contains(" : ")
                            .contains("최종 우승자 : ");
                }
        );
    }

    @Override
    public void runMain(){
        View view=new View();
        RacingController racingController = new RacingController(view);
        racingController.doRacingGame();
    }
}

 

아직 doRacingGame() 메소드밖에 만들지 않았지만, 해당 메소드만 테스트 할 수 있다. 이전에는 Application에 코드를 작성해서 전체를 돌렸다면, 이제는 Controller의 doRacingGame()만 돌려서 테스트가 가능해졌다.

 

 

이제 기능별로 테스트 코드를 작성하여 깃 커밋이 가능해졌다.

 


2-4. Validator 작성

1) 사용자 입력값 검사 기능 추가
2) 도메인 검사 기능 추가

 

Validator 역시 지난주와 유사한 과정이다. 다만 이번주에서는 검증 코드가 여러개가 필요하면 그걸 하나의 메소드로 포장해, 한번만 호출하도록 하였다. (코드가 깔끔)


사용자 입력값 검사 기능 추가

 

README를 보고 검증기를 작성한다.

 

 

// 빈 문자열 검사
public static void validateNotEmpty(String inputMessage){
        if (inputMessage == null || inputMessage.isEmpty()){
            throw new IllegalArgumentException(ErrorMessage.INPUT_NULL_ERROR.getMessage());
        }
}

// ,로 시작 안하는지 검증
public static void validateNotStartByComma(String inputMessage){
    Pattern pattern = Pattern.compile("^,");
    Matcher matcher = pattern.matcher(inputMessage);
    if (matcher.find()){
        throw new IllegalArgumentException(ErrorMessage.INVALID_START_CHAR.getMessage());
    }
}

// ,로 안 끝나는지 검증
public static void validateNotEndByComma(String inputMessage){
    Pattern pattern = Pattern.compile(",$");
    Matcher matcher = pattern.matcher(inputMessage);
    if (matcher.find()){
        throw new IllegalArgumentException(ErrorMessage.INVALID_END_CHAR.getMessage());
    }
}

 

이때, 빈문자열 처리에 관해서만 추가적으로 얘기해보겠다.

 

빈문자열로 테스트에서 입력값 검사를 돌리면 NoSuchElementException() 에러가 뜨는 것을 발견했다. 그래서 디버깅을 통해 왜 에러를 제대로 못잡는지 확인해 보았다.

 

[우테코 2주차] 디버깅으로 입출력 예외 분석하기] (⬅️ 게시글 참고)

 

디버깅을 통해 , 빈 문자열 입력 시 두 개의 에러처리를 해줘야한다는 사실을 찾아냈다.

 

""는 검증기를 호출해서 예외를 던질 수 있지만, NoSuchElementException은 입력을 받는 순간 try-catch로 바로 잡아야하기 때문에, View에서 예외 처리를 하였다.

 


도메인 검사 기능 추가

 

도메인의 관련된 검증기를 하나로 묶어서 도메인 생성자에서 호출하였다.

 

 

코드 한줄만 호출하면 되기 때문에, 코드가 간결하고 깔끔해진 것을 확인할 수 있다.

 


2-5. Validator Test 작성

validator의 테스트는 controller나 doamin 테스트의 일부라고 볼 수 있다. 따라서, 따로 validatorTest를 만들지 않고 각 계층의 테스트 코드에 포함시키도록 하겠다.

 

관련이 있는 각 계층에서 예외처리 테스트를 작성한다.

// RacingControllerTest.java
@ParameterizedTest
@ValueSource(strings = {",pobi", "pobi,"})
void doRacingGame_쉼표_예외_테스트(String text) {
    assertSimpleTest(() ->
            assertThatThrownBy(() -> runException(text, "1"))
                    .isInstanceOf(IllegalArgumentException.class)
    );
}

(참고로 @ParameterizedTest를 사용하면 테스트를 효율적으로 작성할 수 있다)

지난주와 마찬가지로 검증기 코드는 아래와 같은 TDD 과정을 거쳤다.
([우테코 1주차] 입력값 검사에 TDD 적용해보기 참고)

 

domain, controller 각 계층에서 검사하는 것이므로 이후 과정은 생략하겠다.


2-6. Domain 작성

1) 자동차 도메인 객체 생성
2) 게임 도메인 객체 생성 (+추가)


자동차 도메인 객체 생성

 

처음에는 자동차 경주를 도메인으로 설정하려고 했다.
HashMap을 이용하여 자동차와 위치를 한꺼번에 관리하는 방식이 편할 것 같아서였다.

경주 - {참여 자동차 리스트 : 현재 위치} , 총 경기 횟수   ( ❌ )

너무 꼬이는 도메인 객체... (멈춰 🤚)

 

다시 원점으로 돌아가 무엇이 필요한가를 생각해보았다.

총 경기 횟수를 도메인에 저장할 필요가 있을까?

자동차들의 정보를 한꺼번에 알고 있어야 하나?

 

그래서 전부 걷어내고, "무엇을 할 것인가" 에만 집중해서, 최종 출력물에 무엇이 필요한가를 보았다.

자동차가 이동하거나 정지한다.

 

이 행동을 위해 필요한 데이터는 자동차 이름현재 위치이다.

 

즉, 사용자의 요구사항은 자동차 이름, 현재 위치를 알면 제공할 수 있는 기능이다.
(현재 위치를 알면, 최종 우승자도 알 수 있음)

그러면 자동차 객체를 생성해서 위의 두 가지 데이터를 상태로 두자!

 

이렇게 도메인을 정하니 도메인 검증도, 관련 메서드도 훨씬 설계하기 수월했다.

package racingcar.domain;

import racingcar.exception.Validator;

public class Car {
    private String carName;
    private Integer position;

    private Car(String name){
        Validator.validateCarName(name);
        this.carName = name;
        this.position = 0;
    }

    public static Car buildCar(String name){
        return new Car(name);
    }
}

 

도메인의 메서드는 최대한 가볍게 유지하자.

 

여기서 말하는 "가볍게"라는 건, 코드의 양을 말하는 게 아니라,
도메인이 몰라도 되는 로직(ex. 문자 쪼개기)을 도메인에게 책임을 주지 말라는 것이다.

도메인의 상태는 오직 도메인만 바꿀 수 있다.

 

따라서 도메인이 갖고 있는 상태를 반환하거나, 변경하는 등의 최소한의 기능들 위주로 도메인 메소드를 작성하였다. service에서는 필요한 도메인 메서드만 호출해서, 그 외에 비즈니스 로직을 수행한다.


게임 도메인 객체 생성
(+ 추가한 도메인)

 

위에서 자동차라는 객체를 만들고 나니, 자동차들의 경기 순서, 우승자 계산 등의 많은 로직이 서비스에 집중되는 것을 보았다. 따라서 위의 데이터를 도메인 클래스를 추가하여 저장하고자 한다.

무슨 데이터를 도메인에 저장하는게 좋을까요?

 

자동차 하나에 대한 정보(이름, 위치)는 이미 알고 있다. 그러면 어떤 행동을 해야 원하는 결과를 출력할까?

매 라운드 마다 순서대로 자동차 현재 위치 업데이트
마지막 라운드 후 우승자 계산

 

그러면 데이터는 "자동차 순서""우승자"를 저장하면 된다.

 

이 두 데이터를 갖는 Game 객체를 생성한다.

// Game.java
public class Game {
    private List<Car> cars;
    private List<String> winners;

    private Game(List<String> carNameList){
        Validator.validateCarNameList(carNameList);
        this.cars=new ArrayList<>();
        this.winners=new ArrayList<>();
    }

    public static Game buildGame(List<String> carNameList){
        Game game=new Game(carNameList);
        game.enrollCar(carNameList);
        return game;
    }
    // 경주에 참여할 자동차 등록
    private void enrollCar(List<String> carNameList){
        for (String carName : carNameList){
            cars.add(Car.buildCar(carName));
        }
    }
}

 

정리하자면, 아래와 같이 도메인 객체가 형성된다고 볼 수 있다.

사실상 Service에서 Car를 직접 접근하는 일이 없고, Game이 aggregate root 역할을 한다.

 


2-7 Domain Test 작성

도메인은 입출력을 하는것이 아니기 때문에 직접 객체를 생성해서 test를 진행한다. (extends NsTest 안해도 됨, runMain()안해도 됨)

도메인 객체를 private으로 외부 접근을 막아두었기 때문에, 정적 팩토리 메서드를 통해 각 테스트에서 객체를 생성해 테스트를 진행한다.

 

 

아래는 내가 사용한 대표적인 테스트 코드들이다.

 

package racingcar;
//(생략)
public class CarTest{

    @Test
    void 자동차_객체생성_테스트() {
        assertThatCode(() -> Car.buildCar("pobi"))
                .doesNotThrowAnyException();
    }

    @Test
    void 자동차_공백포함_5자초과_예외_테스트() {
        assertThatCode(() -> Car.buildCar(" pho   "))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void 자동차_이름_길이초과_예외_테스트() {
        assertThatThrownBy(() -> Car.buildCar("elizedo"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("자동차 이름은 5자 이하만 가능합니다.");
    }
}
  • 객체가 정상적을 생성되었는지는 .doesNotThrowAnyException()으로 확인
  • 객체 예외 처리를 .isInstanceOf()를 사용
  • hasMessageContaining()을 사용하면 객체 예외 메세지까지 체크 가능

2-8. Service 작성

1) 랜덤 넘버 리스트 생성 기능 추가
2) 경주 게임 구현 기능 추가
3) 게임 결과를 담는 dto 추가

 

여기서 서술하는 Service는 리팩토링을 거친 최종 코드이다. [4.리팩토링] 에 가면 Service 코드가 탑다운 방식으로 리팩토링 된것을 확인할 수 있다.


랜덤 넘버 리스트 생성 기능 추가

 

Randoms.pickNumberInRange(0,9)는 외부 라이브러리이기 때문에 Domain이 아닌 Service에서 호출하였다.

Service에서 자동차 수만큼 랜덤 수를 뽑아서 리스트를 만든 후, Game 도메인에게 리스트를 전달하는 방식으로 사용하였다.

 

public List<Integer> pickRandomNumber(int number){
    List<Integer> randomNumbers = new ArrayList<>();
    for (int i = 1; i<=number; i++){
        int randomNumber = Randoms.pickNumberInRange(0,9);
        randomNumbers.add(randomNumber);
    }
    return randomNumbers;
}

경주 게임 구현 기능 추가

 

게임 도메인을 호출해서 게임을 반복 수행하도록 하였다.

// RacingService.java
public RacingDto getAllRoundGameResult(List<String> carNameList, Integer rounds){
    List<Map<String, Integer>> result = new ArrayList<>();
    Game game=Game.buildGame(carNameList);
    for (int i = 1; i <= rounds; i++){
        game.playRound(pickRandomNumber(carNameList.size()));
        result.add(game.getGameResult());
    }
    List<String> winners = game.getWinners();
    return new RacingDto(result, winners);
}

게임 결과를 담는 dto 추가

 

이 게임 결과를 RacingDto에 담아서 Controller에게 전달하였다.

public class RacingDto {
    private List<Map<String,Integer>> gameHistory;
    private List<String> winners;

    public RacingDto(List<Map<String,Integer>> history, List<String> winner){
        this.gameHistory = history;
        this.winners = winner;
    }

    public List<Map<String,Integer>> getGameHistory(){
        return gameHistory;
    }
    public List<String> getWinners(){
        return winners;
    }
}

2-9. Service 테스트 작성

원래는 아래와 같이 controller를 연결해서 Service 테스트를 했다.

 

 

근데 이렇게 하니까 servcie가 변경되면 controller까지 바꿔줘야 service test가 가능해져서, Controller와 분리하기로 하였다.

Service에서 직접 기능을 호출하여, 반환되는 값을 비교하였다.

 

package racingcar;

public class RacingServiceTest  extends NsTest {

    private static final int MOVING_FORWARD = 4;
    private static final int STOP = 3;

    @Test
    void 게임_기록_기능_테스트() {
        RacingService racingService=new RacingService();

                assertRandomNumberInRangeTest(
                () -> {
                    Map<String,Integer> result = racingService.getOneRoundGameResult(List.of("pobi","woni"));
                    Map<String,Integer> expected=new LinkedHashMap<>();
                    expected.put("pobi", 1);
                    expected.put("woni", 0);
                    System.out.println(result);
                    assertThat(result).containsExactlyEntriesOf(expected);
                },
                MOVING_FORWARD, STOP
        );
    }

    @Override
    public void runMain(){
        RacingService racingService=new RacingService();
    }
}

 

(나중에 DTO를 도입하면서, DTO값을 비교하는 코드로 변경되었다)


3.  테스트

각 계층별 테스트에서 아래의 결과들을 확인했다.

1) Application : 전체 테스트 + 예외 테스트(아래 예외처리들 포함)
2) Domain : 객체 정상 생성 여부 확인, 도메인 검사 예외처리
3) Controller : 로직 안돌리는 입출력 확인, 입력값 검사 예외 처리
4) Service : dto 결과값 확인 (기능이 정상작동하는지 확인)


Application 테스트

 

Application은 전체 테스트이기 때문에 각 계층에서 수행한 테스트도 @ParameterizedTest를 이용해서 다시 한번 점검했다.

 


Domain 테스트

 

Domain은 runMain()없이 바로 객체 생성하여 테스트를 진행한다.

public class CarTest{

    @Test
    void 자동차_객체생성_테스트() {
        assertThatCode(() -> Car.buildCar("pobi"))
                .doesNotThrowAnyException();
    }
}


Controller 테스트

 

Controller를 생성할 때, View와 Service 의존성을 주입하여 테스트한다.

public class RacingControllerTest  extends NsTest {

    @Test
    void doRacingGame_자동차_빈문자열_예외_테스트() {
        assertSimpleTest(() ->
                assertThatThrownBy(() -> runException("", "1"))
                        .isInstanceOf(IllegalArgumentException.class)
        );
    }

    @Override
    public void runMain(){
        View view=new View();
        RacingService racingService=new RacingService();
        RacingController racingController = new RacingController(view,racingService);
        racingController.doRacingGame();
    }
}


Service 테스트

 

Service에서는 dto의 값을 비교하는 테스트를 진행했다.

public class RacingServiceTest  extends NsTest {
    @Test
    void 게임_기록_기능_테스트() {
        RacingService racingService=new RacingService();
                assertRandomNumberInRangeTest(
                () -> {
                    RacingDto result = racingService.getAllRoundGameResult(List.of("pobi","woni"),2);
                    Map<String,Integer> expectedHistory = new LinkedHashMap<>();
                    expectedHistory.put("pobi", 1);
                    expectedHistory.put("woni", 0);
                    List<String> expectedWinners = List.of("pobi");
//                    System.out.println(result);
                    assertThat(result.getGameHistory().get(0)).containsExactlyEntriesOf(expectedHistory);
                    assertThat(result.getWinners()).containsExactlyElementsOf(expectedWinners);
                },
                MOVING_FORWARD, STOP
        );
    }
}


4. 리팩토링

이번에는 Service를 대규모로 리팩토링했는데, 그 이유는 아래와 같다.

1) 서비스가 너무 많은 역할을 수행하고 있으며, 다른 계층의 역할을 침범해 계층간의 경계가 모호하다.
2) 비즈니스 로직의 결과가 여러 개이다. ( 매 라운드 경기 기록, 우승자)
3) 계층별 테스트를 위해 계층간의 결합도를 줄여야한다.

 

리팩토링 전 서비스 코드를 보면, Service에서 Message.java를 가지고와 포맷팅도 하고, Car 객체를 순환하여 게임을 수행하기도 한다.

 

서비스의 역할이 너무 많고, 도메인 객체를 직접 다루다보니 단일 책임으로 쪼개는 것의 어려움을 겪었다. (단일 책임으로 쪼개면 계속 도메인 객체를 파라미터로 넘겨줘야함)

 

 

따라서 계층간의 경계를 분명히 하고, Service에 몰려있는 역할을 다시 분류하여, controller와 domain이 책임지도록 리팩토링 하겠다.

 


출력 형식에 맞게 포맷팅
→ Controller로 책임 이동

 

지금 Service에서 Message.java로 직접 접근하여, 포맷팅을 하고 있다. 하지만 포맷팅은 Controller 계층의 책임이다. 따라서, 이 기능을 Controller로 옮기도록 하겠다.

 

그러기 위해서는 Service에서 결과를 한꺼번에 Controller로 넘겨줘야한다. 이를 위해 Dto를 도입하였다.

public class RacingDto {
    private List<Map<String,Integer>> gameHistory;
    private List<String> winners;

    public RacingDto(List<Map<String,Integer>> history, List<String> winner){
        this.gameHistory = history;
        this.winners = winner;
    }

    public List<Map<String,Integer>> getGameHistory(){
        return gameHistory;
    }
    public List<String> getWinners(){
        return winners;
    }
}

 

그러면 Controller에서 아래와 같이 값을 한꺼번에 넘겨받고, 이를 적잘하게 필요한 형태로 View에게 넘길 수있다.

 


한 라운드의 게임 수행
→ Domain으로 책임 이동

 

Game이라는 도메인 객체를 만들어서, 게임 한 라운드에 일어나는 행동(자동차 위치 갱신 요청, 자동차 위치 반환 요청 등)들을 Game 내에서 수행하도록 하였다.

 

Service는 단지 한 라운마다 Game 도메인에게 "게임 수행해"라고 요청을 보내면,
Game 내에서 모든 동작이 이루어지고 결과만 Service에게 전달한다.

 

 

덕분에 복잡했던 Service가 간단해지고, 계층간의 책임도 명확해져서 더 응집도있는 클래스를 구성할 수 있게 됐다.

 


5. 우테코 과제 제출

 

PR 링크 :

https://github.com/woowacourse-precourse/java-racingcar-8/pull/378

 

[자동차 경주] 박지영 미션 제출합니다. by ParkJiYeoung8297 · Pull Request #378 · woowacourse-precourse/java-raci

java-racingcar-precourse 블로그 링크 : [우테코] 프리코스 2주차 자동차 경주 회고 tistory ⬅️ 요약 버전 [우테코] 프리코스 2주차 자동차 경주 회고 velog ⬅️ 트러블 슈팅, 기타 포함 버전 요구사항 자

github.com

 

 

소감문 : 

이번 주에는 "기능별 테스트"라는 키워드를 가지고 코드를 구현했다.

지난주 과제 테스트에서는 전체 애플리케이션을 실행시켜, 입출력 값으로 테스트 결과를 냈었다. 그 결과, controller를 수정해야만 테스트가 가능했고, "도메인, 서비스 등의 계층만 따로 테스트할 수 없을까?"라는 궁금증이 생겼다. 그러던 중 1 주차 과제 코드 리뷰를 하면서 많은 사람들이 계층을 따로 분리하여 테스트 코드를 작성한 것을 보았다. 따라서 이번 주에는 "기능별 테스트"라는 키워드를 가지고 계층별로 나누어 테스트에 도전해 보았다.

기능별 테스트를 위해 코드를 리팩토링하는 과정에서, 클래스 간의 결합도가 자연스럽게 낮아지는 것을 체감하였다. 처음에는 Service에서 Game 도메인을 가지고, 그걸 Service 내의 다른 메서드에게 파라미터로 넘겨주며 기능을 수행하도록 설계했다. 하지만 이렇게 구성하고 나니, Service 테스트를 할 때도 Game 객체를 생성해서 넣어줘야 하고, Game 도메인이 변경되면 Service 테스트도 영향을 받았다. 그래서 Service에서 수행하는 일부 기능을 Game 도메인에게 책임을 넘겨서, Service에서는 단지 호출만 하도록 하였다. 리팩토링을 거치니 도메인과 Service의 결합도가 낮아지고, Service 단독 테스트가 가능해졌다. 그동안 Domain과 Service, Service와 Controller 간의 역할 분배를 어떻게 해야 하는지 계속 헷갈렸었는데, 테스트가 그걸 구분해 주는 하나의 척도가 될 수 있다는 걸 느꼈다. 

Service에서 Controller로 데이터를 넘기는 과정에서도 많은 시간을 고민했다. Controller에서 도메인 객체를 생성하면 안 되고, service에서 도메인을 controller로 넘겨서도 안된다. 하지만 "경기 기록과 우승자라는 두 가지 결과를 controller에게 어떻게 전달할 것인가?"에 대해 고민한 끝에, Dto를 도입하였다. 과제를 시작하기 전까지만 해도 "웹 개발이 아닌데 dto가 왜 필요하지?"라는 의문이 있었는데, dto는 웹 개발을 위해 존재하는 것이 아니라 Service에서 Controller로 데이터를 한 번에 전달할 수 있는 효과적인 방법이라는 사실을 깨달았다. 경기 기록은 List<Map> 타입, 우승자는 List<String> 타입으로, 둘의 데이터 타입이 달라서 Service에서 두 데이터를 하나로 묶는 것이 어려웠는데, 서로 상이한 타입을 Dto 클래스의 필드로 두니 수월하게 데이터를 묶을 수 있었다. 

또한 공통 피드백으로 나온 디버깅을 이용해서 빈 문자열 입력값 검사 테스트의 의문점을 해결하기도 하였다. 지난주 과제에서 빈 문자열을 입력했을 때, isEmpty()로 에러 처리를 시도했지만 NoSuchElementException 에러를 던지는 것을 확인했다. print 문으로도 값이 출력이 되지 않아 원인을 파악할 수 없었는데, 디버깅을 사용하여 코드 진행 상황을 파악할 수 있었다. 이번 과제에서는 두 개의 입력을 받는데, 같은 검증기 코드(빈 문자열 에러 처리)를 사용해도 빈 문자열이 앞인지 뒤인지에 따라 테스트 결과가 다르게 나오는 문제를 발견하였다. 디버깅 결과, 앞에 값이 없을 때는 변수에 ""이 할당되어 에러 처리가 되지만, 뒤에 값이 없을 때는 변수에 값이 할당되지 않고 Console.close()가 호출되는 것을 확인했다. step into 버튼을 통해 해당 메서드로 들어가니, 최종적으로 nextLine() 메서드에 도달하였고, 이 메서드에서 NoSuchElementException을 던진다는 것을 확인했다. 콘솔 출력으로도 확인하기 어려웠던 문제를 디버깅 기능을 통해 확인할 수 있었고, 이것 외에도 개발 과정에서 디버깅을 사용해 보니 매우 편리한 기능이라는 것을 깨달았다. 코드 진행 상황을 하나씩 따라갈 수 있어서, 테스트 실패 원인이 로직 코드 때문이 아니라 잘못 작성된 테스트 코드 때문이라는 사실도 발견할 수 있었다. 디버깅을 통해 빠르게 오류를 발견하고, 로직의 흐름을 파악할 수 있었다.

이번 주에는 계층별 테스트를 시도하며, 계층 간의 역할과 책임에 대해 더 명확히 구분할 수 있게 되었다. 도메인은 상태를 갖고 있는 객체로, 상태를 수정하고 조회하는 모든 기능은 도메인 내에서 수행되고 캡슐화돼야 한다. Service는 도메인에게 요청하고 값을 반환받을 뿐, 직접 도메인에 접근하면 안 된다. 또한 Service와 Controller 사이에서도 막연하게 사용하던 Dto에 대해, 왜 dto를 사용하게 되었는지 그 필요성을 직접 피부로 느껴볼 수 있었던 시간이었다.

 

 

⬇️ 리팩토링, 트러블 슈팅, 했던 고민까지 포함된 버전을 보고 싶다면 아래의 링크를 추천합니다. (참고로 길이 글이 매우 깁니다 🫠)
[우테코] 프리코스 2주차 자동차 경주 회고 velog