Skip to content

[Volume7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현 - 박시온#294

Merged
hey-sion merged 10 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round7-kafka
Mar 31, 2026
Merged

[Volume7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현 - 박시온#294
hey-sion merged 10 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round7-kafka

Conversation

@hey-sion
Copy link
Copy Markdown

@hey-sion hey-sion commented Mar 27, 2026

📌 Summary

  • 배경: 좋아요 집계, 주문/결제 후속 처리가 모두 동기적으로 비즈니스 트랜잭션에 묶여 있었습니다. 이 방식은 후속 처리 실패가 본 요청 흐름에 직접 영향을 주며, 선착순 쿠폰처럼 순간 트래픽이 몰리는 요구사항을 처리하기 어렵습니다.
  • 목표: ApplicationEvent로 경계를 나누고, Kafka + Transactional Outbox Pattern으로 시스템 간 이벤트 파이프라인을 구축합니다. 최종적으로 Redis + Kafka 조합의 선착순 쿠폰 발급까지 구현합니다.
  • 결과: commerce-api(Producer) → Kafka → commerce-streamer(Consumer) 구조로 전환했습니다. 좋아요/조회/결제 이벤트는 Consumer가 product_metrics에 집계하고, 선착순 쿠폰은 Redis로 즉시 컷오프 후 Kafka Consumer가 실제 발급을 처리합니다.

🧭 Context & Decision

① 이벤트 공통 모듈 분리

Producer와 Consumer가 동일한 이벤트 계약을 따라야 하므로 modules/event-contract를 분리했습니다.

포함한 요소:

  • EventPayload 마커 인터페이스
  • Event<T> 봉투 클래스
  • EventType enum
  • Topic 상수
  • DataSerializer
  • Snowflake
  • 이벤트별 Payload DTO

핵심 원칙은 공통 모듈에는 이벤트 계약만 두고, Spring Bean/JPA/Kafka 설정은 넣지 않는 것입니다.


② Transactional Outbox Pattern으로 Producer 신뢰성 확보

좋아요, 결제 완료, 쿠폰 발급 요청처럼 유실되면 안 되는 이벤트는 DB 트랜잭션과 함께 저장한 뒤 Kafka로 릴레이하도록 설계했습니다.

비즈니스 로직 실행
  → BEFORE_COMMIT: Outbox INSERT (같은 트랜잭션)
  → AFTER_COMMIT + @Async: Kafka send + Outbox DELETE
  → @Scheduled(10초): 미발행 Outbox 재시도 polling

Kafka send 실패 시 Outbox 레코드가 남아있어 polling에서 재시도됩니다. 이로써 At Least Once 발행을 보장합니다.


③ Consumer 멱등성 전략 — 증감 + event_handled

좋아요/조회수/판매량 집계는 덮어쓰기가 아닌 증감 방식(+1/-1)을 선택했습니다.

덮어쓰기 (COUNT 결과를 SET) 증감 (+1/-1)
Producer 부담 COUNT 쿼리 추가 없음
정확성 동시 이벤트 시 일시적 오차 event_handled 전제 시 정확
event_handled 역할 불필요한 재처리 방지 중복 증감 방지 (없으면 카운트 틀어짐)

이벤트는 "발생한 사실"을 표현하는 것이 자연스럽고, Producer에 집계 책임을 부여하지 않기 위해 증감 방식을 선택했습니다. 같은 상품의 이벤트 순서를 보장하기 위해 productId를 partition key로 사용합니다.

Producer: INSERT like + INSERT outbox → 커밋
Kafka:    partition key = productId → 순서 보장
Consumer: handler.handle() + event_handled INSERT → 하나의 @Transactional

handler + event_handled INSERT를 반드시 하나의 트랜잭션으로 묶어야 합니다. handle 성공 후 event_handled INSERT 전에 장애가 나면 Kafka 재전달 시 중복 증감이 발생합니다.


④ version/updated_at을 적용하지 않은 이유

과제 체크리스트에 version/updated_at 기준 최신 이벤트만 반영이 있지만, 현재 다루는 이벤트(PRODUCT_LIKED, PRODUCT_UNLIKED, PRODUCT_VIEWED, PAYMENT_COMPLETED)는 모두 카운터성 fact event입니다.

version/updated_at: 구버전 이벤트를 스킵 → 최신 상태만 반영 (상태 스냅샷에 적합)
event_handled:      동일 이벤트의 재처리만 방지 → 모든 이벤트를 누적 적용 (카운터성 이벤트에 적합)

증감 방식에서는 모든 이벤트를 빠짐없이 적용해야 하므로, 하나라도 스킵하면 카운트가 틀어집니다. 따라서 version 기반 필터링 대신 event_handled로 중복만 방지합니다.


⑤ 상품 조회 이벤트 — Outbox 없이 Kafka 직접 발행

ProductFacade.getActiveProduct()에는 @Cacheable이 걸려 있어 캐시 히트 시 메서드 자체가 실행되지 않습니다. 이벤트 발행을 캐시 바깥으로 꺼내면, 매 조회마다 Outbox INSERT를 위한 write 트랜잭션이 필요합니다.

조회수는 유실되어도 서비스에 치명적이지 않으므로, Outbox 없이 KafkaTemplate.send()로 fire-and-forget 발행합니다.

좋아요/주문/결제 → Outbox 패턴 (유실 방지 필수)
상품 조회       → Kafka 직접 발행 (유실 허용, fire-and-forget)

⑥ 좋아요순 정렬 — ProductMetricsReadModel 도입

Product.likeCount를 제거하고 product_metrics 테이블로 집계를 분리하면서, QueryDSL만으로는 LEFT JOIN 대상이 없어졌습니다.

commerce-api에 읽기 전용 엔티티 ProductMetricsReadModel(@Entity + @Immutable)을 두고, QueryDSL에서 Product와 LEFT JOIN하여 모든 정렬 조건을 통합했습니다.

Before: LIKES_DESC → if early return → native SQL
         나머지     → QueryDSL
         switch에 도달 불가능한 throw 존재

After:  모든 정렬 → QueryDSL (Product LEFT JOIN ProductMetricsReadModel)
         LIKES_DESC도 정상적인 OrderSpecifier 반환

기존 @Cacheable(10분 TTL)은 그대로 유지합니다. Consumer가 product_metrics를 갱신하면, 캐시 TTL 만료 시 다음 조회에서 자연스럽게 반영됩니다.


⑦ 선착순 쿠폰 발급 — Redis + Kafka 조합

기존 동기 발급(/issue)은 유지하고, 선착순 쿠폰은 별도 API(/issue-request) + 비동기 흐름으로 분리했습니다.

CouponPromotion 테이블 분리: 발급 수량(maxQuantity)은 쿠폰 자체의 속성이 아닌 프로모션 정책이므로 별도 테이블로 관리합니다.

Redis + Kafka 역할 분담:

  • Redis: atomic INCR로 선착순 N개 컷오프. 초과 시 즉시 거절하여 불필요한 Kafka 메시지 방지
  • Kafka: 컷오프 통과한 요청의 실제 발급을 비동기 처리. DB 쓰기 부하 평탄화
[1] POST /api/v1/coupons/{couponId}/issue-request
    │
    ▼
[2] CouponIssueRequestFacade (commerce-api)
    ├─ CouponPromotion 조회 + 기간 검증
    ├─ Redis INCR → 초과 시 DECR + 거절 (400)
    ├─ CouponIssueRequest(PENDING) 저장 → 중복 시 DECR + 거절 (409)
    └─ OutboxEventPublisher.publish(COUPON_ISSUE_REQUESTED)
    │
    ▼ (Kafka: coupon-issue-requests 토픽)
    │
[3] CouponIssueConsumer → CouponIssueProcessor (commerce-streamer)
    ├─ 중복 발급 체크 (IssuedCoupon UNIQUE)
    ├─ 발급 가능 쿠폰 확인
    ├─ IssuedCoupon INSERT
    └─ CouponIssueRequest 상태 → SUCCESS / FAILED

결과 조회: GET /api/v1/coupons/issue-requests/{requestId} → PENDING / SUCCESS / FAILED polling


🔁 Flow Diagram

이벤트 파이프라인 (좋아요 예시)

sequenceDiagram
    participant C as Client
    participant API as LikeFacade
    participant DB as Database
    participant K as Kafka
    participant S as commerce-streamer

    C->>API: POST /api/v1/likes
    API->>DB: Like INSERT + Outbox INSERT (같은 TX)
    API-->>C: 200 OK

    Note over API,DB: AFTER_COMMIT + @Async
    API->>K: Kafka send (PRODUCT_LIKED)
    API->>DB: Outbox DELETE

    K->>S: consume (catalog-events)
    S->>DB: event_handled 확인
    S->>DB: product_metrics.like_count + 1
    S->>DB: event_handled INSERT
    S->>K: ack
Loading

선착순 쿠폰 발급

sequenceDiagram
    participant C as Client
    participant API as CouponIssueRequestFacade
    participant R as Redis
    participant DB as Database
    participant K as Kafka
    participant S as CouponIssueProcessor

    C->>API: POST /coupons/{id}/issue-request
    API->>R: INCR coupon-promotion:issued-count:{id}
    alt 수량 초과
        API->>R: DECR (되돌림)
        API-->>C: 400 Bad Request
    else 통과
        API->>DB: CouponIssueRequest(PENDING) + Outbox INSERT
        API-->>C: 202 Accepted (requestId)
    end

    Note over API,DB: AFTER_COMMIT
    API->>K: COUPON_ISSUE_REQUESTED

    K->>S: consume
    S->>DB: 중복 발급 확인
    S->>DB: IssuedCoupon INSERT
    S->>DB: CouponIssueRequest → SUCCESS
    S->>K: ack

    C->>API: GET /issue-requests/{requestId}
    API-->>C: {status: SUCCESS}
Loading

🏗 토픽 설계

토픽 이벤트 partition key
catalog-events PRODUCT_LIKED, PRODUCT_UNLIKED, PRODUCT_VIEWED productId
order-events PAYMENT_COMPLETED orderId
coupon-issue-requests COUPON_ISSUE_REQUESTED couponId

🗄 신규 테이블

테이블 용도
outbox Transactional Outbox Pattern — 미발행 레코드 polling 재시도
product_metrics 좋아요/조회수/판매량 집계 (Consumer가 관리)
event_handled 이벤트 중복 소비 방지 (event_id PK)
coupon_promotions 선착순 프로모션 정책 (coupon_id, max_quantity, started_at, ended_at)
coupon_issue_requests 선착순 발급 요청/결과 (PENDING → SUCCESS/FAILED)

✅ 변경 범위

공유 모듈 (신규)

  • modules/event-contract — Event, EventType, EventPayload, DataSerializer, Snowflake, Topic, Payload DTO

commerce-api (Producer)

  • Outbox, OutboxRepository, OutboxEvent, OutboxEventPublisher, MessageRelay — Outbox 패턴
  • LikeFacadeProduct.likeCount 증감 제거 → PRODUCT_LIKED/UNLIKED 이벤트 발행
  • ProductlikeCount 컬럼 제거
  • ProductFacade — PRODUCT_VIEWED 이벤트 발행 (fire-and-forget)
  • PaymentFacade — PAYMENT_COMPLETED 이벤트 발행
  • ProductRepositoryImpl — LEFT JOIN ProductMetricsReadModel로 좋아요순 정렬 통합
  • CouponIssueRequestFacade — 선착순 쿠폰 발급 요청 (Redis INCR + Outbox)
  • RedisCouponIssueCountManager — Redis 기반 발급 카운트 관리
  • CouponPromotion — 선착순 프로모션 정책 엔티티
  • UserBehaviorLoggingListener — 유저 행동 로깅 (@TransactionalEventListener)
  • Kafka Producer 설정: acks=all, enable.idempotence=true

commerce-streamer (Consumer)

  • MetricsEventConsumer — catalog-events, order-events 소비 + manual ack
  • CouponIssueConsumer — coupon-issue-requests 소비 + manual ack
  • EventProcessingService — event_handled 기반 멱등 처리
  • ProductLiked/Unliked/Viewed/PaymentCompleted EventHandler — product_metrics 집계
  • CouponIssueProcessor — 선착순 쿠폰 실제 발급 처리
  • CouponIssueRequestedEventHandler — 쿠폰 발급 이벤트 → Processor 위임

테스트

  • CouponIssueRequestFacadeTest — 발급 요청 단위 테스트 (Fake 사용)
  • CouponIssueProcessorTest — Consumer 발급 처리 통합 테스트 (@SpringBootTest + EmbeddedKafka)
  • CouponIssueRequestTest, CouponPromotionTest — 도메인 객체 단위 테스트
  • ConcurrencyE2ETest선착순 쿠폰 동시성 테스트 (200명 동시 요청 → 100장 한정 → 정확히 100건만 ACCEPTED 검증)

변경 목적

commerce-api를 Producer, commerce-streamer를 Consumer로 분리해 이벤트 기반 아키텍처로 전환하고 Transactional Outbox + Kafka로 신뢰성 있는 시스템 간 이벤트 파이프라인과 Redis 기반 선착순 쿠폰 컷오프를 구현.

핵심 변경점

공통 모듈(modules/event-contract)로 이벤트 계약 정의(Event, EventType, Topic, Payload들, Snowflake, DataSerializer). Producer 쪽은 Outbox 엔터티·OutboxEventPublisher·MessageRelay(트랜잭션 AFTER_COMMIT 발행 및 스케줄 재시도) 추가, 조회 이벤트는 직접 Kafka 전송. Consumer 쪽은 MetricsEventConsumer/CouponIssueConsumer → EventProcessingService(멱등성 보장 via event_handled) → 핸들러들로 product_metrics 집계 및 CouponIssueProcessor로 발급 처리. Product.likeCount 제거, product_metrics 읽기 모델 도입. RedisCouponIssueCountManager로 선착순 컷오프(INS/DECR 롤백) 및 CouponIssueRequest 상태 관리(PENDING→SUCCESS/FAILED). Kafka 리스너에 DefaultErrorHandler 적용.

리스크/주의사항

Outbox 삭제 시점과 재시도 경쟁 조건, 이벤트 역직렬화에서 payloadClass 미해결 시 예외 가능성, 비동기 집계로 인한 읽기 지연, Redis INCR/DECR 기반 카운터의 분산 일관성·롤백 경로(데이터 손실/중복처리 가능성)를 주의. handler와 event_handled INSERT를 단일 트랜잭션으로 유지해야 순서/멱등성 보장. (구현상 트랜잭션 범위나 Kafka 장애/리트라이 정책에 대해 추가 확인이 필요합니까?)

테스트/검증 방법

단위 테스트: CouponIssueRequestFacadeTest, EventProcessingServiceTest; 통합/연동: CouponIssueProcessorTest(embedded Kafka); E2E 동시성: ConcurrencyE2ETest(동시 요청 수백 건에서 제한 수량만 202 응답 검증, 동일 쿠폰 주문 동시성 검증). 추가로 Outbox 재시도/삭제, Kafka 장애 시 동작, Redis 카운터 경합 케이스에 대한 장애 주입 테스트 권장.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

이 PR은 Outbox 기반 이벤트 퍼블리싱과 Kafka 소비자/핸들러를 도입하여 좋아요·조회·결제 완료·쿠폰 발급 요청을 비동기 처리하고, 쿠폰 동시성은 Redis로 제어하며 상품 메트릭을 별도 읽기 모델로 분리한 변경이다.

Changes

Cohort / File(s) Summary
Event Contract 모듈
modules/event-contract/...
Event, EventType, EventPayload, Topic, Snowflake, DataSerializer 및 여러 EventPayload 클래스와 직렬화/역직렬화 테스트 추가.
Outbox 인프라 (API 쪽)
apps/commerce-api/src/main/java/.../Outbox.java, OutboxEvent.java, OutboxEventPublisher.java, MessageRelay.java, OutboxConfig.java
Outbox 엔티티/이벤트, OutboxEventPublisher로 트랜잭션 내 Outbox 생성, MessageRelay로 Kafka 전송/삭제/스케줄 재시도 및 Kafka 프로듀서/KafkaTemplate/Executor 빈 추가.
쿠폰 발급 도메인 (API)
apps/commerce-api/src/main/java/.../coupon/*, interfaces/api/coupon/*
CouponPromotion, CouponIssueRequest 엔티티·레포지토리·Facade·DTO·컨트롤러 및 Redis 기반 CouponIssueCountManager 추가하여 발급 요청 생성·검증·퍼블리시(OUTBOX) 흐름 구현.
쿠폰 발급 처리 (Streamer)
apps/commerce-streamer/src/main/java/.../CouponIssueProcessor.java, interfaces/consumer/CouponIssueConsumer.java
Streamer에 COUPON_ISSUE_REQUESTED 소비자·EventHandler·CouponIssueProcessor 추가, IssuedCoupon 엔티티 및 관련 저장소 구현으로 비동기 발급 처리 및 중복 방지 로직 구현.
상품 좋아요/조회/결제 이벤트 전파
apps/commerce-api/src/main/java/.../like/LikeFacade.java, product/ProductViewEventPublisher.java, payment/PaymentFacade.java
LikeFacade·ProductViewEventPublisher·PaymentFacade가 OutboxEventPublisher 사용해 이벤트 퍼블리시하도록 변경. ProductService의 동기 likeCount 변경 메서드 제거.
상품 메트릭 읽기 모델 (Streamer & API 조회 변경)
apps/commerce-streamer/.../ProductMetrics.java, apps/commerce-api/.../ProductMetricsReadModel.java, infrastructure/ProductMetricsRepository.java, ProductRepositoryImpl.java, ProductInfo.java
like/view/order 카운트를 ProductMetrics로 분리, ProductRepositoryImpl의 LIKES_DESC 정렬을 metrics 조인으로 변경, ProductInfo에서 likeCount 매핑 변경(0으로 대체).
Kafka 설정 변경
modules/kafka/src/main/java/.../KafkaConfig.java, modules/kafka/src/main/resources/kafka.yml
producer 빈 제거, 단일 리스너 팩토리(SINGLE_LISTENER) 추가(수동 ack, 오류 핸들러 및 backoff), 프로듀서 설정에 acks=all 및 idempotence=true 추가.
테스트 및 인메모리 레포지토리
apps/.../src/test/... , modules/event-contract/src/test/...
쿠폰 발급 페이사드/도메인/인메모리 레포지토리 테스트, Event/Snowflake 테스트, Like/Payment 페이사드 변경에 따른 단위 테스트 업데이트 및 Concurrency E2E 테스트를 쿠폰 중심으로 대체.
빌드 설정
apps/commerce-api/build.gradle.kts, apps/commerce-streamer/build.gradle.kts, modules/event-contract/build.gradle.kts, settings.gradle.kts
modules:event-contract 추가 및 API/스트리머 모듈에 event-contract·kafka 의존성 추가.
기타
.gitignore
QueryDSL 생성 디렉토리(**/generated/) 무시 규칙 추가.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as Commerce API
    participant OutboxPub as Outbox<br/>Publisher
    participant DB as Database
    participant Relay as MessageRelay
    participant Kafka
    participant Streamer as Commerce<br/>Streamer
    participant Processor as CouponIssueProcessor

    Client->>API: POST /api/v1/coupons/{couponId}/issue-request
    API->>API: validate promotion, increment Redis count
    API->>OutboxPub: publish(COUPON_ISSUE_REQUESTED)
    OutboxPub->>DB: insert Outbox row
    API-->>Client: 202 Accepted
    Relay->>DB: find pending outbox, send to Kafka
    Relay->>Kafka: produce message (topic coupon-issue-requests)
    Kafka->>Streamer: deliver message
    Streamer->>Streamer: Consumer -> EventProcessingService -> CouponIssueRequestedHandler
    Streamer->>Processor: process(requestId, couponId, userId)
    Processor->>DB: create IssuedCoupon, update request status
Loading
sequenceDiagram
    participant Client
    participant API as Commerce API
    participant OutboxPub as Outbox<br/>Publisher
    participant Kafka
    participant Streamer as Commerce<br/>Streamer
    participant Metrics as Product<br/>Metrics

    Client->>API: POST /api/v1/likes/{productId}
    API->>OutboxPub: publish(PRODUCT_LIKED)
    OutboxPub->>DB: insert Outbox
    OutboxPub->>Kafka: MessageRelay publishes
    Kafka->>Streamer: CATALOG_EVENTS
    Streamer->>Streamer: ProductLikedEventHandler.handle()
    Streamer->>Metrics: incrementLikeCount(productId)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs


주요 검토 사항

1) MessageRelay 재시도/사망 처리 부재 — 운영 리스크, 메시지 유실 가능성

왜 문제인지(운영): Kafka 전송 실패 시 현재 로직은 오류 로깅만 하고 Outbox를 남겨두며 주기적 재시도(10초)만 수행한다. 지속적 장애 또는 네트워크 분단 시 롤백·DLQ 미비로 메시지가 장기간 큐에 쌓이거나 유실될 수 있다.
수정안: 즉각적인 재시도 정책(지수 백오프 + 최대 재시도 횟수)과 재시도 초과 시 DLQ(또는 별도의 error table)에 기록하여 알림·수동조치가 가능하도록 구현한다. 운영 모니터링 지표(미전송 Outbox 카운트, DLQ 카운트) 추가.
추가 테스트: 장애 시나리오(embedded Kafka 비가용)에서 Outbox가 DLQ로 이동하는 통합 테스트 및 재시도 횟수/간격 검증 테스트를 추가한다.

2) OutboxEventPublisher 직렬화 예외 처리 및 트랜잭션 일관성

왜 문제인지(운영): publish 시점에 payload 직렬화 실패가 트랜잭션 후속 작업을 불투명하게 만들 수 있다(Outbox가 미생성되거나 잘못된 페이로드 저장).
수정안: publish에서 직렬화 실패 시 예외를 던져 트랜잭션을 롤백하도록 명확히 하고, ObjectMapper 인스턴스를 빈(singleton)으로 주입하여 재사용성/구성 일관성을 확보한다.
추가 테스트: 직렬화 실패 케이스(잘못된 POJO)에서 트랜잭션이 롤백되는 통합 테스트 추가.

3) Redis 기반 카운트 원자성 및 장애 복구 한계

왜 문제인지(운영): Redis INCR/DECR는 원자적이지만 네트워크 오류·레디스 장애 시 increment 후 실패된 DB 저장/트랜잭션 롤백에서 카운트 불일치가 발생할 수 있다. decrement 실패 시 불일치 누적 위험이 있다.
수정안: Redis 연산에 대한 예외 처리를 강화하고, Redis 실패 시 낙관적 대체 경로(예: DB 기반 재검증 또는 전용 토큰 테이블)를 마련한다. CouponIssueRequestFacade에서 increment 후 DB 실패 시 decrement 호출이 실패하면 경보 생성 및 수동 보정 절차를 마련한다.
추가 테스트: Redis 예외(Timeout, ConnectionRefused) 시 롤백/보정 경로를 검증하는 단위·통합 테스트를 추가한다.

4) EventHandled 중복 처리/예외 범주화 필요성

왜 문제인지(운영): EventHandled 저장 중 발생하는 모든 DataIntegrityViolationException을 “이미 처리됨”으로만 처리하면 다른 DB 오류(예: 제약 외의 트랜잭션 실패)를 놓칠 수 있다.
수정안: 예외를 세분화하여 유일성 제약 위반인 경우만 스킵 처리하고, 그외 DB 예외는 재시도 또는 NACK 처리하도록 분기한다(예외 메시지/SQLState 검사 또는 DB-전용 예외 매핑 사용).
추가 테스트: EventHandled.save 시 유일성 제약 위반과 일반 DB 예외를 구분하여 각각의 처리 흐름을 검증하는 단위 테스트 추가.

5) ProductMetrics 업서트 DB 종속성 및 호환성 문제

왜 문제인지(운영): ProductMetricsRepository의 native 쿼리는 MySQL 전용(INSERT ... ON DUPLICATE KEY UPDATE)이므로 DB 교체(Postgres 등) 시 호환성 문제 발생 위험이 있다.
수정안: DB 독립적인 업서트 추상화(예: JPA merge 패턴, UPSERT for Postgres 또는 데이터베이스 팩터별 구현)를 도입하거나, ProductMetricsRepository에 DB 벤더 구현 전략을 도입한다.
추가 테스트: H2/Postgres 환경에서 메트릭 증가/감소 쿼리 동작을 검증하는 통합 테스트 추가.

6) 테스트 격리성 및 인메모리 저장소의 리플렉션 사용 문제

왜 문제인지(운영): InMemory 레포지토리들이 리플렉션으로 부모 클래스 id를 설정하는 방식은 JPA 프록시/추후 리팩토링에 취약하고 테스트 신뢰도를 저하시킬 수 있다. ConcurrencyE2ETest의 Redis 키 정리도 동시 실행 시 오염 가능성이 있다.
수정안: 테스트 전용 유틸로 id 생성·주입을 추상화하거나 EntityManager 기반의 테스트 더블을 사용한다. Redis 테스트 키를 테스트별 고유 prefix로 분리하고, BeforeEach에서 엄격하게 정리한다.
추가 테스트: 테스트 격리성(병렬 실행) 시나리오에서 데이터 누수 여부를 검증하는 CI 레벨 테스트 추가.


위 항목들은 운영 안정성·데이터 일관성·이식성·테스트 신뢰성 측면에서 우선 확인해야 할 위험 지점이다.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.67% 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 제목은 주요 변경사항인 '이벤트 기반 아키텍처 및 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

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

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java (1)

103-119: ⚠️ Potential issue | 🟡 Minor

OutboxEventPublisher 호출에 대한 검증이 누락되었다.

AI 요약에 따르면, 결제가 SUCCESS로 전환될 때 PAYMENT_COMPLETED 이벤트가 Outbox로 발행되어야 한다. 그러나 현재 테스트에서는 outboxEventPublisher mock만 주입하고, 실제 호출 여부를 검증하지 않는다.

운영 관점에서 이벤트 발행 누락은 downstream 시스템(product_metrics 집계 등)의 데이터 정합성 문제로 이어질 수 있다.

🧪 이벤트 발행 검증 추가 제안
 `@DisplayName`("SUCCESS 콜백을 받으면 Payment는 COMPLETED, Order는 PAID로 전환된다.")
 `@Test`
 void completesPaymentAndMarksOrderPaid_whenSuccess() {
     // arrange
     Order order = orderRepository.save(Order.create(1L, List.of(new OrderItemSnapshot(1L, "상품", 10000L, 1))));
     Payment payment = paymentRepository.save(Payment.create(order.getId(), "pgOrderCode-001", CardType.SAMSUNG, "1234-5678-9012-3456", 10000L));
     payment.assignPgTransaction("TXN-001");

     // act
     paymentFacade.handleCallback(new PgCallbackCommand("TXN-001", "SUCCESS", null));

     // assert
     assertAll(
             () -> assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED),
-            () -> assertThat(orderRepository.findById(order.getId()).get().getStatus()).isEqualTo(Order.Status.PAID)
+            () -> assertThat(orderRepository.findById(order.getId()).get().getStatus()).isEqualTo(Order.Status.PAID),
+            () -> then(outboxEventPublisher).should().publish(any())
     );
 }

추가로, FAILED 콜백 시에는 이벤트가 발행되지 않아야 함을 검증하는 테스트도 고려해야 한다.

🤖 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/payment/PaymentFacadeTest.java`
around lines 103 - 119, The test completesPaymentAndMarksOrderPaid_whenSuccess
is missing verification that the outboxEventPublisher mock was called with a
PAYMENT_COMPLETED event; update this test to verify
outboxEventPublisher.publish(...) (or the actual publish method on the injected
outboxEventPublisher mock) was invoked with an OutboxEvent whose
type/aggregate/event name corresponds to PAYMENT_COMPLETED after
paymentFacade.handleCallback(...) returns, and add a complementary assertion in
the FAILED callback test to verify outboxEventPublisher.publish was not called;
reference the PaymentFacade.handleCallback invocation and the
outboxEventPublisher mock in your assertions.
🟠 Major comments (21)
modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java-24-37 (1)

24-37: ⚠️ Potential issue | 🟠 Major

직렬화 실패를 RuntimeException으로 통일하면 장애 분류와 재처리 정책이 불명확해진다.

운영 관점에서 페이로드 오류와 일시 장애를 구분하지 못하면 재시도 폭증 또는 DLQ 누락으로 이어질 수 있다. 공통 모듈 예외를 DataSerializationException 같은 명시 타입으로 분리해 호출부가 실패 유형별 정책을 적용할 수 있게 수정하는 것이 필요하다.
추가 테스트로 잘못된 JSON 입력 시 예외 타입과 cause 보존 여부를 검증하고, 직렬화 실패 케이스에서도 동일 타입이 발생하는지 확인하는 테스트를 추가하는 것이 좋다.

수정 예시
 public final class DataSerializer {
+    public static final class DataSerializationException extends RuntimeException {
+        public DataSerializationException(String message, Throwable cause) {
+            super(message, cause);
+        }
+    }
@@
     public static String serialize(Object object) {
         try {
             return objectMapper.writeValueAsString(object);
         } catch (JsonProcessingException e) {
-            throw new RuntimeException("[DataSerializer] serialize failed", e);
+            throw new DataSerializationException("[DataSerializer] serialize failed", e);
         }
     }
@@
     public static <T> T deserialize(String json, Class<T> clazz) {
         try {
             return objectMapper.readValue(json, clazz);
         } catch (IOException e) {
-            throw new RuntimeException("[DataSerializer] deserialize failed", e);
+            throw new DataSerializationException("[DataSerializer] deserialize failed", e);
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java`
around lines 24 - 37, Replace the generic RuntimeException usage in
DataSerializer.serialize and DataSerializer.deserialize with a specific checked
or unchecked DataSerializationException so callers can distinguish payload
errors from transient failures; create a new DataSerializationException class
(e.g., extends RuntimeException) and throw new
DataSerializationException("[DataSerializer] serialize failed", e) from
serialize and similarly from deserialize, preserving the original cause (the
caught JsonProcessingException/IOException) and referencing DataSerializer and
objectMapper to locate the change; also add unit tests for
DataSerializer.serialize with a type that fails to serialize and for
DataSerializer.deserialize with malformed JSON to assert the exception type is
DataSerializationException and that getCause() returns the original exception.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java-39-50 (1)

39-50: ⚠️ Potential issue | 🟠 Major

프로모션 생성 시 불변식 검증 누락으로 잘못된 데이터가 운영 DB에 적재될 수 있다.

운영 관점에서 maxQuantity <= 0 또는 startedAt >= endedAt 데이터가 저장되면 발급 요청이 대량 실패하고 장애 원인 파악이 어려워진다. create()에서 입력 불변식을 즉시 검증하고 CoreException으로 실패를 표준화하는 수정이 필요하다.
추가 테스트로 maxQuantity 경계값(0, 음수), 기간 역전(startedAt >= endedAt), null 입력 케이스를 단위 테스트로 보강하는 것이 좋다.

수정 예시
     public static CouponPromotion create(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) {
+        if (couponId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 식별자는 필수입니다.");
+        }
+        if (maxQuantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "최대 수량은 1 이상이어야 합니다.");
+        }
+        if (startedAt == null || endedAt == null || !startedAt.isBefore(endedAt)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "프로모션 기간이 올바르지 않습니다.");
+        }
         return new CouponPromotion(couponId, maxQuantity, startedAt, endedAt);
     }

Based on learnings: "In the loop-pack-be-l2-vol3-java project, 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/domain/coupon/CouponPromotion.java`
around lines 39 - 50, The create() factory in CouponPromotion currently allows
invalid state to be persisted; update CouponPromotion.create(Long couponId, int
maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) to validate inputs
and throw CoreException on failure (use same error type/format as
validateIssuable()), specifically: check couponId/startedAt/endedAt for null,
enforce maxQuantity > 0, and ensure startedAt.isBefore(endedAt); if any check
fails throw new CoreException(ErrorType.BAD_REQUEST, "...") with clear messages;
keep validateIssuable() as-is for runtime checks and add unit tests covering
maxQuantity boundary (0 and negative), startedAt >= endedAt, and null inputs to
assert CoreException is thrown.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java-30-30 (1)

30-30: ⚠️ Potential issue | 🟠 Major

좋아요 수를 상수 0으로 고정하면 조회 응답 정확도가 깨진다.

운영 관점에서 지표 집계가 정상이어도 API가 항상 0을 내려주면 장애 탐지가 늦어지고 사용자 신뢰가 떨어진다. from(Product)에서 고정값을 넣지 말고, 집계값을 주입받는 팩토리(또는 withLikeCount)를 추가해 호출부가 product_metrics.like_count를 전달하도록 수정하는 것이 안전하다.
추가 테스트로 ProductInfo 매핑 단위 테스트(집계값 반영 여부)와 상품 상세/목록 통합 테스트(실제 likeCount 노출 값 검증)를 보강하는 것이 필요하다.

수정 예시
 public record ProductInfo(
@@
 ) {
@@
-    public static ProductInfo from(Product product) {
+    public static ProductInfo from(Product product, Integer likeCount) {
         return new ProductInfo(
                 product.getId(),
                 new BrandSummary(product.getBrandId(), null),
                 product.getName(),
                 product.getDescription(),
                 product.getPrice(),
                 product.getStockQuantity(),
-                0,
+                likeCount == null ? 0 : likeCount,
                 product.getVisibility(),
                 product.getCreatedAt(),
                 product.getUpdatedAt(),
                 product.getDeletedAt()
         );
     }
🤖 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/product/ProductInfo.java`
at line 30, ProductInfo currently hardcodes likeCount to 0 in the from(Product)
mapping, which breaks metrics; update ProductInfo by removing the constant 0 in
the static factory from(Product) and add an overload or builder method (e.g.,
from(Product) + withLikeCount(long) or a new factory from(Product, long)) so
callers can inject product_metrics.like_count; update all call sites that build
ProductInfo (e.g., service/controller layers) to pass the aggregated like count,
and add/update unit test for ProductInfo mapping to assert the likeCount is
preserved and integration tests for product detail/list endpoints to verify the
actual likeCount is exposed.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java-43-51 (1)

43-51: ⚠️ Potential issue | 🟠 Major

발급 엔티티 팩토리에 도메인 불변식 검증이 없어 실패가 DB 시점으로 지연된다.

운영 관점에서 입력 이상이 소비 트랜잭션 말단에서 DataIntegrityViolationException로 터지면 원인 분류와 재시도 제어가 어려워진다. create()에서 userId/couponId/expiresAt null 검증과 만료 시각 유효성 검증을 선행해 조기 실패하도록 수정하는 것이 필요하다.
추가 테스트로 null 인자 케이스와 과거 만료 시각 케이스에서 즉시 예외가 발생하는지 단위 테스트를 추가하는 것이 좋다.

수정 예시
+import java.util.Objects;
@@
     public static IssuedCoupon create(Long userId, Long couponId, LocalDateTime expiresAt) {
+        Objects.requireNonNull(userId, "userId must not be null");
+        Objects.requireNonNull(couponId, "couponId must not be null");
+        Objects.requireNonNull(expiresAt, "expiresAt must not be null");
+        if (!expiresAt.isAfter(LocalDateTime.now())) {
+            throw new IllegalArgumentException("expiresAt must be in the future");
+        }
         IssuedCoupon issuedCoupon = new IssuedCoupon();
         issuedCoupon.userId = userId;
         issuedCoupon.couponId = couponId;
🤖 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/coupon/IssuedCoupon.java`
around lines 43 - 51, The IssuedCoupon.create factory currently defers invalid
inputs to the DB; add precondition checks in IssuedCoupon.create to validate
userId, couponId and expiresAt are non-null (userId, couponId, expiresAt) and
that expiresAt is in the future (compare to ZonedDateTime.now()), and throw
IllegalArgumentException with clear messages for each violation to fail fast;
update or add unit tests to assert create throws for null
userId/couponId/expiresAt and for an expiresAt in the past.
apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java-23-26 (1)

23-26: ⚠️ Potential issue | 🟠 Major

결제 이벤트 payload 방어가 없어서 소비 지연 장애로 확산될 수 있다.

Line 24에서 getProductIds()를 바로 순회하면 null 또는 null 원소가 포함된 메시지 1건만으로도 NPE가 발생해 소비 루프 재시도/지연을 유발할 수 있다. 운영 안정성을 위해 빈 목록/null 항목을 방어적으로 건너뛰고, 이상 데이터는 경고 로그로 남기는 형태가 필요하다. productIds가 null/empty/null 원소인 케이스에서 예외 없이 처리되고 유효 ID만 카운트 증가하는 테스트를 추가하는 것이 좋다.

수정안 예시
     `@Override`
     public void handle(Event<PaymentCompletedEventPayload> event) {
-        for (Long productId : event.getPayload().getProductIds()) {
-            productMetricsRepository.incrementOrderCount(productId);
-        }
+        if (event.getPayload() == null || event.getPayload().getProductIds() == null) {
+            return;
+        }
+        for (Long productId : event.getPayload().getProductIds()) {
+            if (productId == null) {
+                continue;
+            }
+            productMetricsRepository.incrementOrderCount(productId);
+        }
     }
🤖 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/handler/PaymentCompletedEventHandler.java`
around lines 23 - 26, PaymentCompletedEventHandler.handle currently iterates
event.getPayload().getProductIds() without null checks which can cause NPEs;
update handle(Event<PaymentCompletedEventPayload> event) to defensively check
for null payload and null/empty productIds, skip null productId elements, call
productMetricsRepository.incrementOrderCount(productId) only for non-null
positive IDs, and emit a warning-level log (including event id/context) when
productIds is null/empty or contains nulls/invalid entries; also add unit tests
for Event<PaymentCompletedEventPayload> scenarios (null productIds, empty list,
list with null element) verifying no exception is thrown and only valid IDs are
counted.
apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java-30-40 (1)

30-40: ⚠️ Potential issue | 🟠 Major

Outbox 생성 시각을 로컬 시간에 의존하면 재시도 안정성이 떨어진다.

Line 39의 LocalDateTime.now()는 JVM 기본 타임존에 의존하므로, 운영 환경에서 노드/DB 타임존이 다르면 스케줄러의 createdAt 기준 조회가 흔들려 지연 발행이나 배치 편향을 유발할 수 있다. UTC 기준의 절대시간(Instant 또는 OffsetDateTime)으로 저장하도록 변경하는 것이 안전하다. 추가로 서로 다른 타임존 설정에서 생성/조회 경계(<= now)가 일관적인지 통합 테스트를 넣는 것이 좋다.

수정안 예시
-import java.time.LocalDateTime;
+import java.time.Instant;
@@
-    private LocalDateTime createdAt;
+    private Instant createdAt;
@@
-        outbox.createdAt = LocalDateTime.now();
+        outbox.createdAt = Instant.now();
🤖 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/Outbox.java` around
lines 30 - 40, The Outbox.create method currently sets createdAt using
LocalDateTime.now(), which is timezone-dependent; change the Outbox.createdAt
field to a timezone-aware type (e.g., Instant or OffsetDateTime), update its JPA
mapping (or add a converter) accordingly, and set createdAt = Instant.now() (or
OffsetDateTime.now(ZoneOffset.UTC)) inside Outbox.create; also add/invoke
integration tests that create and query Outbox across differing JVM/DB timezone
settings to ensure createdAt-based queries (<= now) behave consistently.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java-49-51 (1)

49-51: ⚠️ Potential issue | 🟠 Major

LocalDateTime.now() 직접 호출은 테스트 불가능하고 타임존 의존성을 야기한다.

운영 환경에서 서버 타임존 설정에 따라 만료 판정 결과가 달라질 수 있다. 또한 단위 테스트에서 시간을 제어할 수 없어 경계값 테스트가 불가능하다. Clock을 주입받거나, 시간 비교 로직을 서비스 레이어로 이동하여 테스트 가능한 구조로 변경해야 한다.

🔧 권장 수정안: 서비스 레이어에서 시간 비교

도메인 엔티티에서 isIssuable() 메서드를 제거하고, 레포지토리 쿼리에서 이미 시간 조건을 적용하고 있으므로(findByIdAndDeletedAtIsNullAndExpiresAtAfter) 해당 쿼리 결과를 신뢰하는 방식으로 변경한다:

-    public boolean isIssuable() {
-        return deletedAt == null && expiresAt.isAfter(LocalDateTime.now());
-    }
+    public boolean isDeleted() {
+        return deletedAt != null;
+    }

또는 Clock을 파라미터로 받아 테스트 가능하게 변경한다:

public boolean isIssuable(Clock clock) {
    return deletedAt == null && expiresAt.isAfter(LocalDateTime.now(clock));
}
🤖 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/coupon/Coupon.java`
around lines 49 - 51, The isIssuable() method on Coupon directly calls
LocalDateTime.now(), which causes timezone dependency and makes tests
non-deterministic; either remove isIssuable() from the entity and rely on your
repository query (findByIdAndDeletedAtIsNullAndExpiresAtAfter) to enforce
expiry/deleted checks at the service layer, or change the method signature to
accept a Clock (e.g., isIssuable(Clock clock)) and use LocalDateTime.now(clock)
so callers can inject a test Clock; update callers/service logic accordingly to
pass a production Clock.systemDefaultZone() or a controllable test Clock.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java-18-18 (1)

18-18: ⚠️ Potential issue | 🟠 Major

Snowflake 인스턴스의 무작위 machine ID 할당으로 인한 ID 충돌 위험이 있다.

Snowflake 클래스의 기본 생성자에서 RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1)을 통해 0~1023 범위에서 무작위로 machine ID를 할당한다. 따라서 ProductViewEventPublisher에서 new Snowflake()를 매번 호출할 때마다 새로운 인스턴스가 생성되고, 서로 다른 machine ID를 가질 가능성이 높지만 동일한 machine ID를 할당받을 수 있다. 같은 machine ID를 가진 여러 인스턴스에서 동시에 생성된 ID는 timestamp와 sequence 값이 동일할 경우 충돌한다.

운영 관점: 프로덕션 환경에서 이벤트 기반 처리 시 ID 충돌로 인한 데이터 중복 또는 손실이 발생할 수 있다.

수정안:

  • Snowflake를 Spring Bean으로 등록하여 애플리케이션 전체에서 단일 인스턴스 공유
  • 또는 생성자에서 명시적으로 machine ID를 받아 환경별로 설정하는 방식 도입 (예: new Snowflake(machinedId))
  • machine ID 할당을 중앙 조정 방식(Zookeeper, ConfigMap 등)으로 관리

추가 테스트: 여러 Snowflake 인스턴스가 동일한 machine ID를 할당받는 시나리오에서 생성된 ID들이 실제로 충돌하지 않는지 확인하는 테스트 추가 필요.

🤖 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/product/ProductViewEventPublisher.java`
at line 18, ProductViewEventPublisher currently constructs a new Snowflake
instance (private final Snowflake eventIdSnowflake = new Snowflake()) which can
produce colliding IDs because Snowflake's default constructor assigns a random
machine ID; change to use a single, deterministic Snowflake instance
application-wide by either (A) defining a Spring `@Bean` that creates Snowflake
with a fixed machineId (injected into ProductViewEventPublisher) or (B) modify
Snowflake usage to accept an explicit machineId passed into
ProductViewEventPublisher's constructor from configuration/Env, and update
wiring to inject that configured machineId; ensure the chosen approach
guarantees a singleton Snowflake instance or centrally coordinated machineId
allocation.
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java-138-142 (1)

138-142: ⚠️ Potential issue | 🟠 Major

이벤트 페이로드에서 userIdnull로 전달되고 있다.

PaymentCompletedEventPayloaduserId 필드를 정의하지만, PaymentFacade 140번 줄에서 null로 전달된다. Order 엔터티는 userId 필드를 가지고 있으므로 주문 조회 시 이 정보를 함께 전달할 수 있다.

현재 PaymentCompletedEventHandlerproductIds만 사용하므로 즉각적인 오류는 없으나, 이벤트 컨트랙트로서 불완전하다. 다른 서비스에서 이 이벤트를 소비하거나 운영 추적/감사 로그에서 userId가 필요할 경우 누락된 데이터로 인한 운영 장애가 발생할 수 있다.

Order에서 userId를 조회하여 페이로드에 전달하거나, userId가 불필요하다면 PaymentCompletedEventPayload에서 해당 필드를 제거하여 설계를 명확히 한다.

🤖 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 138 - 142, PaymentCompletedEventPayload is being created with a
null userId in PaymentFacade (the call to
PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null,
productIds)); fix by retrieving the Order's userId and passing it into the
payload (e.g., load Order by payment.getOrderId() via your
OrderRepository/OrderService and use order.getUserId() instead of null), and
update any relevant tests; alternatively, if userId truly isn't part of the
event contract, remove the userId field from PaymentCompletedEventPayload and
adjust all callers (including PaymentFacade) and handlers accordingly.
apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java-33-37 (1)

33-37: ⚠️ Potential issue | 🟠 Major

핸들러 예외 발생 시 이벤트 재처리 무한 루프 위험이 있다.

현재 구현에서 handler.handle() 호출 시 예외가 발생하면 @Transactional에 의해 전체 트랜잭션이 롤백되며, EventHandled 레코드도 함께 롤백된다. 이 경우 동일 이벤트가 반복 재처리되어 무한 루프가 발생할 수 있다.

각 EventType마다 단일 핸들러만 supports()가 true를 반환하므로 (equality check 기반), 여러 핸들러가 경쟁하지 않는다. 하지만 사용자 쿠폰 발급 등 비멱등 작업을 수행하는 핸들러의 경우 재처리 시 중복 발급 위험이 높다.

개별 핸들러를 try-catch로 감싸고 실패를 로깅하되, 재시도 전략(exponential backoff, circuit breaker, Dead Letter Queue)을 적용해야 한다. 또한 핸들러별 멱등성을 설계 단계에서 명시하고, 쿠폰 같은 비멱등 작업은 별도 트랜잭션 경계로 격리할 것을 권장한다.

🤖 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/EventProcessingService.java`
around lines 33 - 37, EventProcessingService currently calls
handler.handle(event) inside the `@Transactional` boundary so any exception rolls
back EventHandled and causes infinite reprocessing; wrap each
handler.handle(event) call (iterating eventHandlers and using supports()) in a
try-catch, log the failure with contextual info, and on failure apply a
retry/poison-message strategy: mark the event as failed (e.g., publish to a Dead
Letter Queue or persist a failure record) rather than letting the main
transaction roll back, or execute the handler in a separate transaction boundary
so non-idempotent handlers (e.g., coupon issuance) do not get duplicated; ensure
to reference eventHandlers, supports(), handle(), EventHandled and the
`@Transactional` scope when making the change.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java-27-42 (1)

27-42: ⚠️ Potential issue | 🟠 Major

로그에 민감 정보가 노출될 수 있으며, 무한 재시도 위험이 있다.

운영 관점에서 두 가지 문제가 있다:

  1. 민감 정보 로깅: message 전체를 로그에 출력하면 userId, paymentId, orderId 등의 민감 정보가 로그 시스템에 노출된다. GDPR/개인정보보호 관점에서 위험하다.

  2. 무한 재시도: nack(Duration.ofSeconds(1))은 처리 실패 시 무한히 재시도하므로, 특정 메시지가 지속적으로 실패하면 해당 파티션의 처리가 영구히 지연된다(poison pill blocking).

🛡️ 수정안
     public void consume(String message, Acknowledgment ack) {
         try {
             Event<EventPayload> event = Event.fromJson(message);
             if (event == null) {
-                log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message);
+                log.warn("[MetricsEventConsumer] 이벤트 파싱 실패 - 메시지 스킵");
                 ack.acknowledge();
                 return;
             }

             eventProcessingService.process(event);
             ack.acknowledge();
         } catch (Exception e) {
-            log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e);
+            log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, eventType={}", 
+                    extractEventTypeForLogging(message), e);
             ack.nack(Duration.ofSeconds(1));
         }
     }

추가 고려사항:

  • 재시도 횟수 제한 및 Dead Letter Topic(DLT) 전송 로직 추가를 검토해야 한다.
  • ErrorHandler 또는 RetryTopicConfiguration을 활용한 재시도 정책 구성을 권장한다.
🤖 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/MetricsEventConsumer.java`
around lines 27 - 42, The consume method in MetricsEventConsumer currently logs
the entire message and nacks with an unbounded retry; change it to stop logging
the raw message (use Event.fromJson to extract and log only non-sensitive
metadata like eventId/type or mask userId/paymentId/orderId) and implement a
retry/dead-letter policy instead of infinite nack: add a retry counter (e.g., a
retry header or field) when calling ack.nack or use your framework's
Retry/DeadLetter handling (e.g., RetryTopicConfiguration or an ErrorHandler) to
limit retries and route to a DLT after N attempts; update consume to
check/increment the retry count before nack and call ack.acknowledge or forward
to DLT when exceeded, and ensure eventProcessingService.process remains called
only after masking/validating input.
modules/event-contract/src/main/java/com/loopers/event/Snowflake.java-18-20 (1)

18-20: ⚠️ Potential issue | 🟠 Major

분산 환경에서 machineId 충돌 가능성이 있다.

RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1)로 0~1023 범위에서 무작위로 선택하므로, 다중 인스턴스 배포 시 동일 machineId가 할당될 확률이 존재한다. 인스턴스 수가 많아질수록 충돌 확률이 높아진다(Birthday Paradox).

수정안: machineId를 외부에서 주입받는 생성자를 추가하고, 프로덕션 환경에서는 인스턴스별 고유값을 설정으로 제공하는 것을 권장한다.

♻️ machineId 주입 생성자 추가
 public Snowflake() {
     this.machineId = RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1);
 }
+
+public Snowflake(long machineId) {
+    if (machineId < 0 || machineId > MAX_MACHINE_ID) {
+        throw new IllegalArgumentException("machineId must be between 0 and " + MAX_MACHINE_ID);
+    }
+    this.machineId = machineId;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/event-contract/src/main/java/com/loopers/event/Snowflake.java` around
lines 18 - 20, The Snowflake constructor currently assigns machineId randomly
causing collision risk; add an overloaded constructor Snowflake(long machineId)
that accepts and sets the machineId (validate it is >=0 and <= MAX_MACHINE_ID
and throw IllegalArgumentException if out of range), keep or deprecate the
no-arg constructor as a fallback but prefer the injected constructor for
production, and update any usages to allow providing a configuration-derived
unique machineId per instance.
modules/event-contract/src/main/java/com/loopers/event/Snowflake.java-22-36 (1)

22-36: ⚠️ Potential issue | 🟠 Major

시스템 시계 역행(clock skew) 시 ID 충돌 또는 비단조성 문제가 발생할 수 있다.

운영 환경에서 NTP 동기화, VM 마이그레이션, 컨테이너 재시작 등으로 시스템 시계가 역행할 수 있다. 현재 구현에서 timestamp < lastTimestamp인 경우 별도 처리 없이 sequence를 0으로 리셋하고 진행하므로, 이전에 발급된 ID와 충돌하거나 시간 순서가 역전된 ID가 생성될 수 있다.

수정안: 시계 역행 감지 시 예외를 던지거나, lastTimestamp까지 대기하는 방어 로직을 추가해야 한다.

🛡️ 시계 역행 방어 로직 추가
 public synchronized long nextId() {
     long timestamp = System.currentTimeMillis() - EPOCH;
+    if (timestamp < lastTimestamp) {
+        // 시계가 역행한 경우: lastTimestamp까지 대기하거나 예외 발생
+        timestamp = waitNextMillis(lastTimestamp);
+    }
     if (timestamp == lastTimestamp) {
         sequence = (sequence + 1) & MAX_SEQUENCE;
         if (sequence == 0) {
             timestamp = waitNextMillis(lastTimestamp);
         }
     } else {
         sequence = 0;
     }
     lastTimestamp = timestamp;
     return (timestamp << (MACHINE_ID_BITS + SEQUENCE_BITS))
             | (machineId << SEQUENCE_BITS)
             | sequence;
 }

추가 테스트: 시계 역행 시나리오를 시뮬레이션하는 단위 테스트를 추가하여 방어 로직이 정상 동작하는지 검증해야 한다.

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

In `@modules/event-contract/src/main/java/com/loopers/event/Snowflake.java` around
lines 22 - 36, The nextId() method currently ignores clock rollback (timestamp <
lastTimestamp) which can produce duplicate or non-monotonic IDs; update nextId()
in class Snowflake to detect when timestamp < lastTimestamp and handle it by
either throwing a clear exception (e.g., IllegalStateException with
lastTimestamp and current timestamp) or by blocking until clock catches up (call
waitNextMillis(lastTimestamp)) before proceeding; ensure lastTimestamp is only
updated after the safe timestamp is determined, keep the existing sequence
handling (sequence increment and reset) intact, and add a unit test that
simulates a clock rollback to validate the new defensive behavior.
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java-14-15 (1)

14-15: ⚠️ Potential issue | 🟠 Major

다중 인스턴스 환경에서 Snowflake machineId 충돌 위험이 있다.

운영 관점에서 new Snowflake()는 매 초기화 시 RandomGenerator로 machineId를 선택하므로, 다중 인스턴스 배포(쿠버네티스, 다중 Pod) 환경에서 동일한 machineId를 가진 인스턴스가 존재할 수 있다. 1024개(10비트) 범위 내에서 무작위 선택되므로, 두 개 이상의 commerce-api 인스턴스가 같은 machineId를 할당받을 가능성이 있으며, 이 경우 동일 밀리초 내에 생성된 outboxId 또는 eventId가 충돌할 수 있다.

수정안:

  1. 환경 변수 또는 ConfigMap을 통해 인스턴스별 고유 machineId를 주입받도록 Snowflake 생성자 수정
  2. Kubernetes 환경이라면 Pod ordinal index를 활용하여 machineId 계산
  3. Snowflake 빈을 별도로 구성하여 설정 파일에서 명시적으로 machineId 지정

추가 테스트: 다중 인스턴스 환경에서 ID 충돌 여부를 검증하는 통합 테스트 작성 필요. SnowflakeTest는 단일 인스턴스 내 동시성만 검증하며, 서로 다른 machineId를 가진 인스턴스 간 충돌을 검증하지 않는다.

🤖 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/outbox/OutboxEventPublisher.java`
around lines 14 - 15, OutboxEventPublisher currently creates two Snowflake
instances with new Snowflake() (outboxIdSnowflake and eventIdSnowflake) which
picks a random machineId and can collide across instances; change this so
Snowflake receives a deterministic machineId from configuration: add a
configurable property (env/ConfigMap) or compute from Pod ordinal, inject it
into a Snowflake bean, and replace the direct new Snowflake() calls in
OutboxEventPublisher with that injected bean (or two beans if you need separate
generators) so machineId is explicitly set and testable (reference:
OutboxEventPublisher, outboxIdSnowflake, eventIdSnowflake, Snowflake).
apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java-25-49 (1)

25-49: ⚠️ Potential issue | 🟠 Major

조회한 요청 엔티티의 식별값을 기준으로 발급해야 한다

Line 25-49는 requestId로 요청을 조회한 뒤에도 이벤트 payload의 couponIduserId를 그대로 사용한다. 운영에서 outbox 직렬화 버그나 잘못 재발행된 이벤트가 들어오면 다른 사용자의 요청 상태를 완료로 바꾸면서 엉뚱한 쿠폰을 발급할 수 있다. process()requestId만 받아 request.getCouponId()request.getUserId()만 사용하거나, payload와 불일치하면 예외로 롤백해야 한다. request row와 payload 값이 다를 때 발급이 거부되고 상태가 유지되는 테스트를 추가해야 한다.

🤖 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/CouponIssueProcessor.java`
around lines 25 - 49, process(Long requestId, Long couponId, Long userId)
currently trusts the payload couponId/userId instead of the loaded
CouponIssueRequest; change the logic in process() to either (A) use
request.getCouponId() and request.getUserId() for all downstream operations
(existence check, issuedCouponRepository.save, and request.fail/succeed) or (B)
validate that payload couponId and userId match request.getCouponId() and
request.getUserId(), and if they differ throw/reject so the transaction rolls
back and the request remains pending; update checks that call
issuedCouponRepository.existsByUserIdAndCouponId and
couponRepository.findIssuableCoupon to use the request-derived IDs, and add a
test that simulates a mismatched payload to assert the request state is
unchanged and no IssuedCoupon is saved.
modules/event-contract/src/main/java/com/loopers/event/Event.java-14-36 (1)

14-36: ⚠️ Potential issue | 🟠 Major

이벤트 계약 검증을 생성·역직렬화 시점에 강제해야 한다

Line 14-36은 eventId, type, payload의 null 여부와 type-payload 조합을 확인하지 않는다. 운영에서 잘못된 이벤트 한 건이 Kafka에 들어가면 소비 측에서 재시도 루프를 만들거나 잘못된 집계로 이어질 수 있다. of()fromJson()에서 공통 검증 메서드로 non-null과 type.getPayloadClass().isInstance(payload)를 강제하고, null 필드 및 타입 불일치 JSON에 대한 단위 테스트를 추가해야 한다.

수정 예시
+import java.util.Objects;
+
 `@Getter`
 `@NoArgsConstructor`
 public class Event<T extends EventPayload> {
 
+    private static void validate(EventType type, EventPayload payload) {
+        Objects.requireNonNull(type, "type must not be null");
+        Objects.requireNonNull(payload, "payload must not be null");
+        if (!type.getPayloadClass().isInstance(payload)) {
+            throw new IllegalArgumentException("Payload does not match event type: " + type);
+        }
+    }
+
     private Long eventId;
     private EventType type;
     private T payload;
 
     public static <T extends EventPayload> Event<T> of(Long eventId, EventType type, T payload) {
+        Objects.requireNonNull(eventId, "eventId must not be null");
+        validate(type, payload);
         Event<T> event = new Event<>();
         event.eventId = eventId;
         event.type = type;
@@
     public static Event<EventPayload> fromJson(String json) {
         EventRaw raw = DataSerializer.deserialize(json, EventRaw.class);
         if (raw == null) {
             return null;
         }
         Event<EventPayload> event = new Event<>();
         event.eventId = raw.getEventId();
         event.type = EventType.from(raw.getType());
         event.payload = DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass());
+        Objects.requireNonNull(event.eventId, "eventId must not be null");
+        validate(event.type, event.payload);
         return event;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/event-contract/src/main/java/com/loopers/event/Event.java` around
lines 14 - 36, Add a shared validation method in Event (e.g.,
validateEvent(eventId, type, payload)) and call it from both Event.of(...) and
Event.fromJson(...): ensure eventId, type and payload are non-null and assert
that type.getPayloadClass().isInstance(payload); on violation throw a clear
IllegalArgumentException. In fromJson, perform validation after deserializing
EventRaw and after converting payload with
DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass()). Add
unit tests covering null eventId/type/payload and JSON payload type-mismatch
(using EventRaw -> fromJson path) to ensure exceptions are raised for invalid
inputs.
apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java-46-72 (1)

46-72: ⚠️ Potential issue | 🟠 Major

핸들러 예외 시 원자성 검증이 빠져 있다

현재 테스트는 단위 테스트로 Mock을 사용하고 있어 실제 트랜잭션 경계를 검증하지 못한다. 운영 환경에서 핸들러가 RuntimeException을 던지면 @Transactional 경계 내에서 전체 트랜잭션이 롤백되어야 event_handled 레코드도 함께 롤백된다. 그러나 이 동작을 검증하는 테스트가 없다.

현재 테스트 범위:

  • ✓ 성공 경로: 이벤트 처리 및 핸들러 실행
  • ✓ 중복 감지: 저장 실패 시 핸들러 미실행
  • 예외 흐름: 핸들러 실패 시 트랜잭션 롤백

핸들러 예외 시 event_handled만 남으면 해당 Kafka 메시지는 영구 유실된다. 통합 테스트를 추가하여 핸들러가 RuntimeException을 던질 때 전체 트랜잭션이 롤백되고 event_handled가 저장되지 않는 것을 검증해야 한다.

🤖 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/EventProcessingServiceTest.java`
around lines 46 - 72, Add an integration test (e.g., in
EventProcessingServiceTest or a new class annotated with `@SpringBootTest` and a
real DB via Testcontainers/@AutoConfigureTestDatabase) that does not mock
EventHandledRepository or the handler; register a test handler bean that throws
a RuntimeException when invoked, call eventProcessingService.process(event),
catch the exception, and then assert that eventHandledRepository has no
persisted record (e.g., count() == 0 or findByEventId returns empty) to verify
the `@Transactional` boundary rolls back both handler work and the event_handled
insert; refer to EventProcessingService.process, EventHandledRepository, and the
handler bean used in tests.
apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java-48-53 (1)

48-53: ⚠️ Potential issue | 🟠 Major

예외 타입을 구분하여 중복 발급과 다른 무결성 오류를 분리하여 처리해야 한다.

Line 48-54에서 모든 DataIntegrityViolationException을 중복 발급으로 처리하면, NOT NULL 위반이나 FK 제약 위반 같은 다른 무결성 오류도 재시도 없이 FAILED로 종료되어 이벤트가 유실되고 원인 추적이 불가능해진다. 중복 제약조건 위반만 request.fail()로 매핑하고, 나머지 무결성 오류는 cause와 함께 로깅한 후 예외를 재던져 트랜잭션을 롤백해야 한다.

수정 예시
         try {
             issuedCouponRepository.save(IssuedCoupon.create(userId, couponId, coupon.getExpiresAt()));
         } catch (DataIntegrityViolationException e) {
-            log.warn("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId);
-            request.fail("이미 발급된 쿠폰입니다.");
-            return;
+            if (isDuplicateIssuedCoupon(e)) {
+                log.info("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId);
+                request.fail("이미 발급된 쿠폰입니다.");
+                return;
+            }
+            log.warn("[CouponIssueProcessor] 쿠폰 발급 처리 실패, requestId={}, couponId={}, userId={}", requestId, couponId, userId, e);
+            throw e;
         }

중복 제약 위반(user_id, coupon_id 조합)과 다른 무결성 제약 위반을 식별하는 isDuplicateIssuedCoupon() 메서드를 추가하고, 각각 FAILED 처리와 롤백 재시도로 동작을 검증하는 테스트를 작성해야 한다.

🤖 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/CouponIssueProcessor.java`
around lines 48 - 53, The catch block that treats all
DataIntegrityViolationException as duplicate issuance must be split: implement a
helper isDuplicateIssuedCoupon(Throwable cause) and in the catch for
DataIntegrityViolationException from
issuedCouponRepository.save(IssuedCoupon.create(...)) call it; if true, keep the
current behavior (log warn with requestId/couponId/userId and call
request.fail("이미 발급된 쿠폰입니다.")), otherwise log the full cause (including
e.getCause()) with error level and rethrow the exception to trigger transaction
rollback and retrial; ensure references to IssuedCoupon.create,
issuedCouponRepository.save, request.fail, and the new isDuplicateIssuedCoupon
method are used so tests can target duplicate vs other constraint violations.
apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java-52-61 (1)

52-61: ⚠️ Potential issue | 🟠 Major

재시도 스케줄러가 동일 outbox를 여러 워커가 동시에 집을 수 있다.

현재는 pending row를 조회한 뒤 별도 선점 없이 바로 발행하므로, 같은 JVM의 AFTER_COMMIT 비동기 경로와 스케줄러가 동시에 처리하거나 다중 인스턴스 환경에서 동일 row가 중복 발행될 수 있다. 장애 시 동일 이벤트가 폭증해 consumer idempotency 테이블을 과도하게 두드리므로, PROCESSING 상태 선점, FOR UPDATE SKIP LOCKED, 또는 리더 선출/분산 락 중 하나로 단일 소유권을 보장하기 바란다. 동일 pending row에 대해 두 워커가 동시에 재시도해도 Kafka send가 1회만 호출되는 통합 테스트를 추가해야 한다.

🤖 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/outbox/MessageRelay.java`
around lines 52 - 61, publishPendingEvents currently fetches pending Outbox rows
and calls publishToKafka without any ownership guard, allowing multiple workers
or the AFTER_COMMIT path to process the same row concurrently; modify the flow
to claim rows before sending by updating Outbox to a PROCESSING state (or select
rows with FOR UPDATE SKIP LOCKED) via outboxRepository and only call
publishToKafka for successfully claimed rows, or alternatively guard with a
distributed lock/leader election around publishPendingEvents; also add an
integration test that simulates two concurrent workers attempting to process the
same pending Outbox and asserts that publishToKafka (or the Kafka send) is
invoked exactly once.
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java-33-51 (1)

33-51: ⚠️ Potential issue | 🟠 Major

트랜잭션 롤백 시 Redis 발급 수가 복구되지 않는다.

increment()는 외부 상태를 먼저 바꾸지만 보상은 품절/중복 두 분기에만 있다. 이후 outboxEventPublisher.publish(), JPA flush, commit 단계에서 예외가 나면 DB는 롤백돼도 Redis 카운트는 남아 조기 소진이 발생하므로, rollback hook(afterCompletion)으로 보상을 등록하거나 카운트 확정을 커밋 이후 단계로 옮기기 바란다. OutboxEventPublisher가 예외를 던질 때 요청 row는 저장되지 않고 Redis 카운트도 원복되는 통합 테스트를 추가해야 한다.

🤖 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/CouponIssueRequestFacade.java`
around lines 33 - 51, The Redis counter is incremented before DB commit, so if
later steps (outboxEventPublisher.publish, JPA flush/commit) fail the Redis
count is not restored; in CouponIssueRequestFacade, change the flow to either
(A) register a transaction synchronization
(TransactionSynchronizationManager.registerSynchronization) after calling
couponIssueCountManager.increment(couponId) that will decrement on
afterCompletion when the transaction did not commit, or (B) move the increment
to run after successful commit (use registerSynchronization's afterCommit to
call couponIssueCountManager.increment) and keep decrement handling for the
duplicate/overflow branches; update logic around
couponIssueCountManager.increment/decrement, CouponIssueRequest.create/save and
outboxEventPublisher.publish accordingly and add an integration test that
simulates outboxEventPublisher throwing so the DB rolls back and asserts Redis
count is unchanged.
apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java-39-49 (1)

39-49: ⚠️ Potential issue | 🟠 Major

1초 송신 대기 제한이 중복 발행을 만든다.

브로커 ACK가 1초를 넘으면 이 코드는 실패로 판단하고 outbox row를 남기는데, 실제 전송은 뒤늦게 성공할 수 있어 같은 이벤트가 재시도 경로에서 다시 발행될 수 있다. 운영에서는 consumer idempotency 저장소와 쿠폰/집계 처리 경로에 불필요한 중복 부하가 생기므로, get(1, TimeUnit.SECONDS) 대신 Kafka producer의 delivery timeout과 성공 콜백 완료 시점에만 삭제를 수행하는 방식으로 바꾸기 바란다. send future가 1초 이후 성공하는 상황을 주입해 outbox가 재전송되지 않는지 검증하는 테스트를 추가해야 한다.

🤖 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/outbox/MessageRelay.java`
around lines 39 - 49, The publishToKafka method currently blocks on
kafkaTemplate.send(...).get(1, TimeUnit.SECONDS) which causes false failures and
leaves the outbox row when the broker ACK is slower; change it to not use the 1s
get timeout and instead attach a success/failure callback to the send future
(e.g., use the send Future/ListenableFuture callback) so that
outboxRepository.delete(outbox) is invoked only on successful send and failures
are handled/logged in the failure callback; ensure you rely on the Kafka
producer delivery timeout config rather than an artificial 1s get, and add a
unit/integration test that injects a delayed successful send (future completes
after >1s) to verify the outbox is not retried/duplicated.
🟡 Minor comments (11)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java-44-51 (1)

44-51: ⚠️ Potential issue | 🟡 Minor

상태 전이 가드가 없어 잘못된 상태 변경이 가능하다.

succeed()fail() 메서드가 현재 상태를 검증하지 않아, 이미 SUCCESS 또는 FAILED 상태인 요청에 대해 중복 호출 시 상태가 덮어씌워질 수 있다. Kafka Consumer 재처리 시나리오에서 멱등성 보장에 영향을 줄 수 있다.

🛡️ 상태 전이 검증 추가 제안
 public void succeed() {
+    if (this.status != Status.PENDING) {
+        throw new IllegalStateException("Only PENDING requests can succeed. Current: " + this.status);
+    }
     this.status = Status.SUCCESS;
 }

 public void fail(String reason) {
+    if (this.status != Status.PENDING) {
+        throw new IllegalStateException("Only PENDING requests can fail. Current: " + this.status);
+    }
     this.status = Status.FAILED;
     this.reason = reason;
 }

대안으로, 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/domain/coupon/CouponIssueRequest.java`
around lines 44 - 51, The succeed() and fail(String) methods currently overwrite
status unconditionally; add state-transition guards in CouponIssueRequest so
terminal states cannot be overwritten: in succeed() check current status and
only set this.status = Status.SUCCESS (and set reason null if desired) when not
already SUCCESS or FAILED, and in fail(String reason) only set this.status =
Status.FAILED and this.reason when current status is not SUCCESS or FAILED (or
throw/return early); update or add unit tests for succeed() and fail() to verify
idempotency and that Status.SUCCESS / Status.FAILED and reason are not changed
once terminal.
apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java-39-48 (1)

39-48: ⚠️ Potential issue | 🟡 Minor

ThreadPoolTaskExecutor 설정에 운영 안정성 관련 문제가 있다.

  1. 수동 initialize() 호출 불필요: Spring Bean으로 등록되면 InitializingBean.afterPropertiesSet()이 자동 호출되어 초기화된다. 수동 호출은 중복이며 혼란을 줄 수 있다.

  2. RejectedExecutionHandler 미설정: 큐(100)가 가득 차고 max 스레드(4)가 모두 사용 중일 때 기본적으로 AbortPolicy가 적용되어 RejectedExecutionException이 발생한다. Outbox 발행 실패 시 재시도 스케줄러가 처리하겠지만, 예외 로깅이 과도해질 수 있다.

  3. Graceful shutdown 미설정: 애플리케이션 종료 시 진행 중인 Kafka 발행 작업이 유실될 수 있다.

🛡️ 개선된 Executor 설정
 `@Bean`("outboxPublishExecutor")
 public Executor outboxPublishExecutor() {
     ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
     executor.setCorePoolSize(2);
     executor.setMaxPoolSize(4);
     executor.setQueueCapacity(100);
     executor.setThreadNamePrefix("outbox-publish-");
+    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+    executor.setWaitForTasksToCompleteOnShutdown(true);
+    executor.setAwaitTerminationSeconds(30);
-    executor.initialize();
     return executor;
 }

CallerRunsPolicy를 사용하면 큐 초과 시 호출 스레드에서 직접 실행하여 백프레셔 효과를 얻을 수 있다. 단, 호출 스레드 블로킹 영향을 고려해야 한다.

🤖 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/config/OutboxConfig.java` around
lines 39 - 48, The outboxPublishExecutor bean currently calls
ThreadPoolTaskExecutor.initialize() manually, lacks a RejectedExecutionHandler
and graceful shutdown settings; remove the manual initialize() call, configure
the executor to use a CallerRunsPolicy for rejection handling (to provide
backpressure), and enable graceful shutdown by calling
setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(...) on
the ThreadPoolTaskExecutor in the outboxPublishExecutor method so in-flight
Kafka publishes are allowed to complete on application stop.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java-42-90 (1)

42-90: ⚠️ Potential issue | 🟡 Minor

시작/종료 시각 경계 테스트가 빠져 있다.

프로모션 발급 로직은 보통 오픈/마감 시각 경계에서만 장애가 재현되므로, startedAt와 정확히 같은 시각 및 endedAt와 정확히 같은 시각에 대한 정책이 테스트에 없으면 배포 후 특정 시점에만 실패가 나도 CI에서 잡지 못한다. 경계값 케이스를 명시적으로 추가하고, 필요하면 현재 시각을 고정할 수 있게 만들어 테스트를 결정적으로 유지하는 편이 안전하다. 추가로 startedAt == now, endedAt == now를 각각 분리해 검증하는 테스트를 보강하기 바란다.

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/test/java/com/loopers/domain/coupon/CouponPromotionTest.java`
around lines 42 - 90, Add two boundary tests for startedAt == now and endedAt ==
now to make issuance checks deterministic: capture ZonedDateTime now into a
local variable and create a promotion with CouponPromotion.create(1L, 100, now,
now.plusDays(1)) then call promotion.validateIssuable() and assert no exception
(method: validateIssuable), and create another promotion with
CouponPromotion.create(1L, 100, now.minusDays(1), now) then call
validateIssuable() and assert it throws CoreException with
ErrorType.BAD_REQUEST; place these near the other tests in the ValidateIssuable
nested class and reuse the same now variable so the tests are deterministic.
apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java-17-21 (1)

17-21: ⚠️ Potential issue | 🟡 Minor

비동기 이벤트 리스너의 예외 처리 및 페이로드 로깅 보안 점검 필요

운영 관점 문제점:

  1. @Async 리스너에서 예외 발생 시 기본적으로 SimpleAsyncUncaughtExceptionHandler가 로깅만 하고 종료된다. 로깅 실패가 비즈니스 로직에 영향을 주지 않아야 하지만, 예외 추적이 어려울 수 있다.
  2. outbox.getPayload()를 INFO 레벨로 전체 출력하면 userId 등 사용자 식별자가 로그에 남을 수 있다. 로그 집계 시스템에서 민감 정보 노출 위험이 있다.
🛡️ 수정안
 `@Async`("outboxPublishExecutor")
 `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
 public void handleOutboxEvent(OutboxEvent event) {
-    Outbox outbox = event.getOutbox();
-    log.info("[UserBehavior] type={}, partitionKey={}, payload={}",
-            outbox.getEventType(), outbox.getPartitionKey(), outbox.getPayload());
+    try {
+        Outbox outbox = event.getOutbox();
+        log.info("[UserBehavior] type={}, partitionKey={}",
+                outbox.getEventType(), outbox.getPartitionKey());
+        log.debug("[UserBehavior] payload={}", outbox.getPayload());
+    } catch (Exception e) {
+        log.warn("[UserBehavior] Failed to log outbox event", e);
+    }
 }

추가 검토 사항: 로깅 레벨 정책(DEBUG vs INFO)과 민감 정보 마스킹 정책을 팀 내에서 확인하라.

🤖 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/UserBehaviorLoggingListener.java`
around lines 17 - 21, The async listener
UserBehaviorLoggingListener::handleOutboxEvent must not blindly log full
payloads or swallow exceptions: wrap the body in a try-catch that logs
unexpected exceptions via log.error including the exception stack (so async
uncaught errors are visible) and ensure normal processing is unaffected; change
payload logging to either log only non-sensitive metadata at INFO and the full
payload at DEBUG, or apply a masking function to Outbox.getPayload() (implement
a sanitizer that strips userId/PII) before including it in log.info, referencing
handleOutboxEvent, UserBehaviorLoggingListener, and Outbox.getPayload to locate
the code to update.
apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java-6-9 (1)

6-9: ⚠️ Potential issue | 🟡 Minor

제네릭 타입 시그니처의 타입 안전성 개선 필요

EventProcessingService.process() 메서드에서 @SuppressWarnings("unchecked")로 이미 억제되고 있으나, 근본적인 설계 개선이 필요하다.

문제점: supports(Event<EventPayload>) 메서드의 느슨한 타입과 handle(Event<T>) 메서드의 구체적 타입 사이 불일치로 인해 타입 체커 경고가 발생한다. 또한 List<EventHandler> 선언이 원시 타입(raw type)으로 설정되어 있어 추가 타입 안전성 문제를 야기한다.

운영 영향: 현재는 Java 타입 소거(type erasure)로 인해 런타임 ClassCastException 위험이 없지만, 코드 유지보수성과 타입 안전성이 저하되고 미래 리팩토링 시 버그 위험이 높아진다.

개선안:

// 1. EventHandler 인터페이스 개선
public interface EventHandler<T extends EventPayload> {
    boolean supports(Event<? extends EventPayload> event);
    void handle(Event<T> event);
}

// 2. EventProcessingService의 List 타입 명시
private final List<EventHandler<?>> eventHandlers;

이렇게 수정하면 @SuppressWarnings("unchecked")를 제거할 수 있다.

🤖 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/EventHandler.java`
around lines 6 - 9, The EventHandler generic signatures are too loose which
causes unchecked casts in EventProcessingService; update the EventHandler
interface so supports accepts Event<? extends EventPayload> while keeping
handle(Event<T>) as-is, and change the EventProcessingService eventHandlers
field to use a parameterized List<EventHandler<?>> instead of a raw List; update
any usages in EventProcessingService.process() accordingly so you can remove the
`@SuppressWarnings`("unchecked") and restore type safety (refer to the
EventHandler interface, supports(...) and handle(...), and the
EventProcessingService.eventHandlers and process(...) method).
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java-12-19 (1)

12-19: ⚠️ Potential issue | 🟡 Minor

매핑 진입점에서 null을 즉시 검증해야 한다.

Line 12-19는 request 또는 request.getStatus()가 null이면 NPE로 터져 장애 시 원인 추적이 어려워진다. 운영 관점에서 예외 원인을 명확히 남기기 위해 fail-fast 검증을 추가하는 것이 안전하다. 또한 null 입력/상태에 대한 단위 테스트를 추가해 예외 타입과 메시지를 고정하는 것이 좋다.

수정안 예시
 import com.loopers.domain.coupon.CouponIssueRequest;
+import java.util.Objects;
@@
     public static CouponIssueRequestInfo from(CouponIssueRequest request) {
+        Objects.requireNonNull(request, "couponIssueRequest must not be null");
+        Objects.requireNonNull(request.getStatus(), "couponIssueRequest.status must not be null");
         return new CouponIssueRequestInfo(
             request.getId(),
             request.getCouponId(),
             request.getUserId(),
             request.getStatus().name(),
             request.getReason()
         );
     }

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/application/coupon/CouponIssueRequestInfo.java`
around lines 12 - 19, The from(CouponIssueRequest request) mapping should
fail-fast on nulls: validate that request is not null and that
request.getStatus() is not null at the start of
CouponIssueRequestInfo.from(...); if either is null throw a clear, deterministic
exception (e.g., IllegalArgumentException or NullPointerException) with a
descriptive message indicating which value is null so logs are actionable, then
proceed to use request.getStatus().name(); after making this change, add unit
tests for null request and null status to assert the exact exception type and
message.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java-35-36 (1)

35-36: ⚠️ Potential issue | 🟡 Minor

시간 타입이 불일치한다: expiresAtLocalDateTime, 나머지 감사 필드는 ZonedDateTime이다.

운영 관점에서 타임존이 혼재된 경우, 서버 타임존 설정에 따라 만료 시간 비교가 예상과 다르게 동작할 수 있다. expiresAtZonedDateTime으로 통일하거나, 모든 시간 필드를 Instant로 표준화하는 것이 타임존 관련 장애를 예방한다.

Also applies to: 38-45

🤖 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/coupon/Coupon.java`
around lines 35 - 36, The expiresAt field (private LocalDateTime expiresAt) is
inconsistent with the audit fields which use ZonedDateTime; change expiresAt and
the other time-related fields referenced around lines 38-45 to use a single
standardized timezone-safe type (preferably java.time.Instant) so all timestamps
are consistent; update the field types, imports, and any JPA column
mappings/serialization logic (e.g., in Coupon class and any
getters/setters/constructors) to Instant and ensure nullability annotations
(`@Column`(nullable = false)) remain correct.
apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java-26-31 (1)

26-31: ⚠️ Potential issue | 🟡 Minor

DataIntegrityViolationException 처리가 중복 이벤트 외의 경우도 포함한다.

DataIntegrityViolationException은 PK 중복 외에도 FK 제약 조건 위반, 컬럼 길이 초과 등 다양한 원인으로 발생할 수 있다. 현재 구현에서는 모든 DataIntegrityViolationException을 "이미 처리된 이벤트"로 간주하여 무시하므로, 실제 데이터 무결성 오류가 발생해도 감지되지 않는다.

운영 관점에서 이벤트가 조용히 누락되면 디버깅이 어렵다. 예외 메시지나 SQL 상태 코드를 확인하여 정확히 중복 키 위반인지 검증하거나, 별도의 existsById 체크를 선행하는 방안을 검토해야 한다.

🔍 중복 키 위반만 처리하는 예시
         try {
             eventHandledRepository.save(new EventHandled(event.getEventId(), LocalDateTime.now()));
         } catch (DataIntegrityViolationException e) {
+            // 중복 키 위반인지 확인 (MySQL: 1062, PostgreSQL: 23505)
+            if (isDuplicateKeyException(e)) {
+                log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId());
+                return;
+            }
+            throw e; // 다른 무결성 오류는 재전파
-            log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId());
-            return;
         }
🤖 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/EventProcessingService.java`
around lines 26 - 31, The catch-all DataIntegrityViolationException hides
non-duplicate integrity errors; update EventProcessingService to distinguish
true duplicate-key cases from other integrity failures before treating an event
as already handled: either (a) perform a pre-check using
eventHandledRepository.existsById(event.getEventId()) and skip save when true,
or (b) if you keep the save+catch pattern around eventHandledRepository.save(new
EventHandled(...)), inspect the DataIntegrityViolationException cause (e.g.,
SQLException/ConstraintViolationException) and verify SQL state/error code or
constraint name to confirm a duplicate-key violation before returning; for other
cases rethrow or log error details (include exception message/stack) so real
integrity problems are not silently ignored.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java-38-41 (1)

38-41: ⚠️ Potential issue | 🟡 Minor

예외 발생 시 전체 메시지 로깅은 민감 정보 노출 위험이 있다.

message 전체를 로그에 출력하면 userId, couponId 등 사용자 식별 정보가 로그에 남을 수 있다. 운영 환경에서 로그 집계 시스템에 이러한 정보가 저장되면 GDPR/개인정보 보호 정책 위반 가능성이 있다.

eventId와 예외 메시지만 로깅하거나, 메시지 내용을 마스킹하는 방안을 검토해야 한다.

🔒 민감 정보 마스킹 예시
         } catch (Exception e) {
-            log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e);
+            log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기", e);
             ack.nack(Duration.ofSeconds(1));
         }
🤖 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/CouponIssueConsumer.java`
around lines 38 - 41, The current catch block in CouponIssueConsumer logs the
entire "message" which may contain PII; change the log call to avoid
full-message dumps by extracting and logging only a non-sensitive identifier
(e.g., eventId from the incoming object) and the exception message or a masked
version of the payload, and keep ack.nack(Duration.ofSeconds(1)) unchanged;
specifically, update the log.error invocation to include CouponIssueConsumer,
the eventId (or a safely masked payload) and e.getMessage() (or sanitized
details) instead of the raw "message" variable so sensitive fields like
userId/couponId are not persisted in logs.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java-38-41 (1)

38-41: ⚠️ Potential issue | 🟡 Minor

decrement() 실패 시 카운터 불일치 위험이 있다.

운영 관점에서 increment()는 null 체크 후 예외를 던지지만, decrement()는 결과를 무시한다. Redis 연결 실패 또는 명령 실패 시 카운트가 실제 발급 수보다 높게 유지되어, 정상 요청이 거절되는 상황이 발생할 수 있다.

수정안: decrement() 결과도 검증하거나, 최소한 로깅을 추가하여 운영 시 모니터링할 수 있도록 해야 한다.

🛡️ decrement 결과 검증 추가
 `@Override`
 public void decrement(Long couponId) {
-    redisTemplate.opsForValue().decrement(key(couponId));
+    Long count = redisTemplate.opsForValue().decrement(key(couponId));
+    if (count == null) {
+        log.warn("Redis DECR 실패: couponId={}", couponId);
+    }
 }
🤖 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/RedisCouponIssueCountManager.java`
around lines 38 - 41, decrement(Long couponId) currently ignores the result of
redisTemplate.opsForValue().decrement(key(couponId)) which can mask Redis
failures; update RedisCouponIssueCountManager.decrement to capture the returned
Long, verify it's non-null (and optionally non-negative), and on null or
unexpected value either throw a specific runtime exception or at minimum log an
error including the couponId, the key(couponId) and the decrement result so
operators can detect failures (mirror the null-check behavior used in
increment()).
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java-40-44 (1)

40-44: ⚠️ Potential issue | 🟡 Minor

모든 무결성 예외를 중복 요청으로 바꾸면 실제 장애가 숨는다.

유니크 충돌 외의 FK/nullable/schema 오류까지 모두 409로 치환하면 운영에서 실제 장애를 사용자 중복 요청으로 오인하게 되고, 현재 보상 로직은 Redis 카운트까지 잘못 감소시킬 수 있다. 중복 제약 위반만 선별해 CONFLICT로 변환하고, 나머지는 cause를 보존한 채 그대로 전파하거나 별도 로그로 남기기 바란다. non-unique DataIntegrityViolationException이 발생할 때 409로 오인하지 않고 원인 예외가 남는지 검증하는 테스트를 추가해야 한다. As per coding guidelines "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."

🤖 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/CouponIssueRequestFacade.java`
around lines 40 - 44, The current catch of DataIntegrityViolationException in
CouponIssueRequestFacade (around couponIssueRequestRepository.save(...) and
couponIssueCountManager.decrement(...)) must be tightened: detect whether the
DataIntegrityViolationException is a unique-constraint violation (inspect the
root cause / SQLState / constraint name) and only for that case decrement the
Redis counter and throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한
쿠폰입니다.") while preserving the cause; for all other
DataIntegrityViolationException cases do not treat them as 409—either rethrow
the original exception (or wrap it preserving cause) and log an error (separate
user-facing message from log message) so the real database issue isn’t hidden.
Also add a unit/integration test that simulates a non-unique
DataIntegrityViolationException (e.g., FK/null/schema error) and asserts that no
CONFLICT is returned and couponIssueCountManager.decrement is not called and
that the original cause is preserved.

Comment on lines +25 to +26
public ProducerFactory<String, String> outboxProducerFactory(KafkaProperties kafkaProperties) {
Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties());
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
# Spring Kafka 버전 확인
rg -n "spring-kafka" --type=gradle --type=xml -C2 || fd -e gradle -e xml | xargs rg -n "spring-kafka" -C2 2>/dev/null | head -30

# KafkaProperties 사용 패턴 확인
ast-grep --pattern 'buildProducerProperties($$$)'

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

Length of output: 67


🏁 Script executed:

# 1. 프로젝트 빌드 설정 파일 찾기
fd -e gradle -e xml -name "*build*" -o -name "pom.xml" | head -20

# 2. 프로젝트 루트 확인
ls -la | grep -E "build|pom|gradle"

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

Length of output: 720


🏁 Script executed:

# 3. OutboxConfig.java 파일 위치 확인
find . -name "OutboxConfig.java" -type f

# 4. 파일 내용 확인 (지정된 위치)
cat -n "apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java" 2>/dev/null || echo "File not found at specified path"

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

Length of output: 2509


🌐 Web query:

Spring Kafka KafkaProperties buildProducerProperties deprecated

💡 Result:

Yes, the buildProducerProperties method in Spring Boot's KafkaProperties class (org.springframework.boot.autoconfigure.kafka.KafkaProperties) is deprecated since Spring Boot 3.2.0 and marked for removal in 3.4.0. It has been superseded by buildProducerProperties(SslBundles sslBundles) to support SSL bundles for improved SSL configuration. As of Spring Boot 3.4.x and 3.5.x, the parameterless buildProducerProperties remains available but deprecated. In Spring Boot 4.0.x (package moved to org.springframework.boot.kafka.autoconfigure.KafkaProperties), the non-overloaded buildProducerProperties exists without deprecation markings in the API docs, indicating the deprecation was cleaned up post-removal target. If using the method in custom configuration (e.g., for KafkaTemplate or ProducerFactory), inject SslBundles and call kafkaProperties.buildProducerProperties(sslBundles) (pass null if no SSL bundle needed). Spring Boot auto-configuration handles this internally via SslBundleSslEngineFactory. Example migration: @Bean public ProducerFactory<String, Object> producerFactory(KafkaProperties kafkaProperties, @Qualifier("kafkaSslBundles") SslBundles sslBundles) { return new DefaultKafkaProducerFactory<>(kafkaProperties.buildProducerProperties(sslBundles)); } Configure SSL via spring.kafka.producer.ssl.bundle=kafka in properties.

Citations:


🏁 Script executed:

# Spring Boot 버전 확인
cat -n build.gradle.kts | head -50

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

Length of output: 1713


🏁 Script executed:

# 전체 빌드 파일에서 Spring Boot/Kafka 버전 검색
rg -n "spring" build.gradle.kts | head -20

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

Length of output: 974


🏁 Script executed:

# gradle.properties에서 Spring Boot 및 Spring Cloud 버전 확인
cat -n gradle.properties

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

Length of output: 747


🏁 Script executed:

# settings.gradle.kts에서도 버전 정보 확인
cat -n settings.gradle.kts

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

Length of output: 1301


buildProducerProperties() 메서드를 신규 시그니처로 업데이트해야 한다.

현재 프로젝트는 Spring Boot 3.4.4를 사용 중이며, buildProducerProperties() 메서드는 Spring Boot 3.2.0부터 deprecated되어 3.4.0에서 제거 대상으로 표시되었다. 프로젝트의 버전이 이미 제거 대상 버전(3.4.0)을 넘어섰으므로 이 deprecated 메서드는 향후 버전에서 동작을 보장할 수 없다.

kafkaProperties.buildProducerProperties(sslBundles) 메서드로 변경하고, SslBundles 의존성을 주입받아야 한다. SSL 번들이 없는 경우 null을 전달할 수 있다.

public ProducerFactory<String, String> outboxProducerFactory(
    KafkaProperties kafkaProperties,
    `@Qualifier`("kafkaSslBundles") SslBundles sslBundles) {
    Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties(sslBundles));
    // 나머지 설정...
}
🤖 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/config/OutboxConfig.java` around
lines 25 - 26, Update the outboxProducerFactory signature to accept an
SslBundles dependency and call the newer
KafkaProperties.buildProducerProperties(SslBundles) overload: add a parameter
like SslBundles (use `@Qualifier`("kafkaSslBundles") if your configuration
provides that bean) to the outboxProducerFactory method and replace
buildProducerProperties() with buildProducerProperties(sslBundles); if no SSL
bundles are configured you may pass null. Ensure references to
outboxProducerFactory, KafkaProperties.buildProducerProperties, and SslBundles
are updated accordingly.

Comment on lines +38 to +41
} catch (Exception e) {
log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e);
ack.nack(Duration.ofSeconds(1));
}
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
# Description: Kafka Consumer 설정 및 DLQ 구성 확인

echo "=== KafkaConfig 확인 ==="
fd "KafkaConfig.java" --exec cat {}

echo ""
echo "=== application.yml에서 Kafka 설정 확인 ==="
fd -e yml -e yaml | xargs rg -l "kafka" | head -3 | xargs cat

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

Length of output: 10358


🏁 Script executed:

# CouponIssueConsumer 전체 파일 확인
fd "CouponIssueConsumer.java" --exec cat {}

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

Length of output: 1535


🏁 Script executed:

# 코드베이스에서 DLQ, Dead Letter, error handler 관련 설정 확인
rg -i "dead.?letter|dlq|error.*topic" --type java -A 3 -B 3

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

Length of output: 67


🏁 Script executed:

# Kafka listener 및 retry 관련 설정 확인
rg "ContainerProperties|ErrorHandler|ListenerContainerFactory" --type java -B 2 -A 5

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

Length of output: 5623


🏁 Script executed:

# Kafka 토픽 설정 및 DLT 토픽 정의 확인
rg -i "topic|coupon.issue" --type java -B 2 -A 2 | head -50

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

Length of output: 5479


🏁 Script executed:

# 다른 consumer 구현 확인 (error handling 비교)
fd -e java -type f -exec grep -l "@KafkaListener" {} \; | head -5 | xargs cat

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

Length of output: 255


🏁 Script executed:

# Spring Kafka 에러 처리 관련 설정 (RecoveryCallback, ErrorHandler 등)
rg -i "commonerrorhandler|deadletterpublishing|errorhandler|recoverycallback" --type java -B 3 -A 3

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

Length of output: 67


🏁 Script executed:

# 토픽 설정 파일 또는 초기화 코드에서 DLT 토픽 생성 로직 확인
rg -i "createtopic|topicpartition|@topic|\.dlt|dead" --type java -B 3 -A 3 | head -40

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

Length of output: 67


🏁 Script executed:

# application.yml에서 max-attempts, retry-template 관련 설정 확인
rg -i "max.attempts|retry.topic|dlq|dead.letter" --type yml -B 2 -A 2

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

Length of output: 112


🏁 Script executed:

# KafkaConfig에서 setCommonErrorHandler 또는 유사 설정 확인
cat modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java | grep -i "error\|handler\|dlt\|retry"

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

Length of output: 67


재시도 메커니즘 강화가 필요하다.

nack(Duration.ofSeconds(1))은 1초 후 재처리를 요청하지만, 현재 코드베이스에는 재시도 횟수 제한과 Dead Letter Topic이 설정되지 않아 처리 불가능한 메시지(예: 역직렬화 성공 후 비즈니스 로직 실패)의 경우 무한 재시도가 발생한다. 이는 브로커 부하 증가, 메시지 적체, 운영 장애로 이어질 수 있다.

다음 조치를 수행하기 바란다:

  1. KafkaConfig에 CommonErrorHandler 추가: DeadLetterPublishingRecoverer를 이용한 DLT 토픽으로의 자동 전송 구현
  2. application.yml에 재시도 정책 설정: spring.kafka.listener.poll-timeout, spring.kafka.retry.topic.enabled, spring.kafka.retry.topic.max.attempts 등으로 재시도 제한 설정
  3. 예외 유형별 처리 전략: 파싱 실패(복구 불가)는 즉시 DLT 전송, 데이터 불일치(일시적 실패)는 재시도 후 DLT 전송으로 구분
  4. 운영 환경 테스트: 실제 프로덕션 환경에서 메시지 처리 실패 시나리오에 대한 재시도 동작 및 DLT 전달 여부 검증
🤖 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/CouponIssueConsumer.java`
around lines 38 - 41, The current catch block in CouponIssueConsumer using
ack.nack(Duration.ofSeconds(1)) risks infinite retries; instead add a
CommonErrorHandler in KafkaConfig that uses a DeadLetterPublishingRecoverer to
route failed records to a DLT after a bounded number of attempts, configure
retry properties in application.yml (spring.kafka.listener.poll-timeout,
spring.kafka.retry.topic.enabled, spring.kafka.retry.topic.max.attempts), and
implement exception-specific handling (treat deserialization/parsing exceptions
as non-retriable and immediately send to DLT, while treating transient business
exceptions as retriable up to the configured max attempts) so that ack.nack is
replaced by the broker-managed retry/DLT flow for the CouponIssueConsumer
processing path.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`:
- Around line 26-30: Wrap the Event.fromJson(message) call inside a dedicated
try-catch in CouponIssueConsumer so JSON parsing exceptions don't escape: if
parsing fails catch the exception, compute and log only non-sensitive metadata
(e.g., payload length and a short hash and exception summary) via log.warn (do
NOT log the raw message or sensitive fields), call ack.acknowledge() and return;
keep eventProcessingService.process(event.getPayload()) unchanged so its
exceptions still propagate for retry. Add two tests: (1) a malformed JSON test
asserting eventProcessingService.process is NOT called and ack.acknowledge() is
called once, and (2) a log-capture test asserting the parsing-failure log
contains only length/hash/summary and does NOT contain raw payload fields like
userId or couponId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3437647f-2f04-40b0-9819-4a2930496858

📥 Commits

Reviewing files that changed from the base of the PR and between fd754ee and fefa128.

📒 Files selected for processing (3)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java

Comment on lines +26 to +30
Event<EventPayload> event = Event.fromJson(message);
if (event == null) {
log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message);
ack.acknowledge();
return;
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

역직렬화 실패 처리 경로가 실제로 동작하지 않고, 원문 메시지 로깅으로 보안/운영 리스크가 발생한다.

Line 26에서 Event.fromJson(message)가 예외를 던지면 Line 27 null 분기와 Line 29 ack.acknowledge()가 실행되지 않아 파싱 불가 메시지가 재시도 경로로 들어가 지연을 유발한다. 또한 Line 28의 message 전체 로깅은 민감정보 노출과 로그 폭증 위험이 있다.

수정안은 파싱 단계만 별도 try-catch로 감싸고, 실패 시에는 원문 대신 길이/해시/예외 요약만 로깅 후 ack 처리하는 방식이 적절하다. 비즈니스 처리(eventProcessingService.process) 예외는 현재처럼 상위로 전파해 재처리 정책을 따르도록 유지하면 된다.

수정 예시 diff
 public void consume(String message, Acknowledgment ack) {
-    Event<EventPayload> event = Event.fromJson(message);
-    if (event == null) {
-        log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message);
-        ack.acknowledge();
-        return;
-    }
+    Event<EventPayload> event;
+    try {
+        event = Event.fromJson(message);
+    } catch (RuntimeException e) {
+        log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, messageLength={}, messageHash={}, cause={}",
+                message == null ? 0 : message.length(),
+                message == null ? 0 : message.hashCode(),
+                e.toString());
+        ack.acknowledge();
+        return;
+    }
+    if (event == null) {
+        log.warn("[CouponIssueConsumer] 이벤트 파싱 결과가 null이다");
+        ack.acknowledge();
+        return;
+    }

     eventProcessingService.process(event);
     ack.acknowledge();
 }

추가 테스트는 최소 2가지가 필요하다.

  1. malformed JSON 입력 시 eventProcessingService.process가 호출되지 않고 ack.acknowledge()가 1회 호출되는지 검증해야 한다.
  2. 파싱 실패 로그에 원문 payload(예: userId, couponId)가 포함되지 않는지 로그 캡처 기반으로 검증해야 한다.

As per coding guidelines, "로깅 시 민감정보 노출 가능성을 점검한다."

🤖 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/CouponIssueConsumer.java`
around lines 26 - 30, Wrap the Event.fromJson(message) call inside a dedicated
try-catch in CouponIssueConsumer so JSON parsing exceptions don't escape: if
parsing fails catch the exception, compute and log only non-sensitive metadata
(e.g., payload length and a short hash and exception summary) via log.warn (do
NOT log the raw message or sensitive fields), call ack.acknowledge() and return;
keep eventProcessingService.process(event.getPayload()) unchanged so its
exceptions still propagate for retry. Add two tests: (1) a malformed JSON test
asserting eventProcessingService.process is NOT called and ack.acknowledge() is
called once, and (2) a log-capture test asserting the parsing-failure log
contains only length/hash/summary and does NOT contain raw payload fields like
userId or couponId.

@hey-sion hey-sion merged commit 5dbcac4 into Loopers-dev-lab:hey-sion Mar 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant