Skip to content

[6팀 이지현] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 #32

Open
j2h30728 wants to merge 52 commits intohanghae-plus:mainfrom
j2h30728:main
Open

[6팀 이지현] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 #32
j2h30728 wants to merge 52 commits intohanghae-plus:mainfrom
j2h30728:main

Conversation

@j2h30728
Copy link

@j2h30728 j2h30728 commented Aug 4, 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는 잘 제거했나요?

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

과제 셀프회고

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

알게 된 점

  • 리액트 프로젝트를 항상 새로 만들거나 읽기 힘들정도의 레거시를 맛보지 못햇다는 것을 깨닫게 되었습니다.
  • 프롭드릴링의 끔찍함에 대해서 잘 몰랐다는 걸 깨달았습니다. 제가 훅을 적절하게 분산시켜 만들지 못한 점도 있겠지만, 프롭드릴링을 경험하니 숨이 턱턱 막혀서 사이다 오백만병을 찾고싶었습니다.
좋았던 점
  • 쏙쏙 함수형 코딩을 가볍게 읽어 본적이 있는데 적용해볼 생각은 못했었습니다. 책에서 강조하는 순수함수의 '계산'이라는 포인트가 계속 머릿속에 맴돌아서 학습에 정말 좋았습니다.
    물론, 이 과제가 그것을 기반으로 잘 작성되었는가?라고 하면 아니라고 1초만에 대답할 것 같습니다.
  • 그래도 발제에서 강조하셨던 데이터 > 계산 > 액션을 지키려 노력을 많이 했습니다.

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

Basic : Model > Service > Hook > View 4계층

선택 이유
사실, 힌트 폴더를 보면서 Model > Hook > View 로 3계층을 만들 생각이었습니다.
각 도메인의 Model 내 함수에 존재하는 복잡도와 역할의 범위가 너무 차이가 많이 나기 시작했습니다.
하나의 모델 안에 단순히 매핑하거나 순회를 도는 함수가 있는 반면, 모델함수를 조합하여 비즈니스 로직 함수가 존재하기도 했습니다.

해당 내용을 퍼블렉시티에게 설명하면서 model을 어떻게 관리해야할지 조언을 얻었고, 비즈니스로직중 단순 계산이나 포맷팅은 model과 비즈니스로직 및 조합의 경우는 service로 분리하는 방식을 선택하게 되었습니다.

구조

저처럼 힌트를 확인하고 시작한 스터디원이라면 거의 똑같은 계층을 생각하고 만들지 않았을까 합니다.

데이터의 호출은 model > service > hook 순으로만 부르게 작성했습니다.
hook 에서는 model을 직접 부르지 않고 service를 부르게끔 노력했으며, 다른 서비스 간 함수는 호출하되 순환참조는 일어나지 않게 했습니다.
후반부에 급하게 작성한 것들이 너무 많아 이 룰이 지켜졌을지는 잘 모르겠습니다. (제발..)

sequenceDiagram
    Component->>Hook: 이벤트 발생
    Hook->>Service: 비즈니스 로직 실행
    Service->>Model: 데이터 조작
    Model-->>Service: 결과 반환
    Service-->>Hook: 처리 결과
    Hook-->>Component: 상태 업데이트
Loading

상태

model > service 순으로 도메인 중심적으로만 생각하여 reducer(useReducer)를 채택하여 cart, product, coupon 을 관리하고 있습니다.
결국에는 볼륨이 다소 큰 useCart ,useActionCart 와 같이 상태와 액션 훅을 각각 만드는 상황이 되었습니다.

contextAPI에서 상태 프로바이더와 액션 프로바이더를 각각 만드는 것이 생각이 났으며, 그런 관점으로 구축해나갔습니다.

코치님들이 훅을 최대한 분리 하라는 말씀을 정말 많이 하셨는데 저는 그러지 못해 조금 아쉽다는 생각이 들었습니다.
각각의 도메인을 중심으로 관리하려 했기에, 다른 스터디원 분들의 코드와 솔루션 코드를 참고해서 좀 더 인사이트를 얻어야 할것 같습니다.

Advanced : 3계층 + Atoms

(사실, 3계층 + atoms 이름은 클로드가 알려줬습니다. 하하)
cart,coupon, product와 같이 도메인마다 contextAPI로 묶으려고 했던 것을 jotai로 교체 했더니 생각한것이랑 비슷하면서 달랐습니다.
원래도 취향으로 인해 jotai나 recoil을 잘 쓰는 편이 아니라 쪼금 마음에 안들기도 했습니다.

흐름을 따라갈 수 있다는 관점에서는 프롭드릴링 좋다는 생각을 했었고, 컴포넌트 관리나 복잡성을 줄이기 위함에서는 jotai가 월등히 좋은 것을 체감했습니다.

sequenceDiagram
    participant C as Component
    participant H as Hook
    participant A as Atom
    participant S as Service
    participant M as Model

    C->>H: 이벤트 발생
    H->>A: 전역 상태 읽기
    H->>S: 비즈니스 로직 실행
    S->>M: 데이터 조작
    M-->>S: 결과 반환
    S-->>H: 처리 결과
    H->>A: 전역 상태 업데이트
    A-->>C: 자동 리렌더링 (Props drilling 없음)
Loading

순수함수

model 과 service 계층의 함수는 모두 순수함수로 만들었습니다.

아쉬운 점

피알을 작성하면서 코드를 다시 확인 하니 타입 일관성을 지키지 않은 부분도 있더라구요?!
service 계층에서는 밸리데이트, 포맷팅, 에러핸들링이 함께 존재하기 때문에 결과 값을 타입으로 두어 일관성을 주려고 노력했습니다.
해당 비즈니스 로직이 성공인가 실패인가에 대한 내용도 값으로 지정하여 반환된 곳에서 사용할 수있겠끔 했습니다.

초반에는 Result 타입으로 success, value, message 로 포맷팅하여 사용하고 있었습니다.

하지만 notification 함수에 인자를 넘겨주기 위해서는 어떤 상태(status)인가에 대한 정보가 필요했습니다.
success를 제거하고 status('success','error')를 전달함으로써, 의미상으로 성공여부를 줄 뿐 아니라 notification 인자에 그대로 넘겨 주었습니다.

  • 변경의도 : notification.add(result.message, result.status);
  • 현재 : onSuccess: (message) => notification.add(message, "success"), (흑, 어쩌다 바꿔버렸다..)
    현재 코드에서는 다르게 notification을 띄우지만 해당 타입 선언은 위와 같은 패턴을 위하여 사용했습니다.

그런데 위에서 말했듯이 타입을 완전히 정리하지 못했었더라구요. 타입을 확인하다 놀래서 눈물쭈륵 하면서 피알에 쓰게 되었습니다.

export type CartOperationResult =
  | {
      value: CartItem[];
      message: string;
      status: "success";
    }
  | {
      message: string;
      status: "error";
    };

export type CouponOperationResult =
  | {
      success: true; // 이걸 정리했어야 했는데!
      value: Coupon[];
      message: string;
      status: "success"; // 이미 stauts 가 'success'임을 보여주는것이데 중복 코드 ㅠ_ㅠ
    }
  | {
      success: false;
      message: string;
      status: "error";
    };

export type OrderOperationResult =
  | {
      orderNumber: string;
      message: string;
      status: "success";
    }
  | {
      message: string;
      status: "error";
    };

export type ProductOperationResult =
  | {
      value: Product[];
      message: string;
      status: "success";
    }
  | {
      message: string;
      status: "error";
    };

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

이번 주차의 주제 중 하나인, 함수형 프로그래밍의 중심인 순수함수를 많이 만들어보려는 활동은 많이 했습니다.
하지만, 디자인 패턴에 대해서는 고려해보지않고 과제를 진행했습니다.

물론 이전까지 생각해보지 못했던 계층을 고민하며 작성한 경험은 좋았지만, 앞으로는 특정 문제를 해결하기위해 패턴을 사용해보고싶습니다.

사실 notification, localstorage 에서 obserber 패턴을 차용하려 했는데, 복잡도가 올라갈 수 밖에 없는게 아쉽더라구요.
localstorage는 제대로 만들지 못했던 건지 테스트 코드가 통과되지 못하는 경우도 왕왕있어서 결국에는 패턴을 후순위로 두고 작업했습니다.
1순위는 계층나누기, 2순위는 데이터, 계산 , 액션 고려하며 순수함수 만들기로 잡았습니다.

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

models, service

사실 아키텍쳐에 대해서 잘 모릅니다. 그냥 불편해서 고민하다 지금의 구조를 가지게 되었습니다.
과제를 마치고나서 클로드에게 basic구조가 어느정도 괜찮은 구조인지, 모델과 서비스는 순수함수로 잘 만들어졌는지 검토를 하기도 했습니다.

이런 방식의 구조라면, model과 service에는 각각 어떤 역할의 함수가 존재하게 되는 것일까요?
기본적으로 models를 데이터 중심으로, service를 비즈니스로직 중심으로 작성했습니다.

처음 과제를 내실때 힌트에 models 폴더를 넣어주신 의도에서는, 어떤 함수가 들어있길 바라셨을까요?
도메인 종속인 비즈니스 로직의 순수함수인것을 알지만 어떤 범위까지의 함수인지 너무 궁금합니다.

커스텀 훅

도메인 단위로 장바구니 목록, 상품 목록 등을 기준잡아서 하나의 상태, 여러개의 액션으로 생각했습니다.

그래서 제 커스텀훅은 reducer를 썼다고는 하나, 무척 사이즈가 큽니다. 어쩌면 무척 클린하지 않을지도 모른다고 생각합니다.
하나의 목적을 위해서만 수정해야한다는 것이 단일책임원칙이라고 알고있습니다. 저의 훅은 그 목적이 너무 넓지 않았나 생각이 듭니다. ㅠ_ㅠ

qna나 다른 질문에서 답변을 많이 해주셨을수도 있겠지만, 이렇게 하나의 상태에 여러 액션이라면 커스텀 훅을 한두개로 모아서 가져가 되는 것일까요?
아니면, 이 상황은 리듀서를 써서 그런것일까요? 짧은 식견으로 고민이 많습니다.

+) 정말 궁금해서 제 코드를 넣어서 클로드에게 물어봤더니, DDD 였기에 도메인 로직이 응집되어 훅이 커졌다고 알려주더라구요.
혹시 이렇게 이해해도 될까요?

j2h30728 added 30 commits August 3, 2025 17:50
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 잘 만드셨습니다. 전체 구조를 도메인(entities -> service -> hook -> UI) 관점으로 정리하고 Jotai로 전역 상태를 옮긴 시도, UI 컴포넌트의 분리(많은 재사용 컴포넌트/아이콘/토스트 등)는 매우 긍정적입니다. 아래 리뷰는 요청하신 흐름(변화 시나리오 → 변화 영향 → 개선 방향)을 따라 작성했습니다. Github PR 코멘트로 바로 붙여넣기 좋게 마크다운으로 정리합니다.

주의: 아래 피드백은 코드 일부만을 발췌한 예시/권장안이므로 실제 파일 경로/타입에 맞게 조정해 주세요.


안녕하세요, PR 고생하셨습니다! 🙌
(이 코멘트는 AI가 작성한 리뷰입니다.)

질문에 대한 요약 답변

  • models vs services 역할:
    • models: 도메인 엔티티/순수 계산(단순 포맷/매핑/순회). 외부 의존(로컬스토리지, 알림, 네트워크 등) 없어야 함.
    • services: 비즈니스 규칙(모델 함수 조합, validation, 정책), 사이드이펙트(로컬스토리지, 서버 호출, notification 호출)를 수행하거나 이를 위임.
    • 즉 models는 “순수함수 모음”, services는 “순수함수 + orchestration + 외부어댑터 사용”의 책임을 가져야 합니다.
  • 커스텀 훅이 커지는 문제:
    • 도메인 응집(DDD 관점) 때문에 커지는 것은 자연스러울 수 있습니다. 다만 책임 경계를 더 작게 쪼개면 테스트/재사용성이 올라갑니다.
    • 권장: 상태(state) 접근 훅(useCartState)과 액션 훅(useCartActions)으로 분리하거나, state atom + action hook으로 나누세요. (예: useCartState -> read-only, useCartActions -> 명령형 액션 제공)
    • 리듀서를 쓴 것이 원인이라기보다 "한 훅이 상태 + 모든 액션 + UI 로직까지 담당" 한 점이 문제입니다.
  • DDD로 인해 훅이 커졌다는 이해는 타당합니다 — 단, 훅이 커도 '외부에 공개하는 API가 단순한지'와 '내부 변경 동선이 짧은지'를 확인하세요.

종합 피드백 (키워드)

  1. 핵심 키워드
    • 응집도(cohesion): 도메인 단위로 묶여 있는가? (상태·비즈니스·모델 묶음의 경계)
    • 결합도(coupling): UI ↔ 도메인 ↔ 서비스가 명확한 인터페이스로 결합되어 있는가?
    • 상태관리 포터블리티: jotai에 묶여있는 구현을 다른 라이브러리로 옮기기 쉬운가?
    • 패키징(모듈화) 준비성: 도메인을 라이브러리/패키지로 떼어낼 수 있는가?
    • 에러/결과 타입 일관성: Result 타입의 중복/불일치 문제
  2. PullRequestBody(셀프회고)에 대한 인사이트
    • 좋은 점
      • 계층(entities → service → hook → UI)으로 명시한 설계 철학이 명확하고 일관되게 적용되었습니다.
      • 순수함수에 대한 의식적 분리는 테스트 가능한 코드와 재사용성을 키웠습니다.
      • Jotai로 전역상태를 뽑아 UI 간 props drilling을 제거한 시도는 적절했습니다.
    • 한 단계 더 생각해볼 질문(인사이트 확장)
      • "서비스" 레이어 내부에서 외부 의존(로컬스토리지, 알림)을 직접 호출하나요, 아니면 어댑터를 주입하나요? (DI/어댑터로 빼면 테스트/포팅 쉬움)
      • 훅이 커질 때 공개 API(surface)를 어떻게 설계했나요? (public actions 목록은 작게, 내부 구현은 크게)
      • Jotai (또는 Context) 로직을 변경할 때 변경의 범위는 어느 파일들인가요? (변경 동선 파악)
  3. 리뷰 받고 싶은 질문에 대한 답변(요약)
    • models vs services: models = 순수 계산/매핑, services = 정책/조합/사이드이펙트. 모델은 services가 소비해야 하고 서비스가 모델을 호출하는 구조가 옳습니다.
    • 커스텀 훅은 DDD 관점으로 응집이 높아 커진 것일 수 있음. 다만 공개되는 액션 API들을 적게 유지(예: useCartActions { add, remove, update } )하고 내부는 작은 함수로 분해하세요.

상세 피드백(문제별)
먼저 개념 정의(단어 기준)

  • 응집도(cohesion) 정의 (요청하신 기준 반영)
    • 변경에 대한 파일/코드 수정의 동선이 짧은가? (변경 시 영향을 받는 파일 수가 적은가)
    • 라이브러리로 떼어낼 때, 관련 코드(모델+서비스+타입)가 하나의 디렉터리/패키지로 매끄럽게 묶이는가
  • 결합도(coupling) 정의
    • 모듈(함수/컴포넌트/서비스)이 서로를 직접 구현 상세로 의존하지 않고 "인터페이스"(props, 콜백, 타입)에 의해 결합되는가

아래 각 항목에서: 문제 정의 → AS-IS(문제 상황) → TO-BE(권장 개선, 코드 예시)

  1. 상태관리 라이브러리가 달라지는 경우 (예: jotai → zustand / redux / react-query)
  • 문제 정의
    • 현재 코드가 jotai atoms(api: atomWithStorage 등)에 꽤 밀착되어 있음. 다른 라이브러리로 교체할 때 import/사용 패턴과 Provider 필요 여부, atomWithStorage의 자동 로컬스토리지 동작 등을 대체해야 함.
    • 수정 시 필요한 파일이 많은지(=응집도 낮음) 확인 필요.
  • AS-IS (jotai)
    • src/advanced/atoms/cartAtom.ts
      import { atomWithStorage } from "jotai/utils";
      import { CartItem } from "../../types";
      export const cartAtom = atomWithStorage<CartItem[]>("cart", []);
    • 컴포넌트 쪽에서 useAtom(cartAtom)으로 직접 읽고 쓰는 패턴
  • TO-BE (예시: zustand로 교체)
    • 장점: 간단한 getter/setter API, 로컬스토리지 미들웨어 사용 가능, 변화 시점에 Provider 불필요
    • 예시 코드 (새 파일: src/advanced/stores/useCartStore.ts)
      import create from "zustand";
      import { persist } from "zustand/middleware";
      import { CartItem } from "../../types";
      
      type CartState = {
        cart: CartItem[];
        add: (item: CartItem) => void;
        remove: (productId: string) => void;
        updateQuantity: (productId: string, qty: number) => void;
        clear: () => void;
      };
      
      export const useCartStore = create<CartState>()(
        persist(
          (set) => ({
            cart: [],
            add: (item) =>
              set((s) => {
                // merge logic...
                return { cart: [...s.cart, item] };
              }),
            remove: (productId) =>
              set((s) => ({ cart: s.cart.filter((c) => c.product.id !== productId) })),
            updateQuantity: (productId, qty) =>
              set((s) => ({ cart: s.cart.map((it) => it.product.id === productId ? { ...it, quantity: qty } : it) })),
            clear: () => set({ cart: [] }),
          }),
          { name: "cart" } // localStorage key
        )
      );
    • 변경 영향: atom 파일들을 zustand store 파일로 옮기고 useAtom 호출부를 useCartStore 훅 호출로 교체. Provider 제거(혹은 필요 없음).
  • TO-BE (예시: Redux Toolkit)
    • createSlice로 slice 생성, Provider로 store 감쌈. 장점: 엄격한 패턴, 미들웨어/DevTools.
  • 권장
    • 전역 상태 접근을 직접 many components에서 useAtom/useStore 하게 하지 말고 "domain hooks" (예: useCartState(), useCartActions())를 만들어두세요. 이렇게 하면 상태 라이브러리 변경 시 import 변경을 한 곳으로 (domain hook)만 모을 수 있습니다.
    • 예: src/advanced/hooks/useCart.ts가 모든 컴포넌트의 진입점이 되도록 (추상화 레이어).
  1. Notification (추적/결합 문제)
  • 문제 정의
    • notificationActionsAtom을 전역에서 바로 사용하거나 service에서 직접 호출하면, notification 구현에 종속됨(결합 증가).
    • 또한 서비스에서 notification 형태에 맞춘 문자열/상태를 반환하지 않으면 호출부가 수동으로 변환해야 함.
  • AS-IS (App 이전 코드)
    • App에서 addNotification(message, type)을 직접 호출해서 UI를 띄움
  • AS-IS (현재)
    • notificationActionsAtom에 ADD/REMOVE 액션을 dispatch함
  • TO-BE (권장) — 두 가지 레이어
    1. 서비스는 항상 Result 타입(구조화된 결과)만 반환
      // services/cart.ts (순수)
      export const addToCartService = (cart: CartItem[], product: Product) : CartOperationResult => {
        if (product.stock <= 0) return { status: 'error', message: '재고 없음' };
        // ...
        return { status: 'success', value: newCart, message: '장바구니에 담겼습니다' };
      }
    2. 훅 또는 컴포넌트 레벨에서 onSuccess/onError 콜백을 통해 알림을 띄움 (결합 완화)
      // hooks/useCartActions.ts
      export function useCartActions() {
        const [, notificationActions] = useAtom(notificationActionsAtom);
        const setCart = ...;
        const addToCart = (product, { onSuccess, onError } = {}) => {
          const res = addToCartService(...);
          if (res.status === 'success') {
            setCart(res.value);
            onSuccess?.(res.message);
            notificationActions({ type: 'ADD', payload: { message: res.message, type: 'success' }});
          } else {
            onError?.(res.message);
            notificationActions({ type: 'ADD', payload: { message: res.message, type: 'error' }});
          }
        };
        return { addToCart };
      }
    • 또는 notification을 완전히 외부 의존으로 주입(inject)하도록 해도 좋음:
      function useCartActions({ notify } : { notify: (msg:string, type:'success'|'error') => void }) { ... }
  • 권장: 서비스를 알림 구현과 분리 → 서비스는 항상 구조화된 결과를 반환하고, 알림은 훅/호출자에게 위임.
  1. 모듈화(패키지로 배포) 관점: 응집도 / 결합도 분석
  • 문제 정의
    • 현재 파일 분리는 잘 되어 있으나 "패키지로 분리" 시 public API(어떤 함수/타입만 노출할 것인가)를 정리해야 함.
    • 예: components/ui, atoms, services, utils가 섞여있는데 service가 UI에 의존하지 않아야 패키지로 떼어내기 쉬움.
  • 응집도 평가 (현재)
    • +: domain 함수(cartService.calculateCartTotal), utils(쿠폰/가격 유틸), components/폴더 분리가 잘 되어 있음 → 도메인별로 묶으면 패키지화 가능
    • -: 일부 컴포넌트(예: Cart 컴포넌트가 cartService를 import하여 일부 계산 수행) — 이건 괜찮지만 서비스/모델이 UI 의존을 포함하면 패키징이 번거로움.
  • TO-BE: 패키징 예시 (cart-domain 패키지)
    • 디렉터리 구조(권장)
      • packages/cart-domain/
        • src/index.ts // public API
        • src/model.ts // CartItem 타입, model 순수함수
        • src/service.ts // 순수 + orchestration (side-effect는 adapter로 분리)
        • src/adapters/storage.ts // localStorage adapter (패키지 내부에 있거나 호출자에게 제공)
        • src/types.ts
    • src/index.ts (공개 인터페이스만 노출)
      export * from './types';
      export * from './model';
      export * from './service';
      // NOT export UI / not export internal adapters unless intended
    • service 내부에서 localStorage를 직접 쓰지 말고 adapter를 인자로 받게 해서 호환성/테스트성을 높임:
      // service.ts
      export function createCartService(storageAdapter: StorageAdapter) {
        return {
          addToCart(cart, product) { /* use storageAdapter.save(...) */ }
        }
      }
  • 권장: 도메인을 라이브러리화할 때는 다음 규칙을 따르세요:
    • 모델(model)과 서비스(service)는 UI 의존이 없어야 함.
    • 사이드이펙트(로컬스토리지/notification/API)는 어댑터로 추상화하고 호출자(provided context/hook)가 어댑터를 주입.
    • 패키지의 public surface를 작게 유지 (몇 개의 함수와 타입만 export).
  1. 결합도 세부: 인터페이스 설계 관점
  • 문제 정의
    • 일부 함수/훅이 내부에서 구체적 구현(알림, 로컬스토리지 등)에 의존. 이는 라이브러리/테스트/교체 비용을 늘림.
  • AS-IS (안좋은 예시)
    const useAddProduct = (addNotification) => { ... } // addNotification이라는 구체적 함수명을 직접 요구
  • TO-BE (인터페이스로 결합 낮추기)
    const useAddProduct = ({ onSuccess, onError } : { onSuccess?: (msg:string) => void; onError?: (msg:string) => void }) => { ... };
    또는 DI
    const useAddProduct = (deps: { notify: (msg, type) => void }) => { ... };
  • 권장: 훅/서비스는 콜백(onSuccess/onError) 혹은 adapter 객체를 통해 의존을 주입받아 구현 의존을 낮추세요.
  1. 타입 일관성 (PR 본문에서 본 문제)
  • 문제: 여러 OperationResult 타입들이 status/success 등의 필드 혼재 (예: status와 success 둘 다 사용).
  • 권장 통일안:
    type Result<T> =
      | { status: 'success'; value: T; message?: string }
      | { status: 'error'; message: string; code?: string };
    • 모든 서비스는 이 패턴을 따르도록 강제하면 호출부가 단순화됩니다 (switch on status).
  • Benefit: notification이나 UI는 result.status에 따라 onSuccess onError만 호출하면 됨.

구체적인 코드 AS-IS / TO-BE 예시(요청사항 반영)

  1. AS-IS (cartAtom + components 직접 useAtom)
// src/advanced/atoms/cartAtom.ts
export const cartAtom = atomWithStorage<CartItem[]>("cart", []);
// 컴포넌트
const [cart, setCart] = useAtom(cartAtom);

문제: 모든 컴포넌트가 useAtom을 직접 호출 → 상태 라이브러리 변경 시 모든 컴포넌트 수정 필요.

TO-BE: 추상화 훅 한 곳에 모아두기

// src/advanced/hooks/useCartState.ts
export function useCartState() {
  // 내부에서 jotai/zustand/redux 어느걸 쓰든 여기만 바꾼다
  const [cart] = useAtom(cartAtom);
  return { cart };
}

// src/advanced/hooks/useCartActions.ts
export function useCartActions() {
  const [, setCart] = useAtom(cartAtom);
  const notification = useNotification();
  const addToCart = (product: Product) => {
    // use cartService which is pure
    const res = cartService.addToCart(/*...*/);
    if (res.status === 'success') {
      setCart(res.value);
      notification.success(res.message);
    } else {
      notification.error(res.message);
    }
  }
  return { addToCart, removeFromCart, updateQuantity };
}

효과: 상태 엔진 교체 시 hooks 폴더만 바꾸면 됨.

  1. AS-IS (서비스 → notification 직접 호출 OR 컴포넌트가 알림을 알고 있음)
// components에서 직접 addNotification 호출
addNotification('장바구니에 담았습니다', 'success');

TO-BE (service → Result, hook/consumer가 알림 담당)

// cartService.ts (순수)
export function addToCartService(cart, product) : Result<CartItem[]> { ... }

// useCartActions.ts
const res = addToCartService(cart, product);
if (res.status === 'success') {
  setCart(res.value);
  notify(res.message, 'success');
} else {
  notify(res.message, 'error');
}

효과: notification 구현이 바뀌어도 서비스 코드는 그대로.

  1. AS-IS (큰 훅)
// useCart.ts (단일 거대한 훅)
export function useCart() {
  const [cart, setCart] = useAtom(cartAtom);
  const addToCart = (...) => { ... }
  const updateQty = (...) => { ... }
  const calculateTotals = (...) => { ... } // UI용 포맷팅 포함
  // lots of UI-format logic and state toggles...
}

TO-BE (작게 분리)

// useCartState.ts
export function useCartState(){ return { cart } }

// useCartActions.ts
export function useCartActions(){ return { addToCart, removeFromCart } }

// useCartSelectors.ts (계산 함수)
export function useCartSelectors() {
  const { cart } = useCartState();
  const totals = useMemo(()=> cartService.calculateCartTotal(cart), [cart]);
  return { totals, totalItemCount: cart.reduce(...) };
}

효과: 각 훅이 단일 책임을 가지므로 테스트·리팩토링 쉬움.


파일/디렉터리별 코멘트(간단)

  • src/advanced/App.tsx
    • 좋아요: App을 Provider로 감싸고 AppContent 분리, NotificationToast/Header 컴포넌트 사용.
    • 개선: AppContent 내부에서 isAdminAtom 사용만 하는데 AppContent에 많은 컴포넌트 로직이 남아 있으면 다시 커질 수 있음. 가능한 UI는 페이지 컴포넌트(AdminPage, CartPage)로 완전히 위임하세요 (이미 일부 진행됨 — 좋음).
  • atoms/*
    • atomWithStorage 사용은 편하지만 service 레벨에서는 로컬스토리지에 직접 의존하지 않도록 주의.
    • notificationActionsAtom의 액션 인터페이스가 'ADD'|'REMOVE' 문자열 기반인데, 타입 안전성과 확장성을 위해 액션 타입을 enum/union으로 관리하세요.
  • components/*
    • UI 컴포넌트(PriceDisplay, Button, QuantityControls 등)를 잘 분리하셨습니다. 재사용성/테스트성 좋음.
    • Cart 컴포넌트가 cartService를 import: 괜찮지만 cartService는 순수함수만 제공하도록 하고 side-effect는 hook에서 처리되도록 정리하세요.
  • services/, utils/
    • services는 순수 함수 + orchestration으로 유지. 외부 사이드 이펙트는 adapter로 추상화 권장.
  • 타입 Result들
    • 통합 제안 (위에서 설명한 Result 패턴) 적용 권장.

우선순위 개선 리스트 (권장 순서)

  1. domain hooks 추상화 레이어 만들기 (useCartState/useCartActions/useCartSelectors) — 상태 라이브러리 변경 시 영향 최소화
  2. 서비스가 반환하는 결과 타입 통일(Result) — notification/consumer 처리 단순화
  3. notification 호출부 정리 — 서비스에서 직접 알림을 하지 않도록 변경(서비스는 Result만 반환)
  4. 패키지 분리 고려: cart-domain (model+service+types), ui-kit (Button/PriceDisplay/Icons) — public API 정리
  5. (옵션) 상태 라이브러리 교체 시나리오 시, 추상화 레이어만 바꿔서 실험(zustand 또는 redux 적용)

마무리 — 확인 질문 (제가 도와드릴 수 있는 것들)

  • 상태 라이브러리 변경(예: jotai→zustand)로 실제로 교체해보길 원하시나요? 바꾼 예시 코드를 PR 형태로 만들어 드립니다.
  • 서비스 ↔ storage/notification 어댑터 주입 패턴으로 리팩터링한 간단한 예시(파일 단위)를 원하시나요?
  • 현재 코드의 특정 커스텀 훅(useCart 등) 파일을 붙여주시면, "분리 리팩터링(3~4개의 작은 훅)" 실습용 패치 제안서를 만들어 드립니다.

필요하시면 위 세 가지 중 하나를 선택해서 구체적인 코드 변경(패치)까지 만들어드리겠습니다.

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.

2 participants