플러스 과제 트러블 슈팅

김신영·2025년 7월 4일

    @Override
    public Page<TodoQueryDslResponse> searchTodos(
            String title, String nickname,
            LocalDateTime start, LocalDateTime end, Pageable pageable) {

        List<TodoQueryDslResponse> content = queryFactory
                .select(new QTodoQueryDslResponse(
                        todo.title,
                        manager.id.countDistinct(),
                        select(comment.id.count())
                                .from(comment)
                                .where(comment.todo.eq(todo))
                ))
                .from(todo)
                .leftJoin(todo.managers, manager) //담당자
                .leftJoin(manager.user, user) //담당자의 user (닉네임)
                .where(
                        titleContains(title),
                        managerNicknameContains(nickname),
                        createdAfter(start),
                        createdBefore(end)
                )
                .groupBy(todo.id, todo.title)
                .orderBy(todo.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();


        //countQuery: 닉네임 검색이 없으면 JOIN 생략 가능
        JPAQuery<Long> countBase = queryFactory
                .select(todo.id.countDistinct())
                .from(todo);


                if (StringUtils.hasText(nickname)) {
                    countBase.leftJoin(todo.managers, manager)
                            .leftJoin(manager.user, user);
                }

        Long total = countBase
                .where(
                        titleContains(title),
                        managerNicknameContains(nickname),
                        createdAfter(start),
                        createdBefore(end)
                )
                .fetchOne();

        return new PageImpl<>(content, pageable, total == null ? 0 : total);

    }


    //동적 조건 메서드
    private BooleanExpression titleContains(String t) {
        return (t == null || t.isBlank()) ? null : todo.title.containsIgnoreCase(t);
    }

    private BooleanExpression managerNicknameContains(String n) {
        return StringUtils.hasText(n) ? user.nickname.containsIgnoreCase(n) : null;
    }

    private BooleanExpression createdAfter(LocalDateTime from) {
        return from == null ? null : todo.createdAt.goe(from);
    }

    private BooleanExpression createdBefore(LocalDateTime to) {
        return to == null ? null : todo.createdAt.loe(to);
    }

동적 JOIN

카운트 쿼리에서 닉네임 조건이 없을 땐 JOIN을 건너뛰어 성능 개선했습니다.

LEFT VS INNER JOIN

처음엔 todo가 생성될 때 manager가 본인으로 지정되기 때문에 manager를 innerjoin처리, user또한 innerjoin처리 해줬었습니다.
그런데 가만히 생각해보니 로직이 변경될 가능성이나, manager를 맡은 user가 탈퇴했을 때 null값이 들어갈 가능성이 있다고 판단하여 leftjoin을 사용해주었습니다.

manager.user.nickname

처음에는 todo.user.nickname으로 닉네임을 조회했으나 담당자 닉네임 검색을 요구했으니 manager.user.nickname을 조회하도록 고쳤습니다.

댓글 서브쿼리

처음에는 LEFT JOIN managers m LEFT JOIN comments c를 한 뒤 GROUP BY로 묶어주어 COUNT(DISTINCT)처리해줬었습니다.
이 때 옵티마이저가 두 테이블을 동시에 조인해야 해서 메모리/정렬/네트워크 비용이 커지는 문제가 있었습니다.

한 Todo에 담당자·댓글이 많아지면 manager × comment 조인으로 결과 row 수가 커질 수 있음을 판단해
댓글 수를 서브쿼리로 가져오도록 했습니다.
담당자 조인은 닉네임 조건, 담당자 수 집계 등에 필요하다고 판단해 그대로 조인했고
댓글은 1:N이 더 커질 확률이 높아 서브쿼리로 빼주었습니다.

QDto 프로젝션

Querydsl은 문자열 JPQL 대비 Q클래스와 자바 체이닝으로 인해 컴파일 타임에 필드 오타나 타입 불일치를 잡아줄 수 있다는 장점이 있습니다.
일관성을 유지하기 위해, 프로젝션 또한 컴파일 타입에서 검증 가능한 QDto 생성자 프로젝션을 사용했습니다.
엔티티를 직접 조회하지 않고 필요한 필드만 projection하여 N+1 문제를 우회할 수 있었고, 네트워크 페이로드와 메모리 사용량도 줄일 수 있었습니다.

0개의 댓글