이번 4주차 미션은 코수타에서 준이 말씀해주셨던 것처럼 까다롭고 문제의 길이가 상당히 길었다.
그래서 과제를 본격적으로 시작하기 전에, README.md 에 기능 명세 작성에 투자를 많이 했다.
저번주차 미션에서 출력 형식에 관해 처리를 못해주었다는 아쉬움이 들어서 이번 과제에서는 더더욱 리드미를 꼼꼼히 읽어보았다.
(과제는 private 레포를 따로 만드는 방식으로 진행되었다.
현재는 과제가 공개 되어있지만, 훗날 다시 비공개 처리가 될수도 있음을 감안하여 링크는 따로 첨부하지 않는다.)
이전 미션들은 명확한 목적과 출력해야하는 값, 그리고 입력 받아야하는 값들이 이미 정해져있기 때문에 해당 과제의 목적에만 충실할 수 있었.
하지만 이번 과제에서는 많이 부족하지만 실제 내가 회사에서 업무를 하고 있다고 가정해보았다.
이를 통해 어플리케이션의 내용이 만약 확장되거나, 변경됐을시 어떻게 비용을 줄일수 있을 것인가? 같은 비즈니스 로직에 대해서 고민해 볼수 있었다.
등 많은 경우의 수를 생각해보며 만들어 볼 수 있었다.
이러한 경우의 수들을 생각해보며 만들다보니, 자연스럽게 추상화 작업이 필요했고 이 부분에 대해서 학습할 수 있었다.
가령 증정 선물 이벤트 같은 경우, 현재는 1개이지만 나중에 여러개가 될수도 있을 가능성을 대비하여 처음에는 1개의 값만 반환해주는 find 메서드를 사용했다가 후에 filter 메서드로 변경하였다.
이어서 무료 증정 선물의 가격을 나타내줄때도 reduce 메서드를 사용했다.
import ALL_MENU_DATA from '../menus/allMenu.js';
import deepFreeze from '../../utils/deepFreeze.js';
const FREE_GIFT_MENU = ALL_MENU_DATA.filter((item) => item.isFreeGift);
const TOTAL_FREE_GIFT_PRICE = FREE_GIFT_MENU.reduce((acc, gift) => acc + gift.price, 0);
const FREE_GIFT_CONFIG_DATA = deepFreeze({
menu: FREE_GIFT_MENU,
total_price: TOTAL_FREE_GIFT_PRICE,
price_condition: 120000,
});
export default FREE_GIFT_CONFIG_DATA;
또한 현재 과제에서는 12월과 크리스마스로 이벤트 기간이 고정 되어있지만, 만약 여름철에 새로운 프로모션을 기획 할수도 있다고 가정해보았다.
그럼으로써 서버에서 해당하는 이벤트 기간에 대한 데이터를 받아왔다고 가정하고, dateConfigData를 만들어서 이벤트 기간을 동적으로 결정할수 있도록 하였다.
import deepFreeze from '../../utils/deepFreeze.js';
/**
* 전체적인 연도, 월, 날짜, 요일을 관리하는 날짜 시스템 객체
* 이벤트 기간에 대한 수정이 필요하다면, 여기서 값만 바꿔주면 된다.
*/
const YEAR_STANDARD = new Date().getFullYear();
const MONTH_STANDARD = 11;
const MIN_DATE = 1;
const MAX_DATE = new Date(YEAR_STANDARD, MONTH_STANDARD + 1, 0).getDate();
const DAY_TO_NUMBER = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
const EVENT_TARGET_DATE = 25;
/**
* 방문 날짜에 해당 하는 요일 반환
* @param { number } nowDate
* @returns
*/
const VISIT_DAY = (nowDate) => new Date(YEAR_STANDARD, MONTH_STANDARD, nowDate).getDay();
const DATE_CONFIG_DATA = deepFreeze({
year_standard: YEAR_STANDARD,
month_standard: MONTH_STANDARD + 1,
min_date: MIN_DATE,
max_date: MAX_DATE,
day_to_number: DAY_TO_NUMBER,
event_target_date: EVENT_TARGET_DATE,
visit_day: (nowDate) => VISIT_DAY(nowDate),
});
export default DATE_CONFIG_DATA;
위의 말씀드린 재사용성과 확장성을 더 견고하게 구축하기 위해서, 이번 과제에서 필요한 정보 값들을 직접 서버 db를 통해 가져왔다고 상상하며 만들어보았다.
전체 메뉴에 대한 정보를 담고 있는 ALL_MENU 라는 객체 배열 파일을 만들어서 각각 음식의 이름, 카테고리, 가격, 증정 선물 인지 아닌지 같은 음식 데이터들에 대해서 데이터화 해서 정리했다.
만약 음식에 대한 정보가 변경되거나 새로운 음식이 추가, 기존 메뉴가 삭제 되어야 한다고 했을때, 최상단의 메뉴 데이터 파일만 조작하면 되도록 만들려 했다.
이벤트 뱃지나, 무료 증정 선물 같은 데이터도 변경사항이 있을시 최상단의 database 폴더에서 해당 내용만 변경하면 되도록 만들려 했다.
이벤트에 관한 내용들 또한 처음에는 각각의 객체로 만들어줘서 관리를 해주었는데, 할인률 계산 클래스에서 가독성이 안좋아지는 문제가 발생했다.
그래서 해당 데이터를 객체 배열 형식으로 만들어주고 해당 이벤트의 'slug' 프로퍼티를 추가하여 할인률을 계산할때도 동적으로 계산이 될수 있도록 노력해보았다.
import deepFreeze from '../../utils/deepFreeze.js';
/**
* 각각의 카테고리를 테이블로 생각하고 카테고리 별로 합쳐서 총 데이터로 취급
*/
const ALL_MENU_DATA = deepFreeze([
{
id: 1,
menu: '양송이수프',
category: 'appetizer',
price: 6000,
isFreeGift: false,
},
{
id: 2,
menu: '타파스',
category: 'appetizer',
price: 5500,
isFreeGift: false,
},
{
id: 3,
menu: '시저샐러드',
category: 'appetizer',
price: 8000,
isFreeGift: false,
},
//... 중략
]);
export default ALL_MENU_DATA;
const EVENT_CONFIG_DATA = deepFreeze([
{
id: 1,
title: '평일 할인',
slug: 'weekday',
sale_price: 2023,
sale_category: 'dessert',
},
{
id: 2,
title: '주말 할인',
slug: 'weekend',
sale_price: 2023,
sale_category: 'main',
},
{
id: 3,
title: '특별 할인',
slug: 'special',
sale_price: 1000,
},
{
id: 4,
title: '증정 이벤트',
slug: 'free_gift',
sale_price: FREE_GIFT_CONFIG_DATA.total_price,
},
{
id: 5,
title: '크리스마스 디데이 할인',
slug: 'target_event',
sale_basic_price: 1000,
sale_range: 100,
sale_start_day: 1,
sale_end_day: 25,
},
]);
import deepFreeze from '../../utils/deepFreeze.js';
const EVENT_BADGE_CONFIG_DATA = deepFreeze([
{ id: 1, badge: '산타', minPrice: 20000 },
{ id: 2, badge: '트리', minPrice: 10000 },
{ id: 3, badge: '별', minPrice: 5000 },
]);
export default EVENT_BADGE_CONFIG_DATA;
이번 과제에서 가장 문제가 되었던 부분은 혜택 내역 적용에 관한 부분이었다.
처음에는 주문 내역을 담당하는 총체적인 Order 클래스와, 할인 혜택을 적용시켜서 혜택 결과를 반환하는 DiscountMachine 클래스를 만들어서 구현을 했었는데, 각각의 클래스의 무게가 너무 무거워지는 문제가 발생했다.
이 경우에 변경사항이 있을시에 각각 코드의 의존도가 높아서 재사용성이 떨어지고, 코드가 서로 엉켜있어서 문제가 발생했을때 디버깅이 어려워지는 문제점이 발견 되었다.
문제 해결 방법을 찾는 도중, 서비스 레이어와 유틸리티 클래스라는 것을 알게 되었고 할인 혜택들의 취합 계산만 도와주는 DiscountMachineHelper 클래스와 주문내역 취합을 도와주는 OrderService, 그리고 이벤트 적용 유무 판단을 도와주는 EligibleChecker 클래스를 만들어서 클래스 분리를 해볼수 있었다.
아직 미숙하여 서비스 레이어와 유틸리티 클래스의 정확한 구분을 해내기는 어렵다.
하지만 "서비스 레이어는 비즈니스 상태를 저장하여 해당 로직의 핵심 기능을 담당하고, 유틸리티 클래스는 상태를 저장하지 않고 정적 메서드들로 이루어진 재사용이 가능한 클래스"라고 정리해보고 구분해보았다.
지금까지의 과제에서는 유효성 검증을 해당 도메인 클래스 내에서 한번에 처리를 해주었다.
하지만 이번 과제에서 주문내역 같은 경우, 검증해야할 부분이 상당히 많다보니 주문 입력에 대한 유효성 검증을 해주는 클래스를 따로 만들어주었다.
제가 생각한 주문 내역에 관한 유효성 검증은 총 2가지의 카테고리이다.
이렇게 총 2가지 종류로 분류하여 OrderFormat, OrderRules 유효성 검증 클래스를 만들어 주었다.
만약 각 속성마다 추가하고 싶은 검증 내용이 있다면 해당 폴더에서 원하는 유효성 검증 함수만 추가해주면 되도록 확장성을 염두해두었으며, 각각 독립적으로 작동하게 나누어서 변경사항이 있다면 해당 클래스에서 수정하면 되도록 노력해보았습니다.
class OrderValidator {
#orderFormatValidator = new OrderFormatValidator();
#orderRulesValidator = new OrderRulesValidator();
validate(orderList) {
this.#orderFormatValidator.validateInputFormat(orderList);
this.#orderRulesValidator.validateOrderRules(orderList);
}
}
export default OrderValidator;
증정 선물 이벤트와 이벤트 뱃지에 관한 검증 부분을 처음에는 각각 Service 클래스로 구현을 해주었.
하지만 증정 선물 이벤트나, 이벤트 뱃지는 어차피 이미 데이터가 결정 되어있는 상태여서, 만약에 이 이벤트가 존재한다면, 해당 데이터만 반환해주면 되는것 아닌가? 라는 생각에 객체로 다시 바꾸어주었다.
하지만 [증정 선물, 이벤트 뱃지]는 이미 조건에 따라서 반환 값이 정해져있다는 부분을 다시 생각해보며 "만약 이벤트 뱃지나, 증정 선물 같은 물품들이 더 존재하게 된다면?" 이라는 가정을 해보았다.
그래서 마지막에 EligibleChecker 라는 서비스 클래스를 만들어서, 해당 조건에 충족할때 해당 물건을 반환하도록 하도록 하였고 응집도와 통일성을 높이려고 노력했다.
이를 통해 무엇을 클래스로 만들어야하는지, 또 단순한 객체들이더라도 어떻게 클래스화 해볼수 있을지에 대해서 고심해볼 수 있는 기회였다.
이미 틀이 정해져 있고, 외부 값이 주입되어서 해당 틀에 맞도록 값이 반환 되어야만 하는 상황에서만 클래스를 사용했었다.
하지만 각각 반환하는 값이 다르더라도 큰 틀에서의 교집합 (예를 들어 조건 충족이, 이미 정해져 있는 값 반환)이 존재한다면 이걸 다시 묶어서 클래스화 해줄수 있구나를 배웠다.
class EligibilityChecker {
/**
* 총 금액에 따른 증정 선물 유무 판단 함수
* @param { number } totalPrice
* @returns { string | undefined }
*/
static isEligibleForFreeGift(totalPrice) {
return totalPrice > FREE_GIFT_CONFIG_DATA.price_condition && FREE_GIFT_CONFIG_DATA.menu;
}
/**
* 총 혜택 금액에 따른 이벤트 뱃지 반환 함수
* @param { number } totalDiscountPrice
* @returns { string | undefined}
*/
static isEligibleForEventBadge(totalDiscountPrice) {
const foundBadge = EVENT_BADGE_CONFIG_DATA.find(
(option) => totalDiscountPrice >= option.minPrice,
);
return foundBadge && foundBadge.badge;
}
}
export default EligibilityChecker;
위의 말씀드린 5번을 구현하는 과정에서 객체였을땐 eligibleChecker.js, 클래스였을땐 EligibleChecker.js 로 파일명을 변경했는데 이 과정에서 깃허브는 대소문자를 구별하지 않는다는 것을 알게 되었습니다.
결과적으로 깃허브에서 두 파일이 동시에 올라가게 되었고 이는 나중에 과제 제출시에 문제가 발생한다고 판단해서 지우고 다시 push를 하려했습니다.
하지만 로컬에선 해당 파일을 지웠지만 이미 깃허브에는 해당 파일이 업로드가 된 상태라서 push를 하려할때 conflict 에러가 계속 발생하였습니다.
처음 겪어보는 상황이라서 많이 당황했고, 깃 커밋 내역도 지저분해졌지만 이 기회로 깃의 merge와 rebase에 대해서 배워볼 수 있었습니다.
커밋 내역이 지저분해졌다.🥲
3주차 로또 미션을 구현할때, 구현 자체는 완성했지만 나중에 미션이 끝나고 보니 수익률 사이에 ","를 넣어주지 못했음을 깨달았다.
최대한 요구 사항을 꼼꼼히 살펴보았다고 생각했는데, 이러한 문제가 발생한 것을 반성하고 이번 4주차 미션에서는 한치의 오차도 없도록 더 꼼꼼히 요구 사항을 살펴보아야겠다고 다짐했다.
기능 구현을 하기 전에 최대한 리드미에 상세하게 기능 목록을 작성했으며, 문제 예시들을 드래그해보며 최대한 공백까지 일치시키도록 노력하였다.
또한 중간 중간 다시 요구사항을 살펴보며, 완료된 기능에 한해서는 ⭕️ 표시를 해보며 살아있는 README.md 파일을 만들려고 노력하였다.
이번 미션에서 jest의 coverage 기능을 사용하여 더욱더 꼼꼼한 테스트코드를 작성 해볼 수 있었습니다.
실제로 기능 구현이 끝났다고 생각했는데도, DiscountMachine에서 coverage 에서 3개의 함수에서 uncovered lines가 나왔다.
이 coverage 기능을 사용해서 더 꼼꼼한 테스트 코드를 작성해보고 기능 설계를 해볼 수 있었다.
클래스들이 하나씩 완성 될때마다 테스트 코드도 작성해서 단위 테스트를 작성했었는데, 이를 통해서 오류를 방지 할 수 있었다.
처음에 요구사항에 1만원 보다 주문 금액이 적으면 이벤트 혜택이 적용이 안된다는 부분을 아예 주문이 안되는것으로 오판했다.
이렇게 파악한 상태로 기능 구현을 하고, 테스트 코드를 작성해보다가 제가 요구 사항을 잘못 이해하고 있음을 깨달았다.
이런 오류를 방지함으로써, 작은 테스트코드부터 작성해보는것이 얼마나 본인이 작성하는 코드에 확신을 안겨주는 것인지 배울 수 있었다.
이번 과제에서는 많은 테스트 케이스들 검증하려했다.
원래 날짜와 주문 내역을 받아서, 테스트 결과 문자 배열을 반환해주는 createTestCase 라는 함수를 만들려고 했었다.
그리하여 그 함수 안에서 기존에 어플리케이션에서 사용했던 EligibilityChecker나 OrderService 같은 클래스들을 다시 불러와서 재사용하려 했다.
처음에는 "재사용성이 높아져서 좋다." 라는 판단을 했다.
하지만 곰곰히 생각해보니 테스트 코드는 설계의 오류를 검증해보고 그것을 판단할 수 있어야하는데, 만약 위의 클래스 내부 코드에서 오류가 있는 상태에서 그걸 그대로 import 해서 테스트를 해보면 제대로 된 오류를 검증할수 없지 않나? 라는 생각이 들었다.
그래서 결과값을 생성해주는 함수를 만들려고 했으나, 해당 함수도 결국엔 똑같은 어플리케이션 로직을 거쳐야한다는 판단을 해서 예상되는 결과 값들을 직접 하드코딩 해주어서 만들어주었다.