CODING/스파르타 내일배움캠프 TIL

54_Spring 플러스 주차 개인 과제 트러블슈팅_25.3.14(금)

codingTrip 2025. 3. 15. 00:50

Spring 플러스 주차 개인 과제 트러블슈팅

도전

Lv 3-10. QueryDSL 사용하여 검색 기능 만들기

하나를 해결하면 하나가 문제가 되는 Error Chain...시간이었다.

error: incompatible types: StringPath cannot be converted to String
    @QueryProjection
    public TodoSearchResponse(String title, Long managerCount, Long commentCount) {
        this.title = title;
        this.managerCount = managerCount;
        this.commentCount = commentCount;
    }

=> dto에 @QueryProjections 넣고 CompileJava를 실행해서 Q클래스 만들어주었다.

 


content와 total 부분 개념 적립

    @Override
    public Page<TodoSearchResponse> search(TodoSearchCondition condition, Pageable pageable) {
        List<TodoSearchResponse> content = getTodoSearchResponse(condition, pageable);
        Long total = getTotal(condition);

        return PageableExecutionUtils.getPage(content, pageable, total::longValue);
    }

위의 코드에서 total은 조건을 만족하는 전체 조회 건수이고

이를 통해서 페이지네이션이 구현된다.

그런데 나는 이 부분을

  • 해당 일정의 담당자 수를 넣어주세요.
  • 해당 일정의 총 댓글 개수를 넣어주세요.

이 조건과 같이 섞어서 생각하면서 시행착오를 겪었다.


return PageableExecutionUtils.getPage(content, pageable, total::longValue);

이 부분은 트러블 슈팅은 아니고 단순히 궁금해서 찾아본 점을 적어본다.

PageImpl과 다른 점이 궁금했는데

위의 메서드가 더 성능 최적화에 좋다고 한다.

 

LongSupplier타입과 Long 타입 아니라는 부분

=> total::longValue를 사용하며 해결했다.


java.lang.IllegalArgumentException: manager.todo is not a root path

2025-03-14T17:39:47.639+09:00 ERROR 9831 --- [expert] [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: manager.todo is not a root path] with root cause
.from(todo)
.leftJoin(todo, manager.todo).fetchJoin()
.leftJoin(todo, comment.todo).fetchJoin()

원인은 조인 시 왼쪽에는 조인 대상, 오른쪽에는 alias가 와야 하는데 그렇지 못해서 였다.

.from(todo)
.leftJoin(todo.managers, manager).fetchJoin()
.leftJoin(todo.comments, comment).fetchJoin()

위와 같이 수정하니 잘 되었다.


org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmListJoin(org.example.expert.domain.todo.entity.Todo(todo).managers(manager))]

2025-03-14T17:50:33.751+09:00 ERROR 10198 --- [expert] [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmListJoin(org.example.expert.domain.todo.entity.Todo(todo).managers(manager))]] with root cause

원인은 엔티티가 아닌 dto 상태에서 fetch join한 것이 문제였다.

.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(todo.comments, comment)

java.sql.SQLSyntaxErrorException: In aggregated query without GROUP BY, expression #1 of SELECT list contains nonaggregated column 'expert.t1_0.title'; this is incompatible with sql_mode=only_full_group_by

2025-03-14T18:00:29.369+09:00  WARN 10291 --- [expert] [nio-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1140, SQLState: 42000
2025-03-14T18:00:29.372+09:00 ERROR 10291 --- [expert] [nio-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper   : In aggregated query without GROUP BY, expression #1 of SELECT list contains nonaggregated column 'expert.t1_0.title'; this is incompatible with sql_mode=only_full_group_by
2025-03-14T18:00:29.378+09:00 ERROR 10291 --- [expert] [nio-8080-exec-7] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL
[/* select todo.title, count(manager), count(comment)
from Todo todo
left join todo.managers as manager
left join todo.comments as comment
order by todo.createdAt desc
*/ select t1_0.title,count(m1_0.id),count(c1_0.id)
from todos t1_0 l
eft join managers m1_0 on t1_0.id=m1_0.todo_id l
eft join comments c1_0 on t1_0.id=c1_0.todo_id
order by t1_0.created_at desc limit ?,?]
[In aggregated query without GROUP BY, expression #1 of SELECT list contains nonaggregated column 'expert.t1_0.title'; this is incompatible with sql_mode=only_full_group_by] [n/a]; SQL [n/a]] with root cause

count 쿼리인데 group by를 쓰지 않은 점이 문제가 되었다.

    private List<TodoSearchResponse> getTodoSearchResponse(TodoSearchCondition condition, Pageable pageable) {
        List<TodoSearchResponse> content = queryFactory
                .select(new QTodoSearchResponse(
                        todo.title,
                        manager.count(),
                        comment.count()
                ))
                .from(todo)
                .leftJoin(todo.managers, manager)
                .leftJoin(todo.comments, comment)
                .where(
                        titleContains(condition.getTitle()),
                        createdAtGoe(condition.getCreatedAtGoe()),
                        createdAtLoe(condition.getCreatedAtLoe()),
                        managerNicknameContains(condition.getManagerNickname())
                )
                .orderBy(todo.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        return content;
    }

    private Long getTotal(TodoSearchCondition condition) {
        Long total = queryFactory
                .select(todo.count())
                .from(todo)
                .leftJoin(todo.managers, manager)
                .leftJoin(todo.comments, comment)
                .where(
                        titleContains(condition.getTitle()),
                        createdAtGoe(condition.getCreatedAtGoe()),
                        createdAtLoe(condition.getCreatedAtLoe()),
                        managerNicknameContains(condition.getManagerNickname())
                )
                .fetchOne();

        return total;
    }

따라서 아래와 같이 서브 쿼리로 해당하는 조건의 개수를 불러왔다.

.select(new QTodoSearchResponse(
                        todo.title,
                        JPAExpressions.select(manager.count()).from(manager).where(manager.todo.eq(todo)),
                        JPAExpressions.select(comment.count()).from(comment).where(comment.todo.eq(todo))
                ))

where절에 미리 설정했던 검색 조건이 작동하지 않는 상태

Hibernate: 
    /* select
        todo.title,
        (select
            count(manager) 
        from
            Manager manager 
        where
            manager.todo = todo),
        (select
            count(comment) 
        from
            Comment comment 
        where
            comment.todo = todo) 
    from
        Todo todo   
    left join
        todo.managers as manager   
    left join
        todo.comments as comment 
    order by
        todo.createdAt desc */ select
            t1_0.title,
            (select
                count(m2_0.id) 
            from
                managers m2_0 
            where
                m2_0.todo_id=t1_0.id),
            (select
                count(c2_0.id) 
            from
                comments c2_0 
            where
                c2_0.todo_id=t1_0.id) 
        from
            todos t1_0 
        left join
            managers m1_0 
                on t1_0.id=m1_0.todo_id 
        left join
            comments c1_0 
                on t1_0.id=c1_0.todo_id 
        order by
            t1_0.created_at desc 
        limit
            ?, ?
Hibernate: 
    /* select
        count(todo) 
    from
        Todo todo   
    left join
        todo.managers as manager   
    left join
        todo.comments as comment */ select
            count(t1_0.id) 
        from
            todos t1_0 
        left join
            managers m1_0 
                on t1_0.id=m1_0.todo_id 
        left join
            comments c1_0 
                on t1_0.id=c1_0.todo_id

where 조건이 전혀 보이지 않아서 당황했었다.

@Getter
@AllArgsConstructor
public class TodoSearchCondition {

    private String title;
    private LocalDateTime createdAtGoe;
    private LocalDateTime createdAtLoe;
    private String managerNickname;
}

request dto에 생성자를 추가하니 아래와 같이 조회가 되었다.

Hibernate: 
    /* select
        todo.title,
        (select
            count(manager) 
        from
            Manager manager 
        where
            manager.todo = todo),
        (select
            count(comment) 
        from
            Comment comment 
        where
            comment.todo = todo) 
    from
        Todo todo   
    left join
        todo.managers as manager   
    left join
        todo.comments as comment 
    where
        todo.title like ?1 escape '!' 
    order by
        todo.createdAt desc */ select
            t1_0.title,
            (select
                count(m2_0.id) 
            from
                managers m2_0 
            where
                m2_0.todo_id=t1_0.id),
            (select
                count(c2_0.id) 
            from
                comments c2_0 
            where
                c2_0.todo_id=t1_0.id) 
        from
            todos t1_0 
        left join
            managers m1_0 
                on t1_0.id=m1_0.todo_id 
        left join
            comments c1_0 
                on t1_0.id=c1_0.todo_id 
        where
            t1_0.title like ? escape '!' 
        order by
            t1_0.created_at desc 
        limit
            ?, ?
Hibernate: 
    /* select
        count(todo) 
    from
        Todo todo   
    left join
        todo.managers as manager   
    left join
        todo.comments as comment 
    where
        todo.title like ?1 escape '!' */ select
            count(t1_0.id) 
        from
            todos t1_0 
        left join
            managers m1_0 
                on t1_0.id=m1_0.todo_id 
        left join
            comments c1_0 
                on t1_0.id=c1_0.todo_id 
        where
            t1_0.title like ? escape '!'

한글로 제목을 검색하면 발생하는 문제

튜터님과 1시간 30분 정도 씨름했으나 나왔던 결론은 위와 같다...

신기했던 점은 똑같이 포스트맨에서 실행했는데(title=아)

계속 다르게 조회되었다는 점이었다.

전체 조회가 되기도 하고, 빈 배열이 나오기도 하고, 잘 조회가 되기도 했다.

혹시나 해서 영어로 제목 검색을 해보니 이 때에는 잘 조회가 되었다.

한글로 조회했을 때는 사이에 이상한 문자가 들어가기도 했다.(포스트맨에서 육안으로는 보이지 않는)

 

결론은 위와 같지만 그래도 튜터님과 같이 씨름하면서 얻게 된 팁들에 대해 적어보고자 한다.

 

1. 검색 시, 파라미터가 잘 바인딩 되어있는지 확인하기

콘솔에서 보면 파라미터가 모두 ?으로 되어 있어

내가 원하는 대로 값이 잘 매칭되고 있는지를 확인하기 어려웠다.

logging.level.org.hibernate.orm.jdbc.bind=TRACE

application.properties 파일에서 위와 같이 설정하고 나니

아래처럼 파라미터를 확인할 수 있었다.

2025-03-14T23:04:08.840+09:00 TRACE 14660 --- [expert] [nio-8080-exec-6] org.hibernate.orhttp://m.jdbc.bind : binding parameter (1:VARCHAR) <- [%j%]

이를 보았을 때는 쿼리 상의 문제는 없어 보였다.

 

 ❌ DI

@Configuration
public class JPAConfiguration {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

위처럼 config를 생성해서 bean을 등록하지 않아서 발생한 문제인가? 했으나 이도 아니었다.

 

@ModelAttribute

@ModelAttribute TodoSearchCondition condition

컨트롤러에서 ModelAttribute 설정을 하지 않았던 것이 문제가 되었나?고 했지만
(물론 다른 곳에서 문제가 될 수도 있지만)

일단 지금의 문제가 해결되지는 않았다.

 

pageable

@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size

컨트롤러 Pageable pageable을 직접 받았으나

위와 같이 수정해보았다.

그러나 직접적인 원인 해결은 되지 못했다.

 

2. 포스트맨 외 확인 방법

curl -X GET "https://example.com/api" -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json"

포스트맨과 말고도 터미널에서 위와 같은 형식으로 조회할 수 있다고 말씀해주셨다.

그러나 생각만큼 잘 조회가 되지 않고 400 에러 발생으로 다른 방법을 찾기로 했다.

 

3. 디버그 모드 실행

 

문제의 원인으로 보이는 코드를 break point 걸고 디버그 모드로 실행해서 확인한다.

콘솔 로그로 확인해보니

title이 "아"로 내가 검색한 그대로 받아질 때가 있고

""처럼 나올 때도 있었다.

계속 같은 조건으로 조회했다고 생각했는데도 계속 결과가 달라지니

정말로 내 눈을 믿을 수가 없었다.

 

=> 튜터님께서 문제를 해결하시는 그 흐름을 같이 느낄 수 있어서 재미있는 시간이었다.

내가 영속성 컨텍스트에 대해서 확실하게 개념이 적립된 상태가 아니라서

쿼리를 잘못 짜서

문제인가 짐작하고 갔으나 생각보다 그렇지 않았던 점이 신기했다.


같은 값이 중복

결과가 중복으로 나오는 문제가 발생했다.

이를 해결하고자 일정의 아이디로 group by해서 문제를 해결하고자 했으나 아래와 같은 오류가 발생했다.

2025-03-14T21:42:27.759+09:00 ERROR 13718 --- [expert] [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: cohttp://m.querydsl.core.NonUniqueResultException: jakarta.persistence.NonUniqueResultException: Query did not return a unique result: 3 results were returned] with root cause

사실... 좀 더 생각해보면 나오는 문제였다.

검색 조건과 정확히 값이 일치하는 결과가 아닌

like문을 사용했기 때문에(포함)

여러 일정들이 나올 것이고

그 일정 아이디로 그룹화를 해도 각 아이디별로 전체 개수가 나올 것이다.

 

    @Override
    public Page<TodoSearchResponse> search(TodoSearchCondition condition, Pageable pageable) {
        List<TodoSearchResponse> content = getTodoSearchResponse(condition, pageable);
        Long total = getTotal(condition);

        return PageableExecutionUtils.getPage(content, pageable, total::longValue);
    }

    private List<TodoSearchResponse> getTodoSearchResponse(TodoSearchCondition condition, Pageable pageable) {
        List<TodoSearchResponse> content = queryFactory
                .select(new QTodoSearchResponse(
                        todo.title,
                        JPAExpressions.select(manager.count()).from(manager).where(manager.todo.eq(todo)),
                        JPAExpressions.select(comment.count()).from(comment).where(comment.todo.eq(todo))
                ))
                .from(todo)
                .where(
                        titleContains(condition.getTitle()),
                        createdAtGoe(condition.getCreatedAtGoe()),
                        createdAtLoe(condition.getCreatedAtLoe()),
                        managerNicknameContains(condition.getManagerNickname())
                )
                .orderBy(todo.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        return content;
    }

    private Long getTotal(TodoSearchCondition condition) {
        Long total = queryFactory
                .select(todo.count())
                .from(todo)
                .where(
                        titleContains(condition.getTitle()),
                        createdAtGoe(condition.getCreatedAtGoe()),
                        createdAtLoe(condition.getCreatedAtLoe()),
                        managerNicknameContains(condition.getManagerNickname())
                )
                .fetchOne();

        return total;
    }

    private BooleanExpression titleContains(String title) {
        return hasText(title) ? todo.title.contains(title) : null;
    }

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

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

    private BooleanExpression managerNicknameContains(String managerNickname) {
        return hasText(managerNickname) ? manager.user.nickname.contains(managerNickname) : null;
    }

위는 현재 상태에서 최종적으로 작성한 버전이다.

이미 서브쿼리로 관리자 수와 댓글 수를 count하고 있기 때문에

left join이 필요가 없다고 판단했다.

위와 같이 하고 나니 중복 값은 발생하지 않았다.

 

물론 지금 상황도 완벽하다고 생각하지는 않다.

이 상태에서 월요일에 튜터님께 피드백을 받고자 한다.

 

사실 과제를 하다보면, 개발을 하다보면

예기치 못한 일들을 마주하게 된다.

원인이 정말 이해하기 어렵거나 예상치 못한 곳에서 발견하기도 한다.

이 과정들이 쉽지는 않지만 힘이 들지만

그래도 나름? 재미로 느껴지기에

이 분야를 진로로 진지하게 임하고 있는 것이다.