❓DOM이란
브라우저의 렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 각각 DOM과 CSSOM을 생성하고, 렌더트리로 결합된다. 이 때 생성된 DOM은 HTML을 파싱한 결과물이고, HTML의 모든 요소가 노드 객체로 변환되어 객체들을 트리구조로 구성한 것을 DOM이라고 한다.
1️⃣ HTML 요소와 노드 객체
DOM은 HTML의 모든 요소가 노드 객체로 변환되어 트리구조를 이루는 것이라고 설명했고, HTML의 요소들은 위와 같이 구성되어 있다.
이 때 시작 태그와 종료 태그 사이에는 텍스트 뿐만 아니라 다른 HTML 요소도 포함될 수 있고 따라서 HTML 요소 간에는 중첩 관계를 통한 계층적인 부자 관계가 형성된다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<ul id="fruit">
<li id="apple">apple</li>
<li id="grape">grape</li>
<li id="tomato">tomato</li>
</ul>
</body>
</html>
다음과 같은 HTML 파일이 존재한다고 가정해보자.
렌더링 엔진은 해당 HTML 파일을 파싱하여 DOM을 생성하고, 생성된 DOM은 아래 그림과 같은 구조를 갖는다.
위처럼 DOM은 계층적인 구조로 생성되고, HTML 요소 사이의 공백, 개행은 텍스트 노드가 되기 때문에, 실제로는 위의 DOM 구조에서 공백 텍스트 노드가 포함되어 있어야한다. 각각의 노드 객체는 타입이 있는데 중요한 노드 타입은 4가지로 나눌 수 있다.
🔘 문서 노드: DOM 트리의 최상위인 루트 노드로 document 객체를 가리킨다. 따라서 DOM 트리의 노드들에 접근하기 위해서는 문서 노드를 통해야 한다.
🔘 요소 노드: HTML 요소를 가리키는 객체다.
🔘 어트리뷰트 노드: HTML 요소의 어트리뷰트를 가리키는 객체로 요소 노드에만 연결되어 있다.
🔘 텍스트 노드: HTML 요소의 텍스트를 가리키는 객체로 DOM 트리의 최종단이다.
2️⃣ 노드 객체의 상속
DOM은 HTML 문서를 파싱하여 계층적인 구조로 생성된다고 했다. 추가적으로 DOM은 이를 제어할 수 있는 API를 제공한다. 따라서 DOM을 구성하는 노드 객체들은 DOM API를 사용하는 것이 가능하고, 자신의 부모, 형제, 자식 노드를 탐색하거나 자신의 어트리뷰트, 텍스트를 조작하는 것이 가능하다.
DOM을 구성하는 노드 객체는 빌트인 객체가 아니라 브라우저 환경에서 제공하는 호스트 객체지만 자바스크립트 객체인 것은 똑같다. 따라서 노드 객체들도 다음과 같이프로토타입에 의한 상속 구조를 갖는다.
위의 코드의 ul 태그는 Object > EventTarget > Node > Element > HTMLElement > HTMLUListElement 로 프로토타입 체인이 이루어져있고, 따라서 ul 요소 노드 객체는 해당 프로토타입 체인 상에 존재하는 모든 프로퍼티 또는 메서드를 상속받아 사용하는 것이 가능하다.
3️⃣ DOM API
DOM은 HTML 문서를 계층적인 구조로 나타내고 정보를 표현하는 것과 더불어 각 노드 객체의 타입에 따라 DOM API를 사용하여 HTML의 구조나 스타일 등을 동적으로 조작하는 것이 가능하다. 모든 DOM API를 소개하는 것 대신 중요한 개념 또는 메서드 몇개를 소개해보고자 한다.
요소 노드 획득
텍스트 노드 또는 어트리뷰트 노드 모두 요소 노드와 연결되어 있기 때문에 동적으로 HTML의 구조 또는 내용을 조작하기 위해서는 가장 먼저 요소 노드를 획득해야 한다. 요소 노드 획득의 대표적인 메서드는 다음과 같다.
Document.prototype.getElementById
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<ul id="fruit">
<li id="apple">apple</li>
<li id="grape">grape</li>
<li id="tomato">tomato</li>
</ul>
<script>
const $elem = document.getElementById("fruit");
console.log($elem);
</script>
</body>
</html>
id 어트리뷰트 값을 통해 단 하나의 요소 노드를 반환하고 인수로 전달된 id 어트리뷰트 값이 존재하지 않는다면 null을 반환한다.
Document.prototype.querySelector(All) / Element.prototype.querySelector(All)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<ul id="fruit">
<li id="apple">apple</li>
<li id="grape">grape</li>
<li id="tomato">tomato</li>
</ul>
<script>
const $elem = document.querySelectorAll("ul > li");
console.log($elem); // NodeList(3) [li#apple, li#grape, li#tomato]
</script>
</body>
</html>
querySelector은 인수로 전달한 css 선택자를 만족하는 하나의 요소 노드를 반환하는 반면 querySelectorAll은 모든 요소 노드를 반환한다.
이 때 여러개의 노드는 DOM 컬렉션 객체인 NodeList 객체를 반환하게 된다.
요소 노드를 취득하는 경우 한 가지 주의해야 할 점은 위의 querySelectorAll 또는 getElementsByClassName등의 DOM API를 사용하여 여러개의 결과 값이 반환되는 경우 결과 값은 HTMLCollection 또는 NodeList라는 유사 배열 객체이면서 이터러블에 담겨 반환된다. 이 HTMLCollection 또는 NodeList는 노드 객체의 상태변화를 실시간으로 반영한다. 아래 코드를 보자.
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
</head>
<body>
<ul id="fruit">
<li class="red">apple</li>
<li class="red">grape</li>
<li class="red">tomato</li>
</ul>
<script>
const $elems = document.getElementsByClassName("red");
console.log($elems);
for (let i = 0; i < $elems.length; i++) {
$elems[i].className = "blue";
}
</script>
</body>
</html>
getElementsByClassName 메서드를 통해 HTMLCollection에 클래스 이름이 red인 요소들이 담겨있는 객체를 반환한다. 이어서 for 문으로 HTMLCollection 객체에 포함된 요소들의 클래스를 blue로 바꿔주는 작업을 한다. 이 때 모든 요소의 클래스가 정상적으로 교체되었는지 확인해보면 다음 그림과 같이 정상적으로 교체되지 않은 것을 확인할 수 있다.
HTMLCollection 객체는 상태 변화를 실시간으로 반영하기 때문에 for 문을 통해 첫 번째 요소가 blue 클래스 값을 가지면 $elems인 HTMLCollection 객체는 두개의 요소만 갖게된다. 이후 변수 i의 값이 0을 지나 1이 되었을 때 참조하게되는 $elems[1]의 값은 기존 $elems[2]과 동일하다.
따라서 위와 같이 HTMLCollection 또는 NodeList 객체의 요소들의 상태를 순회하면서 변경할 때의 부작용을 피하기 위해서는 해당 객체들을 배열로 변환하여 사용하는 것이 좋다. HTMLCollection, NodeList 객체 모두 유사 배열 객체이면서 이터러블이기 때문에 스프레드 문법 또는 Array.from 메서드를 사용하여 배열로 변환하여 사용한다.
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
</head>
<body>
<ul id="fruit">
<li class="red">apple</li>
<li class="red">grape</li>
<li class="red">tomato</li>
</ul>
<script>
const $elems = document.getElementsByClassName("red");
[...$elems].forEach((elem) => {
elem.className = "blue";
});
</script>
</body>
</html>
요소 탐색
취득한 요소 노드를 기점으로 해당 노드의 부모, 형제, 자식 노드를 탐색해야하는 경우에 사용한다. 부모, 형제, 자식 노드를 탐색하는 각각의 메서드가 존재하지만 이 부분에서는 Node.prototype의 요소 탐색 메서드를 사용하는지 또는 Element.prototype의 요소 탐색 메서드를 사용하는지에 따라 차이점이 존재한다.(부모 노드 탐색 제외)
위에서 언급했듯이 HTML 문서를 파싱할 때 요소 사이의 스페이스, 탭, 줄바꿈 등의 공백은 공백 텍스트 노드가 생성된다. 따라서 노드를 탐색하는 경우 Node.prototype의 요소 탐색 메서드를 사용하는 경우 탐색 대상 중 공백 텍스트 노드가 포함될 수 있고, Element.prototype의 요소 탐색 메서드를 사용하는 경우에는 탐색 대상 중 요소 노드만 탐색이 가능하다.
DOM 조작
새로운 노드를 생성하여 기존 DOM에 추가하거나 기존에 존재하던 노드를 삭제 또는 교체하는 것으로 리플로우와 리페인트가 발생할 수 있다.
요소 노드의 HTML 마크업을 취득해 DOM을 조작하는 방법인 innerHTML 메서드는 간단하게 DOM 조작이 가능하지만 XSS 공격에 취약하기 때문에 지양하도록 한다. 대신 insertAdjacentHTML 또는 노드를 직접 생성/삽입/삭제하는 방법을 사용한다.
insertAdjacentHTML
Element.prototype.insertAdjacentHTML 메서드는 두 번째 인수로 전달한 HTML 마크업 문자를 파싱해서 결과적으로 생성된 노드를 메서드의 첫 번째 인수로 전달한 위치에 삽입하여 DOM에 반영한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<!-- beforebegin -->
<ul id="fruit">
<!-- afterbegin -->
<li id="apple">apple</li>
<li id="grape">grape</li>
<li id="tomato">tomato</li>
<!-- beforeend -->
</ul>
<!-- afterend -->
<script>
const $foo = document.getElementById("fruit");
$foo.insertAdjacentHTML("end", '<li id="peach">peach</li>');
</script>
</body>
</html>
기존 요소에는 영향을 미치지 않고 취득한 요소를 기준으로 원하는 포지션에 삽입되길 원하는 요소만을 파싱하여 추가하기 때문에 innerHTML 보다 효율적이고 빠르지만 HTML 마크업 문자열을 파싱하기 때문에 여전히 XSS 공격에는 취약하다.
DOM 직접 조작
🔘 createElement: 인수로 전달하는 태그이름을 통해 해당 요소 노드를 생성하여 반환한다.
🔘 createTextNode: 인수로 전달하는 문자열을 통해 텍스트 노드를 생성하여 반환한다.
🔘 appendChild: 인수로 전달하는 노드를 appendChild 메서드를 호출한 노드의 마지막 자식 노드로 추가한다.
🔘 insertBefore: 첫 번째 인수로 전달받은 노드를 두 번째 인수로 전달받은 노드 앞에 삽입한다. 이 때 두 번쨰 인수로 전달받은 노드는 반드시 insertBefore 메서드를 호출한 노드의 자식노드여야 한다.
🔘 replaceChild: 두 번째 인수로 전달받은 노드를 첫 번째 인수로 전달받은 노드로 교체한다. 두 번째 인수로 전달한 oldChild 노드는 replaceChild 메서드를 호출한 노드의 자식노드여야 한다.
🔘 removeChild: 인수로 전달한 노드를 DOM에서 삭제한다. 인수로 전달한 노드는 removeChild 메서드를 호출한 노드의 자식노드여야 한다.