배포링크: https://nemobim.github.io/front_6th_chapter2-2/
과제의 핵심취지
- React의 hook 이해하기
- 함수형 프로그래밍에 대한 이해
- 액션과 순수함수의 분리
과제에서 꼭 알아가길 바라는 점
- 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup
- 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct()
- 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등
- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str)
기본과제
-
Component에서 비즈니스 로직을 분리하기
-
비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기
-
뷰데이터와 엔티티데이터의 분리에 대한 이해
-
entities -> features -> UI 계층에 대한 이해
-
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
-
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
-
계산함수는 순수함수로 작성이 되었나요?
-
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
-
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
-
계산함수는 순수함수로 작성이 되었나요?
-
특정 Entitiy만 다루는 함수는 분리되어 있나요?
-
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
-
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
-
재사용 가능한 Custom UI 컴포넌트를 만들어 보기
-
재사용 가능한 Custom 라이브러리 Hook을 만들어 보기
-
재사용 가능한 Custom 유틸 함수를 만들어 보기
-
그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기
-
UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
과제 셀프회고
최대한 ai 덜 쓰기를 도전했으나,,, 시간 부족으로 인해 이번에도 도움을 받았다. 지난 번에 사용했던 바닐라 전용 커서 클린코드 룰을 리액트로 수정하고 사용했다.
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
우선 기능은 그대로 유지한채, 점진적인 마이그레이션을 가장 신경썻습니다. 커밋을 상세하게 남겨서 어느 시점이든 돌아갈 수 있도록 했고 테스트를 진행하며 통과된 후에만 커밋을 진행했습니다.
내가 가장 집중했던 것: "책임을 나누자"
막막함을 해결하기 위해 일단 "하나씩 책임을 나누는 것" 부터 시작했습니다.
1. 거대한 단일 컴포넌트에서 책임 분리
- Before (Origin): 1,124줄의 거대한 단일 컴포넌트
const App = () => {
// 🚨 50개 이상의 상태가 한 곳에 집중
const [products, setProducts] = useState<ProductWithUI[]>(...);
const [cart, setCart] = useState<CartItem[]>(...);
const [coupons, setCoupons] = useState<Coupon[]>(...);
const [isAdmin, setIsAdmin] = useState(false);
const [showCouponForm, setShowCouponForm] = useState(false);
// ... 20개 이상의 상태
// 🚨 비즈니스 로직이 컴포넌트에 직접 구현
const calculateItemTotal = (item: CartItem): number => {
const discount = getMaxApplicableDiscount(item);
return Math.round(price * quantity * (1 - discount));
};
// 🚨 1000줄 이상의 JSX가 모두 인라인
return <div>{/* 모든 UI가 여기에 */}</div>;
};
- After (Basic): 역할별로 분리, basic 에서는 일단 의존성은 냅두고 꼬인 상태부터 정리했습니다.
const AppContent = () => {
// ✅ UI 상태만 관리
const [isAdmin, setIsAdmin] = useState(false);
// ✅ 엔티티별 Hook으로 분리
const { products } = useProduct();
const { cart, addToCart } = useCart({ products });
const { coupons } = useCoupon();
// ✅ 페이지 컴포넌트로 분리
return (
<div>
<Header />
{isAdmin ? <AdminPage /> : <CustomerPage />}
</div>
);
};
- After (Advanced): 더 높은 추상화, 상태 관리 로직까지 컴포넌트에서 분리하여 단순화
const AppContent = () => {
// ✅ 전역 상태는 atom으로
const [isAdmin] = useAtom(isAdminAtom);
return (
<div>
<Header />
{isAdmin ? <AdminPage /> : <CustomerPage />}
</div>
);
};
2. 비즈니스 로직을 순수함수로 분리
- Before (Origin): 컴포넌트 내부에 비즈니스 로직 포함 컴포넌트가 계산 로직을 직접 가지고 있어 재사용이 어렵고 코드가 길어짐
// 🚨 컴포넌트 안에서 계산
const getMaxApplicableDiscount = (item: CartItem): number => {
const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10);
if (hasBulkPurchase) {
return Math.min(baseDiscount + 0.05, 0.5);
}
return baseDiscount;
};
- After: 순수함수로 분리 입력이 같으면 항상 같은 출력을 보장할 수 있는 순수함수로 분리하고, 공통된 로직을 쓰는 곳에서 재사용
// utils/cartCalculations.ts
export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => {
const { price } = item.product;
const { quantity } = item;
const discount = calculateMaxApplicableDiscount(item, cart);
return Math.round(price * quantity * (1 - discount));
};
// 작은 단위의 순수함수들
const checkBulkPurchase = (cart: CartItem[]): boolean => {
return cart.some((cartItem) => cartItem.quantity >= BULK_PURCHASE_THRESHOLD);
};
3. 엔티티별 상태 관리 분리
- Before (Origin): 모든 상태 로직이 컴포넌트에 기존에는 장바구니와 관련된 로직이 컴포넌트 여러곳에 섞여있어 구조파악하기 힘듦
// 🚨 localStorage 로직도 컴포넌트에
const [cart, setCart] = useState<CartItem[]>(() => {
const saved = localStorage.getItem('cart');
try {
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
// 🚨 복잡한 비즈니스 로직도 컴포넌트에
const addToCart = useCallback((product: ProductWithUI) => {
const remainingStock = getRemainingStock(product);
if (remainingStock <= 0) {
addNotification('재고가 부족합니다!', 'error');
return;
}
// ... 50줄의 복잡한 로직
}, [cart, addNotification]);
- After (Basic): 엔티티별 Hook 분리 장바구니 관련 모든 로직이 한 곳에 응집되어 관리하기 쉬움
// hooks/useCart.ts - 장바구니만 담당
export const useCart = ({ products }: UseCartProps) => {
const [cart, setCart] = useState<CartItem[]>(
loadDataFromStorage<CartItem[]>("cart", [])
);
const addToCart = useCallback((product: ProductWithUI) => {
if (!validateStockAvailability(product, cart)) {
showToast("재고가 부족합니다!", "error");
return;
}
setCart((prevCart) => addItemToCart(prevCart, product));
}, [cart, showToast]);
return { cart, addToCart, removeFromCart, updateCartQuantity };
};
과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
아직 서로 의존하는 함수가 남아있는거같아서 그 부분을 개선하고싶습니다... 예를 들어
const formatCurrency = (price: number, isAdmin: boolean): string => {
const formattedPrice = price.toLocaleString();
return isAdmin ? `${formattedPrice}원` : `₩${formattedPrice}`;
};
export const formatPrice = (price: number, isAdmin: boolean, stockChecker?: IStockChecker): string => {
if (stockChecker && checkSoldOut(stockChecker)) {
return "SOLD OUT";
}
return formatCurrency(price, isAdmin);
};
가격 포맷팅 하나 때문에 isAdmin을 여러 컴포넌트에 props로 계속 내려주기 싫었는데,,, 적절한 대안을 찾지 못해서 일단 내려받는 걸로 작성했습니다.
고려했던 대안들:
- 함수 내부에서 isAdmin 직접 가져오기 (전역 상태 의존)
- admin 전용 함수 따로 만들기 (함수 분리)
- 현재처럼 매개변수로 받기 (props drilling)
그리고 추가적으로 jotai도 persist 옵션이 있었을텐데.. 시간이 없어서 적용하지 못했습니다.
// src/advanced/hooks/useCart.ts - localStorage 동기화가 Hook 내부에
useEffect(() => {
if (cart.length > 0) {
saveDataToStorage("cart", cart);
} else {
removeDataFromStorage("cart");
}
}, [cart]);
atomWithStorage를 사용했다면 localStorage 동기화 로직을 완전히 제거할 수 있었텐데..싶습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
Jotai 설계 패턴
Basic 단계에서 custom hook으로 상태 관리를 구현했고 Advanced 단계에서 Jotai를 도입했습니다. 기존 hook 로직을 재사용하고 싶어서 atom은 순수 상태만 관리하도록 작성했는데, 이 방식이 적절한지 궁금합니다... Jotai를 사용할 때 순수 상태만 atom으로 관리하고 로직은 별도 hook에서 처리 vs 모든 것을 atom으로 통합해 관리
- 현재 방식: 순수 Atom + 별도 Hook
// atoms/cartAtoms.ts - 상태만 관리
export const cartAtom = atom<CartItem[]>(initialCart);
// hooks/useCartActions.ts - 로직은 별도 hook에서
export const useCartActions = () => {
const [cart, setCart] = useAtom(cartAtom);
const { showToast } = useToast();
const addToCart = useCallback((product: ProductWithUI) => {
if (!validateStockAvailability(product, cart)) {
showToast("재고가 부족합니다!", "error");
return;
}
setCart((prevCart) => addItemToCart(prevCart, product));
}, [cart, setCart, showToast]);
return { addToCart, removeFromCart };
};
- Atom에 로직까지 포함
// atoms/cartAtoms.ts
export const cartAtom = atom<CartItem[]>(initialCart);
export const addToCartAtom = atom(
null,
(get, set, product: ProductWithUI) => {
const currentCart = get(cartAtom);
if (!validateStockAvailability(product, currentCart)) {
//대강 이런식으로,,?
return;
}
set(cartAtom, addItemToCart(currentCart, product));
}
);
컴포넌트 상태 전달 패턴
컴포넌트에서 상태를 어떻게 전달할지 고민됩니다. 여러 컴포넌트에서 같은 hook을 직접 사용하고 있을때 props로 처리 vs 컴포넌트에서 hook 사용하기 어떤 게 더 좋은 패턴일까요?,,,
- 같은 hook을 여러 컴포넌트에서 사용하는 것이 괜찮은지
- 언제는 props로 전달하고, 언제는 hook을 쓴다는 기준이 있는지 (뎁스가 깊을때 빼고)
const Header = () => {
const { cart } = useCart(); // 개수 표시용
return <div>장바구니 ({cart.length})</div>;
};
const ProductCard = () => {
const { addToCart } = useCart(); // 추가용
return <button onClick={() => addToCart(product)}>추가</button>;
};
과제 피드백
수고하셨습니다. 도은님!
Q. Basic 단계에서 custom hook으로 상태 관리를 구현했고 Advanced 단계에서 Jotai를 도입했습니다. 기존 hook 로직을 재사용하고 싶어서 atom은 순수 상태만 관리하도록 작성했는데, 이 방식이 적절한지 궁금합니다...
A. 저라면 도은님과 동일하게 구성했을 것 같습니다. 가급적 atom에 과한 로직을 두지 않고 훅에서 처리할 것 같아요. 오히려 atom에서 많은 로직을 처리하면 오히려 역할이 모호해지는 것 같아요. 최대한 순수한 상태로 유지되는 아톰과 validateStockAvailability과 같은 판단 함수 그리고 UI처리하는 훅을 조합하는 형태로 가는 것이 역할을 잘 분리하는 것이 아닐까 싶습니다.
Q. 컴포넌트에서 상태를 어떻게 전달할지 고민됩니다. 여러 컴포넌트에서 같은 hook을 직접 사용하고 있을때 props로 처리 vs 컴포넌트에서 hook 사용하기
A. 여러가지 이유가 있을 수 있지만 일단 뎊스가 깊지않고 훅을 통한 컴포넌트간 재사용이 필요하지 않다면 저는 기본적으로 프롭으로 전달할 것 같습니다.
훅을 고려하는 것은 해당 훅으로 분리(추상화) 해야할 작업(코드)의 양, 재사용성등을 고려할 것 같아요.
혹은 단순 데이터를 공유하는 용도는 스토어에서 데이터를 꺼낼때는 훅을 통해 레이어를 구성하자라고 팀에서 약속한 경우 그렇게 할 것 같아요.