JPA·JPQL 사용사례와 성능 최적화 — Hibernate 7.2 에서 JDBC 왕복 횟수를 직접 세 봤다
JPA 성능 글은 대개 “N+1 은 fetch join 으로 푼다” 같은 처방 목록이다. 처방은 맞다. 하지만 몇 번이 몇 번으로 줄어드는지, 어떤 처방이 어디서 깨지는지는 직접 돌려보지 않으면 감으로 남는다.
그래서 이번엔 숫자를 셌다. Hibernate ORM 7.2.7.Final + H2 2.3.232 (인메모리) 위에 JDBC DataSource 를 프록시로 감싸 executeQuery / executeUpdate / executeBatch / addBatch 호출 수를 그대로 집계했다. 데이터는 팀 10개 × 회원 10명(+ 팀당 프로젝트 3개)이다.
시간(ms)은 재지 않았다. 인메모리 H2 에서 잰 지연 시간은 실제 네트워크 너머 DB 와 전혀 다르기 때문이다. 대신 DB 왕복(round trip) 횟수를 센다. 이 값은 DB 종류와 상관없이 결정적이고, 실제 운영에서 지연을 키우는 건 대부분 이 횟수다.
범위 메모: No-Offset 페이징·커버링 인덱스·count 쿼리 분리·QueryDSL 동적 쿼리는 이 글에서 다루지 않는다. 같은 날 별도 글(QueryDSL 사용사례와 성능)이 다룬다.
한눈에 보는 결과
| # | 시나리오 | DB 왕복 | 비고 |
|---|---|---|---|
| 1 | Team 10개 조회 후 members 접근 (N+1) |
11 | 1 + 팀 수(10) |
| 2 | join fetch t.members |
1 | distinct 없이도 Team 10개로 반환 |
| 3 | EntityGraph (jakarta.persistence.fetchgraph) |
1 | |
| 4 | hibernate.default_batch_fetch_size=100 |
2 | where team_id in (?, … ) 한 번 |
| 4b | batch fetch + 페이징(0,3) | 2 | 페이징이 DB 에서 정상 적용 |
| 5 | Member 100건 조회 후 team.getName() (ManyToOne N+1) |
11 | 100+1 이 아니라 1 + 서로 다른 팀 수 |
| 5x | 같은 상황에서 m.team.name 필드 직접 접근 |
1 | 쿼리는 안 나가지만 값이 전부 null |
| 6 | 컬렉션 join fetch + 페이징(0,3) |
1 | SQL 에 offset/fetch 가 없음 → 경고 HHH90003004, 메모리에서 자름 |
| 6b | ToOne join fetch + 페이징(0,3) |
1 | SQL 에 offset ? rows fetch first ? rows only 정상 |
| 6c | fail_on_pagination_over_collection_fetch=true |
— | 6번이 예외로 바뀜 |
| 7 | List 컬렉션 두 개 동시 join fetch |
— | MultipleBagFetchException |
| 8 | DTO 프로젝션 select new |
1 | |
| 9a | 변경 감지 루프로 100건 수정 | 101 | select 1 + update 100 |
| 9b | JPQL 벌크 update |
1 | |
| 9c | 벌크 후 같은 엔티티 재조회 | — | clear 전 20(옛 값), clear 후 120 |
| 10 | persist 후 JPQL 실행 |
2 | JPQL 직전에 insert 가 자동 flush |
| 11 | SEQUENCE insert 500건, jdbc.batch_size=0 |
513 | 시퀀스 12 + insert 501 |
| 11 | SEQUENCE insert 500건, jdbc.batch_size=50 |
23 | 시퀀스 12 + executeBatch 11 |
| 11 | IDENTITY insert 500건, batch_size 0 / 50 |
500 / 500 | 배치 설정이 아무 효과 없음 |
아래는 각 항목을 “언제 쓰는가 → 무엇이 일어났나 → 근거” 순서로 풀어 쓴 것이다.
실험 장치
엔티티는 일부러 단순하게 잡았다. 모든 연관은 LAZY, 컬렉션은 List 다.
@Entity class Team {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE) Long id;
String name;
@OneToMany(mappedBy = "team") List<Member> members = new ArrayList<>();
@OneToMany(mappedBy = "team") List<Project> projects = new ArrayList<>();
}
@Entity class Member {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE) Long id;
String name;
int age;
@ManyToOne(fetch = FetchType.LAZY) Team team;
}
왕복 수는 Hibernate 통계 대신 JDBC 프록시로 셌다. Hibernate Statistics 의 prepareStatementCount 는 배치 실행 횟수를 직접 보여주지 않아서, executeBatch 를 따로 세려면 드라이버 경계에서 세는 편이 정확하다. 시나리오마다 새 세션을 열고, 끝나면 롤백해 다음 시나리오에 영향이 없게 했다.
1. N+1 — 왜 생기고, 정확히 몇 번인가
사용사례. 목록 화면에서 부모 엔티티를 조회한 뒤 각 부모의 자식(컬렉션) 또는 연관 엔티티를 화면에 찍는 경우. 가장 흔한 JPA 코드 모양이다.
List<Team> teams = em.createQuery("select t from Team t", Team.class).getResultList();
for (Team t : teams) total += t.getMembers().size(); // 여기서 팀마다 select
결과는 11 왕복이다. 팀 목록 1번, 그리고 팀마다 members 를 초기화하는 select 10번.
반대 방향(ManyToOne)은 흥미롭다. Member 100건을 조회한 뒤 member.getTeam().getName() 을 부르면 101번이 아니라 11번이다. 같은 Team 은 영속성 컨텍스트(1차 캐시)에 한 번 올라오면 다시 조회하지 않기 때문이다. 즉 ManyToOne N+1 의 실제 비용은 서로 다른 부모의 수에 비례한다. 부모 종류가 적으면 덜 아프고, 부모가 행마다 다르면(예: 주문 → 고객) 정확히 N+1 이 된다.
함정: 프록시의 필드를 직접 읽으면 쿼리 대신 null 이 나온다
5x 는 실수로 발견한 결과다. 처음엔 m.team.name 처럼 필드에 직접 접근했는데, 왕복이 1번밖에 안 나와서 “N+1 이 없네?” 라고 착각할 뻔했다. 실제로는 100명의 팀 이름이 전부 null 이었다.
LAZY @ManyToOne 자리에는 실제 Team 이 아니라 Hibernate 가 만든 프록시 서브클래스가 들어간다. 프록시는 메서드 호출을 가로채 초기화하므로, 클래스 밖에서 필드를 직접 읽으면 초기화되지 않은 프록시 자신의 빈 필드를 읽게 된다. Hibernate 소개서도 프록시를 “실제 엔티티인 척하는 객체”로 설명하고, 지연 로딩이 이 방식으로 동작한다고 쓴다.1 실무 코드에선 접근자를 쓰므로 드물지만, 테스트나 벤치마크 코드에서 “N+1 이 안 난다”는 잘못된 결론을 내기 딱 좋다.
2. 해법 셋 — fetch join, EntityGraph, batch fetch
fetch join (JPQL)
select t from Team t join fetch t.members
1 왕복. 생성된 SQL 은 Team–Member inner join 하나다.
과거 Hibernate 5 시절엔 이렇게 하면 결과 리스트에 Team 이 회원 수만큼 중복돼 나와서 select distinct 를 붙이는 게 관례였다. Hibernate 7.2.7 에서는 distinct 없이도 결과 리스트 크기가 10(팀 수)이었다. 엔티티 결과의 중복 제거를 Hibernate 가 스스로 하기 때문이다(Hibernate 6.0 부터의 동작).2 오래된 블로그의 “fetch join 엔 반드시 distinct” 조언은 지금 버전에선 필요 없다.
주의: 위 inner join 은 회원이 없는 팀을 결과에서 뺀다. 빈 팀도 필요하면
left join fetch.
EntityGraph
JPQL 을 고치지 않고 “이번 조회에선 이것도 같이 가져와”를 바깥에서 지정하는 방법이다. 같은 쿼리를 화면마다 다른 fetch 범위로 재사용할 때 쓴다.
EntityGraph<Team> graph = em.createEntityGraph(Team.class);
graph.addAttributeNodes("members");
em.createQuery("select t from Team t", Team.class)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
1 왕복. Spring Data JPA 에서는 리포지토리 메서드에 @EntityGraph(attributePaths = "members") 를 붙이는 것과 같다.3
batch fetch (default_batch_fetch_size)
hibernate.default_batch_fetch_size=100
쿼리는 그대로 두고, 지연 로딩이 일어날 때 아직 초기화되지 않은 같은 종류의 컬렉션을 묶어서 한 번에 가져오는 설정이다. 결과는 2 왕복. 두 번째 SQL 이 이렇다.
select ... from Member m1_0 where m1_0.team_id in (?,?,?, ... ,?)
IN 절의 ? 는 100개였다. 팀이 10개뿐인데도 100개인 이유는 Hibernate 가 배치 크기에 맞춘 고정 길이 파라미터 목록을 쓰고 남는 자리를 채우기 때문으로 보인다(관찰 결과이며, 이 동작의 근거 문서는 찾지 못했다). 이렇게 하면 SQL 문자열 종류가 줄어 statement 캐시에 유리하다는 해석이 흔하지만, 이 실험에서 검증한 것은 아니다.
셋 중 무엇을 쓰나
Hibernate 소개서는 기본 처방으로 join fetch 또는 EntityGraph 를 꼽고, batch fetch 는 “outer join 이 카테시안 곱과 거대한 결과를 낳는 드문 경우”의 해법이라고 쓴다.4 다만 아래 두 함정(페이징, 다중 컬렉션)에 걸리는 순간 batch fetch 가 사실상 유일한 선택지가 된다. 실무에서 default_batch_fetch_size 를 전역으로 켜 두는 관행이 흔한 이유다.
3. 함정 ① — 컬렉션 fetch join 에 페이징을 걸면
사용사례. “팀 목록을 3개씩 페이지로, 회원도 같이” — 관리자 화면에서 아주 흔하다.
em.createQuery("select t from Team t join fetch t.members order by t.id", Team.class)
.setFirstResult(0).setMaxResults(3)
.getResultList();
반환은 Team 3개로 겉보기엔 정상이다. 하지만 실제 SQL 을 보면:
select ... from Team t1_0 join Member m1_0 on t1_0.id=m1_0.team_id order by t1_0.id
offset / fetch first 가 없다. 회원 100행을 전부 메모리로 가져온 뒤 자바에서 3개를 잘랐다. 로그에는 이 경고 한 줄만 남는다.
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
행 단위로 페이징하면 팀 하나의 회원이 페이지 경계에서 잘리므로, Hibernate 로서는 이게 유일하게 정확한 방법이다. 문제는 데이터가 100만 건이면 100만 건이 다 올라온다는 것이다. Hibernate 사용자 가이드도 fetch join 은 “제한·페이징된 쿼리에서는 대체로 피해야 한다”고 명시한다.5
비교로 ToOne 쪽(select m from Member m join fetch m.team)에 페이징을 걸면 SQL 에 offset ? rows fetch first ? rows only 가 정상적으로 붙는다(6b). 행이 늘어나지 않기 때문이다.
대응.
- 조용히 지나가지 않게 막는다.
hibernate.query.fail_on_pagination_over_collection_fetch=true를 켜면 같은 코드가HibernateException으로 바뀐다(6c). 기본값은false다.6 운영 전 테스트 환경에서만이라도 켜 둘 가치가 있다. - 페이징은 부모에만, 자식은 batch fetch 로. 4b 실험이 이 조합이다.
select t from Team t order by t.id에 페이징을 걸고default_batch_fetch_size로 회원을 묶어 가져오면 2 왕복이고, 페이징은 DB 에서 처리된다.
4. 함정 ② — List 컬렉션 두 개를 동시에 fetch join
select t from Team t join fetch t.members join fetch t.projects
결과는 예외다.
MultipleBagFetchException: cannot simultaneously fetch multiple bags:
[bench.Team.members, bench.Team.projects]
순서 정보(@OrderColumn) 없는 List 는 Hibernate 에서 bag(중복 허용, 순서 없음)으로 취급된다.7 두 bag 을 한 SQL 로 조인하면 결과가 members × projects 카테시안 곱이 되는데, bag 은 중복이 허용되므로 어느 행이 진짜 중복인지 가려낼 수 없다. 그래서 Hibernate 는 아예 거부한다.
컬렉션을 Set 으로 바꾸면 예외는 사라지지만 카테시안 곱은 그대로다(팀당 회원 10 × 프로젝트 3 = 30행). 사용자 가이드도 여러 컬렉션을 병렬로 fetch 하면 “DB 수준에서 카테시안 곱이 생기고 성능이 매우 나쁠 수 있다”고 경고한다.8 현실적인 해법은 컬렉션 하나만 fetch join, 나머지는 batch fetch 다.
5. DTO 프로젝션 — 조회 전용이면 엔티티를 안 만든다
사용사례. 통계·목록 API 처럼 읽기만 하고 수정하지 않는 화면.
select new bench.Main$TeamCount(t.name, count(m))
from Team t left join t.members m
group by t.name
1 왕복, 결과는 TeamCount[name=team0, cnt=10] 형태의 레코드 10개. select new 는 결과를 엔티티 대신 사용자 클래스로 직접 만든다.9 결과 객체는 영속성 컨텍스트에 등록되지 않으므로 스냅샷 보관·변경 감지 비용이 없고, 집계처럼 엔티티로 표현할 수 없는 모양도 바로 받을 수 있다. 단점은 생성자 경로를 문자열로 전체 클래스명까지 적어야 한다는 것이다(중첩 클래스는 $). 이 불편이 QueryDSL Projections 나 Spring Data 인터페이스 프로젝션이 쓰이는 이유다.
6. 쓰기 — 변경 감지 vs 벌크 연산
변경 감지 루프는 건수만큼 update 한다
for (Member m : em.createQuery("select m from Member m", Member.class).getResultList())
m.age++;
em.flush();
101 왕복 — select 1 + update 100. 엔티티 하나당 update 하나다.
JPQL 벌크 update 는 한 번이다
em.createQuery("update Member m set m.age = m.age + 1").executeUpdate();
1 왕복, 100건 변경. Hibernate 소개서는 “여러 엔티티를 한 번에 바꾸려면 update 문을 쓰라”고 권한다.10
대가: 영속성 컨텍스트가 옛 값을 들고 있다
Member m = /* name='m0-0' 조회 */; // age = 20
em.createQuery("update Member m set m.age = m.age + 100").executeUpdate();
/* 같은 조건으로 다시 조회 */ .getAge(); // → 20 (!!)
em.clear();
/* 다시 조회 */ .getAge(); // → 120
벌크 update 는 DB 를 직접 바꾸고 영속성 컨텍스트는 건드리지 않는다. 재조회 쿼리는 DB 에 실제로 나가서 120 을 읽어 오지만, 같은 id 의 엔티티가 이미 1차 캐시에 있으면 Hibernate 는 이미 있는 인스턴스를 돌려준다. 그래서 화면에는 20 이 찍힌다.
Spring Data JPA 의 @Modifying(clearAutomatically = true) 가 바로 이 문제를 위한 옵션이다. 공식 문서는 벌크 쿼리 실행 후 EntityManager 에 오래된 엔티티가 남을 수 있으며, 자동으로 비우려면 이 속성을 켜라고 설명한다. 동시에 clear() 는 아직 flush 되지 않은 변경을 버린다는 경고도 함께 단다.11 그래서 벌크 연산은 트랜잭션 맨 앞에 두거나, 별도 트랜잭션으로 빼는 편이 안전하다. 그리고 벌크 update 는 기본적으로 @Version 컬럼도 올리지 않는다. 낙관적 락을 쓰는 엔티티라면 update versioned 를 써야 한다.12
7. 자동 flush — JPQL 은 쓰기를 먼저 내보낸다
em.persist(new Member("new", 30, team));
em.createQuery("select count(m) from Member m", Long.class).getSingleResult();
2 왕복, 순서는 insert → select count. 결과 count 는 101 로 방금 persist 한 행이 포함됐다. JPQL 은 DB 에서 실행되므로, 아직 메모리에만 있는 변경이 결과에 영향을 줄 수 있으면 Hibernate 가 쿼리 직전에 flush 한다(AUTO flush).13
비교로 persist 직후 em.find(Team.class, id) 는 insert 를 내보내지 않았다(10b). 이미 1차 캐시에 있는 엔티티는 DB 에 가지 않으므로 flush 할 이유가 없다. “persist 했는데 왜 insert 가 안 나가지?” 와 “조회만 했는데 왜 insert 가 나가지?” 는 같은 규칙의 양면이다.
8. 쓰기 배치 — jdbc.batch_size 와 IDENTITY 의 함정
사용사례. 대량 등록, 엑셀 업로드, 정산 결과 적재처럼 한 트랜잭션에서 수백~수천 건을 insert 하는 경우.
| ID 전략 | hibernate.jdbc.batch_size |
왕복 | 내역 |
|---|---|---|---|
| SEQUENCE | 0 (기본) | 513 | 시퀀스 12 + insert 501 |
| SEQUENCE | 50 | 23 | 시퀀스 12 + executeBatch 11 (addBatch 501) |
| IDENTITY | 0 | 500 | insert 500 |
| IDENTITY | 50 | 500 | insert 500 — 배치 없음 |
SEQUENCE 는 insert 전에 id 를 알 수 있으므로 50건씩 묶어 executeBatch 한 번으로 보낸다. 시퀀스 조회 12번은 Hibernate 기본 allocationSize=50 에 따른 pooled 최적화 덕분에 501건에 12번으로 끝났다.
IDENTITY 는 id 를 insert 를 실행해야 받을 수 있다. Hibernate 는 영속화 시점에 id 가 필요하므로 insert 를 그 자리에서 하나씩 실행할 수밖에 없다. 사용자 가이드는 이를 명시적으로 적고 있다. IDENTITY 생성기를 쓰면 “Hibernate 가 JDBC 수준의 insert 배치를 투명하게 끈다”.14 같은 가이드는 시퀀스를 지원하지 않는 DB(예: MySQL 5.7)에서만 IDENTITY 를 쓰라고 권한다.15 MySQL 에서 batch_size 를 켰는데 아무 변화가 없다면 이것이 원인이다.
관련 설정 메모:
hibernate.jdbc.batch_size는 기본적으로 꺼져 있다. 사용자 가이드는 10~50 사이 값을 권한다.14hibernate.order_inserts=true는 여러 엔티티 타입이 섞여 있을 때 같은 타입끼리 모아 배치가 끊기지 않게 한다(이 실험에서는 켜 둠).- 수천 건 이상이면 주기적으로
flush()+clear()를 해서 1차 캐시가 무한히 커지지 않게 해야 한다.14 아예 영속성 컨텍스트가 없는StatelessSession도 선택지다.10
정리 — 증상별 처방
| 증상 | 먼저 볼 것 | 처방 |
|---|---|---|
| 목록 API 쿼리가 행 수만큼 나간다 | 지연 로딩 위치 | join fetch / EntityGraph, 페이징이 있으면 batch fetch |
| 페이징이 느리고 메모리가 튄다 | 로그의 HHH90003004 |
컬렉션 fetch join 제거 + batch fetch, fail_on_pagination…=true |
MultipleBagFetchException |
List 컬렉션 2개 이상 fetch | 하나만 fetch join, 나머지 batch fetch |
| 조회 전용 화면이 무겁다 | 엔티티 전체 로딩 여부 | select new DTO 프로젝션 |
| 대량 수정이 건수만큼 update | 변경 감지 루프 | 벌크 JPQL + clearAutomatically |
| 벌크 후 화면 값이 안 바뀐다 | 1차 캐시 | 벌크를 트랜잭션 앞에, 또는 clear() |
batch_size 를 켰는데 insert 수 그대로 |
ID 전략 | IDENTITY → SEQUENCE(가능하면) |
한계
- H2 인메모리 한 종류에서만 쟀다. 왕복 횟수는 Hibernate 가 결정하므로 DB 에 크게 의존하지 않지만, IDENTITY·시퀀스 지원, 페이징 SQL 문법은 방언마다 다르다. MySQL·PostgreSQL 에서 다시 재지는 않았다.
- 지연 시간은 재지 않았다. “11 왕복이 1 왕복보다 몇 배 느리다”는 네트워크 RTT 와 행 수에 달려 있어 여기서 숫자로 말할 수 없다.
- 데이터가 작다. 팀 10 × 회원 10 이다. 카테시안 곱·인메모리 페이징의 메모리 피해는 규모가 커져야 드러나고, 이 실험은 SQL 모양과 왕복 수만 보여준다.
- 2차 캐시,
@BatchSize개별 지정, subselect fetch 는 다루지 않았다. - Hibernate 6 의 엔티티 결과 자동 중복 제거는 마이그레이션 가이드를 근거로 들었고, 7.2.7 에서 그 동작을 관찰했다. 5.x 와의 대조 실험은 하지 않았다.
관련 글
References
-
Hibernate ORM 7.2, An Introduction to Hibernate 7, §5.6 “Proxies and lazy fetching”. https://docs.jboss.org/hibernate/orm/7.2/introduction/html_single/Hibernate_Introduction.html ↩
-
Hibernate ORM 6.0 Migration Guide, “DISTINCT” 항목 — 엔티티 결과의 중복 제거가 Hibernate 쪽에서 자동 적용됨. https://docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html ↩
-
Spring Data JPA Reference, “Query Methods → Configuring Fetch- and LoadGraphs” (
@EntityGraph). https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html ↩ -
같은 문서, §8.4–8.6 “Association fetching”, “Batch fetching and subselect fetching”, “Join fetching”. 위 링크. ↩
-
Hibernate ORM 7.2 User Guide, §17.8.4 “join fetch for association fetching” — “Fetch joins should usually be avoided in limited or paged queries.” https://docs.jboss.org/hibernate/orm/7.2/userguide/html_single/Hibernate_User_Guide.html ↩
-
같은 가이드, Appendix A.16.5
hibernate.query.fail_on_pagination_over_collection_fetch(Since 5.2.13, Default false). 위 링크. ↩ -
같은 가이드, §3.9.11 “Bags” 및 컬렉션 시맨틱 표(BAG: 중복 허용, 순서 없음). 위 링크. ↩
-
같은 가이드, §17.8.4 — 여러 컬렉션을 병렬로 fetch 하면 DB 수준 카테시안 곱이 생긴다는 경고. 위 링크. ↩
-
같은 가이드, §17.9.1 “select new”. 위 링크. ↩
-
같은 문서, 대량 작업에서의
update문 사용 및StatelessSession설명. 위 링크. ↩ ↩2 -
같은 문서, “Modifying Queries” —
@Modifying(clearAutomatically = true)및EntityManager.clear()가 flush 되지 않은 변경을 버린다는 경고. 위 링크. ↩ -
같은 가이드, update 문은 기본적으로
@Version컬럼에 영향을 주지 않으며update versioned로 지정 가능. 위 링크. ↩ -
같은 가이드, §7.1.2 “AUTO flush on JPQL/HQL query”. 위 링크. ↩
-
같은 가이드, §13 “Batching” —
hibernate.jdbc.batch_size기본 비활성, 10~50 권장, “Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator”,flush()/clear()주기적 호출. 위 링크. ↩ ↩2 ↩3 -
같은 가이드, 식별자 생성기 성능 권고 — “the IDENTITY generators disables JDBC batching for INSERT statements”. 위 링크. ↩