과제 간 트러블 슈팅(2)

제이 용·2025년 12월 23일

궁금궁금(feat.QueryDsl)

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 쿼리 → 개수 계산

    • JPA & QueryDSL에서 권장되는 패턴

왜 fetchOne()을 쓰는가?

.select(todo.id.count())
  • count()는 결과가 항상 1행

  • 리스트가 아니라 단일 값

    • 그래서:
fetch()(List<Long>)

fetchOne()(Long)

왜 keyword, start, end 조건을 똑같이 넣어야 할까?

“검색 조건이 다르면 total이 의미 없어짐”

  • 목록 조회 결과: 5개

  • total: 전체 100개 (이러면 안됨)

목록 쿼리와 count 쿼리는 조건이 반드시 동일해야 함!!


요약

페이징 처리에서는 현재 페이지의 데이터 조회와

전체 데이터 개수 조회를 분리해야 하며,

이를 위해 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 쿼리: 단순한 구조 → 전체 개수 계산용

페이징에서 정확한 total 값을 얻기 위한 필수 구조


왜 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은 성능 저하만 유발

의도적으로 단순한 count 쿼리 작성 = 성능 최적화

요약

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는 기술적 관심사(로깅) 담당

즉, 역할이 명확하게 분리되어 있음

이 구조가 “맞는 이유”를 한 줄씩 뜯어보면

책임 분리가 명확하다 (SRP)

  • 서비스 : 책임
  • ManagerService : 매니저 등록 비즈니스 규칙
  • LogService : 요청 기록을 DB에 안전하게 남김

ManagerService가 로그 테이블 직접 만지면 책임이 섞임


트랜잭션 경계를 분리하기 위함

  • 핵심 목적은 이거 👇

“매니저 등록이 실패해도 로그는 반드시 저장”

  • 이건
@Transactional(REQUIRES_NEW)
  • 메서드 단위 트랜잭션 분리가 필요하고,

  • 클래스 안에서는 불가능

  • Spring AOP 프록시 특성

그래서 별도 서비스로 분리 + DI 가 올바른 구조.

중요한 포인트 (많이 놓친다고함.)

@Transactional(propagation = REQUIRES_NEW)
public void saveLog() { ... }
  • 이걸 같은 서비스 클래스에서 호출하면 적용 안 됨

  • 왜냐하면:

    • Spring의 @Transactional은 프록시 기반

    • 자기 자신 메서드 호출(self-invocation)은 프록시를 안 탐

그래서 무조건 다른 Bean이어야 함

LogService 분리는 필수


생각보다 실무에서 흔한 패턴이라고 한다!(feat.튜터님!)

실무 예시들:

OrderServicePaymentLogService

UserServiceLoginHistoryService

ReservationServiceAuditLogService

“비즈니스 서비스 → 로그/이력 서비스”

매우 정상적인 구조


그럼 언제 서비스 → 서비스 DI가 안 좋을까?

  • 피해야 할 경우 체크리스트

    • 서로가 서로를 호출한다 (순환 참조)

    • A 서비스가 B 서비스의 도메인 규칙을 대신 처리

    • “편해서” 그냥 갖다 쓴 경우

    • 공통 로직이라고 다 서비스로 빼버린 경우


요약

이 구조는 비즈니스 로직과 기술적 관심사를 분리하고,

트랜잭션 전파 옵션(REQUIRES_NEW)을 적용하기 위한 의도적인 서비스 분리로

Spring AOP 특성을 고려한 올바른 설계였다!

요약2

서비스 간 DI는 무조건 나쁜 설계가 아니라,

책임이 명확히 분리되고 트랜잭션/기술적 관심사를 분리하기 위한 경우에는 권장되는 구조다.

특히 @Transactional(REQUIRES_NEW)는 자기 자신 호출로는 동작하지 않기 때문에

별도의 서비스 분리가 필수적이다.


과제를 하면서 딥한 지식들이 점점 늘어난다. 재밌으면서도 항상 부족한 느낌을 받아서 아쉽기도 하다..(;´д`)ゞ

0개의 댓글