본문으로 건너뛰기
김신건의 로그

[Rails] 쿼리 최적화: preload, eager_load, includes, references

· 수정 · 📖 약 2분 · 967자/단어 #rails #active-record #n-plus-one #performance #eager-loading
Rails N+1, preload, eager_load, includes, references rails, rails eager loading, Active Record 쿼리 최적화, Rails 쿼리 최적화

정의

Active Record 는 연관 (has_many, belongs_to) 을 lazy 로딩한다. 부모 객체를 받아온 뒤 자식을 순회하면 자식마다 SELECT 가 1번씩 추가로 발생 = N+1 문제.

Rails 의 해결 도구는 세 가지 메서드 + 보조 메서드 1 개:

메서드동작조건 사용 시
preload(:assoc)항상 2개의 분리된 쿼리 (parent 한 번 + child IN 한 번)연관 테이블 조건 불가
eager_load(:assoc)항상 1개의 LEFT OUTER JOIN 쿼리연관 테이블 조건 가능
includes(:assoc)상황에 따라 preload 또는 eager_load 자동 선택조건 추가 시 자동 eager_load
references(:assoc)includes 와 조합, 강제 eager_load보조

N+1 시나리오

class School < ApplicationRecord
  has_many :teachers
end

class Teacher < ApplicationRecord
  belongs_to :school
end
School.all.each do |school|
  school.teachers.each do |teacher|
    puts "#{school.name} : #{teacher.name}"
  end
end

발생 SQL:

SELECT * FROM schools;                                          -- 1번
SELECT * FROM teachers WHERE school_id = 1;                     -- N번
SELECT * FROM teachers WHERE school_id = 2;
...

school 5개면 6 쿼리, 100개면 101 쿼리. 트랜잭션 응답시간 폭발.

preload: 별도 2 쿼리

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

항상 2 개의 쿼리. 부모 한 번, 자식 IN 한 번. row 가 부모 수와 동일 (중복 없음).

제약: 연관 테이블의 컬럼을 조건절에 사용 불가.

# ❌ Error: column teachers.name does not exist
School.preload(:teachers).where('teachers.name = ?', '홍길동')

자기 자신 (선행 모델) 조건은 OK:

School.preload(:teachers).where(name: '루비 고등학교')

preload 가 적합:

  • 자식 데이터 양이 많을 때 (JOIN 시 부모 row 복제로 메모리 폭발 방지)
  • 자식 컬럼 검색 필요 없을 때
  • 카르테시안 곱 회피

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" = '홍길동';

1 개의 LEFT OUTER JOIN 쿼리. 연관 테이블 조건 가능.

제약: 부모 row 가 자식 수만큼 곱해진다. school 5 개에 teacher 평균 20 명이면 100 row 반환 → Active Record 가 메모리에서 dedup. 큰 데이터에선 메모리 부담.

eager_load 가 적합:

  • 연관 테이블 조건 필요
  • 자식 수가 적을 때
  • JOIN 으로 한 번에 모든 데이터 가져오는 게 효율적인 경우

includes: 자동 선택

School.includes(:teachers)

조건절 없으면 preload 처럼 동작 (2 쿼리):

SELECT * FROM schools;
SELECT * FROM teachers WHERE school_id IN (1, 2, 3, 4, 5);

조건절 추가하면 자동으로 eager_load 처럼 동작 (JOIN):

School.includes(:teachers).where('teachers.name = ?', '홍길동')
SELECT "schools"."id" AS t0_r0, ..., "teachers"."name" AS t1_r1
FROM "schools" LEFT OUTER JOIN "teachers" ON ...
WHERE "teachers"."name" = '홍길동';

includes 가 가장 권장. Rails 가 자동으로 최적 선택.

references: 강제 eager_load

조건이 string 이 아닌 hash 라 includes 가 detect 못 할 때 강제:

School.includes(:teachers).where(teachers: { name: '홍길동' })
# → Rails 4+ 자동 detect, JOIN 사용

명시적으로 강제:

School.includes(:teachers).references(:teachers)
# → 조건 없어도 JOIN 사용

복잡한 동적 query 빌딩에서 안전망.

다중 / 중첩 연관

# 다중
Post.includes(:author, :comments)

# 중첩 (post → comments → user)
Post.includes(comments: :user)

# 깊은 중첩
Post.includes(author: { posts: :comments }, comments: :user)

preload, eager_load 도 동일 문법.

중첩 깊을수록 JOIN 폭발 위험. 깊은 nested 는 preload 로 단계 분리가 안전.

bullet gem 으로 자동 감지

# Gemfile
group :development do
  gem 'bullet'
end
# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.console = true
  Bullet.rails_logger = true
  Bullet.add_footer = true
end

N+1 발생 시 자동 로그 + UI footer 표시. CI 에서 Bullet.raise = true 로 실패시킬 수도.

strict_loading (Rails 6.1+)

class School < ApplicationRecord
  has_many :teachers, strict_loading: true
end

school = School.find(1)
school.teachers.to_a   # ❌ ActiveRecord::StrictLoadingViolationError

연관 lazy 접근을 컴파일 타임처럼 차단. preload / includes 없으면 즉시 에러. 의도하지 않은 N+1 방지.

School.strict_loading.find(1)             # 인스턴스별
config.active_record.strict_loading_by_default = true   # 전역 (개발 / 테스트)

counter_cache: count 의 N+1

# ❌ 매 school 마다 COUNT 쿼리
School.all.each { |s| puts s.teachers.count }

counter_cache 로 부모에 카운트 컬럼:

class Teacher < ApplicationRecord
  belongs_to :school, counter_cache: true
end

# 마이그레이션
add_column :schools, :teachers_count, :integer, default: 0
# 기존 데이터 재계산
School.reset_counters(school.id, :teachers)

school.teachers.countschool.teachers_count 컬럼 직접 read. 자식 create / destroy 시 자동 갱신.

대규모 동시 INSERT 시 row lock 경합 주의. 절대값 정확성 critical 아니면 counter_cache: true 권장.

자주 보는 함정

1. find vs find_by + includes

# ❌ find_by 가 includes 무시
School.includes(:teachers).find_by(id: 1)

# ✓
School.includes(:teachers).where(id: 1).first

(Rails 7+ 에서 일부 개선됐지만 안전한 패턴 권장)

2. uniq / distinct 후 includes

# JOIN 결과의 row 중복 제거하면서 preload 가 깨질 수 있음
School.includes(:teachers).distinct

Rails 가 알아서 처리하지만 가끔 의도와 다른 row count. 명시적으로 분리:

school_ids = School.joins(:teachers).distinct.pluck(:id)
School.where(id: school_ids).includes(:teachers)

3. select 와 함께

select 로 부분 컬럼만 가져올 때 연관 FK 누락 주의:

# ❌ school_id 가 select 에 없으면 teachers 매칭 불가
Teacher.select(:name).includes(:school)   # error

4. polymorphic + includes

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

# polymorphic 은 type 마다 다른 테이블 → JOIN 불가
Comment.includes(:commentable)   # preload 강제됨

자동으로 preload 만 사용 (JOIN 불가). 의도 그대로.

5. scope 와 조합

class Post < ApplicationRecord
  scope :published, -> { where(published: true) }
end

# scope 가 includes / preload 안에 작동
User.includes(:posts).merge(Post.published)

merge 로 scope 결합.

성능 측정

# 쿼리 수 측정
expect do
  User.includes(:posts).each { |u| u.posts.size }
end.to make_database_queries(count: 2)   # rspec-rails-shoulda matchers

# 로그
ActiveRecord::Base.logger = Logger.new(STDOUT)
# 또는 .logger.level = Logger::DEBUG

베스트 프랙티스

  • 기본은 includes: Rails 가 알아서 최적 선택
  • 조건 없으면 의도 명확히 preload: 부모 row 중복 회피
  • 연관 테이블 조건 + 작은 결과면 eager_load 또는 includes + references
  • bullet gem 개발 환경에 필수
  • Rails 6.1+ 면 strict_loading 적극 활용: 의도 안 한 lazy 차단
  • count 가 자주 발생하면 counter_cache
  • 체이닝 길어지면 별도 query 로 분리: 가독성 + 성능 모두 개선
  • EXPLAIN ANALYZE 로 실제 plan 확인 (.explain)

관련 위키

이 글의 용어 (6개)
[비교] JPA EAGER vs Rails eager_loadframeworks
정의 JPA 와 Rails 는 둘 다 "eager" 라는 용어를 ORM 의 연관 로딩 전략에 사용한다. 이름은 비슷하지만 의미는 정반대. - JPA : 매핑 선언 시점에 영구 설…
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[Rails] ActiveRecord 기초: Model, Migration, CRUDrails
정의 ActiveRecord는 Rails의 ORM. 클래스 ↔ 테이블 1:1 매핑(관례), 인스턴스 = 행. Active Record 패턴 그대로(데이터 + 행동). 마이그레이션…
[Rails] AR Associations: belongs_to, has_many, polymorphicrails
정의 ActiveRecord associations로 테이블 간 관계를 객체 그래프로 표현. 6가지 매크로( , , , , , )와 polymorphic, STI 같은 고급 패턴…
[Spring] Spring Data JPA: Repository, Query Methodsspring
정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…
Ruby on Railsrails
정의 Ruby on Rails (또는 Rails)는 Ruby 언어 기반의 풀스택 웹 프레임워크다. David Heinemeier Hansson (DHH)이 2004년 Baseca…

이 개념을 다룬 위키 페이지 (2)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기