Skip to content

[5팀 여찬규] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#53

Open
chan9yu wants to merge 38 commits intohanghae-plus:mainfrom
chan9yu:main
Open

[5팀 여찬규] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#53
chan9yu wants to merge 38 commits intohanghae-plus:mainfrom
chan9yu:main

Conversation

@chan9yu
Copy link
Member

@chan9yu chan9yu commented Aug 7, 2025

과제의 핵심취지

  • 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는 분리되어 있나요?

  • 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?

심화과제

  • 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다.

  • 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요.

  • Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다.

  • Context나 Jotai를 사용해서 전역상태관리를 구축했나요?

  • 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요?

  • 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요?

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

배포 링크

https://chan9yu.github.io/front_6th_chapter2-2/

과제 셀프회고

지난주 과제에서 아쉬웠던 점을 바탕으로, 이번 주는 먼저 리팩토링 계획을 세운 뒤 순차적으로 문제를 해결하는 방식으로 과제를 진행했습니다!

Setp 1. 모놀리식 분해 시작

  • 거대한 App.tsx 분석: 400줄이 넘는 파일의 모든 로직이 집중된 상태
  • 페이지 분리: AdminPage, CartPage로 UI 관심사 분리
  • 컴포넌트 추출: Header, 각종 UI 컴포넌트들 분리

Setp 2. 도메인 중심 아키텍처 구축

  • Cart 도메인 분리: domains/cart/ 생성, 계산 로직과 타입 분리
  • Coupon 도메인 분리: domains/coupon/ 생성, 유효성 검사 로직 독립화
  • Product 도메인 분리: domains/product/ 생성, CRUD 로직 체계화
  • Shared 영역 정리: domains/notification/ → shared/ 이동 (관심사 분리)

Setp 3. Props Drilling 문제 인식

  • Props 폭증 발견: 컴포넌트 분리하면서 props가 12개, 10개씩 증가
  • 유지보수성 저하: 새 기능 추가 시 여러 컴포넌트 수정 필요
  • 재사용성 부족: props 의존성으로 인한 컴포넌트 결합도 증가

Setp 4. Advanced - 전역 상태 관리 전환

  • Jotai 도입: Context API 대신 더 가벼운 Jotai 선택
  • Atom 설계: atomWithStorage로 localStorage 동기화, 파생 상태 구현
  • 도메인별 Store: domains/*/store/atoms.ts 구조로 도메인 독립성 확보

Setp 5: Advanced - 점진적 Props Drilling 제거

  • 각종 컴포넌트 10개 정도되는 props → 0개 props
  • 컴포넌트 자립화: 각 컴포넌트가 필요한 상태만 직접 구독

Setp 6. Advanced - Hook 아키텍처 정립

  • 전역 상태 Hook: useCartAtom, useProductAtom, useCouponAtom
  • 로컬 상태 Hook: useProductForm, useCouponForm (폼 전용)
  • 역할 분리: 전역 비즈니스 데이터 vs 로컬 UI 상태 명확히 구분

Setp 7. Advanced - 구조 최적화

  • 불필요한 추상화 제거: useDebounceSearchAtom, useAdminModeAtom 등 정리
  • Import 경로 정리: 도메인별 barrel export 패턴 적용
  • Hook 위치 재조정: store/hooks/ → hooks/ 폴더로 이관

Setp 8. Advanced - 배포 준비

  • 빌드 시스템 최적화: pnpm build:advanced로 선택적 빌드
  • Vite 설정 통합: 조건부 설정으로 구성
  • 파일명 표준화: index.advanced.html → index.html 자동 변환

과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요?

폴더구조

폴더 구조에 특히 신경을 기울였으며, 파일의 위치와 계층 구조가 높은 응집도를 가질 수 있도록 하는 관점에서 작업을 진행했습니다.

도메인 중심 vs 기술 중심 구조 분리
  • 처음에는 모든 것을 기술별로 분류하려 했지만(components/, hooks/, utils/), 실제로는 도메인별 분류가 훨씬 직관적이고 유지보수하기 쉬웠습니다.
  • domains/cart/에서 장바구니 관련 모든 것(컴포넌트, 훅, 타입, 유틸)을 찾을 수 있어서 개발 속도가 빨라졌습니다.
  src/advanced/
  ├── domains/                    # 비즈니스 도메인 (기능별)
  │   ├── cart/
  │   │   ├── components/        # Cart 전용 컴포넌트
  │   │   ├── hooks/             # Cart 전용 훅
  │   │   ├── store/             # Cart 전용 상태
  │   │   ├── services/          # Cart 비즈니스 로직
  │   │   ├── types/             # Cart 타입
  │   │   └── utils/             # Cart 계산 함수
  │   ├── coupon/                # 쿠폰 도메인
  │   │   ├── components/
  │   │   ├── hooks/
  │   │   ├── store/
  │   │   └── ...
  │   └── product/               # 상품 도메인
  │       ├── components/
  │       ├── hooks/
  │       ├── store/
  │       └── ...
  ├── shared/                    # 공통 기능 (타입별)
  │   ├── components/           # 범용 UI 컴포넌트
  │   │   ├── ui/              # Button, Input 등
  │   │   └── icons/           # 아이콘들
  │   ├── hooks/               # 범용 훅
  │   ├── store/               # 전역 UI 상태
  │   ├── types/               # 공통 타입
  │   └── utils/               # 공통 유틸리티
  └── app/                      # 애플리케이션 레이어
      ├── components/          # 앱 레벨 컴포넌트
      ├── pages/               # 페이지 컴포넌트
      └── App.tsx              # 라우팅 & 전역 설정

Barrel Export 패턴 사용

각 폴더마다 index.ts를 두어서 깔끔한 import 경로를 만들 수 있었습니다.

// Before: 복잡한 경로들
import { CartItem } from "../domains/cart/types/entities";
import { useCartActions } from "../domains/cart/hooks/useCartActions";
import { calculateTotal } from "../domains/cart/utils/calculators";

// After: 깔끔한 단일 import
import { CartItem, useCartActions, calculateTotal } from "../domains/cart";

배럴파일 사용하면서 고민했던 점

순환 의존성 문제와의 씨름

  • 처음에는 모든 도메인에서 배럴파일을 무분별하게 사용하다가 순환 의존성 문제를 마주했습니다.
// domains/cart/index.ts에서
export * from "./hooks/useCartAtom";

// domains/cart/hooks/useCartAtom.ts에서
import { useCouponAtom } from "../../coupon"; // 순환 의존성 위험!
  • 이를 해결하기 위해 계층별 의존성 규칙을 정했습니다: domains/ → shared/ → app/ 순으로만 의존하도록 제한했습니다.
Import 지옥 vs 명확성의 트레이드오프
  • 배럴파일을 사용하면 import가 간결해지지만, 실제로 어느 파일에서 가져온 건지 추적하기 어려워집니다.
// 간결하지만 출처가 불명확
import { useCartAtom, CartItem, calculateCartTotal } from "../domains/cart";

// 장황하지만 명확한 출처
import { useCartAtom } from "../domains/cart/hooks/useCartAtom";
import { CartItem } from "../domains/cart/types/entities";
  • 개발 단계에서는 명확한 경로를, 완성 후에는 배럴파일을 사용하는 방식으로 절충했습니다.
도메인별 배럴파일 vs 기능별 배럴파일
  • 처음에는 각 하위 폴더마다 배럴파일을 만들었습니다
    domains/cart/
    ├── components/index.ts
    ├── hooks/index.ts
    ├── types/index.ts
    └── index.ts # 모든 것을 다시 re-export
    
  • 하지만 이는 과도한 중첩을 만들어서, 결국 도메인 레벨에서만 배럴파일을 유지하는 것으로 정리했습니다.
결론적으로 제가 나름 정한 배럴파일 사용 원칙
  1. 도메인 레벨에서만 사용 (하위 폴더마다 만들지 않음)
  2. 공개 API만 export (내부 구현체는 숨김)
  3. 순환 의존성 체크 필수

이런 고민들을 통해 깔끔한 import 경로와 명확한 모듈 경계를 동시에 얻을 수 있었습니다!

Jotai

  • atomatomWithStorage의 간결함과 직광성이 좋아서 놀랐습니다 Redux보다 훨씬 간단하면서도 TypeScript와의 호환성이 뛰어났습니다
  • 파생 상태를 atom((get) => ...) 형태로 자연스럽게 표현할 수 있어서 코드가 매우 깔끔해졌습니다

Props Drilling 제거 효과

  • 단순히 props 개수만 줄어든 것이 아니라, 컴포넌트가 훨씬 독립적이고 재사용 가능해졌습니다.
  • 새로운 기능을 추가할 때도 부모 컴포넌트를 건드릴 필요 없이 해당 컴포넌트에서 필요한 상태만 구독하면 되니 개발 속도가 빨라졌습니다.

도메인 중심 설계

  • 도메인별로 atom과 hook을 분리하니 코드의 응집도가 높아지고, 각 도메인의 로직을 이해하기 쉬워졌습니다.

이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요?

전역 상태와 로컬 상태의 경계? 정하기

  • 모든 상태를 전역으로 만들면 오히려 복잡해질 수 있어서, 정말 여러 컴포넌트에서 공유되는 상태만 전역으로 관리했습니다
  • 폼 상태 같이 특정 컴포넌트에서만 사용되는 것은 useProductForm 같은 로컬 훅으로 분리했습니다

기존 테스트가 깨지지 않도록 주의

  • 테스트가 모두 통과하도록 하면서 리팩토링을 진행하는 것이 가장 까다로웠던거 같습니다

이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요!

Jotai의 고급 기능

  • atomFamilyselectAtom 같은 기능들도 사용하고 싶습니다 (조타이는 이번에 처음이라서..)
  • Suspense와 함께 사용하는 async atom도 써보고싶어요

더 복잡한 상태 관리 시나리오

  • 현재는 단순한 CRUD이지만, 실시간 데이터 동기화나 낙관적 업데이트 같은 복잡한 시나리오에서 Jotai를 어떻게 활용할 수 있을지 궁금합니다.

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

파일 분리를 하면서 제일 고민이 되었던 부분은Notification를 어떻게 하냐 였는데요
Notification에 대한 로직들을 domain/notification 폴더로 만들어 관리를 하니 나름 응집도 레벨도 올라가고 보기좋았지만 Notification이 domain에 들어가야되나..? 라는 고민을 하게되었습니다. 결국 현재는 shared안에 componenet, hooks, types,
state ... 이런식으로 분리가되어 버렸는데 Notification을 어떻게 구조를 가져가야될 고민입니다!

첫 번째 시도: domains/notification/ (잘못된 접근)

domains/notification/
├── components/
│ ├── NotificationList.tsx
│ └── index.ts
├── hooks/
│ ├── useNotifications.ts
│ └── index.ts
├── types/
│ ├── entities.ts
│ └── index.ts
├── constants/, models/, utils/ # 빈 디렉토리들
└── index.ts

처음에는 "응집도 높이기"라는 명목으로 notification 관련 모든 것을 한 곳에 모았습니다. 하지만 곧 근본적인 문제를 발견했어요:

"Cart, Coupon, Product vs Notification의 본질적 차이"

  • Cart/Coupon/Product: 비즈니스 도메인 전문가가 존재하는 실제 업무 영역
  • Notification: 모든 도메인에서 공통으로 사용하는 기술적/UI 관심사

이때 깨달은 건, **"응집도가 높다고 모두 같은 곳에 두는 게 아니다"**라는 점이었습니다.

두 번째 시도: shared/notifications/ (구조적 불일치)

shared/notifications/ # ❌ 기능별 분류
├── components/
├── hooks/
└── types/

이번엔 shared로 옮겼지만 또 다른 문제가 생겼습니다. shared 영역은 타입별로 분류해야 하는데 기능별로 분류한 거죠. 나중에 Button, Modal, Toast 같은 다른 공통 컴포넌트가 추가되면 일관성이 깨질 거라 생각했어요.

최종 결론: shared/ 내 타입별 분류

shared/
├── components/
│ ├── ui/
│ │ ├── Notification.tsx # ✅ 다른 UI 컴포넌트와 함께
│ │ └── Button.tsx
│ └── NotificationList.tsx
├── hooks/
│ ├── useNotifications.ts # ✅ 다른 공통 훅들과 함께
│ └── useDebounceValue.ts
├── store/
│ └── notificationAtoms.ts # ✅ 전역 UI 상태
└── types/
└── notification.ts # ✅ 공통 타입들과 함께

현재 구조에 대한 의문과 대안 고민

현재 구조는

  • 타입별 분류로 일관성 확보
  • 다른 공통 기능들과 함께 관리되어 찾기 쉬움
  • 확장성 좋음 (Toast, Modal 등 추가 시)

같은 의도로 만들어지긴 했습니다.

하지만 여전히 남은 의문:

  1. 흩어져 있는 느낌: Notification 관련 파일들이 4군데(components, hooks, store, types)로 분산되어 있어서 처음 보는 사람이 전체 그림을 파악하기 어려울 수 있어요.
  2. 중간 지점은 없을까: 완전히 흩어트리지도, 무리하게 묶지도 않는 절충안이 있을지 궁금합니다.

궁금한 점들

  1. 현재의 타입별 분류가 맞는 접근일까요? 아니면 notification처럼 관련성이 높은 기능들은 어느 정도 묶어두는 게 나을까요?
  2. "공통 기능"의 경계를 어떻게 정의해야 할까요? Toast, Modal 같은 것들도 notification과 비슷한 딜레마에 빠질 것 같아요.

chan9yu added 30 commits August 4, 2025 12:42
- Notification 범용 UI 컴포넌트 분리
- NotificationList를 app/components로 이관
@heojungseok
Copy link

해냈네..

@chan9yu
Copy link
Member Author

chan9yu commented Aug 8, 2025

해냈네..

컨피던스 레벨 10.... 🫠

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.

안녕하세요! PR 잘 정리하셨고 리팩토링의 의도와 결과를 아주 잘 문서화해주셨네요. 아래 피드백은 제공하신 PR 파일 변경 목록과 PR 본문(셀프회고, 고민 등)을 바탕으로 작성한 코드/아키텍처 관점의 리뷰입니다. 이 리뷰를 그대로 GitHub PR Review로 붙여 넣으셔도 괜찮게 마크다운 형식으로 정리했습니다.

참고: 이 피드백은 AI가 생성한 내용입니다 🤖


질문에 대한 간단 답변 (PullRequestBody에서 요청하신 부분)

  1. Notification을 domain에 넣어야 하나?

    • 권장: shared(공통, UI/infra)로 두는 편이 좋습니다. Notification은 cross-cutting concern으로 여러 도메인에서 공통으로 사용되므로 shared/ui + shared/store(또는 shared/notifier) 형태로 관리하세요. 다만 Notification 관련 *컨슈머(표시 방식)*는 shared에, 각 도메인의 비즈니스 로직에서 발생시키는 이벤트는 도메인 내부에서 처리하되 shared의 notifier API(또는 이벤트 인터페이스)를 통해 호출하도록 분리하면 적절합니다.
  2. 공통 기능의 경계 정의(Toast, Modal 등)

    • UI 컴포넌트(Toast, Modal) + UI 전용 상태(visible 등)는 shared에,
    • 비즈니스에서 발생하는 “언제 보여줄지” 결정은 각 도메인 훅/서비스가 담당하되, shared의 노티파이어 API(또는 이벤트)만 호출하도록 설계하세요.

종합 피드백 (요약 키워드 및 PR 본문에 대한 피드백)

  • 핵심 키워드

    • 도메인 중심 구조화 (domains/*)
    • 전역 상태: Jotai 도입 (atom / atomWithStorage / 파생 상태)
    • 컴포넌트/훅 분리 (Container / Presentational)
    • 배럴(Barrel) 사용의 장단점 및 순환 의존성 관리
    • Notification: cross-cutting vs domain
    • 응집도(높음) / 결합도(낮추기) — 대부분 의도대로 정리됨
    • 모듈화(패키징) 가능성 — 일부 공개 API 정리 필요
  • PR 본문(과제 셀프회고 / 신경 쓴 점)에 대한 인사이트 피드백

    • 잘한 점
      • 대형 App.tsx를 기능별/역할별로 명확히 쪼갠 점: domains, shared, app 구조는 유지보수성/탐색성을 크게 개선합니다.
      • Jotai를 도입해 전역 상태(도메인별 atom)를 도메인 내부로 넣은 점: 응집도가 높아지고 각 도메인의 “공개 API(hook)”로 소비하는 방식은 바람직합니다.
      • 배럴을 이용하면서 순환 의존성을 경험하고 규칙을 세운 점: 실무에서도 중요한 경험입니다.
    • 더 생각해볼 점(질문)
      • 각 도메인의 공개 API(도메인 루트 index.ts)에 어떤 항목만 노출할지 규칙이 있나요? (public API 정의 필요)
      • Jotai atom이 비즈니스 모델(엔티티)과 UI 상태를 섞지 않도록 경계 규칙을 더 명확히 할 수 있나요?
      • Notification을 shared로 옮겼을 때 "도메인의 내부 이벤트가 Notification에 직접 의존"하는 경우를 어떻게 방지할 계획인가요?
  • PR 본문의 "리뷰 받고 싶은 내용"에 대한 답변(요약)

    1. 현재의 타입별 분류(Shared 안에 components/hooks/store/types로 분류)는 적절합니다. 다만 notification 관련 파일이 분산된 점(components/hooks/store/types에 분산)은 UX 개발자가 전체 흐름 파악 시 불편하니, 다음 중 하나를 권합니다:
      • shared/notifications 폴더를 만들어 shared/ui에 라우팅되는 컴포넌트들(Toast, NotificationList)은 shared/components/ui에 두고, notification 전용 훅/atoms/타입을 shared/notifications 내부에 모아 두되, UI만 공유하는 규칙으로 관리 (즉, UI는 ui/에, 상태·훅은 notifications/에).
      • 또는 shared/internal-notification/ 처럼 “공통이지만 하나의 기능으로 묶는” 중간 위치를 허용.
    2. 결론: 현재 방식(타입별 분류)은 일관성이 있어도 "관련 파일이 분산"되는 단점이 있으니, Notification 같은 cross-cutting concern은 묶어두되 내부 구조는 철저히 public(노출) vs internal(비공개)로 나누세요.

상세 피드백 — 개념 정의, 문제, AS-IS, TO-BE

먼저 피드백에서 다룰 개념 정의(간단)

  • 응집도(cohesion): 한 단위(파일/폴더/모듈)가 얼마나 관련된 책임을 묶어 가지고 있는가. (여기서 측정 기준: 변경 동선의 짧음, 모듈화/패키징 시 분리 용이성)
  • 결합도(coupling): 모듈 간 의존성의 강도. 낮을수록 좋음. 좋은 결합도는 인터페이스(명확한 함수/훅 시그니처)를 통해 모듈을 연결하는 것.

1) 상태관리 라이브러리가 달라지는 경우 (jotai → tanstack-query / zustand / redux)

정의: 상태관리 라이브러리 교체는 public API(도메인 훅/액션)를 얼마나 깔끔하게 유지했는지에 따라 영향도가 달라집니다. 결합도가 낮을수록 교체 작업 범위가 작습니다.

문제: 현재 도메인 컴포넌트/훅이 Jotai API(useAtom/useAtomValue/atomWithStorage) 직접에 의존하면, 라이브러리 교체 시 도메인 내부 구현 전부를 수정해야 합니다. 반대로, 도메인이 "훅 인터페이스"로 은닉한다면 교체는 훨씬 쉬워집니다.

AS-IS (예시)

  • domains/adminMode (현재)
// domains/shared/store/adminModeAtom.ts
import { atom } from 'jotai';
export const adminModeAtom = atom(false);

// 사용 예 (Header)
import { useAtom } from 'jotai';
const [isAdmin, setIsAdmin] = useAtom(adminModeAtom);

문제점: useAtom / atom 타입에 직접 결합됨 — Jotai를 사용하는 컴포넌트가 많으면 교체 범위가 넓음.

TO-BE (인터페이스 캡슐화 — Jotai 내부는 숨기기)

  • domains/shared/store/adminMode.ts (공개 API)
// public API (이 파일만 다른 모듈에서 import)
export function useAdminMode() : [boolean, (v: boolean) => void, () => void] { ... }

// 내부 구현 (Jotai 기반)
import { useAtom } from 'jotai';
import { adminModeAtom } from './adminModeAtom';
export function useAdminMode() {
  const [isAdmin, setIsAdmin] = useAtom(adminModeAtom);
  const toggle = () => setIsAdmin(prev => !prev);
  return [isAdmin, setIsAdmin, toggle];
}
  • Jotai → Zustand로 전환 시:
    • 변경 범위: ONLY domains/shared/store/adminMode.ts 내부만 변경. useAdminMode 호출부(컴포넌트)는 수정 불필요.

Zustand 변환 내부 (TO-BE 구현 예)

// zustand-store.ts (internal)
import create from 'zustand';
export const useAdminStore = create(set => ({
  isAdmin: false,
  setAdmin: (v) => set({ isAdmin: v }),
  toggleAdmin: () => set(s => ({ isAdmin: !s.isAdmin }))
}));

// public hook (same signature)
export function useAdminMode() {
  const isAdmin = useAdminStore(s => s.isAdmin);
  const setIsAdmin = useAdminStore(s => s.setAdmin);
  const toggle = useAdminStore(s => s.toggleAdmin);
  return [isAdmin, setIsAdmin, toggle] as const;
}
  • TanStack Query는 서버 상태용이므로 로컬 UI/엔티티 상태를 저장하기 위해서는 별도 store 필요(예: Zustand) — 설계상 고려 요소입니다.

요약 권장사항

  • 모든 전역 상태는 "domain의 public hook" (useXxx)으로 감싸서 외부에서 라이브러리에 의존하지 않도록 하세요.
  • public hook의 시그니처는 단순하고 명확하게: 반환 값과 액션(함수)을 제공. 내부에서 라이브러리 교체가 필요하면 내부 구현만 바꾸면 됩니다.

2) 모듈화를 하여 패키지로 배포해야 하는 경우

목표: domains/* 를 독립 패키지로 떼어내는 시나리오에서의 준비와 점검.

점검 관점

  • 응집도가 높은가? (파일 변경/추적 경로가 짧은가)
  • 어떤 코드들이 서로 엮여있는가? (외부 의존성/순환 참조)

문제 정의

  • 현재 domains 에 대한 배럴(Re-export) 패턴을 사용 중. 이게 편하지만, domain 간 순환 import가 쉽게 발생할 수 있음(이미 PR 본문에서 경험하셨음).
  • Notification처럼 cross-cutting concern은 scattered(분산)되어 있어 패키지로 떼기 어렵다.

AS-IS (문제 보이는 구조 예)

domains/cart/
  hooks/useCartAtom.ts  // exports
  index.ts              // export * from './hooks/useCartAtom'
domains/coupon/
  hooks/useCouponAtom.ts
  index.ts              // export * from '../cart' ? (순환 발생 가능)

문제: domain A의 index가 domain B를 re-export할 때 순환이 발생하면 빌드/타입 시스템/런타임에서 문제.

TO-BE(패키징 준비)

  • 규칙: 도메인 수준에서만 배럴 파일을 제공하고, 배럴은 "public API만" 노출. 내부 경로(internal/ 또는 private/ 폴더)는 외부에 내보내지 말 것.
  • 의존성 규칙: domains/* → shared/* → app/* 순으로만 의존. 도메인끼리는 가급적 직접 의존 금지(혹은 명확한 의존성 방향을 문서화).
  • internal 폴더를 사용해 비공개 구현을 숨김:
domains/cart/
  src/
    index.ts          // export { useCart, CartSidebar, types... } -- public
    internal/
      useCartAtomImpl.ts
      utils/
  • 패키지화 시 public index.ts 내용만 패키지에 포함. 내부는 publish 제외.

패키징 시 노출 항목 예

  • exports:
    • types: CartItem, CartProduct
    • hooks: useCart(), useCartActions()
    • components: CartSidebar, CartItemView
  • 비노출: 상세 구현 유틸(helpers), atom 구현 파일, 내부 컴포넌트

응집도/결합도 검사 체크리스트 (패키지화 관점)

  • 변경 시 파일 동선이 짧은가? (예: cart의 할인 로직 수정 → cart 패키지 내부에서만 수정으로 충분한가)
  • external에 노출된 API가 명확하고 최소한인가?
  • domain이 shared에만 의존하고 다른 domain에 의존하지 않는가?
  • 배럴은 public API에만 한정되어 있는가?

3) Notification 구조(구체적인 제안)

문제 요약: Notification은 UI(Toast) + 상태 + 소비 발신자(도메인 훅/서비스) 관련 파일이 흩어져 있음. PR 본문에서도 고민하셨듯 "어디에 넣을 것인가"의 딜레마.

권장 아키텍처(간단)

  • shared/notifications/
    • components/Notification.tsx (Toast 구현, UI)
    • service/notifier.ts (notify API, 이벤트 버스)
    • store/notificationAtoms.ts (Jotai 등으로 단순 구현 — 단, 도메인에 노출 X)
    • hooks/useNotifications.ts (NotificationList 소비용)
    • types.ts

패턴 1 (간단): shared의 notifier 함수를 도메인이 불러서 사용

// shared/notifier.ts
type NotifyOpts = { message: string, level?: 'info'|'error' };
export const notify = (opts: NotifyOpts) => {
  // 내부: jotai atom 업데이트 or event emitter publish
};
  • 도메인에서 사용:
import { notify } from '../../shared/notifier';
function someAction() {
  try { ... } catch(e) {
    notify({ message: '실패', level: 'error' });
  }
}
  • 장점: 간단, 전역 호출 가능
  • 단점: 도메인이 shared notifier에 의존 → 약한 결합 발생 (이건 acceptable: notifier는 infra/utility)

패턴 2 (DI/콜백 권장 — 결합도 최소화)

  • 도메인 훅은 UI(알림) 로직을 받지 않음. 도메인의 public API(예: addProduct)를 호출한 상위 레이어가 결과(성공/실패)에 따라 notify를 호출.
// domains/product/useProductActions.ts
export function useProductActions() {
  const addProduct = async (p) => {
    // 반환값으로 success/fail, 혹은 Promise reject
    try {
      // do work
      return { ok: true, product: created };
    } catch(e) {
      return { ok: false, error: e };
    }
  };
  return { addProduct };
}

// UI
const { addProduct } = useProductActions();
const res = await addProduct(payload);
if(res.ok) notify({message: '추가됨'});
else notify({message: '실패', level: 'error'});
  • 장점: 도메인이 Notification에 의존하지 않고, 호출자(컴포넌트)에서 알림을 결정 -> 결합이 낮음

추천: 대부분의 경우 패턴 2를 기본으로 삼고, 자동 알림이 반드시 필요한 경우 shared/notifier를 사용할 수 있도록 옵션 제공.


상세 코드 제안 (AS-IS vs TO-BE) — 실제 PR에서 적용 가능한 예제

  1. adminMode 예제 (AS-IS: Jotai direct → TO-BE: Hook 캡슐화)
    AS-IS:
// Header.tsx
import { useAtom } from 'jotai';
import { adminModeAtom } from '../../shared/store/adminModeAtom';
const [isAdmin, setIsAdmin] = useAtom(adminModeAtom);

TO-BE:

// shared/store/useAdminMode.ts (public API)
import { useAtom } from 'jotai';
import { adminModeAtom } from './adminModeAtom';
export function useAdminMode() {
  const [isAdmin, setIsAdmin] = useAtom(adminModeAtom);
  const toggle = () => setIsAdmin(v => !v);
  return { isAdmin, setIsAdmin, toggle };
}

// Header.tsx
import { useAdminMode } from '../../shared/store/useAdminMode';
const { isAdmin, toggle } = useAdminMode();
  • 장점: 내부 구현을 Jotai → Zustand 등으로 바꿀 때 Header는 손댈 필요 없음.
  1. useAddProduct 결합도 개선 (나쁜 예 vs 좋은 예)

나쁜 예 (결합도 높음)

// BAD: addNotification을 직접 파라미터로 받음 — 상위 레이어 용어(addNotification)을 고정해서 결합 발생
const useAddProduct = (addNotification) => {
  const add = (p) => {
    // 로직
    addNotification('상품 추가됨');
  };
  return { add };
};

좋은 예 (결합도 낮음)

// GOOD: 결과를 반환하거나 onSuccess/onError 콜백을 받음
const useAddProduct = ({ onSuccess, onError } = {}) => {
  const add = async (p) => {
    try {
      // 비즈니스 로직
      onSuccess?.();
      return { ok: true };
    } catch (e) {
      onError?.(e);
      return { ok: false, error: e };
    }
  };
  return { add };
};

// 호출부(UI)
const { add } = useAddProduct({
  onSuccess: () => notify({ message: '상품 추가됨' }),
  onError: (e) => notify({ message: e.message, level: 'error' })
});
  • 또는 Promise 반환 방식:
const res = await add(product);
if(!res.ok) notify(...);

둘 중 하나의 규칙(콜백 or 결과 반환)을 프로젝트 규칙으로 정하면 결합도를 낮출 수 있습니다.

  1. 도메인 패키지화 대비 public index 예시

AS-IS (prone to circular):

// domains/cart/index.ts
export * from './hooks/useCartAtom';
export * from './components/CartSidebar';
export * from './types';

TO-BE (public API 만)

// domains/cart/index.ts (public)
export { CartSidebar } from './components/CartSidebar';
export type { CartItem, CartProduct } from './types';
export { useCart } from './hooks/useCart'; // useCart는 내부 구현을 숨긴다

// 내부 구현은 internal/ 아래에 두고 export하지 않음.
  • 빌드/패키징 시 index.ts만 패키지에 포함.

PR 파일별(변경사항) 상세 코멘트 & 개선 제안

  1. App 통째 제거하고 app/ 폴더로 분리한 것 — 매우 좋음

    • App.tsx의 모든 로직을 app/{components,pages}와 domains로 분리한 것은 SRP, 응집도 관점에서 매우 바람직.
    • 개선 제안: app의 public index(App export)와 각 도메인의 public API(export 할 것) 문서(README) 작성 권장.
  2. Jotai 의존 추가 (package.json, pnpm-lock 등) — 괜찮음

    • 권장: 각 도메인에서 atom을 직접 export하지 말고 public hook에 캡슐화하세요.
    • 권장: atomWithStorage를 사용하는 경우, serialization boundary를 명확히 하고 migration 전략(version)을 고려하세요.
  3. Barrel(도메인/공유) 사용 & 순환 의존성 관리

    • PR 본문에 이미 규칙을 만드신 점 좋습니다. 추가로 다음을 권장합니다:
      • ESLint plugin(import/no-cycle) 또는 자체 module-boundary-check(예: dependency-cruiser) 적용.
      • 도메인 간 의존은 interface/adapter로만 허용(예: domains/product가 domains/cart를 직접 import 하지 않음).
  4. Notification 위치 관련

    • 현재 shared에 분산돼 있음. 위에서 제안한 shared/notifications 폴더로 모으는 것을 추천.
    • UI/서버/도메인 사이의 notifier contract(예: notify({message, level}))를 정의하고, 도메인은 이 contract만 호출하거나(권장하지 않음) 반환값 패턴으로 알림을 호출하도록 유도.
  5. ESLint/Tooling/CI 변경

    • eslint.config.js로 전환하신 것은 모던한 방식. 주의: 프로젝트 팀원들이 로컬에서 동일하게 작동하도록 README에 lint setup 문서화 필요.
    • lefthook 사용: pre-commit 훅 설명을 README에 추가하세요.

응집도(정의 + 현재 코드가 규칙에 부합하는지)

정의(요청하신 규칙을 포함)

  • 응집도란: "변경에 대한 파일/코드의 추가/수정/삭제 등이 얼마나 짧은 동선으로 일어나는가"와 "라이브러리로 떼어낼 때 매끄럽게 분리되는가"로 평가.

현재 상태 평가

  • 장점(응집도 ↑)
    • domains 폴더 구조로 비즈니스 관련 코드(컴포넌트/훅/스토어/유틸)가 모여 있음 — 특정 도메인을 변경할 때 동선이 짧아짐.
    • 도메인별 store/ hooks/ components 분리로 이해하기 쉬움.
  • 단점(응집도 저하 가능)
    • Notification 관련 파일이 shared의 서로 다른 폴더에 흩어져 있어, Notification 관련 변경 시 동선이 길어질 수 있음.
    • 배럴로 광범위하게 re-export 하면 내부 구현이 노출/의존되어 응집도 역효과 발생 가능.

결론: 대부분 응집도가 높아졌고 훌륭하지만 Notification과 같은 cross-cutting concern은 한 곳으로 묶어 응집도를 높이세요.


결합도(정의 + 현재 코드가 규칙에 부합하는지)

정의: 모듈 간 직접적인 구현 의존(함수명·구조·라이브러리 API에 대한 의존)이 적을수록 좋다. 인터페이스(훅 시그니처)를 통해 의존하면 결합도가 낮아짐.

현재 상태 평가

  • 장점(결합도 ↓ 시도)
    • app 레이어는 domains의 public 컴포넌트/훅만 사용하고, 세부 구현은 숨김(일부). Header가 useAdminAtom를 외부 hook으로 바꾼 점 등은 좋음.
  • 문제점(개선 필요)
    • 일부 파일에서 Jotai API(useAtom, useAtomValue)를 직접 사용하고 있음 → 외부 컴포넌트가 라이브러리에 결합될 가능성 있음.
    • 배럴 사용 시 "어디서 왔는지" 추적이 힘들어져 의존 추론이 어려워짐.

권장 개선

  • 모든 전역 상태 접근은 public hook(예: useCart(), useAdminMode())으로 캡슐화.
  • 도메인 훅은 호출자에게 Promise/결과를 반환하거나 onSuccess/onError 인터페이스를 제공하고, addNotification 같은 구체적 이름은 인자로 받지 않도록 설계.

마무리 정리 — 우선순위 액션 아이템

  1. public hook 캡슐화 규칙 적용

    • 모든 domain atoms/스토어는 domain 내부에서만 접근하고, 외부는 useXxx public hook을 호출.
    • 문서화: domain마다 PUBLIC_API.md 작성.
  2. Notification 재구성

    • shared/notifications 폴더로 묶기.
    • notify(contract) 하나만 공유하거나, 도메인에서는 결과 반환 패턴을 사용.
  3. 배럴(Barrel) 규칙 강제화

    • 도메인 레벨에서만 배럴 제공.
    • 내부(impl) 파일은 export 하지 않음.
    • ESLint 또는 dependency-cruiser로 순환 의존성 검사 추가.
  4. 상태관리 라이브러리 교체 대비

    • 현재 Jotai 기반 구현을 public hook으로 감싸기만 하면 라이브러리 교체는 한 파일 또는 한 모듈 수준에서 가능.
  5. 패키징 준비

    • 각 domain의 index.ts에 public API만 남기고 internal 파일을 숨기는 구조로 정리.
    • 패키지화 시 포함/제외 목록 정리(package.json exports, .npmignore).

마지막으로 — PR 본문(셀프회고)에 대한 코멘트 & 질문 제안

잘 하셨습니다. 특히 도메인 중심, 배럴 규칙 수립, Jotai 학습/도입, Props 제거 과정을 체계적으로 진행하신 점이 인상적입니다. 다음 질문을 스스로/팀과 토론해보세요:

  • domain의 public API는 어떤 규칙으로 선정하나? (컴포넌트/훅/타입/유틸 중 무엇을 공개할지)
  • Notification은 shared로 두되, 도메인에서 UI를 트리거해야 하나? 아니면 호출자(컨테이너)에서 제어해야 하나?
  • 배럴 사용 시 “개발 단계(명확 경로 사용) vs 배포 단계(배럴 사용)” 워크플로를 팀 규칙으로 정할 것인가?

원하시면 아래 작업을 실제 코드로 만들어 드립니다:

  • useAdminMode / useCart public hook 리팩토링 패치 예시
  • Notification shared 모듈화(구조 + 간단 구현)
  • ESLint rule / dependency-cruiser 설정 예시로 순환 의존 검사 추가

원하시는 것을 알려주세요. 다시 한번 잘 정리하신 PR 축하드리고, 아주 좋은 방향으로 진행되고 있습니다!

Comment on lines +2 to +3

export const couponNotificationService = {
Copy link

Choose a reason for hiding this comment

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

쿠폰 관련 알림을 서비스로 빼신게 너무 인상적이에요 보기에 너무 깔끔합니다,, 🤩

Comment on lines +1 to +3
import { tv } from "tailwind-variants";

const paymentInfoLine = tv({
Copy link

Choose a reason for hiding this comment

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

찬규님 코드 덕에 tv라는 헬퍼 함수를 처음 알게 되었네요!! 감사합니다 👏

<main className="mx-auto max-w-7xl px-4 py-8">
{isAdminMode ? <AdminPage /> : <CartPage />}
</main>
<NotificationList notifications={notifications} onRemove={removeNotification} />

Choose a reason for hiding this comment

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

혹시 notifications, removeNotification 를 App 에서 만들고 주입한 이유가 있나요?
그냥 NotificationList 안에서 만들면 어떨까 싶어서유

<nav className="-mb-px flex space-x-8">
<button
onClick={handleProductsTabClick}
className={tabButton({ active: activeTab === "products" })}

Choose a reason for hiding this comment

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

소소하게 아래처럼 해도 좋았을 것 같아유

          className={tabButton({ activeTab: "products" })}

Comment on lines +44 to +45

if (value === "" || /^\d+$/.test(value)) {

Choose a reason for hiding this comment

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

요렇게 가독성을 좀 더 높여봐도 좋을 것 같습니다.

Suggested change
if (value === "" || /^\d+$/.test(value)) {
const isNumbericString = /^\d+$/.test;
if (value === "" || isNumberString(value)) {

@creco-hanghae
Copy link

저번 주차 고생하셨습니다, 이번 주차도 화이팅

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