Drizzle ORM Prepared·Batch

Drizzle Prepared Statement란?

Drizzle ORMPrepared Statement는 SQL 쿼리를 미리 컴파일하여 반복 실행 시 파싱 오버헤드를 제거하고, SQL Injection을 원천 차단하는 성능·보안 최적화 기법입니다. Drizzle은 여기에 타입 안전한 플레이스홀더Batch API를 결합하여, ORM 수준에서 데이터베이스 네이티브 성능에 근접한 쿼리 실행을 가능하게 합니다.

이 글에서는 Prepared Statement 생성과 실행, 동적 플레이스홀더, Batch API로 다중 쿼리 일괄 실행, 트랜잭션 내 배치, 성능 벤치마크까지 실무 패턴을 다룹니다.

Prepared Statement 기본 사용

import { drizzle } from 'drizzle-orm/node-postgres';
import { eq, and, gte, sql, placeholder } from 'drizzle-orm';
import { users, orders, products } from './schema';

const db = drizzle(pool);

// 기본 Prepared Statement: .prepare()로 생성
const getUserById = db
  .select()
  .from(users)
  .where(eq(users.id, placeholder('id')))
  .prepare('get_user_by_id');

// 실행: 플레이스홀더에 값 바인딩
const user = await getUserById.execute({ id: 123 });

// 반복 실행 시 쿼리 파싱 생략 → 성능 향상
for (const userId of userIds) {
  const result = await getUserById.execute({ id: userId });
  // ...
}

다중 플레이스홀더

// 복합 조건 Prepared Statement
const findOrders = db
  .select({
    id: orders.id,
    total: orders.totalAmount,
    status: orders.status,
    userName: users.name,
  })
  .from(orders)
  .innerJoin(users, eq(orders.userId, users.id))
  .where(
    and(
      eq(orders.userId, placeholder('userId')),
      eq(orders.status, placeholder('status')),
      gte(orders.createdAt, placeholder('since')),
    ),
  )
  .orderBy(orders.createdAt)
  .limit(placeholder('limit'))
  .offset(placeholder('offset'))
  .prepare('find_orders_filtered');

// 실행
const results = await findOrders.execute({
  userId: 42,
  status: 'CONFIRMED',
  since: new Date('2026-01-01'),
  limit: 20,
  offset: 0,
});

// INSERT Prepared Statement
const insertOrder = db
  .insert(orders)
  .values({
    userId: placeholder('userId'),
    productId: placeholder('productId'),
    quantity: placeholder('quantity'),
    totalAmount: placeholder('total'),
    status: 'PENDING',
  })
  .returning()
  .prepare('insert_order');

const newOrder = await insertOrder.execute({
  userId: 42,
  productId: 'prod-001',
  quantity: 3,
  total: 89.97,
});

// UPDATE Prepared Statement
const updateOrderStatus = db
  .update(orders)
  .set({ status: placeholder('newStatus'), updatedAt: new Date() })
  .where(
    and(
      eq(orders.id, placeholder('orderId')),
      eq(orders.status, placeholder('currentStatus')),
    ),
  )
  .returning()
  .prepare('update_order_status');

const updated = await updateOrderStatus.execute({
  orderId: 'order-123',
  newStatus: 'SHIPPING',
  currentStatus: 'CONFIRMED',
});

Batch API: 다중 쿼리 일괄 실행

Drizzle의 db.batch()는 여러 독립 쿼리를 단일 라운드트립으로 데이터베이스에 전송합니다. Drizzle ORM 동적 쿼리 심화에서 다뤘던 복잡한 쿼리들을 배치로 묶으면 네트워크 지연을 극적으로 줄일 수 있습니다.

// 기본 Batch: 여러 쿼리를 한 번에 실행
const [allUsers, recentOrders, productCount] = await db.batch([
  db.select().from(users).where(eq(users.status, 'ACTIVE')),
  db.select().from(orders).where(gte(orders.createdAt, lastWeek)).limit(100),
  db.select({ count: sql<number>`count(*)` }).from(products),
]);

// 타입 안전: 각 결과의 타입이 정확히 추론됨
// allUsers: { id: number, name: string, ... }[]
// recentOrders: { id: string, totalAmount: number, ... }[]
// productCount: { count: number }[]

대시보드 데이터 일괄 조회

// 대시보드용 다중 집계 쿼리 배치
async function getDashboardData(tenantId: string) {
  const now = new Date();
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);

  const [
    totalRevenue,
    ordersByStatus,
    topProducts,
    newCustomers,
    recentOrders,
  ] = await db.batch([
    // 1. 총 매출
    db.select({
      total: sql<number>`COALESCE(SUM(${orders.totalAmount}), 0)`,
    })
    .from(orders)
    .where(
      and(
        eq(orders.tenantId, tenantId),
        gte(orders.createdAt, thirtyDaysAgo),
        eq(orders.status, 'DELIVERED'),
      ),
    ),

    // 2. 상태별 주문 수
    db.select({
      status: orders.status,
      count: sql<number>`count(*)::int`,
    })
    .from(orders)
    .where(eq(orders.tenantId, tenantId))
    .groupBy(orders.status),

    // 3. 인기 상품 TOP 10
    db.select({
      productId: orderItems.productId,
      productName: products.name,
      totalSold: sql<number>`SUM(${orderItems.quantity})::int`,
      revenue: sql<number>`SUM(${orderItems.quantity} * ${orderItems.unitPrice})`,
    })
    .from(orderItems)
    .innerJoin(products, eq(orderItems.productId, products.id))
    .innerJoin(orders, eq(orderItems.orderId, orders.id))
    .where(
      and(
        eq(orders.tenantId, tenantId),
        gte(orders.createdAt, thirtyDaysAgo),
      ),
    )
    .groupBy(orderItems.productId, products.name)
    .orderBy(sql`SUM(${orderItems.quantity}) DESC`)
    .limit(10),

    // 4. 신규 고객 수
    db.select({
      count: sql<number>`count(*)::int`,
    })
    .from(users)
    .where(
      and(
        eq(users.tenantId, tenantId),
        gte(users.createdAt, thirtyDaysAgo),
      ),
    ),

    // 5. 최근 주문 5건
    db.select({
      id: orders.id,
      customerName: users.name,
      total: orders.totalAmount,
      status: orders.status,
      createdAt: orders.createdAt,
    })
    .from(orders)
    .innerJoin(users, eq(orders.userId, users.id))
    .where(eq(orders.tenantId, tenantId))
    .orderBy(sql`${orders.createdAt} DESC`)
    .limit(5),
  ]);

  return {
    revenue: totalRevenue[0]?.total ?? 0,
    ordersByStatus,
    topProducts,
    newCustomers: newCustomers[0]?.count ?? 0,
    recentOrders,
  };
}

Prepared Statement + Batch 조합

// Prepared Statement를 배치 내에서 사용
const getUser = db
  .select()
  .from(users)
  .where(eq(users.id, placeholder('id')))
  .prepare('batch_get_user');

const getOrderCount = db
  .select({ count: sql<number>`count(*)::int` })
  .from(orders)
  .where(eq(orders.userId, placeholder('userId')))
  .prepare('batch_order_count');

// 사용자 프로필 페이지: 사용자 정보 + 주문 수 동시 조회
async function getUserProfile(userId: number) {
  const [userData, orderCount] = await db.batch([
    getUser.execute({ id: userId }),
    getOrderCount.execute({ userId }),
  ]);

  return {
    ...userData[0],
    totalOrders: orderCount[0]?.count ?? 0,
  };
}

대량 Insert 최적화

// 1. 단순 벌크 Insert (Drizzle 기본)
await db.insert(products).values(productList);
// → INSERT INTO products VALUES (...), (...), (...) ...

// 2. 청크 단위 벌크 Insert (메모리 관리)
async function bulkInsert<T>(
  table: any,
  data: T[],
  chunkSize = 1000,
) {
  const chunks = [];
  for (let i = 0; i < data.length; i += chunkSize) {
    chunks.push(data.slice(i, i + chunkSize));
  }

  let totalInserted = 0;
  for (const chunk of chunks) {
    await db.insert(table).values(chunk);
    totalInserted += chunk.length;
  }
  return totalInserted;
}

// 3. Upsert 배치 (ON CONFLICT)
async function batchUpsert(items: ProductInput[]) {
  const chunks = chunkArray(items, 500);

  for (const chunk of chunks) {
    await db
      .insert(products)
      .values(chunk)
      .onConflictDoUpdate({
        target: products.sku,
        set: {
          name: sql`EXCLUDED.name`,
          price: sql`EXCLUDED.price`,
          stock: sql`EXCLUDED.stock`,
          updatedAt: new Date(),
        },
      });
  }
}

// 4. 트랜잭션 내 배치 (원자성 보장)
async function processOrderBatch(orderInputs: OrderInput[]) {
  return db.transaction(async (tx) => {
    const createdOrders = [];

    for (const input of orderInputs) {
      // 재고 확인 + 차감 + 주문 생성을 트랜잭션 내에서
      const [product] = await tx
        .select()
        .from(products)
        .where(eq(products.id, input.productId))
        .for('update');  // SELECT FOR UPDATE

      if (product.stock < input.quantity) {
        throw new Error(`Insufficient stock for ${product.name}`);
      }

      await tx
        .update(products)
        .set({ stock: sql`${products.stock} - ${input.quantity}` })
        .where(eq(products.id, input.productId));

      const [order] = await tx
        .insert(orders)
        .values({
          userId: input.userId,
          productId: input.productId,
          quantity: input.quantity,
          totalAmount: product.price * input.quantity,
        })
        .returning();

      createdOrders.push(order);
    }

    return createdOrders;
  });
}

성능 비교

패턴 1000건 조회 네트워크 왕복 비고
개별 쿼리 (for loop) ~2000ms 1000회 가장 느림
Prepared + for loop ~1200ms 1000회 파싱 생략
Batch API ~200ms 1회 단일 왕복
IN 절 쿼리 ~50ms 1회 단일 쿼리
벌크 INSERT ~80ms 1회 단일 INSERT 문

핵심: 같은 테이블에서 여러 ID로 조회할 때는 IN 절이 가장 빠르고, 서로 다른 테이블/쿼리를 동시에 실행할 때는 Batch API가 최적입니다. Drizzle ORM Schema 설계 심화에서 다뤘던 인덱스 설계와 함께 적용하면 극적인 성능 향상을 달성할 수 있습니다.

마무리

Drizzle의 Prepared Statement는 쿼리 파싱 캐싱타입 안전 플레이스홀더를, Batch API는 다중 쿼리 단일 라운드트립을 제공합니다. 대시보드 집계, 대량 데이터 처리, 트랜잭션 내 배치 작업에서 이 두 기능을 적절히 조합하면 ORM의 편의성을 유지하면서 네이티브 SQL에 근접한 성능을 달성할 수 있습니다.

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