NestJS + TypeORM 관계 로딩: Eager

왜 관계 로딩 전략이 중요한가

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()에서 selectrelations를 동시에 쓸 때, 관계 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항목

  1. eager는 1단계, 1:1 또는 소량 M:1에만 — 대량 1:N에 eager 금지
  2. Lazy는 루프 밖에서만 사용 — 루프 내 await은 N+1 확정
  3. QueryBuilder 쓸 때 eager 의존 금지 — leftJoinAndSelect로 명시
  4. TypeORM logging으로 쿼리 수 확인logging: true 또는 logging: ['query']를 DataSource 옵션에 설정
  5. 페이지네이션 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

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux