Drizzle ORM 동적 쿼리·필터 심화

동적 쿼리가 필요한 이유

실무 API에서 검색·필터·정렬·페이지네이션은 필수입니다. 사용자가 어떤 필터를 조합할지 미리 알 수 없으므로, 런타임에 조건을 동적으로 조립하는 쿼리 빌더가 필요합니다. Drizzle ORM은 TypeScript의 타입 시스템과 결합하여, 동적이면서도 완전한 타입 안전성을 보장하는 쿼리 작성이 가능합니다.

기본 WHERE 조건 조합

Drizzle의 where 절에 조건을 동적으로 조합하는 핵심 패턴입니다:

import { and, or, eq, like, gte, lte, inArray, isNull, SQL } from 'drizzle-orm';
import { products } from './schema';

interface ProductFilter {
  name?: string;
  categoryId?: number;
  minPrice?: number;
  maxPrice?: number;
  status?: 'active' | 'inactive';
  tags?: string[];
}

async function findProducts(db: DrizzleDB, filter: ProductFilter) {
  const conditions: SQL[] = [];

  if (filter.name) {
    conditions.push(like(products.name, `%${filter.name}%`));
  }
  if (filter.categoryId) {
    conditions.push(eq(products.categoryId, filter.categoryId));
  }
  if (filter.minPrice !== undefined) {
    conditions.push(gte(products.price, filter.minPrice));
  }
  if (filter.maxPrice !== undefined) {
    conditions.push(lte(products.price, filter.maxPrice));
  }
  if (filter.status) {
    conditions.push(eq(products.status, filter.status));
  }
  if (filter.tags?.length) {
    conditions.push(inArray(products.tag, filter.tags));
  }

  return db.select()
    .from(products)
    .where(conditions.length ? and(...conditions) : undefined);
}

핵심은 SQL[] 배열에 조건을 수집한 뒤 and()로 합치는 것입니다. 빈 배열이면 undefined를 전달하여 WHERE 절 자체를 생략합니다.

OR 조건과 복합 필터

AND와 OR을 중첩하여 복잡한 조건을 표현합니다:

interface OrderSearchFilter {
  keyword?: string;        // 주문번호 또는 상품명 검색
  userId?: string;
  status?: string[];
  dateFrom?: Date;
  dateTo?: Date;
}

async function searchOrders(db: DrizzleDB, filter: OrderSearchFilter) {
  const conditions: SQL[] = [];

  // keyword → 주문번호 OR 상품명 검색 (OR 조건)
  if (filter.keyword) {
    conditions.push(
      or(
        like(orders.orderNumber, `%${filter.keyword}%`),
        like(orders.productName, `%${filter.keyword}%`),
      )!,
    );
  }

  if (filter.userId) {
    conditions.push(eq(orders.userId, filter.userId));
  }

  // 다중 상태 필터
  if (filter.status?.length) {
    conditions.push(inArray(orders.status, filter.status));
  }

  // 날짜 범위
  if (filter.dateFrom) {
    conditions.push(gte(orders.createdAt, filter.dateFrom));
  }
  if (filter.dateTo) {
    conditions.push(lte(orders.createdAt, filter.dateTo));
  }

  return db.select()
    .from(orders)
    .where(and(...conditions))
    .orderBy(desc(orders.createdAt));
}

재사용 가능한 필터 빌더

필터 로직을 클래스로 추상화하면 여러 엔티티에서 재사용할 수 있습니다:

import { SQL, and, or, eq, like, gte, lte, inArray, between, isNull, isNotNull } from 'drizzle-orm';
import { PgColumn } from 'drizzle-orm/pg-core';

class QueryFilter {
  private conditions: SQL[] = [];

  eq(column: PgColumn, value: unknown): this {
    if (value !== undefined && value !== null) {
      this.conditions.push(eq(column, value));
    }
    return this;
  }

  like(column: PgColumn, value?: string): this {
    if (value) {
      this.conditions.push(like(column, `%${value}%`));
    }
    return this;
  }

  between(column: PgColumn, min?: number | Date, max?: number | Date): this {
    if (min !== undefined) this.conditions.push(gte(column, min));
    if (max !== undefined) this.conditions.push(lte(column, max));
    return this;
  }

  in(column: PgColumn, values?: unknown[]): this {
    if (values?.length) {
      this.conditions.push(inArray(column, values));
    }
    return this;
  }

  isNull(column: PgColumn, shouldBeNull?: boolean): this {
    if (shouldBeNull === true) this.conditions.push(isNull(column));
    if (shouldBeNull === false) this.conditions.push(isNotNull(column));
    return this;
  }

  build(): SQL | undefined {
    return this.conditions.length ? and(...this.conditions) : undefined;
  }
}

// 사용
async function findProducts(db: DrizzleDB, filter: ProductFilter) {
  const where = new QueryFilter()
    .like(products.name, filter.name)
    .eq(products.categoryId, filter.categoryId)
    .between(products.price, filter.minPrice, filter.maxPrice)
    .in(products.tag, filter.tags)
    .eq(products.status, filter.status)
    .build();

  return db.select().from(products).where(where);
}

동적 정렬

클라이언트가 정렬 필드와 방향을 지정할 수 있는 패턴입니다:

import { asc, desc, SQL } from 'drizzle-orm';
import { PgColumn } from 'drizzle-orm/pg-core';

type SortDirection = 'asc' | 'desc';

interface SortOption {
  field: string;
  direction: SortDirection;
}

// 허용된 정렬 필드 매핑 (SQL Injection 방지)
const SORTABLE_FIELDS: Record<string, PgColumn> = {
  name: products.name,
  price: products.price,
  createdAt: products.createdAt,
  rating: products.rating,
};

function buildOrderBy(sorts: SortOption[]): SQL[] {
  return sorts
    .filter(s => SORTABLE_FIELDS[s.field])  // 허용된 필드만
    .map(s => {
      const column = SORTABLE_FIELDS[s.field];
      return s.direction === 'desc' ? desc(column) : asc(column);
    });
}

// 사용
const orderByClause = buildOrderBy([
  { field: 'price', direction: 'desc' },
  { field: 'name', direction: 'asc' },
]);

const results = await db.select()
  .from(products)
  .where(whereClause)
  .orderBy(...orderByClause);

커서 기반 페이지네이션

대규모 데이터에서 offset 페이지네이션은 성능 문제가 있습니다. 커서 기반이 권장됩니다:

interface CursorPaginationParams {
  cursor?: string;  // 마지막 항목의 ID (base64 인코딩)
  limit: number;
}

interface PaginatedResult<T> {
  data: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

async function findProductsPaginated(
  db: DrizzleDB,
  filter: ProductFilter,
  pagination: CursorPaginationParams,
): Promise<PaginatedResult<Product>> {
  const conditions: SQL[] = [];

  // 기존 필터 조건 추가
  const filterWhere = new QueryFilter()
    .like(products.name, filter.name)
    .eq(products.categoryId, filter.categoryId)
    .build();

  if (filterWhere) conditions.push(filterWhere);

  // 커서 조건
  if (pagination.cursor) {
    const cursorId = Buffer.from(pagination.cursor, 'base64').toString();
    conditions.push(gt(products.id, cursorId));
  }

  const results = await db.select()
    .from(products)
    .where(conditions.length ? and(...conditions) : undefined)
    .orderBy(asc(products.id))
    .limit(pagination.limit + 1);  // 1개 더 조회하여 hasMore 판단

  const hasMore = results.length > pagination.limit;
  const data = hasMore ? results.slice(0, -1) : results;
  const lastItem = data[data.length - 1];

  return {
    data,
    nextCursor: lastItem
      ? Buffer.from(lastItem.id).toString('base64')
      : null,
    hasMore,
  };
}

서브쿼리와 집계

동적 필터와 서브쿼리를 조합하는 고급 패턴입니다:

// 주문 수가 N건 이상인 사용자 필터링
async function findActiveUsers(db: DrizzleDB, minOrders: number) {
  const orderCountSubquery = db
    .select({
      userId: orders.userId,
      orderCount: count(orders.id).as('order_count'),
    })
    .from(orders)
    .where(eq(orders.status, 'completed'))
    .groupBy(orders.userId)
    .as('order_stats');

  return db.select({
    id: users.id,
    name: users.name,
    email: users.email,
    orderCount: orderCountSubquery.orderCount,
  })
  .from(users)
  .innerJoin(
    orderCountSubquery,
    eq(users.id, orderCountSubquery.userId),
  )
  .where(gte(orderCountSubquery.orderCount, minOrders))
  .orderBy(desc(orderCountSubquery.orderCount));
}

// 동적 집계 쿼리
async function getProductStats(
  db: DrizzleDB,
  categoryId?: number,
  dateFrom?: Date,
) {
  const conditions: SQL[] = [];
  if (categoryId) conditions.push(eq(products.categoryId, categoryId));
  if (dateFrom) conditions.push(gte(products.createdAt, dateFrom));

  return db.select({
    category: products.categoryId,
    avgPrice: avg(products.price).as('avg_price'),
    maxPrice: max(products.price).as('max_price'),
    totalProducts: count(products.id).as('total'),
  })
  .from(products)
  .where(conditions.length ? and(...conditions) : undefined)
  .groupBy(products.categoryId);
}

NestJS 서비스 통합

NestJS 서비스에서 동적 쿼리를 깔끔하게 통합합니다:

// product.dto.ts
export class FindProductsDto {
  @IsOptional() @IsString()
  name?: string;

  @IsOptional() @IsInt()
  categoryId?: number;

  @IsOptional() @IsNumber()
  minPrice?: number;

  @IsOptional() @IsNumber()
  maxPrice?: number;

  @IsOptional() @IsIn(['active', 'inactive'])
  status?: 'active' | 'inactive';

  @IsOptional() @IsString()
  sortBy?: string = 'createdAt';

  @IsOptional() @IsIn(['asc', 'desc'])
  sortDir?: 'asc' | 'desc' = 'desc';

  @IsOptional() @IsString()
  cursor?: string;

  @IsOptional() @IsInt() @Min(1) @Max(100)
  limit?: number = 20;
}

// product.service.ts
@Injectable()
export class ProductService {
  constructor(
    @Inject(DRIZZLE) private readonly db: DrizzleDB,
  ) {}

  async findAll(dto: FindProductsDto): Promise<PaginatedResult<Product>> {
    const where = new QueryFilter()
      .like(products.name, dto.name)
      .eq(products.categoryId, dto.categoryId)
      .between(products.price, dto.minPrice, dto.maxPrice)
      .eq(products.status, dto.status)
      .build();

    const orderBy = buildOrderBy([
      { field: dto.sortBy ?? 'createdAt', direction: dto.sortDir ?? 'desc' },
    ]);

    return this.paginateWithCursor(where, orderBy, {
      cursor: dto.cursor,
      limit: dto.limit ?? 20,
    });
  }
}

SQL Injection 방지

Drizzle ORM은 파라미터화된 쿼리를 자동 생성하므로 기본적으로 안전합니다. 하지만 raw SQL을 사용할 때는 반드시 sql 템플릿 리터럴을 사용하세요:

import { sql } from 'drizzle-orm';

// ✅ 안전 — 파라미터화됨
const result = await db.select()
  .from(products)
  .where(sql`${products.name} ILIKE ${`%${searchTerm}%`}`);

// ❌ 위험 — 절대 사용 금지
// .where(sql.raw(`name LIKE '%${searchTerm}%'`))

정리

Drizzle ORM의 동적 쿼리는 SQL[] 배열 패턴과 and()/or() 조합으로 구현합니다. QueryFilter 빌더로 재사용성을 높이고, 화이트리스트 기반 동적 정렬로 보안을 확보하며, 커서 페이지네이션으로 대규모 데이터를 효율적으로 처리합니다. TypeScript 타입 시스템이 컴파일 타임에 쿼리 오류를 잡아주므로, 동적이면서도 안전한 쿼리 작성이 가능합니다.

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