본문 바로가기

백엔드

RealEstate 도메인 API 코드 개선 및 쿼리 최적화 - 집콕 서버 리팩토링(1)

 

서론

이건 2024 상반기 학기 중에 진행했던 내용이다. 

 

저번 포스팅에 정리했듯이 학기 중에는 각자 할 일이 많아서 크게 진도 나가지 못했다. 학기 중에는 nGrinder 환경 세팅을 좀 하고 RealEstate 도메인 API 개선을 좀 했다. 

 

3개의 큰 도메인 중 (RealEstate, User, Kok) 왜 RealEstate를 먼저 했냐면 메인 화면의 지도에서 보여지는 매물을 조회하는 API 가 있어서 중요하다고 판단했고, 해당 API 에서 발생되는 쿼리가 302개나 되어서 가장 먼저 해보기로 했다. (요청 하나 당 쿼리가 302개면 이 API는 쓸모없게 되는거 아닌가...??)

 

 

개선 전

 

 

우리가 살펴본 API는 메인 화면 지도에서 보여질 매물을 반환하는 API이다. 메인화면 지도가 줌 인/아웃 될 때마다, 혹은 이동될 때마다 해당 API 가 호출 되는 방식이다.

 

API 명세서 내용이다.

 

클라이언트는 화면에서 보여지는 지도의 우측 상단의 모서리, 좌측 하단의 모서리의 위도 경도 값을 query parameter 로 보내주면 서버에서는 해당 좌표 내부에 포함되는 매물을 조회해서 정보를 반환하는 방식의 API이다. 

이 API는 로그인 된 사용자와 로그인 되지 않은 사용자를 분리해서 filtering 을 다르게 적용하는데, 로그인 되지 않은 사용자의 경우에는 발생 query가 합리적이어서 따로 리팩토링 하지 않았다. 

 

문제는 로그인 된 사용자의 경우에 발생했다. 아래는 로그인된 사용자의 경우에 작동하는 코드이다. 서버는 Java Spring Boot을 사용했다. 

List<RealEstate> realEstateList = this.realEstateRepository.findByLatitudeBetweenAndLongitudeBetween(getRealEstateOnMapRequest.getSouthWestLat(),getRealEstateOnMapRequest.getNorthEastLat(),getRealEstateOnMapRequest.getSouthWestLon(),getRealEstateOnMapRequest.getNorthEastLon());
User user = this.userRepository.findByUserId(userId);

if(realEstateList == null){
    throw new RealEstateException(PROPERTY_NOT_FOUND);
}

TransactionType userTransactionType;
RealEstateType userRealEstateType;

GetLoginMapRealEstateResponse getLoginMapRealEstateResponse = new GetLoginMapRealEstateResponse();

userTransactionType = user.getTransactionType();
userRealEstateType = user.getRealEstateType();

//filter 정보 mapping
getLoginMapRealEstateResponse.setFilter(GetLoginMapRealEstateResponse.Filter.builder()
        .transactionType(userTransactionType)
        .realEstateType(userRealEstateType)
        .mdepositMin(user.getTransactionPriceConfig().getMDepositMin())
        .mdepositMax(user.getTransactionPriceConfig().getMDepositMax())
        .mpriceMin(user.getTransactionPriceConfig().getMPriceMin())
        .mpriceMax(user.getTransactionPriceConfig().getMPriceMax())
        .ydepositMin(user.getTransactionPriceConfig().getYDepositMin())
        .ydepositMax(user.getTransactionPriceConfig().getYDepositMax())
        .purchaseMin(user.getTransactionPriceConfig().getPurchaseMin())
        .purchaseMax(user.getTransactionPriceConfig().getPurchaseMax())
        .build()
);

if(userTransactionType != null && userRealEstateType != null){
    realEstateList = realEstateList
            .stream()
            .filter(result -> result.getTransactionType().equals(userTransactionType) && result.getRealEstateType().equals(userRealEstateType))
            .filter(result -> filterPriceConfig(result, getLoginMapRealEstateResponse.getFilter(), true))
            .toList();
}

//realEstateInfo mapping
List<GetLoginMapRealEstateResponse.RealEstateInfo> realEstateInfoList = realEstateList
        .stream()
        .filter(result -> result.getUser() == null || result.getUser().getUserId().equals(userId))
        .map(result -> GetLoginMapRealEstateResponse.RealEstateInfo.builder()
                .realEstateId(result.getRealEstateId())
                .imageURL(result.getImageUrl())
                .deposit(result.getDeposit())
                .price(result.getPrice())
                .transactionType(result.getTransactionType().toString())
                .realEstateType(result.getRealEstateType().toString())
                .address(result.getAddress())
                .detailAddress(result.getDetailAddress())
                .latitude(result.getLatitude())
                .longitude(result.getLongitude())
                .agent(result.getAgent() == null ? "직접 등록한 매물" : result.getAgent())
                .isZimmed(this.zimRepository.findFirstByUserAndRealEstate(user, result) != null)
                .isKokked(this.kokRepository.findFirstByUserAndRealEstate(user, result) != null)
                .build())
        .collect(Collectors.toList());

getLoginMapRealEstateResponse.setRealEstateInfoList(realEstateInfoList);
return getLoginMapRealEstateResponse;

 

이 코드를 한 번 호출할 때마다 302개의 쿼리가 발생되었고, 이 API는 메인화면에서 지도의 상태가 변화할 때마다 호출된다. 즉 빈번하게 호출되는 가장 중요한 메인 API인데 호출 시마다 어마어마한 쿼리가 발생되는 것이다. 

이 API에 대해서 쿼리 최적화를 해보기로 했다. 코드에 대한 리팩토링은 나중에 하고 우선 쿼리 최적화를 우선적으로 진행하기로 했다. 이 포스팅에서도 코드 리팩토링은 다루지 않는다. 

 

전체적인 구조를 간략하게 설명해보면

인자로 들어온 북동쪽, 남서쪽 위도, 경도 정보를 토대로 RealEstateRepository에서 between 구문을 통해 해당 범위에 포함되는 매물을 realEstateList 변수에 List 형태로 가져온다. 여기서 쿼리가 한 번 (1️⃣) 발생한다.

그리고 인자로 들어온 userId 값을 통해 user를 조회하는 코드가 있는데 이건 Jwt 인증/인가 할 때 interceptor에서 한 번 조회를 하기 때문에 영속성 컨텍스트에 남아있어서 위 코드에서는 따로 쿼리가 발생하지 않는다. 전체적으로 보면 하나 발생하긴 한다. (1️⃣)

가져온 user 의 필터링 정보를 DTO에 mapping 하고, realEstateList 를 stream으로 만들어서 filter를 적용하고 DTO에 들어갈 클래스로 map 시켜서 바꾸는 것이다. 

 

문제는 마지막 realEstateList 를 stream으로 펴서, filter 적용하고 map 으로 DTO에 들어갈 클래스 리스트로 만드는 과정에서 300개 의 쿼리가 발생한 것이다. (3️⃣0️⃣0️⃣) 

문제의 코드...

 

map 부분에서 리스트의 요소를 GetLoginMapRealEstateResponse.RealEstateInfo 클래스의 빌더 패턴을 활용할 때 문제가 생겼다. 발생되는 300개의 쿼리가 모두 Zim, Kok table에 대해서 발생하는 것을 보아 위의 코드에서 isZimmed, isKokked 를 판단할 때 문제가 생긴 것이다.

 

저 코드를 작성할 당시에는 발생되는 쿼리를 생각하지 않고 기능 구현만을 우선적으로 했기 때문에 발견하지 못했지만 쿼리를 신경쓰게 되고 나니 쉽게 보이는 문제였다. 저렇게 stream 안에서 repository에 접근하는 코드를 작성하게 되면, stream으로 펴진 list의 요소 개수만큼 쿼리가 발생하게 되는 것이다. 

조회한 매물의 개수가 150개 였기 때문에 (=realEstateList 의 요소가 150개 였기 때문에) 각 매물 당 zim, kok 에 대해서 쿼리가 두 번 발생하게 되었고, 그래서 150 x 2 = 300개의 쿼리가 발생한 것이다. 

 

 

개선 후

//realEstateInfo mapping
List<GetLoginMapRealEstateResponse.RealEstateInfo> realEstateInfoList = realEstateList
        .stream()
        .filter(result -> result.getUserId() == null || result.getUserId().equals(userId))
        .map(result -> GetLoginMapRealEstateResponse.RealEstateInfo.builder()
                .realEstateId(result.getRealEstateId())
                .imageURL(result.getImageUrl())
                .deposit(result.getDeposit())
                .price(result.getPrice())
                .transactionType(result.getTransactionType().toString())
                .realEstateType(result.getRealEstateType().toString())
                .address(result.getAddress())
                .detailAddress(result.getDetailAddress())
                .latitude(result.getLatitude())
                .longitude(result.getLongitude())
                .agent(result.getAgent() == null ? "직접 등록한 매물" : result.getAgent())
                .isZimmed(zimmedRealEstates.contains(result))
                .isKokked(kokkedRealEstates.contains(result))
                .build())
        .collect(Collectors.toList());

 

개선된 부분을 보면 isZimmed와 isKokked 를 판단하는 부분에서 Repository에 접근하는 코드가 아니라 zimmedRealEstates와 kokkedRealEstates를 접근해서 판단한다. 

 

아래는 zimmedRealEstates와 kokkedRealEstates 를 선언하는 코드이다. 

 

User user = this.userRepository.findByUserId(userId);

Set<RealEstate> zimmedRealEstates = user.getZims().stream()
        .map(Zim::getRealEstate)
        .collect(Collectors.toSet());

Set<RealEstate> kokkedRealEstates = user.getKoks().stream()
        .map(Kok::getRealEstate)
        .collect(Collectors.toSet());

 

User 를 조회할 때 연관관계로 zim과 kok을 가지고 있기 때문에 이를 이용했다. zim과 kok이 각각 user와 다대일 관계를 가지고 있어서 user 를 조회할 때 zim과 kok은 defualt로 Lazy 로딩이다. 

User를 조회할 때 zim과 kok을 한 번에 fetch join해서 불러오면 쿼리 하나로 user, zim, kok을 불러올 수 있을 것 같아서 @EntityGraph 어노테이션을 사용해서 User를 조회할 때 zim과 kok을 fetch join 해서 가져 왔다. 

 

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    User findByEmail(String userEmail);

    @EntityGraph(attributePaths = {"zims", "koks"})
    User findByUserId(long userId);
}

 

근데 문제가 하나 있었다. 원래 코드에서 User는 zim과 kok을 List 형태로 가지고 있었다. 그래서 fetch join을 하게 되면 zim은 join하지만 kok에 대한 쿼리가 따로 날라가거나, kok은 join하지만 zim은 쿼리가 따로 날라가게 되었다. 만약 두 개를 동시에 fetch join 하려고 하게 되면 MultipleBagFetchException 이 발생하게 된다.

이는 Hybernate에서 OneToMany 관계인 table에 대해서 두 개 이상 fetch join하려고 할 때 발생시키는 Exception으로, 너무 많은 fetch join으로 인해 OOM (Out Of Memory)이 발생할 것을 우려해서 미리 막는 것이다. 

 

이를 해결할 수 있는 방법은 여러가지가 있을 텐데 우리는 User가 zim 과 kok 을 List 형태가 아닌 Set으로 가지고 있게 해서 User를 조회할 때 zim 과 kok, 두 개 이상의 table을 fetch join 할 수 있도록 했다.

List로 굳이 가지고 있을 필요가 없는 이유는 위 코드에서는 zim과 kok의 유무가 중요한 것이지, zim과 kok의 순서가 중요하거나 중복을 허용할 필요가 전혀 없기 때문에 Set으로 변경해서 해결했다. 

 

그랬더니 발생 쿼리가 302개에서 2개로 줄였고, zim과 kok 의 유무 판단은 O(N) 에서 O(1) 로 바꾸었기 때문에 (= List에서 Set으로 바꾸었기 때문에 ) 속도도 더 빨라졌을 것이라 기대했다. 

 

성능 차이 

nGrinder로 TPS를 직접 측정해보았다. 

 

Spec은 다음과 같다

Agent 1
Vuser per agent 3000
Duration 5 minutes

 

개선 전 TPS

개선 전 TPS

 

개선 후 TPS

개선 후 TPS

 

 

쿼리 최적화를 했더니 TPS 가 26.7 에서 81.8 로 늘어났음을 확인할 수 있다.

 

 

결론

쿼리 최적화를 했더니 TPS가 늘어났다. 확실히 우리가 하는 스터디가 무의미하지 않다는 것이 증명되어서 나름 뿌듯했고 첫 발자국을 내딛은 기분이다. 물론 학기 중에 했던 것이고 포스팅은 시간이 좀 지나고 하는 것이지만 앞으로 진행사항을 계속 포스팅 해보도록 하겠다. 

 

학기 중에 나갔던 진도는 여기까지이다. 나름 N+1 공부도 했고 Hybernate가 쿼리를 생성하는 방식도 좀 익혔다. nGrinder 환경 세팅이 좀 애먹었었는데 나름 잘 해결되어서 매우 다행이라고 생각한다.