내 Query에 물음표를 달아보자
들어가기에 앞서
비지니스 로직을 작성하며 영속성, JOIN, N+1 등 다양한 문제들을 고민하며 작성했지만, 프로젝트 후반부가 되면서 전보다 인사이트가 조금 생겼다.
작성한 코드들을 다시 셀프 리뷰 하면서 더 좋은 방향에 대해 고민하고, 궁금했지만 넘어갔던 것들에 대해 다시 짚어보려고한다.
공모 상세 정보 가져오기
-
service :
getDetailpublic 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초
- 왼쪽 사진) 기존, 오른쪽 사진) 개선 쿼리


- 현재 연관관계에 의해 비지니스 로직을 처리 하려면 Vacation Table를 중심으로 4개의 Table이 JOIN 된다. JOIN 연산이 너무 많이 일어나서, 성능이 떨어질 것 같은데 차라리 Interest, Picture Table에 vacation_id로 indexing을 하는게 더 낫지 않을까 생각했다.
- 실제로 쿼리는 총 몇개가 던져 졌는가?
공모 참여하기
-
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 (?, ?, ?, ?, ?, ?)
-
- 위의 repository에 적힌 3개의 쿼리가 수행된다.
- 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=?
- transaction으로 처리한 이유는?
Leave a comment