functionCounter(){const[count,setCount]=useState(0);consthandleClick=()=>{setCount(count+1);setCount(count+1);setCount(count+1);console.log(count);// 여전히 0 — 즉시 반영되지 않는다};// 결과: count는 1이 된다 (3이 아님!)}
세 번 호출해도 count는 같은 클로저 값(0)을 참조하므로 setCount(0 + 1)이 세 번 실행될 뿐이다.
const[user,setUser]=useState({name:'김도윤',age:25});// ❌ 잘못된 방법 — 직접 변경user.age=26;setUser(user);// React가 변경을 감지하지 못함 (같은 참조)// ✅ 올바른 방법 — 새 객체 생성setUser(prev=>({...prev,age:26}));
React는 참조 비교(Object.is)로 상태 변경을 감지한다. 같은 객체 참조를 전달하면 리렌더링이 발생하지 않는다.
useEffect: 부수 효과 관리
기본 구조
1
2
3
4
5
6
useEffect(()=>{// 실행할 부수 효과return ()=>{// cleanup 함수 (선택)};},[dependencies]);
의존성 배열에 따라 실행 시점이 달라진다:
의존성 배열
실행 시점
생략
매 렌더링마다
[] (빈 배열)
마운트 시 1번
[a, b]
a 또는 b가 변경될 때
함정 1: 빈 의존성 배열에서 stale closure
1
2
3
4
5
6
7
8
9
10
11
functionTimer(){const[count,setCount]=useState(0);useEffect(()=>{constid=setInterval(()=>{console.log(count);// 항상 0 — stale closure!setCount(count+1);// 항상 0 + 1 = 1},1000);return ()=>clearInterval(id);},[]);// count를 의존성에 넣지 않았다}
[]을 전달하면 effect는 마운트 시점의 count(0)를 영원히 참조한다. 해결 방법:
1
2
3
4
5
6
7
8
9
// 방법 1: 함수형 업데이트setCount(prev=>prev+1);// 방법 2: 의존성 배열에 추가 (단, 매번 재실행됨)useEffect(()=>{...},[count]);// 방법 3: useRef로 최신 값 추적constcountRef=useRef(count);countRef.current=count;
함정 2: 객체/배열 의존성
1
2
3
4
5
6
7
8
9
10
functionUserProfile({userId}){const[user,setUser]=useState(null);// ⚠️ options가 매 렌더링마다 새 객체 → effect 무한 실행constoptions={includeDetails:true};useEffect(()=>{fetchUser(userId,options).then(setUser);},[userId,options]);// options는 매번 새 참조!}
객체와 배열은 매 렌더링마다 새 참조가 만들어진다. 해결:
1
2
3
4
5
6
7
// 방법 1: 의존성을 원시 값으로 분해useEffect(()=>{fetchUser(userId,{includeDetails:true}).then(setUser);},[userId]);// 방법 2: useMemo로 참조 안정화constoptions=useMemo(()=>({includeDetails:true}),[]);
Cleanup 함수의 중요성
1
2
3
4
5
6
7
8
useEffect(()=>{constws=newWebSocket('wss://api.example.com/feed');ws.onmessage=(event)=>setMessages(prev=>[...prev,event.data]);return ()=>{ws.close();// 컴포넌트 언마운트 시 연결 종료};},[]);
cleanup을 빠뜨리면 메모리 누수가 발생한다. 구독, 타이머, WebSocket, 이벤트 리스너는 반드시 cleanup에서 정리하자.
흔한 실수: useEffect 안에서 상태 설정 → 무한 루프
1
2
3
4
5
6
7
8
9
// ❌ 무한 루프useEffect(()=>{setCount(count+1);// 상태 변경 → 리렌더링 → effect 재실행 → ...});// ❌ 미묘한 무한 루프useEffect(()=>{setItems([...items,newItem]);// items가 변경 → effect 재실행},[items]);
// ✅ 사용이 정당한 경우// 1. React.memo로 감싼 자식에게 전달하는 콜백constMemoChild=React.memo(({onClick})=><buttononClick={onClick}>Click</button>);functionParent(){consthandleClick=useCallback(()=>{console.log('clicked');},[]);return<MemoChildonClick={handleClick}/>;}// 2. useEffect 의존성에 들어가는 함수constfetchData=useCallback(()=>{returnapi.get(`/users/${userId}`);},[userId]);useEffect(()=>{fetchData().then(setData);},[fetchData]);// 3. 비용이 큰 계산constresult=useMemo(()=>{returnheavyComputation(data);// 수만 건 데이터 처리},[data]);
남용하지 말 것
1
2
3
4
5
6
7
8
9
10
// ❌ 불필요한 useMemo — 단순 계산constfullName=useMemo(()=>`${firstName}${lastName}`,[firstName,lastName]);// 그냥 이렇게 쓰면 된다:constfullName=`${firstName}${lastName}`;// ❌ 불필요한 useCallback — memo된 자식에게 전달하지 않는 콜백consthandleClick=useCallback(()=>{setCount(c=>c+1);},[]);// 자식이 React.memo가 아니면 어차피 리렌더링된다
useMemo/useCallback 자체도 비용이 있다. 의존성 배열 비교, 이전 값 캐싱 등의 오버헤드가 발생한다. 단순한 연산에 적용하면 오히려 성능이 나빠진다. “측정 후 최적화”가 원칙이다.
functionuseDebounce(value,delay){const[debounced,setDebounced]=useState(value);useEffect(()=>{consttimer=setTimeout(()=>setDebounced(value),delay);return ()=>clearTimeout(timer);},[value,delay]);returndebounced;}// 사용: 검색 입력 디바운스functionSearchBar(){const[query,setQuery]=useState('');const[results,setResults]=useState([]);constdebouncedQuery=useDebounce(query,300);useEffect(()=>{if (debouncedQuery){searchApi(debouncedQuery).then(setResults);}},[debouncedQuery]);return<inputvalue={query}onChange={e=>setQuery(e.target.value)}/>;}
Custom Hook 설계 원칙:
하나의 관심사에 집중 — useFetch는 API 호출만, useLocalStorage는 로컬 저장소만
반환 값은 사용처에 맞게 — 단일 값이면 값 자체를, 여러 값이면 객체로
cleanup을 잊지 말 것 — 타이머, 구독, 요청 취소
Hook 규칙을 준수 — 내부에서 다른 Hook을 조건부로 호출하지 않기
Hook의 규칙과 그 이유
규칙 1: 최상위에서만 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 조건문 안에서 호출if (isLoggedIn){const[user,setUser]=useState(null);// 금지!}// ❌ 반복문 안에서 호출for (constitemofitems){useEffect(()=>{...});// 금지!}// ✅ 항상 컴포넌트/Hook 최상위에서 호출const[user,setUser]=useState(null);const[items,setItems]=useState([]);
이유: React는 Hook을 호출 순서로 식별한다. 내부적으로 Hook 상태를 배열(또는 연결 리스트)로 관리하며, 매 렌더링마다 같은 순서로 호출되어야 올바른 상태를 매칭할 수 있다. 조건부 호출은 순서를 깨뜨린다.
Hook은 단순한 API 변경이 아니라 React의 사고방식 자체를 바꾼 전환점이었다. 생명주기 중심에서 동기화 중심으로, 상속 기반에서 합성 기반으로의 전환이다.
핵심 정리:
Hook
용도
주의점
useState
상태 관리
비동기 업데이트, 객체 불변성
useEffect
부수 효과
의존성 배열, cleanup, stale closure
useCallback
함수 메모이제이션
React.memo와 함께 써야 의미 있음
useMemo
값 메모이제이션
측정 후 적용, 단순 계산에 남용 금지
useRef
DOM 접근 / 뮤터블 값
변경해도 리렌더링 안 됨
Custom Hook
로직 재사용
use 접두사, Hook 규칙 준수
클래스 컴포넌트를 억지로 Hook으로 바꿀 필요는 없다. 하지만 새 코드를 작성한다면 Hook이 기본이다. 공식 문서에서도 함수 컴포넌트 + Hook을 권장하고 있으며, React의 최신 기능(Server Components, Suspense 등)도 함수 컴포넌트를 전제로 설계되고 있다.