diff --git a/src/main/java/com/sajang/devracebackend/controller/RoomController.java b/src/main/java/com/sajang/devracebackend/controller/RoomController.java index cbf058b..2d44bf8 100644 --- a/src/main/java/com/sajang/devracebackend/controller/RoomController.java +++ b/src/main/java/com/sajang/devracebackend/controller/RoomController.java @@ -2,10 +2,9 @@ import com.sajang.devracebackend.config.RabbitConfig; import com.sajang.devracebackend.dto.problem.ProblemSaveRequestDto; -import com.sajang.devracebackend.dto.room.RoomSaveResponseDto; +import com.sajang.devracebackend.dto.room.RoomResponseDto; import com.sajang.devracebackend.dto.room.RoomWaitRequestDto; import com.sajang.devracebackend.dto.room.RoomWaitResponseDto; -import com.sajang.devracebackend.dto.userroom.CodeRoomResponseDto; import com.sajang.devracebackend.dto.userroom.UserPassRequestDto; import com.sajang.devracebackend.dto.userroom.RoomCheckAccessResponseDto; import com.sajang.devracebackend.dto.room.RoomCheckStateResponseDto; @@ -18,10 +17,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; @@ -41,9 +36,16 @@ public class RoomController { @PostMapping("/rooms") @Operation(summary = "방생성 Page - 방 생성 [jwt O]") - public ResponseEntity> createRoom(@RequestBody ProblemSaveRequestDto problemSaveRequestDto) throws IOException { - RoomSaveResponseDto roomSaveResponseDto = roomService.createRoom(problemSaveRequestDto); - return ResponseData.toResponseEntity(ResponseCode.CREATED_ROOM, roomSaveResponseDto); + public ResponseEntity> createRoom(@RequestBody ProblemSaveRequestDto problemSaveRequestDto) throws IOException { + RoomResponseDto roomResponseDto = roomService.createRoom(problemSaveRequestDto); + return ResponseData.toResponseEntity(ResponseCode.CREATED_ROOM, roomResponseDto); + } + + @GetMapping("/rooms") + @Operation(summary = "초대링크 방 조회 [jwt O]", description = "URI : /rooms?link={방링크 string}") + public ResponseEntity> findRoomByLink(@RequestParam(value = "link", required = true) String link) { + RoomResponseDto roomResponseDto = roomService.findRoomByLink(link); + return ResponseData.toResponseEntity(ResponseCode.READ_ROOM, roomResponseDto); } @GetMapping("/rooms/{roomId}") @@ -68,7 +70,7 @@ public ResponseEntity> checkState(@PathV } @PostMapping("/rooms/{roomId}") // 의미상 PUT보단 POST가 더 적합하다고 판단했음. - @Operation(summary = "문제풀이 Page - 문제풀이 실패 및 성공 [jwt O]", description = "isRetry==0 : 첫풀이 경우 / isRetry==1 : 재풀이 경우") + @Operation(summary = "문제풀이 Page - 문제풀이 성공 및 실패/퇴장 [jwt O]", description = "isRetry==0 : 첫풀이 경우 / isRetry==1 : 재풀이 경우") public ResponseEntity passSolvingProblem( @PathVariable(value = "roomId") Long roomId, @RequestBody UserPassRequestDto userPassRequestDto) { @@ -84,24 +86,6 @@ public ResponseEntity userStopWaitRoom(@PathVariable(value = "room return ResponseData.toResponseEntity(ResponseCode.UPDATE_ROOM); } - @GetMapping("/rooms") - @Operation(summary = "내 코드방 목록 조회/정렬/검색 [jwt O]", - description = """ - - 전체 정렬 URI : /rooms?page={페이지번호 int} - - 성공or실패 정렬 URI : /rooms?isPass={풀이성공여부 int}&page={페이지번호 int} - - 문제번호 검색 URI : /rooms?number={문제번호 int}&page={페이지번호 int} - - 초대링크 탐색 URI : /rooms?link={방링크 string} \nsize, sort 필요X (기본값 : page=0 & size=9 & sort=createdTime,desc) - """) - public ResponseEntity>> findCodeRoom( - @RequestParam(value = "isPass", required = false) Integer isPass, - @RequestParam(value = "number", required = false) Integer number, - @RequestParam(value = "link", required = false) String link, - @PageableDefault(page=0, size=9, sort="createdTime", direction = Sort.Direction.DESC) Pageable pageable) { - - Page codeRoomResponseDtoPage = userRoomService.findCodeRoom(isPass, number, link, pageable); - return ResponseData.toResponseEntity(ResponseCode.READ_USERROOM, codeRoomResponseDtoPage); - } - @MessageMapping("wait.enter") // 웹소켓 메시지 처리 (백엔드로 '/pub/wait.enter'를 호출시 이 브로커에서 처리) public void userWaitRoom(@Payload RoomWaitRequestDto roomWaitRequestDto) { // '/exchange/wait.exchange/waitingroom.{roomId}' 구독되어있는 프론트엔드에게 메세지 전달. diff --git a/src/main/java/com/sajang/devracebackend/controller/UserController.java b/src/main/java/com/sajang/devracebackend/controller/UserController.java index 4a3398b..4942952 100644 --- a/src/main/java/com/sajang/devracebackend/controller/UserController.java +++ b/src/main/java/com/sajang/devracebackend/controller/UserController.java @@ -4,18 +4,21 @@ import com.sajang.devracebackend.dto.user.UserResponseDto; import com.sajang.devracebackend.dto.user.UserSolvedResponseDto; import com.sajang.devracebackend.dto.user.UserUpdateRequestDto; +import com.sajang.devracebackend.dto.userroom.CodeRoomResponseDto; import com.sajang.devracebackend.response.ResponseCode; import com.sajang.devracebackend.response.ResponseData; +import com.sajang.devracebackend.service.UserRoomService; import com.sajang.devracebackend.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -26,6 +29,7 @@ public class UserController { private final UserService userService; + private final UserRoomService userRoomService; @GetMapping("/users") @@ -50,17 +54,33 @@ public ResponseEntity updateUserProfile( return ResponseData.toResponseEntity(ResponseCode.UPDATE_USER); } - @GetMapping("/users/solved-count") - @Operation(summary = "백준 solvedCount값 조회 [jwt O]") - public ResponseEntity> checkUserSolvedCount() { - UserSolvedResponseDto userSolvedResponseDto = userService.checkUserSolvedCount(); - return ResponseData.toResponseEntity(ResponseCode.READ_SOLVEDCOUNT, userSolvedResponseDto); + @GetMapping("/users/rooms") + @Operation(summary = "코드방 목록 조회/정렬/검색 [jwt O]", + description = """ + - 전체 정렬 URI : /users/rooms?page={페이지번호 int} + - 성공or실패 정렬 URI : /users/rooms?isPass={풀이성공여부 int}&page={페이지번호 int} + - 문제번호 검색 URI : /users/rooms?number={문제번호 int}&page={페이지번호 int} \nsize, sort 필요X (기본값 : size=9 & sort=createdTime,desc) + """) + public ResponseEntity>> findCodeRooms( + @RequestParam(value = "isPass", required = false) Integer isPass, + @RequestParam(value = "number", required = false) Integer number, + @PageableDefault(size=9, sort="createdTime", direction = Sort.Direction.DESC) Pageable pageable) { + + Page codeRoomResponseDtoPage = userRoomService.findCodeRooms(isPass, number, pageable); + return ResponseData.toResponseEntity(ResponseCode.READ_USERROOM, codeRoomResponseDtoPage); } - @GetMapping("/users/room-check") + @GetMapping("/users/rooms-check") @Operation(summary = "메인 Page - 참여중인 방 여부 검사 [jwt O]", description = "roomId == Long or null") public ResponseEntity> checkCurrentRoom() { UserCheckRoomResponseDto userCheckRoomResponseDto = userService.checkCurrentRoom(); return ResponseData.toResponseEntity(ResponseCode.READ_ROOM, userCheckRoomResponseDto); } + + @GetMapping("/users/solved-count") + @Operation(summary = "백준 solvedCount값 조회 [jwt O]") + public ResponseEntity> checkUserSolvedCount() { + UserSolvedResponseDto userSolvedResponseDto = userService.checkUserSolvedCount(); + return ResponseData.toResponseEntity(ResponseCode.READ_SOLVEDCOUNT, userSolvedResponseDto); + } } diff --git a/src/main/java/com/sajang/devracebackend/domain/Room.java b/src/main/java/com/sajang/devracebackend/domain/Room.java index 5481cb0..11908f3 100644 --- a/src/main/java/com/sajang/devracebackend/domain/Room.java +++ b/src/main/java/com/sajang/devracebackend/domain/Room.java @@ -36,14 +36,14 @@ public class Room extends BaseEntity implements Serializable{ @Column(name = "room_state") private RoomState roomState; + // Room 조회시 Room.problem N+1 문제 해결 ==> Eager 조회 : '@EntityGraph 적용 메소드 활용', Lazy 조회 : '기본 지연로딩 용도' @ManyToOne(fetch = FetchType.LAZY) // Room-Problem 단방향매핑 @JoinColumn(name = "problem_id") private Problem problem; - // 밑처럼 과도한 N+1 문제를 유발할 서비스 로직이 없을뿐더러 활용 빈도수도 낮기때문에, fetch join이나 @EntityGraph를 사용하지 않은채로 지연 로딩을 유지하도록 했음. - // 과도한 N+1 예시: Rooms를 JPA의 findAll()로 호출하고, 각 Room을 순회하며 room.getUserRoomList.get내부속성()으로 접근하는 경우. + // Room 조회시 Room.userRoomList N+1 문제 해결 ==> Eager 조회 : '방의 입장인원 전원 퇴장여부 확인 용도', Lazy 조회 : '기본 지연로딩 용도' @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 bbbc025..6340ce9 100644 --- a/src/main/java/com/sajang/devracebackend/domain/User.java +++ b/src/main/java/com/sajang/devracebackend/domain/User.java @@ -47,10 +47,9 @@ 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내부속성()으로 접근하는 경우. + // User 조회시 User.userRoomList N+1 문제 해결 ==> Eager 조회 : '사용자의 참여중인 방 찾는 용도', Lazy 조회 : '기본 지연로딩 용도 & 회원탈퇴 용도' @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 0404215..e6ad223 100644 --- a/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java +++ b/src/main/java/com/sajang/devracebackend/domain/mapping/UserRoom.java @@ -39,11 +39,13 @@ public class UserRoom extends BaseEntity implements Serializable { @Column(name = "leave_time") private LocalDateTime leaveTime; // 퇴장 시각 (반면, createdTime = 입장 시각) + // Eager 조회의 용도가 적어, 기본 Lazy 조회로만 사용함. @ManyToOne(fetch = FetchType.LAZY) // User-UserRoom 양방향매핑 @JoinColumn(name = "user_id") private User user; - @ManyToOne(fetch = FetchType.LAZY) // Room-UserRoom 양방향매핑 (default로 Lazy를 적용하되, Eager가 필요할경우 따로 메소드를 만들어 사용함.) + // UserRoom 조회시 UserRoom.room N+1 문제 해결 ==> Eager 조회 : '@EntityGraph 적용 메소드 활용', Lazy 조회 : '기본 지연로딩 용도' + @ManyToOne(fetch = FetchType.LAZY) // Room-UserRoom 양방향매핑 @JoinColumn(name = "room_id") private Room room; diff --git a/src/main/java/com/sajang/devracebackend/dto/room/RoomSaveResponseDto.java b/src/main/java/com/sajang/devracebackend/dto/room/RoomResponseDto.java similarity index 87% rename from src/main/java/com/sajang/devracebackend/dto/room/RoomSaveResponseDto.java rename to src/main/java/com/sajang/devracebackend/dto/room/RoomResponseDto.java index 938597f..54570f4 100644 --- a/src/main/java/com/sajang/devracebackend/dto/room/RoomSaveResponseDto.java +++ b/src/main/java/com/sajang/devracebackend/dto/room/RoomResponseDto.java @@ -9,7 +9,7 @@ @Getter @NoArgsConstructor @AllArgsConstructor -public class RoomSaveResponseDto { +public class RoomResponseDto { private Long roomId; } diff --git a/src/main/java/com/sajang/devracebackend/repository/RoomRepository.java b/src/main/java/com/sajang/devracebackend/repository/RoomRepository.java index 5ba27ac..af4d442 100644 --- a/src/main/java/com/sajang/devracebackend/repository/RoomRepository.java +++ b/src/main/java/com/sajang/devracebackend/repository/RoomRepository.java @@ -3,5 +3,8 @@ import com.sajang.devracebackend.domain.Room; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RoomRepository extends JpaRepository { + Optional findByLink(String link); } \ No newline at end of file diff --git a/src/main/java/com/sajang/devracebackend/repository/UserRepository.java b/src/main/java/com/sajang/devracebackend/repository/UserRepository.java index 73c471c..cd56c67 100644 --- a/src/main/java/com/sajang/devracebackend/repository/UserRepository.java +++ b/src/main/java/com/sajang/devracebackend/repository/UserRepository.java @@ -3,11 +3,20 @@ import com.sajang.devracebackend.domain.User; import com.sajang.devracebackend.domain.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { + // '@EntityGraph' 또는 'Fetch Join'을 통해 User 조회시, 내부의 Lazy 로딩으로 선언된 속성 또한 Eager로 한번에 조회. + // ==> N+1 문제 해결 (간단한 로직은 '@EntityGraph'로, 복잡한 로직이거나 기존 JPA 메소드와의 네이밍 충돌을 방지하고자하는 경우는 'Fetch Join'을 활용.) + + // - Eager 조회 : 'User + User.userRoomList' + @Query("SELECT u FROM User u JOIN FETCH u.userRoomList WHERE u.id = :userId") + Optional findByIdWithEagerUserRoomList(@Param("userId") Long userId); // 기존의 JPA findById() 메소드를 @Override 하지않고 유지하기위해, @Query로 새로운 메소드 네이밍을 지어주었음. -> '@EntityGraph' 말고 'Fetch Join' 사용. + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); // '소셜 타입, 식별자'로 해당 회원을 찾기 위한 메소드 (차후 추가정보 입력 회원가입 가능) boolean existsByBojId(String bojId); List findByIdIn(List userIdList); diff --git a/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java b/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java index 9445bb1..018d905 100644 --- a/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java +++ b/src/main/java/com/sajang/devracebackend/repository/UserRoomRepository.java @@ -7,21 +7,36 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface UserRoomRepository extends JpaRepository { + // '@EntityGraph' 또는 'Fetch Join'을 통해 UserRoom 조회시, 내부의 Lazy 로딩으로 선언된 속성 또한 Eager로 한번에 조회. + // ==> N+1 문제 해결 (간단한 로직은 '@EntityGraph'로, 복잡한 로직이거나 기존 JPA 메소드와의 네이밍 충돌을 방지하고자하는 경우는 'Fetch Join'을 활용.) - // 서비스 로직에 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); + // - Eager 조회 : 'UserRoom + UserRoom.room', Lazy 조회 : 'UserRoom.user' + @EntityGraph(attributePaths = {"room"}) // 반환값이 List가 아닌 고유한 하나의 값이기에, @EntityGraph임에도 DISTINCT 없이 작성했음. + Optional findByUser_IdAndRoom_Id(Long userId, Long roomId); // 이는 @Query가 아니므로, JPA네이밍 규칙에 영향을 받음. + + // - Eager 조회 : 'UserRoom + UserRoom.room + UserRoom.room.userRoomList', Lazy 조회 : 'UserRoom.user' + @Query("SELECT ur FROM UserRoom ur JOIN FETCH ur.room r JOIN FETCH r.userRoomList WHERE ur.user.id = :userId AND ur.room.id = :roomId") + Optional findByUser_IdAndRoom_IdWithUserRoomList(@Param("userId") Long userId, @Param("roomId") Long roomId); // @Query 이므로, With문처럼 JPA네이밍 규칙을 지키지않아도됨. + + // - Eager 조회 : 'UserRoom + UserRoom.room + UserRoom.room.problem', Lazy 조회 : 'UserRoom.user' + @Query("SELECT ur FROM UserRoom ur JOIN FETCH ur.room r JOIN FETCH r.problem WHERE ur.user.id = :userId AND ur.room.id = :roomId") + Optional findByUser_IdAndRoom_IdWithProblem(@Param("userId") Long userId, @Param("roomId") Long roomId); // @Query 이므로, With문처럼 JPA네이밍 규칙을 지키지않아도됨. boolean existsByUserAndRoom(User user, Room room); - 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 findAllByRoom_Link(String link, Pageable pageable); + // 밑의 3가지 메소드 모두, 페이징에서 OneToMany 속성을 Eager 조회시키지 않을것이기에, '중복 제거 처리' 및 '배치 사이즈 선언'을 하지 않았음. + @EntityGraph(attributePaths = {"room", "room.problem"}) + Page findAllByUser_IdAndIsLeave(Long userId, Integer isLeave, Pageable pageable); + + @EntityGraph(attributePaths = {"room", "room.problem"}) + Page findAllByUser_IdAndIsLeaveAndIsPass(Long userId, Integer isLeave, Integer isPass, Pageable pageable); + + @EntityGraph(attributePaths = {"room", "room.problem"}) + Page findAllByUser_IdAndIsLeaveAndRoom_Problem_Number(Long userId, Integer isLeave, Integer number, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/sajang/devracebackend/service/RoomService.java b/src/main/java/com/sajang/devracebackend/service/RoomService.java index 99b3ef5..95e789b 100644 --- a/src/main/java/com/sajang/devracebackend/service/RoomService.java +++ b/src/main/java/com/sajang/devracebackend/service/RoomService.java @@ -3,12 +3,13 @@ import com.sajang.devracebackend.domain.Room; import com.sajang.devracebackend.dto.problem.ProblemSaveRequestDto; import com.sajang.devracebackend.dto.room.RoomCheckStateResponseDto; -import com.sajang.devracebackend.dto.room.RoomSaveResponseDto; +import com.sajang.devracebackend.dto.room.RoomResponseDto; import java.io.IOException; public interface RoomService { Room findRoom(Long roomId); - RoomSaveResponseDto createRoom(ProblemSaveRequestDto problemSaveRequestDto) throws IOException; + RoomResponseDto findRoomByLink(String link); + RoomResponseDto createRoom(ProblemSaveRequestDto problemSaveRequestDto) throws IOException; RoomCheckStateResponseDto checkState(Long roomId); } diff --git a/src/main/java/com/sajang/devracebackend/service/UserRoomService.java b/src/main/java/com/sajang/devracebackend/service/UserRoomService.java index 69da67f..84a9417 100644 --- a/src/main/java/com/sajang/devracebackend/service/UserRoomService.java +++ b/src/main/java/com/sajang/devracebackend/service/UserRoomService.java @@ -11,12 +11,12 @@ import org.springframework.data.domain.Pageable; public interface UserRoomService { - UserRoom findUserRoom(Long userId, Long roomId); // Eager 조회 : 'UserRoom + 내부 Room', Lazy 조회 : '내부 User' + UserRoom findUserRoomWithEagerRoom(Long userId, Long roomId, boolean isIncludeUserRoomList, boolean isIncludeProblem); RoomWaitResponseDto userWaitRoom(RoomWaitRequestDto roomWaitRequestDto); void usersEnterRoom(Long roomId); void userStopWaitRoom(Long roomId); SolvingPageResponseDto loadSolvingPage(Long roomId); RoomCheckAccessResponseDto checkAccess(Long roomId); void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDto); - Page findCodeRoom(Integer isPass, Integer number, String link, Pageable pageable); + Page findCodeRooms(Integer isPass, Integer number, Pageable pageable); } diff --git a/src/main/java/com/sajang/devracebackend/service/UserService.java b/src/main/java/com/sajang/devracebackend/service/UserService.java index 8fa766c..8431c1f 100644 --- a/src/main/java/com/sajang/devracebackend/service/UserService.java +++ b/src/main/java/com/sajang/devracebackend/service/UserService.java @@ -13,7 +13,7 @@ public interface UserService { User findUser(Long userId); User findLoginUser(); - List findUsersOriginal(List userIdList, Boolean isDto); + List findUsersOriginal(List userIdList, boolean isDto); UserResponseDto findUserProfile(); void updateUserProfile(MultipartFile imageFile, UserUpdateRequestDto userUpdateRequestDto) throws IOException; UserSolvedResponseDto checkUserSolvedCount(); diff --git a/src/main/java/com/sajang/devracebackend/service/impl/AuthServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/AuthServiceImpl.java index 623a405..febd5bb 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/AuthServiceImpl.java @@ -43,7 +43,7 @@ public SignupResponseDto signup(MultipartFile imageFile, SignupRequestDto signup // - 기본사진으로 변경O : if 'imageFile == null && signupRequestDto.getIsImageChange() == 1' --> AWS S3 업로드X & User imageUrl값 null로 업데이트 if(signupRequestDto.getBojId() == null) { - throw new Exception400.UserBadRequest("회원가입 백준id null 에러"); + throw new Exception400.UserBadRequest("회원가입 bojId==null 에러"); } if(userRepository.existsByBojId(signupRequestDto.getBojId()) == true) { // 이미 해당 백준id로 가입한 사용자가 존재하는경우, 예외 처리. throw new Exception400.BojIdDuplicate(signupRequestDto.getBojId()); 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 8fd0018..cdd6f80 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/ChatServiceImpl.java @@ -81,7 +81,9 @@ else if(chatRequestDto.getMessageType().equals(MessageType.RANK)) { @Transactional(readOnly = true) @Override public Slice findChatsByRoom(Long roomId, Pageable pageable) { - UserRoom userRoom = userRoomService.findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); + + // 'UserRoom.room' Eager 로딩 (N+1 문제 해결) + UserRoom userRoom = userRoomService.findUserRoomWithEagerRoom(SecurityUtil.getCurrentMemberId(), roomId, false, false); LocalDateTime leaveTime = userRoom.getLeaveTime(); // 퇴장한 경우, 본인 퇴장시각 이하까지의 채팅 내역 조회 diff --git a/src/main/java/com/sajang/devracebackend/service/impl/RoomServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/RoomServiceImpl.java index 3055d14..706f5df 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/RoomServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/RoomServiceImpl.java @@ -4,9 +4,10 @@ import com.sajang.devracebackend.domain.Room; import com.sajang.devracebackend.dto.problem.ProblemSaveRequestDto; import com.sajang.devracebackend.dto.room.RoomCheckStateResponseDto; -import com.sajang.devracebackend.dto.room.RoomSaveResponseDto; +import com.sajang.devracebackend.dto.room.RoomResponseDto; import com.sajang.devracebackend.dto.user.UserResponseDto; import com.sajang.devracebackend.repository.*; +import com.sajang.devracebackend.response.exception.Exception400; import com.sajang.devracebackend.response.exception.Exception404; import com.sajang.devracebackend.service.ProblemService; import com.sajang.devracebackend.service.RoomService; @@ -34,12 +35,25 @@ public class RoomServiceImpl implements RoomService { @Override public Room findRoom(Long roomId) { return roomRepository.findById(roomId).orElseThrow( - ()->new Exception404.NoSuchRoom(String.format("roomId = %d", roomId))); + () -> new Exception404.NoSuchRoom(String.format("roomId = %d", roomId))); + } + + @Transactional(readOnly = true) + @Override + public RoomResponseDto findRoomByLink(String link) { + if(link == null) throw new Exception400.RoomBadRequest("방 조회 link==null 에러"); + + Room room = roomRepository.findByLink(link).orElseThrow( + () -> new Exception404.NoSuchRoom("link = " + link)); + + return RoomResponseDto.builder() + .roomId(room.getId()) + .build(); } @Transactional @Override - public RoomSaveResponseDto createRoom(ProblemSaveRequestDto problemSaveRequestDto) throws IOException { + public RoomResponseDto createRoom(ProblemSaveRequestDto problemSaveRequestDto) throws IOException { Integer problemNumber = problemSaveRequestDto.getProblemNumber(); Problem problem = problemRepository.findByNumber(problemNumber) @@ -59,11 +73,11 @@ public RoomSaveResponseDto createRoom(ProblemSaveRequestDto problemSaveRequestDt .problem(problem) .build(); Long roomId = roomRepository.save(room).getId(); - RoomSaveResponseDto roomSaveResponseDto = RoomSaveResponseDto.builder() + RoomResponseDto roomResponseDto = RoomResponseDto.builder() .roomId(roomId) .build(); - return roomSaveResponseDto; + return roomResponseDto; } @Transactional(readOnly = true) 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 77a80a2..5e4cb2a 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/UserRoomServiceImpl.java @@ -30,6 +30,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.function.BiFunction; import java.util.stream.Collectors; @Service @@ -42,14 +43,27 @@ 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(Long userId, Long roomId) { - return userRoomRepository.findByUser_IdAndRoom_Id(userId, roomId).orElseThrow( - ()->new Exception404.NoSuchUserRoom(String.format("userId = %d & roomId = %d", userId, roomId))); + public UserRoom findUserRoomWithEagerRoom(Long userId, Long roomId, boolean isIncludeUserRoomList, boolean isIncludeProblem) { + // 서비스 로직에 UserRoom과 내부의 Room 정보를 모두 조회하여 사용하는 경우가 많았기에, 이 코드를 작성함. + // '@EntityGraph' 또는 'Fetch Join'을 통해 UserRoom 조회시, 내부의 Lazy 로딩으로 선언된 속성 또한 Eager로 한번에 조회하여 N+1 문제를 해결함. + + if(!isIncludeUserRoomList && !isIncludeProblem) { + // - Eager 조회 : 'UserRoom + UserRoom.room', Lazy 조회 : 'UserRoom.user' + return findUserRoom(userId, roomId, userRoomRepository::findByUser_IdAndRoom_Id); + } + else if(isIncludeUserRoomList && !isIncludeProblem) { + // - Eager 조회 : 'UserRoom + UserRoom.room + UserRoom.room.userRoomList', Lazy 조회 : 'UserRoom.user' + return findUserRoom(userId, roomId, userRoomRepository::findByUser_IdAndRoom_IdWithUserRoomList); + } + else if(!isIncludeUserRoomList && isIncludeProblem) { + // - Eager 조회 : 'UserRoom + UserRoom.room + UserRoom.room.problem', Lazy 조회 : 'UserRoom.user' + return findUserRoom(userId, roomId, userRoomRepository::findByUser_IdAndRoom_IdWithProblem); + } + else { // 잘못된 파라미터 입력 + throw new IllegalArgumentException("메소드에 잘못된 파라미터를 입력하였습니다."); + } } @Transactional @@ -129,44 +143,36 @@ public void userStopWaitRoom(Long roomId) { @Transactional(readOnly = true) @Override public SolvingPageResponseDto loadSolvingPage(Long roomId) { - UserRoom userRoom = findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. - List rankUserIdList = userRoom.getRoom().getWaiting(); // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. + // 'UserRoom.room & UserRoom.room.problem' Eager 로딩 (N+1 문제 해결) + UserRoom userRoom = findUserRoomWithEagerRoom(SecurityUtil.getCurrentMemberId(), roomId, false, true); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. + + List rankUserIdList = userRoom.getRoom().getWaiting(); List rankUserDtoList = userService.findUsersOriginal(rankUserIdList, true); - return new SolvingPageResponseDto(userRoom, rankUserDtoList); // UserRoom의 Room의 Problem은 호출 빈도수가 낮기도하고, JPA메소드 네이밍이 겹치기에, eager 적용을 하지 않았음. + return new SolvingPageResponseDto(userRoom, rankUserDtoList); // Fetch Join으로 UserRoom 내부의 UserRoom.room과 UserRoom.room.problem은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. } @Transactional(readOnly = true) @Override public RoomCheckAccessResponseDto checkAccess(Long roomId) { -// System.out.println("========== !!! 메소드 시작 !!! ==========\n"); -// System.out.println("===== UserRoom 조회 ====="); + // 'UserRoom.room' Eager 로딩 (N+1 문제 해결) Optional optionalUserRoom = userRoomRepository.findByUser_IdAndRoom_Id(SecurityUtil.getCurrentMemberId(), roomId); -// System.out.println("===== UserRoom 조회 완료. [1번의 쿼리 발생] =====\n"); // 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()) // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. .isLeave(isLeave) .build(); -// System.out.println("===== UserRoom의 Room 내부의 변수를 사용. [@EntityGraph 미처리시 추가쿼리 발생 O] =====\n"); - -// System.out.println("========== !!! 메소드 종료 !!! =========="); return roomCheckAccessResponseDto; } @@ -174,11 +180,13 @@ public RoomCheckAccessResponseDto checkAccess(Long roomId) { @Transactional @Override public void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDto) { - UserRoom userRoom = findUserRoom(SecurityUtil.getCurrentMemberId(), roomId); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. - Room room = userRoom.getRoom(); // 이 시점에는 아직 @EntityGraph의 영향을 받지않아, 아직 조회 쿼리가 1번으로 유지됨. + + // 'UserRoom.room & UserRoom.userRoomList' Eager 로딩 (N+1 문제 해결) + UserRoom userRoom = findUserRoomWithEagerRoom(SecurityUtil.getCurrentMemberId(), roomId, true, false); // 어차피 문제풀이 페이지는 입장 이후이기에, 부모 Room을 갖고있는 자식 UserRoom은 반드시 존재함. + Room room = userRoom.getRoom(); // 이 시점에는 아직 Fetch Join의 영향을 받지않아, 아직 조회 쿼리가 1번으로 유지됨. userRoom.updateCode(userPassRequestDto.getCode()); - userRoom.updateIsLeave(1); + userRoom.updateIsLeave(1); // 더티체킹으로 update가 DB에 바로 반영되지않지만, UserRoom은 동일 트랜잭션 내의 JPA 영속성 컨텍스트가 관리하는 상위 엔티티이므로, 이후 호출되는 하위 'UserRoom.getRoom().getUserRoomList()'에서도 update상태 확인이 가능함. userRoom.updateLeaveTime(LocalDateTime.now()); if(userPassRequestDto.getIsRetry() == 0 || (userPassRequestDto.getIsRetry() == 1 && userPassRequestDto.getIsPass() == 1)) { @@ -186,28 +194,27 @@ public void passSolvingProblem(Long roomId, UserPassRequestDto userPassRequestDt userRoom.updateIsPass(userPassRequestDto.getIsPass()); } - List userRoomList = room.getUserRoomList(); // @EntityGraph로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. - Boolean isLeaveAllUsers = userRoomList.stream() - .allMatch(enterUserRoom -> enterUserRoom.getIsLeave() == 1); // 입장했던 모든 유저의 isLeave 값이 1인지 확인 + List userRoomList = room.getUserRoomList(); // Fetch Join으로 UserRoom 내부의 Room은 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. + Boolean isLeaveAllUsers = userRoomList.stream() // Fetch Join으로 UserRoom 내부의 Room.userRoomList는 Eager 로딩 처리되어, N+1 문제가 발생하지 않음. + .allMatch(enterUserRoom -> enterUserRoom.getIsLeave() == 1); // 입장했던 모든 유저의 isLeave 값이 1인지 확인 (DB에는 위의 updateIsLeave(1)가 아직 반영되지않았지만, 동일 트랜잭션 내라서 바로 확인이 가능함.) if(isLeaveAllUsers == true) room.updateRoomState(RoomState.FINISH); } @Transactional(readOnly = true) @Override - public Page findCodeRoom(Integer isPass, Integer number, String link, Pageable pageable) { + public Page findCodeRooms(Integer isPass, Integer number, Pageable pageable) { + Long loginUserId = SecurityUtil.getCurrentMemberId(); Page userRoomPage; - if(isPass == null && number == null && link == null) { // 전체 정렬 조회의 경우 - userRoomPage = userRoomRepository.findAllByIsLeaveAndUser_Id(1, SecurityUtil.getCurrentMemberId(), pageable); - } - else if(isPass != null && number == null && link == null) { // 성공or실패 정렬 조회의 경우 - userRoomPage = userRoomRepository.findAllByIsLeaveAndIsPass(1, isPass, pageable); + // 'UserRoom.room & UserRoom.room.problem' Eager 로딩 (N+1 문제 해결) + if(isPass == null && number == null) { // 전체 정렬 조회의 경우 + userRoomPage = userRoomRepository.findAllByUser_IdAndIsLeave(loginUserId, 1, pageable); } - else if(isPass == null && number != null && link == null) { // 문제번호 검색 조회의 경우 - userRoomPage = userRoomRepository.findAllByIsLeaveAndRoom_Problem_Number(1, number, pageable); + else if(isPass != null && number == null) { // 성공or실패 정렬 조회의 경우 + userRoomPage = userRoomRepository.findAllByUser_IdAndIsLeaveAndIsPass(loginUserId, 1, isPass, pageable); } - else if(isPass == null && number == null && link != null) { // 초대링크 탐색 조회의 경우 - userRoomPage = userRoomRepository.findAllByRoom_Link(link, pageable); + else if(isPass == null && number != null) { // 문제번호 검색 조회의 경우 + userRoomPage = userRoomRepository.findAllByUser_IdAndIsLeaveAndRoom_Problem_Number(loginUserId, 1, number, pageable); } else { // 잘못된 URI throw new Exception400.UserRoomBadRequest("잘못된 쿼리파라미터로 API를 요청하였습니다."); @@ -215,4 +222,12 @@ else if(isPass == null && number == null && link != null) { // 초대링크 탐 return userRoomPage.map(userRoom -> new CodeRoomResponseDto(userRoom)); } + + + // ========== 유틸성 메소드 ========== // + + private static UserRoom findUserRoom(Long userId, Long roomId, BiFunction> function) { // userId,roomId,함수를 받아서 UserRoom을 찾는 메소드 (dto가 아닌 entity를 반환하므로, private 접근제어자 사용.) + return function.apply(userId, roomId).orElseThrow( + () -> new Exception404.NoSuchUserRoom(String.format("userId = %d & roomId = %d", userId, roomId))); + } } diff --git a/src/main/java/com/sajang/devracebackend/service/impl/UserServiceImpl.java b/src/main/java/com/sajang/devracebackend/service/impl/UserServiceImpl.java index 5db2351..f07a55a 100644 --- a/src/main/java/com/sajang/devracebackend/service/impl/UserServiceImpl.java +++ b/src/main/java/com/sajang/devracebackend/service/impl/UserServiceImpl.java @@ -1,7 +1,6 @@ package com.sajang.devracebackend.service.impl; import com.sajang.devracebackend.domain.User; -import com.sajang.devracebackend.domain.mapping.UserRoom; import com.sajang.devracebackend.dto.user.UserCheckRoomResponseDto; import com.sajang.devracebackend.dto.user.UserResponseDto; import com.sajang.devracebackend.dto.user.UserSolvedResponseDto; @@ -22,7 +21,6 @@ import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; @Service @@ -37,7 +35,7 @@ public class UserServiceImpl implements UserService { @Override public User findUser(Long userId) { return userRepository.findById(userId).orElseThrow( - ()->new Exception404.NoSuchUser(String.format("userId = %d", userId))); + () -> new Exception404.NoSuchUser(String.format("userId = %d", userId))); } @Transactional(readOnly = true) @@ -50,7 +48,7 @@ public User findLoginUser() { @Transactional(readOnly = true) @Override - public List findUsersOriginal(List userIdList, Boolean isDto) { // 기존 userId리스트의 순서를 보장하는 User&UserDto리스트 반환 메소드 + public List findUsersOriginal(List userIdList, boolean isDto) { // 기존 userId리스트의 순서를 보장하는 User&UserDto리스트 반환 메소드 List userList = userRepository.findByIdIn(userIdList); Map userMap = userList.stream() .collect(Collectors.toMap(User::getId, user -> user)); @@ -106,26 +104,22 @@ public UserSolvedResponseDto checkUserSolvedCount() { @Transactional(readOnly = true) @Override public UserCheckRoomResponseDto checkCurrentRoom() { - User user = findLoginUser(); - Optional optionalUserRoom = user.getUserRoomList().stream() + // 'User.userRoomList' Eager 로딩 (N+1 문제 해결) + User user = userRepository.findByIdWithEagerUserRoomList(SecurityUtil.getCurrentMemberId()).orElseThrow( + () -> new Exception404.NoSuchUser(String.format("userId = %d", SecurityUtil.getCurrentMemberId()))); + + UserCheckRoomResponseDto userCheckRoomResponseDto = user.getUserRoomList().stream() .filter(userRoom -> userRoom.getIsLeave() == 0) // getIsLeave() 값이 0인 경우만 필터링 (참여중인 방 찾기) - .findFirst(); - UserRoom userRoom = optionalUserRoom.orElse(null); - - UserCheckRoomResponseDto userCheckRoomResponseDto; - if(userRoom == null) { // 참여중인 방이 없을때 X - userCheckRoomResponseDto = UserCheckRoomResponseDto.builder() - .isExistRoom(false) - .roomId(null) - .build(); - } - else { // 참여중인 방이 있을때 O - userCheckRoomResponseDto = UserCheckRoomResponseDto.builder() - .isExistRoom(true) - .roomId(userRoom.getRoom().getId()) - .build(); - } + .findFirst() + .map(userRoom -> UserCheckRoomResponseDto.builder() // 참여중인 방이 있을때 O + .isExistRoom(true) + .roomId(userRoom.getRoom().getId()) // User.userRoomList.room까지 Eager 처리하면 참여중인 방이 없을시의 성능 하락이 심하기에, 처리하지않음. + .build()) + .orElse(UserCheckRoomResponseDto.builder() // 참여중인 방이 없을때 X + .isExistRoom(false) + .roomId(null) + .build()); return userCheckRoomResponseDto; } @@ -133,7 +127,7 @@ public UserCheckRoomResponseDto checkCurrentRoom() { // ========== 유틸성 메소드 ========== // - public static UserSolvedResponseDto getSolvedCount(String bojId) { // WebClient로 외부 solved API 호출 메소드 + public static UserSolvedResponseDto getSolvedCount(String bojId) { // WebClient로 외부 solved API 호출 메소드 (entity가 아닌 dto를 반환하므로, public 접근제어자 가능.) try { WebClient webClient = WebClient.builder() .baseUrl("https://solved.ac/api/v3")