04 — Eager vs Lazy 로딩
질문: 관계를 언제 DB에서 로드할 것인가 — 엔티티를 가져올 때 자동으로 같이인가, 필요한 순간 지연시키는가, 매번 명시적으로인가? 한 줄 답: 세 가지 모드가 있다 — eager (자동 join, N+1로 가는 가장 빠른 길), lazy (Promise 필드, await 한 번 = 쿼리 한 번 함정), 명시 relations (
find({ relations })로 매번 지정, 사실상 유일하게 안전한 디폴트).
한 장 그림
Why — 왜 기본값이 중요한가
엔티티는 관계의 그래프다. User → Posts → Comments → Author 같은 깊이 4의 체인이 있을 때, 어디까지 SQL로 가져올 것인가는 애플리케이션 성능을 결정한다.
| 시나리오 | SQL 비용 |
|---|---|
| User만 가져오기 | SELECT * FROM users WHERE id=? (1쿼리) |
| User + 모든 posts | SELECT u, p FROM users u LEFT JOIN posts p ON ... (1쿼리 + join) |
| User + posts + 각 post의 comments | join 2단계 (1쿼리) 또는 N+1 (1 + N 쿼리) |
| User + posts + comments + author | join 3단계 또는 카르테시안 폭발 가능 |
결정적 사실: 기본값으로 어떤 모드를 선택하느냐가 1000번의 find 코드의 기본 성능을 결정한다.
How — 세 가지 모드 코드
1) Lazy 모드 — 전염성 함정
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@OneToMany(() => Post, (post) => post.user, { lazy: true })
posts: Promise<Post[]>; // ★ Promise 타입
}
// 사용
const user = await userRepo.findOne({ where: { id: 1 } });
console.log(user.posts); // Promise<Post[]>
const posts = await user.posts; // ← 여기서 SELECT * FROM posts WHERE userId=1특징:
- 필드 타입이
Promise<T>로 바뀐다 → TypeScript 시그니처가 모든 곳에서 변경됨 (전염성) await user.posts한 번 호출에 쿼리 1회 발생- 리스트에서 각 user마다 await 호출 → N+1 (
for (const u of users) await u.posts)
2) Eager 모드 — 기본 join
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.user, { eager: true })
posts: Post[];
}
// 사용
const user = await userRepo.findOne({ where: { id: 1 } });
// → SELECT u.*, p.* FROM users u LEFT JOIN posts p ON p.userId=u.id WHERE u.id=1
console.log(user.posts); // 이미 로드되어 있음특징:
- 모든
find/findOne이 자동으로 LEFT JOIN relations: { posts: true }를 안 써도 항상 따라온다- 깊이 있는 관계까지 eager면 카르테시안 폭발
단:
eager는QueryBuilder.createQueryBuilder()에는 적용되지 않는다 — find류에만 동작.
3) 명시 relations — 권장 디폴트
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.user) // ★ eager/lazy 옵션 없음
posts: Post[];
}
// 사용 — 필요할 때만 명시
const user = await userRepo.findOne({
where: { id: 1 },
relations: { posts: true }, // ★ 의식적 선택
});특징:
- 기본은 안 로드. 필요한 곳에서만
relations지정 - 코드의 각 호출 지점이 자기 비용을 명시
- TypeScript 타입은 항상
Post[](Promise 아님)
What — 세 모드 비교 테이블
| 항목 | Lazy | Eager | 명시 relations |
|---|---|---|---|
| 필드 타입 | Promise<T> ★ | T | T |
find() 자동 동작 | 안 가져옴 | 항상 LEFT JOIN | 안 가져옴 |
| 가져오기 방법 | await entity.field | 자동 | relations: { field: true } |
| 쿼리 횟수 | 호출 횟수만큼 | 1 (join) | 1 (join) |
| N+1 위험 | 극도로 높음 (리스트 + await) | 중간 (깊은 관계) | 낮음 (의식적 선택) |
| TypeScript 시그니처 | 전염됨 | 일반 | 일반 |
QueryBuilder 호환 | 별도 처리 | 적용 안 됨 | 별도 처리 |
| 권장 시점 | 거의 안 씀 | 얕은 + 항상 같이 가는 관계만 | 거의 모든 경우 ★ |
What-if — 함정의 카탈로그
1) Eager + 리스트 조회 — 카르테시안 폭발
@Entity()
export class User {
@OneToMany(() => Post, (p) => p.user, { eager: true })
posts: Post[]; // 한 user당 평균 50 post
}
@Entity()
export class Post {
@ManyToMany(() => Tag, { eager: true })
@JoinTable()
tags: Tag[]; // 한 post당 평균 5 tag
}
// 사용
await userRepo.find(); // ★ user 1000명결과 SQL:
SELECT * FROM users u
LEFT JOIN posts p ON p.userId=u.id
LEFT JOIN post_tags pt ON pt.postId=p.id
LEFT JOIN tags t ON t.id=pt.tagId→ 1000 user × 50 post × 5 tag = 25만 row가 SELECT로 돌아옴 (사실상 모두 중복 데이터). 메모리 폭발 + 응답 1분 이상.
2) Lazy + for-of — 전형적 N+1
@OneToMany(() => Post, (p) => p.user, { lazy: true })
posts: Promise<Post[]>;
// 사용
const users = await userRepo.find(); // 1쿼리 (user 1000명)
for (const user of users) {
const posts = await user.posts; // ★ 각 user마다 1쿼리
}
// → 총 1 + 1000 = 1001 쿼리이게 N+1의 교과서적 예. lazy는 각 await가 쿼리 1회를 보장하므로, 리스트 안에서 await하면 자동으로 N+1. 다음 챕터 05 — Cascade & Orphan 다음의 07 — 성능 & N+1에서 끝장을 본다.
3) Eager에 QueryBuilder는 적용 안 됨
@OneToMany(() => Post, (p) => p.user, { eager: true })
posts: Post[];
// find — eager 적용됨
const u1 = await userRepo.findOne({ where: { id: 1 } });
console.log(u1.posts); // 있음
// QueryBuilder — eager 무시
const u2 = await userRepo
.createQueryBuilder('user')
.where('user.id = :id', { id: 1 })
.getOne();
console.log(u2.posts); // ★ undefined!→ 같은 엔티티인데 어느 API로 가져왔는지에 따라 완전히 다른 데이터 형태. 디버깅 지옥.
4) Lazy 필드를 동기 코드에 노출
@OneToMany(() => Post, (p) => p.user, { lazy: true })
posts: Promise<Post[]>;
// React 컴포넌트
<div>{user.posts.length}</div> // ★ Promise.length → undefineduser.posts가 Promise라는 사실을 프론트엔드까지 의식해야 한다 — 모델 시그니처 오염. 서버↔클라이언트 계약이 비대칭이 된다.
5) relations: true 깊이를 통제 못 함
await userRepo.find({
relations: { posts: { comments: { author: true } } }, // ★ 깊이 4
});posts × comments × authors 카르테시안 결과 — 이미 N+1보다 무거운 단일 쿼리. 깊이 2 이상은 QueryBuilder + leftJoinAndSelect + select로 명시 select하는 게 안전.
Insight — eager는 기본값으로 결코 두지 말 것
”TypeORM 공식 문서가 lazy를 첫 번째로 소개한 결과”
typeorm 초창기 문서는 lazy를 권장했다 — Hibernate 영향. 결과적으로 Promise 필드의 전염성이 현장에서 가장 큰 불만이 되었고, Prisma가 eager-by-default + select-on-demand로 대안을 보이면서 TypeORM의 권장도 명시 relations로 이동했다.
현재 권장: eager/lazy 옵션은 기본 끄기. 모든 find에 명시 relations 사용.
”Prisma는 어떻게 다르게 결정했는가”
// Prisma
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: { include: { comments: true } } }, // ★ 명시
});Prisma는 eager/lazy 옵션 자체가 없다 — 모든 관계는 명시적으로 include하거나 안 가져온다. 옵션의 부재가 함정의 부재. TypeORM의 데코레이터 옵션이 너무 많은 것이 성능 사고의 근원이라는 비판의 출발점.
”Hibernate의 lazy fetching은 세션 안에서만 동작한다”
JPA/Hibernate에서 lazy는 Session/EntityManager가 살아 있는 동안만 풀린다 — 컨트롤러를 떠나는 순간 LazyInitializationException 폭탄. TypeORM은 Promise로 우회했지만, 본질은 같다: 언제든 lazy 필드 접근이 쿼리를 트리거한다.
멘탈 모델: lazy = “지금 안 가져왔지만 나중에 필요하면 즉시 쿼리 보낼게”라는 시한폭탄.
”relations 옵션의 진짜 비용”
find({ relations: { posts: true } })는 겉으로 join 한 번처럼 보이지만, 깊이 2 이상이면 카르테시안 곱 결과를 코드가 다시 그룹화한다. 깊은 관계는 분리된 쿼리 + 코드 조립이 더 빠르다 — 이게 07 챕터의 데이터로더 패턴.
”eager는 결코 안 쓴다가 아니라 언제 써도 되는가”
eager가 안전한 단 한 가지 경우: 얕은 1:1 + 항상 같이 가져옴이 보장 + N+1 시점이 없음 (find의 결과가 항상 단일).
예:
@Entity()
export class User {
@OneToOne(() => Profile, { eager: true }) // ★ 항상 1:1, 단일 조회 위주
@JoinColumn()
profile: Profile;
}이외에는 모두 명시 relations.
요약
명시 relations가 사실상 유일하게 안전한 디폴트. eager: true는 N+1로 가는 가장 빠른 길 — 모든 find에 조용히 join이 따라붙는다. lazy는 Promise 전염이라는 다른 비용 — 코드 시그니처가 모든 곳에서 변한다. 깊이 2 이상의 relations는 분리 + 조립으로 — 카르테시안 폭발을 피한다.
다음: 05 — Cascade & Orphan — 로딩과 짝을 이루는 영속성 전파. TypeORM의 cascade는 DB CASCADE와 다른 것.