https://github.com/jinyoung234/javascript-menu
일식: 규동, 우동, 미소시루, 스시, 가츠동, 오니기리, 하이라이스, 라멘, 오코노미야끼
한식: 김밥, 김치찌개, 쌈밥, 된장찌개, 비빔밥, 칼국수, 불고기, 떡볶이, 제육볶음
중식: 깐풍기, 볶음면, 동파육, 짜장면, 짬뽕, 마파두부, 탕수육, 토마토 달걀볶음, 고추잡채
아시안: 팟타이, 카오 팟, 나시고렝, 파인애플 볶음밥, 쌀국수, 똠얌꿍, 반미, 월남쌈, 분짜
양식: 라자냐, 그라탱, 뇨끼, 끼슈, 프렌치 토스트, 바게트, 스파게티, 피자, 파니니
MVC 패턴으로 구현을 진행할 때면 크게 controller - model - view로 각각의 역할을 분리하게 된다.
기존 controller의 역할을 다음과 같이 정의하고 있었다.
- model에 유저 데이터를 넘겨주면 그에 맞는 필요한 데이터를 전달 받는다.
- model로 부터 받은 데이터를 통해 view로 데이터를 전달하여 화면을 렌더링한다.
Controller.js
class Controller {
#model;
constructor() {
this.#lotto = new Lotto();
}
static #convertUserDataToArray(data) {
return data.split(',');
}
getLottoNumbers() {
return Controller.convertUserDataToArray(this.#lotto.getLotto())
}
}
controller에서 model(Lotto)로 직접 접근하다 보니 다음과 같은 문제점이 있었다.
- 너무 많은 model 객체와 controller가 결합된다.
- model로 부터 받은 데이터를 controller에서 다시 가공하는 로직이 많아져 불필요한 static 메서드가 발생한다.
LottoGame.js
export default class LottoGame {
createWinningLottoNumbers(winningNumbers: string) {
return Lotto.fromByString(winningNumbers, SYMBOLS.COMMA).getLottoNumbers();
}
createLottoNumbers(amount: number) {
const lottos = LottoMerchant.from(amount).sellLotto();
return lottos.map((lotto) => lotto.getLottoNumbers());
}
createResults({ winningLottoNumber, bonusNumber, lottoNumbers, investmentAmount }: CreateResultsParams) {
return Bank.from(winningLottoNumber, bonusNumber).calculateResults(lottoNumbers, investmentAmount);
}
}
로또 미션 중 작성 했던 LottoGame 객체이며, LottoGame을 통해 controller - model 간 상호 작용을 한 클래스를 통해 관리하고자 했다.
이렇게 비즈니스 로직을 주고 받는 역할의 클래스를 설계함으로써, 결합도를 낮추고 불 필요한 메서드를 줄여 controller의 가독성을 높이는 역할을 하고 있었다.
Controller와 Model 사이에서 중개자 역할을 하며, 여러 도메인 로직 또는 외부 서비스 호출을 조정하는 레이어
Service Layer가 주는 이점은 다음과 같다.
- 컨트롤러는 사용자 인터페이스와 관련된 로직에만 집중하고, Service Layer는 비즈니스 로직을 처리할 수 있다.
- 여러 컨트롤러에서 동일한 동작이 필요할 때 Service Layer에서 해당 로직을 한 번만 구현하여 재사용이 가능하다.
LottoGame의 경우 model로써 설계했었기 때문에 이번 미션에서 제대로 service layer를 구현해보고 싶었다.
MenuGameService.js
const { Coach, MenuCategory } = require('../domain');
class MenuGameService {
#coaches;
#calendar;
constructor() {
this.#coaches = [];
this.#calendar = ['월요일', '화요일', '수요일', '목요일', '금요일'];
}
createCoaches({ coachNames, hateMenus }) {
coachNames.forEach((name, index) => {
const hateMenu = hateMenus[index];
this.#coaches = [...this.#coaches, Coach.from({ name, hateMenu })];
});
}
getMenuRecommendResult() {
const menuCategories = MenuCategory.create().getWeeklyMenuCategories(this.#calendar);
const recommendedMenus = this.#coaches.map((coach) => [
coach.getName(),
...menuCategories.map((menuCategory) => coach.receiveMenuRecommendation(menuCategory)),
]);
return {
menuCategories,
recommendedMenus,
calendar: this.#calendar,
};
}
}
module.exports = MenuGameService;
MenuGameService 내 존재하는 필드와 메서드의 역할은 아래와 같다.
- 필드
- coaches - 입력 값을 통해 생성된 코치들(Model)
- calendar - 월 ~ 금요일의 메뉴 추천을 위해 존재하는 데이터
- 메서드
- createCoaches - 유저로 부터 받은 코치들의 이름, 못 먹는 메뉴를 전달 받아 코치 들을 생성하는 메서드
- getMenuRecommendResult - 월 ~ 금요일의 메뉴 카테고리, 추천된 메뉴, 월 ~ 금요일 데이터를 반환하는 메서드
GameController.js
class App {
#inputView;
#outputView;
#menuGameService;
constructor() {
this.#inputView = InputView;
this.#outputView = OutputView;
this.#menuGameService = new MenuGameService();
}
#printGuidingMentions() {
this.#outputView.print(OUTPUT_MESSAGE_TEXT.LUNCH_MENU_SUGGESTIONS);
}
*#getCoachNames() {
while (true) {
try {
const inputData = yield (resolve) => this.#inputView.readCoachNames(resolve);
const coachNames = inputData.split(SYMBOLS.COMMA);
CoachValidator.validateCoachName(coachNames);
return coachNames;
} catch (error) {
this.#outputView.print(error.message);
}
}
}
*#getHateMenus(coachName) {
while (true) {
try {
const inputHateMenu = yield (resolve) => this.#inputView.readHateMenus(resolve, coachName);
const [hateMenus, menuTable] = [
inputHateMenu && inputHateMenu.split(SYMBOLS.COMMA),
MenuTable.create().getMenus(),
];
MenuValidator.validateHateMenu(hateMenus, menuTable);
return hateMenus;
} catch (error) {
this.#outputView.print(error.message);
}
}
}
*#getCoachHateMenus(coachNames) {
const coachHateMenus = [];
for (const coachName of coachNames) {
const hateMenus = yield* this.#getHateMenus(coachName);
coachHateMenus.push(hateMenus);
}
return coachHateMenus;
}
*#getCoachInfo() {
const coachNames = yield* this.#getCoachNames();
const hateMenus = yield* this.#getCoachHateMenus(coachNames);
return { coachNames, hateMenus };
}
*#addCoachInfo() {
const { coachNames, hateMenus } = yield* this.#getCoachInfo();
this.#menuGameService.createCoaches({ coachNames, hateMenus });
}
#getMenuRecommendResult() {
return this.#menuGameService.getMenuRecommendResult();
}
#printRecommendedMenuResult({ menuCategories, calendar, recommendedMenus }) {
this.#outputView.print(OUTPUT_MESSAGE_TEXT.RECOMMEND_MENUS_RESULT);
this.#outputView.print(
OUTPUT_MESSAGE_METHOD.RECOMMEND_MENUS_RESULT({ menuCategories, calendar, recommendedMenus }),
);
}
#exitGame() {
this.#outputView.print(OUTPUT_MESSAGE_TEXT.SUCCESS_RECOMMEND_MENUS);
Console.close();
}
*#processGame() {
this.#printGuidingMentions();
yield* this.#addCoachInfo();
this.#printRecommendedMenuResult({ ...this.#getMenuRecommendResult() });
yield this.#exitGame();
}
play() {
runGenerator(this.#processGame.bind(this));
}
}
const app = new App();
app.play();
module.exports = App;
controller는 아래와 같이 기존 설계 방향에 맞게 관심사가 명확하게 분리된 것을 확인할 수 있다.
- 유저에게 데이터를 입력 받는 로직(InputView - Controller)
- 유저 데이터를 전달 하여 데이터를 받는 로직 (Service(Model) - Controller)
- 전달 받은 데이터를 통해 화면에 그리는 로직(OutputView - Controller)
MenuGameService.js
const { Coach, MenuCategory } = require('../domain');
class MenuGameService {
// ...
getMenuRecommendResult() {
const menuCategories = new MenuCategory().getWeeklyMenuCategories(this.#calendar);
// ...
}
module.exports = MenuGameService;
특정 메서드를 사용할 때 new 연산자를 통해 생성자 함수를 호출하는 방식이 일반적인 함수 호출 하는 방식에 비해 가독성이 떨어진다고 생각했다.
또한, 생성자 호출 시 매개변수를 넣을 때 1개 넣을지, 2개 넣을지 헷갈리는 경우가 있었기 때문에 이를 명확히 하고 싶었다.
객체 생성의 역할을 하는 클래스 메서드
객체의 생성과 사용을 분리하기 위해 고안된 메서드 이다.
class Model {
// ...
static create() {
return new Model();
}
static of(a, b) {
return new Model(a, b);
}
static from(data) {
return new Model(data);
}
}
const model = Model.create();
정적 팩토리 메서드를 통해 생성 시 일반 함수 처럼 호출이 가능하며, 메서드 명에 따라 어떤 매개변수를 갖는지 명확히 파악할 수 있다.
또한, 만약 인스턴스를 생성해야 하는 방식이 더 필요하다면 메서드를 더 추가하면 되기 때문에 역할에 맞게 분리 할 수도 있다.
미션 수행 과정에서 정적 팩토리 메서드에 대한 컨벤션을 다음과 같이 나누었다.
- create - 매개변수 없이 인스턴스를 생성하는 경우
- of - 2개 이상의 매개 변수를 통해 인스턴스를 생성하는 경우
- from - 1개의 매개 변수를 통해 인스턴스를 생성하는 경우
javascript의 Array 메서드의 경우도 of는 여러 개의 매개변수를, from은 한 개의 매개변수를 필요로 하기 때문에 이를 참고하여 컨벤션을 적용했다.
MenuRecommender.js, Coach.js
class MenuRecommender {
// ...
static of(coach, menuCategory) {
return new MenuRecommender(coach, menuCategory);
}
}
class Coach {
// ...
receiveMenuRecommendation(menuCategory) {
const newRecommendedMenu = MenuRecommender.of(this, menuCategory).recommendMenu();
this.#recommendedMenus = [...this.#recommendedMenus, newRecommendedMenu];
return newRecommendedMenu;
}
}
MenuRecommender는 추천 받을 코치와 카테고리를 받아 메뉴를 추천하는 클래스이기 때문에 2개의 매개변수가 필요하다.
Coach에서 MenuRecommender를 사용하기 때문에 of 메서드를 통해 여러개의 매개변수가 필요하다는 것을 쉽게 파악할 수 있다.
CategoryNumberMaker.js, MenuCategory.js
class CategoryNumberMaker {
// ...
static from(calendar) {
return new CategoryNumberMaker(calendar);
}
}
class MenuCategory {
// ...
getWeeklyMenuCategories(calendar) {
return CategoryNumberMaker.from(calendar)
.createCategoryNumbers()
.map((categoryIndex) => this.#menuCategories[categoryIndex - 1]);
}
}
CategoryNumberMaker는 calendar의 요일 수 만큼 카테고리 번호를 생성하는 클래스로 한 개의 매개변수가 필요하여 from으로 선언했고, MenuCategory에서 쉽게 파악이 가능한 걸 알 수 있다.
또한, 전체적으로 일반 함수를 호출하는 것 처럼 보이기 때문에 가독성도 개선된 것을 알 수 있다.