본문 바로가기

React

React Hooks로 커스텀 훅 설계하기

커스텀 훅은 비즈니스/뷰 로직을 깔끔하게 재사용하도록 만드는 가장 실용적인 수단입니다. 이 글에서는 실무에서 바로 쓰는 기준, API 설계 원칙, 비동기/구독/성능/테스트/타이핑까지 커버하는 설계 패턴과 예시 코드를 제공합니다.

1. 언제 커스텀 훅을 만들까

- 컴포넌트들 사이에 반복되는 상태/이펙트/콜백이 있을 때입니다.
- DOM이나 외부 상태(브라우저, 스토어)를 구독해야 할 때입니다.
- 복잡한 비동기 흐름(취소, 중복 요청, 로딩/에러 관리)이 필요할 때입니다.

2. 기본 원칙 체크리스트

- 훅 이름은 use로 시작합니다(useXxx).
- 훅 내부에서 다른 훅 호출은 항상 동일한 순서를 보장합니다(조건부 호출 금지).
- 훅은 UI를 반환하지 않습니다(JSX 리턴 금지). 데이터, 상태, 콜백만 노출합니다.
- 입력은 가능하면 options 객체로, 출력은 객체 형태로(확장성/가독성).
- 외부에 노출하는 콜백/객체는 참조가 안정적이도록 관리합니다.

3. API 디자인: 입력/출력

- 입력: options 객체 패턴 권장({delay, onError, deps 등}).
- 출력: 상태+액션을 객체로 반환({value, loading, error, run}).
- 간단 토글 예시:

import { useCallback, useState } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  const setTrue = useCallback(() => setOn(true), []);
  const setFalse = useCallback(() => setOn(false), []);
  return { on, toggle, setOn, setTrue, setFalse };
}

4. 최신 값/안정 콜백 패턴(useLatest/useEvent)

의존성 배열에 함수를 넣으면 참조가 자주 바뀔 수 있습니다. 내부적으로 최신 함수를 참조하되 바깥에는 안정적인 함수를 노출하세요.

import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';

export function useLatest(value) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

// 안정적인 이벤트 콜백(useEvent 대체 패턴)
export function useStableCallback(fn) {
  const latest = useLatest(fn);
  return useCallback((...args) => latest.current?.(...args), [latest]);
}

// SSR 안전한 LayoutEffect
export const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

5. 비동기 훅 설계: 취소/중복/경쟁 상태

- AbortController로 이전 요청 취소하기.
- 최신 요청만 반영하기(경쟁 상태 방지).
- fetcher는 AbortSignal을 받아야 합니다.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

/**
 * useAsync
 * fetcher: ({ signal, args }) => Promise<T>
 */
export function useAsync(fetcher, { immediate = true, deps = [] } = {}) {
  const latestFetcher = useLatest(fetcher);
  const abortRef = useRef(null);
  const [state, setState] = useState({ loading: immediate, data: undefined, error: undefined });

  const run = useCallback(async (...args) => {
    // 이전 요청 취소
    if (abortRef.current) abortRef.current.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    setState(s => ({ ...s, loading: true, error: undefined }));
    try {
      const data = await latestFetcher.current({ signal: controller.signal, args });
      if (!controller.signal.aborted) {
        setState({ loading: false, data, error: undefined });
      }
      return data;
    } catch (e) {
      if (!controller.signal.aborted) {
        setState({ loading: false, data: undefined, error: e });
      }
      throw e;
    }
  }, [latestFetcher]);

  useEffect(() => {
    if (immediate) run();
    return () => abortRef.current?.abort();
    // deps는 사용자가 메모이즈한 안정 배열을 전달해야 합니다.
  }, [run, immediate, ...deps]);

  // 참조 안정성을 위해 run만 새로워질 때에만 객체가 바뀌도록 메모이즈
  return useMemo(() => ({ ...state, run }), [state, run]);
}

// 사용 예시
// const { data, loading, error, run } = useAsync(
//   ({ signal }) => fetch(url, { signal }).then(r => r.json()),
//   { deps: [url] }
// );

6. 디바운스 훅: 값 vs 콜백

목적에 따라 값 디바운싱과 콜백 디바운싱을 구분하세요.

import { useEffect, useMemo, useRef, useState } from 'react';

export function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

export function useDebouncedCallback(fn, delay = 300) {
  const latest = useLatest(fn);
  return useMemo(() => {
    let id;
    const debounced = (...args) => {
      if (id) clearTimeout(id);
      id = setTimeout(() => latest.current(...args), delay);
    };
    debounced.cancel = () => id && clearTimeout(id);
    return debounced;
  }, [delay, latest]);
}

7. 외부 상태 구독(useSyncExternalStore)

브라우저/스토어 등 외부 소스 구독에는 useSyncExternalStore를 사용해 동시성에 안전한 구독을 구현합니다.

import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}
function getSnapshot() {
  return navigator.onLine;
}
function getServerSnapshot() {
  return true; // SSR 기본값
}
export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

8. SSR/환경 고려 사항

- window/document 접근은 가드 처리(typeof window !== 'undefined').
- useLayoutEffect는 SSR에서 경고가 나므로 useIsomorphicLayoutEffect로 대체.
- 이벤트 리스너/타이머/observer는 반드시 cleanup 반환.

9. 성능: 리렌더 최소화

- 반환 객체/함수는 useMemo/useCallback으로 참조를 안정화합니다.
- 큰 데이터에서 필요한 조각만 선택해 반환합니다(selector 패턴).
- useEffect deps는 최소화하고, 최신 값은 useLatest로 참조합니다.
- 불필요한 state 파편화 지양(서로 의존적이면 하나의 state로 묶기).

10. 테스트: renderHook로 동작 보장

React Testing Library의 renderHook으로 커스텀 훅을 단위 테스트합니다.

import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

test('useToggle toggles boolean', () => {
  const { result } = renderHook(() => useToggle(false));
  expect(result.current.on).toBe(false);
  act(() => result.current.toggle());
  expect(result.current.on).toBe(true);
});

11. 타입스크립트 타이핑 팁

- 제네릭으로 입력/출력 타입을 노출합니다.
- 반환 객체는 명시적 타입을 부여해 DX를 높입니다.

// useAsync TS 시그니처 예시
export function useAsync<TArgs extends any[], TData>(
  fetcher: (ctx: { signal: AbortSignal; args: TArgs }) => Promise<TData>,
  options?: { immediate?: boolean; deps?: ReadonlyArray<unknown> }
): {
  data: TData | undefined;
  loading: boolean;
  error: unknown;
  run: (...args: TArgs) => Promise<TData>;
} { /* ...구현은 위 JS 참고... */ }

마무리 체크리스트

- [ ] 훅 이름은 use로 시작합니다.
- [ ] 조건부 훅 호출이 없습니다.
- [ ] 입력은 options 객체, 출력은 객체(상태+액션)입니다.
- [ ] 노출 콜백/객체는 참조가 안정적입니다(useCallback/useMemo).
- [ ] 비동기 취소/경쟁 상태를 처리합니다(AbortController).
- [ ] 외부 구독은 useSyncExternalStore를 사용합니다.
- [ ] SSR/브라우저 차이를 고려했습니다.
- [ ] 테스트와 타입을 제공합니다.

위 패턴만 지켜도 커스텀 훅은 예측 가능하고 재사용성이 높아집니다. 팀 컨벤션에 맞춰 옵션/반환 형태를 표준화하고, 공통 훅을 꾸준히 라이브러리화해 생산성을 끌어올리세요.