들어가며
이전 글에서 훅 추상화를 아토믹하게 쪼개는 이야기를 했었는데요. 몬스터 훅 대신 URL 필터 동기화, 날짜 범위 처리 같은 로직을 각각 분리해서 조합해 쓰는 방식이었죠. 덕분에 각 로직이 한 가지 일만 해서 파악하기 쉬웠어요.
근데 막상 컴포넌트를 보면 좀 달랐어요. 예를 들어 상품 상세 페이지를 만든다고 해볼게요
function ProductPage({ productId }) {
// 상품 정보 관련
const { data: product } = useProductQuery(productId);
// 장바구니 관련
const [quantity, setQuantity] = useState(1);
const { mutate: addToCart } = useAddToCart();
// 리뷰 관련
const { data: reviews } = useReviewsQuery(productId);
const [reviewText, setReviewText] = useState('');
const { mutate: submitReview } = useSubmitReview();
// 이 컴포넌트는 "상품 정보" + "장바구니" + "리뷰" 세 가지 주제가 섞여있음
return (
<>
<ProductInfo product={product} />
<QuantitySelector value={quantity} onChange={setQuantity} />
<Button onClick={() => addToCart({ productId, quantity })}>장바구니</Button>
<ReviewList reviews={reviews} />
<ReviewForm text={reviewText} setText={setReviewText} onSubmit={submitReview} />
</>
);
}
훅과 함수는 잘 쪼갰는데, 그것들이 조합되는 컴포넌트는 여러 주제(관심사)가 섞여 있더라고요.
책임(행동) vs 관심사(주제)
행동 단위로 분리했던 방식
저는 "각 모듈은 한 가지 일(행동)만 한다"는 원칙으로 훅을 분리했어요.
그래서 행동 단위로는 철저하게 분리했습니다:
- 데이터 가져오기 →
useProductQuery훅 - 장바구니 추가하기 →
useAddToCart훅 - 리뷰 가져오기 →
useReviewsQuery훅 - 리뷰 제출하기 →
useSubmitReview훅
각 훅은 명확히 "한 가지 일"만 합니다.
문제: 컴포넌트는 여러 주제를 다룸
근데 이 훅들을 조합하는 컴포넌트를 보면:
function ProductPage({ productId }) {
const product = useProductQuery(productId); // "상품 정보" 주제
const [quantity, setQuantity] = useState(1); // "장바구니" 주제
const addToCart = useAddToCart(); // "장바구니" 주제
const reviews = useReviewsQuery(productId); // "리뷰" 주제
const [reviewText, setReviewText] = useState(''); // "리뷰" 주제
const submitReview = useSubmitReview(); // "리뷰" 주제
return (
<>
<ProductInfo /> {/* "상품 정보"에 관한 UI */}
<CartActions /> {/* "장바구니"에 관한 UI */}
<ReviewSection /> {/* "리뷰"에 관한 UI */}
</>
);
}
이 컴포넌트는 "상품 정보", "장바구니", "리뷰" 세 가지 관심사(주제)가 섞여 있어요.
클래스로 바라보기
사실 이건 컴포넌트를 클래스처럼 바라보면 이해가 쉬워요.
객체지향에서 클래스는 "관련된 데이터와 행동을 하나로 묶은 것"이잖아요. 컴포넌트도 마찬가지입니다. 상태(state)와 메서드(이벤트 핸들러), 그리고 렌더링(UI)을 하나로 묶은 단위거든요.
// 컴포넌트를 클래스처럼 바라보면...
class ProductPage {
// 필드 (상태)
product: Product;
quantity: number;
reviews: Review[];
reviewText: string;
// 메서드 (행동)
addToCart() { ... }
submitReview() { ... }
// render (UI)
render() { ... }
}
이렇게 보면 문제가 바로 보이죠. 이 클래스는 "상품 정보", "장바구니", "리뷰" 세 가지 주제의 데이터와 행동을 전부 가지고 있어요. 객체지향 관점에서 보면 전형적인 God Class입니다.
클래스 설계에서 "이 클래스가 너무 많은 책임을 가지고 있나?"를 판단할 때, 필드들이 서로 관련 있는지를 봅니다. quantity와 reviewText는 서로 아무 상관이 없어요. 같은 클래스에 있을 이유가 없는 거죠.
행동 vs 주제
책임(Responsibility) = 행동(What it does)
- "데이터를 가져온다" -
fetchData() - "장바구니에 추가한다" -
addToCart()
관심사(Concern) = 주제(What it's about)
- "상품 정보에 관한 것" -
ProductInfoSection - "장바구니에 관한 것" -
CartSection - "리뷰에 관한 것" -
ReviewSection
함수나 훅은 행동으로, 컴포넌트는 주제로 쪼개야 합니다.
컴포넌트는 주제로 쪼개기
리팩토링
Before: 여러 주제가 섞임
function ProductPage({ productId }) {
// 상품 정보 관련
const { data: product } = useProductQuery(productId);
// 장바구니 관련
const [quantity, setQuantity] = useState(1);
const { mutate: addToCart } = useAddToCart();
// 리뷰 관련
const { data: reviews } = useReviewsQuery(productId);
const [reviewText, setReviewText] = useState('');
const { mutate: submitReview } = useSubmitReview();
return (
<>
<ProductInfo product={product} />
<QuantitySelector value={quantity} onChange={setQuantity} />
<Button onClick={() => addToCart({ productId, quantity })}>
장바구니 추가
</Button>
<ReviewList reviews={reviews} />
<ReviewForm
text={reviewText}
setText={setReviewText}
onSubmit={() => submitReview({ productId, text: reviewText })}
/>
</>
);
}
After: 주제별로 분리
// 페이지는 레이아웃만 담당
function ProductPage({ productId }) {
return (
<>
<ProductInfoSection productId={productId} />
<CartSection productId={productId} />
<ReviewSection productId={productId} />
</>
);
}
// "상품 정보" 주제만
function ProductInfoSection({ productId }) {
const { data: product } = useProductQuery(productId);
return <ProductInfo product={product} />;
}
// "장바구니" 주제만
function CartSection({ productId }) {
const [quantity, setQuantity] = useState(1);
const { mutate: addToCart } = useAddToCart();
return (
<>
<QuantitySelector value={quantity} onChange={setQuantity} />
<Button onClick={() => addToCart({ productId, quantity })}>
장바구니 추가
</Button>
</>
);
}
// "리뷰" 주제만
function ReviewSection({ productId }) {
const { data: reviews } = useReviewsQuery(productId);
const [reviewText, setReviewText] = useState('');
const { mutate: submitReview } = useSubmitReview();
return (
<>
<ReviewList reviews={reviews} />
<ReviewForm
text={reviewText}
setText={setReviewText}
onSubmit={() => submitReview({ productId, text: reviewText })}
/>
</>
);
}
이제 각 컴포넌트가 하나의 명확한 주제만 다룹니다.
클래스로 비유하면, 하나의 God Class를 세 개의 작은 클래스로 쪼갠 거예요. 각 클래스가 자기 데이터와 행동만 가지고 있으니 훨씬 깔끔하죠.
로직 계층 내리기
Before 코드를 다시 보면, ProductPage가 모든 훅을 직접 호출하고 있었어요:
function ProductPage({ productId }) {
// 이 컴포넌트가 모든 로직의 집합점
const { data: product } = useProductQuery(productId);
const [quantity, setQuantity] = useState(1);
const { mutate: addToCart } = useAddToCart();
const { data: reviews } = useReviewsQuery(productId);
const [reviewText, setReviewText] = useState('');
const { mutate: submitReview } = useSubmitReview();
// ...
}
훅들은 예쁘게 분리되어 있지만, 그걸 전부 호출하는 이 컴포넌트 자체가 하나의 거대한 몬스터훅처럼 동작하고 있었던 거죠.
주제별로 컴포넌트를 분리하면, 로직도 자연스럽게 각자의 자리로 내려갑니다:
- 장바구니 로직(
useState,useAddToCart) →CartSection안으로 - 리뷰 로직(
useReviewsQuery,useState,useSubmitReview) →ReviewSection안으로
// ProductPage는 더 이상 로직을 직접 들고 있지 않음
function ProductPage({ productId }) {
return (
<>
<ProductInfoSection productId={productId} />
<CartSection productId={productId} /> {/* 장바구니 로직은 여기 안에 */}
<ReviewSection productId={productId} /> {/* 리뷰 로직은 여기 안에 */}
</>
);
}
이제 ProductPage는 레이아웃만 담당하고, 각 로직은 해당 주제를 책임지는 컴포넌트가 가지고 있어요. 한 곳에서 모든 걸 끌어안던 구조가 사라진 거죠.
마치며
다만 기능 확장, 잘못된 설계 등등으로 큰 변경이 있을 때도 있습니다.
관심사별로 컴포넌트를 나누는 작업은 시간이 꽤 소요되더라고요. 변경에 따라 최선의 구조도 달라지구요.
그래서 저는 해당 피쳐를 마무리할 때, 즉 하위 코드들의 변경 가능성이 적을 때 일괄로 로직의 계층을 내려 처리하는 걸 선호합니다.
'<frontend> > clean?code' 카테고리의 다른 글
| 컴포넌트 분기를 상위로 밀어내기 (15) | 2025.12.13 |
|---|---|
| 훅 추상화 강도 (3) | 2025.08.31 |