Drizzle Prepared Statement란?
Drizzle ORM의 Prepared 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에 근접한 성능을 달성할 수 있습니다.