N + 1 문제 문제를 넘어서, fetch join 문제로 해결할려고 했지만,
JPA 컬렉션 필드를 Pagination할 때, fetch join으로 채택하는 순간
⚠️ 뻥튀기 되는 문제(MultipleBagFetchException) 즉, 중복문제!가 발생한다!
결국엔 Batch Size로 해결법으로 귀결!
@Override
public Page<Board> findBoardPage(BoardRequestDto pageRequestDto) {
Pageable pageable = pageRequestDto.getPageable();
OrderSpecifier[] orderSpecifiers = createOrderSpecifier(pageRequestDto.getOrderCondition());
List<Board> boardList = queryFactory
.selectFrom(board)
.leftJoin(board.categoryBridges, categoryBridge)
.where(getSearch(pageRequestDto)
// , board.id.in(
// JPAExpressions
// .select(categoryBridge.board.id)
// .from(categoryBridge)
// .where(categoryBridge.category.id.in(pageRequestDto.getCategoryIdList()))
// )
)
.groupBy(board.id)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(orderSpecifiers)
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(board.count())
.from(board)
.leftJoin(board.categoryBridges, categoryBridge)
.where(getSearch(pageRequestDto)
// , board.id.in(
// JPAExpressions
// .select(categoryBridge.board.id)
// .from(categoryBridge)
// .where(categoryBridge.category.id.in(pageRequestDto.getCategoryIdList()))
// )
)
// .groupBy(board.id)
;
return PageableExecutionUtils.getPage(boardList, pageable, countQuery::fetchOne);
}
@Override
public Page<Board> findBoardPage(BoardRequestDto pageRequestDto) {
Pageable pageable = pageRequestDto.getPageable();
OrderSpecifier[] orderSpecifiers = createOrderSpecifier(pageRequestDto.getOrderCondition());
List<Long> boardIdList = queryFactory
.select(board.id)
.from(board)
.leftJoin(board.tagBridges, tagBridge)
.where(eqTagIdList(pageRequestDto))
.groupBy(board.id)
.fetch();
log.info("boardIdList: {}", boardIdList);
List<Board> boardList = queryFactory
.selectFrom(board)
.where(
board.id.in(boardIdList),
getSearch(pageRequestDto)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(orderSpecifiers)
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(board.count())
.from(board)
.where(
board.id.in(boardIdList),
getSearch(pageRequestDto)
)
;
return PageableExecutionUtils.getPage(boardList, pageable, countQuery::fetchOne);
}
-> 안됨!
in 쿼리로 들어올 idList 들이 너무 많아지면 문제 생김!
다 안되는 얘기!
이걸로 해결!
hibernate.default_batch_fetch_size ,@BatchSize를 적용한다.application.yml
jpa:
open-in-view: true
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 # 위치 꼭 이곳!
dialect: org.hibernate.dialect.MySQL8Dialect
Entity
@BatchSize(size = 100)
@OneToMany(mappedBy = "board")
private List<TagBridge> tagBridges = new ArrayList<>();
ProjectRepositoryCustomImpl
@Override
public Page<Project> findPageBy(ProjectSearchRequest request, String userEmail) {
Pageable pageable = createPageable(request.getPage(), request.getSize(), request.getSort());
List<Project> projects = queryFactory
.selectFrom(project)
// // 필요한 연관관계만 fetch join (N+1 방지) -> Batch size 설정으로 대체, 만약 fetch join 사용 시 페이징 오류 발생
// .leftJoin(project.tasks)
// .leftJoin(project.labels)
// .leftJoin(project.userProjects)
.where(
notRemoved(), // ⭐ 프로젝트가 제거되지 않은 상태조건(필수)
containsSearchKeyword(request.getSearchKeyword()), // 검색어 필터
eqStatus(request.getStatus()), // 상태 필터
filterByUserAccess(userEmail) // 🔐 사용자별 프로젝트 접근 권한 필터
)
.orderBy(createCustomOrderSpecifier(request.getOrderCondition())) // 커스텀 정렬 조건
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
log.info("projects: {}", projects);
// 카운트 쿼리 (최적화된 단순 카운트)
Long total = queryFactory
.select(project.count())
.from(project)
// // 필요한 연관관계만 fetch join (N+1 방지)
// .leftJoin(project.tasks)
// .leftJoin(project.labels)
// .leftJoin(project.userProjects)
.where(
notRemoved(), // ⭐ 프로젝트가 제거되지 않은 상태조건(필수)
containsSearchKeyword(request.getSearchKeyword()),
eqStatus(request.getStatus()),
filterByUserAccess(userEmail) // 🔐 사용자별 프로젝트 접근 권한 필터
)
.fetchOne();
log.info("📊 프로젝트 페이지 조회 결과 - 총 {}개 프로젝트 조회됨", total);
return PageableExecutionUtils.getPage(projects, pageable, () -> total != null ? total : 0L);
}
해결된 사진
주테이블: project
컬렉션: tasks, labels, userProjects
주테이블 project_id IN 쿼리로 담아서 조회한다. size는 100개만큼!

default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다.
이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든
순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.