Prisma soft delete 직접 구현 시행착오
1. 배경
초기에는 라이브러리를 사용했으나, Prisma Client 옵션과 내부 동작 문제로 직접 구현을 선택했다.
- Express + TypeScript 환경
- Prisma v6.19.0 사용 (use middleware 제거된 상태)
- 일부 모델에만 soft delete 적용 필요
- previewFeatures = ["strictUndefinedChecks"] Prisma Client 설정
- 초반에 사용한 soft delete 라이브러리 - prisma-extension-soft-delete (opens in a new tab)
주요 문제점
- nest 관련 자료는 많으나 express 자료를 찾기에 어려움을 느꼈다.
- Prisma 공식 문서의 middleware 관련 안내가 있었으나 현재는 사라진 것으로 보인다. (use 사라지면서 같이 문서도 지워진 것 같음)
- 처음에는 use 제거를 모르고 use 사용 코드를 참고했다, 블로그를 따라해봐도 에러가 계속 떠 있어서 찾아보니 use는 v4.16.0. 버전부터 사용 중지되었다.
- 최신 버전에서 완전 지워짐, Prisma Client extensions를 사용하라고 안내되어 있다.
- 공식문서를 참고해서 진행하려고 했는데 공식에서는 하나의 모델 또는 전체모델 대상으로 하는 예시 코드가 나와있었다.
- 몇개의 모델들만 선택해서 soft delete를 적용하려고 하다보니 시행착오가 있었다.
2. 라이브러리 이용
설치 - npm install prisma-extension-soft-delete
src/utils/prisma.ts
import { PrismaClient } from '@prisma/client';
import { createSoftDeleteExtension } from 'prisma-extension-soft-delete';
const prisma = new PrismaClient().$extends(
createSoftDeleteExtension({
models: {
User: true,
Device: true,
RefreshToken: true,
},
defaultConfig: {
field: 'deletedAt',
createValue: (deleted) => {
if (deleted) return new Date();
return null;
},
},
}),
);
export default prisma;기본적으로 삭제가 됐을 때 deletedAt 데이터 업데이트를 이용한 soft delete를 적용시켜놨는데,
로그인에 필요한 JWT 토큰을 삭제할 때는 삭제된 이유를 같이 DB에 저장하기 위해서 추가적인 $extends를 사용해서 custom 메서드 deleteWithReason을 구현했다.
const basePrisma = new PrismaClient().$extends(
/* ... */
// 리프레시 토큰 삭제 확장
const prisma = basePrisma.$extends({
model: {
refreshToken: {
async deleteWithReason(args: { where: { jti: string }; reason: DeletedTokenReason }) {
return await basePrisma.refreshToken.update({
where: args.where,
data: {
reason: args.reason,
deletedAt: new Date(),
},
});
},
},
},
});
export default prisma;3. 문제 발생
데이터 업데이트를 위해서 Prisma.skip 사용 했을 때 에러가 발생했다.
원인
schema.prisma에서 previewFeatures = ["strictUndefinedChecks"] 설정 후 ?? Prisma.skip를 사용하면 빈 객체는 업데이트 안 하고 스킵되어야 했다. 하지만 soft delete 확장과 함께 사용 시 에러가 발생했다
임시 방편
skipRemover 확장을 추가하여 update, updateMany에서 빈 객체를 제거하는 removeSkipFields 함수를 구현했다.
const basePrisma = new PrismaClient()
.$extends({
name: 'skipRemover',
query: {
$allModels: {
async update({ args, query }) {
if (args.data) {
args.data = removeSkipFields(args.data);
}
return query(args);
},
// updateMany도 동일하게 처리
},
},
})
.$extends(createSoftDeleteExtension({ /* ... */ }));
// 빈 객체 필드를 제거하는 함수
const removeSkipFields = <T>(data: T): T => {
// 빈 객체인 필드만 제거하고 나머지는 유지
};4. 피드백
skip이 안 되는 이유와 임시 방편에 대한 것을 멘토님께 여쭤본 결과
라이브러리가 의존하는 하위 라이브러리에서 업데이트 작업 중 데이터를 깊은 복사하면서 데이터가 변경된다는 것을 알게되었다.
이후 soft delete를 라이브러리 사용하지 말고 직접 구현해보라는 의견을 주셨고 직접 구현해보기 시작했다.
5. 구현 시작
구글 검색과 여러 블로그들을 참고하면서 prisma의 use를 이용했는데 에러가 발생했고, 최신 버전에서 완전히 제거됐다는 것을 알게 되었다. $extension을 이용하라고 한다.
관련 자료를 찾아본 결과
extends에 각각 모델마다 작업을 작성해주거나$allModels를 이용한다는 것을 알게 되었다.- 나는 몇몇 개의 모델에만 동일하게 적용하고 싶었기에 완전히 맞는 참고 자료를 찾기 힘들었다.
- 전체 모델 적용 참고: Medium - Implementing soft delete in Prisma (opens in a new tab)
1차 구현
softDelete() 함수를 만들어서 원하는 모델에만 적용했다.
import { DeletedTokenReason, Prisma, PrismaClient } from '@prisma/client';
type UpdateDelegate<T> = {
update(
args: Prisma.Args<T, 'update'>
): Prisma.Result<T, Prisma.Args<T, 'update'>, 'update'>;
};
export const softDelete = () => {
return {
async delete<T>(
this: T,
args: Prisma.Args<T, 'delete'> & { reason?: DeletedTokenReason}
): Promise<Prisma.Result<T, Prisma.Args<T, 'update'>, 'update'>> {
const ctx =
Prisma.getExtensionContext(this) as unknown as UpdateDelegate<T>;
return ctx.update({
where: args.where,
data: {
deletedAt: new Date(),
...(args.reason ? { reason: args.reason } : {}),
},
} as Prisma.Args<T, 'update'>);
},
};
}
const prisma = new PrismaClient().$extends({
model: {
user: softDelete(),
device: softDelete(),
refreshToken: softDelete(),
},
});
export default prisma;특징
Prisma.getExtensionContext를 사용해 타입 안전성 확보delete작업을update로 변환reason파라미터 추가 지원
단점
- 읽기 작업이나 deleteMany 등은 아직 구현하지 않음
- 각 모델마다 동일한 함수를 반복해서 적용해야 함
- 새로운 모델 추가 시 매번
softDelete()추가 필요 - 유지보수성이 떨어짐
- 새로운 모델 추가 시 매번
문제
모델별 타입 지정의 복잡성
발견한 문제
각 모델마다 함수를 지정하는 방식은 신경쓸 게 너무 많음:
- 하나하나 타입을 지정하려면 너무 복잡함
- 필요한 작업들:
delete,deleteMany,findMany,findFirst,findUnique,update,updateMany,findUniqueOrThrow,findFirstOrThrow,count,aggregate,upsert,include,select - 안전장치 구현 필요: ToOne 관계 업데이트 방지, 복합 고유 인덱스 처리 등
새로운 접근 방법 모색
참고 자료:
- Prisma Client Extensions 공식 문서 (opens in a new tab)
- StackOverflow: Prisma soft delete with $extends (opens in a new tab)
- Prisma: Add custom method to all models (opens in a new tab)
방향 전환
$allModels로 만들되, 모델 이름을 가져와서 리스트에 있는 모델에만 적용하는 방식으로 개발
const SOFT_DELETE_MODELS = new Set(['User', 'Device', 'RefreshToken', /* ... */]);
export const softDelete = Prisma.defineExtension({
query: {
$allModels: {
async delete({ model, args, query }) {
if (!SOFT_DELETE_MODELS.has(model)) return query(args);
// 문제: query.update()를 호출하려 했으나 런타임 에러 발생
return (query as any).update({ ...args, data: { deletedAt: new Date() } });
},
},
},
});단점
- 빌드에는 문제 없음
- 런타임 에러 발생
/* eslint-disable @typescript-eslint/no-explicit-any */사용- 타입 안전성을 포기하고
any타입 사용 - TypeScript의 장점을 살리지 못함
- 타입 안전성을 포기하고
query as any타입 캐스팅delete를update로,deleteMany를updateMany로 변환할 때 타입 에러 발생- 이를 우회하기 위해
any로 캐스팅 - 실제로 런타임에서 작동하지 않음
실패 원인 분석
모델 익스텐션에서 쿼리 메서드(delete, deleteMany)를 직접 호출하려고 했으나, 이는 쿼리 익스텐션의 영역이었다, extensions만 생각하고 있었지 어떤 유형이 필요할지 생각하지 못했다. (model, client, query, result 4개의 구성 요소에서 하나 이상을 사용해 확장을 만들 수 있다고 한다.)
- **쿼리 익스텐션(query extension)**과 **모델 익스텐션(model extension)**은 서로 다른 개념
- 이전 구현은 모델 익스텐션 방식 → 각 모델별 custom 메서드 추가
- 모델 익스텐션에서 쿼리를 변환하려고 시도 → 런타임 에러
- 실제로 필요한 것은 쿼리 익스텐션 방식 → 쿼리 실행 전 인터셉트
Prisma Client Extensions - model (opens in a new tab)
Prisma Client Extensions - query (opens in a new tab)
2차 구현
prisma 깃허브 토론에서 내 상황에 맞는 코드를 발견했고 참고해서 진행했다.
prisma - Discussions #21530 (opens in a new tab)
const SOFT_DELETE_MODELS = ['User', 'Device', 'RefreshToken', /* ... */] as const;
const softDeleteExtension = Prisma.defineExtension((client) => {
return client.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
// soft delete 미지원 모델은 그대로 통과
if (!isSoftDeleteModel(model)) return query(args);
// 읽기 작업: deletedAt: null 필터 추가
if (READ_OPERATIONS.includes(operation)) {
return handleReadOperation(operation, args, client, model, query);
}
// 삭제 작업: update로 변환하여 deletedAt 설정
if (DELETE_OPERATIONS.includes(operation)) {
return handleDeleteOperation(operation, args, client, model);
}
return query(args);
},
},
},
});
});
const prisma = new PrismaClient().$extends(softDeleteExtension);핵심 함수들:
// 읽기 작업 처리 (findUnique는 findFirst로 리다이렉트)
function handleReadOperation(operation, args, client, model, query) {
if (operation === 'findUnique') {
// findUnique는 추가 필터를 지원하지 않으므로 findFirst로 전환
addSoftDeleteFilter(args);
return client[model].findFirst(args);
}
addSoftDeleteFilter(args);
return query(args);
}
// 삭제 작업을 update로 변환
function handleDeleteOperation(operation, args, client, model) {
const deletedAtData = { deletedAt: new Date(), ...(args.reason && { reason: args.reason }) };
if (operation === 'delete') {
return client[model].update({ where: args.where, data: deletedAtData });
} else if (operation === 'deleteMany') {
return client[model].updateMany({ where: args.where, data: deletedAtData });
}
}6. 최종 구현 분석
주요 변경 사항 요약
1단계 → 2단계 변경
- 문제: 라이브러리 사용 시 스키마 제너레이터와 충돌,
Prisma.skip에러 - 해결:
prisma-extension-soft-delete라이브러리 사용 +skipRemover확장 추가
2단계 → 3단계 변경
- 문제: 라이브러리 내부에서 데이터 깊은 복사 시 데이터 변경 발생
- 해결: 직접 구현 시작 (모델 익스텐션 방식)
3단계 → 최종 변경
- 문제: 모델 익스텐션 방식은 타입 관리가 복잡하고 런타임 에러 발생
- 해결: 쿼리 익스텐션(query extension) 방식으로 전환
최종 구현의 핵심 특징
1. 쿼리 인터셉트 방식
Prisma.defineExtension((client) => {
return client.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
// 모든 쿼리를 가로채서 처리
}
}
}
})
})2. 선택적 모델 적용
const SOFT_DELETE_MODELS = [
'User', 'Device', 'RefreshToken', 'Store',
'Product', 'Order', 'OrderItem', 'Payment',
] as const;
if (!isSoftDeleteModel(model)) {
return query(args); // soft delete 미지원 모델은 그대로 통과
}3. 작업별 처리 로직 분리
-
읽기 작업 (
findMany,findFirst,findUnique,count,groupBy,aggregate)deletedAt: null필터 자동 추가findUnique는 필터 추가가 안 되므로findFirst로 리다이렉트
-
업데이트 작업 (
update,updateMany,upsert)deletedAt: null필터 자동 추가 (이미 삭제된 데이터는 수정 불가)
-
삭제 작업 (
delete,deleteMany)- 실제 삭제 대신
update/updateMany로 변환 deletedAt에 현재 시간 설정reason파라미터 지원
- 실제 삭제 대신
4. 타입 안전성 확보
export type ExtendedPrismaClient = typeof prisma;
export type ExtendedTransactionClient = Parameters<
Parameters<ExtendedPrismaClient['$transaction']>[0]
>[0];
export type ExtendedDeleteArgs<T> = T & { reason?: string };비교
| 방식 | 장점 | 단점 | 결과 |
|---|---|---|---|
| 라이브러리 사용 | • 구현 간편 • 문서화된 API | • Prisma Client 옵션 충돌 • Prisma.skip 에러• 데이터 깊은 복사 버그 | X |
| 모델 익스텐션 | • 각 모델별 세밀한 제어 • Custom 메서드 추가 가능 | • 타입 관리 복잡 • 모든 작업 수동 구현 필요 • 반복 코드 많음 • 안전장치 직접 구현 필요 | X |
| 모델 익스텐션 + $allModels | • 코드 중복 제거 • 모델 선택적 적용 | • any 타입 남발• 런타임 에러 발생 • 쿼리 변환 실패 | X |
| 쿼리 익스텐션 (최종) | • 모든 쿼리 자동 인터셉트 • 타입 안전성 확보 • 코드 간결 • 유지보수 용이 | • 최소화 | O |
주요 사항
findUnique문제 해결: 필터 추가 불가 →findFirst로 리다이렉트- 모델 선택적 적용:
SOFT_DELETE_MODELS배열로 관리 - 타입 추론 문제 해결: 쿼리 익스텐션 방식으로 Prisma의 타입 시스템 활용
- 로깅 추가: 디버깅을 위한 상세 로그 (
logger.debug) - 안전장치: 예상치 못한 상황에서 에러 throw
7. 결론
1. 라이브러리 사용 (prisma-extension-soft-delete)
↓ 문제: Prisma Client 옵션 충돌
2. skipRemover 확장 추가
↓ 문제: 근본 원인 여전히 존재
3. 멘토님 피드백 → 직접 구현 결정
↓
4. 모델 익스텐션 방식 (각 모델에 함수 적용)
↓ 문제: 런타임 에러, any 타입 남발
5. 쿼리 익스텐션 방식 (최종)
→ 성공깨달음
-
모델 익스텐션 vs 쿼리 익스텐션의 차이 이해
- 모델 익스텐션: 새로운 메서드 추가 (e.g.
prisma.user.customMethod()) - 쿼리 익스텐션: 기존 쿼리 가로채기/변환 (e.g.
delete→update)
- 모델 익스텐션: 새로운 메서드 추가 (e.g.
-
타입 안전성 유지의 중요성
any타입을 사용하면 빌드는 성공해도 런타임 에러 발생 가능