왜 관계 로딩 전략이 중요한가
NestJS + TypeORM 프로젝트에서 Entity 간 관계(@OneToMany, @ManyToOne, @ManyToMany)는 필수입니다. 그러나 로딩 전략을 의식하지 않으면 N+1 문제가 발생해 쿼리 수가 폭증하고, 운영 환경에서 DB CPU가 튀는 장애로 이어집니다.
이 글에서는 TypeORM 공식 문서(Eager and Lazy Relations, Find Options, Select QueryBuilder)를 근거로 세 가지 로딩 전략의 동작 원리, N+1 발생 조건, 실무 최적화 패턴을 정리합니다.
TypeORM의 세 가지 관계 로딩 전략
1. Eager Loading (eager: true)
Entity 정의에서 eager: true를 설정하면, find() / findOne() 호출 시 해당 관계를 항상 자동으로 LEFT JOIN합니다.
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Post, (post) => post.author, { eager: true })
posts: Post[];
}
동작: userRepository.find()만 호출해도 LEFT JOIN post가 자동 추가됩니다.
주의: TypeORM 공식 문서에 명시된 대로, eager는 find* 계열에서만 작동하고 QueryBuilder에서는 무시됩니다. 또한 관계의 한쪽에만 eager: true를 설정할 수 있습니다(양쪽 설정 시 무한 루프).
2. Lazy Loading (Promise 기반)
관계 필드의 타입을 Promise<T>로 선언하면, 해당 필드에 접근(await)할 때 별도 쿼리가 실행됩니다.
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => Post, (post) => post.author)
posts: Promise<Post[]>;
}
// service
const user = await userRepository.findOneBy({ id: 1 });
const posts = await user.posts; // 이 시점에 SELECT * FROM post WHERE authorId = 1
주의: TypeORM의 Lazy Loading은 JavaScript Promise 기반이며, Hibernate 같은 Proxy 패턴이 아닙니다. await 시점마다 매번 새 쿼리가 발생하므로, 루프 안에서 접근하면 N+1 문제가 그대로 발생합니다.
3. QueryBuilder의 명시적 Join
leftJoinAndSelect()를 사용해 필요한 관계만 선택적으로 조인합니다.
const users = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment')
.where('user.isActive = :active', { active: true })
.getMany();
이 방식이 가장 예측 가능합니다. 어떤 관계를 로딩하는지 코드에 명시되고, 불필요한 JOIN이 자동으로 추가되지 않습니다.
전략별 비교 테이블
| 항목 | Eager | Lazy (Promise) | QueryBuilder Join |
|---|---|---|---|
| 쿼리 시점 | find() 호출 시 자동 | await 접근 시 | 명시적 호출 시 |
| N+1 위험 | 낮음 (JOIN 자동) | 높음 (루프 내 await) | 낮음 (명시적) |
| QueryBuilder 적용 | ❌ 무시됨 | ❌ 별도 로직 | ✅ 완전 제어 |
| 불필요 데이터 로딩 | 높음 (항상 로딩) | 낮음 (접근 시만) | 낮음 (선택적) |
| 코드 가독성 | 높음 (암묵적) | 보통 | 높음 (명시적) |
| 적합 케이스 | 소규모·항상 필요한 관계 | 거의 쓰지 않는 관계 | 복잡 쿼리·운영 API |
N+1 문제 재현과 원인 분석
가장 흔한 N+1 시나리오를 코드로 재현합니다.
// ❌ N+1 발생 코드
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
async getAllUsersWithPosts() {
const users = await this.userRepo.find(); // SELECT * FROM user (1 쿼리)
for (const user of users) {
// Lazy 관계라면:
const posts = await user.posts; // SELECT * FROM post WHERE authorId=? (N 쿼리)
console.log(user.name, posts.length);
}
// 총 1 + N 쿼리 실행
}
}
원인: find()는 eager: true가 아닌 관계를 로딩하지 않습니다. Lazy 필드에 루프 내에서 접근하면 User 수만큼 추가 쿼리가 발생합니다.
해결 방법 1: find()의 relations 옵션
// ✅ 1 쿼리로 해결
const users = await this.userRepo.find({
relations: { posts: true },
});
// SELECT ... FROM user LEFT JOIN post ON ... (단일 쿼리)
해결 방법 2: QueryBuilder JOIN
// ✅ 조건부 JOIN + 선택적 컬럼
const users = await this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post', 'post.isPublished = :pub', { pub: true })
.getMany();
실무에서 자주 빠지는 함정 4가지
함정 1: eager + QueryBuilder 조합 착각
eager: true로 설정해 두고 QueryBuilder를 쓰면, 관계가 로딩되지 않습니다. 공식 문서에 명시: “Eager relations only work when you use find* methods.”
// ❌ posts가 빈 배열로 나옴
const users = await userRepository
.createQueryBuilder('user')
.getMany();
// user.posts === undefined (eager 무시됨)
// ✅ 수정
const users = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.getMany();
함정 2: 깊은 중첩 관계의 연쇄 eager
User → posts(eager) → comments(eager) → author(eager)처럼 체인이 길어지면, 단순 find() 한 번에 거대한 JOIN이 생성됩니다. 데이터가 많으면 Cartesian Product로 결과 행 수가 폭증합니다.
// 위험: 3단계 eager 체인
// 실제 생성 SQL: SELECT ... FROM user
// LEFT JOIN post ON ...
// LEFT JOIN comment ON ...
// LEFT JOIN user author ON ...
// 결과 행 수 = users × posts × comments
대책: eager는 1단계 이하만 사용하고, 2단계 이상은 QueryBuilder 또는 relations 옵션으로 명시적으로 제어하세요.
함정 3: Lazy Loading의 캐싱 착각
TypeORM의 Lazy Loading은 캐싱하지 않습니다. 같은 엔티티에서 await user.posts를 두 번 호출하면 두 번 쿼리합니다. Hibernate의 First-Level Cache와 다릅니다.
const user = await userRepo.findOneBy({ id: 1 });
const a = await user.posts; // SELECT ... (1회)
const b = await user.posts; // SELECT ... (2회, 캐싱 없음!)
함정 4: select 옵션과 relations 충돌
find()에서 select와 relations를 동시에 쓸 때, 관계 Entity의 PK가 select에 포함되지 않으면 매핑이 깨집니다.
// ❌ post.id가 누락되면 관계 매핑 실패
const users = await userRepo.find({
select: { id: true, name: true, posts: { title: true } },
relations: { posts: true },
});
// ✅ 관계 Entity의 PK 포함
const users = await userRepo.find({
select: { id: true, name: true, posts: { id: true, title: true } },
relations: { posts: true },
});
NestJS 실무 패턴: 용도별 추천 전략
| 시나리오 | 추천 전략 | 이유 |
|---|---|---|
| Admin 목록 API (페이지네이션) | QueryBuilder + take/skip |
JOIN 제어 + 페이지네이션 정확도 |
| 상세 조회 API (1건) | findOne({ relations }) |
간결하고 충분 |
| 배치/크론 대량 처리 | QueryBuilder + stream() |
메모리 효율 |
| 항상 필요한 1단계 관계 | eager: true |
편의성 (단, 1단계만) |
| 거의 안 쓰는 무거운 관계 | Lazy (주의해서) | 불필요 로딩 방지 |
QueryBuilder 페이지네이션과 JOIN: getManyAndCount 주의점
getManyAndCount()는 내부적으로 두 쿼리를 실행합니다(데이터 + COUNT). OneToMany JOIN이 포함되면 COUNT가 부풀어오릅니다.
// ❌ count가 post 수 기준으로 부풀어짐
const [users, count] = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.take(10)
.skip(0)
.getManyAndCount();
// count = user 수가 아닌 JOIN 결과 행 수
// ✅ 서브쿼리로 페이징 후 관계 로딩
const userIds = await userRepo
.createQueryBuilder('user')
.select('user.id')
.take(10)
.skip(0)
.getMany();
const users = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.whereInIds(userIds.map(u => u.id))
.getMany();
이 패턴은 TypeORM GitHub 이슈 #3816에서도 반복적으로 논의된 알려진 문제입니다.
운영 체크리스트: N+1 예방 5항목
- eager는 1단계, 1:1 또는 소량 M:1에만 — 대량 1:N에 eager 금지
- Lazy는 루프 밖에서만 사용 — 루프 내 await은 N+1 확정
- QueryBuilder 쓸 때 eager 의존 금지 — leftJoinAndSelect로 명시
- TypeORM logging으로 쿼리 수 확인 —
logging: true또는logging: ['query']를 DataSource 옵션에 설정 - 페이지네이션 API는 서브쿼리 패턴 — take/skip + JOIN 조합 시 COUNT 확인 필수
TypeORM DataSource logging 설정으로 N+1 탐지
// app.module.ts (NestJS)
TypeOrmModule.forRoot({
type: 'mysql',
// ... 기타 설정
logging: ['query'], // 모든 쿼리 로그 출력
maxQueryExecutionTime: 1000, // 1초 이상 쿼리 경고
}),
개발 환경에서 logging: ['query']를 켜두면 API 호출 한 번에 몇 개의 SELECT가 발생하는지 바로 확인할 수 있습니다. N+1이 발생하면 동일 패턴의 SELECT가 반복적으로 출력됩니다.
정리
TypeORM의 관계 로딩은 “편리함 vs 예측 가능성”의 트레이드오프입니다. Eager는 편리하지만 QueryBuilder에서 무시되고, Lazy는 N+1 위험이 크며, QueryBuilder JOIN이 가장 안전하지만 코드량이 늘어납니다.
실무에서는 기본값을 “로딩 안 함”으로 두고, 필요한 곳에서만 relations 또는 leftJoinAndSelect로 명시적으로 로딩하는 것이 가장 안전합니다. 운영 환경에서는 반드시 쿼리 로깅을 켜서 N+1을 조기 탐지하세요.
참고 자료: TypeORM 공식 — Eager and Lazy Relations | TypeORM 공식 — Find Options | TypeORM 공식 — Select QueryBuilder