refactor : 누락된 명세 보강 및 수정#51
Conversation
|
Caution Review failedThe pull request is closed. Walkthrough여러 도메인 리팩터링과 기능 추가: 아바타 도메인 재구성(마스터/아이템/레포지토리/컨트롤러/서비스), 홈 화면·미션·위시트리 기능 추가, 회원가입 닉네임 기반 전환 및 Spring Security(UserDetails) 도입, CI/CD·도커·환경설정(.env.example, application.yml, Firebase) 재정비 및 다수의 패키지/임포트 이동/테스트 비활성화. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor C as 클라이언트
participant AC as AuthController
participant AS as AuthService
participant UR as UserRepository
participant JWT as JwtTokenProvider
participant RT as RefreshTokenRepository
C->>AC: POST /api/v1/auth/signup {nickname}
AC->>AS: registerNewUser(request, deviceId)
AS->>UR: save(User with nickname)
AS->>JWT: generateAccessToken(uuid)
AS->>JWT: generateRefreshToken(uuid)
AS->>RT: save(refreshToken, deviceId)
AS-->>AC: AnonymousRegistrationResponse
AC-->>C: ApiResponse<AnonymousRegistrationResponse>
sequenceDiagram
autonumber
actor U as 사용자
participant HC as HomeController
participant HS as HomeService
participant UR as UserRepository
participant GR as GardenRepository
participant NR as NotificationRepository
participant UDM as UserDailyMissionRepository
U->>HC: GET /api/v1/home
HC->>HS: getHomeScreenData(userId)
HS->>UR: findById(userId)
HS->>GR: findByUserWithDetails(user)
HS->>NR: countByReceiverAndIsReadFalse(user)
HS->>UDM: findTodayMissionsWithMasterByUser(...)
HS-->>HC: HomeResponseDto
HC-->>U: ApiResponse<HomeResponseDto>
sequenceDiagram
autonumber
actor Sys as 스케줄러
participant WS as WishTreeService
participant WR as WishTreeRepository
participant UR as UserRepository
participant GS as GardenService
Sys->>WS: addPointsToWishTree(userId, points)
WS->>WR: findByUserId(userId)
alt 없음
WS->>UR: findById(userId)
WS->>WR: save(new WishTree(user))
end
WS->>WR: wishTree.addPoints(points)
alt 진화 -> TREE
WS->>GS: unlockNewGardenSlot(userId)
end
sequenceDiagram
autonumber
participant GH as GitHub Actions
participant EC2 as EC2 호스트
participant DC as docker-compose
GH->>EC2: SSH로 .env 및 serviceAccountKey.json 작성
GH->>EC2: AWS ECR 로그인
GH->>EC2: ECR_REGISTRY 설정
GH->>DC: docker-compose pull
GH->>DC: docker-compose up -d --remove-orphans
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes 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
🪧 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: 45
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (18)
src/main/java/com/example/cp_main_be/domain/avatar/image/service/ImageProcessingService.java (1)
41-44: 멀티파트 part의 filename/콘텐츠타입 누락 가능성 — MultipartBodyBuilder 사용 권장
BodyInserters.fromMultipartData("image", imageFile.getResource())는 filename 메타가 비어 전송될 수 있습니다. FastAPI(UploadFile)에서 filename을 기대하면 호환성 문제가 생깁니다.MultipartBodyBuilder로 filename과 part content-type을 명시해 주세요.적용 제안 diff:
- .contentType(MediaType.MULTIPART_FORM_DATA) - .body(BodyInserters.fromMultipartData("image", imageFile.getResource())) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData( + new org.springframework.http.client.MultipartBodyBuilder() + .part("image", imageFile.getResource()) + .filename(imageFile.getOriginalFilename()) + .contentType( + imageFile.getContentType() != null + ? MediaType.parseMediaType(imageFile.getContentType()) + : MediaType.APPLICATION_OCTET_STREAM) + .build() + ))추가로 필요한 import:
import org.springframework.http.client.MultipartBodyBuilder;src/test/java/com/example/cp_main_be/domain/social/diary/service/DiaryServiceTest.java (1)
1-297: 패키지 대소문자 불일치 수정 (domain/.../diary/domain/Repository → repository)
Java 표준에 따라 모든 패키지명은 소문자로 작성되어야 하나, 현재 메인 소스는.../diary/domain/Repository디렉토리 및 패키지를 사용하고, 테스트 소스는.../diary/domain/repository를 참조해 Linux 기반 CI 환경에서 컴파일 에러가 발생하고 있습니다. 아래 항목을 반영해 반드시 수정해주세요.• 디렉토리 및 패키지명 변경
src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository/DiaryRepository.java
→ 디렉토리명Repository→repository로 변경
→ 파일 상단package선언도 다음과 같이 수정- package com.example.cp_main_be.domain.social.diary.domain.Repository; + package com.example.cp_main_be.domain.social.diary.domain.repository;• import 구문 일괄 수정
- 메인 코드(서비스·리스너 등):
import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository;
→...domain.repository.DiaryRepository;- 테스트 코드(
DiaryServiceTest.java,LikeServiceTest.java,FeedServiceTest.java,CommentServiceTest.java등):
이미 소문자 경로를 사용 중이나, 메인 코드 수정 후 잘못된 참조가 없도록 재검토• CI 단계에서 패키지 경로 점검 자동화 (권장)
- 레포 전체에서 대소문자 불일치가 발생하는 import가 있는지 검사하는 스크립트를 추가해 재발 방지
src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java (1)
65-93: targetType 문자열 하드코딩/대소문자 혼재 — enum 도입 권장"feed"/"DIARY"/"AVATAR_POST" vs "diary"/"avatar_post" 등 혼재되어 유지보수/버그 리스크가 큽니다. enum LikeTargetType으로 통일하고 switch 사용을 권장합니다.
간단 보정(임시):
- if ("feed".equalsIgnoreCase(targetType)) { + if ("FEED".equalsIgnoreCase(targetType)) { @@ - } else if ("DIARY".equalsIgnoreCase(targetType)) { + } else if ("DIARY".equalsIgnoreCase(targetType)) { @@ - } else if ("AVATAR_POST".equalsIgnoreCase(targetType)) { + } else if ("AVATAR_POST".equalsIgnoreCase(targetType)) {근본 해결(권장):
- enum LikeTargetType { FEED, DIARY, AVATAR_POST }
- 서비스 파라미터를 enum으로 받고, 컨트롤러에서 문자열→enum 변환/검증
Also applies to: 107-125
src/main/java/com/example/cp_main_be/domain/social/comment/service/CommentService.java (1)
70-71: 이벤트 리스너 커밋 이후 실행 보장 위해AFTER_COMMIT적용 필요현재
CommentService에서 댓글 생성 이벤트를 트랜잭션 내에서 즉시 발행하고 있으며, 이를 처리하는 리스너는@EventListener+@Transactional로 등록되어 있어, 트랜잭션 커밋 전에 실행됩니다. 만약 리스너가 커밋 이후의 일관된 DB 상태(ID 조회, 영속 상태 보장 등)를 필요로 한다면, 다음과 같이 변경하여 처리 시점을 보장해 주세요.• 대상 파일
src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java• 수정 위치 및 예시 (handleCommentCreatedEvent 기준)
- @EventListener - @Transactional public void handleCommentCreatedEvent(CommentCreatedEvent event) { //... } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleCommentCreatedEvent(CommentCreatedEvent event) { //... }
- 위와 같이
@EventListener및 별도@Transactional애노테이션 대신
@TransactionalEventListener(phase = AFTER_COMMIT)를 사용하면,
DB 커밋 이후에만 리스너가 실행되어 일관된 상태를 보장합니다.• 참고
- 다른 이벤트 핸들러(
handleLikeCreatedEvent,handleGuestbookCreatedEvent등)에도 동일하게 적용을 검토해 주세요.src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java (1)
87-93: Optional#get() 직접 호출로 NPE/NoSuchElementException 위험
findById(...).get()은 대상이 삭제되었거나 없는 경우 런타임 예외가 발생합니다. 안전하게orElseThrow로 명시적 예외를 던지거나, 존재하지 않으면 알림 생성을 스킵하도록 처리하세요.적용 예시(diff):
- if (Objects.equals(targetType, "DIARY")) { - receiver = diaryRepository.findById(targetId).get().getUser(); - url = "/api/v1/diaries/" + targetId; - } else { - receiver = avatarPostRepository.findById(targetId).get().getUser(); - url = "/api/v1/avatar-posts/" + targetId; - } + if (Objects.equals(targetType, "DIARY")) { + var diary = + diaryRepository + .findById(targetId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 일기(ID=" + targetId + ")입니다.")); + receiver = diary.getUser(); + url = "/api/v1/diaries/" + targetId; + } else if (Objects.equals(targetType, "AVATAR_POST")) { + var avatarPost = + avatarPostRepository + .findById(targetId) + .orElseThrow( + () -> new RuntimeException("존재하지 않는 아바타 포스트(ID=" + targetId + ")입니다.")); + receiver = avatarPost.getUser(); + url = "/api/v1/avatar-posts/" + targetId; + } else { + // 알 수 없는 타깃 타입인 경우 스킵(로그만) + log.warn("Unknown like targetType: {} (targetId: {})", targetType, targetId); + return; + }필요 시 프로젝트의 표준
NotFound예외로 교체하세요.src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java (1)
59-74: CORS: credentials=true와 와일드카드 패턴(*)의 병행 사용은 운영에서 과도하게 개방적입니다Spring은 addAllowedOriginPattern("*") + allowCredentials(true) 조합에서 요청 Origin을 에코하지만, 결과적으로 모든 오리진에 인증 쿠키/토큰을 노출할 수 있습니다. 운영에서는 허용 오리진을 프로퍼티 기반 화이트리스트로 제한하세요.
예시(프로퍼티 주입형 화이트리스트):
- configuration.addAllowedOriginPattern("*"); + // application.yml: cors.allowed-origins: "https://example.com,https://app.example.com" + String origins = System.getProperty("cors.allowed-origins", + System.getenv().getOrDefault("CORS_ALLOWED_ORIGINS", "*")); + if ("*".equals(origins)) { + // 로컬/테스트만 와일드카드 허용 + configuration.addAllowedOriginPattern("*"); + } else { + for (String o : origins.split(",")) { + configuration.addAllowedOrigin(o.trim()); + } + }운영 프로필에서는 가능하면
allowCredentials(false)+ 토큰 방식(Authorization 헤더)으로의 전환도 검토해 주세요. 값 주입/프로파일 분기를 위한 보일러플레이트 추가가 필요하면 도와드리겠습니다.src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java (4)
110-118: 성공 케이스에 대한 유저 조회 Stubbing 추가서비스가 내부에서
userRepository.findById(user.getId())를 호출한다면, 성공 케이스에서도 해당 Stubbing이 필요합니다.// when - gardenService.unlockNewGardenSlot(user.getId()); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + gardenService.unlockNewGardenSlot(user.getId());
139-146: 레벨 부족 케이스에도 유저 조회 Stubbing 필요예외 흐름이라도 유저 조회가 선행된다면 동일하게 Stubbing해야 합니다.
- IllegalStateException exception = - assertThrows( - IllegalStateException.class, () -> gardenService.unlockNewGardenSlot(user.getId())); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> gardenService.unlockNewGardenSlot(user.getId()));
166-173: 최대 개수 도달 케이스에도 유저 조회 Stubbing 필요동일 사유로 아래와 같이 보강해 주세요.
- IllegalStateException exception = - assertThrows( - IllegalStateException.class, () -> gardenService.unlockNewGardenSlot(user.getId())); + given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> gardenService.unlockNewGardenSlot(user.getId()));
27-31: 텃밭 서비스 테스트: UserRepository 모킹 누락GardenServiceTest에서 사용자 조회 로직(
userRepository.findById(...)등)을 호출하는데, 현재@Mock UserRepository와 해당 Stubbing이 빠져 있어 NPE 또는Optional.empty()에 의한 예외가 발생할 수 있습니다. 아래와 같이 모킹과 Stubbing을 추가해 주세요.• 대상 파일
– src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java• UserRepository FQN
– com.example.cp_main_be.domain.member.user.domain.repository.UserRepository• 추가할 Mock 필드
@Mock private GardenRepository gardenRepository; + @Mock private com.example.cp_main_be.domain.member.user.domain.repository.UserRepository userRepository;• 예시 Stubbing
// 예: userId로 조회하는 경우 Long userId = 1L; User mockUser = new User(...); when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); // 또는 username으로 조회하는 경우 String username = "testUser"; when(userRepository.findByUsername(username)).thenReturn(Optional.of(mockUser));위 변경으로 테스트가 안정적으로 사용자 정보를 모킹하여 성공 케이스를 검증할 수 있습니다.
src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java (3)
53-69: 사진 업로드 완료 처리 로직 단순화 및 완료 시각 누락 보완 제안
- 현재는 이미지 URL을 set한 뒤 동일 문자열 비교로 완료 처리합니다. 사실상 항상 true가 되어 중복 조건입니다.
- 완료 시간(completedAt) 기록이 누락되어 다른 미션(퀴즈) 처리와 불일치합니다.
- 업로드 파일의 null/empty 검증도 없어서 NPE/의도치 않은 완료 처리 위험이 있습니다.
아래처럼 조건 제거, 입력 검증, 완료 시각 기록을 권장합니다.
public String uploadPictureForDailyMission(Long userDailyMissionId, MultipartFile file) { UserImageMission userDailyMission = (UserImageMission) userDailyMissionRepository .findById(userDailyMissionId) .orElseThrow(() -> new RuntimeException("미션을 찾을 수 없습니다.")); - String imageUrl = s3Uploader.upload(file, "mission-images"); + if (file == null || file.isEmpty()) { + throw new CustomApiException(ErrorCode.INVALID_REQUEST); + } + String imageUrl = s3Uploader.upload(file, "mission-images"); userDailyMission.setDailyMissionImage(DailyMissionImage.builder().imageUrl(imageUrl).build()); - if (userDailyMission.getDailyMissionImage().getImageUrl().equals(imageUrl)) { - userDailyMission.setCompleted(true); - } + userDailyMission.setCompleted(true); + userDailyMission.setCompletedAt(LocalDateTime.now()); return imageUrl; }
120-136: 완료 처리 시 경험치 지급 대상 식별 방식 개선현재
userService.getCurrentUser().getId()로 경험치를 지급합니다. 서비스 계층에서 “현재 사용자” 컨텍스트에 의존하면 호출 컨텍스트(관리자 작업/스케줄러 등)에 따라 오동작할 수 있습니다.userDailyMission의 소유자에게 확정적으로 지급하는 편이 안전합니다.public void completeDailyMission(Long userDailyMissionId) { UserDailyMission userDailyMission = getUserDailyMission(userDailyMissionId); userDailyMission.setCompleted(true); userDailyMission.setCompletedAt(LocalDateTime.now()); final int PHOTO_MISSION_COMPLETE_POINT = 15; final int QUIZ_MISSION_COMPLETE_POINT = 15; final int DIARY_MISSION_COMPLETE_POINT = 15; MissionType type = userDailyMission.getDailyMissionMaster().getMissionType(); - if (type.equals(MissionType.PHOTO)) - userService.addExperience(userService.getCurrentUser().getId(), PHOTO_MISSION_COMPLETE_POINT); - else if (type.equals(MissionType.QUIZ)) - userService.addExperience(userService.getCurrentUser().getId(), QUIZ_MISSION_COMPLETE_POINT); - else - userService.addExperience(userService.getCurrentUser().getId(), DIARY_MISSION_COMPLETE_POINT); + Long ownerId = userDailyMission.getUser().getId(); + if (type.equals(MissionType.PHOTO)) + userService.addExperience(ownerId, PHOTO_MISSION_COMPLETE_POINT); + else if (type.equals(MissionType.QUIZ)) + userService.addExperience(ownerId, QUIZ_MISSION_COMPLETE_POINT); + else + userService.addExperience(ownerId, DIARY_MISSION_COMPLETE_POINT); userDailyMissionRepository.save(userDailyMission); }
71-118: 메서드명 오타 및 선택지 식별자 비교 로직 수정 필요
- 필수 조치
•UserDailyMissionService.summitAnswer→submitAnswer으로 변경
- 연관 호출부:
UserDailyMissionController(라인 91)- 서비스 메서드 선언부 (src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java:71)
- 단위 테스트 (
UserDailyMissionServiceTest)
• DTO의selectedOptionId(PK)와QuizOptions.optionOrder(순서) 비교 로직 불일치- 아래처럼
QuizOptions.id와 비교하도록 변경- public Boolean summitAnswer(QuizRequestDTO request, Long userDailyMissionId) { + public Boolean submitAnswer(QuizRequestDTO request, Long userDailyMissionId) { ... - for (QuizOptions quizOptions : quizOptionsList) { - if (quizOptions.getOptionOrder() == request.getSelectedOptionId()) { + for (QuizOptions quizOptions : quizOptionsList) { + if (quizOptions.getId().equals(request.getSelectedOptionId())) { selectedOption = quizOptions; isCorrect = quizOptions.isCorrect(); break; } }
- 선택 사항
• 퀴즈 정답·완수 처리 로직(completedAt, 고정 점수 부여)과 사진/일기 미션의completeDailyMission지급 기준이 상이함
→ 포인트/경험치 정책을 별도 유틸 또는 정책 클래스로 분리하여 일관화 권장src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/domain/repository/UserDailyMissionRepository.java (1)
16-24: 일자 단위 집계 그룹핑 오류 가능성(DAY만 사용 시 월 경계 합산).
DAY(udm.completedAt)만 그룹핑하면 기간이 월을 넘을 때 동일 일자(1~31)가 합쳐집니다. 실사용이 “해당 월 한정”이면 안전하지만, 일반적으로는YEAR/MONTH/DAY또는 ‘DATE(날짜)’로 그룹핑해야 합니다.선호 옵션:
- 옵션 A(권장): DTO를
LocalDate date, long count형태로 바꾸고DATE(udm.completedAt)로 그룹핑- 옵션 B(현 DTO 유지):
YEAR/MONTH/DAY를 함께 SELECT/GROUP BY/ORDER BY에 포함해 중복을 방지예시 A(휴버네이트 함수 사용, DTO 변경 필요):
@Query(""" SELECT new ...MissionCountPerDay(FUNCTION('date', udm.completedAt), COUNT(udm.id)) FROM UserDailyMission udm WHERE udm.user = :user AND udm.isCompleted = true AND udm.completedAt BETWEEN :startDate AND :endDate GROUP BY FUNCTION('date', udm.completedAt) ORDER BY FUNCTION('date', udm.completedAt) """)예시 B(연/월/일 분리, DTO/매핑 보강 필요):
@Query(""" SELECT new ...MissionCountPerDay( YEAR(udm.completedAt), MONTH(udm.completedAt), DAY(udm.completedAt), COUNT(udm.id)) FROM UserDailyMission udm WHERE udm.user = :user AND udm.isCompleted = true AND udm.completedAt BETWEEN :startDate AND :endDate GROUP BY YEAR(udm.completedAt), MONTH(udm.completedAt), DAY(udm.completedAt) ORDER BY YEAR(udm.completedAt), MONTH(udm.completedAt), DAY(udm.completedAt) """)src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java (2)
88-121: 닉네임 검증/중복 체크 누락으로 계정 생성 무결성 저하 가능.현재
registerNewUser는 요청 닉네임을 그대로 저장합니다. 형식/길이/금칙어/중복 검증이 없으면
- DB Unique 제약 위반으로 500 발생,
- 의도치 않은 계정 대량 생성(스팸) 등의 문제가 생길 수 있습니다.
다음 보완을 권장합니다.
- DTO에 Bean Validation(@notblank, @SiZe, @pattern) 추가 + 컨트롤러
@Valid.- 서비스에서 중복 선검사(
existsByUsername) 및 트리밍/정규화.- 충돌 시 명확한 에러코드 매핑.
예시(서비스 레벨 보강):
public AnonymousRegistrationResponse registerNewUser( RegistrationRequest request, String deviceId) { - // 2. 랜덤 닉네임 생성 로직 삭제, 요청받은 닉네임 사용 + // 1) 닉네임 전처리/검증 + String nicknameRaw = request.getNickname(); + String nickname = nicknameRaw == null ? null : nicknameRaw.trim(); + if (nickname == null || nickname.isEmpty()) { + throw new CustomApiException(ErrorCode.INVALID_REQUEST); // 실제 ErrorCode에 맞게 조정 + } + // 금칙어/형식/길이 검증은 Bean Validation로 위임하거나 여기서 추가 + if (userRepository.existsByUsername(nickname)) { + throw new CustomApiException(ErrorCode.DUPLICATE_RESOURCE); // 실제 코드로 교체 + } UUID newUuid = UUID.randomUUID(); - String nickname = request.getNickname(); User newUser = User.builder() .uuid(newUuid) .username(nickname) // 사용자가 입력한 닉네임으로 설정 .build();컨트롤러에서도
@Valid RegistrationRequest를 적용해 주세요.
90-114: 디바이스 중복 가입 로직 구현 필요
다음 사항을 확인하고, 기기(deviceId) 기반 중복 가입을 방지하는 로직을 추가해주세요.
- UserRepository에는
existsByUsername(String username)메서드가 이미 존재합니다.
• 위치:src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java:14- RefreshTokenRepository(또는 다른 리포지토리)에는 아직
findByDeviceId또는existsByDeviceId같은 메서드가 정의되어 있지 않습니다.해결 방안 예시:
RefreshTokenRepository에등의 메서드를 추가Optional<RefreshToken> findByDeviceId(String deviceId); Boolean existsByDeviceId(String deviceId);- 가입(onboarding) 처리 시
• 해당 deviceId로 이미 저장된 RefreshToken이 있으면 기존 사용자 재사용 또는 예외 처리
• 없으면 신규 생성.github/workflows/ci-cd.yml (2)
8-10: PR 동기화 이벤트 누락으로 커밋 푸시 시 빌드 미실행.
pull_request.types에synchronize가 없어 PR에 새 커밋을 푸시해도 워크플로우가 트리거되지 않습니다.pull_request: - types: [ opened, edited ] # PR 생성/수정 시에만 동작하도록 타입 명시 + types: [ opened, edited, synchronize ] # 커밋 푸시(synchronize) 시에도 동작 branches: [ develop ]
146-156: EC2 가용성 체크에서 nc 미설치로 실패 가능.
ubuntu-latest에nc가 기본 설치되어 있지 않아 단계가 실패하거나 출력이 비어ec2-available이 unset 될 수 있습니다.
/dev/tcp기반 체크로 대체하거나netcat을 설치하세요. 간단 대체안:- - name: Check EC2 instance status + - name: Check EC2 instance status id: check-ec2-status run: | - if timeout 10 nc -z ${{ secrets.EC2_HOST }} 22 2>/dev/null; then + if timeout 10 bash -lc 'echo > /dev/tcp/${{ secrets.EC2_HOST }}/22' 2>/dev/null; then echo "EC2 instance is reachable" echo "available=true" >> $GITHUB_OUTPUT else echo "EC2 instance is not reachable or stopped" echo "available=false" >> $GITHUB_OUTPUT fi continue-on-error: true
| # Spring Profile | ||
| SPRING_PROFILES_ACTIVE=prod |
There was a problem hiding this comment.
🛠️ Refactor suggestion
샘플 프로필을 prod로 고정하는 것은 오탑/오배포 리스크
샘플에서는 보통 local/dev를 기본값으로 둡니다. 실서비스에서만 prod로 오버라이드되도록 변경을 권장합니다.
-SPRING_PROFILES_ACTIVE=prod
+SPRING_PROFILES_ACTIVE=local📝 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.
| # Spring Profile | |
| SPRING_PROFILES_ACTIVE=prod | |
| # Spring Profile | |
| SPRING_PROFILES_ACTIVE=local |
🤖 Prompt for AI Agents
In .env.example around lines 16-17, the sample hardcodes
SPRING_PROFILES_ACTIVE=prod which risks accidental production deployments;
change the example default to a non-production profile (e.g., local or dev) and
add a short comment indicating that production should be set only in real
deployments or via environment-specific overrides (CI/config management), so
users must explicitly opt-in to prod.
| implementation 'com.fasterxml.jackson.core:jackson-databind' | ||
| // JJWT 추가 | ||
| implementation 'io.jsonwebtoken:jjwt-api:0.12.5' | ||
| runtimeOnly 'com.h2database:h2' |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
로컬 H2 실행을 위한 추가는 적절 — 운영 번들링 영향만 점검해 주세요
local/H2 프로필 구동 보장을 위해 runtimeOnly 추가한 선택은 합리적입니다.
운영 이미지에 H2 드라이버가 함께 포함되지 않는지 확인해 주세요. 또한 Spring Boot BOM이 해석한 H2 실제 버전을 점검해 호환성 이슈를 예방하는 것을 권장합니다. 아래 스크립트로 설정/프로필과 H2 사용 흔적을 빠르게 스캔할 수 있습니다.
🏁 Script executed:
#!/bin/bash
# H2 관련 yml 설정 확인
rg -n -C2 -g 'src/main/resources/**' -P '(?i)h2|jdbc:h2|h2\.console|spring\.datasource'
# 빌드 스크립트에서 H2 선언 확인
rg -nP 'com\.h2database:h2' build.gradle
# (선택) Dockerfile/CI에서 prod 이미지에 H2가 포함될 여지가 있는지 확인
fd -HI 'Dockerfile*' | xargs -I{} sh -c "echo '--- {} ---'; cat {}"Length of output: 2559
🏁 Script executed:
#!/bin/bash
# Check if Spring Boot plugin is applied in build.gradle
rg -nP 'org\.springframework\.boot' build.gradle || rg -nP 'apply plugin.*spring-boot' build.gradle
# Check for bootJar task configuration
rg -nP 'bootJar' -C2 build.gradle
# Check for developmentOnly configuration (to isolate H2 from production)
rg -nP 'developmentOnly' -C2 build.gradleLength of output: 1169
H2 의존성 bootJar 포함 여부 확인 및 개발 전용 전환 필요
현재 build.gradle에 H2 드라이버가
runtimeOnly 'com.h2database:h2'로 선언되어 있어, Spring Boot의 bootJar 기본 동작에 따라 최종 JAR(→prod 이미지)에 포함됩니다. 로컬 프로파일 전용이라면 production 번들에 불필요하게 포함되는 것이므로, 아래 수정이 필요합니다.
• build.gradle (라인 49)
runtimeOnly 'com.h2database:h2'→
developmentOnly 'com.h2database:h2'
또는bootJar { requiresUnpack = [] }등으로 명시적 제외 설정
• H2 실제 버전 확인
./gradlew dependencyInsight \
--dependency com.h2database:h2 \
--configuration runtimeClasspath명령으로 Spring Boot BOM이 해석한 버전을 점검하시기 바랍니다.
🤖 Prompt for AI Agents
In build.gradle around line 49, the H2 dependency is declared as runtimeOnly
which causes the H2 driver to be bundled into the bootJar (production image);
change that declaration to developmentOnly 'com.h2database:h2' to keep H2 out of
production artifacts (or alternatively configure bootJar to exclude/unpack it
explicitly), and run the suggested dependencyInsight Gradle command to verify
the resolved H2 version from the Spring Boot BOM.
| volumes: | ||
| - ./serviceAccountKey.json:/app/serviceAccountKey.json |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Firebase 서비스 키는 read-only 바인드 또는 Docker secrets로 마운트하세요
현재 키 파일이 쓰기 가능으로 컨테이너에 노출됩니다. 최소한 읽기 전용으로 바꾸고, 가능하면 secrets 사용을 고려하세요.
최소 수정(읽기 전용 마운트):
- - ./serviceAccountKey.json:/app/serviceAccountKey.json
+ - ./serviceAccountKey.json:/app/serviceAccountKey.json:ro대안(secrets 사용 — 애플리케이션이 /run/secrets 경로를 읽도록 환경도 함께 조정 필요):
services:
spring-app:
# ...
- volumes:
- - ./serviceAccountKey.json:/app/serviceAccountKey.json:ro
+ secrets:
+ - firebase_service_account
+ environment:
+ # GOOGLE_APPLICATION_CREDENTIALS를 secrets 경로로 지정
+ GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase_service_account
restart: always
+secrets:
+ firebase_service_account:
+ file: ./serviceAccountKey.json또한 serviceAccountKey.json이 VCS에 포함되지 않도록 .gitignore에 확실히 추가되어 있는지 확인 부탁드립니다.
🏁 Script executed:
#!/bin/bash
# 1. docker-compose.yml 파일 위치 확인
fd docker-compose.yml
# 2. serviceAccountKey.json 볼륨 마운트 라인(라인 14-15) 확인
grep -n 'serviceAccountKey.json' -C2 $(fd docker-compose.yml)
# 3. .gitignore에 serviceAccountKey.json 항목이 있는지 확인
grep -R 'serviceAccountKey.json' -n .gitignore || echo ".gitignore에 serviceAccountKey.json 항목이 없습니다."Length of output: 412
Firebase 서비스 키 마운트 보안 강화: 읽기 전용 및 .gitignore 등록 필수
현재 docker-compose.yml에서 서비스 키 파일이 쓰기 가능으로 컨테이너에 노출되어 있습니다. 아래 수정 사항을 반영해 보안을 강화해주세요.
주요 수정 사항:
volumes에:ro옵션 추가 (읽기 전용 마운트).gitignore에serviceAccountKey.json항목 등록 (VCS에 민감정보 포함 방지)
필수 수정 (읽기 전용 마운트 및 .gitignore 등록):
services:
spring-app:
# ...
- volumes:
- - ./serviceAccountKey.json:/app/serviceAccountKey.json
+ volumes:
+ - ./serviceAccountKey.json:/app/serviceAccountKey.json:ro
restart: always# .gitignore
+ serviceAccountKey.json선택 사항 (Docker secrets 사용 – 애플리케이션이 /run/secrets 경로를 읽도록 환경 변수 조정 필요):
services:
spring-app:
# ...
- volumes:
- - ./serviceAccountKey.json:/app/serviceAccountKey.json:ro
+ secrets:
+ - firebase_service_account
+ environment:
+ GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase_service_account
restart: always
secrets:
firebase_service_account:
file: ./serviceAccountKey.json🤖 Prompt for AI Agents
In docker-compose.yml around lines 14 to 15, the serviceAccountKey.json volume
is mounted writable; change the volume mapping to be read-only by adding the :ro
option (e.g. ./serviceAccountKey.json:/app/serviceAccountKey.json:ro) to prevent
in-container modification, and also add serviceAccountKey.json to the repository
.gitignore to avoid committing the secret; optionally consider using Docker
secrets and adjust the app to read from /run/secrets if you switch to that
approach.
| public static ItemRarity fromCode(String code) { | ||
| for (ItemRarity rarity : values()) { | ||
| if (rarity.getCode().equals(code)) { | ||
| return rarity; | ||
| } | ||
| } | ||
| throw new IllegalArgumentException("Unknown ItemRarity code: " + code); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
fromCode 선형 탐색 → 해시 룩업으로 변경
매 호출 O(n) 선형 탐색입니다. 정적 맵 캐시로 O(1) 처리하고, 입력 널/대소문자 처리도 명확히 해주세요.
다음과 같이 리팩터 권장:
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
@@
public enum ItemRarity {
@@
private final int sortOrder; // 정렬 순서
+
+ private static final Map<String, ItemRarity> CODE_MAP =
+ Arrays.stream(values())
+ .collect(Collectors.toUnmodifiableMap(ItemRarity::getCode, Function.identity()));
public static ItemRarity fromCode(String code) {
- for (ItemRarity rarity : values()) {
- if (rarity.getCode().equals(code)) {
- return rarity;
- }
- }
- throw new IllegalArgumentException("Unknown ItemRarity code: " + code);
+ if (code == null) {
+ throw new IllegalArgumentException("ItemRarity code must not be null");
+ }
+ ItemRarity rarity = CODE_MAP.get(code.toUpperCase(Locale.ROOT));
+ if (rarity != null) return rarity;
+ throw new IllegalArgumentException("Unknown ItemRarity code: " + code);
}참고: code를 선언부에서도 대문자로만 사용하므로, look-up에서 toUpperCase(Locale.ROOT) 통일을 권장합니다.
📝 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 static ItemRarity fromCode(String code) { | |
| for (ItemRarity rarity : values()) { | |
| if (rarity.getCode().equals(code)) { | |
| return rarity; | |
| } | |
| } | |
| throw new IllegalArgumentException("Unknown ItemRarity code: " + code); | |
| } | |
| // Add these imports at the top of src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/ItemRarity.java | |
| import java.util.Arrays; | |
| import java.util.Locale; | |
| import java.util.Map; | |
| import java.util.function.Function; | |
| import java.util.stream.Collectors; | |
| public enum ItemRarity { | |
| // … existing constants … | |
| private final int sortOrder; // 정렬 순서 | |
| // + Static cache for constant-time lookups | |
| private static final Map<String, ItemRarity> CODE_MAP = | |
| Arrays.stream(values()) | |
| .collect(Collectors.toUnmodifiableMap(ItemRarity::getCode, Function.identity())); | |
| public String getCode() { | |
| // … existing implementation … | |
| } | |
| public static ItemRarity fromCode(String code) { | |
| // + Explicit null check | |
| if (code == null) { | |
| throw new IllegalArgumentException("ItemRarity code must not be null"); | |
| } | |
| // + Case-normalize and fetch from cache | |
| ItemRarity rarity = CODE_MAP.get(code.toUpperCase(Locale.ROOT)); | |
| if (rarity != null) { | |
| return rarity; | |
| } | |
| throw new IllegalArgumentException("Unknown ItemRarity code: " + code); | |
| } | |
| // … remaining code … | |
| } |
| @Query( | ||
| value = "SELECT * FROM avatar WHERE is_default_avatar = false ORDER BY RAND() LIMIT 1", | ||
| nativeQuery = true) | ||
| Avatar findRandomAvatar(); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
ORDER BY RAND()의 비용/이식성 문제 및 비어있는 결과 처리
ORDER BY RAND()+LIMIT 1는 전체 정렬이 발생해 테이블이 커질수록 성능 저하가 큽니다. 또한 DB별 함수가 상이합니다(MySQL:RAND(), PostgreSQL:RANDOM()), 네이티브 쿼리는 이식성 저하 요인이 됩니다.- 결과가 없을 때
null반환보다는Optional<Avatar>가 안전합니다.
Pageable 기반 파생쿼리로 대체하는 방식을 권장합니다(무작위 오프셋 방식). 리포지토리에는 파생 메서드만 두고, “랜덤 선택”은 서비스에서 오프셋을 계산하도록 분리하세요.
아래와 같이 교체를 제안합니다.
public interface AvatarRepository extends JpaRepository<Avatar, Long> {
-
- @Query(
- value = "SELECT * FROM avatar WHERE is_default_avatar = false ORDER BY RAND() LIMIT 1",
- nativeQuery = true)
- Avatar findRandomAvatar();
+ // 기본 아바타가 아닌 행을 페이지로 조회 (서비스에서 랜덤 오프셋 적용)
+ org.springframework.data.domain.Page<Avatar> findByIsDefaultAvatarFalse(
+ org.springframework.data.domain.Pageable pageable);
}서비스 예시(참고용, 다른 파일):
// AvatarService.java (예시)
public Optional<Avatar> pickRandomNonDefault() {
long count = avatarRepository.count(Example.of(new Avatar(false /* isDefaultAvatar */)));
if (count == 0) return Optional.empty();
int idx = ThreadLocalRandom.current().nextInt((int) Math.min(count, Integer.MAX_VALUE));
Page<Avatar> page = avatarRepository.findByIsDefaultAvatarFalse(PageRequest.of(idx, 1));
return page.hasContent() ? Optional.of(page.getContent().get(0)) : Optional.empty();
}🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/AvatarRepository.java
around lines 9 to 12, replace the native ORDER BY RAND() query and
null-returning signature with a derived, pageable-friendly method and Optional
result: remove the nativeQuery method and add a repository method like
findByIsDefaultAvatarFalse(Pageable pageable) that returns Page<Avatar> or
List<Avatar> (and change the repository return types to Optional where
appropriate); then implement the random selection logic in the service layer by
counting non-default avatars, computing a random offset within [0, count), and
calling the repository with PageRequest.of(offset, 1) to retrieve a single
avatar, returning Optional.empty() when count is zero — this avoids ORDER BY
RAND() portability/performance issues and ensures safe Optional handling for
empty results.
| @@ -41,22 +41,18 @@ protected void doFilterInternal( | |||
| logger.info("Token found: {}", token); | |||
There was a problem hiding this comment.
원문 토큰을 INFO 로그로 남기면 보안 사고로 직결됩니다.
logger.info("Token found: {}", token);은 액세스 토큰 평문 노출입니다. 마스킹/해시 또는 DEBUG 이하로 내리고, 토큰 값 자체는 남기지 마세요.
- logger.info("Token found: {}", token);
+ // 원문 토큰 로그 금지: 존재만 디버그 레벨로 기록
+ logger.debug("Token received.");📝 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.
| logger.info("Token found: {}", token); | |
| // 원문 토큰 로그 금지: 존재만 디버그 레벨로 기록 | |
| logger.debug("Token received."); |
🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/global/jwt/JwtAuthenticationFilter.java
around line 41, the code currently logs the raw access token which exposes
sensitive credentials; remove logging of the plaintext token and replace it with
a non-sensitive alternative such as logging only token presence or a
hashed/masked token, or change the level to DEBUG/TRACE and ensure the token
value itself is never logged; implement one of these safe options and ensure any
tests or usages that relied on the raw token log are updated accordingly.
| security: | ||
| debug: true |
There was a problem hiding this comment.
prod에 TRACE 보안 로깅이 누출됩니다(민감정보 노출 위험).
기본 문서(프로파일 무관)에 spring.security.debug: true와 logging.level.org.springframework.security: TRACE가 선언되어 있어 prod에서도 TRACE가 적용됩니다. JWT/인증 관련 민감 정보가 로그에 남을 수 있습니다. 해당 설정은 local 프로파일로 한정하거나 prod에서 명시적으로 낮춰주세요.
다음과 같이 기본 문서의 로깅 블록을 제거하고, local 문서에만 추가하는 것을 권장합니다.
-logging:
- level:
- org:
- springframework:
- security: TRACElocal 문서에 추가:
spring:
h2:
console:
enabled: true
path: /h2-console
+
+logging:
+ level:
+ org:
+ springframework:
+ security: TRACEprod 문서에선 명시적으로 낮추는 것도 안전합니다:
spring:
security:
debug: false
+logging:
+ level:
+ org:
+ springframework:
+ security: INFOAlso applies to: 34-40
🤖 Prompt for AI Agents
In src/main/resources/application.yml around lines 14-15 (and also apply the
same change for lines 34-40), the file enables spring security debug/TRACE
logging in the default profile which leaks sensitive JWT/auth info in prod;
remove the security debug/TRACE settings from the default application.yml, add
equivalent debug/TRACE entries only to application-local.yml (so local testing
retains them), and explicitly set the security logging level to a non-verbose
level (e.g., INFO or WARN) in application-prod.yml to ensure production does not
emit TRACE logs.
| jpa: | ||
| hibernate: | ||
| ddl-auto: create-drop # ????? ?? ? ??? ??, ?? ? ?? | ||
| defer-datasource-initialization: true |
There was a problem hiding this comment.
🛠️ Refactor suggestion
local(H2)에서는 Hibernate Dialect를 H2Dialect로 오버라이드하세요.
현재 기본 문서에 PostgreSQLDialect가 지정되어 있고, local은 H2(MODE=PostgreSQL)를 사용합니다. MODE를 줘도 Hibernate Dialect가 Postgres로 고정되면 미묘한 호환 이슈가 발생할 수 있습니다. local 프로파일 내에서 H2Dialect를 명시하는 것을 권장합니다.
spring:
jpa:
+ database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
defer-datasource-initialization: true📝 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.
| jpa: | |
| hibernate: | |
| ddl-auto: create-drop # ????? ?? ? ??? ??, ?? ? ?? | |
| defer-datasource-initialization: true | |
| jpa: | |
| database-platform: org.hibernate.dialect.H2Dialect | |
| hibernate: | |
| ddl-auto: create-drop # ????? ?? ? ??? ??, ?? ? ?? | |
| defer-datasource-initialization: true |
🤖 Prompt for AI Agents
In src/main/resources/application.yml around lines 52 to 55, the configuration
uses the default PostgreSQL dialect while the local profile runs H2
(MODE=PostgreSQL); explicitly override Hibernate dialect for the local profile
by adding a property under the local profile (e.g.,
spring.jpa.properties.hibernate.dialect: org.hibernate.dialect.H2Dialect) so
Hibernate uses H2Dialect when running with the local H2 database.
| import static org.mockito.Mockito.*; | ||
|
|
||
| import com.example.cp_main_be.domain.content.image.ImageUploader; | ||
| import com.example.cp_main_be.domain.avatar.image.ImageUploader; |
There was a problem hiding this comment.
ImageUploader 패키지 이동 반영 — LGTM. 단, DiaryRepository 패키지 경로도 함께 정리 필요
현재 테스트는 com.example...diary.domain.repository.DiaryRepository를 임포트하고 있으며, 본 PR의 일부 파일(LikeService)에서는 대문자 ‘Repository’ 경로를 사용합니다. 파이프라인 실패도 해당 경로 불일치로 보입니다. 패키지명은 소문자 관례를 따르는 것이 바람직합니다.
권장 조치(우선순위 순):
- 생산 코드에서 DiaryRepository의 패키지를 소문자 repository로 정규화하고 전역 임포트를 교정(권장).
- 임시방편으로 테스트 임포트를 대문자 Repository 경로로 맞춤.
테스트 쪽 임시 수정 예(임포트 교체):
import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository; // 임시 방편
// ↑ 최종적으로는 소문자 repository로 통일 권장추가로, 보안 컨텍스트 오염 방지를 위해 각 테스트 종료 시 초기화를 권장합니다:
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}🤖 Prompt for AI Agents
In
src/test/java/com/example/cp_main_be/domain/social/diary/service/DiaryServiceTest.java
around line 9, the test imports ImageUploader but the DiaryRepository import
path casing is inconsistent with production files (some use
...domain.repository.DiaryRepository, others
...domain.Repository.DiaryRepository) causing CI failures; update the test to
import the correct package path (preferably normalize production code to use
lowercase 'repository' and update all imports accordingly, or as a temporary fix
change this test import to match the current capitalized path used elsewhere),
and add an @AfterEach tearDown method that calls
SecurityContextHolder.clearContext() to prevent security context leakage between
tests.
| // 비즈니스 로직 메서드들 | ||
| public void equipItem(ItemMaster item) { | ||
| if (item.getType() == HAT) { | ||
| this.equippedHat = item; | ||
| } else if (item.getType() == CLOTHES) { | ||
| this.equippedClothes = item; | ||
| } else if (item.getType() == SHOES) { | ||
| this.equippedShoes = item; | ||
| } else if (item.getType() == ACCESSORY) { | ||
| this.equippedAccessory = item; | ||
| } else { | ||
| throw new IllegalArgumentException("장착할 수 없는 아이템 타입: " + item.getType()); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
장착 가능 여부 검증 누락 — 불가 아이템 장착 방지
ItemMaster#isEquippable()이 존재합니다. null 및 장착 가능 여부를 선행 검증하세요.
public void equipItem(ItemMaster item) {
- if (item.getType() == HAT) {
+ if (item == null) {
+ throw new IllegalArgumentException("아이템이 null 입니다.");
+ }
+ if (!item.isEquippable()) {
+ throw new IllegalArgumentException("장착할 수 없는 아이템: " + item.getType());
+ }
+ if (item.getType() == HAT) {
this.equippedHat = item;
} else if (item.getType() == CLOTHES) {
this.equippedClothes = item;
} else if (item.getType() == SHOES) {
this.equippedShoes = item;
} else if (item.getType() == ACCESSORY) {
this.equippedAccessory = item;
} else {
throw new IllegalArgumentException("장착할 수 없는 아이템 타입: " + item.getType());
}
}📝 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 void equipItem(ItemMaster item) { | |
| if (item.getType() == HAT) { | |
| this.equippedHat = item; | |
| } else if (item.getType() == CLOTHES) { | |
| this.equippedClothes = item; | |
| } else if (item.getType() == SHOES) { | |
| this.equippedShoes = item; | |
| } else if (item.getType() == ACCESSORY) { | |
| this.equippedAccessory = item; | |
| } else { | |
| throw new IllegalArgumentException("장착할 수 없는 아이템 타입: " + item.getType()); | |
| } | |
| } | |
| // 비즈니스 로직 메서드들 | |
| public void equipItem(ItemMaster item) { | |
| if (item == null) { | |
| throw new IllegalArgumentException("아이템이 null 입니다."); | |
| } | |
| if (!item.isEquippable()) { | |
| throw new IllegalArgumentException("장착할 수 없는 아이템: " + item.getType()); | |
| } | |
| if (item.getType() == HAT) { | |
| this.equippedHat = item; | |
| } else if (item.getType() == CLOTHES) { | |
| this.equippedClothes = item; | |
| } else if (item.getType() == SHOES) { | |
| this.equippedShoes = item; | |
| } else if (item.getType() == ACCESSORY) { | |
| this.equippedAccessory = item; | |
| } else { | |
| throw new IllegalArgumentException("장착할 수 없는 아이템 타입: " + item.getType()); | |
| } | |
| } |
🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java
around lines 52 to 65, the equipItem method currently assumes item is non-null
and equippable; first validate that item is not null and that
item.isEquippable() returns true, and if either check fails throw an
IllegalArgumentException with a clear message; only after these checks inspect
item.getType() to assign to
equippedHat/equippedClothes/equippedShoes/equippedAccessory as before.
| @Query( | ||
| "SELECT im FROM ItemMaster im WHERE im.isActive = true AND im.type IN ('HAT', 'CLOTHES', 'SHOES', 'ACCESSORY') ORDER BY im.sortOrder ASC") | ||
| List<ItemMaster> findEquippableItems(); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Enum을 문자열 리터럴로 비교하는 JPQL은 동작 불안정 — 파라미터/정규 참조로 교체 필요
im.type IN ('HAT', 'CLOTHES', ...)는 Enum을 문자열로 비교하고 있어 매핑 방식(STRING/ORDINAL)과 무관하게 취약합니다. JPQL에선 Enum 상수를 FQCN으로 참조하거나, Enum 리스트 파라미터를 사용하세요.
다음처럼 파라미터 리스트로 교체를 권장합니다:
- @Query(
- "SELECT im FROM ItemMaster im WHERE im.isActive = true AND im.type IN ('HAT', 'CLOTHES', 'SHOES', 'ACCESSORY') ORDER BY im.sortOrder ASC")
- List<ItemMaster> findEquippableItems();
+ @Query("SELECT im FROM ItemMaster im " +
+ "WHERE im.isActive = true AND im.type IN :types " +
+ "ORDER BY im.sortOrder ASC")
+ List<ItemMaster> findEquippableItems(@Param("types") List<ItemType> types);대안(파라미터 없이 고정 집합): FQCN으로 명시
@Query("SELECT im FROM ItemMaster im WHERE im.isActive = true AND " +
"im.type IN (com.example.cp_main_be.domain.avatar.avatar.domain.ItemType.HAT, " +
" com.example.cp_main_be.domain.avatar.avatar.domain.ItemType.CLOTHES, " +
" com.example.cp_main_be.domain.avatar.avatar.domain.ItemType.SHOES, " +
" com.example.cp_main_be.domain.avatar.avatar.domain.ItemType.ACCESSORY) " +
"ORDER BY im.sortOrder ASC")혹은 파생쿼리로 간결화:
List<ItemMaster> findByIsActiveTrueAndTypeInOrderBySortOrderAsc(List<ItemType> types);🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/ItemMasterRepository.java
around lines 26 to 28, the JPQL uses enum names as string literals (im.type IN
('HAT', 'CLOTHES', 'SHOES', 'ACCESSORY')) which is fragile; change it to either
(A) reference the Enum constants by FQCN in the query (e.g. ItemType.HAT etc.)
so JPQL compares Enums properly, or (B) replace the custom @Query with a derived
repository method that accepts a List<ItemType> parameter such as
findByIsActiveTrueAndTypeInOrderBySortOrderAsc(List<ItemType> types) and call it
with the desired enum list; implement one of these fixes and remove the
string-literal IN clause.
| @Query( | ||
| "SELECT im FROM ItemMaster im WHERE im.id IN :ids AND im.isActive = true ORDER BY im.sortOrder ASC") | ||
| List<ItemMaster> findByIdsAndIsActiveTrue(@Param("ids") List<Long> ids); | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
메서드 네이밍 오해 소지 및 빈 컬렉션 IN 절 문제 — 파생 쿼리로 정리
메서드명 findByIdsAndIsActiveTrue는 존재하지 않는 프로퍼티 ids를 암시합니다. 관례상 IdIn이 맞습니다. 또한 IN (:ids)에 빈 리스트가 전달되면 JPA 구현체에 따라 예외가 납니다. 서비스 레이어에서 빈 입력을 단락(return emptyList) 처리하고, 리포지토리는 파생 쿼리로 단순화하세요.
- @Query(
- "SELECT im FROM ItemMaster im WHERE im.id IN :ids AND im.isActive = true ORDER BY im.sortOrder ASC")
- List<ItemMaster> findByIdsAndIsActiveTrue(@Param("ids") List<Long> ids);
+ List<ItemMaster> findByIdInAndIsActiveTrueOrderBySortOrderAsc(List<Long> ids);서비스 단에서의 가드 예:
if (ids == null || ids.isEmpty()) return Collections.emptyList();코드베이스에서 ItemMasterRepository와 ItemMaster 엔티티의 정의 및 필드를 확인하기 위해 다음 스크립트를 실행합니다.
🏁 Script executed:
#!/bin/bash
set -e
# 1. ItemMasterRepository 위치 및 메서드 정의 확인
REPO_FILE=$(fd --type f ItemMasterRepository.java | head -n1)
echo "Repo file: $REPO_FILE"
if [[ -z "$REPO_FILE" ]]; then
echo "ItemMasterRepository.java를 찾을 수 없습니다."
exit 1
fi
echo "=== findBy 메서드 정의 주변 ==="
grep -n -C3 "findBy" "$REPO_FILE"
# 2. ItemMaster 엔티티 필드(id, isActive, sortOrder) 확인
ENTITY_FILE=$(fd --type f ItemMaster.java | head -n1)
echo "Entity file: $ENTITY_FILE"
if [[ -z "$ENTITY_FILE" ]]; then
echo "ItemMaster.java를 찾을 수 없습니다."
exit 1
fi
echo "=== 엔티티 클래스 정의 및 주요 필드 ==="
grep -n "class ItemMaster" "$ENTITY_FILE"
grep -n "private .*id" "$ENTITY_FILE"
grep -n "isActive" "$ENTITY_FILE"
grep -n "sortOrder" "$ENTITY_FILE"Length of output: 3232
메서드 네이밍 및 빈 리스트 IN 절 처리 개선 필요
- 수정 위치:
•src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/ItemMasterRepository.java54–57라인
현재 메서드명 findByIdsAndIsActiveTrue는 엔티티에 ids라는 프로퍼티가 없어 메소드 이름만 보고는 동작 방식을 유추하기 어려우며, @Query 내부의 IN (:ids)에 빈 리스트를 전달할 경우 Hibernate가 “parameter list must not be empty” 예외를 던질 수 있습니다. 이를 방지하고 가독성을 높이기 위해 파생 쿼리(Derived Query) 방식으로 메서드를 리네이밍하고, 서비스 레이어에서 빈 입력에 대해 빈 결과를 반환하도록 처리하세요.
@@ src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/ItemMasterRepository.java:54-57
- @Query(
- "SELECT im FROM ItemMaster im WHERE im.id IN :ids AND im.isActive = true ORDER BY im.sortOrder ASC")
- List<ItemMaster> findByIdsAndIsActiveTrue(@Param("ids") List<Long> ids);
+ // 파생 쿼리로 대체하여 메서드명과 실제 조회 조건 일치
+ List<ItemMaster> findByIdInAndIsActiveTrueOrderBySortOrderAsc(List<Long> ids);서비스 레이어에서 빈 입력 처리 예시:
public List<ItemMaster> getItemsByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
return itemMasterRepository.findByIdInAndIsActiveTrueOrderBySortOrderAsc(ids);
}🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/ItemMasterRepository.java
around lines 54-57, the current custom method name findByIdsAndIsActiveTrue is
misleading (there is no ids property) and the @Query IN (:ids) will throw if
passed an empty list; change the repository to use a derived query method name
like findByIdInAndIsActiveTrueOrderBySortOrderAsc (removing or replacing the
custom JPQL), and in the service layer ensure any null/empty ids input returns
Collections.emptyList() before calling the repository so you never pass an empty
collection to an IN parameter.
| @Query( | ||
| "SELECT im FROM ItemMaster im WHERE im.type = :type AND im.isActive = true " | ||
| + "ORDER BY im.price ASC, im.sortOrder ASC") | ||
| Optional<ItemMaster> findCheapestByType(@Param("type") ItemType type); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
JPQL에 LIMIT 부재 — 단일 결과 Optional로 매핑 시 NonUniqueResultException 위험
현재 @query는 정렬만 지정하고 제한이 없어 다건이 반환됩니다. Spring Data가 자동으로 1건 제한을 걸지 않으므로 단건 매핑 시 예외가 발생할 수 있습니다. 파생 메서드의 findFirst...OrderBy... 패턴으로 교체하세요.
- @Query(
- "SELECT im FROM ItemMaster im WHERE im.type = :type AND im.isActive = true "
- + "ORDER BY im.price ASC, im.sortOrder ASC")
- Optional<ItemMaster> findCheapestByType(@Param("type") ItemType type);
+ Optional<ItemMaster> findFirstByTypeAndIsActiveTrueOrderByPriceAscSortOrderAsc(ItemType type);📝 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.
| @Query( | |
| "SELECT im FROM ItemMaster im WHERE im.type = :type AND im.isActive = true " | |
| + "ORDER BY im.price ASC, im.sortOrder ASC") | |
| Optional<ItemMaster> findCheapestByType(@Param("type") ItemType type); | |
| // Replaces the custom @Query to ensure only one result is fetched | |
| Optional<ItemMaster> findFirstByTypeAndIsActiveTrueOrderByPriceAscSortOrderAsc(ItemType type); |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/repository/ItemMasterRepository.java
around lines 65 to 68, the custom JPQL query sorts results but lacks a LIMIT,
which risks NonUniqueResultException when mapping to Optional<ItemMaster>;
replace the custom @Query method with a Spring Data derived query that enforces
a single result, e.g. remove the @Query and rename the method to use the
findFirst...OrderBy... pattern (for example
findFirstByTypeAndIsActiveTrueOrderByPriceAscSortOrderAsc) so Spring Data
returns only the first matching entity wrapped in Optional.
| // [수정] 새로운 아바타 생성 로직 구현 | ||
| public void createAvatar(Long userId, Long masterId, String nickname) { | ||
| User user = | ||
| userRepository | ||
| .findById(userId) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); | ||
|
|
||
| AvatarMaster master = | ||
| avatarMasterRepository | ||
| .findById(masterId) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); | ||
|
|
||
| // TODO: 사용자가 이미 해당 종류의 아바타를 가지고 있는지 확인하는 로직 추가 가능 | ||
|
|
||
| Avatar newAvatar = Avatar.builder().user(user).avatarMaster(master).nickname(nickname).build(); | ||
|
|
||
| avatarRepository.save(newAvatar); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
아바타 생성 시 입력값 검증 및 중복 보유 방지 로직 보강 권장
nickname의 null/blank 검증이 없습니다.- 동일 masterId 아바타를 이미 보유 중인지 체크 TODO가 남아 있습니다.
public void createAvatar(Long userId, Long masterId, String nickname) {
+ if (nickname == null || nickname.isBlank()) {
+ throw new IllegalArgumentException("닉네임은 필수입니다.");
+ }
@@
- // TODO: 사용자가 이미 해당 종류의 아바타를 가지고 있는지 확인하는 로직 추가 가능
+ // TODO: 사용자가 이미 해당 종류의 아바타를 보유 중인지 확인 (unique constraint 수준에서 한 번 더 방어)
+ // if (avatarRepository.existsByUserAndAvatarMaster(user, master)) { throw ... }중복 보유 체크용 Repository 메서드 존재 여부 확인:
🏁 Script executed:
#!/bin/bash
rg -nP 'AvatarRepository' -C3 src/main/java
rg -nP 'existsByUserAndAvatarMaster' src/main/javaLength of output: 5162
아바타 생성 로직 보강 필요: 입력값 검증 및 중복 보유 체크 구현
현재 AvatarService#createAvatar 메서드에는
nicknamenull/blank 검증이 빠져 있고,- 중복 보유 여부를 판단할
existsByUserAndAvatarMaster메서드가AvatarRepository에 정의되어 있지 않습니다.
아래 사항을 반드시 반영해주세요.
• 입력값 검증
nickname == null || nickname.isBlank()검사 후IllegalArgumentException또는 적절한 커스텀 예외를 던지도록 추가
• 중복 보유 체크용 Repository 메서드 선언
AvatarRepository인터페이스에 다음과 같은 메서드 선언boolean existsByUserAndAvatarMaster(User user, AvatarMaster master);
- (또는 JPQL/
@Query로 동일 기능 구현)
• 서비스 계층 로직 수정 (src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java)
public void createAvatar(Long userId, Long masterId, String nickname) {
+ if (nickname == null || nickname.isBlank()) {
+ throw new IllegalArgumentException("닉네임은 필수입니다.");
+ }
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
AvatarMaster master =
avatarMasterRepository
.findById(masterId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
- // TODO: 사용자가 이미 해당 종류의 아바타를 가지고 있는지 확인하는 로직 추가 가능
+ // 중복 보유 체크
+ if (avatarRepository.existsByUserAndAvatarMaster(user, master)) {
+ throw new CustomApiException(ErrorCode.ALREADY_EXISTS);
+ }
Avatar newAvatar = Avatar.builder()
+ .user(user)
+ .avatarMaster(master)
.nickname(nickname)
.build();
avatarRepository.save(newAvatar);
}🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/avatar/avatar/service/AvatarService.java
around lines 27 to 44, the createAvatar method lacks nickname validation and a
duplicate-avatar ownership check; add a null/blank check for nickname and throw
IllegalArgumentException or an appropriate custom exception when invalid, update
AvatarRepository to declare boolean existsByUserAndAvatarMaster(User user,
AvatarMaster master) (or equivalent @Query), and modify the service to call that
repository method before creating a new Avatar to throw a suitable exception if
the user already owns that AvatarMaster; keep existing user/master not-found
checks intact.
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (11)
src/main/java/com/example/cp_main_be/domain/admin/presentation/AdminController.java (2)
53-56: PathVariable 이름 불일치로 인한 바인딩 실패 가능성 (400/500)
@PutMapping("/missions/daily/{dailyMissionMasterId}")인데 파라미터는@PathVariable Long id로 되어 있어 런타임 바인딩이 실패할 수 있습니다. 명시적으로 키를 일치시키세요.- public ResponseEntity<ApiResponse<AdminResponseDTO.DailyMissionMastersResDTO>> updateDailyMission( - @RequestBody AdminRequestDTO.UpdateMissionRequestDTO requestDTO, @PathVariable Long id) { + public ResponseEntity<ApiResponse<AdminResponseDTO.DailyMissionMastersResDTO>> updateDailyMission( + @RequestBody AdminRequestDTO.UpdateMissionRequestDTO requestDTO, + @PathVariable("dailyMissionMasterId") Long id) {
91-96: 요청 DTO 바인딩 누락: @RequestBody 추가 필요PUT/POST JSON 본문을 받을 DTO에
@RequestBody가 누락되어 있습니다. 기본@ModelAttribute바인딩으로 처리되면 FE가 JSON으로 보내는 경우 파싱 실패합니다.권장 수정:
@PutMapping("/users/{userId}/status") @Operation(summary = "사용자 상태 변경(ban 등) API") public ResponseEntity<ApiResponse<User>> changeUserStatus( @PathVariable(name = "userId") Long userId, - AdminRequestDTO.ChangeUserStatusRequestDTO request) { + @RequestBody AdminRequestDTO.ChangeUserStatusRequestDTO request) {@PostMapping("/quiz") @Operation(summary = "퀴즈 선지 등록 API") public ResponseEntity<ApiResponse<QuizOptions>> createQuizOption( - AdminRequestDTO.CreateQuizRequestDTO requestDTO) { + @RequestBody AdminRequestDTO.CreateQuizRequestDTO requestDTO) {@PostMapping("/plants") @Operation(summary = "새 식물 등록 API") public ResponseEntity<ApiResponse<AdminResponseDTO.PlantMasterResDTO>> createNewPlant( - AdminRequestDTO.CreatePlantMasterRequestDTO requestDTO) { + @RequestBody AdminRequestDTO.CreatePlantMasterRequestDTO requestDTO) {@PutMapping("/plants/{plantId}") @Operation(summary = "식물 정보 수정 API") public ResponseEntity<ApiResponse<AdminResponseDTO.PlantMasterResDTO>> updatePlantMasters( @PathVariable(name = "plantId") Long plantId, - AdminRequestDTO.UpdatePlantMasterRequestDTO requestDTO) { + @RequestBody AdminRequestDTO.UpdatePlantMasterRequestDTO requestDTO) {Also applies to: 100-103, 109-113, 117-123
src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java (1)
84-86: NPE 위험: lastEventId null 처리 필요
hasLostData가lastEventId가 null일 때 NPE를 유발할 수 있습니다. 기본값/널 체크 추가를 권장합니다.- private boolean hasLostData(String lastEventId) { - return !lastEventId.isEmpty(); - } + private boolean hasLostData(String lastEventId) { + return lastEventId != null && !lastEventId.isEmpty(); + }src/test/java/com/example/cp_main_be/domain/user/service/UserServiceTest.java (2)
84-95: 검증 메서드 불일치: getUsername() → getNickname()로 수정 필요도메인 전환에 따라 접근자도
getNickname()을 사용해야 합니다. 현재 assertion은 항상 실패합니다.- Assertions.assertEquals("test", foundUser.getUsername()); + Assertions.assertEquals("test", foundUser.getNickname());
149-160: 검증 메서드 불일치: getUsername() → getNickname()로 수정 필요닉네임 업데이트 검증 또한 접근자를 교체해야 합니다.
- Assertions.assertEquals(newNickname, user.getUsername()); + Assertions.assertEquals(newNickname, user.getNickname());src/main/java/com/example/cp_main_be/domain/member/user/service/CustomUserDetailsService.java (1)
19-27: UUID 파싱 실패 시 IllegalArgumentException 누수 — UsernameNotFoundException으로 매핑 필요
UUID.fromString(uuid)가 잘못된 입력에서IllegalArgumentException을 던지므로, 보안 필터 체인 상 500이 날 수 있습니다.UsernameNotFoundException으로 일관 매핑해 401/403 흐름으로 처리되도록 보강해 주세요. 또한, LAZY 연관을 접근할 수 있는 시점 보장을 위해@Transactional(readOnly = true)를 권장합니다.수정 예시:
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; ... @Override - public UserDetails loadUserByUsername(String uuid) throws UsernameNotFoundException { - User user = - userRepository - .findByUuid(UUID.fromString(uuid)) - .orElseThrow(() -> new UsernameNotFoundException("User not found with uuid: " + uuid)); - - // DB에서 조회한 '우리 User 엔티티'를 그대로 반환합니다. - return user; - } + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String uuid) throws UsernameNotFoundException { + final UUID parsed; + try { + parsed = UUID.fromString(uuid); + } catch (IllegalArgumentException e) { + throw new UsernameNotFoundException("Invalid uuid: " + uuid); + } + return userRepository + .findByUuid(parsed) + .orElseThrow(() -> new UsernameNotFoundException("User not found with uuid: " + uuid)); + }추가 메모:
- 도메인
User를 그대로 Principal로 두는 접근은 간단하지만, 보안 컨텍스트에 불필요한 필드가 노출될 수 있습니다. 최소 정보 DTO(UserPrincipal)로의 래핑도 고려해 보세요(선택).src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
24-28: Optional.get() 직접 호출로 인한 NSEE 가능성 — 일관된 예외 처리로 정비 권장다른 메서드들이
orElseThrow(UserNotFoundException)을 사용하는 것과 달리addExperience만get()을 사용합니다. 동일 예외 정책으로 맞추면 런타임 500 리스크를 줄일 수 있습니다.적용 제안:
public void addExperience(Long actorId, int points) { - User user = userRepository.findById(actorId).get(); + User user = + userRepository + .findById(actorId) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + actorId)); user.addExperience(points); levelService.checkLevelUp(user); }src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java (2)
119-119: 업데이트 케이스 단언도 동일 이슈수정 시나리오에서도
getUsername()을 사용 중입니다. 닉네임 스펙에 맞춰 갱신해 주세요.- Assertions.assertEquals(writer.getUsername(), updatedComment.getWriter()); + Assertions.assertEquals(writer.getNickname(), updatedComment.getWriter());
29-33: CommentServiceTest에 DiaryRepository import 누락
src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java상단에DiaryRepositoryimport 구문이 빠져 있어
@Mock private DiaryRepository diaryRepository;선언에서 컴파일 에러가 발생합니다.아래 예시처럼 테스트 파일 최상단 import 목록에 추가해주세요:
--- a/src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java +++ b/src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java @@ import com.example.cp_main_be.domain.social.comment.domain.repository.CommentRepository; import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository; +import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository; import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.extension.ExtendWith;필요 시 리팩터링된 실제 패키지 경로(
domain.Repository또는domain.repository등)를 확인 후 경로를 조정하세요.src/test/java/com/example/cp_main_be/domain/social/like/service/LikeServiceTest.java (1)
33-34: LikeServiceTest에 DiaryRepository import 누락으로 인한 컴파일 오류 해결 필요확인 결과,
DiaryRepository는
com.example.cp_main_be.domain.social.diary.domain.Repository패키지에 위치합니다.
LikeServiceTest에 해당 import 문이 빠져 있어 컴파일 에러가 발생합니다.수정 사항:
- 파일:
src/test/java/com/example/cp_main_be/domain/social/like/service/LikeServiceTest.java- 위치: 기존 import 구문 바로 아래
추가할 코드:
import org.mockito.junit.jupiter.MockitoExtension; +import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository; @ExtendWith(MockitoExtension.class) class LikeServiceTest { @Mock private LikeRepository likeRepository; @Mock private UserRepository userRepository; @Mock private DiaryRepository diaryRepository; @Mock private NotificationService notificationService; …src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java (1)
88-122: 닉네임 중복 처리 및 경쟁 조건 완화 필요가입 시 닉네임은 사용자 가시 ID 성격이 강합니다. 현재는 중복 검증/제약이 없어 경쟁 조건 시 동일 닉네임이 다수 생성될 수 있습니다. 사전 존재 확인과 DB 유니크 위반 처리(트랜잭션 내)를 추가하세요.
사전 체크 + 저장 시 예외 변환 예시:
public AnonymousRegistrationResponse registerNewUser( RegistrationRequest request, String deviceId) { // 2. 랜덤 닉네임 생성 로직 삭제, 요청받은 닉네임 사용 UUID newUuid = UUID.randomUUID(); String nickname = request.getNickname(); + // 사전 중복 검사 (소프트 가드) + if (userRepository.existsByNickname(nickname)) { + throw new CustomApiException(ErrorCode.DUPLICATE_NICKNAME); // 실제 코드에 맞춰 변경 + } User newUser = User.builder() .uuid(newUuid) .nickname(nickname) // 사용자가 입력한 닉네임으로 설정 .build(); - userRepository.save(newUser); + try { + userRepository.save(newUser); + } catch (DataIntegrityViolationException e) { + // 레이스 시 DB 유니크 위반을 도메인 예외로 변환 + throw new CustomApiException(ErrorCode.DUPLICATE_NICKNAME); + }추가 import(파일 상단):
import org.springframework.dao.DataIntegrityViolationException;검증 스크립트(유니크 제약 존재 여부 및 DTO 유효성 확인):
#!/bin/bash # 닉네임 유니크 제약 존재 여부 rg -nP --type=java '@Column\([^)]*nickname[^)]*unique\s*=\s*true' -C2 || true fd -t f -a -g '*migration*' 2>/dev/null | xargs -I{} rg -nP 'unique.*nickname' '{}' || true # RegistrationRequest 검증 어노테이션 점검(길이/금칙어/공백 등) rg -nP --type=java 'class\s+RegistrationRequest\b' -n -C5 rg -nP --type=java '@(NotBlank|Size|Pattern).*nickname' -n -C1
♻️ Duplicate comments (6)
src/main/java/com/example/cp_main_be/global/jwt/JwtAuthenticationFilter.java (1)
41-41: 원문 토큰 로그 금지 (재지적)이전에 지적된 토큰 평문 로그가 여전히 남아 있습니다. 보안 사고로 직결될 수 있으니 토큰 값 자체를 절대 로그로 남기지 마세요. 존재 여부만 DEBUG 이하 레벨로 기록하세요.
- logger.info("Token found: {}", token); + // 원문 토큰 로그 금지: 존재만 디버그 레벨로 기록 + logger.debug("Token received.");src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java (1)
7-7: 비밀번호 해시 응답 누출 방지(@JsonIgnore 추가).엔티티가 직렬화 경로에 실릴 경우
passwordHash가 외부로 노출될 수 있습니다. Jackson에서 제외하세요.import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonIgnore; @@ - private String passwordHash; + @JsonIgnore + private String passwordHash;Also applies to: 44-45
src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (4)
52-56: [필수] 하루 조회 범위: 반개구간([start, nextStart))으로 변경.
23:59:59포함 방식은 경계 손실/타임존 이슈로 누락 가능성이 있습니다.endExclusive = (내일 00:00)을 사용하고, Repository 쿼리는< :endExclusive로 맞춰주세요.- LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); - LocalDateTime endOfDay = LocalDate.now().atTime(23, 59, 59); + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endExclusive = LocalDate.now().plusDays(1).atStartOfDay(); @@ - List<UserDailyMission> todayMissions = - userDailyMissionRepository.findTodayMissionsWithMasterByUser(user, startOfDay, endOfDay); + List<UserDailyMission> todayMissions = + userDailyMissionRepository.findTodayMissionsWithMasterByUser(user, startOfDay, endExclusive);Repository JPQL도 다음과 같이 확인/수정 필요:
- 조건:
createdAt >= :startDate AND createdAt < :endDate(endDate는 endExclusive 의미)검증 스크립트:
#!/bin/bash # 서비스/레포 시그니처 및 JPQL 경계 조건 확인 rg -nP "findTodayMissionsWithMasterByUser" -C3 src/main/java rg -nP "createdAt.*(BETWEEN|<|<=|>=)" -C2 src/main/java | sed -n '1,200p'
47-47: [필수] NPE 위험: avatar, gardenBackground에 대한 null 가드 추가.
garden.getAvatar()/garden.getGardenBackground()가 null이면 NPE 발생합니다. 가드를 추가하고, 배경 URL은 null-safe로 처리하세요.- Avatar avatar = garden.getAvatar(); + Avatar avatar = garden.getAvatar(); + if (avatar == null) { + throw new CustomApiException(ErrorCode.NOT_FOUND); + } @@ - HomeResponseDto.GardenInfo gardenInfo = + String backgroundImageUrl = + garden.getGardenBackground() != null ? garden.getGardenBackground().getImageUrl() : null; + + HomeResponseDto.GardenInfo gardenInfo = HomeResponseDto.GardenInfo.builder() .waterCount(garden.getWaterCount()) - .maxWaterCount(100) + .maxWaterCount(MAX_WATER_COUNT) .sunlightCount(garden.getSunlightCount()) - .maxSunlightCount(100) - .backgroundImageUrl(garden.getGardenBackground().getImageUrl()) + .maxSunlightCount(MAX_SUNLIGHT_COUNT) + .backgroundImageUrl(backgroundImageUrl) .avatar(avatarInfo) .build();Also applies to: 115-117
74-82: 컴파일 오류 가능성:item.getType().getDisplayName()존재 여부.이전 리뷰에서도 지적된 부분입니다.
ItemType#getDisplayName()이 없다면 컴파일 실패합니다. 도메인에 맞게item.getTypeDisplayName()(또는item.getMaster().getTypeDisplayName())을 사용하세요.- .itemType(item.getType().getDisplayName()) + .itemType(item.getTypeDisplayName())확인 스크립트:
#!/bin/bash # ItemType 또는 관련 도메인에 getDisplayName 존재 여부 확인 rg -nP 'enum\s+ItemType\b' -C2 src/main/java rg -nP '\bgetDisplayName\s*\(' -C2 src/main/java rg -nP '\bgetTypeDisplayName\s*\(' -C2 src/main/java
145-158: [필수] 주간 집계는 완료일(completedAt) 기준으로 계산.활동 여부는 생성시점이 아니라 완료시점이 더 정확합니다. 레포 시그니처와 필터/매핑을
completedAt기준으로 바꿔주세요.- List<UserDailyMission> recentMissions = - userDailyMissionRepository.findByUserIdAndCreatedAtAfter(userId, sevenDaysAgo); + List<UserDailyMission> recentMissions = + userDailyMissionRepository.findByUserIdAndCompletedAtAfter(userId, sevenDaysAgo); @@ - recentMissions.stream() - .filter(UserDailyMission::isCompleted) - .map(mission -> mission.getCreatedAt().toLocalDate()) + recentMissions.stream() + .filter(UserDailyMission::isCompleted) + .filter(m -> m.getCompletedAt() != null) + .map(m -> m.getCompletedAt().toLocalDate()) .collect(Collectors.toSet());레포지토리 메서드 존재 여부 확인:
#!/bin/bash rg -nP 'findByUserIdAnd(CompletedAt|CreatedAt)After' -C2 src/main/java존재하지 않으면 아래 시그니처/JPQL을 추가해 주세요.
List<UserDailyMission> findByUserIdAndCompletedAtAfter(Long userId, LocalDateTime cutoff);
🧹 Nitpick comments (32)
src/main/java/com/example/cp_main_be/domain/social/diaryimage/presentation/DiaryImageController.java (7)
9-9: 와일드카드 import 지양 권장팀의 코딩 규칙에 따라 다르지만, 일반적으로 Checkstyle/Spotless에서
*import를 금지합니다. 명시적 import로 전환을 제안합니다.-import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController;
20-30: 파일 업로드 엔드포인트: consumes 지정 및 파라미터 명확화 제안멀티파트 업로드를 명시적으로 선언하면 클라이언트/문서 모두에 이점이 있습니다. 또한 Swagger에서 파일 파라미터를 binary로 명세하면 스키마가 명확해집니다.
+import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestPart; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; - @PostMapping("{diaryId}/images") + @PostMapping(value = "{diaryId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity<ApiResponse<Void>> saveDiaryImage( - @PathVariable Long diaryId, @RequestParam("file") MultipartFile file) { + @PathVariable Long diaryId, + @Parameter(description = "업로드할 이미지 파일", required = true, + schema = @Schema(type = "string", format = "binary")) + @RequestPart("file") MultipartFile file) {추가 고려사항(선택):
- 빈 파일 업로드 방지:
file == null || file.isEmpty()사전 체크 후 400 반환 또는 도메인 예외 발생.- 생성 응답: 이미지 리소스가 생성되는 행위라면 201 Created + Location 헤더/이미지 ID 반환을 고려해 주세요(서비스 시그니처 조정 필요).
32-38: 삭제 응답은 204 No Content가 더 REST-친화적현재 200 OK + null 바디를 반환합니다. 표준적으로는 204가 적합합니다. 팀의 응답 래퍼 일관성을 해치지 않는 선에서 다음 중 하나를 고려해 주세요.
옵션 A: 래퍼 미사용(표준 204)
- public ResponseEntity<ApiResponse<Void>> deleteDiaryImage( + public ResponseEntity<Void> deleteDiaryImage( @PathVariable Long diaryId, @PathVariable Long imageId) { diaryService.deleteDiaryImage(diaryId, imageId); - return ResponseEntity.ok(ApiResponse.success(null)); + return ResponseEntity.noContent().build(); }옵션 B: 래퍼 유지 + 상태코드만 204(단, 204에서는 바디가 없어야 하므로 래퍼 사용이 곤란)
- 래퍼를 고수한다면 현행 200 유지도 수용 가능합니다(팀 규칙 우선).
14-14: 경로 표기 일관성(Nit): 선행 슬래시 사용 권장동작에는 차이가 없지만, 다수 코드베이스에서
@RequestMapping("/api/v1/diaries")로 표준화합니다.-@RequestMapping("api/v1/diaries") +@RequestMapping("/api/v1/diaries")
18-18: 권한/소유자 검증이 컨트롤러 또는 서비스 레이어에 존재하는지 확인파일 등록/삭제는 민감 동작입니다. 메서드 시큐리티(예:
@PreAuthorize) 또는 서비스 레벨의 소유자 검증이 있는지 확인해 주세요. 없다면 추가를 권장합니다.예시:
@PreAuthorize("@diaryPermissionEvaluator.isOwner(authentication, #diaryId)") @PostMapping(value = "{diaryId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)필요 시 PermissionEvaluator/메서드 시큐리티 설정까지 포함해 드릴 수 있습니다.
20-30: OpenAPI 응답 스펙 보강 제안성공/에러 응답을 명시하면 클라이언트/문서 일관성이 좋아집니다(예: 201/400/401/403/404/415). 아래와 같이
@ApiResponses추가를 고려해 주세요.@Operation(summary = "일기 이미지 등록", description = "일기 이미지를 등록합니다") @ApiResponses({ @ApiResponse(responseCode = "201", description = "생성됨"), @ApiResponse(responseCode = "400", description = "잘못된 요청"), @ApiResponse(responseCode = "401", description = "인증 필요"), @ApiResponse(responseCode = "403", description = "권한 없음"), @ApiResponse(responseCode = "404", description = "일기 없음"), @ApiResponse(responseCode = "415", description = "지원하지 않는 미디어 타입") })필요 시 삭제 API에도 동일 패턴을 적용해 주세요.
20-38: WebMvc 슬라이스 테스트 추가 제안간단한 컨트롤러 레벨 테스트로 회귀를 방지하세요:
- 업로드: multipart/form-data, 최대 허용 크기 초과/빈 파일 케이스 400 검증
- 삭제: 권한 없는 사용자 403, 존재하지 않는 리소스 404, 성공 204/200 검증
원하시면
@WebMvcTest(DiaryImageController.class)기반 테스트 템플릿을 생성해 드립니다.src/main/java/com/example/cp_main_be/domain/admin/presentation/AdminController.java (2)
81-81: 닉네임 전환 반영 OK. 단, DTO 필드명(username)과 의미 불일치 가능성 확인 필요
.username(user.getNickname())는 동작은 하지만, API/스키마에서 username을 nickname 의미로 사용하게 되어 혼란을 줄 수 있습니다. 가능하면AdminResponseDTO.UserResDTO의 필드명을nickname으로 교정하고, 아래처럼 빌더 호출도 일치시키는 것을 권장합니다.적용 예(해당 DTO에
nickname필드가 존재할 때에 한함):- .username(user.getNickname()) + .nickname(user.getNickname())다음 영향도 확인을 권장합니다:
- AdminResponseDTO.UserResDTO의 필드/스키마 명세
- Swagger 문서(요약/설명) 및 FE 의존 코드
91-96: 엔티티 직접 반환 대신 DTO 사용 권장
changeUserStatus가ApiResponse<User>(엔티티)를 직접 반환합니다. 엔티티 노출은 필드/지연로딩/보안(예: 내부 식별자) 이슈를 야기할 수 있으니 전용 Response DTO를 권장합니다.원하시면 간단한
ChangeUserStatusResponseDTO스케치와 매퍼 코드 제안드리겠습니다.src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java (2)
10-17: 닉네임 전환과 DTO 필드(username) 불일치프로젝트 전반에서 nickname을 채택하는 흐름과 달리, 본 Response의 최상위 필드가
username으로 유지되어 있어 혼선을 유발할 수 있습니다. 가급적 필드명을nickname으로 교정하고 API 문서에 반영하세요. 호환성 유지가 필요하면 일시적으로@JsonAlias("username")를 활용하는 전략도 가능합니다.예시(단계적 전환):
- private String username; + private String nickname;필요 시(선택):
@JsonAlias("username")로 입력 호환 유지// import com.fasterxml.jackson.annotation.JsonAlias; @JsonAlias("username") private String nickname;
26-32: MyInfoResponseDTO의 필드명도 nickname으로 일관화 권장별도 DTO에서도
username을 사용 중입니다. 상위 정책과 맞춰nickname으로 통일하세요.public static class MyInfoResponseDTO { private Long id; - private String username; + private String nickname; private UUID uuid; }src/main/java/com/example/cp_main_be/domain/social/comment/dto/response/CommentResponse.java (1)
16-16: 네이밍 정밀도 개선 제안: createAt → createdAt영문 시제에 맞춰
createAt보다는createdAt이 더 자연스럽고 일반적입니다. Setter 호출도 함께 맞춰 주세요. 외부 API 계약 영향이 있다면 단계적 전환을 권장합니다.- private LocalDateTime createAt; + private LocalDateTime createdAt;- response.setCreateAt(comment.getCreatedAt()); + response.setCreatedAt(comment.getCreatedAt());Also applies to: 25-25
src/main/java/com/example/cp_main_be/global/dto/AuthorResponse.java (1)
14-14: DTO 필드명(username)과 값(nickname)의 의미 불일치 — API 명세 합의 필요현재 필드명은
username인데, 값은user.getNickname()입니다. 외부 공개 DTO라면 응답 스키마의 의미가 혼란스러울 수 있습니다. 두 가지 방향 중 하나로 명확히 하는 걸 권장합니다.
- 옵션 A(권장): 필드명을
nickname으로 변경해 응답 키도 일치시키기.- 옵션 B(하위 호환 유지 필요 시): 필드는 유지하되, 명세서/스웨거에 “username은 닉네임을 의미”한다고 명시. 가능하면 버전 분리로 점진 이행.
추가로,
user가 null인 경로가 없다면 그대로 두되, 방어 코드가 필요하면Objects.requireNonNull(user)사용을 고려하세요.다음은 옵션 A 예시입니다(라인 14 및 클래스 내 필드 선언 변경):
- this.username = user.getNickname(); + this.nickname = user.getNickname();옵션 A 전체 예시(파일 외 범위 수정 포함):
@Getter public class AuthorResponse { private Long userId; private String nickname; private String profileImageUrl; public AuthorResponse(User user) { this.userId = user.getId(); this.nickname = user.getNickname(); this.profileImageUrl = user.getProfileImageUrl(); } }이 변경이 API 소비자에 영향을 주는지 확인 부탁드립니다. 필요 시, 제가 스웨거 스키마/문서를 함께 업데이트하겠습니다.
src/test/java/com/example/cp_main_be/domain/social/follow/service/FollowServiceTest.java (1)
38-40: 닉네임 전환 반영 및 테스트 보완 제안: LGTM
- 예외 검증을 구체화하세요
· 이미 팔로우된 상태나 언팔로우되지 않은 상태에서 발생하는 예외를AlreadyFollowingException,FollowNotFoundException등으로 분리하고, 테스트에서도 해당 예외 타입으로 검증하도록 수정하면 의도가 명확해집니다.- 알림 발송 사이드 이펙트 검증 추가
· 팔로우 성공 시NotificationService.send(User receiver, User sender, NotificationType notificationType, String url)메서드 호출이 예상되므로,FollowServiceTest에서 모킹한notificationService에 대해 아래와 같이 호출 여부를verify(...)로 검증하세요.// Given User follower = User.builder().id(followerId).nickname("follower").build(); User following = User.builder().id(followingId).nickname("following").build(); // When followService.follow(followerId, followingId); // Then verify(notificationService).send( eq(following), eq(follower), eq(NotificationType.FOLLOW), anyString() );NotificationService 공개 메서드 (참고):
- send(User receiver, User sender, NotificationType notificationType, String url) — src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java:60
- subscribe, getNotifications, readNotification 등 총 9개 메서드 확인됨
수정 대상 위치:
- src/test/java/com/example/cp_main_be/domain/social/follow/service/FollowServiceTest.java
• Lines 38–40, 58–60, 77–79, 99–101, 118–121, 145–148src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
84-103: getCurrentUser() 메서드 방어 코드 보강: 인증 정보 및 anonymousUser 처리 추가getCurrentUser()가 SecurityContext에 인증 정보가 없거나, AnonymousAuthenticationToken(“anonymousUser”)인 경우 NPE 또는 잘못된 사용자 조회가 발생할 수 있으므로, 아래처럼 최소한의 방어 코드를 추가하시길 권장드립니다.
• 대상 파일
- src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java
• 적용 제안 (최소 변경)
public User getCurrentUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + // 1) 인증 객체가 없거나 인증되지 않은 상태면 예외 + if (authentication == null || !authentication.isAuthenticated()) { + throw new IllegalArgumentException("인증 정보를 찾을 수 없습니다."); + } + Object principal = authentication.getPrincipal(); + // 2) principal이 null이거나 anonymousUser(String)인 경우 예외 + if (principal == null || "anonymousUser".equals(principal)) { + throw new IllegalArgumentException("인증 정보를 찾을 수 없습니다."); + } @@ - // Principal이 User 객체인 경우 (현재 JWT 필터에서 이렇게 저장함) - if (principal instanceof User) { - return (User) principal; - } + // 3) User 객체인 경우 바로 반환 + if (principal instanceof User) { + return (User) principal; + } @@ - // Principal이 String(UUID)인 경우 (백업 처리) - if (principal instanceof String) { + // 4) String(UUID)인 경우 백업 조회 + if (principal instanceof String) { String uuidString = (String) principal; UUID userUuid = UUID.fromString(uuidString); return userRepository .findByUuid(userUuid) .orElseThrow(() -> new IllegalArgumentException("현재 로그인한 사용자를 찾을 수 없습니다.")); } @@ - throw new IllegalArgumentException("인증 정보를 찾을 수 없습니다."); + // 그 외 타입은 처리 불가 + throw new IllegalArgumentException("유효하지 않은 인증 정보입니다."); }
authentication.isAuthenticated()만으로 anonymousUser를 걸러내지 못하므로, principal이"anonymousUser"인 경우도 명시적으로 처리했습니다.- principal 타입이 현재는
User또는String(UUID)로 한정되지만, 향후UserDetails만 넘어오는 케이스가 생기면 별도 분기를 추가해((UserDetails) principal).getUsername()으로 사용자 조회 로직을 확장하시길 권장드립니다.src/main/java/com/example/cp_main_be/domain/garden/garden/presentation/GardenController.java (1)
53-53: AuthenticationPrincipal 사용으로 일관성 및 의존성 축소 제안동일 컨트롤러 내 다른 엔드포인트(
waterGarden,sunlightGarden)는@AuthenticationPrincipal Long userId를 사용합니다.unlockGarden도 동일하게 맞추면UserService의존과 보안 컨텍스트 접근을 줄이고 일관성이 좋아집니다.적용 제안:
@PutMapping("/slots/unlock") - public ResponseEntity<ApiResponse<Void>> unlockGarden() { - User currentUser = userService.getCurrentUser(); - gardenService.unlockNewGardenSlot(currentUser.getId()); + public ResponseEntity<ApiResponse<Void>> unlockGarden(@AuthenticationPrincipal Long userId) { + gardenService.unlockNewGardenSlot(userId); return ResponseEntity.ok(ApiResponse.success(null)); }추가로, 이 변경이 적용되면
Userimport와UserService주입 필드는 제거 가능 여부를 검토해 주세요.src/test/java/com/example/cp_main_be/domain/social/like/service/LikeServiceTest.java (1)
160-161: targetType 대소문자 혼재 — 상수/Enum로 표준화 권장
"DIARY"vs"diary","AVATAR_POST"vs"avatar_post"가 혼재합니다. 서비스가 내부적으로 소문자 기준으로 처리하더라도 테스트/프로덕션 모두 동일 상수/Enum을 사용하면 회귀를 줄일 수 있습니다.옵션:
- Enum 도입(예:
TargetType.DIARY,TargetType.AVATAR_POST) 후 테스트는TargetType.DIARY.name()또는toLowerCase()일괄 사용.- 최소한 테스트 내 상수화:
- String targetType = "DIARY"; + final String TARGET_TYPE = "DIARY";그리고 제거/추가 케이스 모두 동일 상수/케이싱 사용.
Also applies to: 220-221, 248-249
src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java (2)
59-66: 내 정보 조회에 등록용 DTO 재사용 + null 필드 전달 — 전용 DTO로 분리 권장회원 “내 정보” 응답에서
UserRegisterResponse(등록용)를 재사용하며 토큰 필드를null로 채워 보내는 것은 API 계약이 모호해지고, 클라이언트 파싱 실수를 유발합니다. 전용 DTO(UserRegisterResponse.MyInfoResponseDTO)로 반환 타입을 좁혀 응답 스키마를 명확히 해주세요.다음과 같이 메서드 본문과 시그니처를 간결화할 수 있습니다(실제 팩토리/빌더 유무에 맞춰 조정).
- public ResponseEntity<ApiResponse<UserRegisterResponse>> getMyInfo( - @AuthenticationPrincipal User user) { - // SecurityContextHolder에서 현재 인증된 사용자(UUID)를 가져옴 - UserRegisterResponse userRegisterResponse = - new UserRegisterResponse( - user.getId(), user.getNickname(), user.getUuid(), null, null); // 토큰은 응답에 포함하지 않음 - return ResponseEntity.ok(ApiResponse.success(userRegisterResponse)); - } + public ResponseEntity<ApiResponse<UserRegisterResponse.MyInfoResponseDTO>> getMyInfo( + @AuthenticationPrincipal User user) { + var dto = UserRegisterResponse.MyInfoResponseDTO.of(user); // 또는 builder/from(User) + return ResponseEntity.ok(ApiResponse.success(dto)); + }전역 보완(참고용):
// UserRegisterResponse.MyInfoResponseDTO 내부 (예시) public static MyInfoResponseDTO of(User user) { return new MyInfoResponseDTO(user.getId(), user.getNickname(), user.getUuid()); }
70-73: 레벨 조회 반환 타입 정합성은 좋습니다. 경로 일관성은 소폭 손봐도 좋겠습니다.
UserRegisterResponse.LevelStatusResponseDTO로 타입 일관화된 점은 👍 입니다. 경로는/users/me/level처럼 “me” 네임스페이스에 붙이면 UX/REST 일관성이 약간 더 좋아집니다. 선택 사항입니다.src/main/java/com/example/cp_main_be/global/jwt/JwtAuthenticationFilter.java (3)
25-29: 사용되지 않는 의존성 제거: UserRepository 필드현재 인증은
CustomUserDetailsService경로로 처리되고 있고,userRepository필드는 사용되지 않습니다. 불필요한 빈 주입을 제거하세요(테스트 안정성, 메모리/시작시간 미세 최적화).private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private final CustomUserDetailsService customUserDetailsService;또한 상단 import도 제거하세요.
- import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository;
44-48: 사용자 로드 실패 예외 처리 보강
loadUserByUsername(uuidStr)실패 시(삭제/비활성화 등) 필터가 예외 전파로 5xx를 유발할 수 있습니다. 인증 미설정 상태로 통과시키고 WARN 로그만 남기도록 방어 코드를 권장합니다.try { UserDetails userDetails = customUserDetailsService.loadUserByUsername(uuidStr); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // UsernameNotFoundException 등 logger.warn("Failed to load user details for uuid={} : {}", uuidStr, e.getMessage()); }위 변경 시, 실패 케이스에서 기대 동작(익명으로 통과 vs 401 반환)이 보안 설정과 일치하는지 확인 부탁드립니다.
35-35: 필터 실행 로그 레벨 다운그레이드 제안요청마다 INFO 로그는 소음이 큽니다. 운영 환경에서는 DEBUG로 낮추는 것을 권장합니다.
- logger.info("JwtAuthenticationFilter is running for URI: {}", request.getRequestURI()); + logger.debug("JwtAuthenticationFilter is running for URI: {}", request.getRequestURI());src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (2)
95-99: 조용한 no-op 반환 대신 명시적 도메인 예외/결과 필요최대 텃밭 개수 도달 시 단순
return은 클라이언트 입장에서 성공/실패 구분이 불가합니다. 예외를 던지거나 boolean/상태 응답으로 “생성되지 않음”을 명확히 표기해 주세요.- return; + throw new CustomApiException(ErrorCode.LIMIT_EXCEEDED); // 또는 ErrorCode.BAD_REQUEST 등 정책에 맞는 코드또는 메서드 시그니처를
boolean unlockNewGardenSlot(Long userId)로 바꾸고 생성 성공 여부를 반환하는 것도 선택지입니다.
104-110: 슬롯 번호 선정 로직의 결손 슬롯 처리 확인 요청
slotNumber = currentGardens + 1은 중간 슬롯 삭제가 허용될 경우(예: 2번 삭제) 빈 슬롯(2)을 건너뛰고 증가합니다. “최소 가용 슬롯”을 선택해야 하는지(1,3,4 → 다음은 2) 도메인 정책 확인 부탁드립니다.필요 시, “가장 작은 결손 슬롯 찾기” 유틸 구현 도와드리겠습니다.
src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java (2)
10-10: 파라미터 명 혼동: nickname으로 명확화
findByNickname(String username)의 파라미터 명이 혼동을 줍니다. 가독성을 위해nickname으로 명확화하세요(쿼리 메서드 동작에는 영향 없음).- Optional<User> findByNickname(String username); + Optional<User> findByNickname(String nickname);
14-14: 불리언 반환 형식: 박싱 타입(Boolean) → 기본형(boolean)Spring Data JPA의
existsBy*는boolean이 일반적이며,null가능성을 제거합니다.- Boolean existsByNickname(String nickname); + boolean existsByNickname(String nickname);src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java (3)
48-54: 직렬화 순환/페이로드 과대화 위험: avatarList에 참조 관리 어노테이션 고려.
gardens/diaries/bookMarks에는@JsonManagedReference가 있으나avatarList에는 없습니다. API 응답에User를 실을 가능성이 있다면 순환 직렬화 및 페이로드 과대화를 피하기 위해 동일한 패턴 적용을 권장합니다. (반대편Avatar.user에는@JsonBackReference필요)@OneToMany( mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) // User와 Avatar의 1:N 관계 설정 @Builder.Default - private List<Avatar> avatarList = new ArrayList<>(); + @JsonManagedReference + private List<Avatar> avatarList = new ArrayList<>();참고: 반대편 매핑(Avatar.java)의
user필드에@JsonBackReference가 없다면 추가가 필요합니다. 확인 부탁드립니다.
159-162: NPE 안전한 비교로 변경.
status가 null인 레거시 데이터가 있을 경우를 대비해 null-safe 비교로 변경하세요.- return this.status == UserStatus.ACTIVE; // 활성 상태인 경우에만 계정 활성화 + return UserStatus.ACTIVE.equals(this.status); // 활성 상태인 경우에만 계정 활성화
79-83: 네이밍 일관성: bookMarks → bookmarks 권장.필드명이 카멜케이스 컨벤션에서 벗어나 있습니다. 도메인 전반 사용처가 많다면 이번 PR 범위를 넘을 수 있으니 추후 리팩터 이슈로 분리 권장합니다.
src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (3)
30-34: 매직 넘버(100) 상수화.가독성과 재사용성을 위해 상수로 승격하세요.
private final UserDailyMissionRepository userDailyMissionRepository; private final NotificationRepository notificationRepository; + + private static final int MAX_WATER_COUNT = 100; + private static final int MAX_SUNLIGHT_COUNT = 100;빌더 사용부는 위 NPE 가드 코멘트에 포함된 diff처럼
MAX_*로 대체하세요.Also applies to: 112-114
84-101: 미사용 로컬 변수 제거(hatInfo/clothesInfo).생성 후 사용되지 않습니다. 혼동 및 유지보수 비용을 줄이기 위해 제거하세요. 추후 DTO에 포함할 계획이면 해당 필드를 DTO로 연결하세요.
- // 또는 각각 개별적으로 가져올 수도 있습니다 - HomeResponseDto.ItemInfo hatInfo = null; - if (avatar.getEquippedHat() != null) { - hatInfo = - HomeResponseDto.ItemInfo.builder() - .itemType("HAT") - .itemImageUrl(avatar.getEquippedHat().getImageUrl()) - .build(); - } - - HomeResponseDto.ItemInfo clothesInfo = null; - if (avatar.getEquippedClothes() != null) { - clothesInfo = - HomeResponseDto.ItemInfo.builder() - .itemType("CLOTHES") - .itemImageUrl(avatar.getEquippedClothes().getImageUrl()) - .build(); - } + // (미사용 블록 제거)
66-71: DTO 필드명 의미 명확화 제안(username → displayName/nickname).API 스키마 관점에서 혼동 소지가 있습니다. 클라이언트/문서화 범위를 고려해
username필드명을displayName또는nickname으로 일치시키는 것을 제안합니다.
📜 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 (27)
src/main/java/com/example/cp_main_be/domain/admin/presentation/AdminController.java(1 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/presentation/GardenController.java(1 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/auth/service/AuthService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java(6 hunks)src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java(2 hunks)src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/member/user/service/CustomUserDetailsService.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/service/AvatarPostScheduler.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/comment/dto/response/CommentResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/diaryimage/presentation/DiaryImageController.java(2 hunks)src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java(1 hunks)src/main/java/com/example/cp_main_be/global/dto/AuthorResponse.java(1 hunks)src/main/java/com/example/cp_main_be/global/jwt/JwtAuthenticationFilter.java(3 hunks)src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java(4 hunks)src/test/java/com/example/cp_main_be/domain/notification/service/NotificationServiceTest.java(3 hunks)src/test/java/com/example/cp_main_be/domain/social/avatarpost/service/AvatarPostServiceTest.java(2 hunks)src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java(5 hunks)src/test/java/com/example/cp_main_be/domain/social/diary/service/DiaryServiceTest.java(2 hunks)src/test/java/com/example/cp_main_be/domain/social/feed/service/FeedServiceTest.java(2 hunks)src/test/java/com/example/cp_main_be/domain/social/follow/service/FollowServiceTest.java(6 hunks)src/test/java/com/example/cp_main_be/domain/social/like/service/LikeServiceTest.java(8 hunks)src/test/java/com/example/cp_main_be/domain/user/service/UserServiceTest.java(5 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
- src/main/java/com/example/cp_main_be/domain/social/avatarpost/service/AvatarPostScheduler.java
- src/main/java/com/example/cp_main_be/global/config/SecurityConfig.java
- src/test/java/com/example/cp_main_be/domain/social/diary/service/DiaryServiceTest.java
- src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (4)
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (1)
Service(22-132)src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java (1)
Service(31-216)src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
Service(16-104)src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java (1)
Service(32-188)
🔇 Additional comments (17)
src/main/java/com/example/cp_main_be/domain/social/diaryimage/presentation/DiaryImageController.java (3)
6-6: Swagger 태그 import 추가 좋습니다문서화 품질을 높이기 위한 기반이 잘 깔렸습니다. 이 변경 자체는 그대로 OK입니다.
15-15: 컨트롤러 수준 @tag 적용 적절도메인 단위로 그룹핑이 명확해져 탐색성이 좋아집니다.
1-1: 패키지 이동 영향 점검 완료: 추가 작업 불필요검사 결과 다음과 같이 확인되었습니다.
- 이전 패키지 경로(
com.example.cp_main_be.domain.social.diary.presentation) 참조는DiaryController.java의 패키지 선언 외에는 존재하지 않습니다.- 커스텀
@ComponentScan또는scanBasePackages사용은 전혀 없습니다.- springdoc(OpenAPI) 설정에서 특정 패키지 스캔을 제한하는 옵션도 없습니다.
따라서 패키지 이동에 따른 간접 영향은 없으며, 추가 조치는 필요하지 않습니다.
src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java (1)
98-98: 닉네임 기반 메시지 전환 LGTM
sender.getNickname()사용으로 도메인 전반의 닉네임 전환과 일관됩니다.src/main/java/com/example/cp_main_be/domain/social/comment/dto/response/CommentResponse.java (1)
21-21: 닉네임 전환 반영 확인
comment.getWriter().getNickname()적용 일관성 확인되며 문제 없습니다.src/test/java/com/example/cp_main_be/domain/user/service/UserServiceTest.java (4)
36-37: 테스트 생성부의 닉네임 전환 LGTM
닉네임 빌더 전환이 의도와 일치합니다.
102-103: 테스트 생성부의 닉네임 전환 LGTM
nickname("testuser")사용 일관성 OK.
118-119: 테스트 생성부의 닉네임 전환 LGTM
nickname("test")사용 일관성 OK.
1-212: 레거시username식별자 잔존 여부 점검 완료검색 결과,
getUsername()메서드는 Spring Security의UserDetails구현을 위해 의도적으로 남아 있고,- DTO 클래스(
UserRegisterResponse,AdminResponseDTO.UserResDTO,AuthorResponse등)의username필드 및 빌더 메서드는 외부 API 스펙에 따라 사용되고 있으며,- 테스트 코드 내 주석 처리된
username빌더 호출은 더 이상 활성 코드가 아닙니다.따라서 추가적인 식별자 제거 작업은 불필요합니다.
src/test/java/com/example/cp_main_be/domain/social/feed/service/FeedServiceTest.java (1)
54-55: 닉네임 전환 반영 LGTM
User.builder().nickname("mockUser")로의 변경이 일관되고, 이후 피드 생성/정렬 검증 로직과도 충돌이 없습니다.src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
6-6: DTO 네임스페이스 및getLevel반환 타입 반영 완료 확인
UserController(src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java)
→ 메서드 시그니처 및 내부 로직 모두UserRegisterResponse.LevelStatusResponseDTO사용 확인UserService(src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java)
→ 반환 타입과 빌더 호출이 일관되게 변경된 것 확인- DTO 정의( src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java)
→LevelStatusResponseDTO클래스가 새 네임스페이스로 제대로 선언됨Swagger 스펙도 반환 타입 기반으로 자동 생성되므로 별도 수정은 불필요합니다. 외부 호출부와 스웨거 반영이 모두 완료되어 추가 조치 없이 바로 머지 가능합니다.
src/test/java/com/example/cp_main_be/domain/social/avatarpost/service/AvatarPostServiceTest.java (1)
40-41: 닉네임 기반 빌더 전환 반영 잘 됨
username(...)→nickname(...)전환이 테스트 데이터 생성부에 정확히 반영되었습니다. 도메인 변경과 일치합니다.Also applies to: 76-78
src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java (2)
46-46: 닉네임 기반 빌더 전환 일관 반영 OK테스트 데이터의
User.builder().nickname("...")사용이 전반적으로 일관되게 반영되었습니다.Also applies to: 96-96, 153-153, 172-172, 209-209
60-61: 테스트 단언 수정 불필요:getUsername()사용이 맞습니다.User 도메인 모델에는
getNickname()메서드가 존재하지 않으며, 닉네임 필드(nickname)는 여전히getUsername()으로 반환되도록 구현되어 있습니다. 따라서 테스트에서writer.getUsername()을 검증하는 것이 올바르며,getNickname()으로 변경할 필요가 없습니다.Likely an incorrect or invalid review comment.
src/test/java/com/example/cp_main_be/domain/social/like/service/LikeServiceTest.java (1)
66-67: 닉네임 기반 빌더 전환 반영 OK (주석 포함 구간까지 일관 적용)테스트 전반에서
User.builder().nickname(...)으로 전환이 잘 반영되었습니다.Also applies to: 101-102, 122-123, 157-159, 187-189, 217-219, 245-247
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (1)
85-110: ✔ 호출부 전환 점검 완료: 신규 시그니처(unlockNewGardenSlot(Long))만 사용 중입니다.검색 결과, 기존
unlockGarden(User)호출은 모두 제거되었으며 아래 호출부에서 올바르게unlockNewGardenSlot(Long userId)만 사용 중임을 확인했습니다:
- src/main/java/com/example/cp_main_be/domain/garden/garden/presentation/GardenController.java (53행):
gardenService.unlockNewGardenSlot(currentUser.getId());- src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java (43행):
gardenService.unlockNewGardenSlot(userId);- src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java (111, 142, 169행 등):
gardenService.unlockNewGardenSlot(user.getId());호출 및 예외 테스트잔여
unlockGarden(User)호출부가 없으므로 시그니처 변경에 따른 추가 작업은 필요 없습니다.src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (1)
65-72: 표시명으로 닉네임 사용 — 적절합니다.보안 식별자(UUID)를 반환하는
User#getUsername()대신 화면 표시에nickname을 사용한 점 👍
| @Transactional | ||
| public void unlockGarden(User user) { | ||
| public void unlockNewGardenSlot(Long userId) { | ||
| User user = | ||
| userRepository | ||
| .findById(userId) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND)); | ||
|
|
||
| int currentGardens = user.getGardens().size(); | ||
| long userLevel = user.getLevel(); | ||
|
|
||
| // 최대 텃밭 개수(4개)를 초과하는지 확인 | ||
| // 최대 텃밭 개수 제한은 여전히 유효하므로 여기서 검사 | ||
| if (currentGardens >= MAX_GARDEN_COUNT) { | ||
| throw new IllegalStateException("텃밭은 최대 " + MAX_GARDEN_COUNT + "개까지만 생성할 수 있습니다."); | ||
| // 이미 최대치이므로 조용히 종료하거나 예외를 던질 수 있습니다. | ||
| // 여기서는 추가 생성을 막고 그냥 리턴합니다. | ||
| return; | ||
| } | ||
|
|
||
| // 사용자의 레벨이 현재 보유한 텃밭 수보다 많아야 새 텃밭을 열 수 있음 | ||
| if (userLevel <= currentGardens) { | ||
| throw new IllegalStateException("레벨이 부족하여 더 이상 텃밭을 잠금 해제할 수 없습니다."); | ||
| } | ||
| // [기존 레벨 체크 로직 삭제!] | ||
|
|
||
| // 새 텃밭 생성 (슬롯 번호는 기존 텃밭 수 + 1) | ||
| // TODO: 새로 생성된 텃밭의 기본 Avatar, Background 설정 로직 필요 | ||
| Garden newGarden = Garden.builder().user(user).slotNumber(currentGardens + 1).build(); | ||
|
|
||
| gardenRepository.save(newGarden); | ||
|
|
||
| // User 엔티티의 gardens 리스트에도 추가하여 영속성 컨텍스트와 객체 상태의 일관성을 맞춤 | ||
| // User 엔티티의 gardens 리스트에도 추가 | ||
| user.addGarden(newGarden); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
동시성 취약점: 슬롯 언락 경쟁 조건으로 중복/초과 생성 가능
현재 currentGardens = user.getGardens().size() 기반으로 slotNumber = currentGardens + 1을 계산합니다. 동일 사용자에 대해 동시에 요청이 들어오면 둘 다 최대치 검사 통과 후 각각 저장되어 슬롯 번호 충돌/최대 개수 초과가 발생할 수 있습니다. DB 유니크 제약과 비관적 락(or 낙관적 재시도)으로 보강하세요.
권장 접근:
- DB 제약:
(user_id, slot_number)유니크 인덱스 추가. - 락/계산: “다음 슬롯 번호”를
FOR UPDATE로 계산. - 서비스: 유니크 위반(DataIntegrityViolationException) 재시도로 보정 또는 도메인 예외 변환.
서비스 측 최소 변경 예시(레포지토리 지원 메서드 존재 가정):
- int currentGardens = user.getGardens().size();
+ // 동시성 안전한 다음 슬롯 번호 계산 (FOR UPDATE)
+ int nextSlot = gardenRepository.nextSlotNumberForUpdate(userId);
- if (currentGardens >= MAX_GARDEN_COUNT) {
+ if (nextSlot > MAX_GARDEN_COUNT) {
- return;
+ throw new CustomApiException(ErrorCode.LIMIT_EXCEEDED); // 실제 에러코드에 맞춰 수정
}
- Garden newGarden = Garden.builder().user(user).slotNumber(currentGardens + 1).build();
+ Garden newGarden = Garden.builder().user(user).slotNumber(nextSlot).build();레포지토리 보강(참고):
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select coalesce(max(g.slotNumber), 0) + 1 from Garden g where g.user.id = :userId")
int nextSlotNumberForUpdate(@Param("userId") Long userId);DB 마이그레이션(예시):
ALTER TABLE garden
ADD CONSTRAINT uq_garden_user_slot UNIQUE (user_id, slot_number);🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 85-110, the current calculation of slotNumber using
user.getGardens().size() is vulnerable to concurrent requests causing
duplicate/over-capacity slots; add a DB unique constraint on (user_id,
slot_number) and change the service to compute the next slot using a repository
method that acquires a PESSIMISTIC_WRITE lock (e.g.,
nextSlotNumberForUpdate(userId)), create the Garden with that locked slot
number, and save it; additionally catch DataIntegrityViolationException (or
unique constraint violation) and implement a short retry loop or translate to a
domain exception so concurrent insert races are corrected or surfaced cleanly.
| Optional<User> findByNickname(String username); | ||
|
|
||
| Optional<User> findByUuid(UUID uuid); | ||
|
|
||
| Boolean existsByNickname(String nickname); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
❓ Verification inconclusive
닉네임 유니크 보장 및 경쟁 조건 대응
가입 시 닉네임 중복은 전형적인 경쟁 조건입니다. DB 유니크 제약과 서비스 계층의 DataIntegrityViolationException 처리로 방어하세요.
권장:
- User 엔티티:
@Column(name = "nickname", unique = true) - DB 마이그레이션:
ALTER TABLE user ADD CONSTRAINT uq_user_nickname UNIQUE (nickname); - 가입 서비스: 사전
existsByNickname체크 + 유니크 위반 예외 변환
점검 스크립트:
🏁 Script executed:
#!/bin/bash
# 엔티티 유니크 매핑 및 마이그레이션 유무 점검
rg -nP --type=java '@Column\([^)]*name\s*=\s*"nickname"[^)]*unique\s*=\s*true' -C2
fd -t f -a -g '*migration*' 2>/dev/null | xargs -I{} rg -nP 'unique.*nickname|unique index.*nickname' '{}' || trueLength of output: 183
닉네임 유니크 제약 및 경쟁 조건 방어 로직 추가 필요
스크립트 검증 결과, User 엔티티에 닉네임에 대한 @Column(unique = true) 매핑과 DB 마이그레이션 파일에서 닉네임 유니크 제약 정의가 모두 누락된 것으로 확인되었습니다. 이 상태로 운영 시 가입 시점의 동시성 경쟁으로 인해 동일 닉네임이 중복 저장될 위험이 있습니다. 아래와 같이 필수 조치를 적용해주세요.
-
User 엔티티에 유니크 제약 매핑 추가
- 파일:
src/main/java/com/example/cp_main_be/domain/member/user/domain/entity/User.java - 예시:
@Entity public class User { //… @Column(name = "nickname", unique = true, nullable = false) private String nickname; //… }
- 파일:
-
DB 마이그레이션으로 유니크 제약 추가
- 디렉토리:
src/main/resources/db/migration(혹은 프로젝트에서 사용하는 마이그레이션 경로) - 예시 (Flyway 기준):
-- V2__add_unique_constraint_on_user_nickname.sql ALTER TABLE "user" ADD CONSTRAINT uq_user_nickname UNIQUE (nickname);
- 디렉토리:
-
회원가입 서비스에 경쟁 조건 방어 로직 구현
- 파일:
src/main/java/com/example/cp_main_be/domain/member/user/application/SignupService.java(경로는 실제 서비스 클래스 위치에 맞춰 조정) - 흐름 제안:
existsByNickname사전 체크- 저장 시
DataIntegrityViolationException예외 처리 후 사용자 정의 예외로 변환
- 예시:
public void signUp(SignUpRequest req) { if (userRepository.existsByNickname(req.getNickname())) { throw new NicknameAlreadyExistsException("이미 사용 중인 닉네임입니다."); } try { userRepository.save(User.from(req)); } catch (DataIntegrityViolationException e) { throw new NicknameAlreadyExistsException("이미 사용 중인 닉네임입니다.", e); } }
- 파일:
위 세 가지 조치는 데이터 무결성을 보장하고, 동시성 이슈로 인한 장애를 예방하기 위해 반드시 적용되어야 합니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/member/user/domain/repository/UserRepository.java
around lines 10-15: the repository review flagged missing uniqueness and
race-condition protections for nickname; update the User entity
(src/main/java/com/example/cp_main_be/domain/member/user/domain/entity/User.java)
to annotate the nickname column as unique and not-null, add a DB migration
(src/main/resources/db/migration) that creates a UNIQUE constraint on the
user.nickname column (e.g., ALTER TABLE "user" ADD CONSTRAINT uq_user_nickname
UNIQUE (nickname)), and harden the signup flow (e.g.,
src/main/java/.../application/SignupService.java) to check existsByNickname
before save and catch DataIntegrityViolationException on save to translate it
into a NicknameAlreadyExistsException to handle concurrent inserts.
| @Column(unique = true) | ||
| private UUID uuid; | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
uuid null 가능성 제거: not-null 제약 및 PrePersist에서 자동 생성.
getUsername()이 uuid.toString()을 반환하므로, uuid가 null이면 인증 흐름에서 NPE가 발생합니다. DB 제약과 생성 시 초기화를 권장합니다.
- @Column(unique = true)
+ @Column(nullable = false, unique = true)
private UUID uuid;
@@
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
if (this.status == null) this.status = UserStatus.ACTIVE;
if (this.role == null) this.role = Role.USER; // [추가] 기본 역할을 USER로 설정
+ if (this.uuid == null) this.uuid = UUID.randomUUID();
}Also applies to: 86-92
🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java
around lines 32-34 (and also apply same change for the similar code at lines
86-92): the uuid field must be non-null to avoid NPE in getUsername(); add a DB
not-null constraint (e.g., @Column(unique = true, nullable = false)) and ensure
uuid is initialized before persist by adding a @PrePersist method that sets uuid
= UUID.randomUUID() if uuid == null; keep getUsername() unchanged but now safe
since uuid will always be non-null.
| import com.example.cp_main_be.domain.member.user.dto.request.AvatarChangeRequest; | ||
| import com.example.cp_main_be.domain.member.user.dto.request.NicknameChangeRequest; | ||
| import com.example.cp_main_be.domain.member.user.dto.response.UserResponse; | ||
| import com.example.cp_main_be.domain.member.user.dto.response.UserRegisterResponse; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
UserRegisterResponse 전환 후 잔존 참조 정리 제안
UserRegisterResponse로 전환된 만큼, 기존 UserResponse 참조가 남아있지 않은지 전역 점검을 권장합니다. 닉네임/username 전환 혼재도 함께 확인해 주세요.
다음 스크립트로 남아있는 참조를 빠르게 점검할 수 있습니다.
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 구 DTO/필드 잔존 여부 점검
rg -nP --type=java '\bUserResponse\b' -C2
rg -nP --type=java '\bgetUsername\s*\(' -C2
# 신 DTO 사용 현황
rg -nP --type=java '\bUserRegisterResponse\b' -C2Length of output: 63
첫 스크립트가 rg의 매치 실패(종료 코드 1)로 인해 즉시 종료되어 이후 패턴(getUsername, UserRegisterResponse) 점검이 이루어지지 않았습니다. 모든 검사를 실행할 수 있도록 아래와 같이 set -e를 제거하거나 각 명령 뒤에 || true를 붙인 스크립트로 재검증을 제안드립니다.
이 스크립트 실행 후에도 잔존 참조가 없다면 UserRegisterResponse 전환이 완전하다고 판단할 수 있습니다.
🏁 Script executed:
#!/bin/bash
# 각 패턴 조회 시 매치 실패에도 다음 명령이 실행되도록 || true 추가
rg -nP --type=java '\bUserResponse\b' -C2 || true
rg -nP --type=java '\bgetUsername\s*\(' -C2 || true
rg -nP --type=java '\bUserRegisterResponse\b' -C2 || trueLength of output: 9464
UserRegisterResponse 전환 및 username → nickname/uuid 잔존 참조 전면 정리 필요
아래 파일들에서 여전히 getUsername() 호출과 username 기반 어설션이 남아 있어, DTO 전환이 불완전한 상태입니다. 다음 위치를 모두 점검하여 nickname 또는 uuid로 교체하거나 불필요한 코드는 제거해 주세요.
- src/test/java/com/example/cp_main_be/domain/admin/service/AdminServiceTest.java
- 라인 154:
assertThat(users.get(0).getUsername()).isEqualTo("유저1");
- 라인 154:
- src/test/java/com/example/cp_main_be/domain/user/service/UserServiceTest.java
- 라인 93:
Assertions.assertEquals("test", foundUser.getUsername()); - 라인 157:
Assertions.assertEquals(newNickname, user.getUsername());
- 라인 93:
- src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java
- 라인 60:
Assertions.assertEquals(writer.getUsername(), createdComment.getWriter()); - 라인 119:
Assertions.assertEquals(writer.getUsername(), updatedComment.getWriter());
- 라인 60:
- src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java
- 라인 138:
public String getUsername()→public String getUuid()혹은getNickname()으로 대체
- 라인 138:
추가로, 서비스·컨트롤러 층에서도 username 필드나 메서드가 남아 있지 않은지 재검토해 주세요.
| request.setToken(token); | ||
|
|
||
| User user = User.builder().id(userId).username("testuser").build(); | ||
| User user = User.builder().id(userId).nickname("testuser").build(); |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
닉네임 전환 반영 LGTM
테스트 픽스처에서 .username(...) → .nickname(...) 전환이 일관되게 반영되었습니다. 도메인 모델 변경과 정합성이 맞습니다.
레거시 사용 흔적이 남아있지 않은지 빠르게 점검하려면 아래 스크립트를 권장합니다.
Also applies to: 65-65, 106-106
🏁 Script executed:
#!/bin/bash
# username/nickname 호출 흔적 점검
rg -nP '\.username\(' -S || true
rg -nP '\bgetUsername\(' -S || trueLength of output: 1872
🚩 Legacy username 호출 잔여 발견 – 추가 반영 필요
아래 위치에서 여전히 .username(...) 또는 getUsername() 호출이 확인되어, 도메인 모델의 nickname 전환이 완전히 반영되지 않았습니다. 다음 항목을 검토 후 일관되게 nickname으로 교체하거나 불필요한 코드를 제거해 주세요.
-
src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java:67
.username(user.getNickname())→.nickname(user.getNickname()) -
src/main/java/com/example/cp_main_be/domain/admin/presentation/AdminController.java:81
.username(user.getNickname())→.nickname(user.getNickname()) -
src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java:138
public String getUsername()→public String getNickname()
(호출부 일괄 교체 또는 기존 메서드에 별칭(alias) 추가 고려) -
테스트 코드 내 주석처리된 레거시 빌더 호출
- src/test/java/com/example/cp_main_be/domain/admin/service/AdminServiceTest.java (라인 145–146)
- src/test/java/com/example/cp_main_be/domain/garden/service/GardenServiceTest.java (라인 70, 87)
→ 불필요한 주석 삭제 또는username()→nickname()으로 수정
-
테스트 어설션에서의
getUsername()호출- src/test/java/com/example/cp_main_be/domain/social/comment/service/CommentServiceTest.java (라인 60, 119)
- src/test/java/com/example/cp_main_be/domain/user/service/UserServiceTest.java (라인 93, 157)
→getUsername()→getNickname()으로 변경
위 항목을 모두 반영한 뒤, RG 스크립트를 재실행하여 잔여 호출이 없는지 최종 확인 바랍니다.
🤖 Prompt for AI Agents
In
src/test/java/com/example/cp_main_be/domain/notification/service/NotificationServiceTest.java
around line 41, the test still constructs User with a legacy username property;
replace any .username(...) or getUsername() usages with .nickname(...) and
getNickname() respectively (or remove the obsolete builder call) and ensure the
User builder uses .nickname(userIdOrValue). Also review and apply the same
replacement across the reported files (HomeService.java:67,
AdminController.java:81, User.java:138 and the listed test files), or add a
temporary alias getUsername() that delegates to getNickname() if necessary, then
run the RG script to confirm no residual username calls remain.
| import com.example.cp_main_be.domain.social.avatarpost.domain.repository.AvatarPostRepository; | ||
| import com.example.cp_main_be.domain.social.diary.domain.Diary; | ||
| import com.example.cp_main_be.domain.social.diary.domain.repository.DiaryRepository; | ||
| import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
패키지 경로 대소문자 오류 가능성(domain.Repository) — CI/리눅스 환경에서 컴파일 실패 위험
import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository; 처럼 패키지 세그먼트가 Repository(대문자)로 보입니다. 자바 패키지는 소문자 관례이며, 실제 소스 경로와 대소문자가 다르면 리눅스(대소문자 구분 파일 시스템)에서 빌드가 실패합니다. 의도된 리팩터링이 아니라면 소문자 repository로 수정해 주세요.
수정 예시:
-import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository;
+import com.example.cp_main_be.domain.social.diary.domain.repository.DiaryRepository;실제 선언/경로를 확인하는 스크립트:
🏁 Script executed:
#!/bin/bash
# 선언부와 경로의 패키지 일치 여부 점검
fd -a 'DiaryRepository.java'
rg -nP '^\s*package\s+com\.example\.cp_main_be\.domain\.social\.diary\.domain\.[Rr]epository\s*;' -n -C0Length of output: 418
패키지 경로 대소문자 불일치로 인한 빌드 실패 위험 – 경로 및 선언 수정 필요
CI(리눅스) 환경에서는 파일 시스템이 대소문자를 구분하기 때문에, 현재 src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository/DiaryRepository.java 파일의 디렉터리명(Repository)과 패키지 선언이 대문자(Repository)로 설정되어 있으면 아래와 같은 문제가 발생합니다.
- 패키지 명명 관례(Java conventions) 위반
- 로컬(macOS/Windows)에서는 통과하더라도 CI(리눅스)에서 컴파일 오류 발생
다음 위치들을 반드시 수정해 주세요:
• 경로명:
- src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository/ → …/repository/
• 패키지 선언(소스) 및 import 구문:
- package com.example.cp_main_be.domain.social.diary.domain.Repository;
- import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository;
예시 수정안:
# 디렉터리명 변경
git mv \
src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository \
src/main/java/com/example/cp_main_be/domain/social/diary/domain/repository--- a/src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository/DiaryRepository.java
+++ b/src/main/java/com/example/cp_main_be/domain/social/diary/domain/repository/DiaryRepository.java
@@ -1,1 +1,1 @@
-package com.example.cp_main_be.domain.social.diary.domain.Repository;
+package com.example.cp_main_be.domain.social.diary.domain.repository;--- a/src/test/java/com/example/cp_main_be/domain/social/feed/service/FeedServiceTest.java
+++ b/src/test/java/com/example/cp_main_be/domain/social/feed/service/FeedServiceTest.java
@@ -16,1 +16,1 @@
-import com.example.cp_main_be.domain.social.diary.domain.Repository.DiaryRepository;
+import com.example.cp_main_be.domain.social.diary.domain.repository.DiaryRepository;위 변경을 적용한 후 전체 빌드를 재실행하여 오류가 없는지 확인해 주세요.
🤖 Prompt for AI Agents
In
src/test/java/com/example/cp_main_be/domain/social/feed/service/FeedServiceTest.java
around line 16, the import uses an uppercase directory/package "Repository"
which will break CI on case-sensitive filesystems; rename the filesystem
directory
src/main/java/com/example/cp_main_be/domain/social/diary/domain/Repository to
repository, update the package declaration in DiaryRepository.java to package
com.example.cp_main_be.domain.social.diary.domain.repository; update all imports
(including this test) from ...Repository.DiaryRepository to
...repository.DiaryRepository, commit the path rename (git mv) so history is
preserved, and re-run the full build to confirm the error is resolved.
참고용
Summary by CodeRabbit
New Features
Changes