# DOM이란?
문서 객체 모델(DOM, Document Object Model)은 HTML, XML 문서의 프로그래밍 인터페이스이다. - MDN
API와 관련된 자료를 찾아볼 때마다 지겹도록 들은 인터페이스에 대한 개념이 또 등장한다. 인터페이스라 함은 서로 다른 두 대상이 소통할 때 필요한 규격이라고 정리할 수 있는데 이러한 측면을 DOM에 적용해보면 어떻게 될까?
텍스트 파일로 만들어져 있는 웹 문서를 브라우저에 렌더링하려면 웹 문서를 브라우저가 이해할 수 있는 구조로 메모리에 올려야 한다. 브라우저의 렌더링 엔진은 웹 문서를 로드한 후, 파싱하여 웹 문서를 브라우저가 이해할 수 있는 구조로 구성하여 메모리에 적재하는데 이를 DOM이라 한다. 즉 모든 요소와 요소의 어트리뷰트, 텍스트를 각각의 객체로 만들고 이들 객체를 부자 관계를 표현할 수 있는 트리 구조로 구성한 것이 DOM이다. - Poimaweb(Js-dom) (opens new window)
DOM의 필요성부터 생각해보자. 브라우저가 HTML 문서 안의 다양한 요소, 속성들을 해석하여 렌더링하게 될텐데 단순 HTML 코드 텍스트 나열이 아니라 사용자가 보기에 자연스러운 웹 페이지로 구성이 된다.
이러한 구성을 위한 작업을 (웹 문서의 로드, 파싱) 브라우저의 렌더링 엔진이 처리하고 이에 대한 결과물로써 DOM이 나오는 것이다. 브라우저는 DOM을 확인하고 문서에 대한 해석과 더불어 우리가 보고 있는 페이지를 나타낸다.
WARNING
DOM은 프로그래밍 언어가 아니다. HTML 소스와 같은 문서를 객체 지향적으로 표현한 추상적 모델일 뿐이다. 하지만 DOM이 없으면 웹 브라우저에 접근하는 스크립트 언어가 페이지 또는 XML 요소와 관련된 개념에 대해 정보를 갖지 못하게 된다.
위에서 말했듯, DOM은 브라우저와 스크립트 언어 사이를 이어주는 API와 같은 역할을 한다고 보면 된다.
초창기 웹의 발전에 이바지한 자바스크립트는 DOM과 함께 길을 가는 듯 했지만 나중에는 각각 분리되어
발전하게 된다. 우리가 보고 있는 웹 페이지는 DOM과 스크립트 언어(Python, Javascript, ASP.NET 등) 이 함께 어우러진 결과이다.
스크립트 언어에 대한 설명은 다음의 링크를 (opens new window) 참조하자.
DOM에 대한 문서를 본격적으로 작성하기 전에는 DOM이 자바스크립트 언어의 한 부분인 줄 알았지만, 이 둘은 완전 별개의 존재라는 것을 깨달았다. 파이썬 웹 크롤링 실습을 할 때 우리가 특정 페이지 요소에 접근할 수 있었던 이유에 대해 생각하게 되었는데, 바로 DOM이라는 객체 모델에 파이썬이라는 스크립트 언어가 접근하여 요청에 따른 웹 페이지를 해석할 수 있었다는 것을 알 수 있었다.
DOM을 파이썬이 해석하는 예제는 다음과 같다. MDN(DOM 소개) (opens new window) 글을 참조하여 새롭게 예제 코드를 작성해보았다.
# Python DOM example
import xml.dom.minidom as m
doc = m.parse("C:\\Users\\Parkjju\\Desktop\\sitemap.xml")
doc.nodeName # DOM property of document object;
p_list = doc.getElementsByTagName("url")
print(p_list)
실제 나의 뷰프레스 사이트맵 파일을 로컬에 저장하여 파이썬 파싱 모듈의 parse 메서드에 파라미터로 전달해보았다. 참고로 parse 메서드는 파라미터로 file만 받고 있다. (추후 BeautifulSoup 모듈을 활용하여 HTTP 프로토콜을 따르는 웹 사이트에 접근하는 예제도 추가 예정)
getElementByTagName이라는 메서드로 xml파일에 접근하고 있는데, 이게 실제로 정상적으로 이루어진다는 것으로부터 미루어 짐작하면 파이썬이라는 스크립트 언어도 DOM이라는 객체 모델을 통해 xml사이트에 대한 해석을 정상적으로 처리하였다고 이해할 수 있겠다.
DOM은 문서에 대한 객체 모델로 존재하여 API 역할을 하는 것 외에도 실제로 연결되어 있는 소스(HTML 등) 내의 요소에 접근 / 수정하는 기능도 제공한다. (각 요소가 갖는 프로퍼티와 메서드를 제공한다.)
# DOM 트리
문서에 대한 객체 트리 모델로써 존재하는 DOM이 바로 DOM Tree이다. HTML 문서가 DOM으로 구성된 후에는 웹 문서에 대한 동적 변경을 위해 각 요소에 대한 프로퍼티와 메서드를 자바스크립트 객체 형태로 제공한다. 이를 DOM API라고 한다.
달리 말하면 정적인 웹페이지에 접근하여 동적으로 웹페이지를 변경하기 위한 유일한 방법은 메모리 상에 존재하는 DOM을 변경하는 것이고, 이때 필요한 것이 DOM에 접근하고 변경하는 프로퍼티와 메소드의 집합인 DOM API이다. - Poimeweb(DOM) (opens new window)
(위의 두 문장을 요약하고 싶었지만 도저히 불가능했다. DOM과 DOM API에 대한 완벽한 설명이다👍)
DOM 트리는 네 종류의 노드로 구성된다.
- 문서 노드(Document Node)
- 요소 노드(Element Node)
- 어트리뷰트 노드(Attribute Node)
- 텍스트 노드(Text Node)
각 노드에 대한 개념은 다음과 같다.
- 문서 노드 - 트리 최상위에 위치하며 나머지 노드에 접근하기 위해서는 문서 노드를 거쳐야한다. 즉, DOM 트리에 접근하기 위한 시작점(Entry Point)이다.
- 요소 노드 - HTML 요소를 표현한다. HTML 요소는 중첩에 의해 부모-자식 관계를 맺게 된다. 이 관계들은 결과적으로 HTML 문서의 구조를 서술한다고 말 할 수 있겠다. 텍스트 노드에 접근하려면 먼저 요소 노드를 찾아 접근해야 한다. 요소 노드는 요소별 특성 표현을 위해
HTMLElement객체를 상속한 객체로 구성된다. - 어트리뷰트 노드 - HTML 요소의 어트리뷰트를 표현한다. 요소 자식이 아닌 요소의 일부로 표현된다. 동일한 이름의 자바스크립트 객체 프로퍼티를 통해 접근할 수 있다.
Most HTML attributes can be accessed through the JavaScript object property of the same name. - (Document Object Model (opens new window))
- 텍스트 노드 - HTML 요소의 텍스트를 표현한다. 요소 노드의 자식이고 자신의 자식 노드를 가질 수 없다. DOM 트리의 최종단이다.
Attribute vs Property
어트리뷰트는 HTML 문서에서 작성되는 요소의 속성-값 쌍을 말하고, 프로퍼티는 HTML 문서가 DOM으로 파싱된 이후의 어트리뷰트를 말한다. 위의 정의에 따라 어트리뷰트는 값이 정적이고 프로퍼티는 동적으로 변할 수 있다.
HTMLElement란?
Javascript Reference (opens new window) 페이지를 참고해보자. DOM을 구성하는 노드 중 HTML 요소를 나타내는 요소 노드는 각 요소 특성 표현을 위한 인터페이스를 마련해두고 있다. 예를 들어, HTMLAnchorElement는 하이퍼링크 기능을 갖는 <a></a> 요소를 다루고 위 요소를 다루기 위한 속성과 메서드를 제공하는 인터페이스이다. 인터페이스에 대한 설명은 자바의 인터페이스 개념을 참고하자. 한 마디로 정리하면 프로토콜과 비슷하다고 볼 수 있다. 특정 기능을 구현하는 데에 있어서 필요한 기본 틀이라고 생각하자.
DOM을 통해 웹페이지를 조작하기 위해서는 요소를 찾고, 해당 요소의 텍스트 또는 어트리뷰트를 조작한다. 자바스크립트는 이를 위한 API를 제공한다.
# DOM 쿼리 - 선택
실습 예제
모든 예제 코드는 현재 문서 기준, 크롬 개발자 도구를 통해 확인하실 수 있습니다 😃
그렇다면 이제 자바스크립트에서 제공하는 API를 가지고 실제 HTML요소에 접근해보자.
document.getElementById(id)- id 어트리뷰트를 통해 요소 노드 한 개를 선택한다.
- DOM 노드 중
HTMLElement를 상속받는 객체를 리턴한다. - 모든 브라우저에서 동작
실제로 현재 열려있는 뷰프레스 문서 기준으로 reference id값을 갖는 요소를 선택해보자. 그 후에 이 요소의 프로토타입을 차례로 찍어보자.
프로토타입에 대한 설명은 자바스크립트는 왜 프로토타입을 선택했을까? (opens new window) 문서를 참조하자.
myElement = document.getElementById("reference");
console.log(myElement); // HTML 요소가 출력된다.
console.log(myElement.__proto__); // HTML 요소의 원형 - HTMLHeadingElement라는 인터페이스가 출력된다.
console.log(myElement.__proto__.__proto__); // 모든 HTML 요소의 원형인 HTMLElement가 출력된다.
console.log(myElement.__proto__.__proto__.__proto__); // 모든 요소 노드의 원형인 Element가 출력된다.
console.log(myElement.__proto__.proto__.proto__.__proto__); // 모든 노드의 원형인 Node가 출력된다.
- document.querySelector(Selector)
- CSS 셀렉터를 통해 요소에 접근한다.
- 셀렉터 값에 해당하는 요소 한 개만 가져온다. 중복될 경우 첫 번째 요소 하나만 가져온다.
- IE8 이상의 브라우저에서 동작한다.
myElement = document.querySelector('#reference');
console.log(myElement);
- document.getElementsByClassName("className") & document.getElementsByTagName("TagName")
- 클래스명, 태그명에 따라 요소를 전부 선택하여 가져온다.
HTMLCollection타입으로 가져온다. (주의)
const myElements = document.getElementsByTagName("p");
console.log(myElements.__proto__) // HTMLCollection
HTMLCollection에 대한 설명은 querySelectorAll API까지 본 후에 후술하겠습니다.
- document.querySelectorAll(Selector)
- CSS 셀렉터, 태그명 등에 해당하는 요소 전체를 가져온다.
NodeList타입으로 반환한다.
const myElements = document.querySelectorAll("p");
console.log(myElements.__proto__); // NodeList ...
HTMLCollection vs NodeList
HTMLCollection 타입은 살아있는 요소들의 집합이다. 이에 반해 NodeList는 죽어있다. 즉 동적이냐 정적이냐의 차이이다.
HTMLCollection은 유사 배열(array-like object)이라고도 불린다. 유사 배열이라 함은 배열과 유사한 역할(인덱스를 통한 엘리먼트 접근)을 하지만 실제 배열에서 제공하는 모든 메서드를 가지고 있지 않다는 것이다. HTMLCollection은 배열의 .forEach, .map메서드 등을 제공하지 않는다.
HTMLCollection 객체 요소에 접근하기 위해서는 인덱스를 이용하거나, 속성명 입력을 통해 접근할 수 있다. name 어트리뷰트가 지정되어 있는 요소의 경우 namedItem 메서드를 통해 접근할 수도 있고, 인덱스 접근 시 item 메서드를 통해 접근할 수도 있다.
const myElements = document.querySelectorAll("p");
console.log(myElements.item(0)); // 0번째 p태그 원소 출력
NodeList도 역시 유사 배열이지만, forEach, item, entires, keys, values 메소드를 제공한다. 가장 중요한 특징으로 정적 콜렉션이라는 점을 들 수 있는데 이는 DOM이 변경되어도 콜렉션 내용에는 변화가 없다는 것이다.
위 두 타입의 차이점을 느낄 수 있는 예제 코드는 바로 반복문이다. 반복문의 반복 수 조건으로 콜렉션 프로퍼티인 length를 전달하면 동적/정적 차이를 느낄 수 있다.
const hCollection = document.getElementsByClassName("sidebar-heading"); // HTMLCollection
console.log(hCollection.__proto__); // HTMLCollection
for(let i=0; i<hCollection.length; i++){
console.log(hCollection[i])
hCollection[i].className = "foo";
} // 5개만 출력
const nCollection = document.querySelectorAll(".sidebar-heading"); // NodeList
console.log(nCollection.__proto__); // NodeList
for(let i=0; i<nCollection.length; i++){
console.log(nCollection[i])
nCollection[i].className = "foo";
} // 10개 모두 출력
hCollection의 경우 for문을 순회함과 동시에 반복문 조건인 hCollection.length라는 프로퍼티값이 변하게 되어 총 5번의 출력이 이루어진다. 이에 반해 nCollection의 경우 nCollection.length 프로퍼티 값에도 변화가 없어서 총 10번의 출력이 이루어진다.
이 밖에 다양한 API들이 있으니 공식 문서를 참조하자.
# DOM 쿼리 - 탐색
DOM 요소를 탐색하는 API는 다음과 같다.
parentNodefirstChild,lastChild,firstElementChild,lastElementChild,hasChildNodeschildNodes-NodeList반환children-HTMLCollection반환previousSibling,nextSiblingpreviousElementSibling,nextElementSibling
탐색 쿼리에서 주의할 것은 API명에 Element가 포함되어 있는지 여부이다. 기본적으로 HTML문서 작성 이후 DOM으로 바뀌는 과정에서 브라우저는 각 요소 사이에 공백 문자를 삽입하게 되어 있다. 실제로 HTML이 DOM으로 바뀐 이후의 트리 구조를 보면 #text라는 부분이 삽입되어 있음을 확인할 수 있다.
위 API중 firstChild, firstElementChild를 비교해보자.
const myBody = document.body;
console.log(myBody.firstChild); // #text
console.log(myBody.firstElementChild); // HTML요소
# DOM 쿼리 - 조작 1
DOM에서 조작할 수 있는 노드 종류는 다음과 같다.
- 텍스트 노드
- 어트리뷰트 노드
- HTML 콘텐츠
# 텍스트 노드
텍스트노드는 요소 노드의 자식이다. 따라서 접근하기 위해서는 다음의 수순이 필요하다.
- 조작할 텍스트 노드의 부모 노드를 선택한다.
firstChild프로퍼티를 사용한다. (not firstElementChild!!)- 텍스트 노드의 프로퍼티인
nodeValue를 이용하여 텍스트 값에 접근할 수 있다. nodeValue를 통해 텍스트를 수정한다.
const myElement = document.querySelectorAll(".sidebar-heading");
const firstSidebarElement = myElement[0].firstChild;
console.log(firstSidebarElement.firstChild.__proto__); // Text
firstSidebarElement.firstChild.nodeValue = "텍스트 바꾸기~!";
// 실제로 사이드바 텍스트가 변경됩니다.
요소의 nodeType, nodeName 프로퍼티를 통해 노드에 대한 정보를 확인할 수 있다. nodeType 경우 노드 타입마다 상수값이 정해져있어서 출력시 숫자값이 나온다. 각 타입마다 할당된 숫자를 보려면 다음의 링크를 참조하자. (opens new window)
const myElement = document.querySelectorAll(".sidebar-heading");
const firstSidebarElement = myElement[0].firstChild;
console.log(firstSidebarElement.nodeType); // 1
# 어트리뷰트 노드
className-class어트리뷰트 값에 접근한다. 클래스가 여러 개일 경우 공백 문자를 구분자로 하여 문자열이 반환되므로split()메서드로 배열화 하여 저장하자.classList- 클래스 값이 여러 개일 경우 배열형태로 반환해준다.add,remove등 클래스 관련 메서드를 지원한다.id- id어트리뷰트 값에 접근한다.hasAttribute(어트리뷰트 이름)- 파라미터로 전달된 어트리뷰트를 보유하는지 검사한다.getAttribute(어트리뷰트 이름)- 선택된 HTML 요소의 파라미터로 전달한 어트리뷰트의 값을 얻는다.setAttribute(어트리뷰트 이름, 값)- 선택된 HTML 요소의 파라미터로 전달한 어트리뷰트에 값을 세팅한다.removeAttribute(어트리뷰트 이름)- 선택된 HTML 요소의 파라미터로 전달한 어트리뷰트를 제거한다.
# HTML 콘텐츠 조작
동적으로 마크업 요소를 추가하기 위해 사용하는 메서드이다. 이는 크로스 스크립팅 공격(XSS: Cross-Site Scripting Attacks)에 취약하므로 주의가 필요하다. XSS는 해커가 악성 스크립트를 웹사이트에 주입하는 행위를 말한다.
HTML콘텐츠 접근 프로퍼티는 다음과 같다.
textContentinnerTextinnerHTML- ...etc
textContent
const myElement = document.querySelector(".sidebar-heading");
console.log(myElement.textContent); // sidebar-heading[0] 요소에 포함된 텍스트 컨텐츠 출력
// myElement 내의 모든 마크업이 무시된다.
myElement.textContent = "Contents !!";
// 마크업을 포함한 문자열이더라도 마크업이 인식되지는 않는다.
myElement.textContent = "<h1>H1 Contents?</h1>";
innerText
사용하지 않는게 좋다. 이유는 다음과 같다.
- 비표준이다. - 브라우저 각 환경을 고려하지 않은 프로퍼티이다.
- CSS에 순종적이다. -
visibility: hidden일 경우 텍스트가 반영되지 않는다. - CSS를 고려하기 때문에 속도가 느리다. 참고문서 (opens new window) IE의 경우 93배까지 속도가 느려진다고 한다.(충격)
let myElement = document.querySelector(".sidebar-heading");
console.log(myElement.innerText); // TIL
console.log(myElement.innerHTML); // HTML 요소로 출력
myElement.style.visibility = "hidden"; // 스타일 hidden으로 텍스트 감추기
console.log(myElement.innerText); // 공백 반환
console.log(myElement.innerHTML); // 요소 그대로 출력됨.
innerHTML
해당 요소의 모든 자식 요소를 포함하는 콘텐츠를 하나의 문자열로 반환받는다. 마크업을 인식한다.
let myElement = document.querySelector(".sidebar-headng");
myElement.innerHTML += "<h1>H1 !!!!!!!</h1>";
실제 HTML 콘텐츠에 인식되어 동적으로 나타나게 된다.
# DOM 쿼리 - 조작 2
innerHTML을 통해 문자열로 요소를 직접 조작해야할까? 이를 위해 마련된 메서드가 존재한다.
createElement(태그명)-document.createElement(TagName)으로 생성하며, 생성한 요소는 직접 붙여야 브라우저 상에 렌더링된다.createTextNode(텍스트)appendChild(Node)removeChild(Node)
createElement(tag) 주의사항
createElement(TagName)은 태그명만 파라미터로 받을 수 있으며 마크업 형태로는 받을 수 없다. 가령, createElement('<h1>Hello~</h1>') 형태로 파라미터를 받게 되면 에러가 발생한다. 생성한 요소에 텍스트를 붙이고 싶다면 텍스트 노드를 먼저 생성한 뒤 해당 요소의 자식으로 텍스트 노드를 붙이면 된다. DOM과 관련된 API를 다룰 때에는 트리 구조로 구성해야 한다는 것을 기억하자.
const newElement = document.createElement("h1");
const newTextNode = document.createTextNode("Hello!!"); // newElement에 붙일 텍스트 노드
newElement.appendChild(newTextNode);
console.log(newElement); // HTML요소로 정상 등록 되었음을 확인할 수 있다.
// span, div를 newElement의 자식으로 등록해보자.
const newSpanElement = document.createElement("span");
const newDivElement = document.createElement("div");
newElement.appendChild(newSpanElement);
newElement.appendChild(newDivElement);
// 등록된 span요소 노드를 삭제한다.
newElement.removeChild(newElement.firstElementChild);
요소 삭제에 있어서 번거롭지만 removeChild메서드의 경우 파라미터로 전달되어야 하는 값이 노드타입이기 때문에 노드 검색 단계를 통해 반환받은 객체를 전달해야한다.
# insertAdjacentHTML(position, string)
이 메서드 역시 XSS공격에 취약하다.
인자로 전달한 문자열을 HTML로 파싱한 뒤 position값에 해당하는 위치에 해당 요소를 삽입한다.
position에 전달될 수 있는 값은 다음과 같다.
beforebegin: 삽입 대상 HTML요소의 시작태그 바로 위에 요소가 추가된다.afterbegin: 삽입 대상 HTML요소의 시작태그 바로 아래에 요소가 추가된다. (자식으로)beforeend: 삽입 대상 HTML요소의 종료 태그 바로 위에 요소가 추가된다. (자식으로)afterend: 삽입 대상 HTML요소의 종료 태그 바로 아래에 요소가 추가된다.
const myElement = document.querySelector(".sidebar-heading");
myElement.insertAdjacentHTML("afterbegin", "<span>Hello</span>");
console.log(myElement); // span이 추가된 요소
console.log(myElement.firstElementChild); // 추가한 span이 자식요소로 들어옴
myElement.insertAdjacentHTML("afterend", "<span>Hello~</span>");
console.log(myElement.nextElementSibling); // 추가한 span이 형제 노드로 추가
XSS 공격에 대한 방지로 텍스트 변경 시에는 textContent를, 요소 추가 및 삭제 시에는 createElement, removeChild 등의 DOM트리를 통한 접근 방식을 이용하자.