Skip to Content
WebJavaScriptWeb APIs14. 타이머·requestAnimationFrame·Page Lifecycle

이번 편의 결과물: 타이핑을 멈추면 500ms 후 자동 저장되고, 탭을 벗어나면 즉시 저장됩니다. · 다루는 개념: setTimeout 디바운스, requestAnimationFrame으로 저장 애니메이션, visibilitychange로 백그라운드 저장

이 편에서 만드는 파일

notes-app/ └── src/ ├── render.js (~) └── main.js (~)

개념 정리

디바운스: 마지막 입력 뒤에만 실행

디바운스(debounce)는 짧은 시간 안에 이벤트가 여러 번 발생해도, 마지막 이벤트 뒤 일정 시간이 지나야 실제 동작을 한 번만 실행하는 패턴입니다. 타이핑마다 저장을 시도하면 스토리지 호출이 낭비되므로, 입력이 멈춘 뒤에만 저장합니다.

let timer = null function debounceSave() { clearTimeout(timer) timer = setTimeout(save, 500) }

키를 누를 때마다 clearTimeout으로 이전 예약을 취소하고 새로 예약합니다. 500ms 안에 다음 키 입력이 없어야 save가 실행됩니다.

requestAnimationFrame으로 애니메이션 만들기

requestAnimationFrame(줄여서 rAF)은 콜백을 다음 화면 갱신 직전에 호출해달라고 브라우저에 요청합니다. setInterval로 애니메이션을 만들면 화면 주사율과 어긋나 끊기거나 낭비가 생기지만, rAF는 브라우저의 실제 렌더링 타이밍에 맞춰 호출됩니다.

방식호출 시점탭이 백그라운드일 때
setInterval(fn, 16)고정 간격(대략)계속 실행됨(배터리 낭비)
requestAnimationFrame(fn)다음 리페인트 직전브라우저가 자동으로 쉼

rAF 콜백은 인자로 현재 시각(고정밀 타임스탬프)을 받습니다. 이전 프레임과의 시간 차(delta)를 계산해 진행률을 구하는 것이 표준 패턴입니다.

Page Lifecycle과 visibilitychange

탭을 다른 창으로 전환하거나 최소화하면 document.visibilityState'visible'에서 'hidden'으로 바뀌고, document에서 visibilitychange 이벤트가 발생합니다. 저장 대기 중인 타이머가 있는데 탭이 백그라운드로 가면, 타이머가 끝나기 전에 브라우저가 탭을 완전히 종료시킬 수도 있습니다. visibilitychange에서 대기 중인 저장을 즉시 실행해 이 위험을 없앱니다.

이벤트발생 시점이번 편에서의 용도
visibilitychange탭이 보이거나 숨겨질 때hidden이 되면 대기 중인 저장을 즉시 실행

beforeunload는 탭을 완전히 닫을 때만 발생하고 모바일 브라우저에서는 신뢰도가 낮으므로, 이번 편에서는 visibilitychange를 기본으로 씁니다.

실습

1. src/render.js 열기

13편에서 만든 renderDetail, watchEditorChanges, getEditorText, watchListLayout은 그대로 두고, setSaveStatus에 투명도 처리를 더하고 rAF 기반 저장 완료 애니메이션 함수를 추가합니다.

2. 코드 작성

// src/render.js (renderDetail, watchEditorChanges, getEditorText, watchListLayout은 그대로 유지) export function setSaveStatus(text) { const statusEl = document.querySelector('#save-status') if (!statusEl) return statusEl.textContent = text statusEl.style.opacity = text === '' ? '0' : '1' } export function animateSavedBadge() { const statusEl = document.querySelector('#save-status') if (!statusEl) return statusEl.textContent = '저장됨' statusEl.style.opacity = '0' const duration = 300 let start = null function step(timestamp) { if (start === null) start = timestamp const elapsed = timestamp - start const progress = Math.min(elapsed / duration, 1) statusEl.style.opacity = String(progress) if (progress < 1) { requestAnimationFrame(step) } else { setTimeout(() => setSaveStatus(''), 800) } } requestAnimationFrame(step) }

animateSavedBadgerAF를 재귀 호출하는 루프입니다. 첫 호출에서 받은 타임스탬프를 start에 저장해두고, 이후 프레임마다 경과 시간(elapsed)을 계산해 opacity를 0에서 1로 서서히 올립니다. 애니메이션이 끝나면 0.8초 뒤 문구를 지웁니다.

// src/main.js (showDetail과 저장 관련 로직만 표시. registerRoute 등 라우팅 로직은 그대로 유지) import { getEditorText, watchEditorChanges, setSaveStatus, animateSavedBadge, renderDetail } from './render.js' import { editNote } from './notes.js' let saveTimer = null let editingId = null function scheduleSave() { clearTimeout(saveTimer) setSaveStatus('저장 중...') saveTimer = setTimeout(runSave, 500) } async function runSave() { if (!editingId) return const body = getEditorText() await editNote(editingId, { body }) saveTimer = null animateSavedBadge() } function flushPendingSave() { if (saveTimer === null) return clearTimeout(saveTimer) saveTimer = null runSave() } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushPendingSave() }) async function showDetail(params) { // 기존 로직으로 notes를 불러와 note를 찾는 부분은 그대로 둡니다. renderDetail(note, viewRoot) if (note) { editingId = note.id watchEditorChanges(scheduleSave) } }

scheduleSave는 편집 영역이 바뀔 때마다 호출되지만, clearTimeout으로 이전 예약을 취소하므로 실제 runSave는 마지막 변경 뒤 500ms가 지나야 실행됩니다. editNote는 11편에서 만든 notes.js의 함수로, IndexedDB에 변경분을 반영합니다. flushPendingSave는 예약된 타이머가 있으면 즉시 실행해, 탭이 백그라운드로 가기 전에 저장을 끝냅니다.

3. 실행

npm run dev

4. 확인

  • 메모 상세 화면의 편집 영역에 글자를 입력하고 바로 멈추면, 0.5초 뒤 “저장됨” 문구가 서서히 나타났다가 0.8초 후 사라진다(부드러운 opacity 전환 확인).
  • 글자를 계속 이어서 입력하면 “저장 중…”이 유지되고 “저장됨”으로 바뀌지 않는다(디바운스 확인).
  • 편집 중 다른 탭으로 전환하면(visibilitychange) 0.5초를 기다리지 않고 즉시 저장이 실행된다. 개발자 도구 Application 탭에서 IndexedDB 값을 확인해 반영 여부를 본다.

직접 해보기

  1. 디바운스 대기 시간을 500ms에서 1000ms로 늘려, 저장까지 걸리는 시간이 어떻게 달라지는지 확인해봅니다.
  2. animateSavedBadgeduration을 800으로 늘려 애니메이션이 더 천천히 진행되게 해봅니다.

정답 보기

saveTimer = setTimeout(runSave, 1000)

animateSavedBadge 내부의 const duration = 300const duration = 800으로 바꾸면 됩니다. progresselapsed / duration이므로 값이 커질수록 같은 경과 시간에 대한 진행률이 낮아져 애니메이션이 느리게 보입니다.

자주 하는 실수

증상원인고치는 법
저장이 계속 미뤄져서 영영 실행 안 됨매번 clearTimeout 없이 setTimeout만 계속 호출해 타이머가 계속 쌓임새로 예약하기 전에 반드시 clearTimeout(saveTimer)를 먼저 호출한다
애니메이션이 뚝뚝 끊김setInterval로 애니메이션을 구현해 화면 갱신 주기와 어긋남requestAnimationFrame으로 바꿔 브라우저 리페인트에 맞춘다
탭을 벗어난 순간 입력한 내용이 저장되지 않음visibilitychange를 등록하지 않았거나 flushPendingSave를 호출하지 않음hidden 상태가 되는 즉시 대기 중인 저장을 실행하도록 연결한다

확인 문제

문제 14지선다
디바운스 패턴에서 clearTimeout을 먼저 호출하는 이유로 옳은 것은?
문제 24지선다
requestAnimationFrame이 setInterval보다 애니메이션에 적합한 이유로 옳은 것은?
문제 34지선다
document.visibilityState가 hidden으로 바뀌는 시점으로 옳은 것은?
문제 44지선다
이번 편의 runSave 함수가 saveTimer를 null로 되돌리는 이유로 옳은 것은?

참고 자료

Last updated on