IndexedDB
고친 사람 github-actions[bot]
IndexedDB 는 브라우저 안에 데이터베이스를 하나 차려 줍니다. 웹 페이지가 서버에 묻지 않고도 많은 데이터를 방문자의 컴퓨터에 넣어 두고 꺼내 씁니다. 창을 닫았다 다시 열어도 넣어 둔 값이 남아 있습니다.
쉽고 빠른 이해
IndexedDB 는 웹 페이지가 자기 몫의 저장 공간에 객체를 넣고 키로 다시 꺼내게 해 줍니다. 메일 웹 앱이 받은 편지를 여기 넣어 두면 신호가 끊긴 지하철에서도 편지 목록이 뜹니다.
이게 없으면 페이지는 값이 필요할 때마다 서버에 물어야 합니다. 브라우저의 다른 저장소는 글자만 조금 담거나 요청마다 서버로 실려 가서 큰 데이터를 두기 어렵습니다.
- 페이지가 이름과 버전을 대고 데이터베이스를 엽니다
- 읽기와 쓰기를 트랜잭션 하나로 묶어 요청합니다
- 결과는 바로 오지 않고 나중에 이벤트로 도착합니다
대가도 있습니다. 결과를 나중에 받는 코드를 따로 짜야 해서 코드가 길어집니다. 디스크가 모자라면 브라우저가 이 데이터를 지울 수 있어서 원본은 여전히 서버에 둡니다.
상세
IndexedDB 는 브라우저가 웹 페이지에 내주는 데이터베이스입니다. 서버의 데이터베이스와 달리 방문자 컴퓨터의 디스크에 남습니다. 페이지에 실린 JavaScript 코드가 API(Application Programming Interface, 프로그램이 다른 프로그램의 기능을 부르는 창구)를 불러 값을 넣고 꺼냅니다.
브라우저마다 따로 만들지만 모두 같은 규격 문서를 따릅니다. 그래서 한 번 짠 코드가 여러 브라우저에서 똑같이 돕니다.
브라우저에 데이터베이스가 따로 필요한 까닭
IndexedDB 가 나오기 전에도 브라우저에는 값을 넣어 둘 곳이 둘 있었습니다. 쿠키와 웹 스토리지입니다. 둘 다 작은 값을 두는 데 맞춰져 있습니다.
쿠키는 사이트가 브라우저에 맡겨 두는 작은 문자열입니다. 브라우저는 그 사이트로 요청을 보낼 때마다 쿠키를 함께 실어 보냅니다. 큰 데이터를 쿠키에 넣으면 요청마다 그만큼을 서버로 나르게 됩니다.
웹 스토리지는 키 하나에 문자열 하나를 넣어 두는 저장소입니다. localStorage 가 그
대표입니다. 값이 문자열뿐이라 객체는 JSON(JavaScript Object Notation, 자바스크립트 객체
표기법) 문자열로 바꿔 넣어야 합니다.
웹 스토리지를 부르면 결과가 나올 때까지 코드가 멈춰 기다립니다. 이렇게 기다리는 호출을 동기 호출이라고 합니다. 반대로 부르자마자 돌아오고 결과를 나중에 알려 주는 호출을 비동기 호출이라고 합니다.
동기 호출이 문제가 되는 것은 페이지의 코드가 화면 그리기와 한 줄로 돌기 때문입니다. 이 한 줄을 메인 스레드라고 부릅니다. 여기서 동기 호출이 디스크를 오래 읽으면 그동안 클릭도 스크롤도 멈춥니다.
셋을 나란히 놓으면 이렇습니다. IndexedDB 가 무엇을 메우러 나왔는지가 마지막 열에 보입니다.
| 쿠키 | 웹 스토리지 | IndexedDB | |
|---|---|---|---|
| 담는 값 | 문자열 | 문자열 | 객체 · 파일 · 이진 데이터 |
| 호출 방식 | 동기 | 동기 | 비동기 |
| 요청마다 서버로 가나 | 간다 | 안 간다 | 안 간다 |
| 담는 양 | 아주 적다 | 적다 | 많다 |
페이지 밖에서 도는 스크립트도 IndexedDB 를 부를 수 있습니다. 서비스 워커가 그 예입니다. 서비스 워커는 네트워크 요청을 가로채는 스크립트입니다. 브라우저가 페이지와 따로 돌립니다. 서비스 워커에서는 웹 스토리지를 쓸 수 없습니다.
앞서 있던 Web SQL
IndexedDB 보다 먼저 Web SQL 이라는 규격이 있었습니다. 브라우저 안에 SQL(Structured Query Language, 구조화 질의 언어) 데이터베이스를 두고 SQL 문장으로 부르는 방식입니다.
이 규격은 표준으로 굳지 못했습니다. 구현이 모두 SQLite 라는 데이터베이스 엔진 하나 위에 섰습니다. SQL 문법도 SQLite 의 것을 따랐습니다. 엔진 하나의 동작을 규격으로 삼게 되어 다른 구현이 설 틈이 없었습니다.
IndexedDB 는 질의 언어를 정하지 않았습니다. 키로 찾고 키 순서대로 훑는 작은 동작만 정했습니다. 더 복잡한 질의는 그 위에 코드로 쌓습니다.
출처와 데이터베이스와 오브젝트 스토어
IndexedDB 의 데이터는 네 겹으로 놓입니다. 가장 바깥이 출처입니다. 출처 안에 데이터베이스가 있습니다. 데이터베이스 안에는 오브젝트 스토어가, 스토어 안에는 레코드가 있습니다.
출처는 주소의 앞부분인 프로토콜과 호스트와 포트를 한 벌로 묶은 것입니다.
https://mail.example.com 과 https://news.example.com 은 호스트가 달라서 다른 출처입니다.
브라우저는 IndexedDB 를 출처마다 따로 둡니다. 다른 출처의 페이지는 이 데이터베이스를 열지 못합니다. 출처끼리 이렇게 갈라 두는 규칙이 동일 출처 정책입니다.
한 출처는 이름을 달리해 데이터베이스를 여럿 만들 수 있습니다. 데이터베이스마다 버전 번호가 붙습니다. 버전은 구조를 바꿀 때 올리는 정수입니다. 자세한 것은 아래 「버전과 구조 바꾸기」 소절에 있습니다.
오브젝트 스토어는 레코드를 모아 두는 통입니다. 관계형 데이터베이스의 테이블에 해당합니다. 테이블과 달리 열을 미리 정하지 않습니다. 레코드의 값은 JavaScript 객체 하나입니다. 속성은 레코드마다 달라도 됩니다.
레코드는 키 하나와 값 하나로 이루어집니다. 스토어 안의 레코드는 키 순서로 정렬되어 놓입니다. 그래서 키의 범위를 주고 그 사이를 차례로 훑을 수 있습니다.
네 겹을 위에서 아래로 그리면 이렇습니다. 메일 웹 앱이 받은 편지를 inbox 라는 스토어에 담은
모습입니다.
flowchart TD
O["출처 · https://mail.example.com"] --> D["데이터베이스 mail · 버전 2"]
D --> S["오브젝트 스토어 inbox"]
S --> R1["레코드 · 키 41 → 편지 객체"]
S --> R2["레코드 · 키 42 → 편지 객체"]
키를 정하는 방법
레코드의 키는 스토어를 만들 때 정한 방식으로 붙습니다. 방식은 셋입니다.
| 방식 | 키가 어디서 오나 | 맞는 데이터 |
|---|---|---|
| 키 경로 | 값 안의 속성 하나. id 를 키 경로로 두면 mail.id 가 키다 |
값에 이미 고유한 번호가 있을 때 |
| 자동 증가 | 브라우저가 1, 2, 3 처럼 매긴다 | 번호를 따로 안 매긴 데이터 |
| 따로 넘김 | 넣을 때마다 코드가 키를 준다 | 값 밖에서 키가 정해질 때 |
키로 쓸 수 있는 값은 숫자 · 문자열 · 날짜 · 이진 데이터와, 이것들을 담은 배열입니다. 일반 객체는 키가 될 수 없습니다.
값을 복사해 담는 규칙
레코드를 넣을 때 브라우저는 객체를 복사해서 담습니다. 이 복사 규칙을 구조화된 복제라고
부릅니다. JSON 으로 바꾸는 것보다 담는 폭이 넓어서 날짜 · 파일 · Map 이 모양 그대로
담깁니다.
함수는 복사하지 못합니다. 함수가 든 객체를 넣으면 DataCloneError 로 실패합니다. 클래스로
만든 객체는 속성만 담겨서, 꺼내면 메서드가 없는 평범한 객체로 돌아옵니다.
요청과 이벤트로 받는 결과
읽기와 쓰기는 모두 트랜잭션 안에서 합니다. 트랜잭션은 여러 요청을 한 묶음으로 처리해 전부 반영하거나 전부 되돌리는 단위입니다. IndexedDB 에서 트랜잭션이 어떻게 끝나는지는 다음 소절에서 봅니다.
IndexedDB 의 호출은 거의 다 곧바로 요청 객체 하나를 돌려줍니다. 결과는 그 요청 객체에 나중에
채워집니다. 준비가 되면 브라우저가 요청 객체에 success 이벤트를 보냅니다. 실패하면 error
이벤트를 보냅니다.
키 42 인 편지 한 통을 꺼내는 코드를 봅니다. 데이터베이스는 이미 열어 db 에 담아 두었습니다.
const tx = db.transaction('inbox');
const store = tx.objectStore('inbox');
const req = store.get(42);
req.onsuccess = () => {
const mail = req.result;
mail.subject; // '회의 일정'
mail.from; // '[email protected]'
};
transaction('inbox') 가 inbox 스토어에 읽기 트랜잭션을 엽니다. get(42) 는 부르자마자
요청 객체 req 를 돌려주고 끝납니다. onsuccess 에 넘긴 함수는 디스크에서 값을 다 읽은 뒤에
불립니다. 그때 req.result 에 편지 객체가 들어 있습니다.
이 방식은 요청이 이어질수록 함수 안에 함수가 겹겹이 쌓입니다. 편지를 읽고 그 결과로 다른
스토어를 또 읽으면 onsuccess 안에 onsuccess 가 들어갑니다.
프로미스는 나중에 채워질 값을 담는 JavaScript 객체입니다. await 를 쓰면 그 값을
기다리는 코드를 위에서 아래로 적을 수 있습니다. 그래서 요청 객체를 프로미스로 감싸는 idb
같은 작은 라이브러리를 흔히 얹어 씁니다.
트랜잭션이 끝나는 시점
트랜잭션을 열 때는 건드릴 오브젝트 스토어 목록과 모드를 정합니다. 모드는 읽기만 하는
readonly 와 쓰기도 하는 readwrite 둘입니다. 읽기만 하는 트랜잭션끼리는 함께 돕니다. 같은
스토어에 쓰는 트랜잭션은 하나씩 차례로 돕니다.
트랜잭션에 요청을 걸 수 있는 때는 정해져 있습니다. 트랜잭션을 연 코드가 도는 동안, 그리고 그
트랜잭션의 요청 결과를 받은 함수가 도는 동안입니다. 이때를 트랜잭션이 활성인 동안이라고
부릅니다. 활성이 아닐 때 요청을 걸면 TransactionInactiveError 로 실패합니다.
IndexedDB 의 트랜잭션은 커밋을 부르지 않아도 됩니다. 걸어 둔 요청이 다 끝나면 브라우저가 알아서 커밋합니다. 결과를 받은 함수가 새 요청을 걸면 그 요청까지 기다립니다. 덕분에 트랜잭션 하나가 오래 열린 채로 다른 쓰기를 막아 두는 일이 없습니다.
되돌리는 길은 둘입니다. 하나는 코드가 abort() 를 부르는 것입니다. 다른 하나는 실패한 요청을
코드가 처리하지 않는 것입니다. 둘 다 그 트랜잭션의 쓰기를 전부 되돌립니다.
처리한다는 것은 실패한 요청의 error 이벤트를 받는 함수에서 event.preventDefault() 를 부르는
것입니다. preventDefault() 는 이벤트에 딸린 기본 동작을 막는 메서드입니다. 여기서 막는 기본
동작이 되돌림입니다. onerror 를 달기만 하고 이것을 부르지 않으면 트랜잭션은 그대로 되돌려집니다.
stateDiagram-v2
[*] --> 활성: transaction() 을 부른다
활성 --> 활성: 결과를 받은 함수가 새 요청을 건다
활성 --> 커밋: 요청이 다 끝나고 새 요청이 없다
활성 --> 되돌림: abort() 또는 preventDefault() 없는 실패
커밋 --> [*]
되돌림 --> [*]
자동 커밋 때문에 흔히 걸리는 실수가 있습니다. 트랜잭션 도중에 서버 응답을 기다리는 경우입니다. 기다리는 동안 이 트랜잭션에는 IndexedDB 에 건 요청이 하나도 남아 있지 않습니다.
서버로 보낸 요청은 이 셈에 들지 않습니다. 브라우저는 IndexedDB 요청이 없으니 트랜잭션을
커밋하고 닫습니다. 응답을 받은 뒤 IndexedDB 에 요청을 걸면 TransactionInactiveError 로
실패합니다.
const tx = db.transaction('inbox', 'readwrite');
const store = tx.objectStore('inbox');
const res = await fetch('/mail/43');
store.put(await res.json()); // 실패한다
fetch 로 서버 응답을 기다리는 동안 트랜잭션은 이미 닫혔습니다. 그래서 서버에서 받을 것은
트랜잭션을 열기 전에 다 받아 둡니다.
인덱스와 커서로 찾기
스토어는 키로만 찾을 수 있습니다. 키가 아닌 속성으로 찾으려면 인덱스를 만듭니다. 인덱스는 값 안의 속성 하나를 골라 그 순서로 레코드를 한 번 더 정렬해 둔 목록입니다.
편지를 번호가 아니라 날짜로 찾고 싶다면 date 속성에 by_date 라는 인덱스를 만듭니다.
레코드를 넣거나 고치면 브라우저가 인덱스도 함께 고칩니다.
인덱스에는 같은 값이 두 번 들어오지 못하게 막는 설정도 있습니다. 이런 인덱스를 유일 인덱스라고
부릅니다. 사람마다 하나뿐인 메일 주소 같은 속성에 씁니다. 이미 있는 값을 넣으려는 쓰기는
ConstraintError 로 실패합니다.
여러 레코드를 차례로 읽을 때는 커서를 씁니다. 커서는 키 순서대로 레코드를 하나씩 가리키며 다음으로 넘어갑니다. 키 범위를 주면 그 범위 안만 훑습니다.
키 40 부터 45 까지를 훑는 코드입니다. 그 사이 레코드가 다 있다고 봅니다.
const range = IDBKeyRange.bound(40, 45);
const req = store.openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return; // 범위 끝
cursor.key; // 40, 41, … 45
cursor.continue();
};
onsuccess 는 레코드 하나마다 한 번씩 불립니다. continue() 가 커서를 다음 레코드로 옮깁니다.
범위를 다 돌면 req.result 가 null 이 되어 함수가 끝납니다. 인덱스로 찾을 때도 모양이 같습니다.
store.index('by_date') 로 인덱스를 꺼내 openCursor 를 부르면 날짜 순서로 훑습니다.
IndexedDB 에는 두 스토어를 엮는 조인이 없습니다. 편지와 보낸 사람을 함께 보이려면 두 스토어를 각각 읽어 코드에서 맞춥니다. 조건 여러 개를 한 번에 거는 질의도 없어서, 인덱스 하나로 범위를 좁히고 나머지 조건은 코드에서 거릅니다.
버전과 구조 바꾸기
오브젝트 스토어와 인덱스는 아무 때나 만들 수 없습니다. 데이터베이스를 열 때 지금보다 높은
버전 번호를 대면 브라우저가 upgradeneeded 이벤트를 보냅니다. 스토어와 인덱스는 이 이벤트
안에서만 만들고 지웁니다.
서버 쪽의 스키마 마이그레이션과 같은 일입니다. 다른 것은 이 코드가 도는 곳과 때입니다. 방문자마다 그 사람의 브라우저에서, 새 코드를 받은 뒤 처음 열 때 돕니다.
오래 안 온 방문자는 버전 1 에서 3 으로 한 번에 건너뛰기도 합니다. 그래서 업그레이드 코드는 이전 버전을 보고 버전마다 할 일을 차례로 합니다.
const req = indexedDB.open('mail', 2);
req.onupgradeneeded = (e) => {
const db = req.result;
e.oldVersion; // 처음 열면 0
if (e.oldVersion < 1) {
db.createObjectStore('inbox', { keyPath: 'id' });
}
if (e.oldVersion < 2) {
const s = req.transaction.objectStore('inbox');
s.createIndex('by_date', 'date');
}
};
처음 온 방문자는 두 if 를 다 지나 스토어와 인덱스를 함께 만듭니다. 버전 1 을 가진 방문자는
두 번째 if 만 지나 인덱스를 더합니다.
업그레이드가 도는 동안에는 브라우저가 연 트랜잭션 하나가 이미 돌고 있습니다. 코드는
req.transaction 으로 그 트랜잭션을 받습니다. 두 번째 if 는 거기서 이미 있는 inbox 스토어를
꺼내 인덱스를 붙입니다.
데이터베이스의 버전은 올라가기만 합니다. 지금보다 낮은 버전으로 열면 VersionError 로
실패합니다.
같은 사이트를 탭 두 개로 열어 둔 방문자는 업그레이드가 막힐 수 있습니다. 옛 탭이 버전 1 로 연결을 연 채로 있으면, 버전 2 로 열려는 새 탭은 그 연결이 닫힐 때까지 구조를 못 바꿉니다.
브라우저는 옛 탭의 연결에 versionchange 이벤트를 보냅니다. 옛 탭이 연결을 닫으면 새 탭의
업그레이드가 이어집니다. 그래서 versionchange 를 받으면 연결을 닫고 새로고침을 알리는 코드를
흔히 넣어 둡니다.
옛 탭이 연결을 닫지 않으면 새 탭에는 blocked 이벤트가 갑니다. 새 탭의 업그레이드는 옛 연결이
닫힐 때까지 멈춰 기다립니다.
sequenceDiagram
participant 옛탭 as 옛 탭 · 버전 1
participant 브라우저
participant 새탭 as 새 탭 · 버전 2
새탭->>브라우저: 버전 2 로 연다
브라우저->>옛탭: versionchange
Note over 옛탭,새탭: 옛 탭이 안 닫으면 새 탭에 blocked 가 가고 기다린다
옛탭->>브라우저: 연결을 닫는다
브라우저->>새탭: upgradeneeded
브라우저가 지울 수 있는 데이터
IndexedDB 에 넣은 값은 방문자의 브라우저가 쥐고 있습니다. 방문자가 사이트 데이터를 지우면 함께 사라집니다. 디스크가 모자라면 브라우저가 한 출처의 데이터를 통째로 지우기도 합니다.
출처마다 쓸 수 있는 양도 브라우저가 정합니다. 그 양을 넘기면 쓰기가 QuotaExceededError 로
실패합니다.
navigator.storage.persist() 로 지우지 말아 달라고 청할 수 있습니다. 받아들일지는 브라우저가
정합니다. 받아들여지면 디스크가 모자라도 브라우저가 이 출처의 데이터를 알아서 지우지 않습니다.
그래서 IndexedDB 를 쓰는 앱은 원본을 서버에 둡니다. 지워진 것을 알아채면 서버에서 다시 받아 채우도록 짭니다.
서버 데이터베이스와 다른 점
서버 데이터베이스와 이름은 같지만 놓인 곳과 쓰는 사람이 다릅니다.
| 서버 데이터베이스 | IndexedDB | |
|---|---|---|
| 놓인 곳 | 서버 한 대나 여러 대 | 방문자마다 그 사람의 브라우저 |
| 쓰는 사람 | 모든 사용자가 한 곳을 함께 | 방문자 한 명과 그 출처의 페이지 |
| 찾는 방법 | 질의 언어로 조건을 건다 | 키 · 키 범위 · 인덱스 |
| 구조를 바꾸는 때 | 배포할 때 한 번 | 방문자마다 다음에 열 때 |
| 데이터가 사라지나 | 운영자가 지울 때만 | 브라우저가 지울 수 있다 |
IndexedDB 는 한 브라우저 안만 압니다. 같은 사람이 휴대폰과 노트북에서 같은 데이터를 보려면 서버와 맞추는 동기화 코드를 따로 짭니다.
쓰는 곳과 안 쓰는 곳
네트워크가 끊겨도 돌아야 하는 앱이 IndexedDB 를 씁니다. 신호가 끊긴 지하철에서도 받은 편지 목록을 보여 주는 메일 웹 앱이 그 예입니다. 먼저 IndexedDB 에 쓰고 나중에 서버로 올리는 설계를 오프라인 우선이라고 부릅니다.
큰 데이터를 방문자 쪽에 쌓아 둘 때도 씁니다. 지도 조각 이미지나 첨부 파일처럼 다시 받기 비싼 것들입니다. 서비스 워커가 다음 요청까지 남겨야 할 값을 적어 두는 데도 씁니다.
설정값 몇 개를 두는 데는 웹 스토리지 한 줄이 짧습니다. 버전 · 트랜잭션 · 이벤트를 다 갖춰야 하는 IndexedDB 는 그만큼 코드가 길어집니다.
로그인 토큰 같은 비밀값은 여기 두면 같은 출처의 스크립트가 모두 읽습니다. 공격자가 페이지에 스크립트를 끼워 넣는 XSS(Cross-Site Scripting, 교차 사이트 스크립팅)가 일어나면 그 스크립트도 읽습니다.
관련 항목
IndexedDB 와 같은 역할을 두고 겨루는 브라우저 저장소
웹 스토리지 · localStorage · 세션 스토리지 · 쿠키 · 캐시 스토리지 · 브라우저 캐시 · Web SQL · OPFS
IndexedDB 를 이루는 구성 요소
오브젝트 스토어 · 레코드 · 인덱스 · 키 경로 · 자동 증가 · 커서 · 키 범위 · 트랜잭션
IndexedDB 가 값을 담을 때 따르는 복사 규칙
구조화된 복제 · 직렬화 · JSON · Blob · ArrayBuffer
IndexedDB 를 부르는 브라우저 실행 환경
JavaScript · 메인 스레드 · 이벤트 루프 · 웹 워커 · 서비스 워커 · 프로미스 · 콜백 · 비동기 프로그래밍
IndexedDB 의 저장 공간을 나누고 제한하는 규칙
동일 출처 정책 · 오리진 · 사이트 격리 · 스토리지 분할 · 스토리지 할당량 · 영속 스토리지 · XSS
IndexedDB 에서 자주 나는 오류
TransactionInactiveError · DataCloneError · QuotaExceededError · VersionError · ConstraintError · DOMException
IndexedDB 가 빌려 온 데이터베이스 개념
데이터베이스 · 키-값 저장소 · ACID · 원자성 · 커밋 · 롤백 · 스키마 마이그레이션 · NoSQL · 관계형 데이터베이스 · 조인
IndexedDB 를 프로미스로 감싼 라이브러리
idb · Dexie.js · localForage · PouchDB
IndexedDB 를 얹어 만드는 웹 애플리케이션
프로그레시브 웹 앱 · 오프라인 우선 · 백그라운드 동기화 · 데이터 동기화
IndexedDB 가 속하는 상위 분류
다른 이름: Indexed Database API · IndexedDB API · 인덱스드 DB