React Hooks 마스터하기: 2026년 실전 가이드
useState부터 커스텀 훅까지, React Hooks의 모든 것을 실무 예제와 함께 깊이 있게 다룹니다. 흔한 실수와 해결법도 포함되어 있습니다.
React Hooks는 함수형 컴포넌트에서 상태 관리와 생명주기 기능을 사용할 수 있게 해주는 혁신적인 기능입니다. 2019년에 도입된 이후 React 개발의 표준이 되었으며, 2026년 현재 거의 모든 React 프로젝트에서 사용되고 있습니다. 이 글에서는 Hooks의 기본부터 고급 패턴까지 깊이 있게 다루겠습니다.
Hooks가 등장한 배경
Hooks가 등장하기 전, React에서 상태 관리나 생명주기 기능을 사용하려면 클래스 컴포넌트를 작성해야 했습니다. 하지만 클래스 컴포넌트에는 몇 가지 문제점이 있었습니다.
첫째, this 바인딩이 복잡했습니다. 이벤트 핸들러에서 this가 제대로 작동하려면 생성자에서 바인딩하거나 화살표 함수를 사용해야 했습니다. 이는 JavaScript에 익숙하지 않은 개발자들에게 혼란을 주었습니다.
둘째, 생명주기 메서드에 관련 없는 로직이 섞이는 문제가 있었습니다. componentDidMount에서 이벤트 리스너를 등록하고, componentWillUnmount에서 해제하는 식으로 하나의 관심사가 여러 메서드에 분산되었습니다.
셋째, 컴포넌트 간 로직 재사용이 어려웠습니다. Higher-Order Components나 Render Props 같은 패턴을 사용해야 했는데, 이는 "래퍼 지옥"을 만들어 코드를 이해하기 어렵게 만들었습니다.
Hooks는 이러한 문제들을 우아하게 해결합니다.
useState: 상태 관리의 기본
useState는 가장 기본적인 Hook입니다. 함수형 컴포넌트에서 상태를 관리할 수 있게 해줍니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
<button onClick={() => setCount(count - 1)}>감소</button>
<button onClick={() => setCount(0)}>리셋</button>
</div>
);
}
useState는 배열을 반환합니다. 첫 번째 요소는 현재 상태 값이고, 두 번째 요소는 상태를 업데이트하는 함수입니다. 배열 구조 분해를 사용하여 원하는 이름으로 받을 수 있습니다.
함수형 업데이트의 중요성
이전 상태 값을 기반으로 새 상태를 설정해야 할 때는 함수형 업데이트를 사용해야 합니다. 이는 React의 상태 업데이트가 비동기적으로 일어나기 때문입니다.
// 잘못된 방법 - 예상대로 동작하지 않을 수 있음
const incrementThree = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count가 0이었다면 결과는 3이 아니라 1
};
// 올바른 방법 - 함수형 업데이트
const incrementThree = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 정확히 3 증가
};
객체와 배열 상태 관리
객체나 배열을 상태로 관리할 때는 불변성을 유지해야 합니다. 직접 수정하지 않고 새로운 객체나 배열을 만들어 설정해야 합니다.
// 객체 상태
const [user, setUser] = useState({ name: '', email: '' });
const updateName = (name: string) => {
setUser(prev => ({ ...prev, name }));
};
// 배열 상태
const [items, setItems] = useState<string[]>([]);
const addItem = (item: string) => {
setItems(prev => [...prev, item]);
};
const removeItem = (index: number) => {
setItems(prev => prev.filter((_, i) => i !== index));
};
지연 초기화
초기 상태를 계산하는 데 비용이 많이 드는 경우, 함수를 전달하여 지연 초기화할 수 있습니다. 이 함수는 초기 렌더링에만 실행됩니다.
// 매 렌더링마다 실행됨 (비효율적)
const [data, setData] = useState(expensiveCalculation());
// 초기 렌더링에만 실행됨 (효율적)
const [data, setData] = useState(() => expensiveCalculation());
useEffect: 부수 효과 관리
useEffect는 컴포넌트의 렌더링 이후에 실행되는 부수 효과를 관리합니다. 데이터 페칭, 구독 설정, DOM 조작 등에 사용됩니다.
import { useState, useEffect } from 'react';
function DocumentTitle() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `클릭 횟수: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
클릭 ({count})
</button>
);
}
의존성 배열 이해하기
useEffect의 두 번째 인자인 의존성 배열은 effect가 언제 실행될지를 결정합니다.
// 1. 의존성 배열이 없음 - 매 렌더링 후 실행
useEffect(() => {
console.log('매번 실행됨');
});
// 2. 빈 배열 - 마운트 시에만 실행
useEffect(() => {
console.log('마운트 시에만 실행');
}, []);
// 3. 특정 값 - 해당 값이 변경될 때만 실행
useEffect(() => {
console.log('count 또는 name이 변경됨');
}, [count, name]);
의존성 배열에는 effect 내부에서 사용하는 모든 반응형 값을 포함해야 합니다. 이를 지키지 않으면 버그가 발생할 수 있습니다.
클린업 함수의 중요성
useEffect에서 반환하는 함수는 클린업 함수입니다. 컴포넌트가 언마운트되거나 다음 effect가 실행되기 전에 호출됩니다. 메모리 누수를 방지하기 위해 반드시 필요합니다.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// 클린업 함수 - 반드시 인터벌 정리
return () => {
clearInterval(interval);
};
}, []);
return <div>경과 시간: {seconds}초</div>;
}
이벤트 리스너, 타이머, 구독 등을 설정할 때는 항상 클린업 함수에서 정리해야 합니다.
데이터 페칭 패턴
useEffect로 데이터를 가져올 때는 몇 가지 주의할 점이 있습니다.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}
cancelled 플래그를 사용하여 컴포넌트가 언마운트된 후 상태 업데이트를 방지합니다. 이는 메모리 누수를 방지하고 React 경고를 피하는 데 중요합니다.
useRef: 렌더링과 무관한 값
useRef는 렌더링 사이에 값을 유지하면서, 값이 변경되어도 리렌더링을 발생시키지 않는 참조를 만듭니다.
DOM 접근
가장 흔한 사용 사례는 DOM 요소에 접근하는 것입니다.
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" placeholder="입력하세요" />
<button onClick={focusInput}>포커스</button>
</div>
);
}
이전 값 저장
렌더링 사이에 값을 저장하는 데도 유용합니다.
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef<number>();
useEffect(() => {
prevCountRef.current = count;
});
return (
<div>
<p>현재: {count}, 이전: {prevCountRef.current}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
커스텀 훅 만들기
커스텀 훅을 통해 컴포넌트 로직을 재사용 가능한 함수로 추출할 수 있습니다. 이름이 use로 시작해야 합니다.
useLocalStorage 훅
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue] as const;
}
// 사용 예시
function App() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
현재 테마: {theme}
</button>
);
}
useDebounce 훅
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 사용 예시 - 검색 입력
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// API 호출
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="검색..."
/>
);
}
Hooks 사용 규칙
React Hooks를 사용할 때 반드시 지켜야 하는 규칙이 있습니다.
첫째, 최상위에서만 Hook을 호출해야 합니다. 조건문, 반복문, 중첩 함수 내부에서 Hook을 호출하면 안 됩니다. React는 Hook이 호출되는 순서에 의존하여 상태를 관리합니다.
// 잘못된 예
function Component({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // 조건문 안에서 Hook 호출
}
}
// 올바른 예
function Component({ isLoggedIn }) {
const [user, setUser] = useState(null);
// 조건부 로직은 Hook 내부에서
useEffect(() => {
if (isLoggedIn) {
fetchUser().then(setUser);
}
}, [isLoggedIn]);
}
둘째, React 함수 내에서만 Hook을 호출해야 합니다. 일반 JavaScript 함수에서는 Hook을 호출할 수 없습니다. React 함수 컴포넌트나 커스텀 Hook에서만 호출해야 합니다.
마무리
React Hooks는 함수형 컴포넌트의 가능성을 크게 확장시켜 주었습니다. useState, useEffect, useRef 등 기본 Hook을 잘 이해하고, 필요에 따라 커스텀 Hook을 만들어 사용하면 더 깔끔하고 재사용 가능한 코드를 작성할 수 있습니다.
특히 useEffect의 의존성 배열과 클린업 함수를 정확히 이해하는 것이 중요합니다. 이 부분에서 많은 버그가 발생하기 때문입니다.
다음 프로젝트에서 이 글의 내용을 적용해보시기 바랍니다.