Skip to content

[volume-7] ApplicationEvent 분리 + Transactional Outbox + Kafka 기반 비동기 처리#270

Open
juoklee wants to merge 8 commits intoLoopers-dev-lab:juokleefrom
juoklee:round7
Open

[volume-7] ApplicationEvent 분리 + Transactional Outbox + Kafka 기반 비동기 처리#270
juoklee wants to merge 8 commits intoLoopers-dev-lab:juokleefrom
juoklee:round7

Conversation

@juoklee
Copy link
Copy Markdown

@juoklee juoklee commented Mar 26, 2026

📌 Summary

  • 배경: 기존 주문-결제 플로우는 모든 부가 로직(로깅, 좋아요 집계 등)이 하나의 트랜잭션에 몰려 있었고, 시스템 간 통신이 동기 호출로 결합되어 있었다. 선착순 쿠폰 발급은 동기 API로 처리되어 대량 트래픽 시 병목이 발생할 수 있었다.
  • 목표: ApplicationEvent로 인프로세스 부가 로직을 분리하고, Transactional Outbox + Kafka로 시스템 간 이벤트 파이프라인을 구축하며, Kafka 기반 선착순 쿠폰 비동기 발급을 구현한다.
  • 결과: 주문/결제/좋아요 이벤트가 AFTER_COMMIT으로 비동기 발행되고, Outbox Relay가 1초 주기로 Kafka에 동기 전송(send().get()). commerce-streamer에서 ProductMetrics 집계를 낙관적 락 + 재시도로 처리하며, 선착순 쿠폰은 Kafka Partition 기반 순차 처리로 락 경합을 최소화. 동시성 하드 테스트(100명 좋아요, 50건 주문, 200명 쿠폰) 전부 통과.

🧭 Context & Decision

문제 정의

  • 현재 동작: OrderFacade.createOrder() 안에서 주문 생성 + 재고 차감 + 쿠폰 사용 + 부가 로직이 하나의 트랜잭션으로 묶여 있음. 좋아요 집계는 5분 주기 스케줄러로만 동기화.
  • 문제: 부가 로직 실패가 핵심 트랜잭션을 롤백시킬 수 있고, 시스템 간 결합도가 높아 장애 전파 위험. 선착순 쿠폰은 동기 발급으로 대량 동시 요청 시 DB 락 경합이 심함.
  • 성공 기준: 이벤트 기반으로 관심사를 분리하고, Kafka를 통해 시스템 간 느슨한 결합을 달성하며, 모든 이벤트 처리가 멱등하고 동시성 안전해야 한다.

핵심 설계 결정

결정 1: 인프로세스 이벤트 vs Kafka 직접 발행

  • A. Kafka로만 이벤트 발행 — 단순하지만, 인프로세스 로깅/실시간 좋아요 반영에 불필요한 네트워크 홉
  • B. ApplicationEvent(인프로세스) + Kafka(시스템 간) 병행
  • 이유: 로깅, 실시간 좋아요 카운트 반영 같은 인프로세스 사이드이펙트는 ApplicationEvent로 즉시 처리. 시스템 간 데이터 파이프라인(집계, 쿠폰)은 Kafka로 비동기 전달. 관심사에 맞는 채널 분리.

결정 2: Kafka 발행 방식 — 직접 발행 vs Transactional Outbox

  • A. Facade에서 KafkaTemplate.send() 직접 호출 — 단순하지만, 비즈니스 TX 커밋 ↔ Kafka 발행 간 원자성 보장 불가
  • B. Transactional Outbox 패턴
  • 이유: 비즈니스 로직과 같은 트랜잭션에서 Outbox 테이블에 이벤트를 기록하면, TX 커밋 = 이벤트 기록이 원자적으로 보장됨. Relay가 polling하여 Kafka에 발행.
  • 트레이드오프: 최대 1초 지연(polling 주기). at-least-once 보장이므로 Consumer 측 멱등 처리 필수.

결정 3: Outbox Relay 발행 방식 — 동기 vs 비동기

  • A. KafkaTemplate.send() (비동기, fire-and-forget) — 빠르지만 발행 실패 감지 어려움
  • B. KafkaTemplate.send().get() (동기)
  • 이유: 안전성 우선. send().get()으로 Kafka ACK를 확인한 후에만 published=true로 마킹. 실패 시 다음 주기에 재시도. 순서 보장을 위해 실패 시 즉시 break.

결정 4: Consumer 멱등 처리 — eventId 발급 방식

  • A. UUID 별도 생성
  • B. Outbox PK 재사용
  • 이유: 추가 필드 없이 이미 unique한 Outbox ID를 Kafka 헤더로 전달. Consumer가 event_handled 테이블에서 중복 체크.

결정 5: 동시성 제어 — 비관적 락 vs 낙관적 락

  • 쿠폰 발급: 비관적 락(findByIdForUpdate) ✅ — 수량 초과는 절대 허용 불가, 정합성 최우선
  • ProductMetrics 집계: 낙관적 락(@Version) + 재시도 ✅ — 충돌 시 데이터 유실 없이 재시도로 해결, 읽기 성능 유지
  • 이유: 쿠폰은 "100장 한정"이라는 hard limit이 있어 비관적 락이 적합. 집계는 카운터 증감이므로 충돌 빈도가 낮고, 재시도 비용이 락 대기 비용보다 작음.

결정 6: 선착순 쿠폰 — 동기 API vs Kafka 비동기

  • A. 동기 API에서 바로 발급 — 단순하지만 대량 동시 요청 시 DB 락 경합으로 타임아웃
  • B. API는 202 Accepted + Kafka로 순차 처리
  • 이유: 같은 couponId를 Partition Key로 사용하면 같은 Partition으로 라우팅되어 Consumer가 순차 처리. 락 경합이 원천적으로 감소. 사용자는 requestId로 polling하여 결과 확인.

🏗️ Design Overview

변경 범위

  • commerce-api: ApplicationEvent 발행 + Outbox 기록 + 선착순 쿠폰 API/Consumer
  • commerce-streamer: Kafka Consumer + ProductMetrics 집계 (독립 도메인 모델)
  • modules/kafka: Producer 설정 강화 (acks=all, enable.idempotence=true)

주요 컴포넌트 책임

컴포넌트 레이어 책임
OrderCreatedEvent 등 6개 Domain 인프로세스 이벤트 객체 (Java record)
OrderEventListener 등 4개 Application 이벤트 수신 → 로깅/실시간 반영
OutboxEventPublisher Domain Interface Outbox 기록 추상화 (DIP)
OutboxEventPublisherImpl Infrastructure JSON 직렬화 + DB 저장
OutboxRelayScheduler Infrastructure 1초 주기 polling → Kafka 동기 발행
MetricsAggregator Application (streamer) ProductMetrics 집계 + 낙관적 락 재시도
CatalogEventConsumer Interfaces (streamer) Kafka 수신 → MetricsAggregator 위임
CouponIssueFacade Application 비동기 쿠폰 발급 요청/처리/조회
CouponIssueConsumer Interfaces Kafka 수신 → 쿠폰 발급 처리

Kafka Topic 설계

Topic Partition Key Producer Consumer 용도
order-events orderId commerce-api commerce-streamer 주문/결제 집계
catalog-events productId commerce-api commerce-streamer 좋아요 집계
coupon-issue-requests couponId commerce-api commerce-api 선착순 쿠폰 발급

Outbox → Kafka 흐름

[비즈니스 TX]
  ├── 핵심 로직 (주문/결제/좋아요)
  ├── outbox_event INSERT (published=false)
  └── ApplicationEvent 발행 (인프로세스)

[OutboxRelayScheduler — 1초 주기]
  ├── SELECT unpublished ORDER BY created_at LIMIT 100
  ├── KafkaTemplate.send().get() — 동기 발행
  ├── 성공 → published=true, published_at 기록
  └── 실패 → break (다음 주기에 재시도, 순서 보장)

🔁 Flow Diagram

1. 주문 생성 → 이벤트 발행 → 집계

sequenceDiagram
    autonumber
    participant Client
    participant API as OrderFacade
    participant DB as commerce-api DB
    participant Outbox as OutboxRelay
    participant Kafka
    participant Streamer as MetricsAggregator

    Client->>API: POST /orders
    API->>DB: Order 저장 + 재고 차감 + Outbox INSERT
    API-->>Client: 201 Created

    Note over API: ApplicationEvent 발행 (AFTER_COMMIT)
    API->>API: OrderEventListener (로깅)

    Outbox->>DB: SELECT unpublished events
    Outbox->>Kafka: send("order-events", orderId, payload).get()
    Outbox->>DB: published=true

    Kafka->>Streamer: ORDER_CREATED 메시지
    Streamer->>Streamer: eventId 중복 체크
    Streamer->>DB: ProductMetrics.orderCount++, salesAmount+=
    Streamer->>DB: event_handled INSERT
    Streamer->>Kafka: manual ack
Loading

2. 좋아요 토글 → 실시간 반영 + Kafka 집계

sequenceDiagram
    autonumber
    participant Client
    participant API as LikeFacade
    participant DB as commerce-api DB
    participant Listener as LikeEventListener
    participant Outbox as OutboxRelay
    participant Kafka
    participant Streamer as MetricsAggregator

    Client->>API: POST /likes/products/{id}
    API->>DB: Like 토글 + Outbox INSERT
    API-->>Client: 200 OK

    Note over API: AFTER_COMMIT
    API->>Listener: LikeToggledEvent
    Listener->>DB: Product.likeCount 실시간 증감

    Outbox->>Kafka: send("catalog-events", productId, payload).get()
    Kafka->>Streamer: LIKE_TOGGLED 메시지
    Streamer->>Streamer: 낙관적 락 재시도 (최대 20회)
    Streamer->>DB: ProductMetrics.likeCount 증감
Loading

3. 선착순 쿠폰 비동기 발급

sequenceDiagram
    autonumber
    participant Client
    participant API as CouponIssueFacade
    participant DB
    participant Outbox as OutboxRelay
    participant Kafka
    participant Consumer as CouponIssueConsumer

    Client->>API: POST /coupons/{id}/issue-async
    API->>DB: 중복 체크 (member_id + coupon_id)
    API->>DB: CouponIssueRequest(PENDING) + Outbox INSERT
    API-->>Client: 202 Accepted + requestId

    Outbox->>Kafka: send("coupon-issue-requests", couponId, payload).get()

    Kafka->>Consumer: 쿠폰 발급 메시지
    Consumer->>API: processIssuance(requestId)
    API->>DB: CouponIssueRequest 조회 (이미 처리됐으면 skip)
    API->>DB: Coupon findByIdForUpdate (비관적 락)

    alt 수량 남음
        API->>DB: MemberCoupon 발급 + request.complete()
    else 수량 소진
        API->>DB: request.fail("수량 소진")
    end

    Client->>API: GET /coupons/issue-requests/{requestId}
    API-->>Client: status=COMPLETED / PENDING / FAILED
Loading

🧪 동시성 통합 테스트 결과

1. ProductMetrics 집계 동시성 (MetricsAggregatorConcurrencyTest)

테스트 스레드 요청 수 기대값 결과
500명 동시 좋아요 50 500 likeCount=500 ✅ PASS
300건 주문 동시 집계 50 300 orderCount=300, salesAmount 정확 ✅ PASS
같은 eventId 3번 호출 1 3 likeCount=1 (멱등) ✅ PASS
  • 동시성 전략: @Version 낙관적 락 + TransactionTemplate + 최대 50회 재시도 (랜덤 백오프)
  • 핵심 해결: @TransactionalTransactionTemplate으로 전환하여 재시도 시 새 트랜잭션 시작
  • MAX_RETRIES 튜닝 과정: 20 스레드에서는 MAX_RETRIES=20으로 충분했으나, 50 스레드 경합에서 497/500 실패 발생 → MAX_RETRIES=50으로 상향하여 500/500 통과

2. 선착순 쿠폰 동시성 (CouponAsyncIssueConcurrencyTest)

테스트 스레드 요청 수 수량 기대값 결과
1000명 동시 발급 50 1000 100장 COMPLETED=100, FAILED=900 ✅ PASS
같은 requestId 2번 호출 1 2 - 1장만 발급 (멱등) ✅ PASS
  • 동시성 전략: findByIdForUpdate 비관적 락 + Kafka Partition Key(couponId) 순차 처리
  • 핵심 해결: @Transactional(noRollbackFor = CoreException.class)로 비즈니스 예외 시 트랜잭션 롤백 방지
  • 테스트 구조: 1차 50스레드 동시 burst → 2차 PENDING 잔여분 순차 재처리 (Kafka 재전달 시뮬레이션)

변경 목적

  • 모놀리식 트랜잭션에서 이벤트 처리 로직을 분리하여 결합도 감소 및 롤백 영향 범위 축소
  • Transactional Outbox 패턴으로 이벤트 발행의 원자성 보장
  • 쿠폰 발급 핫스팟의 DB 잠금 경합 완화를 위해 비동기 처리 도입

핵심 변경점

  1. ApplicationEvent + Outbox 이중 발행: 도메인 이벤트(AFTER_COMMIT, 비동기)로 로깅/실시간 업데이트, Outbox(동기)로 Kafka 발행
  2. OutboxRelayScheduler: 1초마다 미발행 이벤트 폴링 → KafkaTemplate.send().get()으로 동기 송신 → markPublished() 처리 (Outbox PK를 eventId 헤더로 전송)
  3. 쿠폰 비동기 발급: API는 202 Accepted 반환, CouponIssueRequest 저장, coupon-issue-requests 토픽(파티션 키=couponId)으로 발행, 컨슈머가 순차 처리(pessimistic lock)
  4. ProductMetrics 동시성 제어: @Version 필드로 낙관적 락, ObjectOptimisticLockingFailureException 발생 시 최대 50회 재시도(exponential backoff)
  5. idempotency 구현: commerce-streamer 컨슈머에서 eventId 기반 EventHandled 테이블 중복 체크

리스크/주의사항

  • Outbox Relay와 Kafka 송신의 동기화 문제: send().get()이 타임아웃되거나 실패 시 이벤트가 발행되지 않을 수 있음. 모니터링과 별도의 데드레터 처리 전략 필요
  • 낙관적 락 재시도는 최대 50회로 제한되어 있으나, 고동시성 상황에서 결국 실패할 수 있음. 거래 로직의 우선순위 재검토 필요
  • CouponIssueRequest의 중복 요청 체크(PENDING/COMPLETED 상태)는 낙관적 해결이므로, race condition 가능성 확인 필요

테스트/검증

  • CouponAsyncIssueConcurrencyTest: 1000개 동시 요청 vs 100개 제한 쿠폰 → 정확히 100개 COMPLETED, 나머지 FAILED 검증
  • MetricsAggregatorConcurrencyTest: 500개 동시 좋아요, 300개 동시 주문으로 정확한 집계 및 낙관적 락 재시도 동작 검증
  • LikeEventListener/OrderEventListener: AFTER_COMMIT 비동기 실행 및 좌표 업데이트 검증
  • Outbox Relay 및 컨슈머: eventId 헤더 전파 및 중복 무시 동작 확인 필요 (통합 테스트 권장)

juoklee added 8 commits March 24, 2026 20:08
- CouponIssueRequest 엔티티 및 상태 관리 (PENDING/COMPLETED/FAILED)
- CouponIssueFacade: 비동기 발급 요청, Consumer 처리, 상태 조회
- CouponIssueV1Controller: POST /issue-async (202), GET /issue-requests/{id}
- CouponIssueConsumer: Kafka Consumer (manual ack, 멱등 처리)
- CouponService.issueCoupon에 noRollbackFor 적용 (트랜잭션 공유 시 rollback 방지)
- 동시성 테스트 (200명 동시 요청 → 100장 한정 수량 보장)
- 멱등성 테스트 (중복 processIssuance 호출 시 1장만 발급)
- @transactional → TransactionTemplate 전환으로 재시도 루프 적용
- OptimisticLockingFailureException 발생 시 랜덤 백오프 재시도 (최대 20회)
- 좋아요 동시성 테스트 (100건 동시 → likeCount 정확히 100)
- 주문 동시성 테스트 (50건 동시 → orderCount, salesAmount 정합성 보장)
- 멱등성 테스트 (같은 eventId 중복 처리 방지)
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

📝 Walkthrough

Walkthrough

이벤트 기반 아키텍처를 도입하여 주문, 결제, 좋아요 등의 도메인 이벤트를 발행하고, Outbox 패턴으로 트랜잭션 경계를 관리한다. 쿠폰 비동기 발급 시스템을 추가하고, commerce-streamer에서 Kafka 이벤트를 소비하여 상품 메트릭을 집계한다.

Changes

Cohort / File(s) Summary
Outbox 패턴 기초
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java
Outbox 패턴 구현으로 이벤트 발행 트랜잭션 일관성 보장. OutboxRelayScheduler는 고정 1초 주기로 미발행 이벤트 100개를 Kafka로 릴레이하며, JSON 직렬화 실패 시 처리 누락 위험 존재.
쿠폰 비동기 발급 시스템
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestReader.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/...
쿠폰 발급 요청을 PENDING/COMPLETED/FAILED 상태로 관리하며, 중복 발급 방지. CouponService.issueCoupon은 CoreException 발생 시 롤백하지 않도록 변경하여, 재시도 안전성과 상태 일관성 확보.
주문/결제 이벤트 발행
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
OrderFacade와 PaymentFacade에 ApplicationEventPublisher와 OutboxEventPublisher 주입. 주문 생성/취소 및 결제 완료/실패 시 이벤트와 Outbox 레코드 동시 발행으로 트랜잭션 경계 관리.
좋아요 이벤트 기반 구조
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/event/LikeToggledEvent.java, apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventListener.java
LikeFacade에서 LikeToggledEvent 발행 추가. LikeEventListener는 상품/브랜드별로 ProductService/BrandService의 증감 메서드 호출. 이벤트 발행과 리스너 실행 사이의 네트워크 지연으로 인한 일관성 문제 가능성 점검 필요.
도메인 이벤트 정의
apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java
레코드 기반 불변 이벤트 정의. OrderCreatedEvent의 OrderItemSnapshot으로 주문 생성 시점의 상품 정보 보존.
이벤트 리스너
apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java, apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java, apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventListener.java
@TransactionalEventListener(phase = AFTER_COMMIT)와 @Async("eventExecutor")로 트랜잭션 커밋 후 비동기 처리. 현재 로깅만 수행하므로, 실제 부수 효과 처리 시 재시도 및 데드레터 큐 전략 필요.
쿠폰 비동기 발급 API 및 컨슈머
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java, apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueInfo.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java, apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
requestAsyncIssuance는 쿠폰-회원 중복 요청 검사 후 PENDING 상태로 저장 후 이벤트 발행. processIssuance는 Kafka 컨슈머에서 호출되어 실제 발급 수행. CouponIssueConsumer의 unwrapPayload 메서드는 타입 추론에 의존하므로, 메시지 형식 변경 시 예상치 못한 장애 가능성.
비동기 설정
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java, apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java
@EnableAsync와 eventExecutor 빈(core 4, max 8, queue 100) 추가. 이벤트 리스너와 컨슈머가 공유하는 스레드풀이므로, 한쪽의 과부하가 다른 쪽의 처리를 지연시킬 수 있으므로 모니터링 필요.
Kafka 의존성 및 설정
apps/commerce-api/build.gradle.kts, modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java, modules/kafka/src/main/resources/kafka.yml
Kafka 모듈 의존성 추가. SINGLE_LISTENER 팩토리 빈으로 메시지 단위 처리. producer.acks=all과 enable.idempotence=true로 전송 신뢰성 강화하되, 처리량 감소에 대한 성능 영향 검증 필요.
commerce-streamer 메트릭 수집
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java
MetricsAggregator는 이벤트 중복 처리 방지(eventId 기반) 및 낙관적 락 재시도(최대 50회)로 동시성 보장. 이벤트 처리 실패 시 EventHandled 미기록으로 재처리되지만, 최대 재시도 초과 시 메트릭 손실 가능성.
commerce-streamer 이벤트 컨슈머
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
catalog-events와 order-events 토픽을 단일 메시지 수신 모드로 처리. 페이로드 포맷이 JSON 객체 키 기반 분기(targetType, items, paymentId)이므로, 예상 필드 부재 시 NPE 가능.
commerce-streamer 설정 및 제거
apps/commerce-streamer/src/main/resources/application.yml, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
애플리케이션명을 commerce-api에서 commerce-streamer로 수정. demo-kafka 설정 제거 및 DemoKafkaConsumer 삭제로 테스트 인프라 정리.
쿠폰 발급 테스트
apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java, apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueF acadeTest.java
1000개 요청 중 100개만 발급되는 제한 쿠폰 시나리오와 중복 호출 시 1개만 발급되는 멱등성 검증. 하지만 Kafka 재전송 시뮬레이션이 동기 호출이므로, 실제 비동기 재전송 시나리오 커버 필요.
이벤트 리스너 테스트
apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.java
LikeEventListener의 좋아요 증감 로직 검증. 대상 타입(PRODUCT/BRAND)별 분기 처리 확인.
메트릭 수집 테스트
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java, apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java
500개 동시 좋아요 요청과 300개 주문 이벤트 집계의 정확성 및 중복 이벤트 멱등성 검증.
컨슈머 테스트
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java, apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java
토픽별 메시지 파싱 및 MetricsAggregator 호출 검증.
기존 테스트 수정
apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java, apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
이벤트 리스너 비동기 처리를 위해 sleep(500-1000ms) 추가. LikeV1ApiE2ETest는 이전 스케줄러 동기화 대기 방식에서 이벤트 처리 비동기 대기로 변경. PaymentFacadeTest에는 이벤트 발행자 목업 추가.
좋아요 스케줄러
apps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.java
동기화 주기를 300초에서 600초로 변경. 이벤트 기반 처리로의 전환으로 인한 스케줄러 용도 재정의 필요.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Commerce API
    participant ApplicationEventPublisher
    participant OutboxEventPublisher
    participant Database
    participant OutboxRelayScheduler
    participant Kafka

    Client->>Commerce API: POST /api/v1/coupons/{id}/issue-async
    Commerce API->>Database: Check duplicate request
    Commerce API->>Database: Save CouponIssueRequest (PENDING)
    Commerce API->>ApplicationEventPublisher: Publish (in-memory event)
    Commerce API->>OutboxEventPublisher: Publish (OutboxEvent entity)
    OutboxEventPublisher->>Database: Save OutboxEvent (published=false)
    Commerce API-->>Client: 202 ACCEPTED
    
    OutboxRelayScheduler->>Database: Query unpublished events
    Database-->>OutboxRelayScheduler: Return OutboxEvent list
    OutboxRelayScheduler->>Kafka: Send to coupon-issue-requests topic
    Kafka-->>OutboxRelayScheduler: Ack
    OutboxRelayScheduler->>Database: Mark OutboxEvent published=true
Loading
sequenceDiagram
    participant Kafka
    participant CouponIssueConsumer
    participant CouponIssueFacade
    participant CouponService
    participant ApplicationEventPublisher
    participant Commerce Streamer
    participant Database

    Kafka->>CouponIssueConsumer: Message (requestId)
    CouponIssueConsumer->>CouponIssueFacade: processIssuance(requestId)
    CouponIssueFacade->>Database: Load CouponIssueRequest
    CouponIssueFacade->>CouponService: issueCoupon(couponId, memberId)
    CouponService->>Database: Update coupon quantity & create MemberCoupon
    Database-->>CouponService: MemberCouponId
    CouponService-->>CouponIssueFacade: Success
    CouponIssueFacade->>Database: Mark request COMPLETED
    CouponIssueFacade->>ApplicationEventPublisher: Publish event (async)
    ApplicationEventPublisher->>Commerce Streamer: Event delivered (async)
    CouponIssueConsumer->>Kafka: Acknowledge
Loading
sequenceDiagram
    participant Kafka
    participant OrderEventConsumer
    participant MetricsAggregator
    participant EventHandledRepository
    participant ProductMetricsRepository
    participant Database

    Kafka->>OrderEventConsumer: ORDER_CREATED (eventId, items)
    OrderEventConsumer->>MetricsAggregator: handleOrderCreated(eventId, items)
    MetricsAggregator->>EventHandledRepository: Check existsByEventId
    alt Event already handled (idempotent)
        EventHandledRepository-->>MetricsAggregator: true
        MetricsAggregator->>Database: No update (return early)
    else New event
        EventHandledRepository-->>MetricsAggregator: false
        loop For each OrderItemInfo
            MetricsAggregator->>ProductMetricsRepository: findByProductId
            alt Metrics exist
                ProductMetricsRepository-->>MetricsAggregator: ProductMetrics
                MetricsAggregator->>Database: Update orderCount, salesAmount
            else Create new
                MetricsAggregator->>Database: Create & save ProductMetrics
            end
        end
        MetricsAggregator->>EventHandledRepository: Save EventHandled
        EventHandledRepository->>Database: Insert record
    end
    OrderEventConsumer->>Kafka: Acknowledge offset
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 이 변경의 핵심인 ApplicationEvent 분리, Transactional Outbox 패턴, Kafka 기반 비동기 처리를 명확히 요약하고 있다.
Description check ✅ Passed PR 설명은 템플릿의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 포함하고, 설계 결정 배경, 트레이드오프, 동시성 테스트 결과까지 상세히 기술하고 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (11)
apps/commerce-streamer/src/main/resources/application.yml-17-17 (1)

17-17: ⚠️ Potential issue | 🟡 Minor

애플리케이션 이름 변경에 따른 관측 체계 연동 확인이 필요하다

Line 17 변경은 로그 인덱스, 메트릭 라벨, 알림 룰 필터 조건에 직접 영향을 주어 장애 탐지 누락을 만들 수 있다. 운영 관점에서 서비스명 변경 영향 범위를 배포 체크리스트에 명시하고, 대시보드/알림 쿼리를 함께 갱신해야 한다.
추가 테스트로 dev 환경 스모크 테스트에서 service=commerce-streamer 기준 메트릭 수집/알림 트리거가 정상 동작하는지 검증을 권장한다.

As per coding guidelines **/application*.yml: "운영에 영향을 주는 설정 변경은 근거와 영향 범위를 요구한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-streamer/src/main/resources/application.yml` at line 17, 앱 설정에서
name: commerce-streamer 변경은 로그/메트릭/알림 쿼리의 서비스 라벨에 영향을 주므로, application*.yml의
name 항목 변경 사유와 영향 범위를 배포 체크리스트(운영 문서)에 명시하고 관련 대시보드/알림 룰의 쿼리(예:
service=commerce-streamer 라벨 필터), 로그 인덱스 및 메트릭 라벨을 찾아 모두 갱신하세요; 변경 후 dev 환경 스모크
테스트에서 service=commerce-streamer 기준으로 메트릭 수집과 알림 트리거가 정상 동작하는지 검증하고(테스트 절차와 결과를
체크리스트에 첨부), 변경 내역을 application*.yml 운영 영향 근거로 문서화해 승인을 받으세요.
apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java-5-13 (1)

5-13: ⚠️ Potential issue | 🟡 Minor

이벤트 payload의 리스트를 방어적으로 복사해야 한다.

현재 items가 외부에서 넘긴 리스트 참조를 그대로 보존한다. 발행 직후 원본 컬렉션이 수정되면 outbox 직렬화와 비동기 리스너가 서로 다른 주문 스냅샷을 읽을 수 있어 운영에서 재현하기 어려운 불일치가 생긴다. canonical constructor에서 List.copyOfObjects.requireNonNull로 고정하고, 원본 리스트 변경이 event에 반영되지 않는지와 null 입력이 거부되는지를 검증하는 테스트를 추가하는 편이 안전하다.

🔧 수정안
 import java.util.List;
+import java.util.Objects;
 
 public record OrderCreatedEvent(
     Long orderId,
     Long memberId,
     long totalAmount,
     List<OrderItemSnapshot> items
 ) {
+    public OrderCreatedEvent {
+        orderId = Objects.requireNonNull(orderId, "orderId");
+        memberId = Objects.requireNonNull(memberId, "memberId");
+        items = List.copyOf(Objects.requireNonNull(items, "items"));
+    }
+
     public record OrderItemSnapshot(Long productId, String productName, long price, int quantity) {}
 }

As per coding guidelines, **/*.java: null 처리, 방어적 복사, 불변성을 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java`
around lines 5 - 13, OrderCreatedEvent currently stores the external items list
reference; change the canonical constructor for OrderCreatedEvent to defensively
copy and null-check the items by wrapping the incoming items with
Objects.requireNonNull(...) and List.copyOf(...), ensuring the stored field is
an unmodifiable copy (leave OrderItemSnapshot as-is), and add unit tests that
assert modifying the original list after constructing OrderCreatedEvent does not
change the event's items and that passing null for items throws a
NullPointerException.
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-17-27 (1)

17-27: ⚠️ Potential issue | 🟡 Minor

로깅에 민감 정보가 포함될 수 있다.

memberIdamount가 로그에 출력된다. 코딩 가이드라인에 따르면 "로깅 시 민감정보 노출 가능성을 점검"해야 한다.

운영 환경에서:

  1. memberId는 사용자 식별자로 GDPR/개인정보보호법 대상일 수 있다
  2. amount는 금융 정보로 PCI-DSS 등 규정 대상일 수 있다
  3. reason 필드에 사용자 입력이 포함될 경우 로그 인젝션 위험이 있다

로그 레벨을 DEBUG로 변경하거나, 프로덕션에서는 마스킹 처리를 적용하는 것을 권장한다. 또한 로그 집계 시스템의 접근 제어 정책을 확인해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java`
around lines 17 - 27, The logs in handlePaymentCompleted and handlePaymentFailed
currently emit potentially sensitive fields (memberId, amount, reason) from
PaymentCompletedEvent and PaymentFailedEvent; change the logging to avoid raw
sensitive data by either lowering the log level to DEBUG for these handlers
(handlePaymentCompleted, handlePaymentFailed) and/or replace memberId and amount
with masked/aggregated values (e.g., hash or partial mask) and sanitize/escape
reason to prevent log injection before passing to log.info; ensure
masking/sanitization happens in the event-to-log path so production logging
never emits full identifiers or raw user-provided text.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java-26-32 (1)

26-32: ⚠️ Potential issue | 🟡 Minor

예외 처리가 프로젝트 패턴과 불일치한다.

학습된 가이드라인에 따르면, 이 프로젝트는 CoreException을 통한 통합 에러 핸들링을 사용한다. IllegalArgumentException을 직접 던지면 ApiControllerAdvice에서 일관된 응답 포맷으로 처리되지 않을 수 있다.

또한 직렬화 실패는 개발자가 디버깅해야 할 상황이므로, 예외 발생 전에 payload 타입/내용을 로깅하면 운영 시 원인 분석이 용이하다.

🔧 수정 제안
     private String serialize(Object payload) {
         try {
             return objectMapper.writeValueAsString(payload);
         } catch (JsonProcessingException e) {
-            throw new IllegalArgumentException("Outbox 이벤트 직렬화 실패", e);
+            log.error("Outbox 이벤트 직렬화 실패 - payloadType={}", payload.getClass().getSimpleName(), e);
+            throw new CoreException(ErrorCode.INTERNAL_SERVER_ERROR, "Outbox 이벤트 직렬화 실패", e);
         }
     }

Based on learnings: "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java`
around lines 26 - 32, In OutboxEventPublisherImpl.serialize(Object payload)
replace throwing IllegalArgumentException with wrapping the
JsonProcessingException into the project's unified CoreException so
ApiControllerAdvice can handle it; before rethrowing, log the payload type and a
safe representation of its content (using the existing logger) to aid debugging,
and include the original JsonProcessingException as the cause when constructing
the CoreException so the stack trace is preserved.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java-11-16 (1)

11-16: ⚠️ Potential issue | 🟡 Minor

복합 인덱스 전략을 재검토해야 한다.

현재 CouponIssueRequest 엔티티에는 idx_member_coupon (member_id, coupon_id) 인덱스가 정의되어 있고, request_idunique=true 제약으로 인덱스가 암묵적으로 생성된다. 다만 existsByMemberIdAndCouponIdAndStatusIn() 메서드는 세 컬럼(memberId, couponId, status)에 필터를 적용하는데, 현재 인덱스는 처음 두 컬럼만 포함한다.

수정안: idx_member_coupon를 확장하여 status 컬럼을 추가하면 쿼리 성능을 개선할 수 있다.

`@Index`(name = "idx_member_coupon_status", columnList = "member_id, coupon_id, status")

현재 설계상 select-then-act 패턴으로 동시성 중복 방지는 미뤄진 상태이므로, 검색 성능은 매우 중요한 부분이다. 마이그레이션에서 인덱스 생성을 확인하고, 필요시 실제 쿼리 실행 계획(EXPLAIN)으로 성능을 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java`
around lines 11 - 16, The composite index on CouponIssueRequest is missing the
status column used by existsByMemberIdAndCouponIdAndStatusIn(Long memberId, Long
couponId, List<CouponIssueRequestStatus> statuses), causing suboptimal query
plans; update the entity's index definition (replace or extend
idx_member_coupon) to include status (e.g., change to idx_member_coupon_status
with columnList "member_id, coupon_id, status") and add a migration to create
that index, then validate with EXPLAIN that queries for the repository method
use the new index.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java-80-82 (1)

80-82: ⚠️ Potential issue | 🟡 Minor

addSalesAmount에 음수 검증이 없어 데이터 무결성 위험이 있다.

amount가 음수로 전달될 경우 salesAmount가 감소하여 잘못된 집계 결과가 발생할 수 있다. 환불 등의 케이스가 아니라면 방어 로직 추가를 권장한다.

🛡️ 음수 검증 추가
 public void addSalesAmount(long amount) {
+    if (amount < 0) {
+        throw new IllegalArgumentException("salesAmount cannot be negative: " + amount);
+    }
     this.salesAmount += amount;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 80 - 82, The addSalesAmount method in ProductMetrics currently
allows negative values which can corrupt salesAmount; update
ProductMetrics.addSalesAmount to validate that the incoming amount is
non-negative (amount >= 0) and reject negatives (e.g., throw
IllegalArgumentException with a clear message including the invalid amount)
before updating the salesAmount field to enforce data integrity.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java-46-48 (1)

46-48: ⚠️ Potential issue | 🟡 Minor

로그 메시지가 실제 발행 건수를 정확히 반영하지 않는다.

예외 발생 시 break로 루프를 중단하지만, 로그는 events.size()(조회 건수)를 출력한다. 실제로 성공한 건수와 불일치하여 모니터링/알림 지표로 사용하기 어렵다.

♻️ 성공 건수 별도 추적
+    int relayedCount = 0;
     for (OutboxEvent event : events) {
         try {
             // ... send logic ...
             event.markPublished();
+            relayedCount++;
         } catch (Exception e) {
             // ... error handling ...
             break;
         }
     }

-    if (!events.isEmpty()) {
-        log.info("Outbox relay 완료: {}건", events.size());
+    if (relayedCount > 0) {
+        log.info("Outbox relay 완료: {}건 (전체 {}건 중)", relayedCount, events.size());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java`
around lines 46 - 48, The log currently prints events.size() which is the
fetched batch size, not the number actually published; change the loop in
OutboxRelayScheduler (the method that iterates over events and may break on
exception) to maintain a local counter (e.g., successCount) that you increment
only when an event is successfully relayed, and log that successCount (and
optionally total attempted) instead of events.size(); also ensure you log the
current successCount before breaking on exception so the recorded number
reflects actual successful publishes for the batch.
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java-88-88 (1)

88-88: ⚠️ Potential issue | 🟡 Minor

주석과 실제 값 불일치

주석에 "50건 주문 동시 집계"라고 되어 있으나, 실제 orderCount는 300이다.

-        // when — 50건 주문 동시 집계
+        // when — 300건 주문 동시 집계
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java`
at line 88, The inline comment in MetricsAggregatorConcurrencyTest says "50건 주문
동시 집계" but the test uses orderCount = 300, so make them consistent: either
change the comment to reflect 300 orders or set the orderCount variable (symbol:
orderCount in class MetricsAggregatorConcurrencyTest) to 50; update the comment
near the concurrent aggregation test and ensure any related assertions or setup
that depend on orderCount are adjusted accordingly.
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java-48-48 (1)

48-48: ⚠️ Potential issue | 🟡 Minor

주석과 실제 값 불일치

주석에 "100명이 동시에 좋아요"라고 되어 있으나, 실제 requestCount는 500이다. 주석이 코드와 일치하지 않으면 유지보수 시 혼란을 야기한다.

-        // when — 100명이 동시에 좋아요
+        // when — 500명이 동시에 좋아요
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java`
at line 48, The comment in MetricsAggregatorConcurrencyTest says "100명이 동시에 좋아요"
but the test uses requestCount = 500, causing mismatch; either update the
comment to reflect 500 (e.g., "500명이 동시에 좋아요") or change the requestCount to 100
so the comment and code match—locate the requestCount variable in the test
method (or class) and make the comment and the constant value consistent.
apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java-74-74 (1)

74-74: ⚠️ Potential issue | 🟡 Minor

주석과 실제 값 불일치

주석에 "200명이 동시에"라고 되어 있으나, 실제 requestCount는 1000이다.

-        // when — 1차: 200명이 동시에 processIssuance 호출 (동시성 경합 시뮬레이션)
+        // when — 1차: 1000명이 동시에 processIssuance 호출 (동시성 경합 시뮬레이션)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java`
at line 74, The comment in CouponAsyncIssueConcurrencyTest says "200명이 동시에" but
the test uses requestCount = 1000; update the comment to match the actual value
or change requestCount to 200 so they are consistent—locate the requestCount
variable in the test method (or class-level declaration) and either edit the
Korean comment to "1000명이 동시에" or set requestCount to 200, and ensure any
related explanatory text referencing concurrency matches the chosen value.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java-63-68 (1)

63-68: ⚠️ Potential issue | 🟡 Minor

unwrapPayload() 메서드의 비효율적 직렬화 패턴 개선 필요

현재 구현은 OutboxEventPublisherImpl에서 payload를 문자열로 직렬화한 후, 다시 Kafka JsonSerializer로 이중 인코딩되고 있다. 수신 측에서 readValue(bytes, String.class)readTree() 순서로 처리하는 것은 문자열 래핑을 풀어내고 다시 파싱하는 비효율적인 과정이다.

운영 문제: 불필요한 직렬화/역직렬화 단계가 CPU 사용량을 증가시키고, 특히 고처리량 환경에서 성능 저하를 초래한다.

개선 방안:

  • unwrapPayload()에서 바이트를 직접 JsonNode로 파싱하도록 변경: return objectMapper.readTree(bytes);
  • 또는 페이로드 저장 시 이중 인코딩 제거 검토 필요

OrderEventConsumer, CouponIssueConsumer 등 다른 consumer도 동일 패턴을 사용하므로 일괄 최적화를 권장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 63 - 68, The unwrapPayload(Object value) method currently
deserializes bytes into a String then reparses—replace this with direct JSON
parsing to avoid double-encoding: in unwrapPayload(Object value) (and the same
method in OrderEventConsumer, CouponIssueConsumer, etc.) detect byte[] and call
objectMapper.readTree(bytes) to return a JsonNode (or otherwise parse bytes
directly to the target JSON type) instead of objectMapper.readValue(bytes,
String.class); update the method return type and downstream callers accordingly
or harmonize payload handling so the consumer works with JsonNode directly.
🧹 Nitpick comments (23)
apps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.java (1)

28-28: 스케줄 주기를 설정값으로 외부화하는 것이 운영 대응에 유리하다

Line 28의 고정값(600_000ms)은 운영 중 지연 증가나 DB 부하 상황에서 즉시 조정이 어려워 재배포 비용을 유발한다. 설정 기반으로 전환하면 환경별 튜닝과 장애 대응 속도가 개선된다.

수정안 diff
-    `@Scheduled`(fixedRate = 600_000)
+    `@Scheduled`(fixedRateString = "${like.sync.fixed-rate-ms:600000}")

추가 테스트로 @SpringBootTest(properties = "like.sync.fixed-rate-ms=1000") 환경에서 스케줄 트리거 주기가 오버라이드되는지 검증하는 통합 테스트를 권장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.java`
at line 28, The fixed 600_000ms in the `@Scheduled` annotation of
LikeCountSyncScheduler should be externalized to a configuration property so
operators can change the schedule without redeploying; replace the hardcoded
fixedRate with a property reference (e.g., use fixedRateString =
"${like.sync.fixed-rate-ms:600000}") in the `@Scheduled` on LikeCountSyncScheduler
and add a default fallback value, update application.yml/application.properties
to document like.sync.fixed-rate-ms, and add an integration test using
`@SpringBootTest`(properties = "like.sync.fixed-rate-ms=1000") to assert the
scheduler triggers with the overridden interval.
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java (1)

5-6: 문자열 중심 publish 시그니처는 라우팅 오타를 운영에서만 드러내는 구조다.

현재 형태는 aggregateType/eventType/topic/partitionKey 오타가 컴파일 단계에서 차단되지 않아, 실제 운영에서만 이벤트 유실이나 오분배로 드러날 수 있다.
수정안으로 이벤트 메타데이터를 값 객체(또는 enum)로 묶어 타입 안정성을 높이는 편이 안전하다.
추가 테스트로 잘못된 이벤트 타입/토픽이 생성 단계에서 차단되는 단위 테스트와, 정상 메타데이터만 Outbox 저장되는 계약 테스트를 추가하는 것이 좋다.

제안 diff
 public interface OutboxEventPublisher {
-
-    void publish(String aggregateType, Long aggregateId, String eventType,
-                 String topic, String partitionKey, Object payload);
+    void publish(OutboxPublishCommand command);
 }
+
+public record OutboxPublishCommand(
+    String aggregateType,
+    Long aggregateId,
+    String eventType,
+    String topic,
+    String partitionKey,
+    Object payload
+) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java`
around lines 5 - 6, The current publish(String aggregateType, Long aggregateId,
String eventType, String topic, String partitionKey, Object payload) API is
string-centric and allows routing typos to slip into production; change the
contract on OutboxEventPublisher to accept a typed metadata value object (e.g.,
EventMetadata or OutboxEventMeta) or enums (e.g., EventType, EventTopic) instead
of raw strings for aggregateType/eventType/topic/partitionKey, update all
callers to construct and pass that metadata object, and adjust
storage/serialization paths to persist the metadata fields from the typed
object; also add unit tests that assert invalid enum/value-object construction
is rejected and a contract test ensuring only events with valid metadata are
stored by the Outbox repository.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java (1)

67-67: 비동기 전용 롤백 정책을 공유 도메인 서비스에 직접 두지 않는 편이 안전하다.

noRollbackFor = CoreException.class는 현재 비동기 발급 요청의 실패 상태 기록을 위해 필요한 정책인데, 이를 CouponService.issueCoupon()에 붙이면 다른 호출자도 동일한 커밋 규칙을 강제로 따라가게 된다. 운영 중 동기 발급 경로나 후행 검증이 추가되면 business 예외에서도 수량 차감만 커밋되는 부분 커밋으로 이어질 수 있으니, 비동기 전용 애플리케이션 서비스나 TransactionTemplate 쪽으로 분리하고 이 메서드는 기본 롤백 규칙을 유지하는 편이 안전하다. 추가 테스트로는 동기 호출과 비동기 호출 각각에서 CoreException 발생 시 동기 경로는 전체 롤백되고 비동기 요청 상태만 FAILED로 남는지 분리 검증해야 한다.

As per coding guidelines **/domain/**/*.java: 도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다, **/*Service*.java: 트랜잭션 경계(@Transactional) 위치와 전파, readOnly, 롤백 조건을 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java`
at line 67, The `@Transactional`(noRollbackFor = CoreException.class) on
CouponService.issueCoupon mixes an async-only rollback policy into a shared
domain service; remove that attribute from CouponService.issueCoupon to restore
default rollback behavior, and move the noRollbackFor handling into the async
issuance boundary (either a dedicated async application service or use
TransactionTemplate to execute the async-only flow with noRollbackFor =
CoreException.class). Update callers so synchronous paths call the domain method
with normal transactions, and add tests that assert CoreException causes full
rollback for the sync path but leaves only the async request marked FAILED for
the async path.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)

45-48: 이벤트 계약 문자열을 공용 상수로 고정하는 편이 안전하다.

aggregateType, eventType, topic이 발행 지점마다 문자열로 흩어지면 한쪽만 이름이 바뀌어도 운영에서는 조용히 메시지가 끊긴다. 공용 enum/상수로 승격하고, outbox 레코드 metadata를 검증하는 계약 테스트를 추가해 publisher와 consumer가 같은 값을 쓰도록 고정하는 편이 낫다.

🔧 수정안
-        outboxEventPublisher.publish("PRODUCT", productId, "LIKE_TOGGLED",
-            "catalog-events", String.valueOf(productId), productLikeEvent);
+        outboxEventPublisher.publish(OutboxContracts.Aggregate.PRODUCT, productId, OutboxContracts.EventType.LIKE_TOGGLED,
+            OutboxContracts.Topic.CATALOG_EVENTS, String.valueOf(productId), productLikeEvent);

OutboxContracts는 별도 파일에서 publisher와 consumer가 함께 참조하는 상수 또는 enum으로 정의하면 된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java`
around lines 45 - 48, Replace the inline strings used when publishing outbox
events with shared constants/enums and add a contract test to ensure publisher
and consumer agree on metadata; specifically, introduce a shared OutboxContracts
(or related enums like OutboxAggregateType, OutboxEventType, OutboxTopic) and
update the publish call in LikeFacade (the outboxEventPublisher.publish
invocation that currently uses "PRODUCT", "LIKE_TOGGLED", "catalog-events") to
use those constants, and add a contract test that loads OutboxContracts and
asserts the outbox record metadata (aggregateType/eventType/topic) matches the
expected constant values so publisher and consumer cannot drift.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java (1)

4-4: 사용되지 않는 import가 있다.

CouponIssueRequestRepository가 import되었으나 이 인터페이스에서 사용되지 않는다.

♻️ 수정 제안
 package com.loopers.infrastructure.coupon;
 
 import com.loopers.domain.coupon.CouponIssueRequest;
-import com.loopers.domain.coupon.CouponIssueRequestRepository;
 import com.loopers.domain.coupon.CouponIssueRequestStatus;
 import org.springframework.data.jpa.repository.JpaRepository;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java`
at line 4, Remove the unused import of CouponIssueRequestRepository from
CouponIssueRequestJpaRepository: the import statement for
com.loopers.domain.coupon.CouponIssueRequestRepository is not referenced
anywhere in the CouponIssueRequestJpaRepository class, so delete that import to
clean up unused dependencies and satisfy static analysis.
apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java (1)

29-35: ZonedDateTime.now() 직접 호출은 테스트 격리성을 저하시킨다.

정적 팩토리 메서드 내에서 ZonedDateTime.now()를 직접 호출하면 단위 테스트에서 시간 검증이 어렵다. 시간 관련 버그(예: 타임존 불일치로 인한 중복 처리)를 재현하기 어렵다.

운영 관점에서 handledAt 값이 서버 타임존에 의존하므로, 멀티 리전 배포 시 일관성 문제가 발생할 수 있다. Clock을 주입받거나 Instant/UTC 기반으로 저장하는 방안을 고려해야 한다.

♻️ Clock 주입 방식 제안
-    public static EventHandled of(String eventId, String eventType) {
+    public static EventHandled of(String eventId, String eventType, Clock clock) {
         EventHandled entity = new EventHandled();
         entity.eventId = eventId;
         entity.eventType = eventType;
-        entity.handledAt = ZonedDateTime.now();
+        entity.handledAt = ZonedDateTime.now(clock);
         return entity;
     }
+
+    public static EventHandled of(String eventId, String eventType) {
+        return of(eventId, eventType, Clock.systemUTC());
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java`
around lines 29 - 35, EventHandled.of currently calls ZonedDateTime.now()
directly which hurts testability and multi-region consistency; change the
factory to accept a Clock (e.g., EventHandled.of(String eventId, String
eventType, Clock clock)) or return/store an Instant in UTC (e.g., handledAt as
Instant.from(Instant.now(clock))). Update the EventHandled.of method, the
handledAt field type if necessary, and call Instant.now(clock) or
ZonedDateTime.now(clock).withZone(ZoneOffset.UTC) so tests can inject a fixed
Clock and production uses Clock.systemUTC() for consistent UTC timestamps.
apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.java (1)

18-92: 예외/경계 케이스 테스트가 누락되었다.

현재 테스트는 정상 흐름만 검증한다. 운영 환경에서 예기치 않은 입력으로 인한 장애를 방지하려면 다음 케이스를 추가하는 것이 좋다:

  1. LikeTargetType에 새로운 타입이 추가될 경우의 동작 (default/else 분기)
  2. targetId가 존재하지 않는 ID일 때 서비스 계층의 예외 처리

실제 listener가 @Async로 동작하므로, 예외 발생 시 로깅/알림 처리가 제대로 되는지도 통합 테스트에서 확인이 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.java`
around lines 18 - 92, Add unit tests for the missing edge cases: (1) create a
test that sends a LikeToggledEvent with an unexpected/unsupported LikeTargetType
(use null) to LikeEventListener.handleLikeToggled and assert the listener does
not call productService or brandService (verifyNoInteractions(productService,
brandService)) and does not throw; (2) create a test where productService (or
brandService) is stubbed to throw a not-found exception when
increaseLikeCount/decreaseLikeCount is invoked (e.g., doThrow(new
EntityNotFoundException()).when(productService).increaseLikeCount(10L)), call
handleLikeToggled with that targetId and verify the exception is handled (no
exception propagated) and that the appropriate service method was invoked
(verify(productService).increaseLikeCount(10L)) and no other interactions occur;
reference LikeToggledEvent, LikeTargetType, LikeEventListener.handleLikeToggled,
productService and brandService in the tests.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java (2)

56-59: ZonedDateTime.now() 대신 명시적 시간대 사용을 권장한다.

ZonedDateTime.now()는 JVM의 기본 시간대를 사용하므로, 서버 간 시간대 설정이 다를 경우 데이터 불일치가 발생할 수 있다. 운영 환경에서 여러 서버가 다른 시간대로 설정되어 있다면 createdAt 기준 정렬 및 조회 결과가 예상과 다를 수 있다.

♻️ UTC 사용 권장
 `@PrePersist`
 private void prePersist() {
-    this.createdAt = ZonedDateTime.now();
+    this.createdAt = ZonedDateTime.now(java.time.ZoneOffset.UTC);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java`
around lines 56 - 59, The prePersist method in OutboxEvent sets createdAt with
ZonedDateTime.now() which relies on the JVM default zone; change it to produce a
UTC timestamp explicitly (e.g., use ZonedDateTime.now(ZoneOffset.UTC) or
Instant.now() converted to ZonedDateTime/OffsetDateTime in UTC) so createdAt is
consistent across servers; update the prePersist method to assign createdAt
using the chosen UTC-based call and ensure any related
serialization/deserialization expectations for createdAt remain compatible.

17-27: 다중 인스턴스 확장 시 Outbox Relay 중복 발행 방지 방안 검토 필요

현재 단일 인스턴스로 운영 중이므로 즉시 문제는 아니지만, 향후 다중 인스턴스 배포로 확장할 경우 다음 위험이 발생한다.

운영 관점 문제점:

  • 여러 스케줄러 인스턴스가 동시에 findTop100ByPublishedFalse()로 미발행 이벤트를 조회하면, 동일 이벤트를 여러 인스턴스가 동시에 Kafka에 발행할 수 있다.
  • OutboxEvent에 @Version 필드가 없어 낙관적 잠금이 불가능하고, ShedLock 같은 분산 락도 미적용 상태다.
  • Consumer 측 멱등성(event_handled 테이블)이 2차 방어선이지만, 네트워크 비용과 consumer 부하를 고려하면 producer 측 중복 방지가 우선이다.

권장 수정안:
다중 인스턴스 확장 시 다음 중 하나를 선택하여 적용한다.

  • @Version 필드 추가 후 낙관적 잠금 구현 (OutboxEvent에 버전 컬럼 추가)
  • ShedLock 또는 분산 락 도입 (스케줄러 메서드에 @SchedulerLock 적용)

추가 테스트:
다중 인스턴스 배포 시 동일 이벤트에 대한 중복 발행 없음을 검증하는 통합 테스트 추가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java`
around lines 17 - 27, OutboxEvent 엔티티에서 다중 인스턴스 환경에서 중복 발행이 발생할 수 있으니 우선
OutboxEvent 클래스에 낙관적 락용 버전 필드(`@Version private Long version`)를 추가하고, 발행 로직(예:
findTop100ByPublishedFalse()를 호출하는 스케줄러/서비스)에서 이벤트를 소비·수정(예: published=true로
변경)할 때 JPA의 낙관적 락을 활용하도록 변경해 주세요; 대안으로 스케줄러 메서드에 분산 락(`@SchedulerLock`)을 적용하는
방법도 제시하니 OutboxEvent에 `@Version`을 추가하거나 스케줄러 메서드에 `@SchedulerLock`을 적용해 중복 발행을
방지하고, 이후 다중 인스턴스 통합 테스트를 추가해 동일 이벤트가 중복 발행되지 않음을 검증해 주세요.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (2)

50-54: updatedAt 타임스탬프도 명시적 UTC 사용을 권장한다.

OutboxEvent와 동일하게 ZonedDateTime.now()는 JVM 기본 시간대를 사용한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 50 - 54, The onUpdate() lifecycle method sets updatedAt using the
JVM default zone; change it to set updatedAt explicitly in UTC (e.g., use
ZonedDateTime.now(ZoneOffset.UTC) or Instant.now().atZone(ZoneOffset.UTC)) so
updatedAt matches the UTC behaviour used by OutboxEvent; update the onUpdate()
method accordingly to assign the UTC ZonedDateTime to the updatedAt field.

70-74: decrementLikeCount가 음수 방지 시 무시하는 방식은 운영 상 디버깅을 어렵게 할 수 있다.

likeCount가 0일 때 감소 요청이 들어오면 아무 동작 없이 무시된다. 이는 이벤트 순서 역전이나 중복 unlike 이벤트 발생 시 문제를 추적하기 어렵게 만든다. 최소한 로그를 남기거나, 호출자에게 실패 여부를 반환하는 것을 고려할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 70 - 74, The current ProductMetrics.decrementLikeCount silently
ignores attempts to decrement when likeCount is 0 which hides problems; change
decrementLikeCount to surface failures by returning a boolean (true if
decremented, false if not) and emit a warn-level log when a decrement was
attempted at zero (use the class logger in ProductMetrics and include contextual
identifiers like productId). Update the method signature and its callers to
handle the boolean result, and ensure the decrement path still decrements
this.likeCount when >0.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java (1)

30-31: failReason 컬럼에 길이 제한이 없어 예외 메시지가 길 경우 DB 오류 발생 가능성이 있다.

예외의 스택 트레이스나 긴 오류 메시지가 저장될 경우 DB 컬럼 크기 초과 오류가 발생할 수 있다. 길이 제한 또는 truncate 로직 적용을 권장한다.

♻️ 길이 제한 추가
-@Column(name = "fail_reason")
+@Column(name = "fail_reason", length = 500)
 private String failReason;

또는 fail() 메서드에서 truncate:

 public void fail(String failReason) {
     this.status = CouponIssueRequestStatus.FAILED;
-    this.failReason = failReason;
+    this.failReason = failReason != null && failReason.length() > 500 
+        ? failReason.substring(0, 500) : failReason;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java`
around lines 30 - 31, CouponIssueRequest의 필드 failReason에 길이 제한이 없어 긴 예외 메시지로 인해
DB 삽입 실패가 발생할 수 있으니, 해결책으로 CouponIssueRequest 클래스의 failReason 필드에 `@Column`(name =
"fail_reason", length = <saneLimit>)를 추가하거나 `@Lob를` 사용해 컬럼 타입을 변경하여 DB 제약을 맞추고,
동시에 fail() 메서드에서 전달되는 메시지를 안전하게 잘라내는 로직(예: substring 또는 Apache Commons
StringUtils.abbreviate를 사용해 최대 길이로 truncate)도 추가해 저장 전 길이를 보장하세요; 참조 대상: 클래스
CouponIssueRequest, 필드 failReason, 메서드 fail().
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java (2)

96-106: createRecord 헬퍼 메서드가 CatalogEventConsumerTest와 중복된다.

테스트 유틸리티 클래스로 추출하면 중복을 줄이고 일관성을 유지할 수 있다. 테스트 모듈 내 공통 헬퍼로 분리하는 것을 고려할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java`
around lines 96 - 106, The createRecord helper in OrderEventConsumerTest
duplicates the one in CatalogEventConsumerTest; extract it into a shared test
utility (e.g., TestRecordUtils or ConsumerTestUtils) as a public static method
(preserving signature createRecord(String topic, String jsonPayload, String
eventId)) and have both OrderEventConsumerTest and CatalogEventConsumerTest call
that utility method; update imports/usages in both test classes and ensure the
objectMapper reference is handled (pass it in, or create a local mapper inside
the utility) so the tests compile and no duplicate helper remains.

43-72: 예외 및 경계 조건 테스트가 누락되어 있다.

CatalogEventConsumerTest와 동일하게 다음 시나리오에 대한 테스트 추가를 권장한다:

  1. itemspaymentId도 없는 알 수 없는 메시지 타입 → log.warn 호출 및 정상 ack 검증
  2. items 배열 내 필수 필드(productId, price, quantity) 누락
  3. JSON 파싱 실패 시 예외 처리 및 ack 동작
  4. eventId 헤더 누락 시 fallback 값 생성 검증

이 테스트들은 운영 환경에서 비정상 메시지 유입 시 consumer의 안정성을 보장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java`
around lines 43 - 72, Add tests to OrderEventConsumerTest that cover the missing
edge cases: (1) send a message without items or paymentId and assert
orderEventConsumer.consume logs a warning (mock the logger) and ack. (2) send
payloads where items array elements are missing productId, price, or quantity
and assert consumer handles each case (either skips/logs) and still ack. (3)
simulate invalid JSON (malformed payload) passed to orderEventConsumer.consume
and assert parsing error is handled and ack is called. (4) send a record missing
the eventId header and assert orderEventConsumer generates a fallback eventId
(verify metricsAggregator.handleOrderCreated called with a non-null/generated id
or that the produced header is present) — use the existing helpers createRecord,
the ack mock, itemsCaptor, and verify interactions with metricsAggregator and
ack in each test.
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java (1)

37-50: 예외 상황에 대한 테스트가 누락되어 있다.

현재 정상 케이스(PRODUCT like/unlike, BRAND ignore)만 테스트하고 있다. 운영 환경에서 발생할 수 있는 다음 시나리오에 대한 테스트 추가를 권장한다:

  1. 잘못된 JSON 형식의 payload
  2. targetType, targetId 등 필수 필드 누락
  3. eventId 헤더 누락 시 동작

이러한 케이스가 ack.acknowledge() 호출 여부 및 예외 전파 방식을 검증해야 consumer의 안정성을 보장할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java`
around lines 37 - 50, Add unit tests in CatalogEventConsumerTest that cover
error scenarios: (1) invalid JSON payload — create a ConsumerRecord with
malformed JSON via createRecord and assert that
catalogEventConsumer.consume(record, ack) either throws the expected exception
or does not call ack.acknowledge() and does not invoke
metricsAggregator.handleLikeToggled; (2) missing required fields (e.g., payload
without targetType or targetId) — call catalogEventConsumer.consume and assert
the consumer's error handling (exception type or no ack and no metricsAggregator
interaction); (3) missing eventId header — build a record without the eventId
header and assert whether consume throws or silently skips, and verify
ack.acknowledge() behavior and that metricsAggregator.handleLikeToggled is not
called. Ensure each test uses the same helpers (createRecord, ack,
catalogEventConsumer, metricsAggregator) and explicitly verifies ack invocation
and metricsAggregator interactions for each scenario.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java (1)

23-25: @transactional 내에서 동기식 외부 호출(Kafka)은 트랜잭션 유지 시간을 늘린다.

100건의 이벤트를 처리하는 동안 DB 트랜잭션이 열려 있어, Kafka 지연 시 DB 커넥션이 장시간 점유된다. 규모가 커지면 배치 크기 조정 또는 트랜잭션 분리(이벤트별 개별 트랜잭션)를 고려해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java`
around lines 23 - 25, The relay() method in OutboxRelayScheduler is annotated
with `@Transactional` but performs synchronous Kafka sends inside the transaction,
which can hold DB connections during network delays; refactor so DB work is
committed before sending events: move the Kafka publishing out of the
`@Transactional` scope (e.g., extract DB fetch/mark-as-sent logic into a
transactional method and call a separate non-transactional publish loop), or use
TransactionTemplate/afterCommit callbacks to publish after commit or send
asynchronously (use kafkaTemplate.send(...).addCallback(...) or an executor) and
adjust batching logic in relay() to limit processed events per invocation to
avoid long transactions.
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java (1)

22-22: MAX_RETRIES 값 검토 필요

재시도 횟수가 50회로 설정되어 있다. 낙관적 락 충돌이 50회 이상 발생한다면 설계 자체에 문제가 있을 가능성이 높다. 운영 환경에서 이 수치에 근접하는 경우가 있는지 모니터링하고, 필요시 적절한 알림을 설정해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java`
at line 22, MAX_RETRIES in MetricsAggregator is set to 50 which is unusually
high; change it to a more conservative default (e.g., 3-10) and make it
configurable so it can be tuned per environment (e.g., via application config or
environment variable referenced in MetricsAggregator). Also add instrumentation
(counter/metric) for retry occurrences inside the retry logic in
MetricsAggregator and emit/emit a log when retries approach the limit so
operators can create alerts from that metric/log.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java (1)

41-55: DTO 정의 적절함

CouponIssueResponse record가 CouponIssueInfo로부터 매핑되어 엔티티 직접 노출을 방지한다. 다만 failReason이 사용자에게 노출되는데, 내부 오류 메시지가 그대로 전달될 경우 보안/UX 측면에서 문제가 될 수 있다. 사용자용 메시지와 내부 로그용 메시지 분리를 고려해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java`
around lines 41 - 55, The current CouponIssueResponse.from(CouponIssueInfo) maps
info.failReason() directly into the response, which may expose internal error
details; change the mapping so CouponIssueResponse carries a user-facing failure
message only (e.g., map info.failReason() through a sanitizer/mapper or convert
to a public enum/message) and keep the raw/internal message in a separate
internal-only field or log (do not expose internalFailReason in the DTO sent to
clients). Update the from(CouponIssueInfo) factory to perform this
transformation (or lookup) for failReason and ensure any internal failure detail
is logged via existing logging rather than returned to callers.
apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java (1)

76-90: 테스트 안정성: timeout 및 awaitTermination 누락

latch.await()에 timeout이 없다. 1000건의 요청을 50개 스레드로 처리하므로 CI 환경에서 지연 발생 시 테스트가 무한 대기할 수 있다. 특히 DB 락 경합이 심한 경우 더 오래 걸릴 수 있다.

제안 수정
-        latch.await();
-        executor.shutdown();
+        boolean completed = latch.await(120, TimeUnit.SECONDS);
+        executor.shutdown();
+        executor.awaitTermination(30, TimeUnit.SECONDS);
+        assertThat(completed).withFailMessage("동시성 테스트가 timeout 내에 완료되지 않았다").isTrue();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java`
around lines 76 - 90, The test uses CountDownLatch latch.await() with no timeout
and then executor.shutdown(), risking indefinite hang in CI; change to use
latch.await(timeout, TimeUnit.SECONDS) with a reasonable timeout (e.g., based on
requestCount or a fixed upper bound) and after calling executor.shutdown() call
executor.awaitTermination(timeout, TimeUnit.SECONDS) and, if it returns false,
call executor.shutdownNow() to force stop; update references in the test to
CountDownLatch/latch.await(...) and
executor.shutdown()/executor.awaitTermination(...) so the test always fails fast
instead of hanging.
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java (2)

62-63: 테스트 안정성: timeout 및 awaitTermination 누락

latch.await()에 timeout이 없어 테스트가 무한 대기할 수 있다. 또한 executor.shutdown()awaitTermination()을 호출하지 않으면 태스크가 완전히 종료되기 전에 assertion이 수행될 가능성이 있다. CI 환경에서 flaky test의 원인이 된다.

제안 수정
-        latch.await();
-        executor.shutdown();
+        boolean completed = latch.await(60, TimeUnit.SECONDS);
+        executor.shutdown();
+        executor.awaitTermination(10, TimeUnit.SECONDS);
+        assertThat(completed).isTrue();

TimeUnit import 추가 필요:

import java.util.concurrent.TimeUnit;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java`
around lines 62 - 63, Replace the unbounded latch.await() and missing executor
termination handling to avoid hangs: change latch.await() to latch.await(30,
TimeUnit.SECONDS) (add import java.util.concurrent.TimeUnit if missing), then
after executor.shutdown() call executor.awaitTermination(30, TimeUnit.SECONDS)
and, if it returns false, call executor.shutdownNow() to force stop; ensure
assertions run only after termination.

103-104: 동일하게 timeout 및 awaitTermination 적용 필요

concurrentLikeToggled_exactCount와 동일한 문제가 있다. 테스트 안정성을 위해 timeout과 awaitTermination을 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java`
around lines 103 - 104, The test currently calls latch.await() and
executor.shutdown() without timeouts which can hang; change the call in
MetricsAggregatorConcurrencyTest (the section around
concurrentLikeToggled_exactCount style usage) to use a timed wait on the
CountDownLatch (e.g., latch.await(<timeout>, <TimeUnit>)) and after
executor.shutdown() call executor.awaitTermination(<timeout>, <TimeUnit>), and
if awaitTermination returns false, call executor.shutdownNow(); this mirrors the
timeout/termination pattern used in concurrentLikeToggled_exactCount to ensure
the test fails fast and cleans up the executor.
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java (1)

69-80: 예외 처리 구조 적절함

CoreException(비즈니스 예외)은 FAILED로 처리하고, 그 외 예외는 rethrow하여 Kafka 재시도를 유도하는 설계가 적절하다. 다만 e.getMessage()를 직접 failReason에 저장하면 내부 구현 상세가 노출될 수 있으므로, 사용자용 메시지를 별도로 관리하는 것을 고려해야 한다. As per coding guidelines, 예외 처리 시 사용자 메시지와 로그 메시지를 분리하도록 제안한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java`
around lines 69 - 80, The catch(CoreException e) stores e.getMessage() into
request.fail which can leak internal details; update the CoreException handling
in CouponIssueFacade (the catch block around couponService.issueCoupon) to pass
a sanitized/user-facing message to request.fail (e.g., map the exception to a
friendly string or call a new getPublicMessage()/toUserMessage() on
CoreException) while logging the full internal message and stack trace
separately (use log.warn with e or log.debug for internals). Ensure
couponService.issueCoupon remains unchanged; implement the mapping or accessor
on CoreException (or an ErrorMapper) and use that mapped/user message in
request.fail, keeping the detailed e.getMessage()/exception in logs only.
apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java (1)

133-175: 낙관적 락 재시도 동작 테스트 누락

MetricsAggregator.executeWithRetryObjectOptimisticLockingFailureException 발생 시 재시도하는 로직이 있으나, 이에 대한 단위 테스트가 없다. 재시도 성공/실패 케이스를 검증하는 테스트 추가를 권장한다.

테스트 케이스 예시
`@Test`
`@DisplayName`("낙관적 락 충돌 시 재시도 후 성공한다")
void retryOnOptimisticLockFailure() {
    // given
    given(eventHandledRepository.existsByEventId("retry-1")).willReturn(false);
    ProductMetrics metrics = ProductMetrics.create(100L);
    given(productMetricsRepository.findByProductId(100L)).willReturn(Optional.of(metrics));
    
    AtomicInteger callCount = new AtomicInteger(0);
    doAnswer(invocation -> {
        if (callCount.incrementAndGet() < 3) {
            throw new ObjectOptimisticLockingFailureException(ProductMetrics.class, 100L);
        }
        invocation.<Consumer<TransactionStatus>>getArgument(0).accept(null);
        return null;
    }).when(transactionTemplate).executeWithoutResult(any());

    // when
    metricsAggregator.handleLikeToggled("retry-1", 100L, true);

    // then
    assertThat(callCount.get()).isEqualTo(3);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java`
around lines 133 - 175, Add unit tests in MetricsAggregatorTest to cover
MetricsAggregator.executeWithRetry's optimistic-lock retry behavior: create one
test that mocks transactionTemplate.executeWithoutResult (use doAnswer with an
AtomicInteger) to throw ObjectOptimisticLockingFailureException for the first
N-1 calls and succeed on the Nth, invoke the public entry point that uses
executeWithRetry (e.g., handleLikeToggled or the relevant handler) and assert
the executeWithoutResult was called N times and the operation succeeded; add a
second test that always throws ObjectOptimisticLockingFailureException from
executeWithoutResult and assert the method ultimately fails (exception
propagated) after the configured retry attempts. Reference
transactionTemplate.executeWithoutResult, MetricsAggregator.executeWithRetry,
ObjectOptimisticLockingFailureException, and the public handler method
(handleLikeToggled / the handler you call) when implementing the mocks and
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f5a8d49e-1d9d-4ee0-8166-c71760848270

📥 Commits

Reviewing files that changed from the base of the PR and between 151d891 and ab15125.

📒 Files selected for processing (57)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.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/domain/coupon/CouponIssueRequest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestReader.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/LikeToggledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestReaderImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandledRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • modules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (1)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java

Comment on lines +17 to +18
log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
event.memberId(), event.productId());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

상품 조회 이벤트를 INFO 로그로 남기지 않는 편이 안전하다.

상품 조회는 고트래픽 경로라서 현재처럼 memberId까지 INFO로 남기면 로그 비용이 빠르게 커지고, 사용자 식별자가 장기 보관 로그에 남는다. 운영 로그는 DEBUG로 낮추거나 샘플링하고, 꼭 필요하면 식별자는 제거한 별도 메트릭/감사 경로로 보내는 편이 안전하다. 추가 테스트로는 appender 기반 테스트를 넣어 INFO 레벨 로그에 사용자 식별자가 출력되지 않는지 검증해야 한다.

수정 예시
-        log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
-            event.memberId(), event.productId());
+        log.debug("[이벤트] 상품 조회 - productId={}", event.productId());

As per coding guidelines **/*.java: 로깅 시 민감정보 노출 가능성을 점검한다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
event.memberId(), event.productId());
log.debug("[이벤트] 상품 조회 - productId={}", event.productId());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventListener.java`
around lines 17 - 18, Change the high-volume product-view log in
UserActivityEventListener so it does not write user-identifying data at INFO
level: replace or move the current log.info("[이벤트] 상품 조회 - memberId={},
productId={}", event.memberId(), event.productId()) to a lower level (e.g.,
log.debug) or remove memberId from the INFO message and only log productId; if
you must capture memberId send it to a separate audited/metrics path. Also add
an appender-based test that exercises UserActivityEventListener and asserts
INFO-level output does not contain event.memberId() (but allows DEBUG to contain
it if needed).

Comment on lines +99 to +107
List<OrderCreatedEvent.OrderItemSnapshot> itemSnapshots = commands.stream()
.map(cmd -> new OrderCreatedEvent.OrderItemSnapshot(
cmd.productId(), cmd.productName(), cmd.productPrice(), cmd.quantity()))
.toList();
OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(
order.getId(), memberId, order.getTotalAmount(), itemSnapshots);
eventPublisher.publishEvent(orderCreatedEvent);
outboxEventPublisher.publish("ORDER", order.getId(), "ORDER_CREATED",
"order-events", String.valueOf(order.getId()), orderCreatedEvent);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

할인 주문의 이벤트 금액 기준이 서로 달라 downstream 집계가 어긋날 수 있다.

여기서 담는 totalAmount는 할인 후 총액인데, item snapshot의 price는 할인 전 단가다. 같은 이벤트 안에 순액과 총액 기준이 섞여 있으면 상품별 매출을 price * quantity로 집계하는 쪽과 주문 총액을 보는 쪽의 수치가 영구히 갈라진다. item snapshot에 할인 배분값 또는 netAmount를 포함해 금액 기준을 하나로 맞추고, 쿠폰 적용 주문이 consumer 집계까지 거쳐도 주문 총액과 상품별 합계가 기대한 기준으로 일치하는 통합 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 99 - 107, 현재 OrderCreatedEvent에 담긴 totalAmount(할인 후 총액)와
OrderItemSnapshot.price(할인 전 단가)가 혼재되어 downstream 집계 불일치를 초래합니다; 수정책으로
OrderItemSnapshot(생성하는 map 람다)에 할인 배분된 금액을 표현하는 필드(예: netAmount 또는
discountedPrice, 그리고 quantity를 곱한 netLineAmount)를 추가하고 OrderCreatedEvent 생성 시 해당
net 금액들을 함께 전달 및 설정하도록 변경하여 totalAmount 기준과 일치시키세요(참조 대상: OrderCreatedEvent,
OrderCreatedEvent.OrderItemSnapshot, 매핑 람다, outboxEventPublisher.publish 호출 지점).
또한 쿠폰/할인 적용 주문에 대해 주문 총액(totalAmount)과 item별 net 합계가 동일함을 검증하는 통합 테스트를 추가해
consumer 집계까지 검증하세요.

Comment on lines +113 to +117
PaymentCompletedEvent completedEvent = new PaymentCompletedEvent(
payment.getId(), payment.getOrderId(), payment.getMemberId(), payment.getAmount());
eventPublisher.publishEvent(completedEvent);
outboxEventPublisher.publish("ORDER", payment.getOrderId(), "PAYMENT_COMPLETED",
"order-events", String.valueOf(payment.getOrderId()), completedEvent);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

결제 성공 이벤트를 발행하기 전에 response 경로도 terminal 상태를 잠금으로 재확인해야 한다.

지금은 processPaymentResponse()가 잠금 없이 결제를 읽고 바로 상태 전환과 PAYMENT_COMPLETED publish를 수행하므로, PG callback이나 복구 스케줄러가 먼저 성공 처리를 끝낸 직후에도 같은 결제를 다시 처리할 수 있다. 운영에서는 중복 outbox 적재로 downstream이 두 번 처리되거나, 이미 성공한 결제에 대해 markProcessing() 예외가 나서 호출자에게 오류를 반환하는 장애로 이어진다. processPaymentResponse()getPaymentForUpdate()로 읽은 뒤 terminal 상태면 즉시 반환하고, 그 이후에만 상태 전환과 event/outbox publish를 수행해야 한다. 추가 테스트로는 processPaymentResponse()processCallback()을 동시에 호출해도 outbox row와 완료 이벤트가 정확히 1건만 생성되는지 검증해야 한다.

수정 예시
 `@Transactional`
 public PaymentInfo processPaymentResponse(Long paymentId, PaymentGatewayResponse pgResponse) {
-    Payment payment = paymentService.getPayment(paymentId);
+    Payment payment = paymentService.getPaymentForUpdate(paymentId);
+    if (payment.getStatus().isTerminal()) {
+        log.info("이미 처리된 PG 응답 무시. paymentId={}, status={}", paymentId, payment.getStatus());
+        return PaymentInfo.from(payment);
+    }
 
     if (pgResponse == null) {
         // CircuitBreaker fallback: null 반환 → 실패 처리
         payment.fail("PG 서비스 일시 장애");

Also applies to: 141-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`
around lines 113 - 117, processPaymentResponse() must re-check the payment's
terminal state after acquiring the row lock (use getPaymentForUpdate()) and
return immediately if the payment is already in a terminal state before calling
markProcessing(), publishing the PaymentCompletedEvent or writing to outbox;
update the block around
markProcessing()/eventPublisher.publishEvent/outboxEventPublisher.publish to
perform the terminal-state check on the locked Payment and only proceed to state
transition and publishes when non-terminal. Apply the same change pattern to the
analogous code near processCallback(), and add a concurrency test that calls
processPaymentResponse() and processCallback() in parallel and asserts exactly
one outbox row and one PaymentCompletedEvent are produced.


public interface OutboxEventJpaRepository extends JpaRepository<OutboxEvent, Long> {

List<OutboxEvent> findTop100ByPublishedFalseOrderByCreatedAtAsc();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이 조회만으로는 relay 다중 인스턴스에서 같은 outbox를 중복 집어갈 수 있다.

현재 계약은 published=false 행을 읽은 뒤 전송 성공 후에야 상태를 바꾸는 2단계라서, 스케줄러가 두 대 이상이거나 한 배치가 폴링 주기를 넘기면 같은 outbox를 여러 relay가 동시에 읽고 Kafka에 중복 전송할 수 있다. 운영에서는 중복 이벤트가 downstream 부하와 중복 처리 비용으로 바로 이어진다. PESSIMISTIC_WRITE + SKIP LOCKED 또는 IN_PROGRESS 같은 선점 상태를 두는 claim 쿼리로 바꾸고, 선점과 조회를 같은 트랜잭션으로 묶는 편이 안전하다. 추가 테스트로는 두 relay 스레드/인스턴스가 동시에 폴링해도 같은 outbox id가 한 번만 publish 되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java`
at line 9, 현재 OutboxEventJpaRepository의
findTop100ByPublishedFalseOrderByCreatedAtAsc() 조회는 다중 relay 인스턴스에서 동일한 outbox를
중복 소비할 수 있으므로, 해당 메서드를 선점(claim) 쿼리로 바꿔야 합니다: 트랜잭션 내에서 PESSIMISTIC_WRITE + SKIP
LOCKED 또는 상태 플래그(IN_PROGRESS)를 이용해 레코드를 선점(예: update ... where published=false
and status is null -> set status='IN_PROGRESS')한 뒤 같은 트랜잭션에서 조회하여 반환하도록 구현하고,
publish 성공/실패 시 상태를 적절히 published=true 또는 상태 롤백/재시도 처리하도록 변경(관련 위치:
OutboxEventJpaRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc,
OutboxEvent 엔티티의 상태 필드 및 이를 사용하는 발행 로직)하고, 마지막으로 두 개 이상의 relay 스레드/인스턴스가 동시 폴링해도
동일 outbox id가 한 번만 publish 되는 통합 테스트를 추가하세요.

"eventId", String.valueOf(event.getId()).getBytes(StandardCharsets.UTF_8)
));

kafkaTemplate.send(record).get();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

kafkaTemplate.send().get() 호출에 타임아웃이 없어 무한 대기 가능성이 있다.

Kafka 브로커 장애나 네트워크 지연 시 get()이 무한 대기하여 DB 트랜잭션이 장시간 유지될 수 있다. 이는 커넥션 풀 고갈 및 후속 요청 처리 지연으로 이어질 수 있다.

🔧 타임아웃 적용
-kafkaTemplate.send(record).get();
+kafkaTemplate.send(record).get(10, java.util.concurrent.TimeUnit.SECONDS);

또한 InterruptedExceptionTimeoutException 처리가 필요하다. 현재 Exception으로 일괄 처리하고 있으나 명시적 예외 타입을 catch하는 것이 디버깅에 유리하다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
kafkaTemplate.send(record).get();
kafkaTemplate.send(record).get(10, java.util.concurrent.TimeUnit.SECONDS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java`
at line 37, OutboxRelayScheduler currently calls
kafkaTemplate.send(record).get() which can block forever; replace this with a
bounded wait (e.g., kafkaTemplate.send(record).get(timeout, TimeUnit.SECONDS))
and choose an appropriate timeout constant, then explicitly catch
InterruptedException and TimeoutException (keep ExecutionException or handle
underlying cause) instead of a blanket Exception; on InterruptedException re-set
the thread interrupt flag (Thread.currentThread().interrupt()), log
timeout/interrupt with context (record/key) and fail the relay attempt
gracefully so DB transaction isn't held indefinitely.

Comment on lines +90 to +96
private boolean isDuplicate(String eventId) {
if (eventHandledRepository.existsByEventId(eventId)) {
log.info("중복 이벤트 무시: eventId={}", eventId);
return true;
}
return false;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# EventHandled 엔티티에 unique constraint가 있는지 확인
rg -n "class EventHandled" --type=java -A 15
rg -n "uniqueConstraints|@Column.*unique" --type=java -g "*EventHandled*"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1996


🏁 Script executed:

# markHandled 메서드 구현 확인
rg -n "markHandled" --type=java -B 2 -A 5

# MetricsAggregator에서 isDuplicate과 markHandled 호출 부분
rg -n "isDuplicate|markHandled" apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java -B 3 -A 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6262


isDuplicate 체크와 markHandled 저장 사이의 경합 조건으로 인한 미처리 예외

isDuplicate 조회와 markHandled 저장이 원자적으로 수행되지 않는다. 동일 eventId를 가진 두 메시지가 거의 동시에 처리되면, 둘 다 isDuplicate를 통과한 후 markHandled에서 PK constraint violation이 발생한다.

eventIdEventHandled 엔티티의 primary key이므로 데이터베이스 레벨에서 중복 저장은 방지되지만, 두 번째 save() 호출 시 DataIntegrityViolationException이 발생하며 이를 처리하지 않는다. 결과적으로 이벤트 핸들러에서 예외가 전파되어 운영 장애로 이어질 수 있다.

수정안:

  • markHandled 메서드에서 PK constraint violation을 catch하고 로깅 후 정상 반환하거나
  • isDuplicate 체크와 markHandled 저장을 하나의 트랜잭션 내 원자적 연산으로 변경 (예: SELECT FOR UPDATE 또는 database unique constraint + exception handling)
  • 추가 테스트: 동시에 같은 eventId로 여러 스레드가 이벤트를 처리할 때 중복 저장 없음과 예외 미발생 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java`
around lines 90 - 96, Race between isDuplicate and markHandled can cause a
DataIntegrityViolationException when two threads save the same EventHandled PK;
modify markHandled(EventHandled event) to catch DataIntegrityViolationException
(or more specific constraint violation exception), log an informative message
including event.getEventId(), and return normally instead of rethrowing;
alternatively make markHandled and isDuplicate run atomically by annotating the
service method that calls them with `@Transactional` and using a repository method
that acquires a row lock or relying on the DB unique constraint plus the
exception handling change; ensure you reference EventHandled,
eventHandledRepository, isDuplicate and markHandled in the fix and add a
concurrent test that simulates multiple threads saving the same eventId to
assert no exception leaks.

Comment on lines +37 to +45
String targetType = node.get("targetType").asText();
if (!"PRODUCT".equals(targetType)) {
log.debug("PRODUCT 외 타입 무시: targetType={}", targetType);
ack.acknowledge();
return;
}

Long productId = node.get("targetId").asLong();
boolean liked = node.get("liked").asBoolean();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

JSON 필드 null 체크 누락으로 인한 NPE 위험

node.get("targetType"), node.get("targetId"), node.get("liked")가 null일 경우 asText(), asLong(), asBoolean() 호출 시 NPE가 발생한다. 잘못된 payload가 유입되면 consumer가 예외를 발생시키고, 현재 로직상 ack 처리되어 해당 이벤트가 유실된다.

제안 수정
+            JsonNode targetTypeNode = node.get("targetType");
+            if (targetTypeNode == null) {
+                log.warn("targetType 필드 누락: eventId={}", eventId);
+                ack.acknowledge();
+                return;
+            }
-            String targetType = node.get("targetType").asText();
+            String targetType = targetTypeNode.asText();
             if (!"PRODUCT".equals(targetType)) {
                 log.debug("PRODUCT 외 타입 무시: targetType={}", targetType);
                 ack.acknowledge();
                 return;
             }

-            Long productId = node.get("targetId").asLong();
-            boolean liked = node.get("liked").asBoolean();
+            JsonNode targetIdNode = node.get("targetId");
+            JsonNode likedNode = node.get("liked");
+            if (targetIdNode == null || likedNode == null) {
+                log.warn("필수 필드 누락: eventId={}, targetId={}, liked={}", eventId, targetIdNode, likedNode);
+                ack.acknowledge();
+                return;
+            }
+            Long productId = targetIdNode.asLong();
+            boolean liked = likedNode.asBoolean();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 37 - 45, The JSON field access in CatalogEventConsumer uses
node.get("targetType"), node.get("targetId"), and node.get("liked") directly
then calls asText()/asLong()/asBoolean(), which can throw NPE on malformed
payloads; update the parsing to safely handle missing/null fields (e.g., use
node.hasNonNull("targetType")/hasNonNull("targetId")/hasNonNull("liked") or
node.path(...) which returns a safe MissingNode) before calling
asText()/asLong()/asBoolean(), and when a required field is missing log a clear
debug/error message including the raw payload and call ack.acknowledge() without
throwing so the consumer does not crash or lose ack semantics (refer to the
variables node, targetType, productId, liked and the ack handling in
CatalogEventConsumer).

Comment on lines +49 to +52
} catch (Exception e) {
log.error("catalog-events 처리 실패. eventId={}, error={}", eventId, e.getMessage(), e);
ack.acknowledge();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Spring Kafka error handling best practices DLQ

💡 Result:

Spring Kafka error handling best practices for Dead Letter Queues (DLQ), also called Dead Letter Topics (DLT), focus on using non-blocking retries with @RetryableTopic (available since 2.7) combined with DLT configuration. This approach automatically creates retry topics and a DLT, handles retries with backoff, and routes unprocessable messages to DLT after exhausting attempts. Key practices: 1. Use @RetryableTopic on @KafkaListener methods: - Set attempts (default 3), backoff (e.g., exponential), include/exclude exceptions for retries. - Example: @RetryableTopic( attempts = "4", backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000), dltStrategy = DltStrategy.FAIL_ON_ERROR, include = {RetryableException.class}, exclude = {DeserializationException.class} ) @KafkaListener(topics = "main-topic") public void process(String message) { ... } This creates retry topics (e.g., main-topic-retry-1000) and main-topic-dlt. 2. Add @DltHandler in the same class for DLT processing: @DltHandler public void handleDlt(String message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { // Log, alert, store for analysis, or reprocess after fix } Default logs if not provided. 3. DLT Strategies (dltStrategy): - ALWAYS_RETRY_ON_ERROR (default): Retry DLT processing failures by looping back to DLT. - FAIL_ON_ERROR: Fail fast on DLT errors. - NO_DLT: Skip DLT entirely. 4. Enable infrastructure: Add @EnableKafkaRetryTopic or extend RetryTopicConfigurationSupport in @Configuration. 5. Alternative for simple cases: DefaultErrorHandler + DeadLetterPublishingRecoverer (blocking retries): @Bean public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) { return new DefaultErrorHandler( new DeadLetterPublishingRecoverer(template), new FixedBackOff(5000L, 3L)); } DLT topic: original-dlt. Prefer @RetryableTopic for non-blocking. Operational best practices: - Monitor DLT volume, age, error types; alert on spikes. - Enrich DLT messages with headers (exception FQCN, stack trace auto-added). - Use exponential backoff, classify retryable vs. non-retryable exceptions. - Set topic retention appropriately; consider compaction for DLT. - Avoid DLT for strict ordering needs. - Customize suffixes: retryTopicSuffix="-retry", dltTopicSuffix="-dlt". - For batch listeners, use DefaultErrorHandler (no @RetryableTopic support). @RetryableTopic is recommended for production as it provides non-blocking retries preserving partition ordering within topics.

Citations:


🏁 Script executed:

# CatalogEventConsumer.java 파일 전체 내용 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3096


🏁 Script executed:

# Spring Kafka 버전 확인
find . -name "pom.xml" -o -name "build.gradle" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# pom.xml에서 spring-kafka 버전 확인
grep -A 5 "spring-kafka" pom.xml 2>/dev/null || grep -A 5 "spring-kafka" */pom.xml 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# KafkaConfig 파일 찾기
find . -name "KafkaConfig.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 155


🏁 Script executed:

# pom.xml 찾기 (모든 경로)
find . -name "pom.xml" -type f | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Spring Kafka 버전 확인 (다양한 위치 시도)
find . -name "pom.xml" -type f -exec grep -l "spring-kafka" {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# KafkaConfig.java 내용 확인
cat -n ./modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5094


🏁 Script executed:

# 프로젝트 구조에서 pom.xml 찾기
find . -type f -name "pom.xml" | grep -v ".git"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 프로젝트 루트의 pom.xml 직접 확인
ls -la | grep pom
cat pom.xml | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 124


🏁 Script executed:

# 프로젝트 루트의 모든 build 설정 파일 확인
ls -la | grep -E "gradle|maven"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 472


🏁 Script executed:

# build.gradle 확인
find . -name "build.gradle" -type f | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# MetricsAggregator 찾기
find . -name "MetricsAggregator.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 178


🏁 Script executed:

# build.gradle.kts에서 spring-kafka 버전 확인
cat build.gradle.kts | grep -A 3 -B 3 "spring-kafka\|spring.kafka"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# MetricsAggregator.java 파일 내용 확인
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4882


🏁 Script executed:

# build.gradle.kts 전체 파일 확인
cat build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3870


🏁 Script executed:

# gradle.properties 확인
cat gradle.properties

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 649


🏁 Script executed:

# commerce-streamer의 build.gradle 파일 찾기
find . -path "*/commerce-streamer/*" -name "build.gradle*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 126


🏁 Script executed:

# kafka 모듈의 build.gradle 찾기
find . -path "*/kafka/*" -name "build.gradle*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 117


🏁 Script executed:

# commerce-streamer의 build.gradle.kts 확인
cat ./apps/commerce-streamer/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 992


🏁 Script executed:

# kafka 모듈의 build.gradle.kts 확인
cat ./modules/kafka/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 390


예외 발생 시 무조건 ack 처리로 인한 메시지 유실 문제

현재 코드는 모든 Exception을 동일하게 처리하여 ack.acknowledge()를 호출하고 있다. 이로 인해 일시적 장애(DB 연결 실패, 트랜잭션 타임아웃 등)로 인한 예외 발생 시에도 메시지가 즉시 커밋되어 재처리 기회를 잃게 된다. 운영 관점에서 이벤트 유실은 메트릭 데이터 불일치로 이어진다.

예외 유형을 구분하여 처리해야 한다. 일시적 장애는 재시도하고, 영구적 실패는 Dead Letter Queue로 라우팅하는 전략을 수립할 것을 권장한다. Spring Kafka 3.3 이상에서는 @RetryableTopic으로 비차단 재시도와 DLT를 간편하게 구성할 수 있으므로, @RetryableTopic 도입을 검토하거나, DefaultErrorHandlerDeadLetterPublishingRecoverer를 통해 재시도 정책을 명시적으로 정의해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 49 - 52, The catch-all exception handling in CatalogEventConsumer
currently calls ack.acknowledge() for every Exception, causing message loss;
change the error handling so only successful processing explicitly calls
ack.acknowledge() (move ack into the normal success path), and distinguish
transient vs permanent failures in the catch: for transient errors (e.g., DB
connection timeouts, transient network/SQLExceptions) rethrow or let the
listener container handle retries (or annotate the consumer method with
`@RetryableTopic`) so the message is retried; for permanent errors send to a Dead
Letter Queue (use Spring Kafka's DefaultErrorHandler with a
DeadLetterPublishingRecoverer or configure `@RetryableTopic` with DLT) and do not
ack in that case. Locate the try/catch in CatalogEventConsumer (the block that
currently calls ack.acknowledge() in the catch) and implement the described
ack-on-success and retry/DLT strategy.

Comment on lines +58 to +64
for (JsonNode item : itemsNode) {
items.add(new MetricsAggregator.OrderItemInfo(
item.get("productId").asLong(),
item.get("price").asLong(),
item.get("quantity").asInt()
));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

items 배열 내 필드 접근 시 NPE 위험이 있다.

item.get("productId"), item.get("price"), item.get("quantity")null을 반환할 경우 asLong(), asInt() 호출 시 NPE가 발생한다. Kafka 메시지 형식이 변경되거나 잘못된 메시지가 유입될 때 문제가 된다.

🛡️ 필드 존재 여부 검증 추가
 for (JsonNode item : itemsNode) {
+    if (!item.has("productId") || !item.has("price") || !item.has("quantity")) {
+        log.warn("order item 필드 누락. eventId={}", eventId);
+        continue;
+    }
     items.add(new MetricsAggregator.OrderItemInfo(
         item.get("productId").asLong(),
         item.get("price").asLong(),
         item.get("quantity").asInt()
     ));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`
around lines 58 - 64, In the for-loop inside OrderEventConsumer that builds
MetricsAggregator.OrderItemInfo from itemsNode, guard against NPE by validating
each field before calling asLong()/asInt(): check item.get("productId"),
item.get("price"), and item.get("quantity") are non-null (or use JsonNode::has /
JsonNode::path with safe defaults) and either skip the malformed item and log a
warning or supply sensible default values when constructing new
MetricsAggregator.OrderItemInfo; update the loop to perform these null/type
checks and logging so malformed Kafka messages do not throw NPEs.

Comment on lines +69 to +72
private void handlePaymentCompleted(String eventId, JsonNode node) {
Long orderId = node.get("orderId").asLong();
metricsAggregator.handlePaymentCompleted(eventId, orderId);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

handlePaymentCompleted에서도 필드 접근 전 null 체크가 필요하다.

node.get("orderId")null인 경우 동일하게 NPE가 발생한다.

🛡️ 방어 로직 추가
 private void handlePaymentCompleted(String eventId, JsonNode node) {
+    if (!node.has("orderId")) {
+        log.warn("paymentCompleted 이벤트에 orderId 누락. eventId={}", eventId);
+        return;
+    }
     Long orderId = node.get("orderId").asLong();
     metricsAggregator.handlePaymentCompleted(eventId, orderId);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void handlePaymentCompleted(String eventId, JsonNode node) {
Long orderId = node.get("orderId").asLong();
metricsAggregator.handlePaymentCompleted(eventId, orderId);
}
private void handlePaymentCompleted(String eventId, JsonNode node) {
if (!node.has("orderId")) {
log.warn("paymentCompleted 이벤트에 orderId 누락. eventId={}", eventId);
return;
}
Long orderId = node.get("orderId").asLong();
metricsAggregator.handlePaymentCompleted(eventId, orderId);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`
around lines 69 - 72, The handlePaymentCompleted method dereferences
node.get("orderId") without null checks which can cause an NPE; update
handlePaymentCompleted to first verify the JsonNode and the "orderId" field
exist (e.g., node != null && node.hasNonNull("orderId")) or retrieve the field
into a variable and check for null before calling asLong(), and if missing, log
or increment a metric and return early instead of calling
metricsAggregator.handlePaymentCompleted(eventId, orderId).

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