React의 Custom Hook: 로직 재사용을 올바르게 분리하기

1. 개요

React 애플리케이션에서 여러 컴포넌트가 같은 로직(예: API 호출, localStorage 읽기, 입력값 debounce)이 필요할 때, state와 useEffect를 이리저리 복사-붙여넣기하게 되기 쉽습니다. 이 글에서는 그 로직을 custom hook으로 묶는 방법을 보여줍니다: use로 시작하는 함수로, 내장 hook들(useState, useEffect, useRef…)을 활용하여 재사용 가능하도록 하는 방법입니다. 이 글을 읽고 나면 useFetch, useLocalStorage, useDebounce를 작성할 수 있고, renderHook으로 테스트하는 방법도 알게 될 것입니다.

2. 문제: 컴포넌트 간 반복되는 로직

두 컴포넌트 모두 API를 호출하고 로딩/에러 상태를 관리해야 하는 경우를 가정해봅시다:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...렌더링
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...렌더링
}

CustomerList와 OrderList는 거의 같은 로직을 포함하고 있으며, URL만 다릅니다. 나중에 재시도나 요청 취소 기능을 추가하려면 모든 곳에서 수정해야 합니다 — 이것이 바로 custom hook이 필요한 신호입니다.

3. Custom hook이란

Custom hook은 일반적인 JavaScript 함수로, 이름이 use로 시작하며, React의 다른 hook들을 내부에서 호출합니다. JSX를 반환하지 않습니다 — 상태와 함수를 반환하며, 이를 호출하는 컴포넌트에 붙는 “미니 스토어” 같은 역할을 합니다.

주의: 컴포넌트가 custom hook을 호출할 때마다 React는 독립적인 상태 인스턴스를 생성합니다. 두 컴포넌트가 useFetch를 사용해도 상태를 공유하지 않습니다 — 로직만 공유할 뿐입니다.

4. 첫 번째 custom hook 작성: useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

설명:

  • cancelled 플래그는 race condition을 방지합니다: url이 변경되기 전에 이전 요청이 완료되면, 이전 결과가 상태에 설정되지 않습니다.
  • Cleanup 함수 (return () => { cancelled = true })는 컴포넌트가 unmount되거나 url이 변경될 때 실행됩니다 — useEffect의 생명주기와 정확히 일치합니다.
  • Hook은 객체 { data, loading, error }를 반환하여 호출하는 곳에서 쉽게 구조 분해할 수 있습니다.

컴포넌트가 이제 간결해집니다:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>로딩 중...</p>;
  if (error) return <p>오류: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // 비슷한 렌더링 로직이며, 더 이상 fetch 로직을 반복할 필요가 없습니다
}

5. 두 번째 예제: useLocalStorage

localStorage와 상태를 동기화하는 로직도 복사-붙여넣기하기 쉽습니다:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

설명:

  • useState(() => ...)는 lazy initializer를 사용합니다 — 함수는 mount 시에만 한 번 실행되고, 매번 렌더링할 때마다 localStorage를 읽지 않습니다.
  • Hook은 useState처럼 정확히 [value, setValue] 튜플을 반환하여 사용자가 바로 익숙할 수 있습니다.

컴포넌트에서 사용:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      현재 테마: {theme}
    </button>
  );
}

5.1. state가 이전 값에 의존할 때

이전 값을 기반으로 업데이트해야 할 때(예: counter), stale closure를 피하기 위해 functional update를 사용하세요:

setValue((prev) => prev + 1); // setValue(value + 1)보다 더 안전합니다

6. 세 번째 예제: useDebounce

검색 입력값을 debounce하는 것은 매우 흔한 문제입니다:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

설명:

  • value가 변경될 때마다 새로운 setTimeout이 생성되고, 이전 setTimeout은 cleanup에서 clearTimeout됩니다.
  • 사용자가 delayMs 동안 입력을 멈춘 후에야 debounced 값이 업데이트됩니다.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. 이름 지정 규칙과 Rules of Hooks

Custom hook은 내장 hook처럼 Rules of Hooks를 따라야 합니다:

  1. 이름이 use로 시작해야 합니다 — ESLint 플러그인 eslint-plugin-react-hooks가 이 접두사를 기반으로 규칙을 확인하며, use가 없으면 이것이 hook인지 인식하지 못합니다.
  2. Hook은 함수의 최상위 레벨에서만 호출하세요. if, for, 콜백 내부에서 호출하지 마세요.
  3. Hook은 함수 컴포넌트나 다른 custom hook 내에서만 호출하세요. 일반 함수에서는 호출하지 마세요.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

exhaustive-deps를 활성화하면 custom hook의 useEffect에서 dependency를 빠뜨리지 않게 도와줍니다 — “stale data” 버그의 가장 흔한 원인입니다.

8. Custom hook 테스트

Custom hook은 JSX를 반환하지 않기 때문에 컴포넌트처럼 렌더링할 수 없습니다. @testing-library/react 라이브러리는 테스트 환경에서 hook을 호출하기 위해 renderHook을 제공합니다.

<!-- React 프로젝트에는 적용되지 않음; 아래를 참고하여 npm으로 dependency를 설치하세요 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('지연 시간이 지난 후에만 값을 업데이트합니다', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // 아직 변경되지 않음

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce 완료됨
    vi.useRealTimers();
  });
});

설명:

  • renderHook은 hook을 호출하는 함수를 받고, result.current를 반환합니다 — 현재 렌더링에서 hook이 반환하는 값입니다.
  • rerender는 새로운 props를 가진 컴포넌트 다시 렌더링을 시뮬레이션합니다(사용자가 입력할 때 value가 변경되는 것처럼).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...))를 사용하면 실제로 기다릴 필요 없이 시간을 제어할 수 있어, 테스트가 빠르고 안정적으로 실행됩니다.
✓ useDebounce > 지연 시간이 지난 후에만 값을 업데이트합니다

9. 자주 발생하는 오류 및 언제 사용하면 안 되는가

자주 발생하는 오류:

  • useEffect에서 cleanup을 빠뜨림: useFetch에서 cancelled 플래그가 없으면, 나중에 반환된 이전 요청이 새로운 데이터를 덮어쓸 수 있습니다 — 잘못된 데이터가 표시됩니다.
  • dependency 누락: hook의 useEffect가 외부 변수를 사용하지만 dependency array에 선언하지 않으면, hook은 “낡은” 값을 사용합니다(stale closure).
  • 불필요하게 매번 새로운 객체 반환: hook이 매번 object literal로 { data, loading, error }를 반환하면, 하위의 React.memo를 사용하는 컴포넌트는 reference가 변경되어 불필요하게 다시 렌더링될 수 있습니다. 문제가 측정되면 useMemo를 사용할 수 있습니다.

custom hook을 작성하면 안 되는 경우:

  • Logic이 단 한 곳에서만 사용되고 재사용할 가능성이 없는 경우 — 분리하면 코드를 따라가기만 더 어려워집니다(다른 파일로 이동해야 함).
  • Logic이 순수 함수(pure function)이고 상태/effect와 무관한 경우 — 일반적인 JS 함수로 작성해야 하며, hook이 필요하지 않습니다.
  • 트리의 다양한 위치에 있는 여러 컴포넌트 간에 상태(로직만이 아닌) 공유가 필요한 경우 — custom hook은 이를 할 수 없으므로, Context나 상태 관리 라이브러리(Zustand, Redux…)가 필요합니다.

주의: Custom hook은 로직 중복 문제를 해결하지만, 컴포넌트 간 상태 공유 문제는 해결하지 않습니다. 이 두 가지 문제를 혼동하지 마세요.

10. 결론

Custom hook은 상태 관련 로직을 컴포넌트에서 분리하는 도구로, 코드를 간결하게 하고, 테스트하기 쉽게 하며, 재사용하기 편하게 만듭니다. 기억해야 할 것은: 이름을 use로 시작하게 하고, Rules of Hooks를 따르며, render 대신 renderHook으로 테스트합니다. 위의 useFetch, useLocalStorage, useDebounce를 이용하면, useMediaQuery, useForm 같은 다른 hook들을 “한 번 작성하고, 여러 곳에서 사용하기”의 정신으로 작성할 수 있는 틀을 갖추게 됩니다.

reactcustom-hookjavascriptfrontendtesting