새로고침해도 음악이 이어진다 (feat. IndexedDB) - 5

yoon·2026년 4월 7일

yoon-play2

목록 보기
5/5
post-thumbnail

🤔 왜 필요한가?

음악을 틀고 다른 페이지로 이동하거나 새로고침하면 어떻게 될까요?

Jotai atom은 메모리에만 존재합니다. 새로고침하는 순간 플레이리스트, 현재 곡, 볼륨이 모두 초기화됩니다.

스트리밍 앱에서 새로고침할 때마다 음악이 처음부터 다시 시작된다면 불편하겠죠.
재생 상태를 브라우저에 저장해서 새로고침 후에도 이어 들을 수 있게 만들고 싶었습니다.




🚨 문제 예상 (원인)

localStorage는 안 될까?

가장 먼저 떠오르는 건 localStorage입니다. 하지만 몇 가지 한계가 있습니다.

// localStorage로 플레이리스트 저장한다면...
localStorage.setItem('playlist', JSON.stringify(playlist));
문제내용
용량 제한보통 5MB 이하. 트랙 데이터(썸네일 URL, 제목 등)가 쌓이면 금방 한계에 도달
동기 I/O저장/읽기가 메인 스레드를 블로킹. 데이터가 많으면 UI가 멈출 수 있음
구조화 불가문자열만 저장 가능. JSON.stringify/parse 비용 + 관계형 데이터 표현 어려움
중복 저장좋아요 목록과 플레이리스트에 같은 트랙이 있으면 데이터가 중복
// 재생목록이 여러 개가 된다면 (v2 확장 시 예상되는 문제)
localStorage:
  likedTracks: [{ videoId: "abc", title: "노래A", thumbnail: "..." }, ...]
  playlist_1:  [{ videoId: "abc", title: "노래A", thumbnail: "..." }, ...]  // 중복!
  playlist_2:  [{ videoId: "abc", title: "노래A", thumbnail: "..." }, ...]  // 중복!



💡 아이디어 + 해결

IndexedDB는 브라우저 내장 NoSQL 데이터베이스입니다.

localStorageIndexedDB
저장 방식문자열 onlyJS 객체, Blob 등
용량~5MB수백MB (디스크 여유 공간 기준)
I/O동기 (블로킹)비동기 (논블로킹)
구조화불가인덱스, 쿼리 가능

중복 저장 문제는 정규화된 스키마 설계로 해결했습니다.
트랙 데이터를 한 곳(tracks)에만 저장하고, 다른 스토어는 ID만 참조합니다.




🗂️ 스키마 설계: 정규화

문제: 같은 곡이 여러 곳에 중복 저장된다

"노래A"를 좋아요 추가하고, 플레이리스트 2개에도 넣으면 어떻게 될까요?

❌ 비정규화 구조 (중복 발생)

likedTracks:   [{ videoId: "abc", title: "노래A", thumbnail: "..." }]
playlist_1:    [{ videoId: "abc", title: "노래A", thumbnail: "..." }]  ← 같은 데이터
playlist_2:    [{ videoId: "abc", title: "노래A", thumbnail: "..." }]  ← 같은 데이터

썸네일 URL이 바뀌거나 제목이 수정되면 3곳을 전부 업데이트해야 합니다.
어느 한 곳을 빠뜨리면 데이터가 달라지는 불일치가 생깁니다.

해결: tracks를 단일 저장소로 분리

트랙 데이터는 tracks 스토어 한 곳에만 저장하고,
다른 스토어는 ID만 참조합니다.

✅ 정규화 구조

tracks (단일 저장소)
 └─ "abc"{ title: "노래A", thumbnail: "...", ... }  // 딱 1번만 존재

playlists (재생목록 목록)
 ├─ "__liked__"
 └─ "__playlist__uuid1"

playlistTracks (관계 테이블 - 다대다)
 ├─ "__liked__:abc"         → playlistId: "__liked__",        trackId: "abc", order: 0
 └─ "__playlist__uuid1:abc" → playlistId: "__playlist__uuid1", trackId: "abc", order: 0

"노래A"가 몇 개의 재생목록에 들어가 있든 tracks에는 딱 1개만 존재합니다.
데이터 수정이 필요하면 tracks 한 곳만 바꾸면 됩니다.

관계 구조 한눈에 보기

tracks ◀──────────────────── playlistTracks ──────────────────▶ playlists
 └─ "abc" (노래A)             ├─ "__liked__:abc"                  ├─ "__liked__"
 └─ "def" (노래B)             └─ "__playlist__:abc"               └─ "__playlist__uuid"

관계형 DB의 다대다(N:M) 관계playlistTracks라는 중간 테이블로 표현한 것입니다.
한 트랙이 여러 재생목록에 속할 수 있고, 한 재생목록에 여러 트랙이 담길 수 있습니다.




⚙️ IndexedDB 적용

idb 라이브러리로 편리하게

raw IndexedDB API는 콜백 기반이라 코드가 복잡합니다. idb 라이브러리를 사용하면 Promise 기반으로 깔끔하게 쓸 수 있습니다.

npm install idb

스키마 정의

// lib/indexedDB/index.ts
export interface PlayerDBSchema extends DBSchema {
  // 트랙 단일 저장소 - 좋아요/재생목록 공통 사용, 중복 방지
  tracks: {
    key: string;        // videoId
    value: PlaylistItem;
  };

  // 유저 재생목록 (폴더)
  playlists: {
    key: string;
    value: {
      id: string;       // "__liked__" | "__playlist__{uuid}"
      title: string;
      createdAt: number;
      updatedAt: number;
    };
  };

  // 재생목록 ↔ 트랙 관계 테이블 (다대다)
  playlistTracks: {
    key: string;        // `${playlistId}:${trackId}`
    value: {
      id: string;
      playlistId: string;
      trackId: string;  // tracks.key 참조
      order: number;
      addedAt: number;
    };
    indexes: {
      'by-playlist': string;
      'by-track': string;
    };
  };

  // 현재 플레이어 상태
  playerState: {
    key: string;
    value: {
      playlist: string[];              // trackId 배열만 저장 (full 객체 X)
      currentVideoId: string | null;
      playlistSource: PlaylistSource;
    };
  };
}

playerState에도 트랙 ID 배열만 저장하는 게 포인트입니다. 읽을 때 tracks에서 조인합니다.

DB 초기화 + 마이그레이션

const DB_NAME = 'player-db';
const DB_VERSION = 4;

export const getPlayerDB = () => {
  if (!dbPromise) {
    dbPromise = openDB<PlayerDBSchema>(DB_NAME, DB_VERSION, {
      upgrade(db, oldVersion, newVersion, transaction) {

        // v1 → v2: 기존 스토어 구조를 정규화된 구조로 교체
        if (oldVersion < 2) {
          const legacyDb = db as any;

          // 레거시 스토어 제거
          if (legacyDb.objectStoreNames.contains('likedPlaylist')) {
            legacyDb.deleteObjectStore('likedPlaylist');
          }

          // 새 스토어 생성
          db.createObjectStore('tracks', { keyPath: 'id' });

          const playlistStore = db.createObjectStore('playlists', { keyPath: 'id' });
          // 시스템 플레이리스트 초기 데이터
          playlistStore.put({
            id: LIKED_PLAYLIST_ID,
            title: '좋아요한 플레이리스트',
            createdAt: Date.now(),
            updatedAt: Date.now(),
          });

          const relationStore = db.createObjectStore('playlistTracks', { keyPath: 'id' });
          relationStore.createIndex('by-playlist', 'playlistId');
          relationStore.createIndex('by-track', 'trackId');

          db.createObjectStore('playerState');
        }

        // v2 → v3: 좋아요 플레이리스트 title 변경
        if (oldVersion < 3) {
          const playlistStore = transaction.objectStore('playlists');
          playlistStore.put({
            id: LIKED_PLAYLIST_ID,
            title: '좋아요한 플레이리스트',
            updatedAt: Date.now(),
            createdAt: Date.now(),
          });
        }
      },
    });
  }

  return dbPromise;
};

oldVersion < N 패턴으로 버전별 마이그레이션을 순차 적용합니다.
기존 사용자의 DB도 자동으로 최신 버전으로 업그레이드됩니다.

플레이어 상태 저장/불러오기

// lib/indexedDB/playerStateDB.ts

// 저장: 트랜잭션으로 atomic하게
export const savePlayerState = async (state: PlayerStateValue) => {
  const db = await getPlayerDB();

  // playerState + tracks 두 스토어를 하나의 트랜잭션으로 묶음
  const tx = db.transaction(['playerState', 'tracks'], 'readwrite');

  // tracks 스토어에도 최신 정보 저장 (put = upsert, 중복 방지)
  for (const track of state.playlist) {
    await tx.objectStore('tracks').put({ ...track, id: track.videoId });
  }

  // playerState에는 ID 배열만 저장
  await tx.objectStore('playerState').put(
    {
      playlist: state.playlist.map(track => track.videoId),
      currentVideoId: state.currentVideoId,
      playlistSource: state.playlistSource,
    },
    PLAYER_STATE_KEY,
  );

  await tx.done; // 트랜잭션 커밋
};

// 불러오기: ID 배열 → tracks 조인
export const getPlayerState = async (): Promise<PlayerStateValue | undefined> => {
  const db = await getPlayerDB();
  const entity = await db.get('playerState', PLAYER_STATE_KEY);

  if (!entity) return undefined;

  // ID 배열로 실제 트랙 데이터 조회
  const tracks = await Promise.all(
    entity.playlist.map(id => db.get('tracks', id))
  );

  return {
    playlist: tracks.filter(Boolean) as PlaylistItem[],
    currentVideoId: entity.currentVideoId,
    playlistSource: entity.playlistSource,
  };
};

트랜잭션으로 묶는 이유: tracks 저장 중 실패하면 playerState도 저장되지 않습니다.
부분 저장으로 인한 데이터 불일치를 방지합니다.

usePlayerCore에서 연동

export const usePlayerCore = () => {
  // 앱 시작 시 저장된 상태 복원
  const restorePlayerState = useCallback(async () => {
    const savedState = await playerStateDB.getPlayerState();
    if (!savedState) return;

    setPlaylist(savedState.playlist);
    setPlaylistSource(savedState.playlistSource);
    setCurrentVideoId(savedState.currentVideoId);
  }, [setPlaylist, setPlaylistSource, setCurrentVideoId]);

  useEffect(() => {
    restorePlayerState();
  }, [restorePlayerState]);

  // 상태 변경 시 자동 저장
  useEffect(() => {
    if (!playlistSource) return;
    if (playlist.length === 0) return;

    playerStateDB.savePlayerState({ playlist, currentVideoId, playlistSource });
  }, [playlist, currentVideoId, playlistSource]);
};

DB 무결성 보장: validateAndRepairDB

앱 시작 시 DB 상태를 검사하고 이상이 있으면 자동 복구합니다.

export const validateAndRepairDB = async () => {
  const db = await getPlayerDB();

  // 좋아요 플레이리스트가 없으면 복구
  const liked = await db.get('playlists', LIKED_PLAYLIST_ID);
  if (!liked) {
    await db.put('playlists', {
      id: LIKED_PLAYLIST_ID,
      title: '좋아요한 플레이리스트',
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  }

  // 고아 트랙 정리 (존재하지 않는 플레이리스트를 참조하는 관계 레코드 삭제)
  const allPlaylistTracks = await db.getAll('playlistTracks');
  const allPlaylists = await db.getAll('playlists');
  const playlistIds = new Set(allPlaylists.map(p => p.id));

  const orphanTracks = allPlaylistTracks.filter(
    track => !playlistIds.has(track.playlistId)
  );

  await Promise.all(orphanTracks.map(track => db.delete('playlistTracks', track.id)));
};
// app/providers/DBProvider.tsx
export function DBProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    validateAndRepairDB(); // 앱 마운트 시 한 번 실행
  }, []);

  return <>{children}</>;
}



📊 Before / After

Before: v1 (yoon-play) · localStorage

이전에 만들었던 yoon-play v1에서는 보관함이 하나(좋아요 목록)뿐이었고,
YouTube API 응답을 그대로 localStorage에 저장했습니다.

{
  "#myPlayListState": [
    {
      "kind": "youtube#searchResult",
      "etag": "je810BRbL_DSPPdeAju-JAVqI8w",
      "id": { "kind": "youtube#video", "videoId": "U34kLXjdw90" },
      "snippet": {
        "title": "지브리의 피아노 OST 모음",
        "channelTitle": "Soothing Piano Relaxing",
        "thumbnails": {
          "default": { "url": "...", "width": 120, "height": 90 },
          "medium":  { "url": "...", "width": 320, "height": 180 },
          "high":    { "url": "...", "width": 480, "height": 360 }
        },
        "publishedAt": "2021-07-30T01:25:42Z",
        "channelId": "UC3fcwju0wMjPhmmAWr0dUKQ"
        // ... 그 외 불필요한 필드들
      }
    }
  ]
}

실제로 필요한 건 4개 필드뿐인데, API 응답 전체를 저장하고 있었습니다.🥲

필요한 것:  videoId, title, channelTitle, thumbnail  → ~200 bytes
실제 저장:  YouTube API 응답 전체                    → ~1,000 bytes (5배)

v1은 보관함이 하나뿐이라 중복 문제는 없었지만,
v2(yoon-play2)에서 재생목록이 여러 개로 늘어나는 순간 이 구조 그대로 쓰면 중복이 바로 터질 상황이었습니다.
그리고 새로고침하면 재생 상태가 초기화되는 문제도 그대로였고요. 😢

After: v2 (yoon-play2) · IndexedDB + 정규화

[새로고침 전 상태]
  노래A를 좋아요 + 플레이리스트 2개에 추가

IndexedDB:
  tracks:         { "abc": { title: "노래A", ... } }   // 딱 1번만 저장 ✅
  playlists:      { "__liked__", "__playlist__uuid1", "__playlist__uuid2" }
  playlistTracks: { "__liked__:abc", "uuid1:abc", "uuid2:abc" }
  playerState:    { playlist: ["abc", ...], currentVideoId: "abc" }

[새로고침 후]
  DBProvider 마운트 → validateAndRepairDB 실행
  usePlayerCore → getPlayerState → ID로 tracks 조인 → atom 복원
  이어 듣기 시작 ✅
Before (v1 · localStorage)After (v2 · IndexedDB)
새로고침 후 재생 상태초기화 😢복원 ✅
저장 데이터YouTube API 응답 전체 (~1,000 bytes/곡)필요한 필드만 (~200 bytes/곡) ✅
트랙 데이터 중복보관함 1개라 없었지만, 여러 재생목록 확장 시 즉시 발생정규화로 원천 차단 ✅
저장 용량~5MB 한계수백MB ✅
메인 스레드 블로킹있음 (동기)없음 (비동기) ✅
데이터 무결성수동 관리트랜잭션 + 자동 복구 ✅



🎓 회고

1. DB 설계는 처음부터 정규화를 고민하자

처음엔 좋아요 목록과 재생목록에 트랙 데이터를 그냥 통째로 저장했습니다.
한 곡이 여러 목록에 있으면 데이터가 중복되고, 썸네일이 바뀌면 모든 곳을 수정해야 했습니다.
tracks를 단일 저장소로 분리하고 나서야 일관성이 생겼습니다.
관계형 DB의 정규화 원칙이 IndexedDB에서도 그대로 적용됩니다.

2. 트랜잭션은 데이터 일관성의 보험이다

tracksplayerState를 각각 따로 저장하면, 중간에 실패했을 때 한쪽만 저장되는 상황이 생길 수 있습니다.
트랜잭션으로 묶으면 전부 성공하거나 전부 실패합니다.

3. 마이그레이션은 미리 설계하자

oldVersion < N 패턴으로 버전별 마이그레이션을 관리하니, 스키마가 바뀌어도 기존 사용자 데이터를 안전하게 유지할 수 있었습니다.
처음부터 버전 관리를 염두에 두지 않았다면, 스키마를 바꿀 때마다 기존 데이터가 날아가거나 오류가 생길 수 있어요.




지금까지 긴 글 읽어주셔서 감사합니다 :)

💬 비슷한 문제를 겪으셨거나, 더 좋은 해결 방법이 있다면 댓글로 공유해주세요!

profile
Frontend Developer 😆 | PM

0개의 댓글