Native DOM은 진짜 느려요?

@Ryan· January 15, 2025 · 9 min read

"DOM 조작은 느려요" 라는 말은, 프론트엔드 개발자 사이에서는 당연한 것처럼 받아들여지고 있습니다. 이러한 인식 때문에 React와 같은 Virtual DOM 기반 라이브러리 / 프레임워크들이 등장했고, 이 신기술들을 채택하는 주요 근거로 활용되기도 합니다.

하지만, Virtual DOM도 결국 Native DOM에서 파생된 것일텐데, 어떻게 Native DOM이 더 느릴까? 하는 의문이 생겨서, 아주 간단한 실험을 해보았습니다.


실험 설계

다음 두 가지 방식의 실험을 진행해 볼 겁니다.

  1. createElement + appendChild를 활용한 직접 DOM 조작
  2. DocumentFragement를 활용한 일괄 처리 (repaint 최소화를 위함)

다만, 이 실험을 할 때 고려해야할 점이 몇 가지 있습니다.

현대의 브라우저는 화면을 그릴 때, 생각보다 최적화가 잘 되어 있습니다. 무식하게 많은 요소를 쌓을 때에도, 내부적으로 최적화를 수행하여 reflow/repaint를 최소화합니다.

그렇기 때문에, 저는 다음과 같은 트릭을 활용하여 제가 의도하는 바를 명확하게 실험해볼 수 있도록 할겁니다.

  1. DOM 조작 시 추가되는 요소들의 높이를 지정하고, 스타일의 변화를 주어 브라우저가 각 요소마다 스타일을 계산하도록 강제함
  2. 각 요소가 추가될 때 마다 offsetHeight에 접근하여 reflow 강제함
  3. 요소 처리 후 requestAnimationFrame을 활용하여 DOM 업데이트가 완료된 시점을 정확히 캐치

또한, 보다 정확한 수치 도출을 위해 각 테스트를 5번씩 돌리고, 이에 대한 평균값을 구할 것입니다. 이 때, 각 테스트 사이에는 약간의 간격을 주어 각 테스트가 비워질 때 쌓이는 가비지 컬렉터의 영향을 최소화 하였습니다.

간단한 실험임에도, 고려해야할 사항이 정말 많네요.

실험에 사용될 코드는 다음과 같습니다.

  1. createElement + appendChild를 활용한 직접 DOM 조작
const testDOM = (count) => {
	return new Promise((resolve) => {
		const container = document.querySelector('.dom-container');
		container.innerHTML = '';

		for (let i = 0; i < count; i++) {
			const div = document.createElement('div');
			div.innerHTML = `number ${i}`;
			div.style.height = '20px';
			div.style.backgroundColor = i % 2 ? '#f0f0f0' : '#fff';
			container.appendChild(div);
			div.offsetHeight; // 강제로 리플로우 발생
		}

		// 실제 DOM 업데이트 완료를 기다림
		requestAnimationFrame(() => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
	});
};
};
  1. DocumentFragement를 활용한 일괄 처리
const testDocumentFragment = (count) => {
	return new Promise((resolve) => {
		const container = document.querySelector('.dom-container-fragment');
		container.innerHTML = '';

		const fragment = document.createDocumentFragment();
		for (let i = 0; i < count; i++) {
			const div = document.createElement('div');
			div.innerHTML = `number ${i}`;
			div.style.height = '20px';
			div.style.backgroundColor = i % 2 ? '#f0f0f0' : '#fff';
			fragment.appendChild(div);
		}
		container.appendChild(fragment);

		// 실제 DOM 업데이트 완료를 기다림
		requestAnimationFrame(() => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
	});
};
};
  1. 테스트 실행 코드
const runTest = (testFn, count, iterations = 5) => {
  const times = []

  const runIteration = () => {
    const startTime = performance.now()
    testFn(count).then(() => {
      const endTime = performance.now()
      times.push(Number((endTime - startTime).toFixed(2)))

      if (times.length === iterations) {
        const average = (times.reduce((a, b) => a + b, 0) / iterations).toFixed(
          2
        )
        const result = document.getElementById(
          testFn === testDOM ? "result" : "result-fragment"
        )
        result.innerHTML = `Average time (${iterations} runs): ${average}ms<br>
								  All times: ${times.join("ms, ")}ms`
      } else {
        setTimeout(() => runIteration(), 100)
      }
    })
  }

  runIteration()
}

Native DOM 실험

요소를 100개, 1000개, 5000개 순으로 쌓아보겠습니다.

이 결과를 표로 정리하면 다음과 같습니다.

요소의 갯수 직접 삽입 방식 Document Fragment 방식
100 10.48ms 7.86ms
1000 98.04ms 15.44ms
5000 1898.08ms 47.98ms

확연한 차이가 보이네요. 두 방식 모두 요소의 갯수에 따라 작업 시간이 비례하지만, 직접 삽입 방식은 기하급수적으로 상승하는게 눈에 보입니다.

이 결과를 통해, DOM 처리의 속도에 영향을 주는 것은 단순한 요소의 추가가 아니라 reflow/repaint 과정 때문임을 알 수 있습니다.

또한, 많은 요소들을 쌓아야 하는 경우가 있다면 reflow를 최소화하는 document Fragment를 활용하는 것이 유의미한 효과가 있다고 할 수 있겠네요.


Virtual DOM 실험

그렇다면, Virtual DOM을 활용하면 얼마나 빠르게 작업을 완료할 수 있을까요? React를 활용해 같은 방식의 실험을 해보았습니다.

React의 경우 첫 렌더링에는 컴포넌트 초기화가 발생하기 때문에, 이 과정을 제외시키기 위해 6번 반복 후 첫 번째 테스트 결과는 제외하였습니다.

const [times, setTimes] = useState([])
const [virtualDomItems, setVirtualDomItems] = useState([])
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const startTimeRef = useRef(0)
const iterationRef = useRef(0)
const newTimesRef = useRef([])

// DOM 업데이트 완료를 감지하는 useEffect
useEffect(() => {
  if (!isRunning) return

  // 실제 DOM 업데이트가 완료된 후 시간 측정
  requestAnimationFrame(() => {
    const endTime = performance.now()
    const time = (endTime - startTimeRef.current).toFixed(2)
    newTimesRef.current.push(Number(time))

    if (newTimesRef.current.length === 6) {
      // 6번 실행
      setTimes(newTimesRef.current.slice(1)) // 첫 번째 결과 제외
      setIsRunning(false)
      newTimesRef.current = []
      iterationRef.current = 0
    } else {
      setTimeout(() => {
        runIteration()
      }, 100)
    }
  })
}, [virtualDomItems])

const runIteration = useCallback(() => {
  startTimeRef.current = performance.now()

  const newItems = Array.from({ length: count }, (_, i) => ({
    text: `number ${i}`,
    height: "20px",
    backgroundColor: i % 2 ? "#f0f0f0" : "#fff",
  }))

  setVirtualDomItems(newItems)
  iterationRef.current += 1
}, [count])

const runTest = useCallback(() => {
  setIsRunning(true)
  newTimesRef.current = []
  iterationRef.current = 0
  setVirtualDomItems([]) // 초기화

  // 첫 번째 반복 시작
  setTimeout(() => {
    runIteration()
  }, 100)
}, [runIteration])

const average = times.length
  ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(2)
  : 0

테스트는 똑같이 100, 1000, 5000 순서로 돌려보았습니다.

결과 표

요소의 갯수 직접 삽입 방식 Document Fragment 방식 Virtual DOM 방식
100 10.48ms 7.86ms 3.48ms
1000 98.04ms 15.44ms 5.04ms
5000 1898.08ms 47.98ms 23.16ms

Virtual DOM의 압승입니다.


결론

  1. DOM 조작 성능 차이의 주요 원인:
  2. 단순한 DOM 요소 생성이나 추가가 아닌, reflow/repaint 과정이 성능에 가장 큰 영향을 미칩니다.
  3. 직접 DOM 조작 방식에서는 각 요소 추가마다 reflow가 발생하여 요소 수가 증가할수록 기하급수적으로 성능이 저하됩니다.
  4. 최적화 방법별 효과:
  5. DocumentFragment를 사용하면 DOM 업데이트를 일괄처리하여 reflow/repaint 횟수를 크게 줄일 수 있습니다.
  6. Virtual DOM은 실제 DOM 조작을 최소화하고 변경사항을 효율적으로 배치 처리하여 가장 좋은 성능을 보여줍니다.
  7. 시사점:
  8. 소규모 DOM 조작(100개 이하)의 경우 세 방식 모두 실용적인 성능을 보여줍니다.
  9. 대규모 DOM 조작이 필요한 경우, Virtual DOM이나 DocumentFragment를 사용하는 것이 크게 유리합니다.
  10. Virtual DOM 기반 프레임워크의 사용이 항상 필수적인 것은 아니며, DocumentFragment와 같은 기본적인 최적화 기법으로도 상당한 성능 개선을 얻을 수 있습니다.

결론적으로 "DOM 조작은 느리다"는 통념이 맞지만, 그것이 Native DOM 자체의 문제라기보다는 최적화되지 않은 DOM 조작 방식의 문제임을 알 수 있었습니다. 만약 Native DOM과 Virtual DOM 사이에서 고민할 일이 생긴다면, 단순히 요소의 양 보다는 서비스 플로우에 따라 repaint와 reflow가 얼마나 발생할지를 고려해서 선택하는 것이 좋겠습니다!

@Ryan
일단 해보자
© ryanbae.dev, Built with Gatsby