항해플러스 5주차 회고록
항해플러스 5주차 회고록입니다.
5주차 회고
이번 주는 시간투자를 많이 못했다. 취업을 하려고 항해를 수강하고 있는데 정작 가장 중요한 취준에 소홀했다.
이전엔 과제8 취준2의 비율로 흘러갔다. 그래서 이번 주부터는 이 일상의 비율을 과감하게 뒤집어보았다. 꼭 필요한 과정이기 때문에 지금부터 적절한 균형을 맞추자.
확실히 집보단 카페가서 몰입하기 좋은 환경이다. 덕분에 집중력이 높아져 짧은 시간에도 효율을 낼 수 있었다. 집중이 안되면 환경을 바꿔보자.
요즘 공부할 때 듣는 로파이 비트.
KPT 회고
Keep
- 이번 주차 과제는 준코님의 요구사항의 변화로 알아가는 클린코드를 읽고 이 관점을 기반으로 요구사항에 유연하게 대응할 수 있는 리팩토링을 한번 시도해보았는데 좋은 경험이었다.
- 지금은 취준 8 과제 2의 비율로 균형을 뒤집어보았는데 나름 괜찮은것 같다. 이력서 초안도 다 완성되어간다.
Problem
- 집에서 집중을 못하는 것.
- 기술 블로그 미루지 않기
Try
- 집 밖으로 나가기
- 적극적으로 이력서 피드백 받기
- 코테 문제 하루에 2개씩 푸는 루틴 추가하기
- 이력서 주도 개발 실행하기
과제 셀프 회고
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 { /* ... */ }
// 재고 초과 에러
export class StockExceededError extends CartError { /* ... */ }
// 장바구니가 비어있는 에러
export class EmptyCartError extends CartError { /* ... */ }
// 수량 유효성 검증 에러
export class InvalidQuantityError extends CartError { /* ... */ }
마찬가지로 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 drilling 최대한 줄여보기
이번 과제의 핵심인 props drilling을 경험해보고 이걸 최대한 줄여보려고 고민했다. 처음엔 전형적으로 App 컴포넌트에서 모든 상태를 정의한 뒤 props로 넘겨줬는데, 이렇게 하니까 거의 4\~5단계 수준의 props drilling이 발생했고 컴포넌트 간 결합도도 엄청 강해졌다.
예를 들어, ProductMagagement (관리자에서 쓰는 상품 탭) 컴포넌트는 12개나 되는 props를 받았고, ShopPage는 페이지 컴포넌트인데도 상위에서 9개 정도를 받고 있었다.
이 문제를 해결할 수 있을 것 같았는데, 마침 로컬스토리지에 저장되어 있던 상태에서 아이디어를 얻었다. '어차피 참조가 달라도 키가 같으면 훅으로 동기화되지 않을까?' 하고 접근했고, 최대한 상태를 격리하면서 리팩토링했다.
ProductManagement부터 useProducts, useCart, useProductForm 훅을 직접 사용하도록 변경했더니, 12개였던 props가 addNotification 하나로 줄었다.
다음으로 ShopPage도 useCart, useCoupons 훅을 직접 사용하도록 변경하니까 9개였던 props가 하나로 줄었다.
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한테 넘겨줘야 했다는 거다. 이 부분은 좀 더 개선할 필요가 있다고 생각한다.