본문 바로가기
스파르타(부트캠프)/Troubleshooting

[내일배움캠프] 플러스주차(스프링 심화)에 대한 트러블슈팅

by Kimuky 2024. 12. 19.

1. N+1, JOIN FETCH, 1차 캐시, 영속성?

1) 개요

    // TODO: 3. N+1 문제
    public List<ReservationResponseDto> getReservations() {
        List<Reservation> reservations = reservationRepository.findAll();

        return reservations.stream().map(reservation -> {
            User user = reservation.getUser();
            Item item = reservation.getItem();

            return new ReservationResponseDto(
                    reservation.getId(),
                    user.getNickname(),
                    item.getName(),
                    reservation.getStartAt(),
                    reservation.getEndAt()
            );
        }).toList();
    }

이 코드를 N+1 문제를 해결해야한다.

추가적으로

- 스트림으로 원하는 칼럼을 핸들링 하는 것 부터 이상한 것 같음 -> 원하는 칼럼만 들고 올 것임

2) 문제상황

    // TODO: 3. N+1 문제
    public List<ReservationResponseDto> getReservations() {
        List<Reservation> reservations = reservationRepository.findAll();

        return reservations.stream().map(reservation -> {
            User user = reservation.getUser();
            Item item = reservation.getItem();

            return new ReservationResponseDto(
                    reservation.getId(),
                    user.getNickname(),
                    item.getName(),
                    reservation.getStartAt(),
                    reservation.getEndAt()
            );
        }).toList();
    }

 

코드를 자세히 보면 List<Reservation> reservations = reservationRepository.findAll();

-> jpa메소드  findAll()을 써서 다 들고 온다. 또한, 유저, 아이템을 추가적으로 들고오는 과정에서 N+1 문제 발생

그렇기에 jpql로 간단히 해결 가능할 것이라는 생각

   // 1차로 생각한 방법
   @Query("SELECT r " +
            "from Reservation r " +
            "join fetch  r.item i " +
            "join fetch  r.user u")
    List<ReservationResponseDto> findAllCustom();

 

잘 작동된다. 하지만 쓸모없는 칼럼까지 다 들고와서 서비스에서 스트림처리하는 것은 옳지 않다고 판단

    // 2차로 생각한 방법
    @Query("SELECT new com.example.demo.dto.ReservationResponseDto(r.id, u.nickname, i.name, r.startAt, r.endAt) " +
            "from Reservation r " +
            "join fetch  r.item i " +
            "join fetch  r.user u")
    List<ReservationResponseDto> findAllCustom();

 

빌드가 되지 않는다. 쿼리가 잘못 되었다는 에러로그가 출력이 된다.

3) 해결

   // 3차로 생각한 방법
  
   @Query("SELECT new com.example.demo.dto.ReservationResponseDto(r.id, u.nickname, i.name, r.startAt, r.endAt) " +
            "from Reservation r " +
            "join  r.item i " +
            "join  r.user u")
    List<ReservationResponseDto> findAllCustom();

join fetch -> join으로 교체

 

    // TODO: 3. N+1 문제
    public List<ReservationResponseDto> getReservations() {
        return reservationRepository.findAllCustom();
    }


=> 서비스 코드도 간단해졌다.

 

그렇다면 왜 join fetch가 오류를 발생시켰나 생각을 해보았고 검색을 좀 해보니

- join fetch는 entity 를 들고와서 1차 캐시에 저장을 하는데

저렇게 dto로 하나의 필드를 들고와서 영속성 전이를 시킨다는 것이 말이 안된다.

 

4) 결론

- 영속성 전이

- 1차 캐시

- N+1 문제

- jpql 지식

 

등이 많이 부족한 것 같다. 좀 더 쿼리를 작성해보며 쿼리 로그가 어떻게 올라오는지 한 번 더 확인 해보아야 할 것이다.


2. 쿼리 개선

1) 개요

   // TODO: 4. find or save 예제 개선
    @Transactional
    public void reportUsers(List<Long> userIds) {
        for (Long userId : userIds) {
            User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("해당 ID에 맞는 값이 존재하지 않습니다."));

            user.updateStatusToBlocked();

            userRepository.save(user);
        }
    }

이 문제를 해결시켜야 한다. 이렇게 작동하면 쿼리는 유저수 만큼 쿼리가 돌 것이다. 그럼 유저를 100, 10000, 100_000을 신고한다면 find, update 두개가 있기에 쿼리 * 2 만큼 발생할 것이다.

2) 문제상황

   // TODO: 4. find or save 예제 개선
    @Transactional
    public void reportUsers(List<Long> userIds) {
        for (Long userId : userIds) {
            User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("해당 ID에 맞는 값이 존재하지 않습니다."));

            user.updateStatusToBlocked();

            userRepository.save(user);
        }
    }

 

그렇다면 해당 유저를 id로 찾는것은

    // TODO: 4. find or save 예제 개선
    @Transactional
    public void reportUsers(List<Long> userIds) {
        List<User> userList = userRepository.findByIdIn(userIds);
    }

 

간단히 해결할 수 있는데 해당 유저 stauts 필드를 어떻게 수정할 것인가에 대한 문제이다.

 

find는 in 이라는 메소드를 지원해 한번에 찾을 수 있지만 update는 저런게 있는지도 모르겠다.

3) 해결

    // TODO: 4. find or save 예제 개선
    @Transactional
    public void reportUsers(List<Long> userIds) {
        List<User> userList = userRepository.findByIdIn(userIds);
        userRepository.reportUsers("BLOCKED", userList);
    }

 

reportUsers를  jpql로 구현하기로 결정

    @Modifying
    @Query("Update User u Set u.status = :blocked WHERE u IN :userIds")
    void reportUsers(String blocked, List<User> userIds);

 

이렇게하면 유저 수가 아무리 많아도 쿼리는 2번만 발생한다.

4) 결론

jpql로 해결할 수 있었지만, 분명 jpa 메소드로도 수정할 수 있을 것 같은데, jpql보다는 이렇게 간단한거는 jpa메소드를 활용해보아야겠다.