Skip to content

[VOLUME-7] 이벤트 기반 아키텍처 (ApplicationEvent + Kafka + 선착순 쿠폰) - 오세룡#296

Merged
letter333 merged 17 commits intoLoopers-dev-lab:letter333from
letter333:feature/kafka-pipeline
Mar 31, 2026
Merged

[VOLUME-7] 이벤트 기반 아키텍처 (ApplicationEvent + Kafka + 선착순 쿠폰) - 오세룡#296
letter333 merged 17 commits intoLoopers-dev-lab:letter333from
letter333:feature/kafka-pipeline

Conversation

@letter333
Copy link
Copy Markdown

@letter333 letter333 commented Mar 27, 2026

📌 Summary

  • 배경: WEEK7 과제 — 이벤트 기반 아키텍처의 Why → How → Scale을 관통하는 구현
  • 목표: ApplicationEvent로 경계를 나누고, Kafka로 이벤트 파이프라인을 구축한 뒤, 선착순 쿠폰 발급에 적용
  • 결과: Step 1(ApplicationEvent), Step 2(Kafka Pipeline), Step 3(선착순 쿠폰 발급) Must-Have 전체 완료. 모든 테스트 통과.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 주문/결제/좋아요 등 핵심 로직과 부가 로직(로깅, 집계, 알림)이 하나의 트랜잭션에 강결합
  • 문제(또는 리스크): 부가 로직 실패 시 핵심 로직까지 롤백, 서비스 간 동기 호출로 확장성 제한
  • 성공 기준(완료 정의): check.md Must-Have 체크리스트 전체 완료, 동시성 테스트 통과

선택지와 결정

1. 이벤트 분리 판단 기준 — 단일 질문이 아닌 다층적 판단

"이걸 이벤트로 분리해야 하는가?"를 결정하기 위해 4단계 질문을 거쳤습니다:

단계 질문 판단 결과
① 실패해도 괜찮은가? 부가 로직(로깅, 집계, 알림) 식별 재고 차감·결제는 동기 유지, 로깅·집계·알림은 분리 대상
② 같은 TX에서 실행해야 하는가? 리스너 phase 결정 Outbox INSERT는 비즈니스 TX 안에서(@eventlistener), 로깅·집계는 커밋 후(@TransactionalEventListener AFTER_COMMIT)
③ 같은 서비스 내에서 충분한가? ApplicationEvent vs Kafka 전파 결정 UserActionEvent·CacheEvict는 JVM 내부로 충분, ProductLiked·OrderCompleted는 commerce-streamer에서 집계 필요 → Kafka 전파
④ 비동기 복잡성 대비 이득이 있는가? 동기 유지 vs 비동기 전환 모놀리스에서 동기 처리 가능한 핵심 로직(재고+주문)은 같은 TX 유지. 비동기는 부가 로직에만 적용

트레이드오프: 같은 서비스 내에서 동기 처리가 가능한데 굳이 비동기로 만들면, 복잡성만 늘고 이득은 없다. 마이크로서비스 간 통신처럼 같은 TX에 넣을 수 없는 제약이 있을 때 비동기(Saga)를 선택한다.

구체적 예시 — "재고 차감을 비동기로?":
주문 생성 시 재고를 Kafka로 비동기 차감하면, 결제 후 재고 부족이 발견되어 보상 트랜잭션(주문 취소+결제 취소+쿠폰 되돌림)이 필요해지고, 보상 작업마저 실패할 수 있다. 같은 서비스 내에서 DB 롤백이 가능한 상황에서 이 복잡성은 불필요하다.

2. Eventual Consistency — 좋아요 집계 분리와 낙관적 count ±1

  • 고려한 대안:
    • A: 현재 DB 값 그대로 반환 (가장 단순하지만 토글 결과가 응답에 반영 안 됨, UX 나쁨)
    • B: liked 여부만 반환 (API 스펙 변경 필요, 프론트 수정 필요)
    • C: 낙관적 count ±1 반환 (현재 likeCount ±1 계산값 반환)
  • 최종 결정: C — 낙관적 count ±1
  • 근거: 사용자에게 즉각적 피드백을 주면서도 실제 값과의 차이는 수ms~수십ms. 동시 토글 시 미세한 오차는 있으나 UX 관점에서 최선.
  • Step 1은 fire-and-forget(자동 복구 없음)이지만, Step 2에서 Outbox+Kafka로 At Least Once를 보장하여 집계 신뢰성을 강화.

3. Outbox Pattern — Polling Publisher vs CDC

  • 고려한 대안:
    • A: 순수 Polling Publisher (5초 주기 폴링)
    • B: Hybrid (즉시 발행 시도 + Outbox 백업)
    • C: CDC (Debezium으로 binlog 읽기)
  • 최종 결정: A — 순수 Polling Publisher
  • 근거: 부가 로직(집계, 로깅)에 5초 지연은 문제 없으며, 학습 목적에 적합한 단순한 구조. 향후 규모 확장 시 B→C로 자연 전환 가능.
  • 진화 경로: ApplicationEvent(Step 1) → Outbox+Polling(Step 2) → Outbox+CDC → CDC only
방법 지연 DB 부하 복잡도
Polling (채택) 1~5초 매 폴링 SELECT 낮음
Hybrid ~0ms 정상 시 없음 중간
CDC ~ms 없음 (binlog) 높음

4. Manual ACK — "중복은 멱등으로, 유실은 복구 불가"

  • Auto ACK: 메시지 수신 즉시 ACK → 처리 전 다운 시 메시지 유실
  • Manual ACK: 처리 완료 후 명시적 ACK → 다운 시 재전달 (중복 가능)
  • 최종 결정: Manual ACK + event_handled 테이블로 멱등 처리
  • 원칙: "중복은 멱등 처리(event_handled)로 해결할 수 있지만, 유실은 복구할 수 없다"

5. Consumer 이벤트 소비 전략 — Incremental vs State

  • 고려한 대안:
    • A: 모든 이벤트에 version/updated_at 기반 stale skip 적용
    • B: 이벤트 유형별 전략 분리 (Incremental은 eventId 멱등, State는 createdAt 기반 skip)
  • 최종 결정: B — 이벤트 유형별 전략 분리
  • 근거: Incremental 이벤트(LIKED +1, UNLIKED -1)에 version skip을 적용하면, 순서 역전된 이벤트를 버리게 되어 counter가 맞지 않는다. 모든 이벤트가 처리되어야 정합성 유지.
이벤트 유형 처리 전략 이유
Incremental (LIKED/UNLIKED) eventId 멱등 + tracker 기록 모든 이벤트 처리 필수, skip 시 카운트 불일치
State (ORDER_COMPLETED) isNewerEvent로 stale skip 순서 역전된 이벤트가 최신 상태를 덮어쓰지 않도록 보호

aggregate_event_tracker 테이블로 aggregate별 마지막 이벤트 시각을 GREATEST UPSERT로 추적. 향후 state-replacement 이벤트 추가 시 즉시 활용 가능.

6. 선착순 쿠폰 동시성 제어 — 삼중 방어

  • 고려한 대안:
    • A: DB 비관적 락만 사용
    • B: Redis INCR + DB 비관적 락 + UK 제약 (삼중 방어)
  • 최종 결정: B — Redis로 고속 차단, DB로 최종 보장
  • 트레이드오프: Redis-DB 간 정합성 관리 복잡도 증가, 하지만 대다수 초과 요청을 Redis에서 즉시 차단하여 DB 부하 대폭 감소

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api, commerce-streamer, modules/kafka
  • 신규 추가:
    • application/event/ — ApplicationEvent 리스너 (Payment, Order, Like, UserAction, CacheEvict)
    • application/kafka/ — Outbox Pattern (OutboxEventRecorder, OutboxPublisher)
    • domain/outbox/ — Outbox 도메인 모델
    • domain/eventtracker/ — aggregate_event_tracker 도메인
    • infrastructure/outbox/, infrastructure/eventtracker/ — JPA 구현체
    • interfaces/consumer/ — Kafka Consumer (Catalog, Order, Coupon)
    • domain/coupon/, infrastructure/coupon/ — 쿠폰 발급 도메인/인프라
  • 제거/대체: 기존 동기 부가 로직 호출 → 이벤트 기반 비동기 처리로 대체

주요 컴포넌트 책임

  • OutboxEventRecorder: ApplicationEvent를 수신하여 Outbox 테이블에 원자적으로 기록 (@eventlistener — 같은 TX에서 실행)
  • OutboxPublisher: Outbox 테이블을 폴링하여 Kafka로 발행 (At Least Once)
  • MetricsService: 이벤트 멱등 처리(event_handled) + aggregate_event_tracker 갱신 + 집계 수행
  • AggregateEventTrackerRepository: aggregate별 마지막 이벤트 시각 추적 (GREATEST UPSERT)
  • CouponIssueService: Redis INCR 선착순 카운트 + DB 비관적 락 + UK 제약으로 쿠폰 발급
  • CouponIssueConsumer: Kafka 배치 소비(3000건) + 수동 ACK

🔁 Flow Diagram

Main Flow — Step 1→2: ApplicationEvent → Outbox → Kafka

하나의 비즈니스 이벤트가 여러 리스너에 의해 독립적으로 처리되는 구조 (Observer 패턴):

sequenceDiagram
  autonumber
  participant Facade as LikeFacade
  participant DB as likes 테이블
  participant Outbox as outbox_events
  participant Listener1 as LikeCountListener<br/>(AFTER_COMMIT)
  participant Listener2 as OutboxEventRecorder<br/>(같은 TX)
  participant Scheduler as OutboxPublisher
  participant Kafka as Kafka Broker
  participant Consumer as commerce-streamer
  participant Metrics as product_metrics

  Facade->>DB: ① 좋아요 토글 (INSERT/DELETE)
  Facade->>Facade: ② publishEvent(ProductLikedEvent)
  Note over Facade,Listener2: 같은 트랜잭션
  Listener2->>Outbox: ③ outbox INSERT (원자적)
  Facade-->>Facade: TX 커밋
  Listener1->>DB: ④ likeCount 업데이트 (AFTER_COMMIT, 별도 TX)
  Scheduler->>Outbox: ⑤ 미발행 폴링 (5초)
  Scheduler->>Kafka: ⑥ send(catalog-events, productId, message)
  Kafka->>Consumer: ⑦ batch consume + manual ACK
  Consumer->>Consumer: ⑧ event_handled 멱등 체크
  Consumer->>Metrics: ⑨ product_metrics UPSERT
Loading

Main Flow — Step 3: 선착순 쿠폰 발급

sequenceDiagram
  autonumber
  participant Client
  participant API as commerce-api
  participant Kafka as Kafka Broker
  participant Consumer as commerce-streamer
  participant Redis
  participant DB as MySQL

  Client->>API: POST /coupons/{id}/issue/async
  API->>Kafka: send(coupon-issue-requests, couponId)
  API-->>Client: 202 Accepted (requestId)
  Kafka->>Consumer: consume batch
  Consumer->>Redis: INCR coupon:issued:{couponId}
  alt 수량 초과
    Consumer->>Redis: DECR (원복) + SET status=EXHAUSTED
  else 수량 이내
    Consumer->>DB: SELECT FOR UPDATE (비관적 락)
    Consumer->>DB: INSERT member_coupons (UK: member_id+coupon_id)
    alt 중복 발급
      Consumer->>Redis: SET status=DUPLICATE
    else 발급 성공
      Consumer->>Redis: SET status=SUCCESS
    end
  end
  Consumer->>Kafka: acknowledge()
  Client->>API: GET /coupons/issue-requests/{requestId}
  API->>Redis: GET status (TTL 24h)
  API-->>Client: 발급 상태 응답
Loading

🤖 Generated with Claude Code

변경 목적: ApplicationEvent로 트랜잭션 경계를 분리하고 Outbox→Kafka 파이프라인을 도입해 로깅·집계·알림 등 부가 로직을 비동기화하며, 선착순 쿠폰 발급을 이벤트 기반(비동기 요청 + 상태 조회)으로 구현하여 시스템 일관성과 확장성을 높임.
핵심 변경점: ApplicationEvent 도입(상품 좋아요/주문/결제 이벤트 등) 및 AFTER_COMMIT 리스너 적용, Outbox 패턴(OutboxEventRecorder/OutboxPublisher)으로 이벤트를 DB에 기록 후 폴링으로 Kafka 발행(토픽: catalog-events, order-events, coupon-issue-requests), Kafka 소비는 배치·수동 ACK·멱등 처리(event_handled) 적용, 선착순 쿠폰은 Redis INCR + DB SELECT FOR UPDATE + DB 유니크 제약으로 삼중 방어하고 비동기 처리(202 Accepted) + 상태 조회(Redis TTL 24h) 제공.
리스크/주의사항: Outbox의 폴링(기본 5초)로 인한 지연과 같은 aggregate 내 이벤트 순서 보장 문제, Redis와 DB의 카운트 불일치(네트워크/예외 시) 처리 경로 및 롤백/보상 전략 검토 필요, 현재 배치 소비는 “레코드 실패 로그 후 전체 배치 ACK” 방식으로 일부 실패 시 데이터 손실 가능성—적용 정책(부분 재시도/DLQ 등) 확인 권장. 또한 Kafka 프로듀서 설정(acks=all, idempotence)·토픽 파티셔닝 운영·모니터링(메트릭/알람) 구성 확인이 필요합니다.
테스트/검증 방법: 단위/통합 테스트 대거 추가(리스너·Outbox·쿠폰 서비스·메트릭 등), Kafka Testcontainers로 E2E 검증, 동시성 테스트 포함(예: 100동시 요청 vs 50수량 → 정확히 50건 발급), 중복 발급(DUPLICATE) 및 DB 무결성(DataIntegrityViolationException) 시나리오 검증.
확인 질문: Outbox 폴링 주기·배치 사이즈와 Kafka 토픽/파티션 운영 정책(부분 실패 처리, DLQ 등)을 운영 기준에 맞게 조정할지 우선 결정해 주시겠습니까? 또한 모니터링/알림(예: Outbox 출판 실패, Redis/DB 불일치 경고) 요구사항이 있으면 알려주세요.

리뷰 포인트

  1. 이벤트 분리 경계 판단
    이벤트 분리 판단 기준의 적절성

이벤트 분리 여부를 결정할 때 4단계 질문을 기준으로 삼았습니다:
① 실패해도 괜찮은가? → ② 같은 TX에서 실행해야 하는가? → ③ 같은 서비스 내에서 충분한가? → ④ 비동기 복잡성 대비 이득이 있는가?

이 기준으로 재고 차감/쿠폰 적용은 동기(같은 TX), 로깅/집계/알림은 이벤트로 분리했는데, 실무에서 이 기준 외에 추가로 고려해야 할 판단 축이 있을까요? 예를 들어 "지연 허용 시간"이나 "데이터 소유권(어떤 서비스의 데이터인가)" 같은
기준이 실제로 의사결정에 영향을 주는지 궁금합니다.

  1. 모놀리스에서의 비동기 적용 범위

현재 모놀리식 구조에서는 "같은 서비스 내에서 동기 처리가 가능하면 굳이 비동기로 만들지 않는다"는 원칙을 세웠습니다. 재고 차감을 Kafka로 비동기 처리하면 "결제했는데 재고가 없음" 문제와 Saga 보상 트랜잭션 복잡성이 발생하기
때문입니다.

그렇다면 모놀리식 구조에서 핵심 로직을 비동기로 전환해야 하는 시점은 언제인가요?
단순히 "마이크로서비스로 분리할 때"가 아니라, 모놀리식 내에서도 비동기가 필요한 경우가 있는지 궁금합니다.

  1. 리스너 트랜잭션 격리

@eventlistener vs @TransactionalEventListener 선택 기준

OutboxEventRecorder는 비즈니스 TX와 원자성이 필요하므로 @eventlistener(같은 TX), 유저 행동 로깅은 부가 로직이므로 @TransactionalEventListener(AFTER_COMMIT)을 사용했습니다.

이때 AFTER_COMMIT 리스너 내에서 새로운 이벤트를 publishEvent()하면 TX 컨텍스트가 없어서 다른 @TransactionalEventListener가 수신하지 못하는 문제를 발견했습니다. 그래서 중간 이벤트 없이 직접 UserActionLog를 저장하는 방식으로
변경했는데, Spring에서 이 문제를 우회하는 더 일반적인 패턴이 있을까요?

letter333 and others added 15 commits March 25, 2026 00:53
- UserActionEvent (record): memberId, actionType, targetId, targetType, metadata
- ActionType enum: VIEW, LIKE, ORDER, PAYMENT
- UserActionEventListener: @TransactionalEventListener(AFTER_COMMIT) + REQUIRES_NEW
- UserActionLog 도메인 객체 + Repository 인터페이스 (DIP)
- UserActionLogEntity (JPA) + UserActionLogRepositoryImpl
- UserActionEventListenerTest 단위 테스트 (4개 케이스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ProductLikedEvent / ProductUnlikedEvent 이벤트 정의
- ProductLikeCountListener: @eventlistener로 likeCount 업데이트 위임
- LikeFacade: 동기 increaseLikeCount/decreaseLikeCount 호출 제거
- LikeFacade: 낙관적 count ±1 반환 (현재 DB 값 기준 계산)
- LikeFacadeTest: 이벤트 발행 검증으로 테스트 수정
- UserActionEventListener: TransactionTemplate(REQUIRES_NEW) 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- OrderCompletedEvent: 주문 생성 완료 시 발행 (orderId, memberId, productIds, totalAmount)
- PaymentSuccessEvent: 결제 성공 시 발행 (paymentId, orderId, memberId, amount)
- OrderEventListener: OrderCompletedEvent → UserActionEvent(ORDER) 변환 발행
- PaymentEventListener: PaymentSuccessEvent → UserActionEvent(PAYMENT) 변환 발행
- OrderFacade.createOrder(): 주문 저장 후 OrderCompletedEvent 발행 추가
- PaymentFacade.handleCallback(): 결제 성공 시 PaymentSuccessEvent 발행 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- LikeFacade: UserActionEvent(LIKE) 발행 추가 (좋아요 행동 로깅)
- io/step1-event-flow.md: 전체 이벤트 흐름 다이어그램 및 트랜잭션 경계 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- OutboxEvent 도메인 객체 (aggregateType, aggregateId, eventType, payload)
- OutboxEventRepository 인터페이스 (save, findUnpublished, markPublished)
- OutboxEventEntity JPA Entity (outbox_events 테이블, idx_outbox_unpublished 인덱스)
- OutboxEventService: @transactional(MANDATORY)로 도메인 TX 내 기록 강제
- OutboxEventServiceTest 단위 테스트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- commerce-api에 kafka 모듈 의존성 추가
- KafkaTopic: catalog-events, order-events, coupon-issue-requests 상수 정의
- kafka.yml: acks=all, enable.idempotence=true Producer 설정 강화
- OutboxPublisher: 5초 주기 Scheduler로 미발행 outbox 이벤트 Kafka 발행
- KafkaTestContainersConfig: 테스트용 Kafka 컨테이너 자동 생성
- OutboxPublisherTest 단위 테스트 (4개 케이스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ProductMetrics 도메인 (productId PK, likeCount, viewCount, salesCount, salesAmount)
- ProductMetricsEntity: ON DUPLICATE KEY UPDATE로 atomic upsert 구현
- ProductMetricsRepository: incrementLikeCount, incrementViewCount, incrementSalesCount
- EventHandledEntity: event_id VARCHAR PK로 멱등 처리 지원
- EventHandledRepository: existsByEventId로 중복 이벤트 skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- OutboxEventRecorder: @eventlistener로 비즈니스 TX 내에서 outbox 기록
  - ProductLikedEvent → PRODUCT/PRODUCT_LIKED (catalog-events)
  - ProductUnlikedEvent → PRODUCT/PRODUCT_UNLIKED (catalog-events)
  - OrderCompletedEvent → ORDER/ORDER_COMPLETED (order-events)
- OutboxEventRecorderTest 단위 테스트 (3개 케이스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CatalogEventConsumer: catalog-events 토픽 배치 수신 (Manual ACK)
- OrderEventConsumer: order-events 토픽 배치 수신 (Manual ACK)
- MetricsService: 멱등 처리 (event_handled) + product_metrics 집계
  - PRODUCT_LIKED → likeCount +1
  - PRODUCT_UNLIKED → likeCount -1
- OutboxPublisher: eventId/eventType/aggregateId 포함 메시지 포맷으로 변경
- MetricsServiceTest 단위 테스트 (3개 케이스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 수동 JSON 문자열 조립 → ObjectMapper 사용 (OutboxEventRecorder, OutboxPublisher, OrderEventListener, PaymentEventListener)
- MetricsService TOCTOU 레이스 컨디션 수정 (existsById → save-first + DataIntegrityViolation catch)
- 이벤트 타입 상수화 (OutboxAggregateType, OutboxEventType 클래스 추가)
- 날짜 타입 통일 (도메인 객체 LocalDateTime → ZonedDateTime)
- Consumer 실패 건수 로깅 개선 (partition 정보, 배치 요약 로그)
- ProductLikeCountListener 미사용 import 제거 + @requiredargsconstructor 적용
- ProductMetricsRepositoryImpl 중복 @transactional 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- POST /api/v1/coupons/{couponId}/issue/async 엔드포인트 추가 (HTTP 202)
- GET /api/v1/coupons/issue-requests/{requestId} 상태 조회 엔드포인트 추가 (임시 PENDING)
- CouponFacade.requestCouponIssueAsync(): UUID requestId 생성 → KafkaTemplate.send().get() 동기 발행
- Kafka 발행 실패 시 CoreException(SERVICE_UNAVAILABLE) 반환
- CouponIssueRequestInfo, CouponIssueRequestStatusInfo DTO 추가
- CouponV1Dto에 CouponIssueAsyncResponse, CouponIssueRequestStatusResponse 추가
- CouponFacadeAsyncIssueTest 단위 테스트 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CouponIssueConsumer: coupon-issue-requests 토픽 배치 리스너, 건별 처리, manual ACK
- CouponIssueService: Redis INCR pre-check → DB 비관적 락 발급 → Redis 상태 기록
  - 수량 초과 시 DECR 보상 + EXHAUSTED 상태
  - 중복 발급 시 DECR 보상 + DUPLICATE 상태
  - 실패 시 DECR 보상 + FAILED 상태
- Domain: CouponIssueDomain, CouponIssueRepository, MemberCouponIssueRepository, CouponCodeGenerator
- Infrastructure: CouponIssueEntity, MemberCouponIssueEntity (coupons/member_coupons 테이블 매핑)
- CouponIssueRedisRepository: INCR/DECR 카운터, totalQuantity 캐시 (TTL=validUntil+1일), 요청 상태 (TTL=24h)
- CouponIssueServiceTest: 수량 초과, 정상 발급, 중복 발급 단위 테스트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GET /api/v1/coupons/issue-requests/{requestId} 엔드포인트 Redis 연동
- CouponIssueStatusRedisRepository: defaultRedisTemplate으로 요청 상태 조회
- CouponFacade.getCouponIssueRequestStatus(): Redis 조회, 없으면 PENDING 반환
- 단위 테스트 추가 (Redis 상태 있음 → 반환, 없음 → PENDING)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 총 수량 50개 쿠폰에 100명 동시 요청 → 정확히 50개만 발급 검증
- 동일 회원 중복 발급 시도 → 1개만 발급, 나머지 DUPLICATE 검증
- CouponIssueService: self-invocation 문제 해결 (TransactionTemplate 사용)
- CouponIssueEntity: coupons 테이블 필수 컬럼 추가 (테스트 DDL 호환)
- MemberCouponIssueEntity: (member_id, coupon_id) unique constraint 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- aggregate_event_tracker 테이블로 aggregate별 마지막 이벤트 시각 추적
- Consumer에서 createdAt 파싱하여 MetricsService에 전달
- Incremental 이벤트(LIKED/UNLIKED): eventId 멱등 + tracker 기록
- State 이벤트(ORDER_COMPLETED): isNewerEvent로 stale 이벤트 skip
- ORDER_COMPLETED 판매 집계(salesCount/salesAmount) 실제 구현
- OutboxEventRecorder에 productIds payload 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Kafka 기반 비동기 쿠폰 발급과 Outbox 기반 이벤트 퍼블리셔를 도입하고, 도메인 이벤트 및 리스너, 사용자 액션 로그, 상품 메트릭 집계 시스템을 통합하여 이벤트 중심 처리 흐름을 추가했다다.

Changes

Cohort / File(s) Summary
빌드·설정·테스트컨테이너
apps/commerce-api/build.gradle.kts, modules/kafka/src/main/resources/kafka.yml, modules/kafka/src/testFixtures/java/.../KafkaTestContainersConfig.java, apps/commerce-api/src/main/resources/application.yml
Kafka 모듈 의존성 추가, producer 신뢰성 설정(acks=all, idempotence, max.in.flight), Testcontainers Kafka 설정과 application.yml에 kafka.yml import 추가다.
비동기 쿠폰 API·컨트롤러·DTO·레포
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java, .../CouponIssueRequestInfo.java, .../CouponIssueRequestStatusInfo.java, .../interfaces/api/coupon/CouponV1*.java, .../infrastructure/coupon/CouponIssueStatusRedisRepository.java
비동기 쿠폰 발급 요청 엔드포인트와 상태 조회 API 추가, 요청을 Kafka로 발행하고 Redis에서 상태를 조회하는 흐름을 구현다.
쿠폰 발급 처리(consumer/service)
apps/commerce-streamer/.../CouponIssueConsumer.java, .../CouponIssueService.java, .../domain/coupon/*, .../infrastructure/coupon/*
Kafka 소비자와 CouponIssueService로 Redis 카운터 + DB 비관적 락 기반 발급 처리, 실패·중복·초과 상태 기록 및 보정 로직을 구현다.
Outbox 패턴 및 퍼블리셔
apps/commerce-api/src/main/java/com/loopers/domain/outbox/*, .../infrastructure/outbox/*, .../OutboxEventRecorder.java, .../OutboxPublisher.java, .../OutboxEventType.java, .../OutboxAggregateType.java, .../KafkaTopic.java
트랜잭션 내 Outbox 이벤트 기록, 스케줄러 기반 미발행 이벤트 Kafka 전송 및 발행 마킹 로직을 추가다.
도메인 이벤트·리스너·이벤트 발행 포인트
apps/commerce-api/src/main/java/com/loopers/application/event/*, apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java, .../order/OrderFacade.java, .../payment/PaymentFacade.java
새로운 이벤트 타입과 여러 EventListener 추가, Like/Order/Payment 흐름에서 이벤트 발행을 추가하여 사이드 이펙트를 이벤트로 분리했다다.
사용자 액션 로그 및 저장소
apps/commerce-api/src/main/java/com/loopers/domain/actionlog/*, .../infrastructure/actionlog/*
UserActionLog 도메인·엔티티·JPA 리포지토리와 구현체 추가, AFTER_COMMIT 후 별도 트랜잭션으로 로그 저장하도록 구현다.
메트릭·이벤트 중복·순서 추적(streamer)
apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java, .../domain/metrics/*, .../infrastructure/metrics/*, .../domain/eventhandled/*, .../domain/eventtracker/*, .../infrastructure/eventhandled/*, .../infrastructure/eventtracker/*
Kafka 이벤트를 기반으로 상품 메트릭 집계, 이벤트 중복 방지와 순서 추적을 위한 저장소·엔티티·레포지토리를 추가다.
Kafka 소비자(배치 리스너)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/*
배치 모드 Kafka 컨슈머 추가, 레코드별 실패 로깅 후 배치 일괄 acknowledge 방식으로 설계다.
Redis 기반 쿠폰 상태·카운터 레포지토리
apps/commerce-streamer/.../CouponIssueRedisRepository.java, apps/commerce-api/.../CouponIssueStatusRedisRepository.java
issued count 증감·초기화, totalQuantity 저장/조회와 요청 상태(24h TTL) 저장/조회 기능을 추가다.
테스트 추가·수정
apps/commerce-api/src/test/..., apps/commerce-streamer/src/test/..., modules/kafka/src/testFixtures/...
비동기 쿠폰, Outbox 퍼블리셔/레코더, 이벤트 리스너, MetricsService 단위 테스트와 동시성 통합 테스트(CouponIssueConcurrencyTest) 등을 추가 또는 수정했다다.

Sequence Diagram(s)

비동기 쿠폰 발급 흐름

sequenceDiagram
    actor Client
    participant API as Commerce API
    participant Kafka as Kafka Broker
    participant Streamer as Commerce Streamer
    participant Redis as Redis
    participant DB as Database

    Client->>API: POST /coupons/{couponId}/issue/async
    API->>API: 인증·쿠폰 존재 검증, requestId 생성
    API->>Kafka: send(topic=coupon-issue-requests, value={requestId, memberId, couponId})
    Kafka-->>API: send ack
    API-->>Client: 202 ACCEPTED (requestId, status=PENDING)

    Kafka->>Streamer: 배치 수신
    Streamer->>Redis: incrementIssuedCount(couponId)
    Streamer->>Redis: getTotalQuantity(couponId) or load from DB
    alt 수량 초과
        Streamer->>Redis: setRequestStatus(requestId, "EXHAUSTED")
    else 수량 허용
        Streamer->>DB: findByIdForUpdate(couponId) (PESSIMISTIC WRITE)
        DB-->>Streamer: CouponIssueEntity
        Streamer->>DB: save issuedQuantity, MemberCouponEntity
        Streamer->>Redis: setRequestStatus(requestId, "SUCCESS")
    end
Loading

Outbox 발행 흐름

sequenceDiagram
    participant Service as Business Service
    participant OutboxSvc as OutboxEventService
    participant DB as Database
    participant Publisher as OutboxPublisher
    participant Kafka as Kafka Broker

    Service->>OutboxSvc: recordEvent(aggregateType, aggregateId, eventType, payload)
    OutboxSvc->>DB: insert OutboxEvent (published_at = NULL)
    DB-->>OutboxSvc: 저장 완료
    Service->>Service: 트랜잭션 커밋

    loop 주기
        Publisher->>DB: findUnpublished(limit)
        DB-->>Publisher: OutboxEvent 목록
        Publisher->>Kafka: send(topic, key=aggregateId, value=message)
        Kafka-->>Publisher: send ack or exception
        alt send 성공
            Publisher->>DB: markPublished(eventId)
        else send 실패
            Publisher->>Publisher: 로그 및 재시도 없음(현재)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70분

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 ApplicationEvent 기반 아키텍처 구현과 Kafka 파이프라인, 선착순 쿠폰 발급이라는 주요 변경 사항을 명확히 전달한다.
Description check ✅ Passed PR 설명이 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram, Review Points)을 모두 포함하며 충분히 상세하게 기술되어 있다.

✏️ 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.

@letter333 letter333 changed the title feat: WEEK7 이벤트 기반 아키텍처 (ApplicationEvent + Kafka + 선착순 쿠폰) [VOLUME-7] 이벤트 기반 아키텍처 (ApplicationEvent + Kafka + 선착순 쿠폰) - 오세룡 Mar 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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)

135-145: ⚠️ Potential issue | 🟠 Major

결제 복구 경로에서 PaymentSuccessEvent 발행이 누락되었다.

applyRecoveryResult에서 결제 성공 처리 시 PaymentSuccessEvent를 발행하지 않는다. handleCallback 성공 경로와 일관성이 없어 복구 경로로 성공 처리된 결제는 유저 행동 로깅이 누락된다.

운영 관점에서 이는 결제 메트릭 및 사용자 행동 추적의 불완전성을 야기한다.

🔧 수정안
         if (pgResponse.isSuccess()) {
             if (payment.getTransactionId() == null) {
                 payment.assignTransactionId(pgResponse.transactionId());
             }
             payment.markSuccess();
             return transactionTemplate.execute(status -> {
                 Payment saved = paymentService.save(payment);
                 orderService.changeStatus(payment.getOrderId(), OrderStatus.PAID);
+
+                applicationEventPublisher.publishEvent(new PaymentSuccessEvent(
+                    saved.getId(), saved.getOrderId(), saved.getMemberId(), saved.getAmount()
+                ));
+
                 return PaymentInfo.from(saved);
             });

추가 테스트: 복구 경로에서 결제 성공 시 PaymentSuccessEvent 발행 검증 테스트 케이스를 추가해야 한다.

🤖 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 135 - 145, applyRecoveryResult currently marks and persists a
successful recovered payment but does not publish the PaymentSuccessEvent,
causing missing user-action logging; update PaymentFacade.applyRecoveryResult to
publish the same PaymentSuccessEvent that handleCallback emits after saving the
payment (inside the transactionTemplate.execute block or immediately after save)
using the existing event publisher (e.g., the ApplicationEventPublisher or the
class' event publishing method), ensuring you pass the saved Payment/Order
identifiers and any payload handleCallback uses; also add a unit/integration
test asserting that a successful recovery triggers publication of
PaymentSuccessEvent.
🟠 Major comments (32)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java-52-55 (1)

52-55: ⚠️ Potential issue | 🟠 Major

정수 파싱 실패 미처리로 소비자 재시도 폭주를 유발할 수 있다

운영 관점에서 Redis 데이터 오염(수동 수정/이전 버그/마이그레이션 흔적) 시 Integer.parseInt 예외가 발생하면 배치 소비가 실패하고 동일 메시지 재처리가 반복될 수 있다. 파싱 실패를 방어적으로 처리해 Optional.empty() 반환 + 경고 로그로 격리하는 것이 안전하다. 또한 비정상 값("abc") 입력 시 예외 없이 폴백 동작하는 테스트를 추가해야 한다.

🤖 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/redis/CouponIssueRedisRepository.java`
around lines 52 - 55, The method getTotalQuantity currently calls
Integer.parseInt on the Redis value which will throw NumberFormatException for
corrupted values; update getTotalQuantity in CouponIssueRedisRepository to
defensively catch NumberFormatException (and nulls are already handled), log a
warning including the key (TOTAL_QUANTITY_KEY + couponId) and the bad value via
the class logger, and return Optional.empty() on parse failure instead of
letting the exception propagate; also add a unit test for getTotalQuantity that
seeds masterRedisTemplate (or a mock) with a non-numeric value like "abc" for
TOTAL_QUANTITY_KEY + couponId and asserts the method returns Optional.empty()
and that a warning log was emitted.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java-46-50 (1)

46-50: ⚠️ Potential issue | 🟠 Major

수량 저장과 TTL 설정이 분리되어 있어 장애 시 만료 누락이 발생한다

운영 관점에서 setexpire 사이에 장애가 나면 만료 없는 키가 남아 이후 캠페인에서도 잘못된 수량 기준이 유지될 수 있다. 값 저장과 TTL 부여를 원자적으로 처리하도록 단일 명령(또는 Lua 스크립트) 기반으로 변경하는 편이 안전하다. 추가로 저장 직후 TTL 존재를 보장하는 통합 테스트를 넣어 만료 누락 회귀를 막아야 한다.

🤖 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/redis/CouponIssueRedisRepository.java`
around lines 46 - 50, The current setTotalQuantity saves the value and then
calls setTtlByValidUntil, which can leave a non-expiring key if a failure occurs
between those calls; change setTotalQuantity to perform an atomic
save-with-expiry (e.g., use masterRedisTemplate.opsForValue().set(...) overload
that accepts a timeout or execute a Redis SET/SETEX via RedisCallback or a small
Lua script) instead of separate masterRedisTemplate.opsForValue().set and
setTtlByValidUntil calls so the value and TTL are applied in one command; also
add an integration test that writes via setTotalQuantity and immediately
verifies the TTL exists (non-negative ttl) to prevent regressions.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java-5-5 (1)

5-5: ⚠️ Potential issue | 🟠 Major

상태값을 String으로 노출하면 도메인 불변식이 깨진다

운영 관점에서 상태 오타/임의값이 저장되면 후속 소비자와 조회 API가 상태를 해석하지 못해 요청이 영구 PENDING처럼 보이는 장애로 이어질 수 있다. status는 문자열 대신 전용 enum(예: CouponIssueStatus) 또는 값 객체로 타입을 고정하는 편이 안전하다. 또한 저장/조회 경로에 대해 “허용 상태만 저장 가능” 케이스와 “미정의 상태 입력 시 실패” 케이스를 추가 테스트해야 한다.

제안 수정안
- void save(Long memberId, Long couponId, String couponCode, String status, java.time.LocalDateTime expiredAt);
+ void save(Long memberId, Long couponId, String couponCode, CouponIssueStatus status, java.time.LocalDateTime expiredAt);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java`
at line 5, The repository API currently exposes status as a raw String which
breaks domain invariants; change the save method signature in
MemberCouponIssueRepository (the save(Long memberId, Long couponId, String
couponCode, String status, LocalDateTime expiredAt) declaration) to accept a
dedicated enum type (e.g. CouponIssueStatus) instead of String, update all
callers to pass the enum, and add validation at the persistence entry point to
reject null/unknown enum values; also add unit tests covering allowed states
persistence and rejection of undefined/invalid states to ensure only permitted
CouponIssueStatus values are stored.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java-5-6 (1)

5-6: ⚠️ Potential issue | 🟠 Major

event_handled 테이블의 무한 증가 문제를 해결하기 위해 TTL/정리 전략을 마련해야 한다.

멱등성 보장을 위해 처리된 이벤트 ID를 저장하는 테이블이다. 현재 코드에서는 삭제 로직이나 TTL 정책이 없어서 이벤트 볼륨이 높을 경우 테이블이 무한히 증가한다. 운영 관점에서 데이터베이스 성능 저하와 저장 비용 증가를 초래한다.

수정안:

  • handledAt 컬럼을 활용한 배치 정리 작업 추가 (예: 30일 이상 경과한 레코드 삭제)
  • @Scheduled 작업으로 정기적인 정리 로직 구현 또는 데이터베이스 TTL 정책 적용
  • handledAt 컬럼에 인덱스 추가 (DELETE 쿼리 성능 최적화)
  • 정리 대상 레코드 조회 테스트 추가 (삭제 전 정확한 기간 동작 검증)
🤖 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/eventhandled/EventHandledJpaRepository.java`
around lines 5 - 6, The event_handled table currently has no TTL/cleanup,
causing unbounded growth; add a scheduled cleanup that deletes records older
than a retention window using the handledAt column, add an indexed handledAt for
delete performance, and add tests to verify deletion semantics. Concretely: add
a repository delete method (e.g., in EventHandledJpaRepository define long
deleteByHandledAtBefore(Instant cutoff) or a `@Modifying` `@Query` delete for
handledAt < :cutoff), implement a scheduled service (e.g.,
EventHandledCleanupService with `@Scheduled`) that computes cutoff =
now().minus(30, ChronoUnit.DAYS) and invokes that repository method, create a DB
migration to add an index on handledAt and (optionally) a TTL/policy if
supported by the DB, and add an integration test that inserts records with
handledAt beyond and within cutoff and asserts only old records are removed.
apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java-3-10 (1)

3-10: ⚠️ Potential issue | 🟠 Major

UserActionEvent에서 필수 필드(memberId, actionType) 검증이 없어 실행 시점 장애 발생 위험이 있다.

UserActionEventListener의 line 30에서 event.actionType().name()을 호출하는데, actionType이 null이면 NPE가 발생한다. 또한 UserActionLogEntitymember_id, action_type, target_id, target_type 모두에 nullable=false 제약이 있으므로, 상위에서 검증되지 않은 null 값이 전파되면 DB 제약조건 위반으로 실패한다. 현재 OrderCompletedEvent, PaymentSuccessEvent 등 상위 이벤트가 검증이 없으므로, 하위에서 방어적 검증이 필요하다.

생성 시점 필수 필드 검증 추가
 public record UserActionEvent(
     Long memberId,
     ActionType actionType,
     Long targetId,
     String targetType,
     String metadata
 ) {
+    public UserActionEvent {
+        if (memberId == null) {
+            throw new IllegalArgumentException("memberId must not be null");
+        }
+        if (actionType == null) {
+            throw new IllegalArgumentException("actionType must not be null");
+        }
+        if (targetId == null) {
+            throw new IllegalArgumentException("targetId must not be null");
+        }
+        if (targetType == null) {
+            throw new IllegalArgumentException("targetType must not be null");
+        }
+    }
 }

테스트로는 각 필수 필드가 null일 때 IllegalArgumentException이 발생하는지 확인해야 한다.

🤖 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/UserActionEvent.java`
around lines 3 - 10, UserActionEvent must validate required fields to prevent
NPEs and DB constraint violations: add defensive null checks in the
UserActionEvent canonical constructor (record) to throw IllegalArgumentException
when memberId or actionType is null (include clear messages), and consider
validating targetId/targetType if they are also non-null in UserActionLogEntity;
update callers (e.g., OrderCompletedEvent/PaymentSuccessEvent) only if
necessary, and add unit tests asserting that constructing UserActionEvent with
null memberId or null actionType throws IllegalArgumentException.
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java-9-11 (1)

9-11: ⚠️ Potential issue | 🟠 Major

markPublished 결과가 없어 발행 상태 이상을 탐지하기 어렵다.

운영 관점에서 void markPublished(Long id)는 업데이트 미반영(이미 발행됨/대상 없음) 상황을 호출자가 감지하지 못해, 중복 발행 또는 누락 조사 난이도를 높인다.

수정안은 상태 전이 결과를 boolean 또는 int로 반환하도록 계약을 강화하고, published_at is null 조건 기반 갱신 여부를 호출자가 처리하도록 바꾸는 것이다.

추가 테스트는 (1) 미발행 건 업데이트 성공 경로, (2) 이미 발행된 건/없는 건에서 0건 갱신 경로를 분리해 검증하면 된다.

🔧 제안 수정안
 public interface OutboxEventRepository {
 
     OutboxEvent save(OutboxEvent event);
 
     List<OutboxEvent> findUnpublished(int limit);
 
-    void markPublished(Long id);
+    boolean markPublished(Long id);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java`
around lines 9 - 11, Change the contract of markPublished to return an update
result (boolean or int) instead of void so callers can detect no-op updates;
update the OutboxEventRepository signature from void markPublished(Long id) to
e.g. boolean markPublished(Long id) or int markPublished(Long id), change the
implementation to perform the UPDATE with "WHERE id = :id AND published_at IS
NULL" and return whether rowsAffected > 0 (or the count), and add tests for (1)
successful update when unpublished and (2) zero-update when already published or
missing; adjust any callers of markPublished to handle the returned result
accordingly.
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java-3-8 (1)

3-8: ⚠️ Potential issue | 🟠 Major

이벤트 레코드에 null 방어 로직을 추가해야 한다.

PaymentSuccessEvent는 결제 도메인의 핵심 이벤트로, 현재 PaymentFacade에서 저장된 Payment 엔티티의 값으로 생성된다. 하지만 레코드 자체가 null을 허용하므로, 직렬화 실패, 테스트 중 실수, 또는 향후 유지보수 과정에서 null이 주입될 경우 이벤트 소비자(PaymentEventListener)에서 NPE가 발생해 장애를 유발할 수 있다.

레코드 생성 시점에 null을 차단하는 컴팩트 생성자를 추가하면, 문제를 조기에 감지할 수 있다.

수정안
 package com.loopers.application.event;
 
+import java.util.Objects;
+
 public record PaymentSuccessEvent(
     Long paymentId,
     Long orderId,
     Long memberId,
     Long amount
 ) {
+    public PaymentSuccessEvent {
+        Objects.requireNonNull(paymentId, "paymentId must not be null");
+        Objects.requireNonNull(orderId, "orderId must not be null");
+        Objects.requireNonNull(memberId, "memberId must not be null");
+        Objects.requireNonNull(amount, "amount must not be null");
+    }
 }

추가 테스트로 각 필드에 null을 주입했을 때 생성 단계에서 NullPointerException이 발생하는지 검증해야 한다.

🤖 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/PaymentSuccessEvent.java`
around lines 3 - 8, PaymentSuccessEvent currently allows nulls and should guard
against them: add a compact record constructor inside PaymentSuccessEvent that
calls Objects.requireNonNull(...) for each component (paymentId, orderId,
memberId, amount) so construction fails fast with NPE when any field is null;
update or add unit tests to assert that constructing PaymentSuccessEvent with
null for each individual field throws NullPointerException, and verify places
that create this record (e.g., PaymentFacade and consumers like
PaymentEventListener) still compile and work with the non-null guarantees.
apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java-5-10 (1)

5-10: ⚠️ Potential issue | 🟠 Major

레코드의 productIds 필드가 외부 mutable Set 참조를 그대로 보관하여 이벤트 불변성을 위반한다.

OrderFacade의 Collectors.toSet()으로 생성한 mutable HashSet이 방어적 복사 없이 그대로 전달되므로, 외부에서 원본 Set을 변경할 경우 이벤트 페이로드가 손상될 수 있다. 이는 Kafka Outbox 패턴에서 멱등 처리와 장애 재현을 어렵게 만든다.

컴팩트 생성자에서 null 검증 후 Set.copyOf(...)로 방어적 복사를 적용해야 한다.

제안 수정안
 package com.loopers.application.event;
 
+import java.util.Objects;
 import java.util.Set;
 
 public record OrderCompletedEvent(
     Long orderId,
     Long memberId,
     Set<Long> productIds,
     Long totalAmount
 ) {
+    public OrderCompletedEvent {
+        Objects.requireNonNull(orderId, "orderId must not be null");
+        Objects.requireNonNull(memberId, "memberId must not be null");
+        Objects.requireNonNull(totalAmount, "totalAmount must not be null");
+        productIds = Set.copyOf(Objects.requireNonNull(productIds, "productIds must not be null"));
+    }
 }

추가 테스트: (1) 생성 후 외부 Set 변경이 productIds()에 반영되지 않는지 검증, (2) productIds null 입력 시 즉시 실패하는지 검증.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java`
around lines 5 - 10, OrderCompletedEvent currently stores the externally
supplied mutable Set in the productIds record component; add a compact
constructor in OrderCompletedEvent that null-checks productIds and uses
Set.copyOf(productIds) to make a defensive, unmodifiable copy (apply the same
for other components if needed), ensuring immutability; update/add tests to
verify that mutating the source Set after construction does not affect
productIds() and that passing null for productIds fails fast.
apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java-14-22 (1)

14-22: ⚠️ Potential issue | 🟠 Major

코드 주석의 의도(AFTER_COMMIT, Eventual Consistency)와 구현이 불일치한다

LikeFacade의 주석(라인 41)에서는 "실제 likeCount 업데이트는 AFTER_COMMIT 리스너에서 별도 트랜잭션으로 처리"한다고 명시하고 있으나, ProductLikeCountListener는 @EventListener만 사용하고 있다. @EventListener는 기본적으로 발행자의 트랜잭션 컨텍스트 내에서 동기적으로 실행되므로, ProductService의 increaseLikeCount/decreaseLikeCount가 실패하면 LikeFacade의 전체 트랜잭션이 롤백된다. 이는 주석에서 언급한 별도 트랜잭션과 Eventual Consistency 패턴에 모순된다.

LikeFacade의 lowCount 갱신 실패가 좋아요 등록/취소 자체를 롤백시키지 않으려면, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)으로 변경하거나 비동기 처리(@Async)를 적용해야 한다. 테스트에서도 이벤트 수신 실패 시 발행자 트랜잭션이 유지되는지 검증하는 통합 테스트가 필요하다.

🤖 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/ProductLikeCountListener.java`
around lines 14 - 22, ProductLikeCountListener currently uses `@EventListener`
which runs synchronously in the publisher's transaction and conflicts with
LikeFacade's comment about AFTER_COMMIT/eventual consistency; change the two
listener methods (handleProductLiked and handleProductUnliked) to use
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) so
ProductService.increaseLikeCount/decreaseLikeCount run in a separate transaction
(or alternatively annotate the class/methods with `@Async` if you prefer
asynchronous handling), and add an integration test that emits
ProductLikedEvent/ProductUnlikedEvent and asserts the publisher transaction is
committed even if the listener throws (verifying that like count failures do not
roll back the original LikeFacade transaction).
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-117-121 (1)

117-121: ⚠️ Potential issue | 🟠 Major

OrderEventListener@TransactionalEventListener(phase = AFTER_COMMIT)로 변경하거나 예외 처리 추가 필요

OrderEventListener.handleOrderCompleted()@EventListener로 구현되어 있어 OrderFacade.createOrder() 트랜잭션 내에서 동기적으로 실행된다. 이벤트 발행 중 예외 발생 시(예: JSON 직렬화 실패) 전체 주문 생성이 롤백된다. 주문이 이미 저장된 상태에서 이벤트 발행 실패로 인해 롤백되면 운영 관점에서 주문 손실, 데이터 불일치 문제가 발생한다.

수정 방안:

  1. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 변경 → 주문 커밋 후 이벤트 발행 → 이벤트 실패가 주문 생성에 영향 없음
  2. 또는 handleOrderCompleted() 내에 명시적 예외 처리 및 로깅 추가 → 이벤트 발행 실패를 격리

참고: UserActionEventListener는 이미 @TransactionalEventListener(phase = AFTER_COMMIT)으로 올바르게 구현되어 있으며, PaymentEventListener도 동일한 문제를 가지고 있으므로 함께 수정 권장.

🤖 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 117 - 121, OrderFacade.createOrder() currently publishes
OrderCompletedEvent which is handled synchronously because
OrderEventListener.handleOrderCompleted() is annotated with `@EventListener`;
change OrderEventListener.handleOrderCompleted() to
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) so the
handler runs only after the order transaction commits, or alternatively wrap the
handler logic in handleOrderCompleted() with explicit try-catch and robust
logging to prevent handler exceptions from bubbling up and rolling back the
saved order; apply the same change/defensive handling to PaymentEventListener
(and verify UserActionEventListener is already using AFTER_COMMIT).
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java-13-15 (1)

13-15: ⚠️ Potential issue | 🟠 Major

비관적 락에 타임아웃 힌트가 누락되었다.

findByIdForUpdatePESSIMISTIC_WRITE 락을 사용하지만 lock.timeout 힌트가 없다. 동시성이 높은 선착순 쿠폰 발급 시나리오에서 락 경합이 발생하면 무한 대기로 인해 스레드 고갈 및 서비스 장애가 발생할 수 있다.

Based on learnings: "lock timeout hint (jakarta.persistence.lock.timeout) is missing, risking infinite wait."

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

 public interface CouponIssueJpaRepository extends JpaRepository<CouponIssueEntity, Long> {

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

추가 테스트: 락 타임아웃 발생 시 PessimisticLockException 처리 로직이 상위 레이어에 구현되어 있는지 확인이 필요하다.

🤖 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/CouponIssueJpaRepository.java`
around lines 13 - 15, The PESSIMISTIC_WRITE lock on
CouponIssueJpaRepository.findByIdForUpdate is missing a lock timeout hint which
can cause infinite waits under contention; add a `@QueryHints` annotation on
findByIdForUpdate to provide the jakarta.persistence.lock.timeout hint (e.g.,
"0" for no-wait or a suitable millisecond value) so lock acquisition fails fast,
and keep the existing `@Lock/`@Query annotations; also verify that upstream code
handles PessimisticLockException thrown when the timeout expires.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueRepositoryImpl.java-16-18 (1)

16-18: 🛠️ Refactor suggestion | 🟠 Major

commerce-streamer 모듈 내에 MemberCouponStatus enum을 정의하여 타입 안전성을 확보해야 한다.

status 파라미터가 String 타입으로 정의되어 있어서 "AVALIABLE"과 같은 오타나 잘못된 상태값이 런타임에 데이터베이스에 저장될 수 있다. MemberCouponIssueRepository 인터페이스와 구현체, MemberCouponIssueEntity.of() 메서드에서 모두 String을 사용하고 있으므로, 다음과 같이 commerce-streamer 모듈 내에 enum을 정의하여 컴파일 타임 검증을 활용해야 한다.

public enum MemberCouponStatus {
    AVAILABLE,   // 사용 가능
    USED,        // 사용됨
    EXPIRED      // 만료됨
}

이 enum을 MemberCouponIssueRepository, MemberCouponIssueRepositoryImpl, MemberCouponIssueEntity에서 사용하도록 변경하고, CouponIssueService에서도 "AVAILABLE" 문자열 대신 MemberCouponStatus.AVAILABLE을 전달하도록 수정한다. 수정 후 모든 상태값 전달 경로가 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/MemberCouponIssueRepositoryImpl.java`
around lines 16 - 18, Introduce a new enum MemberCouponStatus { AVAILABLE, USED,
EXPIRED } in the commerce-streamer module and replace all String-typed status
usages with this enum: change the MemberCouponIssueRepository signature,
MemberCouponIssueRepositoryImpl.save(Long memberId, Long couponId,
MemberCouponStatus status, LocalDateTime expiredAt), and the call to
MemberCouponIssueEntity.of(...) to accept MemberCouponStatus instead of String;
update MemberCouponIssueEntity.of(...) and its backing field to store the enum
(and mapping if using JPA) and update callers such as CouponIssueService to pass
MemberCouponStatus.AVAILABLE (and other enum constants) rather than raw strings,
then run/adjust tests to ensure all status paths use the enum.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java-50-53 (1)

50-53: ⚠️ Potential issue | 🟠 Major

좋아요 취소도 LIKE로 기록되어 행동 로그가 오염된다.

운영에서 행동 로그를 통계나 추천 입력으로 쓰면 취소 동작까지 like로 집계되어 지표가 왜곡된다. 수정안은 unlike 경로에 별도 actionType을 두거나, 최소한 metadata에 liked 값을 넣어 downstream이 구분하게 만드는 것이다. 추가 테스트는 unlike 호출 시 발행된 UserActionEvent가 like와 구분 가능한 정보를 포함하는지 검증하는 것이다.

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

In `@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java`
around lines 50 - 53, In LikeFacade change the event published for the unlike
flow so downstream can distinguish cancels: either publish a distinct ActionType
(e.g., ActionType.UNLIKE) instead of ActionType.LIKE or add disambiguating
metadata (e.g., a "liked": false field) to the UserActionEvent; update the
unlike handler where applicationEventPublisher.publishEvent(...) is called and
adjust the UserActionEvent construction so the ActionType and/or metadata
clearly indicate an unlike, and add/adjust tests to assert the published event
contains the new ActionType or the "liked" flag for unlike calls.
apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java-7-9 (1)

7-9: ⚠️ Potential issue | 🟠 Major

신규성 판정과 tracker 갱신을 원자적으로 묶어야 한다.

운영에서 같은 aggregate의 이벤트가 병렬 소비되면 isNewerEvent() 확인 직후 더 최신 이벤트가 먼저 반영될 수 있어, 뒤늦은 구버전 이벤트도 통과해 집계가 중복되거나 최신 상태를 덮을 수 있다. 수정안은 advanceIfNewer(...) 같은 단일 메서드로 계약을 바꾸고 DB에서 비교와 갱신을 한 번에 수행하는 것이다. 추가 테스트는 같은 aggregateId에 대해 older/newer 이벤트를 동시에 처리할 때 newer 1건만 반영되는지 검증하는 것이다.

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java`
around lines 7 - 9, 현재 분리된 upsert(String aggregateId, String eventType,
ZonedDateTime eventCreatedAt)와 isNewerEvent(...)는 경쟁 조건으로 인해 동시에 들어오는 동일
aggregateId의 이벤트 처리에서 잘못된 older 이벤트를 허용할 수 있으니 두 메서드를 단일 원자적 계약으로 합치세요: 예컨대
advanceIfNewer(String aggregateId, String eventType, ZonedDateTime
eventCreatedAt)를 추가하고 구현체에서 DB 레벨(단일 SQL 또는 트랜잭션/업서트)로 “현재 저장된 eventCreatedAt보다
더 최신인지 비교한 뒤 최신일 때만 갱신” 로직을 수행하도록 변경하며 기존 upsert/isNewerEvent 호출을
advanceIfNewer로 교체하고, 동시성 테스트를 추가해 동일 aggregateId에 대해 older/newer 이벤트를 병렬로 처리할 때
newer 이벤트 한 건만 반영되는지 검증하세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java-12-18 (1)

12-18: ⚠️ Potential issue | 🟠 Major

미발행 행을 단순 조회하면 다중 인스턴스에서 중복 발행이 증폭된다.

운영에서 publisher가 둘 이상 뜨면 각 인스턴스가 같은 published_at IS NULL 행을 동시에 가져가 Kafka로 중복 전송할 수 있다. consumer가 멱등이어도 중복이 인스턴스 수만큼 증폭되어 Kafka 부하와 지연이 커진다. 수정안은 조회 시점에 원자적으로 claim하도록 상태/lease 컬럼을 두거나, 잠금 기반으로 서로 다른 배치를 가져가게 만드는 것이다. 추가 테스트는 두 publisher가 동시에 poll할 때 동일 outbox row가 한 번만 선점되는지 검증하는 것이다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java`
around lines 12 - 18, 현재 findUnpublished/markPublished 패턴은 다중 인스턴스에서 동일 행을 중복으로
가져와 중복 발행을 유발하므로 outbox_events 테이블에 lease/claim 컬럼(예: claimed_by, claimed_until
또는 lease_token)을 추가하고 원자적으로 행을 선점(claim)하는 메서드를 구현하세요: 예컨대 트랜잭션 내에서 SELECT ...
FOR UPDATE SKIP LOCKED로 unpublished 행(id, published_at IS NULL)을 LIMIT :limit 만큼
락으로 가져오고 해당 id들에 대해 UPDATE로 claimed_by와 claimed_until(또는 lease 토큰)을 설정한 뒤 claim한
행만 반환하는 새 리포지토리 메서드(claimUnpublished 또는 fetchAndClaimUnpublished)를 추가하고 기존
findUnpublished는 제거하거나 테스트 전용으로만 남기세요; 또한 markPublished는 claimed_by/lease 검증을
추가해 자신이 선점한 행만 published_at을 갱신하도록 수정하고, 동시성 테스트를 추가해 두 publisher가 동시에 poll할 때
동일 outbox row가 한 번만 선점되는지 검증하세요 (참고 메서드/엔티티: findUnpublished, markPublished,
OutboxEventEntity, outbox_events).
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerEntity.java-29-30 (1)

29-30: ⚠️ Potential issue | 🟠 Major

타임존 일관성이 보장되지 않으면 이벤트 순서 판정이 뒤바뀔 수 있다.

DATETIME(6)은 타임존을 저장하지 않는 MySQL 컬럼이다. ZonedDateTime을 이 컬럼에 저장할 때 Hibernate는 암묵적으로 변환 처리하지만, JVM 기본 타임존이나 JDBC 타임존 설정이 일관되지 않으면 저장과 조회 시점에 다른 존 정보로 복원될 수 있다. 그 결과 isNewerEventisAfter() 비교 결과가 뒤집혀 최신 이벤트 누락이나 중복 집계가 발생한다.

수정안: Instant로 정규화하고 BIGINT 또는 TIMESTAMP 컬럼을 사용하거나, 명시적인 @Convert로 UTC OffsetDateTime 변환을 강제한다. 테스트는 다른 offset의 동일 시각이 저장·조회되어도 동일한 순서로 비교되는지 검증하고, hibernate.jdbc.time_zone, spring.jpa.properties.hibernate.jdbc.time_zone 설정이 명확히 문서화되어야 한다.

🤖 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/eventtracker/AggregateEventTrackerEntity.java`
around lines 29 - 30, The field lastEventCreatedAt in
AggregateEventTrackerEntity is mapped as ZonedDateTime to a DATETIME(6) column
which loses timezone and can flip isNewerEvent comparisons (isAfter()); change
the mapping to a timezone-safe representation by either converting
lastEventCreatedAt to Instant and persist as BIGINT/TIMESTAMP, or force UTC
OffsetDateTime via a JPA AttributeConverter and use a
TIMESTAMP/offset-preserving column; update the entity (lastEventCreatedAt) and
any code using isNewerEvent to operate on the normalized type, add unit tests
that write/read timestamps with different offsets to assert comparison
stability, and document required hibernate.jdbc.time_zone /
spring.jpa.properties.hibernate.jdbc.time_zone settings.
apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java-19-35 (1)

19-35: ⚠️ Potential issue | 🟠 Major

OrderEventListener의 동기 실행이 주문 완료 흐름을 깨뜨릴 수 있다.

OrderEventListener는 @EventListener를 사용하여 동기적으로 실행되므로, toJson() 또는 publishEvent() 실패 시 발생한 예외가 OrderFacade.createOrder()로 전파되어 주문 생성 트랜잭션을 롤백할 수 있다. 부가 로직인 유저 행동 로깅 실패로 인해 핵심 비즈니스 로직인 주문 완료가 실패하는 것은 이벤트 기반 아키텍처 원칙에 위배된다.

수정안: UserActionEventListener가 올바르게 사용하고 있는 것처럼, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 변경하여 주문 트랜잭션 커밋 이후에 실행되도록 분리하면 된다. 추가 테스트는 ObjectMapper 직렬화 예외 발생 시에도 주문이 정상 완료되는지, 그리고 유저 행동 로그는 누락되는지(비핵심 기능이므로 허용 가능) 검증하는 것이 필요하다.

동일한 패턴 문제는 PaymentEventListener에도 존재하므로 함께 개선하도록 한다.

🤖 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 19 - 35, Change the synchronous
OrderEventListener.handleOrderCompleted so user-action logging runs after the
order transaction commits: replace `@EventListener` with
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) on
handleOrderCompleted (so failures in toJson() or
applicationEventPublisher.publishEvent do not roll back
OrderFacade.createOrder()), preserve the existing toJson() handling but ensure
any serialization exceptions do not affect order commit by running in
AFTER_COMMIT; apply the same change to the analogous PaymentEventListener
listener method to keep the pattern consistent.
apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java-25-37 (1)

25-37: ⚠️ Potential issue | 🟠 Major

UserActionLog 저장 실패 시 예외 처리가 없어 로그 유실 가능성이 있다.

@TransactionalEventListener(phase = AFTER_COMMIT)으로 원본 트랜잭션 커밋 후 실행되므로, userActionLogRepository.save() 실패 시:

  1. 원본 비즈니스 로직은 이미 커밋됨
  2. 예외가 상위로 전파되어 스레드 풀 에러 로그만 남음
  3. 사용자 액션 로그가 유실됨

운영 환경에서 DB 일시적 장애 시 액션 로그 전체가 누락될 수 있다. 최소한 실패 로깅과 함께 graceful 처리가 필요하다.

🛡️ 에러 핸들링 추가 제안
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(UserActionEventListener.class);
+
     `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
     public void handleUserAction(UserActionEvent event) {
-        transactionTemplate.executeWithoutResult(status -> {
-            UserActionLog log = new UserActionLog(
-                event.memberId(),
-                event.actionType().name(),
-                event.targetId(),
-                event.targetType(),
-                event.metadata()
-            );
-            userActionLogRepository.save(log);
-        });
+        try {
+            transactionTemplate.executeWithoutResult(status -> {
+                UserActionLog actionLog = new UserActionLog(
+                    event.memberId(),
+                    event.actionType().name(),
+                    event.targetId(),
+                    event.targetType(),
+                    event.metadata()
+                );
+                userActionLogRepository.save(actionLog);
+            });
+        } catch (Exception e) {
+            log.error("Failed to save UserActionLog: memberId={}, actionType={}, targetId={}",
+                event.memberId(), event.actionType(), event.targetId(), e);
+            // 재시도 큐 또는 fallback 저장소 고려
+        }
     }
🤖 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/UserActionEventListener.java`
around lines 25 - 37, The handler handleUserAction (annotated
`@TransactionalEventListener` phase=AFTER_COMMIT) currently calls
userActionLogRepository.save inside transactionTemplate.executeWithoutResult
without any error handling, so failures will drop logs and propagate exceptions
to the thread pool; wrap the save logic in a try-catch inside
transactionTemplate.executeWithoutResult, catch Exception, and handle gracefully
by logging a detailed error via your logger (include event.memberId(),
event.actionType(), event.targetId(), event.metadata() and the exception), avoid
rethrowing so the exception doesn't bubble, and optionally emit a metric or
enqueue the event to a retry/fallback (e.g., a dead-letter queue) for later
replay to prevent permanent loss.
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java-30-34 (1)

30-34: ⚠️ Potential issue | 🟠 Major

인프라스트럭처 레이어에서 트랜잭션 관리를 선언하고 있다.

프로젝트 컨벤션에 따르면 트랜잭션 관리는 Application 레이어의 책임이며, Infrastructure 레이어 구현체에서는 @Transactional을 선언하면 안 된다. 현재 markPublished() 메서드에 @Transactional이 선언되어 있으나, 호출부인 OutboxPublisher.publishOutboxEvents() 메서드에는 트랜잭션 경계가 없다.

운영 관점에서 문제: 트랜잭션 관리 책임이 분산되면 레이어별 역할이 모호해져 유지보수가 어렵고, 향후 다른 비즈니스 로직에서 재사용할 때 트랜잭션 의도가 불명확하다.

수정안:

  1. OutboxEventRepositoryImpl.markPublished()에서 @Transactional 제거
  2. OutboxPublisher.publishOutboxEvents()@Transactional 추가

이렇게 하면 Kafka 발행 성공 후 published_at 업데이트가 하나의 트랜잭션 단위로 관리되며, 트랜잭션 책임이 Application 레이어에 일관되게 유지된다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java`
around lines 30 - 34, Remove the `@Transactional` annotation from
OutboxEventRepositoryImpl.markPublished and instead declare the transaction
boundary on the application-layer method OutboxPublisher.publishOutboxEvents by
annotating that method with `@Transactional`; ensure publishOutboxEvents starts
the transaction that performs Kafka publishing and then calls
OutboxEventRepositoryImpl.markPublished(id) so the published_at update occurs
within the same transaction and transaction management remains in the
Application layer.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java-41-50 (1)

41-50: ⚠️ Potential issue | 🟠 Major

실패한 메시지가 영구적으로 손실된다.

배치 처리 후 실패 여부와 관계없이 acknowledgment.acknowledge()를 호출하고 있다. 쿠폰 발급 실패 시 해당 메시지는 재처리되지 않고 영구 손실된다. 선착순 쿠폰 발급은 사용자에게 중요한 기능이므로 메시지 손실은 CS 이슈로 직결된다.

운영 관점에서 다음 개선이 필요하다:

  1. 실패한 메시지를 DLQ(Dead Letter Queue)로 전송하여 수동 재처리 가능하도록 구성
  2. 또는 실패 시 acknowledgment를 호출하지 않고 재시도하도록 변경
  3. 최소한 실패한 requestId 목록을 별도 테이블에 기록하여 추적 가능하도록 구성

추가로 동시성 테스트(50/100)에서 이 시나리오가 커버되는지 확인 필요하다.

🛡️ DLQ 패턴 적용 제안
+    private final KafkaTemplate<String, String> kafkaTemplate;
+    private static final String DLQ_TOPIC = "coupon-issue-requests-dlq";
+
     `@KafkaListener`(
         topics = {"coupon-issue-requests"},
         containerFactory = KafkaConfig.BATCH_LISTENER
     )
     public void handleCouponIssueRequests(
         List<ConsumerRecord<Object, Object>> messages,
         Acknowledgment acknowledgment
     ) {
-        int failCount = 0;
+        List<ConsumerRecord<Object, Object>> failedMessages = new ArrayList<>();
         for (ConsumerRecord<Object, Object> message : messages) {
             try {
                 // ... processing logic
             } catch (Exception e) {
-                failCount++;
+                failedMessages.add(message);
                 log.error("coupon-issue-requests 메시지 처리 실패: partition={}, offset={}, error={}",
                     message.partition(), message.offset(), e.getMessage());
             }
         }
-        if (failCount > 0) {
-            log.warn("coupon-issue-requests 배치 처리 완료: total={}, failed={}", messages.size(), failCount);
+        // Send failed messages to DLQ
+        for (ConsumerRecord<Object, Object> failed : failedMessages) {
+            try {
+                kafkaTemplate.send(DLQ_TOPIC, failed.value().toString());
+            } catch (Exception e) {
+                log.error("DLQ 전송 실패: {}", e.getMessage());
+            }
         }
+        if (!failedMessages.isEmpty()) {
+            log.warn("coupon-issue-requests 배치 처리 완료: total={}, failed={}", 
+                messages.size(), failedMessages.size());
+        }
         acknowledgment.acknowledge();
     }
🤖 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 41 - 50, The current batch handler in CouponIssueConsumer always
calls acknowledgment.acknowledge() even when failCount > 0, causing failed
messages to be lost; modify the code to collect failures (use the existing
failCount and capture failed message identifiers from message.key() or
payload/requestId), then either (A) send each failed message + error context to
a DLQ topic using a KafkaTemplate (e.g.,
kafkaTemplate.send("coupon-issue-requests-dlq", failedKey,
failedPayloadWithError)) or (B) avoid acknowledging the batch so the broker can
redeliver (only call acknowledgment.acknowledge() when there are zero failures),
and additionally persist failed requestIds to a repository (e.g.,
failedRequestRepository.saveAll(failedIds)) for operational tracking; ensure
DLQ/persist operations succeed before acknowledging successful offsets (i.e.,
only call acknowledgment.acknowledge() after DLQ/persist completes), and update
the log statements to include the failed request identifiers for traceability.
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java-18-24 (1)

18-24: ⚠️ Potential issue | 🟠 Major

Outbox 필수 필드 검증이 없어 비정상 이벤트가 저장될 수 있다

잘못된 이벤트가 outbox에 적재되면 소비 실패 재시도로 큐 지연이 누적되는 운영 문제가 생긴다. 생성자에서 aggregateType, aggregateId, eventType, payload의 null/blank 검증을 추가해 저장 이전에 차단하는 수정이 필요하다. 추가 테스트로 빈 문자열/널 입력 시 예외 발생을 검증해야 한다.

As per coding guidelines **/*.java: "null 처리 ... 구현 안정성을 점검한다."

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

In `@apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java`
around lines 18 - 24, The OutboxEvent constructor currently accepts null/blank
values which can store invalid events; update the OutboxEvent(String
aggregateType, String aggregateId, String eventType, String payload) constructor
to validate that aggregateType, aggregateId, eventType, and payload are non-null
and non-blank, and throw an IllegalArgumentException (with clear messages naming
the offending field) when any check fails; add unit tests for OutboxEvent to
assert that null and empty-string inputs for each of these fields cause the
constructor to throw the expected exception.
apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java-18-35 (1)

18-35: ⚠️ Potential issue | 🟠 Major

생성자 입력 검증이 없어 잘못된 도메인 객체가 생성될 수 있다

필수값 누락 객체가 생성되면 저장 시점에서 늦게 실패하거나 로그 분석 파이프라인에서 장애를 유발하는 문제다. 생성자에서 memberId, actionType, targetId, targetType에 대한 null/blank 검증을 추가해 조기 실패하도록 수정이 필요하다. 추가 테스트로 필수값 누락 시 예외가 발생하는지 검증해야 한다.

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

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java`
around lines 18 - 35, Add defensive null/blank checks in the UserActionLog
constructors (both UserActionLog(Long memberId, String actionType, Long
targetId, String targetType, String metadata) and UserActionLog(Long id, Long
memberId, String actionType, Long targetId, String targetType, String metadata,
ZonedDateTime createdAt)): validate that memberId and targetId are non-null, and
that actionType and targetType are non-null and not blank (trim before
checking); throw IllegalArgumentException with a clear message on violation;
ensure createdAt continues to be set appropriately (use provided createdAt in
the 7-arg ctor, ZonedDateTime.now() in the 5-arg ctor) and leave metadata
nullable, and add unit tests asserting constructors throw when required fields
are missing or blank.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java-48-49 (1)

48-49: ⚠️ Potential issue | 🟠 Major

시간 필드 타입 혼용(LocalDateTime/ZonedDateTime)은 만료 판정 불일치 위험이 있다

서버 타임존 차이나 DB 세션 타임존 차이에서 만료 판정이 엇갈리면 쿠폰 오발급/미발급 장애로 이어지는 문제다. 시간 타입을 단일 기준(예: UTC Instant 또는 OffsetDateTime)으로 통일하고 생성 시점도 동일 기준으로 기록하도록 수정하는 것이 필요하다. 추가 테스트로 서로 다른 타임존 환경에서 만료 경계값 판정이 동일한지 검증해야 한다.

Also applies to: 64-68, 70-80, 89-90

🤖 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/MemberCouponIssueEntity.java`
around lines 48 - 49, The entity MemberCouponIssueEntity mixes LocalDateTime
with other timezone-aware types causing expiration mismatches; change its time
fields (e.g., issuedAt, expiresAt, any expiry-related getters/setters referenced
in this class) to a single UTC-based type such as java.time.Instant (or
OffsetDateTime with UTC offset), update JPA mapping accordingly (persist
Instant/OffsetDateTime consistently), ensure creation uses a UTC clock (e.g.,
Instant.now(Clock.systemUTC())) wherever instances are created, and add
unit/integration tests that assert expiration boundary decisions are identical
across different JVM/DB timezone settings.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major

생성자와 수량/기간 계산에 대한 null 방어가 없어 런타임 장애를 유발할 수 있다

비정상 데이터 유입 시 isWithinIssuePeriod()/hasRemainingQuantity()에서 NPE가 발생하면 컨슈머 재처리 루프와 지연 누적 문제가 생긴다. 생성자에서 필수 필드 null 검증을 추가하고, 필요 시 불변식 위반 예외를 즉시 발생시키는 수정이 필요하다. 추가 테스트로 validFrom, validUntil, totalQuantity, issuedQuantity null 입력 실패를 검증해야 한다.

As per coding guidelines **/*.java: "null 처리 ... 구현 안정성을 점검한다."

Also applies to: 31-38

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java`
around lines 17 - 25, The constructor CouponIssueDomain should enforce non-null
and basic invariant checks for totalQuantity, issuedQuantity, validFrom and
validUntil to avoid NPEs in isWithinIssuePeriod() and hasRemainingQuantity();
update the constructor to use Objects.requireNonNull (or throw
IllegalArgumentException) for validFrom/validUntil/totalQuantity/issuedQuantity,
validate totalQuantity and issuedQuantity are non-negative and issuedQuantity <=
totalQuantity, and validate validFrom is not after validUntil (throw a clear
exception on violation); add unit tests that assert the constructor throws for
null validFrom/validUntil/totalQuantity/issuedQuantity and for invalid
quantity/period combinations so invalid data fails fast.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java-81-83 (1)

81-83: ⚠️ Potential issue | 🟠 Major

발급 수량 증가 로직에 상한 검증이 없어 과발급 데이터가 저장될 수 있다

상위 계층 검증이 깨지면 DB 카운터가 실제 재고를 초과해 선착순 정책 신뢰도가 무너지는 문제다. issuedQuantity >= totalQuantity 또는 null 상태에서 예외를 던져 엔티티 레벨에서도 불변식을 강제하는 수정이 필요하다. 추가 테스트로 수량이 가득 찬 상태에서 증가 시도 시 예외와 값 불변을 검증해야 한다.

수정 예시
 public void incrementIssuedQuantity() {
-    this.issuedQuantity++;
+    if (issuedQuantity == null || totalQuantity == null) {
+        throw new IllegalStateException("Coupon quantity is not initialized");
+    }
+    if (issuedQuantity >= totalQuantity) {
+        throw new IllegalStateException("Coupon quantity exceeded");
+    }
+    this.issuedQuantity++;
 }
🤖 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/CouponIssueEntity.java`
around lines 81 - 83, The incrementIssuedQuantity method in CouponIssueEntity
must enforce the invariant that issuedQuantity and totalQuantity are non-null
and that issuedQuantity < totalQuantity before incrementing; update
incrementIssuedQuantity to validate issuedQuantity and totalQuantity (throw
IllegalStateException or a domain-specific exception if issuedQuantity is null,
totalQuantity is null, or issuedQuantity >= totalQuantity) and only increment
when the check passes, leaving issuedQuantity unchanged on failure; add unit
tests that attempt to increment when issuedQuantity == totalQuantity and when
either field is null, asserting that the exception is thrown and issuedQuantity
remains unchanged.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java-45-46 (1)

45-46: ⚠️ Potential issue | 🟠 Major

status를 문자열로 저장하면 유효하지 않은 상태값이 저장될 수 있다

상태 오염이 발생하면 상태 조회 API/집계/재처리 분기에서 예외가 발생해 운영 복구 비용이 커지는 문제다. enum 기반으로 타입을 고정하고 JPA 매핑을 EnumType.STRING으로 제한하는 수정이 필요하다. 추가 테스트로 허용 상태만 저장되는지와 상태 직렬화/역직렬화 라운드트립을 검증해야 한다.

Also applies to: 82-90

🤖 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/MemberCouponIssueEntity.java`
around lines 45 - 46, Replace the String-typed status field in
MemberCouponIssueEntity with a dedicated enum type (e.g., MemberCouponStatus)
and annotate the field with `@Enumerated`(EnumType.STRING) so JPA persists only
named enum values; create the enum with the allowed statuses used by the
application, update any constructors/getters/setters referencing status to use
MemberCouponStatus, and add tests that assert only allowed enum values can be
persisted and that an entity round-trips through
serialization/deserialization/persistence without changing the status value.
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java-44-46 (1)

44-46: ⚠️ Potential issue | 🟠 Major

issue()가 도메인 불변식을 내부에서 강제하지 않아 오발급 위험이 있다

호출자가 canIssue()를 누락하면 삭제/기간만료/수량초과 상태에서도 발급 카운트가 증가하는 운영 문제가 생긴다. issue() 내부에서 canIssue()를 검사하고 실패 시 예외를 던지도록 수정이 필요하다. 추가 테스트로 만료/삭제/소진 상태에서 issue() 호출 시 예외와 카운트 불변을 검증해야 한다.

수정 예시
 public void issue() {
-    this.issuedQuantity++;
+    if (!canIssue()) {
+        throw new IllegalStateException("Coupon cannot be issued");
+    }
+    this.issuedQuantity++;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java`
around lines 44 - 46, In CouponIssueDomain, make issue() enforce the domain
invariant by calling canIssue() and throwing a clear runtime exception (e.g.,
IllegalStateException) when canIssue() is false instead of unconditionally
incrementing issuedQuantity; update the issue() implementation to perform the
check and only increment issuedQuantity on success, and add unit tests that call
issue() on deleted, expired, and fully consumed coupons to assert an exception
is thrown and that issuedQuantity remains unchanged.
apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java-51-60 (1)

51-60: ⚠️ Potential issue | 🟠 Major

Kafka 발행과 markPublished 사이에 장애 발생 시 이벤트 유실 가능성이 있다.

kafkaTemplate.send().get() 성공 후 markPublished() 호출 전에 애플리케이션이 종료되면 이벤트가 Kafka에는 발행되었으나 Outbox에서는 미발행 상태로 남아 중복 발행된다. 이는 At-Least-Once를 보장하므로 의도된 동작일 수 있으나, 반대로 markPublished() 실패 시에도 예외가 발생하여 다음 이벤트 처리가 중단될 수 있다.

또한 kafkaTemplate.send().get()에 타임아웃이 없어 Kafka 브로커 장애 시 무한 대기할 수 있다.

🔧 제안: 타임아웃 추가 및 markPublished 실패 처리
 for (OutboxEvent event : events) {
     try {
         String topic = resolveTopicName(event.getAggregateType());
         String message = buildMessage(event);
-        kafkaTemplate.send(topic, event.getAggregateId(), message).get();
-        outboxEventRepository.markPublished(event.getId());
+        kafkaTemplate.send(topic, event.getAggregateId(), message).get(10, TimeUnit.SECONDS);
+        try {
+            outboxEventRepository.markPublished(event.getId());
+        } catch (Exception markEx) {
+            log.warn("Outbox 이벤트 발행 후 마킹 실패 (중복 발행 가능): eventId={}", event.getId(), markEx);
+        }
     } catch (Exception e) {
-        log.warn("Outbox 이벤트 발행 실패: eventId={}, error={}", event.getId(), e.getMessage());
+        log.warn("Outbox 이벤트 발행 실패: eventId={}", event.getId(), e);
     }
 }

java.util.concurrent.TimeUnit import가 필요하다.

🤖 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/kafka/OutboxPublisher.java`
around lines 51 - 60, OutboxPublisher currently calls kafkaTemplate.send(topic,
key, message).get() then outboxEventRepository.markPublished(...), which risks
infinite blocking on send and event loss if the process dies between send and
markPublished; add a bounded timeout to the send Future (use future.get(timeout,
TimeUnit.SECONDS)) to avoid hanging, catch
TimeoutException/ExecutionException/InterruptedException separately, and on
successful send wrap markPublished in its own try-catch so failures to persist
the published flag are handled (log error and optionally retry or enqueue for
retry) without aborting the loop; update imports to include
java.util.concurrent.TimeUnit and reference methods resolveTopicName,
buildMessage, kafkaTemplate.send(...).get(), and
outboxEventRepository.markPublished(...) when applying the changes.
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java-139-145 (1)

139-145: ⚠️ Potential issue | 🟠 Major

kafkaTemplate.send().get()에 타임아웃이 없어 Kafka 브로커 장애 시 요청 스레드가 무한 대기한다.

사용자 요청 스레드에서 동기적으로 Kafka 발행을 대기하므로, 브로커 연결 지연이나 장애 시 HTTP 요청이 타임아웃될 때까지 블로킹된다. 서비스 전체의 스레드 풀이 고갈될 수 있다.

🔧 제안: 타임아웃 추가
-            kafkaTemplate.send(KafkaTopic.COUPON_ISSUE_REQUESTS, String.valueOf(couponId), payload).get();
+            kafkaTemplate.send(KafkaTopic.COUPON_ISSUE_REQUESTS, String.valueOf(couponId), payload)
+                .get(5, TimeUnit.SECONDS);

java.util.concurrent.TimeUnit import 및 TimeoutException 처리가 필요하다.

🤖 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 139 - 145, The synchronous kafkaTemplate.send(...).get() call in
CouponFacade can block indefinitely; replace it with a timed wait (e.g.,
future.get(<N>, TimeUnit.SECONDS)) and add handling for TimeoutException (and
InterruptedException/ExecutionException) — import java.util.concurrent.TimeUnit
and java.util.concurrent.TimeoutException — log the timeout with couponId and
throw an appropriate CoreException (ErrorType.SERVICE_UNAVAILABLE) on timeout;
if InterruptedException is caught, restore the thread interrupt status
(Thread.currentThread().interrupt()) before throwing; keep the existing
JsonProcessingException handling unchanged.
apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java-75-80 (1)

75-80: 🛠️ Refactor suggestion | 🟠 Major

예외 로깅 시 스택 트레이스가 누락되어 운영 디버깅이 어렵다.

e.getMessage()만 로깅하면 예외의 원인 체인(cause chain)이 유실되어 장애 분석이 어려워진다. 특히 NullPointerException이나 nested exception의 경우 메시지만으로는 원인 파악이 불가능하다.

수정안
        } catch (Exception e) {
            couponIssueRedisRepository.decrementIssuedCount(couponId);
            couponIssueRedisRepository.setRequestStatus(requestId, "FAILED");
-           log.error("쿠폰 발급 실패: couponId={}, memberId={}, requestId={}, error={}",
-               couponId, memberId, requestId, e.getMessage());
+           log.error("쿠폰 발급 실패: couponId={}, memberId={}, requestId={}",
+               couponId, memberId, requestId, e);
        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java`
around lines 75 - 80, The catch block in CouponIssueService currently logs only
e.getMessage(), losing the stack trace; update the error logging in the catch
that calls couponIssueRedisRepository.decrementIssuedCount(couponId) and
couponIssueRedisRepository.setRequestStatus(requestId, "FAILED") so that the
full exception is logged (pass the Throwable e to log.error rather than
e.getMessage(), e.g. keep the structured message with couponId, memberId,
requestId and supply e as the final parameter) to preserve the stack trace and
cause chain for debugging.
apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java-86-88 (1)

86-88: ⚠️ Potential issue | 🟠 Major

예외 발생 시 이벤트가 처리된 것으로 기록되어 메트릭 유실이 발생한다.

운영 관점에서 심각한 데이터 정합성 문제다. payload 파싱 중 예외가 발생하면:

  1. 예외가 catch되어 로깅만 수행
  2. 트랜잭션은 정상 커밋
  3. eventHandledRepository.save(eventId)가 이미 수행되어 이벤트는 처리 완료로 기록
  4. 재처리 시 중복 이벤트로 판단되어 스킵됨
  5. 결과적으로 해당 주문의 매출 메트릭이 영구적으로 유실됨
수정안 - 예외 시 트랜잭션 롤백
        } catch (Exception e) {
            log.error("ORDER_COMPLETED payload 파싱 실패: aggregateId={}, error={}", aggregateId, e.getMessage());
+           throw new RuntimeException("ORDER_COMPLETED 처리 실패", e);
        }

또는 DLQ(Dead Letter Queue) 패턴을 적용하여 파싱 실패 이벤트를 별도 처리할 수 있다.

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java`
around lines 86 - 88, The catch in MetricsService (the ORDER_COMPLETED payload
parsing block) swallows parsing exceptions which allows the transaction to
commit and eventHandledRepository.save(eventId) to mark the event handled,
causing metric loss; fix by preventing commit on parse failure: either move
eventHandledRepository.save(eventId) so it only runs after successful
parsing/processing, or rethrow a RuntimeException from the catch (or mark the
current transaction for rollback) so the transaction rolls back and the event
can be retried or sent to a DLQ; ensure the change is applied in the
ORDER_COMPLETED handling path in MetricsService and keep or add logic to route
unparseable events to a DLQ for manual inspection.
apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java-55-60 (1)

55-60: ⚠️ Potential issue | 🟠 Major

canIssue() 실패 시 Redis 카운터가 감소되지 않아 카운터 드리프트가 발생한다.

운영 관점에서 심각한 문제다. canIssue()false를 반환할 때 Redis의 발급 카운트를 감소시키지 않으면, 시간이 지남에 따라 Redis 카운터와 실제 DB 발급 수량 간 불일치가 누적된다. 이로 인해 실제로는 수량이 남아있음에도 조기에 EXHAUSTED로 처리되는 장애가 발생할 수 있다.

Line 71-74의 DataIntegrityViolationException 처리와 Line 75-80의 일반 예외 처리에서는 decrementIssuedCount를 호출하고 있으나, 이 경로에서는 누락되어 있다.

수정안
                if (!coupon.canIssue()) {
                    log.info("쿠폰 발급 불가: couponId={}, deleted={}, period={}, remaining={}",
                        couponId, coupon.isDeleted(), coupon.isWithinIssuePeriod(), coupon.hasRemainingQuantity());
+                   couponIssueRedisRepository.decrementIssuedCount(couponId);
                    couponIssueRedisRepository.setRequestStatus(requestId, "FAILED");
                    return;
                }

추가로 이 경로에 대한 단위 테스트를 작성해야 한다.

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

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java`
around lines 55 - 60, When coupon.canIssue() returns false you must decrement
the Redis issued counter to avoid drift: in the block inside CouponIssueService
where you currently log and setRequestStatus(requestId, "FAILED"), call
decrementIssuedCount(requestId) (or the existing decrementIssuedCount helper) on
couponIssueRedisRepository before returning, ensuring the same requestId is
used; also add a unit test for the canIssue=false path that verifies
decrementIssuedCount was invoked and request status set to FAILED (mock
couponIssueRedisRepository and coupon.canIssue()).

letter333 and others added 2 commits March 27, 2026 19:18
- applyRecoveryResult()에서 결제 성공 시 PaymentSuccessEvent 발행 추가
- handleCallback()과 동일하게 유저 행동 로깅이 동작하도록 보장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- @eventlistener → @TransactionalEventListener(AFTER_COMMIT) 변경
- toJson()/publishEvent() 예외가 메인 TX를 롤백시키지 않도록 격리
- 중간 UserActionEvent 발행 대신 UserActionLog 직접 저장 (REQUIRES_NEW TX)
- AFTER_COMMIT 내 publishEvent는 TX 컨텍스트 부재로 수신 불가하므로 직접 저장 방식 채택

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java (1)

34-46: 운영 관점에서 예외 처리 및 로깅 추가 권장

@TransactionalEventListener(AFTER_COMMIT)로 변경하여 메인 트랜잭션 격리 문제는 해결되었다. 그러나 현재 구조에서 toJson() 실패 또는 userActionLogRepository.save() 실패 시 예외가 상위로 전파되어 로그에 불필요한 스택 트레이스가 남으며, 장애 발생 시 원인 파악이 어려워진다.

부가 로직(로깅)은 실패해도 핵심 비즈니스에 영향이 없으므로, try-catch로 감싸고 실패 시 경고 로그를 남기는 방식이 운영에 유리하다.

♻️ 운영 안정성을 위한 예외 처리 추가
 `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
 public void handlePaymentSuccess(PaymentSuccessEvent event) {
+    try {
         transactionTemplate.executeWithoutResult(status -> {
             UserActionLog actionLog = new UserActionLog(
                 event.memberId(),
                 ActionType.PAYMENT.name(),
                 event.paymentId(),
                 "PAYMENT",
                 toJson(Map.of("orderId", event.orderId(), "amount", event.amount()))
             );
             userActionLogRepository.save(actionLog);
         });
+        log.debug("PaymentSuccessEvent 처리 완료: paymentId={}", event.paymentId());
+    } catch (Exception e) {
+        log.warn("PaymentSuccessEvent 처리 실패: paymentId={}, error={}", event.paymentId(), e.getMessage(), e);
+    }
 }

추가 테스트: toJson() 또는 save() 실패 시 예외가 외부로 전파되지 않고 로그만 남는지 검증하는 단위 테스트를 추가하라.

🤖 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 34 - 46, handlePaymentSuccess in PaymentEventListener should not
let toJson() or userActionLogRepository.save() exceptions propagate; wrap the
transactionTemplate.executeWithoutResult body (or its contents) in a try-catch
that catches Exception, logs a warning with the exception (e.g.,
logger.warn("Failed to record user action for payment {}, member {}",
event.paymentId(), event.memberId(), e)), and do not rethrow so the failure of
the ancillary logging doesn't affect business flow; update handlePaymentSuccess
and related pieces (transactionTemplate.executeWithoutResult, toJson,
userActionLogRepository.save) accordingly and add a unit test that simulates
toJson() or save() throwing to verify exceptions are swallowed and only a
warning is logged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java`:
- Around line 34-46: handlePaymentSuccess in PaymentEventListener should not let
toJson() or userActionLogRepository.save() exceptions propagate; wrap the
transactionTemplate.executeWithoutResult body (or its contents) in a try-catch
that catches Exception, logs a warning with the exception (e.g.,
logger.warn("Failed to record user action for payment {}, member {}",
event.paymentId(), event.memberId(), e)), and do not rethrow so the failure of
the ancillary logging doesn't affect business flow; update handlePaymentSuccess
and related pieces (transactionTemplate.executeWithoutResult, toJson,
userActionLogRepository.save) accordingly and add a unit test that simulates
toJson() or save() throwing to verify exceptions are swallowed and only a
warning is logged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4949f668-0ae8-4e2a-96c9-6ac1b406264b

📥 Commits

Reviewing files that changed from the base of the PR and between 7e7abd6 and e46a4c2.

📒 Files selected for processing (4)
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant