01 · N+1 패턴 — TypeORM의 기본값

이 문서가 답하는 질문: TypeORM에서 N+1은 정확히 어디서 어떻게 발생하는가? 왜 그것이 버그가 아니라 기본값인가? 한 줄 답: @OneToMany 관계를 그냥 데코레이터로 박고 끝내면, lazy 접근마다 SQL이 발사된다 — eager는 그 방향을 뒤집을 뿐 N+1을 막지 않는다.”


Why — 왜 TypeORM은 N+1을 기본값으로 두는가

ORM의 가장 오래된 트레이드오프가 여기에 있다.

  • 만약 모든 관계를 기본적으로 join하면 → findOne(User) 한 번에 모든 자식 트리를 로드하는 원치 않는 쿼리가 된다.
  • 만약 모든 관계를 기본적으로 lazy하게 두면 → user.posts 접근마다 SQL이 나가는 N+1이 된다.

TypeORM은 Hibernate의 결정을 그대로 따라 lazy를 기본값으로 둔다. 이것은 방어적인 선택이지만, 명시적 끄기가 없으면 프로덕션 DB가 죽는다.

핵심 주장:

  • TypeORM은 데코레이터로 관계만 박아 두면 SQL을 최대한 미룬다.
  • find()기본적으로 관계를 가져오지 않는다.
  • eager: true방향을 뒤집어도 문제가 없어지지 않는다 — 단지 항상 join이 붙는 다른 문제가 된다.

How — N+1이 발생하는 정확한 자리

엔티티 정의

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  name: string;
 
  @OneToMany(() => Post, (post) => post.user)
  posts: Post[];   // 데코레이터만 박혀 있다 — 기본은 lazy
}
 
@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  title: string;
 
  @ManyToOne(() => User, (user) => user.posts)
  user: User;
}

가장 무해해 보이는 코드

const users = await userRepo.find({ take: 100 });
 
for (const user of users) {
  console.log(user.posts);  // ← 여기서 N+1
}

발사되는 SQL

-- 1번 (부모)
SELECT * FROM user LIMIT 100;
 
-- N번 (자식) — eager일 경우만 자동 발사, lazy + 직접 접근일 경우 undefined
-- 사람이 매번 find로 가져와야 한다:
SELECT * FROM post WHERE user_id = 1;
SELECT * FROM post WHERE user_id = 2;
SELECT * FROM post WHERE user_id = 3;
-- ... 100번

중요: TypeORM의 기본 lazyHibernate와 달리 user.posts를 접근해도 자동으로 SQL을 발사하지 않는다undefined가 반환된다. 그 자리에서 개발자가 수동으로 postRepo.find({ where: { user: { id: user.id } } })루프 안에서 호출하는 순간 N+1이 손으로 만들어진다.

eager: true의 함정

@OneToMany(() => Post, (post) => post.user, { eager: true })
posts: Post[];

이제 findOne(User) 한 번에 항상 posts가 같이 온다 — 좋은 일 같지만:

// 이 코드는 어디서나 자동으로 posts를 로드한다
const user = await userRepo.findOne({ where: { id: 1 } });
// → SELECT user.*, post.* FROM user LEFT JOIN post ON ... WHERE user.id = 1
// 100명을 가져올 때도 — 카르테시안 폭발의 출발점
const users = await userRepo.find({ take: 100 });
// → SELECT user.*, post.* FROM user LEFT JOIN post ON ... LIMIT 100
//   posts가 평균 50개라면: 100 × 50 = 5000행 메모리 적재

eagerN+1을 막지 않는다 — 단지 항상 join이 붙는 다른 문제로 교환한다.

Mermaid — TypeORM의 두 기본 경로


What — TypeORM N+1의 세 얼굴

얼굴 1 — 루프 안에서 await

const users = await userRepo.find({ take: 100 });
const enriched = [];
 
for (const user of users) {
  const posts = await postRepo.find({ where: { user: { id: user.id } } });
  enriched.push({ ...user, posts });
}
// → 1 + 100 = 101번의 round-trip

얼굴 2 — eager + 거대한 부모 컬렉션

@Entity()
class User {
  @OneToMany(() => Post, (p) => p.user, { eager: true })
  posts: Post[];
}
 
const users = await userRepo.find();   // 1만 명
// → 한 방의 거대 JOIN, 메모리 OOM

얼굴 3 — lazy properties (Promise<T> 반환)

TypeORM은 진짜 lazy도 지원한다 — Promise<Post[]>를 반환 타입으로 선언하면.

@OneToMany(() => Post, (post) => post.user)
posts: Promise<Post[]>;   // ← Promise 타입
 
const user = await userRepo.findOne({ where: { id: 1 } });
const posts = await user.posts;  // ← 여기서 새 SQL이 발사된다
-- 위의 await에서 발사
SELECT * FROM post WHERE user_id = 1;

이게 가장 암묵적으로 위험하다 — 코드만 봐서는 SQL이 발사되는지 안 보인다.

비교 테이블

패턴SQL 발사 시점N+1 위험메모리 위험권장
기본 lazy + 명시 안 함find() 1번만낮음 (관계 미접근)낮음OK
기본 lazy + 루프 안 fetchfind() + N번매우 높음낮음금지
eager: true + findOnejoin 1번없음낮음OK
eager: true + find (큰 N)join 1번없음매우 높음위험
Promise<T> lazy property접근 시마다암묵적으로 높음낮음금지
relations: { ... } 명시join 1번없음중간권장

What-if — 잘못된 해석들

오해 1 — “lazy면 자동으로 가져와 줄 거다”

아니다. TypeORM의 lazy는 undefined를 반환한다 (Promise<T> 타입이 아닌 한). Hibernate처럼 proxy로 가로채서 자동 fetch하지 않는다.

오해 2 — “eager: true가 안전한 디폴트다”

아니다. eager는 모든 find 호출에 항상 join을 붙인다. count 쿼리, list 쿼리, 단순 ID 조회 등 posts가 필요 없는 곳에서도 비용을 만든다.

오해 3 — “Promise lazy property가 깔끔하다”

타입은 깔끔해 보이지만, await user.postsSQL을 발사한다는 사실이 호출 측에서 안 보인다. 코드 리뷰에서 N+1을 잡기 어려운 가장 위험한 패턴이다.

오해 4 — “TypeORM이 Hibernate처럼 자동 join 추론해줄 거다”

아니다. TypeORM은 명시적으로 relations 옵션을 안 주면 절대 join하지 않는다. 이게 Hibernate보다 보수적이지만 암묵적 마법이 없는 트레이드오프다.


Insight — 한 단락 이야기

“TypeORM의 N+1은 Hibernate가 2003년에 만든 기본값의 유산이다”

2003년 Hibernate 2.0이 *fetch=“lazy”*를 디폴트로 정했을 때, 그 결정은 Java EJB 2.x의 eager-only 모델이 만든 OOM 사고에 대한 반작용이었다. “필요할 때만 가져오자” 라는 단순한 직관 — 하지만 그 직관이 resolver-per-field루프 안 await 같은 접근 패턴과 만났을 때 N+1이 폭발했다. TypeORM은 그 유산을 그대로 계승하면서도 한 가지를 더 보수적으로 만들었다 — Promise<T>가 아닌 lazy 관계는 자동 proxy fetch도 안 한다. 이것은 암묵적 마법을 없앤 선택이지만, 명시적 끄기를 강제하는 학습 곡선을 만들었다. TypeORM에서 N+1을 만나는 것은 내가 무엇을 명시하지 않았는지를 만나는 것이다. 다음 문서(02)는 그 명시의 첫 번째 도구인 relations 옵션을 본다.


요약 + Mermaid

핵심 키
TypeORM의 기본 fetchlazy (Hibernate 따라감)
lazy의 의미undefined (Promise<T>가 아닌 한)
N+1이 자동 발생하나아니다 — 손으로 fetch해야 발생
eager: true의 문제join이 항상 붙음 — 카르테시안 위험
명시적 끄기 도구relations 옵션 (다음 문서)

한 줄 결론 — TypeORM의 N+1은 기본값으로 lazy인 결정과 루프 안에서 await하는 접근 패턴이 만나서 손으로 만들어지는 문제다. 다음 문서(02)는 그것을 한 방의 LEFT JOIN으로 흡수하는 relations 옵션을 본다.