# 고급 React Hooks: 패턴과 최적화 > 검증된 패턴으로 고급 React Hooks를 마스터합니다. 커스텀 훅, 최적화된 useEffect, useMemo, useCallback, 그리고 성능 기법. - Published: 2026-02-16 - Updated: 2026-04-28 - Author: SharpSkill - Tags: react hooks, useEffect, custom hooks, performance, react patterns - Reading time: 12 min --- React Hooks는 함수형 컴포넌트에서 state와 사이드 이펙트를 다루는 방식을 크게 바꿨습니다. `useState`와 `useEffect`의 기본 사용을 넘어서, 고급 패턴을 익히면 재사용 가능하고 성능이 우수하며 유지보수하기 쉬운 코드를 작성할 수 있습니다. > **사전 지식** > > 이 가이드는 기본적인 Hooks(useState, useEffect, useContext)에 대한 친숙함을 전제로 합니다. 예제는 견고한 타입 지정을 위해 React 18+와 TypeScript를 사용합니다. ## useEffect 마스터하기: 흔한 함정 피하기 `useEffect`는 자주 오해되고 잘못 사용됩니다. 동작을 깊이 이해하면 미묘한 버그와 성능 문제를 피할 수 있습니다. 기본 원칙은 단순합니다. `useEffect`는 컴포넌트를 외부 시스템과 동기화하기 위한 것입니다. 효과가 외부(API, DOM, 타이머 등)와 통신하지 않는다면, 아마도 그 효과는 필요 없습니다. ```tsx // hooks/useDocumentTitle.ts // Custom hook to synchronize document title import { useEffect } from 'react' export function useDocumentTitle(title: string) { useEffect(() => { // Save the previous title const previousTitle = document.title // Update the title document.title = title // Cleanup: restore previous title on unmount return () => { document.title = previousTitle } }, [title]) // Only trigger when title changes } ``` 가장 흔한 실수는 의존성 배열을 누락하거나 불안정한 값을 포함하는 것입니다. 효과 안에서 사용하는 모든 값은 의존성에 나열해야 합니다. ```tsx // components/UserProfile.tsx 'use client' import { useEffect, useState } from 'react' interface User { id: string name: string email: string } interface UserProfileProps { userId: string } export function UserProfile({ userId }: UserProfileProps) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { // Flag to prevent state updates after unmount let isMounted = true // Controller to cancel pending requests const controller = new AbortController() async function fetchUser() { setLoading(true) setError(null) try { const response = await fetch(`/api/users/${userId}`, { signal: controller.signal }) if (!response.ok) { throw new Error('User not found') } const data = await response.json() // Check if component is still mounted if (isMounted) { setUser(data) } } catch (err) { // Ignore abort errors if (err instanceof Error && err.name !== 'AbortError') { if (isMounted) { setError(err.message) } } } finally { if (isMounted) { setLoading(false) } } } fetchUser() // Cleanup: abort request and mark as unmounted return () => { isMounted = false controller.abort() } }, [userId]) // Re-run effect when userId changes if (loading) return
Loading...
if (error) return
Error: {error}
if (!user) return null return (

{user.name}

{user.email}

) } ``` `AbortController`와 `isMounted` 플래그를 함께 사용하는 이 패턴은 메모리 누수와 언마운트된 컴포넌트에 대한 state 업데이트를 방지합니다. ## 재사용 가능한 커스텀 훅 만들기 커스텀 훅은 재사용 가능한 로직을 캡슐화합니다. 좋은 커스텀 훅은 단일 책임 원칙을 따르고 명확한 API를 제공합니다. ```tsx // hooks/useLocalStorage.ts // Generic hook to persist state in localStorage import { useState, useEffect, useCallback } from 'react' export function useLocalStorage( key: string, initialValue: T ): [T, (value: T | ((prev: T) => T)) => void, () => void] { // Initialize state with stored value or default const [storedValue, setStoredValue] = useState(() => { if (typeof window === 'undefined') { return initialValue } try { const item = window.localStorage.getItem(key) return item ? JSON.parse(item) : initialValue } catch (error) { console.warn(`Error reading localStorage "${key}":`, error) return initialValue } }) // Sync with localStorage on every change useEffect(() => { if (typeof window === 'undefined') return try { window.localStorage.setItem(key, JSON.stringify(storedValue)) } catch (error) { console.warn(`Error writing localStorage "${key}":`, error) } }, [key, storedValue]) // Function to update value const setValue = useCallback((value: T | ((prev: T) => T)) => { setStoredValue((prev) => { const nextValue = value instanceof Function ? value(prev) : value return nextValue }) }, []) // Function to remove value const removeValue = useCallback(() => { setStoredValue(initialValue) if (typeof window !== 'undefined') { window.localStorage.removeItem(key) } }, [key, initialValue]) return [storedValue, setValue, removeValue] } ``` 이 훅은 데이터를 영속적으로 저장하고 싶은 어떤 컴포넌트에서도 사용할 수 있습니다. ```tsx // components/ThemeToggle.tsx 'use client' import { useLocalStorage } from '@/hooks/useLocalStorage' type Theme = 'light' | 'dark' | 'system' export function ThemeToggle() { const [theme, setTheme] = useLocalStorage('theme', 'system') return ( ) } ``` > **명명 규칙** > > 커스텀 훅은 항상 "use"로 시작합니다(useLocalStorage, useFetch, useDebounce). 이 규칙 덕분에 React가 훅 규칙을 검증하고 린팅 도구가 올바르게 동작합니다. ## useReducer를 활용한 컴포지션 패턴 여러 액션을 가진 복잡한 state에서는, 여러 번의 `useState` 호출보다 `useReducer`가 더 예측 가능한 구조를 제공합니다. ```tsx // hooks/useCart.ts // Cart management hook with useReducer import { useReducer, useCallback, useMemo } from 'react' interface CartItem { id: string name: string price: number quantity: number } interface CartState { items: CartItem[] isOpen: boolean } type CartAction = | { type: 'ADD_ITEM'; payload: Omit } | { type: 'REMOVE_ITEM'; payload: string } | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } } | { type: 'CLEAR_CART' } | { type: 'TOGGLE_CART' } function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'ADD_ITEM': { const existingItem = state.items.find( (item) => item.id === action.payload.id ) if (existingItem) { // Increment quantity if item exists return { ...state, items: state.items.map((item) => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ) } } // Add new item return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }] } } case 'REMOVE_ITEM': return { ...state, items: state.items.filter((item) => item.id !== action.payload) } case 'UPDATE_QUANTITY': return { ...state, items: state.items.map((item) => item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item ).filter((item) => item.quantity > 0) } case 'CLEAR_CART': return { ...state, items: [] } case 'TOGGLE_CART': return { ...state, isOpen: !state.isOpen } default: return state } } const initialState: CartState = { items: [], isOpen: false } export function useCart() { const [state, dispatch] = useReducer(cartReducer, initialState) // Memoized actions to avoid re-renders const addItem = useCallback( (item: Omit) => { dispatch({ type: 'ADD_ITEM', payload: item }) }, [] ) const removeItem = useCallback((id: string) => { dispatch({ type: 'REMOVE_ITEM', payload: id }) }, []) const updateQuantity = useCallback((id: string, quantity: number) => { dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }) }, []) const clearCart = useCallback(() => { dispatch({ type: 'CLEAR_CART' }) }, []) const toggleCart = useCallback(() => { dispatch({ type: 'TOGGLE_CART' }) }, []) // Memoized derived values const total = useMemo( () => state.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ), [state.items] ) const itemCount = useMemo( () => state.items.reduce((sum, item) => sum + item.quantity, 0), [state.items] ) return { items: state.items, isOpen: state.isOpen, total, itemCount, addItem, removeItem, updateQuantity, clearCart, toggleCart } } ``` 이 접근 방식은 카트 로직을 한 곳에 모으고 단위 테스트 작성도 쉽게 만듭니다. ## useMemo와 useCallback을 통한 최적화 이 훅들은 불필요한 계산이나 함수의 재생성을 막습니다. 사용은 선별적이어야 하며, 무분별하게 적용하면 오히려 성능을 떨어뜨립니다. ```tsx // components/ProductList.tsx 'use client' import { useState, useMemo, useCallback, memo } from 'react' interface Product { id: string name: string price: number category: string inStock: boolean } interface ProductListProps { products: Product[] } // Memoized child component const ProductCard = memo(function ProductCard({ product, onAddToCart }: { product: Product onAddToCart: (id: string) => void }) { console.log(`Render ProductCard: ${product.name}`) return (

{product.name}

${product.price}

) }) export function ProductList({ products }: ProductListProps) { const [filter, setFilter] = useState('') const [sortBy, setSortBy] = useState<'name' | 'price'>('name') const [cart, setCart] = useState([]) // useMemo: memoize result of expensive computation const filteredAndSortedProducts = useMemo(() => { console.log('Computing filteredAndSortedProducts') let result = products // Filter by name if (filter) { result = result.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()) ) } // Sort result = [...result].sort((a, b) => { if (sortBy === 'name') { return a.name.localeCompare(b.name) } return a.price - b.price }) return result }, [products, filter, sortBy]) // Recompute only when these deps change // useCallback: memoize a function const handleAddToCart = useCallback((productId: string) => { setCart((prev) => [...prev, productId]) }, []) // Empty deps: function never changes // Memoized statistics const stats = useMemo( () => ({ total: filteredAndSortedProducts.length, inStock: filteredAndSortedProducts.filter((p) => p.inStock).length, avgPrice: filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) / filteredAndSortedProducts.length || 0 }), [filteredAndSortedProducts] ) return (
setFilter(e.target.value)} placeholder="Search..." className="px-3 py-2 border rounded-lg" />

{stats.total} products ({stats.inStock} in stock) - Average price: ${stats.avgPrice.toFixed(2)}

{filteredAndSortedProducts.map((product) => ( ))}
) } ``` > **useMemo/useCallback은 언제 써야 할까요?** > > 이 훅들은 복잡성을 늘립니다. 다음과 같은 경우에만 한정해 사용하세요. (1) 정말로 비싼 계산, (2) `memo()`로 메모이제이션된 컴포넌트로 전달되는 props, (3) 다른 훅의 의존성. ## 검색 입력을 위한 debounce 훅 Debounce는 어떤 동작이 너무 자주 실행되는 것을 막습니다. 검색 필드에 입력할 때가 대표적인 예입니다. ```tsx // hooks/useDebounce.ts // Generic debounce hook import { useState, useEffect } from 'react' export function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { // Create a timer that updates the value after delay const timer = setTimeout(() => { setDebouncedValue(value) }, delay) // Cleanup: cancel timer if value changes before delay return () => { clearTimeout(timer) } }, [value, delay]) return debouncedValue } ``` ```tsx // components/SearchInput.tsx 'use client' import { useState, useEffect } from 'react' import { useDebounce } from '@/hooks/useDebounce' interface SearchResult { id: string title: string } export function SearchInput() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) // Debounce the query by 300ms const debouncedQuery = useDebounce(query, 300) useEffect(() => { // Don't search if query is empty if (!debouncedQuery.trim()) { setResults([]) return } async function search() { setLoading(true) try { const response = await fetch( `/api/search?q=${encodeURIComponent(debouncedQuery)}` ) const data = await response.json() setResults(data.results) } catch (error) { console.error('Search error:', error) } finally { setLoading(false) } } search() }, [debouncedQuery]) // Only triggered when debouncedQuery changes return (
setQuery(e.target.value)} placeholder="Search..." className="w-full px-4 py-2 border rounded-lg" /> {loading && (
)} {results.length > 0 && (
    {results.map((result) => (
  • {result.title}
  • ))}
)}
) } ``` 사용자가 입력을 멈춘 뒤 300ms가 지나서야 컴포넌트가 검색을 수행하므로 수십 번의 불필요한 요청을 줄여 줍니다. ## 고급 ref를 위한 useImperativeHandle 이 훅은 자식 컴포넌트의 특정 메서드를 ref를 통해 부모 컴포넌트에 노출합니다. ```tsx // components/VideoPlayer.tsx 'use client' import { useRef, useImperativeHandle, forwardRef, useState, useCallback } from 'react' // Interface of exposed methods export interface VideoPlayerRef { play: () => void pause: () => void seek: (time: number) => void getCurrentTime: () => number } interface VideoPlayerProps { src: string poster?: string } export const VideoPlayer = forwardRef( function VideoPlayer({ src, poster }, ref) { const videoRef = useRef(null) const [isPlaying, setIsPlaying] = useState(false) // Expose only desired methods to parent useImperativeHandle( ref, () => ({ play: () => { videoRef.current?.play() setIsPlaying(true) }, pause: () => { videoRef.current?.pause() setIsPlaying(false) }, seek: (time: number) => { if (videoRef.current) { videoRef.current.currentTime = time } }, getCurrentTime: () => { return videoRef.current?.currentTime ?? 0 } }), [] // No deps: methods always use current ref ) const handlePlayPause = useCallback(() => { if (isPlaying) { videoRef.current?.pause() } else { videoRef.current?.play() } setIsPlaying(!isPlaying) }, [isPlaying]) return (
) } ) ``` ```tsx // components/VideoController.tsx 'use client' import { useRef } from 'react' import { VideoPlayer, VideoPlayerRef } from './VideoPlayer' export function VideoController() { const playerRef = useRef(null) const handleSkipForward = () => { if (playerRef.current) { const currentTime = playerRef.current.getCurrentTime() playerRef.current.seek(currentTime + 10) } } return (
) } ``` 이 패턴은 미디어 컴포넌트, 폼, 그리고 명령형 제어가 필요한 모든 인터페이스에 특히 유용합니다. ## 요청 라이프사이클 관리 훅 엄격한 타입 지정과 함께 로딩, 에러, 데이터 상태를 다루는 고급 훅입니다. ```tsx // hooks/useFetch.ts // Generic hook for HTTP requests import { useState, useEffect, useCallback, useRef } from 'react' interface UseFetchState { data: T | null loading: boolean error: Error | null } interface UseFetchOptions { immediate?: boolean // Execute immediately on mount onSuccess?: (data: T) => void onError?: (error: Error) => void } export function useFetch( url: string, options: UseFetchOptions = {} ) { const { immediate = true, onSuccess, onError } = options const [state, setState] = useState>({ data: null, loading: immediate, error: null }) // Ref to prevent updates after unmount const mountedRef = useRef(true) // Ref for abort controller const abortControllerRef = useRef(null) const execute = useCallback(async () => { // Abort previous request if in progress abortControllerRef.current?.abort() abortControllerRef.current = new AbortController() setState((prev) => ({ ...prev, loading: true, error: null })) try { const response = await fetch(url, { signal: abortControllerRef.current.signal }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const data: T = await response.json() if (mountedRef.current) { setState({ data, loading: false, error: null }) onSuccess?.(data) } return data } catch (error) { // Ignore abort errors if (error instanceof Error && error.name === 'AbortError') { return null } const err = error instanceof Error ? error : new Error('Unknown error') if (mountedRef.current) { setState({ data: null, loading: false, error: err }) onError?.(err) } return null } }, [url, onSuccess, onError]) const reset = useCallback(() => { setState({ data: null, loading: false, error: null }) }, []) // Execute on mount if immediate=true useEffect(() => { if (immediate) { execute() } return () => { mountedRef.current = false abortControllerRef.current?.abort() } }, [execute, immediate]) return { ...state, execute, reset, isIdle: !state.loading && !state.data && !state.error } } ``` 이 훅은 자동 취소와 라이프사이클 관리를 갖춘, 모든 요청을 처리할 수 있는 완전한 API를 제공합니다. ## 결론 고급 React Hooks 패턴은 유지보수성과 성능을 동시에 갖춘 코드를 가능하게 합니다. 핵심 포인트는 다음과 같습니다. - ✅ **useEffect**: 항상 cleanup을 포함하고 AbortController로 레이스 컨디션을 처리합니다 - ✅ **커스텀 훅**: 재사용 가능한 로직을 명확한 API로 캡슐화합니다 - ✅ **useReducer**: 여러 액션을 가진 복잡한 state에는 useReducer를 선택합니다 - ✅ **useMemo/useCallback**: 필요한 곳에만 선별적으로 사용합니다 - ✅ **useDebounce**: 검색처럼 자주 실행되는 동작을 제한합니다 - ✅ **useImperativeHandle**: 명령형 메서드를 통제된 방식으로 노출합니다 이 패턴들에 대한 숙련도가 중급 React 개발자와 전문가를 가르는 차이를 만듭니다. 각 훅은 특정 문제를 해결하기 위해 존재합니다. 적절한 시점에 적절한 도구를 선택하는 것이 견고한 React 아키텍처의 열쇠입니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/react-next/advanced-react-hooks-patterns-optimizations