Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.example.cp_main_be.domain.garden.garden.service;

import com.example.cp_main_be.domain.avatar.avatar.domain.Avatar;
import com.example.cp_main_be.domain.avatar.avatar.domain.repository.AvatarRepository;
import com.example.cp_main_be.domain.garden.garden.domain.Garden;
import com.example.cp_main_be.domain.garden.garden.domain.GardenBackground;
import com.example.cp_main_be.domain.garden.garden.domain.repository.GardenBackgroundRepository;
Expand All @@ -11,6 +13,9 @@
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.service.UserService;
import com.example.cp_main_be.domain.mission.wishTree.WishTree;
import com.example.cp_main_be.domain.mission.wishTree.WishTreeRepository;
import com.example.cp_main_be.domain.mission.wishTree.WishTreeStage;
import com.example.cp_main_be.global.common.CustomApiException;
import com.example.cp_main_be.global.common.ErrorCode;
import java.time.LocalDateTime;
Expand Down Expand Up @@ -39,8 +44,10 @@ public class GardenService {
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;
private final GardenBackgroundRepository gardenBackgroundRepository;
private final AvatarRepository avatarRepository;
private final UserRepository userRepository;
private final FriendWateringLogRepository friendWateringLogRepository;
private final WishTreeRepository wishTreeRepository;

public GardenResponse findGardenById(Long gardenId) {
Garden garden =
Expand Down Expand Up @@ -160,21 +167,55 @@ public void unlockNewGardenSlot(Long userId) {
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

int currentGardens = user.getGardens().size();
WishTree wishTree =
wishTreeRepository
.findByUserId(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND));

// 최대 텃밭 개수 제한은 여전히 유효하므로 여기서 검사
if (currentGardens >= MAX_GARDEN_COUNT) {
// 이미 최대치이므로 조용히 종료하거나 예외를 던질 수 있습니다.
// 여기서는 추가 생성을 막고 그냥 리턴합니다.
throw new CustomApiException(ErrorCode.GARDEN_SLOT_MAXED_OUT);
}
// 1. 사용자의 WishTree 포인트를 기준으로 현재 단계를 결정
// User는 WishTree를 가지고 있고, WishTree가 포인트를 관리합니다.
WishTreeStage currentStage = WishTreeStage.getStageForPoints(wishTree.getPoints());

// [기존 레벨 체크 로직 삭제!]
// 2. 현재 단계에서 가질 수 있는 최대 텃밭 개수를 가져옴
long maxGardensForStage = currentStage.getMaxGardens();

// TODO: 새로 생성된 텃밭의 기본 Avatar, Background 설정 로직 필요
Garden newGarden = Garden.builder().user(user).slotNumber(currentGardens + 1).build();
// 3. 사용자가 현재 보유한 텃밭 개수 확인
int currentGardenCount = user.getGardens().size();

// 4. 최대 텃밭 개수에 도달했는지 확인
if (currentGardenCount >= maxGardensForStage) {
// 현재 단계에서는 더 이상 텃밭을 생성할 수 없음
// ErrorCode에 GARDEN_SLOT_LOCKED 추가가 필요할 수 있습니다.
throw new CustomApiException(
ErrorCode.GARDEN_SLOT_LOCKED, "현재 단계에서는 더 이상 텃밭을 만들 수 없습니다. 소원나무를 성장시켜주세요.");
}
Comment on lines +188 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

슬롯 확장 동시성 취약 — 중복 슬롯/초과 생성 가능성

동시 요청에서 currentGardenCount 기반 계산은 경쟁 조건을 유발합니다. 사용자별 슬롯 행 잠금 또는 고유 제약 + 비관적 잠금으로 방어하세요.

DB/리포지토리/서비스 제안:

-- DB: 사용자별 슬롯번호 유니크 제약
ALTER TABLE garden ADD CONSTRAINT uq_garden_user_slot UNIQUE (user_id, slot_number);
// GardenRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select coalesce(max(g.slotNumber),0) from Garden g where g.user.id = :userId")
int findMaxSlotNumberByUserIdForUpdate(Long userId);
- int currentGardenCount = user.getGardens().size();
+ int currentMaxSlot = gardenRepository.findMaxSlotNumberByUserIdForUpdate(userId);
...
- if (currentGardenCount >= maxGardensForStage) {
+ if (currentMaxSlot >= maxGardensForStage) {
     throw new CustomApiException(
         ErrorCode.GARDEN_SLOT_LOCKED, "현재 단계에서는 더 이상 텃밭을 만들 수 없습니다. 소원나무를 성장시켜주세요.");
   }
...
- .slotNumber(currentGardenCount + 1)
+ .slotNumber(currentMaxSlot + 1)
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 188 to 193, the current check using currentGardenCount can race
under concurrent requests allowing duplicate/over-limit garden creation; fix by
enforcing a DB-level unique constraint on (user_id, slot_number) and performing
the count/slot selection inside a transactional method with a pessimistic lock
query (or select max(slot_number) FOR UPDATE) on the user's garden rows so
concurrent threads serialize; adjust GardenRepository to provide a
PESSIMISTIC_WRITE locked accessor and wrap creation in a single @Transactional
unit that rechecks limits and handles unique-constraint violation to return a
proper ErrorCode.GARDEN_SLOT_LOCKED response.


// 5. 새로 생성될 텃밭의 기본 배경과 아바타를 설정 (ID 1L을 기본값으로 가정)
GardenBackground defaultBackground =
gardenBackgroundRepository
.findById(1L)
.orElseThrow(
() ->
new CustomApiException(
ErrorCode.DEFAULT_RESOURCE_NOT_FOUND, "기본 텃밭 배경을 찾을 수 없습니다."));
Avatar defaultAvatar =
avatarRepository
.findById(1L)
.orElseThrow(
() ->
new CustomApiException(
ErrorCode.DEFAULT_RESOURCE_NOT_FOUND, "기본 아바타를 찾을 수 없습니다."));

// 6. 새로운 텃밭 생성
Garden newGarden =
Garden.builder()
.user(user)
.slotNumber(currentGardenCount + 1)
.gardenBackground(defaultBackground)
.avatar(defaultAvatar)
.build();

gardenRepository.save(newGarden);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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;
Expand All @@ -13,6 +14,9 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.websocket.server.PathParam;
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;
Expand Down Expand Up @@ -80,6 +84,21 @@ public ResponseEntity<ApiResponse<UserRegisterResponse.LevelStatusResponseDTO>>
return ResponseEntity.ok(ApiResponse.success(levelStatusResponseDTO));
}

@Operation(
summary = "내 텃밭 ID 목록 조회",
description = "현재 로그인한 유저와 연결된 모든 텃밭의 ID 목록을 조회합니다. 텃밭 슬롯 번호 순으로 정렬됩니다.")
@GetMapping("/me/gardens")
public ResponseEntity<ApiResponse<List<Long>>> getMyGardenIds(
@AuthenticationPrincipal User user) {
List<Long> gardenIds =
user.getGardens().stream()
.sorted(Comparator.comparing(Garden::getSlotNumber))
.map(Garden::getId)
.collect(Collectors.toList());

return ResponseEntity.ok(ApiResponse.success(gardenIds));
}
Comment on lines +87 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

컨트롤러에서 LAZY 컬렉션 접근 위험 및 N+1 가능성

user.getGardens()가 LAZY면 트랜잭션 밖에서 LazyInitializationException이 날 수 있습니다. 또한 정렬/매핑을 애그리거트 외부에서 수행 중입니다. 서비스 계층에서 fetch join으로 ID만 조회하는 리포지토리 메서드를 도입하세요.

권장 리포지토리/서비스 예시:

// GardenRepository
@Query("select g.id from Garden g where g.user.id = :userId order by g.slotNumber asc")
List<Long> findIdsByUserIdOrderBySlot(Long userId);
-List<Long> gardenIds =
-    user.getGardens().stream()
-        .sorted(Comparator.comparing(Garden::getSlotNumber))
-        .map(Garden::getId)
-        .collect(Collectors.toList());
+List<Long> gardenIds = gardenService.findMyGardenIds(user.getId());

검증 스크립트(매핑 확인):


🏁 Script executed:

#!/bin/bash
# User 엔티티에서 gardens 컬렉션 fetch 타입/매핑 확인
rg -n -C3 'class\s+User\b'
rg -n -C2 '@OneToMany[^\n]*gardens'

Length of output: 750


이전 스크립트가 @OneToMany 어노테이션과 gardens 필드를 정확히 찾지 못해 매핑 정보를 확인할 수 없습니다. User 엔티티에서 해당 필드 선언과 페치 전략을 명확히 보기 위해 아래 스크립트를 실행해주세요.


🏁 Script executed:

#!/bin/bash
# User 엔티티에서 @OneToMany 어노테이션 위치와 속성 확인
rg -n -C3 '@OneToMany' src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java

# User 엔티티에서 gardens 필드 선언부 확인
rg -n -C3 'gardens' src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java

Length of output: 1488


컨트롤러에서 LAZY 컬렉션 직접 접근 위험 – 서비스 계층으로 조회 로직 이동 필요

현재 User 엔티티의 gardens 컬렉션은 @OneToMany(mappedBy="user", cascade=ALL, orphanRemoval=true)로 선언되어 있어 JPA 기본 페치 전략인 LAZY를 사용합니다. 컨트롤러(getMyGardenIds)에는 트랜잭션이 걸려 있지 않으므로, 요청 처리 시점에 세션이 닫혀 LazyInitializationException이 발생할 수 있습니다. 또한, 정렬·매핑 로직이 프레젠테이션 계층에 흩어져 있어 책임 분리가 미흡합니다.

  • User 엔티티 매핑 확인

    • @OneToMany(mappedBy="user", …) 에서 fetch 속성 미설정 → 기본 LAZY 페치
    • private List<Garden> gardens = new ArrayList<>();
  • 컨트롤러 문제

    • @GetMapping 메서드에 @Transactional 미적용
    • user.getGardens() 호출 시점에 트랜잭션/세션 미존재 → LazyInitializationException 리스크
    • 프레젠테이션 계층에서 정렬·매핑 로직 수행 → SRP 위반

제안하는 리팩터링

  1. Repository에 사용자별 텃밭 ID만 조회하는 JPQL 메서드 추가
    public interface GardenRepository extends JpaRepository<Garden, Long> {
      @Query("select g.id from Garden g where g.user.id = :userId order by g.slotNumber asc")
      List<Long> findIdsByUserIdOrderBySlot(Long userId);
    }
  2. Service 계층에 트랜잭셔널 메서드 구현
    @Service
    public class GardenService {
      private final GardenRepository gardenRepo;
      @Transactional(readOnly = true)
      public List<Long> getMyGardenIds(Long userId) {
        return gardenRepo.findIdsByUserIdOrderBySlot(userId);
      }
    }
  3. 컨트롤러에서 직접 엔티티 컬렉션 대신 서비스 호출로 교체
    - List<Long> gardenIds =
    -     user.getGardens().stream()
    -         .sorted(Comparator.comparing(Garden::getSlotNumber))
    -         .map(Garden::getId)
    -         .collect(Collectors.toList());
    + List<Long> gardenIds = gardenService.getMyGardenIds(user.getId());

이렇게 하면

  • 불필요한 엔티티 그래프 로딩 방지
  • 트랜잭션 경계 내에서 안전하게 쿼리 수행
  • 프레젠테이션 계층의 역할이 명확해집니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java
lines 87-100, the controller directly accesses the LAZY User.gardens collection
(user.getGardens()) which can trigger LazyInitializationException and mixes
presentation logic with data access; replace this by adding a GardenRepository
method to select garden IDs by userId ordered by slot, implement a @Service
GardenService with a @Transactional(readOnly=true) method getMyGardenIds(Long
userId) that calls the repository query, and update the controller to call that
service method (passing the authenticated user's id) and return the result
instead of iterating over user.getGardens(). Ensure no direct traversal of the
LAZY collection in the controller.


@Operation(summary = "유저 정보 조회", description = "유저 정보를 조회합니다")
@GetMapping("/{userId}")
public ResponseEntity<ApiResponse<UserProfileResponse>> getUserInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ public enum ErrorCode {
HttpStatus.BAD_REQUEST, "E40003", "오늘은 다른 사람의 정원에 더 이상 물을 줄 수 없습니다."),
ALREADY_WATERED_GARDEN(HttpStatus.BAD_REQUEST, "E40004", "이 정원에는 오늘 이미 물을 주었습니다."),
SUNLIGHT_COOL_DOWN(HttpStatus.BAD_REQUEST, "E40005", "오늘은 이미 햇빛을 주었습니다."),
GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."),
GARDEN_SLOT_MAXED_OUT(
HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."), // 최종 3개 도달 시 사용 가능
INVALID_FILE(HttpStatus.BAD_REQUEST, "E40007", "적절하지 않은 파일 내용/포맷입니다."),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E40008", "파일 크기 제한을 넘었습니다."),
GARDEN_SLOT_LOCKED(
HttpStatus.BAD_REQUEST, "E40008", "현재 단계에서는 더 이상 텃밭을 만들 수 없습니다. 소원나무를 성장시켜주세요."),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E40009", "파일 크기 제한을 넘었습니다."),
INVALID_MISSION_TYPE_FOR_QUIZ_OPTION(
HttpStatus.BAD_REQUEST, "E40009", "퀴즈 타입의 미션에만 선지를 추가할 수 있습니다."),
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "E40010", "잘못된 요청입니다."),
HttpStatus.BAD_REQUEST, "E40010", "퀴즈 타입의 미션에만 선지를 추가할 수 있습니다."),
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "E40011", "잘못된 요청입니다."),

// 403 Forbidden
ACCESS_DENIED(HttpStatus.FORBIDDEN, "E40301", "요청에 대한 권한이 없습니다."),
Expand All @@ -35,6 +38,8 @@ public enum ErrorCode {
QUIZ_NOT_FOUND(HttpStatus.NOT_FOUND, "E40406", "해당 퀴즈를 찾을 수 없습니다."),
REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "E40407", "해당 신고를 찾을 수 없습니다."),
AVATAR_MASTER_NOT_FOUND(HttpStatus.NOT_FOUND, "E40408", "해당 아바타 원본을 찾을 수 없습니다."),
WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40409", "소원나무 정보를 찾을 수 없습니다."),
DEFAULT_RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40410", "필수 기본 리소스를 찾을 수 없습니다."),

// 417 Expectation Failed
UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."),
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ spring:
password:
jpa:
hibernate:
ddl-auto: create-drop
ddl-auto: update
defer-datasource-initialization: true
h2:
console:
Expand Down