사전 레이아웃 이동
문제

레이아웃 이동

gabury1

이미 화면에 그려져 있던 요소가 다음 프레임에서 자리를 옮기는 일입니다. 뒤늦게 도착한 콘텐츠가 먼저 그려진 것을 밀어내면서 벌어집니다. 브라우저는 옮겨간 정도를 프레임마다 값으로 잽니다.

상세

MDN(Mozilla Developer Network) 은 뷰포트 안에서 보이는 요소가 두 프레임 사이에 위치를 바꾸면 레이아웃 이동이 일어난 것이라고 적습니다. 그런 요소를 unstable 하다고 부르고, 그것이 시각적 안정성이 없다는 표시라고 적습니다.

Layout Instability 명세는 웹페이지에서 DOM(Document Object Model, 문서 객체 모델) 요소가 밀리는 것이 사용자 경험을 해친다고 적고, 오늘날 웹에서 자주 일어난다고 적습니다. 원인으로는 콘텐츠가 비동기로 로드되면서 페이지의 다른 요소를 밀어내는 경우가 잦다고 적습니다.

Layout Instability API(Application Programming Interface, 응용 프로그램 인터페이스)는 이런 페이지를 가려내려고 사용자 세션의 애니메이션 프레임마다 값을 하나씩 보고합니다. 그 값의 이름이 layout shift 입니다. 명세가 정하는 것은 사용자 에이전트가 그 값을 계산하는 방법입니다.

세는 단위는 요소가 아니라 프레임입니다. 명세는 노드가 unstable-candidate 이면서 inline clip crosser 가 아닐 때 그 노드를 unstable 하다고 정의합니다.

발생 조건

flowchart TD
    A["앞 프레임에 이미 있던 요소인가"] -- 아니오 --> X["이동으로 안 센다"]
    A -- 예 --> B["뷰포트 안에서 보이나"]
    B -- 아니오 --> X
    B -- 예 --> C["두 프레임 사이에 시작 위치가 바뀌었나"]
    C -- 아니오 --> X
    C -- 예 --> D["레이아웃 이동"]

세 가지가 함께 서야 합니다.

첫째, 그 요소가 앞 프레임에 이미 있어야 합니다. 명세는 첫 프레임에서는 어떤 노드에도 이전 프레임의 시작 위치가 없고 그래서 unstable node set 이 비어 있다고 적습니다. 뒤늦게 삽입된 요소 자신은 견줄 앞자리가 없습니다. web.dev 는 이런 움직임이 리소스가 비동기로 로드되거나 DOM 요소가 기존 콘텐츠 앞에 동적으로 추가될 때 대개 일어난다고 적습니다. 밀리는 쪽은 뒤에 온 요소가 아니라 이미 있던 콘텐츠입니다.

둘째, 그 요소가 뷰포트 안에서 보여야 합니다. MDN 의 정의가 뷰포트 안에서 보이는 요소로 못 박습니다.

셋째, 두 프레임 사이에 시작 위치가 달라져야 합니다. 프레임 하나만 놓고는 판정이 안 됩니다. 앞 프레임의 시작 위치와 이번 프레임의 시작 위치를 견주는 것이 세는 방식입니다.

방아쇠는 대개 로드 시점입니다. web.dev 는 레이아웃 이동의 원인으로 셋을 듭니다. 크기를 모르는 이미지나 비디오, 처음 쓰던 대체 폰트보다 크게 또는 작게 그려지는 폰트, 스스로 크기를 바꾸는 서드파티 광고나 위젯입니다.

조건 셋째를 없애면 안 터집니다. 자리를 미리 예약해 두면 콘텐츠가 도착해도 시작 위치가 안 바뀝니다. MDN 은 <img> 에 height 와 width 를 함께 적으면 이미지가 로드되기 전에 브라우저가 종횡비를 계산할 수 있다고 적습니다. 그 종횡비로 이미지를 보여줄 공간을 예약하고, 그래서 이미지를 내려받아 화면에 그릴 때 레이아웃 이동을 줄이거나 아예 막는다고 적습니다.

예시

크기 속성이 없는 img 한 줄

HTML
<img
  class="fit-picture"
  src="/shared-assets/images/examples/grapefruit-slice.jpg"
  alt="Grapefruit slice atop a pile of other slices" />

MDN 의 <img> 문서가 첫 예제로 싣는 마크업입니다. src 와 alt 만 있고 width 와 height 가 없습니다. 두 속성이 없으면 브라우저가 종횡비를 미리 계산할 근거가 없고, 예약할 공간도 없습니다. MDN 은 두 속성을 함께 써서 이미지의 고유 크기를 정해 주면 이미지가 로드되기 전에도 자리를 차지하게 해서 콘텐츠 레이아웃 이동을 완화할 수 있다고 적습니다.

이동 값 0.1875 가 나오는 프레임

web.dev 가 든 계산 예입니다. 한 프레임에서 뷰포트의 절반을 차지하던 요소가 다음 프레임에서 뷰포트 높이의 25% 만큼 아래로 내려갑니다. 두 프레임에서 그 요소가 보이던 영역의 합집합이 뷰포트 전체의 75% 라서 impact fraction 이 0.75 입니다.

distance fraction 은 unstable 요소가 그 프레임에서 움직인 가장 큰 가로 또는 세로 거리를 뷰포트의 더 큰 변으로 나눈 값입니다. 이 예에서는 뷰포트의 높이 쪽이 더 크고 요소가 높이의 25% 를 움직였으니 distance fraction 이 0.25 입니다. 그래서 layout shift score 는 0.75 × 0.25 = 0.1875 입니다.

PerformanceObserver 로 받는 layout-shift 엔트리

JavaScript
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Count layout shifts without recent user input only
    if (!entry.hadRecentInput) {
      console.log("LayoutShift value:", entry.value);
      if (entry.sources) {
        for (const { node, currentRect, previousRect } of entry.sources)
          console.log("LayoutShift source:", node, {
            currentRect,
            previousRect,
          });
      }
    }
  }
});
observer.observe({ type: "layout-shift", buffered: true });

MDN 의 LayoutShift 문서에 실린 예제입니다. entry.value 가 그 프레임의 이동 값이고, entry.sources 는 실제로 움직인 요소들의 배열입니다. MDN 은 LayoutShift.sources 를 부르면 LayoutShiftAttribution 인스턴스가 배열로 돌아온다고 적습니다. 이 인터페이스는 움직인 요소에 대한 디버깅 정보를 준다고 적습니다. previousRect 는 이동 전 요소의 위치, currentRect 는 이동 후 요소의 위치를 나타내는 DOMRectReadOnly 객체입니다.

경계

CSS(Cascading Style Sheets, 캐스케이딩 스타일 시트)의 transform 으로만 움직인 요소도 레이아웃 이동인가. 아닙니다.

명세는 노드가 움직였는지 판정할 때 시작 위치를 transform 을 적용한 것과 적용하지 않은 것 양쪽으로 본다고 적습니다. transform 이 바뀌었다는 이유만으로 노드가 unstable 이 되지 않게 하려는 것입니다. 다만 명세는 CSS transform 이 시각적 표현을 계산할 때와 뷰포트 밖의 점을 제외할 때는 언제나 반영된다고 덧붙입니다.

web.dev 도 같은 자리를 손잡이로 적습니다. transform 속성을 쓰면 레이아웃 이동을 일으키지 않고 요소를 애니메이션할 수 있다고 적고, height 와 width 속성을 바꾸는 대신 transform: scale() 을 쓰고 top · right · bottom · left 를 바꾸는 대신 transform: translate() 를 쓰라고 적습니다.

운영

무엇을 보고 아나. 브라우저가 프레임마다 내주는 layout-shift 엔트리입니다. PerformanceObserver 로 받으면 됩니다. 엔트리에는 hadRecentInput 이 붙습니다. MDN 은 이 값이 lastInputTime 이 과거 500밀리초 안이면 참이라고 적고, lastInputTime 은 가장 최근의 excluding input 시각 또는 그런 입력이 없었으면 0 이라고 적습니다. 여기서 excluding input 은 그 엔트리를 CLS(Cumulative Layout Shift, 누적 레이아웃 이동) 점수의 기여자에서 빼는 사용자 입력입니다. 명세는 excluding input 을 문서와의 능동적 상호작용을 알리는 입력 장치의 이벤트, 또는 뷰포트 크기를 직접 바꾸는 이벤트로 정의하고, 대체로 mousedown · keydown · pointerdown · change 가 여기 든다고 적습니다. 다만 플릭이나 스크롤 제스처를 시작하거나 갱신하는 것만이 효과인 이벤트는 excluding input 이 아니라고 적습니다.

어느 값부터 손대야 하나. web.dev 는 CLS 점수를 0.1 이하로 두는 것을 목표로 삼으라고 적습니다. 대부분의 사용자에게 이 목표를 맞추는지 보려면 페이지 로드의 75분위를 재라고 적고, 모바일과 데스크톱 장치를 갈라서 재라고 적습니다. 값의 등급은 0.1 이하가 good, 0.25 초과가 poor 입니다.

한 번의 이동만 세는 것이 아닙니다. web.dev 는 레이아웃 이동이 몰려 터지는 구간을 session window 라고 부릅니다. 개별 이동 사이의 간격이 1초 미만이고 창 전체의 길이가 최대 5초인 구간입니다. 가장 큰 버스트는 그 창들 중 안에 든 레이아웃 이동의 누적 점수가 최대인 창입니다.

손잡이는 이미 앞에서 나왔습니다. <img> 의 width · height 로 자리를 예약하는 것과, 움직이는 연출을 transform 으로 돌리는 것입니다.

관련 항목

이것을 정의하는 표준·문서

WICG(Web Platform Incubator Community Group) · MDN · web.dev

이 값을 재고 나르는 API

API · PerformanceObserver · LayoutShift · LayoutShiftAttribution · DOMRectReadOnly · previousRect · currentRect · impact fraction · distance fraction · 세션

이 값을 CLS 로 모으는 규칙

CLS · session window · excluding input · hadRecentInput · lastInputTime

이동 여부를 판정하는 주체와 대상

브라우저 · 사용자 에이전트 · DOM · 노드 · 뷰포트

이 이동을 막거나 일으키는 스타일 속성

CSS · transform · width · height

다른 이름: layout shift · Layout Instability · 레이아웃 시프트