커스텀 훅 설계 — 로직 재사용의 정석
비슷한 로직이 여러 컴포넌트에 반복되고 있다면, 그것을 함수로 추출하듯 훅으로 추출할 수 있지 않을까요?
개념 정의
커스텀 훅은 React의 내장 훅을 조합하여 재사용 가능한 로직을 캡슐화 한 함수입니다. use로 시작하는 이름 규칙을 따르며, 상태 관리, 부수효과, 계산 로직 등을 컴포넌트에서 분리할 수 있습니다.
왜 필요한가
여러 컴포넌트에서 동일한 패턴이 반복될 때, 커스텀 훅으로 추출하면 다음과 같은 이점이 있습니다.
- **코드 재사용 **: 같은 로직을 여러 컴포넌트에서 공유
- ** 관심사 분리 **: 컴포넌트는 UI에, 훅은 로직에 집중
- ** 테스트 용이성 **: 로직을 독립적으로 테스트 가능
- ** 가독성 **: 컴포넌트 코드가 간결해짐
설계 원칙
1. 네이밍
// ✅ 구체적이고 의도가 드러나는 이름
useWindowSize()
useLocalStorage('theme')
useDebounce(searchTerm, 300)
useIntersectionObserver(ref)
// ❌ 모호한 이름
useData()
useHelper()
useStuff()
2. 단일 책임
하나의 훅은 하나의 관심사만 다룹니다.
// ❌ 너무 많은 책임
function useEverything() {
const auth = useAuth();
const theme = useTheme();
const data = useFetch('/api/data');
const windowSize = useWindowSize();
return { auth, theme, data, windowSize };
}
// ✅ 관심사별로 분리
function useAuth() { /* 인증 로직 */ }
function useTheme() { /* 테마 로직 */ }
function useFetch(url) { /* 데이터 페칭 */ }
3. 반환값 패턴
// 패턴 1: 단일 값 — 간단한 경우
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return width; // 단일 값
}
// 패턴 2: 배열 [value, setter] — useState와 비슷한 인터페이스
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle]; // 배열: 이름 변경 자유
}
const [isOpen, toggleOpen] = useToggle();
const [isDark, toggleDark] = useToggle(true);
// 패턴 3: 객체 — 여러 값을 반환
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// ...
return { data, loading, error, refetch }; // 객체: 이름으로 접근
}
const { data, loading, error } = useFetch('/api/users');
실전 예제
useDebounce
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 사용
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
useLocalStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
setStoredValue(prev => {
const newValue = value instanceof Function ? value(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(newValue));
} catch (error) {
console.warn(`localStorage에 저장 실패: ${error}`);
}
return newValue;
});
}, [key]);
return [storedValue, setValue];
}
// 사용
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
현재: {theme}
</button>
);
}
useIntersectionObserver
function useIntersectionObserver(options = {}) {
const [entry, setEntry] = useState(null);
const [node, setNode] = useState(null);
const observer = useRef(null);
useEffect(() => {
if (observer.current) observer.current.disconnect();
if (!node) return;
observer.current = new IntersectionObserver(
([entry]) => setEntry(entry),
options
);
observer.current.observe(node);
return () => observer.current.disconnect();
}, [node, options.threshold, options.root, options.rootMargin]);
return [setNode, entry]; // callback ref로 사용
}
// 사용
function LazyImage({ src, alt }) {
const [ref, entry] = useIntersectionObserver({ threshold: 0.1 });
const isVisible = entry?.isIntersecting;
return (
<div ref={ref}>
{isVisible ? (
<img src={src} alt={alt} />
) : (
<div className="placeholder" />
)}
</div>
);
}
usePrevious
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// 사용
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>현재: {count}, 이전: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>증가</button>
</div>
);
}
훅 합성
커스텀 훅 안에서 다른 커스텀 훅을 사용할 수 있습니다.
function useSearchResults(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 300); // 커스텀 훅 사용
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
); // 다른 커스텀 훅 사용
return {
query,
setQuery,
results: data,
loading,
error,
};
}
// 컴포넌트는 매우 간결해짐
function SearchPage() {
const { query, setQuery, results, loading } = useSearchResults();
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading ? <Spinner /> : <ResultList items={results} />}
</div>
);
}
커스텀 훅 vs 유틸 함수
// 유틸 함수: React 훅을 사용하지 않는 순수 로직
function formatDate(date) {
return new Intl.DateTimeFormat('ko-KR').format(date);
}
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// 커스텀 훅: React 훅을 사용하는 상태/부수효과 로직
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
훅을 사용하지 않는 로직은 일반 함수로 분리 하는 것이 적절합니다.
주의사항
상태는 공유되지 않습니다
function ComponentA() {
const [value, toggle] = useToggle(); // 독립적인 인스턴스
// ...
}
function ComponentB() {
const [value, toggle] = useToggle(); // ComponentA와 무관한 별도 인스턴스
// ...
}
훅 규칙을 준수해야 합니다
// ❌ 조건문 안에서 훅 호출
function useBadHook(condition) {
if (condition) {
const [value, setValue] = useState(0); // 규칙 위반
}
}
// ✅ 훅은 항상 최상위에서 호출
function useGoodHook(condition) {
const [value, setValue] = useState(0);
// 조건부 로직은 훅 호출 후에
if (!condition) return null;
return value;
}
주의할 점
훅 규칙을 무시하면 예측 불가능한 버그가 발생
조건문이나 반복문 안에서 훅을 호출하면, 렌더링마다 훅의 호출 순서가 달라져 React가 state를 잘못 매칭합니다. 훅은 항상 함수 최상위 에서 호출해야 합니다.
React 훅을 사용하지 않는 로직은 일반 함수로
useDateFormat 같은 이름이지만 내부에 useState, useEffect 등의 React 훅이 없다면, use 접두사를 붙이면 안 됩니다. 일반 유틸리티 함수로 분리하는 것이 올바릅니다.
너무 이른 추상화
하나의 컴포넌트에서만 사용하는 로직을 커스텀 훅으로 추출하면 오히려 코드 흐름을 따라가기 어려워집니다. 두 곳 이상에서 재사용될 때 추출하는 것이 적절합니다.
정리
| 항목 | 설명 |
|---|---|
| 커스텀 훅 | use로 시작하며 React 훅을 조합한 재사용 가능한 로직 |
| 반환값 | 1~2개면 배열, 3개 이상이면 객체가 적절 |
| 인스턴스 | 각 호출마다 독립적인 인스턴스 생성 — state를 공유하지 않음 |
| 합성 | 훅 안에서 다른 커스텀 훅 호출 가능 |
| 추출 기준 | 두 곳 이상에서 재사용될 때 추출 — 너무 이른 추상화 금지 |
컴포넌트는 UI에, 훅은 로직에 집중하면 코드가 깔끔해집니다. "이 로직을 훅으로 추출할 수 있을까?"라는 질문이 좋은 설계의 출발점입니다.
댓글 로딩 중...