junyeokk
Blog
MikroORM·2025. 11. 27

Soft Delete Filter

데이터를 삭제할 때 실제로 DB에서 행을 지워버리는 것을 hard delete라고 한다. 간단하고 직관적이지만 한 번 지우면 복구가 불가능하다. 사용자가 실수로 삭제했거나, 감사 로그가 필요하거나, 삭제된 데이터를 일정 기간 보관해야 하는 규정이 있다면 hard delete는 적합하지 않다.

이 문제를 해결하는 패턴이 soft delete다. 실제로 행을 삭제하는 대신, deletedAt 같은 컬럼에 삭제 시점을 기록하고 조회할 때 이 값이 null인 행만 가져오는 방식이다. 데이터는 DB에 그대로 남아있으니 언제든 복구할 수 있고, 삭제 이력도 추적할 수 있다.

문제는 "조회할 때 삭제된 행을 제외한다"는 조건을 모든 쿼리에 수동으로 붙여야 한다는 것이다. WHERE deleted_at IS NULL을 빼먹으면 삭제된 데이터가 노출된다. 쿼리가 수십 개, 수백 개가 되면 실수가 생기기 마련이다.

MikroORM의 Filter 기능은 이 문제를 엔티티 레벨에서 해결한다. 필터 조건을 한 번 정의해두면 해당 엔티티에 대한 모든 쿼리에 자동으로 적용된다.


@Filter 데코레이터

@Filter() 데코레이터를 엔티티 클래스에 붙이면 해당 엔티티를 조회할 때마다 지정한 조건이 자동으로 WHERE절에 추가된다.

typescript
import { Entity, Property, Filter } from '@mikro-orm/core';

@Entity()
@Filter({
  name: 'softDelete',
  cond: { deletedAt: null },
  default: true,
})
export class Article {
  @Property({ nullable: true })
  deletedAt?: Date;
  
  // ... 다른 필드들
}

세 가지 핵심 속성이 있다.

name

필터의 이름이다. 쿼리할 때 이 이름으로 필터를 켜거나 끌 수 있다. 엔티티 하나에 여러 필터를 붙일 수 있는데, 이름으로 구분한다.

cond

필터 조건이다. 위 예시에서는 { deletedAt: null } — deletedAt이 null인 행만 조회한다는 뜻이다. 정적 객체뿐 아니라 콜백 함수로도 정의할 수 있어서, 런타임에 동적으로 조건을 생성할 수도 있다.

typescript
@Filter({
  name: 'softDelete',
  cond: (args, type) => {
    // 삭제 쿼리에서는 필터를 적용하지 않음
    if (type === 'delete') return {};
    return { deletedAt: null };
  },
  args: false,
  default: true,
})

콜백은 세 가지 인자를 받는다:

  • args — 사용자가 전달한 파라미터 (없으면 args: false 명시)
  • type — 현재 작업 종류. 'read', 'update', 'delete' 중 하나
  • em — 현재 EntityManager 인스턴스

type으로 분기하면 "읽기에는 필터를 적용하되 삭제 쿼리에서는 빼기" 같은 세밀한 제어가 가능하다. 비동기 콜백도 지원한다.

default

true로 설정하면 별도로 지정하지 않아도 모든 쿼리에 필터가 자동 적용된다. soft delete 필터는 대부분 default: true로 설정한다 — 삭제된 데이터가 기본적으로 안 보여야 하니까.


실제 soft delete 엔티티 설계

soft delete를 적용하려면 두 가지가 필요하다: 삭제 표시 필드와 필터, 그리고 실제 삭제 대신 deletedAt을 채우는 로직.

typescript
@Entity()
@Filter({
  name: 'softDelete',
  cond: { deletedAt: null },
  default: true,
})
export class Post {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Property()
  content!: string;

  @Property({ nullable: true })
  deletedAt?: Date;

  // 편의 메서드
  softDelete() {
    this.deletedAt = new Date();
  }

  restore() {
    this.deletedAt = undefined;
  }
}

삭제할 때 em.remove()가 아니라 softDelete() 메서드를 호출한다:

typescript
const post = await em.findOneOrFail(Post, postId);
post.softDelete();
await em.flush();

이렇게 하면 DB에서 행이 사라지지 않고, deleted_at 컬럼에 현재 시간이 기록된다. 이후 em.find(Post, {})를 호출하면 필터가 자동으로 WHERE deleted_at IS NULL을 붙여서 삭제된 포스트는 조회 결과에 포함되지 않는다.


필터를 비활성화해서 삭제된 데이터 조회하기

관리자 페이지에서 삭제된 항목을 보여주거나, 복구 기능을 만들어야 할 때는 필터를 꺼야 한다. find()filters 옵션으로 제어한다.

typescript
// 특정 필터만 비활성화
const allPosts = await em.find(Post, {}, {
  filters: { softDelete: false },
});

// 모든 필터 비활성화
const everything = await em.find(Post, {}, {
  filters: false,
});

filters: false는 해당 엔티티에 걸린 모든 필터를 무시한다. 특정 필터만 끄고 싶으면 객체 형태로 해당 필터 이름에 false를 지정한다.

findOne(), findAndCount(), count(), nativeUpdate(), nativeDelete() 모두 같은 방식으로 필터를 제어할 수 있다.

삭제된 데이터만 조회

삭제된 항목만 따로 보고 싶다면 기본 필터를 끄고 직접 조건을 건다:

typescript
const deletedPosts = await em.find(Post, {
  deletedAt: { $ne: null },
}, {
  filters: { softDelete: false },
});

관계(Relation)에서의 필터 동작

soft delete 필터의 진짜 복잡한 부분은 관계에서 나타난다. Post 엔티티에 soft delete 필터가 걸려 있고, Comment가 Post를 참조한다고 하자.

typescript
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
export class Post {
  @PrimaryKey()
  id!: number;

  @Property({ nullable: true })
  deletedAt?: Date;

  @OneToMany(() => Comment, comment => comment.post)
  comments = new Collection<Comment>(this);
}

@Entity()
export class Comment {
  @ManyToOne(() => Post)
  post!: Post;

  @Property()
  content!: string;
}

MikroORM v6부터 필터가 관계에도 자동 적용된다. Comment를 조회할 때 Post에 걸린 soft delete 필터 때문에 자동으로 Post 테이블에 JOIN이 발생한다.

여기서 중요한 차이가 있다:

  • NOT NULL FK: post 필드가 필수(nullable이 아닌)면 INNER JOIN이 걸린다. 삭제된 Post를 참조하는 Comment는 아예 결과에서 사라진다.
  • Nullable FK: post 필드가 nullable이면 LEFT JOIN + WHERE 조건이 걸린다. Post가 없는(null인) Comment는 살아남지만, 삭제된 Post를 참조하는 Comment는 제외된다.

이 동작이 의도한 것일 수도 있고 아닐 수도 있다. 댓글이 달린 글이 soft delete되었을 때 댓글도 같이 안 보이게 하고 싶다면 이대로 좋다. 하지만 댓글은 독립적으로 보여줘야 한다면 관계 레벨에서 필터를 비활성화해야 한다:

typescript
@Entity()
export class Comment {
  // 이 관계에서는 Post의 softDelete 필터를 적용하지 않음
  @ManyToOne(() => Post, { filters: false })
  post!: Post;
}

또는 특정 필터만 선택적으로 끌 수 있다:

typescript
@ManyToOne(() => Post, { filters: { softDelete: false } })
post!: Post;

autoJoinRefsForFilters

관계에 필터가 자동 적용되는 것 자체를 ORM 설정 레벨에서 끄고 싶다면:

typescript
MikroORM.init({
  autoJoinRefsForFilters: false,
  // ...
});

filtersOnRelations: false로 관계 필터를 완전히 비활성화할 수도 있다. 단, 이 경우 select-in 로딩 전략에서 별도 쿼리가 발생하면서 필터가 그 쿼리 레벨에서 적용되는 등 동작이 달라질 수 있으니 주의해야 한다.


strict 옵션

기본적으로 nullable 관계에서 필터는 "값이 있을 때만" 적용된다. FK가 null이면 소유 엔티티를 버리지 않는다. 하지만 strict: true로 설정하면 nullable 관계라도 필터에 걸리면 소유 엔티티까지 결과에서 제외된다.

typescript
@Filter({
  name: 'softDelete',
  cond: { deletedAt: null },
  default: true,
  strict: true,  // nullable 관계에서도 엄격하게 적용
})

이건 멀티테넌트 같은 상황에서 유용하다. 다른 테넌트의 데이터를 참조하는 행이 있으면 nullable이든 아니든 결과에서 제거해야 하니까. soft delete에서는 상황에 따라 결정하면 된다.


글로벌 필터

여러 엔티티에 같은 soft delete 로직을 적용해야 한다면 @Filter()를 각 엔티티마다 붙이는 게 번거로울 수 있다. 글로벌 필터를 사용하면 한 곳에서 정의하고 여러 엔티티에 적용할 수 있다.

EntityManager에서 동적 등록

typescript
// 특정 엔티티에만 적용
em.addFilter('softDelete', { deletedAt: null }, [Post, Comment, User]);

// 모든 엔티티에 적용 (deletedAt 필드가 있는 엔티티에만 의미 있음)
em.addFilter('softDelete', { deletedAt: null });

ORM 설정에서 등록

typescript
MikroORM.init({
  filters: {
    softDelete: {
      cond: { deletedAt: null },
      entity: ['Post', 'Comment', 'User'],
      default: true,
    },
  },
});

글로벌 필터는 엔티티 레벨 필터와 같은 방식으로 비활성화할 수 있다. 단, 글로벌 필터를 적용할 때는 해당 엔티티에 실제로 deletedAt 필드가 있는지 확인해야 한다. 없는 엔티티에 적용하면 쿼리 에러가 난다.


공통 Base Entity로 추출하기

실무에서는 soft delete가 필요한 엔티티가 여러 개이기 마련이다. 매번 deletedAt 필드와 @Filter()를 중복해서 쓰는 대신, 베이스 엔티티로 추출하면 깔끔하다.

typescript
@Filter({
  name: 'softDelete',
  cond: { deletedAt: null },
  default: true,
})
export abstract class SoftDeletableEntity {
  @Property({ nullable: true })
  deletedAt?: Date;

  softDelete() {
    this.deletedAt = new Date();
  }

  restore() {
    this.deletedAt = undefined;
  }

  get isDeleted(): boolean {
    return this.deletedAt != null;
  }
}

@Entity()
export class Post extends SoftDeletableEntity {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;
}

@Entity()
export class Comment extends SoftDeletableEntity {
  @PrimaryKey()
  id!: number;

  @Property()
  content!: string;
}

이제 PostComment 모두 softDelete(), restore(), isDeleted를 사용할 수 있고, 조회 시 자동으로 삭제된 항목이 필터링된다.


QueryBuilder에서 필터 적용

EntityManager를 통한 쿼리(find, findOne 등)에는 필터가 자동으로 적용되지만, QueryBuilder를 직접 사용할 때는 필터가 자동 적용되지 않는다. 이 점을 모르면 soft delete된 데이터가 그대로 노출될 수 있다.

QueryBuilder에서 필터를 적용하려면 applyFilters()를 명시적으로 호출해야 한다:

typescript
const qb = em.createQueryBuilder(Post);
qb.select('*').where({ category: 'tech' });

// 필터 적용
await qb.applyFilters();

const results = await qb.getResultList();

인자로 FindOptions.filters와 동일한 형태를 받는다:

typescript
// 특정 필터만 비활성화
await qb.applyFilters({ softDelete: false });

// 모든 필터 비활성화
await qb.applyFilters(false);

applyFilters()를 빼먹기 쉬우니, 팀에서 QueryBuilder를 사용하는 경우 코드 리뷰에서 이 부분을 확인하는 습관을 들이는 게 좋다.


파라미터를 받는 필터

필터 조건이 고정값이 아니라 런타임에 결정되는 경우도 있다. 예를 들어 멀티테넌트 + soft delete를 조합하는 경우:

typescript
@Entity()
@Filter({
  name: 'tenantSoftDelete',
  cond: (args) => ({
    deletedAt: null,
    tenantId: args.tenantId,
  }),
  default: true,
})
export class Post {
  @Property({ nullable: true })
  deletedAt?: Date;

  @Property()
  tenantId!: string;
}

파라미터는 em.setFilterParams()로 설정한다:

typescript
// 미들웨어에서 요청의 테넌트 정보를 주입
em.setFilterParams('tenantSoftDelete', { tenantId: request.tenantId });

// 이후 쿼리에 자동 적용
const posts = await em.find(Post, {});
// WHERE deleted_at IS NULL AND tenant_id = ?

NestJS에서는 요청 스코프 미들웨어에서 setFilterParams()를 호출하는 패턴이 일반적이다.


hard delete가 필요한 경우

soft delete를 사용하더라도 실제로 데이터를 지워야 하는 경우가 있다. GDPR 같은 개인정보 규정에 따른 완전 삭제, 또는 일정 기간 지난 soft delete 데이터의 정리 등이다.

이때는 필터를 비활성화하고 em.remove()를 사용한다:

typescript
// 30일 이상 지난 soft delete 데이터 영구 삭제
const oldDeleted = await em.find(Post, {
  deletedAt: { $lt: thirtyDaysAgo },
}, {
  filters: { softDelete: false },
});

for (const post of oldDeleted) {
  em.remove(post);
}

await em.flush();

또는 nativeDelete()로 더 효율적으로:

typescript
await em.nativeDelete(Post, {
  deletedAt: { $lt: thirtyDaysAgo },
}, {
  filters: { softDelete: false },
});

주의사항

unique 제약 조건과의 충돌

email 컬럼에 unique 인덱스가 걸려 있는 상황에서 사용자를 soft delete하면, 같은 email로 새 사용자를 만들 수 없다. 삭제된 행이 DB에 남아있으니까. 이를 해결하려면 partial unique index를 사용한다:

sql
CREATE UNIQUE INDEX idx_user_email_active 
ON "user" (email) 
WHERE deleted_at IS NULL;

MikroORM에서는 @Index() 데코레이터에 expression을 지정할 수 있다:

typescript
@Entity()
@Index({
  name: 'idx_user_email_active',
  expression: 'CREATE UNIQUE INDEX "idx_user_email_active" ON "user" ("email") WHERE "deleted_at" IS NULL',
})
export class User extends SoftDeletableEntity {
  @Property({ unique: false }) // 일반 unique 제거
  email!: string;
}

성능

soft delete된 행이 계속 쌓이면 테이블 크기가 커지고 쿼리 성능에 영향을 줄 수 있다. deletedAt 컬럼에 인덱스를 추가하고, 오래된 soft delete 데이터는 주기적으로 아카이빙하거나 영구 삭제하는 전략이 필요하다.

typescript
@Property({ nullable: true, index: true })
deletedAt?: Date;

CASCADE 삭제

DB 레벨의 ON DELETE CASCADE는 soft delete와 함께 동작하지 않는다. 실제로 행이 삭제되지 않으니 cascade가 트리거되지 않는다. 연관 엔티티도 함께 soft delete해야 한다면 애플리케이션 코드에서 직접 처리해야 한다:

typescript
async softDeletePost(postId: number) {
  const post = await em.findOneOrFail(Post, postId, {
    populate: ['comments'],
  });
  
  post.softDelete();
  for (const comment of post.comments) {
    comment.softDelete();
  }
  
  await em.flush();
}

nativeUpdate/nativeDelete에서의 필터

nativeUpdate()nativeDelete()에도 필터가 적용된다. 즉, soft delete된 행에 대해 nativeUpdate()를 실행하면 해당 행은 건너뛴다. 삭제된 행을 업데이트해야 한다면 (예: 복구) 필터를 비활성화해야 한다.


정리

  • @Filter의 default: true로 모든 조회에 자동 적용하되, QueryBuilder에서는 applyFilters()를 명시적으로 호출해야 한다
  • 관계에서 필터가 자동 JOIN으로 전파되므로, 의도하지 않은 결과 제외를 방지하려면 관계 레벨에서 filters: false를 설정한다
  • unique 제약 조건 충돌은 partial index로 해결하고, 오래된 soft delete 데이터는 주기적 아카이빙 전략이 필요하다

관련 문서