Custom Hook 리팩토링: useAsync
문제 상황
API 데이터 페칭 로직은 대부분의 React 애플리케이션에서 반복적으로 사용됩니다. 하지만 매번 비슷한 패턴의 코드를 작성하면 다음과 같은 문제가 발생합니다.
🚨 문제점
- 코드 중복: loading, error, data 상태를 매번 선언
- 유지보수 어려움: 에러 처리 로직을 여러 곳에서 수정해야 함
- 버그 가능성: cleanup 로직 누락으로 메모리 누수 발생
- 테스트 어려움: 컴포넌트와 로직이 결합되어 테스트 복잡
Before: 코드 중복 발생
// UserProfile.tsx
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
})
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// PostList.tsx
function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch('/api/posts')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
})
.then(data => {
if (!cancelled) {
setPosts(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// Comments.tsx - 또 동일한 패턴 반복...
// Products.tsx - 또 동일한 패턴 반복...
// 😱 매번 30줄 이상의 코드 중복!
문제 분석
// 각 컴포넌트마다 반복되는 패턴
const [data, setData] = useState(null); // 1. 상태 선언 (3줄)
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // 2. cleanup 플래그
setLoading(true); // 3. 로딩 시작
setError(null); // 4. 에러 초기화
fetch(url) // 5. 데이터 페칭
.then(/* ... */) // 6. 성공 처리
.catch(/* ... */); // 7. 에러 처리
return () => { cancelled = true; }; // 8. cleanup
}, [dependencies]); // 9. 의존성 관리
// 매번 이 9가지 단계를 반복! 😫
해결 방법: Custom Hook
1. useAsync Hook 생성
// hooks/useAsync.ts
import { useState, useEffect, useCallback } from 'react';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
interface UseAsyncReturn<T> extends AsyncState<T> {
execute: () => Promise<void>;
reset: () => void;
}
function useAsync<T>(
asyncFunction: () => Promise<T>,
immediate: boolean = true
): UseAsyncReturn<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: immediate,
error: null,
});
const execute = useCallback(async () => {
setState({ data: null, loading: true, error: null });
try {
const response = await asyncFunction();
setState({ data: response, loading: false, error: null });
} catch (error) {
setState({ data: null, loading: false, error: error as Error });
}
}, [asyncFunction]);
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null });
}, []);
useEffect(() => {
if (immediate) {
execute();
}
// cleanup: 컴포넌트 언마운트 시 진행 중인 요청 무시
return () => {
// Note: 실제로는 AbortController 사용 권장
};
}, [execute, immediate]);
return {
...state,
execute,
reset,
};
}
export default useAsync;
2. After: Hook 사용
// UserProfile.tsx - 30줄 → 15줄로 축소!
function UserProfile({ userId }: { userId: number }) {
const fetchUser = useCallback(
() => fetch(`/api/users/${userId}`).then(res => res.json()),
[userId]
);
const { data: user, loading, error } = useAsync<User>(fetchUser);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// PostList.tsx - 30줄 → 12줄로 축소!
function PostList() {
const { data: posts, loading, error } = useAsync<Post[]>(
() => fetch('/api/posts').then(res => res.json())
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
리팩토링 효과 비교
📊 코드 라인 수 비교
| 항목 | Before | After | 감소율 |
|---|---|---|---|
| UserProfile.tsx | 33줄 | 15줄 | -55% |
| PostList.tsx | 30줄 | 12줄 | -60% |
| Comments.tsx | 28줄 | 10줄 | -64% |
| 총 합계 | 91줄 | 37줄 | -59% |
✅ 개선 효과
1. 코드 중복 제거
// Before: 각 컴포넌트마다 반복
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// + useEffect 20줄...
// After: 한 줄로 간단하게
const { data, loading, error } = useAsync(fetchData);
2. 에러 처리 일관성
// Before: 각 컴포넌트마다 다른 에러 처리
.catch(err => {
console.log(err); // 어디는 console.log
setError(err.message); // 어디는 message만
setError(err); // 어디는 전체 객체
});
// After: Hook에서 통일된 에러 처리
// 모든 곳에서 동일한 방식으로 에러 처리
3. 메모리 누수 방지
// Before: cleanup 로직을 빼먹기 쉬움 😱
useEffect(() => {
fetch(url).then(setData);
// ❌ return cleanup 누락!
}, [url]);
// After: Hook이 자동으로 cleanup 처리 ✅
const { data } = useAsync(fetchData);
4. 재사용성 극대화
// 동일한 Hook을 다양한 곳에서 재사용
const userProfile = useAsync(() => fetchUser(userId));
const userPosts = useAsync(() => fetchPosts(userId));
const userComments = useAsync(() => fetchComments(userId), false); // 수동 실행
5. 테스트 용이성
// Before: 컴포넌트 전체를 테스트해야 함
test('UserProfile renders user data', async () => {
render(<UserProfile userId={1} />);
// fetch mock, waitFor, 복잡한 테스트...
});
// After: Hook만 독립적으로 테스트
test('useAsync fetches data correctly', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(() => Promise.resolve('data'))
);
await waitForNextUpdate();
expect(result.current.data).toBe('data');
});
📈 유지보수 개선
Before: 여러 파일 수정 필요
// 에러 처리 로직 변경 시
// ❌ 10개 파일 모두 수정해야 함
UserProfile.tsx ← 수정
PostList.tsx ← 수정
Comments.tsx ← 수정
Products.tsx ← 수정
Orders.tsx ← 수정
// ... 10개 파일
After: 한 파일만 수정
// ✅ useAsync.ts 파일 하나만 수정
hooks/useAsync.ts ← 여기만 수정하면 끝!
왜 외부 라이브러리 대신 Custom Hook인가?
라이브러리 vs Custom Hook 비교
| 비교 항목 | React Query / SWR | Zustand | Custom Hook |
|---|---|---|---|
| 번들 크기 | 40-50KB | 3KB | 0KB (추가 없음) |
| 학습 곡선 | 높음 | 중간 | 낮음 |
| 프로젝트 의존성 | 높음 | 중간 | 없음 |
| 커스터마이징 | 제한적 | 중간 | 완전 자유 |
| React 버전 | 특정 버전 필요 | 특정 버전 필요 | 모든 버전 |
| 유지보수 | 라이브러리 업데이트 필요 | 라이브러리 업데이트 필요 | 직접 제어 |
1. 번들 크기 절약
# React Query
npm install @tanstack/react-query # 📦 ~40KB
# SWR
npm install swr # 📦 ~30KB
# Zustand
npm install zustand # 📦 ~3KB
# Custom Hook
# ✅ 0KB! 추가 패키지 없음
실제 영향:
// React Query 사용 시
import { useQuery } from '@tanstack/react-query';
// + 40KB의 코드가 번들에 포함
// + Provider 설정 필요
// + QueryClient 설정 필요
// Custom Hook 사용 시
import { useAsync } from './hooks/useAsync';
// ✅ 단 2KB의 코드만 포함
// ✅ 추가 설정 불필요
2. 학습 곡선 최소화
// ❌ React Query: 새로운 API 학습 필요
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5000,
cacheTime: 10000,
refetchOnWindowFocus: true,
// 🤔 이게 다 뭐지? 공식 문서 읽어야 함
});
// ✅ Custom Hook: React 기본 지식만으로 이해 가능
const { data, loading, error } = useAsync(() => fetchUser(userId));
// 😊 간단! useState, useEffect만 알면 됨
3. 프로젝트 의존성 감소
// ❌ 라이브러리 의존
// package.json
{
"dependencies": {
"react-query": "^3.39.3", // 라이브러리 버전 관리 필요
"zustand": "^4.3.8" // 보안 패치 추적 필요
}
}
// 문제 상황
// - react-query 메이저 업데이트 → 마이그레이션 필요
// - 보안 취약점 발견 → 긴급 업데이트 필요
// - 라이브러리 deprecated → 대체 라이브러리 찾아야 함
// ✅ Custom Hook: 의존성 없음
{
"dependencies": {
"react": "^18.2.0" // React만 있으면 됨
}
}
4. 완전한 커스터마이징
// ❌ React Query: 제한적인 커스터마이징
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
// 🚫 특정 케이스에서 원하는 동작을 구현하기 어려움
// 🚫 라이브러리의 설계 철학에 따라야 함
});
// ✅ Custom Hook: 원하는 대로 수정 가능
function useAsync<T>(asyncFn: () => Promise<T>) {
// ✅ 우리 프로젝트에 맞게 자유롭게 수정
// ✅ 특수한 에러 처리 로직 추가
// ✅ 로깅, 모니터링 통합
// ✅ 회사 정책에 맞는 캐싱 전략
// 예: 회사 정책 - 모든 API 호출을 로깅
useEffect(() => {
logger.track('API_CALL', { function: asyncFn.name });
}, []);
// 예: 특정 에러 코드 처리
if (error?.code === 'AUTH_ERROR') {
redirectToLogin();
}
}
5. 프로젝트 규모에 따른 선택
작고 중간 규모 프로젝트 → Custom Hook 추천
// 요구사항이 간단한 경우
// ✅ 기본적인 데이터 페칭
// ✅ loading/error 상태 관리
// ✅ 간단한 캐싱
// Custom Hook으로 충분!
const { data, loading, error } = useAsync(fetchData);
대규모 프로젝트 + 복잡한 요구사항 → 라이브러리 고려
// 복잡한 요구사항이 있는 경우
// - 자동 재시도 (exponential backoff)
// - 정교한 캐싱 전략
// - Optimistic updates
// - 서버 상태 동기화
// - Infinite scroll
// - Prefetching
// - Background refetching
// → 이런 경우 React Query 사용 권장
6. 실제 프로젝트 결정 가이드
Custom Hook을 선택해야 할 때 ✅
// 1. 프로젝트 초기 단계
// 2. 간단한 CRUD 애플리케이션
// 3. 번들 크기가 중요한 경우
// 4. 팀원들의 React 경험이 다양한 경우
// 5. 빠른 프로토타이핑이 필요한 경우
라이브러리를 선택해야 할 때 ⚠️
// 1. 복잡한 서버 상태 동기화 필요
// 2. 고급 캐싱 전략 필요
// 3. 대규모 데이터 fetching
// 4. 팀이 이미 해당 라이브러리 경험 보유
// 5. 시간이 촉박하고 검증된 솔루션 필요
7. 점진적 마이그레이션 가능
// 장점: Custom Hook으로 시작 → 필요시 라이브러리로 전환 쉬움
// Step 1: Custom Hook으로 시작
const { data, loading } = useAsync(fetchUser);
// Step 2: 나중에 React Query 필요하면 쉽게 교체
const { data, isLoading } = useQuery(['user'], fetchUser);
// 🎯 컴포넌트 코드는 거의 변경 불필요!
8. 실제 사례 비교
사례 1: 스타트업 MVP 개발
// Before: React Query 도입
// - 팀원 학습 시간: 2주
// - 번들 크기 증가: 40KB
// - 사용하는 기능: 기본 fetching만
// After: Custom Hook 사용
// - 팀원 학습 시간: 1일
// - 번들 크기: 변화 없음
// - 필요한 기능 모두 충족
// 결과: 개발 속도 2배 향상 ⚡
사례 2: 대규모 대시보드 애플리케이션
// Before: Custom Hook 사용
// - 복잡한 캐싱 로직 직접 구현
// - 버그 발생 빈도 높음
// - 유지보수 비용 증가
// After: React Query 도입
// - 검증된 캐싱 전략
// - 버그 대폭 감소
// - DevTools로 디버깅 쉬움
// 결과: 안정성 향상, 유지보수 비용 50% 감소 📉
실전 활용 예제
예제 1: 재시도 로직 추가
// hooks/useAsync.ts
function useAsync<T>(
asyncFunction: () => Promise<T>,
options?: {
immediate?: boolean;
retries?: number;
retryDelay?: number;
}
) {
const { immediate = true, retries = 0, retryDelay = 1000 } = options || {};
const execute = useCallback(async () => {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try {
setState({ data: null, loading: true, error: null });
const response = await asyncFunction();
setState({ data: response, loading: false, error: null });
return;
} catch (error) {
lastError = error as Error;
if (i < retries) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
setState({ data: null, loading: false, error: lastError });
}, [asyncFunction, retries, retryDelay]);
// ...
}
// 사용
const { data, loading, error } = useAsync(
() => fetch('/api/unstable-endpoint').then(r => r.json()),
{ retries: 3, retryDelay: 1000 }
);
예제 2: AbortController로 취소 처리
function useAsync<T>(asyncFunction: () => Promise<T>) {
const abortControllerRef = useRef<AbortController | null>(null);
const execute = useCallback(async () => {
// 이전 요청 취소
abortControllerRef.current?.abort();
// 새로운 AbortController 생성
abortControllerRef.current = new AbortController();
setState({ data: null, loading: true, error: null });
try {
const response = await asyncFunction();
setState({ data: response, loading: false, error: null });
} catch (error) {
if (error.name === 'AbortError') {
// 취소된 요청은 무시
return;
}
setState({ data: null, loading: false, error: error as Error });
}
}, [asyncFunction]);
useEffect(() => {
return () => {
// 컴포넌트 언마운트 시 진행 중인 요청 취소
abortControllerRef.current?.abort();
};
}, []);
return { ...state, execute, reset };
}
예제 3: 수동 실행 모드
function SearchComponent() {
const [query, setQuery] = useState('');
// immediate: false로 설정하면 자동 실행 안 함
const { data, loading, execute } = useAsync(
() => fetch(`/api/search?q=${query}`).then(r => r.json()),
false // 수동 모드
);
const handleSearch = () => {
if (query.trim()) {
execute(); // 버튼 클릭 시 수동 실행
}
};
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="검색어 입력"
/>
<button onClick={handleSearch} disabled={loading}>
{loading ? '검색 중...' : '검색'}
</button>
{data && (
<ul>
{data.results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}
예제 4: 여러 API 동시 호출
function UserDashboard({ userId }: { userId: number }) {
const profile = useAsync<User>(
() => fetch(`/api/users/${userId}`).then(r => r.json())
);
const posts = useAsync<Post[]>(
() => fetch(`/api/users/${userId}/posts`).then(r => r.json())
);
const followers = useAsync<User[]>(
() => fetch(`/api/users/${userId}/followers`).then(r => r.json())
);
// 모든 데이터가 로딩 중인지 확인
const isLoading = profile.loading || posts.loading || followers.loading;
// 하나라도 에러가 있는지 확인
const hasError = profile.error || posts.error || followers.error;
if (isLoading) return <div>Loading...</div>;
if (hasError) return <div>Error occurred</div>;
return (
<div>
<h1>{profile.data?.name}</h1>
<p>Posts: {posts.data?.length}</p>
<p>Followers: {followers.data?.length}</p>
</div>
);
}
예제 5: 조건부 데이터 페칭
function PostComments({ postId, isOpen }: { postId: number; isOpen: boolean }) {
const { data: comments, loading } = useAsync(
() => fetch(`/api/posts/${postId}/comments`).then(r => r.json()),
isOpen // isOpen이 true일 때만 데이터 페칭
);
if (!isOpen) return null;
if (loading) return <div>Loading comments...</div>;
return (
<div>
{comments?.map(comment => (
<div key={comment.id}>{comment.text}</div>
))}
</div>
);
}
정리
Custom Hook의 가치
- 단순성: React의 기본 개념만으로 구현
- 제로 의존성: 추가 패키지 불필요
- 완전한 제어: 프로젝트 요구사항에 맞게 자유롭게 수정
- 점진적 확장: 필요할 때 기능 추가 가능
언제 사용할까?
- ✅ 프로젝트 초기 단계
- ✅ 간단한 데이터 페칭이 필요한 경우
- ✅ 번들 크기를 최소화해야 하는 경우
- ✅ 팀의 React 이해도가 다양한 경우
언제 라이브러리를 고려할까?
- ⚠️ 복잡한 캐싱 전략이 필요한 경우
- ⚠️ 서버 상태 동기화가 중요한 경우
- ⚠️ 대규모 데이터 처리가 필요한 경우
- ⚠️ 검증된 솔루션이 빠르게 필요한 경우
핵심: 작게 시작해서 필요할 때 확장하자. Custom Hook으로 시작했다가 나중에 React Query로 전환하는 것은 어렵지 않지만, 처음부터 과도한 도구를 도입하면 복잡도만 증가한다.
참고 자료
React 공식 문서
- Reusing Logic with Custom Hooks - Custom Hook 작성 가이드
- useEffect 레퍼런스 - Effect Hook 사용법
- useState 레퍼런스 - State Hook 사용법
- useCallback 레퍼런스 - 콜백 메모이제이션
- Rules of Hooks - Hook 사용 규칙
웹 API 문서 (MDN)
- AbortController - 비동기 작업 취소
- Fetch API - HTTP 요청
- Promise - 비동기 처리
비교 대상 라이브러리
데이터 페칭 라이브러리
- TanStack Query (React Query) - 강력한 서버 상태 관리 라이브러리
- SWR - Vercel의 데이터 페칭 라이브러리
- RTK Query - Redux Toolkit의 데이터 페칭 솔루션
상태 관리 라이브러리
관련 아티클 및 블로그
- How to fetch data with React Hooks - Robin Wieruch
- useEffect vs useLayoutEffect - Kent C. Dodds
- The State of React State Management in 2024 - Frontend Mastery
- React Query vs SWR - LogRocket Blog
테스팅
- React Hooks Testing Library - Hooks 테스트 라이브러리
- Testing Library - React 테스팅 라이브러리
TypeScript
- React TypeScript Cheatsheet - TypeScript + React 가이드
- TypeScript Handbook - Generics - 제네릭 타입
성능 최적화
- React Dev: useMemo - 메모이제이션 최적화
- React Dev: useCallback - 함수 메모이제이션
- Web.dev: Code Splitting - 코드 스플리팅
추가 학습 자료
- Patterns.dev - React 디자인 패턴
- React Hooks in TypeScript - TypeScript Hook 패턴
- Overreacted Blog by Dan Abramov - React 핵심 개발자의 블로그
댓글