고딩왕 코범석

Querydsl에서 여러 자식들을 DTO로 받기 본문

Language & Framework/Spring

Querydsl에서 여러 자식들을 DTO로 받기

고딩왕 코범석 2021. 11. 17. 19:46
반응형

안녕하세요! 이번 포스팅에서는 Querydsl로 하나의 부모 객체에 있는 여러 자식들을 DTO로 받아보는 시간을 가져보겠습니다.


우선, 예제에 사용될 엔티티 클래스들과 구조를 살펴보겠습니다. 그림으로 먼저 확인한 다음 엔티티 코드를 보겠습니다.


image


하나의 회원은 여러 주소를 가질 수 있고, 여러 게시글을 가질 수 있는 구조입니다. 이 구조를 바탕으로 전체 회원들의 주소와 게시글들을 DTO로 가져오는 예제를 만들어보겠습니다. 코드를 보면 명확하게 이해될겁니다 :)


Member.java

@Entity @Getter
@NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PROTECTED)
@Builder
public class Member {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    public static Member of() {
        return Member.builder()
                .username(UUID.randomUUID().toString().substring(0, 8))
                .build();
    }
}

Address.java

@Entity @Getter
@NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PROTECTED)
@Builder
public class Address {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "ADDRESS_ID")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    private String fullAddress;

    public static Address of(Member member, String fullAddress) {
        return Address.builder()
                .member(member)
                .fullAddress(fullAddress)
                .build();
    }
}

Article.java

@Entity @Getter
@NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PROTECTED)
@Builder
public class Article {

    @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "ARTICLE_ID")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    private String title;

    public static Article of(Member member, String title) {
        return Article.builder()
                .member(member)
                .title(title)
                .build();
    }
}

다음은 컨트롤러, 서비스, 리파지토리 코드들입니다.


// Controller
@RestController
@RequiredArgsConstructor
public class MemberInfoController {

    private final MemberInfoService service;

    @GetMapping("/members/info")
    public ResponseEntity<List<MemberInfo>> membersInfo() {
        return ResponseEntity.ok(service.getMembersInfo());
    }
}

// Service
@Service
@RequiredArgsConstructor
@Transactional
public class MemberInfoService {

    private final MemberRepository memberRepository;

    public List<MemberInfo> getMembersInfo() {
        return memberRepository.getMembersInfoV1();
    }
}

// Repository
public interface MemberRepository extends JpaRepository<Member, Long>, MemberQueryRepository {
}

MemberRepository 까지는 이해하시는데 별 무리 없을 것 입니다. 이제 DTO로 가져올 Repository 클래스인 MemberQueryRepository를 보겠습니다. 모든 회원들의 username, 주소명, 게시글 제목을 가져오는 기능입니다.


// MemberQueryRepository
public interface MemberQueryRepository {

    List<MemberInfo> getMembersInfo();
}

// MemberQueryRepositoryImpl
@Repository
@RequiredArgsConstructor
public class MemberQueryRepositoryImpl implements MemberQueryRepository{

    private final JPAQueryFactory query;

    @Override
    public List<MemberInfo> getMembersInfo() {
        Map<Long, MemberInfo> resultMap = query
                .from(member)
                .join(address).on(address.member.id.eq(member.id))
                .join(article).on(article.member.id.eq(member.id))
                .transform(groupBy(member.id).as(new QMemberInfo(
                        member.username,
                        list(new QMemberInfo_AddressInfo(address.fullAddress)),
                        list(new QMemberInfo_ArticleInfo(article.title))
                )));

        return resultMap.keySet().stream()
                .map(resultMap::get)
                .collect(toList());
    }
}

그리고 이 정보들을 가져올 DTO 클래스 입니다. QueryProjection을 이용하여 정보들을 가져왔습니다.


@Getter @Setter
@NoArgsConstructor(access = PRIVATE) @Builder
public class MemberInfo {

        private String username;

        @Builder.Default
        private List<AddressInfo> addressInfos = new ArrayList<>();

        @Builder.Default
        private List<ArticleInfo> articleInfos = new ArrayList<>();

        @QueryProjection
        public MemberInfo(String username, List<AddressInfo> addressInfos, List<ArticleInfo> articleInfos) {
            this.username = username;
            this.addressInfos = addressInfos;
            this.articleInfos = articleInfos;
        }

    @Getter @Setter
    @NoArgsConstructor(access = PRIVATE) @Builder
    public static class AddressInfo {
        private String fullAddress;

        @QueryProjection
        public AddressInfo(String fullAddress) {
            this.fullAddress = fullAddress;
        }
    }

    @Getter @Setter
    @NoArgsConstructor(access = PRIVATE) @Builder
    public static class ArticleInfo {
        private String title;

        @QueryProjection
        public ArticleInfo(String title) {
            this.title = title;
        }
    }
}

우선, JPA에서 여러 자식 객체를 조회할 경우 MultipleBagException이 발생합니다. 이를 해결하기위해 Many쪽 객체들을 List가 아닌 Set으로 설정하고 batch_fetch_size를 설정하는 방법이 있지만 포스팅의 목적이 DTO로 조회하는 방법이기 때문에 Querydsl의 transform 메서드를 사용해서 진행하겠습니다.


Transform

Querydsl에서의 Transform은 결과 집합을 만들기 위한 메서드입니다. 제가 작성한 transform 메서드 안에 groupBy(member.id) 의 의미는 멤버 엔티티의 ID를 Key로 삼고 조회할 value들 (as(...))을 담아 Map에 넣다 라는 의미입니다. 디버깅을 통해 더 자세하게 확인할 수 있는데, 디버깅을 하기 전에 미리 데이터들을 저장해보겠습니다.


@Slf4j
@Component
@RequiredArgsConstructor
public class Init implements CommandLineRunner {

    private final MemberRepository memberRepository;
    private final ArticleRepository articleRepository;
    private final AddressRepository addressRepository;

    @Override
    public void run(String... args) throws Exception {
        Member member1 = Member.of("member1");
        Member member2 = Member.of("member2");
        memberRepository.saveAll(List.of(member1, member2));

        // Member1은 주소가 2개, 게시글이 1개
        Address address1 = Address.of(member1, "멤버1-주소1");
        Address address2 = Address.of(member1, "멤버1-주소2");
        addressRepository.saveAll(List.of(address1, address2));

        Article article1 = Article.of(member1, "멤버1-게시글1");
        articleRepository.save(article1);

        // Member2는 주소가 1개, 게시글이 3개
        Address address3 = Address.of(member2, "멤버2-주소1");
        addressRepository.save(address3);

        Article article2 = Article.of(member2, "멤버2-게시글1");
        Article article3 = Article.of(member2, "멤버2-게시글2");
        Article article4 = Article.of(member2, "멤버2-게시글3");
        articleRepository.saveAll(List.of(article2, article3, article4));

        log.info("======== Complete Insert Dummy Data..");
    }
}

그리고 디버깅 브레이크 포인트를 찍어 어떤 구조로 리턴이 되는지 확인해보겠습니다.


image


이렇게 memberId가 key, 그에 해당하는 value가 값이 되어 map으로 표현됨을 알 수 있고, 리턴 값을 살펴보겠습니다.

[
    {
        "username": "member1",
        "addressInfos": [
            {
                "fullAddress": "멤버1-주소1"
            },
            {
                "fullAddress": "멤버1-주소2"
            }
        ],
        "articleInfos": [
            {
                "title": "멤버1-게시글1"
            },
            {
                "title": "멤버1-게시글1"
            }
        ]
    },
    {
        "username": "member2",
        "addressInfos": [
            {
                "fullAddress": "멤버2-주소1"
            },
            {
                "fullAddress": "멤버2-주소1"
            },
            {
                "fullAddress": "멤버2-주소1"
            }
        ],
        "articleInfos": [
            {
                "title": "멤버2-게시글1"
            },
            {
                "title": "멤버2-게시글2"
            },
            {
                "title": "멤버2-게시글3"
            }
        ]
    }
]

어라... 뭔가 이상합니다. member1의 경우는 주소 2개, 게시글 1개를 넣었는데 게시글 DTO가 2개 출력이 되었고, member2의 경우는 주소가 1개, 게시글 3개를 넣었는데 주소 DTO가 3개 출력되었습니다.


원인은 카테시안 곱이 발생했기 때문입니다. 중복된 데이터들을 제거하기 위해서는 Set 자료구조를 다음과 같이 사용해야합니다. 참고로 AddressInfo와 ArticleInfo가 중복되었음을 판별하기 위해 @EqualsAndHashCode 를 사용해야합니다. 예제에서는 하나의 필드밖에 없기 때문에 해당 필드가 같을 경우 중복되었음을 처리했지만, 실제 상황에서는 자식 엔티티의 ID 값을 받아와서 ID로 EqualsAndHashCode 설정하는 방법이 더 나은 방법입니다.


@Getter @Setter
@NoArgsConstructor(access = PRIVATE) @Builder
public class MemberInfo {

        private String username;

        @Builder.Default
        private Set<AddressInfo> addressInfos = new LinkedHashSet<>();

        @Builder.Default
        private Set<ArticleInfo> articleInfos = new LinkedHashSet<>();

        @QueryProjection
        public MemberInfo(String username, Set<AddressInfo> addressInfos, Set<ArticleInfo> articleInfos) {
            this.username = username;
            this.addressInfos = addressInfos;
            this.articleInfos = articleInfos;
        }

    @Getter @Setter
    @NoArgsConstructor(access = PRIVATE) @Builder
    @EqualsAndHashCode(of = "fullAddress")
    public static class AddressInfo {
        private String fullAddress;

        @QueryProjection
        public AddressInfo(String fullAddress) {
            this.fullAddress = fullAddress;
        }
    }

    @Getter @Setter
    @NoArgsConstructor(access = PRIVATE) @Builder
    @EqualsAndHashCode(of = "title")
    public static class ArticleInfo {
        private String title;

        @QueryProjection
        public ArticleInfo(String title) {
            this.title = title;
        }
    }
}

그 다음, MemberInfoRepositoryImpl 코드를 수정해보겠습니다.


@Repository
@RequiredArgsConstructor
public class MemberQueryRepositoryImpl implements MemberQueryRepository{

    private final JPAQueryFactory query;

    @Override
    public List<MemberInfo> getMembersInfo() {
        Map<Long, MemberInfo> resultMap = query
                .from(member)
                .join(address).on(address.member.id.eq(member.id))
                .join(article).on(article.member.id.eq(member.id))
                .transform(groupBy(member.id).as(new QMemberInfo(
                        member.username,
                        set(new QMemberInfo_AddressInfo(address.fullAddress)),
                        set(new QMemberInfo_ArticleInfo(article.title))
                )));

        return resultMap.keySet().stream()
                .map(resultMap::get)
                .collect(toList());
    }
}

다시 컨트롤러로 요청하여 리턴된 JSON을 확인해보겠습니다.


[
    {
        "username": "member1",
        "addressInfos": [
            {
                "fullAddress": "멤버1-주소1"
            },
            {
                "fullAddress": "멤버1-주소2"
            }
        ],
        "articleInfos": [
            {
                "title": "멤버1-게시글1"
            }
        ]
    },
    {
        "username": "member2",
        "addressInfos": [
            {
                "fullAddress": "멤버2-주소1"
            }
        ],
        "articleInfos": [
            {
                "title": "멤버2-게시글1"
            },
            {
                "title": "멤버2-게시글2"
            },
            {
                "title": "멤버2-게시글3"
            }
        ]
    }
]

정상적으로 중복 없이 원하는 데이터를 가져올 수 있습니다!


이번 포스팅은 여기서 마무리하겠습니다. 끝까지 봐주셔서 감사합니다. 언제나 피드백은 환영합니다!

반응형