Skip to content

✨ feat: AI 파생 이미지 유사성 판단 투표 시스템 구현#170

Merged
goalSetter09 merged 52 commits intodevelopfrom
modic_backend_168
Sep 21, 2025
Merged

✨ feat: AI 파생 이미지 유사성 판단 투표 시스템 구현#170
goalSetter09 merged 52 commits intodevelopfrom
modic_backend_168

Conversation

@goalSetter09
Copy link
Collaborator

@goalSetter09 goalSetter09 commented Sep 14, 2025

Summary

원본 게시물 이미지와 AI 생성 파생 이미지 간의 유사성을 판단하기 위한 완전한 투표 시스템을 구현했습니다. 커뮤니티 기반으로 AI 파생 게시물의 품질을 검증할 수 있습니다.

🎯 주요 기능

1. 투표 참여 시스템

  • POST /api/votes/{voteId}/decisions - 사용자가 투표에 참여
  • 중복 투표 방지 및 권한 검증 (생성자/원작자 투표 불가)
  • 분산 락을 통한 동시성 제어
  • 실시간 투표 집계 업데이트

2. 투표 조회 시스템

  • GET /api/votes/random - 참여 가능한 랜덤 투표 1건 조회
  • GET /api/votes/{voteId}/results - 특정 투표 결과 조회
  • 원본 이미지(A)와 생성된 이미지(B)의 presigned URL 제공
  • 투표 집계 정보 포함 (찬성/반대/총 가중치)

3. 투표 생성 및 연동

  • AI 파생 게시물 생성 시 자동으로 투표 생성
  • PostEntityderivedPostStatus 필드 추가
  • 투표 완료 시 게시물 상태 자동 업데이트 (APPROVED/REJECTED)

4. AI 평가 시스템 (준비)

  • AI 초기 평가를 위한 구조 준비
  • 가중치 기반 투표 시스템 (AI: 20, 사용자: 1)
  • 비동기 AI 연동을 위한 확장 가능한 구조

🔧 기술적 개선사항

  • 성능 최적화: Page<SimilarityVoteEntity> 대신 Optional<SimilarityVoteEntity> 사용
  • 동시성 제어: 분산 락을 통한 안전한 투표 집계 업데이트
  • 데이터 무결성: 중복 투표 방지 및 권한 검증
  • 확장성: AI 평가와 비동기 처리를 위한 구조적 준비

📋 구현된 전체 구성요소

Domain Layer

  1. Entities: SimilarityVoteEntity, SimilarityVoteResultEntity, SimilarityVoteSummaryEntity
  2. Enums: VoteStatus, VoteDecision, VoteType, PostStatus
  3. Repositories: 3개 Repository 인터페이스 (쿼리 최적화 포함)
  4. Services: VotingService (투표 참여), VoteQueryService (투표 조회)

Web Layer

  1. Controller: VoteController - 3개 API 엔드포인트
  2. DTOs: VoteDetailResponse, VoteSummaryResponse, VoteParticipationRequest/Response

Integration

  1. AiDerivedPostService 투표 생성 연동
  2. PostEntity 상태 관리 필드 추가
  3. LockManager 분산 락 키 추가

🛠️ API 응답 예시

GET /api/votes/random

{
  "voteId": 123,
  "originalImageUrl": "https://cloudfront.example.com/original-image.jpg",
  "derivedImageUrl": "https://cloudfront.example.com/derived-image.jpg",
  "approveWeight": 25,
  "denyWeight": 15,
  "totalWeight": 40,
  "status": "IN_PROGRESS"
}

POST /api/votes/{voteId}/decisions

{
  "voteId": 123,
  "decision": "APPROVE",
  "currentApproveWeight": 26,
  "currentDenyWeight": 15,
  "isCompleted": false
}

🗃️ DB Migration SQL

새로 추가된 테이블 (3개)

similarity_vote 테이블

CREATE TABLE similarity_vote (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    original_image_id BIGINT NOT NULL COMMENT '원본 이미지 ID',
    derived_image_id BIGINT NOT NULL COMMENT '파생 AI 이미지 ID', 
    derived_post_id BIGINT NULL COMMENT '파생 게시물 ID',
    vote_type VARCHAR(50) NOT NULL COMMENT '투표 타입 (SIMILARITY)',
    status VARCHAR(50) NOT NULL COMMENT '투표 상태 (PENDING, IN_PROGRESS, COMPLETED, CANCELLED)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_similarity_vote_original_image_id (original_image_id),
    INDEX idx_similarity_vote_derived_image_id (derived_image_id),
    INDEX idx_similarity_vote_status (status)
) COMMENT = '유사성 투표 메인 테이블';

similarity_vote_result 테이블

CREATE TABLE similarity_vote_result (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    vote_id BIGINT NOT NULL COMMENT '투표 ID',
    user_id BIGINT NOT NULL COMMENT '사용자 ID',
    decision VARCHAR(50) NOT NULL COMMENT '투표 결정 (APPROVE, DENY, PENDING)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_similarity_vote_result_vote_id (vote_id),
    INDEX idx_similarity_vote_result_user_id (user_id),
    UNIQUE KEY uk_similarity_vote_result_vote_user (vote_id, user_id)
) COMMENT = '개별 투표 결과 테이블';

similarity_vote_summary 테이블

CREATE TABLE similarity_vote_summary (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    vote_id BIGINT NOT NULL UNIQUE COMMENT '투표 ID',
    approve_weight BIGINT NOT NULL DEFAULT 0 COMMENT '찬성 가중치 합계',
    deny_weight BIGINT NOT NULL DEFAULT 0 COMMENT '반대 가중치 합계',
    total_weight BIGINT NOT NULL DEFAULT 0 COMMENT '총 가중치',
    ai_decision VARCHAR(50) NULL COMMENT 'AI 결정 (APPROVE, DENY)',
    final_decision VARCHAR(50) NULL COMMENT '최종 결정 (APPROVE, DENY, PENDING)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_similarity_vote_summary_vote_id (vote_id)
) COMMENT = '투표 집계 요약 테이블';

기존 테이블 수정 (1개)

post 테이블 컬럼 추가

ALTER TABLE post 
ADD COLUMN derived_post_status VARCHAR(50) NULL COMMENT 'AI 파생 게시물 승인 상태 (PENDING, APPROVED, REJECTED)';

⚙️ application.yml 설정 추가

다음 설정을 application.yml에 추가해주세요:

vote:
  similarity:
    min-total-weight: 50        # 투표 완료를 위한 최소 총 가중치
    ai-vote-weight: 20          # AI 투표 가중치 
    human-vote-weight: 1        # 인간 투표 가중치
    enable-ai-assessment: true  # AI 평가 활성화 여부
    max-votes-per-user-per-day: 100  # 사용자별 일일 최대 투표 횟수

📊 전체 변경사항 요약

새로 추가된 구성요소:

  • 3개 테이블 추가: similarity_vote, similarity_vote_result, similarity_vote_summary
  • 1개 컬럼 추가: post.derived_post_status
  • 5개 Enum 클래스: VoteStatus, VoteDecision, VoteType, PostStatus
  • 3개 API 엔드포인트:
    • GET /api/votes/random (랜덤 투표 조회)
    • POST /api/votes/{voteId}/decisions (투표 참여)
    • GET /api/votes/{voteId}/results (투표 결과 조회)
  • 설정 프로퍼티: vote.similarity.* 5개 설정값
  • 9개 에러 코드: V-001 ~ V-009 투표 관련 예외 처리

🔄 투표 시스템 플로우

  1. 게시물 생성: AI 파생 게시물 생성 시 PENDING 상태로 투표 자동 생성
  2. 투표 진행: 커뮤니티 사용자들이 GET /api/votes/random으로 투표 조회 후 참여
  3. 실시간 집계: 투표 참여 시 분산 락으로 안전하게 집계 업데이트
  4. 투표 완료: 총 가중치가 임계값에 도달하면 자동으로 게시물 상태 결정
  5. 상태 반영: APPROVED 또는 REJECTED 상태로 게시물 최종 승인

Test plan

  • 투표 참여 시스템: POST /api/votes/{voteId}/decisions 동작 확인
  • 투표 조회 시스템: GET /api/votes/random, GET /api/votes/{voteId}/results 확인
  • 중복 투표 방지: 동일 사용자 중복 투표 검증
  • 권한 검증: 생성자/원작자 투표 권한 제한 확인
  • 분산 락: 동시성 제어 및 집계 정확성 검증
  • presigned URL: 이미지 URL 생성 기능 검증
  • 에러 처리: V-001 ~ V-009 예외 코드 처리 확인
  • Entity 연동: 투표-게시물 상태 연동 확인
  • DB 마이그레이션: SQL 실행 후 테이블 생성 확인 필요
  • 설정값 적용: application.yml 설정 후 기능 동작 확인 필요
  • 전체 플로우: 게시물 생성 → 투표 → 상태 업데이트 End-to-End 테스트 필요

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • AI 채팅: 채팅방 조회/메시지 전송/컨텍스트 리셋 및 SSE 실시간 응답 구독
    • 유사도 투표: 랜덤 투표 제공, 투표 참여/결과 조회 및 최종 결정에 따른 게시물 상태 반영
    • AI 채팅용 이미지 업로드/조회 및 내 생성 이미지 목록
  • Changes

    • 오류 코드 확장(AI·투표 관련) 및 AI 파생 게시물 상태(PostStatus) 추가
    • AI 이미지/권한 모델·서비스 재구성 및 API 경로 정리
  • Documentation

    • OpenAPI/스키마 보강(요청/응답 DTO에 설명·예시 추가)
  • Chores

    • AWS SDK v2 전환, 의존성·Gradle 업데이트, 테스트·빌드 스크립트 정비

goalSetter09 and others added 20 commits September 7, 2025 15:46
AI 생성 이미지와 원작 이미지 간의 유사성을 투표로 판단하는 시스템을 위한
기본 데이터베이스 구조 및 엔티티 클래스들을 구현했습니다.

- VoteStatus, VoteDecision, VoteType enum 클래스 생성
- SimilarityVoteEntity: 투표 정보 저장 (원작/파생 이미지 관계)
- SimilarityVoteResultEntity: 개별 사용자 투표 결과 저장
- SimilarityVoteSummaryEntity: 투표 집계 및 최종 결정 저장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
AI 생성 이미지 유사성 투표를 위한 데이터 액세스 레이어를 구현했습니다.

- SimilarityVoteRepository: 투표 기본 CRUD 및 상태별 조회
  - 랜덤 투표 목록 페이지네이션 (ORDER BY RAND())
  - 투표 권한 확인 쿼리 (이미지 소유자/생성자 체크)
- SimilarityVoteResultRepository: 개별 투표 결과 관리
  - 중복 투표 방지 및 일일 투표 제한 확인
  - 투표 통계 및 사용자별 참여 현황
- SimilarityVoteSummaryRepository: 투표 집계 및 완료 상태 관리
  - AI 판단 결과 추적 및 최종 결정 통계

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add comprehensive vote participation logic with distributed locking
- Implement vote permission validation (excludes image creator and owner)
- Add vote summary aggregation with weighted voting (AI: 20, Human: 1)
- Include daily vote limit enforcement and duplicate vote prevention
- Add 8 new vote-related error codes for proper exception handling
- Extend LockManager with voteSummaryLock for concurrency control
- Create VoteProperties for configurable vote parameters
- Add VoteParticipationResponse DTO for API responses

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Object[] findVotePermissionInfo()를 두 개의 타입 안전한 메서드로 분리:
  - findDerivedImageCreatorId(): 파생 이미지 생성자 ID 조회 (Optional<Long>)
  - findOriginalImageOwnerId(): 원작 이미지 소유자 ID 조회 (Optional<Long>)
- VotingService.validateVotePermission() 메서드를 타입 안전한 방식으로 리팩토링
- 테스트 용이성을 위해 isRestrictedUser() 메서드 분리
- 권한 검증 단계별 상세한 에러 로깅 추가
- 런타임 캐스팅 오류 및 배열 인덱스 의존성 완전 제거
- 의미있는 변수명으로 코드 가독성 향상

개선 효과:
- 런타임 캐스팅 대신 컴파일 타임 타입 안전성 확보
- 더 나은 에러 메시지와 디버깅 기능
- 유지보수성 및 테스트 용이성 개선
- 관심사의 명확한 분리

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- VoteQueryService: 투표 관련 읽기 전용 쿼리 서비스 추가
- VoteController: 투표 결과 조회 엔드포인트 구현
- VoteSummaryResponse: 투표 집계 결과 응답 DTO 구현

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add VoteParticipationRequest DTO for vote decision input
- Add POST /api/votes/{voteId}/decisions endpoint in VoteController
- Integrate with existing VotingService for vote participation logic
- Add comprehensive Swagger documentation with all error responses
- Support APPROVE/DENY vote decisions with validation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- PostStatus Enum 추가 (PENDING, APPROVED, REJECTED)
- PostEntity에 derivedPostStatus 필드 추가 (AI 파생 게시물 상태 관리)
- PostEntity에 updateDerivedPostStatus() 메서드 추가
- CreateAiDerivedPostRequest에 originalImageId 필드 추가 (투표용 원본 이미지 선택)
- 전체 DTO에 Swagger 문서화 완성

투표 시스템과 기존 게시물 시스템 통합을 위한 기본 구조 완성

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- AiDerivedPostService에 투표 시스템 의존성 추가
- createAiDerivedPost 메서드에 originalImageId 파라미터 추가
- PostEntity를 PENDING 상태로 생성하여 투표 대기 상태 설정
- SimilarityVoteEntity 자동 생성 (PENDING 상태)
- SimilarityVoteSummaryEntity 초기화 (기본값 0)
- VoteDecision에 PENDING 상태 추가 (null 대신 명시적 상태 관리)
- updateFinalDecision 로직 개선 (투표가 없을 때 PENDING 유지)

파생 게시물 생성 시 자동으로 투표 시스템과 연동되도록 구현 완료

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- SimilarityVoteEntity에 derived_post_id 컬럼 추가
- 빌더 및 생성자에 derivedPostId 매개변수 추가
- 투표 완료 시 연결된 파생 게시물 상태 업데이트를 위한 기반 구조 마련

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- SimilarityVoteEntity 생성 시 derivedPostId 설정
- 투표와 파생 게시물 간 직접적인 연결고리 구현
- 투표 완료 후 게시물 상태 업데이트를 위한 연동 준비

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- PostEntityRepository 의존성 추가
- updateDerivedPostStatus() 메서드 구현
- checkAndCompleteVote()에 게시물 상태 업데이트 로직 통합
- 투표 결과에 따라 APPROVED/REJECTED 상태로 자동 변경
- 안전한 예외 처리로 투표 완료와 게시물 상태 업데이트 독립성 보장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- createAiDerivedPost 호출 시 request.originalImageId() 매개변수 추가
- 메서드 시그니처 불일치로 인한 컴파일 오류 수정
- 투표 시스템 연동을 위한 완전한 매개변수 전달 보장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- VoteProperties 클래스의 가중치 필드를 Long에서 int로 수정
- SimilarityVoteSummaryEntity의 updateFinalDecision 메서드에 최소 총 가중치 매개변수 추가
- SimilarityVoteSummaryRepository의 isVoteCompleted 메서드에서 Long 타입을 int로 변경
- VotingService에서 updateFinalDecision 호출 시 임계치 기반으로 수정

가중치 처리의 일관성을 높이고 성능을 개선하기 위한 변경 사항입니다.
- 채팅방 생성
- 채팅 요청
- 채팅 SSE 응답 구현

- 채팅 AI 요청 미구현
- AiAgent 미구현
- VoteDetailResponse DTO 추가: 투표 상세 정보 응답 구조 정의
- GET /api/votes/random 엔드포인트 추가: 참여 가능한 랜덤 투표 1건 조회
- VoteQueryService.getRandomVoteForParticipation() 메서드 구현
- SimilarityVoteRepository에 단일 랜덤 투표 조회 메서드 추가
- ErrorCode에 V-009 참여 가능한 투표 없음 예외 추가
- AiChatImageEntity 연동을 위한 JOIN 쿼리 수정
- presigned URL 생성을 통한 원본 이미지(A)와 생성된 이미지(B) 제공

🔧 성능 최적화: Page 대신 Optional을 사용하여 불필요한 count 쿼리 제거
- AiChatImageEntity → CreatedAiImageEntity로 정정
- VoteDetailResponse 코드 포맷팅 개선
- PostService에 Todo 주석 추가 (AI_DERIVED 조회 시 PostStatus 필터링)
@coderabbitai
Copy link

coderabbitai bot commented Sep 14, 2025

Walkthrough

AI 채팅/이미지 도메인 전면 개편과 함께 투표 시스템을 신규 도입하고, AWS SDK v2로 마이그레이션, 티켓 도메인 리네이밍 및 관련 테스트/빌드 설정을 광범위하게 변경했습니다. (요약 50단어 이내)

Changes

Cohort / File(s) Summary
Error codes
src/main/java/.../common/error/ErrorCode.java
기존 P-003/P-004 제거, AI_SERVER_ERROR, AI_CHAT_ROOM_NOT_FOUND 추가, V-001~V-009 투표 관련 오류 코드 추가
Properties & Config
src/main/java/.../common/property/config/PropertyConfig.java, .../property/property/AiProperties.java, .../property/property/VoteProperties.java
AiProperties·VoteProperties 추가 및 @EnableConfigurationProperties 바인딩 확장
Distributed Lock
src/main/java/.../common/redis/distributedLock/LockManager.java
VOTE_SUMMARY_PREFIXvoteSummaryLock(long, Runnable) 메서드 추가
Post 도메인 확장
src/main/java/.../domain/post/entity/PostEntity.java, .../post/enums/PostStatus.java, .../post/service/AiDerivedPostService.java, .../post/service/PostService.java, .../web/post/controller/AiDerivedPostController.java, .../web/post/dto/request/CreateAiDerivedPostRequest.java
파생 게시물 상태(enum/필드) 추가, AI 파생 생성에 originalImageId 인자 추가 및 투표 엔티티 초기화 연계, Swagger 주석 보강
Voting: 엔티티/레포/서비스/웹
src/main/java/.../domain/vote/entity/*, .../domain/vote/enums/*, .../domain/vote/repository/*, .../domain/vote/service/*, src/main/java/.../web/vote/*
SimilarityVote, SimilarityVoteResult, SimilarityVoteSummary 엔티티, 관련 enum/repository/서비스(VoteQueryService, VotingService) 및 컨트롤러/DTO 추가
AI Chat: 엔티티/레포/서비스/SSE/MQ
src/main/java/.../domain/ai/aiChat/*, src/main/java/.../common/sse/service/EmitterService.java, src/main/java/.../common/amqp/service/MessageQueueService.java
채팅방·메시지·이미지 엔티티·리포지토리·서비스 추가, SSE 발행기 단순화, MQ 메시지 전달 방식 변경
AI Server: DTO/서비스/리스너
src/main/java/.../domain/ai/aiServer/*
OpenAI 연동 DTO/서비스(AiChatService 등), 분류·요청·리스닝 로직 및 AiChatImageEntity 개편 추가
Web: AI Chat 컨트롤러/API
src/main/java/.../web/ai/aiChat/controller/*, .../web/ai/aiServer/dto/response/*
채팅룸 조회/메시지 전송/메시지 목록/컨텍스트 리셋/SSE 구독 및 AI 이미지 업로드/조회 엔드포인트 추가
Image Infra (AWS SDK v2)
src/main/java/.../domain/image/config/S3Config.java, .../image/service/ImageValidationService.java, .../image/util/S3ImageUtil.java, .../image/util/CloudFrontKeyLoader.java, .../image/util/CloudFrontUrlSigner.java
AWS SDK v2(S3Client/S3Presigner) 도입, S3/CloudFront 서명·프리사인·삭제·복사 로직으로 전환
Ticket 리네이밍/이관
src/main/java/.../domain/ticket/*, src/main/java/.../domain/ticket/repository/TicketRepository.java, src/main/java/.../domain/ticket/service/TicketService.java, src/main/java/.../web/ticket/*
AiRequestTicket*Ticket*(엔티티/상수/레포/서비스/컨트롤러/DTO)로 명칭·패키지 변경
Deprecated 제거 (구 AI 이미지 흐름)
src/main/java/.../domain/ai/domain/CreatedAiImageEntity.java, .../domain/ai/service/*AiImage*Service.java, .../domain/ai/repository/*, .../domain/ai/listener/*, .../web/ai/controller/AiImageController.java, .../web/ai/dto/*
기존 AI 이미지 생성 관련 엔티티·서비스·리포·컨트롤러·DTO·리스너 삭제
Build & Wrapper
build.gradle, gradle/wrapper/gradle-wrapper.properties, gradlew, gradlew.bat
의존성/버전 업데이트, AWS SDK v2로 전환, WebFlux 추가, Gradle wrapper 버전 변경 및 스크립트 수정
Tests 업데이트/마이그레이션
src/test/... 다수
S3 v2 반영, AI Chat/투표/티켓 구조 변경에 따른 테스트 수정·마이그레이션 및 일부 테스트 제거

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant VC as VoteController
  participant VS as VotingService
  participant LM as LockManager
  participant VSR as SimilarityVoteSummaryRepository
  participant VRR as SimilarityVoteResultRepository
  participant VR as SimilarityVoteRepository
  Note over C,VC: 투표 참여 흐름 (요약)
  C->>VC: POST /api/votes/{voteId}/decisions
  VC->>VS: participateVote(voteId, userId, decision)
  VS->>LM: voteSummaryLock(voteId, runnable)
  activate LM
  LM-->>VS: 실행 보장(락 획득)
  VS->>VR: findById(voteId)
  VS->>VRR: existsByVoteIdAndUserId(voteId,userId)
  VS->>VRR: save(voteResult)
  VS->>VSR: updateWeights / updateFinalDecision
  VS->>VSR: isVoteCompleted(voteId,minTotalWeight)
  alt 완료됨
    VS->>VR: update(status=COMPLETED)
    VS->>PostEntityRepo: updateDerivedPostStatus(...)
  end
  deactivate LM
  VS-->>VC: VoteParticipationResponse
  VC-->>C: 200 OK
Loading
sequenceDiagram
  autonumber
  participant U as User
  participant AC as AiChatController
  participant AMS as AiChatMessageService
  participant AS as AiServerService
  participant MQ as MessageQueueService
  participant ER as EmitterService
  participant L as AiImageCreatedListener
  Note over U,AC: AI 채팅/이미지 생성 요약 흐름
  U->>AC: POST /api/posts/{postId}/chat/messages
  AC->>AMS: sendUserMessage(userId, postId, request)
  AMS->>AS: processAiRequest(nowUserId, chatMessage, images)
  alt 분류=CHAT
    AS->>AS: requestChatCreationToServer(...)
    AS-->>ER: sendToClient(chat response)
  else 분류=IMAGE
    AS->>MQ: sendImageGenerationRequest(message)
    MQ-->>L: AiImageResponseMessageDto (비동기)
    L->>ER: sendToClient(ImageResultResponse)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120+ minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • yooooonshine

Poem

(\_/)
(•ᴗ•) 깡총—새 투표가 춤추네, V 코드로 빛나고,
채팅은 속삭여, 큐는 울리고 MQ가 달려와,
구름(url)엔 서명이 반짝, 토끼는 락을 걸고 돌봐주네.

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning PR 설명은 기능 개요, 상세 구현, DB 마이그레이션 SQL, API 예시, 테스트 플랜 등 매우 상세하여 대부분 필요한 정보가 포함되어 있으나, 저장소에서 요구하는 템플릿(헤더 "## 개요" 및 "## 작업사항") 구조를 따르지 않고 있어 템플릿 검증 기준을 충족하지 않습니다. 템플릿의 섹션 제목과 구조가 요구되는 리포지토리 규칙을 고려할 때 현재 설명을 템플릿의 "## 개요"에 간단 요약을 두고 "## 작업사항"에 변경 항목(파일/테이블/설정/미해결 이슈 포함)으로 재구성해야 합니다. 또한 DB 마이그레이션 적용 방법과 남은 E2E/마이그레이션 체크 항목을 작업사항에 명확히 표기해 주세요. 해결: PR 설명을 저장소 템플릿에 맞춰 "## 개요"와 "## 작업사항" 헤더로 재구성하고, 작업사항에 변경 파일 목록, DB 마이그레이션 SQL 위치와 실행 지침, 적용 전/후 검증(예: E2E, 마이그레이션 확인) 등 필수 정보를 명확히 기재해 주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed 제목 "✨ feat: AI 파생 이미지 유사성 판단 투표 시스템 구현"은 변경셋의 핵심 변경사항(유사성 판단을 위한 투표 시스템 도입 및 AI 연동)을 명확히 요약하고 있어 스캔 시 주요 목적을 바로 파악할 수 있으므로 관련성이 높습니다. 제목은 간결하고 구현 범위와 목적을 잘 드러내며 불필요한 파일 나열이나 모호한 표현이 없습니다.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch modic_backend_168

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (27)
src/main/java/hanium/modic/backend/domain/vote/enums/VoteDecision.java (1)

8-21: 의미 혼동 가능성: APPROVE=유사성 낮음, DENY=유사성 높음

도메인 상 정의가 올바르지만, 용어만 보면 직관이 어려울 수 있습니다. 외부/프론트와의 커뮤니케이션을 위해 API 문서(스웨거)에도 동일 설명을 반복 기재하는 것을 권장합니다.

src/main/java/hanium/modic/backend/web/vote/dto/response/VoteParticipationResponse.java (1)

3-8: 스웨거 메타데이터 추가 제안

응답 DTO에도 설명/예시를 부여하면 클라이언트 통합이 수월합니다.

 package hanium.modic.backend.web.vote.dto.response;
 
+import io.swagger.v3.oas.annotations.media.Schema;
 
-public record VoteParticipationResponse(Long voteId) {
+@Schema(description = "투표 참여 응답")
+public record VoteParticipationResponse(
+        @Schema(description = "투표 ID", example = "123")
+        Long voteId
+) {
 	
 	public static VoteParticipationResponse of(Long voteId) {
 		return new VoteParticipationResponse(voteId);
 	}
 }
src/main/java/hanium/modic/backend/web/post/dto/request/CreateAiDerivedPostRequest.java (1)

19-25: 제목/설명 길이 제한 추가 권장(DB 컬럼과 일치)

입력 밸리데이션으로 과도한 길이로 인한 500을 예방하세요.

 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.PositiveOrZero;
+import jakarta.validation.constraints.Size;

 ...
 	@NotBlank(message = "제목은 필수입니다.")
+	@Size(max = 100, message = "제목은 최대 100자까지 가능합니다.")
 	@Schema(description = "게시물 제목", example = "멋진 AI 아트")
 	String title,
 
 	@NotBlank(message = "설명은 필수입니다.")
+	@Size(max = 1000, message = "설명은 최대 1000자까지 가능합니다.")
 	@Schema(description = "게시물 설명", example = "AI로 생성한 아름다운 작품입니다.")
 	String description,

실제 DB 컬럼 길이에 맞춰 max 값을 조정해 주세요.

src/main/java/hanium/modic/backend/domain/post/service/PostService.java (2)

329-333: AI_DERIVED 조회 시 PostStatus 필터링 바로 반영 제안

운영 노출 안전성을 위해 기본값으로 APPROVED만 노출하는 것이 안전합니다. 리포지토리 메서드 추가 후 아래처럼 적용을 권장합니다.

-            case AI_DERIVED -> postEntityRepository.findAllByIsAiDerivedPost(true, pageable);
+            case AI_DERIVED -> postEntityRepository
+                .findAllByIsAiDerivedPostAndDerivedPostStatusIn(true, Set.of(PostStatus.APPROVED), pageable);

추가 코드(별도 위치):

  • import: PostStatus, java.util.Set
  • PostEntityRepository: Page findAllByIsAiDerivedPostAndDerivedPostStatusIn(boolean isDerived, Set statuses, Pageable pageable);

164-166: 주석-구현 불일치

주석은 null로 설정이라 되어 있으나 실제 값은 false입니다. 주석을 현실에 맞게 수정하거나, 진짜 삼상 로직이 필요하면 null 사용으로 변경하세요.

-        // 비로그인 사용자이므로 좋아요 여부는 null로 설정
+        // 비로그인 사용자이므로 좋아요 여부는 false로 설정
         Boolean isLikedByCurrentUser = false;
src/main/java/hanium/modic/backend/domain/vote/enums/VoteStatus.java (1)

3-8: 상태 전이 기준 주석 보강 제안

클라이언트/운영 모니터링 시 해석력을 높이기 위해 상수별 의미를 명시해 두면 좋습니다.

 public enum VoteStatus {
-    PENDING,
-    IN_PROGRESS,
-    COMPLETED,
-    CANCELLED
+    /** 생성만 되었고 시작 조건(예: 최소 표시 수) 미충족 */
+    PENDING,
+    /** 참여 가능(랜덤 조회 대상) */
+    IN_PROGRESS,
+    /** 최종 결정/집계 완료 */
+    COMPLETED,
+    /** 관리자/시스템에 의해 중도 취소 */
+    CANCELLED
 }
src/main/java/hanium/modic/backend/web/vote/dto/response/VoteDetailResponse.java (2)

11-16: OpenAPI에 URL 필드 형식 명시(URI) 권장.
클라이언트 스키마 가독성을 위해 format="uri"를 부여하세요.

다음 변경을 고려해 주세요:

-    @Schema(description = "원본 이미지 URL (A)", example = "https://cloudfront.example.com/original-image.jpg")
+    @Schema(description = "원본 이미지 URL (A)", example = "https://cloudfront.example.com/original-image.jpg", format = "uri")
     String originalImageUrl,

-    @Schema(description = "생성된 이미지 URL (B)", example = "https://cloudfront.example.com/derived-image.jpg")
+    @Schema(description = "생성된 이미지 URL (B)", example = "https://cloudfront.example.com/derived-image.jpg", format = "uri")
     String derivedImageUrl,

26-27: status 예시값 제공 권장.
스웨거 예시값이 있으면 문서 소비자가 상태를 이해하기 쉽습니다.

-    @Schema(description = "투표 상태")
+    @Schema(description = "투표 상태", example = "IN_PROGRESS")
     VoteStatus status
src/main/java/hanium/modic/backend/web/vote/dto/response/VoteSummaryResponse.java (1)

8-15: DTO 문서화 정합성: OpenAPI 스키마 주석 추가 권장.
VoteDetailResponse와 문서 스タ일을 맞추면 API 일관성이 좋아집니다.

+import io.swagger.v3.oas.annotations.media.Schema;
 
-public record VoteSummaryResponse(
-    Long voteId,
-    Long approveWeight,
-    Long denyWeight,
-    Long totalWeight,
-    VoteDecision finalDecision,
-    VoteStatus status
-) {
+@Schema(description = "투표 요약 정보 응답")
+public record VoteSummaryResponse(
+    @Schema(description = "투표 ID", example = "1") Long voteId,
+    @Schema(description = "찬성 가중치", example = "25") Long approveWeight,
+    @Schema(description = "반대 가중치", example = "15") Long denyWeight,
+    @Schema(description = "총 가중치", example = "40") Long totalWeight,
+    @Schema(description = "최종 결정", example = "APPROVE") VoteDecision finalDecision,
+    @Schema(description = "투표 상태", example = "IN_PROGRESS") VoteStatus status
+) {
src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java (2)

52-55: derived_post_status 칼럼 기본/널 전략 점검.
AI 파생 게시물만 상태가 의미가 있으므로 널 허용은 합리적입니다. 다만 생성 시 기본값(PENDING) 미지정이면 상태 전파 전까지 null일 수 있습니다.


56-77: 생성 시 derivedPostStatus 기본값을 PENDING으로 설정하고 PrePersist 안전망 추가
AiDerivedPostService가 이미 PENDING으로 설정하고 있음(src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java), 그러나 테스트의 PostFactory 등 일부 생성 경로에서는 derivedPostStatus를 설정하지 않음(src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java). src/main/resources에서 마이그레이션 스크립트는 발견되지 않음. 방어적으로 아래 변경 적용 권장.

-        this.derivedPostStatus = derivedPostStatus;
+        this.derivedPostStatus = derivedPostStatus != null
+            ? derivedPostStatus
+            : (Boolean.TRUE.equals(this.isAiDerivedPost) ? PostStatus.PENDING : null);
// 클래스 내부에 추가
@jakarta.persistence.PrePersist
private void prePersist() {
    if (Boolean.TRUE.equals(isAiDerivedPost) && derivedPostStatus == null) {
        derivedPostStatus = PostStatus.PENDING;
    }
}
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)

31-31: 투표 요약 락에 명시적 LockOptions 추가 권장

검증 결과: voteSummaryLock은 VotingService.participateVote에서 호출되며, DistributionLockExecutor의 기본 DEFAULT_OPTS는 waitTime=3s·leaseTime=10s입니다. postLike는 POST_LIKE_OPTS(wait=1s·lease=3s)를 사용 중이므로 voteSummary도 짧은 wait/lease를 명시해 잠금 고착 위험을 줄이길 권장합니다.

 private final String AI_PERMISSION_PREFIX = "lock:ai:perm:";
 private final String VOTE_SUMMARY_PREFIX = "lock:vote:summary:";
+private final LockOptions VOTE_SUMMARY_OPTS =
+    LockOptions.builder().waitTime(1).leaseTime(3).timeUnit(TimeUnit.SECONDS).build();

 public void voteSummaryLock(long voteId, Runnable block) throws LockException {
-    exec.withLock(VOTE_SUMMARY_PREFIX + voteId, block);
+    exec.withLock(VOTE_SUMMARY_PREFIX + voteId, VOTE_SUMMARY_OPTS, block);
 }
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (2)

69-73: updateWeights 메서드에 null 방어 로직 추가 필요

updateWeights 메서드가 null 파라미터를 받을 경우 NPE가 발생할 수 있습니다. 생성자에서는 null을 0L로 처리하지만, 이 메서드에서는 null 체크가 없습니다.

다음과 같이 수정하세요:

 public void updateWeights(Long approveWeight, Long denyWeight) {
-    this.approveWeight = approveWeight;
-    this.denyWeight = denyWeight;
-    this.totalWeight = approveWeight + denyWeight;
+    this.approveWeight = approveWeight != null ? approveWeight : 0L;
+    this.denyWeight = denyWeight != null ? denyWeight : 0L;
+    this.totalWeight = this.approveWeight + this.denyWeight;
 }

75-83: 가중치 추가 메서드에 null 및 음수 검증 필요

addApproveWeightaddDenyWeight 메서드에서 null이나 음수 weight를 받을 경우 데이터 무결성 문제가 발생할 수 있습니다.

다음과 같이 개선하면 더 안전합니다:

 public void addApproveWeight(Long weight) {
+    if (weight == null || weight < 0) {
+        return;
+    }
     this.approveWeight += weight;
     this.totalWeight += weight;
 }

 public void addDenyWeight(Long weight) {
+    if (weight == null || weight < 0) {
+        return;
+    }
     this.denyWeight += weight;
     this.totalWeight += weight;
 }
src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java (1)

114-121: VoteSummary 초기화 시 null 대신 0L 사용

Builder 패턴 사용 시 weight 필드들을 명시적으로 0L로 설정하고 있는데, 엔티티 생성자에서 이미 null 체크를 하고 있으므로 중복됩니다.

다음과 같이 간소화할 수 있습니다:

 SimilarityVoteSummaryEntity voteSummary = SimilarityVoteSummaryEntity.builder()
     .voteId(savedVote.getId())
-    .approveWeight(0L)
-    .denyWeight(0L)
-    .totalWeight(0L)
     .aiDecision(VoteDecision.PENDING)
     .finalDecision(VoteDecision.PENDING)
     .build();
src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (2)

112-116: weight 타입 캐스팅 안전성 검토

Line 113, 115에서 intlong으로 캐스팅하고 있는데, VoteProperties에서 weight를 int로 정의한 이유가 있는지 확인이 필요합니다.

VoteProperties의 weight 필드들을 long 타입으로 변경하면 불필요한 캐스팅을 제거할 수 있습니다:

 if (decision == VoteDecision.APPROVE) {
-    summary.addApproveWeight((long)weight);
+    summary.addApproveWeight(weight);
 } else {
-    summary.addDenyWeight((long)weight);
+    summary.addDenyWeight(weight);
 }

171-175: 파생 포스트 유효성 검증 로직 개선 제안

Line 172에서 PostEntity가 null인지 체크한 후 getIsAiDerivedPost()를 호출하는데, orElse(null) 대신 Optional을 활용하면 더 명확합니다.

다음과 같이 개선할 수 있습니다:

-PostEntity derivedPost = postEntityRepository.findById(derivedPostId).orElse(null);
-if (derivedPost == null || !derivedPost.getIsAiDerivedPost()) {
+Optional<PostEntity> derivedPostOpt = postEntityRepository.findById(derivedPostId);
+if (derivedPostOpt.isEmpty() || !derivedPostOpt.get().getIsAiDerivedPost()) {
     log.warn("유효하지 않은 파생 포스트: postId={}, voteId={}", derivedPostId, voteId);
     return;
 }
+PostEntity derivedPost = derivedPostOpt.get();
src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java (2)

61-63: 이미지 미존재 시 예외 코드 부적합

원본/파생 이미지가 없을 때 VOTE_NOT_FOUND_EXCEPTION을 던지면 원인 파악이 어렵습니다. IMAGE_NOT_FOUND_EXCEPTION으로 구분하는 편이 진단/모니터링에 유리합니다.

다음 변경을 제안합니다:

- .orElseThrow(() -> new AppException(VOTE_NOT_FOUND_EXCEPTION));
+ .orElseThrow(() -> new AppException(IMAGE_NOT_FOUND_EXCEPTION));

(두 위치 모두 동일)

Also applies to: 67-69


51-81: 쿼리 왕복 최소화 (옵션)

vote, summary, imagePath 두 건을 순차 조회합니다. 단건 랜덤 조회이긴 하나 트래픽이 늘면 합성 프로젝션으로 한 번에 가져오거나, 리포지토리에서 JOIN + projection DTO로 경로 두 개(imagePath)와 집계(approve/deny/total)를 묶어 반환하면 효율이 좋아집니다.

원하시면 projection 인터페이스/DTO 기반 Native/JPQL 예시를 드리겠습니다.

src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (2)

37-46: 집계 성능을 위한 복합 인덱스 추가 제안

countByVoteIdAndDecision 사용을 고려하면 (vote_id, decision) 복합 인덱스를 추가하면 카디널리티가 좋아져 집계가 빨라집니다.

다음 변경을 권장합니다:

 @Table(name = "similarity_vote_result",
-   indexes = {
+   indexes = {
      @Index(name = "idx_similarity_vote_result_vote_id", columnList = "vote_id"),
-     @Index(name = "idx_similarity_vote_result_user_id", columnList = "user_id")
+     @Index(name = "idx_similarity_vote_result_user_id", columnList = "user_id"),
+     @Index(name = "idx_similarity_vote_result_vote_decision", columnList = "vote_id, decision")
    },

43-46: Result에는 PENDING 저장 방지 필요

결과 테이블의 decision은 APPROVE/DENY만 허용되는 것이 자연스럽습니다. 서비스 레벨에서 PENDING 저장을 금지하거나, JPA/DB 제약(검증)으로 방어하는 것을 권합니다.

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1)

31-34: 주석 정확도 수정

“(APPROVE)”로 한정되어 있으나 메서드는 모든 결정값을 카운트합니다. 주석을 일반화하세요.

src/main/java/hanium/modic/backend/web/vote/controller/VoteController.java (2)

38-45: 사용자 맥락 고려 옵션

“참여 가능한” 의미가 사용자별이라면 @currentuser를 수신해 본인이 이미 참여/소유한 투표를 제외하는 옵션을 추가하는 것이 좋습니다(쿼리에서 NOT EXISTS subquery).


58-67: 중복된 @apiresponse 정리 (문서 품질)

동일 responseCode가 여러 개로 중복 표기되어 Swagger UI에서 덮어써질 수 있습니다. @ApiResponses로 묶는 것을 권장합니다.

예시:

-  @ApiResponse(responseCode = "400", description = "이미 투표에 참여했습니다.[V-002]")
-  @ApiResponse(responseCode = "400", description = "진행 중인 투표가 아닙니다.[V-005]")
-  @ApiResponse(responseCode = "400", description = "일일 투표 한도를 초과했습니다.[V-006]")
+  @ApiResponses({
+    @ApiResponse(responseCode = "400", description = "이미 투표에 참여했습니다.[V-002]"),
+    @ApiResponse(responseCode = "400", description = "진행 중인 투표가 아닙니다.[V-005]"),
+    @ApiResponse(responseCode = "400", description = "일일 투표 한도를 초과했습니다.[V-006]")
+  })
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (3)

65-67: Enum 비교의 타입 안정성

문자열 리터럴 대신 파라미터 바인딩 또는 정규화된 enum 참조가 더 안전합니다.

예시:

- @Query("SELECT COUNT(sv) FROM SimilarityVoteEntity sv WHERE sv.status = 'IN_PROGRESS'")
+ @Query("SELECT COUNT(sv) FROM SimilarityVoteEntity sv WHERE sv.status = :status")
+ long countInProgressVotes(@Param("status") VoteStatus status);

호출부: countInProgressVotes(VoteStatus.IN_PROGRESS)


21-32: 삭제 권장 — findRandomVotesForParticipation(Pageable) (미사용)
rg 검색 결과 선언부(src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java:31) 외 호출처 없음. 미사용이면 제거해 복잡성 낮추기.


37-43: ORDER BY RAND() 성능 이슈 — 리팩터 권장

전체 테이블 정렬을 유발해 대규모에서 비용이 큽니다. 단건 랜덤 조회 목적이면 아래 대안 중 하나를 적용하세요:

  • 근사 샘플링: MAX(id) 기반 랜덤 id 시도 → 해당 id가 없으면 재시도( PK 갭 처리 필요 ).
  • 사전 샘플링 풀: 최근 N건 또는 상태별 샘플 테이블/캐시를 유지하고 그 안에서 RAND() 적용.
  • COUNT 캐시 + OFFSET 랜덤: 상태별 건수(캐시 또는 허용 오차)로 랜덤 오프셋 산출 후 LIMIT 1.

현재 트래픽/행 수에서 병목이 아니면 당장은 유지 가능하나, 쿼리 응답시간·테이블 크기 증가 시 리팩터 대상입니다.

Location: src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (lines 37–43)

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 998cbe1 and a45bd6f.

📒 Files selected for processing (26)
  • src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1 hunks)
  • src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java (2 hunks)
  • src/main/java/hanium/modic/backend/common/property/property/VoteProperties.java (1 hunks)
  • src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (2 hunks)
  • src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java (5 hunks)
  • src/main/java/hanium/modic/backend/domain/post/enums/PostStatus.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java (6 hunks)
  • src/main/java/hanium/modic/backend/domain/post/service/PostService.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/enums/VoteDecision.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/enums/VoteStatus.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/enums/VoteType.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/post/controller/AiDerivedPostController.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/post/dto/request/CreateAiDerivedPostRequest.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/vote/controller/VoteController.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/vote/dto/request/VoteParticipationRequest.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/vote/dto/response/VoteDetailResponse.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/vote/dto/response/VoteParticipationResponse.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/vote/dto/response/VoteSummaryResponse.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (2)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
  • Slf4j (17-61)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (2)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (1)
  • Table (19-97)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java (1)
  • Table (20-70)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (3)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java (1)
  • Table (20-70)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (1)
  • Table (20-57)
src/main/java/hanium/modic/backend/common/property/property/VoteProperties.java (1)
  • Getter (8-18)
src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java (1)
src/main/java/hanium/modic/backend/domain/post/service/PostService.java (1)
  • Service (37-335)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java (2)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (1)
  • Table (19-97)
src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (1)
  • Table (20-57)
🔇 Additional comments (21)
src/main/java/hanium/modic/backend/domain/vote/enums/VoteType.java (1)

3-5: 단일 투표 타입 정의 LGTM

현재 요구사항에 부합합니다. 추후 타입 확장 시 DTO/엔티티 직렬화 전략(STRING) 일관성만 유지하면 됩니다.

src/main/java/hanium/modic/backend/domain/post/enums/PostStatus.java (1)

7-22: 마이그레이션: derived_post_status 기본값(PENDING) 및 NOT NULL 제약 적용 필요

  • 검증: src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java에 derivedPostStatus(@column name="derived_post_status", @Enumerated(EnumType.STRING))가 추가되어 있고, src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java에서 생성 시 .derivedPostStatus(PostStatus.PENDING)로 설정되며, src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java에서 투표 완료 후 APPROVED/REJECTED로 갱신됨.
  • 조치: 기존 DB 레코드에 대해 derived_post_status를 'PENDING'으로 백필(backfill)한 뒤(또는 임시로 nullable 허용), DEFAULT 'PENDING' 및 NOT NULL 제약을 마이그레이션으로 적용할 것. 또는 백필을 생략하면 코드에서 null 처리 로직을 명확히 추가할 것.
  • 참조 파일: PostEntity.java, AiDerivedPostService.java, VotingService.java
src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1)

89-99: 에러 코드 정합성 확인 결과 및 조치 요청

  • 중복 코드 문자열 없음.
  • AI_IMAGE_DATA_INCONSISTENCY의 코드 문자열이 "A-003"입니다 — AI 계열 접두사("AI-00x")와 불일치하니 "AI-003"으로 교정 권장. (src/main/java/hanium/modic/backend/common/error/ErrorCode.java:77)
  • NO_AVAILABLE_VOTES_EXCEPTION은 현재 HttpStatus.NOT_FOUND(404)입니다 — API 소비자 관점에서 204 No Content도 고려 가능하므로 팀 컨벤션(404 vs 204) 확인 필요.
src/main/java/hanium/modic/backend/common/property/config/PropertyConfig.java (2)

15-15: VoteProperties 등록 적절합니다 (바인딩 활성화).
스프링 부트 바인딩 대상에 정상적으로 추가됐습니다.


28-30: 프로퍼티 존재/초기값 검증 필요

저장소 루트에서 아래 명령을 실행해 application*.yml/.properties와 VoteProperties 사용처 및 vote.similarity 참조를 재검증하세요. "No files were searched"가 계속 나오면 rg --debug 출력도 첨부하세요.

#!/bin/bash
set -euo pipefail
echo "== properties (application*.yml / application*.properties) =="
rg -n -S --hidden --no-ignore --glob 'application*.yml' --glob 'application*.yaml' --glob 'application*.properties' -e 'vote\.similarity' -C2 || true
echo
echo "== Java usages (VoteProperties) =="
rg -n -S --hidden --no-ignore -g '!**/build/**' -e '\bVoteProperties\b' -C2 || true
echo
echo "== Any code refs to vote.similarity =="
rg -n -S --hidden --no-ignore -g '!**/build/**' -e 'vote\.similarity' -C2 || true
src/main/java/hanium/modic/backend/web/post/controller/AiDerivedPostController.java (1)

48-48: originalImageId 전달 추가에 따른 검증/권한 체크 확인.
서비스 계층에서 원본 이미지 존재 여부 및 접근 권한(작성자 소유/공개 범위 등)을 검증하는지 확인해 주세요. DTO에 @NotNull 적용 여부도 함께 점검을 권장합니다.

src/main/java/hanium/modic/backend/web/vote/dto/request/VoteParticipationRequest.java (1)

10-12: LGTM – 필수 필드 검증과 스키마 정의 적절.
요청 페이로드 최소 검증 충족합니다.

src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java (1)

89-96: updateFinalDecision 메서드의 동점 처리 로직 검토

Line 92에서 approveWeight > denyWeight 조건으로 최종 결정을 내리는데, 동점일 경우 DENY로 결정됩니다. 이것이 의도된 동작인지 확인이 필요합니다.

비즈니스 요구사항에서 동점 처리 방식이 명시되어 있는지 확인해 주세요. 만약 동점을 별도로 처리해야 한다면 다음과 같이 수정할 수 있습니다:

 if (this.totalWeight >= minTotalWeight) {
-    this.finalDecision = this.approveWeight > this.denyWeight ? VoteDecision.APPROVE : VoteDecision.DENY;
+    if (this.approveWeight > this.denyWeight) {
+        this.finalDecision = VoteDecision.APPROVE;
+    } else if (this.approveWeight < this.denyWeight) {
+        this.finalDecision = VoteDecision.DENY;
+    } else {
+        // 동점 처리 로직 (예: PENDING 유지 또는 별도 상태)
+        this.finalDecision = VoteDecision.PENDING;
+    }
 } else {
     this.finalDecision = VoteDecision.PENDING;
 }
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java (1)

11-50: 리포지토리 메서드들이 잘 구성되어 있습니다

JPQL 쿼리가 명확하고 인덱스도 적절히 활용할 수 있도록 구성되어 있습니다. 특히 voteId로 조회하는 쿼리들이 인덱스를 잘 활용할 것으로 보입니다.

src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java (1)

102-124: 투표 엔티티 생성 로직이 명확하게 구현되었습니다

SimilarityVoteEntity와 SimilarityVoteSummaryEntity를 생성하고 초기화하는 로직이 잘 구성되어 있습니다. PENDING 상태로 시작하여 추후 투표 진행을 위한 준비가 완료됩니다.

src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java (1)

1-70: 투표 엔티티가 잘 설계되었습니다

테이블 인덱스가 적절히 설정되어 있고, 필요한 필드들이 명확하게 정의되어 있습니다. 특히 status로 조회하는 경우가 많을 것으로 예상되는데 인덱스가 잘 준비되어 있습니다.

src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (3)

52-98: 투표 참여 메서드의 동시성 제어가 잘 구현되었습니다

분산 락을 사용하여 동시성 문제를 방지하고, 순차적으로 검증 단계를 거치는 구조가 견고합니다.


157-196: 파생 포스트 상태 업데이트 예외 처리가 적절합니다

투표 완료는 성공했지만 포스트 업데이트가 실패한 경우 로그만 남기고 진행하는 방식이 합리적입니다. 투표 결과는 이미 저장되었으므로 추후 재처리가 가능합니다.


205-227: 투표 권한 검증 로직이 명확하게 구현되었습니다

파생 이미지 생성자와 원작 이미지 소유자를 구분하여 검증하는 로직이 잘 구성되어 있고, 로그도 적절히 남기고 있습니다.

src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java (3)

37-45: 집계 조회 흐름 깔끔합니다

엔티티 존재 검증 → 요약 조회 → DTO 변환까지 읽기 전용 트랜잭션에 잘 맞습니다.


51-55: “참여 가능한”의 사용자 맥락 불명확

현재 엔드포인트/서비스가 사용자 컨텍스트 없이 전역 IN_PROGRESS 중 임의 1건만 반환합니다. “이미 내가 참여한 투표 제외”나 “자기 컨텐츠 투표 제외” 요구가 있으면 필터링 조건/파라미터가 필요합니다.

원하시면 @currentuser를 받아 SimilarityVoteResult 기준으로 제외하는 버전도 제안하겠습니다.


64-71: presigned URL 보장 확인

ImageUtil.createImageGetUrl이 만료 시간/권한이 포함된 presigned GET URL을 생성하는지 확인 필요합니다. 단순 공개 URL이면 문서/설명과 불일치할 수 있습니다.

src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java (1)

20-27: 유니크 제약과 인덱스 구성 합리적

(vote_id, user_id) 유니크와 각 단일 컬럼 인덱스가 조회 패턴과 잘 맞습니다.

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1)

52-53: Distinct 카운트 쿼리 적합

JPQL DISTINCT 카운트 사용 적절합니다. 상응 인덱스(user_id, vote_id)가 이미 정의되어 있어 비용도 낮습니다.

src/main/java/hanium/modic/backend/web/vote/controller/VoteController.java (2)

38-45: 랜덤 조회 엔드포인트 OK

쿼리 서비스 위임과 AppResponse 래핑이 일관적입니다.


47-56: 결과 조회 엔드포인트 OK

유효성 검사(@min) 및 에러 스펙 문서화가 적절합니다.

Comment on lines 25 to 32
@Query(value = "SELECT * FROM similarity_vote sv " +
"WHERE sv.status = 'IN_PROGRESS' " +
"ORDER BY RAND() " +
"LIMIT ?1",
countQuery = "SELECT COUNT(*) FROM similarity_vote sv WHERE sv.status = 'IN_PROGRESS'",
nativeQuery = true)
Page<SimilarityVoteEntity> findRandomVotesForParticipation(Pageable pageable);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Pageable과 native LIMIT 혼용 오류

Pageable을 인자로 받는 native 쿼리에 “LIMIT ?1”을 넣으면 ?1에는 Pageable 객체가 바인딩되어 실행 오류가 납니다. LIMIT 절은 제거하고 Pageable에게 맡기세요.

다음 수정이 필요합니다:

- @Query(value = "SELECT * FROM similarity_vote sv " +
-   "WHERE sv.status = 'IN_PROGRESS' " +
-   "ORDER BY RAND() " +
-   "LIMIT ?1",
+ @Query(value = "SELECT * FROM similarity_vote sv " +
+   "WHERE sv.status = 'IN_PROGRESS' " +
+   "ORDER BY RAND()",
   countQuery = "SELECT COUNT(*) FROM similarity_vote sv WHERE sv.status = 'IN_PROGRESS'",
   nativeQuery = true)
 Page<SimilarityVoteEntity> findRandomVotesForParticipation(Pageable pageable);
📝 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.

Suggested change
@Query(value = "SELECT * FROM similarity_vote sv " +
"WHERE sv.status = 'IN_PROGRESS' " +
"ORDER BY RAND() " +
"LIMIT ?1",
countQuery = "SELECT COUNT(*) FROM similarity_vote sv WHERE sv.status = 'IN_PROGRESS'",
nativeQuery = true)
Page<SimilarityVoteEntity> findRandomVotesForParticipation(Pageable pageable);
@Query(value = "SELECT * FROM similarity_vote sv " +
"WHERE sv.status = 'IN_PROGRESS' " +
"ORDER BY RAND()",
countQuery = "SELECT COUNT(*) FROM similarity_vote sv WHERE sv.status = 'IN_PROGRESS'",
nativeQuery = true)
Page<SimilarityVoteEntity> findRandomVotesForParticipation(Pageable pageable);
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java
around lines 25-32, the native query incorrectly includes "LIMIT ?1" which binds
the Pageable object to the LIMIT parameter and causes runtime errors; remove the
"LIMIT ?1" clause and let Spring Data handle pagination via the Pageable
argument (keep the ORDER BY RAND() and the countQuery as-is), so the query
becomes a native SELECT without LIMIT and the repository method continues to
accept Pageable to produce a Page result.

@goalSetter09 goalSetter09 changed the title ✨ feat: 랜덤 투표 조회 API 구현 ✨ feat: AI 파생 이미지 유사성 판단 투표 시스템 구현 Sep 14, 2025
- createAiDerivedPost 메서드에 originalImageId 파라미터를 추가하여 파생 게시물 생성 시 원본 이미지 ID를 포함하도록 수정
- 관련 테스트 및 통합 테스트에서 originalImageId를 반영하여 요청 구조를 업데이트
- 유효성 검사 및 예외 처리 로직 개선으로 안정성 향상

이 변경 사항은 파생 게시물 생성 시 원본 이미지와의 연관성을 명확히 하여 기능을 강화합니다.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (3)
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1)

20-26: DATE() 제거 및 범위 조건 전환 굿.

벤더 의존 제거와 인덱스 활용에 유리합니다. 이 구간은 그대로 가시죠.

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (2)

32-35: 비연관 엔티티 JOIN ... ON — JPQL 이식성 문제. 크로스 조인/연관 탐색으로 교체.

Hibernate 의존을 줄이려면 다음처럼 수정하세요.

-	@Query("SELECT cai.userId FROM SimilarityVoteEntity sv " +
-		"JOIN CreatedAiImageEntity cai ON cai.id = sv.derivedImageId " +
-		"WHERE sv.id = :voteId")
+	@Query("SELECT cai.userId FROM SimilarityVoteEntity sv, CreatedAiImageEntity cai " +
+		"WHERE cai.id = sv.derivedImageId AND sv.id = :voteId")
 	Optional<Long> findDerivedImageCreatorId(@Param("voteId") Long voteId);

42-46: 동일 이슈: PostImageEntity/PostEntity 조인도 크로스 조인으로.

-	@Query("SELECT p.userId FROM SimilarityVoteEntity sv " +
-		"JOIN PostImageEntity pi ON pi.id = sv.originalImageId " +
-		"JOIN PostEntity p ON p.id = pi.postId " +
-		"WHERE sv.id = :voteId")
+	@Query("SELECT p.userId FROM SimilarityVoteEntity sv, PostImageEntity pi, PostEntity p " +
+		"WHERE pi.id = sv.originalImageId AND p.id = pi.postId AND sv.id = :voteId")
 	Optional<Long> findOriginalImageOwnerId(@Param("voteId") Long voteId);
🧹 Nitpick comments (13)
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (3)

76-85: originalImageId 1L 하드코딩 → 실제 픽스처 사용 권장

여러 테스트에서 originalImageId를 1L로 하드코딩했습니다. 존재 검증/소유권 검증이 추가될 경우 취약합니다. 테스트 픽스처로 “실제 원본 이미지 레코드”를 생성하고 그 ID를 사용해주세요. 동시에 생성 성공 시 다음 부수효과도 검증하는 것이 좋습니다:

  • 생성된 Post의 derivedPostStatus 초기값(PENDING 등)
  • 자동 생성된 유사성 투표(SimilarityVoteEntity) 존재 여부

필드가 필수로 바뀐 취지를 end-to-end로 보장할 수 있습니다.

원하시면 레포 내 enum/엔티티 정의를 스캔해 초기 상태/연관 엔티티 명세를 확인하는 스크립트를 제공할게요.

Also applies to: 130-139, 183-192


270-270: 의미 없는 반환값 호출

ContextHolderUtil.getCurrentUser(); 호출 결과를 사용하지 않습니다. 시큐리티 컨텍스트 준비가 목적이라면 주석으로 의도를 남기거나 제거해도 동작에 영향 없습니다.


355-363: 다중 오류 메시지 비교 방식 적절

Matchers.hasItems를 사용해 순서 의존성을 제거한 점 좋습니다. 동일 패턴을 단위 테스트에도 적용해 일관성 유지 권장합니다.

src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerTest.java (2)

55-56: 검증 실패 메시지 비교 시 순서 의존성 제거 권장

첫 번째 원소(reason[0]) 고정 비교는 검증 순서 변경에 취약합니다. 포함 여부로 검증하도록 바꾸세요.

다음 변경을 적용하세요:

-            .andExpect(jsonPath("$.reason[0]").value(expectedErrorMessage));
+            .andExpect(jsonPath("$.reason", org.hamcrest.Matchers.hasItem(expectedErrorMessage)));

또는 상단에 임포트를 추가한 뒤 Matchers.hasItem을 사용하세요:

+import org.hamcrest.Matchers;
...
-            .andExpect(jsonPath("$.reason[0]").value(expectedErrorMessage));
+            .andExpect(jsonPath("$.reason", Matchers.hasItem(expectedErrorMessage)));

66-73: originalImageId 누락 케이스 추가 권장

유효성 실패 케이스에 originalImageId 누락이 없습니다(통합 테스트에는 존재). 단위 테스트에서도 동일 케이스를 추가해 컨트롤러 계층의 검증 일관성을 확보하세요.

다음 항목을 invalidCreateAiDerivedPostRequests()에 추가하세요:

       return Stream.of(
+        Arguments.of(
+          new CreateAiDerivedPostRequest(
+            1L,
+            null,     // 원본 이미지 ID 누락
+            "AI Generated Post",
+            "This is an AI derived post",
+            2000L,
+            1000L,
+            300L
+          ),
+          "원본 이미지 ID는 필수입니다.",
+          "원본 이미지 ID 누락"
+        ),

Also applies to: 79-86, 92-99, 105-112, 118-125, 131-138, 144-151, 157-164, 170-177, 183-190, 196-203

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java (1)

24-24: 타입 정합성: minTotalWeight를 long으로.

엔티티의 totalWeight가 long(추정)이라면 파라미터도 long으로 맞추세요. 불필요한 박싱/캐스팅을 줄입니다.

src/test/java/hanium/modic/backend/domain/ai/service/AiDerivedPostServiceTest.java (3)

92-101: save 스텁에서 id 미설정 — 후속 로직 의존 시 회귀 위험.

유효한 엔티티 id/derivedPostId가 필요한 로직이 생길 수 있으니 스텁에서 id도 채워 반환하거나 캡처로 검증하세요.

-		when(similarityVoteRepository.save(any(SimilarityVoteEntity.class))).thenAnswer(invocation -> {
+		when(similarityVoteRepository.save(any(SimilarityVoteEntity.class))).thenAnswer(invocation -> {
 			SimilarityVoteEntity arg = invocation.getArgument(0);
-			return SimilarityVoteEntity.builder()
+			return SimilarityVoteEntity.builder()
+				.id(999L)
 				.originalImageId(arg.getOriginalImageId())
 				.derivedImageId(arg.getDerivedImageId())
 				.derivedPostId(1L)
 				.voteType(VoteType.SIMILARITY_CHECK)
 				.status(VoteStatus.PENDING)
 				.build();
 		});

104-106: originalImageId 전달·영속 검증 추가 제안.

ArgumentCaptor/argThat으로 save 인자가 기대값을 담는지 확인해 테스트 신뢰도를 높이세요.

ArgumentCaptor<SimilarityVoteEntity> voteCaptor = ArgumentCaptor.forClass(SimilarityVoteEntity.class);
verify(similarityVoteRepository).save(voteCaptor.capture());
assertThat(voteCaptor.getValue().getOriginalImageId()).isEqualTo(originalImageId);
assertThat(voteCaptor.getValue().getDerivedImageId()).isEqualTo(createdAiImageId);

62-67: 미사용 Mock 정리 고려.

SimilarityVoteSummaryRepository 목은 이 테스트에서 사용되지 않습니다. 불필요 목은 제거해 가독성과 유지보수를 개선하세요.

src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (3)

55-57: 입력 방어: decision null 처리.

NPE/무효 저장 방지를 위해 초기에 null 검증을 추가하세요(간단히 Objects.requireNonNull).

 public VoteParticipationResponse participateVote(Long voteId, Long userId, VoteDecision decision) {
-		try {
+		java.util.Objects.requireNonNull(decision, "decision must not be null");
+		try {

114-120: 캐스팅 제거를 위한 타입 일치.

weight를 long으로 받아 summary 내부 타입과 일치시키면 (long) 캐스팅이 불필요합니다.

-	private void updateVoteSummary(Long voteId, VoteDecision decision, int weight) {
+	private void updateVoteSummary(Long voteId, VoteDecision decision, long weight) {
...
-			summary.addApproveWeight((long)weight);
+			summary.addApproveWeight(weight);
...
-			summary.addDenyWeight((long)weight);
+			summary.addDenyWeight(weight);

249-253: 일일 한도 타임존 명시.

서버 로컬 타임존(LocalDate.now()) 의존은 오프셋 변경 시 오집계 위험이 있습니다. 애플리케이션 기본 ZoneId(설정)로 계산하세요.

-		LocalDate today = java.time.LocalDate.now();
+		ZoneId zone = voteProperties.getZoneId(); // 또는 App 공통 설정
+		LocalDate today = LocalDate.now(zone);
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1)

14-26: 권장: similarity_vote_result에 (user_id, create_at) 복합 인덱스 추가

countTodayVotesByUserId 쿼리는 userId + createAt 범위를 필터하므로, 현재의 단일 인덱스(idx_similarity_vote_result_user_id) 외에 @Index(name="idx_similarity_vote_result_user_create_at", columnList="user_id, create_at") 를 src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java의 @table(indexes={...})에 추가하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a45bd6f and 2d85758.

📒 Files selected for processing (7)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (1 hunks)
  • src/test/java/hanium/modic/backend/domain/ai/service/AiDerivedPostServiceTest.java (8 hunks)
  • src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (6 hunks)
  • src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerTest.java (15 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (1)
src/test/java/hanium/modic/backend/base/login/ContextHolderUtil.java (1)
  • ContextHolderUtil (10-25)
src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (2)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
  • Slf4j (17-61)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (8)
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (1)

337-346: 검증 케이스 확장 OK

originalImageId 누락에 대한 검증 추가가 명확합니다. 메시지(i18n) 키와 실제 문자열이 바뀔 수 있어 리소스 번역 변경 시 테스트가 쉽게 깨질 수 있습니다.

리소스 번역 키를 사용하는 경우(예: MessageSource)라면, 문자열 비교 대신 코드/키 기반 검증으로 전환할지 검토해보세요.

src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerTest.java (3)

59-60: 서비스 미호출 검증 시그니처 업데이트 LGTM

새 파라미터 반영된 시그니처로 never() 검증한 부분 문제 없습니다.


216-217: 404 시나리오 업데이트 LGTM

서비스 시그니처(추가된 originalImageId)와 매칭되는 목 설정이 정확합니다.

Also applies to: 225-228


245-246: 403 시나리오 업데이트 LGTM

동일하게 서비스 호출 인자 매칭이 정확합니다.

Also applies to: 255-258

src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (1)

58-93: 예외 전파 정책 확인.

락 블록 내부에서 발생한 AppException은 LockException과 달리 그대로 전파됩니다. 의도라면 OK, 아니라면 래핑/변환 정책을 명확히 하세요.

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java (1)

20-26: 확인 완료 — createAt 필드가 맞습니다.

BaseEntity(src/main/java/hanium/modic/backend/common/entity/BaseEntity.java:23)에 private LocalDateTime createAt;로 선언되어 있고, SimilarityVoteResultEntity(src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteResultEntity.java:31)가 BaseEntity를 상속하므로 쿼리의 svr.createAt는 유효합니다.

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (1)

20-25: 확인: Enum 저장 방식과 네이티브 쿼리 문자열이 일치합니다.

SimilarityVoteEntity.status는 @Enumerated(EnumType.STRING)로 저장되고 VoteStatus에 IN_PROGRESS가 정의되어 있으므로 네이티브 쿼리의 'IN_PROGRESS' 사용은 적절합니다. (참조: src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java, src/main/java/hanium/modic/backend/domain/vote/enums/VoteStatus.java)

src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java (1)

16-16: 검증 필요 — 엔티티에 유니크 선언은 있으나 마이그레이션/DB 반영 여부 확인 필요

src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteSummaryEntity.java에 @Index(name = "idx_similarity_vote_summary_vote_id", columnList = "vote_id")와 @column(name = "vote_id", nullable = false, unique = true)이 선언되어 있습니다.
운영 DB나 마이그레이션(Flyway/Liquibase) 파일에서 동일한 유니크 인덱스/제약이 실제로 생성되어 있는지 확인하고, 없다면 마이그레이션으로 추가하세요.

Comment on lines +20 to +25
@Query(value = "SELECT * FROM similarity_vote sv " +
"WHERE sv.status = 'IN_PROGRESS' " +
"ORDER BY RAND() " +
"LIMIT 1",
nativeQuery = true)
Optional<SimilarityVoteEntity> findRandomVoteForParticipation();
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

ORDER BY RAND() LIMIT 1은 대용량에서 병목.

테이블이 커지면 풀스캔/정렬 비용이 큽니다. min/max id 기반 랜덤 피벗 후 1건 조회 전략으로 교체를 고려하세요.

// 1) id 범위 조회 -> 랜덤 피벗 계산
// 2) 피벗 이상에서 LIMIT 1 조회, 없으면 최소 id부터 재조회 (두 번까지)

또는 Pageable 기반 native 쿼리로 랜덤 OFFSET 접근(카운트 필요)도 선택지입니다.

Comment on lines +22 to +24
@Query("SELECT COUNT(svs) > 0 FROM SimilarityVoteSummaryEntity svs " +
"WHERE svs.voteId = :voteId AND svs.totalWeight >= :minTotalWeight")
boolean isVoteCompleted(@Param("voteId") Long voteId, @Param("minTotalWeight") int minTotalWeight);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

COUNT(...) > 0 형태는 JPQL 이식성 취약 — CASE WHEN으로 교체하세요.

Hibernate 외 벤더에서 실패할 수 있습니다. 아래처럼 CASE WHEN으로 안전하게 반환하세요.

-	@Query("SELECT COUNT(svs) > 0 FROM SimilarityVoteSummaryEntity svs " +
-		"WHERE svs.voteId = :voteId AND svs.totalWeight >= :minTotalWeight")
-	boolean isVoteCompleted(@Param("voteId") Long voteId, @Param("minTotalWeight") int minTotalWeight);
+	@Query("SELECT CASE WHEN COUNT(svs) > 0 THEN true ELSE false END " +
+		"FROM SimilarityVoteSummaryEntity svs " +
+		"WHERE svs.voteId = :voteId AND svs.totalWeight >= :minTotalWeight")
+	boolean isVoteCompleted(@Param("voteId") Long voteId, @Param("minTotalWeight") long minTotalWeight);
📝 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.

Suggested change
@Query("SELECT COUNT(svs) > 0 FROM SimilarityVoteSummaryEntity svs " +
"WHERE svs.voteId = :voteId AND svs.totalWeight >= :minTotalWeight")
boolean isVoteCompleted(@Param("voteId") Long voteId, @Param("minTotalWeight") int minTotalWeight);
@Query("SELECT CASE WHEN COUNT(svs) > 0 THEN true ELSE false END " +
"FROM SimilarityVoteSummaryEntity svs " +
"WHERE svs.voteId = :voteId AND svs.totalWeight >= :minTotalWeight")
boolean isVoteCompleted(@Param("voteId") Long voteId, @Param("minTotalWeight") long minTotalWeight);
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java
around lines 22 to 24, replace the JPQL using "COUNT(...) > 0" with a CASE WHEN
form to avoid portability issues across JPA providers; change the query to
select "CASE WHEN COUNT(svs) > 0 THEN true ELSE false END" (keeping the same
WHERE clause and parameters) so the method still returns a boolean in a
vendor-safe way.

Comment on lines +134 to +151
private boolean checkAndCompleteVote(Long voteId) {
boolean isCompleted = voteSummaryRepository.isVoteCompleted(voteId, voteProperties.getMinTotalWeight());

if (isCompleted) {
// 투표 상태를 COMPLETED로 변경
SimilarityVoteEntity vote = similarityVoteRepository.findById(voteId)
.orElseThrow(() -> new AppException(VOTE_NOT_FOUND_EXCEPTION));

if (vote.getStatus() == VoteStatus.IN_PROGRESS) {
vote.updateStatus(VoteStatus.COMPLETED);
similarityVoteRepository.save(vote);
log.info("투표 완료 처리: voteId={}", voteId);

// 연결된 파생 게시물의 상태 업데이트
updateDerivedPostStatus(voteId);
}
}

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

락 보유 시간 단축: 파생 포스트 업데이트는 락 밖으로.

상태를 COMPLETED로 변경 후 파생 포스트 업데이트는 락 외부(또는 비동기)로 이동해 락 경합을 줄이세요. 현재는 네 번의 DB 접근이 락 내부에 있습니다.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java
around lines 134-151, the code performs updateDerivedPostStatus while still
inside the critical section causing long lock hold and multiple DB accesses;
change the flow so only the minimal status change and save remain inside the
lock, capture a flag (e.g., shouldUpdateDerived = true) when you transitioned
IN_PROGRESS -> COMPLETED, release the lock, and then call
updateDerivedPostStatus outside the lock (or dispatch it to an async executor)
so the expensive derived-post updates do not run while holding the lock.

- Spring AI 라이브러리 등과 충돌나므로 업그레이드 함
- Spring AI에서 의존성 충돌이 자꾸 발생해 제거했다.
yooooonshine and others added 22 commits September 15, 2025 16:04
feat: AI 채팅 기능 구현 및 주요 도메인 기능 개발
AI 생성 이미지와 원작 이미지 간의 유사성을 투표로 판단하는 시스템을 위한
기본 데이터베이스 구조 및 엔티티 클래스들을 구현했습니다.

- VoteStatus, VoteDecision, VoteType enum 클래스 생성
- SimilarityVoteEntity: 투표 정보 저장 (원작/파생 이미지 관계)
- SimilarityVoteResultEntity: 개별 사용자 투표 결과 저장
- SimilarityVoteSummaryEntity: 투표 집계 및 최종 결정 저장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
AI 생성 이미지 유사성 투표를 위한 데이터 액세스 레이어를 구현했습니다.

- SimilarityVoteRepository: 투표 기본 CRUD 및 상태별 조회
  - 랜덤 투표 목록 페이지네이션 (ORDER BY RAND())
  - 투표 권한 확인 쿼리 (이미지 소유자/생성자 체크)
- SimilarityVoteResultRepository: 개별 투표 결과 관리
  - 중복 투표 방지 및 일일 투표 제한 확인
  - 투표 통계 및 사용자별 참여 현황
- SimilarityVoteSummaryRepository: 투표 집계 및 완료 상태 관리
  - AI 판단 결과 추적 및 최종 결정 통계

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Object[] findVotePermissionInfo()를 두 개의 타입 안전한 메서드로 분리:
  - findDerivedImageCreatorId(): 파생 이미지 생성자 ID 조회 (Optional<Long>)
  - findOriginalImageOwnerId(): 원작 이미지 소유자 ID 조회 (Optional<Long>)
- VotingService.validateVotePermission() 메서드를 타입 안전한 방식으로 리팩토링
- 테스트 용이성을 위해 isRestrictedUser() 메서드 분리
- 권한 검증 단계별 상세한 에러 로깅 추가
- 런타임 캐스팅 오류 및 배열 인덱스 의존성 완전 제거
- 의미있는 변수명으로 코드 가독성 향상

개선 효과:
- 런타임 캐스팅 대신 컴파일 타임 타입 안전성 확보
- 더 나은 에러 메시지와 디버깅 기능
- 유지보수성 및 테스트 용이성 개선
- 관심사의 명확한 분리

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- VoteQueryService: 투표 관련 읽기 전용 쿼리 서비스 추가
- VoteController: 투표 결과 조회 엔드포인트 구현
- VoteSummaryResponse: 투표 집계 결과 응답 DTO 구현

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add VoteParticipationRequest DTO for vote decision input
- Add POST /api/votes/{voteId}/decisions endpoint in VoteController
- Integrate with existing VotingService for vote participation logic
- Add comprehensive Swagger documentation with all error responses
- Support APPROVE/DENY vote decisions with validation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- PostStatus Enum 추가 (PENDING, APPROVED, REJECTED)
- PostEntity에 derivedPostStatus 필드 추가 (AI 파생 게시물 상태 관리)
- PostEntity에 updateDerivedPostStatus() 메서드 추가
- CreateAiDerivedPostRequest에 originalImageId 필드 추가 (투표용 원본 이미지 선택)
- 전체 DTO에 Swagger 문서화 완성

투표 시스템과 기존 게시물 시스템 통합을 위한 기본 구조 완성

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- AiDerivedPostService에 투표 시스템 의존성 추가
- createAiDerivedPost 메서드에 originalImageId 파라미터 추가
- PostEntity를 PENDING 상태로 생성하여 투표 대기 상태 설정
- SimilarityVoteEntity 자동 생성 (PENDING 상태)
- SimilarityVoteSummaryEntity 초기화 (기본값 0)
- VoteDecision에 PENDING 상태 추가 (null 대신 명시적 상태 관리)
- updateFinalDecision 로직 개선 (투표가 없을 때 PENDING 유지)

파생 게시물 생성 시 자동으로 투표 시스템과 연동되도록 구현 완료

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- SimilarityVoteEntity에 derived_post_id 컬럼 추가
- 빌더 및 생성자에 derivedPostId 매개변수 추가
- 투표 완료 시 연결된 파생 게시물 상태 업데이트를 위한 기반 구조 마련

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- SimilarityVoteEntity 생성 시 derivedPostId 설정
- 투표와 파생 게시물 간 직접적인 연결고리 구현
- 투표 완료 후 게시물 상태 업데이트를 위한 연동 준비

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- PostEntityRepository 의존성 추가
- updateDerivedPostStatus() 메서드 구현
- checkAndCompleteVote()에 게시물 상태 업데이트 로직 통합
- 투표 결과에 따라 APPROVED/REJECTED 상태로 자동 변경
- 안전한 예외 처리로 투표 완료와 게시물 상태 업데이트 독립성 보장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- createAiDerivedPost 호출 시 request.originalImageId() 매개변수 추가
- 메서드 시그니처 불일치로 인한 컴파일 오류 수정
- 투표 시스템 연동을 위한 완전한 매개변수 전달 보장

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- VoteProperties 클래스의 가중치 필드를 Long에서 int로 수정
- SimilarityVoteSummaryEntity의 updateFinalDecision 메서드에 최소 총 가중치 매개변수 추가
- SimilarityVoteSummaryRepository의 isVoteCompleted 메서드에서 Long 타입을 int로 변경
- VotingService에서 updateFinalDecision 호출 시 임계치 기반으로 수정

가중치 처리의 일관성을 높이고 성능을 개선하기 위한 변경 사항입니다.
- VoteDetailResponse DTO 추가: 투표 상세 정보 응답 구조 정의
- GET /api/votes/random 엔드포인트 추가: 참여 가능한 랜덤 투표 1건 조회
- VoteQueryService.getRandomVoteForParticipation() 메서드 구현
- SimilarityVoteRepository에 단일 랜덤 투표 조회 메서드 추가
- ErrorCode에 V-009 참여 가능한 투표 없음 예외 추가
- AiChatImageEntity 연동을 위한 JOIN 쿼리 수정
- presigned URL 생성을 통한 원본 이미지(A)와 생성된 이미지(B) 제공

🔧 성능 최적화: Page 대신 Optional을 사용하여 불필요한 count 쿼리 제거
- AiChatImageEntity → CreatedAiImageEntity로 정정
- VoteDetailResponse 코드 포맷팅 개선
- PostService에 Todo 주석 추가 (AI_DERIVED 조회 시 PostStatus 필터링)
…한 컴파일 에러 수정

- VoteQueryService에서 CreatedAiImageEntity 참조를 AiChatImageEntity로 변경
- SimilarityVoteRepository JPQL 쿼리에서 엔티티명 업데이트
- AiDerivedPostControllerIntegrationTest에서 새로운 originalImageId 파라미터 추가
- 테스트 케이스 검증 메시지에 누락된 필드 검증 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 32

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (13)
src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/GetRemainingGenerationsResponse.java (1)

3-6: 도메인 명칭 불일치(aiImagePermissionId) — 공개 API 필드명 정합성 확보 필요

현재 시스템은 AiChatRoom 기반인데, 필드명이 과거 AiImagePermission 용어를 유지하고 있습니다. 공개 스키마 혼선을 막기 위해 출시 전 rename을 권합니다(호환 필요 시 일시적 @JsonAlias 병행).

-public record GetRemainingGenerationsResponse(
-	Long aiImagePermissionId,
-	Integer remainingGenerations
-) {
+public record GetRemainingGenerationsResponse(
+	Long roomId,
+	Integer remainingGenerations
+) {
 }

추가로 remainingGenerations는 음수가 될 수 없으므로 primitive(int)로의 전환 및 0 미만 방지 검증도 고려해 주세요.

src/main/java/hanium/modic/backend/domain/image/util/CloudFrontKeyLoader.java (1)

16-31: PKCS#1 PEM 미지원 시 서비스 기동 실패 가능 — 포맷 검증 및 에러 메시지 보강 필요

현재는 PKCS#8(-----BEGIN PRIVATE KEY-----)만 처리합니다. 운영에 PKCS#1(-----BEGIN RSA PRIVATE KEY-----) 키가 배포되면 즉시 실패합니다. 포맷 검증을 명시하고, 부적합 시 원인 로깅을 강화해 주세요. 또한 Base64 디코더에 문자열을 직접 전달하면 불필요한 인코딩을 줄일 수 있습니다.

 public static PrivateKey loadFromPem(String pem) {
-    try {
-        String privateKeyPEM = pem
+    try {
+        if (pem == null || pem.isBlank()) {
+            log.error("CloudFrontKeyLoader: 빈 PEM 입력");
+            throw new AppException(ErrorCode.S3_SERVER_ERROR);
+        }
+        String trimmed = pem.trim();
+        if (trimmed.contains("-----BEGIN RSA PRIVATE KEY-----")) {
+            // 현재 PKCS#1은 미지원. 운영에선 PKCS#8로 변환 필요.
+            // 예시: openssl pkcs8 -topk8 -inform PEM -outform PEM -in pkcs1.pem -out pkcs8.pem -nocrypt
+            log.error("CloudFrontKeyLoader: PKCS#1 형식의 RSA 개인키가 제공되었습니다. PKCS#8(-----BEGIN PRIVATE KEY-----) 형식만 지원합니다.");
+            throw new AppException(ErrorCode.S3_SERVER_ERROR);
+        }
+        String privateKeyPEM = trimmed
             .replace("-----BEGIN PRIVATE KEY-----", "")
             .replace("-----END PRIVATE KEY-----", "")
             .replaceAll("\\s", "");
 
-        byte[] decoded = Base64.getDecoder().decode(privateKeyPEM.getBytes(StandardCharsets.UTF_8));
+        byte[] decoded = Base64.getDecoder().decode(privateKeyPEM);
         KeyFactory keyFactory = KeyFactory.getInstance("RSA");
         PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded);
 
         return keyFactory.generatePrivate(keySpec);
     } catch (Exception e) {
         log.error("Failed to load CloudFront private key from PEM", e);
         throw new AppException(ErrorCode.S3_SERVER_ERROR);
     }
 }
src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewControllerIntegrationTest.java (2)

119-121: 리뷰 수정 요청에 잘못된 ID 사용 — 이미지 ID가 아닌 ‘리뷰 ID’를 전송

CreatePostReviewRequest가 이미지 ID 목록을 기대한다면 postReviewImage.getPostReviewId()가 아니라 postReviewImage.getId()를 사용해야 합니다. 현재 로직은 다른 엔드포인트 계약을 깨뜨릴 수 있습니다.

-    final CreatePostReviewRequest request = new CreatePostReviewRequest("수정된 리뷰입니다",
-        List.of(postReviewImage.getPostReviewId()));
+    final CreatePostReviewRequest request = new CreatePostReviewRequest("수정된 리뷰입니다",
+        List.of(postReviewImage.getId()));

146-148: 리뷰 수정(타인 시도) 테스트도 동일한 ID 오류

위와 동일하게 이미지 ID를 사용하세요.

-    final CreatePostReviewRequest request = new CreatePostReviewRequest("수정 시도",
-        List.of(postReviewImage.getPostReviewId()));
+    final CreatePostReviewRequest request = new CreatePostReviewRequest("수정 시도",
+        List.of(postReviewImage.getId()));
src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java (1)

47-53: 음수/0 가격 방어 없음 → 잔여 티켓 증가/논리 오류 가능.

ticketPrice가 0 이하이면 의미가 불명확하거나 오동작합니다. 양수 검증을 추가하세요.

 public void decreaseTicket(final long ticketPrice) {
+  if (ticketPrice <= 0) {
+    throw new IllegalArgumentException("ticketPrice must be positive");
+  }
   if (this.ticketCount - ticketPrice < MINIMUM_TICKET_COUNT) {
     throw new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION);
   }
   this.ticketCount -= ticketPrice;
 }
src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java (2)

20-25: 메시지 타입이 Object로 느슨함 → 생산/소비자 간 DTO 불일치 은닉 위험.

DLQ/리스너 쪽 DTO와 엄격히 일치시키는 것이 안전합니다. 현재 PR 컨텍스트상 프로듀서는 AiChatRequestDto를 전송합니다. 시그니처를 구체 타입으로 바꾸는 것을 권장합니다.

-import hanium.modic.backend.domain.ai.aiServer.dto.AiImageRequestMessageDto;
+import hanium.modic.backend.domain.ai.aiServer.dto.AiChatRequestDto;

- public void sendImageGenerationRequest(Object message) {
+ public void sendImageGenerationRequest(AiChatRequestDto message) {
   rabbitTemplate.convertAndSend(
     AI_IMAGE_REQUEST_EXCHANGE,
     AI_IMAGE_REQUEST_ROUTING_KEY,
     message);
 }

20-25: 프로듀서/컨슈머 DTO 불일치 — 정렬 필요

AiServerService가 AiChatRequestDto를 발행하지만 DLQ 리스너(DlqListener)는 AiImageRequestMessageDto를 기대합니다. RabbitTemplate에 Jackson2JsonMessageConverter가 설정되어 있어 직렬화는 통과할 수 있으나 타입·목적 불일치로 의도치 않은 동작(필드 누락·변환 오류·비즈니스 로직 불일치) 위험이 있습니다.

  • 수정 포인트

    • src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java — sendImageGenerationRequest(aiChatRequestDto) 호출
    • src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java — public void sendImageGenerationRequest(Object message) (파라미터를 구체화)
    • src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java — handleFinalFailedMessage(AiImageRequestMessageDto ...)
    • 참고: src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java — Jackson2JsonMessageConverter 설정
  • 권장 해결(택1)

    1. Producer에서 AiImageRequestMessageDto로 변환해 발행(권장).
    2. 또는 DLQ 리스너를 AiChatRequestDto(또는 공용 DTO/Map)로 변경해 requestId만 안정적으로 추출하도록 처리.
      추가: MessageQueueService 서명을 구체 DTO로 변경하고 불필요한 import 제거.
src/test/java/hanium/modic/backend/domain/ai/entityfactory/AiFactory.java (1)

12-21: 필수 필드 누락으로 엔티티 저장 시 실패 가능.

AiChatImageEntitystatus/aiChatRoomId/fromOriginImagenullable = false입니다. 팩토리에서 기본값을 세팅하세요.

+import hanium.modic.backend.domain.ai.aiServer.enums.AiImageStatus;
 ...
 public static AiChatImageEntity createMockCreatedAiImage(Long userId, Long postId, String requestId) {
   return AiChatImageEntity.builder()
     .userId(userId)
     .postId(postId)
     .imagePath("test/path/ai-image.png")
     .fullImageName("ai-image-full-name.png")
     .imageName("ai-image")
     .extension(ImageExtension.PNG)
     .imagePurpose(ImagePrefix.AI_RESPONSE)
+    .status(AiImageStatus.RESPONSE)
+    .aiChatRoomId(1L)
+    .fromOriginImage(false)
     .build();
 }
src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (2)

39-49: 중첩(재진입) 락 가능성: Deadlock/실패 위험

useTicket() 내부에서 aiRequestTicketLock을 획득한 뒤 getTicketEntity()를 호출하는데, 내부에서 다시 refreshTicketIfExpired()가 동일 락을 획득합니다. 분산락은 보통 스레드 재진입을 지원하지 않아 교착/실패 위험이 큽니다.

락 보유 중에는 재락 획득을 피하세요. 예:

lockManager.aiRequestTicketLock(userId, () -> {
-  TicketEntity userTicket = getTicketEntity(userId);
+  TicketEntity userTicket = ticketRepository.findByUserId(userId)
+      .orElseGet(() -> createInitialTicket(userId));
+  userTicket = refreshTicketIfExpiredWithoutLock(userTicket);
   userTicket.decreaseTicket(ticketPrice);
   ticketRepository.save(userTicket);
});

그리고 아래에 refreshTicketIfExpiredWithoutLock을 추가하세요(메서드 예시는 본 코멘트 하단에 첨부).


39-49: 다음 보조 메서드를 추가하세요.

// TicketService 내부에 추가
private TicketEntity refreshTicketIfExpiredWithoutLock(TicketEntity userTicket) {
  if (userTicket.isTicketExpired()) {
    userTicket.resetTickets();
    return ticketRepository.save(userTicket);
  }
  return userTicket;
}

Also applies to: 56-63

src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java (2)

99-114: 코인 부족 시에도 권한 증가가 발생 — 금전 손실 위험(선차감/보상 처리 필요)

현재 서비스는 upsertAndIncrease → consumeCoin 순서입니다. 코인 부족 예외가 나도 권한이 증가합니다. 테스트가 이 잘못된 계약을 고정(freeze)하고 있어 더 위험합니다. 권한 부여는 “결제 성공 후” 수행되거나, 실패 시 확실히 보상(롤백)되어야 합니다.

제안(서비스 로직 리팩터링; 다른 파일 변경):

// src/main/java/.../AiImagePermissionService.java
 @Transactional
 public void buyAiImagePermissionByCoin(Long userId, Long postId) {
   PostEntity post = postRepository.findById(postId)
     .orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));
-  aiChatRoomRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
-  userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+  // 1) 결제 선차감
+  userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+  // 2) 권한 증가 (예외 시 결제 보상 필요)
+  aiChatRoomRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
 }

테스트 수정:

- verify(aiChatRoomRepository).upsertAndIncrease(testUser.getId(), testPost.getId(), 20);
+ verify(aiChatRoomRepository, never()).upsertAndIncrease(anyLong(), anyLong(), anyInt());

보상 패턴이 필요하면, 2)에서 예외 시 코인 환불 API 호출을 추가하거나, 사가/아웃박스 기반으로 일관성 확보를 검토하세요.


163-178: 티켓 부족 시에도 권한 증가가 발생 — 동일 위험

티켓 부족 케이스도 upsert가 선행됩니다. 결제 실패 시 권한이 증가하지 않도록 순서를 뒤집거나 보상 로직을 도입해야 합니다. 테스트 역시 현재의 잘못된 동작을 고정합니다.

제안(서비스/테스트 동시 수정):

// 서비스
- aiChatRoomRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
- ticketService.useTicket(userId, post.getTicketPrice());
+ ticketService.useTicket(userId, post.getTicketPrice());
+ aiChatRoomRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);

// 테스트
- verify(aiChatRoomRepository).upsertAndIncrease(testUser.getId(), testPost.getId(), 20);
+ verify(aiChatRoomRepository, never()).upsertAndIncrease(anyLong(), anyLong(), anyInt());
src/main/java/hanium/modic/backend/domain/image/util/S3ImageUtil.java (1)

86-108: 여러 삭제도 동일 문제(+부분 유효성 검증 없음)

  • @async void 동일 문제.
  • 유효하지 않은 키 하나 때문에 전체 삭제가 실패할 수 있습니다. 유효한 키만 모아 부분 성공을 허용하거나 실패/성공 건수를 로깅하세요.

제안(diff: 유효키 필터 및 예외 처리 단순화):

- public void deleteImages(List<String> imagePaths) {
+ public CompletableFuture<Void> deleteImages(List<String> imagePaths) {
   if (imagePaths == null || imagePaths.isEmpty()) {
-    return;
+    return CompletableFuture.completedFuture(null);
   }
- for (String imagePath : imagePaths) { validateImagePath(imagePath); }
+ List<String> validKeys = imagePaths.stream()
+   .filter(k -> k != null && !k.isEmpty())
+   .toList();
+ if (validKeys.isEmpty()) return CompletableFuture.completedFuture(null);

- List<ObjectIdentifier> objectIdentifiers = imagePaths.stream()
+ List<ObjectIdentifier> objectIdentifiers = validKeys.stream()
    .map(key -> ObjectIdentifier.builder().key(key).build())
    .collect(Collectors.toList());
   try {
     s3Client.deleteObjects(deleteObjectsRequest);
-  } catch (S3Exception e) {
-    log.error("S3ImageUtil.deleteImages() - S3Exception: {}", e.getMessage());
-    throw new AppException(S3_SERVER_ERROR);
+  } catch (S3Exception e) {
+    log.error("S3ImageUtil.deleteImages() - S3Exception: {}", e.getMessage(), e);
   }
+  return CompletableFuture.completedFuture(null);
 }
🧹 Nitpick comments (102)
src/main/java/hanium/modic/backend/web/ai/aiServer/dto/response/SendUserMessageResponse.java (2)

1-1: 패키지명 소문자 컨벤션 권장: aiServer → aiserver
Java 패키지명은 전부 소문자가 일반적입니다. 모듈 내 네이밍 일관성도 함께 확인 부탁드립니다.

적용 예시:

-package hanium.modic.backend.web.ai.aiServer.dto.response;
+package hanium.modic.backend.web.ai.aiserver.dto.response;

(파일 경로도 동일하게 이동 필요)


3-5: requestId를 UUID로 강제하는 방안 검토
내부에서 UUID를 사용한다면 타입을 UUID로 노출해 스키마 명확성과 런타임 검증을 강화할 수 있습니다. 외부 계약이 문자열이면 현 상태 유지가 맞습니다.

적용 예시:

-package hanium.modic.backend.web.ai.aiServer.dto.response;
+package hanium.modic.backend.web.ai.aiServer.dto.response;
+import java.util.UUID;

-public record SendUserMessageResponse(
-	String requestId
-) {
+public record SendUserMessageResponse(
+	UUID requestId
+) {
 }

외부 API/클라이언트와의 계약이 UUID 문자열인지 확인 부탁드립니다.

src/main/java/hanium/modic/backend/web/ai/aiServer/dto/response/MyGeneratedAiImageResponse.java (3)

3-3: 불필요한 import 제거 (컴파일 경고 방지)

AiChatImageEntity를 사용하지 않습니다. 정리해 주세요.

-import hanium.modic.backend.domain.ai.aiServer.entity.AiChatImageEntity;

8-15: API 계약 재확인: imageId 노출 여부 확정 필요

요구사항 기준으로 이미지 ID를 외부 응답에서 숨길지/유지할지 결정이 필요합니다. 숨기는 방향이라면 아래처럼 제거해 주세요. 유지한다면 컨트롤러/문서(스웨거)와 클라이언트 계약을 일관되게 업데이트 바랍니다.

 public record MyGeneratedAiImageResponse(
-	@Schema(description = "생성된 AI 이미지 ID", example = "1") Long imageId,
-
 	@Schema(description = "이미지 조회 URL", example = "https://presigned-url.com/image.jpg") String imageUrl,
 
 	@Schema(description = "해당 이미지가 생성된 포스트 ID", example = "1") Long postId,
 
 	@Schema(description = "해당 이미지가 생성된 AI 채팅방 ID", example = "1") Long aiChatRoomId
 ) {

12-15: 필수 필드 명시로 스웨거 스키마 강화

응답에서 항상 제공되는 값이라면 requiredMode를 명시해 문서 신뢰도를 높여 주세요.

-	@Schema(description = "해당 이미지가 생성된 포스트 ID", example = "1") Long postId,
+	@Schema(description = "해당 이미지가 생성된 포스트 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) Long postId,
 
-	@Schema(description = "해당 이미지가 생성된 AI 채팅방 ID", example = "1") Long aiChatRoomId
+	@Schema(description = "해당 이미지가 생성된 AI 채팅방 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) Long aiChatRoomId
src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/BuyAiImagePermissionResponse.java (2)

7-8: Record에 @builder 필요성 재검토

record는 캐노니컬 생성자가 있어 DTO 용도라면 빌더가 과잉일 수 있습니다. Lombok 의존도/바이트코드 복잡도를 줄이기 위해 빌더 제거를 고려해 주세요(호출부에서 빌더 사용 중이면 유지).

적용 예(diff):

-@Builder
 public record BuyAiImagePermissionResponse(

9-11: Integer → int 변경 권장 — remainingGenerations

BuyAiImagePermissionResponse.remainingGenerations은 null을 기대하는 흐름이 없어 원시형 int로 변경해 널 처리 비용과 혼동을 줄이세요. 코드 검색 결과(테스트/컨트롤러)에서는 숫자 값만 사용되며, 엔티티 필드에는 @column(nullable = false)로 선언되어 있습니다.

-	@Schema(description = "남은 생성 횟수", example = "3")
-	Integer remainingGenerations
+	@Schema(description = "남은 생성 횟수", example = "3")
+	int remainingGenerations
src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewImageControllerIntegrationTest.java (2)

219-224: MIME 타입 하드코딩은 테스트 신뢰도를 떨어뜨릴 수 있음

모든 업로드에 image/jpeg을 고정하면, 확장자/콘텐츠 타입 기반 검증을 수행하는 코드에서 오검증 가능성이 있습니다(예: image.txt 케이스). 확장자에 따라 동적으로 설정하는 편이 안전합니다.

아래처럼 확장자 기반으로 contentType을 정하고 빌더에 적용해 주세요.

-   PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+   String contentType =
+       filePath.endsWith(".jpg") || filePath.endsWith(".jpeg") ? "image/jpeg" :
+       filePath.endsWith(".png") ? "image/png" :
+       filePath.endsWith(".gif") ? "image/gif" : "text/plain";
+
+   PutObjectRequest putObjectRequest = PutObjectRequest.builder()
      .bucket(s3Properties.getBucketName())
      .key(filePath)
-     .contentType("image/jpeg")
+     .contentType(contentType)
      .build();

Also applies to: 226-229


233-239: 정리(cleanup) 내구성 보강 제안

테스트 실패 시에도 S3 객체가 남지 않도록 정리를 보장하는 게 좋습니다. 두 가지 옵션:

  • 각 테스트에서 업로드 후 try/finally로 삭제 보장
  • 혹은 @AfterEach 훅에서 업로드한 키들을 일괄 삭제

또한 LocalStack/S3 호환 스토리지에서 드물게 NoSuchKey류 예외가 날 수 있으니 삭제 시 예외 무시 처리도 고려해 보세요.

예시:

@AfterEach
void tearDown() {
    // 업로드 키들을 추적했다가 여기서 deleteImageFromS3(...) 호출
}

또는 삭제 메서드 내 예외 무시:

- s3Client.deleteObject(deleteObjectRequest);
+ try {
+   s3Client.deleteObject(deleteObjectRequest);
+ } catch (software.amazon.awssdk.services.s3.model.S3Exception ignore) {
+   // 테스트 정리 단계: 키 미존재는 무시
+ }
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/ClassifyRequestCategoryDto.java (1)

3-6: 카테고리 입력은 enum으로 강제하는 것이 안전합니다

자유 문자열(String)은 오타/미지원 값 유입 위험이 있습니다. enum(RequestCategory)으로 타입을 좁혀 주세요.

 package hanium.modic.backend.domain.ai.aiServer.dto;
 
+import hanium.modic.backend.domain.ai.aiServer.enums.RequestCategory;
 
-public record ClassifyRequestCategoryDto(
-    String category
-) {
+public record ClassifyRequestCategoryDto(
+    RequestCategory category
+) {
 }
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiResponseSseService.java (3)

26-38: 중복 연결·자원 정리 고려: 동일 requestId 재구독 시 이전 Emitter 정리 필요

현재는 동일 requestId로 addEmitter를 여러 번 호출하면 이전 Emitter가 맵에서 덮어쓰기 되어 누수 가능성이 있습니다. EmitterService.addEmitter에서 이전 Emitter를 complete 처리하고 onCompletion/onTimeout/onError 콜백으로 맵 제거를 보장해 주세요(하단 EmitterService 코멘트의 diff 참조).


28-29: DB 인덱스 권장: requestId + senderType 조회

findByRequestIdAndSenderType 호출이 핫패스라면 AiChatMessage 테이블의 (request_id, sender_type) 복합 인덱스를 권장합니다. 대기열 규모가 커질수록 효과가 큽니다.

원한다면 인덱스 DDL 제안 드리겠습니다.


46-48: 이벤트 이름(name) 추가 고려

클라이언트에서 이벤트 타입으로 필터링한다면 .name("ai-response") 지정이 유용합니다. 현재는 id/data만 포함됩니다.

src/main/java/hanium/modic/backend/common/sse/service/EmitterService.java (2)

21-21: 무한 성장 방지 가드 필요

emitters 맵은 무제한 성장할 수 있습니다. Emitter 수 제한/TTL 기반 정리(스케줄러), 혹은 멀티 서버 전개 시 Redis 기반 pub/sub + 키 만료 전략을 고려해 주세요.


35-49: 클라이언트 필터링 개선 및 관측성 보강

이벤트 이름을 지정하고, emitter 미존재 시 로그 남기기를 권장합니다.

-            try {
-                emitter.send(SseEmitter.event()
-                    .id(requestId)
-                    .data(data));
+            try {
+                emitter.send(SseEmitter.event()
+                    .id(requestId)
+                    .name("ai-response")
+                    .data(data));
                 emitter.complete();
                 emitters.remove(requestId);
             } catch (IOException e) {
                 emitter.completeWithError(e);
                 emitters.remove(requestId);
             }
         }

추가로 로깅이 필요하다면 Lombok @slf4j를 사용해 아래와 같이 보완할 수 있습니다(참고 코드):

// 클래스 상단
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class EmitterService {
    // ...
    public void sendToClient(String requestId, Object data) {
        SseEmitter emitter = emitters.get(requestId);
        if (emitter == null) {
            log.warn("SSE emitter not found. requestId={}", requestId);
            return;
        }
        // ...
    }
}
src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1)

88-102: AI/Vote 에러 코드 중복 없음 확인 — 중복검사 자동화 권장

스크립트 결과: 총 64개 코드, 중복 없음. CI나 테스트 단계에 에러 코드 문자열 중복 검사 자동화 추가 권장.

src/main/java/hanium/modic/backend/domain/ai/aiServer/enums/SenderType.java (1)

6-9: ChatGPT 역할 매핑 확인 필요 (USER/AI ↔ user/assistant/system).

내부 도메인 값(USER/AI)과 외부 ChatGPT role(user/assistant/system/tool) 간 매핑 규칙이 명확해야 합니다. 변환 계층에서 누락 시 잘못된 요청이 될 수 있으니 매핑 코드/테스트 존재 여부를 확인해 주세요. 시스템/도구 메시지 필요 시 enum 확장도 고려해 주세요.

src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/chatGpt/ChatGPTMessage.java (1)

3-7: role 값 유효성 검증 추가 제안.

허용 역할만 전송되도록 레코드에 간단한 검증을 넣어 API 오용을 방지합시다.

아래와 같이 컴팩트 생성자와 허용 목록을 추가해 주세요.

 package hanium.modic.backend.domain.ai.aiServer.dto.chatGpt;

+import java.util.Objects;
+import java.util.Set;
+
 public record ChatGPTMessage(
 	String role,
 	String content
 ) {
+	private static final Set<String> ALLOWED_ROLES = Set.of("system", "user", "assistant", "tool");
+
+	public ChatGPTMessage {
+		Objects.requireNonNull(role, "role must not be null");
+		Objects.requireNonNull(content, "content must not be null");
+		if (!ALLOWED_ROLES.contains(role)) {
+			throw new IllegalArgumentException("Unsupported role: " + role);
+		}
+	}
 }
src/main/java/hanium/modic/backend/domain/postReview/service/PostReviewAuthorizationService.java (3)

42-44: 주석과 구현 불일치(AiImagePermission → AiChatRoom).

라인 주석이 이전 용어를 참조합니다. 유지보수성을 위해 최신 도메인 용어로 교체해 주세요.

- // AiImagePermission 존재 여부 확인 (해당 그림체를 사용한 이력이 있는지)
+ // AiChatRoom 존재 여부 확인 (해당 게시물과 연결된 AI 채팅 사용 이력 확인)

Also applies to: 64-66


32-44: 권한 기준 재확인: ‘채팅방 존재’가 ‘사용 이력’의 충분조건인가요?

existsByUserIdAndPostId가 “실제 사용(예: 이미지 생성/대화 참여)”을 보장하지 않을 수 있습니다. 요구사항이 “사용 이력”이라면 사용 플래그/메시지 존재 여부 등 보다 강한 조건이 필요할 수 있습니다.

인덱스(user_id, post_id) 존재 여부도 확인 부탁드립니다(없으면 exists 쿼리 성능 저하 가능).


54-68: 중복 로직 소폭 정리 제안.

post 존재 확인/자기글 검증이 두 메서드에 중복됩니다. private 헬퍼로 공통화하면 가독성과 테스트 용이성이 좋아집니다.

src/main/java/hanium/modic/backend/domain/ai/aiServer/enums/AiImageStatus.java (1)

3-8: 상태 명칭/전이 정의 명확화 제안.

REQUEST vs REQUEST_PENDING 의미가 모호합니다(요청 완료/대기 구분). 전이 다이어그램/자바독으로 상태 의미를 고정하거나, 예: REQUESTED/QUEUED/FAILED/RESPONDED 등으로 명확화하는 리네이밍을 고려해 주세요. 엔티티에서는 반드시 @Enumerated(EnumType.STRING)으로 저장해 리네이밍/추가 시 안전성을 확보하세요.

src/main/java/hanium/modic/backend/domain/ai/aiServer/enums/RequestCategory.java (1)

3-7: 확장성 고려: 카테고리 증가 여지 주석/문서화.

업스케일/편집/모더레이션 등 추가 카테고리 가능성을 주석으로 남기고, 분류 실패 시 기본값/에러 처리 정책을 명확히 해두면 방어적입니다.

src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/chatGpt/ChatGPTRequest.java (1)

5-9: 불변성/검증 강화: messages 방어적 복사 및 빈 목록 금지.

외부에서 List를 변경할 수 있습니다. 컴팩트 생성자에서 null/비어있음 검증과 List.copyOf로 방어적 복사를 해주세요.

 package hanium.modic.backend.domain.ai.aiServer.dto.chatGpt;

 import java.util.List;
+import java.util.Objects;

 public record ChatGPTRequest(
 	String model,
 	List<ChatGPTMessage> messages
 ) {
+	public ChatGPTRequest {
+		Objects.requireNonNull(model, "model must not be null");
+		Objects.requireNonNull(messages, "messages must not be null");
+		if (messages.isEmpty()) {
+			throw new IllegalArgumentException("messages must not be empty");
+		}
+		messages = List.copyOf(messages);
+	}
 }

또한 model 허용값(예: gpt-4o 등) 상수화/검증을 고려해 주세요.

src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatMessagesResponse.java (1)

8-12: Boolean 대신 boolean, List 방어적 복사로 null/불변성 보장

API 응답에서 hasNext가 null일 이유가 없고, messages는 외부에서 변형될 수 있습니다. primitive와 방어적 복사로 안전성을 높이는 편이 좋습니다.

-public record ChatMessagesResponse(
-	List<ChatMessageResponse> messages,
-	Boolean hasNext
-) {
-}
+public record ChatMessagesResponse(
+	List<ChatMessageResponse> messages,
+	boolean hasNext
+) {
+	public ChatMessagesResponse {
+		messages = (messages == null) ? List.of() : List.copyOf(messages);
+	}
+}
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/chatGpt/GptChatResponseDto.java (1)

3-4: 필드명 명확화 제안(response → message/content)

도메인 전반에서 “message/content” 용어를 쓰고 있다면 일관되게 맞추는 편이 가독성에 유리합니다. 하위 호환 이슈가 없다면 개명 검토 바랍니다.

-public record GptChatResponseDto(String response, String newSummary) {
+public record GptChatResponseDto(String message, String newSummary) {
 }
src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/AiRequestStatusResponse.java (1)

5-8: Enum null 방지 — 생성자에서 명시적 검증

상태값은 필수여야 합니다. null 유입을 조기에 차단하세요.

+import java.util.Objects;
+
 public record AiRequestStatusResponse(
 	AiImageStatus status
 ) {
+	public AiRequestStatusResponse {
+		Objects.requireNonNull(status, "status must not be null");
+	}
 }
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/ImageResultResponse.java (1)

3-7: URL 타입 엄격화 및 null 방지

imageUrl을 String 대신 URI로 모델링하면 유효성 보장과 IDE 지원이 좋아집니다. requestId/imageUrl 모두 null 차단 권장.

+import java.net.URI;
+import java.util.Objects;
 
 public record ImageResultResponse(
 	String requestId,
-	String imageUrl
+	URI imageUrl
 ) {
+	public ImageResultResponse {
+		Objects.requireNonNull(requestId, "requestId must not be null");
+		Objects.requireNonNull(imageUrl, "imageUrl must not be null");
+	}
 }

SSE/클라이언트가 문자열 URL을 기대한다면 직렬화 계층에서 URI→String 변환이 문제없는지 확인 부탁드립니다.

src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/GetChatRoomResponse.java (1)

10-18: primitive 사용 및 값 검증으로 API 견고성 강화

remainingGenerations는 음수 불가, createdAt/contextResetAt은 null 금지일 가능성이 큽니다. primitive와 간단한 검증을 추가하면 안전합니다.

-public record GetChatRoomResponse(
-	Long roomId,
-	Long userId,
-	Long postId,
-	Integer remainingGenerations,
-	String chatSummary,
-	LocalDateTime contextResetAt,
-	LocalDateTime createdAt
-) {
+public record GetChatRoomResponse(
+	Long roomId,
+	Long userId,
+	Long postId,
+	int remainingGenerations,
+	String chatSummary,
+	LocalDateTime contextResetAt,
+	LocalDateTime createdAt
+) {
+	public GetChatRoomResponse {
+		if (remainingGenerations < 0) throw new IllegalArgumentException("remainingGenerations must be >= 0");
+	}

추가로 외부 소비용 API라면 LocalDateTime 대신 OffsetDateTime/Instant 사용도 고려해 주세요(타임존 명확화).

src/test/java/hanium/modic/backend/web/post/controller/PostImageControllerIntegrationTest.java (5)

95-103: 테스트 메서드명과 실제 기대가 불일치합니다

실패 케이스를 검증하지만 메서드명이 Success로 끝납니다. 의미가 정확히 드러나도록 이름을 변경해 주세요.

제안:

-@DisplayName("이미지 저장 콜백 실패 : 이미지를 저장하지 않음")
-public void createImageUrlCallbackSuccess() throws Exception {
+@DisplayName("이미지 저장 콜백 실패: 원격 저장이 되지 않은 경우 400")
+public void createImageUrlCallbackFailWhenNotStored() throws Exception {

149-158: 미사용 로컬 변수 제거

CreateImageSaveUrlRequest request 변수를 생성만 하고 사용하지 않습니다. 제거해 주세요.

-    CreateImageSaveUrlRequest request = new CreateImageSaveUrlRequest(imagePurpose, fileName);

71-76: 키 충돌 방지: 테스트용 S3 키를 유니크하게 생성

병렬 실행 시 동일 키를 사용하면 충돌합니다. UUID 등을 붙여 유니크한 경로를 사용하세요.

+import java.util.UUID;
...
-    final String imagePath = "test/image/path/" + fileName;
+    final String imagePath = "test/image/path/" + UUID.randomUUID() + "/" + fileName;
...
-    final String imagePath = "test/image/path";
+    final String imagePath = "test/image/path/" + UUID.randomUUID();

Also applies to: 153-156


165-171: 예외 원인 가시화와 정리 보장 강화

S3 put/delete 실패 시 원인 파악이 어렵습니다. 테스트 헬퍼에서 S3Exception을 잡아 assert 실패로 전환하고 메시지를 포함시키면 디버깅이 수월합니다.

 private void uploadImage(String filePath, String content) {
-    PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+    PutObjectRequest putObjectRequest = PutObjectRequest.builder()
         .bucket(s3Properties.getBucketName())
         .key(filePath)
         .contentType("image/jpeg")
         .build();

-    s3Client.putObject(
-        putObjectRequest,
-        RequestBody.fromString(content)
-    );
+    try {
+        s3Client.putObject(putObjectRequest, RequestBody.fromString(content));
+    } catch (software.amazon.awssdk.services.s3.model.S3Exception e) {
+        org.junit.jupiter.api.Assertions.fail("S3 putObject 실패: " + e.awsErrorDetails().errorMessage());
+    }
 }
 
 private void deleteImage(String filePath) {
-    DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
+    DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
         .bucket(s3Properties.getBucketName())
         .key(filePath)
         .build();
 
-    s3Client.deleteObject(deleteObjectRequest);
+    try {
+        s3Client.deleteObject(deleteObjectRequest);
+    } catch (software.amazon.awssdk.services.s3.model.S3Exception e) {
+        // 테스트 정리 단계: 경고만 남기고 진행
+        org.slf4j.LoggerFactory.getLogger(getClass()).warn("S3 deleteObject 경고: {}", e.awsErrorDetails().errorMessage());
+    }
 }

Also applies to: 176-187, 189-196


30-39: 실 S3 의존 통합 테스트는 플래키 — test 프로필에서 LocalStack/모의 S3로 전환하거나 테스트 분리 필요

검증 결과: BaseIntegrationTest와 BackendApplicationTests에 @activeprofiles("test")가 설정되어 있으나 src/main/resources 및 src/test/resources에서 S3 엔드포인트/LocalStack 설정은 발견되지 않았고, src/test/java/hanium/modic/backend/web/post/controller/PostImageControllerIntegrationTest.java(임포트 라인 30–39)에 S3Client 관련 import가 존재합니다.
조치: test 프로필에서 S3Client를 LocalStack 또는 Testcontainers LocalStack/Fake S3 엔드포인트로 바인딩하거나, 실 S3에 의존하는 통합 테스트에 @tag를 붙여 분리 실행하도록 변경하세요.

src/main/java/hanium/modic/backend/web/ai/aiChat/dto/request/BuyAiImagePermissionRequest.java (1)

7-13: 입력 제약 강화(LGTMs) + 스웨거 명세에 최소값 표시

@NotNull, @positive 추가는 적절합니다. 스웨거에도 최소값을 표시하면 클라이언트 구현이 쉬워집니다.

-	@Schema(description = "구매할 게시물 ID", example = "1", required = true)
+	@Schema(description = "구매할 게시물 ID", example = "1", required = true, minimum = "1")
src/main/java/hanium/modic/backend/domain/image/service/ImageValidationService.java (1)

27-39: imagePath 사전 검증 추가로 방어적 코딩

null/blank 키에 대해 headObject 호출 전 빠르게 차단하면 불필요한 S3 호출과 500 매핑을 줄일 수 있습니다.

 public void validateImageSaved(String imagePath) {
-    try {
+    try {
+        if (imagePath == null || imagePath.isBlank()) {
+            throw new AppException(INVALID_IMAGE_FILE_PATH_EXCEPTION);
+        }
         HeadObjectRequest headRequest = HeadObjectRequest.builder()
             .bucket(s3Properties.getBucketName())
             .key(imagePath)
             .build();
         s3Client.headObject(headRequest);
     } catch (NoSuchKeyException e) {
         throw new AppException(ErrorCode.IMAGE_NOT_STORE_EXCEPTION);
     } catch (S3Exception e) {
         log.error("S3 에러 (validateImageSaved): {}", e.awsErrorDetails().errorMessage(), e);
         throw new AppException(ErrorCode.S3_SERVER_ERROR);
     }
 }
src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiImagePermissionController.java (4)

33-41: 문서의 하드코딩된 “기본 20회” 표현 제거/치환 권장

설정값 기반(예: 프로퍼티)이라면 스펙 문서에서 고정 숫자는 오해 소지가 큽니다. 일반적 설명으로 바꾸거나 예시로 표시해 주세요.

-        description = "코인을 사용하여 AI 이미지 생성권을 구매합니다. 기본 20회 생성 가능합니다.",
+        description = "코인을 사용하여 AI 이미지 생성권을 구매합니다. 기본 제공 횟수는 설정에 따릅니다.",

55-61: 티켓 구매 API 설명도 동일하게 정렬

위와 동일한 사유로 “기본 20회” 제거/치환이 필요합니다.

-        description = "티켓을 사용하여 AI 이미지 생성권을 구매합니다. 기본 20회 생성 가능합니다.",
+        description = "티켓을 사용하여 AI 이미지 생성권을 구매합니다. 기본 제공 횟수는 설정에 따릅니다.",

81-89: 용어 일관성(포스트/게시물) 정리

본 컨트롤러는 “게시물 ID”, 다른 컨트롤러는 “포스트 ID”로 혼용됩니다. 공개 API에서는 하나로 통일해 주세요.


43-51: 201/204 상태코드 고려(선택)

리소스 생성/권한 부여 성격이라면 201 Created(Location) 또는 204 No Content도 후보입니다. 현재 200 OK도 문제는 없으나 REST 문맥상 재검토 권장.

변경 의사가 있으면 응답 스펙(클라이언트 의존성 포함) 영향 범위를 알려 주세요.

Also applies to: 63-71

src/main/java/hanium/modic/backend/domain/image/util/CloudFrontUrlSigner.java (1)

3-11: 미사용 import 정리

URLEncoder는 사용되지 않습니다. 제거해 주세요.

-import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatMessageRequest.java (1)

12-18: 텍스트 길이 상한 추가 및 요구사항 확인(@notblank로 ‘텍스트-only’ 강제됨)

  • 대용량 payload/비정상 입력 방지를 위해 길이 제한을 두는 것이 안전합니다. 기존에 @Size를 import하셨으니 바로 활용해 주세요.
  • 현재 @NotBlank로 텍스트 미포함 메시지는 거절됩니다. “이미지 ID만 첨부(무텍스트)”가 유즈케이스라면 Bean Validation 조건을 재검토해야 합니다.

다음 패치를 제안드립니다:

   @Schema(description = "채팅 메시지 내용", example = "안녕하세요! 이 그림을 더 밝게 만들어주세요.", required = true)
   @NotBlank(message = "메시지 내용은 필수입니다.")
+  @Size(max = 2000, message = "메시지는 최대 2000자까지 허용됩니다.")
   String textContent,

확인 요청:

  • 텍스트 없이 이미지 첨부만 허용해야 하나요? 허용 시 @NotBlank 제거 또는 필드 제약 조건을 커스텀 Validator로 조정이 필요합니다.
src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/DlqListener.java (2)

14-14: 미사용 import 정리(AiChatRoomRepository)

AiChatRoomRepository를 import만 하고 사용하지 않습니다. 정리해 주세요(spotless/checkstyle 경고 방지).

-import hanium.modic.backend.domain.ai.aiChat.repository.AiChatRoomRepository;

32-33: DLQ 원인 추적 로그 보강(x-death 헤더 활용) — 선택사항

운영 추적성을 위해 x-death 헤더(재시도 횟수, 사유, 라우팅 키 등)를 함께 로깅하면 분석이 쉬워집니다. 현재 Message 파라미터가 있으므로 이를 활용하세요.

 @RabbitListener(queues = AI_IMAGE_REQUEST_DLQ)
 public void handleFinalFailedMessage(AiImageRequestMessageDto messageDto, Message message) {
-    log.error("[최종 실패] AI 이미지 생성 요청 최종 실패: requestId={}", messageDto.requestId());
+    Object xDeath = message.getMessageProperties().getHeaders().get("x-death");
+    log.error("[최종 실패] AI 이미지 생성 요청 최종 실패: requestId={}, x-death={}", messageDto.requestId(), xDeath);
src/main/java/hanium/modic/backend/domain/ai/aiServer/repository/AiChatImageRepository.java (1)

16-18: 조회 패턴에 맞는 DB 인덱스/제약 추가 및 Slice 고려

  • 쿼리: findAllByUserIdAndStatusOrderByIdDesc(user_id, status, id DESC) 인덱스가 있어야 효율적입니다.
  • imagePath는 사실상 유니크일 가능성이 높으므로 유니크 인덱스를 권장합니다.
  • 총 카운트가 불필요하다면 Page 대신 Slice로 과부하를 줄일 수 있습니다.

예시 DDL:

CREATE INDEX IF NOT EXISTS ix_ai_chat_image_user_status_id_desc
  ON ai_chat_image (user_id, status, id DESC);

CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_chat_image_image_path
  ON ai_chat_image (image_path);

코드 대안:

// 필요 시
Slice<AiChatImageEntity> findAllByUserIdAndStatusOrderByIdDesc(Long userId, AiImageStatus status, Pageable pageable);
src/main/java/hanium/modic/backend/web/ticket/controller/TicketController.java (1)

30-43: GET 호출 시 상태 변경(티켓 갱신) — REST 관점에서 부자연스러움

설명상 “하루가 지나면 이 API 호출 시 티켓 갱신”은 읽기 요청이 쓰기 부작용을 유발합니다. 운영상 문제는 없으나, 가능하면:

  • 별도 POST /api/ai/tickets/refresh 또는 배치/스케줄러에서 갱신
  • 문서화에 부작용 명시

또한 스웨거 응답 코드에 401/403을 추가하면 클라이언트 통합이 수월합니다.

src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewControllerIntegrationTest.java (2)

36-36: 미사용 의존성 정리(S3Client, S3Properties)

S3Client, S3Properties가 주입/import되나 사용되지 않습니다. 테스트 단순화를 위해 제거를 권장합니다.

-import software.amazon.awssdk.services.s3.S3Client;
@@
-@Autowired
-private S3Client s3Client;
@@
-@Autowired
-private S3Properties s3Properties;

Also applies to: 56-56, 58-60


70-76: 주석 최신화(용어 변경 반영)

주석은 ‘AiImagePermission 생성’으로 되어 있으나 실제로는 AiChatRoomEntity를 저장합니다. 오해 방지를 위해 주석을 갱신하세요.

-        // AiImagePermission 생성 (해당 그림체를 사용한 이력 추가)
+        // AiChatRoom 생성 (해당 그림체 사용 이력/잔여 생성권 관리)
src/main/java/hanium/modic/backend/common/property/property/AiProperties.java (2)

8-11: 프로퍼티 유효성 검증과 민감정보 취급 강화 제안

실행 시점에 빈 값으로 올라오면 늦게 터집니다. @Validated와 필수 키에 대한 제약을 추가하고(예: apiKey), 추후 실수로 로그/Actuator로 노출되지 않도록 일관 관리 권장.

적용 예시:

 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;

 @Getter
 @Setter
-@ConfigurationProperties(prefix = "ai")
+@ConfigurationProperties(prefix = "ai")
+@Validated
 public class AiProperties {

   private OpenAi openai = new OpenAi();

   @Getter
   @Setter
   public static class OpenAi {
-    private String apiKey;
+    @NotBlank
+    private String apiKey;
     private String model;
   }
 }

Also applies to: 15-20


13-13: 중첩 프로퍼티 명시로 가독성 향상

openai 필드에 @NestedConfigurationProperty를 부여하면 바인딩 구조가 명확해집니다(동작에는 큰 차이 없음).

+import org.springframework.boot.context.properties.NestedConfigurationProperty;

-  private OpenAi openai = new OpenAi();
+  @NestedConfigurationProperty
+  private OpenAi openai = new OpenAi();
src/main/java/hanium/modic/backend/domain/image/config/S3Config.java (1)

21-32: AWS 자격 증명 처리 개선: 기본 자격 증명 공급자 우선 + 중복 제거

고정 키(StaticCredentialsProvider)만 사용하면 운영 환경(EC2/IRSA/Env)에서의 보안/유연성이 떨어집니다. 키가 없을 땐 DefaultCredentialsProvider를 사용하고, 중복된 크레덴셜 생성 코드를 제거하세요.

  • 운영/스테이징에서 액세스 키를 구성하지 않고도 정상 동작하는지 확인 부탁드립니다(역할/메타데이터 기반).
 import hanium.modic.backend.common.property.property.S3Properties;
 import lombok.RequiredArgsConstructor;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.presigner.S3Presigner;

 @Bean
 public S3Client s3Client() {
-  AwsBasicCredentials awsCreds = AwsBasicCredentials.create(
-    s3Properties.getAccessKey(),
-    s3Properties.getSecretKey()
-  );
-
-  return S3Client.builder()
-    .region(Region.of(s3Properties.getRegion()))
-    .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
-    .build();
+  boolean hasStatic = s3Properties.getAccessKey() != null && !s3Properties.getAccessKey().isBlank()
+    && s3Properties.getSecretKey() != null && !s3Properties.getSecretKey().isBlank();
+  AwsCredentialsProvider provider = hasStatic
+    ? StaticCredentialsProvider.create(AwsBasicCredentials.create(
+        s3Properties.getAccessKey(), s3Properties.getSecretKey()))
+    : DefaultCredentialsProvider.create();
+
+  return S3Client.builder()
+    .region(Region.of(s3Properties.getRegion()))
+    .credentialsProvider(provider)
+    .build();
 }

 @Bean
 public S3Presigner s3Presigner() {
-  AwsBasicCredentials awsCreds = AwsBasicCredentials.create(
-    s3Properties.getAccessKey(),
-    s3Properties.getSecretKey()
-  );
-
-  return S3Presigner.builder()
-    .region(Region.of(s3Properties.getRegion()))
-    .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
-    .build();
+  boolean hasStatic = s3Properties.getAccessKey() != null && !s3Properties.getAccessKey().isBlank()
+    && s3Properties.getSecretKey() != null && !s3Properties.getSecretKey().isBlank();
+  AwsCredentialsProvider provider = hasStatic
+    ? StaticCredentialsProvider.create(AwsBasicCredentials.create(
+        s3Properties.getAccessKey(), s3Properties.getSecretKey()))
+    : DefaultCredentialsProvider.create();
+
+  return S3Presigner.builder()
+    .region(Region.of(s3Properties.getRegion()))
+    .credentialsProvider(provider)
+    .build();
 }

추가 확인 사항:

  • s3Properties.getRegion() 미설정 시 즉시 실패합니다. 모든 프로필에 region이 설정되어 있는지 점검 바랍니다.

Also applies to: 34-45

src/test/java/hanium/modic/backend/web/ticket/controller/TicketControllerIntegrationTest.java (4)

27-31: 미사용 필드/의존성 제거

UserEntityRepository, testUser가 사용되지 않습니다. 테스트 신뢰성과 가독성을 위해 제거하세요.

-import hanium.modic.backend.domain.user.entity.UserEntity;
-import hanium.modic.backend.domain.user.repository.UserEntityRepository;
+// imports trimmed

 @Autowired
-private UserEntityRepository userRepository;
-
-private UserEntity testUser;
+// remove unused fields

Also applies to: 18-20


55-55: 하드코딩된 사용자 ID(1L/999L) 의존도 축소

인증 컨텍스트/테스트 유틸로부터 동적으로 ID를 가져오도록 변경하면 테스트 견고성이 높아집니다(시드 데이터, 순서 영향 최소화).

예: SecurityContextHolder 또는 테스트 유틸을 통해 현재 사용자 ID 조회 → 해당 값으로 검증.

Also applies to: 66-71, 91-94, 107-108, 135-140, 155-156, 160-161


51-53: 시간 의존 단언 플레이키니스 완화

LocalDateTime.now()를 여러 번 호출하면 경계 시각에 플래키해질 수 있습니다. 테스트 시작 시 now를 캡처해 재사용하거나 Clock 주입을 권장합니다.

예:

LocalDateTime now = LocalDateTime.now();
assertThat(ticketInfo.nextReset()).isAfter(now);
assertThat(ticketInfo.nextReset()).isBefore(now.plusDays(1).plusMinutes(1));

Also applies to: 83-84, 121-122


99-105: 리플렉션 기반 내부 필드 조작 최소화

도메인에 테스트용 팩토리/세터(패키지 프라이빗) 등을 제공하면 구조 변경에 덜 취약합니다.

src/test/java/hanium/modic/backend/domain/post/service/AiDerivedPostServiceTest.java (3)

109-133: 유사성 투표 초기화 동작 검증 보강

생성 흐름에서 SimilarityVoteRepository.save(...) 호출 및 저장 값(원본/파생 이미지/포스트 ID, 타입/상태)을 캡처해 단언하면 회귀를 막을 수 있습니다.

     // PostImageEntity 저장 검증
     ArgumentCaptor<PostImageEntity> imageCaptor = ArgumentCaptor.forClass(PostImageEntity.class);
     verify(postImageEntityRepository, times(1)).save(imageCaptor.capture());
     PostImageEntity savedImage = imageCaptor.getValue();
     assertThat(savedImage.getImagePath()).isEqualTo(mockAiImage.getImagePath()); // s3 이미지는 같은 것을 사용
     assertThat(savedImage.getFullImageName()).isEqualTo(mockAiImage.getFullImageName());
     assertThat(savedImage.getImageName()).isEqualTo(mockAiImage.getImageName());
     assertThat(savedImage.getExtension()).isEqualTo(mockAiImage.getExtension());
     assertThat(savedImage.getImagePurpose()).isEqualTo(ImagePrefix.POST);

-    verify(createdAiImageRepository, times(1)).findById(createdAiImageId);
+    verify(createdAiImageRepository, times(1)).findById(createdAiImageId);
+
+    // SimilarityVote 저장 검증
+    ArgumentCaptor<SimilarityVoteEntity> voteCaptor = ArgumentCaptor.forClass(SimilarityVoteEntity.class);
+    verify(similarityVoteRepository, times(1)).save(voteCaptor.capture());
+    SimilarityVoteEntity savedVote = voteCaptor.getValue();
+    assertThat(savedVote.getOriginalImageId()).isEqualTo(originalImageId);
+    assertThat(savedVote.getDerivedImageId()).isEqualTo(createdAiImageId);
+    assertThat(savedVote.getDerivedPostId()).isEqualTo(mockSavedPost.getId());
+    assertThat(savedVote.getVoteType()).isEqualTo(VoteType.SIMILARITY_CHECK);
+    assertThat(savedVote.getStatus()).isEqualTo(VoteStatus.PENDING);

148-160: 실패 케이스에서 부수효과 발생 금지 단언 추가

이미지 미존재/권한 없음 케이스에서 투표 저장이 호출되지 않았음을 검증하세요.

 verify(createdAiImageRepository, times(1)).findById(nonExistentAiImageId);
 verify(postEntityRepository, never()).save(any());
 verify(postImageEntityRepository, never()).save(any());
+verify(similarityVoteRepository, never()).save(any());
 verify(createdAiImageRepository, times(1)).findById(createdAiImageId);
 verify(postEntityRepository, never()).save(any());
 verify(postImageEntityRepository, never()).save(any());
+verify(similarityVoteRepository, never()).save(any());

Also applies to: 186-192


42-44: 필드 명명 일관성

타입은 AiChatImageRepository인데 필드명이 createdAiImageRepository입니다. 도메인 리네이밍 이후 명명 정합성을 맞추면 가독성이 좋아집니다.

src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatMessageResponse.java (2)

30-30: 접근자명 확인: getCreateAt() 오타 가능성

엔티티 관례상 getCreatedAt()일 확률이 높습니다. 실제 엔티티 시그니처와 일치하는지 확인하고 필요 시 수정해주세요.

-      entity.getCreateAt(),
+      entity.getCreatedAt(),

12-21: DTO 구성은 전반적으로 적절합니다

레코드 기반 DTO, 팩토리 메서드 두 가지 오버로드 구성 깔끔합니다. 이후 presigned URL 연동 시 imageUrl 계산은 서비스/어셈블러 계층에서 책임 분리 유지 추천.

src/main/java/hanium/modic/backend/domain/ai/aiChat/entity/AiChatMessageEntity.java (3)

26-31: request_id 조회가 빈번한데 인덱스 부재.

리스너/서비스에서 requestId 기반 조회가 자주 발생합니다. request_id 인덱스를 추가하세요.

 @Table(name = "ai_chat_messages",
-  indexes = {
+  indexes = {
     @Index(name = "idx_user_post_order", columnList = "user_id, post_id, message_order"),
-    @Index(name = "idx_user_post_created", columnList = "user_id, post_id, create_at")
+    @Index(name = "idx_user_post_created", columnList = "user_id, post_id, create_at"),
+    @Index(name = "idx_request_id", columnList = "request_id")
   })

Also applies to: 61-63


25-31: 메시지 순번 유일성 보장 필요.

동시 저장 시 같은 (user_id, post_id)에 대해 message_order 충돌 가능성이 있습니다. 유니크 제약으로 방어하세요.

-@Table(name = "ai_chat_messages",
-  indexes = {
+@Table(name = "ai_chat_messages",
+  uniqueConstraints = {
+    @jakarta.persistence.UniqueConstraint(
+      name = "uk_ai_chat_msg_user_post_order",
+      columnNames = {"user_id", "post_id", "message_order"}
+    )
+  },
+  indexes = {
     @Index(name = "idx_user_post_order", columnList = "user_id, post_id, message_order"),
     @Index(name = "idx_user_post_created", columnList = "user_id, post_id, create_at")
   })

4-4: 미사용 import 제거.

AiChatImageEntity import가 사용되지 않습니다.

-import hanium.modic.backend.domain.ai.aiServer.entity.AiChatImageEntity;
src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java (1)

61-63: 시간 의존성 분리 제안.

일자 갱신 로직 테스트 용이성과 일관성을 위해 Clock 주입으로 현재시각 의존성을 분리하는 것을 권장합니다.

src/main/java/hanium/modic/backend/common/amqp/service/MessageQueueService.java (1)

10-10: 미사용 import 제거.

-import hanium.modic.backend.domain.ai.aiServer.dto.AiImageRequestMessageDto;
src/main/java/hanium/modic/backend/domain/ai/aiServer/dto/chatGpt/ChatGPTResponse.java (1)

5-14: Lombok 중복 및 snake_case 매핑 불명확.

  • @Data@Getter 중복입니다.
  • OpenAI 응답은 snake_case가 섞여 있습니다. 전역 ObjectMapper가 SNAKE_CASE 전략이 아니라면 역직렬화 실패합니다. 클래스 수준에 네이밍 전략을 지정하세요.
 package hanium.modic.backend.domain.ai.aiServer.dto.chatGpt;

 import java.util.List;

 import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.Getter;
+import lombok.Data;
 import lombok.NoArgsConstructor;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;

-@Data
-@NoArgsConstructor
-@AllArgsConstructor
-@Getter
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
 public class ChatGPTResponse {

   ...

-  @Data
-  @Getter
-  public static class Choice {
+  @Data
+  @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+  public static class Choice {
     private int index;
     private Message message;
     private String finishReason;
   }

   @Data
   public static class Message {
     private String role;
     private String content; // JSON 형식의 감정 분석 결과를 담음
     private String refusal;
   }

-  @Data
+  @Data
+  @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
   public static class Usage {
     private int promptTokens;
     private int completionTokens;
     private int totalTokens;
     private PromptTokensDetails promptTokensDetails;
     private CompletionTokensDetails completionTokensDetails;

-    @Data
+    @Data
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
     public static class PromptTokensDetails {
       private int cachedTokens;
       private int audioTokens;
     }

-    @Data
+    @Data
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
     public static class CompletionTokensDetails {
       private int reasoningTokens;
       private int audioTokens;
       private int acceptedPredictionTokens;
       private int rejectedPredictionTokens;
     }
   }

전역 ObjectMapper가 이미 SNAKE_CASE라면 이 변경은 선택 사항입니다. 설정 여부를 확인 부탁드립니다.

Also applies to: 23-29, 31-36, 38-59

src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatController.java (2)

88-89: 잘못된 파라미터 설명.

@PathVariablepostId인데 설명이 "ai chat room ID"로 표기되어 있습니다. 문서 혼동을 야기합니다.

- @Parameter(description = "ai chat room ID") @PathVariable @Positive(message = "포스트 ID는 양수여야 합니다.") Long postId,
+ @Parameter(description = "포스트 ID") @PathVariable @Positive(message = "포스트 ID는 양수여야 합니다.") Long postId,

166-168: 무제한 타임아웃은 리소스 누수 위험.

Long.MAX_VALUE 대신 합리적 타임아웃(예: 환경설정 기반)을 권장합니다. 타임아웃/하트비트 설계가 없다면 프록시/클라이언트에서 끊길 수 있습니다.

src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiChatService.java (1)

24-31: OpenAI 엔드포인트/헤더 하드코딩.

베이스 URL과 인증 헤더를 프로퍼티로 이동하고 Content-Type/Accept를 명시하면 운영/테스트 분리가 쉬워집니다.

src/test/java/hanium/modic/backend/domain/ai/entityfactory/AiFactory.java (1)

27-37: 테스트 편의성 개선 제안.

createMockCreatedAiImageWithId도 동일 필드 기본값이 보장되므로 별도 변경은 없으나, 필요시 aiChatRoomId를 파라미터로 받을 수 있게 확장하면 재사용성이 좋아집니다.

src/main/java/hanium/modic/backend/domain/ai/aiServer/listener/AiImageCreatedListener.java (1)

79-79: 주석 오타: "3.응답 채팅 저장"이 중복됨

Line 79의 주석이 "3.응답 채팅 저장"으로 되어 있는데, Line 64-76에서 이미 "3.응답 이미지 저장"이 있습니다. 번호를 "4.응답 채팅 저장"으로 수정해야 합니다.

-	// 3.응답 채팅 저장
+	// 4.응답 채팅 저장
src/main/java/hanium/modic/backend/domain/ai/aiChat/repository/AiChatMessageRepository.java (1)

69-69: @Param 어노테이션 누락

findByRequestIdAndSenderType 메서드의 파라미터에 @Param 어노테이션이 없습니다. JPQL 쿼리가 아니라 메서드 이름 기반 쿼리이므로 필수는 아니지만, 다른 메서드들과 일관성을 위해 추가하는 것이 좋습니다.

-	Optional<AiChatMessageEntity> findByRequestIdAndSenderType(String requestId, SenderType senderType);
+	Optional<AiChatMessageEntity> findByRequestIdAndSenderType(
+		@Param("requestId") String requestId, 
+		@Param("senderType") SenderType senderType
+	);
src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiChatMessageService.java (2)

77-84: 이미지 리스트 초기화 및 검증 로직 개선 필요

Line 77에서 빈 리스트로 초기화한 후 Line 79-81에서 다시 할당하는 구조가 불필요합니다. 또한 이미지 소유권 검증이 조건문 안에만 있어서 구조가 복잡합니다.

-	// 이미지 조회
-	List<AiChatImageEntity> aiChatImages = List.of();
-	if (request.aiChatImageId() != null) {
-		aiChatImages = aiChatImageRepository.findById(request.aiChatImageId())
-			.map(List::of)
-			.orElse(List.of());
-		// 이미지들이 자신의 것인지 확인
-		validateAiChatImagesOwnership(userId, aiChatImages);
-	}
+	// 이미지 조회 및 검증
+	List<AiChatImageEntity> aiChatImages = request.aiChatImageId() != null 
+		? aiChatImageRepository.findById(request.aiChatImageId())
+			.map(image -> {
+				// 이미지 소유권 검증
+				if (!image.getUserId().equals(userId)) {
+					throw new AppException(ErrorCode.IMAGE_CAN_NOT_BE_STOLEN_EXCEPTION);
+				}
+				return List.of(image);
+			})
+			.orElse(List.of())
+		: List.of();

102-107: 이미지 존재 검증 로직의 반환값 활용 가능

validateAiChatImageExistence 메서드가 이미지를 조회하지만 반환하지 않고, Line 79-81에서 다시 조회합니다. 중복 조회를 피하기 위해 Optional을 반환하거나 조회 로직을 통합하는 것이 효율적입니다.

src/main/java/hanium/modic/backend/domain/ai/aiServer/entity/AiChatImageEntity.java (1)

43-44: Boolean 타입 필드의 null 처리 고려 필요

fromOriginImage 필드가 Boolean 래퍼 타입이지만 nullable = false로 설정되어 있습니다. primitive boolean 타입을 사용하거나, null 값을 허용한다면 기본값 처리를 고려해야 합니다.

-	@Column(name = "from_origin_image", nullable = false)
-	private Boolean fromOriginImage; // 원본 이미지로부터 파생되었는지
+	@Column(name = "from_origin_image", nullable = false)
+	private boolean fromOriginImage; // 원본 이미지로부터 파생되었는지

또는 Builder에서:

-		Boolean fromOriginImage,
+		boolean fromOriginImage,
src/main/java/hanium/modic/backend/domain/ai/aiChat/entity/AiChatRoomEntity.java (2)

56-64: 동시 갱신 안전성: 낙관적 락(@Version) 고려

남은 생성 횟수 증감은 경쟁 조건에 취약합니다(동시 감소 시 손실 업데이트). 서비스 레벨 락을 사용하지 않는 경로가 생길 가능성에 대비해 낙관적 락을 추가하세요.

빌더 범위를 벗어나므로 참고 코드로 제안합니다:

// 필드 근처에 추가
@Version
private Long version;

91-94: 시간 소스 고정 필요(테스트/타임존 일관성)

LocalDateTime.now()는 서버 타임존/시계에 의존합니다. Clock 주입 후 LocalDateTime.now(clock)을 사용하면 재현성과 일관성이 좋아집니다. 요약 초기화도 null/빈 문자열 중 한 가지로 컨벤션을 고정하세요(현재는 빈 문자열).

src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiChatImageService.java (3)

45-51: 이중 DB 조회 제거(미세 최적화)

현재 소유자 검증(existsByIdAndUserId) 후 다시 findById를 호출합니다. 한 번의 조회로 소유자 확인과 URL 생성을 처리하면 효율적입니다.

- validateImageOwnerByImageId(imageId, userId);
- return createImageGetUrl(imageId);
+ AiChatImageEntity image = aiChatImageRepository.findById(imageId)
+   .orElseThrow(() -> new AppException(IMAGE_NOT_FOUND_EXCEPTION));
+ if (!image.getUserId().equals(userId)) {
+   throw new AppException(ErrorCode.IMAGE_CAN_NOT_BE_STOLEN_EXCEPTION);
+ }
+ return imageUtil.createImageGetUrl(image.getImagePath());

109-131: 읽기 전용 트랜잭션 지정 권장

페이지 조회 메서드는 JPA 플러시를 유발하지 않습니다. @Transactional(readOnly = true)를 붙여 힌트를 주는 것이 좋습니다.

- public PageResponse<MyGeneratedAiImageResponse> getMyGeneratedImages(Long userId, int page, int size) {
+ @Transactional(readOnly = true)
+ public PageResponse<MyGeneratedAiImageResponse> getMyGeneratedImages(Long userId, int page, int size) {

62-70: S3 객체 미삭제는 비용/정책 이슈 가능

DB만 삭제하고 S3는 유지합니다. 보존 정책이라면 OK지만, 장기적으로 비용/정책 준수 이슈가 될 수 있습니다. 수명주기(Lifecycle) 규칙 또는 별도 정리 배치를 함께 운영하는지 확인 바랍니다.

src/test/java/hanium/modic/backend/domain/ticket/service/TicketServiceTest.java (3)

89-97: Reflection 기반 시간 조작 대신 테스트용 시계 또는 세터 제공 권장

테스트 안정성을 위해 엔티티에 테스트 전용 진입점(예: 패키지 전용 setLastIssuedAt 또는 Clock 주입)을 두면 리플렉션 의존도를 줄일 수 있습니다.

예시(참고 코드):

// 엔티티
void _testOnly_setLastIssuedAt(LocalDateTime t) { this.lastIssuedAt = t; }

145-148: 예외 메시지 단정은 취약합니다 — 에러 코드로 검증하세요

국제화/문구 변경에 덜 민감하도록 AppExceptionErrorCode를 검증하는 단정으로 바꾸는 것을 권장합니다.

- assertThatThrownBy(() -> ticketService.useTicket(TEST_USER_ID, 1L))
-   .isInstanceOf(AppException.class)
-   .hasMessage(ErrorCode.AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION.getMessage());
+ assertThatThrownBy(() -> ticketService.useTicket(TEST_USER_ID, 1L))
+   .isInstanceOf(AppException.class)
+   .extracting("errorCode")
+   .isEqualTo(ErrorCode.AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION);

182-215: 동시성 테스트 시작 동기화 보강

실제 동시 시작을 보장하려면 CyclicBarrier 등으로 스레드 시작 타이밍을 맞추는 것이 더 엄밀합니다. 현재 구현도 유효하나, 미세 타이밍에 따라 드물게 플래키해질 수 있습니다.

CyclicBarrier barrier = new CyclicBarrier(threadCount);
executor.execute(() -> {
  try {
    barrier.await();
    ticketService.useTicket(TEST_USER_ID, 1);
  } catch (Exception ignore) {
  } finally {
    latch.countDown();
  }
});
src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java (1)

38-40: 미사용 의존성 정리

imageUtil이 본 클래스에서 사용되지 않습니다. 주입을 제거하거나 실제 사용처를 반영하세요.

src/test/java/hanium/modic/backend/web/post/controller/PostControllerIntegrationTest.java (1)

319-329: 테스트 업로드 헬퍼 개선 제안: 콘텐츠 타입/바이트 사용

문자열 본문에 고정 "image/jpeg"는 실제 확장자/내용과 불일치할 수 있습니다. 바이트 기반으로 일반화하면 안정적입니다.

-private void uploadImage(String filePath, String  content) {
+private void uploadImage(String filePath, String content) {
   PutObjectRequest putObjectRequest = PutObjectRequest.builder()
     .bucket(s3Properties.getBucketName())
     .key(filePath)
-    .contentType("image/jpeg")
+    .contentType("application/octet-stream")
     .build();
 
-  s3Client.putObject(
-    putObjectRequest,
-    RequestBody.fromString(content)
-  );
+  s3Client.putObject(putObjectRequest, RequestBody.fromBytes(content.getBytes()));
}
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (2)

41-52: 필드명 컨벤션 위반으로 인한 가독성 저하

빈 주입 필드 AiChatImageRepository는 대문자로 시작합니다. 관례상 소문자 카멜케이스를 사용해주세요.

-@Autowired
-private AiChatImageRepository AiChatImageRepository;
+@Autowired
+private AiChatImageRepository aiChatImageRepository;

그리고 하단 사용처도 함께 변경:

- createdAiImage = AiChatImageRepository.save(createdAiImage);
+ createdAiImage = aiChatImageRepository.save(createdAiImage);
...
- otherUserAiImage = AiChatImageRepository.save(otherUserAiImage);
+ otherUserAiImage = aiChatImageRepository.save(otherUserAiImage);

53-54: ObjectMapper 직접 생성 지양

테스트 전역 설정(모듈 등록, 직렬화 정책 등)과 불일치할 수 있습니다. 테스트 컨텍스트에서 주입받은 ObjectMapper를 사용하세요.

-private final ObjectMapper objectMapper = new ObjectMapper();
+@Autowired
+private ObjectMapper objectMapper;
src/test/java/hanium/modic/backend/web/ai/controller/AiImagePermissionControllerIntegrationTest.java (2)

51-55: 티켓 초기값을 테스트에서 명시적으로 고정

현재 빌더가 기본 티켓 수를 설정한다는 가정에 의존합니다. 테스트 안정성을 위해 초기 티켓 수(예: 3)를 명시적으로 설정하는 편이 안전합니다.

가능하다면 다음과 같이 초기값을 지정하세요(엔티티 빌더에 필드가 있다면):

-private TicketEntity createTestTicket(UserEntity user) {
-  return ticketRepository.save(TicketEntity.builder()
-    .userId(user.getId())
-    .build());
-}
+private TicketEntity createTestTicket(UserEntity user) {
+  return ticketRepository.save(TicketEntity.builder()
+    .userId(user.getId())
+    .ticketCount(3L)
+    .build());
+}

194-205: 리포지토리 조회 시 orElseThrow 사용 권장

널 허용 후 즉시 notNull 단언보다 예외를 즉시 발생시키는 편이 의도에 명확합니다.

-TicketEntity updatedTicket = ticketRepository.findByUserId(user.getId()).orElse(null);
-assertThat(updatedTicket).isNotNull();
+TicketEntity updatedTicket = ticketRepository.findByUserId(user.getId()).orElseThrow();
src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (2)

56-63: 만료 갱신 시 불필요 저장 최소화

만료되지 않은 경우에도 매번 save()를 호출합니다. 변경 발생시에만 저장하세요.

 if (userTicket.isTicketExpired()) {
   userTicket.resetTickets();
-}
-ticketRepository.save(userTicket);
+  ticketRepository.save(userTicket);
+}

추가로, 상단 코멘트의 중첩 락 이슈를 해소하려면 본 메서드는 외부 락 없이 호출되는 경로로만 사용하고, 락이 필요한 경로에서는 refreshTicketIfExpiredWithoutLock을 사용하세요.


24-27: 만료 정보 응답 계산 로직 재검토

lastIssuedAt.plusDays(1)가 만료/갱신 시점을 의미한다면, ZoneId 및 경계(자정 기준) 고려가 필요합니다. 프런트와 합의된 절대 시각(UTC 또는 KST)로 일관되게 반환하세요.

만료 시각 산출 규칙 문서/테스트 케이스 유무를 확인해주세요.

src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatImageController.java (1)

19-25: 미사용 import 정리

AiImageStatus, AiRequestStatusResponse는 본 컨트롤러에서 사용되지 않습니다.

-import hanium.modic.backend.domain.ai.aiServer.enums.AiImageStatus;
...
-import hanium.modic.backend.web.ai.aiChat.dto.response.AiRequestStatusResponse;
build.gradle (1)

31-39: 과도한 force 사용 최소화

Boot BOM이 버전을 관리합니다. 강제 고정은 상호 의존 버전 충돌을 키웁니다. 꼭 필요한 항목만 constraint 혹은 BOM으로 관리하세요.

src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java (2)

77-91: 실패 보상 증가 로직은 OK, 단 예외 맵핑 세분화 권장

증가 보상은 타당합니다. 다만 락 실패/권한 부재를 동일 코드로 묶으면 클라이언트 처리 난이도가 올라갑니다.

락 실패와 권한 미존재 케이스를 서로 다른 에러코드로 노출해 디버깅 가능성을 높이세요.


30-31: 상수 정의 컨벤션

불변 상수는 static final로 두고 이름을 의미 중심으로 바꾸면 가독성이 좋습니다.

-private final int AI_IMAGE_PERMISSION_COUNT = 20; // 구매 시 제공되는 이미지 생성 횟수
+private static final int DEFAULT_PURCHASE_GENERATIONS = 20; // 구매 시 제공되는 이미지 생성 횟수

그리고 사용처를 모두 치환하세요.

src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java (3)

68-69: 매직 넘버(20) 하드코딩으로 테스트 취약

AI 이미지 생성권 기본 수량(20)을 테스트에 직접 하드코딩하면 운영 중 설정 변경 시 테스트가 쉽게 깨집니다. ArgumentCaptor로 값만 캡처해 “0보다 큼” 같은 의미 검증으로 바꾸거나, 설정/상수를 주입받아 테스트에서 읽도록 리팩터링하세요.

제안(diff: 테스트에서 ArgumentCaptor 사용):

- verify(aiChatRoomRepository).upsertAndIncrease(testUser.getId(), testPost.getId(), 20);
+ ArgumentCaptor<Integer> cap = ArgumentCaptor.forClass(Integer.class);
+ verify(aiChatRoomRepository).upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), cap.capture());
+ assertThat(cap.getValue()).isGreaterThan(0);

Also applies to: 132-134


187-209: 락 기반 감소 로직은 적절하나 경계값(0) 검증 테스트 추가 필요

성공 케이스는 좋습니다. 남은 횟수 0에서의 동작(예외 코드, 메시지)을 검증하는 테스트를 추가해 주세요. 엔티티의 decreaseRemainingGenerations가 0 미만 방지를 확실히 보장하는지도 함께 확인 바랍니다.

원하시면 0 경계 테스트 케이스를 추가하는 패치 제안 드립니다.


219-237: 락 예외 경로 미커버

LockManager가 LockException을 던지는 경로(AI_IMAGE_PERMISSION_FAIL_EXCEPTION 매핑)를 테스트로 보강하세요.

제안(테스트 추가 스텁):

doThrow(new LockException("lock failed"))
  .when(lockManager).aiImagePermissionLock(anyLong(), anyLong(), any(Runnable.class));

AppException ex = assertThrows(AppException.class,
  () -> aiImagePermissionService.consumeRemainingGenerations(userId, postId));
assertEquals(AI_IMAGE_PERMISSION_FAIL_EXCEPTION, ex.getErrorCode());
src/main/java/hanium/modic/backend/domain/image/util/S3ImageUtil.java (1)

146-156: 불필요한 Date 변환

Instant를 Date로 변환 후 다시 Instant로 변환하고 있습니다. expires를 그대로 전달해도 됩니다. 가독성 개선 차원에서 교체 권장.

- String signedUrl = CloudFrontUrlSigner.getSignedUrl(
+ String signedUrl = CloudFrontUrlSigner.getSignedUrl(
    resourceUrl,
    cloudFrontProperties.getKeyPairId(),
    pk,
-   Date.from(expires).toInstant()
+   expires
 );
src/main/java/hanium/modic/backend/domain/ai/aiServer/service/AiServerService.java (3)

55-77: 메시지 발행과 트랜잭션 불일치 — 아웃박스/커밋 후 발행 필요

processAiRequest는 @transactional입니다. MQ 발행이 DB 트랜잭션과 분리되어 “DB 롤백·MQ 발행” 또는 “DB 커밋·MQ 미발행” 불일치가 발생할 수 있습니다. 트랜잭셔널 아웃박스나 afterCommit 훅(@TransactionalEventListener 또는 TransactionSynchronization)으로 커밋 이후 발행을 보장하세요.

예시(커밋 후 발행):

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  @Override public void afterCommit() {
    messageQueueService.sendImageGenerationRequest(aiChatRequestDto);
  }
});

장기적으로는 아웃박스 테이블 + 폴러/리레이어 패턴을 권장합니다.


190-196: 사용권 소모 타이밍 재검토 권장

현재 “MQ 전송 → 사용권 소모” 순서입니다. 전송 예외면 소모 안 되지만, 전송 성공 후 DLQ 최종 실패 시 DlqListener에서 복구하므로 설계 의도는 일관적입니다. 다만 MQ 전송과 DB 커밋의 원자성이 없으므로 상기 아웃박스 패턴과 함께 정합성 보강을 권장합니다.


233-248: 컨텍스트 메시지 정렬 보강(선택)

최근 N개를 “내림차순”으로 가져오면 생성 모델에 투입 시 시간 순서가 역전될 수 있습니다. convertFromEntities 전에 messageOrder로 오름차순 정렬을 권장합니다.

return aiChatMessageRepository
  .findTopNByUserIdAndPostIdOrderByMessageOrderDesc(userId, postId, pageable)
  .stream().sorted(Comparator.comparing(AiChatMessageEntity::getMessageOrder)).toList();

Comment on lines 22 to +26
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Milestone/Snapshot 저장소 전역 추가는 공급망 리스크

릴리즈가 아닌 아티팩트를 전역으로 받아들이면 예기치 않은 상향/호환 깨짐 위험이 있습니다. 필요한 모듈 범위로 국소화하거나 제거를 권장합니다.

  • 특정 의존성에 한정해 maven { url ... }를 개별 서브프로젝트나 필요 Task에만 선언
  • 해제 시 캐시를 비워 재현성 확인
🤖 Prompt for AI Agents
In build.gradle around lines 22-26, the global addition of Spring
milestone/snapshot repositories increases supply-chain risk; remove these global
maven entries and instead declare them only where needed (either in the specific
subproject build.gradle or a specific task), or restrict their scope using a
content filter (e.g., configure the repository with content { includeModule(...)
/ includeGroup(...) } in the subproject or in dependencyResolutionManagement) so
only the required artifact(s) are fetched from milestone/snapshot; after making
the change, clear Gradle caches (e.g., ./gradlew --refresh-dependencies or
delete ~/.gradle/caches) and verify builds for reproducibility.

Comment on lines +88 to 90
// webClient용 라이브러리, WebFlux 사용x
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 Analysis chain

WebFlux 스타터 동시 사용 주의

MVC+WebFlux 스타터 동시 포함은 서버 이중 탑재(톰캣+Netty)와 클래스패스 부피 증가를 야기합니다. WebClient만 필요하면 spring-boot-starter-webflux 사용은 가능하지만, 런타임 충돌 여부를 확인하세요.

다음으로 내장 서버 충돌 여부를 점검해주세요.


🏁 Script executed:

#!/bin/bash
rg -nPI 'Netty|Reactor Netty|undertow' -g 'build/**/reports/*' -S || true

Length of output: 148


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "== Searching repository for WebFlux / Web starters and embedded servers =="
rg -nPI --hidden -S --glob '!**/build/**' --glob '!**/target/**' --glob '!**/.git/**' -C3 'spring-boot-starter-webflux|spring-boot-starter-web|spring-boot-starter-tomcat|spring-boot-starter-undertow|spring-boot-starter-jetty|spring-boot-starter-reactor-netty|io\.projectreactor\.netty|reactor-netty|reactor\.netty|netty|undertow|jetty' || true

echo
echo "== Searching for WebClient usage =="
rg -nPI --hidden -S --glob '!**/build/**' --glob '!**/target/**' --glob '!**/.git/**' -C2 'org\.springframework\.web\.reactive\.function\.client\.WebClient|WebClient' || true

echo
echo "== Searching for spring.main.web-application-type settings =="
rg -nPI --hidden -S --glob '!**/build/**' --glob '!**/target/**' --glob '!**/.git/**' -C2 'spring\.main\.web-application-type|web-application-type' || true

echo
echo "== List of build files found (build.gradle*, build.gradle.kts, pom.xml, settings.gradle*) =="
find . -maxdepth 6 -type f \( -name 'build.gradle*' -o -name 'build.gradle.kts' -o -name 'pom.xml' -o -name 'settings.gradle*' \) -print | sed 's|^\./||' || true

echo
echo "== Show dependency lines from found build files =="
for f in $(find . -maxdepth 6 -type f \( -name 'build.gradle*' -o -name 'build.gradle.kts' -o -name 'pom.xml' \)); do
  echo "---- $f ----"
  rg -nPI --hidden -S -n 'spring-boot-starter-webflux|spring-boot-starter-web|spring-boot-starter-tomcat|spring-boot-starter-undertow|spring-boot-starter-jetty|spring-boot-starter-reactor-netty|io\.projectreactor\.netty|reactor-netty|reactor\.netty|netty|undertow|jetty' "$f" || true
done

Length of output: 971


WebFlux 스타터 동시 포함 확인 — 즉시 수정 필요

build.gradle에 'org.springframework.boot:spring-boot-starter-web' (44행)과 'org.springframework.boot:spring-boot-starter-webflux' (89행)가 동시에 선언되어 있습니다. 톰캣 + Reactor Netty 이중 내장 서버, 클래스패스 증가 및 포트/런타임 충돌 가능성이 있습니다.

  • 조치(권장): WebClient만 필요하면 spring-boot-starter-webflux를 제거하고 대신 org.springframework:spring-webflux 모듈만 추가하거나, webflux 스타터에서 Reactor Netty(io.projectreactor.netty) 의존성을 exclude 하세요.
  • 검증: 또는 spring.main.web-application-type=servlet으로 강제하거나 ./gradlew dependencies --configuration runtimeClasspath / dependencyInsight로 의존성 트리를 확인하고 애플리케이션 로그에서 Netty/Tomcat 기동 여부를 확인하세요.
🤖 Prompt for AI Agents
In build.gradle around lines 88-90 (and note web starter at line 44), you
currently include both 'org.springframework.boot:spring-boot-starter-web' and
'org.springframework.boot:spring-boot-starter-webflux', which causes embedded
Tomcat + Reactor Netty conflict; fix by removing the webflux starter if you only
need WebClient and instead add the lighter 'org.springframework:spring-webflux'
dependency OR keep the starter but exclude Reactor Netty (exclude group
'io.projectreactor.netty', artifact 'reactor-netty') so only servlet stack runs;
after the change verify with ./gradlew dependencies --configuration
runtimeClasspath or dependencyInsight and confirm the app starts with only
Tomcat (or only Netty) in logs.

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

🧩 Analysis chain

Gradle 8.13 → 8.8 다운그레이드: 호환성·공급망 보안 점검 필요

  • 이유 없는 다운그레이드는 플러그인/툴체인 호환성 이슈(예: Spring Boot/ Kotlin/ Java 21+/ 기타 Gradle 플러그인 최소 지원 버전) 유발 위험이 큽니다. CI·로컬 모두에서 빌드/테스트가 안정적인지 확인해 주세요.
  • gradle-wrapper.properties만 바꾸고 gradle-wrapper.jar를 갱신하지 않으면 wrapper mismatch 문제가 발생할 수 있습니다. 반드시 wrapper 태스크로 동기화했는지 확인 바랍니다.
  • 공급망 보안 강화를 위해 distributionSha256Sum 고정이 권장됩니다. 현재 validateDistributionUrl=true만으로는 무결성 검증이 불충분합니다.

아래 변경으로 SHA256을 고정해 주세요(값은 실제 해시로 교체).

 distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+distributionSha256Sum=<PUT_ACTUAL_SHA256_OF_gradle-8.8-bin.zip_HERE>

다음 스크립트로 기본 점검을 자동화해 주세요(레포 루트에서 실행).


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gradle-wrapper.properties =="
cat gradle/wrapper/gradle-wrapper.properties

echo -e "\n== gradle-wrapper.jar manifest (Gradle-Version 확인) =="
if command -v unzip >/dev/null 2>&1; then
  unzip -p gradle/wrapper/gradle-wrapper.jar META-INF/MANIFEST.MF | rg -n 'Gradle-Version|Wrapper-Distribution-Url' || true
else
  echo "unzip 미존재: 스킵"
fi

echo -e "\n== Gradle 플러그인 선언 스캔 =="
rg -nP -C1 "id\\s*['\"][^'\"]+['\"]\\s*(version\\s*['\"][^'\"]+['\"])?|plugins\\s*\\{|kotlin\\(\"jvm\"|spring-boot" --type gradle || true

echo -e "\n== Java 툴체인/호환성 설정 스캔 =="
rg -nP "java\\s*\\{\\s*toolchain|JavaLanguageVersion\\.of\\(|sourceCompatibility|targetCompatibility" --type gradle || true

echo -e "\n== 버전 카탈로그(있을 경우) =="
fd libs.versions.toml -uu 2>/dev/null | while read -r f; do
  echo "---- $f ----"
  sed -n '1,120p' "$f"
done

Length of output: 2321


Gradle Wrapper 확인 및 SHA256 설정 필수

  • Spring Boot Gradle 플러그인(3.4.4) 최소 요구사항은 Gradle 7.6.4+ 또는 8.4+로, Gradle 8.8은 호환됨 (github.com)
  • ./gradlew wrapper 실행 후 gradle/wrapper/gradle-wrapper.jar Manifest의 Gradle-Version 및 Wrapper-Distribution-Url이 8.8로 동기화됐는지 확인
  • 공급망 무결성 검증을 위해 gradle/wrapper/gradle-wrapper.properties에
    distributionSha256Sum=<실제 SHA256 해시>  
    
    추가 필요
🤖 Prompt for AI Agents
In gradle/wrapper/gradle-wrapper.properties around line 3, the distribution URL
is set to Gradle 8.8 but the wrapper SHA256 is missing and the wrapper
JAR/Manifest may be out of sync; run ./gradlew wrapper to regenerate the
wrapper, verify gradle/wrapper/gradle-wrapper.jar Manifest entries
(Gradle-Version and Wrapper-Distribution-Url) match 8.8, and then add a
distributionSha256Sum=<actual SHA256 hash for gradle-8.8-bin.zip> to
gradle/wrapper/gradle-wrapper.properties to enable supply-chain integrity
verification.

Comment on lines +24 to 27
// requestId와 SseEmitter를 ,emitter관리 맵에 저장
public void addEmitter(String requestId, SseEmitter emitter) {
emitters.put(requestId, emitter);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Emitter 누수 가능성: 콜백 정리 및 중복 교체 처리 추가 권장

Emitter를 put만 하면 타임아웃/완료/에러 시 맵에서 제거되지 않아 누수됩니다. 이전 동일 키가 있을 경우 안전하게 종료하는 것도 권장합니다.

-// requestId와 SseEmitter를 ,emitter관리 맵에 저장
-public void addEmitter(String requestId, SseEmitter emitter) {
-    emitters.put(requestId, emitter);
-}
+// requestId와 SseEmitter를 emitter 관리 맵에 저장 (중복 교체·콜백 정리)
+public void addEmitter(String requestId, SseEmitter emitter) {
+    // 기존 emitter가 있으면 정리
+    SseEmitter old = emitters.put(requestId, emitter);
+    if (old != null) {
+        try { old.complete(); } catch (Exception ignore) {}
+    }
+    // 연결 수명 이벤트에 대한 정리 콜백 등록
+    emitter.onCompletion(() -> emitters.remove(requestId));
+    emitter.onTimeout(() -> emitters.remove(requestId));
+    emitter.onError(ex -> emitters.remove(requestId));
+}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/sse/service/EmitterService.java
around lines 24 to 27, the addEmitter method currently just puts the SseEmitter
into the map which can leak emitters on timeout/complete/error and may overwrite
an existing emitter for the same requestId; update it to (1) check for an
existing emitter for the requestId and if present safely complete/close it
before replacing, and (2) register completion, timeout, and error callbacks on
the newly added emitter that remove the emitter from the map (and perform any
necessary cleanup) to avoid memory leaks; ensure removal and replacement are
done in a thread-safe way (e.g., using map operations or synchronization) so no
emitter is left referenced after termination.

Comment on lines +3 to +12
import java.time.LocalDateTime;

/**
* 컨텍스트 초기화 응답 DTO
*/
public record ChatContextResetResponse(
Long roomId,
LocalDateTime contextResetAt
) {
} No newline at end of file
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

❓ Verification inconclusive

API 시간 타입: LocalDateTime → Instant/OffsetDateTime 권장.

LocalDateTime은 타임존이 없어 클라이언트별 해석이 달라질 수 있습니다. 공개 API에는 Instant(UTC)나 OffsetDateTime을 사용하세요.

 package hanium.modic.backend.domain.ai.aiChat.dto;

-import java.time.LocalDateTime;
+import java.time.Instant;

 /**
  * 컨텍스트 초기화 응답 DTO
  */
 public record ChatContextResetResponse(
 	Long roomId,
-	LocalDateTime contextResetAt
+	Instant contextResetAt
 ) {
 }

대안: OffsetDateTime 사용 또는 Jackson @JsonFormat으로 일관 포맷 지정.


API 시간 타입을 LocalDateTime → Instant(또는 OffsetDateTime)로 변경하세요.

LocalDateTime은 타임존 정보가 없어 클라이언트별 해석이 달라집니다; 공개 API DTO에는 Instant(UTC) 또는 OffsetDateTime 사용 권장.

파일: src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatContextResetResponse.java (라인 3–12)

 package hanium.modic.backend.domain.ai.aiChat.dto;

-import java.time.LocalDateTime;
+import java.time.Instant;

 /**
  * 컨텍스트 초기화 응답 DTO
  */
 public record ChatContextResetResponse(
 	Long roomId,
-	LocalDateTime contextResetAt
+	Instant contextResetAt
 ) {
 }

대안: OffsetDateTime 사용 또는 Jackson @JsonFormat으로 일관 포맷 지정.

📝 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.

Suggested change
import java.time.LocalDateTime;
/**
* 컨텍스트 초기화 응답 DTO
*/
public record ChatContextResetResponse(
Long roomId,
LocalDateTime contextResetAt
) {
}
package hanium.modic.backend.domain.ai.aiChat.dto;
import java.time.Instant;
/**
* 컨텍스트 초기화 응답 DTO
*/
public record ChatContextResetResponse(
Long roomId,
Instant contextResetAt
) {
}
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ai/aiChat/dto/ChatContextResetResponse.java
around lines 3–12, the DTO exposes LocalDateTime which lacks timezone info;
change the type to java.time.Instant (or java.time.OffsetDateTime if you need
offset) for the contextResetAt field, update the import accordingly, and adjust
any places that construct this DTO to pass an Instant (or OffsetDateTime).
Alternatively, if you must keep LocalDateTime, add a Jackson @JsonFormat with a
fixed timezone/offset to ensure consistent serialization across clients.

Comment on lines +102 to +124
// 투표 시스템 연동: SimilarityVoteEntity 생성 (PENDING 상태)
SimilarityVoteEntity similarityVote = SimilarityVoteEntity.builder()
.originalImageId(originalImageId)
.derivedImageId(createdAiImageId)
.derivedPostId(savedPost.getId()) // 생성된 파생 포스트 ID 연결
.voteType(VoteType.SIMILARITY_CHECK)
.status(VoteStatus.PENDING) // AI 평가 대기 상태
.build();

SimilarityVoteEntity savedVote = similarityVoteRepository.save(similarityVote);

// 투표 집계 초기화: SimilarityVoteSummaryEntity 생성 (기본값 0)
SimilarityVoteSummaryEntity voteSummary = SimilarityVoteSummaryEntity.builder()
.voteId(savedVote.getId())
.approveWeight(0L)
.denyWeight(0L)
.totalWeight(0L)
.aiDecision(VoteDecision.PENDING) // AI 평가 결과 대기
.finalDecision(VoteDecision.PENDING) // 최종 결정 대기
.build();

voteSummaryRepository.save(voteSummary);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

originalImageId 검증 및 참조 무결성 확인 필요

originalImageId의 존재/소유/연관 포스트 일치 여부를 확인하지 않습니다. 원본 포스트의 이미지가 맞는지, 파생 포스트의 부모 포스트와 일치하는지 검증을 추가하세요. 그렇지 않으면 임의의 이미지에 대해 투표가 생성될 수 있습니다.

예시(참고 코드):

PostImageEntity original = postImageEntityRepository.findById(originalImageId)
  .orElseThrow(() -> new AppException(ErrorCode.POST_IMAGE_NOT_FOUND_EXCEPTION));
if (!Objects.equals(original.getPost().getId(), createdAiImage.getPostId())) {
  throw new AppException(ErrorCode.POST_IMAGE_MISMATCH_EXCEPTION);
}

또한 동일 derivedImageId로 중복 투표가 생성되지 않도록 유니크 제약(예: SimilarityVoteEntity.derivedImageId)이나 사전 존재 체크를 권장합니다.

🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java
around lines 102 to 124, you must validate originalImageId and enforce
referential integrity before creating the SimilarityVote and
SimilarityVoteSummary: load the PostImageEntity for originalImageId (or throw
POST_IMAGE_NOT_FOUND_EXCEPTION), verify its associated post ID equals the
created/saved derived post's parent/post ID (or throw
POST_IMAGE_MISMATCH_EXCEPTION) and only then proceed; additionally check for
existing SimilarityVote for the same derivedImageId (and/or originalImageId) to
prevent duplicate votes (or enforce a DB unique constraint) and only save the
new vote/summary after these checks.

Comment on lines +9 to +12
public interface TicketRepository extends JpaRepository<TicketEntity, Long> {

Optional<TicketEntity> findByUserId(Long userId);
} No newline at end of file
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

❓ Verification inconclusive

Optional findByUserId는 다중 행 시 런타임 예외 위험.

user_id가 유니크하지 않다면 JPA가 NonUniqueResultException을 던질 수 있습니다. 1) 스키마에 UNIQUE(user_id) 제약을 두거나, 2) 최신 1건만 의도라면 메서드를 findFirstByUserIdOrderByIdDesc로 변경하세요. 둘 다 어렵다면 List<TicketEntity> findAllByUserId가 안전합니다.

가능한 수정:

- Optional<TicketEntity> findByUserId(Long userId);
+ Optional<TicketEntity> findFirstByUserIdOrderByIdDesc(Long userId);

또는 마이그레이션에서 user_id UNIQUE 제약 추가.


Optional findByUserId — 다중 행 시 런타임 예외 위험

파일: src/main/java/hanium/modic/backend/domain/ticket/repository/TicketRepository.java (Lines 9–12)

user_id가 유니크하지 않으면 JPA의 단건 조회가 NonUniqueResultException(또는 Spring에서 변환된 예외)으로 실패합니다.

  • 해결(권장): DB 스키마에 UNIQUE(user_id) 제약 추가.
  • 대안(최신 1건 의도): 메서드를 Optional findFirstByUserIdOrderByIdDesc(Long userId)로 변경.
  • 대안(다중 허용): List findAllByUserId(Long userId)로 변경.

권장 변경 예시:

- Optional<TicketEntity> findByUserId(Long userId);
+ Optional<TicketEntity> findFirstByUserIdOrderByIdDesc(Long userId);
📝 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.

Suggested change
public interface TicketRepository extends JpaRepository<TicketEntity, Long> {
Optional<TicketEntity> findByUserId(Long userId);
}
public interface TicketRepository extends JpaRepository<TicketEntity, Long> {
Optional<TicketEntity> findFirstByUserIdOrderByIdDesc(Long userId);
}
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ticket/repository/TicketRepository.java
around lines 9 to 12, the current method Optional<TicketEntity>
findByUserId(Long userId) will throw a runtime NonUniqueResultException if
user_id is not unique; update accordingly: if user_id should be unique, add a
UNIQUE constraint on the DB schema and keep/remove method as needed; if you
intend to return the latest single ticket change the method to
findFirstByUserIdOrderByIdDesc(Long userId); if you intend to allow multiple
tickets return List<TicketEntity> findAllByUserId(Long userId). Apply the
appropriate signature change and update any callers to handle Optional vs List
or single entity semantics.

Comment on lines +149 to +176
@GetMapping("/sse/{requestId}")
@Operation(
summary = "AI 이미지 생성 상태 실시간 구독 (SSE)",
description = """
AI 이미지 생성 요청 후, 해당 요청 ID로 SSE 구독을 시작해야 실시간으로 이미지를 받을 수 있습니다.
서버는 이미지 생성 완료 시 SSE를 통해 이미지를 전송하고 서버연결을 끊습니다.
""",
responses = {
@ApiResponse(responseCode = "400", description = "사용자 입력 오류[C-001]"),
@ApiResponse(responseCode = "403", description = "유저 권한 오류[C-002]")
}
)
public SseEmitter subscribe(
@PathVariable @NotBlank(message = "요청 ID는 필수입니다.") String requestId,
@CurrentUser UserEntity userEntity
) {
// SSE 연결 생성 및 Emitter 등록
// Todo: 현재 Timeout을 무한대로 설정했는데, 적절한 값으로 변경 필요 및 처리 기능 필요
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
aiResponseSseService.addEmitter(userEntity.getId(), requestId, emitter);

// 기타 장애가 나면 연결 해제
emitter.onCompletion(() -> aiResponseSseService.removeEmitter(requestId));
emitter.onTimeout(() -> aiResponseSseService.removeEmitter(requestId));
emitter.onError((e) -> aiResponseSseService.removeEmitter(requestId));

return emitter;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

SSE 구독 권한 검증 누락.

requestId가 현재 사용자 소유인지 검증이 없습니다. 타인의 요청에 대한 SSE를 구독할 수 있는 취약점입니다.

권장 수정(예시):

-@GetMapping("/sse/{requestId}")
+@GetMapping(value = "/sse/{requestId}", produces = org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE)
 public SseEmitter subscribe(
   @PathVariable @NotBlank(message = "요청 ID는 필수입니다.") String requestId,
   @CurrentUser UserEntity userEntity
 ) {
+  // 요청 소유자 검증 (서비스에 유틸 추가 필요)
+  // aiChatMessageService.verifyRequestOwnership(userEntity.getId(), requestId);
+
   SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
   aiResponseSseService.addEmitter(userEntity.getId(), requestId, emitter);
   ...
   return emitter;
 }

검증 메서드 구현이 필요하면 알려주세요. 서비스/레포지토리 단에서 existsByRequestIdAndUserId로 간단히 구현 가능합니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatController.java
around lines 149-176, the SSE subscribe endpoint does not verify that the
supplied requestId belongs to the current user, allowing subscription to other
users' requests; before creating or registering the SseEmitter, call a
service/repository method such as existsByRequestIdAndUserId(requestId,
userEntity.getId()) (or a dedicated validation method in a service) and if it
returns false, throw an appropriate access/forbidden exception or return a 403
response; only if validation succeeds create the SseEmitter and add it to
aiResponseSseService, and ensure you do not register the emitter until after
ownership is confirmed (also maintain the existing completion/timeout/error
removal handlers).

@@ -1,4 +1,4 @@
package hanium.modic.backend.web.ai.dto.response;
package hanium.modic.backend.web.ai.aiChat.dto.response;
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

🧩 Analysis chain

패키지명에 대문자 포함(aiChat) — Java 패키지 컨벤션 위반 및 이식성 리스크

Java 패키지는 전부 소문자 사용이 권장됩니다. 대소문자 구분이 다른 OS(특히 macOS/Windows)의 파일시스템에서 빌드·런타임 이슈를 유발할 수 있습니다. 하위 패키지를 ai.chat(또는 aichat)처럼 소문자로 정리하는 것을 권장합니다.

적용 예(diff):

-package hanium.modic.backend.web.ai.aiChat.dto.response;
+package hanium.modic.backend.web.ai.chat.dto.response;

아래 스크립트로 대소문자 패키지 경로/임포트 사용 흔적을 점검해 주세요(리네임 후 모두 업데이트 필요).


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "[package 선언 중 aiChat 사용 위치]"
rg -nP '^\s*package\s+hanium\.modic\.backend\.web\.ai\.aiChat\b' -C0 || true

echo "[import 에서 aiChat 사용 위치]"
rg -nP '^\s*import\s+hanium\.modic\.backend\.web\.ai\.aiChat\b' -C0 || true

echo "[실제 디렉터리명에 대문자 포함 여부]"
fd -HI --type d 'aiChat' || true

Length of output: 2126


패키지명 대문자 사용(aiChat) — 소문자 패키지로 리팩터 필수

검증 결과: 다음 파일/디렉터리에서 'aiChat' 사용이 확인되었습니다.

  • 패키지 선언:
    • src/main/java/hanium/modic/backend/web/ai/aiChat/dto/request/BuyAiImagePermissionRequest.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/BuyAiImagePermissionResponse.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/AiRequestStatusResponse.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiImagePermissionController.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatImageController.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatController.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/GetRemainingGenerationsResponse.java
  • import 사용 위치:
    • src/main/java/hanium/modic/backend/domain/ai/aiChat/service/AiImagePermissionService.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiImagePermissionController.java
    • src/main/java/hanium/modic/backend/web/ai/aiChat/controller/AiChatImageController.java
    • src/test/java/hanium/modic/backend/web/ai/controller/AiImagePermissionControllerIntegrationTest.java
  • 디렉터리:
    • src/main/java/hanium/modic/backend/web/ai/aiChat/
    • src/main/java/hanium/modic/backend/domain/ai/aiChat/

조치 (필수):

  • 패키지명/디렉터리 'aiChat'을 소문자 형태로 리네임(권장: subpackage을 'chat'으로 변경 → hanium.modic.backend.web.ai.chat 등)하고, 모든 package 선언·import를 일관되게 업데이트할 것.
  • case-only rename 주의: macOS/Windows 같은 case-insensitive FS에서는 git이 변경을 감지하지 못함. git mv를 통한 임시명 경유(예: git mv aiChat aiChat_tmp && git mv aiChat_tmp chat) 또는 core.ignorecase 조정을 사용해 올바르게 커밋할 것.
  • 변경 후 전체 빌드·테스트 실행 및 전역 검색(rg)으로 누락된 참조가 없는지 확인할 것.
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/web/ai/aiChat/dto/response/BuyAiImagePermissionResponse.java
(and the other listed files/dirs from lines 1–1), the package segment "aiChat"
must be renamed to a lowercase subpackage (recommend "chat"); update the package
declarations and all imports to hanium.modic.backend.web.ai.chat (and similarly
under domain paths), and rename the directories accordingly; if your filesystem
is case-insensitive, perform a two-step git move (e.g., git mv aiChat aiChat_tmp
&& git mv aiChat_tmp chat) or adjust core.ignorecase so Git records the change;
after renaming, run a full build and test suite and perform a global search (rg)
to verify no references remain.

Comment on lines +151 to 154
boolean permissionExists = aiChatRoomRepository.existsByUserIdAndPostId(user.getId(), post.getId());
// upsert가 먼저 실행되므로 권한은 생성되지만 코인 차감에서 실패해야 함
// 실제로는 트랜잭션 롤백이 되어야 하지만, 현재 구조상 체크
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

검증 누락: 코인 부족 시 권한 생성 여부 단언 필요

permissionExists를 계산만 하고 단언하지 않습니다. 실패 시 권한 미생성을 보장하도록 단언을 추가하세요.

-boolean permissionExists = aiChatRoomRepository.existsByUserIdAndPostId(user.getId(), post.getId());
-// upsert가 먼저 실행되므로 권한은 생성되지만 코인 차감에서 실패해야 함
-// 실제로는 트랜잭션 롤백이 되어야 하지만, 현재 구조상 체크
+boolean permissionExists = aiChatRoomRepository.existsByUserIdAndPostId(user.getId(), post.getId());
+assertThat(permissionExists).isFalse();

또는 서비스 트랜잭션 정책 변경 후 기대값을 재조정하세요(아래 서비스 코멘트 참조).

📝 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.

Suggested change
boolean permissionExists = aiChatRoomRepository.existsByUserIdAndPostId(user.getId(), post.getId());
// upsert가 먼저 실행되므로 권한은 생성되지만 코인 차감에서 실패해야 함
// 실제로는 트랜잭션 롤백이 되어야 하지만, 현재 구조상 체크
}
boolean permissionExists = aiChatRoomRepository.existsByUserIdAndPostId(user.getId(), post.getId());
assertThat(permissionExists).isFalse();
// upsert가 먼저 실행되므로 권한은 생성되지만 코인 차감에서 실패해야 함
// 실제로는 트랜잭션 롤백이 되어야 하지만, 현재 구조상 체크
}
🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/web/ai/controller/AiImagePermissionControllerIntegrationTest.java
around lines 151 to 154, the test computes permissionExists but never asserts
it; add a clear assertion that when coin deduction fails the permission is not
persisted (e.g., assert that permissionExists is false) so the test verifies
that no permission remains after the failed operation (or if you changed service
transaction semantics, adjust the expected boolean accordingly).

@goalSetter09 goalSetter09 merged commit 5119e20 into develop Sep 21, 2025
1 of 2 checks passed
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (3)

51-51: 필드명이 타입명과 동일해 혼동 유발 → 카멜케이스로 변경 권장
동일 식별자는 가독성 저하 및 IDE 경고를 유발합니다. 변수명을 소문자 카멜케이스로 교체하세요.

- @Autowired
- private AiChatImageRepository AiChatImageRepository;
+ @Autowired
+ private AiChatImageRepository aiChatImageRepository;

- createdAiImage = AiChatImageRepository.save(createdAiImage);
+ createdAiImage = aiChatImageRepository.save(createdAiImage);

- otherUserAiImage = AiChatImageRepository.save(otherUserAiImage);
+ otherUserAiImage = aiChatImageRepository.save(otherUserAiImage);

Also applies to: 76-76, 186-186


174-187: 중복 엔티티 빌더 코드 → 헬퍼 추출로 단순화 제안
동일 패턴이 반복됩니다. 테스트 가독성을 위해 팩토리/헬퍼로 추출하세요.

// 예시: 클래스 내부 헬퍼
private AiChatImageEntity persistAiChatImageForUser(Long userId, String imagePath, String fullName, String name) {
    AiChatImageEntity e = AiChatImageEntity.builder()
        .userId(userId)
        .postId(999L)
        .aiChatRoomId(999L)
        .imagePath(imagePath)
        .fullImageName(fullName)
        .imageName(name)
        .extension(ImageExtension.PNG)
        .imagePurpose(ImagePrefix.AI_RESPONSE)
        .fromOriginImage(true)
        .status(AiImageStatus.RESPONSE)
        .build();
    return aiChatImageRepository.save(e);
}

376-385: PNG 파일에 JPEG Content-Type 지정됨 → 메타데이터 불일치 수정
엔티티·파일명이 PNG인데 Content-Type은 image/jpeg입니다. 다운스트림 검증/서빙 시 오동작 가능.

-            .contentType("image/jpeg")
+            .contentType("image/png")

추가로, 필요 시 확장자→MIME 매핑으로 일반화하는 헬퍼 적용을 고려해 주세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6ffff57 and 474ab1a.

📒 Files selected for processing (3)
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java (1 hunks)
  • src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/hanium/modic/backend/domain/vote/service/VoteQueryService.java
  • src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java
🔇 Additional comments (8)
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java (8)

21-23: LGTM: 도메인 엔티티/리포지토리 임포트 교체 적절합니다.


33-36: LGTM: AWS SDK v2 임포트로 정상 전환되었습니다.


41-41: LGTM: S3Client(v2) 주입 전환 이상 없습니다.


81-81: LGTM: originalImageId 전달 추가 적절합니다.


135-135: LGTM: 미존재 AI 이미지 케이스 페이로드 구성 타당합니다.


190-190: LGTM: originalImageId 전달 추가 적절합니다.


344-345: LGTM: 입력 검증(원본 이미지 ID 누락 포함) 메시지/케이스 보강 적절합니다.

Also applies to: 361-361


389-395: LGTM: S3 삭제 요청 구성 적절합니다.

Comment on lines +63 to 77
// AiChatImageEntity 생성 및 저장
AiChatImageEntity createdAiImage = AiChatImageEntity.builder()
.userId(currentUser.getId())
.postId(999L) // 임시 값
.requestId("test-request-123")
.aiChatRoomId(999L) // 임시 값
.imagePath("imagePath")
.fullImageName("ai-image-full.png")
.imageName("ai-image")
.extension(ImageExtension.PNG)
.imagePurpose(ImagePrefix.AI_RESPONSE)
.fromOriginImage(true)
.status(AiImageStatus.RESPONSE)
.build();
createdAiImage = createdAiImageRepository.save(createdAiImage);
createdAiImage = AiChatImageRepository.save(createdAiImage);
uploadImage("imagePath", "imageContent1");
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 Analysis chain

생성 플로우 OK. 단, ‘투표 자동 생성’ 및 derivedPostStatus 검증 누락
PR 목표상 파생 포스트 생성 시 유사성 투표가 자동 생성되고 PostEntity.derivedPostStatus가 초기화됩니다. 해당 부작용을 이 통합 테스트에서 함께 검증해 주세요.

테스트 보강 전, 아래 스크립트로 리포지토리/필드 존재를 확인해 주세요. 결과에 맞춰 단언을 추가하겠습니다.


🏁 Script executed:

#!/bin/bash
# SimilarityVote 리포지토리/엔티티 검색
rg -nP --type=java -C2 '(interface|class)\s+.*SimilarityVote.*Repository|class\s+SimilarityVote(Entity|Result|Summary)'

# PostEntity에 파생 상태 필드 존재 여부
rg -nP --type=java -C2 '\bPostEntity\b'
rg -nP --type=java -C2 '\bderivedPostStatus\b|\bDerivedPostStatus\b'

Length of output: 101545


통합 테스트에 '유사성 투표 자동 생성' 및 derivedPostStatus 검증 추가 필요

  • Post 저장 검증 직후(src/test/.../AiDerivedPostControllerIntegrationTest.java:101-103) savedPost.getDerivedPostStatus()가 PostStatus.PENDING인지 단언 추가.
  • 유사성 투표 생성 검증: SimilarityVoteRepository / SimilarityVoteSummaryRepository / SimilarityVoteResultRepository에서 해당 파생 포스트 또는 원본 이미지에 대한 투표 레코드가 생성됐는지 단언 추가(동일 테스트 파일).

확인된 위치: src/main/java/hanium/modic/backend/domain/vote/entity/SimilarityVoteEntity.java, src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteRepository.java, src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteSummaryRepository.java, src/main/java/hanium/modic/backend/domain/vote/repository/SimilarityVoteResultRepository.java, src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java(derivedPostStatus 필드), src/main/java/hanium/modic/backend/domain/post/service/AiDerivedPostService.java(derivedPostStatus = PostStatus.PENDING 설정).

🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/web/post/controller/AiDerivedPostControllerIntegrationTest.java
around lines 63-77 (and specifically insert after the Post save assertions at
~lines 101-103), add assertions to verify savedPost.getDerivedPostStatus()
equals PostStatus.PENDING, and assert that SimilarityVoteRepository,
SimilarityVoteSummaryRepository, and SimilarityVoteResultRepository contain
expected records for the created derived post or its original image; inject/use
the three repositories in the test, query by derivedPostId or originalImageId
(whichever your service creates votes for), and assert non-empty results (and
optionally expected counts) to confirm the automatic similarity-vote creation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants