[2팀 이진희] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#33
Open
bebusl wants to merge 19 commits into
Open
Conversation
fixup
- `useProducts` 훅을 새로 만들어 상품 관련 상태 및 CRUD 로직을 `App.tsx`에서 분리했습니다. - 상품 추가, 수정, 삭제와 같은 비동기 작업을 처리하고 사용자에게 알림을 표시하기 위해 `useTask` 훅을 도입했습니다. - 이를 통해 `App.tsx`의 복잡도를 낮추고 코드 재사용성을 높였습니다.
JunilHwang
reviewed
Aug 8, 2025
JunilHwang
left a comment
There was a problem hiding this comment.
안녕하세요 — PR 잘 구성하셨습니다. 전반적으로 책임 분리(components / hooks / models / utils / pages)를 적극적으로 적용하려는 의도가 잘 보이고, 순수함수(모델)와 사이드 이펙트(훅/컴포넌트)를 분리하려는 시도도 좋습니다. 아래 피드백은 자동화된 AI 리뷰(참고용)이며, 곧바로 GitHub PR 코멘트로 붙여 넣을 수 있게 종합 피드백 + 상세 피드백(개념 정의 → 문제 → AS-IS → TO-BE)을 마크다운 형태로 정리했습니다.
간단 요약(키워드)
- 응집도(cohesion): 모델(순수함수) / 훅(사이드 이펙트) / UI의 분리 → 대체로 잘 했음, 일부 혼재 지점 존재
- 결합도(coupling): addNotification 등 콜백이 여러 곳으로 흘러 감 → 낮출 여지
- 상태관리 라이브러리 교체 대비성: custom hooks로 잘 추상화되어 있어 교체 난이도는 낮음. 다만 타입/의존성 오류와 몇몇 구현 결함이 이식 비용을 올림
- 모듈화(패키징): 구조(components, hooks, models, utils, pages, data)는 패키지화하기에 적합. 그러나 public API를 정하고 내부 의존성 정리가 필요
- 주요 구현 버그/개선 포인트: useLocalStorage 로직, useNotification 타입(컴포넌트 타입 혼용), formatPrice 위치/중복, 일부 TODO 미구현(모델 함수들), setState 내부에서의 throw/검증 전략
질문(풀리퀘스트 바디)에 대한 답변 요약
- "과제 셀프회고"에 대한 피드백(인사이트)
- 아주 적절한 관찰입니다. 책임 분리 → DDD/레이어드 아키텍처로 자연스럽게 이어지는 경험을 잘 설명하셨습니다. formatPrice / findById의 고민은 ‘도메인 종속성 vs UI 목적’의 전형적 사례로, 이를 통해 “함수의 목적(Presentation vs Domain)”을 명확히 하는 규칙을 만드는 것이 실무에 큰 도움이 됩니다.
- 추가 인사이트 질문:
- formatPrice처럼 ‘도메인 정보(제품 재고, 권한 등)’와 ‘표현(포맷)’이 섞인 경우, 우선순위는 무엇인가요? (1) 완전한 도메인 함수 → 뷰 레이어에서 포맷, (2) 뷰 편의 함수 → 도메인 상태를 참조. 어느 쪽을 선호합니까?
- '이중검사' 구조(외부 검사 + 내부 검사 적용)를 API 레벨에서 어떻게 문서화/강제할 수 있을까요? (예: addToCart는 실패시 에러를 throw / 반환 타입으로 실패를 알려주고, 호출자는 useTask로 처리)
- "이번 과제에서 제일 신경 쓴 부분"에 대한 피드백
- 책임의 분리에 대한 고민/결정 과정이 합리적입니다. 다만 다음 질문을 이어보면 좋습니다.
- "Form이 UI인지 도메인인지"의 경계는 팀 합의(가이드라인)로 정해야 합니다. 가이드라인 샘플: "비즈니스 규칙(유효성·도메인 제약)이 포함되면 feature 레이어, 그렇지 않으면 UI 레이어".
- formatPrice처럼 애매한 함수는 내부적으로 더 작은 큐(순수 함수: formatCurrency, isSoldOut)로 쪼개면 위치 결정이 쉬워집니다.
- 책임의 분리에 대한 고민/결정 과정이 합리적입니다. 다만 다음 질문을 이어보면 좋습니다.
종합 피드백 (PullRequestFiles 기반)
- 주요 피드백 키워드
- 응집도: models/cart 등 순수 로직은 잘 분리됨. 다만 useProducts에 UI 속성(product.isRecommended, description)이 포함되어 도메인-UI 경계가 모호.
- 결합도: addNotification이 여러 훅/페이지로 prop으로 전달되고 있음. Context 또는 전역 상태(혹은 이벤트 버스)로 낮출 수 있음.
- 상태관리 교체 용이성: custom hooks 추상화(예: useCart, useProducts, useCoupons, useNotification) 덕분에 라이브러리 교체 비용이 낮음. 하지만 타입/의존성 문제(아래 상세)가 이식성 비용을 올릴 수 있음.
- 모듈화/패키징 준비도: 폴더/파일 분리는 적절. 패키지화하려면 public API(각 훅의 반환 타입)를 명확히 문서화해야 함.
- PullRequestBody(셀프회고) 대한 피드백(인사이트 중심)
- 좋음: 책임 분리·도메인 중심 구조로의 전환 경험과 이유를 명확히 서술하셨습니다. formatPrice, findById와 같은 구체적 케이스를 통해 설계 고민의 깊이가 드러납니다.
- 더 생각해볼 질문:
- formatPrice를 도메인 함수로 둔다면 presentation의 유연성(통화 기호/로케일 등)은 어떻게 보장할 건가요?
- setState 안/밖에서의 검증을 API 레벨(예: addToCart가 실패를 반환)로 통일하면, UI는 어떤 책임을 갖게 되나요? (UX: 에러 메시지/토스트)
- 지금 구조에서 각 훅이 단독으로 테스트 가능한가요? 만약 아니라면 어떤 의존성을 분리해야 하나요?
- "리뷰 받고 싶은 내용"에 대한 답변
- 전반적 구조(레이어 분리)는 잘 되어 있습니다. 다만 다음 개선 제안/검사 포인트를 권합니다:
- 타입/구현 버그(아래 상세) 우선 해결
- useLocalStorage/Notification 타입 충돌 해결
- formatPrice 위치 통일(또는 쪼갬)
- useTask의 성공/실패 핸들링을 전역 이벤트(또는 훅 반환값)로 표준화
- 상태관리 교체 시나리오(아래 예시) 검토
상세 피드백 — 각 개념 정의 → 문제 → AS-IS → TO-BE
먼저 개념 정의(피드백에서 사용할 기준)
- 응집도(cohesion): 한 모듈(파일/훅/패키지)이 수행하는 책임의 일관성. 높은 응집도: 모듈 내부 코드 변경 범위가 작음(변경 시 편의성). 사용자의 정의(동선이 짧고 패키지로 떼어낼 수 있는가)를 반영:
- 응집도 체크 포인트: 해당 파일/훅에서 변경이 발생할 때 수정해야 할 파일이 적은가? 패키지로 떼어낼 때 외부 의존성이 적은가?
- 결합도(coupling): 모듈 간 상호 결합 정도. 낮은 결합도는 명확한 인터페이스(함수/객체 반환)를 통해 성취. 콜백(onError/onSuccess) 패턴은 결합도를 낮추는 좋은 예.
이제 파일 기반 문제들과 제안
- useLocalStorage 구현 결함 (핵심 — PR 전체에 영향)
- 문제 정의: setValue 내부에서 localStorage에 저장/삭제를 결정할 때 value(파라미터)를 사용해 검사함. 만약 value가 함수(updater)일 경우 value instanceof Function 분기가 실행되고 value(함수)와 비교/배열 검사 로직이 잘못 동작. 또한 initialValue가 undefined일 때의 타입 안전성 등.
- AS-IS
(현재 핵심 부분)const setValue = (value: T | ((prev: T) => T)) => { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (value === undefined || (Array.isArray(value) && !value.length)) { localStorage.removeItem(key); } else { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } };
- 문제:
- value가 함수일 때 if 분기에서 value === undefined는 항상 false, Array.isArray(value) 검사도 잘못된 값 검사. 의도한 것은 valueToStore 기반으로 로직을 처리하는 것.
- TO-BE (수정 제안)
const setValue = (value: T | ((prev: T) => T)) => { const valueToStore = typeof value === "function" ? (value as (prev: T) => T)(storedValue) : value; setStoredValue(valueToStore); try { // 빈 배열/빈 오브젝트 등의 정책은 명확히 정의: 여기선 null/undefined 혹은 빈 배열이면 제거 if (valueToStore === undefined || valueToStore === null || (Array.isArray(valueToStore) && valueToStore.length === 0)) { window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch { // localStorage 실패 방어 (e.g. private mode) } };
- 부가 권장사항:
- 초기화 시 try/catch로 안전 처리(이미 구현되어 있으나 타입을 명확히).
- optional initialValue를 타입으로 보장(제네릭 제약 or default fallback).
- useNotification 타입 충돌 & 구현 실수
- 문제 정의: src/basic/hooks/useNotification.ts에서 Notification 타입을 사용하고 있으나 파일은 components/Notification에서 default export(React 컴포넌트)를 import. 즉 타입과 값이 이름이 충돌하거나 타입이 정의되지 않아 타입 에러.
- AS-IS (핵심)
useNotification.ts:여기서 Notification은 컴포넌트(값)입니다. 타입이 아니라서 오류가 납니다.import Notification from "../components/Notification"; const [notifications, setNotifications] = useState<Notification[]>([]);
- TO-BE
- 분리된 타입 정의 파일(types.ts)에 Notification 타입을 정의하고 재사용하세요.
- 예:
// src/basic/types/notification.ts (또는 src/types.ts에 추가) export type AppNotification = { id: string; message: string; type: "error" | "success" | "warning"; };
- 그리고 각 파일에서:
import { AppNotification } from "../types/notification"; const [notifications, setNotifications] = useState<AppNotification[]>([]);
- components/Notification는 타입을 import해서 props에 사용:
import { AppNotification } from "../types/notification"; type Props = { notifications: AppNotification[], removeNotification: (id: string) => void }
- 부가 권장사항: 타입을 중앙화(src/types.ts)하면 패키징(외부 공개 API) 시 유용합니다.
- formatPrice의 위치 & 중복
- 문제 정의: formatPrice가 App 내부에 있고 utils/formatters.ts에 formatPrice도 있음(미사용). 또한 formatPrice는 도메인 정보(제품 재고, isAdmin)에 의존하여 "표현"과 "도메인"이 섞여 있음.
- AS-IS (App 내)
const formatPrice = (price: number, productId?: string): string => { if (productId) { const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product, cart) <= 0) { return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } return `₩${price.toLocaleString()}`; };
- 문제: formatPrice는 presentation(통화 포맷)과 domain(isSoldOut, isAdmin 변조)을 혼합. 유지보수/테스트 어려움.
- TO-BE (권장)
- 분리: presentation 포맷 함수(formatCurrency) vs 도메인 판단(isSoldOut).
- 예:
// models/product.ts (순수) export const isSoldOut = (product: Product, cart: CartItem[]) => product.stock - (cart.find(i => i.product.id === product.id)?.quantity || 0) <= 0; // utils/formatters.ts export const formatCurrency = (price: number, locale = "ko-KR", currency = "KRW") => new Intl.NumberFormat(locale, { style: "currency", currency }).format(price); // App/Component const displayPrice = (price, productId) => { if (productId) { const product = products.find(p => p.id === productId); if (product && isSoldOut(product, cart)) return "SOLD OUT"; } return formatCurrency(price); }
- 장점: formatCurrency는 어디서든 재사용 가능(패키지화 용이). isSoldOut는 순수 함수(models)에 있어 테스트하기 쉬움.
- setState 내에서 throw / 검증 책임(동시성 문제)
- 문제 정의: PR 본문에서도 언급된 것처럼 setState 내부에서 throw하면(또는 사이드 이펙트) React 내부 동작을 방해할 가능성이 있음. 또한 외부에서 먼저 검사하면 race condition 발생 가능.
- 권장 패턴 (TO-BE)
- 검증을 atomic하게 처리하는 한 곳(서비스/레ducer)으로 모아서 처리. 예: useCart가 검증 + 상태변경을 담당하고, 실패는 반환값으로 알림/예외를 던짐.
- 예: useCart API
// AS-IS: addToCart(product) { setCart(prev => {...}) } // TO-BE: const addToCart = (product: Product) => { // 모든 검사(현재 cart 상태 기준)는 hook 내부에서 수행 (동기) const currentCart = cart; // local snapshot const remaining = getRemainingStock(product, currentCart); if (remaining <= 0) return { ok: false, reason: 'OUT_OF_STOCK' }; setCart(prev => {/* update */}); return { ok: true }; }
- UI는 반환값을 보고 알림을 표시:
const res = addToCart(product); if (!res.ok) addNotification('재고가 없습니다');
- 또는 useTask와 조합해 에러를 throw하고 useTask에서 onError로 처리하는 방법도 깔끔.
- useTask 패턴: 장점/권장 개선
- 이미 좋은 시도입니다: 사이드 이펙트(알림 등)를 분리해서 onSuccess/onError로 처리. 다만:
- deps 인자(배열)를 options로 넘기는 방식은 사용 시 복잡도를 올릴 수 있음. taskFn 자체가 변경되면 run이 바뀌도록 taskFn을 의존성으로 포함시키면 충분합니다.
- 반환값(혹은 상태: loading, error)을 제공하면 UI가 로딩 스피너 같은 것을 쉽게 붙일 수 있음.
- TO-BE 예:
interface TaskReturn<T> { run: (...args: any[]) => Promise<T|undefined>, loading: boolean, error: unknown | null }
- 상태관리 라이브러리 교체 시나리오 (jotai/zustand/RTK Query 등)
- 현재 상태: useProducts/useCart/useCoupons/useNotification/useLocalStorage 로 추상화되어 있음(좋음). 교체 용이성은 비교적 높음.
- 각 라이브러리로 바꿀 때 예상 변화와 권장 방식:
- Jotai (원자 기반)
- 제품/카트/쿠폰을 atoms로 이동.
- 장점: 컴포넌트에서 직접 구독(selective) 가능, 비동기 atom으로 persisted state와 통합 쉬움.
- TO-BE 예 (간단):
// atoms.ts import { atom } from 'jotai'; export const productsAtom = atom(initialProducts); export const cartAtom = atom<CartItem[]>([]); // selector atom for totals export const cartTotalsAtom = atom((get) => calculateCartTotal(get(cartAtom), get(selectedCouponAtom)));
- 기존 hooks: useProducts/useCart는 단순 래퍼(hook)로 변경하여 내부에서 atoms를 호출하도록 유지(호출부 변경 최소화).
- Zustand
- 글로벌 store definition으로 이동. Hook 형태(사용법 동일) 유지 가능.
- TO-BE 예:
// store.ts const useStore = create(set => ({ products: initialProducts, addProduct: (p) => set(state => ({ products: [...state.products, p] })), cart: [], addToCart: (product) => set(...), }));
- 장점: 단일 store, 성능 제어, 미들웨어.
- TanStack Query (데이터 fetching 중심)
- 서버-싱크 데이터(제품 목록, 쿠폰)를 캐싱/비동기 처리에 적합. localStorage persistence는 initialData/Mutation 시 로컬 write로 처리.
- Jotai (원자 기반)
- 마이그레이션 전략(권장)
- 각 훅(useCart 등)을 thin wrapper로 유지: 내부 구현만 바꾼다. 호출부(컴포넌트)는 변경 없음.
- 도메인/순수함수(models/*)은 그대로 사용(이식성 유지).
- 테스트/타입 정합성 먼저 확보 → 리팩토링.
- 패키지(모듈화)로 분리할 때 고려사항 & 권장 public API
- 분리 제안: @your-org/ui (components/Header, Notification), @your-org/hooks (useCart, useProducts, useCoupons, useNotification), @your-org/models (cart 계산 함수), @your-org/utils (formatters, local storage hook)
- 패키지의 public surface 최소화(응집도 향상):
- exports:
- @pkg/models: calculateCartTotal, getRemainingStock, calculateItemTotal, types
- @pkg/hooks: useCart, useProducts, useCoupons, useNotification (명확한 반환 타입으로 문서화)
- @pkg/ui: Header, Notification, AdminPage, CartPage (있는 경우)
- exports:
- TO-BE: root index.ts 예
// packages/models/src/index.ts export * from './cart'; export * from './product'; // packages/hooks/src/index.ts export { useCart } from './useCart'; export { useProducts } from './useProducts';
- 패키지화 체크리스트:
- 내부 상태에 window/localStorage 직접 사용하는지 확인 → Node 환경(SSR) 호환성 위해 guard 필요(window check).
- 의존성 정리: peerDependencies와 bundledDependencies 구분.
- 결합도: addNotification prop 전파 문제
- 문제 정의: addNotification이 App에서 만들어져서 AdminPage/CartPage로 전달됨. 현재는 관리하기 쉽지만 패키징/재사용성 측면에서 결합도를 높임.
- AS-IS
<AdminPage addNotification={addNotification} ... />
- TO-BE(옵션1) — Context
- NotificationContext를 만들어 useNotificationContext 훅으로 소비.
const NotificationContext = createContext<NotificationApi | null>(null); // Provider in App <NotificationProvider>{children}</NotificationProvider>
- 장점: prop drilling 제거, 쉽게 패키징
- TO-BE(옵션2) — hook 반환으로 이벤트 노출
- useCart 등 훅이 실패/성공 결과를 반환해, 호출자가 addNotification을 직접 호출하게 함(결합 낮춤).
- 예:
const res = addToCart(product); if (!res.ok) notify('error');
- 권장: Notification은 cross-cutting concern이므로 Context를 권장. 다만 library로 분리 시 외부 알림 시스템과의 연동 포인트를 만들어야 함.
- useProducts 훅 내 UI 속성(응집도 관련)
- 문제 정의: Product 타입 확장(ProductWithUI: description, isRecommended) 이 훅 내부에 존재. useProducts는 제품 데이터(도메인)와 UI 메타(isRecommended)가 혼재.
- 제안:
- Product 타입을 domain 측(타입 정의)에서 확정하고, UI 별 메타는 presentation 레이어(컴포넌트)로 이동시키거나, UI 메타를 별도 store/atom으로 관리.
- TO-BE:
- domain/Product: id, name, price, stock, discounts
- ui/ProductMeta: isRecommended, displayDescription
- 이렇게 하면 useProducts는 도메인 책임만 갖고, AdminPage 등의 UI는 meta를 조합.
- models/cart 미구현 TODOs
- PR에 calculateCartTotal/getRemainingStock는 구현되어 있으나 add/update/remove 등 몇몇 함수는 TODO로 남아 있음. 테스트 및 문서화 필요.
- 권장: 모든 순수함수(models)에는 유닛 테스트 추가(특히 할인 규칙들).
구체적 코드 개선 예제(AS-IS vs TO-BE)
A) 결합도 낮추기: addNotification 전달을 Context로 전환
- AS-IS (App -> prop drilling)
// App.tsx const { notifications, addNotification, removeNotification } = useNotification(); <AdminPage addNotification={addNotification} ... /> <CartPage addNotification={addNotification} ... />
- TO-BE (NotificationContext)
// NotificationContext.tsx export const NotificationContext = createContext<NotificationApi | null>(null); export const NotificationProvider = ({children}) => { const api = useNotification(); return ( <NotificationContext.Provider value={api}> <Notification notifications={api.notifications} removeNotification={api.removeNotification} /> {children} </NotificationContext.Provider> ); }; // App.tsx return ( <NotificationProvider> <Header ... /> <AdminPage ... /> // 이제 addNotification prop 불필요 </NotificationProvider> ); // AdminPage.tsx const { addNotification } = useContext(NotificationContext)!;
B) useCart API 변경: 내부 검증 책임 + 반환값 표준화
- AS-IS
const addToCart = useCallback((product: Product) => { setCart(prev => { ... }); }, [setCart]);
- TO-BE
// useCart.ts const addToCart = (product: Product) => { // atomic 검증 const remaining = getRemainingStock(product, cart); if (remaining <= 0) return { ok: false, reason: 'OUT_OF_STOCK' }; setCart(prev => { ... }); return { ok: true }; }; // 호출부 const res = addToCart(product); if (!res.ok) addNotification('재고 부족', 'error');
C) Jotai로 migration 예시 (useCart 래퍼 유지)
- TO-BE
// atoms.ts import { atom } from 'jotai'; export const cartAtom = atom<CartItem[]>([]); // models 소비 export const cartTotalsAtom = atom((get) => calculateCartTotal(get(cartAtom), get(selectedCouponAtom))); // useCart.ts (래퍼) import { useAtom } from 'jotai'; export const useCart = () => { const [cart, setCart] = useAtom(cartAtom); const addToCart = (product: Product) => { setCart((prev) => { ... }); } return { cart, addToCart, ... } }
- 이 방식은 컴포넌트 호출부 변경 없이 내부 구현만 바꿀 수 있습니다.
마무리 정리(우선순위 액션 아이템)
- 치명적/타입 에러 우선 해결
- useNotification.ts의 Notification 타입 충돌 해결(중앙 타입 정의).
- useLocalStorage의 setValue 로직 수정.
- formatPrice 리팩토링
- presentation(formatCurrency) / domain(isSoldOut) 분리.
- useCart API 방어성 개선
- 검증을 훅 내부로 이동하고 실패 시 반환값으로 알리기(또는 useTask로 onError 처리).
- Notification 전달 방식 개선
- Context Provider로 전환하여 prop drilling 제거(패키지화 대비).
- 테스트 추가(모델 함수)
- calculateCartTotal, getRemainingStock, getMaxApplicableDiscount 등 유닛 테스트 추가.
- 패키지화 준비
- types 중앙화, public API 설계(index.ts exports), window 가드(SSR) 추가.
추가 질문(검토용)
- 패키지로 배포할 때 어떤 범위를 외부로 드러내고 싶으신가요? (예: hooks 패키지만, or ui + hooks)
- Notification을 Context로 바꿀 의향이 있으신가요? (권장)
- 상태관리 라이브러리(예: Jotai, Zustand, Redux)에 대해 선호하시는 바가 있나요? (팀 합의 시향후 리팩토링 방향 달라짐)
종결 메모
- 전반적으로 훌륭한 분할과 책임 의식이 보입니다. 위에서 지적한 타입/로직 버그들을 먼저 고치고(특히 useLocalStorage와 Notification 타입), formatPrice 같은 애매한 함수는 분해하면 유지보수가 크게 수월해집니다. 상태관리 라이브러리 교체는 현재 훅 추상화 덕분에 비교적 쉬울 것으로 보이며, Context를 통해 cross-cutting concern을 정리하면 패키지화/재사용성에 큰 도움이 됩니다.
원하시면
-
- useLocalStorage / useNotification 등 문제 코드의 patch(PR-ready) 코드를 만들어 드리거나,
-
- Jotai/Zustand 마이그레이션 샘플(작은 예제) 코드를 제공해 드리겠습니다.
원하시는 다음 작업을 알려주세요.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
과제의 핵심취지
과제에서 꼭 알아가길 바라는 점
기본과제
Component에서 비즈니스 로직을 분리하기
비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기
뷰데이터와 엔티티데이터의 분리에 대한 이해
entities -> features -> UI 계층에 대한 이해
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
특정 Entitiy만 다루는 함수는 분리되어 있나요?
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다.
어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요.
Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다.
Context나 Jotai를 사용해서 전역상태관리를 구축했나요?
전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요?
도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요?
전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요?
과제 셀프회고
과제를 하면서 알게 된 점과 좋았던 점
책임의 분리'에서 시작된 도메인 주도 설계 학습
저번 과제와 이번 과제를 하면서 '책임의 분리'를 고민하다 자연스럽게 도메인 주도 설계에 대해 얕게나마 공부하게 됐다. 평소 "이걸 왜 이렇게까지 해야 하지?" 했던 부분들을 직접 경험하며 깨달을 수 있어 좋았다.
이번 경험을 통해 방법론과 디자인 패턴을 공부하는 이유가 문제를 체계적으로 분석하고, 유지보수성과 확장성을 고려한 설계를 하기 위함이라는 걸 알게 됐어. 다양한 패턴을 알면 여러 관점에서 문제를 고민해 더 좋은 설계를 할 수 있다는 인사이트도 얻었다.
역할 중심 vs. 도메인 중심 구조
기존 회사 코드는 역할 중심 구조로 되어 있었다. 각 역할별 관심사를 분리하는 데는 효과적이었지만, 도메인 코드가 흩어져 있어 기능 단위로 전체 흐름을 파악하기 어려운 단점이 있었다.
반면, 이번 과제에 적용하려고 시도한 도메인 중심 구조는 관련 기능, 타입, API 호출, UI 컴포넌트 등이 한곳에 모여 응집도가 높다. 덕분에 특정 도메인에 집중하기 쉽고, 유지보수나 확장 시에도 훨씬 효율적이어 보인다.
물론 프로젝트 규모나 팀 상황에 따라 적합한 구조는 달라질 수 있다. 도메인 중심 설계가 무조건 정답은 아니지만, 대부분의 프로덕트에서는 이 구조가 더 적절할 거라 생각한다.
성장 포인트
평소엔 프로젝트 구조를 직접 설계할 기회가 적어 늘 정해진 구조 안에서 코드를 작성하다 보니 "왜 코드 파악이 어렵지?"라는 고민만 계속했었다.
이번 과제를 통해 그 고민을 직접 해결해볼 수 있어 좋았고, 직접 문제를 해결하는 과정 속에서 배움의 속도가 빨라지는 걸 느꼈다.
다양한 방법론과 디자인 패턴의 중요성을 깨닫고, 여러 관점에서 문제를 바라보는 시야를 얻게 된 이번 과제는 나에게 매우 의미 있는 경험이었다.
이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요?
책임의 분리
책임 분리에 가장 신경을 썼다.
처음에는 단순하게 가고 싶었다.
components,features,layout정도로 1depth로 시작해서 점점 쌓아가자는 식이었다.근데 막상 "상품 관리" 같은 탭이 생기니까 애매해졌다.
Form은 어디에 둬야 하지? 그냥 UI 컴포넌트인가? 아니면 이 자체가 비즈니스 단위의 feature인가?Form + Table + Submit로직이 하나의 덩어리로 움직일 땐 이걸ProductManagePage같은 단위로 묶는 게 나은가?이 과정에서 "이 파일은 어떤 책임을 가지는가?"라는 질문을 계속 던지게 됐다. 단순히 뷰만 그리는 컴포넌트인지, 아니면 도메인 지식이 들어가는지.
결국 지금은 책임의 구분이 더 중요하다는 걸 느꼈고, 어떤 파일이 어떤 레이어의 역할인지 명확히 하려고 노력 중이다.
formatPrice의 위치
처음엔 단순한 가격 포맷팅 함수라고 생각했는데, 알고 보니 아니다.
이 함수는
productId가 있으면 재고 확인도 하고, 관리자인지에 따라 포맷도 다르게 뿌려준다. 즉, 도메인 정보를 엄청 물고 있다.그래서 이걸 어디 둬야 할까를 두고 엄청 고민했다.
models/price? → 가격 느낌은 맞지만 product, cart 다 들고 오면 애매함productUtils? → 네이밍은 괜찮은데, formatPrice가 utility스럽지 않음domain/product? → 거기 두면 비즈니스 로직처럼 보여서 부담됨결국 이건 비즈니스와 프레젠테이션의 어중간한 사이에 있는 함수였고, 그래서 더더욱 "위치"에 대한 고민이 깊어졌다.
findById도 위치 고민 대상이었다
이건 그냥 배열에서 찾는 건데도, 이걸 어디에 둘지 애매했다.
models에 두기엔 너무 단순하고,utils로 뺄까 싶다가도products라는 데이터 자체에 강하게 의존한다는 생각이 들었음."그럼 결국 이걸 분리할 의미가 있나?"라는 의문에 도달했었고, 지금은 그냥 책임이 명확한 class나 service 구조가 있으면 이런 고민 줄어들겠다는 결론에 가까워졌다.
상태 업데이트와 유효성 검사, 그리고 throw Error
addToCart로직을 구현하면서 가장 처음 든 생각은 단순했다."그냥 setCart 안에서 재고 체크하고 부족하면 throw 하면 되는 거 아냐?"
근데 곧 문제가 생긴다.
setState의 updater 함수는 기본적으로 순수해야 한다고 생각한다.여기서 throw를 던지면 리액트 내부에서 무슨 일이 일어날지 장담 못한다.
게다가, 여기서 예외를 던지면 외부에서 잡기도 애매하다.
→ 결론: ❌ setCart 내부에서는 throw하지 말자.
그럼 외부에서 먼저 검사하고 넘기면 될까?
이건 또 완전히 안전하지 않다.
state batching 이나 여러 탭에서 동시 접근이 있는 상황에서는, 외부에서 본 재고와 실제 상태가 달라질 수 있다.
즉, 검사는 외부에서도 하고, 내부에서도 한 번 더 해야 한다.
이중 체크가 필요한 구조가 됐다.
useTask로 정리한 이유
이렇게 유효성 검사 → 상태 업데이트 → 알림까지 흐름을 구성하고 나면, 부수효과 처리도 애매해진다.
setCart안에서 알림 띄우면 안 되고, 바깥에서 try-catch로 잡자니 에러 핸들링 구조가 지저분하다.그래서
useTask라는 훅을 만들었다.이렇게 작성하니
비즈니스 로직과사이드 이펙트가 분리되는 느낌이라 개인적으로 나쁘진 않아보인다.성공했을 때, 실패했을 때 UI에 어떤 반응을 줄지도 명확히 설정할 수 있고, 재사용성도 높다고 생각한다.
정리
setState내부는 순수해야 하고, side effect는 외부에서 처리하는 게 맞다이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요!
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)