React Hook과 memo: 성능 최적화의 시작

React

React

vs
React Hooks

React Hooks

React Hook 완전 정복 2026
React Hook과 메모이제이션 가이드

React Hook과 memo 완전 가이드

useState · useEffect · useRef · useMemo · useCallback · Custom Hook — 실전 패턴 총정리

✦ 핵심 훅 완전 정리 ✦ 흔한 실수 & 해결책 ✦ 커스텀 훅 패턴

React Hook은 2019년 React 16.8에서 도입된 이후 React 개발의 핵심이 되었습니다. 클래스 컴포넌트 없이도 상태 관리와 사이드 이펙트 처리가 가능해졌지만, 각 훅의 동작 원리를 정확히 이해하지 않으면 미묘한 버그와 성능 문제가 생기기 쉽습니다. 이 글에서는 핵심 훅들의 올바른 사용법과 실수를 방지하는 패턴을 정리합니다.

useState — 상태 관리의 기본

useState는 컴포넌트의 로컬 상태를 관리합니다. 상태가 변경되면 컴포넌트가 리렌더됩니다.

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  // ❌ 현재 상태에 직접 의존하는 업데이트 (stale closure 위험)
  const increment = () => setCount(count + 1)

  // ✅ 함수형 업데이트 — 항상 최신 상태를 받음
  const incrementSafe = () => setCount(prev => prev + 1)

  // 객체 상태 업데이트 — 반드시 스프레드로 복사
  const [user, setUser] = useState({ name: 'Jake', age: 30 })
  const updateAge = () => setUser(prev => ({ ...prev, age: 31 }))

  return 
}
⚠️ 흔한 실수: 객체 직접 변경
user.age = 31처럼 상태 객체를 직접 수정하면 React가 변경을 감지하지 못해 리렌더가 발생하지 않습니다. 반드시 setUser({...user, age: 31})처럼 새 객체를 만들어 전달해야 합니다.

useEffect — 사이드 이펙트 처리

useEffect는 렌더링 이후 실행되는 사이드 이펙트를 처리합니다. API 호출, 이벤트 리스너, 타이머 등에 사용합니다.

import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 언마운트 후 setState 방지를 위한 cleanup 패턴
    let cancelled = false

    async function fetchUser() {
      const data = await getUser(userId)
      if (!cancelled) setUser(data)
    }

    fetchUser()

    // cleanup 함수 — 컴포넌트 언마운트 또는 다음 effect 실행 전 호출
    return () => { cancelled = true }
  }, [userId])

  useEffect(() => {
    const handleResize = () => console.log(window.innerWidth)
    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  }, [])

  return user ? 
{user.name}
:
로딩 중...
}
⚠️ 의존성 배열 빠뜨리기
useEffect 내에서 사용하는 변수나 함수는 의존성 배열에 반드시 포함해야 합니다. eslint-plugin-react-hooksexhaustive-deps 규칙을 활성화하면 빠진 의존성을 자동으로 감지할 수 있습니다.

useRef — 렌더링 없는 값 저장

useRef는 두 가지 용도로 사용됩니다. 첫째는 DOM 요소에 직접 접근하는 것이고, 둘째는 리렌더를 발생시키지 않는 값을 저장하는 것입니다.

import { useRef, useEffect } from 'react'

function VideoPlayer() {
  // DOM 접근
  const videoRef = useRef(null)

  const handlePlay = () => {
    videoRef.current?.play()
  }

  // 이전 값 추적 (렌더 발생 없이)
  const prevCountRef = useRef(null)
  const [count, setCount] = useState(0)

  useEffect(() => {
    prevCountRef.current = count
  })

  // 타이머 ID 저장 (clearTimeout을 위해)
  const timerRef = useRef(null)

  const startTimer = () => {
    timerRef.current = setTimeout(() => console.log('완료'), 3000)
  }

  useEffect(() => () => clearTimeout(timerRef.current), [])

  return 

useMemo vs useCallback 실전 비교

구분 useMemo useCallback
캐싱 대상 계산된 함수 자체
사용 시점 비싼 계산, 객체 안정화 자식에 전달하는 콜백
반환값 콜백의 반환값 콜백 함수 자체
동치식 useMemo(() => fn(), deps) useMemo(() => fn, deps)

useReducer — 복잡한 상태 관리

여러 하위 값을 포함하는 복잡한 상태 로직, 또는 다음 상태가 이전 상태에 의존하는 경우 useReducer가 useState보다 적합합니다.

import { useReducer } from 'react'

type State = { count: number; status: 'idle' | 'loading' | 'error' }
type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setLoading' }
  | { type: 'setError' }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 }
    case 'decrement': return { ...state, count: state.count - 1 }
    case 'setLoading': return { ...state, status: 'loading' }
    case 'setError': return { ...state, status: 'error' }
    default: return state
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, { count: 0, status: 'idle' })

  return (
    
{state.count}
) }

useContext — 전역 상태 공유

useContext는 prop drilling(중간 컴포넌트를 거쳐 props를 전달하는 것)을 피할 때 유용합니다. 단, Context 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더되므로 과도한 사용은 성능 문제를 일으킵니다.

import { createContext, useContext, useState } from 'react'

// Context 생성 (기본값 명시 권장)
const ThemeContext = createContext('light')

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  return (
    
      {children}
      
    
  )
}

// 커스텀 훅으로 감싸면 사용 편의성 + 오류 방지
function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme은 ThemeProvider 내에서만 사용 가능합니다')
  return ctx
}

커스텀 Hook 패턴 3가지

커스텀 Hook은 컴포넌트 로직을 재사용 가능한 함수로 추출하는 패턴입니다. 이름은 반드시 use로 시작해야 합니다.

// 패턴 1: 데이터 패칭 훅
function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch(url)
      .then(r => r.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return { data, loading, error }
}

// 패턴 2: 로컬스토리지 동기화 훅
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key)
    return stored ? JSON.parse(stored) : initialValue
  })

  const setStoredValue = useCallback((val) => {
    setValue(val)
    localStorage.setItem(key, JSON.stringify(val))
  }, [key])

  return [value, setStoredValue]
}

// 패턴 3: 디바운스 훅
function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debounced
}

Hook 규칙과 흔한 실수

React Hook에는 반드시 지켜야 하는 두 가지 규칙이 있습니다.

  • 최상위에서만 호출: 반복문, 조건문, 중첩 함수 안에서 훅을 호출하면 안 됩니다.
  • React 함수 컴포넌트 또는 커스텀 훅에서만 호출: 일반 JavaScript 함수에서는 사용할 수 없습니다.
💡 eslint-plugin-react-hooks 필수 설치
위 규칙들을 자동으로 검사해주는 ESLint 플러그인입니다. CRA, Next.js, Vite 등 주요 프레임워크의 기본 설정에 이미 포함되어 있습니다. 직접 설정할 때는 npm install eslint-plugin-react-hooks로 설치하세요.

자주 묻는 질문 (FAQ)

Q. useEffect의 클린업 함수는 언제 실행되나요?
A. 클린업 함수는 두 가지 시점에 실행됩니다. 첫째, 컴포넌트가 DOM에서 제거될 때(언마운트)입니다. 둘째, 다음 effect가 실행되기 직전입니다. 예를 들어 의존성 배열의 값이 변경되어 effect가 재실행될 때, 이전 effect의 클린업이 먼저 실행되고 새 effect가 실행됩니다. 이 동작을 이해하면 이벤트 리스너 중복 등록 같은 버그를 방지할 수 있습니다.
Q. useState의 초기값으로 함수를 전달하면 어떤 이점이 있나요?
A. useState(heavyComputation())처럼 직접 값을 전달하면 매 렌더마다 함수가 실행됩니다. 반면 useState(() => heavyComputation())처럼 함수를 전달하면(Lazy initialization) 초기 렌더 시 한 번만 실행됩니다. localStorage에서 값을 읽거나 복잡한 초기 계산이 필요할 때 이 패턴을 사용하면 성능을 개선할 수 있습니다.
Q. useRef와 useState의 차이는 무엇인가요?
A. 가장 큰 차이는 리렌더 여부입니다. useState는 값이 변경되면 컴포넌트를 리렌더하지만, useRef는 .current 값을 변경해도 리렌더가 발생하지 않습니다. 따라서 화면에 표시할 필요가 없는 값(타이머 ID, 이전 값 추적, DOM 참조 등)에는 useRef를, 화면에 표시해야 하는 값에는 useState를 사용하세요.
Q. React 18의 useId 훅은 어떤 상황에 사용하나요?
A. useId는 서버와 클라이언트 간에 안정적으로 일치하는 고유 ID를 생성합니다. 주로 접근성(a11y) 목적으로 input과 label을 연결할 때 사용합니다. SSR 환경에서 Math.random()이나 증가하는 숫자로 ID를 만들면 hydration 불일치가 발생할 수 있는데, useId는 이 문제를 해결합니다.
Q. 커스텀 훅과 일반 유틸리티 함수의 차이는 무엇인가요?
A. 커스텀 훅은 내부에서 다른 훅(useState, useEffect 등)을 사용하는 함수입니다. 일반 유틸리티 함수는 훅을 사용하지 않는 순수 함수입니다. 커스텀 훅은 이름이 use로 시작해야 하며, React의 훅 규칙이 적용됩니다. 훅이 필요 없다면 커스텀 훅이 아닌 일반 함수로 만드는 것이 더 적합합니다.

댓글