본문 바로가기

node js/TypeOrm

NestJs TypeOrm N + 1 Problem을 알아보자!

나는 이것이 궁금했다.

트리스티가 프로젝트를 하며 궁금했던 것들을 정리한 코~너 입니다. 틀린사항이 있다면 언제든지 알려주세요!

 

 

 

📣 오늘의 주제 - NestJs TypeOrm N + 1 문제를 알아보자!

항해 99 1기가 끝나고 개인적으로 공부하고 있던 중, N + 1 문제에 대해 알게 되었습니다. 그래서 오늘은 N + 1 문제가 무엇이고 NestJs에서 TypeOrm을 사용할 경우 어떻게 해결할 수 있을지 알아보도록 하겠습니다. 추가로 SpringBoot Jpa의 N + 1 문제랑은 어떻게 다를지도 한번 봐보도록 하겠습니다. NestJs를 더 잘 사용해보고 싶어서 조금씩 SpringBoot를 보던 중이었는데, 똑같은 문제가 발생할지 정말 궁금했거든요 ~~ 

 

 

N + 1 문제를 알아보기 전에, 먼저 eager loading 및 lazy loading에 대해서 알아보도록 하겠습니다. N + 1 문제와 관련된 중요한 개념이거든요! 

 

사용한 예제에서는 company의 OneToMany를 기준으로 작성되었습니다. employee의 ManyToOne 기준으로는 직접 연습해 봅시다. 모든 코드는 깃허브에 있습니다. 이해가 안 가시거나 틀린 내용이 있다면 언제든지 말씀해주세요~~

 

 

WonDongGyun/NestJs-TypeOrm-N-1-Problem

Contribute to WonDongGyun/NestJs-TypeOrm-N-1-Problem development by creating an account on GitHub.

github.com

 


 

 

💬 What is Eager Loading?

Eager Loading이란 즉시 로딩이라고 불리우며, 로딩 시 참조해야 하는 정보를 미리 전부 가져옵니다. 예를 들면 롤오버 기능이 존재하는 웹페이지를 만드는 경우, Eager Loading을 적용하면 페이지 렌더링 이전에 필요한 모든 연관 리소스들을 한 번에 가져올 수 있습니다. 마우스로 조작할 때마다 새로 리소스를 가져오지 않아서 편리해 보이지만, 사용자들이 해당 리소스를 전부 사용하지 않을 때는 리소스가 낭비될 수 있고, 초기 로딩 속도가 느려질 수 있다는 특징이 있습니다.

 

그렇다면 이 개념을 ORM에 적용하면 어떻게 될까요? 이때의 Eager Loading은 내가 필요한 엔티티와 관련된 모든 데이터를 가져오는 것이라고 생각하시면 됩니다.

 

예제는 다음과 같이 Company와 Employee를 사용하겠으며 관계는 1: N입니다.

 

 

예제용으로 만든 간단한 테이블입니다. 도커 사용방법은 깃허브를 참조해주세요~!

 

 

만약 다른 언어 (예를 들면 java나 php에서요!)에서 ORM을 사용하다가 오셨거나, ORM을 처음 접하신다면 NestJs TypeOrm을 대부분 이런 식으로 작성하셨을 것 같습니다.

 

// Company Entity
@Entity('COMPANY')
export class Company extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	companyId: number;

	@Column({ type: 'varchar' })
	companyName: string;

	@OneToMany(() => Employee, (employee) => employee.company, {
		onDelete: 'CASCADE'
	})
	employee: Employee[];
}

 

// Employee Entity
@Entity('EMPLOYEE')
export class Employee extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	employeeId: number;

	@Column({ type: 'varchar' })
	employeeName: string;

	@ManyToOne(() => Company, (company) => company.employee)
	@JoinColumn([{ name: 'companyId', referencedColumnName: 'companyId' }])
	company: Company;
}

 

 

 

spring에서 사용하는 Jpa hibernate ORM의 경우에는 OneToMany의 default fetch Type은 Lazy Loazding이고 ManyToOne의 default fetch Type은 Eager이기 때문에 N + 1 문제를 바로 만나보실 수 있습니다.

하지만 TypeOrm은 조금 다릅니다!

 

과연 TypeOrm에서는 OneToManyManyToOne의 default fetch Type이 어떻게 되어 있을까요? 한번 TypeOrm Relation 옵션 코드를 확인해보겠습니다.

 

 

// TypeOrm Relation option code

export interface RelationOptions {
    ...

    /**
     * Set this relation to be lazy. Note: lazy relations are promises. When you call them they return promise
     * which resolve relation result then. If your property's type is Promise then this relation is set to lazy automatically.
     */
    lazy?: boolean;
    /**
     * Set this relation to be eager.
     * Eager relations are always loaded automatically when relation's owner entity is loaded using find* methods.
     * Only using QueryBuilder prevents loading eager relations.
     * Eager flag cannot be set from both sides of relation - you can eager load only one side of the relationship.
     */
    eager?: boolean;

    ...
}

 

 

보시는 바와 같이 TypeOrm은 기본적으로 어떤 종류의 관계에서도 기본적으로 fetch type을 적용하지 않습니다. 즉, 기본적으로 명시해주지 않았다면 Eager도 아니고 Lazy도 적용돼있지 않은 것입니다. 이처럼 아무런 fetch type도 적용되어 있지 않다면 결과는 어떻게 나올까요?

 

 

 

 

밑에 코드로 한번 실험해 보겠습니다.

 

// company service

@Injectable()
export class CompanyService {
	constructor(
		@InjectRepository(Company)
		private readonly companyRepository: Repository<Company>
	) {}

	getAllCompany() {
		return this.companyRepository.find();
	}

	getCompany(companyId: number) {
		return this.companyRepository.findOne(companyId);
	}

	setCompany(setComanyDto: SetCompanyDto) {
		return this.companyRepository.save(setComanyDto);
	}
}

 

 

 

 

 

간단하게 findOne 메서드를 사용했을 때는 company 데이터만 나오는 것을 확인할 수 있습니다. 현재 companyId = 1에는 관련된 employee가 있는데 아무런 출력을 하지 않습니다. SQL을 확인해도 특별한 것 없이 IN 절을 사용해서 companyId = 1 인 데이터를 찾고 있네요. 만약 default fetch Type이 Eager 였다면, 관련된 모든 employee가 출력되었을 것입니다.

 

 

findOne을 사용하여 찾았지만 관련된 employee는 출력되지 않았다.

query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName` FROM `COMPANY` `Company` WHERE `Company`.`companyId` IN (?) -- PARAMETERS: ["1"]

 

 

 

 

 

그러면 한번 fetchType에 Eager를 적용해보고 findOne을 다시 사용해보겠습니다.

 

TypeOrm에서는 다음과 같이 관계에서 Eager 혹은 Lazy의 옵션을 true로 지정해서 적용할 수 있습니다. 이 경우라면 OneToMany 관계에서 Eager Loading을 할 수 있겠네요.

 

// Company Entity
@Entity('COMPANY')
export class Company extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	companyId: number;

	@Column({ type: 'varchar' })
	companyName: string;

	@OneToMany(() => Employee, (employee) => employee.company, {
		onDelete: 'CASCADE',
        eager: true
	})
	employee: Employee[];
}

 

 

 

 

 

Eager Loading을 적용하고 위와 똑같이 findOne 메소드를 사용하여 companyId = 1인 데이터를 찾으면, 다음과 같이 연관된 employee 데이터를 가져오는 것을 볼 수 있습니다. SQL에서는 left Join을 해서 연관된 데이터를 가져오고 있습니다. 

 

 

companyId = 1과 관련된 많은 employee 데이터

 

query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName`, `Company_employee`.`createdDt` AS `Company_employee_createdDt`, `Company_employee`.`updatedDt` AS `Company_employee_updatedDt`, `Company_employee`.`employeeId` AS `Company_employee_employeeId`, `Company_employee`.`employeeName` AS `Company_employee_employeeName`, `Company_employee`.`companyId` AS `Company_employee_companyId` FROM `COMPANY` `Company` LEFT JOIN `EMPLOYEE` `Company_employee` ON `Company_employee`.`companyId`=`Company`.`companyId` WHERE `Company`.`companyId` IN (?) -- PARAMETERS: ["1"]

 

 

 

 

이처럼 연관된 데이터를 한 번에 가져오는 것을 Eager Loading이라고 합니다. 다만 TypeOrm에서는 Eager Loading을 사용할 때 몇 가지 주의사항이 있습니다.

 

 

  1. Entity의 Eager Loading 설정은 find 메서드를 사용할 때만 작동합니다.
  2. 단순히 QueryBuilder를 사용할 때는 적용되지 않으며, Eager Loading의 효과를 보고 싶다면 leftJoinAndSelect를 사용하여야 합니다. 
  3. Eager Loading은 관계의 한쪽에서만 사용할 수 있으며 양쪽에서 true로 사용할 수 없습니다. (만약 양쪽을 true로 하면 Maximum call stack error가 발생합니다! company에서도 연관된 employee 데이터를 가져오고, 그 employee 데이터에서도 관련된 company를 가져오는 무한 참조 현상이 발생하기 때문입니다.)

 

 

 

Eager Loading은 이처럼 join을 사용해서 연관된 데이터를 한 번에 가져오기 때문에 언뜻 보면 참으로 좋아 보이는 녀석일 수 있지만, 이와 같은 단점도 존재합니다. 

 

  1. 긴 초기 로딩 시간 (연관 데이터를 한 번에 가져오기 때문입니다.)
  2. 불필요한 데이터를 많이 로드하게 됩니다. 이렇게 되면 프론트엔드에서는 받기 싫은 데이터를 받아야 하는 오버 패칭이 발생합니다. (당장 사용하지 않을 불필요한 데이터를 받는 현상을 오버 패칭이라고 합니다.)
  3.  Entity가 많으면 많을수록 join이 발생하게 되고, 이러한 join으로 인한 성능 저하를 피하기 힘듭니다.

 

 

 

 

 

 

 

 

 

💬 What is Lazy Loading?

 

Lazy Loading이란 지연 로딩이라고 불리며, Eager Loading과는 다르게 필요한 순간에만 데이터를 가져옵니다. Lazy Loading도 Eager Loading과 마찬가지로 다음과 같이 설정할 수 있습니다.

 

 

 

// Company Entity
@Entity('COMPANY')
export class Company extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	companyId: number;

	@Column({ type: 'varchar' })
	companyName: string;

	@OneToMany(() => Employee, (employee) => employee.company, {
		onDelete: 'CASCADE',
        lazy: true
	})
	employee: Employee[];
}

 

 

 

 

 

Lazy Loading을 적용하였을 때는 어떤 결과가 나올까요? 똑같이 join을 사용해서 연관된 데이터를 가지고 올까요? 위의 예제에서 사용했던 CompanyService를 그대로 사용한다면 Lazy Loading을 적용했을 때랑 안 했을 때랑 똑같은 결과가 나오게 됩니다. 왜냐하면 Lazy Loading의 특성상 필요한 경우에만 해당 데이터를 로드하는데, 예제의 CompanyService에서는 employee가 필요하다고 요청하지 않았기 때문입니다. 그래서 그냥 평범한 Company 데이터만 출력된 거죠.

 

 

Lazy Loading을 적용하였지만 CompanyService에서 employee 데이터가 필요하다고 요청하지 않았기 때문에 아무런 변화가 없다.

query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName` FROM `COMPANY` `Company` WHERE `Company`.`companyId` IN (?) -- PARAMETERS: ["1"]

 

 

 

그렇다면 Eager Loading처럼 해당 CompanyId와 연관된 Employee 데이터를 가지고 오려면 어떻게 해야 할까요? 우선 코드를 다음과 같이 변경해야 합니다. 

 

 

// company service

@Injectable()
export class CompanyService {
	constructor(
		@InjectRepository(Company)
		private readonly companyRepository: Repository<Company>
	) {}

	async getAllCompany() {
		return this.companyRepository.find();
	}

	async getCompany(companyId: number) {
    		// return = this.companyRepository.findOne(companyId);
    
		const companies: Company = await this.companyRepository.findOne(
			companyId
		);
		const employees: Employee[] = await companies.employee;
		return companies;
	}

	setCompany(setComanyDto: SetCompanyDto) {
		return this.companyRepository.save(setComanyDto);
	}
}

 

 

 

Lazy Loading을 적용하고 findOne 메서드를 사용해서 employee 데이터를 가지고 오려면 위와 같이 코드를 변경해주시면 됩니다. Lazy Loading을 적용하고 employee 데이터를 가지고 오기 위해서는 async await 구문을 사용해 가지고 올 수 있습니다. 이 부분에서 다른 언어의 ORM을 사용하셨던 분들은 이상한 점을 발견하실 수 있을 것 같습니다. 굳이 async와 await를 사용해서 한번 더 employee 데이터를 가져와야 하는지에 대해서 말이죠.

 

잠깐 TypeOrm의 공식문서를 확인해봅시다. TypeOrm의 공식문서에는 다음과 같이 적혀있습니다.

 

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.

 

 

해석: 다른 언어에서 Lazy Loading을 사용하다가 온 경우 TypeOrm을 사용할 때 주의해야 합니다. 그러한 언어들은 비동기화되지 않으며, Lazy Loading이 다른 방식으로 이루어지기 때문에 Promise를 사용할 수 없습니다. Javascript 혹은 Node.js에서는 Lazy Loading을 사용하기 위해서는 Promise가 사용됩니다. 이것은 비표준 방법이며 TypeOrm에서의 실험적인 기능입니다. 

 

 

 

 

즉, TypeOrm에서는 Lazy Loading을 사용하기 위해서는 반드시 비동기 처리 객체인 Promise를 사용해야 하며,

Javascipt에서는 async await 구문을 사용하여 이러한 비동기 작업들을 쉽게 처리할 수 있습니다. Lazy Loading을 통해 company와 관련한 employee 데이터를 가지고 오기 위해서는 아래와 같이 await 구문을 사용해서 관련한 employee 데이터를 찾아야 합니다. 만약 해당 코드 없이 단순히 findOne 메서드만 사용할 시, company와 관련한 employee 데이터는 가지고 올 수 없습니다. 

 

const employees: Employee[] = await companies.employee;

 

 

 

 

추가로 TypeOrm에서 Lazy를 표현할 때는 코드를 두가지 방식으로 작성할 수 있는데, 첫번째 코드와 두번째 코드는 똑같은 기능을 수행합니다. 다만, 개발자들이 훨씬 더 직관적으로 알아볼 수 있는 코드는 2번째 코드입니다. ESlint를 사용하시는 경우에도 2번째 코드를 사용할 경우 도움이 됩니다. TypeOrm 공식문서에서도 나와있는 방법이니 첫번째 방법보다는 두번째 방법으로 작성해주세요.

 

// 첫번째 방법

@Entity('COMPANY')
export class Company extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	companyId: number;

	@Column({ type: 'varchar' })
	companyName: string;

	@OneToMany(() => Employee, (employee) => employee.company, {
		onDelete: 'CASCADE',
		lazy: true
	})
	employee: Employee[];
}

// 두번째 방법

@Entity('COMPANY')
export class Company extends TimeStamped {
	@PrimaryGeneratedColumn('increment')
	companyId: number;

	@Column({ type: 'varchar' })
	companyName: string;

	@OneToMany(() => Employee, (employee) => employee.company, {
		onDelete: 'CASCADE',
		lazy: true
	})
	employee: Promise<Employee[]>;
}

 

 

 

 

 

 

 

 

 

 

 

이번에는 모든 company를 가져오는 find 메서드를 Lazy Loading 방식으로 고쳐보겠습니다.

 

 

// company service

@Injectable()
export class CompanyService {
	constructor(
		@InjectRepository(Company)
		private readonly companyRepository: Repository<Company>
	) {}

	async getAllCompany() {
		const companies = this.companyRepository.find();

		for (const company of await companies) {
			const employee = await company.employee;
		}
		return companies;
	}

	async getCompany(companyId: number) {
		const companies: Company = await this.companyRepository.findOne(
			companyId
		);
		const employees: Employee[] = await companies.employee;
		return companies;
	}

	setCompany(setComanyDto: SetCompanyDto) {
		return this.companyRepository.save(setComanyDto);
	}
}

 

 

 

 

find 메서드를 사용하면 결과는 다음과 같이 각 companyId에 연관된 employee 데이터를 가져오는 것을 볼 수 있습니다. Eager Loading에서는 join을 사용하여 1번의 쿼리로 해당 명령을 수행하지만, Lazy Loading의 경우에는 SQL을 보시면 쿼리를 7번 수행하는 모습을 볼 수 있습니다.

 

 

query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName` FROM `COMPANY` `Company`
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [1]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [2]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [3]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [4]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [5]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [6]

 

 

 

Lazy Loading은 Eager Loading에 비해 쿼리가 많이 발생한다는 단점이 있으나, 다음과 같은 장점이 있습니다. 

 

  1. 초기 로딩 시간을 줄일 수 있습니다. (연관된 데이터를 전부 가져오지 않기 때문)
  2. 자원 소비를 Eager Loading을 사용했을 때에 비해 줄일 수 있습니다.

 

 

 

이러한 장단점들이 있으니 Eager Loading과 Lazy Loading을 상황에 맞게 잘 사용해야겠죠?

 

 

 

💬 What is N + 1 Problem?

 

이제 Eager Loading과 Lazy Loading에 대해 알게 되었으니, N + 1 문제를 해결해보도록 합시다. 우린 벌써 N + 1 문제를 만났어요!

 

 

Lazy Loading을 적용하고 find 메소드를 사용했을 때 7번의 쿼리가 발생한 것을 볼 수 있었죠? 이 문제를 바로 N + 1 문제라고 합니다. N + 1 문제란 1번의 쿼리를 사용해 N 건의 데이터를 가져오는데, 관련 칼럼을 얻기 위해 추가적으로 N번의 쿼리가 실행되는 문제입니다. 그래서 총 N + 1번의 쿼리가 실행되는 문제인데요, Orm을 사용하면 흔하게 발생하는 문제입니다. 

 

제대로 구분해 보기 위해서 Eager Loading 설정 시와 Lazy Loading 설정 시 연관된 employee 데이터를 얻기 위한 쿼리를 각각 비교해 보도록 하겠습니다.

 

 

// OneToMany eager: true
query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName`, `Company_employee`.`createdDt` AS `Company_employee_createdDt`, `Company_employee`.`updatedDt` AS `Company_employee_updatedDt`, `Company_employee`.`employeeId` AS `Company_employee_employeeId`, `Company_employee`.`employeeName` AS `Company_employee_employeeName`, `Company_employee`.`companyId` AS `Company_employee_companyId` FROM `COMPANY` `Company` LEFT JOIN `EMPLOYEE` `Company_employee` ON `Company_employee`.`companyId`=`Company`.`companyId`


// OneToMany lazy: true
query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName` FROM `COMPANY` `Company`
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [1]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [2]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [3]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [4]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [5]
query: SELECT `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` 
AS `employee_companyId` FROM `EMPLOYEE` `employee` WHERE `employee`.`companyId` IN (?) -- PARAMETERS: [6]

 

 

company Entity의 OneToMany fetchType을 Eager로 하였을 때는 join을 사용해서 한 번의 쿼리가 발생하는데 반해, Lazy로 하였을 경우에는 7번의 쿼리가 발생하게 됩니다. 현재 저의 mysql의 company 테이블에는 총 6개의 데이터가 들어가 있습니다. 그래서 1번의 쿼리로 모든 company를 조회하고, 조회된 company의 companyId 개수만큼 employee 테이블에서 해당 companyId와 연관된 데이터를 찾기에 6번의 쿼리가 발생합니다. 따라서 총 7번(1 + 6)의 쿼리가 발생하게 됩니다.

 

TypeOrm에서는 이러한 N + 1 문제를 find 메서드에 relations 옵션을 적용하거나 QueryBuilder를 사용해서 해결합니다. 먼저 relations 옵션을 적용해 보겠습니다. company에서 하위의 employee 데이터를 찾는 것이기 때문에 relations 옵션을 다음과 같이 적용해 줍니다. 참고로, 이때의 OneToMany fetchType은 Lazy를 적용하였습니다. (eager로 하셔도 상관없습니다.)

 

// company Service

@Injectable()
export class CompanyService {
	...
	async getAllCompany() {
		const companies = this.companyRepository.find({
			relations: ['employee']
		});

		for (const company of await companies) {
			const employee = await company.employee;
		}
		return companies;
	}

	...
}

 

query: SELECT `Company`.`createdDt` AS `Company_createdDt`, `Company`.`updatedDt` AS `Company_updatedDt`, `Company`.`companyId` AS `Company_companyId`, `Company`.`companyName` AS `Company_companyName`, `Company__employee`.`createdDt` AS 
`Company__employee_createdDt`, `Company__employee`.`updatedDt` AS `Company__employee_updatedDt`, `Company__employee`.`employeeId` AS `Company__employee_employeeId`, `Company__employee`.`employeeName` AS `Company__employee_employeeName`, 
`Company__employee`.`companyId` AS `Company__employee_companyId` FROM `COMPANY` `Company` LEFT JOIN `EMPLOYEE` `Company__employee` ON `Company__employee`.`companyId`=`Company`.`companyId`

 

 

쿼리를 보시면 fetchType이 현재 Lazy가 적용되어 있음에도 불구하고, 문제없이 한 번의 쿼리만을 사용해서 연관된 모든 employee 데이터를 가지고 오는 것을 볼 수 있습니다. 

 

TypeOrm에서 find 메서드의 relations 옵션은 메인 Entity와 연관된 데이터를 가지고 오는 역할을 하게 되는데요, 이것이 가능한 이유는 relations 옵션이 join 혹은 leftjoinAndSelect 역할을 하기 때문입니다. 그래서 Lazy로 적용되어 있음에도 한 번의 쿼리만을 사용해서 관련된 데이터를 가지고 올 수 있는 것입니다. N + 1 문제를 해결하는 것이 목표였으니 companyService 코드에서 불필요한 코드를 없애고 다음과 같이 변경해도 같은 로직을 수행합니다.

 

 

// company Service

@Injectable()
export class CompanyService {
	...
	async getAllCompany() {
		return this.companyRepository.find({
			relations: ['employee']
		});
	}

	...
}

 

 

 

이번에는 QueryBuilder를 사용해서 연관 데이터를 가지고 오도록 하겠습니다. QueryBuilder의 경우에는 사용자가 SQL에 기본지식이 있어야 하지만, TypeOrm으로 복잡한 SQL을 표현하고 싶을 때 사용할 수 있습니다. 다만 여기에는 자동으로 join을 거는 기능이 없으니, 반드시 명시해서 작성해주셔야 합니다. 다음과 같이 작성해주시면 동일한 결과를 얻을 수 있습니다.

 

// company Service

@Injectable()
export class CompanyService {
	...
	async getAllCompany() {
		return this.companyRepository
			.createQueryBuilder('company')
			.leftJoinAndSelect('company.employee', 'employee')
			.getMany();
	}

	...
}

 

query: SELECT `company`.`createdDt` AS `company_createdDt`, `company`.`updatedDt` AS `company_updatedDt`, `company`.`companyId` AS `company_companyId`, `company`.`companyName` AS `company_companyName`, `employee`.`createdDt` AS `employee_createdDt`, `employee`.`updatedDt` AS `employee_updatedDt`, `employee`.`employeeId` AS `employee_employeeId`, `employee`.`employeeName` AS `employee_employeeName`, `employee`.`companyId` AS `employee_companyId` FROM `COMPANY` `company` 
LEFT JOIN `EMPLOYEE` `employee` ON `employee`.`companyId`=`company`.`companyId`