[volume-7] ApplicationEvent 분리 + Transactional Outbox + Kafka 기반 비동기 처리#270
[volume-7] ApplicationEvent 분리 + Transactional Outbox + Kafka 기반 비동기 처리#270juoklee wants to merge 8 commits intoLoopers-dev-lab:juokleefrom
Conversation
- CouponIssueRequest 엔티티 및 상태 관리 (PENDING/COMPLETED/FAILED)
- CouponIssueFacade: 비동기 발급 요청, Consumer 처리, 상태 조회
- CouponIssueV1Controller: POST /issue-async (202), GET /issue-requests/{id}
- CouponIssueConsumer: Kafka Consumer (manual ack, 멱등 처리)
- CouponService.issueCoupon에 noRollbackFor 적용 (트랜잭션 공유 시 rollback 방지)
- 동시성 테스트 (200명 동시 요청 → 100장 한정 수량 보장)
- 멱등성 테스트 (중복 processIssuance 호출 시 1장만 발급)
- @transactional → TransactionTemplate 전환으로 재시도 루프 적용 - OptimisticLockingFailureException 발생 시 랜덤 백오프 재시도 (최대 20회) - 좋아요 동시성 테스트 (100건 동시 → likeCount 정확히 100) - 주문 동시성 테스트 (50건 동시 → orderCount, salesAmount 정합성 보장) - 멱등성 테스트 (같은 eventId 중복 처리 방지)
📝 WalkthroughWalkthrough이벤트 기반 아키텍처를 도입하여 주문, 결제, 좋아요 등의 도메인 이벤트를 발행하고, Outbox 패턴으로 트랜잭션 경계를 관리한다. 쿠폰 비동기 발급 시스템을 추가하고, commerce-streamer에서 Kafka 이벤트를 소비하여 상품 메트릭을 집계한다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant Commerce API
participant ApplicationEventPublisher
participant OutboxEventPublisher
participant Database
participant OutboxRelayScheduler
participant Kafka
Client->>Commerce API: POST /api/v1/coupons/{id}/issue-async
Commerce API->>Database: Check duplicate request
Commerce API->>Database: Save CouponIssueRequest (PENDING)
Commerce API->>ApplicationEventPublisher: Publish (in-memory event)
Commerce API->>OutboxEventPublisher: Publish (OutboxEvent entity)
OutboxEventPublisher->>Database: Save OutboxEvent (published=false)
Commerce API-->>Client: 202 ACCEPTED
OutboxRelayScheduler->>Database: Query unpublished events
Database-->>OutboxRelayScheduler: Return OutboxEvent list
OutboxRelayScheduler->>Kafka: Send to coupon-issue-requests topic
Kafka-->>OutboxRelayScheduler: Ack
OutboxRelayScheduler->>Database: Mark OutboxEvent published=true
sequenceDiagram
participant Kafka
participant CouponIssueConsumer
participant CouponIssueFacade
participant CouponService
participant ApplicationEventPublisher
participant Commerce Streamer
participant Database
Kafka->>CouponIssueConsumer: Message (requestId)
CouponIssueConsumer->>CouponIssueFacade: processIssuance(requestId)
CouponIssueFacade->>Database: Load CouponIssueRequest
CouponIssueFacade->>CouponService: issueCoupon(couponId, memberId)
CouponService->>Database: Update coupon quantity & create MemberCoupon
Database-->>CouponService: MemberCouponId
CouponService-->>CouponIssueFacade: Success
CouponIssueFacade->>Database: Mark request COMPLETED
CouponIssueFacade->>ApplicationEventPublisher: Publish event (async)
ApplicationEventPublisher->>Commerce Streamer: Event delivered (async)
CouponIssueConsumer->>Kafka: Acknowledge
sequenceDiagram
participant Kafka
participant OrderEventConsumer
participant MetricsAggregator
participant EventHandledRepository
participant ProductMetricsRepository
participant Database
Kafka->>OrderEventConsumer: ORDER_CREATED (eventId, items)
OrderEventConsumer->>MetricsAggregator: handleOrderCreated(eventId, items)
MetricsAggregator->>EventHandledRepository: Check existsByEventId
alt Event already handled (idempotent)
EventHandledRepository-->>MetricsAggregator: true
MetricsAggregator->>Database: No update (return early)
else New event
EventHandledRepository-->>MetricsAggregator: false
loop For each OrderItemInfo
MetricsAggregator->>ProductMetricsRepository: findByProductId
alt Metrics exist
ProductMetricsRepository-->>MetricsAggregator: ProductMetrics
MetricsAggregator->>Database: Update orderCount, salesAmount
else Create new
MetricsAggregator->>Database: Create & save ProductMetrics
end
end
MetricsAggregator->>EventHandledRepository: Save EventHandled
EventHandledRepository->>Database: Insert record
end
OrderEventConsumer->>Kafka: Acknowledge offset
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 17
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (11)
apps/commerce-streamer/src/main/resources/application.yml-17-17 (1)
17-17:⚠️ Potential issue | 🟡 Minor애플리케이션 이름 변경에 따른 관측 체계 연동 확인이 필요하다
Line 17 변경은 로그 인덱스, 메트릭 라벨, 알림 룰 필터 조건에 직접 영향을 주어 장애 탐지 누락을 만들 수 있다. 운영 관점에서 서비스명 변경 영향 범위를 배포 체크리스트에 명시하고, 대시보드/알림 쿼리를 함께 갱신해야 한다.
추가 테스트로 dev 환경 스모크 테스트에서service=commerce-streamer기준 메트릭 수집/알림 트리거가 정상 동작하는지 검증을 권장한다.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-streamer/src/main/resources/application.yml` at line 17, 앱 설정에서 name: commerce-streamer 변경은 로그/메트릭/알림 쿼리의 서비스 라벨에 영향을 주므로, application*.yml의 name 항목 변경 사유와 영향 범위를 배포 체크리스트(운영 문서)에 명시하고 관련 대시보드/알림 룰의 쿼리(예: service=commerce-streamer 라벨 필터), 로그 인덱스 및 메트릭 라벨을 찾아 모두 갱신하세요; 변경 후 dev 환경 스모크 테스트에서 service=commerce-streamer 기준으로 메트릭 수집과 알림 트리거가 정상 동작하는지 검증하고(테스트 절차와 결과를 체크리스트에 첨부), 변경 내역을 application*.yml 운영 영향 근거로 문서화해 승인을 받으세요.apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java-5-13 (1)
5-13:⚠️ Potential issue | 🟡 Minor이벤트 payload의 리스트를 방어적으로 복사해야 한다.
현재
items가 외부에서 넘긴 리스트 참조를 그대로 보존한다. 발행 직후 원본 컬렉션이 수정되면 outbox 직렬화와 비동기 리스너가 서로 다른 주문 스냅샷을 읽을 수 있어 운영에서 재현하기 어려운 불일치가 생긴다. canonical constructor에서List.copyOf와Objects.requireNonNull로 고정하고, 원본 리스트 변경이 event에 반영되지 않는지와null입력이 거부되는지를 검증하는 테스트를 추가하는 편이 안전하다.🔧 수정안
import java.util.List; +import java.util.Objects; public record OrderCreatedEvent( Long orderId, Long memberId, long totalAmount, List<OrderItemSnapshot> items ) { + public OrderCreatedEvent { + orderId = Objects.requireNonNull(orderId, "orderId"); + memberId = Objects.requireNonNull(memberId, "memberId"); + items = List.copyOf(Objects.requireNonNull(items, "items")); + } + public record OrderItemSnapshot(Long productId, String productName, long price, int quantity) {} }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/event/OrderCreatedEvent.java` around lines 5 - 13, OrderCreatedEvent currently stores the external items list reference; change the canonical constructor for OrderCreatedEvent to defensively copy and null-check the items by wrapping the incoming items with Objects.requireNonNull(...) and List.copyOf(...), ensuring the stored field is an unmodifiable copy (leave OrderItemSnapshot as-is), and add unit tests that assert modifying the original list after constructing OrderCreatedEvent does not change the event's items and that passing null for items throws a NullPointerException.apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-17-27 (1)
17-27:⚠️ Potential issue | 🟡 Minor로깅에 민감 정보가 포함될 수 있다.
memberId와amount가 로그에 출력된다. 코딩 가이드라인에 따르면 "로깅 시 민감정보 노출 가능성을 점검"해야 한다.운영 환경에서:
memberId는 사용자 식별자로 GDPR/개인정보보호법 대상일 수 있다amount는 금융 정보로 PCI-DSS 등 규정 대상일 수 있다reason필드에 사용자 입력이 포함될 경우 로그 인젝션 위험이 있다로그 레벨을
DEBUG로 변경하거나, 프로덕션에서는 마스킹 처리를 적용하는 것을 권장한다. 또한 로그 집계 시스템의 접근 제어 정책을 확인해야 한다.🤖 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 17 - 27, The logs in handlePaymentCompleted and handlePaymentFailed currently emit potentially sensitive fields (memberId, amount, reason) from PaymentCompletedEvent and PaymentFailedEvent; change the logging to avoid raw sensitive data by either lowering the log level to DEBUG for these handlers (handlePaymentCompleted, handlePaymentFailed) and/or replace memberId and amount with masked/aggregated values (e.g., hash or partial mask) and sanitize/escape reason to prevent log injection before passing to log.info; ensure masking/sanitization happens in the event-to-log path so production logging never emits full identifiers or raw user-provided text.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java-26-32 (1)
26-32:⚠️ Potential issue | 🟡 Minor예외 처리가 프로젝트 패턴과 불일치한다.
학습된 가이드라인에 따르면, 이 프로젝트는
CoreException을 통한 통합 에러 핸들링을 사용한다.IllegalArgumentException을 직접 던지면ApiControllerAdvice에서 일관된 응답 포맷으로 처리되지 않을 수 있다.또한 직렬화 실패는 개발자가 디버깅해야 할 상황이므로, 예외 발생 전에 payload 타입/내용을 로깅하면 운영 시 원인 분석이 용이하다.
🔧 수정 제안
private String serialize(Object payload) { try { return objectMapper.writeValueAsString(payload); } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Outbox 이벤트 직렬화 실패", e); + log.error("Outbox 이벤트 직렬화 실패 - payloadType={}", payload.getClass().getSimpleName(), e); + throw new CoreException(ErrorCode.INTERNAL_SERVER_ERROR, "Outbox 이벤트 직렬화 실패", e); } }Based on learnings: "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.java` around lines 26 - 32, In OutboxEventPublisherImpl.serialize(Object payload) replace throwing IllegalArgumentException with wrapping the JsonProcessingException into the project's unified CoreException so ApiControllerAdvice can handle it; before rethrowing, log the payload type and a safe representation of its content (using the existing logger) to aid debugging, and include the original JsonProcessingException as the cause when constructing the CoreException so the stack trace is preserved.apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java-11-16 (1)
11-16:⚠️ Potential issue | 🟡 Minor복합 인덱스 전략을 재검토해야 한다.
현재
CouponIssueRequest엔티티에는idx_member_coupon (member_id, coupon_id)인덱스가 정의되어 있고,request_id는unique=true제약으로 인덱스가 암묵적으로 생성된다. 다만existsByMemberIdAndCouponIdAndStatusIn()메서드는 세 컬럼(memberId, couponId, status)에 필터를 적용하는데, 현재 인덱스는 처음 두 컬럼만 포함한다.수정안:
idx_member_coupon를 확장하여 status 컬럼을 추가하면 쿼리 성능을 개선할 수 있다.`@Index`(name = "idx_member_coupon_status", columnList = "member_id, coupon_id, status")현재 설계상 select-then-act 패턴으로 동시성 중복 방지는 미뤄진 상태이므로, 검색 성능은 매우 중요한 부분이다. 마이그레이션에서 인덱스 생성을 확인하고, 필요시 실제 쿼리 실행 계획(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/infrastructure/coupon/CouponIssueRequestJpaRepository.java` around lines 11 - 16, The composite index on CouponIssueRequest is missing the status column used by existsByMemberIdAndCouponIdAndStatusIn(Long memberId, Long couponId, List<CouponIssueRequestStatus> statuses), causing suboptimal query plans; update the entity's index definition (replace or extend idx_member_coupon) to include status (e.g., change to idx_member_coupon_status with columnList "member_id, coupon_id, status") and add a migration to create that index, then validate with EXPLAIN that queries for the repository method use the new index.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java-80-82 (1)
80-82:⚠️ Potential issue | 🟡 Minor
addSalesAmount에 음수 검증이 없어 데이터 무결성 위험이 있다.
amount가 음수로 전달될 경우salesAmount가 감소하여 잘못된 집계 결과가 발생할 수 있다. 환불 등의 케이스가 아니라면 방어 로직 추가를 권장한다.🛡️ 음수 검증 추가
public void addSalesAmount(long amount) { + if (amount < 0) { + throw new IllegalArgumentException("salesAmount cannot be negative: " + amount); + } this.salesAmount += amount; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java` around lines 80 - 82, The addSalesAmount method in ProductMetrics currently allows negative values which can corrupt salesAmount; update ProductMetrics.addSalesAmount to validate that the incoming amount is non-negative (amount >= 0) and reject negatives (e.g., throw IllegalArgumentException with a clear message including the invalid amount) before updating the salesAmount field to enforce data integrity.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java-46-48 (1)
46-48:⚠️ Potential issue | 🟡 Minor로그 메시지가 실제 발행 건수를 정확히 반영하지 않는다.
예외 발생 시
break로 루프를 중단하지만, 로그는events.size()(조회 건수)를 출력한다. 실제로 성공한 건수와 불일치하여 모니터링/알림 지표로 사용하기 어렵다.♻️ 성공 건수 별도 추적
+ int relayedCount = 0; for (OutboxEvent event : events) { try { // ... send logic ... event.markPublished(); + relayedCount++; } catch (Exception e) { // ... error handling ... break; } } - if (!events.isEmpty()) { - log.info("Outbox relay 완료: {}건", events.size()); + if (relayedCount > 0) { + log.info("Outbox relay 완료: {}건 (전체 {}건 중)", relayedCount, events.size()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java` around lines 46 - 48, The log currently prints events.size() which is the fetched batch size, not the number actually published; change the loop in OutboxRelayScheduler (the method that iterates over events and may break on exception) to maintain a local counter (e.g., successCount) that you increment only when an event is successfully relayed, and log that successCount (and optionally total attempted) instead of events.size(); also ensure you log the current successCount before breaking on exception so the recorded number reflects actual successful publishes for the batch.apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java-88-88 (1)
88-88:⚠️ Potential issue | 🟡 Minor주석과 실제 값 불일치
주석에 "50건 주문 동시 집계"라고 되어 있으나, 실제
orderCount는 300이다.- // when — 50건 주문 동시 집계 + // when — 300건 주문 동시 집계🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java` at line 88, The inline comment in MetricsAggregatorConcurrencyTest says "50건 주문 동시 집계" but the test uses orderCount = 300, so make them consistent: either change the comment to reflect 300 orders or set the orderCount variable (symbol: orderCount in class MetricsAggregatorConcurrencyTest) to 50; update the comment near the concurrent aggregation test and ensure any related assertions or setup that depend on orderCount are adjusted accordingly.apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java-48-48 (1)
48-48:⚠️ Potential issue | 🟡 Minor주석과 실제 값 불일치
주석에 "100명이 동시에 좋아요"라고 되어 있으나, 실제
requestCount는 500이다. 주석이 코드와 일치하지 않으면 유지보수 시 혼란을 야기한다.- // when — 100명이 동시에 좋아요 + // when — 500명이 동시에 좋아요🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java` at line 48, The comment in MetricsAggregatorConcurrencyTest says "100명이 동시에 좋아요" but the test uses requestCount = 500, causing mismatch; either update the comment to reflect 500 (e.g., "500명이 동시에 좋아요") or change the requestCount to 100 so the comment and code match—locate the requestCount variable in the test method (or class) and make the comment and the constant value consistent.apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java-74-74 (1)
74-74:⚠️ Potential issue | 🟡 Minor주석과 실제 값 불일치
주석에 "200명이 동시에"라고 되어 있으나, 실제
requestCount는 1000이다.- // when — 1차: 200명이 동시에 processIssuance 호출 (동시성 경합 시뮬레이션) + // when — 1차: 1000명이 동시에 processIssuance 호출 (동시성 경합 시뮬레이션)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java` at line 74, The comment in CouponAsyncIssueConcurrencyTest says "200명이 동시에" but the test uses requestCount = 1000; update the comment to match the actual value or change requestCount to 200 so they are consistent—locate the requestCount variable in the test method (or class-level declaration) and either edit the Korean comment to "1000명이 동시에" or set requestCount to 200, and ensure any related explanatory text referencing concurrency matches the chosen value.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java-63-68 (1)
63-68:⚠️ Potential issue | 🟡 Minor
unwrapPayload()메서드의 비효율적 직렬화 패턴 개선 필요현재 구현은
OutboxEventPublisherImpl에서 payload를 문자열로 직렬화한 후, 다시 KafkaJsonSerializer로 이중 인코딩되고 있다. 수신 측에서readValue(bytes, String.class)→readTree()순서로 처리하는 것은 문자열 래핑을 풀어내고 다시 파싱하는 비효율적인 과정이다.운영 문제: 불필요한 직렬화/역직렬화 단계가 CPU 사용량을 증가시키고, 특히 고처리량 환경에서 성능 저하를 초래한다.
개선 방안:
unwrapPayload()에서 바이트를 직접JsonNode로 파싱하도록 변경:return objectMapper.readTree(bytes);- 또는 페이로드 저장 시 이중 인코딩 제거 검토 필요
OrderEventConsumer,CouponIssueConsumer등 다른 consumer도 동일 패턴을 사용하므로 일괄 최적화를 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java` around lines 63 - 68, The unwrapPayload(Object value) method currently deserializes bytes into a String then reparses—replace this with direct JSON parsing to avoid double-encoding: in unwrapPayload(Object value) (and the same method in OrderEventConsumer, CouponIssueConsumer, etc.) detect byte[] and call objectMapper.readTree(bytes) to return a JsonNode (or otherwise parse bytes directly to the target JSON type) instead of objectMapper.readValue(bytes, String.class); update the method return type and downstream callers accordingly or harmonize payload handling so the consumer works with JsonNode directly.
🧹 Nitpick comments (23)
apps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.java (1)
28-28: 스케줄 주기를 설정값으로 외부화하는 것이 운영 대응에 유리하다Line 28의 고정값(600_000ms)은 운영 중 지연 증가나 DB 부하 상황에서 즉시 조정이 어려워 재배포 비용을 유발한다. 설정 기반으로 전환하면 환경별 튜닝과 장애 대응 속도가 개선된다.
수정안 diff
- `@Scheduled`(fixedRate = 600_000) + `@Scheduled`(fixedRateString = "${like.sync.fixed-rate-ms:600000}")추가 테스트로
@SpringBootTest(properties = "like.sync.fixed-rate-ms=1000")환경에서 스케줄 트리거 주기가 오버라이드되는지 검증하는 통합 테스트를 권장한다.🤖 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/LikeCountSyncScheduler.java` at line 28, The fixed 600_000ms in the `@Scheduled` annotation of LikeCountSyncScheduler should be externalized to a configuration property so operators can change the schedule without redeploying; replace the hardcoded fixedRate with a property reference (e.g., use fixedRateString = "${like.sync.fixed-rate-ms:600000}") in the `@Scheduled` on LikeCountSyncScheduler and add a default fallback value, update application.yml/application.properties to document like.sync.fixed-rate-ms, and add an integration test using `@SpringBootTest`(properties = "like.sync.fixed-rate-ms=1000") to assert the scheduler triggers with the overridden interval.apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.java (1)
5-6: 문자열 중심 publish 시그니처는 라우팅 오타를 운영에서만 드러내는 구조다.현재 형태는
aggregateType/eventType/topic/partitionKey오타가 컴파일 단계에서 차단되지 않아, 실제 운영에서만 이벤트 유실이나 오분배로 드러날 수 있다.
수정안으로 이벤트 메타데이터를 값 객체(또는 enum)로 묶어 타입 안정성을 높이는 편이 안전하다.
추가 테스트로 잘못된 이벤트 타입/토픽이 생성 단계에서 차단되는 단위 테스트와, 정상 메타데이터만 Outbox 저장되는 계약 테스트를 추가하는 것이 좋다.제안 diff
public interface OutboxEventPublisher { - - void publish(String aggregateType, Long aggregateId, String eventType, - String topic, String partitionKey, Object payload); + void publish(OutboxPublishCommand command); } + +public record OutboxPublishCommand( + String aggregateType, + Long aggregateId, + String eventType, + String topic, + String partitionKey, + Object payload +) {}🤖 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/OutboxEventPublisher.java` around lines 5 - 6, The current publish(String aggregateType, Long aggregateId, String eventType, String topic, String partitionKey, Object payload) API is string-centric and allows routing typos to slip into production; change the contract on OutboxEventPublisher to accept a typed metadata value object (e.g., EventMetadata or OutboxEventMeta) or enums (e.g., EventType, EventTopic) instead of raw strings for aggregateType/eventType/topic/partitionKey, update all callers to construct and pass that metadata object, and adjust storage/serialization paths to persist the metadata fields from the typed object; also add unit tests that assert invalid enum/value-object construction is rejected and a contract test ensuring only events with valid metadata are stored by the Outbox repository.apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java (1)
67-67: 비동기 전용 롤백 정책을 공유 도메인 서비스에 직접 두지 않는 편이 안전하다.
noRollbackFor = CoreException.class는 현재 비동기 발급 요청의 실패 상태 기록을 위해 필요한 정책인데, 이를CouponService.issueCoupon()에 붙이면 다른 호출자도 동일한 커밋 규칙을 강제로 따라가게 된다. 운영 중 동기 발급 경로나 후행 검증이 추가되면 business 예외에서도 수량 차감만 커밋되는 부분 커밋으로 이어질 수 있으니, 비동기 전용 애플리케이션 서비스나TransactionTemplate쪽으로 분리하고 이 메서드는 기본 롤백 규칙을 유지하는 편이 안전하다. 추가 테스트로는 동기 호출과 비동기 호출 각각에서CoreException발생 시 동기 경로는 전체 롤백되고 비동기 요청 상태만FAILED로 남는지 분리 검증해야 한다.As per coding guidelines
**/domain/**/*.java: 도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다,**/*Service*.java: 트랜잭션 경계(@Transactional) 위치와 전파, readOnly, 롤백 조건을 점검한다.🤖 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/CouponService.java` at line 67, The `@Transactional`(noRollbackFor = CoreException.class) on CouponService.issueCoupon mixes an async-only rollback policy into a shared domain service; remove that attribute from CouponService.issueCoupon to restore default rollback behavior, and move the noRollbackFor handling into the async issuance boundary (either a dedicated async application service or use TransactionTemplate to execute the async-only flow with noRollbackFor = CoreException.class). Update callers so synchronous paths call the domain method with normal transactions, and add tests that assert CoreException causes full rollback for the sync path but leaves only the async request marked FAILED for the async path.apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)
45-48: 이벤트 계약 문자열을 공용 상수로 고정하는 편이 안전하다.
aggregateType,eventType,topic이 발행 지점마다 문자열로 흩어지면 한쪽만 이름이 바뀌어도 운영에서는 조용히 메시지가 끊긴다. 공용 enum/상수로 승격하고, outbox 레코드 metadata를 검증하는 계약 테스트를 추가해 publisher와 consumer가 같은 값을 쓰도록 고정하는 편이 낫다.🔧 수정안
- outboxEventPublisher.publish("PRODUCT", productId, "LIKE_TOGGLED", - "catalog-events", String.valueOf(productId), productLikeEvent); + outboxEventPublisher.publish(OutboxContracts.Aggregate.PRODUCT, productId, OutboxContracts.EventType.LIKE_TOGGLED, + OutboxContracts.Topic.CATALOG_EVENTS, String.valueOf(productId), productLikeEvent);
OutboxContracts는 별도 파일에서 publisher와 consumer가 함께 참조하는 상수 또는 enum으로 정의하면 된다.🤖 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 45 - 48, Replace the inline strings used when publishing outbox events with shared constants/enums and add a contract test to ensure publisher and consumer agree on metadata; specifically, introduce a shared OutboxContracts (or related enums like OutboxAggregateType, OutboxEventType, OutboxTopic) and update the publish call in LikeFacade (the outboxEventPublisher.publish invocation that currently uses "PRODUCT", "LIKE_TOGGLED", "catalog-events") to use those constants, and add a contract test that loads OutboxContracts and asserts the outbox record metadata (aggregateType/eventType/topic) matches the expected constant values so publisher and consumer cannot drift.apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java (1)
4-4: 사용되지 않는 import가 있다.
CouponIssueRequestRepository가 import되었으나 이 인터페이스에서 사용되지 않는다.♻️ 수정 제안
package com.loopers.infrastructure.coupon; import com.loopers.domain.coupon.CouponIssueRequest; -import com.loopers.domain.coupon.CouponIssueRequestRepository; import com.loopers.domain.coupon.CouponIssueRequestStatus; import org.springframework.data.jpa.repository.JpaRepository;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java` at line 4, Remove the unused import of CouponIssueRequestRepository from CouponIssueRequestJpaRepository: the import statement for com.loopers.domain.coupon.CouponIssueRequestRepository is not referenced anywhere in the CouponIssueRequestJpaRepository class, so delete that import to clean up unused dependencies and satisfy static analysis.apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java (1)
29-35:ZonedDateTime.now()직접 호출은 테스트 격리성을 저하시킨다.정적 팩토리 메서드 내에서
ZonedDateTime.now()를 직접 호출하면 단위 테스트에서 시간 검증이 어렵다. 시간 관련 버그(예: 타임존 불일치로 인한 중복 처리)를 재현하기 어렵다.운영 관점에서
handledAt값이 서버 타임존에 의존하므로, 멀티 리전 배포 시 일관성 문제가 발생할 수 있다.Clock을 주입받거나Instant/UTC기반으로 저장하는 방안을 고려해야 한다.♻️ Clock 주입 방식 제안
- public static EventHandled of(String eventId, String eventType) { + public static EventHandled of(String eventId, String eventType, Clock clock) { EventHandled entity = new EventHandled(); entity.eventId = eventId; entity.eventType = eventType; - entity.handledAt = ZonedDateTime.now(); + entity.handledAt = ZonedDateTime.now(clock); return entity; } + + public static EventHandled of(String eventId, String eventType) { + return of(eventId, eventType, Clock.systemUTC()); + }🤖 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/idempotency/EventHandled.java` around lines 29 - 35, EventHandled.of currently calls ZonedDateTime.now() directly which hurts testability and multi-region consistency; change the factory to accept a Clock (e.g., EventHandled.of(String eventId, String eventType, Clock clock)) or return/store an Instant in UTC (e.g., handledAt as Instant.from(Instant.now(clock))). Update the EventHandled.of method, the handledAt field type if necessary, and call Instant.now(clock) or ZonedDateTime.now(clock).withZone(ZoneOffset.UTC) so tests can inject a fixed Clock and production uses Clock.systemUTC() for consistent UTC timestamps.apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.java (1)
18-92: 예외/경계 케이스 테스트가 누락되었다.현재 테스트는 정상 흐름만 검증한다. 운영 환경에서 예기치 않은 입력으로 인한 장애를 방지하려면 다음 케이스를 추가하는 것이 좋다:
LikeTargetType에 새로운 타입이 추가될 경우의 동작 (default/else 분기)targetId가 존재하지 않는 ID일 때 서비스 계층의 예외 처리실제 listener가
@Async로 동작하므로, 예외 발생 시 로깅/알림 처리가 제대로 되는지도 통합 테스트에서 확인이 필요하다.🤖 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/event/LikeEventListenerTest.java` around lines 18 - 92, Add unit tests for the missing edge cases: (1) create a test that sends a LikeToggledEvent with an unexpected/unsupported LikeTargetType (use null) to LikeEventListener.handleLikeToggled and assert the listener does not call productService or brandService (verifyNoInteractions(productService, brandService)) and does not throw; (2) create a test where productService (or brandService) is stubbed to throw a not-found exception when increaseLikeCount/decreaseLikeCount is invoked (e.g., doThrow(new EntityNotFoundException()).when(productService).increaseLikeCount(10L)), call handleLikeToggled with that targetId and verify the exception is handled (no exception propagated) and that the appropriate service method was invoked (verify(productService).increaseLikeCount(10L)) and no other interactions occur; reference LikeToggledEvent, LikeTargetType, LikeEventListener.handleLikeToggled, productService and brandService in the tests.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.java (2)
56-59:ZonedDateTime.now()대신 명시적 시간대 사용을 권장한다.
ZonedDateTime.now()는 JVM의 기본 시간대를 사용하므로, 서버 간 시간대 설정이 다를 경우 데이터 불일치가 발생할 수 있다. 운영 환경에서 여러 서버가 다른 시간대로 설정되어 있다면createdAt기준 정렬 및 조회 결과가 예상과 다를 수 있다.♻️ UTC 사용 권장
`@PrePersist` private void prePersist() { - this.createdAt = ZonedDateTime.now(); + this.createdAt = ZonedDateTime.now(java.time.ZoneOffset.UTC); }🤖 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/OutboxEvent.java` around lines 56 - 59, The prePersist method in OutboxEvent sets createdAt with ZonedDateTime.now() which relies on the JVM default zone; change it to produce a UTC timestamp explicitly (e.g., use ZonedDateTime.now(ZoneOffset.UTC) or Instant.now() converted to ZonedDateTime/OffsetDateTime in UTC) so createdAt is consistent across servers; update the prePersist method to assign createdAt using the chosen UTC-based call and ensure any related serialization/deserialization expectations for createdAt remain compatible.
17-27: 다중 인스턴스 확장 시 Outbox Relay 중복 발행 방지 방안 검토 필요현재 단일 인스턴스로 운영 중이므로 즉시 문제는 아니지만, 향후 다중 인스턴스 배포로 확장할 경우 다음 위험이 발생한다.
운영 관점 문제점:
- 여러 스케줄러 인스턴스가 동시에
findTop100ByPublishedFalse()로 미발행 이벤트를 조회하면, 동일 이벤트를 여러 인스턴스가 동시에 Kafka에 발행할 수 있다.- OutboxEvent에
@Version필드가 없어 낙관적 잠금이 불가능하고, ShedLock 같은 분산 락도 미적용 상태다.- Consumer 측 멱등성(
event_handled테이블)이 2차 방어선이지만, 네트워크 비용과 consumer 부하를 고려하면 producer 측 중복 방지가 우선이다.권장 수정안:
다중 인스턴스 확장 시 다음 중 하나를 선택하여 적용한다.
@Version필드 추가 후 낙관적 잠금 구현 (OutboxEvent에 버전 컬럼 추가)- ShedLock 또는 분산 락 도입 (스케줄러 메서드에
@SchedulerLock적용)추가 테스트:
다중 인스턴스 배포 시 동일 이벤트에 대한 중복 발행 없음을 검증하는 통합 테스트 추가 필요하다.🤖 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/OutboxEvent.java` around lines 17 - 27, OutboxEvent 엔티티에서 다중 인스턴스 환경에서 중복 발행이 발생할 수 있으니 우선 OutboxEvent 클래스에 낙관적 락용 버전 필드(`@Version private Long version`)를 추가하고, 발행 로직(예: findTop100ByPublishedFalse()를 호출하는 스케줄러/서비스)에서 이벤트를 소비·수정(예: published=true로 변경)할 때 JPA의 낙관적 락을 활용하도록 변경해 주세요; 대안으로 스케줄러 메서드에 분산 락(`@SchedulerLock`)을 적용하는 방법도 제시하니 OutboxEvent에 `@Version`을 추가하거나 스케줄러 메서드에 `@SchedulerLock`을 적용해 중복 발행을 방지하고, 이후 다중 인스턴스 통합 테스트를 추가해 동일 이벤트가 중복 발행되지 않음을 검증해 주세요.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (2)
50-54:updatedAt타임스탬프도 명시적 UTC 사용을 권장한다.
OutboxEvent와 동일하게ZonedDateTime.now()는 JVM 기본 시간대를 사용한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java` around lines 50 - 54, The onUpdate() lifecycle method sets updatedAt using the JVM default zone; change it to set updatedAt explicitly in UTC (e.g., use ZonedDateTime.now(ZoneOffset.UTC) or Instant.now().atZone(ZoneOffset.UTC)) so updatedAt matches the UTC behaviour used by OutboxEvent; update the onUpdate() method accordingly to assign the UTC ZonedDateTime to the updatedAt field.
70-74:decrementLikeCount가 음수 방지 시 무시하는 방식은 운영 상 디버깅을 어렵게 할 수 있다.
likeCount가 0일 때 감소 요청이 들어오면 아무 동작 없이 무시된다. 이는 이벤트 순서 역전이나 중복 unlike 이벤트 발생 시 문제를 추적하기 어렵게 만든다. 최소한 로그를 남기거나, 호출자에게 실패 여부를 반환하는 것을 고려할 수 있다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java` around lines 70 - 74, The current ProductMetrics.decrementLikeCount silently ignores attempts to decrement when likeCount is 0 which hides problems; change decrementLikeCount to surface failures by returning a boolean (true if decremented, false if not) and emit a warn-level log when a decrement was attempted at zero (use the class logger in ProductMetrics and include contextual identifiers like productId). Update the method signature and its callers to handle the boolean result, and ensure the decrement path still decrements this.likeCount when >0.apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java (1)
30-31:failReason컬럼에 길이 제한이 없어 예외 메시지가 길 경우 DB 오류 발생 가능성이 있다.예외의 스택 트레이스나 긴 오류 메시지가 저장될 경우 DB 컬럼 크기 초과 오류가 발생할 수 있다. 길이 제한 또는 truncate 로직 적용을 권장한다.
♻️ 길이 제한 추가
-@Column(name = "fail_reason") +@Column(name = "fail_reason", length = 500) private String failReason;또는
fail()메서드에서 truncate:public void fail(String failReason) { this.status = CouponIssueRequestStatus.FAILED; - this.failReason = failReason; + this.failReason = failReason != null && failReason.length() > 500 + ? failReason.substring(0, 500) : failReason; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java` around lines 30 - 31, CouponIssueRequest의 필드 failReason에 길이 제한이 없어 긴 예외 메시지로 인해 DB 삽입 실패가 발생할 수 있으니, 해결책으로 CouponIssueRequest 클래스의 failReason 필드에 `@Column`(name = "fail_reason", length = <saneLimit>)를 추가하거나 `@Lob를` 사용해 컬럼 타입을 변경하여 DB 제약을 맞추고, 동시에 fail() 메서드에서 전달되는 메시지를 안전하게 잘라내는 로직(예: substring 또는 Apache Commons StringUtils.abbreviate를 사용해 최대 길이로 truncate)도 추가해 저장 전 길이를 보장하세요; 참조 대상: 클래스 CouponIssueRequest, 필드 failReason, 메서드 fail().apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java (2)
96-106:createRecord헬퍼 메서드가CatalogEventConsumerTest와 중복된다.테스트 유틸리티 클래스로 추출하면 중복을 줄이고 일관성을 유지할 수 있다. 테스트 모듈 내 공통 헬퍼로 분리하는 것을 고려할 수 있다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java` around lines 96 - 106, The createRecord helper in OrderEventConsumerTest duplicates the one in CatalogEventConsumerTest; extract it into a shared test utility (e.g., TestRecordUtils or ConsumerTestUtils) as a public static method (preserving signature createRecord(String topic, String jsonPayload, String eventId)) and have both OrderEventConsumerTest and CatalogEventConsumerTest call that utility method; update imports/usages in both test classes and ensure the objectMapper reference is handled (pass it in, or create a local mapper inside the utility) so the tests compile and no duplicate helper remains.
43-72: 예외 및 경계 조건 테스트가 누락되어 있다.
CatalogEventConsumerTest와 동일하게 다음 시나리오에 대한 테스트 추가를 권장한다:
items도paymentId도 없는 알 수 없는 메시지 타입 →log.warn호출 및 정상 ack 검증items배열 내 필수 필드(productId,price,quantity) 누락- JSON 파싱 실패 시 예외 처리 및 ack 동작
eventId헤더 누락 시 fallback 값 생성 검증이 테스트들은 운영 환경에서 비정상 메시지 유입 시 consumer의 안정성을 보장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java` around lines 43 - 72, Add tests to OrderEventConsumerTest that cover the missing edge cases: (1) send a message without items or paymentId and assert orderEventConsumer.consume logs a warning (mock the logger) and ack. (2) send payloads where items array elements are missing productId, price, or quantity and assert consumer handles each case (either skips/logs) and still ack. (3) simulate invalid JSON (malformed payload) passed to orderEventConsumer.consume and assert parsing error is handled and ack is called. (4) send a record missing the eventId header and assert orderEventConsumer generates a fallback eventId (verify metricsAggregator.handleOrderCreated called with a non-null/generated id or that the produced header is present) — use the existing helpers createRecord, the ack mock, itemsCaptor, and verify interactions with metricsAggregator and ack in each test.apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java (1)
37-50: 예외 상황에 대한 테스트가 누락되어 있다.현재 정상 케이스(PRODUCT like/unlike, BRAND ignore)만 테스트하고 있다. 운영 환경에서 발생할 수 있는 다음 시나리오에 대한 테스트 추가를 권장한다:
- 잘못된 JSON 형식의 payload
targetType,targetId등 필수 필드 누락eventId헤더 누락 시 동작이러한 케이스가
ack.acknowledge()호출 여부 및 예외 전파 방식을 검증해야 consumer의 안정성을 보장할 수 있다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java` around lines 37 - 50, Add unit tests in CatalogEventConsumerTest that cover error scenarios: (1) invalid JSON payload — create a ConsumerRecord with malformed JSON via createRecord and assert that catalogEventConsumer.consume(record, ack) either throws the expected exception or does not call ack.acknowledge() and does not invoke metricsAggregator.handleLikeToggled; (2) missing required fields (e.g., payload without targetType or targetId) — call catalogEventConsumer.consume and assert the consumer's error handling (exception type or no ack and no metricsAggregator interaction); (3) missing eventId header — build a record without the eventId header and assert whether consume throws or silently skips, and verify ack.acknowledge() behavior and that metricsAggregator.handleLikeToggled is not called. Ensure each test uses the same helpers (createRecord, ack, catalogEventConsumer, metricsAggregator) and explicitly verifies ack invocation and metricsAggregator interactions for each scenario.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java (1)
23-25: @transactional 내에서 동기식 외부 호출(Kafka)은 트랜잭션 유지 시간을 늘린다.100건의 이벤트를 처리하는 동안 DB 트랜잭션이 열려 있어, Kafka 지연 시 DB 커넥션이 장시간 점유된다. 규모가 커지면 배치 크기 조정 또는 트랜잭션 분리(이벤트별 개별 트랜잭션)를 고려해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java` around lines 23 - 25, The relay() method in OutboxRelayScheduler is annotated with `@Transactional` but performs synchronous Kafka sends inside the transaction, which can hold DB connections during network delays; refactor so DB work is committed before sending events: move the Kafka publishing out of the `@Transactional` scope (e.g., extract DB fetch/mark-as-sent logic into a transactional method and call a separate non-transactional publish loop), or use TransactionTemplate/afterCommit callbacks to publish after commit or send asynchronously (use kafkaTemplate.send(...).addCallback(...) or an executor) and adjust batching logic in relay() to limit processed events per invocation to avoid long transactions.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java (1)
22-22: MAX_RETRIES 값 검토 필요재시도 횟수가 50회로 설정되어 있다. 낙관적 락 충돌이 50회 이상 발생한다면 설계 자체에 문제가 있을 가능성이 높다. 운영 환경에서 이 수치에 근접하는 경우가 있는지 모니터링하고, 필요시 적절한 알림을 설정해야 한다.
🤖 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/metrics/MetricsAggregator.java` at line 22, MAX_RETRIES in MetricsAggregator is set to 50 which is unusually high; change it to a more conservative default (e.g., 3-10) and make it configurable so it can be tuned per environment (e.g., via application config or environment variable referenced in MetricsAggregator). Also add instrumentation (counter/metric) for retry occurrences inside the retry logic in MetricsAggregator and emit/emit a log when retries approach the limit so operators can create alerts from that metric/log.apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java (1)
41-55: DTO 정의 적절함
CouponIssueResponserecord가CouponIssueInfo로부터 매핑되어 엔티티 직접 노출을 방지한다. 다만failReason이 사용자에게 노출되는데, 내부 오류 메시지가 그대로 전달될 경우 보안/UX 측면에서 문제가 될 수 있다. 사용자용 메시지와 내부 로그용 메시지 분리를 고려해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.java` around lines 41 - 55, The current CouponIssueResponse.from(CouponIssueInfo) maps info.failReason() directly into the response, which may expose internal error details; change the mapping so CouponIssueResponse carries a user-facing failure message only (e.g., map info.failReason() through a sanitizer/mapper or convert to a public enum/message) and keep the raw/internal message in a separate internal-only field or log (do not expose internalFailReason in the DTO sent to clients). Update the from(CouponIssueInfo) factory to perform this transformation (or lookup) for failReason and ensure any internal failure detail is logged via existing logging rather than returned to callers.apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java (1)
76-90: 테스트 안정성: timeout 및 awaitTermination 누락
latch.await()에 timeout이 없다. 1000건의 요청을 50개 스레드로 처리하므로 CI 환경에서 지연 발생 시 테스트가 무한 대기할 수 있다. 특히 DB 락 경합이 심한 경우 더 오래 걸릴 수 있다.제안 수정
- latch.await(); - executor.shutdown(); + boolean completed = latch.await(120, TimeUnit.SECONDS); + executor.shutdown(); + executor.awaitTermination(30, TimeUnit.SECONDS); + assertThat(completed).withFailMessage("동시성 테스트가 timeout 내에 완료되지 않았다").isTrue();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.java` around lines 76 - 90, The test uses CountDownLatch latch.await() with no timeout and then executor.shutdown(), risking indefinite hang in CI; change to use latch.await(timeout, TimeUnit.SECONDS) with a reasonable timeout (e.g., based on requestCount or a fixed upper bound) and after calling executor.shutdown() call executor.awaitTermination(timeout, TimeUnit.SECONDS) and, if it returns false, call executor.shutdownNow() to force stop; update references in the test to CountDownLatch/latch.await(...) and executor.shutdown()/executor.awaitTermination(...) so the test always fails fast instead of hanging.apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java (2)
62-63: 테스트 안정성: timeout 및 awaitTermination 누락
latch.await()에 timeout이 없어 테스트가 무한 대기할 수 있다. 또한executor.shutdown()후awaitTermination()을 호출하지 않으면 태스크가 완전히 종료되기 전에 assertion이 수행될 가능성이 있다. CI 환경에서 flaky test의 원인이 된다.제안 수정
- latch.await(); - executor.shutdown(); + boolean completed = latch.await(60, TimeUnit.SECONDS); + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + assertThat(completed).isTrue();
TimeUnitimport 추가 필요:import java.util.concurrent.TimeUnit;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java` around lines 62 - 63, Replace the unbounded latch.await() and missing executor termination handling to avoid hangs: change latch.await() to latch.await(30, TimeUnit.SECONDS) (add import java.util.concurrent.TimeUnit if missing), then after executor.shutdown() call executor.awaitTermination(30, TimeUnit.SECONDS) and, if it returns false, call executor.shutdownNow() to force stop; ensure assertions run only after termination.
103-104: 동일하게 timeout 및 awaitTermination 적용 필요
concurrentLikeToggled_exactCount와 동일한 문제가 있다. 테스트 안정성을 위해 timeout과 awaitTermination을 추가해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.java` around lines 103 - 104, The test currently calls latch.await() and executor.shutdown() without timeouts which can hang; change the call in MetricsAggregatorConcurrencyTest (the section around concurrentLikeToggled_exactCount style usage) to use a timed wait on the CountDownLatch (e.g., latch.await(<timeout>, <TimeUnit>)) and after executor.shutdown() call executor.awaitTermination(<timeout>, <TimeUnit>), and if awaitTermination returns false, call executor.shutdownNow(); this mirrors the timeout/termination pattern used in concurrentLikeToggled_exactCount to ensure the test fails fast and cleans up the executor.apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java (1)
69-80: 예외 처리 구조 적절함
CoreException(비즈니스 예외)은 FAILED로 처리하고, 그 외 예외는 rethrow하여 Kafka 재시도를 유도하는 설계가 적절하다. 다만e.getMessage()를 직접failReason에 저장하면 내부 구현 상세가 노출될 수 있으므로, 사용자용 메시지를 별도로 관리하는 것을 고려해야 한다. As per coding guidelines, 예외 처리 시 사용자 메시지와 로그 메시지를 분리하도록 제안한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.java` around lines 69 - 80, The catch(CoreException e) stores e.getMessage() into request.fail which can leak internal details; update the CoreException handling in CouponIssueFacade (the catch block around couponService.issueCoupon) to pass a sanitized/user-facing message to request.fail (e.g., map the exception to a friendly string or call a new getPublicMessage()/toUserMessage() on CoreException) while logging the full internal message and stack trace separately (use log.warn with e or log.debug for internals). Ensure couponService.issueCoupon remains unchanged; implement the mapping or accessor on CoreException (or an ErrorMapper) and use that mapped/user message in request.fail, keeping the detailed e.getMessage()/exception in logs only.apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java (1)
133-175: 낙관적 락 재시도 동작 테스트 누락
MetricsAggregator.executeWithRetry가ObjectOptimisticLockingFailureException발생 시 재시도하는 로직이 있으나, 이에 대한 단위 테스트가 없다. 재시도 성공/실패 케이스를 검증하는 테스트 추가를 권장한다.테스트 케이스 예시
`@Test` `@DisplayName`("낙관적 락 충돌 시 재시도 후 성공한다") void retryOnOptimisticLockFailure() { // given given(eventHandledRepository.existsByEventId("retry-1")).willReturn(false); ProductMetrics metrics = ProductMetrics.create(100L); given(productMetricsRepository.findByProductId(100L)).willReturn(Optional.of(metrics)); AtomicInteger callCount = new AtomicInteger(0); doAnswer(invocation -> { if (callCount.incrementAndGet() < 3) { throw new ObjectOptimisticLockingFailureException(ProductMetrics.class, 100L); } invocation.<Consumer<TransactionStatus>>getArgument(0).accept(null); return null; }).when(transactionTemplate).executeWithoutResult(any()); // when metricsAggregator.handleLikeToggled("retry-1", 100L, true); // then assertThat(callCount.get()).isEqualTo(3); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.java` around lines 133 - 175, Add unit tests in MetricsAggregatorTest to cover MetricsAggregator.executeWithRetry's optimistic-lock retry behavior: create one test that mocks transactionTemplate.executeWithoutResult (use doAnswer with an AtomicInteger) to throw ObjectOptimisticLockingFailureException for the first N-1 calls and succeed on the Nth, invoke the public entry point that uses executeWithRetry (e.g., handleLikeToggled or the relevant handler) and assert the executeWithoutResult was called N times and the operation succeeded; add a second test that always throws ObjectOptimisticLockingFailureException from executeWithoutResult and assert the method ultimately fails (exception propagated) after the configured retry attempts. Reference transactionTemplate.executeWithoutResult, MetricsAggregator.executeWithRetry, ObjectOptimisticLockingFailureException, and the public handler method (handleLikeToggled / the handler you call) when implementing the mocks and assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f5a8d49e-1d9d-4ee0-8166-c71760848270
📒 Files selected for processing (57)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueFacade.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueInfo.javaapps/commerce-api/src/main/java/com/loopers/application/event/LikeEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeCountSyncScheduler.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestReader.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.javaapps/commerce-api/src/main/java/com/loopers/domain/event/LikeToggledEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEvent.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisherImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponIssueV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaapps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAsyncIssueConcurrencyTest.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/event/LikeEventListenerTest.javaapps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.javaapps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.javaapps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandledRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaapps/commerce-streamer/src/main/resources/application.ymlapps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorConcurrencyTest.javaapps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregatorTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.javamodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javamodules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (1)
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
| log.info("[이벤트] 상품 조회 - memberId={}, productId={}", | ||
| event.memberId(), event.productId()); |
There was a problem hiding this comment.
상품 조회 이벤트를 INFO 로그로 남기지 않는 편이 안전하다.
상품 조회는 고트래픽 경로라서 현재처럼 memberId까지 INFO로 남기면 로그 비용이 빠르게 커지고, 사용자 식별자가 장기 보관 로그에 남는다. 운영 로그는 DEBUG로 낮추거나 샘플링하고, 꼭 필요하면 식별자는 제거한 별도 메트릭/감사 경로로 보내는 편이 안전하다. 추가 테스트로는 appender 기반 테스트를 넣어 INFO 레벨 로그에 사용자 식별자가 출력되지 않는지 검증해야 한다.
수정 예시
- log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
- event.memberId(), event.productId());
+ log.debug("[이벤트] 상품 조회 - productId={}", event.productId());As per coding guidelines **/*.java: 로깅 시 민감정보 노출 가능성을 점검한다.
📝 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.
| log.info("[이벤트] 상품 조회 - memberId={}, productId={}", | |
| event.memberId(), event.productId()); | |
| log.debug("[이벤트] 상품 조회 - productId={}", event.productId()); |
🤖 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/UserActivityEventListener.java`
around lines 17 - 18, Change the high-volume product-view log in
UserActivityEventListener so it does not write user-identifying data at INFO
level: replace or move the current log.info("[이벤트] 상품 조회 - memberId={},
productId={}", event.memberId(), event.productId()) to a lower level (e.g.,
log.debug) or remove memberId from the INFO message and only log productId; if
you must capture memberId send it to a separate audited/metrics path. Also add
an appender-based test that exercises UserActivityEventListener and asserts
INFO-level output does not contain event.memberId() (but allows DEBUG to contain
it if needed).
| List<OrderCreatedEvent.OrderItemSnapshot> itemSnapshots = commands.stream() | ||
| .map(cmd -> new OrderCreatedEvent.OrderItemSnapshot( | ||
| cmd.productId(), cmd.productName(), cmd.productPrice(), cmd.quantity())) | ||
| .toList(); | ||
| OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent( | ||
| order.getId(), memberId, order.getTotalAmount(), itemSnapshots); | ||
| eventPublisher.publishEvent(orderCreatedEvent); | ||
| outboxEventPublisher.publish("ORDER", order.getId(), "ORDER_CREATED", | ||
| "order-events", String.valueOf(order.getId()), orderCreatedEvent); |
There was a problem hiding this comment.
할인 주문의 이벤트 금액 기준이 서로 달라 downstream 집계가 어긋날 수 있다.
여기서 담는 totalAmount는 할인 후 총액인데, item snapshot의 price는 할인 전 단가다. 같은 이벤트 안에 순액과 총액 기준이 섞여 있으면 상품별 매출을 price * quantity로 집계하는 쪽과 주문 총액을 보는 쪽의 수치가 영구히 갈라진다. item snapshot에 할인 배분값 또는 netAmount를 포함해 금액 기준을 하나로 맞추고, 쿠폰 적용 주문이 consumer 집계까지 거쳐도 주문 총액과 상품별 합계가 기대한 기준으로 일치하는 통합 테스트를 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 99 - 107, 현재 OrderCreatedEvent에 담긴 totalAmount(할인 후 총액)와
OrderItemSnapshot.price(할인 전 단가)가 혼재되어 downstream 집계 불일치를 초래합니다; 수정책으로
OrderItemSnapshot(생성하는 map 람다)에 할인 배분된 금액을 표현하는 필드(예: netAmount 또는
discountedPrice, 그리고 quantity를 곱한 netLineAmount)를 추가하고 OrderCreatedEvent 생성 시 해당
net 금액들을 함께 전달 및 설정하도록 변경하여 totalAmount 기준과 일치시키세요(참조 대상: OrderCreatedEvent,
OrderCreatedEvent.OrderItemSnapshot, 매핑 람다, outboxEventPublisher.publish 호출 지점).
또한 쿠폰/할인 적용 주문에 대해 주문 총액(totalAmount)과 item별 net 합계가 동일함을 검증하는 통합 테스트를 추가해
consumer 집계까지 검증하세요.
| PaymentCompletedEvent completedEvent = new PaymentCompletedEvent( | ||
| payment.getId(), payment.getOrderId(), payment.getMemberId(), payment.getAmount()); | ||
| eventPublisher.publishEvent(completedEvent); | ||
| outboxEventPublisher.publish("ORDER", payment.getOrderId(), "PAYMENT_COMPLETED", | ||
| "order-events", String.valueOf(payment.getOrderId()), completedEvent); |
There was a problem hiding this comment.
결제 성공 이벤트를 발행하기 전에 response 경로도 terminal 상태를 잠금으로 재확인해야 한다.
지금은 processPaymentResponse()가 잠금 없이 결제를 읽고 바로 상태 전환과 PAYMENT_COMPLETED publish를 수행하므로, PG callback이나 복구 스케줄러가 먼저 성공 처리를 끝낸 직후에도 같은 결제를 다시 처리할 수 있다. 운영에서는 중복 outbox 적재로 downstream이 두 번 처리되거나, 이미 성공한 결제에 대해 markProcessing() 예외가 나서 호출자에게 오류를 반환하는 장애로 이어진다. processPaymentResponse()도 getPaymentForUpdate()로 읽은 뒤 terminal 상태면 즉시 반환하고, 그 이후에만 상태 전환과 event/outbox publish를 수행해야 한다. 추가 테스트로는 processPaymentResponse()와 processCallback()을 동시에 호출해도 outbox row와 완료 이벤트가 정확히 1건만 생성되는지 검증해야 한다.
수정 예시
`@Transactional`
public PaymentInfo processPaymentResponse(Long paymentId, PaymentGatewayResponse pgResponse) {
- Payment payment = paymentService.getPayment(paymentId);
+ Payment payment = paymentService.getPaymentForUpdate(paymentId);
+ if (payment.getStatus().isTerminal()) {
+ log.info("이미 처리된 PG 응답 무시. paymentId={}, status={}", paymentId, payment.getStatus());
+ return PaymentInfo.from(payment);
+ }
if (pgResponse == null) {
// CircuitBreaker fallback: null 반환 → 실패 처리
payment.fail("PG 서비스 일시 장애");Also applies to: 141-145
🤖 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 113 - 117, processPaymentResponse() must re-check the payment's
terminal state after acquiring the row lock (use getPaymentForUpdate()) and
return immediately if the payment is already in a terminal state before calling
markProcessing(), publishing the PaymentCompletedEvent or writing to outbox;
update the block around
markProcessing()/eventPublisher.publishEvent/outboxEventPublisher.publish to
perform the terminal-state check on the locked Payment and only proceed to state
transition and publishes when non-terminal. Apply the same change pattern to the
analogous code near processCallback(), and add a concurrency test that calls
processPaymentResponse() and processCallback() in parallel and asserts exactly
one outbox row and one PaymentCompletedEvent are produced.
|
|
||
| public interface OutboxEventJpaRepository extends JpaRepository<OutboxEvent, Long> { | ||
|
|
||
| List<OutboxEvent> findTop100ByPublishedFalseOrderByCreatedAtAsc(); |
There was a problem hiding this comment.
이 조회만으로는 relay 다중 인스턴스에서 같은 outbox를 중복 집어갈 수 있다.
현재 계약은 published=false 행을 읽은 뒤 전송 성공 후에야 상태를 바꾸는 2단계라서, 스케줄러가 두 대 이상이거나 한 배치가 폴링 주기를 넘기면 같은 outbox를 여러 relay가 동시에 읽고 Kafka에 중복 전송할 수 있다. 운영에서는 중복 이벤트가 downstream 부하와 중복 처리 비용으로 바로 이어진다. PESSIMISTIC_WRITE + SKIP LOCKED 또는 IN_PROGRESS 같은 선점 상태를 두는 claim 쿼리로 바꾸고, 선점과 조회를 같은 트랜잭션으로 묶는 편이 안전하다. 추가 테스트로는 두 relay 스레드/인스턴스가 동시에 폴링해도 같은 outbox id가 한 번만 publish 되는지 검증해야 한다.
🤖 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`
at line 9, 현재 OutboxEventJpaRepository의
findTop100ByPublishedFalseOrderByCreatedAtAsc() 조회는 다중 relay 인스턴스에서 동일한 outbox를
중복 소비할 수 있으므로, 해당 메서드를 선점(claim) 쿼리로 바꿔야 합니다: 트랜잭션 내에서 PESSIMISTIC_WRITE + SKIP
LOCKED 또는 상태 플래그(IN_PROGRESS)를 이용해 레코드를 선점(예: update ... where published=false
and status is null -> set status='IN_PROGRESS')한 뒤 같은 트랜잭션에서 조회하여 반환하도록 구현하고,
publish 성공/실패 시 상태를 적절히 published=true 또는 상태 롤백/재시도 처리하도록 변경(관련 위치:
OutboxEventJpaRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc,
OutboxEvent 엔티티의 상태 필드 및 이를 사용하는 발행 로직)하고, 마지막으로 두 개 이상의 relay 스레드/인스턴스가 동시 폴링해도
동일 outbox id가 한 번만 publish 되는 통합 테스트를 추가하세요.
| "eventId", String.valueOf(event.getId()).getBytes(StandardCharsets.UTF_8) | ||
| )); | ||
|
|
||
| kafkaTemplate.send(record).get(); |
There was a problem hiding this comment.
kafkaTemplate.send().get() 호출에 타임아웃이 없어 무한 대기 가능성이 있다.
Kafka 브로커 장애나 네트워크 지연 시 get()이 무한 대기하여 DB 트랜잭션이 장시간 유지될 수 있다. 이는 커넥션 풀 고갈 및 후속 요청 처리 지연으로 이어질 수 있다.
🔧 타임아웃 적용
-kafkaTemplate.send(record).get();
+kafkaTemplate.send(record).get(10, java.util.concurrent.TimeUnit.SECONDS);또한 InterruptedException 및 TimeoutException 처리가 필요하다. 현재 Exception으로 일괄 처리하고 있으나 명시적 예외 타입을 catch하는 것이 디버깅에 유리하다.
📝 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.
| kafkaTemplate.send(record).get(); | |
| kafkaTemplate.send(record).get(10, java.util.concurrent.TimeUnit.SECONDS); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayScheduler.java`
at line 37, OutboxRelayScheduler currently calls
kafkaTemplate.send(record).get() which can block forever; replace this with a
bounded wait (e.g., kafkaTemplate.send(record).get(timeout, TimeUnit.SECONDS))
and choose an appropriate timeout constant, then explicitly catch
InterruptedException and TimeoutException (keep ExecutionException or handle
underlying cause) instead of a blanket Exception; on InterruptedException re-set
the thread interrupt flag (Thread.currentThread().interrupt()), log
timeout/interrupt with context (record/key) and fail the relay attempt
gracefully so DB transaction isn't held indefinitely.
| private boolean isDuplicate(String eventId) { | ||
| if (eventHandledRepository.existsByEventId(eventId)) { | ||
| log.info("중복 이벤트 무시: eventId={}", eventId); | ||
| return true; | ||
| } | ||
| return false; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# EventHandled 엔티티에 unique constraint가 있는지 확인
rg -n "class EventHandled" --type=java -A 15
rg -n "uniqueConstraints|@Column.*unique" --type=java -g "*EventHandled*"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1996
🏁 Script executed:
# markHandled 메서드 구현 확인
rg -n "markHandled" --type=java -B 2 -A 5
# MetricsAggregator에서 isDuplicate과 markHandled 호출 부분
rg -n "isDuplicate|markHandled" apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.java -B 3 -A 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6262
isDuplicate 체크와 markHandled 저장 사이의 경합 조건으로 인한 미처리 예외
isDuplicate 조회와 markHandled 저장이 원자적으로 수행되지 않는다. 동일 eventId를 가진 두 메시지가 거의 동시에 처리되면, 둘 다 isDuplicate를 통과한 후 markHandled에서 PK constraint violation이 발생한다.
eventId는 EventHandled 엔티티의 primary key이므로 데이터베이스 레벨에서 중복 저장은 방지되지만, 두 번째 save() 호출 시 DataIntegrityViolationException이 발생하며 이를 처리하지 않는다. 결과적으로 이벤트 핸들러에서 예외가 전파되어 운영 장애로 이어질 수 있다.
수정안:
markHandled메서드에서 PK constraint violation을 catch하고 로깅 후 정상 반환하거나isDuplicate체크와markHandled저장을 하나의 트랜잭션 내 원자적 연산으로 변경 (예: SELECT FOR UPDATE 또는 database unique constraint + exception handling)- 추가 테스트: 동시에 같은
eventId로 여러 스레드가 이벤트를 처리할 때 중복 저장 없음과 예외 미발생 검증
🤖 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/metrics/MetricsAggregator.java`
around lines 90 - 96, Race between isDuplicate and markHandled can cause a
DataIntegrityViolationException when two threads save the same EventHandled PK;
modify markHandled(EventHandled event) to catch DataIntegrityViolationException
(or more specific constraint violation exception), log an informative message
including event.getEventId(), and return normally instead of rethrowing;
alternatively make markHandled and isDuplicate run atomically by annotating the
service method that calls them with `@Transactional` and using a repository method
that acquires a row lock or relying on the DB unique constraint plus the
exception handling change; ensure you reference EventHandled,
eventHandledRepository, isDuplicate and markHandled in the fix and add a
concurrent test that simulates multiple threads saving the same eventId to
assert no exception leaks.
| String targetType = node.get("targetType").asText(); | ||
| if (!"PRODUCT".equals(targetType)) { | ||
| log.debug("PRODUCT 외 타입 무시: targetType={}", targetType); | ||
| ack.acknowledge(); | ||
| return; | ||
| } | ||
|
|
||
| Long productId = node.get("targetId").asLong(); | ||
| boolean liked = node.get("liked").asBoolean(); |
There was a problem hiding this comment.
JSON 필드 null 체크 누락으로 인한 NPE 위험
node.get("targetType"), node.get("targetId"), node.get("liked")가 null일 경우 asText(), asLong(), asBoolean() 호출 시 NPE가 발생한다. 잘못된 payload가 유입되면 consumer가 예외를 발생시키고, 현재 로직상 ack 처리되어 해당 이벤트가 유실된다.
제안 수정
+ JsonNode targetTypeNode = node.get("targetType");
+ if (targetTypeNode == null) {
+ log.warn("targetType 필드 누락: eventId={}", eventId);
+ ack.acknowledge();
+ return;
+ }
- String targetType = node.get("targetType").asText();
+ String targetType = targetTypeNode.asText();
if (!"PRODUCT".equals(targetType)) {
log.debug("PRODUCT 외 타입 무시: targetType={}", targetType);
ack.acknowledge();
return;
}
- Long productId = node.get("targetId").asLong();
- boolean liked = node.get("liked").asBoolean();
+ JsonNode targetIdNode = node.get("targetId");
+ JsonNode likedNode = node.get("liked");
+ if (targetIdNode == null || likedNode == null) {
+ log.warn("필수 필드 누락: eventId={}, targetId={}, liked={}", eventId, targetIdNode, likedNode);
+ ack.acknowledge();
+ return;
+ }
+ Long productId = targetIdNode.asLong();
+ boolean liked = likedNode.asBoolean();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 37 - 45, The JSON field access in CatalogEventConsumer uses
node.get("targetType"), node.get("targetId"), and node.get("liked") directly
then calls asText()/asLong()/asBoolean(), which can throw NPE on malformed
payloads; update the parsing to safely handle missing/null fields (e.g., use
node.hasNonNull("targetType")/hasNonNull("targetId")/hasNonNull("liked") or
node.path(...) which returns a safe MissingNode) before calling
asText()/asLong()/asBoolean(), and when a required field is missing log a clear
debug/error message including the raw payload and call ack.acknowledge() without
throwing so the consumer does not crash or lose ack semantics (refer to the
variables node, targetType, productId, liked and the ack handling in
CatalogEventConsumer).
| } catch (Exception e) { | ||
| log.error("catalog-events 처리 실패. eventId={}, error={}", eventId, e.getMessage(), e); | ||
| ack.acknowledge(); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Spring Kafka error handling best practices DLQ
💡 Result:
Spring Kafka error handling best practices for Dead Letter Queues (DLQ), also called Dead Letter Topics (DLT), focus on using non-blocking retries with @RetryableTopic (available since 2.7) combined with DLT configuration. This approach automatically creates retry topics and a DLT, handles retries with backoff, and routes unprocessable messages to DLT after exhausting attempts. Key practices: 1. Use @RetryableTopic on @KafkaListener methods: - Set attempts (default 3), backoff (e.g., exponential), include/exclude exceptions for retries. - Example: @RetryableTopic( attempts = "4", backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000), dltStrategy = DltStrategy.FAIL_ON_ERROR, include = {RetryableException.class}, exclude = {DeserializationException.class} ) @KafkaListener(topics = "main-topic") public void process(String message) { ... } This creates retry topics (e.g., main-topic-retry-1000) and main-topic-dlt. 2. Add @DltHandler in the same class for DLT processing: @DltHandler public void handleDlt(String message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { // Log, alert, store for analysis, or reprocess after fix } Default logs if not provided. 3. DLT Strategies (dltStrategy): - ALWAYS_RETRY_ON_ERROR (default): Retry DLT processing failures by looping back to DLT. - FAIL_ON_ERROR: Fail fast on DLT errors. - NO_DLT: Skip DLT entirely. 4. Enable infrastructure: Add @EnableKafkaRetryTopic or extend RetryTopicConfigurationSupport in @Configuration. 5. Alternative for simple cases: DefaultErrorHandler + DeadLetterPublishingRecoverer (blocking retries): @Bean public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) { return new DefaultErrorHandler( new DeadLetterPublishingRecoverer(template), new FixedBackOff(5000L, 3L)); } DLT topic: original-dlt. Prefer @RetryableTopic for non-blocking. Operational best practices: - Monitor DLT volume, age, error types; alert on spikes. - Enrich DLT messages with headers (exception FQCN, stack trace auto-added). - Use exponential backoff, classify retryable vs. non-retryable exceptions. - Set topic retention appropriately; consider compaction for DLT. - Avoid DLT for strict ordering needs. - Customize suffixes: retryTopicSuffix="-retry", dltTopicSuffix="-dlt". - For batch listeners, use DefaultErrorHandler (no @RetryableTopic support). @RetryableTopic is recommended for production as it provides non-blocking retries preserving partition ordering within topics.
Citations:
- 1: https://docs.spring.io/spring-kafka/reference/retrytopic/dlt-strategies.html
- 2: https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html
- 3: https://docs.spring.io/spring-kafka/reference/retrytopic/topic-naming.html
- 4: https://docs.spring.io/spring-kafka/reference/retrytopic/retry-config.html
- 5: https://docs.spring.io/spring-kafka/docs/current/api/org/springframework/kafka/annotation/RetryableTopic.html
- 6: https://docs.enterprise.spring.io/spring-kafka/reference/3.1-SNAPSHOT/retrytopic.html
- 7: https://stackoverflow.com/questions/73166560/spring-kafka-deadletterpublishingrecoverer-vs-retrytopicconfiguration
🏁 Script executed:
# CatalogEventConsumer.java 파일 전체 내용 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3096
🏁 Script executed:
# Spring Kafka 버전 확인
find . -name "pom.xml" -o -name "build.gradle" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# pom.xml에서 spring-kafka 버전 확인
grep -A 5 "spring-kafka" pom.xml 2>/dev/null || grep -A 5 "spring-kafka" */pom.xml 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# KafkaConfig 파일 찾기
find . -name "KafkaConfig.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 155
🏁 Script executed:
# pom.xml 찾기 (모든 경로)
find . -name "pom.xml" -type f | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Spring Kafka 버전 확인 (다양한 위치 시도)
find . -name "pom.xml" -type f -exec grep -l "spring-kafka" {} \;Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# KafkaConfig.java 내용 확인
cat -n ./modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5094
🏁 Script executed:
# 프로젝트 구조에서 pom.xml 찾기
find . -type f -name "pom.xml" | grep -v ".git"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 프로젝트 루트의 pom.xml 직접 확인
ls -la | grep pom
cat pom.xml | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 124
🏁 Script executed:
# 프로젝트 루트의 모든 build 설정 파일 확인
ls -la | grep -E "gradle|maven"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 472
🏁 Script executed:
# build.gradle 확인
find . -name "build.gradle" -type f | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# MetricsAggregator 찾기
find . -name "MetricsAggregator.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 178
🏁 Script executed:
# build.gradle.kts에서 spring-kafka 버전 확인
cat build.gradle.kts | grep -A 3 -B 3 "spring-kafka\|spring.kafka"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# MetricsAggregator.java 파일 내용 확인
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregator.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4882
🏁 Script executed:
# build.gradle.kts 전체 파일 확인
cat build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3870
🏁 Script executed:
# gradle.properties 확인
cat gradle.propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 649
🏁 Script executed:
# commerce-streamer의 build.gradle 파일 찾기
find . -path "*/commerce-streamer/*" -name "build.gradle*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 126
🏁 Script executed:
# kafka 모듈의 build.gradle 찾기
find . -path "*/kafka/*" -name "build.gradle*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 117
🏁 Script executed:
# commerce-streamer의 build.gradle.kts 확인
cat ./apps/commerce-streamer/build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 992
🏁 Script executed:
# kafka 모듈의 build.gradle.kts 확인
cat ./modules/kafka/build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 390
예외 발생 시 무조건 ack 처리로 인한 메시지 유실 문제
현재 코드는 모든 Exception을 동일하게 처리하여 ack.acknowledge()를 호출하고 있다. 이로 인해 일시적 장애(DB 연결 실패, 트랜잭션 타임아웃 등)로 인한 예외 발생 시에도 메시지가 즉시 커밋되어 재처리 기회를 잃게 된다. 운영 관점에서 이벤트 유실은 메트릭 데이터 불일치로 이어진다.
예외 유형을 구분하여 처리해야 한다. 일시적 장애는 재시도하고, 영구적 실패는 Dead Letter Queue로 라우팅하는 전략을 수립할 것을 권장한다. Spring Kafka 3.3 이상에서는 @RetryableTopic으로 비차단 재시도와 DLT를 간편하게 구성할 수 있으므로, @RetryableTopic 도입을 검토하거나, DefaultErrorHandler와 DeadLetterPublishingRecoverer를 통해 재시도 정책을 명시적으로 정의해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 49 - 52, The catch-all exception handling in CatalogEventConsumer
currently calls ack.acknowledge() for every Exception, causing message loss;
change the error handling so only successful processing explicitly calls
ack.acknowledge() (move ack into the normal success path), and distinguish
transient vs permanent failures in the catch: for transient errors (e.g., DB
connection timeouts, transient network/SQLExceptions) rethrow or let the
listener container handle retries (or annotate the consumer method with
`@RetryableTopic`) so the message is retried; for permanent errors send to a Dead
Letter Queue (use Spring Kafka's DefaultErrorHandler with a
DeadLetterPublishingRecoverer or configure `@RetryableTopic` with DLT) and do not
ack in that case. Locate the try/catch in CatalogEventConsumer (the block that
currently calls ack.acknowledge() in the catch) and implement the described
ack-on-success and retry/DLT strategy.
| for (JsonNode item : itemsNode) { | ||
| items.add(new MetricsAggregator.OrderItemInfo( | ||
| item.get("productId").asLong(), | ||
| item.get("price").asLong(), | ||
| item.get("quantity").asInt() | ||
| )); | ||
| } |
There was a problem hiding this comment.
items 배열 내 필드 접근 시 NPE 위험이 있다.
item.get("productId"), item.get("price"), item.get("quantity")가 null을 반환할 경우 asLong(), asInt() 호출 시 NPE가 발생한다. Kafka 메시지 형식이 변경되거나 잘못된 메시지가 유입될 때 문제가 된다.
🛡️ 필드 존재 여부 검증 추가
for (JsonNode item : itemsNode) {
+ if (!item.has("productId") || !item.has("price") || !item.has("quantity")) {
+ log.warn("order item 필드 누락. eventId={}", eventId);
+ continue;
+ }
items.add(new MetricsAggregator.OrderItemInfo(
item.get("productId").asLong(),
item.get("price").asLong(),
item.get("quantity").asInt()
));
}🤖 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 58 - 64, In the for-loop inside OrderEventConsumer that builds
MetricsAggregator.OrderItemInfo from itemsNode, guard against NPE by validating
each field before calling asLong()/asInt(): check item.get("productId"),
item.get("price"), and item.get("quantity") are non-null (or use JsonNode::has /
JsonNode::path with safe defaults) and either skip the malformed item and log a
warning or supply sensible default values when constructing new
MetricsAggregator.OrderItemInfo; update the loop to perform these null/type
checks and logging so malformed Kafka messages do not throw NPEs.
| private void handlePaymentCompleted(String eventId, JsonNode node) { | ||
| Long orderId = node.get("orderId").asLong(); | ||
| metricsAggregator.handlePaymentCompleted(eventId, orderId); | ||
| } |
There was a problem hiding this comment.
handlePaymentCompleted에서도 필드 접근 전 null 체크가 필요하다.
node.get("orderId")가 null인 경우 동일하게 NPE가 발생한다.
🛡️ 방어 로직 추가
private void handlePaymentCompleted(String eventId, JsonNode node) {
+ if (!node.has("orderId")) {
+ log.warn("paymentCompleted 이벤트에 orderId 누락. eventId={}", eventId);
+ return;
+ }
Long orderId = node.get("orderId").asLong();
metricsAggregator.handlePaymentCompleted(eventId, orderId);
}📝 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.
| private void handlePaymentCompleted(String eventId, JsonNode node) { | |
| Long orderId = node.get("orderId").asLong(); | |
| metricsAggregator.handlePaymentCompleted(eventId, orderId); | |
| } | |
| private void handlePaymentCompleted(String eventId, JsonNode node) { | |
| if (!node.has("orderId")) { | |
| log.warn("paymentCompleted 이벤트에 orderId 누락. eventId={}", eventId); | |
| return; | |
| } | |
| Long orderId = node.get("orderId").asLong(); | |
| metricsAggregator.handlePaymentCompleted(eventId, orderId); | |
| } |
🤖 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 69 - 72, The handlePaymentCompleted method dereferences
node.get("orderId") without null checks which can cause an NPE; update
handlePaymentCompleted to first verify the JsonNode and the "orderId" field
exist (e.g., node != null && node.hasNonNull("orderId")) or retrieve the field
into a variable and check for null before calling asLong(), and if missing, log
or increment a metric and return early instead of calling
metricsAggregator.handlePaymentCompleted(eventId, orderId).
📌 Summary
send().get()). commerce-streamer에서 ProductMetrics 집계를 낙관적 락 + 재시도로 처리하며, 선착순 쿠폰은 Kafka Partition 기반 순차 처리로 락 경합을 최소화. 동시성 하드 테스트(100명 좋아요, 50건 주문, 200명 쿠폰) 전부 통과.🧭 Context & Decision
문제 정의
핵심 설계 결정
결정 1: 인프로세스 이벤트 vs Kafka 직접 발행
결정 2: Kafka 발행 방식 — 직접 발행 vs Transactional Outbox
결정 3: Outbox Relay 발행 방식 — 동기 vs 비동기
결정 4: Consumer 멱등 처리 — eventId 발급 방식
결정 5: 동시성 제어 — 비관적 락 vs 낙관적 락
findByIdForUpdate) ✅ — 수량 초과는 절대 허용 불가, 정합성 최우선@Version) + 재시도 ✅ — 충돌 시 데이터 유실 없이 재시도로 해결, 읽기 성능 유지결정 6: 선착순 쿠폰 — 동기 API vs Kafka 비동기
🏗️ Design Overview
변경 범위
주요 컴포넌트 책임
OrderCreatedEvent등 6개OrderEventListener등 4개OutboxEventPublisherOutboxEventPublisherImplOutboxRelaySchedulerMetricsAggregatorCatalogEventConsumerCouponIssueFacadeCouponIssueConsumerKafka Topic 설계
order-eventscatalog-eventscoupon-issue-requestsOutbox → Kafka 흐름
🔁 Flow Diagram
1. 주문 생성 → 이벤트 발행 → 집계
sequenceDiagram autonumber participant Client participant API as OrderFacade participant DB as commerce-api DB participant Outbox as OutboxRelay participant Kafka participant Streamer as MetricsAggregator Client->>API: POST /orders API->>DB: Order 저장 + 재고 차감 + Outbox INSERT API-->>Client: 201 Created Note over API: ApplicationEvent 발행 (AFTER_COMMIT) API->>API: OrderEventListener (로깅) Outbox->>DB: SELECT unpublished events Outbox->>Kafka: send("order-events", orderId, payload).get() Outbox->>DB: published=true Kafka->>Streamer: ORDER_CREATED 메시지 Streamer->>Streamer: eventId 중복 체크 Streamer->>DB: ProductMetrics.orderCount++, salesAmount+= Streamer->>DB: event_handled INSERT Streamer->>Kafka: manual ack2. 좋아요 토글 → 실시간 반영 + Kafka 집계
sequenceDiagram autonumber participant Client participant API as LikeFacade participant DB as commerce-api DB participant Listener as LikeEventListener participant Outbox as OutboxRelay participant Kafka participant Streamer as MetricsAggregator Client->>API: POST /likes/products/{id} API->>DB: Like 토글 + Outbox INSERT API-->>Client: 200 OK Note over API: AFTER_COMMIT API->>Listener: LikeToggledEvent Listener->>DB: Product.likeCount 실시간 증감 Outbox->>Kafka: send("catalog-events", productId, payload).get() Kafka->>Streamer: LIKE_TOGGLED 메시지 Streamer->>Streamer: 낙관적 락 재시도 (최대 20회) Streamer->>DB: ProductMetrics.likeCount 증감3. 선착순 쿠폰 비동기 발급
sequenceDiagram autonumber participant Client participant API as CouponIssueFacade participant DB participant Outbox as OutboxRelay participant Kafka participant Consumer as CouponIssueConsumer Client->>API: POST /coupons/{id}/issue-async API->>DB: 중복 체크 (member_id + coupon_id) API->>DB: CouponIssueRequest(PENDING) + Outbox INSERT API-->>Client: 202 Accepted + requestId Outbox->>Kafka: send("coupon-issue-requests", couponId, payload).get() Kafka->>Consumer: 쿠폰 발급 메시지 Consumer->>API: processIssuance(requestId) API->>DB: CouponIssueRequest 조회 (이미 처리됐으면 skip) API->>DB: Coupon findByIdForUpdate (비관적 락) alt 수량 남음 API->>DB: MemberCoupon 발급 + request.complete() else 수량 소진 API->>DB: request.fail("수량 소진") end Client->>API: GET /coupons/issue-requests/{requestId} API-->>Client: status=COMPLETED / PENDING / FAILED🧪 동시성 통합 테스트 결과
1. ProductMetrics 집계 동시성 (MetricsAggregatorConcurrencyTest)
@Version낙관적 락 +TransactionTemplate+ 최대 50회 재시도 (랜덤 백오프)@Transactional→TransactionTemplate으로 전환하여 재시도 시 새 트랜잭션 시작2. 선착순 쿠폰 동시성 (CouponAsyncIssueConcurrencyTest)
findByIdForUpdate비관적 락 + Kafka Partition Key(couponId) 순차 처리@Transactional(noRollbackFor = CoreException.class)로 비즈니스 예외 시 트랜잭션 롤백 방지변경 목적
핵심 변경점
리스크/주의사항
테스트/검증