"값이 바뀌면 다시 그리는 거 아니에요?"
프론트엔드 개발을 하다 보면 useRef를 '리렌더 안 되는 useState'쯤으로 이해하고 넘어가는 경우가 많습니다. 저도 한때 그랬고요. 그런데 Figma에서 볼 때는 괜찮았는데 실제로 구현하면 미묘하게 어긋나듯이, "값이 바뀌면 리렌더된다"는 직관도 React 내부에서는 상당히 다르게 동작합니다. 최근 velog에 올라온 chahyunnee님의 useRef 내부 동작 분석 글이 이 갭을 Fiber 수준까지 파고들어 정리해줬는데, 사용자 입장에서는 이게 컴포넌트 설계와 렌더링 최적화에 직접적으로 영향을 미치는 이야기라 꼼꼼히 짚어볼 가치가 있습니다.
React는 '값의 변화'를 감지하지 않는다
핵심부터 말하면, React는 값이 바뀌었는지 감시(watch)하지 않습니다. React가 리렌더를 실행하는 기준은 명확합니다 — setState 호출, dispatch 호출, props 변경, context 변경. 이 네 가지 공식 업데이트 경로를 통과한 변화만 렌더 대상으로 인식합니다. setCount(1)을 호출하면 내부적으로 업데이트 객체가 생성되고, 해당 컴포넌트의 업데이트 큐에 적재된 뒤, React 스케줄러에 "렌더 예약" 알림이 전달됩니다. React 18 이후로는 이 스케줄러가 Concurrent Mode를 통해 우선순위를 판단하고, 급한 작업을 먼저 끼워넣는 것까지 가능해졌죠.
반면 ref.current = 10은? 업데이트 객체 생성 없음, 큐 등록 없음, 스케줄러 알림 없음. 그냥 자바스크립트 객체의 프로퍼티 하나를 바꾼 것일 뿐입니다. React 입장에서는 아무 일도 일어나지 않은 셈이에요.
같은 Fiber, 완전히 다른 슬롯 구조
사실 이건 flexbox로 해결되지만 grid가 더 적절한 레이아웃이 있듯이, useState와 useRef는 같은 Fiber 노드의 Hook 연결 리스트에 나란히 저장되지만 슬롯의 내부 구조가 근본적으로 다릅니다.
- useState 슬롯:
memoizedState(현재 값) + 업데이트 큐 + setter/dispatch 연결 구조. 단순히 값만 저장하는 게 아니라, 업데이트 시스템 전체가 함께 붙어 있습니다. - useRef 슬롯:
{ current: 초기값 }— 끝. 업데이트 큐 없음, setter 없음, dispatch 없음.
React는 다음 렌더에서도 같은 슬롯에서 동일한 객체 참조를 그대로 반환합니다. 그래서 Hook은 반드시 항상 같은 순서로 호출되어야 하고, 조건문 안에서 Hook을 쓰면 안 되는 이유도 여기에 있습니다. 이건 마치 호이스팅에서 var와 let/const가 모두 호이스팅은 되지만 TDZ(Temporal Dead Zone) 여부에 따라 완전히 다른 동작을 보이는 것과 비슷한 구조적 대칭이에요.
실무에서 이걸 왜 알아야 하나
사용자 입장에서는 이 차이가 렌더링 최적화 전략에 직접 연결됩니다. 예를 들어 스크롤 위치 추적, 이전 값 캐싱, DOM 요소 직접 접근 같은 작업에서 useState를 쓰면 매 변경마다 불필요한 리렌더가 발생합니다. jiwon-frontend님의 React Hook 정리 글에서도 onScroll 이벤트에 useRef를 사용하는 패턴이 소개되어 있는데, 정확히 이 원리를 활용한 것이죠. Lighthouse의 TBT(Total Blocking Time)나 INP(Interaction to Next Paint) 같은 Core Web Vitals 지표에서 불필요한 리렌더 한 번이 수십 ms를 잡아먹는 경우를 생각하면, 이건 이론이 아니라 실전 성능 문제입니다.
또 하나, TypeScript와 함께 쓸 때도 이 이해가 중요합니다. useRef<HTMLInputElement>(null)의 반환 타입이 MutableRefObject인지 RefObject인지에 따라 .current의 readonly 여부가 갈리는데, 이건 TypeScript 타입 추론의 초기값 기반 추론 원리와 맞물려 있어요. 초기값으로 null을 넘기면서 제네릭에 null을 포함시키냐 아니냐에 따라 타입이 달라지니까요.
정리하며: '당연한 것'을 의심하는 습관
기획자가 이걸 의도한 건지 의심하듯, 우리가 매일 쓰는 Hook의 내부 동작도 한 번쯤 의심해볼 필요가 있습니다. useRef가 리렌더링을 안 하는 이유는 "원래 그런 거"가 아니라, Fiber 슬롯에 업데이트 큐를 설계하지 않은 의도적 결정 때문입니다. 호이스팅이나 타입 추론처럼, 당연하게 쓰는 문법들의 내부 동작을 파헤치면 결국 더 견고한 컴포넌트 설계로 돌아옵니다.
여기서 로딩 스켈레톤 넣으면 어떨까요… 가 아니라, 여기서 useRef 넣으면 리렌더 100번을 줄일 수 있을까요? 를 고민하는 게 진짜 프론트엔드 최적화의 시작점입니다.