📑 목차
웹 페이지의 부드러움과 반응성은 사용자 경험에 결정적인 영향을 미칩니다. 하지만 때로는 웹 페이지가 버벅거리거나 애니메이션이 끊기는 현상을 마주하게 됩니다. 이러한 성능 저하의 주범 중 하나가 바로 'Layout Thrashing'입니다. 이 글에서는 Layout Thrashing이 무엇인지, 어떤 CSS 패턴이 이를 유발하는지, 그리고 어떻게 하면 이를 효과적으로 피할 수 있는지에 대한 유익하고 실용적인 정보를 제공합니다.
Layout Thrashing의 기본 개념 이해
Layout Thrashing을 이해하기 위해서는 먼저 브라우저가 웹 페이지를 화면에 그리는 과정을 간략하게 알아볼 필요가 있습니다.
- 스타일 계산 (Recalculate Style): 브라우저가 HTML 요소에 어떤 CSS 스타일이 적용되어야 하는지 계산합니다.
- 레이아웃 (Layout 또는 Reflow): 각 요소의 크기와 위치를 계산하여 페이지의 전체적인 구조를 만듭니다. 이 과정은 다른 요소의 위치에 영향을 줄 수 있는 속성(예: width, height, margin, padding, top, left, display 등)이 변경될 때 발생합니다.
- 페인트 (Paint 또는 Repaint): 레이아웃이 결정된 요소들을 화면의 픽셀로 채웁니다. 요소의 색상, 배경, 그림자 등 시각적 속성이 변경될 때 발생합니다. 레이아웃이 변경되지 않더라도 페인트는 발생할 수 있습니다 (예: color, background-color 변경).
- 합성 (Composite): 페인트 된 레이어들을 모아 최종적으로 화면에 표시합니다. transform, opacity 등 레이아웃이나 페인트에 영향을 주지 않고 GPU 가속을 사용할 수 있는 속성 변경 시 주로 발생합니다.
이러한 과정은 일반적으로 순차적으로 진행됩니다. 문제는 JavaScript가 DOM(문서 객체 모델)이나 CSSOM(CSS 객체 모델)을 변경한 후, 변경된 요소의 레이아웃 정보를 즉시 요청할 때 발생합니다. 브라우저는 레이아웃 정보를 정확히 제공하기 위해 아직 처리되지 않은 스타일 변경 사항을 강제로 즉시 적용하고, 그에 따라 레이아웃을 다시 계산해야 합니다. 이처럼 "스타일을 변경하는 쓰기 작업"과 "레이아웃 정보를 읽는 읽기 작업"이 교차하여 반복적으로 발생할 때, 브라우저는 불필요하게 레이아웃을 여러 번 계산하게 되는데, 이를 Layout Thrashing이라고 부릅니다.
Layout Thrashing은 브라우저의 렌더링 파이프라인을 비효율적으로 만듭니다. 매번 강제로 레이아웃을 재계산하는 것은 많은 CPU 자원을 소모하며, 이는 웹 페이지의 성능 저하, 애니메이션 끊김, 사용자 인터페이스의 버벅거림으로 이어져 사용자 경험을 크게 해치게 됩니다.
Layout Thrashing을 유발하는 흔한 CSS 패턴
Layout Thrashing은 주로 JavaScript를 통해 DOM/CSSOM을 조작할 때 발생하지만, 특정 CSS 속성들을 JavaScript와 함께 사용할 때 그 위험이 더욱 커집니다. 다음은 Layout Thrashing을 유발하기 쉬운 대표적인 패턴들입니다.
스타일 변경 후 레이아웃 정보 즉시 읽기
가장 기본적인 Layout Thrashing 패턴입니다. JavaScript로 요소의 스타일을 변경(쓰기)한 직후, 해당 요소 또는 관련 요소의 레이아웃 정보를 요청(읽기)할 때 발생합니다. 브라우저는 정확한 레이아웃 정보를 제공하기 위해, 아직 적용되지 않은 스타일 변경 사항을 강제로 적용하고 레이아웃을 재계산하게 됩니다.
<script>
const element = document.getElementById('myElement');
// 쓰기 작업: 요소의 너비를 변경
element.style.width = '200px';
// 읽기 작업: 변경된 요소의 offsetWidth를 즉시 요청
// 브라우저는 정확한 값을 반환하기 위해 여기서 레이아웃을 강제로 재계산합니다.
const currentWidth = element.offsetWidth;
console.log(currentWidth);
// 또 다른 쓰기 작업
element.style.height = '100px';
// 또 다른 읽기 작업
const currentHeight = element.offsetHeight;
console.log(currentHeight);
</script>
위 코드에서 element.style.width 변경 후 element.offsetWidth를 읽는 순간 한 번, element.style.height 변경 후 element.offsetHeight를 읽는 순간 또 한 번, 총 두 번의 강제 레이아웃 재계산이 발생합니다.
반복문 내에서 스타일 변경 및 레이아웃 정보 읽기
이 패턴은 앞서 설명한 기본 패턴이 반복문 안에서 발생하는 경우로, 가장 치명적인 Layout Thrashing의 원인이 됩니다. 반복문의 매 이터레이션마다 쓰기 작업과 읽기 작업이 교차하면, 브라우저는 N번의 강제 레이아웃 재계산을 수행하게 되어 성능이 극도로 저하됩니다.
<script>
const items = document.querySelectorAll('.item');
items.forEach(item => {
// 쓰기 작업: 각 아이템의 너비를 10px씩 증가
item.style.width = (item.offsetWidth + 10) + 'px'; // 여기서 item.offsetWidth 읽기
// 읽기 작업: 변경된 너비를 다시 읽어서 로깅
console.log(item.offsetWidth); // 여기서 또 다시 item.offsetWidth 읽기
});
</script>
이 예시에서는 각 .item 요소마다 item.offsetWidth를 읽는 작업이 두 번 발생하며, 이로 인해 각 아이템마다 최소 두 번의 강제 레이아웃 재계산이 발생할 수 있습니다. 아이템의 개수가 많아질수록 성능 저하는 비례해서 심화됩니다.
애니메이션 및 전환 효과
CSS 애니메이션이나 JavaScript를 이용한 애니메이션에서 Layout Thrashing이 발생하기 쉽습니다. 특히 width, height, margin, padding, top, left, right, bottom 등 요소의 기하학적 속성을 직접 애니메이션 할 때 그렇습니다. 이러한 속성들은 레이아웃에 직접적인 영향을 미치기 때문에, 매 프레임마다 레이아웃이 재계산될 수 있습니다.
/ Layout Thrashing을 유발할 수 있는 CSS 애니메이션 예시 /
.bad-animation {
animation: move-and-resize 1s infinite alternate;
}
@keyframes move-and-resize {
from {
width: 100px;
left: 0;
}
to {
width: 200px;
left: 100px;
}
}
이 애니메이션은 width와 left 속성을 변경하므로, 매 프레임마다 레이아웃 재계산을 유발하여 부드럽지 못한 애니메이션이 될 가능성이 큽니다.
CSS 속성 강제 재계산
일부 CSS 속성 변경은 다른 속성보다 더 넓은 범위의 레이아웃 재계산을 유발합니다. 예를 들어, display: none에서 display: block으로 변경하는 것은 해당 요소뿐만 아니라 주변 요소들의 레이아웃에도 영향을 미칠 수 있습니다. 또한, 테이블 레이아웃과 관련된 속성(예: table-layout)이나 플렉스박스, 그리드 레이아웃의 복잡한 속성 변경도 넓은 범위의 레이아웃 재계산을 유발할 수 있습니다.
<script>
const container = document.getElementById('myContainer');
const items = container.children;
// 컨테이너의 display 속성을 변경하여 전체 레이아웃에 영향을 줍니다.
container.style.display = 'none';
// 이 시점에 container는 화면에서 사라졌지만, 브라우저는 아직 이것을 처리하지 않았을 수 있습니다.
// 하지만 아래에서 item의 offsetWidth를 읽으려고 하면,
// 브라우저는 container의 display 변경을 강제로 적용하고 레이아웃을 재계산합니다.
const firstItemWidth = items[0].offsetWidth;
console.log(firstItemWidth);
container.style.display = 'block';
const secondItemWidth = items[1].offsetWidth;
console.log(secondItemWidth);
</script>
위 예시에서 container.style.display = 'none' 변경 후 items[0].offsetWidth를 읽을 때, 그리고 container.style.display = 'block' 변경 후 items[1].offsetWidth를 읽을 때 강제 레이아웃 재계산이 발생합니다.
Layout Thrashing을 피하는 실용적인 방법
Layout Thrashing을 피하기 위한 핵심 원칙은 "읽기" 작업과 "쓰기" 작업을 분리하여 브라우저가 레이아웃 재계산을 최적화할 수 있도록 하는 것입니다. 다음은 Layout Thrashing을 피하는 데 도움이 되는 실용적인 방법들입니다.
DOM 변경과 레이아웃 읽기 분리
가장 중요한 원칙입니다. 모든 "읽기" 작업(예: offsetWidth, offsetHeight, getBoundingClientRect(), getComputedStyle() 등)을 한 번에 모아서 처리하고, 모든 "쓰기" 작업(예: element.style.width = '...', element.classList.add('...') 등)을 한 번에 모아서 처리합니다. 이렇게 하면 브라우저는 모든 쓰기 작업을 한 번에 적용한 후, 필요한 경우에만 한 번의 레이아웃 재계산을 수행하여 모든 읽기 요청에 응답할 수 있습니다.
<script>
const element = document.getElementById('myElement');
// 모든 읽기 작업을 먼저 수행
const initialWidth = element.offsetWidth;
const initialHeight = element.offsetHeight;
console.log('초기 너비:', initialWidth, '초기 높이:', initialHeight);
// 모든 쓰기 작업을 나중에 수행
element.style.width = (initialWidth + 50) + 'px';
element.style.height = (initialHeight + 20) + 'px';
</script>
이 방식은 단일 요소에 대한 간단한 조작뿐만 아니라, 여러 요소에 대한 반복적인 조작에서도 적용되어야 합니다. FastDOM과 같은 라이브러리는 이러한 읽기/쓰기 작업을 자동으로 스케줄링하여 Layout Thrashing을 방지하는 데 도움을 줄 수 있습니다. 이는 특히 복잡한 애니메이션이나 대규모 DOM 조작이 필요한 경우에 전문가들이 추천하는 방법입니다.
CSS transform 및 opacity 활용
애니메이션이나 시각적 변화를 줄 때는 width, height, top, left와 같이 레이아웃에 영향을 미치는 속성 대신 transform (translate, scale, rotate 등)과 opacity 속성을 사용하는 것이 좋습니다. 이 속성들은 레이아웃 재계산을 유발하지 않고, GPU 가속을 활용하여 훨씬 부드럽고 성능 효율적인 애니메이션을 구현할 수 있습니다. 이는 레이아웃과 페인트 과정을 건너뛰고 합성(Composite) 단계에서만 처리되기 때문입니다.
/ Layout Thrashing을 피하는 CSS 애니메이션 예시 /
.good-animation {
animation: move-and-fade 1s infinite alternate;
}
@keyframes move-and-fade {
from {
transform: translateX(0) scale(1);
opacity: 1;
}
to {
transform: translateX(100px) scale(1.2);
opacity: 0.5;
}
}
이 애니메이션은 transform과 opacity만 사용하므로 레이아웃 재계산을 유발하지 않아 훨씬 부드럽게 실행됩니다.
CSS 클래스 활용
JavaScript로 개별 CSS 속성을 직접 변경하는 대신, 미리 정의된 CSS 클래스를 토글하는 방식으로 스타일을 변경하는 것이 좋습니다. 이렇게 하면 브라우저가 스타일 변경을 최적화하여 처리할 수 있는 기회가 늘어납니다.
/ CSS /
.my-element {
width: 100px;
height: 100px;
background-color: blue;
transition: all 0.3s ease-in-out;
}
.my-element.active {
width: 150px;
height: 150px;
background-color: red;
transform: translateX(50px);
}
<!-- JavaScript -->
<script>
const element = document.getElementById('myElement');
// 개별 스타일 변경 (Layout Thrashing 유발 가능성 있음)
// element.style.width = '150px';
// element.style.height = '150px';
// CSS 클래스 토글 (브라우저가 최적화하여 처리)
element.classList.toggle('active');
</script>
클래스를 사용하면 여러 속성을 한 번에 변경할 수 있으며, 브라우저는 이러한 변경 사항을 효율적으로 배치하여 Layout Thrashing을 줄일 수 있습니다.
requestAnimationFrame 사용
애니메이션이나 시각적 업데이트와 관련된 JavaScript 코드는 requestAnimationFrame()을 사용하여 스케줄링하는 것이 좋습니다. requestAnimationFrame()은 브라우저가 다음 리페인트(repaint)를 수행하기 직전에 콜백 함수를 실행하도록 보장합니다. 이렇게 하면 브라우저가 렌더링 주기를 효율적으로 관리하고, 불필요한 레이아웃 재계산을 피할 수 있습니다.
<script>
const element = document.getElementById('myElement');
let position = 0;
function animate() {
// 모든 읽기 작업을 먼저 수행 (여기서는 position 변수를 사용)
// 모든 쓰기 작업을 나중에 수행
element.style.transform = `translateX(${position}px)`;
position += 2;
if (position < 200) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
</script>
이 예시에서는 transform 속성을 사용하여 Layout Thrashing을 피하고, requestAnimationFrame으로 애니메이션을 부드럽게 만듭니다.
가상 DOM 또는 오프스크린 작업
복잡한 DOM 조작이 필요한 경우, 직접 화면에 보이는 DOM을 여러 번 조작하기보다는 가상 DOM(Virtual DOM)을 사용하거나, 오프스크린(off-screen)에서 DOM을 조작한 후 최종 결과만 화면에 한 번에 반영하는 방법을 고려할 수 있습니다. 예를 들어, 요소를 document.createDocumentFragment()에 추가하여 조작한 후, 마지막에 fragment를 실제 DOM에 한 번만 추가하는 것입니다. 이는 여러 번의 DOM 조작으로 인한 반복적인 레이아웃 재계산을 피하는 데 효과적입니다.
<script>
const container = document.getElementById('myContainer');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
div.style.height = '20px'; // 쓰기 작업
fragment.appendChild(div);
}
// 모든 요소가 fragment에 추가된 후, 실제 DOM에 한 번만 추가 (한 번의 레이아웃 재계산)
container.appendChild(fragment);
</script>
이 방법은 100개의 div를 개별적으로 추가하여 100번의 잠재적 레이아웃 재계산을 유발하는 대신, 한 번의 레이아웃 재계산만으로 처리합니다.
CSS Containment 속성
CSS contain 속성은 브라우저에게 특정 요소의 스타일, 레이아웃, 페인트가 다른 요소에 영향을 미치지 않음을 알려주는 강력한 도구입니다. 이는 브라우저가 렌더링 범위를 제한하고 불필요한 재계산을 피하는 데 도움을 줍니다.
- contain: layout;: 요소 내부의 레이아웃 변경이 외부 요소에 영향을 미치지 않음을 선언합니다.
- contain: size;: 요소의 크기가 외부 요소에 영향을 미치지 않음을 선언합니다.
- contain: style;: 요소 내부의 스타일 변경이 외부 요소에 영향을 미치지 않음을 선언합니다.
- contain: paint;: 요소의 콘텐츠가 요소의 경계 밖으로 페인트 되지 않음을 선언합니다.
- contain: strict;: layout, size, style, paint를 모두 포함합니다.
- contain: content;: layout, paint를 포함합니다.
/ CSS /
.isolated-container {
contain: layout size paint; / 이 컨테이너 내부의 변화가 외부 레이아웃에 영향을 주지 않음 /
width: 300px;
height: 200px;
overflow: auto;
}
이 속성은 특히 복잡한 위젯이나 독립적인 컴포넌트에서 유용하게 사용될 수 있습니다. 브라우저는 contain 속성을 통해 해당 요소의 변경 사항이 전역 레이아웃에 영향을 주지 않으므로, 더 효율적으로 렌더링을 최적화할 수 있습니다.
흔한 오해와 사실 관계
Layout Thrashing에 대한 몇 가지 흔한 오해들을 바로잡아 봅시다.
오해 모든 CSS 변경은 Layout Thrashing을 유발한다
사실: 그렇지 않습니다. Layout Thrashing은 "쓰기(스타일 변경)"와 "읽기(레이아웃 정보 요청)" 작업이 번갈아 발생할 때 주로 발생합니다. transform, opacity 등과 같이 레이아웃에 영향을 주지 않는 속성들의 변경은 Layout Thrashing을 유발하지 않으며, 심지어 레이아웃 재계산 없이 페인트 과정만 다시 수행하거나(Repaint), 합성(Composite) 단계에서만 처리되어 매우 효율적입니다. 중요한 것은 어떤 속성을 변경하느냐와 그 변경 후 레이아웃 정보를 즉시 읽느냐입니다.
오해 JavaScript는 항상 느리다
사실: JavaScript 자체의 실행 속도보다는 JavaScript가 DOM을 조작하는 방식이 성능에 더 큰 영향을 미칩니다. 현대 JavaScript 엔진은 매우 빠르지만, DOM API는 브라우저의 렌더링 파이프라인과 밀접하게 연결되어 있기 때문에 비효율적인 DOM 조작은 쉽게 성능 병목 현상을 일으킬 수 있습니다. 특히 Layout Thrashing은 JavaScript가 DOM을 비효율적으로 조작할 때 발생하는 대표적인 성능 문제입니다. 따라서 JavaScript를 사용하더라도 효율적인 DOM 조작 패턴을 따른다면 충분히 빠르고 부드러운 사용자 경험을 제공할 수 있습니다.
비용 효율적인 활용 방법
Layout Thrashing을 피하는 것은 단순히 성능을 높이는 것뿐만 아니라, 개발 시간을 절약하고 유지보수 비용을 줄이는 데도 기여합니다.
- 성능 모니터링 도구 활용: 브라우저의 개발자 도구(Chrome DevTools의 Performance 탭)를 적극적으로 활용하세요. 여기서 Layout Thrashing이 발생하는지, 어떤 스크립트가 레이아웃 재계산을 유발하는지 시각적으로 확인할 수 있습니다. 불필요한 레이아웃 재계산이 반복되는 구간(특히 "Layout" 이벤트가 여러 번 연속으로 나타나는 부분)을 찾아 집중적으로 개선할 수 있습니다. 이는 문제의 원인을 정확히 파악하여 불필요한 작업에 시간을 낭비하지 않게 합니다.
- 작은 변화부터 시작: 모든 코드를 한 번에 최적화하려 하지 마세요. 가장 눈에 띄는 성능 병목 현상부터 해결하고, 점진적으로 개선해 나가는 것이 비용 효율적입니다. 특히 사용자가 자주 상호작용하는 부분(예: 내비게이션, 검색 결과 필터링, 이미지 슬라이더)부터 최적화하면 사용자 경험 개선 효과가 큽니다.
- 핵심 사용자 경험에 집중: 모든 애니메이션이나 DOM 조작에 완벽한 최적화를 적용하는 것은 비현실적일 수 있습니다. 사용자에게 가장 중요한 인터랙션과 애니메이션이 부드럽게 작동하도록 우선순위를 정하고, 여기에 최적화 노력을 집중하세요. 예를 들어, 페이지 로딩 시의 초기 애니메이션이나 스크롤 시 발생하는 복잡한 효과는 특히 주의해야 합니다.
이러한 접근 방식은 개발 자원을 효율적으로 사용하고, 가장 큰 영향력을 발휘할 수 있는 부분에 집중하여 전반적인 프로젝트의 비용 효율성을 높이는 데 도움을 줍니다.
자주 묻는 질문
Q1 Layout Thrashing을 어떻게 감지할 수 있나요
가장 효과적인 방법은 크롬 개발자 도구의 'Performance' 탭을 사용하는 것입니다. 웹 페이지의 동작을 기록한 후, 타임라인에서 'Layout' 또는 'Recalculate Style' 이벤트가 반복적으로, 특히 짧은 시간 안에 여러 번 발생하는지 확인하세요. 이러한 이벤트가 JavaScript 실행 블록과 교차하여 나타난다면, Layout Thrashing이 발생하고 있을 가능성이 매우 높습니다. 각 이벤트의 세부 정보를 클릭하면 어떤 스크립트가 해당 레이아웃 재계산을 유발했는지 확인할 수 있습니다.
Q2 모든 애니메이션을 transform으로 바꿔야 하나요
가능하다면 transform과 opacity 속성을 사용하는 것이 성능상 가장 좋습니다. 이들은 레이아웃이나 페인트를 유발하지 않고 GPU 가속을 활용하여 합성(Composite) 단계에서만 처리되기 때문입니다. 하지만 모든 애니메이션이 transform으로만 구현될 수 있는 것은 아닙니다. 예를 들어, 요소의 실제 너비나 높이가 변해야 하는 경우가 있습니다. 이럴 때는 width, height와 같은 레이아웃 속성을 사용할 수밖에 없지만, 앞서 설명한 "읽기/쓰기 분리" 원칙과 requestAnimationFrame을 사용하여 Layout Thrashing을 최소화하는 노력이 필요합니다. 항상 성능과 디자인 요구사항 사이의 균형을 고려해야 합니다.
Q3 FastDOM 같은 라이브러리를 꼭 사용해야 하나요
필수는 아니지만, 대규모 애플리케이션이나 복잡한 DOM 조작이 많은 프로젝트에서는 매우 유용할 수 있습니다. FastDOM과 같은 라이브러리는 읽기/쓰기 작업을 자동으로 스케줄링하여 Layout Thrashing을 방지해 줍니다. 이는 개발자가 수동으로 모든 읽기/쓰기 작업을 분리하는 실수를 줄여주고, 코드의 일관성을 유지하는 데 도움을 줍니다. 소규모 프로젝트나 간단한 DOM 조작의 경우, 개발자가 직접 "읽기"와 "쓰기"를 명확히 분리하는 것으로도 충분히 효과를 볼 수 있습니다. 프로젝트의 규모와 팀의 숙련도에 따라 도입 여부를 결정하는 것이 좋습니다.
'생활 정보' 카테고리의 다른 글
| DOM과 CSSOM은 언제, 어떻게 결합되는가 (0) | 2026.01.03 |
|---|---|
| 브라우저는 CSS를 어떤 순서로 처리할까? 렌더링 파이프라인 정리 (0) | 2026.01.03 |
| CSS 속성별 GPU 가속 여부 정리 (transform, filter, opacity 등) (0) | 2025.12.18 |
| CSS Reflow와 Repaint가 발생하는 정확한 조건과 최소화 전략 (0) | 2025.12.18 |
| CSS에서 글자 크기가 줄어들지 않을 때 발생하는 흔한 원인 (0) | 2025.12.15 |