본문 바로가기
Programming/Ruby On Rails

[Ruby On Rails] Eager loading으로 N+1 문제 해결하기

by 가론노미 2023. 4. 16.

Ruby On Rails에서 N+1 문제를 해결할 수 있는 방법에 대해 알아보려고 한다.

N+1 문제란?

연관 관계가 설정된 엔티티를 조회할 경우 조회된 데이터 개수(n)만큼 연관 관계의 조회 쿼리가 추가로 실행되는 것을 의미한다.

 

School 테이블과 Teacher 테이블이 1:N 관계라고 가정해보자.

class School < ApplicationRecord
  has_many: :teachers
end

class Teacher < ApplicationRecord
  belongs_to: :school
end

 

각 학교별 선생님들의 이름을 출력하고자 했을 때,

School 데이터 전체를 조회하는 쿼리 하나와 각각의 school에 속해 있는 teachers 데이터를 조회하는 쿼리가 추가로 발생하는 것을 볼 수 있다.

School.all.each do |school|
    school.teachers.each do |teacher|
       puts "#{school.name} : #{teacher.name}"
    end
end
> SELECT `schools`.* FROM `schools` ORDER BY `schools`.`id`
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` = 1
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` = 2
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` = 3
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` = 4
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` = 5

 

이러한 N+1 문제를 피할 수 있는 2가지 방법은 아래와 같다.

  • 연관 관계를 가져오기 위해 큰 조인 기반의 쿼리를 만듦
  • 연관 관계별로 별도의 쿼리를 만듦

Ruby On Rails에서는 Eager loading을 통해 N+1 쿼리 문제를 해결할 수 있다.

preload

연관 관계에 있는 테이블별로 별도의 쿼리가 생성된다. (테이블 당 하나의 쿼리)

School.preload(:teachers)
> SELECT `schools`.* FROM `schools`
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` IN (1,2,3,4,5)

 

별도로 쿼리가 만들어지기 때문에 아래와 같이 연결 관계의 테이블에 관한 조건절(where, order by)은 사용할 수 없다.

School.preload(:teachers).where('teachers.name=?', '홍길동')

 

그러나 선행 모델에 관한 조건절 사용은 가능하다.

School.preload(:teaches).where('schools.name=?', '루비 고등학교')

eager_load

LEFT OUTER JOIN을 사용하여 단일 쿼리로 모든 연결 관계를 로드한다.

단일 쿼리로 조회하기 때문에 타 테이블을 참고하는 조건절 사용이 가능하다.

School.eager_load(:teachers).where('teachers.name=?', '홍길동').to_a
SELECT "schools"."id" AS t0_r0, "schools"."name" AS t0_r1, "teachers"."id" AS t1_r0,
          "teachers"."name" AS t1_r1, "teachers"."school_id" AS t1_r2
    FROM "schools" LEFT OUTER JOIN "teachers" ON "teachers"."school_id" = "schools"."id"
    WHERE "teachers"."name" = "홍길동"

includes

기본 동작은 preload와 같이 별도의 쿼리에서 연결 관계를 로드한다.

School.includes(:teachers)
> SELECT `schools`.* FROM `schools`
> SELECT `teachers`.* FROM `teachers` WHERE `teachers`.`school_id` IN (1,2,3,4,5)

preload와 차이점은 타 테이블의 조건절을 추가했을 때 자동으로 eager_load와 같이 단일 쿼리로 동작하게 된다.

또한, 어떠한 이유로 단일 쿼리를 사용하도록 강제하고 싶다면 조건절 추가 없이도 references를 통해 단일 쿼리로 실행할 수 있다.

School.includes(:teachers).where('teachers.name = ?', '홍길동')

> SELECT "schools"."id" AS t0_r0, "schools"."name" AS t0_r1, "teachers"."id" AS t1_r0,
          "teachers"."name" AS t1_r1, "teachers"."school_id" AS t1_r2
    FROM "schools" LEFT OUTER JOIN "teachers" ON "teachers"."school_id" = "schools"."id"
    WHERE "teachers"."name" = "홍길동"
School.includes(:teachers).references(:teachers)

> SELECT "schools"."id" AS t0_r0, "schools"."name" AS t0_r1, "teachers"."id" AS t1_r0,
          "teachers"."name" AS t1_r1, "teachers"."school_id" AS t1_r2
    FROM "schools" LEFT OUTER JOIN "teachers" ON "teachers"."school_id" = "schools"."id"