Skip to content

Week7 이벤트 & 카프카 구현하기#277

Open
hyejin0810 wants to merge 3 commits intoLoopers-dev-lab:hyejin0810from
hyejin0810:week7
Open

Week7 이벤트 & 카프카 구현하기#277
hyejin0810 wants to merge 3 commits intoLoopers-dev-lab:hyejin0810from
hyejin0810:week7

Conversation

@hyejin0810
Copy link
Copy Markdown

@hyejin0810 hyejin0810 commented Mar 27, 2026

📌 Summary

  • 배경: 좋아요/조회/주문/결제 등 도메인 이벤트가 동기 처리에 묶여 있었고, 선착순 쿠폰 발급의 동시성 제어 수단이 없었습니다.
  • 목표: Transactional Outbox 패턴 기반의 Kafka 이벤트 파이프라인을 구축하여 메시지 유실 없는 비동기 처리를 달성하고, 선착순 쿠폰을 Redis INCR gate + Kafka Consumer로 안전하게 발급합니다.
  • 결과: Producer → Outbox → Kafka → Consumer → DLQ 전체 파이프라인이 동작하며, 멱등성·재처리·장애 복구 전략까지 문서화했습니다.

🧭 Context & Decision

왜 Transactional Outbox인가

비즈니스 데이터 변경과 Kafka 발행의 원자성이 핵심 과제였습니다. kafkaTemplate.send()를 트랜잭션 내에서 직접 호출하면 DB 롤백 시 이미 발행된 메시지를 회수할 수 없고, 트랜잭션 밖에서 호출하면 커밋 성공 후 발행 실패로 메시지가
유실됩니다.

Outbox 패턴은 이벤트를 비즈니스 데이터와 같은 트랜잭션outbox_events 테이블로 저장하고, 별도 스케줄러가 polling하여 Kafka로 발행합니다. DB 커밋이 곧 발행 보장이 되므로, at-least-once 시맨틱이 자연스럽게 달성됩니다.

선택지와 결정

Outbox 발행 방식: 전체 배치 vs 개별 트랜잭션

초기 구현에서는 배치 단위로 조회 → 순차 전송 → 성공한 것만 모아서 한 번에 saveAll하는 방식이었습니다. 이 방식은 한 건의 발행 실패가 나머지 성공 건의 상태 반영을 지연시키고, 실패한 이벤트를 재시도와 최종 실패로 분류할 수 없는
문제가 있었습니다.

최종적으로 이벤트마다 독립 트랜잭션(FOR UPDATE SKIP LOCKED)으로 변경했습니다. 각 이벤트를 비관적 락으로 잠근 뒤 Kafka 전송 → 성공 시 markPublished() / 실패 시 incrementRetryCount() → 커밋하는 구조입니다. 5회 초과
실패 시 FAILED로 마킹하여 수동 개입 대상을 명확히 식별할 수 있습니다.

Consumer 오프셋: auto commit vs manual ack

enable.auto.commit=false + AckMode.MANUAL을 선택했습니다. 자동 커밋은 처리 완료 전에 오프셋이 커밋되어 장애 시 메시지가 유실될 수 있기 때문입니다. 수동 커밋은 비즈니스 로직 + DLQ 전송까지 완료된 후에만 ack하므로, 처리 실패
시 재배달이 보장됩니다.

선착순 쿠폰: DB 락 vs Redis INCR gate

DB 비관적 락은 Kafka Consumer가 단일 스레드(concurrency=1)로 순차 처리하므로 불필요한 오버헤드입니다. Redis INCR은 원자적 연산으로 DB 트랜잭션 밖에서 수량을 빠르게 체크하고, 초과 시 즉시 차단합니다. DB 커밋 실패 시 DECR로
보상하며, Redis는 gate 역할이고 DB가 source of truth입니다.


🏗️Design Overview

변경 범위

  • commerce-api (Producer 측): Outbox 엔티티/스케줄러, 이벤트 리스너, 도메인 이벤트 발행
  • commerce-streamer (Consumer 측): Kafka Consumer 3종, Metrics 집계, 선착순 쿠폰 발급
  • modules/kafka: KafkaConfig, kafka.yml 설정
  • .docs/: 설계 문서 4종 업데이트 + 운영 Runbook/의사결정 기록 신규

주요 컴포넌트 책임

Producer 측 (commerce-api)

컴포넌트 책임
OutboxEventListener @TransactionalEventListener(BEFORE_COMMIT)로 도메인 이벤트를 수신하여 outbox_events에 저장합니다. 비즈니스 데이터와 같은 트랜잭션에서 실행되어 원자성을 보장합니다.
OutboxPublisher 1초 주기로 PENDING 이벤트를 polling하여 Kafka로 발행합니다. FOR UPDATE SKIP LOCKED로 다중 인스턴스 환경에서 중복 발행을 방지하고, 개별 실패 처리 + 연속 실패 시 조기 종료(circuit breaker)를 적용했습니다.

|
| ProductViewedEventPublisher | 조회 이벤트는 유실 허용 가능하므로 Outbox를 거치지 않고 Kafka에 직접 발행합니다. 고빈도 조회마다 Outbox INSERT를 방지합니다. |

Consumer 측 (commerce-streamer)

컴포넌트 책임
CatalogEventConsumer catalog-events 토픽을 batch(500건, concurrency=3)로 소비합니다. LIKED/UNLIKED/PRODUCT_VIEWED 이벤트를 수신하여 ProductMetrics의 좋아요 수/조회수를 갱신합니다.
OrderEventConsumer order-events 토픽을 batch로 소비합니다. PAYMENT_COMPLETED 이벤트 수신 시 ProductMetrics의 판매수를 갱신합니다.
CouponIssueConsumer coupon-issue-requests 토픽을 single(concurrency=1)로 순차 소비합니다. 순서 보장이 필요한 선착순 쿠폰 발급을 처리합니다.
FcfsCouponIssueService Redis INCR로 수량 gate를 수행하고, DB 트랜잭션에서 실제 발급을 처리합니다. DB 실패 시 Redis DECR 보상을 수행합니다.
MetricsApplicationService ProductMetrics 갱신을 담당합니다. OptimisticLock 충돌 시 최대 3회 재시도하며, EventHandled 저장과 비즈니스 로직을 같은 트랜잭션에서 수행하여 멱등성을 보장합니다.

🔁 Flow Diagram

Transactional Outbox 발행 흐름

  sequenceDiagram
      participant App as ApplicationService
      participant DB
      participant OutboxListener as OutboxEventListener
      participant Publisher as OutboxPublisher
      participant Kafka

      App->>DB: 비즈니스 데이터 저장
      App->>App: eventPublisher.publishEvent()
      OutboxListener->>DB: outbox_events INSERT (같은 TX)
      Note over DB: TX 커밋 → 비즈니스 + Outbox 원자적 저장

      loop 1초 주기 polling
          Publisher->>DB: findPendingIds (LIMIT 100)
          Publisher->>DB: findByIdForUpdate (SKIP LOCKED)
          Publisher->>Kafka: send(topic, key, payload)
          alt 성공
              Publisher->>DB: markPublished()
          else 실패
              Publisher->>DB: incrementRetryCount()
              Note over Publisher: 5회 초과 시 markFailed() → 수동 확인 필요
          end
      end
Loading

선착순 쿠폰 발급 흐름

  sequenceDiagram
      participant API as CouponController
      participant DB as commerce-api DB
      participant Kafka
      participant Consumer as CouponIssueConsumer
      participant Redis
      participant StreamerDB as commerce-streamer DB

      API->>DB: CouponIssueRequest(PENDING) + Outbox 저장 (같은 TX)
      API-->>API: 202 Accepted 반환

      Note over Kafka: OutboxPublisher polling → 발행

      Kafka->>Consumer: COUPON_ISSUE_REQUESTED 수신
      Consumer->>Redis: INCR coupon:fcfs:{couponId}:count
      alt count <= maxQuantity
          Consumer->>StreamerDB: TX { 쿠폰 발급 + issuedCount++ + request.markSuccess() + EventHandled }
          alt DB 실패
              Consumer->>Redis: DECR (보상)
              Consumer->>Consumer: DLQ 전송
          end
      else count > maxQuantity
          Consumer->>Redis: DECR (보상)
          Consumer->>StreamerDB: request.markFailed("선착순 마감")
      end
Loading

Consumer 에러 처리 흐름 (Batch)

  flowchart TD
      A[poll 500건] --> B{레코드 순회}
      B -->|성공| C[비즈니스 처리 + EventHandled 저장]
      B -->|실패| D[DLQ 동기 전송 5s timeout]
      D -->|DLQ 성공| B
      D -->|DLQ 실패| E[RuntimeException throw]
      C --> B
      B -->|순회 완료| F[acknowledgment.acknowledge]
      E --> G[ack 미호출 → 전체 배치 재배달]
Loading

🛡️ 신뢰성 보장 전략

Producer 신뢰성

설정 효과
acks all 리더 + ISR 복제 완료 후 응답, 메시지 유실 방지
enable.idempotence true 재시도 시 중복 메시지 방지
retries 3 일시적 브로커 장애 시 자동 재시도

Consumer 신뢰성

전략 구현 효과
수동 오프셋 커밋 enable.auto.commit=false + AckMode.MANUAL 처리 완료 후에만 커밋, 재배달 보장
멱등성 EventHandled 테이블 (eventId PK) 중복 메시지 처리 방지
DLQ 격리 per-record DLQ 전송 (동기 5s timeout) 실패 메시지 격리
OptimisticLock 재시도 최대 3회 동시성 충돌 완화

재처리 안전성

오프셋 되감기를 통한 재처리 시, EventHandled 테이블에 이미 처리된 eventId가 남아 있으므로 중복 처리가 방지됩니다. 단, 잘못된 처리를 보정하기 위한 재처리에서는 EventHandled 레코드를 먼저 삭제해야 skip 없이 재처리됩니다.


테스트/검증

  • 동시 쿠폰 발급 테스트: CouponConcurrencyTest, CouponIssueConsumerConcurrencyTest로 PENDING 상태 생성과 수량 초과 방지 검증
  • 이벤트 발행/처리 테스트: LikeFacadeTest, OrderFacadeTest로 Spring event 발행 확인
  • 실제 재시도, 보상, DLQ 시나리오에 대한 통합 테스트는 미포함

변경 목적

동기 처리에 묶인 도메인 이벤트(좋아요/주문 등)의 비동기화와 선착순 쿠폰 발급의 동시성 제어를 위해 Transactional Outbox 패턴 기반 Kafka 이벤트 파이프라인을 도입했습니다.

핵심 변경점

Producer(commerce-api): Outbox 엔티티, 1초 주기 폴링 스케줄러(OutboxRelayScheduler), OutboxEventPublisher를 추가하여 비즈니스 트랜잭션과 이벤트 저장의 원자성을 확보. 좋아요/주문 이벤트는 Outbox 저장 후 Kafka 발행 및 Spring 비동기 리스너 호출.

Consumer(commerce-streamer): Catalog/Coupon 이벤트 컨슈머, EventHandled 테이블 기반 멱등성 처리, 선착순 쿠폰은 DB 비관적 락(PESSIMISTIC_WRITE)으로 동시성 제어.

신뢰성 설정: Kafka Producer는 acks=all, enable.idempotence=true, retries=3; Consumer는 수동 오프셋 커밋(enable.auto.commit=false, ack-mode: manual).

주의사항 및 확인 필요 항목

  • Outbox 이벤트 발행 시 markSent()를 Kafka 비동기 콜백 내에서 호출하지 않고 즉시 실행하므로, 실제 Kafka 전송 실패 시 메시지 유실 가능성 있음
  • 재시도 로직(목표의 "5회 초과 시 FAILED 마킹")이 구현되지 않아 일회성 전송만 시도
  • 선착순 쿠폰에서 예상된 "Redis INCR gate" 대신 DB SELECT COUNT 방식 사용으로 고부하 시 race condition 가능성
  • 설계 문서와 운영 Runbook(.docs/)이 PR에 포함되지 않음

테스트/검증 방법

동시성 테스트(CouponConcurrencyTest, CouponIssueConsumerConcurrencyTest)로 쿠폰 발급 제한을 검증했으나, Outbox 메시지 유실 시나리오와 Kafka 재전송 실패 시 보상 처리에 대한 테스트가 부재합니다. Outbox 폴링 방식의 메시지 순서 보장 및 중복 처리에 대한 검증 필요.

조혜진 and others added 2 commits March 24, 2026 16:07
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Kafka 의존성을 추가하고 아웃박스 패턴과 이벤트 기반 아키텍처를 도입했다. 쿠폰 발급, 좋아요, 주문 생성 시 이벤트를 발행하며, 별도의 스트리머 애플리케이션에서 이 이벤트를 소비하여 처리한다.

Changes

Cohort / File(s) Summary
의존성 및 설정
build.gradle.kts, application.yml, AsyncConfig.java
Kafka 모듈 의존성 추가, 외부 kafka.yml 설정 임포트, 비동기 및 스케줄링 활성화.
아웃박스 패턴 도메인
domain/outbox/OutboxEvent.java, domain/outbox/OutboxStatus.java, domain/outbox/OutboxEventPayload.java
아웃박스 이벤트 엔티티, 상태 열거형, 페이로드 레코드 정의.
아웃박스 인프라
infrastructure/outbox/OutboxJpaRepository.java, infrastructure/outbox/OutboxRepositoryImpl.java, infrastructure/outbox/OutboxRelayScheduler.java, domain/outbox/OutboxEventPublisher.java, domain/outbox/OutboxEventRepository.java
JPA 저장소, 리포지토리 구현, 1초 간격 스케줄된 Kafka 릴레이 스케줄러, 이벤트 발행자.
쿠폰 도메인
domain/coupon/Coupon.java, domain/coupon/CouponIssueRequest.java, domain/coupon/CouponIssueStatus.java, domain/coupon/CouponRepository.java, domain/coupon/CouponIssueRequestRepository.java, domain/coupon/CouponService.java
쿠폰 엔티티, 발급 요청 엔티티, 상태 열거형, 저장소 인터페이스, 서비스 로직 추가.
쿠폰 인프라
infrastructure/coupon/CouponJpaRepository.java, infrastructure/coupon/CouponRepositoryImpl.java, infrastructure/coupon/CouponIssueRequestJpaRepository.java, infrastructure/coupon/CouponIssueRequestRepositoryImpl.java
Spring Data JPA 저장소 및 구현체.
쿠폰 애플리케이션
application/coupon/CouponFacade.java, application/coupon/CouponIssueInfo.java, interfaces/api/coupon/CouponV1Controller.java, interfaces/api/coupon/CouponV1Dto.java
쿠폰 발급 요청/조회 트랜잭션 로직, DTO 및 REST 컨트롤러.
이벤트 기반 아키텍처 (좋아요)
domain/like/LikeAddedEvent.java, domain/like/LikeRemovedEvent.java, application/event/LikeEventHandler.java, application/like/LikeFacade.java, domain/product/ProductService.java
좋아요 이벤트 레코드, 트랜잭션 이벤트 리스너, 파사드에서 아웃박스/인프로세스 이벤트 발행 추가, 상품 좋아요 수 증감 메서드 추가.
이벤트 기반 아키텍처 (주문)
domain/order/OrderCreatedEvent.java, application/event/OrderEventHandler.java, application/order/OrderFacade.java
주문 생성 이벤트 레코드, 트랜잭션 이벤트 리스너, 파사드에서 아웃박스/인프로세스 이벤트 발행 추가.
Kafka 토픽 정의
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java, modules/kafka/src/main/resources/kafka.yml
세 가지 토픽 상수(catalog-events, order-events, coupon-issue-requests) 정의, 프로듀서 멱등성 및 acks=all 설정.
스트리머 쿠폰 도메인
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/*
쿠폰, 발급 요청, 상태, 발급된 쿠폰 엔티티 추가.
스트리머 메트릭 및 아웃박스
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/domain/outbox/EventHandled.java
상품 메트릭 엔티티(좋아요, 주문, 조회 카운터), 이벤트 처리 추적 엔티티.
스트리머 인프라
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/*, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/outbox/EventHandledJpaRepository.java
JPA 저장소 인터페이스.
스트리머 Kafka 컨슈머
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
Catalog 이벤트(좋아요) 배치 컨슈머, 쿠폰 발급 배치 컨슈머(멱등성, 재고 검증, 비관적 락 포함).
테스트
src/test/java/com/loopers/application/coupon/CouponConcurrencyTest.java, src/test/java/com/loopers/application/like/LikeFacadeTest.java, src/test/java/com/loopers/application/order/OrderFacadeTest.java, src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerConcurrencyTest.java
쿠폰 동시성 검증, 좋아요/주문 이벤트 발행 검증, 쿠폰 발급 컨슈머 동시성 검증.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CommerceAPI as Commerce API
    participant OutboxDB as Outbox DB
    participant Kafka
    participant Streamer as Commerce Streamer
    participant AppDB as App DB

    rect rgba(100, 150, 200, 0.5)
    Note over Client,AppDB: 쿠폰 발급 요청 흐름
    Client->>CommerceAPI: POST /api/v1/coupons/{couponId}/issue
    CommerceAPI->>CommerceAPI: 사용자 인증
    CommerceAPI->>AppDB: CouponIssueRequest 생성
    CommerceAPI->>OutboxDB: OutboxEvent 저장<br/>(eventType: COUPON_ISSUE_REQUESTED)
    CommerceAPI->>Client: CouponIssueInfo 반환
    end

    rect rgba(150, 150, 100, 0.5)
    Note over CommerceAPI,Streamer: 아웃박스 릴레이 및 스트리머 처리
    CommerceAPI->>OutboxDB: findPendingEvents() 1초마다
    CommerceAPI->>Kafka: publish to COUPON_ISSUE_REQUESTS
    Kafka->>Streamer: consume COUPON_ISSUE_REQUESTS
    Streamer->>Streamer: 멱등성 확인 (eventId)
    Streamer->>AppDB: Coupon 조회 (비관적 락)
    Streamer->>AppDB: 재고 검증
    Streamer->>AppDB: IssuedCoupon 저장
    Streamer->>AppDB: CouponIssueRequest markSuccess()
    Streamer->>AppDB: EventHandled 기록
    end

    rect rgba(200, 150, 150, 0.5)
    Note over Client,AppDB: 좋아요 이벤트 흐름
    Client->>CommerceAPI: POST /api/v1/likes
    CommerceAPI->>AppDB: Like 엔티티 생성
    CommerceAPI->>OutboxDB: OutboxEvent 저장<br/>(eventType: LIKE_ADDED)
    CommerceAPI->>CommerceAPI: LikeAddedEvent 발행 (인프로세스)
    CommerceAPI->>AppDB: ProductService.increaseLikes()
    CommerceAPI->>Client: 응답 반환
    end

    rect rgba(150, 200, 150, 0.5)
    Note over Kafka,Streamer: 스트리머의 Catalog 이벤트 처리
    Kafka->>Streamer: consume CATALOG_EVENTS<br/>(LIKE_ADDED/LIKE_REMOVED)
    Streamer->>Streamer: 멱등성 확인 (eventId)
    Streamer->>AppDB: ProductMetrics 업데이트<br/>(likeCount 증감)
    Streamer->>AppDB: EventHandled 기록
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

주요 복잡도 요인:

  • 아웃박스 패턴과 Kafka를 통한 이벤트 기반 아키텍처 도입으로 여러 서비스 간 상호작용 구조 변경
  • 쿠폰 발급 시 비관적 락, 멱등성, 재고 검증 등의 동시성 제어 로직
  • LikeFacade와 OrderFacade의 기존 캐싱 전략에서 이벤트 기반 업데이트로의 전환
  • OutboxRelayScheduler의 배치 처리 및 에러 핸들링 로직
  • commerce-streamer의 배치 컨슈머에서 트랜잭션 처리 및 부분 실패 처리
  • 테스트 코드에서 동시성 시나리오 검증 추가

운영 관점 주의사항:

  1. 아웃박스 릴레이가 1초 고정 지연으로 설정되어 있어 대용량 이벤트 발생 시 처리 지연 가능성 – 모니터링 필요
  2. Kafka 프로듀서 설정에서 acks=all과 멱등성 활성화로 인한 성능 영향 – 처리량 테스트 권장
  3. CouponIssueConsumer에서 per-record 예외 처리 후 배치 ACK로 인한 부분 실패 재처리 필요 – 데드레터 큐 고려

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 제목이 주요 변경사항인 Kafka 이벤트 파이프라인과 Transactional Outbox 패턴 도입을 명확히 나타내고 있다.
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.

@hyejin0810 hyejin0810 changed the title Week7 카프카 구현하기 Week7 이벤트 & 카프카 구현하기 Mar 27, 2026
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: 18

Note

Due to the large number of review comments, Critical, Major 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)
modules/kafka/src/main/resources/kafka.yml (1)

24-24: ⚠️ Potential issue | 🔴 Critical

Consumer value-serializer가 아닌 value-deserializer를 사용해야 한다.

Consumer 설정에서 value-serializer로 지정되어 있으나, Consumer는 메시지를 역직렬화하므로 value-deserializer가 올바른 키이다. 이 오류로 인해 Kafka Consumer가 메시지 수신 시 역직렬화 실패가 발생할 수 있다.

🐛 수정안
     consumer:
       group-id: loopers-default-consumer
       key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
-      value-serializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
+      value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/kafka/src/main/resources/kafka.yml` at line 24, The Kafka consumer
property key is incorrect: replace the consumer config key "value-serializer"
with "value-deserializer" in kafka.yml so the consumer uses deserialization;
keep the deserializer class value as
org.apache.kafka.common.serialization.ByteArrayDeserializer and update any
equivalent "key-serializer"/"key-deserializer" entries if present to match
consumer semantics.
🟡 Minor comments (11)
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreatedEvent.java-5-6 (1)

5-6: ⚠️ Potential issue | 🟡 Minor

팩토리 메서드에 null 방어가 필요하다.

운영에서 예외가 발생했을 때 원인 메시지 없는 NPE는 장애 추적 시간을 늘린다. 수정안으로 from 시작 지점에서 Objects.requireNonNull(order, "...")로 실패를 명확히 해야 한다. 추가 테스트로 OrderCreatedEvent.from(null) 호출 시 의도한 메시지로 즉시 실패하는지 검증해야 한다. As per coding guidelines **/*.java: "null 처리 ... 안정성을 점검한다."

수정 예시 diff
 package com.loopers.domain.order;
 
+import java.util.Objects;
+
 public record OrderCreatedEvent(Long orderId, Long userId, Long totalAmount) {
 
     public static OrderCreatedEvent from(Order order) {
+        Objects.requireNonNull(order, "order must not be null");
         return new OrderCreatedEvent(order.getId(), order.getUserId(), order.getTotalAmount());
     }
 }
🤖 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/order/OrderCreatedEvent.java`
around lines 5 - 6, Add a null-check at the start of the factory method
OrderCreatedEvent.from(Order) by calling Objects.requireNonNull(order, "order
must not be null") so a clear NPE message is thrown instead of an ambiguous one;
update the method in class OrderCreatedEvent and add a unit test that calls
OrderCreatedEvent.from(null) asserting a NullPointerException with the same
message to verify behavior.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java-9-14 (1)

9-14: ⚠️ Potential issue | 🟡 Minor

coupon_issue_requests 테이블에 coupon_id 컬럼 인덱스를 추가하다.

findByCouponIdcountByCouponIdAndStatus 쿼리가 모두 coupon_id 컬럼을 조건으로 사용하는데, 현재 인덱스가 정의되지 않았다. 데이터 증가에 따라 전체 테이블 스캔으로 인한 성능 저하가 발생한다.

수정안:

  • coupon_id 단일 인덱스 추가: CREATE INDEX idx_coupon_issue_requests_coupon_id ON coupon_issue_requests(coupon_id)
  • 선택적으로 (coupon_id, status) 복합 인덱스 고려 (countByCouponIdAndStatus 최적화)

추가 테스트:

  • 쿠폰 발급 대량 요청 시나리오에서 쿼리 응답 시간 측정
  • 쿠폰_이슈_요청 테이블 100만 행 이상 상황에서 인덱스 적용 전후 성능 비교
🤖 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 9 - 14, Add a DB index for coupon_id because repository methods
findByCouponId and countByCouponIdAndStatus (in CouponIssueRequestJpaRepository)
filter on coupon_id; apply the single-column index with SQL like CREATE INDEX
idx_coupon_issue_requests_coupon_id ON coupon_issue_requests(coupon_id), and
optionally add a composite index for (coupon_id, status) to further optimize
countByCouponIdAndStatus; after applying the migration, run load tests (bulk
coupon issue and a >1M rows scenario) to compare query latency before/after.
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

패키지명 confgconfig의 오타로, 8개 파일에서 참조 중이다.

현재 패키지명이 com.loopers.confg.kafka로 되어 있으나, 표준 명명 관례에 따르면 com.loopers.config.kafka로 수정되어야 한다. 이 오타는 다음과 같은 문제를 초래한다:

  • 코드 검색(grep, IDE 검색) 시 혼란 초래
  • 개발자의 오타 가능성으로 인한 실수 유발
  • 패키지명 일관성 부족

검증 결과, 다음 8개 파일에서 해당 패키지를 참조하고 있으므로 패키지명 변경 시 일괄 수정이 필요하다:

  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java

모든 참조 파일의 import 문을 수정한 후, 각 모듈의 빌드 및 단위 테스트를 실행하여 패키지 변경으로 인한 영향이 없는지 확인해야 한다.

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

In `@modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java` at line
1, The package declaration "com.loopers.confg.kafka" is misspelled and must be
renamed to "com.loopers.config.kafka"; update the package line in
KafkaTopics.java and change all import statements that reference
com.loopers.confg.kafka to com.loopers.config.kafka across the codebase (e.g.,
imports in KafkaConfig, OrderFacade, LikeFacade, CouponFacade,
DemoKafkaConsumer, CouponIssueConsumer, CatalogEventConsumer), then rebuild and
run unit tests for affected modules to ensure no breakage from the refactor.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java-11-12 (1)

11-12: ⚠️ Potential issue | 🟡 Minor

order_id 컬럼에 명시적 인덱스 추가 필요

findByOrderIdIn 메서드가 생성하는 WHERE order_id IN (...) 쿼리는 현재 orderId 컬럼에 인덱스가 없으면 order_items 테이블 전체를 스캔한다. OrderFacade에서 대량의 주문에 대한 아이템을 조회하는 패턴이므로, 쿼리 성능 저하로 이어질 수 있다.

OrderItem 엔티티의 orderId 필드에 @Index 애너테이션을 추가하거나, @Table 의 indexes 파라미터에 인덱스를 정의하는 것을 권장한다.

🤖 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/order/OrderItemJpaRepository.java`
around lines 11 - 12, The repository method findByOrderIdIn generates WHERE
order_id IN (...) queries that will cause full table scans if the
OrderItem.orderId column is not indexed; update the OrderItem entity to add an
index on the orderId field (either by annotating the field with `@Index` or adding
an entry to the `@Table`(indexes = ... ) annotation) so the DB will create an
index for order_id and improve performance when OrderFacade queries many orders.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java-23-24 (1)

23-24: ⚠️ Potential issue | 🟡 Minor

totalQuantity 필드 타입과 생성자 파라미터 타입이 불일치한다.

필드는 Integer(nullable)이고 생성자는 int(non-null)를 받는다. DB 컬럼이 nullable=false이므로 필드도 int로 통일하거나, 생성자에서 null 검증을 추가해야 한다. 기존 데이터에 NULL이 있을 경우 조회 시 NPE 위험이 있다.

🛠️ int 타입으로 통일 제안
-    `@Column`(name = "total_quantity", nullable = false)
-    private Integer totalQuantity;
+    `@Column`(name = "total_quantity", nullable = false)
+    private int totalQuantity;
🤖 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 23 - 24, The field totalQuantity is declared as Integer but the
constructor takes an int, causing a mismatch and potential NPE; fix by making
the types consistent—preferably change the field declaration totalQuantity from
Integer to int (and update any getters/setters and usages) and update the
constructor parameter to int if not already, or alternatively keep Integer and
add a null-check in the Coupon constructor to validate/convert a null to a safe
default; adjust the field, constructor (Coupon), and any accessor methods to
match the chosen non-null primitive type.
apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java-64-90 (1)

64-90: ⚠️ Potential issue | 🟡 Minor

주문 생성 테스트가 핵심 outbox 계약을 놓치고 있다.

현재 검증은 publishEvent(any(OrderCreatedEvent.class))만 있어서, outbox 저장이 빠지거나 topic/key/payload가 잘못 나가도 통과한다. 운영에서는 주문은 생성됐는데 Kafka 후속 처리만 멈추는 회귀를 CI가 잡지 못한다. outboxEventPublisher.publish(...)의 인자를 정확히 검증하고, OrderCreatedEventany() 대신 실제 orderId를 캡처해 확인해야 한다. 추가로 outbox 저장 또는 주문 생성이 예외를 던질 때 두 publisher가 호출되지 않는 실패 케이스 테스트를 넣어야 한다. 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/application/order/OrderFacadeTest.java`
around lines 64 - 90, The test only verifies
eventPublisher.publishEvent(any(OrderCreatedEvent.class)) and misses asserting
the outbox contract and exact OrderCreatedEvent contents; update
publishesOrderCreatedEvent_whenOrderCreated to capture and assert
outboxEventPublisher.publish(...) is called with the correct topic/key/payload
(use an ArgumentCaptor for the outbox payload) and replace
any(OrderCreatedEvent.class) with a captured OrderCreatedEvent to assert the
orderId matches the created Order (order.getId()); also add a failure-path test
that simulates orderService.createOrder(...) or the outbox save throwing an
exception and assert that neither eventPublisher.publishEvent(...) nor
outboxEventPublisher.publish(...) are invoked in that exception case.
apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java-57-98 (1)

57-98: ⚠️ Potential issue | 🟡 Minor

좋아요 테스트도 outbox와 payload를 함께 고정해야 한다.

현재 두 테스트 모두 publishEvent(any(...))만 확인해서, outbox 저장이 빠지거나 잘못된 productId/userId로 발행돼도 통과한다. 운영에서는 좋아요 DB 상태와 Kafka 기반 지표 갱신이 어긋나는 회귀를 CI가 놓친다. outboxEventPublisher.publish(...)topic/key/eventType/dataLikeAddedEvent/LikeRemovedEvent의 실제 필드를 캡처해 검증해야 한다. 추가로 likeService가 예외를 던질 때 두 publisher가 호출되지 않는 실패 케이스도 넣어야 한다. 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/application/like/LikeFacadeTest.java`
around lines 57 - 98, Update the LikeFacadeTest tests to assert concrete event
payloads instead of using any(...): for publishesLikeAddedEvent_whenLikeAdded
and publishesLikeRemovedEvent_whenLikeRemoved, capture the arguments passed to
eventPublisher.publishEvent and outboxEventPublisher.publish (use
ArgumentCaptor) and assert LikeAddedEvent/LikeRemovedEvent fields
(userId/loginId, productId) match the arranged User and Product, and assert
outboxEventPublisher.publish was called with the expected topic, key, eventType
and serialized data matching the event payload; additionally add failure-case
tests where likeService (or likeFacade internals) throws and assert neither
eventPublisher.publishEvent nor outboxEventPublisher.publish is invoked. Ensure
you reference likeFacade.addLike, likeFacade.removeLike, eventPublisher,
outboxEventPublisher.publish, LikeAddedEvent and LikeRemovedEvent when locating
code to change.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java-58-69 (1)

58-69: ⚠️ Potential issue | 🟡 Minor

JSON 필드 null 체크가 누락되어 있다.

node.get("eventId"), node.get("data") 등이 null 반환 시 NPE가 발생한다. CatalogEventConsumer와 동일하게 방어적 null 체크를 추가해야 한다.

🤖 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 58 - 69, Add defensive null checks in CouponIssueConsumer where you
read JSON fields: verify node.get("eventId") and node.get("data") are non-null
before calling asText()/asLong(), and likewise ensure data.get("requestId"),
data.get("couponId"), and data.get("userId") are non-null before using asLong();
if any required field is missing, log a debug/error with the eventId (if
available) and return early (similar to CatalogEventConsumer) so
eventHandledRepository.existsById and subsequent processing never receive nulls.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java-53-65 (1)

53-65: ⚠️ Potential issue | 🟡 Minor

JSON 필드 접근 시 NPE 위험이 있다.

node.get("eventId"), node.get("eventType"), node.get("data") 등이 null을 반환할 수 있다. 잘못된 형식의 메시지가 들어오면 NullPointerException이 발생하여 해당 레코드 처리가 실패하고, DLQ로 전송되지 않은 채 로그만 남게 된다. 운영 시 원인 파악이 어려워진다.

수정안: null 체크 및 명확한 예외 처리
 String rawPayload = new String((byte[]) record.value());
 JsonNode node = objectMapper.readTree(rawPayload);

-String eventId = node.get("eventId").asText();
-String eventType = node.get("eventType").asText();
+JsonNode eventIdNode = node.get("eventId");
+JsonNode eventTypeNode = node.get("eventType");
+if (eventIdNode == null || eventTypeNode == null) {
+    throw new IllegalArgumentException("필수 필드 누락: eventId 또는 eventType");
+}
+String eventId = eventIdNode.asText();
+String eventType = eventTypeNode.asText();
🤖 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 53 - 65, The code in CatalogEventConsumer reads JSON fields
(eventId, eventType, occurredAt, data, productId) without null checks which can
cause NPEs; update the parsing in the consumer (around variables eventId,
eventType, occurredAt, data, productId and the objectMapper.treeToValue call) to
first validate node.hasNonNull("eventId"), node.hasNonNull("eventType"),
node.hasNonNull("data") and node.hasNonNull("occurredAt") and that
data.hasNonNull("productId"); if any required field is missing or of the wrong
type, throw a clear runtime exception (e.g., IllegalArgumentException) including
the raw node/payload and a short reason so the message is rejected consistently
(DLQ-friendly) instead of causing an NPE; also wrap objectMapper.treeToValue(…
ZonedDateTime.class) in a try/catch to rethrow a descriptive exception on parse
errors.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java-42-47 (1)

42-47: ⚠️ Potential issue | 🟡 Minor

동일 타임스탬프의 이벤트가 누락될 수 있다.

eventTime.isAfter(this.updatedAt) 조건은 동일 시각의 이벤트를 무시한다. 밀리초 단위로 동시에 발생한 좋아요 이벤트가 있으면 두 번째 이벤트가 처리되지 않는다. 빈도는 낮지만 데이터 정합성 이슈가 될 수 있다.

수정안: 동일 시각 허용
 public void increaseLikeCount(ZonedDateTime eventTime) {
-    if (eventTime.isAfter(this.updatedAt)) {
+    if (!eventTime.isBefore(this.updatedAt)) {
         this.likeCount++;
         this.updatedAt = eventTime;
     }
 }

단, 이 경우 중복 처리 방지는 EventHandled 테이블의 멱등성 체크에 의존해야 한다.

🤖 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 42 - 47, The current condition in increaseLikeCount(ZonedDateTime
eventTime) uses eventTime.isAfter(this.updatedAt) which ignores events with an
identical timestamp; change the condition to accept equal timestamps (e.g.,
eventTime.isAfter(this.updatedAt) || eventTime.isEqual(this.updatedAt) or use
!eventTime.isBefore(this.updatedAt)) so that events with the same ZonedDateTime
are applied; keep in mind duplicate protection should be enforced by the
EventHandled idempotency check rather than this timestamp comparison.
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerConcurrencyTest.java-82-87 (1)

82-87: ⚠️ Potential issue | 🟡 Minor

예외를 무시하면 테스트 실패 원인 파악이 어렵다.

catch (Exception ignored)로 모든 예외를 삼키면, 예상치 못한 오류(예: DB 연결 실패, 잘못된 직렬화)가 발생해도 테스트가 통과할 수 있다. 실제 동시성 문제가 아닌 환경 문제로 발급 수가 적어져도 테스트가 성공으로 처리된다.

수정안: 예외 수집 및 검증
+List<Exception> errors = Collections.synchronizedList(new ArrayList<>());
+
 for (ConsumerRecord<Object, Object> record : records) {
     executor.submit(() -> {
         try {
             startLatch.await();
             consumer.consume(List.of(record), ack);
-        } catch (Exception ignored) {
+        } catch (Exception e) {
+            errors.add(e);
         } finally {
             doneLatch.countDown();
         }
     });
 }
+
+// Assert 섹션에 추가
+assertThat(errors).isEmpty();
🤖 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/CouponIssueConsumerConcurrencyTest.java`
around lines 82 - 87, The test swallows all exceptions in the worker thread
(catch (Exception ignored)) which hides failures; change the thread lambda in
CouponIssueConsumerConcurrencyTest so it captures exceptions instead of ignoring
them (e.g., add a ConcurrentLinkedQueue<Throwable> or AtomicReference<Throwable>
declared outside the lambda), push any caught exception from the catch block
into that collection when invoking consumer.consume(List.of(record), ack), and
after awaiting doneLatch check/throw/assert that the collection is empty (or
rethrow the first captured Throwable) so test fails on real errors; keep
doneLatch.countDown() in finally.
🧹 Nitpick comments (15)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java (1)

33-39: 상태 전이에 도메인 규칙 가드가 필요하다.

운영에서 재처리나 중복 소비가 발생하면 SUCCESSFAILED로 덮어써져 집계/모니터링/고객 응대 데이터가 왜곡될 수 있다. 수정안으로 PENDING -> SUCCESS|FAILED만 허용하고, 이미 종결된 상태에서의 변경은 no-op 또는 예외로 차단하는 전이 규칙을 메서드에 추가해야 한다. 추가 테스트로 전이 매트릭스 테스트(PENDING->SUCCESS, PENDING->FAILED, SUCCESS->FAILED, FAILED->SUCCESS, 동일 상태 재호출)를 명시해 회귀를 방지해야 한다. As per coding guidelines **/domain/**/*.java: "도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다."

수정 예시 diff
 public class CouponIssueRequest extends BaseEntity {
@@
     public void markSuccess() {
-        this.status = CouponIssueStatus.SUCCESS;
+        if (this.status == CouponIssueStatus.SUCCESS) {
+            return;
+        }
+        if (this.status != CouponIssueStatus.PENDING) {
+            throw new IllegalStateException("Invalid transition: " + this.status + " -> SUCCESS");
+        }
+        this.status = CouponIssueStatus.SUCCESS;
     }
 
     public void markFailed() {
-        this.status = CouponIssueStatus.FAILED;
+        if (this.status == CouponIssueStatus.FAILED) {
+            return;
+        }
+        if (this.status != CouponIssueStatus.PENDING) {
+            throw new IllegalStateException("Invalid transition: " + this.status + " -> FAILED");
+        }
+        this.status = CouponIssueStatus.FAILED;
     }
 }
🤖 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 33 - 39, The markSuccess and markFailed methods in
CouponIssueRequest must enforce domain transition guards so only PENDING ->
SUCCESS or PENDING -> FAILED are allowed; update markSuccess() and markFailed()
to check this.status == CouponIssueStatus.PENDING and either set the new status
or else be a no-op (or throw a DomainException per project convention) to
prevent overwriting terminal states, keep logic inside CouponIssueRequest
(domain layer) and do not mix infra concerns; also add unit tests for the
transition matrix covering PENDING->SUCCESS, PENDING->FAILED, SUCCESS->FAILED,
FAILED->SUCCESS and repeated calls to the same transition to prevent
regressions.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java (1)

8-22: userId를 API 응답에 포함하는 것이 적절한지 검토가 필요하다.

userId는 사용자 식별 정보(PII)에 해당할 수 있다. 클라이언트가 본인의 발급 요청만 조회한다면 userId를 응답에 포함할 필요가 없을 수 있다. 타인의 요청 정보를 열람할 수 있는 API라면 userId 노출이 보안 문제가 될 수 있다.

추가 테스트: 권한 없는 사용자가 타인의 requestId로 조회 시 userId가 노출되는지 확인하는 인가 테스트가 필요하다.

🤖 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/CouponV1Dto.java`
around lines 8 - 22, IssueResponse currently exposes userId (from
CouponIssueInfo) which may be PII; remove the userId field from the
IssueResponse record and from the static IssueResponse.from(CouponIssueInfo)
mapping so API responses no longer include user identifiers, and instead rely on
server-side authorization to return only the requesting user's data or add an
explicit authorizedUserId check in the controller/service layer before
constructing IssueResponse; update any callers that construct IssueResponse to
use the new constructor signature and add/adjust authorization tests to ensure
unauthenticated/unauthorized requests cannot see other users' request details.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java (1)

47-49: 빈/널 orderIds 가드가 없어 불필요한 조회 부하를 만들 수 있다.

운영 관점에서 배치/집계 호출이 빈 목록을 자주 전달하면 의미 없는 DB 호출이 누적될 수 있다.
수정안은 null 또는 빈 목록 입력 시 즉시 List.of()를 반환하고, 그 외에는 조회를 수행하도록 가드를 추가하는 방식이다.
추가 테스트로 null/빈 목록 입력 시 빈 결과 반환 및 저장소 미호출, 정상 목록 입력 시 기존 조회 동작 유지를 검증해야 한다.

수정 예시 diff
 `@Transactional`(readOnly = true)
 public List<OrderItem> getOrderItemsByOrderIds(List<Long> orderIds) {
+    if (orderIds == null || orderIds.isEmpty()) {
+        return List.of();
+    }
     return orderItemRepository.findByOrderIds(orderIds);
 }
As per coding guidelines: "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/order/OrderService.java`
around lines 47 - 49, Add a null/empty guard in getOrderItemsByOrderIds to avoid
unnecessary DB calls: if the incoming orderIds is null or orderIds.isEmpty()
return List.of() immediately, otherwise call
orderItemRepository.findByOrderIds(orderIds); also add unit tests asserting that
null and empty inputs return an empty list and do not invoke
orderItemRepository, and that a non-empty list delegates to
orderItemRepository.findByOrderIds as before.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java (1)

21-24: orderIds가 null이거나 빈 리스트일 때의 동작 확인 필요.

orderIds가 null인 경우 JPA 레벨에서 NullPointerException이 발생할 수 있다. 또한, 매우 큰 리스트가 전달될 경우 IN 절의 DB별 제한(Oracle 1000개 등)에 걸릴 수 있다. 호출부에서 방어 로직이 있는지 확인하거나, 여기서 early return을 추가하는 것이 운영 안정성 측면에서 권장된다.

♻️ 방어 로직 추가 예시
 `@Override`
 public List<OrderItem> findByOrderIds(List<Long> orderIds) {
+    if (orderIds == null || orderIds.isEmpty()) {
+        return List.of();
+    }
     return orderItemJpaRepository.findByOrderIdIn(orderIds);
 }
🤖 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/order/OrderItemRepositoryImpl.java`
around lines 21 - 24, In OrderItemRepositoryImpl.replace the current
findByOrderIds(List<Long> orderIds) body to guard against null/empty and
oversized IN lists: return Collections.emptyList() immediately when orderIds is
null or empty, and for large lists split orderIds into chunks (e.g., <= 900) and
invoke orderItemJpaRepository.findByOrderIdIn(...) per chunk, aggregating
results into a single List<OrderItem> to return; ensure you reference the
existing method findByOrderIds and the repository call
orderItemJpaRepository.findByOrderIdIn when implementing this logic.
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java (1)

3-7: 상수 전용 클래스는 인스턴스화 방지를 위해 private 생성자를 추가해야 한다.

KafkaTopics는 상수만 포함하는 유틸리티 클래스이므로, 인스턴스 생성을 방지하고 final로 상속을 막는 것이 관례이다.

♻️ 유틸리티 클래스 패턴 적용
-public class KafkaTopics {
+public final class KafkaTopics {
+    private KafkaTopics() {
+        // prevent instantiation
+    }
+
     public static final String CATALOG_EVENTS = "catalog-events";
     public static final String ORDER_EVENTS = "order-events";
     public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests";
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java` around
lines 3 - 7, Make KafkaTopics a non-instantiable utility class by declaring the
class final and adding a private constructor (e.g., private KafkaTopics() {
throw new AssertionError("no instances"); }) to prevent instantiation; keep the
existing constants (CATALOG_EVENTS, ORDER_EVENTS, COUPON_ISSUE_REQUESTS)
unchanged and ensure no public constructors remain.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java (1)

28-31: 생성자에서 totalQuantity 음수 검증이 누락되었다.

선착순 쿠폰 발급에서 totalQuantity가 음수이면 Redis INCR gate 로직과 재고 비교가 오작동할 수 있다. 도메인 불변성 보장을 위해 생성자에서 방어 코드를 추가하는 것이 좋다.

🛡️ 음수 검증 추가
     public Coupon(String name, int totalQuantity) {
+        if (totalQuantity < 0) {
+            throw new IllegalArgumentException("totalQuantity must be non-negative");
+        }
         this.name = name;
         this.totalQuantity = totalQuantity;
     }
🤖 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 28 - 31, 생성자 Coupon(String name, int totalQuantity)에서
totalQuantity가 음수일 경우 도메인 불변성이 깨지지 않도록 방어 로직을 추가하세요; Coupon 클래스의 생성자(Coupon)에서
전달받은 totalQuantity를 검사하여 음수이면 IllegalArgumentException(또는 도메인에 맞는 예외)으로 즉시 던지거나
0으로 정규화하는 방식으로 처리하고, 이 검사 후에만 this.totalQuantity에 값을 할당하도록 수정하세요.
apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java (1)

14-19: @async 메서드의 예외 처리 전략이 부재하다.

@Async 메서드에서 발생한 예외는 기본적으로 콘솔에 출력되고 무시된다. 현재는 단순 로깅이라 치명적이지 않지만, 향후 로직 추가 시 장애 추적이 어려워질 수 있다.

AsyncUncaughtExceptionHandler를 구성하거나, 메서드 내 try-catch로 예외를 명시적으로 처리하는 것이 운영 안정성에 도움이 된다.

🤖 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/OrderEventHandler.java`
around lines 14 - 19, The `@Async` method OrderEventHandler.handle lacks exception
handling; either wrap the method body in a try-catch that logs errors with
context (orderId, userId, totalAmount) and any stacktrace, or register an
AsyncUncaughtExceptionHandler bean (and wire it into your AsyncConfigurer) to
centrally handle/log uncaught exceptions from `@Async` methods; pick one approach,
update OrderEventHandler.handle or add the AsyncUncaughtExceptionHandler
implementation and ensure exceptions include the event details for traceability.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java (1)

37-41: 생성자에서 couponId, userId null 검증이 누락되었다.

둘 다 nullable=false 컬럼이지만 생성자에서 검증이 없다. null 입력 시 DB 저장 단계에서 예외가 발생하므로, 빠른 실패(fail-fast)를 위해 생성자에서 검증하는 것이 디버깅에 유리하다.

🛡️ null 검증 추가
     public IssuedCoupon(Long couponId, Long userId) {
+        if (couponId == null || userId == null) {
+            throw new IllegalArgumentException("couponId and userId must not be null");
+        }
         this.couponId = couponId;
         this.userId = userId;
         this.issuedAt = ZonedDateTime.now();
     }
🤖 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 37 - 41, The IssuedCoupon(Long couponId, Long userId) constructor
lacks null checks for couponId and userId; add fail-fast validation at the start
of the constructor (in the IssuedCoupon class constructor) to throw
IllegalArgumentException (or NullPointerException per project convention) with
clear messages like "couponId must not be null" and "userId must not be null"
before assigning fields and setting issuedAt so nulls are rejected earlier than
the DB layer.
apps/commerce-streamer/src/main/java/com/loopers/domain/outbox/EventHandled.java (1)

25-28: eventId null 검증이 없고, ZonedDateTime.now() 호출이 테스트 격리를 어렵게 한다.

  1. eventId가 null이면 DB 저장 시 NPE 또는 constraint violation이 발생한다. 생성자에서 방어 코드 추가가 필요하다.
  2. ZonedDateTime.now() 직접 호출은 단위 테스트에서 시간 제어가 어렵다. Clock 주입 또는 팩토리 메서드 패턴을 고려할 수 있다.
🛡️ null 검증 추가 예시
     public EventHandled(String eventId) {
+        if (eventId == null || eventId.isBlank()) {
+            throw new IllegalArgumentException("eventId must not be null or blank");
+        }
         this.eventId = eventId;
         this.handledAt = ZonedDateTime.now();
     }
🤖 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/outbox/EventHandled.java`
around lines 25 - 28, The EventHandled constructor lacks null validation for
eventId and directly calls ZonedDateTime.now(), making tests brittle; update the
EventHandled(String eventId) constructor to validate eventId (e.g.,
Objects.requireNonNull or throw IllegalArgumentException) and introduce
time-source injection by adding an overload or factory that accepts a
java.time.Clock (e.g., EventHandled(String eventId, Clock clock)) and set
handledAt using ZonedDateTime.now(clock), providing a default constructor or
factory that delegates to Clock.systemDefaultZone() for production use so tests
can supply a fixed Clock.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java (1)

8-10: countByCouponId 쿼리 성능 모니터링 권장

IssuedCoupon 엔티티에 (coupon_id, user_id) 복합 유니크 제약이 정의되어 있으나, coupon_id 단독 인덱스가 없다. countByCouponId 쿼리는 복합 인덱스를 활용하거나 테이블 스캔을 수행하므로, 데이터 증가 시 성능 저하 가능성이 있다.

현재는 Redis INCR 게이트가 주요 동시성 제어 역할을 하고 countByCouponId는 백업 검증으로만 사용되므로 즉각적인 문제가 아니다. 운영 중 발급 데이터가 대규모로 증가하면 issued_coupons 테이블의 coupon_id 컬럼에 인덱스를 추가하여 COUNT 쿼리 성능을 개선할 필요가 있다.

향후 단계에서 데이터 증가 추이를 모니터링하고, 필요시 인덱스 추가 또는 쿼리 최적화를 검토할 것을 권장한다.

🤖 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/infrastructure/coupon/IssuedCouponJpaRepository.java`
around lines 8 - 10, The countByCouponId repository method on IssuedCoupon can
suffer table-scan as there is no single-column index on coupon_id; monitor
growth of issued_coupons and, if COUNT queries become slow, add a DB index on
coupon_id (or a covering index) to support countByCouponId, and consider adding
metrics/alerts around execution time of
IssuedCouponJpaRepository.countByCouponId and query plans; also confirm the
existing (coupon_id, user_id) unique constraint still supports
existsByCouponIdAndUserId without change.
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerConcurrencyTest.java (1)

91-92: ExecutorService 종료 처리가 불완전하다.

executor.shutdown()은 새 작업 제출만 막고, 실행 중인 작업 완료를 기다리지 않는다. doneLatch.await()가 타임아웃되면 백그라운드 스레드가 여전히 DB에 접근할 수 있어 다음 테스트에 영향을 줄 수 있다.

수정안
-doneLatch.await(30, TimeUnit.SECONDS);
-executor.shutdown();
+boolean completed = doneLatch.await(30, TimeUnit.SECONDS);
+executor.shutdown();
+executor.awaitTermination(5, TimeUnit.SECONDS);
+assertThat(completed).isTrue();
🤖 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/CouponIssueConsumerConcurrencyTest.java`
around lines 91 - 92, The test currently only calls executor.shutdown() after
doneLatch.await(...), which doesn't wait for running tasks to finish and can
leave background threads touching the DB; update the teardown to (1) check the
boolean result of doneLatch.await(30, TimeUnit.SECONDS), (2) call
executor.shutdown() and then block on executor.awaitTermination(...) for a short
timeout, and (3) if termination times out, call executor.shutdownNow() to force
interruption; reference the existing doneLatch.await(...) and
executor.shutdown() calls when making the change so the test reliably stops
background threads before finishing.
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (1)

28-30: 동시 업데이트 보호를 위한 낙관적 잠금 고려가 필요하다.

다중 스케줄러 인스턴스 환경에서 동일 이벤트에 대한 동시 업데이트가 발생할 수 있다. FOR UPDATE SKIP LOCKED가 repository에서 적용되더라도, @Version 필드를 추가하면 추가적인 동시성 보호와 lost update 감지가 가능하다.

수정안
 `@Column`(name = "status", nullable = false, length = 20)
 private OutboxStatus status = OutboxStatus.PENDING;
+
+@Version
+private Long version;
🤖 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/OutboxEvent.java`
around lines 28 - 30, OutboxEvent 엔티티에 낙관적 잠금을 추가하세요: 클래스 OutboxEvent에 `@Version이`
붙은 Long 타입 필드(예: private Long version;)를 추가해 JPA의 Optimistic Locking을 활성화하고, 필요한
getter/setter를 생성해 주입되도록 하세요; 이렇게 하면 repository의 FOR UPDATE SKIP LOCKED와 함께 동시
업데이트 시 lost update 감지(OptimisticLockException)를 받을 수 있습니다.
apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponConcurrencyTest.java (2)

83-89: 예외를 무시하면 동시성 테스트의 신뢰성이 떨어진다.

catch (Exception ignored) 패턴은 테스트 중 발생한 오류를 숨긴다. 앞서 리뷰한 CouponIssueConsumerConcurrencyTest와 동일하게 예외 수집 후 검증하는 방식을 권장한다.

🤖 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/CouponConcurrencyTest.java`
around lines 83 - 89, The test currently swallows exceptions in the worker
threads (catch (Exception ignored)) which hides failures; update
CouponConcurrencyTest to collect exceptions from each thread (e.g., a
ConcurrentLinkedQueue<Throwable> or AtomicReference<Throwable>) inside the catch
block when calling couponFacade.requestIssue(loginId, rawPassword,
coupon.getId()), and after doneLatch.await() assert that the collection is empty
or rethrow the first captured exception so failures are visible (mirror the
approach used in CouponIssueConsumerConcurrencyTest); ensure startLatch and
doneLatch usage remains the same but remove silent ignores and perform
verification on the collected exceptions at the end of the test.

117-127: 실제 컨슈머 코드를 테스트하지 않고 시뮬레이션만 수행한다.

이 테스트는 CouponIssueRequest의 상태를 직접 markSuccess()/markFailed()로 변경하여 "컨슈머 순차 처리를 시뮬레이션"한다. 실제 CouponIssueConsumer의 비즈니스 로직(중복 발급 체크, 수량 확인, IssuedCoupon 저장 등)은 테스트되지 않는다.

테스트 이름 exactly100SucceedAnd100Fail_whenProcessedSequentially가 기대하는 바와 실제 검증 내용이 다르다.

두 가지 방향을 고려할 수 있다:

  1. 테스트 이름/설명을 "요청 상태 변경 시나리오 검증"으로 명확히 하거나
  2. 실제 Consumer를 호출하는 통합 테스트로 변경 (commerce-streamer 모듈의 CouponIssueConsumerConcurrencyTest처럼)
🤖 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/CouponConcurrencyTest.java`
around lines 117 - 127, The test
exactly100SucceedAnd100Fail_whenProcessedSequentially is only simulating
consumer behavior by directly calling
CouponIssueRequest.markSuccess()/markFailed() and saving via
couponIssueRequestJpaRepository, so it doesn't exercise the actual business
logic in CouponIssueConsumer (duplication checks, inventory decrement,
IssuedCoupon persistence). Either rename the test to reflect it's validating
"request state change simulation" or convert it into an integration-style test
that invokes the real consumer (e.g., dispatch requests into the same path used
by CouponIssueConsumer or reuse the approach from commerce-streamer’s
CouponIssueConsumerConcurrencyTest) so the actual methods and classes
(CouponIssueConsumer, CouponIssueRequest processing flow, IssuedCoupon
persistence) are exercised rather than directly mutating request state.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java (1)

24-30: 배치 전체를 단일 트랜잭션으로 처리하면 장애 격리가 어렵다.

100개 이벤트를 단일 트랜잭션에서 처리 중 마지막 이벤트에서 예외가 발생하면 전체 배치가 롤백된다. PR 설명에 언급된 "이벤트별 독립 트랜잭션(FOR UPDATE SKIP LOCKED)" 설계와 현재 구현이 일치하지 않는다.

수정안: 이벤트별 독립 트랜잭션 적용
-@Scheduled(fixedDelay = 1000)
-@Transactional
-public void relay() {
+@Scheduled(fixedDelay = 1000)
+public void relay() {
     List<OutboxEvent> pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE);
     if (pendingEvents.isEmpty()) {
         return;
     }
 
     for (OutboxEvent event : pendingEvents) {
-        try {
-            // ... 처리 로직
-        } catch (Exception e) {
-            log.error("[Outbox] 릴레이 처리 실패: eventId={}", event.getEventId(), e);
-        }
+        relayEvent(event.getId()); // 별도 트랜잭션 메서드 호출
     }
-
-    log.debug("[Outbox] {}건 발행 완료", pendingEvents.size());
 }
+
+@Transactional
+public void relayEvent(Long eventId) {
+    // FOR UPDATE SKIP LOCKED로 개별 조회 후 처리
+}

별도 빈의 메서드 호출 또는 TransactionTemplate을 사용해야 프록시 우회 문제를 방지할 수 있다.

🤖 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 24 - 30, The relay() method in OutboxRelayScheduler currently loads
a batch with outboxEventRepository.findPendingEvents(BATCH_SIZE) and processes
them in one `@Transactional` scope, causing whole-batch rollback on a single
failure; change to process each OutboxEvent in its own transaction by: (1)
modifying the repository query to acquire rows with FOR UPDATE SKIP LOCKED (or
add a repository method that does), and (2) moving per-event processing into a
separate transactional boundary (either a new `@Service` bean method, e.g.,
OutboxRelayProcessor.processEvent(OutboxEvent) called from relay(), or use
TransactionTemplate to execute per-event work), so each event commit/rollback is
isolated and the proxy is not bypassed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a640f4f-cf16-4558-bcac-961fce2dc9ba

📥 Commits

Reviewing files that changed from the base of the PR and between d4fe785 and 633ccce.

📒 Files selected for processing (56)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.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/config/AsyncConfig.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeAddedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRemovedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPayload.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/outbox/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/outbox/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CouponIssueConsumerConcurrencyTest.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java
  • modules/kafka/src/main/resources/kafka.yml

Comment on lines 39 to +43
@Transactional(readOnly = true)
public List<CouponInfo.IssuedInfo> getMyCoupons(String loginId, String rawPassword) {
User user = userService.authenticate(loginId, rawPassword);
return issuedCouponRepository.findAllByUserId(user.getId()).stream()
.map(CouponInfo.IssuedInfo::from)
.toList();
public CouponIssueInfo getIssueResult(String loginId, String rawPassword, Long requestId) {
userService.authenticate(loginId, rawPassword);
CouponIssueRequest request = couponService.getRequest(requestId);
return CouponIssueInfo.from(request);
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

발급 요청 조회에 소유자 검증이 빠져 있다.

Line 41에서 인증만 하고 request.getUserId()를 비교하지 않아, 다른 사용자가 requestId만 알면 발급 성공/실패 상태를 조회할 수 있다. 이는 쿠폰 발급 이력 노출로 이어지는 권한 우회다. authenticate(...) 결과의 사용자와 요청 소유자를 비교하고 불일치 시 기존 패턴대로 NOT_FOUND를 반환해야 한다. 추가로 다른 사용자로 getIssueResult를 호출했을 때 CoreException(ErrorType.NOT_FOUND)가 발생하는 테스트를 넣어야 한다.

수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
...
 `@Transactional`(readOnly = true)
 public CouponIssueInfo getIssueResult(String loginId, String rawPassword, Long requestId) {
-    userService.authenticate(loginId, rawPassword);
+    User user = userService.authenticate(loginId, rawPassword);
     CouponIssueRequest request = couponService.getRequest(requestId);
+    if (!request.getUserId().equals(user.getId())) {
+        throw new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다.");
+    }
     return CouponIssueInfo.from(request);
 }
🤖 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/CouponFacade.java`
around lines 39 - 43, getIssueResult currently only authenticates the caller but
does not verify ownership of the CouponIssueRequest, allowing any authenticated
user who knows requestId to read another user's issuance info; update
CouponFacade.getIssueResult to compare the authenticated user's id (from
userService.authenticate(...) return or context) with request.getUserId() and
throw CoreException(ErrorType.NOT_FOUND) when they differ (matching the existing
NOT_FOUND pattern), and add a unit/integration test that calls getIssueResult as
a different user and asserts CoreException(ErrorType.NOT_FOUND) is thrown.

Comment on lines +20 to +31
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleLikeAdded(LikeAddedEvent event) {
productService.increaseLikes(event.productId());
log.info("[UserBehavior] 좋아요 추가 - userId={}, productId={}", event.userId(), event.productId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleLikeRemoved(LikeRemovedEvent event) {
productService.decreaseLikes(event.productId());
log.info("[UserBehavior] 좋아요 취소 - userId={}, productId={}", event.userId(), 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

🧩 Analysis chain

🌐 Web query:

In Spring Framework, when a listener uses both @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) and @async, is the event persisted or retried automatically if the listener throws or the process stops after the transaction commits?

💡 Result:

No.

  • @TransactionalEventListener(phase = AFTER_COMMIT) only defers invocation until after the surrounding transaction successfully commits; it does not make the event durable (it’s still an in-memory application event). (docs.spring.io)
  • Adding @Async just hands the listener execution off to a TaskExecutor on another thread. If the JVM/process stops after the DB transaction commits but before (or during) that async execution, the event is simply lost—there’s nothing persisted for Spring to replay. (Spring’s transaction-bound event support is for timing, not delivery guarantees.) (docs.spring.io)
  • If the async listener throws, the original transaction is already committed, so it won’t roll anything back and Spring won’t automatically retry the listener; the exception is merely handled via async exception/error-handling mechanisms (e.g., executor/error handler / AsyncUncaughtExceptionHandler). (docs.spring.io)

If you need “persist + retry”, you have to add it yourself (e.g., transactional outbox table + background publisher, or publish to a durable broker like JMS/Kafka with retries).

Citations:


🏁 Script executed:

# First, let's examine the full file
find . -name "LikeEventHandler.java" -type f

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

Length of output: 170


🏁 Script executed:

# Check for event definitions and the full handler file
fd "LikeEventHandler" --type f

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

Length of output: 168


🏁 Script executed:

# Search for typical logging patterns with userId in the codebase
rg "userId.*log\.|log\..*userId" --type java -B2 -A2 | head -50

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

Length of output: 3458


🏁 Script executed:

# Check if there are any logging guidelines or patterns in similar event handlers
fd "EventHandler" --type f -path "*/application/event/*" | head -10

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

Length of output: 253


🏁 Script executed:

# Let's view the full LikeEventHandler.java file
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventHandler.java

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

Length of output: 1559


🏁 Script executed:

# Check if there are exception handlers or error handling patterns in similar event handlers
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java

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

Length of output: 931


🏁 Script executed:

# Check if there's a security or logging policy document
find . -name "*security*" -o -name "*logging*" -o -name "*SECURITY*" | grep -E "\.(md|txt|properties|yml|yaml)$" | head -20

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

Length of output: 134


🏁 Script executed:

# Check the logging configuration
cat -n ./supports/logging/src/main/resources/logging.yml

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

Length of output: 137


🏁 Script executed:

# Search for any documentation or guidelines about logging sensitive data
find . -name "*.md" -type f | xargs grep -l "userId\|sensitive\|로깅\|민감" 2>/dev/null | head -10

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

Length of output: 130


🏁 Script executed:

# Check if there are integration tests for event handlers
find . -path "*/test/*" -name "*EventHandler*" -type f | head -10

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

Length of output: 67


🏁 Script executed:

# Check the event classes to understand the data structure
find . -name "LikeAddedEvent.java" -o -name "LikeRemovedEvent.java" | xargs cat -n

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

Length of output: 318


🏁 Script executed:

# Check the ProductService to understand what increaseLikes/decreaseLikes do
find . -name "ProductService.java" -type f | xargs cat -n

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

Length of output: 3052


🏁 Script executed:

# Check for any retry mechanisms or recovery patterns in the codebase
rg "Retryable|@Retry|RetryTemplate|resilience4j" --type java | head -20

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

Length of output: 67


🏁 Script executed:

# Check the logback configuration for logging levels
cat -n ./supports/logging/src/main/resources/logback.xml 2>/dev/null | head -50

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

Length of output: 67


🏁 Script executed:

# Check if there are other async handlers and how they handle exceptions
rg "@Async" --type java -B5 -A10 | grep -A10 "EventHandler\|Consumer" | head -60

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

Length of output: 3574


🏁 Script executed:

# Search for TaskExecutor configuration
find . -name "*.java" -type f | xargs grep -l "TaskExecutor\|AsyncConfigur" | head -10

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

Length of output: 67


🏁 Script executed:

# Check the Product entity to understand likes field
find . -name "Product.java" -path "*/domain/product/*" -type f | xargs cat -n

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

Length of output: 3722


비동기 이벤트 리스너로는 좋아요 카운트 갱신이 유실될 수 있다.

@TransactionalEventListener(AFTER_COMMIT)@Async 조합에서는 DB 트랜잭션 커밋 이후 별도 스레드에서 productService.increaseLikes/decreaseLikes를 실행한다. 이 구간에 예외 발생, 스레드 풀 포화, 프로세스 종료 등이 발생하면 재시도 없이 이벤트가 소실되어 좋아요 관계 데이터와 Product.likesCount가 영구히 어긋날 수 있다. 조회 경로에 직접 사용되는 카운트라면 같은 트랜잭션에서 동기 반영하거나, 꼭 비동기여야 한다면 transactional outbox 패턴이나 Kafka 같은 재처리 가능한 메커니즘으로 전환해야 한다.

추가로 핸들러에 예외 처리가 없어서, productService.increaseLikes/decreaseLikes 실패 시 로그나 모니터링 없이 조용히 실패한다. 예외를 잡아서 로깅하고, 리스너 실패/애플리케이션 재시작 시나리오에서 좋아요 수가 보정되는지 확인하는 통합 테스트를 추가해야 한다.

🤖 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/LikeEventHandler.java`
around lines 20 - 31, The current `@TransactionalEventListener` methods
handleLikeAdded and handleLikeRemoved call
productService.increaseLikes/decreaseLikes asynchronously (`@Async`) after commit
which risks lost updates; either make the updates synchronous inside the
committing transaction (remove `@Async` and run in same thread) or replace the
async event with a durable, replayable mechanism (implement transactional outbox
or publish to Kafka) so retries are possible; additionally wrap calls to
productService.increaseLikes/decreaseLikes in try/catch inside these handlers to
log failures and surface metrics/alerts, and add an integration test that
simulates handler failure/restart to verify likesCount is eventually
consistent/repaired.

Comment on lines +24 to +31
log.info("[UserBehavior] 좋아요 추가 - userId={}, productId={}", event.userId(), event.productId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleLikeRemoved(LikeRemovedEvent event) {
productService.decreaseLikes(event.productId());
log.info("[UserBehavior] 좋아요 취소 - userId={}, productId={}", event.userId(), 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 로그에 사용자 식별자를 남기지 마십시오.

Line 24와 Line 31은 고빈도 좋아요 이벤트마다 userIdproductId를 INFO로 남긴다. 운영에서는 로그 비용이 급증하고 사용자 식별자가 과도하게 축적된다. INFO에는 집계 가능한 metric이나 저카디널리티 정보만 남기고, userId는 제거하거나 최소한 DEBUG로 낮추는 편이 안전하다. 추가로 로그 appender 테스트로 INFO 메시지에 userId가 포함되지 않는지 확인해야 한다. 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/event/LikeEventHandler.java`
around lines 24 - 31, The INFO logs in handleLikeRemoved (and the corresponding
like-add handler) include per-user identifiers (event.userId()), which must be
removed or demoted to DEBUG to avoid high-cardinality sensitive data in INFO;
update the log calls in the methods handleLikeRemoved (and the like-add handler
where log.info("[UserBehavior] 좋아요 추가 ...") is used) to omit event.userId()
(leave productId or other low-cardinality fields only) or change the level to
log.debug(...) if the userId must be retained for troubleshooting; additionally
add or update a log-appender test to assert that INFO-level messages from these
handlers do not contain userId values.

Comment on lines +7 to +10
@EnableAsync
@EnableScheduling
@Configuration
public class AsyncConfig {}
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

🏁 Script executed:

#!/bin/bash
# 목적: 현재 코드베이스에 명시적 Async/Scheduler 실행기 구성이 있는지 확인한다.
# 기대 결과:
# - ThreadPoolTaskExecutor / ThreadPoolTaskScheduler 빈 정의가 존재해야 한다.
# - `@Async` 사용처가 늘어났다면 실행기 명시가 확인되어야 한다.

echo '--- Async/Scheduling annotations ---'
rg -n '@EnableAsync|@EnableScheduling|@Async|@Scheduled'

echo '--- Executor/Scheduler bean definitions ---'
rg -n 'ThreadPoolTaskExecutor|ThreadPoolTaskScheduler|TaskExecutor|SchedulingConfigurer|AsyncConfigurer|AsyncUncaughtExceptionHandler'

echo '--- Relevant config files ---'
fd -t f '.*Config\.java$|.*Scheduler.*\.java$|.*EventHandler.*\.java$'

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

Length of output: 1883


🏁 Script executed:

# AsyncConfig 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java

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

Length of output: 448


🏁 Script executed:

# `@Async` 메서드들이 executor를 지정하는지 확인
rg -A 5 '@Async' apps/commerce-api/src/main/java/com/loopers/application/event/

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

Length of output: 2300


🏁 Script executed:

# OutboxRelayScheduler 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java

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

Length of output: 2077


기본 Async/Scheduling 실행기 의존은 운영 리스크가 크고, 비동기 메서드의 예외 처리가 누락되어 있다.

@EnableAsync@EnableScheduling이 활성화되었으나 명시적인 ThreadPoolTaskExecutor, ThreadPoolTaskScheduler, AsyncConfigurer가 정의되지 않아 Spring의 기본 설정을 사용 중이다. 트래픽 급증 시 스레드/큐 제어와 백프레셔가 불명확해 스레드 풀 고갈 또는 이벤트 처리 지연으로 이어질 수 있다.

추가로 LikeEventHandlerhandleLikeAdded(), handleLikeRemoved()OrderEventHandlerhandle() 메서드는 @Async 어노테이션만 있고 예외 처리가 없어서 비동기 실행 중 예외가 발생하면 무시된다. OutboxRelayScheduler@Scheduled(fixedDelay = 1000)으로 배치 처리하되, Kafka 발행 실패 시 재시도 메커니즘이 없고 기본 scheduler를 사용한다.

수정안은 다음과 같다:

  1. AsyncConfigThreadPoolTaskExecutor(corePoolSize, maxPoolSize, queueCapacity, rejectedExecutionHandler 명시)와 ThreadPoolTaskScheduler(poolSize, errorHandler 명시)를 빈으로 등록한다.
  2. AsyncUncaughtExceptionHandler를 구현하여 비동기 메서드의 예외를 적절히 로깅하고 처리한다.
  3. @Async 메서드에서 executor를 명시하거나(@Async("executorName")), 최소한 메서드 레벨에서 try-catch로 예외를 처리하여 장애 전파를 방지한다.
  4. OutboxRelayScheduler의 발행 실패 이벤트는 재시도 큐에 등록하거나 상태를 별도로 추적하여 나중에 재처리한다.

추가 테스트로 컨텍스트 테스트에서 지정 실행기 빈이 실제로 바인딩되는지 확인하고, 부하 테스트에서 동시 비동기 작업 100개 이상 발생 시 큐 포화, 거부, 예외 처리 동작을 검증해야 한다.

🤖 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/AsyncConfig.java` around
lines 7 - 10, AsyncConfig currently relies on Spring defaults and lacks custom
executors and an uncaught exception handler; add beans for a
ThreadPoolTaskExecutor (set corePoolSize, maxPoolSize, queueCapacity and a
RejectedExecutionHandler) and a ThreadPoolTaskScheduler (set poolSize and an
ErrorHandler) and implement AsyncConfigurer or provide an
AsyncUncaughtExceptionHandler bean to log/handle async exceptions. Update `@Async`
usages: either annotate methods to use the named executor (e.g.,
`@Async`("yourExecutor")) or wrap body of LikeEventHandler.handleLikeAdded,
LikeEventHandler.handleLikeRemoved, and OrderEventHandler.handle in try/catch to
log and surface errors; for OutboxRelayScheduler, switch scheduled execution to
the custom scheduler and add a retry/queueing mechanism for publish failures
(persist failed outbox entries or push to a retry queue/state for later
reprocessing). Also add context/unit tests to assert the custom
executor/scheduler beans are used and add a load test scenario for >100
concurrent async tasks to validate queue/rejection behavior.

Comment on lines +14 to +34
public class Coupon extends BaseEntity {

@Column(name = "name", nullable = false, length = 100)
private String name;

@Column(name = "total_quantity", nullable = false)
private Integer totalQuantity;

protected Coupon() {}

public Coupon(String name, Integer totalQuantity) {
if (name == null || name.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 비어있을 수 없습니다.");
}
if (totalQuantity == null || totalQuantity <= 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "발급 수량은 0보다 커야 합니다.");
}
this.name = name;
this.totalQuantity = totalQuantity;
}
}
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
# BaseEntity의 equals/hashCode 구현 확인
ast-grep --pattern $'class BaseEntity {
  $$$
  equals($_) {
    $$$
  }
  $$$
}'

rg -n "equals|hashCode" --type=java -A 5 -B 2 | rg -A 7 "BaseEntity"

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

Length of output: 67


🏁 Script executed:

# BaseEntity 파일 찾기
find . -name "BaseEntity.java" -type f 2>/dev/null

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

Length of output: 147


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java

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

Length of output: 2291


🏁 Script executed:

# Verify if equals/hashCode might be generated by Lombok elsewhere
rg -n "@EqualsAndHashCode|@Data" ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java

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

Length of output: 67


Coupon 엔티티에 equals/hashCode 메서드를 구현해야 한다.

BaseEntityequals/hashCode를 정의하지 않으므로 기본 객체 동일성(identity comparison)을 사용한다. 이는 JPA 엔티티에서 중대한 문제다. 새로 생성된 쿠폰(id=0L)과 영속화된 쿠폰(생성된 id)은 동일한 데이터를 가지고 있어도 절대 같다고 판정되지 않는다.

이 문제는 다음 상황에서 버그를 유발한다:

  • Set<Coupon> 연산: 중복 삽입
  • Map<Coupon, V> 연산: 키 조회 실패
  • 비즈니스 로직의 엔티티 동등성 검사

해결책: equals/hashCode를 식별자 기반(id)으로 구현하거나, 비즈니스 키(예: name)를 사용한 일관된 전략을 정의한다. 변경 후 Set/Map 컬렉션 및 equals 비교 시나리오에 대한 단위 테스트를 추가한다.

🤖 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/Coupon.java` around
lines 14 - 34, The Coupon entity lacks equals/hashCode causing identity-based
comparisons to break collection and business logic; implement equals(Object) and
hashCode() on Coupon using a consistent identifier strategy (preferably the
entity id when non-null, falling back to a business key like name for transient
instances) and ensure they reference the id field from BaseEntity and the name
field in Coupon; update tests to cover Set<Coupon>/Map<Coupon,V> behavior and
equals comparisons between transient (id null/0) and persisted instances.

Comment on lines +42 to +63
public void increaseLikeCount(ZonedDateTime eventTime) {
if (eventTime.isAfter(this.updatedAt)) {
this.likeCount++;
this.updatedAt = eventTime;
}
}

public void decreaseLikeCount(ZonedDateTime eventTime) {
if (eventTime.isAfter(this.updatedAt)) {
if (this.likeCount > 0) {
this.likeCount--;
}
this.updatedAt = eventTime;
}
}

public void increaseOrderCount(ZonedDateTime eventTime) {
if (eventTime.isAfter(this.updatedAt)) {
this.orderCount++;
this.updatedAt = eventTime;
}
}
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

동시 접근 시 카운터 업데이트 손실(lost update)이 발생할 수 있다.

CatalogEventConsumerProductMetrics를 조회하고 increaseLikeCount() 등을 호출하는 과정에서, 동일 productId에 대한 동시 요청이 있으면 this.likeCount++ 연산이 race condition에 노출된다. 낙관적 잠금(@Version)이나 비관적 잠금 없이는 동시 업데이트 시 일부 증감이 무시된다.

운영 시 좋아요 수가 실제와 불일치하는 문제가 발생할 수 있다.

수정안: 낙관적 잠금 추가
 `@Column`(name = "updated_at", nullable = false)
 private ZonedDateTime updatedAt;
+
+@Version
+private Long version;

또는 @Lock(LockModeType.PESSIMISTIC_WRITE)를 repository 메서드에 적용하는 방안도 고려할 수 있다. PR 설명에 "OptimisticLock 재시도 최대 3회"가 언급되어 있으므로 @Version 적용이 의도된 설계로 보인다.

🤖 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 42 - 63, ProductMetrics' counter methods (increaseLikeCount,
decreaseLikeCount, increaseOrderCount) are vulnerable to lost updates under
concurrent access; add JPA optimistic locking by introducing a version field
annotated with `@Version` in the ProductMetrics entity, and implement a retry
wrapper where the repository/service that invokes these methods (e.g.,
CatalogEventConsumer -> ProductMetrics repository/service) catches
OptimisticLockException and retries the fetch-modify-save cycle up to 3 times
before failing; ensure the retry logic re-reads the entity each attempt so the
increment/decrement applies to the latest state.

Comment on lines +13 to +15
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithLock(Long id);
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

findByIdWithLock 메서드에 lock timeout 힌트가 누락되었다.

Pessimistic lock 획득 시 timeout 설정이 없으면 데드락 상황에서 무한 대기가 발생할 수 있다. 이 프로젝트의 다른 WithLock 메서드들은 @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))를 일관되게 적용하고 있다. 운영 환경에서 lock 경합 시 장애 확산을 방지하려면 반드시 timeout을 설정해야 한다.

🔒️ 수정안
+import org.springframework.data.jpa.repository.QueryHints;
+import jakarta.persistence.QueryHint;

 public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {

     `@Lock`(LockModeType.PESSIMISTIC_WRITE)
+    `@QueryHints`(`@QueryHint`(name = "jakarta.persistence.lock.timeout", value = "3000"))
     `@Query`("SELECT c FROM Coupon c WHERE c.id = :id")
     Optional<Coupon> findByIdWithLock(Long id);
 }

Based on learnings: "In PR #148, ALL WithLock repository methods have QueryHints with jakarta.persistence.lock.timeout applied consistently."

📝 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
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithLock(Long id);
`@Lock`(LockModeType.PESSIMISTIC_WRITE)
`@QueryHints`(`@QueryHint`(name = "jakarta.persistence.lock.timeout", value = "3000"))
`@Query`("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdWithLock(Long id);
🤖 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/infrastructure/coupon/CouponJpaRepository.java`
around lines 13 - 15, The findByIdWithLock repository method is missing a lock
timeout hint; add a QueryHints annotation to the findByIdWithLock method (the
Optional<Coupon> findByIdWithLock(Long id) method) with a QueryHint name
"jakarta.persistence.lock.timeout" and value "3000" so the pessimistic write
lock won’t wait indefinitely during deadlocks; ensure the new `@QueryHints`(...)
is applied alongside `@Lock`(LockModeType.PESSIMISTIC_WRITE) and `@Query`("SELECT c
FROM Coupon c WHERE c.id = :id").

Comment on lines +1 to +6
package com.loopers.infrastructure.metrics;

import com.loopers.domain.metrics.ProductMetrics;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetrics, Long> {}
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: ProductMetrics 엔티티의 동시성 제어 메커니즘 확인
ast-grep --pattern $'@Entity
$$$
class ProductMetrics {
  $$$
}'

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# ProductMetrics 엔티티 위치 찾기
fd -e java | xargs grep -l "class ProductMetrics"

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

Length of output: 168


🏁 Script executed:

#!/bin/bash
# CatalogEventConsumer 위치 찾기
fd -e java | xargs grep -l "CatalogEventConsumer"

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

Length of output: 179


🏁 Script executed:

#!/bin/bash
# ProductMetrics 엔티티 내용 확인 (컨텍스트 포함)
rg "class ProductMetrics" -A 30 -B 3

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

Length of output: 3832


🏁 Script executed:

#!/bin/bash
# ProductMetrics 엔티티 전체 내용 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java

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

Length of output: 2162


🏁 Script executed:

#!/bin/bash
# CatalogEventConsumer 내용 확인
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: 4051


🏁 Script executed:

#!/bin/bash
# ProductMetrics 업데이트 로직 검색 (서비스 레이어)
rg "productMetricsJpaRepository|ProductMetrics" -B 3 -A 3 --type java | grep -E "(class|interface|@Service|@Transactional|save|update)"

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

Length of output: 4639


동시성 제어 메커니즘이 부재하여 Lost Update 발생 위험이 있다.

ProductMetrics 엔티티에 @Version 필드가 없고, CatalogEventConsumer의 timestamp 기반 검사(eventTime.isAfter(updatedAt))는 데이터베이스 수준의 동시성 제어가 아니다. 여러 Consumer 인스턴스나 Kafka 파티션이 동시에 같은 productId의 metrics를 업데이트할 때, 다음과 같은 Race Condition이 발생한다:

  1. Thread A, B가 동일한 ProductMetrics 로드 (likeCount=5, updatedAt=t1)
  2. Thread A: likeCount 6으로 증가, updatedAt=t2로 저장
  3. Thread B: 여전히 old updatedAt=t1 기반 비교, likeCount 6으로 증가
  4. 결과: likeCount=7이 되어야 하지만 6이 됨 (Lost Update)

수정안: ProductMetrics에 @Version 필드를 추가하거나, Repository에서 findById()PESSIMISTIC_WRITE 락을 적용한다.

수정 예시
`@Entity`
`@Table`(name = "product_metrics")
`@Getter`
public class ProductMetrics {
    // ...
    `@Version`
    private Long version;
    // ...
}

또는 Repository에 custom 메서드 추가:

`@Lock`(LockModeType.PESSIMISTIC_WRITE)
`@Query`("SELECT p FROM ProductMetrics p WHERE p.productId = :productId")
Optional<ProductMetrics> findByIdWithLock(`@Param`("productId") Long productId);

추가로 높은 동시성 환경에서는 UPDATE product_metrics SET like_count = like_count + 1 WHERE product_id = ? 형태의 벌크 업데이트도 고려해야 한다.

🤖 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/infrastructure/metrics/ProductMetricsJpaRepository.java`
around lines 1 - 6, The ProductMetrics updates are vulnerable to lost-updates
because there is no DB-level concurrency control; add an optimistic lock field
(annotate a Long version field with `@Version` in the ProductMetrics entity) or
add a pessimistic-locking finder in ProductMetricsJpaRepository (e.g. declare
findByIdWithLock annotated with `@Lock`(LockModeType.PESSIMISTIC_WRITE) and use
that from CatalogEventConsumer instead of the plain findById), and alternatively
consider performing an atomic bulk update (UPDATE ... SET like_count =
like_count + 1 WHERE product_id = ?) from the consumer to avoid race conditions.

Comment on lines +36 to +47
@Transactional
public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment acknowledgment) {
for (ConsumerRecord<Object, Object> record : records) {
try {
processRecord(record);
} catch (Exception e) {
log.error("[CatalogConsumer] 처리 실패: topic={}, partition={}, offset={}",
record.topic(), record.partition(), record.offset(), e);
}
}
acknowledgment.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

배치 전체를 단일 트랜잭션으로 처리하면 부분 롤백 문제가 발생한다.

@Transactionalconsume() 메서드 전체를 감싸므로, 한 레코드에서 RuntimeException(예: DB constraint violation)이 발생하면 try-catch를 벗어나 전체 트랜잭션이 롤백된다. 이 경우 이미 성공적으로 처리된 다른 레코드의 EventHandled 저장도 함께 롤백되어, 다음 폴링에서 중복 처리된다.

PR 설명의 "멱등성" 보장이 운영 환경에서 깨질 수 있다.

수정안: 레코드별 독립 트랜잭션 적용

별도 서비스 클래스에 @Transactional(propagation = REQUIRES_NEW) 메서드를 만들거나, TransactionTemplate을 사용하여 레코드별 트랜잭션 격리를 보장해야 한다.

// 별도 서비스에서 호출
`@Transactional`(propagation = Propagation.REQUIRES_NEW)
public void processRecordInNewTx(ConsumerRecord<Object, Object> record) throws Exception {
    // 처리 로직
}
🤖 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 36 - 47, The consume() method is currently annotated with
`@Transactional` causing the entire batch to rollback on a single record failure;
remove `@Transactional` from CatalogEventConsumer.consume and instead invoke a new
per-record transactional boundary (e.g., create a new service method like
processRecordInNewTx(ConsumerRecord<Object,Object>) annotated with
`@Transactional`(propagation = Propagation.REQUIRES_NEW) or execute per-record
logic via TransactionTemplate). Ensure consume() catches exceptions from
processRecord/processRecordInNewTx, logs the error (as already done), and
continues processing other records so successful EventHandled persists
independently of failed records.

Comment on lines +85 to +96
// 수량 확인
long issuedCount = issuedCouponRepository.countByCouponId(couponId);
if (issuedCount >= coupon.getTotalQuantity()) {
log.info("[CouponConsumer] 쿠폰 소진: couponId={}, issuedCount={}, totalQuantity={}",
couponId, issuedCount, coupon.getTotalQuantity());
request.markFailed();
eventHandledRepository.save(new EventHandled(eventId));
return;
}

// 발급 처리
issuedCouponRepository.save(new IssuedCoupon(couponId, userId));
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
# IssuedCoupon 테이블의 unique constraint 또는 Redis 사용 여부 확인
rg -n "unique|UNIQUE" --type java -g "*IssuedCoupon*"
rg -n "RedisTemplate|StringRedisTemplate" --type java -g "*CouponIssue*"

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

Length of output: 250


🏁 Script executed:

#!/bin/bash
# 1. CouponIssueConsumer.java의 전체 consume() 메서드 확인
echo "=== CouponIssueConsumer consume() method ==="
rg -A 50 "public void consume" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java | head -80

# 2. IssuedCoupon.java 전체 확인
echo -e "\n=== IssuedCoupon entity ==="
cat apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java

# 3. DataIntegrityViolationException 핸들링 확인
echo -e "\n=== Exception handling in commerce-streamer ==="
rg -n "DataIntegrityViolationException|UniqueConstraint" apps/commerce-streamer/src/main/java/com/loopers/

# 4. Redis 사용 여부 확인
echo -e "\n=== Redis usage in commerce-streamer ==="
rg -n "redis|Redis|INCR" apps/commerce-streamer/src/main/java/com/loopers/ --type java

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

Length of output: 3884


쿠폰 초과 발급 위험: 총 수량 확인과 저장 사이 TOCTOU 경쟁 조건 존재

countByCouponId()로 현재 발급 수를 조회(라인 85)한 후 IssuedCoupon 저장(라인 94) 사이에 다른 트랜잭션이 동일 쿠폰을 발급할 수 있다. IssuedCoupon 테이블의 Unique Constraint는 (coupon_id, user_id)로 정의되어 있어 동일 사용자의 중복 발급만 방지하며, 서로 다른 사용자의 동시 발급으로 인한 총 수량 초과는 막지 못한다. 또한 findByIdWithLock(couponId)로 획득한 Coupon 행 락은 IssuedCoupon 테이블의 INSERT를 제어하지 않는다.

운영 영향: 동시 요청이 많은 인기 쿠폰의 경우, 총 수량 한도를 초과하여 발급될 수 있으며, 이는 정산 오류 및 고객 불만으로 이어진다.

필수 수정 사항:

방안 1: IssuedCoupon 저장 시 unique constraint 위반 예외 처리 추가
try {
    issuedCouponRepository.save(new IssuedCoupon(couponId, userId));
} catch (DataIntegrityViolationException e) {
    log.warn("[CouponConsumer] 쿠폰 발급 실패 (중복 또는 초과): couponId={}, userId={}", couponId, userId, e);
    request.markFailed();
    eventHandledRepository.save(new EventHandled(eventId));
    return;
}
방안 2: Redis INCR을 이용한 원자적 수량 확인 (권장)
Long currentCount = redisTemplate.opsForValue().increment("coupon:" + couponId + ":issued");
if (currentCount > coupon.getTotalQuantity()) {
    redisTemplate.opsForValue().decrement("coupon:" + couponId + ":issued");
    log.info("[CouponConsumer] 쿠폰 소진: couponId={}, totalQuantity={}", couponId, coupon.getTotalQuantity());
    request.markFailed();
    eventHandledRepository.save(new EventHandled(eventId));
    return;
}
// DB 저장 진행
issuedCouponRepository.save(new IssuedCoupon(couponId, userId));

추가 테스트 요구: 동시 발급 시나리오(예: totalQuantity=10, 동시 요청 20개)에서 정확히 10개만 발급되는지 검증하는 동시성 테스트 추가 필요.

🤖 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 85 - 96, The code in CouponIssueConsumer uses
issuedCouponRepository.countByCouponId(...) then
issuedCouponRepository.save(...) causing a TOCTOU race that can exceed
coupon.getTotalQuantity(); fix by making the save operation resilient to
concurrent inserts: either wrap issuedCouponRepository.save(new
IssuedCoupon(couponId, userId)) in a try/catch that catches
DataIntegrityViolationException (or the JPA/sql unique/constraint exception) and
on catch log the failure and call request.markFailed() and
eventHandledRepository.save(new EventHandled(eventId)), or implement an atomic
counter (e.g., Redis INCR on key "coupon:{couponId}:issued") before DB insert
and decrement on overflow as an alternative; update the logic around
countByCouponId, findByIdWithLock, issuedCouponRepository.save,
request.markFailed, and eventHandledRepository.save accordingly and add
concurrency tests to validate totalQuantity enforcement.

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

🧹 Nitpick comments (3)
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (3)

15-15: 미사용 import 제거 필요

ProductCacheService가 import되어 있으나 코드에서 사용되지 않는다. 이전 버전에서 캐시 무효화 로직이 제거되면서 남은 것으로 보인다. 미사용 import는 빌드 시간에는 영향 없으나, 코드 가독성과 의존성 추적을 방해한다.

🧹 미사용 import 제거
-import com.loopers.infrastructure.product.ProductCacheService;
🤖 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`
at line 15, Remove the unused import of ProductCacheService from
LikeFacade.java: delete the line "import
com.loopers.infrastructure.product.ProductCacheService;" and confirm there are
no references to ProductCacheService in the LikeFacade class (e.g., constructor,
fields, or methods) before committing; run a quick build or static-import check
to ensure no remaining usages.

54-54: 반환값 무시에 대한 명시적 의도 표현 권장

productService.getProduct(productId) 호출 시 반환값을 무시하고 있다. 상품 존재 여부 검증 목적임을 알 수 있으나, 코드만 봐서는 의도가 불명확하다. 장애 상황에서 디버깅 시 혼란을 줄 수 있다.

💡 의도를 명시하는 방법
-        productService.getProduct(productId);
+        productService.getProduct(productId); // 상품 존재 검증

또는 검증 전용 메서드를 사용하는 것이 더 명확하다:

productService.validateProductExists(productId);

Also applies to: 68-68

🤖 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`
at line 54, The call productService.getProduct(productId) is currently ignoring
its return value which obscures intent; change this to an explicit existence
check or a clearer helper such as
productService.validateProductExists(productId) (or assign the result to a
variable and assert/throw if null) so readers and debuggers know the call is for
validation; update both occurrences (the call at
productService.getProduct(productId) and the similar call around line 68) to use
the validation method or an explicit null-check with a clear exception/log
message.

4-4: 미사용 import 제거 필요: ProductCacheService

line 15의 ProductCacheService import는 코드에서 사용되지 않는다. 불필요한 의존성을 제거하여 클래스 복잡도를 낮춘다.

패키지명 일관성: confgconfig 리팩토링 권장

com.loopers.confg.kafka 패키지명이 다른 모듈의 com.loopers.config 규칙과 불일치한다. kafka 모듈의 패키지명을 com.loopers.config.kafka로 통일하면 향후 유지보수 시 패키지명 오타로 인한 혼동을 방지할 수 있다.

🤖 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`
at line 4, Remove the unused import ProductCacheService from LikeFacade and any
other classes where it's not referenced to eliminate dead dependency; then
rename the misspelled package com.loopers.confg.kafka to
com.loopers.config.kafka in the kafka module and update all imports (e.g.,
references to KafkaTopics) and package declarations (including the import line
in LikeFacade referencing com.loopers.confg.kafka.KafkaTopics) to the corrected
com.loopers.config.kafka so package names are consistent across modules.
🤖 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-api/src/main/java/com/loopers/application/like/LikeFacade.java`:
- Around line 56-62: Add robust async error handling and drift detection:
implement an AsyncUncaughtExceptionHandler in AsyncConfig to log and emit
monitoring metrics for uncaught exceptions from `@Async` methods; wrap
LikeEventHandler.handleLikeAdded and handleLikeRemoved bodies in try-catch that
logs both system and user-facing messages and emits failure metrics when
productService.increaseLikes()/decreaseLikes() or other processing fails; ensure
the caller sequence that publishes outboxEventPublisher.publish(...) and
eventPublisher.publishEvent(new LikeAddedEvent(...)) remains unchanged but
consider emitting a correlated trace/id on both events for debugging; and add a
nightly drift detection job that compares Product.likesCount and
ProductMetrics.likeCount, emits alerts when they diverge, and provides a manual
reconciliation flag in the job for operators.

In
`@apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java`:
- Around line 67-93: Test only verifies eventPublisher.publishEvent and misses
verifying outbox publishing; add assertions to ensure
outboxEventPublisher.publish(...) is called once with an OutboxEvent whose
type/key/payload correspond to the OrderCreatedEvent produced by
orderFacade.createOrder, and keep verifying
eventPublisher.publishEvent(OrderCreatedEvent). Update the
publishesOrderCreatedEvent_whenOrderCreated test to verify
outboxEventPublisher.publish was invoked (use argument capture or matchers to
assert eventType, key and payload contents), and add a separate test that
simulates outboxEventPublisher.publish throwing (when createOrder is called) to
assert the expected rollback/exception propagation behavior per your policy.
Ensure you reference orderFacade.createOrder, eventPublisher.publishEvent,
outboxEventPublisher.publish, OrderCreatedEvent and OutboxEvent when locating
the code to change.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java`:
- Line 15: Remove the unused import of ProductCacheService from LikeFacade.java:
delete the line "import com.loopers.infrastructure.product.ProductCacheService;"
and confirm there are no references to ProductCacheService in the LikeFacade
class (e.g., constructor, fields, or methods) before committing; run a quick
build or static-import check to ensure no remaining usages.
- Line 54: The call productService.getProduct(productId) is currently ignoring
its return value which obscures intent; change this to an explicit existence
check or a clearer helper such as
productService.validateProductExists(productId) (or assign the result to a
variable and assert/throw if null) so readers and debuggers know the call is for
validation; update both occurrences (the call at
productService.getProduct(productId) and the similar call around line 68) to use
the validation method or an explicit null-check with a clear exception/log
message.
- Line 4: Remove the unused import ProductCacheService from LikeFacade and any
other classes where it's not referenced to eliminate dead dependency; then
rename the misspelled package com.loopers.confg.kafka to
com.loopers.config.kafka in the kafka module and update all imports (e.g.,
references to KafkaTopics) and package declarations (including the import line
in LikeFacade referencing com.loopers.confg.kafka.KafkaTopics) to the corrected
com.loopers.config.kafka so package names are consistent across modules.
🪄 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: b9dc6212-46da-4ae8-b706-f527a61be706

📥 Commits

Reviewing files that changed from the base of the PR and between 633ccce and 22a0e86.

📒 Files selected for processing (5)
  • apps/commerce-api/build.gradle.kts
  • 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/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
✅ Files skipped from review due to trivial changes (2)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/resources/application.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Comment on lines +56 to +62
outboxEventPublisher.publish(
KafkaTopics.CATALOG_EVENTS,
productId.toString(),
"LIKE_ADDED",
Map.of("productId", productId)
);
eventPublisher.publishEvent(new LikeAddedEvent(user.getId(), 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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: LikeEventHandler의 에러 처리 및 재시도 로직 확인

echo "=== LikeEventHandler 에러 처리 확인 ==="
ast-grep --pattern $'@Async
public void $METHOD($$$) {
    $$$
}'

echo ""
echo "=== `@Async` 메서드의 예외 처리 설정 확인 ==="
rg -n "AsyncUncaughtExceptionHandler|@Async" --type java -C 3

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

Length of output: 2961


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/event/LikeEventHandler.java

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

Length of output: 1559


🏁 Script executed:

rg -n "AsyncUncaughtExceptionHandler|ListeningExecutorService|TaskExecutor" --type java -A 5

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

Length of output: 67


🏁 Script executed:

rg -n "increaseLikes|decreaseLikes" --type java -A 10

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

Length of output: 6136


🏁 Script executed:

rg -n "EnableAsync|AsyncConfigurer|Executor" --type java -A 10

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

Length of output: 17569


🏁 Script executed:

head -100 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java

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

Length of output: 2527


🏁 Script executed:

rg -n "@Transactional|@Retry|@Recover" apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java -B 2 -A 2

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

Length of output: 1633


🏁 Script executed:

rg -n "reconcile|drift|monitor|metric" --type java -i -l

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

Length of output: 534


🏁 Script executed:

cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | head -50

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

Length of output: 1721


🏁 Script executed:

rg -n "likeCount|likesCount" --type java apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java -B 2 -A 2

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

Length of output: 888


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java

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

Length of output: 448


@async 이벤트 실패 시 에러 처리 및 모니터링 메커니즘 필요

Outbox 이벤트(Kafka)와 Spring 이벤트를 이중 발행하는 구조다. 두 시스템이 서로 다른 DB 필드를 업데이트하므로 중복 문제는 없으나, 운영 관점에서 다음과 같은 위험이 있다.

문제점:

  1. LikeEventHandler@Async 메서드(handleLikeAdded, handleLikeRemoved)에 에러 처리가 없다. 예외 발생 시 호출자에게 전파되지 않으며, 전역 AsyncUncaughtExceptionHandler도 설정되어 있지 않아 실패가 침묵한다.
  2. productService.increaseLikes() 또는 decreaseLikes() 실패 시 Product.likesCount가 업데이트되지 않으면서 ProductMetrics.likeCount와 정합성이 깨진다.
  3. 두 카운트 간 drift를 감지하고 알림하는 메트릭 또는 배치가 보이지 않는다.

수정 방안:

  • AsyncConfigAsyncUncaughtExceptionHandler 구현하여 async 실패를 로깅 및 모니터링한다.
  • LikeEventHandler 메서드 내에 try-catch를 추가하여 실패 사유를 명확히 기록한다(로그: 시스템 메시지, 사용자 메시지 분리).
  • 야간 배치로 Product.likesCountProductMetrics.likeCount의 drift를 감지하고, 불일치 시 알람을 발생시킨다.
  • 필요 시 수동 재조정 프로세스를 마련한다.

테스트:

  • LikeEventHandler에서 productService.increaseLikes() 실패 시나리오를 통합 테스트에 포함한다.
  • 두 카운트 간 차이 발생 시 drift 감지 로직을 검증한다.
🤖 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 56 - 62, Add robust async error handling and drift detection:
implement an AsyncUncaughtExceptionHandler in AsyncConfig to log and emit
monitoring metrics for uncaught exceptions from `@Async` methods; wrap
LikeEventHandler.handleLikeAdded and handleLikeRemoved bodies in try-catch that
logs both system and user-facing messages and emits failure metrics when
productService.increaseLikes()/decreaseLikes() or other processing fails; ensure
the caller sequence that publishes outboxEventPublisher.publish(...) and
eventPublisher.publishEvent(new LikeAddedEvent(...)) remains unchanged but
consider emitting a correlated trace/id on both events for debugging; and add a
nightly drift detection job that compares Product.likesCount and
ProductMetrics.likeCount, emits alerts when they diverge, and provides a manual
reconciliation flag in the job for operators.

Comment on lines +67 to +93
@DisplayName("주문 생성 시 OrderCreatedEvent가 발행된다.")
@Test
void publishesOrderCreatedEvent_whenOrderCreated() {
// Arrange
String loginId = "testuser";
String rawPassword = "Test1234!";
User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com");
user.restoreBalance(100000L);
Product product = new Product(0L, "테스트상품", 10000, 100, "설명", "img.jpg");
Brand brand = new Brand("테스트브랜드", "설명");
Order order = new Order(user.getId(), "ORD-20240101-TEST", 10000L);
List<OrderRequest.OrderItemRequest> items = List.of(new OrderRequest.OrderItemRequest(1L, 1));

given(userService.authenticate(loginId, rawPassword)).willReturn(user);
given(productService.getProduct(1L)).willReturn(product);
given(brandService.getBrandsByIds(any())).willReturn(List.of(brand));
given(orderService.generateOrderNumber()).willReturn("ORD-20240101-TEST");
given(orderService.createOrder(any(), any(), any())).willReturn(order);
given(orderService.createOrderItem(any(), any(), any(), any(), any(), any(), any())).willReturn(null);
given(orderService.getOrderItems(any())).willReturn(List.of());

// Act
orderFacade.createOrder(loginId, rawPassword, items);

// Assert
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
}
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

아웃박스 발행 경로 검증이 빠져 있어 운영 회귀를 놓칠 수 있다

현재 검증은 eventPublisher.publishEvent(...)만 확인하므로, outboxEventPublisher.publish(...)가 누락/오동작해도 테스트가 통과한다. 운영에서는 이 회귀가 발생하면 주문 후속 처리(스트리머 소비)가 조용히 끊겨 장애 탐지가 늦어진다.
수정안으로 아웃박스 발행 호출 자체와 핵심 인자(최소 1회 호출, 이벤트 타입/키/페이로드)를 함께 검증해야 한다.
추가 테스트로는 정상 발행 1건 검증 외에, 아웃박스 발행 예외 시 기대 동작(롤백/예외 전파 정책)을 명시적으로 검증하는 실패 케이스를 포함해야 한다.

수정 예시 diff
@@
             // Assert
+            verify(outboxEventPublisher).publish(any(), any(), any(), any());
             verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));

As per coding guidelines **/*Test*.java: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

🤖 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/order/OrderFacadeTest.java`
around lines 67 - 93, Test only verifies eventPublisher.publishEvent and misses
verifying outbox publishing; add assertions to ensure
outboxEventPublisher.publish(...) is called once with an OutboxEvent whose
type/key/payload correspond to the OrderCreatedEvent produced by
orderFacade.createOrder, and keep verifying
eventPublisher.publishEvent(OrderCreatedEvent). Update the
publishesOrderCreatedEvent_whenOrderCreated test to verify
outboxEventPublisher.publish was invoked (use argument capture or matchers to
assert eventType, key and payload contents), and add a separate test that
simulates outboxEventPublisher.publish throwing (when createOrder is called) to
assert the expected rollback/exception propagation behavior per your policy.
Ensure you reference orderFacade.createOrder, eventPublisher.publishEvent,
outboxEventPublisher.publish, OrderCreatedEvent and OutboxEvent when locating
the code to change.

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