Prisma Client Extensions 심화

Prisma Client Extensions란?

Prisma Client Extensions는 Prisma 4.16+에서 도입된 기능으로, Prisma Client에 커스텀 메서드, 쿼리 수정, 결과 변환을 타입 안전하게 추가하는 공식 확장 메커니즘입니다. 기존 Prisma Middleware의 한계(전역 적용, 타입 불안전)를 해결하며, 소프트 딜리트, 감사 로그, 멀티테넌시 같은 횡단 관심사를 깔끔하게 구현할 수 있습니다.

이 글에서는 model extensions, client extensions, query extensions, result extensions 네 가지 확장 타입과 실무 패턴을 심화 정리합니다.

Extension 기본 구조

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient().$extends({
  name: 'myExtension',  // 디버깅용 이름 (선택)

  // 1. model: 특정 모델에 커스텀 메서드 추가
  model: { ... },

  // 2. client: PrismaClient 인스턴스에 메서드 추가
  client: { ... },

  // 3. query: 쿼리 실행 전후 로직 주입
  query: { ... },

  // 4. result: 쿼리 결과에 가상 필드/메서드 추가
  result: { ... },
});

// Extensions는 체이닝 가능 — 불변 인스턴스 반환
const extendedPrisma = prisma
  .$extends(softDeleteExtension)
  .$extends(auditLogExtension)
  .$extends(cacheExtension);

Model Extensions: 커스텀 메서드

특정 모델에 비즈니스 로직 메서드를 타입 안전하게 추가합니다.

const prisma = new PrismaClient().$extends({
  model: {
    user: {
      // 이메일로 사용자 찾기 + 존재하지 않으면 에러
      async findByEmailOrThrow(email: string) {
        const user = await prisma.user.findUnique({ where: { email } });
        if (!user) throw new NotFoundException(`User ${email} not found`);
        return user;
      },

      // 활성 사용자만 조회하는 편의 메서드
      async findActive(args?: Prisma.UserFindManyArgs) {
        return prisma.user.findMany({
          ...args,
          where: { ...args?.where, deletedAt: null, status: 'ACTIVE' },
        });
      },

      // 사용자 통계
      async getStats() {
        const [total, active, newThisMonth] = await Promise.all([
          prisma.user.count(),
          prisma.user.count({ where: { status: 'ACTIVE' } }),
          prisma.user.count({
            where: {
              createdAt: {
                gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
              },
            },
          }),
        ]);
        return { total, active, newThisMonth };
      },
    },

    order: {
      // 주문 상태 전이 검증 포함
      async transition(orderId: string, newStatus: OrderStatus) {
        const order = await prisma.order.findUniqueOrThrow({
          where: { id: orderId },
        });

        const validTransitions: Record<OrderStatus, OrderStatus[]> = {
          PENDING: ['CONFIRMED', 'CANCELLED'],
          CONFIRMED: ['SHIPPING', 'CANCELLED'],
          SHIPPING: ['DELIVERED'],
          DELIVERED: [],
          CANCELLED: [],
        };

        if (!validTransitions[order.status]?.includes(newStatus)) {
          throw new Error(`Cannot transition from ${order.status} to ${newStatus}`);
        }

        return prisma.order.update({
          where: { id: orderId },
          data: { status: newStatus, updatedAt: new Date() },
        });
      },
    },
  },
});

// 사용
const user = await prisma.user.findByEmailOrThrow('test@example.com');
const stats = await prisma.user.getStats();
await prisma.order.transition('order-123', 'CONFIRMED');

Query Extensions: 쿼리 가로채기

Middleware를 대체하는 핵심 패턴입니다. 모델·작업별로 세밀하게 적용할 수 있고, 완전한 타입 안전성을 제공합니다.

소프트 딜리트 자동화

const softDeleteExtension = Prisma.defineExtension({
  name: 'softDelete',
  query: {
    // $allModels: 모든 모델에 적용
    $allModels: {
      // delete → update(deletedAt)로 변환
      async delete({ model, operation, args, query }) {
        return query({
          ...args,
          // delete를 update로 변환
        });
        // 실제로는 $allOperations에서 처리
      },

      async $allOperations({ model, operation, args, query }) {
        // 소프트 딜리트 대상 모델 확인
        const softDeleteModels = ['User', 'Order', 'Product'];
        if (!softDeleteModels.includes(model)) return query(args);

        // delete → update로 변환
        if (operation === 'delete') {
          return (prisma as any)[model[0].toLowerCase() + model.slice(1)].update({
            ...args,
            data: { deletedAt: new Date() },
          });
        }

        // deleteMany → updateMany로 변환
        if (operation === 'deleteMany') {
          return (prisma as any)[model[0].toLowerCase() + model.slice(1)].updateMany({
            ...args,
            data: { deletedAt: new Date() },
          });
        }

        // find 계열: deletedAt이 null인 것만 조회
        if (operation.startsWith('find') || operation === 'count') {
          args.where = { ...args.where, deletedAt: null };
          return query(args);
        }

        return query(args);
      },
    },
  },
});

쿼리 성능 로깅

const queryLoggingExtension = Prisma.defineExtension({
  name: 'queryLogging',
  query: {
    $allModels: {
      async $allOperations({ model, operation, args, query }) {
        const start = performance.now();
        const result = await query(args);
        const duration = performance.now() - start;

        // 느린 쿼리 경고 (100ms 이상)
        if (duration > 100) {
          console.warn(
            `[SLOW QUERY] ${model}.${operation} took ${duration.toFixed(1)}ms`,
            JSON.stringify(args).slice(0, 200),
          );
        }

        // 메트릭 수집
        queryMetrics.observe({
          model,
          operation,
          duration,
        });

        return result;
      },
    },
  },
});

멀티테넌시 자동 필터링

function multiTenantExtension(tenantId: string) {
  return Prisma.defineExtension({
    name: 'multiTenant',
    query: {
      $allModels: {
        async $allOperations({ model, operation, args, query }) {
          // 테넌트 필드가 있는 모델만 필터링
          const tenantModels = ['Order', 'Product', 'Invoice'];
          if (!tenantModels.includes(model)) return query(args);

          // 읽기: tenantId 필터 자동 추가
          if (['findMany', 'findFirst', 'findUnique', 'count'].includes(operation)) {
            args.where = { ...args.where, tenantId };
          }

          // 쓰기: tenantId 자동 주입
          if (['create', 'createMany'].includes(operation)) {
            if (args.data) {
              if (Array.isArray(args.data)) {
                args.data = args.data.map((d: any) => ({ ...d, tenantId }));
              } else {
                args.data = { ...args.data, tenantId };
              }
            }
          }

          // 수정/삭제: tenantId 조건 추가 (다른 테넌트 데이터 접근 방지)
          if (['update', 'delete', 'updateMany', 'deleteMany'].includes(operation)) {
            args.where = { ...args.where, tenantId };
          }

          return query(args);
        },
      },
    },
  });
}

// NestJS에서 요청별 테넌트 Prisma 인스턴스 생성
@Injectable({ scope: Scope.REQUEST })
export class TenantPrismaService {
  public readonly client: ReturnType<typeof createTenantClient>;

  constructor(@Inject(REQUEST) private request: Request) {
    const tenantId = request.headers['x-tenant-id'] as string;
    this.client = basePrisma.$extends(multiTenantExtension(tenantId));
  }
}

Result Extensions: 가상 필드

쿼리 결과에 계산된 가상 필드를 타입 안전하게 추가합니다. Prisma Raw SQL 쿼리 심화에서 다뤘던 복잡한 쿼리 결과도 가상 필드로 보강할 수 있습니다.

const resultExtension = Prisma.defineExtension({
  name: 'computedFields',
  result: {
    user: {
      // fullName 가상 필드
      fullName: {
        needs: { firstName: true, lastName: true },
        compute(user) {
          return `${user.firstName} ${user.lastName}`;
        },
      },

      // 프로필 URL
      profileUrl: {
        needs: { id: true, username: true },
        compute(user) {
          return `/users/${user.username || user.id}`;
        },
      },

      // 마스킹된 이메일
      maskedEmail: {
        needs: { email: true },
        compute(user) {
          const [local, domain] = user.email.split('@');
          return `${local[0]}***@${domain}`;
        },
      },
    },

    order: {
      // 주문 총액 (items 필요)
      totalAmount: {
        needs: { subtotal: true, taxRate: true, discountAmount: true },
        compute(order) {
          const tax = Number(order.subtotal) * Number(order.taxRate);
          return Number(order.subtotal) + tax - Number(order.discountAmount);
        },
      },

      // 주문 상태 한글 표시
      statusLabel: {
        needs: { status: true },
        compute(order) {
          const labels: Record<string, string> = {
            PENDING: '주문 대기',
            CONFIRMED: '주문 확인',
            SHIPPING: '배송 중',
            DELIVERED: '배송 완료',
            CANCELLED: '주문 취소',
          };
          return labels[order.status] || order.status;
        },
      },
    },
  },
});

// 사용: 가상 필드가 타입에 자동 포함
const user = await prisma.user.findUnique({ where: { id: 1 } });
console.log(user.fullName);     // "John Doe" — 타입 안전!
console.log(user.maskedEmail);  // "j***@example.com"

Client Extensions: 유틸리티 메서드

const clientExtension = Prisma.defineExtension({
  name: 'clientUtils',
  client: {
    // 트랜잭션 래퍼 with 재시도
    async $transactionWithRetry<T>(
      fn: (tx: Prisma.TransactionClient) => Promise<T>,
      maxRetries = 3,
    ): Promise<T> {
      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          return await (this as any).$transaction(fn, {
            maxWait: 5000,
            timeout: 10000,
            isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
          });
        } catch (error: any) {
          // P2034: 직렬화 실패 시 재시도
          if (error.code === 'P2034' && attempt < maxRetries) {
            await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 100));
            continue;
          }
          throw error;
        }
      }
      throw new Error('Transaction failed after max retries');
    },

    // 커넥션 풀 상태 확인
    async $healthCheck() {
      try {
        await (this as any).$queryRaw`SELECT 1`;
        return { status: 'healthy', timestamp: new Date() };
      } catch (error) {
        return { status: 'unhealthy', error: String(error), timestamp: new Date() };
      }
    },
  },
});

// 사용
const result = await prisma.$transactionWithRetry(async (tx) => {
  await tx.order.update({ where: { id: orderId }, data: { status: 'CONFIRMED' } });
  await tx.inventory.decrement({ where: { productId }, data: { quantity: 1 } });
  return tx.notification.create({ data: { type: 'ORDER_CONFIRMED', orderId } });
});

Extension 합성과 순서

주의사항 설명
체이닝 순서 query extension은 마지막에 추가된 것이 먼저 실행 (LIFO)
불변성 $extends()는 새 인스턴스 반환, 원본은 변경되지 않음
타입 추론 체이닝이 길어지면 TS 컴파일 느려짐 — 3~5개 이내 권장
Middleware 대비 Extensions가 모델별 세밀 제어 가능, Middleware는 deprecated 방향
// 권장: 팩토리 함수로 조합
function createExtendedPrisma(tenantId?: string) {
  let client = new PrismaClient()
    .$extends(resultExtension)     // 1. 가상 필드
    .$extends(softDeleteExtension) // 2. 소프트 딜리트
    .$extends(queryLoggingExtension); // 3. 로깅 (가장 바깥)

  if (tenantId) {
    client = client.$extends(multiTenantExtension(tenantId));
  }

  return client.$extends(clientExtension); // 4. 유틸리티
}

export type ExtendedPrismaClient = ReturnType<typeof createExtendedPrisma>;

마무리

Prisma Client Extensions는 model 커스텀 메서드, query 가로채기, result 가상 필드, client 유틸리티 네 축으로 Prisma를 프로젝트 맞춤형 ORM으로 확장합니다. Middleware 대비 타입 안전성과 모델별 세밀 제어가 가능하므로, 새 프로젝트에서는 Extensions를 기본으로 채택하는 것을 권장합니다.

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