JPA를 쓰다 보면 처음에는 쿼리가 한 번만 나간 것처럼 보이다가, 컬렉션을 순회하는 순간 로그가 길게 늘어지는 경우가 있다. 예를 들어 부서 목록을 조회한 뒤 각 부서의 직원 수를 화면에 찍는 코드가 그렇다. 부서 10개를 가져왔고 department.getEmployees().size()를 10번 호출했을 뿐인데, 직원 컬렉션을 가져오는 SQL이 부서마다 한 번씩 실행된다.
이 패턴을 보통 N+1 문제라고 부른다. 첫 쿼리 1번으로 부모 엔티티 N개를 가져오고, 각 부모의 연관 데이터를 초기화하기 위해 N번의 추가 쿼리가 나가는 모양이다. Hibernate의 배치 페칭(batch fetching)은 이 N번의 추가 select를 없애지는 않지만, 여러 개를 한 번의 IN 조건으로 묶어서 왕복 횟수를 줄인다.
Hibernate ORM User Guide 12.8 Batch fetching은 @BatchSize를 사용하면 초기화되지 않은 엔티티 프록시나 컬렉션을 가져올 때 여러 개를 한 번의 데이터베이스 왕복으로 로딩할 수 있다고 설명한다. 이 글에서는 배치 페칭이 어떤 큐를 보고 대상을 묶는지, 왜 JOIN FETCH와 같은 해결책을 완전히 대체하지는 못하는지까지 정리했다.
배치 페칭은 "처음부터 join으로 모두 가져오는 전략"이 아니다. 기본 조회는 그대로 두고, 나중에 lazy association이나 entity proxy를 실제로 초기화해야 하는 순간에 동작하는 최적화다.
Hibernate 배치 페칭의 핵심은 이미 영속성 컨텍스트에 걸려 있는 미초기화 대상들을 모아
where id in (...)또는where foreign_key in (...)형태로 가져오는 것이다.
비슷한 이름 때문에 JDBC batching과 헷갈리기 쉽다. 두 개는 해결하는 문제가 다르다.
| 구분 | 대상 | 줄이는 것 | 대표 설정 |
|---|---|---|---|
| batch fetching | lazy 로딩 select | 조회 쿼리 왕복 횟수 | @BatchSize, hibernate.default_batch_fetch_size |
| JDBC batching | insert/update/delete | 쓰기 statement 전송 횟수 | hibernate.jdbc.batch_size |
따라서 hibernate.jdbc.batch_size를 올린다고 N+1 조회가 줄어들지는 않는다. 반대로 @BatchSize를 붙여도 insert 성능이 좋아지는 것은 아니다. 배치 페칭은 조회, 그중에서도 지연 로딩이 실제로 발생하는 시점의 select 전략이다.
Hibernate 공식 문서 기준으로 배치 페칭을 켜는 방법은 크게 두 가지다. 특정 엔티티나 컬렉션에 @BatchSize(size = n)을 붙일 수 있고, 전역 기본값으로 hibernate.default_batch_fetch_size를 둘 수 있다. User Guide의 fetch 관련 설정 설명에 따르면 기본적으로 Hibernate는 @BatchSize가 명시된 엔티티와 컬렉션에 대해서만 batch fetching을 사용한다.
예시 모델을 단순화하면 다음과 같다.
@Entity
class Department {
@Id
private Long id;
@OneToMany(mappedBy = "department")
@BatchSize(size = 5)
private List<Employee> employees = new ArrayList<>();
}
@Entity
class Employee {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
}
이 상태에서 부서 목록을 먼저 조회한다고 해보자.
List<Department> departments = entityManager.createQuery(
"select d from Department d", Department.class
).getResultList();
for (Department department : departments) {
// 첫 접근 순간 employees 컬렉션 초기화가 필요하다.
System.out.println(department.getEmployees().size());
}
배치 페칭이 없다면 흐름은 대략 이렇게 간다.
1. select d from department d
2. department[0].employees 접근 -> select * from employee where department_id = ?
3. department[1].employees 접근 -> select * from employee where department_id = ?
4. department[2].employees 접근 -> select * from employee where department_id = ?
...
@BatchSize(size = 5)가 있으면 첫 번째 컬렉션을 초기화하는 순간 Hibernate는 현재 세션 안에 있는 같은 역할의 미초기화 컬렉션을 함께 본다. 여기서 같은 역할이란 대략 Department.employees처럼 같은 매핑을 가진 컬렉션을 뜻한다. 그리고 최대 5개의 owner id를 골라 한 번에 조회한다.
select
e.department_id,
e.id,
e.name
from employee e
where e.department_id in (?, ?, ?, ?, ?)
부서가 10개이고 batch size가 5라면, 직원 컬렉션을 가져오는 쿼리는 10번이 아니라 2번으로 줄어든다. Hibernate User Guide의 예시도 같은 흐름을 보여준다. 여러 Department 엔티티와 연결된 Employee 컬렉션을 가져올 때 @BatchSize가 있으면 두 번의 SQL로 처리되고, 없으면 10번의 쿼리가 필요하다고 설명한다.
엔티티 프록시도 비슷하다. Employee.department가 lazy이고 여러 Employee가 이미 세션에 올라와 있다면, 첫 번째 department 프록시를 초기화할 때 다른 미초기화 department 프록시의 id도 함께 묶을 수 있다. Hibernate @BatchSize Javadocs는 이때 primary key 목록을 SQL IN 조건 안에 넣어 한 번의 round trip으로 여러 엔티티나 컬렉션을 가져올 수 있다고 설명한다.
즉, 배치 페칭의 대상은 "DB에 있는 모든 row"가 아니라 "현재 세션이 알고 있는 미초기화 프록시나 컬렉션"이다. 이 차이를 놓치면 batch size만 크게 잡으면 모든 N+1이 사라질 것처럼 오해하기 쉽다.
블로그 목록 화면을 예로 들어보자. Post 20개를 조회한 뒤 각 글의 댓글 수를 보여줘야 한다.
@Entity
class Post {
@Id
private Long id;
@OneToMany(mappedBy = "post")
@BatchSize(size = 10)
private List<Comment> comments = new ArrayList<>();
}
List<Post> posts = entityManager.createQuery(
"select p from Post p order by p.id desc", Post.class
).setMaxResults(20).getResultList();
for (Post post : posts) {
int count = post.getComments().size();
System.out.printf("post=%d comments=%d%n", post.getId(), count);
}
배치 페칭이 없다면 예상 쿼리는 Post 조회 1번 + 댓글 컬렉션 조회 20번이다. @BatchSize(size = 10)이 적용되면 댓글 컬렉션 조회는 대략 2번으로 줄어든다.
Post 20개 조회: 1번
Comment 컬렉션 초기화: ceil(20 / 10) = 2번
총 쿼리 수: 21번 -> 3번
물론 실제 쿼리 수는 이미 초기화된 컬렉션이 있는지, 세션에 어떤 엔티티가 올라와 있는지, 접근 순서가 어떤지에 따라 달라질 수 있다. 그래서 배치 페칭은 "항상 정확히 N / size번으로 줄어든다"라기보다, 같은 세션 안에서 함께 초기화할 수 있는 대상이 있을 때 그만큼 묶는 전략으로 이해하는 편이 안전하다.
여기서 중요한 점은 결과 row의 양은 줄지 않는다는 것이다. 댓글 200개를 화면에 모두 써야 한다면 결국 200개 row는 가져와야 한다. 배치 페칭은 row 수를 줄이는 기술이 아니라, 여러 번 나누어 가져오던 것을 덜 잘게 나누는 기술이다.
N+1을 줄이는 대표 방법으로 JOIN FETCH도 자주 쓴다.
select distinct p
from Post p
left join fetch p.comments
where p.id in :ids
JOIN FETCH는 필요한 연관 데이터를 처음 쿼리에서 같이 가져온다. 반면 배치 페칭은 처음에는 부모만 가져오고, 나중에 lazy association을 건드리는 순간 여러 association을 묶어 가져온다. 그래서 둘은 장단점이 다르다.
| 전략 | 장점 | 주의할 점 |
|---|---|---|
| JOIN FETCH | 필요한 데이터를 한 쿼리로 가져오기 쉽다 | 컬렉션 join 시 row가 곱해지고 pagination과 충돌하기 쉽다 |
| batch fetching | 기존 lazy 모델을 크게 바꾸지 않고 N+1 왕복을 줄인다 | 첫 접근 시점에 추가 select가 나가며, 쿼리 수가 1번으로 고정되지는 않는다 |
| DTO projection | 화면에 필요한 컬럼만 명확히 가져온다 | 변경 감지 대상 엔티티가 아니라 조회 모델로 다뤄야 한다 |
Hibernate User Guide도 @BatchSize가 N+1보다 낫지만, 대부분의 경우 필요한 데이터를 한 번에 가져올 수 있는 DTO projection이나 JOIN FETCH가 더 나은 대안일 수 있다고 덧붙인다. 이 문장은 배치 페칭을 "최종 해결책"이 아니라 "지연 로딩 구조 안에서 왕복을 줄이는 완충 장치"로 보라는 뜻에 가깝다고 이해했다.
개인적으로는 다음 기준으로 나눠 생각한다.
JOIN FETCH나 entity graph를 검토한다.첫째, batch size는 클수록 무조건 좋은 값이 아니다. size를 크게 잡으면 쿼리 횟수는 줄어들 수 있지만, 한 번의 IN 절이 길어지고 한 번에 가져오는 row도 늘어난다. 데이터베이스의 파라미터 제한, 실행 계획, 네트워크 payload, 애플리케이션 메모리를 같이 봐야 한다. 처음부터 500이나 1000 같은 값을 전역으로 두기보다, 실제 접근 패턴이 있는 association에 작은 값으로 시작하는 쪽이 보수적이다.
둘째, 전역 설정은 편하지만 영향 범위가 넓다.
hibernate.default_batch_fetch_size=32
이 설정은 여러 매핑에 공통 기본값을 줄 수 있어 편하다. 다만 어떤 연관관계에서 추가 select가 묶이는지 로그를 보고 확인해야 한다. 특정 컬렉션만 문제가 된다면 @BatchSize를 해당 컬렉션에 붙이는 방식이 의도를 더 분명히 드러낸다.
셋째, 배치 페칭은 세션 경계 안에서 의미가 있다. lazy association을 세션 밖에서 접근하면 배치로 묶을 기회가 없고, 일반적으로는 LazyInitializationException 같은 문제를 먼저 만나게 된다. Open Session in View에 의존해 화면 렌더링 중 lazy 로딩을 허용하는 구조라면, 쿼리가 어느 계층에서 발생하는지도 함께 점검해야 한다.
마지막으로 lock mode가 얽힌 경우에는 단순히 batch fetching이 될 것이라고 가정하면 안 된다. Hibernate User Guide는 LockModeType이 NONE이 아니면 Hibernate가 batch fetching을 실행하지 않아 미초기화 entity proxy가 초기화되지 않는다고 설명한다. 락을 잡는 조회와 일반 목록 조회는 쿼리 전략을 따로 확인하는 편이 좋다.
Hibernate 배치 페칭은 N+1 문제를 "한 쿼리"로 바꾸는 기능이 아니라, lazy 초기화 시점의 여러 select를 IN 조건 기반의 더 적은 select로 묶는 기능이다. 현재 세션에 올라와 있는 미초기화 프록시나 컬렉션이 묶음의 후보가 되고, @BatchSize나 hibernate.default_batch_fetch_size가 그 최대 크기를 정한다.
그래서 배치 페칭은 다음 문장으로 기억하면 좋겠다.
JOIN FETCH가 처음부터 같이 가져오는 전략이라면, batch fetching은 나중에 필요해진 것들을 혼자 보내지 않는 전략이다.
다음에 더 파고들 주제로는 FetchMode.SUBSELECT와 배치 페칭의 차이, 그리고 컬렉션 fetch join과 pagination이 충돌하는 이유가 있다. 둘 다 N+1을 피하려다 다른 비용을 만나는 지점이라 같이 공부할 만하다.
@BatchSize Javadocs