[volume-7] 이벤트 기반 아키텍처 구현#327
Conversation
- PaymentFacade에서 PG 직접 호출 제거, PaymentRequestedEvent 발행으로 교체 - PaymentEventHandler가 AFTER_COMMIT 후 비동기로 PG 호출 처리 - docker-java.properties 추가로 Testcontainers Docker API 버전 호환성 해결
- LikeService에서 직접 likeCount 업데이트 제거, LikedEvent/UnlikedEvent 발행으로 교체 - LikeEventHandler가 AFTER_COMMIT 후 비동기로 likeCount 증가/감소 처리
- UserActionLoggingHandler 추가 (ProductViewedEvent, OrderCreatedEvent 수신 후 Slf4j 로깅) - ProductController 상품 조회 시 ProductViewedEvent 발행 - OrderFacade 주문 생성 시 OrderCreatedEvent 발행
- OutboxModel/Status/Repository: outbox 테이블 도메인 레이어 - OutboxEventPublisher: Spring 이벤트를 outbox에 저장 (BEFORE_COMMIT) - OutboxRelayScheduler: 1초마다 PENDING outbox를 Kafka로 relay - OrderCreatedEvent에 OrderItemInfo 목록 추가 (per-product sales_count 집계용) - AsyncConfig에 @EnableScheduling 추가
- OrderCreatedEvent에 items 파라미터가 추가되면서 기존 테스트 컴파일 에러 발생 - List.of()를 4번째 인자로 추가해 수정
- KafkaConfig에 STRING_LISTENER factory 추가 (String payload 수신용) - ProductMetricsModel: 상품별 like/view/sales count 집계 테이블 - EventHandledModel: 중복 메시지 방지용 처리 이력 테이블 - CatalogEventsConsumer: LikedEvent/UnlikedEvent/ProductViewedEvent 수신 후 product_metrics 업데이트 - OrderEventsConsumer: OrderCreatedEvent 수신 후 상품별 sales_count 누적
- 앱 시작 시 catalog-events, order-events 토픽 자동 생성 - auto.create.topics.enable=false 환경에서도 동작하도록 NewTopic 빈 등록
- CouponTemplateModel에 totalCount 추가 (null=기간, 값=선착순) - 쿠폰 발급을 Kafka 비동기 처리로 전환 (202 Accepted) - 선착순: key=templateId → 단일 파티션 순차 처리로 동시성 제어 - 기간쿠폰: key=null → 라운드로빈으로 여러 파티션 병렬 처리 - coupon-issue-requests 파티션 3개로 설정
- OutboxRelayScheduler가 발행 시 payload에 outboxId 포함 - Consumer의 event_handled 키를 partition-offset → outboxId로 변경 - Outbox 재발행 시 다른 offset으로 와도 중복 처리 방지
📝 Walkthrough변경 목적: 좋아요/조회/주문/결제/쿠폰 발급의 핵심 흐름을 이벤트 기반으로 분리하고, Kafka + Outbox로 비동기 처리와 재처리 안정성을 높이는 것입니다. WalkthroughKafka 아웃박스 패턴과 도메인 이벤트(좋아요, 주문, 결제, 상품조회, 쿠폰발급) 발행/구독 구조를 도입한다. CouponController와 PaymentFacade는 동기 처리 대신 이벤트/큐 기반 비동기 처리로 전환되며, commerce-streamer 모듈에 이벤트 소비 후 상품 메트릭(좋아요/조회/판매 수)을 집계하는 컨슈머가 추가된다. Changes비동기 이벤트/아웃박스 파이프라인
Sequence Diagram(s)sequenceDiagram
participant CouponController
participant KafkaTemplate
participant CouponIssueConsumer
participant UserCouponRepository
CouponController->>CouponController: validateTemplate(templateId)
CouponController->>KafkaTemplate: send(coupon-issue-requests, event)
KafkaTemplate-->>CouponIssueConsumer: consume(record)
CouponIssueConsumer->>UserCouponRepository: countByTemplateId / existsByMemberIdAndTemplateId
CouponIssueConsumer->>UserCouponRepository: save(UserCouponModel)
sequenceDiagram
participant PaymentFacade
participant PaymentEventHandler
participant PgClient
participant OrderService
PaymentFacade->>PaymentEventHandler: publishEvent(PaymentRequestedEvent)
PaymentEventHandler->>PgClient: createTransaction(...)
alt PG 500 오류
PaymentEventHandler->>OrderService: cancelBySystem(orderId)
end
sequenceDiagram
participant OutboxEventPublisher
participant OutboxRelayScheduler
participant KafkaTemplate
participant CatalogEventsConsumer
OutboxEventPublisher->>OutboxRelayScheduler: save(OutboxModel)
OutboxRelayScheduler->>KafkaTemplate: send(topic, payload)
KafkaTemplate-->>CatalogEventsConsumer: consume(record)
CatalogEventsConsumer->>CatalogEventsConsumer: update ProductMetricsModel
참고 사항(운영 관점): 관련 이슈: 명시된 이슈 번호 없음 🐰 (풀숲에서 조용히 코드를 살피며) 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java (1)
49-62: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win비동기 후속 처리를 분리해
requestPayment테스트를 결정적으로 바꿔야 한다다.PaymentFacade.requestPayment()는 커밋 후PaymentEventHandler의@Async경로를 타므로, 지금처럼 즉시PENDING/null만 단정하면 뒤늦은 PG 처리로 상태가 바뀌어 CI가 흔들린다다. 이 테스트에서는ApplicationEventPublisher를 목으로 바꿔 결제 생성만 검증하고, PG 호출과 실패 복구는PaymentEventHandlerTest에서 별도로 검증해야 한다다. 추가로 요청 직후 상태와 비동기 후속 처리 완료 후 상태를 분리한 테스트가 필요하다다.🤖 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/payment/PaymentFacadeTest.java` around lines 49 - 62, `PaymentFacadeTest.requestPayment_createsPendingPayment` is currently asserting the asynchronous post-commit `PaymentEventHandler` side effects, which makes the test nondeterministic. Update the `requestPayment` test to mock `ApplicationEventPublisher` and verify only that `PaymentFacade.requestPayment()` creates the payment and publishes the event, without asserting the later PG-driven state changes. Move the async PG success/failure behavior into `PaymentEventHandlerTest`, using `PaymentEventHandler` and its `@Async` flow to verify those outcomes separately.Source: Path instructions
apps/commerce-api/src/test/java/com/loopers/interfaces/api/PaymentApiE2ETest.java (1)
81-99: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win비동기 후속 처리를 기다리도록 E2E를 안정화하라
PaymentEventHandler는@Async@TransactionalEventListener(AFTER_COMMIT)라서, 이 테스트가 201/PENDING만 확인하고 끝나면 PG 후속 처리와truncateAllTables()가 겹쳐 다음 테스트까지 상태가 새는 flaky가 생긴다.
pgClient.createTransaction()을 명시적으로 스텁하고, handler 완료 후 payment/order의 최종 상태까지 기다려 검증하라.
성공/실패 경로를 나눈 E2E 테스트도 추가하라.🤖 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/interfaces/api/PaymentApiE2ETest.java` around lines 81 - 99, The PaymentApiE2ETest payment-creation case only asserts the immediate 201/PENDING response, but it does not wait for the async post-commit flow in PaymentEventHandler. Stub pgClient.createTransaction() in the test setup, then wait for the handler-driven update to finish and assert the final payment/order state as well, so truncateAllTables() cannot race with background processing. Add separate E2E coverage for the success and failure paths, using the existing PaymentApiE2ETest and PaymentEventHandler flow to locate the behavior.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java (1)
64-76: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift좋아요 집계 갱신을 재시도·보정 가능하게 바꿔야 한다.
OutboxEventPublisher가 외부 전파는 보장하지만,LikeEventHandler의@Async집계 갱신은 별도 경로라 예외가 나도 Like 트랜잭션은 이미 커밋된 뒤이고product_like_view만 stale해져 상세 조회와 정렬의 likeCount가 실제 Like 데이터와 어긋난다. 행이 없을 때는 upsert하고, 실패 시 재시도 또는 주기적 재계산으로 복구해야 한다. 현재 테스트는AopTestUtils로@Async프록시를 우회하므로, 실제 빈으로 이벤트를 발행했을 때 like_count가 갱신되고 실패 시에도 보정되는 통합 테스트를 추가해야 한다.🤖 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/LikeService.java` around lines 64 - 76, 좋아요 집계 갱신이 비동기 처리와 별도 경로라 실패 시 `product_like_view`가 실제 Like 데이터와 어긋나는 문제를 해결해야 합니다. `LikeService.incrementLikeCount`와 `decrementLikeCount` 기준으로 조회 후 없으면 예외를 던지는 대신 upsert/생성으로 보정 가능하게 바꾸고, `LikeEventHandler`의 `@Async` 갱신 실패는 재시도 또는 주기적 재계산으로 복구되도록 연결하세요. 또한 `AopTestUtils`로 프록시를 우회하는 기존 테스트 대신 실제 빈을 통해 이벤트를 발행했을 때 집계가 반영되고 실패 후에도 보정되는 통합 테스트를 추가하세요.Source: Path instructions
🧹 Nitpick comments (19)
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxModel.java (2)
52-55: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift영구 실패(Poison Message)에 대한 상태/재시도 추적이 없다.
OutboxStatus가 PENDING/PUBLISHED만 가지고 있어, Kafka 발행이 계속 실패하는 레코드는 무한정 PENDING 상태로 재시도만 반복된다. 재시도 횟수·최종 실패 시각을 남길 필드와FAILED(또는 DLQ 이관) 상태가 없으면 장애 감지·알림이 불가능하다. 운영 관점에서 최소한retryCount필드와 임계치 초과 시FAILED전이를 추가하는 것을 권장한다.🤖 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/outbox/OutboxModel.java` around lines 52 - 55, `OutboxModel.markPublished()` and the `OutboxStatus` workflow only support PENDING/PUBLISHED, so failed Kafka publishes can retry forever without traceability. Update `OutboxModel` to track retry metadata such as `retryCount` and a final failure timestamp, extend `OutboxStatus` with a `FAILED` (or DLQ-related) state, and change the publishing/retry logic to increment the count on each failure and transition to `FAILED` once the threshold is exceeded.
41-50: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
create()에 입력값 검증이 없다.
topic,partitionKey,eventType,payload가 null/blank인 채로 생성되면 DB 제약조건 위반 시점까지 오류가 지연된다. 아웃박스는 발행 신뢰성이 핵심인 컴포넌트이므로, 생성 시점에 즉시 실패하도록 검증하는 편이 운영 관점에서 원인 파악에 유리하다.🛡️ 제안 diff
public static OutboxModel create(String topic, String partitionKey, String eventType, String payload) { + if (topic == null || topic.isBlank() || eventType == null || eventType.isBlank() || payload == null) { + throw new IllegalArgumentException("topic, eventType, payload는 필수값이다."); + } OutboxModel outbox = new OutboxModel();As per path instructions, "null 처리, 방어적 복사, 불변성 ... 점검한다"는 기준에 따른 지적이다.
🤖 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/outbox/OutboxModel.java` around lines 41 - 50, `OutboxModel.create()` is allowing invalid outbox data to be constructed, so add upfront validation for `topic`, `partitionKey`, `eventType`, and `payload` before assigning fields. In `create()`, fail fast when any required value is null or blank, and keep the existing initialization of `status` and `createdAt` unchanged so the factory method only returns a valid `OutboxModel`.Source: Path instructions
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
16-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win메서드명이 락 획득 동작을 드러내지 않는다.
findByProductId라는 이름과 달리 실제로는 항상PESSIMISTIC_WRITE락을 거는findByProductIdForUpdate를 호출한다. 현재는 갱신 목적의 컨슈머에서만 쓰이지만, 향후 조회 전용 용도로 이 메서드가 재사용되면 불필요한 락 경합을 유발할 수 있다. 인터페이스 메서드명을findByProductIdForUpdate로 명시하거나, 락이 필요 없는 조회용 메서드를 별도로 제공하는 편이 안전하다.🤖 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/metrics/ProductMetricsRepositoryImpl.java` around lines 16 - 19, `ProductMetricsRepositoryImpl.findByProductId` is misleading because it always delegates to `findByProductIdForUpdate`, which acquires a pessimistic write lock. Rename the repository method to `findByProductIdForUpdate` to reflect the locking behavior, or add a separate non-locking lookup method and keep the current one explicitly for update use. Make sure any interface and callers are updated to use the new, clearly named method.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java (1)
12-13: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winOutboxJpaRepository.java:12-13 — 복합 인덱스와 Pageable 조회로 바꾸는 편이 낫다
status만 필터링하고createdAt으로 정렬하므로, PENDING 레코드가 누적되면 배치 조회가 정렬 비용에 묶인다.outbox(status, created_at)복합 인덱스를 추가하고, 쿼리는Pageable기반으로 바꿔 DB 방언 의존성을 줄이는 편이 낫다.
PENDING/PUBLISHED가 섞인 데이터에서 오래된 PENDING만 제한 개수만큼 반환하는 통합 테스트를 추가해야 한다.🤖 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/OutboxJpaRepository.java` around lines 12 - 13, Update OutboxJpaRepository.findByStatusOrderByCreatedAtAsc to use Pageable instead of a LIMIT clause in the JPQL query, so the repository stays database-dialect agnostic. Add or ensure a composite index on OutboxModel for status and createdAt to support the status filter plus ordering. Also add an integration test around OutboxJpaRepository/OutboxModel that verifies only the oldest PENDING rows are returned when PENDING and PUBLISHED records are mixed.Source: Path instructions
apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxEventPublisherTest.java (1)
37-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win실패/롤백 케이스 테스트가 없다.
현재 테스트는 4가지 이벤트에 대한 happy-path 저장만 검증한다. Outbox 패턴의 핵심 보장은 "도메인 트랜잭션과 outbox 기록의 원자성"인데, 트랜잭션이 롤백될 때 outbox 레코드도 함께 롤백되는지 확인하는 테스트가 없다. 이 부분이 깨지면 도메인 상태와 outbox 상태가 불일치해 데이터 정합성 문제로 이어질 수 있다.
@Transactional테스트 컨텍스트에서 도메인 로직이 예외로 실패하는 시나리오를 구성해 outbox 레코드가 저장되지 않음을 검증하는 테스트를 추가해야 한다.As per path instructions, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."
🤖 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/interfaces/event/outbox/OutboxEventPublisherTest.java` around lines 37 - 105, 현재 OutboxEventPublisherTest의 Handle 네스트에는 성공 저장만 있어 실패/롤백 시 outbox가 같이 취소되는지 검증이 빠져 있습니다. OutboxEventPublisher.handle 또는 이를 호출하는 `@Transactional` 테스트 흐름에서 예외를 발생시키는 케이스를 추가해, 트랜잭션이 롤백된 뒤 outboxJpaRepository.findAll()에 레코드가 남지 않는지 확인하는 테스트를 넣으세요. LikedEvent, UnlikedEvent, ProductViewedEvent, OrderCreatedEvent와 동일한 방식으로 AopTestUtils.getTargetObject(outboxEventPublisher)를 사용해 경로를 찾고, 실패 시 저장이 일어나지 않음을 assert 하세요.Source: Path instructions
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventsConsumerTest.java (1)
15-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
consume()엔트리포인트가 테스트되지 않아 outboxId 추출/ack 관련 회귀를 잡아내지 못한다.모든 테스트가
orderEventsConsumer.process(eventId, payload)를 직접 호출해 내부 로직만 검증하고, 실제@KafkaListener진입점인consume()(outboxId 추출, ack 처리, 예외 로깅)은 전혀 실행되지 않는다.CatalogEventsConsumer에서 발견된 것과 유사하게OrderEventsConsumer의consume()에도 예외 처리 결함이 있을 경우, 현재 테스트 스위트로는 이를 검출할 수 없다. 또한 malformed payload/알 수 없는 eventType 등 실패 케이스 테스트도 없다.
ack를 mock으로 주입해consume()을 직접 호출하는 테스트를 추가하고,outboxId누락/파싱 실패 시 ack 미호출 및 로그 기록을 검증해야 한다.As per path instructions, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."
🤖 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/interfaces/consumer/OrderEventsConsumerTest.java` around lines 15 - 107, 현재 테스트는 OrderEventsConsumer.process만 직접 호출해 `@KafkaListener` 엔트리포인트인 consume의 outboxId 추출, ack 처리, 예외 흐름을 검증하지 못합니다. OrderEventsConsumer.consume를 대상으로 Ack를 mock 주입해 직접 호출하는 테스트를 추가하고, 정상 메시지에서는 ack 호출과 process 위임을, malformed payload나 outboxId 누락/알 수 없는 eventType에서는 ack 미호출 및 로그 처리를 검증하세요. process 중심 기존 테스트는 유지하되 consume 경로를 커버하도록 보강하세요.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java (1)
11-25: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick wincatalog-events/order-events 파티션이 1개라 파티션 키 설계가 무의미해진다.
OutboxEventPublisher는 productId/orderId를 partitionKey로 지정해 두었지만(예:event.productId().toString()), 실제 토픽 파티션 수가 1개면 모든 메시지가 단일 파티션·단일 컨슈머 스레드로 순차 처리된다. 운영 관점에서, 이 PR의 목적이 동기 처리 병목을 비동기·이벤트 기반으로 분산시키는 것인데 파티션 1개 구성은 catalog-events(좋아요/조회) 트래픽 증가 시 컨슈머 처리량 병목을 그대로 남겨둔다.파티션 수를 늘리고(예: 3~6), 현재처럼 productId 기반 파티션 키를 유지해 동일 상품에 대한 이벤트 순서는 보존하면서 상품 간 병렬 처리를 확보해야 한다.
추가 테스트: 파티션 증가 후에도 동일 productId 이벤트들이 항상 같은 파티션으로 라우팅되어 순서가 보장되는지 검증하는 테스트가 필요하다.
🤖 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/config/KafkaTopicConfig.java` around lines 11 - 25, Update the Kafka topic setup in KafkaTopicConfig so catalog-events and order-events are created with multiple partitions instead of 1, and keep the existing productId/orderId partition key behavior in OutboxEventPublisher to preserve per-entity ordering while enabling parallel consumption. Locate the topic definitions in catalogEventsTopic and orderEventsTopic, adjust the partition count to a higher value, and add a test that verifies messages with the same productId land on the same partition even after increasing partitions.apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.java (1)
65-80: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff영구 실패(포이즌 메시지) 시나리오에 대한 테스트가 없다.
OutboxStatus는 PENDING/PUBLISHED만 존재하므로, Kafka 발행이 계속 실패하는 레코드는 영구히 PENDING 상태로 남아 스케줄러가 매 주기 재시도한다. 만약findPendingBatch가 오래된 순으로 조회한다면 손상된 페이로드 하나가 배치를 계속 점유해 신규 레코드의 발행이 지연되는(starvation) 운영 리스크가 있다. 현재 테스트는 단발성 실패만 검증하며, 반복 실패 시의 동작(재시도 횟수 제한, DLQ 이동 등)에 대한 케이스가 없다.이 부분은
OutboxStatus/OutboxRelayScheduler구현(다른 파일)에 재시도 상한이나 FAILED 상태가 없다는 설계적 한계에서 기인한다. 재시도 정책 추가와 함께 관련 테스트 보강을 제안한다.🤖 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/interfaces/event/outbox/OutboxRelaySchedulerTest.java` around lines 65 - 80, The current OutboxRelaySchedulerTest only covers a single Kafka send failure, but it does not verify poison-message handling or repeated retry behavior. Update the outbox relay flow around OutboxRelayScheduler and OutboxStatus so there is an explicit policy for repeated failures, such as a retry limit with a FAILED/DLQ outcome instead of leaving records PENDING forever. Add tests in OutboxRelaySchedulerTest that simulate multiple consecutive failures and assert the record transitions or remediation path after the retry threshold is exceeded.Source: Path instructions
apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledModel.java (1)
11-31: 🚀 Performance & Scalability | 🔵 Trivialevent_handled 테이블 보관 정책이 없다.
멱등성 체크용
event_handled레코드가 삭제되는 로직이 어디에도 보이지 않는다. 이벤트 유입량이 많을수록 테이블이 무한정 커져existsById조회 성능과 스토리지 비용에 영향을 줄 수 있다.handledAt컬럼을 활용해 일정 기간 경과 레코드를 배치로 정리하는 보존 정책(TTL) 또는 파티셔닝 전략을 운영 단계에서 고려할 것을 제안한다.🤖 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/eventhandled/EventHandledModel.java` around lines 11 - 31, `EventHandledModel`에는 멱등성 체크용 레코드의 보관/정리 정책이 없어서 `event_handled`가 계속 누적될 수 있습니다. `handledAt`를 기준으로 오래된 레코드를 주기적으로 삭제하는 배치 정리(TTL) 또는 파티셔닝/아카이빙 전략을 추가하도록 안내하고, `EventHandledModel.of`와 `event_handled` 사용 흐름을 기준으로 운영 측에서 유지 정책이 적용되도록 조치하세요.modules/kafka/src/main/resources/kafka.yml (1)
17-20: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
retries: 3은 멱등 프로듀서(enable.idempotence=true) 설정과 함께 사용하기엔 다소 낮다.일시적 브로커 오류(리더 변경, 네트워크 지연 등) 시 3회 이내 재시도가 소진되면 아웃박스 릴레이가 실패로 처리되어(
OutboxRelayScheduler의kafkaTemplate.send(...).get()동기 호출과 결합 시) 불필요한 아웃박스 재시도/알림이 발생할 수 있다.retries를Integer.MAX_VALUE또는 미설정(멱등 프로듀서 기본값 사용)으로 변경하고,delivery.timeout.ms로 전체 타임아웃을 관리하는 방식을 검토할 것을 제안한다.🤖 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 `@modules/kafka/src/main/resources/kafka.yml` around lines 17 - 20, The Kafka producer config in kafka.yml uses a low fixed retry count alongside enable.idempotence in the producer properties, which can cause avoidable send failures during transient broker issues. Update the producer settings under the kafka configuration to remove the hardcoded retries: 3 or raise it to a much higher value consistent with idempotent producer behavior, and rely on delivery.timeout.ms for overall send timeout control. Keep the existing acks: all and enable.idempotence settings aligned with the producer setup used by OutboxRelayScheduler and its kafkaTemplate.send(...).get() flow.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java (1)
42-63: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valuepayload를 두 번 파싱한다(
extractOutboxId,process각각readTree호출).기능상 문제는 없으나 불필요한 JSON 파싱 비용이 발생한다.
consume()에서 한 번만 파싱한JsonNode를process에 전달하는 방식으로 개선할 수 있다.Also applies to: 65-71
🤖 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/OrderEventsConsumer.java` around lines 42 - 63, `OrderEventsConsumer.process` is parsing the same payload again even though `consume()` already parses it via `extractOutboxId`, causing unnecessary JSON overhead. Refactor the flow so `consume()` parses the payload once with `objectMapper.readTree` and passes the resulting `JsonNode` into `process`, then update `process` to use the provided node instead of calling `readTree` itself; keep the existing `eventHandledRepository` and `productMetricsRepository` logic unchanged.apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java (1)
23-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCouponFacade의 발급 검증을 공통화하라
issue()와validateTemplate()가 같은canIssue()검사와 예외 처리를 반복한다. 수정 누락이 나면 발급 API와 템플릿 검증 경로의 정책이 서로 달라질 수 있으므로, 공통 private 메서드로 빼서 한 곳에서만 관리하라. 추가로 두 메서드에 대해 발급 가능/불가 케이스를 각각 커버하는 테스트를 넣어 회귀를 막아라.🤖 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/coupon/CouponFacade.java` around lines 23 - 37, CouponFacade.issue and CouponFacade.validateTemplate duplicate the same canIssue validation and CoreException handling, so extract that logic into a shared private helper in CouponFacade and have both public methods call it to keep the issuance policy in one place. Use the existing couponTemplateService.getById, template.canIssue, and CoreException/ErrorType.BAD_REQUEST flow as the common path. Add tests covering both issue() and validateTemplate() for issuable and non-issuable template cases to ensure the shared validation stays consistent.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/interfaces/event/UserActionLoggingHandler.java (1)
20-24: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
OrderCreatedEvent리스너는 커밋 이후 실행을 보장하지 않는다.
OrderFacade.createOrder는@Transactional메서드이며 그 안에서eventPublisher.publishEvent(OrderCreatedEvent.from(...))를 호출한다. 이 메서드는 순수@EventListener+@Async조합이므로, 이벤트 디스패치는 발행 시점에 즉시 별도 스레드로 넘어가며 원본 트랜잭션의 커밋 여부와 무관하게 실행된다. 즉 주문 생성 트랜잭션이 커밋 시점에 예기치 않게 실패하더라도 "주문 생성됨" 로그가 이미 기록될 수 있어, 운영 관점에서 감사/분석 로그와 실제 DB 상태가 어긋날 수 있다.같은 코호트의
LikeEventHandler,PaymentEventHandler가 "트랜잭션 커밋 이후" 처리 패턴을 따르는 것과도 일관성이 떨어진다.@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 전환하고@Async는 유지하는 것을 제안한다. 전환 후에는 커밋 후에만 리스너가 호출되는지 검증하는 테스트(예:@Transactional테스트에서 rollback 시 로그가 남지 않는지)를 추가해야 한다.♻️ 제안 수정안
+import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; `@Async` - `@EventListener` + `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) public void handle(OrderCreatedEvent event) { log.info("[OrderCreated] memberId={}, orderId={}, totalPrice={}", event.memberId(), event.orderId(), event.totalPrice()); }🤖 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/interfaces/event/UserActionLoggingHandler.java` around lines 20 - 24, `UserActionLoggingHandler.handle(OrderCreatedEvent)` is using plain `@EventListener` with `@Async`, so it can run before the surrounding `OrderFacade.createOrder` transaction commits. Change this listener to `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` while keeping `@Async`, matching the existing `LikeEventHandler` and `PaymentEventHandler` pattern. Add a test around `OrderCreatedEvent` delivery to verify the handler only runs after commit and does not run when the transaction rolls back.apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponApiE2ETest.java (1)
89-94: 🗄️ Data Integrity & Integration | 🔵 Trivial202 응답에 상관관계 식별자가 없다 - 폴링 전략 확인이 필요하다.
ApiResponse<Void>로 응답 바디가 비게 되면서, 클라이언트는 이번 발급 요청과 이후GET /api/v1/users/me/coupons폴링 결과를 연관지을 방법이 templateId 매칭 외에는 없다. 동시에 여러 템플릿을 발급 요청한 경우 구분이 모호해질 수 있다.CouponController가 이번 리뷰 범위에 없어 실제 구현을 확인할 수 없으므로, 요청 추적용 식별자(outboxId 등)를 응답에 포함하는 방안을 검토해야 한다.🤖 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/interfaces/api/CouponApiE2ETest.java` around lines 89 - 94, The 202 response for the coupon issuance flow has no correlation identifier, so the client cannot reliably match a request to the later polling result. Update the issuance API and its contract around CouponController/CouponApiE2ETest so the response includes a request-tracking ID (for example outboxId or similar) instead of only ApiResponse<Void>, and adjust the test to assert that identifier is returned alongside the accepted status.apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentRequestedEvent.java (1)
6-13: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win카드번호 원문 노출 위험이 있는 record toString().
PaymentRequestedEvent는 record이므로cardNo를 포함한 기본toString()이 자동 생성된다. 추후 디버그 로그나 예외 메시지에서 이벤트 객체를 그대로 출력하면 카드번호 원문이 로그에 남는다.toString()을 오버라이드해 마스킹 처리할 것을 제안한다.🔒 마스킹 toString 제안
public record PaymentRequestedEvent( Long paymentId, Long orderId, Long memberId, CardType cardType, String cardNo, Long amount ) { + `@Override` + public String toString() { + String masked = cardNo == null ? null : cardNo.replaceAll("\\d(?=\\d{4})", "*"); + return "PaymentRequestedEvent[paymentId=%s, orderId=%s, memberId=%s, cardType=%s, cardNo=%s, amount=%s]" + .formatted(paymentId, orderId, memberId, cardType, masked, amount); + } public static PaymentRequestedEvent of(PaymentModel payment, Long memberId) {As per path instructions, "로깅 시 민감정보 노출 가능성을 점검한다".
🤖 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/interfaces/event/payment/PaymentRequestedEvent.java` around lines 6 - 13, PaymentRequestedEvent is a record, so its generated toString() currently exposes cardNo and can leak raw card data in logs or exceptions. Override toString() in PaymentRequestedEvent to mask the cardNo field before rendering the event, while keeping the other fields readable for debugging.Source: Path instructions
apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.java (1)
58-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
PaymentFacade.recoverAsFailure와 로직 중복.
PaymentEventHandler.recoverAsFailure(orderId)가 실패 사유를 하드코딩한 채PaymentFacade.recoverAsFailure(orderId, reason)와 동일한 취소 흐름(결제 실패 처리 후 주문 상태 확인 및 취소)을 별도로 재구현한다. 두 곳 중 한쪽만 수정되면 취소 로직이 어긋날 위험이 있으므로PaymentFacade를 재사용하도록 통합할 것을 제안한다.♻️ 중복 제거 제안
- private final PaymentService paymentService; - private final OrderService orderService; - private final PgClientWrapper pgClient; + private final PaymentService paymentService; + private final PaymentFacade paymentFacade; + private final PgClientWrapper pgClient; ... - private void recoverAsFailure(Long orderId) { - paymentService.failByOrderId(orderId, "PG 처리 실패"); - OrderModel order = orderService.getById(orderId); - if (order.getStatus() != OrderStatus.CANCELLED) { - orderService.cancelBySystem(orderId); - } - } + private void recoverAsFailure(Long orderId) { + paymentFacade.recoverAsFailure(orderId, "PG 처리 실패"); + }🤖 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/interfaces/event/payment/PaymentEventHandler.java` around lines 58 - 64, `PaymentEventHandler.recoverAsFailure` duplicates the same failure-and-cancel flow already implemented in `PaymentFacade.recoverAsFailure(orderId, reason)`. Update `PaymentEventHandler` to delegate to `PaymentFacade` instead of directly calling `paymentService.failByOrderId`, `orderService.getById`, and `orderService.cancelBySystem`, and make sure the failure reason is passed through as a parameter rather than hardcoded. Use the existing `PaymentFacade.recoverAsFailure` method as the single source of truth for this recovery logic.apps/commerce-api/src/test/java/com/loopers/interfaces/event/payment/PaymentEventHandlerTest.java (1)
52-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win서킷 브레이커 오픈(
CallNotPermittedException) 분기 테스트 누락.
handle()은 타임아웃과CallNotPermittedException을 동일하게 취급해 "배치 복구 대기" 경로로 보내는데, 현재 테스트는 성공/타임아웃/500 에러만 검증하고 서킷 오픈 케이스는 다루지 않는다.AopTestUtils로 이미 동기 호출 패턴을 갖추고 있으니 추가 비용이 적다. 해당 예외를 던지는 케이스를 추가해 PENDING 유지 여부를 검증할 것을 제안한다.As per path instructions, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다".
🤖 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/interfaces/event/payment/PaymentEventHandlerTest.java` around lines 52 - 117, `PaymentEventHandlerTest.Handle`에 서킷 브레이커 오픈 상태인 `CallNotPermittedException` 분기 테스트가 빠져 있습니다. `handle()`가 타임아웃과 동일하게 이 예외를 PENDING 유지 경로로 처리하는지 확인할 수 있도록, `pgClient.createTransaction`이 `CallNotPermittedException`을 던지도록 설정한 테스트 케이스를 추가하세요. 기존의 `AopTestUtils.getTargetObject(paymentEventHandler).handle(event)` 호출 패턴을 그대로 사용해, 처리 후 `PaymentModel`의 상태가 `PENDING`으로 유지되고 `transactionKey`가 변경되지 않는지 검증하면 됩니다.Source: Path instructions
apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java (1)
46-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win이벤트 발행 여부에 대한 검증이 누락되었다.
eventPublisher를 mock으로 주입했지만LikedEvent/UnlikedEvent발행 여부를verify하지 않는다. Like 저장/삭제만 확인하고 있어, 이벤트 미발행 회귀가 발생해도 단위 테스트로는 감지되지 않는다.✅ 검증 추가 예시
// act likeService.like(memberId, product.getId()); // assert assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isTrue(); + Mockito.verify(eventPublisher).publishEvent(new LikedEvent(memberId, product.getId()));As per path instructions, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다"에 근거한 제안이다.
Also applies to: 54-66, 88-101
🤖 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/LikeServiceTest.java` around lines 46 - 48, `LikeServiceTest`에서 `eventPublisher`를 mock으로 주입하고도 `LikeService`의 저장/삭제 결과만 검증하고 있어 `LikedEvent`와 `UnlikedEvent` 발행 누락을 잡지 못합니다. `like()`, `unlike()`(또는 해당 테스트 메서드들)에서 `ApplicationEventPublisher`의 `publishEvent` 호출을 `verify`로 추가해, 각 동작 이후 올바른 이벤트가 발행되는지 확인하도록 테스트를 보강하세요.Source: Path instructions
apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java (1)
93-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win고정
Thread.sleep(3000)은 플래키 테스트 위험이 있다.비동기 핸들러 완료를 기다리기 위해 고정 sleep을 쓰면 부하가 큰 CI 환경에서 3초 내 처리가 끝나지 않을 경우 테스트가 간헐적으로 실패하고, 평소에는 불필요하게 3초씩 낭비한다. Awaitility 같은 폴링 기반 대기로 교체하는 것을 권장한다.
♻️ Awaitility로 교체 예시
- executor.shutdown(); - Thread.sleep(3000); // wait for async event handlers to complete - - // assert - ProductLikeViewModel result = productLikeViewJpaRepository.findById(savedProduct.getId()).orElseThrow(); - assertThat(result.getLikeCount()).isEqualTo(10); + executor.shutdown(); + + // assert + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + ProductLikeViewModel result = productLikeViewJpaRepository.findById(savedProduct.getId()).orElseThrow(); + assertThat(result.getLikeCount()).isEqualTo(10); + });As per path instructions, "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다"에 근거한 제안이다.
Also applies to: 132-132
🤖 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/interfaces/api/LikeConcurrencyE2ETest.java` at line 93, `LikeConcurrencyE2ETest`에서 고정 `Thread.sleep(3000)`으로 비동기 이벤트 처리를 기다리는 부분이 플래키 테스트와 불필요한 지연을 유발합니다. 해당 대기를 `Awaitility` 같은 폴링 기반 조건 대기로 바꾸고, `like`/이벤트 핸들러가 끝났음을 검증하는 상태를 기준으로 `await`하도록 수정하세요. 같은 패턴이 다른 위치에도 있으면 함께 교체해 `LikeConcurrencyE2ETest` 전체에서 고정 sleep을 제거하세요.Source: Path instructions
🤖 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/config/AsyncConfig.java`:
- Around line 16-25: `AsyncConfig.asyncExecutor()` is currently shared by
`PaymentEventHandler`, `LikeEventHandler`, and `UserActionLoggingHandler`, so
split it into separate executors for payment events and lightweight events.
Update the async/event handler wiring to use the appropriate executor and
configure each `ThreadPoolTaskExecutor` with an explicit
`RejectedExecutionHandler` so queue saturation behavior is deterministic. Add
integration tests covering executor isolation under load, verifying that payment
and like/log events do not block each other and that submission failure handling
still matches the expected response and follow-up flow.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java`:
- Around line 13-14: `UserCouponJpaRepository` exposes `countByTemplateId()`
which will be slow without a single-column `template_id` index, while
`existsByMemberIdAndTemplateId()` already relies on the existing composite
unique key. Add the `user_coupons(template_id)` index in the JPA
entity/migration that defines the table, and keep the repository method as-is;
then add an integration test around `countByTemplateId()` that loads a large
enough dataset and verifies the execution plan uses the new index.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java`:
- Around line 22-25: `OutboxRepositoryImpl.findPendingBatch` currently reads
PENDING rows without any locking, so concurrent scheduler runs can select the
same outbox twice. Update this path to use a claim/selection mechanism such as
`PESSIMISTIC_WRITE` or `FOR UPDATE SKIP LOCKED` in the
`outboxJpaRepository.findByStatusOrderByCreatedAtAsc` query so only one instance
can pick each record. Also add an integration test around `findPendingBatch`/the
scheduled relay flow that runs concurrent fetches and verifies the same outbox
is selected only once.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java`:
- Around line 20-37: The issue is that CouponController.issue currently owns
Kafka publishing concerns instead of just request handling. Move the
ObjectMapper serialization, partitionKey decision, and KafkaTemplate.send logic
out of CouponController into CouponFacade (or a service method it delegates to),
and have the controller only validate the request and return the response. Keep
the controller focused on validateTemplate(...) plus calling a facade method for
coupon issuance, and reference CouponIssueRequestEvent, KafkaTemplate, and
ObjectMapper only inside the facade/service layer.
- Around line 34-36: `CouponController`의 `JsonProcessingException` 처리에서 순수
`RuntimeException`을 던지지 말고 `CoreException`으로 변경해 `@ControllerAdvice`의 표준 에러 처리
흐름을 타도록 수정하세요. `createCoupon`(또는 해당 직렬화 예외를 잡는 메서드)에서 사용 중인 예외 래핑을
`CoreException`에 맞는 에러 코드/메시지로 바꾸고, 필요한 경우 `CoreException`용 `@ControllerAdvice`
매핑과 상태 코드·응답 바디 포맷이 일관된지 검증하는 테스트를 추가하세요.
- Around line 29-33: The coupon issuance flow in CouponController currently
fires kafkaTemplate.send() without handling delivery failure, so move this
request publishing to the same outbox/relay pattern used by other events or, at
minimum, handle the send result with explicit failure detection and
retry/compensation logic. Update the validateTemplate() path and the send logic
in CouponController to ensure a successful 202 is only returned when the request
is durably recorded or publishable, and add a test around
CouponController/couponFacade flow that verifies Kafka send failures do not drop
the request silently.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`:
- Around line 31-39: 현재 CouponIssueConsumer.consume()가 모든 예외를 잡아 로그만 남기고 끝내서
Spring Kafka의 재시도/에러 처리 흐름이 막혀 있습니다. consume()에서는 실패를 삼키지 말고 예외를 다시 던져 컨테이너가 실패로
인식하게 바꾸고, Kafka 리스너 설정 쪽에 DefaultErrorHandler와 DeadLetterPublishingRecoverer를
연결해 N회 재시도 후 DLQ로 보내도록 구성하세요. CouponIssueConsumer, consume(), 그리고 관련 Kafka
listener/container 설정을 찾아 정상 메시지 처리와 손상된 payload의 DLQ 이동을 검증하는 테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxRelayScheduler.java`:
- Around line 26-38: OutboxRelayScheduler.relay() blocks indefinitely on
kafkaTemplate.send(...).get(), so add an explicit timeout to the send wait and
keep the current failure path so unpublished outbox records remain pending for
retry. Update the relay loop in OutboxRelayScheduler to use a timed get on the
Kafka future, handle timeout the same as other send failures, and continue
processing subsequent OutboxModel items in the batch. Add a test around relay()
that mocks a delayed Kafka send and verifies it fails fast within the timeout
and moves on to the next pending record.
- Around line 27-29: The relay() flow in OutboxRelayScheduler currently reads
pending rows with a plain findPendingBatch(BATCH_SIZE), which can let multiple
scheduler instances pick the same PENDING OutboxModel. Update the outbox
acquisition path in OutboxJpaRepository to use a locking strategy such as
pessimistic locking with SKIP LOCKED, or serialize execution with `@SchedulerLock`
so only one relay runner claims a batch at a time. Keep the change centered
around relay() and the repository method used to fetch pending outbox rows. Add
a concurrency test proving that two relay() calls from separate scheduler
instances cannot publish the same outbox more than once.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.java`:
- Around line 36-55: The try/catch in PaymentEventHandler currently wraps both
pgClient.createTransaction and paymentService.assignTransactionKey, so DB update
failures are misreported as PG failures. Split these into separate steps with
distinct exception handling around createTransaction and assignTransactionKey,
and use different log messages for PG communication failure vs local
transaction-key persistence failure. Keep the original cause when rethrowing or
handling exceptions, and preserve the existing timeout/circuit-breaker path
while making the non-PG failure path call recoverAsFailure appropriately.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.java`:
- Around line 51-63: The Kafka send verification in OutboxRelaySchedulerTest is
asserting the wrong payload shape because withOutboxId(outbox) injects the
outboxId into the message. Update the relay() test to capture the third argument
passed to kafkaTemplate.send and assert that the payload includes the outboxId
along with the original data, using the existing OutboxRelayScheduler and
OutboxModel setup. Also add/adjust the failure-path test so it verifies
kafkaTemplate.send is not called when publishing fails, instead of hardcoding
the current payload string.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsModel.java`:
- Around line 29-30: In CatalogEventsConsumer and OrderEventsConsumer, the
MANUAL ack flow is swallowing failures in catch (Exception), so retryable errors
never reach the retry/DLQ path. Update the consumer handlers to rethrow
OptimisticLockException and any other transient failures instead of catching and
suppressing them, and configure DefaultErrorHandler or a retry-topic/backoff
policy so retries are bounded and eventually route to DLQ. Keep successful
acking only after processing completes, and add a test that verifies an
optimistic lock failure is retried and then sent to DLQ if it still fails.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java`:
- Around line 42-65: `CatalogEventsConsumer.process` has unsafe JSON field
access and no poison-pill handling for unknown event types. Update the payload
parsing to validate `eventType` and `productId` explicitly (using safe
lookup/clear checks) and throw a descriptive error instead of allowing NPEs from
`root.get(...)` or `data.get(...)`. For the `switch` in `process`, route unknown
or malformed events to a DLQ or a bounded retry path with ack-after-limit
behavior, and keep the existing save flow only for valid events. Add tests
covering missing `eventType`, missing `productId`, and unknown `eventType` cases
to verify deterministic handling.
- Around line 32-40: The `CatalogEventsConsumer.consume()` flow currently
extracts `outboxId` before the `try`, so malformed JSON or missing fields bypass
the error handling and listener failure path. Move
`extractOutboxId(record.value())` inside the `try` and make payload validation
in `process()`/`extractOutboxId()` null-safe for `root.get("eventType")` and
`data.get("productId")`, throwing a clear exception when required fields are
missing. Ensure the catch in `consume()` handles these failures uniformly, and
add tests covering missing `outboxId`, `eventType`, and `data.productId` to
verify they do not escape the listener and go through the intended retry/DLQ
path.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java`:
- Around line 51-60: The loop in OrderEventsConsumer is doing a findByProductId
and save for every item, which creates unnecessary per-item read/write overhead.
Update the OrderEventsConsumer flow to aggregate quantities by productId first,
then apply one update per product, or replace the current repository usage with
an atomic incrementSalesCount(productId, quantity) style API in
ProductMetricsRepository/ProductMetricsModel. Make sure the refactor preserves
the existing concurrency protection and add a test covering a large order with
duplicate productId values to verify counts are combined correctly.
---
Outside diff comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java`:
- Around line 64-76: 좋아요 집계 갱신이 비동기 처리와 별도 경로라 실패 시 `product_like_view`가 실제 Like
데이터와 어긋나는 문제를 해결해야 합니다. `LikeService.incrementLikeCount`와 `decrementLikeCount`
기준으로 조회 후 없으면 예외를 던지는 대신 upsert/생성으로 보정 가능하게 바꾸고, `LikeEventHandler`의 `@Async`
갱신 실패는 재시도 또는 주기적 재계산으로 복구되도록 연결하세요. 또한 `AopTestUtils`로 프록시를 우회하는 기존 테스트 대신 실제
빈을 통해 이벤트를 발행했을 때 집계가 반영되고 실패 후에도 보정되는 통합 테스트를 추가하세요.
In
`@apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java`:
- Around line 49-62: `PaymentFacadeTest.requestPayment_createsPendingPayment` is
currently asserting the asynchronous post-commit `PaymentEventHandler` side
effects, which makes the test nondeterministic. Update the `requestPayment` test
to mock `ApplicationEventPublisher` and verify only that
`PaymentFacade.requestPayment()` creates the payment and publishes the event,
without asserting the later PG-driven state changes. Move the async PG
success/failure behavior into `PaymentEventHandlerTest`, using
`PaymentEventHandler` and its `@Async` flow to verify those outcomes separately.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/PaymentApiE2ETest.java`:
- Around line 81-99: The PaymentApiE2ETest payment-creation case only asserts
the immediate 201/PENDING response, but it does not wait for the async
post-commit flow in PaymentEventHandler. Stub pgClient.createTransaction() in
the test setup, then wait for the handler-driven update to finish and assert the
final payment/order state as well, so truncateAllTables() cannot race with
background processing. Add separate E2E coverage for the success and failure
paths, using the existing PaymentApiE2ETest and PaymentEventHandler flow to
locate the behavior.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java`:
- Around line 23-37: CouponFacade.issue and CouponFacade.validateTemplate
duplicate the same canIssue validation and CoreException handling, so extract
that logic into a shared private helper in CouponFacade and have both public
methods call it to keep the issuance policy in one place. Use the existing
couponTemplateService.getById, template.canIssue, and
CoreException/ErrorType.BAD_REQUEST flow as the common path. Add tests covering
both issue() and validateTemplate() for issuable and non-issuable template cases
to ensure the shared validation stays consistent.
In `@apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java`:
- Around line 11-25: Update the Kafka topic setup in KafkaTopicConfig so
catalog-events and order-events are created with multiple partitions instead of
1, and keep the existing productId/orderId partition key behavior in
OutboxEventPublisher to preserve per-entity ordering while enabling parallel
consumption. Locate the topic definitions in catalogEventsTopic and
orderEventsTopic, adjust the partition count to a higher value, and add a test
that verifies messages with the same productId land on the same partition even
after increasing partitions.
In `@apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxModel.java`:
- Around line 52-55: `OutboxModel.markPublished()` and the `OutboxStatus`
workflow only support PENDING/PUBLISHED, so failed Kafka publishes can retry
forever without traceability. Update `OutboxModel` to track retry metadata such
as `retryCount` and a final failure timestamp, extend `OutboxStatus` with a
`FAILED` (or DLQ-related) state, and change the publishing/retry logic to
increment the count on each failure and transition to `FAILED` once the
threshold is exceeded.
- Around line 41-50: `OutboxModel.create()` is allowing invalid outbox data to
be constructed, so add upfront validation for `topic`, `partitionKey`,
`eventType`, and `payload` before assigning fields. In `create()`, fail fast
when any required value is null or blank, and keep the existing initialization
of `status` and `createdAt` unchanged so the factory method only returns a valid
`OutboxModel`.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java`:
- Around line 12-13: Update OutboxJpaRepository.findByStatusOrderByCreatedAtAsc
to use Pageable instead of a LIMIT clause in the JPQL query, so the repository
stays database-dialect agnostic. Add or ensure a composite index on OutboxModel
for status and createdAt to support the status filter plus ordering. Also add an
integration test around OutboxJpaRepository/OutboxModel that verifies only the
oldest PENDING rows are returned when PENDING and PUBLISHED records are mixed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.java`:
- Around line 58-64: `PaymentEventHandler.recoverAsFailure` duplicates the same
failure-and-cancel flow already implemented in
`PaymentFacade.recoverAsFailure(orderId, reason)`. Update `PaymentEventHandler`
to delegate to `PaymentFacade` instead of directly calling
`paymentService.failByOrderId`, `orderService.getById`, and
`orderService.cancelBySystem`, and make sure the failure reason is passed
through as a parameter rather than hardcoded. Use the existing
`PaymentFacade.recoverAsFailure` method as the single source of truth for this
recovery logic.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentRequestedEvent.java`:
- Around line 6-13: PaymentRequestedEvent is a record, so its generated
toString() currently exposes cardNo and can leak raw card data in logs or
exceptions. Override toString() in PaymentRequestedEvent to mask the cardNo
field before rendering the event, while keeping the other fields readable for
debugging.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/event/UserActionLoggingHandler.java`:
- Around line 20-24: `UserActionLoggingHandler.handle(OrderCreatedEvent)` is
using plain `@EventListener` with `@Async`, so it can run before the surrounding
`OrderFacade.createOrder` transaction commits. Change this listener to
`@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` while
keeping `@Async`, matching the existing `LikeEventHandler` and
`PaymentEventHandler` pattern. Add a test around `OrderCreatedEvent` delivery to
verify the handler only runs after commit and does not run when the transaction
rolls back.
In
`@apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java`:
- Around line 46-48: `LikeServiceTest`에서 `eventPublisher`를 mock으로 주입하고도
`LikeService`의 저장/삭제 결과만 검증하고 있어 `LikedEvent`와 `UnlikedEvent` 발행 누락을 잡지 못합니다.
`like()`, `unlike()`(또는 해당 테스트 메서드들)에서 `ApplicationEventPublisher`의
`publishEvent` 호출을 `verify`로 추가해, 각 동작 이후 올바른 이벤트가 발행되는지 확인하도록 테스트를 보강하세요.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponApiE2ETest.java`:
- Around line 89-94: The 202 response for the coupon issuance flow has no
correlation identifier, so the client cannot reliably match a request to the
later polling result. Update the issuance API and its contract around
CouponController/CouponApiE2ETest so the response includes a request-tracking ID
(for example outboxId or similar) instead of only ApiResponse<Void>, and adjust
the test to assert that identifier is returned alongside the accepted status.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java`:
- Line 93: `LikeConcurrencyE2ETest`에서 고정 `Thread.sleep(3000)`으로 비동기 이벤트 처리를 기다리는
부분이 플래키 테스트와 불필요한 지연을 유발합니다. 해당 대기를 `Awaitility` 같은 폴링 기반 조건 대기로 바꾸고, `like`/이벤트
핸들러가 끝났음을 검증하는 상태를 기준으로 `await`하도록 수정하세요. 같은 패턴이 다른 위치에도 있으면 함께 교체해
`LikeConcurrencyE2ETest` 전체에서 고정 sleep을 제거하세요.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxEventPublisherTest.java`:
- Around line 37-105: 현재 OutboxEventPublisherTest의 Handle 네스트에는 성공 저장만 있어 실패/롤백
시 outbox가 같이 취소되는지 검증이 빠져 있습니다. OutboxEventPublisher.handle 또는 이를 호출하는
`@Transactional` 테스트 흐름에서 예외를 발생시키는 케이스를 추가해, 트랜잭션이 롤백된 뒤
outboxJpaRepository.findAll()에 레코드가 남지 않는지 확인하는 테스트를 넣으세요. LikedEvent,
UnlikedEvent, ProductViewedEvent, OrderCreatedEvent와 동일한 방식으로
AopTestUtils.getTargetObject(outboxEventPublisher)를 사용해 경로를 찾고, 실패 시 저장이 일어나지
않음을 assert 하세요.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.java`:
- Around line 65-80: The current OutboxRelaySchedulerTest only covers a single
Kafka send failure, but it does not verify poison-message handling or repeated
retry behavior. Update the outbox relay flow around OutboxRelayScheduler and
OutboxStatus so there is an explicit policy for repeated failures, such as a
retry limit with a FAILED/DLQ outcome instead of leaving records PENDING
forever. Add tests in OutboxRelaySchedulerTest that simulate multiple
consecutive failures and assert the record transitions or remediation path after
the retry threshold is exceeded.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/event/payment/PaymentEventHandlerTest.java`:
- Around line 52-117: `PaymentEventHandlerTest.Handle`에 서킷 브레이커 오픈 상태인
`CallNotPermittedException` 분기 테스트가 빠져 있습니다. `handle()`가 타임아웃과 동일하게 이 예외를
PENDING 유지 경로로 처리하는지 확인할 수 있도록, `pgClient.createTransaction`이
`CallNotPermittedException`을 던지도록 설정한 테스트 케이스를 추가하세요. 기존의
`AopTestUtils.getTargetObject(paymentEventHandler).handle(event)` 호출 패턴을 그대로
사용해, 처리 후 `PaymentModel`의 상태가 `PENDING`으로 유지되고 `transactionKey`가 변경되지 않는지 검증하면
됩니다.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledModel.java`:
- Around line 11-31: `EventHandledModel`에는 멱등성 체크용 레코드의 보관/정리 정책이 없어서
`event_handled`가 계속 누적될 수 있습니다. `handledAt`를 기준으로 오래된 레코드를 주기적으로 삭제하는 배치 정리(TTL)
또는 파티셔닝/아카이빙 전략을 추가하도록 안내하고, `EventHandledModel.of`와 `event_handled` 사용 흐름을 기준으로
운영 측에서 유지 정책이 적용되도록 조치하세요.
In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java`:
- Around line 16-19: `ProductMetricsRepositoryImpl.findByProductId` is
misleading because it always delegates to `findByProductIdForUpdate`, which
acquires a pessimistic write lock. Rename the repository method to
`findByProductIdForUpdate` to reflect the locking behavior, or add a separate
non-locking lookup method and keep the current one explicitly for update use.
Make sure any interface and callers are updated to use the new, clearly named
method.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java`:
- Around line 42-63: `OrderEventsConsumer.process` is parsing the same payload
again even though `consume()` already parses it via `extractOutboxId`, causing
unnecessary JSON overhead. Refactor the flow so `consume()` parses the payload
once with `objectMapper.readTree` and passes the resulting `JsonNode` into
`process`, then update `process` to use the provided node instead of calling
`readTree` itself; keep the existing `eventHandledRepository` and
`productMetricsRepository` logic unchanged.
In
`@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventsConsumerTest.java`:
- Around line 15-107: 현재 테스트는 OrderEventsConsumer.process만 직접 호출해 `@KafkaListener`
엔트리포인트인 consume의 outboxId 추출, ack 처리, 예외 흐름을 검증하지 못합니다.
OrderEventsConsumer.consume를 대상으로 Ack를 mock 주입해 직접 호출하는 테스트를 추가하고, 정상 메시지에서는 ack
호출과 process 위임을, malformed payload나 outboxId 누락/알 수 없는 eventType에서는 ack 미호출 및 로그
처리를 검증하세요. process 중심 기존 테스트는 유지하되 consume 경로를 커버하도록 보강하세요.
In `@modules/kafka/src/main/resources/kafka.yml`:
- Around line 17-20: The Kafka producer config in kafka.yml uses a low fixed
retry count alongside enable.idempotence in the producer properties, which can
cause avoidable send failures during transient broker issues. Update the
producer settings under the kafka configuration to remove the hardcoded retries:
3 or raise it to a much higher value consistent with idempotent producer
behavior, and rely on delivery.timeout.ms for overall send timeout control. Keep
the existing acks: all and enable.idempotence settings aligned with the producer
setup used by OutboxRelayScheduler and its kafkaTemplate.send(...).get() flow.
🪄 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: 714dc217-37f1-4d54-97ce-e0b9d35f7daf
📒 Files selected for processing (63)
.gitignoreCLAUDE.mdapps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeService.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.javaapps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.javaapps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateModel.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxModel.javaapps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/UserActionLoggingHandler.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponIssueRequestEvent.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/like/LikeEventHandler.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/like/LikedEvent.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/like/UnlikedEvent.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderCreatedEvent.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxRelayScheduler.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentRequestedEvent.javaapps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductViewedEvent.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/UserCouponServiceTest.javaapps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.javaapps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateModelTest.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/PaymentApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/event/UserActionLoggingHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/event/like/LikeEventHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxEventPublisherTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/event/payment/PaymentEventHandlerTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledModel.javaapps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsModel.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.javaapps/commerce-streamer/src/main/resources/application.ymlapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventsConsumerTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventsConsumerTest.javamodules/jpa/src/testFixtures/resources/docker-java.propertiesmodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javamodules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (1)
- CLAUDE.md
| @Bean | ||
| public Executor asyncExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(10); | ||
| executor.setQueueCapacity(100); | ||
| executor.setThreadNamePrefix("async-event-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'FILES:\n'
git ls-files 'apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java' \
'apps/commerce-api/src/main/java/**/*.java' \
| sed -n '1,200p'
printf '\nSEARCH: async / transactional event usage\n'
rg -n "`@Async`|`@TransactionalEventListener`|PaymentEventHandler|ThreadPoolTaskExecutor|asyncExecutor|paymentExecutor|RejectedExecutionHandler|CallerRunsPolicy" apps/commerce-api/src/main/java -S
printf '\nOUTLINE AsyncConfig:\n'
ast-grep outline apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java --view expandedRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 14581
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- AsyncConfig.java ---'
cat -n apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java
echo
echo '--- PaymentEventHandler.java ---'
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.java
echo
echo '--- LikeEventHandler.java ---'
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/event/like/LikeEventHandler.java
echo
echo '--- UserActionLoggingHandler.java ---'
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/event/UserActionLoggingHandler.java
echo
echo '--- Search for AsyncConfigurer / executor bean names / qualifiers ---'
rg -n "AsyncConfigurer|getAsyncExecutor|taskExecutor|asyncExecutor\(|paymentExecutor\(|`@Async`\(\"|Qualifier\(" apps/commerce-api/src/main/java -SRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 7020
결제 이벤트와 경량 이벤트의 Executor를 분리해야 한다다
asyncExecutor 하나를 PaymentEventHandler, LikeEventHandler, UserActionLoggingHandler가 함께 사용하고 있어, PG 지연이나 이벤트 폭주가 큐를 포화시키면 @Async 제출 단계에서 RejectedExecutionException이 발생할 수 있다. @TransactionalEventListener(AFTER_COMMIT) 경로라 커밋 이후에도 요청 스레드로 예외가 전파되어 500 오류처럼 보일 수 있다. 결제용과 경량 이벤트용 Executor를 분리하고, 포화 정책을 RejectedExecutionHandler로 명시하라. 큐 포화 상황에서 결제 요청과 좋아요/로그 이벤트가 서로 간섭하지 않는지, 그리고 제출 실패 시 응답과 후속 처리 동작이 의도대로 유지되는지 통합 테스트를 추가하라.
🤖 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/config/AsyncConfig.java` around
lines 16 - 25, `AsyncConfig.asyncExecutor()` is currently shared by
`PaymentEventHandler`, `LikeEventHandler`, and `UserActionLoggingHandler`, so
split it into separate executors for payment events and lightweight events.
Update the async/event handler wiring to use the appropriate executor and
configure each `ThreadPoolTaskExecutor` with an explicit
`RejectedExecutionHandler` so queue saturation behavior is deterministic. Add
integration tests covering executor isolation under load, verifying that payment
and like/log events do not block each other and that submission failure handling
still matches the expected response and follow-up flow.
| long countByTemplateId(Long templateId); | ||
| boolean existsByMemberIdAndTemplateId(Long memberId, Long templateId); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -e sql -e xml | xargs rg -n -i 'user_coupon' -A5
fd -i 'V*user_coupon*'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '== files ==\n'
git ls-files | rg -n 'UserCoupon|user_coupon|coupon|migration|schema|ddl|flyway|liquibase|V[0-9]+__'
printf '\n== entity/repo references ==\n'
rg -n --hidden -S 'UserCoupon|user_coupon|member_id|template_id|countByTemplateId|existsByMemberIdAndTemplateId' .
printf '\n== likely DDL/migrations ==\n'
fd -HI -t f . . | rg -n '(^|/)(db/migration|migrations|schema|flyway|liquibase|ddl)(/|$)|(\.sql$|\.xml$|\.yaml$|\.yml$)'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 37017
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== UserCouponModel.java =='
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java
echo
echo '== import.sql =='
sed -n '1,120p' apps/commerce-api/src/main/resources/import.sql
echo
echo '== application.yml =='
sed -n '1,160p' apps/commerce-api/src/main/resources/application.ymlRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 5902
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== UserCouponModel.java =='
sed -n '1,140p' apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponModel.java
echo
echo '== import.sql =='
sed -n '1,140p' apps/commerce-api/src/main/resources/import.sql
echo
echo '== application.yml =='
sed -n '1,180p' apps/commerce-api/src/main/resources/application.ymlRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 5902
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== jpa.yml =='
sed -n '1,220p' modules/jpa/src/main/resources/jpa.yml
echo
echo '== user coupon repository impl =='
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
echo
echo '== coupon consumer =='
sed -n '1,140p' apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 6341
template_id 단일 인덱스를 추가해야 한다.
countByTemplateId()는 발급 처리마다 호출되므로 user_coupons(template_id) 인덱스가 없으면 데이터가 쌓일수록 집계가 풀스캔으로 느려진다. existsByMemberIdAndTemplateId()는 이미 UK(member_id, template_id)로 충분하므로 별도 제약 추가는 필요 없다. 대량 적재 후 countByTemplateId()의 실행 계획이 인덱스를 타는 통합 테스트를 추가해야 한다.
🤖 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/coupon/UserCouponJpaRepository.java`
around lines 13 - 14, `UserCouponJpaRepository` exposes `countByTemplateId()`
which will be slow without a single-column `template_id` index, while
`existsByMemberIdAndTemplateId()` already relies on the existing composite
unique key. Add the `user_coupons(template_id)` index in the JPA
entity/migration that defines the table, and keep the repository method as-is;
then add an integration test around `countByTemplateId()` that loads a large
enough dataset and verifies the execution plan uses the new index.
Source: Path instructions
| @Override | ||
| public List<OutboxModel> findPendingBatch(int limit) { | ||
| return outboxJpaRepository.findByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING, limit); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'Files:\n'
git ls-files 'apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/*' \
'apps/commerce-api/src/main/java/com/loopers/**/OutboxRelayScheduler.java' \
'apps/commerce-api/src/main/java/com/loopers/**/ProductMetricsJpaRepository.java'
printf '\nSearch for lock annotations and scheduled usage:\n'
rg -n "`@Lock`|SKIP LOCKED|`@Scheduled`|findByStatusOrderByCreatedAtAsc|findPendingBatch" apps/commerce-api/src/main/java/com/loopers -SRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 2160
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the outbox repository implementation and related scheduler/repository files.
for f in \
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java \
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java \
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java \
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMetricsJpaRepository.java
do
echo "===== $f ====="
wc -l "$f"
sed -n '1,220p' "$f"
echo
doneRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 2116
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the scheduler and repository definitions if the exact paths differ.
rg -n "class OutboxRelayScheduler|interface OutboxJpaRepository|class OutboxRepositoryImpl|interface ProductMetricsJpaRepository" apps/commerce-api/src/main/java/com/loopers -S
# Show surrounding context for the matched declarations.
rg -n -A40 -B20 "class OutboxRelayScheduler|interface OutboxJpaRepository|class OutboxRepositoryImpl|interface ProductMetricsJpaRepository" apps/commerce-api/src/main/java/com/loopers -SRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 12268
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the exact lines in the scheduler and related repository implementations.
for f in \
$(git ls-files 'apps/commerce-api/src/main/java/com/loopers/**/OutboxRelayScheduler.java' \
'apps/commerce-api/src/main/java/com/loopers/**/OutboxRepositoryImpl.java' \
'apps/commerce-api/src/main/java/com/loopers/**/OutboxJpaRepository.java' \
'apps/commerce-api/src/main/java/com/loopers/**/ProductMetricsJpaRepository.java')
do
echo "===== $f ====="
nl -ba "$f" | sed -n '1,220p'
echo
doneRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 320
PENDING 배치를 선점 락으로 조회해야 한다
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java:23-24에서 락 없이 PENDING을 읽으면, @Scheduled(fixedDelay = 1000)로 도는 릴레이가 여러 인스턴스에서 같은 레코드를 동시에 집어 Kafka로 중복 발행할 수 있다. PESSIMISTIC_WRITE 또는 FOR UPDATE SKIP LOCKED로 선점을 보장하고, 동시 실행 시 동일 outbox가 한 번만 선택되는 통합 테스트를 추가해야 한다.
🤖 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/OutboxRepositoryImpl.java`
around lines 22 - 25, `OutboxRepositoryImpl.findPendingBatch` currently reads
PENDING rows without any locking, so concurrent scheduler runs can select the
same outbox twice. Update this path to use a claim/selection mechanism such as
`PESSIMISTIC_WRITE` or `FOR UPDATE SKIP LOCKED` in the
`outboxJpaRepository.findByStatusOrderByCreatedAtAsc` query so only one instance
can pick each record. Also add an integration test around `findPendingBatch`/the
scheduled relay flow that runs concurrent fetches and verifies the same outbox
is selected only once.
Source: Path instructions
| private final KafkaTemplate<String, String> kafkaTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @ResponseStatus(HttpStatus.CREATED) | ||
| @ResponseStatus(HttpStatus.ACCEPTED) | ||
| @PostMapping("/api/v1/coupons/{templateId}/issue") | ||
| public ApiResponse<CouponDto.IssueResponse> issue( | ||
| public ApiResponse<Void> issue( | ||
| @PathVariable Long templateId, | ||
| @RequestAttribute("userId") Long userId | ||
| ) { | ||
| var userCoupon = couponFacade.issue(userId, templateId); | ||
| return ApiResponse.success(CouponDto.IssueResponse.from(userCoupon.getId())); | ||
| var template = couponFacade.validateTemplate(templateId); | ||
| try { | ||
| String payload = objectMapper.writeValueAsString(new CouponIssueRequestEvent(userId, templateId)); | ||
| String partitionKey = template.getTotalCount() != null ? String.valueOf(templateId) : null; | ||
| kafkaTemplate.send("coupon-issue-requests", partitionKey, payload); | ||
| } catch (JsonProcessingException e) { | ||
| throw new RuntimeException("쿠폰 발급 요청 직렬화 실패", e); | ||
| } | ||
| return ApiResponse.success(null); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Kafka 발행 로직을 Service/Facade로 이동해야 한다.
컨트롤러가 ObjectMapper 직렬화, 파티션 키 결정, KafkaTemplate 발행까지 모두 수행하고 있다. 운영 관점에서 이는 컨트롤러가 인프라 세부사항(Kafka 토픽명, 파티션 전략)에 직접 결합되어 향후 발행 방식 변경(예: 다른 쿠폰 흐름도 outbox 패턴으로 통일) 시 컨트롤러를 매번 수정해야 하고, 컨트롤러 단위 테스트에서 Kafka 관련 목(mock) 구성이 불필요하게 늘어난다. CouponFacade에 발행 책임을 위임하고 컨트롤러는 요청 검증과 응답 조립만 담당하도록 정리해야 한다.
♻️ 제안 리팩터링 방향
public ApiResponse<Void> issue(
`@PathVariable` Long templateId,
`@RequestAttribute`("userId") Long userId
) {
- var template = couponFacade.validateTemplate(templateId);
- try {
- String payload = objectMapper.writeValueAsString(new CouponIssueRequestEvent(userId, templateId));
- String partitionKey = template.getTotalCount() != null ? String.valueOf(templateId) : null;
- kafkaTemplate.send("coupon-issue-requests", partitionKey, payload);
- } catch (JsonProcessingException e) {
- throw new RuntimeException("쿠폰 발급 요청 직렬화 실패", e);
- }
+ couponFacade.requestIssue(userId, templateId);
return ApiResponse.success(null);
}As per path instructions, "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다."
🤖 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/interfaces/api/coupon/CouponController.java`
around lines 20 - 37, The issue is that CouponController.issue currently owns
Kafka publishing concerns instead of just request handling. Move the
ObjectMapper serialization, partitionKey decision, and KafkaTemplate.send logic
out of CouponController into CouponFacade (or a service method it delegates to),
and have the controller only validate the request and return the response. Keep
the controller focused on validateTemplate(...) plus calling a facade method for
coupon issuance, and reference CouponIssueRequestEvent, KafkaTemplate, and
ObjectMapper only inside the facade/service layer.
Source: Path instructions
| var template = couponFacade.validateTemplate(templateId); | ||
| try { | ||
| String payload = objectMapper.writeValueAsString(new CouponIssueRequestEvent(userId, templateId)); | ||
| String partitionKey = template.getTotalCount() != null ? String.valueOf(templateId) : null; | ||
| kafkaTemplate.send("coupon-issue-requests", partitionKey, payload); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: CouponIssueRequestEvent 레코드 정의 및 필드명 확인
fd 'CouponIssueRequestEvent.java' --exec cat -n {}Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 324
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== CouponController =="
sed -n '1,220p' apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java
echo
echo "== CouponIssueConsumer references =="
rg -n "CouponIssueRequestEvent|memberId|templateId|coupon-issue-requests|kafkaTemplate.send|whenComplete|CompletableFuture" apps -g '*.java'
echo
echo "== Outbox references =="
rg -n "outbox|Outbox" apps -g '*.java'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 50394
쿠폰 발급 요청도 발행 실패를 흡수하지 말고 outbox 또는 결과 처리로 전환해야 한다
kafkaTemplate.send()의 결과를 전혀 처리하지 않아 브로커 장애나 일시적 네트워크 오류가 나면 202 응답 후 요청이 유실될 수 있다. 다른 이벤트와 같이 outbox/relay로 옮기거나, 최소한 send 결과에 대한 실패 처리와 재시도를 넣어야 한다. 추가로 Kafka 실패 시 요청이 유실되지 않음을 검증하는 테스트를 넣어야 한다.
🤖 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/interfaces/api/coupon/CouponController.java`
around lines 29 - 33, The coupon issuance flow in CouponController currently
fires kafkaTemplate.send() without handling delivery failure, so move this
request publishing to the same outbox/relay pattern used by other events or, at
minimum, handle the send result with explicit failure detection and
retry/compensation logic. Update the validateTemplate() path and the send logic
in CouponController to ensure a successful 202 is only returned when the request
is durably recorded or publishable, and add a test around
CouponController/couponFacade flow that verifies Kafka send failures do not drop
the request silently.
| outboxJpaRepository.save(OutboxModel.create("catalog-events", "10", "LikedEvent", "{\"productId\":10}")); | ||
| when(kafkaTemplate.send(anyString(), anyString(), anyString())) | ||
| .thenReturn(CompletableFuture.completedFuture(mock(SendResult.class))); | ||
|
|
||
| // act | ||
| outboxRelayScheduler.relay(); | ||
|
|
||
| // assert | ||
| var records = outboxJpaRepository.findAll(); | ||
| assertThat(records).hasSize(1); | ||
| assertThat(records.get(0).getStatus()).isEqualTo(OutboxStatus.PUBLISHED); | ||
| verify(kafkaTemplate, times(1)).send("catalog-events", "10", "{\"productId\":10}"); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate relevant files =="
git ls-files | rg 'OutboxRelayScheduler|OutboxModel|OutboxRelaySchedulerTest|withOutboxId|OutboxStatus' || true
echo "== outline candidate files =="
for f in \
apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.java \
apps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxRelayScheduler.java \
apps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxModel.java
do
if [ -f "$f" ]; then
echo "--- $f ---"
wc -l "$f"
sed -n '1,220p' "$f" | cat -n
fi
done
echo "== search for withOutboxId / retries =="
rg -n "withOutboxId|retry|PUBLISHED|PENDING|FAILED|send\\(" apps/commerce-api/src/main/java apps/commerce-api/src/test/java -S || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 22220
Kafka 발행 payload 검증을 실제 전송 형식에 맞춰 수정해야 한다.
withOutboxId(outbox)가 outboxId를 주입하므로 현재 verify(kafkaTemplate, times(1)).send("catalog-events", "10", "{\"productId\":10}")는 실제 전송값과 다르다. 운영 중 메시지 포맷 변경을 놓치기 쉬우므로 ArgumentCaptor로 payload를 받아 outboxId 포함 여부를 검증하고, 실패 케이스에서는 send 미호출도 함께 확인해야 한다.
🤖 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/interfaces/event/outbox/OutboxRelaySchedulerTest.java`
around lines 51 - 63, The Kafka send verification in OutboxRelaySchedulerTest is
asserting the wrong payload shape because withOutboxId(outbox) injects the
outboxId into the message. Update the relay() test to capture the third argument
passed to kafkaTemplate.send and assert that the payload includes the outboxId
along with the original data, using the existing OutboxRelayScheduler and
OutboxModel setup. Also add/adjust the failure-path test so it verifies
kafkaTemplate.send is not called when publishing fails, instead of hardcoding
the current payload string.
| @Version | ||
| private Long version; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# CatalogEventsConsumer / OrderEventsConsumer의 낙관적 락 재시도 처리 여부 확인
rg -n -C5 'OptimisticLock|`@Retryable`|RetryTemplate' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 176
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 소비자 구현 파일 위치 파악
fd -a '.*Consumer.*\.java$|.*Listener.*\.java$' apps/commerce-streamer/src/main/java/com/loopers -t f
# 낙관적 락/재시도/예외 처리 관련 키워드 전체 검색
rg -n -C 3 'OptimisticLock|ObjectOptimisticLockingFailureException|`@Retryable`|RetryTemplate|DeadLetter|DefaultErrorHandler|SeekToCurrentErrorHandler|KafkaListenerErrorHandler|ErrorHandler' apps/commerce-streamer/src/main/java/com/loopers
# ProductMetricsModel과 이를 사용하는 코드 위치 확인
rg -n -C 3 'findByProductId|incrementLikeCount|incrementViewCount|addSalesCount|save\(' apps/commerce-streamer/src/main/java/com/loopersRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 515
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 소비자 구현 구조 확인
ast-grep outline apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java
ast-grep outline apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
# Kafka 오류 처리/재시도 설정 위치 탐색
rg -n -C 4 'DefaultErrorHandler|CommonErrorHandler|ConcurrentKafkaListenerContainerFactory|KafkaListenerContainerFactory|RetryTemplate|`@Retryable`|SeekToCurrentErrorHandler|DeadLetter|KafkaTemplate' apps/commerce-streamer/src/main/java/com/loopers
# 소비자 파일의 핵심 메서드만 라인 포함으로 읽기
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | sed -n '1,240p'
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java | sed -n '1,240p'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 484
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 소비자 본문 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | sed -n '1,260p'
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java | sed -n '1,260p'
# Kafka 설정/에러 핸들러 확인
rg -n -C 4 'DefaultErrorHandler|CommonErrorHandler|ConcurrentKafkaListenerContainerFactory|KafkaListenerContainerFactory|RetryTemplate|`@Retryable`|SeekToCurrentErrorHandler|DeadLetter|ErrorHandler|KafkaListenerContainerFactoryConfigurer' apps/commerce-streamer/src/main/java/com/loopers apps/commerce-streamer/src/main/resourcesRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 6640
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Kafka 리스너 설정 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/confg/kafka/KafkaConfig.java | sed -n '1,260p'
# 관련 빈/설정에서 ack mode, error handler, retry 설정 검색
rg -n -C 4 'AckMode|MANUAL|RECORD|BATCH|DefaultErrorHandler|CommonErrorHandler|ConcurrentKafkaListenerContainerFactory|setErrorHandler|setCommonErrorHandler|setAckMode|RetryTemplate|`@Retryable`' apps/commerce-streamer/src/main/java/com/loopers apps/commerce-streamer/src/main/resourcesRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 286
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# KafkaConfig 실제 위치 찾기
fd -a 'KafkaConfig\.java$' apps/commerce-streamer/src/main/java
# Kafka 관련 설정 전체 탐색
rg -n -C 4 'AckMode|MANUAL|RECORD|BATCH|DefaultErrorHandler|CommonErrorHandler|ConcurrentKafkaListenerContainerFactory|KafkaTemplate|setErrorHandler|setCommonErrorHandler|setAckMode|RetryTemplate|`@Retryable`' apps/commerce-streamer/src/main/java apps/commerce-streamer/src/main/resourcesRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 1329
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# KafkaConfig 및 STRING_LISTENER 정의 위치 찾기
rg -n -C 4 'class KafkaConfig|STRING_LISTENER|BATCH_LISTENER' apps/commerce-streamer/src/main/java
# consumer 패키지에서 KafkaConfig import/사용부 재확인
rg -n -C 3 'containerFactory = KafkaConfig\.STRING_LISTENER|containerFactory = KafkaConfig\.BATCH_LISTENER' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumerRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 6609
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Kafka 설정과 ack 모드 위치를 전역으로 확인
fd -a 'KafkaConfig\.java$|.*Kafka.*Config.*\.java$' .
rg -n -C 4 'AckMode|MANUAL_IMMEDIATE|MANUAL|DefaultErrorHandler|CommonErrorHandler|ConcurrentKafkaListenerContainerFactory|setAckMode|setCommonErrorHandler|setErrorHandler|RetryTemplate|`@Retryable`' .
# consumer에서 예외를 삼키는 지점 재확인
rg -n -C 3 'catch \(Exception e\)|ack\.acknowledge\(\)|process\(' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumerRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 12499
실패한 이벤트는 예외를 삼키지 말고 재시도 경로로 넘겨야 한다
CatalogEventsConsumer.java:32-39와 OrderEventsConsumer.java:32-39는 MANUAL ack를 쓰면서 catch (Exception)에서 예외를 삼켜 버린다. 이러면 낙관적 락 충돌 같은 일시 오류가 재시도나 DLQ로 이어지지 않아, 실패한 이벤트가 커밋되지 않은 채 운영 중 지연되거나 누락될 수 있다. OptimisticLockException은 다시 던지고, DefaultErrorHandler나 재시도 토픽으로 횟수 제한과 백오프를 두어야 한다. 낙관적 락 실패가 발생했을 때 재시도 후에도 실패하면 DLQ로 떨어지는 테스트를 추가해야 한다.
🤖 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/metrics/ProductMetricsModel.java`
around lines 29 - 30, In CatalogEventsConsumer and OrderEventsConsumer, the
MANUAL ack flow is swallowing failures in catch (Exception), so retryable errors
never reach the retry/DLQ path. Update the consumer handlers to rethrow
OptimisticLockException and any other transient failures instead of catching and
suppressing them, and configure DefaultErrorHandler or a retry-topic/backoff
policy so retries are bounded and eventually route to DLQ. Keep successful
acking only after processing completes, and add a test that verifies an
optimistic lock failure is retried and then sent to DLQ if it still fails.
| public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) { | ||
| String eventId = extractOutboxId(record.value()); | ||
| try { | ||
| process(eventId, record.value()); | ||
| ack.acknowledge(); | ||
| } catch (Exception e) { | ||
| log.error("[CatalogEventsConsumer] 처리 실패: eventId={}", eventId, e); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the relevant file and nearby config/tests.
git ls-files 'apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java' \
'apps/commerce-streamer/src/main/java/**/KafkaConfig.java' \
'apps/commerce-streamer/src/main/java/**/KafkaTopicConfig.java' \
'apps/commerce-streamer/src/test/java/**' | sed 's#^`#-` #'
echo
echo '--- CatalogEventsConsumer.java (outline) ---'
ast-grep outline apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java --view expanded || true
echo
echo '--- Search for related consumer/config symbols ---'
rg -n "extractOutboxId|acknowledge\\(|setCommonErrorHandler|DefaultErrorHandler|DeadLetter|catalog-events|partition|ConcurrentKafkaListenerContainerFactory|KafkaTopicConfig|KafkaConfig" apps/commerce-streamer/src/main/java apps/commerce-streamer/src/test/javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 2939
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the relevant consumer file with line numbers.
sed -n '1,220p' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | cat -nRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 3520
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the consumer and any nearby retry/DLQ configuration.
sed -n '1,220p' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | cat -n
echo
echo '--- Kafka-related configs ---'
rg -n "DefaultErrorHandler|DeadLetter|setCommonErrorHandler|ConcurrentKafkaListenerContainerFactory|KafkaTemplate|retry|DLQ|dead letter|catalog-events|topic" apps/commerce-streamer/src/main/javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 3962
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the relevant consumer file and any consumer tests.
sed -n '1,220p' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | cat -n
echo
echo '--- Potential tests ---'
rg -n "CatalogEventsConsumer|consume\\(|extractOutboxId|acknowledge\\(" apps/commerce-streamer/src/test/javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 3842
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect the consumer implementation.
sed -n '1,220p' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java | cat -n
echo
echo '--- 2) Kafka error handling / topic config ---'
rg -n "DefaultErrorHandler|DeadLetter|setCommonErrorHandler|ConcurrentKafkaListenerContainerFactory|KafkaTemplate|retry|DLQ|dead letter|catalog-events|topic|partition" apps/commerce-streamer/src/main/java
echo
echo '--- 3) Tests around this consumer ---'
rg -n "CatalogEventsConsumer|consume\\(|extractOutboxId|acknowledge\\(" apps/commerce-streamer/src/test/javaRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 4315
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect consumer tests and Kafka config to distinguish a true issue from an unsupported concern.
sed -n '1,260p' apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventsConsumerTest.java | cat -n
echo
echo '--- OrderEventsConsumerTest ---'
sed -n '1,260p' apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventsConsumerTest.java | cat -n
echo
echo '--- KafkaConfig files ---'
fd -a 'KafkaConfig.java|KafkaTopicConfig.java' apps/commerce-streamer/src/main/java || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 9025
outboxId 추출과 payload 검증을 같은 실패 경로로 묶어야 한다.
extractOutboxId(record.value())가try밖에 있어 JSON 파싱 실패나outboxId누락 시consume()의 로그와 처리 경로를 건너뛴다. 이 경우 원인 식별이 어렵고, 잘못된 레코드가 다시 들어와 적체를 만들 수 있다.extractOutboxId를try안으로 옮기고,root.get("eventType")와data.get("productId")도 null-safe하게 검증해 malformed payload를 명시적으로 실패 처리해야 한다.outboxId누락,eventType누락,data.productId누락 payload에 대해 예외가 listener 밖으로 새지 않고 의도한 재처리/DLQ 경로로 분기되는 테스트를 추가해야 한다.
🤖 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/CatalogEventsConsumer.java`
around lines 32 - 40, The `CatalogEventsConsumer.consume()` flow currently
extracts `outboxId` before the `try`, so malformed JSON or missing fields bypass
the error handling and listener failure path. Move
`extractOutboxId(record.value())` inside the `try` and make payload validation
in `process()`/`extractOutboxId()` null-safe for `root.get("eventType")` and
`data.get("productId")`, throwing a clear exception when required fields are
missing. Ensure the catch in `consume()` handles these failures uniformly, and
add tests covering missing `outboxId`, `eventType`, and `data.productId` to
verify they do not escape the listener and go through the intended retry/DLQ
path.
| @Transactional | ||
| public void process(String eventId, String payload) throws Exception { | ||
| if (eventHandledRepository.existsById(eventId)) { | ||
| return; | ||
| } | ||
|
|
||
| JsonNode root = objectMapper.readTree(payload); | ||
| String eventType = root.get("eventType").asText(); | ||
| JsonNode data = root.get("data"); | ||
| Long productId = data.get("productId").asLong(); | ||
|
|
||
| ProductMetricsModel metrics = productMetricsRepository.findByProductId(productId) | ||
| .orElseGet(() -> ProductMetricsModel.create(productId)); | ||
|
|
||
| switch (eventType) { | ||
| case "LikedEvent" -> metrics.incrementLikeCount(); | ||
| case "UnlikedEvent" -> metrics.decrementLikeCount(); | ||
| case "ProductViewedEvent" -> metrics.incrementViewCount(); | ||
| default -> throw new IllegalArgumentException("알 수 없는 이벤트 타입: " + eventType); | ||
| } | ||
|
|
||
| productMetricsRepository.save(metrics); | ||
| eventHandledRepository.save(EventHandledModel.of(eventId)); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
필드 null-safety 부재 및 알 수 없는 eventType에 대한 DLQ/재시도 제한이 없다.
root.get("eventType"), data.get("productId")는 필드가 없으면 null을 반환하므로 .asText()/.asLong() 호출 시 NPE가 발생할 수 있다. 또한 알 수 없는 eventType은 IllegalArgumentException을 던지는데(line 60), 이 예외는 매번 동일하게 재현되므로 ack 없이 재전달되면 무한 재시도 루프(poison pill)에 빠져 해당 파티션 처리가 정체된다.
JsonNode.path()로 안전하게 필드를 조회해 명확한 예외 메시지를 던지고, 알 수 없는 eventType/파싱 실패 메시지는 별도 DLQ 토픽으로 전송하거나 최대 재시도 횟수 초과 시 ack 처리 후 별도 로그/알람으로 남기는 정책을 도입해야 한다.
추가 테스트: eventType 또는 productId 필드가 누락된 payload, 그리고 알 수 없는 eventType payload에 대해 NPE 없이 명확한 예외/로그로 처리되는지 검증하는 테스트가 필요하다.
🤖 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/CatalogEventsConsumer.java`
around lines 42 - 65, `CatalogEventsConsumer.process` has unsafe JSON field
access and no poison-pill handling for unknown event types. Update the payload
parsing to validate `eventType` and `productId` explicitly (using safe
lookup/clear checks) and throw a descriptive error instead of allowing NPEs from
`root.get(...)` or `data.get(...)`. For the `switch` in `process`, route unknown
or malformed events to a DLQ or a bounded retry path with ack-after-limit
behavior, and keep the existing save flow only for valid events. Add tests
covering missing `eventType`, missing `productId`, and unknown `eventType` cases
to verify deterministic handling.
| for (JsonNode item : items) { | ||
| Long productId = item.get("productId").asLong(); | ||
| long quantity = item.get("quantity").asLong(); | ||
|
|
||
| ProductMetricsModel metrics = productMetricsRepository.findByProductId(productId) | ||
| .orElseGet(() -> ProductMetricsModel.create(productId)); | ||
|
|
||
| metrics.addSalesCount(quantity); | ||
| productMetricsRepository.save(metrics); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'FILES\n'
git ls-files | rg 'OrderEventsConsumer\.java|ProductMetricsModel\.java|ProductMetricsRepository\.java|product metrics|Metrics' || true
printf '\nOUTLINE OrderEventsConsumer\n'
if [ -f apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java ]; then
ast-grep outline apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java --view expanded || true
fi
printf '\nOUTLINE ProductMetricsModel / Repository\n'
for f in $(git ls-files | rg 'ProductMetricsModel\.java|ProductMetricsRepository\.java' || true); do
echo "--- $f"
ast-grep outline "$f" --view expanded || true
done
printf '\nRG `@Version` / transactional / findByProductId / incrementSalesCount\n'
rg -n "`@Version`|`@Transactional`|findByProductId|incrementSalesCount|salesCount|ProductMetricsModel" apps/commerce-streamer/src/main/java || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 5916
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in \
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java \
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java \
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java \
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsModel.java
do
echo "===== $f ====="
wc -l "$f"
echo "---"
cat -n "$f" | sed -n '1,220p'
echo
doneRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 7860
아이템별 조회·저장 루프를 줄여야 한다
- 현재 경로는
PESSIMISTIC_WRITE와@Version으로 동시성 유실은 어느 정도 막고 있지만, 항목 수만큼findByProductId/save가 반복되어 주문이 커질수록 읽기·쓰기 부하가 선형으로 늘어난다. productId별로 수량을 먼저 합산한 뒤 한 번씩만 갱신하거나,incrementSalesCount(productId, quantity)같은 원자적 증분 API로 바꾸는 편이 낫다.- 대량 주문과 동일
productId가 여러 번 포함된 케이스에 대한 통합 테스트를 추가해야 한다.
🤖 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/OrderEventsConsumer.java`
around lines 51 - 60, The loop in OrderEventsConsumer is doing a findByProductId
and save for every item, which creates unnecessary per-item read/write overhead.
Update the OrderEventsConsumer flow to aggregate quantities by productId first,
then apply one update per product, or replace the current repository usage with
an atomic incrementSalesCount(productId, quantity) style API in
ProductMetricsRepository/ProductMetricsModel. Make sure the refactor preserves
the existing concurrency protection and add a test covering a large order with
duplicate productId values to verify counts are combined correctly.
🧭 Context & Decision
문제 정의
선택지와 결정
Step 2 — Kafka 직렬화 방식
KafkaTemplate<Object, Object>+ JsonSerializer — String을 이중 직렬화하는 버그 발생 (String이 다시 JSON 문자열로 감싸짐)KafkaTemplate<String, String>+ StringSerializer — String 그대로 전송Step 2 — Consumer 이벤트 타입 구분
liked-events,unliked-events등)eventType필드를 envelope으로 감싸서 발행{"eventType": "LikedEvent", "data": {...}, "outboxId": "1"}Step 2 — 멱등성 키 전략
partition-offset— Kafka 메타데이터 기반, payload 파싱 불필요. 단, Outbox 크래시 후 재발행 시 다른 offset으로 중복 도달 가능outboxId— 같은 비즈니스 이벤트는 동일한 ID. Outbox 재발행이 몇 번 와도 한 번만 처리Step 3 — 선착순 쿠폰 동시성 제어
SELECT FOR UPDATE) — DB가 직렬화 담당. 트랜잭션 유지 시간 증가, DB 병목key=templateId로 같은 쿠폰 요청을 단일 파티션으로 라우팅 → Consumer 단일 스레드가 순차 처리Step 3 — 기간 쿠폰 vs 선착순 쿠폰 처리 방식
totalCount유무로 파티션 키 분기 — 선착순은key=templateId, 기간은key=null(라운드로빈)Step 3 — 발급 결과 확인 방식
GET /api/v1/users/me/coupons주기적 호출🤔 고민한 점 / 막혔던 부분
KafkaTemplate<Object, Object>이중 직렬화 버그처음에
JsonSerializer를 썼는데 Consumer에서 payload가"\"{\\"eventType\\":\\"LikedEvent\\",...}\""처럼 이중으로 감싸져서 파싱 실패. String을 JSON으로 직렬화하면 문자열 자체가 다시 JSON 문자열로 인코딩되는 구조였음.KafkaTemplate<String, String>+StringSerializer로 교체해서 해결.outboxId 닭-달걀 문제
OutboxEventPublisher에서 payload를 만들 때 outboxId를 넣으려 했는데, payload를 만들어야OutboxModel을 save할 수 있고, save 후에야 ID가 생기는 순환 문제 발생.OutboxRelayScheduler가 발행 직전에 이미 저장된 outbox의 ID를 payload에 추가하는 방식으로 해결.선착순 쿠폰 동시성 — Kafka가 정말 충분한가
DB 락 없이 Kafka 파티션만으로 동시성을 제어하는 게 맞는지 고민.
key=templateId→ 같은 파티션 → Consumer 단일 스레드 순차 처리라는 구조상,countByTemplateId()를 읽는 시점에 이전 처리가 항상 완료된 상태임을 확인. 추가 락 없이 안전하다고 판단.version/updated_at최신 이벤트 반영 요구사항체크리스트에 있는 항목인데, 이벤트 payload에 타임스탬프가 없어 직접적인 필터링은 불가.
key=productId→ 단일 파티션 → Kafka 순서 보장으로 오래된 이벤트가 최신 이후에 도달하는 경우 자체가 발생하지 않음.outboxId멱등성 키로 재발행 중복까지 차단하여 동일한 보호 효과를 낸다고 판단.Consumer 실패와 DLQ 없는 무한 재시도
process()에서 예외 발생 시 ack를 보내지 않으면 같은 메시지가 무한 재시도됨. 비즈니스 실패(수량 초과, 중복)는 정상 케이스라 ack 후 skip 처리. 기술적 실패(DB 오류 등)는 재시도 의도적으로 허용. DLQ 미구현으로 반복 기술 실패 시 파티션 블로킹 가능 — Nice-to-Have로 분류.🙋 기타
Transactional Outbox Pattern을 선택한 이유
좋아요 등록과 Kafka 발행을 하나의 트랜잭션으로 묶을 수 없어서, 핵심 로직(좋아요 저장)은 트랜잭션 안에서 완료하고 Kafka 발행은 outbox 테이블을 경유해서 처리. 덕분에 Kafka가 잠깐 다운되더라도 이벤트가 유실되지 않고, 1초마다 재시도하는 구조.