우아한테크코스 6기 프리코스 - 자동차 경주 미션 회고

jiny·2023년 11월 1일
0
post-thumbnail

🎯 기능 구현 목록 작성

우선, 기능 구현 목록을 작성하기 전 전체적인 아키텍처를 객체들의 협력 관점에서 정의 했고, 그 후 객체들이 어떤 책임을 가질지 생각하며 기능 구현 목록을 작성했습니다.

객체 간의 협력과 기능 요구 사항을 종합하여 아래와 같은 기능 목록 들을 도출할 수 있었습니다.

기능 구현 목록

  • 자동차 이름 입력 기능
  • 자동차 이동 횟수 입력 기능
  • 자동차 전진 기능
  • 최종 우승자 확인 기능
  • 자동차 전진 결과 및 최종 우승자 출력 기능

기능 목록을 도출 한 다음, 미션 에서 제공하는 기능 요구 사항과 매칭 되는 기능을 찾아보며 기능 구현 목록을 최종적으로 구현하였습니다.

제가 도출한 기능 목록 구현은 README.md 에서 확인이 가능합니다.

🎯 미션 내 목표

이번 2주차 미션의 경우 메일로 다음과 같이 공지됬었습니다.

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

또한, 커밋 메시지 컨벤션도 함께 추가되었기 때문에 저는 함수 및 메서드 분리, 각 함수별 테스트 작성, 커밋 메시지 컨벤션 총 3가지의 카테고리를 설정했고 추가적인 학습이 필요한 부분 들을 스스로 고민하여 체크 후 아래와 같은 리스트 들을 도출할 수 있었습니다.

  • 커밋 메세지 컨벤션 지키기
    • type 에 들어갈 수 있는 항목들 잘 고려해서 커밋에 추가하기
    • scope 에 들어갈 수 있는 항목들 잘 고려해서 커밋에 추가하기
    • short summary에 들어갈 수 있는 항목들 잘 고려해서 커밋에 추가하기
    • message body에 들어갈 수 있는 항목들 잘 고려해서 커밋에 추가하기
  • 각 함수 별로 의미 있는 테스트를 작성하기
    • Black Box 원칙 준수하기
    • Annotative 원칙 준수하기
    • Single Door 원칙 준수하기
    • Independent 원칙 준수하기
    • Copy 원칙 준수하기
  • 함수를 잘 분리할 수 있는 방법 고민하기
    • 하나의 역할을 가지도록 함수를 작게 만들기
    • 명확한 네이밍을 가진 함수를 만들려고 노력하기
    • 함수 당 추상화 수준은 하나로 만들기
    • 명령과 조회를 분리하여 함수와 메서드를 설계하기

🔥 커밋 메시지 컨벤션 지키기

이번 미션의 경우 과제 진행 요구 사항에 커밋 메시지 컨벤션이 추가되었는데요.

커밋 메시지 컨벤션을 천천히 읽어보며 기존의 커밋 방식에서 컨벤션에 최대한 맞추려 해보았습니다.

✔️ 하나의 커밋으로 알아보는 커밋 메시지 컨벤션

빨간색 박스의 경우 type 으로써 refactor, feat, test 등 여러 타입 들이 있지만 readme.md를 update하는 과정 이었기 때문에 문서 작업을 의미하는 docs를 추가하였습니다.

주황색 박스의 경우 scope로써, 변경 된 모듈 및 파일에 대해 표기할 수 있으며 optional 한 특성이 있습니다. 이번 커밋 에선 README.md 변경 이었기 때문에, 다음과 같이 명명하였습니다.

노란색 박스의 경우 short summary로, 명령문, 현재 시제로 작성합니다. 영문자의 경우 첫글자를 대문자로 쓰지 않고 소문자로 써야 하는 특징이 있습니다.

초록색 박스의 경우 body로써, 명령문, 현재 시제로 작성하길 권장하며 변경한 이유와 변경 전과의 차이점을 잘 드러내야 합니다.

🔥 함수를 어떤 관점에서 분리할지 고려하기

함수 분리의 경우 경우에 따라서 아예 분리하지 않고 하나의 함수로 관리할 수도, 함수를 원자 단위로 분해 할 수도 있습니다.

전자의 경우 미션에서 지양하고 있기 때문에 후자로 접근해보면 분리했을 때 함수의 재 사용성은 좋겠지만, 읽어야 할 코드량이 많아지며 전체적인 복잡도가 올라가는 단점이 존재합니다.

따라서, '효과적인 함수 분리'를 고려 함으로써 코드의 유지보수와 가독성에 좋은 영향을 줄 수 있다고 생각했습니다. 저는 켄트 백의 '클린 코드' 중 함수 파트를 읽어보며 4가지 관점에서 고려했고, 이런 관점 들을 미션에 적용해보려고 했습니다.

✔️ 함수를 작게 만들기

if - else 문, while 문에 들어가는 블록이 한 줄이어야 하며, 함수 내 들여 쓰기 수준은 2단을 넘어서면 안된다고 언급 합니다.

함수를 작게 만들면 하나의 역할만을 하기 때문에, 간결하고 읽기 쉬운 함수를 만들 수 있습니다.

✔️ 함수 당 추상화 수준은 하나로 맞추기

추상화 수준을 맞추기 위해 클린 코드에서는 아래와 같은 사항 들을 권장하고 있습니다.

  • 코드는 위에서 아래로 이야기처럼 읽혀야 좋다.
  • 한 함수 다음에는 추상화 수준이 한 단계 낮아지는 것이 좋다.

또한 저의 경우 사람 마다 세부 기능을 분리하는 것은 다르다고 생각하기 때문에 함수 내 모든 추상화 수준이 동일할 때 함수가 확실히 하나의 작업만 할 수 있다고 생각했습니다.

RacingGameController.js

class RacingGameController {
  #inputView = InputView;

  #racingGameService = RacingGameService;

  #outputView = OutputView;

  async run() {
    await this.#processRacingGame();
  }

  async #processRacingGame() {
    const { racingResult, racingWinners } = await this.#requireRacingGameResult();
    this.#outputView.printRacingGameResult({ racingResult, racingWinners });
  }

  async #requireRacingGameResult() {
    const racingCarNames = await this.#requireRacingCarNames();
    const moveCount = await this.#requireRacingCarMoveCount();
    return this.#racingGameService.calculateRacingGameResult(racingCarNames, moveCount);
  }

  async #requireRacingCarNames() {
    const inputRacingCarNames = await this.#inputView.readRacingCarNames();
    validateCommon(inputRacingCarNames);
    const racingCars = inputRacingCarNames.split(SYMBOLS.comma);
    validateCarNames(racingCars);
    return racingCars;
  }

  async #requireRacingCarMoveCount() {
    const inputMoveCount = await this.#inputView.readRacingCarMoveCount();
    validateCommon(inputMoveCount);
    const moveCount = Number(inputMoveCount);
    validateMoveCount(moveCount);
    return inputMoveCount;
  }
}

저의 경우 controllermodelview상호작용하며 애플리케이션 흐름을 제어하는 책임으로 보았기 때문에 모듈 내 메서드의 prefix 들을 'require'와 'process'로 추상화 수준을 통일하였습니다.

require 관련 메서드는 외부로 부터 전달 하거나 전달 받은 데이터를 제어 하는 메서드로, process애플리케이션 실행 메서드로 추상화 수준을 분리하여 모듈 내에서 일관성을 지키며 가독성 및 함수의 간결함을 얻고자 하였습니다.

또한, 내려가기 원칙을 통해 코드가 위에서 아래로 읽히도록 설계하였습니다.

✔️ 서술적인 이름을 사용할 수 있는 함수 만들기

코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라 할 수 있다. - 워드 커닝햄(Ward Cunningham)

함수 네이밍을 통해 어떤 동작을 할 지 예측 가능하기 쉬워진다면 코드를 읽는 사람도 쉽게 이해할 수 있으며, 함수가 잘 분리했을 것이라고 예측했습니다. 따라서, 모든 모듈들에 대해 코드 흐름에 맞는 정확한 의미를 전달하는 함수의 네이밍을 고민 후 적용해보려 노력했습니다.

RacingWinnerCalculator.js

class RacingWinnerCalculator {
  
  #lastRacingStatus;

  constructor(lastRacingStatus) {
    this.#lastRacingStatus = [...lastRacingStatus];
  }

  calculateRacingWinners() {
    return [...this.#lastRacingStatus]
      .sort((racingStatus, otherRacingStatus) => otherRacingStatus.position - racingStatus.position)
      .filter(({ position }, _, racingCarResult) => position === racingCarResult.at(0).position)
      .map(({ carName }) => carName)
      .join(`${SYMBOLS.comma} `);
  }
}

export default RacingWinnerCalculator;

리팩터링 전 코드의 경우 하나의 public 메서드에서 체이닝을 통해 모든 기능을 순차적으로 실행 후 반환하는 것을 알 수 있습니다.

RacingWinnerCalculator의 경우 크게 3가지 세부 기능으로 나눠 볼 수 있습니다.

  • position 기준으로 내림 차순 정렬
  • top position을 제외한 racingCarInfo 필터링
  • racingCarInfo에서 carName 추출

고려하기 전엔 calculateRacingWinners에서 함수를 체이닝 하는 형태로 하나의 메서드 에서 관리하여 코드의 전체 line은 짧았지만, 세부 로직을 보기 어렵다고 생각했습니다.

그래서 위 세부 기능에 대해 네이밍을 붙여 리팩터링을 진행했습니다.

class RacingWinnerCalculator {

  // ...

  #sortLastRacingStatusPositionByDescending() {
    this.#lastRacingStatus = this.#lastRacingStatus.sort(
      (racingCarInfo, otherRacingCarInfo) => otherRacingCarInfo.position - racingCarInfo.position,
    );
    return this;
  }

  #filterTopPositionCars() {
    const topPosition = this.#lastRacingStatus.at(0).position;
    this.#lastRacingStatus = this.#lastRacingStatus.filter(
      ({ position }) => position === topPosition,
    );
    return this;
  }

  #extractWinnerNames() {
    return this.#lastRacingStatus.map(({ carName }) => carName);
  }

  calculateRacingWinners() {
    return this.#sortLastRacingStatusPositionByDescending()
      .#filterTopPositionCars()
      .#extractWinnerNames()
      .join(`${SYMBOLS.comma} `);
  }
}

작업 별로 sortLastRacingStatusPositionByDescending, filterTopPositionCars, extractWinnerNames 의미 있는 네이밍을 붙여 분리함으로써 메서드 동작 의미를 명확하게 전달할 수 있었습니다.

✔️ 명령과 조회를 분리하기

함수는 크게 특정 명령에 응답하는 case특정 요청을 통해 작업한 결과를 반환하는 case가 있습니다.

두 가지 case를 한번에 수행하는 함수가 많아질 경우 애플리케이션의 동작 흐름을 예측하기 어렵게 만들게 됩니다.

runRace() {
  return Array.from({ length: this.#moveCount }, () => {
    const { minNumber, maxNumber } = RANDOM_NUMBER_RANGE;
    this.#racingStatus = this.#racingStatus.map((currentRacingCarInfo) =>
      RacingCar.from(currentRacingCarInfo).move(pickRandomNumberInRange(minNumber, maxNumber)),
    );
    return [...this.#racingStatus];
  });
}

저의 경우 RacingGame 클래스의 runRace 메서드가 명령과 조회를 동시에 하는 case인 것을 알 수 있습니다.

함수를 분리하기 전, racingStatus를 update 시키는 명령과 RacingResult를 반환하는 조회runRace 메서드에 몰려 있는 것을 알 수 있습니다. 즉, 명령과 조회를 함수로 분리하여 표현할 수 있습니다.

#updateRacingStatus() {
  const { minNumber, maxNumber } = RANDOM_NUMBER_RANGE;
  this.#racingStatus = this.#racingStatus.map((currentRacingCarInfo) =>
    RacingCar.from(currentRacingCarInfo).move(pickRandomNumberInRange(minNumber, maxNumber)),
  );
}

runRace() {
  return Array.from({ length: this.#moveCount }, () => {
    this.#updateRacingStatus();
    return [...this.#racingStatus];
  });
}

따라서 해당 원칙에 맞게 update 로직을 updateRacingStatus분리하여, 예측 가능한 동작을 만들었으며 분리된 함수는 가독성 또한 개선되는 것을 알 수 있습니다.

🔥 각 함수 별로 의미 있는 테스트를 작성하기

이번 미션에서는 함수들에 대해 테스트 하는 요구 사항이 추가 되었었습니다.

저의 경우 모든 모듈에 대해 유닛 테스트 하는 것이 예측 가능한 동작을 만들어낼 수 있지만, 테스트 비용을 최소화 시켜 프로덕션 코드의 코드 퀄리티를 유지하는 것을 우선 순위로 설정하는 것도 중요하다고 생각했습니다.

따라서 분리된 모듈에 대해 어떤 항목 들을 테스트 해야할지, 어떤 구조로 테스트 코드를 설계해야 할 지에 대해 yoni goldberg - javascript-testing-best-practices 에서 언급하는 BASIC 원칙을 학습하며 미션에 적용해보려 노력했습니다.

✔️ Black Box

Testing the internals brings huge overhead for almost nothing. - 1.4 Stick to black-box testing: Test only public methods

테스트 코드테스트 대상을 호출하는 클라이언트가 무엇을 받게 될 것인지에 대해 관심을 가져야 하기 때문에, 내부 작동 방식을 테스트하는 white box test가 아닌, 테스트 대상이 반환하는 결과에 집중하는 black box test가 되어야 한다고 언급합니다.

외부로 노출되는 것에 초점을 맞춰 세부적인 코드의 양을 줄이면, 기능에 대한 테스트에 집중할 수 있으며, 불필요한 테스트 코드를 줄여 복잡성을 낮출 수 있습니다.

그래서 모든 모듈에 해당 원칙을 적용했지만 RacingCar의 테스트 코드에서 예시를 보여드릴려고 합니다.

RacingCar.test.js

describe('RacingCar 테스트', () => {
  test.each([
    {
      input: {
        racingCarInfo: { carName: 'A', position: 0 },
        randomNumber: RacingCar.MOVE_THRESHOLD,
      },
      expected: { carName: 'A', position: 1 },
    },
    {
      input: {
        racingCarInfo: { carName: 'B', position: 0 },
        randomNumber: RacingCar.MOVE_THRESHOLD - 1,
      },
      expected: { carName: 'B', position: 0 },
    },
    {
      input: {
        racingCarInfo: { carName: 'C', position: 0 },
        randomNumber: RacingCar.MOVE_THRESHOLD + 1,
      },
      expected: { carName: 'C', position: 1 },
    },
  ])(
    '입력 받은 자동차의 position이 $input.racingCarInfo.position 이고 랜덤 숫자가 $input.randomNumber일 때, 예상 position은 $expected.position 이다.',
    ({ input: { randomNumber, racingCarInfo }, expected }) => {
      // given
      const racingCar = new RacingCar(racingCarInfo);

      // when
      const updateRacingCarInfo = racingCar.move(randomNumber);

      // then
      expect(updateRacingCarInfo.position).toBe(expected.position);
    },
  );
});

현재의 테스트 코드를 보면 public methodmove에 대한 테스트를 하는 것을 알 수 있습니다.

move 메서드에서 확인할 수 있는 요소는 아래와 같습니다.

  1. randomNumber가 4보다 미만인 경우
  2. randomNumber가 4인 경우
  3. randomNumber가 4보다 초과하는 경우

test.each를 통해 이동할 수 있는 case(랜덤 값이 4 이상)과 이동할 수 없는 케이스(4 미만인 경우)의 경계 값을 설정한 다음, 결과 값이 잘 나오는지 테스트 함으로써, 외부로 노출 되는 값을 테스트하고 불필요한 테스트를 줄여 전체적인 복잡성을 낮출 수 있었습니다.

✔️ Annotative

테스트 코드의 경우 불필요한 비용을 줄여야하기 때문에, A(rrange)A(ction)A(ssert) 패턴을 통해 선언적이고 구조적인 테스트 코드를 만들어 예측 가능하고 주석 처럼 느껴지도록 읽기 쉬운 코드를 만들도록 장려합니다.

Arrange테스트의 필요한 데이터를 나열하며, Action은 동작을 실행하고, Assert제대로 동작하는지 테스트 하는 과정을 의미합니다.

저는 AAA 패턴을 사용하지는 않았지만 비슷한 패턴인 given - when - then을 모든 테스트 모듈에 적용하여 테스트 코드를 선언적이고 구조적으로 만들어 읽기 쉽고 예측 가능한 테스트 코드를 만들고자 하였습니다.

✔️ Single Door

이 원칙은 모든 테스트 코드가 단 한 가지 기능에 대해 테스트 하는 것에 집중하는 것을 의미 합니다.

즉, 하나의 작업을 실행하여 작업에 대한 응답으로 발생한 결과 하나만을 확인해야 하는 것을 의미합니다.

저는 이 원칙의 경우 따로 적용하기 보단, Black box test비슷한 맥락이라고 파악 되었으며 이 원칙이 잘 적용되려면 테스트 대상의 설계가 중요하다는 것을 알게 되었습니다.

예시로 Model Layer의 경우 각 객체 들은 이미 하나의 기능에 대해 필요한 책임을 구현하도록 설계 했었기 때문단일 동작에 대한 결과 값 테스팅이 쉽게 이뤄지는 것을 알 수 있었습니다.

✔️ Independent

테스트 모듈 들은 어떤 코드 와도 겹치지 않는 독립적 공간으로써, 짧고 선언적인 테스트 코드를 유지하며 글로벌 객체와 연동할 경우 side effect를 고려하여 설계를 하는 것을 강조합니다.

특히, 불필요한 mocking에 대해 종속성을 분리하고 코드 그 자체로 설명할 수 있는 환경을 유지하는 것이 핵심이라고 언급합니다.

저는 이 항목을 통해 RacingCar테스트 코드에 mocking 하는 로직을 제거하여 이전 보다 읽기 쉽고 예측 가능한 테스트 모듈 을 만들 수 있었습니다.

move() {
  const { position: prevPosition } = this.#racingCarInfo;
  const { minNumber, maxNumber } = RANDOM_NUMBER_RANGE;
  const randomNumber = this.#randomNumberGenerator(minNumber, maxNumber);
  return randomNumber >= RacingCar.MOVE_THRESHOLD
    ? { ...this.#racingCarInfo, position: prevPosition + 1 }
    : this.#racingCarInfo;
}

이전의 move 메서드는 Random 모듈에 의존하여 랜덤 값을 자체적으로 생성하고 있었습니다.

RacingCar.test.js

// ...

// given
const mockRandomGenerator = jest.fn(() => randomNumber);
const racingCar = new RacingCar(racingCarInfo, mockRandomGenerator);

// when
const updateRacingCarInfo = racingCar.move();

// then
expect(updateRacingCarInfo.position).toBe(expected.position);

그렇기 때문에 이미 RacingGame이 모킹하여 테스트 하고 있는 상태에서 위 코드와 같이 RacingCar 마저 mocking을 하는 것이 불필요하다고 느껴졌습니다.

이러한 의존성을 제거하기 위해 random 값을 외부로 분리하는 것이 필요했습니다.

관련 레퍼런스를 찾아보다 메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기에서 지금의 상황과 동일한 case를 마주할 수 있었고 망설임 없이 move의 매개변수로 받게 함으로써 RacingCar의 불필요한 모킹을 줄일 수 있었습니다.

✔️ Copy

테스트의 작성 의도를 쉽게 파악하기 위해선 테스트에 의미 있는 정보는 외부에 두어선 안되며, 필요한 경우에는 복사를 허용하지만 되도록 불필요한 중복은 피해야 하는 것을 강조하고 있습니다.

저는 validation 모듈 테스트를 할 때 이 원칙을 지키고자 하였습니다.

CarNamesValidation.test.js

describe('validateCarNames 테스트', () => {
  describe('예외 테스트', () => {
    test.each([
      {
        carNames: ['aaaaaa', 'bbbbbb'],
        expectedErrorMessage: CAR_NAME_VALIDATION_TYPES.lengthOfCarName.errorMessage,
      },
      {
        carNames: ['aaa', 'aaa'],
        expectedErrorMessage: CAR_NAME_VALIDATION_TYPES.duplicateCarNames.errorMessage,
      },
    ])(
      '입력된 자동차 이름들이 $carNames 일 때 "$expectedErrorMessage" 메시지와 함께 에러가 발생해야 한다.',
      ({ carNames, expectedErrorMessage }) => {
        // given - when
        const startValidation = () => validateCarNames(carNames);
        // then
        expect(startValidation(carNames)).toThrow(new AppError(expectedErrorMessage));
      },
    );
  });

  describe('비 예외 테스트', () => {
    test.each([
      {
        carNames: ['aaa', 'bbb', 'ccc'],
      },
      {
        carNames: ['car1', 'car2'],
      },
    ])('입력된 자동차 이름이 $carNames 일 때 에러가 발생하지 않는다.', ({ carNames }) => {
      // given - when
      const startValidation = () => validateCarNames(carNames);
      // then
      expect(startValidation(carNames)).not.toThrow();
    });
  });
});

startValidation유틸 함수로 아예 분리 할 수도 있었지만, 외부로 분리했을 때 이 함수가 어떤 기능을 수행할지 파악하는 것이 어려워질 것이라 생각했습니다.

그래서 startValidation예외 테스트와 비 예외 테스트 모두에 같은 로직을 넣었지만 이는 코드 중복을 야기 하기 때문에 또 다른 방법이 필요했습니다.

describe('validateCarNames 테스트', () => {
  const startValidation = (carNames) => () => validateCarNames(carNames);

  describe('예외 테스트', () => {
    test.each([
      {
        carNames: ['aaaaaa', 'bbbbbb'],
        expectedErrorMessage: CAR_NAME_VALIDATION_TYPES.lengthOfCarName.errorMessage,
      },
      {
        carNames: ['aaa', 'aaa'],
        expectedErrorMessage: CAR_NAME_VALIDATION_TYPES.duplicateCarNames.errorMessage,
      },
    ])(
      '입력된 자동차 이름들이 $carNames 일 때 "$expectedErrorMessage" 메시지와 함께 에러가 발생해야 한다.',
      ({ carNames, expectedErrorMessage }) => {
        // then
        expect(startValidation(carNames)).toThrow(new AppError(expectedErrorMessage));
      },
    );
  });

  describe('비 예외 테스트', () => {
    test.each([
      {
        carNames: ['aaa', 'bbb', 'ccc'],
      },
      {
        carNames: ['car1', 'car2'],
      },
    ])('입력된 자동차 이름이 $carNames 일 때 에러가 발생하지 않는다.', ({ carNames }) => {
      // then
      expect(startValidation(carNames)).not.toThrow();
    });
  });
});

validateCarNames에 넣어주는 인자를 제외하곤 같은 로직 이었기 때문에 클로저로 만들어 테스트 상단에 위치 시킴으로써, 중복도 방지하고 의미 있는 메서드를 테스트에 포함시킬 수 있었습니다.

레퍼런스

커밋 컨벤션

함수 분리

함수 테스트

0개의 댓글