Drizzle ORM Schema 설계란?
Drizzle ORM의 스키마는 TypeScript 코드로 데이터베이스 테이블 구조를 정의합니다. SQL에 가까운 선언적 문법으로 컬럼 타입, 제약 조건, 인덱스, 관계를 표현하며, 스키마 정의가 곧 타입 시스템이 됩니다. 별도의 데코레이터나 클래스 없이 순수 함수와 객체로 스키마를 구성하는 것이 Drizzle의 핵심 철학입니다.
기본 테이블 정의
PostgreSQL, MySQL, SQLite 각각 전용 스키마 빌더를 제공합니다. 여기서는 PostgreSQL 기준으로 설명합니다.
import {
pgTable, serial, varchar, text, integer,
timestamp, boolean, decimal, pgEnum, uuid,
} from 'drizzle-orm/pg-core';
// Enum 정의
export const userStatusEnum = pgEnum('user_status', [
'active', 'inactive', 'suspended',
]);
export const orderStatusEnum = pgEnum('order_status', [
'pending', 'confirmed', 'shipped', 'delivered', 'cancelled',
]);
// 사용자 테이블
export const users = pgTable('users', {
id: serial('id').primaryKey(),
uuid: uuid('uuid').defaultRandom().notNull().unique(),
email: varchar('email', { length: 255 }).notNull().unique(),
username: varchar('username', { length: 50 }).notNull().unique(),
passwordHash: text('password_hash').notNull(),
displayName: varchar('display_name', { length: 100 }),
status: userStatusEnum('status').default('active').notNull(),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow().notNull()
.$onUpdate(() => new Date()),
});
컬럼 타입 상세 활용
Drizzle은 각 DB의 네이티브 타입을 충실하게 지원합니다. 적절한 타입을 선택하면 스토리지 효율과 쿼리 성능이 향상됩니다.
import {
pgTable, serial, varchar, text, integer, bigint,
real, doublePrecision, decimal, numeric,
boolean, date, time, timestamp, interval,
json, jsonb, uuid, inet, macaddr, cidr,
smallint, smallserial, bigserial,
} from 'drizzle-orm/pg-core';
export const products = pgTable('products', {
id: serial('id').primaryKey(),
// 문자열 타입
name: varchar('name', { length: 200 }).notNull(),
slug: varchar('slug', { length: 200 }).notNull().unique(),
description: text('description'),
// 숫자 타입
stockQuantity: integer('stock_quantity').default(0).notNull(),
viewCount: bigint('view_count', { mode: 'number' }).default(0),
// 정밀 소수점 (금액)
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
comparePrice: decimal('compare_price', { precision: 10, scale: 2 }),
// Boolean
isPublished: boolean('is_published').default(false).notNull(),
isFeatured: boolean('is_featured').default(false).notNull(),
// JSON/JSONB (PostgreSQL)
metadata: jsonb('metadata').$type<{
weight?: number;
dimensions?: { width: number; height: number; depth: number };
tags?: string[];
}>(),
// 배열 타입 (PostgreSQL)
images: text('images').array(),
// 날짜/시간
publishedAt: timestamp('published_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow().notNull(),
});
인덱스와 제약 조건
테이블 정의의 세 번째 인자로 인덱스, 유니크 제약, 체크 제약을 설정합니다.
import { index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
status: orderStatusEnum('status').default('pending').notNull(),
totalAmount: decimal('total_amount', { precision: 12, scale: 2 }).notNull(),
currency: varchar('currency', { length: 3 }).default('KRW').notNull(),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
// 복합 인덱스
index('idx_order_user_status').on(table.userId, table.status),
index('idx_order_created').on(table.createdAt),
// 부분 인덱스: 삭제되지 않은 주문만
index('idx_order_active').on(table.userId, table.createdAt)
.where(sql`${table.deletedAt} IS NULL`),
// 유니크 인덱스
uniqueIndex('uq_order_user_pending')
.on(table.userId)
.where(sql`${table.status} = 'pending'`),
// 체크 제약
check('chk_total_positive', sql`${table.totalAmount} > 0`),
check('chk_currency_code', sql`length(${table.currency}) = 3`),
]);
외래 키와 관계 정의
외래 키는 컬럼 레벨의 .references()나 테이블 레벨의 foreignKey()로 설정하고, Relations은 별도의 relations() 함수로 정의합니다.
import { relations } from 'drizzle-orm';
import { foreignKey } from 'drizzle-orm/pg-core';
// 주문 항목 테이블
export const orderItems = pgTable('order_items', {
id: serial('id').primaryKey(),
orderId: integer('order_id').notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
productId: integer('product_id').notNull()
.references(() => products.id, { onDelete: 'restrict' }),
quantity: integer('quantity').notNull(),
unitPrice: decimal('unit_price', { precision: 10, scale: 2 }).notNull(),
subtotal: decimal('subtotal', { precision: 12, scale: 2 }).notNull(),
}, (table) => [
index('idx_item_order').on(table.orderId),
index('idx_item_product').on(table.productId),
check('chk_quantity', sql`${table.quantity} > 0`),
]);
// 자기 참조 관계 (카테고리 트리)
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
parentId: integer('parent_id'),
sortOrder: integer('sort_order').default(0).notNull(),
}, (table) => [
// 자기 참조 외래 키
foreignKey({
columns: [table.parentId],
foreignColumns: [table.id],
name: 'fk_category_parent',
}).onDelete('set null'),
]);
// Relations 정의 (쿼리 빌더용)
export const usersRelations = relations(users, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, {
fields: [orders.userId],
references: [users.id],
}),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, {
fields: [orderItems.orderId],
references: [orders.id],
}),
product: one(products, {
fields: [orderItems.productId],
references: [products.id],
}),
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
parent: one(categories, {
fields: [categories.parentId],
references: [categories.id],
relationName: 'parentChild',
}),
children: many(categories, { relationName: 'parentChild' }),
}));
커스텀 타입과 $type
TypeScript 타입을 JSONB 컬럼에 바인딩하거나, 커스텀 직렬화/역직렬화 로직을 적용합니다.
import { customType } from 'drizzle-orm/pg-core';
// JSONB에 타입 바인딩
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface UserPreferences {
theme: 'light' | 'dark';
language: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
}
export const userProfiles = pgTable('user_profiles', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
address: jsonb('address').$type<Address>(),
preferences: jsonb('preferences').$type<UserPreferences>()
.default({
theme: 'light',
language: 'ko',
notifications: { email: true, push: true, sms: false },
}),
});
// 커스텀 타입 정의: 자동 변환
const money = customType<{
data: number;
driverData: string;
}>({
dataType() {
return 'decimal(12, 2)';
},
fromDriver(value: string): number {
return parseFloat(value);
},
toDriver(value: number): string {
return value.toFixed(2);
},
});
export const transactions = pgTable('transactions', {
id: serial('id').primaryKey(),
amount: money('amount').notNull(),
// DB에는 decimal로 저장, TypeScript에서는 number로 사용
});
스키마 분할과 모듈화
대규모 프로젝트에서는 스키마를 도메인별 파일로 분할하여 관리합니다. 마이그레이션은 전체 스키마를 참조합니다.
// src/db/schema/index.ts — 통합 export
export * from './users';
export * from './products';
export * from './orders';
export * from './categories';
// src/db/schema/users.ts
export const users = pgTable('users', { /* ... */ });
export const userProfiles = pgTable('user_profiles', { /* ... */ });
export const usersRelations = relations(users, ({ one, many }) => ({ /* ... */ }));
// src/db/schema/orders.ts
export const orders = pgTable('orders', { /* ... */ });
export const orderItems = pgTable('order_items', { /* ... */ });
export const ordersRelations = relations(orders, ({ one, many }) => ({ /* ... */ }));
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts', // 통합 스키마
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
// 타입 추출: insert/select 타입 자동 생성
import { InferInsertModel, InferSelectModel } from 'drizzle-orm';
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
export type Order = InferSelectModel<typeof orders>;
export type NewOrder = InferInsertModel<typeof orders>;
PostgreSQL 고급 기능 활용
import { pgTable, pgSchema, pgView, pgMaterializedView } from 'drizzle-orm/pg-core';
// 스키마(네임스페이스) 분리
const analyticsSchema = pgSchema('analytics');
export const pageViews = analyticsSchema.table('page_views', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
path: varchar('path', { length: 500 }).notNull(),
userId: integer('user_id'),
viewedAt: timestamp('viewed_at', { withTimezone: true })
.defaultNow().notNull(),
});
// Generated Column (PostgreSQL 12+)
export const orderSummary = pgTable('order_summary', {
id: serial('id').primaryKey(),
quantity: integer('quantity').notNull(),
unitPrice: decimal('unit_price', { precision: 10, scale: 2 }).notNull(),
// 계산 컬럼: quantity * unitPrice 자동 계산
totalPrice: decimal('total_price', { precision: 12, scale: 2 })
.generatedAlwaysAs(sql`${orderSummary.quantity} * ${orderSummary.unitPrice}`),
});
운영 베스트 프랙티스
- 스키마 모듈화: 도메인별 파일 분할 후
index.ts에서 통합 export — 대규모 프로젝트 필수 - $type으로 JSONB 타입 안전: JSONB 컬럼에 TypeScript 인터페이스를 바인딩하여 타입 안전성을 확보하세요
- 부분 인덱스 활용: Soft Delete 패턴에서는
.where()로 삭제되지 않은 행만 인덱싱하세요 - decimal for 금액: 금액은 반드시
decimal타입 — float/real은 부동소수점 오차 발생 - InferSelectModel/InferInsertModel: 수동 타입 정의 대신 스키마에서 자동 추출하세요
- check 제약 적극 활용: 비즈니스 규칙을 DB 레벨에서 강제하여 데이터 무결성을 보장하세요