2주차 미션의 공통 피드백을 읽어보며 README.md
를 다시 한번 살펴보게 되었고, 그 동안 미션 내 코드에만 집중한 탓에 README.md의 내용이 부실하다는 것을 인지하게 되었습니다.
이번 미션에서는 README.md
에서 해당 프로젝트가 어떠한 프로젝트인지 쉽게 파악할 수 있도록 한 줄 설명을 추가했으며, gif
를 통해 어떤 형태로 동작 하는지에 대해 시각화 하여 가독성을 높이고자 했습니다.
또한, 기능 요구 사항
을 7개의 section으로 분리 한 후 각 기능에 대한 예시를 함께 제공하여 각 기능이 어떤 기능을 하는 것인지 명확히 보일 수 있도록 신경 썼습니다.
적용 사항은 README.md 에서 확인하실 수 있습니다!
✨ 기능 구현 목록
- 로또 구매 금액 입력 기능
- 로또 구매 기능
- 구매한 로또 출력 기능
- 당첨 번호 입력 기능
- 보너스 번호 입력 기능
- 로또 당첨 확인 기능
- 게임 결과 출력 기능
이번 미션의 경우 잘못된 값을 입력 시 에러 메시지 출력 후 해당 부분부터 입력을 다시 받도록 하는 기능이 추가 되어 어려웠던 것 같습니다.
먼저, 기능 목록을 도출 한 다음, 미션 에서 제공하는 기능 요구 사항과 매칭 되는 기능을 찾아보며 기능 구현 목록을 최종적으로 구현하였습니다.
이번 3주차 미션의 경우에도 2주차 미션 목표 + 2가지 목표가 추가되었습니다.
- 클래스(객체)를 분리하는 연습
- 도메인 로직에 대한 단위 테스트를 작성하는 연습
저는 클래스(객체)를 효과적으로 분리 하기 위한 방법
, 도메인 로직에 대한 단위 테스트 작성을 효과적으로 하기 위한 방법
총 2가지의 카테고리를 설정했습니다.
그 후 세부적인 TO-DO
를 설정하기 위한 레퍼런스 자료 들을 찾아본 후 미션에 적용하기 위해 노력하였습니다.
클래스(객체)를 분리하는 연습
에 대해 이전 미션까지는 클래스
와 MVC 패턴
을 통해 각 레이어 당 역할을 부여하며 전체 아키텍처를 구성 했었지만 이번 미션 부터는 제공 받은 클래스를 제외한 모든 모듈을 함수
와 객체
를 통해 각 레이어의 역할에 따라 분리 함으로써 전체 아키텍처를 구성하고자 했습니다.
제가 함수형 사고를 지향 했던 이유는 아래와 같습니다.
/**
* @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)),
);
}
이런 연산을 하기 위해 인스턴스를 만들고 메서드 호출을 하기 보단, 순수 함수를 통해 객체를 넘겨주면 결과 값을 바로 받는 것이 더 직관적이고 간결한 표현을 만들 수 있다고 생각했습니다.
또한, 인스턴스를 생성하지 않기 때문에 성능적으로도 이점을 볼 수 있다고 생각했습니다.
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)
를 잘 활용하면 접근 제어자 처럼 필요한 것 들만 드러낼 수 있습니다.
객체 지향 프로그래밍
의 장점 중 하나인 응집도/결합도
및 역할-협력-책임
관리의 이점을 활용하고자 함수와 객체를 적절히 조합하여 클래스와 같이 표현하고자 하였습니다.
함수형 사고에 맞는 아키텍처를 찾던 중 어니언 아키텍처
에 대해 알게 되었으며, 이 아키텍처를 통해 도메인 내 비즈니스 로직을 다른 의존성으로부터 격리(직접 의존하는 것이 아닌, 인자로 부터 값을 받는 형태)시켜, 도메인 로직의 재 사용성과 테스트 용이성을 높이며, 변경에 유연하게 대응하고자 하였습니다.
자세한 설명은 어니언 아키텍처을 참고해주세요!
✨ 아키텍처 내 구성 요소
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를 읽어보며 단위 테스트를 하는 방법을 미션에 적용 했습니다.
코드 베이스 내 단위가 예상대로 작동 하는지 검증하는 단계의 테스트
이 때, 단위
는 가장 작은 코드 조각으로써 함수, 클래스, 메서드에 대해 개별적으로 테스트 하는 것을 의미합니다.
각 테스트 모듈
들은 아래와 같은 특징을 가집니다.
- 한 눈에 알아보기 쉬운 네이밍을 가진다.
- 코드 중복을 최소화 한다.
- 하나의 작업에 대해 테스트 한다.
- 외부 테스트 모듈에 영향 받지 않고 독립적이다.
- 모킹을 최소화 하며 순수한 데이터에 대해 테스트를 한다.
테스트 할 모듈
들은 작업 단위
로 구성 되어 있기 때문에, 기능 확장이나 코드를 리팩터링 할 경우 다시 단위 테스트를 실행하여 기존 기능을 손상시키지 않기 때문에 유지 보수를 원활히 할 수 있습니다.
또한, 테스트를 하는 중요한 목적 중 하나는 작성한 코드에 대해 피드백 받는 것이기 때문에 핵심 기능에 대해 피드백 받으며 빠르게 큰 기능을 만들어 갈 수 있습니다.
마지막으로 단위 테스트 중 하나라도 중단되면 근본 원인을 빠르게 격리하여 디버깅 작업을 줄일 수 있습니다.
소스 코드에 ESLint
를 적용하여 일관성을 보장하듯 테스트 코드에도 eslint-plugin-jest와 같은 라이브러리를 통해 테스트 코드 작성 과정에서의 실수를 줄일 수 있으며 일관성을 보장할 수 있습니다.
/* eslint-disable max-lines-per-function */
// ...
함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현하기 위해 ESLint
내 max-lines-per-function
규칙을 적용하였습니다.
이는, 테스트 코드 에도 영향을 받아 주석으로 린트 규칙을 지웠기 때문에 테스트 코드가 전체적으로 깔끔하지 못했었습니다.
"overrides": [
{
"files": ["*.test.js", "ApplicationTest.js", "LottoTest.js"],
"rules": {
"max-lines-per-function": ["off"]
}
}
],
.eslintrc.json
의 설정을 찾아보던 중 overrides
를 통해 주석을 제거함으로써 테스트 코드를 더 깔끔하게 만들 수 있었습니다.
또한, eslint-plugin-jest
를 작업 환경에 적용 시킴으로써, 테스트 suite의 불필요한 띄워쓰기, 오타 등의 문제를 잡아 일관성을 보장할 수 있었습니다.
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
에 적용해보았습니다.
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를 검증하도록 변경하였습니다.
그 결과 함수가 두 번 호출된다.
, 첫 번째 호출은 실패 후 에러 로깅이 발생한다.
, 두 번째 호출은 성공적인 결과를 반환한다.
로 테스트 항목을 분리할 수 있었습니다.