React 컴포넌트 설계 개념 정리

park·2026년 7월 9일

코드 리뷰에서 배운 React 컴포넌트 설계 3가지

ProductCard 컴포넌트를 리뷰받다가 이런 코멘트를 받았습니다.

"카드 안에서 useNavigate를 직접 쓰지 말고, onNavigate prop으로 부모에게 넘기는 게 어떨까요?"

처음엔 "그냥 카드 안에서 이동시키면 되는 거 아닌가?" 싶었는데, 이 안에는 관심사 분리 / props 네이밍 / 함수 분리라는 세 가지 개념이 다 들어 있었습니다. 이 글은 그 세 가지를 정리한 기록입니다.


1. 관심사 분리 (Separation of Concerns)

한 줄로 요약하면 이렇습니다. 컴포넌트는 자기 일에만 집중하고, 다른 관심사는 부모에게 넘긴다.

무엇이 문제였나

원래 ProductCard는 내부에서 useNavigate를 직접 호출하고 있었습니다.

// before — 카드가 "이동"까지 스스로 결정
export default function ProductCard({ id, ... }) {
  const navigate = useNavigate();
  const handleClick = (productId) => navigate(toProductDetail(productId));
  return <div onClick={() => handleClick(id)}>...</div>;
}

카드가 해야 할 일은 상품 정보를 보여주는 것입니다. 그런데 useNavigate가 들어오면서 "라우팅"이라는 전혀 다른 관심사까지 카드가 떠안게 됐습니다.

"react-router 환경에서만 동작한다"는 말의 의미

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으로 문서화할 때 이 차이를 체감하게 될 거라고 봅니다.


2. props 네이밍 — onClick vs onNavigate

핵심 원칙은 이렇습니다. prop 이름은 "무슨 일이 일어났나(이벤트)"가 아니라 "무엇을 원하나(의도)"로 짓는다.

이름성격의미
onClick저수준 (이벤트)"클릭이라는 물리적 사건이 발생함" — 그 클릭이 뭘 의미하는지는 안 담김
onNavigate고수준 (의도)"이동하고 싶다" — 이름만 봐도 의도가 드러남

부모 입장에서 비교하면 차이가 분명합니다.

// onClick — 이게 무슨 클릭인지 매번 확인해야 함
<ProductCard onClick={...} />

// onNavigate — 이름만 봐도 의도가 명확
<ProductCard onNavigate={(id) => navigate(...)} />

특히 카드 안에 클릭 지점이 여러 개일 때 이 차이가 커집니다. 카드에는 카드 전체(상세 이동), 담기 버튼, 수량 +/- 버튼처럼 클릭할 곳이 여럿 있습니다. 이걸 전부 onClick으로 받으면 어떤 클릭인지 구분할 수 없습니다. 그래서 의도별로 이름을 붙여야 합니다.

onNavigate, onAddToCart, onChangeQuantity ...

prop 이름이 곧 문서 역할을 합니다.


3. 함수 분리 — 언제 하고, 언제 안 하나

"함수는 웬만하면 분리하라"는 말을 많이 듣지만, 진짜 기준은 길이 · 재사용 · 명확성 세 가지입니다.

분리하는 게 좋을 때인라인이 나을 때
로직이 여러 줄일 때한 줄짜리일 때
여러 곳에서 재사용할 때한 곳에서만 쓸 때
이름을 붙이면 의미가 명확해질 때이름 없이도 뭘 하는지 뻔할 때

이번 케이스는 인라인이 맞다

<ProductCard onNavigate={(id) => navigate(toProductDetail(id))} />
  • 한 줄 ✅
  • 한 곳에서만 사용 ✅
  • 뭘 하는지 뻔함 ✅

이럴 땐 인라인이 오히려 읽기 편합니다. 억지로 빼면 이렇게 됩니다.

const handleNavigate = (id: string) => navigate(toProductDetail(id));
// ...한참 아래...
<ProductCard onNavigate={handleNavigate} />

선언부와 사용부가 멀어져서 코드를 읽을 때 눈이 위아래로 왔다 갔다 하게 됩니다. 오히려 불편하죠.

정리

"무조건 분리"가 아니라 "분리가 가독성이나 재사용에 도움이 될 때 분리"입니다. 한 줄이고, 한 곳에서만 쓰고, 뭘 하는지 자명한 함수라면 인라인이 원칙에 더 맞습니다.


마무리

  • 관심사 분리: UI 컴포넌트가 라우팅 같은 외부 시스템을 직접 알지 않게 하고, 동작은 부모가 주입한다. → 재사용성과 테스트 용이성 확보.
  • props 네이밍: 이벤트명(onClick)이 아니라 의도명(onNavigate)으로. → 이름이 곧 문서가 된다.
  • 함수 분리: 길이 · 재사용 · 명확성이 기준. 한 줄 · 단일 사용 · 자명한 함수는 인라인.

한 줄짜리 리뷰 코멘트였지만, 그 뒤에는 이만큼의 설계 원칙이 있었습니다. 앞으로 컴포넌트를 만들 때 "이 컴포넌트가 원래 알아야 하는 일인가?"를 먼저 물어보게 될 것 같습니다.

profile
park

0개의 댓글