Refactor/diary 코드 확인#114
Hidden character warning
Conversation
Walkthrough여러 도메인에서 예외 처리를 ErrorCode/CustomApiException으로 표준화하고, 다이어리 이미지 업로드를 DiaryImageService로 분리. 다이어리 생성은 ID 반환 후 재조회로 변경. 가든 물주기 로직 리팩터링(소유자/친구 분기), 스케줄러 기반 오래된 워터링 로그 일괄 삭제 및 관련 리포지토리/엔티티 보강. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as 사용자
participant C as DiaryImageController
participant S as DiaryImageService
participant Upl as S3Uploader
participant R as DiaryImageRepository
U->>C: POST /diaries/images (파일)
C->>S: uploadDiaryImage(file, user)
S->>Upl: upload(file, "diary-images")
Upl-->>S: imageUrl
S->>R: save(DiaryImage{imageUrl, user})
R-->>S: saved(Image id)
S-->>C: ImageUploadResponse{id, imageUrl}
C-->>U: 200 OK + {imageId, imageUrl}
sequenceDiagram
autonumber
actor U as 사용자
participant DC as DiaryController
participant DS as DiaryService
participant DR as DiaryRepository
U->>DC: POST /diaries (title, content, imageId?)
DC->>DS: createDiary(user, req)
DS->>DR: save(diary with optional image link)
DR-->>DS: saved(id)
DS-->>DC: diaryId
DC->>DS: findDiaryById(diaryId)
DS-->>DC: Diary
DC-->>U: 200 OK + DiaryResponse
sequenceDiagram
autonumber
actor A as 사용자
participant GS as GardenService
participant GR as GardenRepository
participant FR as FriendWateringLogRepository
A->>GS: waterGarden(gardenId)
GS->>GR: findById(gardenId)
alt 소유자 물주기
GS->>GS: check 8h cooldown
GS->>GS: increment waterCount, award points
else 친구 물주기
GS->>GS: check daily limit & already watered today
GS->>GS: increment waterCount, award points
GS->>FR: save(FriendWateringLog)
end
GS-->>A: 성공 또는 CustomApiException
note right of GS: 매일 04:00 @Scheduled로 오래된 로그 삭제 실행
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java (1)
81-86: updateImage 메서드: 이전 DiaryImage 연관 해제 누락 → unique 제약 충돌 위험
DiaryImage.java에서
@OneToOne
@JoinColumn(name = "diary_id", unique = true)
설정을 확인했습니다. 이로 인해 각 DiaryImage는 유일하게 하나의 Diary만 참조할 수 있습니다.Diary.java의
@OneToOne(mappedBy = "diary", fetch = FetchType.LAZY)매핑에는 cascade나 orphanRemoval 옵션이 없어, 기존에 연결된 DiaryImage 인스턴스가 수동으로 해제되지 않으면 DB의 unique 제약 위반이 발생할 수 있습니다.필수 조치: updateImage 호출 시 이전 연관을 해제하도록 수정하세요.
— 수정 제안 (src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java:81–86)
public void updateImage(DiaryImage diaryImage) { - this.diaryImage = diaryImage; - if (diaryImage != null) { - diaryImage.setDiary(this); // 자식(DiaryImage)에게 부모(Diary)가 누구인지 알려줌 - } + DiaryImage prev = this.diaryImage; + this.diaryImage = diaryImage; + // 이전 연관 해제 + if (prev != null && prev != diaryImage) { + prev.setDiary(null); + } + // 새 연관 설정 + if (diaryImage != null) { + diaryImage.setDiary(this); + } }추가 권고 사항:
- 필요하다면
Diary엔티티의@OneToOne(mappedBy = "diary", orphanRemoval = true)옵션을 통해 고아 객체 자동 삭제을 활성화하는 것도 고려해 주세요.src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (4)
58-77: 퀴즈 옵션 미저장 가능성: 영속화 누락
quizOptionsList를 생성만 하고 저장하지 않습니다.Quiz↔QuizOptions에 cascade(PERSIST)가 설정되지 않았다면 옵션이 DB에 저장되지 않습니다. 현재 코드만 보면 컬렉션을quiz에 추가도 하지 않아 cascade가 있어도 동작하지 않을 수 있습니다.옵션 1: 리포지토리로 일괄 저장
List<QuizOptions> quizOptionsList = createQuizRequestDTO.getQuizOptions().stream() .map( quizOption -> QuizOptions.builder() .quiz(quiz) .optionText(quizOption.getOptionText()) .optionOrder(quizOption.getOptionOrder()) .build()) .toList(); + quizOptionsRepository.saveAll(quizOptionsList);파일 상단/필드 주입(외부 변경 필요):
import com.example.cp_main_be.domain.mission.quiz.domain.repository.QuizOptionsRepository; @RequiredArgsConstructor public class AdminService { // ... private final QuizOptionsRepository quizOptionsRepository; }옵션 2: 연관관계 편의 메서드 + cascade로 관리 (권장)
Quiz엔티티에@OneToMany(mappedBy="quiz", cascade = PERSIST, orphanRemoval = true)와addOption(QuizOptions o)추가- 여기서는
quiz.addOption(...)으로만 추가 후quizRepository.save(quiz)로 일괄 영속화
155-171: createQuizOption: 생성만 하고 반환 — 영속화 누락
QuizOptions.builder(...).build()후save를 호출하지 않아 영속화되지 않습니다. 반환 객체는 transient 상태입니다.public QuizOptions createQuizOption( AdminRequestDTO.CreateQuizOptionRequestDTO requestDTO, Long quizId) { Quiz quiz = quizRepository .findById(quizId) .orElseThrow(() -> new CustomApiException(ErrorCode.QUIZ_NOT_FOUND)); if (quiz.getDailyMissionMaster().getMissionType() != MissionType.QUIZ) { throw new CustomApiException(ErrorCode.INVALID_MISSION_TYPE_FOR_QUIZ_OPTION); } - return QuizOptions.builder() // QuizOptions 퀴즈의 선지 - .optionText(requestDTO.getOptionText()) - .optionOrder(requestDTO.getOptionOrder()) - .quiz(quiz) - .build(); + QuizOptions option = QuizOptions.builder() + .optionText(requestDTO.getOptionText()) + .optionOrder(requestDTO.getOptionOrder()) + .quiz(quiz) + .build(); + return quizOptionsRepository.save(option); }
189-192: addDeliveryPlant: DeliveryPlant 영속화 누락S3 업로드 후
DeliveryPlant를 build만 하고 저장하지 않습니다. DB 반영을 위해 리포지토리 저장이 필요합니다. 또한 업로드 실패 시 예외를CustomApiException으로 변환하는 것이 일관된 에러 처리에 도움이 됩니다.public DeliveryPlant addDeliveryPlant(MultipartFile file, DeliveryPlantRequest request) { - String imageUrl = s3Uploader.upload(file, "/deliveryplant"); - return DeliveryPlant.builder().name(request.getName()).imageUrl(imageUrl).build(); + try { + String imageUrl = s3Uploader.upload(file, "/deliveryplant"); + DeliveryPlant plant = DeliveryPlant.builder() + .name(request.getName()) + .imageUrl(imageUrl) + .build(); + return deliveryPlantRepository.save(plant); + } catch (Exception e) { + // ErrorCode.FILE_UPLOAD_FAILED 존재 여부에 따라 적절히 치환 + throw new CustomApiException(ErrorCode.FILE_UPLOAD_FAILED, "배송 식물 이미지 업로드 실패"); + } }필드 주입(외부 변경 필요):
import com.example.cp_main_be.domain.delivery.domain.repository.DeliveryPlantRepository; private final DeliveryPlantRepository deliveryPlantRepository;
58-77: QuizOptions 영속화 로직 누락 확인 및 처리 필요관리자용 퀴즈 생성(createQuiz) 시 QuizOptions 엔티티가 실제로 저장되지 않습니다. 코드베이스를 확인한 결과:
- QuizOptionsRepository 인터페이스는 존재하나(src/.../QuizOptionsRepository.java), AdminService에서 주입·사용되고 있지 않습니다.
- Quiz 엔티티(src/.../Quiz.java)에는 반대편 @onetomany 매핑이 없고, cascade 옵션도 설정되지 않았습니다.
- createQuiz 메서드에서는 quizOptionsList를 생성만 하고 quizOptionsRepository.saveAll 혹은 quiz 엔티티에 연관관계를 설정한 뒤 cascade로 저장하는 로직이 빠져 있습니다.
따라서 다음 중 하나를 반영해 주세요:
- QuizOptionsRepository를 AdminService에 주입(private final QuizOptionsRepository quizOptionsRepository) 후
quizOptionsRepository.saveAll(quizOptionsList) 호출
(또는 개별 save 호출)- Quiz 엔티티에
필드를 추가하고, createQuiz에서@OneToMany(mappedBy = "quiz", cascade = CascadeType.PERSIST, orphanRemoval = true) private List<QuizOptions> options = new ArrayList<>();quiz.setOptions(quizOptionsList)후 quizRepository.save(quiz) 실행이 중에서 팀 컨벤션에 맞는 방식을 선택해 영속화 로직을 보완해 주세요.
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (2)
45-52: 예외 타입 일관화: CustomApiException 사용 권장findGardenById는 여전히 IllegalArgumentException을 던집니다. 서비스 전반에서 ErrorCode 기반 예외를 사용하도록 통일하세요.
public GardenResponse findGardenById(Long gardenId) { Garden garden = gardenRepository .findById(gardenId) - .orElseThrow(() -> new IllegalArgumentException("Garden not found")); + .orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND)); return GardenResponse.from(garden); }
139-151: CustomApiException + ErrorCode 통일 및 SUNLIGHT_ALREADY_GIVEN_TODAY 추가 필요아래와 같이
GardenService내 예외를CustomApiException+ 전용ErrorCode로 통일하면서, 누락된SUNLIGHT_ALREADY_GIVEN_TODAY코드를ErrorCodeenum에 추가해주세요.· 점검 결과
–GARDEN_NOT_FOUND(E40402) ✔
–ACCESS_DENIED(E40301) ✔
–SUNLIGHT_ALREADY_GIVEN_TODAY❌· 수정이 필요한 위치
src/main/java/com/example/cp_main_be/global/common/ErrorCode.javasrc/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
- ErrorCode enum에
SUNLIGHT_ALREADY_GIVEN_TODAY추가enum ErrorCode { // ... GARDEN_NOT_FOUND(HttpStatus.NOT_FOUND, "E40402", "해당 텃밭을 찾을 수 없습니다."), + SUNLIGHT_ALREADY_GIVEN_TODAY(HttpStatus.BAD_REQUEST, "E40003", "오늘은 이미 햇빛을 주었습니다. 내일 오전 6시 이후에 다시 시도해주세요."), WATERING_COOL_DOWN(HttpStatus.BAD_REQUEST, "E40002", "아직 물을 줄 수 없습니다. 8시간이 지나야 가능합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "E40301", "요청에 대한 권한이 없습니다."), // ... }
GardenService.java에서 예외를CustomApiException+ErrorCode로 대체.orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND)); // 햇빛은 본인만 줄 수 있도록 검증 if (!garden.getUser().getId().equals(actorId)) { - throw new CustomApiException(ErrorCode.ACCESS_DENIED, "자신의 정원에만 햇빛을 줄 수 있습니다."); + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "자신의 정원에만 햇빛을 줄 수 있습니다."); } // 하루에 한 번만 햇빛을 줄 수 있도록 체크 (초기화 시간: 오전 6시) LocalDateTime startOfSunlightDay = getStartOfCurrentSunlightDay(); if (garden.getLastSunlightReceivedAt() != null && garden.getLastSunlightReceivedAt().isAfter(startOfSunlightDay)) { - throw new CustomApiException(ErrorCode.SUNLIGHT_ALREADY_GIVEN_TODAY, - "오늘은 이미 햇빛을 주었습니다. 내일 오전 6시 이후에 다시 시도해주세요."); + throw new CustomApiException(ErrorCode.SUNLIGHT_ALREADY_GIVEN_TODAY, + "오늘은 이미 햇빛을 주었습니다. 내일 오전 6시 이후에 다시 시도해주세요."); }이후에는 모든 햇빛/물주기 로직에서 일관된
ErrorCode사용이 보장됩니다.src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (1)
29-38: createDiary 반환형 변경에 따른 테스트 코드 업데이트 필요Controller 쪽 호출부는
Long diaryId = diaryService.createDiary(…)이후findDiaryById(diaryId)로 정상 업데이트된 것이 확인되었습니다.
그러나 테스트 코드에서는createDiary호출부가 주석 처리되어 있어 실제 동작 검증이 누락된 상태입니다.점검할 위치
src/test/java/com/example/cp_main_be/domain/social/diary/service/DiaryServiceTest.java위 주석을 제거하고, 반환값을
- 78번, 100번 라인:
// diaryService.createDiary(ownerUser, request);Long으로 받아 적절한 assertion 로직(예:verify(diaryRepository).save(…)또는assertEquals(expectedId, actualId))을 추가해 주세요.
🧹 Nitpick comments (22)
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java (2)
25-27: 주석과 검증 규칙 불일치: isPublic 선택값 vs @NotNull 필수값주석(“값을 보내지 않으면 기본값(true)”)과 달리 @NotNull로 필수 입력이 됩니다. 의도에 맞추어 아래 중 하나로 정리해 주세요.
- 선택값(권장): @NotNull 제거 후 null이면 서비스/엔티티 기본값(true) 적용
- 필수값: 주석을 “필수값”으로 수정
적용 예시(선택값으로 운영):
- @NotNull(message = "공개 여부는 필수값입니다. (true/false)") private Boolean isPublic;로직 일관성 확인을 위해, Controller/Service에서 null인 isPublic 처리(기본 true 적용)가 구현되어 있는지 한번 점검 부탁드립니다.
22-23: imageUrl는 폐지(또는 비권장) 방향이면 @deprecated 표시 고려이미지 참조가 imageId로 전환되는 흐름이면, 혼선을 줄이기 위해 imageUrl 필드에 @deprecated를 붙이거나 API 문서에 우선순위를 명시해 주세요. 동시에 두 필드가 들어올 때의 처리도 문서화가 필요합니다.
적용 예시:
- // 이미지는 보통 S3 같은 곳에 먼저 업로드한 뒤, 그 URL을 받아와 저장합니다. - private String imageUrl; + // 기존 URL 직접 전달 방식은 imageId 도입으로 대체 예정 + @Deprecated + private String imageUrl;src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java (1)
22-23: imageUrl 비권장 시 @deprecated 고려Create와 동일한 맥락으로, imageId 중심 흐름이면 imageUrl에 @deprecated 부착 및 문서화가 혼선을 줄입니다.
- private String imageUrl; + @Deprecated + private String imageUrl;src/main/java/com/example/cp_main_be/global/common/ErrorCode.java (3)
12-22: HTTP 상태 재매핑 검토(표준 관례와의 정렬)아래 항목들은 업계 관례에 비춰 다음 상태 코드가 더 적합할 수 있습니다. 호환성 영향이 있으므로 신중히 검토해 주세요.
- WATERING_COOL_DOWN, SUNLIGHT_COOL_DOWN, FRIEND_WATERING_LIMIT_EXCEEDED: 429 TOO_MANY_REQUESTS 제안(쿨다운/레이트 제한 성격).
- FILE_SIZE_EXCEEDED: 413 PAYLOAD_TOO_LARGE 제안.
- INVALID_FILE(포맷/콘텐츠 부적합): 415 UNSUPPORTED_MEDIA_TYPE 고려(또는 계속 400).
- UPLOAD_FAILED: 417은 드문 선택입니다. 500/502/503 중 상황에 맞게 재매핑 권장.
변경 시, 게이트웨이/모바일 클라이언트에서 상태 코드 분기 의존성이 있는지 영향 범위를 확인해 주세요.
Also applies to: 24-26, 38-40
45-48: ErrorCode 역매핑/중복 방지 유틸 추가 제안(fromCode, 중복 코드 가드)운영/로그 파이프라인에서 문자열 코드 → enum 역매핑이 필요할 수 있습니다. 또한 동일 코드 중복을 조기에 감지하면 안전합니다.
적용 예시:
private final HttpStatus status; private final String code; private final String message; + + // --- Utility: reverse lookup & duplication guard --- + private static final java.util.Map<String, ErrorCode> BY_CODE; + static { + java.util.Map<String, ErrorCode> m = new java.util.HashMap<>(); + for (ErrorCode e : values()) { + if (m.put(e.code, e) != null) { + throw new IllegalStateException("Duplicate ErrorCode code: " + e.code); + } + } + BY_CODE = java.util.Collections.unmodifiableMap(m); + } + + public static java.util.Optional<ErrorCode> fromCode(String code) { + return java.util.Optional.ofNullable(BY_CODE.get(code)); + }원하시면 풀네임 대신 import(java.util.Map, HashMap, Collections, Optional)를 추가하는 형태로 정리해 드리겠습니다.
12-22: 사소한 메시지 톤/용어 일관성파일 관련 메시지에서 “파일 크기 제한을 넘었습니다” vs “적절하지 않은 파일 내용/포맷” 등 용어를 사전 정의된 용어집에 맞춰 통일하면 UX/번역에 유리합니다. 예: “파일 크기(용량) 초과”, “지원하지 않는 파일 형식”.
src/main/java/com/example/cp_main_be/global/common/CustomApiException.java (1)
14-17: CustomApiException에 root cause 보존용 생성자 추가 권장CustomApiException 클래스에 현재 두 개의 생성자만 정의되어 있습니다. 디버깅 시 내부 예외(cause)를 함께 전달할 수 있도록 아래와 같이 cause를 받는 생성자를 추가하면 예외 전파 시 원인을 보다 명확히 파악할 수 있습니다.
- 파일:
src/main/java/com/example/cp_main_be/global/common/CustomApiException.java
- 아래 diff처럼 생성자 추가
@@ -14,6 +14,14 public CustomApiException(ErrorCode errorCode, String message) { super(message); this.errorCode = errorCode; } + + public CustomApiException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public CustomApiException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + }선택적 개선 사항
private static final long serialVersionUID = 1L;추가로 직렬화 경고 제거public HttpStatus getStatus() { return errorCode.getStatus(); }메서드 추가하여 HTTP 상태 코드 편의 제공위 변경은 기존 코드 동작에 영향을 주지 않는 선택적 리팩터링이며, 필요에 따라 적용하시면 좋습니다.
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java (1)
8-10: NPE 방지: from(...)에 null 방어 로직 추가 권장
diaryImage가 null이거나 내부 필드가 null이면 NPE가 발생합니다. 간단한 사전 검증을 넣어두는 편이 안전합니다.public static ImageUploadResponse from(DiaryImage diaryImage) { - return new ImageUploadResponse(diaryImage.getId(), diaryImage.getImageUrl()); + if (diaryImage == null) { + throw new IllegalArgumentException("diaryImage must not be null"); + } + return new ImageUploadResponse(diaryImage.getId(), diaryImage.getImageUrl()); }src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java (3)
32-34: POST 생성 응답을 201 Created + Location 헤더로 반환 권장리소스 생성 시 REST 관례상 201과 Location 헤더를 반환하면 클라이언트 UX가 좋아집니다. 현재는 200 OK로 본문만 내려보내고 있습니다.
- Long diaryId = diaryService.createDiary(user, request); - Diary diary = diaryService.findDiaryById(diaryId); - return ResponseEntity.ok(ApiResponse.success(DiaryResponse.from(diary))); + Long diaryId = diaryService.createDiary(user, request); + Diary diary = diaryService.findDiaryById(diaryId); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(diaryId) + .toUri(); + return ResponseEntity + .created(location) + .body(ApiResponse.success(DiaryResponse.from(diary)));추가로 필요한 import (파일 상단에 추가):
import java.net.URI; import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
30-35: 서비스 시그니처 사용 일관성
createDiary(user, request)는 엔티티를 받고,updateDiary(user.getId(), ...)는 ID를 받습니다. 보안/테스트 관점에서 일관된 형태(가능하면 ID 기반)를 권장합니다.
51-51: 비공개 글 접근 제어 TODO 처리 제안주석으로 남겨진 접근 제어가 미구현입니다. 권한 체크 누락은 정보 노출로 이어질 수 있어 우선순위를 높게 잡는 것이 좋습니다. 필요 시 구현안 제시 가능합니다.
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (1)
43-93: 퀴즈 생성 시 유효성 검증 제안 (정답 번호 범위 등)
answerNumber가 옵션 개수 범위 내인지, 옵션 order 중복/누락이 없는지 검증 로직을 추가하면 데이터 정합성이 올라갑니다.src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java (1)
7-7: 와일드카드 import 지양
import lombok.*;는 팀 컨벤션과 정적 분석에 불리합니다. 사용한 어노테이션만 명시적으로 import 해주세요.-import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor;src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java (3)
39-41: 양방향 연관관계 일관성 유지 편의 메서드 보완setDiary 호출 시 Diary 쪽 역방향 필드도 함께 맞춰주지 않으면 객체 그래프가 불일치할 수 있습니다. 아래처럼 방어 코드를 추가하세요.
- public void setDiary(Diary diary) { - this.diary = diary; - } + public void setDiary(Diary diary) { + this.diary = diary; + if (diary != null && diary.getDiaryImage() != this) { + diary.setDiaryImage(this); + } + }
32-33: createdAt 자동 세팅 누락 가능성: JPA Auditing 적용 권장createdAt은 현재 어디에서도 설정되지 않습니다. 스프링 데이터 JPA Auditing을 사용해 자동 세팅하도록 구성하는 것을 권장합니다.
예시:
// 클래스 상단 @EntityListeners(AuditingEntityListener.class) // 필드 @CreatedDate @Column(name = "created_at", updatable = false) private LocalDateTime createdAt;리포지토리/설정에 @EnableJpaAuditing이 활성화되어 있는지 확인해 주세요.
27-31: 삭제·정합성 정책 점검user는 nullable=false로 잘 설정되어 있습니다. 다만 다음 정책을 명확히 해두는 것을 권장합니다.
- 사용자 삭제 시 해당 DiaryImage 처리: CASCADE/ON DELETE 설정 또는 배치 삭제.
- Diary 삭제 시 연결된 DiaryImage 처리: orphanRemoval 또는 별도 서비스 레이어 정리.
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java (2)
98-99: 아바타 조회 실패 시 전용 코드 사용 권장findAvatarById는 NOT_FOUND보다 AVATAR_NOT_FOUND 같은 전용 코드를 사용하는 편이 API 레이어에서 구체적 분기에 유리합니다.
- .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND, "해당 아바타를 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_NOT_FOUND, "해당 아바타를 찾을 수 없습니다."));
83-88: Garden 슬롯 번호 산정 로직 보강 제안삭제 등으로 슬롯에 공백이 생긴 경우 size()+1은 중복을 유발할 수 있습니다. max(slotNumber)+1 방식으로 보강하거나, DB 고유 제약/인덱스로 중복을 방지하는 것이 안전합니다.
예시(간단 스트림 기준):
int nextSlot = userGardens.stream().mapToInt(Garden::getSlotNumber).max().orElse(0) + 1; Garden newGarden = Garden.builder() .user(user) .slotNumber(nextSlot) .avatar(newAvatar) .build();가능하다면 Repository로 next slot을 계산하는 전용 쿼리를 두는 것도 좋습니다.
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (2)
75-83: 쿨타임 비교의 시간대 일관성다른 일일주기 계산은 Asia/Seoul 기준을 사용하지만, 여기서는 시스템 기본시간을 사용합니다. 운영 환경이 KST가 아닐 수 있으므로 동일한 시간대(Asia/Seoul)로 통일하는 것을 권장합니다.
- if (garden.getLastWateredByOwnerAt() != null - && garden.getLastWateredByOwnerAt().plusHours(8).isAfter(LocalDateTime.now())) { + LocalDateTime nowKst = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + if (garden.getLastWateredByOwnerAt() != null + && garden.getLastWateredByOwnerAt().plusHours(8).isAfter(nowKst)) { throw new CustomApiException(ErrorCode.WATERING_COOL_DOWN); }
185-198: 배경 변경 API도 예외 코드 일관화여기서도 IllegalArgumentException 대신 CustomApiException을 사용해 주세요.
Garden garden = gardenRepository .findById(gardenId) - .orElseThrow(() -> new IllegalArgumentException("해당 텃밭을 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND)); GardenBackground newBackground = gardenBackgroundRepository .findById(backgroundId) - .orElseThrow(() -> new IllegalArgumentException("해당 배경화면을 찾을 수 없습니다.")); + .orElseThrow(() -> new CustomApiException(ErrorCode.BACKGROUND_NOT_FOUND));src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (2)
40-55: 이미지 소유권 검증은 적절하나, ‘이미 연결된 이미지’ 방어 로직과 제약 추가를 권장
- 소유권 체크는 올바릅니다. 추가로, 해당 DiaryImage가 이미 다른 Diary와 연결돼 있지 않은지 방어하면 데이터 무결성이 강화됩니다.
- DB 레벨에서도 DiaryImage.diary_id에 유니크 제약(1:1 보장) 또는 “이미지 1개는 하나의 일기에만 연결” 인덱싱을 권장합니다.
다음과 같이 간단한 선행 검증을 추가해 보세요:
// 보안: 이미지를 업로드한 사용자와 일기 작성자가 동일한지 확인 if (!diaryImage.getUser().getId().equals(user.getId())) { throw new CustomApiException(ErrorCode.ACCESS_DENIED); } + // 무결성: 이미 다른 일기에 연결된 이미지인지 확인 + if (diaryImage.getDiary() != null) { + throw new CustomApiException(ErrorCode.ACCESS_DENIED, "이미 다른 일기에 연결된 이미지입니다."); + }
85-88: 좋아요 조회 시 ID 기반 메서드 사용 권장 및TargetTypeenum 적용 고려현재
LikeRepository에는 이미 ID 기반 exists 메서드가 정의되어 있으므로, 아래처럼User엔티티 대신userId를 사용하는 시그니처를 바로 적용할 수 있습니다. 또한,targetType필드를 문자열 대신 enum으로 매핑하면 상수 관리와 컴파일 타임 타입 검증이 가능해집니다.
ID 기반 조회:
기존isLiked = likeRepository.existsByUserAndTargetIdAndTargetType( currentUser, diaryId, "DIARY");변경
- isLiked = likeRepository.existsByUserAndTargetIdAndTargetType( - currentUser, diaryId, "DIARY"); + isLiked = likeRepository.existsByUserIdAndTargetIdAndTargetType( + currentUser.getId(), diaryId, TargetType.DIARY.name());→
existsByUserIdAndTargetIdAndTargetType(Long, Long, String)메서드가 이미 정의되어 있습니다.enum 매핑:
엔티티Like의@Column(name = "target_type", nullable = false) private String targetType;를
@Enumerated(EnumType.STRING) @Column(name = "target_type", nullable = false) private TargetType targetType;으로 변경하고, 리포지토리 시그니처도
boolean existsByUserIdAndTargetIdAndTargetType( Long userId, Long targetId, TargetType targetType);로 수정하면 컴파일 타임에 잘못된 타입 사용을 방지할 수 있습니다.
인덱스 확인:
Like엔티티에 선언된 uniqueConstraints(uk_likes_user_target)가(user_id, target_id, target_type)조합의 유일성을 보장하며, 자동으로 복합 인덱스도 생성됩니다. 별도 인덱스 추가는 필요하지 않습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (16)
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java(5 hunks)src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java(4 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java(4 hunks)src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java(1 hunks)src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/domain/Diary.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/CreateDiaryRequest.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java(4 hunks)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/domain/DiaryImage.java(2 hunks)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java(1 hunks)src/main/java/com/example/cp_main_be/global/common/CustomApiException.java(1 hunks)src/main/java/com/example/cp_main_be/global/common/ErrorCode.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java (2)
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (1)
Service(29-193)src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (1)
Service(20-152)
src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (2)
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (1)
Service(29-193)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java (1)
Service(14-42)
src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/FriendWateringLog.java (1)
src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java (1)
Entity(13-104)
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (4)
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java (1)
Service(23-115)src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (1)
Service(30-184)src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
Service(30-224)src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java (1)
Service(31-216)
🔇 Additional comments (16)
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/request/UpdateDiaryRequest.java (1)
24-25: isPublic 필드의 전체 갱신(PUT) 의도 확인되어 @NotNull 유지가 타당합니다검토 결과
UpdateDiaryRequest는 부분 갱신이 아닌@PutMapping("/{diaryId}")로만 매핑되어 전체 갱신이 의도된 DTO임이 확인되었습니다.
따라서isPublic필드에@NotNull을 유지하는 것이 적절하며, 별도의 수정은 불필요합니다.src/main/java/com/example/cp_main_be/global/common/ErrorCode.java (1)
10-43: 에러 체계 확장 전반은 방향 좋습니다
- 도메인별 4xx/404 세분화, 메시지 일관(국문) 및 코드 부여가 명확합니다.
- 추후 클라이언트/로그 파이프라인에서 코드 기준 집계가 용이합니다.
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/dto/response/ImageUploadResponse.java (1)
6-6: 빌드 JDK 버전 및 호환성 설정 확인 필요
record는 Java 16 이상에서 정식 지원됩니다. 현재 소스/타깃 호환성 설정이 build 스크립트에서 조회되지 않아, 실제 빌드 환경이 Java 16 이상인지 확인이 필요합니다.확인 요청 사항:
- Gradle:
build.gradle또는build.gradle.kts에서
•sourceCompatibility/targetCompatibility
• 또는java { toolchain { languageVersion.set(...) } }설정- Maven:
pom.xml에서
•maven-compiler-plugin의<source>/<target>또는<release>설정- CI/CD 및 로컬 환경에서
•./gradlew -v또는mvn -v명령으로 사용 중인 JDK 버전 확인src/main/java/com/example/cp_main_be/domain/garden/wateringlog/domain/repository/FriendWateringLogRepository.java (1)
24-26: 트랜잭션 경계 정상 확인
GardenService.cleanupOldWateringLogs()메서드에 이미 다음과 같이 @scheduled 와 @transactional 어노테이션이 적용되어 있어, 스케줄러 실행 시 쓰기 트랜잭션이 열리도록 정상 설정되어 있습니다.
- 파일:
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
라인 243–244@Scheduled(cron = "0 0 4 * * *") @Transactional // 쓰기 작업을 위한 트랜잭션 경계 설정 public void cleanupOldWateringLogs() { … }따라서 별도의 추가 조치 없이 트랜잭션 경계가 적절히 보장됩니다.
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (3)
95-103: 표준화된 예외로 전환: 삭제 로직 LGTM
QUIZ_NOT_FOUND로 일관된 예외 전환 좋습니다. 트랜잭션 경계 내에서 삭제 처리도 적절합니다.
121-133: Dirty Checking 활용한 업데이트 LGTM조회-수정-반환 패턴이 @transactional 하에서 자연스럽게 동작합니다. 예외 코드 표준화도 적절합니다.
178-186: 신고 상태 변경 로직 LGTM표준화된 예외와 Dirty Checking로 간결하게 처리되어 있습니다.
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java (2)
28-28: AI_AVATAR_MASTER_ID 상수 추출 👍의도 명확해졌고, 상수 위치도 적절합니다.
101-106: 발신자 미존재 처리 코드 정교화 👍USER_NOT_FOUND로 명시한 점 좋습니다. 에러 코드 일관성에 부합합니다.
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java (1)
22-23: 역할 분리 방향성 👍이미지 업로드/삭제를 DiaryImageService로 분리한 것은 응집도 향상과 API 유연성 측면에서 적절합니다.
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (1)
55-70: 소유자/친구 분기 메서드 분리 리팩터링 👍가독성과 테스트 용이성이 향상되었습니다. 상단의 N+1 방지 코멘트도 적절합니다. 필요 시 findByIdWithUser 도입을 검토해 주세요.
src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (5)
12-13: 예외 표준화 도입 적절합니다CustomApiException/ErrorCode로 예외를 일원화한 방향 좋습니다. 컨트롤러/전역 핸들러에서 일관된 응답 포맷 유지에 도움이 됩니다.
57-57: ID만 반환하는 형태 유지 👍API 경량화 관점에서 합리적입니다. 이후 응답 본문이 필요할 때 컨트롤러에서 조회해 조합하는 패턴과 잘 맞습니다.
63-66: 존재하지 않는 일기 예외 처리 적절findById에서 DIARY_NOT_FOUND로 즉시 예외 처리하는 방식은 호출부 단순화에 유리합니다.
75-81: 상세 조회의 예외 및 접근 제어 로직 적절
- N+1 방지를 위한 findByIdWithDetails 사용과 DIARY_NOT_FOUND 매핑 모두 적절합니다.
- 비공개 글 접근 차단 메시지 명확합니다. 향후 관리자/친구 권한 허용 요건이 생기면 이 분기만 확장하면 됩니다.
103-106: 소유권 검증 로직 명료합니다수정 권한을 ID 비교로 단언하고 ACCESS_DENIED로 매핑한 점 좋습니다.
| // TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다. | ||
| // 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나, | ||
| // 이 로직에 대한 명확한 주석과 문서화가 필요합니다. | ||
| master = | ||
| avatarMasterRepository | ||
| .findById(masterId + 2) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); // 예외 유형 구체화 | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND)); |
There was a problem hiding this comment.
매직 넘버(masterId + 2) 사용은 즉시 제거해야 합니다
masterId에 2를 가산하는 로직은 데이터 정합성을 심각하게 훼손할 수 있는 크리티컬 이슈입니다. FE/DB 간 ID 매핑 문제는 계약을 맞춰 해결해야 하며, 임시 변환은 금지해야 합니다.
- // TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다.
- // 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나,
- // 이 로직에 대한 명확한 주석과 문서화가 필요합니다.
- master =
- avatarMasterRepository
- .findById(masterId + 2)
+ master =
+ avatarMasterRepository
+ .findById(masterId)
.orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // TODO: 'masterId + 2'와 같은 매직 넘버 로직은 위험합니다. | |
| // 프론트엔드에서 전달하는 ID와 DB의 ID가 일치하도록 데이터 정합성을 맞추거나, | |
| // 이 로직에 대한 명확한 주석과 문서화가 필요합니다. | |
| master = | |
| avatarMasterRepository | |
| .findById(masterId + 2) | |
| .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); // 예외 유형 구체화 | |
| .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND)); | |
| master = | |
| avatarMasterRepository | |
| .findById(masterId) | |
| .orElseThrow(() -> new CustomApiException(ErrorCode.AVATAR_MASTER_NOT_FOUND)); |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
around lines 46 to 52, the code uses a magic offset (masterId + 2) when loading
the AvatarMaster which must be removed; change the lookup to use the
frontend-supplied masterId directly (avatarMasterRepository.findById(masterId))
and, if a mapping is required, introduce a clearly named mapping function or DTO
conversion that translates frontend IDs to persistence IDs with unit tests and
documentation; also validate masterId (null/negative) before lookup and keep a
brief Javadoc comment explaining the chosen ID contract (or the mapping
rationale) so the behavior is explicit and not hidden by a magic number.
| .orElseThrow( | ||
| () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다.")); | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
WishTree 미존재 시 전용 에러 코드 사용 검토
현재 NOT_FOUND + 메시지를 사용합니다. 에러 핸들링 일관성과 클라이언트 분기 처리를 위해 WISH_TREE_NOT_FOUND 같은 전용 코드 사용을 권장합니다(존재 시).
- .orElseThrow(
- () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
+ .orElseThrow(
+ () -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));ErrorCode에 WISH_TREE_NOT_FOUND가 없으면 추가 도입이 필요합니다. 다음 스크립트로 확인하세요.
🏁 Script executed:
#!/bin/bash
rg -n "enum\s+ErrorCode" -A200 | rg -n "WISH_TREE_NOT_FOUND|AVATAR_NOT_FOUND|GARDEN_SLOT_MAXED_OUT"Length of output: 256
WishTree 전용 에러 코드 추가 필요
ErrorCode enum에 WISH_TREE_NOT_FOUND가 정의되어 있지 않아, 서비스 코드에서 해당 코드를 사용하려면 먼저 enum에 항목을 추가해야 합니다. 아래 위치를 확인하고 수정해주세요.
• src/main/java/com/example/cp_main_be/global/common/ErrorCode.java
기존 enum 목록 중 적절한 위치에 다음 항목을 추가합니다.
(HTTP 상태 및 코드 체계에 맞춰 번호는 필요에 따라 조정하세요.)
public enum ErrorCode {
// ...
GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."),
+ WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40404", "사용자의 소원나무를 찾을 수 없습니다."),
// ...
}• src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
조회 실패 시 NOT_FOUND 대신 추가한 전용 코드를 사용하도록 수정합니다.
- .orElseThrow(
- () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));
+ .orElseThrow(
+ () -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다."));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .orElseThrow( | |
| () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다.")); | |
| public enum ErrorCode { | |
| // ... | |
| GARDEN_SLOT_MAXED_OUT(HttpStatus.BAD_REQUEST, "E40006", "더 이상 텃밭을 추가할 수 없습니다."), | |
| WISH_TREE_NOT_FOUND(HttpStatus.NOT_FOUND, "E40404", "사용자의 소원나무를 찾을 수 없습니다."), | |
| // ... | |
| } |
| .orElseThrow( | |
| () -> new CustomApiException(ErrorCode.NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다.")); | |
| .orElseThrow( | |
| () -> new CustomApiException(ErrorCode.WISH_TREE_NOT_FOUND, "사용자의 소원나무를 찾을 수 없습니다.")); |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
around lines 77-79, the code throws a CustomApiException using
ErrorCode.NOT_FOUND for missing WishTree; add a dedicated enum entry to
src/main/java/com/example/cp_main_be/global/common/ErrorCode.java (e.g.,
WISH_TREE_NOT_FOUND with the appropriate HTTP status and numeric code consistent
with your scheme) and then change the AvatarService.orElseThrow to use
ErrorCode.WISH_TREE_NOT_FOUND (preserving or updating the message text as
needed).
| public class GardenService { | ||
|
|
||
| private static final int MAX_GARDEN_COUNT = 4; | ||
| private static final int MAX_GARDEN_COUNT = 3; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
MAX_GARDEN_COUNT 4→3 변경은 브레이킹 체인지: 도메인/클라이언트 영향 검증 필요
정원 최대 개수 축소는 사용자 경험과 기존 데이터/클라이언트 로직에 영향을 줍니다. 정책 변경 근거와 마이그레이션/알림 계획, 관련 화면(예: 슬롯 해제 버튼, 홈 요약)의 표기 변경을 점검해 주세요.
영향 분포 탐색 스크립트:
🏁 Script executed:
#!/bin/bash
# MAX_GARDEN_COUNT 사용처 및 슬롯 관련 문구/상수 검색
rg -n "MAX_GARDEN_COUNT|maxGardens|GARDEN_SLOT_MAXED_OUT|slotNumber|정원|텃밭" -C2Length of output: 39874
🏁 Script executed:
#!/bin/bash
cat src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java | sed -n '1,200p'Length of output: 1063
GardenService 최대 개수 논리 불일치 – 즉각 수정 필요
GardenService의 MAX_GARDEN_COUNT = 3이 WishTree 단계별 허용 슬롯(최대 4개) 및 AvatarService(stage.getMaxGardens()) 로직과 맞지 않습니다. 이로 인해
- WishTreeService가 TREE 단계(4개 허용) 진입 시
unlockNewGardenSlot호출 →currentGardens >= 3판정으로 예외 발생 - AvatarService는 최대 4개까지 허용
- 기존 주석 처리된 GardenServiceTest는 “최대 4개” 기준으로 테스트 기대치 설정
등 심각한 동작 불일치가 발생합니다. 아래 항목을 긴급 정비해주세요:
• GardenService.java (33행)
– MAX_GARDEN_COUNT 상수를 제거하거나, WishTreeStage.getMaxGardens(userStage) 기반으로 동적 계산하도록 리팩토링
• WishTreeStage.java (16–20행)
– 단계별 maxGardens 값(1·2·3·4·5)이 의도한 정책(최대 3 vs. 4)과 부합하는지 재검토
• AvatarService.java (81–89행)
– MAX_GARDEN_COUNT 대신 stage.getMaxGardens() 로직과 일관성 유지
• GardenServiceTest.java
– “최대 텃밭 개수(3개)” 테스트명, slotNumber, 예외 메시지(“더 이상 텃밭을 추가할 수 없습니다.”)로 업데이트
• UI/클라이언트 화면
– 슬롯 확장 버튼 활성화 조건, 홈 요약 가드닝 슬롯 표시 텍스트(“3/3” or “4/4”) 동기화
• 데이터 & 마이그레이션 계획
– 이미 4개를 보유한 유저 처리 로직, 기존 데이터 검증 및 사용자 공지 방안 수립
위 수정이 완료되면 종합 테스트 후 클라이언트 영향 범위를 최종 확인해 주세요.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around line 33, the hardcoded MAX_GARDEN_COUNT = 3 conflicts with
WishTreeStage/AvatarService which use stage.getMaxGardens() (allowing up to 4);
remove the hardcoded constant and replace usages with a dynamic check against
the user's current stage (e.g., stage.getMaxGardens(userStage) or equivalent
lookup) so GardenService enforces the same limit as AvatarService/WishTree; also
review WishTreeStage (lines ~16–20) to confirm stage maxGardens values are
correct, update AvatarService ( ~81–89) to rely on stage.getMaxGardens() if not
already, adjust GardenServiceTest expectations/messages to the chosen max
(rename test and update slotNumber and exception text), and coordinate UI/client
and data-migration plans for users already holding 4 gardens.
| private void waterFriendGarden(Long actorId, Garden garden) { | ||
| User actor = | ||
| userRepository | ||
| .findById(actorId) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| // 낮 12시 이후 -> 그날의 12시 날짜 반환, 이전 -> 전날의 12시 날짜 반환 | ||
| LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay(); | ||
|
|
||
| // 1. 하루에 3회 제한 체크 | ||
| checkFriendWateringLimit(actor, startOfWateringDay); | ||
|
|
||
| // 2. 같은 정원에 하루 한 번 제한 체크 | ||
| checkAlreadyWateredToday(actor, garden, startOfWateringDay); | ||
|
|
||
| // 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다. | ||
| userService.addExperience(actorId, WATERING_POINTS); | ||
| garden.increaseWaterCount(); | ||
|
|
||
| // 물주기 활동 기록 | ||
| FriendWateringLog log = | ||
| FriendWateringLog.builder() | ||
| .waterGiver(actor) | ||
| .wateredGarden(garden) // wateredAt은 @CreatedDate가 자동으로 설정합니다. | ||
| .build(); | ||
| friendWateringLogRepository.save(log); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
친구 물주기 동시성·중복 처리 보강
동시에 두 요청이 들어올 경우 존재 여부 체크 후 삽입 사이 레이스가 발생할 수 있습니다. DB 레벨 제약 또는 고유 인덱스를 두어 하루 1회/정원 제한을 강제하세요. 또한 count/exists 쿼리에 필요한 인덱스(water_giver_id, watered_garden_id, watered_at)를 확인해 주세요.
- 제약(예시): UNIQUE (water_giver_id, watered_garden_id, watering_day)
- watering_day는 watered_at을 정오 기준으로 버킷팅한 값(생성 시 트리거/애플리케이션 측 계산)으로 저장
- Repository exists/count 쿼리 컬럼에 복합 인덱스 추가
| /** 오래된 친구 물주기 로그를 주기적으로 삭제하는 스케줄링 작업입니다. cron = "0 0 4 * * *" : 매일 새벽 4시에 실행됩니다. */ | ||
| @Scheduled(cron = "0 0 4 * * *") | ||
| @Transactional // 쓰기 작업이므로 클래스 레벨의 readOnly 설정을 오버라이드합니다. | ||
| public void cleanupOldWateringLogs() { | ||
| // 7일 이상된 기록을 삭제하도록 설정. 이 값은 application.yml에서 관리하는 것이 더 좋습니다. | ||
| final int RETENTION_DAYS = 7; | ||
| LocalDateTime cutoffDate = LocalDateTime.now().minusDays(RETENTION_DAYS); | ||
| log.info("Starting cleanup of friend watering logs older than {} days...", RETENTION_DAYS); | ||
| int deletedCount = friendWateringLogRepository.deleteByWateredAtBefore(cutoffDate); | ||
| log.info("Finished cleanup. Deleted {} old friend watering logs.", deletedCount); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
정기 로그 정리: 리포지토리 메서드 시그니처/인덱스/설정 외부화 확인
- deleteByWateredAtBefore가 @Modifying으로 선언되어 있고 반환 타입이 int인지 확인 필요
- 대용량 삭제 대비 인덱스(watered_at) 존재 확인
- RETENTION_DAYS를 설정 값으로 외부화(@value 또는 @ConfigurationProperties) 권장
리포지토리/인덱스/프로퍼티 점검 스크립트:
설정 외부화 예시:
@Value("${watering-log.retention-days:7}")
private int retentionDays;🏁 Script executed:
#!/bin/bash
# 메서드 선언 확인
rg -nP "interface\s+FriendWateringLogRepository" -A200 | rg -n "deleteByWateredAtBefore"
# @Modifying 사용 여부
rg -n "@Modifying" -C2
# watered_at 컬럼 인덱스 힌트 (DDL/엔티티)
rg -n "watered_at|wateredAt|@Index|@Table" -C2
# 설정 파일에서 보존일 외부화 여부
fd -a "application*.yml" | xargs -I{} sh -c 'echo "--- {} ---"; rg -n "watering-log-retention|retention|cleanup" {}'Length of output: 10651
GardenService.cleanupOldWateringLogs() 개선 필요 사항 확인
아래 사항들을 반영해 주세요:
- FriendWateringLogRepository.deleteByWateredAtBefore 메서드는 이미 @Modifying 애노테이션을 사용하고 있으며 int 반환 타입을 갖고 있어 적절히 정의되어 있습니다.
- 대용량 데이터 삭제 성능을 위해 watered_at 컬럼에 인덱스가 존재하는지 확인하고, 없다면 데이터베이스나 JPA 엔티티(@Index) 수준에서 인덱스를 추가해 주세요.
- 현재 RETENTION_DAYS 상수가 코드에 하드코딩되어 있으므로, @value 또는 @ConfigurationProperties 를 활용해 application.yml 등 설정 파일로 외부화해 주세요.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 242-252, remove the hardcoded RETENTION_DAYS and inject it from
configuration (use @Value("${garden.watering.retention-days}") or a
@ConfigurationProperties class) so the cron job reads the retention days from
application.yml; keep the cleanup logic but use the injected int field and
ensure transactional and @Scheduled remain; verify
FriendWateringLogRepository.deleteByWateredAtBefore(...) is used as-is (it
already returns int and is @Modifying); and add an index on the watered_at
column at the DB level or annotate the JPA entity with @Table(indexes =
@Index(name = "idx_watered_at", columnList = "watered_at")) if missing to
improve delete performance.
| public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage( | ||
| @RequestParam MultipartFile file, @AuthenticationPrincipal User user) { | ||
|
|
||
| // 1. 서비스 계층에 이미지 저장 및 연결 요청 | ||
| diaryService.saveDiaryImage(diaryId, file); | ||
| // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) | ||
| ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user); | ||
|
|
||
| // 2. 성공 응답 반환 | ||
| return ResponseEntity.ok(ApiResponse.success(null)); | ||
| // 2. 업로드된 이미지의 ID와 URL을 반환 | ||
| return ResponseEntity.ok(ApiResponse.success(response)); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
업로드 API: 멀티파트 파라미터 선언/유효성 및 응답 코드(201) 보완
- 멀티파트는 @RequestPart 사용이 명시적입니다.
- 입력 검증(널/빈 파일)은 서비스에서도 하지만, 컨트롤러에서 즉시 검출해 주면 UX가 좋아집니다.
- 생성 작업이므로 201 Created를 반환하는 것이 더 RESTful합니다.
- @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage(
- @RequestParam MultipartFile file, @AuthenticationPrincipal User user) {
+ @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage(
+ @RequestPart("file") MultipartFile file, @AuthenticationPrincipal User user) {
- // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음)
+ if (user == null) {
+ throw new CustomApiException(ErrorCode.UNAUTHORIZED);
+ }
+ if (file == null || file.isEmpty()) {
+ throw new CustomApiException(ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다.");
+ }
+ // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음)
ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user);
- // 2. 업로드된 이미지의 ID와 URL을 반환
- return ResponseEntity.ok(ApiResponse.success(response));
+ // 2. 업로드된 이미지의 ID와 URL을 반환
+ return ResponseEntity.status(201).body(ApiResponse.success(response));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage( | |
| @RequestParam MultipartFile file, @AuthenticationPrincipal User user) { | |
| // 1. 서비스 계층에 이미지 저장 및 연결 요청 | |
| diaryService.saveDiaryImage(diaryId, file); | |
| // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) | |
| ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user); | |
| // 2. 성공 응답 반환 | |
| return ResponseEntity.ok(ApiResponse.success(null)); | |
| // 2. 업로드된 이미지의 ID와 URL을 반환 | |
| return ResponseEntity.ok(ApiResponse.success(response)); | |
| @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) | |
| public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage( | |
| @RequestPart("file") MultipartFile file, @AuthenticationPrincipal User user) { | |
| if (user == null) { | |
| throw new CustomApiException(ErrorCode.UNAUTHORIZED); | |
| } | |
| if (file == null || file.isEmpty()) { | |
| throw new CustomApiException(ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다."); | |
| } | |
| // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) | |
| ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user); | |
| // 2. 업로드된 이미지의 ID와 URL을 반환 | |
| return ResponseEntity.status(201).body(ApiResponse.success(response)); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java
around lines 29-36, change the multipart parameter annotation from @RequestParam
to @RequestPart and add an explicit null/empty file check at the controller
start to return a 400 Bad Request when the uploaded file is missing or empty;
also mark the endpoint as consuming multipart/form-data (e.g., via consumes
attribute) and return a 201 Created response with the ApiResponse body on
success instead of 200 OK.
| private final DiaryImageRepository diaryImageRepository; | ||
| private final DiaryRepository diaryRepository; | ||
| private final S3Uploader s3Uploader; | ||
|
|
||
| /** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */ | ||
| public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { | ||
| String imageUrl = s3Uploader.upload(file, "diary-images"); | ||
|
|
||
| DiaryImage diaryImage = | ||
| DiaryImage.builder() | ||
| .imageUrl(imageUrl) | ||
| .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용 | ||
| .build(); | ||
|
|
||
| DiaryImage savedImage = diaryImageRepository.save(diaryImage); | ||
|
|
||
| return ImageUploadResponse.from(savedImage); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
이미지 입력 검증·예외 처리 누락: 업로드 안정성과 보안 강화를 위한 보완 필요
현재 파일 타입/크기 검증이 없고, 업로드 실패에 대한 예외 매핑이 없어 S3 오류가 그대로 노출될 수 있습니다. 기본적인 유효성 검증과 예외 래핑을 추가해 주세요. 또한 업로더가 null인 경우도 방어해야 합니다.
적용 예시:
public class DiaryImageService {
private final DiaryImageRepository diaryImageRepository;
private final DiaryRepository diaryRepository;
private final S3Uploader s3Uploader;
+
+ // 최대 5MB 예시. 구성 파일로 외부화 권장.
+ private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024L;
/** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */
public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) {
- String imageUrl = s3Uploader.upload(file, "diary-images");
+ if (user == null) {
+ throw new CustomApiException(ErrorCode.UNAUTHORIZED);
+ }
+ if (file == null || file.isEmpty()) {
+ throw new CustomApiException(ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다.");
+ }
+ String contentType = file.getContentType();
+ if (contentType == null || !contentType.startsWith("image/")) {
+ throw new CustomApiException(ErrorCode.INVALID_FILE_TYPE, "이미지 파일만 업로드할 수 있습니다.");
+ }
+ if (file.getSize() > MAX_IMAGE_SIZE) {
+ throw new CustomApiException(ErrorCode.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다.");
+ }
+ String imageUrl;
+ try {
+ imageUrl = s3Uploader.upload(file, "/diary-images");
+ } catch (Exception e) {
+ throw new CustomApiException(ErrorCode.IMAGE_UPLOAD_FAILED, "이미지 업로드 중 오류가 발생했습니다.", e);
+ }
DiaryImage diaryImage =
DiaryImage.builder()
.imageUrl(imageUrl)
.user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용
.build();
DiaryImage savedImage = diaryImageRepository.save(diaryImage);
return ImageUploadResponse.from(savedImage);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private final DiaryImageRepository diaryImageRepository; | |
| private final DiaryRepository diaryRepository; | |
| private final S3Uploader s3Uploader; | |
| /** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */ | |
| public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { | |
| String imageUrl = s3Uploader.upload(file, "diary-images"); | |
| DiaryImage diaryImage = | |
| DiaryImage.builder() | |
| .imageUrl(imageUrl) | |
| .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용 | |
| .build(); | |
| DiaryImage savedImage = diaryImageRepository.save(diaryImage); | |
| return ImageUploadResponse.from(savedImage); | |
| } | |
| private final DiaryImageRepository diaryImageRepository; | |
| private final DiaryRepository diaryRepository; | |
| private final S3Uploader s3Uploader; | |
| // 최대 5MB 예시. 구성 파일로 외부화 권장. | |
| private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024L; | |
| /** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */ | |
| public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { | |
| if (user == null) { | |
| throw new CustomApiException(ErrorCode.UNAUTHORIZED); | |
| } | |
| if (file == null || file.isEmpty()) { | |
| throw new CustomApiException(ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다."); | |
| } | |
| String contentType = file.getContentType(); | |
| if (contentType == null || !contentType.startsWith("image/")) { | |
| throw new CustomApiException(ErrorCode.INVALID_FILE_TYPE, "이미지 파일만 업로드할 수 있습니다."); | |
| } | |
| if (file.getSize() > MAX_IMAGE_SIZE) { | |
| throw new CustomApiException(ErrorCode.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."); | |
| } | |
| String imageUrl; | |
| try { | |
| imageUrl = s3Uploader.upload(file, "/diary-images"); | |
| } catch (Exception e) { | |
| throw new CustomApiException(ErrorCode.IMAGE_UPLOAD_FAILED, "이미지 업로드 중 오류가 발생했습니다.", e); | |
| } | |
| DiaryImage diaryImage = | |
| DiaryImage.builder() | |
| .imageUrl(imageUrl) | |
| .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용 | |
| .build(); | |
| DiaryImage savedImage = diaryImageRepository.save(diaryImage); | |
| return ImageUploadResponse.from(savedImage); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java
around lines 19 to 36, add input validation and exception handling: validate
that MultipartFile file and User user are non-null, that file is not empty,
enforce allowed content types (e.g., image/png, image/jpeg) and a max size
limit, then call s3Uploader.upload inside a try-catch that maps any S3/IO
exceptions to a custom runtime exception (e.g., ImageUploadException) with a
clear message; only after a successful upload proceed to build and save
DiaryImage and return the response. Ensure to fail fast with clear exceptions
for invalid input (null user, empty file, disallowed type, oversize) and log or
wrap underlying errors instead of leaking library-specific exceptions.
| String imageUrl = s3Uploader.upload(file, "diary-images"); | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
S3 업로드 경로 포맷 일관화 필요
다른 서비스(AdminService.addDeliveryPlant)에서는 "/deliveryplant"처럼 슬래시로 시작하는 경로를 사용합니다. 여기서는 "diary-images"로 시작하여 키 규칙이 달라질 수 있습니다. S3Uploader 구현이 어떻게 경로를 결합하는지 확인 후, 포맷을 통일하세요. 예: "/diary-images".
다른 호출부의 경로 사용 현황을 확인하는 스크립트:
🏁 Script executed:
#!/bin/bash
# S3Uploader.upload 호출부와 두 번째 인자(경로) 목록 점검
rg -nP "s3Uploader\.upload\s*\(\s*[^,]+,\s*([^\)]+)\)" -C1Length of output: 1914
🏁 Script executed:
#!/bin/bash
# S3Uploader 클래스 파일 위치 찾기
file=$(rg -l "class S3Uploader" -g "*.java")
echo "S3Uploader 구현 파일: $file"
# upload 메서드 구현 확인 (경로 결합 로직 포함)
rg -nP "public\s+String\s+upload" -C20 "$file"Length of output: 1906
S3 업로드 경로 형식 일관화 필요
현재 S3Uploader.upload 메서드는 전달된 path를 그대로 키 접두사로 사용합니다.
DiaryImageService(src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java:25) 와
UserDailyMissionService(src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java:57) 에서는"diary-images","mission-images"형태로 슬래시 없이 사용AdminService.addDeliveryPlant(src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java:190) 에서는"/deliveryplant"처럼 앞 슬래시 포함
이 불일치로 S3 키에 불필요한 선행 슬래시가 들어가거나, 없는 경우가 발생하므로 다음 중 하나로 포맷을 통일해주세요.
- AdminService 호출부 수정 (권장)
- String imageUrl = s3Uploader.upload(file, "/deliveryplant"); + String imageUrl = s3Uploader.upload(file, "deliveryplant");
- 또는 모든 호출부에 선행 슬래시를 추가
- s3Uploader.upload(file, "diary-images"); + s3Uploader.upload(file, "/diary-images");
- 더 나아가
S3Uploader.upload내부에서path앞뒤의 슬래시를 제거·추가하는 로직(예:path = path.replaceAll("^/+|/+$", ""))을 추가해도 좋습니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| String imageUrl = s3Uploader.upload(file, "diary-images"); | |
| // before | |
| String imageUrl = s3Uploader.upload(file, "/diary-images"); | |
| // rest of method unchanged |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java (1)
29-29: S3 업로드 경로 포맷 일관화 필요(선행 슬래시 혼용)현재 본 메서드는 "diary-images"를 사용하고, 다른 호출부(AdminService 등)는 "/deliveryplant"처럼 선행 슬래시를 사용합니다. 키 접두사 규칙을 통일하시거나, S3Uploader 내부에서 경로 정규화(
trim('/'))를 수행하도록 개선해 주세요.두 가지 대안:
- 호출부 통일: 모두 "diary-images" 같이 선행 슬래시 없이 사용
- Uploader 정규화:
path = path.replaceAll("^/+|/+$", "")다음 스크립트로 호출부 일관성 확인:
#!/bin/bash rg -n -C1 -g '!**/build/**' -e 's3Uploader\.upload\('src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java (1)
25-37: 업로드 API: @RequestPart 사용 및 201 응답, 입력 1차 검증 권장멀티파트는 @RequestPart가 더 명시적이고, 생성 작업은 201이 적합합니다. 비로그인/빈 파일을 컨트롤러 레벨에서 빠르게 차단하면 UX가 개선됩니다.
- @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage( - @RequestParam MultipartFile file, @AuthenticationPrincipal User user) { + @PostMapping(value = "/diaries/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity<ApiResponse<ImageUploadResponse>> uploadDiaryImage( + @RequestPart("file") MultipartFile file, @AuthenticationPrincipal User user) { - // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) + if (user == null) { + throw new com.example.cp_main_be.global.common.CustomApiException( + com.example.cp_main_be.global.common.ErrorCode.UNAUTHORIZED); + } + if (file == null || file.isEmpty()) { + throw new com.example.cp_main_be.global.common.CustomApiException( + com.example.cp_main_be.global.common.ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다."); + } + // 1. 서비스 계층에 이미지 업로드 요청 (아직 일기와 연결되지 않음) ImageUploadResponse response = diaryImageService.uploadDiaryImage(file, user); - // 2. 업로드된 이미지의 ID와 URL을 반환 - return ResponseEntity.ok(ApiResponse.success(response)); + // 2. 업로드된 이미지의 ID와 URL을 반환 + return ResponseEntity.status(201).body(ApiResponse.success(response)); }주의: ErrorCode.UNAUTHORIZED 추가가 수반됩니다(별도 코멘트 참고).
🧹 Nitpick comments (6)
src/main/java/com/example/cp_main_be/global/common/ErrorCode.java (3)
39-44: UPLOAD_FAILED의 HTTP 상태 재검토(417 → 500/502 권장)업로드 실패는 클라이언트 기대 위반(417) 보다는 서버/인프라 오류(500) 또는 외부 의존성 오류(502/503)에 가깝습니다. 의미를 명확히 하기 위해 상태코드 상향을 권장합니다.
- UPLOAD_FAILED(HttpStatus.EXPECTATION_FAILED, "E41701", "파일 업로드에 실패했습니다."), + UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E50010", "파일 업로드에 실패했습니다."),참고: 코드 문자열(E50010)은 프로젝트 컨벤션에 맞게 조정하세요.
19-23: 파일 업로드 오류 상태코드 일관성 검증 완료아래 스크립트 결과,
INVALID_FILE과FILE_SIZE_EXCEEDED는 프로젝트 내에서 두 군데에서만 사용 중임을 확인했습니다.
– src/main/java/com/example/cp_main_be/global/infra/S3StorageService.java (25행)
– src/main/java/com/example/cp_main_be/domain/avatar/image/presentation/ImageController.java (29행, 40행)따라서, **옵션 A(기존 상수 매핑 변경)**를 선택하면
ErrorCodeenum만 수정해도 전역 적용되어 추가 작업 없이 일관된 HTTP Status를 반환하게 됩니다.@@ src/main/java/com/example/cp_main_be/global/common/ErrorCode.java - INVALID_FILE(HttpStatus.BAD_REQUEST, "E40007", "적절하지 않은 파일 내용/포맷입니다."), - FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "E40008", "파일 크기 제한을 넘었습니다."), + INVALID_FILE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "E40007", "적절하지 않은 파일 내용/포맷입니다."), + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "E40008", "파일 크기 제한을 넘었습니다."),• 옵션 B(신규 상수 추가) 사용 시, 호출부를 아래와 같이 모두 교체해야 하므로 별도 작업이 필요합니다.
throw new CustomApiException(ErrorCode.INVALID_FILE_TYPE); throw new CustomApiException(ErrorCode.PAYLOAD_TOO_LARGE);
25-27: 401 UNAUTHORIZED 코드 추가 및 인증 검증 처리 필요현재
ErrorCode.java에 401 상태를 나타내는UNAUTHORIZED항목이 없으며, 컨트롤러/서비스 계층에서 인증되지 않은(비로그인) 요청을 명확히 구분할 수 있는 로직도 구현되어 있지 않습니다.
src/main/java/com/example/cp_main_be/global/common/ErrorCode.java에 401 코드(UNAUTHORIZED) 추가@AuthenticationPrincipal으로 주입된User혹은UserDetails파라미터가null인 경우,CustomApiException(ErrorCode.UNAUTHORIZED)를 던져 401 응답을 반환하도록 컨트롤러/서비스 보강- 주요 인증 필요 엔드포인트(예:
LikeController,GuestbookController,FollowController등)에 예외 처리 코드 삽입 검토적용 예시:
--- 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 @@ -23,6 +23,9 @@ public enum ErrorCode { // 400 Bad Request INVALID_TOKEN(HttpStatus.BAD_REQUEST, "E40001", "유효하지 않은 토큰입니다."), INVALID_REQUEST(HttpStatus.BAD_REQUEST, "E40010", "잘못된 요청입니다."), + + // 401 Unauthorized + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E40101", "인증이 필요합니다."), // 403 Forbidden ACCESS_DENIED(HttpStatus.FORBIDDEN, "E40301", "요청에 대한 권한이 없습니다."),컨트롤러 예시 보강:
@PostMapping("/diaries/{diaryId}/likes") public ResponseEntity<ApiResponse<Void>> likeDiary( @AuthenticationPrincipal User user, @PathVariable Long diaryId) { if (user == null) { throw new CustomApiException(ErrorCode.UNAUTHORIZED); } likeService.addLike(user.getId(), diaryId, "DIARY"); return ResponseEntity.ok(ApiResponse.success(null)); }위와 같이 401/403 흐름을 명확히 분리하면, 인증 미비(비로그인)와 권한 부족을 구분하여 클라이언트에 보다 정확한 응답을 제공할 수 있습니다.
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java (2)
24-24: 미사용 의존성 제거
DiaryRepository필드가 현재 사용되지 않습니다. 유지 시 혼동을 초래할 수 있으니 제거를 권장합니다.- private final DiaryRepository diaryRepository;추가로 관련 import도 삭제:
// 제거 대상 import import com.example.cp_main_be.domain.mission.diary.domain.repository.DiaryRepository;
42-73: 다음 사항을 검토하세요:userId·imageUrl보호 로직 및 연관관계 설정
userId가null일 때
•image.getDiary().getUser().getId().equals(userId)호출 시 NPE는 발생하지 않고false가 반환되어 접근 거부(ACCESS_DENIED) 처리되지만, 인증 실패(401)와 권한 부족(403)을 명확히 구분하고 싶다면 메소드 초반에 명시적 null 체크 후ErrorCode.UNAUTHORIZED를 던지는 방안을 고려해 보세요.imageUrlnull 방어
•DiaryImage.imageUrl은@Column(nullable = false)로 null이 허용되지 않으므로 실제로는 항상 값이 존재합니다. 빈 문자열("")만 방지하면 충분하므로,url != null && !url.isBlank()정도로만 체크해도 무방합니다.Diary–DiaryImage연관관계
•Diary.java쪽@OneToOne(mappedBy = "diary", fetch = LAZY)에는cascade나orphanRemoval설정이 없습니다.
• 현재deleteDiaryImage()에서diary.setDiaryImage(null)후diaryImageRepository.delete(image)로 수동 삭제를 처리하고 있어 문제는 없습니다.
• 만약 연관 관계를 끊을 때 자동으로 자식 엔티티를 제거하고 싶다면
@OneToOne(mappedBy = "diary", orphanRemoval = true)- 또는
cascade = CascadeType.REMOVE
설정을 추가하는 방안을 검토해 보세요.src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java (1)
41-44: 삭제 API: 비로그인 차단 추가(선택), 204 No Content 고려현재 비로그인 상황의 처리가 컨트롤러 레벨에 없습니다. 빠른 피드백을 위해 401 처리를 추가하는 것을 권장합니다. 또한 삭제 성공 시 204도 고려 가능합니다(메서드 시그니처 변경 필요).
- public ResponseEntity<ApiResponse<Void>> deleteDiaryImage( - @PathVariable Long diaryId, @PathVariable Long imageId, @AuthenticationPrincipal User user) { - diaryImageService.deleteDiaryImage(diaryId, imageId, user.getId()); - return ResponseEntity.ok(ApiResponse.success(null)); + public ResponseEntity<ApiResponse<Void>> deleteDiaryImage( + @PathVariable Long diaryId, @PathVariable Long imageId, @AuthenticationPrincipal User user) { + if (user == null) { + throw new com.example.cp_main_be.global.common.CustomApiException( + com.example.cp_main_be.global.common.ErrorCode.UNAUTHORIZED); + } + diaryImageService.deleteDiaryImage(diaryId, imageId, user.getId()); + return ResponseEntity.ok(ApiResponse.success(null)); }204로 변경하려면 반환 타입을
ResponseEntity<Void>로 바꾸고return ResponseEntity.noContent().build();를 사용하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java(1 hunks)src/main/java/com/example/cp_main_be/global/common/ErrorCode.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java (2)
src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java (1)
Service(29-193)src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (1)
Service(20-152)
🔇 Additional comments (1)
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/presentation/DiaryImageController.java (1)
22-23: 역할 분리 좋습니다이미지 업로드/삭제를 전용 서비스로 분리한 점이 명확하고 테스트/유지보수에 유리합니다.
| /** 이미지를 S3에 업로드하고, DiaryImage 엔티티를 생성하여 DB에 저장합니다. 아직 Diary와는 연결되지 않은 상태입니다. */ | ||
| public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) { | ||
| String imageUrl = s3Uploader.upload(file, "diary-images"); | ||
|
|
||
| DiaryImage diaryImage = | ||
| DiaryImage.builder() | ||
| .imageUrl(imageUrl) | ||
| .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용 | ||
| .build(); | ||
|
|
||
| DiaryImage savedImage = diaryImageRepository.save(diaryImage); | ||
|
|
||
| return ImageUploadResponse.from(savedImage); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
업로드: 입력 검증 및 예외 매핑 추가 필요
현재 비로그인/빈 파일/비이미지/과대용량에 대한 방어가 없어 안정성과 보안에 취약합니다. 또한 S3 예외가 그대로 노출될 수 있습니다. 서비스 레이어에서 빠르게 검증하고 라이브러리 예외를 표준화된 ErrorCode로 래핑해 주세요.
적용 예시(핵심 라인 교체):
- public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) {
- String imageUrl = s3Uploader.upload(file, "diary-images");
-
- DiaryImage diaryImage =
- DiaryImage.builder()
- .imageUrl(imageUrl)
- .user(user) // 이미지 업로더를 기록하여 추후 권한 검증에 사용
- .build();
-
- DiaryImage savedImage = diaryImageRepository.save(diaryImage);
-
- return ImageUploadResponse.from(savedImage);
- }
+ public ImageUploadResponse uploadDiaryImage(MultipartFile file, User user) {
+ if (user == null) {
+ throw new CustomApiException(ErrorCode.UNAUTHORIZED);
+ }
+ if (file == null || file.isEmpty()) {
+ throw new CustomApiException(ErrorCode.INVALID_REQUEST, "업로드할 파일이 없습니다.");
+ }
+ String contentType = file.getContentType();
+ if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
+ throw new CustomApiException(ErrorCode.INVALID_FILE, "이미지 파일만 업로드할 수 있습니다.");
+ }
+ if (file.getSize() > MAX_IMAGE_SIZE) {
+ throw new CustomApiException(ErrorCode.FILE_SIZE_EXCEEDED, "파일 크기 제한을 넘었습니다.");
+ }
+ String imageUrl;
+ try {
+ imageUrl = s3Uploader.upload(file, "diary-images");
+ } catch (Exception e) {
+ throw new CustomApiException(ErrorCode.UPLOAD_FAILED, "이미지 업로드 중 오류가 발생했습니다.", e);
+ }
+
+ DiaryImage diaryImage =
+ DiaryImage.builder().imageUrl(imageUrl).user(user).build();
+
+ DiaryImage savedImage = diaryImageRepository.save(diaryImage);
+ return ImageUploadResponse.from(savedImage);
+ }해당 변경을 지원하기 위해 클래스 상단에 상수/유틸을 추가하세요(선택 확장: HEIC/WEBP 허용 여부).
// 추가 import
import java.util.Set;
// 클래스 내부 필드
private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024L; // 5MB
private static final Set<String> ALLOWED_CONTENT_TYPES =
Set.of("image/jpeg", "image/png", "image/webp");테스트 포인트:
- 비로그인: UNAUTHORIZED
- 빈 파일: INVALID_REQUEST
- 비이미지: INVALID_FILE
- 초과 용량: FILE_SIZE_EXCEEDED
- S3 예외: UPLOAD_FAILED
필요하시면 위 케이스를 포함한 단위/슬라이스 테스트 템플릿을 드리겠습니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diaryimage/service/DiaryImageService.java
around lines 27 to 40, add input validation and exception mapping: introduce
MAX_IMAGE_SIZE and ALLOWED_CONTENT_TYPES constants at the class level, validate
user != null (throw/return UNAUTHORIZED), file != null && !file.isEmpty
(INVALID_REQUEST), file.getContentType() in ALLOWED_CONTENT_TYPES
(INVALID_FILE), and file.getSize() <= MAX_IMAGE_SIZE (FILE_SIZE_EXCEEDED) before
uploading; wrap the s3Uploader.upload call in try/catch to translate any
S3/library exceptions into your standard UPLOAD_FAILED ErrorCode and then
proceed to build/save DiaryImage and return the response.
📝 개요
이번 PR의 핵심 내용을 한 줄로 요약해 주세요.
💻 작업 내용
이번 PR에서 작업한 내용을 상세히 설명해 주세요.
작업 내용 1
작업 내용 2
...
✅ PR 체크리스트
PR을 보내기 전에 아래 체크리스트를 확인해 주세요.
커밋 메시지는 포맷에 맞게 작성했나요?
스스로 코드를 다시 한번 검토했나요?
관련 이슈를 연결했나요?
빌드 및 테스트가 로컬에서 성공했나요?
🔗 관련 이슈
#103
스크린샷 (선택)
UI 변경 사항이 있다면 스크린샷을 첨부해 주세요.
Summary by CodeRabbit
신기능
리팩터링
변경/공개 API