재사용 가능하고 복원력 있는 React 사용자 정의 훅 만들기
Lukas Schneider
DevOps Engineer · Leapcell

서론
끊임없이 진화하는 최신 웹 개발 환경에서 React는 동적인 사용자 인터페이스를 구축하는 데 지배적인 라이브러리로 확고히 자리 잡았습니다. React의 성공에 크게 기여한 요인은 컴포넌트 기반 아키텍처를 수용하고, 최근에는 훅(Hooks)의 도입입니다. 훅은 함수형 컴포넌트에서 상태와 부수 효과를 관리하는 방식을 근본적으로 변화시켜 더 깔끔하고, 읽기 쉬우며, 더 조합하기 좋은 코드를 만들었습니다. 그러나 단순히 훅을 사용하는 것과 고품질의 재사용 가능한 사용자 정의 훅을 만드는 것은 별개의 문제입니다. 이러한 구분은 확장 가능하고, 유지보수 가능하며, 견고한 React 애플리케이션을 구축하는 데 매우 중요합니다. 신중한 설계 없이는 사용자 정의 훅이 개발을 돕기보다는 방해하는, 긴밀하게 결합되고 취약한 논리 조각으로 빠르게 전락할 수 있습니다. 이 글에서는 애플리케이션에 진정한 힘을 실어주고 코드베이스를 더 효율적이고 관리하기 쉽게 만드는 사용자 정의 훅을 만드는 효과적인 설계 패턴과 모범 사례를 탐구합니다.
견고한 사용자 정의 훅을 위한 핵심 개념
설계 패턴을 살펴보기 전에 효과적인 사용자 정의 훅 생성을 뒷받침하는 핵심 개념에 대한 공통된 이해를 확립합시다. 이러한 용어는 논의 전반에 걸쳐 다시 언급될 것입니다.
사용자 정의 훅 (Custom Hook): 이름이 "use"로 시작하고 다른 훅을 호출할 수 있는 JavaScript 함수입니다. 사용자 정의 훅을 사용하면 재사용 가능한 상태 로직을 전용 함수로 추출할 수 있습니다. 이들은 UI가 아닌 논리를 컴포넌트 간에 공유하기 위한 것입니다.
재사용성 (Reusability): 큰 수정 없이 여러 다양한 컴포넌트나 애플리케이션에 통합될 수 있는 사용자 정의 훅의 능력입니다. 재사용 가능한 훅은 다양한 맥락에서 일반적인 문제를 해결하기에 충분히 일반적입니다.
유지보수성 (Maintainability): 시간이 지남에 따라 사용자 정의 훅을 이해하고 수정하며 디버깅하는 용이성입니다. 잘 설계된 훅은 자체 포함되어 있으며 명확한 책임을 가지므로 변경 시 버그 발생 가능성이 줄어듭니다.
관심사 분리 (Separation of Concerns): 큰 시스템을 각각 특정 관심사를 다루는 별개의 겹치지 않는 섹션으로 나누는 원칙입니다. 사용자 정의 훅의 경우, 이는 각 훅이 이상적으로 한 가지 일을 잘하도록 해야 한다는 것을 의미합니다.
API 설계 (API Design): 사용자 정의 훅이 소비하는 컴포넌트에 노출하는 인터페이스로, 인수, 반환 값 및 암시적 동작을 포함합니다. 좋은 API는 직관적이고 예측 가능하며 인지 부하를 최소화합니다.
테스트 (Testing): 다양한 조건에서 사용자 정의 훅이 예상대로 작동하는지 확인하는 프로세스입니다. 고품질 훅은 논리와 UI를 분리하기 때문에 본질적으로 테스트 가능합니다.
재사용 가능하고 유지보수 가능한 사용자 정의 훅 설계
고품질이며 재사용 가능한 사용자 정의 훅을 만드는 것은 설계 및 구현에 대한 사려 깊은 접근 방식을 필요로 합니다. 여기서는 몇 가지 핵심 패턴과 전략을 탐구할 것입니다.
1. 단일 책임 원칙
좋은 소프트웨어 설계의 초석은 사용자 정의 훅에도 적용됩니다. 각 훅은 단일하고 잘 정의된 목적을 가져야 합니다. 너무 많은 일을 하려는 "신 훅"을 피하십시오.
문제: 사용자 인증, 프로필 업데이트 및 친구 요청을 처리하는 useUserManagement
훅.
해결책: useAuthentication
, useUserProfile
, useFriendRequests
로 분할합니다.
예시:
모달의 가시성을 관리하는 사용자 정의 훅을 고려해 보세요.
// 나쁨: 너무 복잡하고, UI 로직과 상태를 혼합함 function useModal(initialState = false) { const [isOpen, setIsOpen] = React.useState(initialState); const [modalTitle, setModalTitle] = React.useState(''); // UI 관련 사항 const openModal = (title) => { setIsOpen(true); setModalTitle(title); }; const closeModal = () => setIsOpen(false); return { isOpen, openModal, closeModal, modalTitle }; // UI 관련 상태 노출 } // 좋음: 가시성 토글에만 집중 function useToggle(initialState = false) { const [isVisible, setIsVisible] = React.useState(initialState); const show = () => setIsVisible(true); const hide = () => setIsVisible(false); const toggle = () => setIsVisible(prev => !prev); return { isVisible, show, hide, toggle }; } // 좋은 예시로 사용: function MyComponent() { const { isVisible, show, hide } = useToggle(); return ( <div> <button onClick={show}>모달 열기</button> {isVisible && ( <div className="modal"> <h2>모달 제목</h2> {/* 제목은 훅이 아닌 컴포넌트에서 처리됨 */} <p>모달 내용</p> <button onClick={hide}>닫기</button> </div> )} </div> ); }
useToggle
훅은 모달, 드롭다운, 툴팁 또는 부울 토글에 적용될 수 있는 부울 상태만 관리하므로 훨씬 더 재사용 가능합니다.
2. 명확하고 직관적인 API
사용자 정의 훅의 입력과 출력은 명시적이고 이해하기 쉬워야 합니다. 훅이 기능을 수행하기 위해 어떤 인수가 필요하며 어떤 데이터나 함수를 소비하는 컴포넌트에 다시 노출해야 하는지 생각해 보세요.
인수:
- 최소한으로 유지하세요.
- 여러 개의 선택적 인수의 경우 종종 옵션 객체로 전달되어 구조 분해 할당을 사용하세요.
반환 값:
- 종종 튜플
[state, setState]
또는 객체{ state, handlers }
입니다. - 명확성과 확장성을 기준으로 선택하세요. 튜플은 간단한 상태 관리자(예:
useState
)에 적합하며, 객체는 여러 관련 값과 함수를 반환하는 훅에 더 좋습니다.
예시: 비동기 데이터 가져오기 처리
// 빈약한 API: 순서가 있는 인수에 의존하며 확장성이 떨어짐 function useFetch(url, options, initialData) { /* ... */ } // 더 나은 API: 명확성과 향후 확장을 위해 옵션 객체 사용 function useFetch(url, { initialData = null, immediate = true, cacheKey = null, headers = {} } = {}) { const [data, setData] = React.useState(initialData); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const fetchData = React.useCallback(async (payload = {}) => { setLoading(true); setError(null); try { const response = await fetch(url, { ...options, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); setData(result); } catch (err) { setError(err); } finally { setLoading(false); } }, [url, options]); // 메모이제이션을 위한 의존성 React.useEffect(() => { if (immediate) { fetchData(); } }, [immediate, fetchData]); return { data, loading, error, fetchData }; } // 사용법: function UserProfile({ userId }) { const { data: user, loading, error, fetchData } = useFetch(`/api/users/${userId}`, { initialData: { name: '', email: '' }, immediate: true, headers: { 'Authorization': 'Bearer ...' } }); if (loading) return <div>사용자 로딩 중...</div>; if (error) return <div>오류: {error.message}</div>; return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> <button onClick={() => fetchData()}>프로필 새로고침</button> </div> ); }
옵션 객체는 useFetch
훅의 API를 더 설명적으로 만들고 기존 통합을 중단하지 않고 새 구성 매개변수를 쉽게 추가할 수 있게 합니다.
3. 복잡한 로직 및 부수 효과 캡슐화
사용자 정의 훅은 복잡한 로직, 특히 부수 효과(예: useEffect
, useRef
, API 호출, DOM 조작)를 포함하는 로직을 추상화하는 데 이상적입니다. 이렇게 하면 컴포넌트를 깔끔하게 유지하고 렌더링에 집중할 수 있습니다.
예시: 값 디바운싱
function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = React.useState(value); React.useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } // 사용법: function SearchInput() { const [searchTerm, setSearchTerm] = React.useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); // 디바운스된 검색어가 변경될 때만 실행되는 Effect React.useEffect(() => { if (debouncedSearchTerm) { console.log('검색어에 대한 결과 가져오는 중:', debouncedSearchTerm); // 디바운스된 검색어로 API 호출 수행 } }, [debouncedSearchTerm]); return ( <input type="text" placeholder="검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> ); }
여기서 useDebounce
는 useState
및 useEffect
(클린업 포함)를 포함한 디바운싱의 전체 로직을 캡슐화하여 디바운스된 값이 필요한 모든 컨텍스트에서 매우 재사용 가능하게 만듭니다.
4. 합리적인 기본값 및 구성 옵션 제공
기본값을 합리적으로 제공하여 훅을 가능한 한 플러그 앤 플레이 방식으로 설계하세요. 더 많은 제어가 필요한 경우 옵션 객체를 통해 사용자 정의를 허용하세요.
예시: useLocalStorage
function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = React.useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(error); return initialValue; } }); const setValue = (value) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error(error); } }; return [storedValue, setValue]; } // 사용법: function SettingsPanel() { const [theme, setTheme] = useLocalStorage('appTheme', 'light'); const [notificationsEnabled, setNotificationsEnabled] = useLocalStorage('notifications', true); return ( <div> <label> 테마: <select value={theme} onChange={(e) => setTheme(e.target.value)}> <option value="light">밝은</option> <option value="dark">어두운</option> </select> </label> <label> <input type="checkbox" checked={notificationsEnabled} onChange={(e) => setNotificationsEnabled(e.target.checked)} /> 알림 활성화 </label> </div> ); }
useLocalStorage
는 기존 데이터를 구문 분석하거나 initialValue
로 대체하여 유용한 기본값을 즉시 제공하여 복잡한 설정 없이 사용하기 쉽게 만듭니다.
5. 성능 고려 (메모이제이션)
비용이 많이 드는 계산을 수행하거나 함수를 반환하는 사용자 정의 훅의 경우 useMemo
및 useCallback
을 사용하여 불필요한 렌더링이나 함수 재생성을 방지하세요. 이는 자주 호출되거나 복잡한 상태를 관리하는 훅에 특히 중요합니다.
예시: 메모이제이션된 함수가 있는 useCounter
function useCounter(initialCount = 0) { const [count, setCount] = React.useState(initialCount); // increment 및 decrement 함수 메모이제이션을 위한 useCallback 사용 const increment = React.useCallback(() => { setCount(prevCount => prevCount + 1); }, []); // 빈 의존성 배열은 이 함수들이 한 번 생성됨을 의미 const decrement = React.useCallback(() => { setCount(prevCount => prevCount - 1); }, []); const reset = React.useCallback(() => { setCount(initialCount); }, [initialCount]); // initialCount에 의존 return { count, increment, decrement, reset }; } // 사용법: function CounterDisplay() { const { count, increment, decrement, reset } = useCounter(10); return ( <div> <p>카운트: {count}</p> <button onClick={increment}>증가</button> <button onClick={decrement}>감소</button> <button onClick={reset}>초기화</button> </div> ); }
increment
, decrement
, reset
을 메모이제이션함으로써 이러한 함수는 CounterDisplay
의 각 렌더링 시 다시 생성되지 않아, prop으로 전달될 수 있는 불필요한 하위 컴포넌트 렌더링을 방지합니다.
6. 훌륭한 테스트 커버리지 제공
고품질 훅은 철저하게 테스트됩니다. 훅은 논리를 추상화하기 때문에 전체 컴포넌트보다 격리해서 테스트하기 쉬운 경우가 많습니다. React Testing Library와 같은 라이브러리를 사용하여 UI와 독립적으로 테스트하세요.
useToggle
훅 테스트 예시 (Jest 및 React Testing Library의 renderHook
사용):
// useToggle.test.js import { renderHook, act } from '@testing-library/react-hooks'; import { useToggle } from './useToggle'; // useToggle이 useToggle.js에 있다고 가정 describe('useToggle', () => { it('주어진 초기 상태로 초기화되어야 함', () => { const { result } = renderHook(() => useToggle(true)); expect(result.current.isVisible).toBe(true); }); it('초기 상태가 제공되지 않으면 기본값은 false여야 함', () => { const { result } = renderHook(() => useToggle()); expect(result.current.isVisible).toBe(false); }); it('상태를 토글해야 함', () => { const { result } = renderHook(() => useToggle(false)); act(() => { result.current.toggle(); }); expect(result.current.isVisible).toBe(true); act(() => { result.current.toggle(); }); expect(result.current.isVisible).toBe(false); }); it('상태를 true로 설정해야 함', () => { const { result } = renderHook(() => useToggle(false)); act(() => { result.current.show(); }); expect(result.current.isVisible).toBe(true); }); it('상태를 false로 설정해야 함', () => { const { result } = renderHook(() => useToggle(true)); act(() => { result.current.hide(); }); expect(result.current.isVisible).toBe(false); }); });
이 테스트 제품군은 useToggle
훅의 다양한 동작을 다루며, 신뢰성과 정확성을 보장합니다.
결론
단일 책임, 명확한 API 설계, 효과적인 캡슐화, 합리적인 기본값, 성능 최적화 및 포괄적인 테스트와 같은 원칙을 준수함으로써 React 사용자 정의 훅의 품질과 재사용성을 크게 향상시킬 수 있습니다. 이러한 설계 패턴은 장기적으로 개발 및 유지보수하기에 더 즐거운 코드베이스를 육성합니다. 잘 만들어진 사용자 정의 훅은 복잡한 로직을 추상화하는 강력한 도구이며, React 애플리케이션을 정교하고 확장 가능한 시스템으로 변환합니다.