ORM 사용에서 OneToMany와 ManyToOne과 같은 연관 관계로 묶인 테이블을 조회할 때 성능이슈가 발생하는 문제을 말한다.
가령 Channel과 User라는 Entity가 1:다 관계로 묶여 있다고 하고, Channel의 레코드는 총 10개가 있다고 가정하자.
이제 ORM으로 Channel Entity를 대상으로 findAll을 명령하면 내부적으로 "SELECT * FROM Channel"이라는 쿼리문 하나를 생성하여 데이터베이스에 요청할 것이다. 이때 생성된 쿼리문이 1개이고 딱 한 번 요청을 하는 것이다.
하지만 문제는 Channel과 연관된 User데이터도 함께 가져오려고 한다는 것이다. 그래서 최초 요청했던 findAll의 결과로 총 10개의 레코드를 획득할텐데, 이 각각의 채널에 속한 User정보를 가져오기 위해 "SELECT * FROM User WHERE User.channel_id=?"와 같은 형태의 쿼리문을 총 10개 생성해서 요청을 하게 된다. 그래서 의도는 findAll을 사용하여 SQL에 딱 한 번의 요청을 할 것으로 기대하지만 사실상 총 11번의 요청을 하게 된 셈이다.
User 테이블에 총 10개의 Channel id를 타겟으로 총 10번을 조회한다고 생각해보면 끔찍하지 않은가? Channel과 User 레코드가 모두 많으면 많을 수록 심각한 성능이슈로 이어질 가능성이 높을 것이다.
정리하자면 N+1에서 1이 findAll 요청에 해당하고 N이 쉽게 말해 findAll 결과 갯수, 구체적으로는 findAll의 결과인 각각의 레코드를 대상으로 연관된 테이블을 조회하기 위한 요청의 갯수를 의미한다.
ORM에서 Fetch의 EAGER과 LAZY 설정
위 N+1문제가 ORM에서 늘상 발생하는 그런 문제는 아니다. 내부적으로 ORM에서는 OneToMany나 ManyToOne 등과 같은 관계 설정에서 옵션으로 Fetch를 설정하는 옵션이 있다. 아래 TypeORM 예시를 보자
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { User } from './User';
@Entity()
export class Channel {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// EAGER로 설정된 OneToMany 관계
@OneToMany(() => User, user => user.channel, { eager: true })
users: User[];
}
eager: true로 설정할 경우 위에서 설명한 N+1 방식으로 동작하게 된다. 즉 Channel을 조회하는 것 만으로도 연관된 User정보를 함께 들고 오게 된다.
eager 대신에 lazy: true로 설정하게 되면 find만으로는 연관된 정보를 당장에 들고오지는 않는다 하지만 N+1 문제를 일으킬 여지가 남아 있다. 아래 코드를 보자
// 예제: ChannelEntity 조회 (Lazy Loading 활성화)
const channel = await channelRepository.findOne(1); // channelEntity의 id가 1인 개체 조회
console.log(channel.users);
Lazy가 활성화 되었기 때문에 channel.users에 접근이 가능하다. 그리고 이 코드가 실행되는 순간 N+1에서 N이 동작한다. 즉 저런식으로 코드를 작성할 경우 N+1을 나눠서 실행한 것과 다를게 없다.
헷깔릴 수 있어 부가 설명을 하자면 위 코드의 경우 findOne을 썼기 때문에 N = 1이 된다. 채널 하나를 대상으로 연관 데이터 조회 쿼리를 날리기 때문이다. 총 2개의 쿼리를 날린 셈이다. 하지만 그냥 find를 쓰면(typeORM은 find가 곧 findAll이다.) N 개수가 늘어날 것이다.
내가 면접에서 N+1 문제를 대답할 수 없었던 이유
typeORM은 기본적으로 N+1 문제를 경험하도록 두지 않는다. 우선 기본적으로 관계 설정에서의 디폴트 값은 eager도 lazy도 아닌 아무 설정도 되어있지 않다. 그렇기 때문에 기본적으로 위 코드처럼 코드를 작성할 일이 없다. 도큐먼트에서도 JOIN 연산이 필요한 쿼리를 요청할 때는 find의 relations 옵션을 쓰거나 createQueryBuilder 메서드를 쓰도록 알려주기 때문이다.
relations 옵션
// ...윗 부분 생략
const channels = await this.channelRepository.find({
relations: ['user']
});
console.log(channels[0].users)
위 처럼 relations 옵션을 적용하면 기본적으로 JOIN 연산이 적용된다. 옵션에 달린 주석도 한 번 살펴보자
기본적으로는 join이 적용된다고 명시되어 있다. 추가적으로 relationLoadStrategy옵션을 통해 "join"과 "query" 방식 중 선택할 수도 있다. 주석에서 명시된 것과 같이 경우에 따라서는 join이 오히려 세부 query 요청보다 느릴 가능성도 있으니 그때는 "query" 방식을 적용하라고 한다. 여기서 query는 N+1 문제를 부를 수 있는 그 쿼리 요청 방식을 말한다. (그런데 @deprecated가 있는 것을 보니 웬만해서는 그냥 join을 쓰는 듯 하다.) 어찌됐건 relations 옵션을 설정하면 join연산이 적용된 쿼리문을 한 개를 요청하게 된다.
createQueryBuilder
const channels = await this.channelRepository
.createQueryBuilder('channel')
.leftJoinAndSelect('channel.users', 'user')
.getMany();
위 메서드처럼 쿼리를 직접 작성하는 듯한 느낌의 방식으로도 연관 데이터 불러오기가 가능하다. 추가적으로 select 메서드가 추가적으로 적용된다면 join 결과 중 일부 컬럼만 뽑아서 가져올 수도 있다. 이 방식도 relations를 사용하는 것과 큰 차이는 없다.
정리하자면 나의 경우 처음부터 연관 데이터를 불러와야 할 경우 createQueryBuilder를 써서 JOIN을 적용하는것을 당연하게 여겼고, 습관이 들어져 있었기 때문에 ORM을 통한 N+1문제를 발생시킬 일이 없었다. 하지만 필요에 따라 세부적인 쿼리 튜닝이 필요할 경우 이러이러한 방식을 활용할 수 있다는 점을 알아둘 수 있는 시간이었다.
'백엔드 개발자(node.js)가 되는 과정' 카테고리의 다른 글
HTTP 전송을 위한 TCP 커넥션에 대하여 (0) | 2024.03.28 |
---|---|
JWT와 토큰 생성, 강제 로그아웃에 대하여 (0) | 2024.02.14 |
var, let, const의 차이와 호이스팅, 스코프 (0) | 2024.02.08 |
Redis에 대해서 살펴보자 (0) | 2024.02.07 |
HTTP와 Kafka를 통한 MongoDB 도큐먼트 생성 비교 (부하테스트) (0) | 2024.01.31 |