백엔드 개발자(node.js)가 되는 과정

데이터베이스의 1:N 관계에서 N+1 문제가 무엇인가?

soopy 2024. 2. 8. 17:24
728x90

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문제를 발생시킬 일이 없었다. 하지만 필요에 따라 세부적인 쿼리 튜닝이 필요할 경우 이러이러한 방식을 활용할 수 있다는 점을 알아둘 수 있는 시간이었다.

728x90
728x90