
이번 과제는 저번주처럼 후회하고 싶지 않기 때문에... 제공된 리팩토링 힌트 폴더 구조를 그대로 따르기로 했다.
refactoring(hint)
├── components - icon 및 ui
├── constants - 초기 데이터 상수
├── hooks - 상태 관리 (localStorage 연동)
├── models - 비즈니스 로직 (순수 함수)
├── utils - 공용 유틸리티
│ ├── validators.ts
│ ├── formatters.ts
│ └── hooks - 유틸 전용 훅
│ ├── useLocalStorage.ts
│ └── useDebounce.ts
├── App.tsx
└── main.tsx
제일 먼저는 상품 컴포넌트, 알림 모달 컴포넌트, 쿠폰 컴포넌트, svg 아이콘 등 작은 단위의 컴포넌트 분리를 진행했다. 이게 좋은 선택이었는진 모르겠지만 딱 작은 컴포넌트만 분리하고 그 이상은 진행하지 않았기에.. 이후에 걸림돌이 되지 않았던 것 같다.
컴포넌트 분리 후 models 폴더 내에 비즈니스 로직 (순수 함수) 구현을 했는데, 이때 models과 hooks의 차이가 뭔지 잘 이해되지 않았다. 결국 하는 역할은 똑같은거 아닌가? 하는 생각이 들었고.. 이 의문은 useLocalStorage 훅을 구현하고 적용한 이후에 해결되었다. model은 내가 연산 기호를 정의한 거라면 hook은 그 연산 기호를 사용하여 직접 상태 값을 업데이트 해주는 느낌..? 이 구현 순서를 의도하여 힌트를 주신 건가? 하는 생각도 들었다.
// src/basic/models/cart.ts
export const cartModel = {
/**
* 장바구니에 상품 추가
*/
addToCart: (cart: ICartItem[], product: IProductWithUI): ICartItem[] => {
// 이미 장바구니에 존재하는 상품 처리
const existingItem = cart.find((item) => item.product.id === product.id);
if (existingItem) {
const newQuantity = existingItem.quantity + 1;
// 재고 초과 시 기존 cart 반환
if (newQuantity > product.stock) {
return cart;
}
// 수량만 업데이트
return cart.map((item) =>
item.product.id === product.id
? { ...item, quantity: newQuantity }
: item
);
}
// 장바구니에 없는 상품이면 새 아이템 추가
return [...cart, { product, quantity: 1 }];
},
};
// src/basic/hooks/useCart.ts
export const useCart = () => {
// 로컬스토리지 연동된 cart
const [cart, setCart] = useLocalStorage<ICartItem[]>("cart", initialCarts);
/**
* 장바구니에 상품 추가
*/
const addToCart = (product: IProductWithUI) => {
setCart((prev) => cartModel.addToCart(prev, product));
};
return { addToCart };
};
장바구니에 상품을 추가하는 함수를 기준으로 설명하자면, models의 순수 함수는 장바구니 배열과 추가할 상품을 둘 다 인자로 받아 이미 장바구니에 존재하는 상품인지를 확인한 후 각 상황에 맞는 값을 반환한다. 그리고 hooks에서는 장바구니와 연동된 setCart 함수를 통해 cartModel의 순수 함수를 사용하여 cart 상태를 업데이트해주었다.
hooks 함수를 구현하면서 가장 고민했던 부분은 addNotification 함수 처리인 것 같다. 상태 관리만 담당하는 함수가 ui 관련 처리까지 담당해도 되는가에 대해 오래 고민했는데, 단일 책임 원칙에 따라 역할을 분리하는 것이 맞다고 판단하여 해당 함수가 필요한 컴포넌트 내에서 hooks 함수와 addNotification 함수를 같이 받아와 처리하도록 구현했다.
// 🌟 개선 예시
// models/cart.ts (pure)
export const cartModel = {
isCouponApplicable: (cartItems: CartItem[], coupon: Coupon) => {
const total = calculateCartTotalFromItems(cartItems); // pure fn
if (coupon.discountType === 'percentage' && total.totalAfterDiscount < ORDER.MIN_FOR_COUPON) {
return { ok: false, reason: 'MIN_PRICE' };
}
return { ok: true };
},
applyCoupon: (cartItems: CartItem[], coupon: Coupon) => {
if (!cartModel.isCouponApplicable(cartItems, coupon).ok) {
return { cartItems, selectedCoupon: null, error: 'NOT_APPLICABLE' };
}
// 실제 할인 계산은 calculateCartTotalFromItems가 적용하도록 selectedCoupon만 반환
return { cartItems, selectedCoupon: coupon, error: null };
}
};
// hooks/useCart.ts
export const useCart = () => {
const [cart, setCart] = useLocalStorage('cart', []);
const [selectedCoupon, setSelectedCoupon] = useState<ICoupon|null>(null);
const applyCoupon = (coupon: ICoupon, options?: { onSuccess?: ()=>void; onError?: (msg:string)=>void }) => {
const result = cartModel.applyCoupon(cart, coupon);
if (result.error) {
options?.onError?.(MESSAGES.COUPON.MIN_PRICE);
return false;
}
setSelectedCoupon(coupon);
options?.onSuccess?.();
return true;
};
return { cart, selectedCoupon, applyCoupon, setSelectedCoupon, ... };
};
// CouponSelector.tsx (TO-BE)
const { applyCoupon } = useCart();
const { addNotification } = useNotification();
const onSelect = (coupon) => {
applyCoupon(coupon, {
onSuccess: () => addNotification(MESSAGES.COUPON.APPLIED, 'success'),
onError: (msg) => addNotification(msg ?? MESSAGES.COUPON.MIN_PRICE, 'error')
});
};
그런데 코드 리뷰로 받은 개선 예시를 보고... 이런 방법이 있구나 싶었다.
나는 applyCoupon 함수 내에 setSelectedCoupont, addNotification 등의 함수 처리를 어떻게 해야할지 모르겠어서 useCart 훅 내부가 아닌 해당 함수를 필요로 하는 컴포넌트 내에 작성했다.
그런데 위의 예시를 보면 model에서 장바구니 금액이 쿠폰 최소 조건을 충족하는지 판단하고 -> hook에서 성공, 에러에 따라 콜백 함수를 호출하고 -> 컴포넌트에선 콜백 함수로 addNotification 함수를 보내 알림 ui 처리를 한다.
비즈니스 로직, 상태, UI를 분리해 테스트 용이성과 유연성을 높인.. 너무나도 깔끔한 구조이다. 왜 이럴 생각을 못했을까 🥲 (isCouponApplicable 함수까지는 고민해봤는데 그 이후에 진도가 나가지 않아 지웠버렸다.. ㅜㅜ)
// src/advanced/components/product/ProductForm.tsx
interface ProductFormProps {
// product
setShowProductForm: React.Dispatch<React.SetStateAction<boolean>>;
editingProduct: string | null;
setEditingProduct: React.Dispatch<React.SetStateAction<string | null>>;
productForm: IProductForm;
setProductForm: React.Dispatch<React.SetStateAction<IProductForm>>;
}
심화 과제는 기본 과제에 전역 상태 라이브러리를 적용하는 것이었는데 상품 생성 및 수정 폼 관련 props는 끝내 지우지 못했다.. 커스텀 훅으로의 추상화가 필요하다는 걸 인지하고 있었지만 과제 마무에 집중하면서 미루고 미루다 결국 리팩토링하지 못했다. 추후에는 이 부분을 폼 전용 커스텀 훅으로 분리하여 좀 더 읽기 쉽고 관리하기 쉬운 구조로 개선하고 싶다!!!
하라는 대로 하는 자세.. 👍
이렇게 구현해볼까? 하는 마음이 생기면 망설이지 말고 해보기
코드 리뷰로 알게된 내용들을 6주차 과제에 녹여보기..!!
휫자의 연금술사님 잘 봤읍니다