[우아한 테크 코스 준비] - 점심 메뉴 추천

jiny·2023년 9월 24일
0
post-thumbnail

레포지토리 링크

https://github.com/jinyoung234/javascript-menu

👍 기능 요구 사항 설계

이름 입력 기능

  • 코치의 이름을 입력해 주세요. (, 로 구분)라는 메시지와 함께 메뉴 추천 받을 코치의 이름을 입력받을 수 있어야 한다.
  • 코치의 이름은 최소 2글자, 최대 4글자까지만 입력 가능해야 한다.
  • 코치는 최소 2명, 최대 5명 까지 생성할 수 있어야 한다.

못 먹는 메뉴 입력 기능

  • ‘코치 이름’ 가 못 먹는 메뉴를 입력해 주세요. 라는 메시지와 함께 코치 별로 못 먹는 메뉴를 입력할 수 있어야 한다.
  • 코치 마다 못 먹는 메뉴는 0 ~ 2개이며 먹지 못하는 메뉴에 대해 빈 값으로 입력이 가능하다.
  • 추천을 못하는 경우는 발생하지 않는다.

점심 메뉴 추천 기능

  • 코치 들은 점심 식사를 월, 화, 수, 목, 금요일에 같이 한다.
  • 메뉴 추천은 월요일에 추천할 카테고리(일식, 한식, 중식, 아시안, 양식)를 정한 후 각 코치가 월요일에 먹을 메뉴를 추천하며 이 과정을 금요일까지 반복한다.
  • 카테고리는 일식(1), 한식(2), 중식(3), 아시안(4), 양식(5)으로 고정되어야 한다.
  • 메뉴는 아래와 같은 형태로 구성되어야 한다.
일식: 규동, 우동, 미소시루, 스시, 가츠동, 오니기리, 하이라이스, 라멘, 오코노미야끼
한식: 김밥, 김치찌개, 쌈밥, 된장찌개, 비빔밥, 칼국수, 불고기, 떡볶이, 제육볶음
중식: 깐풍기, 볶음면, 동파육, 짜장면, 짬뽕, 마파두부, 탕수육, 토마토 달걀볶음, 고추잡채
아시안: 팟타이, 카오 팟, 나시고렝, 파인애플 볶음밥, 쌀국수, 똠얌꿍, 반미, 월남쌈, 분짜
양식: 라자냐, 그라탱, 뇨끼, 끼슈, 프렌치 토스트, 바게트, 스파게티, 피자, 파니니
  • 한 주에 같은 카테고리는 최대 2회까지 고를 수 있다.
  • 각 코치에게 한 주에 중복되지 않는 메뉴를 추천 해야 한다.
    < 예시 >
    • 구구: 비빔밥, 김치찌개, 쌈밥, 규동, 우동 → 한식을 3회 먹으므로 불가능
    • 토미: 비빔밥, 비빔밥, 규동, 우동, 볶음면 → 한 코치가 같은 메뉴를 먹으므로 불가능
    • 제임스: 비빔밥, 김치찌개, 스시, 가츠동, 짜장면 → 매일 다른 메뉴를 먹으므로 가능
    • 포코: 비빔밥, 김치찌개, 스시, 가츠동, 짜장면 → 제임스와 메뉴가 같지만, 포코는 매번 다른 메뉴를 먹으므로 가능
  • 메뉴 추천 시 “메뉴 추천 결과 입니다.”라는 메시지와 함께 추천된 메뉴가 코치 및 카테고리 별로 보여야한다.

프로그램 종료 기능

  • 메뉴 추천이 완료 되면 “추천을 완료했습니다.”와 함께 종료되어야 한다.

예외 처리

공통

  • 예외 상황 시 에러 문구를 출력해야 하며, 에러 문구는 "[ERROR]"로 시작해야 한다.

입력

  • 입력 값을 구분하는 것은 ,만 가능하다.
  • 사용자가 잘못된 값을 입력한 경우 throw문을 사용해 예외를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

🔥 도전한 것

서비스 레이어 분리

기존 아키텍처의 문제점

MVC 패턴으로 구현을 진행할 때면 크게 controller - model - view로 각각의 역할을 분리하게 된다.

기존 controller의 역할을 다음과 같이 정의하고 있었다.

  1. model에 유저 데이터를 넘겨주면 그에 맞는 필요한 데이터를 전달 받는다.
  2. 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)로 직접 접근하다 보니 다음과 같은 문제점이 있었다.

  1. 너무 많은 model 객체와 controller가 결합된다.
  2. 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의 가독성을 높이는 역할을 하고 있었다.

Service Layer

Controller와 Model 사이에서 중개자 역할을 하며, 여러 도메인 로직 또는 외부 서비스 호출을 조정하는 레이어

Service Layer가 주는 이점은 다음과 같다.

  1. 컨트롤러는 사용자 인터페이스와 관련된 로직에만 집중하고, Service Layer는 비즈니스 로직을 처리할 수 있다.
  2. 여러 컨트롤러에서 동일한 동작이 필요할 때 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에서 쉽게 파악이 가능한 걸 알 수 있다.

또한, 전체적으로 일반 함수를 호출하는 것 처럼 보이기 때문에 가독성도 개선된 것을 알 수 있다.

0개의 댓글