NestJS + Drizzle ORM 통합이란?
Drizzle ORM은 SQL-like 문법, 제로 오버헤드 타입 추론, 경량 런타임으로 주목받는 TypeScript ORM입니다. NestJS의 의존성 주입(DI) 시스템과 결합하면, TypeORM이나 Prisma와는 다른 SQL 친화적이면서도 구조화된 데이터 계층을 구축할 수 있습니다.
이 글에서는 DynamicModule로 Drizzle 통합, 트랜잭션 관리, 다중 데이터베이스 연결, Repository 패턴 구현, 마이그레이션 연동까지 실무 설정을 다룹니다.
DrizzleModule: Dynamic Module 설계
// drizzle/drizzle.module.ts
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
export const DRIZZLE = Symbol('DRIZZLE');
export const PG_POOL = Symbol('PG_POOL');
export interface DrizzleModuleOptions {
connectionString: string;
schema: Record<string, unknown>;
poolConfig?: {
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
};
}
export interface DrizzleModuleAsyncOptions {
imports?: any[];
useFactory: (...args: any[]) => DrizzleModuleOptions | Promise<DrizzleModuleOptions>;
inject?: any[];
}
@Global()
@Module({})
export class DrizzleModule {
static forRoot(options: DrizzleModuleOptions): DynamicModule {
const poolProvider: Provider = {
provide: PG_POOL,
useFactory: () => {
return new Pool({
connectionString: options.connectionString,
max: options.poolConfig?.max ?? 20,
idleTimeoutMillis: options.poolConfig?.idleTimeoutMillis ?? 30000,
connectionTimeoutMillis: options.poolConfig?.connectionTimeoutMillis ?? 5000,
});
},
};
const drizzleProvider: Provider = {
provide: DRIZZLE,
useFactory: (pool: Pool) => {
return drizzle(pool, {
schema: options.schema,
logger: process.env.NODE_ENV !== 'production',
});
},
inject: [PG_POOL],
};
return {
module: DrizzleModule,
providers: [poolProvider, drizzleProvider],
exports: [DRIZZLE, PG_POOL],
};
}
static forRootAsync(options: DrizzleModuleAsyncOptions): DynamicModule {
const poolProvider: Provider = {
provide: PG_POOL,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
return new Pool({
connectionString: config.connectionString,
max: config.poolConfig?.max ?? 20,
idleTimeoutMillis: config.poolConfig?.idleTimeoutMillis ?? 30000,
});
},
inject: options.inject || [],
};
const drizzleProvider: Provider = {
provide: DRIZZLE,
useFactory: async (pool: Pool, ...args: any[]) => {
const config = await options.useFactory(...args);
return drizzle(pool, {
schema: config.schema,
logger: process.env.NODE_ENV !== 'production',
});
},
inject: [PG_POOL, ...(options.inject || [])],
};
return {
module: DrizzleModule,
imports: options.imports || [],
providers: [poolProvider, drizzleProvider],
exports: [DRIZZLE, PG_POOL],
};
}
}
AppModule에서 등록
// app.module.ts
import * as schema from './database/schema';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// 동기 설정
// DrizzleModule.forRoot({
// connectionString: 'postgresql://user:pass@localhost:5432/mydb',
// schema,
// }),
// 비동기 설정 (ConfigService 사용)
DrizzleModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
connectionString: config.getOrThrow('DATABASE_URL'),
schema,
poolConfig: {
max: config.get('DB_POOL_MAX', 20),
idleTimeoutMillis: 30000,
},
}),
inject: [ConfigService],
}),
OrderModule,
UserModule,
],
})
export class AppModule {}
Repository 패턴 구현
Drizzle ORM 동적 쿼리 심화에서 다뤘던 쿼리 빌더를 NestJS Repository로 감싸면 계층 분리가 깔끔해집니다.
// database/base.repository.ts
import { Inject } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE } from './drizzle.module';
import * as schema from './schema';
export type DrizzleDB = NodePgDatabase<typeof schema>;
export abstract class BaseRepository {
constructor(@Inject(DRIZZLE) protected readonly db: DrizzleDB) {}
}
// order/order.repository.ts
import { Injectable } from '@nestjs/common';
import { eq, and, gte, desc, sql, SQL } from 'drizzle-orm';
import { orders, users, orderItems, products } from '../database/schema';
@Injectable()
export class OrderRepository extends BaseRepository {
async findById(id: string) {
const result = await this.db.query.orders.findFirst({
where: eq(orders.id, id),
with: {
user: { columns: { id: true, name: true, email: true } },
items: {
with: { product: true },
},
},
});
return result ?? null;
}
async findByUserWithPagination(
userId: number,
page: number,
limit: number,
filters?: { status?: string; since?: Date },
) {
const conditions: SQL[] = [eq(orders.userId, userId)];
if (filters?.status) {
conditions.push(eq(orders.status, filters.status));
}
if (filters?.since) {
conditions.push(gte(orders.createdAt, filters.since));
}
const [data, countResult] = await Promise.all([
this.db
.select({
id: orders.id,
status: orders.status,
totalAmount: orders.totalAmount,
createdAt: orders.createdAt,
})
.from(orders)
.where(and(...conditions))
.orderBy(desc(orders.createdAt))
.limit(limit)
.offset((page - 1) * limit),
this.db
.select({ count: sql<number>`count(*)::int` })
.from(orders)
.where(and(...conditions)),
]);
return {
data,
total: countResult[0]?.count ?? 0,
page,
limit,
totalPages: Math.ceil((countResult[0]?.count ?? 0) / limit),
};
}
async create(input: NewOrder) {
const [order] = await this.db
.insert(orders)
.values(input)
.returning();
return order;
}
async updateStatus(id: string, status: string) {
const [updated] = await this.db
.update(orders)
.set({ status, updatedAt: new Date() })
.where(eq(orders.id, id))
.returning();
return updated ?? null;
}
}
트랜잭션 관리
// order/order.service.ts
@Injectable()
export class OrderService {
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDB,
private readonly orderRepository: OrderRepository,
private readonly eventEmitter: EventEmitter2,
) {}
async createOrder(userId: number, items: CreateOrderItemDto[]) {
return this.db.transaction(async (tx) => {
// 1. 재고 확인 + 락
const productIds = items.map((i) => i.productId);
const availableProducts = await tx
.select()
.from(products)
.where(inArray(products.id, productIds))
.for('update');
// 2. 재고 검증
for (const item of items) {
const product = availableProducts.find((p) => p.id === item.productId);
if (!product || product.stock < item.quantity) {
throw new BadRequestException(
`Insufficient stock for ${product?.name ?? item.productId}`,
);
}
}
// 3. 주문 생성
const totalAmount = items.reduce((sum, item) => {
const product = availableProducts.find((p) => p.id === item.productId)!;
return sum + product.price * item.quantity;
}, 0);
const [order] = await tx
.insert(orders)
.values({ userId, totalAmount, status: 'PENDING' })
.returning();
// 4. 주문 항목 생성
await tx.insert(orderItems).values(
items.map((item) => ({
orderId: order.id,
productId: item.productId,
quantity: item.quantity,
unitPrice: availableProducts.find((p) => p.id === item.productId)!.price,
})),
);
// 5. 재고 차감
for (const item of items) {
await tx
.update(products)
.set({ stock: sql`${products.stock} - ${item.quantity}` })
.where(eq(products.id, item.productId));
}
return order;
});
}
}
// 트랜잭션 데코레이터 (선택적)
export function Transactional() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const db = (this as any).db as DrizzleDB;
return db.transaction((tx) => {
(this as any).db = tx;
const result = originalMethod.apply(this, args);
(this as any).db = db; // 복원
return result;
});
};
};
}
다중 데이터베이스 연결
// 읽기/쓰기 분리 또는 다중 DB
export const DRIZZLE_WRITE = Symbol('DRIZZLE_WRITE');
export const DRIZZLE_READ = Symbol('DRIZZLE_READ');
@Global()
@Module({})
export class DrizzleMultiModule {
static forRoot(config: {
write: DrizzleModuleOptions;
read: DrizzleModuleOptions;
}): DynamicModule {
return {
module: DrizzleMultiModule,
providers: [
{
provide: DRIZZLE_WRITE,
useFactory: () => {
const pool = new Pool({
connectionString: config.write.connectionString,
});
return drizzle(pool, { schema: config.write.schema });
},
},
{
provide: DRIZZLE_READ,
useFactory: () => {
const pool = new Pool({
connectionString: config.read.connectionString,
});
return drizzle(pool, { schema: config.read.schema });
},
},
],
exports: [DRIZZLE_WRITE, DRIZZLE_READ],
};
}
}
// Repository에서 사용
@Injectable()
export class OrderRepository {
constructor(
@Inject(DRIZZLE_WRITE) private readonly writeDb: DrizzleDB,
@Inject(DRIZZLE_READ) private readonly readDb: DrizzleDB,
) {}
async findById(id: string) {
return this.readDb.query.orders.findFirst({
where: eq(orders.id, id),
});
}
async create(input: NewOrder) {
return this.writeDb.insert(orders).values(input).returning();
}
}
헬스체크와 Graceful Shutdown
// health/drizzle.health.ts
@Injectable()
export class DrizzleHealthIndicator extends HealthIndicator {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
const client = await this.pool.connect();
await client.query('SELECT 1');
client.release();
return this.getStatus(key, true, {
totalCount: this.pool.totalCount,
idleCount: this.pool.idleCount,
waitingCount: this.pool.waitingCount,
});
} catch (error) {
return this.getStatus(key, false, { error: error.message });
}
}
}
// Graceful Shutdown
@Module({})
export class DrizzleModule implements OnModuleDestroy {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async onModuleDestroy() {
await this.pool.end();
}
}
Drizzle ORM Prepared·Batch에서 다뤘던 성능 최적화와 결합하면, NestJS에서 최고 수준의 데이터 계층을 구축할 수 있습니다.
마이그레이션 연동
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema/index.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
// package.json scripts
{
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
마무리
NestJS + Drizzle ORM 통합은 DynamicModule로 DI 연결, BaseRepository로 계층 분리, 트랜잭션 래핑, 읽기/쓰기 분리까지 구현하면 SQL 친화적이면서도 NestJS의 구조적 장점을 모두 활용할 수 있습니다. TypeORM 대비 런타임 오버헤드가 적고, Prisma 대비 SQL 제어가 자유로운 것이 핵심 장점입니다.