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

[비교] JPA EAGER vs Rails eager_load

· 수정 · 📖 약 4분 · 1,470자/단어 #jpa #rails #hibernate #active-record #n-plus-one #comparison
JPA vs Rails eager loading, EAGER 비교, fetch type Rails, Rails eager_load JPA, N+1 JPA Rails, jpa rails n+1

정의

JPA 와 Rails 는 둘 다 “eager” 라는 용어를 ORM 의 연관 로딩 전략에 사용한다. 이름은 비슷하지만 의미는 정반대.

N+1 문제란

N+1 문제는 연관 객체를 지연 로딩(lazy loading)할 때 발생하는 성능 패턴이다. 부모 목록 1회 쿼리 후, 각 부모의 연관 객체를 N번 추가 쿼리하는 구조다.

sequenceDiagram
    participant APP as 애플리케이션
    participant DB as 데이터베이스

    APP->>DB: SELECT * FROM posts (1번)
    DB-->>APP: [post1, post2, post3, ... postN]

    loop N번 반복
        APP->>DB: SELECT * FROM users WHERE id = ?
        DB-->>APP: author
    end

    Note over APP,DB: 총 N+1번 쿼리 발생

N=100 이면 101번 쿼리. N=1000 이면 1001번 쿼리. ORM의 편리함이 성능 문제의 원인이 된다.

  • JPA FetchType.EAGER: 매핑 선언 시점에 영구 설정. 그 entity 를 조회할 때마다 항상 자동으로 함께 로드. 끄는 게 권장.
  • Rails eager_load: 쿼리 시점에 옵트인. 그 쿼리에서만 JOIN 으로 함께 로드. 권장 패턴.

이름이 같아 혼동하기 쉬워 별도 페이지로 정리한다.

로딩 전략 비교 시각화

flowchart LR
    subgraph JPA["JPA 전략"]
        JL["FetchType.LAZY<br/>(기본 권장)"]
        JE["FetchType.EAGER<br/>(기본값, 위험)"]
        JF["JOIN FETCH<br/>(쿼리 시점 명시)"]
        JB["@BatchSize<br/>(IN절 묶음)"]
        JG["EntityGraph<br/>(동적 fetch)"]
    end

    subgraph RAILS["Rails 전략"]
        RL["lazy (기본)"]
        RPL["preload<br/>(2쿼리 IN절)"]
        REL["eager_load<br/>(LEFT OUTER JOIN)"]
        RI["includes<br/>(자동 선택)"]
    end

    JE -->|"N+1 유발"| PROBLEM["N+1 문제"]
    RL -->|"N+1 유발"| PROBLEM

    JF -->|"해결"| SOL["N+1 해결"]
    JB -->|"해결"| SOL
    JG -->|"해결"| SOL
    RPL -->|"해결"| SOL
    REL -->|"해결"| SOL
    RI -->|"해결"| SOL

1줄 비교

측면JPA EAGERRails eager_load
선언 위치Entity 매핑 (@ManyToOne(fetch=EAGER))Query 호출 (.eager_load(:assoc))
적용 범위그 entity 의 모든 조회그 쿼리만
변경 가능성코드 수정 + 재배포쿼리마다
권장 사용거의 항상 비권장권장 (또는 includes)
기본값@ManyToOne 은 EAGER (위험)명시 안 함 (lazy)
실행 방식즉시 SELECT (별도 또는 JOIN)LEFT OUTER JOIN 단일 쿼리

JPA EAGER 의 의미

@Entity
class Post {
    @ManyToOne(fetch = FetchType.EAGER)   // ← 기본값 (위험)
    @JoinColumn(name = "author_id")
    User author;
}

List<Post> posts = em.createQuery("SELECT p FROM Post p", Post.class).getResultList();
// → posts SELECT 1번 + 각 post 의 author SELECT N번 = N+1
// 또는 Hibernate 의 batch fetch 가 켜있으면 IN 절로 일부 묶임

“Post 가 어디서 조회되든 author 도 함께 로드”. 매핑이 영구. 끄려면 코드 수정.

위험:

  • N+1 폭탄
  • 깊은 그래프 (Post → Comment → User → Profile) 가 의도치 않게 전부 로드
  • 끌 수 없음 (LAZY 로 매핑 변경 후 재배포 필요)

규칙: 모든 연관 LAZY 선언, 쿼리마다 필요한 것만 fetch join / EntityGraph / @BatchSize 로 명시. spring-jpa-fetch-type 참고.

Rails eager_load 의 의미

class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
end

# 기본 (lazy)
posts = Post.all
posts.each { |p| puts p.author.name }   # N+1

# eager_load 옵트인
posts = Post.eager_load(:author)
# → 1쿼리: SELECT p.*, u.* FROM posts LEFT OUTER JOIN users ON ...
posts.each { |p| puts p.author.name }   # 추가 쿼리 없음

“이 쿼리 한 번만 author 도 함께”. 쿼리 시점 결정. 다음 쿼리는 lazy.

권장 패턴: 연관은 lazy by default, 필요할 때만 includes/preload/eager_load 명시. rails-query-optimization 참고.

정확한 매핑

JPA 와 Rails 의 개념적 대응:

Rails 메서드해당 JPA 패턴발생 쿼리
preload(:assoc)@BatchSize 또는 JOIN FETCH 안 쓰는 explicit 2-쿼리2 쿼리 (부모 + 자식 IN)
eager_load(:assoc)JOIN FETCH p.assoc JPQL1 쿼리 (LEFT OUTER JOIN)
includes(:assoc)(없음)preload 또는 eager_load 자동
references(:assoc)(없음, JPQL 의 JOIN FETCH 와 유사)eager_load 강제

JPA 의 FetchType.EAGER 는 Rails 에 직접적 대응 없음. 굳이 비유하면 association 정의에 eager_load(:assoc) 를 default scope 로 박아두는 것 (안티패턴).

Rails 의 “EAGER” 라는 단어

Rails 문서 / 커뮤니티에서 “eager loading” 은 일반적으로 includes / preload / eager_load 셋을 통칭. “eager 로 가져온다” = “lazy 안 하고 미리 가져온다” 의미.

JPA 의 EAGER 는 fetch type = 영구 매핑 속성. 다른 개념.

권장 패턴 비교

// JPA: lazy + 쿼리마다 명시
@Entity
class Post {
    @ManyToOne(fetch = FetchType.LAZY)
    User author;
}

@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
# Rails: 기본 lazy + 쿼리마다 명시
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
end

def self.with_author
  includes(:author)
end

두 ORM 모두 lazy by default + 쿼리 시점에 fetch 선택 이 best practice. JPA 가 기본을 EAGER 로 잘못 정한 게 함정.

N+1 디버깅 도구

항목JPA / HibernateRails
로그org.hibernate.SQL=DEBUGActiveRecord::Base.logger
정적 분석Hypersistence Utilsbullet gem
자동 감지(수동)strict_loading (Rails 6.1+)
Profilerp6spy, datasource-proxyrack-mini-profiler
인덱스 검사Hibernate StatisticsEXPLAIN ANALYZE

bullet gem 처럼 자동 N+1 알림 도구가 JPA 진영엔 약함. 직접 통합 테스트에서 Statement count 측정.

JPA: EntityGraph (동적 Fetch 전략)

@NamedEntityGraph 또는 동적 EntityGraph로 쿼리별 fetch 전략을 지정한다:

// 정적 선언
@Entity
@NamedEntityGraph(
  name = "Post.withAuthor",
  attributeNodes = @NamedAttributeNode("author")
)
class Post {
  @ManyToOne(fetch = FetchType.LAZY)
  User author;

  @OneToMany(fetch = FetchType.LAZY)
  List<Comment> comments;
}

// 사용
EntityGraph graph = em.getEntityGraph("Post.withAuthor");
Map<String, Object> hints = Map.of("jakarta.persistence.fetchgraph", graph);
List<Post> posts = em.createQuery("SELECT p FROM Post p", Post.class)
  .setHint("jakarta.persistence.fetchgraph", graph)
  .getResultList();

Spring Data JPA에서:

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {

  @EntityGraph(attributePaths = {"author"})
  List<Post> findAll();

  @EntityGraph(attributePaths = {"author", "comments"})
  Optional<Post> findById(Long id);
}

EntityGraph 타입:

  • FETCH: 명시한 필드는 EAGER, 나머지는 LAZY
  • LOAD: 명시한 필드는 EAGER, 나머지는 매핑 기본값 유지

JPA: @BatchSize (N+1 완화)

@BatchSize는 N+1을 완전히 없애지는 않지만, N번 쿼리를 IN절 묶음으로 줄인다:

@Entity
class Post {
  @OneToMany
  @BatchSize(size = 25)
  List<Comment> comments;
}

SELECT * FROM comments WHERE post_id IN (1, 2, ..., 25) 형태로 25개씩 묶어서 조회. N=100 이면 100번 → 4번으로 감소.

전역 배치 크기 설정 (Hibernate):

# application.yml
spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100

Rails: includes / preload / eager_load 선택 기준

세 메서드는 동작 방식이 다르다:

# preload: 항상 2번 쿼리 (부모 SELECT + IN절 자식 SELECT)
Post.preload(:author)
# → SELECT * FROM posts
# → SELECT * FROM users WHERE id IN (1, 2, 3, ...)

# eager_load: 항상 JOIN 1번 쿼리
Post.eager_load(:author)
# → SELECT posts.*, users.* FROM posts
#     LEFT OUTER JOIN users ON posts.author_id = users.id

# includes: Rails가 상황에 따라 preload 또는 eager_load 자동 선택
Post.includes(:author)
# where 조건에 연관 테이블 컬럼 사용 시 → eager_load
# 그 외 → preload
Post.includes(:author).where('users.active = ?', true).references(:author)
# → eager_load 강제

중첩 연관 로딩:

# posts → author → profile 까지 한번에
Post.includes(author: :profile)

# 여러 연관 동시에
Post.includes(:author, :comments, :tags)

# 3단계 중첩
Post.includes(comments: { author: :profile })

Rails strict_loading (6.1+):

# 모델에서 lazy loading 완전 차단 (개발/테스트 환경에서 N+1 조기 발견)
class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true
end

# 특정 쿼리에만 적용
Post.strict_loading.all

함정 비교

JPA 함정

  1. @ManyToOne 기본 EAGER: 명시적으로 LAZY 안 하면 위험
  2. @OneToOne 양방향 LAZY 안 됨 (proxy 못 만듦): bytecode enhancement 또는 optional=false
  3. 컬렉션 fetch join + Pageable = in-memory paging: OOM 위험
  4. OSIV (Open Session In View) 가 LAZY 함정 가림: 운영에선 끄기 권장 spring.jpa.open-in-view=false

Rails 함정

  1. find_by + includes 일부 버전에서 includes 무시: .where().first 패턴 권장
  2. uniq/distinct + includes 의 row count 미묘함
  3. select 시 FK 누락: select(:name).includes(:school) 깨짐
  4. polymorphic 은 JOIN 불가, preload 강제

작은 결과 / 큰 결과의 전략

결과 크기JPA 권장Rails 권장
부모 < 10JOIN FETCHeager_load
부모 < 1000, 자식 비대2 쿼리 (@BatchSize)preload
PageableEntityGraph + slice (collection 제외)includes + paging
깊은 nested단계별 explicit fetchpreload 단계 분리

정리

질문
JPA EAGER 와 Rails eager_load 가 같은 뜻인가?아니다. 이름만 같다.
JPA EAGER 사용 권장하나?아니다. 모든 연관 LAZY.
Rails eager_load 사용 권장하나?그렇다. lazy default + 쿼리마다 명시.
둘 다 N+1 해결책인가?Rails eager_load 는 명백한 해결책. JPA EAGER 는 종종 N+1 의 원인.

이름이 미묘하게 같아서 “EAGER 면 빠른 것” 으로 단순화하지 말 것. 두 ORM 의 정반대 권장 사항을 기억.

쿼리 로그로 N+1 확인

JPA / Hibernate

# application.yml
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
// p6spy로 실제 파라미터까지 확인
// pom.xml: p6spy 의존성 추가 후
// spy.properties: appender=com.p6spy.engine.spy.appender.Slf4JLogger

Rails

# config/environments/development.rb
config.log_level = :debug  # SQL 쿼리 전부 출력

# bullet gem: N+1 자동 감지 (개발 환경)
# Gemfile
gem 'bullet', group: :development

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.rails_logger = true
end

bullet gem이 N+1을 감지하면 로그에:

USE eager loading detected
  Post => [:author]
  Add to your query: .includes([:author])

전략 선택 요약

상황JPA 선택Rails 선택
부모 < 10개 + 연관 1개JOIN FETCHeager_load
부모 수백 개 + 연관 1개@BatchSize 또는 JOIN FETCHpreload 또는 includes
연관 컬럼으로 WHERE 필터링JOIN FETCH + JPQL whereeager_load + where
페이지네이션 + 연관EntityGraph (컬렉션 제외)includes (컬렉션 주의)
다단계 중첩단계별 쿼리 + @BatchSizepreload 단계 분리
개발 중 N+1 조기 감지Hibernate Statistics / p6spybullet gem + strict_loading

관련 위키

이 글의 용어 (5개)
[JPA] FetchType: LAZY vs EAGER, N+1, fetch joinspring
정의 FetchType 은 JPA 가 연관 entity 를 언제 로드할지 결정하는 hint. - : entity 로딩 시 즉시 함께 SELECT - : 실제 사용 시점 (prox…
[JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
[Rails] 쿼리 최적화: preload, eager_load, includes, referencesrails
정의 Active Record 는 연관 ( , ) 을 lazy 로딩한다. 부모 객체를 받아온 뒤 자식을 순회하면 자식마다 SELECT 가 1번씩 추가로 발생 = N+1 문제. R…
[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이 구현체 자…

💬 댓글

사이트 검색 / 명령어

검색

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