Drizzle ORM 심화: 고급 쿼리, 트랜잭션, 성능 최적화
이 글의 핵심
Drizzle ORM은 TypeScript 스키마에서 SQL에 가까운 타입 안전 쿼리를 제공합니다. 이 글에서는 다중 조인·서브쿼리, 트랜잭션과 행 수준 락, Prepared Statement, 관계형 로딩과 N+1 방지, drizzle-kit 마이그레이션 전략, Prisma와의 설계·운영 비교, 인덱스·배치·연결 풀까지 실무 성능 관점으로 설명합니다.
Drizzle ORM은 TypeScript로 스키마를 정의하고, 그 스키마에서 쿼리 결과 타입을 컴파일 시점에 추론하는 경량 ORM입니다. 단순 CRUD를 넘어서면 복잡한 조인, 서브쿼리와 CTE, 트랜잭션과 락, Prepared Statement, 관계 데이터 로딩 성능, 마이그레이션 절차가 문제가 됩니다. 이 글은 PostgreSQL을 기준으로 이 주제들을 설명하며, 대부분의 개념은 Drizzle이 지원하는 MySQL과 SQLite에도 적용됩니다.
pgTable로 테이블을 정의하고 db.select().from(...) 쿼리를 실행해 보았으며, drizzle-kit으로 마이그레이션을 생성하는 흐름을 안다고 가정합니다. Drizzle은 SQL에 가깝게 쓰되 타입이 깨지지 않게 하는 데 초점을 둔 도구라서, 고급 주제로 갈수록 EXPLAIN, 격리 수준, 락, 인덱스 같은 SQL 지식이 그대로 실력이 됩니다. ORM이 실행 계획이나 트랜잭션 경계를 대신 설계해 주지 않습니다.
복잡한 조인과 서브쿼리
다중 조인과 상관 서브쿼리
다음은 사용자별 가장 최근 주문의 금액 합계를 구하는 쿼리입니다. 조인 조건 안에 상관 서브쿼리를 sql 템플릿으로 넣었습니다.
import { eq, and, sql } from 'drizzle-orm';
import { users, orders, orderItems } from './schema';
const rows = await db
.select({
userName: users.name,
orderId: orders.id,
lineTotal: sql<string>`sum(${orderItems.qty} * ${orderItems.unitPrice})`,
})
.from(users)
.innerJoin(
orders,
and(
eq(orders.userId, users.id),
eq(
orders.createdAt,
sql`(select max(o2.created_at) from orders o2 where o2.user_id = ${users.id})`,
),
),
)
.innerJoin(orderItems, eq(orderItems.orderId, orders.id))
.groupBy(users.id, users.name, orders.id);
두 가지를 주의해야 합니다. 첫째, sql<T>의 타입 인자는 런타임 변환을 하지 않고 타입만 붙입니다. PostgreSQL 드라이버는 numeric이나 bigint 합계를 정밀도 손실을 막으려고 문자열로 돌려주므로, 위에서는 sql<number>가 아니라 sql<string>으로 두었습니다. 숫자가 필요하면 .mapWith(Number)로 변환합니다. 둘째, 같은 사용자의 주문 두 건이 같은 created_at을 가지면 둘 다 “최신 주문”으로 잡힙니다. 정확히 한 건만 원한다면 row_number() over (partition by user_id order by created_at desc, id desc) 같은 윈도우 함수나 PostgreSQL의 DISTINCT ON을 쓰는 편이 안전합니다.
조인이 깊어질수록 일대다 관계에서 행이 곱해져, 그 위에 sum이나 count를 하면 같은 값이 중복 합산되는 실수가 흔합니다. 서브쿼리, CTE, 윈도우 함수 중 무엇을 쓸지는 데이터 분포와 인덱스에 따라 달라지므로 EXPLAIN ANALYZE로 실제 행 수와 비용을 확인하는 것이 전제입니다.
EXISTS와 IN
“조건을 만족하는 행이 존재하는가”는 exists로, “목록에 포함되는가”는 inArray로 표현합니다.
import { exists, inArray, eq, and, sql } from 'drizzle-orm';
import { users, orders } from './schema';
const activeUserIds = [1, 2, 3];
await db
.select()
.from(users)
.where(
and(
inArray(users.id, activeUserIds),
exists(
db
.select({ one: sql`1` })
.from(orders)
.where(and(eq(orders.userId, users.id), eq(orders.status, 'paid'))),
),
),
);
PostgreSQL 옵티마이저는 EXISTS 상관 서브쿼리를 세미 조인으로 바꿔 실행하는 경우가 많아, orders(user_id, status) 같은 인덱스가 있으면 빠릅니다. 계획이 바깥 행마다 안쪽을 다시 스캔하는 형태로 나온다면 인덱스부터 확인하고, 그래도 느리면 조인이나 미리 집계한 서브쿼리로 바꿉니다. inArray에 빈 배열을 넘기는 경우도 처리해 두어야 합니다. 최근 버전은 항상 거짓인 조건으로 바꿔 주지만, 예전 버전에서는 오류가 났습니다.
CTE(WITH)
복잡한 리포트 쿼리는 CTE로 단계를 나누면 읽기 쉽습니다. Drizzle에서는 $with로 CTE를 정의하고 with로 붙입니다.
const paidTotals = db.$with('paid_totals').as(
db
.select({
userId: orders.userId,
total: sql<string>`sum(${orders.amount})`.as('total'),
})
.from(orders)
.where(eq(orders.status, 'paid'))
.groupBy(orders.userId),
);
const result = await db
.with(paidTotals)
.select({ name: users.name, total: paidTotals.total })
.from(users)
.innerJoin(paidTotals, eq(paidTotals.userId, users.id));
CTE 안에서 sql 표현식으로 만든 컬럼은 .as('total')로 이름을 붙여야 바깥에서 참조할 수 있습니다. PostgreSQL 12부터는 재귀가 아니고 부작용이 없으며 한 번만 참조되는 CTE를 옵티마이저가 본 쿼리에 인라인하므로, CTE를 쓴다고 해서 항상 임시 결과를 만드는 것은 아닙니다. 반대로 결과를 한 번 계산해 재사용하게 하려면 AS MATERIALIZED를 명시해야 합니다. 성능은 결국 최종 실행 계획으로 판단합니다.
트랜잭션과 락
기본 트랜잭션
결제, 재고, 포인트처럼 여러 테이블이 함께 맞아야 하는 연산은 하나의 트랜잭션으로 묶습니다. db.transaction 콜백의 tx 객체는 db와 같은 API를 제공하며, 콜백 안에서 예외가 나면 롤백됩니다.
await db.transaction(async (tx) => {
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, userId))
.for('update'); // 이 행에 대한 다른 트랜잭션의 갱신·잠금을 커밋 시까지 막음
if (!user || user.balance < amount) {
throw new Error('INSUFFICIENT_FUNDS');
}
await tx
.update(users)
.set({ balance: sql`${users.balance} - ${amount}` })
.where(eq(users.id, userId));
await tx.insert(ledger).values({
userId,
amount: -amount,
reason: 'PAYMENT',
});
});
for('update') 없이 잔액을 읽고 검사하면, 동시에 들어온 두 결제가 둘 다 같은 잔액을 보고 통과해 잔액이 음수가 될 수 있습니다. 행 잠금으로 두 번째 트랜잭션은 첫 번째가 커밋할 때까지 기다린 뒤 갱신된 잔액을 읽습니다. 잠금 없이 하려면 UPDATE ... SET balance = balance - $1 WHERE id = $2 AND balance >= $1처럼 조건부 갱신 한 문장으로 처리하고 영향받은 행 수를 확인하는 방법도 있습니다.
예외 타입은 재시도해도 되는 오류(데드락, 직렬화 실패)와 비즈니스 오류(잔액 부족)로 나눠 두어야, 재시도 로직이 잔액 부족 같은 오류까지 다시 실행하지 않습니다.
격리 수준과 데드락
PostgreSQL의 기본 격리 수준은 read committed이며, db.transaction(fn, { isolationLevel: 'serializable' })처럼 트랜잭션 단위로 올릴 수 있습니다. PostgreSQL은 MVCC 스냅샷으로 격리를 구현하므로, repeatable read나 serializable로 올린다고 읽기 잠금이 늘지는 않습니다. 대신 동시에 같은 데이터를 고친 트랜잭션 중 하나가 직렬화 실패(SQLSTATE 40001)로 중단되므로, 애플리케이션이 트랜잭션 전체를 재시도하는 코드를 반드시 갖춰야 합니다.
데드락은 두 트랜잭션이 서로 상대가 잡은 행을 기다릴 때 생깁니다. PostgreSQL은 이를 감지해 한쪽을 40P01 오류로 중단시킵니다. 여러 행을 갱신할 때 항상 같은 순서(예: user_id 오름차순)로 잠그면 대부분 피할 수 있고, 외부 HTTP 호출을 트랜잭션 밖으로 빼면 잠금 보유 시간이 짧아져 충돌 자체가 줄어듭니다. 재시도가 붙는 결제나 웹훅 처리는 멱등 키에 유일 제약을 걸어 같은 요청이 두 번 반영되지 않게 합니다.
SELECT ... FOR UPDATE SKIP LOCKED
여러 워커가 같은 테이블에서 작업을 가져가는 큐에서는 for('update', { skipLocked: true })를 씁니다. 다른 워커가 이미 잠근 행은 기다리지 않고 건너뛰므로, 워커들이 같은 작업을 집지 않으면서도 서로 대기하지 않습니다.
const [job] = await tx
.select()
.from(jobs)
.where(eq(jobs.status, 'pending'))
.orderBy(jobs.createdAt)
.limit(1)
.for('update', { skipLocked: true });
이 쿼리는 트랜잭션 안에서 실행하고 작업 상태를 바꾼 뒤 커밋해야 의미가 있습니다. 트랜잭션 밖에서 실행하면 문장이 끝나자마자 잠금이 풀립니다.
Prepared Statement
쓰는 이유
Prepared Statement는 SQL 문과 파라미터를 분리해 보내므로 인젝션을 막고, 같은 SQL을 반복 실행할 때 파싱과 계획 수립 비용을 줄일 수 있습니다. 다만 Drizzle의 일반 쿼리도 이미 파라미터 바인딩을 쓰므로 인젝션 방어 면에서는 차이가 없고, 이득은 주로 Drizzle 쪽의 SQL 문자열 생성 비용과 DB 쪽 파싱 비용을 아끼는 데 있습니다.
import { eq, sql } from 'drizzle-orm';
const userById = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder('id')))
.prepare('user_by_id');
const row = await userById.execute({ id: 42 });
PostgreSQL에서 이름 있는 준비문은 연결에 묶여 있습니다. PgBouncer를 트랜잭션 모드로 쓰면 요청마다 다른 서버 연결이 배정되어 “prepared statement does not exist” 같은 오류가 날 수 있으므로, 풀러 버전과 설정(PgBouncer 1.21 이상의 max_prepared_statements 등)을 확인하거나 이름 없는 실행을 씁니다.
PostgreSQL은 준비문을 처음 다섯 번은 파라미터 값에 맞춘 계획(custom plan)으로 실행하고, 그 뒤에는 값과 무관한 일반 계획(generic plan)이 충분히 좋으면 그것을 재사용합니다. 데이터 분포가 크게 치우친 컬럼에서는 일반 계획이 특정 값에 대해 나쁜 계획이 될 수 있어, 준비문으로 바꾼 뒤 일부 쿼리가 느려진다면 plan_cache_mode를 확인합니다.
동적 IN 목록
inArray에 넘기는 ID 개수가 요청마다 다르면 IN ($1, $2, ...)의 파라미터 수가 바뀌어 매번 다른 SQL이 됩니다. PostgreSQL에서는 배열 하나를 파라미터로 넘기는 = ANY($1::int[]) 형태(sql`${users.id} = any(${ids})`)로 바꾸면 SQL이 하나로 고정됩니다.
관계 데이터 로딩과 N+1
N+1은 부모 N행을 읽은 뒤 자식을 행마다 따로 조회해 쿼리가 N+1번 나가는 패턴입니다. 반복문 안에서 db.select()를 호출하는 코드가 전형적인 원인입니다.
Drizzle의 관계형 쿼리 API(db.query.users.findMany({ with: { posts: true } }))는 관계를 포함해도 SQL 한 개를 만들어 이 문제를 피합니다. 대신 자식 행을 JSON으로 집계하는 서브쿼리를 생성하므로, 관계를 여러 단계로 중첩하거나 자식이 많으면 쿼리 하나가 무거워질 수 있습니다. 핫 경로에서는 생성된 SQL을 로그로 확인하고 EXPLAIN을 봅니다.
목록 화면에서 사용자, 프로필, 최근 주문을 함께 보여 준다면 필요한 컬럼만 고른 조인 쿼리 한 번이 대개 가장 예측 가능합니다. GraphQL 리졸버처럼 코드 구조상 조인을 쓰기 어려운 곳에서는 요청 단위로 ID를 모아 inArray 한 번으로 가져오는 배치 로더(DataLoader 패턴)를 씁니다.
어떤 방식이든 조인 키, 필터 컬럼, 정렬 컬럼에 맞는 인덱스가 없으면 느립니다. pg_stat_statements나 슬로 쿼리 로그로 총 소요 시간이 큰 쿼리부터 확인하는 것이 ORM을 바꾸는 것보다 효과가 큽니다.
마이그레이션 전략
drizzle-kit 워크플로
스키마 파일을 원본으로 두고 drizzle-kit generate로 SQL 마이그레이션 파일을 만든 뒤, 생성된 SQL을 리뷰하고 스테이징에서 적용해 본 다음 프로덕션에 반영합니다. drizzle-kit push는 마이그레이션 파일 없이 스키마를 DB에 바로 맞추므로 로컬 프로토타이핑용이며, 프로덕션에서 쓰면 변경 이력이 남지 않습니다. 생성된 SQL은 반드시 사람이 읽어야 합니다. 예를 들어 컬럼 이름 변경을 drizzle-kit이 삭제 후 추가로 해석하면 데이터가 사라지므로, 생성 시 묻는 질문에 주의해 답합니다.
무중단 배포를 깨는 변경
PostgreSQL에서 특히 주의할 변경은 다음과 같습니다.
- 컬럼 타입 변경: 대부분 테이블 전체를 다시 쓰며 그동안
ACCESS EXCLUSIVE잠금이 걸립니다. 새 컬럼 추가, 이중 쓰기, 백필, 읽기 전환, 옛 컬럼 삭제의 단계로 나눕니다. - 기존 컬럼에
NOT NULL추가: 전체 행을 검사하는 동안 테이블이 잠깁니다.CHECK (col IS NOT NULL) NOT VALID제약을 먼저 추가하고VALIDATE CONSTRAINT로 검증한 뒤SET NOT NULL을 하면 PostgreSQL 12 이상에서 긴 잠금을 피할 수 있습니다. 새 컬럼을 상수 기본값과 함께 추가하는 것은 PostgreSQL 11부터 메타데이터만 바뀌어 빠릅니다. - 큰 테이블의 인덱스 생성:
CREATE INDEX CONCURRENTLY를 씁니다. 이 문은 트랜잭션 블록 안에서 실행할 수 없으므로, 마이그레이션 러너가 파일을 트랜잭션으로 감싸 실행한다면 별도로 실행해야 합니다.
시드와 테스트 데이터
로컬에서는 마이그레이션 후 시드 스크립트로 최소 데이터를 넣고, E2E 테스트는 Docker로 띄운 DB와 고정 시드로 재현성을 확보합니다. 프로덕션 데이터를 그대로 복제해 오는 방식은 개인정보 문제가 있으므로 마스킹 절차가 필요합니다.
Prisma와 Drizzle 비교
| 관점 | Prisma | Drizzle |
|---|---|---|
| 쿼리 스타일 | 고수준 API, 관계 탐색이 편리 | SQL에 가깝고 세밀하게 제어 |
| 스키마 | 별도 DSL인 schema.prisma | TypeScript 스키마 |
| 마이그레이션 | prisma migrate로 생성·적용 일체화 | drizzle-kit으로 SQL 생성, 적용 방식은 선택 |
| 런타임 | 생성된 클라이언트, 과거에는 Rust 쿼리 엔진 바이너리 포함 | 드라이버 위의 얇은 쿼리 빌더 |
| 요구 역량 | 초반 생산성이 높음 | SQL과 인덱스 이해가 그대로 필요 |
Prisma는 오랫동안 Rust로 만든 쿼리 엔진을 함께 배포해 번들 크기와 서버리스 콜드 스타트에서 불리했고, 최근 버전에서는 이를 TypeScript 기반으로 옮기고 있으므로 비교할 때는 사용하려는 버전을 기준으로 봐야 합니다.
복잡한 리포트와 배치 쿼리가 많고 팀이 SQL과 실행 계획을 직접 다룰 수 있으며 런타임이 가벼워야 한다면 Drizzle이 맞습니다. CRUD 중심이고 관계 탐색 API를 넓게 쓰며 마이그레이션과 클라이언트가 한 도구로 묶인 워크플로를 원한다면 Prisma가 맞습니다. 옮길 때는 기능 대응표를 만들고 트랜잭션, 락, raw SQL, 마이그레이션 스크립트를 단계적으로 이전합니다.
연결 풀과 서버리스
Drizzle의 운영 품질은 결국 아래 드라이버와 풀 설정이 결정합니다. 앱 인스턴스마다 풀이 있으므로 DB가 받는 연결 수는 “인스턴스 수 × 풀 max”입니다. 이 값이 DB의 max_connections에 가까우면 배포 중 인스턴스가 잠시 두 배가 될 때 연결 오류가 나고, 연결이 너무 많으면 DB 쪽 프로세스 경합으로 오히려 느려집니다.
Vercel, Lambda, Cloudflare Workers 같은 서버리스 환경에서는 인스턴스가 짧게 살거나 수백 개로 늘어나므로, 요청마다 새 연결을 만들면 연결 수가 폭증합니다. 해결 방법은 HTTP나 WebSocket으로 쿼리하는 서버리스 드라이버(Neon의 @neondatabase/serverless 등)를 쓰거나, PgBouncer나 RDS Proxy 같은 외부 풀러를 두고 앱 풀 크기를 작게 잡는 것입니다. 외부 풀러의 트랜잭션 모드에서는 세션에 상태를 남기는 기능(SET, 임시 테이블, 앞에서 본 이름 있는 준비문)이 기대대로 동작하지 않습니다.
읽기 전용 복제본을 쓸 때는 Drizzle의 withReplicas로 읽기와 쓰기 연결을 나누고, 트랜잭션은 항상 프라이머리에서 실행되게 합니다. 복제본은 지연이 있으므로 방금 쓴 데이터를 바로 읽어야 하는 요청은 프라이머리로 보냅니다.
마이그레이션은 앱 기동 시 migrate()로 실행하기보다 CI/CD의 별도 단계나 Kubernetes Job으로 적용하는 편이 안전합니다. 인스턴스 여러 개가 동시에 기동하며 같은 마이그레이션을 실행하려 드는 경쟁을 피할 수 있고, 마이그레이션 실패가 앱 기동 실패로 번지지 않습니다.
문제 해결
조인이 예상보다 느리면 조인 키의 타입이 서로 다르지 않은지(integer와 bigint, text와 varchar의 콜레이션 차이 등) 확인합니다. 암묵적 형변환이 끼면 인덱스를 쓰지 못할 수 있습니다. 선택도가 낮은 컬럼(값 종류가 적은 상태 컬럼 등)만으로 만든 인덱스는 거의 도움이 되지 않으므로 복합 인덱스나 부분 인덱스를 검토합니다.
데드락이 간헐적으로 나면 트랜잭션 안의 갱신 순서를 통일하고, lock_timeout을 설정해 무한정 기다리지 않게 한 뒤 재시도 정책을 둡니다.
대량 적재나 마이그레이션 뒤에 실행 계획이 나빠졌다면 통계가 오래된 경우가 많으므로 ANALYZE를 실행합니다. 준비문으로 바꾼 쿼리만 느려졌다면 앞에서 설명한 일반 계획 문제일 수 있습니다.
서버리스나 풀러 환경에서 간헐적으로 “connection closed”가 나면 풀러의 유휴 타임아웃이나 세션 한도를 확인하고, 연결 오류에 한해 재시도합니다. 로컬에서만 빠르고 배포 환경에서 느리다면 앱과 DB가 다른 리전에 있지 않은지부터 확인합니다. 쿼리 하나마다 리전 간 왕복 지연이 더해지기 때문입니다.
참고
- Drizzle 공식 문서: https://orm.drizzle.team/
- PostgreSQL 문서(격리·락): https://www.postgresql.org/docs/current/