이번 편의 결과물: 메모 목록을 스크롤로 끝까지 내리면 화면 아래 감시 요소가 보이는 순간 다음 20개가 자동으로 이어붙습니다. · 다루는 개념: IntersectionObserver 생성·observe, 감시 대상(sentinel) 패턴, 페이지 단위 추가 로드
이 편에서 만드는 파일
notes-app/
└── src/
├── storage.js (~)
├── render.js (~)
└── main.js (~)개념 정리
기존 스크롤 이벤트 방식의 문제
무한 스크롤을 scroll 이벤트로 구현하면 스크롤할 때마다 getBoundingClientRect() 같은 레이아웃 계산을 반복 실행해야 해서 메인 스레드에 부담을 줍니다. IntersectionObserver는 브라우저가 렌더링 파이프라인 안에서 교차 여부를 비동기로 계산해 알려주므로, 스크롤마다 직접 위치를 재는 코드가 필요 없습니다.
| 구성 요소 | 역할 |
|---|---|
new IntersectionObserver(callback, options) | 관찰자를 만든다. callback은 교차 상태가 바뀔 때 호출된다 |
observer.observe(element) | 관찰 대상을 등록한다 |
entry.isIntersecting | 콜백 인자로 받는 관찰 결과 중, 화면(또는 지정한 루트)과 겹치는지 여부 |
observer.unobserve(element) | 특정 대상 감시를 멈춘다 |
감시 대상(sentinel) 패턴
목록 맨 아래에 눈에 보이지 않는 빈 <div>(sentinel)를 두고, 이 요소가 화면에 나타나는 순간을 “사용자가 목록 끝까지 스크롤했다”는 신호로 씁니다. sentinel 자체는 스타일도 내용도 없고, 오직 관찰 대상 역할만 합니다.
실습
1. src/storage.js에 페이지 단위 조회 추가
11편에서 만든 getAllNotes는 전체를 한 번에 가져오므로, 커서(cursor)로 정해진 개수만 순서대로 읽는 함수를 추가합니다.
2. 코드 작성
// src/storage.js (기존 openDatabase, getAllNotes, putNote, deleteNote, migrateFromLocalStorage 유지)
export async function getNotesPage(offset, limit) {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const results = []
let skipped = 0
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = event.target.result
if (!cursor || results.length >= limit) {
resolve(results)
return
}
if (skipped < offset) {
skipped += 1
cursor.continue()
return
}
results.push(cursor.value)
cursor.continue()
}
request.onerror = () => reject(request.error)
})
}openCursor()는 레코드를 하나씩 순회할 수 있는 커서를 반환합니다. offset만큼 건너뛴 뒤 limit개를 채우면 cursor.continue()를 더 호출하지 않고 즉시 resolve합니다.
3. src/render.js에 이어붙이기 렌더 함수 추가
// src/render.js (renderList, renderDetail은 그대로 유지)
export function appendNotes(notes, container) {
notes.forEach((note) => {
const item = document.createElement('li')
item.dataset.id = note.id
item.innerHTML = `
<a href="/notes/${note.id}" data-link>${note.title}</a>
<button type="button" data-action="delete">삭제</button>
`
container.appendChild(item)
})
}4. src/main.js에 무한 스크롤 로직 추가
// src/main.js (목록 화면 부분)
import { getNotesPage } from './storage.js'
import { appendNotes } from './render.js'
const PAGE_SIZE = 20
let currentOffset = 0
let isLoading = false
let hasMore = true
const listElement = document.querySelector('#note-list')
const sentinel = document.querySelector('#scroll-sentinel')
async function loadNextPage() {
if (isLoading || !hasMore) return
isLoading = true
const notes = await getNotesPage(currentOffset, PAGE_SIZE)
appendNotes(notes, listElement)
currentOffset += notes.length
hasMore = notes.length === PAGE_SIZE
isLoading = false
}
const observer = new IntersectionObserver((entries) => {
const [entry] = entries
if (entry.isIntersecting) {
loadNextPage()
}
})
observer.observe(sentinel)
loadNextPage()isLoading 플래그는 응답이 오기 전에 sentinel이 다시 감지돼 같은 페이지를 중복 요청하는 것을 막습니다. hasMore는 받은 개수가 PAGE_SIZE보다 적으면 더 가져올 데이터가 없다고 판단해 이후 요청을 멈춥니다.
5. index.html에 sentinel 요소 추가
10편에서 만든 #note-list는 그대로 두고, 그 바로 아래에 sentinel 요소만 추가합니다.
<!-- index.html의 #note-list 아래에 추가 -->
<div id="scroll-sentinel" style="height: 1px;"></div>6. 실행
npm run dev확인
- 메모가 20개 이상 저장된 상태에서 목록 화면을 열면 처음에는 20개만 보입니다.
- 스크롤을 아래로 내려
#scroll-sentinel이 화면에 들어오는 순간 다음 20개가 이어붙습니다. - 저장된 메모를 모두 불러온 뒤에는 더 스크롤해도 추가 요청이 발생하지 않습니다(개발자 도구 콘솔에
loadNextPage호출 로그를 임시로 찍어 확인 가능). - 스크롤을 아주 빠르게 여러 번 내려도 같은 페이지가 중복으로 붙지 않습니다.
직접 해보기
IntersectionObserver옵션에rootMargin: '200px'를 추가해, sentinel이 화면에 완전히 들어오기 전(스크롤 200px 남았을 때)부터 미리 로드되도록 만들어봅니다.- 목록이 비어 있을 때(
currentOffset === 0이고 첫 페이지 결과도 없을 때) “저장된 메모가 없습니다” 안내 문구를 보여줘봅니다.
정답 보기
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadNextPage()
},
{ rootMargin: '200px' }
)자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 스크롤 시 같은 메모가 두 번 붙음 | isLoading 체크 없이 sentinel 감지마다 loadNextPage를 바로 실행 | 요청이 끝나기 전까지 isLoading을 true로 유지해 중복 호출을 막는다 |
| 메모가 20개 미만인데도 계속 요청이 발생 | hasMore를 갱신하지 않음 | 받은 개수가 PAGE_SIZE보다 적으면 hasMore = false로 바꿔 관찰을 사실상 멈춘다 |
| sentinel이 항상 화면에 보여 요청이 한 번에 몰림 | sentinel을 목록 바깥(항상 보이는 위치)에 둠 | sentinel은 반드시 목록 맨 아래, 스크롤해야 보이는 위치에 둔다 |