0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【実践】論理削除をやめて物理削除+Archiveテーブルに移行した話

Posted at

TL;DR

  • 論理削除(deleted_atカラム)は一見安全だが、運用で多くの問題が発生する
  • 代替として「物理削除 + Archiveテーブル」パターンを採用
  • Prisma + PostgreSQLでの具体的な実装方法を紹介

この記事の対象読者

  • 論理削除で苦労した経験がある方
  • 新規プロジェクトの削除戦略を検討中の方
  • Prisma/NestJSを使っている方

論理削除で発生した問題(実体験)

問題1: WHERE句の追加漏れ

// 本来書くべきコード
const users = await prisma.user.findMany({
  where: { deletedAt: null }
});

// 実際に書いてしまったコード
const users = await prisma.user.findMany();
// → 削除済みユーザーも取得してしまいバグ発生

「ミドルウェアで自動付与すればいい」と思いますよね。でもそうすると...

// ミドルウェア設定
prisma.$use(async (params, next) => {
  if (params.action === 'findMany') {
    params.args.where = { ...params.args.where, deletedAt: null };
  }
  return next(params);
});

// 管理画面で削除済みユーザー一覧を見たい
// → 取得できない!

// 回避策として別のPrismaインスタンスを作る?
// → 管理コストが増大

問題2: UNIQUE制約との戦い

-- メールアドレスをUNIQUEにしたい
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);

-- ユーザーA: test@example.com で登録
-- ユーザーAを論理削除
-- ユーザーB: 同じtest@example.comで登録したい
-- → エラー!UNIQUEに引っかかる

部分インデックスで解決できるが...

-- PostgreSQLの場合
CREATE UNIQUE INDEX users_email_unique ON users(email) WHERE deleted_at IS NULL;

-- MySQLの場合
-- 部分インデックスがない!Generated Columnで代替が必要
ALTER TABLE users ADD COLUMN email_unique VARCHAR(255)
  GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN email ELSE NULL END);
CREATE UNIQUE INDEX users_email_unique ON users(email_unique);

DBによって対応が異なり、ポータビリティが下がります。

問題3: テーブル・インデックスの肥大化

-- 100万ユーザーのうち80万が退会済み
-- deleted_at IS NULLのチェックが毎回必要
SELECT * FROM users WHERE email = ? AND deleted_at IS NULL;

-- インデックスも80万件の「ゴミ」を含んでいる

解決策: 物理削除 + Archiveテーブル

方針

  1. メインテーブルからは物理削除する(DELETE)
  2. 削除前にArchiveテーブルにJSON形式で保存
  3. 監査ログは別テーブルで管理

Step 1: Archiveテーブルの作成

// prisma/schema.prisma

model Archive {
  id         String   @id @default(uuid()) @db.Uuid
  entityName String   @map("table_name")    // "User", "Post" など
  recordId   String   @map("record_id")     // 削除されたレコードのID
  data       Json                           // レコード内容をJSONで保存
  createdAt  DateTime @default(now()) @map("created_at")

  @@index([entityName, recordId])
  @@map("archives")
}

マイグレーション実行:

npx prisma migrate dev --name add_archive_table

Step 2: ArchiveServiceの実装

// src/archive/archive.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

interface ArchiveRecord {
  entityName: string;
  recordId: string;
  data: Record<string, unknown>;
}

@Injectable()
export class ArchiveService {
  constructor(private readonly prisma: PrismaService) {}

  /**
   * レコードをアーカイブに保存
   */
  async archive(record: ArchiveRecord): Promise<void> {
    await this.prisma.archive.create({
      data: {
        entityName: record.entityName,
        recordId: record.recordId,
        data: record.data,
      },
    });
  }

  /**
   * アーカイブからレコードを検索
   */
  async findArchived(
    entityName: string,
    recordId: string,
  ): Promise<Record<string, unknown> | null> {
    const archive = await this.prisma.archive.findFirst({
      where: { entityName, recordId },
      orderBy: { createdAt: 'desc' },
    });
    return archive?.data as Record<string, unknown> | null;
  }

  /**
   * 特定エンティティの削除履歴一覧
   */
  async listArchived(entityName: string): Promise<Record<string, unknown>[]> {
    const archives = await this.prisma.archive.findMany({
      where: { entityName },
      orderBy: { createdAt: 'desc' },
    });
    return archives.map((a) => a.data as Record<string, unknown>);
  }
}

Step 3: Userリポジトリでの使用

// src/user/user.repository.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ArchiveService } from '../archive/archive.service';

@Injectable()
export class UserRepository {
  constructor(
    private readonly prisma: PrismaService,
    private readonly archiveService: ArchiveService,
  ) {}

  async delete(userId: string): Promise<void> {
    // 1. 削除対象のユーザーを取得
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
    });

    if (!user) {
      throw new Error('User not found');
    }

    // 2. トランザクションでアーカイブ保存 → 物理削除
    await this.prisma.$transaction(async (tx) => {
      // アーカイブに保存
      await tx.archive.create({
        data: {
          entityName: 'User',
          recordId: userId,
          data: user,
        },
      });

      // 物理削除
      await tx.user.delete({
        where: { id: userId },
      });
    });
  }

  // 通常のfindはシンプルに
  async findById(userId: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { id: userId },
      // deleted_at条件が不要!
    });
  }

  async findAll(): Promise<User[]> {
    return this.prisma.user.findMany();
    // シンプル!
  }
}

Step 4: 削除済みユーザーの確認(管理画面向け)

// src/admin/admin.controller.ts

@Controller('admin')
@UseGuards(AdminGuard)
export class AdminController {
  constructor(private readonly archiveService: ArchiveService) {}

  @Get('deleted-users')
  async getDeletedUsers() {
    return this.archiveService.listArchived('User');
  }

  @Get('deleted-users/:id')
  async getDeletedUser(@Param('id') id: string) {
    return this.archiveService.findArchived('User', id);
  }
}

Step 5: ユーザー復元機能(オプション)

// src/user/user.service.ts

@Injectable()
export class UserService {
  async restoreUser(userId: string): Promise<User> {
    // アーカイブからデータ取得
    const archivedData = await this.archiveService.findArchived('User', userId);

    if (!archivedData) {
      throw new Error('Archived user not found');
    }

    // トランザクションで復元 → アーカイブ削除
    return this.prisma.$transaction(async (tx) => {
      // ユーザー再作成
      const user = await tx.user.create({
        data: {
          id: archivedData.id,
          email: archivedData.email,
          displayName: archivedData.displayName,
          // ... その他のフィールド
        },
      });

      // アーカイブから削除
      await tx.archive.deleteMany({
        where: { entityName: 'User', recordId: userId },
      });

      return user;
    });
  }
}

GDPR対応(完全削除)

GDPRの「忘れられる権利」に対応する場合:

// 完全削除(アーカイブからも削除)
async purgeUser(userId: string): Promise<void> {
  await this.prisma.$transaction(async (tx) => {
    // 本体がまだあれば削除
    await tx.user.deleteMany({
      where: { id: userId },
    });

    // アーカイブからも削除
    await tx.archive.deleteMany({
      where: { entityName: 'User', recordId: userId },
    });

    // 監査ログは残す(誰がいつ削除要求したかの記録)
    await tx.auditLog.create({
      data: {
        auditableType: 'User',
        auditableId: userId,
        action: 'GDPR_PURGE',
        auditedChanges: { reason: 'User requested data erasure' },
        version: 1,
      },
    });
  });
}

Before / After 比較

クエリのシンプルさ

// Before(論理削除)
const users = await prisma.user.findMany({
  where: {
    deletedAt: null,
    posts: {
      some: {
        deletedAt: null,
        comments: {
          some: { deletedAt: null }
        }
      }
    }
  }
});

// After(物理削除)
const users = await prisma.user.findMany({
  where: {
    posts: {
      some: {
        comments: { some: {} }
      }
    }
  }
});

UNIQUE制約

// Before(論理削除)
// 部分インデックスが必要、DBによっては対応していない

// After(物理削除)
// 普通のUNIQUE制約でOK
model User {
  email String @unique  // これだけ!
}

注意点とトレードオフ

外部キー制約

関連レコードがある場合、削除順序に注意:

// 子レコードを先に削除
await tx.post.deleteMany({ where: { authorId: userId } });
await tx.user.delete({ where: { id: userId } });

// または ON DELETE CASCADE を設定
model Post {
  author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

Archiveテーブルの定期パージ

// 1年以上前のアーカイブを削除するバッチ
@Cron('0 0 * * 0') // 毎週日曜0時
async purgeOldArchives() {
  const oneYearAgo = new Date();
  oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);

  await this.prisma.archive.deleteMany({
    where: { createdAt: { lt: oneYearAgo } },
  });
}

まとめ

観点 論理削除 物理削除+Archive
クエリ 毎回WHERE条件必要 シンプル
UNIQUE制約 部分インデックス必要 標準で対応
復元 容易 可能(要実装)
GDPR対応 追加対応必要 容易
複雑さ 暗黙的 明示的

論理削除は「簡単そう」に見えて、運用で複雑さが増大します。
物理削除 + Archiveは初期実装は少し手間ですが、長期的にはメンテナンスしやすいシステムになります。

プロジェクトの要件に合わせて検討してみてください。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?