[우아한 테크코스] - 로또 미션 step 1 회고

jiny·2024년 2월 26일
0
post-thumbnail

Intro

두 번째 미션인 로또 미션 step 1(콘솔 기반 로또 게임)을 마치고 다시 회고하게 되었다.

이번 미션의 핵심 키워드는 TDD와 리팩터링 이었는데, 이러한 키워드에 대해 어떤 방식으로 진행했는지, 그리고 리뷰어님에게 받은 리뷰에 대해 어떻게 의사결정 했는지에 대해 적어보려고 한다.

TDD

매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나이다. - wikipedia -

쉽게 생각하면, 프로덕션 코드 작성 전 테스트를 먼저 작성하는 테스트 우선 개발과 리팩터링을 결합한 발전된 개발 접근 방식이다.

TDD를 하는 목적은 아래와 같이 정리하였다.

  • 동작 가능한 작은 범위를 빠르게 피드백 받을 수 있다.
  • 한 부분을 변경하면 다른 부분이 작동하지 않거나 "하나의 버그를 '수정'하면 다른 버그가 생성되는 등 코드 작성 시간 보다 버그 잡는 시간에 더 투자하지 않을 수 있다.

TDD는 주로 단위 테스트와 함께 결합할 때 더욱 효과적인데, 개발해야하는 최소한의 기능 단위에 대해 먼저 테스트 코드를 작성 후 모듈을 구현함으로써, 해당 기능에 대해 빠르게 피드백 받을 수 있다.

또한, 요구 사항에 대해 디자인 패턴 없이 동작 가능한 최소한의 모듈을 생성할 수 있어, 오버엔지니어링이 거의 발생하지 않는다는 장점이 있다.

코드를 살펴보기 전에 TDD의 구성 요소에 대해 살펴보자.

TDD 진행 방식

  1. 구현 해야 할 기능에 대한 테스트 코드를 먼저 작성한다.

  2. 테스트를 진행하여 실패한다. (red)

  3. 테스트 코드를 위한 구현체를 작성한다.

  4. 테스트를 통과 시킨다. (green)

  5. 테스트를 통과 시킨 모듈에 대한 리팩터링을 진행한다. (refactor)

  6. 1-5 과정을 반복한다.

TDD의 Cycle은 흔히 red-green-refactor 라고 불리기도 한다.

우선, 특정 요구 사항에 대한 테스트 코드를 먼저 작성한다.

테스트 코드를 작성할 때는 다른 것들을 고려하지 않고, 요구 사항(기능 구현 목록)을 최우선 순위로 잡고 구현하는 것을 원칙으로 한다.

이렇게 했을 때, 테스트 코드는 기능 명세서로써 다른 개발자 들이 보기 쉬운 또 다른 문서가 될 수 있다.

그 후, 테스트를 실패하는 코드를 만드는데 테스트가 실패할 때는 구현 모듈이 import 하지 않은 오류인지 아니면 다른 오류가 발생했는지 확인하는 것이 핵심이다.

이를 확인했다면, 최소한의 기능만 반영시킨(테스트를 green으로만 만드는) 모듈을 만들어 green 테스트로 만든다.

여기까지 성공했다면, 그 모듈에 대한 리팩터링을 진행하여 알아보기 쉽고, 유지보수 하기 좋은 코드로 만들어낸다.

이 과정을 한 사이클로 잡고 모든 기능에 대해 동일한 방법을 적용하면 TDD로써 개발이 가능한 형태다.

예시 - 로또 구입 & 로또 생성 기능

이 기능을 예시로 들면, 구입 금액을 받아 그 구입 금액 만큼의 로또를 생성해야 한다.

또한, 생성된 로또 번호는 4가지 사항을 확인해야 한다.

import Lotto from './Lotto.js';

describe('로또 생성 테스트', () => {
  // given
  let lottoNumber;

  beforeEach(() => {
    const lotto = new Lotto();

    // when
    lottoNumber = lotto.createNumber();
  });

  test('생성된 로또 번호는 6개다', () => {
    // then
    expect(lottoNumber.length).toBe(6);
  });

  test('로또 번호는 1 ~ 45 사이여야 한다.', () => {
    // then
    const isValidRange = lottoNumber.every((number) => number >= 1 && number <= 45);
    expect(isValidRange).toBeTruthy();
  });

  test('로또 번호는 서로 중복되지 않아야 한다.', () => {
    // then
    const isDuplicated = new Set(lottoNumber).size === lottoNumber.length;
    expect(isDuplicated).toBeTruthy();
  });

  test('로또 번호는 오름차순으로 정렬되어야 한다.', () => {
    // then
    const isSorted = lottoNumber.every((num, i) => i === 0 || num >= lottoNumber[i - 1]);
    expect(isSorted).toBeTruthy();
  });
});
import LottoBuyer from './LottoBuyer.js';

describe('로또 구입 기능 테스트', () => {
  // given
  const TEST_CASES = [
    {
      buyLottoPrice: 3000,
      expectedLottoCount: 3,
    },
    {
      buyLottoPrice: 5000,
      expectedLottoCount: 5,
    },
  ];

  test.each(TEST_CASES)(
    '구입금액이 $buyLottoPrice원 일 때, 로또 발행이 $expectedLottoCount번 되어야 한다.',
    ({ buyLottoPrice, expectedLottoCount }) => {
      const lottoBuyer = new LottoBuyer(buyLottoPrice);

      // when
      const lottoNumbers = lottoBuyer.purchase();

      // then
      expect(lottoNumbers.length).toBe(expectedLottoCount);
    },
  );
});

테스트 케이스를 확인해보면 로또 생성 테스트나, 로또 구입 테스트나 기능 구현 목록에 있는 내용과 거의 일치하는 것을 알 수 있다.

기능 구현 목록에 초점을 맞춰 테스트 코드를 작성하다보니 docs에 있는 문서와 테스트 코드를 함께 확인하여 각 기능에 대한 동작이 어떻게 이루어질지 쉽게 예측 가능하다고 생각했다. (문서화의 중요성)

import Lotto from '../Lotto/Lotto.js';

/**
 * @module LottoBuyer
 * 로또 구매 금액 만큼의 로또를 구매하는 역할을 수행하는 도메인 모듈
 */
class LottoBuyer {
  static LOTTO_PRICE_PER_UNIT = 1000;

  static BUY_LOTTO_PRICE_RANGE = Object.freeze({
    min: 1000,
    max: 10000,
  });

  #budget;

  /**
   * @param {number} buyLottoPrice - 로또 구입 금액
   */
  constructor(buyLottoPrice) {
    this.#budget = buyLottoPrice;
  }

  /**
   * @returns {import('../../types/jsDoc.js').LottoNumber[]} 구매 금액 만큼의 로또 번호열
   */
  purchase() {
    const lottoCount = this.#budget / LottoBuyer.LOTTO_PRICE_PER_UNIT;

    return Array.from({ length: lottoCount }, () => Lotto.create().createNumber());
  }
}

export default LottoBuyer;
import { sortByAscending } from '../../utils/array.js';
import Random from '../../utils/random.js';

class Lotto {
  static LOTTO_RULE = {
    min: 1,
    max: 45,
    count: 6,
  };

  static create() {
    return new Lotto();
  }

  /**
   * @returns {import('../../types/jsDoc.js').LottoNumber} 1 ~ 45의 값들이 6개 담긴 숫자 배열
   */
  createNumber() {
    const lottoNumber = Random.pickUniqueNumbersInRange({
      start: Lotto.LOTTO_RULE.min,
      end: Lotto.LOTTO_RULE.max,
      count: Lotto.LOTTO_RULE.count,
    });

    return sortByAscending(lottoNumber);
  }
}

export default Lotto;

기능 구현 목록에 초점을 맞춰 테스트 코드를 만들다보니 모듈을 만들 때 아래와 같은 장점이 있었다.

  1. 하나의 역할을 가지는 모듈을 만들 수 있었다.

  2. 모듈이 하나의 역할만 가지다 보니 하나의 메세지(public 메서드)에 대해 객체 간 상호작용하게 되어 복잡성이 완화될 수 있다.

  3. 또한, 작성된 코드들이 직관적이라고 생각되었다.

class vs object literal

controller에 대해 리뷰어님이 class로 만들어 주신 이유에 대해 여쭤보셨다.

사실 이전 부터 맹목적인 class 사용에 대해 회의감이 있었기 때문에 스스로 생각했던 것들을 정리해보았다.

정리한 내용들은 아래의 아티클에서 더 자세히 확인해 볼 수 있다.

⚠️ 예제 코드 없이 개념 위주의 설명이 이어집니다!

Class가 ES6에서 해결하고자 했던 것들

  1. 코드를 어지럽히는 .prototype에 대한 참조를 더 이상 하지 않는다.

  2. 특정 class는 더 이상 Object.create(..)를 사용하여 연결된 .prototype 객체를 대체하거나 .proto 또는 Object.setPrototypeOf(..)로 설정할 필요 없이 다른 class 들을 상속(extends) 할 수 있다.

  3. super(..)는 이제 매우 유용한 상대적 다형성 기능을 제공 하므로, 체인의 한 수준에 있는 메서드가 상대적으로 한 단계 위쪽의 같은 이름의 메서드를 참조할 수 있다.

  4. class 키워드는 프로퍼티(상태)를 명시적으로 지정하는 방법을 제공하지 않고 메서드만을 지정할 수 있다.

    • 이러한 특성이 일부에게는 제한적으로 보일 수 있지만, 사실상 클래스 구문은 프로그래머가 실수로 인해 발생할 수 있는 문제로부터 보호해주는 역할을 한다고 볼 수 있다.
  5. extends를 사용하면 Array나 RegExp와 같은 내장 객체 (서브)타입도 쉽게 상속 가능하다.

Class의 문제점(with Kyle Simson)

  1. 클래스 구문은 대부분 기존의 [Prototype] 메커니즘 위에 구문론적 설탕을 얹은 것에 불과하다.

    • 즉, 클래스는 전통적인 클래스 지향 언어에서와 같이 선언 시 정적으로 정의를 복사하지 않는다.
  2. prototype으로 접근하여 class의 method를 직접적으로 조작 가능하다.

  3. super의 작동 방식에는 미묘한 문제가 존재한다.

    • super는 선언 시점에 정적으로 바인딩 된다.
  4. ES6 클래스의 가장 큰 문제는 이러한 다양한 문제 때문에 클래스를 선언하면 (기존 클래스처럼) 클래스가 (향후 인스턴스화된) 사물에 대한 정적 정의라는 것을 암시하는 듯한 구문을 사용하게 된다는 점이다.

Consider using classes when

  1. 팀원 대부분이 모듈에 익숙하지 않은 경우

  2. 작성 중인 코드가 관용적인 JS 코드의 모범이 될 것으로 예상되지 않는 경우

  3. 클래스 패러다임에서 강력하게 적용되는 널리 알려진 패턴(예: 로컬 상태, 퍼블릭/프라이빗 등)을 활용하려는 경우

  4. 주어진 클래스를 여러 번 인스턴스화할 계획인 경우

  5. 항상 동일한 인수를 가진 클래스를 인스턴스화하지 않는 경우

  6. 클래스의 모든 또는 대부분의 기능(예: 상속, 종속성 주입 등)을 활용하는 경우

Consider avoiding classes when

  1. 주어진 런타임에 클래스를 한 번만 인스턴스화하는 경우

  2. 데이터 구조에 로컬 상태가 필요하지 않은 경우

  3. 최소한의 공용 메서드가 있는 경우

  4. 새로운 데이터 구조를 생성하기 위해 데이터 구조를 확장하지 않는 경우

  5. 생성자는 의존성 주입에만 사용되는 경우

  6. 생성자가 항상 동일한 인수를 사용하여 호출되는 경우

미션을 진행하면서 Consider avoiding classes when 내 1, 3으로 인해 클래스 대신 object literal을 고민했던거 같고, Consider using classes when의 2, 3, 6으로 인해 class를 사용하려고 했던거 같다.

특히, 3번 case가 object에 비해 캡슐화 적인 관점에서 코드가 더 깔끔해지고, 구조가 명확해진다는 점에서 최근에는 class를 많이 사용하게 되는거 같다.

이러한 내 생각을 리뷰어님에게 전달 드렸던거 같다.

console.log의 동작 방식

리뷰어님이 이러한 리뷰를 남겨 주셨었다.

// console.js
const Console = Object.freeze({
  print(message) {
    console.log(message)
  },
})

작성했던 printreadLineAsync와 동일한 layer에서 호출하기 위한 helper 메서드 성격으로 만들었었다.

리뷰어님이 남겨주신 리뷰의 목적을 추측해보았을 때 아마 console.log의 실제 동작 방식이 기존에 작성한 print와 차이점이 있을거라고 생각했다.

console standard 내 log 파트에서 console.log의 스펙을 확인할 수 있었다.

실제 console.log는 들어오는 인자들에 대해 spread 문법을 사용하여 배열 형태로 받고 있었다.

또한, logger의 스펙을 살펴보면 이전에 작성했던 로직과는 차이점이 있는 것을 알 수 있었다.

print(...args) {
  // 매개변수가 0이라면 early return
  if (args.length === 0) return;

  // first와 rest로 나눈다.
  const [first, ...rest] = args;

  // rest의 크기가 0이라면, first만 있는 것이므로 console.log에 first 넣어 호출한 후 마찬가지로 early return 한다.
  if (rest.length === 0) {
    console.log(first);
    return;
  }

  // 그게 아니라면 args 통째로 console.log에 호출한다.
  console.log(...args);
},
  
print('a', 'b', 'c') // 'a' 'b' 'c'
console.log('a', 'b', 'c') // 'a' 'b' 'c'

실제로 돌려보면 console.log와 동일한 결과인 것을 알 수 있다.

라이브러리를 사용할 때 구체적인 스펙을 꼭 확인해야 하는 점을 명심해야겠다고 다짐했다!

구체적인 테스트 케이스

// WinningNumberValidator.js
import WinningNumberValidator from './WinningNumberValidator.js';
import AppError from '../../errors/AppError/AppError.js';

describe('당첨 번호 유효성 검사', () => {
  // given
  const validateWinningNumber = (inputValue) => WinningNumberValidator.check(inputValue);

  const ERROR_CASES = [
    {
      input: 'a,b,c,d,e,f',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
    },
    {
      input: '1.2,3,4,5,6',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
    },
    {
      input: '1,2,3,4,5,-6',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
    },
    {
      input: '1,2',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidLength.errorMessage,
    },
    {
      input: '33',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidLength.errorMessage,
    },
    {
      input: '1,2,3,4,5,60',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isValidRange.errorMessage,
    },
    {
      input: '1,1,2,3,4,5',
      expectedErrorMessage: WinningNumberValidator.validationTypes.isUnique.errorMessage,
    },
  ];

  describe('예외 테스트', () => {
    test.each(ERROR_CASES)(
      '입력값 "$input"에 대해 "$expectedErrorMessage" 메세지와 함께 에러가 발생해야 한다.',
      ({ input, expectedErrorMessage }) => {
        // when - then
        expect(() => validateWinningNumber(input)).toThrow(new AppError(expectedErrorMessage));
      },
    );
  });

  describe('정상 작동 테스트', () => {
    // given
    const SUCCESS_CASES = [{ input: '1,2,3,4,5,6' }];

    test.each(SUCCESS_CASES)('입력값 "$input"에 대해 에러가 발생하지 않아야 한다.', ({ input }) => {
      // when - then
      expect(() => validateWinningNumber(input)).not.toThrow();
    });
  });
});

아마 각 validation type 마다 case가 다양한 탓에 더 구체화 시켜도 좋을 것 같다고 이해했다.

import WinningNumberValidator from './WinningNumberValidator.js';
import AppError from '../../errors/AppError/AppError.js';

describe('당첨 번호 유효성 검사', () => {
  // given
  const validateWinningNumber = (inputValue) => WinningNumberValidator.check(inputValue);

  describe('예외 테스트', () => {
    describe('당첨 번호 입력 형식이 다른 case', () => {
      // given
      const ERROR_CASES = [
        {
          input: 'a,b,c,d,e,f',
          errorCause: '숫자가 아닌 다른 값이 들어왔다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
        },
        {
          input: '1.2,3,4,5,6',
          errorCause: ',가 아닌 .가 포함되었다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
        },
        {
          input: '1,2,3,4,5,-6',
          errorCause: '양수의 값이 들어오지 않았다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidType.errorMessage,
        },
      ];

      test.each(ERROR_CASES)(
        '입력 값 "$input"에 대해 "$errorCause"는 이유로 "$expectedErrorMessage" 메세지와 함께 에러가 발생해야 한다.',
        ({ input, expectedErrorMessage }) => {
          // when - then
          expect(() => validateWinningNumber(input)).toThrow(new AppError(expectedErrorMessage));
        },
      );
    });

    describe('당첨 번호가 6개가 아닌 case', () => {
      // given
      const ERROR_CASES = [
        {
          input: '1,2',
          errorCause: '당첨 번호가 2개다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidLength.errorMessage,
        },
        {
          input: '33',
          errorCause: '당첨 번호가 1개다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidLength.errorMessage,
        },
      ];

      test.each(ERROR_CASES)(
        '입력 값 "$input"에 대해 "$errorCause"는 이유로 "$expectedErrorMessage" 메세지와 함께 에러가 발생해야 한다.',
        ({ input, expectedErrorMessage }) => {
          // when - then
          expect(() => validateWinningNumber(input)).toThrow(new AppError(expectedErrorMessage));
        },
      );
    });

    describe('당첨 번호의 범위를 벗어나는 case', () => {
      // given
      const ERROR_CASES = [
        {
          input: '1,2,3,4,5,60',
          errorCause: '60은 45보다 크다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidRange.errorMessage,
        },
        {
          input: '0,2,3,4,5,6',
          errorCause: '0은 1보다 작다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isValidRange.errorMessage,
        },
      ];

      test.each(ERROR_CASES)(
        '입력 값 "$input"에 대해 "$errorCause"는 이유로 "$expectedErrorMessage" 메세지와 함께 에러가 발생해야 한다.',
        ({ input, expectedErrorMessage }) => {
          // when - then
          expect(() => validateWinningNumber(input)).toThrow(new AppError(expectedErrorMessage));
        },
      );
    });

    describe('당첨 번호 내 중복된 값이 존재하는 case', () => {
      // given
      const ERROR_CASES = [
        {
          input: '1,1,2,3,4,5',
          errorCause: '1과 1이 중복된다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isUnique.errorMessage,
        },
        {
          input: '1,1,1,1,1,1',
          errorCause: '모든 값이 중복된다',
          expectedErrorMessage: WinningNumberValidator.validationTypes.isUnique.errorMessage,
        },
      ];

      test.each(ERROR_CASES)(
        '입력 값 "$input"에 대해 "$errorCause"는 이유로 "$expectedErrorMessage" 메세지와 함께 에러가 발생해야 한다.',
        ({ input, expectedErrorMessage }) => {
          // when - then
          expect(() => validateWinningNumber(input)).toThrow(new AppError(expectedErrorMessage));
        },
      );
    });
  });

  describe('정상 작동 테스트', () => {
    // given
    const SUCCESS_CASES = [{ input: '1,2,3,4,5,6' }];

    test.each(SUCCESS_CASES)('입력값 "$input"에 대해 에러가 발생하지 않아야 한다.', ({ input }) => {
      // when - then
      expect(() => validateWinningNumber(input)).not.toThrow();
    });
  });
});

그래서 각 validation type 마다 context(description)을 추가 후 에러 원인(error cause)를 추가적으로 작성하여 구체화 시켰다.

확실히 각 유효성 항목에 대해 입력 값, 에러 원인, 에러 메시지를 구체화 시키니 테스트 결과도 더 알아보기 쉬웠다.

끝으로

이제 터미널 기반 애플리케이션 만드는 것은 끝이 났다.

사실 프리 코스 이전, 프리 코스, 우아한테크코스 기간 동안 계속해서 만들어왔던 애플리케이션이었기 때문에 배울게 있을까? 라는 건방진 생각도 했었다.

하지만 리뷰어 님들의 꼼꼼한 리뷰 덕분에 가독성 측면에서 애플리케이션 설계 방식에 대해 더 고민해볼 수 있었던거 같다.

이제 step 2부터는 웹 애플리케이션을 만들게 될텐데 웹은 터미널과 달리 DOM을 사용하게 된다.

즉, side-effect가 이전보다 훨씬 많이 발생할텐데 이에 대한 대비를 어떻게 할지 고민해봐야 할 것 같다.

0개의 댓글