프론트엔드 뜯어보기

리플로우와 리페인트

강제 동기 레이아웃과 레이아웃 스래싱, 그리고 contain

1장 · 파이프라인의 레이아웃 구간 · 읽는 시간 약 20분

목차 · 진행 0%

    0장에서 크기나 위치를 바꾸면 레이아웃부터 다시 한다는 것을 봤다. 그렇다면 width를 바꾸는 코드는 다 느릴까? 그렇지 않다. 브라우저는 바뀐 것을 모아 두었다가 프레임마다 한 번에 처리한다. 문제는 그 "모아 두기"를 코드가 스스로 깨뜨릴 때 생긴다. 요소의 너비를 읽는 코드 한 줄만으로도 깨진다.

    이 장에서는 레이아웃이 언제 일어나는지, 어떤 코드가 레이아웃을 앞당기는지 알아보고, 그 비용이 실제로 얼마인지 직접 재 본다.

    리플로우와 리페인트는 언제 일어나나

    리플로우는 요소의 위치와 크기를 다시 계산하는 일이고, 보통 리페인트가 뒤따른다. 크기·위치에 영향을 주는 스타일(width, height, left, top 같은 기하 속성)을 바꾸거나, 요소를 넣고 빼거나, 클래스를 바꿔 그런 스타일이 달라지면 레이아웃을 다시 해야 한다. 색처럼 모양만 바뀌면 레이아웃은 건너뛰고 리페인트만 한다.

    중요한 점은 스타일을 바꾸는 그 순간에 브라우저가 레이아웃을 하지 않는다는 것이다. 브라우저는 "레이아웃을 다시 계산해야 한다"는 표시만 해 두고 JavaScript를 마저 실행한다. 그리고 다음 프레임을 그릴 때 스타일 계산과 레이아웃을 한 번에 한다. 스타일을 백 번 바꿔도 프레임 안에서는 레이아웃이 한 번이면 된다.

    레이아웃 한 번의 비용도 작지 않다. web.dev는 레이아웃이 거의 항상 문서 전체를 대상으로 한다고 설명한다. 요소가 많을수록 위치와 크기를 계산할 대상도 많아진다.

    강제 동기 레이아웃

    그런데 JavaScript가 스타일을 바꾼 직후 요소의 크기를 읽으면 어떻게 될까? 브라우저는 바뀌기 전에 계산해 둔 값을 돌려줄 수 없다. 그래서 그 자리에서 스타일 계산과 레이아웃을 끝낸 뒤에야 값을 돌려준다. 이것을 강제 동기 레이아웃(forced synchronous layout, 강제 리플로우)이라고 한다.

    box.classList.add('super-big') // 쓰기: 레이아웃을 다시 계산해야 한다고 표시만 한다
    console.log(box.offsetHeight) // 읽기: 지금 당장 레이아웃을 해야 답할 수 있다

    레이아웃을 앞당기는 읽기는 생각보다 많다. Chrome 팀의 Paul Irish가 정리한 목록에 따르면 대표적인 것은 다음과 같다.

    • offsetWidth·offsetHeight·offsetTop·offsetLeft, clientWidth·clientHeight 같은 박스 크기 값
    • getBoundingClientRect(), getClientRects()
    • scrollTop·scrollLeft·scrollWidth·scrollHeight 읽기, scrollIntoView()
    • getComputedStyle()로 width·height 같은 레이아웃 값을 읽을 때
    • focus(), innerText

    다행히 이 읽기들이 늘 비싼 것은 아니다. 레이아웃 계산이 끝난 최신 상태라면, 즉 마지막 레이아웃 뒤로 바뀐 것이 없다면 브라우저는 계산해 둔 값을 바로 돌려준다. 비용은 쓰기 바로 뒤의 읽기에서 생긴다.

    // 문단 100개의 너비를 상자 너비에 맞춘다
    for (const p of paragraphs) {
      p.style.width = `${box.offsetWidth}px`
    }
    예측먼저 답해 보세요

    상자 너비를 읽어 문단 100개에 하나씩 쓰는 반복문은 레이아웃을 최대 몇 번 하게 만들까?

    레이아웃 스래싱 직접 재 보기

    아래 실험은 이 페이지의 실제 DOM에서 막대 수백 개의 너비를 바꾸고, 걸린 시간을 performance.now()로 잰다. "번갈아"는 위 반복문처럼 막대마다 막대들을 담은 상자의 너비를 읽고 바로 쓴다. "몰아서"는 상자 너비를 한 번만 읽고 나서 모든 막대를 쓴다. 두 방식 모두 마지막 레이아웃 한 번까지 포함해 잰다.

    실험읽기·쓰기를 번갈아 할 때와 몰아서 할 때

    아래 막대들의 너비를 전부 바꿉니다. "번갈아"는 막대마다 막대들을 담은 상자의 너비를 읽은 뒤 바로 쓰고, "몰아서"는 한 번만 읽은 뒤 전부 씁니다. 값은 기기마다 다르니 절댓값보다 두 방식의 배율을 보세요.

    요소 수
    재기

    버튼을 눌러 재 보세요.

    • 해볼 것
    • "번갈아"(읽고 바로 쓰기)로 한 번 재 본다
    • 같은 요소 수에서 몰아서도 재 보고 배율을 확인한다
    • 요소 수를 1,000개로 늘려 두 방식을 다시 재 본다

    요소 수를 늘려 보면 차이가 더 벌어진다. 번갈아 방식은 요소 수만큼 레이아웃을 하고, 레이아웃 한 번의 비용도 요소 수에 따라 커진다. 그래서 전체 시간은 레이아웃 횟수와 한 번의 비용을 곱한 만큼 늘어나기 쉽다. 몰아서 방식은 레이아웃을 한 번만 하니 요소 수에 비례하는 정도로만 늘어난다.

    읽기를 먼저, 쓰기는 나중에

    고치는 방법은 단순하다. 읽기를 모두 먼저 하고, 쓰기는 그 뒤에 몰아서 한다. 읽는 동안에는 레이아웃이 최신 상태로 남아 있으니 계산해 둔 값을 그대로 돌려받고, 쓰기가 끝난 뒤 프레임 끝에 레이아웃을 한 번 한다.

    const width = box.offsetWidth // 읽기는 한 번, 먼저
    for (const p of paragraphs) {
      p.style.width = `${width}px` // 쓰기는 그 뒤에 몰아서
    }

    코드가 여러 함수·컴포넌트에 흩어져 있으면 순서를 맞추기 어렵다. 그럴 때는 쓰기를 requestAnimationFrame으로 미루는 방법이 있다. requestAnimationFrame 콜백은 다음 화면을 그리기 직전에 실행된다. 그래서 지금 하던 읽기를 모두 끝낸 뒤에 쓰기가 한꺼번에 일어난다.

    const rect = el.getBoundingClientRect() // 읽기는 지금
    requestAnimationFrame(() => {
      tooltip.style.top = `${rect.bottom}px` // 쓰기는 다음 프레임 직전에
    })

    미룬 콜백 안에서 다시 레이아웃 값을 읽으면 같은 문제가 생긴다. 콜백 안에서도 읽기와 쓰기의 순서는 지켜야 한다.

    함정 찾기AI가 준 툴팁 위치 맞추기 코드, 그대로 받아도 될까?
    내가 AI에게 한 요청
    “툴팁 수십 개를 각자 기준 요소 바로 아래에 붙이고, 너비는 지금 너비보다 줄어들지 않게 해 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 2개입니다.

    contain과 content-visibility: 레이아웃이 번지지 않게

    읽기·쓰기 순서를 지켜도 레이아웃 한 번 자체가 비쌀 수 있다. 앞에서 봤듯 레이아웃은 거의 항상 문서 전체를 대상으로 하기 때문이다. CSS로 "이 상자 안의 변화는 밖에 영향을 주지 않는다"고 브라우저에 약속하면 계산 범위를 줄일 수 있다.

    • contain: layout: 이 요소 안의 배치가 바깥과 서로 영향을 주지 않는다. 안쪽이 바뀌면 이 요소의 하위 트리만 다시 계산하면 된다.
    • contain: paint: 자손이 이 요소의 경계 밖으로 그려지지 않는다(넘치는 부분은 잘린다). 요소가 화면 밖이면 안쪽을 그리지 않아도 된다.
    • contain: content: layout paint style을 한꺼번에 건다. 크기 제한(size)은 빠져 있어서 요소 크기는 여전히 내용에 따라 정해진다.

    content-visibility: auto는 한 걸음 더 간다. 화면에서 먼 요소는 안쪽의 레이아웃·페인트를 아예 건너뛰고, 가까워지면 그때 그린다. 긴 목록이나 긴 문서의 첫 로딩이 빨라진다. 건너뛴 동안에는 크기를 알 수 없으니 contain-intrinsic-size로 자리 크기를 정해 준다. 그래야 스크롤하며 그려질 때 페이지가 출렁이지 않는다.

    section {
      content-visibility: auto;
      contain-intrinsic-size: auto 500px; /* 그리기 전 자리 크기. 한 번 그린 뒤에는 실제 크기를 기억한다 */
    }

    content-visibility는 2024년 9월에 주요 브라우저에 모두 들어왔다(MDN Baseline 2024). 건너뛴 내용도 DOM과 접근성 트리에는 남아 있어서 페이지 내 찾기와 탭 이동이 그대로 된다. 다만 Safari 26 이전(18.x)에서는 건너뛴 내용이 페이지 내 찾기에 걸리지 않는다(MDN 호환성 데이터). 요소가 수천 개라 DOM 자체가 부담이라면 보이는 것만 DOM에 두는 목록 가상화를 함께 검토한다.

    DevTools로 확인하기

    강제 리플로우는 코드만 봐서는 놓치기 쉽다. Chrome DevTools의 Performance 패널로 기록하면 인사이트 목록에 "Forced reflow"가 뜬다. Chrome 문서는 이 인사이트의 통과 기준을 "30ms보다 오래 걸린 강제 리플로우가 없을 것"으로 둔다. 기록에서 Layout 이벤트가 JavaScript 실행 중간에 촘촘히 박혀 있다면 레이아웃 스래싱을 의심해 볼 만하다.

    확인 문제장을 덮기 전에 떠올려 보기

    1. el.style.width = "100px" 바로 다음 줄에 두면 강제 동기 레이아웃을 일으키는 것은?

    2. 레이아웃 계산이 끝난 최신 상태(마지막 레이아웃 뒤로 바뀐 것이 없을 때)에서 getBoundingClientRect()를 읽으면?

    3. 화면에서 먼 긴 섹션의 레이아웃·페인트를 건너뛰게 하는 CSS는?

    다음2장. 스크롤에 맞춰 움직이는 세 가지 방법스크롤에 맞춰 무언가를 움직일 때, 강제 동기 레이아웃의 비용이 어디서 다시 나타나는지 봅니다.

    『프론트엔드 뜯어보기』 1장. 리플로우와 리페인트 · younggeun0. Claude와 함께 썼습니다. 사실 주장은 공식 문서로, 데모 결과는 실제 브라우저로 확인했습니다.

    보기 옵션