NestJS + MikroORM populate

populate를 제대로 모르면 N+1이 돌아온다

MikroORM에서 관계 데이터를 로딩하는 핵심 메커니즘은 populate입니다. TypeORM의 relations 옵션과 비슷하지만, MikroORM은 두 가지 LoadStrategy(SELECT_IN, JOINED)를 제공하며, 직렬화(Serialization)까지 populate와 긴밀하게 연결됩니다.

populate를 지정하지 않으면 관계 필드는 초기화되지 않은 Reference 상태로 남아, JSON 응답에 빈 객체나 에러가 발생합니다. 반대로 무분별하게 populate하면 불필요한 쿼리와 데이터 전송이 급증합니다.

이 글은 MikroORM 공식 문서(Loading Strategies, Serializing)를 근거로, NestJS 실무에서의 populate 전략과 함정을 정리합니다.

LoadStrategy: SELECT_IN vs JOINED

MikroORM v5+에서 LoadStrategy는 두 가지입니다. v5부터 기본값이 SELECT_IN으로 변경되었습니다(v4는 JOINED).

SELECT_IN (기본값)

메인 엔티티를 먼저 조회한 뒤, 관계 엔티티를 별도 쿼리(WHERE id IN (…))로 로딩합니다.

const posts = await em.find(Post, {}, {
  populate: ['author', 'tags'],
  strategy: LoadStrategy.SELECT_IN, // 기본값
});
// 실행 쿼리:
// 1) SELECT * FROM post
// 2) SELECT * FROM user WHERE id IN (1, 2, 3, ...)  -- author
// 3) SELECT * FROM tag INNER JOIN post_tags WHERE post_id IN (1, 2, 3, ...) -- tags

장점: JOIN으로 인한 Cartesian Product가 없어 결과 행이 부풀어오르지 않습니다. OneToMany 관계에서 특히 유리합니다.

단점: 쿼리 수가 1 + (populate한 관계 수)만큼 발생합니다.

JOINED

단일 쿼리에서 LEFT JOIN으로 모든 관계를 한 번에 로딩합니다.

const posts = await em.find(Post, {}, {
  populate: ['author', 'tags'],
  strategy: LoadStrategy.JOINED,
});
// 실행 쿼리:
// SELECT p.*, u.*, t.*
// FROM post p
// LEFT JOIN user u ON p.author_id = u.id
// LEFT JOIN post_tags pt ON pt.post_id = p.id
// LEFT JOIN tag t ON pt.tag_id = t.id

장점: 단일 쿼리로 네트워크 라운드트립이 최소화됩니다.

단점: OneToMany + ManyToMany가 중첩되면 Cartesian Product로 결과 행 수가 급증합니다.

전략 비교 테이블

항목 SELECT_IN (기본) JOINED
쿼리 수 1 + N (N = populate 관계 수) 1
Cartesian Product 위험 없음 높음 (다중 1:N 중첩 시)
페이지네이션 정확도 ✅ 메인 쿼리 기준 정확 ❌ JOIN 행 기준으로 limit/offset 왜곡
적합 케이스 1:N, M:N 관계 다수 1:1, N:1 관계 위주
DB 라운드트립 다수 1회
메모리 효율 ✅ 중복 행 없음 ❌ JOIN으로 중복 행 발생

populate 힌트의 다양한 형태

MikroORM의 populate는 여러 형태로 지정할 수 있습니다.

1. 문자열 배열 (가장 일반적)

await em.find(Post, {}, {
  populate: ['author', 'tags', 'comments.author'],
});
// 중첩 관계: comments의 author까지 로딩

2. boolean 객체 (타입 안전)

await em.find(Post, {}, {
  populate: {
    author: true,
    tags: true,
    comments: {
      author: true, // 중첩
    },
  },
});

3. Populate ALL (*)

await em.find(Post, {}, {
  populate: ['*'], // 모든 직접 관계 로딩 (1단계만)
});

주의: ['*']는 1단계 직접 관계만 로딩합니다. 중첩 관계는 포함되지 않습니다. 운영에서 무분별하게 쓰면 불필요한 데이터가 대량 로딩됩니다.

NestJS Service에서의 실무 패턴

@Injectable()
export class PostService {
  constructor(
    @InjectRepository(Post)
    private readonly postRepo: EntityRepository<Post>,
  ) {}

  // 목록 API: 최소한의 populate
  async findAll(page: number, limit: number) {
    return this.postRepo.find(
      {},
      {
        populate: ['author'],           // author만 (목록에서 tags는 불필요)
        limit,
        offset: (page - 1) * limit,
        orderBy: { createdAt: 'DESC' },
        // SELECT_IN 기본값 → 페이지네이션 정확
      },
    );
  }

  // 상세 API: 깊은 populate
  async findOne(id: number) {
    return this.postRepo.findOneOrFail(
      { id },
      {
        populate: ['author', 'tags', 'comments.author'],
        strategy: LoadStrategy.JOINED, // 단건이라 JOINED이 효율적
      },
    );
  }
}

Serialization과 populate의 관계

MikroORM에서 엔티티를 JSON으로 직렬화할 때, populate되지 않은 관계는 FK 값(또는 빈 Reference)으로 출력됩니다. 이를 제어하는 방법은 세 가지입니다.

1. serialize() 유틸리티 (권장)

import { serialize } from '@mikro-orm/core';

const post = await em.findOneOrFail(Post, 1, {
  populate: ['author', 'tags'],
});

const json = serialize(post, {
  populate: ['author', 'tags'],  // 직렬화 범위 지정
  forceObject: true,             // Collection을 배열로 변환
  exclude: ['author.password'],  // 민감 필드 제외
});

2. @Property({ hidden: true }) — 필드 수준 제외

@Entity()
export class User {
  @Property({ hidden: true })
  password: string;

  @Property({ hidden: true })
  passwordHash: string;
}
// serialize() 시 password, passwordHash 필드가 자동 제외

3. @Serialized() 커스텀 직렬화 (v6)

@Entity()
export class Post {
  @ManyToOne(() => User)
  author: User;

  // 직렬화 시 author를 커스텀 형태로 변환
  @Property({ persist: false })
  @Serialized()
  get authorName() {
    return this.author?.name;
  }
}

populate + fields: 필요한 컬럼만 로딩

MikroORM v5+에서 fields 옵션으로 SELECT할 컬럼을 제한할 수 있습니다. populate와 결합하면 네트워크 전송량과 메모리를 절약합니다.

const posts = await em.find(Post, {}, {
  populate: ['author'],
  fields: ['title', 'createdAt', 'author.name', 'author.avatar'],
  // SELECT p.id, p.title, p.created_at, u.id, u.name, u.avatar
  // FROM post p LEFT JOIN user u ON ...
});

주의: fields를 사용할 때 관계 엔티티의 PK는 항상 자동 포함됩니다(MikroORM이 내부적으로 추가). 명시하지 않아도 됩니다.

자주 빠지는 함정 4가지

함정 1: populate 없이 관계 필드 접근 → 초기화되지 않은 Collection

// ❌ populate 없이 관계 접근
const post = await em.findOneOrFail(Post, 1);
console.log(post.tags.getItems()); 
// Error: Collection<Tag> of entity Post[1] not initialized

// ✅ populate 지정
const post = await em.findOneOrFail(Post, 1, {
  populate: ['tags'],
});
console.log(post.tags.getItems()); // [Tag, Tag, ...]

MikroORM은 TypeORM과 달리 초기화되지 않은 관계에 접근하면 명시적으로 에러를 던집니다. 이는 의도된 설계로, N+1 문제를 조기에 발견하게 합니다.

함정 2: JOINED + 페이지네이션 = 잘못된 결과 수

// ❌ JOINED + limit → 결과 왜곡
const posts = await em.find(Post, {}, {
  populate: ['comments'],
  strategy: LoadStrategy.JOINED,
  limit: 10,
});
// SQL: SELECT ... FROM post LEFT JOIN comment ON ... LIMIT 10
// LIMIT이 JOIN 결과 행에 적용되어, 실제 post는 10개 미만일 수 있음

// ✅ SELECT_IN + limit → 정확한 페이지네이션
const posts = await em.find(Post, {}, {
  populate: ['comments'],
  strategy: LoadStrategy.SELECT_IN, // 기본값
  limit: 10,
});
// 1) SELECT * FROM post LIMIT 10 (정확히 10건)
// 2) SELECT * FROM comment WHERE post_id IN (...)

이것이 MikroORM v5에서 기본 전략을 SELECT_IN으로 변경한 핵심 이유입니다. 공식 문서에서 명시적으로 “JOINED strategy is not suitable for pagination with to-many relations”라고 경고합니다.

함정 3: populate된 관계를 JSON.stringify하면 순환 참조

// ❌ 양방향 관계에서 JSON.stringify → 순환 참조 에러
const post = await em.findOneOrFail(Post, 1, {
  populate: ['author', 'author.posts'],
});
JSON.stringify(post); // TypeError: Converting circular structure to JSON

// ✅ serialize() 사용 — 순환 참조 자동 처리
import { serialize } from '@mikro-orm/core';
const json = serialize(post, { populate: ['author'] });
// author.posts는 직렬화에서 제외됨

NestJS의 Controller에서 엔티티를 직접 반환하면 내부적으로 JSON.stringify()가 호출됩니다. 양방향 관계가 populate되어 있으면 순환 참조 에러가 발생합니다. 반드시 serialize() 또는 DTO 변환을 거치세요.

함정 4: populate 깊이를 제한하지 않으면 데이터 폭발

// ❌ 3단계 중첩 populate — 데이터 폭발 가능
await em.find(Post, {}, {
  populate: ['comments.author.posts.tags'],
});
// post → comments → author → posts → tags: 5단계 조인

// ✅ 필요한 깊이만 명시
await em.find(Post, {}, {
  populate: ['comments.author'], // 2단계까지만
  fields: ['title', 'comments.content', 'comments.author.name'],
});

시나리오별 추천 전략 테이블

시나리오 LoadStrategy populate 범위 이유
목록 API + 페이지네이션 SELECT_IN (기본) 1단계 최소한 페이지네이션 정확도 보장
상세 조회 (단건) JOINED 필요한 관계 전부 1회 쿼리로 효율적
Admin 대시보드 (복잡 관계) SELECT_IN 2단계까지 + fields 제한 Cartesian Product 방지
API 응답 직렬화 serialize() + exclude 순환 참조·민감 필드 제어
배치 처리 (대량) SELECT_IN 최소한 + fields 메모리 효율

운영 체크리스트 5항목

  1. 페이지네이션 API에서는 SELECT_IN 사용 — JOINED + limit/offset은 1:N 관계에서 결과가 왜곡됩니다. 기본값(SELECT_IN)을 유지하세요.
  2. 단건 조회에서만 JOINED 고려 — limit가 없는 findOne()에서 JOINED를 쓰면 쿼리 수가 1회로 줄어 효율적입니다.
  3. populate + fields 조합으로 전송량 절약 — 목록 API에서 모든 컬럼을 로딩하지 말고, 필요한 필드만 SELECT하세요.
  4. Controller에서 엔티티 직접 반환 금지serialize() 또는 DTO 변환을 거쳐 순환 참조와 민감 필드 노출을 방지하세요.
  5. populate 깊이는 2단계 이하 원칙 — 3단계 이상 중첩이 필요하면, 별도 쿼리로 분리하거나 API를 나누는 것이 안전합니다.

정리

MikroORM의 populate는 “무엇을, 어떤 전략으로 로딩하고, 어떻게 직렬화할 것인가”를 한 세트로 설계해야 합니다. SELECT_IN은 페이지네이션과 1:N 관계에 안전하고, JOINED는 단건 조회에 효율적입니다.

초기화되지 않은 Collection 에러는 MikroORM이 N+1을 조기 탐지하게 해주는 장치이므로, 무시하지 말고 populate를 명시적으로 지정하세요. 직렬화에서는 serialize()를 사용해 순환 참조와 민감 필드를 제어하는 것이 NestJS 실무의 핵심 패턴입니다.

참고 자료: MikroORM 공식 — Loading Strategies | MikroORM 공식 — Serializing | MikroORM 공식 — EntityManager

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