DB/JPA

[Hibernate/JPA] Fetching

꾸준함. 2025. 6. 9. 11:07

1. JDBC Statement Fetch Size

 

1.1 Statement Fetch Size란?

  • JDBC Statement의 ResultSet은 DB 커서로, 한 번에 "몇 개의 행(row)"을 클라이언트로 가져올지 결정하는 값이 fetch size
  • statement.setFetchSize(n)와 같이 설정하면
    • n 개씩 데이터 fetch 하고
    • 네트워크 round-trip 횟수와 메모리 사용량에 직접적 영향을 끼침

 

1.2 DBMS별 기본 fetch size와 특징

 

가. Oracle

  • default fetch size: 10
  • 10g, 11g 드라이버는 최대 사이즈로 버퍼 미리 할당 (VARCHAR2(4000) 컬럼은 4000바이트)
  • 12c부터는 실제 데이터 크기만큼만 할당 (메모리 효율 ↑)
  • Statement 캐싱 사용 시에만 버퍼 재활용

 

나. SQL Server

  • default fetch size:128
  • 드라이버에서 adaptive buffering으로 메모리 부족 방지

 

다. PostgreSQL

  • default: 모든 데이터를 한 번에 prefetch (전체를 한 번에 가져옴)
  • fetch size 지정 시 서버 커서로 동작
    • JPA 2.2 getResultStream, Hibernate scroll 등에서 유용

 

라. MySQL

  • default: 모든 데이터를 한 번에 prefetch
  • streaming 옵션 지정하는 방법은 다음과 같음
    • 한 행씩: statement.setFetchSize(Integer.MIN_VALUE)
    • 여러 행씩: 커넥션 속성 useCursorFetch=true + setFetchSize(n)

 

1.3 Hibernate에서 fetch size 설정 방법

 

// 전역 설정
<property name="hibernate.jdbc.fetch_size" value="50"/>
// 쿼리별 설정
entityManager.createQuery("select p from Post p", Post.class)
.setHint("org.hibernate.fetchSize", 50)
.getResultStream();
view raw .xml hosted with ❤ by GitHub

 

1.4 fetch size 설정에 따른 영향도

  • fetch size가 작을 경우
    • DB→애플리케이션 왕복이 자주 발생 → 네트워크 오버헤드, 응답시간 증가
    • i.g. fetch size=1, 10,000 rows → 10,000번 왕복, 500ms+

 

  • fetch size가 클수록
    • 왕복 횟수 감소, 응답시간 대폭 단축 (i.g. fetch size=100, 100회 왕복)
    • 너무 클 경우 메모리 부담 증가, 성능 개선 한계에 도달

 

  • fetch size는 50~200 사이의 값이 대부분의 환경에서 효과적이며 너무 큰 값은 메모리/GC 병목 유발 가능하므로 주의 필요

 

1.5 실무 적용 팁

  • 대량 데이터 스크롤 및 스트리밍 처리 시 fetch size 반드시 명시하고 커서 기반으로 점진적 fetch 하는 것을 권장
  • 기본 fetch size가 낮은 Oracle 등은 전역/쿼리별 fetch size 설정으로 성능 개선 효과를 크게 볼 수 있음
  • Hibernate/JPA에서 getResultStream(), scroll() 등에서 fetch size 명시를 통해 네트워크 및 메모리 효율 극대화 가능

 

2. JDBC ResultSet Size

 

2.1 ResultSet 크기를 제한해야 하는 이유

  • 데이터는 시간이 지날수록 계속 증가하기 때문에 쿼리의 기본 결과 집합이 점점 커져 응답 속도 저하, 리소스 낭비, 예기치 않은 부하 발생할 수 있음
  • 따라서 다비즈니스 요구사항에 따라 필요한 데이터만 정확히 가져오는 것이 중요
    • UI 페이징, API 응답, 보고서 등에서 꼭 필요한 N개만 가져오는 Top-N 혹은 Next-N 쿼리 필수

 

  • 엔티티 전체가 아니라 DTO Projection을 통해 필요한 컬럼만 조회할 경우 성능 대폭 향상 가능

 

2.2 SQL 표준 및 DB별 페이징 (Top-N/Next-N)

 

가. SQL 2008 표준

  • FETCH FIRST N ROWS ONLY
  • OFFSET M ROWS FETCH NEXT N ROWS ONLY
  • 지원하는 DB: Oracle 12c+, SQL Server 2012+, PostgreSQL 8.4+

 

나. DB별 레거시 문법

 

<-- Oracle 12 이전 Top-N -->
SELECT * FROM (SELECT ... ORDER BY ...) WHERE ROWNUM <= 5
<-- Oracle 12 이전 Next-N -->
SELECT * FROM (
SELECT t.*, ROWNUM AS rn FROM (
SELECT ... ORDER BY ...
) t WHERE ROWNUM <= 10
) WHERE rn > 5
<-- SQL Server Top-N -->
SELECT TOP 5 ...
<-- SQL Server Next-N -->
SELECT ... FROM (
SELECT ROW_NUMBER() OVER (ORDER BY ...) AS rn, ... FROM ...
) t WHERE rn > 5 AND rn <= 10
<-- PostgreSQL/MySQL Top-N -->
LIMIT 5
<-- PostgreSQL/MySQL Next-N -->
LIMIT 5 OFFSET 5
view raw .sql hosted with ❤ by GitHub

 

 

다. JPA/JPQL 쿼리

 

<-- JPA/JPQL 쿼리 -->
.setFirstResult(offset).setMaxResults(limit) // SQL에 맞게 자동 변환(LIMIT/OFFSET 등)
view raw .java hosted with ❤ by GitHub

 

2.3 JDBC Statement의 setMaxRows와 SQL-Level 페이징의 차이

 

가. statement.setMaxRows(n)

  • JDBC 표준 메서드로 결과 집합 크기 제한
  • 해당 방식은 단점이 많음
    • 대부분의 DB에서 실행 계획에 영향 미치지 않음 (Oracle, PostgreSQL 등)
    • 결과를 모두 계산/정렬 후, n개만 클라이언트에 반환
    • SQL Server/MySQL은 SET ROWCOUNT, SET SQL_SELECT_LIMIT 등으로 실제 플랜에 반영

 

나. SQL-Level 페이징 (LIMIT, FETCH FIRST 등)

  • Optimizer가 아예 "n개만 필요"함을 알고, Index Seek 등 최적 경로 선택
  • 쿼리 성능, 네트워크, 메모리 모두에 효율적

 

2.4 실전 적용 팁

 

가. Projection 적용

  • SELECT *와 같이 asterisk를 적용하면 모든 컬럼 반환 (불필요한 네트워크/메모리/CPU 낭비)
    • 따라서 "SELECT title FROM post ..."와 같이 실제 필요한 컬럼만 Projection 하는 것을 권장
    • DTO/튜플 프로젝션을 적극 활용할 경우 성능 대폭 향상 가능 (특히 JOIN/대용량 데이터에서 효과 큼)

 

나. 실전 예제

 

<-- JPQL 페이징 -->
List<Post> posts = em.createQuery(
"select p from Post p order by p.createdOn desc", Post.class)
.setFirstResult(10) // OFFSET 10
.setMaxResults(10) // LIMIT 10
.getResultList();
<-- Native SQL 페이징 -->
List<Tuple> posts = em.createNativeQuery(
"SELECT p.id, p.title FROM post p ORDER BY p.created_on LIMIT 10 OFFSET 10", Tuple.class)
.getResultList();
<-- JDBC setMaxRows -->
Statement stmt = conn.createStatement();
stmt.setMaxRows(10);
ResultSet rs = stmt.executeQuery("SELECT * FROM post");
view raw .java hosted with ❤ by GitHub

 

3. JPA 쿼리 프로젝션

 

3.1 기본 Object[] 프로젝션

  • JPA에서 여러 컬럼을 선택하면 기본적으로 각 행이 Object[]로 반환됨
  • JPQL, Criteria, Native SQL 모두에서 사용 가능
  • 단점이 많은 프로젝션 유형
    • 가장 범용적이나 타입 안전성이 떨어짐
    • 컬럼 순서 또는 별칭이 바뀌면 코드 수정 필요

 

List<Object[]> tuples = em.createQuery(
"select p.id, p.title from Post p"
).getResultList();
Object[] tuple = tuples.get(0);
Long id = ((Number) tuple[0]).longValue();
String title = (String) tuple[1];
view raw .java hosted with ❤ by GitHub

 

3.2 Tuple 프로젝션

  • JPA 2.0+ 버전에서 Tuple 타입 사용 시 컬럼 별칭 (alias)으로 결괏값을 타입 안전하게 조회 가능
  • JPQL/Native SQL 모두 지원

 

List<Tuple> tuples = em.createQuery(
"select p.id as id, p.title as title from Post p", Tuple.class
).getResultList();
Tuple tuple = tuples.get(0);
Long id = tuple.get("id", Number.class).longValue();
String title = tuple.get("title", String.class);
view raw .java hosted with ❤ by GitHub

 

3.3 DTO 프로젝션

  • 타입 안전, 유지보수성/가독성/IDE 지원 우수
  • 복잡한 쿼리, 대규모 프로젝트에서 필수

 

가. JPQL 생성자 표현식

  • DTO 생성자에 맞춰 쿼리 작성
  • Hibernate Types의 ClassImportIntegrator를 활용하면 패키지 경로 없이 단순 클래스명으로도 사용 가능

 

public class PostDTO {
private final Long id;
private final String title;
public PostDTO(Number id, String title) {
this.id = id.longValue();
this.title = title;
}
// getters
}
List<PostDTO> dtos = em.createQuery(
"select new com.example.PostDTO(p.id, p.title) from Post p", PostDTO.class
).getResultList();
view raw .java hosted with ❤ by GitHub

 

나. SqlResultSetMapping + NamedNativeQuery

  • Native SQL 결과를 DTO 생성자에 직접 매핑

 

@SqlResultSetMapping(
name = "PostDTOMapping",
classes = @ConstructorResult(
targetClass = PostDTO.class,
columns = {
@ColumnResult(name = "id"),
@ColumnResult(name = "title")
}
)
)
@NamedNativeQuery(
name = "PostDTONativeQuery",
query = "SELECT p.id AS id, p.title AS title FROM post p",
resultSetMapping = "PostDTOMapping"
)
List<PostDTO> dtos = em.createNamedQuery("PostDTONativeQuery").getResultList();
view raw .java hosted with ❤ by GitHub

 

3.4 Java 14+ 버전부터 도입된 Records로 DTO 프로젝션

  • Java 14부터 지원되는 record로 DTO를 매우 간결하게 선언
  • 역시 Hibernate Types의 ClassImportIntegrator를 활용하면 단순 클래스명으로도 쿼리에서 참조 가능
  • 타입 안전, 유지보수성/가독성/IDE 지원 우수
  • 복잡한 쿼리, 대규모 프로젝트에서 필수

 

public record PostRecord(Number id, String title) {}
List<PostRecord> records = em.createQuery(
"select new PostRecord(p.id, p.title) from Post p", PostRecord.class
).getResultList();
view raw .java hosted with ❤ by GitHub

 

4. Hibernate ResultTransformer와 계층적 DTO 프로젝션

 

4.1 Hibernate ResultTransformer와 DTO 프로젝션

  • ResultTransformer는 쿼리 결과 (Object[] 혹은 Tuple)를 개발자가 원하는 DTO 형태로 변환하는 Hibernate 고유 기능
  • 대표적으로 Transformers.aliasToBean() ResultTransformer를 사용하면 SQL/JPQL 컬럼 별칭(alias)과 DTO 클래스의 setter 이름을 매칭하여 자동으로 DTO를 생성
  • 컬럼 별칭이 DTO의 프로퍼티와 정확히 일치해야 하며, DTO에 기본 생성자와 setter가 있어야 함

 

public class PostDTO {
private Long id;
private String title;
// getters/setters 필수
}
// JPQL이나 Native SQL 쿼리에서 사용
List<PostDTO> dtos = entityManager.createQuery(
"select p.id as id, p.title as title from Post p"
)
.unwrap(org.hibernate.query.Query.class)
.setResultTransformer(Transformers.aliasToBean(PostDTO.class))
.getResultList();
view raw .java hosted with ❤ by GitHub

 

  • Native SQL에서도 동일하게 활용 가능하지만 컬럼 별칭에 쌍다옴표를 써야하고 대소문자 구문이 정확히 맞아야 함

 

List<PostDTO> dtos = entityManager.createNativeQuery(
"SELECT p.id AS \"id\", p.title AS \"title\" FROM post p"
)
.unwrap(org.hibernate.query.Query.class)
.setResultTransformer(Transformers.aliasToBean(PostDTO.class))
.getResultList();
view raw .java hosted with ❤ by GitHub

 

4.2 계층적 (Parent-Child) DTO 프로젝션

  • 부모/자식 테이블 JOIN 쿼리 결과를 계층적 DTO로 변환
  • SQL 조인 결과는 "평면 테이블"이지만, ResultTransformer를 구현해 부모 DTO 내부에 자식 DTO 컬렉션을 담는 "계층형 객체"로 변환 가능

 

// 계층적 DTO 예시
public class PostDTO {
private Long id;
private String title;
private List<PostCommentDTO> comments = new ArrayList<>();
// 생성자, getters/setter
}
public class PostCommentDTO {
private Long id;
private String review;
// 생성자, getters/setter
}
<-- SQL 예시 -->
SELECT
p.id AS p_id,
p.title AS p_title,
pc.id AS pc_id,
pc.review AS pc_review
FROM
post p
JOIN
post_comment pc
ON
p.id = pc.post_id
ORDER BY pc.id
// 커스텀 ResultTransformer 구현
public class PostDTOResultTransformer implements ResultTransformer {
private Map<Long, PostDTO> postDTOMap = new LinkedHashMap<>();
private Map<String, Integer> aliasToIndexMap;
// transformTuple()에서 부모 PostDTO가 없으면 생성, 자식 DTO는 항상 추가
@Override
public Object transformTuple(Object[] tuple, String[] aliases) {
if (aliasToIndexMap == null) {
aliasToIndexMap = new LinkedHashMap<>();
for (int i = 0; i < aliases.length; i++) {
aliasToIndexMap.put(aliases[i], i);
}
}
Long postId = ((Number) tuple[aliasToIndexMap.get("p_id")]).longValue();
PostDTO postDTO = postDTOMap.computeIfAbsent(postId, id -> new PostDTO(tuple, aliasToIndexMap));
postDTO.getComments().add(new PostCommentDTO(tuple, aliasToIndexMap));
return postDTO;
}
// transformList()에서 최종적으로 중복 없는 부모 DTO 리스트만 반환
@Override
public List transformList(List collection) {
return new ArrayList<>(postDTOMap.values());
}
}
// 결과적으로, 각 PostDTO에는 관련 PostCommentDTO 리스트가 포함된 계층적 객체 그래프가 만들어짐
List<PostDTO> postDTOs = entityManager.createNativeQuery(
"SELECT p.id AS \"p_id\", p.title AS \"p_title\", pc.id AS \"pc_id\", pc.review AS \"pc_review\" " +
"FROM post p JOIN post_comment pc ON p.id = pc.post_id ORDER BY pc.id"
)
.unwrap(org.hibernate.query.NativeQuery.class)
.setResultTransformer(new PostDTOResultTransformer())
.getResultList();
view raw .java hosted with ❤ by GitHub

 

4.3 실무 적용 팁

  • aliasToBean 방식은 단순/플랫 DTO에 매우 편리하며 계층적 구조는 커스텀 ResultTransformer로 구현하는 것을 권장
    • Native SQL, JPQL, Criteria API 등 모든 쿼리에서 사용 가능

 

  • DTO 필드는 final로 선언하지 말고, 특히 aliasToBean 사용 시 기본 생성자와 setter를 반드시 제공하는 것을 권장
    • 컬럼 별칭 (alias)은 DTO 속성과 정확히 일치해야 함

 

5. 엔티티 Fetching

 

5.1 엔티티 식별자 직접 조회

 

가. JPA

  • find: 즉시 로딩된 POJO 반환
  • getReference: 프록시 반환 (실제 값은 속성 접근 시 지연 로딩)

 

 

// find: 즉시 로딩된 POJO 반환
Post post = entityManager.find(Post.class, 1L);
// getReference: 프록시 반환
Post post = entityManager.getReference(Post.class, 1L);
view raw .java hosted with ❤ by GitHub

 

나. Hibernate

  • get: find와 동일, 엔티티 POJO 반환
  • load: getReference와 동일, 프록시 반환

 

// 여러 엔티티 동시 조회
List<Book> books = session.byMultipleIds(Book.class).multiLoad(listOfIds);
view raw .java hosted with ❤ by GitHub

 

5.2 Hibernate 비즈니스 키로 직접 조회

  • @NaturalIdCache, @Cache 어노테이션 활용 시 Hibernate가 2차 캐시에서 조회하므로 DB 접근을 최소화시킬 수 있음

 

// @NaturalId로 엔티티 내 비즈니스 키 지정
@Entity
public class Post {
@Id
private Long id;
@NaturalId
@Column(nullable = false, unique = true)
private String slug;
// 중략
}
// 조회 코드
Post post = session.bySimpleNaturalId(Post.class).load("my-slug");
view raw .java hosted with ❤ by GitHub

 

5.3 JPQL, SQL, Criteria API 쿼리로 엔티티 조회

 

// JPQL
Post post = em.createQuery(
"select p from Post p where p.slug = :slug", Post.class
).setParameter("slug", slug).getSingleResult();
List<Post> posts = em.createQuery(
"select p from Post p where p.slug in (:slugs)", Post.class
).setParameter("slugs", slugs).getResultList();
// Native SQL
Post post = (Post) em.createNativeQuery(
"SELECT * FROM post WHERE slug = :slug", Post.class
).setParameter("slug", slug).getSingleResult();
// Criteria API
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Post> cq = cb.createQuery(Post.class);
Root<Post> root = cq.from(Post.class);
cq.where(cb.equal(root.get("slug"), slug));
Post post = em.createQuery(cq).getSingleResult();
view raw .java hosted with ❤ by GitHub

 

5.4 Criteria API와 Metamodel 활용

  • 엔티티 Metamodel은 다음과 같은 기능을 수행
    • JPA 메타모델 프로세서가 엔티티 필드별 정적 타입 필드를 생성
    • Criteria API에서 하드코딩 없이 타입 안전하게 속성 접근 가능

 

Root<Post> post = criteria.from(Post.class);
post.fetch(Post_.comments, JoinType.LEFT); // 컬렉션 fetch join
Predicate titlePredicate = cb.like(post.get(Post_.title), "%Hibernate%");
criteria.where(titlePredicate);
view raw .java hosted with ❤ by GitHub

 

5.5 정리

  • find/get은 즉시 DB SELECT, getReference/load는 지연로딩 프록시 (속성 접근 시 SELECT)
  • ManyToOne/OneToOne 관계 설정 시 getReference/load 사용하면 불필요한 SELECT 쿼리 방지할 수 있음
  • Hibernate의 natural id/비즈니스 키 조회는 캐시를 잘 활용하면 DB round-trip을 크게 줄일 수 있음
  • Criteria API + Metamodel은 동적 쿼리·복잡한 조인에서 타입 안전성과 유지보수성 모두 보장하기 때문에 권장하는 방법

 

6. 엔티티 연관관계 Fetching 전략

 

6.1 FetchType.EAGER vs FetchType.LAZY

 

가. @ManyToOne, @OneToOne

  • default:FetchType.EAGER
  • 엔티티를 로드할 때 연관된 엔티티도 즉시(조인 또는 추가 쿼리로) 같이 로딩

 

나. @OneToMany, @ManyToMany

  • default:FetchType.LAZY
  • 컬렉션은 실제로 접근할 때까지 SQL 쿼리 실행 (프록시/지연 로딩)

 

다. fetch 속성으로 매핑 시점에 기본 전략을 오버라이드 가능

  • 쿼리 시점에는 JOIN FETCH, 엔티티 그래프 등으로 동적으로 재정의 가능
    • 단, Hibernate는 EAGER를 완전히 무시하지 않음

 

6.2 JPQL/Criteria 쿼리와 Fetch 전략

 

가. 직접 조회(find, getReference 등)

  • EAGER 연관관계는 LEFT JOIN으로 한 번에 로딩

 

나. JPQL/Criteria 쿼리

  • EAGER 연관관계를 명시적으로 JOIN FETCH 하지 않으면 Hibernate가 별도 SELECT 쿼리 (N+1 쿼리)로 추가 로딩

 

6.3 컬렉션 연관관계 EAGER Fetch의 위험성

  • EAGER로 컬렉션 (Set, List 등) Fetch 시 SQL 카르테시안 곱 발생
    • i.g. Post에 comments, tags 모두 EAGER로 할 경우 post × comments × tags 크기의 결과 집합 생성 (심각한 성능 저하)

 

  • 컬렉션은 반드시 LAZY로 설정하는 것을 권장하며, 실제로 필요한 경우에만 JOIN FETCH 등으로 명시적 로딩하는 것을 권장

 

6.4 N + 1 쿼리 문제

  • LAZY 연관관계에서 흔히 발생하지만, EAGER 연관관계에도 쿼리에서 명시적 페칭이 없으면 똑같이 발생
    • i.g. post_comment 3개 SELECT 시 post_comment 3개 SELECT 각 comment의 post를 가져올 때마다 추가 SELECT 절이 나가 총 4번 쿼리 (N + 1)

 

  • JPQL JOIN FETCH을 통해 해결 가능

 

select pc from PostComment pc join fetch pc.post where pc.review = :review
view raw .sql hosted with ❤ by GitHub

 

6.5 여러 컬렉션 동시 Fetch 시 주의 사항

  • List 타입 컬렉션을 두 개 이상 동시에 fetch join 할 경Hibernate에서 MultipleBagFetchException 발생
    • 중복+카르테시안 곱 문제
    • Set 타입은 가능하지만, 여전히 카르테시안 곱으로 비효율적

 

  • 권장하는 방법은 다음과 같음
    • 한 번에 컬렉션 하나만 fetch join
    • 다른 컬렉션은 추가 쿼리로 별도 조회 (같은 조건으로 여러 번)
    • 또는 DTO 프로젝션으로 필요한 데이터만 쿼리

 

6.6 LazyInitializationException

  • 영속성 컨텍스트가 닫힌 후 프록시 객체의 지연 로딩 속성에 접근하면 예외 발생
    • 영속성 컨텍스트가 열려 있을 때 필요한 연관관계를 모두 미리 fetch 함으로써 해결 가능 (JOIN FETCH, 엔티티 그래프, Hibernate.initialize() 등)
    • 혹은 DTO 프로젝션 사용함으로써 해결 가능 (엔티티가 필요하지 않은 뷰/API 계층)

 

  • Open Session in View, enable_lazy_load_no_trans 두 패턴 모두 안티패턴
    • 트랜잭션 없는 상태에서 세션을 유지하거나 임시 세션을 열어 프록시 로딩
    • DB 커넥션/트랜잭션 관리가 불명확, N + 1문제, 관심사 분리 위배, 예측 불가한 부작용

 

참고

인프런 - 고성능 JPA & Hibernate (High-Performance Java Persistence)

반응형

'DB > JPA' 카테고리의 다른 글

[Hibernate/JPA] 캐싱  (0) 2025.06.11
[Hibernate/JPA] 트랜잭션과 동시성 제어 패턴  (0) 2025.06.09
[Hibernate/JPA] Batching  (0) 2025.06.04
[Hibernate/JPA] Statement  (0) 2025.06.04
[Hibernate/JPA] 영속성 컨텍스트  (0) 2025.06.04