[volume-8] Redis 대기열과 실측 기반 입장 제어#375
Conversation
📝 Walkthrough
Walkthrough대기열 기반 주문 제어, 선착순 쿠폰 비동기 발급, 상품 이벤트의 Outbox·Kafka 처리, 카탈로그 지표 갱신, 대기열 용량 벤치마크와 관련 문서 및 테스트가 추가되었다. Changes선착순 쿠폰 발급
대기열 기반 주문 제어
카탈로그 이벤트와 Outbox 릴레이
대기열 용량 벤치마크
Sequence Diagram(s)대기열 admission 및 주문 흐름sequenceDiagram
participant Client
participant QueueV1Controller
participant WaitingQueueFacade
participant RedisWaitingQueueRepository
participant QueuedOrderFacade
participant OrderFacade
Client->>QueueV1Controller: POST /api/v1/queue/enter
QueueV1Controller->>WaitingQueueFacade: enter(userLoginId)
WaitingQueueFacade->>RedisWaitingQueueRepository: enqueueIfAbsent/findRank
RedisWaitingQueueRepository-->>WaitingQueueFacade: queue position
WaitingQueueFacade-->>Client: Queue position response
WaitingQueueAdmitScheduler->>WaitingQueueAdmitService: admit()
WaitingQueueAdmitService->>RedisWaitingQueueRepository: popWaitingUsers/issueEntryToken
Client->>QueueV1Controller: POST /api/v1/orders with X-Entry-Token
QueueV1Controller->>QueuedOrderFacade: createOrder(..., entryToken)
QueuedOrderFacade->>RedisWaitingQueueRepository: claimEntryToken
QueuedOrderFacade->>OrderFacade: createOrder(...)
OrderFacade-->>QueuedOrderFacade: OrderInfo
QueuedOrderFacade->>RedisWaitingQueueRepository: completeEntryToken
QueuedOrderFacade-->>Client: Order response
Outbox 이벤트 릴레이 및 소비 흐름sequenceDiagram
participant ProductLikeFacade
participant CatalogEventOutboxListener
participant OutboxEventRepository
participant OutboxRelayScheduler
participant KafkaOutboxMessagePublisher
participant CatalogEventConsumer
participant CatalogMetricsEventProcessor
ProductLikeFacade->>CatalogEventOutboxListener: ProductLikeChangedEvent
CatalogEventOutboxListener->>OutboxEventRepository: save READY OutboxEvent
OutboxRelayScheduler->>KafkaOutboxMessagePublisher: relayReadyEvents
KafkaOutboxMessagePublisher->>CatalogEventConsumer: publish catalog event
CatalogEventConsumer->>CatalogMetricsEventProcessor: process CatalogEventMessage
CatalogMetricsEventProcessor->>CatalogMetricsEventProcessor: check eventId and update product metrics
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 17
🧹 Nitpick comments (23)
apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java (1)
46-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
eventPublishermock 상호작용 검증 누락
ApplicationEventPublishermock이 추가되었으나 테스트에서publishEvent()호출을 검증하지 않는다.ProductLikeFacade.likeProduct()가 좋아요 처리 시 카탈로그 이벤트를 발행한다면, 단위 테스트에서verify(eventPublisher).publishEvent(any())검증을 추가하여 이벤트 발행 회귀를 조기에 감지해야 한다. E2E 테스트(storesCatalogEventInOutbox_once_whenUserLikesProduct)에서 outbox 기록을 검증하고 있으나, 단위 테스트에서도 발행 호출 자체를 검증하여 실패 지점을 좁히는 것이 권장된다.As per coding guidelines,
**/*Test*.java리뷰 기준에 따라 mock 남용으로 의미가 약해지면 테스트 방향을 재정렬하도록 제안한다.🧪 제안: 이벤트 발행 검증 추가
// act productLikeFacade.likeProduct("user1234", 1L); // assert verify(productService, never()).findProductsByIdsForUpdate(any()); verify(productService, never()).saveProducts(any()); verify(productCacheRepository).evictProduct(1L); verify(productCacheRepository).evictProductLists(); + verify(eventPublisher).publishEvent(any()); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java` around lines 46 - 48, ProductLikeFacadeTest의 eventPublisher mock에 대한 상호작용 검증이 누락되어 있다. ProductLikeFacade.likeProduct()의 성공 경로 테스트에 verify(eventPublisher).publishEvent(any()) 검증을 추가하고, 필요한 Mockito import와 호출 횟수 조건을 맞춰 이벤트 발행 회귀를 감지하도록 수정한다.Source: Path instructions
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaEntity.java (1)
27-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
status필드를 String magic value 대신 enum으로 관리하라.
"AVAILABLE"이라는 magic string은 오타나 무효한 값이 런타임까지 전파될 수 있어 운영 장애의 원인이 된다. 동일 도메인에IssueRequestStatusenum이 존재하므로, 발급 쿠폰 상태에 대해서도 enum을 도입하여 컴파일 타임에 값을 검증하는 것이 안전하다.♻️ 제안: coupon status enum 도입
+public enum IssuedCouponStatus { + AVAILABLE, + USED, + EXPIRED +}엔티티 변경:
- `@Column`(nullable = false) - private String status; + `@Enumerated`(EnumType.STRING) + `@Column`(nullable = false) + private IssuedCouponStatus status;private IssuedCouponJpaEntity(Long couponId, String userLoginId, ZonedDateTime expiredAt) { this.couponId = couponId; this.userLoginId = userLoginId; - this.status = "AVAILABLE"; + this.status = IssuedCouponStatus.AVAILABLE; this.expiredAt = expiredAt; }Also applies to: 39-44
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaEntity.java` around lines 27 - 28, IssuedCouponJpaEntity의 status 필드를 String 대신 발급 쿠폰 상태를 표현하는 enum 타입으로 변경하고, AVAILABLE 등 기존 magic value를 해당 enum 상수로 대체하세요. JPA 저장 시 enum이 문자열로 매핑되도록 `@Enumerated`(EnumType.STRING)을 적용하며, 생성자·접근자·상태 비교 등 관련 사용처도 새 enum 타입에 맞게 수정하세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java (1)
12-26: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winKafka topic 파티션/복제본 수가 하드코딩되어 있다.
partitions(3),replicas(1)이 하드코딩되어 있다.replicas(1)은 단일 브로커 환경에서만 동작하므로 프로덕션에서 장애 허용성이 없다. 환경별로 파티션/복제본 수를 조정할 수 있도록CatalogEventTopicProperties로 외부화하는 것을 권장한다.♻️ Proposed fix: properties 기반 구성
`@Bean` public NewTopic catalogEventsTopic(CatalogEventTopicProperties properties) { return TopicBuilder.name(properties.catalogEvents()) - .partitions(3) - .replicas(1) + .partitions(properties.partitions()) + .replicas(properties.replicas()) .build(); } `@Bean` public NewTopic couponIssueRequestsTopic(CatalogEventTopicProperties properties) { return TopicBuilder.name(properties.couponIssueRequests()) - .partitions(3) - .replicas(1) + .partitions(properties.partitions()) + .replicas(properties.replicas()) .build(); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java` around lines 12 - 26, Kafka topic의 partitions와 replicas 값이 하드코딩되어 있다. CatalogEventTopicProperties에 각 토픽의 파티션 수와 복제본 수 설정을 추가하고, KafkaTopicConfig의 catalogEventsTopic 및 couponIssueRequestsTopic에서 해당 프로퍼티 값을 사용하도록 변경해 환경별 외부 설정이 가능하게 하라.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)
52-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winno-op event publisher 생성자가
public이면 이벤트 유실이 silent fail될 수 있다.운영 관점 문제:
event -> {}no-op publisher를 전달하는 보조 생성자가public으로 노출되어 있다. 이 생성자를 사용하는 코드(테스트 또는 비-Spring 컨텍스트)에서는 모든 도메인 이벤트가 조용히 무시되며, outbox에 아무것도 기록되지 않는다. 프로덕션 코드에서 실수로 사용될 위험도 있다.수정안:
🔒 보조 생성자를 package-private으로 변경
- public OrderFacade( + OrderFacade( OrderService orderService, ProductService productService, OrderProductProcessService orderProductProcessService, CouponService couponService, Clock clock ) {추가 테스트:
publishProductOrderedEvents가 호출되는지 검증하는 테스트에서 반드시 6-인자 생성자를 사용하도록 가이드.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java` around lines 52 - 68, OrderFacade의 no-op event publisher를 사용하는 5-인자 보조 생성자를 public에서 package-private으로 변경해 프로덕션 코드의 실수 사용을 제한하세요. 이벤트 발행을 검증하는 테스트와 publishProductOrderedEvents 관련 테스트는 반드시 event publisher를 명시하는 6-인자 생성자를 사용하도록 수정하세요.apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeChangedEvent.java (1)
15-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
ZonedDateTime.now()대신Clock를 주입받도록 팩토리 메서드를 개선하자.운영 관점 문제:
ZonedDateTime.now()를 직접 호출하면 테스트에서 타임스탬프를 제어할 수 없다.OrderFacade에서 이미Clock을 주입받아ZonedDateTime.now(clock)를 사용하는 패턴이 존재하므로, 이벤트 팩토리도 동일한 패턴을 따르는 것이 일관성과 테스트성에 유리하다.수정안:
♻️ Clock 주입 팩토리 메서드 추가
public static ProductLikeChangedEvent liked(Long productId, String userLoginId) { + return liked(productId, userLoginId, Clock.systemUTC()); +} + +public static ProductLikeChangedEvent liked(Long productId, String userLoginId, Clock clock) { return new ProductLikeChangedEvent( UUID.randomUUID().toString(), CatalogEventType.PRODUCT_LIKED, productId, userLoginId, 1, - ZonedDateTime.now() + ZonedDateTime.now(clock) ); }추가 테스트: 고정
Clock을 주입하여occurredAt이 예상 시각과 일치하는지 검증.Also applies to: 26-35
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeChangedEvent.java` around lines 15 - 24, ProductLikeChangedEvent의 liked 팩토리가 ZonedDateTime.now()를 직접 호출하지 않도록 Clock 파라미터를 받는 오버로드 또는 주입형 팩토리 메서드로 변경하고, ZonedDateTime.now(clock)을 사용해 occurredAt을 생성하세요. 기존 호출부를 새 시그니처에 맞게 갱신하고, 고정 Clock으로 occurredAt이 예상 시각과 일치하는지 테스트를 추가하세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java (1)
10-12: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winOutbox 테이블成长에 따른 쿼리 성능 저하 및 중복 처리 리스크 점검 필요.
운영 관점 문제: Outbox 테이블은 이벤트가 누적될수록 계속 성장한다.
findByStatusInOrderByCreatedAtAsc는(status, created_at)복합 인덱스가 없으면 full scan + sort로退化하고,findByEventId도event_id인덱스가 없으면 느려진다. 또한 다중 인스턴스에서 relay service가 동시에 polling할 때 동일 PENDING 이벤트를 중복 처리할 위험이 있다.수정안:
(status, created_at)및(event_id)인덱스 추가- 다중 인스턴스 환경이라면
@Lock(LockModeType.PESSIMISTIC_WRITE)또는FOR UPDATE SKIP LOCKED쿼리로 중복 처리 방지-- 예시 인덱스 CREATE INDEX idx_outbox_status_created_at ON outbox_event (status, created_at); CREATE INDEX idx_outbox_event_id ON outbox_event (event_id);추가 테스트: 대량 outbox 데이터(10만 건 이상)로 polling 쿼리 성능 측정 및 다중 relay 인스턴스 동시 실행 시 중복 처리 발생 여부 검증.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java` around lines 10 - 12, Outbox 조회 성능과 다중 relay 중복 처리 방지를 보완하세요. OutboxEventJpaRepository의 findByStatusInOrderByCreatedAtAsc 및 findByEventId에 대응하는 (status, created_at)와 event_id 인덱스를 엔티티 마이그레이션에 추가하고, polling 조회에는 PESSIMISTIC_WRITE 또는 FOR UPDATE SKIP LOCKED 방식의 잠금을 적용하세요. 대량 데이터 성능과 다중 인스턴스 동시 polling 중복 처리를 검증하는 테스트도 추가하세요.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/application/event/ProductOrderedEvent.java (1)
16-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
ProductLikeChangedEvent와 동일하게Clock주입 팩토리를 추가하자.
ZonedDateTime.now()직접 호출로 인한 테스트성 저하 문제는ProductLikeChangedEvent에서 제시한 것과 동일하다.OrderFacade.publishProductOrderedEvents에서clock을 전달하면 된다.수정안:
♻️ Clock 주입 팩토리 메서드 추가
public static ProductOrderedEvent ordered(Long productId, Long orderId, String userLoginId, int quantity) { + return ordered(productId, orderId, userLoginId, quantity, Clock.systemUTC()); +} + +public static ProductOrderedEvent ordered(Long productId, Long orderId, String userLoginId, int quantity, Clock clock) { return new ProductOrderedEvent( UUID.randomUUID().toString(), CatalogEventType.PRODUCT_ORDERED, productId, orderId, userLoginId, quantity, - ZonedDateTime.now() + ZonedDateTime.now(clock) ); }추가 테스트: 고정
Clock으로occurredAt검증.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/application/event/ProductOrderedEvent.java` around lines 16 - 26, ProductOrderedEvent.ordered는 ZonedDateTime.now()를 직접 호출하지 말고 Clock을 받는 오버로드 팩토리 메서드를 추가해 주입된 시각을 사용하도록 수정하세요. 기존 호출 호환성을 유지하고, OrderFacade.publishProductOrderedEvents에서 clock을 전달하며, 고정 Clock으로 occurredAt을 검증하는 테스트를 추가하세요.apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxRelayServiceTest.java (1)
42-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win부분 실패 시나리오 테스트와 메트릭 검증이 누락되어 있다.
운영 관점 문제: 여러 이벤트 중 일부만 발행 실패할 때, 실패한 이벤트만 FAILED로 처리되고 나머지는 정상적으로 PUBLISHED 처리되는지 검증하는 테스트가 없다. 이 간극으로 인해 relay service의 개별 이벤트 독립성이 깨질 수 있다. 또한
SimpleMeterRegistry를 주입했으나 카운터 증가를 검증하지 않아 메트릭 신뢰성이 미확인 상태다.수정안:
🧪 부분 실패 및 메트릭 검증 테스트 추가
`@DisplayName`("여러 이벤트 중 일부 발행 실패 시 실패한 이벤트만 FAILED 처리된다.") `@Test` void marksFailedOnlyForFailedEvent_whenPartialFailure() { // arrange OutboxEvent event1 = new OutboxEvent("event-1", "catalog-events", "1", "PRODUCT_LIKED", "{}"); OutboxEvent event2 = new OutboxEvent("event-2", "catalog-events", "2", "PRODUCT_ORDERED", "{}"); OutboxEventRepository repository = mock(OutboxEventRepository.class); OutboxMessagePublisher publisher = mock(OutboxMessagePublisher.class); when(repository.findRelayableEvents(10)).thenReturn(List.of(event1, event2)); doThrow(new IllegalStateException("kafka down")) .when(publisher) .publish(event1); SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); OutboxRelayService relayService = new OutboxRelayService( repository, publisher, new OutboxProperties(true, Duration.ofSeconds(5), 10, Duration.ofSeconds(3)), meterRegistry ); // act relayService.relayReadyEvents(); // assert assertThat(event1.getStatus()).isEqualTo(OutboxEventStatus.FAILED); assertThat(event2.getStatus()).isEqualTo(OutboxEventStatus.PUBLISHED); verify(repository).save(event1); verify(repository).save(event2); }추가 테스트: 빈 이벤트 목록 반환 시 정상 종료 여부, 메트릭 카운터 값 검증.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxRelayServiceTest.java` around lines 42 - 67, OutboxRelayServiceTest의 marksFailed_whenPublishFails 테스트만으로는 부분 실패 처리와 메트릭을 검증하지 못합니다. OutboxRelayService 테스트에 여러 이벤트 중 publisher.publish가 특정 이벤트에서만 실패하도록 구성해 실패 이벤트는 FAILED 및 원인을 저장하고 나머지는 PUBLISHED 처리되는지와 각 상태 저장을 검증하는 테스트를 추가하세요. 주입한 SimpleMeterRegistry에서 성공·실패 카운터 값을 확인하고, findRelayableEvents가 빈 목록을 반환할 때 예외 없이 종료되는 테스트도 추가하세요.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/application/event/KafkaEventEnvelope.java (1)
6-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
data필드의 가변 Map에 대한 방어적 복사 필요
Map<String, Object> data는 record의 component 참조는 불변이지만 Map 자체는 가변이다. 이벤트 envelope가 여러 consumer에게 전달되는 환경에서 한 consumer가 Map을 수정하면 다른 consumer에 영향을 미친다. 운영 관점에서 추적困难한 side-effect 버그를 유발할 수 있다.♻️ 제안: compact constructor에서 방어적 복사 적용
public record KafkaEventEnvelope( String eventId, String eventType, String aggregateType, Long aggregateId, ZonedDateTime occurredAt, Map<String, Object> data -) { +) { + public KafkaEventEnvelope { + data = data == null ? null : Map.copyOf(data); + } }As per path instructions,
**/*.java파일은 방어적 복사와 불변성을 점검한다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/application/event/KafkaEventEnvelope.java` around lines 6 - 14, KafkaEventEnvelope의 data Map이 외부 변경에 노출되지 않도록 compact constructor에서 null 검증과 함께 방어적 복사를 적용하세요. 생성자 내부에서 Map.copyOf(data) 또는 동등한 불변 복사본으로 data를 재할당해 consumer 간 공유 상태 변경을 차단하고, record의 다른 필드는 기존 동작을 유지하세요.Source: Path instructions
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/CatalogMetricsEventProcessorIntegrationTest.java (1)
47-65: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win동일 eventId 멱등성 검증은 양호하나, 이벤트 타입별 분기 검증이 누락되었다.
PRODUCT_LIKED케이스만 검증하고 있다.PRODUCT_UNLIKED,PRODUCT_VIEWED,PRODUCT_ORDERED등 다른 이벤트 타입에 대한 delta 반영이 정상적으로 동작하는지 통합 테스트에서 확인할 필요가 있다. 운영 관점에서 특정 이벤트 타입의 metrics 갱신이 조용히 실패하는 회귀를 늦게 발견할 수 있다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/CatalogMetricsEventProcessorIntegrationTest.java` around lines 47 - 65, 현재 통합 테스트는 PRODUCT_LIKED만 검증하므로 이벤트 타입별 metrics delta 반영 검증이 누락되어 있다. CatalogMetricsEventProcessorIntegrationTest의 이벤트 처리 테스트에 PRODUCT_UNLIKED, PRODUCT_VIEWED, PRODUCT_ORDERED 케이스를 추가하고, 각 이벤트 타입에 맞는 카운트가 정상적으로 증가·감소하는지와 process 결과를 검증하라.apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEvent.java (1)
61-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win상태 전환에 대한 가드가 없어 잘못된 상태 천이가 가능하다.
markPublished와markFailed가 현재 상태를 검증하지 않는다.PUBLISHED상태의 이벤트에markFailed가 호출되면 상태가FAILED로 되돌아가고,publishedAt은 이전 값에 그대로 남아 데이터 불일치가 발생한다. outbox 이벤트는 상태 머신으로 동작해야 하므로 전환 전 유효성 검증이 필요하다.public void markPublished(ZonedDateTime publishedAt) { + if (this.status != OutboxEventStatus.READY) { + throw new IllegalStateException("Cannot mark as published from status: " + this.status); + } this.status = OutboxEventStatus.PUBLISHED; this.failReason = null; this.publishedAt = publishedAt; } public void markFailed(String failReason) { + if (this.status != OutboxEventStatus.READY) { + throw new IllegalStateException("Cannot mark as failed from status: " + this.status); + } this.status = OutboxEventStatus.FAILED; this.failReason = failReason; + this.publishedAt = null; }추가 테스트:
READY가 아닌 상태에서markPublished/markFailed호출 시 예외 발생을 검증하는 단위 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEvent.java` around lines 61 - 70, OutboxEvent의 markPublished와 markFailed에 상태 전환 가드가 없어 잘못된 재처리가 가능하다. 두 메서드 모두 현재 상태가 READY일 때만 전환하도록 검증하고, 그 외 상태에서는 적절한 예외를 발생시켜 상태와 publishedAt이 변경되지 않게 하라. 또한 READY가 아닌 각 상태에서 두 메서드 호출 시 예외가 발생하는 단위 테스트를 추가하라.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxMessagePublisher.java (1)
28-28: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winpayload를 Map로 역직렬화 후 재전송하는 대신 원본 문자열을 직접 전송하는 것이 안전하다.
objectMapper.readValue(payload, Map.class)로 JSON을 Map로 역직렬화한 후 Kafka serializer가 다시 직렬화한다. 이 과정에서 숫자 정밀도 손실, 중첩 객체의 타입 변형, null 키 처리 등에서 원본과 달라질 수 있다. outbox에 저장된 원본 payload를 그대로 전송하는 것이 데이터 무결성에 유리하다.- Map<String, Object> payload = objectMapper.readValue(event.getPayload(), Map.class); - kafkaTemplate.send(event.getTopic(), event.getMessageKey(), payload) + kafkaTemplate.send(event.getTopic(), event.getMessageKey(), event.getPayload()) .get(outboxProperties.timeout().toMillis(), TimeUnit.MILLISECONDS);
KafkaTemplate<Object, Object>의 value serializer가StringSerializer로 설정되어 있어야 한다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxMessagePublisher.java` at line 28, KafkaOutboxMessagePublisher의 payload 처리에서 event.getPayload()를 Map으로 역직렬화하지 말고 원본 JSON 문자열을 그대로 Kafka 메시지 값으로 전달하도록 수정하세요. KafkaTemplate의 값 타입과 serializer 설정도 StringSerializer에 맞게 확인·조정하고, 불필요한 Map 역직렬화 및 관련 타입 처리를 제거하세요.apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java (1)
145-160: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
issueTo가 first-come 쿠폰의 발급 한도를 검증하지 않는다.
CouponService.issueCoupon에서isFirstCome()일 때 예외를 던지도록 서비스 계층에서 보호하고 있으나, 도메인 모델 자체가 자체 불변식을 강제하지 않는다. 다른 호출 경로(예: 비동기 발급 processor)에서issueTo를 직접 호출할 경우issuedCount >= issueLimit상태에서도 발급이 진행될 수 있다.도메인 규칙은 도메인 모델에서 강제해야 하며, 서비스 계층의 가드는 보조 수단이어야 한다.
public IssuedCoupon issueTo(String userLoginId, ZonedDateTime now) { ... if (isExpired(now)) { throw new CoreException(ErrorType.CONFLICT, "만료된 쿠폰은 발급할 수 없습니다."); } + if (isFirstCome() && issuedCount >= issueLimit) { + throw new CoreException(ErrorType.CONFLICT, "발급 한도가 초과되었습니다."); + } return new IssuedCoupon(id, userLoginId, expiredAt); }추가 테스트:
issuedCount == issueLimit인 first-come 쿠폰에 대해issueTo호출 시 예외가 발생함을 검증하는 단위 테스트가 필요하다.As per coding guidelines,
**/domain/**/*.java경로에서 "도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다" 및 "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다"를 적용했다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java` around lines 145 - 160, Coupon.issueTo 메서드에서 first-come 쿠폰의 발급 한도를 도메인 규칙으로 검증하도록 수정하세요. isFirstCome()이고 issuedCount가 issueLimit 이상이면 기존 도메인 예외 형식에 맞춰 발급을 거부하고, 서비스 계층 검증은 보조 수단으로 유지하세요. 또한 issuedCount == issueLimit인 first-come 쿠폰의 issueTo 호출이 예외를 발생시키는 단위 테스트를 추가하세요.Source: Path instructions
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java (1)
8-8: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win
request_id컬럼에 인덱스 추가를 권장한다.
findByRequestIdAndDeletedAtIsNull은request_id로 조회한다. 발급 요청이 누적되는 운영 환경에서 인덱스가 없으면 풀 스캔이 발생하여 조회 지연이 발생할 수 있다.request_id컬럼에 데이터베이스 인덱스를 추가하라.추가 테스트: 대량 데이터(10만 건 이상) 환경에서
findByRequestIdAndDeletedAtIsNull조회 지연 시간을 측정하여 인덱스 적용 전후를 비교한다.As per path instructions,
**/*Repository*.java: 정렬/인덱스 활용 가능성을 점검한다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java` at line 8, CouponIssueRequestJpaRepository의 findByRequestIdAndDeletedAtIsNull 조회를 지원하도록 CouponIssueRequestJpaEntity의 request_id 컬럼에 데이터베이스 인덱스를 추가하라. 운영 데이터 10만 건 이상을 기준으로 해당 메서드의 인덱스 적용 전후 조회 성능을 검증하는 테스트도 추가하라.Source: Path instructions
apps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.java (1)
68-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winclearAutomatically = true로 인한 detached entity 조작
왜 문제인지(운영 관점):
increaseIssuedCount의clearAutomatically = true가 영속성 컨텍스트를 전체 삭제하여, 이후delete(issuedCoupon)과save(requestEntity)가 detached entity를 조작하게 된다. 현재는 Spring Data JPA의 merge 동작으로 기능하지만, 코드 변경 시 잠재적 버그 발생 가능성이 있다.수정안: SOLD_OUT 경로에서
issuedCoupon을 식별자 기반으로 재조회 후 삭제하거나,increaseIssuedCount실행 후requestEntity를 재조회하여 managed 상태를 유지하도록 수정한다.추가 테스트: SOLD_OUT 경로에서
issuedCoupon이 정상 삭제되고requestEntity가 정상 갱신되는지 통합 테스트로 검증한다. 기존rejectsRequest_whenCouponIsSoldOut테스트가 있으나, detached entity 경로의 안정성을 명시적으로 검증하는 것이 권장된다.As per path instructions for
**/*Repository*.java: 영속성 컨텍스트 오염 가능성을 점검한다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.java` around lines 68 - 73, CouponIssueProcessor의 SOLD_OUT 분기에서 increaseIssuedCount 호출로 영속성 컨텍스트가 초기화된 뒤 detached 상태의 issuedCoupon과 requestEntity를 사용하지 않도록 수정하세요. increaseIssuedCount 이후 issuedCoupon을 식별자로 재조회해 삭제하고 requestEntity도 재조회하거나 managed 상태를 복원한 뒤 reject를 수행하세요. rejectsRequest_whenCouponIsSoldOut 통합 테스트를 보강해 쿠폰 삭제와 요청 갱신을 명시적으로 검증하세요.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueueRepository.java (1)
39-56: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winClaim TTL이 token 잔여 TTL을 그대로 상속하므로 주문 처리 시간이 부족할 수 있다.
운영 관점 문제: token TTL이 5분일 때 사용자가 만료 직전에 claim하면 claim TTL이 수 초 남짓이 된다. 주문 처리가 이보다 오래 걸리면 claim이 만료되어
completeEntryToken이 실패하고, token이 정리되지 않은 채 잔존한다. 동시 재시도 시나리오에서 만료된 claim에 대한 재claim이 허용되어 중복 주문이 발생할 수 있다.수정안: claim TTL에 최소 하한선을 설정하여 주문 처리에 충분한 시간을 보장한다.
추가 테스트: token 잔여 TTL이 1초일 때 claim 후 5초 경과 시 claim이 유효한지 검증하는 테스트.
🔧 Proposed fix: add minimum TTL floor for claim
local ttl = redis.call('PTTL', KEYS[1]) if ttl <= 0 then return 0 end - local claimed = redis.call('SET', KEYS[2], ARGV[1], 'PX', ttl, 'NX') + local minClaimTtl = tonumber(ARGV[2]) + local claimTtl = ttl + if claimTtl < minClaimTtl then + claimTtl = minClaimTtl + end + local claimed = redis.call('SET', KEYS[2], ARGV[1], 'PX', claimTtl, 'NX') if claimed then return 1 end return 0Java 측에서
minClaimTtl값을 추가 ARGV로 전달:`@Override` public boolean claimEntryToken(String userLoginId, String token) { Long claimed = redisTemplate.execute( CLAIM_ENTRY_TOKEN_SCRIPT, List.of(entryTokenKey(userLoginId), entryTokenClaimKey(userLoginId)), - token + token, + String.valueOf(properties.minClaimTtl().toMillis()) ); return Long.valueOf(1L).equals(claimed); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueueRepository.java` around lines 39 - 56, CLAIM_ENTRY_TOKEN_SCRIPT가 token의 잔여 TTL만 claim에 적용해 처리 중 claim이 조기 만료되는 문제를 수정한다. Java의 claim 호출 로직에서 최소 claim TTL을 정의해 추가 ARGV로 전달하고, 스크립트의 TTL 계산 시 token 잔여 TTL과 최소값 중 큰 값을 사용하도록 변경한다. Redis SET의 PX 값이 최소 하한을 보장하도록 하며, 잔여 TTL이 1초인 token을 claim한 뒤 5초 후에도 claim이 유효한지 검증하는 테스트를 추가한다.apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java (1)
14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win상태 필드를
final로 선언하여 불변성을 보장하라.
status,rejectReason,issuedCouponId,processedAt필드에 setter나 상태 전환 메서드가 없다. API 모듈에서는 요청 생성과 조회만 담당하고 상태 변경은 streamer에서 처리하므로, 이 필드들은 생성 후 변경되지 않는다.final로 선언하여 스레드 안전성과 의도를 명확히 하라.♻️ Proposed fix
public class CouponIssueRequest { private final String requestId; private final Long couponId; private final String userLoginId; - private IssueRequestStatus status; - private IssueRejectReason rejectReason; - private Long issuedCouponId; + private final IssueRequestStatus status; + private final IssueRejectReason rejectReason; + private final Long issuedCouponId; private final ZonedDateTime requestedAt; - private ZonedDateTime processedAt; + private final ZonedDateTime processedAt;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java` around lines 14 - 18, CouponIssueRequest의 status, rejectReason, issuedCouponId, processedAt 필드를 final로 선언하고, 생성자에서 모든 값을 초기화하도록 수정하세요. 요청 생성 및 조회 로직에서 해당 필드에 대한 재할당이 발생하지 않는지 함께 확인하고 제거하세요.apps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkResult.java (2)
121-123: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
ratePerSecond에서Double.POSITIVE_INFINITY반환 시 보고서 출력이 깨질 수 있다.
durationMs <= 0일 때Infinity를 반환하면 CSV 파싱이 실패하고 Markdown 보고서에서 수치 분석이 어려워진다. 측정 결과가 0ms인 경우(드물지만 발생 가능) 대비해0.0또는Double.NaN을 반환하도록 수정하라.♻️ Proposed fix
private static double ratePerSecond(long count, double durationMs) { - return durationMs <= 0 ? Double.POSITIVE_INFINITY : count * 1_000.0 / durationMs; + return durationMs <= 0 ? 0.0 : count * 1_000.0 / durationMs; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkResult.java` around lines 121 - 123, ratePerSecond 메서드에서 durationMs가 0 이하일 때 CSV와 Markdown 출력에 사용할 수 없는 Double.POSITIVE_INFINITY 대신 0.0 또는 Double.NaN을 반환하도록 변경하라.
104-104: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
theoreticalAdmissionTps계산 시admitDelayMs == 0이면Infinity가 반환되는데, 동일한 문제가 발생한다.ratePerSecond수정과 함께 일관되게 처리하라.♻️ Proposed fix
- admitDelayMs == 0 ? Double.POSITIVE_INFINITY : batchSize * 1_000.0 / admitDelayMs, + admitDelayMs == 0 ? 0.0 : batchSize * 1_000.0 / admitDelayMs,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkResult.java` at line 104, WaitingQueueBenchmarkResult의 theoreticalAdmissionTps 계산에서 admitDelayMs가 0일 때 Double.POSITIVE_INFINITY를 반환하지 않도록 ratePerSecond와 동일한 유한값 처리 방식을 적용하세요. 해당 계산식과 관련 필드 또는 생성 로직을 확인해 0 지연 시 일관된 기본값을 사용하도록 수정하세요.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java (1)
11-25: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
replicas(1)과partitions(3)하드코딩을 구성 가능하도록 변경하라.
replicas(1)은 브로커 장애 시 데이터 유실로 이어진다. 운영 환경에서는 최소 3 replica가 권장된다.partitions(3)도 처리량 요구에 따라 조정되어야 하므로 프로퍼티로 외부 주입 가능해야 한다.♻️ Proposed fix: properties 기반 구성
`@Configuration` public class KafkaTopicConfig { + private final int partitions; + private final int replicas; + + public KafkaTopicConfig( + `@Value`("${kafka.topics.partitions:3}") int partitions, + `@Value`("${kafka.topics.replicas:1}") int replicas + ) { + this.partitions = partitions; + this.replicas = replicas; + } + `@Bean` public NewTopic catalogEventsTopic(CatalogEventTopicProperties properties) { return TopicBuilder.name(properties.catalogEvents()) - .partitions(3) - .replicas(1) + .partitions(partitions) + .replicas(replicas) .build(); } `@Bean` public NewTopic couponIssueRequestsTopic(CatalogEventTopicProperties properties) { return TopicBuilder.name(properties.couponIssueRequests()) - .partitions(3) - .replicas(1) + .partitions(partitions) + .replicas(replicas) .build(); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java` around lines 11 - 25, KafkaTopicConfig의 catalogEventsTopic 및 couponIssueRequestsTopic에서 하드코딩된 partitions(3)과 replicas(1)을 제거하고, 토픽별 또는 공통 설정 프로퍼티를 통해 외부 주입하도록 변경하라. CatalogEventTopicProperties에 파티션 수와 replica 수 필드를 추가하고 설정 파일 바인딩을 지원한 뒤, 각 TopicBuilder에 해당 프로퍼티 값을 사용하라.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.java (2)
55-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
firstCome()팩토리가 값과 최소 주문 금액을 하드코딩한다.
value=1000L,minOrderAmount=0L,type="FIXED"가 고정되어 있어 다른 금액이나 타입의 선착순 쿠폰을 생성할 수 없다. 호출부에서 파라미터를 주입받도록 수정하라.♻️ Proposed fix
- public static CouponJpaEntity firstCome(String name, ZonedDateTime expiredAt, Long issueLimit) { - return new CouponJpaEntity(name, "FIXED", 1000L, 0L, expiredAt, issueLimit); + public static CouponJpaEntity firstCome(String name, String type, Long value, Long minOrderAmount, ZonedDateTime expiredAt, Long issueLimit) { + return new CouponJpaEntity(name, type, value, minOrderAmount, expiredAt, issueLimit); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.java` around lines 55 - 57, CouponJpaEntity.firstCome()가 쿠폰 타입, 할인 값, 최소 주문 금액을 하드코딩하지 않도록 해당 값을 파라미터로 추가하고, 전달받은 값으로 CouponJpaEntity를 생성하도록 수정하라. 모든 호출부도 새 메서드 시그니처에 맞게 업데이트하라.
17-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
type필드를 String 대신 enum으로 매핑하는 것을 권장한다.
@Enumerated(EnumType.STRING)을 사용하면 잘못된 타입 값이 DB에 저장되는 것을 방지할 수 있다. 현재 String으로 인해"FIXED"외에 임의의 문자열이 저장될 수 있다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.java` around lines 17 - 18, CouponJpaEntity의 type 필드를 String 대신 적절한 쿠폰 타입 enum으로 변경하고, `@Enumerated`(EnumType.STRING)을 적용해 enum 이름이 문자열로 저장되도록 수정하세요. 관련 생성자, 접근자, 매핑 로직도 enum 타입에 맞게 업데이트하세요.apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRecord.java (1)
36-56: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
restore()팩토리에 입력 검증이 없다.commerce-api의
CouponIssueRequest.reconstruct()는 모든 필드에 대해 null/blank 검증을 수행하지만, streamer의restore()는 어떤 검증도 수행하지 않는다. Kafka 메시지 역직렬화 또는 DB 복원 시 잘못된 데이터가 도메인 객체로 유입될 수 있다.🛡️ Proposed fix: restore() 검증 추가
public static CouponIssueRequestRecord restore( String requestId, Long couponId, String userLoginId, IssueRequestStatus status, IssueRejectReason rejectReason, Long issuedCouponId, ZonedDateTime requestedAt, ZonedDateTime processedAt ) { + if (requestId == null || requestId.isBlank()) { + throw new IllegalArgumentException("requestId must not be blank"); + } + if (couponId == null) { + throw new IllegalArgumentException("couponId must not be null"); + } + if (userLoginId == null || userLoginId.isBlank()) { + throw new IllegalArgumentException("userLoginId must not be blank"); + } + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + if (requestedAt == null) { + throw new IllegalArgumentException("requestedAt must not be null"); + } return new CouponIssueRequestRecord( requestId, couponId, userLoginId, status, rejectReason, issuedCouponId, requestedAt, processedAt ); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRecord.java` around lines 36 - 56, CouponIssueRequestRecord.restore()에 입력 검증이 없어 잘못된 데이터가 도메인 객체로 유입될 수 있습니다. commerce-api의 CouponIssueRequest.reconstruct()와 동일한 기준으로 restore()의 모든 인자에 null/blank 검증을 추가하고, 유효하지 않은 값은 생성 전에 적절한 예외로 거부하도록 수정하세요.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java`:
- Line 45: ProductLikeFacadeTest에 like/unlike 성공 경로의 이벤트 발행 검증을 추가하세요. 각 테스트에서
eventPublisher.publishEvent가 ProductLikeChangedEvent.liked(...) 또는 unliked(...)를
정확한 productId와 userLoginId로 호출했는지 verify하도록 작성하고, 잘못된 payload나 이벤트 누락도 검출되게 하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/QueuedOrderFacade.java`:
- Around line 22-29: QueuedOrderFacade의 주문 생성 후 토큰 처리에서 예외 원인을 보존하도록 수정하세요.
createOrder 실패 시 releaseEntryTokenClaim을 별도 try-catch로 감싸 정리 실패를 로그로만 처리하고 원래
예외를 다시 전파하세요. completeEntryToken 호출도 try-catch로 감싸 실패를 로깅한 뒤 이미 생성된 orderInfo를
반환하며, 반환값은 적절히 처리하고 관련 테스트를 추가하세요.
- Line 19: OrderFacade.createOrder의 트랜잭션 커밋 직후 completeEntryToken 실패를 처리하지 않아
주문과 입장 토큰 상태가 불일치합니다. completeEntryToken에 재시도와 최종 실패 시 보상 처리를 추가하고, 커밋 이후 Redis
장애에서도 안전하게 재시도할 수 있도록 구현하세요. createOrder의 커밋 후 처리 및 completeEntryToken 관련 테스트에
Redis 장애와 재시도 시나리오를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.java`:
- Around line 41-54: CoreException 생성 시 JsonProcessingException 원인이 유실되고 있습니다.
CoreException에 Throwable cause를 받는 생성자를 추가하고, CatalogEventOutboxListener의 save
메서드 catch 블록에서 원본 exception을 cause로 전달하세요. 직렬화 실패 테스트에서
CoreException#getCause()가 원본 JsonProcessingException인지 검증하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/CouponIssueOutboxListener.java`:
- Around line 23-37: CouponIssueOutboxListener.handle의 메트릭 증가와 예외 처리를 개선하라. 저장
메트릭은 BEFORE_COMMIT이 아닌 커밋 성공 후에만 증가하도록 AFTER_COMMIT 처리로 분리하고, CoreException에
Throwable cause를 전달하는 생성자를 추가해 JsonProcessingException의 원인을 보존하라. 필요하면 eventId
중심의 로깅을 사용자 메시지와 분리하고, 롤백 시 메트릭이 증가하지 않으며 직렬화 실패 시 cause가 유지되는 테스트를 추가하라.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxRelayService.java`:
- Around line 19-32: OutboxRelayService.relayReadyEvents가 전체 배치를 단일 트랜잭션으로 처리해
Kafka 발행과 상태 저장 실패 시 중복 발행 위험이 있다. 이벤트별 처리를 별도 빈의 메서드로 분리하고 해당 메서드에
`@Transactional`(propagation = REQUIRES_NEW)을 적용해 각 이벤트의 트랜잭션 경계를 독립시키며,
self-invocation은 피하라. Kafka publish 성공 후 DB 저장 실패 및 다음 relay 재실행 시나리오를 검증하는 회귀
테스트도 추가하라.
- Around line 26-28: OutboxRelayService의 findRelayableEvents 조회가 FAILED 이벤트를 무제한
재처리하고 다중 인스턴스 중복 발행을 허용합니다. FAILED 이벤트에 재시도 횟수와 다음 재시도 시각을 적용해 한도 초과 또는 시각 미도래
이벤트를 제외하고, READY/재시도 대상 조회에 SELECT ... FOR UPDATE SKIP LOCKED 또는 동등한 claim 처리를
추가하세요. 관련 저장소·엔티티 상태 변경과 함께 중복 실행 시 단일 발행 및 재시도 한도 초과 제외 테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueAdmitScheduler.java`:
- Around line 20-23: WaitingQueueAdmitScheduler.admit() runs concurrently on
every application instance, multiplying admission throughput. Protect the
scheduled execution with a distributed lock such as ShedLock or Redis, configure
an appropriate lock duration around the 100ms interval, and add an integration
test with multiple instances verifying that admit() executes only once per
interval.
In
`@apps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueAdmitService.java`:
- Around line 16-22: WaitingQueueAdmitService.admit의 대기열 제거와 entry token 발급이
비원자적으로 수행되어 사용자 유실 및 부분 실패가 발생할 수 있다. RedisWaitingQueueRepository에 Lua 기반 원자 연산
메서드를 추가해 ZPOPMIN과 사용자별 token SET/EXPIRE를 한 번에 처리하고, admit은 개별 pop 및
issueEntryToken 호출 대신 해당 메서드를 사용하도록 변경하라. 원자 처리와 장애·부분 실패 시 사용자 복구를 검증하는 테스트도
추가하라.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxMessagePublisher.java`:
- Around line 26-37: In KafkaOutboxMessagePublisher.publish, retain the
CompletableFuture returned by kafkaTemplate.send and cancel it explicitly when
get times out, preferably with interruption enabled, before propagating the
failure. Add or update tests covering delayed Kafka responses to verify timeout
handling, event state transitions, and whether the message is actually
published; also ensure consumer-side idempotency or an appropriate
retry/fallback strategy prevents duplicates.
In
`@apps/commerce-api/src/test/java/com/loopers/application/queue/WaitingQueueIntegrationTest.java`:
- Around line 107-129: Replace the hardcoded expectedAdmittedUsers value in
admitsOnlyBatchSize_whenWaitingUsersExceedBatchSize with the injected
WaitingQueueProperties.batchSize(), and use that value consistently in all
assertions. Add coverage for exactly batchSize waiting users being fully
admitted and for admitting with zero waiting users returning 0.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java`:
- Around line 393-425: findAll() 결과 순서에 의존해 이벤트를 검증하는 테스트가 플래키할 수 있습니다.
storesCatalogEventInOutbox_once_whenUserLikesProduct와
storesCatalogEventInOutbox_whenUserUnlikesProduct에서 이벤트를 인덱스로 조회하지 말고 eventType
등 식별 조건으로 필터링해 검증하세요. 각 outbox 이벤트의 status가 READY인지도 assertion으로 추가하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.java`:
- Around line 38-39: Update FirstComeCouponIssueProcessor to handle missing
request or coupon records without throwing IllegalStateException: in the request
lookup and coupon lookup, record an explicit rejection using
IssueRejectReason.NOT_FOUND and return true. Add NOT_FOUND to IssueRejectReason,
and add tests verifying nonexistent requestId and couponId are rejected without
exceptions.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogEventMessage.java`:
- Around line 30-39: CatalogEventMessage의 intValue 메서드에서 문자열 파싱 실패를 안전하게 처리하도록
수정하세요. Integer.parseInt 호출을 예외 처리해 NumberFormatException 발생 시 0을 반환하고,
eventType과 key를 포함한 warn 로그를 남기도록 하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogMetricsEventProcessor.java`:
- Around line 20-37: process 메서드의 중복 이벤트 및 신규 ProductMetrics 동시 생성 경쟁을 원자적으로
처리하도록 수정하세요. eventHandledJpaRepository의 사전 existsById 확인 대신 이벤트 처리 레코드를 먼저
saveAndFlush하고 DataIntegrityViolationException 발생 시 duplicate로 기록한 뒤 false를
반환하세요. ProductMetrics 생성은 productMetricsJpaRepository의 저장 시 unique 제약 위반을 잡아 기존
레코드를 재조회하고 delta를 적용하도록 보완하며, 동일 eventId 동시 처리와 동일 productId 신규 생성 경쟁 테스트를
추가하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueRequestConsumer.java`:
- Around line 29-35: CouponIssueRequestConsumer.consume의 개별 레코드 예외 처리 부재로 배치 전체
재처리와 poison pill이 발생할 수 있습니다. 각 레코드 처리를 try-catch로 감싸고, 실패 시 `@Slf4j` 로그를 남긴 뒤
DLT로 전송하거나 스킵하여 후속 레코드가 처리되도록 하며 acknowledgment 동작을 이에 맞게 조정하세요. 가능하면 Spring
Kafka DefaultErrorHandler와 DLT 처리기를 설정하고, 잘못된 페이로드와 정상 메시지가 함께 있는 배치 테스트를 추가하세요.
In
`@apps/commerce-streamer/src/test/java/com/loopers/application/coupon/FirstComeCouponIssueProcessorIntegrationTest.java`:
- Around line 59-100: FirstComeCouponIssueProcessorIntegrationTest에 동일 이벤트를 두 번
process하는 멱등성 테스트를 추가하고, 두 번째 호출의 반환값과 발급 쿠폰이 하나뿐인지, 요청 상태 및 eventHandled 기록이
유지되는지 검증하라. 또한 동일 사용자가 같은 쿠폰을 다시 요청하는 테스트를 추가해 두 번째 처리 결과가 REJECTED이고
rejectReason이 IssueRejectReason.ALREADY_ISSUED이며 중복 발급이 생성되지 않는지 확인하라. 기존 테스트의
processor.process와 issuedCouponJpaRepository 검증 패턴을 활용하라.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/KafkaEventEnvelope.java`:
- Around line 6-14: KafkaEventEnvelope의 data Map이 외부 변경에 노출되지 않도록 compact
constructor에서 null 검증과 함께 방어적 복사를 적용하세요. 생성자 내부에서 Map.copyOf(data) 또는 동등한 불변
복사본으로 data를 재할당해 consumer 간 공유 상태 변경을 차단하고, record의 다른 필드는 기존 동작을 유지하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeChangedEvent.java`:
- Around line 15-24: ProductLikeChangedEvent의 liked 팩토리가 ZonedDateTime.now()를 직접
호출하지 않도록 Clock 파라미터를 받는 오버로드 또는 주입형 팩토리 메서드로 변경하고, ZonedDateTime.now(clock)을 사용해
occurredAt을 생성하세요. 기존 호출부를 새 시그니처에 맞게 갱신하고, 고정 Clock으로 occurredAt이 예상 시각과 일치하는지
테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/ProductOrderedEvent.java`:
- Around line 16-26: ProductOrderedEvent.ordered는 ZonedDateTime.now()를 직접 호출하지
말고 Clock을 받는 오버로드 팩토리 메서드를 추가해 주입된 시각을 사용하도록 수정하세요. 기존 호출 호환성을 유지하고,
OrderFacade.publishProductOrderedEvents에서 clock을 전달하며, 고정 Clock으로 occurredAt을
검증하는 테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`:
- Around line 52-68: OrderFacade의 no-op event publisher를 사용하는 5-인자 보조 생성자를
public에서 package-private으로 변경해 프로덕션 코드의 실수 사용을 제한하세요. 이벤트 발행을 검증하는 테스트와
publishProductOrderedEvents 관련 테스트는 반드시 event publisher를 명시하는 6-인자 생성자를 사용하도록
수정하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEvent.java`:
- Around line 61-70: OutboxEvent의 markPublished와 markFailed에 상태 전환 가드가 없어 잘못된
재처리가 가능하다. 두 메서드 모두 현재 상태가 READY일 때만 전환하도록 검증하고, 그 외 상태에서는 적절한 예외를 발생시켜 상태와
publishedAt이 변경되지 않게 하라. 또한 READY가 아닌 각 상태에서 두 메서드 호출 시 예외가 발생하는 단위 테스트를 추가하라.
In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java`:
- Around line 145-160: Coupon.issueTo 메서드에서 first-come 쿠폰의 발급 한도를 도메인 규칙으로 검증하도록
수정하세요. isFirstCome()이고 issuedCount가 issueLimit 이상이면 기존 도메인 예외 형식에 맞춰 발급을 거부하고,
서비스 계층 검증은 보조 수단으로 유지하세요. 또한 issuedCount == issueLimit인 first-come 쿠폰의 issueTo
호출이 예외를 발생시키는 단위 테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java`:
- Around line 14-18: CouponIssueRequest의 status, rejectReason, issuedCouponId,
processedAt 필드를 final로 선언하고, 생성자에서 모든 값을 초기화하도록 수정하세요. 요청 생성 및 조회 로직에서 해당 필드에 대한
재할당이 발생하지 않는지 함께 확인하고 제거하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java`:
- Around line 12-26: Kafka topic의 partitions와 replicas 값이 하드코딩되어 있다.
CatalogEventTopicProperties에 각 토픽의 파티션 수와 복제본 수 설정을 추가하고, KafkaTopicConfig의
catalogEventsTopic 및 couponIssueRequestsTopic에서 해당 프로퍼티 값을 사용하도록 변경해 환경별 외부 설정이
가능하게 하라.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxMessagePublisher.java`:
- Line 28: KafkaOutboxMessagePublisher의 payload 처리에서 event.getPayload()를 Map으로
역직렬화하지 말고 원본 JSON 문자열을 그대로 Kafka 메시지 값으로 전달하도록 수정하세요. KafkaTemplate의 값 타입과
serializer 설정도 StringSerializer에 맞게 확인·조정하고, 불필요한 Map 역직렬화 및 관련 타입 처리를 제거하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java`:
- Around line 10-12: Outbox 조회 성능과 다중 relay 중복 처리 방지를 보완하세요.
OutboxEventJpaRepository의 findByStatusInOrderByCreatedAtAsc 및 findByEventId에
대응하는 (status, created_at)와 event_id 인덱스를 엔티티 마이그레이션에 추가하고, polling 조회에는
PESSIMISTIC_WRITE 또는 FOR UPDATE SKIP LOCKED 방식의 잠금을 적용하세요. 대량 데이터 성능과 다중 인스턴스 동시
polling 중복 처리를 검증하는 테스트도 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueueRepository.java`:
- Around line 39-56: CLAIM_ENTRY_TOKEN_SCRIPT가 token의 잔여 TTL만 claim에 적용해 처리 중
claim이 조기 만료되는 문제를 수정한다. Java의 claim 호출 로직에서 최소 claim TTL을 정의해 추가 ARGV로 전달하고,
스크립트의 TTL 계산 시 token 잔여 TTL과 최소값 중 큰 값을 사용하도록 변경한다. Redis SET의 PX 값이 최소 하한을
보장하도록 하며, 잔여 TTL이 1초인 token을 claim한 뒤 5초 후에도 claim이 유효한지 검증하는 테스트를 추가한다.
In
`@apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java`:
- Around line 46-48: ProductLikeFacadeTest의 eventPublisher mock에 대한 상호작용 검증이
누락되어 있다. ProductLikeFacade.likeProduct()의 성공 경로 테스트에
verify(eventPublisher).publishEvent(any()) 검증을 추가하고, 필요한 Mockito import와 호출 횟수
조건을 맞춰 이벤트 발행 회귀를 감지하도록 수정한다.
In
`@apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxRelayServiceTest.java`:
- Around line 42-67: OutboxRelayServiceTest의 marksFailed_whenPublishFails
테스트만으로는 부분 실패 처리와 메트릭을 검증하지 못합니다. OutboxRelayService 테스트에 여러 이벤트 중
publisher.publish가 특정 이벤트에서만 실패하도록 구성해 실패 이벤트는 FAILED 및 원인을 저장하고 나머지는 PUBLISHED
처리되는지와 각 상태 저장을 검증하는 테스트를 추가하세요. 주입한 SimpleMeterRegistry에서 성공·실패 카운터 값을 확인하고,
findRelayableEvents가 빈 목록을 반환할 때 예외 없이 종료되는 테스트도 추가하세요.
In
`@apps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkResult.java`:
- Around line 121-123: ratePerSecond 메서드에서 durationMs가 0 이하일 때 CSV와 Markdown 출력에
사용할 수 없는 Double.POSITIVE_INFINITY 대신 0.0 또는 Double.NaN을 반환하도록 변경하라.
- Line 104: WaitingQueueBenchmarkResult의 theoreticalAdmissionTps 계산에서
admitDelayMs가 0일 때 Double.POSITIVE_INFINITY를 반환하지 않도록 ratePerSecond와 동일한 유한값 처리
방식을 적용하세요. 해당 계산식과 관련 필드 또는 생성 로직을 확인해 0 지연 시 일관된 기본값을 사용하도록 수정하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.java`:
- Around line 68-73: CouponIssueProcessor의 SOLD_OUT 분기에서 increaseIssuedCount 호출로
영속성 컨텍스트가 초기화된 뒤 detached 상태의 issuedCoupon과 requestEntity를 사용하지 않도록 수정하세요.
increaseIssuedCount 이후 issuedCoupon을 식별자로 재조회해 삭제하고 requestEntity도 재조회하거나
managed 상태를 복원한 뒤 reject를 수행하세요. rejectsRequest_whenCouponIsSoldOut 통합 테스트를 보강해
쿠폰 삭제와 요청 갱신을 명시적으로 검증하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRecord.java`:
- Around line 36-56: CouponIssueRequestRecord.restore()에 입력 검증이 없어 잘못된 데이터가 도메인
객체로 유입될 수 있습니다. commerce-api의 CouponIssueRequest.reconstruct()와 동일한 기준으로
restore()의 모든 인자에 null/blank 검증을 추가하고, 유효하지 않은 값은 생성 전에 적절한 예외로 거부하도록 수정하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java`:
- Line 8: CouponIssueRequestJpaRepository의 findByRequestIdAndDeletedAtIsNull 조회를
지원하도록 CouponIssueRequestJpaEntity의 request_id 컬럼에 데이터베이스 인덱스를 추가하라. 운영 데이터 10만 건
이상을 기준으로 해당 메서드의 인덱스 적용 전후 조회 성능을 검증하는 테스트도 추가하라.
In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.java`:
- Around line 55-57: CouponJpaEntity.firstCome()가 쿠폰 타입, 할인 값, 최소 주문 금액을 하드코딩하지
않도록 해당 값을 파라미터로 추가하고, 전달받은 값으로 CouponJpaEntity를 생성하도록 수정하라. 모든 호출부도 새 메서드 시그니처에
맞게 업데이트하라.
- Around line 17-18: CouponJpaEntity의 type 필드를 String 대신 적절한 쿠폰 타입 enum으로 변경하고,
`@Enumerated`(EnumType.STRING)을 적용해 enum 이름이 문자열로 저장되도록 수정하세요. 관련 생성자, 접근자, 매핑 로직도
enum 타입에 맞게 업데이트하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaEntity.java`:
- Around line 27-28: IssuedCouponJpaEntity의 status 필드를 String 대신 발급 쿠폰 상태를 표현하는
enum 타입으로 변경하고, AVAILABLE 등 기존 magic value를 해당 enum 상수로 대체하세요. JPA 저장 시 enum이
문자열로 매핑되도록 `@Enumerated`(EnumType.STRING)을 적용하며, 생성자·접근자·상태 비교 등 관련 사용처도 새 enum
타입에 맞게 수정하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java`:
- Around line 11-25: KafkaTopicConfig의 catalogEventsTopic 및
couponIssueRequestsTopic에서 하드코딩된 partitions(3)과 replicas(1)을 제거하고, 토픽별 또는 공통 설정
프로퍼티를 통해 외부 주입하도록 변경하라. CatalogEventTopicProperties에 파티션 수와 replica 수 필드를 추가하고
설정 파일 바인딩을 지원한 뒤, 각 TopicBuilder에 해당 프로퍼티 값을 사용하라.
In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/CatalogMetricsEventProcessorIntegrationTest.java`:
- Around line 47-65: 현재 통합 테스트는 PRODUCT_LIKED만 검증하므로 이벤트 타입별 metrics delta 반영
검증이 누락되어 있다. CatalogMetricsEventProcessorIntegrationTest의 이벤트 처리 테스트에
PRODUCT_UNLIKED, PRODUCT_VIEWED, PRODUCT_ORDERED 케이스를 추가하고, 각 이벤트 타입에 맞는 카운트가
정상적으로 증가·감소하는지와 process 결과를 검증하라.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 66594a7b-74ab-42c8-86d5-44707b85dab8
📒 Files selected for processing (100)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.javaapps/commerce-api/src/main/java/com/loopers/application/event/CatalogEventType.javaapps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/event/KafkaEventEnvelope.javaapps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeChangedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/event/ProductOrderedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/QueuedOrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventTopicProperties.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/CouponIssueOutboxListener.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEvent.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventRepository.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventStatus.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxMessagePublisher.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxProperties.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxRelayScheduler.javaapps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxRelayService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueAdmitScheduler.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueAdmitService.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueFacade.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueInfo.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueProperties.javaapps/commerce-api/src/main/java/com/loopers/application/queue/WaitingQueueRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/IssueRejectReason.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/IssueRequestStatus.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/KafkaOutboxMessagePublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueueRepository.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxRelayServiceTest.javaapps/commerce-api/src/test/java/com/loopers/application/queue/WaitingQueueIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkConfig.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkConfigTest.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkReport.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkReportTest.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkResult.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkStatistics.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueBenchmarkStatisticsTest.javaapps/commerce-api/src/test/java/com/loopers/benchmark/queue/WaitingQueueCapacityBenchmarkTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueV1ApiE2ETest.javaapps/commerce-api/src/test/resources/application.propertiesapps/commerce-streamer/src/main/java/com/loopers/application/coupon/CouponIssueRequestMessage.javaapps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogEventMessage.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogMetricsEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRecord.javaapps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssueRejectReason.javaapps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssueRequestStatus.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/CatalogEventTopicProperties.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/EventHandledJpaEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/EventHandledJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueRequestConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.javaapps/commerce-streamer/src/main/resources/application.ymlapps/commerce-streamer/src/test/java/com/loopers/application/coupon/FirstComeCouponIssueProcessorIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/application/metrics/CatalogMetricsEventProcessorIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/application/metrics/CatalogMetricsEventProcessorTest.javadocs/week8/01-waiting-queue-capacity-benchmark.mddocs/week8/02-waiting-queue-benchmark-results.mdhttp/commerce-api/queue-v1.httpmodules/kafka/src/main/resources/kafka.yml
| } | ||
| productCacheRepository.evictProduct(productId); | ||
| productCacheRepository.evictProductLists(); | ||
| eventPublisher.publishEvent(ProductLikeChangedEvent.liked(productId, userLoginId)); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check event listener transaction phase for ProductLikeChangedEvent
rg -n '`@EventListener`|`@TransactionalEventListener`' --type=java -g '**/outbox/*Listener*.java' -g '**/event/*Listener*.java'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ProductLikeFacade =="
fd -a 'ProductLikeFacade.java' apps
echo
echo "== ProductLikeFacadeTest =="
fd -a 'ProductLikeFacadeTest.java' apps
echo
echo "== Event classes/listeners mentioning ProductLikeChangedEvent =="
rg -n "ProductLikeChangedEvent|`@EventListener`|`@TransactionalEventListener`|publishEvent\\(" apps/commerce-api/src/main/java apps/commerce-api/src/test/java
echo
echo "== File outline: ProductLikeFacade.java =="
ast-grep outline apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java --view expanded || true
echo
echo "== File outline: ProductLikeFacadeTest.java =="
ast-grep outline apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java --view expanded || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 4149
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Locate relevant files =="
git ls-files 'apps/commerce-api/src/main/java/**/ProductLikeFacade.java' \
'apps/commerce-api/src/test/java/**/ProductLikeFacadeTest.java' \
'apps/commerce-api/src/main/java/**/*.java' \
'apps/commerce-api/src/test/java/**/*.java' | sed -n '1,200p'
echo
echo "== Search for ProductLikeChangedEvent across commerce-api =="
rg -n "ProductLikeChangedEvent|publishEvent\\(|`@EventListener`|`@TransactionalEventListener`" apps/commerce-api/src/main/java apps/commerce-api/src/test/java
echo
echo "== Print ProductLikeFacade.java around publishEvent lines =="
FILE=$(git ls-files 'apps/commerce-api/src/main/java/**/ProductLikeFacade.java' | head -n 1)
if [ -n "${FILE:-}" ]; then
nl -ba "$FILE" | sed -n '1,180p'
fi
echo
echo "== Print ProductLikeFacadeTest.java =="
FILE2=$(git ls-files 'apps/commerce-api/src/test/java/**/ProductLikeFacadeTest.java' | head -n 1)
if [ -n "${FILE2:-}" ]; then
nl -ba "$FILE2" | sed -n '1,260p'
fiRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 20305
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ProductLikeFacadeTest.java =="
sed -n '1,220p' apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.java
echo
echo "== CatalogEventOutboxListener.java =="
sed -n '1,220p' apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.java
echo
echo "== ProductLikeFacade.java =="
sed -n '1,180p' apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 11010
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,240p' apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeTest.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 3977
ProductLikeFacadeTest에 이벤트 발행 검증을 추가하라다. like/unlike 경로에서 eventPublisher.publishEvent(...)가 누락되거나 잘못된 payload로 바뀌어도 현재 테스트는 이를 잡지 못하므로, 카탈로그 outbox 이벤트 누락 회귀가 운영에서 그대로 통과할 수 있다다. ProductLikeFacadeTest의 각 경로에 verify(eventPublisher).publishEvent(ProductLikeChangedEvent.liked(...))와 unliked(...)를 추가하라다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java`
at line 45, ProductLikeFacadeTest에 like/unlike 성공 경로의 이벤트 발행 검증을 추가하세요. 각 테스트에서
eventPublisher.publishEvent가 ProductLikeChangedEvent.liked(...) 또는 unliked(...)를
정확한 productId와 userLoginId로 호출했는지 verify하도록 작성하고, 잘못된 payload나 이벤트 누락도 검출되게 하세요.
| private final OrderFacade orderFacade; | ||
| private final WaitingQueueRepository waitingQueueRepository; | ||
|
|
||
| public OrderInfo createOrder(String userLoginId, List<OrderProductCommand> commands, Long couponId, String entryToken) { |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check if OrderFacade.createOrder has `@Transactional` annotation.
rg -n "`@Transactional`" -A 2 -B 2 --type java -g "**/OrderFacade.java" "createOrder"Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 228
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the relevant files first.
git ls-files | rg 'QueuedOrderFacade\.java|OrderFacade\.java|EntryToken|Redis|OrderFacade'
# Show compact structure for the candidate files.
for f in $(git ls-files | rg 'QueuedOrderFacade\.java|OrderFacade\.java'); do
echo "===== $f ====="
wc -l "$f"
ast-grep outline "$f" --view expanded || true
doneRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 2330
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "===== OrderFacade relevant slice ====="
sed -n '60,100p' apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
echo "===== QueuedOrderFacade full file ====="
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/QueuedOrderFacade.java
echo "===== Search for completeEntryToken and after-commit hooks ====="
rg -n "completeEntryToken|TransactionalEventListener|AFTER_COMMIT|afterCommit|TransactionSynchronization" apps modules -g "*.java"Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 5259
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,240p' apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/RedisWaitingQueueRepository.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 5835
커밋 직후 Redis 완료 실패를 보상 처리해야 한다 OrderFacade.createOrder는 트랜잭션으로 커밋되지만, 그 직후 completeEntryToken이 실패하면 주문은 생성됐는데 입장 토큰만 점유된 상태가 남는다. completeEntryToken에 재시도나 실패 보상 경로를 넣고, 커밋 후 Redis 장애와 재시도 시나리오를 추가 테스트하라다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/QueuedOrderFacade.java`
at line 19, OrderFacade.createOrder의 트랜잭션 커밋 직후 completeEntryToken 실패를 처리하지 않아
주문과 입장 토큰 상태가 불일치합니다. completeEntryToken에 재시도와 최종 실패 시 보상 처리를 추가하고, 커밋 이후 Redis
장애에서도 안전하게 재시도할 수 있도록 구현하세요. createOrder의 커밋 후 처리 및 completeEntryToken 관련 테스트에
Redis 장애와 재시도 시나리오를 추가하세요.
| try { | ||
| orderInfo = orderFacade.createOrder(userLoginId, commands, couponId); | ||
| } catch (RuntimeException exception) { | ||
| waitingQueueRepository.releaseEntryTokenClaim(userLoginId, entryToken); | ||
| throw exception; | ||
| } | ||
| waitingQueueRepository.completeEntryToken(userLoginId, entryToken); | ||
| return orderInfo; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
주문 생성 후 completeEntryToken 실패 시 데이터 불일치 및 예외 원천 소실 위험이 있다.
운영 관점 문제:
completeEntryToken에서 Redis 장애 발생 시 주문은 이미 영속화되었으나 예외가 전파되어 사용자에게 오류 응답이 반환된다. 사용자가 재시도하면 claim이 여전히 활성 상태이므로 "이미 사용 중" 오류가 발생하며, claim TTL 만료까지 대기해야 한다.- catch 블록 내
releaseEntryTokenClaim이 예외를 던지면 원래 주문 생성 예외가 소실된다. 사용자는 Redis 오류를 보게 되며, 원인 추적이 어려워진다. completeEntryToken반환값이 무시된다. claim이 만료된 경우 token이 정리되지 않아 잔여 TTL 동안 중복 claim이 가능하다.
수정안:
completeEntryToken호출을 try-catch로 감싸고, 실패 시 로그만 남기고 주문 결과를 반환한다. token은 TTL로 자동 만료된다.- catch 블록의
releaseEntryTokenClaim호출 시 발생 가능한 예외를 방어하여 원래 예외를 보존한다.
추가 테스트:
completeEntryToken실패 시 주문이 정상 생성되는지 검증하는 테스트.releaseEntryTokenClaim실패 시 원래 예외가 전파되는지 검증하는 테스트.
As per coding guidelines, 예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다.
🔧 Proposed fix for error handling
public OrderInfo createOrder(String userLoginId, List<OrderProductCommand> commands, Long couponId, String entryToken) {
claimEntryToken(userLoginId, entryToken);
OrderInfo orderInfo;
try {
orderInfo = orderFacade.createOrder(userLoginId, commands, couponId);
} catch (RuntimeException exception) {
- waitingQueueRepository.releaseEntryTokenClaim(userLoginId, entryToken);
+ try {
+ waitingQueueRepository.releaseEntryTokenClaim(userLoginId, entryToken);
+ } catch (RuntimeException releaseException) {
+ log.warn("Failed to release entry token claim. userLoginId={}", userLoginId, releaseException);
+ }
throw exception;
}
- waitingQueueRepository.completeEntryToken(userLoginId, entryToken);
+ try {
+ waitingQueueRepository.completeEntryToken(userLoginId, entryToken);
+ } catch (RuntimeException completeException) {
+ log.warn("Failed to complete entry token. userLoginId={}", userLoginId, completeException);
+ }
return orderInfo;
}📝 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.
| try { | |
| orderInfo = orderFacade.createOrder(userLoginId, commands, couponId); | |
| } catch (RuntimeException exception) { | |
| waitingQueueRepository.releaseEntryTokenClaim(userLoginId, entryToken); | |
| throw exception; | |
| } | |
| waitingQueueRepository.completeEntryToken(userLoginId, entryToken); | |
| return orderInfo; | |
| try { | |
| orderInfo = orderFacade.createOrder(userLoginId, commands, couponId); | |
| } catch (RuntimeException exception) { | |
| try { | |
| waitingQueueRepository.releaseEntryTokenClaim(userLoginId, entryToken); | |
| } catch (RuntimeException releaseException) { | |
| log.warn("Failed to release entry token claim. userLoginId={}", userLoginId, releaseException); | |
| } | |
| throw exception; | |
| } | |
| try { | |
| waitingQueueRepository.completeEntryToken(userLoginId, entryToken); | |
| } catch (RuntimeException completeException) { | |
| log.warn("Failed to complete entry token. userLoginId={}", userLoginId, completeException); | |
| } | |
| return orderInfo; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/QueuedOrderFacade.java`
around lines 22 - 29, QueuedOrderFacade의 주문 생성 후 토큰 처리에서 예외 원인을 보존하도록 수정하세요.
createOrder 실패 시 releaseEntryTokenClaim을 별도 try-catch로 감싸 정리 실패를 로그로만 처리하고 원래
예외를 다시 전파하세요. completeEntryToken 호출도 try-catch로 감싸 실패를 로깅한 뒤 이미 생성된 orderInfo를
반환하며, 반환값은 적절히 처리하고 관련 테스트를 추가하세요.
Source: Path instructions
| private void save(String eventId, String eventType, Long productId, KafkaEventEnvelope envelope) { | ||
| try { | ||
| outboxEventRepository.save(new OutboxEvent( | ||
| eventId, | ||
| topicProperties.catalogEvents(), | ||
| String.valueOf(productId), | ||
| eventType, | ||
| objectMapper.writeValueAsString(envelope) | ||
| )); | ||
| meterRegistry.counter("outbox_event_saved_total", "eventType", eventType).increment(); | ||
| } catch (JsonProcessingException exception) { | ||
| throw new CoreException(ErrorType.INTERNAL_ERROR, "카탈로그 이벤트 직렬화에 실패했습니다."); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: CoreException이 cause를 받는 생성자를 지원하는지 확인.
rg -n "class CoreException" --type java -g '!**/build/**' -A 20Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "[1] Locate CoreException definition"
fd -a "CoreException.java" .
echo
echo "[2] Search for CoreException declarations and constructors"
rg -n "class CoreException|CoreException\(" --type java -g '!**/build/**' -A 6 -B 2 .
echo
echo "[3] Search for call sites that pass a cause"
rg -n "new CoreException\([^)]*,[^)]*,[^)]*\)" --type java -g '!**/build/**' .
echo
echo "[4] Inspect the target listener for surrounding context"
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 50394
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "[1] Inspect CoreException and related error handling"
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java
echo
echo "[2] Find global exception handlers / loggers for CoreException"
rg -n "CoreException|`@ExceptionHandler`|ErrorType|logger\.|log\." apps/commerce-api/src/main/java -g '!**/build/**' -A 4 -B 4
echo
echo "[3] Inspect the target listener"
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 50395
CoreException에 원인 예외를 전달하도록 바꿔야 한다
현재 CoreException은 Throwable cause를 받지 않아 JsonProcessingException의 스택과 세부 원인이 사라진다. 운영에서 어떤 필드 때문에 직렬화가 깨졌는지 추적하기 어려우므로, CoreException에 cause 생성자를 추가하고 이곳에서 원본 예외를 넘겨야 한다. 추가 테스트로 직렬화 실패 시 CoreException#getCause()가 원본 JsonProcessingException인지 확인해야 한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.java`
around lines 41 - 54, CoreException 생성 시 JsonProcessingException 원인이 유실되고 있습니다.
CoreException에 Throwable cause를 받는 생성자를 추가하고, CatalogEventOutboxListener의 save
메서드 catch 블록에서 원본 exception을 cause로 전달하세요. 직렬화 실패 테스트에서
CoreException#getCause()가 원본 JsonProcessingException인지 검증하세요.
Source: Path instructions
| @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | ||
| public void handle(CouponIssueRequestedEvent event) { | ||
| try { | ||
| outboxEventRepository.save(new OutboxEvent( | ||
| event.eventId(), | ||
| topicProperties.couponIssueRequests(), | ||
| String.valueOf(event.couponId()), | ||
| event.eventType(), | ||
| objectMapper.writeValueAsString(event.toEnvelope()) | ||
| )); | ||
| meterRegistry.counter("outbox_event_saved_total", "eventType", event.eventType()).increment(); | ||
| } catch (JsonProcessingException exception) { | ||
| throw new CoreException(ErrorType.INTERNAL_ERROR, "쿠폰 발급 요청 이벤트 직렬화에 실패했습니다."); | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Verify CoreException constructors to check if it accepts a Throwable cause.
# Search for CoreException class definition
rg -nP -C10 'class CoreException' --type=javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the listener and exception classes, then inspect the relevant ranges.
fd -a 'CouponIssueOutboxListener.java|CoreException.java|ErrorType.java' apps .
rg -n --hidden --glob '*.java' 'class CoreException|record CoreException|extends RuntimeException|extends Exception' apps .
rg -n --hidden --glob '*.java' 'TransactionalEventListener|BEFORE_COMMIT|AFTER_COMMIT|meterRegistry.counter|outbox_event_saved_total|JsonProcessingException|log\.error|Logger' apps/commerce-api/src/main/java apps -S
# Print the listener and exception definitions if found.
for f in $(fd -a 'CouponIssueOutboxListener.java|CoreException.java|ErrorType.java' apps .); do
echo "===== $f ====="
wc -l "$f"
sed -n '1,220p' "$f"
doneRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 17766
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,220p' apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java
echo '===== ApiControllerAdvice ====='
sed -n '1,220p' apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
echo '===== CatalogEventOutboxListener ====='
sed -n '1,220p' apps/commerce-api/src/main/java/com/loopers/application/outbox/CatalogEventOutboxListener.java
echo '===== OutboxRelayService ====='
sed -n '1,220p' apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxRelayService.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 8134
CouponIssueOutboxListener.handle에서 메트릭과 예외 처리를 분리해야 한다다
outbox_event_saved_total는BEFORE_COMMIT에서 증가한다. 이후 롤백되면 저장되지 않은 이벤트도 성공으로 집계되므로AFTER_COMMIT으로 옮겨야 한다다.CoreException은 cause를 받지 않아JsonProcessingException의 스택 트레이스가 유실된다.CoreException(ErrorType, String, Throwable)를 추가해 원인을 전달하고, 필요하면eventId중심의 로그를 남겨 사용자 메시지와 분리하라다.- 롤백 시 counter가 증가하지 않는지, 직렬화 실패 시 cause가 유지되는지 확인하는 테스트를 추가하라다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/main/java/com/loopers/application/outbox/CouponIssueOutboxListener.java`
around lines 23 - 37, CouponIssueOutboxListener.handle의 메트릭 증가와 예외 처리를 개선하라. 저장
메트릭은 BEFORE_COMMIT이 아닌 커밋 성공 후에만 증가하도록 AFTER_COMMIT 처리로 분리하고, CoreException에
Throwable cause를 전달하는 생성자를 추가해 JsonProcessingException의 원인을 보존하라. 필요하면 eventId
중심의 로깅을 사용자 메시지와 분리하고, 롤백 시 메트릭이 증가하지 않으며 직렬화 실패 시 cause가 유지되는 테스트를 추가하라.
Source: Path instructions
| CouponIssueRequestJpaEntity requestEntity = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull(event.requestId()) | ||
| .orElseThrow(() -> new IllegalStateException("coupon issue request not found: " + event.requestId())); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
IllegalStateException 전파로 인한 영구 재시도 위험
왜 문제인지(운영 관점): orElseThrow가 던지는 IllegalStateException이 컨슈머까지 전파되어 배치 재시도를 유발한다. "coupon issue request not found" / "coupon not found"는 재시도로 해결되지 않는 영구 오류이므로 poison pill이 된다.
수정안: 요청/쿠폰 미발견 시 예외 대신 거부 처리 후 return true하도록 수정한다. IssueRejectReason에 NOT_FOUND를 추가하여 명시적으로 거부 처리한다.
추가 테스트: 존재하지 않는 requestId/couponId에 대해 예외 없이 거부 처리되는지 확인하는 테스트를 추가한다.
Also applies to: 47-48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/coupon/FirstComeCouponIssueProcessor.java`
around lines 38 - 39, Update FirstComeCouponIssueProcessor to handle missing
request or coupon records without throwing IllegalStateException: in the request
lookup and coupon lookup, record an explicit rejection using
IssueRejectReason.NOT_FOUND and return true. Add NOT_FOUND to IssueRejectReason,
and add tests verifying nonexistent requestId and couponId are rejected without
exceptions.
| private int intValue(String key) { | ||
| Object value = data == null ? null : data.get(key); | ||
| if (value instanceof Number number) { | ||
| return number.intValue(); | ||
| } | ||
| if (value instanceof String stringValue) { | ||
| return Integer.parseInt(stringValue); | ||
| } | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Integer.parseInt 검증 부재로 이벤트 처리 장애 가능
intValue 메서드는 null과 알 수 없는 타입에 대해 0을 반환하지만, 문자열 값이 정수가 아닌 경우(예: "abc", "12.5") NumberFormatException이 발생해 이벤트 처리가 중단된다. 운영 관점에서 잘못된 메시지 하나가 consumer 처리를 실패시키고 재시도 루프에 빠질 수 있다.
🛡️ 제안: parse 실패 시 안전한 fallback + 로깅
if (value instanceof String stringValue) {
- return Integer.parseInt(stringValue);
+ try {
+ return Integer.parseInt(stringValue);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
}추가로, fallback 시 eventType과 key를 포함한 warn 로그를 남겜 데이터 손상을 추적 가능하게 하는 것을 권장한다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private int intValue(String key) { | |
| Object value = data == null ? null : data.get(key); | |
| if (value instanceof Number number) { | |
| return number.intValue(); | |
| } | |
| if (value instanceof String stringValue) { | |
| return Integer.parseInt(stringValue); | |
| } | |
| return 0; | |
| } | |
| private int intValue(String key) { | |
| Object value = data == null ? null : data.get(key); | |
| if (value instanceof Number number) { | |
| return number.intValue(); | |
| } | |
| if (value instanceof String stringValue) { | |
| try { | |
| return Integer.parseInt(stringValue); | |
| } catch (NumberFormatException e) { | |
| return 0; | |
| } | |
| } | |
| return 0; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogEventMessage.java`
around lines 30 - 39, CatalogEventMessage의 intValue 메서드에서 문자열 파싱 실패를 안전하게 처리하도록
수정하세요. Integer.parseInt 호출을 예외 처리해 NumberFormatException 발생 시 0을 반환하고,
eventType과 key를 포함한 warn 로그를 남기도록 하세요.
Source: Path instructions
| @Transactional | ||
| public boolean process(CatalogEventMessage event) { | ||
| if (eventHandledJpaRepository.existsById(event.eventId())) { | ||
| meterRegistry.counter("catalog_event_consume_total", "result", "duplicate", "eventType", event.eventType()).increment(); | ||
| return false; | ||
| } | ||
|
|
||
| ProductMetricsJpaEntity metrics = productMetricsJpaRepository.findByProductId(event.productId()) | ||
| .orElseGet(() -> ProductMetricsJpaEntity.create(event.productId())); | ||
| metrics.applyLikeDelta(event.likeCountDelta()); | ||
| metrics.applyViewDelta(event.viewCountDelta()); | ||
| metrics.applySalesDelta(event.salesCountDelta()); | ||
|
|
||
| productMetricsJpaRepository.save(metrics); | ||
| eventHandledJpaRepository.save(EventHandledJpaEntity.handled(event.eventId(), event.eventType())); | ||
| meterRegistry.counter("catalog_event_consume_total", "result", "success", "eventType", event.eventType()).increment(); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
중복 이벤트 처리 TOCTOU 경쟁 및 ProductMetrics 생성 경쟁
existsById 확인과 EventHandledJpaEntity 저장 사이에 시간차가 있어 다중 인스턴스 환경에서 동일 이벤트가 중복 처리될 수 있다. 또한 findByProductId + orElseGet 패턴도 신규 상품에 대해 동시 생성 경쟁을 유발해 product_id unique constraint 위반이 발생할 수 있다. 두 경우 모두 트랜잭션 롤백으로 데이터 무결성은 유지되지만, 반복 실패와 재시도로 인해 처리 지연과 메시지 적체가 발생한다.
운영 관점에서 다중 consumer 인스턴스 환경에서 이벤트 중복 처리 실패가 빈번하면 throughput 저하와 DLQ 적체로 이어진다.
🔒 제안: saveAndFlush + DataIntegrityViolationException catch로 원자적 중복 검사
`@Transactional`
public boolean process(CatalogEventMessage event) {
- if (eventHandledJpaRepository.existsById(event.eventId())) {
- meterRegistry.counter("catalog_event_consume_total", "result", "duplicate", "eventType", event.eventType()).increment();
- return false;
- }
-
- ProductMetricsJpaEntity metrics = productMetricsJpaRepository.findByProductId(event.productId())
- .orElseGet(() -> ProductMetricsJpaEntity.create(event.productId()));
- metrics.applyLikeDelta(event.likeCountDelta());
- metrics.applyViewDelta(event.viewCountDelta());
- metrics.applySalesDelta(event.salesCountDelta());
-
- productMetricsJpaRepository.save(metrics);
- eventHandledJpaRepository.save(EventHandledJpaEntity.handled(event.eventId(), event.eventType()));
- meterRegistry.counter("catalog_event_consume_total", "result", "success", "eventType", event.eventType()).increment();
- return true;
+ try {
+ eventHandledJpaRepository.saveAndFlush(
+ EventHandledJpaEntity.handled(event.eventId(), event.eventType()));
+ } catch (DataIntegrityViolationException e) {
+ meterRegistry.counter("catalog_event_consume_total",
+ "result", "duplicate", "eventType", event.eventType()).increment();
+ return false;
+ }
+
+ ProductMetricsJpaEntity metrics = productMetricsJpaRepository.findByProductId(event.productId())
+ .orElseGet(() -> ProductMetricsJpaEntity.create(event.productId()));
+ metrics.applyLikeDelta(event.likeCountDelta());
+ metrics.applyViewDelta(event.viewCountDelta());
+ metrics.applySalesDelta(event.salesCountDelta());
+
+ try {
+ productMetricsJpaRepository.saveAndFlush(metrics);
+ } catch (DataIntegrityViolationException e) {
+ // 동시 생성 경쟁: 이미 다른 인스턴스가 생성함. 다시 조회해서 업데이트.
+ metrics = productMetricsJpaRepository.findByProductId(event.productId())
+ .orElseThrow(() -> e);
+ metrics.applyLikeDelta(event.likeCountDelta());
+ metrics.applyViewDelta(event.viewCountDelta());
+ metrics.applySalesDelta(event.salesCountDelta());
+ productMetricsJpaRepository.save(metrics);
+ }
+
+ meterRegistry.counter("catalog_event_consume_total",
+ "result", "success", "eventType", event.eventType()).increment();
+ return true;
}saveAndFlush로 EventHandledJpaEntity 삽입을 먼저 flush하여 PK 위반을 즉시 감지한다. ProductMetrics 생성 경쟁은 constraint violation 발생 시 재조회 후 업데이트하는 방식으로 해결한다.
추가 테스트: 동일 eventId에 대한 동시 처리 시 한 건만 성공하는지, 동일 productId 신규 생성 경쟁 시 두 이벤트의 delta가 모두 반영되는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/CatalogMetricsEventProcessor.java`
around lines 20 - 37, process 메서드의 중복 이벤트 및 신규 ProductMetrics 동시 생성 경쟁을 원자적으로
처리하도록 수정하세요. eventHandledJpaRepository의 사전 existsById 확인 대신 이벤트 처리 레코드를 먼저
saveAndFlush하고 DataIntegrityViolationException 발생 시 duplicate로 기록한 뒤 false를
반환하세요. ProductMetrics 생성은 productMetricsJpaRepository의 저장 시 unique 제약 위반을 잡아 기존
레코드를 재조회하고 delta를 적용하도록 보완하며, 동일 eventId 동시 처리와 동일 productId 신규 생성 경쟁 테스트를
추가하세요.
Source: Path instructions
| public void consume(List<ConsumerRecord<String, Object>> records, Acknowledgment acknowledgment) throws IOException { | ||
| for (ConsumerRecord<String, Object> record : records) { | ||
| CouponIssueRequestMessage event = objectMapper.readValue(payload(record.value()), CouponIssueRequestMessage.class); | ||
| firstComeCouponIssueProcessor.process(event); | ||
| } | ||
| acknowledgment.acknowledge(); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
개별 레코드 예외 처리 부재로 인한 컨슈머 장애 위험
왜 문제인지(운영 관점): 루프 내 개별 레코드 처리 중 예외 발생 시 acknowledgment.acknowledge()가 호출되지 않아 전체 배치가 재시도된다. 역직렬화 실패나 FirstComeCouponIssueProcessor.process()에서 던져진 IllegalStateException이 영구 오류인 경우, 후속 메시지가 무한히 차단되는 poison pill 현상이 발생한다.
수정안: per-record try-catch를 추가하고, 실패한 메시지는 에러 로그를 남긴 후 DLT(Dead Letter Topic)로 전송하거나 스킵하도록 수정한다. Spring Kafka의 DefaultErrorHandler + DLT 처리기 적용을 권장한다. @Slf4j 선언이 없으므로 추가가 필요하다.
추가 테스트: 잘못된 페이로드가 포함된 배치에서 정상 메시지가 정상 처리되는지 확인하는 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueRequestConsumer.java`
around lines 29 - 35, CouponIssueRequestConsumer.consume의 개별 레코드 예외 처리 부재로 배치 전체
재처리와 poison pill이 발생할 수 있습니다. 각 레코드 처리를 try-catch로 감싸고, 실패 시 `@Slf4j` 로그를 남긴 뒤
DLT로 전송하거나 스킵하여 후속 레코드가 처리되도록 하며 acknowledgment 동작을 이에 맞게 조정하세요. 가능하면 Spring
Kafka DefaultErrorHandler와 DLT 처리기를 설정하고, 잘못된 페이로드와 정상 메시지가 함께 있는 배치 테스트를 추가하세요.
| @DisplayName("선착순 쿠폰 요청을 처리하면 발급 쿠폰을 만들고 요청 상태를 ISSUED로 확정한다.") | ||
| @Test | ||
| void issuesCouponAndMarksRequestIssued_whenStockRemains() { | ||
| // arrange | ||
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | ||
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | ||
|
|
||
| // act | ||
| boolean processed = processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | ||
|
|
||
| // assert | ||
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-1").orElseThrow().toRecord(); | ||
| assertAll( | ||
| () -> assertThat(processed).isTrue(), | ||
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.ISSUED), | ||
| () -> assertThat(request.getIssuedCouponId()).isNotNull(), | ||
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user1234")).isPresent(), | ||
| () -> assertThat(eventHandledJpaRepository.existsById("event-1")).isTrue() | ||
| ); | ||
| } | ||
|
|
||
| @DisplayName("발급 한도가 소진되면 요청 상태를 REJECTED/SOLD_OUT으로 확정한다.") | ||
| @Test | ||
| void rejectsRequest_whenCouponIsSoldOut() { | ||
| // arrange | ||
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | ||
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | ||
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-2", coupon.getId(), "user5678", ZonedDateTime.now())); | ||
| processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | ||
|
|
||
| // act | ||
| boolean processed = processor.process(issueRequested("event-2", "request-2", coupon.getId(), "user5678")); | ||
|
|
||
| // assert | ||
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-2").orElseThrow().toRecord(); | ||
| assertAll( | ||
| () -> assertThat(processed).isTrue(), | ||
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.REJECTED), | ||
| () -> assertThat(request.getRejectReason()).isEqualTo(IssueRejectReason.SOLD_OUT), | ||
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user5678")).isEmpty() | ||
| ); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Kafka 중복 메시지 처리(멱등성) 및 ALREADY_ISSUED 거부 케이스 테스트를 추가하라.
Kafka는 at-least-once delivery를 보장하므로 동일 이벤트가 재전송될 수 있다. EventHandledJpaRepository.existsById("event-1") 검증으로 보아 idempotency tracking은 존재하지만, 중복 처리 시 두 번째 process() 호출의 반환값과 중복 발급 방지가 테스트되지 않았다. 운영 중 재전송 시 중복 발급이 발생하면 쿠폰 수량 초과 위험이 있다.
또한 IssueRejectReason.ALREADY_ISSUED가 정의되어 있으나, 동일 사용자의 중복 요청 거부 시나리오가 테스트에 없다. 경계값/실패 케이스 점검 기준에 미달한다.
💚 제안: 멱등성 및 ALREADY_ISSUED 테스트 추가
+ `@DisplayName`("동일 이벤트를 재처리해도 중복 발급하지 않고 false를 반환한다.")
+ `@Test`
+ void doesNotReprocessDuplicateEvent() {
+ // arrange
+ CouponJpaEntity coupon = couponJpaRepository.save(
+ CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 10L));
+ couponIssueRequestJpaRepository.save(
+ CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now()));
+
+ // act
+ processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234"));
+ boolean result = processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234"));
+
+ // assert
+ assertThat(result).isFalse();
+ assertThat(issuedCouponJpaRepository.count()).isEqualTo(1);
+ }
+
+ `@DisplayName`("동일 사용자의 중복 요청은 ALREADY_ISSUED로 거부된다.")
+ `@Test`
+ void rejectsDuplicateUserWithAlreadyIssued() {
+ // arrange
+ CouponJpaEntity coupon = couponJpaRepository.save(
+ CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 10L));
+ couponIssueRequestJpaRepository.save(
+ CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now()));
+ couponIssueRequestJpaRepository.save(
+ CouponIssueRequestJpaEntity.request("request-2", coupon.getId(), "user1234", ZonedDateTime.now()));
+ processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234"));
+
+ // act
+ boolean processed = processor.process(issueRequested("event-2", "request-2", coupon.getId(), "user1234"));
+
+ // assert
+ var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-2")
+ .orElseThrow().toRecord();
+ assertAll(
+ () -> assertThat(processed).isTrue(),
+ () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.REJECTED),
+ () -> assertThat(request.getRejectReason()).isEqualTo(IssueRejectReason.ALREADY_ISSUED)
+ );
+ }📝 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.
| @DisplayName("선착순 쿠폰 요청을 처리하면 발급 쿠폰을 만들고 요청 상태를 ISSUED로 확정한다.") | |
| @Test | |
| void issuesCouponAndMarksRequestIssued_whenStockRemains() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| // act | |
| boolean processed = processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // assert | |
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-1").orElseThrow().toRecord(); | |
| assertAll( | |
| () -> assertThat(processed).isTrue(), | |
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.ISSUED), | |
| () -> assertThat(request.getIssuedCouponId()).isNotNull(), | |
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user1234")).isPresent(), | |
| () -> assertThat(eventHandledJpaRepository.existsById("event-1")).isTrue() | |
| ); | |
| } | |
| @DisplayName("발급 한도가 소진되면 요청 상태를 REJECTED/SOLD_OUT으로 확정한다.") | |
| @Test | |
| void rejectsRequest_whenCouponIsSoldOut() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-2", coupon.getId(), "user5678", ZonedDateTime.now())); | |
| processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // act | |
| boolean processed = processor.process(issueRequested("event-2", "request-2", coupon.getId(), "user5678")); | |
| // assert | |
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-2").orElseThrow().toRecord(); | |
| assertAll( | |
| () -> assertThat(processed).isTrue(), | |
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.REJECTED), | |
| () -> assertThat(request.getRejectReason()).isEqualTo(IssueRejectReason.SOLD_OUT), | |
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user5678")).isEmpty() | |
| ); | |
| } | |
| `@DisplayName`("선착순 쿠폰 요청을 처리하면 발급 쿠폰을 만들고 요청 상태를 ISSUED로 확정한다.") | |
| `@Test` | |
| void issuesCouponAndMarksRequestIssued_whenStockRemains() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| // act | |
| boolean processed = processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // assert | |
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-1").orElseThrow().toRecord(); | |
| assertAll( | |
| () -> assertThat(processed).isTrue(), | |
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.ISSUED), | |
| () -> assertThat(request.getIssuedCouponId()).isNotNull(), | |
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user1234")).isPresent(), | |
| () -> assertThat(eventHandledJpaRepository.existsById("event-1")).isTrue() | |
| ); | |
| } | |
| `@DisplayName`("발급 한도가 소진되면 요청 상태를 REJECTED/SOLD_OUT으로 확정한다.") | |
| `@Test` | |
| void rejectsRequest_whenCouponIsSoldOut() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save(CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 1L)); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| couponIssueRequestJpaRepository.save(CouponIssueRequestJpaEntity.request("request-2", coupon.getId(), "user5678", ZonedDateTime.now())); | |
| processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // act | |
| boolean processed = processor.process(issueRequested("event-2", "request-2", coupon.getId(), "user5678")); | |
| // assert | |
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-2").orElseThrow().toRecord(); | |
| assertAll( | |
| () -> assertThat(processed).isTrue(), | |
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.REJECTED), | |
| () -> assertThat(request.getRejectReason()).isEqualTo(IssueRejectReason.SOLD_OUT), | |
| () -> assertThat(issuedCouponJpaRepository.findByCouponIdAndUserLoginIdAndDeletedAtIsNull(coupon.getId(), "user5678")).isEmpty() | |
| ); | |
| } | |
| `@DisplayName`("동일 이벤트를 재처리해도 중복 발급하지 않고 false를 반환한다.") | |
| `@Test` | |
| void doesNotReprocessDuplicateEvent() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save( | |
| CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 10L)); | |
| couponIssueRequestJpaRepository.save( | |
| CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| // act | |
| processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| boolean result = processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // assert | |
| assertThat(result).isFalse(); | |
| assertThat(issuedCouponJpaRepository.count()).isEqualTo(1); | |
| } | |
| `@DisplayName`("동일 사용자의 중복 요청은 ALREADY_ISSUED로 거부된다.") | |
| `@Test` | |
| void rejectsDuplicateUserWithAlreadyIssued() { | |
| // arrange | |
| CouponJpaEntity coupon = couponJpaRepository.save( | |
| CouponJpaEntity.firstCome("선착순", ZonedDateTime.now().plusDays(7), 10L)); | |
| couponIssueRequestJpaRepository.save( | |
| CouponIssueRequestJpaEntity.request("request-1", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| couponIssueRequestJpaRepository.save( | |
| CouponIssueRequestJpaEntity.request("request-2", coupon.getId(), "user1234", ZonedDateTime.now())); | |
| processor.process(issueRequested("event-1", "request-1", coupon.getId(), "user1234")); | |
| // act | |
| boolean processed = processor.process(issueRequested("event-2", "request-2", coupon.getId(), "user1234")); | |
| // assert | |
| var request = couponIssueRequestJpaRepository.findByRequestIdAndDeletedAtIsNull("request-2") | |
| .orElseThrow().toRecord(); | |
| assertAll( | |
| () -> assertThat(processed).isTrue(), | |
| () -> assertThat(request.getStatus()).isEqualTo(IssueRequestStatus.REJECTED), | |
| () -> assertThat(request.getRejectReason()).isEqualTo(IssueRejectReason.ALREADY_ISSUED) | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-streamer/src/test/java/com/loopers/application/coupon/FirstComeCouponIssueProcessorIntegrationTest.java`
around lines 59 - 100, FirstComeCouponIssueProcessorIntegrationTest에 동일 이벤트를 두 번
process하는 멱등성 테스트를 추가하고, 두 번째 호출의 반환값과 발급 쿠폰이 하나뿐인지, 요청 상태 및 eventHandled 기록이
유지되는지 검증하라. 또한 동일 사용자가 같은 쿠폰을 다시 요청하는 테스트를 추가해 두 번째 처리 결과가 REJECTED이고
rejectReason이 IssueRejectReason.ALREADY_ISSUED이며 중복 발급이 생성되지 않는지 확인하라. 기존 테스트의
processor.process와 issuedCouponJpaRepository 검증 패턴을 활용하라.
📌 Summary
💬 리뷰 포인트
🧭 Context & Decision
문제 정의
POST /api/v1/queue/enter로 대기열에 진입하고GET /api/v1/queue/position으로 순번·대기 수·예상 대기 시간·입장 token을 조회합니다.ZPOPMIN으로 꺼내 5분 TTL token을 발급합니다.X-Entry-Token을 claim한 요청만 실행하며, 성공 시 token과 claim을 삭제하고 실패 시 claim을 해제합니다.admit-batch-size=18은 Tomcat 최대 worker 200개에서 여유 10%를 제외해 정한 초기값이며, 운영 SLA는 아직 확정되지 않았습니다.ZPOPMIN과 사용자별 tokenSET이 분리되어 있어 중간 장애 시 사용자 유실 가능성이 있고, 여러 API 인스턴스가 scheduler를 각각 실행하면 전체 admission rate가 인스턴스 수에 따라 증가할 수 있습니다.선택지와 결정
SET NXclaim합니다. Redis 상태가 늘지만 check-then-act 경쟁 조건을 제거할 수 있습니다.INCRsequence를 score로 사용하고, token claim·complete·release를 각각 Lua script로 처리했습니다.batch=5,delay=100ms는 지연시간 안정성,batch=10,delay=100ms는 처리량 우선 후보로 남겼습니다.45.1 TPS와 p95 약 206224ms, batch 10은 약 54.7~55.2 TPS와 p95 약 420ms를 보였습니다.SET전 장애를 주입해 사용자 유실을 재현하고, pop-token 단일 Lua 또는 reservation ZSET·복구 worker 중 하나를 선택합니다.10/20/40과 shared-product/unique-product workload를 분리해 DB pool과 hot-row lock 병목을 비교합니다.🤔 고민한 점 / 막혔던 부분
batch=18,delay=100ms는 이론상 180명/s를 입장시키지만 baseline에서 batch 10보다 successful TPS가 낮고 p95가 높았습니다. P0 이후 pool sweep에서도 batch 18은 pool 10·20·40에 걸쳐 일관된 이점을 보이지 않았습니다.