본문 바로가기

웹 개발/프론트엔드 - JavaScript

[JavaScript] 한 달 동안 자바스크립트 박살내기 (13) - DOM과 상태

「한 달 동안 자바스크립트 박살내기」는, ChatGPT쨩이 선생님이 되어, 매일 수업해주는 느낌으로 진행합니다.

GPT쨩의 말은 이렇게 작성합니다.

 

 

 

오늘 공부 내용 및 목표

 

오늘 학습 목표

오늘은 어제 배운 fetch와 비동기 결과가 실제 브라우저 화면으로 이어지는 지점을 다룬다. 지금까지는 값, 객체, Promise처럼 JavaScript 안쪽의 흐름을 많이 봤다면, 오늘부터는 그 데이터를 HTML 화면에 어떻게 반영하는지가 중심이야.

오늘 핵심은 DOM API 몇 개를 외우는 게 아니라, 데이터(state)와 화면(DOM)을 분리하고 state 변경 → render() → DOM 갱신 흐름을 만드는 것이야. 이 흐름은 나중에 React 같은 프레임워크를 이해할 때도 그대로 연결된다.

이번엔 말로만 자주 들은 DOM에 대해 배운다.

 

오늘의 위치

단계: 브라우저와 JavaScript 연결
주제: DOM, querySelector, 요소 생성/수정/삭제, state와 DOM 분리, 작은 render() 함수
필수 예상시간: 약 90분

 

 

사전 지식 1 - document

document는 브라우저가 현재 페이지의 HTML 문서를 JavaScript에서 접근할 수 있게 제공하는 객체야.
예를 들어 HTML이:
<p id="hp">100</p>
이라면 JavaScript에서:
console.log(document);
처럼 현재 문서 전체를 다룰 수 있어.
오늘은 document 자체의 세부 구조를 외우지 않아도 돼. "현재 페이지의 HTML에 접근하는 출발점" 정도로 잡으면 충분해.

document는 예약어처럼, 현재 페이지의 HTML문서를 말한다. 정도로 알고 가면 될거같다.

 

 

사전 지식 2 - CSS selector

CSS selector는 원래 CSS에서 스타일을 적용할 요소를 고르는 문법이지만, JavaScript에서도 요소를 찾을 때 그대로 사용한다.
<p id="hp">100</p>  
<p class="status">ready</p>
"#hp"      // id="hp"인 요소  
".status"  // class="status"인 요소  
"p"        // p 요소

CSS에서 배운 부분이다.

 

 

 

 

어제 내용 복습

 

복습 1

어제 배운 fetch()는 HTTP 404 응답을 받았을 때 반드시 rejected Promise가 될까?

안된다. 그래서 .status 로 확인해주어야 한다.

정답. .ok로 확인해도 된다.

 

 

복습 2

아래 async function에서 throw가 실행되면 load()가 반환하는 Promise는 어떤 상태가 될까?
async function load() {  
 throw new Error("failed");  
}

이러면 rejected가 된다.

정답.

 

 

복습 3

8/31 내용 복습. try / catch에서 try 안에서 예외가 발생하면 실행 흐름은 어디로 이동할까?

catch - finally 순으로 이동한다.

정답.

 

 

복습 4

8/27 내용 복습. 아래 map()의 callback은 각 원소를 받아 무엇을 반환해야 할까?
const doubled = [1, 2, 3].map((value) => {  
 // ?  
});

저장 대상의 이름이 doubled인걸 보면, return value * 2; 를 넣어주면 될거같다.

정답.

 

 

 

핵심 수업

 

DOM - HTML문서를 JavaScript가 다룰 수 있는 객체 구조로 표현한 것

DOM은 브라우저가 HTML 문서를 JavaScript에서 읽고 수정할 수 있도록 객체들의 트리 형태로 표현한 것이야.
예를 들어 HTML이 아래와 같다고 하자.
<div id="character">  
 <span class="name">Gran</span>  
 <span class="hp">100</span>  
</div>
브라우저는 단순히 이 문자열만 기억하는 게 아니라, 대략 이런 관계의 객체 구조를 만든다.

document
└─ div#character
     ├─ span.name
     │  └─ "Gran"
     └─ span.hp
           └─ "100"

음, 이렇게 요소 단위로 객체 구조가 만들어진다고 한다.

 

이 객체 구조를 JavaScript에서 찾아서 수정하면 브라우저 화면도 바뀐다.
중요한 구분은 이거야.

HTML → 문서의 원래 마크업
DOM → 브라우저가 그 문서를 객체 구조로 표현한 것

실행 중에 JavaScript가 DOM을 바꾸면, 처음 작성했던 HTML 파일 자체가 수정되는 건 아니야. 현재 브라우저 메모리 안의 문서 상태가 바뀌는 것이야.

JavaScript가 사용자 입력이나 시간 등에 따라 페이지 모습을 바꿀 때가 있는데, 그건 DOM을 바꾸는 것이라 HTML 파일 자체가 수정되는 것은 아니다.

HTML 파일을 브라우저 메모리에 로드한 뒤, 로드된 것을 보여주는 형태를 바꾸는 것이라고 할 수 있겠다.

 

 

Element와 querySelector()

Element는 div, p, button 같은 HTML 요소 하나를 JavaScript에서 표현하는 객체야.
그리고, querySelector()는 CSS selector 문자열을 받아 문서에서 처음 일치하는 Element 하나를 반환하는 메서드야.
예를 들어: 
<p id="hp">100</p>
라고 할 때, id="hp"인 요소를 찾으려면,
onst hpElement = document.querySelector("#hp");  
console.log(hpElement);

querySelector로 찾을 요소는 CSS selector로 지정해주면 된다.

그런데 여기서, querySelector는 가장 먼저 찾는 요소를 반환하기 때문에, 대상 요소가 없다면 null이 반환된다.

 

이렇게 요소를 찾고 나서는, 그 요소의 textContent라는 프로퍼티를 바꿈으로써 텍스트를 변경해줄 수 있다.

hpElement.textContent = "80";

이러면, 브라우저 화면의 텍스트도 80으로 바뀌게 된다.

 

 

DOM 관련 메소드 정리

document.createElement()는 새 HTML 요소에 해당하는 DOM 객체를 메모리 안에 만드는 메서드야.
const li = document.createElement("li");  
li.textContent = "Potion";

이러면 li요소가 새로 만들어지고, 내부 텍스트는 Potion으로 지정된다.

하지만, 이 시점에서 아직 화면엔 표시되지 않는데, 그 이유는 요소 객체가 생성만 되고 문서의 어디에 둘지를 지정하지 않았기 때문이다.

그 처리는, 아래에서 설명할 append()가 해준다.

 

append()는 DOM 요소의 마지막 자식으로 값이나 다른 요소를 추가하는 메서드야.
const itemsElement = document.querySelector("#items");  
  
const li = document.createElement("li");  
li.textContent = "Potion";  
  
itemsElement.append(li);

이러면, items 아이디를 가진 요소에 추가해준다.

이것으로, 실제 문서 트리에 연결되어 화면에 나타나게 된다.

 

remove()는 현재 요소를 DOM 트리에서 제거하는 메서드야.

remove()는 createElement()와 반대로, 그 요소를 DOM으로부터 제거하는 메소드이다.

 

 

state - 현재 프로그램이 기억하고 있는 데이터

오늘부터 state라는 말을 자주 쓸 거야.
state는 현재 프로그램 또는 화면이 어떤 상태인지 나타내는 데이터야.
예를 들어 전투 화면이라면:
const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100  
};
이 객체는 화면 그 자체가 아니야. 그냥 JavaScript가 가진 데이터야.

Next.js에서 useState로 자주 접했던 그 개념인것같다.

 

HTML이:
<p id="hp">100</p>
이고 JavaScript에서:
const battleState = {  
 hp: 100  
};  
  
battleState.hp = 80;
라고 해도 화면의 <p>는 자동으로 80이 되지 않아.

이는 왜냐하면, JavaScript의 battleState 객체는 DOM의 요소와 별개의 객체이기 때문이다.

그렇기 때문에, 화면도 바꾸려면 명시적으로 DOM을 수정해주어야 한다.

const hpElement = document.querySelector("#hp");  
hpElement.textContent = battleState.hp;

 

그런데, 여기서 문제는, 어떤 상태와 관련된 곳이 많아지면 그 갯수만큼 수정을 해주어야 한다는 번거로움이 존재한다.

그래서, 화면에 그리는 책임을 한 곳에 모으는 방법이 존재한다.

 

 

render() - 현재 state를 읽어 DOM을 그 상태에 맞게 갱신하는 함수

render()는 JavaScript에 원래 존재하는 특별한 API가 아니야. 우리가 화면 갱신 역할을 맡기려고 흔히 붙이는 함수 이름이야.
예를 들어:

JavaScript가 제공하는 함수가 아니라 그냥 그런 역할을 하는 함수 이름을 render라고 많이 짓는다는 이야기.

 

<h2 id="name"></h2>  
<p id="hp"></p>
const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100  
};  
  
const nameElement = document.querySelector("#name");  
const hpElement = document.querySelector("#hp");  
  
function render() {  
 nameElement.textContent = battleState.name;  
 hpElement.textContent = `${battleState.hp} / ${battleState.maxHp}`;  
}  
  
render();

이러면, 상태의 갱신에 맞게 UI를 갱신하려면 render()를 호출하기만 하면 된다.

흐름으론 다음과 같다.

 

어떤 원인 (사용자 입력 / API 응답 / 타이머 등)
↓
state 변경
↓
render()
↓
DOM 갱신
↓
화면 변경

 

 

선행 개념 - event와 addEventListener()

내일 이벤트 자체를 자세히 배울 거지만, 오늘 직접 코딩에서 버튼을 눌렀을 때 state를 바꿔야 하므로 최소한만 먼저 설명할게.
이벤트는 브라우저에서 발생한 사건이야.
예를 들면:

click → 사용자가 요소를 클릭함
input → 입력값이 바뀜
keydown → 키를 누름

이건 사실 익숙한 개념이다.

이러한 이벤트가 발생했을 때 callback을 등록하는게 addEventLister()이다.

 

const button = document.querySelector("#damage-button");  
  
button.addEventListener("click", function () {  
 console.log("clicked");  
});

이러면, button이 클릭되면 clicked가 출력되도록 된다.

 

 

DOM과 event

<h2 id="name"></h2>  
<p id="hp"></p>  
<button id="damage-button">20 데미지</button>
const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100  
};  
  
const nameElement = document.querySelector("#name");  
const hpElement = document.querySelector("#hp");  
const damageButton = document.querySelector("#damage-button");  
  
function render() {  
 nameElement.textContent = battleState.name;  
 hpElement.textContent = `${battleState.hp} / ${battleState.maxHp}`;  
}  
  
render();  
  
damageButton.addEventListener("click", function () {  
 battleState.hp -= 20;  
 render();  
});

이러면, "20데미지" 라는 버튼이 damageButton에 저장되고, damageButton의 클릭 이벤트가 발생할 때마다 상태의 hp가 20 감소, render()가 실행되게 된다.

버튼 클릭까지 연결된 흐름은 다음과 같다.

 

버튼 클릭
↓
callback 실행
↓
battleState.hp 변경
↓
render()
↓
DOM 갱신
↓
화면 변경

 

 

 

코드 보고 동작 예측하기

 

예측 1

HTML:
<p id="hp">100</p>
JavaScript:
const state = {  
 hp: 100  
};  
  
const hpElement = document.querySelector("#hp");  
  
state.hp -= 30;  
  
console.log(state.hp);  
console.log(hpElement.textContent);
두 줄의 출력은 각각 무엇일까?

첫 번째 출력은 70, 두 번째 출력은 100이다.

상태가 hpElement에 연결되어있지 않기 때문.

정답.

 

 

예측 2

<ul id="items"></ul>
const itemsElement = document.querySelector("#items");  
const li = document.createElement("li");  
li.textContent = "Potion";  
  
console.log(itemsElement.children.length);  
  
itemsElement.append(li);  
  
console.log(itemsElement.children.length);
두 번의 length는 각각 얼마일까?
참고로 children은 해당 요소의 자식 Element들을 나타내는 컬렉션이야. 오늘은 length로 개수만 확인하면 충분해.

각각 0, 1이다.

append가 실행되어야 DOM의 요소에 연결되기 때문이다.

정답.

 

 

예측 3

<p id="hp">100</p>
const state = {  
 hp: 100  
};  
  
const hpElement = document.querySelector("#hp");  
  
function render() {  
 hpElement.textContent = state.hp;  
}  
  
render();  
state.hp = 50;  
  
console.log(hpElement.textContent);  
  
render();  
  
console.log(hpElement.textContent);
두 번의 출력은 각각 무엇일까?

100, 50이다.

state.hp을 50으로 바꾼 뒤여도, render()이 실행되어야 hpElement에도 적용되기 때문이다.

정답.

 

 

 

직접 코딩

 

작은 전투 UI 만들기

작은 전투 UI를 만들어보자.
HTML은 아래를 그대로 사용해도 돼.
<!doctype html>  
<html lang="ko">  
<head>  
 <meta charset="UTF-8">  
 <title>Battle UI</title>  
</head>  
<body>  
 <h1>Battle</h1>  
  
 <h2 id="name"></h2>  
 <p id="hp"></p>  
 <p id="status"></p>  
  
 <button id="damage-button">20 데미지</button>  
 <button id="heal-button">10 회복</button>  
  
 <script src="./main.js"></script>  
</body>  
</html>
main.js에서는 아래 요구사항을 직접 구현해봐.
const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100,  
 status: "ready"  
};  
  
// TODO 1: 필요한 DOM 요소를 querySelector()로 찾기  
  
// TODO 2: render() 만들기  
// - name 표시  
// - "HP: 현재HP / 최대HP" 표시  
// - status 표시  
  
// TODO 3: 최초 render() 호출  
  
// TODO 4: damage-button click  
// - hp 20 감소  
// - 0 아래로 내려가지 않게 하기  
// - status를 "damaged"로 변경  
// - render()  
  
// TODO 5: heal-button click  
// - hp 10 증가  
// - maxHp보다 올라가지 않게 하기  
// - status를 "healed"로 변경  
// - render()

아래와 같이 작성해주었다.

 

const battleState = {
  name: "Gran",
  hp: 100,
  maxHp: 100,
  status: "ready"
};

const nameElement = document.querySelector("#name");
const hpElement = document.querySelector("#hp");
const statusElement = document.querySelector("#status");

function render() {
  nameElement.textContent = battleState.name;
  hpElement.textContent = battleState.hp;
  statusElement.textContent = battleState.status;
}

render();

const damageButton = document.querySelector("#damage-button");
damageButton.addEventListener("click", () => {
  battleState.hp = Math.max(0, battleState.hp - 20);
  battleState.status = "damaged";
  render();
})

const healButton = document.querySelector("#heal-button");
healButton.addEventListener("click", () => {
  battleState.hp = Math.min(battleState.maxHp, battleState.hp + 10);
  battleState.status = "healed";
  render();
})

 

 

코드 읽기 / 디버깅 드릴 1

<p id="hp">100</p>
const battleState = {  
 hp: 100  
};  
  
const hpElement = document.querySelector("#hp");  
  
function damage() {  
 battleState.hp -= 20;  
}  
  
damage();  
  
console.log(battleState.hp);
JavaScript 콘솔에는 80이 나오는데 브라우저 화면에는 계속 100이 보인다.
버그의 원인은 무엇이고, 구조적으로 어떻게 고치는 게 좋을까?

battleState 상태는 갱신되었지만, 그게 DOM에 반영되지 않았기 때문이다.

damage 내에서 hpElement를 갱신하는 처리를 추가해준다.

정답.

 

 

코드 읽기 / 디버깅 드릴 2

<p id="hp">100</p>
const hpElement = document.querySelector("#character-hp");  
hpElement.textContent = "80";
실행하면 Cannot set properties of null과 비슷한 TypeError가 발생했다.
가장 먼저 무엇을 확인해야 할까?

querySelector로 요소를 성공적으로 찾았는지를 확인해야한다.

정답.

 

 

코드 읽기 / 디버깅 드릴 3

const battleState = {  
 hp: 100  
};  
  
function render() {  
 document.querySelector("#hp").textContent = battleState.hp;  
}  
  
function damage() {  
 battleState.hp -= 20;  
 document.querySelector("#hp").textContent = battleState.hp;  
}
현재는 화면이 잘 동작한다. 그런데 render()가 있는데도 damage()에서 DOM을 직접 수정하는 구조가 장기적으로 좋지 않은 이유를 설명해봐.

battleState.hp의 값 변경을 반영해야하는 곳이 많아지면 damage() 내에도 그 수만큼 갱신처리를 넣어줘야 하기 때문이다.

정답.

 

 

 

종료 테스트

 

1번 문제

다음 코드에서 요소를 찾지 못하면 hpElement에는 무엇이 들어갈까?
const hpElement = document.querySelector("#hp");

null이 들어간다.

정답.

 

 

2번 문제

아래 코드 실행 후 state.hp가 50이 되었다. DOM의 텍스트도 자동으로 50이 되는가? 이유도 설명해봐.
const state = {  
 hp: 100  
};  
  
state.hp = 50;

자동으로 50이 되지 않는다. state.hp가 어느 요소에도 연결되어있지 않기 때문이다.

정답.

 

 

3번 문제

createElement()와 append()의 역할 차이를 설명해봐.

createElement()는 DOM상에 새로운 요소를 생성하는 역할을 하고, append()는 그러한 요소를 다른 요소에 연결해주는 역할을 한다.

정답.

 

 

4번 문제

아래 코드에서 첫 번째 console.log와 두 번째 console.log가 다른 값을 출력하는 이유를 설명해봐.
<p id="hp">100</p>
const state = {  
 hp: 100  
};  
  
const hpElement = document.querySelector("#hp");  
  
state.hp = 40;  
console.log(state.hp);  
console.log(hpElement.textContent);

첫번째 출력은 JavaScript상의 상태이기 때문에 40이 출력되고, 두번째 출력은 DOM상의 요소의 텍스트값이기 때문에 100이 출력된다.

정답.

 

 

 

감상

 

잠이 안와서 그대로 날밤새고 미라클 모닝을 했다. 뿌듯한데 뭔가 몸이 처진다...