본문으로 바로가기
검색 (Ctrl+K)

CSS만으로 화면 밖 렌더링 줄이기

·
---
·
개발

CSS만으로 렌더링 최적화를 해보자

들어가며

무한 스크롤이나 대용량 테이블을 만들다 보면 Windowing 또는 Virtualization을 검토하게 된다. 목록이 길어질수록 데이터뿐 아니라 DOM도 계속 늘어나기 때문에 최적화를 고려해야한다.

React에서 목록 가상화가 필요할 때는 오래전부터 react-virtualized (새 탭에서 열림)나, 이를 더 작고 가볍게 다시 만든 react-window (새 탭에서 열림)를 많이 사용하곤 한다. 두 라이브러리 모두 화면에 보이는 항목만 렌더링하는 List와 Grid 컴포넌트를 제공한다.

최근에는 같은 문제를 컴포넌트보다 로직에 가깝게 푸는 TanStack Virtual (새 탭에서 열림)도 선택지로 자리 잡았다. TanStack Virtual은 정해진 마크업이나 스타일을 제공하지 않는 headless virtualization 도구다. 어떤 DOM 구조로 그리고 어떻게 스타일링할지는 개발자가 직접 결정할 수 있다.

하지만 가상화 라이브러리는 기존 목록 구조를 바꾸고 항목 크기나 스크롤 위치를 관리해야 해 도입 비용이 적지 않다. 목록 규모가 크지 않다면 오버 엔지니어링이 될 수도 있다.

그렇다면 DOM을 줄여야 할 정도는 아니지만 화면 밖 요소의 렌더링 비용이 신경 쓰이는 상황은 어떨까?

이때 CSS만으로 시도해 볼 수 있는 속성이 content-visibility다. 브라우저가 화면 밖 콘텐츠의 Layout과 Paint를 건너뛰도록 만드는 속성이다.

content-visibility 속성이란?

MDN (새 탭에서 열림)content-visibility를 다음과 같이 설명한다.

“controls whether or not an element renders its contents at all”

직역하면 요소가 자신의 콘텐츠를 렌더링할지 제어하는 CSS 속성이다. 단순히 화면에서 감추는 속성은 아니다. 브라우저가 당장 필요하지 않은 콘텐츠의 Layout과 Paint를 미룰 수 있도록 강한 containment를 함께 적용한다.

.section {
  content-visibility: auto;
}

auto를 사용하면 브라우저가 콘텐츠와 사용자의 관련성을 판단한다. 화면에서 멀리 떨어져 당장 필요하지 않은 콘텐츠라면 내부 렌더링을 건너뛰고, 화면 가까이 돌아오면 다시 Layout과 Paint를 수행한다. 이 과정은 브라우저가 맡기 때문에 IntersectionObserver로 직접 표시 상태를 전환할 필요가 없다.

content-visibility는 화면 밖 DOM을 제거하지 않는다.

React 컴포넌트는 여전히 실행되고 DOM도 만들어진다. Elements 패널이나 querySelector로 확인해도 화면 밖 항목은 그대로 있다. 생략 대상은 DOM 생성이 아니라, 지금 당장 보이지 않는 콘텐츠를 배치하고 그리는 작업이다.

auto로 생략된 콘텐츠는 DOM뿐 아니라 접근성 트리에도 남는다. 페이지 내 검색, 탭 이동, 포커스 같은 브라우저 기능도 계속 사용할 수 있다. 콘텐츠 자체를 숨겨 이런 기능에서도 제외하는 hidden과 다른 지점이다.

작업content-visibility: auto 적용 후
데이터 요청그대로 발생
React 컴포넌트 실행그대로 발생
DOM 생성그대로 발생
DOM 메모리그대로 사용
화면 밖 Layout생략 가능
화면 밖 Paint생략 가능

속성값은 visible, hidden, auto 세 가지로 나뉜다.

content-visibility: visible;
content-visibility: hidden;
content-visibility: auto;

visible은 일반적인 방식으로 렌더링하는 기본값이고, hidden은 콘텐츠 렌더링을 건너뛴다. 긴 목록과 무한 스크롤에서 눈여겨볼 값은 브라우저가 사용자와의 관련성을 판단해 화면 밖 콘텐츠의 렌더링을 생략하는 auto다.

예상 크기를 이용한 공간 확보

content-visibility: auto만 붙이면 끝일 것 같지만 한 가지 문제가 남는다. 브라우저가 화면 밖 콘텐츠의 Layout을 생략하는 동안에는 내부 콘텐츠의 실제 크기도 바로 알 수 없다. 아무 공간도 잡아 두지 않으면 콘텐츠가 활성화되는 순간 스크롤바 길이나 스크롤 위치가 달라질 수 있다.

그래서 contain-intrinsic-size를 함께 쓴다.

.page {
  content-visibility: auto;
  contain-intrinsic-size: auto 400px;
}

아직 렌더링하지 않은 .page가 우선 400px 높이의 공간을 차지하게 만드는 코드이다. 해당 요소를 한 번 렌더링한 뒤에는 auto가 실제 측정 크기를 기억해 다시 쓴다.

물론 400px을 그대로 복사해서는 안 된다. 페이지당 항목 수와 카드 높이, Grid의 열 수와 간격을 보고 실제 크기에 가까운 값을 골라야 한다. 예상값이 크게 빗나가면 콘텐츠가 활성화될 때 스크롤 위치가 움직일 수 있다.

실제 사용에서는 content-visibilitycontain-intrinsic-size를 한 쌍으로 보는 편이 안전하다.

Windowing과의 차이

둘 다 화면 밖 콘텐츠에 드는 비용을 줄이지만 범위는 꽤 다르다.

구분content-visibilityWindowing
화면 밖 DOM유지생성하지 않거나 제거
React 초기 렌더링모든 컴포넌트 실행화면 근처 컴포넌트만 실행
화면 밖 Layout·Paint생략 가능DOM이 없으므로 발생하지 않음
메모리목록과 함께 증가비교적 일정하게 유지
구현 복잡도CSS 중심측정과 위치 계산 필요
페이지 내 검색보존하기 쉬움별도 대응이 필요할 수 있음
대규모 목록한계 존재상대적으로 적합

둘의 경계는 다음처럼 나뉜다.

화면 밖 Layout과 Paint가 병목
→ content-visibility 검토

DOM 생성과 메모리까지 병목
→ Windowing 검토

content-visibility는 가상 스크롤의 CSS 버전이라기보다, Windowing을 도입하기 전에 꺼내 볼 수 있는 더 작은 선택지에 가깝다.

무한 스크롤에 적용하기

페이지 경계를 유지하는 다음 무한 스크롤을 예로 들어 보자.

type FetchState = {
  data: Item[][];
  hasNextPage: boolean;
};
<div className="infinite-list">
  {data.map((page, pageIndex) => (
    <ul
      key={page[0]?.id ?? pageIndex}
      className="infinite-page"
    >
      {page.map(item => (
        <li key={item.id} className="infinite-item">
          <strong>{item.title}</strong>
          <p>{item.description}</p>
        </li>
      ))}
    </ul>
  ))}
</div>

속성을 어디에 붙일지가 첫 번째 고민이다. 전체 List와 각 ListItem, Page 청크가 후보로 나온다.

전체 List 단위

.infinite-list {
  content-visibility: auto;
}

가장 단순한 방식이다. 다만 무한 목록의 일부가 화면에 보이는 동안 전체 컨테이너가 사용자와 관련된 상태로 남을 수 있어, 하나의 긴 목록 전체를 생략 단위로 삼기에는 너무 크다.

ListItem 단위

.infinite-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px;
}

화면 밖 항목을 가장 세밀하게 생략한다. 그만큼 항목 수와 같은 개수의 containment 단위가 생기므로 작은 카드 수백 개를 각각 관리하는 비용도 함께 고려해야 한다.

Page 단위

.infinite-page {
  display: grid;
  grid-template-columns: repeat(
    auto-fill,
    minmax(220px, 1fr)
  );

  content-visibility: auto;
  contain-intrinsic-size: auto 400px;
}

Page는 전체 List보다 작고 ListItem보다 크다. 무엇보다 Item[][]에 이미 있는 페이지 경계를 그대로 렌더링 청크로 쓸 수 있다. 이 구조에서는 Page를 첫 후보로 잡고, ListItem과의 차이를 측정해 보는 흐름이 자연스럽다.

더 세밀하게 생략하는 ListItem 방식이 빠를지, 관리할 단위가 적은 Page 방식이 빠를지는 목록의 구조와 카드 복잡도에 따라 달라진다.

생략 상태와 성능 확인

화면만 봐서는 브라우저가 내부 렌더링을 실제로 생략하는지 알기 어렵다. contentvisibilityautostatechange 이벤트를 붙이면 각 요소의 상태 변화를 확인할 수 있다.

document
  .querySelectorAll('.infinite-page')
  .forEach((element, index) => {
    element.addEventListener(
      'contentvisibilityautostatechange',
      event => {
        console.log({
          page: index + 1,
          skipped: event.skipped,
        });
      },
    );
  });

skipped: true는 콘텐츠 렌더링이 생략된 상태, skipped: false는 화면 근처에서 다시 활성화된 상태다. 스크롤을 움직였을 때 멀어진 페이지가 true로 바뀐다면 브라우저가 해당 페이지를 생략 단위로 쓰고 있는 셈이다.

하지만 이벤트 로그만으로 빨라졌다고 말할 수는 없다. 실제 효과는 Performance 패널에서 확인해야 한다. 비교 대상은 미적용, ListItem 적용, Page 적용 세 가지다. Production build와 같은 데이터·DOM 규모, viewport, 스크롤 구간, CPU throttling을 사용하고 각 조건을 최소 3회 측정한다.

직접 스크롤하면 매번 속도가 달라지니 가능하면 시작점과 종료점을 자동 스크롤로 고정한다.

const start = performance.now();
const duration = 5000;
const target = document.documentElement.scrollHeight - innerHeight;

function scrollFrame(now) {
  const progress = Math.min((now - start) / duration, 1);
  window.scrollTo(0, target * progress);

  if (progress < 1) {
    requestAnimationFrame(scrollFrame);
  }
}

requestAnimationFrame(scrollFrame);

기록할 값은 Rendering, Layout, Paint, 긴 프레임 수와 DOM 노드 수다. event.skipped는 생략 상태를 확인하는 보조 자료로 둔다.

지표미적용ListItemPage
Rendering39.4ms222.2ms100.1ms
Layout0ms21.0ms6.6ms
Paint0ms70.8ms17.5ms
긴 프레임 수0–10–10–1
목록 DOM 노드 수574574574

한 번의 최고 기록보다 반복 측정의 중앙값이 낫다. DOM 노드 수는 세 조건에서 거의 같아야 하는데, 바로 이 지점에서 Windowing과의 차이가 드러난다.

세 조건이 비슷하게 나올 수도 있다. 카드가 단순하거나 DOM 규모가 작을 수 있고, 병목이 Layout과 Paint 대신 이미지 처리나 JavaScript에 있을 수도 있다. 그런 결과라면 content-visibility보다 다른 비용을 먼저 살펴볼 근거가 생긴다.

실제 서비스의 적용 사례

ABEMA 편성표

ABEMA 편성표는 가로축에 채널과 날짜, 세로축에 방송 시간이 놓인 큰 화면이다. 화면 밖에 프로그램 셀과 채널 column이 많이 남지만 기존 코드가 복잡해 가상 스크롤을 넣기에는 회귀 위험이 있었다.

ABEMA 팀은 각 프로그램 셀과 각 채널 column에 적용한 결과를 비교했다. 더 작은 프로그램 셀이 아니라 채널 column 단위에서 더 좋은 수치가 나왔다고한다.

적용 단위TTITBTRendering
프로그램 셀10.63초886.7ms661.7ms
채널 column9.2초823.3ms546ms

Production build 기반 로컬 측정에서 content-visibility 적용 전후도 따로 비교했다. CPU 6배 감속 환경의 Rendering 시간은 686ms에서 586ms로 줄었고, 저사양 환경의 스크롤도 눈에 띄게 부드러워졌다고 한다. 다만 React 재렌더링 최적화와 함께 배포했기 때문에 운영 환경에서 content-visibility만의 효과를 분리하지는 못했다.

이 사례는 현재 예제의 ListItem과 Page 비교에 곧바로 연결된다. 생략 단위를 작게 쪼개는 것보다 콘텐츠 구조에 맞는 청크를 잡는 편이 나을 수 있다는 근거다.

출처: CyberAgent Developers Blog — ABEMA 편성표 성능 개선 (새 탭에서 열림)

적용 전 확인할 한계

content-visibility를 붙여도 API 요청, 이미지 다운로드와 디코딩, React 컴포넌트 실행, DOM 생성과 메모리, 이벤트 리스너, 무거운 JavaScript 계산은 그대로 남는다.

이미지에 실제 src가 있다면 화면 밖에서도 네트워크 요청이 발생할 수 있다. 이 비용을 미루려면 네이티브 loading="lazy"나 IntersectionObserver 기반 지연 로딩이 따로 필요하다.

content-visibility
→ 화면 밖 Layout과 Paint 제어

이미지 lazy loading
→ 네트워크 요청과 이미지 디코딩 시점 제어

처음부터 보이는 콘텐츠나 LCP 후보도 적절한 대상이 아니다. 작고 단순한 요소를 무분별하게 잘게 나누면 containment 관리 비용만 늘어날 수 있다.

마치며

content-visibility는 CSS로 구현하는 가상 스크롤이 아니다. DOM은 유지한 채 화면 밖 콘텐츠의 Layout과 Paint를 생략한다. 해결 범위가 좁은 대신 구조를 거의 바꾸지 않고 시작할 수 있다는 점이 매력적이다.

현재처럼 무한 스크롤 데이터가 Item[][]로 나뉜 구조라면 Page를 첫 적용 후보로 잡아 볼 만하다. 이미 존재하는 페이지 경계를 렌더링 청크로 쓰고, contentvisibilityautostatechange로 생략 상태를 확인한 다음 Performance trace에서 ListItem 방식과 비교한다.

그 결과 DOM 생성과 메모리가 병목으로 남는다면 그때 Windowing으로 넘어가면 된다.

참고 자료