지난 글에서 제값을 하지 못하는 얇은 커스텀 훅은 만들지 말자고 했다. 그런데 레거시 코드를 한참 돌아다녀 보니 얇은 훅은 혼자 오지 않았다. 쌓이고, 엮이고, 서로를 감쌌다. 이번에는 그렇게 마주친 유형을 사례로 정리한다. 코드는 모두 익명화하고 구조만 남겼다.

TL;DR

  • 얇은 훅 하나보다 그것들이 탑을 쌓고 서로를 감쌀 때 생기는 탐색 비용이 더 큰 문제다.
  • 훅을 만들었다고 추상화가 완성되는 것은 아니다. 재사용도, 이름이 주는 의미도, 감출 복잡성도 없다면 간접 참조 한 겹만 늘어난다.
  • 재사용, 도메인 의미를 드러내는 이름, 공통 정책, 라이프사이클 보장처럼 명확한 계약이 있다면 얇아도 정당한 훅이 될 수 있다.
  • 판단 기준은 훅의 줄 수가 아니라 이 계층을 없앴을 때 어떤 계약이 사라지는가라는 질문이다.

사례 1. 매개변수만 채워주는 래퍼

가장 흔한 형태다. 원본 훅에 특정 매개변수를 채워 호출하는 몇 줄짜리 래퍼다.

export const useScopedThingQuery = () => {
  const params = useScopedThingParams();
  return useThingQuery(params);
};

이 형태가 항상 나쁜 것은 아니다. 특정 맥락의 쿼리에 이름을 붙이는 정당한 이유가 있을 수 있다. 호출부가 여러 곳이거나, 매개변수 조립이 복잡하거나, 공통 정책을 한 곳에 모으고 싶을 때가 그렇다.

다음 조건이 모두 맞으면 의심해 볼 만하다.

  • 호출부가 하나뿐이다.
  • 매개변수 조립이 한 줄이다.
  • 원본 훅의 반환값을 가공하지 않고 그대로 통과시킨다.
  • 별도의 오류 처리, 캐시 정책, 권한 검사 같은 공통 계약도 없다.

이 경우 이름을 하나 얻는 대신 파일 하나와 정의를 확인하러 이동하는 비용을 낸다. 그 이름이 호출부의 의미를 실제로 더 선명하게 만들지 못한다면, 그냥 호출부에서 useThingQuery(params)를 사용하는 편이 낫다.

판단: 래퍼가 이름, 재사용, 매개변수 조립, 공통 정책 중 하나라도 담당하는가? 아무 계약도 없다면 제거 후보로 볼 수 있다.

사례 2. 훅이 훅을 부르는 탑

컴포넌트에서는 훅 하나만 호출하지만 내부를 따라가면 이런 구조가 나오기도 한다.

<Section>
  -> useThingApply()          // 얇음
    -> useScopedThingQuery()  // 얇음
      -> useThingQuery()      // 실제 작업이 있는 곳

각 계층이 얇아서 어디에서 실제 동작이 추가되는지 알려면 컴포넌트와 여러 훅 파일을 모두 열어야 한다. 모두 열어 보면 매개변수를 조금 바꿔 아래로 넘기고 결과를 그대로 올리는 것이 전부인 경우도 있다.

얇은 훅 하나는 코드 이동 한 번이지만, 탑은 그 비용이 누적된다. 읽는 사람은 매 계층에서 “여기서는 어떤 계약을 더하지?”를 확인해야 한다. 답이 계속 “없음”이더라도 확인하기 전에는 함부로 지울 수 없다.

이것이 얇은 추상화의 실제 비용이다. 코드를 읽는 데 시간이 들 뿐 아니라 삭제할 때도 모든 계층의 호출 관계를 다시 검증해야 한다.

판단: 체인을 끝까지 펼쳐 본다. 각 계층이 추가하는 것이 매개변수 전달과 반환값 전달뿐이라면 계층을 합칠 수 있는지 검토한다.

사례 3. 재사용되지 않지만 추출된 훅

한 컴포넌트에서만 사용하는 로직을 컴포넌트가 지저분해 보인다는 이유로 훅에 옮긴 경우다.

// 이 훅의 호출부는 하나뿐이다.
export const useOnlyThisComponentNeedsIt = () => {
  // ...
};

여기서도 재사용되지 않는 훅은 무조건 나쁘다고 결론 내리면 안 된다. 로직과 데이터 조합을 프레젠테이션 컴포넌트에서 분리하면 컴포넌트가 마크업에 집중할 수 있고, 복잡한 오케스트레이션에 이름을 붙일 수도 있다.

React 공식 문서도 작은 중복을 모두 커스텀 훅으로 추출할 필요는 없으며, 훅이 구체적인 사용 목적을 드러내야 한다고 설명한다. 핵심은 코드가 짧은지가 아니라 의도가 분명한지다.

다만 훅으로 옮겼다고 자동으로 테스트하기 쉬워지는 것은 아니다. 커스텀 훅은 React 실행 환경과 렌더러가 필요할 수 있다. 계산 규칙을 검증하려는 목적이라면 순수 함수로 분리하는 편이 더 직접적이다. 훅은 그 순수 함수와 다른 훅들을 조합하는 역할을 맡을 수 있다.

판단할 때는 다음을 확인한다.

  • 훅이 여러 상태와 부수 효과를 조율해 컴포넌트에서 감출 만한 복잡성을 갖고 있는가?
  • 테스트할 계산 규칙을 순수 함수로 분리했는가?
  • 훅을 인라인하면 컴포넌트의 핵심 마크업을 읽기 어려워지는가?
  • 재사용 가능성이 아니라 실제 재사용 요구가 있는가?

아무 조건에도 해당하지 않는다면 훅은 컴포넌트를 정리한 것이 아니라 로직을 옆 파일로 옮겨 숨긴 것일 수 있다. 컴포넌트의 줄 수는 줄지만 전체 이해 비용은 그대로이거나 늘어난다.

판단: 분리의 이유가 복잡성 격리, 공통 계약, 실제 재사용인가? 단순히 컴포넌트의 줄 수를 줄이기 위한 것이라면 다시 생각한다.

사례 4. 서버 상태를 클라이언트 상태로 감싸고 다시 훅으로 감싸기

이 사례는 훅보다 상태 소유권에 가까운 문제지만, 그 위에 훅이 추가되면서 구조가 더 깊어진다.

서버 상태를 캐싱하는 쿼리 라이브러리가 있는데도 클라이언트 스토어가 그 파생 데이터 전체를 다시 보관하는 경우가 있다. 사용자가 무엇을 선택했는지는 클라이언트 상태가 맞다. 하지만 선택된 대상의 서버 데이터 사본 전체를 스토어에 복제하면 같은 데이터의 출처가 둘이 된다.

그 위에 스토어의 데이터를 꺼내 다시 가공하는 훅이 생기면 계층이 하나 더 쌓인다.

// 스토어가 서버 객체의 사본을 보관하고, 훅이 다시 꺼내 가공한다.
const useSelectedThingOptions = () => {
  const item = useStore((state) => state.selectedItem);
  return useMemo(() => (item ? transform(item) : []), [item]);
};

대개 더 안정적인 형태는 클라이언트에는 선택한 ID를 두고, 서버 데이터는 그 ID로 쿼리하는 것이다.

const selectedItemId = useStore((state) => state.selectedItemId);
const { data: item } = useThingQuery(selectedItemId);
const derived = useMemo(() => (item ? transform(item) : []), [item]);

물론 서버 객체의 사본을 클라이언트에 두는 것이 항상 잘못은 아니다. 편집 중인 초안, 특정 시점의 스냅샷, 낙관적 업데이트처럼 별도의 수명과 동기화 정책이 필요하다면 의도적인 클라이언트 상태가 될 수 있다. 중요한 것은 어느 쪽이 원본이고 언제 동기화하는지가 명시돼 있는지다.

TanStack Query 공식 문서는 서버 상태 도구와 클라이언트 상태 도구의 역할을 구분하면서도 둘이 함께 쓰일 수 있다고 설명한다. 따라서 상태 관리 도구의 공존 자체보다 각 데이터의 소유권이 명확한지를 봐야 한다.

이 구조를 걷어내려면 사용 지점이 많을수록 변경 범위가 커진다. 얇은 훅 하나를 지우듯 가볍게 처리할 수 없다. 따라서 다른 작업을 하다가 발견했더라도 함께 고치기보다 별도 작업으로 범위와 검증 경로를 잡는 편이 안전하다.

이런 구조적 부채를 발견했지만 현재 변경에 섞지 않은 판단은 전환용 피처 플래그를 지우기 쉽게 설계한 글에서도 다뤘다.

얇은 래퍼 훅의 런타임 비용도 문자 그대로 0은 아니다. 다만 성능 영향을 판단할 때는 함수 호출뿐 아니라 상태 구독과 렌더링 범위를 함께 봐야 한다. 성능 문제로 제거하려면 먼저 측정해야 하고, 측정 근거가 없다면 우선 읽기 비용과 상태 소유권 문제로 다루는 편이 정확하다.

판단: 스토어에 있는 것이 사용자 선택 같은 클라이언트 상태인가, 서버 데이터의 사본인가? 사본이라면 원본과 동기화 정책을 확인하고, 구조 변경은 별도 작업으로 다룬다.

공통 판단 기준

새로운 계층을 마주칠 때마다 다음 질문을 던질 수 있다.

이 계층을 없애면 어떤 계약이 사라지는가?

  • 도메인 의미를 드러내는 이름, 실제 재사용, 공통 정책, 라이프사이클 보장, 테스트 가능한 경계가 사라진다면 정당한 계층일 수 있다.
  • 별다른 계약은 사라지지 않고 정의로 이동하는 과정만 줄어든다면, 그 계층은 비용만 추가했을 가능성이 크다.

얇은 훅을 옹호할 때 자주 나오는 말은 “나중에 재사용할 수도 있다”이다. 하지만 지난 글에서 말했듯 있을 것 같다는 예상만으로는 충분하지 않다. 실제 요구가 생겼을 때 추출해도 늦지 않다.

반대로 호출부가 하나라는 이유만으로 무조건 인라인해서도 안 된다. 하나뿐인 호출부라도 결제, 인증, 분석 이벤트처럼 중요한 정책 경계를 이름으로 드러내는 훅은 가치가 있다. 개수가 아니라 계약으로 판단해야 한다.

체크리스트

훅을 만들거나 기존 훅을 검토할 때 확인한다.

  • 이 훅의 책임을 한 문장으로 설명할 수 있는가?
  • 호출부가 하나라면 도메인 의미를 드러내는 이름이나 감출 복잡성이 충분한가?
  • 반환값을 가공 없이 통과시키기만 하는가?
  • 체인을 끝까지 펼쳤을 때 각 계층이 어떤 계약을 추가하는가?
  • 분리 이유가 복잡성, 공통 정책, 실제 재사용인가? 아니면 줄 수를 줄이기 위한 것인가?
  • 테스트하려는 로직은 훅보다 순수 함수로 분리할 수 있는가?
  • 스토어에 있는 값은 클라이언트 상태인가, 서버 상태의 사본인가?
  • 서버 상태의 사본이라면 원본과 동기화 시점이 명확한가?

정리

지난 글이 훅 하나를 만들지 말지에 관한 이야기였다면, 이번 글은 이미 쌓인 훅을 어떻게 읽고 판단할지에 관한 이야기다.

  1. 얇은 훅이 이어져 있다면 체인을 펼쳐 보고 전달만 하는 계층은 접는다.
  2. 재사용되지 않는 훅도 정당할 수 있다. 호출부 개수보다 분리하면서 생기는 계약을 확인한다.
  3. 서버 상태를 클라이언트 상태로 다시 감싼 구조는 소유권을 의심하되, 큰 변경은 별도 작업으로 분리한다.
  4. 모든 계층에 같은 질문을 던진다. 이 계층을 없애면 어떤 계약이 사라지는가?

추상화를 아끼는 것은 지난 글에서 말한 대로 let it go에 가깝다. 이미 쌓인 구조 앞에서는 무작정 걷어내지도, 그대로 방치하지도 않는다. 펼쳐 보고, 접을 수 있는 것은 접고, 큰 구조는 근거와 함께 기록한다.