NestJS + MikroORM 쿼리 필터 활용

MikroORM Filter란 무엇인가

MikroORM의 Filter는 엔티티 조회 시 자동으로 적용되는 사전 정의 조건(pre-defined criteria)이다. 공식 문서는 이를 “데이터베이스 뷰(view)처럼 동작하지만 애플리케이션 내부에서 매개변수화할 수 있는 것”이라고 설명한다. (mikro-orm.io/docs/filters)

Filter는 find(), findOne(), findAndCount(), count(), nativeUpdate(), nativeDelete()에 자동 적용되며, v6부터는 관계(relation) 조인에도 적용된다. 이 글에서는 NestJS 프로젝트에서 Filter를 활용하여 Soft Delete와 멀티테넌트를 선언적으로 구현하는 실무 패턴을 다룬다.

1. Filter 기본 구조: 엔티티 레벨 vs 글로벌

Filter는 두 가지 방식으로 정의할 수 있다:

구분 엔티티 레벨 Filter 글로벌 Filter
정의 위치 엔티티 클래스에 @Filter() 데코레이터 em.addFilter() 또는 ORM config의 filters 옵션
적용 범위 해당 엔티티만 전체 엔티티 또는 지정 엔티티
매개변수 전달 FindOptions.filters em.setFilterParams()
기본 활성화 default: true로 설정 가능 기본적으로 활성화 (마지막 인자로 비활성화 가능)

엔티티 레벨 예시

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

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

  @Property()
  title!: string;

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

이 설정만으로 em.find(Article, {})을 호출하면 자동으로 WHERE deleted_at IS NULL 조건이 추가된다. 삭제된 데이터를 포함하려면 명시적으로 비활성화해야 한다:

// 삭제된 레코드 포함
const all = await em.find(Article, {}, { filters: { softDelete: false } });

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

2. Soft Delete 패턴: 실무 구현 체크리스트

Soft Delete를 Filter로 구현할 때 놓치기 쉬운 포인트들을 정리한다.

구현 체크리스트 (8항목)

  1. 기본 활성화(default: true)를 반드시 설정하라. 누락하면 어디서든 삭제된 데이터가 노출된다.
  2. 관계 엔티티에도 동일 필터를 적용하라. Article에만 softDelete 필터를 걸고 Comment에는 안 걸면, 삭제된 Article의 Comment가 그대로 조회된다.
  3. v6의 관계 필터 자동 적용을 이해하라. MikroORM v6부터 필터가 관계 JOIN ON 조건에도 자동 적용된다. NOT NULL 외래키에서는 INNER JOIN으로 변환되어 삭제된 엔티티를 참조하는 행 자체가 제외된다.
  4. Unique 제약조건과의 충돌을 처리하라. email 컬럼에 UNIQUE가 걸려 있으면 soft delete 후 같은 email로 재가입이 불가능하다. 해결: partial unique index(WHERE deleted_at IS NULL) 사용 또는 삭제 시 email 값을 변환.
  5. 삭제 메서드를 표준화하라. em.remove()가 아닌 별도의 softDelete() 메서드를 서비스 레이어에 두고 deletedAt = new Date()를 설정.
  6. 관리자 API에서는 필터를 비활성화하라. 삭제된 데이터 조회가 필요한 관리자 화면에서는 filters: { softDelete: false }를 명시.
  7. QueryBuilder 사용 시 수동 적용이 필요하다. Filter는 EntityManager 메서드에만 자동 적용된다. QueryBuilder에서는 qb.applyFilters()를 반드시 호출해야 한다.
  8. nativeUpdate/nativeDelete에도 필터가 적용됨을 인지하라. 이미 soft-delete된 레코드를 nativeUpdate로 수정하려면 필터 비활성화가 필요하다.

3. 멀티테넌트 패턴: 글로벌 Filter + NestJS 미들웨어

멀티테넌트 SaaS에서 “모든 쿼리에 tenant 조건을 자동 부여”하는 것은 보안의 핵심이다. MikroORM의 글로벌 Filter와 NestJS의 요청 스코프를 결합하면 이를 선언적으로 구현할 수 있다.

Step 1: 테넌트 엔티티에 필터 정의

@Entity()
@Filter({
  name: 'tenant',
  cond: (args) => ({ tenant: { id: args.tenantId } }),
  default: true,
})
export class Project {
  @PrimaryKey()
  id!: number;

  @ManyToOne(() => Tenant)
  tenant!: Tenant;

  @Property()
  name!: string;
}

Step 2: NestJS 미들웨어에서 필터 파라미터 주입

import { Injectable, NestMiddleware } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly em: EntityManager) {}

  use(req: any, res: any, next: () => void) {
    const tenantId = req.headers['x-tenant-id'];
    if (tenantId) {
      this.em.setFilterParams('tenant', { tenantId: Number(tenantId) });
    }
    next();
  }
}

핵심 원리: MikroORM의 @CreateRequestContext()가 요청마다 새로운 EntityManager fork를 만들고, 그 fork에 setFilterParams()로 tenantId를 설정하면 해당 요청의 모든 쿼리에 tenant 조건이 자동 적용된다. 필터 파라미터는 fork된 EM의 모든 후속 fork에도 복사된다 (공식 문서: “Filters as well as filter params set on the EM will be copied to all its forks”).

Step 3: 모듈에 미들웨어 등록

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';

@Module({ /* ... */ })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TenantMiddleware).forRoutes('*');
  }
}

4. 동적 조건: 비동기 콜백과 operation type 활용

Filter의 cond는 비동기 콜백을 지원하며, 세 가지 인자를 받는다:

인자 타입 설명
args Dictionary 사용자가 전달한 매개변수
type 'read' | 'update' | 'delete' 현재 작업 종류
em EntityManager 현재 EntityManager 인스턴스

이를 활용하면 “읽기에만 필터 적용, 업데이트 시에는 해제”와 같은 조건부 로직이 가능하다:

@Filter({
  name: 'publishedOnly',
  cond: async (args, type, em) => {
    if (type === 'update' || type === 'delete') {
      return {};  // update/delete에는 필터 미적용
    }
    return { publishedAt: { $lte: em.raw('NOW()') } };
  },
  args: false,
  default: true,
})

주의: 매개변수가 필요 없는 콜백 필터에서 type 인자를 사용하려면 반드시 args: false를 명시해야 한다. 그렇지 않으면 MikroORM이 누락된 매개변수 에러를 발생시킨다.

5. v6에서 달라진 관계 필터링: autoJoinRefsForFilters

MikroORM v6의 가장 큰 변경 중 하나는 필터가 관계 JOIN 조건에도 자동 적용되는 것이다. 이는 Soft Delete 필터와 함께 사용할 때 특히 중요하다.

시나리오 v5 동작 v6 동작
M:1 관계의 대상 엔티티가 soft-deleted 부모 엔티티는 정상 반환, 관계 필드만 NULL NOT NULL FK → INNER JOIN으로 부모도 제외
Nullable 관계의 대상이 soft-deleted 관계 필드 NULL로 반환 LEFT JOIN + WHERE로 필터 적용, 관계 없으면 부모 유지
QueryBuilder 사용 필터 미적용 여전히 applyFilters() 수동 호출 필요

이 동작이 원치 않을 경우 두 가지 옵션이 있다:

  • autoJoinRefsForFilters: false — ORM 설정에서 자동 JOIN 비활성화
  • filtersOnRelations: false — 관계에 대한 필터 적용 자체를 비활성화 (이 경우 select-in 로딩 전략에서 동작이 달라진다)

관계 수준에서도 개별 제어가 가능하다:

// 특정 관계에서 softDelete 필터 비활성화
@ManyToOne({ filters: { softDelete: false } })
originalAuthor?: Author;

6. strict 옵션: nullable 관계에서도 테넌트 격리 보장

기본적으로 nullable 관계에서 필터가 적용되면, 관계 값이 NULL인 행(즉 관계가 없는 행)은 유지된다. 그러나 멀티테넌트처럼 “관계가 있든 없든 테넌트가 맞지 않으면 무조건 제외”해야 하는 경우가 있다.

이때 strict: true 옵션을 사용한다:

em.addFilter('tenant', { tenant: tenantId }, User, { strict: true });

strict 필터는 LEFT JOIN을 유지하되, WHERE 조건에서 관계 값이 존재하면서 필터 조건에 맞지 않는 행을 제거한다. nullable 관계에서도 테넌트 격리를 보장하는 핵심 옵션이다.

7. 장애 대응: Filter 관련 자주 겪는 버그와 디버깅

버그 1: 삭제된 데이터가 여전히 조회된다

원인: default: true 미설정, 또는 QueryBuilder 사용 시 applyFilters() 호출 누락.

디버깅: MikroORM의 debug 모드(debug: true)를 켜고 실제 실행되는 SQL에 WHERE 조건이 포함되어 있는지 확인한다.

버그 2: 관계 로딩 시 예상치 못한 INNER JOIN으로 데이터 소실

원인: v6에서 NOT NULL FK가 있는 관계의 대상 엔티티에 softDelete 필터가 적용되면 INNER JOIN으로 변환된다.

해결: 해당 관계에 @ManyToOne({ filters: { softDelete: false } })를 설정하거나, FK를 nullable로 변경한다.

버그 3: 글로벌 필터 파라미터가 설정되지 않아 빈 결과 반환

원인: 미들웨어가 setFilterParams()를 호출하기 전에 쿼리가 실행되거나, 미들웨어가 해당 라우트에 적용되지 않았다.

해결: NestJS 미들웨어 등록 순서 확인. @CreateRequestContext() 데코레이터가 서비스 메서드에 올바르게 적용되었는지 점검.

버그 4: 같은 이름의 필터가 의도치 않게 연동

원인: MikroORM에서 필터 이름은 엔티티와 무관하게 단일 토글로 제어된다. Author와 Book에 모두 tenant라는 이름의 필터가 있으면, filters: { tenant: false }가 양쪽 모두에 적용된다.

대응: 의도적인 설계(멀티테넌트처럼 모든 엔티티에 동일 조건)라면 같은 이름 사용. 엔티티별 독립 제어가 필요하면 이름을 구분(authorTenant, bookTenant)한다.

실전 정리: Filter 도입 전 확인해야 할 7가지

  1. Soft Delete용 필터는 default: true로 설정했는가?
  2. 관련 엔티티(자식, 참조)에도 동일 필터를 적용했는가?
  3. QueryBuilder를 쓰는 곳에서 applyFilters()를 호출하고 있는가?
  4. v6 관계 필터링(autoJoinRefsForFilters)의 INNER JOIN 영향을 파악했는가?
  5. 멀티테넌트 필터의 파라미터 주입 타이밍이 쿼리 실행 전인가?
  6. 관리자 API에서 필터 비활성화 경로를 확보했는가?
  7. 필터 이름의 글로벌 토글 특성을 이해하고 네이밍했는가?

본문의 모든 내용은 MikroORM 공식 문서 — FiltersMikroORM v6 릴리즈 노트에 근거합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux