겸손하기 꾸준하기 건강하기

우테코 6기 프리코스(back-end) 2주차 회고 본문

woowacourse/백엔드 6기 프리코스

우테코 6기 프리코스(back-end) 2주차 회고

seminss 2023. 11. 2. 00:00

 
2주 차 미션에서는 1주 차에서 학습한 것에 더해 함수를 분리하고, 각 함수별로 테스트를 작성하는 것에 익숙해지는 것을 목표로 하고 있어요. 이번에 테스트를 처음 접하시는 분들은 언어별 테스트 도구를 학습하고 작은 단위의 기능부터 테스트를 작성해 보길 바랍니다. 
 

미션 - 자동차 경주


1주 차 공통 피드백

  1. 요구사항을 정확히 준수한다.
  2. 커밋 메세지를 의미 있게 작성한다.
  3. git을 통해 관리할 자원에 대해서도 고려한다.
  4. Pull Request를 보내기 전 브랜치를 확인한다.
  5. PR을 한 번 작성했다면 닫지 말고 추가 커밋을 한다.
  6. 이름을 통해 의도를 드러낸다.
  7. 축약하지 않는다.
  8. 공백도 코딩 컨벤션이다.
  9. 공백 라인을 의미있게 사용한다.
  10. space와 tab을 혼용하지 않는다.
  11. 의미 없는 주석을 달지 않는다.
  12. IDE의 코드 자동 정렬 기능을 활용한다.
  13. Java에서 제공하는 API를 적극 활용한다.
  14. 배열 대신 Java Collection을 사용한다.

1일 차 (Oct 26, 2023)

2주 차 미션이 나온 뒤 사실,, 1주 차 회고를 미처 작성하지 못했었다.
때문에 1주 차에 개발했던 부분들, 고민했던 부분들을 먼저 정리했고,
다른 분들 코드를 보면서 2주 차 미션에서 참조하면 좋을 부분들을 정리했다.
 
밤에 2주차 레포지토리를 fork 해서 기능을 간단하게 살펴봤다.
 


2일 차 (Oct 27, 2023)

2일 차는 1일 차에 설계하던 부분을 이어서 진행했다.
 
우선은 변하지 않는 것과 대체 가능한 것을 나누어 보았다.
 
변하지 않는 것자동차를 이용한 경주 게임 << 이라는 것으로 정의했다. 
 
대체 가능한 것은 다음과 같이 정의했다.

  1. 자동차 객체 생성 방식 (랜덤 하게 만들 수도, 사용자 입력을 받을 수도 있다.)
  2. 게임 횟수 지정 방식 (랜덤하게 만들 수도, 사용자 입력을 받을 수도 있다.)
  3. 전진 조건 (랜덤 한 수> 4에 따라 RandomMoves 가 결정되지만, 어떤 방식으로든 변경 가능하다.)
  4. 결과 출력 방식 (pobi : ---- 지만, 변경될 수도.. 특히 - 문자는 충분히 변경 가능하다.)
  5. 우승자 선정 방식 (중복 허용, 중복 비허용 모두 가능하다.)
  6. 우승자 출력 방식 (최종 우승자 : pobi, jun 지만 변경될 수도 있다.)

이 친구들은 전부 다른 클래스로 개발되어야 하는 기능들이라고 생각했다.
 
 
이에 기능 명세서를 다음과 같이 작성해 봤다.
1주 차에서 배운 내용들을 바탕으로, 2주 차 설계에 녹여내려고 고민을 정말 많이 했다.
 

## 🌠 기능 목록 설계


- [ ] 사용자 입력을 받는다. `InputView`
  - [ ] 경주 할 자동차 이름을 받는다.
  - [ ] 자동차의 이동 횟수를 입력 받는다.

- [ ] 자동차 이름 입력을 검증한다. `CarNameInputValidator`
  - [ ] 입력 값이 존재 하는가? 확인한다.
  - [ ] 구분자가 쉼표인가? 확인한다.
  - [ ] 이름이 5자 이하인가? 확인한다.

- [ ] 이동 횟수 입력을 검증한다. `MoveCountInputValidator`
  - [ ] 입력 값이 존재 하는가? 확인한다.
  - [ ] 정수인가? 확인한다.

- [ ] 게임에 필요한 문구를 출력한다. `OutputView`
  - [ ] 자동차 이름 입력 받을 때 필요한 문구를 출력한다.
  - [ ] 시도할 회수 입력 받을 때 필요한 문구를 출력한다.
  - [ ] 실행 결과 안내 문구를 출력한다.
  - [ ] 각 이동 결과를 출력한다.
  - [ ] 우승자를 출력한다.

- [ ] OutputView 에서 사용할 출력문을 정의한다. `ConstantView`
  - [ ] "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"
  - [ ] "시도할 회수는 몇회인가요?"
  - [ ] "실행 결과"
  - [ ] 줄바꿈

- [ ] IllegalArgumentException이 터질 때의 출력할 메세지를 정의한다. `ErrorMessage`
  - [ ] 입력 값이 존재하지 않는 경우에 대한 정의
  - [ ] 자동차 이름의 구분이 잘못된 경우에 대한 정의
  - [ ] 이름 규칙이 맞지 않는 경우(5자 이하여야 한다.)에 대한 정의
  - [ ] 이동 횟수가 정수로 입력되지 않은 경우에 대한 정의

- [x] 자동차의 정보를 저장한다. `Car`
  - [x] 이름을 갖는다.
  - [x] MoveStrategy에 의해 정해진 n번의 전진/정지 여부를 갖는다.
  - [x] 현재 위치를 저장한다.
  - [x] 한 번 이동할 때마다, 현재 상태를 보여줄 수 있다.
  - [x] 이동할 때마다 이동 횟수는 1씩 증가한다.함

- [x] 각 자동차 별 이동 횟수를 정의 한다. `RandomMoveStrategy`
  - [x] 0에서 9 사이에서 무작위 값을 구한 후, 무작위 값이 4 이상일 경우 전진으로 정의한다.
  - [x] 각 Car가 n번의 전진/정지 여부를 가질 수 있도록 한다.

- [ ] 우승자를 선정한다. `WinnerDeterminationService`
  - [ ] 총 시도 횟수 동안 가장 많은 전진을 한 자동차를 찾아 정의한다.
  - [ ] 우승자는 중복을 허용한다.
  - [ ] 

- [ ] 우승자 정보를 저장한다. `Winner`
  - [ ] 최종 우승자를 makeWinnerService로부터 입력 받아 결과를 보여준다.
  - [ ] 여러 명의 우승자가 있을 때 정렬 조건이 필요하다.
 
- [ ] 게임을 관리한다. `RacingCarGameController`

- [ ] 애플리케이션을 관리한다. `Application`
  - [ ] IllegalArgumentException이 발생한 경우 애플리케이션을 종료한다.

 
1주차 미션에서 정말 많은 분들이 mvc 패턴을 적용하셨는데, 나는 mvc 패턴에 대해 들어보기는 했지만, 제대로 공부하고 적용해 본 적은 없었다. mvc 패턴을 사용하신 분들의 코드가 상대적으로 클래스 간 역할/책임이 잘 분리되어 있던 건 맞지만, 내가 도메인을 명확하게 나눈다면 문제가 되지 않으리라 생각했다. 
 
But! 나도 mvc 패턴으로 구현을 한다면 다른 분들과 코드 리뷰 할 때, 각 애플리케이션의 구조를 이해하는 시간을 단축할 수 있을 것 같아, 2주 차에는 적용해 봐야겠다는 결심을 했다.
 
구현을 할 때 꼬이지 않기 위해서는, 설계부터 꼼꼼하게 해둬야 한다는 것을 1주 차에 느꼈기 때문에, 기능 명세서를 최대한 촘촘하게 작성하려고 노력했다.
 
추가로 자동차의 경쟁 로직이 얼마든지 대체될 수 있다고 판단해서 Strategy 패턴.!이라는 것도 사용해 보기로 했다.
 


개발에 들어갔다.
 

평소에는 애플리케이션의 진행 흐름대로 개발하는 스타일이기 때문에, 출력 부분 먼저 개발을 해왔지만
이번에는 Car, RandomGenerator, MoveStrategy 먼저 구현을 했다.
RacingCarGame의 핵심 로직을 담당한다고 생각했기 때문이다.
 
이때 car이 저장하는 값은 name, movementFlags, completedMoves로 정했다. 그리고 
{ jun : ---  }과 같은 실행 결과를 toString을 오버라이딩 해서 써보고 싶어서, 다음과 같이 Car을 구현했다.
(1주 차에 다른 분 코드를 리뷰하면서 알게 된 방법이다.)
 

public class Car {
    private final String name;
    private final List<Boolean> movementFlags;
    private int completedMoves;

    public Car(String name, List<Boolean> movementFlags){
        this.name=name;
        this.movementFlags = movementFlags;
        this.completedMoves = 0;
    }

    public void move(){
        this.completedMoves++;
    }

    @Override
    public String toString(){
        StringBuilder sb = new StringBuilder();
        sb.append(name).append(" : ");
        for(int step = 0; step< completedMoves; step++) {
            if(movementFlags.get(step)) {
                sb.append("-");
            }
        }
        return sb.toString();
    }
}

결론을 말하자면, 최종적으로는 toString 부분을 빼게 된다. (mvc 패턴의 구현에 적합하지 않다 판단했기 때문)
아무튼 초기 Car의 설계는 위와 같았다는 것이다 ㅎㅎ
 
이후, toString이 올바른 결과를 출력하는지, move 가 올바른 이동을 하도록 하는지 테스트했다.
 


 
그리고 이동 전략을 의미하는 MoveStrategy 인터페이스와 RandomMoveStrategy을 나눠 구현했다.
과제 요구사항으로 주어진 이동 전략은, Random 한 수를 기준으로 4이상일 때만 전진하도록 하는 방식이지만,
이 부분은 언제든지 변경될 수 있는 부분이라 판단했다.
 
Random한 수가 아닐 수도 있고,, 4가 아니라 다른 수로 얼마든지 변경 가능하지 않은가?
따라서 MoveStrategy도 인터페이스로 빼주었다.
 
RandomGenerator도 인터페이스로 빼주었는데, 우테코에서 제공하는 Randoms.pickNumberInRange()가 얼마든지 변경될 수 있기 때문이다. (실제로 랜덤 한 수를 추출할 때는 확률 조정이 가능하다.)
편리한 테스트를 하기 위한 목적도 있었다. RadomMoveStratey를 테스트하기 위해선, 랜덤 값을 생성하는 로직에 내가 값을 따로 넣어주기 위한 fake 클래스가 필요한데, 그러기 위해서는 RandomGenerator의 추상화가 필요했다.

public class RandomMoveStrategy implements MoveStrategy{
    private final int totalMoves;
    private final RandomGenerator randomGenerator;

    public RandomMoveStrategy(int totalMoves, RandomGenerator randomGenerator) {
        this.totalMoves = totalMoves;
        this.randomGenerator = randomGenerator;
    }

    @Override
    public List<Boolean> createMovementFlags() {
        return IntStream.range(0, totalMoves)
                .mapToObj(i->decideMovement())
                .collect(Collectors.toList());
    }

    private Boolean decideMovement(){
        int randomNumber = randomGenerator.generate();
        return randomNumber >= 4;
    }
}

나중에 확인한 사실이지만, totalMoves는 생성자에서 초기화하면 안 됐고, createMovementFlags에서 파라미터로 넘겨줘야 했다.. 이 전략을 선택할 초기 단계에서는 내가 몇 번의 이동을 할 것인지 알 수 없기 때문이다..
 

class CampRandomGeneratorTest {

    @Test
    void toChar_랜덤한_수가_전부_0면_전부_False () {
        RandomGenerator fakeGenerator = ()->0;
        RandomMoveStrategy strategy = new RandomMoveStrategy(5,fakeGenerator);
        List<Boolean> movementFlags = strategy.createMovementFlags();
        assertTrue(movementFlags.stream().noneMatch(Boolean::booleanValue));
    }

    @Test
    void toChar_랜덤한_수가_전부_4면_전부_Ture () {
        RandomGenerator fakeGenerator = ()->4;
        RandomMoveStrategy strategy = new RandomMoveStrategy(5,fakeGenerator);
        List<Boolean> movementFlags = strategy.createMovementFlags();
        assertTrue(movementFlags.stream().allMatch(Boolean::booleanValue));
    }

    @Test
    void toChar_랜덤한_수가_전부_9면_전부_True () {
        RandomGenerator fakeGenerator = ()->9;
        RandomMoveStrategy strategy = new RandomMoveStrategy(5,fakeGenerator);
        List<Boolean> movementFlags=strategy.createMovementFlags();
        assertTrue(movementFlags.stream().allMatch(Boolean::booleanValue));
    }
}

그리고는 랜덤 값으로 0, 9, 4를 넣어 경곗값 테스트를 진행해 줬다. (이렇게 값을 넣기 위해서 RandomGenerator의 추상화가 필요했다!!!)
 


3일 차 (Oct 28, 2023)

바로 Winner 클래스를 만들까 하다가,, Winner을 객체로 빼는 게 좋을지, 그냥 Controller에서 바로 처리하는 게 좋을지 확신이 서지 않았다. 그래서 3일 차에는 OutputView와 Validator을 먼저 정의해 주기로 했다.

    public List<String> validateAndGetCarNames(String userInput) {
        validateNotEmpty(userInput);
        validateSeparator(userInput);
        List<String> carNames= splitCarNames(userInput);
        validateLength(carNames);
        validateDuplicate(carNames);
        return carNames;
    }

개인적으로 Validator 클래스 잘 작성한 것 같아서 맘에 든다!
 
1주 차 미션에서는 파싱 하는 부분을 따로 객체로 뺐었는데, Validator 내에 변환 로직을 넣는 것도 일반적인 케이스 같더라.
그래서 나도 2주 차에는, 검증된 carNames를 List <String> 형으로 파싱 해서 반환할 수 있도록 해보았다.
 
 
그리고 3일 차에는 MVC 패턴을 구현하는 법에 대해서도 좀 찾아봤다!
원래는 mvc 패턴에 대해 따로 공부한 적이 없으니 model, view, conroller로 나눠서 일단 구현해 보자!! 하고
무지성 개발하기 하고 있었지만, 필수 규칙 정도는 지켜야 할 것 같아서 좀 찾아봤다.

  1. Model은 Controller와 View에 의존하지 않아야 한다.
  2. View는 Model에만 의존해야 하고, Controller에는 의존하면 안 된다.
  3. View가 Model로부터 데이터를 받을 때는, 사용자마다 다르게 보여주어야 하는 데이터에 대해서만 받아야 한다.
  4. Controller는 Model과 View에 의존해도 된다.
  5. View가 Model로부터 데이터를 받을 때, 반드시 Controller에서 받아야 한다.

 
(출처 : 우아한 테코톡)
이걸 보고 Model에서 toString을 사용해서 결과를 출력하려고 했던 건 잘못된 접근이라는 걸 알게 됐다.
 


4일 차 (Oct 29, 2023)

뭐 이렇게 몰아했어..? 싶은 4일 차 ㅎㅎ
 
하나하나 꼼꼼하게 하려고 하니 개발이 자꾸 느려져서,, 마음이 불편하길래ㅠㅠ
빨리 초록불 뜨는 것부터 보자!! 하는 마인드로, 리펙토링 할 부분들은 리드미에 남겨두면서, 기능 개발 완료를 목표로 삼았다.
 
3일 차에 만들었던 CarNameInputValidator과 더불어 시도 횟수 입력받는 부분을 검증하는 TriesCountValidator 클래스도 작성했고, 두 클래스 모두 올바르게 검증을 해주는지 테스트했다.
 
테스트 짤 때마다 귀찮고,, 그랬는데, Validator 테스트하면서 조건식을 잘못 짰던 걸 발견했다.
휴~ 이래서 테스트 짜는구나 싶더라ㅎㅎ
 
다음으론, 우승자 결정 로직을 만들었다. 우승자를 결정하기 위해 Car에 MovementFlags에서 True인 개수를 세어 '전진 횟수'를 저장할 수 있도록 수정했다. 모든 Car에서 이 '전진 횟수'를 비교해 'max 전진 횟수'를 결정하고, 다시 Car을 순회해 'this. 전진 횟수'와 'max 전진 횟수'가 같다면 우승자로 결정하기 위함이었다.
WinnerDetreminationService(현 WinnerService)에 해당 로직을 넣어줬고, 우승자가 잘 결정되는지 테스트해 주었다!
 
자잘하게 놓쳤던 부분들을 수정하고, RacingCarCotroller까지 구현함으로써 초록불이 뜨는 것을 확인했다!

public class RacingGameController {
    private final MoveStrategy moveStrategy;
    private final CarNameValidator carNameValidator;
    private final TriesCountValidator triesCountValidator;
    private final WinnerService winnerService;

    public RacingGameController(MoveStrategy moveStrategy, CarNameValidator carNameValidator,
                                TriesCountValidator triesCountValidator, WinnerService winnerService) {
        this.moveStrategy = moveStrategy;
        this.carNameValidator = carNameValidator;
        this.triesCountValidator = triesCountValidator;
        this.winnerService = winnerService;
    }

    public void runRacingGame() {
        String carNameUserInput = InputView.askCarNames();
        List<String> carNames = carNameValidator.validateAndGetCarNames(carNameUserInput);
        String triesCountUserInput = InputView.askTriesCount();
        int triesCount = triesCountValidator.validateAndGetTriesCount(triesCountUserInput);
        List<Car> racingCars = initializeRacingCars(carNames, triesCount);
        displayCarMovements(triesCount, racingCars);
        displayWinners(racingCars);
    }

    private List<Car> initializeRacingCars(List<String> carNames, int triesCount) {
        List<Car> racingCars = new ArrayList<>();
        for (String carName : carNames) {
            List<Boolean> movementFlags = moveStrategy.createMovementFlags(triesCount);
            racingCars.add(new Car(carName, movementFlags));
        }
        return racingCars;
    }

    private void displayCarMovements(int triesCount, List<Car> racingCars) {
        OutputView.printNewLine();
        OutputView.printResultMessage();
        IntStream.range(0, triesCount)
                .forEach(moveIndex -> executeOneMove(racingCars));
    }

    private static void executeOneMove(List<Car> racingCars) {
        for (Car car : racingCars) {
            car.incrementMoveCount();
            OutputView.printMovements(car);
        }
        OutputView.printNewLine();
    }

    private void displayWinners(List<Car> racingCars) {
        List<Car> winners = winnerService.findWinners(racingCars);
        OutputView.printWinners(winners); //winner 객체로 뺄 지 고민 해보기
    }
}

그런데 3일 차부터 쭉~ 고민되었던 부분이 있었다.
'Winner을 객체로 뺄지 말지'였다..
 
 
MVC 설계 원칙 중에 "View는 Model에만 의존해야 하고, Controller에는 의존하면 안 된다"가 있었는데, 이를 지키지 못하고 있는 것 같았기 때문이다..

    private static String buildWinnersString(List<Car> winners) {
        StringBuilder sb = new StringBuilder();
        sb.append(WINNER.getMessage()).append(GAME_RESULT_SEPARATOR.getValue());
        int i;
        for (i = 0; i < winners.size() - 1; i++) {
            sb.append(winners.get(i).getName());
            sb.append(CAR_NAME_SEPARATOR.getValue()).append(WHITE_SPACE.getValue());
        }
        sb.append(winners.get(i).getName());
        return sb.toString();
    }

OutputView 클래스를 보면 List <Car> winners 가 있는데, 이게 Controller의 코드라고 생각됐다. 모델인 Car로 만들어진 List지만 그 List가 컨트롤러에 의해 만들어졌으니 문제인 게 아닐까..?라는 추측이었던 것..! (사실 mvc에 대해 잘 몰라서 그렇다 ㅠ)
 
뺐을 때와 빼지 않았을 때의 장단점을 비교해 보았다. 컨트롤러에서 List <Car>을 사용하면 코드가 단순해진다는 장점이 있었고, Winner DTO를 만들면 결합도가 낮아진다는 것이 장점이 있었다.
 
두 방법이 전부 장점이 있으니, Car이 아니라, Winner에서만 필요한 정보가 있다면 Winner를 따로 만들기로 했다.
 
name -> 회차별 이동 결과를 출력하기 위해 필요하다.
movementFlags -> 회차별 이동 결과를 출력하기 위해 필요하다.
completeMoves -> 회차별 이동 결과를 출력하기 위해 필요하다.
totalForward -> '이 게임에서 몇 번의 전진을 해야 우승자가 되는가'를 알기 위해 필요하다.
 
Car에 있는 각 변수들 중에서 OutputView에서 우승자를 출력하기 위해 따로 선언된 것을 없다고 판단됐다. totalForward는 WinnerService에서 사용하는데, 각 자동차에 대한 정보이기 때문에 Car에 있는 게 맞아 보였다. (실제로 movementFlags를 읽어 초기화한다.)
 
때문에 Winner DTO가 가질 정보가 Car의 부분집합이라고 생각되어, Winner DTO를 따로 만들지 않기로 결정했다!
 


5일 차 (Oct 30, 2023)

"커밋 메시지 컨벤션 가이드를 참고해 커밋 메시지를 작성한다"..... 이걸 왜 5일 차가 되어 발견했을까?
"README.md에 정리한 기능 목록 단위로 추가한다"는 챙겼는데, 커밋 메시지 컨벤션을 확인하지 않았어서, 부랴부랴 확인했다.
 
커밋 메시지 컨벤션

 

AngularJS Git Commit Message Conventions

AngularJS Git Commit Message Conventions. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

커밋 메시지 대수정을 해줬다.
 
하면서 자잘한 커밋 하나로 합치는 법, 커밋 메세지 body  적는 법을 알게 되었다! 
 
이 날은 커밋 메세지 수정하는 데 시간을 너무 많이 써서 리펙토링을 딱히 하지 못했다. (ㅠㅠ 어떻게 하면 명료하고 정확한 메시지를 전달할 수 있을지 고민하느라 오래걸륌..)
 


6일 차 (Oct 31, 2023)

6일 차는 개인적으로 너무 바빴던 하루라, 테스트 성공하는 것을 확인만 했다.
 


7일 차 (Nov 1, 2023)

 
7일 차의 주요 수정 사항은 Controller의 역할 분리였다.
리펙토링 전 Controller는, 너무 많은 책임을 담당하고 있었다. (4일 차 부분에 있는 Controller와 동일)
 
첫 번째로는 게임 로직을 분리해 줬다.
초기화는 RandomMoveService(구 RandomMoveStrategy)가 담당하고, 우승자를 결정하는 로직은 WinnerService가 담당하고 있었지만, RacingGameService를 만들어 이 둘을 호출해서 로직을 수행하고, 컨트롤러는 이 RacingGameService만 사용하도록 했다.

public class RacingGameService {

    private final MoveService moveService;
    private final WinnerService winnerService;

    public RacingGameService(MoveService moveService, WinnerService winnerService) {
        this.moveService = moveService;
        this.winnerService = winnerService;
    }

    public List<Car> initializeRacingCars(List<String> carNames, int triesCount) {
        List<Car> racingCars = new ArrayList<>();
        for (String carName : carNames) {
            List<Boolean> movementFlags = moveService.createMovementFlags(triesCount);
            racingCars.add(new Car(carName, movementFlags));
        }
        return racingCars;
    }

    public List<Car> findWinners(List<Car> racingCars) {
        return winnerService.findWinners(racingCars);
    }
}

 
두 번째로는 Controller에 껴있는 출력 로직을 빼줬다.
OutputView랑 타협해서 코드를 재배치해줬다.

public class OutputView {
    public static void printInputCarNameMessage() {
        printMessage(INPUT_CAR_NAMES.getMessage());
    }

    public static void printInputTriesCountMessage() {
        printMessage(INPUT_TRIES_COUNT.getMessage());
    }

    public static void displayCarsCurrentState(List<Car> racingCars) {
        printMessage();
        printMessage(RESULT.getMessage());
        for (Car car : racingCars) {
            printMessage(getSingleCarMovementString(car));
        }
    }

    public static void displayWinners(List<Car> winners) {
        printMessage();
        printMessage(getWinnersString(winners));
    }

    private static String getSingleCarMovementString(Car car) {
        StringBuilder sb = new StringBuilder();
        sb.append(car.getName()).append(GAME_RESULT_SEPARATOR.getValue());
        for (int moveIndex = 0; moveIndex < car.getCompletedMoves(); moveIndex++) {
            if (car.getMovementFlags().get(moveIndex)) {
                sb.append(CAR_MOVEMENT.getValue());
            }
        }
        return sb.toString();
    }

    private static String getWinnersString(List<Car> winners) {
        String winnerNames = winners.stream()
                .map(Car::getName)
                .collect(Collectors.joining(CAR_NAME_SEPARATOR.getValue() + SPACE.getValue()));
        return WINNER.getMessage() + GAME_RESULT_SEPARATOR.getValue() + winnerNames;
    }

    private static void printMessage() {
        System.out.println();
    }

    private static void printMessage(String message) {
        System.out.println(message);
    }

}

 
코드를 정리하면서 네이밍 규칙도 만들어봤는데,
 
print는 로직 없이 단순하게 출력문을 찍어내는 메서드, 
display는 print 문들을 결합해야 하거나 약간의 로직이 들어가는 메서드,
get은 출력에 필요한 문자열을 포맷팅 해서 반환하는 메서드로 정의했다.
 
또한, public 메서드는 위에, private은 아래에 위치시켜서 나름의 컨벤션을 지키려고 해 봤다.
 
추가로, 기존 OutputView의 지저분한 코드들을 Stream을 이용해 수정해 줬다.
 
최종 우승자를 출력할 때는 다음과 같이 우승자 1, 우승자 2, 우승자 3 형태로 결합해야 하는데, 기존 코드는 좀 노답이었다.

최종 우승자 : pobi, jun
private static String buildWinnersString(List<Car> winners) {
    StringBuilder sb = new StringBuilder();
    sb.append(WINNER.getMessage()).append(GAME_RESULT_SEPARATOR.getValue());
    int i;
    for (i = 0; i < winners.size() - 1; i++) {
        sb.append(winners.get(i).getName());
        sb.append(CAR_NAME_SEPARATOR.getValue()).append(WHITE_SPACE.getValue());
    }
    sb.append(winners.get(i).getName());
    return sb.toString();
}

출력 형식을 맞추기 위해 마지막 Car을 별도로 더해주는... 정말 꼴도 보기 싫은 형태의 코드였는데
어떻게 수정할까 수정할까 하다가 7일 차에 Collectors.joining을 통해 Car.getName()을 합쳐줄 수 있었다.

    private static String getWinnersString(List<Car> winners) {
        String winnerNames = winners.stream()
                .map(Car::getName)
                .collect(Collectors.joining(CAR_NAME_SEPARATOR.getValue() + SPACE.getValue()));
        return WINNER.getMessage() + GAME_RESULT_SEPARATOR.getValue() + winnerNames;
    }


솔직히 이건 Stream을 쓰면 될 것 같은데,, 어떻게 써야 할지 감이 안 와서 GPT의 도움을 받았다 😓
3주 차 미션을 할 때는 Stream 내용을 간단하게라도 정리해서 자유자재로 쓸 수 있도록 연습을 해봐야 할 것 같다.
 
그리하여 최종 Controller는 다음과 같아졌다.

public class RacingGameController {
    private final CarNameValidator carNameValidator;
    private final TriesCountValidator triesCountValidator;
    private final RacingGameService racingGameService;

    public RacingGameController(MoveService moveService, CarNameValidator carNameValidator,
                                TriesCountValidator triesCountValidator, WinnerService winnerService) {
        this.carNameValidator = carNameValidator;
        this.triesCountValidator = triesCountValidator;
        this.racingGameService = new RacingGameService(moveService, winnerService);
    }

    public void startRacingGame() {
        String carNameUserInput = InputView.askCarNames(); //자동차 이름 입력
        List<String> carNames = carNameValidator.validateAndGetCarNames(carNameUserInput);

        String triesCountUserInput = InputView.askTriesCount(); //실행 횟수 입력
        int triesCount = triesCountValidator.validateAndGetTriesCount(triesCountUserInput);

        List<Car> racingCars = racingGameService.initializeRacingCars(carNames, triesCount);
        runRaces(triesCount, racingCars); //게임 진행

        List<Car> winners = racingGameService.findWinners(racingCars);
        OutputView.displayWinners(winners); //우승자
    }

    private void runRaces(int triesCount, List<Car> racingCars) {
        IntStream.range(0, triesCount).forEach(moveIndex -> {
            executeOneMove(racingCars);
            OutputView.displayCarsCurrentState(racingCars);
        });
    }

    private void executeOneMove(List<Car> racingCars) {
        for (Car car : racingCars) {
            car.incrementMoveCount();
        }
    }
}


개인적으로는 깔끔해져서 맘에 드는데! 익일(11/2) 코드 리뷰를 받으면 또 어떤 문제점들이 나올지.. 설레면서도 기대가 된다.
 
이후에는, 추가로 진행해 준 부분은 놓친 상수값의 추가, 디렉터리 구조 개선, 클래스 이름 통일 등의 자잘한 리펙토링을 해줬다.

 


그렇게 최종적으로 완료한 기능 명세서는 다음과 같다. 

## 🌠 기능 목록 설계

#### 애플리케이션을 관리한다. `Application`
- [x] IllegalArgumentException이 발생한 경우 애플리케이션을 종료한다.

#### 게임 흐름을 관리한다. `RacingGameController`
- [x] 레이싱 게임이 진행되게 한다.

#### 자동차의 정보를 저장한다. `Car`
- [x] 이름을 갖는다.
- [x] 게임에서 이뤄지는 n번의 이동에 대한 전진/정지 여부를 알고 있다.
- [x] 게임 중 전진할 횟수를 알고 있다.
- [x] 현재 위치 정보를 알고 있다.
  - [x] 이동할 때마다 1씩 증가한다.

#### 게임의 주요 로직을 처리한다. `RacingGameService`
- [x] 각 자동차의 움직임을 결정하는 플래그를 생성한다.
- [x] 우승자를 결정한다.

#### 자동차 별 이동 횟수를 정의한다. `RandomMoveService`
- [x] 게임 속 자동차 n번의 전진/정지 여부를 한 번에 결정한다. 
- [x] 0에서 9 사이에서 무작위 값을 구한 후, 무작위 값이 4 이상일 경우 전진으로 정의한다.

#### 우승자를 선정한다. `WinnerService`
- [x] 우승자가 되려면 몇회의 전진을 해야 하는지 찾는다.
- [x] 가장 많은 전진을 한 자동차를 찾아 우승자로 정의한다.
- [x] 우승자는 여러 명이 될 수 있다.
  - [x] 게임 시작 시 입력받은 이름순으로 반환한다.
  
#### 사용자 입력을 받는다. `InputView`
- [x] 경주할 자동차 이름을 받는다.
- [x] 자동차의 이동 시도 횟수를 입력받는다.

#### 자동차 이름 입력을 검증한다. `CarNameInputValidator`
- [x] 입력 값이 존재 하는지 체크한다.
  - [x] 그 전에 이름의 공백을 제거한다.
- [x] 구분자가 쉼표인지 체크한다.
  - [x] 한 명인 경우는 존재 하지 않는다.(경주기 때문에 2명 이상)
  - [x] 이름이 5자 이하인지 체크한다.
  - [x] 중복 이름이 있는지 체크한다.

#### 이동 횟수 입력을 검증한다. `TriesCountInputValidator`
- [x] 입력 값이 존재 하는지 체크한다.
  - [x] 그 전에 공백을 제거한다.
  - [x] 정수인지 체크한다.
  - [x] 양수인지 체크한다.

#### 게임에 필요한 문구를 출력한다. `OutputView`
- [x] 자동차 이름 입력 받을 때 필요한 메시지를 출력한다.
- [x] 시도할 회수 입력 받을 때 필요한 메시지를 출력한다.
- [x] 회차별 자동차들의 이동 결과를 출력한다.
- [x] 우승자를 출력한다.

#### OutputView에서 사용할 출력문을 정의한다. `ConstantView`
- [x] "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"
- [x] "시도할 회수는 몇 회인가요?"
- [x] "실행 결과"
- [x] "최종 우승자"

#### 에러 메세지를 정의한다. `ErrorMessage`
- [x] 입력값이 존재하지 않는 경우에 대한 정의
- [x] 자동차 이름의 구분이 잘못된 경우에 대한 정의
- [x] 이름에 중복이 있는 경우에 대한 정의
- [x] 이름 규칙이 맞지 않는 경우(5자 이하여야 한다.)에 대한 정의
- [x] 이동 횟수가 정수로 입력되지 않은 경우에 대한 정의
- [x] 이동 횟수가 '양의' 정수가 아닌 경우에 대한 정의

미션 제출 후 다른 분들 코드를 읽어봤다. + 코드 리뷰

다음 주차에 적용해 볼만한 아이디어 몇 가지

1. 테스트할 때, controller와 service도 테스트를 해준다. controller는 mock을 사용하고, service는 사용하지 않는다.

2. 테스트할 때 어떤 값에 대해 진행할지도 미리 고민해 보면 좋다 (어떤 값을 리턴할 지도 미리 결정해 두면 객체 생성 시 편리)

3. 결괏값 포맷하는 아이디어

public record CarResultResponse(String carName, int movingCount) {

    private static final String GAME_RESULT_MESSAGE_FORMAT = "%s : %s";
    private static final String MOVEMENT_SYMBOL = "-";

    @Override
    public String toString() {
        return String.format(GAME_RESULT_MESSAGE_FORMAT, carName, MOVEMENT_SYMBOL.repeat(movingCount));

 

4. validator에서 중복 체크할 때 Stream에 frequency를 사용하면 편한 검증 가능

5. AssertJ의 extracting

@Test
void no_extracting() throws Exception{
    List<String> names = new ArrayList<>();
    for (Member member : members) {
        names.add(member.getName());
    }

    assertThat(names).containsOnly("dexter", "james", "park", "lee");
}

Member List에서 해당 Member들의 이름을 테스트하려면,

Name List를 만들고 for each를 통해 테스트해야 한다.

@Test
void extracting() throws Exception{
    assertThat(members)
            .extracting("name")
            .containsOnly("dexter", "james", "park", "lee");
}

 

assertj에서는 extracting을 통해 더 간결한 테스트가 가능하다. 출처

6. 일급 컬렉션 개념 - 2주 차 미션에서는 Winner에서 사용했으면 적절했을 듯

7. Enum으로 매직 리터럴을 사용하면 format, 들어갈 변수를 미리 지정할 수 있다.=

개인적인 다음 주차 목표

1. 리드미를 기능 별로!! (클래스별로 x) 잘 작성하기.

- 여력이 된다면 프로젝트를 이해하기 위한 추가적인 설명 작성하기.

2. 기능별로 전부!!! 테스트하기. 상향식 테스트 꼼꼼하게.

3. 2주 차에 고민했던 부분 놓치지 않고 3주차에도 잘 적용하기. 대신 좀 더 빠르게!

4. 개발을 잘 완료한 뒤, 2주차에 배운 내용들 적절한 상황에 적용해 보기.

 

더보기

2주 차 메일을 통해 받은 “공통 목표”는, 함수를 분리하고, 함수별로 테스트를 작성하는 것이었습니다. 사실 이 부분은 java-baseball 미션이 종료된 뒤, 개인적으로 세웠던 목표이기도 했습니다.

프리코스를 시작할 당시, 테스트 코드에 친숙해지는 것이 개인적 목표 중 하나였고, 1주 차 미션에서도 테스트 코드를 작성해 보려는 시도를 했었지만, 모듈 간 의존성이 컸던 탓에 좋은 테스트를 하기가 쉽지 않았기 때문이었습니다.

따라서 이번 2주 차에는 공통 목표를 성취하기 위해, 설계를 꼼꼼하게, 그리고 각 객체 간의 역할과 책임을 잘 나누어 구성을 해보자는 목표를 세웠습니다.

본격적인 기능명세서를 작성하기 앞서, ‘변하지 않는 것’과 ‘대체할 수 있는 것’을 나눠보기로 했습니다.

'변하지 않는 것'은 <자동차를 이용한 경주 게임>이라 정의했고, '대체할 수 있는 것'은 다음과 같이 정의했습니다.

  1. 자동차 객체 생성 방식 (랜덤하게 만들 수도, 사용자 입력을 받을 수도 있다.)
  2. 게임 횟수 지정 방식 (랜덤하게 만들 수도, 사용자 입력을 받을 수도 있다.)
  3. 전진 조건 (랜덤 한 수> 4에 따라 RandomMoves 가 결정되지만, 어떤 방식으로든 변경 가능하다.)
  4. 결과 출력 방식 (pobi : ---- 지만, 변경될 수도.. 특히 - 문자는 충분히 변경 가능하다.)
  5. 우승자 선정 방식 (중복 허용, 중복 비허용 모두 가능하다.)
  6. 우승자 출력 방식 (최종 우승자 : pobi, jun 지만 변경될 수도 있다.)

초안이지만, 나름대로 객체를 잘 나누었다고 생각해 만족했고, 테스트도 수월할 줄 알았습니다.

그러나 랜덤 값에 따라 ‘전진을 잘하는지’ 테스트하는 부분에서 문제를 마주쳤습니다. 전진을 잘하는지 테스트하려면, 랜덤 값을 테스트 코드에서 직접 넣어주어야 값의 예측이 되고, 테스트의 자동화가 됩니다.

단순하게 RandomMoveService로 정의해서 Random 값을 딱 생성하고, 전진하도록 Service를 구현했더니, ‘전진하는 것만’ 테스트할 수가 없었습니다.

따라서 이 부분을 추상화해 주었습니다. 인터페이스로 Random Generator를 작성해서, fake 방법을 구현체로 넣으니 RandomMoveService에서 전진하는 부분만 테스트할 수 있었습니다.

결과적으로는 테스트를 위한 추상화, 분리였지만, 조금 더 생각을 해보니 랜덤 한 값을 추출하는 로직도, 사실은 ‘변하지 않는 것’과 ‘대체할 수 있는 것’에서 ‘대체할 수 있는 것’에 속하는 부분이었다는 것을 알게 되었습니다.

실제로 랜덤 값을 추출할 때 수별로 확률 조정이 가능하고, 우아한 테크코스 측에서 제공하는 메서드도 변경이 될 수 있기 때문입니다.

처음에 설계할 당시에는 발견하지 못했고, 테스트를 위해 강제된 분리였다고 생각하지만, 오히려 테스트를 위한 이런 고민이, 객체 지향적 설계의 밑거름이 되어 주었다고 생각합니다. 또한, 생각했던 것보다 정말 많은 책임이 뭉쳐 하나의 애플리케이션이 구동된다는 것도 느낄 수 있었습니다.

테스트를 짤 때 가장 어려웠던 부분은, ‘어떻게 테스트해야 메서드가 옳게 작동함을 완벽히 검증할 수 있을까?’였습니다. 적절하지 않은 검증은 테스트의 의미가 없다고 생각했기 때문입니다.

우선은 최대한 다양한 케이스를 생각하고, 전부 테스트 코드에 녹여 보려고 노력했습니다. 소프트웨어공학 시간에 배운 경곗값 테스트, 긍정 부정 테스트의 개념을 이번 주차 테스트를 작성하면서 적용해 보려고 시도했습니다.

특히 입력을 검증하는 Validator의 테스트에서 많이 고민했고, 실제로 자동차 이름 길이를 검증하는 조건이 잘못되었던 것을 발견할 수 있었습니다.

프로그램을 구현하다 보면, 본인 코드에 너무 몰입하게 되어 객관적으로 코드를 살피지 못하고, 사소한 문제도 잡지 못할 확률이 높아진다고 생각합니다. 그러나 테스트 코드를 작성하면서 이 문제가 해소되는 것을 느꼈습니다.

validator에서 문제가 있다는 것을 테스트 코드에서 발견하지 못했다면, 저는 아마 Application까지 전부 작성하고, 왜 전체 테스트가 성공하지 않는지 오랜 시간을 고민했을 것입니다.

또한, 메서드별로, 모듈별로 작게 테스트를 진행하면서 한 층 한 층 코드를 쌓아가니, 그만큼 이전 코드에 대해 확신이 생겼고, 이후 작성하는 코드를 더 자신감 있게, 과감하게 가져갈 수 있게 되었습니다.

그러나 아직 테스트 코드를 작성하는 것이 익숙하지 않아, 놓친 점이 다수 있었을 것이라 생각합니다.

프리코스에 참여하시는 동반자분들과의 코드 리뷰를 통해 이 부분을 검토받고, 3주 차 미션에서 더 꼼꼼하고 완성도 있는 테스트 코드를 작성해 보고 싶습니다.

이번 미션을 마치고 나니, 추가된 요구사항 있었던 depth를 낮추는 것, 3항 연산자를 쓰지 않는 것, 메서드가 한 가지 일만 하도록 최대한 작게 만드는 것 모두 테스트를 더 편리하게 하기 위함이었음을 느낄 수 있었습니다.

해당 요구사항을 지키면서 코드를 짜려고 노력하다 보니, 1주 차보다 더 편하게 테스트 코드를 짤 수 있었고, 1주 차보다 성장한 2주 차였다고 생각합니다.

감사합니다.

https://github.com/seminss/java-racingcar-6/tree/seminss

 

GitHub - seminss/java-racingcar-6

Contribute to seminss/java-racingcar-6 development by creating an account on GitHub.

github.com