JavaScript 및 IndexedDB

JavaScript 및 IndexedDB

이 글에서는 JavaScript와 IndexedDB에 대해 설명합니다.

이 튜토리얼에서는 JavaScript와 IndexedDB에 대해 단계별로 설명하며, 각 단계마다 실용적인 샘플 코드를 제공하여 이해를 돕습니다.

YouTube Video

javascript-indexed-db.html
  1<!DOCTYPE html>
  2<html lang="en">
  3<head>
  4  <meta charset="UTF-8">
  5  <title>JavaScript &amp; HTML</title>
  6  <style>
  7    * {
  8        box-sizing: border-box;
  9    }
 10
 11    body {
 12        margin: 0;
 13        padding: 1em;
 14        padding-bottom: 10em;
 15        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 16        background-color: #f7f9fc;
 17        color: #333;
 18        line-height: 1.6;
 19    }
 20
 21    .container {
 22        max-width: 800px;
 23        margin: 0 auto;
 24        padding: 1em;
 25        background-color: #ffffff;
 26        border: 1px solid #ccc;
 27        border-radius: 10px;
 28        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
 29    }
 30
 31    .container-flex {
 32        display: flex;
 33        flex-wrap: wrap;
 34        gap: 2em;
 35        max-width: 1000px;
 36        margin: 0 auto;
 37        padding: 1em;
 38        background-color: #ffffff;
 39        border: 1px solid #ccc;
 40        border-radius: 10px;
 41        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
 42    }
 43
 44    .left-column, .right-column {
 45        flex: 1 1 200px;
 46        min-width: 200px;
 47    }
 48
 49    h1, h2 {
 50        font-size: 1.2rem;
 51        color: #007bff;
 52        margin-top: 0.5em;
 53        margin-bottom: 0.5em;
 54        border-left: 5px solid #007bff;
 55        padding-left: 0.6em;
 56        background-color: #e9f2ff;
 57    }
 58
 59    button {
 60        display: block;
 61        margin: 1em auto;
 62        padding: 0.75em 1.5em;
 63        font-size: 1rem;
 64        background-color: #007bff;
 65        color: white;
 66        border: none;
 67        border-radius: 6px;
 68        cursor: pointer;
 69        transition: background-color 0.3s ease;
 70    }
 71
 72    button:hover {
 73        background-color: #0056b3;
 74    }
 75
 76    #output {
 77        margin-top: 1em;
 78        background-color: #1e1e1e;
 79        color: #0f0;
 80        padding: 1em;
 81        border-radius: 8px;
 82        min-height: 200px;
 83        font-family: Consolas, monospace;
 84        font-size: 0.95rem;
 85        overflow-y: auto;
 86        white-space: pre-wrap;
 87    }
 88
 89    .highlight {
 90        outline: 3px solid #ffc107; /* yellow border */
 91        background-color: #fff8e1;  /* soft yellow background */
 92        transition: background-color 0.3s ease, outline 0.3s ease;
 93    }
 94
 95    .active {
 96        background-color: #28a745; /* green background */
 97        color: #fff;
 98        box-shadow: 0 0 10px rgba(40, 167, 69, 0.5);
 99        transition: background-color 0.3s ease, box-shadow 0.3s ease;
100    }
101  </style>
102</head>
103<body>
104    <div class="container">
105        <h1>JavaScript Console</h1>
106        <button id="executeBtn">Execute</button>
107        <div id="output"></div>
108    </div>
109
110    <script>
111        // Override console.log to display messages in the #output element
112        (function () {
113            // Override console.log
114            const originalLog = console.log;
115            console.log = function (...args) {
116                originalLog.apply(console, args);
117                const message = document.createElement('div');
118                message.textContent = args
119                    .map(arg => (typeof arg === "object" && arg !== null ? JSON.stringify(arg) : String(arg)))
120                    .join(" ");
121                output.appendChild(message);
122            };
123
124            // Override console.error
125            const originalError = console.error;
126            console.error = function (...args) {
127                originalError.apply(console, args);
128                const message = document.createElement('div');
129                message.textContent = args
130                    .map(arg => (typeof arg === "object" && arg !== null ? JSON.stringify(arg) : String(arg)))
131                    .join(" ");
132                message.style.color = 'red'; // Color error messages red
133                output.appendChild(message);
134            };
135        })();
136
137        document.getElementById('executeBtn').addEventListener('click', () => {
138            // Prevent multiple loads
139            if (document.getElementById('externalScript')) return;
140
141            const script = document.createElement('script');
142            script.src = 'javascript-indexed-db.js';
143            script.id = 'externalScript';
144            //script.onload = () => console.log('javascript-indexed-db.js loaded and executed.');
145            //script.onerror = () => console.log('Failed to load javascript-indexed-db.js.');
146            document.body.appendChild(script);
147        });
148    </script>
149</body>
150</html>

JavaScript 및 IndexedDB

IndexedDB는 브라우저에 내장된 비동기식 키-값 저장소 데이터베이스입니다. 관계형 데이터베이스와 유사한 기능을 제공하며, 클라이언트 측에 대용량의 구조화된 데이터를 저장하고 검색할 수 있습니다. 특히 오프라인 기능이 있는 애플리케이션이나 PWA(Progressive Web Apps)에 유용합니다.

IndexedDB의 특징

  • 비동기적이며 이벤트 기반으로 동작합니다.
  • JavaScript 객체를 오브젝트 저장소에 저장할 수 있습니다.
  • 쿼리나 인덱스를 통한 검색이 가능합니다.
  • 저장 용량이 매우 크며(수백 MB 이상), 쿠키나 localStorage보다 훨씬 많은 데이터를 저장할 수 있습니다.

데이터베이스 열기 및 생성하기

IndexedDB를 사용하려면 먼저 데이터베이스를 열어야 합니다. 존재하지 않는 경우 자동으로 생성됩니다.

1const request = indexedDB.open('MyDatabase', 1); // Specify DB name and version
  • open 메서드는 데이터베이스를 비동기적으로 열며 다음 세 가지 이벤트를 발생시킵니다.

onsuccess

1// Fired when database is successfully opened
2request.onsuccess = (event) => {
3    const db = event.target.result; // Database instance
4    console.log('Database opened successfully:', db.name);
5};
  • onsuccess 이벤트는 데이터베이스가 성공적으로 열릴 때 발생합니다. 이 시점부터 request.result를 통해 후속 작업을 수행할 수 있습니다.

onerror

1// Fired when database fails to open
2request.onerror = (event) => {
3    console.error('Failed to open database:', event.target.error);
4};
  • onerror 이벤트는 데이터베이스를 여는 데 실패할 때 발생합니다. 여기서 오류 로그를 기록하고 처리해야 합니다.

onupgradeneeded

 1// Fired when database is newly created or upgraded
 2request.onupgradeneeded = (event) => {
 3    const db = event.target.result;
 4    console.log('Database upgrade needed (or newly created):', db.name);
 5
 6    // Example: Create an object store (like a table) if it doesn’t exist
 7    if (!db.objectStoreNames.contains('users')) {
 8        db.createObjectStore('users', { keyPath: 'id' });
 9        console.log('Object store "users" created');
10    }
11};
  • onupgradeneeded 이벤트는 데이터베이스가 새로 생성되거나 지정된 버전이 기존 버전보다 높을 때 발생합니다. 테이블(오브젝트 저장소) 생성 및 스키마 정의는 이 시점에서 해야 합니다.

오브젝트 저장소 생성하기

먼저 onupgradeneeded 이벤트 내에서 '오브젝트 저장소'(테이블에 해당)를 생성합니다.

1request.onupgradeneeded = function (event) {
2    const db = event.target.result;
3    console.log("onupgradeneeded triggered. Database version:", db.version);
4    if (!db.objectStoreNames.contains('users')) {
5        console.log("Creating object store: users");
6        const store = db.createObjectStore('users', { keyPath: 'id' });
7        store.createIndex('name', 'name', { unique: false });
8    }
9};

여기에서는 다음과 같은 설정을 적용합니다:.

  • createObjectStore createObjectStore는 데이터베이스 내에서 오브젝트 저장소를 생성하는 메서드입니다. 오브젝트 저장소에 레코드를 저장할 수 있으며, keyPath 등 다양한 옵션으로 데이터 관리 방식을 정의할 수 있습니다. 이 작업은 반드시 onupgradeneeded 이벤트 내에서 이루어져야 합니다.

  • keyPath: 'id' keyPathid 속성으로 설정하여 각 레코드를 고유하게 식별하도록 기본 키로 지정합니다. 이렇게 하면 데이터를 추가, 검색, 수정할 때 자동으로 id가 사용됩니다.

  • createIndex createIndex 메서드를 사용하여 name 속성을 기반으로 한 인덱스를 생성해 검색에 활용할 수 있습니다. unique: false로 설정하면 동일한 name 값을 가진 여러 레코드를 허용할 수 있습니다.

데이터베이스 연결 성공 시 처리

데이터베이스 연결이 성공했을 때 실행할 처리는 onsuccess 이벤트에 지정합니다. 이 과정에서 데이터베이스 인스턴스를 얻어 데이터 읽기 및 쓰기를 준비합니다.

1request.onsuccess = function (event) {
2    const db = event.target.result;
3    console.log('Database opened successfully');
4    // Use db for reading and writing in subsequent operations
5};
  • 이 처리는 IndexedDB 데이터베이스 연결이 정상적으로 완료될 때 실행됩니다.

  • **event.target.result**에는 열린 데이터베이스 인스턴스(IDBDatabase 객체)가 저장되며, 트랜잭션 시작 및 오브젝트 저장소 접근에 사용합니다.

  • 데이터 추가, 검색, 수정, 삭제 등 실제 읽기/쓰기 작업은 db 객체를 통해 수행합니다.

이 시점에는 데이터베이스가 준비되었으므로 트랜잭션을 안전하게 시작할 수 있습니다.

데이터 추가하기

IndexedDB에 새 데이터를 추가하는 방법입니다.

 1function addUser(db, user) {
 2    const transaction = db.transaction('users', 'readwrite');
 3    const store = transaction.objectStore('users');
 4    const request = store.add(user);
 5
 6    request.onsuccess = () => {
 7        console.log('User added:', user);
 8    };
 9
10    request.onerror = () => {
11        console.error('Add failed:', request.error);
12    };
13}
14
15// Example: Add a user
16request.onsuccess = function (event) {
17    const db = event.target.result;
18    addUser(db, { id: 1, name: 'Alice' });
19    addUser(db, { id: 2, name: 'Bob' });
20    addUser(db, { id: 3, name: 'John' });
21};
  • db.transaction()으로 트랜잭션을 생성하고, 동작할 오브젝트 저장소와 모드(여기서는 readwrite)를 지정합니다.
  • store.add() 메서드로 새 데이터를 추가합니다.
  • 트랜잭션은 자동으로 커밋되지만, 여러 작업을 그룹으로 처리하고 싶을 때는 트랜잭션 종료 이벤트를 활용해 관리할 수 있습니다.

데이터 조회하기(기본 키 검색)

기본 키로 특정 데이터를 조회하는 방법입니다.

 1function getUserById(db, id) {
 2    const transaction = db.transaction('users', 'readonly');
 3    const store = transaction.objectStore('users');
 4    const request = store.get(id);
 5
 6    request.onsuccess = () => {
 7        if (request.result) {
 8            console.log('User found:', request.result);
 9        } else {
10            console.log('User not found');
11        }
12    };
13
14    request.onerror = () => {
15        console.error('Error retrieving user:', request.error);
16    };
17}
18
19// Example: Get a user by id
20request.onsuccess = function (event) {
21    const db = event.target.result;
22    getUserById(db, 1);
23};
  • db.transaction()으로 읽기 전용 트랜잭션을 생성합니다.
  • store.get(id)를 사용해 지정한 기본 키에 해당하는 데이터를 조회할 수 있습니다.
  • 조회에 성공하면 onsuccess가 호출되고, 결과가 있으면 출력됩니다. 결과가 없는 경우 '해당 데이터 없음'으로 처리합니다.

인덱스 검색 활용하기

기본 키 이외의 속성으로 검색하려면, 미리 생성한 인덱스를 사용하세요.

 1function getUserByName(db, name) {
 2    const transaction = db.transaction('users', 'readonly');
 3    const store = transaction.objectStore('users');
 4    const index = store.index('name');
 5    const request = index.get(name);
 6
 7    request.onsuccess = () => {
 8        if (request.result) {
 9            console.log('User by name:', request.result);
10        } else {
11            console.log('No user found with that name');
12        }
13    };
14
15    request.onerror = () => {
16        console.error('Error retrieving user by name:', request.error);
17    };
18}
19
20// Example: Get a user by id
21request.onsuccess = function (event) {
22    const db = event.target.result;
23    getUserByName(db, 'Alice');
24};
  • store.index('name')을 사용해 미리 생성된 name 인덱스에 접근합니다.
  • index.get(value)로 해당 값과 일치하는 첫 레코드를 조회할 수 있습니다. 동일한 값을 가진 레코드가 여러 개인 경우 index.getAll(value)로 모두 조회할 수 있습니다.

데이터 수정하기

기존 데이터를 수정(덮어쓰기)하려면 put() 메서드를 사용하세요.

일치하는 기본 키가 있으면 데이터가 갱신되고, 없으면 새 레코드가 추가됩니다.

 1function updateUser(db, updatedUser) {
 2    const transaction = db.transaction('users', 'readwrite');
 3    const store = transaction.objectStore('users');
 4    const request = store.put(updatedUser);
 5
 6    request.onsuccess = () => {
 7        console.log('User updated:', updatedUser);
 8    };
 9
10    request.onerror = () => {
11        console.error('Update failed:', request.error);
12    };
13}
14
15// Example: Update user
16request.onsuccess = async (event) => {
17    const db = event.target.result;
18
19    // Test : update existing user
20    updateUser(db, { id: 3, name: 'John Updated' });
21
22    // Test : insert new user
23    updateUser(db, { id: 4, name: 'Charlie' });
24};
  • put()은 갱신과 추가를 모두 지원하는 편리한 메서드입니다.
  • 동일한 기본 키의 데이터가 이미 존재하면 덮어써집니다.
  • 갱신 전에 데이터 존재 유무를 확인하려면 get()을 사용해 미리 확인할 수 있습니다.

데이터 삭제하기

지정한 기본 키에 해당하는 데이터를 삭제하려면 delete() 메서드를 사용하세요.

 1function deleteUser(db, id) {
 2    const transaction = db.transaction('users', 'readwrite');
 3    const store = transaction.objectStore('users');
 4    const request = store.delete(id);
 5
 6    request.onsuccess = () => {
 7        console.log(`User with id=${id} deleted successfully`);
 8    };
 9
10    request.onerror = () => {
11        console.error(`Failed to delete user with id=${id}:`, request.error);
12    };
13}
14
15// Example: Delete a user by id
16request.onsuccess = function (event) {
17    const db = event.target.result;
18    deleteUser(db, 4);
19};
  • store.delete(id)로 해당 기본 키의 데이터를 삭제합니다.
  • 데이터가 존재하지 않더라도 오류가 발생하지 않으며 성공으로 처리됩니다.
  • 오류 처리를 구현하면 더 견고한 코드를 만들 수 있습니다.

모든 데이터 조회하기

getAll()

오브젝트 저장소의 모든 레코드를 조회하려면 getAll() 메서드를 사용하세요.

 1function getAllUsers(db) {
 2    const transaction = db.transaction('users', 'readonly');
 3    const store = transaction.objectStore('users');
 4    const request = store.getAll();
 5
 6    request.onsuccess = () => {
 7        console.log('All users:', request.result);
 8    };
 9
10    request.onerror = () => {
11        console.error('Failed to retrieve users:', request.error);
12    };
13}
14
15// Example: Get all users
16request.onsuccess = function (event) {
17    const db = event.target.result;
18    getAllUsers(db);
19};
  • getAll()로 지정된 오브젝트 저장소의 모든 레코드를 배열로 조회할 수 있습니다.
  • 대량의 데이터를 한 번에 조회할 때도 효율적으로 처리됩니다.
  • 결과는 request.result에 배열로 저장됩니다.
  • 실패에 대비해 항상 오류 처리를 추가하세요.

openCursor()

openCursor()는 오브젝트 저장소나 인덱스의 레코드를 순차적으로 탐색하는 메서드입니다. 대량 데이터를 한 번에 조회하지 않고 하나씩 처리하고 싶을 때 유용합니다.

 1function getAllUsersWithCursor(db) {
 2    const transaction = db.transaction('users', 'readonly');
 3    const store = transaction.objectStore('users');
 4    const request = store.openCursor();
 5
 6    request.onsuccess = () => {
 7        const cursor = request.result;
 8        if (cursor) {
 9            console.log('User:', cursor.value); // Process the current record
10            cursor.continue(); // Move to the next record
11        } else {
12            console.log('All users have been processed.');
13        }
14    };
15
16    request.onerror = () => {
17        console.error('Failed to open cursor:', request.error);
18    };
19}
20
21// Example: Get all users
22request.onsuccess = function (event) {
23    const db = event.target.result;
24    getAllUsersWithCursor(db);
25};
  • **openCursor()**를 사용하면 커서를 시작하여 오브젝트 저장소의 모든 레코드를 하나씩 순회할 수 있습니다.
  • **cursor.value**를 사용해 현재 레코드의 데이터 객체를 얻습니다.
  • **cursor.continue()**로 커서를 다음 레코드로 이동시킬 수 있습니다.
  • **cursor === null**이 되면 모든 레코드를 순회한 것입니다.

openCursor()를 사용한 갱신 프로세스 예시

예를 들어, 이름이 'Alice'인 사용자의 이름을 'Alicia'로 변경하는 과정은 다음과 같습니다:.

 1function updateUserName(db, oldName, newName) {
 2    const transaction = db.transaction('users', 'readwrite');
 3    const store = transaction.objectStore('users');
 4    const index = store.index('name'); // Use the 'name' index
 5    const request = index.openCursor(IDBKeyRange.only(oldName));
 6
 7    request.onsuccess = () => {
 8        const cursor = request.result;
 9        if (cursor) {
10            const user = cursor.value;
11            user.name = newName; // Update the name
12            const updateRequest = cursor.update(user);
13
14            updateRequest.onsuccess = () => {
15                console.log('Updated user:', user);
16            };
17
18            updateRequest.onerror = () => {
19                console.error('Failed to update user:', updateRequest.error);
20            };
21
22            cursor.continue();
23        } else {
24            console.log('All matching users have been updated.');
25        }
26    };
27
28    request.onerror = () => {
29        console.error('Cursor error:', request.error);
30    };
31}
32
33// Example: Update user name
34request.onsuccess = function (event) {
35    const db = event.target.result;
36    updateUserName(db, 'Alice', 'Alicia');
37};
  • IDBKeyRange.only(oldName) IDBKeyRange.only을 사용하면 키가 정확히 oldName과 일치하는 레코드만을 대상으로 할 수 있습니다. 특정 값을 직접 접근하고자 할 때 유용합니다.

  • cursor.update() cursor.value를 갱신한 후, update()를 호출하면 해당 레코드가 덮어써집니다.

  • 여러 일치 항목 처리 cursor.continue()를 호출하면 커서를 다음 일치 레코드로 이동할 수 있습니다. 이렇게 하면 동일한 키 또는 조건에 일치하는 여러 레코드를 차례로 처리할 수 있습니다.

  • 오류 처리 처리에 실패했을 때 onerror에서 로그를 출력하면, 원인 조사와 운영 중의 문제 해결이 용이해집니다.

openCursor()를 사용한 삭제 프로세스 예시

예를 들어, 이름이 'Bob'인 모든 사용자를 삭제하는 과정은 다음과 같습니다:.

 1function deleteUsersByName(db, targetName) {
 2    const transaction = db.transaction('users', 'readwrite');
 3    const store = transaction.objectStore('users');
 4    const index = store.index('name');
 5    const request = index.openCursor(IDBKeyRange.only(targetName));
 6
 7    request.onsuccess = () => {
 8        const cursor = request.result;
 9        if (cursor) {
10            const deleteRequest = cursor.delete();
11
12            deleteRequest.onsuccess = () => {
13                console.log('Deleted user:', cursor.value);
14            };
15
16            deleteRequest.onerror = () => {
17                console.error('Failed to delete user:', deleteRequest.error);
18            };
19
20            cursor.continue();
21        } else {
22            console.log('All matching users have been deleted.');
23        }
24    };
25
26    request.onerror = () => {
27        console.error('Cursor error:', request.error);
28    };
29}
30
31// Example: Delete user by name
32request.onsuccess = function (event) {
33    const db = event.target.result;
34    deleteUsersByName(db, 'Bob');
35};
  • cursor.delete() cursor.delete()를 사용하면 현재 커서 위치의 레코드가 삭제됩니다. 결과가 비동기적으로 반환되기 때문에 onsuccess에서 과정을 확인할 수 있습니다.

트랜잭션 및 비동기 처리에 관한 주의사항

IndexedDB는 비동기적이고 이벤트 기반으로 동작합니다. 모든 작업은 onsuccess나 onerror 이벤트로 처리해야 합니다. 여러 처리를 하나로 묶고 싶을 때는 Promise로 감싸는 것이 편리합니다.

 1function openDatabase() {
 2    return new Promise((resolve, reject) => {
 3        const request = indexedDB.open('MyDatabase', 1);
 4
 5        request.onupgradeneeded = function (event) {
 6            const db = event.target.result;
 7
 8            // Initialization process (creating object stores and indexes, etc.)
 9            const store = db.createObjectStore('users', { keyPath: 'id' });
10            store.createIndex('name', 'name', { unique: false });
11        };
12
13        request.onsuccess = function (event) {
14            const db = event.target.result;
15            resolve(db);
16        };
17
18        request.onerror = function () {
19            reject(request.error);
20        };
21    });
22}
23
24function addUserAsync(db, user) {
25    return new Promise((resolve, reject) => {
26        const transaction = db.transaction('users', 'readwrite');
27        const store = transaction.objectStore('users');
28        const request = store.add(user);
29
30        request.onsuccess = () => resolve();
31        request.onerror = () => reject(request.error);
32    });
33}
34
35async function main() {
36    try {
37        const db = await openDatabase();
38        await addUserAsync(db, { id: 1, name: 'Alice' });
39        console.log('User added successfully');
40    } catch (error) {
41        console.error('Error:', error);
42    }
43}
  • openDatabaseaddUserAsync와 같은 함수를 Promise로 감싸면 async/await로 비동기 처리를 직관적으로 다룰 수 있습니다.
  • 이렇게 하면 콜백 지옥을 피하고 코드의 가독성이 좋아집니다.

요약

IndexedDB는 브라우저 측에서 고급 데이터 관리를 하고자 할 때 매우 강력한 기능입니다. 처음에는 이벤트 기반 비동기 처리에 혼란을 느낄 수 있지만, 구조를 이해하면 클라이언트 측에서 본격적인 데이터 조작이 가능합니다.

특히 다음과 같은 점을 기억해 두면 더욱 원활하게 사용할 수 있습니다:.

  • onupgradeneeded로 초기 설정을 진행하세요.
  • 트랜잭션의 읽기/쓰기 모드에 유의하세요.
  • 인덱스를 사용하면 효율적으로 검색할 수 있습니다.
  • 데이터를 객체 형태로 저장할 수 있어 JSON과의 호환성이 높습니다.

IndexedDB를 마스터하면 PWA와 오프라인 앱의 데이터 관리가 훨씬 쉬워집니다.

위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video