8 분 소요

상황설명

회원은 각 장소에 대한 후기를 작성할 수 있고, 각 장소에 대해서 본인을 포함한 다른 회원들이 작성한 후기를 볼 수 있습니다.

후기를 작성할 때는 장소에 대한 평가 태그들을 선택해야 하고, 텍스트를 작성하여 후기를 남깁니다.

후기 리스트를 볼 때는 각 장소에 대한 총 후기 개수 그리고, 한 페이지당 후기 개수를 선택할 수 있으며(5개, 10개 등)

각 후기는 날짜를 기준으로 내림차순으로 정렬되어 텍스트와 아까 선택한 장소 평가 태그들 그리고 마스킹된 회원 이름과 후기 작성일자가 표시됩니다.

각 후기에 출력되는 태그 역시 일정한 순서를 가진 채로 항상 일정한 순으로 출력됩니다.

이를 구현하기 위해 엔티티와 서비스 로직을 구현하는데 이 때, 후기 작성 서비스 로직은 생략하고,

후기 리스트 서비스 로직만 적도록 하겠습니다.

그리고 여기서 바로 1 + N + M 문제가 발생하는데 이를 해결한 방법을 같이 소개합니다.

PlaceReview 엔티티

먼저 장소에 대해서 회원이 작성한 후기를 저장하는 엔티티인 PlaceReview가 있습니다.

@Entity
@Table(name = "place_review")
class PlaceReview(
    @Id
    @Column(name = "place_review_id")
    val id: String = CommonUtils.uuid(),

    @Column(name = "customer_id")
    val customerId: String,

    @Column(name = "place_id")
    val placeId: String,

    val maskedCustomerName: String,
    val createDate: LocalDateTime = LocalDateTime.now(),
    var comment: String?
)

PlaceReview 자신의 아이디는 uuid로 생성하고, 후기를 작성한 고객 아이디, 후기 장소 아이디, 마스크처리된 회원이름(나중에 후기를 작성한 회원이 탈퇴하더라도 마스킹된 이름은 가지고 있어야 하기 때문에), 후기 작성시간, 후기 텍스트를 멤버로 가집니다.

PlaceReviewTag 엔티티

회원이 PlaceReview(후기)를 작성할 때, 장소에 맞는 태그도 선택해야 합니다.

예를 들어, ‘편리한 주차’, ‘볼거리가 많은 장소’, ‘접근이 편한 장소’ 등…(장소마다 태그값들은 복수임.)

그래서 각 장소들에 대한 이러한 태그값들을 미리 정하고, 이를 db에 저장하는데 이를 나타내는 클래스가 바로 PlaceReviewTag입니다.

@Entity
@Table(name = "place_review_tag")
class PlaceReviewTag(
        @Id
        @Column(name = "place_review_tag_key", nullable = false)
        val placeReviewTagKey: String,

        @Column(nullable = false)
        val tag: String,

        @Column(nullable = false)
        val tagValue: String,

        val seq: Int
)

PlaceReviewTag의 id는 예를 들어, ‘접근이 편한 장소’이면 ‘approachable_position’입니다.

tag는 ‘접근이 편한 장소’, tagValue는 ‘대중교통을 이용하여 해당 장소로 이동하기 편해요’입니다.

seq는 장소에 해당하는 여러 태그들이 항상 일정한 순서로 출력되도록 하기 위해 필요합니다.

PlaceReviewTagMapping 엔티티

하나의 PlaceReview는 여러 PlaceReviewTag를 가질 수 있고, 반대로 하나의 PlaceReviewTag도 여러 PlaceReview를 가질 수 있습니다.

즉, 서로 다대다 관계입니다.

이를 db에 표현하기 위해 PlaceReviewTagMapping엔티티가 필요합니다.

@Entity
@Table(name = "pr_prt_mapping")
@IdClass(PlaceReviewTagMappingId::class)
class PlaceReviewTagMapping(
        @Id
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "place_review_id", nullable = false)
        val placeReview: PlaceReview,

        @Id
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "place_review_tag_key", nullable = false)
        val placeReviewTag: PlaceReviewTag
)

이 PlaceReviewTagMapping을 위해서는 복합키인 PlaceReviewTagMappingId가 필요합니다.

PlaceReviewTagMappingId가 없으면 MappingException -> PersitenceException -> BeanCreationException이 발생합니다.

class PlaceReviewTagMappingId : Serializable {
    val placeReview: String = ""
    val placeReviewTag: String = ""
}

이 PlaceReviewTagMapping은 PlaceReview와 일대다 연관관계를 맺고, placeReviewTag와도 일대다 연관관계를 맺고 FetchType은 모두 LAZY로 설정합니다.

후기 리스트 로직 구현

이제 엔티티에 대한 설명은 모두 끝났으니 후기 리스트를 불러오는 api에서 서비스 로직을 구현할 차례입니다.

그리고 바로 여기서 1 + N + M 문제가 발생합니다.

먼저 후기 리스트는 현재 장소에 대한 후기를 모두 출력해주는 기능을 수행하는데 이 때, Paging 기능을 적용하여 한 페이지당 몇 개씩 출력할 지 정할 수 있습니다.

후기를 출력할 때, 총 후기 개수와 각 후기의 텍스트, 태그들, 마스킹된 회원 이름, 후기 작성일이 출력됩니다.(날짜를 기준으로 내림차순으로)

fun getPlaceReviews(
        placeId: String,
        size: Int?,
        page: Int?
    ): GetPlaceReviewsRes {

        //placeId를 통해 validation 체크 생략...


        //page와 size가 제대로 된 값이 들어왔는지 validation 체크
        if (page == null || size == null) {
             return GetPlaceReviewsRes(
                NO_PAGE_INFO
            )
        }

        var pageable = PageRequest.of(page, size)

        var placeReviewPages = repo.placeReviewRepository
                .findAllByPlaceIdOrderByCreateDateDesc(
                    placeId,
                    pageable
                )

        //it가 placeReview
        var reviewList = placeReviewPages.content
                .map {
                    var tagList = repo.placeReviewTagMappingRepository
                        .findAllByPlaceReview(
                            it
                        ).sortedBy { placeReviewTagMapping ->
                            placeReviewTagMapping.placeReviewTag.seq } //placeReviewTagMapping 리스트
                        .map { placeReviewTagMapping ->
                            placeReviewTagMapping.placeReviewTag.tag
                        } // placeReviewTagMapping의 placeReviewTag의 tag필드값 리스트

                    ReviewInfo(
                        comment = it.comment,
                        tagList = tagList,
                        maskedCustomerName = it.maskedCustomerName,
                        createDate = it.createDate.toString().replace(
-                            "T", " "
-                        )
                    )
                }

         return GetPlaceReviewsRes(
                code = OK,
                totalElements = placeReviewPages.totalElements.toInt(),
                reviewList = reviewList
            )
    }

findAllByPlaceIdOrderByCreateDateDesc 메소드 호출시 출력되는 쿼리문

select
        placerevie0_.place_review_id as place_re1_28_,
        placerevie0_.comment as comment2_28_,
        placerevie0_.create_date as create_d3_28_,
        placerevie0_.customer_id as customer4_28_,
        placerevie0_.masked_customer_name as masked_c5_28_,
        placerevie0_.place_id as place_id6_28_ 
from
        place_review placerevie0_ 
where
        placerevie0_.place_id=? 
order by
        placerevie0_.create_date 
desc limit ?


select
        count(placerevie0_.place_review_id) as col_0_0_ 
from
        place_review placerevie0_ 
where
        placerevie0_.place_id=? 

findAllByPlaceReview 메소드 호출시 출력되는 쿼리문

위의 findAllByPlaceIdOrderByCreateDateDesc로 인해 생성되는 placeReviewPages의 content는 placeReviewList입니다.

이를 위의 서비스로직처럼 map을 이용하면 placeReviewList의 size만큼 반복을 돌면서 findAllByPlaceReview 메소드를 호출하게 됩니다.

그래서 placeReviewList의 개수만큼(N) 추가적인 쿼리가 발생합니다.

예를 들어, 한 페이지당 10개씩 후기를 본다고 하면 10번의 쿼리가 발생하고, 100개씩 본다고 하면 100번의 쿼리가 발생하게 됨으로써 시간이 많이 소요됩니다.

findAllByPlaceReview 메소드 호출시 출력되는 쿼리문은 다음과 같습니다.

select
        placerevie0_.place_review_id as place_re1_32_,
        placerevie0_.place_review_tag_key as place_re2_32_ 
from
        pr_prt_mapping placerevie0_ 
where
        placerevie0_.place_review_id=?

-- ...(한 페이지당 후기 개수만큼 똑같은 형태의 쿼리문이 발생)

LazyInitializationException

findAllByPlaceReview로 반환되는 값은 placeReviewTagMapping 리스트입니다.

이 때 문제는 placeReviewTagMapping에서 placeReview와 placeReviewTag는 FetchType이 Lazy로 설정되어 있기 때문에 어떠한 정보도 저장되어 있지 않고 null입니다.

(필요할 때 repository를 이용해 값을 가져와야 합니다.)

그래서 아직 값을 안가져왔기 때문에 null인 상태에서 placeReviewTagMapping.placeReviewTag.seq로 바로 접근하기 때문에 LazyInitializationException이 발생합니다.

1 + N + M 문제 발생

LazyInitializationException을 해결하기 위해서 FetchType을 Eager로 설정할 수도 있고,(별로 추천하지는 않지만) 아니면 placeReviewTagMapping.placeReviewTag.seq을 사용하기 전에 먼저 placeReviewTagRepository에 접근하여 먼저 값을 가져옵니다.

그렇게 하면 LazyInitializationException은 해결할 수 있습니다.

이제 다시 위의 로직을 실행시키고, findAllByPlaceReview 메소드 호출하면 출력되는 쿼리문은 다음과 같습니다.

select
        placerevie0_.place_review_id as place_re1_32_,
        placerevie0_.place_review_tag_key as place_re2_32_ 
from
        pr_prt_mapping placerevie0_ 
where
        placerevie0_.place_review_id=?


select
        placerevie0_.place_review_tag_key as place_re1_29_0_,
        placerevie0_.tag as tag2_29_0_,
        placerevie0_.tag_value as tag_valu3_29_0_ 
from
        place_review_tag placerevie0_ 
where
        placerevie0_.place_review_tag_key=?



select
        placerevie0_.place_review_tag_key as place_re1_29_0_,
        placerevie0_.tag as tag2_29_0_,
        placerevie0_.tag_value as tag_valu3_29_0_ 
from
        place_review_tag placerevie0_ 
where
        placerevie0_.place_review_tag_key=?

select
        placerevie0_.place_review_tag_key as place_re1_29_0_,
        placerevie0_.tag as tag2_29_0_,
        placerevie0_.tag_value as tag_valu3_29_0_ 
from
        place_review_tag placerevie0_ 
where
        placerevie0_.place_review_tag_key=?

-- ... (placeReview를 작성할 때 회원이 선택한 태그개수만큼 쿼리 발생)

현재 N(한 페이지당 출력되는 후기 개수)만큼 쿼리가 출력되는 문제가 있었습니다.

근데 이제는 더 나아가 각 후기당 회원이 선택한 태그개수(M) 만큼 추가적으로 쿼리가 발생합니다.

즉, 어떤 후기는 태그가 10개일 수도 있고, 어떤 후기는 태그가 3개일 수도 있습니다.

LazyInitializationException은 해결했지만 한 후기당 선택된 태그만큼 추가적인 쿼리가 발생하게 되면서 N + M 문제가 발생합니다.

1 + N + M 문제 해결

이 1 + N + M 문제를 해결하기 위해 findAllByPlaceReview의 메소드에 EntityGraph를 적용합니다.

interface PlaceReviewTagMappingRepository : JpaRepository<PlaceReviewTagMapping, String> {

    @EntityGraph(attributePaths = ["placeReviewTag"])
    fun findAllByPlaceReview(placeReview: PlaceReview): List<PlaceReviewTagMapping>

}

이 후 다시 위의 로직을 수행하면 findAllByPlaceReview 메소드 호출시 출력되는 쿼리문은 다음과 같습니다.

select
        placerevie0_.place_review_id as place_re1_32_0_,
        placerevie0_.place_review_tag_key as place_re2_32_0_,
        placerevie1_.place_review_tag_key as place_re1_29_1_,
        placerevie1_.tag as tag2_29_1_,
        placerevie1_.tag_value as tag_valu3_29_1_ 
from
        pr_prt_mapping placerevie0_ 
left outer join
        place_review_tag placerevie1_ 
on
	    placerevie0_.place_review_tag_key=placerevie1_.place_review_tag_key 
where
        placerevie0_.place_review_id=?

--...(이와 똑같은 형태의 쿼리문이 N개 발생)

쿼리 결과를 보면 알수 있듯이 N(한 페이지당 출력되는 후기 개수) + M(각 후기당 회원이 선택한 태그개수)에서 M(각 후기당 회원이 선택한 태그개수)만큼 출력되는 쿼리를 없앨 수 있었습니다.

EntityGraph를 통해 PlaceReviewTagMapping을 가져올 때 한 번에 각 후기당 회원이 선택한 태그정보들을 모두 가져옴으로써 M문제를 해결하였습니다.

1 + N 문제 해결

그러나 추가적으로 N(한 페이지당 출력되는 후기 개수)를 해결하기 위해서는 서비스 구현에서 로직수정이 불가피합니다.

왜냐하면 현재 로직에서는 placeReviewPages.content(placeReviewList)에서 map을 사용하고 있기 때문에 N(한 페이지당 출력되는 후기 개수)문제가 발생하기 때문입니다.

이를 해결하기 위해서 map으로 돌지 않고, 한방 쿼리로 한 페이지에 출력되는 모든 필요한 정보를 가져와야 합니다.

우선 PlaceReviewTagMappingRepository에서 findAllByPlaceReviewIn 메소드를 새로 정의하고 “placeReviewTagMapping”, “placeReview”까지 EntityGraph를 설정합니다.

interface PlaceReviewTagMappingRepository : JpaRepository<PlaceReviewTagMapping, String> {

    @EntityGraph(attributePaths = ["placeReviewTag", "placeReview"])
    fun findAllByPlaceReviewIn(placeReviews: List<PlaceReview>): List<PlaceReviewTagMapping>

}

그리고 서비스 로직을 수정합니다.

fun getPlaceReviews(
        placeId: String,
        size: Int?,
        page: Int?
    ): GetPlaceReviewsRes {

        //placeId를 통해 validation 체크 생략...


        //page와 size가 제대로 된 값이 들어왔는지 validation 체크
        if (page == null || size == null) {
             return GetPlaceReviewsRes(
                NO_PAGE_INFO
            )
        }

        var pageable = PageRequest.of(page, size)

        var placeReviewPages = repo.placeReviewRepository
                .findAllByPlaceIdOrderByCreateDateDesc(
                    placeId,
                    pageable
                )// 여기서 CreateDate의 내림차순에 맞춰 정렬하고 현재 page의 size 개수만큼 placeReview들을 가져옴.

        var allReviewsTagListMap = repo.placeReviewTagMappingRepository
                .findAllByPlaceReviewIn(placeReviewPages.content)
                .groupBy { it.placeReview } //Map<PlaceReview, List<PlaceReviewTagMapping>>이 반환값
                //groupBy하면서  CreateDate의 내림차순에 맞춰 정렬한 것이 무너짐.

        //it는 PlaceReview, it.value는 List<PlaceReviewTagMapping>임
        var reviewList = allReviewsTagListMap.map {
            val tagList = it.value.sortedBy { placeReviewTagMapping ->
+                        placeReviewTagMapping.placeReviewTag.seq }

             ReviewInfo(
-                        comment = it.comment,
-                        tagList = tagList,
-                        maskedCustomerName = it.maskedCustomerName,
-                        createDate = it.createDate.toString().replace(
-                            "T", " "
-                        )
-                    )
        }.sortedByDescending { it.createDate }//groupBy하면서  CreateDate의 내림차순에 맞춰 정렬한 것이 무너진 것을 다시 정렬함.

        return GetPlaceReviewsRes(
                code = OK,
                totalElements = placeReviewPages.totalElements.toInt(),
                reviewList = reviewList
            )
    }

새로 정의한 findAllByPlaceReviewIn을 호출하여 현재 페이지에서 필요한 모든 PlaceReviewTagMapping을 가져옵니다.

다음으로 groupBy를 사용해 PlaceReviewTagMapping의 멤버인 PlaceReview를 Key로 하고, Value를 List<PlaceReviewTagMapping>으로 하는 Map을 만듭니다.

allReviewsTagListMap에서 map을 사용해 placeReviewTagMapping.placeReviewTag.seq을 기준으로 정렬한 tagList를 구해 ReviewInfo를 만들어 주고, 최종적으로 reviewList를 생성합니다.

이 때 이 reviewList는 allReviewsTagListMap를 만들면서 날짜기준 정렬이 무너져서 날짜기준으로 내림차순으로 다시 정렬을 해줍니다.

이제 이 로직을 수행할 때 한 번 호출되는 findAllByPlaceReviewIn 메소드 호출 시 출력되는 쿼리문을 확인해봅니다.pageSize 10개일 경우 예시)

select
        placerevie0_.place_review_id as place_re1_32_0_,
        placerevie0_.place_review_tag_key as place_re2_32_0_,
        placerevie1_.place_review_id as place_re1_28_1_,
        placerevie2_.place_review_tag_key as place_re1_29_2_,
        placerevie1_.comment as comment2_28_1_,
        placerevie1_.create_date as create_d3_28_1_,
        placerevie1_.customer_id as customer4_28_1_,
        placerevie1_.masked_customer_name as masked_c5_28_1_,
        placerevie1_.place_id as place_id6_28_1_,
        placerevie2_.seq as seq2_29_2_,
        placerevie2_.tag as tag3_29_2_,
        placerevie2_.tag_value as tag_valu4_29_2_ 
from
        pr_prt_mapping placerevie0_ 
left outer join
        place_review placerevie1_ 
on
		placerevie0_.place_review_id=placerevie1_.place_review_id 
left outer join
        place_review_tag placerevie2_ 
on 
		placerevie0_.place_review_tag_key=placerevie2_.place_review_tag_key 
where
        placerevie0_.place_review_id in (
            ? , ? , ? , ? , ? , ? , ? , ? , ? , ?
        )

in절에 10개가 들어감으로써 원래는 쿼리문이 10(N)개 나가야할 것이 1번의 쿼리로 해결되었습니다.

in절에는 데이터베이스의 종류마다 차이는 있으나 데이터베이스와 상관없이 1000개까지는 들어갈 수 있습니다.

그래서 pageSize가 100개일 경우 원래는 100번의 쿼리가 나가야하지만 이렇게 되면 1번의 쿼리로 모든 정보를 가져올 수 있기 때문에 1 + N 문제를 해결할 수 있습니다.

더 나아가기

여기서 하나 아쉬운 점이 기존에 날자를 기준으로 정렬을 한 번 했음에도 불구하고, groupBy를 통해 날짜기준 정렬이 무너져서 다시 날짜를 기준으로(sortedByDescending { it.createDate }) 정렬한다는 점입니다.

여기서 이 정렬하는 시간 낭비를 없애기 위해서 로직을 다시 수정합니다.

fun getPlaceReviews(
        placeId: String,
        size: Int?,
        page: Int?
    ): GetPlaceReviewsRes {

        //placeId를 통해 validation 체크 생략...


        //page와 size가 제대로 된 값이 들어왔는지 validation 체크
        if (page == null || size == null) {
             return GetPlaceReviewsRes(
                NO_PAGE_INFO
            )
        }

        var pageable = PageRequest.of(page, size)

        var placeReviewPages = repo.placeReviewRepository
                .findAllByPlaceIdOrderByCreateDateDesc(
                    placeId,
                    pageable
                )// 여기서 CreateDate의 내림차순에 맞춰 정렬하고 현재 page의 size 개수만큼 placeReview들을 가져옴.

        var allReviewsTagListMap = repo.placeReviewTagMappingRepository
                .findAllByPlaceReviewIn(placeReviewPages.content)
                .groupBy { it.placeReview.id } //Map<String(placeId), List<PlaceReviewTagMapping>>이 반환값
                //groupBy하면서  CreateDate의 내림차순에 맞춰 정렬한 것이 무너짐.

        //it는 placeReview
         var reviewList = placeReviewPages.content.map { //placeReviewPages.content는 날짜를 기준으로 정렬되어 있음
                val mappingList = allReviewsTagListMap[it.id]// List<PlaceReviewTagMapping>가 반환값
                    ?: return GetPlaceReviewsRes(
                    PLACE_REVIEW_NOT_FOUND
                )

                val tagList = mappingList
                    .sortedBy { placeReviewTagMapping ->
                        placeReviewTagMapping.placeReviewTag.seq }
                    .map { placeReviewTagMapping ->
                        placeReviewTagMapping.placeReviewTag.tag
                    }

                ReviewInfo(
                    comment = it.comment,
                    tagList = tagList,
                    maskedCustomerName = it.maskedCustomerName,
                   createDate = it.createDate.toString().replace(
-                            "T", " "
-                        )
                )
            }

        return GetPlaceReviewsRes(
                code = OK,
                totalElements = placeReviewPages.totalElements.toInt(),
                reviewList = reviewList
            )
    }

이렇게 날짜를 기준으로 정렬되어 있는 placeReviewPages.content를 재활용하면 groupBy로 무너진 날짜정렬을 다시 한 번 할 필요가 없어지므로 시간 낭비를 줄일 수 있습니다.

이 때 주의할 점은 groupBy시 기존에는 placeReview가 기준이었지만 placeReview.id로 바꿔줘야 한다는 점입니다.

왜냐하면 placeReview.id 대신 placeReview일 경우 allReviewsTagListMap[placeReview] 객체를 넣을 경우 객체의 참조값이 서로 다르기 때문에 allReviewsTagListMap[placeReview]의 값이 null이 나오기 때문입니다.

비록 findAllByPlaceIdOrderByCreateDateDesc 메소드에서 생성되는 placeReview 객체와 findAllByPlaceReviewIn.groupBy(placeReview)를 통해 생성되는 placeReview 객체의 내용은 완전히 동일하지만

findAllByPlaceIdOrderByCreateDateDesc 메소드에서 생성되는 placeReview 객체의 참조값과 findAllByPlaceReviewIn.groupBy(placeReview)를 통해 생성되는 placeReview 객체의 참조값은 서로 다릅니다.

그래서 findAllByPlaceReviewIn.groupBy를 할 때, 유일한 값인 placeReview.Id를 통해 groupBy해주고, allReviewsTagListMap[placeReview.id]를 넣으면 null이 아닌 원하는 값인 PlaceReviewTagMappingList를 구할 수 있습니다.

댓글남기기