프로그래밍/JPA

JPA 복합 키 사용하기 / QueryDSL N:M 관계 조회

승민아 2024. 8. 31. 22:49

최종 목표는 다음과 같은 API 응답을 만들어내는 것이다.

API 응답

 

게시글과 태그 기능을 만들고자 한다.

게시글과 태그는 N:M 관계이지만

중간에 poster_tag라는 테이블을 만들어

1:N과 M:1 관계로 풀어서 다음과 같은 관계를 갖는 테이블을 만들어서 관리하고자 한다.

 

poster과 poster_tag는 1:N 관계이다.

poster_tag과 tag는 M:1 관계이다.

 

먼저 복합키 연관관계를 매핑해보자.

복합키 연관관계 매핑

복합키 : 두개 이상의 컬럼을 묶어 기본 키로 사용하는 것이다.

 

다음과 같은 테이블을 JPA로 복합키를 설정해보자!

+---------------------+       +---------------------+       +---------------------+
|      poster         |       |    poster_tag        |       |        tag          |
+---------------------+       +---------------------+       +---------------------+
| id (PK)             |       | poster_id (PK, FK)  |       | id (PK)             |
| member_id (FK)      |-------| tag_id (PK, FK)     |-------| name                |
| is_private          |       +---------------------+       +---------------------+
| created_date        |
| title               |
| content             |
+---------------------+

 

poster 엔티티

import jakarta.persistence.*;
import lombok.Data;

import java.util.ArrayList;
import java.util.List;

@Entity
@Data
public class Poster {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "poster")
    private List<PosterTag> posterTags = new ArrayList<>();

}

1:N 연관관계는 @OneToMany로 맺는다.

이때 연관관계의 주인은 PosterTag 엔티티의 poster 필드이므로 mappedBy에 poster를 작성해준다.

 

Tag 엔티티

package com.example.bucketlist.domain;

import jakarta.persistence.*;
import lombok.Data;

import java.util.ArrayList;
import java.util.List;

@Entity
@Data
public class Tag {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "tag")
    private List<PosterTag> posterTags = new ArrayList<>();
}

Tag와 PosterTag는 1:M 관계이므로

@OneToMany를 사용한다. 외래 키를 관리하는 연관관계의 주인은 PosterTag이므로 mappedBy도 동일하게 작성한다.

 

PosterTag 엔티티

package com.example.bucketlist.domain;

import jakarta.persistence.*;
import lombok.Data;

@Entity
@Data
public class PosterTag {

    @EmbeddedId
    private PosterTagId id = new PosterTagId();

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("posterId")
    @JoinColumn(name = "poster_id")
    private Poster poster;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("tagId")
    @JoinColumn(name = "tag_id")
    private Tag tag;

}
  • @EmbeddedId : 복합 키를 설정하는 방법이다. 복합키를 위한 클래스를 정의해야한다. 이 방법 외에 @IdClass 방법도 있다.
  • @MapsId
    • Poster, Tag의 기본 키를 PosterTag의 PK로 사용할 수 있도록 매핑한다.
    • posterId, tagId는 PosterTagId 클래스에 정의된 필드 이름이다.

 

PosterTagId 엔티티

@Embeddable
@Getter @Setter
@EqualsAndHashCode
public class PosterTagId implements Serializable {

    @Column(name = "poster_id")
    private Long posterId;

    @Column(name = "tag_id")
    private Long tagId;

}
  • @Embeddable : JPA에게 이 클래스가 다른 클래스에 임베드 될 수 있다는것을 알린다.
    • PosterTagId 클래스가 PosterTag 클래스의 기본 키로 사용되며, 임베드 하여 기본 키로 구성된다는 것이다.
  • JPA 복합 키 클래스는 Serializable을 구현해야만한다.

 

게시글 정보와 게시글 태그, 작성자 정보 등을 담은 API 응답을 내려보자.

API 응답

QueryDSL

@Override
public Page<PosterOverviewResponse> findPosterOverview(int page, int size, List<String> tags) {

    QPoster poster = QPoster.poster;
    QMember member = QMember.member;
    QProfileImage profileImage = QProfileImage.profileImage;
    QPosterTag posterTag = QPosterTag.posterTag;
    QTag tag = QTag.tag;

    Pageable pageable = PageRequest.of(page - 1, size);

    // 쿼리 빌드
    JPAQuery<Tuple> tupleJPAQuery = jpaQueryFactory
            .select(poster.id, member.id, member.nickname, member.email, member.provider, member.providerId, profileImage.storeFileName, poster.title, poster.content, poster.createdDate)
            .from(poster)
            .leftJoin(member).on(member.id.eq(poster.member.id))
            .leftJoin(profileImage).on(profileImage.member.id.eq(member.id))
            .leftJoin(posterTag).on(posterTag.poster.id.eq(poster.id))
            .leftJoin(tag).on(tag.id.eq(posterTag.tag.id))
            .where(poster.isPrivate.isFalse())
            .groupBy(poster.id, member.id, member.nickname, member.email, member.provider, member.providerId, profileImage.storeFileName, poster.title, poster.content, poster.createdDate)
            .offset((page - 1) * size)
            .limit(size)
            .orderBy(poster.id.desc());

    // 카운트 쿼리
    JPAQuery<Long> countQuery = jpaQueryFactory
            .select(poster.id.countDistinct())
            .from(poster)
            .leftJoin(posterTag).on(posterTag.poster.id.eq(poster.id))
            .leftJoin(tag).on(tag.id.eq(posterTag.tag.id))
            .where(poster.isPrivate.isFalse());
    
    // 태그 이름 필터링
    if (tags != null && tags.size() > 0) {
        tupleJPAQuery.where(tag.name.in(tags));
        countQuery.where(tag.name.in(tags));
    }

    List<PosterOverviewResponse> overviewResponses = tupleJPAQuery.fetch()
            .stream()
            .map(tuple -> {

                PosterOverviewResponse posterOverviewResponse = new PosterOverviewResponse();
                posterOverviewResponse.setTitle(tuple.get(poster.title));
                ...

                List<String> tagNamesList = jpaQueryFactory
                        .select(tag.name)
                        .from(posterTag)
                        .join(posterTag.tag, tag)
                        .where(posterTag.poster.id.eq(tuple.get(poster.id)))
                        .fetch();
                posterOverviewResponse.setTags(tagNamesList);
                return posterOverviewResponse;
            })
            .collect(Collectors.toList());

        return PageableExecutionUtils.getPage(overviewResponses, pageable, countQuery::fetchOne);
}

게시글에 해당하는 태그들은 List라서 한번의 쿼리로 응답을 만들어내기 힘들다.

우선적으로 게시글에 관련된 정보들을 조회하는 쿼리를 먼저 날린다.

게시글과 관련된 작성자명, 닉네임은 하나의 결과라서 먼저 날리는 것이다.

 

해당 게시글의 태그는 여러개라서 다음과 같이 응답이 나온다.

id가 91인 게시글에 태그 name이 taga, tagb로 두개가 존재한다.

그래서 먼저 게시글 정보를 조회하는 쿼리를 날리고 해당 게시글 각각의 PK를 가지고 태그들을 추가적으로 조회하는 쿼리를 날리는 방식으로 구현한것이다.

 

그래서 다음과 같이 해당 게시글 정보에 해당하는 태그들을 조회하기 위한 쿼리가

원하는 게시글 조회 개수만큼 더 나간다.

추가적인 조회 쿼리 발생

QueryDSL을 공부해서 개선해보자.

 

결과