Skip to content

[volume-7] 이벤트 기반 아키텍처 구현#327

Merged
SuHyun-git merged 9 commits into
loopers-labs:SuHyun-gitfrom
SuHyun-git:volume-7
Jul 9, 2026
Merged

[volume-7] 이벤트 기반 아키텍처 구현#327
SuHyun-git merged 9 commits into
loopers-labs:SuHyun-gitfrom
SuHyun-git:volume-7

Conversation

@SuHyun-git

Copy link
Copy Markdown

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 좋아요, 조회, 주문 집계가 동기 트랜잭션 안에서 처리됨. 집계 실패가 핵심 로직(좋아요 등록, 주문 생성)까지 롤백시키는 구조. 선착순 쿠폰 발급 시 동시 요청에 대한 수량 초과 발급 리스크 존재.
  • 문제(또는 리스크):
    • 집계 로직이 핵심 트랜잭션에 함께 있어 장애 전파 가능성
    • 선착순 쿠폰: 동시 요청 시 TOCTOU(Time-Of-Check-Time-Of-Use) race condition으로 수량 초과 발급
    • 트래픽 폭주 시 DB write 집중으로 응답 지연
  • 성공 기준(완료 정의):
    • 집계 실패가 핵심 로직에 영향을 주지 않음
    • 선착순 쿠폰 100개 한도에 200명 동시 요청 시 정확히 100개만 발급
    • API는 즉시 응답 (202 Accepted), 실제 처리는 비동기

선택지와 결정

Step 2 — Kafka 직렬화 방식

  • 고려한 대안:
    • A: KafkaTemplate<Object, Object> + JsonSerializer — String을 이중 직렬화하는 버그 발생 (String이 다시 JSON 문자열로 감싸짐)
    • B: KafkaTemplate<String, String> + StringSerializer — String 그대로 전송
  • 최종 결정: B 채택
  • 트레이드오프: 직렬화 방식을 명시적으로 관리해야 하지만, Consumer가 받는 payload가 예측 가능해짐

Step 2 — Consumer 이벤트 타입 구분

  • 고려한 대안:
    • A: 토픽을 이벤트 타입별로 분리 (liked-events, unliked-events 등)
    • B: 하나의 토픽에 eventType 필드를 envelope으로 감싸서 발행
  • 최종 결정: B 채택 — {"eventType": "LikedEvent", "data": {...}, "outboxId": "1"}
  • 트레이드오프: 토픽 수가 늘어나지 않고 Consumer가 하나의 팩토리에서 switch로 분기 가능. 단 payload 구조 의존성 생김

Step 2 — 멱등성 키 전략

  • 고려한 대안:
    • A: partition-offset — Kafka 메타데이터 기반, payload 파싱 불필요. 단, Outbox 크래시 후 재발행 시 다른 offset으로 중복 도달 가능
    • B: outboxId — 같은 비즈니스 이벤트는 동일한 ID. Outbox 재발행이 몇 번 와도 한 번만 처리
  • 최종 결정: B 채택 — RelayScheduler가 발행 직전 payload에 outboxId 추가
  • 트레이드오프: Producer/Consumer 간 payload 구조 의존성 생기지만, 중복 처리 범위가 더 넓어짐
  • 추후 개선 여지: OutboxModel ID를 UUID로 변경하면 save 전에 ID를 알 수 있어 OutboxEventPublisher에서 직접 삽입 가능

Step 3 — 선착순 쿠폰 동시성 제어

  • 고려한 대안:
    • A: DB 비관적 락 (SELECT FOR UPDATE) — DB가 직렬화 담당. 트랜잭션 유지 시간 증가, DB 병목
    • B: Redis 분산 락 — 빠르지만 Redis 의존성 추가, TTL 관리 복잡
    • C: Kafka 파티션 키 — key=templateId로 같은 쿠폰 요청을 단일 파티션으로 라우팅 → Consumer 단일 스레드가 순차 처리
  • 최종 결정: C 채택
  • 트레이드오프: DB/Redis 추가 의존성 없이 Kafka 구조만으로 해결. 단, 발급 결과를 동기로 받을 수 없어 Polling 방식으로 확인해야 함

Step 3 — 기간 쿠폰 vs 선착순 쿠폰 처리 방식

  • 고려한 대안:
    • A: 기간 쿠폰=동기, 선착순=Kafka — 쿠폰 종류마다 처리 경로가 달라 복잡도 증가. 기간 쿠폰도 특정 시간대 트래픽 폭주 대응 불가
    • B: 둘 다 Kafka, 단일 파티션 — 서로 다른 templateId도 직렬화돼 불필요한 병목
    • C: 둘 다 Kafka, totalCount 유무로 파티션 키 분기 — 선착순은 key=templateId, 기간은 key=null(라운드로빈)
  • 최종 결정: C 채택
  • 트레이드오프: 트래픽 버퍼링과 동시성 제어를 모두 해결하면서 파티션 병렬성도 확보. Controller에서 template 조회가 한 번 더 발생하지만 어차피 validateTemplate()에서 조회함

Step 3 — 발급 결과 확인 방식

  • 고려한 대안:
    • A: Polling — GET /api/v1/users/me/coupons 주기적 호출
    • B: SSE — 서버가 결과를 push
    • C: WebSocket — 양방향 연결
    • D: 발급 상태 테이블 + Polling — PENDING/ISSUED/FAILED 상태 제공
  • 최종 결정: A 채택 (구현 단순성 우선)
  • 트레이드오프: 구현이 단순하지만 유저가 "처리 중"인지 "선착순 마감"인지 알 수 없음
  • 추후 개선 여지: D 방식으로 확장하면 실패 사유까지 유저에게 전달 가능

🤔 고민한 점 / 막혔던 부분

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초마다 재시도하는 구조.

- 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으로 와도 중복 처리 방지
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

변경 목적: 좋아요/조회/주문/결제/쿠폰 발급의 핵심 흐름을 이벤트 기반으로 분리하고, Kafka + Outbox로 비동기 처리와 재처리 안정성을 높이는 것입니다.
핵심 변경점: PaymentFacade, LikeService, OrderFacade, ProductController가 직접 후속 처리를 하던 방식에서 이벤트 발행으로 전환됐고, OutboxModel/RelayScheduler/Publisher로 신뢰성 있는 발행 경로가 추가됐습니다.
핵심 변경점: 쿠폰 발급은 coupon-issue-requests Kafka 소비자로 이동했으며, templateId 기반 파티셔닝과 totalCount 유무에 따른 경로 분기로 선착순/기간 쿠폰을 나눠 처리합니다.
핵심 변경점: commerce-streamercatalog-events/order-events를 소비해 조회·좋아요·판매 지표를 갱신하고, outboxId를 처리 이력 키로 사용해 중복 처리를 방지합니다.
리스크/주의사항: 비동기화로 인해 응답 시점과 실제 반영 시점이 달라지므로, 쿠폰 발급 결과는 즉시 반환이 아닌 GET /api/v1/users/me/coupons 폴링으로 확인해야 합니다.
리스크/주의사항: outbox relay, Kafka 전송, 소비자 재처리/ack 전략이 전체 정확성에 직접 영향이 있어, 장애 시 중복/유실이 없는지 확인이 필요합니다.
테스트/검증: 관련 서비스/컨슈머/Outbox에 대한 단위·통합·E2E 테스트가 추가/수정됐으며, 특히 쿠폰 선착순, 결제 상태 전이, 중복 이벤트 방지, outbox 발행/재시도 동작을 검증합니다.
테스트/검증: 추가로, Kafka 토픽 파티션 수와 templateId 라우팅이 실제 운영 설정과 맞는지 확인이 필요할까요?

Walkthrough

Kafka 아웃박스 패턴과 도메인 이벤트(좋아요, 주문, 결제, 상품조회, 쿠폰발급) 발행/구독 구조를 도입한다. CouponController와 PaymentFacade는 동기 처리 대신 이벤트/큐 기반 비동기 처리로 전환되며, commerce-streamer 모듈에 이벤트 소비 후 상품 메트릭(좋아요/조회/판매 수)을 집계하는 컨슈머가 추가된다.

Changes

비동기 이벤트/아웃박스 파이프라인

Layer / File(s) Summary
도메인 이벤트 계약 및 비동기 설정
.../interfaces/event/like/*Event.java, .../order/OrderCreatedEvent.java, .../product/ProductViewedEvent.java, .../payment/PaymentRequestedEvent.java, .../coupon/CouponIssueRequestEvent.java, config/AsyncConfig.java
LikedEvent, UnlikedEvent, OrderCreatedEvent, ProductViewedEvent, PaymentRequestedEvent, CouponIssueRequestEvent 레코드가 정의되고, @EnableAsync/@EnableScheduling 기반 Executor 빈이 추가된다.
좋아요 이벤트 발행/구독 전환
LikeService.java, LikeEventHandler.java, LikeServiceTest.java, LikeServiceIntegrationTest.java, LikeConcurrencyE2ETest.java, LikeEventHandlerTest.java
LikeService는 카운트 직접 갱신 대신 이벤트를 발행하고, LikeEventHandler가 커밋 후 카운트를 증감시키며, 동시성 테스트에 3초 대기가 추가된다.
주문/상품 조회 이벤트
OrderFacade.java, ProductController.java, UserActionLoggingHandler.java, 관련 테스트
주문 생성 시 OrderCreatedEvent, 상품 조회 시 ProductViewedEvent를 발행하며, UserActionLoggingHandler가 두 이벤트를 비동기 로그로 기록한다.
결제 이벤트 처리 전환
PaymentFacade.java, PaymentEventHandler.java, PaymentFacadeTest.java, PaymentApiE2ETest.java, PaymentEventHandlerTest.java
PaymentFacade는 PG 호출을 제거하고 PaymentRequestedEvent만 발행하며, PaymentEventHandler가 커밋 후 PG 호출/타임아웃 판별/실패시 주문 취소를 수행한다.
쿠폰 비동기 발급 흐름
CouponController.java, CouponFacade.java, CouponTemplateModel.java, UserCouponRepository*.java, CouponIssueConsumer.java, 관련 테스트
발급 API는 202 응답과 함께 Kafka로 발급 요청을 전송하고, CouponIssueConsumer가 템플릿 검증/매진/중복 검사 후 UserCoupon을 생성한다. totalCount 필드와 발급 수량 조회 메서드가 추가된다.
Outbox 패턴 인프라
domain/outbox/*.java, infrastructure/outbox/*.java, OutboxEventPublisher.java, OutboxRelayScheduler.java, KafkaTopicConfig.java, 관련 테스트
OutboxModel/Status/Repository가 추가되고, 도메인 이벤트를 트랜잭션 범위 내 outbox에 저장한 뒤 스케줄러가 1초 주기로 Kafka에 릴레이한다. catalog-events/order-events/coupon-issue-requests 토픽이 정의된다.
Kafka 설정 및 의존성
apps/commerce-api/build.gradle.kts, modules/kafka/.../KafkaConfig.java, modules/kafka/.../kafka.yml
commerce-api에 kafka 모듈 의존성을 추가하고, String 전용 KafkaTemplate/리스너 팩토리를 구성하며, producer 설정에 acks/idempotence를 추가한다.
commerce-streamer 이벤트 소비 및 메트릭 집계
domain/eventhandled/*.java, domain/metrics/*.java, infrastructure/eventhandled/*.java, infrastructure/metrics/*.java, interfaces/consumer/CatalogEventsConsumer.java, interfaces/consumer/OrderEventsConsumer.java, application.yml, 관련 테스트
EventHandledModel로 멱등 처리를 보장하고, ProductMetricsModel로 좋아요/조회/판매 카운트를 관리하며, CatalogEventsConsumer와 OrderEventsConsumer가 각 토픽을 소비해 메트릭을 갱신한다.
문서/환경 파일 정리
.gitignore, modules/jpa/src/testFixtures/resources/docker-java.properties
CLAUDE.md 무시 규칙과 docker-java.properties의 api.version 설정이 추가된다.

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)
Loading
sequenceDiagram
  participant PaymentFacade
  participant PaymentEventHandler
  participant PgClient
  participant OrderService
  PaymentFacade->>PaymentEventHandler: publishEvent(PaymentRequestedEvent)
  PaymentEventHandler->>PgClient: createTransaction(...)
  alt PG 500 오류
    PaymentEventHandler->>OrderService: cancelBySystem(orderId)
  end
Loading
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
Loading

참고 사항(운영 관점): LikeConcurrencyE2ETestThread.sleep(3000)을 추가해 비동기 처리 완료를 기다리는 방식은 CI 환경의 부하에 따라 플레이키(flaky) 테스트가 될 수 있어 운영 신뢰도에 문제가 된다. Awaitility 등 폴링 기반 대기로 교체하고, 타임아웃 발생 시 실패 원인을 로그로 남기는 테스트 보강이 필요하다. 또한 OutboxRelayScheduler.relay()에서 kafkaTemplate.send(...).get()을 동기 블로킹으로 호출하는 방식은 브로커 지연 시 스케줄러 스레드가 장시간 점유되어 배치 처리 지연을 유발할 수 있으므로, 타임아웃 지정 및 비동기 콜백 처리로 개선하고 브로커 지연 상황을 재현하는 통합 테스트를 추가하는 것이 바람직하다.


관련 이슈: 명시된 이슈 번호 없음
관련 PR: 명시된 관련 PR 없음
제안 라벨: enhancement, kafka, event-driven, needs-testing
제안 리뷰어: 백엔드 결제/쿠폰 도메인 담당자, Kafka 인프라 담당자

🐰 (풀숲에서 조용히 코드를 살피며)
아웃박스에 담긴 이벤트가,
줄지어 카프카로 흘러가네.
좋아요도 주문도 비동기로 춤추고,
결제 실패엔 취소 도장 콱 찍히네.
운영자는 로그를 보며 중얼댄다, "재시도는 어디에?"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 주요 변경인 이벤트 기반 아키텍처 도입을 직접적으로 요약하며 PR 내용과 일치한다.
Description check ✅ Passed 필수 섹션과 결정 사유가 모두 포함되어 있어 템플릿 요구사항을 대부분 충족한다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 win

OutboxJpaRepository.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에서 발견된 것과 유사하게 OrderEventsConsumerconsume()에도 예외 처리 결함이 있을 경우, 현재 테스트 스위트로는 이를 검출할 수 없다. 또한 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 win

catalog-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 | 🔵 Trivial

event_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회 이내 재시도가 소진되면 아웃박스 릴레이가 실패로 처리되어(OutboxRelaySchedulerkafkaTemplate.send(...).get() 동기 호출과 결합 시) 불필요한 아웃박스 재시도/알림이 발생할 수 있다. retriesInteger.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 value

payload를 두 번 파싱한다(extractOutboxId, process 각각 readTree 호출).

기능상 문제는 없으나 불필요한 JSON 파싱 비용이 발생한다. consume()에서 한 번만 파싱한 JsonNodeprocess에 전달하는 방식으로 개선할 수 있다.

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 win

CouponFacade의 발급 검증을 공통화하라

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 | 🔵 Trivial

202 응답에 상관관계 식별자가 없다 - 폴링 전략 확인이 필요하다.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2f86538 and 575fc06.

📒 Files selected for processing (63)
  • .gitignore
  • CLAUDE.md
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java
  • apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/UserActionLoggingHandler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponIssueRequestEvent.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/like/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/like/LikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/like/UnlikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/outbox/OutboxRelayScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentRequestedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductViewedEvent.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/UserCouponServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/PaymentApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/event/UserActionLoggingHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/event/like/LikeEventHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxEventPublisherTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/event/outbox/OutboxRelaySchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/event/payment/PaymentEventHandlerTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.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/interfaces/consumer/CatalogEventsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventsConsumerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventsConsumerTest.java
  • modules/jpa/src/testFixtures/resources/docker-java.properties
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • modules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (1)
  • CLAUDE.md

Comment on lines +16 to +25
@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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 expanded

Repository: 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 -S

Repository: 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.

Comment on lines +13 to +14
long countByTemplateId(Long templateId);
boolean existsByMemberIdAndTemplateId(Long memberId, Long templateId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.yml

Repository: 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.yml

Repository: 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.java

Repository: 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

Comment on lines +22 to +25
@Override
public List<OutboxModel> findPendingBatch(int limit) {
return outboxJpaRepository.findByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING, limit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 -S

Repository: 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
done

Repository: 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 -S

Repository: 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
done

Repository: 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

Comment on lines +20 to +37
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +29 to +33
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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.

Comment on lines +51 to +63
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}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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 || true

Repository: 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.

Comment on lines +29 to +30
@Version
private Long version;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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/loopers

Repository: 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/resources

Repository: 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/resources

Repository: 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/resources

Repository: 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/consumer

Repository: 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/consumer

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 12499


실패한 이벤트는 예외를 삼키지 말고 재시도 경로로 넘겨야 한다
CatalogEventsConsumer.java:32-39OrderEventsConsumer.java:32-39MANUAL 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.

Comment on lines +32 to +40
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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/java

Repository: 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 -n

Repository: 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/java

Repository: 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/java

Repository: 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/java

Repository: 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 || true

Repository: loopers-labs/loop-pack-be-l2-vol4-java

Length of output: 9025


outboxId 추출과 payload 검증을 같은 실패 경로로 묶어야 한다.

  • extractOutboxId(record.value())try 밖에 있어 JSON 파싱 실패나 outboxId 누락 시 consume()의 로그와 처리 경로를 건너뛴다. 이 경우 원인 식별이 어렵고, 잘못된 레코드가 다시 들어와 적체를 만들 수 있다.
  • extractOutboxIdtry 안으로 옮기고, 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.

Comment on lines +42 to +65
@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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

필드 null-safety 부재 및 알 수 없는 eventType에 대한 DLQ/재시도 제한이 없다.

root.get("eventType"), data.get("productId")는 필드가 없으면 null을 반환하므로 .asText()/.asLong() 호출 시 NPE가 발생할 수 있다. 또한 알 수 없는 eventTypeIllegalArgumentException을 던지는데(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.

Comment on lines +51 to +60
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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 || true

Repository: 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
done

Repository: 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.

@SuHyun-git
SuHyun-git merged commit 96179c8 into loopers-labs:SuHyun-git Jul 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant