FEInterview Prep

react · high priority

React Hooks 심층 — 클로저, 의존성 배열, 그리고 내부 동작

훅이 "마법" 으로 보이지 않게 만드는 정신 모델

advanced 난이도6시간토스카카오네이버당근배민라인쿠팡
시작 전
이해도
매우 낮음

학습 개요

탄생 배경

쉬운 설명

복잡한 개념을 실생활 비유로 설명합니다.

학교 사물함 — 출석 번호 순서대로

한 컴포넌트는 한 명의 학생이고, 매 렌더는 그 학생이 학교에 온 하루입니다. 사물함(`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 가 강제하는 이유입니다.

단순화한 useState 가짜 구현tsx
1// 진짜 구현은 react-reconciler 에 있고 더 복잡하지만, 정신 모델은 이렇다.
2let currentFiber: any; // 지금 렌더 중인 Fiber
3let hookIndex = 0; // 이 컴포넌트에서 몇 번째 훅인지
4
5function 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}
16
17// 컴포넌트가 호출될 때마다 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 를 조건부로 부르는 건 괜찮나요? 안 괜찮다면 그 이유는?

자기 점검

"훅 호출 순서가 슬롯 식별자다" 라는 문장을 본인 언어로 풀어 설명하라.

기대 키워드

FibermemoizedState연결 리스트인덱스Rules of Hooks

자주 하는 오해

훅 이름으로 식별된다고 착각하기 쉽지만, 실제로는 호출 순서(인덱스) 가 식별자다. 그래서 조건부 호출이 금지된다.

useEffect 의 의존성을 비우면 "최신" 값을 본다고 착각하는 사람을 어떻게 설득하겠는가?

기대 키워드

stale closure클로저함수형 업데이트eslint exhaustive-deps

자주 하는 오해

의존성을 비우는 게 "최적화" 라고 생각하기 쉽지만, 실제로는 그 시점 변수에 *영원히* 묶이는 가장 위험한 옵션이다.

useRef 와 useState 의 결정적 차이를 한 줄로 설명하라.

기대 키워드

리렌더트리거값 보관mutable

자주 하는 오해

"둘 다 값을 보관한다" 가 아니라 "ref 는 바뀌어도 리렌더하지 않고, state 는 바뀌면 리렌더한다" 가 핵심. 렌더에 영향이 없는 mutable 박스가 ref.

학습 자료