Home / rails/model / rails joins, left_joins, preload, eag...

rails joins, left_joins, preload, eager_load, includes 차이 한눈에 정리

September 22, 2026 · 11 min read rails/model

연관 모델을 다룰 때 쓰는 메소드가 5개나 있어서 헷갈리기 쉽다.

  • joins
  • left_joins
  • preload
  • eager_load
  • includes

결국 이 메소드들은 두 가지 질문으로 구분된다.

  1. JOIN을 하는가? → 연관 테이블 컬럼으로 where / order 를 걸 수 있는지가 결정된다
  2. 연관 레코드를 메모리에 올려두는가?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)

referencesincludes 와 같이 쓸 때만 의미가 있다.

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이 통째로 바뀌기 때문에, 성능 문제를 추적할 때 헷갈리기 쉽다.

참고