함수 컴포넌트가 옛날 값을 보는 이유
fiber(유지) vs 클로저(스냅샷), 그리고 stale closure
난이도: ★★★★☆ 연관 노트: 조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나
핵심 요약
- 유지(persistence)는 fiber가 한다. 함수 컴포넌트는 매 렌더마다 처음부터 다시 실행되는 함수라 실행이 끝나면 다 잊는다. 그런데 state가 렌더를 넘어 살아남는 건 값이 함수 바깥의 fiber에 저장되기 때문이다.
- 스냅샷 포획(capture)은 클로저가 한다. 렌더 시점에
useState가 fiber에서 읽어온 값은 그 렌더의 지역 const에 박히고, 그 렌더에서 만든 함수(이벤트 핸들러·effect 콜백)들은 그 값을 클로저로 포획한다 — 살아있는 fiber 조회가 아니라 그 순간의 사본. - 리렌더는 매번 새 클로저로 갈아끼운다. 그래서 보통은 최신 값이 보인다. 옛 값이 계속 보이는 건(stale closure) 옛 클로저가 안 버려지고 살아남을 때뿐이다 —
setTimeout, 한 번 등록하고 안 갱신한 리스너,useEffect(…, []).
“함수 컴포넌트는 100% 클로저를 따른다. 단, 값의 렌더-간 유지는 클로저가 아니라 fiber가 담당한다.” 이 둘을 분리하면 모순이 사라진다.
왜 헷갈렸나
- “함수 컴포넌트도 함수니까 클로저를 따른다 → 근데 state는 유지되네? → 그럼 클로저를 안 따르는 건가?”에서 막혔다.
- 함정: state 유지를 “클로저가 하는 일”로 착각한 것. state 유지는 fiber의 일이고, 클로저는 그 fiber 값을 “그 렌더 시점에 읽은 스냅샷”으로 가둘 뿐이다. 역할이 다르므로 둘은 안 싸운다.
- “값을 클로저에서 갖고 오나 fiber에서 갖고 오나?” → 값은 렌더 시 fiber에서 읽어온다. 다만 그 뒤 만들어진 함수가 그 값을 클로저에 가둔다.
메커니즘
비유: 매일 새로 출근하는 직원 + 회사 캐비닛 + 오늘의 메모
매일(=매 렌더): 새 직원 출근 → 매뉴얼(컴포넌트 함수) 0번 줄부터 다시 읽음
│
├─ "useState 줄" → 캐비닛(fiber) 서랍에서 지금 값을 꺼냄 ← 유지 = fiber
│
├─ 꺼낸 숫자를 오늘 메모지(지역 const count)에 적음
│
├─ 오늘 만든 심부름 쪽지(onClick·effect 콜백)에 그 숫자를 베껴 적음 ← 스냅샷 = 클로저
│
└─ 퇴근: 오늘 메모지·쪽지는 원칙적으로 폐기, 캐비닛은 그대로
- 캐비닛(fiber) = 값이 진짜 사는 곳. 렌더를 넘어 유지·갱신.
- 오늘의 심부름 쪽지(클로저) = 그 렌더가 만든 함수가 “그 순간 숫자”를 베껴 들고 다니는 것.
왜 함수는 옛 값을 보나 (stale closure)
const [count] = useState(0); // 이 렌더의 메모지: count = 5 라고 치자
const onClick = () => setTimeout(() => alert(count), 3000); // "5" 쪽지를 포획
버튼 누르고 3초 안에 count가 10이 돼도 → alert은 5. 그 콜백은 “5가 적힌 렌더의 쪽지”를 클로저로 붙잡고 있어서다. 캐비닛(fiber)은 10인데, 손에 든 쪽지가 옛날 것.
캐비닛(fiber): 5 → 10 으로 갱신됨
살아남은 옛 콜백: 손에 든 쪽지 = "5" → alert 5 😵 (stale)
왜 평소엔 stale을 안 겪나
리렌더 = 함수 재실행 = 새 쪽지(새 클로저)를 매번 새로 만든다. 버튼에 붙는 onClick도 새 걸로 갈아끼워진다. 그래서 다음 클릭은 최신 값을 본다. stale은 옛 쪽지가 어딘가(setTimeout 큐, 구독 등)에 붙잡혀 안 버려질 때만 문제다.
렌더 “안”은 동기, 렌더 “사이”는 예약(비동기)
[클릭] onClick() 실행 ── 이 실행은 동기
├─ setCount(10) → "리렌더 해줘" 예약만. count는 아직 5! (즉시 안 바뀜)
└─ onClick 끝
│ (React가 예약된 리렌더를 나중에 처리 — 즉시 아님)
▼
[리렌더] Comp() 새로 실행 ── 이 1회도 위→아래 동기
├─ const count = useState() → fiber에서 10 읽음
├─ const onClick = () => … ← 10 가둔 새 클로저
└─ return JSX
→ setCount(10) 바로 다음 줄에서 console.log(count)가 5인 이유: setCount는 리렌더를 예약할 뿐, 이 렌더의 count 메모지는 안 바뀐다. 10은 다음 렌더의 새 메모지에서만 보인다.
해결 패턴
// ❌ 옛 스냅샷을 읽음 (stale)
const [count, setCount] = useState(0);
const onClick = () => {
setCount(count + 1); // 이 렌더의 count(스냅샷)에 +1 → 연타 시 밀림
setTimeout(() => alert(count), 1000); // 옛 count를 포획
};
// ✅ 최신을 원하면 fiber에 직접 물어본다 (updater function)
const onClick = () => {
setCount((c) => c + 1); // 스냅샷 무시, fiber의 최신 값 기준 +1
};
// ✅ effect에서 최신 값이 필요하면 deps에 넣어 클로저를 갱신하거나, ref로 최신값을 읽는다
useEffect(() => { /* count 사용 */ }, [count]); // deps 갱신 → 새 클로저
다음에 이 상황을 만나면
“콜백/타이머/effect 안에서 값이 옛날 것 같다” → 그 함수가 어느 렌더에서 만들어졌는지 를 의심한다. 살아남은 옛 클로저인지 확인.
- 이 콜백은 이후에도 “그때 그 함수”가 계속 붙잡혀 있나? (setTimeout, 이벤트 리스너,
[]effect) - 최신 값이 필요하면 스냅샷 대신 updater form(
setX(prev => …)) 또는 ref로 읽는가? -
setState직후 줄에서 그 state를 읽으려 하고 있지 않나? (그 값은 다음 렌더에 온다)
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나 — “함수 컴포넌트 = 매번 새로 실행되는 함수 + fiber 저장”이라는 같은 전제를 공유한다.
- 클로저(closure) — 함수가 정의된 스코프의 변수를 포획한다는 JS 기본기. 이걸 알아야 “스냅샷을 가둔다”가 이해된다.
→ 확장 개념 (여기서 더 나아가면)
- Updater function / 함수형 업데이트 —
setX(prev => …)가 왜 스냅샷을 우회하는지. - useEvent / useRef 최신값 패턴 — 클로저 갱신 없이 항상 최신 값을 읽는 이스케이프 해치.
↔ 같은 원리가 적용되는 곳
- 비동기 반복문에서 React 상태가 꼬이는 이유 — 렌더 스냅샷 + 이벤트 루프가 엮인 대표 사례.
- useCallback 안에서 sessionStorage 값이 갱신되지 않는 이유 — 외부 store 값이 deps에 없어 클로저가 고정되는 stale.
- router를 deps에 넣어도 useMemo가 재계산되지 않는 이유 — “참조가 안 바뀌면 갱신 안 됨”이라는 같은 결의 문제.
- 입력창이 사라질 때 값이 멋대로 저장되는 이유 — setState가 즉시 반영되지 않는다는 같은 타이밍 함정.
참고
- React 공식 — State as a Snapshot — 렌더 = 스냅샷
- React 공식 — Queueing a Series of State Updates — updater function
- Dan Abramov — A Complete Guide to useEffect — 렌더별 클로저와 stale closure