Conversation
- 여러 확장성을 고려해 개발자가 사용하는 객체와 내부 객체를 분리하였다.
- 남은 게 0개 허용
Walkthrough분산락 실행기와 LockOptions를 추가해 LockManager를 위임 방식으로 단순화했고, AI 이미지 권한 도메인(AiImagePermission)과 티켓 로직이 재구성되어 권한 upsert/증가와 권한 소비 경로로 전환했습니다. 게시물에 ticketPrice 필드와 관련 DTO/컨트롤러/테스트 일괄 반영 및 ErrorCode·감사 필드 매핑을 조정했습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as User
participant C as AiImagePermissionController
participant S as AiImagePermissionService
participant P as PostRepository
participant R as AiImagePermissionRepository
participant Coin as UserCoinService
U->>C: POST /api/ai/image-permissions/buy-with-coin {postId}
C->>S: buyAiImagePermissionByCoin(userId, postId)
S->>P: findById(postId)
alt post exists
S->>R: upsertAndIncrease(userId, postId, 20)
S->>Coin: consumeCoin(userId, post.nonCommercialPrice)
S-->>C: OK
C-->>U: 200
else post missing
S-->>C: throw POST_NOT_FOUND_EXCEPTION
C-->>U: 404
end
sequenceDiagram
autonumber
actor U as User
participant C as AiImagePermissionController
participant S as AiImagePermissionService
participant P as PostRepository
participant R as AiImagePermissionRepository
participant T as AiRequestTicketService
U->>C: POST /api/ai/image-permissions/buy-with-ticket {postId}
C->>S: buyAiImagePermissionByTicket(userId, postId)
S->>P: findById(postId)
alt post exists
S->>R: upsertAndIncrease(userId, postId, 20)
S->>T: useTicket(userId, post.ticketPrice)
S-->>C: OK
C-->>U: 200
else post missing
S-->>C: throw POST_NOT_FOUND_EXCEPTION
C-->>U: 404
end
sequenceDiagram
autonumber
actor U as User
participant ImgC as AiImageController
participant GenS as AiImageGenerationService
participant PermS as AiImagePermissionService
participant LM as LockManager
participant R as AiImagePermissionRepository
U->>ImgC: POST /api/ai/images/requests
ImgC->>GenS: processImageGeneration(..., postId, userId)
GenS->>PermS: consumeRemainingGenerations(userId, postId)
PermS->>LM: aiImagePermissionLock(userId, postId){block}
activate LM
LM-->>PermS: execute block under lock
deactivate LM
PermS->>R: findByUserIdAndPostId(...)
alt found and remaining > 0
PermS->>R: save(decremented)
GenS-->>ImgC: enqueue + respond
ImgC-->>U: 200
else not found / 0
PermS-->>GenS: throw AI_IMAGE_PERMISSION_NOT_FOUND or AI-007
GenS-->>ImgC: propagate error
ImgC-->>U: 404/400
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. 📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 💡 Knowledge Base configuration:
You can enable these sources in your CodeRabbit configuration. 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 17
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)
55-59: Long 비교에서 리터럴 접미사(L) 누락으로 테스트 실패 가능
getTicketCount()가 Long으로 변경되면서isEqualTo(3)/isEqualTo(2)는 타입 불일치로 실패할 수 있습니다.3L/2L로 수정해 주세요.- assertThat(savedTicket.getTicketCount()).isEqualTo(3); + assertThat(savedTicket.getTicketCount()).isEqualTo(3L); - assertThat(refreshedTicket.getTicketCount()).isEqualTo(3); + assertThat(refreshedTicket.getTicketCount()).isEqualTo(3L); - assertThat(otherTicketAfter.getTicketCount()).isEqualTo(2); // 여전히 2개 + assertThat(otherTicketAfter.getTicketCount()).isEqualTo(2L); // 여전히 2개 - assertThat(currentUserTicket.getTicketCount()).isEqualTo(3); + assertThat(currentUserTicket.getTicketCount()).isEqualTo(3L);Also applies to: 119-128, 154-163
src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java (1)
96-102: processImageGeneration 시그니처 변경 사항 전수 점검 및 수정 필요 (useTicket 제거)아래 항목들을 반드시 반영·검증해 주세요.
• 호출부 업데이트
- 컨트롤러(
AiImageController) 외에AiImageGenerationServiceTest에서도 5개 인자 호출부만 존재함이 확인되었습니다. 테스트 코드의 호출 인자도 최신 시그니처(5개)로 모두 수정해주세요.
• DTO 필드 정리AiImageGenerationRequest에 남아 있는Boolean useTicket필드는 더 이상 사용되지 않으므로 제거하거나, 새로운 결제 방식 표현 방식으로 대체해야 합니다.
• 예외 매핑 최신화processImageGeneration또는 내부AiImagePermissionService·AiRequestTicketService에서 던지는AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION 등)이@ControllerAdvice의@ExceptionHandler에 올바르게 매핑되어 있는지 확인하고, 필요 시 핸들러를 추가·수정해주세요.
🧹 Nitpick comments (56)
src/main/java/hanium/modic/backend/web/ai/dto/response/GetTicketInformationResponse.java (2)
9-9: ticketCount Long 전환은 합리적이나, null 비허용이면 primitive(long)으로 고정 + 스키마 최소값 제약 권장
- 전반적인 Long 전환 흐름과 일치해 타입 확장은 적절합니다.
- 다만 응답 DTO에서 남은 티켓 수가 null일 필요가 없다면 Long 대신 long으로 두는 편이 NPE를 원천 차단하고 박싱 비용도 줄입니다.
- OpenAPI 스키마에 최소값을 명시해 음수 진입을 막는 의도를 드러내면 좋습니다(minimum = "0", example).
null이 “미집계/미보유”의 의미로 필요하다면 현 상태(Long) 유지가 맞습니다. 도메인 요구사항 확인 부탁드립니다.
적용 예시:
- Long ticketCount, + long ticketCount,추가로(선택): 레코드 compact constructor로 방어적 검증을 강제할 수 있습니다.
public GetTicketInformationResponse { if (ticketCount < 0) { throw new IllegalArgumentException("ticketCount must be >= 0"); } java.util.Objects.requireNonNull(nextReset, "nextReset must not be null"); }스키마 보강 예시(라인 8 인근, 참고용):
@Schema(description = "남은 티켓 수", minimum = "0", example = "20")
15-20: 팩토리 메서드에 입력 검증 추가(음수/널)로 계약 보장현재 of(...)는 값 검증이 없어 음수나 null(nextReset) 입력이 그대로 노출될 수 있습니다. 가벼운 가드 추가를 권장합니다.
적용 diff:
- public static GetTicketInformationResponse of(long ticketCount, LocalDateTime nextReset) { - return new GetTicketInformationResponse( - ticketCount, - nextReset - ); - } + public static GetTicketInformationResponse of(long ticketCount, LocalDateTime nextReset) { + if (ticketCount < 0) { + throw new IllegalArgumentException("ticketCount must be >= 0"); + } + java.util.Objects.requireNonNull(nextReset, "nextReset must not be null"); + return new GetTicketInformationResponse(ticketCount, nextReset); + }참고: nextReset은 LocalDateTime이라 타임존 정보가 소실됩니다. 외부 API 클라이언트 혼선을 줄이려면 OffsetDateTime/Instant로의 전환 또는 “서버 기준 타임존(예: UTC)”을 스키마 설명/예제에 명시하는 방법을 고려해 주세요.
src/main/java/hanium/modic/backend/common/entity/BaseEntity.java (1)
22-23: 컬럼 스펠링 컨벤션 정합성(선택): created_at/updated_at 권장사내/산업 표준에 맞춰 과거분 포함 전역 일관성을 원한다면
created_at,updated_at스펠링이 더 흔합니다. 현재create_at,update_at은 오탈자로 오해될 여지가 있습니다. 변경 시 DB 마이그레이션과 영향도(리포트/BI)를 함께 고려해주세요.가능한 변경 예:
- @Column(name = "create_at", updatable = false) + @Column(name = "created_at", updatable = false) - @Column(name = "update_at") + @Column(name = "updated_at")Also applies to: 26-27
src/main/java/hanium/modic/backend/web/ai/dto/request/BuyAiImagePermissionRequest.java (2)
5-5: 불필요한 import 제거record에서는 접근자/게터가 자동 생성되므로
lombok.Getter는 사용되지 않습니다. 정리 부탁드립니다.-import lombok.Getter;
9-11: postId 유효성 강화(@Positive/@min) 및 OpenAPI required 표기 개선
- 입력 검증:
@NotNull만으로는 0/음수 ID를 차단하지 못합니다.@Positive(또는@Min(1)) 추가를 권장합니다.- OpenAPI:
@Schema(required = true)대신requiredMode = REQUIRED사용이 더 명시적입니다.-import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; @Schema(description = "AI 이미지 생성권 구매 요청") public record BuyAiImagePermissionRequest( - @Schema(description = "구매할 게시물 ID", example = "1", required = true) - @NotNull(message = "게시물 ID는 필수입니다.") + @Schema(description = "구매할 게시물 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "게시물 ID는 필수입니다.") + @Positive(message = "게시물 ID는 1 이상의 양수여야 합니다.") Long postId ) { }src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java (1)
11-16: 옵션 값 검증 추가(음수/NULL 방지)와 기본값 단일화 제안현재 Builder로 음수 wait/lease, null timeUnit 설정이 가능해 런타임 예외를 야기할 수 있습니다. 간단한 팩토리 메서드로 유효성 검사 추가를 권장합니다. 또한 기본 옵션을 여러 클래스에서 중복 정의하지 않도록, 본 클래스에
DEFAULT를 두고 Executor에서 이를 참조하도록 일원화하면 드리프트를 줄일 수 있습니다.public class LockOptions { private final long waitTime; private final long leaseTime; private final TimeUnit timeUnit; private final boolean requiresNewTx; // 필요 시 트랜잭션 분리 제어(선택) + + /** + * 합리적 기본값(예: 대기 3초, 리스 10초, SECONDS 기준, 동일 트랜잭션) 제공. + * Executor 등에서 재정의가 필요하면 별도 상수를 정의하기보다 이 DEFAULT를 참조하세요. + */ + public static final LockOptions DEFAULT = LockOptions.builder() + .waitTime(3L) + .leaseTime(10L) + .timeUnit(TimeUnit.SECONDS) + .requiresNewTx(false) + .build(); + + /** + * 안전한 생성 보조: 음수/NULL 방지 + */ + public static LockOptions of(long waitTime, long leaseTime, TimeUnit unit, boolean requiresNewTx) { + if (waitTime < 0) throw new IllegalArgumentException("waitTime must be >= 0"); + if (leaseTime <= 0) throw new IllegalArgumentException("leaseTime must be > 0"); + if (unit == null) throw new IllegalArgumentException("timeUnit must not be null"); + return LockOptions.builder() + .waitTime(waitTime) + .leaseTime(leaseTime) + .timeUnit(unit) + .requiresNewTx(requiresNewTx) + .build(); + } }src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)
21-45: 동시 차감 경쟁 상황 대비 낙관적 락 고려(선택)여러 요청이 동시에 동일 사용자의 티켓을 차감할 수 있는 경로가 있다면, 낙관적 락(@Version)으로 로스트 업데이트를 방지하는 것이 안전합니다. 본 PR에서 권한 구매 플로우에 낙관적 락을 적용했다고 하나, 티켓 자체에도 동일 보안망을 두는 것을 검토해 주세요.
가능한 추가 필드:
// import jakarta.persistence.Version; @Version private Long version;src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (2)
96-96: 매직 넘버(3) 대신 도메인 상수 사용 권장무료 발급 개수가 바뀌면 테스트가 깨질 수 있습니다.
FREE_TICKET_COUNT_PER_DAY상수를 사용해 결합도를 낮추세요.- expiredTicket.decreaseTicket(3); + expiredTicket.decreaseTicket(FREE_TICKET_COUNT_PER_DAY);상수 import 추가:
import static hanium.modic.backend.domain.ai.enums.AiRequestTicketConstants.FREE_TICKET_COUNT_PER_DAY;
99-105: 리플렉션 기반 필드 조작 최소화(선택)만료 시나리오 셋업을 위해 리플렉션을 사용하고 있습니다. 테스트 안정성과 유지보수를 위해 다음 대안을 고려해 주세요:
- 도메인 서비스/헬퍼에서
Clock주입 후 테스트에서 고정 시계 사용- 테스트 유틸로
lastIssuedAt을 간접 변경(팩토리/빌더 확장)src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java (1)
19-19: 테스트 데이터에 ticketPrice 주입은 적절. 매직 넘버(3L) 상수화 권장
- 추후 정책 변경 시 테스트 전반 수정량을 줄이려면 상수로 추출하는 편이 깔끔합니다.
적용 예시(diff: 변경 라인 내 교체):
- .ticketPrice(3L) + .ticketPrice(DEFAULT_TICKET_PRICE)- .ticketPrice(3L) + .ticketPrice(DEFAULT_TICKET_PRICE)파일 상단(클래스 내부) 보조 추가 코드:
private static final long DEFAULT_TICKET_PRICE = 3L;Also applies to: 35-35
src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewControllerIntegrationTest.java (1)
72-76: 권한 이력 생성 로직은 OK. ‘잔여 생성권 0’ 실패 케이스 테스트 추가 제안
- isActive 제거 이후 권한 판단이
remainingGenerations > 0에 수렴했다면, 0일 때의 실패 경로가 AI-007(REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION)로 정확히 매핑되는지 통합 테스트를 하나 보강하는 것을 권합니다. 회귀 방지에 유용합니다.원하시면 본 파일에 아래 형태의 케이스를 추가해 드리겠습니다(개략):
@Test @DisplayName("리뷰 작성 실패 - AI 이미지 생성권 0건") @WithCustomUser(email = "user1@test.com") void createReview_Fail_WhenRemainingZero() throws Exception { // given: postOwner, post, savedImage 준비 aiImagePermissionRepository.save(AiImagePermissionEntity.builder() .userId(user.getId()) .postId(post.getId()) .remainingGenerations(0) .build()); final CreatePostReviewRequest request = new CreatePostReviewRequest("리뷰", List.of(savedImage.getId())); final String json = objectMapper.writeValueAsString(request); // expect mockMvc.perform(post("/api/post-reviews") .param("postId", post.getId().toString()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION.getCode())); }src/test/java/hanium/modic/backend/web/post/controller/PostControllerIntegrationTest.java (2)
86-86: ticketPrice 필드에 대한 검증 단언 추가 권장요청 DTO에 ticketPrice(0L)를 전달하고 있으므로, 영속 결과에도 ticketPrice가 반영됐는지를 단언해 주면 회귀 방지에 도움이 됩니다.
다음과 같이 단언을 추가해 주세요:
@@ assertThat(saved.getCommercialPrice()).isEqualTo(10000L); assertThat(saved.getNonCommercialPrice()).isEqualTo(5000L); + assertThat(saved.getTicketPrice()).isEqualTo(0L); assertThat(images.size()).isEqualTo(2);
147-147: 수정 API 테스트에도 ticketPrice 단언 포함 제안update 요청에서도 ticketPrice(0L)를 전달하므로 업데이트 결과에 반영되었는지 검증해 주세요.
예:
@@ assertThat(updatedPost.getCommercialPrice()).isEqualTo(20000L); assertThat(updatedPost.getNonCommercialPrice()).isEqualTo(10000L); + assertThat(updatedPost.getTicketPrice()).isEqualTo(0L);src/main/java/hanium/modic/backend/web/post/dto/request/UpdatePostRequest.java (1)
29-32: @PositiveOrZero로 검증 통일 및 생성자 호출부 인자 순서 확인 완료
- UpdatePostRequest의
ticketPrice필드에 대해
@min(0) 대신@PositiveOrZero(message = "티켓 가격은 0 이상이어야 합니다.")사용을 권장합니다.- CreatePostRequest의
ticketPrice검증 어노테이션과 메시지도 동일하게 맞춰주세요.- 테스트 및 통합 테스트 내 모든
new UpdatePostRequest(...)호출부가
[title, description, commercialPrice, nonCommercialPrice, ticketPrice, imageIds]순서로 6개 인자를 올바르게 반영하고 있음을 확인했습니다.+ import jakarta.validation.constraints.PositiveOrZero; @@ - @Min(value = 0, message = "티켓 가격은 0 이상이어야 합니다.") + @PositiveOrZero(message = "티켓 가격은 0 이상이어야 합니다.") Long ticketPrice,src/main/java/hanium/modic/backend/web/ai/dto/response/BuyAiImagePermissionResponse.java (1)
6-12: Lombok record + @builder 호환성 확인 및 DTO 단순화 검토
- Lombok 1.18.36( Spring Boot 3.4.4 의존)에서
record와@Builder조합이 정상 지원됨을 확인했습니다.- 단순 DTO라면
@Builder를 제거하고 캐노니컬 생성자만 사용해도 무방합니다.- 다만, 프로젝트 내 다른 DTO 패턴과 일관성을 위해 유지해도 괜찮습니다.
remainingGenerations의 타입은 서비스/엔티티 계층과 맞추는 것이 좋습니다.
- 값이 항상 존재한다면
int(원시)로, 더 큰 범위가 필요하다면Long으로 변경을 고려해 보세요.src/test/java/hanium/modic/backend/web/post/controller/PublicPostControllerTest.java (1)
67-67: 신규 필드(ticketPrice) 응답 JSON 단언 추가 권장컨트랙트 보호를 위해 ticketPrice도 응답 본문에서 검증해 주세요.
예:
@@ .andExpect(jsonPath("$.data.nonCommercialPrice").value(mockPost.getNonCommercialPrice())) + .andExpect(jsonPath("$.data.ticketPrice").value(mockPost.getTicketPrice().intValue())) .andExpect(jsonPath("$.data.likeCount").value(10))src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (2)
124-124: long 파라미터 호출 시 리터럴 표기를 1L로 통일 권장컴파일에 문제는 없지만, 시그니처 명확성을 위해 long 리터럴(1L) 사용을 추천합니다.
- aiRequestTicketService.useTicket(TEST_USER_ID, 1); + aiRequestTicketService.useTicket(TEST_USER_ID, 1L); @@ - aiRequestTicketService.useTicket(TEST_USER_ID, 1); + aiRequestTicketService.useTicket(TEST_USER_ID, 1L); @@ - aiRequestTicketService.useTicket(TEST_USER_ID, 1); + aiRequestTicketService.useTicket(TEST_USER_ID, 1L); @@ - aiRequestTicketService.useTicket(TEST_USER_ID, 1); + aiRequestTicketService.useTicket(TEST_USER_ID, 1L); @@ - aiRequestTicketService.useTicket(TEST_USER_ID_2, 1); + aiRequestTicketService.useTicket(TEST_USER_ID_2, 1L);Also applies to: 173-173, 199-199, 237-237, 245-245
191-191: 스레드 수 캐스팅: Math.toIntExact로 명시적 변환 권장캐스팅은 침묵 오버플로 가능성이 있습니다. 현재 상수(3L)에서는 영향 없지만 습관적으로
Math.toIntExact를 권장합니다.- int threadCount = (int)FREE_TICKET_COUNT_PER_DAY; // 3개 스레드로 정확히 티켓 수만큼 + int threadCount = Math.toIntExact(FREE_TICKET_COUNT_PER_DAY); // 티켓 수만큼src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java (2)
75-79: 에러 시맨틱 가이드 보강 제안(권한 미보유/소진 케이스)멀티라인 description에 AI-004(구매 이력 없음), AI-007(권한 소진)을 명시한 점 좋습니다. 다만 표준 HTTP 시맨틱 관점에서는 다음 정렬을 고려해보세요:
- AI-004(권한 리소스 자체가 없음): 404 또는 403 중 하나로 팀 합의가 필요. 리소스 모델을 "사용자-게시물 생성권"으로 본다면 404가 설득력 있으나, 정책 위반 관점에선 403이 더 보편적입니다.
- AI-007(권한은 있으나 잔여 수량 부족): 409 Conflict가 더 적합합니다. 현재는 400으로 표시되어 있어 클라이언트에서 원인 분기 처리가 모호합니다.
문서-실제 예외 매핑(ControllerAdvice)과 코드 전반의 일관성을 맞추면 클라이언트 개발/운영이 쉬워집니다.
84-84: 응답 코드/에러 정의 정합성 점검: A-005(티켓 처리 실패) 노출 필요성 재검토이 엔드포인트는 이제 "권한 소진/차감" 기반으로 동작하므로 티켓 사용(A-005, A-006)은 직접 개입하지 않을 가능성이 큽니다. 실제로 티켓 구매는 권한 구매 API로 분리되었다면:
- A-005(티켓 처리 실패) 응답은 제거하는 편이 명확합니다.
- A-006(티켓 부족)도 이 엔드포인트에서 발생하지 않는다면 문서에서 제외하고 권한 구매 API에만 표기하세요.
- AI-007/AI-008의 400 코드는 409/500 등으로 재분류를 검토(위 코멘트 참조).
문서만 바꾸면 오해가 생길 수 있으니, 서비스/예외 매핑도 함께 정리 권장합니다.
적용 예(문서만의 예시):
- @ApiResponse(responseCode = "500", description = "티켓 처리에 실패했습니다.[A-005]"), - @ApiResponse(responseCode = "400", description = "티켓이 부족합니다.[A-006]"), - @ApiResponse(responseCode = "400", description = "AI 이미지 생성권이 부족합니다.[AI-007]"), + // 권한 차감 실패(잔여 부족) + @ApiResponse(responseCode = "409", description = "AI 이미지 생성권이 부족합니다.[AI-007]"), @ApiResponse(responseCode = "400", description = "이미지 생성권 처리에 실패하였습니다.(서버 문제)[AI-008]"),Also applies to: 86-89
src/main/java/hanium/modic/backend/web/post/dto/request/CreatePostRequest.java (1)
27-30: ticketPrice 필드 추가 적절함(LGTM) + 문서 보강 제안@NotNull/@min(0)로 입력 검증을 강제한 점 좋습니다. OpenAPI 문서에 ticketPrice 의미(“해당 게시물 AI 생성권 구매 시 소모되는 티켓 수”)를 명시하면 프런트/운영 혼선이 줄어듭니다. DTO에 @Schema(description = "...", example = "3")를 추가하는 가벼운 보강을 추천드립니다.
src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java (2)
49-58: 생성자 null 방어 추가 권장DTO/웹 검증에서 @NotNull이지만, 도메인 계층에서도 방어로직을 두면 안전합니다. null이 들어오면 JPA flush 시점에야 실패하여 디버깅 비용이 커집니다.
다음과 같이 가벼운 선행 검증을 추가하세요.
public PostEntity( Long userId, String title, String description, Long commercialPrice, Long nonCommercialPrice, Long ticketPrice ) { + if (ticketPrice == null) { + throw new IllegalArgumentException("ticketPrice must not be null"); + } this.userId = userId; this.title = title; this.description = description; this.commercialPrice = commercialPrice; this.nonCommercialPrice = nonCommercialPrice; this.ticketPrice = ticketPrice; }
72-74: update 메서드도 null 방어 필요nullable=false 컬럼이므로 null 대입 시 영속화 실패합니다. 동일한 방어를 권장합니다.
- public void updateTicketPrice(Long ticketPrice) { - this.ticketPrice = ticketPrice; - } + public void updateTicketPrice(Long ticketPrice) { + if (ticketPrice == null) { + throw new IllegalArgumentException("ticketPrice must not be null"); + } + this.ticketPrice = ticketPrice; + }src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (2)
73-79: 원시/래퍼 타입 혼용 정리(일관성)본 클래스 내 파라미터가
Long/long혼용입니다. 일관되게 하나로 통일하면 가독성이 좋아집니다. 본 파일은 전반적으로long을 선호하므로 아래처럼 맞추는 것을 제안합니다.- public AiRequestTicketEntity createInitialTicket(Long userId) { + public AiRequestTicketEntity createInitialTicket(long userId) { AiRequestTicketEntity newUserTicket = AiRequestTicketEntity.builder() .userId(userId) .build(); return aiRequestTicketRepository.save(newUserTicket); }
31-35: 확인 완료: user_id 유니크 제약 및 레디슨 락 재진입성 보장
AiRequestTicketEntity클래스의어노테이션을 통해 JPA 레벨에서@Column(name = "user_id", nullable = false, unique = true)user_id에 유니크 제약이 적용되어 있어, 데이터베이스 스키마 생성 시에도 고유 인덱스가 생성됩니다.DistributionLockExecutor의withLock구현을 보면으로RLock lock = redissonClient.getLock(key); executeWith(lock, key, opts, block);RLock(Redisson의 재진입 락)을 사용하고, 중첩 호출 시에도 내부적으로 락 획득 횟수만큼unlock()을 호출하도록 설계되어 있어(tryLock → aopForTransaction → unlock), 재진입 락이 안전하게 동작합니다.- 다만
getTicketEntity의createInitialTicket(userId)호출은 락 경계 바깥에서 수행되므로, 만약 외부에서 직접 이 메서드를 동시 호출할 경우(예: 테스트나 별도 조회 로직) 유니크 제약 위반 예외가 발생할 수 있습니다.
→ 서비스 사용 방식을 명확히 가이드하거나, 초기 생성 로직도aiRequestTicketLock안에서 실행되도록 리팩터링을 고려해볼 수 있습니다.src/test/java/hanium/modic/backend/domain/post/service/PostServiceTest.java (3)
82-82: ticketPrice 전달 검증 추가 필요createPostTest에서 ticketPrice를 주입하지만 저장된 엔티티에 반영되었는지 검증이 없습니다. 다음 단언을 추가해 회귀를 방지하세요.
추가 단언 예시(then 절에 이어서):
assertThat(savedPost.getTicketPrice()).isEqualTo(ticketPrice);Also applies to: 96-96
546-546: updatePost 테스트에 ticketPrice 변경 단언 추가updatePost_Success 테스트도 제목/설명/가격만 검증하고 ticketPrice는 검증하지 않습니다. 다음 단언을 추가해 주세요.
assertEquals(ticketPrice, mockPost.getTicketPrice());Also applies to: 556-556
218-220: Mock 미설정으로 인한 취약한 검증(우연 통과 가능성)해당 테스트에서 expectedImages를 만들 때
imageUtil.createImageGetUrl(...)을 호출하지만 Stub이 없어 null이 반환될 수 있습니다. 현재는 서비스도 동일 Mock을 사용해 “둘 다 null”이라 우연히 통과할 여지가 있습니다. URL 반환을 명시적으로 Stub 하여 의도를 고정하세요.권장 수정(테스트 상단 Given 섹션에 추가):
final String URL = "https://signed-url.com/image.jpg"; when(imageUtil.createImageGetUrl(anyString())).thenReturn(URL);그리고 expectedImages 구성 시 현재 코드대로 호출해도 동일 URL이 주입되어 비교가 더 신뢰할 수 있게 됩니다.
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java (2)
120-123: 티켓 결제 성공 시나리오도 동일하게 정규화upsert stubbing을 타입 매처로 통일해 주세요. 불필요한 매처 혼용을 줄이면 보고된 InvalidUseOfMatchersException(라인 121) 해소에 도움이 됩니다.
-when(aiImagePermissionRepository.upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), eq(20))) - .thenReturn(1); +when(aiImagePermissionRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt())) + .thenReturn(1);
111-114: 예외 발생 시 verify 순서/호출 보장 검토예외가 던져진 후에도 Mockito는 호출을 기록하지만, 때때로 예외 전/후 호출 순서 때문에 UnfinishedVerificationException이 날 수 있습니다. 필요하다면 InOrder로 순서를 명시하세요.
InOrder inOrder = inOrder(postRepository, aiImagePermissionRepository, userCoinService); inOrder.verify(postRepository).findById(testPost.getId()); inOrder.verify(aiImagePermissionRepository).upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), eq(20)); inOrder.verify(userCoinService).consumeCoin(eq(testUser.getId()), eq(testPost.getNonCommercialPrice()));src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java (3)
45-46: 의존성 정리: Repository 직접 의존 제거 고려권한 검증을 AiImagePermissionService로 위임하셨으므로, AiImagePermissionRepository 직접 의존은 제거 가능해 보입니다(아래 중복 검증 제거와 함께). 서비스 경계를 명확히 해 결합도를 낮추는 편이 좋습니다.
67-69: 권한 중복 검증 제거로 I/O 절감 및 단일 책임화이미 consumeRemainingGenerations 내부에서 권한 존재/잔여횟수 검증과 예외 처리를 수행한다면, 사전 exists 체크는 중복입니다. 불필요한 쿼리를 제거하고 실패 시 트랜잭션 롤백에 의존해도 충분합니다.
-// 사용자 권한 검증 -validateAiRequestPermission(userId, postId); ... -// 사용권소모 -aiImagePermissionService.consumeRemainingGenerations(userId, postId); +// 사용권 소모 (내부에서 권한/잔여 검증 및 예외 처리) +aiImagePermissionService.consumeRemainingGenerations(userId, postId);또한 중복 검증 제거 시 AiImagePermissionRepository 필드와 validateAiRequestPermission 메서드도 함께 제거할 수 있습니다(아래 참고).
153-158: validateAiRequestPermission는 중복 역할 가능성해당 메서드는 존재 여부만 검사하고, 바로 이어서 consumeRemainingGenerations에서 다시 권한을 조회/검증할 가능성이 큽니다. 제거를 권장합니다.
-// 해당 Post에 대한 AI 이미지 생성 권한 검증 -private void validateAiRequestPermission(Long userId, Long postId) { - if (!aiImagePermissionRepository.existsByUserIdAndPostId(userId, postId)) { - throw new AppException(ErrorCode.AI_IMAGE_PERMISSION_NOT_FOUND); - } -}필드
private final AiImagePermissionRepository aiImagePermissionRepository;도 함께 정리 가능합니다.src/test/java/hanium/modic/backend/web/ai/controller/AiImagePermissionControllerIntegrationTest.java (2)
52-56: 테스트 데이터 명시성 강화: 초기 티켓 수를 builder로 지정기본값(예: 3장)에 의존하면 엔티티 기본값이 바뀔 때 테스트가 은근히 깨질 수 있습니다. 테스트에서 명시적으로 초기 티켓 수를 지정하는 편이 안전합니다.
private AiRequestTicketEntity createTestTicket(UserEntity user) { - return aiRequestTicketRepository.save(AiRequestTicketEntity.builder() - .userId(user.getId()) - .build()); + return aiRequestTicketRepository.save(AiRequestTicketEntity.builder() + .userId(user.getId()) + .ticketCount(3L) + .build()); }
151-155: 코인 부족 시 upsert 롤백 여부를 단언으로 보강현재 주석만 있고 단언이 없습니다. 트랜잭션 롤백이 보장된다면 권한이 생성되지 않았음을 명시적으로 검증하세요. 또한 유저 코인 값이 변하지 않았는지도 함께 확인하면 좋습니다.
-// 권한이 생성되지 않았는지 확인 (upsert에 의해 생성되었을 수도 있으므로 확인 필요) -boolean permissionExists = aiImagePermissionRepository.existsByUserIdAndPostId(user.getId(), post.getId()); -// upsert가 먼저 실행되므로 권한은 생성되지만 코인 차감에서 실패해야 함 -// 실제로는 트랜잭션 롤백이 되어야 하지만, 현재 구조상 체크 +boolean permissionExists = aiImagePermissionRepository.existsByUserIdAndPostId(user.getId(), post.getId()); +assertThat(permissionExists).isFalse(); +UserEntity unchanged = userRepository.findById(user.getId()).orElseThrow(); +assertThat(unchanged.getCoin()).isEqualTo(500L);롤백이 설계상 어렵다면(분산락/외부 송금 등), 테스트 설명을 현실에 맞게 수정해 주세요.
src/main/java/hanium/modic/backend/web/ai/controller/AiImagePermissionController.java (2)
41-48: 응답 컨벤션 일관화 제안다른 컨트롤러는 AppResponse 래퍼를 사용합니다. 본 엔드포인트도 AppResponse로 일관화하면 API 응답 포맷이 통일됩니다.
-@PostMapping("/buy-with-coin") -public ResponseEntity<Void> buyAiImagePermissionWithCoin( +@PostMapping("/buy-with-coin") +public ResponseEntity<AppResponse<Void>> buyAiImagePermissionWithCoin( @CurrentUser UserEntity user, @Valid @RequestBody BuyAiImagePermissionRequest request ) { aiImagePermissionService.buyAiImagePermissionByCoin(user.getId(), request.postId()); - - return ResponseEntity.ok().build(); + return ResponseEntity.ok(AppResponse.ok()); }
61-69: 응답 컨벤션 일관화(티켓 구매)코인 구매와 동일하게 AppResponse 사용 일관화를 권장합니다.
-@PostMapping("/buy-with-ticket") -public ResponseEntity<Void> buyAiImagePermissionWithTicket( +@PostMapping("/buy-with-ticket") +public ResponseEntity<AppResponse<Void>> buyAiImagePermissionWithTicket( @CurrentUser UserEntity user, @Valid @RequestBody BuyAiImagePermissionRequest request ) { aiImagePermissionService.buyAiImagePermissionByTicket(user.getId(), request.postId()); - - return ResponseEntity.ok().build(); + return ResponseEntity.ok(AppResponse.ok()); }src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java (2)
28-28: 상수는static final로 선언하세요.인스턴스마다 불필요하게 상수 필드가 생성됩니다. 클래스 상수로 승격해 주세요.
- private final int AI_IMAGE_PERMISSION_COUNT = 20; + private static final int AI_IMAGE_PERMISSION_COUNT = 20;
61-73: 소비 경로는 OK. 다만 SQL 원자 갱신으로 락 비용을 줄일 대안 존재현재 분산락 하에서 조회→감소→save 패턴은 정합성은 맞지만 락 비용이 큽니다. 아래처럼 조건부 원자 감소 쿼리(remaining_generations > 0)를 리포지토리에 추가하면 분산락 없이도 레이스를 제거할 수 있습니다. 선택 사항으로 고려해 주세요.
예시 리포지토리 메서드(참고용):
@Modifying @Query(value = """ UPDATE ai_image_permissions SET remaining_generations = remaining_generations - 1, update_at = NOW() WHERE user_id = :userId AND post_id = :postId AND remaining_generations > 0 """, nativeQuery = true) int decrementIfPossible(@Param("userId") Long userId, @Param("postId") Long postId);서비스 사용:
int updated = aiImagePermissionRepository.decrementIfPossible(userId, postId); if (updated == 0) { // 엔티티 없음 또는 잔여 0인 경우를 통합 처리 throw new AppException(REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION); }src/main/java/hanium/modic/backend/domain/ai/domain/AiImagePermissionEntity.java (1)
52-58: 잔여 생성횟수 부족 시 도메인 예외: 적절합니다. 다만 DB 레벨 방어도 고려도메인 방어는 좋습니다. 추가로 DB 레벨에서 음수 방지를 위해 CHECK 제약(또는 MySQL이면
remaining_generations INT UNSIGNED)도 고려해 주세요. 테스트에 음수 방지 케이스를 포함하면 좋습니다.원하시면 Flyway/Liquibase 마이그레이션 스니펫도 제공하겠습니다.
src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (3)
92-101: 검증 강화: 소비 메서드 호출 여부를 명시적으로 검증하세요.성공 케이스에서
consumeRemainingGenerations호출을 검증하면 리팩터링 시 회귀를 막을 수 있습니다.doNothing().when(aiImagePermissionService).consumeRemainingGenerations(TEST_USER_ID, postId); when(aiImageService.createImageGetUrl(mockAiRequest.getId())).thenReturn(TEST_IMAGE_URL); // when RequestAiImageGenerationResponse result = aiImageGenerationService.processImageGeneration( imageUsagePurpose, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_POST_ID, TEST_USER_ID); // then + verify(aiImagePermissionService).consumeRemainingGenerations(TEST_USER_ID, TEST_POST_ID);
113-125: 권한 없음 사전 체크 vs 소모 시점 예외의 중복 처리 재검토(선택)현재는 존재 여부를 리포지토리로 선검사하고, 이후 소모 서비스에서도 예외가 발생할 수 있습니다. 단일 진입점(소모 시도 → 실패 시 권한 없음/잔여 부족 매핑)으로 단순화할 수 있습니다. 테스트는 현 구현을 잘 반영하고 있으니 변경은 선택 사항입니다.
83-111: 성공 케이스에 잔여 부족 실패 케이스 테스트 추가 제안
REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION발생 경로(소모 실패)를 커버하는 파라미터/테스트를 추가하면 회귀 방지에 도움이 됩니다.원하시면 실패 케이스 테스트 메서드 초안을 드리겠습니다.
src/test/java/hanium/modic/backend/web/post/controller/PostControllerTest.java (3)
70-144: 생성 DTO 시그니처 변경으로 테스트 가독성 저하: 빌더/팩토리 사용 고려추가된 long 파라미터(= ticketPrice 추정)가 위치 기반 생성자에 끼어들면서 테스트 가독성과 유지보수성이 떨어집니다. 테스트 전용 빌더/팩토리(또는 명명된 정적 팩토리)를 도입해 의도를 드러내세요.
147-170: 상세 조회 응답의 새 필드 검증 보강
GetPostResponse생성자에 새 필드(0L)가 추가되었지만, 해당 필드에 대한 응답 검증은 없습니다.$.data.ticketPrice(가칭)의 존재/값 검증을 추가해 주세요.
253-386: 업데이트 DTO 유효성도 새 필드에 대한 검증 케이스를 추가하세요.현재 invalid 파라미터 테스트는 기존 필드만 검증합니다.
ticketPrice의 null/음수/상한 등 유효성 실패 케이스를 추가하면 좋습니다.원하시면 파라미터화된 케이스를 포함한 테스트 케이스 초안을 제공하겠습니다.
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (5)
12-16: Javadoc에 사용 가이드(키 규칙/TTL/트랜잭션)를 한 문단 추가해 주세요.
- 어떤 도메인에 어떤 키 규칙(prefix + 구분자 + 식별자)을 사용할지, 키 구성의 불변식, 기본 대기/임차(lease) 시간 정책과 트랜잭션 경계(현재는 내부적으로 REQUIRES_NEW) 기대치를 간략히 명시하면, 이후 락 추가/리뷰가 빨라집니다.
27-30: 상수는 static final로 승격하고 공통 prefix 규칙을 고정합시다.인스턴스당 중복 보관할 이유가 없어 보입니다. 또한 접두사 끝에 항상 콜론을 포함하는 규칙을 주석으로 고정하면 실수(이중 콜론 등)를 줄일 수 있습니다.
- private final String USER_PREFIX = "lock:user:"; - private final String USER_TICKET_PREFIX = "lock:user:ticket:"; - private final String POST_LIKE_PREFIX = "lock:postlike:"; - private final String AI_PERMISSION_PREFIX = "lock:ai:perm:"; + private static final String USER_PREFIX = "lock:user:"; + private static final String USER_TICKET_PREFIX = "lock:user:ticket:"; + private static final String POST_LIKE_PREFIX = "lock:postlike:"; + private static final String AI_PERMISSION_PREFIX = "lock:ai:perm:";
36-41: 다중 사용자 락 키 중복 제거(distinct) 권장.동일 userId가 중복으로 들어올 경우 멀티락에 동일 키가 두 번 포함될 수 있습니다. RedissonMultiLock은 배열 단위로 처리하므로, 중복 키를 제거하는 편이 안전합니다(불필요한 대기/잠재적 예외 회피).
- List<String> keys = userIds.stream() - .map(id -> USER_PREFIX + id) - .toList(); + List<String> keys = userIds.stream() + .distinct() + .map(id -> USER_PREFIX + id) + .toList();
43-47: post-like 전용 옵션(lease 3s)은 짧은 임차 시간으로 인해 조기 만료 위험이 있습니다.DB 커밋/네트워크 지연이 3초를 넘어가면 락이 자동 해제되어 중복 라이크 처리 가능성이 있습니다. Redisson watchdog(자동 연장)을 쓰려면 실행기에서 “leaseTime <= 0”일 때 2-인자 tryLock을 사용하도록 개선하는 방식을 권장드립니다(아래 DistributionLockExecutor 코멘트 참고).
49-51: 권한 구매 흐름은 멀티락으로 한 번에 잡는 전용 메서드 제공을 고려하세요.권한키(AI_PERMISSION_PREFIX userId:postId)와 사용자 자원키(USER_TICKET_PREFIX userId/코인키 등)를 순차로 잡으면, 경쟁 흐름에서 반대 순서로 잡히는 스레드 간 교착 위험이 있습니다. withMultiLock은 내부적으로 정렬하여 이 문제를 회피합니다. LockManager에 결제/권한용 복합 락 헬퍼를 추가하는 것을 제안드립니다.
아래와 같이 전용 메서드를 추가해 서비스단에서 항상 멀티락을 사용하도록 강제할 수 있습니다(참고용):
public void aiPurchaseLocks(long userId, long postId, Runnable block) throws LockException { List<String> keys = List.of( USER_TICKET_PREFIX + userId, // 혹은 USER_COIN_PREFIX + userId AI_PERMISSION_PREFIX + userId + ":" + postId ); exec.withMultiLock(keys, block); }Also applies to: 53-55
src/main/java/hanium/modic/backend/domain/post/service/PostService.java (2)
61-63: ticketPrice 추가에 따른 유효성/불변식 확인 필요.
- 입력 제약: ticketPrice가 null/음수일 수 있는지? DTO에서 @NotNull/@positive로 막는지 재확인 부탁드립니다. 서비스단에서 방어로직(예: null이면 0 또는 미변경)도 선택지입니다.
- 과금 불변식: nonCommercialPrice와 ticketPrice의 동시 설정/우선순위 정책이 있다면, create/update에서 검증을 한 줄 추가하는 것도 안정적입니다.
또한 createPost에서 이미지 로딩이 findById N회로 발생합니다. 성능/일관성 상 배치 조회로 단순화 가능합니다(참고 구현):
// 제안: 배치 조회 + 개수 검증 List<PostImageEntity> list = postImageEntityRepository.findAllByIds(imageIds); if (list.size() != imageIds.size()) { throw new AppException(IMAGE_NOT_FOUND_EXCEPTION); } list.forEach(img -> img.updatePost(postEntity)); postImageEntityRepository.saveAll(list);Also applies to: 70-70
210-223: update 시 ticketPrice가 null이면 ‘미변경’으로 처리하는 방어 로직 제안.Update 요청에서 특정 필드만 변경하는 패턴을 고려하면, null을 “미변경”으로 취급하는 편이 안전합니다(선택). 현재 구현은 null이 들어오면 가격이 null로 덮일 수 있습니다(의도라면 그대로 OK).
- post.updateTicketPrice(ticketPrice); + if (ticketPrice != null) { + post.updateTicketPrice(ticketPrice); + }src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java (3)
27-29: DEFAULT_OPTS는 외부 설정 주입 또는 static final 상수가 적합합니다.옵션을 코드에 고정하지 말고, @ConfigurationProperties로 노출하거나 최소 static final로 명시해두면 테스트/운영 전환이 수월합니다.
45-50: 멀티락 키 중복 제거(distinct)로 안전성 개선.동일 키가 중복되면 배열 기반 멀티락 특성상 예기치 않은 동작이 나올 수 있습니다. 정렬 전에 distinct를 추가하세요.
- List<String> sorted = keys.stream().sorted().toList(); + List<String> sorted = keys.stream().distinct().sorted().toList();
52-73: (선택) 락 획득 실패 시 메시지에 wait/lease 정보를 모두 포함하세요.운영 이슈 트리아지에 도움이 됩니다. 위의 diff에 포함한 TimeoutException 메시지 포맷처럼 wait/lease/unit/key를 모두 남기는 걸 추천합니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (35)
src/main/java/hanium/modic/backend/common/entity/BaseEntity.java(1 hunks)src/main/java/hanium/modic/backend/common/error/ErrorCode.java(1 hunks)src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java(1 hunks)src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java(1 hunks)src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/domain/AiImagePermissionEntity.java(2 hunks)src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java(2 hunks)src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/repository/AiImagePermissionRepository.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java(2 hunks)src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java(1 hunks)src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java(1 hunks)src/main/java/hanium/modic/backend/domain/post/entity/PostEntity.java(2 hunks)src/main/java/hanium/modic/backend/domain/post/service/PostService.java(3 hunks)src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java(0 hunks)src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java(2 hunks)src/main/java/hanium/modic/backend/web/ai/controller/AiImagePermissionController.java(1 hunks)src/main/java/hanium/modic/backend/web/ai/dto/request/BuyAiImagePermissionRequest.java(1 hunks)src/main/java/hanium/modic/backend/web/ai/dto/response/BuyAiImagePermissionResponse.java(1 hunks)src/main/java/hanium/modic/backend/web/ai/dto/response/GetTicketInformationResponse.java(1 hunks)src/main/java/hanium/modic/backend/web/post/controller/PostController.java(3 hunks)src/main/java/hanium/modic/backend/web/post/dto/request/CreatePostRequest.java(1 hunks)src/main/java/hanium/modic/backend/web/post/dto/request/UpdatePostRequest.java(1 hunks)src/main/java/hanium/modic/backend/web/post/dto/response/GetPostResponse.java(2 hunks)src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java(3 hunks)src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java(1 hunks)src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java(11 hunks)src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java(2 hunks)src/test/java/hanium/modic/backend/domain/post/service/PostServiceTest.java(6 hunks)src/test/java/hanium/modic/backend/web/ai/controller/AiImagePermissionControllerIntegrationTest.java(1 hunks)src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java(7 hunks)src/test/java/hanium/modic/backend/web/post/controller/PostControllerIntegrationTest.java(5 hunks)src/test/java/hanium/modic/backend/web/post/controller/PostControllerTest.java(15 hunks)src/test/java/hanium/modic/backend/web/post/controller/PublicPostControllerTest.java(1 hunks)src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewControllerIntegrationTest.java(1 hunks)
💤 Files with no reviewable changes (1)
- src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java
🧰 Additional context used
🧬 Code graph analysis (7)
src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java (3)
src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1)
Service(16-80)src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java (1)
Service(17-82)src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
LockException(3-8)
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java (3)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
LockException(3-8)src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java (1)
PostFactory(10-38)src/test/java/hanium/modic/backend/domain/user/factory/UserFactory.java (1)
UserFactory(9-29)
src/main/java/hanium/modic/backend/web/ai/controller/AiImagePermissionController.java (1)
src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java (1)
Tag(39-179)
src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java (3)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
LockException(3-8)src/main/java/hanium/modic/backend/common/redis/distributedLock/LockOptions.java (1)
Getter(9-16)src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
Slf4j(17-56)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (2)
src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java (1)
Slf4j(18-74)src/main/java/hanium/modic/backend/common/redis/distributedLock/AopForTransaction.java (1)
Component(7-14)
src/main/java/hanium/modic/backend/domain/ai/domain/AiImagePermissionEntity.java (2)
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)
Table(21-64)src/main/java/hanium/modic/backend/common/entity/BaseEntity.java (1)
Getter(15-28)
src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (1)
src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java (1)
PostFactory(10-38)
🪛 GitHub Actions: compile-test
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
[error] 155-155: gradlew test failed: InvalidUseOfMatchersException in test '티켓으로 AI 이미지 생성권 구매 - 티켓 부족' (AiImagePermissionServiceTest.java:155).
[error] 211-211: gradlew test failed: UnfinishedStubbingException in test '이미지 생성권 소모 - 권한 없음' (AiImagePermissionServiceTest.java:211).
[error] 188-188: gradlew test failed: UnfinishedStubbingException in test '이미지 생성권 소모 - 성공' (AiImagePermissionServiceTest.java:188).
[error] 121-121: gradlew test failed: InvalidUseOfMatchersException in test '티켓으로 AI 이미지 생성권 구매 - 성공' (AiImagePermissionServiceTest.java:121).
[error] 66-66: gradlew test failed: InvalidUseOfMatchersException in test '코인으로 AI 이미지 생성권 구매 - 성공' (AiImagePermissionServiceTest.java:66).
[error] 92-92: gradlew test failed: NullPointerException in test '코인으로 AI 이미지 생성권 구매 - 존재하지 않는 포스트' (AiImagePermissionServiceTest.java:92).
[error] 1-1: gradlew test failed: UnfinishedVerificationException in test '코인 부족' (AiImagePermissionServiceTest.java:1).
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java
[error] 76-76: gradlew test failed: AssertionFailedError in test '티켓 정보 조회 - 기존 티켓이 있고 유효할 때' (AiRequestTicketServiceTest.java:76).
🔇 Additional comments (20)
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)
35-35: 컬럼 타입 변경 및 테스트 리터럴 검토 필요현재
ticket_count필드를Long으로 변경하셨으나, 다음 사항을 반드시 확인해 주세요.• DB 마이그레이션 스크립트
–src/main/resources/db/migration/…, Liquibase 설정 등에서ticket_count컬럼이BIGINT로 변경되었는지 확인
• 쿼리·프로젝션·리포트
– 기존에Integer타입으로 작성된 SQL·JPQL·MyBatis 매퍼 등에서Long타입을 수용하도록 파라미터와 매핑을 모두 점검
• 테스트 코드의 리터럴 접미사
– 일부 통합·단위 테스트에서assertThat(...).isEqualTo(3)또는isEqualTo(2)와 같이 L 접미사가 없는 정수 리터럴을 사용 중입니다.
–3L,2L등 Long 리터럴로 변경해야 런타임 비교 실패를 방지할 수 있습니다.
• src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java – 57, 126, 157, 162
• src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java – 55, 110, 129, 178 등위 내용을 점검·적용하신 뒤, 마이그레이션과 테스트가 모두 정상 통과하는지 확인 부탁드립니다.
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)
69-69: 기존 티켓 시나리오 차감 호출 적절
decreaseTicket(1)로 사전 상태를 2장으로 맞추는 접근은 명확합니다. 아래 Long 비교 관련 수정만 반영되면 테스트 의도와 결과가 일치합니다.src/test/java/hanium/modic/backend/web/postReview/controller/PostReviewControllerIntegrationTest.java (1)
22-22: AiImagePermissionEntity import 경로 변경 적절도메인 이동(domain.ai.domain) 반영이 정확합니다. 빌더 사용에도 문제 없습니다.
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)
9-11: int 기반 API 사용처 검증 완료 및 문제 없음
IntStream.range(0, FREE_TICKET_COUNT_PER_DAY)등과 같이int를 기대하는 전역 사용처는 존재하지 않음을 확인했습니다.- 테스트 코드에서는
int threadCount = (int)FREE_TICKET_COUNT_PER_DAY;처럼 명시적 캐스팅을 사용하고 있어 컴파일 오류가 없습니다.- 운영 코드(
AiRequestTicketEntity등)도 모두long으로 일관되게 사용하고 있어 문제를 일으키지 않습니다.※ 참고: 향후
IntStream.range또는 기타int기반 API를 빈번히 호출해야 할 경우,public static int dailyFreeTicketCountInt() { return Math.toIntExact(FREE_TICKET_COUNT_PER_DAY); }와 같은 헬퍼 메서드를 두어 안전하게 변환하는 방법을 권장합니다.
src/test/java/hanium/modic/backend/web/post/controller/PostControllerIntegrationTest.java (3)
36-36: AiImagePermissionEntity 패키지 경로 변경 적합도메인 이동에 따른 import 정정이 일관됩니다. 다른 테스트·프로덕션 코드와 패키지 경로가 일치하는지만 한 번만 더 확인해 주세요.
189-189: 유효성 예외 케이스에서의 ticketPrice 전달 OKDTO 시그니처 변경 반영으로 0L 전달은 타당합니다. 별도 수정 사항 없습니다.
231-231: 이미지 삭제 시나리오의 ticketPrice 전달 OK검증 포커스가 이미지 정합성이므로 ticketPrice 전달만으로 충분합니다.
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (2)
88-88: 만료 티켓 사전 소모 처리 OK만료 케이스 재현을 위한 사전 소모 로직은 타당합니다(갱신 검증에 영향 없음).
141-141: 티켓 0 소진 케이스 재현 로직 적절FREE_TICKET_COUNT_PER_DAY만큼 감소시켜 0 상태를 만드는 접근은 명확합니다.
Also applies to: 159-159
src/main/java/hanium/modic/backend/web/post/controller/PostController.java (3)
94-96: updatePost 시그니처 변경 반영 — LGTMupdatePost에도 ticketPrice를 동일한 순서로 전달했습니다. DTO(UpdatePostRequest) 제약(@NotNull 등)과 API 스펙 문서도 갱신되어 있는지 확인 부탁드립니다.
115-115: 의미 없는 공백 변경동작에는 영향 없습니다. 커밋 정리 시 불필요한 포맷 변경은 최소화해 주세요.
55-57: 승인:ticketPrice파라미터 순서 전역 반영 확인
src/main/java/hanium/modic/backend/web/post/controller/PostController.java
• createPost, updatePost 호출부에서…nonCommercialPrice, ticketPrice, imageIds순서로 전달src/test/java/hanium/modic/backend/domain/post/service/PostServiceTest.java
• createPost 테스트 (라인 96)
• updatePost 테스트 (라인 555, 588)위 3곳의 모든 호출부에서
nonCommercialPrice다음에ticketPrice를, 그 뒤에imageIds를 일관되게 반영하고 있어 추가 수정이 필요 없습니다. 변경 사항을 승인합니다.src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java (1)
49-54: API 시그니처 변경 반영 완료
아래 위치에서 모두 새로운 파라미터 순서(ImagePrefix, String fileName, String imagePath, Long postId, Long userId)로 호출되고 있음을 확인했습니다. 추가 조치 불필요합니다.
- src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java
- src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java
- src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java
- src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
src/main/java/hanium/modic/backend/domain/ai/domain/AiImagePermissionEntity.java (2)
3-3: 낙관적 락(@Version) 적용 여부 재확인 요청
AiImagePermissionEntity에@Version필드가 없어 PR 설명(“낙관적 락 적용”)과 불일치합니다. 현재 분산 락 기반 구조라@Version추가가 필수는 아니나,
- 낙관적 락을 실제로 적용할 계획이라면 해당 엔티티에
@Version컬럼을 추가- 그렇지 않다면 PR 설명에서 “낙관적 락” 언급을 제거 또는 수정
위 둘 중 하나로 정리 부탁드립니다.
• 대상:
- src/main/java/hanium/modic/backend/domain/ai/domain/AiImagePermissionEntity.java (현재
@Version미포함)
• 참고:@Version사용 예시: src/main/java/hanium/modic/backend/domain/postLike/entity/PostStatisticsEntity.java:37 (@Version) [run_scripts 결과]
19-26: 마이그레이션 스크립트에 제약조건 반영 여부 수동 확인 필요확인 결과,
src/main/resources/db/migration또는 이와 유사한 위치에 SQL 마이그레이션 파일이 존재하지 않아 자동 생성된 DDL(또는 Liquibase 등 대체 마이그레이션 도구)을 사용 중인지 명확하지 않습니다.
아래 사항을 수동으로 검토해 주세요:
ai_image_permissions테이블에(user_id, post_id)복합 유니크 제약(uk_ai_image_permission_user_post)이 마이그레이션 스크립트 또는 DDL 파일에 명시되어 있는지 확인- JPA 자동 DDL 생성(
spring.jpa.hibernate.ddl-auto)을 사용 중이라면, 해당 설정으로도 제약이 정상 반영되는지 검증src/main/java/hanium/modic/backend/domain/ai/repository/AiImagePermissionRepository.java (2)
16-19: 리포지토리 공개 API 슬림화: 적절합니다.필요한 조회/존재 여부와 upsert만 노출되어 있어 응집도가 좋고, 서비스 계층에 과도한 쿼리 노출을 줄였습니다.
23-35: H2 호환성 및 MySQL 버전 의존성 점검 요청아래와 같이
ON DUPLICATE KEY UPDATE절에서VALUES()대신 파라미터 직접 사용하는 형태로 변경을 권장드립니다.
동시에, 테스트/프로필 환경에서 H2가 MySQL 호환 모드로 실행되고 있는지 반드시 확인해 주세요.수정 예시:
@Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = """ INSERT INTO ai_image_permissions (user_id, post_id, remaining_generations, create_at, update_at) VALUES (:userId, :postId, :remainingGenerations, NOW(), NOW()) ON DUPLICATE KEY UPDATE - remaining_generations = remaining_generations + VALUES(remaining_generations), + remaining_generations = remaining_generations + :remainingGenerations, update_at = NOW() """, nativeQuery = true)점검 사항:
application-test.properties또는application.yml등에서 H2가MODE=MYSQL(또는MySQLDialect) 모드로 설정되어 있는지 확인- MySQL 일부 버전에서
VALUES(col)구문 비권장으로, 직접 파라미터 참조 방식이 더 이식성 높음설정 파일 내 H2 모드 활성화 여부는 수동으로 검증해 주시기 바랍니다.
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
32-34: 위임 구조 깔끔합니다.도메인별 키 구성만 남기고 실제 락 제어를 실행기에 위임한 점이 명확하고 유지보수성이 좋아졌습니다.
src/main/java/hanium/modic/backend/domain/post/service/PostService.java (1)
54-63: createPost/updatePost 호출부에 ticketPrice 인자 반영 완료 확인
rg 검색 결과,createPost및updatePost호출부 전반에서 모두ticketPrice인자가 정상적으로 추가된 것을 확인했습니다.
- 컨트롤러(
PostController)의 생성·수정 메서드 호출- 서비스 테스트(
PostServiceTest)의 생성·수정 검증- DTO(
CreatePostRequest,UpdatePostRequest), 엔티티(PostEntity), 응답 DTO(GetPostResponse) 및 팩토리(PostFactory) 전반에 걸쳐ticketPrice필드 반영더 이상 누락 호출부가 없으므로 본 이슈는 종료합니다.
src/main/java/hanium/modic/backend/common/redis/distributedLock/DistributionLockExecutor.java (1)
30-38: 단일 키 락 위임 로직은 명료합니다.lock 키를 한 곳에서 획득하고 실행부를 공통화한 점이 이해하기 쉽고 테스트 역시 분리 가능합니다.
| @Column(name = "create_at", updatable = false) | ||
| private LocalDateTime createAt; | ||
|
|
||
| @LastModifiedDate | ||
| @Column(name = "update_at") | ||
| private LocalDateTime updateAt; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
DB 컬럼명 명시 변경에 따른 마이그레이션/쿼리 영향 점검 필요
@Column(name = "create_at"), @Column(name = "update_at")로 명시되면서, 과거에 네이밍 전략(default)로 매핑되던 컬럼명과 불일치할 수 있습니다. 기존 스키마/인덱스/뷰/네이티브 쿼리/ETL이 createAt 혹은 다른 스펠링을 참조하지 않는지 확인해 주세요. Flyway/Liquibase 마이그레이션도 함께 반영되어야 합니다.
다음 스크립트로 레포 내 잠재적 참조를 빠르게 점검할 수 있습니다:
🏁 Script executed:
#!/bin/bash
# create/update 컬럼 참조 탐지
rg -n -C2 -g '!**/build/**' -g '!**/target/**' \
-e '\bcreateAt\b|\bupdateAt\b' \
-e '\bcreate_at\b|\bupdate_at\b' \
--type-add 'sql:*.sql'Length of output: 6936
컬럼명 변경(createAt→create_at, updateAt→update_at) 반영 필수
BaseEntity에서 명시한 @Column(name = "create_at"), @Column(name = "update_at") 적용에 따라, 아래 항목들을 반드시 점검 및 수정해야 합니다.
-
네이티브 SQL 쿼리
AiImagePermissionRepository.java(INSERT INTO ai_image_permissions …create_at,update_at)FollowEntityRepository.java(INSERT INTO follows …create_at,update_at)
-
JPA 쿼리 및 메소드
PostReviewCommentRepository.java(c.createAt,ORDER BY c.createAt)FollowEntityRepository.java(ORDER BY f.createAt)
-
DB 마이그레이션 스크립트
- Flyway/Liquibase 스크립트에 컬럼명 변경(또는 alias) 반영
- 기존 스키마·인덱스·뷰·ETL 파이프라인 등에서
createAt·updateAt참조 여부 확인
위 변경 사항을 반영한 후, 로컬 및 CI 환경에서 컬럼명 불일치로 인한 쿼리 에러가 없는지 충분히 검증해 주세요.
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/entity/BaseEntity.java around lines
22 to 27, the entity columns were renamed to create_at and update_at but related
code and SQL still reference camelCase createAt/updateAt; update all usages
accordingly: change native SQL INSERTs in AiImagePermissionRepository and
FollowEntityRepository to use create_at and update_at, revise JPA queries and
property references in PostReviewCommentRepository and FollowEntityRepository
(ORDER BY c.createAt / f.createAt) to match the mapped column names or use the
entity field names consistently, and update Flyway/Liquibase migration scripts
and any DB objects (indexes, views, ETL) to reflect the new column names (or add
aliases) and run local/CI validation to ensure no query failures remain.
| AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "AI-004", "AI 이미지 생성권을 구매한 이력이 없습니다."), | ||
| AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-005", "티켓 처리에 실패했습니다."), | ||
| AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-006", "티켓이 부족합니다."), | ||
| REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-007", "AI 이미지 생성권이 부족합니다."), | ||
| AI_IMAGE_PERMISSION_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-008", "이미지 생성권 처리에 실패하였습니다."), | ||
| AI_IMAGE_PERMISSION_ALREADY_EXISTS_EXCEPTION(HttpStatus.CONFLICT, "AI-009", "이미 AI 이미지 생성권을 구매했습니다."), | ||
| ; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
AI 에러 코드(78~84) 추가/수정 전반은 타당. 다만 prefix/상태/중복 관점 점검 제안
-
상태코드 매핑 적절성
- 중복 구매: CONFLICT(409) → OK.
- 권한 수량 부족: BAD_REQUEST(400) → 클라이언트 보정 대상이므로 OK.
- 구매 이력 없음: NOT_FOUND(404) → 리소스 부재로 해석 가능하나, 기존 클라이언트가 403을 기대하던 영역인지 확인 바랍니다. 기존 계약과의 호환성 검증을 권합니다.
-
코드 prefix 일관성
- 상단에 A-001~A-003(예: AI_REQUEST_NOT_FOUND)이 남아있고, 신규는 AI-00x 패턴입니다. 도메인(“AI”) 기준으로 prefix 통일을 검토해 주세요. 광범위 영향이 있어선 롤링 변경(구 코드 유지 + 점진적 마이그레이션)을 권합니다.
-
에러코드 중복 및 고유성 보장
- 에러 코드 문자열의 전역 유일성 테스트를 추가하면 회귀를 방지할 수 있습니다. 아래 스크립트로 중복 여부를 빠르게 점검해 주세요.
-
네이밍 컨벤션
- 본 enum에는
..._EXCEPTION접미사 사용과 미사용이 혼재합니다(예: AI_REQUEST_NOT_FOUND vs USER_NOT_FOUND_EXCEPTION). 팀 컨벤션을 문서화해두면 추후 혼선을 줄일 수 있습니다.
- 본 enum에는
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 리포지토리 전역의 "대문자-세자리" 패턴 에러코드를 수집하고 중복을 출력
rg -nP --type=java -g '!**/build/**' -g '!**/out/**' -g '!**/target/**' '"[A-Z]+-\d{3}"' \
| sed -nE 's/.*"([A-Z]+-[0-9]{3})".*/\1/p' \
| sort | uniq -c | awk '$1>1 {printf("DUP\t%s\t(%d occurrences)\n",$2,$1)} END{if(NR==0) print "NO_CODES_FOUND"}'Length of output: 423
글로벌 에러 코드 중복 발견 – 중복 해소 및 일관성 재검토가 필요합니다
- 중복된 에러 코드 발견 (전역
"대문자-세자리"패턴 기준)- A-001 (2회), A-002 (2회), C-001 (3회), C-002 (3회), F-001 (2회), U-002 (7회)
→ 각 코드를 유일하게 조정하고, CI 단계에서 회귀 방지를 위한 중복 검사(스크립트 자동화 등)를 반드시 도입하세요.
- A-001 (2회), A-002 (2회), C-001 (3회), C-002 (3회), F-001 (2회), U-002 (7회)
- 상태 코드 매핑 검토 요청
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "AI-004", ...)
→ 리소스 부재(404) 대신 권한 부족(403)을 기대하는 클라이언트가 있을 수 있으므로, 기존 계약과의 호환성을 검증 바랍니다.
- 에러 코드 prefix 일관성
- 기존 코드:
A-001~A-003(예:AI_REQUEST_NOT_FOUND) - 신규 코드:
AI-004~AI-009
→ 도메인 기준(“AI”)으로 prefix 통일을 검토하고, 광범위 영향을 고려해 롤링 마이그레이션 전략을 권장합니다.
- 기존 코드:
- 네이밍 컨벤션 정리 (접미사
_EXCEPTION사용 일관화)- 예:
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTIONvsREMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION
→ 팀 차원의 문서화를 통해 앞으로 혼선을 방지하세요.
- 예:
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/error/ErrorCode.java around lines
78-84, multiple global error-code collisions and naming/status inconsistencies
were reported: ensure each error code string is globally unique (resolve
duplicates A-001, A-002, C-001, C-002, F-001, U-002 by renumbering to unused
values), unify domain prefix to "AI-" for these new AI errors (or follow the
established domain-prefix convention across the enum), make status codes
consistent with contract (change AI_IMAGE_PERMISSION_NOT_FOUND from
HttpStatus.NOT_FOUND to HttpStatus.FORBIDDEN if the semantics are
permission-related—verify with API contract and update tests), standardize
constant names to use the chosen suffix convention (e.g., either include or
remove _EXCEPTION uniformly) and update any references, and add a CI check
(simple script to detect duplicate code strings) to prevent regressions.
| /** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */ | ||
| private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException { | ||
| boolean acquired = false; | ||
| try { | ||
| acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit()); | ||
| if (!acquired) { | ||
| throw new LockException(new InterruptedException("락 획득 실패: " + logKey)); | ||
| } | ||
| aopForTransaction.proceed(block); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new LockException(e); | ||
| } finally { | ||
| try { | ||
| if (acquired && lock.isHeldByCurrentThread()) { | ||
| lock.unlock(); | ||
| } | ||
| } catch (IllegalMonitorStateException ignored) { | ||
| log.info("락이 이미 해제됨: {}", logKey); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
임차(lease) 만료로 인한 조기 해제 위험 제거 + requiresNewTx 옵션 실제 반영.
- 현재는 tryLock(wait, lease, unit)을 고정 사용하여 Redisson watchdog(자동 연장)이 비활성화됩니다. 블록이 10초(기본값)보다 오래 걸리면 락이 작업 중에 풀릴 수 있습니다.
- LockOptions.requiresNewTx 필드가 정의됐으나 실행 경로에서 사용되지 않습니다(요약과 불일치).
아래처럼 수정하면
- leaseTime <= 0인 경우 2-인자 tryLock을 사용해 watchdog을 활성화하고,
- requiresNewTx가 true일 때만 REQUIRES_NEW 트랜잭션을 사용합니다. 그 외에는 현재 호출 스레드의 트랜잭션(있다면)에 참여합니다.
@@
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
@@
- private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
+ private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
boolean acquired = false;
try {
- acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
+ // leaseTime <= 0 이면 watchdog(자동 연장) 사용
+ if (opts.getLeaseTime() <= 0) {
+ acquired = lock.tryLock(opts.getWaitTime(), opts.getTimeUnit());
+ } else {
+ acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
+ }
if (!acquired) {
- throw new LockException(new InterruptedException("락 획득 실패: " + logKey));
+ throw new LockException(new TimeoutException(
+ "락 획득 실패(대기 " + opts.getWaitTime() + " " + opts.getTimeUnit() + "): " + logKey));
}
- aopForTransaction.proceed(block);
+ if (opts.isRequiresNewTx()) {
+ aopForTransaction.proceed(block); // REQUIRES_NEW
+ } else {
+ // 기존 트랜잭션에 참여(또는 비트랜잭션 수행)
+ block.run();
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockException(e);
} finally {
try {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException ignored) {
log.info("락이 이미 해제됨: {}", logKey);
}
}
}참고: requiresNewTx=false 경로는 호출부가 이미 @transactional이라면 그 트랜잭션에 자연스럽게 참여합니다. 필요 시 AopForTransaction에 REQUIRED 전파 메서드를 추가하는 대안도 있습니다.
📝 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.
| /** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */ | |
| private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException { | |
| boolean acquired = false; | |
| try { | |
| acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit()); | |
| if (!acquired) { | |
| throw new LockException(new InterruptedException("락 획득 실패: " + logKey)); | |
| } | |
| aopForTransaction.proceed(block); | |
| } catch (InterruptedException e) { | |
| Thread.currentThread().interrupt(); | |
| throw new LockException(e); | |
| } finally { | |
| try { | |
| if (acquired && lock.isHeldByCurrentThread()) { | |
| lock.unlock(); | |
| } | |
| } catch (IllegalMonitorStateException ignored) { | |
| log.info("락이 이미 해제됨: {}", logKey); | |
| } | |
| } | |
| } | |
| import java.util.concurrent.TimeUnit; | |
| import java.util.concurrent.TimeoutException; | |
| /** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */ | |
| private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException { | |
| boolean acquired = false; | |
| try { | |
| // leaseTime <= 0 이면 watchdog(자동 연장) 사용 | |
| if (opts.getLeaseTime() <= 0) { | |
| acquired = lock.tryLock(opts.getWaitTime(), opts.getTimeUnit()); | |
| } else { | |
| acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit()); | |
| } | |
| if (!acquired) { | |
| throw new LockException(new TimeoutException( | |
| "락 획득 실패(대기 " + opts.getWaitTime() + " " + opts.getTimeUnit() + "): " + logKey)); | |
| } | |
| if (opts.isRequiresNewTx()) { | |
| aopForTransaction.proceed(block); // REQUIRES_NEW | |
| } else { | |
| // 기존 트랜잭션에 참여(또는 비트랜잭션 수행) | |
| block.run(); | |
| } | |
| } catch (InterruptedException e) { | |
| Thread.currentThread().interrupt(); | |
| throw new LockException(e); | |
| } finally { | |
| try { | |
| if (acquired && lock.isHeldByCurrentThread()) { | |
| lock.unlock(); | |
| } | |
| } catch (IllegalMonitorStateException ignored) { | |
| log.info("락이 이미 해제됨: {}", logKey); | |
| } | |
| } | |
| } |
| public void decreaseTicket(final long ticketPrice) { | ||
| if (this.ticketCount - ticketPrice < MINIMUM_TICKET_COUNT) { | ||
| throw new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION); | ||
| } | ||
| this.ticketCount--; | ||
| this.ticketCount -= ticketPrice; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
차감 금액 검증 누락(0/음수, 과도한 값) 보완
ticketPrice가 0 또는 음수일 때 증가 효과가 나거나(음수), 의미 없는 호출(0)이 됩니다. 입력 검증을 추가해 방어적으로 동작시키는 것을 권장합니다.
- public void decreaseTicket(final long ticketPrice) {
- if (this.ticketCount - ticketPrice < MINIMUM_TICKET_COUNT) {
+ 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;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void decreaseTicket(final long ticketPrice) { | |
| if (this.ticketCount - ticketPrice < MINIMUM_TICKET_COUNT) { | |
| throw new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION); | |
| } | |
| this.ticketCount--; | |
| this.ticketCount -= ticketPrice; | |
| } | |
| 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; | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java
around lines 48-53, add defensive validation for ticketPrice: first reject
non-positive values (ticketPrice <= 0) by throwing an AppException with an
appropriate error code (e.g. INVALID_TICKET_PRICE), and then ensure the
requested deduction does not exceed the allowed removable amount (i.e.
ticketPrice > this.ticketCount - MINIMUM_TICKET_COUNT) — if it does, keep
throwing the existing AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION; after these
checks, perform the subtraction. Ensure any new error code is defined and used
consistently.
| public void buyAiImagePermissionByCoin(Long userId, Long postId) { | ||
| // 1) 포스트 조회 | ||
| PostEntity post = postRepository.findById(postId) | ||
| .orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION)); | ||
|
|
||
| // 2) 권한 업서트 + 증가 (원자적) | ||
| aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT); | ||
|
|
||
| // 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파 | ||
| userCoinService.consumeCoin(userId, post.getNonCommercialPrice()); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
구매 트랜잭션의 원자성 보장 및 주석 불일치 수정 필요
PR 목적(“권한 생성과 결제 처리를 하나의 트랜잭션으로 보장”)과 달리, 현재 주석은 “코인 거래는 별도의 트랜잭션”이라고 안내하고 있습니다. 또한 구매 로직이 aiImagePermissionLock을 사용하지 않아, 동시 이미지 생성(소모)과의 경합 시 다음 상황이 가능합니다:
- 소비(소모) 측이 락을 선점한 동안, 구매 측은 락 없이 업서트 → 소비 측이 조회 시점에 엔티티를 못 봐 “권한 없음”을 던질 수 있음(경합 윈도우).
권한 업서트와 결제(코인 차감)를 동일한 분산락(aiImagePermissionLock)과 동일 트랜잭션 경계에서 처리해 레이스를 제거하세요. 실패 시 전부 롤백되어야 합니다.
@Transactional
public void buyAiImagePermissionByCoin(Long userId, Long postId) {
// 1) 포스트 조회
PostEntity post = postRepository.findById(postId)
.orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));
- // 2) 권한 업서트 + 증가 (원자적)
- aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
-
- // 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파
- userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+ // 2) 동일 키(userId, postId) 기준 분산락으로 구매/소비 경합 제거 + 단일 트랜잭션 내 원자성 보장
+ try {
+ lockManager.aiImagePermissionLock(userId, postId, () -> {
+ // 권한 업서트 + 증가
+ aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
+ // 코인 차감 (동일 트랜잭션 참여)
+ userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+ });
+ } catch (LockException e) {
+ throw new AppException(AI_IMAGE_PERMISSION_FAIL_EXCEPTION);
+ }
}추가로, 하위 서비스(UserCoinService)가 REQUIRES_NEW 등 별도 전파를 사용하지 않는지 확인 필요합니다. 별도 트랜잭션이면 부분 커밋 위험이 있습니다(권한 증가 성공 + 코인 차감 실패/성공 불일치).
🏁 Script executed:
#!/bin/bash
# LockManager/Executor의 트랜잭션 전파 및 사용자 코인/티켓 서비스의 전파 설정을 확인합니다.
rg -n --type=java -C3 'class LockManager|class DistributionLockExecutor|userLock\(|aiImagePermissionLock\('
rg -n --type=java -C3 '@Transactional' src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java
rg -n --type=java -C3 '@Transactional' src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.javaLength of output: 10375
Atomicity 보장 및 주석 불일치 수정 필요
현재 buyAiImagePermissionByCoin 메서드는
@Transactional이 없어 권한 업서트와 코인 차감이 각각 개별 커밋(auto-commit)으로 실행되며- 기존 주석(
코인 거래는 별도 트랜잭션)도 실제 동작과 맞지 않습니다.
이로 인해, 권한 업서트는 성공했으나 코인 차감이 실패할 경우 권한만 생성되는 불일치가 발생할 수 있습니다.
아래와 같이 필수 리팩터링이 필요합니다:
-
AiImagePermissionService.java
buyAiImagePermissionByCoin에@Transactional추가lockManager.aiImagePermissionLock(userId, postId, …)블록 내부에aiImagePermissionRepository.upsertAndIncrease(...)userCoinService.consumeCoin(...)
를 함께 묶어 실행 → 분산락 + 단일 트랜잭션 경계로 원자성 보장
- 메서드 내 주석을 실제 동작에 맞게 수정
-
분산락/트랜잭션 전파 검증
DistributionLockExecutor.withLock가AopForTransaction을 통해 락 내에서 동일 트랜잭션에 참여하도록 구현되었는지 확인UserCoinService.consumeCoin는@Transactional이 없어 기본 전파(REQUIRED)만 사용 중이므로, 별도REQUIRES_NEW가 적용되지 않음을 검증
샘플 diff:
@Transactional
public void buyAiImagePermissionByCoin(Long userId, Long postId) {
- // 2) 권한 업서트 + 증가 (원자적)
- aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
-
- // 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파
- userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+ // 2) 분산락 + 단일 트랜잭션 내 권한 생성 및 코인 차감 (원자성 보장)
+ try {
+ lockManager.aiImagePermissionLock(userId, postId, () -> {
+ aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT);
+ userCoinService.consumeCoin(userId, post.getNonCommercialPrice());
+ });
+ } catch (LockException e) {
+ throw new AppException(AI_IMAGE_PERMISSION_FAIL_EXCEPTION);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void buyAiImagePermissionByCoin(Long userId, Long postId) { | |
| // 1) 포스트 조회 | |
| PostEntity post = postRepository.findById(postId) | |
| .orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION)); | |
| // 2) 권한 업서트 + 증가 (원자적) | |
| aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT); | |
| // 3) 코인 후차감, 코인 거래는 별도의 트랜잭션으로 동작하여 후처리, 예외는 전파 | |
| userCoinService.consumeCoin(userId, post.getNonCommercialPrice()); | |
| } | |
| @Transactional | |
| public void buyAiImagePermissionByCoin(Long userId, Long postId) { | |
| // 1) 포스트 조회 | |
| PostEntity post = postRepository.findById(postId) | |
| .orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION)); | |
| // 2) 분산락 + 단일 트랜잭션 내 권한 생성 및 코인 차감 (원자성 보장) | |
| try { | |
| lockManager.aiImagePermissionLock(userId, postId, () -> { | |
| aiImagePermissionRepository.upsertAndIncrease(userId, postId, AI_IMAGE_PERMISSION_COUNT); | |
| userCoinService.consumeCoin(userId, post.getNonCommercialPrice()); | |
| }); | |
| } catch (LockException e) { | |
| throw new AppException(AI_IMAGE_PERMISSION_FAIL_EXCEPTION); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java
around lines 33-43, the buyAiImagePermissionByCoin method must be made atomic:
add @Transactional to the method, move both
aiImagePermissionRepository.upsertAndIncrease(userId, postId, ...) and
userCoinService.consumeCoin(userId, post.getNonCommercialPrice()) inside the
same lockManager.aiImagePermissionLock(userId, postId, ...) block so they
execute under the distributed lock and a single DB transaction, and update the
inline comments to reflect that coin consumption and permission upsert are
performed together atomically; also verify DistributionLockExecutor.withLock
participates in the same Spring transaction (AOP proxying) and confirm
UserCoinService.consumeCoin does not use REQUIRES_NEW so it will join the outer
transaction.
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
Show resolved
Hide resolved
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
Outdated
Show resolved
Hide resolved
| when(aiImagePermissionRepository.upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), eq(20))) | ||
| .thenReturn(1); | ||
| doThrow(new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION)) | ||
| .when(aiRequestTicketService).useTicket(testUser.getId(), testPost.getTicketPrice()); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
티켓 부족 시나리오 — 매처 일관화 및 예외 stubbing 안정화
여기도 동일한 패턴으로 통일하세요. 또한 useTicket 검증도 타입 매처를 사용하세요.
-when(aiImagePermissionRepository.upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), eq(20)))
- .thenReturn(1);
+when(aiImagePermissionRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt()))
+ .thenReturn(1);
-doThrow(new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION))
- .when(aiRequestTicketService).useTicket(testUser.getId(), testPost.getTicketPrice());
+doThrow(new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION))
+ .when(aiRequestTicketService).useTicket(eq(testUser.getId()), eq(testPost.getTicketPrice()));📝 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.
| when(aiImagePermissionRepository.upsertAndIncrease(eq(testUser.getId()), eq(testPost.getId()), eq(20))) | |
| .thenReturn(1); | |
| doThrow(new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION)) | |
| .when(aiRequestTicketService).useTicket(testUser.getId(), testPost.getTicketPrice()); | |
| when(aiImagePermissionRepository.upsertAndIncrease(anyLong(), anyLong(), anyInt())) | |
| .thenReturn(1); | |
| doThrow(new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION)) | |
| .when(aiRequestTicketService).useTicket(eq(testUser.getId()), eq(testPost.getTicketPrice())); |
🧰 Tools
🪛 GitHub Actions: compile-test
[error] 155-155: gradlew test failed: InvalidUseOfMatchersException in test '티켓으로 AI 이미지 생성권 구매 - 티켓 부족' (AiImagePermissionServiceTest.java:155).
🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
around lines 155 to 159, the test stubs are inconsistent with the project's
matcher pattern and the doThrow for aiRequestTicketService.useTicket uses
concrete values instead of a type matcher; change the upsertAndIncrease and
useTicket stubbing to use the same eq/type matcher pattern as other tests (e.g.,
keep eq(...) for known ids/amounts and use anyLong()/anyInt() or appropriate
type matchers for parameters where the test pattern requires) and replace the
doThrow(...).when(aiRequestTicketService).useTicket(testUser.getId(),
testPost.getTicketPrice()) with a
doThrow(...).when(aiRequestTicketService).useTicket(anyLong(), anyInt()) (or the
exact parameter type matchers used project-wide) so verification and stubbing
are stable and consistent.
src/test/java/hanium/modic/backend/domain/ai/service/AiImagePermissionServiceTest.java
Show resolved
Hide resolved
| .userId(user.getId()) | ||
| .build(); | ||
| existingTicket.decreaseTicket(); // 2개로 만들기 | ||
| existingTicket.decreaseTicket(2); // 2개로 만들기 |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
파이프라인 실패(AssertionFailedError) 원인: 초기 티켓 수 계산 오류
기본 발급량이 FREE_TICKET_COUNT_PER_DAY(예: 3)인 상태에서 decreaseTicket(2)는 잔여 1개가 되어, 기대값 2와 불일치합니다. 아래처럼 “2개 남기기”로 수정하면 실패가 해소됩니다.
다음 패치를 적용하세요:
- existingTicket.decreaseTicket(2); // 2개로 만들기
+ // 기본 발급량에서 2개가 남도록 조정
+ existingTicket.decreaseTicket(FREE_TICKET_COUNT_PER_DAY - 2);수정 후 유사한 실수를 점검:
🏁 Script executed:
#!/bin/bash
# decreaseTicket(2) 사용 흔적 점검
rg -nP --type=java 'decreaseTicket\(\s*2L?\s*\)' -C2Length of output: 1199
🏁 Script executed:
#!/bin/bash
# Print the first 200 lines of the test file for context
sed -n '1,200p' src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.javaLength of output: 7204
기존 티켓 조회 테스트의 decreaseTicket 인자 수정 필요
getTicketInformation_ReturnsExistingTicket_WhenValid 테스트에서 기본 발급량(FREE_TICKET_COUNT_PER_DAY=3)에서 “2개 남기기”를 구현하려면 현재 코드(decreaseTicket(2))가 잔여 1개를 만드는 잘못된 인수입니다.
수정할 위치:
- 파일:
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java - 메서드:
getTicketInformation_ReturnsExistingTicket_WhenValid - 대상 라인: 기존
existingTicket.decreaseTicket(2); // 2개로 만들기
제안하는 패치:
- existingTicket.decreaseTicket(2); // 2개로 만들기
+ // 기본 발급량에서 2개가 남도록 조정
+ existingTicket.decreaseTicket(FREE_TICKET_COUNT_PER_DAY - 2);다른 테스트(expiredTicket.decreaseTicket(2))는 “티켓 소모” 용도이므로 그대로 두어야 합니다.
📝 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.
| existingTicket.decreaseTicket(2); // 2개로 만들기 | |
| // 기본 발급량에서 2개가 남도록 조정 | |
| existingTicket.decreaseTicket(FREE_TICKET_COUNT_PER_DAY - 2); |
🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java
around line 69, the test is trying to set the existing ticket to 2 remaining but
calls existingTicket.decreaseTicket(2) which reduces a 3-count to 1; change that
call to existingTicket.decreaseTicket(1) so the remaining count becomes 2, and
leave expiredTicket.decreaseTicket(2) unchanged.
Summary
AI 이미지 생성권을 코인과 티켓으로 구매할 수 있는 기능을 구현했습니다. 이는 사용자가 게시물에 대한 AI 이미지를 생성하기 전에 필요한 권한을 획득하는 핵심 기능입니다.
• 코인 결제: 게시물의
nonCommercialPrice로 생성권 구매• 티켓 결제: 게시물의
ticketPrice로 생성권 구매• Upsert 패턴: 중복 구매 시 생성 횟수 누적 (기본 20회씩 증가)
• 분산 락: 동시성 제어로 안전한 권한 소모 처리
• 트랜잭션: 권한 생성과 결제 처리의 원자성 보장
주요 구현 사항
🔧 핵심 기능
AiImagePermissionService: 구매 로직과 권한 소모 처리
buyAiImagePermissionByCoin(): 코인으로 구매buyAiImagePermissionByTicket(): 티켓으로 구매consumeRemainingGenerations(): 생성권 차감 (분산 락 적용)AiImagePermissionController: REST API 엔드포인트
POST /api/ai/image-permissions/buy-with-coin: 코인 구매 APIPOST /api/ai/image-permissions/buy-with-ticket: 티켓 구매 API🗃️ 데이터베이스 최적화
ON DUPLICATE KEY UPDATE로 중복 구매 시 생성 횟수 누적(user_id, post_id)복합 유니크 인덱스로 빠른 조회🔒 동시성 제어
aiImagePermissionLock📝 검증 및 오류 처리
@Valid와@NotNull로 요청 데이터 검증Test Coverage
🧪 단위 테스트 (
AiImagePermissionServiceTest)🌐 통합 테스트 (
AiImagePermissionControllerIntegrationTest)@WithCustomUser로 인증된 사용자 환경 테스트기술 스택 & 패턴
🏗️ 아키텍처 패턴
🛠️ 기술 요소
📊 성능 최적화
API 명세
코인으로 구매
티켓으로 구매
에러 코드
🤖 Generated with Claude Code
Summary by CodeRabbit