목차
ORM
ORM
Drizzle
Install dependencies & Configure
Prepare config file
Prepare database schema
drizzle-zod
Migration
Upgrade Project w. Drizzle
Select count
Select List
Select Article
Join Tables
Null handling in Join Query
Insert into Table
Transaction
useLiveQuery
useDrizzleStudio
drizzle 총평
Universal React App
Universal React 는 어떻게 구현되는가
React Server Component in Expo
React Server Component
RSC in Expo
WebView in Stone Age
Expo Dom Component
Dom Component
use client
use server
server action
use dom
How to use RSC in Expo App
“server-only” in Expo
Expo EAS
Platform specific extensions
Expo React Universal
EAS Authentication
EAS Build
Form Handling in RN
Multi-Step Form
Zustand 상태 관리
Zustand Persist
expo-zustand-persist
Zod Schema
Form Components
Project App Upgrade
프로젝트 과제
작업 결과
지난 장에서는 긴 시간을 할애해서 Expo-SQLite 의 기본 API 를 사용한 SQLite I/O 처리 코드를 만들어봤습니다. 지난 장의 코드도, 그 자체로는 나름 흠잡을 데가 없고, 또 의도한 바를 부족함 없이 수행해주고 있었습니다. 그러나, 욕심을 내보면 부족함이 보이죠. 특히, Type Safety 관점에서는 아쉬움이 남고, 타입 관리가 일원화되지 않은 면면들이 보입니다.
이번 장의 주제로 들어가기에 앞서서, 먼저 ORM 을 알아보고, 우리 프로젝트를 ORM 을 사용하는 코드로 업그레이드 해보겠습니다.
RN 을 배우기 위한 프로젝트에서 ORM 은, 조금 곁가지로 빠진다는 느낌이 없지 않습니다. 본인에게 굳이 ORM 까지는 필요없다고 생각된다면 ORM 과 Drizzle 파트는 스킵하셔도 괜찮습니다.
ORM
지난 장에서 우리는 SQLite 의 SQL 문을 직접 작성했고, expo-sqlite 에서 제공하는 api 펑션으로 SQL 문을 SQLite 에게 전달한다는 베이스 컨셉으로 작업했었죠. 이 방식에도 특별히 문제는 없습니다만, 이를 좀 더 업그레이드 해보겠습니다.
Type Safety 가 빠진 코드는 앙꼬 없는 찐빵이죠. 지난 장에서 우리가 작업했던 코드로는, 데이터 원형에 대해 Type Safety 를 확보할 수 없었습니다. 데이터의 Type 원형은 DB.Table 에 뿌리를 두고 있음에도 불구하고, 외부에서 Type 을 관리해주고 export 해주는 엑스트라 모듈이 필요했습니다.
자, 이제 ORM 에 대해서 알아보죠.
.
ORM
Object-Relational Mapping — 객체-관계 매핑 — 은, 프로그래밍 랭귀지의 객체-object 와 데이터베이스의 테이블-Table 을 연결(매핑) 하여, db 작업을 우리 랭귀지의 객체관리로 처리할 수 있게 해주는 도구입니다. 특히 JS 진영에서는, 잘 알려진 DBMS 와 JS 오브젝트 간의 매핑이 지원되고, 또 DB 스키마로부터 타입을 추론해서, DB Table 의 데이터타입과 컬럼들을 VSCode, Cursor 의 타입 자동완성-Type Safety- 과 ESLint 개발환경으로 지원할 수 있도록 해줍니다. ORM 을 꼭 사용해야만 하는 가장 중요한 이유 중 하나가 바로 타입 안정성 — Type Safety 입니다.
단정적으로 말하자면, 아래와 같은 코드를 사용할 수 있게 하는 것이 ORM 을 사용하는 목적입니다.
// ORM 은, Type Safety 가 엄격하게 적용되므로, 각 항목의 데이터 타입이 검증됩니다.
// 예를 들어, name 속성에 number | undefined 타입이 할당된다면 즉시 타입에러를 발생시킵니다.
await db.insert(users)
.values({ id: 1, name: 'John' })
.onConflictDoNothing({ target: users.id });
‘나에게 굳이 이런 것 까지는 필요 없어. 빠르게 태스크를 구현하는게 더 중요할 뿐이지’ …라고 생각하는 분들이 더 많을 거라는 걸 잘 압니다. 하지만, 일단 ORM 환경이 만들어진 후에는, 개발작업이 매우 쉽고 탄탄해집니다. 특히 프로젝트의 규모가 커질 수록, Type Safety, Type Guard 의 필요성도 커지죠. ORM 환경에서는 타입 관리를 더이상 신경쓸 필요가 없어지고, 촘촘한 자동완성으로 오류를 발생시킬 위험을 획기적으로 줄일 수 있습니다. 타입스크립트 프로젝트에서 ORM 은 빠져선 안될 기술 스택입니다.
.
Supabase, Firebase 등의 잘 알려진 BaaS 는, 자체적으로 개발한 ORM 패키지를 배포하고 있습니다. BaaS 를 사용해야 하는 프로젝트에서는 해당 BaaS 의 ORM 패키지를 사용하면 되겠지만, 그렇지 못한 경우도 있기 마련이죠. 로컬 디바이스에서 SQLite 를 사용해야 하는 RN 프로젝트에서는, 잘 알려진 ORM 중 하나를 사용해야 합니다.
Node 진영에서 현재 잘 나가는 ORM 패키지로는 Prisma 와 Drizzle 이 있습니다. 두 ORM 간에는 마치, BootStrap - Tailwind 비슷한, 약간의 온도차와 레벨차이가 존재하는데요... BootStrap 보다는 Tailwind 가 훨씬 더 로우-레벨 이면서 보다 유연하고 구조적인 디자인 시스템의 설계와 관리가 가능한 것 처럼, ORM 에서는 drizzle 이 prisma 보다 훨씬 더 구조적이고 로우-레벨입니다. drizzle 은 초기 세팅 과정과 사용방법이 조금 더 복잡하지만, 결과적으로는 모든 면에서 prisma 를 압도합니다. 무엇보다도, 퍼포먼스와 스피드면에서 drizzle 이 prisma 보다 훨씬 뛰어난 성능을 보여준다 는 측정결과가 많이 보여지고 있습니다. 이러한 측정결과가 나올 수 밖에 없는 배경에는, drizzle 이 싱글 쿼리로 DB 요청을 처리하는 반면에, prisma 는 내부적으로 여러개의 쿼리로 DB 요청 을 발생시키고 이를 취합 처리하는데에 그 원인이 있다고 알려져 있습니다.
벤치마크:
https://orm.drizzle.team/benchmarks
ORM 네 줄 요약:
ORM 이란, 우리 코드의 객체와 DB.Table 을 매핑해주고, 코드 객체로 DB.Table 을 관리할 수 있게 해주는 툴이다.
ORM 은, Type Safety 와 개발자 경험 DX 을 위한 툴이다. 꼭 필요로 하지는 않습니다.
prisma 는 사용하기 쉽고 직관적이지만 퍼포먼스와 확장성에서 뒤쳐진다.
drizzle 은 스키마 세팅이 복잡하지만, 빠르며 확장성 있다.
.
우리는 drizzle 을 사용하겠습니다.
.
.
Drizzle
Official document:
https://orm.drizzle.team/docs/overview
Expo-SQLite:
https://orm.drizzle.team/docs/get-started/expo-new
.
drizzle 을 사용해온 개발자들 사이에서, drizzle 의 역할은 다음 세가지라는 인식이 받아들여지고 있는 것 같습니다.
DB Schema management
DB Migrations management
Type Safety API
이정도의 기대치를 갖고 drizzle 을 시작하는 것이 좋겠습니다.
.
공홈의 안내에 따르면, 다음 단계의 작업이 필요합니다.
install dependencies
prepare config file
Prepare database schema
그리고 마이그레이션 작업도 필요하죠.
migration
하나씩 진행해 봅시다.
.
Install dependencies & configure
// drizzle-kit
npm i -D drizzle-kit
// drizzle-orm
npm i drizzle-orm
// babel-plugin-inline-import 는 마이그레이션 작업에 사용됩니다.
npm i babel-plugin-inline-import
config 파일들에 추가해줍니다. 마이그레이션을 위한 작업입니다.
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql"); // <-- 요거 추가
module.exports = withNativeWind(config, { input: "./src/styles/global.css" });
// babel.config.js
const plugin = require("tailwindcss");
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [["inline-import", { extensions: [".sql"] }]], // <-- 요거 추가
};
};
.
Prepare config file
프로젝트의 루트 레벨에 drizzle.config.ts 파일을 생성해줍니다.
// /drizzle.config.ts
import { Config } from "drizzle-kit";
// Type safety config: Config 의 규격을 엄격히 검증합니다.
export default {
dialect: "sqlite",
driver: 'expo', // <-- very important
schema: "./src/db/drizzle/schema.ts",
out: "./src/db/drizzle/migrations",
//verbose: true,
//strict: true,
} satisfies Config;
config 형식은 보시는대로, schema 에는 drizzle 에서 사용할 db. 스키마 파일을 지정해주었고, out 에는 마이그레이션 파일의 위치를 지정해주었습니다. dialect 는 sqlite 를, 그리고 driver 로 ‘expo’ 를 지정해 줬습니다.
우리의 DB - SQLite 는 로컬 디바이스에 존재하므로, dbCredentials 옵션은 필요하지 않습니다. 만약 외부 BaaS/DB 를 사용하는 프로젝트라면 dbCredentials 를 추가해줘야 합니다.
.
Prepare database schema
drizzle schema 를 맨땅에서 하나씩 생성해주기에는 좀 암담한 측면이 있습니다. DB.Tables 의 모든 컬럼들을, SQLite 의 데이터 타입에 맞춰서 하나씩 지정해줘야 합니다. 게다가 foreign key 와 인덱싱 코드까지 처음부터 떠맡아서 코딩하기에는 아무래도 부담스럽죠.
하지만, 다행히도 우리는, Json 을 SQL 로 완벽하게 변환해둔 SQL 스키마를 갖고 있습니다.
이번에는 SQL 스키마를 drizzle 스키마 파일로 변환해 달라고, 다시 한번 우리의 친구 Claude 에게 부탁해 봤습니다.
// /src/db/drizzle/schema.ts
// Claude 가 제안하는 스키마를 그대로 쓰기엔 부족함과 오류가 좀 많습니다.
// 각자가 Claude 에게서 받은 스키마와 아래 스키마를 비교해보시면 좋을 것 같습니다.
import { InferInsertModel, InferModel } from "drizzle-orm";
import {
text,
integer,
sqliteTable,
primaryKey,
index,
} from "drizzle-orm/sqlite-core";
import { artistTypeEnum, artistTypeEnumArray } from "@/zod-schemas/artists";
// Artists table
export const artists = sqliteTable(
"artists",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
sortName: text("sort_name")!,
type: text("type", { enum: artistTypeEnumArray }).default("person")!,
country: text("country").default("XXA")!,
disambiguation: text("disambiguation")!,
},
(t) => ({ nameIndex: index("name_index").on(t.name) })
);
// Releases table
export const releases = sqliteTable(
"releases",
{
id: text("id").primaryKey(),
title: text("title").notNull(),
artist_id: text("artist_id")
.notNull()
.references(() => artists.id),
status: text("status").default("official"),
release_date: text("release_date"),
country: text("country"),
disambiguation: text("disambiguation"),
packaging: text("packaging"),
},
(t) => ({
titleIndex: index("title_index").on(t.title),
artistIdIndex: index("artist_id_index").on(t.artist_id),
})
);
// Recordings table
export const recordings = sqliteTable(
"recordings",
{
id: text("id").primaryKey(),
title: text("title").notNull(),
length: integer("length", { mode: "number" }), // mode: "number" 추가
disambiguation: text("disambiguation"),
artist_id: text("artist_id").references(() => artists.id),
},
(t) => ({
titleIndex: index("title_index").on(t.title),
artistIdIndex: index("artist_id_index").on(t.artist_id),
})
);
// Release-Recording relationship table
export const releaseRecordings = sqliteTable(
"release_recordings",
{
releaseId: text("release_id").references(() => releases.id),
recordingId: text("recording_id").references(() => recordings.id),
trackPosition: integer("track_position"),
discNumber: integer("disc_number").default(1),
},
(t) => ({
pk: primaryKey({ columns: [t.releaseId, t.recordingId] }),
})
);
// Artist Credit Names table
export const artistCredits = sqliteTable(
"artist_credits",
{
recordingId: text("recording_id").references(() => recordings.id),
artist_id: text("artist_id").references(() => artists.id),
joinPhrase: text("join_phrase"),
name: text("name"),
},
(t) => ({
pk: primaryKey({ columns: [t.recordingId, t.artist_id] }),
})
);
// Tags table
export const tags = sqliteTable(
"tags",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").unique(),
},
(t) => ({ nameIndex: index("name_index").on(t.name) })
);
// Artist Tags relationship table
export const artistTags = sqliteTable(
"artist_tags",
{
artist_id: text("artist_id").references(() => artists.id),
tagId: integer("tag_id").references(() => tags.id),
count: integer("count").default(1),
},
(t) => ({
pk: primaryKey({ columns: [t.artist_id, t.tagId] }),
})
);
// Release Tags relationship table
export const releaseTags = sqliteTable(
"release_tags",
{
releaseId: text("release_id").references(() => releases.id),
tagId: integer("tag_id").references(() => tags.id),
count: integer("count").default(1),
},
(t) => ({
pk: primaryKey({ columns: [t.releaseId, t.tagId] }),
})
);
// Recording Tags relationship table
export const recordingTags = sqliteTable(
"recording_tags",
{
recordingId: text("recording_id").references(() => recordings.id),
tagId: integer("tag_id").references(() => tags.id),
count: integer("count").default(1),
},
(t) => ({
pk: primaryKey({ columns: [t.recordingId, t.tagId] }),
})
);
drizzle 역시 변화의 속도가 빠른 영역입니다. PostgreSQL, MySQL, SQLite 모두에서 API 가 각각 빠르게 업데이트 되고 있는데, 그 범위가 넓기 때문에 미흡한 부분이 존재합니다. 그리고 변화가 빠르기 때문에 불과 몇 달 전까지 유효했던 StackOverflow 와 AI 의 솔루션들이 오히려 혼란을 줄 수도 있습니다.
특히, sqliteTable
API 의 세번째 파라메터-콜백 펑션 이, 이미 몇 개월째 분명하게 교통정리가 안되고 있는데요… 2025.01 현재 SQLite 에서 세번째 인수로 인해 발생하는 이슈가 있습니다.
오피셜 문서
https://orm.drizzle.team/docs/indexes-constraints#unique
에서 보실 수 있지만, 각 DBMS 에 대해서 구문이 다릅니다. drizzle-orm 0.38.4 버전 현재, sqliteTable
API 의 세번째 파라메터로 PG 에서는 Array 를, 나머지 DBMS 에서는 Object 를 수신합니다. 그리고, 일부 비공식 자료에서는, drizzle 에서 세번째 파라메터를 Array 로 통일해서 업데이트가 진행중이라며 Array 사용을 권고하고 있는데, 문제는 Array 를 사용하면 더 심각한 타입에러를 발생시키게 된다는 겁니다. 변화는 진행중이지만 아직 준비가 덜 된 것이라는 결론 밖엔 나오지 않습니다.
아무튼, sqliteTable
API 에서의 타입과 린트 문제는, 업데이트가 진행중이지만 아직 일관된 규칙을 갖추지 못하고 있는 것 같습니다. 때문에, 공식 문서의 지침대로 스키마를 만들었음에도, sqliteTable
펑션 자체에서 가로줄이 그어지면서 deprecated 되었다는 에러를 발생시키고 있습니다. 아직은, 문제가 해결되기를 기다리는 수 밖에는 방법이 없어 보입니다.
결론적으로, 'sqliteTable' is deprecated 문제는, drizzle-orm 0.38.4 버전 현재, 무시하고 넘어가야 할 것 같습니다.
다음으로는, 스키마에서 Foreign Key 가 어떻게 처리되는지를 기억해두는 것도 중요하겠습니다.
// 현재 테이블의 artist_id 컬럼을, 외부 테이블 artists.id 컬럼으로 foreign key 설정합니다.
artistId: text("artist_id").references(() => artists.id),
그리고, index 를 관리하는 코드도 유심히 보고 기억해둡시다.
// sqliteTable 펑션의 세번째 인수, 콜백 펑션으로 인덱스를 생성합니다.
// 공홈의 지침은 Object 를 요구하지만 deprecated 에러를 발생시키는데,
// Array 로의 업데이트가 진행중입니다만, 아직 Lint 준비가 완료되지 않았습니다.
// 깃헙 토론장에서도 이 문제가 좀 시끄러운데요..
// 현재는, Object 를 사용하는 방법 밖에 없습니다.
(t) => ({
titleIndex: index("title_index").on(t.title),
artistIdIndex: index("artist_id_index").on(t.artist_id),
})
그리고 나머지는, 복합 컬럼의 primary key 부분이 조금 생소할 뿐, 충분히 직관적으로 이해할 수 있는 스키마 코드일 것 같습니다.
drizzle 스키마 역시, 앞으로 경험을 쌓아가면서 점차 익숙해지게 되겠죠.
어쨌든, 이렇게 drizzle 스키마 파일의 생성까지는 해치웠 해결했습니다. 앞으로도 drizzle 스키마는, Json → SQL → drizzle schema 순서로 접근하면 효율적으로 해결할 수 있을 것 같습니다.
.
drizzle-zod
drizzle 의 스키마 형식은 zod 의 스키마와 많이 닮아있죠? 실제로, drizzle 스키마로부터 직접 zod 스키마를 추출할 수 있도록 지원해주는 drizzle-zod 가 제공됩니다.
( zod 는, Type Safety 를 위한 스키마 관리 및 유효성 검사 도구입니다. 잘 기억나지 않으시면 React#4 를 잠깐 복습하고 오는 것도 좋겠습니다. )
극단적인 Zod 몰몬 교도들은, 일체의 모든 타입관리를 zod 로 해주자고 제안하고 있습니다. 프로젝트의 가장 근본 단계에서 zod 스키마부터 설계한 후, zod 스키마로부터 데이터 Type 을 추론-inference- 해서 프로젝트의 타입 관리를 일원화해주자는 것이죠.. 이 제안은, 개발 방법론상의 취향 문제를 떠나서 일단 매력적입니다. 타입 관리가 복잡하게 얽히는 불상사는, 프로젝트의 규모가 커질수록 흔하게 겪게 되는 일 중 하나이죠. 명확한 작업 규칙이 중앙에서 강력하게 통제하고 있지 않은 한, 이런 불상사는 곳곳에서 불쑥불쑥 일어나게 됩니다.
프로젝트의 모든 타입은 zod-schema 에서 일원 관리해준다… 이런 강제화된 규칙은, 프로젝트의 모든 팀원들에게도 정서적 안정감을 줄 수 있겠죠.
우리의 프로젝트에서도 zod 주의자들의 엄격하고 통제된 규칙을 적용해보죠. 이제 엄격한 Type 관리를 위해, 앞으로는 다음의 디렉토리와 파일들이 대부분 제거되고 더이상 사용되지 않습니다. constants 의 일부 상수관리 모듈만 남겨두고, types 디렉토리는 완전히 제거될 것입니다. Type 관리가 꼬이게 되는 문제의 근원인 types 폴더를 원천적으로 제거함으로써 Type 이 관리되어야 하는 문제를 없애는 거죠. Type 관리는 사라지고, 관리되어야 할 것은 오직 drizzle 과 zod 의 스키마 뿐입니다.
📦src
┣ 📂constants
┃ ┣ 📜assets.tsx
┃ ┣ 📜color.ts
┃ ┣ 📜Colors.ts
┃ ┣ 📜db.ts
┃ ┣ 📜timeouts.ts
┃ ┗ 📜tokens.tsx
┣ 📂types
┃ ┣ 📜albumType.ts
┃ ┣ 📜artistType.ts
┃ ┣ 📜index.ts
┃ ┣ 📜lastViewedAlbum.ts
┃ ┣ 📜lastViewedArtist.ts
┃ ┣ 📜tagType.ts
┃ ┗ 📜trackType.ts
대신에, zod-schemas 에서 프로젝트의 모든 타입 관리를 대신해 줄 것입니다. 그리고 zod-schemas 의 스키마 원형은, drizzle-schema 로부터 참조됩니다.
📦src
┗ 📂zod-schemas
┃ ┣ 📜albums.ts
┃ ┣ 📜artists.ts
┃ ┣ 📜env.ts
┃ ┣ 📜tokens.ts
┃ ┗ 📜tracks.ts
작업을 시작해봅시다.
먼저 drizzle-zod 패키지를 설치합니다.
# npm i drizzle-zod
drizzle-zod api 인, createInsertSchema 와 createSelectSchema 는 drizzle 의 current schema - 현재 스키마 로부터 직접 추출되기 때문에, 언제나 최신의 drizzle 스키마를 참조해서 zod schema 를 생성해줍니다. 그리고, 프로젝트의 모든 기본 데이터 타입은 zod schema 로부터 추론됩니다. 즉, db 의 스키마를 몇차례든 업그레이드하고 마이그레이션해도, 코드가 작동하는 런타임에는 항상 fresh drizzle schema 가 zod 에게, 그리고 타입으로 전파 되어서, 프로젝트 전역에서 fresh type 이 공급된다는 것이죠. types 폴더에서 타입을 받아오던 과거에는, DB 스키마가 업데이트될 때마다 types 폴더의 모듈도 하드코딩으로 수정해줬어야 했다는 걸 생각해보면, 이제서야 제대로 된 작업을 배운 느낌입니다.
// /src/zod-schemas/artists.ts
// drizzle db 스키마로부터 zod 스키마를 직접 추출합니다.
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { artists } from "../db/drizzle/schema";
import { z } from "zod";
import { apiTagSchema } from "./tags";
import { artistTypeEnumArray } from "./common";
// Zod schemas for different operations
export const selectArtistSchema = createSelectSchema(artists);
export const baseInsertArtistSchema = createInsertSchema(artists);
// 필수 필드와 옵셔널 필드를 분리하여 스키마 구성
export const insertArtistSchema = baseInsertArtistSchema.partial().extend({
id: z.string().uuid(),
name: z.string().min(1, "Artist Name is required"),
sortName: z.string().optional(),
type: z.enum(artistTypeEnumArray).default("person"),
country: z.string().default("XXA"),
disambiguation: z.string().nullable().optional(),
});
// Types
export type SelectArtistSchemaType = z.infer<typeof selectArtistSchema>;
export type InsertArtistSchemaType = z.infer<typeof insertArtistSchema>;
// artists + tags Join schema
export type ArtistWithTagsType = SelectArtistSchemaType & {
tags: [];
};
// Enhanced schema for insert
export const enhancedArtistSchema = insertArtistSchema.extend({
type: z.enum(artistTypeEnumArray).nullable().optional(),
beginDate: z
.string()
.regex(/^\\d{4}(-\\d{2}(-\\d{2})?)?$/)
.nullable()
.optional(),
endDate: z
.string()
.regex(/^\\d{4}(-\\d{2}(-\\d{2})?)?$/)
.nullable()
.optional(),
});
export type EnhancedArtistSchemaType = z.infer<typeof enhancedArtistSchema>;
// API 응답 스키마
// MusicBrainz 의 응답 Json 을 파싱하기 위한 스키마로, drizzle 에는 없는 타입이므로
// 따로 선언해서 관리해줘야 하는 타입입니다.
// zod 스키마는 필요없고 type 만 필요하지만, 패턴을 통일하기 위해 zod 스키마를 사용합니다.
export const apiArtistSchema = z.object({
id: z.string().uuid(),
name: z.string(),
sortName: z.string().optional(),
type: z.enum(artistTypeEnumArray).default("person").optional(),
country: z.string().default("XXA").optional(),
disambiguation: z.string().nullable().optional(),
beginDate: z
.string()
.regex(/^\\d{4}(-\\d{2}(-\\d{2})?)?$/)
.nullable()
.optional(),
endDate: z
.string()
.regex(/^\\d{4}(-\\d{2}(-\\d{2})?)?$/)
.nullable()
.optional(),
score: z.number().optional(),
albumsCnt: z.number().optional(),
tags: z.array(apiTagSchema).optional(),
});
export type ApiArtistSchemaType = z.infer<typeof apiArtistSchema>;
// 최종 타입의 모든 필드 타입들이 optional 로 표시되는 경우,
// tsconfig.json 의 CompilerOptions 에서, "strict": true, 를 설정해주셔야 합니다.
// 프로젝트에서 strict: true 를 설정하기 어려운 상황이라면,
// "strictNullChecks": true 로도 이 문제를 해결할 수 있습니다.
selectArtistsSchema 와 insertArtistsSchema 로, select 와 insert 각각에서 다른 스키마를 사용하는 이유는, insert 에서 사용될 Type 에는 optional — null | undefined 가 허용되어야 하기 때문입니다. 또한, Form.validation 에서 message 를 추가해줘야 할 필요도 있습니다.
귀찮으니, 이것도 그냥 외워둡시다.
drizzle-zod 스키마는, select 스키마와 insert 스키마를 각각 생성해준다.
selectArtistsSchema 는, 이름 붙인 것 처럼, select 상황에서만 사용하기 위해서 작성되었고, drizzle-zod 의 createSelectSchema
로 생성되었습니다.
그리고 insert 상황에서는 일부 컬럼을 optional 로 지정해주고 null | undefined 값 insert 를 허용할 수 있어야 하는데, 이 정책을 적용한 스키마를 createInsertSchema
로 생성해주고 있습니다. insertArtistsSchema 에서는 일부 컬럼에 optional() 을 허용해줬습니다. 그리고 최종적으로는, zod 스키마로부터 테이블에 해당하는 데이터 오브젝트의 Type 이 추론되었고 export 되고 있습니다. 암튼 시간을 갖고 분석해보시면 충분히 이해하실 수 있을 것 같습니다. 중요한 건, SQLite 의 데이터타입 원형이 drizzle 으로, 그리고 다시 zod 스키마로, 그리고 프로젝트의 중심 Data-Type 으로 전달되고 있다는 점이죠. 원 트루 소스의 타입이 drizzle 스키마로부터 최종 타입에 이르기 까지, 순수한 원형으로 전달되어 이어지고 있습니다. 이제야말로 데이터타입의 진정한 일원화가 이루어졌습니다.
그리고, MusicBrainz API 의 응답 Json 을 처리하기 위한 데이터 타입 스키마도 추가해줬습니다. DB.Table 의 스키마와 MusicBrainz 의 응답 스키마가 다르기 때문에 API 응답 Json 의 데이터 스키마를 별도로 생성해줘야만 합니다. 이 부분은 스키마가 아닌 단순 type 만으로도 충분하지만, zod 스키마로부터 type 을 추론하고 있습니다. 일종의 몰몬교도 의식처럼 느껴지기도 하지만, 일관된 패턴이 유지되고 있어서, 오히려 type 을 직접 추가해주는 것보다 자연스럽습니다.
각각의 테이블 들에 대해서도 같은 스키마 작업을 해주었습니다.
drizzle-zod 세 줄 요약
프로젝트 전역에서 사용되는 스키마와 타입의 원 트루 소스는 DB 스키마 이다.
selectArtistsSchema 와 insertArtistsSchema 두가지 zod 스키마가 사용된다.
프로젝트 전역에서 사용될 Data-Type 은, zod/schema.ts 에서 추론되어 export 된다.
별책부록….
zod 스키마 구문에서의 래디컬 차이점
// null 과 undefined 가 허용되지 않습니다. 1byte 이상의 밸류를 필요로 합니다.
z.string() // type: string
// - "hello" ✅
// - "" ✅
// - null ❌
// - undefined ❌
// string 과 null 은 허용되지만 undefined 는 허용하지 않습니다.
z.string().nullable() // type: string | null
// - "hello" ✅
// - "" ✅
// - null ✅
// - undefined ❌
// string 과 undefined 만 허용됩니다. null 은 허용되지 않습니다.
z.string().optional() // type: string | undefined
// - "hello" ✅
// - "" ✅
// - null ❌
// - undefined ✅
// string, null, undefined 모두 허용됩니다. 있으나 마나한 규칙이라는 거죠. 자신감이 바닥칠 때 꼭 필요합니다.
z.string().nullable().optional() // type: string | null | undefined
// - "hello" ✅
// - "" ✅
// - null ✅
// - undefined ✅
.
.
dbdocs.io
SQL 과 drizzle 의 스키마 만으로 테이블의 구조와 관계를 파악하기란, 마치 비전공자가 악보를 보는 일과도 같습니다. 한 눈에 그 의미와 연관성이 잘 안들어오기 마련이죠.
프로젝트의 db 스키마를, 시각화한 ERD 로 표현해주고 테이블 도메인을 관리할 수 있도록 도와주는 웹 서비스가 있습니다.
.
사용방법:
dbdocs 패키지를 -g 설치합니다.
npm install -g dbdocs
dbdocs.io 에서 어카운트를 생성하고, 로그인 합니다.
프로젝트에서 DBML 파일을 만들어줍니다.
Claude 에게 SQL 또는 drizzle-schema 로부터 DBML 파일을 만들어달라고 합시다.
프로젝트의 커맨드라인에서 dbdocs 에 로그인 합니다.
dbdocs login
이메일 OTP 인증을 요구합니다. 인증해줍시다
까짓거
로그인에 성공하였으면, 생성해둔 .dbml 파일을 dbdocs 에 제출합니다. 제 환경은 아래 경로입니다. 프로젝트 명은, 여기서 지정해주는 대로 dbdocs 의 대시보드에 생성됩니다.
dbdocs build ./src/db/drizzle/schema.dbml --project Hoerzu
dbdocs.io 의 대시보드를 확인합니다. 잘 들어갔군요.
테이블간의 관계와 foreign key 의 연관관계를 파악하고 있는 건 매우 중요한 일이죠. 자칫 테이블간의 의존관계를 놓치게 되면 불상사가 일어날 수도 있습니다.
SQLite 의 스키마가 업데이트 될 때, .dbml 문서는 자동으로 업데이트되지 않습니다만, dbdocs 는 앱의 빌드가 생성될때마다 자체적으로 스키마 변화를 감지하고 changelog 에 기록하고 관리해줍니다. 스키마를 업그레이드 해줬을 때, 앱이 빌드되면 dbdocs 의 대시보드에도 업그레이드된 스키마가 반영됩니다.
하지만 아직은 그리 완벽한 것 같진 않습니다. 다른 테이블과 아무런 연관관계가 없는 test_table 에도 연관도가 그어졌군요. 어두운 동굴에 촛불 하나 밝혀진 정도라고 생각하고 넘어갑시다.
.
Migration
이제 drizzle 에서 DB 의 스키마 변경과 마이그레이션을 어떻게 처리할 수 있는지 알아보죠.
마이그레이션 작업은, drizzle.config.ts 파일에서 지정해준 path,
schema: "./src/db/drizzle/schema.ts",
out: "./src/db/drizzle/migrations",
지정된 스키마로부터 current schema 를 읽어와서, 변경이 감지되면 out 의 위치에 마이그레이션 SQL 파일을 생성합니다.
파일이 잘 생성되는지 확인해 봅시다. 프롬프트에 다음 명령을 입력해보죠.
# npx drizzle-kit generate
이 커맨드는, drizzle.config.ts 에 정의된 설정값에 따라, 현재 구동중인 SQLite DB 의 스키마와 /src/db/drizzle/schema.ts
에서 정의된 스키마 간의 차이점을 감지하고 필요하면 마이그레이션 파일을 생성합니다.
위 커맨드 입력 후, 무슨 일이 일어나는지를 순서대로 정리해보면,
drizzle.config.ts 에 정의된 schema 위치의 파일 -타겟 스키마- 을 읽어옵니다.
SQLite 의 현재 상태 DB 스키마 -current schema- 를 로드합니다.
타겟 스키마와 현재 스키마, 두 스키마간의 차이점을 찾습니다.
차이점이 발견되면 새로운 SQL 마이그레이션 파일을 out 위치에 생성합니다.
Delete Migrations
drizzle 스키마를 수정했을 때, SQL 구문이나 Index 규칙 오류, 무결성 오류 등이 발생하면 해당 마이그레이션 파일은 에러를 일으키므로 삭제해줘야 합니다.
잘못 생성된 마이그레이션 파일은 이렇게 삭제합니다.
# npx drizzle-kit drop
// 이후, 마이그레이션 목록에서 삭제할 항목을 선택합니다.
.
우리의 MusicDB 에 테이블을 하나 추가해서, 실제로 drizzle 에서 마이그레이션이 어떻게 진행되는지 살펴보죠.
// /src/db/drizzle/schema.ts
....
// 테스트용 테이블 추가
export const testTable = sqliteTable("test_table", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
});
.
아래 커맨드는, out 의 위치에 마이그레이션 sql 파일을 생성합니다. 여기서 꼭 기억해두어야 할 점은, 아래 커맨드를 처음 실행하면 migrations.js
파일이 생성되는데, 이 모듈이 앞으로의 마이그레이션 코드에서 계속 사용됩니다. 개발작업중에 아래 커맨드를 한번은 필히 실행해줘야만 합니다.
// 마이그레이션의 생성과 푸시를 CLI 에서 처리합니다.
# npx drizzle-kit generate
.
Migration in component
그리고, 실제로는 컴포넌트 단에서의 마이그레이션의 검사와 수행을 해줍니다. 네이티브 앱이 새로 실행될 때마다 이 코드가 인보크 됩니다.
useMigrations()
훅으로 drizzle 마이그레이션 작업이 실행됩니다.
// import 위치를 잘 고르셔야 합니다.
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import migrations from "@/db/drizzle/migrations/migrations";
....
const db = openDatabaseSync(DB_NAME);
const drizzleDB = drizzle(db);
const { success, error } = useMigrations(drizzleDB, migrations);
useMigrations()
훅의 두번째 인수 migrations
는, 위에서
# npx drizzle-kit generate
커맨드로 생성했던 @out/migrations.js 에서 import 해올 수 있습니다. 때문에, 컴포넌트에서 마이그레이션 작업을 처리해주기 위해서는, 먼저 Cli 에서 npx drizzle-kit generate 커맨드를 한번은 꼭 실행해줘야 합니다.
이제, drizzle 로 마이그레이션 코드를 대체하도록 바꿔줍시다.
/src/app/_layout.tsx 를, drizzle 의 db 인스턴스와 마이그레이션을 사용하도록 바꿔줍시다.
순정 SQLite 에서 사용했던 onInit 펑션은 제거 되고, 대신에 drizzle 의 useMigrations 훅이 사용됩니다. 그리고 마이그레이션 작업에 실패하고 error 객체가 리턴되었을 때, 이를 처리하는 에러 컴포넌트가 렌더링되도록 추가해줍니다. 이 기능은 필요에 따라서 개발단계에서만 활성화 되도록 바꿔주셔도 되겠습니다.
모듈들이 임포트 되어오는 위치에 각별히 주의하시고, useMigrations() 훅의 두가지 인수들이 어떻게 준비되는지도 눈여겨 보시기 바랍니다.
// /src/app/_layout.tsx
// import 해올 모듈들의 위치에 주의해야 합니다.
// 같은 이름으로 다른 위치의 모듈들이 있는데, 정확한 위치를 확인해야 합니다.
...
import { openDatabaseSync, SQLiteProvider } from "expo-sqlite";
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import { drizzle } from "drizzle-orm/expo-sqlite";
import { useDrizzleStudio } from "expo-drizzle-studio-plugin";
import migrations from "@/db/drizzle/migrations/migrations";
import { ENV } from "@/constants/env";
const Layout = () => {
const [avatar, setAvatar] = useState({ uri: "" });
const [noticeCnt, setNoticeCnt] = useState(0);
const { colorScheme, setColorScheme, toggleColorScheme } = useColorScheme();
const expoDB = openDatabaseSync(ENV.DB_NAME);
const drizzleDB = drizzle(expoDB);
const { success, error } = useMigrations(drizzleDB, migrations);
// DrizzleStudio
useDrizzleStudio(expoDB);
// Async Data Fetching 을 나중에 추가...
useEffect(() => {
setAvatar({ uri: ENV.AVATAR_OBJ.uri });
setNoticeCnt(1);
}, []);
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<StatusBar
animated
style={colorScheme === "dark" ? "light" : "dark"}
/>
<Suspense fallback={<MigrationFallback />}>
{error ? (
<MigrationFallback error={error} />
) : (
<SQLiteProvider
databaseName={ENV.DB_NAME}
options={{ enableChangeListener: true }}
useSuspense
>
<QueryClientProvider client={queryClient}>
<RootStackLayout
avatar={avatar}
noticeCnt={noticeCnt}
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
initialRouteName="(tabs)" // 추가
/>
</QueryClientProvider>
</SQLiteProvider>
)}
</Suspense>
</ThemeProvider>
<Toaster />
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
export default Layout;
SQLiteProvider 의 onInit 펑션이 사라지고, 대신에 options 에 enableChangeListener
라는 속성이 추가되었습니다. 이 속성은 LiveQuery 와 함께 사용되는데, LiveQuery 는 뒤에 다시 다루겠습니다.
ORM 없이 Expo-SQLite 를 사용할 때에는 onInit 으로 DB 의 마이그레이션 상태를 체크하고 업데이트 해주는 코드를 연결해줬습니다만, drizzle 을 사용하는 오늘의 코드에서는 useMigrations() 훅으로 dbInit() 과 마이그레이션을 모두 처리해줄 수 있습니다. drizzle 을 사용함으로써 마이그레이션 작업도 매우 손쉽고 단순하게 해결되었군요.
drizzle Migration 요약:
drizzle 마이그레이션에서는, onInit 콜백은 사용되지 않고 useMigrations 훅을 사용한다.
.
여기까지의 drizzle 설정을, 우리의 보일러플레이트의 브랜치로 올려두겠습니다.
https://github.com/KangWoosung/expo_sqlite_boilerplate/tree/feature/add-drizzle
.
이상으로, 프로젝트에서 drizzle 을 사용할 준비를 모두 마쳤습니다. 이제 지난 장에서 작업했던, SQL 펑션과 훅으로 구성한 DB 처리 펑션 코드를, drizzle 의 코드로 하나씩 바꿔주면 우리 프로젝트를 drizzle 로 업그레이드 하는 작업이 모두 끝납니다.
.
.
.