Prisma soft delete 직접 구현 시행착오

PrismaExpressTypeScript

1. 배경

초기에는 라이브러리를 사용했으나, Prisma Client 옵션과 내부 동작 문제로 직접 구현을 선택했다.

주요 문제점

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을 이용하라고 한다.

관련 자료를 찾아본 결과

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;

특징

단점

문제

모델별 타입 지정의 복잡성

발견한 문제

각 모델마다 함수를 지정하는 방식은 신경쓸 게 너무 많음:

새로운 접근 방법 모색

참고 자료:

방향 전환

$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() } });
      },
    },
  },
});

단점

실패 원인 분석

모델 익스텐션에서 쿼리 메서드(delete, deleteMany)를 직접 호출하려고 했으나, 이는 쿼리 익스텐션의 영역이었다, extensions만 생각하고 있었지 어떤 유형이 필요할지 생각하지 못했다. (model, client, query, result 4개의 구성 요소에서 하나 이상을 사용해 확장을 만들 수 있다고 한다.)

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단계 변경

2단계 → 3단계 변경

3단계 → 최종 변경

최종 구현의 핵심 특징

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. 작업별 처리 로직 분리

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

주요 사항

  1. findUnique 문제 해결: 필터 추가 불가 → findFirst로 리다이렉트
  2. 모델 선택적 적용: SOFT_DELETE_MODELS 배열로 관리
  3. 타입 추론 문제 해결: 쿼리 익스텐션 방식으로 Prisma의 타입 시스템 활용
  4. 로깅 추가: 디버깅을 위한 상세 로그 (logger.debug)
  5. 안전장치: 예상치 못한 상황에서 에러 throw

7. 결론

1. 라이브러리 사용 (prisma-extension-soft-delete)
   ↓ 문제: Prisma Client 옵션 충돌

2. skipRemover 확장 추가
   ↓ 문제: 근본 원인 여전히 존재

3. 멘토님 피드백 → 직접 구현 결정


4. 모델 익스텐션 방식 (각 모델에 함수 적용)
   ↓ 문제: 런타임 에러, any 타입 남발

5. 쿼리 익스텐션 방식 (최종)
   → 성공

깨달음

  1. 모델 익스텐션 vs 쿼리 익스텐션의 차이 이해

    • 모델 익스텐션: 새로운 메서드 추가 (e.g. prisma.user.customMethod())
    • 쿼리 익스텐션: 기존 쿼리 가로채기/변환 (e.g. deleteupdate)
  2. 타입 안전성 유지의 중요성

    • any 타입을 사용하면 빌드는 성공해도 런타임 에러 발생 가능
© bgkRSS