next.js 오프라인 지원(Offline Support)

55 sec read

비행기나 지하철 안에서, 또는 와이파이가 불안정한 카페에서 갑자기 인터넷이 끊겨 작업하던 내용이 날아가거나 앱이 먹통이 된 경험, 한 번쯤 있으시죠? 만약 우리 앱이 이런 상황에서도 아무 문제 없이 작동한다면 어떨까요?

이런 마법을 가능하게 하는 기술이 바로 오프라인 지원입니다. 사용자의 인터넷 연결 상태와 상관없이 앱의 핵심 기능이 동작하도록 만들고, 나중에 연결이 복구되면 데이터를 알아서 동기화하는 매우 강력하고 사용자 친화적인 기술입니다.

이번 글에서는 오프라인 지원의 핵심 개념들을 알아보고, 인터넷 없이도 메모를 작성하고 저장할 수 있는 ‘오프라인 메모장’ 예제를 통해 그 구현 원리를 기초부터 차근차근 파헤쳐 보겠습니다.

📚 주요 개념 설명

오프라인 지원이라는 든든한 기능을 만들기 위한 네 가지 핵심 도구를 소개합니다.

  • 오프라인 저장 (Offline Storage): “앱의 개인용 보조 배터리 또는 서랍장”에 비유할 수 있습니다. 인터넷이 안 될 때를 대비해, 사용자의 브라우저 안에 있는 작은 데이터베이스(주로 IndexedDB)에 데이터를 임시로 저장해 두는 기술입니다.
  • 동기화 (Synchronization): “사무실에 복귀해서 보고하기”와 같습니다. 오프라인 상태에서 ‘개인 서랍장(IndexedDB)’에 저장해 두었던 모든 변경사항을, 온라인이 되었을 때 진짜 서버(중앙 데이터베이스)에 한꺼번에 전송하여 최신 상태로 맞추는 과정입니다.
  • 충돌 해결 (Conflict Resolution): 내가 오프라인에서 A 문서를 수정했는데, 그 사이에 다른 사용자가 온라인에서 A 문서를 먼저 수정하고 저장했다면? 나중에 내가 온라인이 되어 동기화를 시도할 때 ‘충돌’이 발생합니다. 이 문제를 어떻게 해결할지 정하는 규칙입니다.
  • 백그라운드 동기화 (Background Sync): “내가 잠든 사이에도 일하는 비서”와 같습니다. 앱을 닫거나 다른 탭으로 이동해도, 서비스 워커(Service Worker)라는 브라우저의 특별한 일꾼이 백그라운드에서 조용히 동기화 작업을 처리해 주는 고급 기술입니다.

💡 실제 사용 예제: 인터넷 없이도 든든한, 오프라인 메모장

이제 IndexedDB를 사용하여 인터넷 연결과 상관없이 메모를 저장하고, 온라인이 되면 자동으로 서버와 동기화하는 ‘오프라인 메모장’을 만들어 보겠습니다.

1단계: 개인 서랍장(IndexedDB) 준비하기

가장 먼저, 브라우저 안에 데이터를 저장할 공간을 마련해야 합니다. idb라는 라이브러리를 사용하면 복잡한 IndexedDB를 훨씬 쉽게 다룰 수 있습니다.

// idb 라이브러리 임포트 및 데이터베이스 스키마(설계도) 정의
import { openDB, DBSchema, IDBPDatabase } from 'idb';

interface NotesDB extends DBSchema {
  notes: { // 'notes'라는 이름의 서랍(테이블)
    key: string; // 각 메모의 고유 ID (파일 이름)
    value: Note; // 메모의 실제 내용 (파일 내용)
    indexes: { 'by-date': string }; // 날짜순 정렬을 위한 색인
  };
}

// ...

// IndexedDB 초기화 및 연결
useEffect(() => {
  async function initDB() {
    // 'notes-db'라는 이름의 데이터베이스를 열고, 버전은 1로 설정
    const database = await openDB<NotesDB>('notes-db', 1, {
      // 데이터베이스가 처음 만들어지거나 버전이 올라갈 때 실행됨
      upgrade(db) {
        // 'notes' 서랍을 만들고, 각 메모는 'id'로 구분한다고 알려줌
        const store = db.createObjectStore('notes', { keyPath: 'id' });
        // 나중에 날짜로 메모를 쉽게 찾을 수 있도록 '색인'을 만들어 둠
        store.createIndex('by-date', 'updatedAt');
      }
    });
    setDb(database); // 연결된 DB 객체를 상태에 저장
    loadNotes(database); // DB에서 기존 노트들을 불러옴
  }

  initDB();
}, []);

2단계: 인터넷 상태 감지하기

우리 앱은 현재 온라인인지 오프라인인지 항상 알고 있어야 합니다. 브라우저는 이 상태가 바뀔 때마다 신호를 보내줍니다.

// 온라인/오프라인 상태 모니터링
useEffect(() => {
  // 온라인이 되었을 때 실행할 함수
  const handleOnline = () => {
    setIsOnline(true);
    syncNotes(); // 온라인이 되면 즉시 동기화 시도!
  };
  // 오프라인이 되었을 때 실행할 함수
  const handleOffline = () => setIsOnline(false);

  // 브라우저에 이벤트 리스너 등록
  window.addEventListener('online', handleOnline);
  window.addEventListener('offline', handleOffline);

  // 컴포넌트가 사라질 때 리스너를 깨끗하게 정리 (메모리 누수 방지)
  return () => {
    window.removeEventListener('online', handleOnline);
    window.removeEventListener('offline', handleOffline);
  };
}, []); // 이 useEffect는 맨 처음 한 번만 실행됨

3단계: 저장과 동기화 로직 구현하기

이제 핵심 로직입니다. 메모를 저장할 때는 일단 우리 ‘개인 서랍장(IndexedDB)’에 먼저 저장합니다. 그리고 온라인 상태라면, 동기화를 시도합니다.

// 노트 저장 함수
async function saveNote() {
  if (!db || !currentNote) return;

  // 저장할 노트 객체를 만들고, 'synced' 상태를 false로 설정!
  const noteToSave: Note = {
    ...currentNote,
    updatedAt: new Date().toISOString(),
    synced: false // "이건 아직 서버에 보고 안 된 새 소식이야!"라는 깃발
  };

  // IndexedDB에 노트를 저장 (덮어쓰기)
  await db.put('notes', noteToSave);
  // 목록을 다시 불러와 화면 업데이트
  await loadNotes(db);

  // 온라인 상태라면, 바로 동기화를 시도
  if (isOnline) {
    syncNotes();
  }
}

// 서버와 동기화하는 함수
async function syncNotes() {
  if (!db || !isOnline) return;

  setSyncStatus('동기화 중...');

  try {
    // 'synced'가 false인 노트들을 찾아서 서버로 전송 (실제 구현에서는 false인 것만 필터링)
    const unsyncedNotes = await db.getAll('notes');

    const response = await fetch('/api/notes/sync', { /* ...서버 요청... */ });
    if (!response.ok) { throw new Error('동기화 실패'); }

    // 동기화 성공! 이제 로컬 데이터의 'synced' 깃발을 true로 바꿔줌
    const writeTx = db.transaction('notes', 'readwrite');
    for (const note of unsyncedNotes) {
      await writeTx.store.put({ ...note, synced: true });
    }
    await writeTx.done;

    await loadNotes(db); // 최신 상태로 화면 다시 로드
    setSyncStatus('동기화 완료');
  } catch (error) {
    setSyncStatus('동기화 실패');
  }
}

🎓 주니어 개발자를 위한 핵심 정리

이 예제를 통해 우리는 무엇을 배울 수 있을까요?

  1. 오프라인 우선(Offline First) 철학: 모든 데이터 작업(생성, 수정, 삭제)은 일단 로컬 IndexedDB에서 먼저 이루어집니다. 이것이 인터넷 연결과 상관없이 앱이 빠르게 반응하는 이유입니다. 서버와의 통신은 그 다음 문제입니다.
  2. 동기화 상태(Synced Flag)의 중요성: 각 데이터에 synced와 같은 ‘동기화 여부’를 나타내는 깃발(플래그)을 두는 것이 핵심입니다. 이 깃발을 통해 어떤 데이터를 서버에 보내야 할지 쉽게 알 수 있습니다.
  3. 브라우저 API를 적극 활용하라: 인터넷 연결 상태를 감지하는 online, offline 이벤트는 모든 최신 브라우저가 지원하는 강력한 기본 기능입니다. 특별한 라이브러리 없이도 쉽게 구현할 수 있습니다.
  4. 사용자에게 현재 상태를 명확히 알려주기: UI에 현재 ‘온라인’인지 ‘오프라인’인지, ‘동기화 중’인지 명확하게 표시해 주는 것은 사용자에게 신뢰감을 주고 불안감을 해소하는 데 매우 중요합니다.
  5. idb 라이브러리는 구세주: 순수한 IndexedDB API는 사용하기에 다소 복잡하고 번거롭습니다. idb와 같은 래퍼(wrapper) 라이브러리를 사용하면 async/await와 함께 훨씬 더 직관적이고 현대적인 방식으로 코드를 작성할 수 있습니다.

next.js 클라이언트 상태 관리 웹 캐싱 전략의 모든 것…

매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요?...
eve
1 min read

React 클라이언트 상태 관리 (전역 상태 관리와 Context API)

우리가 큰 건물을 짓는다고 상상해 봅시다. 건물 전체의 ‘중앙 난방 온도’나 ‘비상벨 작동 여부’ 같은 정보는 1층 로비, 10층 사무실, 지하 주차장 등 건물의...
eve
1 min read

 React 19 자동 메모이제이션 (Automatic Memoization)

혹시 개발 공부를 하다가 useMemo, useCallback 같은 훅(Hook)들을 만나고 “이건 왜 써야 하지? 너무 복잡해…”라고 생각했던 적 있으신가요? 이런 훅들은 React의 성능을 최적화하기 위해,...
eve
46 sec read