Skip to content

feat : 7주 과제#290

Open
dfdf0202 wants to merge 1 commit intoLoopers-dev-lab:dfdf0202from
dfdf0202:volume-7
Open

feat : 7주 과제#290
dfdf0202 wants to merge 1 commit intoLoopers-dev-lab:dfdf0202from
dfdf0202:volume-7

Conversation

@dfdf0202
Copy link
Copy Markdown

@dfdf0202 dfdf0202 commented Mar 27, 2026

📌 Summary

  • 배경: 기존 Facade에서 좋아요 집계, 재고 복원 같은 부수효과를 직접 호출하고 있었습니다. 트랜잭션이 묶여 있어 실패 시 영향 범위가 크고, 시스템 간 전파도 동기 방식으로 처리되고 있었습니다. 이 흐름을 ApplicationEvent → Outbox → Kafka 방향으로 점진적으로 분리하는 작업이 이번 주차의 핵심입니다. 선착순 쿠폰은 별도로 Redis + Kafka 조합으로 구현했습니다.
  • 목표: 동기 호출로 묶여 있던 부수효과를 이벤트로 분리하고, 시스템 경계를 넘는 전파는 Outbox 패턴을 통해 Kafka로 안정적으로 발행합니다.
  • 결과: commerce-api에서 도메인 이벤트를 발행하고, commerce-streamer에서 소비하는 구조를 완성했습니다. 선착순 쿠폰은 Redis Sorted Set + Lua 스크립트로 원자적 수량 제한을 구현했고, Redis 장애 시 DB Fallback으로 전환하는 회로 차단기(CircuitBreaker)도 적용했습니다.

🧭 Context & Decision

문제 정의

Facade에서 부수효과(좋아요 집계, 재고 복원, Kafka 발행 등)를 직접 호출하면 몇 가지 문제가 생깁니다. 트랜잭션이 롤백될 때 이미 실행된 외부 API 호출이나 메시지 발행은 되돌릴 수 없습니다. PG 호출처럼 외부 I/O가 긴 작업이 트랜잭션 안에 있으면 DB 커넥션을 불필요하게 오래 점유합니다. 이 문제들을 해결하기 위한 판단을 아래에 정리합니다.

선택지와 결정

판단 1: 부수효과 분리 — @eventlistener vs @TransactionalEventListener

처음에는 @EventListener로 충분하다고 생각했습니다. 이벤트를 발행하고 리스너에서 처리하면 되니까요.

그런데 문제는 트랜잭션이 롤백되더라도 리스너가 실행된다는 점이었습니다. 예를 들어 주문 생성 중 예외가 발생해서 롤백됐는데, 리스너에서 이미 Outbox에 INSERT를 했다면 존재하지 않는 주문에 대한 이벤트가 Kafka로 나가게 됩니다. 데이터 정합성 문제입니다.

그래서 @TransactionalEventListener(phase = AFTER_COMMIT)을 선택했습니다. 트랜잭션이 성공적으로 커밋된 후에만 리스너가 실행되기 때문에, 롤백된 작업에 대한 부수효과가 발생하지 않습니다.

다만 AFTER_COMMIT 리스너 안에서 DB 작업을 하려면 새 트랜잭션이 필요합니다. 기존 트랜잭션은 이미 커밋돼서 끝났기 때문입니다. 그래서 리스너 메서드에 @Transactional(propagation = REQUIRES_NEW)를 붙여서 별도 트랜잭션으로 Outbox INSERT를 수행했습니다.

트레이드오프로는, AFTER_COMMIT 리스너가 실패하면 이벤트가 유실될 수 있습니다. 하지만 Outbox 패턴 자체가 재시도 가능한 구조이고, 메인 비즈니스 로직(주문 생성, 결제 등)이 롤백되는 것보다는 부수효과 유실이 복구하기 쉽다고 판단했습니다.

판단 2: Kafka 발행 시점의 트랜잭션 보장 — Outbox Pattern

이벤트 리스너에서 바로 kafkaTemplate.send()를 호출하면 간단하지만, DB 커밋은 됐는데 Kafka 발행이 실패하면 데이터 불일치가 생깁니다.

Outbox 테이블에 같은 트랜잭션으로 INSERT하고, 별도 스케줄러(OutboxRelay)가 1초 간격으로 폴링해서 Kafka로 발행하도록 했습니다. 발행 성공하면 PUBLISHED, 실패하면 FAILED로 마킹합니다. 재시도 가능한 구조입니다.

트레이드오프로는, 최대 1초 지연이 생기지만 메시지 유실을 방지할 수 있습니다.

판단 3: PG 호출의 트랜잭션 분리 — 결제는 견고해야 합니다

기존에는 PaymentFacade에서 @Transactional 안에서 PG 호출을 직접 하고 있었습니다. 외부 API 호출이 트랜잭션 안에 있으면 PG 응답이 느릴 때 DB 커넥션을 계속 점유하게 됩니다. 커넥션 풀이 고갈되면 결제뿐 아니라 다른 기능까지 영향을 받습니다.

이를 PaymentRequestedEvent로 분리했습니다.

  1. Facade에서 Payment를 PENDING 상태로 저장하고 트랜잭션을 커밋합니다.
  2. @TransactionalEventListener(AFTER_COMMIT)으로 커밋 후 PG 호출합니다.
  3. PG 응답을 받으면 handleCallback을 재사용해서 결과를 처리합니다.

Kafka가 아닌 같은 애플리케이션 내의 ApplicationEvent를 사용했습니다. 결제에는 즉시 응답이 필요하기 때문입니다. Kafka Consumer가 처리할 때까지의 지연은 결제 UX에 적합하지 않고, 같은 서비스 안에서 트랜잭션 밖으로 빼는 것이 목적이었기 때문에 ApplicationEvent면 충분했습니다.

PG 호출이 실패하더라도 checkPaymentStatus에서 PG에 직접 상태를 조회해서 복구할 수 있는 구조도 만들었습니다. Kafka Consumer에서 실패 시 offset 관리나 DLQ 처리를 해야 하는 것에 비하면 훨씬 단순하고 예측 가능한 복구 경로입니다.

결제가 완료된 후 다른 시스템에 알리는 부분은 Kafka를 사용했습니다. PaymentCompletedEvent → Outbox(PAYMENT_COMPLETED) → order-events 토픽 → commerce-streamer의 OrderEventConsumer에서 소비합니다. PG 호출(동기, 즉시 응답 필요)과 결제 결과 전파(비동기, 시스템 간 전파)의 성격이 다르기 때문에 구분해서 처리했습니다.

결제 실패 시에는 PaymentFailedEvent가 발행되어 주문 상태를 PAYMENT_FAILED로 변경하고 재고를 복원합니다. 결제가 실패했는데 재고가 차감된 채로 남아있으면 안 되기 때문에, 실패 보상 처리도 이벤트 기반으로 함께 구성했습니다.

판단 4: 선착순 쿠폰 — 수량 제한 방식을 세 번 바꾸게 된 이유

처음에는 DB만으로 해결하려 했습니다. SELECT FOR UPDATE로 잠그고 발급 수를 세는 방식이었습니다. 하지만 동시에 200명이 몰리면 락 경합이 심해져서 TPS가 바닥을 쳤습니다. 실제로 FirstComeCouponScenarioTest에서 race condition 발생을 확인했습니다.

그래서 Redis INCR로 전환했습니다. 카운터를 원자적으로 올리니까 동시성은 해결됐는데, "누가 먼저 요청했는지" 순서를 보장할 수 없었습니다. 선착순인데 순서가 없으면 의미가 없습니다.

결국 Redis Sorted Set + Lua script로 전환했습니다.

  • ZADD로 timestamp를 score로 넣어서 순서 보장
  • ZCARD로 현재 수를 세서 maxQuantity 초과 시 즉시 거부
  • 이 두 연산을 Lua script 하나로 묶어서 원자성 확보

Redis가 죽으면 어떻게 할지도 고민했습니다. Resilience4j CircuitBreaker를 걸어서 Redis 장애 시 DB Fallback으로 전환하도록 했습니다. DB Fallback은 동시성 제어가 약하지만, 서비스가 완전히 멈추는 것보다는 낫다고 판단했습니다.

Consumer 측에서는 event_handled 테이블로 멱등성을 보장했습니다. Kafka 메시지가 중복 전달되더라도 동일한 eventId가 이미 처리된 기록이 있으면 건너뜁니다.


🏗️ Design Overview

변경 범위

모듈 변경 내용
commerce-api 도메인 이벤트 7개(OrderCreatedEvent, OrderExpiredEvent, FavoriteAddedEvent, FavoriteRemovedEvent, PaymentRequestedEvent, PaymentCompletedEvent, PaymentFailedEvent), 이벤트 리스너 4개(FavoriteEventListener, OrderEventListener, PaymentEventListener, OrderExpireScheduler), OutboxRelay, FirstComeCouponService, PaymentFacade 리팩토링
commerce-streamer Kafka Consumer 3개(CatalogEventConsumer, OrderEventConsumer, CouponIssueConsumer), ProductMetrics 집계, event_handled 멱등성 처리
modules/kafka Kafka topic 3개(catalog-events, order-events, coupon-issue-requests), JPA test profile 추가

주요 컴포넌트 책임

컴포넌트 책임
FavoriteEventListener 좋아요 집계(increaseLikeCount / decreaseLikeCount) + Outbox INSERT, REQUIRES_NEW 트랜잭션
OrderEventListener 주문 생성 Outbox INSERT, 주문 만료 시 재고·쿠폰 복원
PaymentEventListener PG 호출(AFTER_COMMIT), 결제 완료 Outbox INSERT
OrderExpireScheduler 60초마다 미결제 주문 탐색 → OrderExpiredEvent 발행
OutboxRelay 1초마다 INIT 상태 Outbox 폴링 → Kafka 발행 → PUBLISHED/FAILED 마킹
FirstComeCouponService Redis Sorted Set + Lua로 수량 제한, CircuitBreaker로 DB Fallback 전환
CatalogEventConsumer catalog-events 소비 → ProductMetrics 좋아요 수 집계, version 기반 중복 처리 방지
OrderEventConsumer order-events 소비 → 멱등성 체크 후 이벤트 타입별 처리
CouponIssueConsumer coupon-issue-requests 소비 → user_coupon INSERT + 발급 요청 상태 변경

🔁 Flow Diagram

Outbox → Kafka 전체 흐름

sequenceDiagram
    participant Facade
    participant EventPublisher
    participant Listener as @TransactionalEventListener
    participant Outbox as Outbox Table
    participant Relay as OutboxRelay (@Scheduled 1s)
    participant Kafka
    participant Consumer as commerce-streamer Consumer
    participant EventHandled as event_handled Table

    Facade->>EventPublisher: publishEvent(XxxEvent)
    Note over Facade: 메인 트랜잭션 커밋
    EventPublisher->>Listener: AFTER_COMMIT 트리거
    Listener->>Outbox: INSERT (REQUIRES_NEW TX)

    loop 1초마다
        Relay->>Outbox: findByStatusInit()
        Relay->>Kafka: send().get() (동기 발행)
        alt 성공
            Relay->>Outbox: markPublished()
        else 실패
            Relay->>Outbox: markFailed()
        end
    end

    Kafka->>Consumer: 메시지 수신 (batch)
    Consumer->>EventHandled: existsById(eventId) 확인
    alt 이미 처리됨
        Consumer-->>Consumer: skip
    else 미처리
        Consumer->>Consumer: 비즈니스 처리
        Consumer->>EventHandled: save(SUCCESS)
    end
    Consumer->>Kafka: ack.acknowledge()
Loading

Payment 이벤트 기반 PG 호출 흐름

sequenceDiagram
    participant Client
    participant Facade as PaymentFacade
    participant PaymentDB as Payment (PENDING)
    participant Listener as PaymentEventListener
    participant PG as PG API
    participant OrderDB as Orders

    Client->>Facade: createPayment(loginId, cardNo, ...)
    Facade->>PaymentDB: createPaymentWithSnapshots → PENDING
    Facade->>Facade: publishEvent(PaymentRequestedEvent)
    Note over Facade: 트랜잭션 커밋
    Facade-->>Client: FindPaymentResDto (PENDING)

    Note over Listener: AFTER_COMMIT 트리거 (REQUIRES_NEW)
    Listener->>PG: requestPayment(memberId, pgCommand)
    alt PG 성공 (transactionKey 존재)
        PG-->>Listener: transactionKey
        Listener->>PaymentDB: markRequested(transactionKey)
        Listener->>OrderDB: updateStatus(PAYMENT_REQUESTED)
    else PG 실패
        Listener->>PaymentDB: handlePgFailure
        Listener->>OrderDB: updateStatus(PAYMENT_FAILED)
    end

    Note over Facade: PG 콜백 or checkPaymentStatus 경로
    Facade->>Facade: handleCallback(transactionKey, pgStatus)
    alt SUCCESS
        Facade->>PaymentDB: markSuccess
        Facade->>OrderDB: updateStatus(PAID)
        Facade->>Facade: publishEvent(PaymentCompletedEvent)
        Note over Facade: → Outbox → order-events → OrderEventConsumer
    else FAILED
        Facade->>PaymentDB: markFailed
        Facade->>OrderDB: updateStatus(PAYMENT_FAILED)
        Facade->>PaymentDB: restoreStock
        Facade->>Facade: publishEvent(PaymentFailedEvent)
    end

    Note over Client: 상태 직접 조회 경로
    Client->>Facade: checkPaymentStatus(loginId, orderId)
    Facade->>PG: getPaymentByOrderId
    PG-->>Facade: PgPaymentStatus
    Facade->>Facade: handleCallback 재사용
Loading

선착순 쿠폰 발급 흐름

flowchart TD
    A[API 요청: 선착순 쿠폰 발급] --> B{CircuitBreaker 상태}

    B -->|정상| C[Redis Sorted Set + Lua Script 실행]
    C --> D{실행 결과}
    D -->|result == -1| E[이미 발급 요청 예외 CONFLICT]
    D -->|result == -2| F[수량 소진 예외 BAD_REQUEST]
    D -->|result == 1| G[CouponIssueFacade: 발급 요청 DB 저장]
    G --> H[Kafka 발행: coupon-issue-requests]
    H --> I[CouponIssueConsumer 수신]
    I --> J{event_handled 멱등성 체크}
    J -->|중복| K[skip]
    J -->|신규| L[user_coupon INSERT]
    L --> M[issue_request 상태 변경: ISSUED]
    M --> N[event_handled save SUCCESS]

    B -->|OPEN - Redis 장애| O[DB Fallback 실행]
    O --> P{이미 발급 요청 존재?}
    P -->|존재| E
    P -->|없음| Q{발급 수 >= maxQuantity?}
    Q -->|초과| F
    Q -->|여유 있음| G
Loading

Review Point

  1. AFTER_COMMIT 리스너 실패 시 이벤트 유실 대응
  • @TransactionalEventListener(AFTER_COMMIT)을 사용하면 메인 트랜잭션 커밋 후에만 리스너가 실행되어 정합성은 지킬 수 있었는데, 반대로 리스너 자체가 실패하면 이벤트가 유실될 수 있습니다. 현재는 Outbox가 재시도 가능한 구조라 괜찮다고 판단했는데, 실무에서는 이 유실 가능성을 어떻게 보완하시나요? 별도의 보상 배치를 두시는지, 아니면 다른 패턴을 쓰시는지 궁금합니다.
  1. Outbox Relay 폴링 방식의 한계
  • OutboxRelay를 @scheduled(fixedDelay = 1000)으로 1초 간격 폴링하고 있는데, 트래픽이 많아지면 폴링 간격이 병목이 될 수 있고, 반대로 이벤트가 없을 때도 계속 쿼리를 날립니다. 실무에서 Outbox Relay를 운영하실 때 폴링 대신 CDC(Change Data Capture) 같은 방식을 쓰시는지, 폴링이면 어떤 기준으로 간격을 잡으시는지 궁금합니다.
  1. PG 호출을 ApplicationEvent로 분리한 판단에 대해
  • PG 호출을 트랜잭션 밖으로 빼기 위해 ApplicationEvent를 사용했는데, 이러면 PG 호출이 애플리케이션 서버의 스레드 안에서 동기로 실행됩니다. 현재는 createPayment에서 이미 PENDING 응답을 반환한 후라 유저 응답 지연은 없지만, PG 응답이 오래 걸리면 톰캣 스레드가 점유되는 문제가 남아있습니다. 실무에서는 이 부분을 @async나 별도 스레드풀로 처리하시는지, 아니면 PG 타임아웃 설정만으로 충분하다고 보시는지 의견이 궁금합니다.
  1. Redis Sorted Set과 Kafka의 역할 분리에 대해
  • 선착순 쿠폰에서 Redis Sorted Set을 도입한 이유는 성능 때문이었습니다. DB에서 직접 수량 체크와 중복 검증을 하면 락 경합으로 TPS가 나오지 않아서, 1차로 Redis에서 순서 정렬 + 수량 초과 검증 + 중복 요청 체크를 빠르게 처리하고, Kafka Consumer에서는 검증이 끝난 데이터의 DB 저장만 담당하도록 역할을 나눴습니다. 그런데 Redis에서 ZADD는 성공했는데 이후 Kafka 발행이나 DB 저장이 실패하면, Redis에는 자리를 차지하고 있지만 실제 발급은 안 된 상태가 됩니다. 현재는 보상 로직으로 removeFromQueue를 호출하고 있는데, 이 보상마저 실패하면 수량이 하나 줄어든 채로 남게 됩니다. 실무에서 이런 검증 레이어(Redis)와 처리 레이어(DB) 사이의 불일치를 어떻게 다루시는지 궁금합니다.
  1. Consumer 멱등성 처리의 확장성
  • 현재 event_handled 테이블에 eventId를 저장해서 중복 소비를 방지하고 있는데, 이벤트가 계속 쌓이면 이 테이블이 커질 텐데요. 실무에서는 일정 기간이 지난 레코드를 정리하시는지, 아니면 TTL 기반의 다른 저장소(Redis 등)를 멱등성 키 저장소로 쓰시는지 궁금합니다.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Kafka 모듈을 도입하고 이벤트 주도형 아키텍처를 구현하였다. Outbox 패턴으로 비동기 이벤트 퍼블리시를 보장하며, 첫 구매 쿠폰(FCFS) 기능을 Redis 기반으로 추가하고, 결제 상태를 타입 안전한 Enum으로 전환하였다. Commerce-streamer 애플리케이션에 Kafka 컨슈머를 추가하여 이벤트를 처리하도록 구성하였다.

Changes

Cohort / File(s) Summary
빌드 및 설정
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/resources/application.yml, modules/kafka/src/main/resources/kafka.yml, modules/jpa/src/main/resources/jpa.yml
Kafka 모듈 의존성 추가, Kafka 설정 임포트, 프로듀서 멱등성 활성화(enable.idempotence: true), 테스트 프로필용 데이터베이스 기본값 설정
스프링 부트 애플리케이션 초기화
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@EnableScheduling 추가로 스케줄링 기능 활성화
쿠폰 도메인 모델
apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueRequest.java, CouponIssueStatus.java, FirstComeCoupon.java
쿠폰 발급 요청 엔티티, 상태 관리(PENDING/ISSUED/REJECTED), 첫 구매 쿠폰 모델 추가
사용자 쿠폰 도메인
apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java
쿠폰 복구 메서드 추가(사용 → 미사용 전환)
쿠폰 레포지토리 인터페이스
apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java, FirstComeCouponRepository.java
쿠폰 발급 요청 및 첫 구매 쿠폰 조회/저장 계약 정의
쿠폰 서비스
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java
restoreUserCoupon() 메서드 추가
첫 구매 쿠폰 서비스(Redis/Circuit Breaker)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java
Redis Sorted Set 기반 FCFS 큐 관리, Redis 실패 시 DB 폴백 로직, 최대 수량 초과/중복 방지
도메인 이벤트
apps/commerce-api/src/main/java/com/loopers/domain/event/*
FavoriteAddedEvent, FavoriteRemovedEvent, OrderCreatedEvent, OrderExpiredEvent, PaymentRequestedEvent, PaymentCompletedEvent, PaymentFailedEvent 레코드 추가
결제 도메인 타입
apps/commerce-api/src/main/java/com/loopers/domain/payment/PgPaymentStatus.java, PaymentInfo.java
결제 상태를 Enum으로 타입화(SUCCESS/PENDING/FAILED), PaymentInfo.status 필드 타입 변경
주문 도메인
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java, repository/OrderRepository.java, service/OrderService.java
CANCELLED 상태 추가, 만료된 주문 조회 메서드 추가(30분 이상 경과 주문)
Outbox 도메인 모델
apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java, OutboxEventType.java, OutboxStatus.java
Outbox 패턴 구현(INIT → PUBLISHED/FAILED), 이벤트 타입 정의(FAVORITE_ADDED/REMOVED, ORDER_CREATED, PAYMENT_COMPLETED)
Outbox 레포지토리
apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java
Outbox 이벤트 저장 및 INIT 상태 조회 계약
쿠폰 애플리케이션 계층
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java, dto/CouponIssueRequestResDto.java
첫 구매 쿠폰 발급 요청 및 상태 조회 엔드포인트 추가, 발급 실패 시 큐 제거
선호도/주문 애플리케이션 계층
apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java, order/OrderFacade.java
이벤트 퍼블리시로 전환(직접 Like 수 변경 제거), 주문 생성 후 이벤트 발행
결제 애플리케이션 계층
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
PgPaymentStatus 타입 도입, 콜백 시그니처 변경, 소유권 검증 강화, 실패/성공 이벤트 발행
이벤트 리스너
apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java, OrderEventListener.java, PaymentEventListener.java
트랜잭션 커밋 후 실행되는 이벤트 핸들러, Like 수 업데이트 및 Outbox 기록
스케줄러
apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java, OutboxRelay.java
매 60초마다 만료된 주문 처리(취소 상태 전환), 매 초마다 Outbox 이벤트 Kafka로 릴레이
쿠폰 JPA 엔티티 및 레포지토리
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestEntity.java, FirstComeCouponEntity.java, repository/*
데이터베이스 매핑 및 Spring Data JPA 레포지토리 구현
Outbox JPA 엔티티 및 레포지토리
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/entity/OutboxEventEntity.java, repository/*
Outbox 이벤트 영속화 및 상태 관리
결제 어댑터
apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java
결제 게이트웨이 응답의 상태 문자열을 PgPaymentStatus Enum으로 변환, 인식 불가 상태는 FAILED로 대체
주문 레포지토리 구현
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java, impl/OrderRepositoryImpl.java
만료된 주문 조회 쿼리 추가(상태 + 시간 기준)
쿠폰 컨트롤러
apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java
첫 구매 쿠폰 발급(POST /coupons/{couponId}/first-come-issue) 및 상태 조회(GET /coupon-issues/{requestId}/status) 엔드포인트 추가(202 ACCEPTED 응답)
결제 컨트롤러
apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
콜백 핸들러에서 상태 문자열을 PgPaymentStatus로 변환
Kafka 토픽 설정
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
catalog-events, order-events, coupon-issue-requests 토픽 빈 정의(3 파티션, 복제 계수 1)
Streamer 도메인 모델
apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventHandleStatus.java, EventType.java
이벤트 처리 결과(SUCCESS/SKIPPED/FAILED), 이벤트 타입 정의
Streamer JPA 엔티티
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/*, event/entity/EventHandledEntity.java, metrics/entity/ProductMetricsEntity.java
쿠폰 발급 요청/사용자 쿠폰, 이벤트 처리 기록, 상품 메트릭 엔티티 추가
Streamer 레포지토리
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestStreamerJpaRepository.java, event/repository/EventHandledJpaRepository.java, metrics/repository/ProductMetricsJpaRepository.java
JPA 레포지토리 인터페이스 정의
Streamer 컨슈머
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java, CouponIssueConsumer.java, OrderEventConsumer.java
배치 모드 Kafka 리스너, 멱등성 보장(EventHandled 확인), 버전 순서 검증, 이벤트 타입별 메트릭/쿠폰 업데이트
Streamer DTO
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/*
CatalogEventMessage, CouponIssueMessage, OrderEventMessage 레코드 정의
테스트
apps/commerce-api/src/test/*
FavoriteFacade/OrderFacade/PaymentFacade 테스트 업데이트(이벤트 발행 검증), PaymentFacade 통합 테스트 추가(데이터베이스 연동, 소유권 검증, 콜백 플로우), 첫 구매 쿠폰 동시성/시나리오 테스트(Redis FCFS, DB 폴백, 중복 거부)

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller
    participant Facade
    participant Domain Service
    participant ApplicationEventPublisher
    participant EventListener
    participant OutboxRepository
    participant KafkaTemplate
    participant OutboxRelay
    participant Kafka Topic

    Client->>Controller: Request (e.g., create order/favorite)
    Controller->>Facade: Delegate to facade
    Facade->>Domain Service: Execute business logic
    Domain Service->>Domain Service: Create domain entity
    Facade->>ApplicationEventPublisher: Publish domain event
    ApplicationEventPublisher->>EventListener: Fire transactional event
    EventListener->>Domain Service: Update side effects (e.g., like count)
    EventListener->>OutboxRepository: Persist OutboxEvent(INIT)
    EventListener->>EventListener: Return (transaction commits)
    
    OutboxRelay->>OutboxRepository: Poll events with status INIT
    OutboxRepository-->>OutboxRelay: List<OutboxEvent>
    loop For each OutboxEvent
        OutboxRelay->>KafkaTemplate: send(topic, key, payload)
        KafkaTemplate->>Kafka Topic: Publish to Kafka
        OutboxRelay->>OutboxRepository: Update status to PUBLISHED
    end
    
    Kafka Topic->>Streamer Batch Consumer: Receive message batch
    Streamer Batch Consumer->>Streamer Domain: Process each event (with version check)
    Streamer Domain->>Streamer Repository: Persist metrics/entities
    Streamer Batch Consumer->>EventHandledRepository: Record completion (SUCCESS/SKIPPED)
Loading
sequenceDiagram
    participant Client
    participant CouponController
    participant CouponFacade
    participant FirstComeCouponService
    participant Redis as Redis(ZSET)
    participant Database as DB
    participant CouponIssueRequestRepository
    participant KafkaTemplate
    participant Kafka Topic

    Client->>CouponController: POST /first-come-issue
    CouponController->>CouponFacade: requestFirstComeIssue(memberId, templateId)
    CouponFacade->>FirstComeCouponService: addToQueue(coupon, memberId)
    
    FirstComeCouponService->>FirstComeCouponService: Validate active status
    FirstComeCouponService->>Redis: Execute Lua script(ZADD with dedup & maxQty check)
    alt Redis Success
        Redis-->>FirstComeCouponService: Success (queue added)
    else Redis Failure
        FirstComeCouponService->>FirstComeCouponService: fallbackAddToQueue()
        FirstComeCouponService->>Database: Check existing issuance request
        FirstComeCouponService->>Database: Count issued coupons
        FirstComeCouponService->>FirstComeCouponService: Validate capacity
    end
    
    CouponFacade->>CouponIssueRequestRepository: save(CouponIssueRequest)
    CouponFacade->>KafkaTemplate: send(coupon-issue-requests, requestId, payload)
    KafkaTemplate->>Kafka Topic: Publish request event
    CouponController-->>Client: 202 ACCEPTED + requestId
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

복잡도 분석:

  • 새로운 이벤트 주도형 아키텍처 도입 (Outbox 패턴, TransactionalEventListener, Spring Event Publishing)
  • Redis 기반 FCFS 구현 + DB 폴백 로직 (동시성 제어, Lua 스크립트, Circuit Breaker)
  • Kafka 메시지 파이프라인 구현 (토픽 정의, 프로듀서 설정, 멱등성, 배치 컨슈머)
  • 다중 도메인 영향 (쿠폰, 주문, 결제, 선호도)
  • 결제 상태 타입 변환으로 인한 광범위한 영향도
  • 스케줄링 및 트랜잭션 경계 관리의 복잡성
  • 통합 테스트, 동시성 테스트, 시나리오 테스트로 검증 복잡도 증대

운영 관점 주의사항:

  • Outbox Relay 폴링 간격(1초): 데이터베이스 부하 증가 가능성. 네트워크/DB 모니터링 필수.
  • 첫 구매 쿠폰 Redis 폴백: Redis 장애 시 DB 직접 조회로 전환되므로 DB 성능 영향도 관찰 필요.
  • 이벤트 리스너 REQUIRES_NEW 트랜잭션: 별도 트랜잭션에서 실행되므로 원본 트랜잭션 롤백이 발생해도 이벤트는 발행될 수 있음. 멱등성 보장 필수.
  • Kafka 배치 컨슈머 버전 검증: 버전 역순 메시지 도착 시 SKIPPED 처리되므로 메시지 순서 보장 필수.
  • PaymentFacade 콜백 시그니처 변경: String statusPgPaymentStatus 변환으로 인한 호출처 동기화 필수.

Possibly related PRs

  • PR #275: Kafka/Outbox 파이프라인 동일 구현(토픽 정의, 배치 리스너, Streamer 컨슈머 구조)으로 직접 연관.
  • PR #234: @EnableScheduling, PgPaymentStatus 타입 도입, 결제 콜백 처리 로직 변경이 동일하여 결제/스케줄링 코드패스에서 직접 연관.
  • PR #209: 상품 Like 수 관리를 직접 호출에서 FavoriteAddedEvent 리스너로 전환하는 패턴이 동일하여 이벤트 기반 사이드이펙트 처리에서 직접 연관.
🚥 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
Description check ✅ Passed PR 설명이 배경, 목표, 결과를 명확하게 제시하고, 구체적인 설계 의사결정과 트레이드오프를 상세하게 기록하고 있다.
Title check ✅ Passed PR 제목이 주요 변경 사항을 명확하게 표현하고 있다.

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

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

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

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Note

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

Caution

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

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

88-102: ⚠️ Potential issue | 🟠 Major

상태를 갱신한 직후에도 오래된 payment로 응답할 수 있다.

콜백 처리 전 Line 88에서 읽은 payment를 그대로 DTO로 변환하고 있다. handleCallback()이 DB 상태를 바꿔도 현재 객체는 갱신되지 않으므로, 조회 API가 방금 완료된 결제를 REQUESTED로 돌려주는 문제가 생길 수 있다. 콜백 처리 후 결제를 다시 조회해 응답을 만들고, checkPaymentStatus() 한 번 호출만으로 최신 상태가 반환되는 통합 테스트를 추가하는 편이 안전하다.

🤖 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 88 - 102, The current flow in PaymentFacade uses the locally
fetched Payment (from paymentService.getPaymentByOrderId) to build the response
but may call handleCallback(payment.getTransactionKey(), ...) which mutates DB
state; re-fetch the payment after handleCallback completes and before calling
FindPaymentResDto.from so the DTO reflects updated DB state. Concretely: after
invoking handleCallback(...) (inside the PaymentStatus.REQUESTED branch), call
paymentService.getPaymentByOrderId(orderId) again to replace the local payment
variable, then return FindPaymentResDto.from(updatedPayment); also add an
integration test that triggers the callback path and asserts a single
checkPaymentStatus() (or the API call) returns the updated status.
🟠 Major comments (27)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java-86-91 (1)

86-91: ⚠️ Potential issue | 🟠 Major

복원 경로의 동시성 충돌 예외가 미처리되어 500으로 번질 수 있다.

운영 관점에서 보상/재처리 시 같은 쿠폰을 동시에 갱신하면 낙관락 예외가 발생할 수 있는데, 현재는 이를 도메인 예외로 전환하지 않아 장애 전파와 재시도 불안정을 유발한다.
수정안으로 ObjectOptimisticLockingFailureException을 명시적으로 처리하고, 재조회 결과가 이미 복원 상태면 성공으로 간주하는 멱등 분기를 추가하는 것이 안전하다.

추가 테스트로 동일 userCouponId에 대해 동시 restoreUserCoupon 호출 시 하나만 실제 갱신되고 나머지는 도메인 규약에 맞게 성공 처리 또는 CONFLICT로 일관되게 응답하는지 검증해야 한다.
As per coding guidelines **/*Service*.java: "멱등성과 중복 처리 방지 전략을 점검한다."

🤖 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/service/CouponService.java`
around lines 86 - 91, In restoreUserCoupon, catch
ObjectOptimisticLockingFailureException around the
userCouponRepository.update(userCoupon) call; on catch, re-query the userCoupon
via userCouponRepository.findById(userCouponId) and if the reloaded entity is
present and already restored (e.g., userCoupon.isRestored()), treat the
operation as successful and return, otherwise convert the exception into a
domain CoreException with ErrorType.CONFLICT (or a clear conflict message) to
indicate the optimistic lock failure; keep the initial not-found behavior and
ensure these changes are added inside the restoreUserCoupon method where
userCouponRepository.findById and userCouponRepository.update are used.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java-47-53 (1)

47-53: ⚠️ Potential issue | 🟠 Major

복원 로직이 비멱등이라 재처리 시 보상 흐름이 실패할 수 있다.

운영 관점에서 이벤트 중복 전달/재시도 상황에서 이미 AVAILABLE인 쿠폰에 예외를 던지면 보상 트랜잭션이 불필요하게 실패하고 재시도 폭주로 이어질 수 있다.
수정안으로 AVAILABLE 상태는 no-op으로 처리해 복원을 멱등하게 만들고, USED가 아닌 다른 상태만 예외 처리하는 방식이 안전하다.

수정 예시
 public void restore() {
-    if (this.status != CouponEnums.Status.USED) {
+    if (this.status == CouponEnums.Status.AVAILABLE) {
+        return;
+    }
+    if (this.status != CouponEnums.Status.USED) {
         throw new CoreException(ErrorType.BAD_REQUEST, "사용된 쿠폰만 복원할 수 있습니다.");
     }
     this.status = CouponEnums.Status.AVAILABLE;
     this.usedAt = null;
 }

추가 테스트로 restore()를 연속 2회 호출해도 예외 없이 AVAILABLE/usedAt=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/model/UserCoupon.java`
around lines 47 - 53, The restore() method currently throws a CoreException
unless status == CouponEnums.Status.USED, which breaks idempotency on duplicate
events; change restore() so that if status == CouponEnums.Status.AVAILABLE it
becomes a no-op (leave status and usedAt as-is), only throw for statuses that
are neither USED nor AVAILABLE, and when transitioning from USED set status to
CouponEnums.Status.AVAILABLE and usedAt = null; update tests to call restore()
twice and assert final state is AVAILABLE and usedAt == null.
apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java-3-4 (1)

3-4: ⚠️ Potential issue | 🟠 Major

실패 사유를 자유 문자열로 노출하면 운영 로그/이벤트 계약이 불안정해진다.

운영 관점에서 PG 원문 메시지를 reason으로 흘리면 민감정보 유출 위험과 고카디널리티 로그/메시지 폭증이 발생할 수 있다.
수정안으로 reason은 외부 전파용 에러 코드(예: enum 또는 고정 코드 문자열)로 제한하고, 상세 원문은 내부 보안 로그에만 분리 저장하는 방식이 안전하다.

수정 예시
-public record PaymentFailedEvent(String orderId, Long paymentId, String reason) {
+public record PaymentFailedEvent(String orderId, Long paymentId, String reasonCode) {
 }

추가 테스트로 직렬화/역직렬화 계약 테스트에서 허용 코드만 전파되는지, PG 상세 메시지가 이벤트 페이로드에 포함되지 않는지를 검증해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java`
around lines 3 - 4, The PaymentFailedEvent currently exposes a free-text reason
(field reason) which can leak sensitive PG messages and create high-cardinality
events; change the event contract so reason becomes a constrained error code
(e.g., an enum PaymentFailureCode or pre-defined code string) and move any raw
PG/detail text into a separate internal-only field (e.g., internalDetail) that
is not serialized for external consumers; update serialization
annotations/record definition for PaymentFailedEvent and any
constructors/factory methods that create it (refer to PaymentFailedEvent,
orderId, paymentId, reason) and add unit tests that assert only allowed codes
are present after serialize/deserialize and that raw PG messages do not appear
in the event payload.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java-28-30 (1)

28-30: ⚠️ Potential issue | 🟠 Major

상태값을 문자열로 저장하면 운영 중 데이터 무결성이 손상될 수 있다.

UserCouponStreamerEntityCouponIssueRequestStreamerEntity 모두 상태를 raw String으로 저장하고 있다. 데이터베이스 단계에서 문자열 유효성을 검증할 수 없어, 오타나 등록되지 않은 값이 유입되면 Consumer 재처리 실패와 집계 데이터 왜곡을 초래할 수 있다.

현재 아키텍처에서는 두 모듈이 격리되어 있으므로, enum을 공유 모듈(modules:*)로 이동하거나 streamer 내부에 enum을 정의한 후 @Enumerated(EnumType.STRING)을 적용해야 한다.

수정 예시 (streamer 내부 enum 정의 방식)
+public enum CouponStatus {
+    AVAILABLE, USED, EXPIRED
+}
+
+public enum CouponIssueStatus {
+    ISSUED, REJECTED
+}

// UserCouponStreamerEntity
-private String status;
+@Enumerated(EnumType.STRING)
+@Column(nullable = false)
+private CouponStatus status;
-private static final String STATUS_AVAILABLE = "AVAILABLE";
+private static final CouponStatus STATUS_AVAILABLE = CouponStatus.AVAILABLE;

// CouponIssueRequestStreamerEntity  
-private String status;
+@Enumerated(EnumType.STRING)
+@Column(nullable = false)
+private CouponIssueStatus status;
-private static final String STATUS_ISSUED = "ISSUED";
-private static final String STATUS_REJECTED = "REJECTED";
+private static final CouponIssueStatus STATUS_ISSUED = CouponIssueStatus.ISSUED;
+private static final CouponIssueStatus STATUS_REJECTED = CouponIssueStatus.REJECTED;

추가 테스트로 엔티티 저장/조회 라운드트립에서 enum 값이 안정적으로 유지되는지, 비정상 상태 문자열이 유입될 때 매핑 단계에서 차단되는지 검증해야 한다.

🤖 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/entity/UserCouponStreamerEntity.java`
around lines 28 - 30, UserCouponStreamerEntity and
CouponIssueRequestStreamerEntity currently store status as raw String; change to
a typed enum to enforce DB-level validity: create a Status enum (either moved to
shared modules:* or defined inside the streamer package) and replace the private
String status fields with the enum type, annotate the field with
`@Enumerated`(EnumType.STRING) in both classes (UserCouponStreamerEntity,
CouponIssueRequestStreamerEntity), update any setters/getters/usages
accordingly, and add round-trip persistence tests that verify correct enum
storage/retrieval and that invalid/unknown status strings fail mapping at
persist/load time.
apps/commerce-api/src/main/resources/application.yml-81-82 (1)

81-82: ⚠️ Potential issue | 🟠 Major

allow-bean-definition-overriding 범위를 test로 제한하는 것이 안전하다.

운영 관점에서 local까지 override를 허용하면 실제 충돌이 개발 단계에서 은닉되어 배포 직전 장애로 전이될 수 있다. test 전용 프로파일로 분리하거나 override 없이 충돌 빈을 @Primary/@Qualifier로 해소하는 쪽이 안전하다. 추가 테스트로 local 프로파일 contextLoads를 override=false 조건에서 수행해 충돌 조기 검출을 권장한다.

수정안 예시
 ---
 spring:
   config:
     activate:
-      on-profile: local, test
+      on-profile: test
   main:
     allow-bean-definition-overriding: true

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

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

In `@apps/commerce-api/src/main/resources/application.yml` around lines 81 - 82,
현재 application.yml의 main.allow-bean-definition-overriding: true 설정은 운영까지 빈
오버라이드를 허용하므로 main 프로파일이 아닌 test 전용 프로파일로 옮기세요; 즉
main.allow-bean-definition-overriding을 제거하고 동일 설정을 application-test.yml 또는 test
프로파일 블록으로 이동해 오직 테스트 실행 시에만 true 되도록 변경하고, 운영/로컬 환경은 기본값(false)으로 유지하도록 조정하며 충돌이
예상되는 빈들은 `@Primary` 또는 `@Qualifier로` 명시하여 해결하고, 로컬 프로파일에 대해 override=false 조건으로
contextLoads 통합테스트를 추가해 충돌이 조기 검출되도록 만드세요 (참조 필드:
allow-bean-definition-overriding, main, application.yml, application-test.yml,
`@Primary`, `@Qualifier`, contextLoads).
apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java-22-22 (1)

22-22: ⚠️ Potential issue | 🟠 Major

만료 주문 조회는 배치 단위로 제한해야 한다.

운영 관점에서 무제한 List 조회는 주문 적체 시 메모리 급증과 스케줄 지연을 유발한다. 메서드 시그니처를 배치/페이지 기반으로 바꾸고, 구현 쿼리에 (status, created_at) 인덱스 활용을 전제로 설계하는 것이 안전하다. 추가 테스트로 대량 데이터에서 배치 반복 처리 시 누락/중복 없이 종료되는지 검증해야 한다.

수정안 예시
-    List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore);
+    List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore, int limit);

As per coding guidelines **/*Repository*.java: 대량 데이터 병목, 조회 범위, 정렬/인덱스 활용 가능성을 점검한다.

🤖 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/repository/OrderRepository.java`
at line 22, Change the unbounded List-returning method findExpiredOrders in
OrderRepository to a batch/page-based API (e.g., accept a Pageable or (int
limit, long lastId/LocalDateTime lastCreatedAt) cursor) so callers can process
expired orders in fixed-size batches; update the repository signature from
List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore)
to a paged/cursor variant and ensure queries/orderings use (status, created_at)
to leverage the index (sort by createdAt, include id for stable ordering), then
add integration tests that iterate batches over large data to verify no
duplicates or gaps when finishing.
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java-10-10 (1)

10-10: ⚠️ Potential issue | 🟠 Major

다중 인스턴스 환경에서 스케줄러의 동시 실행으로 인한 중복 처리 방어가 필요하다.

운영 환경에서 애플리케이션 인스턴스가 2대 이상일 때, OutboxRelayOrderExpireScheduler가 각 인스턴스에서 독립적으로 실행되어 다음과 같은 문제가 발생한다:

  • OutboxRelay: 동일 이벤트를 여러 번 Kafka로 발행
  • OrderExpireScheduler: 동일 주문을 여러 번 취소 처리 및 이벤트 발행

이를 방지하기 위해 다음 중 하나를 적용해야 한다:

  • ShedLock 도입 (@SchedulerLock 적용)
  • DB 클레임 전략 (SELECT ... FOR UPDATE SKIP LOCKED 또는 상태 플래그 활용)

이후 다중 인스턴스 환경에서 실제 단일 건만 처리되는지 확인하는 통합 테스트를 작성한다.

🤖 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/CommerceApiApplication.java` at
line 10, The app currently enables scheduling globally via `@EnableScheduling` but
lacks multi-instance protection, causing OutboxRelay and OrderExpireScheduler to
run concurrently across instances; add a distributed locking strategy (e.g.,
integrate ShedLock and annotate the scheduled methods in OutboxRelay and
OrderExpireScheduler with `@SchedulerLock`) or implement a DB claim pattern inside
those classes (use SELECT ... FOR UPDATE SKIP LOCKED or a status flag transition
to atomically claim work before processing), and then add an integration test
that runs multiple application instances to assert only one instance processes
each outbox/event or order expiration.
apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java-42-43 (1)

42-43: ⚠️ Potential issue | 🟠 Major

deleteFavorite는 삭제 성공 여부와 무관하게 이벤트를 발행한다.

addFavoriteif (added) 조건으로 실제 추가된 경우에만 이벤트를 발행하지만, deleteFavorite는 삭제 결과를 확인하지 않고 무조건 이벤트를 발행한다. 이미 삭제된 즐겨찾기를 다시 삭제 요청하면 DB에는 변경이 없으나 이벤트가 발행되어 좋아요 카운트가 음수로 내려갈 수 있다.

운영 관점: 중복 삭제 요청 시 likeCount 정합성이 깨질 위험이 있다.

수정안
 `@Transactional`(rollbackFor = {Exception.class})
 public void deleteFavorite(String loginId, String password, Long productId) {
     Member member = memberService.findMember(loginId, password);
     Product product = productService.findProduct(productId);
     FavoriteCommand.Delete command = new FavoriteCommand.Delete(member.getId(), product.getId());
-    favoriteService.delete(command);
-    eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId()));
+    boolean deleted = favoriteService.delete(command);
+    if (deleted) {
+        eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId()));
+    }
 }

추가 테스트: 존재하지 않는 즐겨찾기 삭제 시 이벤트가 발행되지 않음을 검증하는 테스트 케이스를 추가한다.

🤖 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/favorite/FavoriteFacade.java`
around lines 42 - 43, The deleteFavorite flow currently publishes
FavoriteRemovedEvent unconditionally; change the behavior so that
favoriteService.delete(command) returns a boolean (or provide a
favoriteService.exists/deleteIfExists API) indicating whether the entity was
actually removed, then only call eventPublisher.publishEvent(new
FavoriteRemovedEvent(product.getId(), member.getId())) when that boolean is
true; update the deleteFavorite method to use the returned flag (e.g., boolean
deleted = favoriteService.delete(command); if (deleted) publish event) and add a
unit/integration test verifying that deleting a non-existent favorite does not
publish FavoriteRemovedEvent.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java-39-41 (1)

39-41: ⚠️ Potential issue | 🟠 Major

외부 PG 콜백에서 잘못된 상태값 수신 시 예외 처리가 누락되어 있다.

PgPaymentStatus.valueOf(request.status())는 유효하지 않은 상태값에 대해 IllegalArgumentException을 던진다. 콜백 엔드포인트는 외부 PG사가 호출하므로 예기치 않은 값이 올 수 있으며, 이 경우 500 에러가 반환된다.

운영 관점: PG사 연동 스펙 변경 시 예고 없이 서비스 장애가 발생할 수 있다. 또한 500 에러는 PG사 측에서 재시도를 유발하여 불필요한 트래픽이 증가한다.

수정안
 `@PostMapping`("/callback")
 public ApiResponse<Void> handleCallback(`@RequestBody` PaymentCallbackApiReqDto request) {
+    PgPaymentStatus status;
+    try {
+        status = PgPaymentStatus.valueOf(request.status());
+    } catch (IllegalArgumentException e) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "Unknown payment status: " + request.status());
+    }
-    paymentFacade.handleCallback(request.transactionKey(), PgPaymentStatus.valueOf(request.status()));
+    paymentFacade.handleCallback(request.transactionKey(), status);
     return ApiResponse.successNoContent();
 }

추가 테스트: 유효하지 않은 상태값으로 콜백 호출 시 400 에러가 반환되는지 검증하는 통합 테스트를 추가한다.

🤖 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/payment/PaymentV1Controller.java`
around lines 39 - 41, The callback handler in PaymentV1Controller currently
calls PgPaymentStatus.valueOf(request.status()) which will throw
IllegalArgumentException for invalid values; wrap that conversion in a try/catch
(or pre-validate the enum) inside handleCallback, catch
IllegalArgumentException, and return a 400 Bad Request ApiResponse (or throw a
framework BadRequest/ApiException) with a descriptive message instead of letting
it propagate to a 500; update PaymentV1Controller.handleCallback to perform this
validation around PgPaymentStatus.valueOf and add an integration test that posts
an invalid status to the callback endpoint and asserts a 400 response.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major

페이지네이션 미적용 시 대량 데이터 조회 및 트랜잭션 장애 위험이 있다.

findByStatusAndCreatedAtBefore 메서드는 스케줄러(OrderExpireScheduler)가 60초마다 호출하며, 쿼리 결과에 상한선이 없다. 미결제 주문이 대량으로 누적될 경우 메모리 부족 또는 장기 트랜잭션에 의한 락 대기 증가로 인한 장애가 발생할 수 있다.

문제점:

  • 모든 만료 대상 주문을 메모리에 한 번에 로드하여 처리 (stream().toList())
  • 스케줄러가 단일 트랜잭션 내에서 모든 주문의 상태를 순차적으로 업데이트
  • (status, created_at) 복합 인덱스가 없어 쿼리 최적화 불가

수정안:

  • @QueryLIMIT 절을 사용하여 배치 크기를 명시하거나 Pageable을 파라미터로 추가하여 페이지 단위 처리로 변경한다.
  • OrderEntity@Table 애너테이션에 @Index(name = "idx_status_created_at", columnList = "status, created_at") 추가한다.
  • 각 배치마다 별도의 트랜잭션으로 분리하여 락 점유 시간을 최소화한다.

추가 테스트:

  • 만료 대상 주문 10,000건 이상의 시나리오에서 힙 메모리 사용량, 쿼리 실행 시간, 데이터베이스 락 경합을 측정한다.
  • 스케줄러가 배치 크기 제한 하에서도 정상 동작하는지 확인한다.
🤖 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/repository/OrderJpaRepository.java`
at line 17, findByStatusAndCreatedAtBefore currently returns all matching rows
causing potential OOM and long transactions; change the repository method
(findByStatusAndCreatedAtBefore) to support paging (add Pageable parameter or
replace with an `@Query` that enforces a LIMIT) and consume results in fixed-size
batches instead of stream().toList(), add a composite DB index on OrderEntity
(add `@Index`(name="idx_status_created_at", columnList="status, created_at") to
the `@Table` declaration) and update OrderExpireScheduler to iterate pages/batches
and process each batch in its own transaction to minimize lock time.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java-49-53 (1)

49-53: ⚠️ Potential issue | 🟠 Major

만료 주문 조회가 무제한 적재여서 장애 복구 시 병목/메모리 리스크가 크다.

운영 관점에서 만료 주문이 누적된 시점에 전체를 한 번에 읽으면 스케줄러 지연, GC 압박, 후속 처리 타임아웃으로 이어질 수 있다. 배치 크기(예: 200~500) 단위로 페이지 조회 후 반복 처리하도록 바꿔야 한다. 추가 테스트로 대량 더미(예: 5만 건)에서 배치 단위로 분할 조회/처리되는지와 1회 처리 시간 상한을 검증해야 한다.

📦 제안 수정안
+import org.springframework.data.domain.PageRequest;
 ...
     `@Override`
     public List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore) {
         ZonedDateTime zonedExpire = expireBefore.atZone(ZoneId.systemDefault());
-        return orderJpaRepository.findByStatusAndCreatedAtBefore(status, zonedExpire).stream()
+        return orderJpaRepository.findByStatusAndCreatedAtBefore(
+                        status,
+                        zonedExpire,
+                        PageRequest.of(0, 500)
+                ).stream()
                 .map(OrderEntity::toModel)
                 .toList();
     }
🤖 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/repository/impl/OrderRepositoryImpl.java`
around lines 49 - 53, findExpiredOrders currently loads all matching orders at
once causing OOM/GC and scheduler delays; change
OrderRepositoryImpl.findExpiredOrders to page through results using
orderJpaRepository.findByStatusAndCreatedAtBefore with a Pageable (e.g.,
PageRequest.of(page, batchSize)) and loop fetching pages until empty, converting
each page's OrderEntity via OrderEntity::toModel and appending to a result list
(batchSize configurable, e.g., 200–500). Ensure you do not hold unnecessary
references between batches, and add tests that run against large dummy data
(~50k) to verify batching behavior and that each page process time meets the
1-run time bound. Include method/class references: findExpiredOrders,
OrderRepositoryImpl, orderJpaRepository.findByStatusAndCreatedAtBefore,
OrderEntity::toModel.
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java-19-27 (1)

19-27: ⚠️ Potential issue | 🟠 Major

couponTemplateId에 유니크 제약이 없어 단건 조회 계약이 깨질 수 있다.

운영 중 중복 데이터가 한 번이라도 들어가면 FCFS 조회 경로가 런타임 예외로 실패해 발급 처리 장애로 이어질 수 있다. couponTemplateId에 유니크 제약(및 인덱스)을 명시해 데이터 무결성과 조회 성능을 함께 보장해야 한다. 추가 테스트로 동일 couponTemplateId 2건 저장 시 DataIntegrityViolationException이 발생하는 통합 테스트를 넣어야 한다.

🧱 제안 수정안
+import jakarta.persistence.Index;
+import jakarta.persistence.UniqueConstraint;
 ...
-@Table(name = "first_come_coupon")
+@Table(
+        name = "first_come_coupon",
+        uniqueConstraints = `@UniqueConstraint`(name = "uk_first_come_coupon_template_id", columnNames = "couponTemplateId"),
+        indexes = `@Index`(name = "idx_first_come_coupon_template_id", columnList = "couponTemplateId")
+)
 public class FirstComeCouponEntity {
 ...
-    `@Column`(nullable = false)
+    `@Column`(nullable = false)
     private Long couponTemplateId;
🤖 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/entity/FirstComeCouponEntity.java`
around lines 19 - 27, FirstComeCouponEntity의 couponTemplateId에 유니크 제약과 인덱스가 없어
중복 입력 시 단건 조회가 깨질 수 있으니, FirstComeCouponEntity 클래스에 couponTemplateId 컬럼에 대해 고유
제약을 추가(예: `@Column`(unique = true) 또는 `@Table의` uniqueConstraints로 정의)하고 인덱스도 명시해 조회
성능과 무결성을 보장하라; 또한 동일 couponTemplateId를 2건 저장하면 DataIntegrityViolationException이
발생하는 통합 테스트(예: FirstComeCouponEntity 통합 테스트에서 repository.save 두 번 호출 후 예외 기대)를
추가해 검증하라.
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java-35-38 (1)

35-38: ⚠️ Potential issue | 🟠 Major

FAILED 상태 이벤트의 재처리 메커니즘이 없다.

markFailed() 호출 후 해당 이벤트는 findByStatusInit()에서 조회되지 않아 영구적으로 누락된다. 재시도 횟수 제한과 함께 FAILED 이벤트를 재처리하는 로직이 필요하다.

🔧 재시도 메커니즘 제안
// OutboxEvent에 retryCount 필드 추가 후
if (event.getRetryCount() < MAX_RETRY) {
    event.incrementRetryCount();
    // status는 INIT 유지하여 다음 폴링에서 재시도
} else {
    event.markFailed();
    log.error("Outbox 이벤트 최대 재시도 초과 - eventId: {}", event.getId());
}
🤖 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/OutboxRelay.java`
around lines 35 - 38, The Outbox processing currently marks events FAILED with
event.markFailed() which removes them from findByStatusInit() polling and causes
permanent loss; add a retry mechanism by introducing a retryCount field on the
OutboxEvent (and a MAX_RETRY constant), update the processing loop in
OutboxRelay to check event.getRetryCount() before marking failed, call
event.incrementRetryCount() and keep status as INIT when under MAX_RETRY so it
will be re-polled, and only call event.markFailed() and log a “max retries
exceeded” error when retryCount >= MAX_RETRY; update persistence saving logic to
persist retryCount changes (and adjust any repository queries if needed).
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java-1-1 (1)

1-1: 🛠️ Refactor suggestion | 🟠 Major

패키지명 오타: confgconfig (5개 파일 영향)

패키지명 오타로 인해 컴파일 시 import 경로 오류가 발생한다. 현재 5개 파일이 com.loopers.confg 패키지를 참조 중이므로 모두 수정해야 한다:

  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (패키지 선언)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java

패키지 디렉토리명 변경 → 파일의 package 선언 수정 → 모든 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/KafkaConfig.java` at line
1, 패키지명 오타로 인해 import 경로가 깨지므로 package 선언과 참조를 모두 "com.loopers.confg"에서
"com.loopers.config"로 변경하세요: KafkaConfig.java의 package 선언을 수정하고,
CouponIssueConsumer, OrderEventConsumer, DemoKafkaConsumer, CatalogEventConsumer
클래스들에서 모든 import 절에 사용된 com.loopers.confg.*를 com.loopers.config.*로 바꿔주세요; 작업 전
패키지 디렉토리 이름도 confg → config로 변경하고 수정 후 전체 빌드를 실행하여 모듈 의존성이 올바른지 확인하세요.
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java-29-40 (1)

29-40: ⚠️ Potential issue | 🟠 Major

동기 블로킹 호출과 타임아웃 미설정으로 인한 장애 전파 위험이 있다.

kafkaTemplate.send(...).get()은 Kafka 응답까지 스레드를 블로킹한다. Kafka 브로커 지연 시 스케줄러 스레드가 무한 대기하여 후속 이벤트 처리가 중단된다. 또한 타임아웃이 없어 네트워크 장애 시 스레드 리소스가 고갈될 수 있다.

🔧 타임아웃 설정 및 비동기 처리 제안
+import java.util.concurrent.TimeUnit;
+
+private static final int SEND_TIMEOUT_SECONDS = 5;
+private static final int MAX_EVENTS_PER_POLL = 100;
...
-        List<OutboxEvent> events = outboxEventRepository.findByStatusInit();
+        List<OutboxEvent> events = outboxEventRepository.findByStatusInit(MAX_EVENTS_PER_POLL);
...
             try {
-                kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get();
+                kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
+                        .get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
                 event.markPublished();
🤖 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/OutboxRelay.java`
around lines 29 - 40, The loop in OutboxRelay synchronously blocks on
kafkaTemplate.send(...).get(), risking scheduler hangs; replace the blocking
call with an asynchronous send using the ListenableFuture returned by
kafkaTemplate.send(topic, key, payload) and attach addCallback to
markPublished()/markFailed() and persist via outboxEventRepository.save(event)
inside the success/failure callbacks; if you retain any blocking wait, use
future.get(timeout, TimeUnit...) with a configurable timeout and handle
TimeoutException to markFailed() to avoid indefinite blocking.
apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java-24-37 (1)

24-37: ⚠️ Potential issue | 🟠 Major

배치 처리 및 부분 실패에 대한 트랜잭션 격리가 필요하다.

  1. 대량 주문 만료 시 장기 트랜잭션 문제: findExpiredOrders()가 반환하는 주문이 수백 건 이상일 경우, 단일 트랜잭션 내에서 모든 상태 업데이트를 처리하므로 DB 커넥션 점유 시간이 증가하고 락 경합이 발생할 수 있다.

  2. 부분 실패 시 전체 롤백: 루프 중간에 예외 발생 시 이미 처리된 주문들도 함께 롤백되어 다음 스케줄러 실행 때 재처리가 필요해진다. 재처리 중에도 동일 문제가 발생하면 무기한 지연될 수 있다.

배치 단위로 처리하고 각 주문마다 개별 트랜잭션을 분리하여 부분 실패를 격리하도록 개선한다.

🔧 개별 트랜잭션 분리 및 예외 처리
     `@Scheduled`(fixedDelay = 60000)
-    `@Transactional`
     public void expireUnpaidOrders() {
-        List<Orders> expiredOrders = orderService.findExpiredOrders();
+        List<Orders> expiredOrders = orderService.findExpiredOrders();
         if (expiredOrders.isEmpty()) {
             return;
         }

         for (Orders order : expiredOrders) {
-            orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED);
-            eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId()));
-            log.info("주문 만료 처리 - orderId: {}", order.getId());
+            try {
+                expireSingleOrder(order);
+            } catch (Exception e) {
+                log.error("주문 만료 처리 실패 - orderId: {}", order.getId(), e);
+            }
         }
     }
+
+    `@Transactional`
+    private void expireSingleOrder(Orders order) {
+        orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED);
+        eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId()));
+        log.info("주문 만료 처리 - orderId: {}", order.getId());
+    }
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java-29-41 (1)

29-41: ⚠️ Potential issue | 🟠 Major

commerce-api의 CouponIssueStatus enum 값과 동기화 메커니즘이 필요하다.

동일한 coupon_issue_request 테이블을 두 모듈에서 다른 방식으로 매핑하고 있다.

  • commerce-api: CouponIssueStatus enum (EnumType.STRING)
  • commerce-streamer: String 상수 ("ISSUED", "REJECTED")

streamer가 "ISSUED" 문자열로 상태를 갱신할 때, CouponIssueStatus.ISSUED.name()이 변경되면 commerce-api에서 읽은 데이터를 enum으로 역직렬화할 수 없어 런타임 오류가 발생한다. enum 값 변경 시 양쪽 모듈의 상수가 자동으로 동기화되거나, 최소한 enum 값과 streamer 상수가 일치하는지 검증하는 테스트를 추가하라.

구체적 조치
  1. 공유 모듈에 enum을 정의하고 양쪽 모듈에서 참조
  2. 또는 streamer의 상수 값이 enum 값과 항상 일치하는지 확인하는 통합 테스트 추가
🤖 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/entity/CouponIssueRequestStreamerEntity.java`
around lines 29 - 41, The streamer entity CouponIssueRequestStreamerEntity
currently uses String constants STATUS_ISSUED/STATUS_REJECTED and methods
markIssued()/markRejected(), which can drift from commerce-api's
CouponIssueStatus enum; either replace the String status field with the shared
enum type (use the common CouponIssueStatus enum and EnumType.STRING mapping)
and update markIssued()/markRejected() to assign
CouponIssueStatus.ISSUED/REJECTED, or add an automated test that asserts
STATUS_ISSUED.equals(CouponIssueStatus.ISSUED.name()) and
STATUS_REJECTED.equals(CouponIssueStatus.REJECTED.name()) at startup to fail
fast if they diverge. Ensure references to CouponIssueRequestStreamerEntity,
status, STATUS_ISSUED, STATUS_REJECTED, markIssued(), and markRejected() are
updated accordingly.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java-41-79 (1)

41-79: ⚠️ Potential issue | 🟠 Major

정리 경로와 대기 시간이 묶여 있지 않아 CI가 멈추거나 다음 테스트를 오염시킬 수 있다.

운영 관점에서 latch.await()의 무한 대기와 메서드 마지막 정리 코드는 중간 실패 시 thread pool과 Redis 키를 남겨 플래키 테스트를 만든다. 수정안은 await에 timeout을 두고, ExecutorService 종료와 redisTemplate.delete(redisKey)try/finally 또는 @AfterEach로 보장하며, 가능하면 키도 테스트별 suffix로 분리하는 것이다. 추가 테스트로 동일 테스트를 연속 재실행하거나 병렬 실행했을 때 이전 실행의 잔여 상태 없이 항상 독립적으로 통과하는지 검증해야 한다.

Also applies to: 87-103

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

In
`@apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java`
around lines 41 - 79, The test can hang and leak resources because latch.await()
is unbounded and cleanup (executor shutdown and redisTemplate.delete(redisKey))
runs only after a successful await; modify FirstComeCouponConcurrencyTest to
call latch.await(timeout, unit) with a reasonable timeout, and wrap
executor.shutdown()/executor.awaitTermination(...) and
redisTemplate.delete(redisKey) in a try/finally (or move cleanup to `@AfterEach`)
so the thread pool is always terminated and the Redis key always deleted even on
failures; also make redisKey unique per test run (e.g., append a UUID or
test-method-specific suffix) to avoid cross-test contamination and add a
retry/loop test to verify repeated or parallel runs remain independent.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java-61-72 (1)

61-72: ⚠️ Potential issue | 🟠 Major

Redis 스크립트의 예상 밖 반환값을 성공으로 처리하면 fail-open 된다.

현재는 -1-2만 거절하고 그 외 값은 모두 성공으로 간주한다. null이나 잘못된 반환값이 들어오면 실제로 큐에 들어갔는지 모른 채 요청을 통과시켜 과발급으로 이어질 수 있다. 성공은 1만 허용하고 나머지는 fallback 또는 예외로 닫아 두며, redisTemplate.execute()null이나 0을 돌려줄 때 실패 처리되는 테스트를 추가하는 편이 안전하다.

🤖 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/service/FirstComeCouponService.java`
around lines 61 - 72, In FirstComeCouponService where
redisTemplate.execute(FCFS_SCRIPT, List.of(key), ...) is called, do not treat
any return value other than 1 as success: check that result != null and
result.equals(1L) for success, and throw appropriate CoreException for other
cases (including null, 0 or unexpected values) instead of letting them pass;
update handling around FCFS_SCRIPT/redisTemplate.execute to explicitly reject
null/0 and map unknown returns to a safe error path (e.g., BAD_REQUEST or
CONFLICT as appropriate) and add unit tests that assert failure when
redisTemplate.execute returns null or 0 to prevent fail-open behavior.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java-140-166 (1)

140-166: ⚠️ Potential issue | 🟠 Major

"Redis 장애 시 DB Fallback" 시나리오가 실제 fallback을 검증하지 않는다.

현재 테스트는 Redis 예외를 유발하지 않고 정상 경로에서 동일 회원 중복 요청만 확인한다. 장애 경로가 비어 있으면 운영에서 fallbackAddToQueue()의 중복/소진 로직이 깨져도 배포 전에 발견되지 않는다. redisTemplate.execute()를 예외로 유도하거나 fallbackAddToQueue()를 직접 검증해서 duplicate와 sold-out 두 경로를 모두 커버하는 테스트를 추가하는 편이 안전하다. As per coding guidelines, "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/domain/coupon/FirstComeCouponScenarioTest.java`
around lines 140 - 166, The test currently never triggers Redis failure so it
doesn't verify DB fallback; modify or add tests that force
redisTemplate.execute() to throw (or otherwise simulate Redis failure) and then
call firstComeCouponService.addToQueue(...) to exercise
fallbackAddToQueue(templateId, memberId) logic, asserting both duplicate-request
rejection and sold-out behavior; specifically, in Scenario3_Fallback use
FirstComeCoupon.create(...) to build the coupon, stub/mock redisTemplate.execute
to throw a RuntimeException, invoke addToQueue(...) for the first successful DB
fallback claim and assert the second call throws CoreException (duplicate), and
add a separate case that exhausts quantity via fallbackAddToQueue to assert
sold-out is handled. Ensure cleanup still deletes the redisKey.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java-16-31 (1)

16-31: ⚠️ Potential issue | 🟠 Major

이벤트 구성을 생성 시점에 검증해야 한다.

maxQuantity <= 0, startAt == null, endAt == null, startAt >= endAt가 허용되면 잘못된 운영 설정이 도메인 내부에 그대로 들어와 소진 판정 오류나 NullPointerException으로 이어질 수 있다. 생성/복원 팩토리에서 즉시 검증하고, 잘못된 수량·역전된 기간·null 입력이 모두 거절되는 테스트를 추가하는 편이 안전하다. As per coding guidelines, "Java 도메인 모델 리뷰 기준이다. 엔티티/값 객체/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/domain/coupon/model/FirstComeCoupon.java`
around lines 16 - 31, 검증이 빠져 있어 잘못된 도메인 상태가 들어갈 수 있으니 FirstComeCoupon의 private
생성자와 팩토리 메서드(create, reconstruct)에서 입력값을 즉시 검사하도록 수정하세요: startAt와 endAt는
Objects.requireNonNull로 null을 거부하고 startAt.isBefore(endAt) (또는
startAt.compareTo(endAt) < 0)로 기간 역전을 검사하며 maxQuantity는 1 이상의 양수인지 확인해
IllegalArgumentException을 던지도록 하며(reconstruct에서도 동일하게 적용), 이 유효성 검사에 대한 단위 테스트를
추가해 maxQuantity <= 0, null startAt/endAt, startAt >= endAt 케이스들이 거부되는지 검증하세요.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java-34-36 (1)

34-36: ⚠️ Potential issue | 🟠 Major

시작 시각 경계가 제외되어 첫 요청이 탈락할 수 있다.

now.isAfter(startAt)startAt과 정확히 같은 시각을 비활성으로 본다. 선착순 이벤트는 오픈 순간 트래픽이 몰리기 쉬워서, 시작 경계를 포함하지 않으면 정시 요청이 임의로 실패할 수 있다. 시작 조건을 !now.isBefore(startAt)로 바꾸고, now == startAt일 때 활성으로 판단되는 경계 테스트를 추가하는 편이 안전하다.

🤖 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/model/FirstComeCoupon.java`
around lines 34 - 36, The isActive() method in FirstComeCoupon currently uses
now.isAfter(startAt) which treats a request exactly at startAt as inactive;
change the start-bound check to include the boundary by using
!now.isBefore(startAt) (i.e., replace now.isAfter(startAt) with
!now.isBefore(startAt)) so startAt is considered active, and add/adjust a unit
test for FirstComeCoupon.isActive() to assert that a timestamp equal to startAt
returns true; keep the endAt check as-is or ensure its intended exclusivity is
documented.
apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java-31-38 (1)

31-38: ⚠️ Potential issue | 🟠 Major

일반 info 로그에 사용자 식별자를 그대로 남기고 있다.

memberId를 일반 운영 로그에 남기면 로그 수집기와 장기 보관 경로로 사용자 식별자가 그대로 확산된다. 감사 목적이 필요하면 별도 audit 로그로 분리하거나 마스킹/해시된 값만 남기고, 일반 info 로그에서는 제거하는 편이 안전하다. 로그 캡처 테스트로 favorite 이벤트 처리 시 원본 memberId가 일반 로그에 출력되지 않는지 확인하라. As per coding guidelines, "로깅 시 민감정보 노출 가능성을 점검한다."

Also applies to: 44-51

🤖 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/FavoriteEventListener.java`
around lines 31 - 38, The info logs in FavoriteEventListener (the log.info calls
that reference event.memberId() around the product like handling and the later
block 44-51) are emitting raw member identifiers; remove the raw memberId from
general info logs and instead log either a non-reversible masked/hashed
representation (e.g., hash(event.memberId())) or omit it entirely, and if you
need the original for auditing send it to a separate audit logger or secure
store; update the log lines that currently read event.memberId() to use the
masked/hash value or remove that field, and add/adjust a log-capture test to
assert that the original memberId does not appear in regular info logs while
ensuring OutboxEvent.create / outboxEventRepository.save behavior remains
correct if audit retention is required.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java-28-29 (1)

28-29: ⚠️ Potential issue | 🟠 Major

무기한 로컬 캐시가 쿠폰 설정을 영구히 stale 하게 만든다.

한 번 조회한 FirstComeCoupon은 TTL도 eviction도 없어 기간 변경, 수량 조정, 이벤트 종료가 반영되지 않는다. 운영 중 설정이 수정돼도 애플리케이션 재시작 전까지 오래된 startAt/endAt/maxQuantity로 계속 판정해 잘못된 발급 또는 거절이 발생한다. 최소한 수정·삭제 시 명시적 eviction을 넣거나 TTL이 있는 캐시로 교체하고, 설정 변경 후 getByTemplateId()가 최신 값을 다시 읽는 테스트를 추가하는 편이 안전하다. Based on learnings, "Only favor full invalidation when it is intentional and documented; otherwise consider implementing selective eviction by key."

Also applies to: 47-51

🤖 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/service/FirstComeCouponService.java`
around lines 28 - 29, The local ConcurrentHashMap cache field in
FirstComeCouponService (cache) causes stale FirstComeCoupon entries because
there is no TTL or eviction; modify the service to either replace the map with a
TTL-enabled cache (e.g., Caffeine or Guava cache) or add explicit eviction calls
in update/delete flows so changes to startAt/endAt/maxQuantity are immediately
reflected; ensure getByTemplateId() reloads from the source when a cache miss or
after eviction, and add a unit/integration test that updates a coupon setting
then calls getByTemplateId() to assert the new values are returned.
apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java-28-59 (1)

28-59: ⚠️ Potential issue | 🟠 Major

원본 트랜잭션 커밋 후 outbox 저장으로 인한 내구성 문제를 개선하다.

이 리스너는 원본 favorite 변경 트랜잭션이 끝난 후 @TransactionalEventListener(AFTER_COMMIT)으로 구동되고, 새로운 트랜잭션(REQUIRES_NEW)에서 좋아요 집계와 outbox 저장을 수행한다. outboxEventRepository.save() 또는 toJson() 과정에서 예외가 발생하면 listener 트랜잭션은 롤백되지만, 이미 커밋된 원본 favorite 레코드는 그대로 남는다. 결과적으로 outbox 테이블에는 아무 기록이 남지 않아 OutboxRelay는 재시도할 근거를 잃고, 좋아요 카운트 업데이트와 카프카 이벤트 발행이 둘 다 유실되므로 수작업 복구가 필요해진다.

수정안: outbox 저장을 FavoriteFacade의 원본 트랜잭션 내에서 먼저 내구화하거나, 좋아요 카운트 증감과 행동 로그를 OutboxRelay 소비 측으로 옮겨 리스너 실패가 재시도 가능하게 구성하다. 나아가 outboxEventRepository.save() 또는 toJson() 실패를 의도적으로 발생시켰을 때 favorite와 파생 상태(outbox, 집계, 이벤트)가 영구 불일치로 남지 않는 통합 테스트를 추가하다.

또한 memberId는 사용자 식별자이므로 로그 라인 31, 38, 44, 51에서 INFO 수준으로 노출되지 않도록 제거하거나 WARN/ERROR 컨텍스트로 제한하다.

🤖 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/FavoriteEventListener.java`
around lines 28 - 59, The listener persistence is vulnerable because
onFavoriteAdded/onFavoriteRemoved (FavoriteEventListener) perform
outboxEventRepository.save(...) and toJson(...) in a REQUIRES_NEW listener
transaction after the original favorite transaction commits, which can leave
favorite and outbox/aggregation permanently inconsistent; fix by moving the
outboxEventRepository.save(...) (and serialization via toJson(...)) into the
original favorite transaction (e.g., FavoriteFacade where the favorite
create/delete occurs) so the outbox is durably persisted before commit, or
alternatively move productService.increaseLikeCount/decreaseLikeCount and
user-action logging into the OutboxRelay consumer so that the listener only
enqueues an outbox record and retries are possible; add integration tests that
simulate failures in outboxEventRepository.save() and toJson() to assert no
permanent divergence between favorite, outbox, and aggregated counts; also
remove memberId from INFO-level log lines in FavoriteEventListener (the
log.info(...) calls in onFavoriteAdded/onFavoriteRemoved) or demote them to
WARN/ERROR as appropriate.
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java-93-97 (1)

93-97: ⚠️ Potential issue | 🟠 Major

checkPaymentStatus()에서 handleCallback() 직접 호출 시 트랜잭션 경계 무시 문제

checkPaymentStatus()@Transactional 없이 handleCallback()을 직접 호출하므로 Spring 프록시를 거치지 않는다. 결과적으로 handleCallback()@Transactional(rollbackFor = Exception.class) 경계가 무시되어 다음 작업들이 원자적으로 보장되지 않는다:

  • 결제 상태 변경 (line 74, 78)
  • 주문 상태 변경 (line 75, 79)
  • 재고 복원 (line 80)

예를 들어 결제 성공 시 markSuccess()는 성공하지만 updateOrderStatus()가 실패하면 결제는 완료 상태인데 주문은 CREATED 상태로 남아 운영에서 수기 복구가 필요하다. 재고 복원도 마찬가지로 일부만 성공할 수 있다.

해결 방안:

  1. handleCallback()을 별도 @Component로 분리하여 Spring 프록시를 통해 호출하거나
  2. checkPaymentStatus() 자체를 @Transactional로 감싸거나
  3. ApplicationContext.getBean()으로 프록시된 빈을 획득하여 호출

추가 개선:
중간 단계 예외 시 전체 롤백되는 통합 테스트를 추가한다. 현재 checkPaymentStatus_pgSuccess() 테스트는 성공 경로만 검증하며 트랜잭션 경계나 부분 실패 시나리오를 다루지 않는다.

🤖 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 93 - 97, checkPaymentStatus() currently calls handleCallback()
directly so Spring’s `@Transactional` on handleCallback() is bypassed; either make
checkPaymentStatus() `@Transactional` so the whole flow (markSuccess(),
updateOrderStatus(), restoreInventory()) runs in one transaction, or obtain and
call the proxied PaymentFacade bean
(ApplicationContext.getBean(PaymentFacade.class)) / move handleCallback() into a
separate `@Component` and call that bean to ensure Spring proxying; update
references to handleCallback(), checkPaymentStatus(), markSuccess(),
updateOrderStatus() accordingly and add an integration test that simulates a
mid-flow failure to verify the whole operation rolls back.
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-43-50 (1)

43-50: ⚠️ Potential issue | 🟠 Major

PG 실패 처리 시 부분 롤백 위험

handlePgFailureupdateOrderStatus가 동일한 REQUIRES_NEW 트랜잭션 내에서 실행된다. 만약 updateOrderStatus에서 예외가 발생하면 handlePgFailure의 변경도 함께 롤백되어 결제 상태와 주문 상태 간 불일치가 발생할 수 있다.

PG 실패 처리는 반드시 완료되어야 하는 작업이므로, 주문 상태 업데이트 실패가 결제 상태 업데이트를 롤백시키면 안 된다. 두 작업을 분리하거나, 주문 상태 업데이트 실패 시 재시도 로직을 추가해야 한다.

제안 수정
         if (!info.hasTransactionKey()) {
             paymentService.handlePgFailure(event.paymentId());
-            orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_FAILED);
+            try {
+                orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_FAILED);
+            } catch (Exception e) {
+                log.error("주문 상태 업데이트 실패 - orderId: {}, 재시도 필요", event.orderId(), e);
+                // 결제 실패는 이미 저장되었으므로 별도 보상 트랜잭션 또는 스케줄러로 처리
+            }
             log.warn("PG 결제 요청 실패 - paymentId: {}", event.paymentId());
             return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java`
around lines 43 - 50, The current PG-failure branch risks rolling back payment
state if orderService.updateOrderStatus throws; to fix, ensure
paymentService.handlePgFailure is executed in its own committed transaction
before attempting order updates and make updateOrderStatus resilient: invoke
paymentService.handlePgFailure via an explicit new-transaction boundary (e.g.,
call a separate bean/method annotated with REQUIRES_NEW or use
TransactionTemplate) and only after it completes call
orderService.updateOrderStatus inside a try/catch that logs failures and
enqueues/retries the order status update instead of allowing its exception to
propagate; reference PaymentEventListener, paymentService.handlePgFailure, and
orderService.updateOrderStatus to locate the code to change.
🟡 Minor comments (8)
apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java-3-3 (1)

3-3: ⚠️ Potential issue | 🟡 Minor

생성 이벤트에 도메인 불변식 검증이 누락되었다.

운영 관점에서 orderId/memberId null 또는 totalPrice 음수 이벤트는 정산/집계 오염과 재처리 비용을 만든다. compact constructor에서 입력을 검증하도록 수정해야 한다. 추가 테스트로 null id, 음수 금액 케이스 예외를 포함해야 한다.

수정안 예시
 public record OrderCreatedEvent(Long orderId, Long memberId, int totalPrice) {
+    public OrderCreatedEvent {
+        if (orderId == null || memberId == null) {
+            throw new IllegalArgumentException("orderId/memberId must not be null");
+        }
+        if (totalPrice < 0) {
+            throw new IllegalArgumentException("totalPrice must be non-negative");
+        }
+    }
 }

As per coding guidelines **/domain/**/*.java: 도메인 모델의 불변성과 캡슐화를 점검한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java`
at line 3, The OrderCreatedEvent record lacks domain invariants; add a compact
constructor in OrderCreatedEvent to validate inputs: requireNonNull(orderId) and
requireNonNull(memberId) and ensure totalPrice >= 0 (throw
IllegalArgumentException for negative price), so invalid events cannot be
created; update unit tests to assert that constructing OrderCreatedEvent with
null orderId, null memberId, or a negative totalPrice throws the appropriate
exception.
apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java-71-78 (1)

71-78: ⚠️ Potential issue | 🟡 Minor

알 수 없는 PG 상태값 수신 시 로깅이 누락되어 있다.

IllegalArgumentException 발생 시 PgPaymentStatus.FAILED로 폴백하지만 원본 상태값과 예외를 로깅하지 않는다. PG사가 새로운 상태값을 추가하거나 예기치 않은 값을 반환할 경우, 운영팀이 이를 인지하지 못하고 정상 결제가 실패로 처리될 수 있다.

운영 관점: 장애 탐지 및 원인 분석이 어려워진다.

수정안
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 `@RequiredArgsConstructor`
 `@Component`
 public class PgPaymentAdapter implements PaymentGateway {
         if (response.status() != null) {
             try {
                 pgStatus = PgPaymentStatus.valueOf(response.status());
             } catch (IllegalArgumentException e) {
+                log.warn("Unknown PG status received: {}, treating as FAILED", response.status());
                 pgStatus = PgPaymentStatus.FAILED;
             }
         }

추가 테스트: 알 수 없는 상태값 입력 시 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/infrastructure/pg/PgPaymentAdapter.java`
around lines 71 - 78, PgPaymentAdapter currently swallows
IllegalArgumentException when mapping response.status() to PgPaymentStatus,
losing the original value and exception details; update the try/catch around
PgPaymentStatus.valueOf(response.status()) to log the unknown status and
exception (including response.status() and e) via the class logger before
falling back to PgPaymentStatus.FAILED, and add a unit test that supplies an
unknown status string to verify the method returns FAILED and that the log entry
is produced/handled.
apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java-58-59 (1)

58-59: ⚠️ Potential issue | 🟡 Minor

ApplicationEventPublisher mock이 추가되었으나 이벤트 발행 검증이 누락되어 있다.

OrderFacade.createOrder()OrderCreatedEvent를 발행한다면, 해당 이벤트가 올바른 파라미터로 발행되었는지 검증해야 한다. 현재 mock만 선언되어 있고 verify()가 없어 이벤트 발행 로직이 변경되어도 테스트가 실패하지 않는다.

운영 관점: 이벤트 발행 누락 시 Outbox에 기록되지 않아 후속 처리(결제 요청 등)가 동작하지 않을 수 있다.

수정안 (createOrder 성공 테스트에 추가)
+import com.loopers.domain.event.OrderCreatedEvent;
+import org.mockito.ArgumentCaptor;

 // ... in createsOrder_withMultipleProducts test
 assertThat(captured.orderProducts()).hasSize(2);
+
+ArgumentCaptor<OrderCreatedEvent> eventCaptor = ArgumentCaptor.forClass(OrderCreatedEvent.class);
+verify(eventPublisher).publishEvent(eventCaptor.capture());
+assertThat(eventCaptor.getValue().orderId()).isEqualTo(1L);

추가 테스트: 주문 생성 실패 시 이벤트가 발행되지 않음을 검증하는 테스트도 추가한다.

🤖 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 58 - 59, The test declares a mock ApplicationEventPublisher but
never asserts event publication; update the successful createOrder test to
verify eventPublisher.publishEvent(...) was called with an OrderCreatedEvent
containing the expected order id/user/amount (use Mockito.verify and an
ArgumentCaptor or Mockito.eq to check the OrderCreatedEvent fields) by
referencing OrderFacade.createOrder() and OrderCreatedEvent, and add a new
failure-case test that calls OrderFacade.createOrder() with invalid input and
asserts eventPublisher.publishEvent(...) was never invoked
(Mockito.verify(eventPublisher, never())).
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java-7-15 (1)

7-15: ⚠️ Potential issue | 🟡 Minor

분산 시스템에서 타임존 처리와 역직렬화 방어를 강화하라.

  1. 타임존 일관성: LocalDateTime은 타임존 정보를 포함하지 않아 분산 환경에서 시간 불일치를 야기할 수 있다. 데이터베이스 레이어(BaseEntity)에서 ZonedDateTime을 사용하므로, Kafka 메시지도 Instant로 통일하여 명확한 시간 정보를 보장하라.

  2. 역직렬화 방어 부족: ObjectMapper.convertValue() 이후 null 체크 없이 필드에 접근하면 NPE가 발생할 수 있다. 라인 43의 event.eventId() 접근 전에 OrderEventMessage의 필수 필드에 대한 유효성 검증을 추가하라.

  3. 에러 로그 민감정보 노출: 라인 34의 log.error() 로깅이 Kafka 레코드 전체(record.value())를 기록하면 메시지 페이로드의 민감정보가 노출될 수 있다. 운영 로그에는 eventIdeventType 등 필요한 정보만 기록하라.

적용 사항:

  • LocalDateTimeInstant 변경
  • 역직렬화 이후 필수 필드 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/dto/OrderEventMessage.java`
around lines 7 - 15, Change the DTO and deserialization checks to avoid timezone
and NPE issues: replace LocalDateTime with Instant in the OrderEventMessage
record declaration, update any places that construct or read that record to use
Instant, and ensure time handling matches the DB layer (BaseEntity/ZonedDateTime
→ convert to Instant). After calling ObjectMapper.convertValue(...) to map the
Kafka payload to OrderEventMessage, add a null/validity check for required
fields (e.g., event.eventId(), event.eventType(), orderId) before using them and
return or handle errors if missing. Finally, avoid logging the entire Kafka
record on errors; change the log.error(...) calls to only include non-sensitive
identifiers such as record.key(), eventId and eventType (or a short ordinal)
instead of record.value().
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java-56-69 (1)

56-69: ⚠️ Potential issue | 🟡 Minor

운영 환경에서 replicas(1)은 데이터 유실 위험이 있다.

replicas(1) 설정은 단일 브로커 장애 시 해당 파티션의 데이터가 유실된다. 특히 order-eventscoupon-issue-requests는 결제/쿠폰 발급과 관련된 중요 이벤트이므로 운영 환경에서는 최소 replicas(3)이 권장된다.

또한 토픽명이 문자열로 분산되어 있어 OutboxRelay.resolveTopic() 등과 불일치 시 런타임 오류가 발생한다.

🔧 토픽 상수 중앙화 및 환경별 replica 설정 제안
public static final String TOPIC_CATALOG_EVENTS = "catalog-events";
public static final String TOPIC_ORDER_EVENTS = "order-events";
public static final String TOPIC_COUPON_ISSUE_REQUESTS = "coupon-issue-requests";

// 또는 application.yml에서 주입
`@Value`("${kafka.replication-factor:1}")
private int replicationFactor;

`@Bean`
public NewTopic catalogEventsTopic() {
    return TopicBuilder.name(TOPIC_CATALOG_EVENTS)
            .partitions(3)
            .replicas(replicationFactor)
            .build();
}
🤖 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/KafkaConfig.java` around
lines 56 - 69, The three topic beans (catalogEventsTopic, orderEventsTopic,
couponIssueRequestsTopic) currently hardcode replicas(1) and literal topic
names; change them to use centralized topic name constants (e.g.,
TOPIC_CATALOG_EVENTS, TOPIC_ORDER_EVENTS, TOPIC_COUPON_ISSUE_REQUESTS) and
replace replicas(1) with a configurable replicationFactor (inject from
application properties, defaulting to 1) so you can set replicas=3 in
production; also ensure the constants match the names used by
OutboxRelay.resolveTopic() to avoid runtime mismatches.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java-8-19 (1)

8-19: ⚠️ Potential issue | 🟡 Minor

CouponIssueRequestEntitycouponTemplateId 컬럼에 인덱스가 누락되었다.

countByTemplateId()는 선착순 쿠폰 발급 시 최대 수량 검증을 위해 호출되는 핵심 경로이다. 현재 CouponIssueRequestEntitycouponTemplateId@Index 애노테이션이 없으며, 프로덕션 환경에서 ddl-auto: none으로 설정되어 수동 DDL 관리를 한다. 따라서 대량 데이터 누적 시 이 쿼리가 풀 테이블 스캔을 수행하여 응답 시간이 급격히 증가할 수 있다.

조치:

  1. CouponIssueRequestEntity@Index(name = "idx_coupon_template_id", columnList = "couponTemplateId") 추가
  2. 프로덕션 데이터베이스에 인덱스 생성 DDL 마이그레이션 스크립트 작성 및 적용
  3. 쿼리 실행 계획(EXPLAIN)으로 인덱스 사용 여부 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java`
around lines 8 - 19, CouponIssueRequestEntity is missing an index on
couponTemplateId which makes the repository method countByTemplateId(...)
perform full table scans under load; add `@Index`(name = "idx_coupon_template_id",
columnList = "couponTemplateId") to the CouponIssueRequestEntity class (on the
`@Table` annotation or equivalent), create and apply a production DB migration
script that creates the index (CREATE INDEX idx_coupon_template_id ON ...
(couponTemplateId)), and verify the repository query uses the index by running
EXPLAIN on the countByTemplateId query to confirm the index is used.
apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java-190-193 (1)

190-193: ⚠️ Potential issue | 🟡 Minor

테스트 전용 Repository 메서드에 명시적 문서화 필요

updateTransactionKeyAndStatus는 현재 테스트 코드에서만 사용되는 메서드다. 프로덕션 코드에서 의도하지 않은 사용을 방지하고 유지보수 시 혼동을 줄이기 위해, 메서드 정의(PaymentJpaRepository 라인 20)에 @Deprecated 또는 Javadoc 주석을 추가하여 테스트 전용임을 명시해야 한다.

/**
 * 테스트 목적으로만 사용. PG 응답 시뮬레이션 시 결제 상태를 변경한다.
 * `@deprecated` 테스트 전용 메서드
 */
`@Deprecated`(since = "1.0", forRemoval = true)
int updateTransactionKeyAndStatus(`@Param`("id") Long id, `@Param`("transactionKey") String transactionKey, `@Param`("status") PaymentStatus status);

이를 통해 향후 리팩토링 시 불필요한 메서드 제거 판단을 명확히 할 수 있다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java`
around lines 190 - 193, The repository exposes a test-only method
updateTransactionKeyAndStatus used in the test (PaymentFacadeIntegrationTest)
which should be explicitly marked to avoid accidental production use; edit the
method declaration in PaymentJpaRepository to add a Javadoc stating it is for
test purposes only and annotate it with `@Deprecated` (include since/forRemoval
metadata if desired) so callers and future refactors clearly see it is
test-only.
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-69-73 (1)

69-73: ⚠️ Potential issue | 🟡 Minor

결제 실패 이벤트의 Outbox 저장 누락

onPaymentCompleted는 Outbox에 이벤트를 저장하여 Kafka로 전파하지만, onPaymentFailed는 로그만 남긴다. 이 비대칭 처리가 의도적인지 확인이 필요하다.

결제 실패 정보도 downstream 시스템(예: 알림 서비스, 분석 시스템)에 전파해야 하는 경우가 많다. 만약 의도적으로 제외한 것이라면 주석으로 이유를 명시해야 한다. 그렇지 않다면 onPaymentCompleted와 동일하게 Outbox 저장을 추가해야 한다.

제안 수정 (Outbox 저장 추가 시)
     `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+    `@Transactional`(propagation = Propagation.REQUIRES_NEW)
     public void onPaymentFailed(PaymentFailedEvent event) {
         log.info("결제 실패 이벤트 - orderId: {}, paymentId: {}, reason: {}", event.orderId(), event.paymentId(), event.reason());
+        outboxEventRepository.save(OutboxEvent.create(
+                OutboxEventType.PAYMENT_FAILED,
+                event.orderId(),
+                toJson(event)
+        ));
         log.info("유저 행동 로깅 - action: PAYMENT_FAILED, orderId: {}", event.orderId());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java`
around lines 69 - 73, onPaymentFailed currently only logs the failure but does
not persist an Outbox entry like onPaymentCompleted, causing asymmetric
downstream behavior; either add the same Outbox save logic used in
onPaymentCompleted (create an Outbox event payload for PAYMENT_FAILED and
persist via the same Outbox repository/service used by onPaymentCompleted)
inside PaymentEventListener.onPaymentFailed(PaymentFailedEvent) so the failure
is emitted to Kafka, or if omission is intentional add a clear comment
explaining why failures should not be propagated; reference the methods
onPaymentFailed, onPaymentCompleted and the Outbox persistence component
(OutboxRepository/OutboxService or equivalent) when making the change.

Comment on lines +50 to +70
@Transactional(rollbackFor = Exception.class)
public CouponIssueRequestResDto requestFirstComeIssue(String loginId, String password, Long couponTemplateId) {
Member member = memberService.findMember(loginId, password);
FirstComeCoupon fcCoupon = firstComeCouponService.getByTemplateId(couponTemplateId);

firstComeCouponService.addToQueue(fcCoupon, member.getId());
try {
CouponIssueRequest request = couponIssueRequestRepository.save(
CouponIssueRequest.create(couponTemplateId, member.getId()));

kafkaTemplate.send("coupon-issue-requests",
String.valueOf(couponTemplateId),
Map.of("requestId", request.getId(),
"couponTemplateId", couponTemplateId,
"memberId", member.getId()));

return CouponIssueRequestResDto.from(request);
} catch (Exception e) {
firstComeCouponService.removeFromQueue(couponTemplateId, member.getId());
throw 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

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "CouponFacade.java" | head -5

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

Length of output: 167


🏁 Script executed:

find . -type f -name "FirstComeCouponService.java" | head -5

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

Length of output: 180


🏁 Script executed:

find . -type f -name "CouponIssueRequestRepository.java" | head -5

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

Length of output: 189


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java

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

Length of output: 4579


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java

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

Length of output: 4696


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java

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

Length of output: 764


Redis/DB/Kafka 상태 불일치로 FCFS 요청이 유실될 수 있다.

운영 중 Kafka 브로커 장애나 프로세스 종료 시 Redis 대기열 등록, 요청 row 저장, Kafka 발행이 서로 다른 시스템에 분산되어 상태 일관성이 깨진다. 특히 kafkaTemplate.send()는 비동기이고 반환값(ListenableFuture)을 사용하지 않아 브로커 ack 실패가 현재 catch 블록으로 유입되지 않으므로 removeFromQueue()도 실행되지 않는다. 그 결과 Redis에는 요청자 정보가 남아 있고 DB에도 요청 row가 남아 있으나 Kafka에는 발행되지 않는 상태가 발생한다.

수정안: 이 메서드에서 Kafka를 직접 호출하지 말고 요청 저장과 함께 outbox 테이블 행을 동일한 DB 트랜잭션 내에 기록한 뒤 별도 relay 프로세스가 outbox에서 읽어 Kafka로 발행하는 구조로 변경한다. 추가 테스트로 kafkaTemplate.send()가 비동기 콜백에서 예외 발생하는 경우, DB 트랜잭션 커밋 실패 시나리오, Redis 장애 시 fallback 경로를 각각 주입하여 요청 row/Redis 대기열/outbox 행/발행 여부가 함께 일관되게 유지되는지 검증해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java`
around lines 50 - 70, The requestFirstComeIssue method currently calls
kafkaTemplate.send asynchronously after saving a CouponIssueRequest and adding
the member to Redis queue (firstComeCouponService.addToQueue), which can leave
Redis/DB/Kafka inconsistent; instead remove the direct kafkaTemplate.send call
and write an Outbox row (e.g., OutboxEvent entity) in the same DB transaction
alongside couponIssueRequestRepository.save so the event is persisted
atomically, keep firstComeCouponService.removeFromQueue in error handling, and
implement a separate relay process to read Outbox rows and publish to Kafka; add
tests that simulate kafka send failures, DB commit failures, and Redis failures
to verify the queue entry, request row, outbox row and eventual publish are kept
consistent.

Comment on lines +35 to +43
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderCreated(OrderCreatedEvent event) {
log.info("주문 생성 이벤트 - orderId: {}, memberId: {}, totalPrice: {}", event.orderId(), event.memberId(), event.totalPrice());
outboxEventRepository.save(OutboxEvent.create(
OutboxEventType.ORDER_CREATED,
String.valueOf(event.orderId()),
toJson(event)
));
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:

find . -name "OrderEventListener.java" -type f

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

Length of output: 172


🏁 Script executed:

find . -name "*Outbox*" -type f | head -20

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

Length of output: 839


🏁 Script executed:

git ls-files "apps/commerce-api" | grep -E "(Event|Outbox)" | head -20

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

Length of output: 1659


🏁 Script executed:

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

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

Length of output: 3805


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java

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

Length of output: 1760


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java

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

Length of output: 426


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java

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

Length of output: 1429


🏁 Script executed:

rg "publishEvent\|new OrderCreatedEvent\|applicationEventPublisher" --type java -A 3 -B 3 | head -50

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

Length of output: 67


🏁 Script executed:

find . -path "*/order/service/*.java" -type f | head -10

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

Length of output: 261


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java

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

Length of output: 3395


🏁 Script executed:

rg "OrderCreatedEvent" --type java -B 5 -A 2 | head -60

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

Length of output: 4571


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

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

Length of output: 4611


🏁 Script executed:

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

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

Length of output: 4471


🏁 Script executed:

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

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

Length of output: 3211


🏁 Script executed:

rg "OutboxEventType" --type java | head -20

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

Length of output: 2392


🏁 Script executed:

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

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

Length of output: 2173


Outbox를 AFTER_COMMIT에서 저장하면 주문 커밋과 원자성이 끊어진다.

OrderFacade.createOrder()는 주문 저장 후 같은 트랜잭션 내에서 OrderCreatedEvent를 발행한다. 하지만 OrderEventListener.onOrderCreated()는 @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(REQUIRES_NEW)로 설정되어, 주문 커밋 이후 새로운 트랜잭션에서 outbox 행을 저장한다. 운영 중 주문 커밋 직후 JVM이 종료되거나 listener 실행이 실패하면 주문은 이미 생성되었는데 outbox 행이 없어 downstream이 해당 이벤트를 영구히 받지 못한다.

수정안은 outbox 저장을 주문 생성과 같은 트랜잭션 내에서 수행하고, 별도의 OutboxRelay 스케줄러가 이미 존재하므로 outbox 행을 대기 상태로 저장 후 relay가 비동기로 발행하도록 분리하는 것이다. PaymentEventListener의 onPaymentCompleted()와 FavoriteEventListener의 onFavoriteAdded/Removed()도 동일한 패턴을 사용하고 있으므로 함께 수정해야 한다. 추가 테스트로 outbox 저장 실패를 주입했을 때 주문과 outbox가 함께 rollback되어 주문만 있고 outbox는 없는 상태가 생기지 않는지 검증해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java`
around lines 35 - 43, The outbox save must happen in the same transaction as the
domain write so remove the AFTER_COMMIT + REQUIRES_NEW pattern in the listeners
and persist outbox rows inside the ordering transaction: update
OrderEventListener.onOrderCreated to save the OutboxEvent within the original
transaction (e.g., change `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) and remove `@Transactional`(propagation =
Propagation.REQUIRES_NEW) — or alternatively invoke outbox persistence directly
from OrderFacade.createOrder right after the order save using
outboxEventRepository.save(OutboxEvent.create(...)) so the outbox and order
share one commit), and apply the same change to
PaymentEventListener.onPaymentCompleted and
FavoriteEventListener.onFavoriteAdded/Removed; add tests that inject an outbox
save failure to verify both the order and outbox are rolled back together and no
“order without outbox” state can occur, and rely on the existing OutboxRelay
scheduler to asynchronously publish persisted outbox rows.

Comment on lines +49 to +63
public void onOrderExpired(OrderExpiredEvent event) {
log.info("주문 만료 처리 - orderId: {}", event.orderId());
try {
List<OrderProduct> products = orderProductService.findByOrderId(event.orderId());
for (OrderProduct op : products) {
productService.increaseStockAtomic(op.getProductId(), op.getQuantity().value());
}
if (event.userCouponId() != null) {
couponService.restoreUserCoupon(event.userCouponId());
}
log.info("주문 만료 복원 완료 - orderId: {}, 재고 복원 {}건, 쿠폰 복원: {}",
event.orderId(), products.size(), event.userCouponId() != null);
} 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

🧩 Analysis chain

🏁 Script executed:

find . -name "OrderEventListener.java" -type f

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

Length of output: 172


🏁 Script executed:

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

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

Length of output: 3805


🏁 Script executed:

# onOrderExpired 메서드 주변 전체 컨텍스트 확인
sed -n '40,70p' apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

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

Length of output: 1495


🏁 Script executed:

# 클래스 선언과 import 확인
head -50 apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

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

Length of output: 2375


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - orderProductService.findByOrderId
find . -name "*OrderProductService*" -type f

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

Length of output: 264


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - productService.increaseStockAtomic
find . -name "*ProductService*" -type f

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

Length of output: 534


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - couponService.restoreUserCoupon
find . -name "*CouponService*" -type f

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

Length of output: 444


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java

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

Length of output: 8742


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java

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

Length of output: 5546


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderProductService.java

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

Length of output: 954


보상 처리 예외를 무시하면 부분 복구가 커밋된다.

현재 REQUIRES_NEW 트랜잭션에서 예외를 catch하여 무시하므로, 루프 내 여러 상품의 재고 복원 중 하나만 실패해도 앞의 성공한 복원들은 그대로 커밋된다. 예를 들어 첫 번째 상품 재고는 복원되고 두 번째 상품 복원이 실패한 경우, 메서드가 정상 종료되어 트랜잭션이 커밋되어 부분 복구 상태가 영구적으로 남는다. 운영 환경에서는 주문 취소 후 일부 재고와 쿠폰만 복구된 불일치 상태가 발생한다.

예외를 전파하여 REQUIRES_NEW 트랜잭션 전체를 롤백하거나, 실패 건을 outbox 이벤트로 기록해 별도 재시도 메커니즘으로 처리해야 한다. 추가로 OrderProduct 반복 중 특정 상품의 재고 복원만 실패하는 시나리오를 테스트하여 어떤 데이터도 부분 반영되지 않는지 검증해야 한다.

🤖 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/OrderEventListener.java`
around lines 49 - 63, The onOrderExpired method in OrderEventListener currently
swallows exceptions which allows partial commits inside the REQUIRES_NEW
transaction; change the error-handling so failures cause the transaction to roll
back or are recorded for retry: either remove the broad try/catch and let
exceptions from productService.increaseStockAtomic or
couponService.restoreUserCoupon propagate so the REQUIRES_NEW transaction
aborts, or catch per-item failures inside the loop and instead persist an
outbox/failure record for the failed product (including orderId, productId,
quantity, error) and then throw a new exception after the loop to trigger
rollback; also add tests for OrderEventListener.onOrderExpired (mock
OrderProductService.findByOrderId and productService.increaseStockAtomic) to
verify that when any increaseStockAtomic throws, no stock/coupon changes are
committed and the outbox entry is created if using the retry approach.

Comment on lines +46 to +50
Orders savedOrder = orderService.createOrder(command);

eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder.getId(), member.getId(), savedOrder.getTotalPrice().value()));

return FindOrderResDto.from(savedOrder);
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: Check OrderEventListener implementation for exception handling

ast-grep --pattern $'@TransactionalEventListener($$$)
$$$
public void $_(OrderCreatedEvent $_) {
  $$$
}'

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

Length of output: 67


🏁 Script executed:

find . -type f -name "*OrderEventListener*"

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

Length of output: 172


🏁 Script executed:

find . -type f -name "*.java" | xargs grep -l "OrderCreatedEvent" | head -20

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

Length of output: 335


🏁 Script executed:

git ls-files "**/*.java" | grep -i listener | grep -i order

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

Length of output: 170


🏁 Script executed:

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

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

Length of output: 3805


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java

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

Length of output: 228


🏁 Script executed:

rg "getTotalPrice" apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java -A 2 -B 2

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

Length of output: 630


🏁 Script executed:

rg "OutboxEvent" apps/commerce-api/src/main/java --type java -l | head -5

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

Length of output: 566


🏁 Script executed:

rg "outbox.*retry|retry.*outbox" apps/commerce-api/src/main/java --type java -i

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

Length of output: 67


onOrderCreated 메서드에 예외 처리가 누락되어 있어 주문 이벤트 전파 실패 시 복구 메커니즘이 없다.

현재 구현에서 toJson() 호출 시 JsonProcessingException이 발생하면 RuntimeException으로 변환되어 던져진다. AFTER_COMMIT 리스너이므로 클라이언트 응답에는 영향을 주지 않지만, Outbox 레코드 저장이 실패하면서 주문 생성 이벤트가 영구 손실된다. 현재 코드에서 재시도 메커니즘이 없으므로 운영상 심각한 문제가 된다.

onOrderExpired 메서드는 try-catch로 예외를 처리하고 있는데, onOrderCreated는 미보호 상태이다. 일관성 있게 예외를 처리하고, Outbox 저장 실패 시 로그를 남기되 다시 던져서 트랜잭션 롤백 후 재시도하도록 개선해야 한다.

추가로 로그 레벨 38, 44번 라인에서 memberId를 노출하고 있다. 사용자 민감정보이므로 필요한 경우만 로깅하고, 외부 모니터링 시스템으로 유출되지 않도록 주의해야 한다.

수정안:

  • onOrderCreated에서 toJson() 또는 저장 실패 시 log.error()로 기록 후 예외 다시 발생
  • Outbox 발행 실패에 대한 재시도 스케줄러 또는 배치 메커니즘 구현
  • memberId 로깅 필요성 재검토 및 최소화

추가 테스트:

  • JSON 직렬화 실패 시 예외 처리 및 트랜잭션 롤백 동작 검증
  • Outbox 저장 실패 후 복구 메커니즘 동작 확인
  • 민감정보 로그 필터링 동작 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 46 - 50, The onOrderCreated listener currently lacks exception
handling around toJson() and Outbox persistence so serialization/save failures
can drop events; wrap the JSON serialization and outbox save in a try-catch
(mirroring onOrderExpired) that logs an error via log.error with
minimal/non-sensitive context (do not log raw memberId) and then rethrows the
exception to force transaction rollback and allow retry, and refactor any log
statements in onOrderCreated that emit memberId to redact or remove that field;
additionally, plan/implement an Outbox retry mechanism (scheduler or batch
re-publisher) to handle persistent failures and add tests verifying that JSON
serialization failures and outbox save failures cause rollback and trigger the
retry flow.

Comment on lines +69 to 82
public void handleCallback(String transactionKey, PgPaymentStatus status) {
Payment payment = paymentService.getPaymentByTransactionKey(transactionKey);

Orders order = orderService.getOrderByOrderNumber(payment.getOrderId());

if ("SUCCESS".equals(status)) {
if (status == PgPaymentStatus.SUCCESS) {
paymentService.markSuccess(transactionKey);
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
eventPublisher.publishEvent(new PaymentCompletedEvent(payment.getOrderId(), payment.getId()));
} else {
paymentService.markFailed(transactionKey, status);
paymentService.markFailed(transactionKey, status.name());
orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED);
paymentService.restoreStock(payment.getId());
eventPublisher.publishEvent(new PaymentFailedEvent(payment.getOrderId(), payment.getId(), status.name()));
}
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

콜백 상태 머신이 열려 있어 중간 상태와 중복 콜백이 모두 오염을 만든다.

현재 분기는 SUCCESS가 아니면 모두 실패 처리하고, 이미 terminal 상태인 결제인지도 확인하지 않는다. PG가 PENDING 또는 재전송 콜백을 보내면 주문 실패 처리, 재고 복원, 실패/완료 이벤트 발행이 중복되어 운영 데이터가 쉽게 틀어진다. switch(status)로 허용 상태를 명시하고 REQUESTED -> SUCCESS/FAILED 전이만 허용하며, PENDING 콜백과 동일 콜백 2회 전송이 모두 no-op 또는 단일 처리로 끝나는 테스트를 추가하는 편이 안전하다.

🤖 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 69 - 82, The handleCallback method currently treats any non-SUCCESS
as failure and doesn't check current payment state, causing duplicate/partial
callbacks (e.g., PENDING or retransmits) to corrupt data; update handleCallback
to use a switch on PgPaymentStatus and only allow transitions from the
terminal-eligible prior state (e.g., REQUESTED -> SUCCESS or REQUESTED ->
FAILED), no-op on PENDING and on callbacks when payment is already terminal, and
ensure idempotency by checking the payment current status via
payment.getStatus() before calling paymentService.markSuccess/markFailed,
orderService.updateOrderStatus, paymentService.restoreStock, or
eventPublisher.publishEvent; also add tests for PENDING callbacks and repeated
SUCCESS/FAILED callbacks to assert no double-processing.

Comment on lines +3 to +8
public enum OutboxEventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
PAYMENT_COMPLETED
}
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
set -euo pipefail

# PaymentFailedEvent 경로와 OutboxEventType 매핑 누락 여부를 정적 점검한다.
rg -nP 'PaymentFailedEvent|PAYMENT_FAILED|OutboxEventType\.[A-Z_]+' apps/commerce-api/src/main/java apps/commerce-streamer/src/main/java

# Outbox 타입 기반 분기/매핑 지점을 확인한다.
rg -nP 'switch\s*\(|if\s*\(|OutboxEventType' apps/commerce-api/src/main/java | head -n 200

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

Length of output: 17371


🏁 Script executed:

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

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

Length of output: 4471


🏁 Script executed:

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

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

Length of output: 2173


결제 실패 이벤트가 Outbox 저장되지 않아 Kafka 전파 누락이 발생한다.

결제 실패 이벤트 흐름에서 세 가지 결함이 확인된다:

  1. PaymentEventListener.onPaymentFailed()는 로그만 기록하고 Outbox 저장을 수행하지 않는다(현재 71~72줄).
  2. OutboxEventType 열거형에 PAYMENT_FAILED 상수가 누락되었다.
  3. OutboxRelay.resolveTopic()의 switch 문에 PAYMENT_FAILED 케이스가 없다(현재 44~47줄).

결과적으로 PaymentFailedEvent는 발행되지만 다운스트림 시스템으로 전파되지 않아, 주문 상태와 결제 상태가 불일치하고 데이터 정합성이 깨진다.

수정 대상:

  1. OutboxEventType 열거형에 PAYMENT_FAILED 추가
  2. PaymentEventListener.onPaymentFailed()에서 Outbox 이벤트 저장 로직 추가 (onPaymentCompleted와 동일한 패턴)
  3. OutboxRelay.resolveTopic()의 switch 문에 PAYMENT_FAILED -> "order-events" 케이스 추가

결제 실패 경로 전체에 대한 통합 테스트(Outbox 저장 및 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/outbox/model/OutboxEventType.java`
around lines 3 - 8, Add a PAYMENT_FAILED enum constant to OutboxEventType;
update PaymentEventListener.onPaymentFailed() to create and persist an Outbox
event the same way onPaymentCompleted() does (same payload/metadata pattern and
persistence calls) instead of only logging; and add a PAYMENT_FAILED ->
"order-events" case in OutboxRelay.resolveTopic()'s switch so the event is
routed to Kafka; finally add an integration test that triggers the
payment-failed flow and asserts the Outbox row is created and the message is
emitted to the "order-events" topic.

Comment on lines +3 to +8
public enum EventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
PAYMENT_COMPLETED
}
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
set -euo pipefail

# 1) API 측 발행 이벤트 타입 확인
rg -n -C2 '\b(OrderExpiredEvent|PaymentFailedEvent|PAYMENT_FAILED|ORDER_EXPIRED)\b' \
  apps/commerce-api/src/main/java

# 2) Streamer 측 수신 모델/라우팅 타입 확인
rg -n -C2 '\bEventType\b|\beventType\b|\bPAYMENT_FAILED\b|\bORDER_EXPIRED\b' \
  apps/commerce-streamer/src/main/java

# 3) 토픽 매핑 확인
rg -n -C2 '\b(order-events|catalog-events|coupon-issue-requests)\b' \
  modules/kafka/src/main/java apps/commerce-api/src/main/java apps/commerce-streamer/src/main/java

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

Length of output: 22891


🏁 Script executed:

# Check the full OutboxRelay.resolveTopic() method and see what happens with unmapped types
rg -n -A 10 'private String resolveTopic' \
  apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java

# Search for OutboxEventType enum definition
fd -t f '*.java' -x grep -l 'enum OutboxEventType' {} \;

# Check if there are any other event type mappings or enums
rg -n 'enum.*EventType' apps/commerce-api/src/main/java
rg -n 'enum.*EventType' apps/commerce-streamer/src/main/java

# Check OrderEventConsumer full switch statement
rg -n -A 20 'switch (event.eventType())' \
  apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java

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

Length of output: 890


🏁 Script executed:

# Get the full OutboxEventType enum definition
cat apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java

# Get the full EventType enum definition (streamer side)
cat apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java

# Get the full OrderEventConsumer switch statement
rg -n -A 30 'switch \(event\.eventType\(\)\)' \
  apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java

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

Length of output: 860


🏁 Script executed:

# Check if PaymentFailedEvent and OrderExpiredEvent are published to Kafka directly anywhere
rg -n 'kafkaTemplate\.send|eventPublisher\.publish' \
  apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java \
  apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

# Check if OutboxRelay handles PaymentFailedEvent or OrderExpiredEvent 
rg -n -B 5 -A 5 'PaymentFailedEvent|OrderExpiredEvent' \
  apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java

# Check how PaymentFailedEvent and OrderExpiredEvent are created and where they're published
rg -n -B 2 -A 2 'new PaymentFailedEvent|new OrderExpiredEvent' \
  apps/commerce-api/src/main/java/com/loopers/application/event/

# Check OutboxRelay to see which events trigger outbox persistence
rg -n -B 10 'onPaymentCompleted|onOrderCreated|onFavorite' \
  apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java | head -50

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

Length of output: 843


🏁 Script executed:

# Check if PaymentFailedEvent or OrderExpiredEvent are supposed to be handled by OutboxRelay
# by looking for event listener methods in OutboxRelay
rg -n 'EventListener|public void on' \
  apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java

# Get the full OutboxRelay file to understand what events it handles
cat apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java

# Check if there's any documentation or PR description about event types to be streamed
git log --oneline --all | head -20
</parameter>

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

Length of output: 2474


이벤트 라우팅 누락으로 결제 실패 및 주문 만료 이벤트가 Kafka로 발행되지 않는다

PaymentFailedEvent와 OrderExpiredEvent는 API에서 발행되지만 OutboxRelay에 리스너가 없어 Kafka에 전달되지 않는다. 더 심각하게는 OutboxRelay.resolveTopic()이 이 두 이벤트 타입을 처리하지 못하면 MatchException이 발생하거나, 향후 이들을 OutboxEventType에 추가해도 Kafka 메시지를 수신한 streamer의 EventType 열거형과 OrderEventConsumer 스위치 문이 일치하지 않아 처리되지 않는다. 이는 결제 실패 및 주문 만료와 같은 중요한 비즈니스 이벤트 손실로 이어진다.

수정안으로는 다음 세 가지가 필요하다:

  1. OutboxEventType 열거형에 PAYMENT_FAILED, ORDER_EXPIRED 추가
  2. EventType 열거형에 PAYMENT_FAILED, ORDER_EXPIRED 추가
  3. OutboxRelay에 @TransactionalEventListener 메서드 추가하여 PaymentFailedEvent와 OrderExpiredEvent를 OutboxEvent로 저장
  4. OrderEventConsumer의 스위치 문에 PAYMENT_FAILED, ORDER_EXPIRED 케이스 추가

통합 테스트로는 주문 결제 실패 시나리오와 주문 만료 스케줄러 실행 후 이 두 이벤트가 Kafka의 order-events 토픽에 메시지로 나타나는지, streamer에서 정상 처리되는지 검증해야 한다.

수정 예시

OutboxEventType:

 public enum OutboxEventType {
     FAVORITE_ADDED,
     FAVORITE_REMOVED,
     ORDER_CREATED,
-    PAYMENT_COMPLETED
+    ORDER_EXPIRED,
+    PAYMENT_COMPLETED,
+    PAYMENT_FAILED
 }

EventType:

 public enum EventType {
     FAVORITE_ADDED,
     FAVORITE_REMOVED,
     ORDER_CREATED,
-    PAYMENT_COMPLETED
+    ORDER_EXPIRED,
+    PAYMENT_COMPLETED,
+    PAYMENT_FAILED
 }

OutboxRelay:

     `@Scheduled`(fixedDelay = 1000)
     public void relay() {
         ...
     }

+    `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+    public void onPaymentFailed(PaymentFailedEvent event) {
+        OutboxEvent outbox = OutboxEvent.create(event.getOrderId(), OutboxEventType.PAYMENT_FAILED, ...);
+        outboxEventRepository.save(outbox);
+    }
+
+    `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+    public void onOrderExpired(OrderExpiredEvent event) {
+        OutboxEvent outbox = OutboxEvent.create(event.orderId(), OutboxEventType.ORDER_EXPIRED, ...);
+        outboxEventRepository.save(outbox);
+    }

OrderEventConsumer:

         switch (event.eventType()) {
             case ORDER_CREATED ->
                 log.info("주문 생성 이벤트 수신 - orderId: {}, memberId: {}", event.orderId(), event.memberId());
             case PAYMENT_COMPLETED ->
                 log.info("결제 완료 이벤트 수신 - orderId: {}", event.orderId());
+            case PAYMENT_FAILED ->
+                log.info("결제 실패 이벤트 수신 - orderId: {}", event.orderId());
+            case ORDER_EXPIRED ->
+                log.info("주문 만료 이벤트 수신 - orderId: {}", event.orderId());
             default -> {}
         }
📝 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
public enum EventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
PAYMENT_COMPLETED
}
public enum EventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
ORDER_EXPIRED,
PAYMENT_COMPLETED,
PAYMENT_FAILED
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java`
around lines 3 - 8, EventType 열거형에 PAYMENT_FAILED와 ORDER_EXPIRED 값을 추가하고(수정 대상:
EventType), 동일한 값들을 OutboxEventType 열거형에도 추가하여 OutboxRelay.resolveTopic에서 매칭되도록
하세요. OutboxRelay 클래스에 `@TransactionalEventListener` 메서드를 추가해 PaymentFailedEvent와
OrderExpiredEvent를 수신하면 새로운 OutboxEvent(적절한 OutboxEventType)를 생성·저장하도록 구현하고, 저장
시 사용하는 엔티티/생성자 로직이 기존 OutboxEvent 구조와 일치하는지 확인하세요. 또한 OrderEventConsumer의 switch
문에 PAYMENT_FAILED와 ORDER_EXPIRED 케이스를 추가해 Kafka로부터 받은 EventType을 처리하도록 핸들러(예:
handlePaymentFailed, handleOrderExpired)를 연결하세요. 마지막으로 결제 실패와 주문 만료 시나리오에 대한 통합
테스트를 추가해 order-events 토픽에 메시지가 게시되고 streamer에서 해당 EventType 케이스로 정상 처리되는지 검증하세요.

Comment on lines +35 to +43
public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) {
for (ConsumerRecord<Object, Object> record : records) {
try {
transactionTemplate.executeWithoutResult(status -> processRecord(record));
} catch (Exception e) {
log.error("coupon-issue-requests 처리 실패 - record: {}", record, e);
}
}
ack.acknowledge();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java

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

Length of output: 3687


🏁 Script executed:

# Search for ErrorHandler configuration in the project
rg -t java "ErrorHandler|DefaultErrorHandler|DeadLetterPublishingRecoverer" --max-count 20

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

Length of output: 67


🏁 Script executed:

# Search for DLT or Dead Letter Topic configuration
rg -t java "dead.*letter|dlt|DeadLetter" -i --max-count 20

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

Length of output: 67


🏁 Script executed:

# Check for Spring Kafka listener configuration
rg -t java "@KafkaListener|ConcurrentKafkaListenerContainerFactory|ErrorHandlingStrategy" --max-count 20

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

Length of output: 1278


🏁 Script executed:

# Check if there are integration tests for this consumer
fd -t f ".*Test.*\.java$|.*Tests\.java$" | xargs rg -l "CouponIssueConsumer" 2>/dev/null

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

Length of output: 69


🏁 Script executed:

cat -n modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java

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

Length of output: 5105


🏁 Script executed:

# Search for any ErrorHandler setup in KafkaConfig or elsewhere
rg -t java "setCommonErrorHandler|ErrorHandler|recovery" --max-count 30

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

Length of output: 67


배치 단위로 ack를 호출하면서 실패한 레코드가 처리 없이 커밋되어 메시지가 유실된다.

KafkaConfig 라인 86에서 AckMode.MANUAL로 설정되어 있고 배치 리스너로 동작한다. 현재 코드는 레코드별 예외를 삼킨 뒤 배치 전체에 대해 ack.acknowledge()를 호출하므로, 한 건이라도 실패하면 그 레코드의 오프셋이 커밋되어 재처리 없이 운영 환경에서 손실된다. 선착순 쿠폰은 재발급이 어려운 특성을 고려하면 심각한 문제다.

failed 레코드별로 ack를 보류하거나, DefaultErrorHandler로 dead letter topic(DLT)으로 이동하는 방식으로 개선해야 한다. 아울러 라인 40의 에러 로그에서 전체 record 객체를 노출하는 것보다 requestId와 exception 정보만 기록하도록 수정하면, 로그 보안도 개선할 수 있다.

한 레코드 실패 시 해당 오프셋이 재전달되거나 DLT로 이동하는지 확인하는 통합 테스트도 필수다.

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`
around lines 35 - 43, The consume method currently commits the entire batch even
when individual records fail; change the logic in
consume/transactionTemplate/processRecord so that failed records do not cause
the batch ack. For each record, on success call ack for that record's offset or
accumulate only successful offsets before calling ack; alternatively wire
Kafka's DefaultErrorHandler/DeadLetterPublishingRecoverer (configure DLT) for
retries/DLT routing instead of swallowing exceptions in the loop; update the
catch block in consume to log only the record's requestId (from processRecord
payload) and the exception (not the full ConsumerRecord) and rethrow or let the
error handler handle the failed record so its offset is not committed; add an
integration test asserting that a failing record is retried or routed to DLT and
not lost when AckMode.MANUAL is used.

Comment on lines +29 to +55
public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) {
for (ConsumerRecord<Object, Object> record : records) {
try {
transactionTemplate.executeWithoutResult(status -> processRecord(record));
} catch (Exception e) {
log.error("order-events 처리 실패 - record: {}", record, e);
}
}
ack.acknowledge();
}

private void processRecord(ConsumerRecord<Object, Object> record) {
OrderEventMessage event = objectMapper.convertValue(record.value(), OrderEventMessage.class);

if (eventHandledRepository.existsById(event.eventId())) {
return;
}

switch (event.eventType()) {
case ORDER_CREATED ->
log.info("주문 생성 이벤트 수신 - orderId: {}, memberId: {}", event.orderId(), event.memberId());
case PAYMENT_COMPLETED ->
log.info("결제 완료 이벤트 수신 - orderId: {}", event.orderId());
default -> {}
}

eventHandledRepository.save(EventHandledEntity.of(event.eventId(), EventHandleStatus.SUCCESS));
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:

cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java

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

Length of output: 2755


🏁 Script executed:

# transactionTemplate 설정과 Spring Kafka 설정 확인
find apps/commerce-streamer -type f -name "*.java" | xargs rg -l "transactionTemplate|KafkaListener" | head -5

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

Length of output: 458


🏁 Script executed:

# 배치 리스너 설정 확인
rg -A 10 "@KafkaListener|batch.size|batch.max.bytes|enable.auto.commit" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java

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

Length of output: 606


🏁 Script executed:

# 예외 처리 및 재시도 설정 확인
rg -B 5 -A 5 "consume\(" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java

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

Length of output: 722


🏁 Script executed:

find apps/commerce-streamer -type f -name "KafkaConfig.java" | xargs cat -n

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

Length of output: 67


🏁 Script executed:

# 전체 config 파일 검색
find apps -type f -name "*KafkaConfig*" -o -name "*KafkaConsumer*" | head -10

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

Length of output: 176


🏁 Script executed:

# BATCH_LISTENER 상수와 설정 확인
rg -B 5 -A 15 "BATCH_LISTENER|batch.max.poll.records|enable.auto.commit|ack" apps/commerce-streamer/src/main/java/com/loopers/confg/kafka/ 2>/dev/null || find apps/commerce-streamer -path "*confg/kafka*" -type f -name "*.java" | xargs cat -n

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

Length of output: 67


🏁 Script executed:

# OrderEventMessage와 eventType 정의 확인
find apps -name "OrderEventMessage.java" -o -name "OrderEventMessage.kt" | xargs cat -n 2>/dev/null | head -50

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

Length of output: 540


🏁 Script executed:

find apps -name "*.java" | xargs rg -l "BATCH_LISTENER" | head -5

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

Length of output: 458


🏁 Script executed:

# 전체 리포지토리에서 KafkaConfig 검색
find . -name "KafkaConfig.java" -o -name "*Kafka*Config*.java" 2>/dev/null | head -10

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

Length of output: 155


🏁 Script executed:

# EventType 정의 확인
find apps -name "EventType.java" | xargs cat -n

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

Length of output: 290


🏁 Script executed:

# 브로드 검색: class나 interface에서 BATCH_LISTENER 문자열 찾기
rg -l "BATCH_LISTENER" --type java

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

Length of output: 527


🏁 Script executed:

cat -n ./modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java

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

Length of output: 5105


배치 아큀으로 인한 메시지 영구 유실 및 미지원 이벤트 침묵 처리.

현재 구현은 다음과 같은 운영 치명점을 가진다.

  1. 배치 레벨 아큀으로 인한 실패 메시지 손실: processRecord() 내 모든 예외(직렬화 오류, DB 오류, 네트워크 오류 등)는 catch되어 로그만 남기고, 루프가 계속된다. 단일 레코드 실패 후에도 라인 37의 ack.acknowledge()가 호출되어 배치 전체 오프셋이 커밋된다. Spring Kafka 배치 리스너는 이미 오프셋이 커밋되었으므로 재시도하지 않으며, 해당 메시지는 영구 소실된다.

  2. 미지원 이벤트타입을 성공으로 처리: switch 문의 default 분기(라인 52)는 비어있고, EventType enum의 FAVORITE_ADDED, FAVORITE_REMOVED는 처리되지 않는다. 그럼에도 라인 55는 조건 없이 eventHandledRepository.save(... EventHandleStatus.SUCCESS)를 실행하여, 미지원 이벤트도 처리 완료로 기록한다. 스키마 변경이나 토픽 오염 시 버그를 감지하지 못한다.

  3. 재시도 불가능한 예외 처리: 모든 예외를 동일하게 취급하여 재시도 가능 오류(일시적 DB 연결 오류)와 불가능 오류(잘못된 JSON 형식)를 구분하지 않는다.

수정안: 지원하지 않는 eventType에서 명시적 예외를 발생시키고, 개별 레코드 실패 시 해당 레코드만 실패 처리 경로(DLQ, 실패 테이블, 별도 토픽)로 넘기며, 배치 전체가 성공했을 때만 아큀을 수행하도록 변경한다.

추가 테스트: (1) 직렬화 오류 발생 시 오프셋이 커밋되지 않고 재처리되는지, (2) EventType enum에 미지원 값이 추가되었을 때 예외를 발생시키는지, (3) DB 저장 실패 시 메시지가 DLQ로 라우팅되는지 검증한다.

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`
around lines 29 - 55, The consume method currently acknowledges the whole batch
regardless of per-record failures and processRecord quietly treats unsupported
event types as success; change logic so consume only calls ack.acknowledge()
when every record was processed successfully (do not swallow exceptions
per-record), let processRecord throw on unsupported eventType and on retriable
failures so the framework/DLQ can handle them, and only call
eventHandledRepository.save(..., EventHandleStatus.SUCCESS) for events actually
handled (e.g., after the ORDER_CREATED and PAYMENT_COMPLETED branches).
Specifically update consume to collect per-record outcomes or rethrow exceptions
from processRecord instead of catching all exceptions, update processRecord’s
switch default to throw a clear UnsupportedEventTypeException, and separate
handling of transient (retriable) vs non-retriable exceptions so only retriable
ones trigger retries/DLQ behavior rather than committing the batch.

Comment on lines +61 to +63
jdbc-url: ${datasource.mysql-jpa.main.jdbc-url:jdbc:mysql://localhost:3306/loopers}
username: ${datasource.mysql-jpa.main.username:test}
password: ${datasource.mysql-jpa.main.password:test}
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

자기참조 플레이스홀더로 테스트 프로필 기동 실패 위험이 있다.

운영 관점에서 해당 설정은 순환 치환으로 컨텍스트 초기화 실패를 유발할 수 있어 CI 및 통합테스트 안정성을 직접 깨뜨린다.
수정안으로 같은 키를 재참조하지 말고 별도 환경변수 키를 사용해야 한다.

수정 예시
-      jdbc-url: ${datasource.mysql-jpa.main.jdbc-url:jdbc:mysql://localhost:3306/loopers}
-      username: ${datasource.mysql-jpa.main.username:test}
-      password: ${datasource.mysql-jpa.main.password:test}
+      jdbc-url: ${TEST_MYSQL_JDBC_URL:jdbc:mysql://localhost:3306/loopers}
+      username: ${TEST_MYSQL_USERNAME:test}
+      password: ${TEST_MYSQL_PASSWORD:test}

추가 테스트로 test 프로필에서 환경변수 미설정/설정 두 케이스 모두 애플리케이션 컨텍스트 로드가 성공하는 설정 바인딩 테스트를 추가해야 한다.

#!/bin/bash
set -euo pipefail

# 동일 키 자기참조 placeholder 패턴을 탐지한다. 결과가 없으면 정상이다.
rg -nP '^\s*(jdbc-url|username|password):\s*\$\{datasource\.mysql-jpa\.main\.\1:' modules/jpa/src/main/resources/jpa.yml

# test profile 블록을 재확인한다.
rg -n -A10 -B5 'spring\.config\.activate\.on-profile:\s*test' modules/jpa/src/main/resources/jpa.yml
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/jpa/src/main/resources/jpa.yml` around lines 61 - 63, The jpa.yml
entries for jdbc-url, username and password currently reference the same
datasource.mysql-jpa.main.* keys causing a self-referential placeholder loop;
change those placeholders to use distinct environment/property keys (e.g.,
datasource.mysql-jpa.test.jdbc-url, datasource.mysql-jpa.test.username,
datasource.mysql-jpa.test.password) instead of reusing
datasource.mysql-jpa.main.*, update any related references to
datasource.mysql-jpa.main in the test profile block, and add a
configuration-binding integration test that loads the application context under
the "test" profile validating that context initializes successfully both when
the new test-specific environment variables are set and when they are absent.

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