본 회고는 3주차 미션 수행을 하면서 제가 어떤 식으로 구현을 해내고 객체 설계 및 분리를 하려고 했는지
과정
을 중심으로 작성한 글입니다.
앞서 우테코 2주차 미션에서
TDD
를 해본 경험을 토대로 내가 생각하는 이상적인 TDD 개발방식을 정의했었다.
더 자세한 내용을 알고 싶다면 내가 작성한 우테코 프리코스 5기 2주차 회고를 읽어보자.
이 방식을 토대로 코드를 구현하기 전에 순서도 -> 기능 구현 목록을 작성 -> 객체 설계
를 직접 해보았습니다. 그런 다음 테스트 코드 -> 기능 구현 -> 테스트 -> 커밋
과정을 반복했습니다 👍
제가 이번 미션을 통해서 코드 구현, 리팩토링 과정을 통해 3번
의 기능 구현 목록 을 새로 작성하고 5번
의 객체 설계를 다시 하면서 굉장히 고생했습니다 😭😭
이 부분에 대해서 좀 더 자세히 알고 싶으시다면 이 링크를 클릭하시면 확인 가능합니다!!
목표: 도메인 로직에 따라 각 기능 별로 클래스를 분리를 하고 구현을 한 다음 각각의 클래스를 끼워맞추기만 하면 전체 로또 게임 기능이 완성되는 이상적인 개발방식을 하자.
위의 이상적인 개발을 하기 위해 객체 지향의 원리를 이해하고 좋은 객체 설계를 위한 5원칙인 SOLID에 대해 학습하였습니다.
뿐만 아니라 유지보수하기 좋은 객체 구조에 대해 계속 고민하고 학습하고 이번 미션에 적용하려고 노력했습니다.
DI(Dependency Injection)란?
우리는 앞서 객체를 연결할 때 상위 객체 안에 새로운 객체의 인스턴스를 생성하는 방식으로 구현을 하였습니다.하지만 DI의 경우 서로 다른 두 객체를 연결하기 위해 외부에서 객체를 생성해서 넣어주는 것을
의존성 주입
이라고 합니다.좀 더 자세한 설명과 예시를 알고 싶다면 제가 작성한 글인 JS로 알아보는 DI vs IOC vs DIP 에서 확인 가능합니다!!!
생성자 주입
을 통해 DI를 구현, 내가 만든 각 객체들의 결합도를 낮추고 종속성을 감소시키며 테스트를 용이하게 만들었습니다.
// 이때 this.#lotto, this.#bonus 모두 생성자이다.
#drawBonus() {
Console.readLine('보너스 번호를 입력해 주세요.\n', input => {
this.#bonus = new Bonus(input);
const lottoPayment = new LottoAdjustment({
draw: new LottoDrawFactory({ lotto: this.#lotto, bonus: this.#bonus }),
payment: this.#lottoStore,
});
lottoPayment.print();
});
}
각각의 클래스의 public 메서드 테스트 코드 작성 -> 클래스 구현 -> 테스트 -> 커밋
식의 TDD
과정을 반복하여 기능 구현을 해나갔습니다.
구현한 각각의 클래스를 App에서 생성자 주입을 통해 DI를 구현 해 통합 테스트까지 통과했습니다!!
첫번째 구현 완료 후 객체 구조도
예시를 통해서 DI의 단점을 한 번 살펴보자.
class Car() {
constructor() {
// ...
}
...
}
class Mom {
constructor(dish) {
this.dish = dish;
}
cook () {
console.log(`${this.dish.cook()}요리 완성`)!!
}
}
const mom = new Mom(new Car());
위의 예시는 Mom과 Car 객체과 생성자 주입
을 통해 DI
를 구현하였다.
우리 현실 세계에서는 차로 요리를 할 수도 없고, 위의 코드 내에서도 cook()
이라는 함수도 존재하지 않는다.
하지만 Mom객체는 아무것도 모르고 Car객체에게 요리를 하라고 명령을 하고 있다.
이처럼 Mom객체와 Car객체는 아무 이유없이 각자의 역할에 대해 서로 너무 믿고 있었고 의존적이었다고 볼 수 있다.
DI
의 단점을 이해하고 단순히생성자 주입
을 통한DI구현
을 하는 것이 아니라 리팩토링을 통해 상위 모듈과 하위 모듈의 의존성을 줄이기 위해 의존성을 분리하려고 노력했습니다.
- 의존성을 분리하기 위해 자주 쓰이는 다양한 디자인 패턴들을 학습하고 리팩토링 과정에서 적용하려고 노력했습니다.
LottoAdjustment클래스
와LottoStore클래스
사이의 의존성이 존재한다. 이를추상화 메서드
를 통해 의존성을 분리하려고 한다.
팩토리 메서드 패턴
객체 생성 처리를 서브 클래스로 분리해 처리하도록 캡슐화하는 패턴
저의 프로젝트 같은 경우 LottoStore
, Lotto
, Bonus클래스
를 통해서 입력값을 받고 해당 입력값에 대한 에러처리와 입력값을 가져옵니다.
최종 당첨 결과값을 도출하는 LottoAdjustment에게 입력값을 전달해줄 경우 굉장히 많은 값을 전달해야합니다.
(ex) 보너스 번호, 로또 번호, 구매한 로또 번호, 입력한 돈의 값 등등)
직접 인자값을 전달할 경우 유지보수적인 측면에서 굉장히 좋지 못하다고 판단, 팩토리 메서드 패턴
을 사용하여 원하는 객체의 인스턴스값을 자유롭게 받아오는 방식으로 구현하려고 노력하였습니다.
코드를 보시면 쉽게 이해갈 것입니다.
class LottoDrawFactory {
...
constructor({ lotto, bonus, lottoStore }) {
this.#lotto = lotto;
this.#bonus = bonus;
this.#lottoStore = lottoStore;
}
getInstance(type) {
switch (type) {
case VARIABLE_FACTORY.lotto:
return this.#lotto;
case VARIABLE_FACTORY.bonus:
return this.#bonus;
case VARIABLE_FACTORY.lottoStore:
return this.#lottoStore;
default:
throw new Error(VARIABLE_FACTORY.factoryTypeError);
}
}
...
}
리팩토링 후 객체 구조입니다.
기능 구현을 모두 마치고 요구사항을 다시 한 번 살펴보다가 UI로직과 핵심로직을 구분하라는 요구사항을 보고 급하게 객체 구조를 수정하였습니다.
전략 패턴(Strategy Pattern)
행위를 클래스로 캡슐화해 동적으로 행위를 자유롭게 바꿀 수 있게 해주는 패턴
객체 구조 그림 중 UI 하위 클래스인 LottoPaymentComponent
, LottoWinCountComponent
, LottoIncomeComponent
를 한 번 살펴보자.
각각의 클래스에는 화면에 보여주기 위한 print()함수
가 무조건적으로 존재해야하고 이를 제한하기 위해 Component라는 상위 클래스에 print()
함수를 오버라이딩 함수로 선언 하여 하위 클래스에게 print()함수
사용을 강제하였습니다.
코드
class Component {
#ERRORMESSAGE = '[ERROR] YOU SHOULD DECLARE OVERIDING';
print() {
throw new Error(this.#ERRORMESSAGE);
}
}
class LottoIncomeUI extends Component {
...
print() {
Console.print(this.#template());
}
...
}
class LottoPaymentUI extends Component {
...
print() {
const { lottos } = this.state;
Console.print(this.#template());
lottos.forEach(lotto => {
const result = JSON.stringify(lotto.sort((x, y) => x - y)).replace(
/,/g,
', ',
);
Console.print(result);
});
}
...
}
class LottoWinCountUI extends Component {
...
print() {
Console.print(this.#LOTTOSTATIC);
Console.print(this.#SLASH);
this.#template().forEach(setence => Console.print(setence));
}
...
}
[리팩토링 2]의 객체 설계도를 보면
LottoAdjustment 클래스
내부에 1)수익률을 계산하는 함수와 2)맞춘 로또 개수를 구하는 함수 두 가지가 존재한다.
이는 객체 단일 책임원칙에 위배된다고 판단하고 아래와 같이 객체 구조를 수정하였습니다.
(+ LottoAdjustment -> LottoCalculator로 클래스 명을 수정하였습니다.)
템플릿 메서드 패턴
어떤 작업을 처리하는 일부분을 서브 클래스로
캡슐화
해 전체 일을 수행하는 구조는 바꾸지 않으면서 특정 단계에서 수행하는 내역을 바꾸는 패턴
수익률을 계산하는 LottoIncome 클래스
, 로또를 맞춘 갯수의 수를 저장하는 LottoWinCount 클래스
모두 로또를 맞춘 갯수의 수를 저장한 데이터 자료구조를 필요로 한다.
그래서 이 로또를 맞춘 갯수의 수를 저장하는 배열 [0,0,0,0,0]
을 상위 클래스인 LottoCalculator 클래스
에서 저장하고 구매한 로또와 당첨 로또의 수를 비교하여 해당 배열에 값을 채운다.
하위 클래스인 LottoIncome 클래스
, LottoWinCount 클래스
는 로또를 맞춘 갯수의 수를 저장하는 배열의 값을 이용하여 원하는 결과값을 만들어 낸다.
코드를 보시면 아마 쉽게 이해하실 수 있을 것입니다.
코드
class LottoCalculator {
...
#ERROR_MESSAGE = 'OVERIDING_ERROR';
constructor(inputs) {
this.#lotto = inputs.getInstance(VARIABLE_FACTORY.lotto);
this.#bonus = inputs.getInstance(VARIABLE_FACTORY.bonus);
this.payment = inputs.getInstance(VARIABLE_FACTORY.lottoStore);
this.#scoreBoard = [0, 0, 0, 0, 0];
this.#validate().#compareLotto();
}
getResult() {
throw new Error(this.#ERROR_MESSAGE);
}
#compareLotto() {
this.payment
.getLottos()
.map(lottoToBuy => [
this.#matchLottoFor(lottoToBuy),
this.#matchBonusFor(lottoToBuy),
])
.forEach(([lottoCount, bonusCount]) => {
this.#setScoreToMatch([lottoCount, bonusCount]);
});
}
...
}
...
class LottoWinCount extends LottoCalculator {
constructor(inputs) {
super(inputs);
this.#rank = {
fifth: 0,
fourth: 0,
third: 0,
second: 0,
first: 0,
};
this.convertRankData();
}
getResult() {
return this.#rank;
}
...
}
...
class LottoIncome extends LottoCalculator {
constructor(inputs) {
super(inputs);
this.#income = '';
this.#calculateIncome();
}
getResult() {
return this.#income;
}
#calculateIncome() {
this.#income = this.#roundUpFor()
.#makeDecimalFirst()
.#checkDecimalPoint();
}
...
}
Bonus
, Lotto
, LottoStore
와 App 사이의 의존성도 없애면 더 좋았을 것 같다.