d8a80f6272
이름만 다른(표기 변형) [[위키링크]]를 대상 문서의 canonical 제목으로 치환해 끊겼던 1,200개 링크를 연결. 제목/파일명 정규화 일치만 적용하고 별칭 매칭은 과병합 위험으로 제외(애매성 가드). 원본은 _link_reconcile_backup/ 에 백업. 도구: Datacollect/scripts/link_reconcile_apply.mjs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.8 KiB
6.8 KiB
id, title, category, status, canonical_id, aliases, duplicate_of, source_trust_level, confidence_score, verification_status, tags, raw_sources, last_reinforced, github_commit, tech_stack
| id | title | category | status | canonical_id | aliases | duplicate_of | source_trust_level | confidence_score | verification_status | tags | raw_sources | last_reinforced | github_commit | tech_stack | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| wiki-2026-0508-layout-thrashing | Layout Thrashing | 10_Wiki/Topics | verified | self |
|
none | A | 0.9 | applied |
|
2026-05-10 | pending |
|
Layout Thrashing
매 한 줄
"매 layout thrashing = read → write → read → write 반복으로 브라우저가 매번 reflow를 강제로 동기 실행하는 안티 패턴". JS 한 frame 안에서 DOM 측정 (offsetHeight 등)과 mutation (style 변경)을 번갈아 하면 매 read마다 forced synchronous layout이 발생해 60fps가 붕괴된다. 해결은 read와 write를 batch + rAF로 분리.
매 핵심
매 왜 발생하나
- 브라우저는 효율을 위해 style/layout 계산을 async + batched로 처리.
- 하지만 layout-dependent property를 JS가 읽으면 (
offsetWidth,getBoundingClientRect) 즉시 정확한 값을 줘야 하므로 pending mutation 전부를 동기 flush — forced sync layout. - write → read를 반복하면 매 iteration마다 flush → O(n) reflow.
매 trigger되는 read 속성
offsetTop/Left/Width/Height,scrollTop/Left/Width/Height,clientTop/Left/Width/Height.getComputedStyle(),getBoundingClientRect().innerText(computed style 읽음).focus()(scroll 발생 가능).
매 trigger되는 write
- 어떤 layout 영향 style 변경:
el.style.width, class 변경, DOM insert/remove. setAttribute(size/position 영향).
매 해결 원칙
- Read 모두 먼저, 그다음 write 모두.
- rAF: 측정은 현재 frame, 변경은 다음 frame.
- FastDOM 같은 scheduler.
- CSS Containment (
contain: layout) — reflow 범위 격리. - Transform/opacity — composite-only, layout 안 발생.
매 응용
- List virtualization.
- Drag & drop.
- Sticky / parallax.
- Animation.
- Resize observer 응답.
💻 패턴
Anti-pattern — read/write 인터리브
// ❌ N번 forced sync layout
const items = document.querySelectorAll('.item');
items.forEach(el => {
const w = el.offsetWidth; // read (flush!)
el.style.width = (w * 1.1) + 'px'; // write (invalidate)
});
Fix — batch read 후 batch write
// ✅ 1번만 layout 계산
const items = document.querySelectorAll('.item');
const widths = [];
for (const el of items) widths.push(el.offsetWidth); // all reads
for (let i = 0; i < items.length; i++) {
items[i].style.width = (widths[i] * 1.1) + 'px'; // all writes
}
requestAnimationFrame + double-buffer
function resizeAll() {
const items = [...document.querySelectorAll('.item')];
// measure phase
const widths = items.map(el => el.offsetWidth);
// mutate phase — 다음 frame
requestAnimationFrame(() => {
items.forEach((el, i) => {
el.style.width = (widths[i] * 1.1) + 'px';
});
});
}
FastDOM 스타일 scheduler
const reads = [];
const writes = [];
let scheduled = false;
function schedule() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
while (reads.length) reads.shift()();
while (writes.length) writes.shift()();
scheduled = false;
});
}
export const fastdom = {
measure(fn) { reads.push(fn); schedule(); },
mutate(fn) { writes.push(fn); schedule(); },
};
// usage
fastdom.measure(() => {
const w = el.offsetWidth;
fastdom.mutate(() => { el.style.width = (w * 1.1) + 'px'; });
});
ResizeObserver — read/write 분리
const ro = new ResizeObserver(entries => {
// entries already has measurements — read 안 해도 됨
const updates = entries.map(e => ({
el: e.target,
height: e.contentRect.height,
}));
requestAnimationFrame(() => {
for (const u of updates) u.el.style.setProperty('--h', u.height + 'px');
});
});
ro.observe(document.querySelector('#panel'));
React — useLayoutEffect로 측정·변경 분리
import { useLayoutEffect, useRef, useState } from "react";
function AutoFit({ children }) {
const ref = useRef<HTMLDivElement>(null);
const [w, setW] = useState(0);
useLayoutEffect(() => {
// 측정만
const next = ref.current!.offsetWidth;
setW(next); // 변경은 React commit이 batch
}, []);
return <div ref={ref} style={{ minWidth: w }}>{children}</div>;
}
CSS Containment — reflow 범위 격리
.card {
contain: layout style paint; /* 내부 변화가 외부 reflow 안 일으킴 */
}
.list-item {
contain: layout;
content-visibility: auto; /* 보이지 않으면 layout 스킵 */
}
Performance 측정 — DevTools Performance API
performance.mark('measure-start');
const w = el.offsetWidth;
performance.mark('measure-end');
performance.measure('forced-layout', 'measure-start', 'measure-end');
// DevTools Performance tab에서 "Recalculate Style" / "Layout" 보라색 막대 확인
Transform 사용으로 layout 회피
// ❌ left/top — layout 발생
el.style.left = x + 'px'; el.style.top = y + 'px';
// ✅ transform — composite-only
el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
매 결정 기준
| 상황 | Approach |
|---|---|
| 단순 batch | read 먼저 → write 나중 (one pass) |
| 여러 모듈이 DOM 만짐 | FastDOM 또는 자체 scheduler |
| Animation | rAF + transform/opacity |
| 큰 리스트 | virtualization + content-visibility |
| 격리된 위젯 | contain: layout style |
기본값: 모든 hot path에서 read/write 분리 + rAF + transform 우선.
🔗 Graph
- 부모: Web-Performance
- 변형: Forced-Synchronous-Layout, Reflow, Repaint
- 응용: Drag-and-Drop
- Adjacent: requestAnimationFrame, ResizeObserver, Core Web Vitals Optimization (INP, LCP, CLS)
🤖 LLM 활용
언제: 코드에서 read/write interleave 탐지, FastDOM-style 리팩터 제안, Performance trace 해석. 언제 X: 실제 frame budget 측정 — 디바이스/페이지 의존, DevTools 직접.
❌ 안티패턴
- 루프 안에서 offsetX 호출: 가장 흔한 thrashing.
- jQuery
.css()연쇄: 내부에서 measure 트리거. - window.scroll listener에서 read+write: scroll 매 이벤트마다 layout.
- MutationObserver 콜백에서 sync read: 배치 의미 사라짐.
- Transform 가능한데 left/top 사용: 불필요한 layout.
🧪 검증 / 중복
- Verified (Chrome DevTools docs, web.dev "Avoid large, complex layouts" 2026, Wilson Page FastDOM).
- 신뢰도 A.
🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — FastDOM, useLayoutEffect, CSS containment 추가 |