함수 컴포넌트가 옛날 값을 보는 이유

fiber(유지) vs 클로저(스냅샷), 그리고 stale closure

reactclosure

난이도: ★★★★☆ 연관 노트: 조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나


핵심 요약

  1. 유지(persistence)는 fiber가 한다. 함수 컴포넌트는 매 렌더마다 처음부터 다시 실행되는 함수라 실행이 끝나면 다 잊는다. 그런데 state가 렌더를 넘어 살아남는 건 값이 함수 바깥의 fiber에 저장되기 때문이다.
  2. 스냅샷 포획(capture)은 클로저가 한다. 렌더 시점에 useState가 fiber에서 읽어온 값은 그 렌더의 지역 const에 박히고, 그 렌더에서 만든 함수(이벤트 핸들러·effect 콜백)들은 그 값을 클로저로 포획한다 — 살아있는 fiber 조회가 아니라 그 순간의 사본.
  3. 리렌더는 매번 새 클로저로 갈아끼운다. 그래서 보통은 최신 값이 보인다. 옛 값이 계속 보이는 건(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 최신값 패턴 — 클로저 갱신 없이 항상 최신 값을 읽는 이스케이프 해치.

↔ 같은 원리가 적용되는 곳


참고