Drizzle ORM 심화 가이드 — 고급 쿼리와 성능 최적화

Drizzle ORM 심화 가이드 — 고급 쿼리와 성능 최적화

이 글의 핵심

Drizzle ORM은 TypeScript 스키마에서 SQL에 가까운 타입 안전 쿼리를 제공합니다. 이 글에서는 다중 조인·서브쿼리, 트랜잭션과 행 수준 락, Prepared Statement, 관계형 로딩과 N+1 방지, drizzle-kit 마이그레이션 전략, Prisma와의 설계·운영 비교, 인덱스·배치·연결 풀까지 실무 성능 관점으로 설명합니다.

이 글의 핵심

Drizzle ORM은 TypeScript로 스키마를 정의하고, 빌드 타임에 SQL과 타입을 맞추는 경량 ORM입니다. 단순 CRUD를 넘어서 운영 환경에서는 복잡한 조인, 서브쿼리와 CTE, 트랜잭션·락, Prepared Statement, 관계형 데이터 접근 패턴의 성능, 마이그레이션 파이프라인, 그리고 Prisma 등 다른 ORM과의 선택이 문제로 이어집니다. 본문은 PostgreSQL을 기준으로 설명하되, 개념은 MySQL·SQLite 등 Drizzle이 지원하는 드라이버에도 대부분 이전할 수 있습니다.


1. 전제 지식과 Drizzle의 위치

이미 drizzle-orm으로 테이블을 정의하고 db.select().from(...) 수준의 쿼리를 실행해 본 독자를 대상으로 합니다. 스키마 우선(schema-first)으로 pgTable, mysqlTable 등으로 컬럼을 선언하고, drizzle-kit으로 마이그레이션을 생성하는 흐름을 알고 있다고 가정합니다.

Drizzle의 철학은 “SQL에 가깝게 쓰되 타입이 깨지지 않게”에 가깝습니다. 따라서 고급 주제로 갈수록 순수 SQL 지식(EXPLAIN, 격리 수준, 락, 인덱스)이 그대로 실력으로 이어집니다. ORM이 마법을 대신해 주지 않으므로, 실행 계획트랜잭션 경계를 직접 설계해야 합니다.


2. 복잡한 조인과 서브쿼리

2.1 다중 조인과 별칭

동일 테이블을 두 번 조인하거나, 조인 결과에 명시적 별칭이 필요하면 서브쿼리를 as로 이름 붙인 파생 테이블로 쓰는 패턴이 자주 쓰입니다. Drizzle에서는 조인 대상을 sql 보조나 서브쿼리 빌더로 감싼 뒤 .as('alias') 형태로 연결합니다.

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<number>`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이 달라집니다. Drizzle에서는 sql 템플릿으로 DB별 윈도우 함수를 직접 넣거나, 가독성을 위해 뷰(view)를 두는 선택도 흔합니다.

왜 중요한가: 조인 깊이가 늘수록 중복 행 폭발집계 오류(중복 합산)가 생깁니다. 서브쿼리·CTE·윈도우 중 무엇을 쓸지는 데이터 분포와 인덱스에 따라 달라지므로, EXPLAIN ANALYZE로 행 수와 실제 비용을 확인하는 것이 전제입니다.

2.2 EXISTS·IN·상관 서브쿼리

“한 조건을 만족하는 행이 존재하는가”는 exists가, “목록에 포함되는가”는 in이 자연스럽습니다. Drizzle에서는 exists, inArray, notInArray, sql을 조합합니다.

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'))),
      ),
    ),
  );

상관 서브쿼리는 인덱스가 잘 타면 빠르지만, 바깥 행마다 안쪽을 반복하는 형태가 되면 비용이 커집니다. 이 경우 조인으로 바꾸거나, 임시 결과를 먼저 집계하는 편이 낫습니다.

2.3 CTE(WITH)와 가독성

복잡한 리포트 쿼리는 CTE로 단계를 나누면 유지보수에 유리합니다. Drizzle 버전에 따라 with 헬퍼가 제공되므로, 프로젝트의 drizzle-orm 문서를 확인해 공식 with API를 쓰는 것이 좋습니다. CTE가 항상 물리적으로 “임시 테이블”을 만드는 것은 아니며, 옵티마이저가 인라인할 수도 있습니다. 성능은 최종 실행 계획으로 판단합니다.


3. 트랜잭션과 락

3.1 기본 트랜잭션

결제·재고·포인트처럼 여러 테이블이 함께 맞아야 하는 연산은 하나의 트랜잭션으로 묶습니다. Drizzle에서는 드라이버별 db.transaction 콜백에서 tx 객체로 동일 API를 사용합니다.

await db.transaction(async (tx) => {
  const [user] = await tx
    .select()
    .from(users)
    .where(eq(users.id, userId))
    .for('update'); // PostgreSQL: 행 잠금

  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',
  });
});

포인트: throw로 트랜잭션을 롤백시키는 패턴이 일반적입니다. 애플리케이션 예외 타입을 정해 재시도 가능한 오류(데드락 등)와 비즈니스 오류를 구분하면 운영에 유리합니다.

3.2 격리 수준과 데드락

PostgreSQL에서 read committed가 기본이며, 필요 시 세션·트랜잭션 단위로 격리 수준을 올릴 수 있습니다. 격리 수준을 올리면 팬텀·반복 읽기는 줄지만 락 경합·데드락 위험이 커집니다.

실무에서는 다음을 습관화합니다.

  • 같은 순서로 락을 잡기: 예를 들어 항상 user_id 오름차순으로 여러 행을 갱신하면 데드락을 줄일 수 있습니다.
  • 락 보유 시간 최소화: 외부 HTTP 호출은 트랜잭션 밖으로.
  • idempotency 키: 결제·웹훅은 재시도가 붙으므로, 유일 제약 + 멱등 처리로 안전장치를 둡니다.

3.3 SELECT … FOR UPDATE SKIP LOCKED

작업 큐·배치에서 다음 작업을 가져올 때 skip locked 패턴이 쓰입니다. Drizzle에서는 for('update', { skipLocked: true }) 형태(버전별 API 확인)로 매핑합니다. 이 패턴은 경쟁하는 워커가 동시에 같은 행을 집지 않게 해 줍니다.


4. Prepared Statement

4.1 왜 쓰는가

Prepared Statement는 파라미터 바인딩을 강제하여 SQL 인젝션을 줄이고, 동일 SQL을 반복 실행할 때 파싱·계획 캐시 이점을 기대할 수 있습니다. 다만 모든 쿼리에 자동으로 이득이 있는 것은 아니며, 부하 패턴에 따라 달라집니다.

4.2 Drizzle에서의 사용 예

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 });

주의: placeholder 이름·바인딩 객체 형태는 Drizzle 버전과 드라이버에 따라 다를 수 있으므로, 사용 중인 버전 문서를 기준으로 맞춥니다. 서버리스·커넥션 풀 환경에서는 준비문이 연결마다 어떻게 캐시되는지도 확인합니다.

4.3 동적 IN 리스트와의 긴장

파라미터 개수가 요청마다 달라지면 “같은 SQL”이 아니어서 준비문 이점이 줄어듭니다. 이때는 임시 테이블에 ID를 넣고 조인하거나, 배열 파라미터(PostgreSQL = ANY($1::int[])) 같은 DB 기능을 검토합니다.


5. 관계형 쿼리 최적화

5.1 N+1 문제의 본질

N+1은 “부모 N행을 읽은 뒤 자식을 행마다 추가 조회”하는 패턴입니다. Drizzle은 SQL 친화적이라 조인·서브쿼리·배치 로딩을 코드로 명시하는 쪽이 자연스럽습니다. 관계 헬퍼를 쓰더라도 생성되는 SQL 수를 로그·메트릭으로 확인해야 합니다.

5.2 조인으로 한 번에 가져오기

리스트 화면에서 사용자·프로필·최근 주문을 함께 보여 준다면, 한 번의 조인 쿼리로 필요한 컬럼만 선택하는 편이 대체로 예측 가능합니다. select 절에 불필요한 JSON·대용량 텍스트를 넣지 않는 것도 중요합니다.

5.3 배치 로딩(데이터로더)

그래프QL·REST에서 트리 구조로 내려줄 때는 요청 단위로 ID를 모아 inArray 한 번에 가져오는 패턴이 깔끔합니다. 애플리케이션 레이어에서 Promise 캐시를 두어 동일 요청 내 중복 조회를 제거합니다.

5.4 인덱스와 쿼리 형태의 정합성

조인 키·필터 컬럼·정렬 컬럼에 맞는 복합 인덱스가 없으면 ORM을 바꿔도 느립니다. Drizzle을 쓰더라도 EXPLAIN, pg_stat_statements(PostgreSQL), 슬로 쿼리 로그로 상위 쿼리부터 줄입니다.


6. 마이그레이션 전략

6.1 drizzle-kit 워크플로

일반적인 흐름은 다음과 같습니다.

  1. 스키마 파일을 진실 원본으로 유지한다.
  2. drizzle-kit generate로 SQL 마이그레이션을 생성한다.
  3. 스테이징에서 적용·검증 후 프로덕션에 반영한다.

팀 규모가 크면 마이그레이션 리뷰 필수, 다운 마이그레이션 정책(롤백 vs 복구 스크립트)을 문서화합니다.

6.2 제로 다운타임을 깨는 변경

다음은 특히 주의합니다.

  • 타입 변경(문자열 → 숫자 등): 단계적 이중 쓰기·백필이 필요할 수 있습니다.
  • NOT NULL 추가: 기본값·백필 없이 추가하면 테이블 락과 장시간 쿼리를 유발할 수 있습니다.
  • 대형 테이블 인덱스 생성: PostgreSQL의 CONCURRENTLYDB별 온라인 DDL을 검토합니다.

6.3 시드·로컬 개발

로컬은 migrate 후 시드 스크립트로 최소 데이터를 넣고, E2E는 도커 DB + 고정 시드로 재현성을 확보합니다. 프로덕션 데이터를 복제해 오는 방식은 개인정보·보안 이슈가 있으므로 별도 절차가 필요합니다.


7. Prisma vs Drizzle 비교

비교는 “누가 더 좋다”보다 제품·팀 맥락에 맞는지가 중요합니다.

관점PrismaDrizzle
쿼리 스타일고수준 API·관계 탐색이 편리SQL에 가깝고 제어가 세밀
스키마schema.prisma 중심TypeScript 스키마 + SQL 마이그레이션
번들·런타임기능이 풍부한 대신 무게 증가비교적 경량 지향
학습 곡선초반 생산성 높음SQL·인덱스 이해도가 그대로 필요
마이그레이션익숙한 워크플로가 많음팀이 SQL·DDL에 익숙해야 안전

Drizzle이 유리한 경우: SQL·인덱스·실행 계획을 팀이 직접 다루고, 복잡한 리포트·배치가 많으며, 경량 런타임이 중요한 경우.

Prisma가 유리한 경우: CRUD 중심·빠른 프로토타입·관계형 탐색 API를 넓게 쓰고, 마이그레이션·클라이언트 일체화를 우선할 때.

이주 시에는 1:1 기능 대응표를 만들고, 트랜잭션·락·raw SQL·마이그레이션 스크립트를 단계적으로 이전하는 편이 안전합니다.


8. 실전 성능 튜닝 체크리스트

8.1 애플리케이션 계층

  • 커넥션 풀 크기: CPU·DB max_connections·앱 인스턴스 수와 함께 튜닝합니다. 풀이 과하면 DB가 컨텍스트 스위칭으로 느려질 수 있습니다.
  • 트랜잭션 범위: 짧게. 외부 API·파일 I/O는 밖으로.
  • 배치 크기: insert·copy·배치 업데이트는 청크 단위로 나눕니다.
  • 불필요한 select * 금지: Drizzle에서도 명시적 컬럼 선택이 대역폭·디코딩 비용을 줄입니다.

8.2 데이터베이스 계층

  • 인덱스: WHERE·JOIN·ORDER BY에 맞춘 복합 인덱스, 부분 인덱스·표현식 인덱스 검토.
  • 통계 정보: 대량 적재 후 ANALYZE·자동 통계가 제대로 도는지 확인.
  • 파티셔닝: 시계열·대형 로그는 범위 파티셔닝으로 유지보수성을 높일 수 있습니다.

8.3 관측성

  • 슬로 쿼리 로그, APM의 DB 구간, pg_stat_statements로 상위 소비 쿼리를 고정적으로 추적합니다.
  • 캐시(Redis 등)는 일관성 요구사항과 함께 설계합니다. 캐시가 오히려 복잡도를 키우는 경우도 많습니다.

9. 문제 해결 가이드

9.1 예상보다 느린 조인

  • 조인 키 타입·콜레이션 불일치로 인덱스를 못 타는 경우가 있습니다.
  • 선택도가 낮은 컬럼만 걸린 인덱스는 도움이 되지 않을 수 있습니다. 카디널리티를 다시 봅니다.

9.2 간헐적 데드락

  • 트랜잭션 내 갱신 순서를 통일하고, 락 대기 시간 타임아웃·재시도 정책을 둡니다.
  • 동일 비즈니스 키에 대한 동시 요청을 애플리케이션 뮤텍스로 직렬화하는 경우도 있습니다(분산 환경에서는 Redis 등과의 트레이드오프).

9.3 마이그레이션 후 플랜 악화

  • 통계가 오래되었거나, 파라미터 스니핑 이슈일 수 있습니다. 통계 갱신, Prepared vs 일반 실행 비교, 인덱스 사용 여부를 다시 확인합니다.

10. 정리

Drizzle ORM의 심화 주제는 결국 SQL과 트랜잭션 모델을 얼마나 정확히 다루느냐로 귀결됩니다. 복잡한 조인과 서브쿼리는 중복 행·집계 오류를 동반하기 쉬우므로 실행 계획으로 검증하고, 트랜잭션과 락은 경합·데드락·외부 I/O 분리 원칙을 지킵니다. Prepared Statement는 반복 부하에 맞춰 도입하고, 관계형 최적화는 N+1 제거·인덱스·배치 로딩의 삼각형으로 접근합니다. 마이그레이션은 DDL의 운영 영향을 전제로 단계적 전략을 세우며, Prisma와의 선택은 팀의 SQL 역량과 제품 단계에 맡기는 것이 가장 현실적입니다.

배포 전에는 git add·git commit·git pushnpm run deploy를 실행하는 저장소 규칙을 따릅니다.


참고