Layout Thrashing이 INP를 망치는 방법 — Forced Synchronous Layout을 DevTools로 찾고 고치기
버튼을 눌렀는데 화면이 살짝 버벅이는 경험, 다들 한 번쯤 겪어봤을 겁니다. 네트워크 탭에는 아무 이상이 없고, 콘솔에 에러도 없는데 뭔가 어딘가에서 시간이 새고 있는 그 기분. 저도 처음엔 "렌더링이 좀 느린가보다" 하고 넘겼는데, 언젠가 리스트 아이템이 50개에서 500개로 늘어나던 날 이벤트 핸들러 하나가 Performance 패널에서 340ms로 찍혔습니다. 알고 보니 루프 안에 offsetWidth 하나가 레이아웃 계산을 500번씩 강제로 돌리고 있었던 거였죠.
이게 바로 강제 동기 레이아웃(Forced Synchronous Layout)과 레이아웃 스래싱(Layout Thrashing) — JavaScript가 브라우저의 레이아웃 흐름을 억지로 끊어내 Presentation Delay를 늘리는 패턴입니다. 2024년 3월 Google이 FID를 INP(Interaction to Next Paint)로 교체하면서 이 문제는 Core Web Vitals와 SEO에도 직결되는 지표가 됐습니다. Chrome DevTools로 어디서 시간이 새는지 정확히 잡아내고, 읽기/쓰기 순서 하나 바꿔서 수치로 확인 가능한 개선을 만들어내는 흐름을 코드와 함께 살펴봅니다.
핵심 개념
브라우저 렌더링 파이프라인, 먼저 그림을 그려봅시다
브라우저가 화면 하나를 그리기까지 밟는 단계는 이렇습니다.
JavaScript → Style Recalculation → Layout → Paint → Composite여기서 중요한 점은, 브라우저가 원래는 JavaScript 실행이 완전히 끝난 뒤 레이아웃 계산을 한 번에 처리한다는 것입니다. DOM을 열 번 바꿔도 레이아웃 비용은 딱 한 번. 꽤 영리한 최적화죠.
문제는 우리가 이 흐름을 의도치 않게 망가뜨릴 때 생깁니다.
강제 동기 레이아웃 — 브라우저를 억지로 멈추게 만드는 코드
DOM 스타일을 변경(write) 한 직후, 레이아웃 관련 속성을 읽으면(read) 브라우저는 곤란한 상황에 빠집니다. "아직 레이아웃 계산을 안 했는데, 지금 당장 offsetHeight를 달라고? 그럼 지금 바로 계산해야겠다." — 이렇게 예정에 없던 레이아웃 계산을 동기적으로 강제 실행하는 것이 Forced Synchronous Layout입니다.
여기서 "레이아웃을 무효화한다(invalidate)"는 개념을 짚고 넘어가면 좋습니다. DOM을 변경하면 브라우저는 "이전에 계산해 둔 레이아웃 결과가 더 이상 유효하지 않다"고 표시해 둡니다. 보통은 JavaScript가 끝난 뒤 한꺼번에 다시 계산하는데, 그 사이에 레이아웃 값을 읽으려 하면 최신 값을 내주기 위해 그 시점에서 강제로 계산을 완료해야 합니다.
// 이렇게 하면 안 됩니다
element.classList.add('expanded'); // write — 레이아웃 무효화
const height = element.offsetHeight; // read — 강제 동기 레이아웃 발생!레이아웃 계산을 강제로 트리거하는 속성들은 꽤 많습니다. 평소에 아무 생각 없이 쓰던 것들도 포함되어 있어서 처음엔 좀 당황스럽습니다.
| 카테고리 | 속성 / 메서드 |
|---|---|
| 크기/위치 | offsetWidth, offsetHeight, offsetTop, offsetLeft |
| 클라이언트 영역 | clientWidth, clientHeight, clientTop, clientLeft |
| 스크롤 | scrollWidth, scrollHeight, scrollTop, scrollLeft |
| 영역 정보 | getBoundingClientRect(), getClientRects() |
| 스크롤 제어 | scrollIntoView(), scrollIntoViewIfNeeded() |
| 계산된 스타일 | window.getComputedStyle() |
window.getComputedStyle()은 의외로 놓치기 쉽습니다. 계산된 스타일 값을 읽을 때 자연스럽게 쓰게 되는데, 이것도 레이아웃을 강제 트리거합니다.
Forced Synchronous Layout: JavaScript가 DOM을 수정한 직후 레이아웃 속성을 읽을 때, 브라우저가 아직 끝내지 못한 레이아웃 계산을 즉시 동기적으로 완료해야 하는 상황. 렌더링 파이프라인의 자연스러운 흐름을 깨뜨린다.
레이아웃 스래싱 — 루프 하나가 모든 걸 망칩니다
이 문제가 루프 안에서 반복되면 레이아웃 스래싱(Layout Thrashing) 이 됩니다. 한 프레임 안에서 레이아웃 계산이 수십~수백 번 일어나는 상황이죠.
// 이 코드는 elements.length 횟수만큼 레이아웃을 반복 계산합니다
for (const el of elements) {
const width = el.offsetWidth; // read — 강제 동기 레이아웃
el.style.width = (width + 10) + 'px'; // write — 레이아웃 무효화
}
// 다음 반복에서 또 read → 또 강제 계산 → 반복...솔직히 이 패턴은 눈에 잘 안 띕니다. 루프 안에서 DOM 크기를 읽고 바로 적용하는 게 얼마나 자연스럽게 느껴지는지 — 저도 실무에서 리뷰하기 전까지는 아무 생각 없이 짰던 기억이 납니다.
Presentation Delay와 INP — 숫자로 보는 영향
INP는 사용자 상호작용을 세 구간으로 나눕니다.
[Input Delay] → [Processing Time] → [Presentation Delay]Presentation Delay는 이벤트 핸들러 실행이 끝난 뒤부터 브라우저가 실제로 다음 프레임을 화면에 그리기까지의 시간입니다. 레이아웃 계산이 끝나야 Paint로 넘어갈 수 있기 때문에, 강제 동기 레이아웃과 레이아웃 스래싱은 이 구간을 직접적으로 늘립니다.
INP의 "좋음" 기준은 200ms인데, 강제 레이아웃 한 번이 30ms를 먹으면 그것만으로 전체 예산의 15%가 사라집니다. 2024년 3월 Google이 FID를 INP로 교체하면서 이 문제는 SEO와도 직결되는 지표가 됐습니다.
실전 적용
예시 1: DevTools로 강제 동기 레이아웃 찾기
문제가 있는지 눈으로 확인하는 것부터 시작하면 좋습니다. Chrome DevTools Performance 패널이 가장 직관적인 도구입니다.
프로파일 수집 순서:
- DevTools를 열고(
F12) Performance 탭을 선택합니다 Ctrl+E(또는 Record 버튼)로 녹화를 시작합니다- 느리다고 의심되는 상호작용을 재현합니다
- 녹화를 중지합니다
플레임 차트에서 강제 동기 레이아웃은 보라색 Layout 블록에 빨간 삼각형 경고로 나타납니다. 이 경고에 마우스를 올리면 정확히 어떤 JavaScript 라인이 레이아웃을 트리거했는지 볼 수 있습니다.
[JavaScript 실행 블록]
└─ [Forced Synchronous Layout ⚠️] ← 빨간 삼각형
└─ Layout (14.2ms)하단 Summary 탭에서 Rendering 시간이 유난히 높게 잡힌다면 레이아웃 스래싱을 의심해볼 수 있습니다.
Chrome 123 이상이라면 LoAF API로 코드에서 직접 감지할 수도 있습니다:
// LoAF API는 Chrome 123+에서만 지원됩니다 (2024년 초 출시)
if (
'PerformanceObserver' in window &&
PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
for (const script of entry.scripts) {
if (script.forcedStyleAndLayoutDuration > 0) {
console.warn(
'레이아웃 스래싱 감지:',
script.sourceURL,
script.forcedStyleAndLayoutDuration + 'ms'
);
}
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}forcedStyleAndLayoutDuration 속성이 특정 스크립트 실행 중 강제 레이아웃에 쓴 시간을 밀리초 단위로 알려줍니다. CI 파이프라인에 이 관찰자를 붙여두면 회귀 탐지에도 활용할 수 있습니다.
예시 2: 읽기/쓰기 배치 처리 — 가장 직접적인 수정 방법
문제를 찾았다면 수정은 생각보다 단순합니다. 읽기를 먼저 한꺼번에 끝내고, 그 다음에 쓰기를 한꺼번에 처리하면 됩니다.
// 문제가 되는 패턴: 루프마다 강제 레이아웃 발생
function resizeItems(elements) {
for (const el of elements) {
const width = el.offsetWidth; // read — 강제 레이아웃
el.style.width = (width * 1.1) + 'px'; // write — 무효화 반복
}
}
// 개선된 패턴: 읽기 → 쓰기 순서로 분리
function resizeItems(elements) {
// 1단계: 모든 읽기를 한 번에
const widths = elements.map(el => el.offsetWidth);
// 2단계: 모든 쓰기를 한 번에
elements.forEach((el, i) => {
el.style.width = (widths[i] * 1.1) + 'px';
});
}| 패턴 | 레이아웃 계산 횟수 | 비고 |
|---|---|---|
| 루프 내 읽기·쓰기 교차 | elements.length번 |
스래싱 발생 |
| 읽기 배치 → 쓰기 배치 | 1번 | 정상 흐름 |
이 중에서 실무에서 가장 많이 걸리는 함정은 루프인데, 아이템 수가 적을 때는 문제가 안 보이다가 데이터가 늘어나는 순간 갑자기 터집니다. "왜 갑자기 느려졌지?" 싶을 때 이 패턴을 먼저 의심해볼 만합니다.
예시 3: requestAnimationFrame으로 쓰기 타이밍 조정
읽기와 쓰기를 같은 함수 안에서 깔끔하게 분리하기 어려울 때도 있습니다. 이럴 때는 쓰기를 다음 프레임 시작 시점으로 미루는 방법을 사용할 수 있습니다.
const container = document.querySelector('.container');
const elements = document.querySelectorAll('.item');
let rafId = null;
let pendingWidth = null;
function onResize() {
// 현재 프레임에서 읽기
pendingWidth = container.offsetWidth;
// resize 이벤트는 연속으로 발생하므로, 이전 rAF가 있으면 취소합니다
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
rafId = null;
// 다음 프레임 시작 시점에 쓰기 — 읽기 직후 쓰기가 없으므로 강제 레이아웃 없음
elements.forEach(el => {
el.style.width = pendingWidth + 'px';
});
});
}
window.addEventListener('resize', onResize);cancelAnimationFrame 처리가 중요합니다. resize 이벤트는 연속으로 발생하기 때문에, 이전 프레임 요청을 취소하지 않으면 rAF가 여러 개 쌓여서 오히려 문제가 생길 수 있습니다.
예시 4: FastDOM으로 자동 배치 처리
읽기/쓰기 분리 패턴을 팀 전체에서 일관되게 유지하고 싶다면 FastDOM 라이브러리를 고려해볼 수 있습니다. requestAnimationFrame을 내부적으로 활용해 measure(읽기)를 먼저, mutate(쓰기)를 나중에 자동으로 배치합니다.
pnpm add fastdomimport fastdom from 'fastdom';
fastdom.measure(() => {
// 읽기 — FastDOM이 프레임 시작에 배치
const height = element.offsetHeight;
fastdom.mutate(() => {
// 쓰기 — measure 완료 후 배치 실행
element.style.minHeight = height + 'px';
});
});gzip 기준 약 750B로 아주 가볍습니다. Wilson Page의 벤치마크에서 DOM 집약적 작업을 최대 96% 개선했다는 측정 결과가 있는데, 조건에 따라 차이가 있을 수 있으므로 적용 전후를 직접 DevTools로 비교해보는 것을 권장합니다. 레거시 코드베이스에 점진적으로 도입하기에도 부담이 없습니다.
예시 5: ResizeObserver로 크기 감지 패턴 교체
resize 이벤트 핸들러 안에서 offsetWidth를 반복해서 읽는 패턴은 꽤 흔한데, ResizeObserver가 좋은 대안입니다. 브라우저가 레이아웃 계산을 마친 안전한 시점에 콜백을 호출해주기 때문에, 강제 동기 레이아웃 걱정 없이 크기 값을 사용할 수 있습니다.
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
// 이미 레이아웃 계산이 끝난 후 호출됨 — 강제 동기 레이아웃 없음
const { width, height } = entry.contentRect;
entry.target.style.setProperty('--item-width', width + 'px');
}
});
ro.observe(containerElement);마찬가지로, 요소의 가시성을 감지하기 위해 getBoundingClientRect()를 반복 호출하는 패턴은 IntersectionObserver로 대체할 수 있습니다.
예시 6: CSS Containment으로 영향 범위 줄이기
JavaScript 수정 없이도 CSS 한 줄로 레이아웃 재계산 범위를 줄일 수 있습니다. contain: content를 독립적인 컴포넌트에 적용하면 해당 영역 안에서 일어나는 변경이 전체 문서가 아닌 그 서브트리만 재계산하도록 격리됩니다.
/* 독립적인 카드 컴포넌트에 적용 */
.card {
contain: content; /* layout + style + paint를 이 요소 내부로 격리 */
}한 가지 알아두면 좋은 점은, contain: content는 layout + style + paint의 조합이지만 size containment는 포함하지 않습니다. 즉, 자식 요소의 크기 변화가 여전히 부모 컨테이너 크기에 영향을 줄 수 있습니다. 자식이 부모 크기에 아예 영향을 주지 않도록 하려면 contain: strict(= size + layout + style + paint)를 사용할 수 있는데, 이 경우 명시적인 크기를 함께 지정해야 합니다. 실제로 독립적인 컴포넌트에만 선별적으로 적용하는 것을 권장합니다.
또한 레이아웃이나 페인트를 건드리지 않는 transform과 opacity만으로 애니메이션을 구성하면 GPU 합성 레이어에서 처리되어 메인 스레드 부하 자체가 없습니다.
/* 피해야 할 패턴: width/height 트랜지션은 레이아웃을 계속 재계산합니다 */
.box { transition: width 0.3s, height 0.3s; }
/* 권장 패턴: transform은 합성 레이어에서 처리됩니다 */
.box { transition: transform 0.3s, opacity 0.3s; }장단점 분석
장점
| 항목 | 내용 |
|---|---|
| INP 직접 개선 | Presentation Delay 단축으로 Core Web Vitals 점수에 즉각 반영됨 |
| 코드 변경 최소화 | 읽기/쓰기 순서 재배치만으로 대부분 해결 가능 |
| 측정 가능성 | DevTools와 LoAF API로 수치 기반 검증이 가능함 |
| CSS 개선 무손실 | contain, transform 적용은 기능 변경 없이 성능만 개선 |
단점 및 주의사항
이 중에서 실무에서 가장 예상치 못하게 걸리는 함정은 will-change 남용입니다. "성능 개선에 도움이 된다"고 알려진 속성이라 무분별하게 붙이기 쉬운데, 오히려 메모리 소비를 늘리고 성능을 떨어뜨릴 수 있습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
will-change 남용 |
메모리 추가 소비, 오히려 성능 저하 가능 | 실제로 애니메이션되는 요소에만 제한적으로 적용 |
| FastDOM 의존성 추가 | 외부 라이브러리 도입 필요 | 크기가 작으므로 부담은 적음; 팀 합의 후 도입 |
| rAF 기반 지연 | 쓰기가 다음 프레임으로 밀려 일부 시각적 타이밍 이슈 가능 | 애니메이션 로직은 별도 검토 필요 |
| CSS Containment 격리 | 내부 자식이 외부 레이아웃에 영향을 줄 수 없게 됨 | 실제로 독립적인 컴포넌트에만 적용 |
실무에서 가장 흔한 실수
- 리스트 렌더링 루프 안에서 크기 읽기 — 아이템 수가 적을 땐 문제없다가 데이터가 늘어나면서 갑자기 느려지는 전형적인 패턴입니다. 초기에 잡지 않으면 코드베이스 전반에 퍼져서 나중에 한꺼번에 수정하기가 번거로워집니다.
- 이벤트 핸들러 안에서 스타일 변경 후 즉시
getBoundingClientRect()호출 — 애니메이션 시작점을 계산하려고 자연스럽게 쓰는데, 매번 강제 레이아웃이 발생합니다.ResizeObserver콜백 안에서 값을 미리 캐싱해두는 방식이 더 안전합니다. - React/Vue 렌더 사이클 외부에서 직접 DOM 조작 — 프레임워크의 배칭 최적화를 우회해버려서 강제 레이아웃이 발생합니다.
useLayoutEffect나nextTick같은 프레임워크 훅을 활용하는 것이 낫습니다.
마치며
읽기와 쓰기를 분리하는 것, 딱 이 원칙 하나가 레이아웃 스래싱을 막는 핵심입니다.
실제로 이 패턴을 수정하고 나면 DevTools Performance 패널에서 Rendering 시간이 눈에 띄게 줄어드는 것을 확인할 수 있습니다. 상황에 따라 다르지만, 루프 하나를 고쳤을 때 INP 수치가 200ms 아래로 내려오는 경우도 적지 않습니다. 현재 프로젝트에서 어디서 시간이 새고 있는지 확인해보고 싶다면 아래 순서로 시작해볼 수 있습니다.
- Chrome DevTools Performance 패널에서 주요 인터랙션을 녹화해봅니다. 플레임 차트에서 보라색 Layout 블록에 빨간 삼각형이 보이는지 확인해볼 수 있습니다. (
F12→ Performance 탭 →Ctrl+E녹화 시작) - LoAF 관찰자를 개발 환경에 추가해두면
forcedStyleAndLayoutDuration > 0인 스크립트를 콘솔에서 바로 볼 수 있습니다. 위에서 소개한 코드 스니펫에 브라우저 지원 체크가 포함되어 있어 그대로 붙여넣을 수 있습니다. - 문제가 발견된 루프나 이벤트 핸들러에서 읽기 배치 → 쓰기 배치 패턴으로 수정해봅니다. 변경 전후를 Performance 패널로 비교하면 Rendering 시간 변화를 수치로 확인할 수 있습니다.
버벅임을 수치로 잡아내고, 코드 몇 줄로 고치고, 그 차이를 DevTools로 직접 확인하는 흐름 — 한 번 해보면 다음 성능 이슈도 같은 방식으로 접근하고 싶어집니다.
참고 자료
- Avoid large, complex layouts and layout thrashing | web.dev
- How To Fix Forced Reflows And Layout Thrashing | DebugBear
- Performance features reference | Chrome DevTools
- Analyze runtime performance | Chrome DevTools
- Long Animation Frames API | Chrome for Developers
- Long animation frame timing | MDN Web Docs
- What forces layout/reflow | Paul Irish, GitHub Gist
- FastDOM: Eliminates layout thrashing by batching DOM read/write operations | GitHub
- Optimize Interaction to Next Paint | web.dev
- Layout Thrashing and Forced Reflows | webperf.tips