DOM 101 — Nodes & Events

이 글은 HTML과 JavaScript에 대한 기본적인 이해가 있어야 이해하는 데 무리가 없습니다. 만약 두 언어에 대한 문법이 익숙하지 않으시다면, 먼저 간단한 두 언어에 대한 이해부터 시작하시는 걸 권장합니다.

DOM은 Document Object Model의 약자로, HTML과 CSS 등을 바탕으로 Node와 Node Tree를 생성하고 Event를 컨트롤하는 등의 작업을 JavaScript 등을 이용해 처리할 수 있게 해줍니다.

DOM은 크게 아래와 같은 부분으로 나눌 수 있습니다.

  1. Events
  2. Nodes
  3. Ranges
  4. Traversal
  5. Sets

이 글은 WHATWG의 DOM Standard를 기반으로 작성되었으며, DOM 내용 중에서도 가장 기초적인 내용을 다루고자 합니다. 이 글이 다루는 내용은 다음과 같습니다.

  1. HTML이 어떻게 Node가 되고, Node Tree를 형성하는 지 이해한다.
  2. Node에 접근해서 제어하는 방법에 대해서 이해한다.
  3. Node에 이벤트를 주입하여, 본인이 원하는 동작을 하게 만든다.

Node와 Node Tree

여러분들이 작성한 HTML은 문법이 있는 텍스트에 불과하며, 브라우저에서 해석 과정을 거치면서 DOM으로 변환됩니다. DOM으로 변환된 HTML 요소들은 곧 렌더링 되어 화면에 노출됩니다.

브라우저의 동작 방식에 대한 상세한 내용은 Chrome 팀의 Inside look at modern web browser 시리즈를 살펴보시길 바랍니다.

예를 들어, 아주 단순한 형태의 HTML 문서가 있다고 가정해보겠습니다.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>웹 사이트 제목</title>
</head>
<body>
웹 사이트 내용
</body>
</html>

위 HTML에서 텍스트, 요소들을 해석하여 브라우저는 Node를 생성하고 Node Tree를 생성합니다. 위 예제의 Node Tree를 그리면 아래와 같은 그림이 그려집니다.

Document
|- Doctype: html
|- HTML lang="ko"
|--|- HEAD
|--|--|- #text:
|--|--|- META charset="UTF-8"
|--|--|- #text:
|--|--|- TITLE
|--|--|--|- #text: 웹 사이트 제목
|--|--|- #text:
|--|- #text:
|--|- BODY
|--|--|- #text: 웹 사이트 내용

개별 HTML 요소에 대응하는 DOM Element 객체 (HTML, HEAD, META, TITLE, BODY)가 생성된 모습을 살펴볼 수 있습니다. 하나의 HTML 문서 내에서 최상위 Node는 Document 객체 입니다.

대부분의 경우 HTML이 Browser에서 동작하기 때문에, Window를 최상위 Node로 바라볼 수 있지만, DOM Specification은 플랫폼 중립 스펙이기 때문에 Window 객체에 대한 내용은 별도로 다루지 않습니다. 하지만 Browser에서 동작하는 경우 Window 객체가 Document 객체의 Parent Node로서 존재합니다.

이를 그림으로 그려보면 아래와 같습니다.

Window
|- Document
|--|- Doctype: html
|--|- HTML lang="ko"
|--|--|- HEAD
|--|--|--|- #text:
|--|--|--|- META charset="UTF-8"
|--|--|--|- #text:
|--|--|--|- TITLE
|--|--|--|--|- #text: 웹 사이트 제목
|--|--|--|- #text:
|--|--|- #text:
|--|--|- BODY
|--|--|--|- #text: 웹 사이트 내용

이 글에서는 Document 객체를 언제나 최상위 객체라고 가정하고 글을 작성하도록 하겠습니다. HTML은 그 특성상 동일한 요소를 같은 요소 내에서 여러번 사용할 수 있기 때문에, 복잡한 HTML 구조를 생성하면 그만큼 복잡한 Node Tree를 생성하게 됩니다.

현대 브라우저의 성능은 과거 브라우저와는 비교도 안되게 좋지만, 그럼에도 불구하고 웹 개발을 할 때 단순한 HTML 구조로 구현하는 것이 장기적인 성능 개선에 도움을 줍니다.

여러분들이 작성한 HTML, XML의 Node Tree를 살펴보고 싶다면 아래 링크의 도구를 사용하시거나, 브라우저상의 개발자 도구를 사용하면 됩니다. 저는 주로 Chrome과 Chrome DevTools를 사용합니다.

🧱 Node의 구조

JavaScript의 모든 객체는 Prototype 상속 구조를 가집니다. 이는 DOM으로 생성된 Node에서도 다르지 않으며, Node가 상속받는 각 객체별로 가지고 있는 인터페이스가 조금씩 상이합니다.

HTML 요소로 생성된 Node들은 아래와 같은 Prototype 상속 구조를 가집니다.

HTML: 
<div></div>
객체 상속 구조:
HTMLDivElement
|- HTMLElement
|--|- Element
|--|--|- Node
|--|--|--|- EventTarget
|--|--|--|--|- Object

이는 JavaScript 메모리 최적화와도 관련이 있는데, 관련한 글은 가까운 시일내로 별도로 작성하도록 하겠습니다. 각 객체들은 관심사에 맞는 인터페이스를 가지고 있으며, 같은 객체를 상속받는 다른 Node에서도 해당 인터페이스는 동일하게 사용할 수 있습니다.

위 상속 구조에서 HTMLDivElement와 HTMLElement 객체는 DOM 표준 스펙에서 정의하는 것이 아니라 HTML 표준 스펙에서 정의하고 있기 때문에 해당 객체에 대한 상세 내용은 HTML Standard를 참고하시길 바랍니다.

여러분들이 이 구조에 대해서 잘 알고 있으면, 스펙 문서에 정의되어 있는 인터페이스를 살펴보면서 개발에 필요한 속성과 메서드를 살펴볼 수 있으리라 생각됩니다. 예를 들어 Element Interface는 DOM 스펙에서 아래와 같이 정의되어 있습니다.

위 상속 구조에서 살펴보았던 것처럼 Element 인터페이스는 Node 인터페이스를 확장해서 정의한 인터페이스입니다.

읽기 전용 (readonly) 속성 (attribute)으로 문자열(string) 타입을 가진 속성 몇가지를 가지고 있습니다. ? 가 뒤에 붙어있는 건 nullable type으로, 타입이 null이 될 수 있다는 의미입니다.

더 쉽게 이야기하자면 optional 이라고 보셔도 좋습니다.

readonly attribute DOMString? namespaceURI;
readonly attribute DOMString? prefix;
readonly attribute DOMString localName;
readonly attribute DOMString tagName;
[CEReactions] attribute DOMString id;
[CEReactions] attribute DOMString className;
[CEReactions, Unscopable] attribute DOMString slot;
[SameObject] readonly attribute NamedNodeMap attributes;
readonly attribute ShadowRoot? shadowRoot;

메서드들도 가지고 있습니다. 메서드를 표현할 때에는 반환 타입과 함수를 함께 작성합니다.

void setAttribute(DOMString qualifiedName, DOMString value);
void removeAttribute(DOMString qualifiedName);
boolean hasAttribute(DOMString qualifiedName);
DOMString? getAttribute(DOMString qualifiedName);

위 메서드 중 setAttribute 같은 경우 아래와 같이 해석할 수 있습니다.

1. 반환 타입은 void (모든 타입 반환 가능)
2. 메서드명은 setAttribute
3. 첫번째 파라미터는 qualifiedName (어떤 속성을 지정할 건 지)
4. 두번째 파라미터는 value (해당 속성에 어떤 값을 지정할 건 지)

웹 표준 문서에서 인터페이스를 표기하기 위해 사용하는 문법을 Web IDL이라고 칭합니다. Web IDL 문서도 꽤 복잡하게 이루어져 있으니 기회가 되신다면 여러분들도 조금 더 읽어보시는 걸 추천합니다.

Node에 접근하기

앞서서도 이야기했던 것처럼 HTML은 그 특성상 한 요소 내에 동일한 이름의 여러 요소가 존재할 수 있으며, 부모 요소와 자식 요소의 이름이 동일할 수 있는 등 그 문법이 상당히 자유롭습니다.

Node에 접근하기 위한 여러가지 접근 경로가 있으나, 여기에서는 가장 많이 사용되는 인터페이스를 소개하도록 하겠습니다.

parentNode.querySelector(selectors)

querySelector는 CSS Selector를 이용해서 특정 Node에 접근하는 메서드입니다. 어떤 Node던 부모 Node라면 querySelector 메서드를 가지고 있습니다. 다만 querySelector 메서드는 단일 Node를 반환하기 때문에 Selector가 여러개의 Node를 대상으로 하는 경우 해당 Node 중 첫번째 Node만 반환합니다.

HTML:
<div>첫번째 Node</div>
<div>두번째 Node</div>
<div>세번째 Node</div>
JavaScript:
document.querySelector("div");
Result:
<div>첫번째 Node</div>

만약 여러 개의 Node를 모두 가져오고 싶다면 querySelectorAll 을 사용할 수 있습니다.

HTML:
<div>첫번째 Node</div>
<div>두번째 Node</div>
<div>세번째 Node</div>
JavaScript:
document.querySelectorAll("div");
Result:
NodeList(3)
0: div (첫번째 Node)
1: div (두번째 Node)
2: div (세번째 Node)
length: 3

querySelectorAll 이 반환하는 결과값이 Array가 아닌 NodeList 라는 이름의 DOM 인터페이스임을 주의하시기를 바랍니다. 만약 Array에 있는 메서드 등을 사용하고 싶다면 NodeList를 Array로 형변환 후에 사용하여야 합니다.

Node 제어하기

querySelector 를 이용해 가져온 Node에 접근하여, 속성 값이나 Node 내부를 수정할 수 있습니다.

const div = document.querySelector("div");div.classList.add("on");
div.classList.toggle("is-on");
div.setAttribute("class", "on");
div.setAttribute("lang", "ko");

그 외에도 사용 가능한 여러가지 메서드와 속성에 대해서는 DOM Standard에서 정의하는 인터페이스를 살펴보기 바랍니다. DOM Standard가 너무 어렵다면 MDN 등에서 살펴보실 수도 있습니다.

readonly 속성은 변경하는 것이 불가능하기 때문에, getAttribute 등을 이용해서 읽는 것만 가능합니다. setAttribute 를 이용해서 속성 값을 바꾸려고 해도 바뀌지 않으며, 별도의 에러를 반환하지는 않습니다.

div.tagName = "another-name";
// 별도의 에러를 뱉지는 않지만 변경되지 않음
div.tagName
// DIV

👀 Node의 이벤트 지켜보기

웹 플랫폼 전체에서 네트워크 활동, 유저 인터렉션 등의 이벤트가 발생하면 해당 이벤트의 발생 사실을 각 객체에 전달합니다. 이 객체는 EventTarget 인터페이스로 구현되며 addEventListener() 를 호출하여 이벤트 발생을 지켜볼 수 (observer) 있습니다.

특정 Node의 이벤트를 지켜보는 방법은 다음과 같습니다.

const div = document.querySelector("div");div.addEventListener("click", (e) => {
console.log(e);
})

addEventListener 메서드의 첫번째 파라미터는 이벤트 이름이며, 두번째 파라미터는 해당 이벤트가 발생하였을 때 동작시킬 함수 (콜백 함수)입니다. 지금은 생략하였지만 세번째 파라미터는 이벤트의 bubbling 여부를 결정하는 데 이는 다른 글에서 더 자세히 설명하겠습니다.

이벤트의 콜백 함수에서 첫번째 파라미터로 발생한 이벤트의 Event 객체를 받을 수 있는데, 키보드 이벤트가 발생했다면 KeyboardEvent, 마우스 이벤트가 발생했다면 MouseEvent, 터치 이벤트가 발생했다면 TouchEvent 등 다양한 종류의 이벤트가 발생합니다.

개별 Event 인터페이스에서 발생한 이벤트에 대한 더 자세한 정보를 알아낼 수 있습니다. MouseEvent 라면 어느 위치에서 마우스를 클릭했는 지, 왼쪽 마우스인 지 오른쪽 마우스인 지, 마우스를 움직였는 지 등의 정보를 알 수 있습니다.

이벤트의 종류

DOM에서 지켜볼 수 있는 이벤트에도 여러 종류가 있으며, 개발자가 직접 이벤트를 지정할 수도 있습니다 (custom event). 시대의 변화에 따라서 유저가 사용할 수 있는 입력 장치가 다양해지고 있어서, 해당 장치별로 스펙이 분할되어있습니다.

이벤트의 네이밍에 대해서는 대부분 유사한 패턴을 가지고 있기 때문에, 패턴만 기억하고 있다면 이벤트의 종류에 대해 이해하는 건 그렇게 어렵지 않습니다. 다만 Touch 이벤트만 인터페이스가 일부 다르기 때문에 신중하게 접근해야 합니다.

🖱️ Mouse

  1. mousedown — 마우스를 클릭했을 때
  2. mouseenter — 마우스가 해당 요소에 진입했을 때
  3. mouseleave — 마우스가 해당 요소를 벗어났을 때
  4. mousemove — 마우스가 해당 요소에서 움직였을 때
  5. mouseoutmouseleave 와 비슷하나 bubble 됨 (다른 글에서 별도 설명)
  6. mouseovermouseenter 와 비슷하나 bubble 됨
  7. mouseup — 마우스 클릭 상태에서 벗어났을 때

Mouse 이벤트는 동작에 따라서 순서가 상이해서, 클릭하지 않았을 때와 클릭하였을 때의 이벤트가 서로 다르기 때문에, 각 이벤트 순서를 정리하겠습니다.

클릭하지 않고 요소 위를 움직였을 때

  1. mousemove
  2. mouseover
  3. mouseenter
  4. mousemove
  5. mouseout
  6. mouseleave

클릭하고 요소 위를 움직였을 때 (떼었을 때를 포함)

  1. mousedown
  2. mousemove — 만약 마우스 클릭 후 마우스를 움직인 경우 발생
  3. mouseup
  4. click
  5. mousemove — 만약 마우스 클릭 후 마우스를 움직인 경우 발생
  6. mousedown
  7. mousemove — 만약 마우스 클릭 후 마우스를 움직인 경우 발생
  8. mouseup
  9. click
  10. dbclick

☝️ Touch

  • touchstart — 터치를 시작했을 때
  • touchend — 터치를 끝냈을 때
  • touchmove — 터치 후 손가락을 움직였을 때
  • touchcancel — 해당 이벤트 영역 바깥으로 터치 이벤트가 빠져나갔을 때

Touch 이벤트가 발생하는 순서는 다음과 같습니다.

  1. touchstart
  2. touchmove — 유저가 손가락을 옮기면 발생
  3. touchend

⌨️ KeyBoard

KeyBoard 이벤트는 그 동작이 단순하여 제어하기 쉬운 이벤트에 속합니다.

  • keydown — 키보드를 눌렀을 때
  • keyup — 키보드를 뗐을 때

이 이벤트의 재미있는 점은 유저가 어느 키를 입력하고 있는 지 알 수 있다는 점입니다. KeyBoard 이벤트에서 key 속성이나 code 속성에 접근하면 유저가 어떤 키를 입력하고 있는 지 알 수 있습니다.

다만 code 속성값은 가상 키보드에서 문제가 발생할 수 있기 때문에 되도록이면 key 속성값을 사용하는 걸 권장합니다.

document.addEventListener("keydown", (e) => {
console.log(e.key); // 유저가 입력한 key 정보가 노출됩니다.
})

KeyBoard 이벤트의 발생 순서는 다음과 같습니다.

  1. keydown
  2. beforeInput
  3. input
  4. keyup

🙈 Node의 이벤트를 지켜보지 않기

addEventListener 를 통해 추가한 이벤트 리스너를 removeEventListener 를 통해 제거할 수 있습니다. 간단하게 추가한 이름의 반대로 제거하면 되는데, 만약 addEventListener 에서 익명함수를 추가한 경우에는 제거가 불가능합니다.

그래서 향후에는 익명함수가 아니라, 특정 함수를 선언 후 사용하는 걸 권장합니다.

const div = document.querySelector("div");const keyDown = (e) => { // do something }div.addEventListener("keyDown", keyDown); // 이벤트 리스너 추가
div.removeEventListener("keyDown", keyDown); // 이벤트 리스너 제거

이런 습관은 향후에 자바스크립트 코드를 디버깅 할 때에도 도움을 줍니다.

🎉 COMPLETE

이로서 DOM에 대한 가장 기초적인 지식을 마무리하겠습니다! 다음 글에서는 DOM의 Event에 대해서 더 자세히 다루어보도록 하겠습니다.

고맙습니다.

--

--

Business: choeun@techhtml.dev

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store