Project/9uin

spring boot 사이드 프로젝트[8] : 게시글 태그 필터링 (querydsl join)

attlet 2023. 6. 14. 15:07

 

이전글

https://yoonsys.tistory.com/9

 

spring boot 사이드 프로젝트[7] : Custom Repository 작성(사용자명, 글제목으로 게시글 검색)

JpaRepository를 인터페이스에 상속하면 기본적인 메서드들 (ex. findbyid, findAll) 을 제공하지만, 디테일한 데이터를 쿼리하기 위해서는 내가 직접 작성할 필요가 있었다. 처음에는 다음과 같이 메서드

yoonsys.tistory.com

 

 

앞에서는 동적 쿼리를 이용해 사용자 이름, 게시글 이름으로 게시글들을 필터링하는 repository코드를 작성했다. 이번에는 복수의 태그를 이용해 게시글을 필터링해보려 한다.

 

 

 

Entity 수정


여기서 중요한 점은 entity의 수정이었다.  게시글 하나에는 여러개의 기술 스택 태그가 달릴 수 있었고, 태그 하나는 여러 게시글에 사용될 수 있으니 다대다 관계이다.  jpa를 공부할 때 생소했던 부분인데, 다대다 관계같은 경우 manytomany로 연관관계를 만들수도 있지만, 이는 예상치 못한 쿼리가 발생할 수 있다는 점 등의 문제들을 야기할 수 있었다. 그래서 다대다 관계를 일대다, 다대일로 분리하는 방법을 사용했다.

 

 

 

 

ER 다이어그램에서 post부분을 수정한 모습

 

 

skill 도메인을 새로 만든 것

 

 

 

 

 

ER 다이어그램에서 기술 스택 태그 엔티티와, post와 skilltag의 관계인 tagRelation엔티티를 만들었다.  tagRelation같은 경우 post, skilltag의 id들을 fk로 가지는 엔티티이다.

 

 

 

TagPostRelation 엔티티

 

 

 

 

querydsl 코드 작성


이 다음, 이걸 이용해 태그 필터링하는 코드를 repository에 작성했다. 여기서 고비였는데, 이 부분을 어떻게 할지 잘 몰라 허덕였다. querydsl의 join을 이용해야 했다. 

 

처음에 작성한 코드는 이런 형태였다.

 

 

 

CustomPostRepositoryImpl.java

private BooleanExpression TagsEq(List<String> tags) {
    if (tags.isEmpty() == true) return null;

    BooleanExpression combinedExpression = Expressions.asBoolean(true).isTrue();

    List<Predicate> tagPredicate = tags.stream()
            .map(tag -> qPost.id.in(jpaQueryFactory.select(qPost.id)
                    .from(qPost)
                    .join(qPost.tagPostRelationList, qTagPostRelation)
                    .join(qTagPostRelation.skillTag, qSkillTag)
                    .where(qSkillTag.name.eq(tag))))
            .collect(Collectors.toList());



    for(Predicate predicate : tagPredicate){
        combinedExpression = combinedExpression.and(predicate);
    }

    return combinedExpression;
}

 

 

 

태그를 리스트에 담아 전달받으면, 그 리스트를 stream을 이용해 map을 사용하여 모든 요소를 BooleanExpression 형태로 적용시켰다. 문제는 이걸 작성한 후 테스트 코드를 작성해 테스트를 했는데, Page로 받아오면 항상 0개를 가져와 틀린 결과를 반환했다. 

 

 

 

 

CustomPostRepositoryImplTest.java

@Test
@DisplayName("태그 1개인 게시글 필터링")
void TagFiltering(){

    //given
    String title = "";
    String type = "";
    String user_id = "";
    List<String> tags = List.of("react");
    List<Post> postList = new ArrayList<>();

    //when
    Pageable pageable = PageRequest.of(0, 5);
    Page<Post> postPage = postRepository.findPosts(pageable, user_id, title, type, tags);

    //then
    assertEquals(postPage.getSize(), 5);
    List<Post> retPostList = postPage.getContent();
    assertEquals(retPostList.size(), 5);  //여기서 list크기가 0으로, 하나도 가져오지 못함.

    for(Post post : retPostList){
        System.out.println("-----------------");
        System.out.println("Post user id " + post.getUser().getUser_id());
        System.out.println("Post title " + post.getTitle());
        System.out.print("Tag : ");

        post.getTagPostRelationList()
                .stream().forEach(tag -> System.out.print(tag.getSkillTag().getName()));

        System.out.println();
        System.out.println("-----------------");
    }

}

 

 

querydsl의 원리를 다시 공부해야겠다고 생각했고, 원인을 찾아봤다. 원인은 querydsl 작성한 부분에서 qPost의 중첩 쿼리 발생이었다.

 

 

List<Predicate> tagPredicate = tags.stream()
        .map(tag -> qPost.id.in(jpaQueryFactory.select(qPost.id)
                .from(qPost)
                .join(qPost.tagPostRelationList, qTagPostRelation)
                .join(qTagPostRelation.skillTag, qSkillTag)
                .where(qSkillTag.name.eq(tag))))
        .collect(Collectors.toList());

 

 

 

첫 번째 qPost는 메인 쿼리에서 qPost를 사용한다는 것인데, 문제는 내부 서브 쿼리에서의 qPost가 다시 사용되어 중첩된 쿼리가 발생한다는 것이었다. 즉 내부에서 qPost를 사용하려면 이름을 바꿔 사용해야 한다는 것이다. 

 

 

 

  private BooleanExpression TagsEq(List<String> tags) {
        if (tags.isEmpty() == true) return null;

        BooleanExpression combinedExpression = Expressions.asBoolean(true).isTrue();


        for(String tag : tags){
            QPost subqPost = new QPost("subqPost");

//            Predicate tagQuery = qPost.id.in(
//                    jpaQueryFactory.select(qTagPostRelation.post.id)
//                            .from(qTagPostRelation)
//                            .join(qTagPostRelation.skillTag, qSkillTag)
//                            .where(qSkillTag.name.eq(tag))
//            );

            Predicate tagQuery = qPost.id.in(jpaQueryFactory
                    .select(subqPost.id)
                    .from(subqPost)
                    .join(subqPost.tagPostRelationList, qTagPostRelation)
                    .join(qTagPostRelation.skillTag, qSkillTag)
                    .where(qSkillTag.name.eq(tag)));

            combinedExpression = combinedExpression.and(tagQuery);
        }

        return combinedExpression;

 

 

Qpost를 반복문 안에서 새로 선언한다. 이름을 메인 쿼리에 있는 qPost와 다르게 선언하기 위해 생성자를 호출한다.

 

※ 이전에 QPost.post를 이용해 만든 Q클래스는 자동적으로 이름이 "post"로 들어가 있다. 이걸 고려해서 이름을 다르게 만들어주면 된다. 

 

이렇게 해주면 메인 쿼리에 있는 qPost와는 다른 qPost를 사용할 수 있다. 우리가 sql을 작성할 때 서브 쿼리에서는 똑같은 테이블을 쓸 때 이름을 다르게 붙여주는 것을 생각하면 된다.

 

(employee as e를 메인쿼리에 썼다면 내부 서브쿼리에서는 employee as a로 쓴다던지)

 

 

 

 

 

그리고 두 번 join하는 것이 핵심이다. post와 tagPostRelation을 조인해서 post와 연결된 skilltag id를 찾고, 그 id를 이용해 태그 이름을 찾는 것이다.

 

 

 

주석 처리된 코드와 밑에 코드 둘 다 테스트를 통과했고, 이제 다음에 성능 테스트를 할 것이다.