목차
13 장

커스텀 훅

로직 재사용의 표준 도구. 좋은 훅의 인터페이스 형태, 자주 만드는 패턴들, 그리고 훅으로 빼지 말아야 할 경우까지 짚습니다.

12장 마지막에 useTheme이라는 작은 함수를 만들어 Context 사용 편의를 높였습니다. 사실 이 useTheme이 **커스텀 훅(Custom Hook)**의 한 예시였습니다. 본 챕터에서는 커스텀 훅이 무엇이고 왜 만들고 어떻게 만드는지를 본격적으로 다룹니다.

커스텀 훅의 타입 인터페이스(제네릭 훅 등)는 18장 (hooks 타이핑)에서 TypeScript로 굳힙니다. 본 챕터에서 훅의 기본 원리를 단단히 잡아 두면 그 뒷장이 가볍게 읽힙니다.

컴포넌트 사이에서 로직을 공유하는 문제 #

지금까지 우리는 컴포넌트(JSX 반환 함수) 단위로 코드를 재사용했습니다. 그런데 재사용하고 싶은 것이 화면 조각이 아니라 로직이라면 어떨까요?

다음 두 컴포넌트는 거의 같은 로직을 반복합니다.

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  // ... 화면 렌더링 ...
}
src/PostList.jsx
function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => setPosts(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  // ... 화면 렌더링 ...
}

같은 패턴 — 데이터 가져오기 + 로딩 / 에러 state가 중복됩니다. 이걸 한 곳에 모아 재사용할 수 있을까요? 커스텀 훅이 답입니다.

커스텀 훅이란 #

커스텀 훅은 이름이 use로 시작하는, 다른 훅을 사용하는 평범한 함수입니다. 정의는 그게 전부입니다. 새 문법이 있는 게 아니라 그저 컨벤션입니다.

간단한 커스텀 훅
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(initial);

  return { count, increment, decrement, reset };
}

이 함수는 컴포넌트가 아닙니다 (JSX를 반환하지 않으니까요). 하지만 함수 안에서 useState라는 훅을 사용하고 있습니다. 그래서 자기 자신도 훅이 됩니다.

사용하는 쪽:

src/Counter.jsx
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <h2>{count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

useCounter라는 한 줄로 카운터 로직 전체가 캡슐화됐습니다. 같은 훅을 다른 컴포넌트에서도 똑같이 호출하면 그 컴포넌트에도 자기만의 카운터가 생깁니다.

노트
“이름이 use로 시작한다"는 단순한 컨벤션이 아닙니다. 리액트는 함수 이름이 use로 시작하는지로 그 함수가 훅인지 판단하고, 훅의 규칙(아래 설명)을 적용할지를 결정합니다. ESLint의 react-hooks 플러그인도 이 규칙을 강제합니다. 반드시 use로 시작하게 지으세요.

훅의 규칙 #

커스텀 훅을 만들든 쓰든, 모든 훅에는 두 가지 규칙이 있습니다.

규칙 1. 훅은 함수의 최상위에서만 호출 #

잘못된 예 — 조건문 안
function App() {
  if (someCondition) {
    const [count, setCount] = useState(0);  // 🚫
  }
}

훅은 컴포넌트 함수의 최상위 레벨에서만 호출해야 합니다. 조건문, 반복문, 중첩 함수 안에서 호출하면 안 됩니다. 리액트가 훅의 호출 순서로 어떤 state가 어떤 useState인지 식별하기 때문에, 호출 순서가 매번 같아야 합니다.

이 규칙의 유일한 예외가 React 19의 use() 훅입니다. 26장 (Suspense와 use())에서 다루겠습니다.

규칙 2. 훅은 리액트 함수에서만 호출 #

훅은 컴포넌트 함수 또는 다른 커스텀 훅 안에서만 호출할 수 있습니다. 일반 자바스크립트 함수에서 호출하면 안 됩니다.

잘못된 예 — 일반 함수에서 호출
function fetchSomething() {
  const [data, setData] = useState(null);  // 🚫
}

이 규칙들을 어기면 ESLint가 잡아 주고, 런타임에 리액트가 에러를 띄웁니다.

자주 만들어 쓰는 커스텀 훅들 #

직접 만들기도 하고, 라이브러리에서 가져다 쓰기도 하는 흔한 커스텀 훅 예시 몇 개를 살펴보겠습니다.

useToggle — 불리언 토글 #

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}
사용
function App() {
  const [isOpen, toggleOpen] = useToggle();

  return (
    <>
      <button onClick={toggleOpen}>{isOpen ? '닫기' : '열기'}</button>
      {isOpen && <div>패널 내용</div>}
    </>
  );
}

체크박스 토글, 모달 열고 닫기, 메뉴 펼치고 접기 등 자주 등장하는 패턴이라 한 번 만들어 두면 활용도가 높습니다.

useLocalStorage — state ↔ localStorage 동기화 #

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

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

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

  return [value, setValue];
}
사용
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">라이트</option>
      <option value="dark">다크</option>
    </select>
  );
}

useState와 사용법이 거의 같지만, 값이 자동으로 localStorage에 저장되고 페이지를 새로고침해도 유지됩니다. 이렇게 기본 훅을 그대로 쓰는 듯한 인터페이스를 유지하면 사용하는 쪽이 직관적입니다.

useDebounce — 값 변경을 늦추기 #

타이핑 중에 매 글자마다 검색을 보내면 서버에 부담입니다. 사용자가 잠시 멈추기를 기다렸다 보내고 싶을 때 디바운스를 씁니다.

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

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

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

  return debounced;
}
사용
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

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

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

query는 매 키 입력마다 즉시 바뀌지만, debouncedQuery는 500ms 타이핑이 멈춘 뒤에야 갱신됩니다. 그 결과 검색 요청은 사용자가 잠시 쉴 때만 한 번씩 일어납니다. cleanup으로 이전 타이머를 취소하는 부분이 핵심입니다.

useFetch — 데이터 가져오기 #

도입부에서 본 중복 패턴을 훅으로 추출해 봅시다.

src/hooks/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(`요청 실패: ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) setData(json);
      })
      .catch(err => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

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

  return { data, loading, error };
}
사용
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>불러오는 ...</p>;
  if (error) return <p>에러: {error}</p>;
  return <p>{user.name}</p>;
}

function PostList() {
  const { data: posts, loading, error } = useFetch('/api/posts');

  if (loading) return <p>불러오는 ...</p>;
  if (error) return <p>에러: {error}</p>;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

도입부의 중복이 사라지고, 각 컴포넌트는 화면 그리는 일에만 집중하게 됐습니다. 같은 로직을 100군데에서 쓴다 해도 훅 한 개를 고치면 100군데가 함께 바뀝니다.

노트

실무에서는 직접 useFetch를 만들기보다 TanStack Query 같은 라이브러리를 쓰는 경우가 많습니다. 캐싱, 재검증, 백그라운드 업데이트, 페이지네이션 등 직접 구현하기 까다로운 부분을 잘 다듬어 제공하기 때문입니다. 그것도 결국 useEffect + useState로 만든 커스텀 훅이라, 원리를 이해해 두면 라이브러리 학습이 빨라집니다.

이 책의 4부 (모던 Next.js)에서는 한 발 더 나아가 Server Components 환경에서는 데이터 페칭을 useFetch 같은 클라이언트 훅 없이 서버 컴포넌트 함수 본문에서 직접 하는 모델을 봅니다. 25장 (데이터 페칭과 캐싱)에서 다루겠습니다.

훅으로 빼지 말아야 할 경우 #

커스텀 훅은 강력한 도구지만 모든 곳에 쓰는 것이 아닙니다. 두 가지 이상의 컴포넌트가 정말로 같은 로직을 반복할 때가 의미 있는 추출 시점입니다.

1. 단순한 useState wrapper #

과한 추상화
function useName() {
  return useState('');
}

// 사용
const [name, setName] = useName();

useState('') 한 줄을 useName() 한 줄로 바꿔 봤자 추상화 비용만 늘어납니다. 호출하는 쪽이 무엇을 받게 되는지 한 단계 더 들여다봐야 알 수 있게 됩니다. 한 줄짜리는 그냥 인라인으로 두는 게 좋습니다.

2. 한 군데에서만 쓰이는 로직 #

“언젠가 두 번째 컴포넌트에서도 쓸 수 있으니까"라는 이유로 미리 훅으로 빼는 것도 추상화 부채입니다. 진짜로 두 번째 컴포넌트가 같은 로직을 필요로 할 때 추출하면 충분합니다. 그 전까지는 컴포넌트 안에 인라인으로 두는 쪽이 명료합니다.

3. 화면 조각 #

화면을 재사용하고 싶다면 컴포넌트로 빼야지 훅으로 뺄 일이 아닙니다. 훅의 반환값은 값 / 함수이지 JSX가 아닙니다.

좋은 훅의 인터페이스 형태 #

자주 만들어 쓰는 훅들을 보면 인터페이스가 두 가지 패턴 중 하나입니다.

  • 튜플 (배열)useState와 비슷하게 쓰고 싶을 때. const [value, setValue] = useToggle()
  • 객체 — 반환값이 3개 이상이거나 의미를 명확히 하고 싶을 때. const { data, loading, error } = useFetch(url)

반환값이 2개면 튜플, 3개 이상이면 객체가 일반적입니다. 객체로 반환하면 호출하는 쪽에서 필요한 것만 골라 쓸 수 있고, 새 필드를 추가해도 기존 호출 코드가 안 깨집니다.

커스텀 훅의 진짜 가치 #

커스텀 훅을 만들면서 가장 인상적인 점은 추상화의 자유로움입니다. 우리가 추출한 것은 단순한 함수가 아니라 state를 가진 동작 단위입니다. 카운터, 토글, 데이터 페칭, 디바운스 같은 “기능” 들을 컴포넌트와 분리해 독립된 단위로 다룰 수 있게 된 것입니다.

또 하나 중요한 점은 각 컴포넌트가 훅을 호출하면 그 인스턴스가 자기만의 state를 갖는다는 사실입니다. useCounter()를 두 컴포넌트가 호출하면 카운트가 두 개 따로 만들어집니다. 즉 훅은 코드를 공유하지만 state를 공유하지는 않습니다. 둘이 같은 state를 봐야 한다면 11장 (상태 끌어올리기)이나 12장 (Context)를 써야 합니다.

직접 해보기 #

지금까지 만든 컴포넌트들을 커스텀 훅으로 정리해 봅시다.

src/hooks/useToggle.js:

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}

src/hooks/useLocalStorage.js:

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

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

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

  return [value, setValue];
}

src/App.jsx:

src/App.jsx
import { useToggle } from './hooks/useToggle';
import { useLocalStorage } from './hooks/useLocalStorage';

function App() {
  const [isMenuOpen, toggleMenu] = useToggle();
  const [name, setName] = useLocalStorage('userName', '');

  return (
    <div style={{ padding: '16px' }}>
      <h1>커스텀  데모</h1>

      <section style={{ marginTop: '16px' }}>
        <button onClick={toggleMenu}>{isMenuOpen ? '메뉴 닫기' : '메뉴 열기'}</button>
        {isMenuOpen && (
          <ul>
            <li></li>
            <li>소개</li>
            <li>연락처</li>
          </ul>
        )}
      </section>

      <section style={{ marginTop: '16px' }}>
        <p>이름을 입력하세요 (새로고침해도 유지됩니다):</p>
        <input value={name} onChange={(e) => setName(e.target.value)} />
        {name && <p>안녕하세요, {name}!</p>}
      </section>
    </div>
  );
}

export default App;

토글 메뉴는 클릭할 때마다 열리고 닫히고, 이름 입력은 페이지를 새로고침해도 그대로 유지됩니다. 컴포넌트의 코드는 훨씬 짧아졌고, 토글이나 localStorage 동기화 로직은 다른 곳에서도 그대로 가져다 쓸 수 있습니다.

연습문제 #

  1. useDebounce 훅을 직접 만들고, 검색창에 적용해 보세요. query state가 즉시 갱신되는 동안 debouncedQuery는 500ms 멈춘 뒤에야 갱신되는 것을 콘솔 로그로 관찰합니다. cleanup으로 이전 타이머를 취소하는 부분을 빼면 어떻게 되는지도 직접 실험해 봅니다.
  2. useFetch의 약점 발견. 위 useFetch는 같은 URL을 여러 컴포넌트가 호출해도 각자 따로 요청합니다 (캐싱 없음). 다섯 개의 컴포넌트가 /api/users/1을 동시에 호출하는 화면을 만들어 네트워크 탭에서 다섯 번 요청되는 것을 확인해 보세요. 이 문제를 풀려고 만들어진 게 TanStack Query입니다 (이 책은 직접 다루지 않습니다).
  3. 훅으로 빼지 말아야 할 경우 식별. 다음 셋 중 어느 게 훅으로 빼는 것이 적절할지 골라 보세요. (a) useState('') 한 줄, (b) localStorage와 동기화된 state (useLocalStorage), (c) 한 컴포넌트에서만 쓰는 30줄짜리 폼 검증 로직. 답은 (b)만 적절합니다. 왜 그런지 한 단락으로 답해 보세요.

한 줄 요약: 커스텀 훅 = 이름이 use로 시작하고 다른 훅을 사용하는 함수다. 훅의 규칙은 함수 최상위에서만 호출, 컴포넌트나 다른 훅 안에서만 호출. 자주 만드는 패턴은 useToggle, useLocalStorage, useDebounce, useFetch. 훅은 로직을 공유하지 state를 공유하지는 않는다. 한 줄짜리 / 한 군데에서만 쓰이는 로직은 훅으로 빼지 말고 인라인으로 둔다.

다음 챕터 #

지금까지 “어떻게 동작하게 만드는가"에 집중했습니다. 다음 14장 성능 최적화에서는 “어떻게 빠르게 돌아가게 만드는가"를 다루는 도구들 — memo, useMemo, useCallback을 살펴보겠습니다. 그리고 React 19의 React Compiler가 이 도구들의 역할을 어떻게 바꾸는지도 같이 짚겠습니다.

X