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의 기본 lazy는 Hibernate와 달리
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행 메모리 적재eager는 N+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 + 루프 안 fetch | find() + N번 | 매우 높음 | 낮음 | 금지 |
eager: true + findOne | join 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.posts가 SQL을 발사한다는 사실이 호출 측에서 안 보인다. 코드 리뷰에서 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의 기본 fetch | lazy (Hibernate 따라감) |
| lazy의 의미 | undefined (Promise<T>가 아닌 한) |
| N+1이 자동 발생하나 | 아니다 — 손으로 fetch해야 발생 |
| eager: true의 문제 | join이 항상 붙음 — 카르테시안 위험 |
| 명시적 끄기 도구 | relations 옵션 (다음 문서) |
한 줄 결론 — TypeORM의 N+1은 기본값으로 lazy인 결정과 루프 안에서 await하는 접근 패턴이 만나서 손으로 만들어지는 문제다. 다음 문서(02)는 그것을 한 방의 LEFT JOIN으로 흡수하는 relations 옵션을 본다.