Frontend Fundamental 원칙을 적용해보고 느낀점을 비슷한 톤앤매너로 작성해본 글입니다.
대부분 주관적인 생각이라서 취사선택을 해주시면 더 좋아요.
React를 다루다 보면 Props Drilling은 피할 수 없는 숙명처럼 다가옵니다.
일반적으로 State Lifting을 했을 때 하위 컴포넌트로 내 State 또는 핸들러를 내려주어야 할 때 마주쳐요.
Props Drilling은 꼭 해결해야 할 문제처럼 보이지 않을 수도 있어요.
하지만 상위 컴포넌트에서 하위컴포넌트까지 props를 전달하기 위해 10개의 컴포넌트를 지나야 한다고 가정해볼게요. 이때 props로 내려주는 데이터 이름을 바꿔야 하는 상황이 생겼어요.
그럼 개발자는 일일이 10개의 컴포넌트에서 각각 이름을 수정해야 해요.
CartPage 컴포넌트와 CartItem 컴포넌트를 확인 해볼게요, 두 컴포넌트 사이에는 4개의 서로다른 컴포넌트가 존재하는 상황이에요.
CartItem 컴포넌트에서 delete,update같은 행동을 할 때 CartPage에 존재하는 cartItems State를 다뤄야하기 때문에 CartPage에서 handler를 만들어서 props로 전달해줄 수밖에 없는 상황이에요.
const CartItem = ({
cartItem,
deleteCartItemAsyncState,
updateCartItemCountAsyncState,
onDeleteCartItem,
onProductSelect,
onUpdateCartItemCount,
isChecked,
}: {
cartItem: CartItemModel;
deleteCartItemAsyncState: AsyncState<null>;
updateCartItemCountAsyncState: AsyncState<{
id: number;
itemCount: number;
}>;
onDeleteCartItem: (productId: number) => void;
onProductSelect: (productId: number, isChecked: boolean) => void;
onUpdateCartItemCount: (productId: number, itemCount: number) => void;
isChecked: boolean;
}) => {
//... 장바구니 카트 품목 컴포넌트 로직
}
const CartPage = ({ cartId }: { cartId: number }) => {
const navigate = useNavigate();
const {
cartItems,
cartItemsAsyncState,
deleteCartItemAsyncState,
updateCartItemCountAsyncState,
checkedProductIds,
handleDeleteCartItem,
handleUpdateCartItemCount,
handleAllProductSelect,
handleProductSelect,
} = useCartPage({ cartId });
// ... 기타 로직 생략
return (
<CartPageLayout>
<HeaderArea>
<Header actionIcon={<div>SHOP</div>} />
</HeaderArea>
<CartPageBodyArea>
<CartContentTitle>장바구니 <임시추가버튼 /></CartContentTitle>
<CartPageBody
cartItems={cartItems}
checkedProductIds={checkedProductIds}
orderPrice={orderPrice}
deliveryFee={deliveryFee}
totalPrice={totalPrice}
cartItemsAsyncState={cartItemsAsyncState}
deleteCartItemAsyncState={deleteCartItemAsyncState}
updateCartItemCountAsyncState={updateCartItemCountAsyncState}
onDeleteCartItem={handleDeleteCartItem}
onUpdateCartItemCount={handleUpdateCartItemCount}
onAllProductSelect={handleAllProductSelect}
onProductSelect={handleProductSelect}
/>
</CartPageBodyArea>
<BottomArea>
<BaseButton
disabled={isOrderConfirm}
onClick={handleOrderConfirmButtonClick}
>
주문 확인
</BaseButton>
</BottomArea>
</CartPageLayout>
);
};
CartPage 컴포넌트는 CartItem 컴포넌트가 어떤 행위를 하는지 모르는 것이 좋아요. 현재는 useCartPage라는 훅으로 추상화가 되어 세부 구현은 모르는 상황이지만, 실제로 참조는 하고 있는 상황이에요.
혹시라도 요구사항에서 CartItem을 삭제하는 기능이 필요없어져 onDeleteCartItem props가 필요 없어지게 된다면 두 컴포넌트를 포함해 중간에 징검다리 역할을 하는 컴포넌트를 모두 수정해야돼요.
const CartItem = ({
cartItem,
isChecked,
}: {
cartItem: CartItemModel;
isChecked: boolean;
}) => {
const {
requestUpdateCartItemCount,
updateCartItemCountAsyncState,
requestDeleteCartItem,
deleteCartItemAsyncState,
} = useCartContext();
const { checkedProductIdsDispatch } = useCheckedProductContext();
const { id, name, price, itemCount, imgUrl } = cartItem;
const handleDeleteCartItem = async (productId: number) => {
try {
await requestDeleteCartItem(productId);
checkedProductIdsDispatch({ type: "remove", productId: productId });
} catch (error) {
if (error instanceof ApiError) {
alert(error.message);
} else {
alert("알 수 없는 에러가 발생했습니다.");
}
}
};
// ... 기타 핸들러 및 UI
};
CartPage의 UI
<CartPageBodyArea>
<CartContentTitle>
장바구니 <임시추가버튼 />
</CartContentTitle>
<CartPageBody
orderPrice={orderPrice}
deliveryFee={deliveryFee}
totalPrice={totalPrice}
/> // props 제거됨
</CartPageBodyArea>
useCartContext(서버 상태)와 useCheckedProductContext(클라이언트 상태)를 이용해 필요한 값들을 꺼내 스스로 핸들러 로직을 만들 수 있게 되었어요.
이제는 CartItem과 관련된 요구사항이 변경되어도 다른 컴포넌트를 수정하지 않고 CartItem을 포함한 기타 로직들을 최소한으로 건드리고 수정할 수 있게 되었어요.
또한 CartPage와 CartItem을 포함한 중간단계의 컴포넌트들이 불필요한 props를 가지지 않아도 되고, 이로써 CartItem을 제외한 나머지 컴포넌트들이 CartItem이 어떤 역할을 하는지 몰라도 돼요.
이번에는 같은 코드베이스에서 다른 시각으로 접근해볼게요.
CartItem의 컴포넌트에 props를 전달해주는 컴포넌트를 살펴볼게요.
CartPage -> CartPageBody -> CartContent -> CartItemList -> CartItem
이렇게 총 5개의 컴포넌트들이 존재하는 상황이에요.
CartContent, CartItemList는 CartPageBody가 내려주는 props의 대부분이 CartContent, CartItemList를 그대로 거쳐 CartItem으로 전달되는 상황이에요.
const CartPageBody = ({
cartItems,
checkedProductIds,
orderPrice,
deliveryFee,
totalPrice,
cartItemsAsyncState,
deleteCartItemAsyncState,
updateCartItemCountAsyncState,
onDeleteCartItem,
onUpdateCartItemCount,
onAllProductSelect,
onProductSelect,
}: { // props 타입 정의
}) => {
switch (cartItemsAsyncState.status) {
// ... 비동기 상태에 따른 컴포넌트 분기처리 생략
case "success": {
return cartItems.length === 0 ? (
<CartEmpty />
) : (
<CartContent
cartItems={cartItems}
checkedProductIds={checkedProductIds}
orderPrice={orderPrice}
deliveryFee={deliveryFee}
totalPrice={totalPrice}
deleteCartItemAsyncState={deleteCartItemAsyncState}
updateCartItemCountAsyncState={updateCartItemCountAsyncState}
onDeleteCartItem={onDeleteCartItem}
onUpdateCartItemCount={onUpdateCartItemCount}
onAllProductSelect={onAllProductSelect}
onProductSelect={onProductSelect}
/>
);
}
}
};
export default CartPageBody;
문제점은 앞서 확인했던 것과 똑같이 컴포넌트 간 결합도가 높은 점이에요.
혹시라도 요구사항에서 CartItem을 삭제하는 기능이 필요없어져 onDeleteCartItem props가 필요 없어지게 된다면 두 컴포넌트를 포함해 중간에 징검다리 역할을 하는 컴포넌트를 모두 수정해야돼요
const CartPageBody = ({
cartItems,
checkedProductIds,
orderPrice,
deliveryFee,
totalPrice,
cartItemsAsyncState,
deleteCartItemAsyncState,
updateCartItemCountAsyncState,
onDeleteCartItem,
onUpdateCartItemCount,
onAllProductSelect,
onProductSelect,
}: { // props 타입 정의
}) => {
switch (cartItemsAsyncState.status) {
// ... 비동기 상태에 따른 컴포넌트 분기처리 생략
case "success": {
return cartItems.length === 0 ? (
<CartEmpty />
) : (
<CartContent cartItems={cartItems}>
<CartItemList
isSelectAllProduct={checkedProductIds.length === cartItems.length}
onAllProductSelect={onAllProductSelect}
>
{cartItems.map((cartItem) => (
<CartItem
key={cartItem.id}
cartItem={cartItem}
deleteCartItemAsyncState={deleteCartItemAsyncState}
updateCartItemCountAsyncState={updateCartItemCountAsyncState}
onProductSelect={onProductSelect}
onDeleteCartItem={onDeleteCartItem}
onUpdateCartItemCount={onUpdateCartItemCount}
isChecked={checkedProductIds.includes(cartItem.id)}
/>
))}
</CartItemList>
<CartPaymentSummary
orderPrice={orderPrice}
deliveryFee={deliveryFee}
totalPrice={totalPrice}
/>
</CartContent>
);
}
}
};
CartContent, CartItemList가 각각 children props를 받아서 자식 컴포넌트를 노출시킴으로써 CartPageBody가 CartItem이 필요한 props를 직접 전달하는 구조가 되었어요.
아까와 얻게된 이점은 유사해요.
이제는 CartItem과 관련된 요구사항이 변경되어도 다른 컴포넌트를 수정하지 않고 CartItem을 포함한 기타 로직들을 최소한으로 건드리고 수정할 수 있게 되었어요. 또한 CartPage와 CartItem을 포함한 중간단계의 컴포넌트들이 불필요한 props를 가지지 않아도 되고, 이로써 CartItem을 제외한 나머지 컴포넌트들이 CartItem이 어떤 역할을 하는지 몰라도 돼요.
하지만 컴포넌트 합성만이 주는 추가 이점이 있는 것 같아요. 바로 불필요한 추상화 제거예요.
개발자는 CartPageBody만 보고 나머지 컴포넌트의 역할을 어느정도 유추할 수 있어요.
만약 props를 넘겨주기만 하고, UI적인 요소만 담당하고 있는 컴포넌트가 있었다면 children을 이용한 컴포넌트 합성으로 "이 컴포넌트는 UI껍데기 정도입니다"라는 것을 보여줄 수 있어요.
<CartContent>
<CartItemList/>
<CartPaymentSummary />
</CartContent>
// CartContent가 장바구니 목록과 결제정보 요약을 감싸는 UI 컴포넌트이구나..!
하지만 고민해볼 점이 있는 것 같아요.
Props Drilling이 온전히 해소되었냐고 할 수는 없는 것 같아요. 개선된 이후의 CartPageBody도 CartPage로부터 props를 받아서 전달해주고 있어요. props 전달 depth는 줄었지만 여전히 전달을 위한 props가 존재해요.
"CartPageBody도 children을 받으면 되지 않을까?"라고 질문을 할 수도 있어요. 이에 대해서는 아래에서 이야기해볼게요.
불필요한 추상화 제거는 정도에 따라 양날의 검이 될 수 있어요.
만약 10개의 컴포넌트가 결합되어 있는데, 모든 컴포넌트를 합성한다면 개발자는 한 페이지에서 모든 컴포넌트를 확인해야 해요. 이는 가독성을 떨어뜨리고 책임이 한 곳에 집중되는 문제점이 존재해요. CartPageBody의 비동기 처리 로직을 CartPage에 드러낸다면 가독성 문제가 생길 가능성이 높아요.
1. UI 껍데기와 알맹이의 분리가 필요할 때는 '컴포넌트 합성'을 고려해보세요.
컴포넌트가 단순히 UI 껍데기 역할만 하고 있다면, children을 활용한 합성이 꽤 훌륭한 해결책이 됩니다. 불필요한 Props 전달을 없애고, "이 컴포넌트는 UI 레이아웃입니다"라는 역할을 코드로 명확히 드러낼 수 있어요.
단, 모든 것을 최상단으로 끌어올리면 가독성을 해칠 수 있으므로 적절한 선에서 합성을 멈추는 것이 중요해요.
2. 핵심 데이터와 로직이 깊게 전달되어야 할 때는 'Context API'
합성만으로 해결하기에 컴포넌트 depth가 너무 깊거나, 해당 데이터가 여러 하위 컴포넌트에서 직접 참조되어야 한다면 Context API 도입을 고려합니다.
부모-자식 간의 결합을 완전히 끊어내고, 필요한 컴포넌트가 직접 데이터를 가져오게 만들어 보세요.
UI 레이아웃의 분리가 목적이라면 컴포넌트 합성을 우선 고려합니다.
하지만 다루는 데이터가 핵심 비즈니스 상태이고 상위 컴포넌트의 책임이 너무 무거워지는 시점이 온다면, 그때 Context API를 도입합니다.