배포 링크
https://jun17183.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의 책임에 맞도록 코드가 분리가 되었나요?
-
계산함수는 순수함수로 작성이 되었나요?
-
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
-
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
-
계산함수는 순수함수로 작성이 되었나요?
-
특정 Entitiy만 다루는 함수는 분리되어 있나요?
-
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
-
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
-
재사용 가능한 Custom UI 컴포넌트를 만들어 보기
-
재사용 가능한 Custom 라이브러리 Hook을 만들어 보기
-
재사용 가능한 Custom 유틸 함수를 만들어 보기
-
그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기
-
UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
과제 셀프회고
항해에서 종종 듣는 말이 있습니다. 재밌겠다. ...?;; 이건 네트워킹이나 보드게임 모임을 보고 하는 말이 아닙니다. 과제를 보고 하는 말입니다. 과제가 재밌겠다니요. 물론 저도 개발자란 직업을 택한 사람으로서 무슨 느낌인지는 압니다. 하지만 그건 어디까지나 일 치고, 과제 치고 재밌다는 거지, 저는 당연히 넷플릭스 보고 게임 하는 게 더 재밌습니다.
놀랍게도 이번 과제를 하면서 느낀 점은,,, 재밌었습니다. '애니 봐야지~', '게임 한 판 해야지~' 보다 '빨리 과제 해야지~' 라는 생각이 들었습니다. 이유를 떠올려 보자면 이전 과제들과 달리 주체적으로 작업했고, 그에 따른 결과물도 스스로 만족스러웠기 때문이라고 생각합니다.
우선 AI를 사용하지 않았습니다. Agent는 물론 Ask도 거의 사용하지 않았습니다. 4주차를 포함하여 그동안 AI의 명암을 많이 느껴왔지만, 그 중에서도 어두운 면에 대한 피로감이 쌓였던 것 같습니다. 하지만 직접 코드를 치고 결과를 확인하니 내가 확실히 알고 있다는 느낌, 100퍼 내 코드라는 느낌이 곧 즐거움으로 이어졌습니다.
이전 주차 과제들도 큰 도움이 되었습니다. 커스텀 훅과 친해지고, 폴더 구조를 나누는 것도 익숙해지고, 리팩토링도 적응이 되었습니다. 그동안 AI를 통해 많은 코드를 제공 받았고, 다른 분들의 코드도 보고, 솔루션도 얻으며 나만의 방향이 생긴 느낌입니다. 물론 고칠 부분, 아직 배워야 할 점도 많겠지만 어떻게 작업해야 할지 방황하던 지난 주차들과 달리 방향이 생겼으니 나아갈 생각만 하면 되었습니다.
이 밖에도 도움 주신 많은 분들 덕분에 너무 많은 인사이트를 얻은 주차가 아니었나 생각합니다.
(샤라웃)
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
1. 기본 과제에서 심화로 쉽게 넘어갈 수 있도록 baisc 프로젝트 구성하기
props drilling은 피할 수 없겠지만 상태는 한 군데서 관리하고자 했습니다. useAppState라는 훅을 만들어 cart, product와 같은 state를 담았습니다.
// useAppState.tsx
const [products, setProductsState] = useState<Product[]>(() => { ... });
const [cart, setCartState] = useState<Product[]>(() => { ... });
const [coupon, setCouponState] = useState<Product[]>(() => { ... });
const [isAdmin, setIsAdmin] = useState(false);
const [selectedCoupon, setSelectedCoupon] = useState<Coupon | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
return { products, setProductsState, cart, setCartState, ... }
그리고 각 커스텀 훅에서 이를 인자로 받아 사용하였으며, 이를 App에서 한 번만 호출한 뒤 Action 단위로 관리하여 props로 전달하였습니다.
const App = () => {
const appState = useAppState();
const notificationActions = useNotification();
const cartActions = useCart(appState, notificationActions);
const productsActions = useProducts(appState, notificationActions);
const couponActions = useCoupon(appState, notificationActions);
...
return (
{isAdmin ? (
<AdminPage
productsActions={productsActions}
couponActions={couponActions}
notificationActions={notificationActions}
/>
) : (
<UserPage
appState={appState}
productsActions={productsActions}
couponActions={couponActions}
cartActions={cartActions}
notificationActions={notificationActions}
/>
)}
);
}
위와 같은 구성을 통해 개별 props를 난잡하게 보낼 필요가 없고, 심화 과제에선 action 대신 전역 상태 라이브러리를 사용하고 있는 커스텀 훅으로 변경만 해 주면 되었습니다.
2. 우선 나부터 납득할 만한 코드 짜기
이 파일에 있는 게 맞을까 싶은 코드를 최대한 줄이고 싶었습니다. 물론 정답은 없고 의견은 다르겠지만 적어도 자신의 코드에는 의문이 없도록 작업했습니다.
(의문 제거 과정)
제일 헷갈렸던 부분은 역시 formatPrice 함수였습니다. 처음에는 되게 유틸 함수 같은데 isAdmin이나 product 같은 상태를 참조하고 있고, 그래서 상품 훅 useProducts으로 빼려고 하니 getRemainingStock에선 cart를 바라보고 있었습니다.
고민하다 결국 디스코드 채널에 질문을 남기게 되었고 "cart, product 등 엔티티만을 다루는 계층이 존재한다면 두 함수는 존재해선 안되는 함수", "모듈 내에서 암묵적으로 값을 가져와서 부수효과가 일어나고 있다. 이를 분리하는 것이 순수 함수" 라는 감사한 조언들 들었습니다.
(참고) https://bbakjun.notion.site/2-2025-02-03-18d42b6fc4ab807e8a07f3f3631c9415?source=copy_link (준형님 최고)
이를 바탕으로 다음과 같이 계산 부분은 유틸로 분리하고 나머지 부분은 각 사용처에서 인자로 전달하였습니다.
// utils.ts
import { Product, SOLD_OUT } from './types';
export const getRemainingStock = (stock: number, quantity?: number): number => {
return stock - (quantity || 0);
};
export const formatAdminPrice = (product: Product): string => {
return getRemainingStock(product.stock) <= 0 ? SOLD_OUT : `${product.price.toLocaleString()}원`;
};
export const formatUserPrice = (product: Product): string => {
return getRemainingStock(product.stock) <= 0 ? SOLD_OUT : `₩${product.price.toLocaleString()}`;
};
3. 배보다 배꼽이 커지지 않기
어디서 FSD라는 말을 주워 들어서 흉내 내 보기로 하였습니다.
src/
├── pages/
│ ├── admin/
│ │ └── index.ts
│ ├── user/
│ └── index.ts
├── features/
│ ├── cart/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── util/
│ ├── coupon/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── util/
│ ├── notification/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── util/
│ └── products/
│ ├── components/
│ ├── hooks/
│ └── util/
└── shared/
├── components/
├── constants/
├── hooks/
└── util/
막상 이렇게 구조를 만들고 나니 너무 과하다고 느껴졌습니다. 관리자 페이지와 사용자 페이지에서 함께 쓰는 컴포넌트가 없어 각 feature 마다 AdminCartList, UserCartList 처럼 접두어를 달아야 했으며, 폴더 아래에 하나의 파일만 있는 경우가 대부분이었습니다.
차라리 관리자-사용자로 나누는 편이 응집도가 높지 않을까 라고 생각하였기에 다음과 같이 폴더 구조를 수정하였습니다.
src/
├── admin/
│ ├── components/
│ ├── pages/
│ └── index.ts
├── user/
│ ├── components/
│ └── index.ts
└── shared/
├── components/
├── hooks/
├── constants.ts
├── types.ts
└── utils.ts
훨씬 직관적인 폴더 구조라고 생각합니다. 만약 다른 사람이 유지보수를 하게 된다면 이 폴더 구조가 더 파악하기 쉽지 않을까 생각합니다.
이 밖에도 jotai를 사용하는 과정에서 atom, reducer, selector로 기능을 분리했다가, 각 feature 당 reducer로 구성된 atom 파일 하나만 구성하는 등 최대한 직관적이고 가벼운 구조로 구성하려 노력했습니다.
과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
프로젝트의 규모를 생각하여 폴더 구조나 파일 분리를 가벼운 단위로 가져갔습니다. 적절한 선택이라고 생각합니다. 다만 이건 과제이기 때문에 이 정도 규모인 거지, 만약 훨씬 큰 규모의 프로젝트라면 그에 맞는 폴더 구조와 파일 분리를 잘 할 수 있을까 하는 의문이 듭니다.
form 컴포넌트가 너무 무거운 점 또한 아쉽습니다. 다른 분들은 핸들러를 훅으로 분리하는 방안을 말씀하시던데 거기까진 고민의 깊이가 닿지 못했습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
1.
아래와 같이 포맷 함수나 계산 함수를 utils 파일로 분리하긴 했는데 적절하다고 생각하진 않습니다. 당장 인자 값도 product와 같은 엔티티를 쓰고 있으며, utils로만 묶기엔 여러 역할의 함수가 있다고 생각합니다.
다만 나눈다면 어떻게 나눠야 할까? 라는 생각이 듭니다. 각 도메인마다 util 파일이 존재해야 하는 건지, 그렇다면 그건 util 이라고 부르는 건 적절한지 모르겠습니다.
import { CartItem, Coupon, Product, SOLD_OUT } from './types';
// 재고 계산
export const getRemainingStock = (stock: number, quantity?: number): number => {
return stock - (quantity || 0);
};
// 관리자 가격 포맷
export const formatAdminPrice = (product: Product): string => {
return getRemainingStock(product.stock) <= 0 ? SOLD_OUT : `${product.price.toLocaleString()}원`;
};
// 사용자 가격 포맷
export const formatUserPrice = (product: Product): string => {
return getRemainingStock(product.stock) <= 0 ? SOLD_OUT : `₩${product.price.toLocaleString()}`;
};
// 대량 구매 여부 확인
export const hasBulkPurchase = (cart: CartItem[]): boolean => {
return cart.some(cartItem => cartItem.quantity >= 10);
};
// 최대 적용 가능한 할인 계산
export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => {
const { discounts } = item.product;
const { quantity } = item;
const baseDiscount = discounts.reduce((maxDiscount, discount) => {
return quantity >= discount.quantity && discount.rate > maxDiscount
? discount.rate
: maxDiscount;
}, 0);
return hasBulkPurchase(cart) ? Math.min(baseDiscount + 0.05, 0.5) : baseDiscount;
};
// 아이템 총 가격 계산
export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => {
const { price } = item.product;
const { quantity } = item;
const discount = getMaxApplicableDiscount(item, cart);
return Math.round(price * quantity * (1 - discount));
};
// 카트 총 가격 계산
export const calculateCartTotal = (cart: CartItem[], selectedCoupon?: Coupon): {
totalBeforeDiscount: number;
totalAfterDiscount: number;
} => {
let totalBeforeDiscount = 0;
let totalAfterDiscount = 0;
cart.forEach(item => {
totalBeforeDiscount += item.product.price * item.quantity;
totalAfterDiscount += calculateItemTotal(item, cart);
});
if (selectedCoupon) {
if (selectedCoupon.discountType === 'amount') {
totalAfterDiscount = Math.max(
0,
totalAfterDiscount - selectedCoupon.discountValue
);
} else {
totalAfterDiscount = Math.round(
totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)
);
}
}
return {
totalBeforeDiscount: Math.round(totalBeforeDiscount),
totalAfterDiscount: Math.round(totalAfterDiscount),
};
};
2.
Form 컴포넌트의 코드 수를 줄일 수 있는 방법이 있을까요? 아무래도 입력할 부분이 많고 그만큼 jsx 코드 길이도, 핸들러 수도 많아 Form 컴포넌트 만큼은 코드 라인 수가 길어지더라구요.
각 input 단위로 컴포넌트를 나누기엔 최소 컴포넌트 단위가 프로젝트 규모에 비해 너무 작다고 생각이 들더라구요. 핸들러 또한 다른 분들은 훅으로 분리하는 분들도 계시던데, 어차피 이 Form 안에서만 사용할 기능을 훅으로 분리하는 게 맞나 싶기도 합니다. 좋은 방법이 있을까요?
@@장문(코드) 주의@@
import { Product } from '../../../shared/types';
import { initialProductForm } from '../../../shared/constants';
import { ProductDiscountItem } from './ProductDiscountItem';
import { useProducts } from '../../../shared/hooks/useProducts';
import { useNotification } from '../../../shared/hooks/useNotification';
export const ProductForm = ({
setShowProductForm,
productForm,
setProductForm,
}: {
setShowProductForm: (show: boolean) => void;
productForm: Product;
setProductForm: (product: Product) => void;
}) => {
const { addProduct, updateProduct } = useProducts();
const { addNotification } = useNotification();
// 상품 제출
const handleProductSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (productForm.id) {
updateProduct(productForm);
} else {
addProduct(productForm);
}
setProductForm(initialProductForm);
setShowProductForm(false);
};
// 상품 변경
const handleProductChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProductForm({ ...productForm, [name]: value });
};
// #region 상품 정보 변경
// 가격 변경
const changePrice = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || /^\d+$/.test(value)) {
setProductForm({
...productForm,
price: value === '' ? 0 : parseInt(value),
});
}
};
// 가격 포커스 아웃
const handlePriceBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '') {
setProductForm({ ...productForm, price: 0 });
} else if (parseInt(value) < 0) {
addNotification('가격은 0보다 커야 합니다', 'error');
setProductForm({ ...productForm, price: 0 });
}
};
// 재고 변경
const changeStock = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || /^\d+$/.test(value)) {
setProductForm({
...productForm,
stock: value === '' ? 0 : parseInt(value),
});
}
};
// 재고 포커스 아웃
const handleStockBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '') {
setProductForm({ ...productForm, stock: 0 });
} else if (parseInt(value) < 0) {
addNotification('재고는 0보다 커야 합니다', 'error');
setProductForm({ ...productForm, stock: 0 });
} else if (parseInt(value) > 9999) {
addNotification('재고는 9999개를 초과할 수 없습니다', 'error');
setProductForm({ ...productForm, stock: 9999 });
}
};
// 할인 추가
const addDiscount = () => {
setProductForm({
...productForm,
discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }],
});
};
// 할인 제거
const removeDiscount = (index: number) => {
setProductForm({
...productForm,
discounts: productForm.discounts.filter((_, i) => i !== index),
});
};
// 할인 퍼센트 변경
const changeDiscountRate = (index: number, percent: number) => {
const newDiscounts = [...productForm.discounts];
newDiscounts[index].rate = (percent || 0) / 100;
setProductForm({ ...productForm, discounts: newDiscounts });
};
// 할인 수량 변경
const changeDiscountQuantity = (index: number, quantity: number) => {
const newDiscounts = [...productForm.discounts];
newDiscounts[index].quantity = quantity;
setProductForm({ ...productForm, discounts: newDiscounts });
};
// #endregion
return (
<div className="p-6 border-t border-gray-200 bg-gray-50">
<form onSubmit={handleProductSubmit} className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">
{productForm.id ? '상품 수정' : '새 상품 추가'}
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* 상품명 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
상품명
</label>
<input
name="name"
type="text"
value={productForm.name}
onChange={handleProductChange}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border"
required
/>
</div>
{/* 설명 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
설명
</label>
<input
name="description"
type="text"
value={productForm.description}
onChange={handleProductChange}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border"
/>
</div>
{/* 가격 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
가격
</label>
<input
name="price"
type="text"
value={productForm.price === 0 ? '' : productForm.price}
onChange={changePrice}
onBlur={handlePriceBlur}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border"
placeholder="숫자만 입력"
required
/>
</div>
{/* 재고 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
재고
</label>
<input
name="stock"
type="text"
value={productForm.stock === 0 ? '' : productForm.stock}
onChange={changeStock}
onBlur={handleStockBlur}
className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border"
placeholder="숫자만 입력"
required
/>
</div>
</div>
{/* 할인 정책 */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
할인 정책
</label>
<div className="space-y-2">
{productForm.discounts.map((discount, index) => (
<ProductDiscountItem
key={index}
discount={discount}
index={index}
changeDiscountQuantity={changeDiscountQuantity}
changeDiscountRate={changeDiscountRate}
removeDiscount={removeDiscount}
/>
))}
<button
type="button"
onClick={addDiscount}
className="text-sm text-indigo-600 hover:text-indigo-800"
>
+ 할인 추가
</button>
</div>
</div>
{/* 버튼 */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
setProductForm(initialProductForm);
setShowProductForm(false);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700"
>
{productForm.id ? '수정' : '추가'}
</button>
</div>
</form>
</div>
);
};
과제 피드백
수고했어요! 홍준
"놀랍게도 이번 과제를 하면서 느낀 점은,,, 재밌었습니다. '애니 봐야지~', '게임 한 판 해야지~' 보다 '빨리 과제 해야지~' 라는 생각이 들었습니다. 이유를 떠올려 보자면 이전 과제들과 달리 주체적으로 작업했고, 그에 따른 결과물도 스스로 만족스러웠기 때문이라고 생각합니다."
출제자로써 너무나 만족스러운 내용이네요ㅎ 코드를 정리정돈하면서 이해도가 선명해져가는 기분은 뭔가 만들어가는 코딩에 비해 또 새로운 즐거움이죠. 그렇게 직접 해보면서 능동적인 도움을 구해가며 AI와 함께 코딩을 해보는 경험도 좋아보여요. 아주 아주 잘했습니다. 도움이 되었다니 기쁘네요!
Q) 아래와 같이 포맷 함수나 계산 함수를 utils 파일로 분리하긴 했는데 적절하다고 생각하진 않습니다. 당장 인자 값도 product와 같은 엔티티를 쓰고 있으며, utils로만 묶기엔 여러 역할의 함수가 있다고 생각합니다.
다만 나눈다면 어떻게 나눠야 할까? 라는 생각이 듭니다. 각 도메인마다 util 파일이 존재해야 하는 건지, 그렇다면 그건 util 이라고 부르는 건 적절한지 모르겠습니다.
=> 과제의 의도는 도메인에 쓰이는 순수함수와 도메인과 무관한 유틸함수의 경계를 구분하기를 바랬습니다. 도메인을 중심으로 코드를 정리하면 자연스레 비즈니스 로직과 그렇지 않은 로직을 구분할 수 있거든요. 순수함수 vs 부수효과, 도메인 vs 비도메인은 다른 관점이니까요.
=> 모든 도메인에 모든 유틸이 있어야 할 필요는 없습니다. 그리고 같은 유틸스러운 코드라 하더라도 도메인이 엮이고 안 엮이고의 차이를 느껴보기를 바랬습니다. 굳이 나눈다면 도메인을 기준으로 나누는게 좋습니다. 도메인이 포함되지 않은 코드여야 라이브러리로써 다른 데서도 재사용이 가능하니까요.
2 Form 컴포넌트의 코드 수를 줄일 수 있는 방법이 있을까요? 아무래도 입력할 부분이 많고 그만큼 jsx 코드 길이도, 핸들러 수도 많아 Form 컴포넌트 만큼은 코드 라인 수가 길어지더라구요.
=> 그래서 react-hook-form과 같은 form 전용 훅을 사용하게 되죠. 홍준과 같은 고민을 이미 많은 사람들이 해봤을 거에요. 다양한 form을 다루는 hook 라이브러리들을 살펴보면서 이런식으로도 해볼 수 있구나 하는걸 느껴보면 좋겠어요.
ps. 과제 채점 빼먹어서 미안해요!! ^^;