이번 편의 결과물: 메모 편집 중 자동으로 “저장 중” 표시가 뜨고, 창을 좁히면 목록이 1열로 바뀝니다. · 다루는 개념: MutationObserver로 편집 중 변경 감지, ResizeObserver로 요소 크기 변화 감지, 콜백 배치(batch) 타이밍
이 편에서 만드는 파일
notes-app/
└── src/
├── render.js (~)
└── main.js (~)개념 정리
MutationObserver가 감지하는 것
MutationObserver는 DOM 트리의 변화를 비동기로 관찰합니다. 관찰 대상은 속성(attribute), 자식 노드 추가·삭제(childList), 텍스트 내용(characterData) 세 종류입니다.
| 옵션 | 감지 대상 | 예시 |
|---|---|---|
attributes: true | 속성값 변경 | class, data-* 변경 |
childList: true | 자식 노드 추가·삭제 | appendChild, removeChild |
characterData: true | 텍스트 노드 내용 변경 | 텍스트 노드의 .data 변경 |
subtree: true | 대상 하위 전체 포함 | 자손 노드의 변화까지 포함 |
중요한 함정: MutationObserver는 <textarea>나 <input>에 사용자가 타이핑한 값을 감지하지 못합니다. 타이핑은 요소의 value 프로퍼티만 바꿀 뿐 DOM 트리 자체를 바꾸지 않기 때문입니다. DOM 변화로 감지하려면 편집 영역이 contenteditable 속성을 가진 일반 요소여야 합니다. 10편의 상세 화면(renderDetail)에 있던 읽기 전용 본문(<p>${note.body}</p>)을 contenteditable div로 바꾸는 이유입니다.
콜백은 마이크로태스크 단위로 배치된다
MutationObserver의 콜백은 변화가 생길 때마다 즉시 실행되지 않습니다. 같은 태스크 안에서 일어난 변화들을 모아(batch) 하나의 배열(MutationRecord[])로 콜백에 한 번 전달합니다. 한 글자씩 타이핑해도 콜백이 매 키 입력마다 여러 번 호출되지 않고, 브라우저가 처리 가능한 시점에 모아서 호출됩니다.
ResizeObserver로 요소 크기 관찰
ResizeObserver는 특정 요소의 콘텐츠 박스 크기가 바뀔 때마다 콜백을 호출합니다. window의 resize 이벤트와 다른 점은, 창 전체가 아니라 개별 요소의 크기 변화를 감지한다는 것입니다.
| 방식 | 감지 대상 | 언제 쓰나 |
|---|---|---|
window.addEventListener('resize', ...) | 브라우저 창 전체 | 뷰포트 크기 자체가 중요할 때 |
ResizeObserver | 관찰 대상 요소 하나 | 레이아웃 변화로 특정 요소만 좁아질 때 |
실습
1. src/render.js 열기
09~12편에서 만든 renderList, appendNotes는 그대로 두고, renderDetail의 본문 표시 부분을 contenteditable div로 바꾼 뒤 관찰자 관련 함수를 추가합니다.
2. 코드 작성
// src/render.js (renderList, appendNotes는 그대로 유지)
export function renderDetail(note, container) {
if (!note) {
container.innerHTML = '<p>메모를 찾을 수 없습니다.</p><a href="/" data-link>목록으로</a>'
return
}
container.innerHTML = `
<a href="/" data-link>← 목록으로</a>
<h2>${note.title}</h2>
<div id="note-body-editor" contenteditable="true">${note.body}</div>
<span id="save-status"></span>
<p>태그: ${note.tags.join(', ') || '없음'}</p>
`
}
let editorObserver = null
export function watchEditorChanges(onChange) {
const editorEl = document.querySelector('#note-body-editor')
if (!editorEl) return
if (editorObserver) editorObserver.disconnect()
editorObserver = new MutationObserver(() => {
onChange()
})
editorObserver.observe(editorEl, {
childList: true,
characterData: true,
subtree: true,
})
}
export function getEditorText() {
const editorEl = document.querySelector('#note-body-editor')
return editorEl ? editorEl.textContent : ''
}
export function setSaveStatus(text) {
const statusEl = document.querySelector('#save-status')
if (statusEl) statusEl.textContent = text
}
let layoutObserver = null
export function watchListLayout() {
if (layoutObserver) return
const appEl = document.querySelector('#app')
layoutObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const listEl = document.querySelector('#note-list')
if (!listEl) continue
listEl.classList.toggle('note-list--compact', entry.contentRect.width < 480)
}
})
layoutObserver.observe(appEl)
}renderDetail은 상세 화면을 새로 그릴 때마다 호출되므로, #note-body-editor도 매번 새로 생성됩니다. 그래서 watchEditorChanges는 호출될 때마다 기존 관찰자를 disconnect한 뒤 새 요소를 다시 관찰합니다. watchListLayout은 #app 전체 크기를 관찰하다가, 목록 화면에 #note-list가 있을 때만 note-list--compact 클래스를 토글합니다(상세 화면에는 #note-list가 없으므로 continue로 건너뜁니다).
// src/main.js (showDetail 함수와 초기화 부분만 표시. registerRoute 등 라우팅 로직은 그대로 유지)
import { renderDetail, watchEditorChanges, setSaveStatus, watchListLayout } from './render.js'
async function showDetail(params) {
// 기존 로직으로 notes를 불러와 note를 찾는 부분은 그대로 둡니다.
renderDetail(note, viewRoot)
if (note) {
watchEditorChanges(() => setSaveStatus('저장 중...'))
}
}
// bootstrap 등 앱 초기화 시점에 한 번만 호출합니다.
watchListLayout()watchEditorChanges에 넘긴 콜백은 지금은 문구만 띄우고 지우지는 않습니다. 실제로 저장을 실행하고 문구를 지우는 타이밍 제어는 14편(타이머·rAF)에서 이어집니다.
/* src/styles.css 에 추가 */
#note-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
#note-list.note-list--compact {
grid-template-columns: 1fr;
}
#save-status {
font-size: 0.85rem;
color: #6b7280;
}3. 실행
npm run dev4. 확인
- 메모를 클릭해 상세 화면을 열고,
note-body-editor안을 클릭해 글자를 입력하면 즉시save-status자리에 “저장 중…”이 뜬다. - 브라우저 창 폭을 480px 아래로 줄이면 목록 화면의
note-list가 2열에서 1열로 바뀐다. 다시 넓히면 2열로 돌아온다. - 개발자 도구 콘솔에서
document.querySelector('#note-body-editor').textContent = '코드로 바꾼 값'을 실행해도 “저장 중…”이 뜨는지 확인한다(코드로 자식 노드를 바꿔도 감지되는지 확인하는 목적).
직접 해보기
watchListLayout의 임계값 480을 640으로 바꿔, 더 넓은 화면에서도 1열로 전환되게 해봅니다.editorObserver의 옵션에attributes: true를 추가하고, 편집 영역에data-*속성을 콘솔에서 바꿨을 때도 콜백이 호출되는지 확인해봅니다.
정답 보기
const isNarrow = entry.contentRect.width < 640
listEl.classList.toggle('note-list--compact', isNarrow)observe 옵션을 { childList: true, characterData: true, subtree: true, attributes: true }로 바꾸면, 콘솔에서 document.querySelector('#note-body-editor').dataset.test = '1'을 실행했을 때도 콜백이 호출됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 편집 영역에 타이핑해도 콜백이 안 불림 | 편집 영역이 여전히 textarea나 input | 편집 영역을 contenteditable 요소로 바꾸거나, input 이벤트를 함께 쓴다 |
| 상세 화면을 여러 번 열면 콜백이 중복 호출됨 | renderDetail을 부를 때마다 new MutationObserver를 새로 만들고 이전 관찰자를 정리하지 않음 | 새 관찰자를 만들기 전에 disconnect()로 이전 관찰자를 반드시 끊는다 |
characterData 변화가 감지 안 됨 | 텍스트 노드 자체가 아니라 부모 요소 전체를 교체함(textContent = '' 후 재대입) | subtree: true를 켜서 하위 텍스트 노드 변화까지 포함한다 |