DOM 크기가 큰 요소 렌더링 최적화

10일 전

페이지 링크 : https://wakcraft.vercel.app/architect

테스트 환경 : Vercel Production + AMD Ryzen™ 5 5600X + CPU 4x Throttle


문제 1 : 초기 마운트 시

현재 사이트에 등록된 건축가는 326명이다. 더 나은 사용자 경험(검색)을 위해 모든 건축가들을 DOM에 추가했기 때문에, DOM의 크기가 매우 컸다.


이미지

(Lighthouse를 돌려봤을 때)


과도한 DOM 크기 때문에 스타일 계산 시간이 오래 걸리고 레이아웃 비용이 비쌌기 때문에 초기 마운트가 오래 걸리는 문제가 발생했다.

이미지

모든 컴포넌트를 렌더링하기 위해 1,244ms 나 소요됐다.


문제 2 : 검색으로 인한 리렌더링 발생 시

input 이벤트가 발생하면 input 값에 따라 건축가들이 필터링 되고, 필터링 된 건축가 컴포넌트가 모두 리렌더링 된다.

이미지

예를 들어, a 를 검색하면 요소의 개수가 326에서→ 178로 변경된다.

이미지이미지

키보드 입력 이벤트를 처리하는데 483.32ms 가 소요됐다.


메인 스레드는 한 번에 하나의 작업만 처리할 수 있는데, 메인 스레드에서 50ms 이상 걸리는 작업을 “긴 작업”이라고 명시한다. → 483.32ms 는 CPU 4x throttle을 감안해도 매우 긴 작업이다.



해결 방법

1. useDefferedValue를 이용한 리렌더링 지연

이미지

성능 향상을 위해 즉각적인 반응이 필요한 부분과 즉각적인 반응이 필요하지 않은 부분을 분리를 하는 것이 좋다고 판단했다.

검색 필드와 검색 결과에서 유저의 이름은 빠른 반응이 필요하고, 검색 결과에서 유저의 활동내역 같은 부분은 상대적으로 덜 중요하다고 판단했다.

const [input, setInput] = useState("");
const defferedValue = useDeferredValue(input);

return (
	...
	{props.input === props.debouncedSearchText && (
		<Statistics noobprohackerInfo={architect.statistics} />
	)}
	...
)

useDeferredValue 를 사용하면, 값 변화를 지연시켜 나중에 처리되도록 할 수 있다.

→ 즉각적인 반응이 필요한 부분은 우선적으로 처리하게 하고, 즉각적인 반응이 필요하지 않은 부분은 리렌더링 지연을 할 수 있다.


useDefferedValue 사용 안 했을 때의 성능

이미지

useDefferedValue 사용 했을 때의 성능

이미지

즉각적인 반응이 필요한 부분, 즉각적인 반응이 필요하지 않은 부분이 따로 리렌더링되는 것을 볼 수 있다. → 커밋이 총 2번 발생한다.

  1. 즉각적인 반응이 필요한 부분이 렌더링 되었을 때 - 소요시간: 350.00ms

  2. 즉각적인 반응이 필요하지 않은 부분이 렌더링 되었을 떄 - 소요시간: 272.78ms

모든 컴포넌트가 리렌더링 될 때까지 이전에는 483.32ms , 이후에는 350.00ms + 272.78ms 가 걸렸다. 총 리렌더링 시간은 더 증가했지만 사용자 입장에서는 화면의 첫 반응이 483.32ms 에서 350.00ms 로 많이 빨라졌다.

하지만 DOM 크기가 애초에 너무 컸기 때문에 350.00ms 도 사용자 입장에서는 빠른 반응이 아니다.


2. Lazy Loading

326개의 요소들을 전부 렌더링하지 않고, 처음에는 빈 요소를 렌더링하고 뷰포트에 보여지는 요소들만 렌더링하는 방법인 Lazy Loading을 이용했다.

const [isIntersecting, setIsIntersecting] = useState(false);
const observerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
  const element = observerRef.current
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        setIsIntersecting(true)
      }
    },
    { threshold: 0.01 },
  )

  if (element) {
    observer.observe(element)
  }

  return () => {
    if (element) {
      observer.unobserve(element)
    }
  }
}, [])    
  
return (
    <div ref={observerRef}>
	    {isIntersecting ? (
	    <Fragment>
				...
	    </Fragment> // 뷰포트에 보여지면 실제 요소를 렌더링한다.
		  ) : (
		    <div className="h-[95px]"></div> // 처음에는 빈 요소를 렌더링한다.
		  )}
	  </div>
  )

Lazy Loading를 구현하기 위해,IntersectionObserver를 이용했다.


Intersection Observer API는 특정 요소가 뷰포트와 교차하는지 감지하는 API이다.

이는 메인 스레드의 부하를 줄이기 위해, 별도의 브라우저 최적화 프로세스에서 실행하기 때문에 onscroll 이벤트보다 더 적은 리소스를 사용한다.


문제 1 해결

렌더링 시간이 1,244ms에서 317ms 로 줄어들었다.

[이전]

이미지


[이후]

이미지

문제 2 해결

[이전]

이미지이미지


[이후]

이미지이미지

추가 테스트 결과

초기 마운트 시 렌더링에 소요된 시간

1313.2ms311.6ms (5번 평균)

검색 창에 a 를 입력 했을 떄 INP (4x throttle)

424.0ms97.33ms (10번 평균)

검색 창에서 a 를 지웠을 때 INP (4x throttle)

678.4ms138.18ms (10번 평균)