내 Query에 물음표를 달아보자

chloe·2023년 4월 26일
post-thumbnail

들어가기에 앞서

비지니스 로직을 작성하며 영속성, JOIN, N+1 등 다양한 문제들을 고민하며 작성했지만, 프로젝트 후반부가 되면서 전보다 인사이트가 조금 생겼다.

작성한 코드들을 다시 셀프 리뷰 하면서 더 좋은 방향에 대해 고민하고, 궁금했지만 넘어갔던 것들에 대해 다시 짚어보려고한다.

프로젝트 Github


공모 상세 정보 가져오기

  • service : getDetail

    public DetailCahootsDto getDetail(Long cahootsId, Long userId) {
        DetailCahootsDto detailCahootsDto = vacationRepository.getVacationDetail(cahootsId).checkNull();
        List<Interest> interests = interestRepository.findByVacation(vacationRepository.getReferenceById(cahootsId));
        detailCahootsDto.setInterestCount(interests.size());
        Boolean isInterest = (userId != null ? interests.stream().map(Interest::getUser).map(User::getId).anyMatch(id -> id.equals(userId)) : false);
        detailCahootsDto.setIsInterest(isInterest);
        detailCahootsDto.setImages(getImageUrls(cahootsId));
        return detailCahootsDto;
    }
  • repository

    • vacationRepository.getVacationDetail(cahootsId);
    • interestRepository.findByVacation(vacationRepository.getReferenceById(cahootsId));
    • pictureRepository.findUrlsByCahootsId(id);
  • 질문해보기

    • 실제로 쿼리는 총 몇개가 던져 졌는가?

      • 결론 : 위 코드에서 직관적으로 보이는 3개의 조회가 발생했고, 부수적인 쿼리는 없었다.
      • SQL Log
        Hibernate: 
            /* select
                vacation.id,
                vacation.title,
                vacation.location,
                vacation.country,
                vacation.status,
                vacation.theme.themeLocation,
                vacation.theme.themeBuilding,
                vacation.plan.expectedTotalCost,
                vacation.plan.expectedMonth,
                vacation.shortDescription,
                vacation.description,
                vacation.expectedRateOfReturn,
                vacation.stock.price as stockPrice,
                vacation.stock.num as stockNum,
                vacation.stockPeriod.start as stockStart,
                vacation.stockPeriod.end as stockEnd,
                (coalesce(sum(contestParticipation.stocks),
                ?1) * ?2 / vacation.stock.num) as competitionRate 
            from
                Vacation vacation   
            left join
                vacation.historyList as contestParticipation 
            where
                vacation.id = ?3 */
        Hibernate: 
            /* select
                generatedAlias0 
            from
                Interest as generatedAlias0 
            where
                generatedAlias0.vacation=:param0 */
        Hibernate: 
            /* select
                picture.url 
            from
                Picture picture 
            where
                picture.vacation.id = ?1 */
    • interests.stream().map(Interest::getUser) 부분에 왜 추가 쿼리는 발생하지 않을까?

      • 결론 : interests <> user는 단방향 ManyToOne 관계이고 LazyLoading이 적용되어있고, 여기서 식별자 (id)를 조회할 때는 프록시가 초기화 되지 않는다.
      • getUser(), getVacation() → 추가 쿼리 발생
        for (Interest interest : interests) {
            System.out.println(interest.getVacation());
            System.out.println(interest.getUser());
        }
      • getUser().getId(), getVacation().getId() → 추가 쿼리 발생하지 않음
        for (Interest interest : interests) {
            System.out.println(interest.getVacation().getId());
            System.out.println(interest.getUser().getId());
        }
      • 참고 : https://tecoble.techcourse.co.kr/post/2022-10-17-jpa-hibernate-proxy/
      • 더 알게된 점
        • 실제 엔티티 조회 후 다음에 지연 로딩으로 프록시를 가져올 때 앞의 엔티티를 반환해준다.
        • 동일성을 보장해주기 위해서 한 트랜잭션 내에서 최초 생성이 프록시로 된 엔티티는 이후 초기화 여부에 상관 없이 영속성 컨텍스트가 무조건 같은 프록시 객체를 반환
        • https://techvu.dev/128
    • stream에서 User::getId 대신 다른 column (ex. email)을 가져오면 어떻게 되는가?

      • 결론 : 추가적인 조회 Query가 날라간다.
      • 실험 : getUser().getEmail() 로 사용자의 email을 출력해봤다.
        List<Interest> interests = interestRepository.findByVacation(vacationRepository.getReferenceById(cahootsId));
        for (Interest interest: interests) {
            System.out.println(interest.getUser().getEmail());
        }
      • 결과 : 해당 공모를 북마크 한 3명의 정보를 조회하기 위해 3번의 추가 Query 발생
        • SQL Log
          Hibernate: 
              select
                  user0_.id as id1_8_0_,
                  user0_.cash as cash2_8_0_,
                  user0_.email as email3_8_0_,
                  user0_.nickname as nickname4_8_0_,
                  user0_.provider_id as provider5_8_0_,
                  user0_.provider_type as provider6_8_0_,
                  user0_.ranks as ranks7_8_0_,
                  user0_.refresh_token as refresh_8_8_0_,
                  user0_.role as role9_8_0_ 
              from
                  user user0_ 
              where
                  user0_.id=?
          A@gmail.com
          Hibernate: 
              select
                  user0_.id as id1_8_0_,
                  user0_.cash as cash2_8_0_,
                  user0_.email as email3_8_0_,
                  user0_.nickname as nickname4_8_0_,
                  user0_.provider_id as provider5_8_0_,
                  user0_.provider_type as provider6_8_0_,
                  user0_.ranks as ranks7_8_0_,
                  user0_.refresh_token as refresh_8_8_0_,
                  user0_.role as role9_8_0_ 
              from
                  user user0_ 
              where
                  user0_.id=?
          B@gmail.com
          Hibernate: 
              select
                  user0_.id as id1_8_0_,
                  user0_.cash as cash2_8_0_,
                  user0_.email as email3_8_0_,
                  user0_.nickname as nickname4_8_0_,
                  user0_.provider_id as provider5_8_0_,
                  user0_.provider_type as provider6_8_0_,
                  user0_.ranks as ranks7_8_0_,
                  user0_.refresh_token as refresh_8_8_0_,
                  user0_.role as role9_8_0_ 
              from
                  user user0_ 
              where
                  user0_.id=?
          C@gmail.com
    • 3개의 쿼리를 더 줄일 수 없는가?

      • 현재 연관관계에 의해 비지니스 로직을 처리 하려면 Vacation Table를 중심으로 4개의 Table이 JOIN 된다. JOIN 연산이 너무 많이 일어나서, 성능이 떨어질 것 같은데 차라리 Interest, Picture Table에 vacation_id로 indexing을 하는게 더 낫지 않을까 생각했다.
        • 실제로 어떤지 측정을 위해 native query를 작성해서 DB 조회를 해봤다. JOIN으로 한번에 가지고 온 쿼리가 50ms, 세번의 쿼리는 fetch 시간을 제외하더라도 3배 이상이다.
      • Querydsl로 재 작성하여 서버에 배포하여 확인 시 응답 시간은 평균적으로 유사하지만, p95 부터 3배 이상 차이가 나는 것을 확인할 수 있었다.
        • 조건 : 1초당 5번 요청 x 60초

        • 왼쪽 사진) 기존, 오른쪽 사진) 개선 쿼리

공모 참여하기

  • service : participate

    @Transactional
    public void participate(Long cahootsId, Integer stocks, User user){
        if (stocks > 10000) { // 주식 너무 많으면 reject
           throw new ApiException(ErrorCode.INVALID_PARAMETER);
        }
        Vacation vacation = vacationRepository.findByIdAndStatus(cahootsId, VacationStatusType.CAHOOTS_ONGOING).orElseThrow(()-> new ApiException(ErrorCode.VACATION_NOT_FOUND));
        Long cash = user.getCash();
        Long stockTotalPrice = vacation.getStock().getPrice() * stocks;
        verifyUserCash(cash, stockTotalPrice);
    
        // 사용자 정보에서 잔액 차감 + 공모 참여 현황에 추가
        subtractUserCash(user, stockTotalPrice);
        ContestParticipation contestParticipation = ContestParticipation.builder().user(user).vacation(vacation).stocks(stocks).build();
        contestParticipationRepository.save(contestParticipation);
    }
    
    private void verifyUserCash(Long userCash, Long stockTotalPrice){
        if(userCash < stockTotalPrice){ // 공모 정보 가져올 때 진행중이 맞는지 체크하고 잔액이 모자라면 에러
            throw new ApiException(ErrorCode.USER_LACK_OF_CACHE);
        }
    }
    
    private void subtractUserCash(User user, Long stockTotalPrice){
        Long leftCash = user.getCash() - stockTotalPrice;
        user.setCash(leftCash);
        userRepository.updateCash(user.getId(), user.getCash());
    }
  • repository

    • vacationRepository.findByIdAndStatus
    • userRepository.updateCash
    • contestParticipationRepository.save
  • 질문 해보기

    • transaction으로 처리한 이유는?

      • 해당 비지니스 로직이 원자성을 띄어야 하기 때문에 중간에 제대로 처리가 되지 않으면 전부 롤백 해야한다.
    • participate를 호출하면 총 몇 번의 쿼리가 발생하는가?

      • 위의 repository에 적힌 3개의 쿼리가 수행된다.
        • SQL log
           select
                vacation0_.id as id1_9_,
                vacation0_.created_at as created_2_9_,
                vacation0_.updated_at as updated_3_9_,
                vacation0_.country as country4_9_,
                vacation0_.description as descript5_9_,
                vacation0_.expected_rate_of_return as expected6_9_,
                vacation0_.location as location7_9_,
                vacation0_.expected_month as expected8_9_,
                vacation0_.expected_total_cost as expected9_9_,
                vacation0_.short_description as short_d10_9_,
                vacation0_.status as status11_9_,
                vacation0_.num as num12_9_,
                vacation0_.price as price13_9_,
                vacation0_.end as end14_9_,
                vacation0_.start as start15_9_,
                vacation0_.theme_building as theme_b16_9_,
                vacation0_.theme_location as theme_l17_9_,
                vacation0_.title as title18_9_,
                vacation0_.user_id as user_id19_9_ 
            from
                vacation vacation0_ 
            where
                vacation0_.id=? 
                and vacation0_.status=?
          ---------
          update
                user 
            set
                cash=? 
            where
                id=?
          ------------
          insert 
            into
                contest_participation
                (created_at, updated_at, status, stocks, user_id, cahoots_id) 
            values
                (?, ?, ?, ?, ?, ?)
    • parameter로 넘어온 user는 영속성을 가지는가?

      • 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용하기 때문에 해당 로직에서는 트랜잭션이 달라 다른 영속성 컨텍스트를 가진다.
      • Service 계층에 따로 @Transaction이 붙어있지 않으면 Repository 계층 단위로 트랜잭션이 실행된다.
      • 참고 : https://beaniejoy.tistory.com/68
    • 그러면 따로 update query가 없으면 user는 dirty checking에 걸리지 않는가?

      • 안걸린다.
      • SQL log
        select
              vacation0_.id as id1_9_,
              vacation0_.created_at as created_2_9_,
              vacation0_.updated_at as updated_3_9_,
              vacation0_.country as country4_9_,
              vacation0_.description as descript5_9_,
              vacation0_.expected_rate_of_return as expected6_9_,
              vacation0_.location as location7_9_,
              vacation0_.expected_month as expected8_9_,
              vacation0_.expected_total_cost as expected9_9_,
              vacation0_.short_description as short_d10_9_,
              vacation0_.status as status11_9_,
              vacation0_.num as num12_9_,
              vacation0_.price as price13_9_,
              vacation0_.end as end14_9_,
              vacation0_.start as start15_9_,
              vacation0_.theme_building as theme_b16_9_,
              vacation0_.theme_location as theme_l17_9_,
              vacation0_.title as title18_9_,
              vacation0_.user_id as user_id19_9_ 
          from
              vacation vacation0_ 
          where
              vacation0_.id=? 
              and vacation0_.status=?
        -----------
        insert 
            into
                contest_participation
                (created_at, updated_at, status, stocks, user_id, cahoots_id) 
            values
                (?, ?, ?, ?, ?, ?)
    • 수정 시에 save를 호출하지 않고 updateCash를 따로 구현한 이유는 무엇인가?

      • save를 호출하면 영속성이 없는 user는 select and update를 수행하기 때문에 쿼리 두번이 발생한다. update만 수행하기 위해 updateCash를 추가로 구현했다.
      • SQL log
        select
                    vacation0_.id as id1_9_,
                    vacation0_.created_at as created_2_9_,
                    vacation0_.updated_at as updated_3_9_,
                    vacation0_.country as country4_9_,
                    vacation0_.description as descript5_9_,
                    vacation0_.expected_rate_of_return as expected6_9_,
                    vacation0_.location as location7_9_,
                    vacation0_.expected_month as expected8_9_,
                    vacation0_.expected_total_cost as expected9_9_,
                    vacation0_.short_description as short_d10_9_,
                    vacation0_.status as status11_9_,
                    vacation0_.num as num12_9_,
                    vacation0_.price as price13_9_,
                    vacation0_.end as end14_9_,
                    vacation0_.start as start15_9_,
                    vacation0_.theme_building as theme_b16_9_,
                    vacation0_.theme_location as theme_l17_9_,
                    vacation0_.title as title18_9_,
                    vacation0_.user_id as user_id19_9_ 
                from
                    vacation vacation0_ 
                where
                    vacation0_.id=? 
                    and vacation0_.status=?
        ------
        # user select
        select
              user0_.id as id1_8_0_,
              user0_.cash as cash2_8_0_,
              user0_.email as email3_8_0_,
              user0_.nickname as nickname4_8_0_,
              user0_.provider_id as provider5_8_0_,
              user0_.provider_type as provider6_8_0_,
              user0_.ranks as ranks7_8_0_,
              user0_.refresh_token as refresh_8_8_0_,
              user0_.role as role9_8_0_ 
          from
              user user0_ 
          where
              user0_.id=?
        ------
        insert 
                into
                    contest_participation
                    (created_at, updated_at, status, stocks, user_id, cahoots_id) 
                values
                    (?, ?, ?, ?, ?, ?)
        -------
        # user update
        update
                user 
            set
                cash=? 
            where
                id=?
profile
삽질전문 아티스트

0개의 댓글