Skip to main content
Advertisement

핵심 훅 — useState, useEffect, useRef, useMemo, useCallback

훅(Hook)이란?

훅은 함수형 컴포넌트에서 React 기능을 사용할 수 있게 해주는 함수입니다. React 16.8에서 도입되었으며, 클래스 컴포넌트 없이도 상태 관리와 사이드 이펙트 처리가 가능해졌습니다.

훅의 규칙 (Rules of Hooks)

  1. 최상위에서만 호출: 반복문, 조건문, 중첩 함수 안에서 호출 금지
  2. React 함수 컴포넌트 또는 커스텀 훅에서만 호출: 일반 JS 함수에서 호출 금지
// ❌ 규칙 위반
function Counter({ show }) {
if (show) {
const [count, setCount] = useState(0); // 조건문 안 — 금지!
}
}

// ✅ 올바른 사용
function Counter({ show }) {
const [count, setCount] = useState(0); // 최상위에서 호출
if (!show) return null;
return <p>{count}</p>;
}

useState

상태(state)를 선언하고 관리합니다.

import { useState } from 'react';

function Counter() {
// [현재값, 업데이트 함수] = useState(초기값)
const [count, setCount] = useState(0);

return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>초기화</button>
</div>
);
}

함수형 업데이트 (Functional Update)

이전 상태에 기반하여 새 상태를 계산할 때는 함수형 업데이트를 사용합니다.

// ❌ 오래된 클로저 문제 발생 가능
function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1); // 클로저의 count를 참조
setCount(count + 1); // 같은 count를 두 번 참조 → 1만 증가
}
}

// ✅ 함수형 업데이트로 안전하게
function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(prev => prev + 1); // 최신 상태 보장
setCount(prev => prev + 1); // 2 증가
}
}

객체/배열 상태 업데이트

상태는 **불변(immutable)**으로 관리해야 합니다.

function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0,
});

// ❌ 직접 변경 — 리렌더링 안 됨
function badUpdate() {
user.name = '김철수'; // 절대 금지
setUser(user); // 같은 참조이므로 React가 변경 감지 못함
}

// ✅ 스프레드로 새 객체 생성
function handleNameChange(e) {
setUser(prev => ({ ...prev, name: e.target.value }));
}

// 배열 상태
const [items, setItems] = useState([]);

function addItem(newItem) {
setItems(prev => [...prev, newItem]); // 새 배열
}

function removeItem(id) {
setItems(prev => prev.filter(item => item.id !== id));
}

function updateItem(id, changes) {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...changes } : item
));
}
}

지연 초기화 (Lazy Initialization)

초기값 계산이 비싼 경우 함수를 전달하면 최초 렌더링에만 실행됩니다.

// ❌ 매 렌더링마다 parseLocalStorage 실행
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos') || '[]')
);

// ✅ 최초 마운트 시에만 실행
const [todos, setTodos] = useState(() =>
JSON.parse(localStorage.getItem('todos') || '[]')
);

useEffect

사이드 이펙트(데이터 페칭, 구독, DOM 조작, 타이머 등)를 처리합니다.

import { useState, useEffect } from 'react';

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

useEffect(() => {
// 의존성 배열의 값이 바뀔 때마다 실행
async function fetchUser() {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setLoading(false);
}

fetchUser();
}, [userId]); // userId가 바뀔 때마다 재실행

if (loading) return <p>로딩 중...</p>;
return <h1>{user?.name}</h1>;
}

의존성 배열 규칙

useEffect(() => {
// 마운트/언마운트 시에만 실행
}, []);

useEffect(() => {
// 매 렌더링마다 실행 (의존성 배열 생략)
});

useEffect(() => {
// count가 바뀔 때마다 실행
}, [count]);

Cleanup 함수

function Timer() {
const [seconds, setSeconds] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);

// Cleanup: 컴포넌트 언마운트 또는 다음 effect 실행 전 호출
return () => clearInterval(interval);
}, []);

return <p>경과 시간: {seconds}</p>;
}

// 이벤트 구독 예시
function ResizeTracker() {
const [width, setWidth] = useState(window.innerWidth);

useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);

return () => window.removeEventListener('resize', handler);
}, []);

return <p>창 너비: {width}px</p>;
}

Race Condition 방지

function SearchResults({ query }) {
const [results, setResults] = useState([]);

useEffect(() => {
let cancelled = false; // cleanup 플래그

async function search() {
const data = await fetchSearch(query);
if (!cancelled) { // 컴포넌트가 아직 마운트된 경우만 업데이트
setResults(data);
}
}

search();

return () => { cancelled = true; };
}, [query]);

return <ResultList items={results} />;
}

useRef

렌더링과 무관하게 값을 유지하거나, DOM 요소에 직접 접근합니다.

import { useRef, useEffect } from 'react';

// DOM 참조
function TextInput() {
const inputRef = useRef(null);

function focusInput() {
inputRef.current.focus();
}

return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>포커스</button>
</div>
);
}

// 렌더링 간 값 유지 (변경해도 리렌더링 없음)
function StopWatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);

function start() {
intervalRef.current = setInterval(() => {
setTime(prev => prev + 1);
}, 1000);
}

function stop() {
clearInterval(intervalRef.current);
}

return (
<div>
<p>{time}</p>
<button onClick={start}>시작</button>
<button onClick={stop}>정지</button>
</div>
);
}

이전 값 기억하기

function usePrevious(value) {
const ref = useRef();

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current; // 이전 렌더링의 값
}

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

return (
<p>현재: {count}, 이전: {prevCount}</p>
);
}

useMemo

계산 비용이 큰 값을 의존성이 변경될 때만 다시 계산합니다.

import { useMemo, useState } from 'react';

function ProductFilter({ products, query, category }) {
// products, query, category 중 하나라도 바뀔 때만 재계산
const filteredProducts = useMemo(() => {
console.log('필터링 중...');
return products
.filter(p => p.category === category)
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, query, category]);

return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name}: {p.price}</li>
))}
</ul>
);
}

주의: useMemo는 만능이 아닙니다. 단순한 계산에 남용하면 오히려 성능이 저하됩니다. 프로파일러로 측정 후 적용하세요.


useCallback

함수를 의존성이 변경될 때만 재생성합니다. 주로 자식 컴포넌트에 함수를 전달할 때 사용합니다.

import { useState, useCallback, memo } from 'react';

// memo로 감싼 자식 컴포넌트
const ChildButton = memo(function ChildButton({ onClick, label }) {
console.log(`${label} 렌더링`);
return <button onClick={onClick}>{label}</button>;
});

function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');

// ❌ 매 렌더링마다 새 함수 생성 → ChildButton 항상 리렌더
const handleIncrement = () => setCount(prev => prev + 1);

// ✅ 의존성이 없으므로 항상 같은 함수 참조
const handleIncrementMemo = useCallback(() => {
setCount(prev => prev + 1);
}, []);

return (
<div>
<p>카운트: {count}</p>
<input value={text} onChange={e => setText(e.target.value)} />
<ChildButton onClick={handleIncrementMemo} label="증가" />
</div>
);
}

실전 예제: 검색 컴포넌트

import { useState, useEffect, useRef, useMemo, useCallback } from 'react';

function SearchBox({ data, onSelect }) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef(null);
const listRef = useRef(null);

// 검색 결과 메모이제이션
const filtered = useMemo(() => {
if (!query.trim()) return [];
return data.filter(item =>
item.label.toLowerCase().includes(query.toLowerCase())
).slice(0, 10);
}, [data, query]);

// 항목 선택 핸들러 메모이제이션
const handleSelect = useCallback((item) => {
setQuery(item.label);
setIsOpen(false);
onSelect(item);
}, [onSelect]);

// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
function handleClickOutside(event) {
if (listRef.current && !listRef.current.contains(event.target)) {
setIsOpen(false);
}
}

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// 쿼리 변경 시 드롭다운 열기
useEffect(() => {
setIsOpen(query.trim().length > 0 && filtered.length > 0);
}, [query, filtered.length]);

return (
<div ref={listRef} className="search-box">
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="검색..."
/>
{isOpen && (
<ul className="dropdown">
{filtered.map(item => (
<li key={item.id} onClick={() => handleSelect(item)}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}

고수 팁

1. useEffect 의존성 분석 도구

eslint-plugin-react-hooksexhaustive-deps 규칙을 활성화하면 누락된 의존성을 경고합니다.

2. useEffect 대신 이벤트 핸들러 선호

// ❌ useEffect로 상태 동기화
useEffect(() => {
if (formData.price && formData.quantity) {
setTotal(formData.price * formData.quantity);
}
}, [formData.price, formData.quantity]);

// ✅ 이벤트 핸들러에서 직접 계산
function handlePriceChange(price) {
setFormData(prev => ({
...prev,
price,
total: price * prev.quantity,
}));
}

3. useMemo vs useCallback 기억법

  • useMemo: 을 캐싱 → useMemo(() => computeValue(), [deps])
  • useCallback: 함수를 캐싱 → useCallback(() => doSomething(), [deps])
  • useCallback(fn, deps)useMemo(() => fn, deps)와 동일합니다.
Advertisement