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