React 16.8에서 도입된 Hooks는 함수형 컴포넌트에서 상태 관리와 생명주기를 다룰 수 있게 해주었습니다. 클래스 컴포넌트 없이도 강력한 기능을 구현할 수 있는 Hooks를 완전히 정복해봅시다.
useState — 상태 관리
가장 기본적인 Hook으로, 컴포넌트의 상태를 선언하고 업데이트합니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: '', age: 0 });
// 이전 상태 기반으로 업데이트 (권장)
const increment = () => setCount(prev => prev + 1);
// 객체 상태 업데이트 — 스프레드로 병합
const updateName = (name) => setUser(prev => ({ ...prev, name }));
return (
<div>
<p>카운트: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
useEffect — 사이드 이펙트
데이터 페칭, 구독, DOM 조작 등 렌더링 외부의 작업을 처리합니다.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// 의존성 배열의 값이 바뀔 때마다 실행
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
// cleanup — 컴포넌트 언마운트 또는 재실행 전에 호출
return () => { cancelled = true; };
}, [userId]); // userId가 바뀔 때만 재실행
if (!user) return <p>로딩 중...</p>;
return <p>{user.name}</p>;
}
useCallback & useMemo — 성능 최적화
import { useState, useCallback, useMemo } from 'react';
function ExpensiveList({ items, onSelect }) {
// 함수 메모이제이션 — 의존성이 바뀔 때만 새 함수 생성
const handleClick = useCallback((id) => {
onSelect(id);
}, [onSelect]);
// 값 메모이제이션 — 비용이 큰 계산 결과를 캐싱
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.price - b.price);
}, [items]);
return (
<ul>
{sortedItems.map(item => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name} — {item.price}원
</li>
))}
</ul>
);
}
useRef — DOM 참조 & 값 유지
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef(null);
const renderCount = useRef(0); // 렌더링과 무관하게 값 유지
useEffect(() => {
inputRef.current.focus(); // DOM 직접 접근
renderCount.current += 1;
});
return <input ref={inputRef} placeholder="자동 포커스" />;
}
커스텀 Hook — 로직 재사용
반복되는 상태 로직을 커스텀 Hook으로 추출해 재사용합니다.
// useFetch.js
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// 사용
function Posts() {
const { data, loading, error } = useFetch('/api/posts');
if (loading) return <p>로딩 중...</p>;
if (error) return <p>오류 발생</p>;
return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
useContext — 전역 상태 공유
컴포넌트 트리 깊은 곳까지 props를 계속 전달하는 "prop drilling" 문제를 useContext로 해결할 수 있습니다. 로그인 사용자 정보, 테마, 언어 설정처럼 앱 전체에서 공유되는 값에 적합합니다.
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Layout />
</ThemeContext.Provider>
);
}
// 트리 어디서든 바로 꺼내 사용
function Button() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
현재 테마: {theme}
</button>
);
}
useReducer — 복잡한 상태 관리
상태 로직이 복잡해지면 useState 대신 useReducer를 사용하면 상태 전환 로직을 한 곳으로 모을 수 있습니다. Redux 패턴에 익숙하다면 더 자연스럽게 사용할 수 있습니다.
const initialState = { count: 0, loading: false };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'decrement': return { ...state, count: state.count - 1 };
case 'setLoading': return { ...state, loading: action.payload };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>카운트: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
</div>
);
}
자주 하는 실수와 해결법
- 의존성 배열 누락 —
useEffect의 의존성 배열에서 사용하는 변수를 빠뜨리면 오래된 값을 참조하는 클로저 버그가 생깁니다. ESLint의exhaustive-deps규칙을 반드시 켜두세요. - 매 렌더링마다 새 객체/배열 생성 —
useEffect의존성에[]대신{}처럼 리터럴을 넣으면 매 렌더링마다 새 참조가 생겨 무한 실행됩니다. 객체는useMemo로 감싸세요. - 불필요한 useCallback/useMemo 남용 — 메모이제이션 자체도 비용입니다. 렌더링이 실제로 느릴 때만, 또는 자식 컴포넌트에
React.memo를 함께 사용할 때만 적용하세요.
1. Hook은 항상 함수 컴포넌트 또는 커스텀 Hook의 최상위에서만 호출하세요.
2. 조건문, 반복문, 중첩 함수 안에서 Hook을 호출하면 안 됩니다.
3. Hook 이름은 항상
use로 시작해야 합니다.4. useContext는 전역 공유 값에, useReducer는 복잡한 상태 로직에 활용하세요.
useTransition — 긴급하지 않은 상태 업데이트 분리
React 18에서 추가된 useTransition은 UI 응답성을 유지하면서 무거운 상태 업데이트를 처리할 수 있게 해줍니다. 입력 필드 타이핑이 끊기는 문제 없이 대용량 목록 필터링을 처리할 때 특히 유용합니다.
import { useState, useTransition } from 'react';
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // 즉시 업데이트 (긴급)
startTransition(() => {
// 낮은 우선순위로 처리 (긴급하지 않음)
const filtered = searchItems(value);
setResults(filtered);
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>검색 중...</span>}
<ul>{results.map(item => <li key={item.id}>{item.name}</li>)}</ul>
</>
);
}
useDeferredValue — 렌더링 지연
useDeferredValue는 값의 "지연된 버전"을 반환합니다. 무거운 렌더링이 필요한 컴포넌트에 전달해 메인 UI가 블로킹되지 않도록 합니다.
import { useState, useDeferredValue, memo } from 'react';
const HeavyList = memo(({ query }) => {
// 수천 개 항목 렌더링
return items.filter(i => i.includes(query)).map(i => <div key={i}>{i}</div>);
});
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // 지연된 값
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<HeavyList query={deferredQuery} />
</>
);
}
성능 최적화 체크리스트
- ✅ 무거운 계산은
useMemo로 메모이제이션 - ✅ 자식 컴포넌트에 전달하는 함수는
useCallback으로 안정화 - ✅ 순수 컴포넌트는
React.memo로 래핑해 불필요한 리렌더링 방지 - ✅ 대용량 목록은
react-window나react-virtual로 가상화 - ✅ Context는 자주 변경되는 값과 드물게 변경되는 값을 분리
- ✅ React DevTools Profiler로 실제 렌더링 병목 확인 후 최적화
무조건 useMemo와 useCallback을 남용하면 오히려 코드가 복잡해집니다. React DevTools Profiler로 실제 성능 문제를 먼저 확인하고 나서 최적화를 적용하는 순서가 중요합니다.
커스텀 Hook 설계 원칙
커스텀 Hook은 React 개발의 핵심 도구입니다. 좋은 커스텀 Hook을 만들기 위한 원칙들을 살펴봅니다.
첫째, 단일 책임 원칙을 지키세요. useUserProfile이라는 Hook이 API 호출, 로컬 캐싱, 에러 처리, UI 상태까지 모두 담당하면 재사용하기 어렵습니다. useFetch(데이터 패칭), useLocalStorage(저장), useAsync(비동기 상태) 처럼 단일 역할의 작은 Hook을 조합하는 것이 더 유연합니다.
둘째, 제어를 외부에 노출하세요. 내부에서만 작동하는 Hook보다, 사용자가 옵션을 전달해 동작을 커스터마이징할 수 있는 Hook이 훨씬 재사용성이 높습니다. 예를 들어 useDebounce(value, delay)처럼 딜레이를 인자로 받는 방식이 좋습니다.
셋째, 정리(cleanup) 함수를 항상 반환하세요. useEffect 안에서 이벤트 리스너를 등록하거나 타이머를 설정했다면, 반드시 return 함수에서 해제해야 합니다. 이를 빠뜨리면 컴포넌트가 언마운트된 후에도 상태를 업데이트하려는 시도가 발생해 에러와 메모리 누수가 생깁니다.
좋은 커스텀 Hook은 결국 "순수 비즈니스 로직과 React 생명주기의 우아한 결합"입니다. UI와 로직을 분리함으로써 테스트도 쉬워지고, 다른 컴포넌트에서의 재사용도 간단해집니다. 팀에서 공통으로 사용하는 Hook들은 별도 패키지나 훅 라이브러리로 관리하면 코드베이스 전반의 일관성을 유지하는 데 도움이 됩니다.
React Hooks 시대의 상태 관리 전략
React Hooks가 등장하면서 Redux 같은 외부 상태 관리 라이브러리의 필요성이 많이 줄었습니다. useState와 useReducer, 그리고 Context API를 적절히 조합하면 중소 규모 앱에서는 외부 라이브러리 없이도 상태 관리가 충분합니다. 판단 기준은 단순합니다. 상태가 특정 컴포넌트와 그 자식에만 관련된다면 useState로 충분하고, 여러 단계를 넘어 공유해야 한다면 Context, 서버 상태(API 데이터)라면 React Query나 SWR을 고려하세요. 복잡한 클라이언트 상태(다중 탭, 오프라인 지원, 낙관적 업데이트)가 필요한 경우에만 Redux나 Zustand 같은 전용 상태 관리 도구를 도입하는 것이 합리적입니다.
Zustand는 최근 국내 React 프로젝트에서 특히 인기를 얻고 있습니다. Redux에 비해 보일러플레이트가 훨씬 적고, Context보다 리렌더링 최적화가 뛰어나며, 설정 없이 바로 쓸 수 있다는 장점이 있습니다. Jotai나 Recoil도 비슷한 접근으로 원자(Atom) 단위의 상태를 관리해 필요한 컴포넌트만 리렌더링되도록 합니다. 어떤 도구를 선택하든 중요한 것은 프로젝트 규모와 팀의 이해도에 맞는 선택을 하는 것입니다. 과도하게 복잡한 상태 관리 솔루션은 개발 속도를 늦추고 새로운 팀원의 학습 곡선을 높입니다.