Skip to Content
WebJavaScriptWeb APIs13. MutationObserver·ResizeObserver로 자동 저장 표시·반응형 레이아웃

이번 편의 결과물: 메모 편집 중 자동으로 “저장 중” 표시가 뜨고, 창을 좁히면 목록이 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는 특정 요소의 콘텐츠 박스 크기가 바뀔 때마다 콜백을 호출합니다. windowresize 이벤트와 다른 점은, 창 전체가 아니라 개별 요소의 크기 변화를 감지한다는 것입니다.

방식감지 대상언제 쓰나
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 dev

4. 확인

  • 메모를 클릭해 상세 화면을 열고, note-body-editor 안을 클릭해 글자를 입력하면 즉시 save-status 자리에 “저장 중…”이 뜬다.
  • 브라우저 창 폭을 480px 아래로 줄이면 목록 화면의 note-list가 2열에서 1열로 바뀐다. 다시 넓히면 2열로 돌아온다.
  • 개발자 도구 콘솔에서 document.querySelector('#note-body-editor').textContent = '코드로 바꾼 값'을 실행해도 “저장 중…”이 뜨는지 확인한다(코드로 자식 노드를 바꿔도 감지되는지 확인하는 목적).

직접 해보기

  1. watchListLayout의 임계값 480을 640으로 바꿔, 더 넓은 화면에서도 1열로 전환되게 해봅니다.
  2. 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'을 실행했을 때도 콜백이 호출됩니다.

자주 하는 실수

증상원인고치는 법
편집 영역에 타이핑해도 콜백이 안 불림편집 영역이 여전히 textareainput편집 영역을 contenteditable 요소로 바꾸거나, input 이벤트를 함께 쓴다
상세 화면을 여러 번 열면 콜백이 중복 호출됨renderDetail을 부를 때마다 new MutationObserver를 새로 만들고 이전 관찰자를 정리하지 않음새 관찰자를 만들기 전에 disconnect()로 이전 관찰자를 반드시 끊는다
characterData 변화가 감지 안 됨텍스트 노드 자체가 아니라 부모 요소 전체를 교체함(textContent = '' 후 재대입)subtree: true를 켜서 하위 텍스트 노드 변화까지 포함한다

확인 문제

문제 14지선다
MutationObserver가 textarea에 입력한 글자를 감지하지 못하는 이유로 옳은 것은?
문제 24지선다
ResizeObserver와 window의 resize 이벤트의 차이로 옳은 것은?
문제 34지선다
MutationObserver 콜백이 호출되는 방식에 대한 설명으로 옳은 것은?
문제 44지선다
watchEditorChanges가 새 관찰자를 만들기 전에 disconnect()를 호출하는 이유로 옳은 것은?

참고 자료

Last updated on