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 + 모든 postsSELECT u, p FROM users u LEFT JOIN posts p ON ... (1쿼리 + join)
User + posts + 각 post의 commentsjoin 2단계 (1쿼리) 또는 N+1 (1 + N 쿼리)
User + posts + comments + authorjoin 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면 카르테시안 폭발

: eagerQueryBuilder.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 — 세 모드 비교 테이블

항목LazyEager명시 relations
필드 타입Promise<T>TT
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 → undefined

user.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와 다른 것.