Prisma Client Extension 심화

Prisma Client Extension이란?

Prisma 4.16에서 도입된 Client Extension은 Prisma Client에 커스텀 기능을 추가하는 공식 확장 메커니즘입니다. Middleware의 한계를 극복하고, 타입 안전한 방식으로 소프트 삭제, 감사 로그, 멀티테넌시 등을 구현할 수 있습니다.

기존 Middleware는 모든 쿼리에 글로벌로 적용되어 성능 이슈와 타입 추론 문제가 있었지만, Extension은 필요한 모델에만 선택적으로 적용하고 완전한 TypeScript 타입 지원을 제공합니다.

Extension 4가지 컴포넌트

컴포넌트 역할 용도
model 모델에 커스텀 메서드 추가 도메인 로직, 헬퍼 메서드
client 클라이언트에 메서드 추가 유틸리티, 커넥션 관리
query 쿼리 실행 전·후 가로채기 소프트 삭제, 필터링
result 결과에 가상 필드 추가 computed field, 포맷팅

model: 커스텀 메서드 추가

모델에 비즈니스 로직을 직접 추가합니다. Repository 패턴을 Prisma 네이티브로 구현하는 방식입니다:

const prisma = new PrismaClient().$extends({
  model: {
    user: {
      async findByEmail(email: string) {
        return prisma.user.findUnique({
          where: { email },
          include: { profile: true },
        });
      },

      async activate(id: number) {
        return prisma.user.update({
          where: { id },
          data: {
            status: 'ACTIVE',
            activatedAt: new Date(),
          },
        });
      },

      async findActiveUsers(page = 1, size = 20) {
        const [users, total] = await Promise.all([
          prisma.user.findMany({
            where: { status: 'ACTIVE' },
            skip: (page - 1) * size,
            take: size,
            orderBy: { createdAt: 'desc' },
          }),
          prisma.user.count({ where: { status: 'ACTIVE' } }),
        ]);
        return { users, total, pages: Math.ceil(total / size) };
      },
    },
  },
});

// 사용
const user = await prisma.user.findByEmail('theo@test.com');
const active = await prisma.user.findActiveUsers(1, 10);

타입이 완전히 추론되므로 user의 타입이 User & { profile: Profile } | null로 정확히 나옵니다.

result: 가상 필드(Computed Field)

DB에 없는 필드를 쿼리 결과에 추가합니다:

const prisma = new PrismaClient().$extends({
  result: {
    user: {
      fullName: {
        needs: { firstName: true, lastName: true },
        compute(user) {
          return `${user.firstName} ${user.lastName}`;
        },
      },
      isAdult: {
        needs: { birthDate: true },
        compute(user) {
          const age = Math.floor(
            (Date.now() - user.birthDate.getTime()) /
            (365.25 * 24 * 60 * 60 * 1000)
          );
          return age >= 18;
        },
      },
      maskedEmail: {
        needs: { email: true },
        compute(user) {
          const [local, domain] = user.email.split('@');
          return `${local.slice(0, 2)}***@${domain}`;
        },
      },
    },
  },
});

// 사용 — 타입 안전!
const user = await prisma.user.findFirst();
console.log(user.fullName);     // "Theo Kei"
console.log(user.isAdult);      // true
console.log(user.maskedEmail);  // "th***@test.com"

needs에 명시한 필드가 쿼리에 자동 포함됩니다. select에서 fullName만 선택해도 firstNamelastName이 함께 조회됩니다.

query: 쿼리 가로채기

Middleware를 대체하는 핵심 기능입니다. 모델별·작업별로 세밀하게 쿼리를 가로챕니다:

소프트 삭제 구현

const prisma = new PrismaClient().$extends({
  query: {
    // 모든 모델에 적용
    $allModels: {
      // delete → soft delete로 변환
      async delete({ model, operation, args, query }) {
        return query({
          ...args,
          // delete 대신 update로 변환 불가 → findMany 등에서 필터링
        });
      },

      // 조회 시 삭제된 레코드 자동 제외
      async findMany({ model, operation, args, query }) {
        args.where = {
          ...args.where,
          deletedAt: null,
        };
        return query(args);
      },

      async findFirst({ model, operation, args, query }) {
        args.where = {
          ...args.where,
          deletedAt: null,
        };
        return query(args);
      },
    },

    // 특정 모델만 커스텀
    post: {
      async delete({ args, query }) {
        // Post는 실제 삭제 대신 soft delete
        return prisma.post.update({
          where: args.where,
          data: { deletedAt: new Date() },
        });
      },
    },
  },
});

감사 로그 자동화

const prisma = new PrismaClient().$extends({
  query: {
    $allModels: {
      async create({ model, args, query }) {
        const result = await query(args);
        await prisma.auditLog.create({
          data: {
            model,
            action: 'CREATE',
            entityId: String(result.id),
            payload: JSON.stringify(args.data),
            timestamp: new Date(),
          },
        });
        return result;
      },

      async update({ model, args, query }) {
        const result = await query(args);
        await prisma.auditLog.create({
          data: {
            model,
            action: 'UPDATE',
            entityId: String(result.id),
            payload: JSON.stringify(args.data),
            timestamp: new Date(),
          },
        });
        return result;
      },
    },
  },
});

client: 클라이언트 레벨 확장

Prisma Client 자체에 유틸리티 메서드를 추가합니다:

const prisma = new PrismaClient().$extends({
  client: {
    async $totalCount() {
      const [users, posts, comments] = await Promise.all([
        prisma.user.count(),
        prisma.post.count(),
        prisma.comment.count(),
      ]);
      return { users, posts, comments };
    },

    async $healthCheck() {
      try {
        await prisma.$queryRaw`SELECT 1`;
        return { status: 'ok', timestamp: new Date() };
      } catch (e) {
        return { status: 'error', error: e.message };
      }
    },
  },
});

// 사용
const stats = await prisma.$totalCount();
const health = await prisma.$healthCheck();

Extension 합성

여러 Extension을 체이닝해서 조합할 수 있습니다:

// 각 Extension을 함수로 분리
function softDelete() {
  return Prisma.defineExtension({
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, deletedAt: null };
          return query(args);
        },
      },
    },
  });
}

function auditLog() {
  return Prisma.defineExtension({
    query: {
      $allModels: {
        async create({ model, args, query }) {
          const result = await query(args);
          // 감사 로그 저장...
          return result;
        },
      },
    },
  });
}

function computedFields() {
  return Prisma.defineExtension({
    result: {
      user: {
        fullName: {
          needs: { firstName: true, lastName: true },
          compute: (u) => `${u.firstName} ${u.lastName}`,
        },
      },
    },
  });
}

// 합성
const prisma = new PrismaClient()
  .$extends(softDelete())
  .$extends(auditLog())
  .$extends(computedFields());

Prisma.defineExtension()으로 정의하면 타입 추론이 정확하고, npm 패키지로 배포할 수도 있습니다. 이 패턴은 Prisma Migrate 운영 전략에서 다룬 모듈화 접근법과 일맥상통합니다.

멀티테넌시 Extension

Row-Level Security를 Extension으로 구현하는 실전 패턴입니다:

function withTenant(tenantId: string) {
  return Prisma.defineExtension({
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
        async findFirst({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
        async create({ args, query }) {
          args.data = { ...args.data, tenantId };
          return query(args);
        },
        async update({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
        async delete({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
      },
    },
  });
}

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

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

이 패턴은 NestJS 멀티테넌시 설계에서 다룬 Row-Level 격리 방식을 Prisma Extension으로 깔끔하게 구현한 것입니다.

Middleware vs Extension 비교

항목 Middleware Extension (query)
타입 안전 ❌ params.args는 any ✅ 모델별 타입 추론
적용 범위 모든 쿼리에 글로벌 모델·작업별 선택적
합성 $use() 순서 의존 $extends() 체이닝
result 확장 ❌ 불가 ✅ computed field 지원
배포 프로젝트 내부 한정 npm 패키지로 배포 가능

Middleware는 Prisma 5에서도 유지되지만, 새 프로젝트에서는 Extension을 우선 사용하는 것이 권장됩니다.

마무리

Prisma Client Extension은 ORM 레벨에서 소프트 삭제, 감사 로그, 멀티테넌시, 가상 필드 등을 타입 안전하게 구현하는 강력한 도구입니다. model로 도메인 메서드를, result로 computed field를, query로 쿼리 가로채기를, client로 유틸리티를 추가하고, Prisma.defineExtension()으로 재사용 가능한 모듈로 분리하면 유지보수성이 크게 향상됩니다.

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