getBoundingClientRect()로 브라우저 렌더링 톺아보기

getBoundingClientRect()로 브라우저 렌더링 톺아보기

getBoundingClientRect()를 파헤치면서 브라우저 렌더링 과정까지 살펴봅시다

10 min read

레거시 코드를 살펴보다가 오랜만에 getBoundingClientRect()를 마주쳤어요.(이게 뭐였더라...)

페이지 최하단에 도달했을 때 바텀 네비게이션 위에 작은 툴팁을 띄워주는 코드였는데, 별 이상은 없다고 생각했어요. 그런데 Chrome DevTools Performance Trace를 돌려보니 46ms짜리 "Forced Reflow" 경고가 해당 부분에서 찍히고 있었어요.

이번 포스트에서는 getBoundingClientRect()를 살펴보며 자연스럽게 ~ 브라우저 렌더링 과정까지 톺아보려고해요.

getBoundingClientRect()는 요소의 위치와 크기를 반환하는 API예요.

const rect = node.getBoundingClientRect()
// { top: 120, left: 0, width: 375, height: 48, bottom: 168, right: 375 }

뷰포트를 기준으로 요소가 어디에 있는지 알려줘요. "이 요소가 화면 안에 들어왔는지" 감지할 때 자주 쓰이는 패턴이에요.

window.addEventListener('scroll', () => {
  const rect = node.getBoundingClientRect()
  if (rect.top <= window.innerHeight) {
    // 요소가 화면 안으로 들어왔다!
  }
})

스크롤할 때마다 위치를 재서, 화면에 들어왔는지 판단하는 구조예요. 직관적이고 구현도 간단한 편입니다. 그런데 이 패턴엔 두 가지 부담이 겹쳐 있어요.

첫째, 스크롤 이벤트는 너무 자주 발생해요. 마우스 휠을 살짝 굴려도, 손가락을 가볍게 스윽해도 콜백이 수십 번씩 연달아 호출돼요.

둘째, 그 콜백은 메인 스레드에서 돌아요. JS는 싱글 스레드라 스크롤 콜백이 콜 스택을 점유하는 동안에는 다른 코드가 끼어들지 못해요. 클릭 이벤트처럼 태스크 큐에 대기 중인 작업들은 콜 스택이 빌 때까지 실행되지 않아요.

물론 디바운스나 쓰로틀로 호출 빈도는 줄일 수 있어요. 그래도 콜백 안에서 getBoundingClientRect()를 부른다는 사실 자체는 바뀌지 않아서, 호출 한 번의 비용이 그대로 남아요.

IntersectionObserver API 공식 문서에서도 이 패턴을 명시적으로 경고해요.

실제로 제가 마주했던 코드는 이런 모양이었어요.

const bottomSentinelRef = useCallback((node: HTMLDivElement | null) => {
  observerRef.current?.disconnect()
  observerRef.current = null

  if (!node) return

  // ⚠️ 강제 리플로우 발생 지점
  const rect = node.getBoundingClientRect()
  if (rect.top <= window.innerHeight + 100) {
    setIsScrolledToBottom(true)
  }

  const observer = new IntersectionObserver(
    ([entry]) => setIsScrolledToBottom(entry.isIntersecting),
    { threshold: 0, rootMargin: '0px 0px 100px 0px' }
  )
  observer.observe(node)
  observerRef.current = observer
}, [])

getBoundingClientRect()는 옵저버를 달기 직전에 한 번만 실행되는데, 바로 이 한 줄이 강제 리플로우를 일으키고 있었어요.

Performance Trace를 돌려보면 정확히 이 지점이 잡혔어요.

performance-trace getBoundingClientRect 호출 직후 Recalculate style 46.68ms, "Forced reflow is a likely performance bottleneck"

왜 이 API 하나가 46ms가 걸리는 걸까요? 브라우저가 화면을 그리는 방식을 이해하면 이유를 알 수 있습니다.

간략하게 살펴보자면

1) Parse

브라우저가 HTML을 읽으면서 DOM 트리를, CSS를 읽으면서 CSSOM 트리를 만들어요.

2) Render Tree

DOM 트리와 CSSOM 트리를 합쳐 렌더 트리를 구성해요. 실제로 화면에 그려질 요소만 남기고, display: none처럼 보이지 않는 요소는 여기서 걸러져요.

3) Layout

각 요소의 위치와 크기를 계산해요.

width: 50% 같은 상대값을 부모 크기 기준으로 실제 픽셀로 환산하는 것도 여기서 일어나요. 요소 하나의 크기가 바뀌면 주변 요소까지 다시 계산해야 해서(Reflow), 파이프라인에서 가장 비싼 단계예요.

4) Paint

레이아웃이 끝나면 실제 픽셀을 그려요. 텍스트, 색상, 이미지, 테두리 같은 시각적 요소가 이 단계에서 화면에 올라와요.

5) Composite

여러 레이어를 GPU에서 합쳐 최종 화면을 만들어요.

참고로 CPU는 범용 프로세서예요. JS 실행도, 레이아웃 계산도 전부 CPU의 메인 스레드에서 일어나요. GPU는 픽셀 수천 개를 동시에 처리하는 데 특화된 프로세서고요.

transform이나 opacity는 요소의 위치·크기를 건드리지 않아요. GPU에게 "이 레이어를 이렇게 합성해"라고 지시만 하면 되기 때문에, CPU 메인 스레드가 관여하지 않아요. 이게 바로 Layout·Paint를 건너뛰고 Composite만 거치는 속성이 성능상 유리한 이유예요.(Reflow는 비용이 꽤나 비싸다!! 명심.)


여기서 한 가지 짚고 갈 게 있어요. 브라우저는 이 파이프라인을 배치(batch)로 묶어 처리해요.

JS가 DOM을 10번 바꿔도 렌더링은 한 번으로 몰아서 해요. 매번 다시 계산하면 너무 느리니까 브라우저가 알아서 최적화해줘요.

사실 React가 가상 DOM을 두는 이유도 결이 비슷해요. 변경을 메모리 위에서 모아 diff한 뒤, 실제 DOM에는 한 번에 반영해요. JS 레벨의 배치브라우저 레벨의 배치가 함께 돌면서 렌더링 비용을 줄이는 셈이에요.

그런데 getBoundingClientRect()는 이 배치를 깨버립니다.

이 API가 값을 돌려주려면 Layout이 끝난 상태여야 해요. 근런데 DOM을 막 바꾼 직후에 이 API를 부르면 브라우저는 대략난감해집니다.

"방금 모아둔 변경을 한 번에 처리하려고 했는데, 지금 당장 정확한 좌표를 달라고요?!"

어쩔 수 없이 배치를 포기하고 그 자리에서 Layout을 동기로 돌릴 수밖에 없어요. 이게 바로 강제 리플로우(Forced Reflow) 예요. 앞서 말했듯이 Layout은 CPU 작업이에요. 메인 스레드를 점유하면서 계산하기 때문에, 이 시간 동안 사용자 입력이나 다른 JS 실행은 전부 대기 상태가 돼요.

JS 실행 → DOM 변경 → getBoundingClientRect() 호출
                              ↓
                   Layout 강제 동기 계산 (메인 스레드 블로킹)
                              ↓
                   나머지 JS 계속 실행

스크롤 이벤트처럼 고빈도 콜백 안에서 이 호출이 반복되면, 강제 리플로우가 누적되면서 레이아웃 스래싱(Layout Thrashing) 으로 이어져요. 스크롤할 때마다 화면이 미묘하게 끊긴다면, 이 패턴이 숨어 있을 가능성이 높아요.

getBoundingClientRect과는 다르게 IntersectionObserver는 브라우저에게 상당히 겸손하게 부탁합니다.

"이 요소가 뷰포트와 겹치는 상태가 바뀌면, 나중에 알려주셔요~"

Layout 계산을 직접 요청하지 않아요. 브라우저가 렌더링 파이프라인을 한 번 다 돌리고 나서 비동기로 콜백을 보내주게 됩니다. 즉, 메인 스레드를 점유하지 않아요.

추가로 IntersectionObserverobserve()를 부른 직후 초기 교차 상태에 대한 콜백을 한 번 즉시 실행해요. 노드가 처음부터 뷰포트 안에 있었다면 entry.isIntersecting === true로 콜백이 들어와요.

const bottomSentinelRef = useCallback((node: HTMLDivElement | null) => {
  observerRef.current?.disconnect()
  observerRef.current = null

  if (!node) return

  const observer = new IntersectionObserver(
    ([entry]) => setIsScrolledToBottom(entry.isIntersecting),
    { threshold: 0, rootMargin: '0px 0px 100px 0px' }
  )
  observer.observe(node)
  observerRef.current = observer
}, [])

이제 Performance Trace에서 강제 리플로우가 사라지게됩니다.

강제 리플로우는 단순히 "느려진다"는 체감을 넘어 Core Web Vitals 지표에도 직접 영향을 줘요. 메인 스레드가 동기 레이아웃 계산으로 막혀 있으면 콘텐츠가 늦게 렌더링되니 LCP가 나빠지고, 클릭 같은 인터랙션이 응답을 기다려야 하니 INP도 떨어져요.

혹시 최근에 핫했던 Pretext라는 라이브러리를 혹시 들어보셨나요

지금까지 웹에서 텍스트 높이를 알고 싶을 때, 결국 한 번은 getBoundingClientRect()를 거쳐가야 했어요. 앞서 본 강제 리플로우를 그대로 만드는 방식이에요.

그런데 Pretext는 그 과정을 아예 DOM 바깥으로 옮겨요.

pretext-demo Pretext 활용 예시

import { prepare, layout } from '@chenglou/pretext'

const handle = prepare('오늘 점심 뭐 먹지?', '16px Pretendard')
const { height, lineCount } = layout(handle, 320, 20)

prepare()는 화면에 보이지 않는 <canvas>에서 measureText()로 글자 너비만 측정해요. canvas는 DOM 렌더링 파이프라인 바깥에 있어요. 앞서 본 것처럼 Paint·Composite 단계는 GPU가 처리하는 영역인데, canvas도 같은 GPU 가속 경로를 탈 수 있어요. measureText() 자체는 CPU 폰트 엔진 작업이지만, DOM Layout을 전혀 건드리지 않으니 메인 스레드가 막히지 않아요.

측정한 값은 메모리에 저장해두고 이후 layout()은 그 숫자들을 더하다가 컨테이너 너비를 넘으면 줄을 바꾸는 순수 산술 연산(알고리즘)이에요. DOM도, 렌더링 파이프라인도 끼지 않으니 당연히 리플로우는 일어나지 않아요.

최근에 제가 고민했던건 "강제 리플로우를 안 일으키는 방법"이라면, Pretext는 같은 고민을 레이아웃 측정 자체까지 끌고 갔네요.(천재는 많다.)