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(); |
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 |
다. JPA/JPQL 쿼리
<-- JPA/JPQL 쿼리 --> | |
.setFirstResult(offset).setMaxResults(limit) // SQL에 맞게 자동 변환(LIMIT/OFFSET 등) |
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"); |
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]; |
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); |
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(); |
나. 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(); |
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(); |
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(); |
- 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(); |
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(); |
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); |
나. Hibernate
- get: find와 동일, 엔티티 POJO 반환
- load: getReference와 동일, 프록시 반환
// 여러 엔티티 동시 조회 | |
List<Book> books = session.byMultipleIds(Book.class).multiLoad(listOfIds); |
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"); |
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(); |
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); |
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 |
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 |