Posts

Storage말고 IndexedDB는 어때요?

2025-05-21

새로운 회사에서 레거시 코드와 버그를 해결하던 중, 이런 상황을 맞닥뜨렸어요.

문제 상황

우리 서비스는 외부 결제 모듈을 쓰고 있어서 결제 과정 중에 외부 페이지로 이동했다가 완료되면 다시 앱으로 복귀하는 구조였어요. 이미지로 구성된 기프트 카드를 구매할 경우 결제 완료 정보와 함께 카드 정보를 서버로 전달해야 하는데, 해당 기프트 카드 정보가 Recoil로만 관리되고 있었거든요. 당연히 페이지 이동 시 상태가 소멸되고, 카드 정보가 날아간 채로 결제가 완료되는 버그가 발생하고 있었죠.

처음 이 버그를 발견했을 때는 "Recoil 대신 Storage로 관리하면 되는 거 아니야?"라고 생각했어요. 그런데 로직을 구성하다 보니 또 다른 문제가 생겼어요. 해당 기프트 카드의 이미지가 Base64 형태였던 거예요.

Base64와 Storage의 한계

Base64는 바이너리 데이터를 텍스트로 인코딩하는 방식이에요. A-Z, a-z, 0-9, +, /, 총 64개의 ASCII 문자만으로 데이터를 표현하니까 Base64라는 이름이 붙었어요. 바이너리 3바이트를 문자 4개로 변환하는 방식이라 원본보다 약 33% 크기가 늘어나는 게 특징이에요.

우리 회사 앱은 Next.js와 Flutter로 구성된 웹뷰 형태였고, Flutter에서 이미지를 Base64로 가공해 웹으로 전달하는 구조였어요. 문제는 Base64로 인코딩된 이미지 데이터는 길이가 어마어마하게 길다는 점이에요. LocalStorage나 SessionStorage는 도메인당 약 5~10MB 수준의 용량 제한이 있어서 Base64 이미지를 그대로 저장하기가 사실상 불가능했어요.

그래서 예전에 이론으로만 접했던 IndexedDB를 실제로 써보기로 했어요.

IndexedDB란?

IndexedDB는 브라우저에서 사용 가능한 로컬 DB API예요. NoSQL 계열로 key-value 형식으로 데이터를 저장하고, 수백 MB에서 수 GB까지 저장할 수 있어요. 객체나 Blob 같은 복잡한 타입도 그대로 저장할 수 있어서, 문자열만 다루는 LocalStorage와는 성격이 달라요.

LocalStorage와 가장 크게 다른 점은 동작 방식이에요. LocalStorage는 동기적으로 작동해요. localStorage.getItem()을 호출하면 해당 작업이 완료될 때까지 메인 스레드가 그 자리에서 블로킹돼요. 데이터가 작을 때는 문제가 없지만, 수 MB짜리 데이터를 읽고 쓰는 순간 UI가 멈추게 되거든요. IndexedDB는 비동기로 작동하니까 대용량 데이터를 읽고 써도 메인 스레드를 블로킹하지 않아요. 무거운 I/O 작업을 백그라운드에서 처리하면서 UI는 계속 응답할 수 있는 구조예요.

특성LocalStorageIndexedDB
저장 용량~5-10MB수백 MB ~ 수 GB
데이터 타입문자열만객체, Blob 등 다양
동작 방식동기 (메인 스레드 블로킹)비동기
트랜잭션없음있음

IndexedDB 사용하기

DB 생성

IndexedDB를 쓰려면 먼저 DB를 열어야 해요.

const request = indexedDB.open("MyDB", 1);

첫 번째 매개변수는 DB 이름이고, 두 번째는 버전이에요. IndexedDB에서 버전은 단순한 숫자가 아니라 스키마 변경의 트리거예요. Object Store(테이블에 해당하는 개념)를 만들거나 수정하려면 반드시 버전을 올려야 하고, 버전이 올라가면 onupgradeneeded 이벤트가 발생해요. 이 이벤트 핸들러 안에서만 스키마를 변경할 수 있어요.

Object Store 생성

request.onupgradeneeded = function (event) {
  const db = request.result;

  if (!db.objectStoreNames.contains("images")) {
    db.createObjectStore("images", { keyPath: "id" });
  }
};

request.onerror = () => {
  console.error("IndexedDB 열기 실패");
};

request.onsuccess = () => {
  console.log("IndexedDB 연결 성공");
};

createObjectStore로 테이블을 생성하고, keyPath는 각 레코드를 고유하게 식별하는 키예요. SQL의 PRIMARY KEY와 같은 개념이죠. objectStoreNames.contains로 이미 존재하는지 확인하는 이유는, onupgradeneeded가 버전 업그레이드마다 호출되니까 중복 생성을 막기 위해서예요.

CRUD 작업

Create - 데이터 추가

function addImage(id: string, base64: string) {
  const request = indexedDB.open("MyDB");

  request.onsuccess = () => {
    const db = request.result;
    const tx = db.transaction("images", "readwrite");
    const store = tx.objectStore("images");

    store.add({ id, base64 });

    tx.oncomplete = () => {
      console.log("이미지 저장 완료");
    };
  };
}

DB에 접속한 뒤 트랜잭션을 열고, 그 안에서 Object Store에 접근해 데이터를 추가하는 흐름이에요. db.transaction('images', 'readwrite')에서 두 번째 인자는 접근 모드를 의미해요. 읽기만 할 때는 readonly, 쓰기가 필요하면 readwrite를 써요.

IndexedDB의 모든 작업은 트랜잭션 안에서만 이루어져요. 트랜잭션은 원자성(atomicity)을 보장하거든요. 트랜잭션 내의 여러 작업 중 하나라도 실패하면 전체가 롤백돼요. 예를 들어 이미지 저장 도중 오류가 발생하면 중간 상태로 남지 않고 처음 상태로 되돌아가니까 데이터 정합성이 유지되는 거죠.

Read - 데이터 조회

function getImage(id: string) {
  const request = indexedDB.open("MyDB");

  request.onsuccess = () => {
    const db = request.result;
    const tx = db.transaction("images", "readonly");
    const store = tx.objectStore("images");

    const getRequest = store.get(id);

    getRequest.onsuccess = () => {
      const result = getRequest.result;
      if (result) {
        console.log("이미지 데이터:", result.base64);
      } else {
        console.log("해당 ID의 데이터 없음");
      }
    };
  };
}

Read도 동일하게 DB 연결 → 트랜잭션 생성 → 테이블 접근 순으로 동작해요. DB를 생성할 때 지정했던 keyPath로 데이터를 조회해요.

Update - 데이터 수정

function updateImage(id: string, newBase64: string) {
  const request = indexedDB.open("MyDB");

  request.onsuccess = () => {
    const db = request.result;
    const tx = db.transaction("images", "readwrite");
    const store = tx.objectStore("images");

    store.put({ id, base64: newBase64 });

    tx.oncomplete = () => {
      console.log("이미지 업데이트 완료");
    };
  };
}

put은 동일한 keyPath 값을 가진 레코드가 있으면 덮어쓰고, 없으면 새로 생성해요. 기존 데이터를 통째로 교체하는 방식이라서 일부 필드만 수정하고 싶다면 먼저 읽어온 뒤 수정해서 다시 put해야 해요.

Delete - 데이터 삭제

function deleteImage(id: string) {
  const request = indexedDB.open("MyDB");

  request.onsuccess = () => {
    const db = request.result;
    const tx = db.transaction("images", "readwrite");
    const store = tx.objectStore("images");

    store.delete(id);

    tx.oncomplete = () => {
      console.log("이미지 삭제 완료");
    };
  };
}

마치며

IndexedDB는 LocalStorage로는 다룰 수 없는 대용량 데이터나 복잡한 객체를 브라우저에 저장해야 할 때 적합한 선택이에요. Base64 이미지처럼 크기를 예측하기 어려운 데이터라면 특히 그렇고요. 비동기 기반이라 초반엔 콜백 중첩이 거슬릴 수 있는데, idb 같은 Promise 래퍼 라이브러리를 쓰면 훨씬 깔끔하게 쓸 수 있어요. 트랜잭션 기반의 원자성 보장도 있어서, 실제 데이터베이스처럼 신뢰할 수 있는 방식으로 클라이언트 스토리지를 다루고 싶다면 IndexedDB가 꽤 든든한 도구가 돼요.