내 Query에 물음표를 달아보자

들어가기에 앞서

비지니스 로직을 작성하며 영속성, 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=?
        

Leave a comment