Prisma Middleware란?
Prisma Middleware는 모든 Prisma Client 쿼리를 가로채서 전처리·후처리하는 함수입니다. Express 미들웨어처럼 next()를 호출하여 다음 단계로 넘기고, 그 결과를 변환할 수 있습니다. 로깅, soft delete, 감사(audit), 성능 측정 등 횡단 관심사를 한 곳에서 처리합니다.
Prisma 4.16+에서는 Middleware 대신 Client Extension의 query 컴포넌트를 권장하지만, 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.action과params.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로 역할을 나누세요.