Skip to content

[1팀 김휘린] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#38

Open
Hwirin-Kim wants to merge 43 commits intohanghae-plus:mainfrom
Hwirin-Kim:main
Open

[1팀 김휘린] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#38
Hwirin-Kim wants to merge 43 commits intohanghae-plus:mainfrom
Hwirin-Kim:main

Conversation

@Hwirin-Kim
Copy link

@Hwirin-Kim Hwirin-Kim commented Aug 5, 2025

과제의 핵심취지

  • React의 hook 이해하기
  • 함수형 프로그래밍에 대한 이해
  • 액션과 순수함수의 분리

과제에서 꼭 알아가길 바라는 점

  • 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup
  • 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct()
  • 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등
  • 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str)

배포 링크

https://hwirin-kim.github.io/front_6th_chapter2-2/

기본과제

  • 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는 잘 제거했나요?

  • 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요?

과제 셀프회고

이번 과제는 쉽다고 느꼈지만 결론적으로 썩 맘에드는 결과물은 나오지 않아서 좀 아쉽다.
일단 첫 시작은 cart, coupon, proudcts를 단순 훅으로 분리하는 작업을 진행했고
그 다음에 UI를 나누는 작업을 진행했다.
사실 나는 Hint폴더가 있다는걸 모르고 진행해서 좀 엉망진창으로 만들어진 느낌이 강했다.

그러다 같은 조원인 아름님의 코드를 보게 되었는데, model에서 깔끔하게 비즈니스로직을 작성하고 hook에서는 모델에서 만든 함수를 가져다가 상태 변경만 하는 깔끔한 구조를 보고 나도 그런 식으로 바꿨다.

근데 그렇게 하다보니 hooks에서 깔끔한 상태변경만 하고 싶은데, addNotification이라는 토스트 알림 함수가 존재하여 "이렇게 여러 일을 해도 되나..?" 하는 생각이 들었다.

그래서 나는 handler들을 모아둔 Hook을 하나 더 만들게 된다.
이 핸들러훅에서 비로소 컴포넌트로 전달될 함수가 탄생하게 된다.

그래서 데이터 흐름이 어떤 방향이 되었냐 하면..

User Interaction
      ↓
UI Components (pages/components)
      ↓
useAppCore (앱단에 집중된 훅)
      ↓
Handler Hooks (hooks/useXXXHandlers.ts)
      ↓
Entity Hooks (entities/useXXX.ts)
      ↓
Models (entities/xxx.model.ts)
      ↓
순수함수 & State Updates

이런 방식을 가게 되었다.

그리고 심화과제는 기본과제와 똑같은 구조에서 Jotai를 통해 프롭스를 제거해줬다.
따라서 심화과제의 데이터 흐름은 다음과 같다.

User Interaction
      ↓
UI Components (pages/components)
      ↓
Jotai Atoms (전역 상태 직접 접근)
      ↓
Entity Hooks (Jotai 기반 상태 관리)
      ↓
Models (순수 비즈니스 로직)
      ↓
순수함수 & State Updates

즉, 중앙에 집중된 훅들과 핸들러를 통하지 않고 곧바로 전역에 뿌려진 아톰에 접근하는 것이다.

이번 과제에서 프롭스가 너무 많으면서도 깊게 뻗어있어서 Jotai를 적용 후 프롭스를 일부 걷어내다가.. 나머지는 그냥 AI에게 맡겼다.
역시나 이 부분은 간단하고 명확한 작업이라서 금방 해줬다.

과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?

역시나 제일 신경 쓴 부분은 테스트였다.
뭐 하나 수정하면 테스트 진행이 1순위이다.

테스트를 제외하고 가장 크게 신경쓴 부분은 코드의 관심사 분리였다.
그러나 이번 과제에서의 내 코드가 그렇게 까지 잘 분리된것같지 않는다.

나는 일단 model이라는 파일에 일종의 액션함수를 작성했고, 엔티티커스텀훅 내부에서 상태를 만들고 model의 액션함수들을 가져다가 상태를 업데이트할 수 있도록 만들었다.

그리고 그 상태변경 함수들에 조건별 알림처리 등이 있는것이 맘에들지 않아서 핸들러 훅을 정의하고 핸들러 훅 내부에서 조건별 알림처리등을 진행했다.

나름 모델은 액션함수만 쓸거야, 엔티티훅은 상태변경만 다룰거야, 핸들러훅은 기타 다른 동작들을 섞어줄거야 하는 분기가 담겼다.

하지만 뭔가 이게 더 복잡성만 증대시킨것이 아닌가..? 하는 생각도 들었다. 어차피 model에서 상태가 어떻게 변할지 예측이 가능하므로, 엔티티훅이라고 만든 상태변경훅에서 핸들러 훅이 하던 역할을 같이 해주면 굳이 핸들러 훅을 만들지 않아도 되기 때문이다. (물론 그러면 하나의 훅이 덩치가 약간 증가되므로 안좋을것같기도 하지만...)

결과적으로 약간 가독성이 좋지 않은 코드가 되었긴 하지만 내가 중점을 뒀던 부분은 각 코드가 가진 역할의 분리였다..!!

과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?

준일코치님께 네임스페이스에 관한 피드백을 받았는데, 너무나도 충격이였다.
사실 피드백을 받는 시점에 내 코드의 가독성이 매우 안좋았는데, 나는 그것이 코드의 구조가 잘못되어 그렇게 보이는것이라고 생각하고 있었다.

하지만 준일코치님은 네임스페이스에 관한 조언을 해주시며 내 코드를 수정하기 시작했는데, 같은 코드임에도 어떻게 사용하느냐가 정말 큰 가독성 차이를 가져온다는것을 알게 되었다.

그래서 피드백이 끝난 후 곧바로 적용해봤지만...
아직은 서툴고 새벽에 급한 마음으로 수정해서인지 내가 충격을 받았던 그만큼의 깔끔함은 나오지 않았다.

이 부분을 처음부터 알고 적용했더라면 더 깔끔한 구조가 되지 않았을까..? 하는 의문이 들면서 앞으로도 그 방식을 사용해 연습 해볼 예정이다.

리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)

위에도 언급했던 내용인데,
저는 model, entitiy hook, handler hook 세 가지 계층을 나눴습니다.

모델에서는 순수함수로만 구성하면서 도메인별 비즈니스 로직을 담당하고,

엔티티 훅에서는 상태관리에 초점을 두면서 실패나 성공등의 리턴이 필요할 수 있다고 생각이 들어 Result 값을 반환했습니다.

// result 값
{
 success: true,
 message: MESSAGES.SUCCESS.PRODUCT_ADDED,
 type: "success",
}

핸들러 훅에서는 사이드 이펙트 관리를 하며 엔티티와 UI컴포넌트간의 연결다리 역할을 했습니다.

그런데 이 핸들러 훅은 엔티티 훅에서도 해줄 수 있는 역할이라고 생각이 듭니다. 이미 비즈니스 로직은 명확하게 모델에서 분리하였고, 상태를 관리하는 곳에서 어떤 사이드 이펙트나 결과에 관한 UI 로직을 바로 처리하면 더 직관적이지 않나? 라는 생각입니다.

그래서 코치님께서 이 과제를 진행하셨다면 어느정도까지 분리하여 쓰실지, 현실적으로 "여기까지 분리하면 충분하다!" 는 기준이나 실전 팁이 있으신지 너무 궁금합니다..!!

Copy link

@JunilHwang JunilHwang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 과제 잘 진행하셨습니다 — 구조화, 테스트, 관심사 분리에 신경을 많이 쓰신 점이 인상적입니다. 아래 리뷰는 Pull Request의 변경사항들(index.advanced.html, App.tsx 리팩토링, Jotai 도입(atoms), 컴포넌트 분리, 테스트 수정 등)을 바탕으로 “기술 요구사항 변화 시나리오 → 각 시나리오에 따른 코드 변화 예상 → 응집도/결합도 관점에서 평가” 흐름으로 작성했습니다. PR 코멘트로 바로 남기실 수 있도록 마크다운 형식으로 정리했습니다.

주의: 제 코멘트는 코드의 일부를 참조해 개선안(AS-IS → TO-BE) 예시를 제공합니다. 실제 리팩토링 시 프로젝트 전체 컨텍스트(존재하는 훅/모델/유틸 등)를 고려해 적용해주세요.


요약(한줄)

  • 좋은 점: 모델/훅/핸들러/컴포넌트 계층을 나누려는 시도, 테스트 우선 접근, Jotai로 전역 상태 정리, 레이아웃/아이콘/섹션 컴포넌트로 분리한 구조.
  • 개선 포인트: 알림/사이드이펙트와 도메인 상태 결합, atomWithStorage 직접 사용으로 테스트 및 패키지화 어려움, 훅 인스턴스/인터페이스 일관성(콜백 주입 패턴) 개선 여지.

질문에 대한 답변(핵심)

  1. model / entity hook / handler hook 분리에 대한 생각(“어느정도까지 분리하면 충분한가?”)
  • 인사이트:
    • 모델(model) : 순수 비즈니스 로직(계산, 검증)을 캡슐화 — 변경 빈도 낮음, 재사용성 높음.
    • entity hook : 상태 보관(혹은 Atom/Store 래핑)과 순수 모델 함수를 호출해 상태를 변경. 가능하면 “부수효과 없음(=알림, 로깅, 라우팅 등)” 을 유지.
    • handler hook : UI/도메인 간 조정, 알림/트래킹/전환 등 사이드이펙트를 담당.
  • 실무 팁(실용적 기준):
    • “모델 → 상태 업데이트”는 같은 책임이며, 이 두 단계를 분리하는 이유는 테스트와 재사용성임. 모델은 완전한 순수함수가 될수록 좋음.
    • handler는 UI-특화 로직(예: addNotification 호출, 라우팅, Modal 제어)를 포함. handler가 없다면 entity hook에 onSuccess/onError 콜백을 노출하여 호출자(UI)가 처리하도록 함.
    • 규칙 예시:
      • 모델: 순수함수(입력 → 출력), 테스트 100% 커버 권장.
      • entity hook: 상태 get/set + 모델 호출, 반환 값은 Result 객체(성공/실패 + payload/message).
      • handler hook: UI 용 사이드이펙트(알림, focus, 라우트 전환). handler 내부에서 entity hook의 action을 호출하고, 결과에 따라 onSuccess/onError를 실행.
  • 결론(권장 수준): 현재 구조(모델 / entity hook / handler hook)는 합리적. 다만 handler 역할이 중복되거나 과도하게 많아지면 역으로 복잡성이 커집니다. 실무에서는 "핵심 도메인 상태 + 모델"은 패키지화(entities로 추출), handler는 애플리케이션(또는 feature) 레이어로 두는 편이 안정적입니다.

질문에 대한 구체적 권장 인터페이스

  • 안좋은 방식(현재 PR에서 발견된 anti-pattern):
    • 생성 시점에 addNotification 같은 UI 콜백을 주입: const useAddProduct = (addNotification) => { ... }
  • 권장 방식:
    • Hook은 액션 호출 시 콜백을 받음:
      • productActions.add(product, { onSuccess, onError })
    • 또는 상태 업데이트는 Result 반환: const result = productActions.add(product); if (!result.success) onError(result)

질문 마무리(추가 질문 제안)

  • handler 훅의 주된 목적은 무엇인가요? (UI-사이드 이펙트만? 트랜잭션 동작(복수 훅 호출)도 담당?)
  • 실제 배포/패키징 시 entity 훅들을 외부에서 어떤 API로 사용하고 싶으신가요? (hook 기반 그대로? 함수형 액션만?)

종합 피드백 (PR 파일 분석 요약 키워드)

  • 키워드: Jotai(전역 atom), 모델-훅-핸들러 계층화, localStorage(atomWithStorage), 테스트 격리(Provider), 네임스페이싱(바렐), UI/컴포넌트 분리(레이아웃/섹션), 알림(Notifications) 결합, 패키지 분리/모듈화 준비성
  • 긍정 포인트:
    • 앱 구조(Layout/Header/Body, AdminPage/CartPage 분리)로 가독성 향상.
    • Notification UI를 별도 컴포넌트로 분리.
    • 테스트를 Jotai Provider로 감싸 격리시킴 (renderWithProvider).
  • 개선 포인트 요약:
    • 알림/사이드이펙트(Notifications)와 도메인 훅들의 결합이 종종 강함(핸들러 생성 시 addNotification 주입).
    • atomWithStorage 직접 사용으로 테스트/패키지화 시 로컬 스토리지 종속성 발생.
    • 동일 책임 영역의 파일들이 산재(예: handler 훅이 여러 컴포넌트에 흩어짐) — 응집도를 더 올릴 필요.
    • “훅 생성/사용 방식의 일관성”(action signature)을 통일하면 재사용성↑, 테스트 편의성↑.

PullRequestBody의 셀프회고에 대한 피드백 (인사이트, 추가 질문)

  • 당신의 회고 핵심(요약): model(순수함수) / entity hook(상태) / handler hook(사이드이펙트)로 계층 분리 시도 — 좋은 접근. 다만 핸들러 훅이 더 복잡도를 초래할까 하는 고민.
  • 인사이트:
    • “분리 그 자체”가 목적이 되면 오히려 복잡화될 수 있음. 목적은 변화에 대한 격리(변경 범위를 국소화)와 재사용성 증가입니다. 즉 “왜 분리하는가?”(테스트, 재사용, 교체 용이성)를 각 계층에 대해 명확히 하세요.
    • 핸들러를 둔 이유가 ‘UI 알림/네비게이션/복수 훅 조정’이라면 남겨도 괜찮습니다. 다만 핸들러가 단지 "addNotification 호출"만 한다면 entity hook으로 흡수하고, entity hook의 액션에서 onSuccess/onError 콜백을 받도록 변경하는 것이 더 단순합니다.
  • 추가 질문(스스로 고민해볼 거리):
    • “핸들러 훅이 없을 때”와 “핸들러 훅이 있을 때”의 변경 시나리오(예: notification API 변경)를 비교해보셨나요? 어느 쪽이 변경 동선이 짧았는지?
    • 특정 기능(상품 추가/쿠폰 적용 등) 변경 시 몇 개의 파일을 열어야 했나요? (응집도/결합도 체감 지표)

PullRequestBody의 "리뷰 받고 싶은 내용"에 대한 답변(정리)

  • 요점: 모델/엔티티 훅/핸들러 훅을 어느 수준까지 분리할지 물으셨음.
  • 권장 패턴(현실적 기준):
    1. 모델은 항상 순수 함수(도메인 규칙 및 계산). (패키지화 우선 후보)
    2. 엔티티 훅: 상태 + 모델 호출 + Result 반환(성공/실패). 엔티티 훅은 UI 콜백(addNotification)을 몰라야 테스트가 쉬움.
    3. 핸들러 훅: UI 관련 사이드이펙트(알림, 모달, 라우팅)를 묶는 위치. handler 내부에서 entityAction(...).then(onSuccess) 패턴 권장.
  • 규칙 예시:
    • entityAction signature: async function addProduct(product, opts?: { onSuccess?: Fn, onError?: Fn }) => Result
    • handler 사용 예: handler.addProduct(product) { const res = await entity.addProduct(product); if (res.success) addNotification(...) }

상세 피드백 (개념 정의 → 문제정의 → AS-IS → TO-BE)

먼저 개념 정의 — "응집도"와 "결합도" (귀하가 제시한 기준을 포함해 명확히)

  • 응집도(cohesion) 정의(리뷰 내 규칙)
    • 같은 책임(기능)이 모여있는 정도. 응집도가 높으면 기능 변경 시 수정해야 하는 파일/코드의 이동 경로가 짧다.
    • 실용적 규칙(귀하의 정의 반영):
      • 변경에 필요한 파일/코드 추가·수정·삭제의 “동선”이 얼마나 짧은가.
      • 패키지로 떼어낼 때(entities 패키지화) 매끄럽게 떼어낼 수 있는가.
  • 결합도(coupling) 정의
    • 모듈 간 의존성(특히 내부 구현에 대한 의존)이 얼마나 약한가. 낮을수록 좋다.
    • 좋은 결합 방식: 공개된 인터페이스(함수 인자, 이벤트, Result 객체)를 통해 의존을 줄임. (예: onSuccess/onError, Result패턴)
    • 나쁜 예: 구현(함수명 addNotification 등)에 의존해버리는 경우.

문제 항목 1 — Notifications / Side-effect 결합

  • 문제 정의
    • 많은 훅/핸들러에 addNotification(또는 addNotification을 가진 훅)을 주입(또는 직접 호출)하고 있습니다. 구성요소들이 알림 로직에 강하게 결합되어 있어, 알림 API를 바꾸거나 UI를 바꾸면 여러 훅과 컴포넌트를 수정해야 합니다.
  • AS-IS (예시)
    • 사용 형태(anti-pattern):
      // bad
      const useProductHandlers = ({ addNotification }) => {
        const add = (product) => {
          // 상태 변경
          addNotification('추가됨', 'success');
        };
        return { actions: { add } };
      };
  • 문제점
    • addNotification의 서명/동작이 바뀌면 모든 훅 호출부 수정 필요.
    • 테스트에서 addNotification을 모킹해야 하므로 설정이 번거로움.
  • TO-BE (권장)
    • entityAction이 Result를 반환하게 하고 호출자(핸들러나 컴포넌트)가 알림을 발생시키는 방식:
      // entity hook
      const useProduct = () => {
        const add = async (product) => {
          // 상태 변경
          return { success: true, message: '상품 추가됨' };
        };
        return { add };
      };
      
      // handler 또는 component
      const useProductHandlers = () => {
        const product = useProduct();
        const addNotification = useNotificationHook(); // UI 레이어
        const add = async (productData) => {
          const res = await product.add(productData);
          if (res.success) addNotification(res.message, 'success');
        };
        return { add };
      };
    • 또는 entity action에 선택적 콜백을 노출:
      product.add(productData, { onSuccess: () => addNotification(...), onError: () => ... })
  • 기대 효과
    • 알림/로깅 정책을 중앙에서 바꿀 수 있음.
    • entity 훅은 순수 상태 관리에 집중 — 테스트 쉽고 패키지화 가능.

문제 항목 2 — atomWithStorage 직접 사용(로컬 스토리지 종속) → 테스트/패키지화 취약

  • 문제 정의
    • atoms/index.ts에서 atomWithStorage(...)를 직접 사용하여 로컬 스토리지에 강하게 결합. 패키지로 떼어내면 브라우저 환경(또는 storage API)이 없을 경우 문제가 생기고, 테스트 환경에서 초기값을 주기 어렵거나 공유 상태가 누적될 수 있음.
  • AS-IS
    // atoms/index.ts (현재)
    export const productsAtom = atomWithStorage<ProductWithUI[]>("products", initialProducts);
  • TO-BE (의존성 주입형, 테스트 친화적)
    • 팩토리 함수로 만들고 storage를 주입:
    // atoms/createProductsAtom.ts
    export const createProductsAtom = (storage = localStorage) =>
      atomWithStorage<ProductWithUI[]>("products", initialProducts, { getItem: k => storage.getItem(k), setItem: (k,v) => storage.setItem(k,v) });
    
    // 앱 초기화 시
    const productsAtom = createProductsAtom(window.localStorage);
    // 테스트 시
    const productsAtom = createProductsAtom(new InMemoryStorage());
    • 또는 atoms는 순수 상태만 노출하고, storage 싱크는 별도의 sync-layer에서 담당:
      • productsAtom (plain atom)
      • productsStorageSyncAtom(effect) : 앱 루트에서 storage 동기화 effect를 등록
  • 기대 효과
    • 테스트에서 localStorage를 모킹할 필요 없이 InMemoryStorage 주입으로 간편하게 격리.
    • 패키지로 분리할 때 브라우저 전용 코드를 피할 수 있음.

문제 항목 3 — 훅/핸들러가 컴포넌트에서 직접 생성되는 패턴(중복 가능성)

  • 문제 정의
    • 많은 UI 컴포넌트에서 "useXHandlers/useXxx" 훅을 직접 import·사용해 핸들러 로직이 각 컴포넌트 내부에 중복 생성됩니다. 규모가 커지면 동일한 동작(예: addToCart 후 알림, analytics)들이 여러 위치에 흩어질 수 있음.
  • AS-IS 예
    • ProductListSection, CartSection, Admin components 각자 useXHandlers를 import하여 사용.
  • TO-BE 제안
    • Domain-level 훅(예: useCart)이 “상태 + actions”을 제공. UI는 이 액션을 호출하고, 공통 사이드이펙트(analytics, notification)는 app-level middleware/handler에서 구독하거나, hook에 옵션으로 주입:
    const useCart = () => {
      const add = (product, opts?: { notify?: boolean }) => { ... }
      return { add };
    };
    • 또는 handler 레이어를 하나로 모아 라우팅/알림/analytics만 수행.

문제 항목 4 — 네임스페이싱/바렐(인터페이스) 개선 필요

  • 문제 정의
    • 여러 컴포넌트들이 "useProductHandlers/useProductForm/useProductUtils/useProductHandlers" 등 다양한 훅을 import. 패키지화 시 단일 진입점(index.ts)과 네임스페이스를 제공하면 소비자 관점에서 API 사용성이 좋아짐.
  • TO-BE 예시
    • entities/products/index.ts
      export * as ProductModel from './product.model';
      export * as ProductHooks from './hooks';
      export * from './product.types';
    • 사용
      import { ProductHooks } from 'entities/products';
      const { useProductForm } = ProductHooks;
  • 기대 효과
    • 내부 구조 변경(파일 이동) 시 외부 임포트 변화 최소화 → 응집도↑, 결합도↓

문제 항목 5 — Jotai → 다른 상태관리(lib) 변경 시 시나리오와 대응

  • 시나리오 A: Jotai → Zustand (local client state)

    • 영향:
      • atoms → zustand store로 마이그레이션: API 변화(선언 방식), Provider 필요성은 줄음(전역 hook 사용).
      • components: useAtomValue/isAdminAtom → useStore(state => state.isAdmin)
      • tests: Provider 제거하거나 createStore로 테스트 격리 가능.
    • 예상 변경 포인트:
      • src/advanced/atoms/.ts -> src/advanced/stores/.ts
      • useX hooks : useAtom -> useStore selector 호출로 변경
    • 예시 변환(제품):
      AS-IS (Jotai)
      export const productsAtom = atomWithStorage('products', initialProducts);
      const products = useAtomValue(productsAtom);
      TO-BE (Zustand)
      // store.ts
      import create from 'zustand';
      export const useProductsStore = create(set => ({
        products: initialProducts,
        setProducts: (updater) => set(state => ({ products: typeof updater === 'function' ? updater(state.products) : updater }))
      }));
      // 사용
      const products = useProductsStore(state => state.products);
      const setProducts = useProductsStore(state => state.setProducts);
    • 장점: 간단하고 테스트하기 쉬움, 로컬 상태 중심일 때 성능 유리.
    • 단점: Jotai atomic composition(상태 분리) 같은 세밀한 제어는 직접 구현해야 함.
  • 시나리오 B: Jotai → Redux (RTK)

    • 영향:
      • atoms → slices, selectors, useSelector/useDispatch로 대체.
      • 테스트: redux mock store or provider 사용.
    • 예시(Slice)
      // productsSlice.ts
      const productsSlice = createSlice({
        name: 'products',
        initialState: initialProducts,
        reducers: { addProduct(state, action){ state.push(action.payload) } }
      });
      export const { addProduct } = productsSlice.actions;
    • 장점: 대규모 앱에서 표준화/도구(DevTools) 지원 강함.
    • 단점: 보일러플레이트(하지만 RTK로 완화), 기존 atom 분해 수준의 세밀함을 재구성해야 함.
  • 시나리오 C: Jotai → TanStack Query (데이터 패칭 / 캐싱)

    • 영향:
      • 제품 목록처럼 원격 데이터(서버)라면 TanStack Query로 이동 → 쿼리 기반으로 캐시와 동기화(서버-클라이언트 유실).
      • Cart같이 순수 클라이언트 엔티티는 TanStackQuery와 적합하지 않음 — zustand/atom 등을 병행 사용 권장.
    • 권장 패턴:
      • Server-data: TanStack Query
      • Client-only business: Zustand / local atoms
      • 통합: queryClient + local store(또는 Jotai)로 혼용

문제 항목 6 — 패키지화(모듈화) 관점: 응집도/결합도 점검

  • 문제 정의
    • 만약 entities/* 를 별도 패키지로 떼어낼 경우, 현재 어떤 것들이 함께 떼어져야 하고 무엇이 남아야 하는지를 판단해야 합니다.
  • 현재 결합 파악(간단 목록)
    • entities/products : product.model, useProductForm, useProductHandlers, useProductUtils, product.constants, product.types
    • 종속 : notificationsHook(앱 레이어), AdminPage(앱 UI)
  • 추출 기준(권장)
    • 내부에서만 순수하게 도메인 동작(계산, validation, CRUD-ish state)을 처리하는 것들은 entities 패키지에 넣음(= 응집도 높음).
    • Notifications/UI/Router 연관 코드는 앱 레이어에 남기고, entities는 “hook + action(옵션 콜백)”으로 노출.
  • TO-BE: 패키지 엔트리 구조(예)
    packages/entities-products/
      src/
        index.ts          // export model, types, hooks (but no direct UI hooks)
        product.model.ts
        product.types.ts
        product.constants.ts
        hooks/
          useProductsStore.ts // pure state wrapper or udenpendant
          useProductActions.ts // returns actions but does not call UI
      README.md
    
  • 기대 효과
    • entities 패키지는 UI에 무관하게 재사용 가능 → 응집도↑, 결합도↓

문제 항목 7 — 테스트에서 Jotai Provider 사용(현 PR에서 변경함) — 개선점

  • 현재 변경(좋음): renderWithProvider로 각 테스트를 Provider로 감쌈. Jotai atomWithStorage가 localStorage 사용하므로 테스트 간 상태 오염 주의.
  • 권장:
    • tests마다 초기 atom 값을 설정할 수 있는 유틸(Provider에 initialValues 전달) 사용.
    • 또는 atomWithStorage 래핑을 테스트 전용 in-memory storage로 교체.

구체적인 코드 개선(AS-IS → TO-BE) 예시 모음

  1. 알림 의존 제거(핵심 예시)
  • AS-IS:
    // useProductHandlers (current)
    const useProductHandlers = ({ addNotification }) => {
      const add = (product) => {
        // do update
        addNotification('상품 추가됨', 'success');
      };
      return { actions: { add } };
    };
  • TO-BE (Result 반환 + 호출자 책임):
    // useProductEntity.ts (entity hook)
    export const useProductEntity = () => {
      const add = (product) => {
        // update state
        return { success: true, message: '상품 추가됨' } as const;
      };
      return { add };
    };
    
    // useProductHandlers.ts (UI-level)
    export const useProductHandlers = () => {
      const entity = useProductEntity();
      const { push } = useNotifications(); // UI layer only
      const add = (product) => {
        const res = entity.add(product);
        if (res.success) push(res.message, 'success');
      };
      return { add };
    };
  1. atomWithStorage → DI 가능한 팩토리
  • AS-IS:
    export const productsAtom = atomWithStorage('products', initialProducts);
  • TO-BE:
    export const createProductsAtom = (storage = localStorage) =>
      atomWithStorage('products', initialProducts, {
        getItem: (k) => storage.getItem(k),
        setItem: (k, v) => storage.setItem(k, v)
      });
    
    // App init
    const productsAtom = createProductsAtom(window.localStorage);
    또는
    export const productsAtom = atom<ProductWithUI[]>(initialProducts);
    // storage sync effect in app root
    useEffect(() => {
      const p = JSON.parse(localStorage.getItem('products') || '[]');
      set(productsAtom, p || initialProducts);
    }, []);
  1. Jotai → Zustand (예: productsAtom 변환)
  • AS-IS:
    const products = useAtomValue(productsAtom);
    const setProducts = useSetAtom(productsAtom);
  • TO-BE (Zustand):
    import create from 'zustand';
    type State = { products: Product[]; setProducts: (p: Product[]|Fn) => void };
    export const useProductsStore = create<State>((set) => ({
      products: initialProducts,
      setProducts: (p) => set(state => ({ products: typeof p === 'function' ? p(state.products) : p }))
    }));
    
    // 사용
    const products = useProductsStore(s => s.products);
    const setProducts = useProductsStore(s => s.setProducts);
  1. 핸들러의 인터페이스 통일(좋은 예)
  • AS-IS:
    • useProductHandlers({ addNotification }) / useCouponHandlers({ addNotification })
  • TO-BE:
    • entityActions.add(product, { onSuccess, onError })
    async function addProduct(product, {onSuccess, onError} = {}) {
      try {
        // update state
        onSuccess?.(resultPayload);
        return { success: true, payload: resultPayload };
      } catch(e) {
        onError?.(e);
        return { success: false, error: e };
      }
    }

파일/모듈별 코멘트(핵심 파일만)

  • src/advanced/App.tsx

    • 좋아요: Layout/Header/Body 분리로 가독성↑, NotificationComponent 사용.
    • 개선:
      • App이 여전히 일부 로직을 import하고 있을 가능성(예: 이전 addNotification 처리) — App은 라우팅/페이지 선택만 담당하고, 도메인 훅은 페이지 컴포넌트에서 호출하도록 더 분리.
      • isAdmin Atom을 useAtomValue로 읽는 부분은 괜찮습니다. 다만 제품 데이터를 atoms에서 직접 끌어다 쓰는 부분이 App에 남아있지 않도록 주의.
  • src/advanced/atoms/index.ts

    • 좋아요: 전역 상태를 모아서 관리(제품/카트/쿠폰/notifications 등).
    • 개선:
      • atomWithStorage 사용을 팩토리화/DI로 변경해 테스트와 패키지화를 쉽게 하세요.
      • productsAtom / couponsAtom 내부 타입(예: ProductWithUI)이 애플리케이션 전용인지 패키지화 시 외부로 노출해야 하는지 고민 및 정리 필요.
  • src/advanced/tests/origin.test.tsx

    • 좋아요: tests에 Provider를 추가하여 Jotai 격리.
    • 개선:
      • atomWithStorage로 인한 localStorage 누적과 테스트 간 사이드 이슈를 방지하려면 beforeEach에서 localStorage 초기화(이미 하심) 외에 Provider에 초기값 주입 가능하면 더 견고.
      • 테스트 실행 속도/안정성을 위해 setTimeout/3초 알림 테스트는 가능하면 fake timers(vi.useFakeTimers)로 대체.
  • 새로 추가된 UI/컴포넌트(icons, layouts, ui/*)

    • 좋아요: 세세한 컴포넌트 분해로 재사용성↑.
    • 개선:
      • 많은 컴포넌트에서 직접 여러 훅을 import함. (예: ProductListSection에서 productHandlers, cartHandlers, searchProduct 등) — 이 경우 domain-level 훅(예: useProductsDomain)으로 묶어 바깥에서 필요한 조합만 꺼내 쓰도록 하면 테스트/사용이 한결 간단해집니다.
  • admin 관련 컴포넌트(ProductTable, ProductForm, CouponGrid, CouponForm)

    • 좋아요: Admin의 뷰·폼 로직이 잘 분리되어 있음.
    • 개선:
      • ProductForm/CouponForm 구현에서 addNotification를 직접 사용하거나 adminHandlers에 addNotification을 주입하고 있다면, 위에서 제안한 방식(엔티티 액션은 Result 반환, 핸들러에서 알림 처리)으로 바꾸면 더 깔끔합니다.

결론 및 우선 개선 작업(우선순위 추천)

  1. (높음) 알림/사이드이펙트 결합 낮추기

    • Entities(엔티티 훅)의 액션이 Result를 반환하거나 옵션 콜백 방식을 지원하도록 변경.
    • Notification은 UI 레이어(핸들러)로 이동.
  2. (중) atomWithStorage DI 또는 저장소 동기화 분리

    • localStorage 종속성 제거(팩토리/adapter 주입) → 테스트 안정성 및 패키지화 용이.
  3. (중) 훅/핸들러 인터페이스 통일

    • 모든 entity action은 동일한 결과 형태(Result 패턴)를 반환하거나 onSuccess/onError 옵션을 받도록 표준화.
  4. (중) 엔티티 모듈화 준비

    • entities/products 등 도메인 단위를 패키지화할 때 외부에 공개할 API(바렐)를 미리 정의.
    • UI·알림과 분리된 “도메인” 인터페이스만 노출.
  5. (낮) 테스트 가속화

    • 장시간 대기(3초) 테스트는 fake timers 사용 권장.

마지막으로 — 구체적인 코드 스니펫(핵심 개선안 요약)

  1. Result 기반 엔티티 액션(권장)
    AS-IS
const addProduct = (product) => {
  setProducts(prev => [...prev, product]);
  addNotification('상품이 추가되었습니다.');
}

TO-BE

const addProduct = (product) => {
  setProducts(prev => [...prev, product]);
  return { success: true, message: '상품이 추가되었습니다.' }
}

// 사용
const res = await productEntity.addProduct(product);
if (res.success) notification.push(res.message);
  1. Storage 주입 팩토리
    AS-IS
export const productsAtom = atomWithStorage('products', initialProducts);

TO-BE

export const createProductsAtom = (storage = localStorage) =>
  atomWithStorage('products', initialProducts, {
    getItem: k => storage.getItem(k),
    setItem: (k, v) => storage.setItem(k, v)
  });

// 앱에서
const productsAtom = createProductsAtom(window.localStorage);
  1. Jotai → Zustand 변환 예 (간단)
// zustand store
export const useProductsStore = create(set => ({
  products: initialProducts,
  addProduct: (p) => set(state => ({ products: [...state.products, p] })),
  setProducts: (p) => set({ products: p })
}));

// 사용 예
const products = useProductsStore(s => s.products);
const addProduct = useProductsStore(s => s.addProduct);

마무리 — 권장 액션 리스트 (짧게)

  • 알림/사이드이펙트의 책임을 handler/UI로 이동 → entity 훅은 Result 반환/옵션 콜백으로 변경.
  • atomWithStorage → DI 또는 storage-sync 로직 분리(테스트/패키징 고려).
  • 엔티티 관련 파일들(모델+훅+타입)을 하나의 바렐(index.ts)로 묶어 네임스페이스 제공.
  • 훅/액션 인터페이스 표준화(onSuccess/onError 또는 Result).
  • 테스트: 로컬 스토리지 초기화 + fake timers 활용으로 안정성 확보.

필요하시면

  • Jotai → Zustand/Redux/TanStack Query로의 구체적인 마이그레이션 PR 패치(코드 변환) 예시를 하나 만들어 드릴게요.
  • 현재 프로젝트에서 “entities 패키지 추출”을 단계별로 자동화하는 체크리스트도 만들어 드릴 수 있습니다.

끝으로 다시 한 번: 처음 의도(모델·훅·핸들러 분리, 테스트 중시)를 잘 지키신 점이 훌륭합니다. 위 권장 변경은 유지보수성, 테스트 편의성, 패키지화 용이성을 더 높이기 위한 제안입니다. 원하시면 제가 PR에 붙일 코멘트(구체적 코드 라인 단위)도 작성해 드리겠습니다.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스토어를 상태별로 되게 잘 분리하신거 같아요
저는 카트로 예를 들면 카트담기나 삭제같은거도 그냥 스토어로 다 넣어버렸는데
로직들을 model에 분리하신게 더 보기 좋고 분리도 더 잘되있는거 같네요. 제 기준으론 보기 더 좋은거 같아요

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entities를 도메인별로 분리하신 것처럼 atom도 모든 전역 상태를 한 파일에 모으기보다 엔티티별로 나누어 관리하면 유지보수성과 가독성이 더 좋아질 것 같아요.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메시지를 상태별로 분류하신게 인상적입니다.
이렇게 하니까 보기 좋고 편한거 같습니다

Copy link

@Yangs1s Yangs1s Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 고유 아이디 만드는건 좋은거 같습니다. 생각지도 못했는데 되게 인상적이네요
제가 맞게 생각한건지는 모르겠지만, 좀더 명확하고 디버깅할때 되게 편할거 같네요

Copy link

@Legitgoons Legitgoons left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번 주도 과제하느라 수고하셨습니다!

@@ -0,0 +1,6 @@
// Models
export { cartModel } from "./cart.model";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

외부에서 cartModel을 사용하나요? 만약 사용하지 않으면 export는 필요없을 것 같습니다!

Comment on lines +64 to +88
return {
// 네임스페이스 구조
state: {
items: cart,
totalItemCount,
isEmpty,
},
actions: {
add,
remove,
update,
clear,
find,
},

// 하위 호환성을 위해 기존 방식도 유지
cart,
totalItemCount,
isEmpty,
addToCart: add,
removeFromCart: remove,
updateQuantity: update,
clearCart: clear,
findCartItem: find,
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네임스페이스 구조 너무 맛있네요 저도 다음에 해보겠습니다

Comment on lines +5 to +7
export const Body = ({ children }: BodyProps) => {
return <main className="max-w-7xl mx-auto px-4 py-8">{children}</main>;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Body보다 MainLayout이라고 이름을 짓는 것이 컴포넌트의 역할을 더 명확하게 전달할 수 있을 것 같습니다. MainLayout이라는 이름이 페이지의 메인 섹션의 레이아웃을 담당한다는 의미를 직관적으로 보여줄 수 있을 것 같아요.

Comment on lines +1 to +4
interface CartIconProps {
className?: string;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Icon의 props 타입이 반복되는데 공통 IconProps를 만들고 필요에 따라 확장해서 쓰면 좋을 것 같습니다.

Suggested change
interface CartIconProps {
className?: string;
}
interface IconProps {
className?: string;
}
interface CartIconProps extends IconProps {
// 개별 아이콘 별로 추가적으로 필요한 속성들
}

children: React.ReactNode;
}

export const Layout = ({ children }: LayoutProps) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout이라는 이름은 범위가 너무 넓어서 컴포넌트명만 봤을 때 역할을 한눈에 파악하기 어려운 것 같아요! 이 컴포넌트가 전역적으로 페이지 전체를 감싸는 용도라면 GlobalLayout처럼 보다 구체적인 이름을 사용하면 좋을 것 같습니다.

Comment on lines +12 to +16
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === "products"
? "border-gray-900 text-gray-900"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버튼 활성/비활성에 대한 스타일 로직이 반복되고 있기 때문에 공통화하면 좋을 것 같아요.

// 공통 스타일 정의
const TabButtonStyles = {
  active: 'border-gray-900 text-gray-900',
  inactive: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
} as const;

type Tabs = 'coupons' | 'products';

const isTabActive = (tab: Tabs) => activeTab === tab;

const getTabButtonClassName = (tab: Tabs) =>
  `py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
    isTabActive(tab) ? TabButtonStyles.active : TabButtonStyles.inactive
  }`;

// 사용 예시
<button onClick={() => onTabChange('coupons')} className={getTabButtonClassName('coupons')}>
  쿠폰 관리
</button>

<button onClick={() => onTabChange('products')} className={getTabButtonClassName('products')}>
  상품 관리
</button>

Comment on lines +10 to +13
const { addNotification } = useNotifications();
const couponHandlers = useCouponHandlers({ addNotification });
const productHandlers = useProductHandlers({ addNotification });
const couponFormHook = useCouponForm();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 useHandlers에서만 addNotification을 외부에서 주입해서 사용하는 의도가 있으신지 궁금해요.

Comment on lines +72 to +73
adminHandlers.actions.handleCouponSubmit(e);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

폼 제출 핸들러만 별도로 분리하신 이유가 있을까요? 재사용이나 테스트 분리가 목적이 아니라면, handleSubmit, handleDiscountValueChange 같은 로직을 useCouponForm으로 모아두고, CouponForm은 UI 렌더링에만 집중하게 만드는 편이 더 일관되고 관리 포인트도 줄어들 것 같네요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants