본문 바로가기

FE

레이아웃 스래싱(Layout Thrashing)

 

다들 자바스크립트로 DOM 조작하면서왜 이렇게 버벅이지 하고 느껴본 적 있으시죠

 

저는 개발중에 수많은 리스트를 동적으로 추가하는 기능을 만들다가 페이지가 거의 멈추듯이 버벅거리는 경험을 했습니다.

원인을 찾아보니 원인은 바로 레이아웃 스래싱(Layout Thrashing) 이었습니다.

 

오늘은 제가 공부하면서 알게 된, 이 레이아웃 스래싱을 피하고 DOM 성능을 확 끌어올리는 몇 가지 꿀팁을 공유해 보려고 합니다!

 

1.  레이아웃 스래싱 이란? 

우리가 DOM의 스타일이나 구조를 바꾸면 브라우저는 화면을 새로 그리기 위해 꽤 복잡한 과정을 거칩니다.

(DOM → CSSOM → 렌더 트리 → 레이아웃 → 페인트 → 합성)

 

여기서 중요한 게 레이아웃 단계입니다. (브라우저가 각 요소의 크기와 위치를 계산하는 과정)

 

근데 offsetWidth나 getBoundingClientRect 처럼 요소의 크기나 위치를 읽어오는 코드를 실행하면,

브라우저는 강제로  레이아웃 과정을 다시 실행합니다.

 

JavaScript

// 나쁜 예시
for (const el of items) {
  // 1. 읽기 (이때 레이아웃 강제 실행)
  const h = el.offsetHeight;
  // 2. 쓰기 (스타일 변경 -> 또 레이아웃이 필요해짐)
  el.style.height = (h + 10) + 'px';
  // 반복
}

 

이 코드는 루프를 돌 때마다  읽기 → 쓰기 → 읽기 → 쓰기를 반복합니다.  브라우저 입장에서는 "방금 계산 끝냈는데 또 계산하라고?" 하면서 한 프레임에 레이아웃 계산을 수십, 수백 번이나 하게 됩니다

이게 바로 레이아웃 스래싱이고,  화면을 버벅이게 만드는 원인입니다!!

 


2. 원칙:  읽기쓰기는 무조건 분리

해결책은 간단합니다.

읽기쓰기를 섞어 쓰지 말고, 한 번에 모아서 처리하는 겁니다.

  • 1단계: 읽기 (측정) 먼저 다 하기
  • 2단계: 쓰기 (변경) 나중에 다 하기

JavaScript

//좋은 예시

// 1. 읽기 먼저 (배열에 값 저장)
// 레이아웃 계산 1번
const heights = items.map(el => el.offsetHeight);

// 2. 쓰기는 나중에
// 스타일 변경에 따른 레이아웃 계산도 1번
items.forEach((el, i) => {
  el.style.height = (heights[i] + 10) + 'px';
});

 

이렇게 하면 브라우저가 레이아웃 계산을 최소한으로 할 수 있어서 훨씬 빨라집니다.

 

★중요: 스타일을 여러 개 바꿀 땐 el.style.A = ..., el.style.B = ... 이렇게 하나하나 바꾸는 것보다, 

CSS 클래스 하나를 만들어두고 .classList.toggle('active')로 한 번에 처리하는게 효율적입니다.


3. 장바구니를 쓰자 

만약 1000개의 <li> 태그를 for문 안에서 appendChild로 하나씩 추가하면 어떻게 될까요?

DOM 트리가 1000번 바뀌고, 레이아웃도 최소 1000번이 일어날 것입니다...

이럴 땐 가상 장바구니를 쓰면 됩니다.

3-1) DocumentFragment (가상 장바구니)

DocumentFragment는 실제 DOM 트리에 존재하지 않는, 메모리상의 가벼운 DOM 노드입니다

여기에 우리가 만들 요소를 다 담아뒀다가, 마지막에 딱 한 번만 실제 DOM에 추가하는 겁니다

 

JavaScript

// 1. 장바구니 생성
const frag = document.createDocumentFragment();

for (let i = 1; i <= 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  // 2. 실제 DOM이 아니라 장바구니에만 담음
  // 이때는 레이아웃/리플로우 안 일어남
  frag.appendChild(li);
}

// 3. 다 담았으면 실제 DOM에 한번에 추가
// 레이아웃 계산 한 번
document.querySelector('ul.list').appendChild(frag);

이러면 브라우저는 딱 한 번만 레이아웃을 계산하면 되니, 속도가  빨라집니다.

3-2) <template> 태그 

HTML에 템플릿을 만들어두고 찍어내는 방식도 편합니다.

 

HTML

<template id="item-tpl">
  <li class="item">
    <span class="txt"></span>
  </li>
</template>

<ul class="list"></ul>

JavaScript

const tpl = document.getElementById('item-tpl');
const frag = document.createDocumentFragment(); // 장바구니 준비

for (let i = 1; i <= 1000; i++) {
  // 1. 템플릿 복사해서 새 노드 생성
  const node = tpl.content.cloneNode(true);
  // 2. 내용 채우기
  node.querySelector('.txt').textContent = `Item ${i}`;
  // 3. 장바구니에 담기
  frag.appendChild(node);
}

// 4. 한번에 추가
document.querySelector('ul.list').appendChild(frag);

 

이 방식은 HTML 구조가 복잡할 때 코드가 훨씬 깔끔해지고 유지보수하기도 좋습니다

 

 


 

4. requestAnimationFrame (rAF)

브라우저가 화면을 그리는 스케줄에 맞춰서 DOM 변경 작업을 예약하는 방법입니다

requestAnimationFrame 안에 DOM 변경 코드를 넣으면,

브라우저가 다음 프레임을 그리기 직전에 , 요청한 것을 최적의 타이밍에 실행해 줍니다.

JavaScript
 
function addManyItems() {
  const list = document.querySelector('.list');
  const frag = document.createDocumentFragment();
  // ( frag에 li 1000개 담는 코드 ...)

  // DOM 변경 작업을 다음 프레임에 예약
  requestAnimationFrame(() => {
    list.appendChild(frag);
    // (스타일 변경 사항)
  });
}

 

이렇게 하면 애니메이션이나 스크롤 같은 다른 작업들을 방해하지 않고 훨씬 부드럽게 DOM이 변경됩니다.

 

마무리

처음엔 그냥 appendChild하면 되는 거 아닌가 싶었는데, 브라우저가 얼마나 비효율적으로 작동하고 있는지  알게 되니까 코드를 효율적으로 짜는 것이 중요하다고 생각했습니다.

 

DOM 성능 최적화의 핵심은 브라우저가 똑같은 계산을 여러 번 하지 않게 하는것이었습니다 (특히 레이아웃)

 

읽기/쓰기 분리하기 (한 번에 물어보고, 한 번에 바꾸기)
대량 삽입은 DocumentFragment (장바구니)
큰 변경은 rAF (브라우저 타이밍에 맞춰서 예약하기)