쿼리DSL 연관관계 JOIN하여 DTO로 조회하기

24.02.22 17:03 작성 24.02.22 17:21 수정 조회수 243

1

안녕하세요 영한님의 강의를 맹신하는 대학생 개발자입니다!

다름이 아니라 플랫폼을 개발하고 있는데 연관관계가 포함된 People과 PeopleProfile 사이에서 join을 통해 값을 가져오려고 합니다.

 

PeopleEntity

@Getter
@Entity
@Table(name = "people")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class People extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "people_id")
    private Long peopleId;

    @Column(name = "nickname")
    private String nickname;

    @Column(name = "email")
    private String email;

    @Column(name = "phone_number")
    private String phoneNumber;

    @Enumerated(EnumType.STRING)
    @Column(name = "people_type")
    private PeopleType peopleType;

    @Column(name = "profile_image_uri")
    private String profileImageUri;

    @Column(name = "account_number")
    private String accountNumber;

    @OneToOne(cascade = CascadeType.REMOVE)
    private Member member;

    @OneToOne(mappedBy = "people")
    private PeopleProfile peopleProfile;

...
}

 

PeopleProfile

@Entity
@Getter
@Table(name = "people_profile")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PeopleProfile {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "people_profile_id")
    private Long peopleProfileId;

    @Column(name = "introduction")
    private String introduction;

    @Column(name = "activity_area")
    private String activityArea;

    @Column(name = "education")
    private String education;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "people_id")
    private People people;
    
    @ElementCollection
    @CollectionTable(name = "people_hashtags", joinColumns = @JoinColumn(name = "people_profile_id"))
    @Column(name = "hashtags_id")
    private List<Hashtag> hashtags = new ArrayList<>();

...
}

 

Hashtag

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Hashtag {

    @Column(name = "hashtag")
    private String value;

    private Hashtag(String value) {
        this.value = validate(value);
    }

    private String validate(String value) {
        return value;
    }

    public static Hashtag from(String value) {
        return new Hashtag(value);
    }
}

hashtag는 값 타입으로 구현했습니다.

 

PeopleDTO

@Getter
public class CardPeopleResponse {
    @NotNull 
    private Long peopleId;
    @NotNull 
    private String nickname;
    @NotNull 
    private String peopleType;
    @NotNull 
    private String profileImageUri;
    @Valid
    private CardPeopleProfileResponse profile;
    
    @QueryProjection
    public CardPeopleResponse(final Long peopleId, final String nickname, final People Type peopleType,
        final String profileImageUri, @Valid CardPeopleProfileResponse profile) {
        this.peopleId = peopleId;
        this.nickname = nickname;
        this.peopleType = peopleType.name();
        this.profileImageUri = profileImageUri;
        this.profile = profile;
    }
}

PeopleProfileDTO

@Getter
public class CardPeopleProfileResponse {
    @NotNull
    private final String activityArea;
    @NotNull
    private final List<String> hashtags;
    @NotNull
    private final Integer completedProjectsCount;
    @NotNull
    private final Integer interestsCount;

    @QueryProjection
    public CardPeopleProfileResponse(final String activityArea, final List<Hashtag> hashtags, 
            final Integer completedProjectsCount, final Integer interestsCount) {
        this.activityArea = activityArea;
        this.hashtags = hashtags.stream().map(Hashtag::getValue).toList();
        this.completedProjectsCount = completedProjectsCount;
        this.interestsCount = interestsCount;
    }
}

 

 

쿼리DSL

private List<CardPeopleResponse> getCardPeopleContent(Pageable pageable) {
        return queryFactory
            .select(new QCardPeopleResponse(
                people.peopleId,
                people.nickname,
                people.peopleType,
                people.profileImageUri,
                new QCardPeopleProfileResponse(
                    peopleProfile.activityArea,
                    peopleProfile.hashtags,
                    Expressions.constant(0),
                    Expressions.constant(0)
                )
            ))
            .from(people)
            .join(people.peopleProfile, peopleProfile)
            .orderBy(getPeopleOrderSpecifiers(pageable.getSort()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
    }

 

completedProjectsCount와 interestsCount는 추후 개발 예정이므로 Expressions.constant(0)으로 대체 해뒀습니다. 위와 같이 서버를 구현하였을때, 서버 구동은 문제 없이 됩니다.

 

문제점

2024-02-22T16:44:50.163+09:00 DEBUG 3487851 --- [io-52003-exec-1] org.hibernate.SQL                        : 
    select
        p1_0.people_id,
        p1_0.nickname,
        p1_0.people_type,
        p1_0.profile_image_uri,
        p2_0.activity_area,
        h1_0.hashtag 
    from
        people p1_0 
    join
        people_profile p2_0 
            on p1_0.people_id=p2_0.people_id 
    join
        people_hashtags h1_0 
            on p2_0.people_profile_id=h1_0.people_profile_id 
    order by
        p1_0.people_id desc offset ? rows fetch first ? rows only

2024-02-22T16:44:50.274+09:00 ERROR 3487851 --- [io-52003-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: argument type mismatch] with root cause

java.lang.IllegalArgumentException: argument type mismatch
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480) ~[na:na]
        at com.querydsl.core.types.ConstructorExpression.newInstance(ConstructorExpression.java:144) ~[querydsl-core-5.0.0.jar!/:na]
        at com.querydsl.core.types.FactoryExpressionUtils.compress(FactoryExpressionUtils.java:155) ~[querydsl-core-5.0.0.jar!/:na]
        at com.querydsl.core.types.FactoryExpressionUtils.access$100(FactoryExpressionUtils.java:28) ~[querydsl-core-5.0.0.jar!/:na]
        at com.querydsl.core.types.FactoryExpressionUtils$FactoryExpressionAdapter.newInstance(FactoryExpressionUtils.java:62) ~[querydsl-core-5.0.0.jar!/:na]
        at com.querydsl.core.support.ConstantHidingExpression.newInstance(ConstantHidingExpression.java:87) ~[querydsl-core-5.0.0.jar!/:na]
...

다음과 같은 argument type mismatch에러가 발생합니다. DTO에서 값을 받아와서 생성자에서 데이터 타입을 변경하면 문제가 되는걸까요? 이러한 경우에는 보통 어떻게 조회하는지 궁금합니다!

 

답변 1

답변을 작성해보세요.

1

인프런 AI 인턴님의 프로필

인프런 AI 인턴

2024.02.23

안녕하세요, 인프런 AI 인턴입니다.
KeepHun님, 영한님의 강의에 대한 열심히 학습하는 자세가 인상적입니다.

DTO 생성자에서 발생하는 argument type mismatch 오류는 생성자로 전달된 인자의 타입이 기대하는 타입과 일치하지 않을 때 발생합니다. 이 경우, CardPeopleProfileResponse DTO의 생성자를 살펴봐야 할 것 같습니다.

DTO의 생성자는 다음과 같습니다:

public CardPeopleProfileResponse(final String activityArea, final List<Hashtag> hashtags,
        final Integer completedProjectsCount, final Integer interestsCount) {
    this.activityArea = activityArea;
    this.hashtags = hashtags.stream().map(Hashtag::getValue).toList();
    this.completedProjectsCount = completedProjectsCount;
    this.interestsCount = interestsCount;
}

여기서 문제가 되는 부분은 List<Hashtag> 타입의 hashtags 입니다. 쿼리 결과에서는 해시태그 목록이 아닌 단일 Hashtag 객체를 생성자로 전달받으려 하고 있습니다. 즉, 생성자에 전달되어야 하는 것은 List<Hashtag> 타입인데, 실제 전달되는 것은 Hashtag 타입의 객체이기 때문에 오류가 발생합니다.

해결 방법으로는 select 문을 수정하여 List<Hashtag> 타입을 제대로 전달하는 방법을 사용해야 합니다.

예를 들어, 쿼리에서 해시태그 목록을 가져오기 위해 서브쿼리를 사용하거나, groupBy를 사용하여 hashtags를 모아서 반환해야 할 수도 있습니다. 이 부분은 QueryDSL의 구체적인 쿼리 구성에 따라 달라지므로, 아래와 같이 조정할 수 있습니다:

.select(new QCardPeopleResponse(
    people.peopleId,
    people.nickname,
    people.peopleType,
    people.profileImageUri,
    new QCardPeopleProfileResponse(
        peopleProfile.activityArea,
        JPAExpressions.select(peopleProfile.hashtags).from(peopleProfile).where(peopleProfile.people.eq(people)),
        Expressions.constant(0),
        Expressions.constant(0)
    )
))

또한, 쿼리에서 반환하는 값과 DTO 생성자의 파라미터 타입이 일치하는지 확인하시는 것이 중요합니다. Spring Data JPA에서 Projections를 사용하여 DTO로 결과를 매핑할 때에는 생성자의 파라미터 타입과 일치하는 필드만 갖고 오도록 할 수 있습니다. 위 정보에서 제공된 Spring Data JPA의 “Class-based Projections (DTOs)” 부분을 참조하여 각 필드가 일치하도록 쿼리 메서드를 작성하시면 이러한 에러를 피하실 수 있습니다.

다음은 Spring Data JPA의 “Projections” 부분에 나온 Class-based Projections에 대한 관련 내용입니다:

Class-based projections with JPQL is limited to constructor expressions in your JPQL expression, e.g. `SELECT new com.example.NamesOnly(u.firstname, u.lastname) from User u`. (Note the usage of a FQDN for the DTO type!) This JPQL expression can be used in `@Query` annotations as well where you define any named queries.

원문과 링크 정보는 아래와 같습니다.

  • 원문: Class-based projections with JPQL is limited to constructor expressions in your JPQL expression, e.g. SELECT new com.example.NamesOnly(u.firstname, u.lastname) from User u. (Note the usage of a FQDN for the DTO type!) This JPQL expression can be used in @Query annotations as well where you define any named queries.
    (출처: Spring Data JPA - Reference Documentation v3.1.5)
  • 링크: Spring Data JPA - Projections

이렇게 클래스 기반의 DTO 프로젝션을 사용할 때는 JPQL 내의 constructor expressions로 제한되며, @Query 어노테이션을 사용하여 명시적인 쿼리를 정의할 수 있습니다.

KeepHun님께서는 쿼리 결과와 DTO 생성자 간의 타입 불일치를 해결하기 위한 위 정보를 참고하시어 문제를 해결하실 수 있으시길 바랍니다.

KeepHun님의 프로필

KeepHun

질문자

2024.02.26

답변 코드 적용

private List<CardPeopleResponse> getCardPeopleContent(Pageable pageable) {
        return queryFactory
            .select(new QCardPeopleResponse(
                people.peopleId,
                people.nickname,
                people.peopleType,
                people.profileImageUri,
                new QCardPeopleProfileResponse(
                    peopleProfile.activityArea,
                    JPAExpressions.select(peopleProfile.hashtags).from(peopleProfile).where(peopleProfile.people.eq(people))
                )
            ))
            .from(people)
            .join(people.peopleProfile, peopleProfile)
            .orderBy(getPeopleOrderSpecifiers(pageable.getSort()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
    }

위와 같이 작성한 후 실행해보았습니다.

2024-02-25T23:08:06.122+09:00 ERROR 3847208 --- [io-52003-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : ERROR: more than one row returned by a subquery used as an expression

Sql에 해당하는 객체가 하나의 반환값이 아닌 여러 개이기 때문에 에러가 발생하였습니다.

튜플로 조회

private List<CardPeopleResponse> getCardPeopleContent(Pageable pageable) {
      List<Tuple> result = queryFactory
          .select(
              people.peopleId,
              people.nickname,
              people.peopleType,
              people.profileImageUri,
              peopleProfile.activityArea
							peopleProfile.hashtags
          )
          .from(people)
          .join(people.peopleProfile, peopleProfile)
          .orderBy(getPeopleOrderSpecifiers(pageable.getSort()))
          .offset(pageable.getOffset())
          .limit(pageable.getPageSize())
          .fetch();

제가 알기로 객체가 여러 개인 상황에서는 Tuple로 반환받아야 한다고 알고 있습니다. 그래서 튜플로 코드를 처리하였습니다.

select
        p1_0.people_id,
        p1_0.nickname,
        p1_0.people_type,
        p1_0.profile_image_uri,
        p2_0.activity_area,
        h1_0.hashtag 
    from
        people p1_0 
    join
        people_profile p2_0 
            on p1_0.people_id=p2_0.people_id 
    join
        people_hashtags h1_0 
            on p2_0.people_profile_id=h1_0.people_profile_id 
    order by
        p1_0.people_id desc offset ? rows fetch first ? rows only

여전히 hashtags는 하나의 값만 조회했습니다.

최선의 해결책

private List<Hashtag> getHashtagsForPeople(Long peopleId) {
        String sql = "SELECT h.hashtag FROM people_hashtags h WHERE h.people_profile_id = :peopleId";
        List<String> hashtagValues = em.createNativeQuery(sql)
                                       .setParameter("peopleId", peopleId)
                                       .getResultList();

        return hashtagValues.stream()
                            .map(Hashtag::from)
                            .collect(Collectors.toList());
    }

private List<CardPeopleResponse> getCardPeopleContent(Pageable pageable) {
      List<Tuple> result = queryFactory
          .select(
              people.peopleId,
              people.nickname,
              people.peopleType,
              people.profileImageUri,
              peopleProfile.activityArea
          )
          .from(people)
          .join(people.peopleProfile, peopleProfile)
          .orderBy(getPeopleOrderSpecifiers(pageable.getSort()))
          .offset(pageable.getOffset())
          .limit(pageable.getPageSize())
          .fetch();

      return result.stream().map(tuple -> {
          Long peopleId = tuple.get(people.peopleId);
          String nickname = tuple.get(people.nickname);
          PeopleType peopleType = tuple.get(people.peopleType);
          String profileImageUri = tuple.get(people.profileImageUri);
          String activityArea = tuple.get(peopleProfile.activityArea);
          List<Hashtag> hashtags = getHashtagsForPeople(peopleId);
          CardPeopleProfileResponse profile = new CardPeopleProfileResponse(activityArea, hashtags);
          return new CardPeopleResponse(peopleId, nickname, peopleType, profileImageUri, profile);
      }).toList();
  }

따라서 저는 쿼리를 분석해 본 결과 JPA를 사용해서 모든 객체를 조회하는 경우가 아니라면 컬렉션 테이블을 조회할 수 없다는 것을 알게 되었습니다. 따라서 JPQL이 아닌 네이티브 쿼리를 사용해 구현에 성공하였습니다. 하지만 구현은 성공하였지만 아직까지 풀리지 않은 의문증이 있습니다.

  1. 컬렉션 테이블에 값은 수정 불가합니다. 그렇다면 OneToMany로 구현하고 수정 불가하게 구현하는 것과 다른 점이 무엇인가?

  2. QueryDSL 은 튜플에 리스트 객체를 못 담는가?

라는 의문이 들었습니다. 일단 인프런 AI 인턴님께서 답변을 주셔서 좀 더 넓은 시야로 문제 해결에 대해서 탐구하게 되었습니다! 답변 정말 감사합니다!! 의문증은 혹시나 아시면 답변 부탁드립니다!! 지금은 네이티브 쿼리로 구현했지만 추후 수정 불가한 OneToMany 로 구현 해보려고 합니다! 감사합니다 :)