1. 훅(Hook)은 엑셀 매크로가 아니다
React 개발자들이 흔히 하는 실수가 있습니다. 컴포넌트 코드가 좀 길어진다 싶으면, 무조건 useSomething이라는 파일을 만들고 코드를 복사해서 옮겨 넣습니다. 그리고 "리팩토링했다"고 뿌듯해하죠.
하지만 2주 뒤, 다른 동료가 그 훅을 쓰려고 열어보면 절망에 빠집니다.
// 나쁜 예: 이름만 훅이고 실상은 '잡동사니 보관함'
const useModalWithUserAndAuthAndLog = () => {
const [user, setUser] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
// API 호출도 하고...
// 로컬 스토리지도 확인하고...
// 로그도 찍고...
}, []);
return { user, isModalOpen, setIsModalOpen }; // 연관 없는 상태들의 파티
};
이건 추상화가 아닙니다. 그냥 "쓰레기를 안 보이는 곳으로 치운 것"에 불과합니다. 방이 깨끗해 보일지 몰라도, 벽장 문을 열면 쓰레기가 쏟아져 나옵니다(Leaky Abstraction). 진정한 Custom Hook은 "비즈니스 로직의 캡슐화"이자 "재사용 가능한 상태 기계(State Machine)의 설계"여야 합니다.
2. 좋은 추상화의 조건 - "무엇(What)"을 남기고 "어떻게(How)"를 숨겨라
좋은 훅은 내부 구현을 몰라도 쓸 수 있어야 합니다. 외부에서는 "내가 무엇을 원하는지(Intent)"만 전달하고, 훅은 "어떻게 처리할지(Implementation)"를 알아서 수행해야 합니다.
예시 - 폼 입력 처리 (useInput)
// 가장 기초적인 Custom Hook
function useInput(initialValue) {
const [value, setValue] = useState(initialValue);
const handleChange = (e) => {
setValue(e.target.value);
}
return { value, onChange: handleChange };
}
이 훅은 input 태그가 어떻게 동작하는지, 이벤트 객체에서 target.value를 어떻게 꺼내는지(How)를 숨깁니다.
개발자는 "나는 입력값을 관리하고 싶어"라는 의도(What)만 가지고 이 훅을 사용하면 됩니다.
const userHook = useInput("Guest");
<input {...userHook} />
3. Headless UI 패턴 - UI와 로직의 완벽한 분리
최근 React 생태계에서 가장 핫한 키워드는 Headless UI입니다. (Radix UI, Headless UI, React Aria 등이 이 패턴을 씁니다.) "스타일은 네가 알아서 해, 나는 기능만 줄게"라는 철학입니다. 이를 Custom Hook으로 구현하면 재사용성이 극대화됩니다.
예시 - 드롭다운 (Dropdown)
드롭다운을 만들 때 가장 골치 아픈 게 뭘까요?
- 바깥쪽 클릭하면 닫혀야 함 (Click Outside)
- ESC 키 누르면 닫혀야 함
- 접근성 (ARIA 속성) 관리
이걸 매번 Dropdown 컴포넌트 짤 때마다 구현하면 지옥입니다. 이걸 훅으로 뽑아냅니다.
// useDropdown.js
function useDropdown() {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
const toggle = () => setIsOpen(!isOpen);
const close = () => setIsOpen(false);
// Click Outside 감지 로직
useEffect(() => {
const handleClickOutside = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
close();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// ESC 키 감지 로직
useEffect(() => {
const handleEsc = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, []);
return {
isOpen,
toggle,
close,
ref // DOM에 연결할 ref 반환
};
}
이제 디자이너가 "드롭다운 배경을 파란색으로 해주세요", "애니메이션 넣어주세요"라고 요구해도 로직은 건드릴 필요가 없습니다. UI 컴포넌트만 수정하면 됩니다. 로직은 useDropdown이 꽉 잡고 있으니까요.
4. 데이터 페칭의 진화 - useEffect를 쓰지 마세요
React 입문자들이 가장 많이 만드는 훅이 useFetch입니다.
// 초보자의 useFetch
function useFetch(url) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, [url]);
return data;
}
깔끔해 보이지만, 이 코드는 실제로 쓰면 재앙을 부릅니다.
- Race Condition: 컴포넌트가 빠르게 마운트/언마운트되면 이전 요청의 응답이 나중에 덮어씌워질 수 있습니다.
- Caching: 뒤로 갔다가 다시 오면 또 로딩합니다. 사용자 경험이 나쁩니다.
- Deduping: 같은 API를 동시에 5번 호출하면 5번 다 요청이 날아갑니다.
서버 상태(Server State) 관리는 너무나 복잡한 문제입니다. 그래서 2024년 현재는 TanStack Query (React Query)나 SWR 같은 라이브러리를 쓰는 것이 표준입니다. 여러분이 직접 비동기 훅을 짜기보다는, 이런 라이브러리를 래핑(Wrapping)해서 "도메인 훅"을 만드는 것이 훨씬 좋습니다.
// 좋은 예: 도메인 로직을 담은 훅
import { useQuery } from '@tanstack/react-query';
export const useTeamMembers = (teamId) => {
return useQuery({
queryKey: ['team', teamId],
queryFn: () => getTeamMembers(teamId),
staleTime: 1000 * 60, // 1분간 캐시
enabled: !!teamId, // teamId가 있을 때만 요청
});
};
이렇게 하면 컴포넌트에서는 isLoading, error 처리를 우아하게 할 수 있고, 캐싱 혜택도 공짜로 얻습니다.
5. Composition over Configuration (설정보다 조합)
훅을 설계할 때 너무 많은 옵션(파라미터)을 받으려 하지 마세요.
useChart({ color, size, data, xAxis, yAxis, tooltip, legend, ... }) -> 이렇게 만들면 쓰는 사람이 헷갈립니다.
10개의 파라미터를 받는 만능 훅 하나보다, 3개의 기능을 가진 작은 훅 3개를 조합(Composition)해서 쓰는 것이 훨씬 낫습니다.
// ❌ 만능 훅 (God Hook)
const { data } = useTable({ sort: true, filter: true, pagination: true, url: '/api' });
// ✅ 작은 훅들의 조합
const { data } = useQuery(...);
const { sortedData } = useSort(data);
const { filteredData } = useFilter(sortedData);
const { pageData } = usePagination(filteredData);
이렇게 하면 나중에 "필터 기능만 빼주세요"라는 요구사항이 왔을 때 useFilter 줄만 지우면 됩니다. 레고 블록처럼 조립하세요.
6. 함정 - Stale Closure와 의존성 배열
Custom Hook을 만들 때 가장 많이 겪는 버그는 Stale Closure(상한 클로저) 문제입니다.
useEffect나 useCallback의 의존성 배열(dependency array)을 대충 적으면, 훅이 "오래된 상태값"을 기억하고 있는 현상이 발생합니다.
// 버그 발생: count가 1에서 멈춤
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, []); // 의존성 배열을 비워두면 callback이 처음 생성된 버전만 기억함!
}
이런 문제를 해결하려면 useRef를 사용해 최신 콜백을 항상 가리키게 하거나, 의존성 배열을 정확하게 채워줘야 합니다. 이걸 손으로 다 챙기기 어렵기 때문에 ESLint 플러그인(eslint-plugin-react-hooks)을 반드시 켜두고 경고를 무시하지 말아야 합니다.
7. 마무리 - 훅은 "보이지 않는 컴포넌트"다
Custom Hook을 만들 때는 "함수를 짠다"고 생각하지 말고, "UI가 없는 컴포넌트를 짠다"고 생각하세요. 컴포넌트를 쪼갤 때 재사용성과 단일 책임 원칙을 고민하듯이, 훅을 만들 때도 똑같이 고민해야 합니다.
- 이름이 명확한가? (
useData같은 이름은 사형감입니다.) - 의존성이 명확한가? (필요한 인자만 받고 있는가?)
- Side Effect가 예측 가능한가?
- 범용적인가, 특정 비즈니스에 종속적인가? (폴더 구조를 나눌 때 기준이 됨)
잘 만든 Custom Hook 하나는 열 컴포넌트 안 부럽습니다. 여러분의 코드가 "복사 붙여넣기"에서 "우아한 조립"으로 진화하길 바랍니다.