코딩하는 고릴라

리액트 호스트 컴포넌트의 style 프로퍼티는 각 속성값을 모두 비교해 업데이트 여부를 결정한다 본문

React

리액트 호스트 컴포넌트의 style 프로퍼티는 각 속성값을 모두 비교해 업데이트 여부를 결정한다

코릴라입니다 2025. 7. 16. 23:28

하위 컴포넌트로 매번 새로운 style 객체를 만들어 넘겨줬지만, UI가 변하지 않았다.

 최근 컴포넌트 관련 작업을 하다 겪은 상황입니다. 상위 컴포넌트에서는 항상 style 객체를 재생성해서 하위 컴포넌트로 내려주고, 하위 컴포넌트는 prop로 넘어오는 style 객체의 참조가 달라졌으니 리렌더링 되어야 하는데, DOM은 전혀 변하지 않았습니다. 컴포넌트가 리렌더링 됐고, prop으로 넘긴 객체도 새로 만들어졌다면 당연히 DOM에도 변화가 생겨야 한다고 생각했지만 실제로는 그렇지 않았던 것이죠. 이에 대한 이유를 탐구하다 알아낸 리액트의 동작 방식 중 일부를 소개하고자 합니다.

 

리액트 사용시 DOM 직접 조작을 지양해야 하는 이유

 리액트 사용시 DOM을 직접 조작하게 되면 컴포넌트가 설계한 대로 동작하지 않는 경우를 마주할 수 있습니다. 리액트는 컴포넌트를 내부 객체인 Fiber를 통해 관리합니다. Fiber는 컴포넌트가 가진 prop, 상태 등을 관리하며, 이전 상태의 fiber가 가진 prop과 새로운 상태의 fiber가 가진 prop을 얕은 비교 하며 리렌더링의 여부를 판단하는 등 리액트의 핵심 요소입니다. 그러나 직접 DOM에 접근해 요소의 상태를 바꾸는 행위는 fiber의 상태를 변화시키지 못합니다. DOM을 직접 조작하면 HTML 요소가 가진 상태는 변해 UI에 반영된 상태일지라도, fiber는 이 상태를 반영하지 못하고 있는 것입니다. 그렇기에 여기서부터 컴포넌트의 동작을 추적하고 예측하기가 어려워집니다.

 

function App() {
	const [state, setState] = useState(false);

	const style: CSSProperties = {
		width: '100px',
		height: '50px',
		backgroundColor: '#777777',
	};
	console.log('App Rendering');

	function onClick() {
		setState((state) => !state);
	}

	return (
		<div>
			<Rectangular style={style} />
			<Button onClick={onClick} />
		</div>
	);
}
interface RectangularProps {
	style: CSSProperties;
}

function Rectangular({ style }: RectangularProps) {
	console.log('Rectangular Rendering');
	return <div id={'rectangular'} style={style}></div>;
}
interface ButtonProps {
	onClick: MouseEventHandler<HTMLButtonElement>;
}

function Button({ onClick }: ButtonProps) {
	return <button onClick={onClick}>버튼</button>;
}

 

위는 간단한 예시를 보여드리기 위해 작성한 샘플 코드입니다. 

 

 - App 컴포넌트는 단순 리렌더링을 유발하기 위한 state를 가지고 있으며, 리렌더링 될 때 마다 style 객체를 새로 만들어 Rectangular 컴포넌트로 넘겨주고 있습니다. Button 컴포넌트로는 onClick 함수를 넘겨주고 있습니다.

 - Rectangular 컴포넌트는 prop으로 style 객체를 받아 호스트 컴포넌트인 div에 적용시킵니다.

 - Button 컴포넌트는 prop으로 onClick 함수를 받아 클릭시 App 컴포넌트를 리렌더링 시킵니다.

 

위 상태에서 Button을 여러 번 눌러보면, 리렌더링이 원활히 이뤄졌음을 알 수 있는 log가 찍히게 됩니다.

 

그러면, 이제 다음과 같은 버튼을 하나 추가해 보도록 하겠습니다.

function StyleChangeButton() {
	function onClick() {
		const targetElement =
			document.querySelector<HTMLDivElement>('#rectangular');

		if (targetElement) {
			targetElement.style.width = '200px';
			console.log('스타일 변경');
		}
	}
	return <button onClick={onClick}>스타일 변경 버튼</button>;
}

 

 - StyleChangeButton은 DOM에서 id가 rectangular인 요소를 찾은 후, width를 200px로 늘리는 역할을 맡고 있습니다.

 - 리액트 컴포넌트의 상태 변화를 통해 스타일을 변화시키는 것이 아닌, 직접 DOM을 조작하는 방식으로 동작하게 했습니다.

 

 

스타일 변경 버튼 클릭 시, log가 찍힘과 함께 직사각형이 묘하게 길어진 것을 확인할 수 있습니다.

 

개발자 도구에서도 해당 요소의 인라인 스타일 중 width 값이 200px로 늘어난 것을 확인할 수 있었습니다.

 

그럼, 이 상태에서 App 컴포넌트의 리렌더링을 유발하는 버튼을 클릭하면 어떤 일이 일어날 것이라고 예측 할 수 있을까요?

1. App 컴포넌트의 리렌더링이 발생한다.
2. 이 과정에서, style 객체를 새로 만들어 Rectangular 컴포넌트로 내려준다.
3. style 객체는 새로 만들어졌기에 기존에 Rectangular 컴포넌트로 내려준 style 객체와는 다른 참조를 가질 것이며, 리액트는 해당 prop의 얕은 비교를 통해 리렌더링의 대상이라 판단해 리렌더링을 진행한다.
4. 새로 넘어온 style 객체의 width 값은 100px이므로 해당 직사각형의 width가 100px 값을 가지며 다시금 UI가 그려진다.

 

충분히 위와 같은 시나리오를 예상할 수 있습니다.

 

 

하지만 예상과 다른 UI를 보이게 됩니다.  
- 콘솔 로그는 찍힙니다 → 리렌더링은 됐습니다.
- 그러나 width는 여전히 200px로 유지됩니다.

분명 새로 만든 style객체를 넘겼는데, 왜 실제 DOM은 그대로일까요?

 

이에 대한 해답은 아래 코드에서 찾을 수 있었습니다.

 

react/packages/react-dom-bindings/src/client/CSSPropertyOperations.js at d85ec5f5bd778d09214e3429e7fd043c4a152242 · facebook/re

The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

github.com

 

export function setValueForStyles(node, styles, prevStyles) {
  // 생략...

  const style = node.style;

  if (prevStyles != null) {
    // 생략... 

    for (const styleName in prevStyles) {
      if (
        prevStyles.hasOwnProperty(styleName) &&
        (styles == null || !styles.hasOwnProperty(styleName))
      ) {
        // Clear style
        const isCustomProperty = styleName.indexOf('--') === 0;
        if (isCustomProperty) {
          style.setProperty(styleName, '');
        } else if (styleName === 'float') {
          style.cssFloat = '';
        } else {
          style[styleName] = '';
        }
        trackHostMutation();
      }
    }
    for (const styleName in styles) {
      // 이전 style의 값과 새로 넘겨준 style의 값을 프로퍼티를 순회하며 비교. 다를때만 업데이트
      const value = styles[styleName];
      if (styles.hasOwnProperty(styleName) && prevStyles[styleName] !== value) {
        setValueForStyle(style, styleName, value);
        trackHostMutation();
      }
    }
  } else {
    for (const styleName in styles) {
      if (styles.hasOwnProperty(styleName)) {
        const value = styles[styleName];
        setValueForStyle(style, styleName, value);
      }
    }
  }
}

 

 

리액트는 호스트 컴포넌트에 style 객체를 넘겨 CSS를 적용할 때, 단순히 style 객체의 참조값이 달라졌는지 확인해 업데이트 여부를 결정하지 않고 있었습니다. 실제 각 CSS 속성값을 하나하나 비교해 값이 변경되었을 때만 DOM을 업데이트하고 있었습니다.


 

DOM을 직접 조작하는 부분이 UI는 변화시켰으나 Fiber가 가진 prop의 style은 변화시키지 못하였으며, 이 때문에 이전 style과 새 style의 CSS 속성값을 비교하는 과정에서 모든 속성의 값이 동일하게 판단되어 DOM에는 아무런 업데이트도 일어나지 않았습니다.

- 특정 소스에서 컴포넌트의 상태가 아닌 DOM의 인라인 스타일을 직접 조작하고 있던 점
- style 객체로 CSS 설정 시, 모든 속성값을 직접 비교하는 리액트의 동작을 몰랐던 점

 

위 두 요인이 겹쳐 컴포넌트의 동작 추적이 어려웠던 상황을 공유드려봤습니다.

 

🦍 정리하며

이번 경험을 통해 아래와 같은 내용을 배울 수 있었습니다.

- 리액트 호스트 컴포넌트에 style 객체를 넘겨 직접 CSS를 지정할 때, 리액트는 객체의 참조를 비교하지 않고 각 CSS 속성값을 직접 비교해 변경점을 반영한다.
- DOM을 직접 조작하면 React의 Fiber 트리에 반영되지 않아 예측하지 못한 결과가 발생할 수 있다.

 

특히 리액트를 사용하며 DOM을 직접 조작하면 추적이 어렵다는 내용을 이론적으로는 알았으나 몸소 경험하며 왜 직접적인 DOM 조작을 지양하는지 체감할 수 있었습니다.
이와 같은 리액트의 렌더링 메커니즘을 정확히 이해하면, 우리가 만든 컴포넌트가 왜 예상과 다르게 동작하는지 더 쉽게 추적할 수 있을 것 같다는 생각이 들었습니다.

반응형

'React' 카테고리의 다른 글

[React] 메모이제이션 알아보기  (1) 2024.03.26
[React] Lifecycle, useEffect  (0) 2023.11.29
[React] styled-components  (1) 2023.11.28
[React] URL parameter  (1) 2023.11.27
[React] navigate, nested route, outlet  (0) 2023.11.27