리액트 기초 강좌 #13 커스텀 훅

8 분 소요

지난 시간에는 prop drilling을 해결하는 도구인 Context를 배우면서, 마지막에 useTheme이라는 함수를 만들어 사용 편의를 높였습니다. 사실 이 useTheme은 **커스텀 훅(Custom Hook)**의 한 예시였습니다. 이번 시간에는 커스텀 훅이 무엇이고 왜 만들고 어떻게 만드는지를 본격적으로 다뤄보겠습니다.

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

지금까지 우리는 컴포넌트(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인지 식별하기 때문에, 호출 순서가 매번 같아야 합니다.

규칙 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이 핵심 역할을 합니다. value가 자주 바뀌면 매번 이전 타이머를 취소하고 새 타이머를 시작하므로, 결국 마지막 변경 후 delay 시간 동안 입력이 없을 때만 갱신이 일어납니다.

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로 만든 커스텀 훅이라, 원리를 이해해두면 라이브러리 학습이 빨라집니다.

커스텀 훅의 진짜 가치 #

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

또 하나 중요한 점은 각 컴포넌트가 훅을 호출하면 그 인스턴스가 자기만의 state를 갖는다는 사실입니다. useCounter()를 두 컴포넌트가 호출하면 카운트가 두 개 따로 만들어집니다. useState가 그런 식입니다. 즉, 훅은 코드를 공유하지만 state를 공유하지는 않습니다. 둘이 같은 state를 봐야 한다면 #11에서 배운 lifting state up이나 #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 동기화 로직은 다른 곳에서도 그대로 가져다 쓸 수 있습니다.

마무리 #

이번 글에서는 컴포넌트 사이에서 로직을 공유하는 도구인 커스텀 훅을 배웠습니다. 정리하면:

  • 커스텀 훅 = 이름이 use로 시작하고 다른 훅을 사용하는 함수
  • 훅의 규칙: 함수 최상위에서만 호출, 컴포넌트나 다른 훅 안에서만 호출
  • 자주 만드는 패턴: useToggle, useLocalStorage, useDebounce, useFetch
  • 훅은 로직을 공유하지 state를 공유하지는 않는다 (state 공유는 lifting/Context)
  • 라이브러리(TanStack Query 등)도 결국 커스텀 훅으로 만들어진 것

지금까지 우리는 “어떻게 동작하게 만드는가"에 집중했습니다. 다음 글인 “리액트 기초 강좌 #14 성능 최적화"에서는 “어떻게 빠르게 돌아가게 만드는가"를 다루는 도구들 — memo, useMemo, useCallback을 살펴봅니다. 흔히 오용되는 도구들이라 언제 써야 하고 언제 안 써야 하는지까지 함께 짚어보겠습니다.

X