ResizeObserver CSS 변수 직접 갱신 vs React state + rAF 선택 기준
레이아웃 측정값을 UI에 반영할 때 흔히 두 가지 선택지가 있다.
하나는 ResizeObserver(요소 크기 변화를 감지하는 브라우저 API)로 측정한 값을 requestAnimationFrame 안에서 React state로 업데이트하는 방식이고, 다른 하나는 ResizeObserver 콜백에서 DOM의 CSS 변수만 직접 갱신하는 방식이다.
둘 다 같은 문제를 풀 수 있지만 적합한 상황은 다르다. 짧게 말하면 측정값이 React 렌더링 로직에 필요하면 React state가 맞고, CSS 스타일 계산에만 필요하면 CSS 변수를 직접 갱신하는 쪽이 더 낫다.
React state + requestAnimationFrame
이 방식의 흐름은 대략 다음과 같다.
예를 들면 컨테이너 너비에 따라 렌더링할 컬럼 수나 아이템 개수가 달라지는 경우다.
const [columns, setColumns] = useState(3);
useResizeObserver(ref, width => {
requestAnimationFrame(() => {
setColumns(width > 900 ? 4 : width > 600 ? 3 : 2);
});
});
return items.slice(0, columns * 2).map(item => (
<Card key={item.id} item={item} />
));이 경우 columns는 단순한 스타일 값이 아니라 React 렌더 트리를 바꾸는 상태다. 어떤 자식을 렌더링할지, 어떤 props를 넘길지, 어떤 분기를 탈지에 영향을 준다. 이런 값은 React state로 두는 편이 자연스럽다.
장점은 명확하다.
- React의 데이터 흐름 안에서 값이 관리된다.
- JSX 분기, props 전달, children 렌더링에 바로 사용할 수 있다.
- React DevTools에서 상태를 추적하기 쉽다.
- 컴포넌트의 렌더 결과를 바꾸는 값이라면 모델이 단순하다.
하지만 비용도 있다.
- resize가 잦으면 React render가 자주 발생한다.
requestAnimationFrame으로 묶어도 결국setState이후 reconciliation과 commit 비용이 생긴다.- 단순 CSS 보정값을 위해 React 렌더링 전체를 다시 돌리는 것은 과할 수 있다.
ResizeObserver -> setState -> layout change -> ResizeObserver형태의 루프를 만들기 쉽다.- concurrent rendering 환경에서는 "측정 즉시 DOM 반영"이 필요한 값과 타이밍이 어긋날 수 있다.
따라서 React state + rAF는 측정값이 앱 상태이거나 렌더 구조를 바꿀 때 적합하다.
ResizeObserver에서 CSS 변수 직접 갱신
두 번째 방식은 측정값을 React state로 올리지 않고 DOM의 CSS 변수에 바로 반영한다.
예시는 다음과 같다.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width
el.style.setProperty('--container-w', `${width}px`)
el.style.setProperty('--item-scale', String(Math.min(1, width / 800)))
})
ro.observe(el)
return () => ro.disconnect()
}, [])CSS에서는 이렇게 사용할 수 있다.
.panel {
--container-w: 0px;
--item-scale: 1;
transform: scale(var(--item-scale));
}이 방식은 측정값이 React 렌더링에는 필요 없고, CSS 레이아웃이나 시각 보정에만 필요한 경우에 잘 맞는다. 예를 들어 container 기반 spacing, transform scale, sticky offset, mask size, gradient position, 가상화되지 않는 단순 visual adjustment 등이 그렇다.
장점은 다음과 같다.
- React render를 발생시키지 않는다.
- 측정값을 DOM/CSS에 즉시 반영할 수 있다.
- 부모/자식 리렌더 전파가 없다.
- 값이 "앱 상태"가 아니라 "스타일 환경값"일 때 모델이 깔끔하다.
- 고빈도 resize나 animation-adjacent 값에 상대적으로 부담이 적다.
단점도 있다.
- React DevTools에서 상태로 추적되지 않는다.
- JSX 렌더링 분기에 필요해지면 구조를 바꿔야 한다.
- imperative DOM mutation이므로 cleanup과 ref 안정성을 신경 써야 한다.
- 같은 CSS 변수를 React의
styleprop에서도 관리하면 충돌할 수 있다. - 콜백 안에서 layout-affecting write를 과하게 하면 ResizeObserver loop 경고를 만날 수 있다.
성능 관점
CSS 변수 직접 갱신 방식이 보통 더 가볍다.
React state 방식은 최소한 다음 단계를 거친다.
반면 CSS 변수 직접 갱신은 대체로 다음 정도다.
물론 CSS 변수가 width, height, grid-template-columns처럼 layout을 유발하는 속성에 쓰이면 layout 비용은 여전히 든다. CSS 변수 직접 갱신이 모든 렌더링 비용을 없애는 것은 아니다. 다만 React render 비용을 추가로 만들지 않는다는 점이 중요하다.
같은 layout 비용을 치르더라도 React reconciliation까지 끼워 넣을 필요가 없다면 CSS 변수 직접 갱신 쪽이 더 효율적이다.
정확성 관점
핵심 차이는 source of truth다.
React state를 쓰면 측정값이 앱 상태가 된다.
CSS 변수를 직접 갱신하면 측정값은 DOM 스타일 상태가 된다.
측정값이 앱의 의미 있는 상태라면 React state가 맞다. 반대로 렌더 트리에는 의미가 없고 시각적 보정을 위한 파생값이라면 CSS 변수로 두는 편이 낫다.
이 기준을 잘못 잡으면 둘 중 어느 쪽이든 불편해진다.
CSS 변수로 충분한 값을 React state로 올리면 불필요한 렌더링이 늘어난다. 반대로 React 렌더링 분기에 필요한 값을 DOM CSS 변수에만 숨겨두면 데이터 흐름이 끊기고 컴포넌트가 값을 알 수 없게 된다.
rAF는 항상 필요한가
ResizeObserver 콜백 안에서 바로 setProperty를 호출하는 것은 작은 규모에서는 보통 괜찮다.
const ro = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width
el.style.setProperty('--w', `${width}px`)
})하지만 갱신할 값이 많거나, CSS 변수가 layout-affecting 속성에 연결되어 있거나, resize burst를 한 프레임에 모으고 싶다면 rAF를 붙이는 편이 안전하다.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
let frame = 0
const ro = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
el.style.setProperty('--w', `${width}px`)
})
})
ro.observe(el)
return () => {
cancelAnimationFrame(frame)
ro.disconnect()
}
}, [])다만 rAF를 붙이면 반영은 한 프레임 늦어진다. "즉시 반영"이 중요하고 write가 단순하다면 바로 갱신해도 된다. 반대로 write가 많거나 루프 가능성이 있으면 rAF로 모으는 쪽이 낫다.
둘 다 필요한 경우
실무에서는 두 값을 분리하는 방식이 가장 깔끔할 때가 많다.
CSS에서만 필요한 값은 CSS 변수로 즉시 갱신하고, React 렌더링에 필요한 값은 최소한의 상태로만 관리한다.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width
// CSS visual adjustment
el.style.setProperty('--container-w', `${width}px`)
// React render state only changes at semantic boundaries
const nextMode = width > 768 ? 'desktop' : 'mobile'
setMode((prev) => (prev === nextMode ? prev : nextMode))
})
ro.observe(el)
return () => ro.disconnect()
}, [])이렇게 하면 CSS는 세밀한 변화에 빠르게 반응하고, React state는 breakpoint 변경처럼 의미 있는 순간에만 바뀐다.
추천 기준
측정값이 CSS에서만 쓰이면 다음이 기본값이다.
측정값이 React 렌더 조건에 쓰이면 다음이 맞다.
둘 다 필요하면 역할을 나눈다.
크기 기반 CSS 보정이 목적이라면 ResizeObserver에서 CSS 변수만 갱신하는 쪽이 더 좋은 기본값이었다. React state + rAF는 그 값이 컴포넌트 구조나 데이터 흐름을 실제로 바꿀 때만 쓰는 편이 비용과 복잡도 면에서 깔끔했다.