Skip to content

[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구축#283

Open
plan11plan wants to merge 47 commits intoLoopers-dev-lab:plan11planfrom
plan11plan:main
Open

[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구축#283
plan11plan wants to merge 47 commits intoLoopers-dev-lab:plan11planfrom
plan11plan:main

Conversation

@plan11plan
Copy link
Copy Markdown

@plan11plan plan11plan commented Mar 27, 2026

Summary

  • 배경: 스프링 이벤트 도입과 카프카 적용하기
  • 목표: (1) 주요/부가 로직 경계 분리 (2) Kafka 이벤트 파이프라인 + 선별적 Outbox (3) 선착순 쿠폰 발급 동시성 제어
  • 결과: 주문-결제-메트릭 각 경계에서 이벤트 분리 기준을 수립하고, 선착순 쿠폰 발급에 카프카 적용

Context & Decision


1. 주문-결제: 이벤트 경계를 어디에 둘 것인가

문제 정의

주문 생성 과정에는 여러 도메인이 참여한다: 재고 차감, 주문 생성, 쿠폰 사용, 포인트 차감, PG 결제, 포인트 적립. 이 중 어디까지가 "하나의 트랜잭션"이고, 어디부터 이벤트로 분리해야 하는가?

판단 기준: "이 로직이 실패하면 주문 자체가 성립하지 않는가?"

주문이 성립하려면 재고가 확보되고, 돈이 빠져야 한다. 이 조건이 만족되지 않으면 주문이라는 사실 자체가 만들어지면 안 된다. 반면, PG 결제나 포인트 적립은 "주문이 존재한다"는 사실을 전제로 나중에 정합성을 맞추면 되는 로직이다.

[하나의 트랜잭션 — 주문이라는 사실을 만드는 단위]
  재고 차감 → 주문 생성 → 쿠폰 사용 → 포인트 차감

[이벤트 분리 — 주문이 존재한다는 사실 위에서 처리]
  PG 결제 요청 (AFTER_COMMIT)
  포인트 적립 (AFTER_COMMIT)
  주문 완료 이메일 (AFTER_COMMIT + @Async)

왜 재고/포인트 차감은 이벤트로 분리하지 않았는가

재고가 부족한데 주문이 생성되면, 보상 트랜잭션을 만들어야 한다. 포인트도 마찬가지다. "주문은 만들어졌는데 포인트 차감이 실패했다"면 무료 주문이 되어버린다. 이런 자원 차감은 주문과 원자적으로 묶여야 하므로 같은 트랜잭션에서 Facade가 직접 호출한다.

// OrderFacade.processOrder() — 하나의 @Transactional
private OrderModel processOrder(Long userId, OrderCriteria.Create criteria) {
    List<ProductInfo.StockDeduction> deductionInfos =
            productService.validateAndDeductStock(criteria.toStockDeductions());  // 재고 차감
    OrderModel order = orderService.createOrder(userId, ...);                     // 주문 생성
    if (criteria.ownedCouponId() != null) {
        orderService.applyDiscount(order, couponService.useAndCalculateDiscount(...));  // 쿠폰
    }
    userService.deductPoint(userId, order.getTotalPrice());                       // 포인트 차감
    return order;
}

왜 PG 결제만 이벤트로 분리했는가

PG 결제는 외부 시스템 호출이다. 주문 트랜잭션 안에서 PG를 호출하면:

  • PG 응답 지연 → DB 커넥션을 수 초 이상 점유
  • PG 장애 → 주문 트랜잭션 전체 롤백 (이미 차감한 재고/포인트까지)

결제는 "주문이 확정된 후" 시도하면 된다. 성공하면 주문 완료, 실패하면 보상 트랜잭션(재고 복원, 포인트 환불, 쿠폰 복원)으로 처리한다.

// OrderFacade.createOrderWithPayment()
OrderModel order = processOrder(userId, criteria);  // TX 커밋 후
eventPublisher.publishEvent(new OrderPaymentEvent(order.getId(), ...));  // 이벤트 발행

// PaymentEventHandler (AFTER_COMMIT) — 별도 실행
PgPaymentResult pgResult = paymentGateway.requestPayment(...);
if (pgResult.requested()) {
    orderService.completeOrder(event.orderId());  // → OrderCompletedEvent 발행
} else {
    compensateOrder(event);  // 보상 트랜잭션
}

회원가입도 같은 기준

회원가입에서도 "유저 생성"은 핵심이고, 웰컴 포인트/쿠폰/이메일은 부가 로직이다. 유저가 만들어진 뒤 혜택 지급이 실패해도 회원가입 자체가 롤백되면 안 된다.

// UserService.signup()
UserModel user = userRepository.save(UserModel.create(...));
eventPublisher.publishEvent(UserSignedUpEvent.from(user));  // 이벤트 발행

// SignupBenefitHandler (AFTER_COMMIT + REQUIRES_NEW)
userService.addPoint(event.userId(), 1000L);
couponService.issue(welcomeCoupon.getId(), event.userId());
// 실패해도 회원가입은 유지

2. 상품 메트릭: ApplicationEvent → Kafka → 별도 시스템 집계

문제 정의

좋아요 토글, 상품 조회 시 집계(좋아요 수, 조회수)를 어디서 처리할 것인가? 기존에는 Product.likeCount를 좋아요 트랜잭션 안에서 동기적으로 증감했다.

판단: 좋아요 성공과 집계 반영은 분리해야 한다

"좋아요를 눌렀는데 집계 업데이트 때문에 좋아요가 실패한다"는 상황은 부자연스럽다. 좋아요는 성공하되, 집계는 eventual consistency로 나중에 반영해도 된다.

그래서:

  1. 좋아요 서비스ProductLikeModel INSERT/DELETE만 하고, ProductLikedEvent를 발행
  2. CatalogKafkaPublisher가 AFTER_COMMIT으로 Kafka에 발행
  3. commerce-streamerCatalogEventConsumerproduct_metrics 테이블에 집계
좋아요 TX: INSERT product_likes → 이벤트 발행
                                     ↓ (AFTER_COMMIT)
                            Kafka: catalog-events
                                     ↓
                        commerce-streamer: product_metrics upsert

왜 Kafka로 보내는가 (ApplicationEvent만으로 안 되는 이유)

좋아요 집계를 같은 서버의 @TransactionalEventListener로 처리할 수도 있다. 하지만:

  • 집계 로직이 API 서버에 있으면, 트래픽이 몰릴 때 집계 때문에 API 응답이 느려진다
  • 집계는 commerce-api의 관심사가 아니다 — 상품 메트릭은 별도 시스템(commerce-streamer)이 담당하는 게 맞다
  • Kafka를 두면 Consumer가 배치로 처리할 수 있어 DB 쓰기 효율도 높아진다

멱등 처리

Consumer는 event_handled 테이블로 이미 처리한 eventId를 추적한다. Kafka의 At Least Once 특성상 같은 메시지가 재전달될 수 있기 때문이다.

// MetricsAggregationService
public void addLikeCount(String eventId, Long productId, long delta) {
    if (isAlreadyHandled(eventId)) return;  // 멱등 체크
    ProductMetricsEntity metrics = metricsRepository.findByProductId(productId)...;
    metrics.addLikeCount(delta);
    markHandled(eventId);
}

3. 선착순 쿠폰: 단일 서버에서 분산 시스템까지의 진화

문제 정의

선착순 100장 쿠폰에 10,000명이 동시 요청. 초과 발급 0건, 정확히 100장 발급이 목표.


Phase 0: 낙관적 락 + @retryable (기존 쿠폰 발급)

배경: 기존 쿠폰 발급 API는 "누구나 다운받을 수 있는 쿠폰"을 전제로 설계했다. 수량 경합이 거의 발생하지 않는 시나리오이므로, 낙관적 락(@Version)과 재시도(@Retryable)만으로 동시성을 처리했다.

구조:

요청 → SELECT coupon (with @Version) → issuedQuantity++ → OwnedCoupon INSERT
       → 충돌 시 OptimisticLockException → @Retryable 재시도

이 구조가 선착순에 맞지 않는 이유:

  • 낙관적 락은 "충돌이 드문 경우"에 적합하다. 10,000명이 동시에 같은 row를 업데이트하면, 1명만 성공하고 9,999명이 재시도한다.
  • 재시도가 폭발적으로 증가하면서 DB 부하가 기하급수적으로 늘어난다.
  • 100장 발급 완료 후에도 재시도 중인 요청이 계속 DB를 두드린다.

결론: "경합이 드문 일반 쿠폰"에는 적합하지만, "수천~수만 명이 동시에 경쟁하는 선착순 쿠폰"에는 구조적으로 맞지 않는다.


Phase 1: AtomicInteger + Atomic UPDATE (2중 문지기)

Phase 0의 문제를 해결하기 위해 낙관적 락 재시도 대신, 요청을 DB 전에 선제적으로 걸러내는 구조로 전환했다.

구조:

요청 → AtomicInteger (1차) → DB Atomic UPDATE (2차) → OwnedCoupon INSERT
  • 1차 문지기: JVM AtomicInteger로 수량 초과 요청을 DB 전에 차단
  • 2차 문지기: UPDATE coupons SET issued_quantity = issued_quantity + 1 WHERE id = ? AND issued_quantity < total_quantity
  • 중복 발급: OwnedCouponRepository.findByCouponIdAndUserId()로 SELECT 체크

Phase 0 대비 개선: 재시도 폭발이 사라졌다. AtomicInteger가 totalQuantity 초과 요청을 즉시 걸러내므로, DB에는 유효한 요청만 도달한다. 100장 쿠폰 + 1,000명 테스트에서 정확히 100장 발급됐다.

문제 발견: AtomicInteger를 통과한 요청은 여전히 DB를 3번 거친다.

  • 중복 체크 SELECT + issuedQuantity UPDATE + OwnedCoupon INSERT = 3회 DB 연산
  • 10,000 VU 부하 테스트에서 DB 커넥션 풀(100개) 경합이 발생하고, 발급 latency p99가 1초를 넘겼다.

Phase 2: Insert-Only + In-Memory Dedup (DB 연산 최소화)

Phase 1의 3회 DB 연산을 줄이기 위해 두 가지를 동시에 변경했다:

  1. issuedQuantity 컬럼 제거 → UPDATE 쿼리 제거
  2. CouponIssueCounterConcurrentHashMap<userId> 기반 in-memory 중복 체크 추가 → SELECT 쿼리 제거

구조:

요청 → CouponIssueCounter (수량 + 중복 체크) → OwnedCoupon INSERT만
       → UNIQUE(coupon_id, user_id) = 최종 방어
// CouponIssueCounter — 수량 + 중복을 JVM 메모리에서 원자적으로 체크
public AcquireResult tryAcquire(Long couponId, Long userId, IntSupplier totalQuantitySupplier) {
    CounterEntry entry = counters.computeIfAbsent(
            couponId, k -> new CounterEntry(totalQuantitySupplier.getAsInt()));
    if (!entry.issuedUsers().add(userId)) {
        return AcquireResult.ALREADY_ISSUED;  // 중복 → DB 안 감
    }
    if (entry.counter().incrementAndGet() <= entry.totalQuantity()) {
        return AcquireResult.SUCCESS;
    }
    entry.counter().decrementAndGet();
    return AcquireResult.QUANTITY_EXHAUSTED;  // 수량 초과 → DB 안 감
}

Phase 1 대비 개선:

  • DB 연산이 3회(SELECT + UPDATE + INSERT) → 1회(INSERT)로 줄었다.
  • 수량 초과, 중복 발급 모두 in-memory에서 즉시 거절 → DB에는 실제 발급 대상만 도달

단일 서버에서는 이 구조로 기능적으로 충분하다. 수량 정합성도 지키고, 중복도 걸러낸다.

그러나 운영 환경을 고려하면 한계가 있다:

  • 서버 재시작 시 ConcurrentHashMap이 초기화된다. 이미 발급받은 유저가 다시 통과할 수 있다. (DB UNIQUE 제약이 최종 방어하지만, 불필요한 DB 요청이 발생)
  • 다중 인스턴스 배포 시 서버 A와 B가 각자의 메모리를 가지므로, 같은 유저가 서버 A에서 거절되고 서버 B에서 통과할 수 있다.
  • 즉, JVM 메모리에 의존하는 상태는 단일 프로세스 수명에 묶여 있다.

Phase 3: Redis ZSET + Lua Script (상태를 외부로 분리)

Phase 2의 한계를 해결하려면, 수량/중복 체크 상태를 JVM 밖으로 꺼내야 한다. 서버가 재시작되거나 여러 대로 스케일아웃해도 상태가 공유되는 저장소가 필요하다.

Redis로 외부화한 이유:

  • 수량 체크 + 중복 체크 + 발급 기록을 하나의 원자적 연산으로 묶을 수 있다 (Lua Script)
  • 서버 재시작/다중 인스턴스에서도 상태가 유지된다
  • DB보다 훨씬 빠르다 (인메모리, 단순 자료구조)
-- coupon-issue.lua (단일 Lua = 원자적 실행)
local totalQuantity = tonumber(redis.call('GET', quantityKey))
if not totalQuantity then return 'NOT_FOUND' end
if redis.call('ZSCORE', issuedKey, userId) then return 'ALREADY_ISSUED' end
if redis.call('ZCARD', issuedKey) >= totalQuantity then return 'QUANTITY_EXHAUSTED' end
redis.call('ZADD', issuedKey, timestamp, userId)
return 'SUCCESS'

ZSET을 선택한 이유:

  • ZCARD = 발급 수 (수량 체크)
  • ZSCORE = 특정 유저 존재 여부 (중복 체크)
  • ZADD = 발급 기록 (score에 timestamp → 발급 순서 추적 가능)
  • 하나의 자료구조로 3가지 역할을 수행

결과: 10,000 VU 중 Redis에서 99%+가 걸러지고, DB에는 실제 발급 대상(100건)만 도달.


Phase 4: Kafka + Transactional Outbox (비동기 DB INSERT)

Phase 3에서 남은 문제: Redis를 통과한 100건이 동기적으로 DB INSERT를 수행한다. 이 자체는 빠르지만, API 스레드가 DB INSERT 완료까지 블로킹된다. 사용자 입장에서 "발급 완료"를 기다려야 한다.

비동기로 전환한 이유:

  • Redis에서 이미 수량/중복 체크가 끝났다 → 사용자에게 "발급 요청 접수(PENDING)"를 즉시 응답하고, 실제 DB INSERT는 뒤에서 처리해도 된다.
  • API 응답 속도가 Redis 응답 속도(~1ms)로 수렴한다.

왜 직접 Kafka 발행이 아니라 Outbox인가:

  • Redis SUCCESS → Kafka 발행 순서에서, Kafka 발행이 실패하면 Redis에는 "발급됨"이지만 실제 DB에는 저장되지 않는 불일치가 생긴다.
  • Outbox 테이블에 이벤트를 DB 트랜잭션으로 저장하면, 앱이 죽어도 Poller가 재시도한다.
  • Redis 상태와 Outbox 저장이 둘 다 성공해야 사용자에게 PENDING을 반환 → 실패 시 Redis rollback
// CouponFacade.issueCoupon()
@Transactional
public CouponResult.IssuedDetail issueCoupon(Long couponId, Long userId) {
    CouponIssueResult result = couponIssueLimiter.tryIssue(couponId, userId);  // Redis
    // ... 실패 시 즉시 거절 ...
    try {
        outboxEventPublisher.publish("COUPON_ISSUED", ...);  // Outbox INSERT (같은 TX)
        return CouponResult.IssuedDetail.pending(couponId, userId);
    } catch (Exception e) {
        couponIssueLimiter.rollback(couponId, userId);  // Redis 롤백
        throw e;
    }
}

최종 흐름:

Client → Redis Lua (수량+중복) → Outbox INSERT (TX) → 200 PENDING
                                       ↓ (3초 폴링)
                                 Kafka: coupon-issued
                                       ↓
                              CouponIssueConsumer: DB INSERT
                              (UNIQUE 제약 = 최종 중복 방어)

4. Kafka 발행 전략: 선별적 Outbox

판단 기준: "이 이벤트가 유실되면 비즈니스에 어떤 영향이 있는가?"

Outbox는 안전하지만 비용이 있다 — 비즈니스 TX마다 outbox 테이블에 추가 INSERT가 발생한다. 모든 이벤트에 Outbox를 적용하면 DB 쓰기 부하가 불필요하게 증가한다.

이벤트 유실 시 영향 보정 수단 발행 방식
COUPON_ISSUED 유저가 발급받았다 믿지만 DB에 없음 없음 (돈 문제) Outbox
PRODUCT_LIKED 좋아요 수 불일치 분할 재집계 스케줄러 AFTER_COMMIT 직접
PRODUCT_VIEWED 조회수 누락 로그 기반 재집계 AFTER_COMMIT 직접
ORDER_COMPLETED 판매량 누락 배치 재집계 AFTER_COMMIT 직접

통계성 이벤트는 @TransactionalEventListener(AFTER_COMMIT) + try-catch로 best-effort 발행한다. 유실되더라도 배치 재집계로 보정 가능하다.

Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api (order, coupon, product, user, outbox), commerce-streamer
  • 신규 추가:
    • OutboxEventPublisher — JSON 직렬화 + outbox INSERT 캡슐화
    • OutboxKafkaConfig — StringSerializer 기반 전용 KafkaTemplate
    • CouponRedisIssueLimiter — Redis ZSET + Lua Script 래퍼
    • CouponIssueConsumer — Kafka 컨슈머, DB INSERT + 멱등 처리
    • CouponIssueCounter — In-Memory 수량/중복 필터 (Phase 1~2)
    • coupon-issue.lua — 원자적 수량+중복+발급 Lua 스크립트
    • PaymentEventHandler — PG 결제 요청 + 보상 트랜잭션
    • SignupBenefitHandler / SignupNotificationHandler — 회원가입 부가 로직
    • CatalogEventConsumer / OrderEventConsumer — 메트릭 집계 컨슈머 (commerce-streamer)
    • MetricsAggregationService — eventId 기반 멱등 집계
  • 제거/대체:
    • Product.likeCount → Kafka Consumer 기반 product_metrics 집계로 전환
    • CatalogKafkaPublisher / OrderKafkaPublisher — Outbox → AFTER_COMMIT 직접 발행

주요 컴포넌트 책임

  • OrderFacade: 자원 차감(재고/쿠폰/포인트)을 동일 트랜잭션에서 처리, PG 결제는 이벤트 위임
  • CouponFacade: Redis 문지기 호출 → Outbox INSERT 오케스트레이션
  • CouponRedisIssueLimiter: Lua 스크립트 실행, 수량/중복 원자적 체크
  • OutboxEventPublisher: 이벤트 JSON 직렬화 + outbox 테이블 저장
  • OutboxKafkaRelay: 3초 폴링 → 미발행 이벤트 Kafka 발행
  • CouponIssueConsumer: Kafka 메시지 소비 → DB INSERT + UNIQUE 위반 시 skip
  • MetricsAggregationService: eventId 중복 체크 + product_metrics upsert

Flow Diagram

주문-결제 흐름

sequenceDiagram
    autonumber
    participant C as Client
    participant F as OrderFacade
    participant PS as ProductService
    participant OS as OrderService
    participant US as UserService
    participant PG as PaymentEventHandler
    participant EXT as PG Gateway

    C->>F: 주문+결제 요청
    Note over F: @Transactional 시작
    F->>PS: 재고 차감 (atomic UPDATE)
    F->>OS: 주문 생성 (PENDING_PAYMENT)
    F->>US: 포인트 차감 (atomic UPDATE)
    Note over F: @Transactional 커밋
    F-->>C: 200 주문 생성 완료

    Note over PG: AFTER_COMMIT 이벤트
    PG->>EXT: PG 결제 요청
    alt 결제 성공
        PG->>OS: completeOrder() → OrderCompletedEvent 발행
    else 결제 실패
        PG->>PS: 재고 복원
        PG->>US: 포인트 환불
    end
Loading

선착순 쿠폰 발급 흐름

sequenceDiagram
    autonumber
    participant C as Client
    participant F as CouponFacade
    participant R as Redis (Lua Script)
    participant DB as MySQL (Outbox)
    participant P as OutboxKafkaRelay
    participant K as Kafka
    participant CS as CouponIssueConsumer
    participant DB2 as MySQL (OwnedCoupon)

    C->>F: POST /coupons/{id}/issue
    F->>R: tryIssue(couponId, userId)

    alt ALREADY_ISSUED / QUANTITY_EXHAUSTED
        R-->>F: 거절
        F-->>C: 400/409 즉시 거절
    else SUCCESS
        R-->>F: SUCCESS (ZADD 완료)
        F->>DB: outbox_event INSERT (@Transactional)
        F-->>C: 200 PENDING
    end

    Note over P: 3초 간격 폴링
    P->>DB: SELECT unpublished (TOP 100)
    P->>K: send(coupon-issued, payload)
    P->>DB: UPDATE published = true

    K->>CS: consume(CouponIssuedMessage)
    CS->>DB2: INSERT owned_coupon
    Note over CS: UNIQUE(coupon_id, user_id) 위반 시 skip + ack
    CS->>K: ack
Loading

Checklist

Step 1 — ApplicationEvent 경계 분리

  • 주문-결제 플로우: 자원 차감(재고/쿠폰/포인트)은 동일 TX, PG 결제는 AFTER_COMMIT 이벤트
  • 좋아요 처리와 집계 분리 (집계 실패와 무관하게 좋아요 성공)
  • 상품 조회수 INSERT-only 로그 → AFTER_COMMIT 이벤트로 기록
  • 회원가입 부가 로직 분리 (웰컴 포인트/쿠폰: REQUIRES_NEW, 이메일: @async)
  • 결제 실패 시 보상 트랜잭션 (재고 복원, 포인트 환불, 쿠폰 복원)

Step 2 — Kafka 이벤트 파이프라인

  • 좋아요/조회수/주문완료 이벤트를 Kafka로 발행 (AFTER_COMMIT 직접)
  • commerce-streamer Consumer가 product_metrics 집계 (배치 처리)
  • eventId 기반 멱등 처리 (event_handled 테이블)
  • acks=all, idempotence=true, manual Ack 설정
  • PartitionKey 기반 이벤트 순서 보장

Step 3 — 선착순 쿠폰 발급

  • Phase 0: 낙관적 락 + @retryable — 기존 쿠폰 발급 (선착순 부적합 확인)
  • Phase 1: AtomicInteger + Atomic UPDATE — 2중 문지기로 재시도 폭발 제거
  • Phase 2: Insert-Only + In-Memory Dedup — DB 연산 3회→1회 축소
  • Phase 3: Redis ZSET + Lua Script — 상태를 JVM 밖으로 분리
  • Phase 4: Kafka + Outbox — 비동기 처리, 메시지 유실 방지
  • 선별적 Outbox — 쿠폰만 Outbox, 통계성 이벤트는 직접 발행
  • k6 부하 테스트 — 10,000 VU 정합성 검증
  • 4단계 진화별 브랜치 관리 (phase1 ~ phase4)

plan11plan and others added 30 commits March 22, 2026 18:00
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PG 결제 호출만 @TransactionalEventListener(AFTER_COMMIT)으로 TX 밖 분리
- Facade의 processOrder() 흐름 유지 (재고/쿠폰/포인트 직접 호출)
- 보상 트랜잭션 로직 PaymentEventHandler에 구현

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
회원가입 성공 시 하나의 트랜잭션 내에서 부가 작업 수행:
- 웰컴 포인트 1000원 지급
- SignupCouponPolicy enum 기반 웰컴 쿠폰 생성 및 발급
- 가입 축하 이메일 발송 (FakeEmailSender)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
상품 상세 조회 시 이벤트 기반으로 조회 로그를 기록한다:
- ProductViewLogModel: product_views INSERT-only 엔티티 (Source of Truth)
- ProductViewedEvent: 조회 이벤트 record
- ProductViewCountHandler: AFTER_COMMIT + REQUIRES_NEW, try-catch로 실패 격리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
비회원도 접근 가능한 API에서 인증 헤더가 있으면 best-effort로 인증:
- @OptionalLogin 어노테이션: 인증 실패 시 null 허용 (vs @Login은 예외)
- AuthFilter.tryOptionalAuth(): 비인증 URL에서도 헤더 있으면 인증 시도
- ProductFacade.getProductDetail(id, userId): 조회 성공 후 이벤트 발행
- 회원 조회 시 userId 포함, 비회원 시 null

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
도메인 인터페이스만 있고 JPA 구현체가 없어서 빈 주입 실패하던 문제 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
plan11plan and others added 16 commits March 26, 2026 18:00
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

쿠폰 발급을 Redis 기반 제한자와 아웃박스/Kafka 비동기 파이프라인으로 전환하고, 상품 좋아요 카운트를 메트릭 읽기 모델로 분리하며, 주문-결제 흐름을 이벤트 기반으로 재구성했다. 다양한 도메인 이벤트와 핸들러, 메트릭 집계 서비스, 모니터링 인프라가 추가되었다.

Changes

Cohort / File(s) Summary
쿠폰 발급 / 제한자 / 소비자
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueLimiter.java, apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueResult.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRedisIssueLimiter.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueConsumer.java, apps/commerce-api/src/main/resources/scripts/coupon-issue.lua
발급 상태/동시성 책임을 엔티티에서 Redis 기반 제한자로 이동. CouponModel의 issuedQuantity/버전 삭제. 발급 시 Redis 검사 → 아웃박스 이벤트 발행 → Kafka 소비자에서 DB 발급 수행으로 흐름 변경.
아웃박스 / Kafka 릴레이 / 토픽 구성
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/..., modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java, modules/kafka/.../KafkaConfig.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaRelay.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
Outbox 엔티티·퍼블리셔 추가, 전용 KafkaTemplate 빈 등록, 토픽 빈 추가 및 3초 주기 릴레이로 미발행 이벤트를 Kafka로 전송하도록 구현.
상품 좋아요·메트릭 분리
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadEntity.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/...
ProductModel에서 like_count 제거, 좋아요 집계를 별도 product_metrics 읽기 모델로 이동. 리포지토리/서비스에 배치 조회·증감 대신 읽기(및 stock 증감) API 추가. 페이징/정렬 쿼리 조정.
주문·결제 이벤트화
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java, apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java, apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java, apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java, apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.java
createOrderWithPayment 추가로 주문 처리를 먼저 수행하고 결제 이벤트를 발행. PaymentEventHandler가 비동기적으로 PG 호출·결과에 따른 주문 완결/보상(재고·쿠폰·포인트 복구)을 수행하도록 분리.
도메인 이벤트·핸들러·알림
apps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.java, apps/commerce-api/src/main/java/com/loopers/application/user/SignupBenefitHandler.java, apps/commerce-api/src/main/java/com/loopers/application/user/SignupNotificationHandler.java, apps/commerce-api/src/main/java/com/loopers/application/order/OrderPointHandler.java, apps/commerce-api/src/main/java/com/loopers/application/notification/OrderNotificationHandler.java, apps/commerce-api/src/main/java/com/loopers/domain/notification/NotificationSender.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/notification/*
회원가입/주문완료/상품조회 등 이벤트 정의 및 AFTER_COMMIT 트리거 핸들러 추가. NotificationSender 추상화와 SMTP/Fake 구현 제공. 이벤트 실패는 기본적으로 로깅으로 흘려보냄.
인증 선택적 처리 및 컨트롤러 변경
apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java, apps/commerce-api/src/main/java/com/loopers/interfaces/auth/OptionalLogin.java, apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java, apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java, apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java
선택적 로그인(@OptionalLogin) 추가. 비인증 경로에서 헤더 기반 시도 인증 도입. Product 컨트롤러에서 optional login을 받아 조회 시 이벤트에 userId 전달.
메트릭 집계 스트리머
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java
Kafka를 소비해 product_metrics를 집계하는 서비스 추가. 이벤트 멱등성을 위한 handled 이벤트 테이블과 체크 로직 포함.
비동기 / 스케줄 /메일 설정 /모니터링
apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java, apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java, apps/commerce-api/src/main/resources/application.yml, docker/infra-compose.yml, docker/grafana/...
비동기 실행기와 스케줄러 활성화. SMTP 설정 추가. Tomcat/threadpool, JPA pool, Kafka 프로듀서 설정 조정 및 Prometheus/InfluxDB/Grafana 스택과 k6 대시보드 추가.
테스트 조정 및 동시성 테스트 추가
apps/commerce-api/src/test/..., apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueConcurrencyTest.java, ...
쿠폰 발급 흐름 변경에 따른 단위/통합 테스트 보정 및 동시성 E2E 테스트(1,000 threads → 총발급 정확성) 추가. 테스트 인프라에 Redis/DB 정리 로직 통합.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Facade as CouponFacade
    participant Limiter as CouponIssueLimiter
    participant Outbox as OutboxEventPublisher
    participant Relay as OutboxKafkaRelay
    participant Kafka as Kafka Broker
    participant Consumer as CouponIssueConsumer
    participant Service as CouponService

    Client->>Facade: issueCoupon(couponId,userId)
    Facade->>Limiter: tryIssue(couponId,userId)
    alt Redis: SUCCESS
        Limiter-->>Facade: SUCCESS
        Facade->>Outbox: publish(COUPON_ISSUED, payload)
        Outbox-->>Facade: saved
        Facade-->>Client: IssuedDetail(PENDING)
    else Redis: NOT_FOUND/ALREADY_ISSUED/QUANTITY_EXHAUSTED
        Limiter-->>Facade: ERROR
        Facade-->>Client: throw CoreException
    end

    Note over Relay,Kafka: (주기적) Outbox → Kafka 전송
    Relay->>Kafka: send(topic, partitionKey, payload)
    Kafka-->>Consumer: deliver message
    Consumer->>Service: issue(couponId,userId)
    Service->>Service: OwnedCouponModel.create/save
Loading
sequenceDiagram
    participant Client as Client
    participant OrderFacade as OrderFacade
    participant OrderService as OrderService
    participant EventPub as ApplicationEventPublisher/Outbox
    participant PaymentHandler as PaymentEventHandler
    participant PG as PaymentGateway
    participant Kafka as Kafka

    Client->>OrderFacade: createOrderWithPayment(userId, criteria)
    OrderFacade->>OrderService: processOrder(...)
    OrderService-->>OrderFacade: OrderModel
    OrderFacade->>EventPub: publish OrderPaymentEvent (outbox)
    Note over Kafka: PaymentEventHandler consumes after commit
    PaymentHandler->>PG: requestPayment(...)
    alt PG 승인
        PG-->>PaymentHandler: requested
        PaymentHandler->>OrderService: completeOrder(orderId)
        OrderService->>EventPub: publish OrderCompletedEvent (outbox)
    else PG 실패
        PG-->>PaymentHandler: failed
        PaymentHandler->>OrderService: cancelByPaymentFailure(orderId)
        PaymentHandler->>ProductService: increaseStock(...)
        PaymentHandler->>CouponService: restoreByOrderId(...)
        PaymentHandler->>UserService: addPoint(...)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Note

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

Caution

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

⚠️ Outside diff range comments (3)
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java (1)

86-92: ⚠️ Potential issue | 🟠 Major

혼합된 트랜잭션 phase로 인한 이벤트 일관성 문제

completeOrder 메서드에서 이벤트를 발행하는 패턴이 핸들러마다 서로 다른 트랜잭션 전략을 사용하고 있다:

  • OrderKafkaPublisher: phase = BEFORE_COMMIT - 주문 완료 트랜잭션과 같은 트랜잭션에서 실행되므로, 아웃박스 저장 실패 시 주문 완료 전체가 롤백된다.
  • OrderPointHandler: phase = AFTER_COMMIT + propagation = REQUIRES_NEW - 주문 완료 후 별도 트랜잭션에서 실행되므로, 포인트 적립 실패 시 주문은 이미 커밋된 상태다.
  • OrderNotificationHandler: phase = AFTER_COMMIT + @Async - 비동기로 실행되므로 주문 완료와 독립적이다.

이는 Kafka 메시지 발행(아웃박스)이 실패하면 주문 완료가 불가능하지만, 포인트 적립이나 알림 발송이 실패해도 주문은 완료되는 비대칭적 실패 모드를 만든다. 결과적으로 주문 상태와 외부 이벤트 처리 결과 간의 정합성이 보장되지 않는다.

개선 방안: 주문 완료와 후속 이벤트 처리의 일관성 전략을 명확히 한다.

  • 모든 핸들러를 AFTER_COMMIT으로 통일하거나
  • 아웃박스 패턴의 의도를 명확히 하되, 포인트 적립도 같은 트랜잭션(BEFORE_COMMIT) 또는 동기 처리로 변경하거나
  • 각 핸들러의 실패 시 재시도/보상 전략을 명시하는 문서를 추가한다.

운영 중 주문은 완료되었으나 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/domain/order/OrderService.java`
around lines 86 - 92, completeOrder publishes OrderCompletedEvent inside a
`@Transactional` method but the event handlers use mixed transaction phases
(OrderKafkaPublisher: BEFORE_COMMIT, OrderPointHandler: AFTER_COMMIT +
REQUIRES_NEW, OrderNotificationHandler: AFTER_COMMIT + `@Async`) causing
inconsistent failure modes; pick a consistency strategy and apply it across
handlers: either make all handlers AFTER_COMMIT (so events are only processed
after the order transaction commits) or move side-effects that must be atomic
with the order into the same transaction/outbox (change OrderKafkaPublisher to
persist outbox in the transaction and defer actual send to AFTER_COMMIT), or
convert OrderPointHandler to BEFORE_COMMIT or synchronous processing with
explicit retry/compensation; update the annotations on OrderKafkaPublisher,
OrderPointHandler, and OrderNotificationHandler accordingly and add
retry/compensation documentation and monitoring hooks for unprocessed events.
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)

30-55: ⚠️ Potential issue | 🟠 Major

paymentGateway.requestPayment() 호출 시 예외 처리 필수

createPayment 메서드의 라인 36-43에서 paymentGateway.requestPayment() 호출이 예외를 던질 경우, 현재 코드는 어떤 처리도 하지 않는다. 이 경우 결제 레코드가 PENDING 상태로 남아있어 운영상 심각한 문제를 초래한다.

타임아웃 설정은 application.yml에 이미 구성되어 있다(pg-payment: 1000ms 연결 + 1450ms 읽기, pg-payment2: 1000ms 연결 + 5000ms 읽기). 다만, 외부 서비스 호출이 타임아웃되거나 다른 예외를 발생시킬 때는 결제 실패 상태로 처리해야 한다.

현재 코드는 정상 응답(pgResult.requested() == false)에 대해서만 failById() 처리를 하고 있다. 예외 발생 시나리오가 누락되어 있으므로, handleCallback() 메서드(라인 76-87)의 패턴처럼 try-catch로 감싸거나 메서드에 @Transactional을 추가하여 예외 발생 시 자동으로 결제 실패 처리되도록 개선해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`
around lines 30 - 55, The createPayment method does not handle exceptions from
paymentGateway.requestPayment, leaving the PaymentModel in PENDING if the call
throws; wrap the paymentGateway.requestPayment(...) call in a try-catch (or
annotate createPayment with `@Transactional` and handle exceptions similarly) and
on any exception call paymentService.failById(payment.getId()) before rethrowing
a CoreException (e.g., PaymentErrorCode.PG_SERVICE_UNAVAILABLE with the cause)
so failures mirror the existing handleCallback(...) failure handling; reference
createPayment, paymentGateway.requestPayment, paymentService.failById, and
handleCallback to apply the same pattern.
apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/CouponV1ApiE2ETest.java (1)

161-182: ⚠️ Potential issue | 🟡 Minor

myOwnedCoupons_returnsList 테스트의 기대값을 수정하다.

Arrange 단계에서 쿠폰 1개만 생성하지만, 라인 178에서 hasSize(2)를 기대한다. 테스트는 매 실행마다 @BeforeEach와 @AfterEach로 데이터를 정리하므로 다른 데이터 소스가 없다. 기대값이 생성된 데이터와 불일치하므로 수정이 필요하다.

기대값 수정
             // assert
             assertAll(
                     () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
-                    () -> assertThat(response.getBody().data().items()).hasSize(2),
+                    () -> assertThat(response.getBody().data().items()).hasSize(1),
                     () -> assertThat(response.getBody().data().items())
                             .anyMatch(item -> item.couponName().equals("테스트 쿠폰")
                                     && item.status().equals("AVAILABLE")));
🤖 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/interfaces/coupon/CouponV1ApiE2ETest.java`
around lines 161 - 182, The test myOwnedCoupons_returnsList in
CouponV1ApiE2ETest expects two items but only creates one coupon/owned coupon in
the arrange phase; update the assertion in myOwnedCoupons_returnsList to expect
one item (e.g., change the hasSize(2) expectation to hasSize(1)) so the
assertion matches the single OwnedCouponModel created via
OwnedCouponModel.create(saveCoupon(...), userId).
🟡 Minor comments (18)
docker/monitoring-compose.yml-27-29 (1)

27-29: ⚠️ Potential issue | 🟡 Minor

Grafana 관리자 자격 증명 하드코딩.

infra-compose.yml과 동일한 보안 이슈가 존재한다. 환경 변수로 분리하거나, 두 파일 간 공통 설정을 통합하여 일관성을 유지해야 한다.

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

In `@docker/monitoring-compose.yml` around lines 27 - 29, 현재
docker/monitoring-compose.yml에 GF_SECURITY_ADMIN_USER 및
GF_SECURITY_ADMIN_PASSWORD가 하드코딩되어 있어 보안 문제가 있으니 이 값을 직접 파일에 두지 말고 환경변수로 분리하거나
공통 설정으로 통합하세요; replace the literals with compose variable references like
${GRAFANA_ADMIN_USER} and ${GRAFANA_ADMIN_PASSWORD} (or load from a shared .env
used by both monitoring-compose.yml and infra-compose.yml) and ensure those env
names are defined consistently where infra-compose.yml currently sources
credentials so GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD usage remains
identical across files.
docker/infra-compose.yml-131-143 (1)

131-143: ⚠️ Potential issue | 🟡 Minor

Grafana 이미지 버전 미지정 및 관리자 자격 증명 하드코딩 문제.

두 가지 운영/보안 이슈가 있다:

  1. 이미지 버전 미지정: grafana/grafana 이미지에 버전 태그가 없어 빌드 시점마다 다른 버전이 적용될 수 있다.

  2. 자격 증명 하드코딩: GF_SECURITY_ADMIN_USER=admin, GF_SECURITY_ADMIN_PASSWORD=admin이 평문으로 노출되어 있다. 이 compose 파일이 버전 관리에 포함되면 자격 증명이 유출될 수 있다. 로컬 환경이라도 .env 파일 또는 Docker secrets를 통해 분리하는 것이 운영 모범 사례다.

🛡️ 수정안
  grafana:
-   image: grafana/grafana
+   image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
      - ./grafana/dashboards:/var/lib/grafana/dashboards
    environment:
-     - GF_SECURITY_ADMIN_USER=admin
-     - GF_SECURITY_ADMIN_PASSWORD=admin
+     - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
+     - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
    depends_on:
      - prometheus
      - influxdb

추가 테스트: .env.example 파일을 생성하여 환경 변수 설정 방법을 문서화해야 한다.

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

In `@docker/infra-compose.yml` around lines 131 - 143, The grafana service uses an
unpinned image and hardcoded admin credentials: change the image reference in
the grafana service from "grafana/grafana" to a pinned tag (e.g.,
grafana/grafana:<version>) and remove GF_SECURITY_ADMIN_USER and
GF_SECURITY_ADMIN_PASSWORD plaintext entries; instead read them from environment
variables or Docker secrets (use the GF_SECURITY_ADMIN_USER and
GF_SECURITY_ADMIN_PASSWORD variable names already present) by referencing values
from an .env file or Docker secrets so credentials are not checked into git, and
add a .env.example documenting the required variables.
docker/grafana/dashboards/k6-load-testing.json-2-17 (1)

2-17: ⚠️ Potential issue | 🟡 Minor

__inputs와 패널의 데이터소스 참조값이 불일치하므로 운영 환경에서 대시보드의 이식성을 해친다.

__inputs에서 DS_K6 변수를 정의하고 있으나, 패널들은 전체적으로 "InfluxDB-k6"을 직접 하드코딩하고 있다. 현재 환경의 datasource.yml에 동일한 이름으로 정의되어 있어 기능적으로는 정상 동작하지만, 운영 관점에서 다음의 문제가 발생한다.

  1. 대시보드 가져오기 시 혼동: 누군가 이 대시보드를 다른 Grafana 인스턴스에서 가져올 때 __inputs에 의해 데이터소스 선택 프롬프트가 나타나지만, 실제로는 무시되고 "InfluxDB-k6" 검색으로 진행된다. 해당 이름의 데이터소스가 없으면 대시보드는 데이터를 표시하지 못한다.

  2. 이식성 부족: 개발/스테이징 환경에서 데이터소스 이름이 다를 경우(예: InfluxDB 등), 모든 패널을 수동으로 수정해야 한다.

수정안: __inputs의 변수를 실제로 활용하도록 모든 패널의 datasource 값을 "${DS_K6}" 형태로 변경하거나, __inputs를 제거하고 문서화된 데이터소스 이름으로 명시한다.

__requires의 Grafana 4.4.1은 커뮤니티 원본(gnetId: 2587, 2017년)의 메타데이터이며, 현재 최신 Grafana에서도 호환성 문제가 없다.

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

In `@docker/grafana/dashboards/k6-load-testing.json` around lines 2 - 17, The
dashboard defines a datasource variable "__inputs" with name "DS_K6" but panels
hard-code "InfluxDB-k6", breaking portability; update every panel's "datasource"
field that currently uses "InfluxDB-k6" to use the input variable syntax
"${DS_K6}" so panels honor the "__inputs" selection (alternatively remove
"__inputs" if you intend a fixed name), and leave "__requires" as-is.
apps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.java-5-9 (1)

5-9: ⚠️ Potential issue | 🟡 Minor

이메일 PII 로깅 방지 확인 필요

email 필드는 개인식별정보(PII)이다. 이벤트가 로깅 프레임워크에 의해 자동 직렬화될 경우 GDPR/개인정보보호법 위반 가능성이 있다.

수정안:

  1. 로깅 시 이벤트 객체 전체 출력 금지 (toString() 오버라이드로 마스킹)
  2. 또는 이메일 대신 hasEmail boolean 플래그만 포함
♻️ 마스킹된 toString 오버라이드 예시
public record UserSignedUpEvent(Long userId, String name, String email) {

    public static UserSignedUpEvent from(UserModel model) {
        return new UserSignedUpEvent(model.getId(), model.getName(), model.getEmail());
    }

    `@Override`
    public String toString() {
        return "UserSignedUpEvent[userId=" + userId + ", name=" + name + ", email=***]";
    }
}

추가 테스트:

  • 로그 출력 테스트에서 이메일 평문 노출 여부 확인

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/domain/user/event/UserSignedUpEvent.java`
around lines 5 - 9, The UserSignedUpEvent record exposes a plaintext email
(email) which can be serialized into logs; update UserSignedUpEvent to avoid
logging raw PII by either (A) overriding toString() on UserSignedUpEvent to
return a masked representation (include userId and name but replace email with a
fixed token like "***" or "[REDACTED]") or (B) change the event payload to drop
the email field and add a boolean hasEmail flag (keep the static from(UserModel)
factory updated to set hasEmail = model.getEmail() != null/empty); ensure the
from(UserModel) method and any consumers use the new representation and
add/adjust a unit test to assert that toString() (or serialized output) contains
no plaintext email.
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.java-5-11 (1)

5-11: ⚠️ Potential issue | 🟡 Minor

이벤트 생성 단계에서 cardNo 마스킹 및 보상 트랜잭션 실패 시 재시도 메커니즘 추가 필요

  1. 이벤트 내 민감정보 노출 위험: OrderPaymentEvent가 생성될 때 cardNo가 마스킹되지 않은 상태로 전달된다. 이벤트 자체가 로깅되거나 직렬화될 때 원문 카드번호가 노출될 수 있다. 현재 PaymentEventHandler에서 이후에 마스킹하는 것은 이벤트 전송 단계에서는 방어 효과가 없다. OrderFacade에서 이벤트 생성 시점에 사전에 cardNo를 마스킹하여 전달하도록 수정해야 한다.

  2. 보상 트랜잭션 실패 시 복구 전략 부족: PaymentEventHandlercompensateOrder() 메서드는 구현되어 있으나, 보상 트랜잭션 자체가 실패할 경우 재시도 메커니즘이 없다. 이는 결제 생성 실패 시 주문 취소 로직마저 실패하면 데이터 불일치가 발생하여 운영상 수동 개입이 필요해진다. 보상 작업 실패에 대한 재시도 정책(예: @Retryable) 또는 별도의 보상 큐 처리 메커니즘을 도입하여 안정성을 확보해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.java`
around lines 5 - 11, Ensure card numbers are masked when the event is created by
changing the producer path to mask cardNo before constructing OrderPaymentEvent
(update the code that calls new OrderPaymentEvent(...) in OrderFacade to replace
cardNo with a masked value, e.g., keep last 4 digits only), and add a
retry/compensation strategy for failed compensations by enhancing
PaymentEventHandler.compensateOrder() to perform retry logic (use Spring
`@Retryable` or enqueue failed compensation attempts to a dedicated compensation
queue with backoff and limited retries) so compensations are retried on
transient failures.
apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java-26-30 (1)

26-30: ⚠️ Potential issue | 🟡 Minor

payment.getCardType() 반환값에 대한 null 안전성 보장이 필요하다.

payment.getCardType().name() 호출에서 NPE가 발생할 수 있다. PaymentModel의 card_type 컬럼이 nullable = false 제약을 가지더라도, 데이터베이스 제약은 런타임에서 Java null 참조를 방지하지 않는다. Enum 필드를 안전하게 접근하려면 Optional을 사용하거나 명시적 null 검사를 추가해야 한다.

추가로 pending() 메서드에서 4개의 null 값을 명시적으로 전달하는 것도 개선이 필요하다. Optional 필드를 사용하거나 별도의 불완전한 상태 DTO를 정의하여 가독성과 안전성을 높여야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java`
around lines 26 - 30, The call payment.getCardType().name() is not null-safe and
can throw NPE; update the code that constructs OrderResult (where
payment.getId(), payment.getStatus().name(), payment.getCardType().name(),
payment.getMaskedCardNo(), order.getCreatedAt() are passed) to null-check or
wrap card type access with Optional (e.g.,
Optional.ofNullable(payment.getCardType()).map(Enum::name).orElse(null) or a
default like "UNKNOWN") before calling name(); also revise the pending() factory
(which currently passes four explicit nulls) to either use Optional-typed fields
in OrderResult or create a separate partial/incomplete DTO for the pending state
so you don’t pass raw nulls and improve readability and safety.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueLimiter.java-7-7 (1)

7-7: ⚠️ Potential issue | 🟡 Minor

rollback 실패 시 원본 예외를 보존해야 한다.

CouponFacade의 catch 블록에서 rollback이 예외를 던지면 원본 예외가 손실된다. 현재 코드는 outboxEventPublisher 실패로 인한 예외를 rollback 실패 예외로 덮어쓰게 되어, 실제 문제 원인을 파악하기 어려워진다. rollback 시 발생한 예외는 addSuppressed()로 보존하거나, rollback을 별도 try-catch로 감싸서 처리해야 한다.

try {
    outboxEventPublisher.publish(...);
} catch (Exception e) {
    try {
        couponIssueLimiter.rollback(couponId, userId);
    } catch (Exception rollbackError) {
        e.addSuppressed(rollbackError);
    }
    throw e;
}
🤖 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/CouponIssueLimiter.java`
at line 7, The catch in CouponFacade that calls CouponIssueLimiter.rollback
currently lets rollback exceptions overwrite the original
outboxEventPublisher.publish exception; wrap the call to rollback(couponId,
userId) in its own try-catch and on rollback failure call
e.addSuppressed(rollbackError) (where e is the original caught Exception) before
rethrowing e, or otherwise ensure the original exception is rethrown with the
rollback error added via addSuppressed so the original cause is preserved;
locate the catch handling outboxEventPublisher.publish and modify it to use try
{ couponIssueLimiter.rollback(...); } catch (Exception rollbackError) {
e.addSuppressed(rollbackError); } throw e.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponJpaRepository.java-35-37 (1)

35-37: ⚠️ Potential issue | 🟡 Minor

빈 리스트 전달 시 SQL 오류 발생 가능성 - 방어적 검증 추가 필요

호출 경로(CouponFacade.getCoupons → CouponService.countIssuedCoupons → OwnedCouponRepository.countByCouponIds)에서 빈 couponIds 리스트가 전달될 수 있다. 페이징 조회 결과가 비어있을 경우 이 시나리오가 발생한다. 현재 MySQL의 IN 절은 빈 리스트를 처리할 때 SQL 문법 오류를 발생시킨다.

운영 관점에서 빈 페이지 조회는 정상적인 흐름이므로, 이를 안전하게 처리해야 한다. 리포지토리 메서드 시작 부분에서 couponIds.isEmpty() ? Collections.emptyList() : ... 형태의 빈 리스트 검증을 추가하고, 이 시나리오를 커버하는 통합 테스트를 작성한다.

🤖 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/OwnedCouponJpaRepository.java`
around lines 35 - 37, OwnedCouponJpaRepository.countByCouponIdIn can be invoked
with an empty couponIds list (via CouponFacade.getCoupons →
CouponService.countIssuedCoupons), which causes a SQL syntax error for MySQL
IN(). Modify the repository call path to defensively handle empty input: in
OwnedCouponJpaRepository.countByCouponIdIn (or the calling method in
CouponService), if couponIds.isEmpty() return Collections.emptyList()
immediately instead of issuing the query; keep the existing query for non-empty
lists. Add an integration test that simulates a paged result producing an empty
couponIds list and asserts the method returns an empty list without throwing SQL
exceptions.
apps/commerce-api/src/main/java/com/loopers/infrastructure/notification/FakeEmailSender.java-7-14 (1)

7-14: ⚠️ Potential issue | 🟡 Minor

민감정보 로깅 문제를 해결하기 위해 마스킹 처리 및 개발 환경 격리가 필요하다.

이메일 주소(to) 및 본문(body)이 로그에 기록되고 있다. 이는 개인정보가 포함될 수 있는 로그가 운영 환경에 남아 규제 요건(개인정보보호법, GDPR 등) 위반으로 이어질 수 있다.

FakeEmailSender는 개발 전용 구현이므로 @Profile("dev") 또는 @ConditionalOnProperty로 개발 환경에만 활성화되어야 한다. 또한 민감정보는 로깅할 필요가 없으므로 마스킹 처리하거나 DEBUG 레벨로 분리하여 운영 환경에서 기록되지 않도록 설정해야 한다.

♻️ 프로파일 기반 격리 및 민감정보 마스킹 제안
 `@Slf4j`
-@Component
+@Component
+@Profile("dev")
 public class FakeEmailSender implements NotificationSender {

     `@Override`
     public void send(String to, String subject, String body) {
-        log.info("[FakeEmail] to={}, subject={}, body={}", to, subject, body);
+        log.info("[FakeEmail] to={}, subject={}", maskEmail(to), subject);
+        log.debug("[FakeEmail] body={}", body);
+    }
+
+    private String maskEmail(String email) {
+        if (email == null || !email.contains("@")) return "***";
+        int atIndex = email.indexOf("@");
+        return email.substring(0, Math.min(2, atIndex)) + "***" + email.substring(atIndex);
     }
 }
🤖 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/notification/FakeEmailSender.java`
around lines 7 - 14, FakeEmailSender currently logs sensitive data in
send(String to, String subject, String body) and is always active; restrict it
to dev only by annotating the FakeEmailSender class with a profile/conditional
(e.g., `@Profile`("dev") or `@ConditionalOnProperty`) and change logging to avoid
plaintext PII by masking the recipient and body (e.g., show only
hashed/partially masked email or length) or only log subject and a non-sensitive
marker; also demote the log level from INFO to DEBUG for any remaining
non-sensitive traces so production won't record them.
apps/commerce-api/src/main/resources/application.yml-28-35 (1)

28-35: ⚠️ Potential issue | 🟡 Minor

메일 설정의 기본값이 빈 문자열이면 런타임 실패 가능성이 있다.

MAIL_USERNAMEMAIL_PASSWORD의 기본값이 빈 문자열로 설정되어 있다. SMTP 인증이 활성화된 상태에서 빈 자격 증명으로 연결 시도 시 예외가 발생한다.

권장 사항:

  1. 필수 환경 변수 누락 시 애플리케이션 시작 단계에서 실패하도록 설정하거나
  2. 메일 발송 기능을 조건부로 활성화하는 프로파일 분리 적용
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/resources/application.yml` around lines 28 - 35,
The mail config sets MAIL_USERNAME and MAIL_PASSWORD defaulting to empty strings
which will cause runtime failures when mail.smtp.auth is true; update startup
behavior so missing credentials fail fast or disable mail sending: either
require non-empty MAIL_USERNAME/MAIL_PASSWORD by throwing during application
bootstrap (validate the mail.* config and abort startup if MAIL_USERNAME or
MAIL_PASSWORD are blank when mail.smtp.auth is true) or move mail settings into
a separate profile and only enable the mail sender when that profile is active
(condition mail.smtp.auth and presence of MAIL_USERNAME/MAIL_PASSWORD before
instantiating the mail sender/JavaMailSender bean).
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.java-61-67 (1)

61-67: ⚠️ Potential issue | 🟡 Minor

빈 리스트 입력 및 타입 안전성 검증이 필요하다.

  1. couponIds가 빈 리스트일 때 IN 절이 빈 상태로 실행되면 DB에 따라 예외가 발생할 수 있다.
  2. Object[]에서 Long으로 직접 캐스팅 시 JPQL/Hibernate 버전에 따라 Integer가 반환될 수 있어 ClassCastException 위험이 있다.
🛡️ 방어적 코드 제안
     `@Override`
     public Map<Long, Long> countByCouponIds(List<Long> couponIds) {
+        if (couponIds == null || couponIds.isEmpty()) {
+            return Map.of();
+        }
         return ownedCouponJpaRepository.countByCouponIdIn(couponIds).stream()
                 .collect(Collectors.toMap(
-                        row -> (Long) row[0],
-                        row -> (Long) row[1]));
+                        row -> ((Number) row[0]).longValue(),
+                        row -> ((Number) row[1]).longValue()));
     }
🤖 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/OwnedCouponRepositoryImpl.java`
around lines 61 - 67, countByCouponIds에서 빈 couponIds를 바로 DB에 전달하면 일부 DB에서 IN ()로
예외가 발생하고 Object[]에서 Long으로 직접 캐스팅하면 Integer 반환 시 ClassCastException이 발생할 수 있습니다;
우선 couponIds가 null/빈 경우 즉시 빈 Map을 반환하도록 방어적 체크를 추가하고,
ownedCouponJpaRepository.countByCouponIdIn 호출 결과를 처리할 때 row[0]과 row[1]을
(Number)로 받는 방식으로 안전하게 변환해 Number.longValue()로 Long을 생성하여 타입 안전성을 보장하세요 (대상 식별자:
메서드 countByCouponIds, 호출 ownedCouponJpaRepository.countByCouponIdIn).
apps/commerce-api/src/main/java/com/loopers/application/order/OrderPointHandler.java-24-24 (1)

24-24: ⚠️ Potential issue | 🟡 Minor

부동소수점 연산으로 포인트 계산 시 정밀도 손실이 발생할 수 있다.

event.totalPrice() * 0.02는 부동소수점 연산으로, 특정 금액에서 예상치 못한 결과가 발생할 수 있다. 예를 들어 totalPrice = 333일 때 333 * 0.02 = 6.66이지만, 부동소수점 오차로 6.659999...가 될 수 있어 (long) 캐스팅 시 6이 된다.

정수 연산으로 변경하면 이 문제를 방지할 수 있다.

🐛 정수 연산으로 변경
-long pointAmount = (long) (event.totalPrice() * 0.02);
+long pointAmount = event.totalPrice() * 2 / 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/application/order/OrderPointHandler.java`
at line 24, The current point calculation in OrderPointHandler uses
floating-point math (event.totalPrice() * 0.02) which can lose precision;
replace it with integer-safe/BigDecimal arithmetic: construct a BigDecimal from
event.totalPrice(), multiply by 2 and divide by 100 with RoundingMode.DOWN (or
compute using cents as integers) and then extract the long for
pointAmount—update the assignment to pointAmount accordingly (e.g., use
BigDecimal total = BigDecimal.valueOf(event.totalPrice()); pointAmount =
total.multiply(BigDecimal.valueOf(2)).divide(BigDecimal.valueOf(100),
RoundingMode.DOWN).longValue();).
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogModel.java-43-54 (1)

43-54: ⚠️ Potential issue | 🟡 Minor

viewedAt null 검증이 누락되어 DB 제약 위반 예외가 발생할 수 있다.

viewedAt 컬럼이 nullable = false로 선언되어 있으나, create() 팩토리 메서드에서 해당 파라미터의 null 검증이 없다. null이 전달되면 JPA persist 시점에 DataIntegrityViolationException이 발생하여, CoreException 기반 통합 에러 처리 패턴을 우회하게 된다.

🐛 viewedAt 검증 추가
 public static ProductViewLogModel create(Long productId, Long userId, ZonedDateTime viewedAt) {
     validateProductId(productId);
+    validateViewedAt(viewedAt);
     return new ProductViewLogModel(productId, userId, viewedAt);
 }

+private static void validateViewedAt(ZonedDateTime viewedAt) {
+    if (viewedAt == null) {
+        throw new CoreException(ErrorType.BAD_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/domain/product/ProductViewLogModel.java`
around lines 43 - 54, The create factory (ProductViewLogModel.create) validates
productId but not the viewedAt parameter, risking a DB constraint error; add a
null check for viewedAt (e.g., implement a private static
validateViewedAt(ZonedDateTime viewedAt) invoked from create) and throw the same
CoreException(ErrorType.BAD_REQUEST, "...") with an appropriate message when
viewedAt is null so persistence errors are prevented and the unified error
handling is preserved.
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaEventMessage.java-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

패키지명 오타 수정 필수

com.loopers.confg.kafkacom.loopers.config.kafka로 변경해야 한다.

패키지명 오타(confg)로 인해 코드 검색, 패키지 구조 파악, 그리고 향후 리팩토링 시 혼란과 유지보수 비용이 증가한다. 현재 3개 파일(KafkaConfig.java, KafkaEventMessage.java, KafkaTopics.java)과 이를 임포트하는 7개 파일에 영향을 미치므로 조직적인 리팩토링이 필요하다.

수정 시 확인 사항:

  • 패키지명 변경 후 모든 임포트 구문이 자동으로 업데이트되는지 확인
  • commerce-api, commerce-streamer 모듈의 모든 파일이 정상적으로 컴파일되는지 검증
  • 기존 임포트 경로를 사용하는 모든 코드가 제대로 참조되는지 확인
🤖 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/KafkaEventMessage.java`
at line 1, 패키지명 오타(com.loopers.confg.kafka)로 인해 참조가 깨지는 문제: 프로젝트 전반에서 패키지 선언과
import를 com.loopers.config.kafka로 일관되게 변경하세요 — KafkaEventMessage, KafkaConfig,
KafkaTopics 클래스들의 package 문구를 수정하고 이들 클래스를 임포트하는 모든 파일의 import 경로도 갱신한 뒤
commerce-api 및 commerce-streamer 모듈에서 컴파일/빌드 확인을 수행해 누락된 참조가 없는지 검증하세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java-66-71 (1)

66-71: ⚠️ Potential issue | 🟡 Minor

findLikeCountsByProductIds의 빈 리스트 입력과 타입 안전성 문제가 있다.

  1. productIds가 빈 리스트일 경우 IN () 구문이 되어 SQL 오류가 발생할 수 있다.
  2. List<Object[]> 반환은 타입 안전하지 않아 호출부에서 캐스팅 오류 위험이 있다.
🛡️ 빈 리스트 방어 및 DTO 프로젝션 제안
// 호출부에서 빈 리스트 체크
if (productIds.isEmpty()) {
    return Map.of();
}

// 또는 DTO projection 사용
public interface LikeCountProjection {
    Long getProductId();
    Long getLikeCount();
}

`@Query`(value = "SELECT p.id AS productId, COALESCE(pm.like_count, 0) AS likeCount"
        + " FROM products p"
        + " LEFT JOIN product_metrics pm ON p.id = pm.product_id"
        + " WHERE p.id IN :productIds",
        nativeQuery = true)
List<LikeCountProjection> findLikeCountsByProductIds(`@Param`("productIds") List<Long> productIds);
🤖 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/product/ProductJpaRepository.java`
around lines 66 - 71, Guard against an empty productIds list and switch to a
type-safe projection: update findLikeCountsByProductIds to accept productIds but
first handle an empty list (return empty map/collection from callers or add a
guard in the repository layer), and change the repository method return type
from List<Object[]> to a projection interface (e.g., LikeCountProjection with
getProductId() and getLikeCount()) so the `@Query` (findLikeCountsByProductIds)
can return List<LikeCountProjection> instead of raw Object[]; update calling
code to consume the projection or the empty result accordingly.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java-75-77 (1)

75-77: ⚠️ Potential issue | 🟡 Minor

addLikeCount(long delta)가 음수 결과를 허용한다.

unlike 이벤트 처리 시 delta가 음수로 전달될 수 있다. 현재 구현은 likeCount가 음수가 되는 것을 방지하지 않는다. 데이터 정합성을 위해 최소 0 이상을 보장해야 한다.

🛡️ 음수 방지 로직 추가
 public void addLikeCount(long delta) {
-    this.likeCount += delta;
+    this.likeCount = Math.max(0, this.likeCount + delta);
 }
🤖 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/ProductMetricsEntity.java`
around lines 75 - 77, The addLikeCount(long delta) currently allows likeCount to
go negative; update ProductMetricsEntity.addLikeCount to compute a newCount =
this.likeCount + delta and then set this.likeCount = Math.max(0L, newCount) so
the stored value never drops below zero (use long/0L). Ensure you reference the
existing addLikeCount method when making the change so the clamping behavior is
applied where like/unlike events are processed.
apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java-229-257 (1)

229-257: ⚠️ Potential issue | 🟡 Minor

CouponIssueResult.NOT_FOUND 케이스에 대한 테스트가 누락되었다.

CouponFacade.issueCoupon() 메서드는 NOT_FOUND 결과를 명시적으로 처리하고 있으나(lines 74-76), 테스트 파일에는 QUANTITY_EXHAUSTEDALREADY_ISSUED 케이스만 존재한다. Redis 캐시에서 쿠폰 템플릿이 등록되지 않은 상태일 때의 동작을 검증하지 않으면 운영 환경에서 데이터 불일치 상황을 감지할 수 없다.

다음 테스트를 추가하여 NOT_FOUND 예외 흐름을 검증하기를 권고한다:

  • 쿠폰이 Redis에 없을 때 CouponErrorCode.NOT_FOUND 예외 발생
  • 이 경우 Service 메서드가 호출되지 않음을 확인
🤖 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/CouponFacadeTest.java`
around lines 229 - 257, Add a test for the CouponIssueResult.NOT_FOUND path:
stub couponIssueLimiter.tryIssue(1L, <someCouponId>) to return
CouponIssueResult.NOT_FOUND, then assert that calling
CouponFacade.issueCoupon(1L, <sameCouponId>) throws a CoreException whose
getErrorCode() equals CouponErrorCode.NOT_FOUND, and verify
couponService.issue(...) is never invoked; place this alongside the existing
issueCoupon_whenQuantityExhausted and issueCoupon_whenAlreadyIssued tests to
cover the NOT_FOUND branch handled in CouponFacade.issueCoupon.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java-117-118 (1)

117-118: ⚠️ Potential issue | 🟡 Minor

스텁 데이터가 실제 상품 ID와 명시적으로 대응되지 않는다.

ProductModel.create() 호출 후 상품이 영속화되지 않으면 ID는 BaseEntity의 기본값 0L을 유지한다. 현재 Map.of(0L, 3L) 스텁은 암묵적으로 ID 0L인 두 상품에 모두 적용되는데, 이는 명확하지 않으며 향후 엔티티 구조 변경 시 취약해진다. 192-193줄의 정확한 패턴 Map.of(product.getId(), 10L)을 따라 각 상품의 실제 ID로 스텁을 설정해야 한다.

스텁 개선 제안
             when(productService.getLikeCountsByProductIds(anyList()))
-                    .thenReturn(Map.of(0L, 3L));
+                    .thenReturn(Map.of(product1.getId(), 3L, product2.getId(), 0L));
🤖 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/product/ProductFacadeTest.java`
around lines 117 - 118, 테스트의 스텁이 하드코드된 0L ID를 사용해 실제 상품 인스턴스와 매칭되지 않으니
ProductFacadeTest에서 product 생성 후 해당 인스턴스의 실제 ID를 사용하도록 수정하세요: 호출부는
ProductModel.create()로 생성한 각 상품의 getId() 값을 참조해
productService.getLikeCountsByProductIds(anyList())에 반환할 맵을
Map.of(product.getId(), expectedCount) 형태로 설정하고(예: 기존의 Map.of(0L, 3L) 대신 각
product.getId()를 키로 사용), anyList() 매처는 그대로 유지해 테스트가 생성된 엔티티 ID와 일치하도록 만드세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2190d363-4ae9-497a-a312-d9ae755e285a

📥 Commits

Reviewing files that changed from the base of the PR and between 3b3f642 and 7814ba8.

⛔ Files ignored due to path filters (2)
  • docs/round7/선착순쿠폰/ARCHITECTURE.md is excluded by !**/*.md and included by **
  • docs/round7/선착순쿠폰/test-result/AtomicInteger와AtomicUpdate/AtomicInteger와AtomicUpdate.md is excluded by !**/*.md and included by **
📒 Files selected for processing (136)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/event/CouponIssuedMessage.java
  • apps/commerce-api/src/main/java/com/loopers/application/notification/OrderNotificationHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderPointHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewCountHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/user/SignupBenefitHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/user/SignupNotificationHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueLimiter.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueResult.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/OwnedCouponModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/OwnedCouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/SignupCouponPolicy.java
  • apps/commerce-api/src/main/java/com/loopers/domain/notification/NotificationSender.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductLikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductUnlikedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRedisIssueLimiter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/DataGeneratorRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/notification/FakeEmailSender.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/notification/SmtpEmailSender.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaRelay.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/auth/OptionalLogin.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/coupon/CouponLoadTestController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/coupon/dto/AdminCouponV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/payment/PaymentV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/payment/dto/PaymentRequest.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/payment/dto/PaymentResponse.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/main/resources/scripts/coupon-issue.lua
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderPointHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeViewEventTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductViewCountHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/user/SignupBenefitHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/user/SignupNotificationHandlerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/OwnedCouponUseConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/FakeOwnedCouponRepository.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductLikeRepository.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductLikeServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductViewLogModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java
  • apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/AdminCouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/CouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderCouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledEntity.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • docker/grafana/dashboards/k6-load-testing.json
  • docker/grafana/provisioning/dashboards/dashboard.yml
  • docker/grafana/provisioning/datasources/datasource.yml
  • docker/infra-compose.yml
  • docker/monitoring-compose.yml
  • modules/jpa/src/main/resources/jpa.yml
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaEventMessage.java
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java
  • modules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (8)
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderCouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/OwnedCouponUseConcurrencyTest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/AdminCouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java

Comment on lines +41 to +75
PgPaymentResult pgResult = paymentGateway.requestPayment(
new PgPaymentRequest(
String.format("%06d", event.orderId()),
event.cardType().name(),
event.cardNo(),
event.totalPrice(),
CALLBACK_URL,
String.valueOf(event.userId())));

if (pgResult.requested()) {
paymentService.updateRequested(payment.getId(), pgResult.transactionKey());
orderService.completeOrder(event.orderId());
} else {
paymentService.failById(payment.getId());
compensateOrder(event);
}
} catch (Exception e) {
log.error("결제 처리 실패 — orderId={}", event.orderId(), e);
compensateOrder(event);
}
}

private void compensateOrder(OrderPaymentEvent event) {
try {
OrderInfo.PaymentFailureCancellation cancellation =
orderService.cancelByPaymentFailure(event.orderId());

for (OrderInfo.PaymentFailureCancellation.CancelledItem item : cancellation.items()) {
productService.increaseStock(item.productId(), item.quantity());
}
couponService.restoreByOrderId(event.orderId());
userService.addPoint(cancellation.userId(), cancellation.totalPrice());
} catch (Exception e) {
log.error("보상 트랜잭션 실패 — orderId={}", event.orderId(), e);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

PG 예외를 모두 즉시 보상 처리하면 미확정 승인 건과 부분 반영 상태를 만든다.

운영에서 requestPayment()의 타임아웃이나 연결 끊김은 "실패"가 아니라 "결과 미확정"일 수 있는데, 현재 코드는 이를 바로 주문 취소·재고 복구로 보내고 있다. 또한 updateRequested, completeOrder, 보상 로직이 각각 분리돼 있어 중간 실패 시 결제는 요청됨이고 주문은 취소되는 식의 정합성 깨짐이 남는다. 외부 PG 호출 결과를 실패/미확정/성공으로 나누고, 로컬 상태 변경과 보상은 별도 application service의 단일 트랜잭션 및 재시도 가능한 작업으로 관리해야 한다. 추가 테스트로는 PG가 승인 후 응답만 타임아웃되는 경우와 updateRequested() 이후 completeOrder()가 실패하는 경우를 각각 만들어 최종 상태가 일관적인지 검증해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java`
around lines 41 - 75, The code immediately treats any PG exception/timeouts as a
definitive failure and calls compensateOrder(), causing inconsistent states;
instead classify requestPayment outcomes into SUCCESS / UNCONFIRMED / FAILURE
(e.g., return a PgResult.status or throw a specific UncertainPaymentException
from paymentGateway.requestPayment), do not call compensateOrder() on
Unconfirmed/exception cases but mark the Payment as PENDING/UNCONFIRMED and
enqueue/retry reconciliation; move local state transitions
(paymentService.updateRequested, orderService.completeOrder) into a single
application-service transaction or a single method (e.g.,
PaymentApplicationService.completePaymentTransaction) so updateRequested and
completeOrder succeed or roll back together, make compensateOrder() idempotent
and retryable, and add tests for PG approval-with-timeout and for failure after
updateRequested() to verify final consistency.

Comment on lines +26 to +28
long countByCouponId(Long couponId);

Map<Long, Long> countByCouponIds(List<Long> couponIds);
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: Verify implementation handles empty list edge case

# Find the implementation of countByCouponIds
ast-grep --pattern 'Map<Long, Long> countByCouponIds($$$) {
  $$$
}'

# Also check JPA repository method
rg -n "countByCouponIds" --type=java -A 10

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

Length of output: 4289


🏁 Script executed:

#!/bin/bash

# Find the JPA repository interface
fd "OwnedCoupon.*JpaRepository" --type f | head -5

# Check the countByCouponIdIn method definition and query
rg -n "countByCouponIdIn|countByCouponId" --type=java -B 2 -A 5 | head -50

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

Length of output: 6024


🏁 Script executed:

#!/bin/bash

# Look for the full JPA repository interface
fd -e java | xargs grep -l "OwnedCouponJpaRepository" | head -3

# Get more context on the OwnedCouponRepositoryImpl class
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.java | sed -n '1,80p'

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

Length of output: 3110


🏁 Script executed:

#!/bin/bash

# Find JPA repository to see the query for countByCouponIdIn
find . -name "*OwnedCoupon*JpaRepository*" -type f
rg -n "OwnedCouponJpaRepository" --type=java -A 3 | grep -E "interface|extends"

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

Length of output: 5376


🏁 Script executed:

#!/bin/bash

# Check if there are tests for null/empty input handling
rg -n "countByCouponId|countByCouponIds" apps/commerce-api/src/test --type=java -B 2 -A 5 | grep -E "test|Test|null|empty" | head -30

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

Length of output: 3908


🏁 Script executed:

#!/bin/bash

# Verify actual usage patterns in CouponService
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java | sed -n '61,70p'

# Check if countByCouponIds is called with any validation
rg -n "countByCouponIds" apps/commerce-api/src/main/java --type=java -B 5 -A 2

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

Length of output: 3311


🏁 Script executed:

#!/bin/bash

# Check if there's any validation at the service or controller level before calling these methods
rg -n "countIssuedCoupons" apps/commerce-api/src/main/java --type=java -B 5 -A 3 | head -40

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

Length of output: 3968


null 파라미터 검증과 안전한 타입 변환이 필요하다.

countByCouponIds 메서드는 null인 couponIds 파라미터에 대한 검증이 없어 SQLGrammarException을 발생시킨다. 운영 환경에서 입력 검증 오류로 이어진다. 또한 OwnedCouponRepositoryImpl의 타입 변환 (Long) row[0], (Long) row[1]은 null 체크 없이 진행되어 ClassCastException 위험이 있다.

  • countByCouponIds(List<Long>) 입력값 null 체크 추가
  • 쿼리 결과 행의 각 요소에 대한 null 체크 후 변환
  • 빈 리스트는 현재 정상 처리됨 (쿼리 결과 빈 List<Object[]> → 빈 Map)
  • 또한 countByCouponId(Long)도 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/coupon/OwnedCouponRepository.java`
around lines 26 - 28, Add null-argument validation and safe result conversion:
validate inputs in OwnedCouponRepository.countByCouponIds(List<Long>) and
countByCouponId(Long) (throw IllegalArgumentException or return 0 as your
policy) to avoid passing null into the query; in OwnedCouponRepositoryImpl where
you process the query result rows, replace direct casts like (Long) row[0] and
(Long) row[1] with null-safe conversions (check row[i] for null, accept other
Number types then call ((Number)row[i]).longValue(), or default to 0L) before
putting entries into the Map; keep the current behavior for empty input lists
(return empty map) and ensure null inputs are rejected or handled consistently.

Comment on lines +10 to +12
@Slf4j
@RequiredArgsConstructor
public class SmtpEmailSender implements NotificationSender {
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

@Component 어노테이션 누락으로 Spring Bean 등록 실패

@Slf4j@RequiredArgsConstructor만 선언되어 있어 Spring IoC 컨테이너에 Bean으로 등록되지 않는다. NotificationSender를 주입받는 핸들러에서 런타임 시 NoSuchBeanDefinitionException이 발생한다.

🐛 수정 제안
 `@Slf4j`
+@Component
 `@RequiredArgsConstructor`
 public class SmtpEmailSender implements NotificationSender {
🤖 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/notification/SmtpEmailSender.java`
around lines 10 - 12, 클래스 SmtpEmailSender가 `@Slf4j와` `@RequiredArgsConstructor만`
선언되어 Spring Bean으로 등록되지 않으므로 NotificationSender를 주입하는 곳에서
NoSuchBeanDefinitionException이 발생합니다; SmtpEmailSender 클래스 선언부에 Spring의
`@Component`(또는 적절한 `@Service/`@Named 등) 어노테이션을 추가해 IoC 컨테이너에 빈으로 등록하고 기존
`@RequiredArgsConstructor` 기반 생성자 주입이 그대로 작동하는지 확인하세요.

Comment on lines +25 to +37
@Scheduled(fixedDelay = 3000)
@Transactional
public void relay() {
List<OutboxEventEntity> events =
outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc();

if (events.isEmpty()) return;

for (OutboxEventEntity event : events) {
try {
kafkaTemplate.send(event.getTopic(), event.getPartitionKey(), event.getPayload())
.get();
event.markPublished();
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

outbox row를 선점하지 않아 다중 인스턴스에서 중복 발행될 수 있다.

운영에서 API 인스턴스가 2대 이상이면 각 스케줄러가 같은 published=false row를 동시에 읽어 동일 이벤트를 중복 전송할 수 있다. 지금처럼 Kafka ACK 대기를 같은 트랜잭션 안에서 처리하면 그 창이 더 길어지고 DB 커넥션 점유도 같이 커진다. SELECT ... FOR UPDATE SKIP LOCKED 또는 IN_PROGRESS 상태로 먼저 claim하는 짧은 트랜잭션을 application/service 계층에 두고, Kafka 전송은 트랜잭션 밖에서 수행한 뒤 성공 건만 별도 짧은 트랜잭션으로 published 처리하도록 바꾸는 편이 안전하다. 추가 테스트로는 두 개의 relay 실행 주체가 동시에 relay()를 호출해도 한 outbox row가 한 번만 publish 되는지 검증해야 한다. Based on learnings, "Do not annotate infrastructure layer implementations with Transactional (even readOnly = true). Transaction management should be handled by the Application layer ... Apply this rule to all Java files under apps/commerce-api/src/main/java/com/loopers/infrastructure."

🤖 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/OutboxKafkaRelay.java`
around lines 25 - 37, OutboxKafkaRelay.relay currently runs the Kafka send
inside a long `@Transactional` method and reads rows via
outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc(), which allows
duplicate publishes under multiple instances; remove the `@Transactional` from
this infrastructure class and change relay to: in a short transaction claim rows
(either via a repository method that does SELECT ... FOR UPDATE SKIP LOCKED or
by updating status to IN_PROGRESS) so each instance exclusively reserves events,
then outside any transaction perform kafkaTemplate.send(...).get() for each
claimed event, and finally in another short transaction call
event.markPublished() / persist the published flag for only the successfully
sent events; also add tests that run two parallel relay() callers to assert each
outbox row is published only once.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java (1)

19-19: KafkaTemplate<Object, Object> 타입 파라미터로 인한 타입 안전성 저하

Object 타입 사용으로 컴파일 타임에 키/값 타입 오류를 감지할 수 없다. 실제 사용 패턴을 보면 키는 String, 값은 KafkaEventMessage이므로 KafkaTemplate<String, KafkaEventMessage>로 구체화하면 오타나 잘못된 타입 전달을 컴파일 시점에 방지할 수 있다.

♻️ 타입 구체화 예시
-    private final KafkaTemplate<Object, Object> kafkaTemplate;
+    private final KafkaTemplate<String, KafkaEventMessage> kafkaTemplate;

단, Kafka 설정 빈과 다른 Publisher들도 같은 타입으로 통일되어 있는지 확인이 필요하다.

🤖 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/kafka/OrderKafkaPublisher.java`
at line 19, OrderKafkaPublisher currently declares kafkaTemplate as
KafkaTemplate<Object, Object>, reducing type safety; change the field and usages
to KafkaTemplate<String, KafkaEventMessage> (and update constructor parameter
and any send/convert calls in OrderKafkaPublisher) so keys are String and values
are KafkaEventMessage, and then verify Kafka configuration beans and other
publishers use the same generic types to keep wiring compatible.
🤖 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/infrastructure/kafka/OrderKafkaPublisher.java`:
- Around line 31-33: In OrderKafkaPublisher's catch block (the Exception handler
that currently logs "[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED"),
include the order identifier (e.g., orderId or event.getOrderId()) and any
correlation id in the log message to enable tracing which order failed, and also
increment a Micrometer Counter (e.g., kafkaPublishFailureCounter) for Kafka
publish failures so dashboards can track failure trends; add the Counter as a
field (or inject MeterRegistry) if not present and update it inside the catch
before logging the enriched message.
- Around line 27-30: OrderCompletedEvent's nullable Long fields can cause
Map.of(...) to throw NPE in OrderKafkaPublisher when building KafkaEventMessage;
add defensive null checks: prefer adding Objects.requireNonNull(orderId,
"orderId must not be null") and Objects.requireNonNull(userId, "userId must not
be null") in the compact constructor of the OrderCompletedEvent record to fail
fast, and/or validate in OrderKafkaPublisher before calling
KafkaEventMessage.of(...) to log a clear error and skip publishing when fields
are null; update or add unit tests to cover publishing behavior when an event
contains null fields.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java`:
- Line 19: OrderKafkaPublisher currently declares kafkaTemplate as
KafkaTemplate<Object, Object>, reducing type safety; change the field and usages
to KafkaTemplate<String, KafkaEventMessage> (and update constructor parameter
and any send/convert calls in OrderKafkaPublisher) so keys are String and values
are KafkaEventMessage, and then verify Kafka configuration beans and other
publishers use the same generic types to keep wiring compatible.
🪄 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: ec12f309-93c2-4197-9cd9-5b98f92ce25a

📥 Commits

Reviewing files that changed from the base of the PR and between 7814ba8 and 7d64146.

📒 Files selected for processing (2)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.java

Comment on lines +27 to +30
KafkaEventMessage.of("ORDER_COMPLETED", Map.of(
"orderId", event.orderId(),
"userId", event.userId(),
"totalPrice", event.totalPrice())));
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 | 🟡 Minor

Map.of()는 null 값을 허용하지 않아 NPE 가능성 존재

OrderCompletedEventorderId, userIdLong 래퍼 타입이다. 이벤트 생성 시점에 null 검증이 없다면 Map.of()에서 NullPointerException이 발생한다. 이 예외는 catch 블록에서 잡히지만, 실제 원인(null 필드)을 파악하기 어렵고 의도치 않은 유실이 된다.

🛡️ 방어적 처리 방안

방안 1: 이벤트 레코드에서 생성 시점 검증 (권장)

public record OrderCompletedEvent(Long orderId, Long userId, int totalPrice) {
    public OrderCompletedEvent {
        Objects.requireNonNull(orderId, "orderId must not be null");
        Objects.requireNonNull(userId, "userId must not be null");
    }
}

방안 2: 발행 전 명시적 검증

 `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
 public void handleOrderCompleted(OrderCompletedEvent event) {
+    if (event.orderId() == null || event.userId() == null) {
+        log.error("[Kafka] 이벤트 필드 누락 — orderId={}, userId={}", event.orderId(), event.userId());
+        return;
+    }
     try {

단위 테스트에서 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/infrastructure/kafka/OrderKafkaPublisher.java`
around lines 27 - 30, OrderCompletedEvent's nullable Long fields can cause
Map.of(...) to throw NPE in OrderKafkaPublisher when building KafkaEventMessage;
add defensive null checks: prefer adding Objects.requireNonNull(orderId,
"orderId must not be null") and Objects.requireNonNull(userId, "userId must not
be null") in the compact constructor of the OrderCompletedEvent record to fail
fast, and/or validate in OrderKafkaPublisher before calling
KafkaEventMessage.of(...) to log a clear error and skip publishing when fields
are null; update or add unit tests to cover publishing behavior when an event
contains null fields.

Comment on lines +31 to +33
} catch (Exception e) {
log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

로그 메시지에 식별자 누락으로 장애 추적 어려움

Kafka 발행 실패 시 orderId 등 식별 정보가 없어 운영 환경에서 어떤 주문이 실패했는지 추적할 수 없다. 유실 허용 이벤트라도 실패 패턴 분석 및 재처리 대상 식별을 위해 최소한의 컨텍스트가 필요하다.

🔧 수정안
         } catch (Exception e) {
-            log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e);
+            log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED, orderId={}", event.orderId(), e);
         }

추가로 실패 건수를 Micrometer 카운터로 수집하면 대시보드에서 실패 추이를 모니터링할 수 있다.

📝 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
} catch (Exception e) {
log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e);
}
} catch (Exception e) {
log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED, orderId={}", event.orderId(), e);
}
🤖 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/kafka/OrderKafkaPublisher.java`
around lines 31 - 33, In OrderKafkaPublisher's catch block (the Exception
handler that currently logs "[Kafka] 직접 발행 실패 (유실 허용) —
eventType=ORDER_COMPLETED"), include the order identifier (e.g., orderId or
event.getOrderId()) and any correlation id in the log message to enable tracing
which order failed, and also increment a Micrometer Counter (e.g.,
kafkaPublishFailureCounter) for Kafka publish failures so dashboards can track
failure trends; add the Counter as a field (or inject MeterRegistry) if not
present and update it inside the catch before logging the enriched message.

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