본문 바로가기

업브렐라

협업지점 조회 성능 개선, N+1 해결

1. 문제 정의

기존 코드는 개발 일정을 맞추기 위해 성능의 이슈를 감안하더라도 기능 완성에 초점을 두었습니다. 또한 프론트엔드와의 개발 일정이 맞지 않아 기능을 개발한 후 바로 최적화할 수 없는 상황이었습니다.

현재는 양 측이 모두 개발이 완료된 상황이라 코드를 분석해보니 많은 문제점을 발견할 수 있었습니다.

기존 코드

    public List<StoreDetail> findAllStores() {

        return queryFactory
                .selectFrom(storeDetail)
                .join(storeDetail.storeMeta, storeMeta).fetchJoin()
                .join(storeMeta.classification, classification).fetchJoin()
                .join(storeMeta.subClassification, classification).fetchJoin()
                .leftJoin(storeMeta.businessHours, businessHour).fetchJoin()
                .leftJoin(storeDetail.storeImages, storeImage).fetchJoin()
                .where(storeMeta.deleted.isFalse())
                .distinct()
                .fetch();
    }

위 코드에서 가장 큰 문제점은 필요하지 않은 StoreImage와 BusinessHour를 조회한다는 것입니다.

기획서가 나올 당시, 협업지점 관리 페이지 초기 화면에서 images 전부와, businessHours 전부를 조회하기로 하였지만, 개발을 이어나가다 보니 기획이 변경되었고 두 개의 List들은 더이상 필요없는 상황이 되었습니다.

2. 성능개선 V1

image

위와 같이, 이미지와 영업시간은 따로 클릭을 해야 조회가 되기 때문에, 수많은 협업지점들에 대해 데이터가 조회될 필요가 없었습니다.

따라서 다음과 같이 코드를 개선하였습니다.

    public List<StoreDetail> findAllStores() {

        return queryFactory
                .selectFrom(storeDetail)
                .join(storeDetail.storeMeta, storeMeta)
                .join(storeMeta.classification, classification)
                .join(storeMeta.subClassification, classification)
                .where(storeMeta.deleted.isFalse())
                .fetch();
    }

하지만 여전히 코드상 문제가 남아있습니다.

여러 엔티티들간의 연관관계로 인해 23개나 되는 N+1 문제가 발생했습니다.

이를 해결하기 위해 두 가지 방법을 고민했습니다.

3. 성능개선 V2

FetchJoin

FetchJoin을 활용해서 연관된 데이터를 전부 가져오는 방법을 사용해보겠습니다.

    public List<StoreDetail> findAllStores() {

        return queryFactory
                .selectFrom(storeDetail)
                .join(storeDetail.storeMeta, storeMeta).fetchJoin()
                .join(storeMeta.classification, classification).fetchJoin()
                .join(storeMeta.subClassification, classification).fetchJoin()
                .where(storeMeta.deleted.isFalse())
                .fetch();
    }

FetchJoin을 통해 데이터를 가져오니 N+1 문제가 해결되었지만, 여전히 두 가지의 문제점이 남아있었습니다.

페이징이 불가하다는 점과, 필요한 필드수에 비해 너무 많은 필드가 조회된다는 것이었습니다.

페이징만 처리하기 위해서는 BatchSize를 조절해서 문제를 해결하도록 시도해볼 수 있겠으나, 현재는 페이징과 필요한 필드만 조회해야 하기 때문에 DTO를 조회하는 방법을 사용해보도록 하겠습니다.

4. 성능개선 V3

@Override
    public List<SingleStoreResponse> findAllStoresForAdmin() {

        return queryFactory
                .select(new QSingleStoreResponse(
                        storeDetail.storeMeta.id,
                        storeDetail.storeMeta.name,
                        storeDetail.storeMeta.category,
                        new QSingleClassificationResponse(
                                storeDetail.storeMeta.classification.id,
                                storeDetail.storeMeta.classification.type,
                                storeDetail.storeMeta.classification.name,
                                storeDetail.storeMeta.classification.latitude,
                                storeDetail.storeMeta.classification.longitude
                        ),
                        new QSingleSubClassificationResponse(
                                storeDetail.storeMeta.subClassification.id,
                                storeDetail.storeMeta.subClassification.type,
                                storeDetail.storeMeta.subClassification.name
                        ),
                        storeDetail.storeMeta.activated,
                        storeDetail.address,
                        storeDetail.addressDetail,
                        storeDetail.umbrellaLocation,
                        storeDetail.workingHour,
                        storeDetail.contactInfo,
                        storeDetail.instaUrl,
                        storeDetail.storeMeta.latitude,
                        storeDetail.storeMeta.longitude,
                        storeDetail.content,
                        storeDetail.storeMeta.password
                ))
                .from(storeDetail)
                .join(storeDetail.storeMeta, storeMeta)
                .join(storeMeta.classification, classification)
                .join(storeMeta.subClassification, classification)
                .where(storeMeta.deleted.isFalse())
                .fetch();
    }

일반적으로 JPA(Java Persistence API)는 연관 엔터티를 실제로 사용하는 시점에 데이터베이스에서 Entity를 로드(Lazy Loading)합니다. 그러나 이 코드에서는 실제 Entity를 로드하는 것이 아니라, 쿼리 실행 결과를 바로 DTO로 변환하기 때문에 Lazy Loading이 발생하지 않고, 따라서 N+1 문제도 발생하지 않습니다.

이렇게 문제를 해결함으로써, 추후에 협업지점이 많아질 경우에 페이징 처리를 할 수 있고 성능개선도 이뤄낼 수 있었습니다.

4. 개선 전 후 성능 비교

개선 전

select
        distinct storedetai0_.id as id1_6_0_,
        storemeta1_.id as id1_8_1_,
        classifica2_.id as id1_2_2_,
        classifica3_.id as id1_2_3_,
        storedetai0_.address as address2_6_0_,
        storedetai0_.address_detail as address_3_6_0_,
        storedetai0_.contact_info as contact_4_6_0_,
        storedetai0_.content as content5_6_0_,
        storedetai0_.insta_url as insta_ur6_6_0_,
        storedetai0_.store_meta_id as store_me9_6_0_,
        storedetai0_.umbrella_location as umbrella7_6_0_,
        storedetai0_.working_hour as working_8_6_0_,
        storemeta1_.activated as activate2_8_1_,
        storemeta1_.category as category3_8_1_,
        storemeta1_.classification_id as classifi9_8_1_,
        storemeta1_.deleted as deleted4_8_1_,
        storemeta1_.latitude as latitude5_8_1_,
        storemeta1_.longitude as longitud6_8_1_,
        storemeta1_.name as name7_8_1_,
        storemeta1_.password as password8_8_1_,
        storemeta1_.sub_classification_id as sub_cla10_8_1_,
        classifica2_.latitude as latitude2_2_2_,
        classifica2_.longitude as longitud3_2_2_,
        classifica2_.name as name4_2_2_,
        classifica2_.type as type5_2_2_,
        classifica3_.latitude as latitude2_2_3_,
        classifica3_.longitude as longitud3_2_3_,
        classifica3_.name as name4_2_3_,
        classifica3_.type as type5_2_3_ 
    from
        store_detail storedetai0_ 
    inner join
        store_meta storemeta1_ 
            on storedetai0_.store_meta_id=storemeta1_.id 
    inner join
        classification classifica2_ 
            on storemeta1_.classification_id=classifica2_.id 
    inner join
        classification classifica3_ 
            on storemeta1_.sub_classification_id=classifica3_.id 
    where
        storemeta1_.deleted=?

필요한 필드는 22개인데 비해 29개의 필드를 조회하고 있습니다.

위의 쿼리의 평균 실행 속도는 0.4s 입니다.

개선 후

select
        storedetai0_.store_meta_id as col_0_0_,
        storemeta1_.name as col_1_0_,
        storemeta1_.category as col_2_0_,
        storemeta1_.classification_id as col_3_0_,
        classifica8_.type as col_4_0_,
        classifica8_.name as col_5_0_,
        classifica8_.latitude as col_6_0_,
        classifica8_.longitude as col_7_0_,
        storemeta1_.sub_classification_id as col_8_0_,
        classifica17_.type as col_9_0_,
        classifica17_.name as col_10_0_,
        storemeta1_.activated as col_11_0_,
        storedetai0_.address as col_12_0_,
        storedetai0_.address_detail as col_13_0_,
        storedetai0_.umbrella_location as col_14_0_,
        storedetai0_.working_hour as col_15_0_,
        storedetai0_.contact_info as col_16_0_,
        storedetai0_.insta_url as col_17_0_,
        storemeta1_.latitude as col_18_0_,
        storemeta1_.longitude as col_19_0_,
        storedetai0_.content as col_20_0_,
        storemeta1_.password as col_21_0_ 
    from
        store_detail storedetai0_ 
    inner join
        store_meta storemeta1_ 
            on storedetai0_.store_meta_id=storemeta1_.id 
    inner join
        classification classifica2_ 
            on storemeta1_.classification_id=classifica2_.id 
    inner join
        classification classifica3_ 
            on storemeta1_.sub_classification_id=classifica3_.id cross 
    join
        classification classifica8_ cross 
    join
        classification classifica17_ 
    where
        storemeta1_.classification_id=classifica8_.id 
        and storemeta1_.sub_classification_id=classifica17_.id 
        and storemeta1_.deleted=?

위 쿼리는 필요한 필드만 직접 조회하여 N+1 문제를 해결하고 쿼리의 성능을 향상시켰습니다.

쿼리 평균 실행 속도는 0.08s 입니다.

결과적으로 쿼리 실행속도를 0.4s 에서 0.08s로 80%의 성능 개선을 이뤄냈습니다.

이 포스팅은 제가 작성한 UPbrella 프로젝트의 기술 블로그에 작성한 협업지점 조회 성능 개선 포스팅을 옮겨온 것입니다.

'업브렐라' 카테고리의 다른 글

Upbrella 버그 수정기  (0) 2023.12.19
안전하게 비밀번호 생성하기 - HOTP  (0) 2023.10.31
캐시를 통한 성능 최적화  (0) 2023.09.25
Loki를 통한 로그 모니터링  (0) 2023.09.24
nGrinder 자동화  (0) 2023.09.24