SQLite Part 3
그럼 이제부터 본격 SQLite 를 주물럭 해봅시다.
Expo-SQLite 의 공식 페이지는 가까운 곳에 북마크 해두세요. 앞으로 문지방이 닳도록 들락거려야 합니다.
https://docs.expo.dev/versions/latest/sdk/sqlite/
.
SQLite 의 Data Types
SQLite 에서는 내부적으로 5 가지의 데이터 타입만 사용됩니다.
NULL
INTEGER
REAL : 다른 DB 에서의 float 입니다.
TEXT
BLOB : 바이너리 데이터
그러나, 위 5 가지의 데이터타입은 SQLite 가 내부적으로 데이터를 처리하는 Type 이고, 사용자는 조금 더 유연하게 사용할 수 있습니다. 예를 들어:
선언한 데이터 타입 변환된 스토리지 클래스 | |
| INTEGER |
| REAL |
| TEXT |
| BLOB |
| INTEGER, REAL, or TEXT (상황에 따라 다름) |
SQLite는 특정한 데이터 타입 제약을 강제하지 않습니다. 따라서, **타입 계열(Affinity)**을 기반으로 저장하려고 시도합니다. 예를 들어, VARCHAR(20)
같은 데이터 타입은 실제로 TEXT
로 변환됩니다. 인식할 수 없는 데이터 타입은 처리할 수 없겠지만, ANSI 에서 일반적으로 사용되는 데이터 타입은 SQLite 에서 내부적으로 처리할 수 있는 타입계열로 변환해서 처리해줍니다.
이 특성으로 볼때, NUMERIC 타입은 편리해 보입니다. 앞으로 숫자형은 대충 NUMERIC 만 쓰면 될 것 같습니다.
.
DATETIME 타입 문제
SQLite 에는 DATETIME 에 상응하는 데이터 타입이 없습니다. 때문에 DATETIME 은 unix_timestamp - int(10) - 또는 text 형식 을 사용해야 합니다.
그리고 Serverless App 에서는 한가지 문제가 더 생기는데, 바로 서버의 타임존 과 사용자 디바이스의 타임존이 서로 달라서 발생하는 문제가 있습니다. 게다가 사용자의 타임존은 수시로 바뀔 수도 있습니다.
예를 들어서 D-Day Timeout 이 카운트다운 된다고 가정해보죠. 서버에 기록된 D-Day 의 timestamp 와, 사용자 디바이스에서 갖고 있는 timestamp 는 기준 시간이 다르기 때문에, 서버푸시를 받아야 할 때 문제가 생깁니다. 서버푸시 Notification 은 서버 시간에만 의존하게 된다는 것이지요. 만약 서버가 런던에 있다면, 서울의 사용자에게는 Notification 이 아홉시간 늦게 도착할 것입니다.
이 때문에 SQLite 에서 DATETIME 을 timestamp 에 의존하면 안됩니다. SQLite 에서 DATETIME 데이터 타입을 사용할 때에는,
무조건 TEXT 타입, UTC 포맷을 쓰자.
라고 외워둡시다.
'2024-11-28T12:34:56+09:00’ - UTC 포맷은 Timezone 정보를 포함합니다.
DATETIME 을 DB 에 INSERT 할 때,
INSERT INTO events (id, event_date) VALUES (1, '2024-11-28T12:34+09:00');
// event_date 의 data type 은 TEXT 입니다.
그리고, 불러올 때는 로컬 시간으로 변환해줍니다.
// JS
const utcDate = new Date('2024-11-18T12:34:56+09:00');
const localDate = utcDate.toLocaleString(); // 사용자 시간대로 변환
SQLite의 datetime
함수 사용
// SQL
-- UTC로 저장된 데이터를 로컬 시간으로 변환
SELECT datetime(event_date, 'localtime') AS local_event_date FROM events;
-- 로컬 현재 시간을 UTC로 변환하여 저장
INSERT INTO events (event_date) VALUES ( strftime('%Y-%m-%dT%H:%M:%SZ', 'now') );
.
SQLite DB Monitoring
React Native SQLite 개발 프로젝트에서 DB 파일은 물리적으로 안드로이드 에뮬레이터 내부에 위치합니다.
따라서, 우리는 먼저 And.Studio 의 Device Manager 에서 DB 파일 위치를 열어야 합니다. 여기서 [app name] 은 앱 빌드시에 CLI 에서 물어보고 대답했던 그 이름입니다. 잘 기억해두셔야 합니다. (예: com.android…)
data → data → [app name] → files → SQLite → [db_name]
[db_name] 파일을 다운로드 받습니다.
그리고, 윈도우에서 [db_name] 파일을 열람하려면, DB Browser for SQLite 프로그램의 설치가 필요합니다.
여기서 DB Browser for SQLite 를 다운로드 받아 설치합니다.
이제 DB Browser for SQLite 을 실행하고, 다운로드 받은 DB 파일을 불러옵니다.
DB 의 내용이 이제 보이시나요? 눈 감은채 코끼리 더듬다가 갑자기 눈을 뜬 느낌이죠. 하지만, 불러온 DB 의 내용은 자동으로 업데이트 되지않습니다. DB에 변경이 발생하면 다시 다운로드 받고 다시 불러와야만 합니다.
.
Transaction
관계형 테이블을 관리하다보면 여러 테이블에 동시에 또는 순차적으로 새로운 Rows 가 insert 또는 update 되어야 하는 경우를 자주 만나게 됩니다. 우리의 music DB 역시 마찬가지죠. artists 테이블에 insert 할 때, tags 테이블에도 역시 새로운 데이터 항목들이 insert 됩니다.
이들 일련의 쿼리 수행 전체를 하나의 작업으로 묶어서 true or false 상태로 관리하는 것을 트랜잭션이라고 합니다. 즉, 전체 작업에 성공하던지, 아무 일도 안 일어나던지 둘 중 하나여야 하는 것이죠.
트랜잭션은, SQL 단에서 벌크 쿼리로도 처리해줄 수 있고,
// bulk transaction 작업은 BEGIN 으로 시작하고, COMMIT 으로 끝냅니다.
// Escape 가 되지 않으므로, 인젝션 공격에 취약합니다.
// 신뢰하는 컨트롤 상황이 아니라면 사용하지 마세요.
BEGIN;
UPDATE artists SET name = 'new_name' WHERE id= 1;
UPDATE tags SET tags = 'new_tag' WHERE id= 1;
COMMIT;
코드 단에서도 트랜잭션을 처리해줄 수 있습니다. 이 코드는 밑에서 다시 다루겠습니다.
.
Create DB
ANSI SQL 에서 DB 에 데이터를 저장하고 불러오기 위해서는,
DB 를 생성하고,
DB.Table 을 생성해야 합니다.
실제로 Data 가 보관되는 장소는 DB.Table 이고, DB 는 다수의 DB.Table 들이 모인 종합 저장소입니다.
따라서, DB, 즉 db_name 을 생성해주고, 그 다음으로 table_name 을 생성해준 이후에야 실제로 데이터를 저장하고 불러올 수 있습니다.
SQL 에서는 전통적으로 Create database $db_name
처럼 터미널에서 SQL 로 DB 를 직접 생성해 주는데, 우리의 작업환경에서 DB 를 생성하는 방식은 조금 다릅니다. 사실은 Create database 커맨드를 사용할 수 있는 공간이 마땅히 없죠.. 이 때문에 openDatabaseAsync()
또는 프로바이더 SQLiteProvider
가 사용됩니다.
OpenDatabaseAsync() / OpenDatabaseSync()
import * as SQLite from 'expo-sqlite';
// 'databaseName' 이름의 db 열기를 시도하고 없으면 새로 생성합니다.
const db = await SQLite.openDatabaseAsync('databaseName');
SQLiteProvider
// /app/_layout.tsx
import { SQLiteProvider } from 'expo-sqlite/next'
import { QueryClientProvider } from '@tanstack/react-query'
import { dbInit } from "@/db/init";
export default function Layout() {
return (
<SQLiteProvider databaseName="databaseName" onInit={dbInit}>
<QueryClientProvider client={queryClient}>
<Slot />
</QueryClientProvider>
</SQLiteProvider>
);
}
<SQLiteProvider>
컴포넌트는 expo-sqlite 에서 제공하는 컴포넌트로, 내부적으로 데이터베이스 생성 및 연결을 처리합니다. databaseName
속성값에 해당하는 DB 열기를 시도하고, 없으면 해당 DB 를 생성합니다. 그리고 스코프 내 컴포넌트에서,
const db = useSQLiteContext();
useSQLiteContext()
훅으로 db 인스턴스를 불러올 수 있습니다.
<SQLiteProvider>
의 쓰임새가 더 좋아보이죠? 우리 프로젝트에서는 주로 <SQLiteProvider>
를 사용하겠습니다.
.
Backup & Import existing database
외부의 Database 파일을 리소스로 SQLite 의 db 인스턴스를 생성할 수도 있습니다.
import { SQLiteProvider, useSQLiteContext } from 'expo-sqlite';
import { View, Text, StyleSheet } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<SQLiteProvider databaseName="test.db" assetSource={{ assetId: require('./assets/test.db') }}>
<Header />
<Content />
</SQLiteProvider>
</View>
);
}
Expo-SQLite 프로젝트 에서는 dump 같은 백업 툴이 없습니다. 백업 & 이전시, DB file 을 원형 그대로 사용해야 합니다. DB file 의 위치는 , 안드로이드 에뮬레이터의 버추얼 디바이스 - Device Explorer 에서 확인할 수 있습니다.
db file 의 경로는, 안드로이드 스튜디오를 열고, 우측 아이콘바에서 Device Explorer 를 클릭한 후,
data > data > <your-package-name> > files > SQLite
입니다.
만약, 앱에서 주기적인 자동 데이터 백업을 지원하도록 해주려면, expo-file-system
을 사용한 코드가 실행되도록 합니다.
// db file 백업 코드 예제
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
async function backupDatabase(dbName: string) {
try {
// 데이터베이스 파일 경로
const dbPath = `${FileSystem.documentDirectory}SQLite/${dbName}`;
// 백업 파일 경로 (같은 디렉터리에 "backup-" 접두어 추가)
const backupPath = `${FileSystem.documentDirectory}SQLite/backup-${dbName}`;
// 데이터베이스 파일 복사
await FileSystem.copyAsync({
from: dbPath,
to: backupPath,
});
console.log(`Database backed up to: ${backupPath}`);
// 파일 공유 (옵션)
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(backupPath);
}
} catch (error) {
console.error('Error backing up database:', error);
}
}
// 사용 예제
backupDatabase('test.db');
.
Create Table
위 <SQLiteProvider />
에서 잠깐 본, onInit={dbInit}
코드는, DB.Initialize 즉, DB 프로바이더가 처음 호출되는 시점에 트리거됩니다. 그리고, onInit
에서는 일반적으로 DB 의 스키마, 즉 테이블들을 생성, 업데이트 또는 점검합니다.
SQL 문에 익숙한 분들은 어려움이 없겠습니다만, 처음 만나보게 되는 분들도 너무 어려워하실 필요는 없습니다. SQL 은 직관적이어서 배우기 쉬울 뿐더러, AI 를 활용하기에 최적화된 영역이기 때문에 SQL 은 AI 에게 만들어달라고 시키면 어려울 게 없습니다.
간단한 dbInit 코드를 한번 보죠.
import { SQLiteDatabase } from 'expo-sqlite/next'
export async function dbInit(db: SQLiteDatabase) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS goals (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
total REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY NOT NULL,
goal_id INTEGER,
amount REAL NOT NULL,
created_at DEFAULT CURRENT_TIMESTAMP
);
`)
}
// dbInit 펑션은 SQLiteProvider 에서 파라메터 없이 호출되었지만,
// dbInit 펑션의 원형은 db 파라메터를 받도록 코딩되어 있습니다.
// SQLiteProvider는 내부적으로 데이터베이스 인스턴스를 생성하고,
// 이를 onInit 함수에 자동으로 전달합니다.
위는 예제로 만들어본 간단한 dbInit() 코드인데요. 그렇게 어렵진 않죠? 지금은 이해가 안되더라도 걱정할 필요는 없습니다.
.
앞으로 우리는 MusicBrainz 라는 오픈 API 에서 제공하는 Music DB Json 을 굽신굽신 얻어와서, 로컬에서 열람할 수 있는 Music DB 를 구축하는 것을 이번 장의 목표로 하겠습니다.
MusicBrainz 의 API 에,
https://musicbrainz.org/ws/2/artist?query=queen&fmt=json
이렇게 “queen” 을 질의해서 응답받은 Json 을, Claude 에게 주고 DBSchema SQL 을 만들어 달라고 해보았습니다. 지금은 이 스키마가 얼마나 효율적인지는 중요하지 않습니다. 수업의 진도를 빠르고 밀도 높게 진행하는 것이 더 중요하겠죠.
자, 이제 실제로 우리의 프로젝트에서 사용할 DB 의 테이블들을 모두 생성해주는 initialize 펑션을 만들어 보겠습니다.
PRAGMA journal_mode = WAL
은, read & write 방식에 관한 메타 옵션인데, Expo 공홈에서도 WAL 을 사용하라고 강력히 권장하고 있습니다. 기존 저널 방식보다 최소 10배 이상 빠르면서 트랜잭션이 빠릿해지고, 트랜잭션 작업이 진행되는 동안 DB 에 Lock 이 걸리지 않는다고 합니다. 아래의 나머지 PRAGMA 메타 옵션들은 직관적이라서 설명이 필요 없을 것 같습니다.
// /db/initdb.ts
import * as SQLite from 'expo-sqlite';
const initialize = async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(
`PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 3000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA encoding = 'UTF-8';
-- Artists table
CREATE TABLE artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_name TEXT,
type TEXT,
country TEXT,
disambiguation TEXT,
begin_date TEXT,
end_date TEXT
);
-- Releases table (albums)
CREATE TABLE releases (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT,
release_date TEXT,
country TEXT,
disambiguation TEXT,
packaging TEXT,
artist_id TEXT,
FOREIGN KEY(artist_id) REFERENCES artists(id)
);
-- Recordings table (tracks)
CREATE TABLE recordings (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
length INTEGER, -- duration in milliseconds
disambiguation TEXT,
artist_id TEXT,
FOREIGN KEY(artist_id) REFERENCES artists(id)
);
-- Release-Recording relationship table
CREATE TABLE release_recordings (
release_id TEXT,
recording_id TEXT,
track_position INTEGER,
disc_number INTEGER DEFAULT 1,
PRIMARY KEY (release_id, recording_id),
FOREIGN KEY(release_id) REFERENCES releases(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
-- Artist Credit Names table (for featuring artists)
CREATE TABLE artist_credits (
recording_id TEXT,
artist_id TEXT,
join_phrase TEXT,
name TEXT,
PRIMARY KEY (recording_id, artist_id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(artist_id) REFERENCES artists(id)
);
-- Tags table
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
-- Artist Tags relationship table
CREATE TABLE artist_tags (
artist_id TEXT,
tag_id INTEGER,
count INTEGER DEFAULT 1,
PRIMARY KEY (artist_id, tag_id),
FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(tag_id) REFERENCES tags(id)
);
-- Release Tags relationship table
CREATE TABLE release_tags (
release_id TEXT,
tag_id INTEGER,
count INTEGER DEFAULT 1,
PRIMARY KEY (release_id, tag_id),
FOREIGN KEY(release_id) REFERENCES releases(id),
FOREIGN KEY(tag_id) REFERENCES tags(id)
);
-- Recording Tags relationship table
CREATE TABLE recording_tags (
recording_id TEXT,
tag_id INTEGER,
count INTEGER DEFAULT 1,
PRIMARY KEY (recording_id, tag_id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(tag_id) REFERENCES tags(id)
);
-- Create indexes for better query performance
CREATE INDEX idx_artists_name ON artists(name);
CREATE INDEX idx_releases_title ON releases(title);
CREATE INDEX idx_recordings_title ON recordings(title);
CREATE INDEX idx_release_recordings_release ON release_recordings(release_id);
CREATE INDEX idx_release_recordings_recording ON release_recordings(recording_id);
`
);
}
export default initialize
이렇게, initialize()
펑션으로 총 9개의 테이블들이 생성됩니다.
artists
releases (앨범)
recordings (트랙)
release_recordings (관계 테이블)
artist_credits (for featuring artists)
tags (Tags)
artist_tags (Artist Tags 관계 테이블)
release_tags (Release Tags 관계 테이블)
recording_tags (Recording Tags 관계 테이블)
.
Basic CRUD Expo-SQLite Operations API
execAsync
벌크 쿼리를 실행할 때 사용합니다. 복수의 쿼리를 모두 한꺼번에 실행할 수 있으며 벌크모드 트랜젝션이 가능합니다.
runAsync
Write, Update, Delete 액션에서 사용합니다.
getFirstAsync
싱글 Row 를 하나 받아올 때 사용합니다. (예: 아티클 ID 쿼리)
getAllAsync
테이블의 모든 Rows 를 배열로 받아올 때 사용합니다.
cursor 가 사용됩니다. Database 에서 cursor 는 DB 내부적으로 관리되는 offset 이라고 생각하시면 되고, pagination 등에서 사용됩니다. cursor 는 순차적으로 다음 오프셋 위치로 이동해서 대기하게 됩니다.
getEachAsync
반복구문(for/ map) 내에서 Row 각 한개씩을 쿼리해야 할 때 사용합니다.
.
Parameter Binding
SQL 인젝션 공격을 당해본 사람이라면, 이게 얼마나 귀찮고 거머리같은 괴롭힘인지 기억할 것 같은데요.. 인젝션 변태들은 정말 집요하고 끝이 없습니다. 아마도 전세계 웹 트래픽 중에 적어도 10% 는 인젝션 공격이지 않을까 생각될 정돈데요.. 물론, 지금은 이런 공격에 노출된 서비스나 앱을 찾아보기 어렵죠.
그러니까 더더욱, 대비하고 준비하고 항상 생각하면서 작업합시다. 기본이죠. 확실히 인젝션 공격에 문제 없는 쿼리와 파라메터 바인딩 코드인가.. 라는 셀프 필터링을 언제나 하면서 작업합시다.
파라메터 값을 직접 SQL 에 포함시키는 방식은 인젝션 공격에 취약합니다. 인젝션 어서오고, 반면에, Expo-SQLite 에서 자체적으로 관리하고 있는 Sanitizing 로직에 살균 처리를 일임하고, 쿼리 원형에 파라메터를 병행해서 쿼리를 전달하는 방식을 파라메터 바인딩
이라고 합니다.
조금전에 배운 runAsync
평션은, SQL 쿼리를 db 인스턴스에 전달하고 즉각적인 쿼리 수행을 해주는 API 펑션입니다.
// ❌ 이렇게 사용하면 인젝션 공격에 당할 수 있습니다.
const aaa = useLocalSearchParams();
const result = await db.runAsync('INSERT INTO test (value, intValue) VALUES (aaa, 100)');
인젝션 공격에 맞서 앱과 자원을 방어하는 방법은, 스스로 새니타이징 하던지, DBMS-ORM 의 새니타이징 로직에 일임하던지 둘 중의 하나입니다. Expo-SQLite 는 꾸준히 업데이트 되면서 취약점을 보완하고 있으므로, DBMS 의 새니타이징 로직을 신뢰하는 쪽이 확실히 타당합니다.
?
자리에는, 파라메터가 순서대로 할당됩니다.파라메터에 네임을 할당하는 방식으로도 사용할 수 있습니다.
//
const result = await db.runAsync('INSERT INTO test (value, intValue) VALUES (?, ?)', 'aaa', 100);
await db.runAsync('UPDATE test SET intValue = ? WHERE value = ?', 999, name);
await db.runAsync('UPDATE test SET intValue = ? WHERE value = ?', [999, 'aaa']); // Binding unnamed parameters from array
await db.runAsync('DELETE FROM test WHERE value = $value', { $value: 'aaa' }); // Binding named parameters from object
주사맞고 소 잃고 후회말고, 파라메터 바인딩을 꼬박꼬박 사용하자.
.
Prepared Statements : 준비구문(쿼리 원형)
SQL 쿼리의 원형을 사전정의하고, 파라메터만 바꿔주면서 반복사용해야 할 때 사용됩니다. prepareAsync()
, prepareSync()
로 준비 하고, executeAsync()
, executeSync()
로 실행합니다. finalizeAsync()
, finalizeSync()
로 준비구문 작업을 해제합니다.
파라메터 바인딩 방식을 사용하므로, SQL Injection 위험이 줄어들고, expo-sqlite 자체에서 관리하는 새니타이징(살균) 로직에게 인젝션 위험관리를 일임합니다.
// 실행할 쿼리의 원형을 준비: prepareAsync
const statement = await db.prepareAsync(
'INSERT INTO test (value, intValue) VALUES ($value, $intValue)'
);
try {
// 쿼리 원형에 params 할당 & 실행 : executeAsync
let result = await statement.executeAsync({ $value: 'bbb', $intValue: 101 });
console.log('bbb and 101:', result.lastInsertRowId, result.changes);
result = await statement.executeAsync({ $value: 'ccc', $intValue: 102 });
console.log('ccc and 102:', result.lastInsertRowId, result.changes);
result = await statement.executeAsync({ $value: 'ddd', $intValue: 103 });
console.log('ddd and 103:', result.lastInsertRowId, result.changes);
// 쿼리 예약 구문 초기화, 리셋
await statement.finalizeAsync();
} catch(e) {
....
}
// intValue 의 값이 $intValue 보다 같거나 큰 모두를 쿼리하는 쿼리 원형
const statement2 = await db.prepareAsync('SELECT * FROM test WHERE intValue >= $intValue');
try {
// 쿼리 원형에 두개의 파라메터가 전달되고 실행됩니다: value, initValue
// 리턴되는 result 값은, 범위내의 모든 데이터 Rows 를 담은 배열이 됩니다.
const result = await statement2.executeAsync<{ value: string; intValue: number }>({
$intValue: 100,
});
// getFirstAsync() 로 result 의 첫번째 Row 를 받아옵니다.
const firstRow = await result.getFirstAsync();
console.log(firstRow.id, firstRow.value, firstRow.intValue);
// Reset the SQLite query cursor to the beginning for the next `getAllAsync()` call.
// cursor 를 다음 위치로 이동시킵니다.
// cursor 의 다음 위치는, getAllAsync() 에서 적용됩니다.
await result.resetAsync();
// `getAllAsync()` is useful when you want to get all results as an array of objects.
const allRows = await result.getAllAsync();
for (const row of allRows) {
console.log(row.value, row.intValue);
}
// Reset the SQLite query cursor to the beginning for the next `for-await-of` loop.
await result.resetAsync();
// The result object is also an async iterable. You can use it in `for-await-of` loop to iterate SQLite query cursor.
for await (const row of result) {
console.log(row.value, row.intValue);
}
} finally {
await statement2.finalizeAsync();
}
Prepared Statements 세 줄 요약:
const statement = await db.prepareAsync( ‘SQL with $Params’ )
await statement.executeAsync( { $Params } );
await statement.finalizeAsync();
.
Async Transactions
위에서 트랜잭션은, 일련의 DB 쿼리 작업들을 하나로 묶어서 “0” or “1” 로 처리하는 것이라고 배웠습니다. 그럼 실제로 트랜잭션의 코드가 어떻게 사용되는 지 보겠습니다.
// 트랜젝션 시작..
// 트랜젝션 스코프 이내의 db 작업은 모두 묶여서 하나의 결과값만을 가집니다.
// 만약, 하나의 db 작업이라도 실패한다면, 트랜잭션 실행 이전 상태로 롤백 됩니다.
db.withTransactionAsync(async () => {
// try 에 reject 가 하나라도 발생하면 전체가 실패
// withTransactionAsync 는 Async 작업 전용이고, 모두 async 작업이어야 합니다.
try {
await db.runAsync('INSERT INTO test (data) VALUES (?)', "first");
await sleep(2000);
const row = await db.getFirstAsync<{ data: string }>('SELECT data FROM test');
// 이 테스트 코드는 기대한 대로 성공할 것입니다.
expect(row.data).toBe('first'); // Jest test code
} catch (error) {
console.error('Error in transaction:', error);
throw error; // 트랜잭션 롤백을 위해 에러를 다시 던져서, 전체 트랜잭션이 실패하도록 합니다.
}
}).catch(error => {
console.error('Transaction Job failed:', error);
});
// .catch() 를 붙여줄 수 있습니다.
Async Transaction 세줄 요약
db.withTransactionAsync(async() => {...})
— 구문을 그냥 외웁시다.전체 트랜잭션 작업이 모두 성공하면, db 쿼리가 모두 성공하고, 다음 코드블럭으로 진행됩니다.
트랜잭션 스코프 내의 try-catch 에서 하나라도 실패시 throw error 해주면 전체 작업이 롤백됩니다. 롤백된 후 다음 코드블럭으로 진행됩니다. 캐치된 에러는 에러관리 콘솔과 로그에 전달됩니다.
.
그리고, 한가지 주의하셔야 할 점은, 트랜젝션이 가동되고 있는 동안에는 트랜젝션 스코프 밖의 쿼리에도 트랜젝션이 적용될 수 있다는 것입니다. db 인스턴스 자체가 트랜젝션 프로세스로 인스턴스 되어있고, Async 펑션의 특성도 함께 간섭할 수 있기 때문입니다.
참고: https://docs.expo.dev/versions/latest/sdk/sqlite/#executing-queries-within-an-async-transaction
// Promise.all 에서는 순서가 없습니다.
Promise.all([
// 1. 트랜젝션 시작
db.withTransactionAsync(async () => {
// 2. test 테이블에 "first" 가 입력됩니다.
await db.execAsync('INSERT INTO test (data) VALUES (?)', "first");
// 2초간 정지
await sleep(2000);
// 4. 2초 후, test 테이블의 값 읽기 시도를 합니다.
const row = await db.getFirstAsync<{ data: string }>('SELECT data FROM test');
// ❌ 이 테스트코드는 실패할 것입니다.
// 트랜젝션에 간섭이 발생하여서, 트랜젝션 스코프 바깥의 코드가 트랜젝션에 포함되었습니다.
expect(row.data).toBe('first');
}),
// 3. 1초 대기후 test 테이블의 값을, "second" 로 업데이트 합니다.
// 이 쿼리는 트랜젝션 스코프 바깥에 있지만,
// db 인스턴스는 여전히 트랜젝션으로 간주됩니다. 2번 프로세스가 아직 완료되지 않았기 때문입니다.
sleep(1000).then(async () => db.execAsync('UPDATE test SET data = ?', "second")),
]);
어찌 좀 복잡한 예외상황이 예로 나왔는데, 스코프와 비동기 펑션이 서로 간섭하면서 예상치 못한 골때리는 상황이 발생할 수도 있다.. 라는 정도로만 이해하고 넘어가죠.
.
여기까지, Expo-SQLite 에서 사용되는 중요한 펑션과 컨셉들을 살펴봤습니다. 이들 펑션과 컨셉은, 실제로 코딩 작업을 진행하면서 스스로 익숙해져야 조금씩 더 명료해지게 될 것입니다.
그리고, Expo SDK 50 버전에서 현재 버전, 52 버전으로 오면서 이들 API 에는 매우 많은 변화가 있었습니다. 이처럼 변화가 빠르게 일어나고 사용자 풀이 작은 영역에서는 StackOverflow 가 오히려 방해가 될 수도 있습니다. 이 좁은 영역에서 그들의 솔루션은 이미 낡아있고, AI 의 의견들도 몹시 낡아있고 허둥대기도 합니다. 그렇기 때문에, StackOverflow 에서 머무는 시간이 길어질수록 문제해결에 소요되는 시간이 더 길어질 수 있습니다. Expo 52 버전은 2024.11 에 릴리즈 되었습니다. 그리고 많은 API 의 사용방식이 큰 폭으로 바뀌었습니다. 릴리즈 데이트를 참고하면서 여러 솔루션들을 조심스럽게 보셔야 할 것 같습니다. 되도록이면 공홈의 문서를 마르고 닳도록 읽읍시다.
.
Cheatsheet for the common API
Expo 공홈에서 긁어왔습니다. 컨닝페이퍼 처럼 정리해 놓은 걸 칫시트라고 하는데, expo-sqlite 의 주요 메서드들을 정리한 칫시트 입니다. 참고하셔서, 필요한 곳에서 적절한 메서드를 사용하시면 되겠습니다.
** | |||
|
| Executes a SQL query, returning information on the changes made. | Ideal for SQL write operations such as |
|
| Retrieves the first row from the query result. | Suitable for fetching a single row from the database. For example: |
|
| Fetches all query results at once. | Best suited for scenarios with smaller result sets, such as queries with a LIMIT clause, like |
|
| Provides an iterator for result set traversal. This method fetches one row at a time from the database, potentially reducing memory usage compared to | Recommended for handling large result sets incrementally, such as with infinite scrolling implementations. |
.
.
SQLite Part 4
Database Version Management
DB 의 초기 Init 이후에도, 테이블 추가, 컬럼 추가, 인덱스 추가등, 스키마의 변경요인은 언제든 발생하게 됩니다. DB 에 데이터가 쌓이고 개선해야 할 문제점들이 나타나기 시작하면, 스키마의 추가/ 변경이 불가피해지는데요... DB 의 버전을 관리하는 방법에 대해 알아봅시다.
네이티브 앱에서는, 비교적 간단한 문제점들을 개선한 코드를 Over The Air Update -OTA- 업데이트 방식으로 추가하고 개선할 수 있습니다. OTA 업데이트는 우리의 RN 프로젝트 마지막 장에서 한번 다뤄보기로 하고, 오늘은 OTA 업데이트와 함께 작동할, dbInit 펑션에서 조금 더 업그레이드된, upgradeDbIfNeeded() 펑션을 만들어보죠.
DB 의 버전 관리는 꽤나 복잡하고 난이도 높은 관문으로 여겨질 때가 많은데, 사실 그 로직을 정확히 잡아내기가 상당히 난해하기 때문입니다. 예를 들어서, 3월에는 테이블을 두개 추가해줬고, 7월에는 인덱스를 하나 추가하고 두개의 인덱스를 지웠고, 10월에는 테이블에 컬럼을 추가해줬다고 할 때, 1월에 접속한 이후로 접속이 없던 사용자가 12월에 다시 앱에 접속했다면, 이 사용자에게 어떤 스키마를 적용해줘야 할까요? 그리고 DB 는, 서버가 아니라 사용자 디바이스에 존재합니다.
명료한 로직이 없다면 대답을 내놓기가 어려울 수 밖에 없습니다.
DB 버전관리의 요체는, DB 에 버전관리 기능을 추가해서, 요청되는 버전이 현재의 db 버전보다 상위 버전이라면, 해당 버젼에서 변경될 스키마를 DB 에 추가해주는 것입니다. 즉, 스키마를 레이어처럼 차곡차곡 차례차례 덧붙여 주는 것입니다.
먼저 전체적인 작업의 흐름을 단순한 사례를 통해 충분히 이해해본 후에, 다음으로 마이그레이션 작업의 완결성을 보강해주는 작업으로 이어가 보겠습니다.
원활하고 직관적인 유지보수를 위해서, /db/versions 폴더를 따로 두어 DB 의 버전관리를 해줍시다.
📦src
┣ 📂app
┣ 📂assets
┣ 📂components
┣ 📂constants
┃ ┗ 📜db.ts
┣ 📂contexts
┣ 📂db
┃ ┣ 📂versions
┃ ┃ ┣ 📜initdb.ts
┃ ┃ ┗ 📜version01.ts
┃ ┃ ┗ 📜version02.ts
┃ ┣ 📜index.ts
┃ ┣ 📜upgradeDbIfNeeded.ts
┣ 📂hooks
┣ 📂libs
┣ 📂styles
┣ 📂types
┗ 📂utils
/src/db/versions/initdb.ts
파일은, 우리가 이미 앞에서 작업해 놓은 initialize()
펑션을 export 하고 있습니다.
그럼 이제, db 스키마의 변경 요인이 발생했다고 가정해보겠습니다. 즉 DB 의 버전이 up 되는 상황인거죠.
먼저, 추가할 스키마의 코드를, /src/db/versions/version01.ts
에 작성해주고,
// /src/db/versions/version01.ts
import * as SQLite from 'expo-sqlite';
const upgradeDBver01 = async (db: SQLite.SQLiteDatabase) => {
{/* ... add some shcemas ... */}
await db.execAsync(`
CREATE TABLE IF NOT EXISTS ver_01 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
field text
)
`);
}
export default upgradeDBver01
그리고, 앱의 상수를 관리하는 /src/constants/db.ts
에서, 변경될 db 의 버전을 선언해 줍니다.
초기상태의 SQLite db 의 version 은 0 부터 시작합니다. 따라서, 스키마를 추가하는 단계에서 요청하는, 업그레이드 될, DB 의 버전은 1 이 됩니다.
/*
/db/versions/version**.ts 에 업데이트 스키마 작업을 해준 뒤,
DECLARED_DB_VERSION 를 카운트업 해줍니다.
*/
export const DB_NAME = "HOERZU";
export const DECLARED_DB_VERSION=1;
그리고, db 의 현재 버전과 선언된 버전을 비교하고, 필요한 스키마를 onInit 콜백 펑션에 바인딩 해주는 upgradeDbIfNeeded()
펑션을 추가합니다.
// /src/db/upgradeDbIfNeeded.ts
// 개발자가 선언한, 적용되어야 할 DB 버전
import { DECLARED_DB_VERSION } from '@/constants/db';
import { version01 } from '@/db/versions/version01';
import { version02 } from '@/db/versions/version02';
// 버전관리가 되는 스키마 업데이트 펑션입니다.
// 초기상태에서 DB 버전은 0 이고, 버전 밸류는 'PRAGMA user_version' 에서 참조합니다.
// 'PRAGMA user_version' 는 SQLite DB 의 메타 데이터의 하나라고 생각하시면 되겠습니다.
async function upgradeDbIfNeeded(db: SQLiteDatabase) {
// DB 의 메타데이터로 가져온 현재까지의 DB 버전
let { user_version: currentDbVersion } = await db.getFirstAsync<{ user_version: number }>(
'PRAGMA user_version'
);
// 초기 Init
if (currentDbVersion === 0) {
console.log('Initializing database');
initialize(db);
}
// 할 거 없으면 그냥 돌아가자.
if (currentDbVersion >= DECLARED_DB_VERSION ) {
return;
}
// DECLARED_DB_VERSION 버전이 1 일 때..
if (currentDbVersion === 0) {
await version01();
// 다음 버전 처리를 위해 커런트 버전 값을 카운트 업
currentDbVersion++
}
// 다음 버전에서 추가되는 스키마를 버전별로 차례로 추가해줍니다.
// if (currentDbVersion==1) {
// await version02();
// currentDbVersion++
// }
// db 의 메타 데이터 버전 정보를 업데이트
await db.execAsync(`PRAGMA user_version = ${ DECLARED_DB_VERSION }`);
}
// DB 의 버전 관리가 매우 간결해졌고 한눈에 잘 들어옵니다. 만족스럽습니다.
버전이 누적되면서 차례차례 한 단계, 한 버전씩 스키마가 추가되는 원리입니다.
이러한 버전관리 방식은, SQLite 뿐만 아니라 IndexedDB 등, Local First App 영역에서 같은 로직으로, 중요한 역할을 하며 자주 사용되고 있습니다. 기회가 왔을 때 잘 익혀둡시다.
.
여기까지는 이해가 어렵진 않았으리라고 생각됩니다. DECLARED_DB_VERSION
이라는 버전관리 상수를 두고, 각 버전에 따라 스키마 업그레이드 펑션을 관리하고 있습니다. 만약 DECLARED_DB_VERSION
의 값이 DB 의 메타 데이터 에 저장된 버전 정보보다 높을 때에는 각 버전에 대해 할당된 펑션들을 차례로 불러와서 실행시키는, 알고보면 매우 간단한 구조의 버전관리 로직입니다. 그리고, upgradeDbIfNeeded
펑션은, SQLite 프로바이더인 <SQLiteProvifer>
의 콜백 펑션으로 인보크 됩니다. 코드의 흐름과 구조를 이해하기가 그다지 어렵진 않습니다.
.
Structured Version Management
그러나 지금까지의 upgradeDbIfNeeded
펑션 만으로는 많이 부족합니다. DECLARED_DB_VERSION
이라는 번거로운 상수가 추가되어서, 버전관리가 참조하는 리소스가 단일 진실 공급원 원칙에도 위배되고 있습니다. 또한, 전체 마이그레이션 과정의 완결성을 보장하지 못하고 있으며, 에러 처리와 처리기록을 확인할 수 있는 코드도 빠져 있습니다. 그리고, 아무리 꼼꼼한 관리자일지라도, 언젠가는 상수 관리를 깜빡 잊거나 잘못 업데이트한 채, 재앙을 불러올 컴파일을 실행하게 될지도 모릅니다.
이제 우리의 버전 관리 알고리즘을 좀 더 구조화 해보죠. 조금은 까다롭더라도, 오늘 한번 만들어둔 버전 관리 알고리즘이, 앞으로 두고두고 우리 앱의 안정성을 높여주고, 또 우리의 작업 생산성을 높여주게 될지도 모릅니다.
.
새로운 파일, migrateFunctions.ts 를 생성합니다.
// /src/db/migrateFunctions.ts
/*
DB 업그레이드 펑션들을 지정합니다.
업그레이드 펑션 이름을 배열에 추가하고 버전을 지정합니다.
지정된 펑션은, upgradeDbIfNeeded.ts 에서 순차적으로 실행됩니다.
DB 스키마 업그레이드가 필요할 때 해야할 일들 :
1. /db/versions 폴더에 새로운 버전의 스키마 업데이트 펑션 파일을 생성합니다.
2. /db/migrateFunctions.ts - const migrations 배열에 추가한 버전과 펑션을 추가합니다.
--끗--
*/
import { SQLiteDatabase } from "expo-sqlite";
import upgradeDBver01 from "@/db/versions/version01";
type MigrationFn = (db: SQLiteDatabase) => Promise<void>;
type MigrationType = {
version: number;
migrate: MigrationFn;
}
//////////////////////////////////////////////////////////////////////
// 스키마 업그레이드용 펑션, 마이그레이션 펑션과 버전 숫자를 하나씩 할당해줍니다.
// 버전과, 펑션 네임을 페어로 할당해줍니다.
export const migrations: MigrationType[] = [
{ version: 1, migrate: upgradeDBver01 },
// { version: 2, migrate: version02 },
];
//////////////////////////////////////////////////////////////////////
// 최신 버전은 migrations 배열에서 자동으로 계산, export 된다.
// 이제, LATEST_DB_VERSION 이 사용되고, DECLARED_DB_VERSION 는 폐기됩니다.
export const LATEST_DB_VERSION = migrations.length > 0
? Math.max(...migrations.map(m => m.version))
: 0;
export type MigrationLog = {
version: number;
executed_at: string;
success: boolean;
}
// 마이그레이션 기록을 db 에 보관합니다.
export const logMigration = async (db: SQLiteDatabase, version: number, success: boolean) => {
// 신뢰하는 쿼리이므로, 파라메터 바인딩은 필요 없습니다.
await db.execAsync(`
CREATE TABLE IF NOT EXISTS migration_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER,
executed_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
success BOOLEAN
)
`);
// runAsync
await db.runAsync(`
INSERT INTO migration_logs (version, success)
VALUES (?, ?)
`, [version, success]);
};
이제, upgradeDbIfNeeded**()
** 펑션의 작업이 보다 단순화 , 구조화 되며, 에러 핸들링과 로깅을 추가할 수 있습니다.
/*
SQLite DB 버전 관리 Init Function
업그레이드가 필요한 경우에 해당 버전 function 을 실행합니다.
업그레이드 펑션은 migrateFunctions.ts 에서 관리됩니다.
이제 버전 1,000 까지도 너끈히 감당할 수 있게 되었습니다.
*/
import { SQLiteDatabase } from "expo-sqlite"
import { migrations, logMigration, LATEST_DB_VERSION } from "./migrateFunctions";
import initialize from './versions/initdb';
const upgradeDbIfNeeded = async (db: SQLiteDatabase) => {
try {
// 버전 오브젝트의 유효성을 검증
validateVersions();
let { user_version: currentDbVersion } = (await db.getFirstAsync<{ user_version: number }>(
'PRAGMA user_version'
))!;
// 초기 Init 이 누락되었다.
if (currentDbVersion === 0) {
console.log('Initializing database');
initialize(db);
}
if (currentDbVersion >= LATEST_DB_VERSION) {
return;
}
await db.withTransactionAsync(async () => {
for (const migration of migrations) {
if (migration.version > currentDbVersion) {
console.log(`Migrating to version ${migration.version}`);
try {
await migration.migrate(db);
await logMigration(db, migration.version, true);
await db.execAsync(`PRAGMA user_version = ${migration.version}`);
currentDbVersion = migration.version;
} catch (error) {
await logMigration(db, migration.version, false);
throw error;
}
}
}
});
} catch (error) {
console.error('Database migration failed:', error);
throw new Error(`Failed to upgrade database: ${error instanceof Error ? error.message : String(error)}`);
}
}
export default upgradeDbIfNeeded;
// 버전의 유효성을 검증합니다.
const validateVersions = () => {
if (migrations.length === 0) return;
// 버전 순서 확인
const versions = migrations.map(m => m.version);
const sorted = [...versions].sort((a, b) => a - b);
if (!versions.every((v, i) => v === sorted[i])) {
throw new Error('Migrations must be ordered by version');
}
// 버전 연속성 확인
if (versions[0] !== 1) {
throw new Error('Migrations must start from version 1');
}
for (let i = 1; i < versions.length; i++) {
if (versions[i] !== versions[i-1] + 1) {
throw new Error('Migration versions must be consecutive');
}
}
}
수작업으로 관리해주던 상수 DECLARED_DB_VERSION
가 제거되었고, 모든 과정이 구조화, 자동화 되었습니다. 앞으로의 버전관리는, 딱 두가지 작업만 해주면 됩니다.
/db/versions/version[*n].ts 파일에, 스키마 업데이트 펑션을 생성하고,
migrations 어레이에, 버전 넘버와 펑션명을 오브젝트로 할당해주면 됩니다.
이것으로 버전관리가 -끗- 입니다. 슈퍼 울트라 하이퍼 그레이트 파워풀하지 않습니까?
이정도라면 업그레이드, 마이그레이션 작업을 안심하고 맡겨도 될 것 같습니다. 적어도 한가지 문제는 앞으로 오랫동안 for decades 신경쓰지 않아도 될 것 같군요.
.
Suspense for Migration
앱이 오랜 시간동안 유지보수 되어왔고, 또 버전관리 역시 시간이 경과하면서 누적되면, 업그레이드와 스키마 젹용에 꽤 긴 시간이 소요될 수도 있습니다. 데이터 Rows 가 많아질 수록, 인덱싱이 필요할 수록, 마이그레이션에 소요되는 시간은 더 길어질 수 있겠죠.
업그레이드가 진행되는 동안 Suspense 로 사용자에게 현재 상황을 표시해줘야 할 필요가 있습니다.
이럴 땐, <SQLiteProvider >
를 <Suspense>
로 감싸주고, useSuspense
프롭을 추가해줍니다.
Suspense 는 우리의 React 과정에서 충분히 고통스럽게 학습했죠. 고통이 기억 안나거나 심화 학습이 필요한 분들은 React #6 으로 가서 복습합시다.
// /src/app/_layout.tsx
export default function App() {
const [avatar, setAvatar] = useState({ uri: "" });
const [noticeCnt, setNoticeCnt] = useState(0);
const { colorScheme, setColorScheme, toggleColorScheme } = useColorScheme();
// Async Data Fetching 을 나중에 추가...
useEffect(() => {
setAvatar({ uri: avatarObj.uri });
setNoticeCnt(1);
}, []);
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar animated style={colorScheme === "dark" ? "light" : "dark"} />
<Suspense fallback={<MigrationFallback />}>
<SQLiteProvider databaseName={DB_NAME} onInit={initDatabase} useSuspense>
<QueryClientProvider client={queryClient}>
<RootStackLayout
avatar={avatar}
noticeCnt={noticeCnt}
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
/>
</QueryClientProvider>
</SQLiteProvider>
</Suspense>
</GestureHandlerRootView>
</SafeAreaProvider>
);
}
자, 이렇게 RN-Expo-SQLite 프로젝트에서의 복잡한 DB 버전관리를 단순화하였고, 언제든지 업그레이드 해줄 수 있도록 준비를 마쳤습니다.
.
.
.
DB I/O Functions
.
DB.IO 라이브러리
우리가 만들고자 하는 앱은, Private Music DB 입니다. 총 9개의 테이블이 생성되었지만, 모든 테이블에서 CRUD 쿼리를 각각 생성해줘야할 필요는 없습니다.
그리고 이건, 앱 설계와 개발 방법의 선호도 영역이긴 한데, 저는 앱에서 사용되는 모든 SQL Query 들을 각각의 독립된 펑션으로 만들어서 사용하는 방식을 선호합니다. 이 방식은 변화와 업데이트에 가볍고 빠르게 대응할 수 있다는 장점이 있습니다. 컴포넌트 코드에서는 SQL 쿼리를 되도록 사용하지 않습니다.
각각의 테이블에 펑션 모듈 파일을 할당해주고 하나씩 작업해 봅시다.
먼저 테이블 artists
를 담당하는 SQL 처리 모듈을 보겠습니다.
// /db/repositories/artistsRepository.ts
import { SQLiteDatabase, useSQLiteContext } from "expo-sqlite";
export const useArtistsRepository = (db: SQLiteDatabase) => {
async function totalCnt() {
try {
const row = await db.getFirstAsync(`
SELECT COUNT(*) AS total FROM artists;
`);
console.log("total cnt: ", row);
if (row && typeof row === "object" && "total" in row) {
return row.total as number;
}
return 0;
} catch (e) {
console.error(e);
return 0;
}
}
function selectRange(start: number, end: number) {
// 아직 준비중입니다.
}
async function selectById(id: string) {
try {
const result = await db.getFirstAsync(
`
SELECT * FROM artists
WHERE id = ?
`,
[id]
);
return result;
} catch (e) {
console.error(e);
throw e;
}
}
async function insert(artist: any) {
try {
const statement = await db.prepareAsync(`
INSERT INTO artists (id, name, country, type, disambiguation, begin_date, end_date)
VALUES ($id, $name, $country, $type, $disambiguation, $begin_date, $end_date);
`);
await statement.executeAsync({
$id: artist.id,
$name: artist.name,
$country: artist.country,
$type: artist.type,
$disambiguation: artist.disambiguation,
$begin_date: artist["life-span"]?.begin,
$end_date: artist["life-span"]?.end,
});
await statement.finalizeAsync();
console.log("artist insert query completed");
} catch (e) {
console.error(e);
throw e;
}
}
async function deleteById(id: string) {
try {
await db.runAsync(
`
DELETE FROM artists
WHERE id = ?
`,
[id]
);
} catch (e) {
console.error(e);
throw e;
}
}
return { totalCnt, selectRange, selectById, insert, deleteById };
};
**selectRange 펑션은 아직 미완입니다. artists.id 는 UUID 이기 때문에 between 이 의미있는 range 를 리턴하지 못합니다. 어차피 Join 으로 Range 를 해결해야 할 것 같은데, 오늘은 다음으로 미루고 일단 전체적인 그림을 조망하는 정도로만 이해하고 넘어갑시다.
.
Tanstack Custom Hooks
useArtistsRepository 의 내장 메서드는 모두 SQL 을 처리하는 펑션이고, 모두 Async API 함수가 사용되었습니다. 디바이스에 내장된 SQLite 의 응답속도가 현재로서는 충분히 빠르겠지만, 데이터가 쌓이면서 응답에 시간이 소요될 수도 있습니다. 이들 펑션을 Tanstack 으로 감싸서 pending, error 스테이트도 확보할 수 있도록 해주겠습니다.
// /hooks/useArtists.ts
import { useArtistsRepository } from "@/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSQLiteContext } from "expo-sqlite";
export function useArtistsGetTotal() {
const db = useSQLiteContext();
const artistsRepo = useArtistsRepository(db);
const { isPending, error, data } = useQuery({
queryKey: ["total"],
queryFn: artistsRepo.totalCnt,
staleTime: 0,
// cacheTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
return { isPending, error, data };
}
export function useArtistsGetRange(start: number, end: number) {
const db = useSQLiteContext();
const artistsRepo = useArtistsRepository(db);
const { isPending, error, data } = useQuery({
queryKey: ["range", start, end],
queryFn: () => artistsRepo.selectRange(start, end),
});
return { isPending, error, data };
}
export function useArtistsGetById(id: string) {
const db = useSQLiteContext();
const artistsRepo = useArtistsRepository(db);
const { isPending, error, data } = useQuery({
queryKey: ["id", id],
queryFn: () => artistsRepo.selectById(id),
});
return { isPending, error, data };
}
export function useAddArtist() {
const db = useSQLiteContext();
const artistsRepo = useArtistsRepository(db);
const queryClient = useQueryClient();
const { isPending, mutate } = useMutation({
mutationFn: (artist: {
id: string;
name: string;
country: string;
type: string;
disambiguation: string;
"life-span"?: {
begin?: string;
end?: string;
};
}) => artistsRepo.insert(artist),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["total"] });
},
});
return { isAdding: isPending, addArtist: mutate };
}
export function useDeleteArtist() {
const db = useSQLiteContext();
const artistsRepo = useArtistsRepository(db);
const queryClient = useQueryClient();
const { isPending, mutate } = useMutation({
mutationFn: (artistId: string) => artistsRepo.deleteById(artistId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["total"] });
},
});
return { isDeleting: isPending, deleteArtist: mutate };
}
Insert 와 Delete 에는 useQuery 대신 useMutation 이 사용되었습니다. artists count 에 영향을 끼치는 액션이므로 이해하기 어렵지 않습니다.
.
.
이제 이들 펑션을 컴포넌트에서 사용해보죠.
// /src/app/(tabs)/index.tsx
import { View, Text } from 'react-native'
import React, { useEffect } from 'react'
import { useArtistsGetTotal, useTracksGetTotal } from '@/hooks'
const index = () => {
const { isPending:artistsPending, error:artistsError, data:artistsCnt } = useArtistsGetTotal()
return (
<View className='flex justify-center align-middle mt-40'>
{
artistsError ?
<Text>Error: {artistsError.message}</Text>
:
artistsPending ?
<Text>Loading...</Text>
:
<View className='flex flex-row '>
<Text className='p-8 text-4xl '>
{artistsCnt}
</Text>
<Text className='p-8 text-2xl'>favorite artists</Text>
</View>
}
<Text className='p-8'>
<Text className='p-8 text-4xl '>
{ tracksCnt }
</Text>
<Text className='p-8 text-2xl'>unsortable tracks</Text>
</Text>
</View>
)
}
export default index
DB.Query 펑션과 Tanstack 펑션이 2중으로 사용되고 있지만, 이 방식의 장점은, 무엇보다 견고하다는 것입니다. 먼 미래에 SQLite DB 의 응답속도가 늦어지게 되더라도, 이미 그에 대한 준비가 되어 있습니다. 그리고 쿼리가 추가 되거나 변경될 때에는, /db/* 의 해당 모듈만 수정해주면 됩니다. Single Source of True 원칙에 충실하죠. 하지만, 파일 모듈이 복잡해진다는 단점도 있습니다.
다른 테이블들에 대해서도 모두 같은 작업을 해주었습니다. 해당 파일들은 깃헙에 올려져 있습니다. 하단의 깃헙 링크를 참고해주세요.
.
.
.