BackEnd/JPA

[JPA] QueryDSL 에서 Select필드로 상속한 Entity 사용시 경험한 문제

샤아이인 2022. 11. 10.

 

프로젝트를 진행하다 보니, ID값을 공통적인 BaseEntity에 들고 있어서 발생하는 문제가 있었습니다.

2가지 문제였는데

 

1) no property found for class with parameters

2) com.querydsl.core.types.QBean com.example.queyrdsl.entity.BarMoodTag with modifiers "protected"

 

이를 해결해보자!

1. 문제 상황

우선 PostId를 하나 받으면, 해당 포스트에 연관된 모든 MoodTag를 가져오는 것 이 목표인 상황입니다!

 

▶ BaseEntity

@MappedSuperclass
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BaseEntity {

    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid")
    private String id;

    protected BaseEntity(String id) {
        this.id = id;
    }

    // 생략...
}

우선 저희 팀은 UUID 전략을 통해서 ID를 할당하는 방식을 사용하고 있습니다.

또한 id 필드 자체를 BaseEntity상에 두고, 모두가 공통적으로 상속하여 사용하도록 하고 있습니다.

(어떻게 보면 id 필드가 부모에 있었던 게 문제의 근원 이기는 합니다...)

 

이를 상속하는 BarMoodTag는 다음과 같습니다.

 

▶ BarMoodTag

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BarMoodTag extends BaseEntity {

    @Column(nullable = false)
    private String barId;

    @Column(nullable = false)
    private String name;

    private boolean isCuration;

    private Integer count;

    public BarMoodTag(String barId, String name, boolean isCuration, Integer count) {
        this.barId = barId;
        this.name = name;
        this.isCuration = isCuration;
        this.count = count;
    }
}

 

이를 QueryDsl을 통해 Entity를 찾아오려 할 때 문제가 발생하였습니다.

@Override
public List<BarMoodTag> findAllByPostId(String postId) {
    return queryFactory
            .select(Projections.constructor(BarMoodTag.class, barMoodTag.id, barMoodTag.barId, barMoodTag.name, barMoodTag.isCuration, barMoodTag.count))
            .from(barMoodTag)
            .where(barMoodTag.id.in(
                    JPAExpressions
                            .select(attach.tagId)
                            .from(attach)
                            .where(attach.postId.eq(postId))
            )).fetch();
}

위 쿼리에서 눈여겨볼 점은 constructor방식을 사용하려 했다는 점입니다!

 

테스트 코드는 다음과 같습니다.

@DisplayName("Attach 중간 테이블을 통해 저장하고, Post와 관련된 테그를 모두 찾아온다.")
@Test
public void find_mood_tag_test() {
    // given
    Post post = new Post("temp_ip", RESTAURANT_ID, 4.3f, "contents");
    BarMoodTag moodTag1 = new BarMoodTag("bar_uuid1", "mood_tag_1", true, 2);
    BarMoodTag moodTag2 = new BarMoodTag("bar_uuid2", "mood_tag_2", true, 1);
    postRepository.save(post);
    moodTagRepository.saveAll(List.of(moodTag1, moodTag2));

    Attach attach1 = new Attach(post.getId(), moodTag1.getId());
    Attach attach2 = new Attach(post.getId(), moodTag2.getId());
    attachRepository.saveAll(List.of(attach1, attach2));

    em.flush();
    em.clear();

    // when
    List<BarMoodTag> barMoodTags = moodTagRepository.findAllByPostId(post.getId());

    // then
    Assertions.assertThat(barMoodTags).contains(moodTag1, moodTag2);
    Assertions.assertThat(barMoodTags.size()).isEqualTo(2);
}

 

실행 결과는 다음과 같습니다.

생성자에 ID를 받는 부분이 열려 있지가 않았던 것이 문제였습니다!

잠시 생성자를 다시 살펴보면 다음과 같은데,

public BarMoodTag(String barId, String name, boolean isCuration, Integer count) {
    this.barId = barId;
    this.name = name;
    this.isCuration = isCuration;
    this.count = count;
}

위에서 보이듯 자신의 UUID를 받을 부분이 생성자의 파라미터로 명시되어 있지 않는 것이 문제입니다.

 

여기서 이를 해결하는 방법으로 2가지를 생각하였습니다.

1) super(id)를 통해 부모의 id값을 설정하는 생성자를 하나 더 만든다.

2) constructor 방식이 아니라, fields 방식으로 변경한다!

 

여기서 저는 fields방식을 선택하였는데, 그 이유는 "단순하게 테스트 용도를 위해 id값을 받는 파라미터를 만들 필요가 있을까?"였습니다.

따라서 2번 방식을 선택하였고, 다음과 같이 변경되었습니다.

@Override
public List<BarMoodTag> findAllByPostId(String postId) {
    return queryFactory
            .select(Projections.fields(BarMoodTag.class, barMoodTag.id, barMoodTag.barId, barMoodTag.name, barMoodTag.isCuration, barMoodTag.count))
            .from(barMoodTag)
            .where(barMoodTag.id.in(
                    JPAExpressions
                            .select(attach.tagId)
                            .from(attach)
                            .where(attach.postId.eq(postId))
            )).fetch();
}

보시다시피 fields로 변경되어 있는 것을 알 수 있습니다.

 

다시 테스트를 실행해볼까요?

이번에는 다른 문제가 발생하였습니다...

이는 기본 생성자가 protected로 선언되어 있기 때문입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BarMoodTag extends BaseEntity {...}

여기서 또다시 2가지 방식이 생각났습니다.

 

1) 기본 생성자를 public으로 만든다 (무조건 나쁘지는 않은 듯? 약간은 고민되긴 하는 방식)

2) DTO를 별도로 만든다.

 

이번에는 DTO방식을 선택하였는데, 최대한 원 Entity에 변화를 주고 싶지 않았기 때문이며, 저희 애플리케이션 상에서는 MoodTag를 기본 생성자로 생성할 부분이 없기 때문에, 다른 사람이 봤을 때 모르고 기본 생성을 할 수도 있기 때문입니다.

 

이를 막고자 생성자는 그대로 protected로 두고, 다음과 같이 DTO를 만들게 되었습니다.!

@Getter
@NoArgsConstructor
public class BarMoodTagDto {

    private String id;
    private String barId;
    private String name;
    private boolean isCuration;
    private Integer count;

    @QueryProjection
    public BarMoodTagDto(String id, String barId, String name, boolean isCuration, Integer count) {
        this.id = id;
        this.barId = barId;
        this.name = name;
        this.isCuration = isCuration;
        this.count = count;
    }
}

 

이제 이를 사용하는 QueryDSL 부분을 다음과 같이 수정해 봅시다!

@Override
public List<BarMoodTagDto> findAllByPostId(String postId) {
    return queryFactory
            .select(new QBarMoodTagDto(barMoodTag.id, barMoodTag.barId, barMoodTag.name, barMoodTag.isCuration, barMoodTag.count))
            .from(barMoodTag)
            .where(barMoodTag.id.in(
                    JPAExpressions
                            .select(attach.tagId)
                            .from(attach)
                            .where(attach.postId.eq(postId))
            )).fetch();
}

QBarMoodTagDto를 통해 처리하고 있게 만들었습니다!

 

마지막으로 테스트 부분을 조금 변경해주어야 합니다.

List<BarMoodTagDto> barMoodTags = moodTagRepository.findAllByPostId(post.getId());
for (BarMoodTagDto barMoodTag : barMoodTags) {
    System.out.println("barMoodTag = " + barMoodTag.getId());
}

// then
Assertions.assertThat(barMoodTags).extracting("id").contains(moodTag1.getId(), moodTag2.getId());
Assertions.assertThat(barMoodTags.size()).isEqualTo(2);

이전에는 moodTag1, moodTag2가 있는지 확인하고 있었다면, 이번에는 DTO를 반환하기 때문에 id값이 같은지만 확인해주려 합니다!


사실 이러고 끝내려 했는데..... 아디 동일한 id를 가지고 있는 엔티티가 하나는 BarMoodTag고, 다른 하나는 BarMoodTagDto라고?

이거 좀 이상한데?.....

 

DTO 자체에 ID 필드 값이 필요한 것이 맞을까?? 필요 없는 정보인 것 같은데?...

 

따라서 DTO에서 id부분은 삭제하였다.

댓글