여기서부터 JS는 브라우저 밖으로 나온다. 지금까지 다룬 값과 함수는 화면과 상관없이 돌아갔지만, DOM(Document Object Model, 브라우저가 HTML을 객체 트리로 만들어 둔 것)을 만지는 순간 코드가 실제 페이지를 바꾼다. 버튼을 누르면 목록이 늘고 글자가 바뀌는 그 모든 일이 DOM 조작이다.


이번 편의 코드는 브라우저에서 도는 것이라 결과를 콘솔 숫자로 못 보여준다. 대신 화면에서 무엇이 어떻게 바뀌는지로 짚는다. 선택하고, 내용을 바꾸고, 요소를 만들어 붙이고, 이벤트를 듣는 이 네 가지가 DOM의 거의 전부다.


querySelector로 하나, querySelectorAll로 여럿을 고른다. 옛날엔 getElementByIdgetElementsByClassName을 따로 외웠지만, 지금은 CSS 선택자를 그대로 쓰는 이 둘이면 된다. 앞의 것은 맞는 첫 요소 하나를, 뒤의 것은 맞는 전부를 준다.


const title = document.querySelector("#title");
const first = document.querySelector(".item");
const all = document.querySelectorAll(".item");


#title은 아이디가 title인 요소, .item은 클래스가 item인 요소를 CSS에서와 똑같은 문법으로 고른다. querySelector(".item")은 여러 개 중 화면에서 가장 먼저 나오는 하나만 잡고, querySelectorAll은 걸린 것 모두를 담은 목록을 준다. CSS 선택자를 알면 새 메서드를 외울 필요가 없다는 게 이 둘의 진짜 장점이다.


못 찾으면 null이다. DOM 초보가 가장 먼저 만나는 에러가 여기서 나온다. querySelector가 아무것도 못 찾으면 빈 것도 에러도 아닌 null을 주고, 그 null에 대고 무언가를 하려 들면 그때 터진다.


const box = document.querySelector(".nope");
box.textContent = "안녕";


화면엔 아무 변화도 없고 대신 콘솔에 Cannot set properties of null(널의 속성을 설정할 수 없음)이라는 빨간 에러가 뜬다. .nope이라는 요소가 없어 boxnull이 됐기 때문이다. 스크립트를 <head>에 두면 본문이 그려지기 전에 실행돼 멀쩡한 선택자도 null이 되는데, 이 함정을 나는 처음에 며칠 겪었다. 스크립트는 문서 끝이나 defer로 미뤄 두는 게 안전하다.


textContent와 innerHTML은 성질이 다르다. 요소 안의 내용을 바꾸는 길은 둘이다. textContent는 넣은 문자열을 그냥 글자로 박고, innerHTML은 그 문자열을 HTML로 해석해 태그를 살린다.


el.textContent = "<b>굵게</b>";
el.innerHTML = "<b>굵게</b>";


같은 문자열인데 결과가 다르다. textContent 쪽은 화면에 <b>굵게</b>라는 글자가 태그째 그대로 보이고, innerHTML 쪽은 태그가 해석돼 굵은 굵게가 보인다. 편해 보인다고 사용자가 입력한 값을 innerHTML에 그대로 넣으면, 남이 심은 스크립트 태그까지 실행되는 XSS(교차 사이트 스크립팅) 구멍이 뚫린다. 그냥 글자면 무조건 textContent가 안전하다.


속성은 setAttribute, 클래스는 classList로 만진다. 링크 주소나 이미지 경로 같은 속성은 setAttribute로 바꾸고, 클래스는 통째로 갈아엎기보다 classList로 하나씩 더하고 뺀다.


link.setAttribute("href", "https://example.com");
box.classList.add("active");
box.classList.remove("hidden");
box.classList.toggle("open");


href가 새 주소로 바뀌고, box에는 active 클래스가 붙고 hidden이 떨어진다. toggle은 있으면 빼고 없으면 붙여서, 클릭할 때마다 메뉴를 열고 닫는 동작에 딱 맞는다. className에 문자열을 통째로 대입하면 기존 클래스가 전부 날아가니, 하나만 손볼 땐 classList가 사고를 막아준다.


요소를 만들어 붙인다. 화면에 없던 것을 새로 그리려면 요소를 만들고, 내용을 채우고, 어딘가에 붙이는 세 걸음을 밟는다.


const li = document.createElement("li");
li.textContent = "새 할 일";
li.classList.add("item");
document.querySelector("#list").appendChild(li);


createElement로 만든 li는 이 시점엔 메모리에만 있고 화면엔 없다. appendChild#list 안에 붙이는 순간 비로소 목록 맨 아래에 "새 할 일" 항목이 나타난다. 만들자마자 붙였는데 화면에 안 보인다면 대개 붙일 부모를 잘못 골랐거나 그 부모가 null인 경우다.


querySelectorAll의 결과는 배열이 아니다. 이게 은근히 발목을 잡는다. querySelectorAll이 주는 건 NodeList라는 유사 배열이라 forEach는 되지만 map이나 filter는 없다. 배열 메서드를 쓰려면 진짜 배열로 바꿔야 한다.


const items = document.querySelectorAll(".item");
items.forEach(el => el.classList.add("done"));
const texts = [...items].map(el => el.textContent);


forEach로 모든 .itemdone 클래스를 다는 건 잘 된다. 그런데 items.map(...)이라 쓰면 map is not a function으로 터진다. NodeList엔 map이 없어서다. [...items]로 펼치거나 Array.from(items)로 진짜 배열을 만든 뒤에야 map으로 텍스트를 뽑을 수 있다. 이 한 줄을 몰라 나는 처음에 반복문을 손으로 돌렸다.


이벤트는 addEventListener로 듣는다. 클릭이나 입력 같은 사용자 동작에 반응하려면 요소에 귀를 달아준다. 넘긴 함수는 그 일이 벌어질 때마다 불리고, 무슨 일이 났는지 담은 이벤트 객체를 받는다.


const btn = document.querySelector("#save");
btn.addEventListener("click", (e) => {
e.preventDefault();
console.log("눌린 요소:", e.target);
});


버튼을 누를 때마다 함수가 돌아 콘솔에 눌린 요소가 찍힌다. e.preventDefault()는 폼 제출이나 링크 이동 같은 브라우저 기본 동작을 막는데, 이걸 빼먹으면 페이지가 새로고침돼 방금 만든 상태가 통째로 날아간다. e.target은 실제로 눌린 요소를 가리키는데, 이 값 하나가 이벤트를 훨씬 영리하게 다루는 열쇠가 된다.


이벤트 위임으로 한 번에 듣는다. 목록 항목마다 리스너를 다 달면, 항목이 늘 때마다 또 달아줘야 한다. 대신 부모 하나에만 귀를 달고 e.target으로 어느 자식이 눌렸는지 가려내면 훨씬 깔끔하다.


document.querySelector("#list").addEventListener("click", (e) => {
if (e.target.matches(".item")) {
e.target.classList.toggle("done");
}
});


#list 안 아무 데나 클릭이 나면 부모의 리스너가 받고, e.target.matches(".item")로 눌린 게 항목일 때만 done 클래스를 뒤집는다. 나중에 appendChild로 새 항목을 붙여도 리스너를 다시 안 달아도 되는데, 부모가 이미 대신 듣고 있어서다. 이 이벤트 위임(event delegation)은 항목이 계속 늘고 주는 목록에서 특히 값지다.


DOM을 만질 때 나는 순서를 늘 지킨다. 먼저 제대로 선택됐는지 확인하고, 사용자 입력은 textContent로 안전하게 넣고, 목록 이벤트는 부모에 위임한다. 선택이 null인지, 스크립트가 문서보다 먼저 도는지, NodeList를 배열로 바꿨는지 이 셋만 챙겨도 DOM에서 나던 빨간 에러의 대부분이 사라진다. 브라우저 콘솔을 열어 querySelector를 직접 쳐보는 게 가장 빠른 연습이다.