커스텀 훅 만들기
반복되는 상태와 effect 로직을 useToggle·useLocalStorage 훅으로 추출해 컴포넌트 사이에서 재사용합니다.
우리는 useReducer 훅을 통해 복잡한 상태 관리 로직을 효율적으로 다루는 방법을 배웠습니다.
이제 4장 핵심 React 훅의 마지막 주제인 커스텀 훅(Custom Hook)을 알아보겠습니다.
커스텀 훅은 컴포넌트 간에 상태 관련 로직(stateful logic)을 재사용할 수 있도록 해주는 메커니즘입니다.
여러 컴포넌트에서 동일하거나 유사한 로직을 반복해야 할 때, 로직을 별도의 함수로 추출해 use로 시작하는 이름으로 만드는 방식이 바로 커스텀 훅입니다.
클래스형 컴포넌트에서는 render props나 Higher-Order Components (HOCs) 같은 패턴으로 로직을 재사용했지만, 이 방식은 복잡성을 키우는 단점이 있었습니다.
훅은 이러한 문제를 줄이고, 더 간결하고 직관적인 방식으로 로직 재사용을 가능하게 합니다.
왜 커스텀 훅이 필요한가?
리액트 애플리케이션을 개발하다 보면 다음과 같은 상황에 자주 직면합니다.
- 반복되는 로직: 여러 컴포넌트에서 동일한
useState,useEffect등의 훅 조합을 사용하여 비슷한 기능을 구현해야 할 때. (예: 특정 데이터를 불러오는 로직, 입력 폼의 값 관리, 마우스 위치 추적 등) - 복잡한 컴포넌트 분리: 하나의 컴포넌트가 너무 많은 상태 로직을 포함하여 가독성이 떨어지고 유지보수가 어려워질 때.
- 로직 재사용성 향상: 특정 기능을 독립적인 모듈로 만들어 다른 프로젝트에서도 쉽게 가져다 쓰고 싶을 때.
커스텀 훅은 이러한 문제들을 해결하여 코드의 재사용성, 가독성, 유지보수성을 크게 향상시킵니다.
커스텀 훅은 UI 모양보다 반복되는 상태 로직과 효과의 경계를 먼저 찾을 때 설계가 쉬워집니다.
extract · contract · verify
UI 모양이 아니라 반복되는 state·Effect·이벤트 규칙을 찾고, 입력과 반환값, 외부 시스템 경계를 공개 계약으로 정합니다.
범위: 커스텀 훅으로 추출할지, 무엇을 인자로 받고 반환할지, Effect와 cleanup을 어디까지 캡슐화할지 판단합니다.
| 확인 질문 | 추출 신호 | 공개 계약 | 검증·경계 |
|---|---|---|---|
| 반복 항목? | 여러 컴포넌트에 같은 state 전환, Effect, 이벤트 처리 규칙이 반복된다. | JSX와 스타일은 남기고 상태 관련 로직만 함수로 옮긴다. | 추출 전후 동작이 같고 각 Hook 호출의 state가 독립적인지 확인한다. |
| 정말 Hook인가? | use 이름을 붙일 함수는 일반적으로 하나 이상의 React Hook을 호출한다. |
use 다음 대문자로 이름 짓는다. 커스텀 훅과 useState·useEffect 같은 일반 Hook은 컴포넌트나 Hook의 최상위에서 호출한다. |
일반 Hook의 조건문·반복문·중첩 함수 호출은 린터로 막는다. React 19의 use(resource)는 조건문·반복문에서 호출할 수 있지만 try/catch 안에서는 호출할 수 없다. |
| 입력? | 초기값, key, id, URL, 옵션처럼 사용 지점마다 달라지는 값이 있다. | 차이를 명시적 인자로 받고 Effect가 읽는 reactive 값은 의존성에 포함한다. | 입력이 바뀔 때 의도한 재동기화만 일어나며 숨은 전역 의존성이 없는지 본다. |
| 반환? | 컴포넌트가 상태값, 파생값, setter나 명령 함수 중 일부를 사용한다. | 필요한 값만 반환한다. 배열·객체·단일 값은 사용 의미에 맞춰 선택한다. | 호출부가 내부 state나 Effect 구현을 몰라도 의도를 읽을 수 있어야 한다. |
| 외부 연결? | 구독, 타이머, 네트워크 연결, 브라우저 API와 동기화한다. | Effect setup이 지속 자원을 만들 때만 그 자원을 되돌리는 cleanup을 반환한다. | 개발 Strict Mode의 추가 setup·cleanup에서도 동일하게 동작하는지 확인한다. 일회성 setItem 쓰기에는 해제할 자원이 없다. |
| 추출 가치가 있는가? | 중복을 줄이는 동시에 컴포넌트가 구현보다 의도를 말하게 된다. | 커스텀 훅은 상태 자체가 아니라 상태 관련 로직을 공유한다. | 호출별 상태 변화, 오류 경로, 비동기 결과와 실제로 존재하는 cleanup을 테스트한다. |
- 반복 항목?
- 신호 여러 컴포넌트에 같은 state 전환, Effect, 이벤트 규칙이 반복됩니다.
- 선택 JSX는 남기고 상태 관련 로직만 추출합니다.
- 확인 추출 전후 동작과 호출별 독립 state를 검증합니다.
- 정말 Hook인가?
- 계약 다른 React Hook을 호출한다면
use다음 대문자로 이름 짓습니다. - 위치 커스텀 훅과
useState·useEffect같은 일반 Hook은 컴포넌트나 Hook의 최상위에서 호출하고 린터로 확인합니다. - 예외 React 19의
use(resource)는 조건문·반복문에서도 호출할 수 있지만try/catch안에서는 호출할 수 없습니다. - 입력?
- 계약 초기값, key, id, URL, 옵션처럼 달라지는 값을 명시적 인자로 받습니다.
- 확인 Effect가 읽는 reactive 값은 의존성에 포함하고, 의도한 재동기화만 일어나며 숨은 전역 의존성이 없는지 확인합니다.
- 반환?
- 계약 필요한 상태값, 파생값, setter나 명령 함수만 노출합니다.
- 형태 배열, 객체, 단일 값 중 사용 의미가 가장 잘 드러나는 형태를 고릅니다.
- 확인 호출부가 내부 state나 Effect 구현을 몰라도 이름과 반환 계약으로 의도를 읽을 수 있어야 합니다.
- 외부 연결?
- Effect 구독, 타이머, 연결처럼 지속 자원을 만들 때 그 자원을 되돌리는 cleanup을 반환합니다.
- 예외 일회성
localStorage.setItem쓰기는 해제할 자원을 만들지 않습니다. - 확인 개발 Strict Mode의 추가 setup·cleanup에서도 같은 의미를 유지합니다.
- 추출 가치가 있는가?
- 목표 상태 자체가 아니라 상태 관련 로직을 공유해 컴포넌트가 구현보다 의도를 말하게 합니다.
- 테스트 호출별 상태 변화, 오류·비동기 경로와 실제 cleanup을 검증합니다.
커스텀 훅은 반복되는 구현을 공유하지만 상태 자체를 공유하지는 않습니다. 같은 커스텀 훅을 두 번 호출하면 각 호출은 독립적인 상태와 Effect를 가집니다.
커스텀 훅 만들기 규칙
커스텀 훅을 만들 때는 이름뿐 아니라 Hook을 호출하는 위치까지 함께 지켜야 합니다.
이름 규칙: 커스텀 훅의 이름은 use 다음에 대문자가 오는 형태로 짓습니다. (예: useToggle, useFetch, useLocalStorage)
- 이 이름은 React와 린터가 함수 안에 Hook 호출이 있을 수 있음을 식별하게 합니다. 다른 Hook을 호출하지 않는 일반 함수라면 혼동을 피하도록
use접두사를 붙이지 않는 편이 좋습니다.
호출 위치 규칙: 커스텀 훅과 useState, useEffect, useContext, useRef 같은 일반 Hook은 컴포넌트나 다른 커스텀 훅의 최상위에서 호출합니다. 조건문, 반복문, 중첩 함수 안에서 호출하면 안 됩니다. React 19의 use(resource)는 컴포넌트나 커스텀 훅 안이라면 조건문과 반복문에서도 호출할 수 있지만, try/catch 안에서는 호출할 수 없습니다.
간단한 커스텀 훅 예제: useToggle
가장 간단한 커스텀 훅 중 하나인 useToggle을 만들어 봅시다.
이 훅은 불리언(boolean) 상태를 관리하며, 상태를 토글하는 함수를 반환합니다.
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) 의존성이 바뀌지 않는 동안 같은 함수 참조를 유지
const toggle = useCallback(() => {
setValue(prevValue => !prevValue);
}, []);
// (4) 외부에서 사용할 값과 함수를 배열 또는 객체로 반환
return [value, toggle]; // 배열로 반환하는 것이 useState와 유사하여 일반적
}
export default useToggle;useToggle함수는initialValue를 인자로 받아useState로value상태를 초기화합니다.toggle함수는 함수형 updater를 사용해 이전 불리언 값을 반대로 바꿉니다. 여기서는useCallback이 의존성이 바뀌지 않는 동안 같은 함수 참조를 유지하지만, 단순히 감쌌다는 이유만으로 성능이 좋아진다고 단정할 수는 없습니다.[value, toggle]형태로 현재 상태 값과 상태 변경 함수를 배열로 반환합니다. 이 예제는useState와 비슷한 배열 계약을 선택했지만, 커스텀 훅은 사용 지점에 맞춰 값, 배열, 객체 등 필요한 형태를 반환할 수 있습니다.
useToggle 훅 사용하기
이제 이 커스텀 훅을 여러 컴포넌트에서 사용해 봅시다.
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.js에 ToggleExample을 추가하여 실행해 보세요.
이제 isLightOn과 isPanelOpen이라는 두 개의 독립적인 불리언 상태를 useToggle 훅을 통해 간결하게 관리할 수 있습니다.
각 상태의 로직은 useToggle 내부에 캡슐화되어 있어 컴포넌트 코드가 훨씬 깔끔해집니다.
더 복잡한 커스텀 훅 예제
아래 다이어그램은 useLocalStorage가 초기값을 읽고, React state와 브라우저 저장소를 함께 동기화하는 흐름을 보여줍니다.
custom Hook · browser storage
초기 state는 렌더의 lazy initializer가 읽고, 이후 state 변경은 커밋 뒤 Effect가 현재 key에 저장합니다.
범위: 브라우저에서 실행하는 이 예제의 한 Hook 호출만 보여줍니다. key 변경 재로딩, 탭 간 동기화, SSR hydration은 자동으로 제공하지 않습니다.
-
초기 렌더에서 저장값 읽기
lazy initializer가
getItem(key)을 읽고 JSON을 해석합니다. 항목이 없거나 접근·파싱에 실패하면initialValue를 사용합니다. -
호출별 state 만들기
읽은 값은
storedValue가 됩니다. 같은 커스텀 훅을 여러 번 호출해도 각 state는 서로 독립적입니다. -
setter로 다음 state 예약
컴포넌트가
setStoredValue(next)를 호출하면 React가 다음 렌더의 state를 계산합니다. -
커밋 후 현재 key에 저장
useEffect([key, storedValue])가 JSON 문자열을localStorage에 씁니다. 이 일회성 쓰기는 해제할 연결이나 구독을 만들지 않으므로 cleanup이 없습니다. 다만 브라우저 정책·용량 제한이나 직렬화 문제로 쓰기가 예외를 던질 수 있으며, 이 예제는 그 쓰기 오류를 처리하지 않습니다.
로컬 스토리지에 값을 저장하고 불러오는 로직은 많은 웹 애플리케이션에서 반복적으로 사용됩니다.
이 로직을 커스텀 훅으로 만들어 봅시다.
이 훅은 useState와 useEffect를 함께 사용합니다.
useLocalStorage.js 파일 생성
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// (1) 초기 상태를 계산하는 함수 (지연 초기화)
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key); // 로컬 스토리지에서 값 가져오기
return item === null ? initialValue : JSON.parse(item);
} catch {
return initialValue; // 저장소 접근 또는 JSON 파싱 실패 시 초기값 반환
}
});
// (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의 초기값으로 함수를 전달하여 지연 초기화를 구현했습니다. 이 함수는 마운트의 초기화 과정에서 로컬 스토리지를 읽고, 항목이 없거나 접근·파싱에 실패하면initialValue를 사용합니다.useEffect훅을 사용하여 커밋 후storedValue또는key가 변경될 때마다 해당 값을 로컬 스토리지에 저장합니다.JSON.stringify와JSON.parse를 사용하여 JSON으로 표현 가능한 객체나 배열도 저장할 수 있도록 했습니다.[storedValue, setStoredValue]형태로 현재 값과 값을 업데이트하는 함수를 반환합니다.
이 예제의 경계도 분명히 알아두어야 합니다.
- React는 지연 initializer가 순수하기를 기대합니다. 이 브라우저 예제는 초기화 중 저장소 값이 바뀌지 않는다고 가정하고 읽기만 수행하며, 쓰기는 Effect에 둡니다. 개발 환경의 Strict Mode에서는 initializer가 두 번 호출될 수 있으므로 여기서 일회성 부수 효과를 실행하면 안 됩니다.
- 이 Effect는 구독, 타이머, 연결처럼 해제할 지속 자원을 만들지 않고 한 번의
setItem호출만 수행하므로 cleanup 함수가 필요하지 않습니다. cleanup은 설정한 외부 자원을 되돌려야 할 때만 반환합니다. 개발 Strict Mode에서는 Effect setup도 추가 실행될 수 있으므로 같은 값을 다시 써도 안전해야 합니다. localStorage는 브라우저와 origin에 묶인 외부 시스템이며 접근 정책이나 저장 용량 때문에 실패할 수 있습니다. 서버 렌더링과 hydration을 사용하는 앱이라면 서버와 클라이언트의 첫 출력이 같도록 별도의 클라이언트 초기화 전략을 설계해야 합니다.- 이 간단한 구현은
key가 바뀌어도 새 키의 기존 값을 다시 읽지 않고 현재 state를 새 키에 씁니다. 또한storage이벤트를 구독하지 않으므로 다른 탭의 변경을 state로 가져오지 않습니다.
useLocalStorage 훅 사용하기
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.js에 LocalStorageExample을 추가하고 실행해 보세요.
입력 필드에 값을 입력한 후 페이지를 새로고침하거나 브라우저를 닫았다가 다시 열어도 값이 유지되는 것을 확인할 수 있습니다.
이제 로컬 스토리지와 연동되는 상태 관리 로직을 단 한 줄의 코드로 여러 컴포넌트에서 재사용할 수 있게 되었습니다!
커스텀 훅 전체 컴포넌트 테스트 (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;커스텀 훅은 단순히 코드를 함수로 빼는 것이 아니라, 상태 로직의 입력과 출력, 내부 효과, 재사용 범위를 명확히 하는 설계 작업입니다. 앞의 추출 판단표처럼 반복 신호와 공개 계약을 먼저 정하고, Effect가 외부 자원을 설정할 때에만 그 설정을 되돌리는 cleanup을 함께 설계해야 합니다.
커스텀 훅 만들기는 여기까지입니다.
이 장에서는 커스텀 훅의 개념, 필요성, 생성 규칙을 설명하고, useToggle과 useLocalStorage라는 두 가지 실용적인 예제를 통해 상태 관련 로직을 어떻게 추출하고 재사용하는지 상세하게 다루었습니다.
커스텀 훅은 반복되는 상태 로직과 부수 효과를 컴포넌트 밖으로 분리하는 방법입니다.
입력값, 반환값, 내부 효과의 실행 조건을 명확히 두면 재사용성과 테스트 용이성이 함께 좋아집니다.