[5팀 허정석] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#28
Conversation
- calculateItemTotal, getMaxApplicableDiscount, calculateCartTotal 함수 분리 - App.tsx에서 import하여 사용하도록 수정 - 순수 함수로 테스트 가능성 향상
- 계산 함수들을 utils/calculators.ts로 이동 - 포맷팅 함수들을 utils/formatters.ts로 이동 - 전역 상태 의존성 제거하고 명시적 매개변수 전달로 변경
- rules 생성 - timeline 작업 내역 생성 - basic 단계별 적용 범위 작성 - 파일 이동
- utils/filters.ts 생성: filterProducts 함수 추가 - types.ts 생성: ProductWithUI, Notification, ProductForm, CouponForm 타입 정의 - App.tsx에서 검색 로직을 순수함수로 분리 - 타입 안정성 확보 및 코드 구조화
- localStorage 관리를 재사용 가능한 Hook으로 분리 - React prevValue 사용으로 순환 참조 문제 해결
- useNotifications Hook으로 알림 시스템 캡슐화 및 재사용성 확보 - useDebounce Hook으로 검색 성능 최적화
- useCoupon Hook으로 쿠폰 상태 관리 로직 분리 - couponService 생성으로 순수 비즈니스 로직 캡슐화 - App.tsx 중복 상태 관리 문제 해결 (selectedCoupon) - 의존성 주입 패턴 적용으로 결합도 감소
|
화이팅!! |
- productService로 순수 비즈니스 로직 분리 - useProducts Hook으로 상품 CRUD 로직 캡슐화 - 수정 버튼 핸들러 오류 수정 (updateProduct → startEditProduct)
- utils/validators.ts 생성 (validatePrice, validateStock) - services/productService.ts에서 검증 로직을 utils로 이동
- useCart Hook 생성 (hooks/useCart.ts) - cartService 생성 (services/cartService.ts) - validators에 검증 로직 추가 (utils/validators.ts)
- 도메인 서비스 패턴 적용 방법 - cartService와 validators 분리 과정 - 의존성 주입과 인터페이스 학습 포인트
- useCart Hook 수정 - selectedCoupon을 인터페이스에 추가하여 의존성 명시 - useEffect에서 selectedCoupon을 사용하여 totals 계산 - 의존성 배열에 selectedCoupon 추가하여 쿠폰 변경 시 재계산 - App.tsx 수정 - useCoupon을 useCart보다 먼저 호출하여 selectedCoupon 선언 - useCart에 selectedCoupon 전달하여 쿠폰 적용된 totals 계산 - 중복된 totals 계산 코드 제거
|
🦍 |
- useUIState Hook 생성: 전역 UI 상태 관리 (isAdmin, activeTab, showCouponForm) - useCouponForm Hook 생성: 쿠폰 폼 상태 관리 - 모든 Hook에 JSDoc 주석 추가 (9개 Hook 완료) - 도메인 경계에 따른 폼 상태 분리 구조 검증 - App.tsx에서 UI 상태들을 Hook으로 분리하여 관심사 분리 완성
- useUIState, useCouponForm Hook 분리 과정 문서화 - 모든 Hook 주석 추가 작업 완료 내용 정리 - 폼 상태 분리 구조 검토 및 도메인 경계 분석 - 데이터 소유권 원칙과 응집성 원칙 적용 사례 - Hook 분리 작업의 최종 완성도 정리
JunilHwang
left a comment
There was a problem hiding this comment.
안녕하세요, 정석님. PR과 상세한 리포트(타임라인/설계/구현 문서들) 잘 정리해주셨네요. 전체적으로 SRP·계층분리·순수함수·훅 추상화를 의도대로 잘 적용하신 게 보입니다. 아래 피드백은 PR의 파일 변경 목록(.cursor, docs, atoms, hooks, package 등)을 바탕으로 작성했습니다. GitHub PR 코멘트로 바로 붙여넣어 사용하실 수 있게 마크다운 형식으로 정리했습니다.
요약 흐름(요청하신 순서대로)
- 기술 요구사항 변화 시나리오 제시
- 현재 코드(AS-IS)가 각 시나리오에 대응하면 어떤 변화가 발생할지
- 이를 바탕으로 현재 코드가 잘 작성되었는지(응집도 · 결합도 관점) 판단과 개선안(TO-BE; 코드 예시 포함)
공통 안내: 여기서 말하는 "응집도"와 "결합도" 정의
- 응집도(사용자 정의 규칙)
- 변경 작업(파일/코드 추가·수정·삭제)의 동선이 얼마나 짧은가 (작업 범위의 국소성)
- 라이브러리(패키지)로 떼어낼 때 매끄럽게 떼어낼 수 있는가 (추출 난이도)
- 결합도
- 모듈/함수/컴포넌트가 직접 내부 구현을 참조하거나 특정 용어·행위(addNotification 등)에 결속되어 있는지
- 인터페이스(onSuccess/onError, DI)를 통해 약하게 결합되어 있는지
간단 판단 기준:
- 응집도 높음: 관련 코드(도메인 모델, 서비스, 순수함수)가 같은 도메인 폴더에 있고 변경 범위가 작음
- 결합도 낮음: 외부 동작(알림, 저장소, 네트워크)을 콜백/의존성 주입으로 분리함
종합 피드백 (키워드 + PR Body 회고 코멘트에 대한 피드백 + 질문 답변)
- PR 파일 분석에서 도출한 핵심 키워드
- Jotai 전환 & atoms (productsAtom, cartAtom, derived atoms, action atoms)
- Hook 분리 (useLocalStorage, useNotifications, useDebounce, useCoupon, useProducts, useCart, useUIState 등)
- 서비스 레이어 (productService, cartService, couponService)
- Utils 분리 (calculators.ts, formatters.ts, validators.ts, filters.ts)
- Middle-Out 컴포넌트 분리 (CouponManagement, ProductManagement 등)
- Packaging 관련 준비 (디렉터리 분리, docs 정리)
- CI/CD 추가 (.github workflow), lint/format 설정 변경
- 결합점: 알림(addNotification)·localStorage·atoms 간 상호참조
- PullRequestBody의 "과제 셀프회고" / "내가 제일 신경 쓴 부분" 에 대한 피드백 (인사이트 중심)
-
잘하신 점
- 아주 체계적으로 단계별(계산→훅→컴포넌트→전역상태)로 리팩토링하셨고, docs/timeline에 디테일을 남겨 둔 점은 훌륭합니다. 이는 유지보수·온보딩에 큰 자산입니다.
- 서비스(도메인) vs Hook(상태) vs Utils(순수함수) 구분이 명확하며 SRP 원칙을 실천한 흔적이 분명합니다.
- Jotai 도입 후 파생 atom(cartTotalsAtom 등)과 액션 atom(addToCartAtom) 사용으로 전역 상태의 계산/변경을 캡슐화한 점은 전역 상태 설계 관점에서 효과적입니다.
-
인사이트(다음으로 확장해볼 만한 생각거리)
- "액션 atom 내부에서 addNotification을 직접 사용" 같은 패턴은 편하지만 결합을 높입니다. 알림 시스템을 변경(예: toast 라이브러리, 원격 로깅)해야 할 때 영향 범위가 커집니다. 액션은 상태 변경 책임만, 외부 부수효과는 콜백으로 분리하는 패턴(의존성 주입 또는 이벤트 버스)을 고려해보세요.
- Jotai atoms를 직접 export하는 방식은 앱단에서는 편하지만, 라이브러리(패키지)로 추출할 때 문제를 만듭니다. 패키지화하려면 atoms 생성 팩토리(factory)를 제공하거나, atoms를 내부의 구현으로 숨기고 public API(혹은 hooks)만 노출하는 방식이 안전합니다.
- docs에 훌륭한 타임라인과 의사결정 논리(왜 split/왜 atom)를 남기셨는데, “변경에 따른 소유권/영향도(누가 무엇을 바꿔야 하는지)”를 더 명시하면 팀 협업에서 훨씬 유용합니다.
-
이어서 질문해볼 만한 몇가지
- 액션 atom이 로컬 side-effect(예: localStorage, notification)를 직접 다루는 현재 패턴에서, 외부 API(서버) 연동이나 로그 추적을 추가한다면 어떤 전략으로 점진적으로 분리하시겠습니까?
- 패키지를 배포(도메인 → npm으로)할 경우 타입/peerDependencies 관리에서 어떤 기준을 두시겠습니까? (예: React 버전, Jotai 포함 여부)
- 테스트 관점에서 action atom을 어떻게 단위 테스트/통합 테스트로 검증할 계획인가요? (현재 tests 스크립트가 존재하므로 테스트 전략 명확화 권장)
- PullRequestBody의 “리뷰 받고 싶은 내용(질문)”에 대한 답변
질문 1) "application/cart/addItemToCart.ts" 같이 “도메인/동사+목적어” 네이밍과 디렉터리 구조가 적절한지, 복잡한 행동의 분기는 어디까지 application/*에 둘지?
- 네이밍(도메인/동사+목적어)은 좋은 컨벤션입니다. "application/cart/addItemToCart.ts"는 use-case(유저 행동)를 잘 표현합니다.
- 복잡한 행동(검증·계산·여러 정책 조합)의 분기 기준:
- 도메인 순수 로직(계산/검증/정책)은 domain/policies 또는 services/*.ts로 둡니다. (순수함수, 테스트 가능)
- application 레이어는 "오케스트레이션"(입력 수집 → 정책 호출 → 상태 변화 → 외부 콜백 호출)을 담당합니다. 즉 복잡한 분기 자체는 domain/policy에서 결정하고, application은 그 결과에 따라 흐름을 수행(사이드이펙트, 트랜잭션 관리 등)하도록 유지하세요.
- 결론: "도메인/동사+목적어" 네이밍은 적절. 복잡한 분기는 domain(정책 + 계산)으로 올리고, application은 orchestration만 담당.
질문 2) "정책 상향 기준" (현재 제안: 재사용 ≥ 2, 순수성, 화면 무관 설명 가능)
- 제안 기준은 좋습니다만 아래 항목들을 추가하면 더 견고합니다:
- 안정성(변경 빈도): 자주 변경되지 않고, 안정적인 비즈니스 규칙일 것
- 테스트 커버리지: 해당 로직을 단위 테스트로 검증 가능한지
- 복잡성: 단순히 재사용되더라도 비즈니스 규칙·도메인 언어로 설명할 수 있을 정도의 복잡성이 있는지
- 의존성: 외부 I/O(네트워크·DOM)에 의존하지 않는 순수함수일수록 상향 우선
- 최종 추천 규칙: (재사용성≥2 OR 도메인 의미가 강함) AND (순수성 OR 테스트 가능성) AND (안정성/비즈니스 중요도 높음) → domain/policies로 승격
상세 피드백 (파일/기능별: 개념 정의 → 문제 → AS-IS → TO-BE 코드)
먼저 다루는 개념 정의(리뷰에서 사용)
- 결합도(coupling): 모듈 간의 의존성 강도. 낮을수록 변경 영향도가 작음.
- 응집도(cohesion): 한 모듈 내부의 구성요소들이 동일 목적을 공유하는 정도. 높을수록 모듈 추출/패키징이 쉬움.
A. 상태관리 라이브러리가 달라지는 경우 시나리오 + 영향 분석
시나리오 1: Jotai → Zustand (local/global state)
- 영향 포인트
- atoms 파일(productAtoms.ts, cartAtoms.ts, couponAtoms.ts, uiAtoms.ts, notificationAtoms.ts)을 Zustand store slices로 변환
- 파생 atom(derived)들은 selector / computed 상태로 다시 작성 필요
- action atoms (addToCartAtom) → zustand actions (store.setState or immer based)
- AS-IS (Jotai)
// atoms/cartAtoms.ts (AS-IS) export const cartAtom = atom<CartItem[]>([]); export const addToCartAtom = atom(null, (get, set, product: ProductWithUI) => { const cart = get(cartAtom); const newCart = cartService.addItemToCart(product, cart); set(cartAtom, newCart); const notifications = get(notificationsAtom); set(notificationsAtom, [...notifications, { id: Date.now(), message: '장바구니에 담았습니다', type: 'success' }]); });
- 문제: addToCartAtom 내부에서 notificationsAtom을 직접 set → cart와 notification이 강하게 결합됨.
- TO-BE (Zustand slice 스타일; 결합 완화)
// stores/useCartStore.ts (TO-BE) import create from 'zustand'; import { cartService } from '../services/cartService'; interface CartState { cart: CartItem[]; addToCart: (product: ProductWithUI, opts?: { onSuccess?: ()=>void, onError?: (err:any)=>void }) => void; } export const useCartStore = create<CartState>(set => ({ cart: [], addToCart: (product, opts) => { try { set(state => ({ cart: cartService.addItemToCart(product, state.cart) })); opts?.onSuccess?.(); } catch (err) { opts?.onError?.(err); } } }));
- 장점: 알림은 호출자(컴포넌트 또는 hook)에서 onSuccess로 처리하게 하여 결합을 낮춤.
시나리오 2: Jotai → TanStack Query
- tanstack-query는 서버/캐시 중심. 로컬 엔티티 상태(폼, UI)엔 적합하지 않음.
- 영향:
- products, cart와 같은 불변 데이터를 서버-동기 캐시로 이동시키면 fetch/invalidates pattern 필요.
- derive 계산(총액) => useQuery + selector (or useMemo)로 변경.
- 권장 아키텍처:
- 서버 소스의 상품 목록: react-query (data-fetching, cache)
- UI state, ephemeral state: local hooks / atom-like store
- 계산유틸(calculators.ts)은 그대로 재사용 가능
시나리오 3: Jotai → Redux
- atoms -> slices + selectors + thunks
- addToCartAtom (action atom) -> thunk action
- TO-BE (Redux thunk)
// cartSlice.ts const addToCart = createAsyncThunk('cart/addToCart', async ({product}, {getState, dispatch}) => { const newCart = cartService.addItemToCart(product, getState().cart); // dispatch notification action separately dispatch(notificationAdded({ message: 'Added', type: 'success' })); return newCart; });
- 선택의 기준: 규모·팀 선호·middleware 필요 여부에 따라 선택
요약 영향표:
- 파생 상태/derived atoms: 각 라이브러리에서 계산/selector로 다시 작성해야 함
- Action atoms: 라이브러리별 action/thunk/slice로 매핑
- Hooks: atoms를 직접 사용하는 훅은 해당 라이브러리의 구독 API로 수정 필요
B. 모듈화·패키지로 배포해야 하는 경우(응집도·결합도 관점)
- 평가 대상: domain(services/models/calculators), hooks, ui-components
- 패키지화 우선순위(권장)
- domain/policies & services & utils (가장 적합)
- 응집도: 높음 (비즈니스 규칙·계산 함수가 한 도메인에 모여 있음)
- 결합도: 낮음 (순수함수, 도메인 모델)
- 포장 방식: npm 패키지로 배포 가능 (peerDependencies: none or minimal)
- TO-BE export API 예시:
// package entry (e.g., @yourorg/cart-domain) export * from './models'; export * from './services/cartService'; export * from './calculators';
- 권장: 패키지 내부에 React/DOM 의존성 두지 말고, purely JS/TS 로 유지하세요.
- UI 컴포넌트 라이브러리 (Button, Notification 등)
- 응집도: 높음 (UI 관련)
- 결합도: 약간 높음(스타일/테마에 따라) → 테마/스타일을 prop으로 주입하도록 설계 필요
- 포장 방식: separate package, peerDependency로 React 요구
- hooks / atoms (앱 전용)
- 응집도: 보통(앱 전용), 결합도: 높음(앱의 atoms, storage, 디자인에 의존)
- 권장: 앱간 공통성이 매우 높지 않다면 패키지화는 신중. 만약 패키지로 만들 경우 factory 패턴 제공 권장:
// TO-BE: factory for atoms/hook rather than singletons export function createProductModule(opts?: { storageKey?: string }) { const productsAtom = atomWithStorage(opts?.storageKey ?? 'products', []); const useProducts = () => { ... useAtom(productsAtom) ... }; return { productsAtom, useProducts }; }
- 이유: singleton atom이 패키지로 배포되면 여러 앱/버전 충돌 위험
- domain/policies & services & utils (가장 적합)
응집도 체크 리스트(당신의 정의 기준으로)
- 변경 동선 짧은가?
- domain/services/utils: 예 (수정 범위가 작음)
- hooks/atoms/components: 혼재된 경우 종종 여러 파일을 동시에 수정해야 해서 동선이 커질 수 있음
- 패키지로 떼어내기 쉬운가?
- domain/utils: 매우 쉬움 (독립성)
- hooks/atoms: atoms를 단일 인스턴스로 export하면 떼어내기 어려움 → factory로 변경 필요
결합도 예시(안 좋은/좋은)
- 안 좋은 예시 (결합 높음):
// useAddProduct 안좋음: addNotification을 내부에서 직접 사용하도록 강제 const useAddProduct = (addNotification) => { ... }
- 좋은 예시 (결합 낮음):
const useAddProduct = ({ onSuccess, onError }) => { ... } // 호출 측에서 알림을 담당하거나 atom을 사용할 수 있다
권장 리팩토링: action atom / hook에서 알림/사이드이펙트 분리
- AS-IS (action atom with notification)
// atoms/cartAtoms.ts export const addToCartAtom = atom(null, (get, set, product) => { const newCart = cartService.addItemToCart(product, get(cartAtom)); set(cartAtom, newCart); set(notificationsAtom, [...get(notificationsAtom), { ... }]); });
- TO-BE (action atom only updates state; notification handled by caller)
또는 DI 방식:
// atoms/cartAtoms.ts export const addToCartAtom = atom(null, (get, set, product) => { const newCart = cartService.addItemToCart(product, get(cartAtom)); set(cartAtom, newCart); }); // Component or hook: const addToCart = useSetAtom(addToCartAtom); const addNotification = useSetAtom(notificationsAtom); const handleAdd = (product) => { addToCart(product); addNotification([...]); };
const addToCart = (product, { notify }) => { // state change notify('success', 'Added'); }
C. 구체적 파일/흐름 기반 상세 피드백 (PullRequestFiles 기반)
(각 항목: 개념 정의 → 문제 → AS-IS → TO-BE 코드)
- atoms/*.ts (productAtoms/cartAtoms/couponAtoms/uiAtoms/notificationAtoms)
- 개념: 전역 상태의 원자(단위) 저장소
- 문제
- 액션 atom이 다른 atom들을 직접 set(get)을 통해 부수작용을 처리하는 경우가 있음(addToCartAtom 내부 알림)
- atoms 직접 export는 앱내 전역싱글톤이 되어 패키지화·테스트 시 제약(전역 상태 충돌)
- AS-IS
export const cartAtom = atom<CartItem[]>([]); export const addToCartAtom = atom(null, (get, set, product) => { const cart = get(cartAtom); const newCart = cartService.addItemToCart(product, cart); set(cartAtom, newCart); // notifications directly written set(notificationsAtom, [...get(notificationsAtom), { id: Date.now(), message: '...'}]); });
- TO-BE
- 분리 전략 1: 액션 atom은 상태 업데이트만, 알림은 호출자에서 처리
- 분리 전략 2: atoms 생성기(factory)로 교체 (패키지 추출 대비)
// factory pattern export function createCartAtoms({storageKey = 'cart'} = {}) { const cartAtom = atomWithStorage<CartItem[]>(storageKey, []); const addToCartAtom = atom(null, (get, set, product) => { const newCart = cartService.addItemToCart(product, get(cartAtom)); set(cartAtom, newCart); }); return { cartAtom, addToCartAtom }; }
- hooks/* (useLocalStorage, useNotifications, useDebounce, useCoupon, useProducts, useCart, useUIState, useCouponForm)
- 개념: 컴포넌트와 도메인/서비스·UI 상태 사이의 API
- 좋은 점: 관심사 분리, 재사용성 확보
- 문제
- 몇몇 훅은 외부 의존성(예: addNotification)을 인자로 받지만 일부 액션 atom과 내부에서 알림을 직접 호출하는 패턴이 섞여 있음 → 혼란
- useProducts/useCart 아직 일부는 props 의존성을 남겨둔 상태(문서에서도 변환 중 명시)
- AS-IS (안좋은 결합 예)
// useCart({ products, selectedCoupon, addNotification }) // addNotification이 hook 내부로 흘러들어옴 -> 호출자 책임 불명확
- TO-BE (명확한 DI, 콜백 사용하는 패턴)
또는 훅 자체는 상태만 제공하고, 컴포넌트가 알림을 처리하게 함:
const useCart = ({ onNotify } = {}) => { const addToCart = useSetAtom(addToCartAtom); const add = (product) => { addToCart(product); onNotify?.({ type: 'success', message: '장바구니에 담았습니다' }); } return { add }; }
const addToCart = useSetAtom(addToCartAtom); const notify = useSetAtom(notificationsAtom); const handleAdd = (product) => { addToCart(product); notify([...]); // 호출자는 결정권을 가짐 };
- services/* (productService, cartService, couponService)
- 개념: 순수 비즈니스 규칙/검증/생성 등의 도메인 로직
- 장점: 재사용성·테스트 용이성(순수함수)
- 문제점: docs에선 잘 분리됐으나 실제 Hook/Atom과 직접 참조 결합이 있을 수 있음 (예: cartService를 action atom 내부에서 직접 사용)
- 권장: 서비스는 순수/독립으로 유지. Hook/atoms은 service를 주입(DI)해서 사용
- TO-BE (DI 사용)
// useProducts.js export const useProducts = (deps = { productService }) => { const addProduct = (p) => { const created = deps.productService.createProduct(p); setProducts(prev => [...prev, created]); } }
- utils/calculators.ts / filters.ts / formatters.ts
- 개념: 순수함수의 집합(계산/format/필터)
- 장점: 응집도가 높고 패키지 추출 시 좋은 후보
- 문제: 없음(현재 상태에서 매우 적절)
- 권장: export된 함수는 도메인 패키지로 그대로 옮겨도 무난함. 타입과 경계(도메인 모델 타입)를 명확히 export하세요.
- docs / workflows / eslint / prettier / package.json 변경
- 장점: 문서화가 매우 훌륭함. CI(Deploy) 추가와 lint/format 설정도 깔끔함.
- 주의
- package.json에 jotai를 dev/dep로 추가하셨는데, 패키지 버전 충돌을 방지하려면 peerDependencies 정책을 문서화하세요(특히 라이브러리로 추출 시).
- eslint.config.js 추가: 기존 .eslintrc 제거하셨는데 lint 규칙이 프로젝트 전체에 적용되는지 CI에서 검증 필요.
D. AS-IS / TO-BE - 구체적인 코드 교정 예시 (요청하신 방식)
- 알림 결합 문제 (안 좋은 예 → 개선)
- AS-IS
// atoms/cartAtoms.ts export const addToCartAtom = atom(null, (get, set, product) => { const cart = get(cartAtom); set(cartAtom, cart.concat(product)); set(notificationsAtom, [...get(notificationsAtom), { id: Date.now(), message: 'Added', type: 'success' }]); });
- 문제: addToCartAtom이 notificationsAtom을 직접 조작. 알림 로직 바뀌면 atom 수정이 필요 → 결합도 ↑
- TO-BE 1 (콜백/호출자에서 알림)
// atoms/cartAtoms.ts export const addToCartAtom = atom(null, (get, set, product) => { const cart = get(cartAtom); set(cartAtom, cart.concat(product)); }); // Component side const addToCart = useSetAtom(addToCartAtom); const addNotification = useSetAtom(notificationsAtom); const handleAdd = (product) => { addToCart(product); addNotification(prev => [...prev, { id: Date.now(), message: 'Added', type: 'success' }]); };
- TO-BE 2 (옵션: 액션 반환값으로 성공/에러)
// addToCartAtom: return boolean (synchronous) export const addToCartAtom = atom(null, (get, set, product) => { try { const newCart = cartService.addItemToCart(product, get(cartAtom)); set(cartAtom, newCart); return { success: true }; } catch (err) { return { success: false, error: err }; } }); // caller const result = await dispatchAddToCart(product); if (result.success) notify('success', 'Added'); else notify('error', result.error.message);
- Hook 인터페이스 개선: 나쁜 예 → 좋은 예
- AS-IS
// useAddProduct bad const useAddProduct = (addNotification) => { ... }
- TO-BE
const useAddProduct = ({ onSuccess, onError } = {}) => { const addProduct = (payload) => { try { // do add onSuccess?.(); } catch (err) { onError?.(err); } } return { addProduct }; }
- atoms 패키지화(추출) - AS-IS vs TO-BE (factory)
- AS-IS (직접 export)
// productAtoms.ts export const productsAtom = atomWithStorage<Product[]>('products', []); export const productFormAtom = atom({ ... });
- 문제: 앱 범위에선 OK. 패키지로 재사용 시 storage key 충돌, singleton 문제.
- TO-BE (factory)
export function createProductModule(opts = {storageKey: 'products'}) { const productsAtom = atomWithStorage<Product[]>(opts.storageKey, []); const productFormAtom = atom({ ... }); const useProducts = () => { // useAtom(productsAtom) 등 } return { productsAtom, productFormAtom, useProducts }; }
- 장점: 여러 앱/테스트 환경에서 인스턴스 생성 가능
E. 종합 결론 및 우선 순위 제안 (실행 가능한 권고)
- 단기(빠르게 적용 권장)
- 분리: Action atom 내부에서 notification set 호출 제거 → 호출자(훅/컴포넌트)에서 알림 처리. (응집도↗ / 결합도↓)
- atoms → factory 패턴 적용: product/cart/coupon atoms들을 createXModule 형태로 감싸기. (패키지 추출 준비)
- hooks: useAddProduct 등의 public API는 ({onSuccess,onError}) 형태로 바꾸기
- 중기(설계 개선)
- atoms/actions 문서화(usage contract) 및 테스트 추가(액션 atom의 동작 검증)
- services를 점검해 application layer가 orchestration만 하도록 명확히 나누기
- 장기(패키지화/라이브러리)
- domain/services/utils를 독립 패키지로 추출. (types, calculators, policies)
- UI 컴포넌트 패키지(선택): Button/Notification 등은 스타일/테마를 prop으로 주입 가능하게 만들어 배포
- hooks/atoms는 앱 전용으로 남기거나, factory 패턴으로 패키지화
우선순위(권장)
- 액션 atom의 알림 결합 제거 (빠르게 적용 가능)
- atoms factory 적용(테스트/패키지 준비)
- hooks API 통일(onSuccess/onError)
- packaging: domain 패키지 추출
마무리 요약 — “잘하셨습니다” + 핵심 권고
- 전체 설계 방향(유틸·서비스·훅·컴포넌트 분리, 중간 수준의 함수분해, Jotai 전환)은 매우 적절합니다.
- 다음으로 집중할 부분은 결합도 낮추기(특히 알림/side-effect)와 패키지화 대비(Atoms factory, API 안정화)입니다.
- 질문에 대한 구체적 답: "도메인/동사+목적어 네이밍"과 "정책 상향 기준" 모두 적절하며, 정책 승격에 테스트 커버·안정성·재사용성·순수성을 추가하는 것을 권합니다.
원하시면:
- (1) 현재 repo의 특정 파일(예: atoms/cartAtoms.ts, hooks/useCart.ts)을 실제 코드 기준으로 리팩토링 PR 패치(구체적 diff)로 만들어 드리거나,
- (2) 도메인 패키지 추출을 위한 리팩토링 가이드(단계별 체크리스트 + 코드는 어떤 파일을 어디로 옮기고 export를 어떻게 바꿀지) 를 작성해 드릴게요.
원하시는 다음 행동을 알려주세요. 어떤 파일(혹은 영역)을 우선 리팩토링해 드릴까요?
|
정석님 고민한 흔적이 멋져요!! |
| } | ||
|
|
||
| // localStorage와 연동되는 atom | ||
| export const cartAtom = atomWithStorage<CartItem[]>('cart', []); |
| export const addToCartAtom = atom(null, (get, set, product: ProductWithUI) => { | ||
| const cart = get(cartAtom); | ||
| const products = get(productsAtom); | ||
| const notifications = get(notificationsAtom); |
| setTimeout(() => { | ||
| const currentNotifications = get(notificationsAtom); | ||
| set( | ||
| notificationsAtom, | ||
| currentNotifications.filter(n => n.id !== id) | ||
| ); | ||
| }, 3000); |
| export const couponFormAtom = atom({ | ||
| id: '', | ||
| name: '', | ||
| code: '', | ||
| discountType: 'amount' as 'amount' | 'percentage', | ||
| discountValue: 0, | ||
| }); |
There was a problem hiding this comment.
| export const couponFormAtom = atom({ | |
| id: '', | |
| name: '', | |
| code: '', | |
| discountType: 'amount' as 'amount' | 'percentage', | |
| discountValue: 0, | |
| }); | |
| export const couponFormAtom = atom<쿠폰폼타입>({ | |
| id: '', | |
| name: '', | |
| code: '', | |
| discountType: 'amount', | |
| discountValue: 0, | |
| }); |
| import { cartAtom, totalItemCountAtom } from '../../atoms/cartAtoms'; | ||
|
|
||
| export const CartIcon: React.FC = () => { | ||
| const [cart] = useAtom(cartAtom); |
There was a problem hiding this comment.
값을 읽기만 할 땐 useAtomValue
변경만 할 땐 useSetAtom 를 쓰는 것도 좋을 것 같아요
| onChange={e => { | ||
| const value = e.target.value; | ||
| if (value === '' || /^\d+$/.test(value)) { | ||
| setCouponForm({ | ||
| ...couponForm, | ||
| discountValue: value === '' ? 0 : parseInt(value), | ||
| }); | ||
| } | ||
| }} | ||
| onBlur={e => { | ||
| const value = parseInt(e.target.value) || 0; | ||
| if (couponForm.discountType === 'percentage') { | ||
| if (value > 100) { | ||
| addNotification( | ||
| '할인율은 100%를 초과할 수 없습니다', | ||
| 'error' | ||
| ); | ||
| setCouponForm({ | ||
| ...couponForm, | ||
| discountValue: 100, | ||
| }); | ||
| } else if (value < 0) { | ||
| setCouponForm({ | ||
| ...couponForm, | ||
| discountValue: 0, | ||
| }); | ||
| } | ||
| } else { | ||
| if (value > 100000) { | ||
| addNotification( | ||
| '할인 금액은 100,000원을 초과할 수 없습니다', | ||
| 'error' | ||
| ); | ||
| setCouponForm({ | ||
| ...couponForm, | ||
| discountValue: 100000, | ||
| }); | ||
| } else if (value < 0) { | ||
| setCouponForm({ | ||
| ...couponForm, | ||
| discountValue: 0, | ||
| }); | ||
| } | ||
| } | ||
| }} |
| {activeTab === 'products' ? ( | ||
| <ProductManagement | ||
| onAddProduct={onAddProduct} | ||
| onUpdateProduct={onUpdateProduct} | ||
| onDeleteProduct={onDeleteProduct} | ||
| onStartEditProduct={onStartEditProduct} | ||
| /> | ||
| ) : ( | ||
| <CouponManagement /> | ||
| )} |
There was a problem hiding this comment.
| {activeTab === 'products' ? ( | |
| <ProductManagement | |
| onAddProduct={onAddProduct} | |
| onUpdateProduct={onUpdateProduct} | |
| onDeleteProduct={onDeleteProduct} | |
| onStartEditProduct={onStartEditProduct} | |
| /> | |
| ) : ( | |
| <CouponManagement /> | |
| )} | |
| {{ | |
| products: <ProductManagement | |
| onAddProduct={onAddProduct} | |
| onUpdateProduct={onUpdateProduct} | |
| onDeleteProduct={onDeleteProduct} | |
| onStartEditProduct={onStartEditProduct} | |
| />, | |
| coupons:<CouponManagement /> | |
| }[activeTab]} |
이렇게 작성해도 좋을 것 같아요 탭이 늘어났을 때 변경이 쉽도록
There was a problem hiding this comment.
지훈님이 말씀하신대로 한다면 이 객체를 따로 변수로 분리하면 좋겠네요
| {activeTab === 'products' ? ( | |
| <ProductManagement | |
| onAddProduct={onAddProduct} | |
| onUpdateProduct={onUpdateProduct} | |
| onDeleteProduct={onDeleteProduct} | |
| onStartEditProduct={onStartEditProduct} | |
| /> | |
| ) : ( | |
| <CouponManagement /> | |
| )} | |
| type TabType = 'products' | 'coupons' | |
| const activeSection: Record<TabType, ReactNode > = { | |
| products: <ProductManagement | |
| onAddProduct={onAddProduct} | |
| onUpdateProduct={onUpdateProduct} | |
| onDeleteProduct={onDeleteProduct} | |
| onStartEditProduct={onStartEditProduct} | |
| />, | |
| coupons: <CouponManagement /> | |
| }; |
There was a problem hiding this comment.
변수로 분리하면 읽는데 왔다갔다해야하니까 이건 어떤가요 (그냥 지훈님 제안도 나쁘진 않아요)
<Switch
value={activeTab}
cases={{
products: (
<ProductManagement
onAddProduct={onAddProduct}
onUpdateProduct={onUpdateProduct}
onDeleteProduct={onDeleteProduct}
onStartEditProduct={onStartEditProduct}
/>
),
coupons: <CouponManagement />
}}
/>| export const useCart = () => { | ||
| const [cart, setCart] = useAtom(cartAtom); | ||
| const [totalItemCount] = useAtom(totalItemCountAtom); | ||
| const [totals] = useAtom(cartTotalsAtom); | ||
| const [products] = useAtom(productsAtom); | ||
| const { addNotification } = useNotifications(); |
There was a problem hiding this comment.
상태 변경을 분리해서 영향을 덜 받도록 만드는 것도 좋을 것 같습니다
There was a problem hiding this comment.
저도 Cart 가 Notifaction을 알고 있는게 이상한 것 같아요.
useCart 에 인자로 onSuccess 와 onError 를 받고 거기서 notification을 처리해보면 어떨까 싶습니다.
| export const useCoupon = () => { | ||
| const [cart] = useAtom(cartAtom); | ||
| const [coupons, setCoupons] = useAtom(couponsAtom); | ||
| const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); | ||
| const { addNotification } = useNotifications(); |
There was a problem hiding this comment.
atom 내부에서 알림을 띄우는 경우도 있어서 (addToCartAtom) 통일시켜 예측 가능하도록 개선할 수 있을 것 같아요
| const [products] = useAtom(productsAtom); | ||
| const { addNotification } = useNotifications(); | ||
|
|
||
| const onAddToCart = useCallback( |
There was a problem hiding this comment.
on 이라고 붙이는 것이 알맞을지 고려해보기
| const roundToInteger = (value: number): number => Math.round(value); | ||
|
|
||
| // 할인 계산 관련 함수들 - 중간 수준 분리 | ||
| const calculateBaseDiscount = (discounts: any[], quantity: number): number => { |
There was a problem hiding this comment.
특정한 도메인을 다루는 함수들은 별도로 분리하는 건 어떨까요?
이 부분은 저도 헷갈리지만 QnA 시간에 들은 내용을 기반으로 하면 함수, 인자 이름을 특정한 데이터 값으로 지정하면 유틸 함수가 아닐수도 있을 것 같아요
|
컴포넌트나 훅 분리가 깔끔한 것 같습니다 |
| {cart.length > 0 && ( | ||
| <span className='absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center'> | ||
| {totalItemCount} | ||
| </span> | ||
| )} |
There was a problem hiding this comment.
CartIcon 컴포넌트는 Icon 관련 로직만 내부에서 처리하는게 좋을 것 같아요. cart, totalItemCount는 외부 상태인데, UI가 지금 외부 상태에 의존하고 있기 때문에 나중에 수정하거나 확장하기 어려워보여요. totalItemCount를 props로 주입받을 수 있도록 하면 좋을 것 같아요.
• 외부 상태 의존 제거: cartCount만 props로 받아서 어디서든 재사용 가능. 스토어 교체/테스트가 쉬워집니다.
• 단일 책임 분리: 아이콘 + 뱃지 렌더링만 담당. 카트 비즈니스 로직은 상위에서 계산해서 넘깁니다.
interface CartIconProps {
cartCount?: number
}
export const CartIcon: React.FC = ({ cartCount = 0 }: CartIconProps) => {
const showBadge = cartCount > 0;
return (
<div className='relative'>
<svg
className='w-6 h-6 text-gray-700'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z'
/>
</svg>
{showBadge && (
<span className='absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center'>
{showItemCount}
</span>
)}
</div>
);
};
| * @returns 쿠폰 관련 상태와 함수들 | ||
| */ | ||
| export const useCoupon = () => { | ||
| const [cart] = useAtom(cartAtom); |
There was a problem hiding this comment.
cart를 참조하는 곳은 전부 useCart()의 반환값만 쓰도록 통일하면 좋겠습니다. 지금처럼 각 컴포넌트가 useAtom(cartAtom)을 직접 참조하면, 이후 cart에 가공/파생 로직이 추가될 때 참조 지점을 전부 수정해야 해요. 반면 useCart 한 곳으로 캡슐화하면 변경이 생겨도 훅 내부만 고치면 되어 유지보수가 훨씬 수월합니다!
| const [coupons, setCoupons] = useAtom(couponsAtom); | ||
| const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); |
There was a problem hiding this comment.
위 코멘트와 동일하게 useCoupon 반환값으로 사용해주는 게 좋겠습니다!
| import { isAdminAtom } from '../../atoms/uiAtoms'; | ||
|
|
||
| export const Header: React.FC = () => { | ||
| const [isAdmin] = useAtom(isAdminAtom); |
There was a problem hiding this comment.
useUIStates 훅에서 반환하는 isAdmin 값을 사용하면 좋을 것 같아요.
| import { isAdminAtom } from '../../atoms/uiAtoms'; | ||
|
|
||
| export const Navigation: React.FC = () => { | ||
| const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); |
There was a problem hiding this comment.
useUIStates 훅에서 반환하는 isAdmin 값을 사용하면 좋을 것 같아요. (2)
| import { useAtom } from 'jotai'; | ||
| import { isAdminAtom } from '../../atoms/uiAtoms'; | ||
|
|
||
| export const Navigation: React.FC = () => { |
There was a problem hiding this comment.
현재 기능만 놓고 보면 전체 내비게이션이라기보다는 “모드 전환 버튼” 성격이 강하니까, 'AdminToggleButton' 같은 좀 더 구체적인 이름이 적절할 것 같습니다. Navigation이라고 하니 컴포넌트가 어플리케이션 전체 네비게이션 역할을 할 것 같아요.
| import { searchTermAtom } from '../../atoms/uiAtoms'; | ||
|
|
||
| export const SearchBar: React.FC = () => { | ||
| const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); |
| import { activeTabAtom } from '../../../atoms/uiAtoms'; | ||
|
|
||
| export const AdminHeader: React.FC = () => { | ||
| const [activeTab, setActiveTab] = useAtom(activeTabAtom); |
There was a problem hiding this comment.
useUIStates 훅에서 반환하는 값을 사용하면 좋을 것 같아요. (3)
| {coupon.discountType === 'amount' | ||
| ? `${coupon.discountValue.toLocaleString()}원 할인` | ||
| : `${coupon.discountValue}% 할인`} |
There was a problem hiding this comment.
저는 jsx 코드는 진짜 UI만 담당할 수 있도록 이런 부분도 최대한 변수로 분리하려고 하는 편인데요. 다른 분들 의견은 어떨지 궁금하네요
| {coupon.discountType === 'amount' | |
| ? `${coupon.discountValue.toLocaleString()}원 할인` | |
| : `${coupon.discountValue}% 할인`} | |
| const discountLabel = | |
| coupon.discountType === 'amount' | |
| ? `${coupon.discountValue.toLocaleString()}원 할인` | |
| : `${coupon.discountValue}% 할인`; | |
| <span className='inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-white text-indigo-700'> | |
| {discountLabel} | |
| <span> |
| onSubmit, | ||
| onCancel, | ||
| }) => { | ||
| const [couponForm, setCouponForm] = useAtom(couponFormAtom); |
There was a problem hiding this comment.
couponForm 데이터는 CouponForm에서만 쓰일 것 같은데 전역 상태로 구현하신 이유가 궁금합니다.
There was a problem hiding this comment.
JSX 내부에 이벤트 핸들링 로직들은 핸들러 함수로 따로 분리해주는 게 좋을 것 같아요.
| if (isAdmin) { | ||
| return `${price.toLocaleString()}원`; | ||
| } | ||
|
|
||
| // 테스트에서 기대하는 형식으로 변경 | ||
| return `${price.toLocaleString()}원`; |
| import { useCouponForm } from '../../../hooks/useCouponForm'; | ||
|
|
||
| export const CouponManagement: React.FC = () => { | ||
| const [coupons] = useAtom(couponsAtom); |
| export const getRemainingStock = ( | ||
| product: Product, | ||
| cart: CartItem[] | ||
| ): number => { | ||
| const cartItem = cart.find(item => item.product.id === product.id); | ||
| const quantityInCart = cartItem?.quantity || 0; | ||
| return product.stock - quantityInCart; | ||
| }; |
There was a problem hiding this comment.
product의 속성인 stock을 계산하는 것이기 때문에 product 도메인 관련된 부분에서만 사용될 것 같아요. 그래서 모든 곳에서 공용으로 쓰일 수 있는 로직을 포함하는 util보다는 productService에 넣으면 좋을 것 같습니다.
| const hasBulkPurchase = (cart: CartItem[]): boolean => { | ||
| return cart.some(cartItem => cartItem.quantity >= BULK_PURCHASE_THRESHOLD); | ||
| }; |
There was a problem hiding this comment.
계산 유틸보다는 cart 도메인에만 해당되기 때문에 cartService에 넣으면 좋을 것 같아요.
과제의 핵심취지
과제에서 꼭 알아가길 바라는 점
기본과제
Component에서 비즈니스 로직을 분리하기
비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기
뷰데이터와 엔티티데이터의 분리에 대한 이해
entities -> features -> UI 계층에 대한 이해
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
특정 Entitiy만 다루는 함수는 분리되어 있나요?
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
재사용 가능한 Custom UI 컴포넌트를 만들어 보기
재사용 가능한 Custom 라이브러리 Hook을 만들어 보기
재사용 가능한 Custom 유틸 함수를 만들어 보기
그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기
UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
과제 셀프회고
과제 배포
이번 5주차 과제는 지난 회고 에서 언급했던 Try 부분과 지난 주차에서도 사용했던 history 를 남기는 방식을 함께 진행해봤습니다.
과제 진행 요약
utils/*, 도메인 로직은services/*, 상태·흐름은hooks/*, 렌더링은components/*타임라인
08/04 기본 리팩터링
utils/calculators.ts(아이템 합계, 최대 할인, 카트 합계)utils/filters.tstypes.ts(뷰/엔티티 타입 구분)08/05 훅/서비스 분리
useLocalStorage,useNotifications,useDebounceuseCoupon+couponService,useProducts+productService,useCart+cartService/validatorsuseUIState,useCouponForm(소유권 충돌 해결)08/06 컴포넌트 분리(Middle-Out)
CouponManagement→CouponForm/CouponCard/AddCouponButtonProductManagement→ProductForm/ProductTable/AddProductButtoncomponents/pages/admin/*,components/pages/shopping/*App.tsx간소화 계획 수립08/08 고급 단계(Jotai)
atoms/*(cart/product/coupon/ui/notification)cartTotalsAtom), 액션 atom 적용내가 설정한 커서 룰
description: 'AI 강사 역할 및 프로그래밍 가이드라인 - React, TypeScript, 클린코드 교육을 위한 7가지 원칙'
globs: ['src//*.tsx', 'src//*.ts']
alwaysApply: true
AI Instructor Role and Programming Guidelines
Role
7 Principles of AI Programming
Verification and Understanding: Don't blindly trust AI results. Always verify them. Ask yourself: "Why does this solution work? What are its limitations?"
Maintain Fundamentals: Don't lose basic coding skills. Set aside one "No-AI Day" per week for manual coding mode.
Self-Directed Learning: Try on your own before depending on AI. Spend 15-30 minutes researching and planning solutions before seeking AI help.
Quality Management: Review AI code like human code. Foster a culture where "AI creates drafts, but ownership belongs to us."
Active Learning: Don't just receive answers, understand them. Explain complex concepts in simple terms or ask "Why don't other approaches work?"
Knowledge Management: Record and improve areas where AI helped. Follow the principle that "AI should be the last resort."
Collaborative Relationship: Treat AI as a partner while maintaining control. Use AI as a pair programming partner, not just a query tool.
Educational Principles
React Education
TypeScript Education
Clean Code Education
Conversation/Explanation/Feedback/Code Review Standards
My Coding Style
My coding style favors simplicity and clarity.
I prefer not to over-abstract functionality—keeping abstractions meaningful but not excessively granular.
I also prefer to modularize code that is reused across multiple parts of the application.
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
파일, 함수, 컴포넌트가 딱 한 가지 역할만 하도록 나누자!!!
UI(컴포넌트) ↔ 상태/이벤트(훅) ↔ 도메인 로직(서비스/유틸)의 경계를 명확히 하고, 전역 상태는 Jotai로 일원화해 Props Drilling을 제거
productService,cartService,couponService로 비즈니스 로직을 컴포넌트 밖으로 이동 시켰습니다.useProduct,useCart등으로 상태.절차 캡슐화를 하고 의존성 주입으로 결합도를 축소했습니다.CouponManagement,ProductManagement처럼 복잡한 영역부터 점진적으로 분리했습니다.과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
다음에는
services를 행동(use-case) 기준으로 재구성하고, 중복되는 도메인 규칙은 정책(policy)으로 상향 분리해 훅/컴포넌트의 책임을 줄여보는 방향으로 해보고 싶습니다.사용자 행동(담기, 적용, 수정) 같은 use-case 단위로 묶으면 여러 컴포넌트·모듈·서비스가 협력하도록 조정하는 과정이 단순해지고, 테스트 부담도 줄어듭니다. 행동 레이어는 입출력과 흐름만 담당하고 로직은 도메인에 집중하므로, 책임 위치가 명확해져 유지보수가 더 용이해집니다.
그리고 아래는 공통 로직을 상위로 만들었을 때의 예시 구조입니다. (AI 활용)
예시 기준
domain/policies/*shared/*application/*에 유지주의점
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
위 예시 구조의
application/cart/addItemToCart.ts처럼 “도메인/동사+목적어” 네이밍과 디렉터리 구조가 적절한지 또는 다른 컨벤션 예시가 궁금합니다.“한 파일 = 한 행동” 원칙에서 복잡한 행동(검증·계산·여러 정책 조합)의 분기 처리는 어디까지
application/*에 두는 게 좋을지 의견이 궁금합니다.“재사용 ≥ 2, 순수성, 화면 무관 설명 가능”이면
domain/policies/*로 승격한다는 기준이 충분할까요? 더 추가/수정할 항목이 있을까요?