본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

DOM 조작

작성 2026. 10. 21.추가
목차17개 항목
출처와 참고

TIL을 정리하고, Wes Bos의 「Beginner JavaScript」 강의 노트(wesbos.com/javascript)를 함께 참고했다. 탐색·검색·노드 속성·어트리뷰트·문서 수정·스타일은 javascript.info(모던 JavaScript 튜토리얼)의 「DOM 탐색하기」, 「getElement*, querySelector*로 요소 검색하기」, 「주요 노드 프로퍼티」, 「속성과 프로퍼티」, 「문서 수정하기」, 「스타일과 클래스」 글로 보강했다. jQuery 대응표는 「(Now More Than Ever) You Might Not Need jQuery」을 바탕으로 했다.

DOM 조작은 항상 ① 선택(select) → ② 변경(manipulation) 두 단계이고, 사용자의 행동에 반응하려면 여기에 ③ 이벤트 리스너를 붙인다. DOM의 개념과 노드 구조는 DOM과 BOM, 이벤트 전파·위임은 DOM 이벤트: 전파, 위임, 커스텀 이벤트에 있다.

선택

메서드반환
querySelector(selector)CSS 선택자와 일치하는 첫 번째 요소(없으면 null)
querySelectorAll(selector)일치하는 모든 요소의 NodeList(static)
getElementById(id)단일 요소. # 없이 id만 넘긴다
getElementsByTagName·getElementsByClassNameHTMLCollection(live)
  • getElementById는 document에만 있다. id는 문서에서 유일해야 하며, 겹치면 무엇이 반환될지 정해져 있지 않다.
  • id가 있는 요소는 같은 이름의 전역 변수(window.elem)로도 접근된다. 하위 호환용 동작이라 같은 이름의 변수가 선언되면 가려지고, 코드만 봐서는 출처를 알 수 없다. 쓰지 않는다.
  • querySelector는 찾는 즉시 멈추므로 querySelectorAll(css)[0]보다 빠르다.
  • :hover·:checked 같은 가상 클래스도 된다. querySelectorAll(':hover')는 포인터 아래 요소들을 바깥(html)부터 준다.
  • getElementsBy*는 이름의 s대로 컬렉션을 돌려준다. getElementsByTagName('input').value = 5는 아무 일도 하지 않는다. [0]이나 반복으로 요소를 꺼낸다. getElementsByName은 document에만 있고 거의 쓰지 않는다.

querySelector/querySelectorAll은 속성 선택자([name="signup"])·결합자(.item img)까지 모든 CSS 선택자를 쓸 수 있어 주로 이것을 쓴다. document뿐 아니라 어느 요소에서나 부를 수 있고, 그러면 그 요소 안에서만 찾는다. 컴포넌트 단위로 범위를 좁힐 때 쓴다.

const card = document.querySelector('.card')
const img = card.querySelector('img')      // card 안에서만 찾는다
const form = document.querySelector('[name="signup"]')

스크립트가 <head>에서 바로 실행되면 아직 요소가 없어 null이 나온다. defer나 type="module"로 불러 HTML 파싱 뒤에 실행한다(스크립트·스타일 로딩 순서).

HTMLCollection과 NodeList

둘 다 인덱스로 접근하는 유사 배열이다. map·filter 같은 배열 메서드는 없다.

HTMLCollectionNodeList
forEach없음있음
갱신live: DOM 변경이 실시간 반영childNodes는 live, querySelectorAll 결과는 static

live collection은 반복 도중 DOM을 바꾸면 함께 바뀌어 예상치 못한 결과를 낸다. 배열 메서드가 필요하면 Array.from(list)나 [...list]로 바꾼다.

const live = document.getElementsByTagName('div')    // 나중에 div가 추가되면 length가 늘어난다
const fixed = document.querySelectorAll('div')       // 호출한 순간의 목록에서 변하지 않는다
  • DOM 컬렉션과 탐색 속성은 읽기 전용이다. childNodes[0] = …로 바꿀 수 없고 삽입·삭제 메서드를 쓴다.
  • 이터러블이라 for...of로 돈다. for...in은 length·item 같은 속성까지 열거하므로 쓰지 않는다.

탐색: 노드와 요소

<p>나는 <em>정말</em> 좋다</p>에서 p의 요소(element) 자식은 em 하나지만, 노드(node) 자식은 텍스트·em·텍스트 셋이다. 태그로 감싼 것만 요소이고, 텍스트·주석까지 모두 노드다.

방향요소만텍스트 노드 포함
자식children, firstElementChild, lastElementChildchildNodes, firstChild, lastChild
형제previousElementSibling, nextElementSiblingpreviousSibling, nextSibling
부모parentElementparentNode

트리 꼭대기는 document가 바로 준다. document.documentElement는 <html>, document.head는 <head>, document.body는 <body>다. <head> 안의 동기 스크립트가 실행될 때는 아직 <body>를 읽지 않아 document.body가 null이다. DOM에서 null은 「그런 노드가 없다」는 뜻이다.

parentNode와 parentElement는 거의 늘 같지만 <html>에서 다르다. <html>의 부모 document는 요소가 아니어서 parentElement는 null이다. 그래서 while ((el = el.parentElement))는 <html>까지만 올라간다. 자식 노드가 있는지는 hasChildNodes()로 본다.

대부분은 왼쪽(요소) 속성이 필요하다. 다만 parentElement.parentElement.nextElementSibling처럼 구조에 기대어 이동하면 감싸는 div 하나만 생겨도 깨진다. 위로는 closest(selector), 아래로는 querySelector로 찾는다.

const card = button.closest('.card')    // 자기 자신부터 위로 올라가며 첫 일치(없으면 null)
button.matches('.danger')               // 이 요소가 선택자와 일치하나(boolean)
card.contains(button)                   // 자손인가(boolean)

표 탐색 속성

표 요소는 편의 속성을 더 가진다.

요소속성
<table>rows(모든 <tr>), caption·tHead·tFoot, tBodies
<thead>·<tbody>·<tfoot>rows
<tr>cells(<td>·<th>), rowIndex(표 전체에서 몇 번째 줄), sectionRowIndex(그 구역 안에서)
<td>·<th>cellIndex

table.rows[0].cells[1]처럼 쓴다. HTML에 <tbody>를 생략해도 파서가 DOM에 <tbody>를 넣는다는 점에 주의한다(DOM과 BOM). 폼에도 form.elements 같은 전용 탐색 속성이 있다(폼 컨트롤과 포커스 이벤트).

속성(property)과 어트리뷰트(attribute)

요소는 객체다. console.dir(el)로 보면 textContent, parentElement 같은 속성이 보이고, 대부분 읽기(getter)와 쓰기(setter)가 모두 된다.

img.alt = '웃는 강아지'                   // 표준 속성은 점 표기로
img.getAttribute('alt'); img.hasAttribute('alt'); img.setAttribute('alt', '…')
img.naturalWidth                         // 읽기 전용. 이미지가 받아지기 전에는 0
img.addEventListener('load', () => console.log(img.naturalWidth))
  • 이미지 크기처럼 리소스가 받아진 뒤에야 정해지는 값은 그 요소의 load 이벤트 뒤에 읽는다.

브라우저는 HTML을 파싱할 때 그 요소의 표준 어트리뷰트만 같은 이름의 DOM 속성으로 만든다. <body something="x">의 body.something은 undefined다. type은 <input>에서는 표준이지만 <body>에서는 아니다. 표준이 아닌 것은 getAttribute로만 읽힌다. el.attributes는 모든 어트리뷰트를 name·value를 가진 Attr 객체 컬렉션으로 준다.

DOM 속성(property)어트리뷰트(attribute)
사는 곳DOM 객체HTML
값의 타입무엇이든. 표준 속성은 명세가 정한 타입항상 문자열(setAttribute('n', 123) → '123')
이름대소문자를 가린다가리지 않는다(getAttribute('ID') = id)

대부분 한쪽을 바꾸면 다른 쪽도 바뀐다. 예외와 차이를 알아 둔다.

경우동작
id, alt, title 등양방향으로 동기화된다
input.value어트리뷰트 → 속성으로만. 사용자가 입력하거나 input.value를 바꿔도 getAttribute('value')는 처음 값을 그대로 준다. 원래 값으로 되돌릴 때 쓸 수 있다(defaultValue도 같은 값)
checked속성은 불리언 true, 어트리뷰트는 빈 문자열 ''. 현재 체크 상태는 속성에만 있다
style속성은 CSSStyleDeclaration 객체, 어트리뷰트는 문자열
href속성은 절대 URL(https://site.com/page#hello), 어트리뷰트는 적힌 그대로(#hello)

거의 언제나 DOM 속성을 쓴다. HTML에 적힌 그대로의 값이 필요할 때와 비표준 어트리뷰트를 읽을 때만 getAttribute를 쓴다. DOM 노드는 일반 객체라 el.myData = {…}처럼 아무 속성이나 붙일 수 있지만, 그건 HTML과 무관한 JavaScript 속성일 뿐이다.

  • 표준에 없는 어트리뷰트를 임의로 만들지 않는다. 나중에 같은 이름의 표준 속성이 생기면 충돌한다. 직접 정의할 데이터는 data-* 에 넣고 dataset으로 읽는다. data-price="100"은 el.dataset.price(문자열 '100')가 되고, data-user-id는 dataset.userId로 camelCase가 된다. 숫자는 Number()로 바꾼다. dataset에 쓰면 어트리뷰트도 바뀌어 [data-state="pending"] 같은 CSS 선택자가 바로 반응한다. 상태를 클래스 여러 개 대신 data-state 하나로 바꾸면 토글 코드가 단순해진다.

classList

el.classList.add('open', 'active')
el.classList.remove('active')
el.classList.toggle('round')              // 있으면 빼고 없으면 넣는다
el.classList.toggle('hidden', !isVisible) // 두 번째 인자로 강제
el.classList.contains('open')             // boolean
el.classList.replace('old', 'new')

인터랙션의 상당수는 JavaScript가 클래스만 바꾸고, 모양과 전환 효과는 CSS가 맡는 구조다. 스타일을 el.style로 하나씩 바꾸기보다 클래스를 토글한다.

className은 class 어트리뷰트 문자열 전체다. 대입하면 모든 클래스가 바뀐다. 옛 JavaScript에서 class 같은 예약어를 속성 이름으로 쓸 수 없어 생긴 이름이다. 클래스 하나를 다룰 때는 classList를 쓰고, classList는 이터러블이라 for...of로 돈다.

스타일 읽고 쓰기

인라인 스타일 쓰기

el.style은 style 어트리뷰트에 대응하는 객체다. 런타임에 계산해야 하는 값(드래그 좌표, 측정한 높이)만 여기에 쓰고, 나머지는 클래스로 한다.

el.style.left = `${x}px`                // 단위를 붙인다. el.style.left = 10 은 무시된다
el.style.backgroundColor = 'tomato'      // background-color → camelCase
el.style.setProperty('--progress', '40%') // CSS 변수와 대시 이름은 setProperty로
el.style.display = ''                    // 빈 문자열: 인라인 값을 지워 CSS 규칙·기본값으로 되돌린다
el.style.cssText = 'color: red; width: 100px' // 인라인 스타일 전체를 통째로 바꾼다
  • 여러 단어 속성은 camelCase다(z-index → zIndex). 접두사 속성도 같다(-webkit-x → WebkitX).
  • 인라인 값을 없앨 때 delete el.style.display가 아니라 빈 문자열을 넣는다.
  • el.style = '…'처럼 객체에 문자열을 대입하지 않는다. 통째로 바꿀 때는 cssText나 setAttribute('style', …)를 쓰는데, 기존 인라인 스타일을 모두 지우므로 새로 만든 요소에만 쓴다.
  • el.style.margin = '20px'처럼 단축 속성을 쓰면 marginTop·marginLeft도 채워진다.

계산된 스타일 읽기

el.style은 인라인 style 어트리뷰트만 읽는다. 스타일시트로 준 색·여백은 빈 문자열이다. 실제 적용된 값은 getComputedStyle(el, pseudo?)로 읽는다.

const cs = getComputedStyle(document.body)
cs.marginTop                        // '5px'
cs.color                            // 'rgb(255, 0, 0)'
getComputedStyle(el, '::before').content // 가상 요소
  • 돌려주는 것은 이름과 달리 결정 값(resolved value) 이다. 1em·125% 같은 상대값이 16px처럼 절대 단위로 풀려 있고, 기하 값은 50.5px처럼 소수일 수 있다. 과거 명세는 상대값을 그대로 둔 계산 값(computed value)을 줬다가 쓰기 편한 결정 값으로 바뀌었다.
  • paddingLeft·marginTop처럼 개별 속성 이름으로 읽는다. 단축 속성(padding, margin)을 읽는 결과는 브라우저마다 다를 수 있다.
  • 읽기 전용이며, 읽으면 스타일 계산(때로 레이아웃)을 강제한다.
  • :visited 링크의 색은 읽을 수 없다. 방문 기록을 캐내지 못하게 하려는 개인정보 보호 조치이고, CSS도 :visited에 크기를 바꾸는 스타일을 금지한다.
  • 요소가 화면에서 차지한 크기는 CSS width가 아니라 offsetWidth·getBoundingClientRect()로 읽는다(요소 크기·스크롤·좌표).

내용 변경: textContent, innerText, innerHTML

속성동작
textContent<script>·<style>·숨긴 요소까지 포함한 원본 텍스트. 레이아웃을 계산하지 않아 빠르다
innerText화면에 보이는 텍스트만. 스타일을 반영하므로 읽을 때 레이아웃 계산을 일으킨다
innerHTML / outerHTML요소 안(바깥 태그 포함)의 HTML을 문자열로 읽고 쓴다

텍스트를 넣을 때는 textContent를 쓴다. innerHTML에 사용자 입력을 그대로 넣으면 XSS가 된다(웹 보안). textContent에 대입하면 <·>까지 글자 그대로 들어가므로 사용자 입력을 안전하게 쓰는 기본 방법이다.

  • 틀린 HTML을 innerHTML에 넣으면 브라우저가 고쳐서 넣는다('<b>test' → <b>test</b>).
  • innerHTML로 넣은 <script>는 실행되지 않는다. 그렇다고 XSS가 막히는 것은 아니다. <img onerror=…> 같은 속성 핸들러는 실행된다.
  • el.innerHTML += '…'는 덧붙이기가 아니라 다시 쓰기다. el.innerHTML = el.innerHTML + '…'와 같아서 기존 내용을 모두 지우고 새로 파싱한다. 이미지가 다시 로드되고, 선택이 풀리고, 입력란의 값과 붙여 둔 리스너가 사라진다. 덧붙일 때는 insertAdjacentHTML('beforeend', …)나 append를 쓴다.
  • el.outerHTML = '…'는 el을 바꾸지 않는다. 문서에서 el을 빼고 그 자리에 새 HTML을 넣을 뿐이라, 변수 el은 여전히 옛 요소(이제 문서 밖)를 가리킨다. 새 요소가 필요하면 다시 찾는다. 대개 replaceWith가 더 명확하다.
  • 텍스트·주석 노드에는 innerHTML이 없다. 내용은 data(또는 거의 같은 nodeValue)로 읽고 쓴다. 주석 노드의 data로 템플릿 지시(<!-- if isAdmin -->)를 읽는 데 쓰기도 한다.
  • hidden 속성은 display: none과 같다(el.hidden = true). 단, CSS에서 display를 지정하면 그쪽이 이긴다.

생성과 삽입

const li = document.createElement('li') // 메모리에만 있다. 아직 화면에 없다
li.textContent = '부산'
li.classList.add('city')
 
ul.append(li, '텍스트')                  // 마지막 자식으로. 노드·문자열 여러 개
ul.prepend(li)                          // 첫 자식으로
ref.before(li); ref.after(li)           // 형제로
old.replaceWith(li)
const copy = li.cloneNode(true)         // true여야 자식(텍스트 포함)까지 복제
위치 지정 메서드넣는 것
insertAdjacentElement(pos, el)요소
insertAdjacentText(pos, text)텍스트(전체를 다시 쓰지 않고 덧붙인다)
insertAdjacentHTML(pos, html)HTML 문자열(XSS 주의)

pos는 beforebegin(앞 형제) · afterbegin(첫 자식) · beforeend(마지막 자식) · afterend(뒤 형제)다. 이미 문서에 있는 노드를 다시 넣으면 복사가 아니라 이동한다. 옮기기 전에 remove()할 필요가 없다(second.after(first)).

  • append·prepend·before·after·replaceWith에 문자열을 넘기면 텍스트 노드가 된다. div.before('<p>안녕</p>')는 태그가 아니라 글자 <p>안녕</p>를 넣는다. HTML로 해석시키려면 insertAdjacentHTML을 쓴다. 실무에서 insertAdjacentText·insertAdjacentElement는 append 계열로 대신할 수 있어 거의 insertAdjacentHTML만 쓴다.
  • 함수가 노드 배열을 돌려주면 ul.append(...nodes)로 펼쳐 넣는다. DocumentFragment를 꼭 쓰지 않아도 된다.
옛 메서드지금 쓰는 것
parent.appendChild(node)parent.append(node)
parent.insertBefore(node, next) (next가 null이면 끝에)next.before(node)
parent.replaceChild(newNode, old)old.replaceWith(newNode)
parent.removeChild(node)node.remove()

옛 메서드는 노드 하나만 받고 넣거나 뺀 노드를 반환한다. 오래된 코드에서 만난다.

document.write(html) 는 DOM 표준 이전부터 있던 메서드로, 파싱 중인 그 자리에 HTML을 끼워 넣는다. 페이지 로딩이 끝난 뒤 부르면 문서 전체를 지우고 새로 쓴다. 비동기 스크립트에서는 무시되고 파서를 막아 느리므로 쓰지 않는다.

한 번에 넣기

문서에 붙은 요소를 여러 번 고치면 브라우저가 매번 레이아웃을 다시 계산할 수 있다(브라우저 렌더링 과정). 문서 밖에서 다 만든 뒤 한 번에 붙인다.

const fragment = document.createDocumentFragment()
for (const city of cities) {
  const li = document.createElement('li')
  li.textContent = city
  fragment.append(li)
}
ul.append(fragment) // 삽입은 한 번

문자열에서 요소 만들기

템플릿 리터럴로 HTML을 쓰면 짧고 값을 끼워 넣기 쉽다. 하지만 문자열은 요소가 아니어서 classList·리스너를 붙일 수 없다. 문서에 넣기 전에 요소로 다루려면 파싱한다.

const fragment = document.createRange().createContextualFragment(html)
fragment.querySelector('img').addEventListener('load', onLoad)
container.append(fragment)

반복되는 마크업은 HTML의 <template>에 두고 template.content.cloneNode(true)로 복제하는 방법도 있다. 문자열에 사용자 입력이 섞이면 어느 방법이든 XSS 위험이 같다.

삭제

li.remove()          // 자기 자신을 트리에서 뺀다
ul.replaceChildren() // 자식 전부 비우기

뺀 노드도 변수가 참조하고 있으면 메모리에 남아 있어 다시 붙일 수 있다. 반대로, 지운 노드를 계속 참조하면 가비지 컬렉션되지 않는다.

이벤트 받기

요소에 리스너를 붙이는 addEventListener와 지우는 법, 전파·위임·옵션은 DOM 이벤트: 전파, 위임, 커스텀 이벤트에 있다. 노드가 어떤 종류인지(nodeType·nodeName·tagName) 확인하는 법은 DOM과 BOM에 있다.

jQuery 없이: 대응표

jQuery(2006)가 다듬은 API 상당수가 그대로 표준 DOM에 들어왔다. 지금은 모든 브라우저가 상시 업데이트(evergreen)되고 IE가 은퇴해, 새 코드에 jQuery를 들일 이유가 거의 없다.

jQuery표준 DOM
$('.a')document.querySelectorAll('.a'): NodeList는 forEach·for…of로 바로 순회한다
$el.remove() / .prepend() / .before() / .replaceWith()el.remove() / el.prepend() / el.before() / el.replaceWith()
$el.closest('div')el.closest('div')
$el.one('click', fn)el.addEventListener('click', fn, { once: true })
$el.fadeIn(), $el.animate({…})CSS 트랜지션 + 클래스 토글, 또는 el.animate()(Web Animations API, → JavaScript 애니메이션). jQuery animate는 숫자 값만 다뤄 transform·색을 못 움직였다
$.ajax()fetch()(→ AJAX와 fetch). HTTP 오류 상태에서 reject하지 않는 점만 주의한다
$el.hide()el.hidden = true 또는 클래스. hide()는 계산된 스타일을 읽어 느렸다
  • jQuery는 결과를 NodeList가 아닌 자체 객체로 감싸서, 표준 메서드를 쓸 수 없고 같은 일을 두 방식으로 배워야 했다.
  • $ 문법만 원하면 querySelectorAll을 감싼 몇 줄짜리 함수로 충분하다(Bling.js 같은 마이크로 라이브러리가 이 방식이다).
  • 과거에는 모자란 기능을 cdn.polyfill.io 같은 폴리필 서비스로 채웠지만, 이 도메인은 2024년 소유권이 넘어간 뒤 악성 코드를 배포한 공급망 공격에 쓰였다. 폴리필이 필요하면 빌드 도구로 번들에 넣는다.

핵심 정리

  • 선택은 querySelector/querySelectorAll. 어느 요소에서나 불러 범위를 좁힐 수 있다.
  • 탐색은 구조에 기대지 말고 closest(위)·querySelector(아래)로.
  • 사용자 정의 데이터는 data-*와 dataset, 클래스는 classList.
  • createElement → 문서 밖에서 조립 → append·insertAdjacentElement로 한 번에 삽입 → remove.
  • 텍스트는 textContent, HTML 문자열은 XSS를 알고 쓴다. innerHTML +=는 전체를 다시 쓴다.
  • 거의 언제나 DOM 속성을 쓰고, 적힌 원래 값·비표준 어트리뷰트만 getAttribute로 읽는다. input.value는 어트리뷰트로 되돌아가지 않는다.
  • 스타일은 클래스로 바꾸고, 계산이 필요한 값만 style에 단위와 함께 쓴다. 적용된 값은 getComputedStyle로 읽는다.

관련 노트: DOM과 BOM, DOM 이벤트: 전파, 위임, 커스텀 이벤트, 요소 크기·스크롤·좌표, MutationObserver, Selection과 Range, React 이벤트 처리, Debounce와 Throttle, 웹 보안

원본 기록15

댓글

GitHub 계정으로 댓글을 남길 수 있어요.