React의 Custom Hook: 로직 재사용을 올바르게 분리하기
1. 개요
React 애플리케이션에서 여러 컴포넌트가 같은 로직(예: API 호출, localStorage 읽기, 입력값 debounce)이 필요할 때, state와 useEffect를 이리저리 복사-붙여넣기하게 되기 쉽습니다. 이 글에서는 그 로직을 custom hook으로 묶는 방법을 보여줍니다: use로 시작하는 함수로, 내장 hook들(useState, useEffect, useRef…)을 활용하여 재사용 가능하도록 하는 방법입니다. 이 글을 읽고 나면 useFetch, useLocalStorage, useDebounce를 작성할 수 있고, renderHook으로 테스트하는 방법도 알게 될 것입니다.
2. 문제: 컴포넌트 간 반복되는 로직
두 컴포넌트 모두 API를 호출하고 로딩/에러 상태를 관리해야 하는 경우를 가정해봅시다:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...렌더링
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...렌더링
}
CustomerList와 OrderList는 거의 같은 로직을 포함하고 있으며, URL만 다릅니다. 나중에 재시도나 요청 취소 기능을 추가하려면 모든 곳에서 수정해야 합니다 — 이것이 바로 custom hook이 필요한 신호입니다.
3. Custom hook이란
Custom hook은 일반적인 JavaScript 함수로, 이름이 use로 시작하며, React의 다른 hook들을 내부에서 호출합니다. JSX를 반환하지 않습니다 — 상태와 함수를 반환하며, 이를 호출하는 컴포넌트에 붙는 “미니 스토어” 같은 역할을 합니다.
주의: 컴포넌트가 custom hook을 호출할 때마다 React는 독립적인 상태 인스턴스를 생성합니다. 두 컴포넌트가 useFetch를 사용해도 상태를 공유하지 않습니다 — 로직만 공유할 뿐입니다.
4. 첫 번째 custom hook 작성: useFetch
// 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(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
설명:
cancelled플래그는 race condition을 방지합니다: url이 변경되기 전에 이전 요청이 완료되면, 이전 결과가 상태에 설정되지 않습니다.- Cleanup 함수 (
return () => { cancelled = true })는 컴포넌트가 unmount되거나 url이 변경될 때 실행됩니다 — useEffect의 생명주기와 정확히 일치합니다. - Hook은 객체
{ data, loading, error }를 반환하여 호출하는 곳에서 쉽게 구조 분해할 수 있습니다.
컴포넌트가 이제 간결해집니다:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>로딩 중...</p>;
if (error) return <p>오류: {error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// 비슷한 렌더링 로직이며, 더 이상 fetch 로직을 반복할 필요가 없습니다
}
5. 두 번째 예제: useLocalStorage
localStorage와 상태를 동기화하는 로직도 복사-붙여넣기하기 쉽습니다:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
설명:
useState(() => ...)는 lazy initializer를 사용합니다 — 함수는 mount 시에만 한 번 실행되고, 매번 렌더링할 때마다 localStorage를 읽지 않습니다.- Hook은 useState처럼 정확히
[value, setValue]튜플을 반환하여 사용자가 바로 익숙할 수 있습니다.
컴포넌트에서 사용:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
현재 테마: {theme}
</button>
);
}
5.1. state가 이전 값에 의존할 때
이전 값을 기반으로 업데이트해야 할 때(예: counter), stale closure를 피하기 위해 functional update를 사용하세요:
setValue((prev) => prev + 1); // setValue(value + 1)보다 더 안전합니다
6. 세 번째 예제: useDebounce
검색 입력값을 debounce하는 것은 매우 흔한 문제입니다:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
설명:
- value가 변경될 때마다 새로운 setTimeout이 생성되고, 이전 setTimeout은 cleanup에서 clearTimeout됩니다.
- 사용자가 delayMs 동안 입력을 멈춘 후에야 debounced 값이 업데이트됩니다.
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. 이름 지정 규칙과 Rules of Hooks
Custom hook은 내장 hook처럼 Rules of Hooks를 따라야 합니다:
- 이름이 use로 시작해야 합니다 — ESLint 플러그인
eslint-plugin-react-hooks가 이 접두사를 기반으로 규칙을 확인하며, use가 없으면 이것이 hook인지 인식하지 못합니다. - Hook은 함수의 최상위 레벨에서만 호출하세요. if, for, 콜백 내부에서 호출하지 마세요.
- Hook은 함수 컴포넌트나 다른 custom hook 내에서만 호출하세요. 일반 함수에서는 호출하지 마세요.
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
exhaustive-deps를 활성화하면 custom hook의 useEffect에서 dependency를 빠뜨리지 않게 도와줍니다 — “stale data” 버그의 가장 흔한 원인입니다.
8. Custom hook 테스트
Custom hook은 JSX를 반환하지 않기 때문에 컴포넌트처럼 렌더링할 수 없습니다. @testing-library/react 라이브러리는 테스트 환경에서 hook을 호출하기 위해 renderHook을 제공합니다.
<!-- React 프로젝트에는 적용되지 않음; 아래를 참고하여 npm으로 dependency를 설치하세요 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('지연 시간이 지난 후에만 값을 업데이트합니다', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // 아직 변경되지 않음
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // debounce 완료됨
vi.useRealTimers();
});
});
설명:
renderHook은 hook을 호출하는 함수를 받고,result.current를 반환합니다 — 현재 렌더링에서 hook이 반환하는 값입니다.rerender는 새로운 props를 가진 컴포넌트 다시 렌더링을 시뮬레이션합니다(사용자가 입력할 때 value가 변경되는 것처럼).vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))를 사용하면 실제로 기다릴 필요 없이 시간을 제어할 수 있어, 테스트가 빠르고 안정적으로 실행됩니다.
✓ useDebounce > 지연 시간이 지난 후에만 값을 업데이트합니다
9. 자주 발생하는 오류 및 언제 사용하면 안 되는가
자주 발생하는 오류:
- useEffect에서 cleanup을 빠뜨림: useFetch에서 cancelled 플래그가 없으면, 나중에 반환된 이전 요청이 새로운 데이터를 덮어쓸 수 있습니다 — 잘못된 데이터가 표시됩니다.
- dependency 누락: hook의 useEffect가 외부 변수를 사용하지만 dependency array에 선언하지 않으면, hook은 “낡은” 값을 사용합니다(stale closure).
- 불필요하게 매번 새로운 객체 반환: hook이 매번 object literal로
{ data, loading, error }를 반환하면, 하위의 React.memo를 사용하는 컴포넌트는 reference가 변경되어 불필요하게 다시 렌더링될 수 있습니다. 문제가 측정되면 useMemo를 사용할 수 있습니다.
custom hook을 작성하면 안 되는 경우:
- Logic이 단 한 곳에서만 사용되고 재사용할 가능성이 없는 경우 — 분리하면 코드를 따라가기만 더 어려워집니다(다른 파일로 이동해야 함).
- Logic이 순수 함수(pure function)이고 상태/effect와 무관한 경우 — 일반적인 JS 함수로 작성해야 하며, hook이 필요하지 않습니다.
- 트리의 다양한 위치에 있는 여러 컴포넌트 간에 상태(로직만이 아닌) 공유가 필요한 경우 — custom hook은 이를 할 수 없으므로, Context나 상태 관리 라이브러리(Zustand, Redux…)가 필요합니다.
주의: Custom hook은 로직 중복 문제를 해결하지만, 컴포넌트 간 상태 공유 문제는 해결하지 않습니다. 이 두 가지 문제를 혼동하지 마세요.
10. 결론
Custom hook은 상태 관련 로직을 컴포넌트에서 분리하는 도구로, 코드를 간결하게 하고, 테스트하기 쉽게 하며, 재사용하기 편하게 만듭니다. 기억해야 할 것은: 이름을 use로 시작하게 하고, Rules of Hooks를 따르며, render 대신 renderHook으로 테스트합니다. 위의 useFetch, useLocalStorage, useDebounce를 이용하면, useMediaQuery, useForm 같은 다른 hook들을 “한 번 작성하고, 여러 곳에서 사용하기”의 정신으로 작성할 수 있는 틀을 갖추게 됩니다.