ReactEasy#13
React에서 useState와 useEffect의 역할과 사용법을 설명해주세요.
#React#훅#상태관리
힌트
상태 관리와 사이드 이펙트 처리의 기본 훅입니다.
정답 및 해설
React에서 useState와 useEffect의 역할과 사용법을 설명해주세요.
useState와 useEffect는 React 함수형 컴포넌트에서 가장 기본적이고 핵심적인 훅(Hook)입니다. useState는 컴포넌트 내부의 상태(state)를 관리하고, useEffect는 렌더링 이후 발생하는 사이드 이펙트(부수 효과)를 처리합니다. 두 훅을 올바르게 이해하고 조합하면 클래스형 컴포넌트의 setState, componentDidMount, componentDidUpdate, componentWillUnmount 등을 모두 대체할 수 있습니다.
useState
개념과 기본 사용법
useState는 컴포넌트의 로컬 상태를 선언합니다. 초기값을 인수로 받아 [현재 상태, 상태 변경 함수] 형태의 배열을 반환합니다.
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>
);
}
다양한 타입의 상태 관리
function UserForm() {
// 문자열 상태
const [name, setName] = useState('');
// 숫자 상태
const [age, setAge] = useState(0);
// 불리언 상태
const [isVisible, setIsVisible] = useState(false);
// 객체 상태
const [user, setUser] = useState({ name: '', email: '' });
// 배열 상태
const [items, setItems] = useState([]);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input
value={user.email}
onChange={e => setUser(prev => ({ ...prev, email: e.target.value }))}
/>
</form>
);
}
함수형 업데이트 (Functional Update)
이전 상태를 기반으로 새 상태를 계산할 때는 함수형 업데이트를 사용해야 합니다. 비동기 환경에서 최신 상태를 보장합니다.
function Counter() {
const [count, setCount] = useState(0);
// 잘못된 방법: 연속 호출 시 최신 상태를 반영하지 못할 수 있음
const handleWrongIncrement = () => {
setCount(count + 1);
setCount(count + 1); // 여전히 이전 count를 참조
};
// 올바른 방법: 함수형 업데이트로 최신 상태 보장
const handleCorrectIncrement = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 항상 최신 상태에서 +1
};
return (
<div>
<p>{count}</p>
<button onClick={handleCorrectIncrement}>+2 (정확)</button>
</div>
);
}
지연 초기화 (Lazy Initialization)
초기값 계산 비용이 클 때 함수를 전달하면 최초 렌더링 시에만 실행됩니다.
function ExpensiveComponent() {
// 매 렌더링마다 실행됨 (비효율)
// const [state, setState] = useState(computeExpensiveValue());
// 최초 마운트 시에만 실행됨 (효율적)
const [state, setState] = useState(() => {
return computeExpensiveValue();
});
return <div>{state}</div>;
}
function loadFromStorage() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// ...
}
객체 상태 업데이트 시 주의사항
function ProfileEditor() {
const [profile, setProfile] = useState({
name: '홍길동',
age: 30,
address: { city: '서울', district: '강남구' }
});
// 중첩 객체 업데이트: 스프레드 연산자로 불변성 유지
const updateCity = (newCity) => {
setProfile(prev => ({
...prev,
address: {
...prev.address,
city: newCity
}
}));
};
// 배열 상태 업데이트
const [items, setItems] = useState(['사과', '바나나']);
const addItem = (item) => setItems(prev => [...prev, item]);
const removeItem = (index) => setItems(prev => prev.filter((_, i) => i !== index));
const updateItem = (index, newItem) =>
setItems(prev => prev.map((item, i) => i === index ? newItem : item));
}
useEffect
개념과 기본 사용법
useEffect는 컴포넌트가 렌더링된 후 사이드 이펙트를 실행합니다. 데이터 패칭, 구독 설정, DOM 직접 조작, 타이머 등록 등에 사용됩니다.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// 렌더링 후 실행되는 사이드 이펙트
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // userId가 변경될 때마다 재실행
if (!user) return <div>로딩 중...</div>;
return <div>{user.name}</div>;
}
의존성 배열로 실행 시점 제어
function Examples() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// 1. 의존성 배열 없음: 매 렌더링마다 실행
useEffect(() => {
console.log('매 렌더링마다 실행됨');
});
// 2. 빈 배열: 마운트 시 1회만 실행
useEffect(() => {
console.log('컴포넌트 마운트 시 1회 실행');
}, []);
// 3. 특정 의존성: 해당 값이 변경될 때마다 실행
useEffect(() => {
console.log('count가 변경됨:', count);
}, [count]);
// 4. 여러 의존성
useEffect(() => {
console.log('count 또는 name이 변경됨');
}, [count, name]);
}
클린업 함수 (Cleanup Function)
클린업 함수는 컴포넌트가 언마운트되거나 효과가 재실행되기 전에 호출됩니다.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// 클린업: 언마운트 시 또는 재실행 전에 인터벌 정리
return () => {
clearInterval(interval);
};
}, []); // 마운트/언마운트 시에만
return <div>경과 시간: {seconds}초</div>;
}
function ChatRoom({ roomId }) {
useEffect(() => {
// 웹소켓 구독
const socket = createWebSocket(roomId);
socket.connect();
socket.on('message', handleMessage);
// 클린업: roomId 변경 시 이전 구독 해제
return () => {
socket.off('message', handleMessage);
socket.disconnect();
};
}, [roomId]); // roomId가 바뀌면 재연결
}
데이터 패칭과 경쟁 조건(Race Condition) 방지
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) return;
let isCancelled = false; // 클린업 플래그
setLoading(true);
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!isCancelled) {
// 컴포넌트가 여전히 마운트 상태일 때만 상태 업데이트
setResults(data);
setLoading(false);
}
});
return () => {
isCancelled = true; // 이전 요청 무시
};
}, [query]);
return (
<div>
{loading ? <span>검색 중...</span> : results.map(r => <div key={r.id}>{r.title}</div>)}
</div>
);
}
AbortController를 활용한 패칭 취소
function ProductDetail({ productId }) {
const [product, setProduct] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/products/${productId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error('패칭 오류:', err);
}
});
return () => controller.abort(); // 언마운트 시 요청 취소
}, [productId]);
return product ? <div>{product.name}</div> : <div>로딩 중...</div>;
}
이벤트 리스너 등록/해제
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
// 클린업: 이벤트 리스너 제거
return () => window.removeEventListener('scroll', handleScroll);
}, []); // 마운트 시 1회 등록
return <div>스크롤 위치: {scrollY}px</div>;
}
useState와 useEffect 조합 패턴
데이터 패칭 패턴
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return res.json();
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// 사용 예시
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error}</div>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
클래스형 생명주기 메서드와의 비교
| 클래스형 생명주기 | useEffect 대응 |
|---|---|
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [dep]) |
componentWillUnmount | useEffect(() => { return () => {} }, []) |
componentDidMount + componentDidUpdate | useEffect(() => {}) (의존성 배열 없음) |
주요 주의사항 및 Best Practices
| 항목 | 올바른 사용 | 잘못된 사용 |
|---|---|---|
| 함수형 업데이트 | setCount(prev => prev + 1) | 비동기 로직에서 setCount(count + 1) |
| 객체 상태 | 스프레드로 불변성 유지 | 직접 객체 프로퍼티 수정 |
| useEffect 의존성 | 사용하는 모든 외부 값 포함 | 의존성 누락으로 stale closure |
| 클린업 | 구독, 타이머, 이벤트 리스너 해제 | 클린업 없이 반복 등록 |
| 패칭 취소 | AbortController 또는 플래그 사용 | 경쟁 조건 방치 |
핵심 정리
useState는 상태 변경 시 리렌더링을 트리거하며, 불변성을 지켜 새 상태를 설정해야 합니다.useEffect는 렌더링 이후 실행되므로 초기 렌더링에는 이전 상태가 표시될 수 있습니다.- 클린업 함수는 메모리 누수와 경쟁 조건을 방지하는 핵심 메커니즘입니다.
- 의존성 배열에 모든 의존 값을 포함하지 않으면 오래된 클로저(stale closure) 문제가 발생합니다.