본문 바로가기
트러블 슈팅

QueryDsl에서 List<Dto> 방식과 List<Entiry> 방식

by jhdevtrace 2024. 10. 11.

과제 10. QueryDSL 을 사용하여 검색 기능 만들기 중.

 

저는 처음에 Dto에 담아서 가져올 수 있게 만들었습니다. 하지만 다른 사람들의 코드에서는 Entity를 받은 뒤, Dto에 변환하는 작업을 거치고 있었습니다. 그 이유를 찾아보니, 유연한 확장성을 위해서는 Entity로 받는 방식이 좋습니다.

 

그래서 바꿔봤습니다 !

 

처음 만든 코드 List<Dto> 방식

  @Override
    public Page<TodoSearchResponse> searchTodo(Pageable pageable, String title, LocalDateTime startDateTime, LocalDateTime endDateTime, String nickname){
        QTodo todo = QTodo.todo;
        QManager manager = QManager.manager;
        QComment comment = QComment.comment;
        QUser user = QUser.user;

        List<TodoSearchResponse> query = queryFactory
                .select(Projections.constructor(TodoSearchResponse.class,
                        todo.title,
                        manager.countDistinct(),
                        comment.countDistinct()))
                .distinct()
                .from(todo)
                .leftJoin(todo.managers, manager)
                .leftJoin(todo.comments, comment)
                .leftJoin(todo.user, user).fetchJoin()
                .offset(pageable.getOffset())
                .where(
                        titleContains(title),
                        userNicknameContains(nickname),
                        todoDateBetween(startDateTime, endDateTime)
                )
                .groupBy(todo.id)
                .orderBy(todo.createdAt.desc())
                .limit(pageable.getPageSize())
                .fetch();

        return new PageImpl<>(query, pageable, query.size());

    }

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

    private BooleanExpression userNicknameContains(String managerNickname) {
        return managerNickname != null ? user.nickname.contains(managerNickname) : null;
    }

    private BooleanExpression todoDateBetween(LocalDateTime startDate, LocalDateTime endDate) {
        return startDate != null && endDate != null ? todo.createdAt.between(startDate, endDate) : null;
    }

 

 

 

 

 List<Entity> 방식

   @Override
    public Page<TodoSearchResponse> searchTodo(Pageable pageable, String title, LocalDateTime startDateTime, LocalDateTime endDateTime, String nickname){
        QTodo todo = QTodo.todo;
        QManager manager = QManager.manager;
        QComment comment = QComment.comment;
        QUser user = QUser.user;

        List<Todo> todos = queryFactory
                .select(todo)
                .from(todo)
                .leftJoin(todo.managers, manager)
                .leftJoin(todo.comments, comment)
                .leftJoin(todo.user, user)
                .offset(pageable.getOffset())
                .where(
                        titleContains(title),
                        userNicknameContains(nickname),
                        startDateTime != null ? todo.createdAt.gt(startDateTime) : null,
                        endDateTime != null ? todo.createdAt.lt(endDateTime) : null
                )
                .groupBy(todo.id)
                .orderBy(todo.createdAt.desc())
                .limit(pageable.getPageSize())
                .fetch();

        List<TodoSearchResponse> dtoList = todos.stream()
                .map(t -> new TodoSearchResponse(
                        t.getTitle(),
                        t.getManagers().size(),
                        t.getComments().size()))
                .collect(Collectors.toList());

        return new PageImpl<>(dtoList, pageable, dtoList.size());

    }

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

    private BooleanExpression userNicknameContains(String managerNickname) {
        return managerNickname != null ? user.nickname.contains(managerNickname) : null;
    }

 

 

 

============================================================================================

 

 

질문 : 지금 내가 사용한 List<Dto> 방식으로 가져오면 N+1 문제도 발생하지 않고 좋은 것 같은데, 왜 List<Entity> 방식을 일반적으로 쓰고 있나요? List<Dto> 방식의 단점이 무엇인가요?

 

GPT 답변 :

처음부터 List<Dto>로 데이터를 조회하는 방식은 흔히 사용하는 패턴이고, 특정 상황에서는 효율적일 수 있습니다. 하지만 몇 가지 단점제약 사항이 있을 수 있어냥. 이에 대해 자세히 설명해줄게냥.

 

1. 엔티티와 관계 데이터 처리의 복잡성

fetchJoin을 사용하는 이유는 엔티티와 연관된 데이터를 함께 가져와서 N+1 문제를 방지하는 것이죠. 하지만, List<Dto> 방식은 주로 조인을 통해 필요한 데이터만 가져와서 DTO에 매핑하는 방식입니다. 이 방식에서 가장 큰 문제는 복잡한 연관 관계를 처리할 때 나타납니다.

 

DTO 방식의 한계:

  • 여러 관계를 처리할 때 중복된 데이터가 발생할 수 있습니다. 예를 들어, Todo 엔티티가 여러 Manager와 Comment를 가지고 있을 때, 각 Manager와 Comment 간의 관계로 인해 조인된 데이터가 중복될 수 있습니다.
  • DTO 매핑을 통해서 중복된 데이터가 필터링되거나 적절히 처리되지 않으면 결과가 왜곡되거나 불필요하게 데이터 양이 커질 수 있습니다.
  • 반면, 엔티티로 조회한 뒤 변환하는 방식은 HibernateJPA가 관계 데이터를 관리해주므로, 더 안정적으로 다대다, 일대다 관계를 처리할 수 있습니다.

 

2. 유연성 부족

처음부터 List<Dto>로 데이터를 조회하는 방식은 특정 필드만 조회하기 때문에, 동적으로 추가적인 정보가 필요해질 때 한계가 있을 수 있습니다. 예를 들어:

  • 새로운 비즈니스 로직이 추가되거나, DTO에 없는 필드를 추가로 조회해야 할 때, 쿼리를 다시 작성해야 합니다.
  • 반면, 엔티티를 먼저 가져오는 방식은 엔티티 내 모든 필드를 조회하므로, DTO에 없는 필드를 나중에 쉽게 추가할 수 있습니다.

 

3. 성능 문제

fetchJoin을 사용할 때는 연관된 엔티티를 즉시 로딩하기 때문에, List<Dto> 방식으로 조회하는 것보다 더 많은 데이터를 가져오는 경우가 있을 수 있습니다. 하지만 엔티티로 조회한 뒤 필요하지 않은 데이터는 사용하지 않거나 무시할 수 있는 반면, DTO로 조회할 때는 필요하지 않은 데이터까지 전부 쿼리에 명시해야 하므로 성능에 영향을 미칠 수 있습니다.

 

4. 데이터 무결성

엔티티를 사용하는 방식은 데이터의 무결성을 JPA/Hibernate가 관리해주기 때문에 더 안전합니다. 예를 들어, fetchJoin으로 조회한 엔티티는 기본적으로 영속성 컨텍스트에 의해 관리되므로, 데이터가 자동으로 동기화되거나 캐싱될 수 있습니다. 그러나 DTO 방식은 비영속 상태의 데이터이기 때문에, 이러한 관리와 이점을 누릴 수 없습니다.

 

5. 복잡한 비즈니스 로직 처리

만약 DTO에서 연관 관계나 복잡한 비즈니스 로직이 필요하다면, 엔티티를 통해 이러한 로직을 처리하는 것이 더 적합할 수 있습니다. 엔티티는 객체지향적인 접근 방식을 제공하여 연관 엔티티와의 관계를 명확히 하고, 복잡한 로직을 포함할 수 있기 때문입니다.

 

언제 DTO 방식을 사용하는 것이 좋을까?

  • 조회하려는 데이터가 단순하고, 엔티티 간의 복잡한 관계가 없을 때.
  • 성능 최적화를 위해 꼭 필요한 필드만 조회할 때.
  • 비즈니스 로직이 조회 전용이고, 조회된 데이터를 가공하지 않으며 수정할 필요가 없을 때.

 

결론

  • List<Dto>로 바로 조회하는 방식은 간단한 조회 로직에서는 유용할 수 있지만, 복잡한 연관 관계추가적인 유연성이 필요할 때는 문제가 될 수 있습니다.
  • List<Entity>로 먼저 조회한 후 DTO로 변환하는 방식은 복잡한 연관 관계와 N+1 문제를 안정적으로 해결할 수 있고, 유연성과 확장성 측면에서 유리합니다.

 

현재 사용하는 DTO 방식이 잘 작동하고 있고 성능에도 문제가 없다면 그대로 유지할 수 있지만, 복잡한 관계 데이터동적으로 데이터를 확장해야 할 가능성이 있다면 엔티티로 조회하는 방식을 고려하는 것이 더 유리할 수 있어냥!