Long total = queryFactory .select(todo.id.count()) .from(todo) .where( todo.title.contains(keyword), todo.createdAt.between(start, end) ) .fetchOne();
왜 이 코드가 추가로 필요할까?
페이징 응답에는 보통 이 정보들이 필요합니다
현재 페이지의 데이터 목록 (content)
전체 데이터 개수 (totalElements)
전체 페이지 수 (totalPages)
현재 페이지 번호
그런데 !!!
limit, offset이 들어간 조회 쿼리로는 전체 개수를 알 수 없다.
목록 조회 쿼리만 있으면 생기는 문제
List<TodoSearchResponse> content = queryFactory
.select(...)
.from(todo)
.where(...)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
“현재 페이지 데이터”만 조회
DB에는 총 몇 건이 있는지 모름!!
프론트 입장에서는
그래서 count 쿼리가 필요함
select count(todo.id) from todo ...
페이징 조건 ❌ (limit/offset 없음)
검색 조건만 동일하게 적용
전체 검색 결과 개수만 조회
PageImpl<>(content, pageable, total)
왜 목록 조회랑 쿼리를 분리했을까?
count는 필요한 컬럼이 id 하나뿐
projection / join / orderBy 필요 없음
조회 쿼리 → 데이터 가져오기
count 쿼리 → 개수 계산
왜 fetchOne()을 쓰는가?
.select(todo.id.count())
count()는 결과가 항상 1행
리스트가 아니라 단일 값
fetch() ❌ (List<Long>)
fetchOne() ✅ (Long)
왜 keyword, start, end 조건을 똑같이 넣어야 할까?
목록 조회 결과: 5개
total: 전체 100개 (이러면 안됨)
요약
페이징 처리에서는 현재 페이지의 데이터 조회와
전체 데이터 개수 조회를 분리해야 하며,
이를 위해 QueryDSL에서 count 전용 쿼리를 추가로 작성한다.
// 실제 목록 조회 쿼리 (페이징 대상)
List<TodoSearchResponse> content = queryFactory
// TodoSearchResponse DTO로 바로 매핑하기 위해 Projections.constructor 사용
// 엔티티 전체가 아니라 "필요한 필드만" 조회해서 성능 최적화
.select(Projections.constructor(
TodoSearchResponse.class,
// 일정 제목
todo.title,
// 해당 일정의 담당자 수
// JOIN으로 인해 중복 row가 생길 수 있으므로 countDistinct 사용
manager.id.countDistinct(),
// 해당 일정의 댓글 수
// 댓글도 JOIN으로 중복될 수 있으므로 countDistinct 사용
comment.id.countDistinct()
))
// 기준 테이블은 todo
.from(todo)
// 일정과 담당자 관계 LEFT JOIN
// 담당자가 없는 일정도 검색 결과에 포함시키기 위해 LEFT JOIN
.leftJoin(todo.managers, manager)
// 담당자와 유저 JOIN (닉네임 검색을 위함)
.leftJoin(manager.user, user)
// 일정과 댓글 LEFT JOIN
// 댓글이 없는 일정도 검색 결과에 포함
.leftJoin(todo.comments, comment)
// 검색 조건
.where(
// 제목 키워드 부분 일치 검색
todo.title.contains(keyword),
// 담당자 닉네임 부분 일치 검색
manager.user.nickName.contains(managerNickname),
// 일정 생성일 범위 검색
todo.createdAt.between(start, end)
)
// todo 기준으로 집계해야 하므로 groupBy 필수
// count(), countDistinct()를 사용했기 때문
.groupBy(todo.id)
// 최신 일정이 먼저 나오도록 정렬
.orderBy(todo.createdAt.desc())
// 한 페이지에 가져올 데이터 개수 제한
.limit(pageable.getPageSize())
// 실제 데이터 조회
.fetch();
// 전체 데이터 개수 조회 쿼리 (count 쿼리)
Long count = queryFactory
// 전체 검색 결과 개수를 구하기 위한 count 쿼리
.select(todo.id.count())
// 기준 테이블은 동일하게 todo
.from(todo)
// 목록 조회와 "동일한 검색 조건"을 사용해야 함
// 그래야 페이지 수(totalPages)가 정확해짐
.where(
todo.title.contains(keyword),
todo.createdAt.between(start, end)
)
// count는 항상 단일 결과이므로 fetchOne 사용
.fetchOne();
이 코드에서 꼭 이해해야 할 핵심 포인트
목록 조회 쿼리와 count 쿼리를 분리한 이유
목록 조회: limit, groupBy, join → 데이터 표현용
count 쿼리: 단순한 구조 → 전체 개수 계산용
왜 countDistinct를 쓰는가?
manager.id.countDistinct()
comment.id.countDistinct()
JOIN이 들어가면 하나의 todo가 여러 row로 늘어남
그냥 count() 쓰면 중복 카운트 발생
countDistinct()로 실제 개수만 집계
왜 groupBy(todo.id)가 필요한가?
count / countDistinct는 집계 함수
집계 대상이 todo 기준이므로 groupBy(todo.id) 필수
없으면 SQL 에러 발생
왜 count 쿼리에는 JOIN이 없는가?
전체 개수만 알면 됨
JOIN은 성능 저하만 유발
요약
QueryDSL 페이징에서는
목록 조회 쿼리와 전체 개수(count) 쿼리를 분리하고,
목록 조회는 Projection + groupBy로 최적화하며
count 쿼리는 조건만 동일하게 유지한 채 최대한 단순하게 작성한다.
package org.example.expert.domain.todo.searchRepository;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.expert.domain.todo.dto.response.TodoSearchResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.time.LocalDateTime;
import java.util.List;
import static org.example.expert.domain.comment.entity.QComment.comment;
import static org.example.expert.domain.manager.entity.QManager.manager;
import static org.example.expert.domain.todo.entity.QTodo.todo;
import static org.example.expert.domain.user.entity.QUser.user;
@RequiredArgsConstructor
public class SearchRepositoryImpl implements SearchRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<TodoSearchResponse> search(
String keyword,
String managerNickname,
LocalDateTime start,
LocalDateTime end,
Pageable pageable
) {
List<TodoSearchResponse> content = queryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
manager.id.countDistinct(),
comment.id.countDistinct()
))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
.leftJoin(todo.comments, comment)
.where(
todo.title.contains(keyword),
manager.user.nickName.contains(managerNickname),
todo.createdAt.between(start, end)
)
.groupBy(todo.id)
.orderBy(todo.createdAt.desc())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(todo.id.count())
.from(todo)
.where(
todo.title.contains(keyword),
todo.createdAt.between(start, end)
)
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0 : total);
}
“서비스 → 서비스 DI”는 별로인가?(feat.Transactional의 옵션)
보통 사람들이 꺼리는 이유:
❌ 안 좋은 경우
서비스끼리 순환 의존성
한 서비스가 다른 서비스의 비즈니스 로직을 대신 수행
ServiceA -> ServiceB
ServiceB -> ServiceC
ServiceC -> ServiceA ❌
괜찮은 경우는?
핵심 차이
ManagerService는 비즈니스 로직 담당
LogService는 기술적 관심사(로깅) 담당
이 구조가 “맞는 이유”를 한 줄씩 뜯어보면
트랜잭션 경계를 분리하기 위함
@Transactional(REQUIRES_NEW)
메서드 단위 트랜잭션 분리가 필요하고,
클래스 안에서는 불가능
Spring AOP 프록시 특성
@Transactional(propagation = REQUIRES_NEW)
public void saveLog() { ... }
이걸 같은 서비스 클래스에서 호출하면 적용 안 됨
왜냐하면:
Spring의 @Transactional은 프록시 기반
자기 자신 메서드 호출(self-invocation)은 프록시를 안 탐
생각보다 실무에서 흔한 패턴이라고 한다!(feat.튜터님!)
실무 예시들:
OrderService → PaymentLogService
UserService → LoginHistoryService
ReservationService → AuditLogService
그럼 언제 서비스 → 서비스 DI가 안 좋을까?
피해야 할 경우 체크리스트
서로가 서로를 호출한다 (순환 참조)
A 서비스가 B 서비스의 도메인 규칙을 대신 처리
“편해서” 그냥 갖다 쓴 경우
공통 로직이라고 다 서비스로 빼버린 경우
요약
이 구조는 비즈니스 로직과 기술적 관심사를 분리하고,
트랜잭션 전파 옵션(REQUIRES_NEW)을 적용하기 위한 의도적인 서비스 분리로
Spring AOP 특성을 고려한 올바른 설계였다!
요약2
서비스 간 DI는 무조건 나쁜 설계가 아니라,
책임이 명확히 분리되고 트랜잭션/기술적 관심사를 분리하기 위한 경우에는 권장되는 구조다.
특히 @Transactional(REQUIRES_NEW)는 자기 자신 호출로는 동작하지 않기 때문에
별도의 서비스 분리가 필수적이다.