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 통합으로, 요청 컨텍스트에 따른 자동 데이터 격리를 구현할 수 있습니다.