우아한 테크코스 6기 프리코스 - 3주차 로또 미션 회고

jiny·2023년 11월 8일
2
post-thumbnail

🎯 2주차 공통 피드백

2주차 미션의 공통 피드백을 읽어보며 README.md를 다시 한번 살펴보게 되었고, 그 동안 미션 내 코드에만 집중한 탓에 README.md의 내용이 부실하다는 것을 인지하게 되었습니다.

이번 미션에서는 README.md에서 해당 프로젝트가 어떠한 프로젝트인지 쉽게 파악할 수 있도록 한 줄 설명을 추가했으며, gif를 통해 어떤 형태로 동작 하는지에 대해 시각화 하여 가독성을 높이고자 했습니다.

또한, 기능 요구 사항7개의 section으로 분리 한 후 각 기능에 대한 예시를 함께 제공하여 각 기능이 어떤 기능을 하는 것인지 명확히 보일 수 있도록 신경 썼습니다.

적용 사항은 README.md 에서 확인하실 수 있습니다!

🎯 기능 구현 목록 작성

✨ 기능 구현 목록

  • 로또 구매 금액 입력 기능
  • 로또 구매 기능
  • 구매한 로또 출력 기능
  • 당첨 번호 입력 기능
  • 보너스 번호 입력 기능
  • 로또 당첨 확인 기능
  • 게임 결과 출력 기능

이번 미션의 경우 잘못된 값을 입력 시 에러 메시지 출력 후 해당 부분부터 입력을 다시 받도록 하는 기능이 추가 되어 어려웠던 것 같습니다.

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

🎯 미션 내 목표

이번 3주차 미션의 경우에도 2주차 미션 목표 + 2가지 목표가 추가되었습니다.

  1. 클래스(객체)를 분리하는 연습
  2. 도메인 로직에 대한 단위 테스트를 작성하는 연습

저는 클래스(객체)를 효과적으로 분리 하기 위한 방법, 도메인 로직에 대한 단위 테스트 작성을 효과적으로 하기 위한 방법 총 2가지의 카테고리를 설정했습니다.

그 후 세부적인 TO-DO설정하기 위한 레퍼런스 자료 들을 찾아본 후 미션에 적용하기 위해 노력하였습니다.

🔥 클래스(객체)를 효과적으로 분리하기 (with 함수형 프로그래밍)

클래스(객체)를 분리하는 연습에 대해 이전 미션까지는 클래스MVC 패턴을 통해 각 레이어 당 역할을 부여하며 전체 아키텍처를 구성 했었지만 이번 미션 부터는 제공 받은 클래스를 제외한 모든 모듈을 함수객체를 통해 각 레이어의 역할에 따라 분리 함으로써 전체 아키텍처를 구성하고자 했습니다.

제가 함수형 사고를 지향 했던 이유는 아래와 같습니다.

✔️ 기능에 대해 네이밍 부여하기

RacingGame.js

/**
 * @class RacingGame
 * @classdesc '자동차 경주 결과 생성'의 책임을 수행
 */
class RacingGame {
 // ...
  
 // 이동 횟수 만큼의 레이싱을 진행한 결과를 반환하는 메서드
 runRace() {
    // ...
 }
}

주로 클래스로 코드를 구성하다보니 이를 잘 활용하기 위한 방법으로 객체 지향적인 방법을 통해 코드를 구성하였습니다.

해당 기능의 책임을 이 네이밍을 가진 객체에 부여 하는 것이 맞을까?

하지만, 이러한 방법으로 코드를 구성하다보니 도메인 모델을 실제 사물과 계속 비교하며 위와 같은 고민 들을 하게 되었고 기능 적인 요소가 아닌 네이밍에 불 필요한 시간이 소요되는 것을 느꼈습니다.

따라서, 특정 기능을 실제 세계와 비교하는 것이 아닌, 기능 자체를 네이밍 하여 표현할 수 있는 함수형 사고가 더 적합하다고 생각했습니다.

✔️ 불 필요한 인스턴스 생성 줄이기

RacingGame.js

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

RacingCar.js

class RacingCar {
  static MOVE_THRESHOLD = 4;

  static MAX_CAR_NAME_LENGTH = 5;

  #racingCarInfo;

  constructor(racingCarInfo) {
    this.#racingCarInfo = { ...racingCarInfo };
  }

  static from(racingCarInfo) {
    return new RacingCar(racingCarInfo);
  }

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

지난 미션에서 RacingCar의 경우 name, position이 있는 객체를 받아 move 함수를 통해 조건이 성립되면 position을 증가 시키도록 설계 했었으며, RacingGame에서 각 레이싱(lap) 마다 RacingCar를 통해 position증가 시켰었습니다.

const MOVE_THRESHOLD = 4;
const MAX_CAR_NAME_LENGTH = 5;

const move = (racingCarInfo, randomNumber) => {
  return randomNumber >= MOVE_THRESHOLD
    ? { ...racingCarInfo, position: racingCarInfo.position + 1 }
    : racingCarInfo;
};

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

이런 연산을 하기 위해 인스턴스를 만들고 메서드 호출을 하기 보단, 순수 함수를 통해 객체를 넘겨주면 결과 값을 바로 받는 것이 더 직관적이고 간결한 표현을 만들 수 있다고 생각했습니다.

또한, 인스턴스를 생성하지 않기 때문에 성능적으로도 이점을 볼 수 있다고 생각했습니다.

✔️ 예측 가능한 설계 만들기

RacingGame.js

class RacingGame {
  #carNames;

  #moveCount;

  #racingStatus;

  constructor(carNames, moveCount) {
    this.#carNames = carNames;
    this.#moveCount = moveCount;
    this.#racingStatus = this.#initializeRacingStatus();
  }

  #initializeRacingStatus() {
    return this.#carNames.map((carName) => ({ carName, position: 0 }));
  }

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

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

클래스의 경우 내부 필드를 통해 스스로 값을 변경시킬 수 있습니다.

RacingGame의 경우에도 racingStatus를 내부 상태로 가져, updateRacingStatus 메서드가 값을 바로 반환하는 것이 아닌, racingStatus을 조작하고 있는 것을 알 수 있습니다.

자바스크립트에서 함수의 경우 일급 객체 이기 때문에 변수로 선언이 가능하고, 인자로 함수를 넘겨줄 수 있으며, 함수를 반환할 수 있습니다.

이러한 함수의 특성을 통해 순수 함수로 관리하면 항상 예측 가능한 값을 만들어 내어 좋은 가독성과 유지보수를 가질 수 있다고 생각했습니다.

✔️ 복잡도를 낮추기

class Example {
  	#value;
    
    static CONSTANTS = 1;

	constructor() {
    	// ...
      	this.#value = 1;
    }

	method() {
      	this.#privateMethod();
    	console.log(`example ${this.#value}`);
    }

	#privateMethod() {
		console.log('privateMethod');
	}
}

const example = new Example();
example.method() 
// privateMethod
// example 1

클래스필드, 생성자 함수, 접근 제어자, static추가해야 할 요소가 함수 및 객체에 비해 많은 편 입니다.

const privateMethod = () => console.log('privateMethod');

export const CONSTANTS = 1;

const value = 1;

const method = () => {
  privateMethod();
  console.log(`example ${value}`);
}

method()
// privateMethod
// example 1

하지만 함수클래스보다 직관적이고 보기 쉽습니다.

캡슐화의 경우에도 모듈 시스템(import/export)를 잘 활용하면 접근 제어자 처럼 필요한 것 들만 드러낼 수 있습니다.

✔️ MVC 패턴과 유사한 어니언 아키텍처 도입

객체 지향 프로그래밍의 장점 중 하나인 응집도/결합도역할-협력-책임 관리의 이점을 활용하고자 함수와 객체를 적절히 조합하여 클래스와 같이 표현하고자 하였습니다.

함수형 사고에 맞는 아키텍처를 찾던 중 어니언 아키텍처에 대해 알게 되었으며, 이 아키텍처를 통해 도메인 내 비즈니스 로직을 다른 의존성으로부터 격리(직접 의존하는 것이 아닌, 인자로 부터 값을 받는 형태)시켜, 도메인 로직의 재 사용성과 테스트 용이성을 높이며, 변경에 유연하게 대응하고자 하였습니다.

자세한 설명은 어니언 아키텍처을 참고해주세요!

아키텍처 내 구성 요소

  • I/O Layer(cli) - 게임의 입/출력을 담당
  • Error handling Layer(error) - 게임 내 예외 처리를 담당
  • Validation Layer(validations) - 게임 내 유효성 검사를 담당
  • Interaction Layer(interactions) - 위 3개 Layer, Domain Layer와 상호작용 하며 애플리케이션의 흐름 제어를 담당
  • Domain Layer(domain) - 게임 내 비즈니스 데이터 생성을 담당
  • Util Layer(utils) - 선언적인 자바스크립트 빌트인 메서드 생성을 담당

어니언 아키텍처에서는 각 레이어 마다 관심사의 분리가 적절히 이뤄지도록 위 구성 요소들로 책임을 나누어 표현했습니다.

이전 미션에서는 InputView입력 값 전달에 초점이 맞춰지다 보니 사용자에게 보여지는 모든 부분을 담당하는 View Layer에 적합한지에 대해 많은 의문이 있었습니다.

하지만, lottoGameConsole로또 게임에 대한 입/출력 담당역할로 정했기 때문에 이전 보다 더 명확해지는 것을 확인할 수 있었습니다.

I/O Layer & Error handling Layer & Validation Layer ➡️ Interaction Layer ➡️ Domain Layer ➡️ Util Layer

또한 의존성 방향이 위와 같이 되도록 설계하여 비즈니스 로직이 외부로 부터 격리될 수 있었고 결합도를 줄여 유지보수 하기 좋은 환경을 만들 수 있었습니다.

이번 미션의 코드는 로또 미션에서 확인하실 수 있습니다.

🔥 도메인 로직에 대한 단위 테스트를 효과적으로 하기

도메인 로직에 대한 단위 테스트를 작성하는 연습에 대해선 단순히 단위 테스트를 하는 것은 이전에 많이 해보았기 때문에 단위 테스트가 무엇인지, 좋은 단위 테스트를 하기 위한 방법에 대해 고민하였습니다.

그래서 mawrkus 님의 js-unit-testing-guide와 Alex Langdon님의 frontend-unit-testing-best-practices를 읽어보며 단위 테스트를 하는 방법을 미션에 적용 했습니다.

✔️ 단위 테스트란?

코드 베이스 내 단위가 예상대로 작동 하는지 검증하는 단계의 테스트

이 때, 단위가장 작은 코드 조각으로써 함수, 클래스, 메서드에 대해 개별적으로 테스트 하는 것을 의미합니다.

각 테스트 모듈 들은 아래와 같은 특징을 가집니다.

  1. 한 눈에 알아보기 쉬운 네이밍을 가진다.
  2. 코드 중복을 최소화 한다.
  3. 하나의 작업에 대해 테스트 한다.
  4. 외부 테스트 모듈에 영향 받지 않고 독립적이다.
  5. 모킹을 최소화 하며 순수한 데이터에 대해 테스트를 한다.

테스트 할 모듈 들은 작업 단위로 구성 되어 있기 때문에, 기능 확장이나 코드를 리팩터링 할 경우 다시 단위 테스트를 실행하여 기존 기능을 손상시키지 않기 때문에 유지 보수를 원활히 할 수 있습니다.

또한, 테스트를 하는 중요한 목적 중 하나는 작성한 코드에 대해 피드백 받는 것이기 때문핵심 기능에 대해 피드백 받으며 빠르게 큰 기능을 만들어 갈 수 있습니다.

마지막으로 단위 테스트 중 하나라도 중단되면 근본 원인을 빠르게 격리하여 디버깅 작업을 줄일 수 있습니다.

✔️ 테스트 내 린트 적용하기

소스 코드에 ESLint를 적용하여 일관성을 보장하듯 테스트 코드에도 eslint-plugin-jest와 같은 라이브러리를 통해 테스트 코드 작성 과정에서의 실수를 줄일 수 있으며 일관성을 보장할 수 있습니다.

/* eslint-disable max-lines-per-function */
// ...

함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현하기 위해 ESLintmax-lines-per-function 규칙을 적용하였습니다.

이는, 테스트 코드 에도 영향을 받아 주석으로 린트 규칙을 지웠기 때문에 테스트 코드가 전체적으로 깔끔하지 못했었습니다.

.eslintrc.json

"overrides": [
  {
    "files": ["*.test.js", "ApplicationTest.js", "LottoTest.js"],
    "rules": {
      "max-lines-per-function": ["off"]
    }
  }
],

.eslintrc.json의 설정을 찾아보던 중 overrides를 통해 주석을 제거함으로써 테스트 코드를 더 깔끔하게 만들 수 있었습니다.

또한, eslint-plugin-jest를 작업 환경에 적용 시킴으로써, 테스트 suite의 불필요한 띄워쓰기, 오타 등의 문제를 잡아 일관성을 보장할 수 있었습니다.

✔️ describe 블록에서 관련 테스트를 그룹화하기

lottoGameConsole.test.js

import lottoGameConsole from './lottoGameConsole.module';

describe('lottoGameConsole 테스트', () => {
  test.each([{ input: 1, output: '\n1개를 구매했습니다.' }])(
    '로또 $input개 구매 시, 메시지는 "$output" 이어야 한다.',
    ({ input, output }) => {
      // given - when - then
      expect(lottoGameConsole.output.messages.purchasedLottoNumbers(input)).toMatch(output);
    },
  );

  test.each([
    { input: [[1, 2, 3, 4, 5, 6]], output: '[1, 2, 3, 4, 5, 6]' },
    {
      input: [
        [7, 8, 9, 10, 11, 12],
        [13, 14, 15, 16, 17, 18],
      ],
      output: '[7, 8, 9, 10, 11, 12]\n[13, 14, 15, 16, 17, 18]',
    },
  ])('로또 번호 배열의 출력 메시지는 "$output" 이어야 한다.', ({ input, output }) => {
    // given - when - then
    expect(lottoGameConsole.output.messages.lottoNumbers(input)).toMatch(output);
  });

  test.each([
    {
      input: { '1st': 1, '2nd': 1, '3rd': 1, '4th': 1, '5th': 1 },
      output:
        '3개 일치 (5,000원) - 1개\n4개 일치 (50,000원) - 1개\n5개 일치 (1,500,000원) - 1개\n5개 일치, 보너스 볼 일치 (30,000,000원) - 1개\n6개 일치 (2,000,000,000원) - 1개',
    },
    {
      input: { '1st': 1, '3rd': 1 },
      output:
        '3개 일치 (5,000원) - 0개\n4개 일치 (50,000원) - 0개\n5개 일치 (1,500,000원) - 1개\n5개 일치, 보너스 볼 일치 (30,000,000원) - 0개\n6개 일치 (2,000,000,000원) - 1개',
    },
    {
      input: null,
      output:
        '3개 일치 (5,000원) - 0개\n4개 일치 (50,000원) - 0개\n5개 일치 (1,500,000원) - 0개\n5개 일치, 보너스 볼 일치 (30,000,000원) - 0개\n6개 일치 (2,000,000,000원) - 0개',
    },
  ])('rankDistributionTable가 $input일 때 포맷팅 된 메시지는 $output이다.', ({ input, output }) => {
    // given - when - then
    expect(lottoGameConsole.output.messages.rankDistributionTable(input)).toMatch(output);
  });

  test.each([{ input: '1000', output: '총 수익률은 1000%입니다.' }])(
    '수익률이 $input일 때, 포맷팅 된 메시지는 $output이다.',
    ({ input, output }) => {
      // given - when - then
      expect(lottoGameConsole.output.messages.rateOfReturn(input)).toMatch(output);
    },
  );
});

업로드중..

한 테스트 모듈에서 수 많은 test 메서드가 몰려 있다면, 테스트 항목은 많지만 어떤 case에 대해 테스트를 하는지 알기 어렵습니다.

위 사진의 lottoGameConsole 테스트 에서도 모듈 내 message format 관련 메서드가 4개 이기 때문에, test 메서드로 모두 나열해 버리면 어떤 항목을 테스트 하는지 보기 어려운 것을 알 수 있습니다.

업로드중..

하지만, 이를 describe 블록으로 다시 한번 묶음 으로써, 관련 테스트를 그룹으로 분리하여 테스트 파일을 구성하는 것이 가능합니다.

위 사진과 같이 메서드 단위로 나누거나, 작업 단위로 describe로 나누게 되면 어떤 항목을 테스트 하는 것인지 더 직관적으로 확인이 가능합니다.

또한, 내부 describe 블록외부 describe 블록에서 설정 로직을 확장할 수 있기 때문에 추후 테스트 코드의 확장성에도 용이합니다.

✔️ 단위 테스트는 실패할 이유가 하나만 존재하기

단위 테스트이름 그대로 코드의 단일 단위만 테스트 하기 때문에, 단일 책임 원칙을 통해 하나의 실패 이유를 가지면 테스트 실패의 원인을 식별하는데 소요되는 시간을 줄일 수 있습니다.

또한, 테스트 내용이 짧아져 이해하기 더 쉬워집니다.

이 원칙을 저는 systemErrorHandler에 적용해보았습니다.

systemErrorHandler.test.js

import { Console } from '@woowacourse/mission-utils';
import systemErrorHandler from '../../src/error/handlers/systemErrorHandler';

jest.mock('@woowacourse/mission-utils', () => ({
  Console: {
    print: jest.fn(),
  },
}));

describe('입력 관련 예외 처리 테스트', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('retryOnErrors 테스트', () => {
    test('첫 번째 실행은 에러가 발생하여 다시 함수가 호출되며, 두 번째 실행은 정상적으로 실행 후 값을 반환한다.', async () => {
      // given
      const executeTest = jest.fn();
      executeTest.mockRejectedValueOnce(new Error('Test Error')).mockResolvedValueOnce('Success');

      // when
      const result = await systemErrorHandler.retryOnErrors(executeTest);

      // then
      expect(executeTest).toHaveBeenCalledTimes(2);
      expect(Console.print).toHaveBeenCalledWith('Test Error');
      expect(result).toMatch('Success');
    });
  });
});

이전에는 첫 번째 실행은 에러가 발생 후 다시 함수가 호출되어 두 번째 실행에선 정상적으로 실행 후 값을 반환한다. 라는 긴 테스트 항목으로 expect를 3개나 사용하여 검증을 하였습니다.

// ...

describe('입력 관련 예외 처리 테스트', () => {
  let executeTest;

  beforeEach(() => {
    jest.clearAllMocks();
    // given
    executeTest = jest
      .fn()
      .mockRejectedValueOnce(new Error('Test Error'))
      .mockResolvedValueOnce('Success');
  });

  test('함수가 두 번 호출된다.', async () => {
    // when
    await systemErrorHandler.retryOnErrors(executeTest);
    // then
    expect(executeTest).toHaveBeenCalledTimes(2);
  });

  test('첫 번째 호출은 실패 후 에러 로깅이 발생한다.', async () => {
    // when
    await systemErrorHandler.retryOnErrors(executeTest);
    // then
    expect(Console.print).toHaveBeenCalledWith('Test Error');
  });

  test('두 번째 호출은 성공적인 결과를 반환한다.', async () => {
    // when
    const result = await systemErrorHandler.retryOnErrors(executeTest);
    // then
    expect(result).toMatch('Success');
  });
});

그래서 각 expect를 나누고자 하였고 이를 위해 beforeEach 함수 에서 매 executeTest의 모킹을 초기화 시킨 다음 각 expect를 검증하도록 변경하였습니다.

그 결과 함수가 두 번 호출된다., 첫 번째 호출은 실패 후 에러 로깅이 발생한다., 두 번째 호출은 성공적인 결과를 반환한다.테스트 항목을 분리할 수 있었습니다.

레퍼런스

0개의 댓글