CustomHooks 완벽 마스터

CustomHooks의 핵심 개념과 실전 활용법

React중급
12시간
6개 항목
학습 진행률0 / 6 (0%)

학습 항목

1. React
React|Custom|Hooks|완벽|가이드
퀴즈튜토리얼
2. React
React|Hooks|기초|완벽|가이드
퀴즈튜토리얼
3. React
React|Hooks|완벽|가이드
퀴즈튜토리얼
4. React
React|Hooks|완벽|가이드|초급자용
퀴즈튜토리얼
5. React
고급
React|기초부터|심화까지|완벽|가이드
퀴즈튜토리얼
6. React
중급
React|실전|프로젝트|컴포넌트|설계
퀴즈튜토리얼
1 / 6

이미지 로딩 중...

React Custom Hooks 완벽 가이드 - 슬라이드 1/11

React Custom Hooks 완벽 가이드

React Custom Hooks는 컴포넌트 로직을 재사용 가능한 함수로 추출하는 강력한 패턴입니다. 이 가이드에서는 실무에서 자주 사용되는 Custom Hooks 패턴과 작성법, 그리고 실전 팁을 배웁니다.


목차

  1. Custom Hook 기본 개념 - 로직 재사용의 시작
  2. useFetch Hook - API 호출 패턴
  3. useLocalStorage Hook - 로컬 스토리지 동기화
  4. useDebounce Hook - 입력 최적화
  5. useToggle Hook - 불리언 상태 관리
  6. useWindowSize Hook - 반응형 UI 대응
  7. usePrevious Hook - 이전 값 추적
  8. useInterval Hook - 안전한 타이머 관리
  9. useOnClickOutside Hook - 외부 클릭 감지
  10. useMediaQuery Hook - 미디어 쿼리 반응

1. Custom Hook 기본 개념 - 로직 재사용의 시작

시작하며

여러분이 여러 컴포넌트에서 동일한 상태 관리 로직을 반복해서 작성하고 있나요? 예를 들어, API 데이터를 가져오는 로직을 ProductList, UserProfile, Dashboard 컴포넌트에서 각각 따로 구현하고 있다면, 코드 중복이 심해지고 유지보수가 어려워집니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 같은 로직을 여러 곳에 복사-붙여넣기하다 보면, 버그 수정이나 기능 개선 시 모든 곳을 찾아서 수정해야 하는 번거로움이 생깁니다.

또한 코드 리뷰 시에도 동일한 로직이 여러 곳에 흩어져 있어 전체적인 구조를 파악하기 어렵습니다. 바로 이럴 때 필요한 것이 Custom Hooks입니다.

Custom Hooks를 사용하면 반복되는 로직을 한 곳에 모아서 관리하고, 필요한 곳에서 간단히 호출만 하면 됩니다. 이를 통해 코드의 재사용성과 가독성을 크게 향상시킬 수 있습니다.

개요

간단히 말해서, Custom Hook은 'use'로 시작하는 이름을 가진 JavaScript 함수로, 내부에서 다른 Hook을 사용할 수 있는 특별한 함수입니다. 왜 Custom Hook이 필요한지 실무 관점에서 생각해보면, 대부분의 애플리케이션에서는 폼 검증, API 호출, 로컬 스토리지 관리 같은 공통 로직이 반복됩니다.

예를 들어, 사용자 인증 상태를 여러 페이지에서 확인해야 하는 경우, Custom Hook으로 만들어두면 모든 컴포넌트에서 일관된 방식으로 인증 상태를 관리할 수 있습니다. 기존에는 Higher-Order Components(HOC)나 Render Props 패턴을 사용했다면, 이제는 Custom Hooks로 더 직관적이고 간결하게 로직을 공유할 수 있습니다.

Wrapper 지옥에서 벗어나 평면적인 코드 구조를 유지할 수 있죠. Custom Hook의 핵심 특징은 세 가지입니다.

첫째, 컴포넌트 로직을 함수로 추출할 수 있습니다. 둘째, Hook의 규칙을 따르므로 다른 Hook을 내부에서 사용할 수 있습니다.

셋째, 각 호출마다 독립적인 state를 가집니다. 이러한 특징들이 컴포넌트를 깔끔하게 유지하면서도 강력한 기능을 구현할 수 있게 해줍니다.

코드 예제

// Custom Hook: 입력 필드 상태 관리
function useInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  // 입력값 변경 핸들러
  const handleChange = (e) => {
    setValue(e.target.value);
  };

  // 값 초기화 함수
  const reset = () => {
    setValue(initialValue);
  };

  // 필요한 값과 함수들을 객체로 반환
  return { value, onChange: handleChange, reset };
}

// 사용 예시
function LoginForm() {
  const email = useInput('');
  const password = useInput('');

  return (
    <form>
      <input type="email" {...email} />
      <input type="password" {...password} />
      <button type="button" onClick={() => { email.reset(); password.reset(); }}>
        Clear
      </button>
    </form>
  );
}

설명

이것이 하는 일: useInput Hook은 입력 필드의 상태와 이벤트 핸들러를 캡슐화하여 반환합니다. 이를 통해 폼 관리 코드를 극적으로 단순화할 수 있습니다.

첫 번째로, useState를 사용하여 입력값을 관리합니다. initialValue를 받아서 초기 상태를 설정하는데, 이렇게 하면 Hook을 사용할 때마다 다른 초기값을 지정할 수 있어 유연합니다.

Hook 내부에서 useState를 사용할 수 있는 이유는 이것이 Custom Hook이기 때문입니다. 그 다음으로, handleChange 함수가 실행되면서 이벤트 객체를 받아 value를 업데이트합니다.

이 함수는 일반적인 input의 onChange 이벤트와 호환되도록 설계되었습니다. 또한 reset 함수도 제공하여 필요할 때 입력값을 초기화할 수 있게 했습니다.

마지막으로, 필요한 값과 함수들을 객체 형태로 반환하여 최종적으로 컴포넌트에서 사용할 수 있게 만듭니다. 특히 spread 연산자(...)를 사용하면 value와 onChange를 한 번에 input 요소에 전달할 수 있어 매우 편리합니다.

여러분이 이 코드를 사용하면 폼 관리 코드가 훨씬 간결해지고, 입력 필드가 추가될 때마다 동일한 패턴을 반복할 필요가 없습니다. 또한 입력 검증, 디바운싱 같은 기능을 Hook 내부에 추가하면 모든 입력 필드에 일관되게 적용할 수 있습니다.

유지보수성이 크게 향상되고, 테스트도 Hook 단위로 할 수 있어 효율적입니다.

실전 팁

💡 Custom Hook의 이름은 반드시 'use'로 시작해야 합니다. 그래야 React가 Hook의 규칙을 자동으로 검사할 수 있고, ESLint 플러그인도 제대로 작동합니다.

💡 Hook 내부에서 조건문이나 반복문 안에 Hook을 호출하지 마세요. 이는 React의 Hook 규칙 위반이며, 예상치 못한 버그를 일으킵니다. 항상 최상위 레벨에서만 Hook을 호출하세요.

💡 Custom Hook이 여러 값을 반환할 때는 배열보다 객체를 사용하는 것이 좋습니다. 객체를 사용하면 사용하는 쪽에서 필요한 값만 선택적으로 가져올 수 있고, 순서를 신경 쓰지 않아도 됩니다.

💡 Custom Hook을 만들기 전에 정말 재사용이 필요한지 고민하세요. 한두 곳에서만 사용되는 로직이라면 오히려 복잡도만 증가시킬 수 있습니다. 최소 3곳 이상에서 사용될 것 같을 때 추출하는 것이 좋습니다.

💡 Custom Hook의 이름은 구체적이고 명확하게 지으세요. useData보다는 useUserProfile, useFetchProducts처럼 무엇을 하는지 명확히 알 수 있는 이름이 좋습니다.


2. useFetch Hook - API 호출 패턴

시작하며

여러분이 API 데이터를 가져올 때마다 loading 상태, error 상태, data 상태를 매번 선언하고 useEffect에서 fetch를 호출하는 패턴을 반복하고 있나요? 게시글 목록, 사용자 정보, 상품 데이터 등 다양한 API 호출마다 동일한 보일러플레이트 코드를 작성하는 것은 매우 비효율적입니다.

이런 문제는 실제 프로젝트에서 심각한 코드 중복을 만듭니다. 각 컴포넌트마다 로딩 처리, 에러 핸들링, 데이터 저장 로직이 흩어져 있으면 일관성을 유지하기 어렵습니다.

예를 들어, 에러 처리 방식을 변경하려면 모든 API 호출 부분을 찾아서 수정해야 합니다. 바로 이럴 때 필요한 것이 useFetch Custom Hook입니다.

API 호출의 모든 공통 로직을 한 곳에 모아서 관리하면, 컴포넌트는 데이터 표시에만 집중할 수 있습니다.

개요

간단히 말해서, useFetch는 API 요청의 전체 생명주기(로딩, 성공, 실패)를 관리하는 Custom Hook입니다. 왜 이 Hook이 필요한지 실무 관점에서 보면, 대부분의 웹 애플리케이션은 수십 개의 API 엔드포인트와 통신합니다.

각 API 호출마다 동일한 상태 관리 로직을 반복하는 것은 시간 낭비이며, 버그가 발생할 여지도 많아집니다. 예를 들어, 사용자 대시보드에서 프로필, 통계, 최근 활동 등 여러 API를 호출할 때, useFetch를 사용하면 각각을 단 한 줄로 처리할 수 있습니다.

기존에는 각 컴포넌트에서 useEffect, useState를 조합해서 fetch 로직을 직접 작성했다면, 이제는 useFetch를 호출하기만 하면 됩니다. 코드 양이 줄어들 뿐만 아니라, 캐싱, 재시도, 타임아웃 같은 고급 기능도 Hook 내부에 추가하면 모든 API 호출에 자동으로 적용됩니다.

이 Hook의 핵심 특징은 세 가지입니다. 첫째, 로딩/에러/데이터 상태를 자동으로 관리합니다.

둘째, URL이 변경되면 자동으로 재요청합니다. 셋째, 컴포넌트 언마운트 시 정리 작업을 자동으로 수행합니다.

이러한 특징들이 안정적이고 예측 가능한 데이터 fetching을 가능하게 합니다.

코드 예제

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

  useEffect(() => {
    // 이전 요청 취소를 위한 플래그
    let isCancelled = false;

    const fetchData = async () => {
      setLoading(true);
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error('Network response was not ok');
        const result = await response.json();

        // 컴포넌트가 언마운트되지 않았을 때만 상태 업데이트
        if (!isCancelled) {
          setData(result);
          setError(null);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setData(null);
        }
      } finally {
        if (!isCancelled) setLoading(false);
      }
    };

    fetchData();

    // 클린업: 컴포넌트 언마운트 시 실행
    return () => {
      isCancelled = true;
    };
  }, [url]); // url이 변경되면 재실행

  return { data, loading, error };
}

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

설명

이것이 하는 일: useFetch Hook은 URL을 받아서 자동으로 데이터를 가져오고, 그 과정의 모든 상태를 추적합니다. 컴포넌트는 반환된 상태만 확인하면 됩니다.

첫 번째로, 세 가지 상태(data, loading, error)를 선언합니다. 초기에는 loading이 true이고 나머지는 null입니다.

이렇게 명시적으로 상태를 분리하면 UI에서 각 경우를 명확하게 처리할 수 있습니다. 로딩 중일 때는 스피너를, 에러 발생 시에는 에러 메시지를, 성공 시에는 데이터를 표시하는 식입니다.

그 다음으로, useEffect 내부에서 실제 fetch 작업이 실행됩니다. isCancelled 플래그는 매우 중요한데, 컴포넌트가 언마운트된 후에 setState가 호출되는 것을 방지합니다.

예를 들어, 사용자가 페이지를 빠르게 이동했을 때 이전 페이지의 API 응답이 늦게 도착하면 메모리 누수 경고가 발생할 수 있는데, 이를 방지합니다. 세 번째로, try-catch 블록에서 에러를 처리하고, finally에서 로딩 상태를 종료합니다.

response.ok를 확인하는 것은 HTTP 에러(404, 500 등)를 잡기 위함이며, 이를 놓치면 에러가 발생했는데도 정상 처리되는 버그가 생깁니다. 마지막으로, useEffect의 의존성 배열에 url을 넣어서, URL이 변경될 때마다 새로 데이터를 가져오도록 합니다.

예를 들어 userId가 변경되면 자동으로 새로운 사용자 데이터를 불러옵니다. 여러분이 이 Hook을 사용하면 API 호출 코드가 극적으로 간결해집니다.

한 줄로 데이터를 가져올 수 있고, 로딩과 에러 처리도 일관되게 적용됩니다. 또한 Hook 내부에 요청 캐싱, 재시도 로직, 인증 토큰 추가 같은 기능을 넣으면 모든 API 호출에 자동 적용되어 유지보수가 매우 쉬워집니다.

실전 팁

💡 AbortController를 사용하면 요청을 진짜로 취소할 수 있습니다. isCancelled는 상태 업데이트만 막지만, AbortController는 네트워크 요청 자체를 취소하여 불필요한 대역폭 사용을 줄입니다.

💡 에러 처리 시 err.message보다는 커스텀 에러 객체를 사용하세요. 예를 들어 { message, status, timestamp }를 저장하면 UI에서 더 풍부한 에러 정보를 표시할 수 있습니다.

💡 useFetch에 options 파라미터를 추가하면 POST, PUT 같은 다른 HTTP 메서드도 처리할 수 있습니다. { method, headers, body }를 받아서 fetch의 두 번째 인자로 전달하면 됩니다.

💡 SWR이나 React Query 같은 라이브러리를 고려하세요. 간단한 프로젝트에는 직접 만든 useFetch로 충분하지만, 복잡한 캐싱이나 백그라운드 동기화가 필요하면 검증된 라이브러리가 더 안전합니다.

💡 로딩 상태에 debounce를 적용하면 깜빡임을 줄일 수 있습니다. 200ms 이내에 응답이 오면 로딩 UI를 아예 보여주지 않는 식으로 사용자 경험을 개선할 수 있습니다.


3. useLocalStorage Hook - 로컬 스토리지 동기화

시작하며

여러분이 사용자의 테마 설정, 언어 선택, 임시 저장된 폼 데이터 등을 브라우저에 저장하고 싶을 때 어떻게 하시나요? localStorage를 직접 사용하면 JSON 파싱, 에러 처리, React 상태와의 동기화 등 신경 써야 할 부분이 많습니다.

이런 문제는 실제로 사용자 경험에 직접 영향을 미칩니다. 예를 들어, 사용자가 다크 모드를 설정했는데 페이지를 새로고침하면 다시 라이트 모드로 돌아간다면 매우 불편할 것입니다.

또한 여러 탭에서 동시에 앱을 사용할 때 설정이 동기화되지 않으면 혼란스러울 수 있습니다. 바로 이럴 때 필요한 것이 useLocalStorage Hook입니다.

React 상태와 localStorage를 완벽하게 동기화하여, 일반 useState처럼 사용하면서도 자동으로 브라우저에 저장됩니다.

개요

간단히 말해서, useLocalStorage는 useState와 동일한 인터페이스를 제공하면서 자동으로 localStorage와 동기화되는 Hook입니다. 왜 이 Hook이 필요한지 보면, 현대 웹 애플리케이션에서는 사용자 설정, 장바구니 데이터, 임시 저장본 같은 정보를 클라이언트에 저장해야 하는 경우가 많습니다.

하지만 localStorage API는 문자열만 저장할 수 있고, React의 반응성 시스템과 통합되지 않아 수동으로 처리해야 합니다. 예를 들어, 사용자가 선호하는 언어 설정을 저장하고 모든 컴포넌트에서 접근하려면 복잡한 보일러플레이트가 필요합니다.

기존에는 useEffect에서 localStorage.setItem을 호출하고, 컴포넌트 마운트 시 localStorage.getItem으로 값을 읽어와야 했다면, 이제는 useState를 사용하듯이 간단하게 처리할 수 있습니다. 값이 변경되면 자동으로 localStorage에 저장되고, 페이지를 새로고침해도 값이 유지됩니다.

이 Hook의 핵심 특징은 네 가지입니다. 첫째, useState와 동일한 API를 제공하여 학습 곡선이 없습니다.

둘째, 객체나 배열도 자동으로 JSON 직렬화하여 저장합니다. 셋째, localStorage 접근 실패 시 우아하게 처리합니다.

넷째, 다른 탭에서 값이 변경되면 자동으로 동기화할 수 있습니다. 이러한 특징들이 강력하면서도 사용하기 쉬운 상태 영속화를 가능하게 합니다.

코드 예제

function useLocalStorage(key, initialValue) {
  // 초기값 계산 - localStorage에서 읽기 시도
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      // 저장된 값이 있으면 파싱해서 반환, 없으면 초기값
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // setState를 감싸는 함수 - localStorage에도 저장
  const setValue = (value) => {
    try {
      // 함수형 업데이트 지원 (setState처럼)
      const valueToStore = value instanceof Function ? value(storedValue) : value;

      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue];
}

// 사용 예시
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

설명

이것이 하는 일: useLocalStorage는 useState의 기능에 localStorage 영속화를 더한 Hook입니다. 상태가 변경될 때마다 자동으로 브라우저에 저장되고, 페이지를 다시 방문하면 저장된 값을 불러옵니다.

첫 번째로, useState의 초기값으로 함수를 전달하는 lazy initialization 패턴을 사용합니다. 이렇게 하면 localStorage 읽기가 컴포넌트가 처음 렌더링될 때 한 번만 실행되어 성능이 좋습니다.

try-catch로 감싸는 것은 매우 중요한데, 시크릿 모드나 스토리지 할당량 초과 시 localStorage 접근이 실패할 수 있기 때문입니다. 그 다음으로, setValue 함수가 실행되면 두 가지 일을 동시에 합니다.

먼저 React 상태를 업데이트하여 즉시 UI에 반영되게 하고, 그 다음 localStorage에도 저장하여 영속화합니다. value instanceof Function 체크는 setState처럼 함수형 업데이트를 지원하기 위한 것으로, setTheme(prev => prev === 'light' ?

'dark' : 'light') 같은 패턴을 사용할 수 있게 합니다. 세 번째로, JSON.stringify와 JSON.parse를 사용하여 모든 타입의 값을 저장할 수 있습니다.

문자열뿐만 아니라 객체, 배열, 숫자, 불리언 모두 자동으로 직렬화됩니다. 예를 들어 사용자 설정 객체 { fontSize: 14, notifications: true }를 그대로 저장할 수 있습니다.

마지막으로, 반환값이 [storedValue, setValue] 배열인 것은 useState와 완전히 동일한 API를 제공하기 위함입니다. 이렇게 하면 기존 useState를 사용하던 코드에서 import만 바꾸면 바로 localStorage 동기화를 얻을 수 있습니다.

여러분이 이 Hook을 사용하면 사용자 설정이나 임시 데이터를 매우 쉽게 영속화할 수 있습니다. 페이지를 새로고침하거나 브라우저를 닫았다가 다시 열어도 데이터가 유지됩니다.

또한 에러 처리가 내장되어 있어 localStorage를 사용할 수 없는 환경에서도 앱이 정상 작동합니다. 실무에서는 사용자 테마, 언어, 사이드바 상태, 테이블 정렬 옵션 등 다양한 곳에 활용할 수 있습니다.

실전 팁

💡 storage 이벤트를 리스닝하면 다른 탭에서 값이 변경될 때도 동기화할 수 있습니다. useEffect에서 window.addEventListener('storage', handler)를 추가하면 멀티탭 환경에서도 일관성을 유지할 수 있습니다.

💡 key에 네임스페이스를 추가하세요. 예를 들어 'myapp:theme' 처럼 앱 이름을 접두사로 붙이면 다른 앱이나 라이브러리와 충돌을 피할 수 있습니다. 특히 같은 도메인에 여러 앱이 있을 때 중요합니다.

💡 민감한 정보는 절대 localStorage에 저장하지 마세요. 액세스 토큰, 비밀번호, 개인정보는 XSS 공격에 노출될 수 있습니다. 이런 데이터는 httpOnly 쿠키나 메모리에만 저장하세요.

💡 저장 크기를 제한하세요. localStorage는 보통 5-10MB로 제한되므로, 큰 데이터를 저장하기 전에 크기를 체크하고 필요하면 경고를 표시하는 것이 좋습니다.

💡 만료 시간을 추가하면 더 강력합니다. { value, expiry: Date.now() + 86400000 } 형식으로 저장하고, 읽을 때 만료 여부를 확인하면 오래된 데이터를 자동으로 제거할 수 있습니다.


4. useDebounce Hook - 입력 최적화

시작하며

여러분이 검색 기능을 구현할 때, 사용자가 타이핑할 때마다 API를 호출하고 있나요? "React Hooks"를 입력하면 총 11번의 API 요청이 발생합니다.

이는 서버에 불필요한 부하를 주고, 사용자에게는 느린 경험을 제공하며, 비용도 낭비됩니다. 이런 문제는 실시간 검색, 자동완성, 폼 검증 같은 기능에서 자주 발생합니다.

매 키 입력마다 무거운 작업을 수행하면 성능이 급격히 저하되고, 특히 모바일 환경에서는 더욱 심각합니다. 또한 API 호출이 순서대로 완료되지 않으면 이전 결과가 최신 결과를 덮어쓰는 race condition도 발생할 수 있습니다.

바로 이럴 때 필요한 것이 useDebounce Hook입니다. 사용자의 입력이 일정 시간 멈출 때까지 기다렸다가 한 번만 작업을 수행하여, 성능과 사용자 경험을 동시에 개선합니다.

개요

간단히 말해서, useDebounce는 빠르게 변경되는 값을 지연시켜서, 변경이 멈춘 후 일정 시간이 지나면 최종 값만 반환하는 Hook입니다. 왜 이 Hook이 필요한지 실무 관점에서 보면, 검색창에서 사용자가 타이핑을 완료하고 500ms가 지난 후에만 검색 API를 호출하면, 요청 횟수를 90% 이상 줄일 수 있습니다.

예를 들어, 전자상거래 사이트의 상품 검색에서 사용자가 "wireless keyboard"를 입력할 때, "w", "wi", "wir"... 각각에 대해 API를 호출하는 대신 타이핑이 끝난 후 한 번만 호출합니다.

기존에는 setTimeout과 clearTimeout을 수동으로 관리하면서 복잡한 로직을 작성해야 했다면, 이제는 useDebounce로 간단하게 처리할 수 있습니다. 타이머 관리, 클린업, 최신 값 추적을 모두 Hook이 알아서 합니다.

이 Hook의 핵심 특징은 세 가지입니다. 첫째, 빠르게 변경되는 값에서 노이즈를 제거합니다.

둘째, 컴포넌트 언마운트 시 타이머를 자동으로 정리합니다. 셋째, 지연 시간을 자유롭게 설정할 수 있습니다.

이러한 특징들이 불필요한 계산과 네트워크 요청을 획기적으로 줄여줍니다.

코드 예제

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // delay 후에 값을 업데이트할 타이머 설정
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 클린업: 새로운 value가 들어오면 이전 타이머 취소
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]); // value나 delay가 변경될 때마다 실행

  return debouncedValue;
}

// 사용 예시
function SearchBox() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  // debouncedSearchTerm이 변경될 때만 API 호출
  useEffect(() => {
    if (debouncedSearchTerm) {
      // API 호출 로직
      fetch(`/api/search?q=${debouncedSearchTerm}`)
        .then(res => res.json())
        .then(data => console.log(data));
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      type="text"
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
}

설명

이것이 하는 일: useDebounce는 원본 값의 변경을 추적하다가, 지정된 시간 동안 변경이 없으면 그때 비로소 디바운스된 값을 업데이트합니다. 마치 "타이핑이 끝났나요?"라고 계속 묻다가, 일정 시간 침묵이 있으면 "끝났군요!"라고 확정하는 것과 같습니다.

첫 번째로, debouncedValue라는 별도의 상태를 만듭니다. 이 값은 원본 value보다 느리게 업데이트됩니다.

사용자가 타이핑하면 searchTerm은 즉시 변경되어 input에 반영되지만(지연 없는 타이핑 경험), debouncedSearchTerm은 타이핑이 멈춘 후 500ms 뒤에야 업데이트됩니다. 그 다음으로, useEffect 내부에서 setTimeout을 사용하여 지연 로직을 구현합니다.

value가 변경되면 타이머를 시작하는데, delay 밀리초 후에 setDebouncedValue가 실행됩니다. 하지만 중요한 것은 클린업 함수입니다.

value가 다시 변경되면 (사용자가 계속 타이핑하면) 이전 타이머를 취소하고 새 타이머를 시작합니다. 이렇게 해서 연속된 변경 중에는 업데이트가 안 되고, 변경이 멈춘 후에만 업데이트됩니다.

세 번째로, 실제 사용 예시를 보면 두 개의 useEffect가 있습니다. 첫 번째 useEffect는 useDebounce 내부에서 타이머를 관리하고, 두 번째 useEffect는 SearchBox 컴포넌트에서 디바운스된 값으로 API를 호출합니다.

이렇게 관심사가 명확히 분리됩니다. 마지막으로, 의존성 배열에 value와 delay를 모두 넣은 것에 주목하세요.

delay가 변경되는 경우는 드물지만, 변경되면 새로운 지연 시간으로 타이머를 재설정해야 하기 때문입니다. 여러분이 이 Hook을 사용하면 검색, 자동완성, 실시간 검증 같은 기능의 성능이 극적으로 개선됩니다.

API 요청이 90% 이상 줄어들어 서버 비용이 절감되고, 사용자는 더 빠른 응답을 경험합니다. 또한 input의 타이핑은 즉시 반영되므로 사용자 경험도 해치지 않습니다.

실무에서는 검색창, 폼 검증, 윈도우 리사이즈 이벤트 처리, 무한 스크롤 등 다양한 곳에 활용됩니다.

실전 팁

💡 검색에는 300-500ms, 윈도우 리사이즈에는 100-200ms 지연이 적당합니다. 너무 짧으면 디바운스 효과가 없고, 너무 길면 사용자가 느리다고 느낍니다. 기능별로 적절한 지연 시간을 테스트하세요.

💡 즉시 실행이 필요한 경우 leading debounce를 구현하세요. 첫 번째 호출은 즉시 실행하고 이후 연속 호출을 무시하는 방식으로, 버튼 더블클릭 방지 등에 유용합니다.

💡 debounce와 throttle을 혼동하지 마세요. debounce는 연속 이벤트가 멈춘 후 한 번 실행하고, throttle은 일정 간격으로 주기적으로 실행합니다. 스크롤 이벤트는 throttle이 더 적합합니다.

💡 AbortController와 함께 사용하면 더 강력합니다. 새로운 검색어로 API를 호출하기 전에 이전 요청을 취소하여 race condition을 완전히 방지할 수 있습니다.

💡 로딩 인디케이터를 추가할 때는 원본 값이 변경되는 즉시 표시하세요. searchTerm !== debouncedSearchTerm일 때 로딩을 보여주면 사용자가 검색이 준비 중임을 알 수 있습니다.


5. useToggle Hook - 불리언 상태 관리

시작하며

여러분이 모달 열기/닫기, 메뉴 펼치기/접기, 다크모드 켜기/끄기 같은 기능을 구현할 때 매번 const [isOpen, setIsOpen] = useState(false)와 setIsOpen(!isOpen)을 반복하고 있나요? 불리언 토글은 매우 흔한 패턴인데, 매번 동일한 코드를 작성하는 것은 비효율적입니다.

이런 문제는 코드의 가독성을 떨어뜨리고, 실수를 유발할 수 있습니다. 예를 들어, setIsOpen(!isOpen) 대신 setIsOpen(true)를 잘못 사용하면 토글이 아니라 항상 true로만 설정됩니다.

또한 여러 토글 상태가 있을 때 각각의 setState 함수를 관리하기가 복잡해집니다. 바로 이럴 때 필요한 것이 useToggle Hook입니다.

토글 기능을 명확하고 간결한 인터페이스로 제공하여, 의도가 명확한 코드를 작성할 수 있게 합니다.

개요

간단히 말해서, useToggle은 불리언 값의 토글, 설정, 해제를 쉽게 할 수 있는 유틸리티 Hook입니다. 왜 이 Hook이 필요한지 보면, 대부분의 UI에는 수많은 토글 상태가 있습니다.

사이드바 펼침/접힘, 드롭다운 메뉴 열림/닫힘, 체크박스 선택/해제, 모달 표시/숨김 등이 모두 불리언 토글입니다. 각각에 대해 useState와 반전 로직을 작성하는 대신, useToggle로 통일하면 코드가 훨씬 깔끔해집니다.

예를 들어, 설정 페이지에서 여러 옵션 스위치를 관리할 때 일관된 패턴으로 처리할 수 있습니다. 기존에는 setValue(!value) 또는 setValue(prev => !prev)를 직접 작성했다면, 이제는 toggle() 함수를 호출하기만 하면 됩니다.

또한 setTrue(), setFalse() 같은 명시적인 함수도 제공하여 의도를 더 명확하게 표현할 수 있습니다. 이 Hook의 핵심 특징은 세 가지입니다.

첫째, 토글 로직을 캡슐화하여 재사용합니다. 둘째, 명확한 함수명(toggle, setTrue, setFalse)으로 가독성을 높입니다.

셋째, 초기값을 설정할 수 있어 유연합니다. 이러한 특징들이 불리언 상태 관리를 간단하고 명확하게 만들어줍니다.

코드 예제

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  // 토글 함수 - 현재 값을 반전
  const toggle = useCallback(() => {
    setValue(prev => !prev);
  }, []);

  // true로 설정하는 함수
  const setTrue = useCallback(() => {
    setValue(true);
  }, []);

  // false로 설정하는 함수
  const setFalse = useCallback(() => {
    setValue(false);
  }, []);

  return [value, toggle, setTrue, setFalse];
}

// 사용 예시
function Sidebar() {
  const [isOpen, toggle, open, close] = useToggle(false);

  return (
    <>
      <button onClick={toggle}>Toggle Sidebar</button>
      <button onClick={open}>Open Sidebar</button>
      <button onClick={close}>Close Sidebar</button>

      {isOpen && (
        <div className="sidebar">
          <h2>Sidebar Content</h2>
          <button onClick={close}>×</button>
        </div>
      )}
    </>
  );
}

설명

이것이 하는 일: useToggle은 불리언 상태와 함께 세 가지 제어 함수를 반환합니다. 상황에 맞는 함수를 선택해서 사용하면 코드의 의도가 명확해집니다.

첫 번째로, useState로 기본적인 불리언 상태를 만듭니다. initialValue 파라미터로 초기값을 받는데, 기본값은 false입니다.

이렇게 하면 대부분의 경우(모달 닫힘, 메뉴 접힘 등) 파라미터 없이 사용할 수 있고, 필요한 경우에만 true를 전달하면 됩니다. 그 다음으로, 세 가지 함수를 useCallback으로 감싸서 생성합니다.

toggle은 현재 값의 반대로 바꾸고, setTrue와 setFalse는 명시적으로 true 또는 false로 설정합니다. useCallback을 사용하는 이유는 이 함수들이 자식 컴포넌트의 prop으로 전달될 때 불필요한 리렌더링을 방지하기 위함입니다.

의존성 배열이 빈 배열이므로 컴포넌트 생명주기 동안 함수 참조가 변하지 않습니다. 세 번째로, toggle 함수에서 setValue(prev => !prev)를 사용하는 것에 주목하세요.

setValue(!value)로 직접 접근하지 않는 이유는, 함수형 업데이트가 항상 최신 상태를 보장하기 때문입니다. 특히 이벤트 핸들러가 비동기적으로 실행되거나 배치 업데이트될 때 중요합니다.

마지막으로, 사용 예시를 보면 배열 구조 분해를 통해 원하는 이름으로 함수를 가져올 수 있습니다. [isOpen, toggle, open, close]처럼 의미 있는 이름을 사용하면 코드를 읽을 때 무엇을 하는지 바로 알 수 있습니다.

onClick={open}은 onClick={() => setIsOpen(true)}보다 훨씬 명확합니다. 여러분이 이 Hook을 사용하면 불리언 상태 관리 코드가 극적으로 간결해집니다.

특히 모달, 드롭다운, 사이드바 같은 UI 요소가 많은 프로젝트에서 효과적입니다. 모든 토글 로직이 동일한 패턴을 따르므로 팀원들이 코드를 이해하기 쉽고, 버그 발생 가능성도 줄어듭니다.

실무에서는 설정 토글, 필터 표시/숨김, 편집 모드 전환 등 다양한 곳에 활용할 수 있습니다.

실전 팁

💡 객체를 반환하는 버전도 고려하세요. { value, toggle, setTrue, setFalse }로 반환하면 필요한 것만 선택적으로 가져올 수 있어 유연합니다. 특히 많은 함수를 제공할 때 유용합니다.

💡 toggle 함수에 optional 파라미터를 추가하면 더 강력합니다. toggle(nextValue)를 지원하면 조건부 토글이 가능해집니다. nextValue가 주어지면 그 값으로 설정하고, 없으면 반전하는 식입니다.

💡 다중 토글 상태가 있을 때는 객체로 관리하는 useToggles를 만들어보세요. { modal: false, sidebar: false }를 관리하는 Hook이면 상태가 증가해도 코드가 깔끔합니다.

💡 애니메이션과 함께 사용할 때는 상태 변경을 지연시키세요. 모달을 닫을 때 즉시 상태를 false로 하면 애니메이션이 보이지 않습니다. setTimeout으로 애니메이션 시간만큼 지연시킨 후 상태를 변경하세요.

💡 ESC 키로 닫기 같은 글로벌 이벤트와 연동할 때는 useEffect에서 이벤트 리스너를 등록하세요. 키보드 단축키로 토글을 제어하면 접근성이 향상됩니다.


6. useWindowSize Hook - 반응형 UI 대응

시작하며

여러분이 화면 크기에 따라 다른 레이아웃을 보여주거나, 모바일에서 특정 기능을 숨기거나, 차트의 크기를 조정해야 할 때 어떻게 하시나요? CSS 미디어 쿼리만으로는 부족하고, JavaScript에서 윈도우 크기를 알아야 하는 경우가 많습니다.

이런 문제는 반응형 웹 개발에서 매우 흔합니다. 예를 들어, 테이블이 너무 많은 컬럼을 가지고 있어서 작은 화면에서는 일부만 보여주고 나머지는 드롭다운으로 처리하고 싶을 때, JavaScript로 화면 크기를 체크해야 합니다.

또한 캔버스나 차트 라이브러리는 명시적인 너비/높이 값이 필요한 경우가 많습니다. 바로 이럴 때 필요한 것이 useWindowSize Hook입니다.

브라우저 윈도우 크기를 React 상태로 추적하여, 크기 변경에 따라 자동으로 컴포넌트가 업데이트되게 합니다.

개요

간단히 말해서, useWindowSize는 브라우저 윈도우의 너비와 높이를 실시간으로 추적하는 Hook입니다. 왜 이 Hook이 필요한지 보면, 현대 웹 앱은 데스크톱, 태블릿, 모바일 등 다양한 화면 크기를 지원해야 합니다.

CSS 미디어 쿼리는 스타일링에는 완벽하지만, 로직 분기에는 사용할 수 없습니다. 예를 들어, 작은 화면에서는 데이터 테이블을 카드 뷰로 완전히 바꾸고 싶다면 JavaScript에서 화면 크기를 알아야 합니다.

또한 D3.js, Chart.js 같은 시각화 라이브러리는 숫자로 된 크기 값을 요구합니다. 기존에는 useEffect에서 window.addEventListener('resize', handler)를 수동으로 설정하고, 클린업에서 제거하고, throttle이나 debounce도 직접 구현해야 했다면, 이제는 useWindowSize 한 줄로 모든 것이 해결됩니다.

이 Hook의 핵심 특징은 네 가지입니다. 첫째, 윈도우 크기가 변경될 때마다 자동으로 상태를 업데이트합니다.

둘째, 컴포넌트 언마운트 시 이벤트 리스너를 자동으로 정리합니다. 셋째, 서버사이드 렌더링(SSR)에서도 안전하게 작동합니다.

넷째, 초기 렌더링 시에도 올바른 크기를 제공합니다. 이러한 특징들이 반응형 UI 로직을 쉽게 구현할 수 있게 해줍니다.

코드 예제

function useWindowSize() {
  // 초기값 설정 - SSR 환경을 고려
  const [windowSize, setWindowSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  });

  useEffect(() => {
    // resize 이벤트 핸들러
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // 이벤트 리스너 등록
    window.addEventListener('resize', handleResize);

    // 초기 크기 설정 (hydration 이슈 방지)
    handleResize();

    // 클린업: 컴포넌트 언마운트 시 리스너 제거
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return windowSize;
}

// 사용 예시
function ResponsiveChart() {
  const { width, height } = useWindowSize();
  const isMobile = width < 768;

  return (
    <div>
      <h2>Screen: {width} x {height}</h2>
      {isMobile ? (
        <MobileChart width={width} />
      ) : (
        <DesktopChart width={width} height={height} />
      )}
    </div>
  );
}

설명

이것이 하는 일: useWindowSize는 윈도우 resize 이벤트를 리스닝하면서 현재 너비와 높이를 상태로 관리합니다. 사용자가 브라우저 창 크기를 조절하면 자동으로 컴포넌트가 리렌더링됩니다.

첫 번째로, 초기 상태를 설정할 때 typeof window !== 'undefined' 체크를 합니다. 이는 Next.js 같은 SSR 프레임워크에서 필수적입니다.

서버에서는 window 객체가 없기 때문에 이 체크가 없으면 에러가 발생합니다. 서버에서는 0을 기본값으로 사용하고, 클라이언트에서 실제 크기로 업데이트됩니다.

그 다음으로, useEffect 내부에서 handleResize 함수를 정의하고 resize 이벤트에 연결합니다. handleResize는 window.innerWidth와 innerHeight를 읽어서 상태를 업데이트하는 단순한 함수입니다.

innerWidth는 스크롤바를 포함한 뷰포트 너비이고, outerWidth보다 더 유용한 경우가 많습니다. 세 번째로, useEffect 내에서 handleResize()를 직접 호출하는 부분이 중요합니다.

이는 hydration 미스매치를 방지하기 위함입니다. SSR 환경에서 서버가 렌더링한 HTML(width: 0)과 클라이언트가 처음 실행할 때의 실제 윈도우 크기가 다를 수 있는데, 이를 즉시 동기화합니다.

마지막으로, 클린업 함수에서 이벤트 리스너를 제거합니다. 이를 빼먹으면 컴포넌트가 언마운트된 후에도 리스너가 남아서 메모리 누수가 발생합니다.

특히 SPA에서 페이지를 자주 이동하는 경우 심각한 문제가 될 수 있습니다. 여러분이 이 Hook을 사용하면 반응형 로직을 매우 쉽게 구현할 수 있습니다.

화면 크기에 따라 컴포넌트를 조건부 렌더링하거나, 차트나 지도의 크기를 동적으로 조정하거나, 모바일 전용 기능을 제어할 수 있습니다. 실무에서는 데이터 시각화, 반응형 네비게이션, 동적 그리드 레이아웃, 무한 스크롤의 threshold 계산 등에 활용됩니다.

특히 CSS만으로는 해결할 수 없는 복잡한 반응형 요구사항에 필수적입니다.

실전 팁

💡 resize 이벤트에 throttle이나 debounce를 적용하세요. 윈도우 크기를 조절할 때 초당 수십 번 이벤트가 발생하므로, 100-200ms로 제한하면 성능이 크게 향상됩니다. lodash.throttle을 사용하거나 직접 구현할 수 있습니다.

💡 breakpoint 값을 함께 반환하면 더 편리합니다. { width, height, isMobile: width < 768, isTablet: width >= 768 && width < 1024, isDesktop: width >= 1024 } 형태로 반환하면 조건 분기가 명확해집니다.

💡 window.matchMedia를 사용하는 버전도 고려하세요. CSS 미디어 쿼리와 정확히 동일한 조건을 JavaScript에서도 사용할 수 있어 일관성이 향상됩니다. matchMedia('(max-width: 768px)').matches로 체크할 수 있습니다.

💡 차트나 캔버스에 사용할 때는 부모 요소의 크기를 추적하는 것이 더 나을 수 있습니다. ResizeObserver API를 사용하는 useElementSize Hook을 만들면 특정 div의 크기 변경을 감지할 수 있습니다.

💡 초기 렌더링 시 깜빡임을 방지하려면 CSS 변수와 연동하세요. :root { --window-width: 100vw; }를 설정하고 JavaScript에서도 같은 값을 사용하면 SSR과 CSR 사이의 불일치를 최소화할 수 있습니다.


7. usePrevious Hook - 이전 값 추적

시작하며

여러분이 상태나 props가 어떻게 변경되었는지 비교해야 할 때가 있나요? 예를 들어, 사용자 ID가 변경되었을 때만 API를 다시 호출하거나, 이전 값과 현재 값을 비교해서 증가/감소를 표시하고 싶을 때 이전 값을 추적해야 합니다.

이런 문제는 실무에서 자주 마주칩니다. 클래스 컴포넌트에서는 componentDidUpdate에서 prevProps, prevState를 사용할 수 있었지만, 함수형 컴포넌트에서는 기본적으로 이전 값에 접근할 방법이 없습니다.

useEffect의 의존성 배열이 변경을 감지하긴 하지만, 이전 값 자체를 얻을 수는 없습니다. 바로 이럴 때 필요한 것이 usePrevious Hook입니다.

useRef를 활용하여 렌더링 사이에 값을 보존하고, 이전 렌더링의 값을 현재 렌더링에서 사용할 수 있게 합니다.

개요

간단히 말해서, usePrevious는 값의 이전 버전을 기억하여 반환하는 Hook입니다. 현재 값과 이전 값을 비교하여 변경 사항을 감지할 수 있습니다.

왜 이 Hook이 필요한지 실무 관점에서 보면, 값의 변화를 추적해야 하는 경우가 많습니다. 예를 들어, 대시보드에서 판매량이 증가했는지 감소했는지 화살표로 표시하려면 이전 값과 비교해야 합니다.

또한 불필요한 API 호출을 방지하기 위해 실제로 값이 변경되었을 때만 작업을 수행하고 싶을 때도 이전 값이 필요합니다. 기존에는 useState로 별도의 prevValue 상태를 만들고 useEffect에서 수동으로 업데이트해야 했다면, 이제는 usePrevious로 자동으로 이전 값을 추적할 수 있습니다.

상태 관리 코드가 줄어들고 로직이 명확해집니다. 이 Hook의 핵심 특징은 세 가지입니다.

첫째, useRef를 사용하여 리렌더링 사이에 값을 보존합니다. 둘째, 렌더링 타이밍을 고려하여 올바른 이전 값을 반환합니다.

셋째, 모든 타입의 값(원시값, 객체, 배열)을 추적할 수 있습니다. 이러한 특징들이 값의 변화를 쉽게 추적하고 비교할 수 있게 해줍니다.

코드 예제

function usePrevious(value) {
  // ref 생성 - 렌더링 간에 값을 유지
  const ref = useRef();

  useEffect(() => {
    // 렌더링 후에 ref를 업데이트
    ref.current = value;
  }, [value]); // value가 변경될 때마다 실행

  // 현재 렌더링에서는 이전 값을 반환
  return ref.current;
}

// 사용 예시
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <h2>Current: {count}</h2>
      <h3>Previous: {prevCount}</h3>
      {prevCount !== undefined && (
        <p>
          Changed by: {count - prevCount}
          {count > prevCount ? ' ↑' : count < prevCount ? ' ↓' : ' −'}
        </p>
      )}
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

설명

이것이 하는 일: usePrevious는 useRef를 활용하여 이전 렌더링의 값을 현재 렌더링에서 사용할 수 있게 만듭니다. 렌더링 타이밍의 특성을 이용한 영리한 패턴입니다.

첫 번째로, useRef를 사용하여 값을 저장합니다. ref는 useState와 달리 값이 변경되어도 리렌더링을 트리거하지 않으며, 렌더링 사이에 값을 유지합니다.

이것이 핵심입니다. ref.current는 컴포넌트의 생명주기 동안 계속 살아있는 변수 같은 것입니다.

그 다음으로, useEffect에서 ref.current를 업데이트합니다. useEffect는 렌더링이 완료된 후에 실행되므로, 현재 렌더링에서는 아직 이전 값이 ref.current에 있고, useEffect가 실행된 후에야 새 값으로 업데이트됩니다.

이 타이밍 차이가 바로 "이전 값"을 만들어냅니다. 세 번째로, 실행 흐름을 자세히 보면 이렇습니다.

  1. 컴포넌트가 렌더링되면서 usePrevious가 호출되고 ref.current(이전 값)를 반환합니다. 2) 렌더링이 완료됩니다.

  2. useEffect가 실행되어 ref.current를 새 값으로 업데이트합니다. 4) 다음 렌더링 때는 이 업데이트된 값이 "이전 값"이 됩니다.

마지막으로, 사용 예시를 보면 첫 렌더링에서 prevCount는 undefined입니다. ref.current의 초기값을 설정하지 않았기 때문입니다.

따라서 prevCount !== undefined 체크로 첫 렌더링을 구분합니다. 두 번째 렌더링부터는 실제 이전 값이 표시됩니다.

여러분이 이 Hook을 사용하면 값의 변화를 쉽게 추적하고 시각화할 수 있습니다. 증가/감소 표시, 변경 애니메이션, 조건부 효과 실행 등 다양하게 활용됩니다.

실무에서는 폼 필드의 변경 감지(저장 안 됨 경고), 차트의 값 변화 표시(상승/하락 화살표), props 변경 감지(userId가 실제로 바뀌었을 때만 refetch), 애니메이션 트리거(값이 증가할 때만 특별한 효과) 등에 사용됩니다.

실전 팁

💡 초기값을 설정하고 싶다면 useRef(initialValue)를 사용하세요. 첫 렌더링에서도 undefined 대신 의미 있는 값을 가질 수 있습니다. 하지만 대부분의 경우 undefined 체크로 충분합니다.

💡 객체나 배열을 추적할 때는 깊은 비교가 필요할 수 있습니다. usePrevious는 참조만 저장하므로, 객체의 속성 변화를 감지하려면 JSON.stringify나 deep-equal 라이브러리를 사용해야 합니다.

💡 여러 값을 추적하려면 객체로 묶으세요. usePrevious({ userId, productId })처럼 사용하면 관련된 값들을 한 번에 추적할 수 있습니다. 개별적으로 여러 번 호출하는 것보다 효율적입니다.

💡 변경 감지만 필요하다면 useEffect의 의존성 배열로 충분합니다. usePrevious는 실제 이전 값 자체가 필요할 때 사용하세요. 단순히 변경 여부만 확인하는 것은 오버엔지니어링일 수 있습니다.

💡 디버깅에 매우 유용합니다. 예상치 못한 리렌더링이 발생할 때 usePrevious(props)로 어떤 prop이 변경되었는지 쉽게 찾을 수 있습니다. console.log에서 이전 값과 현재 값을 비교하면 원인을 빠르게 파악할 수 있습니다.


8. useInterval Hook - 안전한 타이머 관리

시작하며

여러분이 실시간 시계, 카운트다운, 자동 슬라이드쇼, 폴링 같은 주기적인 작업을 구현할 때 setInterval을 사용하고 있나요? React 컴포넌트에서 setInterval을 직접 사용하면 클로저 문제, 메모리 누수, 타이머 정리 누락 같은 까다로운 버그가 발생하기 쉽습니다.

이런 문제는 실제로 많은 개발자를 괴롭힙니다. 예를 들어, setInterval 콜백에서 state를 참조하면 항상 초기값만 보이는 stale closure 문제가 발생합니다.

또한 컴포넌트가 언마운트될 때 clearInterval을 빼먹으면 백그라운드에서 타이머가 계속 실행되어 메모리를 낭비하고 예상치 못한 setState 호출로 에러를 일으킵니다. 바로 이럴 때 필요한 것이 useInterval Hook입니다.

React의 생명주기와 완벽하게 통합되어 안전하고 예측 가능한 방식으로 주기적인 작업을 수행할 수 있습니다.

개요

간단히 말해서, useInterval은 setInterval의 React 친화적인 버전으로, 클로저 문제를 해결하고 자동 정리 기능을 제공하는 Hook입니다. 왜 이 Hook이 필요한지 보면, 주기적인 작업은 웹 앱에서 매우 흔합니다.

실시간 데이터 업데이트, 자동 저장, 세션 타임아웃 체크, 알림 폴링 등이 모두 일정 간격으로 실행되어야 합니다. 하지만 일반 setInterval은 React의 리렌더링 메커니즘과 잘 맞지 않아 복잡한 보일러플레이트가 필요합니다.

예를 들어, 5초마다 서버에서 새 메시지를 확인하는 기능을 구현할 때, 안전하게 처리하려면 useEffect, useRef, cleanup 등을 조합해야 합니다. 기존에는 useEffect에서 setInterval을 만들고, 의존성 배열을 관리하고, cleanup에서 clearInterval을 호출하는 복잡한 코드를 작성해야 했다면, 이제는 useInterval로 간단하게 처리할 수 있습니다.

콜백 함수와 간격만 전달하면 모든 것이 자동으로 처리됩니다. 이 Hook의 핵심 특징은 네 가지입니다.

첫째, 최신 state와 props에 항상 접근할 수 있습니다(클로저 문제 해결). 둘째, 컴포넌트 언마운트 시 자동으로 타이머를 정리합니다.

셋째, delay를 null로 설정하여 타이머를 일시정지할 수 있습니다. 넷째, delay나 callback이 변경되면 자동으로 타이머를 재설정합니다.

이러한 특징들이 안전하고 유연한 주기적 작업을 가능하게 합니다.

코드 예제

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // 최신 callback을 ref에 저장 (클로저 문제 해결)
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // 인터벌 설정
  useEffect(() => {
    // delay가 null이면 타이머를 설정하지 않음 (일시정지 기능)
    if (delay === null) {
      return;
    }

    function tick() {
      // ref에 저장된 최신 callback 실행
      savedCallback.current();
    }

    const id = setInterval(tick, delay);

    // 클린업: 컴포넌트 언마운트 또는 delay 변경 시 타이머 제거
    return () => clearInterval(id);
  }, [delay]); // delay만 의존성으로 (callback은 제외)
}

// 사용 예시
function Countdown() {
  const [seconds, setSeconds] = useState(60);
  const [isRunning, setIsRunning] = useState(true);

  useInterval(
    () => {
      if (seconds > 0) {
        setSeconds(seconds - 1);
      } else {
        setIsRunning(false);
      }
    },
    isRunning ? 1000 : null  // null이면 타이머 정지
  );

  return (
    <div>
      <h2>Time remaining: {seconds}s</h2>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Resume'}
      </button>
    </div>
  );
}

설명

이것이 하는 일: useInterval은 주기적으로 실행되는 콜백을 설정하되, React의 렌더링 사이클과 안전하게 통합합니다. 두 개의 useEffect를 영리하게 조합하여 일반 setInterval의 문제점을 모두 해결합니다.

첫 번째로, savedCallback이라는 ref를 만들어서 최신 콜백 함수를 저장합니다. 이것이 핵심 트릭입니다.

setInterval의 콜백은 생성 시점의 클로저를 캡처하므로, 나중에 state가 변경되어도 초기값만 보입니다. 하지만 ref는 항상 최신 값을 가리키므로, savedCallback.current()를 호출하면 최신 state에 접근할 수 있습니다.

그 다음으로, 첫 번째 useEffect에서 callback이 변경될 때마다 ref를 업데이트합니다. 이렇게 하면 컴포넌트가 리렌더링되어 새로운 callback이 전달되어도, 기존 타이머는 그대로 유지하면서 실행되는 함수만 최신 버전으로 바뀝니다.

타이머를 재생성하지 않으므로 효율적입니다. 세 번째로, 두 번째 useEffect에서 실제 setInterval을 설정합니다.

delay === null 체크는 타이머를 일시정지하는 기능으로, null을 전달하면 타이머가 설정되지 않습니다. 이는 일시정지/재개 기능을 구현할 때 매우 유용합니다.

의존성 배열에 delay만 넣고 callback은 제외한 것도 중요합니다. callback이 변경되어도 타이머를 재설정하지 않고, ref만 업데이트되기 때문입니다.

마지막으로, cleanup 함수에서 clearInterval(id)를 호출하여 타이머를 정리합니다. 컴포넌트가 언마운트되거나 delay가 변경되면 이전 타이머를 제거하고 새로 설정합니다.

이렇게 메모리 누수를 방지합니다. 여러분이 이 Hook을 사용하면 주기적인 작업을 안전하고 간단하게 구현할 수 있습니다.

클로저 문제를 걱정할 필요 없이 항상 최신 state에 접근하고, 타이머 정리도 자동으로 처리됩니다. 실무에서는 실시간 대시보드 업데이트, 자동 저장 기능, 세션 갱신, 폴링 기반 알림, 게임 타이머, 슬라이드쇼 등 다양한 곳에 활용됩니다.

특히 null delay로 일시정지/재개를 쉽게 구현할 수 있어 사용자 제어가 필요한 타이머에 완벽합니다.

실전 팁

💡 delay를 동적으로 변경하면 타이머 속도를 조절할 수 있습니다. 예를 들어 게임에서 레벨이 올라갈수록 delay를 줄여서 난이도를 높일 수 있습니다. delay가 변경되면 자동으로 타이머가 재설정됩니다.

💡 API 폴링에 사용할 때는 exponential backoff를 고려하세요. 에러가 발생하면 delay를 점진적으로 늘려서(1초, 2초, 4초...) 서버 부하를 줄이고, 성공하면 다시 원래 간격으로 돌아가는 패턴이 유용합니다.

💡 setTimeout이 필요하다면 useTimeout Hook을 만들 수 있습니다. useInterval과 거의 동일하지만 setInterval 대신 setTimeout을 사용하고, 한 번만 실행되도록 합니다. 지연된 작업에 유용합니다.

💡 타이머가 백그라운드 탭에서도 정확히 작동하길 원하지 않는다면 Page Visibility API를 추가하세요. document.hidden을 체크하여 탭이 활성화되어 있을 때만 타이머를 실행하면 리소스를 절약할 수 있습니다.

💡 requestAnimationFrame이 더 적합한 경우도 있습니다. 60fps로 부드러운 애니메이션이 필요하다면 setInterval 대신 useAnimationFrame Hook을 만들어 사용하세요. 브라우저의 렌더링 사이클과 동기화되어 더 부드럽습니다.


9. useOnClickOutside Hook - 외부 클릭 감지

시작하며

여러분이 모달, 드롭다운 메뉴, 팝오버, 툴팁 같은 UI 요소를 만들 때 사용자가 바깥을 클릭하면 닫히게 하고 싶으신가요? 이는 매우 일반적인 UX 패턴이지만, 직접 구현하려면 이벤트 리스너 관리, ref 처리, 클린업 등 신경 써야 할 부분이 많습니다.

이런 문제는 사용자 경험에 직접 영향을 미칩니다. 드롭다운이 열려있는데 바깥을 클릭해도 닫히지 않으면 사용자는 불편함을 느낍니다.

또한 여러 팝업이 동시에 열려있을 때 올바른 것만 닫히게 하려면 로직이 복잡해집니다. ESC 키 지원, 포커스 관리 등도 함께 고려해야 합니다.

바로 이럴 때 필요한 것이 useOnClickOutside Hook입니다. 특정 요소 바깥의 클릭을 감지하여 콜백을 실행하는 깔끔한 인터페이스를 제공합니다.

개요

간단히 말해서, useOnClickOutside는 ref로 지정된 요소 외부를 클릭했을 때 콜백 함수를 실행하는 Hook입니다. 왜 이 Hook이 필요한지 보면, 대부분의 UI 라이브러리에서 오버레이 컴포넌트는 외부 클릭으로 닫힙니다.

사용자가 모달이나 드롭다운을 사용한 후 다른 곳을 클릭하는 것은 "이제 이것을 닫고 싶다"는 자연스러운 신호입니다. 이를 감지하려면 document에 클릭 이벤트 리스너를 달고, 클릭된 요소가 우리 컴포넌트 내부인지 외부인지 확인해야 하는데, 매번 이 로직을 반복하는 것은 비효율적입니다.

기존에는 useEffect에서 document.addEventListener('mousedown', handler)를 설정하고, handler 내부에서 ref.current.contains(event.target)을 체크하고, cleanup에서 리스너를 제거하는 복잡한 코드를 작성해야 했다면, 이제는 useOnClickOutside(ref, callback) 한 줄로 해결됩니다. 이 Hook의 핵심 특징은 네 가지입니다.

첫째, ref로 지정된 요소와 그 자식들의 클릭은 무시합니다. 둘째, 외부 클릭만 정확하게 감지하여 콜백을 실행합니다.

셋째, 컴포넌트 언마운트 시 자동으로 이벤트 리스너를 정리합니다. 넷째, mousedown과 touchstart를 모두 지원하여 모바일에서도 작동합니다.

이러한 특징들이 직관적이고 안정적인 외부 클릭 처리를 가능하게 합니다.

코드 예제

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      // ref가 없거나, 클릭이 내부에서 발생했으면 무시
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }

      // 외부 클릭이므로 handler 실행
      handler(event);
    };

    // 마우스와 터치 이벤트 모두 리스닝
    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    // 클린업: 이벤트 리스너 제거
    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]); // ref와 handler가 변경되면 재설정
}

// 사용 예시
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useOnClickOutside(dropdownRef, () => {
    setIsOpen(false);
  });

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle Menu
      </button>
      {isOpen && (
        <ul className="menu">
          <li>Option 1</li>
          <li>Option 2</li>
          <li>Option 3</li>
        </ul>
      )}
    </div>
  );
}

설명

이것이 하는 일: useOnClickOutside는 document 레벨에서 모든 클릭을 감시하다가, ref로 지정된 요소 바깥을 클릭했을 때만 콜백을 실행합니다. 내부 클릭은 무시하는 것이 핵심입니다.

첫 번째로, useEffect 내부에서 listener 함수를 정의합니다. 이 함수가 모든 클릭 이벤트를 처리하는데, 먼저 ref.current가 존재하는지 확인합니다.

컴포넌트가 조건부 렌더링되거나 아직 마운트되지 않았을 때 ref.current가 null일 수 있기 때문입니다. 그 다음으로, ref.current.contains(event.target) 체크가 핵심 로직입니다.

contains는 DOM API로, 특정 노드가 자신 또는 자손인지 확인합니다. 클릭된 요소(event.target)가 ref 요소의 내부라면 true를 반환하고, 이 경우 early return으로 아무것도 하지 않습니다.

외부 클릭일 때만 handler가 실행됩니다. 세 번째로, mousedown과 touchstart 두 이벤트를 모두 리스닝하는 이유는 크로스 플랫폼 지원을 위함입니다.

click 이벤트 대신 mousedown을 사용하는 이유는 타이밍 때문입니다. click은 mousedown과 mouseup이 모두 발생한 후에 발생하므로, 드래그 같은 상황을 잘못 감지할 수 있습니다.

mousedown이 더 정확합니다. 마지막으로, cleanup 함수에서 두 이벤트 리스너를 모두 제거합니다.

이를 빼먹으면 컴포넌트가 언마운트된 후에도 리스너가 document에 남아서 메모리 누수가 발생하고, 더 이상 존재하지 않는 ref에 접근하려다 에러가 날 수 있습니다. 여러분이 이 Hook을 사용하면 모달, 드롭다운, 팝오버, 사이드 패널 등 오버레이 UI를 쉽게 구현할 수 있습니다.

바깥 클릭으로 닫기는 사용자가 기대하는 표준 동작이므로 UX가 향상됩니다. 실무에서는 컨텍스트 메뉴, 자동완성 드롭다운, 날짜 선택기, 색상 피커, 설정 패널 등 거의 모든 팝업 요소에 활용됩니다.

특히 여러 중첩된 팝업이 있을 때도 각각에 ref를 설정하면 올바르게 작동합니다.

실전 팁

💡 ESC 키 지원도 함께 구현하세요. useEffect에서 keydown 이벤트를 리스닝하고 event.key === 'Escape'일 때 handler를 호출하면 접근성이 크게 향상됩니다. 키보드 사용자를 고려하는 것이 중요합니다.

💡 handler를 useCallback으로 감싸세요. handler가 매 렌더링마다 새로 생성되면 useEffect가 계속 재실행되어 성능이 저하됩니다. useCallback으로 메모이제이션하면 불필요한 리스너 재등록을 방지할 수 있습니다.

💡 포탈(Portal)을 사용하는 모달에도 잘 작동합니다. ref는 DOM 요소를 가리키므로, 포탈로 렌더링된 요소도 정확히 감지합니다. ReactDOM.createPortal과 함께 사용할 때 문제없습니다.

💡 여러 ref를 지원하도록 확장할 수 있습니다. refs 배열을 받아서 refs.some(ref => ref.current?.contains(target)) 식으로 체크하면, 여러 요소 중 하나라도 클릭하면 무시하는 패턴을 만들 수 있습니다.

💡 애니메이션과 함께 사용할 때는 지연을 고려하세요. 모달이 열리는 애니메이션 중에 바깥을 클릭하면 즉시 닫히는 것을 방지하려면, 짧은 delay 후에 리스너를 등록하거나 isAnimating 플래그를 체크하세요.


10. useMediaQuery Hook - 미디어 쿼리 반응

시작하며

여러분이 반응형 웹 디자인을 구현할 때 CSS 미디어 쿼리만으로는 부족한 경우가 있나요? JavaScript에서 화면 크기나 기기 특성에 따라 다른 컴포넌트를 렌더링하거나, 다른 로직을 실행해야 할 때가 많습니다.

이런 문제는 현대 웹 개발에서 매우 흔합니다. 예를 들어, 데스크톱에서는 복잡한 데이터 테이블을 보여주고, 모바일에서는 카드 리스트로 완전히 다른 UI를 렌더링하고 싶을 때, CSS만으로는 불가능합니다.

또한 다크모드 감지, 인쇄 모드, 터치 스크린 여부, 네트워크 상태 등 다양한 미디어 쿼리를 JavaScript에서 사용해야 하는 경우가 있습니다. 바로 이럴 때 필요한 것이 useMediaQuery Hook입니다.

CSS 미디어 쿼리를 React 상태로 변환하여, 화면 크기나 기기 특성에 따라 반응하는 컴포넌트를 쉽게 만들 수 있습니다.

개요

간단히 말해서, useMediaQuery는 CSS 미디어 쿼리를 평가하여 true/false를 반환하고, 조건이 변경되면 자동으로 업데이트되는 Hook입니다. 왜 이 Hook이 필요한지 보면, 반응형 디자인은 CSS로 스타일을 조정하는 것을 넘어섭니다.

때로는 완전히 다른 컴포넌트를 렌더링하거나, 모바일에서 무거운 기능을 비활성화하거나, 터치 인터페이스에 맞는 UX를 제공해야 합니다. CSS는 DOM 구조를 변경할 수 없고 JavaScript 로직을 제어할 수도 없으므로, JavaScript에서 미디어 쿼리를 평가할 방법이 필요합니다.

기존에는 window.matchMedia API를 직접 사용하고, change 이벤트를 리스닝하고, React 상태와 수동으로 동기화해야 했다면, 이제는 useMediaQuery('(max-width: 768px)') 한 줄로 모바일 여부를 알 수 있습니다. CSS와 정확히 동일한 문법을 사용하므로 혼동도 없습니다.

이 Hook의 핵심 특징은 네 가지입니다. 첫째, CSS 미디어 쿼리 문법을 그대로 사용합니다.

둘째, 조건이 변경될 때 자동으로 리렌더링을 트리거합니다. 셋째, SSR 환경에서도 안전하게 작동합니다.

넷째, 다양한 미디어 특성(화면 크기, 방향, 다크모드 등)을 모두 지원합니다. 이러한 특징들이 진정한 반응형 React 앱을 만들 수 있게 해줍니다.

코드 예제

function useMediaQuery(query) {
  // SSR을 고려한 초기값 설정
  const [matches, setMatches] = useState(() => {
    if (typeof window !== 'undefined') {
      return window.matchMedia(query).matches;
    }
    return false;
  });

  useEffect(() => {
    // MediaQueryList 객체 생성
    const mediaQuery = window.matchMedia(query);

    // 현재 상태로 업데이트 (hydration 미스매치 방지)
    setMatches(mediaQuery.matches);

    // 변경 감지 핸들러
    const handler = (event) => {
      setMatches(event.matches);
    };

    // 이벤트 리스너 등록 (최신 API 사용)
    mediaQuery.addEventListener('change', handler);

    // 클린업: 리스너 제거
    return () => {
      mediaQuery.removeEventListener('change', handler);
    };
  }, [query]); // query가 변경되면 재설정

  return matches;
}

// 사용 예시
function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
  const isPortrait = useMediaQuery('(orientation: portrait)');

  return (
    <div className={isDarkMode ? 'dark' : 'light'}>
      {isMobile ? (
        <MobileNavigation />
      ) : (
        <DesktopNavigation />
      )}
      <h2>
        Device: {isMobile ? 'Mobile' : 'Desktop'}
        {isPortrait && ' (Portrait)'}
      </h2>
    </div>
  );
}

설명

이것이 하는 일: useMediaQuery는 window.matchMedia API를 React 상태와 통합하여, 미디어 쿼리 조건의 변화를 자동으로 추적합니다. 화면을 리사이즈하거나 다크모드를 변경하면 즉시 컴포넌트가 업데이트됩니다.

첫 번째로, useState의 초기값을 함수로 설정하여 lazy initialization을 사용합니다. typeof window !== 'undefined' 체크는 Next.js 같은 SSR 환경에서 필수입니다.

서버에서는 window가 없으므로 기본값 false를 사용하고, 클라이언트에서는 실제 matchMedia 결과를 사용합니다. 그 다음으로, useEffect 내부에서 window.matchMedia(query)를 호출하여 MediaQueryList 객체를 얻습니다.

이 객체는 현재 쿼리가 매치되는지(.matches 속성)와 변경을 감지하는 이벤트 리스너를 제공합니다. useEffect 시작 부분에서 setMatches(mediaQuery.matches)를 호출하는 것은 hydration 미스매치를 방지하기 위함입니다.

세 번째로, change 이벤트 리스너를 등록합니다. 미디어 쿼리 조건이 변경될 때마다(예: 화면을 리사이즈하여 768px을 넘거나 내려가면) handler가 호출되어 event.matches로 상태를 업데이트합니다.

addEventListener를 사용하는 것이 현대적인 방식이며, 오래된 브라우저에서는 addListener도 있지만 deprecated되었습니다. 마지막으로, cleanup 함수에서 이벤트 리스너를 제거합니다.

query가 변경되면 이전 리스너를 제거하고 새 쿼리로 리스너를 재등록합니다. 예를 들어 breakpoint를 동적으로 변경하는 경우를 대비한 것입니다.

여러분이 이 Hook을 사용하면 진정한 반응형 React 앱을 만들 수 있습니다. 단순히 CSS로 스타일을 바꾸는 것을 넘어, 화면 크기에 따라 완전히 다른 컴포넌트 트리를 렌더링할 수 있습니다.

실무에서는 모바일/데스크톱 네비게이션 분기, 다크모드 감지, 터치스크린 감지((hover: none)), 인쇄 모드 처리(print), 느린 네트워크 감지(prefers-reduced-data), 고해상도 디스플레이 감지(min-resolution) 등 다양하게 활용됩니다. CSS 미디어 쿼리로 할 수 있는 모든 것을 JavaScript 로직에서도 사용할 수 있게 됩니다.

실전 팁

💡 자주 사용하는 breakpoint를 미리 정의한 Hook을 만드세요. useIsMobile(), useIsTablet(), useIsDesktop() 같은 래퍼를 만들면 매번 쿼리 문자열을 작성하지 않아도 되고, breakpoint 변경 시 한 곳만 수정하면 됩니다.

💡 여러 미디어 쿼리를 조합할 수 있습니다. '(min-width: 768px) and (orientation: landscape)' 처럼 복잡한 조건도 지원하므로, CSS와 정확히 동일한 표현력을 가집니다.

💡 prefers-color-scheme으로 시스템 다크모드를 감지하세요. 사용자의 OS 설정을 자동으로 반영하여 UX를 향상시킬 수 있습니다. isDarkMode && <DarkModeStyles /> 식으로 사용합니다.

💡 prefers-reduced-motion을 체크하여 접근성을 개선하세요. 사용자가 애니메이션 감소를 선호하면 애니메이션을 비활성화하거나 단순화하여 멀미나 주의 산만을 방지할 수 있습니다.

💡 SSR 초기 렌더링과 클라이언트 렌더링의 차이를 고려하세요. 서버는 항상 false를 반환하므로, SEO가 중요한 콘텐츠는 초기에 모두 렌더링하고 클라이언트에서 선택적으로 숨기는 것이 좋습니다.


#React#CustomHooks#useState#useEffect#Hooks