ORM을 사용하다보면 N + 1 문제를 마주하곤 하는데, 특히 ORM의 Default Fetch Type설정이 Lazy일 경우 더 그렇다.
이제 막 typeORM을 사용해보고 있다고 하시는 분과 커피챗을 할 기회가 생겼는데 typeORM에서는 N + 1을 어떻게 해결하냐는 얘기가 나왔었다.
N + 1이 어떤 것인지는 알고 있었으나 나는 typeORM을 사용하면서 실제적으로 N + 1을 마주한 경험이 없다.
실제로 실무에서도 페이징을 위한 paginate 라이브러리 사용 시 Distinct로 클러스터 인덱스를 가져와서 리스트, 페이징에 총 세 번의 쿼리를 사용하는 경우를 제외하고는 본 적이 없다.
왜 그럴까 곰곰이 생각을 해봤다. 최근에 nest에서 graphQL을 사용하고자 했을 때에도 N + 1 문제를 마주했으며
그 때는 DataLoader로 해결했었다.
마주하지 않는 문제더라도, 궁금증이 생겨서 직접 실험해보고 포스팅을 남기기로 했다.
import { UpdatableEntity } from 'src/abstract/base.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Enrollment } from './enrollment.entity';
import { Teacher } from './teacher.entity';
@Entity('lecture', { database: 'test' })
export class Lecture extends UpdatableEntity {
@Column('int', { name: 'teacher_id', nullable: false })
teacherId!: number;
@Column('varchar', { length: 100, unique: true, nullable: false })
title!: string;
@Column('text', { nullable: false })
content!: string;
@Column('int', { nullable: false })
price!: number;
@OneToMany(() => Enrollment, (enrollments) => enrollments.lecture)
@JoinColumn({ referencedColumnName: 'enrollmentId' })
enrollments?: Enrollment[];
@ManyToOne(() => Teacher, (teacher) => teacher.lectures, { eager: true })
@JoinColumn({ name: 'teacher_id', referencedColumnName: 'id' })
teacher?: Teacher;
}
나는 엔터티의 관계 매핑을 할 때 RelationOptions에 loding (eager, lazy) 옵션을 따로 두지 않는다.
예를 들어 위와 같은 강의 엔터티가 있을 때 연관 관계가 있는 테이블이 있을 때, 따로 옵션 설정을 하지 않는다.
왜?
필요하면 relations을 명시해주거나, QueryBuilder로 JoinSelect을 하여 사용하기 때문이다.
알고보니 자연스러운 이 코드 작성 습관이 알아서 N + 1 문제를 직면하지 않도록 만들어 주었다. (좋은건지는 모르겠다.)
만약 위처럼 강의 도메인에서, 강사 명이 필요하거나 수강생 정보가 필요하다면 아래처럼 작성 할 것이다.
type TLectureList = Pick<Lecture, 'title' | 'content' | 'price' | 'teacher'>[];
//강의 리스트 반환.
async findLecturesWithTeachers(): Promise<TLectureList> {
return await this.createQueryBuilder('l')
.innerJoinAndSelect('l.teacher', 't')
.select(['l.title, l.content, l.price', 't.name'])
.getMany();
}
물론 아래처럼 Repository API의 find를 사용하여 relations을 명시해 줄 수 있다.
하지만 관계를 명시한 하위 테이블의 필드는 선택할 수 없다. 그렇기 때문에 개인적으로는 OverFetching 때문에 사용하지 않는 편이다.
//강의 리스트 반환.
async findLecturesWithTeachers(): Promise<TLectureList> {
return await this.find({
select: ['title', 'content', 'price'],
relations: ['teacher'],
});
}
정리해보면
1. RelationOptions의 Loding 설정을 따로 명시하지 않는다.
2. 쿼리 빌더를 주로 사용한다. (스칼라 서브쿼리나 인라인 뷰가 다수 들어가는 복잡한 쿼리는 raw query를 사용한다.)
이런 습관들 때문에 N + 1 문제를 인지하지 못하고 있었다.
typeORM의 Default Fetch Type을 알아보고자 공식문서를 뒤져봤지만 디폴트 타입에 대한 명시는 없었다.
Lazy Loding을 사용하고 싶으면 반드시 비동기처리를 하라는 주의사항 밖에 없었다.
/**
* Describes all relation's options.
*/
export interface RelationOptions {
//...생략...
lazy?: boolean;
eager?: boolean;
//...생략...
}
코드를 열어보면, 둘 다 옵셔널인 것을 보아하니 default는 없는 것 같다. 실제로 확인해보기 위해 아래 코드를 돌려보았다.
async findLectures(): Promise<void> {
const lecture = await this.lectureRepo.findOneBy({ id: 1 });
console.log(lecture);
console.log('Teacher: ', lecture?.teacher);
}
옵션이 없을 때는 1개의 쿼리를 실행했고
Lazy Loding이 적용되었을 때는 Teacher을 찾기 위한 N + 1 쿼리가 발생했다.
마지막으로 Eager Loding에서는 Lecture를 조회할 때 이미 Teacher가 같이 등장한다.
정리
1. typeORM에서 Lazy Loding은 프로미스 객체로 반환된다.
Note: if you came from other languages (Java, PHP, etc.) and are used to use lazy relations everywhere - be careful. Those languages aren't asynchronous and lazy loading is achieved different way, that's why you don't work with promises there. In JavaScript and Node.JS you have to use promises if you want to have lazy-loaded relations. This is non-standard technique and considered experimental in TypeORM.
공식문서에서 언급했듯이, 아래처럼 변경해주면 되겠다.
async findLectures(): Promise<void> {
const lecture = await this.lectureRepo.findOneBy({ id: 1 });
console.log(lecture);
console.log('Teacher: ', await lecture?.teacher);
}
2. 현재의 습관
현재의 코드 습관들이 자연스레 OverFetching과 N + 1 문제를 피하고 있었다.
사내에서는 혼자 백엔드 개발을 하다보니 코드 레벨에서의 관점을 나눌 사람이 없었다.
외부로 눈을 돌려 typeORM을 사용하는 다른 분들과 커피챗을 통해 관련된 코드 습관들이 올바른 방향인지 점검할 필요는 있을 것 같다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!