Reflow

seohyun Kang·2025년 12월 12일

Common

목록 보기
9/9
post-thumbnail

소개

포트폴리오 웹 페이지를 작성하면서 네이버 1784 레퍼런스 웹 페이지에 다양한 CSS 애니메이션이 있었고, 내 방식으로 벤치마킹하여 GUI를 작성했습니다.

흥미로운 점은 Android/Chrome에서는 애니메이션을 렌더링하는데 큰 문제가 없었지만, iOS/Safari 테스트 환경에서는 너무 잦은 Reflow로 인한 버벅거림/프레임 드랍이 발생했습니다.

일반적인 웹 서비스를 작성할 때는 레퍼런스 웹 페이지 수준의 애니메이션을 구현할 일이 없었고, Reflow로 인한 이슈를 실제로 확인한 것은 처음이라 신기해서 문서를 작성합니다.

브라우저 렌더링 프로세스

문제가 되는 부분은 다양한 부분은 다양하지만 잦은 Reflow로 인한 이슈를 확인하려면 브라우저가 웹 페이지를 렌더링하는 프로세스를 이해해야합니다.

서버로부터 리소스를 가져오면, 위 이미지와 같이 DOM/CSSOM를 생성하고 DOM + CSSOM → Render Tree → Layout → Paint의 과정을 거쳐 렌더링을하며, JS는 DOM + CSSOM을 조작합니다.

이를 좀더 세분화 하여 설명하면, 아래의 이미지와 같습니다.

이때, 문제가 되는 부분은 Layout 단계가 빈번하게 발생하는 Reflow입니다.

신기하게도, Chrome/Android의 브라우저 엔진보다 Safari/iOS의 브라우저 엔진이 어떠한 이유인지 모르겠지만 제약사항?이 있는지 혹은 처리능력?이 부족한지 이슈가 발생하는 것처럼 보였습니다.

원인

CSS 애니메이션 구현을 위해 DOM 객체의 위치를 변경할 때,
이로 인해 잦은 Layout 계산(Reflow)이 발생하는 것은 왜 문제가 될까?

기본적으로 우리의 뛰어난 브라우저는 DOM 변경과 스타일 계산, 레이아웃 계산을 효율적으로 처리하고 있으며, DOM 변경이 발생하더라도 가능한 한 이를 즉시 처리하지 않고 내부적으로 Batch로 한 번에 Layout과 Paint를 수행하여 성능을 최적화하고 있습니다.

하지만 이러한 내부 최적화 방식이 어떻게 동작하는지를 이해하지 못하면서, 의도치 않게 브라우저의 최적화를 깨뜨리고 성능 문제를 유발했습니다.

그래서 문제의 원인을 파악하고 해결하기 위해서는, Layout(Reflow)을 발생시키는 읽기와 쓰기가 언제, 어떤 조건에서 실행되는지를 이해할 필요가 있다고 생각했습니다.

사실 이 부분은 React의 Life Cycle과 유사한 관점에서 이해할 수 있었습니다. React는 state가 변경되면, React 엔진이 이를 감지하고 업데이트를 예약한 뒤, 여러 변경 사항을 Batch update하여 불필요한 렌더링을 방지합니다.

하지만 만약 React의 업데이트 흐름을 벗어난 방식으로 DOM이나 값을 조작한다면, React의 예측 가능한 렌더링 사이클이 깨지고 의도하지 않은 동작이 발생합니다.

브라우저의 렌더링 과정 또한 유사한 것으로 보입니다. 브라우저는 Layout 계산을 가능한 한 미루고 한 번에 처리하려 하지만, JavaScript 실행하여 Layout 정보를 읽는 순간 브라우저는 최신 값을 보장하기 위해 즉시 Layout을 강제로 수행하게 된다.

이후 다시 DOM이나 스타일을 변경하는 쓰기 작업이 이어지고, 그 다음에 또 다시 Layout 정보를 읽게 되면, 브라우저는 최적화를 포기하고 Layout을 반복적으로 재계산(Reflow)하여 버벅거림 혹은 프레임 다운과 같은 이슈를 생성합니다.

이 현상이 바로 Layout Thrashing이며, 잦은 Reflow로 인한 성능 저하의 핵심 원인으로 문제는 Layout 계산 자체가 아니라, 브라우저가 Layout을 미리 예측하고 묶어서 처리할 수 없게 만드는 코드 흐름이 었습니다.

해결 방안

1. innerHeight / innerWidth 참조

// Reflow 발생
transform: `translate3d(${(x / window.innerWidth) * 100}%, ${
  (y / window.innerHeight) * 100
}%, 0) translate(${transformX}%, ${transformY}%)`

왜 문제인가?

  • window.innerWidth/Height를 읽을 때마다 브라우저가 현재 레이아웃을 계산해야 함
  • 스크롤 이벤트는 초당 수십~수백 번 발생하므로 심각한 성능 저하

2. top, left 속성 사용

// ❌ 문제: 위치 변경 시 reflow 발생
const slideStyle: React.CSSProperties = {
  position: 'absolute',
  top: `${y}px`,
  left: `${x}px`,
  transform: `translate(${transformX}%, ${transformY}%)`
}

3. Layout 계산을 요청하는 값

카테고리         속성
치수 관련        offsetWidth, offsetHeight, clientWidth, clientHeight
위치 관련        offsetTop, offsetLeft, scrollTop, scrollLeft
윈도우          window.innerWidth, window.innerHeight
계산된 스타일     getComputedStyle(), getBoundingClientRect()

해결 방안

그렇다면, Layout Thrashing을 유발하지 않도록 Layout 계산을 요청하는 값의 사용을 피하기만 하면 될까?

Layout 재계산을 요청하지 않도록 만들면 문제를 해결할 수 있습니다. top/left/right/bottom 대신 translate/translate3d를 사용하고 innerWidth/innerHeight 대신 vw/vh를 사용하면 문제가 해결됩니다.

다만, 여기서 궁금해졌던 것은 해당 값은 왜 Layout 계산을 유발하지 않는가? 였습니다.

Composite

위에서 확인했던 바와 같이 브라우저의 렌더링 과정은 크게 Layout → Paint 단계로 나뉘는데 Composite(Paint → Display)라고 하며, 이 중 Composite 단계는 이미 계산되고 그려진 결과를 다시 조합하여 최종 화면을 구성합니다.

중요한 점은, Composite 단계에서는 요소의 위치나 크기를 다시 계산하지 않으며(Layout), 픽셀을 다시 그리지(Paint)도 않습니다.

대신, 이미 Paint가 완료된 결과를 여러 개의 레이어로 나누어 GPU 상에서 위치 이동, 투명도 변경, 회전 등의 변환만 수행합니다.

이러한 특성 때문에, transform이나 opacity와 같은 속성 변경은 Layout이나 Paint를 거치지 않고 Composite 단계에서만 처리될 수 있습니다.

즉, 요소의 실제 레이아웃 구조에는 영향을 주지 않고, 시각적인 결과만을 변경하는 방식입니다.

결말

결과적으로, 기존의 innerWidth/innerHeighttop/left/right/bottomtranslate3d로 변경했지만, 아직 완벽하게 Layout Thrashing 이슈는 해결되지 않았습니다. (물론 이전보다 나아졌지만) 아마도 Layout의 계산을 유발하는 부분이 더 존재하는 것으로 보이고 추가로 수정할 예정입니다.


Reference : How Browser Rendering Works A Step by Step breakdown

0개의 댓글