Dev Notes
Web Vitals
Web Vitals
Dev Notes
DocsCustom Hook 설계 원칙
Web Vitals
Web Vitals
GitHub
React143분

Custom Hook 설계 원칙

재사용 가능하고 테스트하기 쉬운 Custom Hook을 설계하는 실전 원칙과 패턴

2025년 3월 24일
reacthookscustom-hooksdesign-patternstypescripttesting

적용 환경: React 18+, TypeScript 5+

들어가며

프로젝트가 성장하면 자연스럽게 커스텀 훅을 만들기 시작합니다. useAuth, useForm, useProducts처럼 컴포넌트에서 반복되는 로직을 훅으로 추출하는 것은 React가 권장하는 코드 재사용 방식이기도 합니다. 그런데 시간이 지나면 hooks/ 폴더에 30개 이상의 훅이 쌓이고, 그 중 절반은 단 하나의 컴포넌트에서만 사용되며, 나머지 절반은 수정할 때마다 여러 화면이 동시에 깨지는 상황에 처하게 됩니다.

원인은 대부분 비슷합니다. useProductPage라는 이름의 훅이 상품 조회, 장바구니 추가, 리뷰 제출, 탭 상태까지 모두 관리하고 있고, 테스트를 작성하려면 API 5개를 모킹해야 하며, 새 팀원이 이 훅의 역할을 이해하려면 300줄을 모두 읽어야 합니다. 이런 훅은 로직을 "재사용"한 것이 아니라 단지 컴포넌트 밖으로 "이동"시킨 것에 불과합니다.

커스텀 훅은 함수입니다. 따라서 좋은 함수를 설계하는 원칙이 그대로 적용됩니다. 하나의 책임만 가지고, 인터페이스가 명확하며, 외부 의존성을 주입받고, 작은 훅을 조합하여 큰 훅을 만드는 것이 핵심입니다. 이 문서에서는 실무에서 검증된 커스텀 훅 설계 원칙을 다룹니다.

추출할 때와 하지 말아야 할 때

커스텀 훅의 가장 흔한 실수는 너무 이른 시점에, 그리고 추상화의 가치를 따지지 않고 추출하는 것입니다. useState 하나를 감싸는 useModalOpen을 만들고, useEffect(fn, []) 한 줄을 감싸는 useMountEffect를 만들며, 결국 프로젝트에 "원본보다 이해하기 어려운 추상화"가 쌓이게 됩니다.

TSX
// ❌ useState를 감싸기만 한 추상화 — 열어보면 useState 한 줄이 전부
function useModalOpen() {
  const [isOpen, setIsOpen] = useState(false);
  return [isOpen, setIsOpen] as const;
}
 
function ProductPage() {
  const [isModalOpen, setIsModalOpen] = useModalOpen();
  return <button onClick={() => setIsModalOpen(true)}>상세 보기</button>;
}

useModalOpen은 useState(false)에 이름만 바꿔놓은 것에 불과합니다. 코드를 읽는 사람은 useModalOpen의 구현을 확인하러 다른 파일로 이동해야 하고, 막상 열어보면 useState 한 줄이 전부입니다. 추상화를 했지만 숨겨진 복잡성이 없으므로, 간접 참조 비용만 추가된 셈입니다.

useEffect를 감싸는 패턴도 마찬가지입니다.

TSX
// ❌ useEffect(fn, [])을 감싸기만 한 추상화
function useMountEffect(effect: () => void) {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(effect, []);
}

useEffect(fn, [])는 React 개발자라면 누구나 즉시 이해하는 관용 표현이고, 빈 의존성 배열 [] 자체가 "마운트 시 한 번만 실행"이라는 의미를 전달합니다. useMountEffect는 이 정보를 숨기면서도 대신 제공하는 것이 없습니다. useEffect의 두 번째 인자를 직접 보는 것이 "이 이펙트는 어떤 값에 반응하는가"를 가장 빠르게 파악하는 방법인데, 래퍼를 거치면 그 정보가 한 단계 뒤로 밀려나게 됩니다.

두 경우 모두, 이 훅이 10곳에서 쓰인다 해도 본질은 변하지 않습니다. 원본 API가 이미 충분히 명확하다면 추상화를 추가하는 것은 코드를 명확하게 만드는 것이 아니라 간접 참조만 늘리는 것입니다.

TSX
// ✅ 이미 충분히 명확한 코드에는 추상화가 불필요
function ProductPage() {
  const [isModalOpen, setIsModalOpen] = useState(false);
 
  useEffect(() => {
    analytics.trackPageView("product");
  }, []);
  // ...
}

반대로, 추출이 정당화되는 상황은 훅이 실질적인 복잡성을 캡슐화할 때입니다. 브라우저 미디어 쿼리를 감지하는 로직을 예로 들면, 이벤트 리스너의 등록과 해제, 초기값의 동기적 계산, 리스너 콜백에서의 상태 업데이트를 모두 올바르게 처리해야 합니다. 이 중 하나라도 빠뜨리면 메모리 누수나 SSR 불일치로 이어집니다. 이처럼 실수하기 쉬운 로직을 훅으로 감싸면, 소비자는 내부 구현을 신경 쓰지 않고 useMediaQuery("(min-width: 768px)")라는 선언적 인터페이스만으로 원하는 결과를 얻을 수 있습니다.

TSX
// ✅ 여러 컴포넌트에서 사용되고, 생명주기 관리가 필요한 로직
function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );
 
  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, [query]);
 
  return matches;
}

추출 판단 기준

훅으로 추출할지 판단하는 핵심 질문은 "이 추상화가 숨기는 복잡성이 있는가?"입니다. 이벤트 리스너 생명주기, 경쟁 상태 방지, 비동기 흐름 제어처럼 직접 작성하면 실수하기 쉬운 로직이라면 추출 가치가 있습니다. 반면 useState나 useRef 한 줄을 감싸는 수준이라면, 사용 빈도와 관계없이 원본이 더 명확합니다.

단일 책임 원칙

함수가 하나의 일만 해야 하듯, 커스텀 훅도 하나의 관심사만 담당해야 합니다. 훅의 이름에 "그리고"가 들어갈 수 있다면, 그 훅은 분리가 필요하다는 신호입니다. useProductAndCart는 상품 조회와 장바구니라는 서로 다른 두 가지 관심사를 하나의 훅에 묶고 있기 때문입니다.

실무에서 자주 보이는 안티패턴은 특정 페이지의 모든 로직을 하나의 훅에 담는 것입니다.

TSX
// ❌ 하나의 훅이 5가지 이상의 관심사를 관리
function useProductPage(productId: string) {
  const [product, setProduct] = useState<Product | null>(null);
  const [reviews, setReviews] = useState<Review[]>([]);
  const [cartItems, setCartItems] = useState<CartItem[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [activeTab, setActiveTab] = useState("info");
 
  useEffect(() => {
    Promise.all([
      fetchProduct(productId),
      fetchReviews(productId),
      fetchCart(),
    ]).then(([product, reviews, cart]) => {
      setProduct(product);
      setReviews(reviews);
      setCartItems(cart);
      setIsLoading(false);
    });
  }, [productId]);
 
  const addToCart = async (quantity: number) => {
    /* 장바구니 추가 로직 */
  };
  const submitReview = async (content: string) => {
    /* 리뷰 제출 로직 */
  };
 
  return {
    product, reviews, cartItems, isLoading,
    activeTab, setActiveTab, addToCart, submitReview,
  };
}

분리된 훅은 각각 독립적으로 테스트할 수 있고, 상품 조회 로직을 수정할 때 리뷰나 장바구니 코드를 신경 쓸 필요가 없습니다. 또한 useCart는 상품 상세 페이지뿐만 아니라 장바구니 페이지, 결제 페이지 등 다른 곳에서도 재사용할 수 있습니다.

단일 책임의 실전 기준

훅을 수정해야 하는 "이유"가 하나인지 자문합니다. 상품 API의 응답 형식이 바뀌었을 때 장바구니 훅까지 수정해야 한다면, 두 관심사가 잘못 결합되어 있는 것입니다.

반환값 설계

커스텀 훅의 반환값은 곧 그 훅의 API입니다. 내부 구현이 아무리 좋아도 반환값 설계가 어색하면 사용하는 쪽에서 불편을 겪게 됩니다. React에서 관례적으로 사용되는 반환 패턴은 크게 두 가지로, Tuple 패턴과 Object 패턴이 있습니다.

기준Tuple [a, b]Object { a, b }
적합한 경우반환값 1-2개, 위치의 의미가 명확반환값 3개 이상, 부분 사용이 잦음
이름 변경구조 분해 시 자유롭게 변경 가능별칭(: alias)이 필요
대표 사례useState, useReduceruseQuery, useSWR
가독성값이 적을 때 간결이름이 자기 문서화 역할

Tuple은 useState처럼 [상태, 변경 함수] 구조가 직관적인 경우에 적합합니다. 소비자가 자유롭게 이름을 지을 수 있다는 것이 큰 장점입니다.

TSX
// ✅ Tuple: 반환값이 1-2개이고 위치 의미가 명확할 때
function useBoolean(initial = false) {
  const [value, setValue] = useState(initial);
 
  const handlers = useMemo(
    () => ({
      setTrue: () => setValue(true),
      setFalse: () => setValue(false),
      toggle: () => setValue(v => !v),
    }),
    []
  );
 
  return [value, handlers] as const;
}
 
// 소비자가 의미에 맞게 이름을 자유롭게 변경
const [isOpen, { toggle: toggleMenu }] = useBoolean();
const [isVisible, { setFalse: hide }] = useBoolean(true);

한 가지 주의해야 할 점은 반환 객체의 참조 안정성입니다. 매 렌더링마다 새로운 객체를 반환하면, 이 훅의 반환값을 useEffect의 의존성 배열이나 자식 컴포넌트의 props로 전달할 때 불필요한 재실행이나 리렌더링이 발생할 수 있습니다.

TSX
// ❌ 매 렌더마다 새 객체 참조 생성
function usePagination(totalItems: number, pageSize: number) {
  const [page, setPage] = useState(1);
  const totalPages = Math.ceil(totalItems / pageSize);
 
  // 이 객체는 매 렌더마다 새로 생성됨
  return {
    page,
    setPage,
    totalPages,
    hasNext: page < totalPages,
    hasPrev: page > 1,
  };
}
 
// ✅ useMemo로 참조 안정성 확보
function usePagination(totalItems: number, pageSize: number) {
  const [page, setPage] = useState(1);
  const totalPages = Math.ceil(totalItems / pageSize);
 
  return useMemo(
    () => ({
      page,
      setPage,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1,
    }),
    [page, totalPages]
  );
}

반환값 참조 안정성

반환 객체가 useEffect의 의존성이나 memo된 컴포넌트의 props로 전달될 가능성이 있다면, useMemo로 참조를 안정화하는 것을 고려합니다. 다만 모든 훅에 일괄 적용하기보다는 실제 성능 문제가 관찰될 때 적용하는 편이 합리적입니다.

의존성 주입과 역전

커스텀 훅 내부에 fetch('/api/users') 같은 하드코딩된 의존성이 있으면, 그 훅은 특정 API 엔드포인트, 특정 HTTP 클라이언트, 특정 환경에 묶이게 됩니다. 테스트할 때 글로벌 fetch를 모킹해야 하고, 다른 엔드포인트에 같은 로직을 적용하려면 훅 전체를 복사해야 합니다.

TSX
// ❌ 특정 엔드포인트와 fetch에 강하게 결합
function useUsers() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setIsLoading(false);
      });
  }, []);
 
  return { users, isLoading };
}

의존성 주입의 가치는 재사용성과 테스트 용이성 두 가지 측면에서 드러납니다. useResource는 어떤 엔드포인트든, 어떤 HTTP 클라이언트든 조합할 수 있으며, 테스트 시에는 실제 네트워크 요청 없이 가짜 fetcher를 주입하면 됩니다.

이 원칙은 저장소(storage) 계층에도 동일하게 적용됩니다. localStorage를 직접 참조하는 대신 스토리지 인터페이스를 정의하고 주입받으면, 서버 사이드 렌더링 환경에서는 메모리 기반 구현을, 테스트에서는 목(mock) 구현을 사용할 수 있습니다.

TSX
// ✅ 저장소 어댑터를 주입받아 환경에 유연하게 대응
interface StorageAdapter {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}
 
function usePersistedState<T>(
  key: string,
  initialValue: T,
  storage: StorageAdapter = localStorage
) {
  const [value, setValue] = useState<T>(() => {
    const stored = storage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : initialValue;
  });
 
  useEffect(() => {
    storage.setItem(key, JSON.stringify(value));
  }, [key, value, storage]);
 
  return [value, setValue] as const;
}

테스트에서의 효과

의존성 주입이 적용된 훅은 글로벌 객체를 모킹하지 않아도 테스트할 수 있습니다. usePersistedState를 테스트할 때 { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }을 주입하면 localStorage와 완전히 분리된 테스트가 가능합니다.

TypeScript 제네릭 활용

제네릭을 활용하면 하나의 훅 구현으로 다양한 데이터 타입을 안전하게 처리할 수 있습니다. 앞서 살펴본 useResource<T>가 대표적인 예시로, T라는 타입 매개변수 덕분에 useResource<User[]>, useResource<Product> 등 어떤 타입이든 동일한 데이터 패칭 로직을 재사용할 수 있었습니다.

제네릭에 제약을 추가하면 더 안전한 API를 설계할 수 있습니다. 아래 useSelection 훅은 T extends { id: string } 제약을 통해, id 속성이 없는 타입을 실수로 전달하는 것을 컴파일 타임에 방지합니다.

TSX
// ✅ 제약 제네릭으로 안전한 인터페이스 보장
function useSelection<T extends { id: string }>(items: T[]) {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
 
  const toggle = useCallback((item: T) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(item.id)) {
        next.delete(item.id);
      } else {
        next.add(item.id);
      }
      return next;
    });
  }, []);
 
  const selectedItems = useMemo(
    () => items.filter(item => selectedIds.has(item.id)),
    [items, selectedIds]
  );
 
  return {
    selectedIds,
    selectedItems,
    toggle,
    selectAll: () => setSelectedIds(new Set(items.map(i => i.id))),
    clear: () => setSelectedIds(new Set()),
  };
}

이 훅은 User, Product, Order 등 id 속성을 가진 어떤 타입이든 사용할 수 있으면서도, id가 없는 타입을 전달하면 즉시 컴파일 에러를 발생시킵니다.

TSX
// 사용 예시
const { selectedItems, toggle } = useSelection(products);
// products: Product[] 타입에서 T가 Product로 추론됨
 
// ❌ id 속성이 없는 타입은 컴파일 에러
const { toggle } = useSelection([{ name: "test" }]);
// Error: { name: string }은 { id: string }에 할당할 수 없음

Tuple 반환 시 as const

Tuple을 반환하는 훅에서 as const를 빠뜨리면 TypeScript가 반환 타입을 (string | function)[] 같은 유니온 배열로 추론합니다. return [value, setter] as const를 사용하면 readonly [T, Setter]로 정확한 타입이 추론됩니다.

에러 처리 패턴

커스텀 훅에서 발생하는 에러를 어떻게 다루느냐에 따라 사용자 경험과 디버깅 난이도가 크게 달라집니다. 가장 위험한 패턴은 에러를 무시하는 것으로, catch 블록을 비워두면 네트워크 장애나 서버 오류가 조용히 묻혀 사용자에게 빈 화면만 보이게 됩니다.

TSX
// ❌ 에러를 무시하는 패턴 — 사용자에게 빈 화면, 개발자에게 단서 없음
useEffect(() => {
  fetchData(url)
    .then(setData)
    .catch(() => {}); // 에러가 어디론가 사라짐
}, [url]);

훅 내부에서 에러를 상태로 관리하고 소비자에게 노출하면, 컴포넌트가 에러 상황에 맞는 UI를 렌더링할 수 있습니다. 여기에 retry 함수를 함께 제공하면 사용자가 직접 재시도할 수 있는 경로도 열립니다.

TSX
// ✅ 에러 상태를 명시적으로 관리하고 복구 수단을 제공
interface UseQueryReturn<T> {
  data: T | undefined;
  error: Error | null;
  isLoading: boolean;
  isError: boolean;
  retry: () => void;
}
 
function useQuery<T>(
  queryFn: () => Promise<T>
): UseQueryReturn<T> {
  const [data, setData] = useState<T | undefined>();
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);
 
  const execute = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const result = await queryFn();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    } finally {
      setIsLoading(false);
    }
  }, [queryFn]);
 
  useEffect(() => {
    execute();
  }, [execute]);
 
  return {
    data,
    error,
    isLoading,
    isError: error !== null,
    retry: execute,
  };
}

이 패턴의 핵심은 에러를 훅 내부에서 "처리"하지 않는다는 것입니다. 에러 상태를 반환하여 어떤 UI를 보여줄지는 컴포넌트가 결정하도록 합니다. 에러 메시지를 훅 안에서 alert으로 띄우거나, 자동 리다이렉트를 수행하는 것은 훅의 책임이 아닙니다.

에러를 삼키지 말 것

catch(() => {}), catch(console.log) 패턴은 프로덕션에서 디버깅을 극도로 어렵게 만듭니다. 에러는 반드시 상태로 관리하여 UI에 반영되거나, Error Boundary까지 전파되어야 합니다.

정리(cleanup)와 생명주기

커스텀 훅이 이벤트 리스너, 타이머, 네트워크 요청 같은 외부 리소스를 사용한다면, 컴포넌트가 언마운트되거나 의존성이 변경될 때 이를 적절히 정리해야 합니다. 정리를 빠뜨리면 메모리 누수, 상태 업데이트 경고, 경쟁 상태(race condition) 같은 버그로 이어집니다.

비동기 데이터 패칭에서 가장 흔한 문제는 경쟁 상태입니다. 사용자가 빠르게 페이지를 전환하면 이전 요청의 응답이 현재 페이지의 상태를 덮어쓸 수 있습니다. AbortController를 사용하면 컴포넌트가 언마운트되거나 의존성이 바뀔 때 진행 중인 요청을 취소하여 이 문제를 방지할 수 있습니다.

TSX
// ❌ 이전 요청의 응답이 현재 상태를 덮어쓸 수 있음
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser); // userId가 바뀌어도 이전 요청의 then이 실행됨
  }, [userId]);
 
  return user;
}

또 다른 흔한 문제는 setInterval과 클로저의 결합에서 발생합니다. 콜백 함수가 처음 렌더링 시점의 값을 캡처하여 이후 상태 변경이 반영되지 않는 stale closure 현상입니다. useRef에 최신 콜백을 저장하는 패턴으로 이를 해결할 수 있습니다.

TSX
// ✅ ref를 활용하여 항상 최신 콜백을 실행
function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);
 
  // 매 렌더마다 최신 콜백으로 갱신
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
 
  useEffect(() => {
    if (delay === null) return;
 
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

useInterval에서 delay를 null로 전달하면 인터벌이 일시 정지됩니다. delay 값이 다시 숫자로 바뀌면 재개되는데, 이처럼 선언적인 인터페이스 설계가 명령적인 setInterval/clearInterval 호출보다 React의 사고 모델과 잘 어울립니다.

cleanup 누락은 메모리 누수의 원인

이벤트 리스너, WebSocket, setInterval, IntersectionObserver 등을 사용하는 훅에서 cleanup 함수를 빠뜨리면 언마운트 후에도 리소스가 해제되지 않습니다. useEffect에서 구독을 시작했다면 반환 함수에서 반드시 해제해야 합니다.

합성과 조합

커스텀 훅의 진정한 강점은 작은 훅을 조합하여 더 복잡한 로직을 만들 수 있다는 데 있습니다. HOC나 Render Props가 래퍼 컴포넌트의 중첩("래퍼 지옥")이라는 구조적 한계를 가졌던 반면, 훅은 단순한 함수 호출로 합성됩니다. 이 차이가 React 팀이 훅을 도입한 핵심 동기 중 하나였습니다.

효과적인 합성을 위해서는 훅을 계층적으로 분류하는 것이 도움됩니다. 가장 아래에는 브라우저 API나 React 기본 훅을 감싼 Primitive 훅이 있고, 그 위에 도메인 로직을 다루는 Domain 훅, 마지막으로 여러 Domain 훅을 조합하는 Feature 훅이 위치합니다.

Feature HooksuseDebouncedSearch, useInfiniteScroll
← 여러 Domain 훅을 조합
Domain HooksuseResource, useForm, useAuth
← 비즈니스 로직 캡슐화
Primitive HooksuseDebounce, useBoolean, useMediaQuery
← 브라우저 API / 기본 상태 추상화

아래 예시는 Primitive 레이어의 useDebounce와 Domain 레이어의 useResource를 조합하여 Feature 레이어의 useDebouncedSearch를 만드는 과정입니다.

TSX
// Primitive: 값의 변화를 지연시키는 범용 훅
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
 
  return debounced;
}
 
// Feature: Primitive + Domain 조합
function useDebouncedSearch(query: string) {
  const debouncedQuery = useDebounce(query, 300);
  const { data, isLoading, error } = useResource<SearchResult[]>({
    url: `/api/search?q=${encodeURIComponent(debouncedQuery)}`,
    enabled: debouncedQuery.length > 0,
  });
 
  return {
    results: data ?? [],
    isLoading,
    error,
    debouncedQuery,
  };
}

useDebouncedSearch를 처음부터 하나의 훅으로 작성하는 것도 가능하지만, 그렇게 하면 디바운스 로직을 다른 곳에서 재사용할 수 없고, 데이터 패칭과 디바운스를 독립적으로 테스트할 수도 없습니다. 계층적 합성은 각 레이어의 훅을 독립적으로 검증한 뒤 조합할 수 있다는 점에서 신뢰성 확보에도 유리합니다.

네이밍 컨벤션

커스텀 훅의 이름은 그 훅이 "어떻게 구현되었는가"가 아니라 "무엇을 하는가"를 나타내야 합니다. useSetStateAndCallApi는 내부 구현을 그대로 노출하는 이름이지만, useUserProfile은 이 훅을 사용하면 사용자 프로필을 다룰 수 있다는 의도를 전달합니다.

안 좋은 이름좋은 이름이유
useDatauseProductCatalog너무 범용적인 이름은 검색도, 이해도 어려움
useSetStateAndFetchuseUserProfile구현이 아닌 목적을 이름에 반영
useHandleClickuseDeleteConfirmation이벤트 핸들러가 아닌 비즈니스 의도를 표현
useHelperusePermissions모호한 이름은 코드 리뷰와 유지보수를 방해

도메인 언어를 적극적으로 활용하는 것이 좋습니다. useCart, useAuth, useCheckout처럼 비즈니스 용어를 사용하면, 코드를 읽는 것만으로 애플리케이션의 기능 구조를 파악할 수 있습니다. 특히 IDE의 자동완성에서 use를 입력했을 때, 목적이 명확한 이름들이 나열되면 원하는 훅을 빠르게 찾을 수 있습니다.

불리언을 반환하는 훅은 useIs~ 또는 useCan~ 접두사를 사용하면 반환 타입이 이름에서 드러납니다. useIsOnline(), useCanEdit(documentId) 같은 이름은 별도의 타입 확인 없이도 boolean을 반환할 것이라는 기대를 줍니다.

실전 예제: useForm 설계

지금까지 다룬 원칙을 종합하여 실무에서 가장 많이 필요한 훅 중 하나인 useForm을 설계합니다. 이 훅은 단일 책임(폼 상태 관리), 의존성 주입(검증 함수), 제네릭(폼 값 타입), 명시적 에러 처리를 모두 적용한 예시입니다.

TSX
interface UseFormOptions<T extends Record<string, unknown>> {
  initialValues: T;
  validate?: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit: (values: T) => Promise<void> | void;
}
 
interface UseFormReturn<T extends Record<string, unknown>> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  isSubmitting: boolean;
  setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
  setFieldTouched: (field: keyof T) => void;
  handleSubmit: (e?: React.FormEvent) => Promise<void>;
  reset: () => void;
}
 
function useForm<T extends Record<string, unknown>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>): UseFormReturn<T> {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const setFieldValue = useCallback(<K extends keyof T>(
    field: K,
    value: T[K]
  ) => {
    setValues(prev => ({ ...prev, [field]: value }));
    setErrors(prev => ({ ...prev, [field]: undefined }));
  }, []);
 
  const setFieldTouched = useCallback((field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }));
  }, []);
 
  const handleSubmit = useCallback(async (e?: React.FormEvent) => {
    e?.preventDefault();
 
    // 모든 필드를 touched 상태로 전환
    const allTouched = Object.keys(values).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {} as Record<keyof T, boolean>
    );
    setTouched(allTouched);
 
    if (validate) {
      const validationErrors = validate(values);
      setErrors(validationErrors);
      const hasErrors = Object.values(validationErrors).some(Boolean);
      if (hasErrors) return;
    }
 
    setIsSubmitting(true);
    try {
      await onSubmit(values);
    } finally {
      setIsSubmitting(false);
    }
  }, [values, validate, onSubmit]);
 
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);
 
  return {
    values, errors, touched, isSubmitting,
    setFieldValue, setFieldTouched, handleSubmit, reset,
  };
}

이 훅을 사용하는 쪽의 코드는 다음과 같습니다. 제네릭 T가 initialValues의 타입에서 자동으로 추론되므로 별도의 타입 인자를 명시할 필요가 없으며, setFieldValue의 두 번째 인자도 해당 필드의 타입으로 제한됩니다.

TSX
function SignUpForm() {
  const form = useForm({
    initialValues: { email: "", password: "", name: "" },
    validate: (values) => ({
      email: !values.email.includes("@")
        ? "올바른 이메일 형식이 아닙니다"
        : undefined,
      password: values.password.length < 8
        ? "8자 이상 입력해야 합니다"
        : undefined,
    }),
    onSubmit: async (values) => {
      await api.signUp(values);
    },
  });
 
  return (
    <form onSubmit={form.handleSubmit}>
      <input
        value={form.values.email}
        onChange={e => form.setFieldValue("email", e.target.value)}
        onBlur={() => form.setFieldTouched("email")}
      />
      {form.touched.email && form.errors.email && (
        <span>{form.errors.email}</span>
      )}
 
      <button type="submit" disabled={form.isSubmitting}>
        {form.isSubmitting ? "제출 중..." : "가입하기"}
      </button>
    </form>
  );
}

적용된 설계 원칙

단일 책임: 폼 상태 관리만 담당하며, API 호출은 onSubmit으로 위임합니다. 의존성 주입: 검증 로직(validate)과 제출 로직(onSubmit)을 외부에서 주입받습니다. 제네릭: T로 폼 필드 타입을 매개변수화하여 다양한 폼에 재사용됩니다. 에러 처리: 유효성 검증 에러를 필드별로 관리하고, 제출 에러는 onSubmit 내부에서 소비자가 직접 처리합니다.

테스트 전략

잘 설계된 커스텀 훅은 자연스럽게 테스트하기 쉬운 구조를 가집니다. 단일 책임 원칙을 따르면 테스트 범위가 좁아지고, 의존성 주입이 적용되면 외부 의존성을 모킹 없이 교체할 수 있기 때문입니다.

@testing-library/react의 renderHook을 사용하면 컴포넌트 없이 훅의 동작을 직접 테스트할 수 있습니다. 아래는 앞서 구현한 useForm의 테스트 예시입니다.

TSX
import { renderHook, act } from "@testing-library/react";
import { useForm } from "./useForm";
 
describe("useForm", () => {
  const defaultOptions = {
    initialValues: { email: "", password: "" },
    validate: (values: { email: string; password: string }) => ({
      email: !values.email ? "필수 항목입니다" : undefined,
      password: values.password.length < 8 ? "8자 이상" : undefined,
    }),
    onSubmit: vi.fn(),
  };
 
  test("초기값이 올바르게 설정된다", () => {
    const { result } = renderHook(() => useForm(defaultOptions));
 
    expect(result.current.values).toEqual({ email: "", password: "" });
    expect(result.current.errors).toEqual({});
    expect(result.current.isSubmitting).toBe(false);
  });
 
  test("setFieldValue로 필드 값을 변경하면 해당 에러가 초기화된다", () => {
    const { result } = renderHook(() => useForm(defaultOptions));
 
    act(() => result.current.setFieldValue("email", "user@test.com"));
 
    expect(result.current.values.email).toBe("user@test.com");
    expect(result.current.errors.email).toBeUndefined();
  });
 
  test("유효성 검증 실패 시 onSubmit이 호출되지 않는다", async () => {
    const { result } = renderHook(() => useForm(defaultOptions));
 
    await act(() => result.current.handleSubmit());
 
    expect(result.current.errors.email).toBe("필수 항목입니다");
    expect(defaultOptions.onSubmit).not.toHaveBeenCalled();
  });
});

이 테스트에서 validate와 onSubmit은 훅 외부에서 주입된 의존성이므로, 실제 API 호출이나 복잡한 검증 로직 없이도 훅의 상태 관리 동작을 검증할 수 있습니다.

커스텀 훅 테스트의 더 깊은 내용

비동기 훅 테스트, waitFor 활용법, Context가 필요한 훅의 wrapper 설정 등은 프론트엔드 테스트 전략 문서에서 자세히 다룹니다.

핵심 정리

  1. 추출 시점 판단: "이 추상화가 숨기는 복잡성이 있는가?"를 핵심 기준으로 삼습니다. 이벤트 리스너 생명주기, 경쟁 상태 방지, 비동기 흐름 제어처럼 직접 작성하면 실수하기 쉬운 로직이 추출 대상이며, useState나 useEffect를 감싸기만 하는 수준이라면 원본이 더 명확합니다.

  2. 단일 책임: 하나의 훅은 하나의 관심사를 담당합니다. 훅 이름에 "그리고"가 들어갈 수 있다면 분리를 검토합니다.

  3. 반환값 설계: 반환값이 1-2개이면 Tuple, 3개 이상이면 Object를 사용합니다. 반환 객체의 참조 안정성이 필요한 경우 useMemo를 활용합니다.

  4. 의존성 주입: fetch, localStorage, API 엔드포인트 같은 외부 의존성을 매개변수로 받으면 재사용성과 테스트 용이성이 동시에 향상됩니다.

  5. 합성 우선: 작은 훅을 조합하여 복잡한 훅을 만드는 계층 구조(Primitive → Domain → Feature)가 유지보수와 테스트에 유리합니다.

참고 자료

  • React 공식 문서 - Reusing Logic with Custom Hooks
  • React 공식 문서 - Rules of Hooks
  • Kent C. Dodds - Inversion of Control
  • Dan Abramov - Making setInterval Declarative with React Hooks
  • usehooks-ts - TypeScript Custom Hook 레퍼런스
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

React

SSR Hydration Mismatch의 원리와 해결 패턴

React SSR에서 서버/클라이언트 렌더링 불일치가 발생하는 근본 원인과 체계적 해결 전략

읽기
React

Next.js App Router 렌더링 전략 완전 정리

Server Component와 Client Component, SSG·SSR·ISR의 두 축으로 이해하는 Next.js 렌더링 모델

읽기
React

useEffect를 남용하고 있다는 신호들

useEffect 과사용의 근본 원인과 올바른 멘탈 모델

읽기
React

useEffectEvent: Effect 의존성 딜레마의 종착점

React 19.2 useEffectEvent 훅의 동작 원리, ref 트릭 대체, 실무 적용 전략

읽기
다음 글useReducer 완벽 가이드
목차
  • 들어가며
  • 추출할 때와 하지 말아야 할 때
  • 단일 책임 원칙
  • 의존성 주입과 역전
  • TypeScript 제네릭 활용
  • 에러 처리 패턴
  • 정리(cleanup)와 생명주기
  • 합성과 조합
  • 네이밍 컨벤션
  • 실전 예제: useForm 설계
  • 테스트 전략
  • 참고 자료