From 0c26a2cd183fdb3183ec49a8e3a4e1055fcecd42 Mon Sep 17 00:00:00 2001 From: sahyunjin Date: Fri, 31 May 2024 21:46:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[#115]=20Refactor:=20N+1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20JPA=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sajang/devracebackend/domain/Room.java | 4 +- .../sajang/devracebackend/domain/User.java | 4 +- .../domain/mapping/UserRoom.java | 2 +- .../repository/UserRoomRepository.java | 11 +++- .../service/UserRoomService.java | 4 +- .../service/impl/ChatServiceImpl.java | 13 ++-- .../service/impl/UserRoomServiceImpl.java | 63 +++++++++++-------- 7 files changed, 61 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/sajang/devracebackend/domain/Room.java b/src/main/java/com/sajang/devracebackend/domain/Room.java index a1c17c1..5481cb0 100644 --- a/src/main/java/com/sajang/devracebackend/domain/Room.java +++ b/src/main/java/com/sajang/devracebackend/domain/Room.java @@ -40,8 +40,10 @@ public class Room extends BaseEntity implements Serializable{ @JoinColumn(name = "problem_id") private Problem problem; + // 밑처럼 과도한 N+1 문제를 유발할 서비스 로직이 없을뿐더러 활용 빈도수도 낮기때문에, fetch join이나 @EntityGraph를 사용하지 않은채로 지연 로딩을 유지하도록 했음. + // 과도한 N+1 예시: Rooms를 JPA의 findAll()로 호출하고, 각 Room을 순회하며 room.getUserRoomList.get내부속성()으로 접근하는 경우. @OneToMany(mappedBy = "room") // Room-UserRoom 양방향매핑 (읽기 전용 필드) - private List userRoomList = new ArrayList<>(); // 방의 입장인원 전원 퇴장여부 확인용도에 활용. + private List userRoomList = new ArrayList<>(); // '방의 입장인원 전원 퇴장여부 확인 용도'에 활용. @Builder(builderClassName = "RoomSaveBuilder", builderMethodName = "RoomSaveBuilder") diff --git a/src/main/java/com/sajang/devracebackend/domain/User.java b/src/main/java/com/sajang/devracebackend/domain/User.java index 4bdc9c8..bbbc025 100644 --- a/src/main/java/com/sajang/devracebackend/domain/User.java +++ b/src/main/java/com/sajang/devracebackend/domain/User.java @@ -47,8 +47,10 @@ public class User extends BaseEntity implements Serializable { @Column(name = "refresh_token") private String refreshToken; + // 밑처럼 과도한 N+1 문제를 유발할 서비스 로직이 없을뿐더러 활용 빈도수도 낮기때문에, fetch join이나 @EntityGraph를 사용하지 않은채로 지연 로딩을 유지하도록 했음. + // 과도한 N+1 예시: Users를 JPA의 findAll()로 호출하고, 각 User를 순회하며 user.getUserRoomList.get내부속성()으로 접근하는 경우. @OneToMany(mappedBy = "user") // User-UserRoom 양방향매핑 (읽기 전용 필드) - private List userRoomList = new ArrayList<>(); // 사용자의 참여중인 방 찾는 용도에 활용. + private List userRoomList = new ArrayList<>(); // '사용자의 참여중인 방 찾는 용도 & 회원탈퇴 용도'에 활용. @Builder(builderClassName = "UserSaveBuilder", builderMethodName = "UserSaveBuilder") diff --git a/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java b/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java index 97fa9bf..0404215 100644 --- a/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java +++ b/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java @@ -43,7 +43,7 @@ public class UserRoom extends BaseEntity implements Serializable { @JoinColumn(name = "user_id") private User user; - @ManyToOne(fetch = FetchType.LAZY) // Room-UserRoom 양방향매핑 + @ManyToOne(fetch = FetchType.LAZY) // Room-UserRoom 양방향매핑 (default로 Lazy를 적용하되, Eager가 필요할경우 따로 메소드를 만들어 사용함.) @JoinColumn(name = "room_id") private Room room; diff --git a/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java b/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java index 68e2860..c1dd1f5 100644 --- a/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java +++ b/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java @@ -5,15 +5,22 @@ import com.sajang.devracebackend.domain.mapping.UserRoom; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRoomRepository extends JpaRepository { - Optional findByUserAndRoom(User user, Room room); + + // 서비스 로직에 UserRoom과 내부의 Room 정보를 모두 조회하여 사용하는 경우가 많았기에, 이를 적용하였음. + // @EntityGraph를 통해 UserRoom 조회시, 내부의 Lazy 로딩으로 선언된 Room 또한 Eager로 한번에 조회하여, N+1 문제를 해결. + // ==> Eager 조회 : 'UserRoom + 내부 Room', Lazy 조회 : '내부 User' + @EntityGraph(attributePaths = {"room"}) + Optional findByUser_IdAndRoom_Id(Long userId, Long roomId); + boolean existsByUserAndRoom(User user, Room room); - Page findAllByIsLeaveAndUser(Integer isLeave, User user, Pageable pageable); + Page findAllByIsLeaveAndUser_Id(Integer isLeave, Long userId, Pageable pageable); Page findAllByIsLeaveAndIsPass(Integer isLeave, Integer isPass, Pageable pageable); Page findAllByIsLeaveAndRoom_Problem_Number(Integer isLeave, Integer number, Pageable pageable); Page findAllByIsLeaveAndRoom_Link(Integer isLeave, String link, Pageable pageable); diff --git a/src/main/java/com/sajang/devracebackend/service/UserRoomService.java b/src/main/java/com/sajang/devracebackend/service/UserRoomService.java index fab96fe..69da67f 100644 --- a/src/main/java/com/sajang/devracebackend/service/UserRoomService.java +++ b/src/main/java/com/sajang/devracebackend/service/UserRoomService.java @@ -1,7 +1,5 @@ package com.sajang.devracebackend.service; -import com.sajang.devracebackend.domain.Room; -import com.sajang.devracebackend.domain.User; import com.sajang.devracebackend.domain.mapping.UserRoom; import com.sajang.devracebackend.dto.room.RoomWaitRequestDto; import com.sajang.devracebackend.dto.room.RoomWaitResponseDto; @@ -13,7 +11,7 @@ import org.springframework.data.domain.Pageable; public interface UserRoomService { - UserRoom findUserRoom(User user, Room room); + UserRoom findUserRoom(Long userId, Long roomId); // Eager 조회 : 'UserRoom + 내부 Room', Lazy 조회 : '내부 User' RoomWaitResponseDto userWaitRoom(RoomWaitRequestDto roomWaitRequestDto); void usersEnterRoom(Long roomId); void userStopWaitRoom(Long roomId); diff --git a/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java index 95cbeab..87e26f5 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java @@ -65,9 +65,12 @@ else if(chatRequestDto.getMessageType().equals(MessageType.TALK)) { // 방 채 if(chatRequestDto.getMessage() == null) throw new ChatBadRequestException("채팅시에는 반드시 message를 함께 보내주어야합니다."); message = chatRequestDto.getMessage(); } - else { // 랭킹 상승의 경우 (MessageType.RANK 일때) + else if(chatRequestDto.getMessageType().equals(MessageType.RANK)) { room.addRanking(user.getId()); } + else { // 잘못된 MessageType + throw new ChatBadRequestException("잘못된 MessageType으로 API를 요청하였습니다."); + } Chat chat = chatRequestDto.toEntity(message); chatRepository.save(chat); @@ -78,20 +81,18 @@ else if(chatRequestDto.getMessageType().equals(MessageType.TALK)) { // 방 채 @Transactional(readOnly = true) @Override public Slice findChatsByRoom(Long roomId, Pageable pageable) { - User loginUser = userService.findUser(SecurityUtil.getCurrentMemberId()); - Room room = roomService.findRoom(roomId); - UserRoom userRoom = userRoomService.findUserRoom(loginUser, room); + UserRoom userRoom = userRoomService.findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); LocalDateTime leaveTime = userRoom.getLeaveTime(); // 본인 퇴장시각 이하까지의 채팅 내역 조회 Slice chatSlice = chatRepository.findAllByRoomIdAndCreatedTimeLessThanEqual(roomId, leaveTime, pageable); // 사용자 캐싱을 위한 맵 생성 (이미 검색한것은 다시 검색하지않도록 성능 향상) - Map userCacheMap = new HashMap<>(); + Map cacheUserMap = new HashMap<>(); return chatSlice.map(chat -> { Long senderId = chat.getSenderId(); - User senderUser = userCacheMap.computeIfAbsent(senderId, id -> userService.findUser(id)); // 만약 캐시에 senderId키의 데이터가 없다면, DB조회하고 캐시에 추가. + User senderUser = cacheUserMap.computeIfAbsent(senderId, id -> userService.findUser(id)); // 만약 캐시에 senderId키의 데이터가 없다면, DB조회하고 캐시에 추가. return new ChatResponseDto(chat, senderUser.getNickname(), senderUser.getImageUrl()); }); } diff --git a/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java index 633c7bf..171f972 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java @@ -20,6 +20,7 @@ import com.sajang.devracebackend.service.RoomService; import com.sajang.devracebackend.service.UserRoomService; import com.sajang.devracebackend.service.UserService; +import com.sajang.devracebackend.util.SecurityUtil; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,6 +29,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -40,11 +42,14 @@ public class UserRoomServiceImpl implements UserRoomService { private final ChatRepository chatRepository; + // 서비스 로직에 UserRoom과 내부의 Room 정보를 모두 조회하여 사용하는 경우가 많았기에, 이를 적용하였음. + // @EntityGraph를 통해 UserRoom 조회시, 내부의 Lazy 로딩으로 선언된 Room 또한 Eager로 한번에 조회하여, N+1 문제를 해결. + // ==> Eager 조회 : 'UserRoom + 내부 Room', Lazy 조회 : '내부 User' @Transactional(readOnly = true) @Override - public UserRoom findUserRoom(User user, Room room) { - return userRoomRepository.findByUserAndRoom(user, room).orElseThrow( - ()->new NoSuchUserRoomException(String.format("userId = %d & roomId = %d", user.getId(), room.getId()))); + public UserRoom findUserRoom(Long userId, Long roomId) { + return userRoomRepository.findByUser_IdAndRoom_Id(userId, roomId).orElseThrow( + ()->new NoSuchUserRoomException(String.format("userId = %d & roomId = %d", userId, roomId))); } @Transactional @@ -117,43 +122,51 @@ public void usersEnterRoom(Long roomId) { @Transactional @Override public void userStopWaitRoom(Long roomId) { - User loginUser = userService.findLoginUser(); Room room = roomService.findRoom(roomId); - - room.deleteWaiting(loginUser.getId(), false); // 대기자 목록에서 해당 사용자 제거. + room.deleteWaiting(SecurityUtil.getCurrentMemberId(), false); // 대기자 목록에서 해당 사용자 제거. } @Transactional(readOnly = true) @Override public SolvingPageResponseDto loadSolvingPage(Long roomId) { - User user = userService.findLoginUser(); - Room room = roomService.findRoom(roomId); - UserRoom userRoom = findUserRoom(user, room); + UserRoom userRoom = findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. - List rankUserIdList = room.getWaiting(); + List rankUserIdList = userRoom.getRoom().getWaiting(); // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. List rankUserDtoList = userService.findUsersOriginal(rankUserIdList, true); - return new SolvingPageResponseDto(userRoom, rankUserDtoList); + return new SolvingPageResponseDto(userRoom, rankUserDtoList); // UserRoom의 Room의 Problem은 호출 빈도수가 낮기도하고, JPA메소드 네이밍이 겹치기에, eager 적용을 하지 않았음. } @Transactional(readOnly = true) @Override public RoomCheckAccessResponseDto checkAccess(Long roomId) { - User user = userService.findLoginUser(); - Room room = roomService.findRoom(roomId); - UserRoom userRoom = userRoomRepository.findByUserAndRoom(user, room) - .orElse(null); + System.out.println("========== !!! 메소드 시작 !!! ==========\n"); + + System.out.println("===== UserRoom 조회 ====="); + Optional optionalUserRoom = userRoomRepository.findByUser_IdAndRoom_Id(SecurityUtil.getCurrentMemberId(), roomId); + System.out.println("===== UserRoom 조회 완료. [1번의 쿼리 발생] =====\n"); - Boolean isExistUserRoom = true; - if(userRoom == null) isExistUserRoom = false; - Integer isLeave = null; - if(userRoom != null) isLeave = userRoom.getIsLeave(); + // UserRoom이 존재하면 해당 정보 사용. 그렇지 않다면 DB에 Room 조회 쿼리 날림. + System.out.println("===== UserRoom.getRoom() 실행 ====="); + Room room = optionalUserRoom + .map(UserRoom::getRoom) // 이 시점에는 아직 @EntityGraph의 영향을 받지않아, 아직 조회 쿼리가 1번으로 유지됨. + .orElseGet(() -> roomService.findRoom(roomId)); + System.out.println("===== UserRoom의 Room을 가져오지만 Room 내부의 변수는 사용하지않음. [추가쿼리 발생 X] =====\n"); + Boolean isExistUserRoom = optionalUserRoom.isPresent(); + System.out.println("===== UserRoom.getIsLeave() 실행 ====="); + Integer isLeave = optionalUserRoom.map(UserRoom::getIsLeave).orElse(null); // UserRoom이 없다면 isLeave는 null + System.out.println("===== UserRoom의 Room 외의 타변수를 사용. [추가쿼리 발생 X] =====\n"); + + System.out.println("===== UserRoom.getRoom().getRoomState() 실행 ====="); RoomCheckAccessResponseDto roomCheckAccessResponseDto = RoomCheckAccessResponseDto.builder() .isExistUserRoom(isExistUserRoom) - .roomState(room.getRoomState()) + .roomState(room.getRoomState()) // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. .isLeave(isLeave) .build(); + System.out.println("===== UserRoom의 Room 내부의 변수를 사용. [@EntityGraph 미처리시 추가쿼리 발생 O] =====\n"); + + System.out.println("========== !!! 메소드 종료 !!! =========="); return roomCheckAccessResponseDto; } @@ -161,9 +174,8 @@ public RoomCheckAccessResponseDto checkAccess(Long roomId) { @Transactional @Override public void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDto) { - User user = userService.findLoginUser(); - Room room = roomService.findRoom(roomId); - UserRoom userRoom = findUserRoom(user, room); + UserRoom userRoom = findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. + Room room = userRoom.getRoom(); // 이 시점에는 아직 @EntityGraph의 영향을 받지않아, 아직 조회 쿼리가 1번으로 유지됨. userRoom.updateCode(userPassRequestDto.getCode()); userRoom.updateIsLeave(1); @@ -174,7 +186,7 @@ public void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDt userRoom.updateIsPass(userPassRequestDto.getIsPass()); } - List userRoomList = room.getUserRoomList(); + List userRoomList = room.getUserRoomList(); // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. Boolean isLeaveAllUsers = userRoomList.stream() .allMatch(enterUserRoom -> enterUserRoom.getIsLeave() == 1); // 입장했던 모든 유저의 isLeave 값이 1인지 확인 if(isLeaveAllUsers == true) room.updateRoomState(RoomState.FINISH); @@ -183,11 +195,10 @@ public void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDt @Transactional(readOnly = true) @Override public Page findCodeRoom(Integer isPass, Integer number, String link, Pageable pageable) { - User loginUser = userService.findLoginUser(); Page userRoomPage; if(isPass == null && number == null && link == null) { // 전체 정렬 조회의 경우 - userRoomPage = userRoomRepository.findAllByIsLeaveAndUser(1, loginUser, pageable); + userRoomPage = userRoomRepository.findAllByIsLeaveAndUser_Id(1, SecurityUtil.getCurrentMemberId(), pageable); } else if(isPass != null && number == null && link == null) { // 성공or실패 정렬 조회의 경우 userRoomPage = userRoomRepository.findAllByIsLeaveAndIsPass(1, isPass, pageable); From ab51e1aea53a867a8386f3a107fe229394b0673f Mon Sep 17 00:00:00 2001 From: sahyunjin Date: Fri, 31 May 2024 21:48:09 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[#115]=20Chore:=20N+1=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=9C=EB=A0=A5=EB=AC=B8=EC=9D=84=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/UserRoomServiceImpl.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java index 171f972..5b70a5d 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java @@ -140,33 +140,33 @@ public SolvingPageResponseDto loadSolvingPage(Long roomId) { @Transactional(readOnly = true) @Override public RoomCheckAccessResponseDto checkAccess(Long roomId) { - System.out.println("========== !!! 메소드 시작 !!! ==========\n"); +// System.out.println("========== !!! 메소드 시작 !!! ==========\n"); - System.out.println("===== UserRoom 조회 ====="); +// System.out.println("===== UserRoom 조회 ====="); Optional optionalUserRoom = userRoomRepository.findByUser_IdAndRoom_Id(SecurityUtil.getCurrentMemberId(), roomId); - System.out.println("===== UserRoom 조회 완료. [1번의 쿼리 발생] =====\n"); +// System.out.println("===== UserRoom 조회 완료. [1번의 쿼리 발생] =====\n"); // UserRoom이 존재하면 해당 정보 사용. 그렇지 않다면 DB에 Room 조회 쿼리 날림. - System.out.println("===== UserRoom.getRoom() 실행 ====="); +// System.out.println("===== UserRoom.getRoom() 실행 ====="); Room room = optionalUserRoom .map(UserRoom::getRoom) // 이 시점에는 아직 @EntityGraph의 영향을 받지않아, 아직 조회 쿼리가 1번으로 유지됨. .orElseGet(() -> roomService.findRoom(roomId)); - System.out.println("===== UserRoom의 Room을 가져오지만 Room 내부의 변수는 사용하지않음. [추가쿼리 발생 X] =====\n"); +// System.out.println("===== UserRoom의 Room을 가져오지만 Room 내부의 변수는 사용하지않음. [추가쿼리 발생 X] =====\n"); Boolean isExistUserRoom = optionalUserRoom.isPresent(); - System.out.println("===== UserRoom.getIsLeave() 실행 ====="); +// System.out.println("===== UserRoom.getIsLeave() 실행 ====="); Integer isLeave = optionalUserRoom.map(UserRoom::getIsLeave).orElse(null); // UserRoom이 없다면 isLeave는 null - System.out.println("===== UserRoom의 Room 외의 타변수를 사용. [추가쿼리 발생 X] =====\n"); +// System.out.println("===== UserRoom의 Room 외의 타변수를 사용. [추가쿼리 발생 X] =====\n"); - System.out.println("===== UserRoom.getRoom().getRoomState() 실행 ====="); +// System.out.println("===== UserRoom.getRoom().getRoomState() 실행 ====="); RoomCheckAccessResponseDto roomCheckAccessResponseDto = RoomCheckAccessResponseDto.builder() .isExistUserRoom(isExistUserRoom) .roomState(room.getRoomState()) // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. .isLeave(isLeave) .build(); - System.out.println("===== UserRoom의 Room 내부의 변수를 사용. [@EntityGraph 미처리시 추가쿼리 발생 O] =====\n"); +// System.out.println("===== UserRoom의 Room 내부의 변수를 사용. [@EntityGraph 미처리시 추가쿼리 발생 O] =====\n"); - System.out.println("========== !!! 메소드 종료 !!! =========="); +// System.out.println("========== !!! 메소드 종료 !!! =========="); return roomCheckAccessResponseDto; }