1920px 기준 설계서를 모든 해상도에서 안 깨지게 보여줘야 했다. 폭은 맞췄는데 메뉴 사이드바를 접고 펼 때마다 화면이 뚝뚝 끊겼고, 원인을 파고들수록 문제도 해법도 결국 CPU 메인 스레드 한 곳으로 모였다.
설계서가 좌표부터 폰트와 아이콘까지 전부 픽셀로 박혀 있어서, 반응형으로 다시 짜는 대신 좌표계째 축소하기로 했다. 배율은 가용 폭 / 1920으로 잡으면 되는데, 정작 관건은 무엇으로 축소하느냐였다. 이 선택이 곧 CPU를 얼마나 물고 늘어지느냐를 갈랐기 때문이다.
zoom CSS로 구현 시 CPU 과부하
.monitoring {
zoom: var(--fit-scale, 1);
}
zoom은 폰트도 아이콘도 패딩도 통째로 줄여줘서 처음엔 편했다. 문제는 값이 바뀔 때마다 브라우저가 서브트리 전체의 레이아웃을 다시 계산한다는 점이었다. 이 reflow는 전부 CPU 메인 스레드가 떠안는 일이라, 차트 네 개에 지도와 카드 슬라이더까지 붙은 화면에서 쌀 리가 없었다. 사이드바 애니메이션이 도는 내내 폭이 바뀌니 매 프레임 reflow가 돌았다.

프로파일로 확인. 사이드바를 몇 번 토글하며 녹화했다.
zoom값이 바뀔 때마다 reflow가 일어나다 보니 Frames 트랙에는 한 프레임 예산인 16.6ms를 넘긴 노란 long frame이 계속 반복됐다. 프레임이 제때 그려지지 못하는 과부하 상태다. 실제로 왼쪽 Insights에는 Forced reflow가 경고로 잡혔고 통과한 항목(Passed insights)은 14개에 그쳤다. Main 트랙(CPU)을 보면Recalculate Style과Layout이 구간을 꽉 채우고 있는데, 이 CPU 부하가 곧 화면이 뚝뚝 끊기던 정체였다.
transform으로 GPU에 작업 넘기기
transform: scale()은 사정이 다르다. 합성(compositing) 단계에서만 적용되는 데다, GPU가 이미 그려둔 레이어를 확대하거나 줄이기만 할 뿐 레이아웃 계산은 건드리지 않는다. 말하자면 CPU 메인 스레드가 하던 reflow를 GPU로 넘기는 셈이라, 값이 연달아 바뀌는 사이드바 토글에서 체감 차이가 분명했다.
CPU를 놓아주는 김에 두 가지를 더 정리했다.
- 배율을 React state에 담지 않았다.
setScale을 부를 때마다 컴포넌트 전체가 리렌더되는데 이것도 결국 CPU가 하는 일이다. 배율은 애초에 React가 알 필요 없는 값이라, ref로 잡은 DOM에 CSS 변수(--fit-scale)를style.setProperty로 바로 썼다. 렌더 사이클 바깥이라 리렌더가 0이 된다. - 폭은
ResizeObserver로 부모를 관측했다.window.resize로는 윈도우 크기는 그대로인 채 콘텐츠 폭만 바뀌는 사이드바 토글을 잡지 못하기 때문이다.
transform: scale은 zoom과 달리 레이아웃 박스를 바꾸지 않아서, 줄여놔도 엘리먼트는 여전히 1920px 자리를 차지한다. 그래서 래퍼를 하나 끼워 스케일 대상을 흐름에서 빼내고(position: absolute), 세로는 --fit-h(= stage높이 / scale)로 맞췄다. 여기에 useLayoutEffect로 첫 페인트 전에 적용해서 1920px 원본이 잠깐 노출되는 깜빡임까지 막았다.
.scaler {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.monitoring {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: var(--fit-h, 1028px);
transform: scale(var(--fit-scale, 1));
transform-origin: top left; // center로 두면 축소 결과가 가운데로 모여 좌상단이 어긋남
}

프로파일로 확인.
zoom을transform: scale로 바꾸고 같은 토글을 녹화했다. Insights에서 Forced reflow가 사라졌고 Passed insights도 14개에서 16개로 늘었다. reflow가 빠진 만큼 일이 CPU에서 GPU와 Composite 쪽으로 내려간 셈이다.다만 아직 완벽하진 않다. Frames에 노란 프레임이 조금 남아 있고 INP도 60ms까지 튄다.
ResizeObserver콜백이 불릴 때마다 곧바로 갱신하면서 생기는 잔여 비용이다.
requestAnimationFrame으로 프레임당 한 번만 갱신하기
transform으로 옮기긴 했지만 갱신 자체는 여전히 매 ResizeObserver 콜백마다 동기로 즉시 실행됐다. 사이드바 토글 중에는 폭이 매 프레임 바뀌는데, RO가 한 프레임에 여러 entry를 몰아서 던지거나 레이아웃 변화가 겹치면 그때마다 측정과 DOM 쓰기가 반복된다. 갱신 빈도가 주사율과 무관하게 튄다는 뜻이다.
그래서 RO 콜백에서는 측정과 쓰기를 직접 하지 않고 requestAnimationFrame 하나만 예약하도록 바꿨다. 실제 작업은 페인트 직전 rAF 안에서 프레임당 딱 한 번으로 수렴한다.
- 주사율 상한이 생긴다. 한 프레임에 RO가 몇 번 불리든 rAF는 다음 페인트 직전 한 번만 돌기 때문에, 갱신이 모니터 주사율을 넘을 수 없다.
- read와 write가 분리된다. RO 콜백 안에서 읽고 바로 쓰다 생기는 레이아웃 스래싱과
ResizeObserver loop경고를 피할 수 있다.
함정이 하나 있다. 첫 적용만큼은 반드시 동기로 유지해야 한다. 초기 측정까지 rAF로 미루면 rAF는 useLayoutEffect의 페인트가 끝난 뒤 에 실행되기 때문에, 첫 프레임에 1920px 원본이 노출됐다 줄어드는 깜빡임이 되살아난다. 그래서 초기 한 번은 동기로 처리하고 RO의 후속 갱신만 rAF를 거치도록 갈랐다.

프로파일로 확인. rAF를 도입하고 같은 토글을 녹화했다. Frames 트랙 첫 값이 16.6ms로, 60fps 한 프레임 예산에 정확히 붙었다. 갱신이 프레임당 한 번으로 수렴하니 Main(CPU) 버스트도 프레임 경계에 정렬되고 개수가 줄었다. INP도 60ms에서 40ms로 떨어졌다.
마지막까지 남은 reflow는 --fit-h였다
rAF로 갱신 횟수를 프레임당 한 번까지 줄였는데도 드랍이 남았다. 이번엔 횟수가 아니라 그 한 번의 무게가 문제였고, 범인은 --fit-h였다.
height: var(--fit-h, 1028px); // ← layout 속성! CPU reflow를 부른다
transform: scale(var(--fit-scale)); // 이건 GPU 합성
토글 중에 scale이 바뀌면 designHeight = height / scale가 따라 바뀌면서 --fit-h도 매 프레임 갱신된다. transform은 GPU로 넘겼지만 height는 layout 속성이라, 값을 쓰는 순간 .monitoring 안 flex 트리가 리플로우되고 그 여파로 지도 위젯 자신의 ResizeObserver까지 매 프레임 깨어나 getBoundingClientRect(), 즉 강제 레이아웃을 반복했다. CPU에서 몰아냈다고 믿은 reflow가 --fit-h를 타고 되돌아온 것이다.
그래서 두 변수를 성질대로 갈랐다.
- 싼 쪽인
--fit-scale(GPU 합성)은 지금처럼 매 프레임 rAF로 갱신해서 사이드바를 따라 부드럽게 스케일되게 뒀다. - 비싼 쪽인
--fit-h(CPU 레이아웃)는 리사이즈가 멎은 뒤 디바운스로 딱 한 번만 쓴다. 애니메이션 중에는 손대지 않으니 트리 리플로우도 지도 옵저버 재실행도 그동안 0이 된다.
애니메이션이 도는 동안 생기는 세로 오차는 .scaler의 overflow: hidden이 하단 몇 px 정도를 잠깐 먹어주고, 리사이즈가 멎으면 정확히 스냅된다. 배경색도 같아서 눈에는 거의 안 띈다.
const DESIGN_WIDTH = 1920;
function useFitScale() {
const stageRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const stage = stageRef.current;
const content = contentRef.current;
if (!stage || !content) return;
let lastScale = -1;
let lastH = -1;
let rafId = 0; // 예약된 rAF (0 = 없음)
let settleId = 0; // --fit-h 디바운스 타이머 (0 = 없음)
// 폭 배율(--fit-scale)만 쓴다. transform 값이라 GPU 합성만 → 매 프레임 갱신해도
// 싸다. rAF로 프레임당 1회로 수렴 (주사율 상한).
const applyScale = () => {
rafId = 0;
const width = stage.clientWidth;
if (!width) return;
const scale = width / DESIGN_WIDTH;
if (scale === lastScale) return;
lastScale = scale;
content.style.setProperty("--fit-scale", String(scale));
};
// 세로 역산 높이(--fit-h). layout 속성이라 쓰는 순간 flex 트리 전체가 리플로우되고
// 지도/차트 위젯의 ResizeObserver까지 깨운다(= CPU 부하). 그래서 매 프레임이 아니라
// '리사이즈가 멎은 뒤 한 번'만 쓴다(애니메이션 동안엔 계속 미뤄짐).
const applyHeight = () => {
settleId = 0;
const width = stage.clientWidth;
const height = stage.clientHeight;
if (!width || !height) return;
const scale = width / DESIGN_WIDTH;
const designHeight = height / scale;
if (designHeight === lastH) return;
lastH = designHeight;
content.style.setProperty("--fit-h", `${designHeight}px`);
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(applyScale);
clearTimeout(settleId);
settleId = window.setTimeout(applyHeight, 120);
};
// 첫 적용만은 동기(페인트 전)로 배율과 높이 모두. rAF/타이머로 미루면 첫 프레임에
// 1920px 원본이 노출됐다 줄어드는 깜빡임이 생긴다.
applyScale();
applyHeight();
const ro = new ResizeObserver(schedule);
ro.observe(stage);
return () => {
ro.disconnect();
if (rafId) cancelAnimationFrame(rafId);
if (settleId) clearTimeout(settleId);
};
}, []);
return { stageRef, contentRef };
}

프로파일로는 더 좋아 보였다. 토글 중 매 프레임 일어나던
height리플로우가 사라지면서 Frames가 한층 고르게 유지되고, Main(CPU) 부하도 rAF 단계보다 안정됐다.
그런데 실제로 토글해보니 오히려 더 끊겨 보였다. 애니메이션 중에는 가로(--fit-scale)만 따라가고 세로(--fit-h)는 멈춰 있다가, 토글이 끝나는 순간 한 번에 튀기 때문이다. .scaler의 overflow: hidden이 그동안의 세로 오차를 잠깐 먹고 있다가 리사이즈가 멎을 때 정확한 높이로 스냅되는데, 그 스냅이 눈에는 "뚝" 하고 걸리는 것처럼 보였다. 프레임 그래프는 고른데 체감은 나빠진, 지표와 감각이 어긋나는 경우로 사용자 체감을 중점으로 디바운스를 걷어내고, 두 변수를 매 프레임 함께 쓰는 rAF 버전으로 되돌렸다.
const DESIGN_WIDTH = 1920;
function useFitScale() {
const stageRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const stage = stageRef.current;
const content = contentRef.current;
if (!stage || !content) return;
let lastScale = -1;
let lastH = -1;
let rafId = 0; // 예약된 rAF (0 = 없음)
// 배율(--fit-scale, 합성)과 역산 높이(--fit-h, layout)를 함께 갱신한다.
// --fit-h를 애니메이션 밖으로 디바운스하면 리플로우는 줄지만, 세로가 토글이
// 끝난 뒤에야 한 번에 튀어(스냅) 오히려 끊겨 보인다. 프로파일상 매 프레임
// 같이 갱신해도 프레임 예산(16.6ms) 안이라, 그냥 매 프레임 함께 쓴다.
const update = () => {
rafId = 0;
const width = stage.clientWidth;
const height = stage.clientHeight;
if (!width || !height) return;
const scale = width / DESIGN_WIDTH;
// scale 후 높이가 stage 높이와 정확히 같아지도록 디자인 높이를 역산.
const designHeight = height / scale;
if (scale === lastScale && designHeight === lastH) return;
lastScale = scale;
lastH = designHeight;
content.style.setProperty("--fit-scale", String(scale));
content.style.setProperty("--fit-h", `${designHeight}px`);
};
// RO가 한 프레임에 여러 번 불려도 rAF로 묶어 다음 페인트 직전 1회만 측정·적용
// → 갱신이 주사율을 넘지 않는다. (측정을 rAF 안에 넣어 RO 콜백에서 읽고 바로
// 쓰며 생기는 레이아웃 스래싱과 "ResizeObserver loop" 경고도 피한다.)
const schedule = () => {
if (rafId) return;
rafId = requestAnimationFrame(update);
};
// 첫 적용만은 동기(useLayoutEffect → 페인트 전)로 둔다. rAF로 미루면 첫 프레임에
// 1920px 원본이 노출됐다 줄어드는 깜빡임이 생긴다.
update();
const ro = new ResizeObserver(schedule);
ro.observe(stage);
return () => {
ro.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return { stageRef, contentRef };
}
트레이드오프를 잘못 고른 단계였다. "layout이라 비싸다"는 분석 자체는 맞았지만, 그 비용이 프레임 예산 안이라면 빼는 것 자체가 손해였다. 최적화는 지표만 확인하는게 아니라 눈으로도 확인해야 한다는 걸 여기서 다시 배웠다.
정리
| 관점 | zoom | transform: scale |
|---|---|---|
| 렌더 파이프라인 | Layout → Paint → 합성 | 합성만 |
| 리사이즈 애니메이션 | 매 프레임 CPU reflow | GPU 합성 |
| 레이아웃 박스 | 스케일됨 | 원본 유지 (래퍼로 클리핑) |
- 축소 방식이 곧 부하를 결정한다.
zoom은 매 프레임 CPU에서 reflow를 부르는 반면transform: scale은 GPU 합성으로 끝나니, 일을 CPU에서 GPU로 옮기는 게 첫 번째 레버였다. - React가 알 필요 없는 값은 state에 넣지 않는다. 리렌더도 결국 CPU 일이라, ref와 CSS 변수로 렌더 사이클 밖에서 쓰는 편이 낫다.
- 갱신은
requestAnimationFrame으로 프레임당 한 번으로 묶어 주사율을 상한선으로 둔다. 대신 첫 적용만은 동기로 처리한다. - CSS 변수라고 다 싼 건 아니다.
transform에 물리면 GPU 합성이지만height에 물리면 CPU layout이다. 다만 그 리플로우가 프레임 예산 안이라면 굳이 빼지 마라 —--fit-h를 디바운스로 빼봤다가 세로가 튀어(스냅) 더 끊겨 보였고, 결국 되돌렸다. 지표가 좋아져도 눈이 나빠지면 최적화가 아니다.
마치며
결국 zoom에서 transform으로 옮겨 일을 GPU에 넘기고, rAF로 남은 CPU 작업을 프레임당 한 번으로 묶은 과정이었다. --fit-h까지 빼내려던 건 한 걸음 과했고 프레임 예산 안이라 되돌렸다. 처음부터 끝까지 CPU 메인 스레드를 어떻게 비우느냐의 문제였던 셈이다.