리액트에서는 왜 커스텀 훅을 쓰나요?
들어가며…
이번 글은 리액트 입문 시절에 다들 한번쯤은 배웠던 개념을 다뤄볼까 합니다.
"아, 이거 너무 기초적인데?" 하시는 분들은 넘어가셔도 좋습니다.
아니면 잠깐 초심으로 돌아가 가볍게 살펴보는 것도 추천드립니다. 😊
리액트를 사용해 프론트엔드 개발을 하면서, 평소처럼 커스텀 훅을 활용하던 어느 날이었습니다.
동료 개발자로부터 문득 이런 질문을 받았습니다.
"커스텀 훅은 코드 재사용이 필요할 때만 쓰면 되는 거 아닌가요?"
그 순간 나름의 답변을 했지만, 제가 생각하는 커스텀 훅의 진정한 가치를 제대로 전달하지 못한 것 같아 아쉬움이 남았습니다.
늘 당연하게 써오던 것들을 갑자기 이론적으로 설명해봐라 하면 왜 이렇게 설명이 유창하게 안 나오는지 모르겠습니다. 😅
이참에 글로 정리해보며 다시 한 번 생각을 정리해보려 합니다.
이 글에서는 리액트에서 커스텀 훅이 왜 중요한지, 단순한 코드 재사용을 넘어 어떤 가치를 제공하는지 함께 알아보겠습니다.
실제 예제 코드와 함께 커스텀 훅이 가진 이점들에 대해 살펴보시죠.
1. 관심사 분리 (Separation of Concerns)
"프로그램은 서로 분리된 관심사들의 집합이어야 한다." - Edsger W. Dijkstra "On the role of scientific thought" (1974)
"관심사의 분리" 원칙은 오늘날 소프트웨어 개발에서 중요한 가치를 지닙니다. 소프트웨어는 본질적으로 지속적인 변화를 필요로 하며, 좋은 소프트웨어란 이러한 변화에 유연하게 대응할 수 있어야 하고, 기존 기능의 수정과 확장이 용이해야 합니다.
[ 관심사 분리의 핵심 가치 ]
1. 예측 가능한 변경과 복잡성 관리
- 특정 기능이나 로직이 변경될 때, 영향 범위를 명확히 파악할 수 있습니다
- 여러 책임이 얽혀있는 코드는 이해하기 어렵고 버그 발생 가능성이 높기 때문에, 관심사를 분리하여 각 부분의 복잡성을 독립적으로 관리합니다
- 한 부분의 변경이 다른 부분에 예기치 않은 영향을 미치는 것을 방지합니다
2. 유지보수와 재사용성
- 관련 있는 코드끼리 모여있어 수정이 필요한 부분을 쉽게 찾을 수 있습니다
- 독립적인 모듈로 분리된 코드는 다른 상황에서도 쉽게 재사용할 수 있습니다
- 중복 코드를 줄이고 일관된 동작을 보장합니다
이러한 가치를 실현하기 위해 우리는 다음과 같은 설계 원칙들을 활용하곤 합니다.
- 단일 책임 원칙(SRP): 모듈의 책임 범위를 명확히 하는 가이드라인 제공한다
- KISS 원칙: 각 모듈을 가능한 단순하게 유지한다
[ 리액트에서의 관심사 분리 ]
리액트는 본질적으로 사용자 인터페이스를 구축하기 위한 JavaScript 라이브러리입니다.
리액트의 핵심 목적은 선언적인 방식으로 UI를 구성하고 효율적으로 DOM을 업데이트하는 것입니다.
따라서 리액트 컴포넌트의 주요 관심사는 UI의 렌더링에 있어야 합니다. 각 컴포넌트는 "주어진 상태에 따라 어떻게 화면을 그릴 것인가"에 집중해야 하며, 다른 부가적인 로직들은 커스텀 훅으로 분리하는 것이 좋습니다.
분리가 필요한 로직들
- 비즈니스 로직: 데이터 변환, 계산, 유효성 검증
- 상태 관리: 데이터의 CRUD 작업, 상태 업데이트
- 데이터 처리: API 호출, 데이터 페칭, 에러 핸들링
- 사이드 이펙트: 타이머, 이벤트 리스너, 구독 등
예시 코드로 보는 관심사 분리
// useAuth.ts interface AuthState { user: User | null; isLoading: boolean; error: Error | null; } const useAuth = () => { const [authState, setAuthState] = useState<AuthState>({ user: null, isLoading: false, error: null }); const login = async (credentials: Credentials) => { setAuthState(prev => ({ ...prev, isLoading: true })); try { const response = await authAPI.login(credentials); setAuthState({ user: response.user, isLoading: false, error: null }); // 토큰 관리 localStorage.setItem('token', response.token); } catch (error) { setAuthState({ user: null, isLoading: false, error: error as Error }); } }; // 자동 로그인 처리 useEffect(() => { const token = localStorage.getItem('token'); if (token) { // 토큰 유효성 검증 및 사용자 정보 갱신 } }, []); return { ...authState, login }; };
- 이 훅은 인증이라는 하나의 책임만을 담당합니다
- 컴포넌트에서 복잡한 인증 로직을 완전히 분리하게 해줍니다
- 인증 상태 관리와 관련된 모든 로직이 한 곳에 모여있어 유지보수성이 향상됩니다
// 사용부 const LoginPage = () => { const { user, isLoading, error, login } = useAuth(); if (isLoading) return <LoadingSpinner />; if (error) return <ErrorMessage message={error.message} />; if (user) return <Navigate to="/dashboard" />; return <LoginForm onSubmit={login} />; };
2. 상태 관리 로직 캡슐화
컴포넌트를 개발하다 보면 복잡한 상태 관리 로직이 컴포넌트의 가독성을 해치는 경우가 많습니다.
이러한 상태 관리 로직을 커스텀 훅으로 캡슐화하면 두 가지 중요한 이점을 얻을 수 있습니다.
1. 컴포넌트의 복잡성 감소: UI 렌더링에 집중할 수 있습니다.
2. 상태 관리 로직의 재사용: 검증된 로직을 여러 컴포넌트에서 활용할 수 있습니다.
가장 흔한 예시 중 하나인 폼 상태 관리를 살펴보겠습니다.
const useForm = <T extends object>(initialValues: T) => { const [values, setValues] = useState<T>(initialValues); const [errors, setErrors] = useState({}); const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setValues(prev => ({ ...prev, [e.target.name]: e.target.value })); }; return { values, errors, handleChange }; }; // 구현 세부사항을 몰라도 됨 const SignupForm = () => { const { values, handleChange } = useForm({ email: '', password: '' }); return <form>...</form>; };
위 코드에서는
- 폼 상태 관리의 복잡한 내부 구현을 숨겼습니다
- 컴포넌트는 단순히 values와 handleChange만 사용합니다
- 유효성 검사, 에러 처리 등의 복잡한 로직이 훅 내부에 캡슐화되어 있습니다
3. 선언적 프로그래밍 지원
선언적 프로그래밍은 리액트의 핵심 철학 중 하나입니다.
이는 "어떻게(How)" 구현할 것인지가 아닌, "무엇을(What)" 원하는지를 명시하는 프로그래밍 방식입니다.
- 개발자는 "무엇"을 원하는지만 선언
- 리액트가 "어떻게" 처리할지 담당
const useAsync = <T>(asyncFn: () => Promise<T>) => { const [state, setState] = useState<{ data: T | null; loading: boolean; error: Error | null; }>({ data: null, loading: false, error: null }); const execute = async () => { setState(prev => ({ ...prev, loading: true })); try { const data = await asyncFn(); setState({ data, loading: false, error: null }); } catch (error) { setState({ data: null, loading: false, error: error as Error }); } }; return { ...state, execute }; };
위 코드에서는
- "어떻게" 비동기 작업을 처리할지가 아닌, "무엇을" 할지만 선언합니다
- 로딩, 에러, 성공 상태를 자동으로 관리합니다
- 컴포넌트는 상태를 선언적으로 사용 하면 됩니다
4. Side-Effect 관리
리액트 컴포넌트의 주요 역할은 UI를 렌더링하는 것입니다. 하지만 실제 애플리케이션을 개발하다 보면 API 호출, 데이터 구독, DOM 조작 등 순수한 렌더링 외의 작업들이 필요합니다.
이러한 작업들을 Side-Effect라고 부릅니다.
*컴포넌트의 렌더링 과정에서 직접적으로 일어나지 않는 모든 작업
[ 대표적인 Side-Effect ]
1. 외부 시스템과의 통신
- API 호출 및 데이터 페칭
- WebSocket 연결
- EventListener 설정
2. 브라우저 API 활용
- DOM 직접 조작
- localStorage 접근
- 타이머 설정 (setTimeout/setInterval)
[ Side-Effect를 적절히 관리하지 않으면 발생할 수 있는 문제 ]
- 메모리 누수
- 예측하기 어려운 컴포넌트 동작
- 불필요한 리렌더링
- race condition
예시 코드
const useWebSocket = (url: string) => { const [messages, setMessages] = useState<Message[]>([]); useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (event) => { setMessages(prev => [...prev, JSON.parse(event.data)]); }; return () => ws.close(); }, [url]); return messages; };
- WebSocket 연결이라는 사이드 이펙트를 컴포넌트에서 완전히 분리합니다
- 연결 생성과 정리(cleanup)를 훅 내부에서 처리합니다
- 컴포넌트는 메시지 데이터만 받아서 사용합니다
⁉️ 기습 질문
useEffect들을 한 컴포넌트 안에 두면 응집도가 높아지는데 이 방법은 어떤가요?
⇒ 코드의 맥락, 복잡도, 그리고 재사용성을 고려하여 분리할지 말지 결정해야합니다.
커스텀 훅으로 분리해야 하는 경우
// ✅ 분리 필요: 여러 컴포넌트에서 재사용될 수 있는 범용적인 로직 const useIntersectionObserver = (ref: RefObject<HTMLElement>, options = {}) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver(([entry]) => { setIsVisible(entry.isIntersecting); }, options); if (ref.current) { observer.observe(ref.current); } return () => observer.disconnect(); }, [ref, options]); return isVisible; }; // 여러 컴포넌트에서 재사용 const ImageComponent = () => { const imgRef = useRef(null); const isVisible = useIntersectionObserver(imgRef); return ( <img ref={imgRef} src={isVisible ? "actual-image.jpg" : "placeholder.jpg"} /> ); };
- 동일한 로직이 여러 컴포넌트에서 재사용될 때
- 로직이 충분히 복잡하여 독립적인 단위로 관리될 필요가 있을 때
- 특정 기능이 명확하게 구분되어 있을 때
컴포넌트 내부에 두어야 하는 경우
// ✅ 컴포넌트 내부 유지: 컴포넌트 특화된 로직, 여러 상태가 긴밀히 연관됨 const TodoItem = ({ id, initialText }: TodoItemProps) => { const [isEditing, setIsEditing] = useState(false); const [text, setText] = useState(initialText); const [isSaving, setIsSaving] = useState(false); // 이 이펙트는 컴포넌트의 여러 상태와 긴밀히 연관되어 있음 useEffect(() => { if (!isEditing && text !== initialText) { setIsSaving(true); api.updateTodo(id, text) .finally(() => setIsSaving(false)); } }, [isEditing, text, initialText, id]); return ( <div> {isEditing ? ( <input value={text} onChange={e => setText(e.target.value)} onBlur={() => setIsEditing(false)} /> ) : ( <span onClick={() => setIsEditing(true)}> {text} {isSaving && "(저장 중...)"} </span> )} </div> ); };
- 해당 컴포넌트에만 특화된 로직일 때
- 여러 상태가 긴밀하게 연관되어 있을 때
- 로직이 단순하여 분리의 이점이 크지 않을 때
5. 코드 가독성
복잡한 로직을 의미 있는 단위로 분리하여 코드의 가독성을 높입니다.
const useSearchDebounce = (delay: number = 300) => { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(query); }, delay); return () => clearTimeout(timer); }, [query, delay]); return { query, setQuery, debouncedQuery }; };
- 디바운스라 의미 있는 이름으로 훅을 분리합니다
- 타이머 관리 로직이 컴포넌트를 복잡하게 만들지 않습니다
- 의도가 명확히 드러나는 인터페이스를 제공합니다
6. 유지보수성
로직의 변경이 필요할 때 영향 범위를 최소화하고, 버그 수정을 용이하게 합니다.
const useLocalStorage = <T>(key: string, initialValue: T) => { const [storedValue, setStoredValue] = useState<T>(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); const setValue = (value: T) => { try { setStoredValue(value); window.localStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(error); } }; return [storedValue, setValue] as const; };
맺음말
지금까지 우리는 커스텀 훅이 단순한 코드 재사용 도구를 넘어서는 다양한 가치를 가진다는 것을 살펴보았습니다.
🎯 아키텍처적 가치
- 관심사 분리를 통한 코드 구조화
- 선언적 프로그래밍 지원
- 부수 효과의 효과적인 관리
💻 개발자 경험 향상
- 직관적인 인터페이스 설계 및 제공
- 반복 작업 감소
- 생산성 향상
🛠 유지보수성 개선
- 테스트 용이성
- 버그 수정 편의성
- 기능 확장의 유연성
이제 저는 "코드 재사용을 위해 커스텀 훅을 써야 하나요?"라는 질문에 좀 더 빠르게 답변할 수 있을 것 같습니다.
다만 여기서 주의할 점이 있습니다.
때로는 과도한 추상화가 오히려 코드를 더 복잡하게 만들 수 있다는 점을 기억해야한다는 것입니다.
커스텀 훅을 만들 때는 "이 추상화가 정말 필요한가?", "이 수준의 추상화가 적절한가?"를 항상 고민해야 합니다.
결국 우리의 목표는 코드를 더 이해하기 쉽고 관리하기 쉽게 만드는 것이니까요.
Fred Brooks가 "은탄환은 없다"고 했듯이, 소프트웨어 개발에서 100% 정답은 없으며 완벽한 혹은 완전무결한 추상화란 없습니다.
우리의 목표는 적절한 수준의 추상화를 찾아가는 것임을 잊지 맙시다!