diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java b/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java index f828b597..2daf2689 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java @@ -1,6 +1,5 @@ package com.example.cp_main_be.domain.member.user.presentation; -import com.example.cp_main_be.domain.garden.garden.domain.Garden; import com.example.cp_main_be.domain.member.user.domain.User; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; import com.example.cp_main_be.domain.member.user.dto.request.AvatarChangeRequest; @@ -13,9 +12,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.Comparator; import java.util.List; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -89,12 +86,8 @@ public ResponseEntity> @GetMapping("/me/gardens") public ResponseEntity>> getMyGardenIds( @AuthenticationPrincipal User user) { - List gardenIds = - user.getGardens().stream() - .sorted(Comparator.comparing(Garden::getSlotNumber)) - .map(Garden::getId) - .collect(Collectors.toList()); - + // [수정] 서비스 계층으로 로직을 위임하여 트랜잭션 내에서 안전하게 데이터를 조회합니다. + List gardenIds = userService.getMyGardenIds(user); return ResponseEntity.ok(ApiResponse.success(gardenIds)); } diff --git a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java index f1fd251f..81b96a48 100644 --- a/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java +++ b/src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java @@ -16,14 +16,9 @@ import com.example.cp_main_be.domain.social.follow.domain.repository.FollowRepository; import com.example.cp_main_be.global.common.CustomApiException; import com.example.cp_main_be.global.common.ErrorCode; -import com.example.cp_main_be.global.exception.AvatarNotFoundException; -import com.example.cp_main_be.global.exception.UserNotFoundException; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; @@ -36,6 +31,8 @@ @Transactional public class UserService { + private static final int MAX_FRIEND_WATERING_PER_DAY = 3; + private final UserRepository userRepository; private final LevelService levelService; private final AvatarRepository avatarRepository; @@ -45,8 +42,8 @@ public class UserService { public void addExperience(Long actorId, int points) { User user = userRepository - .findById(actorId) - .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND.getMessage())); + .findById(actorId) // ID로 최신 유저 정보를 조회합니다. + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); user.addExperience(points); levelService.checkLevelUp(user); } @@ -59,7 +56,7 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId) Avatar avatar = avatarRepository .findById(avatarId) - .orElseThrow(() -> new AvatarNotFoundException("아바타를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_NOT_FOUND)); // [버그 수정] AvatarMaster(원본)가 아닌 Avatar(개별 인스턴스)의 imageUrl을 변경해야 합니다. // Avatar 엔티티에 imageUrl 필드가 있어야 합니다. @@ -74,8 +71,12 @@ public void updateAvatar(User user, AvatarChangeRequest request, Long avatarId) } public void updateNickname(User user, String newNickname) { - - user.updateProfile(newNickname, null); + // [수정] stale한 user 객체 대신, ID로 최신 정보를 조회해서 사용합니다. + User managedUser = + userRepository + .findById(user.getId()) + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); + managedUser.updateProfile(newNickname, null); userRepository.save(user); } @@ -86,21 +87,21 @@ public void saveUser(User user) { public void deleteUser(Long userId) { User user = userRepository - .findById(userId) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + .findById(userId) // ID로 최신 유저 정보를 조회합니다. + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); userRepository.delete(user); } public User findUserById(Long id) { return this.userRepository .findById(id) - .orElseThrow(() -> new UserNotFoundException("해당 ID의 사용자를 찾을 수 없습니다 : " + id)); + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); } public User findUserByUuid(UUID uuid) { return this.userRepository .findByUuid(uuid) - .orElseThrow(() -> new UserNotFoundException("해당 UUID의 사용자를 찾을 수 없습니다 : " + uuid)); + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); } public List findAllUsers() { @@ -128,11 +129,11 @@ public User getCurrentUser() { if (principal instanceof String uuidString) { UUID userUuid = UUID.fromString(uuidString); return userRepository - .findByUuid(userUuid) - .orElseThrow(() -> new IllegalArgumentException("현재 로그인한 사용자를 찾을 수 없습니다.")); + .findByUuid(userUuid) // UUID로 최신 유저 정보를 조회합니다. + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); } - throw new IllegalArgumentException("인증 정보를 찾을 수 없습니다."); + throw new CustomApiException(ErrorCode.INVALID_TOKEN, "인증 정보를 찾을 수 없습니다."); } /** @@ -147,9 +148,10 @@ public List getMyGardenIds(User user) { User managedUser = userRepository .findByIdWithGardens(user.getId()) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); return managedUser.getGardens().stream() + .filter(garden -> !garden.isLocked()) // [추가] 잠겨있지 않은(isLocked=false) 텃밭만 필터링합니다. .sorted(Comparator.comparing(Garden::getSlotNumber)) .map(Garden::getId) .collect(Collectors.toList()); @@ -165,12 +167,12 @@ public List getMyGardenIds(User user) { public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId) { User currentUser = userRepository - .findById(currentUserId) - .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); + .findById(currentUserId) // ID로 최신 유저 정보를 조회합니다. + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); User profileUser = userRepository .findByIdWithGardensAndAvatars(profileUserId) - .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); // [수정] 프로필 이미지 URL을 사용자의 첫 번째 아바타 이미지로 설정 String profileImageUrl = @@ -210,29 +212,28 @@ public UserProfileResponse getUserProfile(Long currentUserId, Long profileUserId int todayWateringCountForOthers = friendWateringLogRepository.countByWaterGiverAndWateredAtAfter( currentUser, startOfWateringDay); - Long leftWaterCountForOthers = - (long) (3 - todayWateringCountForOthers); // MAX_FRIEND_WATERING_PER_DAY = 3 + long leftWaterCountForOthers = + Math.max(0, (long) MAX_FRIEND_WATERING_PER_DAY - todayWateringCountForOthers); + + // [성능 개선] N+1 문제를 해결하기 위해, 오늘 내가 물 준 정원 ID 목록을 한 번에 조회합니다. + Set wateredGardenIds = + friendWateringLogRepository.findWateredGardenIdsByGiverAndDate( + currentUser.getId(), startOfWateringDay); // 3. 프로필 주인의 정원 목록 및 물주기 가능 여부 계산 List userGardens = profileUser.getGardens().stream() + .sorted(Comparator.comparing(Garden::getSlotNumber)) .map( garden -> { - // 현재 접속 유저가 이 정원에 오늘 물을 줄 수 있는지 여부 - boolean isWateringAbleByMe = false; - // 조건: 1) 아직 오늘 남에게 물 줄 수 있는 횟수가 남아있어야 하고 (leftWaterCountForOthers > 0) - // 2) 오늘 이 정원에 내가 물을 준 적이 없어야 한다. - if (leftWaterCountForOthers > 0) { - boolean alreadyWateredByMe = - friendWateringLogRepository - .existsByWaterGiverAndWateredGardenAndWateredAtAfter( - currentUser, garden, startOfWateringDay); - isWateringAbleByMe = !alreadyWateredByMe; - } + // DB를 반복 조회하는 대신, 미리 조회한 Set에서 확인하여 성능을 개선합니다. + boolean alreadyWateredByMe = wateredGardenIds.contains(garden.getId()); + boolean isWateringAbleByMe = leftWaterCountForOthers > 0 && !alreadyWateredByMe; HomeResponseDto.AvatarInfo avatarInfoForGarden = HomeResponseDto.AvatarInfo.builder() - .avatarId(garden.getAvatar().getId()) + // 아바타가 없는 텃밭이 있을 수 있는 예외 케이스를 방어합니다. + .avatarId(garden.getAvatar() != null ? garden.getAvatar().getId() : null) .avatarName(garden.getAvatar().getNickname()) .avatarImageUrl(garden.getAvatar().getAvatarMaster().getDefaultImageUrl()) .build(); diff --git a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java index e767dc99..82244137 100644 --- a/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java +++ b/src/main/java/com/example/cp_main_be/global/common/ErrorCode.java @@ -41,6 +41,7 @@ public enum ErrorCode { AVATAR_MASTER_NOT_FOUND(HttpStatus.NOT_FOUND, "E40408", "해당 아바타 원본을 찾을 수 없습니다."), WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40409", "소원나무 정보를 찾을 수 없습니다."), DEFAULT_RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40410", "필수 기본 리소스를 찾을 수 없습니다."), + AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "E40411", "해당 아바타를 찾을 수 없습니다."), // 417 Expectation Failed UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."),