가상 DOM

가은·2025년 2월 19일
0

가상 DOM?
HTML 문서를 자바스크립트 객체로 모델링한 것
문서 객체 모델, Document Object Modal

실제 DOM은 노드 객체로 구성되지만, 가상 DOM은 일반적인 js 객체로 구성된다.
setState 등으로 UI를 변경하게 되면 가상 DOM에서 먼저 업데이트 한 후 변경 사항에 맞춰 실제 DOM을 업데이트하는데 이 과정을 재조정이라고 한다.

가상 DOM은 자바스크립트 객체로 구성되기 때문에 브라우저나 다른 호스트 환경에 영향을 받지 않고 빠르고 효율적인 접근이 가능하다.
실제 DOM에 필요한 최소한의 변경 사항만을 최적화된 방식으로 적용하기 때문에 성능 영향을 최소화한다.

실제 DOM의 문제점

  1. 성능

엘리먼트의 추가나 제거, 업데이트 등으로 변경이 이루어질 때마다 브라우저는 레이아웃을 다시 계산하고 페이지의 영향을 받는 부분을 다시 그린다. 크고 복잡한 웹 일수록 이 과정은 속도가 느려지고 리소스를 많이 사용할 수 있다.

  • offsetWidth와 같은 레이아웃 속성에 접근할 때 발생하는 리플로우를 방지할 때는 getBoundingClientRect() 메서드를 사용할 수 있다.
    → 한 번의 호출로 여러 레이아웃 속성을 검색해 리플로우 발생 횟수를 줄임 (레이아웃 스래싱 현상 최소화)

리플로우와 리페인팅은 CPU 사용량과 처리 시간을 증가시켜 사용자에게 지연이나 충돌을 일으킬 수 있다.

  1. 브라우저 간 호환성

브라우저 간 호환성에 문제가 있기 때문에 이를 해결하고자 리액트의 합성 이벤트 시스템이 등장했다.
SyntheticEvent는 브라우저의 기본 이벤트를 둘러싼 래퍼 객체로, 안정적인 이벤트 시스템을 제공하여 여러 브라우저 이벤트의 결점과 비일관성을 보완한다.

문서 조각

  • DOM 노드를 저장하는 가벼운 컨테이너

기본 DOM에 영향을 주지 않고 여러가지 업데이트를 수행할 수 있는 저장소처럼 동작한다.
업데이트 작업이 완료되면 문서 조각을 DOM에 추가하는 방식으로 리플로우와 리페인팅을 한 번만 발생시킨다.

문서 조각으로 일괄 업데이트, 메모리 효율성, 중복 렌더링 방지와 같은 성능 이점이 있다.

가상 DOM과의 차이점이라고 하면, 문서 조각은 실제 DOM을 업데이트 하기 전에 변경 사항을 그룹화하여 최적화하는 것이고 가상 DOM은 애플리케이션 UI 전반에 걸쳐 차이점을 파악하고 일괄적으로 업데이트를 처리해 렌더링의 효율성을 극대화한다.

가상 DOM 작동 방식

const element = React.createElement(
  'div',
  { className: 'my-class' },
  'hello'
)

{
  $$typeof: Symbol(react.element)
  type: 'div',
  key: null,
  ref: null,
  props: {
	className: 'my-class',
    children: 'hello',
  },
  _owner: null,
  _store: {}
}  
  • $$typeof: 객체가 유효한 리액트 엘리먼트인지 확인할 때 사용하는 특수한 심벌
  • type: 엘리먼트가 나타태는 컴포넌트의 종류 (사용자 정의 컴포넌트도 그대로 사용됨)
  • ref: 기본 DOM 노드에 대한 참조 요청
  • props: 컴포넌트에 전달된 모든 속성과 프롭을 포함하는 객체
  • _owner: 프로덕션 빌드가 아닐 때 접근 가능한 속성으로, 해당 엘리먼트를 생성한 컴포넌트를 추적하기 위해 리액트 내부적으로 사용 (엘리먼트의 업데이트를 담당할 컴포넌트를 결정하는데 사용)
  • _store: 엘리먼트에 대한 추가 데이터를 저장하기 위해 리액트 내부적으로 사용하는 객체 (상태나 콘텍스트 등을 추적할 때 사용)

효율적 업데이트

컴포넌트의 상태나 프롭이 변경되면 리액트는 업데이트된 사용자 인터페이스를 표현하는 새로운 엘리먼트 트리를 생성한다.
이후 비교 알고리즘을 사용해 새 트리를 이전 트리와 비교해 실제 DOM의 업데이트에 필요한 최소한의 변경 사항을 결정한다.

이 비교는 재귀적으로 이루어지며 어느 부분이 변경되었는지 알아내는 작업을 디핑(diffing)이라고 하고, 이때 사용되는 알고리즘을 디핑 알고리즘이라고 한다.
디핑 알고리즘은 실제 DOM에 적용해야 하는 변경 횟수의 최소화를 목표로 한다.

디핑 알고리즘을 통해 실제 DOM을 효율적으로 업데이트하지만, 불필요한 리렌더링이란 문제가 남아있다.
리액트는 컴포넌트 상태가 변경되면 컴포넌트와 모든 자손 컴포넌트를 리렌더링한다.
컴포넌트가 어느 상태에 종속되는지 알지 못하기 때문에 UI의 일관성을 유지하기 위해 모든 컴포넌트를 리렌더링한다.
불필요한 리렌더링은 성능 문제를 야기하기 때문에 최적화를 자주 실행해야한다.


불필요한 리렌더링을 최적화하는 방법

리렌더링 최적화에 대해 알아보기 전에 react의 화면 업데이트 과정에 대해 먼저 알아보자.

  1. 트리거 단계

트리거는 처음 컴포넌트를 렌더할 때(초기 렌더링), 컴포넌트 또는 부모 컴포넌트의 상태가 업데이트 될 때(리렌더링) 발생된다.

여기서 리렌더링에 해당하는 조건에는

  • 상태가 업데이트된 경우
  • 부모 컴포넌트가 리렌더링된 경우
  • 컨텍스트가 업데이트된 경우
  • 커스텀 훅 내부에서 상태 또는 컨텍스트가 업데이트된 경우가 있다.

컴포넌트의 props가 리렌더링에 영향을 주진 않지만, memo가 된 경우에는 props가 변경이 되어야 리렌더링되며, 부모 컴포넌트가 리렌더링된다면 자식 컴포넌트는 props 관계 없이 리렌더링 된다.

  1. 렌더 단계

렌더 단계에서 react는 컴포넌트를 렌더링하여 화면에 표시할 내용을 파악한다.

초기 렌더링은 루트 컴포넌트를 호출하는 것이지만, 리렌더링의 경우 상태가 업데이트되어 트리거된 함수 컴포넌트를 호출한다.

이 단계에서 이전 렌더링과 현재 렌더링 사이의 변경된 속성을 계산하고, DOM에 업데이트해야 하는 최소한의 변경 사항을 계산하는데 이 과정이 위에서 보았던 재조정 과정이다.

  1. 커밋 단계

컴포넌트를 렌더링한 후 커밋 단계에서 DOM을 수정한다.

커밋 이후 브라우저 렌더링을 거치게 된다.

이러한 단계들을 거치는 컴포넌트의 리렌더링의 최적화는 어떻게 해야할까?

합성 컴포넌트 사용

다음과 같은 패턴은 렌더링 시마다 컴포넌트를 새로 생성하므로 성능이 떨어지는 문제와 상태가 초기화될 수 있다는 문제가 있다.

// 안티 패턴
const Component = () => { // 1. 리렌더링
  const OtherComponent = () => <Something />; // 2. 리렌더링 될 때 마다 함수가 새로 생성되어 컴포넌트를 새로 생성

  return (
    <OtherComponent /> // 3. 다시 마운트
  )
}
//컴포넌트 합성 사용
const OtherComponent = () => <Something />; // 2. (다시 마운트되지 않는)동일한 컴포넌트인 상태

const Component = () => { // 1. 리렌더링

  return (
    <OtherComponent /> // 3. 다시 렌더링됨
  )
}

자식 컴포넌트로 상태 내리기

상태가 렌더 트리의 일부 영역에서만 사용된다면 해당 사용 컴포넌트로 내려서 리렌더링의 부분을 최소화하는 것이 좋다.

children을 props로 받기

상태 관리 및 상태를 사용하는 컴포넌트는 작은 컴포넌트로 추출하고, 내부에 children으로 하위 컴포넌트를 전달하는 방법이다.
추출된 컴포넌트가 받는 children은 props일 뿐이기에 상태 변화에 영향을 받지 않는다.

// 전
const Component = () => {
  const [value, setValue] = useState({}); // 1. 리렌더링 트리거

  return (
    <div onScroll={(e) => setValue(e)}> {/* 1. 리렌더링 트리거 */ }
      <VerySlowComponent /> {/* 2. 리렌더 */}
    </div>
  );
};
// 후
const ComponentWithScroll = ({children}: {children: React.ReactNode}) => {
  const [value, setValue] = useState({}); // 1. 리렌더링 트리거

  return (
    <div onScroll={(e) => setValue(e) }> {/* 1. 리렌더링 트리거 */ }
      {children} {/* 2. props라서 리렌더링 영향 받지 않음 */}
    </div>
  );
};

const Component = () => {

  return (
    <ComponentWithScroll>
      <VerySlowComponent /> {/* 3. 영향 받지 않음 */}
    </ComponentWithScroll>
  );
};

children으로 컴포넌트를 받을 수 없는 경우에는 이 와 동일하게 props로 받는 패턴을 사용할 수 있다.

컨텍스트에서 데이터와 API 분리

컨텍스트가 데이터와 API를 가지고 있다면, 이 둘을 서로 다른 provider로 분리할 수 있다.
분리하면 API를 사용하고 있는 컴포넌트는 데이터가 변경되도 리렌더링되지 않는다.

// 전
const Component = () => {
	const [state, setState] = useState(); // 1. 상태 변경
	
	const value = useMemo(
		() => {
			data: state,
			api: (data) => setState(data),
		}),
		[state]
	);
	
	return (
		<Context.Provider value={value}> // 2. 모든 provider consumer 리렌더
			{children}
		</Context.Provider>
	)
}
// 후
const Component = ({children}) => {
	const [state, setState] = useState(); // 1. 상태 변경
	
	return (
		<DataContext.Provider value={state}>
			<ApiContext.Provider value={setState}> // 2. api provider consumer만 리렌더
				{children}
			</ApiContext.Provider>
		</DataContext.Provider>
	);
}
profile
일이 재밌게 진행 되겠는걸?

0개의 댓글