Storage말고 IndexedDB는 어때요?
새로운 회사에서 레거시 코드와 버그를 붙잡고 있던 중, 이런 상황을 맞닥뜨렸습니다.
결제를 마치고 돌아오니 카드 정보가 사라져 있었다
우리 서비스는 외부 결제 모듈을 쓰고 있어서 결제 과정 중에 외부 페이지로 이동했다가 완료되면 다시 앱으로 복귀하는 구조였습니다. 이미지로 구성된 기프트 카드를 구매할 경우 결제 완료 정보와 함께 카드 정보를 서버로 전달해야 하는데, 그 기프트 카드 정보가 Recoil로만 관리되고 있었기 때문입니다. 당연히 페이지 이동 시 상태가 소멸되고, 카드 정보가 날아간 채로 결제가 완료되는 버그가 발생하고 있었습니다.
처음 이 버그를 발견했을 때는 간단하다고 생각했습니다.
"Recoil 대신 Storage로 관리하면 되는 거 아니야?"
그런데 로직을 구성하다 보니 또 다른 문제가 튀어나왔습니다. 해당 기프트 카드의 이미지가 Base64 형태였던 겁니다.
5MB 안에 Base64 이미지는 들어가지 않았다
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는 브라우저 안의 진짜 DB였다
IndexedDB는 브라우저에서 사용 가능한 로컬 DB API입니다. NoSQL 계열로 key-value 형식으로 데이터를 저장하고, 수백 MB에서 수 GB까지 저장할 수 있습니다. 객체나 Blob 같은 복잡한 타입도 그대로 저장할 수 있어서, 문자열만 다루는 LocalStorage와는 성격이 다릅니다.
LocalStorage와 가장 크게 다른 점은 동작 방식입니다. LocalStorage는 동기적으로 작동합니다. localStorage.getItem()을 호출하면 해당 작업이 완료될 때까지 메인 스레드가 그 자리에서 블로킹됩니다. 데이터가 작을 때는 문제가 없지만, 수 MB짜리 데이터를 읽고 쓰는 순간 UI가 멈춰버리죠. IndexedDB는 비동기로 작동하니까 대용량 데이터를 읽고 써도 메인 스레드를 블로킹하지 않습니다. 무거운 I/O 작업을 백그라운드에서 처리하면서 UI는 계속 응답할 수 있는 구조입니다.
| 특성 | LocalStorage | IndexedDB |
|---|---|---|
| 저장 용량 | ~5-10MB | 수백 MB ~ 수 GB |
| 데이터 타입 | 문자열만 | 객체, Blob 등 다양 |
| 동작 방식 | 동기 (메인 스레드 블로킹) | 비동기 |
| 트랜잭션 | 없음 | 있음 |
써보면서 알게 된 것들
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가 버전 업그레이드마다 호출되니까 중복 생성을 막기 위해서입니다.
모든 작업은 트랜잭션 안에서 일어난다
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 래퍼 라이브러리를 쓰면 훨씬 깔끔해집니다. 트랜잭션 기반의 원자성 보장도 있으니, 실제 데이터베이스처럼 신뢰할 수 있는 방식으로 클라이언트 스토리지를 다루고 싶다면 한 번쯤 꺼내볼 만한 도구가 아닐까 싶습니다.