react · high priority
React Hooks 심층 — 클로저, 의존성 배열, 그리고 내부 동작
훅이 "마법" 으로 보이지 않게 만드는 정신 모델
학습 개요
탄생 배경
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“학교 사물함 — 출석 번호 순서대로”
한 컴포넌트는 한 명의 학생이고, 매 렌더는 그 학생이 학교에 온 하루입니다. 사물함(`Fiber.memoizedState` 의 훅 리스트) 은 출석 번호 순서대로 줄지어 있고, 학생은 항상 *같은 순서로* 사물함을 엽니다. 첫 번째는 도시락(useState), 두 번째는 체육복(useEffect), 세 번째는 안경(useRef)… 어느 날 아파서 두 번째 사물함을 건너뛰면(조건부 훅 호출), 세 번째 사물함에서 체육복이 나옵니다 — 모든 게 한 칸씩 밀려 *상태가 꼬입니다*.
핵심 개념
useState/useEffect/useRef 같은 훅은 컴포넌트가 호출될 때 React 내부에서 *현재 처리 중인 Fiber 노드* 를 보고, 그 노드의 memoizedState 에 매달린 *훅 객체 연결 리스트* 를 순서대로 한 칸씩 진행합니다. 첫 번째 useState 는 첫 번째 슬롯, 두 번째 useEffect 는 두 번째 슬롯 — 이런 식으로 *호출 순서* 가 *슬롯 식별자* 입니다.
Rules of Hooks 가 왜 "법" 인가
훅을 if/for/return 안에 넣으면 어떤 렌더에서는 슬롯 #2 가 useEffect 였는데 다음 렌더에서는 useRef 가 들어옵니다. React 는 슬롯의 *타입* 을 검증하지 않으므로 직전 값을 잘못된 훅이 받게 되어 *상태가 꼬이는* 버그가 발생합니다. ESLint 의 react-hooks/rules-of-hooks 가 강제하는 이유입니다.
1// 진짜 구현은 react-reconciler 에 있고 더 복잡하지만, 정신 모델은 이렇다.2let currentFiber: any; // 지금 렌더 중인 Fiber3let hookIndex = 0; // 이 컴포넌트에서 몇 번째 훅인지45function useState<T>(initial: T): [T, (v: T) => void] {6 const fiber = currentFiber;7 const hooks = fiber.memoizedState ??= [];8 const idx = hookIndex++;9 if (hooks[idx] === undefined) hooks[idx] = initial; // 첫 마운트10 const setter = (v: T) => {11 hooks[idx] = v;12 scheduleRender(fiber); // 다시 렌더 예약13 };14 return [hooks[idx], setter];15}1617// 컴포넌트가 호출될 때마다 idx 는 0 부터 시작 → 호출 순서가 슬롯 식별자
실무 적용
어떤 상황에서 사용하는가
검색창 컴포넌트에 입력 디바운스 + 결과 캐싱 + 취소를 모두 넣어야 한다. useEffect 와 useState 만으로 짜다 보니 stale closure, 의존성 누락, race condition 이 뒤섞였다.
어떻게 적용하는가
(1) 디바운스는 `useDebouncedValue` custom hook 으로 분리. (2) 데이터 패칭은 직접 effect 로 짜지 말고 React Query/Tanstack Query 같은 라이브러리에 위임 — 캐싱·취소·중복 제거가 무료. (3) 꼭 직접 짜야 한다면 effect 안에서 `AbortController` 를 만들고 cleanup 에서 abort, 응답에 "stale 여부" 를 표시하는 ref 를 둔다. (4) eslint-plugin-react-hooks 의 exhaustive-deps 경고를 모두 해결.
흔한 실수와 안티패턴
- effect 안에서 async 함수를 직접 만들지 말고 *내부에 async 함수를 정의해 호출* — useEffect cleanup 이 Promise 를 반환받지 못한다.
- 의존성 배열에 매 렌더 새로 만들어지는 객체를 그대로 넣어 무한 루프 — `useMemo` 로 안정화하거나 primitive 로 분해.
- race condition 무시 — 늦게 도착한 응답이 최신 상태를 덮어쓸 수 있다. cleanup 에서 무효화 플래그를 세우거나 AbortController 사용.
- derived state 를 굳이 useState + useEffect 로 동기화 — 렌더 중 직접 계산이 거의 항상 더 단순.
흔한 오해
"useEffect 의 의존성 배열을 비우면 마운트 시점의 최신 값을 본다."
교정정반대다. 마운트 시점의 *그 값* 에 영원히 묶인다 (stale closure).
왜 중요effect 콜백은 그 렌더의 클로저다. 의존성이 비면 React 가 새 콜백을 만들지 않으므로, 첫 렌더의 변수만 보게 된다.
"useCallback / useMemo 는 무조건 성능에 좋다."
교정얕은 비교 + 캐시 유지 비용이 든다. 대상이 안정적이거나 비싼 경우에만 의미 있음.
왜 중요리액트 공식 문서도 "기본은 안 쓰고, 측정 후 필요할 때만" 을 권장. React Compiler 도입 후엔 더더욱 수동 메모는 줄어든다.
"useRef 는 클래스의 this 다."
교정값을 보관한다는 점만 비슷하지, *바뀌어도 리렌더되지 않는다* 는 게 결정적 차이.
왜 중요state 는 변경 시 리렌더 트리거, ref 는 *current 만 갈아끼우는 박스*. 렌더에 영향이 없어야 하는 데이터(타이머 ID, 이전 값 기억) 에 쓴다.
면접 질문
답변 방향 힌트
Fiber 노드의 훅 리스트와 호출 순서가 슬롯 식별자라는 점이 핵심입니다.
반드시 언급할 키워드
- 훅 객체들이 Fiber 의 memoizedState 에 연결 리스트로 매달려 있음
- 호출 순서가 곧 슬롯의 식별자 (이름이 아닌 인덱스)
- 조건/반복으로 호출 순서가 달라지면 슬롯이 어긋나 다른 훅이 다른 값을 받게 됨
- React 는 슬롯의 *타입* 을 검증하지 않음
- eslint-plugin-react-hooks 가 컴파일 타임에 강제
예상 꼬리 질문
- custom hook 안에서 호출한 훅도 호출자의 슬롯을 차지하는 이유는?
- `useEffect` 안에서 setState 를 조건부로 부르는 건 괜찮나요? 안 괜찮다면 그 이유는?
자기 점검
"훅 호출 순서가 슬롯 식별자다" 라는 문장을 본인 언어로 풀어 설명하라.
기대 키워드
자주 하는 오해
훅 이름으로 식별된다고 착각하기 쉽지만, 실제로는 호출 순서(인덱스) 가 식별자다. 그래서 조건부 호출이 금지된다.
useEffect 의 의존성을 비우면 "최신" 값을 본다고 착각하는 사람을 어떻게 설득하겠는가?
기대 키워드
자주 하는 오해
의존성을 비우는 게 "최적화" 라고 생각하기 쉽지만, 실제로는 그 시점 변수에 *영원히* 묶이는 가장 위험한 옵션이다.
useRef 와 useState 의 결정적 차이를 한 줄로 설명하라.
기대 키워드
자주 하는 오해
"둘 다 값을 보관한다" 가 아니라 "ref 는 바뀌어도 리렌더하지 않고, state 는 바뀌면 리렌더한다" 가 핵심. 렌더에 영향이 없는 mutable 박스가 ref.
학습 자료
- Synchronizing with Effectseffect 를 라이프사이클이 아닌 "외부 시스템과의 동기화" 로 보는 정신 모델을 정리한 공식 문서.Docreact.dev
- You Might Not Need an Effect잘못 쓰인 effect 를 진단하고 derived state, event handler, custom hook 으로 옮기는 사례집.Docreact.dev
- A Complete Guide to useEffect클로저, 의존성, race condition, useReducer 까지 effect 관련 사고방식을 가장 깊게 정리한 정전급 글.BlogDan Abramov — Overreacted
- Rules of Hooks훅 호출 규칙의 공식 정의와 ESLint 플러그인 설명.Docreact.dev