자바스크립트 DOM 다루기 - 요소 선택 생성 수정과 innerHTML 이 위험한 이유
이 단원에서 배우는 것
6단원까지 참가자 데이터를 거르고 바꾸고 합치는 법을 배웠다. 그런데 지금까지의 결과는 전부 console.log로만 확인했다. 이제 그 데이터를 실제 화면에 그린다. 여기서부터는 브라우저 전용이다. Node.js에는 document가 없으므로 1단원에서 만든 study.html + study.js 조합으로 실습한다.
querySelector계열로 요소를 찾고, 찾지 못했을 때의 결과를 처리한다.textContent와innerHTML의 차이를 이해하고, 사용자 입력을 화면에 넣는 안전한 방법을 쓴다.- 참가자 배열을 목록 요소로 만들어 화면에 붙이는 함수를 직접 작성한다.
이 단원의 API는 ECMAScript가 아니라 브라우저(DOM 표준)가 제공한다. replaceChildren은 2020년 이후 브라우저에 있고, 나머지는 훨씬 오래됐다.
왜 필요한가
HTML만으로 만든 페이지는 서버가 준 그대로 고정돼 있다. 참가자가 한 명 추가되면 페이지 전체를 다시 받아야 한다. 그런데 실제 화면에서 필요한 것은 대부분 이런 일이다.
- 미납자만 걸러서 목록을 다시 그린다
- 출석 횟수가 바뀌면 그 줄의 숫자만 고친다
- 참가자가 없으면 "아직 참가자가 없습니다"로 바꿔 놓는다
브라우저는 HTML을 읽어서 요소 하나하나를 객체로 만든 트리를 메모리에 갖고 있다. 이것이 DOM이다. 자바스크립트가 그 객체를 고치면 화면이 즉시 따라 바뀐다. 즉 DOM 조작은 "HTML 문자열을 다시 쓰는 일"이 아니라 "이미 만들어진 객체의 값을 바꾸는 일"이다. 이 관점 차이가 뒤에 나올 innerHTML 문제의 뿌리다.
문법과 예제
실습용 HTML
1단원의 study.html을 아래처럼 고친다. defer는 그대로 둔다.
<body>
<h1>스터디 참가자 명단</h1>
<p id="summary"></p>
<ul id="member-list"></ul>
</body>
요소 찾기
const summary = document.querySelector("#summary"); // CSS 선택자 하나
const items = document.querySelectorAll("#member-list li"); // 조건에 맞는 전부
console.log(summary); // <p id="summary"></p>
console.log(items.length); // 0
console.log(document.querySelector("#nothing")); // null
선택자를 못 찾으면 querySelector는 null을, querySelectorAll은 빈 목록을 돌려준다. 오류가 아니다. 그래서 1단원에서 본 Cannot read properties of null은 늘 그 다음 줄에서 터진다.
getElementById는 아이디 전용이라 조금 빠르지만, 선택자 문법을 하나로 통일할 수 있다는 이유로 실무에서는 querySelector를 기본으로 쓴다. 아이디는 앞에 #, 클래스는 .을 붙이는 CSS 규칙 그대로다.
const unpaidRows = document.querySelectorAll(".row.unpaid"); // 두 클래스를 모두 가진 요소
NodeList 는 배열이 아니다
const items = document.querySelectorAll("#member-list li");
items.forEach((li) => console.log(li.textContent)); // forEach 는 있다
// items.map(...) // TypeError: items.map is not a function
const texts = Array.from(items, (li) => li.textContent); // 배열로 바꿔서 쓴다
const texts2 = [...items].map((li) => li.textContent); // 전개 구문도 된다
6단원에서 배운 map·filter를 쓰려면 먼저 배열로 바꿔야 한다. Array.from이나 전개 구문 둘 다 쓴다.
내용 읽고 쓰기
const summary = document.querySelector("#summary");
summary.textContent = "참가자 3명 / 미납 1명"; // 글자로 넣는다
console.log(summary.textContent); // 참가자 3명 / 미납 1명
| 속성 | 하는 일 | 쓰는 곳 |
|---|---|---|
textContent | 내용을 글자 그대로 읽고 쓴다 | 기본값. 대부분 이걸 쓴다 |
innerHTML | 내용을 HTML로 해석해서 읽고 쓴다 | 내가 만든 안전한 마크업만 |
innerText | 화면에 보이는 대로 읽는다 | 거의 필요 없다. 느리다 |
value | 입력창의 현재 값(항상 문자열) | input, textarea, select |
innerText는 CSS로 숨긴 글자를 빼고 읽어 주는 대신, 값을 읽을 때마다 브라우저가 화면 배치를 다시 계산한다. 반복문 안에서 쓰면 눈에 띄게 느려진다. 특별한 이유가 없으면 textContent다.
속성·클래스·데이터
const li = document.querySelector("#member-list li");
li.classList.add("unpaid"); // 클래스 추가
li.classList.remove("unpaid"); // 제거
li.classList.toggle("selected"); // 있으면 빼고 없으면 넣는다
console.log(li.classList.contains("unpaid")); // false
li.dataset.memberId = "3"; // data-member-id="3" 로 들어간다
console.log(li.dataset.memberId); // "3" ← 항상 문자열이다
dataset은 요소에 내 데이터를 붙여 두는 표준 방법이다. 8단원에서 "클릭된 줄이 어느 참가자인가"를 알아낼 때 그대로 쓴다. 값이 항상 문자열이라는 점은 3단원의 형변환과 연결된다. Number(li.dataset.memberId)로 바꿔서 쓴다.
요소 만들어 붙이기 — 실무형 예제
6단원의 참가자 배열을 화면 목록으로 그린다.
const members = [
{ id: 1, name: "김지훈", sessions: 8, paid: true },
{ id: 2, name: "박서연", sessions: 3, paid: false },
{ id: 3, name: "이도현", sessions: 0, paid: true }
];
function createRow(member) {
const li = document.createElement("li");
li.textContent = `${member.name} / 출석 ${member.sessions}회 / ${member.paid ? "납부" : "미납"}`;
li.dataset.memberId = String(member.id);
if (!member.paid) {
li.classList.add("unpaid");
}
return li;
}
function renderMembers(list) {
const box = document.querySelector("#member-list");
const summary = document.querySelector("#summary");
if (list.length === 0) {
box.replaceChildren(); // 자식을 전부 비운다
summary.textContent = "아직 참가자가 없습니다";
return;
}
const rows = list.map(createRow); // 요소 배열을 먼저 만들고
box.replaceChildren(...rows); // 한 번에 교체한다
const unpaid = list.filter((m) => !m.paid).length;
summary.textContent = `참가자 ${list.length}명 / 미납 ${unpaid}명`;
}
renderMembers(members);
화면에는 세 줄이 뜨고 위에 참가자 3명 / 미납 1명이 표시된다. 여기서 눈여겨볼 점이 두 가지다.
- 요소를 다 만든 뒤 마지막에 한 번만 화면에 붙인다.
box.append(li)를 반복문 안에서 부르면 붙일 때마다 브라우저가 배치를 다시 계산한다. 줄 수가 많아지면 이 차이가 크다. - 데이터가 화면을 결정한다.
renderMembers(members.filter((m) => !m.paid))처럼 배열만 바꿔 다시 부르면 화면이 그대로 따라간다. DOM을 부분부분 고치는 것보다 훨씬 덜 틀린다.
replaceChildren()을 인자 없이 부르면 자식을 전부 지운다. 예전에 쓰던 box.innerHTML = ""보다 안전하고 의도가 분명하다.
다른 붙이기 방법들
box.append(li); // 마지막 자식으로 (여러 개, 문자열도 가능)
box.prepend(li); // 첫 자식으로
li.before(otherLi); // 형제로 앞에
li.remove(); // 자기 자신을 제거
box.insertAdjacentHTML("beforeend", "<li>고정 안내 문구</li>"); // HTML 문자열로
insertAdjacentHTML은 innerHTML과 달리 기존 자식을 다시 만들지 않고 끼워 넣는다. 다만 HTML로 해석한다는 위험은 똑같다. 다음 절이 그 얘기다.
실무에서 자주 틀리는 것
1. 사용자 입력을 innerHTML 로 넣는다
가장 위험한 실수다. 참가자 이름을 입력받아 목록에 넣는다고 하자.
// 위험한 코드
const name = input.value;
box.innerHTML += `<li>${name}</li>`;
누군가 이름 칸에 아래 문자열을 넣으면 어떻게 되는가.
<img src="x" onerror="alert(document.cookie)">
innerHTML은 넘겨받은 문자열을 HTML로 해석한다. 이름이 아니라 태그로 인식되고, 이미지 로딩이 실패하면서 onerror에 적힌 자바스크립트가 그 페이지의 권한으로 실행된다. 로그인 쿠키를 훔치거나 사용자를 대신해 요청을 보낼 수 있다. 이것이 XSS다. 참고로 <script> 태그를 넣는 것은 innerHTML로 실행되지 않는데, 그래서 "스크립트만 막으면 된다"고 생각하면 위 사례에 그대로 당한다.
// 안전한 코드 — 글자는 글자로 넣는다
const li = document.createElement("li");
li.textContent = input.value;
box.append(li);
규칙은 단순하다. 사람이 입력한 값이 조금이라도 섞이면 innerHTML과 insertAdjacentHTML을 쓰지 않는다. textContent는 무엇을 넣어도 글자로만 취급하므로 이 문제가 원천적으로 없다. 서버에서 받은 값도 결국 다른 사용자가 입력한 값이라는 점을 잊기 쉽다.
2. innerHTML += 로 목록을 늘린다
box.innerHTML += "<li>박서연</li>"; // 겉보기엔 한 줄 추가
실제로는 기존 자식을 전부 지우고 처음부터 다시 만든다. 문자열을 읽어서 이어붙이고 다시 해석하기 때문이다. 결과는 세 가지다.
- 8단원에서 붙일 이벤트 처리 함수가 전부 사라진다(요소가 새것으로 바뀌므로).
- 입력 중이던 값, 체크 상태, 포커스가 날아간다.
- 줄이 많아질수록 느려진다.
createElement + append, 또는 위 예제처럼 replaceChildren(...rows)를 쓴다.
3. 요소를 찾자마자 값을 쓴다
// 선택자를 오타 냈거나 아직 요소가 없으면 여기서 터진다
document.querySelector("#memberlist").textContent = "3명";
1단원의 defer 문제이기도 하고, 선택자 오타 문제이기도 하다. 원인을 빨리 찾으려면 화면 그리는 함수의 시작 부분에서 한 번 확인해 두는 편이 낫다.
const box = document.querySelector("#member-list");
if (box === null) {
throw new Error("#member-list 요소를 찾지 못했습니다");
}
4단원에서 말한 대로 box?.append(...)로 넘기면 오류는 안 나지만 화면이 조용히 비어 있게 된다. 반드시 있어야 하는 요소는 없을 때 티가 나야 한다.
4. class 를 className 으로 통째로 덮어쓴다
li.className = "unpaid"; // 기존 클래스가 전부 날아간다
li.classList.add("unpaid"); // 기존 것을 두고 추가
CSS 담당자가 붙여 둔 클래스나 다른 기능이 붙인 상태 클래스가 사라져서, 스타일이 깨지거나 다른 기능이 오작동한다. 클래스는 classList로만 만진다.
5. 화면에서 값을 읽어 계산에 쓴다
const shown = document.querySelector("#summary").textContent; // "참가자 3명 / 미납 1명"
화면에 있는 글자를 다시 파싱해서 숫자를 뽑는 코드는 표시 형식이 바뀌는 순간 깨진다. 계산은 원본 데이터 배열로 하고, 화면은 그 결과를 표시만 한다. 화면을 데이터 저장소로 쓰지 않는다는 원칙은 나중에 어떤 프레임워크를 쓰든 그대로 통한다.
표준 문서는 MDN의 Document.querySelector 문서에서 확인할 수 있다.
스스로 확인하기
document.querySelectorAll(".unpaid")의 결과에map을 바로 쓰면 오류가 난다. 이유와 해결 방법 두 가지를 쓰라.- 입력창의 값을 그대로 목록에 추가하려 한다. 아래 코드의 문제점과 고친 코드를 쓰라.
box.innerHTML += `<li>${input.value}</li>`; renderMembers를 고쳐서 "미납자만 보기" 목록을 그리려 한다. 함수 내부를 바꾸지 않고 호출만으로 해결하려면 어떻게 부르면 되는가?
정답
querySelectorAll이 돌려주는 것은 배열이 아니라 NodeList이고, NodeList에는forEach는 있지만map은 없다.Array.from(nodeList)또는[...nodeList]로 배열로 바꾼 뒤map을 쓴다.- 두 가지 문제가 있다. 사용자 입력이 HTML로 해석되어 XSS에 노출되고,
+=가 목록 전체를 다시 만들어 이벤트와 입력 상태를 날린다. 고친 코드:const li = document.createElement("li"); li.textContent = input.value; box.append(li); renderMembers(members.filter((m) => !m.paid));— 화면을 데이터에서 만들어 내도록 짰기 때문에 넘기는 배열만 바꾸면 된다. 미납자가 하나도 없으면 함수 안의 빈 목록 처리가 그대로 동작한다.