image

개요

두번째 프리코스 미션 후기!

첫번째 프리코스에서는 단순한 구현과 객체 분리에 집중을 두었다.

그러나 이번 주차에서는 테스트 코드 작성에 대한 요구사항이 추가되었고, 이에 따라 어떻게 효과적인 테스트 코드를 작성할지에 대한 고민이 더해졌다.

효과적인 테스트 코드를 작성하기 위해, 각 객체의 역할을 명확히 분리하고, 이 과정에서 많은 사람들이 사용하는 MVC 디자인 패턴을 적용하려고 노력했다 사실 아직도 어디까지를 객체로 분리하는게 좋을 지 고민이지만 그 과정에서 배운내용을 정리해보고자 한다.

문제 링크

문제 요구 사항

[추가된 요구사항]

indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.

힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메소드)를 분리하면 된다. Jest를 이용하여 본인이 정리한 기능 목록이 정상 동작함을 테스트 코드로 확인한다.

테스트 도구 사용법이 익숙하지 않다면 tests/StringTest.js를 참고하여 학습한 후 테스트를 구현한다.

[기능 요구 사항]

초간단 자동차 경주 게임을 구현한다.

주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다. 각 자동차에 이름을 부여할 수 있다. 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다. 자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다. 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다. 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다. 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다. 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다. 사용자가 잘못된 값을 입력한 경우 throw문을 사용해 “[ERROR]”로 시작하는 메시지를 가지는 예외를 발생시킨 후, 애플리케이션은 종료되어야 한다.

문제는 이전처럼 복잡하지는 않았지만, 추가된 기능사항에 앞서 말했듯 테스트 코드 가 추가되었고, 나아가 indent에 대한 제약사항은 함수의 역할을 최대한 쪼개라는 의미로 해석했다.

사실 하나의 함수가 너무 많은 역할을 가지면 더 분리할 수 있는 요소가 있는지 고민해봐야했고 다른 객체와의 의존성이 엮여있기때문에 실제 테스트코드를 어떻게 짜야할지 고민하는 계기가 되었었는데

테스트코드를 짜라는 것과, indent에 대한 제약사항이 의미하는 내용은 결국 하나였다고 생각했다.

기능 설계와 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
## 기능 요구 사항

## 자동차

- [x] 이름을 가진다

  - [x] (예외처리) 이름의 경우 5 이하만 가능하다
  - [x] (예외처리)  문자열을 이름으로 가질  없다

- [x] 0~9사이의 값중 무작위 값을 계산한다

  - [x] 4이상일 경우 전진한다

- [x] 현재 전진한 위치 값을 가지고 있어야 한다

## 자동차 경주

- [x]  라운드의 경주를 진행한다
- [x]  라운드별 결과값을 반환
- [x] 최종 우승자 정보를 반환
  - [x] 가장 많이 이동한 위치 정보를 가져올  있어야 
  - [x] 동일한 위치정보가 있다면, 배열을 통해 우승자들의 이름을 배열형태로 반환

## 입력

- [x] 경주할 자동차 이름을 입력 받는다
  - [x] (예외처리)  문자열이 아닌 값을 입력 받아야 한다
- [x] 이동 횟수를 입력 받는다
  - [x] (예외처리) 정수를 입력받아야 한다
  - [x] (예외처리) 1이상의 정수를 입력받아야 한다

## 출력

- [x]  이동 차수별 결과를 출력한다
- [x] 최송 우숭자를 출력한다
  - [x] 우숭자가 여러명이라면 "," 사용해 구분한다.

## 전체 흐름

- [x] 사용자에게 자동차 이름, 진행 라운드 정보를 입력받는다
- [x] 라운드 횟수만큼 "자동차 경주" 경기를 진행한다
- [x]  라운드의 경기를 마친 "자동차 경주" 라운드 결과값을 출력한다
- [x] 전체 라운드를 마치면, "자동차 경주"로부터 우승자 정보를 받아 출력한다



1주차와 다르게 사실 기능을 설계하는데 생각보다 시간을 많이 사용했다.

위에서 작성된 내용은 중간중간 수정사항이 반영되 많이 바뀌기도 했지만, 전체 플로우에 대한 로직을 구현하기 전에 “자동차”“경주”라는 객체는 게임의 상태를 나타내는 독립된 객체로써 볼 수 있을거라고 판단했다.

특히나 기능을 수정하고 변경하는 과정을 두려워하지 않도록 마음을 먹었으며 이 과정에서 처음부터 완벽한 설계를 추구하기보다, 구체적으로 작성하는 데 초점을 두고 지속적으로 업데이트하는 방향을 선택했는데 결국 요구사항 또한 언제나 바뀔 수 있다고 생각했기 떄문이다


Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Car {
  constructor(name) {
    this.validateName(name);
    this.name = name;
    this.position = 0;
  }

  validateName(name) {
    if (name.length > MAX_CAR_NAME_LENGTH) {
      throw new Error(
        `[ERROR] ${ERROR_MESSAGE.invalidLength(MAX_CAR_NAME_LENGTH)}`
      );
    }

    if (name === "") {
      throw new Error(`[ERROR] ${ERROR_MESSAGE.isCarNameNull}`);
    }
  }

  attemptMove() {
    const randomValue = MissionUtils.Random.pickNumberInRange(0, 9);
    if (randomValue >= MOVE_THRESHOLD) {
      this.move();
    }
  }

  move() {
    this.position += 1;
  }

  getPosition() {
    return this.position;
  }

  getName() {
    return this.name;
  }
}

export default Car;

Car객체에서는 경주를 하는 자동차 하나하나를 생각했다.

이름을 가지고, 현재 위치값을 가지고 있으며 요구사항에 따라 일정 횟수가 나오면 위치를 증가시키도록 구성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Race {
  constructor(cars) {
    this.cars = cars;
  }

  progressRound() {
    this.cars.forEach((car) => car.attemptMove());
  }

  getRoundResult() {
    return this.cars.map((car) => {
      return {
        name: car.getName(),
        position: car.getPosition(),
      };
    });
  }

  getMaxPosition() {
    return Math.max(...this.cars.map((car) => car.getPosition()));
  }

  getWinners() {
    return this.cars
      .filter((car) => car.getPosition() === this.getMaxPosition())
      .map((car) => car.getName());
  }
}

export default Race;

또한 경기정보를 가진 Race객체에서는 Car들에 대한 정보를 가지고 있어 경주를 진행시키고, 결과값 정보를 return할 수 있도록 구성했다.

사실 처음에는 “경기 횟수”도 객체 내부의 변수로 다루어서 값을 관리하려고 했는데 컨트롤러 단으로 분리시켰다.

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class CarRaceController {
  constructor() {
    this.race = null;
    this.totalRound = 0;
    this.inputView = new InputView();
    this.outputView = new OutputView();
  }

  async setup() {
    const carNamesInput = await this.inputView.getCarNamesUserInput();
    const cars = carNamesInput.split(",").map((carName) => new Car(carName));
    this.race = new Race(cars);

    const totalRoundInput = await this.inputView.getTotalRoundUserInput();
    this.totalRound = totalRoundInput;
  }

  startRace() {
    this.outputView.printRoundResultInitMessage();
    for (let curRound = 0; curRound < this.totalRound; curRound++) {
      this.race.progressRound();
      this.outputView.printRoundStatus(this.race.getRoundResult());
    }
  }

  showResult() {
    this.outputView.printWinners(this.race.getWinners());
  }
}

export default CarRaceController;

그리고 컨트롤러단에서는 전체 게임의 진행 로직을 담당하도록 구현했으며, 각각의 입출력은 InputView, OutputView를 통해 관리하도록 분리했다.

고민했던 요소

  1. Validation의 위치

    요구사항에 의하면 자동차의 이름은 쉼표로 구분지어 입력받고, 각 자동차의 이름은 5글자를 넘어서는 안된다는 조건이 있었다.

    처음에는 단순히 입력을 처리하는 부분에서 모든 유효성을 검증하려고 했었지만 저는 입력을 받는 곳과 자동차 객체에서 각각 유효성 검증을 처리하기로 결정했다.

    “5자 이하의 자동차 이름” 과 같은 부분은 자동차 도메인 속성의 제약사항이기 때문에 도메인에서 처리하는게 적절하다고 생각했기 때문인데 특히 Car라는 인스턴스가 안전하다는 보장을 하려면 객체 내부에서 자체적인 검증이 필요하다는 생각이 들었다.

    다른 입력문구나, 요구사항이 변경되 다른 로직을 통해 Car를 생성해야한다면 그때도 별도의 검증함수를 중복해서 사용해야하는가?

    자동차의 제약조건을 확인하려면 도메인이 아니라, 이를 생성하기 위해 문자열을 입력받는 입출력 부분을 확인해야하는데 직관적이지 않지 않을까?

    트럭, 스포츠카 등등 속성이 확장됬을때, 객체에서 사용중이였다면 오버라이딩을 통해 명확히 나타내면 좋을 것 같다!

    위와 같은 생각을 기준으로 나는 자동차의 이름은 도메인단위에서, 순수 입력에 대한 검증은 따로 처리하도록 분리했고 이를 통해 각 객체의 역할과 책임이 두드러졌다고 생각한다.

  2. MVC모델을 사용해야 할까?

    아무래도 공부를 목적을 코드를 작성하다보니 최대한 수정에 용이하고 확장했을때 유연하게 대처할 수 있는 좋은 코드가 무엇일지 고민을 많이 했는데 여기서 많은 사람들이 사용하는 MVC패턴을 꼭 적용해야 할지 고민을 많이 했다.

    특히 View 부분을 통해 출력 단위를 분리하고, 도메인은 자신의 정보만 처리하도록 하며 테스트 코드를 작성하면서 MVC패턴의 장점을 크게 이해할 수 있었다.

    예를들어, 경기 결과의 진행상황을 “-“로 표현하고있는데, “!” 으로 표현하거나, 표현해야 할 부분이 바뀐다면??

    Domain자체에서 “결과 값 출력” 에 대한 테스트코드를 “-“문자열을 사용해 잘 처리하고 있었다면 이 요구사항이 수정됨에 따라 이 부분도 같이 바꿔야한다. 사실 도메인의 역할은 달라진게 없고 출력 부분에 대한 로직만 바뀐건데 말이다.

    도메인은 순수한 데이터 처리를 담당하고, View는 출력과 관련된 부분을 처리하므로 요구사항이 변경되더라도 수정 범위를 최소화할 수 있으며 이에 대한 테스트 코드를 작성하며 적절한 관심사의 분리가 어떤 의미에서 중요하고 적절한지 크게 느낄 수 있었다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    // 폴더 구조
    
    ├── App.js
    ├── constants.js
    ├── controller
     └── CarRaceController.js
    ├── index.js
    ├── models
     ├── Car.js
     └── Race.js
    └── views
    ├── InputView.js
    └── OutputView.js
    
    
  3. Race도메인의 책임 분리

    사실 “경기” 라고 하면 전체 경기 횟수와 참여한 자동차 정보들, 그리고 경기 결과에 대한 정보를 가지고 있는 역할을 수행해야 한다고 생각했다.

    그런데, 경기 중간중간의 결과 값을 출력하려면..? Race도메인 자체에서 결과값을 그냥 출력해도 상관 없겠지만 그럼 Race객체와 View사이에 의존성이 생겨버린다.

    도메인은 순수 데이터 정보를 전달하고, 컨트롤러에 의해 View가 호출되며 화면상에 표현되는게 좋은 구조라고 생각했기때문에 이 부분은 좋지 않다고 생각했다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    class Race {
      // 초기 생각했던 방식
      constructor(cars, totalRounds) {
        this.cars = cars;
        this.totalRounds = totalRounds;
      }
    
      start() {
        for (let round = 0; round < this.totalRounds; round++) {
          this.cars.forEach((car) => car.move());
          this.printRoundResult();
        }
      }
    
      printRoundResult() {
        // 각 결과를 Race객체에서 출력하도록 했다.
        this.cars.forEach((car) => {
          Console.log(`${car.getName()} : ${"-".repeat(car.getPosition())}`);
        });
      }
    
      // {...}
    }
    
    export default Race;
    

    그럼 각 경기의 결과값을 멤버변수로 선언하고, 결과값만 따로 준다면??

    이 방법을 사용하면 View와의 의존성도 사라지고 도메인의 역할에 충실할 수 있겠지만 좋은 방법은 아니라고 생각했다.

    왜냐하면, 모든 경기가 끝난 이후에 결과값을 확인할 수 있으머 (경기 내용을 바로바로 확인 못함), 중간에 상호작용 요소나 출력내용이 추가된다면 수정하기에 용이한 구조가 아니라고 생각했기때문이다.

    따라서 Race의 totalRounds 변수를 컨트롤러의 플로우로 분리하고 Race에서는 "단일 경기"를 수행하고 현재 경기의 정보를 전달할 수 있도록 역할을 분리했다.

느낀점

풀이 코드(git)

사실 2주차에서는 구현 방식을 변경했는데 기존플로우가 아래와 같았다면

기능 구현(일단 돌아가게 만들자!) => 객체 분리 => 리펙토링 => 리펙토링…

2주차 부터는 객체를 먼저 정의하고, 각 객체에 대해 작은 단위의 테스트 코드를 작성한 이후 각 객체를 연결해 돌아가게 만든 후, 리펙토링을 진행했다.

image

객체 생성 => 객체에 대한 테스트코드 => 반복 => 리펙토링…

이렇게 코드를 짰더니 좋았던 부분은 아무래도 리펙토링을 한 이후 기존 동작이 잘 돌아가는지 쉽게 확인할 수 있었다는 점이다.

또한 테스트코드를 중심으로 역할을 분리하다보니 다양한 고민을 하게되고, 의존성 문제로 조금 애매하다는 생각이 들었을때에는 “이거 역할을 더 분리해야겠는데..?” 라며 책임을 분산시키도록 더 의식하는데 도움이 되었다.

물론 중간 중간 기능이 바뀌면서 테스트코드도 계속 수정을 했지만 테스트 코드 또한 다른 부분과 같이 하나의 관리대상으로써 크게 중요하다는 부분을 배울 수 있었다고 생각한다.


조금 아쉬웠던 부분은 아직 JS Class문법에 익숙하지 않아 캡슐화를 적절하게 처리하지 못했다는 부분(JS에서는 #을 통해 Java의 private와 같은 역할을 수행한다고 함)과, 테스트 코드가 조금 미흡했다는 점이 있었다.

어쨌든 코딩에서 정답은 없기때문에 자신만의 기준을 세워 고민해보고, 타당한 이유가 있다면 그 방법이 옳다고 볼 수 있다고 생각하기때문에

이번 과제를 통해 이러한 과정을 경험하며, 코드를 작성하는 것의 재미와 더불어 개발자로서의 성장도 느낄 수 있었고 3주차에서는 조금 더 객체지향적인 공부와 관심사를 분리하는데 집중을 두고 코드를 개선시켜보고싶다.

기타 피드백

좋은 피드백 감사드립니다~~~

https://github.com/woowacourse-precourse/javascript-racingcar-6/pull/10

  1. Single Quote 사용하기

    • 에어비엔비 권장사항이라고 한다! (근데 사실 처음 제공해주는 템플릿코드에서 Double Quote를 사용하고 있었으니 어느정도 팀 내의 컨벤션에 따라가도 괜찮을 것 같다)

    • 다만 문자열 내에 "을 표현하는 경우가 많으니, Single Quote를 사용했을때 이점은 분명히 드러난다고 생각한다

      • alert("It's \"game\" time.") vs alert('It\'s "game" time.')

      • 관련 문서

    • 관련 문서

  2. import는 사용하려는 모듈만 명확하게 나타내기

    • import { MissionUtils } from "@woowacourse/mission-utils" (x)

    • import {Console, Random} from "@woowacourse/mission-utils" (0)

    • 번들러는 필요한 메서드가 호출되기 전까지는 해당 메서드를 메모리에 로딩하지 않으므로 성능상의 차이는 없겠지만 나는 이 방법이 사용하려는 모듈정보를 보다 명확히 나타낼 수 있는 장점이 있다고 생각한다.

카테고리:

업데이트:

댓글남기기