Skip to content

[1팀 이의찬] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#46

Open
Legitgoons wants to merge 53 commits into
hanghae-plus:mainfrom
Legitgoons:main
Open

[1팀 이의찬] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#46
Legitgoons wants to merge 53 commits into
hanghae-plus:mainfrom
Legitgoons:main

Conversation

@Legitgoons
Copy link
Copy Markdown

@Legitgoons Legitgoons commented Aug 6, 2025

과제의 핵심취지

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

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

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

배포 링크

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

기본과제

  • Component에서 비즈니스 로직을 분리하기

  • 비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기

  • 뷰데이터와 엔티티데이터의 분리에 대한 이해

  • entities -> features -> UI 계층에 대한 이해

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

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

과제 셀프회고

전체 아키텍처(Basic)

image

학습 목표

일단 테오가 알려준 학습목표를 생각하면서 과제를 진행하려고 노력했습니다.

1. React에서 추상화 벽이 만들어진 코드의 계층과 경계를 이해하기. 코드를 어떻게 잘 분리를 해두는게 좋은가?

  • 이번 과제에서는 크게 4단계로 나누어서 생각했습니다.
  • 데이터 / 모델 (비즈니스 로직) / 로컬 상태(데이터를 비즈니스 로직 태운 다음 local에서 사용하기 위한 상태) / 컴포넌트
  • 결합도가 낮고, 응집도가 높으면 → 기능 통째로 날려도 잘 돌아가야 한다
  • cart가 어떻게 돌아가는지, product가 어떻게 돌아가는지 서로 알 필요가 없다
  • 이 지점에서 FSD가 최근 각광받는 이유도, 기능별 / 레이어별로 분할이 잘 되어 있어 서로 서로 어떻게 돌아가는지 모르고, 낮은 결합도와 높은 응집도를 제공할 수 있기 때문이라고 생각합니다.
    • 물론 이번 과제에서 쓰기에는 오버엔지니어링이라고 생각하기도 했고, 단순한 구조에서 과제를 진행하며 아키텍처 개선이 이래서 필요하구나를 느껴보고 싶어서 hint의 구조를 참조해서 진행했습니다.
    • 코드가 충분히 분리되어 많은 파일이 생긴 basic 과제 막바지 쯤, 같은 도메인의 코드끼리 묶여져 있지 않은 것의 불편함을 느꼈습니다. 그러면서 UIToast를 FSD처럼 폴더 내에서 세그먼트를 분리하고, 캡슐화를 해서 UI만 export해보았습니다. 해당 커밋

2. 계층의 분리의 과정에서 순수함수의 개념과 디자인 패턴이 어떤 도움을 주는가?

  • 기본적으로는 재사용성을 높이고, 테스트를 쉽게 하고, 이름만으로 내부를 추측하게 해 가독성에 도움을 줍니다.
  • 하지만 모두 적절한 추상화가 있어야 가능합니다.
const filteredProducts = debouncedSearchTerm
  ? products.filter(
      (product) =>
        product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
        (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
    )
  : products;
  • 과제를 진행하면서 위의 filteredProducts함수를 어떻게 분리해야할지 몰라 일단 모두 나눠보았었습니다.
normalizeSearchTerm()           // 1. 검색어 정규화
isValidSearchTerm()            // 2. 검색어 유효성 체크
matchesProductName()           // 3. 상품명 include 여부 확인
matchesProductDescription()    // 4. 상품설명 include 여부 확인
matchesProduct()               // 5. 상품 필터링 로직
filterProductsBySearchTerm()  // 6. 메인 로직
  • 하지만 지나친 분리로 오히려 읽기 힘들어지는 과도한 추상화라는 생각이 들어 다시 이를 아래와 같이 수정했습니다.
normalizeSearchTerm()        // 1. 검색어 정규화
matchesProduct()            // 2. 검색어 유효성 체크 ~ 4. 상품설명 include 여부 확인
filterProductsBySearchTerm() // 5. 상품 필터링 로직 ~ 6. 메인 로직 
  • 검색어 정규화 처리는 재사용의 여지를 고려하여 단독으로 분리했습니다.
  • 유효성 체크 ~ 상품설명 include 여부 확인에서는 메인 비즈니스 로직을 과하지 않은 선에서 묶어주고자 했습니다.

3. 컴포넌트에 대한 계층구조와 상태관리. 컴포넌트 계층 분리는 어떻게 해야 하나?

  • UI 컴포넌트와 Entity 컴포넌트로 나눠서 생각해봤습니다.
  • 원래는 이렇게 생각했습니다(as-is)
    • Entity 컴포넌트: 상태를 import해서 사용하는 컴포넌트
    • UI 컴포넌트: 데이터를 사용하지 않는 컴포넌트

Note

  • 지훈님의 피드백: "장바구니 담기 버튼도 sold out 여부에 따라 바뀌는데?"
    → UI 컴포넌트는 데이터를 몰라도 props로 받아서 처리할 수 있어야 한다!
  • 바뀐 생각(to-be)
    • Entity 컴포넌트: 데이터를 직접 가져와서 사용하는 컴포넌트
    • UI 컴포넌트: data를 주입받아서 사용하는 멍청한 컴포넌트
  • 예를 들자면 다음과 같습니다.
ItemCard (데이터 컴포넌트)
├── 상품 데이터를 직접 가져옴
├── sold out 여부 판단
└── AddToCartButton (UI 컴포넌트)
    ├── 데이터를 주입받아 처리
    └── 멍청해서 시키는 대로만 동작
  • 분리해야 하는 이유
    • 재사용성: UI 컴포넌트는 멍청해서 재사용이 쉽다
    • 가독성 & 유지보수성: 데이터와 엮인 컴포넌트를 데이터 가까이 둘 수 있다
  • 만약 재사용하지 않는 UI 컴포넌트도 분리해야할까?
    • 가독성 문제: 데이터 컴포넌트를 데이터 옆에 두고 싶다
    • 유지보수: 구조 파악이 어려워지고 불필요한 컨텍스트를 읽어야 함

4. 컴포넌트 계층 분리를 하는 과정에서 만나게 되는 props drilling problem을 이해하고 개선 방안 찾기

  • 원래 저는 state를 최상단의 각 페이지들에서 내려주는 방식으로 구현하는 걸 선호했습니다. 그리고 그 방법이 props를 통해서 의존성을 보여주는 방식이라고 생각하고 있었습니다.
  • 이번 과제를 통해서 적절한 props drilling의 기준에 대해서 생각해볼 수 있는 기회가 되었습니다.
  • notificationproductForm, couponForm만 Jotai를 사용해 수정하였습니다.
  • notification: 비즈니스 로직과 무관하게 모든 컴포넌트에서 사용할 수 있는 횡단 관심사 컴포넌트이기 때문
    • 고로 의존성을 보여줄 수도 없고, 어디서든 재사용이 가능해야 함 -> 전역으로 사용하는게 확실히 이득
  • productForm&couponForm: 의존성을 보여줄 수 있기에 굳이 전역으로 관리할 필요는 없음
    • 하지만 지나치게 많은 인터페이스가 노출되고 있어 단순화의 필요성을 느낌
    • 사실 프로젝트였다면 현 시점에서는 추가적인 depth가 없으니 수정을 하지 않았을 수도 있겠지만, props drilling을 체험하는 과제인 만큼, 조금 적극적으로 jotai를 사용했습니다.
  • 저에게 기준을 정하라고 한다면, 횡단 관심사이거나 앱의 크기에 비해서 depth가 깊고 인터페이스가 많을 때 전역 상태관리를 고려해볼 것 같습니다.

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

알게된 점

  • as-is: 1년 반 전에 함수형 액션/계산 분리 찍먹해봄, 아키텍처 모름(MVC 패턴도 이해못함)
  • to-be: 액션/계산/데이터 분리가 왜 중요한지 이해함, 레이어 분리, 기능 분리의 중요성과 아키텍처가 어떻게 구현되는 것인지 깨달음

좋았던 점

  • 다른 사람들과 적극적으로 이야기를 나누면서 진행한 것이 가장 좋았습니다. 이번 주 과제를 하면서 특히 많은 이야기를 나눴던 병준님과 지훈님, 휘린님께 감사드립니다.
  • 병준님: 레이어드 아키텍처와 Store/Service 계층 분리 방식 공유
  • 지훈님: 산파법을 통한 컴포넌트 계층 학습, 기준 세우기의 중요성
  • 휘린님: Hook과 UI 분리, FSD 아키텍처에 대한 친절한 설명

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

데이터 → 모델 → 로컬 상태 → 컴포넌트로 레이어를 분리하는 것

  • hint의 단계대로 레이어를 분리하면서, 각 레이어가 다른 단계의 레이어를 침범하지 않도록 했습니다.

AI 적당히 쓰기

  • 최근 함께 자라기라는 책을 읽고 있는데, 난이도가 실력보다 너무 높으면 불안함을 느끼고, 반대로 난이도가 지나치게 낮으면 지루함을 느낀다고 하더라구요.
    • 그리고 불안함을 느끼고 있다면 해결방안으로 제시된 것이 AI등을 사용해서 난이도를 낮추는 것이였습니다.
  • 이에 지난 주차 과제에서는 적극적으로 AI를 사용했고, 불안함에서 벗어날 수 있었습니다. 하지만 반대로 AI를 지나치게 사용하다보니 남는게 없다는 생각이 들었습니다.
    • 이번 주차에서는 AI는 검색을 대신하는 용도와 자동 완성, 그리고 단순 작업을 위주로 사용했습니다.
    • 이렇게 사용하는 동안 느낀 점은, 일단 지난주에 적극적으로 사용했던 agent가 생각보다 굉장히 멍청하다는 것
    • 그리고 직접 hook과 model 부분을 작성하면서 프론트엔드의 추상화 벽이 나눠지는 것을 이해할 수 있었습니다.
  • 앞으로도 처음해보는 것을 곧바로 AI로 작업하기 보다는, 일단 직접 해보면서 이해를 하고 난 후 AI로 작업하려고 합니다.

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

  • 이번 과제를 진행하면서 프론트엔드 아키텍처에 대한 시야를 넓힐 수 있었습니다. 특히 병준님의 경우 레이어드 아키텍처를 사용해서 상태 변경은 Store에, 비즈니스 로직은 Service 계층에 분리하는 방식을 진행하셨는데, 저도 이런 방식으로 진행해보고 싶습니다.

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

  1. 함수 분리 기준: 코치님은 어떤 기준으로 함수를 분리하시나요?
  2. Jotai 적용 범위: productForm과 couponForm에도 Jotai를 적용했는데, 객체로 묶어서 props로 전달하는 것이 더 나았을까요?
  3. 액션 함수 처리 방식: 클로저 vs 명시적 매개변수 전달 중 어떤 방법을 선호하시나요?

refactoring 참조 및 shared 폴더 추가
실수로 origin에 주석작성함,,
- calculateItemTotal, getMaxApplicableDiscount 함수 리팩토링
- 전역 상태 의존성을 제거하여 순수함수로 변환
- calculateCartSubtotal: 할인 전 총액 계산 함수 추가
- calculateCartDiscountedTotal: 상품 할인 적용된 총액 계산 함수 추가
- applyCouponDiscount: 쿠폰 할인 적용 함수 추가
- getCartTotal: 전역 상태를 사용하는 액션 함수 추가
- removeCartItem: 장바구니 아이템 제거 순수 함수 추가
- updateCartItemQuantity: 장바구니 아이템 수량 업데이트 순수 함수 추가
- processQuantityUpdate: 수량 변경 로직 검증 및 처리 순수 함수 추가
- updateQuantity: 비즈니스 로직을 순수 함수로 분리
9.32.0 버전에서 형식의 설정파일을 지원하지 않음
형식으로 마이그레이션
- Header는 양 도메인 모두에서 사용되기에 1 death 위로 올림
- SearchBar는 cartPage에서만 사용되기에 cart 폴더 내 위치
- useCouponForm hook
- couponForm model
- couponFrom constant
- couponForm type
- useProductForm hook
- productForm model
- productForm constant
- productForm type
- notificationsAtom 구현
- notifications 관련 propDrilling 제거
- useNotificationActions 구현
- atoms 내에서 비즈니스 로직을 사용하는 경우가 있기에 shared는 부적절
Copy link
Copy Markdown

@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과 셀프회고 잘 정리해주셔서 코드 구조, 아키텍처 의사결정의 맥락이 잘 보입니다. 아래 리뷰는 요청하신 흐름(기술 요구사항 변화 시나리오 → 변화 시 영향 분석 → 응집도/결합도 관점에서 개선 제안)을 따릅니다. Github PR 코멘트로 그대로 붙여넣을 수 있도록 마크다운 형식으로 드립니다.

요약: 현재 코드는 컴포넌트/훅/모델/atom(상태)으로 계층을 나누는 작업이 잘 이루어졌고 UIToast, Product/Coupon 폼 등 도메인별로 파일이 분리되어 있습니다. 다만 Jotai에 직접 의존하는 부분(특히 atoms의 파생 액션들이 외부 콜백을 직접 받는 형태)과 몇몇 컴포넌트-상태 결합은 라이브러리 교체(예: Jotai → zustand / redux / tanstack-query)나 패키지화 시 비용을 발생시킬 수 있어 추상화 계층 추가를 권장합니다.


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

  1. 함수 분리 기준 — 제가 쓰는 기준(실무 관점)
  • 단일 책임(Single Responsibility): 함수가 하나의 책임/관심사만 갖는가?
  • 사이드 이펙트 여부: 순수함수 가능성(입력 → 출력, 외부 상태 변경 없음). 테스트하기 쉬운가?
  • 재사용성/추상화 레벨: 동일 레벨의 추상화들을 섞고 있지는 않은가?
  • 복잡도/길이: 40~60줄 이상이거나 여러 레이어(유효성 검사 → 포맷 → 비즈니스) 혼재 시 분리 고려
  • 변경 비용(귀하의 응집도 정의): 변경 시 수정해야 할 파일 수/위치가 짧은가?

실전 팁: "순수 함수(Pure) = 모델·계산 로직" / "훅 = 액션 호출·사이드이펙트 + 모델을 조합해 UI 데이터 준비" 로 기준을 정하면 쉽습니다.

  1. Jotai 적용 범위 (productForm / couponForm에 적용한 것)
  • 판단 기준:
    • 횡단 관심사(앱 어디서든 접근)거나 depth/prop-drilling이 심할 때 전역 상태 적절.
    • 단일 컴포넌트 또는 명확한 부모-자식에서만 쓰이면 로컬 상태(혹은 도메인 훅)가 더 낫다.
  • 현재 코드: notification, productForm, couponForm을 Jotai로 분리한 것은 과제 의도(props drilling 체험)와 맞고 notification은 전역으로 사용할 합리적인 대상입니다. 다만 productForm/couponForm은 "전역화할 만큼 넓게 쓰이나?"를 다시 따져볼 필요가 있습니다.
    • 장점: 여러 컴포넌트에서 상태를 읽고 조작 가능 → 편리
    • 단점: Jotai 디테일(atom API)이 컴포넌트에 샘(누수)되어 라이브러리 교체 시 영향 범위가 넓어짐
  • 제안: atoms를 직접 사용하는 컴포넌트를 최소화하고, "도메인 훅(useCouponForm/useProductForm)"으로 캡슐화해 Jotai 의존을 한 곳으로 모으세요. 그러면 라이브러리 교체나 패키지화가 쉬워집니다.
  1. 액션 함수 처리 방식: 클로저 vs 명시적 매개변수
  • 권장: 외부에서 결정할 수 있는 행동(onSuccess/onError, 옵션)은 명시적 콜백/옵션으로 전달하는 편이 결합도가 낮습니다.
    • 좋은 예: useAddProduct({ onSuccess, onError }) 또는 addProduct(product, { onSuccess, onError })
    • 클로저(내부 캡처)는 문맥을 숨길 수 있고 테스트·재사용성 저하. 다만 작은 모듈 내부에서 불변 컨텍스트를 캡처하는 것은 간결성 측면에서 괜찮습니다.
  • 요약: API를 설계할 때 "부작용(알림 등)은 호출자에게 위임(onSuccess/onError)"하면 결합도가 낮아집니다.

종합 피드백

  1. PR에서 보이는 핵심 키워드 (요약)
  • 분리된 레이어(components / atoms / models / hooks)
  • Jotai 기반 전역 상태관리 도입 (atoms 단위 액션 포함)
  • UIToast 컴포넌트화 및 토스트 모델 분리 (ui, model, type 등)
  • Admin/Cart 페이지 컴포넌트 분리 (ProductForm/CouponForm 등)
  • 테스트에서 Jotai Provider 추가
  • 포맷/린트/빌드 스크립트 정리 (prettier, eslint.config.js 변경)
  1. PullRequestBody의 "과제 셀프회고"와 "내가 제일 신경 쓴 부분"에 대한 인사이트
  • 잘 한 점
    • 레이어(데이터→모델→로컬 상태→컴포넌트) 분리에 대한 의도와 실제 구현이 일치합니다. 모델(순수 함수)과 훅(사이드 이펙트)을 의식적으로 분리한 점이 인상적입니다.
    • 과도한 추상화(초반의 작은 함수들 다 쪼갠 사례)를 스스로 줄여 가독성·실용성 관점에서 조정한 판단은 좋은 경험적 결정입니다.
    • 협업(동료와의 토론)을 통해 설계안을 보완해온 과정이 설계 품질 향상에 큰 도움이 됩니다.
  • 더 생각해볼 점(질문으로 이어지는 부분)
    • productForm / couponForm 같은 도메인 상태를 전역으로 잡았을 때, 라이프사이클(리셋/동기화/초기값 제공)은 누가 책임질지(컴포넌트? 훅? atom?)를 명확히 하셨나요? (현재는 atoms에 리셋 액션이 있어 OK지만, 추후 라이브러리 변경 시 위치가 애매해질 수 있음)
    • 현재 atoms에서 onError 콜백을 직접 받는 패턴이 보이는데(예: validateCouponDiscountValueAtom), 이게 컴포넌트 쪽 의존성/테스트 편의성에 어떤 영향을 미칠지 고민해 보셨나요?
    • 테스트에서 Jotai Provider를 각 테스트에 삽입하는 방식은 안정적입니다. 다만 훅을 통해 상태 접근을 캡슐화하면 테스트에서 Provider를 몰라도 되는 경우가 생길 수 있습니다. 훅 레이어를 얼마나 얇게 만들지 전략을 정해보세요.
  1. PullRequestBody의 "리뷰 받고 싶은 내용"에 대한 답변 (정리)
  • 함수 분리 기준: 위 5가지 기준을 따르세요 (단일 책임/부작용/재사용성/복잡도/변경 비용). 구체적 룰 예시는 상세 피드백에 코드로 제공합니다.
  • Jotai 적용 범위: notification은 전역이 적절. productForm/couponForm은 "depth + 인터페이스 복잡도"가 높지 않다면 로컬 훅 또는 provider 패턴(특정 영역의 Provider)에 두는 것이 더 포터블. 만약 전역으로 유지한다면 atoms를 직접 노출하지 말고 useXForm 훅으로 캡슐화해야 라이브러리 전환이 수월합니다.
  • 액션 처리(클로저 vs 매개변수): 가능한 경우 onSuccess/onError 같은 콜백을 명시적 인자로 받아 호출자에게 책임을 위임하세요.

상세 피드백 — 핵심 개념 정의 먼저

  • 응집도(Cohesion) — 여기서 사용하는 정의
    • 변경에 대한 파일과 코드의 추가/수정/삭제 경로가 얼마나 짧은가 (즉, 특정 기능을 수정할 때 건드려야 하는 파일 수가 적을수록 응집도가 높음).
    • 해당 기능을 라이브러리(패키지)로 만들 때, 외부에 얼마나 깔끔하게 떼어낼 수 있는가(내부 구현 세부사항을 숨기고 public API만 남길 수 있는가).
  • 결합도(Coupling)
    • 모듈/함수/컴포넌트가 서로를 직접 참조(구현 의존)하는 정도. 인터페이스(옵션/콜백)를 통해만 상호작용하면 결합도가 낮음.
    • 예: addNotification을 외부에 고정된 함수명으로 주입받는 경우(낮음), vs onSuccess/onError 콜백을 받는 경우(낮음). 반면 Jotai atom을 직접 import해 사용하는 컴포넌트가 많으면 Jotai에게 강하게 결합된 구조가 됨.

상세 항목별 문제 정의 + AS-IS / TO-BE (코드 예시 포함)

아래 항목들은 PR 파일들을 분석하여 뽑은 개선 포인트입니다.


1) Atom 액션이 외부 콜백을 직접 받는 구조(결합도 문제)

  • 개념: 도메인 로직(예: coupon form validation/submit)과 UI 행동(알림 띄우기 등)이 결합되면 라이브러리 변경/테스트가 불편해짐.
  • 문제(AS-IS)
    • src/advanced/atoms/couponFormAtoms.ts
      • validateCouponDiscountValueAtom = atom(null, (get, set, value: string, onError: (message) => void) => { ... })
      • submitCouponFormAtom = atom(null, (get, set, onSubmit: (coupon) => void) => { ... })
    • 이 패턴은 atom 액션에 UI 콜백(onError, onSubmit)을 직접 넘기는 형태로, 호출자가 jotai API를 알고 있어야 하고 atom 내부 코드가 UI용 콜백 규약에 의존합니다.
  • 왜 문제인가?
    • 라이브러리(예: Jotai → zustand) 변경 시 동일한 콜백 시그니처를 재현해야 하고 호출 코드 전부 수정될 가능성.
    • 테스트에서 콜백을 직접 주입해야 해 번거로움이 증가.
  • TO-BE (제안)
    • atoms는 순수 액션(또는 결과를 반환)만 제공하고, UI 콜백은 훅 레이어에서 받도록 캡슐화한다.
    • 도메인 훅을 만들어 UI가 사용할 공용 API를 제공.

AS-IS (간단화된 형태)

// atoms/couponFormAtoms.ts (AS-IS)
export const validateCouponDiscountValueAtom = atom(null, (get, set, value: string, onError: (msg: string) => void) => {
  const current = get(couponFormAtom);
  const validation = validateDiscountValuePure(Number(value), current.discountType);
  if (!validation.isValid) onError(validation.message);
  set(couponFormAtom, { ...current, discountValue: validation.correctedValue });
});

TO-BE (훅으로 캡슐화)

// hooks/useCouponForm.ts (TO-BE)
import { useAtom } from 'jotai';
import { couponFormAtom, updateCouponCodeAtom, updateCouponNameAtom, validateCouponDiscountValueAtom /* keep internal if needed */ } from '../atoms/couponFormAtoms';

export const useCouponForm = () => {
  const [form, setForm] = useAtom(couponFormAtom);

  const updateName = (name: string) => setForm(f => ({ ...f, name }));
  const updateCode = (code: string) => setForm(f => ({ ...f, code: normalizeCode(code) }));

  const validateDiscountValue = (value: string) => {
    const parsed = Number(value);
    const validation = validateDiscountValuePure(parsed, form.discountType);
    // 반환값으로 성공/에러를 알려주고 UI는 onError로 처리
    return validation; // { isValid, message?, correctedValue }
  };

  const submit = (onSubmit: (coupon) => void) => {
    const coupon = formToCoupon(form);
    onSubmit(coupon);
    setForm(DEFAULT_COUPON_FORM);
  };

  return { form, updateName, updateCode, validateDiscountValue, submit };
};
  • 장점: UI는 useCouponForm을 사용하여 onError/onSuccess를 자신이 원하는 형태로 처리. atoms 내부의 Jotai 의존은 훅에 한정되어 라이브러리 전환 시 교체 지점이 명확해짐.

2) Jotai 의존이 레이어 곳곳에 퍼짐(모듈화/교체 비용 증가)

  • 문제(AS-IS)
    • components와 tests에 직접 useAtom / Provider를 쓰는 사례가 늘어남 (ToastContainer, ProductForm, ProductFormAtoms 등).
    • App와 각 페이지는 이제 Jotai-specific API 대신 훅들을 통해 상태를 얻도록 리팩터링 되어 있지만 atoms가 외부로 많이 노출되어 있습니다.
  • TO-BE 제안
    • public API(패키지로 추출할 때 외부 노출될 인터페이스)는 훅(예: useCart, useProducts, useCouponForm, useNotificationActions)으로 한정하세요.
    • atoms 폴더는 내부 구현(detail)로 두고 package boundary 안에 넣습니다. 즉 components import는 hook 또는 domain service만 사용.

예시: store adapter로 추상화

// store/adapter.ts (interface)
export type Store = {
  getCart: () => CartItem[];
  addToCart: (product: Product) => Promise<Result>;
  // ...
};

// store/jotaiAdapter.ts (implementation)
import { /* jatai api */ } from 'jotai';
export const createStore = (): Store => ({
  getCart: () => /* read atoms */,
  addToCart: async (product) => { /* atom update */ },
});
  • benefit: 새 상태관리자로 바꿀 때 adapter만 교체.

3) 기능별 폴더 응집도 평가 (현재 상태 vs 개선안)

  • 좋은 응집도(현재 잘된 점)

    • UIToast: components/ui/UIToast/* 로 model/type/ui로 분리 — 이건 패키지화하기 좋은 구조입니다.
    • admin components: ProductList/ProductForm/CouponForm 잘 분리됨.
    • atoms 폴더: productFormAtoms, couponFormAtoms, notificationAtoms 분리됨.
  • 아쉬운 점

    • atoms의 액션들이 외부 콜백을 받고, 일부 로직(formToProduct 등 모델)과 UI 콜백이 섞여 있음 → 추출 시 재사용이 어렵습니다.
    • 일부 유틸/모델이 src/advanced/models와 components/shared/utils 등에 분산되어 있으면 패키지화할 때 파일 이동이 많이 필요합니다.
  • TO-BE(폴더 구조 예시: 패키지 추출 가능하도록)

    • packages/
      • cart/ (domain: hooks, models, types) -> export useCart API
      • products/ (domain: hooks, models, types) -> export useProducts API
      • ui-toast/ (ui + internal state) -> export + hooks
      • shared/ (types, formatters, constants)
    • 각 패키지는 내부에서 상태관리(혹은 외부 store adapter)를 사용하되 public API는 hooks/components로 한정.

4) API 설계: 액션 함수 인터페이스 (결합도 낮추기)

  • 안 좋은 예(결합도가 높음)
// 사용처 강제: addNotification라는 구체적 이름을 요구
const useAddProduct = (addNotification) => { ... }; // addNotification 주입을 반드시 요구하면 호출자는 addNotification 형태에 결합됨
  • 좋은 예(결합도 낮음 — 호출자가 결정)
const useAddProduct = () => {
  const addProduct = async (product, { onSuccess, onError } = {}) => {
    try {
      // 로직
      onSuccess?.();
    } catch (e) {
      onError?.(e);
    }
  };
  return { addProduct };
};
  • 이유: 호출자는 알림/로깅/추가 행위를 자유롭게 정할 수 있어 재사용성이 올라갑니다.

5) Tests: Jotai Provider 반복 사용 문제 vs 훅 캡슐화

  • 현재: tests에 Provider를 직접 넣어 renderWithProvider 구현 — 안정적.
  • 개선: useX 훅 API만 테스트 대상으로 삼고, atoms 내부 동작은 유닛 테스트(순수 함수)로 검증. 통합 테스트에서는 renderWithProvider 유지. 이렇게 하면 테스트 유지보수가 쉬워집니다.

개선된 코드 예시(구체적 변환 샘플)

문제: couponFormAtoms.submitCouponFormAtom가 onSubmit을 인자로 요구하는 경우 → 캡슐화된 훅으로 바꿔 호출자는 단순한 함수만 사용.

AS-IS (atoms)

// atoms/couponFormAtoms.ts (AS-IS)
export const submitCouponFormAtom = atom(null, (get, set, onSubmit: (coupon) => void) => {
  const currentForm = get(couponFormAtom);
  const couponData = formToCoupon(currentForm);
  onSubmit(couponData);
  set(couponFormAtom, resetCouponForm());
  set(showCouponFormAtom, false);
});

TO-BE (hooks)

// hooks/useCouponForm.ts (TO-BE)
import { useAtom } from 'jotai';
import { couponFormAtom, resetCouponFormAtom, showCouponFormAtom } from '../atoms/couponFormAtoms';
import { formToCoupon } from '../models/couponForm';

export const useCouponForm = () => {
  const [form, setForm] = useAtom(couponFormAtom);
  const [, resetForm] = useAtom(resetCouponFormAtom);
  const [, setShow] = useAtom(showCouponFormAtom);

  const submit = (opts?: { onSubmit?: (coupon) => void; onSuccess?: () => void; onError?: (err) => void; }) => {
    try {
      const couponData = formToCoupon(form);
      opts?.onSubmit?.(couponData);
      opts?.onSuccess?.();
      resetForm();
      setShow(false);
    } catch (e) {
      opts?.onError?.(e);
    }
  };

  return { form, setForm, submit };
};

호출부 (AdminForm)

const { submit: submitCouponForm } = useCouponForm();
const handleCreate = () => {
  submitCouponForm({
    onSubmit: (c) => createCoupon(c),
    onSuccess: () => addNotification({message:'생성됨', type:'success'}),
    onError: (e) => addNotification({message:String(e), type:'error'}),
  });
};

이렇게 하면 atoms는 내부 구현으로 남고, UI는 훅 API만 사용해서 라이브러리 교체가 쉬워집니다.


기술 요구사항 변경 시나리오별 영향 분석

  1. 시나리오: 상태관리 라이브러리가 Jotai → Zustand 또는 Redux 또는 TanStack Query로 변경
  • 현재 코드의 취약점
    • atoms가 프로젝트 전역에 퍼져 있고 components/tests가 직접 Provider/atom API를 사용.
    • atoms에 UI 콜백(onError/onSubmit)을 파라미터로 받는 디자인이 다수.
  • 변화 시 영향
    • Jotai 의존 부분을 직접 사용하는 컴포넌트(ToastContainer, ProductForm 등)를 전부 바꿔야 함.
    • atoms 액션 시그니처와 사용법이 달라지므로 호출부 전부 수정.
    • tests에서 Provider 모킹을 다시 만들어야 함.
  • 완화(권장) 전략
    • 훅 계층(useCart/useProducts/useCouponForm/useNotificationActions)을 만들고 모든 컴포넌트는 훅만 사용하도록 강제합니다.
    • store adapter나 인터페이스를 만들어 각 상태관리 구현체에 대해 어댑터만 바꿈.
    • atoms/implementation 폴더를 내부 구현으로 한정하고 public API는 hooks/index.ts 만 노출.

예: useCart 추상화

// hooks/useCart.ts
export type UseCartAPI = { cart, addToCart, updateQuantity, remove, calculateTotal };

export const useCart = (): UseCartAPI => {
  // 내부 구현: jotai / zustand / react-query 등 어떤 구현도 될 수 있음
};

swap: 구현만 교체하면 대부분의 컴포넌트에 영향 없음.

  1. 시나리오: 모듈화를 하여 패키지로 배포해야 하는 경우
  • 체크리스트 (응집도/결합도 관점)
    • 응집도가 높은가? (동일 도메인 파일이 한 폴더에 모여있는가)
      • UIToast: 응집도 높음 → 그대로 떼어내 패키지화 가능
      • productFormAtoms + productForm model + ProductForm component: 현재는 분리되어 있으나 모델/atoms/component가 같은 도메인 폴더에 모이면 응집도 높음.
    • 결합도는 낮은가? (외부 구현체(예: Jotai)에 직결된 참조가 적은가)
      • atoms가 직접 export되어 여러 컴포넌트에서 import된다면 결합도가 높음.
  • 어떤 코드들이 서로 엮여있는가?
    • ProductForm (component) ↔ productFormAtoms (atoms) ↔ productForm models (formToProduct etc.)
    • UIToast component ↔ notificationAtoms
    • AdminPage/CartPage ↔ useProducts/useCart hooks ↔ hooks 내부에서 atoms사용
  • 패키지화 권장 분리(예시)
    • packages/ui-toast: components + internal state (노출 API: 및 useToast() 훅)
    • packages/domain-products: hooks/useProducts + models + types (노출 API: useProducts)
    • packages/domain-cart: useCart, calculate functions (노출 API: useCart)
    • packages/shared-types: types/interfaces (공유)
  • 추출 전략
    • 1단계: 각 도메인의 public API(훅/컴포넌트)를 정리해서 project 내부에서만 사용하게 변경
    • 2단계: internal atoms / models 파일을 도메인 내부로 이동(외부 import 금지)
    • 3단계: 패키지로 분리(퍼블릭 API만 export)
  • 예시: UIToast 패키지 추출
    • 내부: notificationsAtom, ui.ts, model.ts, type.ts
    • public: ToastContainer (컴포넌트), useNotificationActions (hook)
    • 외부: app은 useNotificationActions().addNotification(...)만 사용 → 내부 구현 변경(예: Jotai → eventbus) 시 영향 최소화

최종 권장 액션 리스트 (우선순위 포함)

  1. (우선) atoms 액션이 UI 콜백을 직접 받는 부분을 훅 API로 캡슐화

    • files: src/advanced/atoms/* → create hooks in src/advanced/hooks/
    • 이유: 라이브러리 교체/패키지화시 영향 최소화
  2. (중간) store adapter / facade 레이어 도입

    • create src/advanced/store/adapter.ts 인터페이스 정의, jotai-impl와 같은 구현으로 시작
    • 이유: 향후 zustand/redux/react-query 등으로 교체 비용 감소
  3. (중간) 폴더 구조 정리 (패키지 추출 준비)

    • domain별( cart, products, coupon, ui-toast )로 묶고 internal/public 파일 분리
  4. (낮음) atoms를 컴포넌트에서 직접 import하지 않도록 규칙 적용

    • 코드리뷰 체크리스트에 추가
  5. (테스트) 유닛 테스트에서 순수 함수/모델을 더 많이 테스트하고, 통합 테스트는 provider로 격리

    • 이유: 테스트 속도 및 명확성

PR 파일별 상세 코멘트(요지)

  • src/advanced/App.tsx
    • 장점: 큰 컴포넌트가 여러 하위 페이지(AdminPage, CartPage)와 훅으로 분리되어 가독성이 좋아졌음.
    • 개선: App에서 formatPrice 같은 UI 전용 로직이 이미 제거되었지만, 아직 formatPrice가 hook 내부에 남아있다면 (보시면 formatPrice가 제거된 듯) UI 포맷 관련 로직은 shared/utils로 옮겨 테스트하고 재사용하세요.
  • src/advanced/components/ui/UIToast/*
    • 매우 좋은 구조: model/type/ui로 분리 — UIToast는 패키지화 후보로 적합합니다.
  • src/advanced/atoms/*
    • 좋은 시도: atoms와 파생 액션을 분리함.
    • 개선: 파생 액션은 훅으로 캡슐화(또는 적어도 atoms 내부에서 외부 콜백을 받지 않도록)
  • src/advanced/components/admin/ProductForm.tsx / CouponForm.tsx
    • 현재 훅과 atom을 같이 사용하고 있는데(특히 validate blur 시 addNotification 직접 호출), 이 부분을 useProductForm/useCouponForm 훅으로 묶어 공용 상태/행동을 노출하면 컴포넌트는 단순 UI로 남습니다.
  • src/advanced/tests/origin.test.tsx
    • 잘 처리하심: Jotai Provider를 test util로 분리( renderWithProvider ).
    • 개선: 테스트가 Jotai 구현 세부사항에 의존하고 있으므로 향후 adapter/훅로 캡슐화하면 테스트도 더 단순화됩니다.

마무리 및 추가 질문(권장)

  • 제안한 훅 캡슐화로 바꿀 때, 저는 다음 순서로 진행합니다:
    1. 현재 atoms 액션을 내부적으로 사용하고 있는 훅(useProductForm 등)을 만든다. (Atom 액션을 그대로 호출)
    2. 컴포넌트에서 atoms 직접 import한 부분을 훅으로 바꾼다.
    3. tests에서는 hooks를 직접 사용하도록 리팩터링.
    4. 이후 store adapter를 도입하여 implementation 교체 시 adapter만 바꾼다.
  • 추가로 몇 가지 질문을 드립니다:
    1. productForm/couponForm이 진짜로 앱 전역에서 여러 곳에서 읽고 쓰이나요(예: multi-step wizard, 여러 admin 패널)? 아니면 admin page 내부에서만 쓰이나요? (전역화 여부 결정에 영향)
    2. 패키지화(실제로 npm 패키지나 mono-repo 패키지) 계획이 있으신가요? 아니라면 내부 모듈화 수준으로도 충분할 수 있습니다.
    3. 액션에 대한 표준(onSuccess/onError) 인터페이스를 팀 규약으로 정할 예정이신가요? (일관성 있는 API가 이후 교체나 자동화에 유리합니다)

필요하시면 다음 작업을 직접 도와드릴 수 있습니다:

  • useCouponForm / useProductForm / useNotificationActions 훅 템플릿을 PR용 코드로 만들어 드리기
  • store adapter 인터페이스 + jotai 구현 예시 코드
  • UIToast를 패키지로 떼어내는 리팩토링 스크립트(파일 이동 + export 정리)

원하시는 다음 단계 알려주시면 구체 코드(리팩터링 diff)로 바로 제공하겠습니다.

Copy link
Copy Markdown

@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.

함수만 store로 가져다 쓸때 useSetAtom을 써도 되더라고요 대괄호 안치고 변수명만 입력해도
값만 가져올땐, useAtomValue을 쓰면 되는거 같고요

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

저는 그냥 따로다로 나와서 오히려 보기가 불편하더라고요.
저도 index 한곳에 넣어서 export시킬걸 그랬네요

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

토스트 테마와 색상별로 나누니까 눈에 더 잘들어오네요

Comment thread src/basic/App.tsx
setTotalItemCount(count);
}, [cart]);

// 로컬 스토리지 동기화
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이미 useLocalStorage를 사용하시면서 동기화 부분을 해결해두셨는데 해당 부분을 아직 지우지 않으신것 같아요!

Comment thread src/basic/App.tsx
}, 3000);
}, []);

const [totalItemCount, setTotalItemCount] = useState(0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이곳은 원래 상태로 존재하던 값을 계산하는 부분이고, 다른 곳에서 set할 필요는 없으니 상태가 아닌 그냥 변수에 계산값 할당으로도 둘 수 있을것같아요!

export function useProducts() {
const [products, setProducts] = useLocalStorage<ProductWithUI[]>('products', initialProducts);

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

거의 모든 함수에 JSDoc이 달려있는데 너무 보기 편하네요! 구분하기도 좋구요!

setSelectedCoupon(coupon);
};

return {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

저도 이번 과제에서 피드백 받았던 내용인데, 리턴할때 이렇게 너무 길어지면 알아보기도 힘들고 프롭스로 다시 넘겨주기도 힘드니, state, action 등으로 한번 묶어서 내보내는것도 좋아보입니다! 이렇게 네임스페이스로 묶어서 내보내면 사용하는곳에서 너무 편리하더라구요! (지난번에 의찬님께서 저한테 설명해주려고 하셨던 부분입니다!)

/**
* 쿠폰 폼 표시 여부 atom
*/
export const showCouponFormAtom = atom<boolean>(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

의찬님도 액션을 모두 스토어에 잘 작성해두셨네요! 저는 이번 과제가 리팩토링하는 과정이 포함되다보니 상태만 아톰으로 변경하고 모델함수는 그대로 사용했는데, 그렇게 하면 변경자체는 빠릅니다..!!

Comment thread src/advanced/App.tsx
Comment on lines +17 to +20
addToCart: addToCartHook,
removeFromCart,
updateQuantity: updateQuantityHook,
applyCoupon: applyCouponHook,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

addToCart, updateQuantity, applyCoupon처럼 이미 의미가 명확한 네이밍인데, 따로 새 이름을 지어서 사용하신 이유가 궁금합니다. 혹시 별도로 구분해야 할 컨텍스트가 있었을까요?

/**
* 쿠폰 폼 데이터 atom
*/
export const couponFormAtom = atom<CouponFormDataType>(DEFAULT_COUPON_FORM);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CouponForm의 데이터는 CouponForm.tsx 컴포넌트에서만 사용되는 걸로 보이는데, 해당 데이터를 전역 상태로 관리하신 이유가 궁금합니다!

@ckdwns9121
Copy link
Copy Markdown
Member

ckdwns9121 commented Aug 10, 2025

의찬님 PR 잘읽고 갑니다. 전체 아키텍쳐를 저렇게 시각화 하다니 대단하신데요? ㅋㅋㅋ
글구 filteredProducts 함수 관련해서 분리하는 과정에 과도한 추상화를 경험하시고 적정선으로 조정하신부분이 되게 인상 깊네요.. 저도 어디까지 추상화해야하는지 항상 고민하는데 되게 기준이 어려운거같습니다. 이번주도 고생하셧서영❗👍

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.

6 participants