MikroORM Filter 동적 쿼리

MikroORM Filter란?

MikroORM Filter는 엔티티 레벨에서 자동으로 WHERE 조건을 주입하는 기능입니다. Soft Delete, Multi-Tenancy, 데이터 접근 제어 등 반복되는 조건을 한 번 정의하면 모든 쿼리에 자동 적용됩니다. MikroORM Unit of Work 패턴과 결합하면 비즈니스 로직에서 인프라 관심사를 완전히 분리할 수 있습니다.

기본 Filter 정의

Soft Delete 필터

가장 흔한 사용 사례입니다. deletedAt 컬럼이 있는 엔티티에서 삭제된 레코드를 자동으로 제외합니다.

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;

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

default: true로 설정하면 em.find(Article, {}) 호출 시 자동으로 WHERE deleted_at IS NULL이 추가됩니다.

Multi-Tenancy 필터

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

  @Property()
  name!: string;

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

// 사용 시 — 필터 파라미터 주입
em.setFilterParams('tenant', { tenantId: currentUser.tenantId });

NestJS 통합 — 요청별 필터 자동 설정

NestJS에서는 미들웨어나 Interceptor로 요청마다 필터 파라미터를 자동 주입합니다.

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: Request, res: Response, next: Function) {
    const tenantId = req.headers['x-tenant-id'] as string;
    if (!tenantId) {
      throw new UnauthorizedException('Tenant header required');
    }

    // 요청 스코프의 EntityManager에 필터 파라미터 설정
    this.em.setFilterParams('tenant', { tenantId });
    next();
  }
}

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

이제 모든 Repository/Service에서 별도 조건 없이 em.find(Project, {})만 호출하면 해당 테넌트의 데이터만 조회됩니다.

동적 Filter — 콜백 함수

정적 조건 외에 콜백 함수를 사용하면 런타임 조건을 동적으로 생성할 수 있습니다.

@Entity()
@Filter({
  name: 'dateRange',
  cond: (args: { from?: Date; to?: Date }, type: 'read') => {
    const cond: Record<string, any> = {};
    if (args.from) cond.createdAt = { $gte: args.from };
    if (args.to) cond.createdAt = { ...cond.createdAt, $lte: args.to };
    return cond;
  },
  default: false,  // 명시적 활성화 필요
})
@Filter({
  name: 'published',
  cond: { status: 'published', publishedAt: { $lte: new Date() } },
  default: false,
})
export class Post {
  @PrimaryKey()
  id!: number;

  @Enum(() => PostStatus)
  status!: PostStatus;

  @Property()
  createdAt: Date = new Date();

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

// 사용
const posts = await em.find(Post, {}, {
  filters: {
    published: true,
    dateRange: { from: new Date('2026-01-01'), to: new Date('2026-03-01') },
  },
});

Filter 활성화/비활성화 제어

// 1. 전역 비활성화 — 관리자 조회 시 soft delete 포함
const allArticles = await em.find(Article, {}, {
  filters: { softDelete: false },
});

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

// 3. 특정 필터만 활성화
const published = await em.find(Post, {}, {
  filters: { published: true },  // published만 활성화
});

// 4. EntityManager 레벨에서 토글
em.getFilterParams('tenant');   // 현재 파라미터 확인
em.setFilterParams('tenant', { tenantId: 'admin' });  // 파라미터 변경

관계(Relation)에도 적용

Filter는 populate를 통한 관계 로딩에도 자동 적용됩니다.

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

  @Property()
  content!: string;

  @ManyToOne(() => Article)
  article!: Article;

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

// Article을 Comment와 함께 로드 — 삭제된 Comment 자동 제외
const article = await em.findOne(Article, { id: 1 }, {
  populate: ['comments'],
  // comments에도 softDelete 필터 자동 적용
});

QueryBuilder와 Filter

// QueryBuilder에서도 필터 자동 적용
const qb = em.createQueryBuilder(Article)
  .select(['id', 'title'])
  .where({ category: 'tech' })
  .orderBy({ createdAt: 'DESC' })
  .limit(10);

// 생성되는 SQL:
// SELECT id, title FROM article
// WHERE category = 'tech' AND deleted_at IS NULL  ← 필터 자동 추가
// ORDER BY created_at DESC LIMIT 10

// QueryBuilder에서 필터 비활성화
const qb2 = em.createQueryBuilder(Article)
  .select('*')
  .withoutFilter('softDelete');  // MikroORM 6+

실전 패턴: RBAC 데이터 접근 제어

@Entity()
@Filter({
  name: 'visibility',
  cond: (args: { userId: string; role: string }) => {
    // 관리자는 모든 데이터 접근
    if (args.role === 'admin') return {};
    
    // 일반 사용자는 본인 데이터 + 공개 데이터만
    return {
      $or: [
        { owner: { id: args.userId } },
        { visibility: 'public' },
      ],
    };
  },
  default: true,
})
export class Document {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Enum(() => Visibility)
  visibility!: Visibility;

  @ManyToOne(() => User)
  owner!: User;
}

// Guard에서 필터 파라미터 주입
@Injectable()
export class DataAccessGuard implements CanActivate {
  constructor(private readonly em: EntityManager) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    
    this.em.setFilterParams('visibility', {
      userId: user.id,
      role: user.role,
    });
    
    return true;
  }
}

성능 고려사항

  • 인덱스: 필터 조건 컬럼에 반드시 인덱스 추가 (deletedAt, tenantId 등)
  • 복합 인덱스: tenant + softDelete 조합이 빈번하면 @Index({ properties: ['tenant', 'deletedAt'] })
  • Partial Index: PostgreSQL에서 WHERE deleted_at IS NULL 부분 인덱스로 인덱스 크기 절감
  • Identity Map: Filter 변경 후 기존 캐시된 엔티티는 영향받지 않음 — em.clear() 필요시 호출

테스트 전략

describe('ArticleService', () => {
  it('soft deleted articles should be excluded by default', async () => {
    // given
    const article = em.create(Article, { title: 'test' });
    article.softDelete();
    await em.flush();
    em.clear();

    // when
    const results = await em.find(Article, {});

    // then — softDelete 필터로 제외됨
    expect(results).toHaveLength(0);
  });

  it('admin can see soft deleted articles', async () => {
    const results = await em.find(Article, {}, {
      filters: { softDelete: false },
    });
    expect(results).toHaveLength(1);
  });
});

정리

MikroORM Filter는 횡단 관심사(Cross-Cutting Concerns)를 엔티티 레벨에서 선언적으로 해결합니다. Soft Delete, Multi-Tenancy, RBAC 데이터 접근 제어 등을 한 번 정의하면 모든 쿼리에 자동 적용되어, 비즈니스 로직에서 반복적인 WHERE 조건을 제거할 수 있습니다. 콜백 함수를 활용한 동적 필터와 NestJS 미들웨어/Guard 통합으로, 요청 컨텍스트에 따른 자동 데이터 격리를 구현할 수 있습니다.

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