icon

안동민 개발노트

4장 : React 훅 기초

커스텀 훅 만들기


우리는 useReducer 훅을 통해 복잡한 상태 관리 로직을 효율적으로 다루는 방법을 배웠습니다. 이제 4장 핵심 React 훅의 마지막 주제인 커스텀 훅(Custom Hook)을 알아보겠습니다.

커스텀 훅은 컴포넌트 간에 상태 관련 로직(stateful logic)을 재사용할 수 있도록 해주는 메커니즘입니다. 여러 컴포넌트에서 동일하거나 유사한 로직을 반복해야 할 때, 로직을 별도의 함수로 추출해 use로 시작하는 이름으로 만드는 방식이 바로 커스텀 훅입니다.

클래스형 컴포넌트에서는 render propsHigher-Order Components (HOCs) 같은 패턴으로 로직을 재사용했지만, 이 방식은 복잡성을 키우는 단점이 있었습니다. 훅은 이러한 문제를 줄이고, 더 간결하고 직관적인 방식으로 로직 재사용을 가능하게 합니다.


왜 커스텀 훅이 필요한가?

리액트 애플리케이션을 개발하다 보면 다음과 같은 상황에 자주 직면합니다.

  • 반복되는 로직: 여러 컴포넌트에서 동일한 useState, useEffect 등의 훅 조합을 사용하여 비슷한 기능을 구현해야 할 때. (예: 특정 데이터를 불러오는 로직, 입력 폼의 값 관리, 마우스 위치 추적 등)
  • 복잡한 컴포넌트 분리: 하나의 컴포넌트가 너무 많은 상태 로직을 포함하여 가독성이 떨어지고 유지보수가 어려워질 때.
  • 로직 재사용성 향상: 특정 기능을 독립적인 모듈로 만들어 다른 프로젝트에서도 쉽게 가져다 쓰고 싶을 때.

커스텀 훅은 이러한 문제들을 해결하여 코드의 재사용성, 가독성, 유지보수성을 크게 향상시킵니다.


커스텀 훅 만들기 규칙

커스텀 훅을 만들 때는 다음과 같은 두 가지 규칙만 지키면 됩니다.

이름 규칙: 커스텀 훅의 이름은 반드시 use로 시작해야 합니다. (예: useToggle, useFetch, useLocalStorage)

  • 이 규칙은 리액트가 훅의 규칙(Hooks Rule)을 강제하고, 개발자 도구에서 훅의 동작을 올바르게 인식하도록 돕습니다.

내부에서 다른 훅 호출: 커스텀 훅 내부에서는 다른 리액트 훅(useState, useEffect, useContext, useRef 등)을 호출할 수 있습니다.


간단한 커스텀 훅 예제: useToggle

가장 간단한 커스텀 훅 중 하나인 useToggle을 만들어 봅시다. 이 훅은 불리언(boolean) 상태를 관리하며, 상태를 토글하는 함수를 반환합니다.

useToggle.js 파일 생성

src/hooks 폴더를 생성하고 그 안에 useToggle.js 파일을 만듭니다.

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

function useToggle(initialValue = false) { // (1) 'use'로 시작하는 함수 이름
  const [value, setValue] = useState(initialValue); // (2) 내부에서 useState 훅 사용

  // (3) 상태를 토글하는 함수 정의 (useCallback으로 최적화)
  const toggle = useCallback(() => {
    setValue(prevValue => !prevValue);
  }, []); // 의존성 배열이 비어있으므로, 컴포넌트가 마운트될 때 한 번만 생성

  // (4) 외부에서 사용할 값과 함수를 배열 또는 객체로 반환
  return [value, toggle]; // 배열로 반환하는 것이 useState와 유사하여 일반적
}

export default useToggle;
  • useToggle 함수는 initialValue를 인자로 받아 useStatevalue 상태를 초기화합니다.
  • toggle 함수는 value 상태를 true에서 false로, false에서 true로 변경하는 역할을 합니다. useCallback으로 감싸서 불필요한 함수 재생성을 막아 최적화했습니다.
  • [value, toggle] 형태로 현재 상태 값과 상태 변경 함수를 배열로 반환합니다. (useState와 동일한 패턴)

useToggle 훅 사용하기

이제 이 커스텀 훅을 여러 컴포넌트에서 사용해 봅시다.

src/components/ToggleExample.js
import React from 'react';
import useToggle from '../hooks/useToggle'; // 커스텀 훅 불러오기

function ToggleExample() {
  const [isLightOn, toggleLight] = useToggle(true); // (1) useToggle 훅 사용
  const [isPanelOpen, togglePanel] = useToggle(false); // (2) 다른 상태에도 재사용

  return (
    <div style={{ border: '1px solid #FF8F00', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>useToggle 커스텀 훅 예제</h2>

      {/* 첫 번째 토글 */}
      <div style={{ marginBottom: '15px' }}>
        <p>전등 상태: {isLightOn ? '켜짐 💡' : '꺼짐 🌑'}</p>
        <button onClick={toggleLight} style={{ padding: '8px 15px', backgroundColor: '#ff8f00', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
          전등 {isLightOn ? '끄기' : '켜기'}
        </button>
      </div>

      {/* 두 번째 토글 (재사용) */}
      <div>
        <p>패널 {isPanelOpen ? '열림 ▼' : '닫힘 ▶'}</p>
        <button onClick={togglePanel} style={{ padding: '8px 15px', backgroundColor: '#ff8f00', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
          패널 {isPanelOpen ? '닫기' : '열기'}
        </button>
        {isPanelOpen && ( // isPanelOpen 상태에 따라 조건부 렌더링
          <div style={{ border: '1px dashed #ccc', padding: '10px', marginTop: '10px', backgroundColor: '#fffbe6' }}>
            <p>이것은 토글된 패널 내용입니다.</p>
          </div>
        )}
      </div>
    </div>
  );
}

export default ToggleExample;

App.jsToggleExample을 추가하여 실행해 보세요. 이제 isLightOnisPanelOpen이라는 두 개의 독립적인 불리언 상태를 useToggle 훅을 통해 간결하게 관리할 수 있습니다. 각 상태의 로직은 useToggle 내부에 캡슐화되어 있어 컴포넌트 코드가 훨씬 깔끔해집니다.


더 복잡한 커스텀 훅 예제

로컬 스토리지에 값을 저장하고 불러오는 로직은 많은 웹 애플리케이션에서 반복적으로 사용됩니다. 이 로직을 커스텀 훅으로 만들어 봅시다. 이 훅은 useStateuseEffect를 함께 사용합니다.

useLocalStorage.js 파일 생성

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

function useLocalStorage(key, initialValue) {
  // (1) 초기 상태를 계산하는 함수 (지연 초기화)
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key); // 로컬 스토리지에서 값 가져오기
      // JSON 문자열이거나 일반 문자열일 수 있으므로 파싱 시도
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue; // 에러 발생 시 초기값 반환
    }
  });

  // (2) storedValue 또는 key가 변경될 때마다 로컬 스토리지 업데이트
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue)); // 값을 JSON 문자열로 저장
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]); // key나 storedValue가 변경될 때마다 실행

  // (3) useState와 유사하게 현재 값과 setter 함수 반환
  return [storedValue, setStoredValue];
}

export default useLocalStorage;
  • useState의 초기값으로 함수를 전달하여 지연 초기화를 구현했습니다. 이 함수는 로컬 스토리지에서 값을 불러와 초기값으로 사용합니다.
  • useEffect 훅을 사용하여 storedValue 또는 key가 변경될 때마다 해당 값을 로컬 스토리지에 저장합니다. JSON.stringifyJSON.parse를 사용하여 객체나 배열도 저장할 수 있도록 했습니다.
  • [storedValue, setStoredValue] 형태로 현재 값과 값을 업데이트하는 함수를 반환합니다.

useLocalStorage 훅 사용하기

src/components/LocalStorageExample.js
import React from 'react';
import useLocalStorage from '../hooks/useLocalStorage'; // 커스텀 훅 불러오기

function LocalStorageExample() {
  // useLocalStorage 훅을 사용하여 'userName'과 'userAge' 상태를 로컬 스토리지와 동기화
  const [userName, setUserName] = useLocalStorage('userName', '게스트');
  const [userAge, setUserAge] = useLocalStorage('userAge', 25);

  return (
    <div style={{ border: '1px solid #1abc9c', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>useLocalStorage 커스텀 훅 예제</h2>

      <div style={{ marginBottom: '15px' }}>
        <label>
          이름:
          <input
            type="text"
            value={userName}
            onChange={(e) => setUserName(e.target.value)}
            style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
          />
        </label>
      </div>

      <div style={{ marginBottom: '15px' }}>
        <label>
          나이:
          <input
            type="number"
            value={userAge}
            onChange={(e) => setUserAge(Number(e.target.value))}
            style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
          />
        </label>
      </div>

      <p>
        저장된 이름: <span style={{ fontWeight: 'bold', color: '#16a085' }}>{userName}</span>
      </p>
      <p>
        저장된 나이: <span style={{ fontWeight: 'bold', color: '#16a085' }}>{userAge}</span>
      </p>
      <p style={{ fontSize: '14px', color: '#666' }}>
        브라우저를 닫았다가 다시 열어도 입력값이 유지됩니다. (로컬 스토리지 확인)
      </p>
    </div>
  );
}

export default LocalStorageExample;

App.jsLocalStorageExample을 추가하고 실행해 보세요. 입력 필드에 값을 입력한 후 페이지를 새로고침하거나 브라우저를 닫았다가 다시 열어도 값이 유지되는 것을 확인할 수 있습니다. 이제 로컬 스토리지와 연동되는 상태 관리 로직을 단 한 줄의 코드로 여러 컴포넌트에서 재사용할 수 있게 되었습니다!


커스텀 훅 전체 컴포넌트 테스트 (App.js)

src/App.js (최종 수정)
import React from 'react';
import './App.css';
import ToggleExample from './components/ToggleExample';
import LocalStorageExample from './components/LocalStorageExample';

function App() {
  return (
    <div className="App">
      <h1>커스텀 훅 만들기</h1>
      <ToggleExample />
      <hr />
      <LocalStorageExample />
    </div>
  );
}

export default App;

커스텀 훅 만들기는 여기까지입니다. 이 장에서는 커스텀 훅의 개념, 필요성, 생성 규칙을 설명하고, useToggleuseLocalStorage라는 두 가지 실용적인 예제를 통해 상태 관련 로직을 어떻게 추출하고 재사용하는지 상세하게 다루었습니다.

이제 여러분은 리액트 컴포넌트의 복잡성을 줄이고, 코드의 재사용성과 유지보수성을 극대화할 수 있는 강력한 도구인 커스텀 훅을 자유롭게 만들고 활용할 수 있게 되었습니다. 이는 함수형 컴포넌트 개발의 진정한 힘을 경험하는 중요한 단계입니다.

목차