NestJS + MikroORM 쿼리 빌더 활용

왜 EntityRepository를 제대로 알아야 하는가

NestJS + MikroORM 프로젝트에서 EntityRepository는 데이터 접근의 진입점입니다. 그러나 TypeORM의 Repository와 달리 MikroORM은 Unit of Work(UoW) 패턴 위에서 동작하기 때문에, save() 한 줄로 끝나는 것이 아니라 persist()flush() 흐름을 이해해야 합니다.

또한 getReference(), assign(), em.flush() 같은 메서드는 불필요한 SELECT를 줄이고 벌크 업데이트를 최적화하는 핵심 도구이지만, 잘못 쓰면 “왜 DB에 반영이 안 되지?”라는 버그로 이어집니다.

이 글은 MikroORM 공식 문서(Working with EntityRepository, Working with EntityManager, Unit of Work)를 근거로 실무에서 빈번한 패턴과 함정을 정리합니다.

EntityRepository의 구조와 EntityManager 관계

MikroORM의 EntityRepository는 내부적으로 EntityManager(이하 em)의 래퍼입니다. 모든 Repository 메서드는 결국 em을 통해 실행됩니다.

// MikroORM 내부 구조 (단순화)
class EntityRepository<T> {
  constructor(protected readonly em: EntityManager) {}

  findOne(where: FilterQuery<T>) {
    return this.em.findOne(this.entityName, where);
  }

  persist(entity: T) {
    return this.em.persist(entity);
  }

  // flush()는 em에 위임
  async flush() {
    return this.em.flush();
  }
}

핵심: Repository는 편의 계층이고, 실제 상태 관리(Identity Map, Change Set 계산)는 모두 EntityManager가 담당합니다. 따라서 같은 em(같은 Request Context) 안에서 서로 다른 Repository를 통해 조작한 엔티티들도 하나의 flush()로 일괄 커밋됩니다.

persist()와 flush() 분리의 의미

TypeORM의 repository.save()는 즉시 INSERT/UPDATE를 실행합니다. MikroORM은 다릅니다:

  • persist(entity): 엔티티를 em의 Identity Map에 등록(관리 대상으로 표시). DB 호출 없음.
  • flush(): Identity Map 내 모든 변경 사항을 계산(Change Set Computation)하고, 한 번에 INSERT/UPDATE/DELETE를 실행.
// NestJS Service 예시
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: EntityRepository<Order>,
  ) {}

  async createOrder(dto: CreateOrderDto): Promise<Order> {
    const order = this.orderRepo.create(dto); // 엔티티 생성
    this.orderRepo.persist(order);             // Identity Map에 등록 (DB 호출 X)

    // 다른 로직 수행...

    await this.orderRepo.flush();              // 이 시점에 INSERT 실행
    return order;
  }
}

이 분리 덕분에 여러 엔티티를 조작한 뒤 한 번의 flush()로 모아서 커밋할 수 있습니다. 트랜잭션 수가 줄고 성능이 개선됩니다.

getReference(): SELECT 없이 관계 설정하기

가장 과소평가된 최적화 메서드입니다. 외래 키만 알고 있을 때, 전체 엔티티를 SELECT 하지 않고 프록시 참조만 생성합니다.

// ❌ 불필요한 SELECT 발생
async assignAuthor(postId: number, authorId: number) {
  const post = await this.postRepo.findOneOrFail(postId);
  const author = await this.userRepo.findOneOrFail(authorId); // SELECT 1회
  post.author = author;
  await this.postRepo.flush();
}

// ✅ getReference로 SELECT 제거
async assignAuthor(postId: number, authorId: number) {
  const post = await this.postRepo.findOneOrFail(postId);
  post.author = this.em.getReference(User, authorId); // SELECT 없음, 프록시만 생성
  await this.postRepo.flush(); // UPDATE post SET author_id = ? WHERE id = ?
}

공식 문서(Entity Manager — Using References)에서 이 패턴을 명시적으로 권장합니다. FK 값만 필요한 관계 설정에서 SELECT를 완전히 제거할 수 있습니다.

getReference 주의점

프록시이므로 PK 외의 속성에 접근하면 LazyLoad가 트리거되거나(Lazy 설정 시) 에러가 발생합니다. FK 설정 용도로만 사용해야 합니다.

const ref = this.em.getReference(User, 1);
console.log(ref.id);   // ✅ PK는 즉시 접근 가능
console.log(ref.name); // ⚠️ 초기화 안 된 프록시 — DB 조회 발생 or undefined

assign(): 부분 업데이트의 안전한 방법

wrap(entity).assign(data) 또는 em.assign(entity, data)는 DTO의 값을 엔티티에 안전하게 병합합니다. 직접 Object.assign을 쓰면 관계 필드가 깨지거나 Change Set 감지가 누락될 수 있습니다.

// ❌ Object.assign — MikroORM의 Change Tracking이 관계를 놓칠 수 있음
Object.assign(order, updateDto);

// ✅ wrap().assign — 관계 필드도 안전하게 처리
import { wrap } from '@mikro-orm/core';

async updateOrder(id: number, dto: UpdateOrderDto) {
  const order = await this.orderRepo.findOneOrFail(id);
  wrap(order).assign(dto, { mergeObjectProperties: true });
  await this.orderRepo.flush();
}

assign()의 옵션:

  • mergeObjectProperties: true — 중첩 객체를 deep merge (기본은 교체)
  • onlyProperties: true — 엔티티에 정의된 속성만 할당 (DTO에 잡 필드가 섞여도 안전)
  • onlyOwnProperties: true (v6) — 상속 속성 제외

공식 문서: Entity Helper — assign

Custom Repository 패턴: NestJS에서의 구현

MikroORM v6에서 Custom Repository를 만드는 공식 패턴입니다.

// order.repository.ts
import { EntityRepository } from '@mikro-orm/mysql'; // 또는 @mikro-orm/postgresql

export class OrderRepository extends EntityRepository<Order> {
  async findActiveByUser(userId: number): Promise<Order[]> {
    return this.find(
      { user: userId, status: OrderStatus.ACTIVE },
      { orderBy: { createdAt: 'DESC' }, limit: 50 },
    );
  }

  async softCancelOrder(id: number): Promise<void> {
    const order = await this.findOneOrFail(id);
    order.status = OrderStatus.CANCELLED;
    order.cancelledAt = new Date();
    // flush는 호출하지 않음 — Service 레이어에서 제어
  }
}

// order.entity.ts
@Entity({ repository: () => OrderRepository })
export class Order {
  @PrimaryKey()
  id: number;

  @ManyToOne(() => User)
  user: User;

  @Enum(() => OrderStatus)
  status: OrderStatus;

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

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

핵심: Entity의 @Entity({ repository: () => OrderRepository })에서 Custom Repository를 연결합니다. NestJS에서는 @InjectRepository(Order)로 주입하면 자동으로 OrderRepository 타입으로 들어옵니다.

// order.service.ts
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: OrderRepository, // Custom Repository 타입으로 주입됨
  ) {}

  async cancelOrder(id: number) {
    await this.orderRepo.softCancelOrder(id);
    await this.orderRepo.flush(); // Service에서 flush 시점 제어
  }
}

flush() 타이밍 전략: 어디서 호출해야 하는가

전략 flush 위치 장점 단점
Service 레이어에서 명시적 flush Service 메서드 끝 비즈니스 로직과 커밋 시점이 명확 flush 누락 가능
Middleware/Interceptor 자동 flush Request 종료 시점 일관된 커밋, 누락 방지 에러 시 롤백 로직 추가 필요
Repository 내부 flush Repository 메서드마다 단위 완결성 여러 Repository 조합 시 중간 커밋 위험

권장: MikroORM 공식 문서와 NestJS-MikroORM 가이드에서는 Service 레이어에서 명시적으로 flush()하는 것을 기본 패턴으로 권장합니다. @CreateRequestContext() 데코레이터와 결합하면 Request 스코프의 em에서 안전하게 동작합니다.

persist() 없이도 flush()가 작동하는 경우

이것이 MikroORM 초보자를 가장 혼란스럽게 하는 부분입니다. 이미 Identity Map에 로딩된 엔티티persist() 없이 속성만 변경하고 flush()하면 자동으로 UPDATE됩니다.

// persist() 호출 없이 UPDATE 발생
const user = await this.userRepo.findOneOrFail(1); // Identity Map에 등록됨
user.name = 'New Name'; // 속성 변경
await this.userRepo.flush(); // Change Set 감지 → UPDATE 실행

// persist()가 필요한 경우: 새 엔티티
const newUser = new User();
newUser.name = 'Brand New';
this.userRepo.persist(newUser); // 새 엔티티이므로 persist 필요
await this.userRepo.flush(); // INSERT 실행

규칙: persist()새 엔티티(INSERT)에만 필요합니다. 기존 엔티티의 수정(UPDATE)은 em이 자동 감지합니다.

자주 빠지는 함정 4가지

함정 1: flush() 안 하고 “DB에 안 들어가요”

TypeORM에서 넘어온 개발자가 가장 많이 겪는 문제입니다. persist()만 호출하면 메모리에만 반영됩니다.

// ❌ flush 누락 — DB에 저장 안 됨
this.orderRepo.persist(newOrder);
return newOrder; // 여기서 반환하면 DB에 INSERT 안 됨

// ✅ flush 추가
this.orderRepo.persist(newOrder);
await this.orderRepo.flush();
return newOrder;

함정 2: 다른 Request Context의 em으로 flush

MikroORM의 Identity Map은 em 인스턴스에 바인딩됩니다. @CreateRequestContext()가 적용된 메서드 안에서 생성한 엔티티를 밖의 em으로 flush하면 Change Set이 비어 있습니다.

// ❌ em 불일치
@CreateRequestContext()
async processQueue(payload: any) {
  const order = this.orderRepo.create(payload);
  this.orderRepo.persist(order);
  // 이 메서드가 끝나면 RequestContext의 em이 폐기됨
}
// flush를 이 메서드 밖에서 호출하면 다른 em이라 무시됨

// ✅ 같은 컨텍스트 안에서 flush
@CreateRequestContext()
async processQueue(payload: any) {
  const order = this.orderRepo.create(payload);
  this.orderRepo.persist(order);
  await this.orderRepo.flush(); // 같은 em에서 flush
}

함정 3: getReference로 만든 프록시의 속성 접근

getReference()로 생성한 프록시에서 PK 이외의 속성을 읽으면, 초기화 안 된 상태에서 undefined가 반환되거나 추가 SELECT가 발생합니다. 관계 설정 전용으로만 사용하세요.

함정 4: Custom Repository에서 em 직접 접근 시 포크(fork) 이슈

MikroORM v6에서 Repository의 this.em은 Request Context가 활성화되어 있으면 자동으로 포크된 em을 사용합니다. 그러나 생성자 시점에서 em을 변수에 캐싱하면 글로벌 em이 캐싱되어 Request Context가 무시됩니다.

// ❌ 생성자에서 em 캐싱 — Request Context 무시
export class OrderRepository extends EntityRepository<Order> {
  private cachedEm: EntityManager;

  constructor(em: EntityManager) {
    super(em);
    this.cachedEm = em; // 글로벌 em이 캐싱됨!
  }

  async customMethod() {
    return this.cachedEm.find(Order, {}); // Request Context 무시
  }
}

// ✅ 항상 this.em으로 접근
export class OrderRepository extends EntityRepository<Order> {
  async customMethod() {
    return this.em.find(Order, {}); // Request Context의 포크된 em 사용
  }
}

성능 최적화 체크리스트 5항목

  1. FK만 필요한 관계 설정에는 getReference() — 불필요한 SELECT를 제거합니다. 대량 데이터 삽입 시 효과가 큽니다.
  2. 부분 업데이트에는 wrap(entity).assign(dto)Object.assign 대신 사용해 Change Set 감지가 정확히 동작하게 합니다.
  3. flush()는 Service 레이어에서 한 번만 — 여러 Repository를 조합하는 로직에서 중간 flush를 피하고, 비즈니스 단위 끝에서 한 번 flush합니다.
  4. 새 엔티티만 persist(), 기존 엔티티 수정은 자동 감지 — 불필요한 persist() 호출을 줄이고 코드를 간결하게 유지합니다.
  5. 배치 삽입 시 em.persistAndFlush() 대신 persist() 모아서 한 번 flush() — 1000건 삽입 시 flush 1회로 모아 INSERT 배치 처리 효과를 얻습니다.

persist + flush 배치 최적화 예시

// ❌ 건별 flush — 1000회 INSERT
for (const dto of bulkDtos) {
  const entity = this.repo.create(dto);
  await this.repo.persistAndFlush(entity); // 매번 flush!
}

// ✅ 모아서 flush — 1회 flush로 배치 INSERT
for (const dto of bulkDtos) {
  const entity = this.repo.create(dto);
  this.repo.persist(entity); // 메모리에만 축적
}
await this.repo.flush(); // 한 번에 INSERT 실행

비교 테이블: MikroORM vs TypeORM Repository 패턴

항목 MikroORM EntityRepository TypeORM Repository
저장 메커니즘 persist() → flush() (UoW) save() (즉시 실행)
변경 감지 Identity Map 기반 자동 감지 없음 (save 시 전체 덮어쓰기)
FK 전용 참조 getReference() ✅ 없음 (전체 find 필요)
부분 업데이트 assign() + flush() update() 또는 save(부분 객체)
배치 최적화 persist 모아서 flush 1회 save([entities]) 또는 insert()
Custom Repository @Entity({ repository }) 연결 @EntityRepository (deprecated) → DataSource.getRepository

정리

MikroORM의 EntityRepository는 TypeORM과 달리 Unit of Work 패턴의 일부입니다. persist()는 등록이고 flush()가 실행입니다. 이 분리를 이해하면 배치 최적화, 트랜잭션 범위 제어, 불필요 쿼리 제거가 자연스럽게 따라옵니다.

getReference()로 SELECT 없이 FK를 설정하고, assign()으로 안전하게 부분 업데이트하며, flush()를 Service 레이어에서 한 번만 호출하는 것이 NestJS + MikroORM 실무의 핵심 패턴입니다.

참고 자료: MikroORM 공식 — Working with EntityRepository | MikroORM 공식 — EntityManager | MikroORM 공식 — Unit of Work | MikroORM 공식 — Entity Helper assign

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