고급 React Hooks: 패턴과 최적화
검증된 패턴으로 고급 React Hooks를 마스터합니다. 커스텀 훅, 최적화된 useEffect, useMemo, useCallback, 그리고 성능 기법.

React Hooks는 함수형 컴포넌트에서 state와 사이드 이펙트를 다루는 방식을 크게 바꿨습니다. useState와 useEffect의 기본 사용을 넘어서, 고급 패턴을 익히면 재사용 가능하고 성능이 우수하며 유지보수하기 쉬운 코드를 작성할 수 있습니다.
이 가이드는 기본적인 Hooks(useState, useEffect, useContext)에 대한 친숙함을 전제로 합니다. 예제는 견고한 타입 지정을 위해 React 18+와 TypeScript를 사용합니다.
useEffect 마스터하기: 흔한 함정 피하기
useEffect는 자주 오해되고 잘못 사용됩니다. 동작을 깊이 이해하면 미묘한 버그와 성능 문제를 피할 수 있습니다.
기본 원칙은 단순합니다. useEffect는 컴포넌트를 외부 시스템과 동기화하기 위한 것입니다. 효과가 외부(API, DOM, 타이머 등)와 통신하지 않는다면, 아마도 그 효과는 필요 없습니다.
// 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
}가장 흔한 실수는 의존성 배열을 누락하거나 불안정한 값을 포함하는 것입니다. 효과 안에서 사용하는 모든 값은 의존성에 나열해야 합니다.
'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<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div>Loading...</div>
if (error) return <div>Error: {error}</div>
if (!user) return null
return (
<div className="p-4 border rounded-lg">
<h2 className="text-xl font-bold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
)
}AbortController와 isMounted 플래그를 함께 사용하는 이 패턴은 메모리 누수와 언마운트된 컴포넌트에 대한 state 업데이트를 방지합니다.
재사용 가능한 커스텀 훅 만들기
커스텀 훅은 재사용 가능한 로직을 캡슐화합니다. 좋은 커스텀 훅은 단일 책임 원칙을 따르고 명확한 API를 제공합니다.
// Generic hook to persist state in localStorage
import { useState, useEffect, useCallback } from 'react'
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
// Initialize state with stored value or default
const [storedValue, setStoredValue] = useState<T>(() => {
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]
}이 훅은 데이터를 영속적으로 저장하고 싶은 어떤 컴포넌트에서도 사용할 수 있습니다.
'use client'
import { useLocalStorage } from '@/hooks/useLocalStorage'
type Theme = 'light' | 'dark' | 'system'
export function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<Theme>('theme', 'system')
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
className="px-3 py-2 border rounded-lg"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
)
}커스텀 훅은 항상 "use"로 시작합니다(useLocalStorage, useFetch, useDebounce). 이 규칙 덕분에 React가 훅 규칙을 검증하고 린팅 도구가 올바르게 동작합니다.
useReducer를 활용한 컴포지션 패턴
여러 액션을 가진 복잡한 state에서는, 여러 번의 useState 호출보다 useReducer가 더 예측 가능한 구조를 제공합니다.
// 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<CartItem, 'quantity'> }
| { 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<CartItem, 'quantity'>) => {
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
}
}이 접근 방식은 카트 로직을 한 곳에 모으고 단위 테스트 작성도 쉽게 만듭니다.
React / Next.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
useMemo와 useCallback을 통한 최적화
이 훅들은 불필요한 계산이나 함수의 재생성을 막습니다. 사용은 선별적이어야 하며, 무분별하게 적용하면 오히려 성능을 떨어뜨립니다.
'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 (
<div className="border rounded-lg p-4">
<h3 className="font-medium">{product.name}</h3>
<p className="text-gray-600">${product.price}</p>
<button
onClick={() => onAddToCart(product.id)}
disabled={!product.inStock}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{product.inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
)
})
export function ProductList({ products }: ProductListProps) {
const [filter, setFilter] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'price'>('name')
const [cart, setCart] = useState<string[]>([])
// 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 (
<div>
<div className="mb-4 flex gap-4">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search..."
className="px-3 py-2 border rounded-lg"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'name' | 'price')}
className="px-3 py-2 border rounded-lg"
>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</div>
<p className="text-sm text-gray-600 mb-4">
{stats.total} products ({stats.inStock} in stock) -
Average price: ${stats.avgPrice.toFixed(2)}
</p>
<div className="grid grid-cols-3 gap-4">
{filteredAndSortedProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
</div>
)
}이 훅들은 복잡성을 늘립니다. 다음과 같은 경우에만 한정해 사용하세요. (1) 정말로 비싼 계산, (2) memo()로 메모이제이션된 컴포넌트로 전달되는 props, (3) 다른 훅의 의존성.
검색 입력을 위한 debounce 훅
Debounce는 어떤 동작이 너무 자주 실행되는 것을 막습니다. 검색 필드에 입력할 때가 대표적인 예입니다.
// Generic debounce hook
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(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
}'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<SearchResult[]>([])
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 (
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
className="w-full px-4 py-2 border rounded-lg"
/>
{loading && (
<div className="absolute right-3 top-2.5">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{results.length > 0 && (
<ul className="absolute w-full mt-1 bg-white border rounded-lg shadow-lg">
{results.map((result) => (
<li
key={result.id}
className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
{result.title}
</li>
))}
</ul>
)}
</div>
)
}사용자가 입력을 멈춘 뒤 300ms가 지나서야 컴포넌트가 검색을 수행하므로 수십 번의 불필요한 요청을 줄여 줍니다.
고급 ref를 위한 useImperativeHandle
이 훅은 자식 컴포넌트의 특정 메서드를 ref를 통해 부모 컴포넌트에 노출합니다.
'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<VideoPlayerRef, VideoPlayerProps>(
function VideoPlayer({ src, poster }, ref) {
const videoRef = useRef<HTMLVideoElement>(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 (
<div className="relative">
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full rounded-lg"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
<button
onClick={handlePlayPause}
className="absolute bottom-4 left-4 px-4 py-2 bg-black/50 text-white rounded"
>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
)
}
)'use client'
import { useRef } from 'react'
import { VideoPlayer, VideoPlayerRef } from './VideoPlayer'
export function VideoController() {
const playerRef = useRef<VideoPlayerRef>(null)
const handleSkipForward = () => {
if (playerRef.current) {
const currentTime = playerRef.current.getCurrentTime()
playerRef.current.seek(currentTime + 10)
}
}
return (
<div className="space-y-4">
<VideoPlayer
ref={playerRef}
src="/videos/demo.mp4"
poster="/images/poster.jpg"
/>
<div className="flex gap-2">
<button
onClick={() => playerRef.current?.play()}
className="px-4 py-2 bg-green-600 text-white rounded"
>
Play
</button>
<button
onClick={() => playerRef.current?.pause()}
className="px-4 py-2 bg-red-600 text-white rounded"
>
Pause
</button>
<button
onClick={handleSkipForward}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
+10s
</button>
</div>
</div>
)
}이 패턴은 미디어 컴포넌트, 폼, 그리고 명령형 제어가 필요한 모든 인터페이스에 특히 유용합니다.
요청 라이프사이클 관리 훅
엄격한 타입 지정과 함께 로딩, 에러, 데이터 상태를 다루는 고급 훅입니다.
// Generic hook for HTTP requests
import { useState, useEffect, useCallback, useRef } from 'react'
interface UseFetchState<T> {
data: T | null
loading: boolean
error: Error | null
}
interface UseFetchOptions {
immediate?: boolean // Execute immediately on mount
onSuccess?: <T>(data: T) => void
onError?: (error: Error) => void
}
export function useFetch<T>(
url: string,
options: UseFetchOptions = {}
) {
const { immediate = true, onSuccess, onError } = options
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: immediate,
error: null
})
// Ref to prevent updates after unmount
const mountedRef = useRef(true)
// Ref for abort controller
const abortControllerRef = useRef<AbortController | null>(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 / Next.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
결론
고급 React Hooks 패턴은 유지보수성과 성능을 동시에 갖춘 코드를 가능하게 합니다. 핵심 포인트는 다음과 같습니다.
- ✅ useEffect: 항상 cleanup을 포함하고 AbortController로 레이스 컨디션을 처리합니다
- ✅ 커스텀 훅: 재사용 가능한 로직을 명확한 API로 캡슐화합니다
- ✅ useReducer: 여러 액션을 가진 복잡한 state에는 useReducer를 선택합니다
- ✅ useMemo/useCallback: 필요한 곳에만 선별적으로 사용합니다
- ✅ useDebounce: 검색처럼 자주 실행되는 동작을 제한합니다
- ✅ useImperativeHandle: 명령형 메서드를 통제된 방식으로 노출합니다
이 패턴들에 대한 숙련도가 중급 React 개발자와 전문가를 가르는 차이를 만듭니다. 각 훅은 특정 문제를 해결하기 위해 존재합니다. 적절한 시점에 적절한 도구를 선택하는 것이 견고한 React 아키텍처의 열쇠입니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

React Compiler 2026: 자동 메모이제이션의 원리와 기술 면접 완벽 가이드
React Compiler v1.0의 자동 메모이제이션 내부 구조, 컴파일 파이프라인, 수동 최적화가 필요한 시나리오를 상세히 분석합니다. 2026년 React 기술 면접에서 자주 출제되는 핵심 질문을 포괄적으로 다룹니다.

React 19: Server Components 프로덕션 환경 완벽 가이드
React 19 Server Components를 프로덕션 환경에서 구현하는 방법을 다룹니다. 아키텍처 설계, 컴포지션 패턴, 스트리밍, 캐싱 전략, 성능 최적화까지 체계적으로 정리했습니다.

프로덕션에서의 React Server Components: 패턴과 함정
프로덕션에서의 React Server Components: 실전에서 검증된 패턴, 흔한 안티패턴, 견고한 Next.js 15 애플리케이션을 위한 디버깅 전략입니다.