Prisma Middleware 실전 패턴

Prisma Middleware란?

Prisma Middleware는 모든 Prisma Client 쿼리를 가로채서 전처리·후처리하는 함수입니다. Express 미들웨어처럼 next()를 호출하여 다음 단계로 넘기고, 그 결과를 변환할 수 있습니다. 로깅, soft delete, 감사(audit), 성능 측정 등 횡단 관심사를 한 곳에서 처리합니다.

Prisma 4.16+에서는 Middleware 대신 Client Extensionquery 컴포넌트를 권장하지만, Middleware는 여전히 유효하며 더 단순한 API를 제공합니다.

Middleware 기본 구조

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

const prisma = new PrismaClient();

// Middleware 등록
prisma.$use(async (params: Prisma.MiddlewareParams, next) => {
  // params.model  — "User", "Post" 등 모델명
  // params.action — "findMany", "create", "update" 등 액션
  // params.args   — where, data, select 등 쿼리 인자

  console.log(`${params.model}.${params.action} 실행`);

  const before = Date.now();
  const result = await next(params);  // 실제 쿼리 실행
  const after = Date.now();

  console.log(`${params.model}.${params.action}: ${after - before}ms`);

  return result;  // 결과 반환 (변환 가능)
});

실전 패턴 1: Soft Delete

데이터를 실제로 삭제하지 않고 deletedAt 필드를 채우는 패턴입니다.

// schema.prisma
model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  deletedAt DateTime? // null이면 활성, 값이 있으면 삭제됨
}

// soft-delete.middleware.ts
const softDeleteModels = ['User', 'Post', 'Comment'];

prisma.$use(async (params, next) => {
  if (!softDeleteModels.includes(params.model ?? '')) {
    return next(params);
  }

  // DELETE → UPDATE (deletedAt 설정)
  if (params.action === 'delete') {
    params.action = 'update';
    params.args['data'] = { deletedAt: new Date() };
    return next(params);
  }

  // deleteMany → updateMany
  if (params.action === 'deleteMany') {
    params.action = 'updateMany';
    if (params.args.data) {
      params.args.data['deletedAt'] = new Date();
    } else {
      params.args['data'] = { deletedAt: new Date() };
    }
    return next(params);
  }

  // 조회 시 삭제된 레코드 자동 제외
  if (['findFirst', 'findMany', 'findUnique', 'count'].includes(params.action)) {
    if (!params.args) params.args = {};
    if (!params.args.where) params.args.where = {};

    // 이미 deletedAt 조건이 있으면 건드리지 않음 (명시적 포함 허용)
    if (params.args.where.deletedAt === undefined) {
      params.args.where.deletedAt = null;
    }
    return next(params);
  }

  // update/updateMany도 삭제된 레코드 제외
  if (['update', 'updateMany'].includes(params.action)) {
    if (!params.args.where) params.args.where = {};
    if (params.args.where.deletedAt === undefined) {
      params.args.where.deletedAt = null;
    }
    return next(params);
  }

  return next(params);
});

// 사용 — 개발자는 soft delete를 의식할 필요 없음
await prisma.user.delete({ where: { id: 1 } });
// 실제 SQL: UPDATE user SET deletedAt = NOW() WHERE id = 1

await prisma.user.findMany();
// 실제 SQL: SELECT * FROM user WHERE deletedAt IS NULL

// 삭제된 레코드도 포함해서 조회 (관리자용)
await prisma.user.findMany({ where: { deletedAt: { not: null } } });

실전 패턴 2: 쿼리 성능 모니터링

// slow-query.middleware.ts
const SLOW_QUERY_THRESHOLD = 500; // ms

prisma.$use(async (params, next) => {
  const start = performance.now();
  const result = await next(params);
  const duration = performance.now() - start;

  if (duration > SLOW_QUERY_THRESHOLD) {
    console.warn(`🐌 Slow Query: ${params.model}.${params.action} (${duration.toFixed(0)}ms)`, {
      model: params.model,
      action: params.action,
      args: JSON.stringify(params.args, null, 2).substring(0, 500),
      duration: Math.round(duration),
    });

    // 프로덕션에서는 모니터링 시스템에 전송
    // await metrics.recordSlowQuery(params.model, params.action, duration);
  }

  return result;
});

실전 패턴 3: 감사 로그(Audit Trail)

// audit.middleware.ts
const auditModels = ['User', 'Order', 'Payment'];
const auditActions = ['create', 'update', 'delete', 'updateMany', 'deleteMany'];

prisma.$use(async (params, next) => {
  if (!auditModels.includes(params.model ?? '') ||
      !auditActions.includes(params.action)) {
    return next(params);
  }

  // 변경 전 상태 캡처 (update/delete인 경우)
  let before = null;
  if (['update', 'delete'].includes(params.action) && params.args.where) {
    before = await prisma[params.model.toLowerCase()].findUnique({
      where: params.args.where,
    });
  }

  const result = await next(params);

  // 감사 로그 저장 (비동기 — 메인 쿼리 성능 영향 없음)
  setImmediate(async () => {
    try {
      await prisma.auditLog.create({
        data: {
          model: params.model,
          action: params.action,
          recordId: String(result?.id ?? params.args?.where?.id ?? 'bulk'),
          before: before ? JSON.stringify(before) : null,
          after: result ? JSON.stringify(result) : null,
          changedFields: params.args?.data ? Object.keys(params.args.data) : [],
          timestamp: new Date(),
          // userId는 AsyncLocalStorage로 주입 (아래 패턴 참고)
        },
      });
    } catch (e) {
      console.error('Audit log failed:', e);
    }
  });

  return result;
});

Middleware에 컨텍스트 전달: AsyncLocalStorage

Middleware는 HTTP 요청 컨텍스트(현재 사용자 등)에 직접 접근할 수 없습니다. AsyncLocalStorage를 사용하면 요청 스코프 데이터를 Middleware에서 읽을 수 있습니다.

import { AsyncLocalStorage } from 'async_hooks';

interface RequestContext {
  userId: string;
  requestId: string;
  ip: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

// NestJS Middleware에서 컨텍스트 설정
@Injectable()
export class ContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const context: RequestContext = {
      userId: req.user?.id ?? 'anonymous',
      requestId: req.headers['x-request-id'] as string ?? randomUUID(),
      ip: req.ip,
    };
    requestContext.run(context, () => next());
  }
}

// Prisma Middleware에서 읽기
prisma.$use(async (params, next) => {
  const ctx = requestContext.getStore();

  if (params.action === 'create' && params.args.data) {
    // 자동으로 createdBy 필드 설정
    params.args.data.createdBy = ctx?.userId ?? 'system';
  }

  if (['update', 'updateMany'].includes(params.action) && params.args.data) {
    params.args.data.updatedBy = ctx?.userId ?? 'system';
  }

  return next(params);
});

Middleware 실행 순서와 조합

// 등록 순서대로 실행 (스택 구조)
prisma.$use(loggingMiddleware);      // 1번째 실행, 마지막에 결과 받음
prisma.$use(softDeleteMiddleware);   // 2번째 실행
prisma.$use(auditMiddleware);        // 3번째 실행, 가장 먼저 결과 받음

// 실행 흐름:
// logging.before → softDelete.before → audit.before
//   → 실제 쿼리 →
// audit.after → softDelete.after → logging.after

주의사항:

  • Middleware가 많으면 모든 쿼리에 오버헤드가 추가됩니다
  • params.actionparams.model로 필터링하여 불필요한 처리를 피하세요
  • Middleware 내에서 같은 Prisma Client로 쿼리하면 무한 루프 위험 — 별도 Client 인스턴스 사용

Middleware vs Client Extension query

기능 Middleware ($use) Client Extension (query)
타입 안전성 약함 (params.args: any) 강함 (모델별 타입 추론)
모델 필터링 문자열 비교 모델별 함수 정의
조합 순서 의존적 $extends 체이닝
권장 시점 전체 모델 공통 로직 모델별 세밀한 제어
// Client Extension 방식 (Prisma 4.16+)
const xprisma = prisma.$extends({
  query: {
    user: {
      async findMany({ model, operation, args, query }) {
        // 타입 안전한 args
        args.where = { ...args.where, deletedAt: null };
        return query(args);
      },
      async delete({ model, operation, args, query }) {
        // delete → update로 변환
        return prisma.user.update({
          where: args.where,
          data: { deletedAt: new Date() },
        });
      },
    },
  },
});

Prisma Client Extension에서 다룬 result, model 컴포넌트와 함께 사용하면 더욱 강력합니다. 새 프로젝트라면 Extension, 기존 프로젝트의 전역 로직이라면 Middleware가 적합합니다.

NestJS 통합: PrismaService에 Middleware 등록

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  constructor() {
    super({
      log: [
        { level: 'query', emit: 'event' },
        { level: 'error', emit: 'stdout' },
      ],
    });

    // Middleware 등록
    this.$use(this.softDeleteMiddleware);
    this.$use(this.performanceMiddleware);
    this.$use(this.auditMiddleware);
  }

  async onModuleInit() {
    await this.$connect();
  }

  private softDeleteMiddleware: Prisma.Middleware = async (params, next) => {
    // ... soft delete 로직
    return next(params);
  };

  private performanceMiddleware: Prisma.Middleware = async (params, next) => {
    // ... 성능 측정 로직
    return next(params);
  };

  private auditMiddleware: Prisma.Middleware = async (params, next) => {
    // ... 감사 로그 로직
    return next(params);
  };
}

Prisma Migrate 운영 전략으로 스키마를 관리하면서, Middleware로 런타임 동작을 제어하면 완전한 Prisma 운영 체계가 됩니다.

정리

Prisma Middleware의 핵심은 “모든 쿼리에 공통 로직을 투명하게 적용”하는 것입니다. Soft delete로 데이터 보호, 성능 모니터링으로 느린 쿼리 탐지, 감사 로그로 변경 추적, AsyncLocalStorage로 요청 컨텍스트 주입까지 — Middleware 하나로 횡단 관심사를 깔끔하게 분리할 수 있습니다. Prisma 4.16+ 환경이라면 모델별 세밀한 제어는 Client Extension으로, 전역 공통 로직은 Middleware로 역할을 나누세요.

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