상품 상세 같은 화면에는 할인·쿠폰·마일리지·결제수단·제휴 혜택이 한꺼번에 얽힌다. UI는 아코디언 하나로 묶여 보이지만, 뒤에서는 요청 파라미터 조합·로그인·경로·로컬 저장값이 모두 한 줄에 섞이기 쉽다. 나는 그걸 한 커스텀 훅에 넣다가, 훅이 무거워질수록 생기는 일을 다시 보게 됐다.

이 글은 특정 라이브러리 판단이 아니라, 이런 정리를 할 때 쓰는 말들—훅의 경량함, 서버 상태 캐시의 키, props 전달, 컴포넌트 경계—을 한 번에 묶어서 적어 본다. 코드베이스 이름은 가명(useMyDiscount, MyDiscountAccordionContainer)으로 바꿨다.


1. 훅이 무거우면 읽기 비용이 기하급수로 든다

커스텀 훅은 “재사용 가능한 상태와 부수 효과의 경계”다. 그런데 한 훅이 useState·useEffect·데이터 패칭·환경 분기까지 모두 먹으면, 호출하는 쪽은 렌더마다 어떤 순서로 무엇이 바뀌는지를 머릿속으로 시뮬레이션해야 한다.

특히 useEffect는 “언제 한 번 더 도나?”를 추적하기 어렵다. React 문서도 Effect를 외부 시스템과 동기화할 때 쓰라고 반복해서 말하고, 불필요한 Effect로 상태를 맞추는 패턴을 경계한다.

요약하면, 훅이 두꺼워질수록 의도와 디버깅 비용이 함께 커진다.


2. 같은 상품인데 쿼리 키가 갈라질 수 있다

클라이언트에서 서버 상태를 캐시할 때(예: TanStack Query), 캐시의 단위는 보통 queryKey다. 키가 조금이라도 다르면 다른 캐시 엔트리로 본다.

문제는 이렇게 생긴다. 훅 안에 호출 위치마다 달라질 수 있는 로컬 상태가 끼어 있으면, 같은 상품 ID라도 요청 파라미터 조합이 달라져 키가 갈라질 수 있다. 그건 의도된 분리일 수도 있지만, 의도 없이 갈라지면 중복 요청화면 간 불일치로 이어진다.

나는 여기서 “요청에 들어가는 값”을 한 훅 호출에 묶지 말고, 단일 출처에서 맞추자는 쪽으로 방향을 잡았다. (아래 4절)


3. props drilling은 ‘죄’가 아니라 ‘비용’이다

React는 기본적으로 부모가 자식에게 props로 데이터를 넘긴다. 그런데 많은 props를 많은 레이어로 통과시키는 건 번거롭고, 그때 Context 등을 고려하라고 안내한다.

즉 “props drilling = 항상 나쁨”이 아니다. 얕고 계약이 명확하면 props가 제일 단순하다. 문제는 부모가 조합용 컨테이너인데 사실상 컨트롤러가 되어, 자식 행마다 필요한 값을 전부 계산해 내려보내는 형태다. 중간 컴포넌트는 자기 역할과 무관한 타입·필드를 알게 되고, 요구사항이 바뀔 때 변경 반경이 커진다.

나의 기준은 이렇다.

  • 부모는 레이아웃·게이트(로딩·실패·최소 조건) 에 가깝게 두고,
  • 행 단위 UI는 필요한 데이터 소스에 가깝게 둔다.

합성은 React가 오래전부터 말해 온 방향과 맞물린다. (예: Thinking in React의 흐름)


4. 이번에 한 일: useMyDiscountMyDiscountAccordionContainer

과거에는 대략 이런 모습이었다.

  • useMyDiscount 안에 로컬 상태useEffect가 있고, 그 위에 데이터 패칭이 얹혀 있다.
  • 여러 위치에서 훅을 동시에 호출하면, 요청 기준이 어긋나 캐시 키가 호출마다 달라질 여지가 있었다.
  • MyDiscountAccordionContainer가 훅을 호출해 결과를 만든 뒤, 자식 행 컴포넌트들에 길게 전달하는 느낌이었다. 컨테이너가 “아코디언 껍데기”가 아니라 데이터 허브에 가까워졌다.

이번에 정리한 방향은 세 가지다.

  1. 요청 파라미터(requestParams에 해당하는 값)의 단일 출처를 둔다. (예: 모듈 스코프의 작은 전역 스토어로 여러 소비자가 같은 값을 본다.)
  2. 그래서 여러 컴포넌트가 같은 훅을 호출해도 같은 요청 기준을 공유하고, 패칭 레이어가 중복 호출을 줄이는 쪽으로 간다.
  3. MyDiscountAccordionContainer로딩·실패·최소 조건 같은 게이트에 집중하고, 세부 행은 각자 필요한 만큼 구독하게 두어 부모가 모든 걸 내려주지 않게 했다.

훅 안의 useEffect는 사라지지 않을 수 있다. 다만 “무엇을 외부와 동기화하는지”가 읽히게 줄이는 게 목표다.


5. 컴포넌트 경계: 연결할수록 흐려진다

각 컴포넌트에는 역할이 있다. 할인 한 줄, 쿠폰 한 줄, 마일리지 한 줄은 표시와 입력에 가깝고, “서버에 무엇을 요청할지”는 한곳에서 일관되게 잡는 편이 안전하다.

부모가 모든 걸 계산해 자식에 나눠주면, 자식은 프레젠테이션이 아니라 props 어댑터가 되기 쉽다. 팀이 커질수록 경계가 흐린 코드는 리뷰·테스트·책임 추적 비용이 커진다.

코드 스케치 (의미만 옮긴 가짜 코드)

Before — 컨테이너가 훅으로 다 꺼내 자식에게 넘김

MyDiscountAccordionContaineruseMyDiscount에서 나온 값을 한꺼번에 받아, 자식 행마다 props로 내려보내는 그림이다. 부모는 “아코디언 껍데기”라기보다 데이터 허브에 가깝다.

const MyDiscountAccordionContainer = ({ itemId }: { itemId: number }) => {
  const { data, flags, handlers, request } = useMyDiscount(itemId);

  return (
    <Accordion>
      <Summary flags={flags} />
      <ProductRow data={data.product} onChange={handlers.product} />
      <CouponRow data={data.coupon} request={request} />
      <MileageRow data={data.mileage} handlers={handlers} />
      {/* …부모가 알아야 할 타입이 늘어난다 */}
    </Accordion>
  );
};

After — 컨테이너는 게이트만, 행은 각자 구독

같은 훅(또는 공유 스토어)을 두되, 요청 기준은 단일 출처로 맞추고 컨테이너는 로딩·실패·최소 조건만 본다. 각 Row는 긴 props 없이 자기 줄에 필요한 만큼만 이어 받는다.

const MyDiscountAccordionContainer = ({ itemId }: { itemId: number }) => {
  const { isLoading, isError, hasRequest } = useMyDiscount(itemId);

  if (isLoading || !hasRequest) return <Skeleton />;
  if (isError) return null;

  return (
    <Accordion>
      <Summary />
      <ProductRow />
      <CouponRow />
      <MileageRow />
    </Accordion>
  );
};

위는 실제 구현과 1:1이 아니라, “부모가 꿰어 주던 줄을 끊고 각자 매달게 했다” 는 뉘앙스만 보여 주려는 스케치다.

마침내 부모와 자식은 자유를 얻었다

예전 그림에서는 부모가 useMyDiscount로 큰 덩어리를 들고 와서 자식마다 잘라 나눠 줬다. 부모는 “나는 레이아웃만”이 아니라 모든 행의 props 시그니처를 아는 사람이 됐고, 자식은 “나는 이 줄만”이 아니라 부모가 정해 준 조각을 받는 쪽에 가까워졌다.

지금은 부모가 언제 보여 줄지(로딩·실패·게이트) 만 신경 쓰고, 자식은 자기 줄의 표시와 입력에 더 가깝게 남는다. 비유하자면, 예전에는 부모가 도시락 칸마다 반찬을 나눠 넣는 사람이었고, 지금은 각 칸이 같은 냉장고를 열어서 필요한 만큼만 꺼낸다. 우스갯소리로 말하면, 마침내 부모와 자식은 자유를 얻었다—부모는 “다 나눠 주지 않아도 되는” 자유, 자식은 “다 받지 않아도 되는” 자유다.


6. 한 줄로

훅을 가볍게 유지하고, 캐시 키의 기준을 흔들지 않으며, 컨테이너는 합성과 게이트에 남긴다.
그게 내가 이번에 다시 확인한 “훅이 무거울 때 생기는 일”의 반대편이다.


참고 링크

주제 URL
Effect 남용과 대안 https://react.dev/learn/you-might-not-need-an-effect
깊은 데이터 전달과 Context https://react.dev/learn/passing-data-deeply-with-context
TanStack Query — Query Keys https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
상태 보존·초기화(필요 시) https://react.dev/learn/preserving-and-resetting-state
UI 구성 사고 (참고) https://react.dev/learn/thinking-in-react