June 26, 2025
프레임워크 내부에서 직접 DOM 조작하기: 선언적 UI의 파괴 💥
JavaScript
React
성능
아키텍처
컴포넌트
Summary
프레임워크가 관리하는 UI 컴포넌트 내부에서 document.querySelector
와 같은 네이티브 DOM API를 직접 사용하는 것은 예측 불가능한 버그, 심각한 성능 저하, 그리고 유지보수성 저하를 야기합니다. 대신 프레임워크의 상태 관리와 선언적 렌더링 방식을 활용하여 UI를 제어해야 합니다.
Why Wrong?
현대 프론트엔드 프레임워크(React, Vue, Angular 등)는 선언적인(Declarative) 방식으로 UI를 구축하고 관리합니다. 개발자는 UI의 '상태(State)'를 정의하고, 프레임워크가 이 상태를 실제 DOM에 반영하는 역할을 담당합니다. 이를 위해 대부분의 프레임워크는 가상 DOM(Virtual DOM)과 효율적인 Reconciliation 알고리즘을 사용하여 실제 DOM 업데이트를 최소화하고 최적화합니다. 하지만 프레임워크가 관리하는 컴포넌트 내부에서 document.querySelector
, element.appendChild
, element.style.left = '...'
와 같은 네이티브 JavaScript DOM API를 직접 사용하는 것은 이러한 프레임워크의 핵심 원칙을 정면으로 위배합니다.
이는 다음과 같은 심각한 문제를 야기합니다:
- 예측 불가능한 동작: 프레임워크의 가상 DOM과 실제 DOM 간의 불일치가 발생하여 UI가 개발자가 의도하지 않은 방식으로 렌더링되거나, 특정 상호작용에서 예상치 못한 버그가 발생합니다. 예를 들어, React가 가상 DOM을 기반으로 DOM 트리를 비교할 때, 개발자가 직접 변경한 DOM 노드는 React의 추적 범위 밖에 있어 충돌이 발생할 수 있습니다.
- 성능 저하: 프레임워크의 최적화된 DOM 업데이트 로직을 무시하고 직접 DOM을 조작하면 불필요한 Reflow(레이아웃 재계산) 및 Repaint(화면 다시 그리기)가 빈번하게 발생하여 렌더링 성능이 저하됩니다. 특히
offsetTop
,offsetLeft
등 레이아웃을 강제하는 속성 접근이나 DOM 트리를 크게 변경하는 작업은 더욱 치명적입니다. 프레임워크는 변경 사항을 일괄 처리하고 최적화하는 반면, 직접 조작은 즉각적인 강제 업데이트를 유발합니다. - 디버깅의 어려움: UI의 상태가 프레임워크의 내부 상태 관리 시스템과 직접 DOM 조작이라는 두 가지 경로로 변경되기 때문에, 어떤 변경이 어떤 원인으로 발생했는지 추적하기가 매우 어려워집니다. 개발자 도구의 컴포넌트 탭에서 보는 상태와 실제 화면이 일치하지 않는 경우가 발생할 수 있습니다.
- 유지보수성 저하: 프레임워크의 패턴을 따르지 않으면 코드를 이해하고 확장하기가 어려워지며, 팀원 간의 협업 효율성도 떨어집니다. 이는 프레임워크를 사용하는 근본적인 이유인 '생산성과 유지보수성 향상'을 무색하게 만듭니다.
How to Fix?
프레임워크의 강력한 기능을 활용하여 선언적으로 UI를 조작해야 합니다.
- 상태 관리 활용: 컴포넌트의 상태(state)를 변경하여 UI를 업데이트하는 것이 가장 기본적인 원칙입니다. React의
useState
,useReducer
, Redux/MobX와 같은 전역 상태 관리 라이브러리 등을 활용하여 데이터 흐름을 명확하게 하고, UI는 이 상태의 함수로만 표현되도록 합니다. 상태가 변경되면 프레임워크가 자동으로 필요한 DOM 업데이트를 처리합니다. - Refs의 제한적 사용: 특정 DOM 노드에 직접 접근해야 하는 불가피한 경우(예: 외부 라이브러리 연동, 포커스 관리, 특정 애니메이션 제어 등)에는
useRef
(React)와 같은 프레임워크가 제공하는 Refs 기능을 사용합니다. 하지만 Refs는 필요한 최소한의 범위 내에서만 사용해야 하며, UI 상태를 변경하는 목적으로는 사용하지 않아야 합니다. Refs를 통해 DOM에 접근하더라도, 상태와 분리된 DOM 속성(e.g.,scrollLeft
)을 읽거나, 외부 라이브러리 함수를 호출하는 등 제한적인 용도로 활용해야 합니다. - 프레임워크가 제공하는 API 사용: CSS 스타일 변경 시에는 인라인 스타일을
state
로 관리하거나, CSS 모듈, Styled Components와 같은 CSS-in-JS 라이브러리를 사용하여 프레임워크의 렌더링 주기에 통합합니다. 조건부 렌더링이나 목록 렌더링은 프레임워크가 제공하는 JSX/템플릿 문법을 활용하여 상태 변화에 따라 UI가 선언적으로 변경되도록 합니다.
Before Code (Bad)
import React, { useEffect, useRef } from 'react';
function BadComponent() {
const containerRef = useRef(null);
useEffect(() => {
// ⚠️ 안티패턴: 직접 DOM 조작
if (containerRef.current) {
const button = document.createElement('button');
button.textContent = '클릭하세요!';
button.style.backgroundColor = 'red'; // 직접 스타일 변경
containerRef.current.appendChild(button); // 직접 DOM 요소 추가
// ⚠️ 안티패턴: 컴포넌트 라이프사이클 외에서 이벤트 리스너 추가/제거
button.addEventListener('click', () => {
alert('버튼 클릭!');
// ⚠️ 안티패턴: 컴포넌트 상태가 아닌 DOM을 직접 변경
containerRef.current.style.border = '2px solid blue';
});
}
// 클린업 함수에서 직접 추가한 이벤트 리스너 제거 안함 (메모리 누수 가능성)
}, []);
return (
<div ref={containerRef}>
<h2>직접 DOM을 조작하는 컴포넌트</h2>
{/* 이 div 내부에 직접 DOM이 추가될 예정 */}
</div>
);
}
export default BadComponent;
After Code (Good)
import React, { useState } from 'react';
function GoodComponent() {
const [isClicked, setIsClicked] = useState(false);
const [borderColor, setBorderColor] = useState('none');
const handleClick = () => {
setIsClicked(true);
setBorderColor('2px solid blue'); // 상태 업데이트로 스타일 변경 유도
alert('버튼 클릭!');
};
// 렌더링 로직은 상태에 따라 선언적으로 변경
// isClicked 상태에 따라 버튼의 텍스트와 배경색이 변경됨
// borderColor 상태에 따라 div의 border 스타일이 변경됨
return (
<div style={{ border: borderColor }}>
<h2>선언적으로 UI를 관리하는 컴포넌트</h2>
<button
onClick={handleClick}
style={{ backgroundColor: isClicked ? 'lightgreen' : 'red' }} // 상태에 따른 스타일 변경
>
{isClicked ? '클릭되었습니다!' : '클릭하세요!'}
</button>
</div>
);
}
export default GoodComponent;