NestJS + Drizzle ORM 통합

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 제어가 자유로운 것이 핵심 장점입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux