[Rails] :includes 로 SQL N+1 문제 개선하기

Rails

N+1 문제란?

  • 쿼리 1번으로 N건을 가져왔는데, 관련 컬럼을 얻기 위해 쿼리를 N번 추가 수행하는 문제
  • 쿼리결과 건수마다 참조 정보를 얻기 위해 건수만큼 반복해서 쿼리를 수행하게 되는 문제
  • DB쿼리 수행비용(횟수)이 크기 때문에, eager loading(즉시 로딩) 등의 방법으로 해결하는 것이 권장됨

출처: 제타위키 - N + 1 쿼리 문제

쉽게 말해, N + 1은 참조 정보를 얻을 때 필요 이상의 쿼리를 수행하게 되는 문제이다. 처음에 원하는 컬럼을 모두 가져오고 (1) + 해당 컬럼들을 하나씩 돌아가며 참조 정보를 얻기 위해 (N) 번의 쿼리를 수행하게 된다. 따라서 하나의 결과를 얻기 위해 총 N + 1번의 쿼리를 수행하게 되는 비효율의 문제가 나타난다. 루비 코드로 구체적인 예를 들어보자.

N + 1 예시)

아래처럼 한 직원이 여러개의 폼을 가지고 있는 1:N 모델이 있다고 하자:

# 직원 모델
class Employee < ApplicationRecord
  has_many: :forms
end

# 폼 모델
class Form < ApplicationRecord
  belongs_to: :employee
end

아래 사진처럼 총 다섯명의 직원이 있고, 각 직원은 여러개의 폼을 가지고 있다. 이때, 각 직원들의 폼을 가져오는 쿼리를 작성한다고 하자.

N+1문제 예시 001

Employee.all.map { |employee| employee.forms }.flatten

언뜻보면 한줄의 짧은 쿼리로 폼 데이터를 가져오는것 처럼 보이지만, 해당 쿼리의 SQL 명령어는 아래와 같이 수행된다.

> SELECT `employees`.* FROM `employees` ORDER BY `employees`.`id`
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` = 1
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` = 2
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` = 3
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` = 4
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` = 5

첫 번째 쿼리에 직원들을 모두 로드한 다음 5개의 추가 쿼리를 수행하여 각 직원의 폼을 가져오기 때문에 데이터베이스에 총 6개의 쿼리가 실행된다. 즉, N = 5인 N + 1 쿼리 문제가 발생한다.

아래 사진과 같이 6 단계에 나눠서 모든 직원들의 폼을 가져오게 된다.

N+1문제 예시 002


해결방법

Rails는 관련 레코드를 미리 로드하고 데이터베이스에 대한 SQL 쿼리 수를 제한하는 :includes라는 ActiveRecord 메소드를 제공한다. 이 기술을 “eager loading(즉시로딩)“이라고 하며, 많은 경우 성능을 상당히 향상시킬 수 있다.

쿼리에 따라 :includes은 ActiveRecord 메서드 :preload 또는 :eager_load를 사용한다. :includes를 설명하기에 앞서, :preload:eager_load 메서드에 대해서 먼저 알아보자.

preload: 사전 테이블 참조

:preload란 말 그대로 미리(pre) 테이블을 참조해 데이터를 로드(load) 하는 방식이다. SQL로 치자면 ‘사전 데이터 참고’로서, 데이터 탐색 전에 사전에 테이블을 참조하는 방식이다.

예를 들어, 책의 작가를 가져오는 쿼리를 :preload로 작성한다고 하자. 책(Book)과 작가(Author) 모델은 1:1 관계라고 가정하자.

books = Book.preload(:author).limit(10)

books.each do |book|
  puts book.author.last_name
end

:preload를 사용하면 총 두 개의 쿼리가 생성된다. 각 association 별로 데이터를 로드하기 위한 별도의 쿼리가 생성 되는 것이다.


SELECT `books`* FROM `books` LIMIT 10
SELECT `authors`.* FROM `authors`
 WHERE `authors`.`book_id` IN (1,2,3,4,5,6,7,8,9,10)

다만, :preload 속성은 association에 대한 필터를 적용하지 않고 메모리에 로드기 때문에 **타 테이블을 참고해서 조건을 표현하는 where, find_by와 같은 조건절을 사용할 수 없다. **

eager_load: LEFT_OUTER_JOIN

:eager_load LEFT_OUTER_JOIN 을 사용해 모든 associations를 단일 쿼리로 로드한다.

LEFT_OUTER_JOIN 이란? left outer join LEFT JOIN이라고도 불리는 LEFT OUTER JOIN 방식은 두 개의 테이블이 있을 때, 주체가 되는 왼쪽 테이블을 기준으로 두 테이블을 서로 맵핑 하는 방식이다. LEFT OUTER JOIN은 오른쪽 테이블에 대응하는 레코드가 없어도, 왼쪽 테이블의 모든 레코드를 가져온다. 더 자세한 정보는 해당 링크에서 확인할 수 있다.

:eager_load 로 동일한 예제 책의 작가를 가져오는 쿼리를 작성해보자.

books = Book.eager_load(:author).limit(10)

books.each do |book|
  puts book.author.last_name
end

:eager_load 속성을 사용하면 동일하게 쿼리가 두 개만 생성된다. 하지만 :preload 와 다른점은, LEFT OUTER JOIN 으로 두 테이블을 조인한다는 것이다. 모든 associations를 단일 쿼리로 로드 한다.

SELECT DISTINCT `books`.`id` FROM `books` LEFT OUTER JOIN `authors` ON `authors`.`book_id` = `books`.`id` LIMIT 10
SELECT `books`.`id` AS t0_r0, `books`.`last_name` AS t0_r1, ...
  FROM `books` LEFT OUTER JOIN `authors` ON `authors`.`book_id` = `books`.`id`
  WHERE `books`.`id` IN (1,2,3,4,5,6,7,8,9,10)

association이 메모리에 로드되므로 eager_load 메소드는 타 테이블을 참조하는 조건절 사용이 가능하다.

includes: preload 혹은 eager_load

include: preload

다시 직원과 폼의 예제로 돌아가자.

:includes는 언제 :preload를 사용할까? 대부분의 경우 :includes는 2개의 쿼리를 발생시키는 :preload 메서드를 사용하도록 기본 설정이 되어있다.

  • 선행 모델에 연결된 모든 레코드 로드
  • 연관된 모델 또는 선행 모델에서 외부 키를 기준으로 선행 모델과 연관된 레코드 로드

따라서, 쿼리에 :preload를 적용하면, 외래 키 Form#employee_id를 기반으로 폼이 로드되어 SQL select 문을 단 두 개만 생성할 수 있다.

Employee.preload(:forms).map { |employee| employee.forms }.flatten

> SELECT `employees`.* FROM `employees`
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` IN (1, 2, 3, 4, 5)

includes 예시 001

위 예제에서 :preload:includes로 대체하면 동일한 SQL문이 생성된다.

Employee.includes(:forms).map { |employee| employee.forms }.flatten

> SELECT `employees`.* FROM `employees`
> SELECT `forms`.* FROM `forms` WHERE `forms`.`employee_id` IN (1, 2, 3, 4, 5)

include: eager_load

:includes는 언제 :eager_load를 사용할까?

:includeswhere 혹은 order 메서드로 타 테이블을 참조하는 조건절을 사용하지 않는다면, 기본적으로 :preload를 사용한다. 다시말해, :includeswhere 혹은 order 메서드로 타 테이블을 참조하는 경우, :eager_load를 사용한다.

해당 방식으로 쿼리를 구성할 때는 :eager_load되는 모델도 명시적으로 참조해야 한다.

Employee.includes(:forms).where('forms.kind = "health"').references(:forms)

이 경우 :includes:eager_load 메소드를 사용하는데, LEFT_OUTER_JOIN 을 통해 중간 테이블을 만들어 모델 결과를 출력한다.

> SELECT `employees`.`id` AS t0_r0, `employees`.`name` AS t0_r1, `forms`.`id` AS t1_r0, `forms`.`employee_id` AS t1_r1, `forms`.`kind` AS t1_r2 LEFT OUTER JOIN `forms` ON `forms`.`employee_id` = `employees`.`id` WHERE (forms.kind = "health")

eager_load 001

:eager_load:includes로 대체해도 SQL 문은 동일하게 출력된다. 또한, 이 경우에는 :reference도 제거할 수 있다.

Employee.eager_load(:forms).where('forms.kind = "health"')
> SELECT `employees`.`id` AS t0_r0, `employees`.`name` AS t0_r1, `forms`.`id` AS t1_r0, `forms`.`employee_id` AS t1_r1, `forms`.`kind` AS t1_r2 LEFT OUTER JOIN `forms` ON `forms`.`employee_id` = `employees`.`id` WHERE (forms.kind = "health")

하지만, :includes:preload로 대체하면 쿼리가 실행되지 않는다. (:preload는 타 테이블을 참고해서 조건을 표현하는 where, find_by와 같은 조건절을 사용할 수 없음)


joins와 includes의 차이

joins: INNER_JOIN

** :joins는 언제 활용하는 걸까?**

관계에서 레코드에 액세스하지 않고 결과만 필터링하는 경우 join이 사용된다. 아래 예제는 ‘유저1’이 작성한 주석과 함께 모든 블로그 게시물을 가져온다. 연결된 코멘트에 액세스하지 않으므로 조인이 적합하다.

Post.joins(:comments).where(:comments => {author: '유저1'}).map { |post| post.title }
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."author" = $1
=> ["유저1의 책 1",
 "유저1의 책 2",
 "유저1의 책 3"]

그렇다면 :joins도 N + 1 문제를 해결할까?

아니다. joins는 데이터를 메모리에 직접 로드하지 않는다. 대신, relationship(관계 모델) 에서 직접 열에 액세스 하기 때문에 N+1 쿼리가 트리거된다.

예를 들어 Comment 관계에 액세스할 때 다음과 같은 추가 쿼리가 생성된다.

Post.joins(:comments).where(:comments => {author: '유저1'}).map { |post| post.comments.size }
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."author" = $1
   (1.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (3.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (1.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (2.1ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (1.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
=> [3,5,2,4,2,1]

:joins는 ’includes, preloadseager_load와 결합할 수 있을까? 그렇다. :joins에 의해 지정된 join 유형(기본값은 INNER_JOIN)은 include 또는 eager_load에서 적용된 모든 join 값을 오버라이딩한다. 참고로, preload는 join을 적용하지 않는다.


퍼포먼스

  • 데이터베이스 요청에서 eager_load가 필수가 아니라면, preload를 사용하는게 더 낫다는 결과가 있다. 출처: Benchmark: preload vs. eager_load
  • 또한, :includes를 사용하는 경우, :preload가 호출될 때 성능이 크게 향상되지만, :eager_load를 호출하면 대부분 부정적인 효과가 난다고 한다. :eager_load는 관계 모델을 로드하기 위해 더 복잡한 쿼리를 구성하므로 속도가 느릴 수 있다. 출처: A Visual Guide to Using :includes in Rails

참고




Profile picture
@김하연
4년차 프론트엔드 개발자 입니다. 사용자 경험 개선, 코드의 재사용성, 읽기 쉬운 코드에 집중하여 개발합니다.
AboutGithub LinkedinResume
Loading script...