🪂

[JS] DOM 트리


DOM(Document Object Model, 문서 객체 모델)은 웹 페이지 내의 모든 콘텐츠를 객체로 나타내준다.

브라우저의 렌더링 엔진은 웹 문서를 로드한 후 모든 요소와 요소의 프로퍼티, 텍스트들을 각각의 객체로 만들고 이들 객체를 부자 관계로 표현한 트리구조를 만든다. 이 트리구조가 DOM이다. DOM은 자바스크립트를 통해 동적으로 변경할 수 있으며 변경된 DOM은 렌더링에 반영된다.

정적인 웹페이지에 접근하여 동적으로 웹페이지를 변경하기 위한 유일한 방법은 DOM을 변경하는 것이고, 이때 필요한 것이 DOM에 접근하고 변경하는 프로퍼티와 메소드이다(DOM API).

DOM 트리

DOM은 HTML을 '객체의 트리'로 구조화한다.

1<!DOCTYPE html>
2<html>
3 <head>
4 <meta charset="utf-8">
5 <title>Simple DOM example</title>
6 </head>
7 <body>
8 <section>
9 <img src="dinosaur.png" alt="A red Tyrannosaurus Rex: A two legged dinosaur standing upright like a human, with small arms, and a large head with lots of sharp teeth." />
10 <p>Here we will add a link to the <a href="https://www.mozilla.org/">Mozilla homepage</a></p>
11 </section>
12 </body>
13</html>

DOM 트리의 노드 종류는 총 12가지인데, 보통 4가지만 주로 다룬다.

  • 문서(document) 노드: 트리의 최상위에 존재한다. DOM에 접근하기 위한 진입점이다.
  • 요소 노드(element node): HTML 태그를 표현하고 DOM 트리를 구성한다.
  • 프로퍼티 노드(attribute node): HTML 태그의 프로퍼티을 표현한다.
  • 텍스트 노드: HTML 요소의 텍스트를 표현한다. 텍스트 노드는 자식 노드를 가질 수 없다. 즉, DOM tree의 최종단이 된다.

참고로 HTML 내부의 주석또한 '주석 노드'로 DOM에 표현된다. HTML 안의 모든 것은 DOM을 구성한다.

위에 DOM 구조를 보면 빈 텍스트 노드들이 있다. 새 줄과 공백은 글자나 숫자처럼 유효한 문자로 취급된다. 위 HTML을 보면 <head><meta>사이에 새 줄과 약간의 공백이 있는데, 이런 것들이 텍스트 노드가 된 것이다. 빈 텍스트 노드들을 지우고 싶다면 모든 띄어쓰기와 들여쓰기를 지워 한 줄로 만들면 된다.

텍스트 노드는 <head> 이전의 공백이나 새 줄</body> 뒤에 공백을 제외한 모든 것을 텍스트 노드로 만든다.

DOM 탐색하기

트리 상단

DOM 트리 상단의 노드들은 document가 제공하는 프로퍼티를 사용해 접근할 수 있다.

  • document.documentElement: <html> 태그에 해당한다. document를 제외하면 DOM 트리 가장 상단에 있다.
  • document.body: <body> 태그에 해당하는 DOM 노드다.
  • document.head: <head> 태그에 해당한다.

자식노드 탐색하기

  • 자식 노드: 바로 아래의 자식 요소를 나타낸다. <html>의 자식 노드는 <head><body>다.
  • 후손 노드: 중첩 관계에 있는 모든 요소를 의미한다.

childNodes는 모든 자식 노드를 담고 있다(텍스트 노드 포함).

아래 예시는 document.body의 모든 자식 노드를 출력한다.

1<html>
2<body>
3 <div>시작</div>
4
5 <ul>
6 <li>항목</li>
7 </ul>
8
9 <div></div>
10
11 <script>
12 for (let i = 0; i < document.body.childNodes.length; i++) {
13 alert( document.body.childNodes[i].nodeName );
14 }
15 // -> #text, DIV, #text, UL, ... , SCRIPT
16 </script>
17 ...추가 내용...
18</body>
19</html>

스크립트 아래 "...추가 내용..."이라는 내용이 있지만 출력되지 않는다. 스크립트 실행 시점엔 브라우저가 추가 내용은 읽지 못한 상태이기 때문이다.

childNodes는 배열 같아 보이지만, 이터러블 유사 배열 객체인 컬렉션(collection)이다. 컬렉션은 for..of를 사용할 수 있지만 배열이 아니기 때문에 배열 메서드를 사용할 수 없다. 하지만 배열 메서드를 사용하고 싶을 땐, Array.from을 사용해 진짜 배열로 만들어 사용할 수 있다.

firstChildlastChild 프로퍼티를 이용하면 첫 번째, 마지막 자식 노드에 빠르게 접근할 수 있다. childNodes나 방금 소개한 프로퍼티같은 탐색용 프로퍼티들은 읽기 전용이다. = 연산을 이용해 자식노드를 교체할 수 없다. DOM을 변경하려면 다른 메서드가 필요하다.

자식 노드의 존재 여부를 검사할 수 있는 elem.hasChildNodes() 메서드도 존재한다.

형제와 부모 노드

다음 형제 노드에 대한 정보는 nextSibling, 이전 형제 노드에 대한 정보는 previousSibling 프로퍼티에서 찾을 수 있다. <body><head>의 '다음'에 있는 형제 노드이고, <head><body> '이전'에 있는 형제 노드다.

부모 노드에 대한 정보는 충분히 예상가게도 parentNode 프로퍼티을 이용해 참조할 수 있다.

요소 간 이동

지금까지 언급한 탐색 관련 프로퍼티는 모든 종류의 노드를 참조한다. 하지만 보통 실전에서는 텍스트 노드나 주속 노드는 잘 다루지 않는다. 요소 노드만 다루기 위해서는 앞과 유사하지만 다른 프로퍼티들을 사용하면 된다.

  • children 프로퍼티는 자식 노드 중 요소 노드만을 가리킨다.
  • firstElementChild, lastElementChild는 각각 첫 번째 자식 요소 노드와 마지막 자식 요소 노드를 가리킨다.
  • previousElementSibling, nextElementSibling도 아까 본 것과 비슷하지만 요소 노드만 가리킨다.
  • parentElement는 부모 요소 노드를 가리킨다. 부모가 요소가 아니면 null을 반환한다.

노드 검색하기

DOM에서 원하는 노드를 검색하게 해주는 주요 메서드 6가지는 아래와 같다.

메서드검색 기준호출 대상이 요소가 될 수 있는지컬렉션 갱신 여부
querySelectorCSS 선택자-
querySelectorALLCSS 선택자-
getElementByIdiddocument에서만 가능-
getElementByNamenamedocument에서만 가능
getElementByTagNametag나 '*'
getElementByClassNameclass

컬렉션 갱신을 한다는 의미는 문서에 변경이 있을 때마다 컬렉션이 자동 갱신되어 최신 상태를 유지한다는 의미다. 배열을 참조 복사했을 때 원본이 변경되면 복제본도 변경되는 것처럼 말이다.

querySelector*의 CSS 선택자에는 가상 클래스(:hover, :active 등)도 사용할 수 있다.

1<div>첫 번째 div</div>
2
3<script>
4 let divs = document.getElementsByTagName('div');
5 alert(divs.length);
6 // -> 1
7</script>
8
9<div>두 번째 div</div>
10
11<script>
12 alert(divs.length);
13 // -> 2
14</script>

여기서 getElementByTagName 대신 querySelector를 사용하면, 두 스크립트가 동일하게 1을 출력한다.

이 외에 알아두면 좋을 메서드는 아래와 같다.

  • elem.matches(css): 해당 CSS 선택자와 일치하는지 여부를 검사한다.
  • elem.closest(css): 해당 CSS 선택자와 일치하는 가장 가까운 조상 요소를 탐색한다. elem 자신도 검색 대상에 포함된다.
  • elemA.contains(elemB): elemAelemB가 속하거나 둘이 같다면 참을 반환한다.

DOM 노드

DOM 노드는 종류에 따라 각각 다른 프로퍼티를 지원한다. 하지만 모든 DOM 노드는 공통 조상으로부터 만들어지기 때문에 공통된 프로퍼티와 메서드가 존재한다.

DOM 노드는 종류에 따라 대응하는 내장 클래스가 다르다. 계층 구조 꼭대기엔 EventTarget이 있고, Node는 EventTarget을, 다른 DOM 노드들은 Node 클래스를 상속받는다.

각 클래스는 다음과 같은 특징을 갖는다.

  • EventTarget: 루트에 있는 추상 클래스다. EventTarget이 모든 DOM 노드의 조상이기 때문에 DOM 노드에서 '이벤트'를 사용할 수 있다.
  • Node: 추상 클래스로, DOM 노드의 베이스 역할을 한다. 추상 클래스이기 때문에 Node 클래스의 객체는 생성되지 않지만, 이 클래스를 여러 클래스에서 상속받는다. getter 역할을 하는 parentNode, nextSibling, childeNodes 등의 주요 '트리 탐색 기능'을 제공한다.
  • Element: DOM 요소를 위한 베이스 클래스다. 앞서 봤던 '요소 전용 탐색'을 도와주는 프로퍼티나 메서드(querySelector, children...) 등이 제공된다.
  • HTMLElement: HTML 요소 노드의 베이스 역할을 하는 클래스다. HTMLElement 클래스를 상속받는 것들은 실제 HTML 요소에 대응된다.

주요 노드 프로퍼티

  • nodeType: 요소 타입을 알고 싶을 때 사용할 수 있는 읽기 전용 프로퍼티다. 요소 노드는 1, 텍스트 노드는 3을 반환한다. 각 노드 타입에 대응되는 상숫값이 있다.

  • nodeName/tagName: 요소 노드의 태그 이름을 알아날 때 사용하는 읽기 전용 프로퍼티다.

  • innerHTML: 요소 안의 HTML을 알아내거나 수정할 수 있다.

elem.innerHTML += "..."를 사용하면 요소에 HTML을 추가할 수 있다. 그런데 += 연산은 조금 특이하게 동작하기 때문에 주의해야 한다. 결과적으로 원래 HTML에 추가하는 것 같아 보이지만, 기존 내용을 삭제한 후 기존 내용과 새로운 내용을 합친 새로운 내용을 쓰게 된다. 즉, 기존 내용이 완전히 삭제된 후 밑바닥부터 다시 내용이 쓰여지기 때문에 이미지 등의 리소스가 전부 다시 로딩된다.

  • outerHTML: 요소의 전체 HTML을 알아낼 수 있다.

  • nodeValue/data: 요소가 아닌 노드(텍스트, 주석 노드 등)의 내용을 읽거나 쓸 때 쓰인다. 두 프로퍼티는 거의 동일하게 동작한다.

  • textContent: HTML에서 모든 텍스트를 읽을 때 사용한다. 할당 연산을 통해 무언가 쓸 수도 있는데, 이때 태그를 포함해도 모두 문자열로 처리된다.

  • hidden: true로 설정하면 CSS에서 display: none을 설정한 것과 동일하다.

속성(attribute)과 프로퍼티

요소 노드에서 대부분의 표준 HTML 속성은 DOM 객체의 프로퍼티가 된다.

하지만, 속성과 프로퍼티가 항상 일대일로 매핑되지는 않는다. 한 번 알아보자.

HTML 속성

HTML에서 태그는 여러 개의 속성을 가질 수 있다. 브라우저는 HTML을 파싱해 DOM 객체로 만들 때 HTML '표준 속성'을 인식해 DOM 프로퍼티를 만든다.

따라서 요소의 표준 속성은 이에 해당하는 프로퍼티가 자연스럽게 만들어지지만, 비표준 속성은 이에 매핑하는 DOM 프로퍼티가 생성되지 않는다.

1<body id="test" something="non-standard">
2 <script>
3 alert(document.body.id);
4 // -> test
5 // 비표준 속성은 프로퍼티로 전환되지 않는다.
6 alert(document.body.something);
7 // -> undefined
8 </script>
9</body>

요소에 어떤 표준 속성이 있는지 알아보려면 해당 요소의 명세서에 정보를 찾을 수 있다.

비표준 속성은 모든 속성을 접근할 수 있는 메서드를 통해 접근할 수 있다.

  • elem.setAttribute(name): 속성 존재 여부 확인
  • elem.getAttribute(name): 속성값을 가져옴
  • elem.setAttribute(name, value): 속성값을 변경
  • elem.removeAttribute(name): 속성값 제거

혹은 elem.attributes를 사용해 모든 속성값을 읽을 수도 있다. attributes가 반환하는 컬렉션은 이터러블이다. 컬렉션에 담긴 각 객체의 name, value 프로퍼티를 사용하면 속성 전체에 접근할 수 있다.

표준 속성이 변하면 대응하는 프로퍼티는 자동으로 갱신된다. 몇몇 경우를 제외하면, 프로퍼티가 변하면 속성 역시 갱신된다.

1<input>
2
3<script>
4 let input = document.querySelector('input');
5
6 // 속성 추가 => 프로퍼티 갱신
7 input.setAttribute('id', 'id');
8 alert(input.id); // id (갱신)
9
10 // 프로퍼티 변경 => 속성 갱신
11 input.id = 'newId';
12 alert(input.getAttribute('id')); // newId (갱신)
13</script>

그런데 input.value처럼 동기화가 속성에서 프로퍼티 방향으로만 일어나는 예외사항도 존재한다. input.value의 속성값을 변화시키면 프로퍼티가 갱신되지만, 프로퍼티를 변화시키면 속성이 갱신되지 않는다.

DOM 프로퍼티 값의 타입

DOM 프로퍼티는 항상 문자열이 아니다. 불린 값을 가질 수도 있고, 객체를 가질 수도 있다.

체크 박스에 사용되는 input.checked 프로퍼티의 경우 불린 값을 가진다. 또, style 속성은 문자열이지만, style 프로퍼티는 객체다.

아주 드물긴 하지만, DOM 프로퍼티 값이 문자열이더라도 속성값과 다른 경우도 있다. href 속성이 상대 URL이나 #hash이더라도, href DOM 프로퍼티엔 항상 URL 전체가 저장되는 경우가 대표적이다.


참조:
ko.javascript.info
https://poiemaweb.com/js-dom
https://developer.mozilla.org/ko/docs/Web/Web_Components/Using_shadow_DOMf