Skip to Content
WebJavaScriptWeb APIs12. IntersectionObserver로 무한 스크롤

이번 편의 결과물: 메모 목록을 스크롤로 끝까지 내리면 화면 아래 감시 요소가 보이는 순간 다음 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 호출 로그를 임시로 찍어 확인 가능).
  • 스크롤을 아주 빠르게 여러 번 내려도 같은 페이지가 중복으로 붙지 않습니다.

직접 해보기

  1. IntersectionObserver 옵션에 rootMargin: '200px'를 추가해, sentinel이 화면에 완전히 들어오기 전(스크롤 200px 남았을 때)부터 미리 로드되도록 만들어봅니다.
  2. 목록이 비어 있을 때(currentOffset === 0이고 첫 페이지 결과도 없을 때) “저장된 메모가 없습니다” 안내 문구를 보여줘봅니다.

정답 보기

const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) loadNextPage() }, { rootMargin: '200px' } )

자주 하는 실수

증상원인고치는 법
스크롤 시 같은 메모가 두 번 붙음isLoading 체크 없이 sentinel 감지마다 loadNextPage를 바로 실행요청이 끝나기 전까지 isLoadingtrue로 유지해 중복 호출을 막는다
메모가 20개 미만인데도 계속 요청이 발생hasMore를 갱신하지 않음받은 개수가 PAGE_SIZE보다 적으면 hasMore = false로 바꿔 관찰을 사실상 멈춘다
sentinel이 항상 화면에 보여 요청이 한 번에 몰림sentinel을 목록 바깥(항상 보이는 위치)에 둠sentinel은 반드시 목록 맨 아래, 스크롤해야 보이는 위치에 둔다

확인 문제

문제 14지선다
IntersectionObserver를 무한 스크롤에 쓰는 이유로 가장 적절한 것은?
문제 24지선다
sentinel 패턴에서 sentinel 요소의 역할은?
문제 34지선다
loadNextPage 함수에서 isLoading 플래그를 두는 목적은?
문제 44지선다
getNotesPage에서 openCursor 대신 getAll을 쓰지 않는 이유로 가장 타당한 것은?

참고 자료

Last updated on