Conversation
📝 WalkthroughWalkthroughKafka 모듈을 도입하고 이벤트 주도형 아키텍처를 구현하였다. Outbox 패턴으로 비동기 이벤트 퍼블리시를 보장하며, 첫 구매 쿠폰(FCFS) 기능을 Redis 기반으로 추가하고, 결제 상태를 타입 안전한 Enum으로 전환하였다. Commerce-streamer 애플리케이션에 Kafka 컨슈머를 추가하여 이벤트를 처리하도록 구성하였다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Controller
participant Facade
participant Domain Service
participant ApplicationEventPublisher
participant EventListener
participant OutboxRepository
participant KafkaTemplate
participant OutboxRelay
participant Kafka Topic
Client->>Controller: Request (e.g., create order/favorite)
Controller->>Facade: Delegate to facade
Facade->>Domain Service: Execute business logic
Domain Service->>Domain Service: Create domain entity
Facade->>ApplicationEventPublisher: Publish domain event
ApplicationEventPublisher->>EventListener: Fire transactional event
EventListener->>Domain Service: Update side effects (e.g., like count)
EventListener->>OutboxRepository: Persist OutboxEvent(INIT)
EventListener->>EventListener: Return (transaction commits)
OutboxRelay->>OutboxRepository: Poll events with status INIT
OutboxRepository-->>OutboxRelay: List<OutboxEvent>
loop For each OutboxEvent
OutboxRelay->>KafkaTemplate: send(topic, key, payload)
KafkaTemplate->>Kafka Topic: Publish to Kafka
OutboxRelay->>OutboxRepository: Update status to PUBLISHED
end
Kafka Topic->>Streamer Batch Consumer: Receive message batch
Streamer Batch Consumer->>Streamer Domain: Process each event (with version check)
Streamer Domain->>Streamer Repository: Persist metrics/entities
Streamer Batch Consumer->>EventHandledRepository: Record completion (SUCCESS/SKIPPED)
sequenceDiagram
participant Client
participant CouponController
participant CouponFacade
participant FirstComeCouponService
participant Redis as Redis(ZSET)
participant Database as DB
participant CouponIssueRequestRepository
participant KafkaTemplate
participant Kafka Topic
Client->>CouponController: POST /first-come-issue
CouponController->>CouponFacade: requestFirstComeIssue(memberId, templateId)
CouponFacade->>FirstComeCouponService: addToQueue(coupon, memberId)
FirstComeCouponService->>FirstComeCouponService: Validate active status
FirstComeCouponService->>Redis: Execute Lua script(ZADD with dedup & maxQty check)
alt Redis Success
Redis-->>FirstComeCouponService: Success (queue added)
else Redis Failure
FirstComeCouponService->>FirstComeCouponService: fallbackAddToQueue()
FirstComeCouponService->>Database: Check existing issuance request
FirstComeCouponService->>Database: Count issued coupons
FirstComeCouponService->>FirstComeCouponService: Validate capacity
end
CouponFacade->>CouponIssueRequestRepository: save(CouponIssueRequest)
CouponFacade->>KafkaTemplate: send(coupon-issue-requests, requestId, payload)
KafkaTemplate->>Kafka Topic: Publish request event
CouponController-->>Client: 202 ACCEPTED + requestId
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 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: 11
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)
88-102:⚠️ Potential issue | 🟠 Major상태를 갱신한 직후에도 오래된
payment로 응답할 수 있다.콜백 처리 전 Line 88에서 읽은
payment를 그대로 DTO로 변환하고 있다.handleCallback()이 DB 상태를 바꿔도 현재 객체는 갱신되지 않으므로, 조회 API가 방금 완료된 결제를REQUESTED로 돌려주는 문제가 생길 수 있다. 콜백 처리 후 결제를 다시 조회해 응답을 만들고,checkPaymentStatus()한 번 호출만으로 최신 상태가 반환되는 통합 테스트를 추가하는 편이 안전하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java` around lines 88 - 102, The current flow in PaymentFacade uses the locally fetched Payment (from paymentService.getPaymentByOrderId) to build the response but may call handleCallback(payment.getTransactionKey(), ...) which mutates DB state; re-fetch the payment after handleCallback completes and before calling FindPaymentResDto.from so the DTO reflects updated DB state. Concretely: after invoking handleCallback(...) (inside the PaymentStatus.REQUESTED branch), call paymentService.getPaymentByOrderId(orderId) again to replace the local payment variable, then return FindPaymentResDto.from(updatedPayment); also add an integration test that triggers the callback path and asserts a single checkPaymentStatus() (or the API call) returns the updated status.
🟠 Major comments (27)
apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java-86-91 (1)
86-91:⚠️ Potential issue | 🟠 Major복원 경로의 동시성 충돌 예외가 미처리되어 500으로 번질 수 있다.
운영 관점에서 보상/재처리 시 같은 쿠폰을 동시에 갱신하면 낙관락 예외가 발생할 수 있는데, 현재는 이를 도메인 예외로 전환하지 않아 장애 전파와 재시도 불안정을 유발한다.
수정안으로ObjectOptimisticLockingFailureException을 명시적으로 처리하고, 재조회 결과가 이미 복원 상태면 성공으로 간주하는 멱등 분기를 추가하는 것이 안전하다.추가 테스트로 동일
userCouponId에 대해 동시restoreUserCoupon호출 시 하나만 실제 갱신되고 나머지는 도메인 규약에 맞게 성공 처리 또는CONFLICT로 일관되게 응답하는지 검증해야 한다.
As per coding guidelines**/*Service*.java: "멱등성과 중복 처리 방지 전략을 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java` around lines 86 - 91, In restoreUserCoupon, catch ObjectOptimisticLockingFailureException around the userCouponRepository.update(userCoupon) call; on catch, re-query the userCoupon via userCouponRepository.findById(userCouponId) and if the reloaded entity is present and already restored (e.g., userCoupon.isRestored()), treat the operation as successful and return, otherwise convert the exception into a domain CoreException with ErrorType.CONFLICT (or a clear conflict message) to indicate the optimistic lock failure; keep the initial not-found behavior and ensure these changes are added inside the restoreUserCoupon method where userCouponRepository.findById and userCouponRepository.update are used.apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java-47-53 (1)
47-53:⚠️ Potential issue | 🟠 Major복원 로직이 비멱등이라 재처리 시 보상 흐름이 실패할 수 있다.
운영 관점에서 이벤트 중복 전달/재시도 상황에서 이미
AVAILABLE인 쿠폰에 예외를 던지면 보상 트랜잭션이 불필요하게 실패하고 재시도 폭주로 이어질 수 있다.
수정안으로AVAILABLE상태는 no-op으로 처리해 복원을 멱등하게 만들고,USED가 아닌 다른 상태만 예외 처리하는 방식이 안전하다.수정 예시
public void restore() { - if (this.status != CouponEnums.Status.USED) { + if (this.status == CouponEnums.Status.AVAILABLE) { + return; + } + if (this.status != CouponEnums.Status.USED) { throw new CoreException(ErrorType.BAD_REQUEST, "사용된 쿠폰만 복원할 수 있습니다."); } this.status = CouponEnums.Status.AVAILABLE; this.usedAt = null; }추가 테스트로
restore()를 연속 2회 호출해도 예외 없이AVAILABLE/usedAt=null상태가 유지되는지 검증해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java` around lines 47 - 53, The restore() method currently throws a CoreException unless status == CouponEnums.Status.USED, which breaks idempotency on duplicate events; change restore() so that if status == CouponEnums.Status.AVAILABLE it becomes a no-op (leave status and usedAt as-is), only throw for statuses that are neither USED nor AVAILABLE, and when transitioning from USED set status to CouponEnums.Status.AVAILABLE and usedAt = null; update tests to call restore() twice and assert final state is AVAILABLE and usedAt == null.apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java-3-4 (1)
3-4:⚠️ Potential issue | 🟠 Major실패 사유를 자유 문자열로 노출하면 운영 로그/이벤트 계약이 불안정해진다.
운영 관점에서 PG 원문 메시지를
reason으로 흘리면 민감정보 유출 위험과 고카디널리티 로그/메시지 폭증이 발생할 수 있다.
수정안으로reason은 외부 전파용 에러 코드(예: enum 또는 고정 코드 문자열)로 제한하고, 상세 원문은 내부 보안 로그에만 분리 저장하는 방식이 안전하다.수정 예시
-public record PaymentFailedEvent(String orderId, Long paymentId, String reason) { +public record PaymentFailedEvent(String orderId, Long paymentId, String reasonCode) { }추가 테스트로 직렬화/역직렬화 계약 테스트에서 허용 코드만 전파되는지, PG 상세 메시지가 이벤트 페이로드에 포함되지 않는지를 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java` around lines 3 - 4, The PaymentFailedEvent currently exposes a free-text reason (field reason) which can leak sensitive PG messages and create high-cardinality events; change the event contract so reason becomes a constrained error code (e.g., an enum PaymentFailureCode or pre-defined code string) and move any raw PG/detail text into a separate internal-only field (e.g., internalDetail) that is not serialized for external consumers; update serialization annotations/record definition for PaymentFailedEvent and any constructors/factory methods that create it (refer to PaymentFailedEvent, orderId, paymentId, reason) and add unit tests that assert only allowed codes are present after serialize/deserialize and that raw PG messages do not appear in the event payload.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java-28-30 (1)
28-30:⚠️ Potential issue | 🟠 Major상태값을 문자열로 저장하면 운영 중 데이터 무결성이 손상될 수 있다.
UserCouponStreamerEntity와CouponIssueRequestStreamerEntity모두 상태를 raw String으로 저장하고 있다. 데이터베이스 단계에서 문자열 유효성을 검증할 수 없어, 오타나 등록되지 않은 값이 유입되면 Consumer 재처리 실패와 집계 데이터 왜곡을 초래할 수 있다.현재 아키텍처에서는 두 모듈이 격리되어 있으므로, enum을 공유 모듈(
modules:*)로 이동하거나 streamer 내부에 enum을 정의한 후@Enumerated(EnumType.STRING)을 적용해야 한다.수정 예시 (streamer 내부 enum 정의 방식)
+public enum CouponStatus { + AVAILABLE, USED, EXPIRED +} + +public enum CouponIssueStatus { + ISSUED, REJECTED +} // UserCouponStreamerEntity -private String status; +@Enumerated(EnumType.STRING) +@Column(nullable = false) +private CouponStatus status; -private static final String STATUS_AVAILABLE = "AVAILABLE"; +private static final CouponStatus STATUS_AVAILABLE = CouponStatus.AVAILABLE; // CouponIssueRequestStreamerEntity -private String status; +@Enumerated(EnumType.STRING) +@Column(nullable = false) +private CouponIssueStatus status; -private static final String STATUS_ISSUED = "ISSUED"; -private static final String STATUS_REJECTED = "REJECTED"; +private static final CouponIssueStatus STATUS_ISSUED = CouponIssueStatus.ISSUED; +private static final CouponIssueStatus STATUS_REJECTED = CouponIssueStatus.REJECTED;추가 테스트로 엔티티 저장/조회 라운드트립에서 enum 값이 안정적으로 유지되는지, 비정상 상태 문자열이 유입될 때 매핑 단계에서 차단되는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java` around lines 28 - 30, UserCouponStreamerEntity and CouponIssueRequestStreamerEntity currently store status as raw String; change to a typed enum to enforce DB-level validity: create a Status enum (either moved to shared modules:* or defined inside the streamer package) and replace the private String status fields with the enum type, annotate the field with `@Enumerated`(EnumType.STRING) in both classes (UserCouponStreamerEntity, CouponIssueRequestStreamerEntity), update any setters/getters/usages accordingly, and add round-trip persistence tests that verify correct enum storage/retrieval and that invalid/unknown status strings fail mapping at persist/load time.apps/commerce-api/src/main/resources/application.yml-81-82 (1)
81-82:⚠️ Potential issue | 🟠 Major
allow-bean-definition-overriding범위를test로 제한하는 것이 안전하다.운영 관점에서 local까지 override를 허용하면 실제 충돌이 개발 단계에서 은닉되어 배포 직전 장애로 전이될 수 있다.
test전용 프로파일로 분리하거나 override 없이 충돌 빈을@Primary/@Qualifier로 해소하는 쪽이 안전하다. 추가 테스트로 local 프로파일contextLoads를 override=false 조건에서 수행해 충돌 조기 검출을 권장한다.수정안 예시
--- spring: config: activate: - on-profile: local, test + on-profile: test main: allow-bean-definition-overriding: trueAs per coding guidelines
**/application*.yml: 운영에 영향을 주는 설정 변경은 근거와 영향 범위를 요구한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/resources/application.yml` around lines 81 - 82, 현재 application.yml의 main.allow-bean-definition-overriding: true 설정은 운영까지 빈 오버라이드를 허용하므로 main 프로파일이 아닌 test 전용 프로파일로 옮기세요; 즉 main.allow-bean-definition-overriding을 제거하고 동일 설정을 application-test.yml 또는 test 프로파일 블록으로 이동해 오직 테스트 실행 시에만 true 되도록 변경하고, 운영/로컬 환경은 기본값(false)으로 유지하도록 조정하며 충돌이 예상되는 빈들은 `@Primary` 또는 `@Qualifier로` 명시하여 해결하고, 로컬 프로파일에 대해 override=false 조건으로 contextLoads 통합테스트를 추가해 충돌이 조기 검출되도록 만드세요 (참조 필드: allow-bean-definition-overriding, main, application.yml, application-test.yml, `@Primary`, `@Qualifier`, contextLoads).apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java-22-22 (1)
22-22:⚠️ Potential issue | 🟠 Major만료 주문 조회는 배치 단위로 제한해야 한다.
운영 관점에서 무제한
List조회는 주문 적체 시 메모리 급증과 스케줄 지연을 유발한다. 메서드 시그니처를 배치/페이지 기반으로 바꾸고, 구현 쿼리에(status, created_at)인덱스 활용을 전제로 설계하는 것이 안전하다. 추가 테스트로 대량 데이터에서 배치 반복 처리 시 누락/중복 없이 종료되는지 검증해야 한다.수정안 예시
- List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore); + List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore, int limit);As per coding guidelines
**/*Repository*.java: 대량 데이터 병목, 조회 범위, 정렬/인덱스 활용 가능성을 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java` at line 22, Change the unbounded List-returning method findExpiredOrders in OrderRepository to a batch/page-based API (e.g., accept a Pageable or (int limit, long lastId/LocalDateTime lastCreatedAt) cursor) so callers can process expired orders in fixed-size batches; update the repository signature from List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore) to a paged/cursor variant and ensure queries/orderings use (status, created_at) to leverage the index (sort by createdAt, include id for stable ordering), then add integration tests that iterate batches over large data to verify no duplicates or gaps when finishing.apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java-10-10 (1)
10-10:⚠️ Potential issue | 🟠 Major다중 인스턴스 환경에서 스케줄러의 동시 실행으로 인한 중복 처리 방어가 필요하다.
운영 환경에서 애플리케이션 인스턴스가 2대 이상일 때,
OutboxRelay와OrderExpireScheduler가 각 인스턴스에서 독립적으로 실행되어 다음과 같은 문제가 발생한다:
OutboxRelay: 동일 이벤트를 여러 번 Kafka로 발행OrderExpireScheduler: 동일 주문을 여러 번 취소 처리 및 이벤트 발행이를 방지하기 위해 다음 중 하나를 적용해야 한다:
- ShedLock 도입 (
@SchedulerLock적용)- DB 클레임 전략 (
SELECT ... FOR UPDATE SKIP LOCKED또는 상태 플래그 활용)이후 다중 인스턴스 환경에서 실제 단일 건만 처리되는지 확인하는 통합 테스트를 작성한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java` at line 10, The app currently enables scheduling globally via `@EnableScheduling` but lacks multi-instance protection, causing OutboxRelay and OrderExpireScheduler to run concurrently across instances; add a distributed locking strategy (e.g., integrate ShedLock and annotate the scheduled methods in OutboxRelay and OrderExpireScheduler with `@SchedulerLock`) or implement a DB claim pattern inside those classes (use SELECT ... FOR UPDATE SKIP LOCKED or a status flag transition to atomically claim work before processing), and then add an integration test that runs multiple application instances to assert only one instance processes each outbox/event or order expiration.apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java-42-43 (1)
42-43:⚠️ Potential issue | 🟠 Major
deleteFavorite는 삭제 성공 여부와 무관하게 이벤트를 발행한다.
addFavorite는if (added)조건으로 실제 추가된 경우에만 이벤트를 발행하지만,deleteFavorite는 삭제 결과를 확인하지 않고 무조건 이벤트를 발행한다. 이미 삭제된 즐겨찾기를 다시 삭제 요청하면 DB에는 변경이 없으나 이벤트가 발행되어 좋아요 카운트가 음수로 내려갈 수 있다.운영 관점: 중복 삭제 요청 시
likeCount정합성이 깨질 위험이 있다.수정안
`@Transactional`(rollbackFor = {Exception.class}) public void deleteFavorite(String loginId, String password, Long productId) { Member member = memberService.findMember(loginId, password); Product product = productService.findProduct(productId); FavoriteCommand.Delete command = new FavoriteCommand.Delete(member.getId(), product.getId()); - favoriteService.delete(command); - eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId())); + boolean deleted = favoriteService.delete(command); + if (deleted) { + eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId())); + } }추가 테스트: 존재하지 않는 즐겨찾기 삭제 시 이벤트가 발행되지 않음을 검증하는 테스트 케이스를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java` around lines 42 - 43, The deleteFavorite flow currently publishes FavoriteRemovedEvent unconditionally; change the behavior so that favoriteService.delete(command) returns a boolean (or provide a favoriteService.exists/deleteIfExists API) indicating whether the entity was actually removed, then only call eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId())) when that boolean is true; update the deleteFavorite method to use the returned flag (e.g., boolean deleted = favoriteService.delete(command); if (deleted) publish event) and add a unit/integration test verifying that deleting a non-existent favorite does not publish FavoriteRemovedEvent.apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java-39-41 (1)
39-41:⚠️ Potential issue | 🟠 Major외부 PG 콜백에서 잘못된 상태값 수신 시 예외 처리가 누락되어 있다.
PgPaymentStatus.valueOf(request.status())는 유효하지 않은 상태값에 대해IllegalArgumentException을 던진다. 콜백 엔드포인트는 외부 PG사가 호출하므로 예기치 않은 값이 올 수 있으며, 이 경우 500 에러가 반환된다.운영 관점: PG사 연동 스펙 변경 시 예고 없이 서비스 장애가 발생할 수 있다. 또한 500 에러는 PG사 측에서 재시도를 유발하여 불필요한 트래픽이 증가한다.
수정안
`@PostMapping`("/callback") public ApiResponse<Void> handleCallback(`@RequestBody` PaymentCallbackApiReqDto request) { + PgPaymentStatus status; + try { + status = PgPaymentStatus.valueOf(request.status()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "Unknown payment status: " + request.status()); + } - paymentFacade.handleCallback(request.transactionKey(), PgPaymentStatus.valueOf(request.status())); + paymentFacade.handleCallback(request.transactionKey(), status); return ApiResponse.successNoContent(); }추가 테스트: 유효하지 않은 상태값으로 콜백 호출 시 400 에러가 반환되는지 검증하는 통합 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java` around lines 39 - 41, The callback handler in PaymentV1Controller currently calls PgPaymentStatus.valueOf(request.status()) which will throw IllegalArgumentException for invalid values; wrap that conversion in a try/catch (or pre-validate the enum) inside handleCallback, catch IllegalArgumentException, and return a 400 Bad Request ApiResponse (or throw a framework BadRequest/ApiException) with a descriptive message instead of letting it propagate to a 500; update PaymentV1Controller.handleCallback to perform this validation around PgPaymentStatus.valueOf and add an integration test that posts an invalid status to the callback endpoint and asserts a 400 response.apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java-17-17 (1)
17-17:⚠️ Potential issue | 🟠 Major페이지네이션 미적용 시 대량 데이터 조회 및 트랜잭션 장애 위험이 있다.
findByStatusAndCreatedAtBefore메서드는 스케줄러(OrderExpireScheduler)가 60초마다 호출하며, 쿼리 결과에 상한선이 없다. 미결제 주문이 대량으로 누적될 경우 메모리 부족 또는 장기 트랜잭션에 의한 락 대기 증가로 인한 장애가 발생할 수 있다.문제점:
- 모든 만료 대상 주문을 메모리에 한 번에 로드하여 처리 (
stream().toList())- 스케줄러가 단일 트랜잭션 내에서 모든 주문의 상태를 순차적으로 업데이트
(status, created_at)복합 인덱스가 없어 쿼리 최적화 불가수정안:
@Query및LIMIT절을 사용하여 배치 크기를 명시하거나Pageable을 파라미터로 추가하여 페이지 단위 처리로 변경한다.OrderEntity의@Table애너테이션에@Index(name = "idx_status_created_at", columnList = "status, created_at")추가한다.- 각 배치마다 별도의 트랜잭션으로 분리하여 락 점유 시간을 최소화한다.
추가 테스트:
- 만료 대상 주문 10,000건 이상의 시나리오에서 힙 메모리 사용량, 쿼리 실행 시간, 데이터베이스 락 경합을 측정한다.
- 스케줄러가 배치 크기 제한 하에서도 정상 동작하는지 확인한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java` at line 17, findByStatusAndCreatedAtBefore currently returns all matching rows causing potential OOM and long transactions; change the repository method (findByStatusAndCreatedAtBefore) to support paging (add Pageable parameter or replace with an `@Query` that enforces a LIMIT) and consume results in fixed-size batches instead of stream().toList(), add a composite DB index on OrderEntity (add `@Index`(name="idx_status_created_at", columnList="status, created_at") to the `@Table` declaration) and update OrderExpireScheduler to iterate pages/batches and process each batch in its own transaction to minimize lock time.apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java-49-53 (1)
49-53:⚠️ Potential issue | 🟠 Major만료 주문 조회가 무제한 적재여서 장애 복구 시 병목/메모리 리스크가 크다.
운영 관점에서 만료 주문이 누적된 시점에 전체를 한 번에 읽으면 스케줄러 지연, GC 압박, 후속 처리 타임아웃으로 이어질 수 있다. 배치 크기(예: 200~500) 단위로 페이지 조회 후 반복 처리하도록 바꿔야 한다. 추가 테스트로 대량 더미(예: 5만 건)에서 배치 단위로 분할 조회/처리되는지와 1회 처리 시간 상한을 검증해야 한다.
📦 제안 수정안
+import org.springframework.data.domain.PageRequest; ... `@Override` public List<Orders> findExpiredOrders(OrderStatus status, LocalDateTime expireBefore) { ZonedDateTime zonedExpire = expireBefore.atZone(ZoneId.systemDefault()); - return orderJpaRepository.findByStatusAndCreatedAtBefore(status, zonedExpire).stream() + return orderJpaRepository.findByStatusAndCreatedAtBefore( + status, + zonedExpire, + PageRequest.of(0, 500) + ).stream() .map(OrderEntity::toModel) .toList(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java` around lines 49 - 53, findExpiredOrders currently loads all matching orders at once causing OOM/GC and scheduler delays; change OrderRepositoryImpl.findExpiredOrders to page through results using orderJpaRepository.findByStatusAndCreatedAtBefore with a Pageable (e.g., PageRequest.of(page, batchSize)) and loop fetching pages until empty, converting each page's OrderEntity via OrderEntity::toModel and appending to a result list (batchSize configurable, e.g., 200–500). Ensure you do not hold unnecessary references between batches, and add tests that run against large dummy data (~50k) to verify batching behavior and that each page process time meets the 1-run time bound. Include method/class references: findExpiredOrders, OrderRepositoryImpl, orderJpaRepository.findByStatusAndCreatedAtBefore, OrderEntity::toModel.apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java-19-27 (1)
19-27:⚠️ Potential issue | 🟠 Major
couponTemplateId에 유니크 제약이 없어 단건 조회 계약이 깨질 수 있다.운영 중 중복 데이터가 한 번이라도 들어가면 FCFS 조회 경로가 런타임 예외로 실패해 발급 처리 장애로 이어질 수 있다.
couponTemplateId에 유니크 제약(및 인덱스)을 명시해 데이터 무결성과 조회 성능을 함께 보장해야 한다. 추가 테스트로 동일couponTemplateId2건 저장 시DataIntegrityViolationException이 발생하는 통합 테스트를 넣어야 한다.🧱 제안 수정안
+import jakarta.persistence.Index; +import jakarta.persistence.UniqueConstraint; ... -@Table(name = "first_come_coupon") +@Table( + name = "first_come_coupon", + uniqueConstraints = `@UniqueConstraint`(name = "uk_first_come_coupon_template_id", columnNames = "couponTemplateId"), + indexes = `@Index`(name = "idx_first_come_coupon_template_id", columnList = "couponTemplateId") +) public class FirstComeCouponEntity { ... - `@Column`(nullable = false) + `@Column`(nullable = false) private Long couponTemplateId;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java` around lines 19 - 27, FirstComeCouponEntity의 couponTemplateId에 유니크 제약과 인덱스가 없어 중복 입력 시 단건 조회가 깨질 수 있으니, FirstComeCouponEntity 클래스에 couponTemplateId 컬럼에 대해 고유 제약을 추가(예: `@Column`(unique = true) 또는 `@Table의` uniqueConstraints로 정의)하고 인덱스도 명시해 조회 성능과 무결성을 보장하라; 또한 동일 couponTemplateId를 2건 저장하면 DataIntegrityViolationException이 발생하는 통합 테스트(예: FirstComeCouponEntity 통합 테스트에서 repository.save 두 번 호출 후 예외 기대)를 추가해 검증하라.apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java-35-38 (1)
35-38:⚠️ Potential issue | 🟠 MajorFAILED 상태 이벤트의 재처리 메커니즘이 없다.
markFailed()호출 후 해당 이벤트는findByStatusInit()에서 조회되지 않아 영구적으로 누락된다. 재시도 횟수 제한과 함께 FAILED 이벤트를 재처리하는 로직이 필요하다.🔧 재시도 메커니즘 제안
// OutboxEvent에 retryCount 필드 추가 후 if (event.getRetryCount() < MAX_RETRY) { event.incrementRetryCount(); // status는 INIT 유지하여 다음 폴링에서 재시도 } else { event.markFailed(); log.error("Outbox 이벤트 최대 재시도 초과 - eventId: {}", event.getId()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java` around lines 35 - 38, The Outbox processing currently marks events FAILED with event.markFailed() which removes them from findByStatusInit() polling and causes permanent loss; add a retry mechanism by introducing a retryCount field on the OutboxEvent (and a MAX_RETRY constant), update the processing loop in OutboxRelay to check event.getRetryCount() before marking failed, call event.incrementRetryCount() and keep status as INIT when under MAX_RETRY so it will be re-polled, and only call event.markFailed() and log a “max retries exceeded” error when retryCount >= MAX_RETRY; update persistence saving logic to persist retryCount changes (and adjust any repository queries if needed).modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java-1-1 (1)
1-1: 🛠️ Refactor suggestion | 🟠 Major패키지명 오타:
confg→config(5개 파일 영향)패키지명 오타로 인해 컴파일 시 import 경로 오류가 발생한다. 현재 5개 파일이
com.loopers.confg패키지를 참조 중이므로 모두 수정해야 한다:
- modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (패키지 선언)
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
패키지 디렉토리명 변경 → 파일의 package 선언 수정 → 모든 import 문 수정 순서로 진행하고, 수정 후 모듈 간 의존성이 올바르게 해결되는지 전체 빌드 테스트를 실행한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java` at line 1, 패키지명 오타로 인해 import 경로가 깨지므로 package 선언과 참조를 모두 "com.loopers.confg"에서 "com.loopers.config"로 변경하세요: KafkaConfig.java의 package 선언을 수정하고, CouponIssueConsumer, OrderEventConsumer, DemoKafkaConsumer, CatalogEventConsumer 클래스들에서 모든 import 절에 사용된 com.loopers.confg.*를 com.loopers.config.*로 바꿔주세요; 작업 전 패키지 디렉토리 이름도 confg → config로 변경하고 수정 후 전체 빌드를 실행하여 모듈 의존성이 올바른지 확인하세요.apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java-29-40 (1)
29-40:⚠️ Potential issue | 🟠 Major동기 블로킹 호출과 타임아웃 미설정으로 인한 장애 전파 위험이 있다.
kafkaTemplate.send(...).get()은 Kafka 응답까지 스레드를 블로킹한다. Kafka 브로커 지연 시 스케줄러 스레드가 무한 대기하여 후속 이벤트 처리가 중단된다. 또한 타임아웃이 없어 네트워크 장애 시 스레드 리소스가 고갈될 수 있다.🔧 타임아웃 설정 및 비동기 처리 제안
+import java.util.concurrent.TimeUnit; + +private static final int SEND_TIMEOUT_SECONDS = 5; +private static final int MAX_EVENTS_PER_POLL = 100; ... - List<OutboxEvent> events = outboxEventRepository.findByStatusInit(); + List<OutboxEvent> events = outboxEventRepository.findByStatusInit(MAX_EVENTS_PER_POLL); ... try { - kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get(); + kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()) + .get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS); event.markPublished();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java` around lines 29 - 40, The loop in OutboxRelay synchronously blocks on kafkaTemplate.send(...).get(), risking scheduler hangs; replace the blocking call with an asynchronous send using the ListenableFuture returned by kafkaTemplate.send(topic, key, payload) and attach addCallback to markPublished()/markFailed() and persist via outboxEventRepository.save(event) inside the success/failure callbacks; if you retain any blocking wait, use future.get(timeout, TimeUnit...) with a configurable timeout and handle TimeoutException to markFailed() to avoid indefinite blocking.apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java-24-37 (1)
24-37:⚠️ Potential issue | 🟠 Major배치 처리 및 부분 실패에 대한 트랜잭션 격리가 필요하다.
대량 주문 만료 시 장기 트랜잭션 문제:
findExpiredOrders()가 반환하는 주문이 수백 건 이상일 경우, 단일 트랜잭션 내에서 모든 상태 업데이트를 처리하므로 DB 커넥션 점유 시간이 증가하고 락 경합이 발생할 수 있다.부분 실패 시 전체 롤백: 루프 중간에 예외 발생 시 이미 처리된 주문들도 함께 롤백되어 다음 스케줄러 실행 때 재처리가 필요해진다. 재처리 중에도 동일 문제가 발생하면 무기한 지연될 수 있다.
배치 단위로 처리하고 각 주문마다 개별 트랜잭션을 분리하여 부분 실패를 격리하도록 개선한다.
🔧 개별 트랜잭션 분리 및 예외 처리
`@Scheduled`(fixedDelay = 60000) - `@Transactional` public void expireUnpaidOrders() { - List<Orders> expiredOrders = orderService.findExpiredOrders(); + List<Orders> expiredOrders = orderService.findExpiredOrders(); if (expiredOrders.isEmpty()) { return; } for (Orders order : expiredOrders) { - orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED); - eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId())); - log.info("주문 만료 처리 - orderId: {}", order.getId()); + try { + expireSingleOrder(order); + } catch (Exception e) { + log.error("주문 만료 처리 실패 - orderId: {}", order.getId(), e); + } } } + + `@Transactional` + private void expireSingleOrder(Orders order) { + orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED); + eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId())); + log.info("주문 만료 처리 - orderId: {}", order.getId()); + }apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java-29-41 (1)
29-41:⚠️ Potential issue | 🟠 Majorcommerce-api의 CouponIssueStatus enum 값과 동기화 메커니즘이 필요하다.
동일한
coupon_issue_request테이블을 두 모듈에서 다른 방식으로 매핑하고 있다.
commerce-api:CouponIssueStatusenum (EnumType.STRING)commerce-streamer: String 상수 ("ISSUED", "REJECTED")streamer가 "ISSUED" 문자열로 상태를 갱신할 때,
CouponIssueStatus.ISSUED.name()이 변경되면 commerce-api에서 읽은 데이터를 enum으로 역직렬화할 수 없어 런타임 오류가 발생한다. enum 값 변경 시 양쪽 모듈의 상수가 자동으로 동기화되거나, 최소한 enum 값과 streamer 상수가 일치하는지 검증하는 테스트를 추가하라.구체적 조치
- 공유 모듈에 enum을 정의하고 양쪽 모듈에서 참조
- 또는 streamer의 상수 값이 enum 값과 항상 일치하는지 확인하는 통합 테스트 추가
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java` around lines 29 - 41, The streamer entity CouponIssueRequestStreamerEntity currently uses String constants STATUS_ISSUED/STATUS_REJECTED and methods markIssued()/markRejected(), which can drift from commerce-api's CouponIssueStatus enum; either replace the String status field with the shared enum type (use the common CouponIssueStatus enum and EnumType.STRING mapping) and update markIssued()/markRejected() to assign CouponIssueStatus.ISSUED/REJECTED, or add an automated test that asserts STATUS_ISSUED.equals(CouponIssueStatus.ISSUED.name()) and STATUS_REJECTED.equals(CouponIssueStatus.REJECTED.name()) at startup to fail fast if they diverge. Ensure references to CouponIssueRequestStreamerEntity, status, STATUS_ISSUED, STATUS_REJECTED, markIssued(), and markRejected() are updated accordingly.apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java-41-79 (1)
41-79:⚠️ Potential issue | 🟠 Major정리 경로와 대기 시간이 묶여 있지 않아 CI가 멈추거나 다음 테스트를 오염시킬 수 있다.
운영 관점에서
latch.await()의 무한 대기와 메서드 마지막 정리 코드는 중간 실패 시 thread pool과 Redis 키를 남겨 플래키 테스트를 만든다. 수정안은await에 timeout을 두고,ExecutorService종료와redisTemplate.delete(redisKey)를try/finally또는@AfterEach로 보장하며, 가능하면 키도 테스트별 suffix로 분리하는 것이다. 추가 테스트로 동일 테스트를 연속 재실행하거나 병렬 실행했을 때 이전 실행의 잔여 상태 없이 항상 독립적으로 통과하는지 검증해야 한다.Also applies to: 87-103
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java` around lines 41 - 79, The test can hang and leak resources because latch.await() is unbounded and cleanup (executor shutdown and redisTemplate.delete(redisKey)) runs only after a successful await; modify FirstComeCouponConcurrencyTest to call latch.await(timeout, unit) with a reasonable timeout, and wrap executor.shutdown()/executor.awaitTermination(...) and redisTemplate.delete(redisKey) in a try/finally (or move cleanup to `@AfterEach`) so the thread pool is always terminated and the Redis key always deleted even on failures; also make redisKey unique per test run (e.g., append a UUID or test-method-specific suffix) to avoid cross-test contamination and add a retry/loop test to verify repeated or parallel runs remain independent.apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java-61-72 (1)
61-72:⚠️ Potential issue | 🟠 MajorRedis 스크립트의 예상 밖 반환값을 성공으로 처리하면 fail-open 된다.
현재는
-1과-2만 거절하고 그 외 값은 모두 성공으로 간주한다.null이나 잘못된 반환값이 들어오면 실제로 큐에 들어갔는지 모른 채 요청을 통과시켜 과발급으로 이어질 수 있다. 성공은1만 허용하고 나머지는 fallback 또는 예외로 닫아 두며,redisTemplate.execute()가null이나0을 돌려줄 때 실패 처리되는 테스트를 추가하는 편이 안전하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java` around lines 61 - 72, In FirstComeCouponService where redisTemplate.execute(FCFS_SCRIPT, List.of(key), ...) is called, do not treat any return value other than 1 as success: check that result != null and result.equals(1L) for success, and throw appropriate CoreException for other cases (including null, 0 or unexpected values) instead of letting them pass; update handling around FCFS_SCRIPT/redisTemplate.execute to explicitly reject null/0 and map unknown returns to a safe error path (e.g., BAD_REQUEST or CONFLICT as appropriate) and add unit tests that assert failure when redisTemplate.execute returns null or 0 to prevent fail-open behavior.apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java-140-166 (1)
140-166:⚠️ Potential issue | 🟠 Major"Redis 장애 시 DB Fallback" 시나리오가 실제 fallback을 검증하지 않는다.
현재 테스트는 Redis 예외를 유발하지 않고 정상 경로에서 동일 회원 중복 요청만 확인한다. 장애 경로가 비어 있으면 운영에서
fallbackAddToQueue()의 중복/소진 로직이 깨져도 배포 전에 발견되지 않는다.redisTemplate.execute()를 예외로 유도하거나fallbackAddToQueue()를 직접 검증해서 duplicate와 sold-out 두 경로를 모두 커버하는 테스트를 추가하는 편이 안전하다. As per coding guidelines, "Java 테스트 리뷰 기준이다. 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java` around lines 140 - 166, The test currently never triggers Redis failure so it doesn't verify DB fallback; modify or add tests that force redisTemplate.execute() to throw (or otherwise simulate Redis failure) and then call firstComeCouponService.addToQueue(...) to exercise fallbackAddToQueue(templateId, memberId) logic, asserting both duplicate-request rejection and sold-out behavior; specifically, in Scenario3_Fallback use FirstComeCoupon.create(...) to build the coupon, stub/mock redisTemplate.execute to throw a RuntimeException, invoke addToQueue(...) for the first successful DB fallback claim and assert the second call throws CoreException (duplicate), and add a separate case that exhausts quantity via fallbackAddToQueue to assert sold-out is handled. Ensure cleanup still deletes the redisKey.apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java-16-31 (1)
16-31:⚠️ Potential issue | 🟠 Major이벤트 구성을 생성 시점에 검증해야 한다.
maxQuantity <= 0,startAt == null,endAt == null,startAt >= endAt가 허용되면 잘못된 운영 설정이 도메인 내부에 그대로 들어와 소진 판정 오류나NullPointerException으로 이어질 수 있다. 생성/복원 팩토리에서 즉시 검증하고, 잘못된 수량·역전된 기간·null 입력이 모두 거절되는 테스트를 추가하는 편이 안전하다. As per coding guidelines, "Java 도메인 모델 리뷰 기준이다. 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java` around lines 16 - 31, 검증이 빠져 있어 잘못된 도메인 상태가 들어갈 수 있으니 FirstComeCoupon의 private 생성자와 팩토리 메서드(create, reconstruct)에서 입력값을 즉시 검사하도록 수정하세요: startAt와 endAt는 Objects.requireNonNull로 null을 거부하고 startAt.isBefore(endAt) (또는 startAt.compareTo(endAt) < 0)로 기간 역전을 검사하며 maxQuantity는 1 이상의 양수인지 확인해 IllegalArgumentException을 던지도록 하며(reconstruct에서도 동일하게 적용), 이 유효성 검사에 대한 단위 테스트를 추가해 maxQuantity <= 0, null startAt/endAt, startAt >= endAt 케이스들이 거부되는지 검증하세요.apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java-34-36 (1)
34-36:⚠️ Potential issue | 🟠 Major시작 시각 경계가 제외되어 첫 요청이 탈락할 수 있다.
now.isAfter(startAt)는startAt과 정확히 같은 시각을 비활성으로 본다. 선착순 이벤트는 오픈 순간 트래픽이 몰리기 쉬워서, 시작 경계를 포함하지 않으면 정시 요청이 임의로 실패할 수 있다. 시작 조건을!now.isBefore(startAt)로 바꾸고,now == startAt일 때 활성으로 판단되는 경계 테스트를 추가하는 편이 안전하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java` around lines 34 - 36, The isActive() method in FirstComeCoupon currently uses now.isAfter(startAt) which treats a request exactly at startAt as inactive; change the start-bound check to include the boundary by using !now.isBefore(startAt) (i.e., replace now.isAfter(startAt) with !now.isBefore(startAt)) so startAt is considered active, and add/adjust a unit test for FirstComeCoupon.isActive() to assert that a timestamp equal to startAt returns true; keep the endAt check as-is or ensure its intended exclusivity is documented.apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java-31-38 (1)
31-38:⚠️ Potential issue | 🟠 Major일반 info 로그에 사용자 식별자를 그대로 남기고 있다.
memberId를 일반 운영 로그에 남기면 로그 수집기와 장기 보관 경로로 사용자 식별자가 그대로 확산된다. 감사 목적이 필요하면 별도 audit 로그로 분리하거나 마스킹/해시된 값만 남기고, 일반 info 로그에서는 제거하는 편이 안전하다. 로그 캡처 테스트로 favorite 이벤트 처리 시 원본memberId가 일반 로그에 출력되지 않는지 확인하라. As per coding guidelines, "로깅 시 민감정보 노출 가능성을 점검한다."Also applies to: 44-51
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java` around lines 31 - 38, The info logs in FavoriteEventListener (the log.info calls that reference event.memberId() around the product like handling and the later block 44-51) are emitting raw member identifiers; remove the raw memberId from general info logs and instead log either a non-reversible masked/hashed representation (e.g., hash(event.memberId())) or omit it entirely, and if you need the original for auditing send it to a separate audit logger or secure store; update the log lines that currently read event.memberId() to use the masked/hash value or remove that field, and add/adjust a log-capture test to assert that the original memberId does not appear in regular info logs while ensuring OutboxEvent.create / outboxEventRepository.save behavior remains correct if audit retention is required.apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java-28-29 (1)
28-29:⚠️ Potential issue | 🟠 Major무기한 로컬 캐시가 쿠폰 설정을 영구히 stale 하게 만든다.
한 번 조회한
FirstComeCoupon은 TTL도 eviction도 없어 기간 변경, 수량 조정, 이벤트 종료가 반영되지 않는다. 운영 중 설정이 수정돼도 애플리케이션 재시작 전까지 오래된startAt/endAt/maxQuantity로 계속 판정해 잘못된 발급 또는 거절이 발생한다. 최소한 수정·삭제 시 명시적 eviction을 넣거나 TTL이 있는 캐시로 교체하고, 설정 변경 후getByTemplateId()가 최신 값을 다시 읽는 테스트를 추가하는 편이 안전하다. Based on learnings, "Only favor full invalidation when it is intentional and documented; otherwise consider implementing selective eviction by key."Also applies to: 47-51
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java` around lines 28 - 29, The local ConcurrentHashMap cache field in FirstComeCouponService (cache) causes stale FirstComeCoupon entries because there is no TTL or eviction; modify the service to either replace the map with a TTL-enabled cache (e.g., Caffeine or Guava cache) or add explicit eviction calls in update/delete flows so changes to startAt/endAt/maxQuantity are immediately reflected; ensure getByTemplateId() reloads from the source when a cache miss or after eviction, and add a unit/integration test that updates a coupon setting then calls getByTemplateId() to assert the new values are returned.apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java-28-59 (1)
28-59:⚠️ Potential issue | 🟠 Major원본 트랜잭션 커밋 후 outbox 저장으로 인한 내구성 문제를 개선하다.
이 리스너는 원본 favorite 변경 트랜잭션이 끝난 후
@TransactionalEventListener(AFTER_COMMIT)으로 구동되고, 새로운 트랜잭션(REQUIRES_NEW)에서 좋아요 집계와 outbox 저장을 수행한다. outboxEventRepository.save() 또는 toJson() 과정에서 예외가 발생하면 listener 트랜잭션은 롤백되지만, 이미 커밋된 원본 favorite 레코드는 그대로 남는다. 결과적으로 outbox 테이블에는 아무 기록이 남지 않아 OutboxRelay는 재시도할 근거를 잃고, 좋아요 카운트 업데이트와 카프카 이벤트 발행이 둘 다 유실되므로 수작업 복구가 필요해진다.수정안: outbox 저장을 FavoriteFacade의 원본 트랜잭션 내에서 먼저 내구화하거나, 좋아요 카운트 증감과 행동 로그를 OutboxRelay 소비 측으로 옮겨 리스너 실패가 재시도 가능하게 구성하다. 나아가 outboxEventRepository.save() 또는 toJson() 실패를 의도적으로 발생시켰을 때 favorite와 파생 상태(outbox, 집계, 이벤트)가 영구 불일치로 남지 않는 통합 테스트를 추가하다.
또한 memberId는 사용자 식별자이므로 로그 라인 31, 38, 44, 51에서 INFO 수준으로 노출되지 않도록 제거하거나 WARN/ERROR 컨텍스트로 제한하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java` around lines 28 - 59, The listener persistence is vulnerable because onFavoriteAdded/onFavoriteRemoved (FavoriteEventListener) perform outboxEventRepository.save(...) and toJson(...) in a REQUIRES_NEW listener transaction after the original favorite transaction commits, which can leave favorite and outbox/aggregation permanently inconsistent; fix by moving the outboxEventRepository.save(...) (and serialization via toJson(...)) into the original favorite transaction (e.g., FavoriteFacade where the favorite create/delete occurs) so the outbox is durably persisted before commit, or alternatively move productService.increaseLikeCount/decreaseLikeCount and user-action logging into the OutboxRelay consumer so that the listener only enqueues an outbox record and retries are possible; add integration tests that simulate failures in outboxEventRepository.save() and toJson() to assert no permanent divergence between favorite, outbox, and aggregated counts; also remove memberId from INFO-level log lines in FavoriteEventListener (the log.info(...) calls in onFavoriteAdded/onFavoriteRemoved) or demote them to WARN/ERROR as appropriate.apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java-93-97 (1)
93-97:⚠️ Potential issue | 🟠 Major
checkPaymentStatus()에서handleCallback()직접 호출 시 트랜잭션 경계 무시 문제
checkPaymentStatus()는@Transactional없이handleCallback()을 직접 호출하므로 Spring 프록시를 거치지 않는다. 결과적으로handleCallback()의@Transactional(rollbackFor = Exception.class)경계가 무시되어 다음 작업들이 원자적으로 보장되지 않는다:
- 결제 상태 변경 (line 74, 78)
- 주문 상태 변경 (line 75, 79)
- 재고 복원 (line 80)
예를 들어 결제 성공 시
markSuccess()는 성공하지만updateOrderStatus()가 실패하면 결제는 완료 상태인데 주문은 CREATED 상태로 남아 운영에서 수기 복구가 필요하다. 재고 복원도 마찬가지로 일부만 성공할 수 있다.해결 방안:
handleCallback()을 별도@Component로 분리하여 Spring 프록시를 통해 호출하거나checkPaymentStatus()자체를@Transactional로 감싸거나ApplicationContext.getBean()으로 프록시된 빈을 획득하여 호출추가 개선:
중간 단계 예외 시 전체 롤백되는 통합 테스트를 추가한다. 현재checkPaymentStatus_pgSuccess()테스트는 성공 경로만 검증하며 트랜잭션 경계나 부분 실패 시나리오를 다루지 않는다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java` around lines 93 - 97, checkPaymentStatus() currently calls handleCallback() directly so Spring’s `@Transactional` on handleCallback() is bypassed; either make checkPaymentStatus() `@Transactional` so the whole flow (markSuccess(), updateOrderStatus(), restoreInventory()) runs in one transaction, or obtain and call the proxied PaymentFacade bean (ApplicationContext.getBean(PaymentFacade.class)) / move handleCallback() into a separate `@Component` and call that bean to ensure Spring proxying; update references to handleCallback(), checkPaymentStatus(), markSuccess(), updateOrderStatus() accordingly and add an integration test that simulates a mid-flow failure to verify the whole operation rolls back.apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-43-50 (1)
43-50:⚠️ Potential issue | 🟠 MajorPG 실패 처리 시 부분 롤백 위험
handlePgFailure와updateOrderStatus가 동일한REQUIRES_NEW트랜잭션 내에서 실행된다. 만약updateOrderStatus에서 예외가 발생하면handlePgFailure의 변경도 함께 롤백되어 결제 상태와 주문 상태 간 불일치가 발생할 수 있다.PG 실패 처리는 반드시 완료되어야 하는 작업이므로, 주문 상태 업데이트 실패가 결제 상태 업데이트를 롤백시키면 안 된다. 두 작업을 분리하거나, 주문 상태 업데이트 실패 시 재시도 로직을 추가해야 한다.
제안 수정
if (!info.hasTransactionKey()) { paymentService.handlePgFailure(event.paymentId()); - orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_FAILED); + try { + orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_FAILED); + } catch (Exception e) { + log.error("주문 상태 업데이트 실패 - orderId: {}, 재시도 필요", event.orderId(), e); + // 결제 실패는 이미 저장되었으므로 별도 보상 트랜잭션 또는 스케줄러로 처리 + } log.warn("PG 결제 요청 실패 - paymentId: {}", event.paymentId()); return; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java` around lines 43 - 50, The current PG-failure branch risks rolling back payment state if orderService.updateOrderStatus throws; to fix, ensure paymentService.handlePgFailure is executed in its own committed transaction before attempting order updates and make updateOrderStatus resilient: invoke paymentService.handlePgFailure via an explicit new-transaction boundary (e.g., call a separate bean/method annotated with REQUIRES_NEW or use TransactionTemplate) and only after it completes call orderService.updateOrderStatus inside a try/catch that logs failures and enqueues/retries the order status update instead of allowing its exception to propagate; reference PaymentEventListener, paymentService.handlePgFailure, and orderService.updateOrderStatus to locate the code to change.
🟡 Minor comments (8)
apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java-3-3 (1)
3-3:⚠️ Potential issue | 🟡 Minor생성 이벤트에 도메인 불변식 검증이 누락되었다.
운영 관점에서
orderId/memberIdnull 또는totalPrice음수 이벤트는 정산/집계 오염과 재처리 비용을 만든다. compact constructor에서 입력을 검증하도록 수정해야 한다. 추가 테스트로 null id, 음수 금액 케이스 예외를 포함해야 한다.수정안 예시
public record OrderCreatedEvent(Long orderId, Long memberId, int totalPrice) { + public OrderCreatedEvent { + if (orderId == null || memberId == null) { + throw new IllegalArgumentException("orderId/memberId must not be null"); + } + if (totalPrice < 0) { + throw new IllegalArgumentException("totalPrice must be non-negative"); + } + } }As per coding guidelines
**/domain/**/*.java: 도메인 모델의 불변성과 캡슐화를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java` at line 3, The OrderCreatedEvent record lacks domain invariants; add a compact constructor in OrderCreatedEvent to validate inputs: requireNonNull(orderId) and requireNonNull(memberId) and ensure totalPrice >= 0 (throw IllegalArgumentException for negative price), so invalid events cannot be created; update unit tests to assert that constructing OrderCreatedEvent with null orderId, null memberId, or a negative totalPrice throws the appropriate exception.apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java-71-78 (1)
71-78:⚠️ Potential issue | 🟡 Minor알 수 없는 PG 상태값 수신 시 로깅이 누락되어 있다.
IllegalArgumentException발생 시PgPaymentStatus.FAILED로 폴백하지만 원본 상태값과 예외를 로깅하지 않는다. PG사가 새로운 상태값을 추가하거나 예기치 않은 값을 반환할 경우, 운영팀이 이를 인지하지 못하고 정상 결제가 실패로 처리될 수 있다.운영 관점: 장애 탐지 및 원인 분석이 어려워진다.
수정안
+import lombok.extern.slf4j.Slf4j; + +@Slf4j `@RequiredArgsConstructor` `@Component` public class PgPaymentAdapter implements PaymentGateway {if (response.status() != null) { try { pgStatus = PgPaymentStatus.valueOf(response.status()); } catch (IllegalArgumentException e) { + log.warn("Unknown PG status received: {}, treating as FAILED", response.status()); pgStatus = PgPaymentStatus.FAILED; } }추가 테스트: 알 수 없는 상태값 입력 시
FAILED로 변환되는지 검증하는 단위 테스트를 추가한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java` around lines 71 - 78, PgPaymentAdapter currently swallows IllegalArgumentException when mapping response.status() to PgPaymentStatus, losing the original value and exception details; update the try/catch around PgPaymentStatus.valueOf(response.status()) to log the unknown status and exception (including response.status() and e) via the class logger before falling back to PgPaymentStatus.FAILED, and add a unit test that supplies an unknown status string to verify the method returns FAILED and that the log entry is produced/handled.apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java-58-59 (1)
58-59:⚠️ Potential issue | 🟡 Minor
ApplicationEventPublishermock이 추가되었으나 이벤트 발행 검증이 누락되어 있다.
OrderFacade.createOrder()가OrderCreatedEvent를 발행한다면, 해당 이벤트가 올바른 파라미터로 발행되었는지 검증해야 한다. 현재 mock만 선언되어 있고verify()가 없어 이벤트 발행 로직이 변경되어도 테스트가 실패하지 않는다.운영 관점: 이벤트 발행 누락 시 Outbox에 기록되지 않아 후속 처리(결제 요청 등)가 동작하지 않을 수 있다.
수정안 (createOrder 성공 테스트에 추가)
+import com.loopers.domain.event.OrderCreatedEvent; +import org.mockito.ArgumentCaptor; // ... in createsOrder_withMultipleProducts test assertThat(captured.orderProducts()).hasSize(2); + +ArgumentCaptor<OrderCreatedEvent> eventCaptor = ArgumentCaptor.forClass(OrderCreatedEvent.class); +verify(eventPublisher).publishEvent(eventCaptor.capture()); +assertThat(eventCaptor.getValue().orderId()).isEqualTo(1L);추가 테스트: 주문 생성 실패 시 이벤트가 발행되지 않음을 검증하는 테스트도 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java` around lines 58 - 59, The test declares a mock ApplicationEventPublisher but never asserts event publication; update the successful createOrder test to verify eventPublisher.publishEvent(...) was called with an OrderCreatedEvent containing the expected order id/user/amount (use Mockito.verify and an ArgumentCaptor or Mockito.eq to check the OrderCreatedEvent fields) by referencing OrderFacade.createOrder() and OrderCreatedEvent, and add a new failure-case test that calls OrderFacade.createOrder() with invalid input and asserts eventPublisher.publishEvent(...) was never invoked (Mockito.verify(eventPublisher, never())).apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java-7-15 (1)
7-15:⚠️ Potential issue | 🟡 Minor분산 시스템에서 타임존 처리와 역직렬화 방어를 강화하라.
타임존 일관성:
LocalDateTime은 타임존 정보를 포함하지 않아 분산 환경에서 시간 불일치를 야기할 수 있다. 데이터베이스 레이어(BaseEntity)에서ZonedDateTime을 사용하므로, Kafka 메시지도Instant로 통일하여 명확한 시간 정보를 보장하라.역직렬화 방어 부족:
ObjectMapper.convertValue()이후 null 체크 없이 필드에 접근하면 NPE가 발생할 수 있다. 라인 43의event.eventId()접근 전에OrderEventMessage의 필수 필드에 대한 유효성 검증을 추가하라.에러 로그 민감정보 노출: 라인 34의
log.error()로깅이 Kafka 레코드 전체(record.value())를 기록하면 메시지 페이로드의 민감정보가 노출될 수 있다. 운영 로그에는eventId와eventType등 필요한 정보만 기록하라.적용 사항:
LocalDateTime→Instant변경- 역직렬화 이후 필수 필드 null 체크 추가
- 에러 로그 출력 정보 제한 (전체 레코드 대신 키 정보만)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java` around lines 7 - 15, Change the DTO and deserialization checks to avoid timezone and NPE issues: replace LocalDateTime with Instant in the OrderEventMessage record declaration, update any places that construct or read that record to use Instant, and ensure time handling matches the DB layer (BaseEntity/ZonedDateTime → convert to Instant). After calling ObjectMapper.convertValue(...) to map the Kafka payload to OrderEventMessage, add a null/validity check for required fields (e.g., event.eventId(), event.eventType(), orderId) before using them and return or handle errors if missing. Finally, avoid logging the entire Kafka record on errors; change the log.error(...) calls to only include non-sensitive identifiers such as record.key(), eventId and eventType (or a short ordinal) instead of record.value().modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java-56-69 (1)
56-69:⚠️ Potential issue | 🟡 Minor운영 환경에서 replicas(1)은 데이터 유실 위험이 있다.
replicas(1)설정은 단일 브로커 장애 시 해당 파티션의 데이터가 유실된다. 특히order-events와coupon-issue-requests는 결제/쿠폰 발급과 관련된 중요 이벤트이므로 운영 환경에서는 최소replicas(3)이 권장된다.또한 토픽명이 문자열로 분산되어 있어
OutboxRelay.resolveTopic()등과 불일치 시 런타임 오류가 발생한다.🔧 토픽 상수 중앙화 및 환경별 replica 설정 제안
public static final String TOPIC_CATALOG_EVENTS = "catalog-events"; public static final String TOPIC_ORDER_EVENTS = "order-events"; public static final String TOPIC_COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; // 또는 application.yml에서 주입 `@Value`("${kafka.replication-factor:1}") private int replicationFactor; `@Bean` public NewTopic catalogEventsTopic() { return TopicBuilder.name(TOPIC_CATALOG_EVENTS) .partitions(3) .replicas(replicationFactor) .build(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java` around lines 56 - 69, The three topic beans (catalogEventsTopic, orderEventsTopic, couponIssueRequestsTopic) currently hardcode replicas(1) and literal topic names; change them to use centralized topic name constants (e.g., TOPIC_CATALOG_EVENTS, TOPIC_ORDER_EVENTS, TOPIC_COUPON_ISSUE_REQUESTS) and replace replicas(1) with a configurable replicationFactor (inject from application properties, defaulting to 1) so you can set replicas=3 in production; also ensure the constants match the names used by OutboxRelay.resolveTopic() to avoid runtime mismatches.apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java-8-19 (1)
8-19:⚠️ Potential issue | 🟡 Minor
CouponIssueRequestEntity의couponTemplateId컬럼에 인덱스가 누락되었다.
countByTemplateId()는 선착순 쿠폰 발급 시 최대 수량 검증을 위해 호출되는 핵심 경로이다. 현재CouponIssueRequestEntity는couponTemplateId에@Index애노테이션이 없으며, 프로덕션 환경에서ddl-auto: none으로 설정되어 수동 DDL 관리를 한다. 따라서 대량 데이터 누적 시 이 쿼리가 풀 테이블 스캔을 수행하여 응답 시간이 급격히 증가할 수 있다.조치:
CouponIssueRequestEntity에@Index(name = "idx_coupon_template_id", columnList = "couponTemplateId")추가- 프로덕션 데이터베이스에 인덱스 생성 DDL 마이그레이션 스크립트 작성 및 적용
- 쿼리 실행 계획(
EXPLAIN)으로 인덱스 사용 여부 검증🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java` around lines 8 - 19, CouponIssueRequestEntity is missing an index on couponTemplateId which makes the repository method countByTemplateId(...) perform full table scans under load; add `@Index`(name = "idx_coupon_template_id", columnList = "couponTemplateId") to the CouponIssueRequestEntity class (on the `@Table` annotation or equivalent), create and apply a production DB migration script that creates the index (CREATE INDEX idx_coupon_template_id ON ... (couponTemplateId)), and verify the repository query uses the index by running EXPLAIN on the countByTemplateId query to confirm the index is used.apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java-190-193 (1)
190-193:⚠️ Potential issue | 🟡 Minor테스트 전용 Repository 메서드에 명시적 문서화 필요
updateTransactionKeyAndStatus는 현재 테스트 코드에서만 사용되는 메서드다. 프로덕션 코드에서 의도하지 않은 사용을 방지하고 유지보수 시 혼동을 줄이기 위해, 메서드 정의(PaymentJpaRepository라인 20)에@Deprecated또는 Javadoc 주석을 추가하여 테스트 전용임을 명시해야 한다./** * 테스트 목적으로만 사용. PG 응답 시뮬레이션 시 결제 상태를 변경한다. * `@deprecated` 테스트 전용 메서드 */ `@Deprecated`(since = "1.0", forRemoval = true) int updateTransactionKeyAndStatus(`@Param`("id") Long id, `@Param`("transactionKey") String transactionKey, `@Param`("status") PaymentStatus status);이를 통해 향후 리팩토링 시 불필요한 메서드 제거 판단을 명확히 할 수 있다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java` around lines 190 - 193, The repository exposes a test-only method updateTransactionKeyAndStatus used in the test (PaymentFacadeIntegrationTest) which should be explicitly marked to avoid accidental production use; edit the method declaration in PaymentJpaRepository to add a Javadoc stating it is for test purposes only and annotate it with `@Deprecated` (include since/forRemoval metadata if desired) so callers and future refactors clearly see it is test-only.apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java-69-73 (1)
69-73:⚠️ Potential issue | 🟡 Minor결제 실패 이벤트의 Outbox 저장 누락
onPaymentCompleted는 Outbox에 이벤트를 저장하여 Kafka로 전파하지만,onPaymentFailed는 로그만 남긴다. 이 비대칭 처리가 의도적인지 확인이 필요하다.결제 실패 정보도 downstream 시스템(예: 알림 서비스, 분석 시스템)에 전파해야 하는 경우가 많다. 만약 의도적으로 제외한 것이라면 주석으로 이유를 명시해야 한다. 그렇지 않다면
onPaymentCompleted와 동일하게 Outbox 저장을 추가해야 한다.제안 수정 (Outbox 저장 추가 시)
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) + `@Transactional`(propagation = Propagation.REQUIRES_NEW) public void onPaymentFailed(PaymentFailedEvent event) { log.info("결제 실패 이벤트 - orderId: {}, paymentId: {}, reason: {}", event.orderId(), event.paymentId(), event.reason()); + outboxEventRepository.save(OutboxEvent.create( + OutboxEventType.PAYMENT_FAILED, + event.orderId(), + toJson(event) + )); log.info("유저 행동 로깅 - action: PAYMENT_FAILED, orderId: {}", event.orderId()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java` around lines 69 - 73, onPaymentFailed currently only logs the failure but does not persist an Outbox entry like onPaymentCompleted, causing asymmetric downstream behavior; either add the same Outbox save logic used in onPaymentCompleted (create an Outbox event payload for PAYMENT_FAILED and persist via the same Outbox repository/service used by onPaymentCompleted) inside PaymentEventListener.onPaymentFailed(PaymentFailedEvent) so the failure is emitted to Kafka, or if omission is intentional add a clear comment explaining why failures should not be propagated; reference the methods onPaymentFailed, onPaymentCompleted and the Outbox persistence component (OutboxRepository/OutboxService or equivalent) when making the change.
| @Transactional(rollbackFor = Exception.class) | ||
| public CouponIssueRequestResDto requestFirstComeIssue(String loginId, String password, Long couponTemplateId) { | ||
| Member member = memberService.findMember(loginId, password); | ||
| FirstComeCoupon fcCoupon = firstComeCouponService.getByTemplateId(couponTemplateId); | ||
|
|
||
| firstComeCouponService.addToQueue(fcCoupon, member.getId()); | ||
| try { | ||
| CouponIssueRequest request = couponIssueRequestRepository.save( | ||
| CouponIssueRequest.create(couponTemplateId, member.getId())); | ||
|
|
||
| kafkaTemplate.send("coupon-issue-requests", | ||
| String.valueOf(couponTemplateId), | ||
| Map.of("requestId", request.getId(), | ||
| "couponTemplateId", couponTemplateId, | ||
| "memberId", member.getId())); | ||
|
|
||
| return CouponIssueRequestResDto.from(request); | ||
| } catch (Exception e) { | ||
| firstComeCouponService.removeFromQueue(couponTemplateId, member.getId()); | ||
| throw e; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "CouponFacade.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 167
🏁 Script executed:
find . -type f -name "FirstComeCouponService.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 180
🏁 Script executed:
find . -type f -name "CouponIssueRequestRepository.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 189
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4579
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4696
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 764
Redis/DB/Kafka 상태 불일치로 FCFS 요청이 유실될 수 있다.
운영 중 Kafka 브로커 장애나 프로세스 종료 시 Redis 대기열 등록, 요청 row 저장, Kafka 발행이 서로 다른 시스템에 분산되어 상태 일관성이 깨진다. 특히 kafkaTemplate.send()는 비동기이고 반환값(ListenableFuture)을 사용하지 않아 브로커 ack 실패가 현재 catch 블록으로 유입되지 않으므로 removeFromQueue()도 실행되지 않는다. 그 결과 Redis에는 요청자 정보가 남아 있고 DB에도 요청 row가 남아 있으나 Kafka에는 발행되지 않는 상태가 발생한다.
수정안: 이 메서드에서 Kafka를 직접 호출하지 말고 요청 저장과 함께 outbox 테이블 행을 동일한 DB 트랜잭션 내에 기록한 뒤 별도 relay 프로세스가 outbox에서 읽어 Kafka로 발행하는 구조로 변경한다. 추가 테스트로 kafkaTemplate.send()가 비동기 콜백에서 예외 발생하는 경우, DB 트랜잭션 커밋 실패 시나리오, Redis 장애 시 fallback 경로를 각각 주입하여 요청 row/Redis 대기열/outbox 행/발행 여부가 함께 일관되게 유지되는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java`
around lines 50 - 70, The requestFirstComeIssue method currently calls
kafkaTemplate.send asynchronously after saving a CouponIssueRequest and adding
the member to Redis queue (firstComeCouponService.addToQueue), which can leave
Redis/DB/Kafka inconsistent; instead remove the direct kafkaTemplate.send call
and write an Outbox row (e.g., OutboxEvent entity) in the same DB transaction
alongside couponIssueRequestRepository.save so the event is persisted
atomically, keep firstComeCouponService.removeFromQueue in error handling, and
implement a separate relay process to read Outbox rows and publish to Kafka; add
tests that simulate kafka send failures, DB commit failures, and Redis failures
to verify the queue entry, request row, outbox row and eventual publish are kept
consistent.
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||
| public void onOrderCreated(OrderCreatedEvent event) { | ||
| log.info("주문 생성 이벤트 - orderId: {}, memberId: {}, totalPrice: {}", event.orderId(), event.memberId(), event.totalPrice()); | ||
| outboxEventRepository.save(OutboxEvent.create( | ||
| OutboxEventType.ORDER_CREATED, | ||
| String.valueOf(event.orderId()), | ||
| toJson(event) | ||
| )); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "OrderEventListener.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 172
🏁 Script executed:
find . -name "*Outbox*" -type f | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 839
🏁 Script executed:
git ls-files "apps/commerce-api" | grep -E "(Event|Outbox)" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1659
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3805
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1760
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 426
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1429
🏁 Script executed:
rg "publishEvent\|new OrderCreatedEvent\|applicationEventPublisher" --type java -A 3 -B 3 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
find . -path "*/order/service/*.java" -type f | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 261
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3395
🏁 Script executed:
rg "OrderCreatedEvent" --type java -B 5 -A 2 | head -60Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4571
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4611
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4471
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3211
🏁 Script executed:
rg "OutboxEventType" --type java | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2392
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2173
Outbox를 AFTER_COMMIT에서 저장하면 주문 커밋과 원자성이 끊어진다.
OrderFacade.createOrder()는 주문 저장 후 같은 트랜잭션 내에서 OrderCreatedEvent를 발행한다. 하지만 OrderEventListener.onOrderCreated()는 @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(REQUIRES_NEW)로 설정되어, 주문 커밋 이후 새로운 트랜잭션에서 outbox 행을 저장한다. 운영 중 주문 커밋 직후 JVM이 종료되거나 listener 실행이 실패하면 주문은 이미 생성되었는데 outbox 행이 없어 downstream이 해당 이벤트를 영구히 받지 못한다.
수정안은 outbox 저장을 주문 생성과 같은 트랜잭션 내에서 수행하고, 별도의 OutboxRelay 스케줄러가 이미 존재하므로 outbox 행을 대기 상태로 저장 후 relay가 비동기로 발행하도록 분리하는 것이다. PaymentEventListener의 onPaymentCompleted()와 FavoriteEventListener의 onFavoriteAdded/Removed()도 동일한 패턴을 사용하고 있으므로 함께 수정해야 한다. 추가 테스트로 outbox 저장 실패를 주입했을 때 주문과 outbox가 함께 rollback되어 주문만 있고 outbox는 없는 상태가 생기지 않는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java`
around lines 35 - 43, The outbox save must happen in the same transaction as the
domain write so remove the AFTER_COMMIT + REQUIRES_NEW pattern in the listeners
and persist outbox rows inside the ordering transaction: update
OrderEventListener.onOrderCreated to save the OutboxEvent within the original
transaction (e.g., change `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) and remove `@Transactional`(propagation =
Propagation.REQUIRES_NEW) — or alternatively invoke outbox persistence directly
from OrderFacade.createOrder right after the order save using
outboxEventRepository.save(OutboxEvent.create(...)) so the outbox and order
share one commit), and apply the same change to
PaymentEventListener.onPaymentCompleted and
FavoriteEventListener.onFavoriteAdded/Removed; add tests that inject an outbox
save failure to verify both the order and outbox are rolled back together and no
“order without outbox” state can occur, and rely on the existing OutboxRelay
scheduler to asynchronously publish persisted outbox rows.
| public void onOrderExpired(OrderExpiredEvent event) { | ||
| log.info("주문 만료 처리 - orderId: {}", event.orderId()); | ||
| try { | ||
| List<OrderProduct> products = orderProductService.findByOrderId(event.orderId()); | ||
| for (OrderProduct op : products) { | ||
| productService.increaseStockAtomic(op.getProductId(), op.getQuantity().value()); | ||
| } | ||
| if (event.userCouponId() != null) { | ||
| couponService.restoreUserCoupon(event.userCouponId()); | ||
| } | ||
| log.info("주문 만료 복원 완료 - orderId: {}, 재고 복원 {}건, 쿠폰 복원: {}", | ||
| event.orderId(), products.size(), event.userCouponId() != null); | ||
| } catch (Exception e) { | ||
| log.error("주문 만료 복원 실패 - orderId: {}, 수동 확인 필요", event.orderId(), e); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "OrderEventListener.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 172
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3805
🏁 Script executed:
# onOrderExpired 메서드 주변 전체 컨텍스트 확인
sed -n '40,70p' apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1495
🏁 Script executed:
# 클래스 선언과 import 확인
head -50 apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2375
🏁 Script executed:
# 호출되는 서비스들의 구현 확인 - orderProductService.findByOrderId
find . -name "*OrderProductService*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 264
🏁 Script executed:
# 호출되는 서비스들의 구현 확인 - productService.increaseStockAtomic
find . -name "*ProductService*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 534
🏁 Script executed:
# 호출되는 서비스들의 구현 확인 - couponService.restoreUserCoupon
find . -name "*CouponService*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 444
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 8742
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5546
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderProductService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 954
보상 처리 예외를 무시하면 부분 복구가 커밋된다.
현재 REQUIRES_NEW 트랜잭션에서 예외를 catch하여 무시하므로, 루프 내 여러 상품의 재고 복원 중 하나만 실패해도 앞의 성공한 복원들은 그대로 커밋된다. 예를 들어 첫 번째 상품 재고는 복원되고 두 번째 상품 복원이 실패한 경우, 메서드가 정상 종료되어 트랜잭션이 커밋되어 부분 복구 상태가 영구적으로 남는다. 운영 환경에서는 주문 취소 후 일부 재고와 쿠폰만 복구된 불일치 상태가 발생한다.
예외를 전파하여 REQUIRES_NEW 트랜잭션 전체를 롤백하거나, 실패 건을 outbox 이벤트로 기록해 별도 재시도 메커니즘으로 처리해야 한다. 추가로 OrderProduct 반복 중 특정 상품의 재고 복원만 실패하는 시나리오를 테스트하여 어떤 데이터도 부분 반영되지 않는지 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java`
around lines 49 - 63, The onOrderExpired method in OrderEventListener currently
swallows exceptions which allows partial commits inside the REQUIRES_NEW
transaction; change the error-handling so failures cause the transaction to roll
back or are recorded for retry: either remove the broad try/catch and let
exceptions from productService.increaseStockAtomic or
couponService.restoreUserCoupon propagate so the REQUIRES_NEW transaction
aborts, or catch per-item failures inside the loop and instead persist an
outbox/failure record for the failed product (including orderId, productId,
quantity, error) and then throw a new exception after the loop to trigger
rollback; also add tests for OrderEventListener.onOrderExpired (mock
OrderProductService.findByOrderId and productService.increaseStockAtomic) to
verify that when any increaseStockAtomic throws, no stock/coupon changes are
committed and the outbox entry is created if using the retry approach.
| Orders savedOrder = orderService.createOrder(command); | ||
|
|
||
| eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder.getId(), member.getId(), savedOrder.getTotalPrice().value())); | ||
|
|
||
| return FindOrderResDto.from(savedOrder); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check OrderEventListener implementation for exception handling
ast-grep --pattern $'@TransactionalEventListener($$$)
$$$
public void $_(OrderCreatedEvent $_) {
$$$
}'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
find . -type f -name "*OrderEventListener*"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 172
🏁 Script executed:
find . -type f -name "*.java" | xargs grep -l "OrderCreatedEvent" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 335
🏁 Script executed:
git ls-files "**/*.java" | grep -i listener | grep -i orderRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 170
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3805
🏁 Script executed:
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 228
🏁 Script executed:
rg "getTotalPrice" apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java -A 2 -B 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 630
🏁 Script executed:
rg "OutboxEvent" apps/commerce-api/src/main/java --type java -l | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 566
🏁 Script executed:
rg "outbox.*retry|retry.*outbox" apps/commerce-api/src/main/java --type java -iRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
onOrderCreated 메서드에 예외 처리가 누락되어 있어 주문 이벤트 전파 실패 시 복구 메커니즘이 없다.
현재 구현에서 toJson() 호출 시 JsonProcessingException이 발생하면 RuntimeException으로 변환되어 던져진다. AFTER_COMMIT 리스너이므로 클라이언트 응답에는 영향을 주지 않지만, Outbox 레코드 저장이 실패하면서 주문 생성 이벤트가 영구 손실된다. 현재 코드에서 재시도 메커니즘이 없으므로 운영상 심각한 문제가 된다.
onOrderExpired 메서드는 try-catch로 예외를 처리하고 있는데, onOrderCreated는 미보호 상태이다. 일관성 있게 예외를 처리하고, Outbox 저장 실패 시 로그를 남기되 다시 던져서 트랜잭션 롤백 후 재시도하도록 개선해야 한다.
추가로 로그 레벨 38, 44번 라인에서 memberId를 노출하고 있다. 사용자 민감정보이므로 필요한 경우만 로깅하고, 외부 모니터링 시스템으로 유출되지 않도록 주의해야 한다.
수정안:
onOrderCreated에서toJson()또는 저장 실패 시log.error()로 기록 후 예외 다시 발생- Outbox 발행 실패에 대한 재시도 스케줄러 또는 배치 메커니즘 구현
memberId로깅 필요성 재검토 및 최소화
추가 테스트:
- JSON 직렬화 실패 시 예외 처리 및 트랜잭션 롤백 동작 검증
- Outbox 저장 실패 후 복구 메커니즘 동작 확인
- 민감정보 로그 필터링 동작 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 46 - 50, The onOrderCreated listener currently lacks exception
handling around toJson() and Outbox persistence so serialization/save failures
can drop events; wrap the JSON serialization and outbox save in a try-catch
(mirroring onOrderExpired) that logs an error via log.error with
minimal/non-sensitive context (do not log raw memberId) and then rethrows the
exception to force transaction rollback and allow retry, and refactor any log
statements in onOrderCreated that emit memberId to redact or remove that field;
additionally, plan/implement an Outbox retry mechanism (scheduler or batch
re-publisher) to handle persistent failures and add tests verifying that JSON
serialization failures and outbox save failures cause rollback and trigger the
retry flow.
| public void handleCallback(String transactionKey, PgPaymentStatus status) { | ||
| Payment payment = paymentService.getPaymentByTransactionKey(transactionKey); | ||
|
|
||
| Orders order = orderService.getOrderByOrderNumber(payment.getOrderId()); | ||
|
|
||
| if ("SUCCESS".equals(status)) { | ||
| if (status == PgPaymentStatus.SUCCESS) { | ||
| paymentService.markSuccess(transactionKey); | ||
| orderService.updateOrderStatus(order.getId(), OrderStatus.PAID); | ||
| eventPublisher.publishEvent(new PaymentCompletedEvent(payment.getOrderId(), payment.getId())); | ||
| } else { | ||
| paymentService.markFailed(transactionKey, status); | ||
| paymentService.markFailed(transactionKey, status.name()); | ||
| orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED); | ||
| paymentService.restoreStock(payment.getId()); | ||
| eventPublisher.publishEvent(new PaymentFailedEvent(payment.getOrderId(), payment.getId(), status.name())); | ||
| } |
There was a problem hiding this comment.
콜백 상태 머신이 열려 있어 중간 상태와 중복 콜백이 모두 오염을 만든다.
현재 분기는 SUCCESS가 아니면 모두 실패 처리하고, 이미 terminal 상태인 결제인지도 확인하지 않는다. PG가 PENDING 또는 재전송 콜백을 보내면 주문 실패 처리, 재고 복원, 실패/완료 이벤트 발행이 중복되어 운영 데이터가 쉽게 틀어진다. switch(status)로 허용 상태를 명시하고 REQUESTED -> SUCCESS/FAILED 전이만 허용하며, PENDING 콜백과 동일 콜백 2회 전송이 모두 no-op 또는 단일 처리로 끝나는 테스트를 추가하는 편이 안전하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`
around lines 69 - 82, The handleCallback method currently treats any non-SUCCESS
as failure and doesn't check current payment state, causing duplicate/partial
callbacks (e.g., PENDING or retransmits) to corrupt data; update handleCallback
to use a switch on PgPaymentStatus and only allow transitions from the
terminal-eligible prior state (e.g., REQUESTED -> SUCCESS or REQUESTED ->
FAILED), no-op on PENDING and on callbacks when payment is already terminal, and
ensure idempotency by checking the payment current status via
payment.getStatus() before calling paymentService.markSuccess/markFailed,
orderService.updateOrderStatus, paymentService.restoreStock, or
eventPublisher.publishEvent; also add tests for PENDING callbacks and repeated
SUCCESS/FAILED callbacks to assert no double-processing.
| public enum OutboxEventType { | ||
| FAVORITE_ADDED, | ||
| FAVORITE_REMOVED, | ||
| ORDER_CREATED, | ||
| PAYMENT_COMPLETED | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# PaymentFailedEvent 경로와 OutboxEventType 매핑 누락 여부를 정적 점검한다.
rg -nP 'PaymentFailedEvent|PAYMENT_FAILED|OutboxEventType\.[A-Z_]+' apps/commerce-api/src/main/java apps/commerce-streamer/src/main/java
# Outbox 타입 기반 분기/매핑 지점을 확인한다.
rg -nP 'switch\s*\(|if\s*\(|OutboxEventType' apps/commerce-api/src/main/java | head -n 200Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 17371
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4471
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2173
결제 실패 이벤트가 Outbox 저장되지 않아 Kafka 전파 누락이 발생한다.
결제 실패 이벤트 흐름에서 세 가지 결함이 확인된다:
PaymentEventListener.onPaymentFailed()는 로그만 기록하고 Outbox 저장을 수행하지 않는다(현재 71~72줄).OutboxEventType열거형에PAYMENT_FAILED상수가 누락되었다.OutboxRelay.resolveTopic()의 switch 문에PAYMENT_FAILED케이스가 없다(현재 44~47줄).
결과적으로 PaymentFailedEvent는 발행되지만 다운스트림 시스템으로 전파되지 않아, 주문 상태와 결제 상태가 불일치하고 데이터 정합성이 깨진다.
수정 대상:
OutboxEventType열거형에PAYMENT_FAILED추가PaymentEventListener.onPaymentFailed()에서 Outbox 이벤트 저장 로직 추가 (onPaymentCompleted와 동일한 패턴)OutboxRelay.resolveTopic()의 switch 문에PAYMENT_FAILED -> "order-events"케이스 추가
결제 실패 경로 전체에 대한 통합 테스트(Outbox 저장 및 Kafka 발행 확인)를 보강해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java`
around lines 3 - 8, Add a PAYMENT_FAILED enum constant to OutboxEventType;
update PaymentEventListener.onPaymentFailed() to create and persist an Outbox
event the same way onPaymentCompleted() does (same payload/metadata pattern and
persistence calls) instead of only logging; and add a PAYMENT_FAILED ->
"order-events" case in OutboxRelay.resolveTopic()'s switch so the event is
routed to Kafka; finally add an integration test that triggers the
payment-failed flow and asserts the Outbox row is created and the message is
emitted to the "order-events" topic.
| public enum EventType { | ||
| FAVORITE_ADDED, | ||
| FAVORITE_REMOVED, | ||
| ORDER_CREATED, | ||
| PAYMENT_COMPLETED | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) API 측 발행 이벤트 타입 확인
rg -n -C2 '\b(OrderExpiredEvent|PaymentFailedEvent|PAYMENT_FAILED|ORDER_EXPIRED)\b' \
apps/commerce-api/src/main/java
# 2) Streamer 측 수신 모델/라우팅 타입 확인
rg -n -C2 '\bEventType\b|\beventType\b|\bPAYMENT_FAILED\b|\bORDER_EXPIRED\b' \
apps/commerce-streamer/src/main/java
# 3) 토픽 매핑 확인
rg -n -C2 '\b(order-events|catalog-events|coupon-issue-requests)\b' \
modules/kafka/src/main/java apps/commerce-api/src/main/java apps/commerce-streamer/src/main/javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 22891
🏁 Script executed:
# Check the full OutboxRelay.resolveTopic() method and see what happens with unmapped types
rg -n -A 10 'private String resolveTopic' \
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
# Search for OutboxEventType enum definition
fd -t f '*.java' -x grep -l 'enum OutboxEventType' {} \;
# Check if there are any other event type mappings or enums
rg -n 'enum.*EventType' apps/commerce-api/src/main/java
rg -n 'enum.*EventType' apps/commerce-streamer/src/main/java
# Check OrderEventConsumer full switch statement
rg -n -A 20 'switch (event.eventType())' \
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 890
🏁 Script executed:
# Get the full OutboxEventType enum definition
cat apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java
# Get the full EventType enum definition (streamer side)
cat apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java
# Get the full OrderEventConsumer switch statement
rg -n -A 30 'switch \(event\.eventType\(\)\)' \
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 860
🏁 Script executed:
# Check if PaymentFailedEvent and OrderExpiredEvent are published to Kafka directly anywhere
rg -n 'kafkaTemplate\.send|eventPublisher\.publish' \
apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java \
apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java
# Check if OutboxRelay handles PaymentFailedEvent or OrderExpiredEvent
rg -n -B 5 -A 5 'PaymentFailedEvent|OrderExpiredEvent' \
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
# Check how PaymentFailedEvent and OrderExpiredEvent are created and where they're published
rg -n -B 2 -A 2 'new PaymentFailedEvent|new OrderExpiredEvent' \
apps/commerce-api/src/main/java/com/loopers/application/event/
# Check OutboxRelay to see which events trigger outbox persistence
rg -n -B 10 'onPaymentCompleted|onOrderCreated|onFavorite' \
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 843
🏁 Script executed:
# Check if PaymentFailedEvent or OrderExpiredEvent are supposed to be handled by OutboxRelay
# by looking for event listener methods in OutboxRelay
rg -n 'EventListener|public void on' \
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
# Get the full OutboxRelay file to understand what events it handles
cat apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
# Check if there's any documentation or PR description about event types to be streamed
git log --oneline --all | head -20
</parameter>Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2474
이벤트 라우팅 누락으로 결제 실패 및 주문 만료 이벤트가 Kafka로 발행되지 않는다
PaymentFailedEvent와 OrderExpiredEvent는 API에서 발행되지만 OutboxRelay에 리스너가 없어 Kafka에 전달되지 않는다. 더 심각하게는 OutboxRelay.resolveTopic()이 이 두 이벤트 타입을 처리하지 못하면 MatchException이 발생하거나, 향후 이들을 OutboxEventType에 추가해도 Kafka 메시지를 수신한 streamer의 EventType 열거형과 OrderEventConsumer 스위치 문이 일치하지 않아 처리되지 않는다. 이는 결제 실패 및 주문 만료와 같은 중요한 비즈니스 이벤트 손실로 이어진다.
수정안으로는 다음 세 가지가 필요하다:
- OutboxEventType 열거형에 PAYMENT_FAILED, ORDER_EXPIRED 추가
- EventType 열거형에 PAYMENT_FAILED, ORDER_EXPIRED 추가
- OutboxRelay에
@TransactionalEventListener메서드 추가하여 PaymentFailedEvent와 OrderExpiredEvent를 OutboxEvent로 저장 - OrderEventConsumer의 스위치 문에 PAYMENT_FAILED, ORDER_EXPIRED 케이스 추가
통합 테스트로는 주문 결제 실패 시나리오와 주문 만료 스케줄러 실행 후 이 두 이벤트가 Kafka의 order-events 토픽에 메시지로 나타나는지, streamer에서 정상 처리되는지 검증해야 한다.
수정 예시
OutboxEventType:
public enum OutboxEventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
- PAYMENT_COMPLETED
+ ORDER_EXPIRED,
+ PAYMENT_COMPLETED,
+ PAYMENT_FAILED
}EventType:
public enum EventType {
FAVORITE_ADDED,
FAVORITE_REMOVED,
ORDER_CREATED,
- PAYMENT_COMPLETED
+ ORDER_EXPIRED,
+ PAYMENT_COMPLETED,
+ PAYMENT_FAILED
}OutboxRelay:
`@Scheduled`(fixedDelay = 1000)
public void relay() {
...
}
+ `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+ public void onPaymentFailed(PaymentFailedEvent event) {
+ OutboxEvent outbox = OutboxEvent.create(event.getOrderId(), OutboxEventType.PAYMENT_FAILED, ...);
+ outboxEventRepository.save(outbox);
+ }
+
+ `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+ public void onOrderExpired(OrderExpiredEvent event) {
+ OutboxEvent outbox = OutboxEvent.create(event.orderId(), OutboxEventType.ORDER_EXPIRED, ...);
+ outboxEventRepository.save(outbox);
+ }OrderEventConsumer:
switch (event.eventType()) {
case ORDER_CREATED ->
log.info("주문 생성 이벤트 수신 - orderId: {}, memberId: {}", event.orderId(), event.memberId());
case PAYMENT_COMPLETED ->
log.info("결제 완료 이벤트 수신 - orderId: {}", event.orderId());
+ case PAYMENT_FAILED ->
+ log.info("결제 실패 이벤트 수신 - orderId: {}", event.orderId());
+ case ORDER_EXPIRED ->
+ log.info("주문 만료 이벤트 수신 - orderId: {}", event.orderId());
default -> {}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public enum EventType { | |
| FAVORITE_ADDED, | |
| FAVORITE_REMOVED, | |
| ORDER_CREATED, | |
| PAYMENT_COMPLETED | |
| } | |
| public enum EventType { | |
| FAVORITE_ADDED, | |
| FAVORITE_REMOVED, | |
| ORDER_CREATED, | |
| ORDER_EXPIRED, | |
| PAYMENT_COMPLETED, | |
| PAYMENT_FAILED | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java`
around lines 3 - 8, EventType 열거형에 PAYMENT_FAILED와 ORDER_EXPIRED 값을 추가하고(수정 대상:
EventType), 동일한 값들을 OutboxEventType 열거형에도 추가하여 OutboxRelay.resolveTopic에서 매칭되도록
하세요. OutboxRelay 클래스에 `@TransactionalEventListener` 메서드를 추가해 PaymentFailedEvent와
OrderExpiredEvent를 수신하면 새로운 OutboxEvent(적절한 OutboxEventType)를 생성·저장하도록 구현하고, 저장
시 사용하는 엔티티/생성자 로직이 기존 OutboxEvent 구조와 일치하는지 확인하세요. 또한 OrderEventConsumer의 switch
문에 PAYMENT_FAILED와 ORDER_EXPIRED 케이스를 추가해 Kafka로부터 받은 EventType을 처리하도록 핸들러(예:
handlePaymentFailed, handleOrderExpired)를 연결하세요. 마지막으로 결제 실패와 주문 만료 시나리오에 대한 통합
테스트를 추가해 order-events 토픽에 메시지가 게시되고 streamer에서 해당 EventType 케이스로 정상 처리되는지 검증하세요.
| public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) { | ||
| for (ConsumerRecord<Object, Object> record : records) { | ||
| try { | ||
| transactionTemplate.executeWithoutResult(status -> processRecord(record)); | ||
| } catch (Exception e) { | ||
| log.error("coupon-issue-requests 처리 실패 - record: {}", record, e); | ||
| } | ||
| } | ||
| ack.acknowledge(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3687
🏁 Script executed:
# Search for ErrorHandler configuration in the project
rg -t java "ErrorHandler|DefaultErrorHandler|DeadLetterPublishingRecoverer" --max-count 20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Search for DLT or Dead Letter Topic configuration
rg -t java "dead.*letter|dlt|DeadLetter" -i --max-count 20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check for Spring Kafka listener configuration
rg -t java "@KafkaListener|ConcurrentKafkaListenerContainerFactory|ErrorHandlingStrategy" --max-count 20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1278
🏁 Script executed:
# Check if there are integration tests for this consumer
fd -t f ".*Test.*\.java$|.*Tests\.java$" | xargs rg -l "CouponIssueConsumer" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 69
🏁 Script executed:
cat -n modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5105
🏁 Script executed:
# Search for any ErrorHandler setup in KafkaConfig or elsewhere
rg -t java "setCommonErrorHandler|ErrorHandler|recovery" --max-count 30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
배치 단위로 ack를 호출하면서 실패한 레코드가 처리 없이 커밋되어 메시지가 유실된다.
KafkaConfig 라인 86에서 AckMode.MANUAL로 설정되어 있고 배치 리스너로 동작한다. 현재 코드는 레코드별 예외를 삼킨 뒤 배치 전체에 대해 ack.acknowledge()를 호출하므로, 한 건이라도 실패하면 그 레코드의 오프셋이 커밋되어 재처리 없이 운영 환경에서 손실된다. 선착순 쿠폰은 재발급이 어려운 특성을 고려하면 심각한 문제다.
failed 레코드별로 ack를 보류하거나, DefaultErrorHandler로 dead letter topic(DLT)으로 이동하는 방식으로 개선해야 한다. 아울러 라인 40의 에러 로그에서 전체 record 객체를 노출하는 것보다 requestId와 exception 정보만 기록하도록 수정하면, 로그 보안도 개선할 수 있다.
한 레코드 실패 시 해당 오프셋이 재전달되거나 DLT로 이동하는지 확인하는 통합 테스트도 필수다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`
around lines 35 - 43, The consume method currently commits the entire batch even
when individual records fail; change the logic in
consume/transactionTemplate/processRecord so that failed records do not cause
the batch ack. For each record, on success call ack for that record's offset or
accumulate only successful offsets before calling ack; alternatively wire
Kafka's DefaultErrorHandler/DeadLetterPublishingRecoverer (configure DLT) for
retries/DLT routing instead of swallowing exceptions in the loop; update the
catch block in consume to log only the record's requestId (from processRecord
payload) and the exception (not the full ConsumerRecord) and rethrow or let the
error handler handle the failed record so its offset is not committed; add an
integration test asserting that a failing record is retried or routed to DLT and
not lost when AckMode.MANUAL is used.
| public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) { | ||
| for (ConsumerRecord<Object, Object> record : records) { | ||
| try { | ||
| transactionTemplate.executeWithoutResult(status -> processRecord(record)); | ||
| } catch (Exception e) { | ||
| log.error("order-events 처리 실패 - record: {}", record, e); | ||
| } | ||
| } | ||
| ack.acknowledge(); | ||
| } | ||
|
|
||
| private void processRecord(ConsumerRecord<Object, Object> record) { | ||
| OrderEventMessage event = objectMapper.convertValue(record.value(), OrderEventMessage.class); | ||
|
|
||
| if (eventHandledRepository.existsById(event.eventId())) { | ||
| return; | ||
| } | ||
|
|
||
| switch (event.eventType()) { | ||
| case ORDER_CREATED -> | ||
| log.info("주문 생성 이벤트 수신 - orderId: {}, memberId: {}", event.orderId(), event.memberId()); | ||
| case PAYMENT_COMPLETED -> | ||
| log.info("결제 완료 이벤트 수신 - orderId: {}", event.orderId()); | ||
| default -> {} | ||
| } | ||
|
|
||
| eventHandledRepository.save(EventHandledEntity.of(event.eventId(), EventHandleStatus.SUCCESS)); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2755
🏁 Script executed:
# transactionTemplate 설정과 Spring Kafka 설정 확인
find apps/commerce-streamer -type f -name "*.java" | xargs rg -l "transactionTemplate|KafkaListener" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 458
🏁 Script executed:
# 배치 리스너 설정 확인
rg -A 10 "@KafkaListener|batch.size|batch.max.bytes|enable.auto.commit" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 606
🏁 Script executed:
# 예외 처리 및 재시도 설정 확인
rg -B 5 -A 5 "consume\(" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 722
🏁 Script executed:
find apps/commerce-streamer -type f -name "KafkaConfig.java" | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 전체 config 파일 검색
find apps -type f -name "*KafkaConfig*" -o -name "*KafkaConsumer*" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 176
🏁 Script executed:
# BATCH_LISTENER 상수와 설정 확인
rg -B 5 -A 15 "BATCH_LISTENER|batch.max.poll.records|enable.auto.commit|ack" apps/commerce-streamer/src/main/java/com/loopers/confg/kafka/ 2>/dev/null || find apps/commerce-streamer -path "*confg/kafka*" -type f -name "*.java" | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# OrderEventMessage와 eventType 정의 확인
find apps -name "OrderEventMessage.java" -o -name "OrderEventMessage.kt" | xargs cat -n 2>/dev/null | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 540
🏁 Script executed:
find apps -name "*.java" | xargs rg -l "BATCH_LISTENER" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 458
🏁 Script executed:
# 전체 리포지토리에서 KafkaConfig 검색
find . -name "KafkaConfig.java" -o -name "*Kafka*Config*.java" 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 155
🏁 Script executed:
# EventType 정의 확인
find apps -name "EventType.java" | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 290
🏁 Script executed:
# 브로드 검색: class나 interface에서 BATCH_LISTENER 문자열 찾기
rg -l "BATCH_LISTENER" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 527
🏁 Script executed:
cat -n ./modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5105
배치 아큀으로 인한 메시지 영구 유실 및 미지원 이벤트 침묵 처리.
현재 구현은 다음과 같은 운영 치명점을 가진다.
-
배치 레벨 아큀으로 인한 실패 메시지 손실: processRecord() 내 모든 예외(직렬화 오류, DB 오류, 네트워크 오류 등)는 catch되어 로그만 남기고, 루프가 계속된다. 단일 레코드 실패 후에도 라인 37의
ack.acknowledge()가 호출되어 배치 전체 오프셋이 커밋된다. Spring Kafka 배치 리스너는 이미 오프셋이 커밋되었으므로 재시도하지 않으며, 해당 메시지는 영구 소실된다. -
미지원 이벤트타입을 성공으로 처리: switch 문의 default 분기(라인 52)는 비어있고, EventType enum의 FAVORITE_ADDED, FAVORITE_REMOVED는 처리되지 않는다. 그럼에도 라인 55는 조건 없이
eventHandledRepository.save(... EventHandleStatus.SUCCESS)를 실행하여, 미지원 이벤트도 처리 완료로 기록한다. 스키마 변경이나 토픽 오염 시 버그를 감지하지 못한다. -
재시도 불가능한 예외 처리: 모든 예외를 동일하게 취급하여 재시도 가능 오류(일시적 DB 연결 오류)와 불가능 오류(잘못된 JSON 형식)를 구분하지 않는다.
수정안: 지원하지 않는 eventType에서 명시적 예외를 발생시키고, 개별 레코드 실패 시 해당 레코드만 실패 처리 경로(DLQ, 실패 테이블, 별도 토픽)로 넘기며, 배치 전체가 성공했을 때만 아큀을 수행하도록 변경한다.
추가 테스트: (1) 직렬화 오류 발생 시 오프셋이 커밋되지 않고 재처리되는지, (2) EventType enum에 미지원 값이 추가되었을 때 예외를 발생시키는지, (3) DB 저장 실패 시 메시지가 DLQ로 라우팅되는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`
around lines 29 - 55, The consume method currently acknowledges the whole batch
regardless of per-record failures and processRecord quietly treats unsupported
event types as success; change logic so consume only calls ack.acknowledge()
when every record was processed successfully (do not swallow exceptions
per-record), let processRecord throw on unsupported eventType and on retriable
failures so the framework/DLQ can handle them, and only call
eventHandledRepository.save(..., EventHandleStatus.SUCCESS) for events actually
handled (e.g., after the ORDER_CREATED and PAYMENT_COMPLETED branches).
Specifically update consume to collect per-record outcomes or rethrow exceptions
from processRecord instead of catching all exceptions, update processRecord’s
switch default to throw a clear UnsupportedEventTypeException, and separate
handling of transient (retriable) vs non-retriable exceptions so only retriable
ones trigger retries/DLQ behavior rather than committing the batch.
| jdbc-url: ${datasource.mysql-jpa.main.jdbc-url:jdbc:mysql://localhost:3306/loopers} | ||
| username: ${datasource.mysql-jpa.main.username:test} | ||
| password: ${datasource.mysql-jpa.main.password:test} |
There was a problem hiding this comment.
자기참조 플레이스홀더로 테스트 프로필 기동 실패 위험이 있다.
운영 관점에서 해당 설정은 순환 치환으로 컨텍스트 초기화 실패를 유발할 수 있어 CI 및 통합테스트 안정성을 직접 깨뜨린다.
수정안으로 같은 키를 재참조하지 말고 별도 환경변수 키를 사용해야 한다.
수정 예시
- jdbc-url: ${datasource.mysql-jpa.main.jdbc-url:jdbc:mysql://localhost:3306/loopers}
- username: ${datasource.mysql-jpa.main.username:test}
- password: ${datasource.mysql-jpa.main.password:test}
+ jdbc-url: ${TEST_MYSQL_JDBC_URL:jdbc:mysql://localhost:3306/loopers}
+ username: ${TEST_MYSQL_USERNAME:test}
+ password: ${TEST_MYSQL_PASSWORD:test}추가 테스트로 test 프로필에서 환경변수 미설정/설정 두 케이스 모두 애플리케이션 컨텍스트 로드가 성공하는 설정 바인딩 테스트를 추가해야 한다.
#!/bin/bash
set -euo pipefail
# 동일 키 자기참조 placeholder 패턴을 탐지한다. 결과가 없으면 정상이다.
rg -nP '^\s*(jdbc-url|username|password):\s*\$\{datasource\.mysql-jpa\.main\.\1:' modules/jpa/src/main/resources/jpa.yml
# test profile 블록을 재확인한다.
rg -n -A10 -B5 'spring\.config\.activate\.on-profile:\s*test' modules/jpa/src/main/resources/jpa.yml🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@modules/jpa/src/main/resources/jpa.yml` around lines 61 - 63, The jpa.yml
entries for jdbc-url, username and password currently reference the same
datasource.mysql-jpa.main.* keys causing a self-referential placeholder loop;
change those placeholders to use distinct environment/property keys (e.g.,
datasource.mysql-jpa.test.jdbc-url, datasource.mysql-jpa.test.username,
datasource.mysql-jpa.test.password) instead of reusing
datasource.mysql-jpa.main.*, update any related references to
datasource.mysql-jpa.main in the test profile block, and add a
configuration-binding integration test that loads the application context under
the "test" profile validating that context initializes successfully both when
the new test-specific environment variables are set and when they are absent.
📌 Summary
🧭 Context & Decision
문제 정의
Facade에서 부수효과(좋아요 집계, 재고 복원, Kafka 발행 등)를 직접 호출하면 몇 가지 문제가 생깁니다. 트랜잭션이 롤백될 때 이미 실행된 외부 API 호출이나 메시지 발행은 되돌릴 수 없습니다. PG 호출처럼 외부 I/O가 긴 작업이 트랜잭션 안에 있으면 DB 커넥션을 불필요하게 오래 점유합니다. 이 문제들을 해결하기 위한 판단을 아래에 정리합니다.
선택지와 결정
판단 1: 부수효과 분리 — @eventlistener vs @TransactionalEventListener
처음에는
@EventListener로 충분하다고 생각했습니다. 이벤트를 발행하고 리스너에서 처리하면 되니까요.그런데 문제는 트랜잭션이 롤백되더라도 리스너가 실행된다는 점이었습니다. 예를 들어 주문 생성 중 예외가 발생해서 롤백됐는데, 리스너에서 이미 Outbox에 INSERT를 했다면 존재하지 않는 주문에 대한 이벤트가 Kafka로 나가게 됩니다. 데이터 정합성 문제입니다.
그래서
@TransactionalEventListener(phase = AFTER_COMMIT)을 선택했습니다. 트랜잭션이 성공적으로 커밋된 후에만 리스너가 실행되기 때문에, 롤백된 작업에 대한 부수효과가 발생하지 않습니다.다만 AFTER_COMMIT 리스너 안에서 DB 작업을 하려면 새 트랜잭션이 필요합니다. 기존 트랜잭션은 이미 커밋돼서 끝났기 때문입니다. 그래서 리스너 메서드에
@Transactional(propagation = REQUIRES_NEW)를 붙여서 별도 트랜잭션으로 Outbox INSERT를 수행했습니다.트레이드오프로는, AFTER_COMMIT 리스너가 실패하면 이벤트가 유실될 수 있습니다. 하지만 Outbox 패턴 자체가 재시도 가능한 구조이고, 메인 비즈니스 로직(주문 생성, 결제 등)이 롤백되는 것보다는 부수효과 유실이 복구하기 쉽다고 판단했습니다.
판단 2: Kafka 발행 시점의 트랜잭션 보장 — Outbox Pattern
이벤트 리스너에서 바로
kafkaTemplate.send()를 호출하면 간단하지만, DB 커밋은 됐는데 Kafka 발행이 실패하면 데이터 불일치가 생깁니다.Outbox 테이블에 같은 트랜잭션으로 INSERT하고, 별도 스케줄러(
OutboxRelay)가 1초 간격으로 폴링해서 Kafka로 발행하도록 했습니다. 발행 성공하면PUBLISHED, 실패하면FAILED로 마킹합니다. 재시도 가능한 구조입니다.트레이드오프로는, 최대 1초 지연이 생기지만 메시지 유실을 방지할 수 있습니다.
판단 3: PG 호출의 트랜잭션 분리 — 결제는 견고해야 합니다
기존에는
PaymentFacade에서@Transactional안에서 PG 호출을 직접 하고 있었습니다. 외부 API 호출이 트랜잭션 안에 있으면 PG 응답이 느릴 때 DB 커넥션을 계속 점유하게 됩니다. 커넥션 풀이 고갈되면 결제뿐 아니라 다른 기능까지 영향을 받습니다.이를
PaymentRequestedEvent로 분리했습니다.@TransactionalEventListener(AFTER_COMMIT)으로 커밋 후 PG 호출합니다.handleCallback을 재사용해서 결과를 처리합니다.Kafka가 아닌 같은 애플리케이션 내의 ApplicationEvent를 사용했습니다. 결제에는 즉시 응답이 필요하기 때문입니다. Kafka Consumer가 처리할 때까지의 지연은 결제 UX에 적합하지 않고, 같은 서비스 안에서 트랜잭션 밖으로 빼는 것이 목적이었기 때문에 ApplicationEvent면 충분했습니다.
PG 호출이 실패하더라도
checkPaymentStatus에서 PG에 직접 상태를 조회해서 복구할 수 있는 구조도 만들었습니다. Kafka Consumer에서 실패 시 offset 관리나 DLQ 처리를 해야 하는 것에 비하면 훨씬 단순하고 예측 가능한 복구 경로입니다.결제가 완료된 후 다른 시스템에 알리는 부분은 Kafka를 사용했습니다.
PaymentCompletedEvent→ Outbox(PAYMENT_COMPLETED) →order-events토픽 → commerce-streamer의OrderEventConsumer에서 소비합니다. PG 호출(동기, 즉시 응답 필요)과 결제 결과 전파(비동기, 시스템 간 전파)의 성격이 다르기 때문에 구분해서 처리했습니다.결제 실패 시에는
PaymentFailedEvent가 발행되어 주문 상태를PAYMENT_FAILED로 변경하고 재고를 복원합니다. 결제가 실패했는데 재고가 차감된 채로 남아있으면 안 되기 때문에, 실패 보상 처리도 이벤트 기반으로 함께 구성했습니다.판단 4: 선착순 쿠폰 — 수량 제한 방식을 세 번 바꾸게 된 이유
처음에는 DB만으로 해결하려 했습니다.
SELECT FOR UPDATE로 잠그고 발급 수를 세는 방식이었습니다. 하지만 동시에 200명이 몰리면 락 경합이 심해져서 TPS가 바닥을 쳤습니다. 실제로FirstComeCouponScenarioTest에서 race condition 발생을 확인했습니다.그래서 Redis INCR로 전환했습니다. 카운터를 원자적으로 올리니까 동시성은 해결됐는데, "누가 먼저 요청했는지" 순서를 보장할 수 없었습니다. 선착순인데 순서가 없으면 의미가 없습니다.
결국 Redis Sorted Set + Lua script로 전환했습니다.
ZADD로 timestamp를 score로 넣어서 순서 보장ZCARD로 현재 수를 세서 maxQuantity 초과 시 즉시 거부Redis가 죽으면 어떻게 할지도 고민했습니다. Resilience4j CircuitBreaker를 걸어서 Redis 장애 시 DB Fallback으로 전환하도록 했습니다. DB Fallback은 동시성 제어가 약하지만, 서비스가 완전히 멈추는 것보다는 낫다고 판단했습니다.
Consumer 측에서는
event_handled테이블로 멱등성을 보장했습니다. Kafka 메시지가 중복 전달되더라도 동일한eventId가 이미 처리된 기록이 있으면 건너뜁니다.🏗️ Design Overview
변경 범위
OrderCreatedEvent,OrderExpiredEvent,FavoriteAddedEvent,FavoriteRemovedEvent,PaymentRequestedEvent,PaymentCompletedEvent,PaymentFailedEvent), 이벤트 리스너 4개(FavoriteEventListener,OrderEventListener,PaymentEventListener,OrderExpireScheduler),OutboxRelay,FirstComeCouponService,PaymentFacade리팩토링CatalogEventConsumer,OrderEventConsumer,CouponIssueConsumer),ProductMetrics집계,event_handled멱등성 처리catalog-events,order-events,coupon-issue-requests), JPA test profile 추가주요 컴포넌트 책임
FavoriteEventListenerincreaseLikeCount/decreaseLikeCount) + Outbox INSERT, REQUIRES_NEW 트랜잭션OrderEventListenerPaymentEventListenerOrderExpireSchedulerOrderExpiredEvent발행OutboxRelayFirstComeCouponServiceCatalogEventConsumercatalog-events소비 →ProductMetrics좋아요 수 집계, version 기반 중복 처리 방지OrderEventConsumerorder-events소비 → 멱등성 체크 후 이벤트 타입별 처리CouponIssueConsumercoupon-issue-requests소비 →user_couponINSERT + 발급 요청 상태 변경🔁 Flow Diagram
Outbox → Kafka 전체 흐름
sequenceDiagram participant Facade participant EventPublisher participant Listener as @TransactionalEventListener participant Outbox as Outbox Table participant Relay as OutboxRelay (@Scheduled 1s) participant Kafka participant Consumer as commerce-streamer Consumer participant EventHandled as event_handled Table Facade->>EventPublisher: publishEvent(XxxEvent) Note over Facade: 메인 트랜잭션 커밋 EventPublisher->>Listener: AFTER_COMMIT 트리거 Listener->>Outbox: INSERT (REQUIRES_NEW TX) loop 1초마다 Relay->>Outbox: findByStatusInit() Relay->>Kafka: send().get() (동기 발행) alt 성공 Relay->>Outbox: markPublished() else 실패 Relay->>Outbox: markFailed() end end Kafka->>Consumer: 메시지 수신 (batch) Consumer->>EventHandled: existsById(eventId) 확인 alt 이미 처리됨 Consumer-->>Consumer: skip else 미처리 Consumer->>Consumer: 비즈니스 처리 Consumer->>EventHandled: save(SUCCESS) end Consumer->>Kafka: ack.acknowledge()Payment 이벤트 기반 PG 호출 흐름
sequenceDiagram participant Client participant Facade as PaymentFacade participant PaymentDB as Payment (PENDING) participant Listener as PaymentEventListener participant PG as PG API participant OrderDB as Orders Client->>Facade: createPayment(loginId, cardNo, ...) Facade->>PaymentDB: createPaymentWithSnapshots → PENDING Facade->>Facade: publishEvent(PaymentRequestedEvent) Note over Facade: 트랜잭션 커밋 Facade-->>Client: FindPaymentResDto (PENDING) Note over Listener: AFTER_COMMIT 트리거 (REQUIRES_NEW) Listener->>PG: requestPayment(memberId, pgCommand) alt PG 성공 (transactionKey 존재) PG-->>Listener: transactionKey Listener->>PaymentDB: markRequested(transactionKey) Listener->>OrderDB: updateStatus(PAYMENT_REQUESTED) else PG 실패 Listener->>PaymentDB: handlePgFailure Listener->>OrderDB: updateStatus(PAYMENT_FAILED) end Note over Facade: PG 콜백 or checkPaymentStatus 경로 Facade->>Facade: handleCallback(transactionKey, pgStatus) alt SUCCESS Facade->>PaymentDB: markSuccess Facade->>OrderDB: updateStatus(PAID) Facade->>Facade: publishEvent(PaymentCompletedEvent) Note over Facade: → Outbox → order-events → OrderEventConsumer else FAILED Facade->>PaymentDB: markFailed Facade->>OrderDB: updateStatus(PAYMENT_FAILED) Facade->>PaymentDB: restoreStock Facade->>Facade: publishEvent(PaymentFailedEvent) end Note over Client: 상태 직접 조회 경로 Client->>Facade: checkPaymentStatus(loginId, orderId) Facade->>PG: getPaymentByOrderId PG-->>Facade: PgPaymentStatus Facade->>Facade: handleCallback 재사용선착순 쿠폰 발급 흐름
flowchart TD A[API 요청: 선착순 쿠폰 발급] --> B{CircuitBreaker 상태} B -->|정상| C[Redis Sorted Set + Lua Script 실행] C --> D{실행 결과} D -->|result == -1| E[이미 발급 요청 예외 CONFLICT] D -->|result == -2| F[수량 소진 예외 BAD_REQUEST] D -->|result == 1| G[CouponIssueFacade: 발급 요청 DB 저장] G --> H[Kafka 발행: coupon-issue-requests] H --> I[CouponIssueConsumer 수신] I --> J{event_handled 멱등성 체크} J -->|중복| K[skip] J -->|신규| L[user_coupon INSERT] L --> M[issue_request 상태 변경: ISSUED] M --> N[event_handled save SUCCESS] B -->|OPEN - Redis 장애| O[DB Fallback 실행] O --> P{이미 발급 요청 존재?} P -->|존재| E P -->|없음| Q{발급 수 >= maxQuantity?} Q -->|초과| F Q -->|여유 있음| GReview Point