"대충 좋으니까" 쓰던 커스텀 훅, 이제는 설명할 수 있습니다
들어가며
"커스텀 훅이 무엇인가요?"
리액트 프론트엔드 개발자 면접 단골 질문입니다. 저는 면접 당시에 GPT가 생성해준 답변을 아래와 같이 설명하는 걸로 넘어갔습니다.
커스텀 훅은 React에서 상태 로직을 재사용하기 위한 기능입니다. 함수 컴포넌트 내에서 useState, useEffect 등 React Hook을 사용하여 로직을 구현하고, 필요한 경우 반환 값으로 상태나 함수를 제공하여 다른 컴포넌트에서 재사용할 수 있도록 합니다.
저는 React로 클라이언트 로직을 구현할 때 습관적으로 useToggle, useModal, useAnalytics와 같은 커스텀 훅(Custom Hook)을 만들어 사용해왔습니다. 처음에는 단순히 반복되는 코드를 여러 번 입력하는 것이 번거로워 재사용성을 높이고자 커스텀 훅을 만들기 시작했습니다. 하지만 프론트엔드 개발자로서 일한지 1년이 지난 지금도 '커스텀 훅이 무엇인가?'라는 질문에 위와 같은 기계적인 답변 밖에 떠오르지 않았습니다. 생각해보면 사용할 줄만 알 뿐, 커스텀 훅이란 것에 대해 깊게 공부해본 적이 없었습니다.
아마 많은 프론트엔드 개발자분들이 저처럼 커스텀 훅이 무엇이고, 무엇을 할 수 있고, 왜, 그리고 어떻게 좋은지 명확히 설명하기 어려워하면서도 무의식적으로 사용하고 계실 것이라 생각합니다. 이번 기회를 통해 커스텀 훅에 대해 깊이 탐구하고 학습하며, 그 본질을 다시 한번 되짚어보고자 합니다.
커스텀 훅이란?
커스텀 훅에 대한 개념은 많은 분들이 알고 계시는 것과 동일합니다.
커스텀 훅(Custom Hook)은 React의 기본 훅(useState, useEffect, useContext 등)을 사용하여 상태 관련 로직(stateful logic)을 재사용 가능한 함수로 만드는 기능입니다.
React 공식 문서에서는 커스텀 훅을 다음과 같이 정의합니다.
"커스텀 훅은 이름이 use로 시작하는 JavaScript 함수입니다. 이 함수는 다른 훅을 호출할 수 있습니다."
핵심은 '로직의 재사용'에 있습니다. 컴포넌트의 기능이 복잡해지면 여러 컴포넌트에서 비슷한 로직(예: 데이터 fetching, 폼 상태 관리, 이벤트 리스너 등록)이 반복적으로 나타날 수 있습니다. 이때 커스텀 훅을 사용하면 해당 로직을 하나의 함수로 추출하여 여러 컴포넌트에서 간결하게 호출하여 사용할 수 있습니다.
훅은 왜 "훅"일까?
훅은 왜 "훅(Hook)"이라는 이름이 붙었을까요? 결론부터 보면, 함수 컴포넌트가 React의 핵심 기능(state, lifecycle 등)에 "갈고리(Hook)를 걸어" 필요한 기능을 가져와 사용한다는 의미에서 '훅'이라는 이름이 붙었습니다.
훅이 없던 시절: 클래스 컴포넌트의 시대
과거 React에서 상태(state)를 관리하거나, 컴포넌트가 생성/소멸할 때 특정 작업을 수행하는 생명주기(lifecycle) 기능을 사용하려면 반드시 클래스 컴포넌트(Class Component)를 사용해야 했습니다.
class Example extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 }; // state는 클래스 내부에서만 선언 가능
}
componentDidMount() { // 생명주기 메서드
document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() { // 생명주기 메서드
document.title = `You clicked ${this.state.count} times`;
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
반면, 함수 컴포넌트(Function Component)는 그저 props를 받아 UI를 그리는 역할만 하는 'Stateless' (상태가 없는) 컴포넌트였습니다. 상태나 생명주기 같은 React의 핵심 기능에 접근할 방법이 없었습니다.
"훅(Hook)"의 등장: 함수 컴포넌트의 혁명
React 팀은 클래스 컴포넌트의 복잡성(예: this 키워드의 혼란, 로직 재사용의 어려움)을 해결하고, 더 간결한 함수 컴포넌트에서 React의 모든 기능을 사용할 수 있게 하고 싶었습니다.
그래서 탄생한 것이 바로 '훅(Hook)'입니다!
useState, useEffect와 같은 훅 함수들은 이름 그대로, 평범했던 함수 컴포넌트에게 React의 보이지 않는 내부 시스템에 '갈고리를 걸 수 있는(hook into)' 능력을 부여했습니다.
- useState: 함수 컴포넌트가 React의 상태(state) 관리 시스템에 갈고리를 걸어 상태를 가질 수 있게 합니다.
- useEffect: 함수 컴포넌트가 React의 생명주기(lifecycle) 시스템에 갈고리를 걸어 컴포넌트의 생성, 업데이트, 소멸 시점에 코드를 실행할 수 있게 합니다.
- useContext: 함수 컴포넌트가 React의 컨텍스트(Context) 시스템에 갈고리를 걸어 전역 데이터를 쉽게 가져올 수 있게 합니다.
React 공식 문서에서도 훅을 소개하며 다음과 같이 설명합니다.
"Hooks are functions that let you 'hook into' React state and lifecycle features from function components." ("훅은 함수 컴포넌트에서 React의 상태와 생명주기 기능에 '연결(hook into)'할 수 있게 해주는 함수입니다.")
따라서 '훅'이라는 이름은 단순히 특별한 함수를 지칭하는 것을 넘어, 기능이 제한적이었던 함수 컴포넌트가 React의 강력한 핵심 기능들을 직접 가져와 사용할 수 있게 만드는 행위와 개념 그 자체를 매우 직관적으로 표현한 것이라고 할 수 있습니다!
커스텀 훅의 라이프사이클
커스텀 훅은 독립적인 라이프사이클을 갖지 않습니다. 훅은 그것을 사용하는 컴포넌트의 라이프사이클에 완전히 의존합니다.
- Mount: 컴포넌트가 처음 렌더링될 때, 해당 컴포넌트 내부에서 사용된 훅들도 함께 호출됩니다. useState는 초기 상태를 설정하고, useEffect는 첫 렌더링 이후에 콜백 함수를 실행합니다.
- Update: 컴포넌트가 리렌더링될 때마다 훅들도 다시 호출됩니다. useState는 최신 상태 값을 반환하고, useEffect는 의존성 배열(dependency array)의 변화에 따라 콜백 함수를 재실행할지 결정합니다.
- Unmount: 컴포넌트가 화면에서 사라질 때, useEffect의 반환 함수(cleanup function)가 호출되어 정리 작업을 수행합니다.
즉, 커스텀 훅은 컴포넌트의 일부처럼 동작하며, 상태와 사이드 이펙트를 컴포넌트에 '연결(hook into)'하는 역할을 합니다.
커스텀 훅의 장점
- 재사용성 (Reusability): 가장 큰 장점입니다. 여러 컴포넌트에서 반복되는 로직을 하나의 훅으로 만들어두면, 필요할 때마다 손쉽게 가져다 쓸 수 있습니다. 이는 코드 중복을 줄이고 개발 생산성을 크게 향상시킵니다.
- 관심사의 분리 (Separation of Concerns): 컴포넌트는 UI를 렌더링하는 역할에 집중하고, 복잡한 상태 관리나 비동기 처리, 사이드 이펙트 등의 로직은 커스텀 훅으로 분리할 수 있습니다. 이를 통해 코드가 훨씬 깔끔해지고 이해하기 쉬워집니다.
- 가독성 및 유지보수성 향상: 로직이 분리되면 컴포넌트 코드는 간결해집니다. useUserData처럼 의미 있는 이름의 훅을 사용하는 것만으로도 "이 컴포넌트는 사용자 데이터를 사용하는구나"라고 쉽게 파악할 수 있습니다. 이는 코드의 가독성을 높이고, 추후 로직 수정이 필요할 때 해당 훅만 수정하면 되므로 유지보수 또한 용이해집니다.
- 추상화 (Abstraction): 복잡한 로직의 내부 구현을 숨기고, 개발자는 훅이 반환하는 값(상태, 함수 등)에만 집중하면 됩니다. 예를 들어 useFetch 훅을 사용한다면, 내부적으로 axios를 쓰는지 fetch API를 쓰는지는 중요하지 않습니다. 그저 데이터를 가져오는 기능에만 집중할 수 있습니다.
커스텀 훅으로 할 수 있는 일!
UI 상태는 훅으로 얼마나 간단해질까?
단순한 true/false 토글 상태나 모달의 열림/닫힘 상태 관리는 매우 흔한 UI 로직입니다. 커스텀 훅을 사용하면 이를 매우 간단하게 만들 수 있습니다.
import { useState, useCallback } from 'react';
export function useToggle(
initialState: boolean = false,
): [boolean, () => void] {
const [state, setState] = useState(initialState);
const toggle = useCallback(() => {
setState(prev => !prev);
}, []);
return [state, toggle] as const;
}
이제 어떤 컴포넌트에서든 const [isToggled, toggle] = useToggle(false);
한 줄만으로 토글 상태와 그 상태를 변경하는 함수를 사용할 수 있습니다.
비동기 작업은 훅으로 어떻게 깔끔해질까?
데이터 fetching과 같은 비동기 작업은 status, data, error라는 3가지 상태를 관리해야 해서 코드가 복잡해지기 쉽습니다. useAsync와 같은 훅을 만들면 이를 체계적으로 관리할 수 있습니다.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: unknown };
export function useAsync<T>(
fn: () => Promise<T>,
deps: DependencyList = [],
) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
useEffect(() => {
const abort = new AbortController();
const run = async () => {
setState({ status: 'loading' });
try {
const data = await fn();
if (!abort.signal.aborted) setState({ status: 'success', data });
} catch (e) {
if (!abort.signal.aborted) setState({ status: 'error', error: e });
}
};
run();
return () => abort.abort();
}, deps);
return state;
}
컴포넌트에서는 const { status, error, data } = useAsync(fetchUsers);
와 같이 선언적으로 비동기 작업을 처리할 수 있게 됩니다.
브라우저 이벤트·디바이스 연동도 훅으로 가능할까?
화면 크기 변화, 스크롤 위치, 온라인 상태 등 브라우저나 디바이스의 상태와 직접 상호작용하는 로직은 useEffect를 사용한 이벤트 리스너의 등록 및 해제가 필수적입니다. 이러한 '설정'과 '정리' 로직을 커스텀 훅으로 캡슐화하면 재사용성이 극대화되고 컴포넌트의 복잡도는 크게 낮아집니다.
import { useState, useEffect } from 'react';
/**
* 브라우저 창의 현재 크기를 추적하는 커스텀 훅
* @returns {{width: number, height: number}} - 현재 창의 가로, 세로 크기
*/
export function useWindowSize() {
// 1. 창 크기를 저장할 state를 생성합니다.
// 초기값은 undefined이며, 클라이언트 사이드에서만 값을 계산합니다.
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
// 2. resize 이벤트를 처리할 핸들러 함수를 정의합니다.
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// 3. 컴포넌트가 마운트될 때 resize 이벤트 리스너를 추가하고,
// 초기 사이즈를 한 번 설정해 줍니다.
window.addEventListener('resize', handleResize);
handleResize(); // 초기 사이즈 설정을 위해 한 번 호출
// 4. 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다. (매우 중요!)
// 이를 'cleanup' 함수라고 부르며, 메모리 누수를 방지합니다.
return () => window.removeEventListener('resize', handleResize);
}, []); // 의존성 배열이 비어 있으므로, 마운트 시 1회만 실행됩니다.
return windowSize;
}
이처럼 useWindowSize 훅을 사용하면 이벤트 리스너를 직접 등록하거나 해제하는 복잡한 과정에 대해 전혀 신경 쓸 필요가 없습니다. 그저 필요한 값(width, height)을 선언적으로 가져다 쓰기만 하면 됩니다. 이와 같은 방식으로 useScroll(스크롤 위치 추적), useOnlineStatus(네트워크 상태 감지), useClipboard(클립보드 복사) 등 수많은 브라우저 API 관련 로직을 명료하고 재사용 가능한 훅으로 만들 수 있습니다.
커스텀 훅과 의존성 주입(DI)
여기서 더 나아가 커스텀 훅은 의존성 주입(Dependency Injection)을 통해 유연성을 극대화합니다. 훅이 필요로 하는 기능(의존성)을 외부에서 인자(argument)로 전달받는 방식입니다.
import { useState, useEffect } from 'react';
/** 비동기 함수를 받아 데이터를 반환하는 커스텀 훅 */
export function useFetch<T>(fetcher: () => Promise<T>): T | null {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
let cancelled = false;
fetcher()
.then(result => {
if (!cancelled) setData(result);
})
.catch(console.error); // 필요하다면 에러 상태도 추가하세요.
return () => {
cancelled = true; // 언마운트 후 setState 방지
};
}, [fetcher]);
return data;
}
// 사용 예시
import { useFetch } from './useFetch';
import { axiosFetcher, swrFetcher} from './api/fetchers';
const ComponentA: React.FC = () => {
const users = useFetch<User[]>(axiosFetcher);
return (
<ul>
{users?.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
};
const ComponentB: React.FC = () => {
const posts = useFetch<Post[]>(swrFetcher);
return (
<ul>
{posts?.map(p => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
};
이처럼 useFetch 훅은 어떻게 데이터를 가져올 것인지에 대한 책임을 외부로 위임합니다. 덕분에 이 훅은 axios 뿐만 아니라 브라우저 내장 fetch, SWR, React-Query 등 어떤 데이터 fetching 방식과도 함께 동작할 수 있는 높은 유연성과 재사용성을 확보하게 됩니다. 이는 특히 테스트 시 실제 API 대신 가짜(Mock) API 함수를 주입하여 훅을 독립적으로 검증할 수 있게 해줍니다.
레이어의 분리
커스텀 훅을 활용하면 UI 로직, 비즈니스 로직, 데이터 fetching 로직 등 코드의 레이어(Layer)를 명확하게 분리할 수 있습니다. 이는 애플리케이션의 아키텍처를 견고하게 만드는 핵심적인 역할을 합니다. 이 주제는 내용이 깊어 다음 아티클에서 더 자세히 다루도록 하겠습니다.
여러가지 커스텀 훅 예시
useToggle
import { useState, useCallback } from 'react';
/**
* boolean 값을 토글하는 커스텀 훅
* @param {boolean} initialState - 초기 상태값 (기본값: false)
* @returns {[boolean, () => void]} - [현재 상태, 토글 함수]
*/
export function useToggle(initialState = false) {
// 1. useState로 토글 상태를 관리합니다.
const [state, setState] = useState(initialState);
// 2. useCallback을 사용하여 함수를 메모이제이션합니다.
// 컴포넌트가 리렌더링되어도 toggle 함수는 재생성되지 않아 성능 최적화에 도움이 됩니다.
const toggle = useCallback(() => {
setState(prev => !prev);
}, []);
// 3. 현재 상태(state)와 상태를 변경하는 함수(toggle)를 배열 형태로 반환합니다.
return [state, toggle] as const;
}
useAsync
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: unknown };
export function useAsync<T>(
fn: () => Promise<T>,
deps: DependencyList = [],
) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
useEffect(() => {
const abort = new AbortController();
const run = async () => {
setState({ status: 'loading' });
try {
const data = await fn();
if (!abort.signal.aborted) setState({ status: 'success', data });
} catch (e) {
if (!abort.signal.aborted) setState({ status: 'error', error: e });
}
};
run();
return () => abort.abort();
}, deps); // deps 내부에 fn이나 필요한 파라미터 포함
return state;
}
useAsyncEffect
export function useAsyncEffect(
effect: () => Promise<void | (() => void)>,
deps?: DependencyList,
) {
useEffect(() => {
let cleanup: (() => void) | void;
effect().then(result => {
cleanup = result;
});
return () => {
cleanup?.();
};
}, deps);
}
비대해진 훅 관리 전략
커스텀 훅도 하나의 기능에 너무 많은 책임을 지게 되면 비대해지고 유지보수가 어려워집니다. 특히나 하나의 페이지의 모든 로직을 처리하는 usePage 라는 훅이 있다면 더욱 곤란할 것입니다. 이를 방지하기 위한 전략은 다음과 같습니다.
- 쪼개기 (단일 책임 원칙): 하나의 훅은 하나의 기능만 잘하도록 만듭니다. 예를 들어, 사용자 정보를 가져오고 수정하고 삭제하는 기능을 모두 담은 useUser 훅이 있다면, 이를 useFetchUser, useUpdateUser, useDeleteUser 등으로 분리하는 것을 고려할 수 있습니다.
- 데이터 계층화: 훅을 조합하여 더 고수준의 훅을 만들 수 있습니다. 예를 들어, useFetch라는 범용 fetching 훅을 먼저 만들고, 이 훅을 사용하여 useFetchUser, useFetchPosts와 같은 구체적인 데이터 소스를 다루는 훅을 만드는 방식입니다. 이를 통해 로직의 재사용성을 극대화하고 계층을 명확히 할 수 있습니다.
// 저수준 훅 (범용)
const useFetch = (url) => { /* ... fetching 로직 ... */ };
// 고수준 훅 (구체적)
const useFetchUser = (userId) => {
return useFetch(`/api/users/${userId}`);
};
const useFetchPosts = (userId) => {
return useFetch(`/api/posts?userId=${userId}`);
};
많은 프론트엔드 개발자들이 커스텀 훅을 잘 쓰면서도 설명하기 힘들어하는 이유
이는 커스텀 훅이 가진 '추상성'과 '선언적 프로그래밍' 패러다임 때문이라고 생각합니다.
- 직관적이지만 설명은 어려운 추상성: useToggle은 "토글 기능을 제공한다"는 것을 직관적으로 알 수 있습니다. 하지만 그 내부에서 useState와 useCallback이 어떻게 동작하고, 왜 useCallback으로 감싸야 하는지, 컴포넌트 리렌더링 시 어떤 일이 일어나는지를 단계별로 설명하는 것은 다른 차원의 문제입니다. 우리는 잘 만들어진 도구를 그 원리를 몰라도 잘 사용하는 것과 비슷합니다.
- 선언적 사고방식: 훅은 "무엇을 할 것인가"를 선언하는 방식에 가깝습니다. "로딩 중이면 스피너를 보여주고, 데이터가 있으면 목록을 보여준다"와 같이 상태에 따른 결과만 기술하면 됩니다. "어떻게" 데이터를 가져오고 상태를 변경하는지에 대한 절차적 로직은 훅 내부에 숨겨져 있습니다. 이 선언적인 방식이 매우 편리하기 때문에, 그 내부의 명령형 로직을 굳이 파헤쳐 보지 않게 되는 경향이 있습니다.
결론적으로, 커스텀 훅은 그 자체로 매우 효율적인 '도구'이기 때문에, 내부 동작 원리를 깊이 이해하지 못해도 당장의 생산성에는 큰 문제가 없어 보이기 때문일 것입니다.
마치며
지금까지 커스텀 훅의 개념부터 장점, 그리고 효과적인 관리 전략까지 살펴보았습니다. 커스텀 훅은 단순히 코드를 재사용하는 것을 넘어, React 애플리케이션을 더 선언적이고, 견고하며, 유지보수하기 쉽게 만드는 핵심적인 도구입니다.
무의식적으로 사용하던 코드에 '왜?'라는 질문을 던지고 그 본질을 탐구하는 과정은 개발자로서 한 단계 성장하는 중요한 계기가 된다고 생각합니다 :)
참조
- React 공식 문서 - 나만의 훅 만들기: https://react.dev/learn/reusing-logic-with-custom-hooks
- useHooks - A collection of modern, server-safe React hooks: https://usehooks.com/