배포링크 : https://yeongseoyoon-hanghae.github.io/front_6th_chapter2-2/
과제의 핵심취지
- 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의 책임에 맞도록 코드가 분리가 되었나요?
-
계산함수는 순수함수로 작성이 되었나요?
-
특정 Entitiy만 다루는 함수는 분리되어 있나요?
-
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
-
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
-
재사용 가능한 Custom UI 컴포넌트를 만들어 보기
-
재사용 가능한 Custom 라이브러리 Hook을 만들어 보기
-
재사용 가능한 Custom 유틸 함수를 만들어 보기
-
그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기
-
UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
과제 셀프회고
일단 폴더구조는 다음과 같습니다.
📁 src/advanced/
├── App.tsx
├── main.tsx
├── __tests__/
│ └── advanced.test.tsx
├── app/
│ ├── components/
│ │ └── Header.tsx
│ └── model/
├── assets/
│ └── icons/
│ ├── CartBagIcon.svg
│ ├── CartIcon.svg
│ ├── CloseIcon.svg
│ ├── ImageIcon.svg
│ ├── PlusIcon.svg
│ └── TrashIcon.svg
├── entities/ # 비즈니스 엔티티
│ ├── cart/ # 장바구니 도메인
│ │ ├── hooks/
│ │ ├── libs/
│ │ ├── model/
│ │ ├── ui/
│ │ ├── index.ts
│ │ └── types.ts
│ ├── product/ # 상품 도메인
│ ├── coupon/ # 쿠폰 도메인
│ ├── notification/ # 알림 도메인
│ └── index.ts
├── features/ # 사용자 시나리오
│ ├── add-coupon/
│ ├── checkout/
│ ├── manage-cart/
│ ├── manage-coupon/
│ ├── manage-products/
│ ├── process-order/
│ ├── search-product/
│ ├── select-coupon/
│ ├── show-notification/
│ ├── view-cart-items/
│ ├── view-product-list/
│ └── index.ts
├── pages/ # 페이지 컴포넌트
│ ├── AdminPage.tsx
│ └── CartPage.tsx
├── shared/ # 공통 유틸리티
│ ├── hooks/
│ ├── libs/
│ ├── ui/
│ ├── store/
│ ├── types/
│ └── index.ts
└── widgets/ # 독립적인 UI 블록
└── admin/
└── ui/
├── CouponsTab.tsx
└── ProductsTab.tsx
과제를 늦게 시작하여 조금 어색한 부분이 있지만...너무 힘든 관계로..여기까지...
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
- FSD(Feature-Sliced Design) 아키텍처 설계 처음에는 테오가 과제로 내주신 refactoring 폴더처럼 잡고갈지, 아니면 회사에서 자주 사용하는 shared-features-pages의 삼단 구조를 가져갈지에 대해서 고민이 되었습니다. 그러다가 FSD 구조를 통해 설계하자는 생각이 들었습니다.
현재 대략적인 폴더 구조는 다음과 같이 되어있는데요.
app/ - 앱 초기화, 라우팅, 전역 설정
pages/ - 페이지별 조합 로직
widgets/ - UI 컴포넌트 조합
features/ - 비즈니스 로직 (장바구니 관리, 주문 처리)
entities/ - 도메인 모델 (Product, Cart, Coupon)
shared/ - 공통 유틸리티
각각의 계층이 각자의 역할을 명확히 해서 컨벤션만 있다면 충분히 책임이 분리되지 않을까 생각했습니다. FSD의 핵심은 기술적 관심사가 아닌 비즈니스 도메인별 분리에 있기 때문입니다.
또 제가 설계한 의존성 방향은 다음과 같은데요.
app → pages → widgets → features → entities → shared
상위 계층이 하위 계층을 참조하는 단방향 의존성을 철저히 지키게하고, 단방향 의존성으로 순환 참조를 방지하도록 했고 이를 통해 하위 계층 변경이 상위에 영향을 주지 않도록 구현하였습니다.
왜 의존성 방향이 단방향으로 흘러야할까?
FSD에서는 순환 참조를 방지할 수 있기 때문에 단방향으로 흘러야 한다고 이야기 합니다. 제가 생각했을때는 만약 A -> B -> C 의 단방향 형태로 흘러가는 형태라면 B가 변경되었을때 C만 영향범위를 고려하면 되지만, A -> B -> C -> A로 순환참조 구조가 된다면 B가 변경되었을때 모든 영향 범위를 고려해야하기 때문이 아닐까 생각했습니다. 때문에 의존성 방향을 단방향으로 흐르도록 설계하지 않았을까요?
아무튼 FSD는 클린 아키텍처 철학과 닮아있는데요,
FSD는 프론트엔드에서 좀 더 최적화된 실용적인 접근을 제공한다고 생각합니다.
계층별 역할과 책임을 명확히 분리했습니다. app은 전역 초기화, 라우팅, Provider 설정을 담당하고, pages는 라우트 컴포넌트와 페이지 조합을 하되 비즈니스 로직은 최소화하도록 구현했습니다. widgets과 features가 조금 불명확해보일 수 있는데, widgets는 사용자의 기능들은 모아 둔 좀 더 큰 기능 단위라고 생각하고, features는 비즈니스 기능을 구현했습니다. entities는 핵심 비즈니스 객체와 도메인 로직을 다루며, shared는 비즈니스와 무관한 재사용 가능 기능을 제공하도록 구현했습니다.
2. Entity 간 순환 참조 문제 해결
처음 제공된 타입은 다음과 같이 되어있었는데요.
export interface Product {
id: string;
name: string;
price: number;
stock: number;
discounts: Discount[];
}
export interface CartItem {
product: Product;
quantity: number;
}
위 타입을 확인해보면, CartItem은 Product를 타입을 참조하고 있는 구조로 되어있는 것을 알 수 있습니다.
만약 entity/cart에 CartItem를 분리하게되면, entity/product에 위치한 Product타입을 CartItem이 참조하게 되어 entity가 entity를 참조하는 구조가 될 것이고, 이것은 FSD의 원칙을 위배한다고 생각했습니다.
그래서 이를 해결하기위해서 CartItem타입을 다음과 같이 변경하였습니다.
export interface Cart {
id: string;
name: string;
price: number;
discounts: Discount[];
quantity: number;
}
export interface Discount {
quantity: number;
rate: number;
}
좀 더 생각해보면 CartItem은 id, name, price, discounts가 있어야하는 것은 명확하지만, 'Product'라는 도메인 정보를 알 필요가 없다고 생각했기때문입니다. Product 객체를 평탄화하니 다른 도메인 정보를 CartItem이 들고있지 않아도 되었습니다.
3. Jotai Provider 스코프 최적화
제가 basic에서 advanced 과제로 넘어가면서 제일 신경 쓴 부분은 “진짜 필요한 범위에만 상태를 쓴다”였습니다. Provider를 남발하면 스토어가 쪼개지고, 반대로 너무 위에다 두면 사실상 전역이 된다고 생각했습니다. 만약 그렇게 된다면 전역 상태를 사용하고, 이를 최상단의 레벨에서 그대로 props로 내려주는것과 차이가 있을까? 하는 생각을 했던 것 같습니다.
아무튼 최종적으로 Provider의 배치는 다음과 같게 되었습니다. 전역(앱 어디에서나 필요): Notification 앱 전반(헤더·페이지 공통으로 필요한 데이터): Product 특정 뷰 묶음(헤더의 카트 숫자 ↔ 카트 페이지 아이템 동기화): Cart 페이지/섹션 단위로 공유되는 상태(주로 장바구니/결제 맥락): Coupon
이렇게 하면 헤더의 카트 개수와 카트 페이지의 상태가 같은 스토어를 공유해 동기화되고, 쿠폰은 페이지 컨텍스트 안에서만 공유돼서 과도하게 전역으로 퍼지지 않을 것이라고 생각했습니다.
Jotai는 Provider가 생성될 때마다 createStore()로 완전 독립 스토어가 생기게 되는데, 컴포넌트 내부에 중복 Provider가 있으면 상태가 갈라져서 동기화가 깨지게 됩니다. 반대로 Provider를 너무 위로 올리면 걍 전역 상태랑 다를 바가 없다고 생각했습니다. 그래서 “필요한 최저 위치의 공통적인 조상코드(?)” 기준으로 Provider를 제공했습니다.
4. 최대한 계산과 액션을 나눠보기
이번에 과제를 구현하면서, 기존에 구현되어있던 코드들은
const formatPrice = (price: number, productId?: string): string => {
if (productId) {
const product = products.find(p => p.id === productId);
if (product && getRemainingStock(product) <= 0) {
return 'SOLD OUT';
}
}
if (isAdmin) {
return `${price.toLocaleString()}원`;
}
return `₩${price.toLocaleString()}`;
};
이전 코드는 한 함수 안에 “데이터 조회 + 조건 분기 + 계산 + 표시(문자열 포맷)”가 한데 섞여 있었습니다. 어떻게 보면 폴더별로 분리도 어렵고 재사용도 어려운 코드가 되었다고 생각합니다. 그리고 해당 함수의 책임이 많은 편이라고 생각이 됐습니다.
일단 계산은 전부 순수 함수로 분리하고자했습니다.
export const calculateRemainingStock = (
totalStock: number,
usedQuantity: number
): number => {
return totalStock - usedQuantity;
};
export const isInStock = (
totalStock: number,
usedQuantity: number = 0
): boolean => {
return calculateRemainingStock(totalStock, usedQuantity) > 0;
};
export const getStockDisplay = (
totalStock: number,
usedQuantity: number = 0
): string => {
return calculateRemainingStock(totalStock, usedQuantity) <= 0
? "SOLD OUT"
: "";
};
도메인 정보는 최대한 배제해서 입력과 출력만 명확하게 만들었습니다. 그리고 각각 함수의 역할에 따라 함수를 분리 배치하고 조합하여 사용할 수 있도록 구현하였습니다. 인풋에 따라 아웃풋을 나오게하고 부수효과가 없는 함수를 만들 수 있도록 구현하였습니다.
그렇게 개선하니 더 이상 계산 함수 안에서 products.find(...) 같은 조회를 하지도 않고, isAdmin 같은 환경 분기도 내부에 두지 않을 수 있었습니다. 호출자가 준비해서 넘겨주고, 계산 함수는 그저 받은 값으로 결과만 산출하게 해서 오염을 막도록 구현하였습니다. 결국 FSD 관점에서도 정리가 잘 됐는데, 범용 수학/포맷은 shared, 순수 도메인 계산은 entities, 여러 도메인을 엮는 합성은 features, 그리고 UI는 그 결과를 가져다 보여주기만 하는 구조로 자리 잡을 수 있었다고 생각합니다.
과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
features의 분리
어려웠던 부분이 있다면 features를 사용자 행위 중심으로 나누는 것에 있었다고 생각합니다. 저희 회사에서 사용하는 shared-features-pages의 삼단구조는 공통화된(좀 더 추상화된)레벨의 shared,그리고 페이지에서 사용하는 컴포넌트가 놓여지는 pages 그리고 그 외의 로직들은 다 features에 들어가게되는데요.
그러다보니 features가 기능이라는 이름을 가짐에도 불구하고 '사용자의 기능'에 집중을해서 배치하기 보다는 'coupon', 'cart', 'product'와 같이 '야! 얘들아! 쿠폰이면 다 여기로 와!'처럼 어떻게 보면...기능 + 도메인의 짬뽕 폴더구조가 되었다고 생각했습니다. 물론 그렇게 배치를 하면 편리한 부분이 있긴하지만 features의 폴더가 너무 거대해지고 관리해야하는 부분이 늘어나게 되더라고요.
이번에 FSD구조를 가져가면서 사용자의 행위를 중심으로 배치를 하니 좀 더 명확하게 기능단위로 분리가 가능하고 각각의 features가 독립적으로 존재하게 되어 나중에 어떤 기능의 유지보수를 하게 된다고 했을때 영향범위가 줄어들 수 있겠다고 느꼈던 것 같습니다.
그런데 이런 부분이 익숙하지 않아 고민했던 부분이 있다면 다음과 같은 부분이었는데요. apply-coupon, delete-coupon등과 같이 기능을 세부적으로 나눠야하는걸까 하는 고민이었습니다. 결론적으로는 "사용자 의도"를 기준으로 슬라이스를 자르고, 서로 다른 맥락이나 복잡도, 재사용 필요, 혹은 컴포넌트가 너무 무거워지는 때만 더 쪼개는 방향으로 정리했습니다.
스토어 격리
zustand에서는 context를 통해서 접근할 수 있는 스코프를 지역적으로 좁혀줄 수 있는데(공식문서에 언급), jotai도 비슷하게 가져갈 수 있는 방향이 있는지 찾아봤습니다. 그런데 jotai에서는 공식문서에서 언급해주는 내용은 딱히 없고 라이브러리 설치를 통해 관리하는 방법을 언급하고 있었습니다.
지금은 시간이 부족해서 해당 방식을 취하진 않고 단순 프로바이더를 통해서 스토어의 격리(스코프의 지정은 아닌)만 해주긴했는데, 시간이 더 있었다면 해당 방식을 취하지 않았을까싶습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
- CartItem에서 Product라는 도메인이 들어와있는것을 피하기 위해 이를 평탄화된 타입으로 가져가게 되었다고 위에서도 언급을 했는데, 사실 Cart라는것은 클라이언트에서 생성되는 데이터 타입(상품과 할인은 어떻게 잘 조합한)이고, 만약 좀 더 확장한다면 해당 장바구니의 내용에 어떤 상품인지, 어떤 쿠폰을 적용시켰는지에 대한 정보가 들어갈 수 있다고 생각하는데요. 그렇게 확장이 된다면 Cart의 타입에는 비롯 상품의 타입뿐아니라 쿠폰 타입 기타 등등이 들어갈 수 있을 것 같습니다.
그렇다면, Cart는 사실상 클라이언트에서 조합을 해서 만들어내는 객체니 이를 features로 분리하는 것이 맞았을까요? 계속 고민이 되는 것은, Cart가 어떻게보면 존재할 수 있는 객체라고 생각했기때문입니다. 만약 Cart에 저장할 수 있는 데이터가 확장이 된다고 하면... Cart는 어떤 폴더에 놓이는게 좋은 선택일까요?
- 계속 고민이 되었던 부분은 거의 비슷한 방향이었는데요. 만약 apply-coupon, delete-coupon과 같은 기능을 '쿠폰 관리'에 초점을 맞추어 manage-coupon과 같이 분리하게 된다면...너무 manage-coupon에 많은 책임이 들어가는것이 아닐까?하는생각이 들었습니다. crud에 맞춰 분리를해야할지 아니면 알잘딱깔센하게 분리해야할지...
과제 피드백
크.. 멋쟁이 영서님. 5주차도 너무 고생했어요!! 여러가지 개인적인 일도 많이 겹쳤을텐데 그럼에도 불구하고 너무 잘 해주셨네요 ㅎㅎ 지난주에도 했던 말인데, 존경합니다.
CartItem에서 Product라는 도메인이 들어와있는것을 피하기 위해 이를 평탄화된 타입으로 가져가게 되었다고 위에서도 언급을 했는데, 사실 Cart라는것은 클라이언트에서 생성되는 데이터 타입(상품과 할인은 어떻게 잘 조합한)이고, 만약 좀 더 확장한다면 해당 장바구니의 내용에 어떤 상품인지, 어떤 쿠폰을 적용시켰는지에 대한 정보가 들어갈 수 있다고 생각하는데요. 그렇게 확장이 된다면 Cart의 타입에는 비롯 상품의 타입뿐아니라 쿠폰 타입 기타 등등이 들어갈 수 있을 것 같습니다. 그렇다면, Cart는 사실상 클라이언트에서 조합을 해서 만들어내는 객체니 이를 features로 분리하는 것이 맞았을까요? 계속 고민이 되는 것은, Cart가 어떻게보면 존재할 수 있는 객체라고 생각했기때문입니다. 만약 Cart에 저장할 수 있는 데이터가 확장이 된다고 하면... Cart는 어떤 폴더에 놓이는게 좋은 선택일까요?
저는 그냥 현재 상황을 유지해도 좋다고 생각해요 ㅎㅎ 완전히 독립적으로 쓰이는게 사실 논리적으로 불가능에 가깝기도 하고, cart가 다른걸 의존하는게 부자연스러운건 아니라서요.
대신 use-case 라는 폴더를 하나 만들어서 관리할 것 같아요. use-case는 각각의 entities에 대한 개별 로직이라기보단, 전체 entities의 소통을 총괄하는 역할인거죠.
가령, 사용자 시나리오를 토대로 생각해보자면
"사용자가 A라는 제품을 장바구니에 추가한다" 를 useCase로 만들고, 여기서 쓰이는 모든 로직과 개체를 가져다 조합하여 사용하는 그런 방식입니다.
useCartUseCase 가 있고, 이 UseCase에서 cart, product, coupon 등을 조합하는거죠.
그래서
features/ cart/ entities/ use-case/ useCartUseCase
요로코롬 사용할 수도 있고
혹은 features/ cart/ entities/ use-case/ useCartUseCase hooks/ useCart
요로코롬 쓰일 수도 있다고 생각해요
계속 고민이 되었던 부분은 거의 비슷한 방향이었는데요. 만약 apply-coupon, delete-coupon과 같은 기능을 '쿠폰 관리'에 초점을 맞추어 manage-coupon과 같이 분리하게 된다면...너무 manage-coupon에 많은 책임이 들어가는것이 아닐까?하는생각이 들었습니다. crud에 맞춰 분리를해야할지 아니면 알잘딱깔센하게 분리해야할지...
이것도 위와 동일하다고 생각해요. useCouponUseCase 같은걸 만들어서 사용하는거죠!
이번주도 고생하셨어요!! 남은 기간도 화이팅입니다~!