React Hook은 함수형 컴포넌트에서 상태와 생명주기를 다룰 수 있게 해주는 강력한 도구입니다. 이 글에서는 단순한 사용법을 넘어, 실제 프로덕션에서 안전하고 효율적으로 React Hook을 활용하는 방법을 TypeScript 기준으로 상세히 다룹니다.
React Hook 가이드 목차
📌 React Hook의 두 가지 철칙
Hook을 사용할 때 반드시 지켜야 할 두 가지 규칙이 있습니다:
- 최상위(Top Level)에서만 호출: 조건문, 반복문, 중첩 함수 안에서 호출 금지
- React 함수 내에서만 호출: React 컴포넌트나 커스텀 Hook에서만 사용
// ❌ 잘못된 예시
function Component({ isAdmin }: Props) {
if (isAdmin) {
const [adminData, setAdminData] = useState(null); // 조건문 안에서 호출
}
for (let i = 0; i < items.length; i++) {
const [item, setItem] = useState(items[i]); // 반복문 안에서 호출
}
}
// ✅ 올바른 예시
function Component({ isAdmin }: Props) {
const [adminData, setAdminData] = useState(null);
const [items, setItems] = useState<Item[]>([]);
// 조건부 로직은 Hook 호출 이후에
useEffect(() => {
if (isAdmin) {
fetchAdminData().then(setAdminData);
}
}, [isAdmin]);
}🎯 핵심 React Hook 완벽 가이드
1. useState – 파생 상태는 계산으로
// ❌ 안티패턴: 파생 가능한 값을 상태로 저장
function BadExample({ items }: { items: Item[] }) {
const [filteredItems, setFilteredItems] = useState<Item[]>([]);
const [filter, setFilter] = useState('');
// filter가 바뀔 때마다 동기화 필요 - 버그 발생 가능
useEffect(() => {
setFilteredItems(items.filter(item => item.name.includes(filter)));
}, [items, filter]);
}
// ✅ 좋은 패턴: 파생 값은 계산
function GoodExample({ items }: { items: Item[] }) {
const [filter, setFilter] = useState('');
// 렌더링마다 계산되지만 성능 문제시에만 useMemo 고려
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}
// 💡 함수형 업데이트: 이전 값 기반 갱신
const [count, setCount] = useState(0);
const increment = () => setCount(prev => prev + 1); // Race condition 방지2. useEffect를 안전하게 쓰는 7가지 패턴
패턴 1: 의존성 배열 정직하게 작성
// ESLint의 exhaustive-deps 규칙을 반드시 켜두세요
useEffect(() => {
// userId와 options를 사용하므로 의존성에 포함
fetchUserData(userId, options);
}, [userId, options]); // 실제 사용한 모든 값 포함패턴 2: 비동기 작업 취소 (AbortController)
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
const search = async () => {
try {
setError(null);
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
setResults(data);
} catch (err) {
// AbortError는 무시
if ((err as Error).name !== 'AbortError') {
setError(err as Error);
}
}
};
if (query) search();
// 클린업: 이전 요청 취소
return () => controller.abort();
}, [query]);
return (
<>
{error && <div>Error: {error.message}</div>}
{results.map(r => <SearchResult key={r.id} {...r} />)}
</>
);
}패턴 3: 이벤트 리스너 클린업
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
// 초기값 설정
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // 마운트 시 한 번만
return size;
}패턴 4: Stale Closure 방지
function Timer() {
const [count, setCount] = useState(0);
const savedCallback = useRef<() => void>();
// 최신 callback 저장
useEffect(() => {
savedCallback.current = () => {
setCount(count + 1); // count는 항상 최신값
};
});
useEffect(() => {
const id = setInterval(() => {
savedCallback.current?.(); // 최신 callback 호출
}, 1000);
return () => clearInterval(id);
}, []); // 의존성 없음
}패턴 5: 조건부 상태 업데이트로 무한 루프 방지
useEffect(() => {
const newValue = computeExpensiveValue(props);
// 값이 실제로 변경될 때만 업데이트
setDerivedState(prev => {
if (prev === newValue) return prev; // 동일하면 업데이트 안 함
return newValue;
});
}, [props]);3. 성능 최적화: useMemo/useCallback 언제 써야 하나?
` 써야 하는 경우 ✅
// 1. 비용이 큰 계산
const expensiveResult = useMemo(() => {
return heavyComputation(largeDataSet);
}, [largeDataSet]);
// 2. 참조 동일성이 중요한 경우 (React.memo 자식 컴포넌트)
const ChildMemo = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
// useCallback 없으면 매번 새 함수 → 자식 리렌더
const handleClick = useCallback(() => {
console.log('Clicked');
}, []); // 의존성 없으면 한 번만 생성
return <ChildMemo onClick={handleClick} />;
}
// 3. 무거운 객체/배열을 props로 전달
const chartData = useMemo(() =>
processDataForChart(rawData),
[rawData]
);` 안 써도 되는 경우 ❌
// 가벼운 계산은 메모화 오버헤드가 더 클 수 있음
const isEven = useMemo(() => number % 2 === 0, [number]); // ❌ 과도함
// 자식이 memo를 사용하지 않으면 의미 없음
function RegularChild({ onClick }: { onClick: () => void }) {
return <button onClick={onClick}>Click</button>;
}
function Parent() {
// RegularChild는 어차피 리렌더되므로 useCallback 불필요
const handleClick = () => console.log('Clicked'); // ✅ 단순하게
}4. useReducer – 복잡한 상태 관리
type State = {
loading: boolean;
error: Error | null;
data: User | null;
};
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User }
| { type: 'FETCH_ERROR'; payload: Error }
| { type: 'RESET' };
function userReducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { loading: false, error: null, data: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'RESET':
return { loading: false, error: null, data: null };
default:
return state;
}
}
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(userReducer, {
loading: false,
error: null,
data: null,
});
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetchUser(userId)
.then(user => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
.catch(error => dispatch({ type: 'FETCH_ERROR', payload: error }));
}, [userId]);
if (state.loading) return <Spinner />;
if (state.error) return <Error error={state.error} />;
if (!state.data) return null;
return <UserCard user={state.data} />;
}5. Context + Hook으로 전역 상태 관리
// auth-context.tsx
type User = { id: string; name: string; role: 'admin' | 'user' };
type AuthState = {
user: User | null;
loading: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthState | null>(null);
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 초기 인증 상태 복원
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
validateToken(token)
.then(setUser)
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = useCallback(async (credentials: Credentials) => {
const { user, token } = await authenticate(credentials);
localStorage.setItem('token', token);
setUser(user);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
setUser(null);
}, []);
// value 객체 메모이제이션 - 리렌더 최소화
const value = useMemo(
() => ({ user, loading, login, logout }),
[user, loading, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
// 타입 안전한 커스텀 Hook
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};🔧 실전 커스텀 Hook 패턴
1. useDebounce – 입력 지연 처리
export function useDebounce<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 사용 예시
function SearchInput() {
const [input, setInput] = useState('');
const debouncedQuery = useDebounce(input, 300);
const { data, loading } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchAPI(debouncedQuery),
enabled: debouncedQuery.length > 2,
});
}2. useLocalStorage – 영속성 있는 상태
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// 초기값 lazy initialization
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// 값 설정 함수
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
setStoredValue(prevValue => {
const nextValue = value instanceof Function ? value(prevValue) : value;
window.localStorage.setItem(key, JSON.stringify(nextValue));
return nextValue;
});
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
},
[key]
);
return [storedValue, setValue];
}3. useIntersectionObserver – 무한 스크롤
interface UseIntersectionObserverProps {
threshold?: number;
root?: Element | null;
rootMargin?: string;
enabled?: boolean;
}
export function useIntersectionObserver(
ref: RefObject<Element>,
options: UseIntersectionObserverProps = {}
): boolean {
const { threshold = 0, root = null, rootMargin = '0px', enabled = true } = options;
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
if (!enabled || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
{ threshold, root, rootMargin }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, threshold, root, rootMargin, enabled]);
return isIntersecting;
}
// 사용 예시: 무한 스크롤
function InfiniteList() {
const loadMoreRef = useRef<HTMLDivElement>(null);
const isLoadMoreVisible = useIntersectionObserver(loadMoreRef);
useEffect(() => {
if (isLoadMoreVisible) {
loadNextPage();
}
}, [isLoadMoreVisible]);
return (
<>
{items.map(item => <Item key={item.id} {...item} />)}
<div ref={loadMoreRef}>Loading more...</div>
</>
);
}4. useSyncExternalStore – 외부 스토어 구독
// 뷰포트 사이즈 구독 예시
function subscribe(callback: () => void) {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}
function getSnapshot() {
return {
width: window.innerWidth,
height: window.innerHeight,
};
}
function getServerSnapshot() {
return { width: 0, height: 0 }; // SSR용
}
export function useViewport() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}🚨 자주 발생하는 버그와 해결책
1. 조건부 Hook 호출
// ❌ 버그: 조건에 따라 Hook 호출 순서가 달라짐
function BadComponent({ isSpecial }: { isSpecial: boolean }) {
if (isSpecial) {
const [special, setSpecial] = useState(true); // 💥 에러!
}
}
// ✅ 해결: Hook을 항상 호출하고 조건부 로직은 내부에서
function GoodComponent({ isSpecial }: { isSpecial: boolean }) {
const [special, setSpecial] = useState(false);
useEffect(() => {
if (isSpecial) {
setSpecial(true);
}
}, [isSpecial]);
}2. 의존성 누락으로 인한 Stale Closure
// ❌ 버그: count가 항상 0으로 고정
function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 항상 0 출력
}, 1000);
return () => clearInterval(id);
}, []); // count 누락!
}
// ✅ 해결: 함수형 업데이트 또는 ref 사용
function GoodCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => {
console.log(prev + 1);
return prev + 1;
});
}, 1000);
return () => clearInterval(id);
}, []); // 의존성 없어도 OK
}3. Context Value 재생성으로 인한 리렌더 폭발
// ❌ 버그: value 객체가 매번 새로 생성
function BadProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}> {/* 매번 새 객체 */}
{children}
</UserContext.Provider>
);
}
// ✅ 해결: useMemo로 메모이제이션
function GoodProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({ user, setUser }),
[user] // user가 변경될 때만 새 객체
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}📊 React Hook 선택 가이드

🎯 요약
- React Hook은 규칙을 지켜야 합니다 – 최상위에서만, React 함수 내에서만
- 의존성 배열은 정직하게 – ESLint를 믿고 모든 의존성 포함
- 파생 상태는 계산으로 – 동기화 지옥을 피하세요
- 메모이제이션은 측정 후 – 무작정 쓰면 오히려 느려집니다
- Context value는 메모 필수 – 리렌더 폭발 방지
- 커스텀 Hook으로 로직 재사용 – 명확한 인터페이스로 설계
- 클린업을 잊지 마세요 – 메모리 누수는 실서비스의 적
🎯 마무리
React Hook은 함수형 프로그래밍의 장점을 React에 도입하여 더 깨끗하고 재사용 가능한 코드를 작성할 수 있게 해줍니다. 처음에는 클래스 컴포넌트에서 Hook으로의 전환이 어려울 수 있지만, 한 번 익숙해지면 훨씬 더 직관적이고 강력한 도구임을 깨닫게 될 것입니다.
React Hook을 마스터하는 가장 좋은 방법은 직접 사용해보는 것입니다. 작은 프로젝트부터 시작해서 점진적으로 복잡한 애플리케이션에 적용해보세요. Custom Hook을 작성하여 팀 내에서 공유하고, 커뮤니티의 다양한 React Hook 라이브러리들도 살펴보시기 바랍니다.
📚 추가 학습 자료
React 공식 문서 – HookuseHooks.com – Custom Hook 예시 모음
React Hook Form – Form 관리 라이브러리
SWR / React Query – 데이터 페칭 Hook 라이브러리
