개발일지

react 성능 최적화 - 대규모 렌더링 최적화

KIMSANGHUN 2025. 3. 27. 19:31

 

Javascript
이 글은 자바스크립트를 기반으로 작성되었습니다


 

10만개의 데이터를 화면에 렌더링하면 어떻게 될까?

이 글은 레퍼런스를 참고해 아이디어를 얻었습니다

 

React v18: useTransition hook — Why???

React v18 introduced the useTransition hook, which may seem like just another hook but let’s look...

dev.to

 

10만개의 비동기 데이터를 생성하고 렌더링 시키고 최적화를 해보려고 합니다
(10만개의 데이터는 성능에 부하가 걸릴정도의 연산 데이터를 사용하겠습니다)

값을 구하는 코드

const cache = new Map<string, Promise<number[]>>()

export const NUMBER_SIZE = 100_000

function factorial(n: number): number {
  let result = 1
  for (let i = 1; i <= n; i++) {
    result *= i
  }
  return result
}

function generateFactorialSeries(value: number, size: number): number[] {
  const result: number[] = []
  for (let i = 0; i < size; i++) {
    result.push(factorial(value + i))
  }
  return result
}

export function fetchData(query: string): Promise<number[]> {
  const value = Math.max(Number(query), 1)

  if (cache.has(query)) {
    return cache.get(query)!
  }

  const promise = new Promise<number[]>((resolve) => {
    resolve(generateFactorialSeries(value, NUMBER_SIZE))
  })

  cache.set(query, promise)
  return promise
}
// PageDeferredValue.tsx

import { useState } from "react"
import { NUMBER_SIZE } from "../components/data"
import SearchSugestions from "../components/search-sugestions"

export default function PageDeferredValue() {
  const [query, setQuery] = useState<string>('1')

  return (
    <div className="w-container p-4 space-y-4">
      <div className="grid gap-2 gap-y-8">
        <h1 className="text-xl font-semibold">factorial ({Number(query)}! ~ {(Number(query) + NUMBER_SIZE - 1).toLocaleString()}!)</h1>
        <input
          type='text'
          className="w-full h-12 border px-2"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="숫자를 입력해주세요"
        />
      </div>
      <SearchSugestions query={query} />
    </div>
  )
}
// SearchSugestions.tsx

import { memo, Profiler, useEffect, useState } from "react"
import { useProfiler } from "../hooks/use-profiler"
import { fetchData } from "./data"

interface SearchSugestionsProps {
  query: string
}

function SearchSugestions({ query }: SearchSugestionsProps) {
  const { id, onRender } = useProfiler()
  const [numbers, setNumbers] = useState<number[]>([])

  const sugestions = numbers.map((num, index) => (
    <p key={`${id}_${index}`} className="text-sm">
      [data:{index + 1}] {num.toLocaleString()}
    </p>
  ))

  useEffect(() => {
    fetchData(query)
      .then((data) => setNumbers(data))
  }, [query])

  return (
    <Profiler id={id} onRender={onRender}>
      <div className="grid gap-y-4">
        {numbers.length > 0 ? sugestions : <p className="col-span-12">-</p>}
      </div>
    </Profiler>
  )
}

export default memo(SearchSugestions)

 

결과

브라우저가 멈춰버릴정도로 부하가 많이 걸렸습니다. 😗
무언가 확실히 잘못되었습니다

 

한참 기다리니까 렌더링이 완료되었습니다

 

 

무엇이 느린걸까?

팩토리얼을 계산하는 연산이 느린걸까 ?
아니면 렌더링되는 요소가 많아서 느린걸까?
아니면 렌더링이 많이 일어나서 느린걸까?

생각이 나는대로 최대한 많이 개선을 해보려고합니다

 

팩토리얼을 계산하는 연산이 느린걸까 ?

팩토리얼을 계산하는 함수에 100이라는 숫자를 넣고 연산시간을 체크했습니다.

const cache = new Map()

const NUMBER_SIZE = 100_000

function factorial(n) {
  let result = 1
  for (let i = 1; i <= n; i++) {
    result *= i
  }
  return result
}

function generateFactorialSeries(value, size) {
  const result = []
  for (let i = 0; i < size; i++) {
    result.push(factorial(value + i))
  }
  return result
}

function fetchData(query) {
  const value = Math.max(Number(query), 1)

  if (cache.has(query)) {
    return cache.get(query)
  }

  const promise = generateFactorialSeries(value, NUMBER_SIZE)

  cache.set(query, promise)
  return promise
}
const t0 = performance.now()
const result = fetchData(100)
const t1 = performance.now()
console.log(result[0])
console.log(`used ${t1 - t0}ms`)

// 9.33262154439441e+157
// used 3643.4338ms

대략 4초 조금 안되게 걸렸습니다. 

현재 제 노트북에서는 렌더링에 10초가 넘는 시간이 소요되고 있습니다.
그렇다면 연산시간을 제외하고도 화면을 그리기까지 대략 6초의 시간이 소요됩니다. 😂

 

어떻게 개선할까?

연산은 백엔드에서 처리하는 과정을 재현한 것으로 연산 자체는 최적화를 하지 않으려고 합니다. 

서버의 응답을 기다리는 동안의 사용자 경험을 개선해보려고 합니다.

사용자 경험을 개선하기 위해 use hooks와 suspense를 이용해서 SearchSugestions 컴포넌트를 감싸주겠습니다.

import { Suspense, useState } from "react"
import { NUMBER_SIZE } from "../components/data"
import SearchSugestions from "../components/search-sugestions"

export default function PageDeferredValue() {
  const [query, setQuery] = useState<string>('1')

  return (
    <div className="w-container p-4 space-y-4">
      <div className="grid gap-2 gap-y-8">
        <h1 className="text-xl font-semibold">factorial ({Number(query)}! ~ {(Number(query) + NUMBER_SIZE - 1).toLocaleString()}!)</h1>
        <input
          type='text'
          className="w-full h-12 border px-2"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="숫자를 입력해주세요"
        />
      </div>
      <Suspense fallback={<>loading...</>}>
        <SearchSugestions query={query} />
      </Suspense>
    </div>
  )
}
// SearchSugestions.tsx

import { Profiler, use } from "react"
import { useProfiler } from "../hooks/use-profiler"
import { fetchData } from "./data"

interface SearchSugestionsProps {
  query: string
}

function SearchSugestions({ query }: SearchSugestionsProps) {
  const { id, onRender } = useProfiler()
  const numbers = use(fetchData(query))

  const sugestions = numbers.map((num, index) => (
    <p key={`${id}_${index}`} className="text-sm">
      [data:{index + 1}] {num.toLocaleString()}
    </p>
  ))

  return (
    <Profiler id={id} onRender={onRender}>
      <div className="grid gap-y-4">
        {numbers.length > 0 ? sugestions : <p className="col-span-12">-</p>}
      </div>
    </Profiler>
  )
}

export default SearchSugestions

 

결과

처음 페이지 진입 시 loading.. 이 뜨면서 훨씬 빠른 초기 렌더링 속도를 보여주었습니다

렌더링되는 요소가 많아서 느린걸까?

렌더링을 할 때 걸리는 속도를 한번 체크해보려고 합니다.
렌더링 속도를 체크하기위해 react 내장함수인 Profiler를 사용했습니다.

로그를 찍는 hooks은 아래의 코드를 사용했습니다.

import { useId } from "react"

function useProfiler() {
  const id = useId()

  const onRender = (
    id: string,
    phase: "mount" | "update" | "nested-update",
    actualDuration: number,
    baseDuration: number,
    startTime: number,
    commitTime: number
  ) => {
    console.log(`[Component ID: "${id}"]`)
    console.log(`step: ${phase}`)
    console.log(`actualDuration: ${actualDuration.toFixed(2)}ms`)
    console.log(`baseDuration: ${baseDuration.toFixed(2)}ms`)
    console.log(`startTime: ${startTime.toFixed(2)}ms`)
    console.log(`commitTime: ${commitTime.toFixed(2)}ms`)
  }

  return { id, onRender }
}

export { useProfiler }

 

렌더링할 때 slice 함수로 1000까지만 렌더링 했을때 걸리는 시간입니다

2.00ms 가 측정되었습니다

 

다음으로 10만개의 데이터를 그릴때에 걸리는 시간입니다

946ms가 측정되었습니다.

무려 400배가 넘는 렌더링 시간차이를 보여주고 있습니다
이 부분에 대해서도 개선할 필요가 분명 있어보입니다

 

react developer tools profiler 측정 화면

 

이 부분에 대해서는 가상화(windowing) 기법을 사용해서 처리해보려고 합니다
react (구) 공식문서에서도 많은 요소를 렌더링 하는 경우에는 windowing 기법을 사용하는 것을 추천하고 있습니다

https://ko.legacy.reactjs.org/docs/optimizing-performance.html

 

성능 최적화 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

가상화 기법이란, 쉽게 말해서 보여지는 부분만 렌더링 시키는 방법입니다

위 공식문서에서 추천하는 라이브러리중 가벼운 라이브러리인 react-window 라이브러리를 선택해 구현해보겠습니다

import { Profiler, use } from "react"
import { FixedSizeList } from "react-window"
import { useProfiler } from "../hooks/use-profiler"
import { fetchData } from "./data"

interface SearchSugestionsProps {
  query: string
}

function SearchSugestions({ query }: SearchSugestionsProps) {
  const { id, onRender } = useProfiler()
  const numbers = use(fetchData(query))

  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className="text-sm px-4">
      {numbers[index].toLocaleString()}
    </div>
  )

  return (
    <Profiler id={id} onRender={onRender}>
      <div className="grid grid-cols-12 gap-y-4">
        {numbers.length > 0 ? (
          <FixedSizeList
            height={600}
            width="100%"
            itemCount={numbers.length}
            itemSize={36}
            className="col-span-12"
          >
            {Row}
          </FixedSizeList>
        ) : (
          <p className="col-span-12">검색결과가 없습니다</p>
        )}
      </div>
    </Profiler>
  )
}

export default SearchSugestions

 

결과

Render: 4389.7ms > 3.6ms (대략 1000배 이상의 속도)

엄청나게 많은 성능차이를 보여주었습니다

컴포넌트를 확인해보니 화면에 보이는 18개의 리스트만 렌더링 된 것이 확인되었습니다

 

렌더링이 많이 일어나서 느린걸까?

현재 input은 controlled방식으로 데이터를 업데이트 하고 있습니다. 
즉, 글자를 하나 입력하고 지울 때마다 렌더링이 발생할 것 입니다. 

그렇다면 숫자를 연속적으로 입력하게 되면 많은 부하가 올 것으로 예상됩니다.

debouncing 기법을 기법을 이용해서 사용자의 입력이 완료될 때까지 기다려주려고 합니다.

다음은 간단하게 구현한 debounce hooks 입니다

import { useEffect, useState } from 'react'

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

 

debouncing 기법을 적용해서 코드를 작성하니 입력을 완료하기 전까지(입력지연이 제가 설정한 delay값인 0.5초를 넘기지 않으면)
렌더링이 되지 않는 것을 확인할 수 있습니다.

이로서 5자리의 숫자를 입력하는 경우 재 렌더링을 5번에서 1번으로 줄일 수 있었습니다.

 

useDeferredValue

useDeferredValue 훅은 상태값 변화에 낮은 우선순위를 지정하는 훅입니다.

https://ko.react.dev/reference/react/useDeferredValue

 

useDeferredValue – React

The library for web and native user interfaces

ko.react.dev

input에 숫자를 입력하게되면 렌더링이 진행되어 input에 입력한 숫자는 보이지 않습니다.
이러한 상황은 사용자 경험에 좋지 않은 영향을 끼칠 것이라고 예상합니다.

useDeferredValue를 사용해서 결과값 렌더링을 지연 시키고 input에 숫자를 먼저 그릴 수 있도록 해주겠습니다.

 

useDeferredValue 적용 코드

import { Suspense, useDeferredValue, useState } from "react"
import { NUMBER_SIZE } from "../components/data"
import SearchSugestions from "../components/search-sugestions"
import useDebounce from "../hooks/use-debounce"

export default function PageDeferredValue() {
  const [query, setQuery] = useState<string>('1')
  const debounceQuery = useDebounce(query, 500)
  const deferredQuery = useDeferredValue(debounceQuery)

  return (
    <div className="w-container p-4 space-y-4">
      <div className="grid gap-2 gap-y-8">
        <h1 className="text-xl font-semibold">factorial ({Number(query)}! ~ {(Number(query) + NUMBER_SIZE - 1).toLocaleString()}!)</h1>
        <input
          type='text'
          className="w-full h-12 border px-2"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="숫자를 입력해주세요"
        />
      </div>
      <Suspense fallback={<>loading...</>}>
        <SearchSugestions query={deferredQuery} />
      </Suspense>
    </div>
  )
}
import { memo, Profiler, use } from "react"
import { FixedSizeList } from "react-window"
import { useProfiler } from "../hooks/use-profiler"
import { fetchData } from "./data"

interface SearchSugestionsProps {
  query: string
}

function SearchSugestions({ query }: SearchSugestionsProps) {
  const { id, onRender } = useProfiler()
  const numbers = use(fetchData(query))

  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className="text-sm px-4">
      {numbers[index].toLocaleString()}
    </div>
  )

  return (
    <Profiler id={id} onRender={onRender}>
      <div className="grid grid-cols-12 gap-y-4">
        {numbers.length > 0 ? (
          <FixedSizeList
            height={600}
            width="100%"
            itemCount={numbers.length}
            itemSize={36}
            className="col-span-12"
          >
            {Row}
          </FixedSizeList>
        ) : (
          <p className="col-span-12">검색결과가 없습니다</p>
        )}
      </div>
    </Profiler>
  )
}

export default memo(SearchSugestions) // memo

 

재 렌더링 사용자 경험 개선하기

현재까지는  query를 업데이트 하게되면 렌더링(데이터 패칭 + 렌더링)이 완료될 때까지 아무 변화가 없습니다

사용자 입장에서는 이 때 화면이 멈춘건지 변하고 있는건지 알 수가 없습니다.
데이터를 테스트하는 저조차도 스트레스를 받았습니다.. 😂

실제 서비스였다면 이탈률이 엄청날 것으로 예상됩니다

query와 deferredQuery의 변화 차이를 이용해서 업데이트를 화면에 보여주겠습니다

코드는 react 공식문서에 있는 코드를 사용했습니다
https://ko.react.dev/reference/react/useDeferredValue

 

useDeferredValue – React

The library for web and native user interfaces

ko.react.dev

 

import { Suspense, useDeferredValue, useState } from "react"
import { NUMBER_SIZE } from "../components/data"
import SearchSugestions from "../components/search-sugestions"
import useDebounce from "../hooks/use-debounce"

export default function PageDeferredValue() {
  const [query, setQuery] = useState<string>('1')
  const debounceQuery = useDebounce(query, 500)
  const deferredQuery = useDeferredValue(debounceQuery)

  const isStale = query !== deferredQuery

  return (
    <div className="w-container p-4 space-y-4">
      <div className="grid gap-2 gap-y-8">
        <h1 className="text-xl font-semibold">factorial ({Number(query)}! ~ {(Number(query) + NUMBER_SIZE - 1).toLocaleString()}!)</h1>
        <input
          type='text'
          className="w-full h-12 border px-2"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="숫자를 입력해주세요"
        />
      </div>
      {/* 업데이트 효과 추가 */}
      <Suspense fallback={<>loading...</>}>
        <div style={{
          opacity: isStale ? 0.3 : 1,
          transition: isStale ? 'opacity 0.2s 0.2s linear' : 'opacity 0s 0s linear'
        }}>
          <SearchSugestions query={deferredQuery} />
        </div>
      </Suspense>
    </div>
  )
}

 

정리

이제는 초기 렌더링 속도를 개선했습니다.
input에 사용자가 입력한 숫자를 바로 볼 수 있게되어 사용자경험을 개선시켰고,
input이 업데이트 될 때 css로 update 효과를 줘서 사용자 경험을 개선시켰습니다.

input의 연속적인 입력에 대한 재 렌더링 횟수도 줄일 수 있었습니다.
또한 windowing 기법을 통해 렌더링의 부하를 줄여 렌더링 시간을 많이 축소시켰습니다.

 

무한한 피드백은 환영입니다.잘못 표현되었거나 오타, 더 개선할 수 있는 점이 있다면 언제든지 댓글로 알려주세요.감사합니다