[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구축#283
[Volume 7] 이벤트 기반 아키텍처 및 Kafka 파이프라인 구축#283plan11plan wants to merge 47 commits intoLoopers-dev-lab:plan11planfrom
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PG 결제 호출만 @TransactionalEventListener(AFTER_COMMIT)으로 TX 밖 분리 - Facade의 processOrder() 흐름 유지 (재고/쿠폰/포인트 직접 호출) - 보상 트랜잭션 로직 PaymentEventHandler에 구현 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
회원가입 성공 시 하나의 트랜잭션 내에서 부가 작업 수행: - 웰컴 포인트 1000원 지급 - SignupCouponPolicy enum 기반 웰컴 쿠폰 생성 및 발급 - 가입 축하 이메일 발송 (FakeEmailSender) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
상품 상세 조회 시 이벤트 기반으로 조회 로그를 기록한다: - ProductViewLogModel: product_views INSERT-only 엔티티 (Source of Truth) - ProductViewedEvent: 조회 이벤트 record - ProductViewCountHandler: AFTER_COMMIT + REQUIRES_NEW, try-catch로 실패 격리 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
비회원도 접근 가능한 API에서 인증 헤더가 있으면 best-effort로 인증: - @OptionalLogin 어노테이션: 인증 실패 시 null 허용 (vs @Login은 예외) - AuthFilter.tryOptionalAuth(): 비인증 URL에서도 헤더 있으면 인증 시도 - ProductFacade.getProductDetail(id, userId): 조회 성공 후 이벤트 발행 - 회원 조회 시 userId 포함, 비회원 시 null Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
도메인 인터페이스만 있고 JPA 구현체가 없어서 빈 주입 실패하던 문제 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthrough쿠폰 발급을 Redis 기반 제한자와 아웃박스/Kafka 비동기 파이프라인으로 전환하고, 상품 좋아요 카운트를 메트릭 읽기 모델로 분리하며, 주문-결제 흐름을 이벤트 기반으로 재구성했다. 다양한 도메인 이벤트와 핸들러, 메트릭 집계 서비스, 모니터링 인프라가 추가되었다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Facade as CouponFacade
participant Limiter as CouponIssueLimiter
participant Outbox as OutboxEventPublisher
participant Relay as OutboxKafkaRelay
participant Kafka as Kafka Broker
participant Consumer as CouponIssueConsumer
participant Service as CouponService
Client->>Facade: issueCoupon(couponId,userId)
Facade->>Limiter: tryIssue(couponId,userId)
alt Redis: SUCCESS
Limiter-->>Facade: SUCCESS
Facade->>Outbox: publish(COUPON_ISSUED, payload)
Outbox-->>Facade: saved
Facade-->>Client: IssuedDetail(PENDING)
else Redis: NOT_FOUND/ALREADY_ISSUED/QUANTITY_EXHAUSTED
Limiter-->>Facade: ERROR
Facade-->>Client: throw CoreException
end
Note over Relay,Kafka: (주기적) Outbox → Kafka 전송
Relay->>Kafka: send(topic, partitionKey, payload)
Kafka-->>Consumer: deliver message
Consumer->>Service: issue(couponId,userId)
Service->>Service: OwnedCouponModel.create/save
sequenceDiagram
participant Client as Client
participant OrderFacade as OrderFacade
participant OrderService as OrderService
participant EventPub as ApplicationEventPublisher/Outbox
participant PaymentHandler as PaymentEventHandler
participant PG as PaymentGateway
participant Kafka as Kafka
Client->>OrderFacade: createOrderWithPayment(userId, criteria)
OrderFacade->>OrderService: processOrder(...)
OrderService-->>OrderFacade: OrderModel
OrderFacade->>EventPub: publish OrderPaymentEvent (outbox)
Note over Kafka: PaymentEventHandler consumes after commit
PaymentHandler->>PG: requestPayment(...)
alt PG 승인
PG-->>PaymentHandler: requested
PaymentHandler->>OrderService: completeOrder(orderId)
OrderService->>EventPub: publish OrderCompletedEvent (outbox)
else PG 실패
PG-->>PaymentHandler: failed
PaymentHandler->>OrderService: cancelByPaymentFailure(orderId)
PaymentHandler->>ProductService: increaseStock(...)
PaymentHandler->>CouponService: restoreByOrderId(...)
PaymentHandler->>UserService: addPoint(...)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 4
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 (3)
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java (1)
86-92:⚠️ Potential issue | 🟠 Major혼합된 트랜잭션 phase로 인한 이벤트 일관성 문제
completeOrder메서드에서 이벤트를 발행하는 패턴이 핸들러마다 서로 다른 트랜잭션 전략을 사용하고 있다:
- OrderKafkaPublisher:
phase = BEFORE_COMMIT- 주문 완료 트랜잭션과 같은 트랜잭션에서 실행되므로, 아웃박스 저장 실패 시 주문 완료 전체가 롤백된다.- OrderPointHandler:
phase = AFTER_COMMIT+propagation = REQUIRES_NEW- 주문 완료 후 별도 트랜잭션에서 실행되므로, 포인트 적립 실패 시 주문은 이미 커밋된 상태다.- OrderNotificationHandler:
phase = AFTER_COMMIT+@Async- 비동기로 실행되므로 주문 완료와 독립적이다.이는 Kafka 메시지 발행(아웃박스)이 실패하면 주문 완료가 불가능하지만, 포인트 적립이나 알림 발송이 실패해도 주문은 완료되는 비대칭적 실패 모드를 만든다. 결과적으로 주문 상태와 외부 이벤트 처리 결과 간의 정합성이 보장되지 않는다.
개선 방안: 주문 완료와 후속 이벤트 처리의 일관성 전략을 명확히 한다.
- 모든 핸들러를
AFTER_COMMIT으로 통일하거나- 아웃박스 패턴의 의도를 명확히 하되, 포인트 적립도 같은 트랜잭션(BEFORE_COMMIT) 또는 동기 처리로 변경하거나
- 각 핸들러의 실패 시 재시도/보상 전략을 명시하는 문서를 추가한다.
운영 중 주문은 완료되었으나 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/order/OrderService.java` around lines 86 - 92, completeOrder publishes OrderCompletedEvent inside a `@Transactional` method but the event handlers use mixed transaction phases (OrderKafkaPublisher: BEFORE_COMMIT, OrderPointHandler: AFTER_COMMIT + REQUIRES_NEW, OrderNotificationHandler: AFTER_COMMIT + `@Async`) causing inconsistent failure modes; pick a consistency strategy and apply it across handlers: either make all handlers AFTER_COMMIT (so events are only processed after the order transaction commits) or move side-effects that must be atomic with the order into the same transaction/outbox (change OrderKafkaPublisher to persist outbox in the transaction and defer actual send to AFTER_COMMIT), or convert OrderPointHandler to BEFORE_COMMIT or synchronous processing with explicit retry/compensation; update the annotations on OrderKafkaPublisher, OrderPointHandler, and OrderNotificationHandler accordingly and add retry/compensation documentation and monitoring hooks for unprocessed events.apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)
30-55:⚠️ Potential issue | 🟠 Major
paymentGateway.requestPayment()호출 시 예외 처리 필수
createPayment메서드의 라인 36-43에서paymentGateway.requestPayment()호출이 예외를 던질 경우, 현재 코드는 어떤 처리도 하지 않는다. 이 경우 결제 레코드가 PENDING 상태로 남아있어 운영상 심각한 문제를 초래한다.타임아웃 설정은 application.yml에 이미 구성되어 있다(pg-payment: 1000ms 연결 + 1450ms 읽기, pg-payment2: 1000ms 연결 + 5000ms 읽기). 다만, 외부 서비스 호출이 타임아웃되거나 다른 예외를 발생시킬 때는 결제 실패 상태로 처리해야 한다.
현재 코드는 정상 응답(
pgResult.requested() == false)에 대해서만failById()처리를 하고 있다. 예외 발생 시나리오가 누락되어 있으므로,handleCallback()메서드(라인 76-87)의 패턴처럼 try-catch로 감싸거나 메서드에@Transactional을 추가하여 예외 발생 시 자동으로 결제 실패 처리되도록 개선해야 한다.🤖 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 30 - 55, The createPayment method does not handle exceptions from paymentGateway.requestPayment, leaving the PaymentModel in PENDING if the call throws; wrap the paymentGateway.requestPayment(...) call in a try-catch (or annotate createPayment with `@Transactional` and handle exceptions similarly) and on any exception call paymentService.failById(payment.getId()) before rethrowing a CoreException (e.g., PaymentErrorCode.PG_SERVICE_UNAVAILABLE with the cause) so failures mirror the existing handleCallback(...) failure handling; reference createPayment, paymentGateway.requestPayment, paymentService.failById, and handleCallback to apply the same pattern.apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/CouponV1ApiE2ETest.java (1)
161-182:⚠️ Potential issue | 🟡 MinormyOwnedCoupons_returnsList 테스트의 기대값을 수정하다.
Arrange 단계에서 쿠폰 1개만 생성하지만, 라인 178에서
hasSize(2)를 기대한다. 테스트는 매 실행마다@BeforeEach와@AfterEach로데이터를 정리하므로 다른 데이터 소스가 없다. 기대값이 생성된 데이터와 불일치하므로 수정이 필요하다.기대값 수정
// assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items()).hasSize(1), () -> assertThat(response.getBody().data().items()) .anyMatch(item -> item.couponName().equals("테스트 쿠폰") && item.status().equals("AVAILABLE")));🤖 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/interfaces/coupon/CouponV1ApiE2ETest.java` around lines 161 - 182, The test myOwnedCoupons_returnsList in CouponV1ApiE2ETest expects two items but only creates one coupon/owned coupon in the arrange phase; update the assertion in myOwnedCoupons_returnsList to expect one item (e.g., change the hasSize(2) expectation to hasSize(1)) so the assertion matches the single OwnedCouponModel created via OwnedCouponModel.create(saveCoupon(...), userId).
🟡 Minor comments (18)
docker/monitoring-compose.yml-27-29 (1)
27-29:⚠️ Potential issue | 🟡 MinorGrafana 관리자 자격 증명 하드코딩.
infra-compose.yml과 동일한 보안 이슈가 존재한다. 환경 변수로 분리하거나, 두 파일 간 공통 설정을 통합하여 일관성을 유지해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/monitoring-compose.yml` around lines 27 - 29, 현재 docker/monitoring-compose.yml에 GF_SECURITY_ADMIN_USER 및 GF_SECURITY_ADMIN_PASSWORD가 하드코딩되어 있어 보안 문제가 있으니 이 값을 직접 파일에 두지 말고 환경변수로 분리하거나 공통 설정으로 통합하세요; replace the literals with compose variable references like ${GRAFANA_ADMIN_USER} and ${GRAFANA_ADMIN_PASSWORD} (or load from a shared .env used by both monitoring-compose.yml and infra-compose.yml) and ensure those env names are defined consistently where infra-compose.yml currently sources credentials so GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD usage remains identical across files.docker/infra-compose.yml-131-143 (1)
131-143:⚠️ Potential issue | 🟡 MinorGrafana 이미지 버전 미지정 및 관리자 자격 증명 하드코딩 문제.
두 가지 운영/보안 이슈가 있다:
이미지 버전 미지정:
grafana/grafana이미지에 버전 태그가 없어 빌드 시점마다 다른 버전이 적용될 수 있다.자격 증명 하드코딩:
GF_SECURITY_ADMIN_USER=admin,GF_SECURITY_ADMIN_PASSWORD=admin이 평문으로 노출되어 있다. 이 compose 파일이 버전 관리에 포함되면 자격 증명이 유출될 수 있다. 로컬 환경이라도.env파일 또는 Docker secrets를 통해 분리하는 것이 운영 모범 사례다.🛡️ 수정안
grafana: - image: grafana/grafana + image: grafana/grafana:10.4.0 ports: - "3000:3000" volumes: - ./grafana/provisioning:/etc/grafana/provisioning - ./grafana/dashboards:/var/lib/grafana/dashboards environment: - - GF_SECURITY_ADMIN_USER=admin - - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} depends_on: - prometheus - influxdb추가 테스트:
.env.example파일을 생성하여 환경 변수 설정 방법을 문서화해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/infra-compose.yml` around lines 131 - 143, The grafana service uses an unpinned image and hardcoded admin credentials: change the image reference in the grafana service from "grafana/grafana" to a pinned tag (e.g., grafana/grafana:<version>) and remove GF_SECURITY_ADMIN_USER and GF_SECURITY_ADMIN_PASSWORD plaintext entries; instead read them from environment variables or Docker secrets (use the GF_SECURITY_ADMIN_USER and GF_SECURITY_ADMIN_PASSWORD variable names already present) by referencing values from an .env file or Docker secrets so credentials are not checked into git, and add a .env.example documenting the required variables.docker/grafana/dashboards/k6-load-testing.json-2-17 (1)
2-17:⚠️ Potential issue | 🟡 Minor
__inputs와 패널의 데이터소스 참조값이 불일치하므로 운영 환경에서 대시보드의 이식성을 해친다.
__inputs에서DS_K6변수를 정의하고 있으나, 패널들은 전체적으로"InfluxDB-k6"을 직접 하드코딩하고 있다. 현재 환경의datasource.yml에 동일한 이름으로 정의되어 있어 기능적으로는 정상 동작하지만, 운영 관점에서 다음의 문제가 발생한다.
대시보드 가져오기 시 혼동: 누군가 이 대시보드를 다른 Grafana 인스턴스에서 가져올 때
__inputs에 의해 데이터소스 선택 프롬프트가 나타나지만, 실제로는 무시되고"InfluxDB-k6"검색으로 진행된다. 해당 이름의 데이터소스가 없으면 대시보드는 데이터를 표시하지 못한다.이식성 부족: 개발/스테이징 환경에서 데이터소스 이름이 다를 경우(예:
InfluxDB등), 모든 패널을 수동으로 수정해야 한다.수정안:
__inputs의 변수를 실제로 활용하도록 모든 패널의datasource값을"${DS_K6}"형태로 변경하거나,__inputs를 제거하고 문서화된 데이터소스 이름으로 명시한다.
__requires의 Grafana 4.4.1은 커뮤니티 원본(gnetId: 2587, 2017년)의 메타데이터이며, 현재 최신 Grafana에서도 호환성 문제가 없다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/grafana/dashboards/k6-load-testing.json` around lines 2 - 17, The dashboard defines a datasource variable "__inputs" with name "DS_K6" but panels hard-code "InfluxDB-k6", breaking portability; update every panel's "datasource" field that currently uses "InfluxDB-k6" to use the input variable syntax "${DS_K6}" so panels honor the "__inputs" selection (alternatively remove "__inputs" if you intend a fixed name), and leave "__requires" as-is.apps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.java-5-9 (1)
5-9:⚠️ Potential issue | 🟡 Minor이메일 PII 로깅 방지 확인 필요
수정안:
- 로깅 시 이벤트 객체 전체 출력 금지 (
toString()오버라이드로 마스킹)- 또는 이메일 대신
hasEmailboolean 플래그만 포함♻️ 마스킹된 toString 오버라이드 예시
public record UserSignedUpEvent(Long userId, String name, String email) { public static UserSignedUpEvent from(UserModel model) { return new UserSignedUpEvent(model.getId(), model.getName(), model.getEmail()); } `@Override` public String toString() { return "UserSignedUpEvent[userId=" + userId + ", name=" + name + ", email=***]"; } }추가 테스트:
- 로그 출력 테스트에서 이메일 평문 노출 여부 확인
As per coding guidelines: "로깅 시 민감정보 노출 가능성을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.java` around lines 5 - 9, The UserSignedUpEvent record exposes a plaintext email (email) which can be serialized into logs; update UserSignedUpEvent to avoid logging raw PII by either (A) overriding toString() on UserSignedUpEvent to return a masked representation (include userId and name but replace email with a fixed token like "***" or "[REDACTED]") or (B) change the event payload to drop the email field and add a boolean hasEmail flag (keep the static from(UserModel) factory updated to set hasEmail = model.getEmail() != null/empty); ensure the from(UserModel) method and any consumers use the new representation and add/adjust a unit test to assert that toString() (or serialized output) contains no plaintext email.apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.java-5-11 (1)
5-11:⚠️ Potential issue | 🟡 Minor이벤트 생성 단계에서 cardNo 마스킹 및 보상 트랜잭션 실패 시 재시도 메커니즘 추가 필요
이벤트 내 민감정보 노출 위험:
OrderPaymentEvent가 생성될 때cardNo가 마스킹되지 않은 상태로 전달된다. 이벤트 자체가 로깅되거나 직렬화될 때 원문 카드번호가 노출될 수 있다. 현재PaymentEventHandler에서 이후에 마스킹하는 것은 이벤트 전송 단계에서는 방어 효과가 없다.OrderFacade에서 이벤트 생성 시점에 사전에cardNo를 마스킹하여 전달하도록 수정해야 한다.보상 트랜잭션 실패 시 복구 전략 부족:
PaymentEventHandler의compensateOrder()메서드는 구현되어 있으나, 보상 트랜잭션 자체가 실패할 경우 재시도 메커니즘이 없다. 이는 결제 생성 실패 시 주문 취소 로직마저 실패하면 데이터 불일치가 발생하여 운영상 수동 개입이 필요해진다. 보상 작업 실패에 대한 재시도 정책(예:@Retryable) 또는 별도의 보상 큐 처리 메커니즘을 도입하여 안정성을 확보해야 한다.🤖 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/event/OrderPaymentEvent.java` around lines 5 - 11, Ensure card numbers are masked when the event is created by changing the producer path to mask cardNo before constructing OrderPaymentEvent (update the code that calls new OrderPaymentEvent(...) in OrderFacade to replace cardNo with a masked value, e.g., keep last 4 digits only), and add a retry/compensation strategy for failed compensations by enhancing PaymentEventHandler.compensateOrder() to perform retry logic (use Spring `@Retryable` or enqueue failed compensation attempts to a dedicated compensation queue with backoff and limited retries) so compensations are retried on transient failures.apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java-26-30 (1)
26-30:⚠️ Potential issue | 🟡 Minor
payment.getCardType()반환값에 대한 null 안전성 보장이 필요하다.
payment.getCardType().name()호출에서 NPE가 발생할 수 있다. PaymentModel의card_type컬럼이nullable = false제약을 가지더라도, 데이터베이스 제약은 런타임에서 Java null 참조를 방지하지 않는다. Enum 필드를 안전하게 접근하려면 Optional을 사용하거나 명시적 null 검사를 추가해야 한다.추가로
pending()메서드에서 4개의 null 값을 명시적으로 전달하는 것도 개선이 필요하다. Optional 필드를 사용하거나 별도의 불완전한 상태 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/application/order/dto/OrderResult.java` around lines 26 - 30, The call payment.getCardType().name() is not null-safe and can throw NPE; update the code that constructs OrderResult (where payment.getId(), payment.getStatus().name(), payment.getCardType().name(), payment.getMaskedCardNo(), order.getCreatedAt() are passed) to null-check or wrap card type access with Optional (e.g., Optional.ofNullable(payment.getCardType()).map(Enum::name).orElse(null) or a default like "UNKNOWN") before calling name(); also revise the pending() factory (which currently passes four explicit nulls) to either use Optional-typed fields in OrderResult or create a separate partial/incomplete DTO for the pending state so you don’t pass raw nulls and improve readability and safety.apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueLimiter.java-7-7 (1)
7-7:⚠️ Potential issue | 🟡 Minorrollback 실패 시 원본 예외를 보존해야 한다.
CouponFacade의 catch 블록에서 rollback이 예외를 던지면 원본 예외가 손실된다. 현재 코드는 outboxEventPublisher 실패로 인한 예외를 rollback 실패 예외로 덮어쓰게 되어, 실제 문제 원인을 파악하기 어려워진다. rollback 시 발생한 예외는
addSuppressed()로 보존하거나, rollback을 별도 try-catch로 감싸서 처리해야 한다.try { outboxEventPublisher.publish(...); } catch (Exception e) { try { couponIssueLimiter.rollback(couponId, userId); } catch (Exception rollbackError) { e.addSuppressed(rollbackError); } throw e; }🤖 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/CouponIssueLimiter.java` at line 7, The catch in CouponFacade that calls CouponIssueLimiter.rollback currently lets rollback exceptions overwrite the original outboxEventPublisher.publish exception; wrap the call to rollback(couponId, userId) in its own try-catch and on rollback failure call e.addSuppressed(rollbackError) (where e is the original caught Exception) before rethrowing e, or otherwise ensure the original exception is rethrown with the rollback error added via addSuppressed so the original cause is preserved; locate the catch handling outboxEventPublisher.publish and modify it to use try { couponIssueLimiter.rollback(...); } catch (Exception rollbackError) { e.addSuppressed(rollbackError); } throw e.apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponJpaRepository.java-35-37 (1)
35-37:⚠️ Potential issue | 🟡 Minor빈 리스트 전달 시 SQL 오류 발생 가능성 - 방어적 검증 추가 필요
호출 경로(CouponFacade.getCoupons → CouponService.countIssuedCoupons → OwnedCouponRepository.countByCouponIds)에서 빈 couponIds 리스트가 전달될 수 있다. 페이징 조회 결과가 비어있을 경우 이 시나리오가 발생한다. 현재 MySQL의 IN 절은 빈 리스트를 처리할 때 SQL 문법 오류를 발생시킨다.
운영 관점에서 빈 페이지 조회는 정상적인 흐름이므로, 이를 안전하게 처리해야 한다. 리포지토리 메서드 시작 부분에서
couponIds.isEmpty() ? Collections.emptyList() : ...형태의 빈 리스트 검증을 추가하고, 이 시나리오를 커버하는 통합 테스트를 작성한다.🤖 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/OwnedCouponJpaRepository.java` around lines 35 - 37, OwnedCouponJpaRepository.countByCouponIdIn can be invoked with an empty couponIds list (via CouponFacade.getCoupons → CouponService.countIssuedCoupons), which causes a SQL syntax error for MySQL IN(). Modify the repository call path to defensively handle empty input: in OwnedCouponJpaRepository.countByCouponIdIn (or the calling method in CouponService), if couponIds.isEmpty() return Collections.emptyList() immediately instead of issuing the query; keep the existing query for non-empty lists. Add an integration test that simulates a paged result producing an empty couponIds list and asserts the method returns an empty list without throwing SQL exceptions.apps/commerce-api/src/main/java/com/loopers/infrastructure/notification/FakeEmailSender.java-7-14 (1)
7-14:⚠️ Potential issue | 🟡 Minor민감정보 로깅 문제를 해결하기 위해 마스킹 처리 및 개발 환경 격리가 필요하다.
이메일 주소(
to) 및 본문(body)이 로그에 기록되고 있다. 이는 개인정보가 포함될 수 있는 로그가 운영 환경에 남아 규제 요건(개인정보보호법, GDPR 등) 위반으로 이어질 수 있다.FakeEmailSender는 개발 전용 구현이므로
@Profile("dev")또는@ConditionalOnProperty로 개발 환경에만 활성화되어야 한다. 또한 민감정보는 로깅할 필요가 없으므로 마스킹 처리하거나 DEBUG 레벨로 분리하여 운영 환경에서 기록되지 않도록 설정해야 한다.♻️ 프로파일 기반 격리 및 민감정보 마스킹 제안
`@Slf4j` -@Component +@Component +@Profile("dev") public class FakeEmailSender implements NotificationSender { `@Override` public void send(String to, String subject, String body) { - log.info("[FakeEmail] to={}, subject={}, body={}", to, subject, body); + log.info("[FakeEmail] to={}, subject={}", maskEmail(to), subject); + log.debug("[FakeEmail] body={}", body); + } + + private String maskEmail(String email) { + if (email == null || !email.contains("@")) return "***"; + int atIndex = email.indexOf("@"); + return email.substring(0, Math.min(2, atIndex)) + "***" + email.substring(atIndex); } }🤖 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/notification/FakeEmailSender.java` around lines 7 - 14, FakeEmailSender currently logs sensitive data in send(String to, String subject, String body) and is always active; restrict it to dev only by annotating the FakeEmailSender class with a profile/conditional (e.g., `@Profile`("dev") or `@ConditionalOnProperty`) and change logging to avoid plaintext PII by masking the recipient and body (e.g., show only hashed/partially masked email or length) or only log subject and a non-sensitive marker; also demote the log level from INFO to DEBUG for any remaining non-sensitive traces so production won't record them.apps/commerce-api/src/main/resources/application.yml-28-35 (1)
28-35:⚠️ Potential issue | 🟡 Minor메일 설정의 기본값이 빈 문자열이면 런타임 실패 가능성이 있다.
MAIL_USERNAME과MAIL_PASSWORD의 기본값이 빈 문자열로 설정되어 있다. SMTP 인증이 활성화된 상태에서 빈 자격 증명으로 연결 시도 시 예외가 발생한다.권장 사항:
- 필수 환경 변수 누락 시 애플리케이션 시작 단계에서 실패하도록 설정하거나
- 메일 발송 기능을 조건부로 활성화하는 프로파일 분리 적용
🤖 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 28 - 35, The mail config sets MAIL_USERNAME and MAIL_PASSWORD defaulting to empty strings which will cause runtime failures when mail.smtp.auth is true; update startup behavior so missing credentials fail fast or disable mail sending: either require non-empty MAIL_USERNAME/MAIL_PASSWORD by throwing during application bootstrap (validate the mail.* config and abort startup if MAIL_USERNAME or MAIL_PASSWORD are blank when mail.smtp.auth is true) or move mail settings into a separate profile and only enable the mail sender when that profile is active (condition mail.smtp.auth and presence of MAIL_USERNAME/MAIL_PASSWORD before instantiating the mail sender/JavaMailSender bean).apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.java-61-67 (1)
61-67:⚠️ Potential issue | 🟡 Minor빈 리스트 입력 및 타입 안전성 검증이 필요하다.
couponIds가 빈 리스트일 때IN절이 빈 상태로 실행되면 DB에 따라 예외가 발생할 수 있다.Object[]에서Long으로 직접 캐스팅 시 JPQL/Hibernate 버전에 따라Integer가 반환될 수 있어ClassCastException위험이 있다.🛡️ 방어적 코드 제안
`@Override` public Map<Long, Long> countByCouponIds(List<Long> couponIds) { + if (couponIds == null || couponIds.isEmpty()) { + return Map.of(); + } return ownedCouponJpaRepository.countByCouponIdIn(couponIds).stream() .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1])); + row -> ((Number) row[0]).longValue(), + row -> ((Number) row[1]).longValue())); }🤖 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/OwnedCouponRepositoryImpl.java` around lines 61 - 67, countByCouponIds에서 빈 couponIds를 바로 DB에 전달하면 일부 DB에서 IN ()로 예외가 발생하고 Object[]에서 Long으로 직접 캐스팅하면 Integer 반환 시 ClassCastException이 발생할 수 있습니다; 우선 couponIds가 null/빈 경우 즉시 빈 Map을 반환하도록 방어적 체크를 추가하고, ownedCouponJpaRepository.countByCouponIdIn 호출 결과를 처리할 때 row[0]과 row[1]을 (Number)로 받는 방식으로 안전하게 변환해 Number.longValue()로 Long을 생성하여 타입 안전성을 보장하세요 (대상 식별자: 메서드 countByCouponIds, 호출 ownedCouponJpaRepository.countByCouponIdIn).apps/commerce-api/src/main/java/com/loopers/application/order/OrderPointHandler.java-24-24 (1)
24-24:⚠️ Potential issue | 🟡 Minor부동소수점 연산으로 포인트 계산 시 정밀도 손실이 발생할 수 있다.
event.totalPrice() * 0.02는 부동소수점 연산으로, 특정 금액에서 예상치 못한 결과가 발생할 수 있다. 예를 들어totalPrice = 333일 때333 * 0.02 = 6.66이지만, 부동소수점 오차로6.659999...가 될 수 있어(long)캐스팅 시6이 된다.정수 연산으로 변경하면 이 문제를 방지할 수 있다.
🐛 정수 연산으로 변경
-long pointAmount = (long) (event.totalPrice() * 0.02); +long pointAmount = event.totalPrice() * 2 / 100;🤖 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/OrderPointHandler.java` at line 24, The current point calculation in OrderPointHandler uses floating-point math (event.totalPrice() * 0.02) which can lose precision; replace it with integer-safe/BigDecimal arithmetic: construct a BigDecimal from event.totalPrice(), multiply by 2 and divide by 100 with RoundingMode.DOWN (or compute using cents as integers) and then extract the long for pointAmount—update the assignment to pointAmount accordingly (e.g., use BigDecimal total = BigDecimal.valueOf(event.totalPrice()); pointAmount = total.multiply(BigDecimal.valueOf(2)).divide(BigDecimal.valueOf(100), RoundingMode.DOWN).longValue();).apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogModel.java-43-54 (1)
43-54:⚠️ Potential issue | 🟡 Minor
viewedAtnull 검증이 누락되어 DB 제약 위반 예외가 발생할 수 있다.
viewedAt컬럼이nullable = false로 선언되어 있으나,create()팩토리 메서드에서 해당 파라미터의 null 검증이 없다. null이 전달되면 JPA persist 시점에DataIntegrityViolationException이 발생하여,CoreException기반 통합 에러 처리 패턴을 우회하게 된다.🐛 viewedAt 검증 추가
public static ProductViewLogModel create(Long productId, Long userId, ZonedDateTime viewedAt) { validateProductId(productId); + validateViewedAt(viewedAt); return new ProductViewLogModel(productId, userId, viewedAt); } +private static void validateViewedAt(ZonedDateTime viewedAt) { + if (viewedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "조회 시각은 필수값입니다."); + } +}🤖 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/product/ProductViewLogModel.java` around lines 43 - 54, The create factory (ProductViewLogModel.create) validates productId but not the viewedAt parameter, risking a DB constraint error; add a null check for viewedAt (e.g., implement a private static validateViewedAt(ZonedDateTime viewedAt) invoked from create) and throw the same CoreException(ErrorType.BAD_REQUEST, "...") with an appropriate message when viewedAt is null so persistence errors are prevented and the unified error handling is preserved.modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaEventMessage.java-1-1 (1)
1-1:⚠️ Potential issue | 🟡 Minor패키지명 오타 수정 필수
com.loopers.confg.kafka→com.loopers.config.kafka로 변경해야 한다.패키지명 오타(
confg)로 인해 코드 검색, 패키지 구조 파악, 그리고 향후 리팩토링 시 혼란과 유지보수 비용이 증가한다. 현재 3개 파일(KafkaConfig.java,KafkaEventMessage.java,KafkaTopics.java)과 이를 임포트하는 7개 파일에 영향을 미치므로 조직적인 리팩토링이 필요하다.수정 시 확인 사항:
- 패키지명 변경 후 모든 임포트 구문이 자동으로 업데이트되는지 확인
- commerce-api, commerce-streamer 모듈의 모든 파일이 정상적으로 컴파일되는지 검증
- 기존 임포트 경로를 사용하는 모든 코드가 제대로 참조되는지 확인
🤖 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/KafkaEventMessage.java` at line 1, 패키지명 오타(com.loopers.confg.kafka)로 인해 참조가 깨지는 문제: 프로젝트 전반에서 패키지 선언과 import를 com.loopers.config.kafka로 일관되게 변경하세요 — KafkaEventMessage, KafkaConfig, KafkaTopics 클래스들의 package 문구를 수정하고 이들 클래스를 임포트하는 모든 파일의 import 경로도 갱신한 뒤 commerce-api 및 commerce-streamer 모듈에서 컴파일/빌드 확인을 수행해 누락된 참조가 없는지 검증하세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java-66-71 (1)
66-71:⚠️ Potential issue | 🟡 Minor
findLikeCountsByProductIds의 빈 리스트 입력과 타입 안전성 문제가 있다.
productIds가 빈 리스트일 경우IN ()구문이 되어 SQL 오류가 발생할 수 있다.List<Object[]>반환은 타입 안전하지 않아 호출부에서 캐스팅 오류 위험이 있다.🛡️ 빈 리스트 방어 및 DTO 프로젝션 제안
// 호출부에서 빈 리스트 체크 if (productIds.isEmpty()) { return Map.of(); } // 또는 DTO projection 사용 public interface LikeCountProjection { Long getProductId(); Long getLikeCount(); } `@Query`(value = "SELECT p.id AS productId, COALESCE(pm.like_count, 0) AS likeCount" + " FROM products p" + " LEFT JOIN product_metrics pm ON p.id = pm.product_id" + " WHERE p.id IN :productIds", nativeQuery = true) List<LikeCountProjection> findLikeCountsByProductIds(`@Param`("productIds") List<Long> productIds);🤖 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/product/ProductJpaRepository.java` around lines 66 - 71, Guard against an empty productIds list and switch to a type-safe projection: update findLikeCountsByProductIds to accept productIds but first handle an empty list (return empty map/collection from callers or add a guard in the repository layer), and change the repository method return type from List<Object[]> to a projection interface (e.g., LikeCountProjection with getProductId() and getLikeCount()) so the `@Query` (findLikeCountsByProductIds) can return List<LikeCountProjection> instead of raw Object[]; update calling code to consume the projection or the empty result accordingly.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java-75-77 (1)
75-77:⚠️ Potential issue | 🟡 Minor
addLikeCount(long delta)가 음수 결과를 허용한다.unlike 이벤트 처리 시
delta가 음수로 전달될 수 있다. 현재 구현은likeCount가 음수가 되는 것을 방지하지 않는다. 데이터 정합성을 위해 최소 0 이상을 보장해야 한다.🛡️ 음수 방지 로직 추가
public void addLikeCount(long delta) { - this.likeCount += delta; + this.likeCount = Math.max(0, this.likeCount + delta); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java` around lines 75 - 77, The addLikeCount(long delta) currently allows likeCount to go negative; update ProductMetricsEntity.addLikeCount to compute a newCount = this.likeCount + delta and then set this.likeCount = Math.max(0L, newCount) so the stored value never drops below zero (use long/0L). Ensure you reference the existing addLikeCount method when making the change so the clamping behavior is applied where like/unlike events are processed.apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java-229-257 (1)
229-257:⚠️ Potential issue | 🟡 Minor
CouponIssueResult.NOT_FOUND케이스에 대한 테스트가 누락되었다.
CouponFacade.issueCoupon()메서드는NOT_FOUND결과를 명시적으로 처리하고 있으나(lines 74-76), 테스트 파일에는QUANTITY_EXHAUSTED와ALREADY_ISSUED케이스만 존재한다. Redis 캐시에서 쿠폰 템플릿이 등록되지 않은 상태일 때의 동작을 검증하지 않으면 운영 환경에서 데이터 불일치 상황을 감지할 수 없다.다음 테스트를 추가하여 NOT_FOUND 예외 흐름을 검증하기를 권고한다:
- 쿠폰이 Redis에 없을 때
CouponErrorCode.NOT_FOUND예외 발생- 이 경우 Service 메서드가 호출되지 않음을 확인
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java` around lines 229 - 257, Add a test for the CouponIssueResult.NOT_FOUND path: stub couponIssueLimiter.tryIssue(1L, <someCouponId>) to return CouponIssueResult.NOT_FOUND, then assert that calling CouponFacade.issueCoupon(1L, <sameCouponId>) throws a CoreException whose getErrorCode() equals CouponErrorCode.NOT_FOUND, and verify couponService.issue(...) is never invoked; place this alongside the existing issueCoupon_whenQuantityExhausted and issueCoupon_whenAlreadyIssued tests to cover the NOT_FOUND branch handled in CouponFacade.issueCoupon.apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java-117-118 (1)
117-118:⚠️ Potential issue | 🟡 Minor스텁 데이터가 실제 상품 ID와 명시적으로 대응되지 않는다.
ProductModel.create()호출 후 상품이 영속화되지 않으면 ID는 BaseEntity의 기본값0L을 유지한다. 현재Map.of(0L, 3L)스텁은 암묵적으로 ID 0L인 두 상품에 모두 적용되는데, 이는 명확하지 않으며 향후 엔티티 구조 변경 시 취약해진다. 192-193줄의 정확한 패턴Map.of(product.getId(), 10L)을 따라 각 상품의 실제 ID로 스텁을 설정해야 한다.스텁 개선 제안
when(productService.getLikeCountsByProductIds(anyList())) - .thenReturn(Map.of(0L, 3L)); + .thenReturn(Map.of(product1.getId(), 3L, product2.getId(), 0L));🤖 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/product/ProductFacadeTest.java` around lines 117 - 118, 테스트의 스텁이 하드코드된 0L ID를 사용해 실제 상품 인스턴스와 매칭되지 않으니 ProductFacadeTest에서 product 생성 후 해당 인스턴스의 실제 ID를 사용하도록 수정하세요: 호출부는 ProductModel.create()로 생성한 각 상품의 getId() 값을 참조해 productService.getLikeCountsByProductIds(anyList())에 반환할 맵을 Map.of(product.getId(), expectedCount) 형태로 설정하고(예: 기존의 Map.of(0L, 3L) 대신 각 product.getId()를 키로 사용), anyList() 매처는 그대로 유지해 테스트가 생성된 엔티티 ID와 일치하도록 만드세요.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2190d363-4ae9-497a-a312-d9ae755e285a
⛔ Files ignored due to path filters (2)
docs/round7/선착순쿠폰/ARCHITECTURE.mdis excluded by!**/*.mdand included by**docs/round7/선착순쿠폰/test-result/AtomicInteger와AtomicUpdate/AtomicInteger와AtomicUpdate.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (136)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponResult.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/event/CouponIssuedMessage.javaapps/commerce-api/src/main/java/com/loopers/application/notification/OrderNotificationHandler.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderPointHandler.javaapps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.javaapps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.javaapps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaymentEvent.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductViewCountHandler.javaapps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.javaapps/commerce-api/src/main/java/com/loopers/application/user/SignupBenefitHandler.javaapps/commerce-api/src/main/java/com/loopers/application/user/SignupNotificationHandler.javaapps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueLimiter.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueResult.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/OwnedCouponModel.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/OwnedCouponRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/coupon/SignupCouponPolicy.javaapps/commerce-api/src/main/java/com/loopers/domain/notification/NotificationSender.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.javaapps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogModel.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductLikedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductUnlikedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/user/UserService.javaapps/commerce-api/src/main/java/com/loopers/domain/user/event/UserSignedUpEvent.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRedisIssueLimiter.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/BulkDataGeneratorService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/datagenerator/DataGeneratorRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsReadJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/notification/FakeEmailSender.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/notification/SmtpEmailSender.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaConfig.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaRelay.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.javaapps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.javaapps/commerce-api/src/main/java/com/loopers/interfaces/auth/OptionalLogin.javaapps/commerce-api/src/main/java/com/loopers/interfaces/coupon/CouponLoadTestController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/coupon/dto/AdminCouponV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.javaapps/commerce-api/src/main/java/com/loopers/interfaces/payment/PaymentV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/payment/dto/PaymentRequest.javaapps/commerce-api/src/main/java/com/loopers/interfaces/payment/dto/PaymentResponse.javaapps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-api/src/main/resources/scripts/coupon-issue.luaapps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueConcurrencyTest.javaapps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/order/OrderIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/order/OrderPointHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeViewEventTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductViewCountHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/application/user/SignupBenefitHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/application/user/SignupNotificationHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.javaapps/commerce-api/src/test/java/com/loopers/concurrency/OwnedCouponUseConcurrencyTest.javaapps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.javaapps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/coupon/FakeOwnedCouponRepository.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductLikeRepository.javaapps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductLikeServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductViewLogModelTest.javaapps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.javaapps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.javaapps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/coupon/AdminCouponV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/coupon/CouponV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderCouponV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledEntity.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.javaapps/commerce-streamer/src/main/resources/application.ymldocker/grafana/dashboards/k6-load-testing.jsondocker/grafana/provisioning/dashboards/dashboard.ymldocker/grafana/provisioning/datasources/datasource.ymldocker/infra-compose.ymldocker/monitoring-compose.ymlmodules/jpa/src/main/resources/jpa.ymlmodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javamodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaEventMessage.javamodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.javamodules/kafka/src/main/resources/kafka.yml
💤 Files with no reviewable changes (8)
- apps/commerce-api/src/test/java/com/loopers/application/order/OrderIntegrationTest.java
- apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderCouponV1ApiE2ETest.java
- apps/commerce-api/src/test/java/com/loopers/concurrency/OwnedCouponUseConcurrencyTest.java
- apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
- apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java
- apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
- apps/commerce-api/src/test/java/com/loopers/interfaces/coupon/AdminCouponV1ApiE2ETest.java
- apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java
| PgPaymentResult pgResult = paymentGateway.requestPayment( | ||
| new PgPaymentRequest( | ||
| String.format("%06d", event.orderId()), | ||
| event.cardType().name(), | ||
| event.cardNo(), | ||
| event.totalPrice(), | ||
| CALLBACK_URL, | ||
| String.valueOf(event.userId()))); | ||
|
|
||
| if (pgResult.requested()) { | ||
| paymentService.updateRequested(payment.getId(), pgResult.transactionKey()); | ||
| orderService.completeOrder(event.orderId()); | ||
| } else { | ||
| paymentService.failById(payment.getId()); | ||
| compensateOrder(event); | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("결제 처리 실패 — orderId={}", event.orderId(), e); | ||
| compensateOrder(event); | ||
| } | ||
| } | ||
|
|
||
| private void compensateOrder(OrderPaymentEvent event) { | ||
| try { | ||
| OrderInfo.PaymentFailureCancellation cancellation = | ||
| orderService.cancelByPaymentFailure(event.orderId()); | ||
|
|
||
| for (OrderInfo.PaymentFailureCancellation.CancelledItem item : cancellation.items()) { | ||
| productService.increaseStock(item.productId(), item.quantity()); | ||
| } | ||
| couponService.restoreByOrderId(event.orderId()); | ||
| userService.addPoint(cancellation.userId(), cancellation.totalPrice()); | ||
| } catch (Exception e) { | ||
| log.error("보상 트랜잭션 실패 — orderId={}", event.orderId(), e); | ||
| } |
There was a problem hiding this comment.
PG 예외를 모두 즉시 보상 처리하면 미확정 승인 건과 부분 반영 상태를 만든다.
운영에서 requestPayment()의 타임아웃이나 연결 끊김은 "실패"가 아니라 "결과 미확정"일 수 있는데, 현재 코드는 이를 바로 주문 취소·재고 복구로 보내고 있다. 또한 updateRequested, completeOrder, 보상 로직이 각각 분리돼 있어 중간 실패 시 결제는 요청됨이고 주문은 취소되는 식의 정합성 깨짐이 남는다. 외부 PG 호출 결과를 실패/미확정/성공으로 나누고, 로컬 상태 변경과 보상은 별도 application service의 단일 트랜잭션 및 재시도 가능한 작업으로 관리해야 한다. 추가 테스트로는 PG가 승인 후 응답만 타임아웃되는 경우와 updateRequested() 이후 completeOrder()가 실패하는 경우를 각각 만들어 최종 상태가 일관적인지 검증해야 한다.
🤖 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/PaymentEventHandler.java`
around lines 41 - 75, The code immediately treats any PG exception/timeouts as a
definitive failure and calls compensateOrder(), causing inconsistent states;
instead classify requestPayment outcomes into SUCCESS / UNCONFIRMED / FAILURE
(e.g., return a PgResult.status or throw a specific UncertainPaymentException
from paymentGateway.requestPayment), do not call compensateOrder() on
Unconfirmed/exception cases but mark the Payment as PENDING/UNCONFIRMED and
enqueue/retry reconciliation; move local state transitions
(paymentService.updateRequested, orderService.completeOrder) into a single
application-service transaction or a single method (e.g.,
PaymentApplicationService.completePaymentTransaction) so updateRequested and
completeOrder succeed or roll back together, make compensateOrder() idempotent
and retryable, and add tests for PG approval-with-timeout and for failure after
updateRequested() to verify final consistency.
| long countByCouponId(Long couponId); | ||
|
|
||
| Map<Long, Long> countByCouponIds(List<Long> couponIds); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Verify implementation handles empty list edge case
# Find the implementation of countByCouponIds
ast-grep --pattern 'Map<Long, Long> countByCouponIds($$$) {
$$$
}'
# Also check JPA repository method
rg -n "countByCouponIds" --type=java -A 10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4289
🏁 Script executed:
#!/bin/bash
# Find the JPA repository interface
fd "OwnedCoupon.*JpaRepository" --type f | head -5
# Check the countByCouponIdIn method definition and query
rg -n "countByCouponIdIn|countByCouponId" --type=java -B 2 -A 5 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6024
🏁 Script executed:
#!/bin/bash
# Look for the full JPA repository interface
fd -e java | xargs grep -l "OwnedCouponJpaRepository" | head -3
# Get more context on the OwnedCouponRepositoryImpl class
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/OwnedCouponRepositoryImpl.java | sed -n '1,80p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3110
🏁 Script executed:
#!/bin/bash
# Find JPA repository to see the query for countByCouponIdIn
find . -name "*OwnedCoupon*JpaRepository*" -type f
rg -n "OwnedCouponJpaRepository" --type=java -A 3 | grep -E "interface|extends"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5376
🏁 Script executed:
#!/bin/bash
# Check if there are tests for null/empty input handling
rg -n "countByCouponId|countByCouponIds" apps/commerce-api/src/test --type=java -B 2 -A 5 | grep -E "test|Test|null|empty" | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3908
🏁 Script executed:
#!/bin/bash
# Verify actual usage patterns in CouponService
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java | sed -n '61,70p'
# Check if countByCouponIds is called with any validation
rg -n "countByCouponIds" apps/commerce-api/src/main/java --type=java -B 5 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3311
🏁 Script executed:
#!/bin/bash
# Check if there's any validation at the service or controller level before calling these methods
rg -n "countIssuedCoupons" apps/commerce-api/src/main/java --type=java -B 5 -A 3 | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3968
null 파라미터 검증과 안전한 타입 변환이 필요하다.
countByCouponIds 메서드는 null인 couponIds 파라미터에 대한 검증이 없어 SQLGrammarException을 발생시킨다. 운영 환경에서 입력 검증 오류로 이어진다. 또한 OwnedCouponRepositoryImpl의 타입 변환 (Long) row[0], (Long) row[1]은 null 체크 없이 진행되어 ClassCastException 위험이 있다.
countByCouponIds(List<Long>)입력값 null 체크 추가- 쿼리 결과 행의 각 요소에 대한 null 체크 후 변환
- 빈 리스트는 현재 정상 처리됨 (쿼리 결과 빈 List<Object[]> → 빈 Map)
- 또한
countByCouponId(Long)도 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/OwnedCouponRepository.java`
around lines 26 - 28, Add null-argument validation and safe result conversion:
validate inputs in OwnedCouponRepository.countByCouponIds(List<Long>) and
countByCouponId(Long) (throw IllegalArgumentException or return 0 as your
policy) to avoid passing null into the query; in OwnedCouponRepositoryImpl where
you process the query result rows, replace direct casts like (Long) row[0] and
(Long) row[1] with null-safe conversions (check row[i] for null, accept other
Number types then call ((Number)row[i]).longValue(), or default to 0L) before
putting entries into the Map; keep the current behavior for empty input lists
(return empty map) and ensure null inputs are rejected or handled consistently.
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| public class SmtpEmailSender implements NotificationSender { |
There was a problem hiding this comment.
@Component 어노테이션 누락으로 Spring Bean 등록 실패
@Slf4j와 @RequiredArgsConstructor만 선언되어 있어 Spring IoC 컨테이너에 Bean으로 등록되지 않는다. NotificationSender를 주입받는 핸들러에서 런타임 시 NoSuchBeanDefinitionException이 발생한다.
🐛 수정 제안
`@Slf4j`
+@Component
`@RequiredArgsConstructor`
public class SmtpEmailSender implements NotificationSender {🤖 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/notification/SmtpEmailSender.java`
around lines 10 - 12, 클래스 SmtpEmailSender가 `@Slf4j와` `@RequiredArgsConstructor만`
선언되어 Spring Bean으로 등록되지 않으므로 NotificationSender를 주입하는 곳에서
NoSuchBeanDefinitionException이 발생합니다; SmtpEmailSender 클래스 선언부에 Spring의
`@Component`(또는 적절한 `@Service/`@Named 등) 어노테이션을 추가해 IoC 컨테이너에 빈으로 등록하고 기존
`@RequiredArgsConstructor` 기반 생성자 주입이 그대로 작동하는지 확인하세요.
| @Scheduled(fixedDelay = 3000) | ||
| @Transactional | ||
| public void relay() { | ||
| List<OutboxEventEntity> events = | ||
| outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc(); | ||
|
|
||
| if (events.isEmpty()) return; | ||
|
|
||
| for (OutboxEventEntity event : events) { | ||
| try { | ||
| kafkaTemplate.send(event.getTopic(), event.getPartitionKey(), event.getPayload()) | ||
| .get(); | ||
| event.markPublished(); |
There was a problem hiding this comment.
outbox row를 선점하지 않아 다중 인스턴스에서 중복 발행될 수 있다.
운영에서 API 인스턴스가 2대 이상이면 각 스케줄러가 같은 published=false row를 동시에 읽어 동일 이벤트를 중복 전송할 수 있다. 지금처럼 Kafka ACK 대기를 같은 트랜잭션 안에서 처리하면 그 창이 더 길어지고 DB 커넥션 점유도 같이 커진다. SELECT ... FOR UPDATE SKIP LOCKED 또는 IN_PROGRESS 상태로 먼저 claim하는 짧은 트랜잭션을 application/service 계층에 두고, Kafka 전송은 트랜잭션 밖에서 수행한 뒤 성공 건만 별도 짧은 트랜잭션으로 published 처리하도록 바꾸는 편이 안전하다. 추가 테스트로는 두 개의 relay 실행 주체가 동시에 relay()를 호출해도 한 outbox row가 한 번만 publish 되는지 검증해야 한다. Based on learnings, "Do not annotate infrastructure layer implementations with Transactional (even readOnly = true). Transaction management should be handled by the Application layer ... Apply this rule to all Java files under apps/commerce-api/src/main/java/com/loopers/infrastructure."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxKafkaRelay.java`
around lines 25 - 37, OutboxKafkaRelay.relay currently runs the Kafka send
inside a long `@Transactional` method and reads rows via
outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc(), which allows
duplicate publishes under multiple instances; remove the `@Transactional` from
this infrastructure class and change relay to: in a short transaction claim rows
(either via a repository method that does SELECT ... FOR UPDATE SKIP LOCKED or
by updating status to IN_PROGRESS) so each instance exclusively reserves events,
then outside any transaction perform kafkaTemplate.send(...).get() for each
claimed event, and finally in another short transaction call
event.markPublished() / persist the published flag for only the successfully
sent events; also add tests that run two parallel relay() callers to assert each
outbox row is published only once.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java (1)
19-19:KafkaTemplate<Object, Object>타입 파라미터로 인한 타입 안전성 저하
Object타입 사용으로 컴파일 타임에 키/값 타입 오류를 감지할 수 없다. 실제 사용 패턴을 보면 키는String, 값은KafkaEventMessage이므로KafkaTemplate<String, KafkaEventMessage>로 구체화하면 오타나 잘못된 타입 전달을 컴파일 시점에 방지할 수 있다.♻️ 타입 구체화 예시
- private final KafkaTemplate<Object, Object> kafkaTemplate; + private final KafkaTemplate<String, KafkaEventMessage> kafkaTemplate;단, Kafka 설정 빈과 다른 Publisher들도 같은 타입으로 통일되어 있는지 확인이 필요하다.
🤖 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/kafka/OrderKafkaPublisher.java` at line 19, OrderKafkaPublisher currently declares kafkaTemplate as KafkaTemplate<Object, Object>, reducing type safety; change the field and usages to KafkaTemplate<String, KafkaEventMessage> (and update constructor parameter and any send/convert calls in OrderKafkaPublisher) so keys are String and values are KafkaEventMessage, and then verify Kafka configuration beans and other publishers use the same generic types to keep wiring compatible.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java`:
- Around line 31-33: In OrderKafkaPublisher's catch block (the Exception handler
that currently logs "[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED"),
include the order identifier (e.g., orderId or event.getOrderId()) and any
correlation id in the log message to enable tracing which order failed, and also
increment a Micrometer Counter (e.g., kafkaPublishFailureCounter) for Kafka
publish failures so dashboards can track failure trends; add the Counter as a
field (or inject MeterRegistry) if not present and update it inside the catch
before logging the enriched message.
- Around line 27-30: OrderCompletedEvent's nullable Long fields can cause
Map.of(...) to throw NPE in OrderKafkaPublisher when building KafkaEventMessage;
add defensive null checks: prefer adding Objects.requireNonNull(orderId,
"orderId must not be null") and Objects.requireNonNull(userId, "userId must not
be null") in the compact constructor of the OrderCompletedEvent record to fail
fast, and/or validate in OrderKafkaPublisher before calling
KafkaEventMessage.of(...) to log a clear error and skip publishing when fields
are null; update or add unit tests to cover publishing behavior when an event
contains null fields.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java`:
- Line 19: OrderKafkaPublisher currently declares kafkaTemplate as
KafkaTemplate<Object, Object>, reducing type safety; change the field and usages
to KafkaTemplate<String, KafkaEventMessage> (and update constructor parameter
and any send/convert calls in OrderKafkaPublisher) so keys are String and values
are KafkaEventMessage, and then verify Kafka configuration beans and other
publishers use the same generic types to keep wiring compatible.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ec12f309-93c2-4197-9cd9-5b98f92ce25a
📒 Files selected for processing (2)
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/OrderKafkaPublisher.java
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/CatalogKafkaPublisher.java
| KafkaEventMessage.of("ORDER_COMPLETED", Map.of( | ||
| "orderId", event.orderId(), | ||
| "userId", event.userId(), | ||
| "totalPrice", event.totalPrice()))); |
There was a problem hiding this comment.
Map.of()는 null 값을 허용하지 않아 NPE 가능성 존재
OrderCompletedEvent의 orderId, userId가 Long 래퍼 타입이다. 이벤트 생성 시점에 null 검증이 없다면 Map.of()에서 NullPointerException이 발생한다. 이 예외는 catch 블록에서 잡히지만, 실제 원인(null 필드)을 파악하기 어렵고 의도치 않은 유실이 된다.
🛡️ 방어적 처리 방안
방안 1: 이벤트 레코드에서 생성 시점 검증 (권장)
public record OrderCompletedEvent(Long orderId, Long userId, int totalPrice) {
public OrderCompletedEvent {
Objects.requireNonNull(orderId, "orderId must not be null");
Objects.requireNonNull(userId, "userId must not be null");
}
}방안 2: 발행 전 명시적 검증
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
+ if (event.orderId() == null || event.userId() == null) {
+ log.error("[Kafka] 이벤트 필드 누락 — orderId={}, userId={}", event.orderId(), event.userId());
+ return;
+ }
try {단위 테스트에서 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/infrastructure/kafka/OrderKafkaPublisher.java`
around lines 27 - 30, OrderCompletedEvent's nullable Long fields can cause
Map.of(...) to throw NPE in OrderKafkaPublisher when building KafkaEventMessage;
add defensive null checks: prefer adding Objects.requireNonNull(orderId,
"orderId must not be null") and Objects.requireNonNull(userId, "userId must not
be null") in the compact constructor of the OrderCompletedEvent record to fail
fast, and/or validate in OrderKafkaPublisher before calling
KafkaEventMessage.of(...) to log a clear error and skip publishing when fields
are null; update or add unit tests to cover publishing behavior when an event
contains null fields.
| } catch (Exception e) { | ||
| log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e); | ||
| } |
There was a problem hiding this comment.
로그 메시지에 식별자 누락으로 장애 추적 어려움
Kafka 발행 실패 시 orderId 등 식별 정보가 없어 운영 환경에서 어떤 주문이 실패했는지 추적할 수 없다. 유실 허용 이벤트라도 실패 패턴 분석 및 재처리 대상 식별을 위해 최소한의 컨텍스트가 필요하다.
🔧 수정안
} catch (Exception e) {
- log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e);
+ log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED, orderId={}", event.orderId(), e);
}추가로 실패 건수를 Micrometer 카운터로 수집하면 대시보드에서 실패 추이를 모니터링할 수 있다.
📝 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.
| } catch (Exception e) { | |
| log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED", e); | |
| } | |
| } catch (Exception e) { | |
| log.warn("[Kafka] 직접 발행 실패 (유실 허용) — eventType=ORDER_COMPLETED, orderId={}", event.orderId(), e); | |
| } |
🤖 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/kafka/OrderKafkaPublisher.java`
around lines 31 - 33, In OrderKafkaPublisher's catch block (the Exception
handler that currently logs "[Kafka] 직접 발행 실패 (유실 허용) —
eventType=ORDER_COMPLETED"), include the order identifier (e.g., orderId or
event.getOrderId()) and any correlation id in the log message to enable tracing
which order failed, and also increment a Micrometer Counter (e.g.,
kafkaPublishFailureCounter) for Kafka publish failures so dashboards can track
failure trends; add the Counter as a field (or inject MeterRegistry) if not
present and update it inside the catch before logging the enriched message.
Summary
Context & Decision
1. 주문-결제: 이벤트 경계를 어디에 둘 것인가
문제 정의
주문 생성 과정에는 여러 도메인이 참여한다: 재고 차감, 주문 생성, 쿠폰 사용, 포인트 차감, PG 결제, 포인트 적립. 이 중 어디까지가 "하나의 트랜잭션"이고, 어디부터 이벤트로 분리해야 하는가?
판단 기준: "이 로직이 실패하면 주문 자체가 성립하지 않는가?"
주문이 성립하려면 재고가 확보되고, 돈이 빠져야 한다. 이 조건이 만족되지 않으면 주문이라는 사실 자체가 만들어지면 안 된다. 반면, PG 결제나 포인트 적립은 "주문이 존재한다"는 사실을 전제로 나중에 정합성을 맞추면 되는 로직이다.
왜 재고/포인트 차감은 이벤트로 분리하지 않았는가
재고가 부족한데 주문이 생성되면, 보상 트랜잭션을 만들어야 한다. 포인트도 마찬가지다. "주문은 만들어졌는데 포인트 차감이 실패했다"면 무료 주문이 되어버린다. 이런 자원 차감은 주문과 원자적으로 묶여야 하므로 같은 트랜잭션에서 Facade가 직접 호출한다.
왜 PG 결제만 이벤트로 분리했는가
PG 결제는 외부 시스템 호출이다. 주문 트랜잭션 안에서 PG를 호출하면:
결제는 "주문이 확정된 후" 시도하면 된다. 성공하면 주문 완료, 실패하면 보상 트랜잭션(재고 복원, 포인트 환불, 쿠폰 복원)으로 처리한다.
회원가입도 같은 기준
회원가입에서도 "유저 생성"은 핵심이고, 웰컴 포인트/쿠폰/이메일은 부가 로직이다. 유저가 만들어진 뒤 혜택 지급이 실패해도 회원가입 자체가 롤백되면 안 된다.
2. 상품 메트릭: ApplicationEvent → Kafka → 별도 시스템 집계
문제 정의
좋아요 토글, 상품 조회 시 집계(좋아요 수, 조회수)를 어디서 처리할 것인가? 기존에는
Product.likeCount를 좋아요 트랜잭션 안에서 동기적으로 증감했다.판단: 좋아요 성공과 집계 반영은 분리해야 한다
"좋아요를 눌렀는데 집계 업데이트 때문에 좋아요가 실패한다"는 상황은 부자연스럽다. 좋아요는 성공하되, 집계는 eventual consistency로 나중에 반영해도 된다.
그래서:
ProductLikeModelINSERT/DELETE만 하고,ProductLikedEvent를 발행CatalogEventConsumer가product_metrics테이블에 집계왜 Kafka로 보내는가 (ApplicationEvent만으로 안 되는 이유)
좋아요 집계를 같은 서버의
@TransactionalEventListener로 처리할 수도 있다. 하지만:멱등 처리
Consumer는
event_handled테이블로 이미 처리한 eventId를 추적한다. Kafka의 At Least Once 특성상 같은 메시지가 재전달될 수 있기 때문이다.3. 선착순 쿠폰: 단일 서버에서 분산 시스템까지의 진화
문제 정의
선착순 100장 쿠폰에 10,000명이 동시 요청. 초과 발급 0건, 정확히 100장 발급이 목표.
Phase 0: 낙관적 락 + @retryable (기존 쿠폰 발급)
배경: 기존 쿠폰 발급 API는 "누구나 다운받을 수 있는 쿠폰"을 전제로 설계했다. 수량 경합이 거의 발생하지 않는 시나리오이므로, 낙관적 락(
@Version)과 재시도(@Retryable)만으로 동시성을 처리했다.구조:
이 구조가 선착순에 맞지 않는 이유:
결론: "경합이 드문 일반 쿠폰"에는 적합하지만, "수천~수만 명이 동시에 경쟁하는 선착순 쿠폰"에는 구조적으로 맞지 않는다.
Phase 1: AtomicInteger + Atomic UPDATE (2중 문지기)
Phase 0의 문제를 해결하기 위해 낙관적 락 재시도 대신, 요청을 DB 전에 선제적으로 걸러내는 구조로 전환했다.
구조:
AtomicInteger로 수량 초과 요청을 DB 전에 차단UPDATE coupons SET issued_quantity = issued_quantity + 1 WHERE id = ? AND issued_quantity < total_quantityOwnedCouponRepository.findByCouponIdAndUserId()로 SELECT 체크Phase 0 대비 개선: 재시도 폭발이 사라졌다. AtomicInteger가 totalQuantity 초과 요청을 즉시 걸러내므로, DB에는 유효한 요청만 도달한다. 100장 쿠폰 + 1,000명 테스트에서 정확히 100장 발급됐다.
문제 발견: AtomicInteger를 통과한 요청은 여전히 DB를 3번 거친다.
Phase 2: Insert-Only + In-Memory Dedup (DB 연산 최소화)
Phase 1의 3회 DB 연산을 줄이기 위해 두 가지를 동시에 변경했다:
issuedQuantity컬럼 제거 → UPDATE 쿼리 제거CouponIssueCounter에ConcurrentHashMap<userId>기반 in-memory 중복 체크 추가 → SELECT 쿼리 제거구조:
Phase 1 대비 개선:
단일 서버에서는 이 구조로 기능적으로 충분하다. 수량 정합성도 지키고, 중복도 걸러낸다.
그러나 운영 환경을 고려하면 한계가 있다:
ConcurrentHashMap이 초기화된다. 이미 발급받은 유저가 다시 통과할 수 있다. (DB UNIQUE 제약이 최종 방어하지만, 불필요한 DB 요청이 발생)Phase 3: Redis ZSET + Lua Script (상태를 외부로 분리)
Phase 2의 한계를 해결하려면, 수량/중복 체크 상태를 JVM 밖으로 꺼내야 한다. 서버가 재시작되거나 여러 대로 스케일아웃해도 상태가 공유되는 저장소가 필요하다.
Redis로 외부화한 이유:
ZSET을 선택한 이유:
ZCARD= 발급 수 (수량 체크)ZSCORE= 특정 유저 존재 여부 (중복 체크)ZADD= 발급 기록 (score에 timestamp → 발급 순서 추적 가능)결과: 10,000 VU 중 Redis에서 99%+가 걸러지고, DB에는 실제 발급 대상(100건)만 도달.
Phase 4: Kafka + Transactional Outbox (비동기 DB INSERT)
Phase 3에서 남은 문제: Redis를 통과한 100건이 동기적으로 DB INSERT를 수행한다. 이 자체는 빠르지만, API 스레드가 DB INSERT 완료까지 블로킹된다. 사용자 입장에서 "발급 완료"를 기다려야 한다.
비동기로 전환한 이유:
왜 직접 Kafka 발행이 아니라 Outbox인가:
최종 흐름:
4. Kafka 발행 전략: 선별적 Outbox
판단 기준: "이 이벤트가 유실되면 비즈니스에 어떤 영향이 있는가?"
Outbox는 안전하지만 비용이 있다 — 비즈니스 TX마다 outbox 테이블에 추가 INSERT가 발생한다. 모든 이벤트에 Outbox를 적용하면 DB 쓰기 부하가 불필요하게 증가한다.
통계성 이벤트는
@TransactionalEventListener(AFTER_COMMIT)+ try-catch로 best-effort 발행한다. 유실되더라도 배치 재집계로 보정 가능하다.Design Overview
변경 범위
commerce-api(order, coupon, product, user, outbox),commerce-streamerOutboxEventPublisher— JSON 직렬화 + outbox INSERT 캡슐화OutboxKafkaConfig— StringSerializer 기반 전용 KafkaTemplateCouponRedisIssueLimiter— Redis ZSET + Lua Script 래퍼CouponIssueConsumer— Kafka 컨슈머, DB INSERT + 멱등 처리CouponIssueCounter— In-Memory 수량/중복 필터 (Phase 1~2)coupon-issue.lua— 원자적 수량+중복+발급 Lua 스크립트PaymentEventHandler— PG 결제 요청 + 보상 트랜잭션SignupBenefitHandler/SignupNotificationHandler— 회원가입 부가 로직CatalogEventConsumer/OrderEventConsumer— 메트릭 집계 컨슈머 (commerce-streamer)MetricsAggregationService— eventId 기반 멱등 집계Product.likeCount→ Kafka Consumer 기반product_metrics집계로 전환CatalogKafkaPublisher/OrderKafkaPublisher— Outbox → AFTER_COMMIT 직접 발행주요 컴포넌트 책임
OrderFacade: 자원 차감(재고/쿠폰/포인트)을 동일 트랜잭션에서 처리, PG 결제는 이벤트 위임CouponFacade: Redis 문지기 호출 → Outbox INSERT 오케스트레이션CouponRedisIssueLimiter: Lua 스크립트 실행, 수량/중복 원자적 체크OutboxEventPublisher: 이벤트 JSON 직렬화 + outbox 테이블 저장OutboxKafkaRelay: 3초 폴링 → 미발행 이벤트 Kafka 발행CouponIssueConsumer: Kafka 메시지 소비 → DB INSERT + UNIQUE 위반 시 skipMetricsAggregationService: eventId 중복 체크 + product_metrics upsertFlow Diagram
주문-결제 흐름
sequenceDiagram autonumber participant C as Client participant F as OrderFacade participant PS as ProductService participant OS as OrderService participant US as UserService participant PG as PaymentEventHandler participant EXT as PG Gateway C->>F: 주문+결제 요청 Note over F: @Transactional 시작 F->>PS: 재고 차감 (atomic UPDATE) F->>OS: 주문 생성 (PENDING_PAYMENT) F->>US: 포인트 차감 (atomic UPDATE) Note over F: @Transactional 커밋 F-->>C: 200 주문 생성 완료 Note over PG: AFTER_COMMIT 이벤트 PG->>EXT: PG 결제 요청 alt 결제 성공 PG->>OS: completeOrder() → OrderCompletedEvent 발행 else 결제 실패 PG->>PS: 재고 복원 PG->>US: 포인트 환불 end선착순 쿠폰 발급 흐름
sequenceDiagram autonumber participant C as Client participant F as CouponFacade participant R as Redis (Lua Script) participant DB as MySQL (Outbox) participant P as OutboxKafkaRelay participant K as Kafka participant CS as CouponIssueConsumer participant DB2 as MySQL (OwnedCoupon) C->>F: POST /coupons/{id}/issue F->>R: tryIssue(couponId, userId) alt ALREADY_ISSUED / QUANTITY_EXHAUSTED R-->>F: 거절 F-->>C: 400/409 즉시 거절 else SUCCESS R-->>F: SUCCESS (ZADD 완료) F->>DB: outbox_event INSERT (@Transactional) F-->>C: 200 PENDING end Note over P: 3초 간격 폴링 P->>DB: SELECT unpublished (TOP 100) P->>K: send(coupon-issued, payload) P->>DB: UPDATE published = true K->>CS: consume(CouponIssuedMessage) CS->>DB2: INSERT owned_coupon Note over CS: UNIQUE(coupon_id, user_id) 위반 시 skip + ack CS->>K: ackChecklist
Step 1 — ApplicationEvent 경계 분리
Step 2 — Kafka 이벤트 파이프라인
acks=all,idempotence=true, manual Ack 설정Step 3 — 선착순 쿠폰 발급