조인과 서브쿼리를 어떠한 경우에 써야할까? 기능은 비슷한데.. 라고 생각하신 적 있으신가요?
오늘은 그 차이에 대해서 한번 알아보고 Mysql 8.0기준으로 한번 테스트도 진행해보겠습니다.
버전을 명시한 이유는 동욱님의 블로그를 읽고 버전마다 최적화 성능이 다르기 때문입니다. 점점 버전이 높아질수록 서브쿼리의 성능이 좋아지고 있다고 합니다.
Join과 Subquery는 언제 사용하고, 왜 사용하는지부터 생각해봅시다.
=> Join, SubQuery 모두 여러 테이블에서 데이터를 가져오기 위해 사용됩니다.
대체적으로는 Join 성능이 SubQuery의 성능보다 좋다고 알려져있지만 DB 버전이 점점 올라가며 SubQuery의 성능 또한 개선이 많이 되었다고 합니다.
어떠한 상황에서 SubQuery 혹은 Join을 써야 더 최적화된 성능을 발휘할 수 있을지에 대해서 알아보겠습니다.
우선 코드를 설명 및 확인하기 위해서 간단히 연관관계를 확인하겠습니다.
사용할 테이블은 managers, todos, users, comments입니다.
할일 조회 요청 | |
startDate | 시작날짜 |
endDate | 종료 날짜 |
managerName | 매니저 이름 |
title | 할일의 제목 |
할일 조회 응답 | |
title | 할일 제목 |
managersCount | 할일별 매니저 수 |
commentsCount | 할일별 댓글 수 |
Join
Join 사용법 in queryDsl
위 요청과 응답을 구분하여 queryDsl을 사용, Join을 이용해 작성해보겠습니다.
@Override
public Page<TodoSearchResponse> findTodos(TodoSearchRequest request, Pageable pageable) {
String title = "%" + request.getTitle().trim() + "%";
String managerName = "%" + request.getManagerName().trim() + "%";
LocalDate startDate = Optional.ofNullable(request.getStartDate())
.orElse(LocalDate.of(1, 1, 1));
LocalDate endDate = Optional.ofNullable(request.getEndDate())
.orElse(LocalDate.now());
BooleanExpression titleWhere = todo.title.like(title);
BooleanExpression managerNameWhere = manager.user.nickname.like(managerName);
BooleanExpression dateWhere =
todo.createdAt.between(startDate.atStartOfDay(), endDate.atTime(23,59,59));
List<TodoSearchResponse> todoList = jpaQueryFactory
.select(
Projections.constructor(TodoSearchResponse.class,
todo.title,
manager.id.countDistinct().coalesce(0L),
comment.id.countDistinct().coalesce(0L)
))
.from(todo)
.leftJoin(manager).on(todo.id.eq(manager.todo.id))
.leftJoin(manager.user, user)
.leftJoin(comment).on(todo.id.eq(comment.todo.id))
.where(titleWhere, managerNameWhere, dateWhere)
.groupBy(todo.id)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long totalCount = Optional.ofNullable(jpaQueryFactory
.select(todo.count())
.from(todo)
.leftJoin(manager).on(todo.id.eq(manager.todo.id))
.leftJoin(manager.user, user)
.where(titleWhere, managerNameWhere, dateWhere)
.fetchOne()).orElse(0L);
return new PageImpl<>(todoList, pageable, totalCount);
}
queryDsl의 장점 중 하나인 가독성을 위해서 BooleanExpression을 사용해서 분리했습니다.
보시면 manager, todo Join / manager, user Join / comment, todo Join 등 .. 벌써 몇개의 연관관계가 얽혀 조인이 걸려 있습니다.
코드는 예상했던 것과 동일하게 동작합니다만, 위험성이 존재합니다.
1. 복잡한 Join 및 Group By로 인한 성능 저하
- todo → managers → user, todo → comments 등 다단계 left join이 발생.
- groupBy(todo.id, todo.title)는 집계 쿼리 성능에 영향을 미침.
- 결과적으로, 테이블 수가 많아질수록 쿼리 실행 속도가 느려짐.
2. 카테시안 곱(Cartesian Product) 유발
- todo 하나에 여러 명의 manager, 여러 개의 comment가 존재할 경우, leftJoin으로 인해 조인 결과가 기하급수적으로 증가함.
- 예: 하나의 todo에 3명의 매니저와 4개의 댓글이 있으면 → 3 × 4 = 12개의 row 생성됨.
- 이로 인해 countDistinct()의 성능이 심각하게 저하될 수 있으며, 불필요한 중복 데이터 처리로 메모리 부담 증가.
3. countQuery와 contentQuery 간 중복된 Join
- countQuery에서도 leftJoin(todo.managers, manager), leftJoin(todo.comments, comment) 등이 반복.
- 쿼리 비용이 2배 이상 증가하며, 특히 countQuery에서는 대부분의 조인이 불필요할 수 있음.
4. manager.countDistinct()와 comment.countDistinct() 사용으로 인한 부하
- countDistinct는 DB 차원에서 중복 제거 연산이 포함되므로, 성능 부하가 크고 인덱스 사용도 제한적임.
- 특히 N:M 관계를 조인한 상태에서 countDistinct를 사용하는 경우, 앞서 언급한 카테시안 곱 문제와 결합되어 병목 유발.
5. 검색 조건이 많아질수록 인덱스를 타기 어려움
- user.nickname.containsIgnoreCase(...), title.containsIgnoreCase(...)는 부분 일치 검색으로, 일반 BTree 인덱스를 활용하기 어려움.
- 데이터 양이 많아질수록 풀스캔(Full Table Scan) 발생 가능성.
6. PageableExecutionUtils.getPage(...) 사용 시 주의
- 내부적으로 countQuery::fetchOne이 호출되며, 복잡한 조인 구조에서 불필요하게 무거운 count 쿼리까지 실행될 수 있음.
- 이는 전체 성능에 큰 영향을 줄 수 있으며, 특히 빈번한 페이지 요청이 많은 경우 심각한 병목 발생 가능.
따라서 위 코드를 SubQuery로 변경을 해보겠습니다. 모든 상황에서 Join보다 서브쿼리가 좋다는 것이 아닌 특정 상황에서 본인이 고려하여 선택을 해야한다고 생각합니다.
물론 아예 저런 고민을 피하기 위해서라면 네트워크 호출을 추가로 하더라도 아예 분리하여 호출을 하는 방법도 있긴 할 겁니다.
이번에는 서브쿼리로만 바꿔서 동일하게 동작하는지 확인해보겠습니다.
Join
SubQuery 사용법 in queryDsl
@Override
public Page<TodoSearchResponse> findTodos(TodoSearchRequest request, Pageable pageable) {
String title = "%" + request.getTitle().trim() + "%";
String managerName = "%" + request.getManagerName().trim() + "%";
LocalDate startDate = Optional.ofNullable(request.getStartDate())
.orElse(LocalDate.of(1, 1, 1));
LocalDate endDate = Optional.ofNullable(request.getEndDate())
.orElse(LocalDate.now());
BooleanBuilder builder = new BooleanBuilder();
if(!request.getTitle().trim().isEmpty()){
builder.and(todo.title.like(title));
}
builder.and(todo.createdAt.between(startDate.atStartOfDay(), endDate.atTime(23,59,59)));
if(!request.getManagerName().trim().isEmpty()) {
builder.and(todo.id.in(
JPAExpressions.select(manager.todo.id).from(manager)
.join(manager.user, user)
.where(user.nickname.like(managerName))
));
}
List<TodoSearchResponse> todoList = jpaQueryFactory
.select(
Projections.constructor(TodoSearchResponse.class,
todo.title,
JPAExpressions.select(manager.id.countDistinct()).from(manager).where(manager.todo.id.eq(todo.id)),
JPAExpressions.select(comment.id.countDistinct()).from(comment).where(comment.todo.id.eq(todo.id))
))
.from(todo)
.where(builder)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long totalCount = Optional.ofNullable(jpaQueryFactory
.select(todo.count())
.from(todo)
.where(builder)
.fetchOne()).orElse(0L);
return new PageImpl<>(todoList, pageable, totalCount);
}
BooleanBuilder을 Expression 대신 사용한 이유는 크게 없습니다.
리팩토링 하다보니 중간중간 조건이 필요할 것 같아서 조금 추가되었는데, 그부분은 원래의 join코드에도 있다고 생각해주시면 감사하겠습니다. 핵심은 결국 todoList를 구하는 select 쿼리에서 내부에 서브쿼리로 작성을 했다는 점입니다.
간단한 조회문에서 실제 속도로만 따진다면 Join이 유리할 것입니다. 하지만 여러 테이블을 Join 하고 그 테이블이 점차 많아질 수 있다면 유지보수 측면에서도 SubQuery를 쓰는 것도 괜찮은 선택일 것 같다고 생각합니다.
또한 특정 상황에서는 서브쿼리를 조인으로 변환할 수 없는 상황도 존재하기 때문에, 본인의 상황에 맞게 잘 선택하여 Join / SubQuery를 구분하여 사용하시면 좋겠습니다.
감사합니다.
참고1 : https://jojoldu.tistory.com/520
MySQL where in (서브쿼리) vs 조인 조회 성능 비교 (5.5 vs 5.6)
MySQL 5.5에서 5.6으로 업데이트가 되면서 서브쿼리(Subquery) 성능 개선이 많이 이루어졌습니다. 이번 시간에는 MySQL 2개의 버전 (5.5, 5.6) 에서 서브쿼리를 통한 조회 (Select)와 Join에서의 조회간의 성능
jojoldu.tistory.com
[MYSQL] 📚 JOIN과 서브쿼리 차이 및 변환 💯 정리
조인(JOIN) vs 서브쿼리(Sub Query) 조인과 서브쿼리는 때로 동일한 결과를 얻을 수 있다. 상황에 따라 조인을 사용하는 것이 훨씬 좋을 때도 있고, 반면에 서브 쿼리를 사용하는 것이 좋을 때도 있다.
inpa.tistory.com
'SpringBoot' 카테고리의 다른 글
Elasticsearch 성능 개선 비교와 사용방법 In SpringBoot (4) | 2025.07.31 |
---|---|
SpringBoot 대용량 데이터 조회 (Index) (1) | 2025.07.03 |
AOP 탐구 (0) | 2025.07.01 |
JPA 영속성 파헤치기 (0) | 2025.06.28 |
SpringEvent / EDA (2) | 2025.06.24 |