이미지 출처: https://www.quora.com/
리액트는 UI를 효율적으로 관리하기 위해 가상 DOM(Virtual DOM)과 상태(state) 기반 업데이트 방식을 사용한다. 하지만 가끔 개발자들이 직접적으로 DOM에 접근하여 조작하려는 경우가 발생한다. 이러한 방식은 리액트의 철학에 어긋나며, "안티 패턴"으로 불린다. 이 문서에서는 왜 직접적인 DOM 접근이 안티 패턴으로 간주되는지 그 이유를 설명한다.
리액트는 상태(state)나 속성(props)의 변화를 기반으로 가상 DOM에서 비교(diffing) 작업을 수행하고, 변경된 부분만 실제 DOM에 반영한다. 직접적으로 DOM을 수정하면 가상 DOM과 실제 DOM의 동기화가 깨지며, 리액트가 예기치 않은 동작을 할 수 있다.
리액트는 가상 DOM을 사용하여 변경 사항을 효율적으로 관리하고, 실제 DOM에 최소한의 업데이트만 수행한다. 직접 DOM을 조작하면 리액트의 이런 최적화가 무효화되어, 전체 성능 저하를 초래할 수 있다. 가상 DOM과 실제 DOM의 불일치가 생기면 리액트가 이를 파악하지 못하고 불필요한 렌더링을 일으킬 수 있다.
상태는 리액트 컴포넌트의 렌더링을 제어하는 주요 매커니즘이다. 리액트는 컴포넌트의 상태를 관리하여 UI를 자동으로 갱신한다. 상태와 UI는 일관성을 유지하며, 이로 인해 개발자는 상태만 변경하면 UI가 자동으로 변경된다는 보장을 받을 수 있다. 그러나 상태 변화에 의존하는 대신 직접적으로 DOM을 조작하면 상태와 실제 DOM이 불일치하는 상황이 발생할 수 있다. 이는 디버깅을 복잡하게 만들고, 리액트의 의도된 동작을 방해하게 된다.
리액트의 컴포넌트는 재사용 가능해야 하며, 이를 위해서는 컴포넌트가 특정 DOM 구조에 의존하지 않아야 한다. 하지만 직접적인 DOM 접근은 해당 컴포넌트가 특정 DOM 요소나 구조에 종속되게 만들어 재사용성을 떨어뜨린다. DOM 구조가 변경되면 관련된 모든 코드를 수정해야 하며, 이는 유지보수를 어렵게 만든다.
리액트 컴포넌트에서 입력 필드의 값을 수동으로 DOM을 통해 수정하는 경우를 가정해보겠다. 이 예시는 useEffect
에서 document.querySelector
를 사용하여 DOM에 직접 접근하여 입력 필드 값을 수정하는 코드이다.
import { useEffect, useState } from 'react';
function MyComponent() {
const [inputValue, setInputValue] = useState('');
useEffect(() => {
// toggle 값이 false일 때 DOM에 직접 접근하여 입력 필드 값을 수정
if (!inputValue) {
document.querySelector('input').value = '직접 설정된 값';
}
}, [inputValue]);
return (
<div>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
</div>
);
}
위 코드에서 useEffect
내부에서 document.querySelector('input')
을 통해 DOM에 직접 접근하여 값을 설정하고 있다. 이는 다음과 같은 문제를 일으킬 수 있다.
inputValue
상태를 기반으로 <input>
필드의 값을 관리하려고 하지만, document.querySelector
로 직접 DOM에 접근하여 값을 설정하면 가상 DOM과 실제 DOM이 일치하지 않게 된다.input
필드의 값은 inputValue
상태에 의존하는데, DOM을 직접 수정하면 리액트는 이를 감지하지 못해 UI가 원하는 대로 동작하지 않을 수 있다.즉, 값이 제대로 동기화되지 않거나, 예기치 않은 렌더링 결과가 발생할 수 있다.
위 코드를 실행한 후 입력 필드에 값을 입력해도, 가상 DOM에서 관리하는 inputValue
와 실제 DOM의 값이 일치하지 않게 되어, 사용자가 입력한 값이 올바르게 반영되지 않을 수 있다.
useEffect
가 실행되고, input
필드의 값이 '직접 설정된 값'으로 강제로 설정된다.inputValue
에 반영되지 않는다. 왜냐하면 리액트는 input
요소의 값을 상태를 통해 관리하는 대신, DOM에서 직접 값을 변경했기 때문이다.리액트는 상태를 기준으로 렌더링을 제어하는데, DOM을 직접 수정하면 리액트의 상태 변화와 일관성이 깨져서 UI가 예상과 다르게 동작할 수 있다.
inputValue
가 비어있는 상태라면 <input>
요소의 value
는 빈 문자열이 되어야 하지만, DOM 조작으로 인해 '직접 설정된 값'으로 변경된다.ref
를 통한 대안리액트는 DOM에 접근해야 하는 특정 상황에 대한 대안으로 ref
를 제공한다. ref
를 사용하면 리액트의 가상 DOM 흐름을 유지하면서도 필요한 경우 DOM에 직접 접근할 수 있다. 이는 리액트가 DOM 조작을 추적할 수 있게 하며, 가상 DOM의 일관성을 유지할 수 있게 한다.
전통적인 자바스크립트에서의 document.getElementById
와 리액트의 useRef
사용을 비교해보면, useRef
는 리액트의 가상 DOM을 유지하면서 DOM 접근을 가능하게 해준다. 리액트에서 제공하는 useRef
를 통해 DOM에 접근하면, 리액트의 상태 관리와 가상 DOM 최적화 이점이 유지된다.
useRef
가 DOM 핸들러를 어떻게 반환하는지 알기 위해 document.getElementById
와 useRef
를 간단히 비교해보자.
import {useRef} from 'react'
function App() {
const buttonRef = useRef(null)
return (
<div className="App">
<header className="App-header">
<button
ref={buttonRef}
onClick={_ => console.log(buttonRef)}
id='sample_button'> onClick </button>
</header>
</div>
);
}
export default App;
위 코드의 버튼을 클릭하면 useRef가 해당 요소의 참조를 저장하므로 아래와 같은 속성 목록을 반환한다.
그러면 이제 순수 자바스크립트의 방식으로 ID 참조를 통해 버튼을 타겟팅해 보자. 그러면 ref
와 document.getElementById
가 모두 같은 값을 반환한다는 것을 알 수 있다.
즉, React에서는 DOM 참조를 직접 사용할 필요가 없다.
import { useState } from 'react';
function MyComponent() {
const [inputValue, setInputValue] = useState('');
return (
<div>
<input
type="text"
value={inputValue || '직접 설정된 값'}
onChange={(e) => setInputValue(e.target.value)}
/>
</div>
);
}
inputValue
상태가 빈 문자열일 때 기본값으로 '직접 설정된 값'을 표시한다.inputValue
상태를 기반으로 설정되며, 사용자가 입력하는 값은 onChange
이벤트를 통해 상태에 반영된다.