[Volume7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현 - 박시온#294
[Volume7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구현 - 박시온#294hey-sion merged 10 commits intoLoopers-dev-lab:hey-sionfrom
Conversation
📝 WalkthroughWalkthrough이 PR은 Outbox 기반 이벤트 퍼블리싱과 Kafka 소비자/핸들러를 도입하여 좋아요·조회·결제 완료·쿠폰 발급 요청을 비동기 처리하고, 쿠폰 동시성은 Redis로 제어하며 상품 메트릭을 별도 읽기 모델로 분리한 변경이다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant API as Commerce API
participant OutboxPub as Outbox<br/>Publisher
participant DB as Database
participant Relay as MessageRelay
participant Kafka
participant Streamer as Commerce<br/>Streamer
participant Processor as CouponIssueProcessor
Client->>API: POST /api/v1/coupons/{couponId}/issue-request
API->>API: validate promotion, increment Redis count
API->>OutboxPub: publish(COUPON_ISSUE_REQUESTED)
OutboxPub->>DB: insert Outbox row
API-->>Client: 202 Accepted
Relay->>DB: find pending outbox, send to Kafka
Relay->>Kafka: produce message (topic coupon-issue-requests)
Kafka->>Streamer: deliver message
Streamer->>Streamer: Consumer -> EventProcessingService -> CouponIssueRequestedHandler
Streamer->>Processor: process(requestId, couponId, userId)
Processor->>DB: create IssuedCoupon, update request status
sequenceDiagram
participant Client
participant API as Commerce API
participant OutboxPub as Outbox<br/>Publisher
participant Kafka
participant Streamer as Commerce<br/>Streamer
participant Metrics as Product<br/>Metrics
Client->>API: POST /api/v1/likes/{productId}
API->>OutboxPub: publish(PRODUCT_LIKED)
OutboxPub->>DB: insert Outbox
OutboxPub->>Kafka: MessageRelay publishes
Kafka->>Streamer: CATALOG_EVENTS
Streamer->>Streamer: ProductLikedEventHandler.handle()
Streamer->>Metrics: incrementLikeCount(productId)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
주요 검토 사항1) MessageRelay 재시도/사망 처리 부재 — 운영 리스크, 메시지 유실 가능성왜 문제인지(운영): Kafka 전송 실패 시 현재 로직은 오류 로깅만 하고 Outbox를 남겨두며 주기적 재시도(10초)만 수행한다. 지속적 장애 또는 네트워크 분단 시 롤백·DLQ 미비로 메시지가 장기간 큐에 쌓이거나 유실될 수 있다. 2) OutboxEventPublisher 직렬화 예외 처리 및 트랜잭션 일관성왜 문제인지(운영): publish 시점에 payload 직렬화 실패가 트랜잭션 후속 작업을 불투명하게 만들 수 있다(Outbox가 미생성되거나 잘못된 페이로드 저장). 3) Redis 기반 카운트 원자성 및 장애 복구 한계왜 문제인지(운영): Redis INCR/DECR는 원자적이지만 네트워크 오류·레디스 장애 시 increment 후 실패된 DB 저장/트랜잭션 롤백에서 카운트 불일치가 발생할 수 있다. decrement 실패 시 불일치 누적 위험이 있다. 4) EventHandled 중복 처리/예외 범주화 필요성왜 문제인지(운영): EventHandled 저장 중 발생하는 모든 DataIntegrityViolationException을 “이미 처리됨”으로만 처리하면 다른 DB 오류(예: 제약 외의 트랜잭션 실패)를 놓칠 수 있다. 5) ProductMetrics 업서트 DB 종속성 및 호환성 문제왜 문제인지(운영): ProductMetricsRepository의 native 쿼리는 MySQL 전용(INSERT ... ON DUPLICATE KEY UPDATE)이므로 DB 교체(Postgres 등) 시 호환성 문제 발생 위험이 있다. 6) 테스트 격리성 및 인메모리 저장소의 리플렉션 사용 문제왜 문제인지(운영): InMemory 레포지토리들이 리플렉션으로 부모 클래스 id를 설정하는 방식은 JPA 프록시/추후 리팩토링에 취약하고 테스트 신뢰도를 저하시킬 수 있다. ConcurrencyE2ETest의 Redis 키 정리도 동시 실행 시 오염 가능성이 있다. 위 항목들은 운영 안정성·데이터 일관성·이식성·테스트 신뢰성 측면에서 우선 확인해야 할 위험 지점이다. 🚥 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)
|
There was a problem hiding this comment.
Actionable comments posted: 2
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/test/java/com/loopers/application/payment/PaymentFacadeTest.java (1)
103-119:⚠️ Potential issue | 🟡 MinorOutboxEventPublisher 호출에 대한 검증이 누락되었다.
AI 요약에 따르면, 결제가 SUCCESS로 전환될 때
PAYMENT_COMPLETED이벤트가 Outbox로 발행되어야 한다. 그러나 현재 테스트에서는outboxEventPublishermock만 주입하고, 실제 호출 여부를 검증하지 않는다.운영 관점에서 이벤트 발행 누락은 downstream 시스템(product_metrics 집계 등)의 데이터 정합성 문제로 이어질 수 있다.
🧪 이벤트 발행 검증 추가 제안
`@DisplayName`("SUCCESS 콜백을 받으면 Payment는 COMPLETED, Order는 PAID로 전환된다.") `@Test` void completesPaymentAndMarksOrderPaid_whenSuccess() { // arrange Order order = orderRepository.save(Order.create(1L, List.of(new OrderItemSnapshot(1L, "상품", 10000L, 1)))); Payment payment = paymentRepository.save(Payment.create(order.getId(), "pgOrderCode-001", CardType.SAMSUNG, "1234-5678-9012-3456", 10000L)); payment.assignPgTransaction("TXN-001"); // act paymentFacade.handleCallback(new PgCallbackCommand("TXN-001", "SUCCESS", null)); // assert assertAll( () -> assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED), - () -> assertThat(orderRepository.findById(order.getId()).get().getStatus()).isEqualTo(Order.Status.PAID) + () -> assertThat(orderRepository.findById(order.getId()).get().getStatus()).isEqualTo(Order.Status.PAID), + () -> then(outboxEventPublisher).should().publish(any()) ); }추가로, FAILED 콜백 시에는 이벤트가 발행되지 않아야 함을 검증하는 테스트도 고려해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java` around lines 103 - 119, The test completesPaymentAndMarksOrderPaid_whenSuccess is missing verification that the outboxEventPublisher mock was called with a PAYMENT_COMPLETED event; update this test to verify outboxEventPublisher.publish(...) (or the actual publish method on the injected outboxEventPublisher mock) was invoked with an OutboxEvent whose type/aggregate/event name corresponds to PAYMENT_COMPLETED after paymentFacade.handleCallback(...) returns, and add a complementary assertion in the FAILED callback test to verify outboxEventPublisher.publish was not called; reference the PaymentFacade.handleCallback invocation and the outboxEventPublisher mock in your assertions.
🟠 Major comments (21)
modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java-24-37 (1)
24-37:⚠️ Potential issue | 🟠 Major직렬화 실패를
RuntimeException으로 통일하면 장애 분류와 재처리 정책이 불명확해진다.운영 관점에서 페이로드 오류와 일시 장애를 구분하지 못하면 재시도 폭증 또는 DLQ 누락으로 이어질 수 있다. 공통 모듈 예외를
DataSerializationException같은 명시 타입으로 분리해 호출부가 실패 유형별 정책을 적용할 수 있게 수정하는 것이 필요하다.
추가 테스트로 잘못된 JSON 입력 시 예외 타입과 cause 보존 여부를 검증하고, 직렬화 실패 케이스에서도 동일 타입이 발생하는지 확인하는 테스트를 추가하는 것이 좋다.수정 예시
public final class DataSerializer { + public static final class DataSerializationException extends RuntimeException { + public DataSerializationException(String message, Throwable cause) { + super(message, cause); + } + } @@ public static String serialize(Object object) { try { return objectMapper.writeValueAsString(object); } catch (JsonProcessingException e) { - throw new RuntimeException("[DataSerializer] serialize failed", e); + throw new DataSerializationException("[DataSerializer] serialize failed", e); } } @@ public static <T> T deserialize(String json, Class<T> clazz) { try { return objectMapper.readValue(json, clazz); } catch (IOException e) { - throw new RuntimeException("[DataSerializer] deserialize failed", e); + throw new DataSerializationException("[DataSerializer] deserialize failed", e); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java` around lines 24 - 37, Replace the generic RuntimeException usage in DataSerializer.serialize and DataSerializer.deserialize with a specific checked or unchecked DataSerializationException so callers can distinguish payload errors from transient failures; create a new DataSerializationException class (e.g., extends RuntimeException) and throw new DataSerializationException("[DataSerializer] serialize failed", e) from serialize and similarly from deserialize, preserving the original cause (the caught JsonProcessingException/IOException) and referencing DataSerializer and objectMapper to locate the change; also add unit tests for DataSerializer.serialize with a type that fails to serialize and for DataSerializer.deserialize with malformed JSON to assert the exception type is DataSerializationException and that getCause() returns the original exception.apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java-39-50 (1)
39-50:⚠️ Potential issue | 🟠 Major프로모션 생성 시 불변식 검증 누락으로 잘못된 데이터가 운영 DB에 적재될 수 있다.
운영 관점에서
maxQuantity <= 0또는startedAt >= endedAt데이터가 저장되면 발급 요청이 대량 실패하고 장애 원인 파악이 어려워진다.create()에서 입력 불변식을 즉시 검증하고CoreException으로 실패를 표준화하는 수정이 필요하다.
추가 테스트로maxQuantity경계값(0, 음수), 기간 역전(startedAt >= endedAt), null 입력 케이스를 단위 테스트로 보강하는 것이 좋다.수정 예시
public static CouponPromotion create(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) { + if (couponId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 식별자는 필수입니다."); + } + if (maxQuantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "최대 수량은 1 이상이어야 합니다."); + } + if (startedAt == null || endedAt == null || !startedAt.isBefore(endedAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "프로모션 기간이 올바르지 않습니다."); + } return new CouponPromotion(couponId, maxQuantity, startedAt, endedAt); }Based on learnings: "In the loop-pack-be-l2-vol3-java project, 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/domain/coupon/CouponPromotion.java` around lines 39 - 50, The create() factory in CouponPromotion currently allows invalid state to be persisted; update CouponPromotion.create(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) to validate inputs and throw CoreException on failure (use same error type/format as validateIssuable()), specifically: check couponId/startedAt/endedAt for null, enforce maxQuantity > 0, and ensure startedAt.isBefore(endedAt); if any check fails throw new CoreException(ErrorType.BAD_REQUEST, "...") with clear messages; keep validateIssuable() as-is for runtime checks and add unit tests covering maxQuantity boundary (0 and negative), startedAt >= endedAt, and null inputs to assert CoreException is thrown.apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java-30-30 (1)
30-30:⚠️ Potential issue | 🟠 Major좋아요 수를 상수
0으로 고정하면 조회 응답 정확도가 깨진다.운영 관점에서 지표 집계가 정상이어도 API가 항상 0을 내려주면 장애 탐지가 늦어지고 사용자 신뢰가 떨어진다.
from(Product)에서 고정값을 넣지 말고, 집계값을 주입받는 팩토리(또는withLikeCount)를 추가해 호출부가product_metrics.like_count를 전달하도록 수정하는 것이 안전하다.
추가 테스트로ProductInfo매핑 단위 테스트(집계값 반영 여부)와 상품 상세/목록 통합 테스트(실제 likeCount 노출 값 검증)를 보강하는 것이 필요하다.수정 예시
public record ProductInfo( @@ ) { @@ - public static ProductInfo from(Product product) { + public static ProductInfo from(Product product, Integer likeCount) { return new ProductInfo( product.getId(), new BrandSummary(product.getBrandId(), null), product.getName(), product.getDescription(), product.getPrice(), product.getStockQuantity(), - 0, + likeCount == null ? 0 : likeCount, product.getVisibility(), product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); }🤖 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/product/ProductInfo.java` at line 30, ProductInfo currently hardcodes likeCount to 0 in the from(Product) mapping, which breaks metrics; update ProductInfo by removing the constant 0 in the static factory from(Product) and add an overload or builder method (e.g., from(Product) + withLikeCount(long) or a new factory from(Product, long)) so callers can inject product_metrics.like_count; update all call sites that build ProductInfo (e.g., service/controller layers) to pass the aggregated like count, and add/update unit test for ProductInfo mapping to assert the likeCount is preserved and integration tests for product detail/list endpoints to verify the actual likeCount is exposed.apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java-43-51 (1)
43-51:⚠️ Potential issue | 🟠 Major발급 엔티티 팩토리에 도메인 불변식 검증이 없어 실패가 DB 시점으로 지연된다.
운영 관점에서 입력 이상이 소비 트랜잭션 말단에서
DataIntegrityViolationException로 터지면 원인 분류와 재시도 제어가 어려워진다.create()에서userId/couponId/expiresAtnull 검증과 만료 시각 유효성 검증을 선행해 조기 실패하도록 수정하는 것이 필요하다.
추가 테스트로 null 인자 케이스와 과거 만료 시각 케이스에서 즉시 예외가 발생하는지 단위 테스트를 추가하는 것이 좋다.수정 예시
+import java.util.Objects; @@ public static IssuedCoupon create(Long userId, Long couponId, LocalDateTime expiresAt) { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(couponId, "couponId must not be null"); + Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + if (!expiresAt.isAfter(LocalDateTime.now())) { + throw new IllegalArgumentException("expiresAt must be in the future"); + } IssuedCoupon issuedCoupon = new IssuedCoupon(); issuedCoupon.userId = userId; issuedCoupon.couponId = couponId;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java` around lines 43 - 51, The IssuedCoupon.create factory currently defers invalid inputs to the DB; add precondition checks in IssuedCoupon.create to validate userId, couponId and expiresAt are non-null (userId, couponId, expiresAt) and that expiresAt is in the future (compare to ZonedDateTime.now()), and throw IllegalArgumentException with clear messages for each violation to fail fast; update or add unit tests to assert create throws for null userId/couponId/expiresAt and for an expiresAt in the past.apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java-23-26 (1)
23-26:⚠️ Potential issue | 🟠 Major결제 이벤트 payload 방어가 없어서 소비 지연 장애로 확산될 수 있다.
Line 24에서
getProductIds()를 바로 순회하면 null 또는 null 원소가 포함된 메시지 1건만으로도 NPE가 발생해 소비 루프 재시도/지연을 유발할 수 있다. 운영 안정성을 위해 빈 목록/null 항목을 방어적으로 건너뛰고, 이상 데이터는 경고 로그로 남기는 형태가 필요하다.productIds가 null/empty/null 원소인 케이스에서 예외 없이 처리되고 유효 ID만 카운트 증가하는 테스트를 추가하는 것이 좋다.수정안 예시
`@Override` public void handle(Event<PaymentCompletedEventPayload> event) { - for (Long productId : event.getPayload().getProductIds()) { - productMetricsRepository.incrementOrderCount(productId); - } + if (event.getPayload() == null || event.getPayload().getProductIds() == null) { + return; + } + for (Long productId : event.getPayload().getProductIds()) { + if (productId == null) { + continue; + } + productMetricsRepository.incrementOrderCount(productId); + } }🤖 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/handler/PaymentCompletedEventHandler.java` around lines 23 - 26, PaymentCompletedEventHandler.handle currently iterates event.getPayload().getProductIds() without null checks which can cause NPEs; update handle(Event<PaymentCompletedEventPayload> event) to defensively check for null payload and null/empty productIds, skip null productId elements, call productMetricsRepository.incrementOrderCount(productId) only for non-null positive IDs, and emit a warning-level log (including event id/context) when productIds is null/empty or contains nulls/invalid entries; also add unit tests for Event<PaymentCompletedEventPayload> scenarios (null productIds, empty list, list with null element) verifying no exception is thrown and only valid IDs are counted.apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java-30-40 (1)
30-40:⚠️ Potential issue | 🟠 MajorOutbox 생성 시각을 로컬 시간에 의존하면 재시도 안정성이 떨어진다.
Line 39의
LocalDateTime.now()는 JVM 기본 타임존에 의존하므로, 운영 환경에서 노드/DB 타임존이 다르면 스케줄러의createdAt기준 조회가 흔들려 지연 발행이나 배치 편향을 유발할 수 있다. UTC 기준의 절대시간(Instant또는OffsetDateTime)으로 저장하도록 변경하는 것이 안전하다. 추가로 서로 다른 타임존 설정에서 생성/조회 경계(<= now)가 일관적인지 통합 테스트를 넣는 것이 좋다.수정안 예시
-import java.time.LocalDateTime; +import java.time.Instant; @@ - private LocalDateTime createdAt; + private Instant createdAt; @@ - outbox.createdAt = LocalDateTime.now(); + outbox.createdAt = Instant.now();🤖 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/Outbox.java` around lines 30 - 40, The Outbox.create method currently sets createdAt using LocalDateTime.now(), which is timezone-dependent; change the Outbox.createdAt field to a timezone-aware type (e.g., Instant or OffsetDateTime), update its JPA mapping (or add a converter) accordingly, and set createdAt = Instant.now() (or OffsetDateTime.now(ZoneOffset.UTC)) inside Outbox.create; also add/invoke integration tests that create and query Outbox across differing JVM/DB timezone settings to ensure createdAt-based queries (<= now) behave consistently.apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java-49-51 (1)
49-51:⚠️ Potential issue | 🟠 Major
LocalDateTime.now()직접 호출은 테스트 불가능하고 타임존 의존성을 야기한다.운영 환경에서 서버 타임존 설정에 따라 만료 판정 결과가 달라질 수 있다. 또한 단위 테스트에서 시간을 제어할 수 없어 경계값 테스트가 불가능하다.
Clock을 주입받거나, 시간 비교 로직을 서비스 레이어로 이동하여 테스트 가능한 구조로 변경해야 한다.🔧 권장 수정안: 서비스 레이어에서 시간 비교
도메인 엔티티에서
isIssuable()메서드를 제거하고, 레포지토리 쿼리에서 이미 시간 조건을 적용하고 있으므로(findByIdAndDeletedAtIsNullAndExpiresAtAfter) 해당 쿼리 결과를 신뢰하는 방식으로 변경한다:- public boolean isIssuable() { - return deletedAt == null && expiresAt.isAfter(LocalDateTime.now()); - } + public boolean isDeleted() { + return deletedAt != null; + }또는
Clock을 파라미터로 받아 테스트 가능하게 변경한다:public boolean isIssuable(Clock clock) { return deletedAt == null && expiresAt.isAfter(LocalDateTime.now(clock)); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java` around lines 49 - 51, The isIssuable() method on Coupon directly calls LocalDateTime.now(), which causes timezone dependency and makes tests non-deterministic; either remove isIssuable() from the entity and rely on your repository query (findByIdAndDeletedAtIsNullAndExpiresAtAfter) to enforce expiry/deleted checks at the service layer, or change the method signature to accept a Clock (e.g., isIssuable(Clock clock)) and use LocalDateTime.now(clock) so callers can inject a test Clock; update callers/service logic accordingly to pass a production Clock.systemDefaultZone() or a controllable test Clock.apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java-18-18 (1)
18-18:⚠️ Potential issue | 🟠 Major
Snowflake인스턴스의 무작위 machine ID 할당으로 인한 ID 충돌 위험이 있다.
Snowflake클래스의 기본 생성자에서RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1)을 통해 0~1023 범위에서 무작위로 machine ID를 할당한다. 따라서ProductViewEventPublisher에서new Snowflake()를 매번 호출할 때마다 새로운 인스턴스가 생성되고, 서로 다른 machine ID를 가질 가능성이 높지만 동일한 machine ID를 할당받을 수 있다. 같은 machine ID를 가진 여러 인스턴스에서 동시에 생성된 ID는 timestamp와 sequence 값이 동일할 경우 충돌한다.운영 관점: 프로덕션 환경에서 이벤트 기반 처리 시 ID 충돌로 인한 데이터 중복 또는 손실이 발생할 수 있다.
수정안:
Snowflake를 Spring Bean으로 등록하여 애플리케이션 전체에서 단일 인스턴스 공유- 또는 생성자에서 명시적으로 machine ID를 받아 환경별로 설정하는 방식 도입 (예:
new Snowflake(machinedId))- machine ID 할당을 중앙 조정 방식(Zookeeper, ConfigMap 등)으로 관리
추가 테스트: 여러
Snowflake인스턴스가 동일한 machine ID를 할당받는 시나리오에서 생성된 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/application/product/ProductViewEventPublisher.java` at line 18, ProductViewEventPublisher currently constructs a new Snowflake instance (private final Snowflake eventIdSnowflake = new Snowflake()) which can produce colliding IDs because Snowflake's default constructor assigns a random machine ID; change to use a single, deterministic Snowflake instance application-wide by either (A) defining a Spring `@Bean` that creates Snowflake with a fixed machineId (injected into ProductViewEventPublisher) or (B) modify Snowflake usage to accept an explicit machineId passed into ProductViewEventPublisher's constructor from configuration/Env, and update wiring to inject that configured machineId; ensure the chosen approach guarantees a singleton Snowflake instance or centrally coordinated machineId allocation.apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java-138-142 (1)
138-142:⚠️ Potential issue | 🟠 Major이벤트 페이로드에서
userId가null로 전달되고 있다.
PaymentCompletedEventPayload는userId필드를 정의하지만,PaymentFacade140번 줄에서null로 전달된다.Order엔터티는userId필드를 가지고 있으므로 주문 조회 시 이 정보를 함께 전달할 수 있다.현재
PaymentCompletedEventHandler는productIds만 사용하므로 즉각적인 오류는 없으나, 이벤트 컨트랙트로서 불완전하다. 다른 서비스에서 이 이벤트를 소비하거나 운영 추적/감사 로그에서userId가 필요할 경우 누락된 데이터로 인한 운영 장애가 발생할 수 있다.Order에서 userId를 조회하여 페이로드에 전달하거나, userId가 불필요하다면 PaymentCompletedEventPayload에서 해당 필드를 제거하여 설계를 명확히 한다.
🤖 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 138 - 142, PaymentCompletedEventPayload is being created with a null userId in PaymentFacade (the call to PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds)); fix by retrieving the Order's userId and passing it into the payload (e.g., load Order by payment.getOrderId() via your OrderRepository/OrderService and use order.getUserId() instead of null), and update any relevant tests; alternatively, if userId truly isn't part of the event contract, remove the userId field from PaymentCompletedEventPayload and adjust all callers (including PaymentFacade) and handlers accordingly.apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java-33-37 (1)
33-37:⚠️ Potential issue | 🟠 Major핸들러 예외 발생 시 이벤트 재처리 무한 루프 위험이 있다.
현재 구현에서 handler.handle() 호출 시 예외가 발생하면
@Transactional에의해 전체 트랜잭션이 롤백되며, EventHandled 레코드도 함께 롤백된다. 이 경우 동일 이벤트가 반복 재처리되어 무한 루프가 발생할 수 있다.각 EventType마다 단일 핸들러만 supports()가 true를 반환하므로 (equality check 기반), 여러 핸들러가 경쟁하지 않는다. 하지만 사용자 쿠폰 발급 등 비멱등 작업을 수행하는 핸들러의 경우 재처리 시 중복 발급 위험이 높다.
개별 핸들러를 try-catch로 감싸고 실패를 로깅하되, 재시도 전략(exponential backoff, circuit breaker, 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/EventProcessingService.java` around lines 33 - 37, EventProcessingService currently calls handler.handle(event) inside the `@Transactional` boundary so any exception rolls back EventHandled and causes infinite reprocessing; wrap each handler.handle(event) call (iterating eventHandlers and using supports()) in a try-catch, log the failure with contextual info, and on failure apply a retry/poison-message strategy: mark the event as failed (e.g., publish to a Dead Letter Queue or persist a failure record) rather than letting the main transaction roll back, or execute the handler in a separate transaction boundary so non-idempotent handlers (e.g., coupon issuance) do not get duplicated; ensure to reference eventHandlers, supports(), handle(), EventHandled and the `@Transactional` scope when making the change.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java-27-42 (1)
27-42:⚠️ Potential issue | 🟠 Major로그에 민감 정보가 노출될 수 있으며, 무한 재시도 위험이 있다.
운영 관점에서 두 가지 문제가 있다:
민감 정보 로깅:
message전체를 로그에 출력하면userId,paymentId,orderId등의 민감 정보가 로그 시스템에 노출된다. GDPR/개인정보보호 관점에서 위험하다.무한 재시도:
nack(Duration.ofSeconds(1))은 처리 실패 시 무한히 재시도하므로, 특정 메시지가 지속적으로 실패하면 해당 파티션의 처리가 영구히 지연된다(poison pill blocking).🛡️ 수정안
public void consume(String message, Acknowledgment ack) { try { Event<EventPayload> event = Event.fromJson(message); if (event == null) { - log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message); + log.warn("[MetricsEventConsumer] 이벤트 파싱 실패 - 메시지 스킵"); ack.acknowledge(); return; } eventProcessingService.process(event); ack.acknowledge(); } catch (Exception e) { - log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); + log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, eventType={}", + extractEventTypeForLogging(message), e); ack.nack(Duration.ofSeconds(1)); } }추가 고려사항:
- 재시도 횟수 제한 및 Dead Letter Topic(DLT) 전송 로직 추가를 검토해야 한다.
ErrorHandler또는RetryTopicConfiguration을 활용한 재시도 정책 구성을 권장한다.🤖 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/MetricsEventConsumer.java` around lines 27 - 42, The consume method in MetricsEventConsumer currently logs the entire message and nacks with an unbounded retry; change it to stop logging the raw message (use Event.fromJson to extract and log only non-sensitive metadata like eventId/type or mask userId/paymentId/orderId) and implement a retry/dead-letter policy instead of infinite nack: add a retry counter (e.g., a retry header or field) when calling ack.nack or use your framework's Retry/DeadLetter handling (e.g., RetryTopicConfiguration or an ErrorHandler) to limit retries and route to a DLT after N attempts; update consume to check/increment the retry count before nack and call ack.acknowledge or forward to DLT when exceeded, and ensure eventProcessingService.process remains called only after masking/validating input.modules/event-contract/src/main/java/com/loopers/event/Snowflake.java-18-20 (1)
18-20:⚠️ Potential issue | 🟠 Major분산 환경에서
machineId충돌 가능성이 있다.
RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1)로 0~1023 범위에서 무작위로 선택하므로, 다중 인스턴스 배포 시 동일machineId가 할당될 확률이 존재한다. 인스턴스 수가 많아질수록 충돌 확률이 높아진다(Birthday Paradox).수정안:
machineId를 외부에서 주입받는 생성자를 추가하고, 프로덕션 환경에서는 인스턴스별 고유값을 설정으로 제공하는 것을 권장한다.♻️ machineId 주입 생성자 추가
public Snowflake() { this.machineId = RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1); } + +public Snowflake(long machineId) { + if (machineId < 0 || machineId > MAX_MACHINE_ID) { + throw new IllegalArgumentException("machineId must be between 0 and " + MAX_MACHINE_ID); + } + this.machineId = machineId; +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/event-contract/src/main/java/com/loopers/event/Snowflake.java` around lines 18 - 20, The Snowflake constructor currently assigns machineId randomly causing collision risk; add an overloaded constructor Snowflake(long machineId) that accepts and sets the machineId (validate it is >=0 and <= MAX_MACHINE_ID and throw IllegalArgumentException if out of range), keep or deprecate the no-arg constructor as a fallback but prefer the injected constructor for production, and update any usages to allow providing a configuration-derived unique machineId per instance.modules/event-contract/src/main/java/com/loopers/event/Snowflake.java-22-36 (1)
22-36:⚠️ Potential issue | 🟠 Major시스템 시계 역행(clock skew) 시 ID 충돌 또는 비단조성 문제가 발생할 수 있다.
운영 환경에서 NTP 동기화, VM 마이그레이션, 컨테이너 재시작 등으로 시스템 시계가 역행할 수 있다. 현재 구현에서
timestamp < lastTimestamp인 경우 별도 처리 없이sequence를 0으로 리셋하고 진행하므로, 이전에 발급된 ID와 충돌하거나 시간 순서가 역전된 ID가 생성될 수 있다.수정안: 시계 역행 감지 시 예외를 던지거나,
lastTimestamp까지 대기하는 방어 로직을 추가해야 한다.🛡️ 시계 역행 방어 로직 추가
public synchronized long nextId() { long timestamp = System.currentTimeMillis() - EPOCH; + if (timestamp < lastTimestamp) { + // 시계가 역행한 경우: lastTimestamp까지 대기하거나 예외 발생 + timestamp = waitNextMillis(lastTimestamp); + } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & MAX_SEQUENCE; if (sequence == 0) { timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0; } lastTimestamp = timestamp; return (timestamp << (MACHINE_ID_BITS + SEQUENCE_BITS)) | (machineId << SEQUENCE_BITS) | sequence; }추가 테스트: 시계 역행 시나리오를 시뮬레이션하는 단위 테스트를 추가하여 방어 로직이 정상 동작하는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/event-contract/src/main/java/com/loopers/event/Snowflake.java` around lines 22 - 36, The nextId() method currently ignores clock rollback (timestamp < lastTimestamp) which can produce duplicate or non-monotonic IDs; update nextId() in class Snowflake to detect when timestamp < lastTimestamp and handle it by either throwing a clear exception (e.g., IllegalStateException with lastTimestamp and current timestamp) or by blocking until clock catches up (call waitNextMillis(lastTimestamp)) before proceeding; ensure lastTimestamp is only updated after the safe timestamp is determined, keep the existing sequence handling (sequence increment and reset) intact, and add a unit test that simulates a clock rollback to validate the new defensive behavior.apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java-14-15 (1)
14-15:⚠️ Potential issue | 🟠 Major다중 인스턴스 환경에서 Snowflake
machineId충돌 위험이 있다.운영 관점에서
new Snowflake()는 매 초기화 시 RandomGenerator로machineId를 선택하므로, 다중 인스턴스 배포(쿠버네티스, 다중 Pod) 환경에서 동일한machineId를 가진 인스턴스가 존재할 수 있다. 1024개(10비트) 범위 내에서 무작위 선택되므로, 두 개 이상의 commerce-api 인스턴스가 같은machineId를 할당받을 가능성이 있으며, 이 경우 동일 밀리초 내에 생성된 outboxId 또는 eventId가 충돌할 수 있다.수정안:
- 환경 변수 또는 ConfigMap을 통해 인스턴스별 고유
machineId를 주입받도록 Snowflake 생성자 수정- Kubernetes 환경이라면 Pod ordinal index를 활용하여
machineId계산- Snowflake 빈을 별도로 구성하여 설정 파일에서 명시적으로
machineId지정추가 테스트: 다중 인스턴스 환경에서 ID 충돌 여부를 검증하는 통합 테스트 작성 필요. SnowflakeTest는 단일 인스턴스 내 동시성만 검증하며, 서로 다른 machineId를 가진 인스턴스 간 충돌을 검증하지 않는다.
🤖 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/outbox/OutboxEventPublisher.java` around lines 14 - 15, OutboxEventPublisher currently creates two Snowflake instances with new Snowflake() (outboxIdSnowflake and eventIdSnowflake) which picks a random machineId and can collide across instances; change this so Snowflake receives a deterministic machineId from configuration: add a configurable property (env/ConfigMap) or compute from Pod ordinal, inject it into a Snowflake bean, and replace the direct new Snowflake() calls in OutboxEventPublisher with that injected bean (or two beans if you need separate generators) so machineId is explicitly set and testable (reference: OutboxEventPublisher, outboxIdSnowflake, eventIdSnowflake, Snowflake).apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java-25-49 (1)
25-49:⚠️ Potential issue | 🟠 Major조회한 요청 엔티티의 식별값을 기준으로 발급해야 한다
Line 25-49는
requestId로 요청을 조회한 뒤에도 이벤트 payload의couponId와userId를 그대로 사용한다. 운영에서 outbox 직렬화 버그나 잘못 재발행된 이벤트가 들어오면 다른 사용자의 요청 상태를 완료로 바꾸면서 엉뚱한 쿠폰을 발급할 수 있다.process()는requestId만 받아request.getCouponId()와request.getUserId()만 사용하거나, payload와 불일치하면 예외로 롤백해야 한다. request row와 payload 값이 다를 때 발급이 거부되고 상태가 유지되는 테스트를 추가해야 한다.🤖 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/CouponIssueProcessor.java` around lines 25 - 49, process(Long requestId, Long couponId, Long userId) currently trusts the payload couponId/userId instead of the loaded CouponIssueRequest; change the logic in process() to either (A) use request.getCouponId() and request.getUserId() for all downstream operations (existence check, issuedCouponRepository.save, and request.fail/succeed) or (B) validate that payload couponId and userId match request.getCouponId() and request.getUserId(), and if they differ throw/reject so the transaction rolls back and the request remains pending; update checks that call issuedCouponRepository.existsByUserIdAndCouponId and couponRepository.findIssuableCoupon to use the request-derived IDs, and add a test that simulates a mismatched payload to assert the request state is unchanged and no IssuedCoupon is saved.modules/event-contract/src/main/java/com/loopers/event/Event.java-14-36 (1)
14-36:⚠️ Potential issue | 🟠 Major이벤트 계약 검증을 생성·역직렬화 시점에 강제해야 한다
Line 14-36은
eventId,type,payload의 null 여부와type-payload조합을 확인하지 않는다. 운영에서 잘못된 이벤트 한 건이 Kafka에 들어가면 소비 측에서 재시도 루프를 만들거나 잘못된 집계로 이어질 수 있다.of()와fromJson()에서 공통 검증 메서드로 non-null과type.getPayloadClass().isInstance(payload)를 강제하고, null 필드 및 타입 불일치 JSON에 대한 단위 테스트를 추가해야 한다.수정 예시
+import java.util.Objects; + `@Getter` `@NoArgsConstructor` public class Event<T extends EventPayload> { + private static void validate(EventType type, EventPayload payload) { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(payload, "payload must not be null"); + if (!type.getPayloadClass().isInstance(payload)) { + throw new IllegalArgumentException("Payload does not match event type: " + type); + } + } + private Long eventId; private EventType type; private T payload; public static <T extends EventPayload> Event<T> of(Long eventId, EventType type, T payload) { + Objects.requireNonNull(eventId, "eventId must not be null"); + validate(type, payload); Event<T> event = new Event<>(); event.eventId = eventId; event.type = type; @@ public static Event<EventPayload> fromJson(String json) { EventRaw raw = DataSerializer.deserialize(json, EventRaw.class); if (raw == null) { return null; } Event<EventPayload> event = new Event<>(); event.eventId = raw.getEventId(); event.type = EventType.from(raw.getType()); event.payload = DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass()); + Objects.requireNonNull(event.eventId, "eventId must not be null"); + validate(event.type, event.payload); return event; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/event-contract/src/main/java/com/loopers/event/Event.java` around lines 14 - 36, Add a shared validation method in Event (e.g., validateEvent(eventId, type, payload)) and call it from both Event.of(...) and Event.fromJson(...): ensure eventId, type and payload are non-null and assert that type.getPayloadClass().isInstance(payload); on violation throw a clear IllegalArgumentException. In fromJson, perform validation after deserializing EventRaw and after converting payload with DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass()). Add unit tests covering null eventId/type/payload and JSON payload type-mismatch (using EventRaw -> fromJson path) to ensure exceptions are raised for invalid inputs.apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java-46-72 (1)
46-72:⚠️ Potential issue | 🟠 Major핸들러 예외 시 원자성 검증이 빠져 있다
현재 테스트는 단위 테스트로 Mock을 사용하고 있어 실제 트랜잭션 경계를 검증하지 못한다. 운영 환경에서 핸들러가
RuntimeException을 던지면@Transactional경계 내에서 전체 트랜잭션이 롤백되어야event_handled레코드도 함께 롤백된다. 그러나 이 동작을 검증하는 테스트가 없다.현재 테스트 범위:
- ✓ 성공 경로: 이벤트 처리 및 핸들러 실행
- ✓ 중복 감지: 저장 실패 시 핸들러 미실행
- ✗ 예외 흐름: 핸들러 실패 시 트랜잭션 롤백
핸들러 예외 시
event_handled만 남으면 해당 Kafka 메시지는 영구 유실된다. 통합 테스트를 추가하여 핸들러가RuntimeException을 던질 때 전체 트랜잭션이 롤백되고event_handled가 저장되지 않는 것을 검증해야 한다.🤖 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/EventProcessingServiceTest.java` around lines 46 - 72, Add an integration test (e.g., in EventProcessingServiceTest or a new class annotated with `@SpringBootTest` and a real DB via Testcontainers/@AutoConfigureTestDatabase) that does not mock EventHandledRepository or the handler; register a test handler bean that throws a RuntimeException when invoked, call eventProcessingService.process(event), catch the exception, and then assert that eventHandledRepository has no persisted record (e.g., count() == 0 or findByEventId returns empty) to verify the `@Transactional` boundary rolls back both handler work and the event_handled insert; refer to EventProcessingService.process, EventHandledRepository, and the handler bean used in tests.apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java-48-53 (1)
48-53:⚠️ Potential issue | 🟠 Major예외 타입을 구분하여 중복 발급과 다른 무결성 오류를 분리하여 처리해야 한다.
Line 48-54에서 모든
DataIntegrityViolationException을 중복 발급으로 처리하면,NOT NULL위반이나 FK 제약 위반 같은 다른 무결성 오류도 재시도 없이 FAILED로 종료되어 이벤트가 유실되고 원인 추적이 불가능해진다. 중복 제약조건 위반만request.fail()로 매핑하고, 나머지 무결성 오류는 cause와 함께 로깅한 후 예외를 재던져 트랜잭션을 롤백해야 한다.수정 예시
try { issuedCouponRepository.save(IssuedCoupon.create(userId, couponId, coupon.getExpiresAt())); } catch (DataIntegrityViolationException e) { - log.warn("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId); - request.fail("이미 발급된 쿠폰입니다."); - return; + if (isDuplicateIssuedCoupon(e)) { + log.info("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId); + request.fail("이미 발급된 쿠폰입니다."); + return; + } + log.warn("[CouponIssueProcessor] 쿠폰 발급 처리 실패, requestId={}, couponId={}, userId={}", requestId, couponId, userId, e); + throw e; }중복 제약 위반(user_id, coupon_id 조합)과 다른 무결성 제약 위반을 식별하는
isDuplicateIssuedCoupon()메서드를 추가하고, 각각 FAILED 처리와 롤백 재시도로 동작을 검증하는 테스트를 작성해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java` around lines 48 - 53, The catch block that treats all DataIntegrityViolationException as duplicate issuance must be split: implement a helper isDuplicateIssuedCoupon(Throwable cause) and in the catch for DataIntegrityViolationException from issuedCouponRepository.save(IssuedCoupon.create(...)) call it; if true, keep the current behavior (log warn with requestId/couponId/userId and call request.fail("이미 발급된 쿠폰입니다.")), otherwise log the full cause (including e.getCause()) with error level and rethrow the exception to trigger transaction rollback and retrial; ensure references to IssuedCoupon.create, issuedCouponRepository.save, request.fail, and the new isDuplicateIssuedCoupon method are used so tests can target duplicate vs other constraint violations.apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java-52-61 (1)
52-61:⚠️ Potential issue | 🟠 Major재시도 스케줄러가 동일 outbox를 여러 워커가 동시에 집을 수 있다.
현재는 pending row를 조회한 뒤 별도 선점 없이 바로 발행하므로, 같은 JVM의
AFTER_COMMIT비동기 경로와 스케줄러가 동시에 처리하거나 다중 인스턴스 환경에서 동일 row가 중복 발행될 수 있다. 장애 시 동일 이벤트가 폭증해 consumer idempotency 테이블을 과도하게 두드리므로,PROCESSING상태 선점,FOR UPDATE SKIP LOCKED, 또는 리더 선출/분산 락 중 하나로 단일 소유권을 보장하기 바란다. 동일 pending row에 대해 두 워커가 동시에 재시도해도 Kafka send가 1회만 호출되는 통합 테스트를 추가해야 한다.🤖 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/outbox/MessageRelay.java` around lines 52 - 61, publishPendingEvents currently fetches pending Outbox rows and calls publishToKafka without any ownership guard, allowing multiple workers or the AFTER_COMMIT path to process the same row concurrently; modify the flow to claim rows before sending by updating Outbox to a PROCESSING state (or select rows with FOR UPDATE SKIP LOCKED) via outboxRepository and only call publishToKafka for successfully claimed rows, or alternatively guard with a distributed lock/leader election around publishPendingEvents; also add an integration test that simulates two concurrent workers attempting to process the same pending Outbox and asserts that publishToKafka (or the Kafka send) is invoked exactly once.apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java-33-51 (1)
33-51:⚠️ Potential issue | 🟠 Major트랜잭션 롤백 시 Redis 발급 수가 복구되지 않는다.
increment()는 외부 상태를 먼저 바꾸지만 보상은 품절/중복 두 분기에만 있다. 이후outboxEventPublisher.publish(), JPA flush, commit 단계에서 예외가 나면 DB는 롤백돼도 Redis 카운트는 남아 조기 소진이 발생하므로, rollback hook(afterCompletion)으로 보상을 등록하거나 카운트 확정을 커밋 이후 단계로 옮기기 바란다.OutboxEventPublisher가 예외를 던질 때 요청 row는 저장되지 않고 Redis 카운트도 원복되는 통합 테스트를 추가해야 한다.🤖 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/CouponIssueRequestFacade.java` around lines 33 - 51, The Redis counter is incremented before DB commit, so if later steps (outboxEventPublisher.publish, JPA flush/commit) fail the Redis count is not restored; in CouponIssueRequestFacade, change the flow to either (A) register a transaction synchronization (TransactionSynchronizationManager.registerSynchronization) after calling couponIssueCountManager.increment(couponId) that will decrement on afterCompletion when the transaction did not commit, or (B) move the increment to run after successful commit (use registerSynchronization's afterCommit to call couponIssueCountManager.increment) and keep decrement handling for the duplicate/overflow branches; update logic around couponIssueCountManager.increment/decrement, CouponIssueRequest.create/save and outboxEventPublisher.publish accordingly and add an integration test that simulates outboxEventPublisher throwing so the DB rolls back and asserts Redis count is unchanged.apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java-39-49 (1)
39-49:⚠️ Potential issue | 🟠 Major1초 송신 대기 제한이 중복 발행을 만든다.
브로커 ACK가 1초를 넘으면 이 코드는 실패로 판단하고 outbox row를 남기는데, 실제 전송은 뒤늦게 성공할 수 있어 같은 이벤트가 재시도 경로에서 다시 발행될 수 있다. 운영에서는 consumer idempotency 저장소와 쿠폰/집계 처리 경로에 불필요한 중복 부하가 생기므로,
get(1, TimeUnit.SECONDS)대신 Kafka producer의 delivery timeout과 성공 콜백 완료 시점에만 삭제를 수행하는 방식으로 바꾸기 바란다. send future가 1초 이후 성공하는 상황을 주입해 outbox가 재전송되지 않는지 검증하는 테스트를 추가해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java` around lines 39 - 49, The publishToKafka method currently blocks on kafkaTemplate.send(...).get(1, TimeUnit.SECONDS) which causes false failures and leaves the outbox row when the broker ACK is slower; change it to not use the 1s get timeout and instead attach a success/failure callback to the send future (e.g., use the send Future/ListenableFuture callback) so that outboxRepository.delete(outbox) is invoked only on successful send and failures are handled/logged in the failure callback; ensure you rely on the Kafka producer delivery timeout config rather than an artificial 1s get, and add a unit/integration test that injects a delayed successful send (future completes after >1s) to verify the outbox is not retried/duplicated.
🟡 Minor comments (11)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java-44-51 (1)
44-51:⚠️ Potential issue | 🟡 Minor상태 전이 가드가 없어 잘못된 상태 변경이 가능하다.
succeed()와fail()메서드가 현재 상태를 검증하지 않아, 이미SUCCESS또는FAILED상태인 요청에 대해 중복 호출 시 상태가 덮어씌워질 수 있다. Kafka Consumer 재처리 시나리오에서 멱등성 보장에 영향을 줄 수 있다.🛡️ 상태 전이 검증 추가 제안
public void succeed() { + if (this.status != Status.PENDING) { + throw new IllegalStateException("Only PENDING requests can succeed. Current: " + this.status); + } this.status = Status.SUCCESS; } public void fail(String reason) { + if (this.status != Status.PENDING) { + throw new IllegalStateException("Only PENDING requests can fail. Current: " + this.status); + } this.status = Status.FAILED; this.reason = reason; }대안으로, 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/domain/coupon/CouponIssueRequest.java` around lines 44 - 51, The succeed() and fail(String) methods currently overwrite status unconditionally; add state-transition guards in CouponIssueRequest so terminal states cannot be overwritten: in succeed() check current status and only set this.status = Status.SUCCESS (and set reason null if desired) when not already SUCCESS or FAILED, and in fail(String reason) only set this.status = Status.FAILED and this.reason when current status is not SUCCESS or FAILED (or throw/return early); update or add unit tests for succeed() and fail() to verify idempotency and that Status.SUCCESS / Status.FAILED and reason are not changed once terminal.apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java-39-48 (1)
39-48:⚠️ Potential issue | 🟡 Minor
ThreadPoolTaskExecutor설정에 운영 안정성 관련 문제가 있다.
수동
initialize()호출 불필요: Spring Bean으로 등록되면InitializingBean.afterPropertiesSet()이 자동 호출되어 초기화된다. 수동 호출은 중복이며 혼란을 줄 수 있다.RejectedExecutionHandler 미설정: 큐(100)가 가득 차고 max 스레드(4)가 모두 사용 중일 때 기본적으로
AbortPolicy가 적용되어RejectedExecutionException이 발생한다. Outbox 발행 실패 시 재시도 스케줄러가 처리하겠지만, 예외 로깅이 과도해질 수 있다.Graceful shutdown 미설정: 애플리케이션 종료 시 진행 중인 Kafka 발행 작업이 유실될 수 있다.
🛡️ 개선된 Executor 설정
`@Bean`("outboxPublishExecutor") public Executor outboxPublishExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix("outbox-publish-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); - executor.initialize(); return executor; }
CallerRunsPolicy를 사용하면 큐 초과 시 호출 스레드에서 직접 실행하여 백프레셔 효과를 얻을 수 있다. 단, 호출 스레드 블로킹 영향을 고려해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java` around lines 39 - 48, The outboxPublishExecutor bean currently calls ThreadPoolTaskExecutor.initialize() manually, lacks a RejectedExecutionHandler and graceful shutdown settings; remove the manual initialize() call, configure the executor to use a CallerRunsPolicy for rejection handling (to provide backpressure), and enable graceful shutdown by calling setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(...) on the ThreadPoolTaskExecutor in the outboxPublishExecutor method so in-flight Kafka publishes are allowed to complete on application stop.apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java-42-90 (1)
42-90:⚠️ Potential issue | 🟡 Minor시작/종료 시각 경계 테스트가 빠져 있다.
프로모션 발급 로직은 보통 오픈/마감 시각 경계에서만 장애가 재현되므로,
startedAt와 정확히 같은 시각 및endedAt와 정확히 같은 시각에 대한 정책이 테스트에 없으면 배포 후 특정 시점에만 실패가 나도 CI에서 잡지 못한다. 경계값 케이스를 명시적으로 추가하고, 필요하면 현재 시각을 고정할 수 있게 만들어 테스트를 결정적으로 유지하는 편이 안전하다. 추가로startedAt == now,endedAt == now를 각각 분리해 검증하는 테스트를 보강하기 바란다.As per coding guidelines, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java` around lines 42 - 90, Add two boundary tests for startedAt == now and endedAt == now to make issuance checks deterministic: capture ZonedDateTime now into a local variable and create a promotion with CouponPromotion.create(1L, 100, now, now.plusDays(1)) then call promotion.validateIssuable() and assert no exception (method: validateIssuable), and create another promotion with CouponPromotion.create(1L, 100, now.minusDays(1), now) then call validateIssuable() and assert it throws CoreException with ErrorType.BAD_REQUEST; place these near the other tests in the ValidateIssuable nested class and reuse the same now variable so the tests are deterministic.apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java-17-21 (1)
17-21:⚠️ Potential issue | 🟡 Minor비동기 이벤트 리스너의 예외 처리 및 페이로드 로깅 보안 점검 필요
운영 관점 문제점:
@Async리스너에서 예외 발생 시 기본적으로SimpleAsyncUncaughtExceptionHandler가 로깅만 하고 종료된다. 로깅 실패가 비즈니스 로직에 영향을 주지 않아야 하지만, 예외 추적이 어려울 수 있다.outbox.getPayload()를 INFO 레벨로 전체 출력하면 userId 등 사용자 식별자가 로그에 남을 수 있다. 로그 집계 시스템에서 민감 정보 노출 위험이 있다.🛡️ 수정안
`@Async`("outboxPublishExecutor") `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) public void handleOutboxEvent(OutboxEvent event) { - Outbox outbox = event.getOutbox(); - log.info("[UserBehavior] type={}, partitionKey={}, payload={}", - outbox.getEventType(), outbox.getPartitionKey(), outbox.getPayload()); + try { + Outbox outbox = event.getOutbox(); + log.info("[UserBehavior] type={}, partitionKey={}", + outbox.getEventType(), outbox.getPartitionKey()); + log.debug("[UserBehavior] payload={}", outbox.getPayload()); + } catch (Exception e) { + log.warn("[UserBehavior] Failed to log outbox event", e); + } }추가 검토 사항: 로깅 레벨 정책(DEBUG vs INFO)과 민감 정보 마스킹 정책을 팀 내에서 확인하라.
🤖 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/UserBehaviorLoggingListener.java` around lines 17 - 21, The async listener UserBehaviorLoggingListener::handleOutboxEvent must not blindly log full payloads or swallow exceptions: wrap the body in a try-catch that logs unexpected exceptions via log.error including the exception stack (so async uncaught errors are visible) and ensure normal processing is unaffected; change payload logging to either log only non-sensitive metadata at INFO and the full payload at DEBUG, or apply a masking function to Outbox.getPayload() (implement a sanitizer that strips userId/PII) before including it in log.info, referencing handleOutboxEvent, UserBehaviorLoggingListener, and Outbox.getPayload to locate the code to update.apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java-6-9 (1)
6-9:⚠️ Potential issue | 🟡 Minor제네릭 타입 시그니처의 타입 안전성 개선 필요
EventProcessingService.process()메서드에서@SuppressWarnings("unchecked")로 이미 억제되고 있으나, 근본적인 설계 개선이 필요하다.문제점:
supports(Event<EventPayload>)메서드의 느슨한 타입과handle(Event<T>)메서드의 구체적 타입 사이 불일치로 인해 타입 체커 경고가 발생한다. 또한List<EventHandler>선언이 원시 타입(raw type)으로 설정되어 있어 추가 타입 안전성 문제를 야기한다.운영 영향: 현재는 Java 타입 소거(type erasure)로 인해 런타임 ClassCastException 위험이 없지만, 코드 유지보수성과 타입 안전성이 저하되고 미래 리팩토링 시 버그 위험이 높아진다.
개선안:
// 1. EventHandler 인터페이스 개선 public interface EventHandler<T extends EventPayload> { boolean supports(Event<? extends EventPayload> event); void handle(Event<T> event); } // 2. EventProcessingService의 List 타입 명시 private final List<EventHandler<?>> eventHandlers;이렇게 수정하면
@SuppressWarnings("unchecked")를 제거할 수 있다.🤖 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/EventHandler.java` around lines 6 - 9, The EventHandler generic signatures are too loose which causes unchecked casts in EventProcessingService; update the EventHandler interface so supports accepts Event<? extends EventPayload> while keeping handle(Event<T>) as-is, and change the EventProcessingService eventHandlers field to use a parameterized List<EventHandler<?>> instead of a raw List; update any usages in EventProcessingService.process() accordingly so you can remove the `@SuppressWarnings`("unchecked") and restore type safety (refer to the EventHandler interface, supports(...) and handle(...), and the EventProcessingService.eventHandlers and process(...) method).apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java-12-19 (1)
12-19:⚠️ Potential issue | 🟡 Minor매핑 진입점에서 null을 즉시 검증해야 한다.
Line 12-19는
request또는request.getStatus()가 null이면 NPE로 터져 장애 시 원인 추적이 어려워진다. 운영 관점에서 예외 원인을 명확히 남기기 위해 fail-fast 검증을 추가하는 것이 안전하다. 또한 null 입력/상태에 대한 단위 테스트를 추가해 예외 타입과 메시지를 고정하는 것이 좋다.수정안 예시
import com.loopers.domain.coupon.CouponIssueRequest; +import java.util.Objects; @@ public static CouponIssueRequestInfo from(CouponIssueRequest request) { + Objects.requireNonNull(request, "couponIssueRequest must not be null"); + Objects.requireNonNull(request.getStatus(), "couponIssueRequest.status must not be null"); return new CouponIssueRequestInfo( request.getId(), request.getCouponId(), request.getUserId(), request.getStatus().name(), request.getReason() ); }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/application/coupon/CouponIssueRequestInfo.java` around lines 12 - 19, The from(CouponIssueRequest request) mapping should fail-fast on nulls: validate that request is not null and that request.getStatus() is not null at the start of CouponIssueRequestInfo.from(...); if either is null throw a clear, deterministic exception (e.g., IllegalArgumentException or NullPointerException) with a descriptive message indicating which value is null so logs are actionable, then proceed to use request.getStatus().name(); after making this change, add unit tests for null request and null status to assert the exact exception type and message.apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java-35-36 (1)
35-36:⚠️ Potential issue | 🟡 Minor시간 타입이 불일치한다:
expiresAt은LocalDateTime, 나머지 감사 필드는ZonedDateTime이다.운영 관점에서 타임존이 혼재된 경우, 서버 타임존 설정에 따라 만료 시간 비교가 예상과 다르게 동작할 수 있다.
expiresAt도ZonedDateTime으로 통일하거나, 모든 시간 필드를Instant로 표준화하는 것이 타임존 관련 장애를 예방한다.Also applies to: 38-45
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java` around lines 35 - 36, The expiresAt field (private LocalDateTime expiresAt) is inconsistent with the audit fields which use ZonedDateTime; change expiresAt and the other time-related fields referenced around lines 38-45 to use a single standardized timezone-safe type (preferably java.time.Instant) so all timestamps are consistent; update the field types, imports, and any JPA column mappings/serialization logic (e.g., in Coupon class and any getters/setters/constructors) to Instant and ensure nullability annotations (`@Column`(nullable = false)) remain correct.apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java-26-31 (1)
26-31:⚠️ Potential issue | 🟡 Minor
DataIntegrityViolationException처리가 중복 이벤트 외의 경우도 포함한다.
DataIntegrityViolationException은 PK 중복 외에도 FK 제약 조건 위반, 컬럼 길이 초과 등 다양한 원인으로 발생할 수 있다. 현재 구현에서는 모든DataIntegrityViolationException을 "이미 처리된 이벤트"로 간주하여 무시하므로, 실제 데이터 무결성 오류가 발생해도 감지되지 않는다.운영 관점에서 이벤트가 조용히 누락되면 디버깅이 어렵다. 예외 메시지나 SQL 상태 코드를 확인하여 정확히 중복 키 위반인지 검증하거나, 별도의
existsById체크를 선행하는 방안을 검토해야 한다.🔍 중복 키 위반만 처리하는 예시
try { eventHandledRepository.save(new EventHandled(event.getEventId(), LocalDateTime.now())); } catch (DataIntegrityViolationException e) { + // 중복 키 위반인지 확인 (MySQL: 1062, PostgreSQL: 23505) + if (isDuplicateKeyException(e)) { + log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId()); + return; + } + throw e; // 다른 무결성 오류는 재전파 - log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId()); - 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/EventProcessingService.java` around lines 26 - 31, The catch-all DataIntegrityViolationException hides non-duplicate integrity errors; update EventProcessingService to distinguish true duplicate-key cases from other integrity failures before treating an event as already handled: either (a) perform a pre-check using eventHandledRepository.existsById(event.getEventId()) and skip save when true, or (b) if you keep the save+catch pattern around eventHandledRepository.save(new EventHandled(...)), inspect the DataIntegrityViolationException cause (e.g., SQLException/ConstraintViolationException) and verify SQL state/error code or constraint name to confirm a duplicate-key violation before returning; for other cases rethrow or log error details (include exception message/stack) so real integrity problems are not silently ignored.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java-38-41 (1)
38-41:⚠️ Potential issue | 🟡 Minor예외 발생 시 전체 메시지 로깅은 민감 정보 노출 위험이 있다.
message전체를 로그에 출력하면userId,couponId등 사용자 식별 정보가 로그에 남을 수 있다. 운영 환경에서 로그 집계 시스템에 이러한 정보가 저장되면 GDPR/개인정보 보호 정책 위반 가능성이 있다.
eventId와 예외 메시지만 로깅하거나, 메시지 내용을 마스킹하는 방안을 검토해야 한다.🔒 민감 정보 마스킹 예시
} catch (Exception e) { - log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); + log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기", e); ack.nack(Duration.ofSeconds(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/interfaces/consumer/CouponIssueConsumer.java` around lines 38 - 41, The current catch block in CouponIssueConsumer logs the entire "message" which may contain PII; change the log call to avoid full-message dumps by extracting and logging only a non-sensitive identifier (e.g., eventId from the incoming object) and the exception message or a masked version of the payload, and keep ack.nack(Duration.ofSeconds(1)) unchanged; specifically, update the log.error invocation to include CouponIssueConsumer, the eventId (or a safely masked payload) and e.getMessage() (or sanitized details) instead of the raw "message" variable so sensitive fields like userId/couponId are not persisted in logs.apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java-38-41 (1)
38-41:⚠️ Potential issue | 🟡 Minor
decrement()실패 시 카운터 불일치 위험이 있다.운영 관점에서
increment()는 null 체크 후 예외를 던지지만,decrement()는 결과를 무시한다. Redis 연결 실패 또는 명령 실패 시 카운트가 실제 발급 수보다 높게 유지되어, 정상 요청이 거절되는 상황이 발생할 수 있다.수정안:
decrement()결과도 검증하거나, 최소한 로깅을 추가하여 운영 시 모니터링할 수 있도록 해야 한다.🛡️ decrement 결과 검증 추가
`@Override` public void decrement(Long couponId) { - redisTemplate.opsForValue().decrement(key(couponId)); + Long count = redisTemplate.opsForValue().decrement(key(couponId)); + if (count == null) { + log.warn("Redis DECR 실패: couponId={}", couponId); + } }🤖 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/RedisCouponIssueCountManager.java` around lines 38 - 41, decrement(Long couponId) currently ignores the result of redisTemplate.opsForValue().decrement(key(couponId)) which can mask Redis failures; update RedisCouponIssueCountManager.decrement to capture the returned Long, verify it's non-null (and optionally non-negative), and on null or unexpected value either throw a specific runtime exception or at minimum log an error including the couponId, the key(couponId) and the decrement result so operators can detect failures (mirror the null-check behavior used in increment()).apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java-40-44 (1)
40-44:⚠️ Potential issue | 🟡 Minor모든 무결성 예외를 중복 요청으로 바꾸면 실제 장애가 숨는다.
유니크 충돌 외의 FK/nullable/schema 오류까지 모두 409로 치환하면 운영에서 실제 장애를 사용자 중복 요청으로 오인하게 되고, 현재 보상 로직은 Redis 카운트까지 잘못 감소시킬 수 있다. 중복 제약 위반만 선별해
CONFLICT로 변환하고, 나머지는 cause를 보존한 채 그대로 전파하거나 별도 로그로 남기기 바란다. non-uniqueDataIntegrityViolationException이 발생할 때 409로 오인하지 않고 원인 예외가 남는지 검증하는 테스트를 추가해야 한다. As per coding guidelines "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."🤖 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/CouponIssueRequestFacade.java` around lines 40 - 44, The current catch of DataIntegrityViolationException in CouponIssueRequestFacade (around couponIssueRequestRepository.save(...) and couponIssueCountManager.decrement(...)) must be tightened: detect whether the DataIntegrityViolationException is a unique-constraint violation (inspect the root cause / SQLState / constraint name) and only for that case decrement the Redis counter and throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다.") while preserving the cause; for all other DataIntegrityViolationException cases do not treat them as 409—either rethrow the original exception (or wrap it preserving cause) and log an error (separate user-facing message from log message) so the real database issue isn’t hidden. Also add a unit/integration test that simulates a non-unique DataIntegrityViolationException (e.g., FK/null/schema error) and asserts that no CONFLICT is returned and couponIssueCountManager.decrement is not called and that the original cause is preserved.
| public ProducerFactory<String, String> outboxProducerFactory(KafkaProperties kafkaProperties) { | ||
| Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties()); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Spring Kafka 버전 확인
rg -n "spring-kafka" --type=gradle --type=xml -C2 || fd -e gradle -e xml | xargs rg -n "spring-kafka" -C2 2>/dev/null | head -30
# KafkaProperties 사용 패턴 확인
ast-grep --pattern 'buildProducerProperties($$$)'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 1. 프로젝트 빌드 설정 파일 찾기
fd -e gradle -e xml -name "*build*" -o -name "pom.xml" | head -20
# 2. 프로젝트 루트 확인
ls -la | grep -E "build|pom|gradle"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 720
🏁 Script executed:
# 3. OutboxConfig.java 파일 위치 확인
find . -name "OutboxConfig.java" -type f
# 4. 파일 내용 확인 (지정된 위치)
cat -n "apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java" 2>/dev/null || echo "File not found at specified path"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2509
🌐 Web query:
Spring Kafka KafkaProperties buildProducerProperties deprecated
💡 Result:
Yes, the buildProducerProperties method in Spring Boot's KafkaProperties class (org.springframework.boot.autoconfigure.kafka.KafkaProperties) is deprecated since Spring Boot 3.2.0 and marked for removal in 3.4.0. It has been superseded by buildProducerProperties(SslBundles sslBundles) to support SSL bundles for improved SSL configuration. As of Spring Boot 3.4.x and 3.5.x, the parameterless buildProducerProperties remains available but deprecated. In Spring Boot 4.0.x (package moved to org.springframework.boot.kafka.autoconfigure.KafkaProperties), the non-overloaded buildProducerProperties exists without deprecation markings in the API docs, indicating the deprecation was cleaned up post-removal target. If using the method in custom configuration (e.g., for KafkaTemplate or ProducerFactory), inject SslBundles and call kafkaProperties.buildProducerProperties(sslBundles) (pass null if no SSL bundle needed). Spring Boot auto-configuration handles this internally via SslBundleSslEngineFactory. Example migration: @Bean public ProducerFactory<String, Object> producerFactory(KafkaProperties kafkaProperties, @Qualifier("kafkaSslBundles") SslBundles sslBundles) { return new DefaultKafkaProducerFactory<>(kafkaProperties.buildProducerProperties(sslBundles)); } Configure SSL via spring.kafka.producer.ssl.bundle=kafka in properties.
Citations:
- 1: https://docs.spring.vmware.com/spring-boot/docs/3.2.17.1/api/org/springframework/boot/autoconfigure/kafka/KafkaProperties.html
- 2: https://docs.spring.io/spring-boot/docs/3.2.3/api/deprecated-list.html
- 3: https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.html
- 4: https://docs.spring.vmware.com/spring-boot/api/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.html
- 5: https://docs.spring.io/spring-boot/4.0/api/java/org/springframework/boot/kafka/autoconfigure/KafkaProperties.html
🏁 Script executed:
# Spring Boot 버전 확인
cat -n build.gradle.kts | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1713
🏁 Script executed:
# 전체 빌드 파일에서 Spring Boot/Kafka 버전 검색
rg -n "spring" build.gradle.kts | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 974
🏁 Script executed:
# gradle.properties에서 Spring Boot 및 Spring Cloud 버전 확인
cat -n gradle.propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 747
🏁 Script executed:
# settings.gradle.kts에서도 버전 정보 확인
cat -n settings.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1301
buildProducerProperties() 메서드를 신규 시그니처로 업데이트해야 한다.
현재 프로젝트는 Spring Boot 3.4.4를 사용 중이며, buildProducerProperties() 메서드는 Spring Boot 3.2.0부터 deprecated되어 3.4.0에서 제거 대상으로 표시되었다. 프로젝트의 버전이 이미 제거 대상 버전(3.4.0)을 넘어섰으므로 이 deprecated 메서드는 향후 버전에서 동작을 보장할 수 없다.
kafkaProperties.buildProducerProperties(sslBundles) 메서드로 변경하고, SslBundles 의존성을 주입받아야 한다. SSL 번들이 없는 경우 null을 전달할 수 있다.
public ProducerFactory<String, String> outboxProducerFactory(
KafkaProperties kafkaProperties,
`@Qualifier`("kafkaSslBundles") SslBundles sslBundles) {
Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties(sslBundles));
// 나머지 설정...
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java` around
lines 25 - 26, Update the outboxProducerFactory signature to accept an
SslBundles dependency and call the newer
KafkaProperties.buildProducerProperties(SslBundles) overload: add a parameter
like SslBundles (use `@Qualifier`("kafkaSslBundles") if your configuration
provides that bean) to the outboxProducerFactory method and replace
buildProducerProperties() with buildProducerProperties(sslBundles); if no SSL
bundles are configured you may pass null. Ensure references to
outboxProducerFactory, KafkaProperties.buildProducerProperties, and SslBundles
are updated accordingly.
| } catch (Exception e) { | ||
| log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); | ||
| ack.nack(Duration.ofSeconds(1)); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Kafka Consumer 설정 및 DLQ 구성 확인
echo "=== KafkaConfig 확인 ==="
fd "KafkaConfig.java" --exec cat {}
echo ""
echo "=== application.yml에서 Kafka 설정 확인 ==="
fd -e yml -e yaml | xargs rg -l "kafka" | head -3 | xargs catRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 10358
🏁 Script executed:
# CouponIssueConsumer 전체 파일 확인
fd "CouponIssueConsumer.java" --exec cat {}Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1535
🏁 Script executed:
# 코드베이스에서 DLQ, Dead Letter, error handler 관련 설정 확인
rg -i "dead.?letter|dlq|error.*topic" --type java -A 3 -B 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Kafka listener 및 retry 관련 설정 확인
rg "ContainerProperties|ErrorHandler|ListenerContainerFactory" --type java -B 2 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5623
🏁 Script executed:
# Kafka 토픽 설정 및 DLT 토픽 정의 확인
rg -i "topic|coupon.issue" --type java -B 2 -A 2 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5479
🏁 Script executed:
# 다른 consumer 구현 확인 (error handling 비교)
fd -e java -type f -exec grep -l "@KafkaListener" {} \; | head -5 | xargs catRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 255
🏁 Script executed:
# Spring Kafka 에러 처리 관련 설정 (RecoveryCallback, ErrorHandler 등)
rg -i "commonerrorhandler|deadletterpublishing|errorhandler|recoverycallback" --type java -B 3 -A 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 토픽 설정 파일 또는 초기화 코드에서 DLT 토픽 생성 로직 확인
rg -i "createtopic|topicpartition|@topic|\.dlt|dead" --type java -B 3 -A 3 | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# application.yml에서 max-attempts, retry-template 관련 설정 확인
rg -i "max.attempts|retry.topic|dlq|dead.letter" --type yml -B 2 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 112
🏁 Script executed:
# KafkaConfig에서 setCommonErrorHandler 또는 유사 설정 확인
cat modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java | grep -i "error\|handler\|dlt\|retry"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
재시도 메커니즘 강화가 필요하다.
nack(Duration.ofSeconds(1))은 1초 후 재처리를 요청하지만, 현재 코드베이스에는 재시도 횟수 제한과 Dead Letter Topic이 설정되지 않아 처리 불가능한 메시지(예: 역직렬화 성공 후 비즈니스 로직 실패)의 경우 무한 재시도가 발생한다. 이는 브로커 부하 증가, 메시지 적체, 운영 장애로 이어질 수 있다.
다음 조치를 수행하기 바란다:
- KafkaConfig에 CommonErrorHandler 추가: DeadLetterPublishingRecoverer를 이용한 DLT 토픽으로의 자동 전송 구현
- application.yml에 재시도 정책 설정:
spring.kafka.listener.poll-timeout,spring.kafka.retry.topic.enabled,spring.kafka.retry.topic.max.attempts등으로 재시도 제한 설정 - 예외 유형별 처리 전략: 파싱 실패(복구 불가)는 즉시 DLT 전송, 데이터 불일치(일시적 실패)는 재시도 후 DLT 전송으로 구분
- 운영 환경 테스트: 실제 프로덕션 환경에서 메시지 처리 실패 시나리오에 대한 재시도 동작 및 DLT 전달 여부 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`
around lines 38 - 41, The current catch block in CouponIssueConsumer using
ack.nack(Duration.ofSeconds(1)) risks infinite retries; instead add a
CommonErrorHandler in KafkaConfig that uses a DeadLetterPublishingRecoverer to
route failed records to a DLT after a bounded number of attempts, configure
retry properties in application.yml (spring.kafka.listener.poll-timeout,
spring.kafka.retry.topic.enabled, spring.kafka.retry.topic.max.attempts), and
implement exception-specific handling (treat deserialization/parsing exceptions
as non-retriable and immediately send to DLT, while treating transient business
exceptions as retriable up to the configured max attempts) so that ack.nack is
replaced by the broker-managed retry/DLT flow for the CouponIssueConsumer
processing path.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`:
- Around line 26-30: Wrap the Event.fromJson(message) call inside a dedicated
try-catch in CouponIssueConsumer so JSON parsing exceptions don't escape: if
parsing fails catch the exception, compute and log only non-sensitive metadata
(e.g., payload length and a short hash and exception summary) via log.warn (do
NOT log the raw message or sensitive fields), call ack.acknowledge() and return;
keep eventProcessingService.process(event.getPayload()) unchanged so its
exceptions still propagate for retry. Add two tests: (1) a malformed JSON test
asserting eventProcessingService.process is NOT called and ack.acknowledge() is
called once, and (2) a log-capture test asserting the parsing-failure log
contains only length/hash/summary and does NOT contain raw payload fields like
userId or couponId.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3437647f-2f04-40b0-9819-4a2930496858
📒 Files selected for processing (3)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.javamodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
- modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
| Event<EventPayload> event = Event.fromJson(message); | ||
| if (event == null) { | ||
| log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message); | ||
| ack.acknowledge(); | ||
| return; |
There was a problem hiding this comment.
역직렬화 실패 처리 경로가 실제로 동작하지 않고, 원문 메시지 로깅으로 보안/운영 리스크가 발생한다.
Line 26에서 Event.fromJson(message)가 예외를 던지면 Line 27 null 분기와 Line 29 ack.acknowledge()가 실행되지 않아 파싱 불가 메시지가 재시도 경로로 들어가 지연을 유발한다. 또한 Line 28의 message 전체 로깅은 민감정보 노출과 로그 폭증 위험이 있다.
수정안은 파싱 단계만 별도 try-catch로 감싸고, 실패 시에는 원문 대신 길이/해시/예외 요약만 로깅 후 ack 처리하는 방식이 적절하다. 비즈니스 처리(eventProcessingService.process) 예외는 현재처럼 상위로 전파해 재처리 정책을 따르도록 유지하면 된다.
수정 예시 diff
public void consume(String message, Acknowledgment ack) {
- Event<EventPayload> event = Event.fromJson(message);
- if (event == null) {
- log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message);
- ack.acknowledge();
- return;
- }
+ Event<EventPayload> event;
+ try {
+ event = Event.fromJson(message);
+ } catch (RuntimeException e) {
+ log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, messageLength={}, messageHash={}, cause={}",
+ message == null ? 0 : message.length(),
+ message == null ? 0 : message.hashCode(),
+ e.toString());
+ ack.acknowledge();
+ return;
+ }
+ if (event == null) {
+ log.warn("[CouponIssueConsumer] 이벤트 파싱 결과가 null이다");
+ ack.acknowledge();
+ return;
+ }
eventProcessingService.process(event);
ack.acknowledge();
}추가 테스트는 최소 2가지가 필요하다.
- malformed JSON 입력 시
eventProcessingService.process가 호출되지 않고ack.acknowledge()가 1회 호출되는지 검증해야 한다. - 파싱 실패 로그에 원문 payload(예: userId, couponId)가 포함되지 않는지 로그 캡처 기반으로 검증해야 한다.
As per coding guidelines, "로깅 시 민감정보 노출 가능성을 점검한다."
🤖 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 26 - 30, Wrap the Event.fromJson(message) call inside a dedicated
try-catch in CouponIssueConsumer so JSON parsing exceptions don't escape: if
parsing fails catch the exception, compute and log only non-sensitive metadata
(e.g., payload length and a short hash and exception summary) via log.warn (do
NOT log the raw message or sensitive fields), call ack.acknowledge() and return;
keep eventProcessingService.process(event.getPayload()) unchanged so its
exceptions still propagate for retry. Add two tests: (1) a malformed JSON test
asserting eventProcessingService.process is NOT called and ack.acknowledge() is
called once, and (2) a log-capture test asserting the parsing-failure log
contains only length/hash/summary and does NOT contain raw payload fields like
userId or couponId.
📌 Summary
🧭 Context & Decision
① 이벤트 공통 모듈 분리
Producer와 Consumer가 동일한 이벤트 계약을 따라야 하므로
modules/event-contract를 분리했습니다.포함한 요소:
EventPayload마커 인터페이스Event<T>봉투 클래스EventTypeenumTopic상수DataSerializerSnowflake핵심 원칙은 공통 모듈에는 이벤트 계약만 두고, Spring Bean/JPA/Kafka 설정은 넣지 않는 것입니다.
② Transactional Outbox Pattern으로 Producer 신뢰성 확보
좋아요, 결제 완료, 쿠폰 발급 요청처럼 유실되면 안 되는 이벤트는 DB 트랜잭션과 함께 저장한 뒤 Kafka로 릴레이하도록 설계했습니다.
Kafka send 실패 시 Outbox 레코드가 남아있어 polling에서 재시도됩니다. 이로써 At Least Once 발행을 보장합니다.
③ Consumer 멱등성 전략 — 증감 + event_handled
좋아요/조회수/판매량 집계는 덮어쓰기가 아닌 증감 방식(+1/-1)을 선택했습니다.
이벤트는 "발생한 사실"을 표현하는 것이 자연스럽고, Producer에 집계 책임을 부여하지 않기 위해 증감 방식을 선택했습니다. 같은 상품의 이벤트 순서를 보장하기 위해
productId를 partition key로 사용합니다.handler + event_handled INSERT를 반드시 하나의 트랜잭션으로 묶어야 합니다. handle 성공 후 event_handled INSERT 전에 장애가 나면 Kafka 재전달 시 중복 증감이 발생합니다.
④ version/updated_at을 적용하지 않은 이유
과제 체크리스트에
version/updated_at 기준 최신 이벤트만 반영이 있지만, 현재 다루는 이벤트(PRODUCT_LIKED, PRODUCT_UNLIKED, PRODUCT_VIEWED, PAYMENT_COMPLETED)는 모두 카운터성 fact event입니다.증감 방식에서는 모든 이벤트를 빠짐없이 적용해야 하므로, 하나라도 스킵하면 카운트가 틀어집니다. 따라서 version 기반 필터링 대신 event_handled로 중복만 방지합니다.
⑤ 상품 조회 이벤트 — Outbox 없이 Kafka 직접 발행
ProductFacade.getActiveProduct()에는@Cacheable이 걸려 있어 캐시 히트 시 메서드 자체가 실행되지 않습니다. 이벤트 발행을 캐시 바깥으로 꺼내면, 매 조회마다 Outbox INSERT를 위한 write 트랜잭션이 필요합니다.조회수는 유실되어도 서비스에 치명적이지 않으므로, Outbox 없이
KafkaTemplate.send()로 fire-and-forget 발행합니다.⑥ 좋아요순 정렬 — ProductMetricsReadModel 도입
Product.likeCount를 제거하고product_metrics테이블로 집계를 분리하면서, QueryDSL만으로는 LEFT JOIN 대상이 없어졌습니다.commerce-api에 읽기 전용 엔티티
ProductMetricsReadModel(@Entity+@Immutable)을 두고, QueryDSL에서 Product와 LEFT JOIN하여 모든 정렬 조건을 통합했습니다.기존
@Cacheable(10분 TTL)은 그대로 유지합니다. Consumer가 product_metrics를 갱신하면, 캐시 TTL 만료 시 다음 조회에서 자연스럽게 반영됩니다.⑦ 선착순 쿠폰 발급 — Redis + Kafka 조합
기존 동기 발급(
/issue)은 유지하고, 선착순 쿠폰은 별도 API(/issue-request) + 비동기 흐름으로 분리했습니다.CouponPromotion 테이블 분리: 발급 수량(
maxQuantity)은 쿠폰 자체의 속성이 아닌 프로모션 정책이므로 별도 테이블로 관리합니다.Redis + Kafka 역할 분담:
결과 조회:
GET /api/v1/coupons/issue-requests/{requestId}→ PENDING / SUCCESS / FAILED polling🔁 Flow Diagram
이벤트 파이프라인 (좋아요 예시)
sequenceDiagram participant C as Client participant API as LikeFacade participant DB as Database participant K as Kafka participant S as commerce-streamer C->>API: POST /api/v1/likes API->>DB: Like INSERT + Outbox INSERT (같은 TX) API-->>C: 200 OK Note over API,DB: AFTER_COMMIT + @Async API->>K: Kafka send (PRODUCT_LIKED) API->>DB: Outbox DELETE K->>S: consume (catalog-events) S->>DB: event_handled 확인 S->>DB: product_metrics.like_count + 1 S->>DB: event_handled INSERT S->>K: ack선착순 쿠폰 발급
sequenceDiagram participant C as Client participant API as CouponIssueRequestFacade participant R as Redis participant DB as Database participant K as Kafka participant S as CouponIssueProcessor C->>API: POST /coupons/{id}/issue-request API->>R: INCR coupon-promotion:issued-count:{id} alt 수량 초과 API->>R: DECR (되돌림) API-->>C: 400 Bad Request else 통과 API->>DB: CouponIssueRequest(PENDING) + Outbox INSERT API-->>C: 202 Accepted (requestId) end Note over API,DB: AFTER_COMMIT API->>K: COUPON_ISSUE_REQUESTED K->>S: consume S->>DB: 중복 발급 확인 S->>DB: IssuedCoupon INSERT S->>DB: CouponIssueRequest → SUCCESS S->>K: ack C->>API: GET /issue-requests/{requestId} API-->>C: {status: SUCCESS}🏗 토픽 설계
🗄 신규 테이블
✅ 변경 범위
공유 모듈 (신규)
modules/event-contract— Event, EventType, EventPayload, DataSerializer, Snowflake, Topic, Payload DTOcommerce-api (Producer)
Outbox,OutboxRepository,OutboxEvent,OutboxEventPublisher,MessageRelay— Outbox 패턴LikeFacade—Product.likeCount증감 제거 → PRODUCT_LIKED/UNLIKED 이벤트 발행Product—likeCount컬럼 제거ProductFacade— PRODUCT_VIEWED 이벤트 발행 (fire-and-forget)PaymentFacade— PAYMENT_COMPLETED 이벤트 발행ProductRepositoryImpl— LEFT JOIN ProductMetricsReadModel로 좋아요순 정렬 통합CouponIssueRequestFacade— 선착순 쿠폰 발급 요청 (Redis INCR + Outbox)RedisCouponIssueCountManager— Redis 기반 발급 카운트 관리CouponPromotion— 선착순 프로모션 정책 엔티티UserBehaviorLoggingListener— 유저 행동 로깅 (@TransactionalEventListener)acks=all,enable.idempotence=truecommerce-streamer (Consumer)
MetricsEventConsumer— catalog-events, order-events 소비 + manual ackCouponIssueConsumer— coupon-issue-requests 소비 + manual ackEventProcessingService— event_handled 기반 멱등 처리ProductLiked/Unliked/Viewed/PaymentCompleted EventHandler— product_metrics 집계CouponIssueProcessor— 선착순 쿠폰 실제 발급 처리CouponIssueRequestedEventHandler— 쿠폰 발급 이벤트 → Processor 위임테스트
CouponIssueRequestFacadeTest— 발급 요청 단위 테스트 (Fake 사용)CouponIssueProcessorTest— Consumer 발급 처리 통합 테스트 (@SpringBootTest + EmbeddedKafka)CouponIssueRequestTest,CouponPromotionTest— 도메인 객체 단위 테스트ConcurrencyE2ETest— 선착순 쿠폰 동시성 테스트 (200명 동시 요청 → 100장 한정 → 정확히 100건만 ACCEPTED 검증)변경 목적
commerce-api를 Producer, commerce-streamer를 Consumer로 분리해 이벤트 기반 아키텍처로 전환하고 Transactional Outbox + Kafka로 신뢰성 있는 시스템 간 이벤트 파이프라인과 Redis 기반 선착순 쿠폰 컷오프를 구현.
핵심 변경점
공통 모듈(modules/event-contract)로 이벤트 계약 정의(Event, EventType, Topic, Payload들, Snowflake, DataSerializer). Producer 쪽은 Outbox 엔터티·OutboxEventPublisher·MessageRelay(트랜잭션 AFTER_COMMIT 발행 및 스케줄 재시도) 추가, 조회 이벤트는 직접 Kafka 전송. Consumer 쪽은 MetricsEventConsumer/CouponIssueConsumer → EventProcessingService(멱등성 보장 via event_handled) → 핸들러들로 product_metrics 집계 및 CouponIssueProcessor로 발급 처리. Product.likeCount 제거, product_metrics 읽기 모델 도입. RedisCouponIssueCountManager로 선착순 컷오프(INS/DECR 롤백) 및 CouponIssueRequest 상태 관리(PENDING→SUCCESS/FAILED). Kafka 리스너에 DefaultErrorHandler 적용.
리스크/주의사항
Outbox 삭제 시점과 재시도 경쟁 조건, 이벤트 역직렬화에서 payloadClass 미해결 시 예외 가능성, 비동기 집계로 인한 읽기 지연, Redis INCR/DECR 기반 카운터의 분산 일관성·롤백 경로(데이터 손실/중복처리 가능성)를 주의. handler와 event_handled INSERT를 단일 트랜잭션으로 유지해야 순서/멱등성 보장. (구현상 트랜잭션 범위나 Kafka 장애/리트라이 정책에 대해 추가 확인이 필요합니까?)
테스트/검증 방법
단위 테스트: CouponIssueRequestFacadeTest, EventProcessingServiceTest; 통합/연동: CouponIssueProcessorTest(embedded Kafka); E2E 동시성: ConcurrencyE2ETest(동시 요청 수백 건에서 제한 수량만 202 응답 검증, 동일 쿠폰 주문 동시성 검증). 추가로 Outbox 재시도/삭제, Kafka 장애 시 동작, Redis 카운터 경합 케이스에 대한 장애 주입 테스트 권장.