ProductCard 컴포넌트를 리뷰받다가 이런 코멘트를 받았습니다.
"카드 안에서
useNavigate를 직접 쓰지 말고,onNavigateprop으로 부모에게 넘기는 게 어떨까요?"
처음엔 "그냥 카드 안에서 이동시키면 되는 거 아닌가?" 싶었는데, 이 안에는 관심사 분리 / props 네이밍 / 함수 분리라는 세 가지 개념이 다 들어 있었습니다. 이 글은 그 세 가지를 정리한 기록입니다.
한 줄로 요약하면 이렇습니다. 컴포넌트는 자기 일에만 집중하고, 다른 관심사는 부모에게 넘긴다.
원래 ProductCard는 내부에서 useNavigate를 직접 호출하고 있었습니다.
// before — 카드가 "이동"까지 스스로 결정
export default function ProductCard({ id, ... }) {
const navigate = useNavigate();
const handleClick = (productId) => navigate(toProductDetail(productId));
return <div onClick={() => handleClick(id)}>...</div>;
}
카드가 해야 할 일은 상품 정보를 보여주는 것입니다. 그런데 useNavigate가 들어오면서 "라우팅"이라는 전혀 다른 관심사까지 카드가 떠안게 됐습니다.
useNavigate는 평범한 함수가 아니라, react-router가 깔아놓은 판(Context) 위에서만 작동하는 훅입니다. <BrowserRouter> 안쪽에서만 살아있고, 그 밖에서 부르면 에러가 납니다.
즉 useNavigate를 카드 안에 넣는 순간, 카드는 "라우터가 있어야만 존재할 수 있는 컴포넌트"가 됩니다. 카드의 본질(이미지·이름·가격을 보여주는 것)은 라우터와 아무 상관이 없는데도 말이죠.
// after — 카드는 "클릭됐다"만 알리고, 뭘 할지는 부모가 결정
type ProductCardProps = Pick<Product, 'id' | 'imageUrl' | ...> & {
onNavigate: (id: string) => void;
};
export default function ProductCard({ id, ..., onNavigate }) {
return <div onClick={() => onNavigate(id)}>...</div>;
}
// 부모 (ProductListPage)
<ProductCard {...product} onNavigate={(id) => navigate(toProductDetail(id))} />
카드는 이제 "이동 방식"을 모릅니다. 그냥 클릭됐다는 사실만 부모에게 알리고, 실제로 어디로 갈지는 부모가 정합니다.
같은 카드를 검색 결과 페이지에서도 쓴다고 해봅시다. 그런데 거기선 클릭했을 때 상세 페이지로 이동하는 게 아니라 미리보기 모달을 띄우고 싶습니다.
useNavigate가 카드 안에 박혀 있으면 → 바꿀 수 없습니다. 카드가 이동 방식을 자기가 정해버렸으니까요.onNavigate를 밖에서 받으면 → 리스트 페이지는 navigate(상세)를, 검색 페이지는 모달 열기를 넘기면 됩니다. 같은 카드, 다른 동작.엘리베이터 버튼은 "눌렸다"는 신호만 보냅니다. 몇 층으로 갈지는 버튼이 정하지 않죠. 만약 버튼이 목적지 층까지 정해버리면, 그 버튼은 다른 건물에 갖다 쓸 수 없습니다.
카드 = 버튼, 이동 결정 = 엘리베이터 시스템(부모)
사실 지금 프로젝트에선 카드가 항상 라우터 안에서만 쓰이고 테스트도 없어서, 당장 얻는 실익이 크지는 않을 수 있습니다.
지금은 원칙을 이해하는 게 목적이고, 나중에 테스트를 작성하거나 컴포넌트를 재사용하거나 Storybook으로 문서화할 때 이 차이를 체감하게 될 거라고 봅니다.
핵심 원칙은 이렇습니다. prop 이름은 "무슨 일이 일어났나(이벤트)"가 아니라 "무엇을 원하나(의도)"로 짓는다.
| 이름 | 성격 | 의미 |
|---|---|---|
onClick | 저수준 (이벤트) | "클릭이라는 물리적 사건이 발생함" — 그 클릭이 뭘 의미하는지는 안 담김 |
onNavigate | 고수준 (의도) | "이동하고 싶다" — 이름만 봐도 의도가 드러남 |
부모 입장에서 비교하면 차이가 분명합니다.
// onClick — 이게 무슨 클릭인지 매번 확인해야 함
<ProductCard onClick={...} />
// onNavigate — 이름만 봐도 의도가 명확
<ProductCard onNavigate={(id) => navigate(...)} />
특히 카드 안에 클릭 지점이 여러 개일 때 이 차이가 커집니다. 카드에는 카드 전체(상세 이동), 담기 버튼, 수량 +/- 버튼처럼 클릭할 곳이 여럿 있습니다. 이걸 전부 onClick으로 받으면 어떤 클릭인지 구분할 수 없습니다. 그래서 의도별로 이름을 붙여야 합니다.
onNavigate, onAddToCart, onChangeQuantity ...
prop 이름이 곧 문서 역할을 합니다.
"함수는 웬만하면 분리하라"는 말을 많이 듣지만, 진짜 기준은 길이 · 재사용 · 명확성 세 가지입니다.
| 분리하는 게 좋을 때 | 인라인이 나을 때 |
|---|---|
| 로직이 여러 줄일 때 | 한 줄짜리일 때 |
| 여러 곳에서 재사용할 때 | 한 곳에서만 쓸 때 |
| 이름을 붙이면 의미가 명확해질 때 | 이름 없이도 뭘 하는지 뻔할 때 |
<ProductCard onNavigate={(id) => navigate(toProductDetail(id))} />
이럴 땐 인라인이 오히려 읽기 편합니다. 억지로 빼면 이렇게 됩니다.
const handleNavigate = (id: string) => navigate(toProductDetail(id));
// ...한참 아래...
<ProductCard onNavigate={handleNavigate} />
선언부와 사용부가 멀어져서 코드를 읽을 때 눈이 위아래로 왔다 갔다 하게 됩니다. 오히려 불편하죠.
"무조건 분리"가 아니라 "분리가 가독성이나 재사용에 도움이 될 때 분리"입니다. 한 줄이고, 한 곳에서만 쓰고, 뭘 하는지 자명한 함수라면 인라인이 원칙에 더 맞습니다.
onClick)이 아니라 의도명(onNavigate)으로. → 이름이 곧 문서가 된다.한 줄짜리 리뷰 코멘트였지만, 그 뒤에는 이만큼의 설계 원칙이 있었습니다. 앞으로 컴포넌트를 만들 때 "이 컴포넌트가 원래 알아야 하는 일인가?"를 먼저 물어보게 될 것 같습니다.