배포링크
과제의 핵심취지
- 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과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
-
엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?
과제 셀프 회고
이번 과제는 개인적으로 시간 투자를 많이 하지 못한 점이 아쉽습니다. 최근 취업 준비에 더 많은 시간을 할애해야겠다는 필요성을 느끼고 있었고, 특히 오프코치님의 멘토링을 들으면서 시간 분배의 중요성에 대해 더욱 체감하게 되었습니다. 요즘은 거의 과제 2, 취업 준비 8 정도의 비율로 시간을 쓰고 있는 것 같아요.
과제는 수요일 저녁쯤 시작했는데, 다행히 리액트에는 익숙해서 진입 장벽은 높지 않았습니다. 특히 수많은 props drilling을 경험하면서 이를 어떻게 개선할 수 있을지 고민해보는 과정이 나름 재미있었고, 리팩토링을 통해 코드 구조를 조금씩 개선해보는 시도도 흥미로웠습니다.
아직 부족한 점이 많지만, 이런 과정을 통해 실제 현업에서의 코드 구조나 설계 방식에 대해 더 깊이 고민해볼 수 있었던 점이 의미 있었던 것 같습니다. 다음 과제는 더 넉넉하게 시간을 투자해서, 조금 더 완성도 있는 결과물을 만들어보고 싶습니다.
과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?
1. 디자인패턴을 도입하여 요구사항 변화에 유연하게 대응해보기
이번 과제에서 가장 신경 썼던 부분은 디자인 패턴을 어떻게 적용해볼 수 있을까에 대한 고민이었습니다.
준일 코치님의 요구사항의 변화로 알아가는 클린코드 글을 굉장히 감명깊게 읽었는데요. 그 관점을 가지고 해당 부분을 신경써서 개발해보고 싶었습니다. (작은 태스크라도 한번 시도해보고 싶었어요)
우선 요구사항을 한번 정의해보면
- 어드민 페이지에서 탭으로 “상품 관리” 탭과 “쿠폰 관리” 탭이 있다.
- “상품 관리”탭을 누르면 상품관리 할 수 있는 콘텐츠가, “쿠폰 관리”를 누르면 쿠폰 관리를 할 수 있는 콘텐츠 가 나온다.
그래서 초기에는 아래와 같이 조건부 렌더링을 사용해서 구현했습니다.
{tab === "상품관리" && <상품관리탭 />}
{tab === "쿠폰관리" && <쿠폰관리탭 />}
만약 여기서 유저관리, 재고관리, 리뷰관리 등등 새로운 탭을 추가해달라는 요구사항이 발생하면 어떻게 될까요
{tab === "상품관리" && <상품관리탭 />}
{tab === "쿠폰관리" && <쿠폰관리탭 />}
{tab === "유저관리" && <유저관리탭 />}
{tab === "재고관리" && <재고관리탭 />}
{tab === "리뷰관리" && <리뷰관리탭 />}
아마 이런식으로 구현해볼 것 같았습니다. 이 방식도 간단하고 직관적이지만, 탭이 많아질수록 컴포넌트가 길어지고 복잡도가 증가할 수 있다는 점에서 구조적으로 불만족스러웠습니다.
그래서 리팩토링을 통해 합성 컴포넌트(Compound Components) 패턴을 적용해보았습니다. Tabs라는 추상화된 UI 컴포넌트를 중심으로, 각 탭의 Trigger와 Panel을 독립적인 방식으로 구성할 수 있도록 구조를 바꿨습니다.
<Tabs defaultValue={ADMIN_TABS.PRODUCTS}>
<Tabs.List>
<Tabs.Trigger value={ADMIN_TABS.PRODUCTS}>상품 관리</Tabs.Trigger>
<Tabs.Trigger value={ADMIN_TABS.COUPONS}>쿠폰 관리</Tabs.Trigger>
</Tabs.List>
<Tabs.Content>
<Tabs.Panel value={ADMIN_TABS.PRODUCTS}>
<ProductManagement ...props />
</Tabs.Panel>
<Tabs.Panel value={ADMIN_TABS.COUPONS}>
<CouponManagement ...props />
</Tabs.Panel>
</Tabs.Content>
</Tabs>
이 방식의 장점은 무엇보다도 구조가 명확하다는 점입니다.
탭의 구성 요소(List, Trigger, Panel)가 명확히 분리되어 있어 코드의 가독성과 유지보수성이 뛰어나고, 새로운 탭을 추가하거나 수정할 때도 전체 구조를 쉽게 파악할 수 있습니다.
또한 Tabs 컴포넌트 내부에서 상태를 관리하기 때문에, 별도의 상태 선언 없이도 UI 전환이 가능해지고, 기능별 컴포넌트를 따로 나눠서 독립적으로 관리할 수 있어 관심사 분리 관점에서도 큰 장점이었습니다.
하지만 이것 역시 탭이 많아지면 유연하게 잘 처리할 수 있는 구조일까? 고민했습니다..
제 결론은 컴포넌트가 굉장히 길어지고 가독성이 매우 안좋아질 것 같았어요. 더 나아가 Router를 추가해서 탭이 변경되면 URL도 변경되는 요구사항을 받으면 어떻게 해야할까.. 이런 고민도 했습니다.
이 둘을 유연하게 처리할 수 있는 방식을 고민해보다가 기능 목록을 객체 배열로 추상화하는 것이였습니다.
const adminFeatures: AdminFeature[] = [
{
id: ADMIN_TABS.PRODUCTS,
label: "상품 관리",
component: ProductManagementFeature,
},
{
id: ADMIN_TABS.COUPONS,
label: "쿠폰 관리",
component: CouponManagementFeature,
},
{
id: ADMIN_TABS.INVENTORY,
label: "재고 관리",
component: InventoryManagementFeature,
},
];
이런식으로요. 이렇게 추상화하면, 각 기능을 새로운 탭에 추가하거나 재배치할 때 페이지 컴포넌트 구조를 건드리지 않아도 되기 때문에 매우 유연하게 관리할 수 있습니다. 또 각 component는 독립적인 컴포넌트로 관리되기 때문에 재사용성과 가독성 모두 좋아졌습니다.
한가지 발생한 문제점은 props가 다른 경우였는데요. 이 부분은 각 Feature 레이어에서 jotai를 활용해 전역상태를 다루고 있었기 때문에 생각보다 쉽게 해결할 수 있었습니다. (직접적인 해결방법은 아닌데 객체에서 render 함수 명시하고 직접 넘기는 방식으로 해결해볼수 있을 것 같아요)
<Tabs defaultValue={adminFeatures[0].id}>
<Tabs.List>
{adminFeatures.map((feature) => (
<Tabs.Trigger key={feature.id} value={feature.id}>
{feature.label}
</Tabs.Trigger>
))}
</Tabs.List>
<Tabs.Content>
{adminFeatures.map((feature) => (
<Tabs.Panel key={feature.id} value={feature.id}>
<feature.component />
</Tabs.Panel>
))}
</Tabs.Content>
</Tabs>
또하나 궁금했던 건 UI 구조를 Router 기반으로 전환하고 싶을 때, 이 패턴이 얼마나 확장성이 좋을까? 였습니다. 다행히도 adminFeatures 형태의 구조는 라우팅 전환에도 유리했습니다.
예를 들어 아래와 같이 Route 배열을 뽑아내는 것도 어렵지 않았습니다.
// React Router와 연동
const AdminRouter = () => {
return (
<Routes>
{adminFeatures.map((feature) => (
<Route
key={feature.id}
path={`/admin/${feature.id}`}
element={<feature.component />}
/>
))}
</Routes>
);
};
각 feature의 id를 path로 활용할 수 있어서 라우트를 자동으로 생성할 수 있었고, 이렇게 구성하면 나중에 탭 기반 UI에서 라우터 기반 UI로 전환하거나, 탭과 라우터를 동시에 사용하는 방식(예: ?tab=products)도 유연하게 적용 가능했습니다.
이처럼 요구사항 변화로 알아가는 클린코드에 대해서 직접 체험해보며 요구사항의 변화를 고려하면서 유연하게 대응하는 코드를 작성해본 좋은 경험이였습니다.
2. 에러클래스+ 고차함수로 유연하게 에러핸들링하기.
이번 과제를 진행하며 코드를 컴포넌트와 훅으로 나누는 과정에서, 에러와 알림 처리가 코드의 가독성과 유지보수성을 해치는 주된 원인이 될 수 있다는 점을 깨달았습니다. 초기에는 기능 구현에 집중하다 보니, 이러한 부수적인 로직이 반복적으로 작성되거나 비즈니스 로직과 뒤섞이는 경우가 많았습니다. 이 부분을 놓치고 싶지 않아서, 더 좋은 구조로 개선해보고자 리팩토링을 시도하게 되었습니다.
<비즈니스 로직과 UI 로직의 관심사 분리>
처음 코드를 작성했을 때는 재고 확인, 수량 변경 같은 비즈니스 로직 안에 addNotification("재고가 부족합니다!", "error")와 같은 UI 관련 로직이 함께 존재했습니다. 이러한 방식은 다음과 같은 문제들을 야기했습니다.
- 코드의 비순수성: 상품을 장바구니에 담는 핵심 로직이 '알림을 띄우는' 부수 효과에 의존하게 되었습니다. 만약 알림이 아닌 다른 방식으로 에러를 처리해야 한다면, 비즈니스 로직이 담긴 함수를 직접 수정해야만 했습니다.
- 재사용성 저하:
addToCart함수는 오직 알림을 띄우는 환경에서만 제대로 동작합니다. 만약 서버 API를 통해 장바구니에 상품을 추가하는 로직을 재사용해야 한다면, UI 관련 코드를 제거하거나 새롭게 작성해야 하는 불편함이 있었습니다.
저는 "어떻게 하면 순수한 비즈니스 로직만 담고 있는 함수를 만들 수 있을까?"라는 근본적인 질문에 대해 고민을 했습니다..
<문제 해결: throw와 고차 함수의 역할 분담>
제가 생각한 해결책은 비즈니스 로직에서는 에러가 발생했을 때 예외를 throw하고, 그 예외를 외부에서 처리하는 것이었습니다. 비즈니스 로직의 핵심은 '어떤 조건에서 어떤 상태 변화가 일어나야 하는지'를 정의하는 것이라고 생각했어요.
1. 비즈니스 로직에 집중하는 Atom
jotai의 atom을 활용하여 장바구니 관련 비즈니스 로직을 구현했습니다. 여기서 핵심은 각 atom의 set 함수는 오직 상태 변경이라는 순수한 역할에만 집중하도록 설계했습니다.
예를 들어, addToCartAtom은 장바구니에 상품을 추가하는 과정에서 재고가 부족하거나 수량이 초과되면 UI 알림을 띄우는 대신 명확한 에러 클래스를 throw 하게끔 구현했습니다.
// 장바구니에 상품 추가하는 atom
export const addToCartAtom = atom(null, (get, set, product: Product) => {
const cart = get(cartAtom);
const remainingStock = getRemainingStockModel(product, cart);
// 비즈니스 로직: 재고가 0 이하면 예외를 던짐
if (remainingStock <= 0) {
throw new InsufficientStockError(product.name, remainingStock);
}
// ...
});
// 재고 부족 에러
export class InsufficientStockError extends CartError {
constructor(productName: string, availableStock: number) {
super(`${productName}의 재고가 부족합니다. (가용 재고: ${availableStock}개)`);
this.name = "InsufficientStockError";
}
}
// 재고 초과 에러
export class StockExceededError extends CartError {
constructor(productName: string, maxStock: number, requestedQuantity: number) {
super(`${productName}의 재고는 ${maxStock}개까지만 있습니다. (요청 수량: ${requestedQuantity}개)`);
this.name = "StockExceededError";
}
}
// 장바구니가 비어있는 에러
export class EmptyCartError extends CartError {
constructor() {
super("장바구니가 비어있습니다.");
this.name = "EmptyCartError";
}
}
// 수량 유효성 검증 에러
export class InvalidQuantityError extends CartError {
constructor(quantity: number) {
super(`유효하지 않은 수량입니다: ${quantity} (1 이상이어야 합니다)`);
this.name = "InvalidQuantityError";
}
}
마찬가지로, updateQuantityAtom 역시 수량이 재고를 초과하면 StockExceededError를 던집니다. 이렇게 함으로써 비즈니스 로직은 어떤 에러가 발생했는지 '알려주는' 역할만 담당하게 만들었습니다.
2. 고차 함수로 에러 책임을 분리
던져진 에러를 처리하는 역할은 비즈니스 로직의 바깥인 고차 함수의 책임으로 분리했습니다.
export const withTryNotifySuccess = <T extends readonly unknown[], R>(
action: (...args: T) => R,
successMessage: string,
addNotification: (message: string, type: "success" | "error") => void
) => {
return (...args: T): R | undefined => {
try {
const result = action(...args); // ✨ 비즈니스 로직(atom) 실행
addNotification(successMessage, "success"); // 성공 시 알림 처리
return result;
} catch (error) {
// ✨ 비즈니스 로직에서 던진 에러를 여기서 잡음
const errorMessage = error instanceof Error ? error.message : "오류가 발생했습니다";
addNotification(errorMessage, "error"); // 에러 메시지를 알림으로 처리
return undefined;
}
};
};
UI 컴포넌트에서는 이 고차 함수를 이용해 비즈니스 로직을 감싸기만 하면 되게끔 설계했습니다.
// UI 레이어에서 비즈니스 로직을 호출하는 예시
const handleAddProduct = useAutoCallback(
withTryNotifySuccess(addProduct, "상품이 추가되었습니다.", addNotification)
);
이러한 구조를 통해 비즈니스 로직은 오직 상태 변경과 유효성 검증에 집중하고, UI 알림 처리는 고차 함수라는 얇은 레이어를 통해 일관되게 처리할 수 있게 리팩토링 하였습니다.
비즈니스 로직의 '순수성'을 지키고, 알림처리는 고차 함수를 통해 명확하게 관심사를 분리할 수 있었습니다. 또한 반복되는 에러 처리 로직을 추상화하여 코드의 일관성, 재사용성을 동시에 확보할 수 있었습니다.
3. Props drliing 최대한 줄여보기
이번 과제의 핵심인 Props drliling을 경험해보고 이걸 최대한 줄여봐야지 고민했습니다. 처음엔 전형적으로 App컴포넌트에서 모든 상태를 다 정의한뒤 props로 내려줬고, 이렇게 하고나니 거의 4레벨 5레벨 수준의 props drliling이 발생했고 컴포넌트간 결합도도 매우 강했습니다.
예를들어서 ProductMagagement (관리자에서 쓰는 상품 탭) 컴포넌트는 12개의 props를 받고 있었고, ShopPage는 페이지단 컴포넌트인데도 상위에서 9개정도 받고있었습니다.
이러한 문제를 분명 해결할 수 있을것 같았는데 마침 로컬스토리지에 저장되어있던 상태에서 아이디어를 떠올랐습니다. 어차피 참조가 달라도 키가 같으면 훅으로 동기화 되지 않을까? 라고 접근을 했고 최대한 상태를 격시시켜가며 리팩토링 했습니다.
ProductManagement부터 useProducts, useCart, useProductForm 훅을 직접 사용하도록 변경했고, 이렇게 하니 12개던 props가 1개(addNotification)로 줄어들었습니다.
다음으로 ShopPage도 useCart, useCoupons 훅을 직접 사용하도록 변경하니 9개던 props가 1개로 줄어들었습니다.
const App = () => {
// 알림 시스템
const { notifications, addNotification, removeNotification } = useNotification();
// 앱 UI 상태 관리
const { isAdmin, toggleAdmin, totalItemCount, setTotalItemCount } = useAppState();
// 검색 상태 관리
const [searchTerm, setSearchTerm] = useState("");
return (
<div className="min-h-screen bg-gray-50">
<Notification notifications={notifications} onRemove={removeNotification} />
<Header
isAdmin={isAdmin}
onToggleAdmin={toggleAdmin}
totalItemCount={totalItemCount}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<main className="max-w-7xl mx-auto px-4 py-8">
{isAdmin ? (
<AdminPage addNotification={addNotification} />
) : (
<ShopPage
addNotification={addNotification}
onTotalItemCountChange={setTotalItemCount}
searchTerm={searchTerm}
/>
)}
</main>
</div>
);
};
export default App;
최종적으로 App 컴포넌트는 UI 상태(isAdmin, toggleAdmin)만 관리하게 되었고, 각 컴포넌트가 필요한 데이터를 직접 localStorage에서 가져오도록 개선할 수 있었습니다.
다만 아쉬운 점은 ShopPage에서 쓰는 장바구니의 개수는 Header가 알지 못하는 문제가 있어서 별도로 따로 상태를 만들어서 Header한테 넘겨주어야 했는데 이부분은 좀 개선할 필요가 있습니다.
과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?
- jotai에 대해서 학습시간이 부족했는데 좀 더 학습하고 구현했다면 더 좋은 구조로 설계할 수 있지 않았을까 하는 아쉬움이 남습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
Q1. 코치님은 zustand와 jotai 중 어떤 전역 상태 관리 라이브러리를 선호하시나요? 그리고 최근에는 서버 상태와 클라이언트 상태를 분리하여 관리하는 아키텍처가 선호되는데, 클라이언트 전역 상태로는 주로 어떤 상태들을 관리하는 것이 좋을까요?
Q2. 함수형 프로그래밍에 대해서 공부해보고싶어요. 코치님은 함수형 프로그래밍을 공부하신 적이 있다면 어떤식으로 공부하셨는지 책이나 방법을 추천해주실 수 있나요?
과제 피드백
창준님 이번 과제도 매우 알차게 해주셨네요. 시간을 많이 쓰지 않으셨음에도 불구하고 명확한 기준으로, 게다가 명확한 관점을 테스트하는 부분에 있어서 잘 시도하신것 같아서 좋았습니다 ㅎㅎ 잘하셨어요. 회고에 작성해주신 부분도 보면서 저도 공부를 하게 되었던 것 같습니다. 고차 함수로 에러를 처리하는것도 좋았고 각 레이어별로 에러처리하는 것도 좋았는데, 이런것들을 동일한 관점에서 에러바운더리에서 하는것도 좋을것 같아요 ㅎㅎ
Q1. 코치님은 zustand와 jotai 중 어떤 전역 상태 관리 라이브러리를 선호하시나요? 그리고 최근에는 서버 상태와 클라이언트 상태를 분리하여 관리하는 아키텍처가 선호되는데, 클라이언트 전역 상태로는 주로 어떤 상태들을 관리하는 것이 좋을까요?
저는 둘 다 사용해야 하는 사례에 따라 선택하는 편인데, 사실 크게 중요했던적은 없었던 것 같아요 ㅎㅎ 둘의 핵심차이는 사실 철학차이이고 상태를 정교하게 관리하는 것이 필요하냐 안하냐의 차이인것 같아요. 하지만, 말씀주신것처럼 요즘의 서버상태와 UI와 관련된, 예를 들어 모달이나 toast를 노출하거나 프롭드릴링이 과하게 발생하는 등 가벼운 상태만 분리하고 가벼운 상태만 전역 상태로 관리하려는 추세에서는 둘의 구성이 크게 차이가 없고 무리가 되는 부분이 없기 때문에 무난하게 선택해도 상관없을 것 같아요.
Q2. 함수형 프로그래밍에 대해서 공부해보고싶어요. 코치님은 함수형 프로그래밍을 공부하신 적이 있다면 어떤식으로 공부하셨는지 책이나 방법을 추천해주실 수 있나요?
저는 갠적으로 엄청 깊~~게 했던건 아니고 짧게 했던 것 같은데요. 지금에 있어서 유효한 방식인지는 모르겠지만.. 일단 시작은 '쏙쏙 들어오는 함수형 코딩'이 설득 관점에서 큰도움이 되고(이미 설득은 되신거 같지만) 실무적인 관점에서 적용하는 방식에 대해 많이 찾아보고 싶어서 유인동님이 생성하셨던 컨텐츠를 많이 봤던 것 같아요. 책이랑 유인동님 강의도 나온건 다 봤던 것 같은데 꽤 오래된것 같아서 지금도 유효할지는 한번 체크해봐야겠네요. 관련해서 아시는분이 함수랑산악회 같은 활동도 진행하시면서 관련 공부를 계속하시는 것 같은데 관심있으시면 연락해드릴게요 ㅎㅎ 추가로 영어로 되어있는 글도 당시에 꽤 많이 읽고 번역도 했던 것 같은데 결국 철학에 맞춰서 많이 사용해보는게 핵심인것 같고 어려운 개념도 많이 있는것 같아서 꾸준히 학습해야 하는거 같더라구요. 그리고 모든걸 함수형으로 작성하는게 아니라 중용해서 쓰는 자세가 중요한것 같아요!
아무튼 나중에 공부하신다면...저도 방법을 좀 알려주세요~