과제의 핵심취지
- 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과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
- 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
배포 주소
https://tomatopickles404.github.io/front_6th_chapter2-2/index.advanced.html
과제 셀프회고
이번 과제의 리팩토링은 지난주보다 볼륨이 크지 않아서 수월할 것이라고 생각했습니다. 그리고 지난주차의 발제를 들으면서 제가 첫 방향을 잘 못 잡고 풀어 나갔다는 것을 깨닫게 되었습니다. 이 연습을 하는 본질적인 이유는 "요구사항을 어떻게 하면 유지보수하기 쉽게 생산할까" 라고 생각하는데, 저는 "어떻게 하면 goal을 전략적으로 수행할까" 에만 너무 포커싱을 둔 채로 과제에 임했다는 것을 회고했습니다. QnA에서 테오의 조언을 듣고 나는 어떤 포지션에서 이 과제를 대해야 할까를 생각 했을 때, AI를 잘 활용하는 방법을 공부하고 적용해보는 단계라기 보다는 직접 리팩토링 해보면서 소화 해보는 시간이 더 필요하다고 생각했습니다. 그래서 이번 과제는 ai 디톡스로 진행했습니다.
1차 시도: 컴포넌트 먼저 분리하기
처음에는 가장 자신 있는 컴포넌트 분리부터 시작했습니다. 그러나 props를 먼저 만들어버리는 구조가 되어버려 코드베이스 파악이 더 어려워졌습니다. 그 기반에서 순수함수를 분리하려고 하니까 난이도가 더 체감되었습니다.
2차 시도: 순수함수 먼저 분리하기
App 컴포넌트에서 페이지와 한 두개 정도의 자식 컴포넌트를 분리한 정도 진행했을 때도 이미 props가 너무 많이 생성되었습니다. 이 기반으로 순수함수 분리를 시도했으나, 함수를 분리할수록 리팩토링할 코드만 생성하고 있다는 느낌을 받았습니다. 고민 끝에 만들었던 것들을 리셋하고 리드미 순서대로 진행하기로 결정했습니다. 리드미를 다시 읽어보니 그 순서가 가장 점진적으로 개선하는 방법이라고 생각되었습니다. 순수함수를 먼저 만들고 분리를 해놓아야 App컴포넌트에서 자유로워지는 코드들이 생기고, 코드가 분리가 된 상태에서 컴포넌트에 스페이싱을 두면 파악이 더 수월할거라고 예측했습니다.
또한, 컴포넌트 분리보다 순수함수 분리하는 감각과 훅을 추상화 하는 것이 더 중요한 감각이라고 생각해 그 부분을 우선순위를 두는게 적절하다고 판단했습니다. 컴포넌트 추상화는 훅만 잘 분리되어 있다면 어렵지 않을 것이라고 생각했고, 그 정도는 AI에게 위임해도 괜찮은 작업이라고 생각했습니다.
커스텀 훅 만들기
예상대로 순수함수부터 분리한 뒤에는 상태와 관련된 로직들이 눈에 잘 보이기 시작했습니다.
엔티티별 훅 분리 전략
분리 우선순위 결정
- useCart: 가장 복잡하고 다른 엔티티와 상호작용이 많음
- useCoupon: 독립적이면서 상대적으로 단순함 (작은 단위부터)
- useProduct: 상품 관리 로직
점진적 분리 과정
// 1단계: useCoupon 분리 (가장 작은 단위)
const useCoupon = () => {
const [coupons, setCoupons] = useLocalStorage('coupons', []);
const [selectedCoupon, setSelectedCoupon] = useState(null);
// ... 쿠폰 관련 로직만
};
// 2단계: useCartItems 분리
const useCartItems = () => {
const [cart, setCart] = useLocalStorage('cart', []);
// ... 장바구니 아이템 관리만
};
// 3단계: useCart 합성 (Facade 패턴)
const useCart = () => {
const couponHook = useCoupon();
const cartItemsHook = useCartItems();
// ... 조합하여 제공
};
의존성 문제와 해결
[문제 1]
// 잘못된 의존성
export function useProduct(addNotification, cart: CartItem[]) {
const getRemainingStock = (product: Product) => {
return product.stock - (cart.find(item => ...)?.quantity ?? 0);
};
}
- useProduct의 훅 내부 함수중 cart 의존성이 존재했습니다.
[고민 과정]
- useProduct가 cart에 의존하는 것이 옳은가?
- Product 도메인이 Cart 도메인을 알아야 하는가?
- 의존성 방향이 잘못되었나?
해결책: 의존성 역전
// 의존성 제거
export function useProduct(addNotification) {
// Product CRUD만 담당
}
// App.tsx에서 cart 주입
const getRemainingStock = (product: Product): number => {
return product.stock - (cart.find(item => ...)?.quantity ?? 0);
};
[문제 2]
// 관심사 혼재
const validateUpdateQuantity = ({ product, newQuantity, addNotification }) => {
if (newQuantity > product.stock) {
addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); // UI 로직
return false;
}
return true;
};
- 훅 내부에 UI 로직이 존재했습니다.
[고민 과정]
- 비즈니스 로직과 UI 로직이 함께 있는 것이 맞는가?
- 훅에서 notification을 직접 호출하는 것이 적절한가?
- 관심사 분리가 제대로 되었는가?
해결책: 관심사 분리
// 비즈니스 로직만 반환
const validateUpdateQuantity = ({ product, newQuantity }) => {
if (newQuantity > product.stock) {
return { isValid: false, message: `재고는 ${product.stock}개까지만 있습니다.` };
}
return { isValid: true };
};
// App.tsx에서 UI 로직 처리
const result = validateUpdateQuantity(params);
if (!result.isValid && result.message) {
addNotification(result.message, 'error');
}
파생 상태 관리
[문제 3]
// 파생 상태를 별도 상태로 관리
const [totalItemCount, setTotalItemCount] = useState(0);
useEffect(() => {
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
setTotalItemCount(count);
}, [cart]);
- 기본 코드 베이스에서 불필요한 상태 관리를 하고 있었습니다.
[고민 과정]
- cart에서 바로 계산할 수 있는 값을 왜 별도 상태로 관리하는가?
- useEffect를 통한 동기화가 정말 필요한가?
- 성능과 메모리 낭비는 없는가?
해결책: 상수 값으로 변경
const totalItemCount = () => {
return cart.reduce((sum, item) => sum + item.quantity, 0);
}
최종 아키텍처(basic)
src/
├── basic/
│ ├── components/
│ │ ├── common/
│ │ ├── cart/
│ │ ├── coupon/
│ │ └── product/
│ ├── hooks/
│ │ ├── admin/
│ │ ├── cart/
│ │ ├── coupon/
│ │ ├── notification/
│ │ ├── product/
│ │ └── search/
│ ├── pages/
│ └── utils/
├── shared/
│ ├── components/
│ ├── hooks/
│ └── utils/
└── types.ts
규모가 크지 않아 역할 기반 레이어(components/hooks/pages/utils)를 유지하되, 그 안에서만 도메인(cart/coupon/product/notification)으로 나눴습니다. 즉, 과도한 구조 전환 없이 응집도만 높이는 방향을 택했습니다.
이로써 얻은 이점은 다음과 같습니다.
- 예측 가능한 경로와 낮은 진입 장벽(역할 기반 레이어 유지)
- 변경 범위의 로컬화 및 응집도 상승(도메인 하위 폴더)
- 공용 레이어(shared) 재사용 극대화로 중복 감소
- 테스트 용이성(도메인 훅/유틸 단위)
- 점진적 확장 경로 확보(규모 확장 시 도메인 루트로 승격 용이)
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
1. 단일책임과 응집도 고려
const productsAtom = atomWithStorage<ProductWithUI[]>('products', initialProducts);
export function useProduct() {
const [products, setProducts] = useAtom(productsAtom);
const addProduct = (newProduct: Omit<ProductWithUI, 'id'>) => {
const product: ProductWithUI = {
...newProduct,
id: `p${Date.now()}`,
};
setProducts((prev) => [...prev, product]);
};
const updateProduct = (productId: string, updates: Partial<ProductWithUI>) => {
setProducts((prev) =>
prev.map((product) => (product.id === productId ? { ...product, ...updates } : product))
);
};
const deleteProduct = (productId: string) => {
setProducts((prev) => prev.filter((p) => p.id !== productId));
};
return {
products,
addProduct,
updateProduct,
deleteProduct,
};
}
[의도]
- 중앙 스토어를 두지 않고, 각 도메인 훅 모듈에서 Jotai atom을 선언해 “모듈 단위 싱글톤”으로 관리했습니다.
- 훅은 그 상태와 관련 비즈니스 로직을 함께 캡슐화해 공개 인터페이스만 노출합니다.
[이점]
- 응집도·SRP: 상태와 로직이 같은 모듈에 있어 변경 이유가 명확합니다.
- 예측가능성: 외부는 훅의 메서드만 사용하므로 사용면이 단순합니다.
- 성능: 구독 범위가 도메인 단위로 좁아져 불필요한 리렌더가 줄어듭니다.
- 테스트 용이: 모듈 스코프의 atom 초기화/리셋이 쉬워 단위 테스트가 간단합니다.
- 확장성: 상위 훅에서 도메인 훅들을 합성해도 전역 결합 없이 확장 가능합니다.
- 지속성 일관: atomWithStorage로 필요한 도메인만 선택적으로 영속화합니다.
[트레이드오프]
- 교차 도메인 의존은 훅의 공개 인터페이스로만 접근(직접 atom 접근 금지).
- 원 사이클 방지(훅 간 순환 의존 금지), 공통 규칙은 shared 유틸/타입으로 승격.
- 전역으로 반드시 공유해야 하는 크로스컷팅(예: auth, theme)은 별도 전역 훅로 관리.
2. 훅 UI 의존 제거 리팩토링
const handleFormSubmit = (e: React.FormEvent, notify?: (msg: string) => void) => {
const result = handleCouponSubmit(e);
if (result.success) {
resetCouponForm();
notify?.('쿠폰이 등록되었습니다.'); // 하드코딩 메시지
}
};
- 훅 내부에서 UI 문구를 하드코딩으로 주입하고 있었습니다.
[개선]
const handleFormSubmit = (e: React.FormEvent, onSuccess?: () => void) => {
const result = handleCouponSubmit(e);
if (result.success) {
resetCouponForm();
onSuccess?.();
}
};
- 성공 시점만 외부 콜백에 위임하여 문구를 외부에서 주입하도록 변경했습니다.
- 여전히 함수에 여러 책임이 존재하는 구조였습니다.
[최종 구조]
const handleFormSubmit = (e:FormEvent) => {
const result = handleCouponSubmit(e);
if (result.success) resetCouponForm();
return result;
};
// Usage: 사용부에서 메시지/알림 처리
onSubmit={(e: FormEvent) => {
const { success } = handleFormSubmit(e);
if (success) {
addNotification('쿠폰이 생성되었습니다.', 'success');
}
}}
- 단일책임/SRP: 훅은 상태/검증/결과만, UI는 사용부에서 담당
- 예측가능성: 훅 API가 결과 타입으로 수렴 → 호출부 분기 명확
- 테스트 용이성: 알림/문구 없이 로직만 테스트 가능
- 확장성: 동일 결과 타입을 다양한 UI(토스트/모달/로그)로 재사용
3. util 함수 활용
format
export const formatMinError = (fieldLabel: string, min: number) =>
`${fieldLabel}은 ${min}보다 커야 합니다.`;
export const formatMaxError = (fieldLabel: string, max: number) =>
`${fieldLabel}은 ${max}를 초과할 수 없습니다.`;
export const formatAdded = (entityLabel: string) =>
`${entityLabel}이 추가되었습니다.`;
export const formatCrudError = (entityLabel: string, actionLabel: string) =>
`${entityLabel} ${actionLabel} 중 오류가 발생했습니다.`;
// 사용 예 (검증)
const validateProductForm = (field: keyof ProductForm, value: number): ValidationResult => {
if (field === 'price') {
return value < 0 ? { isValid: false, message: formatMinError('가격', 0) } : { isValid: true };
}
if (field === 'stock') {
if (value < 0) return { isValid: false, message: formatMinError('재고', 0) };
if (value > 9999) return { isValid: false, message: formatMaxError('재고', 9999) };
return { isValid: true };
}
return { isValid: true };
};
- format 유틸을 만들어서 메세지 레이어를 맞추고자 했습니다.
- 모든 메세지를 커버하기에는 시간이 부족해 중복이 많은 메세지부터 일부 적용했습니다.
commaizedNumber
export const commaizedNumber = (number: number): string => {
return number.toLocaleString();
};
export const commaizedNumberWithUnit = (number: number, unit: string): string => {
return `${number.toLocaleString()}${unit}`;
};
// 사용
<span className="font-bold text-lg text-gray-900">
{commaizedNumberWithUnit(totalAfterDiscount, '원')}
</span>
<button>
{commaizedNumberWithUnit(totalAfterDiscount, '원')} 결제하기
</button>
- toLocaleString 직접 사용 대신 commaizedNumberWithUnit으로 일관성과 확장성(통화/단위) 확보
과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
1. 계산 로직 단순화 하기
아직 많은 함수들이 분리만 되어있는 상태입니다. 액션과 계산 함수에 대한 연습이 덜 되어 그 부분이 아쉽습니다.
[현재]
const calculateCartTotal = (cart: CartItem[], selectedCoupon: Coupon | null) => {
let totalBeforeDiscount = 0;
let totalAfterDiscount = 0;
cart.forEach((item) => {
const itemPrice = item.product.price * item.quantity;
totalBeforeDiscount += itemPrice;
totalAfterDiscount += getProductDiscountedPrice(item, cart);
});
if (selectedCoupon) {
if (selectedCoupon.discountType === 'amount') {
totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue);
} else {
totalAfterDiscount = Math.round(
totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)
);
}
}
return {
totalBeforeDiscount: Math.round(totalBeforeDiscount),
totalAfterDiscount: Math.round(totalAfterDiscount),
};
};
- 변이 기반 계산: 누적 변수(totalBeforeDiscount, totalAfterDiscount)를 외부에서 변경
- 책임 혼재: 합계 계산 + 쿠폰 적용 + 반올림을 한 함수에서 모두 처리
- 조건 중첩: 쿠폰 분기(정액/정률) if-중첩으로 가독성 저하
[개선방향]
function calcCartTotals(cart) {
return cart.reduce(({ before, after }, item) => {
const base = item.product.price * item.quantity;
const rate = getEffectiveDiscountRate(item, cart);
const discounted = calcDiscountedItemTotal(item.product.price, item.quantity, rate);
return { before: before + base, after: after + discounted };
}, { before: 0, after: 0 });
}
- 계산 순수화: reduce로 합계 산출(calcCartTotals), 변이 제거
- 역할 분리
- 할인 규칙 → getEffectiveDiscountRate, calcDiscountedItemTotal
- 쿠폰 적용 → applyCoupon(total, coupon) (전략 함수)
- 반올림 → 최종 출력 레이어에서만 수행
- 가독성/유지보수성: 계산·정책·표현 레이어 분리로 변경 영향 최소화
2. 컴포넌트 추상화, 설계 조금 더 깊게 고민하기
훅 추상화에 시간을 많이 보내서 컴포넌트 추상화는 AI에게 위임했습니다. 이부분도 혼자 고민해보면서 리팩토링 해본다면 좋은 공부가 될 것 같습니다. 다만 생산성은 AI에게 위임하는 것이 더 높은 것 같습니다. 꽤 괜찮고 빠르게 만들어주는 것을 체감했습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
- 모듈 단위 싱글톤 훅(Jotai) 설계에 대한 피드백
const productsAtom = atomWithStorage<ProductWithUI[]>('products', initialProducts);
export function useProduct() {
const [products, setProducts] = useAtom(productsAtom);
const addProduct = (newProduct: Omit<ProductWithUI, 'id'>) => {
const product: ProductWithUI = {
...newProduct,
id: `p${Date.now()}`,
};
setProducts((prev) => [...prev, product]);
};
const updateProduct = (productId: string, updates: Partial<ProductWithUI>) => {
setProducts((prev) =>
prev.map((product) => (product.id === productId ? { ...product, ...updates } : product))
);
};
const deleteProduct = (productId: string) => {
setProducts((prev) => prev.filter((p) => p.id !== productId));
};
return {
products,
addProduct,
updateProduct,
deleteProduct,
};
}
앞서 언급했던 이 모듈 단위 싱글톤 훅에 대해서 코멘트를 받아보고 싶습니다! 저의 고려 방법이 적절했던 것인지, 다른 의견 주실 것이 있는지 궁금합니다.
-
적절한 리팩토링의 타이밍 코치님이 생각하시는 적절한 리팩토링 타이밍은 언제이신가요? 회사일정과 같은 외부요소 고려를 제외하고 순수 코드 상에서 어떤 기준으로 판단하시는지 판단 기준이 궁금합니다!
-
앞서 언급했던 저의 판단 과정이나 리팩토링 코드의 방향은 적절할까요?
과제 피드백
안녕하세요 지호! 수고했습니다. 아주 잘했습니다. 회고의 내용은 그렇지.. 음 그래! 하면서 감탄하면 잘 읽었습니다. 아주 잘쓰는군요. 멋집니다.
Q) 모듈 단위 싱글톤 훅(Jotai) 설계에 대한 피드백 => 너무 잘했습니다. 결합도와 응집도 관련해서 모든 코드는 책임이 있고 자리가 있습니다. atom의 자리가 정해진 건 아닙니다. atom을 몇개 쓰지 않고 전역적인 테마나 설정 관리로 쓴다면 그냥 atom끼리 모아두는게 더 좋을수 도 있습니다.
=> 좋은 자리라는건 쓰이는 곳에 가장 가까이 두는 것입니다. 같이 쓰면 공통화를 하는것도 결국 공통된 곳에 두는게 모두에게 가깝기 때문이죠. productsAtom이 쓰이는 곳은 useProduct 밖에 없으니 이 둘을 한데 두는 것은 아주 잘 결정한 선택입니다.
Q) 적절한 리팩토링의 타이밍
=> 더티코드를 클린하게 하고 결합도를 낮추는 리팩토링, 즉 이 코드를 변경해도 다른 코드에 영향이 없는데 더 나아지는 형태의 리팩토링은 보일때마다 하세요. 이름이 좀 이상한 것 줄바꿈이 없는것 불필요한 인라인 코드, 순수함수로 빼도 괜찮은 코드, 컴포넌트 분리 등...
=> 이번 과제를 해보면서 느꼈겠지만 확신을 가지고 이렇게 바꿔도 테스트 코드는 통과하겠지 하는것과 아.. 이렇게 하면 테스트 되나? 하면서 돌려보던 그 느낌이 다른걸 본인도 알거에요. 그런 코드들은 보일때마다 하세요. 단 커밋을 할때에는 응집도 있게 커밋해야겠죠?
=> 그리고 구조변경이나 응집도를 바꾸는 리팩토링의 경우에는 모두의 합의도 필요하기에 방향성과 합의점을 먼저 만들어가는게 좋습니다. 특히나 어떻게하는가?에 대한 합의보다도 더 큰 상위의 합의를 먼저하는거에요. 가령 이번에는 React19로 올리는게 목적이다 라고 합의를 하면 그걸 하기 위한 리팩토링은 OK 그게 아닌 것들은 좀 우선순위도 미뤄볼수도 있겠죠. 그게 아니라 단순 방법을 가지고 논의하기 시작하면 어려워요.
=> 앞에서 사전작업을 잘해서 충분히 결합도가 낮은 코드라면 대규모 리팩토링도 그렇게 어렵지 않게되요. 그러니 평소에 클린하지 못하다 그런 코드가 보일때마다 야금야금 정리정돈 잊지 않기를 바래요! 평소에는 정리정돈 그러다가 날잡고 대청소, 아주 아주 신중하게 리모델링! 무슨 말인지 알겠죠?
Q) 앞서 언급했던 저의 판단 과정이나 리팩토링 코드의 방향은 적절할까요? => 판단 과정은 아주 훌륭해요. 그리고 atom을 useProduct에 둔 것처럼 자리를 한번 더 생각해보길 바래요. 컴포넌트 props를 다루거나 이벤트 핸들러 등을 다루는데 있어서 혹은 hook을 배치하는데 있어서 이 자리가 최선인가? 하는 생각을 해보길 바래요. 그러면 조~금 더 좋은 코드의 방향성을 가진 코드가 될 것 같아요!
총평은 지금의 방향성이 아주 좋았는데 아직 도착지는 다 못간 느낌입니다. 여전히 더 좋아질 구석이 있는 코드입니다.
수고했습니다. 6주차 과제도 화이팅이에요!