Devin.KR
로그인

자바스크립트 DOM 다루기 - 요소 선택 생성 수정과 innerHTML 이 위험한 이유

개발자 조회 1

이 단원에서 배우는 것

6단원까지 참가자 데이터를 거르고 바꾸고 합치는 법을 배웠다. 그런데 지금까지의 결과는 전부 console.log로만 확인했다. 이제 그 데이터를 실제 화면에 그린다. 여기서부터는 브라우저 전용이다. Node.js에는 document가 없으므로 1단원에서 만든 study.html + study.js 조합으로 실습한다.

  • querySelector 계열로 요소를 찾고, 찾지 못했을 때의 결과를 처리한다.
  • textContentinnerHTML의 차이를 이해하고, 사용자 입력을 화면에 넣는 안전한 방법을 쓴다.
  • 참가자 배열을 목록 요소로 만들어 화면에 붙이는 함수를 직접 작성한다.

이 단원의 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

선택자를 못 찾으면 querySelectornull을, 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 문자열로

insertAdjacentHTMLinnerHTML과 달리 기존 자식을 다시 만들지 않고 끼워 넣는다. 다만 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);

규칙은 단순하다. 사람이 입력한 값이 조금이라도 섞이면 innerHTMLinsertAdjacentHTML을 쓰지 않는다. 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 문서에서 확인할 수 있다.

스스로 확인하기

  1. document.querySelectorAll(".unpaid")의 결과에 map을 바로 쓰면 오류가 난다. 이유와 해결 방법 두 가지를 쓰라.
  2. 입력창의 값을 그대로 목록에 추가하려 한다. 아래 코드의 문제점과 고친 코드를 쓰라.
    box.innerHTML += `<li>${input.value}</li>`;
  3. renderMembers를 고쳐서 "미납자만 보기" 목록을 그리려 한다. 함수 내부를 바꾸지 않고 호출만으로 해결하려면 어떻게 부르면 되는가?

정답

  1. querySelectorAll이 돌려주는 것은 배열이 아니라 NodeList이고, NodeList에는 forEach는 있지만 map은 없다. Array.from(nodeList) 또는 [...nodeList]로 배열로 바꾼 뒤 map을 쓴다.
  2. 두 가지 문제가 있다. 사용자 입력이 HTML로 해석되어 XSS에 노출되고, +=가 목록 전체를 다시 만들어 이벤트와 입력 상태를 날린다. 고친 코드:
    const li = document.createElement("li");
    li.textContent = input.value;
    box.append(li);
  3. renderMembers(members.filter((m) => !m.paid)); — 화면을 데이터에서 만들어 내도록 짰기 때문에 넘기는 배열만 바꾸면 된다. 미납자가 하나도 없으면 함수 안의 빈 목록 처리가 그대로 동작한다.