연관 모델을 다룰 때 쓰는 메소드가 5개나 있어서 헷갈리기 쉽다.
joinsleft_joinspreloadeager_loadincludes
결국 이 메소드들은 두 가지 질문으로 구분된다.
- JOIN을 하는가? → 연관 테이블 컬럼으로
where/order를 걸 수 있는지가 결정된다 - 연관 레코드를 메모리에 올려두는가? →
owner.cats를 호출할 때 N+1이 생기는지가 결정된다
이 글의 SQL은 전부 ActiveRecord 7.1 + SQLite에서 실제로 실행해서 얻은 로그다.
예제 모델
기존 글과 같이 집사(Owner)와 고양이(Cat)로 설명한다.
class Owner < ApplicationRecord
has_many :cats
end
class Cat < ApplicationRecord
belongs_to :owner
end
데이터는 아래와 같다.
| owner | cats |
|---|---|
| kim | nabi, tora |
| lee | mimi |
| park | (없음) |
한눈에 비교
| 메소드 | JOIN | 연관 레코드 로드 | 연관 테이블로 where | 쿼리 수 |
|---|---|---|---|---|
joins |
INNER JOIN | X | O | 1 |
left_joins |
LEFT OUTER JOIN | X | O | 1 |
preload |
안 함 | O | X (에러) | 연관마다 +1 |
eager_load |
LEFT OUTER JOIN | O | O | 1 |
includes |
상황에 따라 | O | O | 1 또는 연관마다 +1 |
includes 는 평소에는 preload 처럼 움직이다가, 연관 테이블을 참조하는 순간 eager_load 로 바뀐다.
joins : 걸러내기만 하고 가져오지는 않는다
Owner.joins(:cats).map(&:name)
SELECT "owners".* FROM "owners" INNER JOIN "cats" ON "cats"."owner_id" = "owners"."id"
-- => ["kim", "kim", "lee"]
주의할 점이 두 가지 있다.
1. has_many를 joins하면 부모가 중복된다
kim은 고양이가 2마리라서 2번 나온다. 고양이가 없는 park은 INNER JOIN이라 빠진다. 중복을 없애려면 distinct 를 붙인다.
Owner.joins(:cats).distinct
SELECT DISTINCT "owners".* FROM "owners" INNER JOIN "cats" ON "cats"."owner_id" = "owners"."id"
-- => ["kim", "lee"]
2. cats는 로드되지 않는다
SELECT "owners".* 만 가져오기 때문에 owner.cats 를 호출하면 그때마다 쿼리가 나간다. 즉 N+1이 된다.
Owner.joins(:cats).map { |o| o.cats.size }
SELECT "owners".* FROM "owners" INNER JOIN "cats" ON "cats"."owner_id" = "owners"."id"
SELECT COUNT(*) FROM "cats" WHERE "cats"."owner_id" = ?
SELECT COUNT(*) FROM "cats" WHERE "cats"."owner_id" = ?
SELECT COUNT(*) FROM "cats" WHERE "cats"."owner_id" = ?
joins 는 연관 테이블 조건으로 부모를 걸러낼 때만 쓴다고 생각하면 된다.
Owner.joins(:cats).where(cats: { name: "nabi" })
# => kim
joins의 세부 동작은 rails joins 메소드의 특징에 대해 알아보자에 정리해 두었다.
left_joins : 연관이 없는 부모도 남긴다
Owner.left_joins(:cats).map(&:name)
SELECT "owners".* FROM "owners" LEFT OUTER JOIN "cats" ON "cats"."owner_id" = "owners"."id"
-- => ["kim", "kim", "lee", "park"]
joins 와 같지만 LEFT OUTER JOIN이라 고양이가 없는 park도 남는다. left_outer_joins 와 같은 메소드다.
“고양이가 없는 집사” 를 찾을 때 자주 쓴다.
Owner.left_joins(:cats).where(cats: { id: nil })
# => park
preload : 쿼리를 나눠서 가져온다
Owner.preload(:cats).map { |o| o.cats.map(&:name) }
SELECT "owners".* FROM "owners"
SELECT "cats".* FROM "cats" WHERE "cats"."owner_id" IN (?, ?, ?)
-- => [["nabi", "tora"], ["mimi"], []]
부모를 먼저 가져오고, 그 id로 연관 테이블을 IN 으로 한 번에 가져온다. JOIN을 하지 않기 때문에 연관 테이블 조건으로 걸러낼 수 없다.
Owner.preload(:cats).where(cats: { name: "nabi" }).to_a
SELECT "owners".* FROM "owners" WHERE "cats"."name" = ?
-- ActiveRecord::StatementInvalid: no such column: cats.name
대신 JOIN으로 행이 불어나지 않으니 연관 테이블이 크거나, 여러 연관을 한꺼번에 가져올 때 유리하다.
eager_load : JOIN해서 한 번에 가져온다
Owner.eager_load(:cats).map { |o| o.cats.map(&:name) }
SELECT "owners"."id" AS t0_r0, "owners"."name" AS t0_r1,
"cats"."id" AS t1_r0, "cats"."name" AS t1_r1, "cats"."owner_id" AS t1_r2
FROM "owners" LEFT OUTER JOIN "cats" ON "cats"."owner_id" = "owners"."id"
-- => [["nabi", "tora"], ["mimi"], []]
LEFT OUTER JOIN 한 방으로 부모와 연관을 모두 가져온다. t0_r0 같은 별칭은 Rails가 결과를 각 모델로 다시 나누기 위해 붙이는 것이다. joins 와 달리 부모 중복은 Rails가 알아서 합쳐준다.
함정 : where를 걸면 연관 레코드까지 걸러진다
Owner.eager_load(:cats).where(cats: { name: "nabi" })
.map { |o| [o.name, o.cats.map(&:name)] }
# => [["kim", ["nabi"]]]
kim의 고양이는 nabi, tora 2마리인데 o.cats 에 nabi만 들어 있다. WHERE로 걸러진 JOIN 결과를 그대로 캐시하기 때문이다.
“nabi를 키우는 집사의 모든 고양이” 가 필요하다면 거르는 쿼리와 가져오는 쿼리를 나눈다.
Owner.where(id: Cat.where(name: "nabi").select(:owner_id)).preload(:cats)
includes : 알아서 preload와 eager_load 중 고른다
아무 조건이 없으면 preload 와 같은 SQL이 나간다.
Owner.includes(:cats).to_a
SELECT "owners".* FROM "owners"
SELECT "cats".* FROM "cats" WHERE "cats"."owner_id" IN (?, ?, ?)
연관 테이블을 해시 조건으로 참조하면 eager_load 로 바뀐다.
Owner.includes(:cats).where(cats: { name: "nabi" })
SELECT "owners"."id" AS t0_r0, ... FROM "owners"
LEFT OUTER JOIN "cats" ON "cats"."owner_id" = "owners"."id" WHERE "cats"."name" = ?
주의할 점은 문자열 조건은 Rails가 감지하지 못한다는 것이다.
Owner.includes(:cats).where("cats.name = ?", "nabi").to_a
# ActiveRecord::StatementInvalid: no such column: cats.name
이때는 references 로 어떤 테이블을 참조하는지 알려줘야 eager_load 로 바뀐다.
Owner.includes(:cats).where("cats.name = ?", "nabi").references(:cats)
references 는 includes 와 같이 쓸 때만 의미가 있다.
includes의 자세한 동작은 rails includes 메소드에 대해 자세히 알아보자를 참고.
count도 결과가 다르다
같은 데이터에 count 를 호출하면 메소드마다 결과가 달라진다.
Owner.joins(:cats).count # => 3 (kim이 2번 세어짐)
Owner.left_joins(:cats).count # => 4 (park까지 포함, kim 2번)
Owner.preload(:cats).count # => 3
Owner.eager_load(:cats).count # => 3
SELECT COUNT(*) FROM "owners" INNER JOIN "cats" ON ...
SELECT COUNT(*) FROM "owners" LEFT OUTER JOIN "cats" ON ...
SELECT COUNT(*) FROM "owners"
SELECT COUNT(DISTINCT "owners"."id") FROM "owners" LEFT OUTER JOIN "cats" ON ...
joins 로 집계할 때는 distinct.count 를 쓰는 습관을 들이자.
어떤 걸 쓰면 되나
| 하고 싶은 것 | 메소드 |
|---|---|
| 연관 조건으로 부모만 걸러내고 싶다 (연관 데이터는 안 씀) | joins (+ 필요하면 distinct) |
| 연관이 없는 부모도 포함 / 연관이 없는 것만 찾고 싶다 | left_joins |
| 목록 화면에서 N+1만 없애고 싶다 | preload 또는 includes |
| 연관 조건으로 거르면서 연관 데이터도 쓰고 싶다 | eager_load (걸러진 연관만 남는 점 주의) |
| 연관 테이블이 크거나 여러 연관을 한꺼번에 가져온다 | preload |
개인적으로는 동작이 예측 가능한 쪽을 선호해서 includes 보다 preload / eager_load 를 명시적으로 쓰는 편이다. includes 는 조건 하나 추가했을 뿐인데 SQL이 통째로 바뀌기 때문에, 성능 문제를 추적할 때 헷갈리기 쉽다.