Skip to content

Conversation

@adminhelper
Copy link
Collaborator

@adminhelper adminhelper commented Dec 19, 2025

📌 Summary

💬 Review Points

✅ Checklist

📎 References

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 주문 결제 추가: 결제 요청, 콜백 처리, 비동기 결제 동기화 및 결제 상태 조회
    • 좋아요 집계 이벤트 추가로 실시간/정확한 좋아요 수 반영
    • 상품 캐시 일관성 개선 및 자동 갱신/무효화
    • 로컬 결제 시뮬레이터 제공
  • Refactor

    • 주문 흐름 확장: 결제 연동과 이벤트 기반 후속 처리로 분리
  • Chores

    • 결제 복원력 강화(타임아웃/재시도/서킷브레이커) 및 이벤트 아웃박스·Kafka 연동 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

Walkthrough

주문-결제 통합 및 이벤트 기반 아키텍처를 도입했습니다. Feign/Resilience4j 의존성 추가, 비동기·스케줄러 활성화, 결제 엔티티/서비스/레포지토리 및 PG Feign 클라이언트와 pg-simulator 모듈, outbox/이벤트 퍼블리셔·리스너·카프카 연계가 추가되었습니다.

Changes

코호트 / 파일(s) 변경 요약
빌드·설정
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/resources/application.yml, settings.gradle.kts
Resilience4j·Spring Cloud OpenFeign 및 :modules:kafka 의존성 추가, Feign/Retry/CircuitBreaker 및 PG/Feign 설정, pg-simulator 모듈 포함
애플리케이션 부트
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@EnableFeignClients, @EnableScheduling, @EnableAsync 추가
주문 도메인 및 앱 계층
apps/commerce-api/src/main/java/com/loopers/application/order/*, apps/commerce-api/src/main/java/com/loopers/domain/order/*, apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/*
CreateOrderCommand에 결제정보 추가, OrderInfo에서 createdAt 제거, OrderFacade에 결제 흐름·이벤트 발행 통합, OrderEvent(PaymentRequested) 추가, API 컨트롤러/DTO 추가
결제 도메인·인프라(Commerce API)
apps/commerce-api/src/main/java/com/loopers/domain/payment/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/*
Payment 엔티티·PaymentStatus·PaymentRepository·PaymentService 추가, PgPaymentClient(Feign) 및 DTO, JPA 레포·구현체 추가, 스케줄러(sync) 추가
결제 처리·동기화 로직
apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java, .../OrderPaymentSupport.java
결제 콜백/동기화 처리, 외부 트랜잭션 상태 매핑 및 주문 상태 업데이트/복구 로직 추가
이벤트·아웃박스 인프라
apps/commerce-api/src/main/java/com/loopers/domain/outbox/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/*
OutboxEvent 엔티티·레포·서비스·퍼블리셔 구현 추가(카프카 전송 루프 포함)
좋아요 이벤트 분리
apps/commerce-api/src/main/java/com/loopers/domain/like/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/like/*
LikeEvent/Publisher 도입, LikeService가 이벤트 발행으로 전환, 집계 리스너 추가(비동기·트랜잭션후커)
상품 캐시/리프레셔
apps/commerce-api/src/main/java/com/loopers/domain/product/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/*
ProductCache 인터페이스 도입, RedisProductCache 구현, cache evict/refresh 서비스 추가
포인트·사용자 연동
apps/commerce-api/src/main/java/com/loopers/domain/point/*, apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
Point.refund 및 PointService.initPoint/refundPoint 추가, UserFacade에서 포인트 초기화 호출
PG 시뮬레이터 모듈
apps/pg-simulator/**
pg-simulator 애플리케이션 추가(결제 트랜잭션 생성/처리/조회, 이벤트 발행·릴레이), 관련 DTO·엔티티·레포지토리·API·설정 추가
스트리머(메트릭/핸들링)
apps/commerce-streamer/src/main/java/com/loopers/**
EventHandled, ProductMetrics 등 MV·메트릭 엔티티 및 소비자(카탈로그/order) 추가, 처리된 이벤트 표식 서비스 추가
테스트·문서·HTTP 스니펫
apps/commerce-api/src/test/**, docs/**, http/commerce-api/*, apps/pg-simulator/README.md
결제 통합을 반영한 통합 테스트(폴링 대기), 문서/예제 및 pg-simulator README 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant OrderAPI as Order API
    participant OrderFacade
    participant PaymentSvc as PaymentService
    participant EventPub as OrderEventPublisher
    participant SpringBus as Spring Event Bus
    participant PaymentListener as OrderPaymentEventListener
    participant PgClient as PgPaymentClient
    participant PgSim as PG Simulator
    participant OrderProc as OrderPaymentProcessor

    Client->>OrderAPI: POST /api/v1/orders (userId, items, payment)
    OrderAPI->>OrderFacade: createOrder(cmd)
    OrderFacade->>PaymentSvc: save(Payment.pending)
    OrderFacade->>EventPub: publish(PaymentRequested)
    OrderAPI-->>Client: 200 OrderResponse

    Note over SpringBus,PaymentListener: 비동기 AFTER_COMMIT 처리
    EventPub->>SpringBus: publishEvent(PaymentRequested)
    SpringBus->>PaymentListener: handle(PaymentRequested)
    PaymentListener->>PgClient: requestPayment(request)
    PgClient->>PgSim: POST /api/v1/payments
    PgSim-->>PgClient: Response(transactionKey,status)

    PaymentListener->>OrderProc: handlePaymentResult(orderId,status,transactionKey)
    OrderProc->>PaymentSvc: findByOrderId(orderId)
    OrderProc->>OrderProc: applyPaymentResult -> update Payment, Order, notify DataPlatform / revert on fail
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 토끼가 깡충 뛰며 외치네,
주문은 발사, 결제는 비동기!
회로차단기와 재시도가 손을 잡고,
시뮬레이터는 연습장 되어주네.
이벤트 따라 춤추는 우리 서비스, 꿀잼! ✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명이 템플릿 구조만 포함하고 있으며, Summary, Review Points, Checklist, References 섹션이 모두 비어있거나 주석만 포함되어 있습니다. Summary 섹션에 변경사항 요약, Review Points에 설계 포인트, Checklist에 완료 항목, References에 참고자료를 구체적으로 작성해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 주요 변경사항(Kafka를 통한 서비스 디커플링)을 명확하게 요약하고 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

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/domain/point/Point.java (1)

40-46: charge 메서드의 불필요한 객체 생성을 제거하세요.

Line 45의 new Point(this.userId, this.balance);는 생성된 객체를 사용하지 않는 불필요한 코드입니다. 이 줄은 아무 효과가 없으므로 제거해야 합니다.

🔎 제안된 수정사항
 public void charge(Long chargeAmount) {
     if (chargeAmount == null || chargeAmount <= 0) {
         throw new CoreException(ErrorType.BAD_REQUEST, "0원 이하로 포인트를 충전 할수 없습니다.");
     }
     this.balance += chargeAmount;
-    new Point(this.userId, this.balance);
 }
🟠 Major comments (19)
docs/6round/6round.md-27-68 (1)

27-68: API 예제 섹션의 마크다운 포맷팅을 명확히 하세요.

API 예제 블록의 구조가 모호하고 혼란스럽습니다. 다음 문제들을 개선해야 합니다:

  • 라인 28의 ###결제 요청은 코드 블록 내에서 주석으로 해석되기 어려움
  • 라인 30-34의 pg - simulator 표현이 구체적이지 않음 (실제 호스트:포트를 명시해야 함)
  • 라인 36의 Content-Type:application/이 불완전함 (예: application/json)
  • 라인 38의 json {은 유효한 JSON이 아님 (이 라인 자체가 필요 없음)
  • 전체 블록이 HTTP 요청/응답 예제인지, 스키마인지 명확하지 않음

HTTP 요청/응답 형식으로 재구성하거나, 요청 페이로드와 응답 예제를 명확히 분리하여 표현하세요. 예를 들어:

### 결제 요청

POST http://localhost:8080/api/v1/payments
X-USER-ID: 135135
Content-Type: application/json

{
  "orderId": "1351039135",
  "cardType": "SAMSUNG",
  "cardNo": "1234-5678-9814-1451",
  "amount": "5000",
  "callbackUrl": "http://localhost:8080/api/v1/examples/callback"
}
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt-15-19 (1)

15-19: 전역 타임존 설정의 부작용 방지

TimeZone.setDefault()는 전체 JVM의 기본 타임존을 변경하므로 다음과 같은 문제를 유발할 수 있습니다:

  • 동일 JVM에서 실행되는 다른 애플리케이션에 영향
  • 테스트 환경에서 예상치 못한 동작
  • 컨테이너/멀티테넌트 환경에서의 이식성 저하

더 안전한 대안을 사용하는 것을 권장합니다:

🔎 권장하는 대안

대안 1: application.yml에서 설정

spring:
  jackson:
    time-zone: Asia/Seoul

대안 2: 필요한 곳에서 명시적으로 ZoneId 사용

val seoulZone = ZoneId.of("Asia/Seoul")
// 필요한 곳에서 명시적으로 사용

대안 3: JVM 시작 시 타임존 설정

-Duser.timezone=Asia/Seoul

PostConstruct 메서드 제거:

-    @PostConstruct
-    fun started() {
-        // set timezone
-        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
-    }
apps/pg-simulator/src/main/resources/application.yml-16-17 (1)

16-17: 애플리케이션 이름이 모듈과 일치하지 않습니다.

pg-simulator 모듈의 설정 파일인데 애플리케이션 이름이 commerce-api로 되어 있습니다. 다른 모듈에서 복사한 것으로 보이며, 이로 인해 로깅, 모니터링, 서비스 디스커버리에서 혼란이 발생할 수 있습니다.

🔎 수정 제안
 spring:
   main:
     web-application-type: servlet
   application:
-    name: commerce-api
+    name: pg-simulator
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java-21-23 (1)

21-23: TaskExecutor를 명시적으로 설정하세요.

@async와 Propagation.REQUIRES_NEW 조합은 동작하지만 중복됩니다. @async가 이미 별도 스레드에서 실행되므로 REQUIRES_NEW는 본질적으로 불필요합니다. 다만 새 트랜잭션 생성 의도를 명시하는 것이므로 반드시 제거할 필요는 없습니다.

더 중요한 문제는 TaskExecutor 설정입니다. 현재 커스텀 AsyncConfigurer 구현이 없으므로 스프링 부트 기본 TaskExecutor가 사용됩니다. 좋아요 집계 처리는 높은 빈도의 이벤트 처리이므로, 스레드 풀 크기, 큐 용량, 거부 정책 등을 명시적으로 구성해야 합니다. 설정 없이는 스레드 풀 고갈로 인한 이벤트 거부 위험이 있습니다.

AsyncConfigurer를 구현하거나 application.yml에서 spring.task.execution 설정으로 스레드 풀을 적절히 구성하세요.

apps/commerce-api/src/main/resources/application.yml-13-15 (1)

13-15: 하드코딩된 로컬호스트 URL

callback base URL이 localhost:8082로 하드코딩되어 있습니다. 이는 로컬 환경에서만 동작하며, dev/qa/prd 환경에서는 올바른 콜백을 받을 수 없습니다.

환경별 프로필 섹션(lines 71-94)에서 이 값을 오버라이드하여 각 환경에 맞는 URL을 설정해야 합니다.

🔎 환경별 설정 예시
---
spring:
  config:
    activate:
      on-profile: dev

app:
  callback:
    base-url: https://dev-commerce-api.example.com

---
spring:
  config:
    activate:
      on-profile: prd

app:
  callback:
    base-url: https://commerce-api.example.com
apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentCommand.java-3-6 (1)

3-6: 결제 데이터 검증 추가 필요

결제 정보를 담는 커맨드 객체에 입력값 검증이 없습니다. 다음 사항을 고려해주세요:

  • cardType: 허용된 카드 타입 목록에 대한 검증 (예: @NotBlank, @Pattern 또는 enum 사용)
  • cardNo: 카드 번호 형식 검증 (예: @NotBlank, @Pattern으로 형식 체크)

민감한 결제 정보이므로 API 계층에서 유효하지 않은 데이터가 도메인으로 전파되지 않도록 검증을 추가하는 것을 권장합니다.

🔎 검증 추가 예시
 package com.loopers.application.order;
 
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
 public record OrderPaymentCommand(
+        @NotBlank(message = "카드 타입은 필수입니다")
         String cardType,
+        @NotBlank(message = "카드 번호는 필수입니다")
+        @Pattern(regexp = "^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}$", message = "올바른 카드 번호 형식이 아닙니다")
         String cardNo
 ) {}
apps/commerce-api/src/main/resources/application.yml-17-21 (1)

17-21: PG 클라이언트 타임아웃 설정 및 환경 구성 개선 필요

PG 클라이언트 설정에서 두 가지 중요한 문제가 있습니다:

  1. 환경별 설정 부재 (line 19, 15): localhost가 hard-coded되어 있으며, 환경별 application-*.yml 프로필이 없습니다. dev/qa/prod 환경 구분을 위해 application-dev.yml, application-prod.yml 등의 프로필을 생성하고 base-url을 환경변수로 관리해야 합니다.

  2. 극도로 짧은 read timeout (line 21, 500ms): 결제 게이트웨이의 표준 백엔드 타임아웃은 60-90초이나, 현재 500ms 설정은 네트워크 지연과 외부 PG 처리 시간을 고려할 때 거의 확실히 실패합니다. 재시도 로직(3회, 200ms 대기)과 함께 작동하면서 중복 결제를 유발할 수 있으므로, 최소 60초 이상으로 설정해야 합니다.

apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java-58-63 (1)

58-63: 환불 로직에서 서비스 계층 검증이 필요합니다.

refundPoint() 메서드는 환불 금액에 대한 추가 검증이 없습니다. usePoint()는 포인트 충분성을 검증하지만, refundPoint()는 환불 가능 금액 제한이나 잔액 상한선 검증이 없어 불균형이 있습니다. 특히 OrderPaymentProcessor에서 서비스 계층을 거치지 않고 point.refund()를 직접 호출하므로, 서비스 계층에서 환불 검증 로직(환불 가능 금액 한도, 거래 기록 확인 등)을 추가하고, 모든 환불 호출이 서비스 계층을 통하도록 일관성 있게 정리하세요.

apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java-10-12 (1)

10-12: 미구현 메서드를 문서화하거나 로깅을 추가하세요.

send 메서드가 비어있습니다. 이것이 의도적인 스텁 구현인지 아직 구현되지 않은 것인지 명확하지 않습니다. 다음 중 하나를 고려하세요:

  1. TODO 주석 추가로 향후 구현 예정임을 표시
  2. 로깅 추가로 메서드 호출을 추적
  3. 아직 준비되지 않았다면 UnsupportedOperationException 발생

이 메서드의 실제 구현이 필요하신가요? 구현 방향을 제안해드릴 수 있습니다.

🔎 TODO 주석을 추가하는 제안
 @Override
 public void send(Order order, Payment payment) {
+    // TODO: 데이터 플랫폼으로 주문/결제 정보 전송 구현 필요
 }
apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt-11-14 (1)

11-14: RestTemplate 타임아웃 미설정 및 Bean 미등록 문제

RestTemplate이 companion object에서 직접 생성되어 Spring 컨텍스트에서 관리되지 않습니다. 타임아웃이 설정되지 않아 콜백 서버가 응답하지 않을 경우 스레드가 무한 대기할 수 있습니다.

🔎 Spring Bean으로 등록하고 타임아웃 설정 제안
 @Component
-class PaymentCoreRelay : PaymentRelay {
+class PaymentCoreRelay(
+    private val restTemplate: RestTemplate
+) : PaymentRelay {
     companion object {
         private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java)
-        private val restTemplate = RestTemplate()
     }

별도 Configuration 클래스에서 RestTemplate Bean 등록:

@Configuration
class RestTemplateConfig {
    @Bean
    fun restTemplate(): RestTemplate {
        val factory = SimpleClientHttpRequestFactory().apply {
            setConnectTimeout(Duration.ofSeconds(5))
            setReadTimeout(Duration.ofSeconds(10))
        }
        return RestTemplate(factory)
    }
}
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt-16-21 (1)

16-21: 비동기 메서드에서 Thread.sleep() 사용은 문제가 될 수 있습니다.

@Async 메서드 내에서 Thread.sleep()을 호출하면 스레드 풀의 워커 스레드를 블로킹하여 성능 저하 및 스레드 고갈 문제를 일으킬 수 있습니다. PG 시뮬레이터라도 비동기 처리의 모범 사례를 따르는 것이 좋습니다.

🔎 제안하는 개선 코드 (Kotlin Coroutines 사용)
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+
 @Async
 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
 fun handle(event: PaymentEvent.PaymentCreated) {
-    val thresholdMillis = (1000L..5000L).random()
-    Thread.sleep(thresholdMillis)
-
-    paymentApplicationService.handle(event.transactionKey)
+    runBlocking {
+        val thresholdMillis = (1000L..5000L).random()
+        delay(thresholdMillis)
+        paymentApplicationService.handle(event.transactionKey)
+    }
 }

또는 Spring의 @Scheduled와 지연된 태스크를 사용하는 방법도 고려할 수 있습니다.

Committable suggestion skipped: line range outside the PR's diff.

apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java-30-31 (1)

30-31: 카드 번호 저장 시 마스킹/암호화 검토

cardNo가 평문으로 저장되고 있습니다. PCI-DSS 컴플라이언스 및 개인정보 보호를 위해 카드 번호는 마스킹(예: ****-****-****-4444) 또는 암호화하여 저장하는 것을 권장합니다.

학습 목적의 프로젝트라면 현재 상태로 진행하되, 실제 운영 환경에서는 반드시 보안 처리가 필요합니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java-31-35 (1)

31-35: 동기화 엔드포인트의 권한 검증이 필요합니다.

syncPayment 엔드포인트는 수동으로 결제 상태를 동기화하는 관리 작업으로 보이지만, 권한 검증이 없습니다. 일반 사용자도 임의의 주문에 대해 동기화를 트리거할 수 있습니다.

관리자 권한 검증 또는 최소한 해당 주문의 소유자 검증을 추가해야 합니다.

apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt-27-28 (1)

27-28: 빈 문자열 검증이 누락되었습니다.

현재 코드는 헤더가 null인 경우만 체크하지만, 헤더가 존재하나 빈 문자열("") 또는 공백만 있는 경우를 검증하지 않습니다. 이 경우 UserInfo("")가 생성되어 다운스트림에서 문제를 일으킬 수 있습니다.

🔎 제안하는 수정
-        val userId = webRequest.getHeader(KEY_USER_ID)
-            ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.")
+        val userId = webRequest.getHeader(KEY_USER_ID)
+            ?.takeIf { it.isNotBlank() }
+            ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.")
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java-27-28 (1)

27-28: Spring 관리 ObjectMapper 주입 권장

기본 ObjectMapper는 Java 8 날짜/시간 타입(LocalDateTime 등) 직렬화를 지원하지 않습니다. Product 엔티티에 날짜 필드가 있다면 직렬화/역직렬화 시 예외가 발생할 수 있습니다. Spring Boot가 구성한 ObjectMapper를 주입받아 사용하는 것이 좋습니다.

🔎 제안: Spring ObjectMapper 주입
 @Component
 @RequiredArgsConstructor
 public class RedisProductCache implements ProductCache {

     private static final Duration TTL_LIST = Duration.ofMinutes(10);
     private static final Duration TTL_DETAIL = Duration.ofMinutes(5);
     private static final Logger log = LoggerFactory.getLogger(RedisProductCache.class);

     private final RedisTemplate<String, String> redisTemplate;
-    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final ObjectMapper objectMapper;
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java-49-55 (1)

49-55: getPaymentsFallback에서 빈 트랜잭션 리스트 반환

폴백에서 빈 트랜잭션 리스트를 반환하면 OrderPaymentProcessor.syncPayment()에서 결제 내역이 없는 것으로 간주하고 경고 로그만 남깁니다. 이로 인해 실제 결제가 처리되었지만 조회 실패로 동기화되지 않는 상황이 발생할 수 있습니다. 폴백에서 별도의 에러 플래그를 포함하거나, 호출자가 메타데이터의 PG_ERROR를 확인하도록 해야 합니다.

apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEvent.java-10-28 (1)

10-28: 이벤트에 민감 정보(카드 번호) 포함 주의

PaymentRequested 이벤트에 cardNo가 포함되어 있습니다. 이벤트가 로깅되거나 메시지 큐(Kafka 등)에 저장될 경우 PCI-DSS 준수 문제가 발생할 수 있습니다. 카드 번호는 마스킹하거나 토큰화된 값을 사용하는 것을 권장합니다.

apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java-104-107 (1)

104-107: 포인트 환불 시 null 체크 후 환불 금액 검증 필요

pointnull이 아닐 때만 환불을 진행하고 있습니다. 그러나 주문 시 포인트를 사용하지 않았다면 order.getTotalAmount() 전체를 환불하는 것이 적절하지 않을 수 있습니다. 사용된 포인트 금액을 별도로 추적하여 환불해야 할 수 있습니다.

🔎 개선 제안

주문에서 실제 사용된 포인트 금액을 추적하고, 해당 금액만 환불하도록 수정을 고려해 주세요:

-    if (point != null) {
-        point.refund(order.getTotalAmount());
-    }
+    if (point != null && order.getUsedPoints() != null && order.getUsedPoints() > 0) {
+        point.refund(order.getUsedPoints());
+    }

Committable suggestion skipped: line range outside the PR's diff.

apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt-24-37 (1)

24-37: DTO에 검증 로직이 포함되어 있습니다

학습된 코딩 가이드라인에 따르면, 이 프로젝트에서 DTO는 순수한 데이터 컨테이너여야 하며 검증 로직을 포함해서는 안 됩니다. 모든 검증 로직(형식 검사 및 비즈니스 규칙 포함)은 도메인 엔티티에 배치되어야 합니다(일반적으로 init 블록에서 CoreException 사용).

현재 validate() 메서드의 검증 로직을 도메인 레이어(예: PaymentCommand.CreateTransaction이나 관련 도메인 엔티티)로 이동하는 것을 고려하세요.

Based on learnings, DTOs should remain pure data containers without validation logic in this project.

🟡 Minor comments (11)
http/commerce-api/pints.http-1-5 (1)

1-5: 파일명 확인: pints.httppoints.http로 수정 필요

파일명이 pints.http로 되어 있으나, API 엔드포인트 경로(/api/v1/points)와 도메인 이름(points)과 일치하지 않습니다. 일관된 명명 규칙을 위해 points.http로 수정하시기 바랍니다.

apps/pg-simulator/src/main/resources/application.yml-63-73 (1)

63-73: qa 및 prd 프로파일에 서버 포트 설정이 누락되었습니다.

local, test, dev 프로파일은 포트 8082를 명시적으로 설정하고 있지만, qaprd 프로파일에는 서버 포트 설정이 없습니다. 이 경우 기본값인 8080 포트가 사용되어 환경 간 일관성이 깨질 수 있습니다.

🔎 수정 제안
 ---
 spring:
   config:
     activate:
       on-profile: qa
+
+server:
+  port: 8082
+
+management:
+  server:
+    port: 8083

 ---
 spring:
   config:
     activate:
       on-profile: prd
+
+server:
+  port: 8082
+
+management:
+  server:
+    port: 8083

 springdoc:
   api-docs:
     enabled: false
http/commerce-api/orders.http-17-17 (1)

17-17: orderId 형식 불일치

callback과 sync 엔드포인트의 예시에서 orderId 형식이 다릅니다:

  • Line 17: order-1 (문자열 형식)
  • Line 27: 1 (숫자 형식)

실제 API 구현에 맞춰 일관된 형식으로 통일하는 것을 권장합니다.

Also applies to: 27-27

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java-14-16 (1)

14-16: @EnableAsync@EnableScheduling을 위한 커스텀 설정 추가 필요

@EnableAsync@EnableScheduling이 활성화되었으나 커스텀 executor 및 scheduler 설정이 없습니다. 현재 기본값은 프로덕션 환경에 적합하지 않으므로 다음을 추가하세요:

  • AsyncConfigurer를 구현하여 ThreadPoolTaskExecutor 설정 (스레드 풀 크기, 큐 용량, 거부 정책 등)
  • SchedulingConfigurer를 구현하여 멀티스레드 TaskScheduler 설정 (스레드 풀 크기 등)

기본 설정만으로는 SimpleAsyncTaskExecutor와 단일스레드 scheduler가 사용되어 메모리 누수 및 동시성 처리의 문제가 발생할 수 있습니다.

apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java-16-20 (1)

16-20: payment 필드에 @NotNull 어노테이션 추가 필요

payment 필드는 OrderFacade의 createOrder 메서드(70-71줄)에서 null 체크 없이 직접 접근되고 있습니다. 모든 테스트 케이스와 DTO 변환 로직에서 payment를 필수로 제공하므로, 레코드에 @NotNull 어노테이션을 추가하여 non-null 계약을 명시하거나, 서비스 계층에서 명시적인 null 검증을 수행해야 합니다. 현재 상태에서는 payment가 null일 경우 NullPointerException 위험이 있습니다.

apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt-18-26 (1)

18-26: 카드 번호(PII) 마스킹 필요

cardNo 필드가 마스킹되지 않은 상태로 저장되고 전체 계층(데이터베이스, API 응답)에 노출되고 있습니다. 이 객체가 로그에 기록되거나 오류 발생 시 카드 번호가 평문으로 노출될 수 있습니다.

toString() 오버라이드로 마스킹하거나, API 응답 직렬화 시 민감 정보를 제외하는 방식으로 보호하는 것을 권장합니다.

apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java-50-56 (1)

50-56: refundPoint에 서비스 계층 입력값 검증 누락

usePoint 메서드(38-44라인)에서는 amount에 대해 서비스 계층에서 null 체크와 양수 검증을 수행하지만, refundPoint에서는 동일한 서비스 계층 검증이 없습니다. 검증이 entity 레벨의 refund() 메서드에만 존재하여 일관성이 부족합니다.

 @Transactional
 public void refundPoint(String userId, Long amount) {
+    if (amount == null || amount <= 0) {
+        throw new CoreException(ErrorType.NOT_FOUND, "환불할 포인트는 1 이상이어야 합니다.");
+    }
     Point point = pointRepository.findByUserId(userId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보를 찾을 수 없습니다."));
     point.refund(amount);
     pointRepository.save(point);
 }
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java-65-66 (1)

65-66: Spring Boot 3.4+에서 @MockBean은 deprecated 상태입니다. @MockitoBean으로 변경하세요.

@MockBean은 Spring Boot 3.4.0부터 deprecated되었으며, 4.0.0 버전에서 제거될 예정입니다. Spring Framework 6.x의 @MockitoBean으로 변경해야 합니다.

단, 주의사항: @MockitoBean은 테스트 클래스의 필드로만 사용 가능하며, @configuration 클래스에서는 사용할 수 없습니다. 현재 프로젝트의 Spring Boot 버전을 확인하고 필요시 마이그레이션 계획을 수립하세요.

apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt-28-31 (1)

28-31: @transactional 어노테이션이 누락되었습니다.

findByOrderId 메서드에 다른 조회 메서드들과 동일하게 @Transactional(readOnly = true) 어노테이션이 필요합니다. 일관성을 위해서도 추가해야 하며, readOnly 트랜잭션은 성능 최적화에도 도움이 됩니다.

🔎 제안하는 수정
+    @Transactional(readOnly = true)
     override fun findByOrderId(userId: String, orderId: String): List<Payment> {
         return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId)
             .sortedByDescending { it.updatedAt }
     }
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt-64-70 (1)

64-70: 상태 변경 시 updatedAt 필드가 갱신되지 않음

approve(), invalidCard(), limitExceeded() 메서드에서 상태를 변경하지만 updatedAt 필드가 갱신되지 않습니다. 상태 변경 추적을 위해 updatedAt도 함께 갱신해야 합니다.

🔎 제안: 상태 변경 시 updatedAt 갱신
 fun approve() {
     if (status != TransactionStatus.PENDING) {
         throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.")
     }
     status = TransactionStatus.SUCCESS
     reason = "정상 승인되었습니다."
+    updatedAt = LocalDateTime.now()
 }

 fun invalidCard() {
     if (status != TransactionStatus.PENDING) {
         throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.")
     }
     status = TransactionStatus.FAILED
     reason = "잘못된 카드입니다. 다른 카드를 선택해주세요."
+    updatedAt = LocalDateTime.now()
 }

 fun limitExceeded() {
     if (status != TransactionStatus.PENDING) {
         throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.")
     }
     status = TransactionStatus.FAILED
     reason = "한도초과입니다. 다른 카드를 선택해주세요."
+    updatedAt = LocalDateTime.now()
 }

또는 JPA의 @PreUpdate 콜백을 활용할 수 있습니다:

@PreUpdate
fun onPreUpdate() {
    updatedAt = LocalDateTime.now()
}

Committable suggestion skipped: line range outside the PR's diff.

apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt-87-100 (1)

87-100: ServerWebInputException은 WebFlux용 예외입니다

ServerWebInputException은 Spring WebFlux에서 사용되는 예외입니다. 현재 프로젝트가 spring-boot-starter-web(WebMVC)을 사용하고 있다면 이 핸들러는 호출되지 않을 수 있습니다. WebMVC 환경에서는 MethodArgumentNotValidException, BindException 등을 처리해야 합니다.

#!/bin/bash
# WebFlux 의존성 확인
rg -n "webflux|reactive" --type gradle --type kotlin
fd build.gradle | xargs grep -l "webflux" 2>/dev/null || echo "webflux not found"
🧹 Nitpick comments (32)
docs/7round/7round.md (2)

24-30: 과제 정보 섹션: 동기/비동기 선택 기준의 구체적 가이드 추가 검토

동기 vs 비동기 처리를 구분하는 감각을 기르는 것이 목표로 제시되었는데, 실제 구현에서 개발자들이 판단할 때 참고할 구체적 기준(예: 응답 시간 영향 여부, 데이터 일관성 요구도 등)이 추가로 명시되면 더욱 도움이 될 것 같습니다.

또한 스프링의 ApplicationEventListener(비동기)와 TransactionalEventListener(AFTER_COMMIT) 사용을 언제 선택할지에 대한 예시가 있으면 구현 시 참고하기 좋을 것으로 보입니다.


46-53: 서버 레벨 로깅과 트랜잭션 경계 분리가 잘 명시됨

"유저의 행동"을 서버 레벨에서 추적하고 로깅하는 항목(48줄)이 별도로 포함되어 있어 관찰성(observability) 관점에서 좋은 설계입니다. 다만 로깅의 범위와 수준(어떤 행동을 기록할지, 민감 정보는 어떻게 처리할지 등)에 대한 구체적 가이드라인이 있으면 구현 시 일관성을 높일 수 있을 것 같습니다.

docs/6round/6round.md (1)

74-83: 비동기 결제 의미 섹션의 포맷팅을 개선하세요.

라인 74-83의 비동기 결제 의미와 확률/지연 정보 부분이 블록 인용문 내에서 일관성 있게 표현되지 않았습니다. 아래와 같이 마크다운을 개선하세요:

  • 요청 성공 확률: 60%
  • 요청 지연: 100ms ~ 500ms
  • 처리 지연: 1s ~ 5s

처리 결과:

  • 성공: 70%
  • 한도 초과: 20%
  • 잘못된 카드: 10%

이렇게 구조화하면 가독성과 이해도가 더 높아집니다.

apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)

1-1: 패키지 구조 개선 제안

패키지명이 com.loopers로 매우 일반적입니다. PG 시뮬레이터의 특성을 반영하여 com.loopers.pg.simulator 또는 com.loopers.pgsimulator와 같이 더 구체적인 패키지명을 사용하는 것을 고려해보세요.

apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java (3)

28-29: 타임아웃 값이 CI/CD 환경에서 불충분할 수 있습니다.

현재 2초 타임아웃은 로컬 개발 환경에서는 충분하지만, CI/CD 파이프라인이나 부하 상황에서는 비동기 이벤트 처리가 지연될 수 있습니다. 타임아웃을 5초 이상으로 늘리거나 환경 변수로 설정 가능하게 하는 것을 권장합니다.

🔎 타임아웃 증가 제안
-    private static final long LIKE_COUNT_AWAIT_TIMEOUT_MILLIS = 2_000L;
+    private static final long LIKE_COUNT_AWAIT_TIMEOUT_MILLIS = 5_000L;
     private static final long LIKE_COUNT_AWAIT_INTERVAL_MILLIS = 50L;

152-174: 폴링 기반 검증 패턴이 적절합니다.

비동기 이벤트 처리의 eventual consistency를 검증하기 위한 폴링 패턴이 잘 구현되었습니다. 다만 더 나은 가독성과 제어를 위해 Awaitility 라이브러리 사용을 고려해볼 수 있습니다.

🔎 Awaitility 사용 예시 (선택사항)

build.gradle에 의존성 추가:

testImplementation 'org.awaitility:awaitility:4.2.0'

그 다음 polling 로직을 다음과 같이 간소화할 수 있습니다:

private void awaitProductLikeCount(Long productId, long expectedCount) {
    await()
        .atMost(Duration.ofMillis(LIKE_COUNT_AWAIT_TIMEOUT_MILLIS))
        .pollInterval(Duration.ofMillis(LIKE_COUNT_AWAIT_INTERVAL_MILLIS))
        .untilAsserted(() -> {
            long current = productRepository.findById(productId)
                    .map(Product::getLikeCount)
                    .orElseThrow();
            assertThat(current).isEqualTo(expectedCount);
        });
}

113-131: 일관성 확인: 이 테스트에서는 await 패턴을 사용하지 않습니다.

다른 테스트들은 awaitProductLikeCount를 사용하는데, 이 테스트(Line 113-131)는 직접 조회합니다. 좋아요가 존재하지 않을 때 unlike 호출이 아무 작업도 하지 않음을 검증하는 테스트이므로 이벤트가 발행되지 않아 await가 불필요한 것으로 보입니다. 하지만 명확성을 위해 주석을 추가하거나, 일관성을 위해 await를 사용하되 값이 변경되지 않음을 확인하는 방식도 고려해볼 수 있습니다.

apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEvent.java (1)

5-33: 불변 이벤트 설계가 우수합니다.

Java record를 활용한 불변 이벤트 구조가 적절합니다. compact constructor에서 null 검증을 수행하고 static factory method를 제공하는 패턴이 일관성 있게 구현되었습니다.

🔎 추가 검증 고려사항 (선택사항)

null 검증 외에 다음 검증도 고려해볼 수 있습니다:

public record ProductLiked(String userId, Long productId) {
    public ProductLiked {
        Objects.requireNonNull(userId, "userId 는 null 일 수 없습니다.");
        if (userId.isBlank()) {
            throw new IllegalArgumentException("userId는 빈 문자열일 수 없습니다.");
        }
        Objects.requireNonNull(productId, "productId 는 null 일 수 없습니다.");
        if (productId <= 0) {
            throw new IllegalArgumentException("productId는 양수여야 합니다.");
        }
    }
    // ...
}

다만 learnings를 참고하면 Like.create()에서 이미 검증을 수행하고 있으므로, 이벤트 레벨에서는 현재의 null 체크만으로도 충분할 수 있습니다.

apps/commerce-api/src/main/resources/application.yml (1)

42-50: 서킷 브레이커 설정 검토 권장

현재 서킷 브레이커 설정을 검토해주세요:

  • failure-rate-threshold: 50 (line 46): 실패율 50%에서 회로가 열립니다. PG와 같은 중요한 외부 시스템의 경우 30-40%가 더 적절할 수 있습니다.
  • slow-call-rate-threshold: 50 (line 50): 느린 호출 비율 50%도 동일하게 검토가 필요합니다.

높은 임계값은 장애 전파를 충분히 차단하지 못할 수 있으므로, 비즈니스 요구사항과 SLA를 고려하여 조정을 검토해주세요.

apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt (1)

3-7: 마지막 열거형 상수 뒤에 trailing comma를 추가하는 것을 고려하세요.

Kotlin의 모범 사례에 따르면 enum 상수 목록 마지막에 trailing comma를 추가하면 향후 값을 추가할 때 diff가 깔끔해지고 merge conflict 가능성이 줄어듭니다.

🔎 제안된 개선사항
 enum class CardType {
     SAMSUNG,
     KB,
-    HYUNDAI,
+    HYUNDAI,
 }
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt (1)

3-7: 마지막 열거형 상수 뒤에 trailing comma를 추가하는 것을 고려하세요.

Kotlin 모범 사례에 따라 enum 값 목록 끝에 trailing comma를 추가하면 향후 유지보수가 용이합니다.

🔎 제안된 개선사항
 enum class TransactionStatus {
     PENDING,
     SUCCESS,
-    FAILED
+    FAILED,
 }
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java (1)

31-35: 구현이 올바르게 작동합니다.

읽기 전용 트랜잭션 설정과 에러 처리가 적절합니다. 필요시 orderId 파라미터에 대한 null 검증을 추가하여 더 방어적인 코드로 만들 수 있습니다.

🔎 방어적 null 검증 추가 (선택사항)
 @Transactional(readOnly = true)
 public Order findById(Long orderId) {
+    if (orderId == null) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID가 null일 수 없습니다.");
+    }
     return orderRepository.findById(orderId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문 정보를 찾을 수 없습니다."));
 }
apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClient.java (1)

6-9: 인터페이스 정의가 올바릅니다. 구현 완료를 확인하세요.

OrderDataPlatformClient 인터페이스가 적절하게 정의되었습니다. AI 요약에 따르면 현재 구현체(OrderDataPlatformClientImpl)는 빈 메서드를 포함하고 있습니다. 데이터 플랫폼 연동이 계획된 작업인지 확인하고, 필요시 TODO 주석이나 이슈 트래킹을 추가하세요.

데이터 플랫폼 연동 구현을 위한 이슈 생성이나 구현 가이드가 필요하시면 도움을 드릴 수 있습니다.

apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt (1)

15-19: 트랜잭션 키 충돌 가능성 및 타임존 고려

UUID 앞 6자리만 사용하면 일일 약 1,670만 개의 고유 키가 가능합니다. PG 시뮬레이터 용도로는 충분하지만, 실제 운영 환경에서는 충돌 가능성이 있습니다.

또한 LocalDateTime.now()는 시스템 기본 타임존을 사용하므로, 분산 환경에서 서버 간 타임존이 다르면 날짜 부분이 불일치할 수 있습니다.

🔎 타임존 명시 및 UUID 길이 확장 제안
 fun generate(): String {
-    val now = LocalDateTime.now()
-    val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6)
+    val now = LocalDateTime.now(java.time.ZoneId.of("Asia/Seoul"))
+    val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 12)
     return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid"
 }
apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt (1)

16-20: 콜백 실패 시 재시도 메커니즘 부재

콜백 호출 실패 시 로깅만 하고 재시도하지 않습니다. 네트워크 일시적 장애로 인해 중요한 결제 콜백이 누락될 수 있습니다.

시뮬레이터 용도라면 현재 구현으로 충분할 수 있으나, 실제 운영 환경에서는 재시도 로직이나 실패 이벤트 큐잉이 필요합니다.

apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt (1)

16-20: 추가 유효성 검증을 고려하세요.

현재 amount만 검증하고 있지만, userId, orderId, cardNo, callbackUrl 등의 필수 문자열 필드들도 빈 값 또는 공백 검증이 필요합니다.

🔎 제안하는 개선 코드
 fun validate() {
+    require(userId.isNotBlank()) { "사용자 ID는 필수입니다." }
+    require(orderId.isNotBlank()) { "주문 ID는 필수입니다." }
+    require(cardNo.isNotBlank()) { "카드 번호는 필수입니다." }
+    require(callbackUrl.isNotBlank()) { "콜백 URL은 필수입니다." }
     if (amount <= 0L) {
         throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.")
     }
 }
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java (1)

14-32: isFailed() 메서드 추가 고려

isPending()isCompleted() 메서드가 있지만 FAIL 상태에 대한 헬퍼 메서드가 없습니다. 일관성을 위해 isFailed() 메서드 추가를 고려해 보세요.

🔎 제안 코드
     public boolean isPending() {
         return this == PENDING;
     }
+
+    public boolean isFailed() {
+        return this == FAIL;
+    }
apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java (1)

82-86: 상태 전환 유효성 검증 추가 고려

updateStatus()가 현재 상태와 무관하게 모든 상태 전환을 허용합니다. pg-simulator의 Kotlin Payment.kt에서는 PENDING 상태에서만 상태 변경을 허용하는 가드가 있습니다.

잘못된 상태 전환(예: SUCCESSFAIL)을 방지하려면 현재 상태 검증을 추가하는 것이 좋습니다.

🔎 제안 코드
     public void updateStatus(PaymentStatus status, String transactionKey, String reason) {
+        if (!this.status.isPending()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "PENDING 상태에서만 상태 변경이 가능합니다.");
+        }
         this.status = status;
         this.transactionKey = transactionKey;
         this.reason = reason;
     }
apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java (1)

9-11: @Service 어노테이션 사용 고려

PaymentService는 도메인 서비스 역할을 하므로 @Component 대신 @Service 어노테이션을 사용하면 의미가 더 명확해집니다. 기능상 차이는 없지만 코드 가독성 측면에서 권장됩니다.

🔎 제안 코드
-import org.springframework.stereotype.Component;
+import org.springframework.stereotype.Service;

-@Component
+@Service
 @RequiredArgsConstructor
 public class PaymentService {
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/OrderPaymentSyncScheduler.java (1)

22-29: 순차 처리 및 관찰성 개선을 고려하세요.

현재 구현은 모든 pending 결제를 순차적으로 처리하며, 개별 실패를 로그로만 기록합니다.

다음 개선 사항을 고려하세요:

  • 대량의 pending 결제가 있을 경우 처리 시간이 길어질 수 있으므로, 배치 크기 제한이나 병렬 처리를 고려
  • 동기화 성공/실패 건수를 메트릭으로 수집하여 모니터링 개선
  • 반복적인 실패 패턴을 감지하기 위한 알림 메커니즘 추가
apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentSupport.java (1)

18-36: Switch 표현식 사용을 고려하세요.

두 매핑 메서드가 동일한 if-statement 패턴을 사용하고 있습니다. Java 14+ 환경이라면 switch 표현식을 사용하여 가독성을 높일 수 있습니다.

🔎 제안하는 리팩토링
 public static OrderStatus mapOrderStatus(TransactionStatus status) {
-    if (status == SUCCESS) {
-        return OrderStatus.COMPLETE;
-    }
-    if (status == TransactionStatus.FAILED) {
-        return OrderStatus.FAIL;
-    }
-    return OrderStatus.PENDING;
+    return switch (status) {
+        case SUCCESS -> OrderStatus.COMPLETE;
+        case FAILED -> OrderStatus.FAIL;
+        case PENDING -> OrderStatus.PENDING;
+    };
 }

 public static PaymentStatus mapPaymentStatus(TransactionStatus status) {
-    if (status == SUCCESS) {
-        return PaymentStatus.SUCCESS;
-    }
-    if (status == TransactionStatus.FAILED) {
-        return PaymentStatus.FAIL;
-    }
-    return PaymentStatus.PENDING;
+    return switch (status) {
+        case SUCCESS -> PaymentStatus.SUCCESS;
+        case FAILED -> PaymentStatus.FAIL;
+        case PENDING -> PaymentStatus.PENDING;
+    };
 }
apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt (1)

29-30: 정렬을 데이터베이스 레벨로 이동하는 것을 고려하세요.

현재 sortedByDescending을 사용하여 메모리에서 정렬하고 있습니다. 결과 세트가 클 경우 비효율적일 수 있습니다. PaymentJpaRepository에 정렬을 포함한 쿼리 메서드를 추가하는 것이 좋습니다.

예: findByUserIdAndOrderIdOrderByUpdatedAtDesc(userId: String, orderId: String) 메서드를 JpaRepository에 추가

apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)

21-24: success() 메서드 오버로딩 시 모호성 발생 가능

success() (Line 22)와 success(data: T? = null) (Line 24)가 인자 없이 호출될 때 컴파일러가 어떤 메서드를 선택할지 모호할 수 있습니다. 실제로 Kotlin에서는 기본 매개변수가 있는 함수보다 인자가 없는 함수를 우선 선택하지만, 명시적으로 구분하는 것이 좋습니다.

🔎 제안: 메서드 시그니처 명확화
 companion object {
-    fun success(): ApiResponse<Any> = ApiResponse(Metadata.success(), null)
-
-    fun <T> success(data: T? = null) = ApiResponse(Metadata.success(), data)
+    fun <T> success(data: T? = null): ApiResponse<T?> = ApiResponse(Metadata.success(), data)
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)

17-21: 인덱스 설계 검토

transactionKey가 이미 @Id로 유일성이 보장되므로, idx_unique_user_order_transaction 인덱스에서 transaction_key를 포함한 유니크 제약은 부분적으로 중복될 수 있습니다. 만약 (user_id, order_id) 조합에 대한 유일성이 필요하다면 해당 조합만으로 유니크 인덱스를 만드는 것이 더 명확할 수 있습니다.

의도적으로 쿼리 최적화를 위해 이렇게 설계했는지 확인해 주세요.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java (1)

88-95: 캐시 키의 정렬 정보 정규화 권장

pageable.getSort().toString()"name: ASC,price: DESC" 형태로 공백과 콜론을 포함합니다. 캐시 키 일관성을 위해 정규화된 형식으로 변환하는 것이 좋습니다.

🔎 제안: 정렬 키 정규화
 private String listKey(Long brandId, Pageable pageable) {
-    String sortKey = pageable.getSort().toString();
+    String sortKey = pageable.getSort().stream()
+            .map(order -> order.getProperty() + "_" + order.getDirection())
+            .collect(Collectors.joining("-"));
     return "product:list:"
             + (brandId == null ? "all" : brandId) + ":"
             + pageable.getPageNumber() + ":"
             + pageable.getPageSize() + ":"
-            + sortKey;
+            + (sortKey.isEmpty() ? "unsorted" : sortKey);
 }
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java (1)

62-68: PaymentCallbackRequest의 필드 검증 누락

transactionKeyreason 필드에 검증 어노테이션이 없습니다. 특히 transactionKey는 결제 처리에 중요한 필드이므로 @NotBlank 검증을 추가하는 것이 좋습니다.

🔎 제안: 검증 어노테이션 추가
 public record PaymentCallbackRequest(
         @NotNull(message = "상태는 필수입니다.")
         PgPaymentV1Dto.TransactionStatus status,
+        @NotBlank(message = "거래 키는 필수입니다.")
         String transactionKey,
         String reason
 ) {
 }
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)

28-31: 생성자 주입과 필드 주입 혼용

@RequiredArgsConstructor를 사용하면서 @Value로 필드 주입을 혼용하고 있습니다. 일관성을 위해 생성자 주입으로 통일하는 것이 좋습니다.

🔎 제안: 생성자 주입으로 통일

별도의 configuration class에서 값을 주입하거나, 생성자 파라미터에 @Value를 사용하는 방법이 있습니다:

@Component
public class OrderFacade {

    private final OrderService orderService;
    private final ProductService productService;
    private final PointService pointService;
    private final PaymentService paymentService;
    private final OrderEventPublisher orderEventPublisher;
    private final String callbackBaseUrl;

    public OrderFacade(
            OrderService orderService,
            ProductService productService,
            PointService pointService,
            PaymentService paymentService,
            OrderEventPublisher orderEventPublisher,
            @Value("${app.callback.base-url}") String callbackBaseUrl) {
        this.orderService = orderService;
        this.productService = productService;
        this.pointService = pointService;
        this.paymentService = paymentService;
        this.orderEventPublisher = orderEventPublisher;
        this.callbackBaseUrl = callbackBaseUrl;
    }
    // ...
}
apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java (1)

57-66: NPE 방지 및 방어적 코드 개선 필요

responsenull이 아니더라도 response.data()null일 수 있으며, 현재 data.transactions()가 비어있을 때 경고 로그만 남기고 반환합니다. 또한, Line 65에서 마지막 트랜잭션을 가져오는 로직은 트랜잭션이 시간순으로 정렬되어 있다고 가정합니다.

🔎 방어적 코드 제안
     ApiResponse<PgPaymentV1Dto.OrderResponse> response = pgPaymentClient.getPayments(payment.getUserId(), payment.getOrderReference());
-    PgPaymentV1Dto.OrderResponse data = response != null ? response.data() : null;
+    if (response == null || response.data() == null) {
+        log.warn("결제 응답이 null입니다. orderId={}, orderReference={}", orderId, payment.getOrderReference());
+        return;
+    }
+    PgPaymentV1Dto.OrderResponse data = response.data();

     if (data == null || data.transactions() == null || data.transactions().isEmpty()) {
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java (1)

37-47: Fallback에서 FAILED 반환 시 일시적 오류와 실제 실패 구분 불가

fallback 메서드에서 TransactionStatus.FAILED를 반환하면, 일시적인 네트워크 오류와 실제 결제 실패를 구분할 수 없습니다. 호출자가 PENDING 상태로 처리하고 나중에 동기화하는 것이 더 안전할 수 있습니다.

🔎 PENDING 상태 반환 제안
 default ApiResponse<PgPaymentV1Dto.Response> fallback(String userId, PgPaymentV1Dto.Request request, Throwable throwable) {
     PgPaymentV1Dto.Response response = new PgPaymentV1Dto.Response(
             null,
-            PgPaymentV1Dto.TransactionStatus.FAILED,
+            PgPaymentV1Dto.TransactionStatus.PENDING,
             throwable.getMessage()
     );
     return new ApiResponse<>(
         ApiResponse.Metadata.fail("PG_ERROR", throwable.getMessage()),
         response
     );
 }
apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt (2)

22-25: 시뮬레이션 비율을 설정 가능하게 변경 권장

RATE_LIMIT_EXCEEDEDRATE_INVALID_CARD가 companion object에 하드코딩되어 있습니다. 테스트 시나리오에 따라 성공/실패 비율을 조정할 수 있도록 설정 파일에서 주입받는 것을 권장합니다.


83-87: notifyTransactionResult에 @transactional(readOnly = true) 누락

notifyTransactionResult는 데이터베이스에서 결제 정보를 조회하지만 @Transactional 어노테이션이 없습니다. 읽기 작업이므로 @Transactional(readOnly = true)를 추가하여 트랜잭션 경계를 명확히 하고 커넥션 최적화를 적용하는 것이 좋습니다.

🔎 수정 제안
+@Transactional(readOnly = true)
 fun notifyTransactionResult(transactionKey: String) {
     val payment = paymentRepository.findByTransactionKey(transactionKey)
         ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
     paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment))
 }
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt (1)

21-21: 하드코딩된 localhost URL

콜백 URL 접두사가 http://localhost:8080으로 하드코딩되어 있습니다. 이것이 PG 시뮬레이터의 의도된 동작이라면 괜찮지만, 다른 환경에서 사용하려면 설정 가능하게 만드는 것을 고려하세요.

🔎 설정 가능한 접두사로 개선

application.yml에서 설정값을 읽도록 변경:

// PaymentApi.kt에 설정 주입
@Value("\${pg-simulator.callback-url-prefix:http://localhost:8080}")
private lateinit var callbackUrlPrefix: String

또는 시뮬레이터의 특성상 현재 구현을 유지하되 주석으로 의도를 명확히 할 수 있습니다.

Comment on lines 17 to 21
public UserInfo register(String userId, String email, String birth, String gender) {
User user = userService.register(userId, email, birth, gender);
pointService.initPoint(user.getUserId());
return UserInfo.from(user);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "UserFacade.java" 2>/dev/null | head -5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

rg -n --type java -A 3 -B 10 'public UserInfo register' apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 657


🏁 Script executed:

rg -n --type java '@Transactional' apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


트랜잭션 경계를 확인하고 해결하세요.

register() 메서드에 @Transactional 어노테이션이 없습니다. 현재 코드에서 userService.register()로 사용자가 생성된 후 pointService.initPoint()에서 예외가 발생하면, 사용자는 이미 데이터베이스에 저장된 상태로 남게 됩니다. 이는 데이터 일관성을 깨뜨리고 포인트 초기화 없는 사용자가 시스템에 남을 수 있습니다.

메서드에 @Transactional 어노테이션을 추가하거나 다른 트랜잭션 관리 메커니즘으로 사용자 등록과 포인트 초기화를 원자성 있게 처리해야 합니다.

🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
around lines 17-21, the register method lacks a transactional boundary so if
pointService.initPoint(...) throws after userService.register(...) the user
remains persisted without points; annotate the method (or the containing class)
with @Transactional so both user creation and point initialization run in the
same transaction (or move the combined logic into a single @Transactional
service method), ensure the transaction manager is the same for both services
and use default Propagation.REQUIRED (or explicitly set it) and configure
rollbackFor as needed so exceptions during initPoint cause the whole transaction
to roll back.

Comment on lines 21 to 30
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LikeEvent.ProductLiked event) {
try {
productRepository.incrementLikeCount(event.productId());
} catch (Exception exception) {
log.error("집계 처리 실패 - productId={}, type=LIKE", event.productId(), exception);
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find ProductRepository interface and implementation
find . -name "*ProductRepository*" -type f | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 267


🏁 Script executed:

# Search for incrementLikeCount and decrementLikeCount implementations
rg -nP --type=java -B5 -A15 "(incrementLikeCount|decrementLikeCount)\s*\("

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 7966


🏁 Script executed:

# Look for retry mechanisms and transactional configuration in the codebase
rg -nP --type=java "@Retry|Resilience4j|@Transactional|propagation" apps/commerce-api/src/main/java/com/loopers/infrastructure | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1161


🏁 Script executed:

# Check ProductJpaRepository for lock methods
rg -nP --type=java "findByIdForUpdate|Lock|@Lock" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# View the complete ProductRepositoryImpl to check for any locking strategies
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2277


🏁 Script executed:

# Check Product entity for lock annotation and version field
rg -nP --type=java -A3 "class Product|@Version|@Lock" apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 206


🏁 Script executed:

# View the complete Product entity
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java | head -100

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3503


🏁 Script executed:

# Check ProductJpaRepository interface for any lock-related methods
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 983


락(Lock) 메커니즘이 누락되어 동시 요청 시 데이터 불일치가 발생합니다.

현재 구현은 findById()로 엔티티를 조회한 후 메모리에서만 수정하므로, 동시에 같은 상품을 조회하는 요청들이 같은 값을 기반으로 수정하여 업데이트가 손실됩니다. 예를 들어, 두 스레드가 동시에 좋아요 수 5를 읽고 각각 6으로 저장하면 결과는 6이 됩니다(7이어야 함). 또한 모든 예외를 로깅만 하고 재시도 메커니즘이 없어 실패하면 복구 불가능합니다.

필수 개선사항:

  1. 비관적 락 적용: ProductJpaRepository에 findByIdWithPessimisticLock() 메서드 추가 후 incrementLikeCount/decrementLikeCount에서 사용
  2. 재시도 메커니즘: PgPaymentClient처럼 @Retry 또는 Spring Retry를 적용하여 일시적 실패(DB 락, 타임아웃) 복구
  3. 보상 메커니즘: 주기적으로 실제 좋아요 수와 집계값 비교 후 불일치 수정

Comment on lines +18 to +31
public record Request(
@NotBlank(message = "주문 ID는 필수입니다")
String orderId,
@NotBlank(message = "카드 타입은 필수입니다")
String cardType,
@NotBlank(message = "카드 번호는 필수입니다")
String cardNo,
@NotNull(message = "결제 금액은 필수입니다")
@Positive(message = "결제 금액은 0보다 커야 합니다")
Long amount,
@NotBlank(message = "콜백 URL은 필수입니다")
String callbackUrl
) {
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for the PgPaymentV1Dto file and examine its full structure
find . -name "PgPaymentV1Dto.java" -type f | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 180


🏁 Script executed:

#!/bin/bash
# Find the file
fd "PgPaymentV1Dto.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 178


🏁 Script executed:

#!/bin/bash
# Read the full PgPaymentV1Dto.java file to check for toString() override and full structure
cat -n "apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentV1Dto.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1875


🏁 Script executed:

#!/bin/bash
# Search for logging patterns that use the Request object or cardNo field
rg "log\.(info|debug|warn|error)" apps/commerce-api/src --type java -A 2 -B 2 | grep -i "request\|cardno\|payment" | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2369


🏁 Script executed:

#!/bin/bash
# Search for any usage of PgPaymentV1Dto.Request in the codebase
rg "PgPaymentV1Dto\.Request\|PaymentV1Dto\.Request" apps/commerce-api/src --type java -B 2 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Search for where the Request DTO is used or passed around
rg "Request\s+" apps/commerce-api/src --type java -B 3 -A 3 | grep -i "pgpayment\|cardno\|log" | head -40

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2192


🏁 Script executed:

#!/bin/bash
# Search for payment request handling and controller endpoints that accept Request
rg "class.*Controller|@PostMapping|@RequestBody.*Request" apps/commerce-api/src --type java -B 2 -A 5 | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5851


🏁 Script executed:

#!/bin/bash
# Examine the PgPaymentClient.java file to see the fallback method implementation
cat -n "apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2810


🏁 Script executed:

#!/bin/bash
# Examine the OrderPaymentEventListener where Request is created
cat -n "apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderPaymentEventListener.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2333


🏁 Script executed:

#!/bin/bash
# Search for any debug/trace logging or FeignClient request logging configuration
rg "logging\.level\|feign\|spring\.cloud" apps/commerce-api --type properties --type yaml -i | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 122


🏁 Script executed:

#!/bin/bash
# Check if there's any custom FeignClient logging or interceptor
rg "RequestInterceptor\|FeignClient.*log\|feign.*interceptor" apps/commerce-api/src --type java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Look for application configuration files
find apps/commerce-api -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" | xargs cat 2>/dev/null | head -100

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1943


🏁 Script executed:

#!/bin/bash
# Search for any logging configuration
rg "log\|LOG" apps/commerce-api/src/main/resources --type yaml -i | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Check the logging configuration
cat apps/commerce-api/src/main/resources/logging.yml

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Find logging configuration files
find apps/commerce-api -name "logging*" -o -name "logback*"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Check what files are in the resources directory
ls -la apps/commerce-api/src/main/resources/

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 263


Request 레코드의 카드 번호 마스킹 필수

Request 레코드에 cardNo 필드가 포함되어 있으나 커스텀 toString() 메서드가 없어 기본 Record toString()이 모든 필드를 평문으로 노출합니다. Feign 클라이언트 DEBUG 로깅, 예외 스택 트레이스, 또는 문자열 연결 시 카드 번호가 노출될 수 있습니다. PCI DSS 규정상 카드 번호는 로그에서 마스킹되어야 합니다. toString() 메서드를 오버라이드하여 카드 번호를 마스킹하세요 (예: 마지막 4자리만 표시).

🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentV1Dto.java
around lines 18 to 31, the Request record exposes cardNo via the default record
toString(), so override toString() to mask the card number (e.g., replace all
but the last 4 digits with asterisks) and return a string that includes orderId,
cardType, masked cardNo, amount, and callbackUrl; handle null or short cardNo
safely (mask entirely if length < 4) and keep existing validation annotations
unchanged.

Comment on lines +38 to +46
try {
Page<Product> page =
objectMapper.readValue(json, new TypeReference<PageImpl<Product>>() {
});
return Optional.of(page);
} catch (Exception e) {
log.warn("Failed to deserialize product list from cache for key: {}", key, e);
return Optional.empty();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# RedisProductCache.java 파일 찾기
find . -name "RedisProductCache.java" -type f

# ObjectMapper 설정 관련 파일 찾기
find . -name "*.java" -type f | xargs grep -l "ObjectMapper" | head -20

# 의존성 파일 찾기
find . -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" | head -10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 764


🏁 Script executed:

#!/bin/bash
# Read JacksonConfig to check for PageImpl handling
echo "=== JacksonConfig.java ==="
cat -n ./supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java

echo -e "\n=== RedisProductCache.java - First 60 lines ==="
head -60 ./apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java

echo -e "\n=== commerce-api build.gradle.kts ==="
cat -n ./apps/commerce-api/build.gradle.kts

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5959


🏁 Script executed:

#!/bin/bash
# Check how ObjectMapper is used elsewhere in the codebase
echo "=== ObjectMapper usage patterns ==="
rg -n "ObjectMapper|@Autowired.*ObjectMapper" --type java -A 2 | head -60

# Check for ObjectMapper bean definitions
echo -e "\n=== ObjectMapper bean definitions ==="
rg -n "Bean.*ObjectMapper|ObjectMapper.*Bean" --type java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2554


ObjectMapper 주입 필요 - PageImpl 역직렬화 불가

현재 코드는 스프링이 관리하는 ObjectMapper 대신 새로운 인스턴스를 생성하고 있습니다. 이로 인해 JacksonConfig에서 등록한 모듈(PageJacksonModule 포함)이 적용되지 않아 PageImpl 역직렬화에 실패합니다. KafkaConfig와 같이 ObjectMapper를 생성자 주입받도록 수정하세요.

private final ObjectMapper objectMapper;

public RedisProductCache(RedisTemplate<String, String> redisTemplate, ObjectMapper objectMapper) {
    this.redisTemplate = redisTemplate;
    this.objectMapper = objectMapper;
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java
around lines 38-46, the code currently creates a new ObjectMapper which prevents
Spring-registered modules (e.g., PageJacksonModule) from being applied and
causes PageImpl deserialization to fail; change the class to accept ObjectMapper
via constructor injection (declare a final ObjectMapper field and assign it from
the constructor parameter alongside the existing RedisTemplate), remove any
local new ObjectMapper() usage, and let Spring inject the application-configured
ObjectMapper so Page deserialization succeeds.

Comment on lines +24 to +29
@Operation(summary = "결제 콜백", description = "PG가 결제 결과를 전달합니다.")
@PostMapping("/{orderReference}/callback")
ApiResponse<Object> callback(
@PathVariable("orderReference") String orderReference,
@Valid @RequestBody OrderV1Dto.PaymentCallbackRequest request
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Search for callback handler implementation
echo "=== Searching for callback handler implementation ==="
fd -type f -name "*.java" -o -name "*.kt" | xargs rg -l "callback|Callback" | head -20

echo -e "\n=== Looking for OrderV1ApiSpec implementation ==="
rg -n "class.*OrderV1.*Controller|class.*OrderV1.*Handler" --type=java --type=kotlin -A 10

echo -e "\n=== Searching for signature/verification logic ==="
rg -n "(signature|verify|authenticate|hmac|secret|validate)" --type=java --type=kotlin | grep -i "callback\|webhook\|payment" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3685


🏁 Script executed:

#!/bin/bash

# Search for callback-related implementations and verify signature logic
echo "=== Searching for OrderV1ApiSpec and callback implementations ==="
find . -type f \( -name "*.java" -o -name "*.kt" \) | xargs grep -l "OrderV1ApiSpec\|PaymentCallback" | head -10

echo -e "\n=== Looking for signature verification methods ==="
find . -type f \( -name "*.java" -o -name "*.kt" \) -exec grep -l "verify.*signature\|HMAC\|hmacSha256" {} \; | head -10

echo -e "\n=== Searching for PG client configuration ==="
find . -type f \( -name "*.java" -o -name "*.kt" \) | xargs grep -l "pgClient\|PgClient\|PaymentGateway" | head -10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 680


🏁 Script executed:

#!/bin/bash

# Look at the callback implementation in OrderV1Controller
echo "=== OrderV1Controller callback method ==="
sed -n '1,120p' apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java | cat -n

echo -e "\n=== Check for signature/verification in OrderPaymentProcessor ==="
cat apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java | head -60 | cat -n

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5735


🏁 Script executed:

#!/bin/bash

# Get callback method from OrderV1Controller
echo "=== OrderV1Controller.java ===" 
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java | head -100 | cat -n

# Check if there's any signature verification logic
echo -e "\n=== Searching for signature/HMAC verification ==="
find . -type f \( -name "*.java" -o -name "*.kt" \) -exec grep -H "hmac\|HMAC\|signature\|Signature" {} \; 2>/dev/null | head -20

# Check OrderPaymentProcessor
echo -e "\n=== OrderPaymentProcessor implementation ===" 
head -80 apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java | cat -n

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 6885


🏁 Script executed:

#!/bin/bash

# Check PaymentCallbackRequest to see what's validated
echo "=== OrderV1Dto PaymentCallbackRequest ===" 
grep -A 20 "class PaymentCallbackRequest\|record PaymentCallbackRequest" apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java

# Check if there's any security filter or interceptor
echo -e "\n=== Looking for security configuration ==="
find . -type f \( -name "*Security*.java" -o -name "*Interceptor*.java" -o -name "*Filter*.java" \) | head -10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 871


PG 콜백 엔드포인트의 발신자 검증 메커니즘을 구현하세요.

OrderV1Controller의 callback 엔드포인트(POST /{orderReference}/callback)에 서명 검증 메커니즘이 없어 보안 위험이 있습니다. 위조된 요청은 원래 출처처럼 행동하지만 위조된 데이터를 포함하는 요청이며, 악의적인 행위자가 시스템에 부작용을 일으키지 않도록 요청이 원본 출처에서 오지 않았는지 확인하는 것이 중요하며, 서명 검증이 가장 널리 사용되고 안전한 접근 방식입니다. 결제 웹훅을 검증하는 것은 모든 민감한 작업에 기본이 되어야 하며, 결제 제공자는 이러한 공격으로부터 보호하기 위한 방법을 고객에게 제공하지만 제공자에 따라 고객의 노력이 필요합니다.

PG에서 제공하는 서명 검증 방식(예: HMAC-SHA256)을 구현하여 콜백 요청의 진정성을 확인하고, 타임스탐프 검증과 요청 본문 검증을 추가하세요.

🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java
around lines 24-29, the callback endpoint lacks webhook sender verification;
implement PG signature verification: require the provider's signature and
timestamp headers (configurable header names), read the raw request body
(preserve bytes), validate timestamp is within a short tolerance window (e.g., 5
minutes) to prevent replay, compute HMAC-SHA256 over the canonical payload
(commonly timestamp + "." + rawBody) using a shared secret injected from secure
configuration, compare signatures using a constant-time comparison, and reject
invalid or stale requests with 401/403 before any side effects; add
failing-unit/integration tests and document the required headers and secret
config.

Comment on lines +1 to +7
package com.loopers.domain.payment

import com.loopers.application.payment.TransactionInfo

interface PaymentRelay {
fun notify(callbackUrl: String, transactionInfo: TransactionInfo)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find PaymentCoreRelay implementation
find . -name "*.kt" -type f | xargs grep -l "PaymentCoreRelay" | head -5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 178


🏁 Script executed:

#!/bin/bash
# Search for PaymentCoreRelay class definition
rg -l "class PaymentCoreRelay" --type kotlin

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 176


🏁 Script executed:

#!/bin/bash
# Check the infrastructure payment directory structure
find apps/pg-simulator/src -type d -name "*payment*"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 423


🏁 Script executed:

#!/bin/bash
# Search for notify method implementation in PaymentRelay implementations
rg -A 15 "fun notify" apps/pg-simulator/src --type kotlin

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2061


🏁 Script executed:

#!/bin/bash
# Read the full PaymentCoreRelay implementation
cat -n apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 999


🏁 Script executed:

#!/bin/bash
# Check if callbackUrl comes from user/untrusted source in PaymentApplicationService
cat -n apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt | grep -A 5 "notify"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 493


🏁 Script executed:

#!/bin/bash
# Check Payment entity to see if callbackUrl is user-controlled
rg -A 10 "class Payment" apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/ --type kotlin

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3250


🏁 Script executed:

#!/bin/bash
# Get full Payment class definition
cat -n apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3342


notify 메서드의 URL 검증 부재 - 심각한 보안 취약점

PaymentCoreRelay.notify() 구현에서 callbackUrl 검증이 완전히 부재합니다. 사용자가 제공한 URL을 검증 없이 직접 RestTemplate.postForEntity()로 전송하므로 다음 조치가 필수입니다:

  1. URL 형식 및 도메인 검증: 요청 전 URL 형식을 검증하고 허용된 도메인만 사용 가능하도록 화이트리스트 구현
  2. SSRF 방지: 내부 IP 범위(localhost, 192.168., 10., 169.254.169.254 등) 접근 차단
  3. RestTemplate 타임아웃 설정: 무한 대기 방지를 위해 연결/읽기 타임아웃 명시적 구성 필요

Comment on lines +28 to +29
// 100ms ~ 500ms 지연
Thread.sleep((100..500L).random())
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

요청 스레드를 차단하는 Thread.sleep() 사용

웹 컨트롤러에서 Thread.sleep()을 사용하면 요청 스레드가 블로킹되어 부하가 높을 때 스레드 풀이 고갈될 수 있습니다. 이는 시뮬레이터의 다른 요청 처리를 지연시키고 전체 시스템의 응답성을 저하시킵니다.

🔎 비동기 지연 구현을 위한 제안

코루틴 기반 비동기 지연을 사용하도록 변경:

+    @PostMapping
+    suspend fun request(
-    @PostMapping
-    fun request(
         userInfo: UserInfo,
         @RequestBody request: PaymentDto.PaymentRequest,
     ): ApiResponse<PaymentDto.TransactionResponse> {
         request.validate()

-        // 100ms ~ 500ms 지연
-        Thread.sleep((100..500L).random())
+        // 100ms ~ 500ms 지연 (non-blocking)
+        kotlinx.coroutines.delay((100..500L).random())

         // 40% 확률로 요청 실패
         if ((1..100).random() <= 40) {

또는 코루틴을 사용하지 않는 경우, Spring의 DeferredResult나 CompletableFuture를 사용하여 비동기 처리를 구현할 수 있습니다.

📝 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.

Suggested change
// 100ms ~ 500ms 지연
Thread.sleep((100..500L).random())
@PostMapping
suspend fun request(
userInfo: UserInfo,
@RequestBody request: PaymentDto.PaymentRequest,
): ApiResponse<PaymentDto.TransactionResponse> {
request.validate()
// 100ms ~ 500ms 지연 (non-blocking)
kotlinx.coroutines.delay((100..500L).random())
// 40% 확률로 요청 실패
if ((1..100).random() <= 40) {
🤖 Prompt for AI Agents
In
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
around lines 28-29, the controller uses Thread.sleep(...) which blocks the
request thread; replace it with a non-blocking delay. Make the endpoint
coroutine-friendly by marking the handler suspend (or return a reactive/async
type), and call kotlinx.coroutines.delay((100..500L).random()) instead of
Thread.sleep; alternatively, if not using coroutines, change the handler to
return a CompletableFuture/DeferredResult and perform the sleep-equivalent
asynchronously on a separate thread or scheduler. Ensure imports and function
signatures are updated accordingly so the delay is non-blocking and the request
thread is not blocked.

Comment on lines +51 to +59
@GetMapping
fun getTransactionsByOrder(
userInfo: UserInfo,
@RequestParam("orderId", required = false) orderId: String,
): ApiResponse<PaymentDto.OrderResponse> {
return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId)
.let { PaymentDto.OrderResponse.from(it) }
.let { ApiResponse.success(it) }
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find PaymentApi.kt file
fd -t f "PaymentApi.kt" apps/pg-simulator/

# Also search for PaymentApplicationService
fd -t f "PaymentApplicationService" apps/pg-simulator/ --type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 265


🏁 Script executed:

#!/bin/bash
# Read PaymentApi.kt around the relevant lines
echo "=== PaymentApi.kt ===" 
wc -l apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
echo ""
echo "Content around lines 51-59:"
sed -n '45,65p' apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 879


🏁 Script executed:

#!/bin/bash
# Read PaymentApplicationService.kt to find findTransactionsByOrderId method
echo "=== PaymentApplicationService.kt ===" 
wc -l apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
echo ""
echo "Full file content:"
cat -n apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4449


@RequestParam(required=false)와 non-nullable 타입의 불일치

Line 54의 orderId: String은 non-nullable 타입인데 required = false로 설정되어 있습니다. 요청에서 orderId가 제공되지 않으면 Spring이 null 값을 non-nullable String에 바인딩하려고 시도하여 NullPointerException이 발생합니다.

다음 중 하나로 수정해야 합니다:

  • orderId를 nullable로 변경: @RequestParam("orderId", required = false) orderId: String?
  • 또는 required = true로 설정하고 필수 파라미터로 유지

후자의 경우 paymentApplicationService.findTransactionsByOrderId에서 null 처리 추가는 불필요합니다. 전자의 경우 해당 메서드도 nullable String을 수용하도록 수정이 필요합니다.

🤖 Prompt for AI Agents
In
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
around lines 51–59, the @RequestParam is marked required = false but the
parameter is non-nullable (orderId: String), which will cause binding to fail
when the param is absent; either change the parameter to nullable (orderId:
String?) and update paymentApplicationService.findTransactionsByOrderId to
accept a nullable String and handle nulls accordingly, or change required =
false to required = true to make the parameter mandatory (no service changes
needed). Make the chosen change consistently in both controller and service
signatures/logic.

@adminhelper
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Dec 25, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

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/resources/application.yml (1)

72-94: 환경별 프로파일에 외부 서비스 URL 오버라이드 필요

dev, qa, prd 프로파일 섹션이 정의되어 있으나, 환경별로 달라져야 하는 다음 설정들에 대한 오버라이드가 누락되었습니다:

  • app.callback.base-url
  • pg.client.base-url
  • 선택적으로 pg.client 타임아웃 값들 (환경별 네트워크 특성에 맞게)

각 환경에 맞는 실제 URL과 타임아웃 설정을 추가하세요.

♻️ Duplicate comments (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java (3)

34-34: 락 메커니즘 누락으로 동시성 문제가 발생합니다.

이전 리뷰에서 지적된 것과 동일한 이슈입니다. incrementLikeCount() 메서드가 비관적 락 없이 조회 후 수정하면, 동시 요청 시 업데이트가 유실됩니다.

이전 리뷰 코멘트를 참고하여 비관적 락 적용 또는 재시도 메커니즘을 구현해주세요.


41-51: ProductLiked 핸들러와 동일한 문제들이 존재합니다.

이 메서드도 다음 이슈들을 동일하게 가지고 있습니다:

  1. @Async + @Transactional 조합의 트랜잭션 경계 문제
  2. decrementLikeCount()의 락 메커니즘 누락 (이전 리뷰와 중복)
  3. 예외 처리로 인한 데이터 불일치 가능성

위의 ProductLiked 핸들러 코멘트를 참고하여 동일하게 수정해주세요.


36-38: 모든 예외를 무시하면 데이터 불일치가 발생합니다.

집계 실패 시 로그만 남기고 예외를 삼키면:

  • 실제 좋아요 수와 Product의 likeCount가 불일치
  • 카탈로그 이벤트도 누락되어 다운스트림 시스템과도 불일치

최소한 중요 메트릭(집계 실패율)을 기록하고, 주기적인 정합성 체크 배치 작업을 고려해주세요.

🧹 Nitpick comments (11)
apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java (1)

26-29: Null 입력 시 silent return이 의도된 동작인지 확인 필요

order 또는 payment가 null일 때 아무런 로그 없이 리턴하고 있습니다. 디버깅 시 문제를 추적하기 어려울 수 있습니다. 의도된 동작이라면 무시해도 되지만, 예상치 못한 null 입력을 감지하려면 경고 로그 추가를 고려해 주세요.

🔎 로깅 추가 제안
+import lombok.extern.slf4j.Slf4j;

+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class OrderDataPlatformClientImpl implements OrderDataPlatformClient {
     ...
     @Override
     public void send(Order order, Payment payment) {
         if (order == null || payment == null) {
+            log.warn("Order or Payment is null, skipping event publish. order={}, payment={}", order, payment);
             return;
         }
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (1)

49-52: 동기 전송 시 타임아웃 설정 확인 필요

kafkaTemplate.send(...).get()은 블로킹 호출입니다. Kafka 브로커 응답이 지연되면 스케줄러 스레드가 장시간 블록될 수 있습니다. 타임아웃이 있는 get(timeout, unit) 사용을 고려해 주세요.

🔎 타임아웃 적용 제안
-                kafkaTemplate.send(event.getTopic(), event.getPartitionKey(), payload).get();
+                kafkaTemplate.send(event.getTopic(), event.getPartitionKey(), payload).get(30, TimeUnit.SECONDS);

import 추가:

import java.util.concurrent.TimeUnit;
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java (1)

49-60: JPA dirty checking으로 명시적 save() 생략 가능

@Transactional 메서드 내에서 관리되는 엔티티는 dirty checking을 통해 자동으로 변경사항이 flush됩니다. outboxEventRepository.save(event) 호출 없이도 상태 변경이 반영됩니다.

다만, 명시적 save()가 코드 가독성과 의도 전달에 도움이 된다면 현재 방식을 유지해도 무방합니다. Based on learnings, 이 프로젝트에서는 JPA dirty checking을 선호하지만 현재 코드도 동작에는 문제없습니다.

apps/commerce-api/src/main/resources/application.yml (2)

32-40: Retry 정책에 exponential backoff 고려

현재 고정된 200ms 대기 시간으로 재시도하고 있습니다. 결제 게이트웨이의 일시적 장애에 대응하기 위해 exponential backoff를 사용하면 재시도 성공률을 높일 수 있습니다.

🔎 Exponential backoff 설정 예시
 resilience4j:
   retry:
     instances:
       pgRetry:
         max-attempts: 3
-        wait-duration: 200ms
+        wait-duration: 500ms
+        exponential-backoff-multiplier: 2
+        exponential-max-wait-duration: 2000ms
         retry-exceptions:
           - feign.RetryableException
         fail-after-max-attempts: true

이렇게 하면 500ms → 1000ms → 2000ms로 점진적으로 재시도 간격이 늘어납니다.


32-50: Resilience4j 모니터링 엔드포인트 확인 권장

Circuit breaker와 retry 설정이 추가되었으므로, 운영 환경에서 모니터링을 위해 actuator 엔드포인트가 활성화되어 있는지 확인하세요.

monitoring.yml 또는 actuator 설정에 다음이 포함되어 있는지 검증하세요:

#!/bin/bash
# Description: Verify resilience4j actuator endpoints configuration

# Search for resilience4j actuator endpoints in monitoring.yml
fd -e yml monitoring.yml --exec rg -n "resilience4j"

# Search for actuator exposure configuration
fd -e yml -e yaml . --exec rg -n "management.endpoints.web.exposure.include" -C 2
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java (1)

3-3: 도메인 레이어에서 인프라스트럭처 레이어를 직접 참조하고 있습니다.

ProductCacheRefreshService는 도메인 패키지(com.loopers.domain.product)에 위치하지만, 인프라스트럭처 패키지(com.loopers.infrastructure.product.ProductCacheRefresher)를 직접 import하고 있습니다. 이는 클린 아키텍처/헥사고날 아키텍처 원칙에 위배됩니다.

도메인 레이어에 ProductCacheEviction 같은 인터페이스를 정의하고, ProductCacheRefresher가 이를 구현하도록 리팩토링하는 것을 권장합니다.

🔎 권장 리팩토링 예시

도메인 레이어에 인터페이스 추가:

// com.loopers.domain.product.ProductCacheEviction
public interface ProductCacheEviction {
    void evict(Long productId, Long brandId);
}

그리고 ProductCacheRefresher가 이 인터페이스를 구현하도록 수정:

-public class ProductCacheRefresher {
+public class ProductCacheRefresher implements ProductCacheEviction {

ProductCacheRefreshService에서 인터페이스 사용:

-import com.loopers.infrastructure.product.ProductCacheRefresher;
...
-    private final ProductCacheRefresher productCacheRefresher;
+    private final ProductCacheEviction productCacheEviction;
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

26-29: 예외 발생 시 로깅 추가를 고려해보세요.

DataIntegrityViolationException을 무시하는 것은 멱등성 패턴에서 올바른 접근입니다(중복 키는 이미 처리된 이벤트를 의미). 하지만 운영 관찰성(observability)을 위해 debug 레벨 로그를 추가하면 중복 이벤트 발생 빈도를 모니터링하는 데 도움이 될 수 있습니다.

🔎 제안
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class EventHandledService {
     ...
         try {
             eventHandledRepository.save(EventHandled.handled(eventId, handler, topic, eventType, occurredAt));
         } catch (DataIntegrityViolationException ignore) {
+            log.debug("Event already handled: eventId={}", eventId);
         }
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java (1)

40-42: 이벤트 메시지 검증을 강화하세요.

현재 검증은 eventId만 확인하지만, Line 50에서 사용되는 productIddelta는 검증하지 않습니다. 또한 특정 이벤트 타입만 처리해야 하는 경우 eventType 검증도 필요할 수 있습니다.

🔎 검증 강화 제안
 private boolean isValid(CatalogEventMessage message) {
-    return message != null || message.eventId() == null) {
-        continue;
-    }
+    return message != null
+            && message.eventId() != null
+            && message.productId() != null;
 }

이벤트 타입 필터링이 필요한 경우:

private boolean shouldProcess(CatalogEventMessage message) {
    return "LikeCreatedEventV1".equals(message.eventType()) 
            || "LikeCanceledEventV1".equals(message.eventType());
}
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

47-48: 음수 클램핑 시 로깅을 고려하세요.

Math.max(0L, next)로 음수를 방지하는 것은 적절하지만, 실제로 클램핑이 발생하면 데이터 불일치(예: 좋아요보다 취소가 많음)를 나타낼 수 있습니다. 진단을 위해 경고 로그 추가를 고려하세요.

🔎 로깅 추가 제안
 public boolean applyLikeDelta(long delta, long version) {
     if (shouldSkip(version, lastCatalogVersion)) {
         return false;
     }
     long next = this.likeCount + delta;
+    if (next < 0) {
+        log.warn("Like count clamped to 0 for productId={}, current={}, delta={}", 
+                 productId, likeCount, delta);
+    }
     this.likeCount = Math.max(0L, next);
     this.lastCatalogVersion = version;
     return true;
 }
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java (2)

75-77: 문자열 리터럴을 상수로 정의하세요.

"COMPLETE" 같은 매직 스트링은 상수나 Enum으로 관리하는 것이 좋습니다. 여러 곳에서 동일한 값을 사용할 때 오타나 불일치를 방지할 수 있습니다.

🔎 상수 정의 제안
+private static final String ORDER_STATUS_COMPLETE = "COMPLETE";
+
 private boolean shouldCountSale(OrderEventMessage message) {
-    return Objects.equals(message.orderStatus(), "COMPLETE");
+    return Objects.equals(message.orderStatus(), ORDER_STATUS_COMPLETE);
 }

또는 OrderStatus enum이 있다면 그것을 사용하세요.


79-87: 캐시 갱신 최적화를 고려하세요.

동일한 productId가 여러 주문 항목에 포함된 경우 Line 85의 refreshIfSoldOut이 중복 호출될 수 있습니다. 중복 productId를 수집하여 한 번만 갱신하면 불필요한 DB 조회와 캐시 작업을 줄일 수 있습니다.

🔎 최적화 제안
 private void handleSalesMetrics(OrderEventMessage message, long version) {
+    Set<Long> processedProducts = new HashSet<>();
     for (OrderEventMessage.OrderItemPayload item : message.items()) {
         if (item == null || item.productId() == null || item.quantity() == null) {
             continue;
         }
         productMetricsService.increaseSales(item.productId(), item.quantity(), version);
-        productCacheRefreshService.refreshIfSoldOut(item.productId());
+        processedProducts.add(item.productId());
     }
+    processedProducts.forEach(productCacheRefreshService::refreshIfSoldOut);
 }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cf6c0c5 and 987d68a.

📒 Files selected for processing (36)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • docs/8round/8round.md
  • modules/kafka/src/main/resources/kafka.yml
✅ Files skipped from review due to trivial changes (1)
  • docs/8round/8round.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java
🧰 Additional context used
🧠 Learnings (15)
📓 Common learnings
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
📚 Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
📚 Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • modules/kafka/src/main/resources/kafka.yml
📚 Learning: 2025-11-15T08:01:46.868Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 29
File: docs/week2/03.1-state-diagram.md:80-105
Timestamp: 2025-11-15T08:01:46.868Z
Learning: MySQL/PostgreSQL에서 `SELECT ... WHERE id IN (...) FOR UPDATE`는 애플리케이션이 제공한 ID 순서를 무시하고 인덱스 스캔 순서(일반적으로 PK 오름차순)로 비관적 락을 획득한다. 따라서 교착상태 방지를 위해 애플리케이션 레벨에서 ID를 정렬하는 것은 효과가 없다. 실제 교착상태 위험은 서로 다른 리소스 타입(예: Product와 PointAccount)을 서로 다른 순서로 락킹할 때 발생하므로, 리소스 타입 간 일관된 락킹 순서를 유지하는 것이 중요하다.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
📚 Learning: 2025-12-16T09:44:15.945Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 58
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt:157-183
Timestamp: 2025-12-16T09:44:15.945Z
Learning: In PaymentService.requestPgPayment (apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt), PG payment requests are idempotent using paymentId as the idempotency key, so Retryable covering the entire method including the pgClient.requestPayment call is safe and will not cause duplicate charges even if retries occur due to ObjectOptimisticLockingFailureException.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
📚 Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java
📚 Learning: 2025-12-20T00:38:37.594Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxRelayService.kt:46-52
Timestamp: 2025-12-20T00:38:37.594Z
Learning: In OutboxRelayService in apps/commerce-api (Loopers-dev-lab/loopers-spring-kotlin-template), the scheduler uses fixedDelay which guarantees serial execution within a single JVM instance, so Volatile is sufficient for backoff state variables (consecutiveFailures, nextAllowedTime) and AtomicInteger/AtomicLong are not necessary for thread safety.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
📚 Learning: 2025-12-18T13:24:54.339Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
📚 Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java
🧬 Code graph analysis (10)
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java (1)
  • Component (9-19)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
  • Component (7-36)
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java (1)
  • Component (9-19)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
  • Component (9-24)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java (1)
  • Component (9-19)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
  • Component (9-24)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java (1)
  • Component (9-19)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java (1)
  • Component (15-61)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java (1)
  • Component (8-23)
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)
  • Component (12-36)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java (1)
  • Component (15-61)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)
  • Component (12-36)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java (5)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java (1)
  • Slf4j (16-94)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
  • Component (7-36)
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java (1)
  • Component (9-19)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
  • Component (9-24)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java (1)
  • Component (9-19)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java (1)
  • Component (10-30)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
  • Component (10-31)
🔇 Additional comments (37)
modules/kafka/src/main/resources/kafka.yml (1)

18-21: 멱등성 프로듀서 설정이 올바르게 구성되었습니다.

acks: all, enable.idempotence: true, max.in.flight.requests.per.connection: 5 조합은 정확히 한 번(exactly-once) 전달 시맨틱을 제공하는 표준 Kafka 프로듀서 패턴입니다. Outbox 패턴 기반의 이벤트 발행 아키텍처에 적합한 설정입니다.

Line 17의 retries: 3 설정이 의도적인지 확인해 주세요. 멱등성이 활성화된 경우 Kafka는 기본적으로 재시도를 MAX_INT로 설정하는데, 3으로 제한하면 빠른 실패를 선호하는 것으로 해석됩니다. 현재 설정대로 유지하려면 타임아웃 관련 전체 동작(request.timeout.ms: 20000 × 최대 3회 재시도)이 애플리케이션 요구사항과 일치하는지 검증하시기 바랍니다.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java (2)

19-20: 잘 구조화된 중첩 레코드입니다.

OrderItemPayload가 주문 항목 정보를 명확하고 간결하게 표현하고 있습니다. 필드 타입과 구조가 적절합니다.


12-13: String 타입 사용은 적절합니다.

orderStatuspaymentStatus 필드가 String 타입인 것은 올바른 설계입니다. 도메인 계층에 해당 enum 타입이 존재하지 않으며, 이벤트 메시지는 Kafka 직렬화/역직렬화가 필요한 DTO로서 String 타입이 표준 패턴입니다. 현재 구현은 유지해도 무방합니다.

Likely an incorrect or invalid review comment.

apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java (2)

17-27: 클래스 구조와 의존성 주입이 적절합니다.

컴포넌트 선언과 필드 구성이 명확하며, 필요한 의존성이 올바르게 주입되고 있습니다.


53-71: 카탈로그 이벤트 발행 로직은 명확합니다.

페이로드 구성과 OutboxService 호출이 적절하며, productId를 파티션 키로 사용하여 순서 보장이 가능합니다.

단, 이 메서드의 실패도 호출부의 catch 블록에서 무시되므로, 위에서 지적한 예외 처리 개선이 필요합니다.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java (1)

3-7: LGTM!

Outbox 이벤트 라이프사이클을 위한 상태 enum이 명확하게 정의되어 있습니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java (1)

10-15: LGTM!

Outbox 패턴에 필요한 쿼리 메서드가 적절하게 정의되어 있습니다. findByStatusOrderByIdAsc는 FIFO 순서로 이벤트를 처리하기 위해 ID 순 정렬을 사용하고 있어 올바른 접근입니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java (1)

44-63: LGTM!

Payload 구성이 명확하고 필요한 필드들이 적절히 포함되어 있습니다. occurredAt 파라미터를 통해 이벤트 발생 시간을 외부에서 주입받아 일관성을 유지하고 있습니다.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java (1)

6-13: LGTM!

도메인 레이어에서 인프라스트럭처 의존성 없이 깔끔한 Repository 인터페이스를 정의하고 있습니다. DIP(의존성 역전 원칙)를 잘 따르고 있습니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (1)

36-58: 트랜잭션 경계와 중복 발행 가능성 검토 필요

현재 구조에서 잠재적 이슈가 있습니다:

  1. publishPendingEvents()@Transactional이 있지만, 내부에서 호출하는 outboxService.markSent()markFailed()도 각각 @Transactional이 선언되어 있습니다.
  2. Kafka 전송 성공 후 markSent() 호출 전에 실패하면, 재시도 시 중복 발행이 발생할 수 있습니다.

fixedDelay를 사용하여 단일 JVM에서 직렬 실행이 보장되므로 동시성 이슈는 없습니다. 다만, 중복 발행 가능성이 낮더라도 consumer 측에서 멱등성 처리가 필요합니다. 이미 event_handled 테이블로 멱등성 처리가 구현되어 있다면 문제없습니다.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (1)

72-81: LGTM!

Factory method 패턴을 사용하여 생성자를 캡슐화하고 있습니다. pending() 메서드를 통해 초기 상태(PENDING, attemptCount=0)가 명확하게 설정됩니다.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java (1)

26-42: LGTM!

append() 메서드가 JSON 직렬화와 이벤트 저장을 트랜잭션 내에서 원자적으로 처리하고 있습니다. 직렬화 실패 시 적절한 예외를 던지고 있어 오류 추적이 용이합니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)

28-35: LGTM!

findTopPending()에서 배치 크기를 1~100으로 제한하는 방어적 코딩이 적용되어 있습니다. 과도한 메모리 사용과 DB 부하를 방지하는 좋은 접근입니다.

apps/commerce-api/build.gradle.kts (1)

5-5: Kafka 모듈 의존성 추가 승인

Kafka 기반 이벤트 아키텍처 구현을 위한 모듈 의존성이 적절하게 추가되었습니다.

apps/commerce-api/src/main/resources/application.yml (2)

25-30: Feign 클라이언트 설정 적절함

pgPaymentClient가 pg.client의 타임아웃 설정을 올바르게 참조하고 있습니다. 다만 앞서 언급한 대로 pg.client.read-timeout-ms 값 조정이 필요합니다.


63-63: Kafka 설정 임포트 승인

이벤트 기반 아키텍처 구현을 위한 kafka.yml 임포트가 적절하게 추가되었습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java (1)

1-7: 표준 JPA 리포지토리 패턴을 올바르게 따르고 있습니다.

ProductInventory 엔티티에 대한 기본 CRUD 작업을 제공하는 표준 Spring Data JPA 리포지토리입니다. 구현이 깔끔하고 적절합니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)

1-7: 표준 JPA 리포지토리 패턴을 올바르게 따르고 있습니다.

ProductMetrics 엔티티에 대한 기본 CRUD 작업을 제공하는 표준 Spring Data JPA 리포지토리입니다. 구현이 깔끔하고 적절합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java (1)

1-26: 읽기 전용 프로젝션 엔티티로 적절하게 구현되었습니다.

ProductInventory는 streamer 모듈에서 product 테이블을 읽기 전용으로 접근하기 위한 프로젝션 엔티티로 보입니다. @GeneratedValue 없이 @id를 사용하고 setter가 없는 것은 기존 데이터를 조회만 하는 용도로 의도된 설계입니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java (1)

1-9: 이벤트 중복 처리 방지를 위한 리포지토리가 올바르게 구현되었습니다.

EventHandled 엔티티에 대한 JPA 리포지토리로, existsByEventId 메서드를 통해 이벤트 처리 여부를 확인할 수 있습니다. Kafka 이벤트 소비자의 멱등성 보장에 적절한 설계입니다.

apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)

16-29: 좋아요 증감 로직 테스트가 올바르게 구현되었습니다.

버전 기반 중복 처리 방지 로직을 검증하고 있으며, 이전 버전이나 중복 버전의 이벤트가 무시되는 것을 확인합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java (1)

5-10: 도메인 리포지토리 인터페이스가 깔끔하게 정의되었습니다.

이벤트 중복 처리 방지를 위한 메서드들이 명확하게 정의되어 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

1-10: 도메인 리포지토리 인터페이스가 올바르게 정의되었습니다.

ProductMetrics 도메인을 위한 리포지토리 인터페이스가 깔끔하게 정의되어 있으며, Optional을 적절히 사용하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryRepository.java (1)

5-8: LGTM!

도메인 레이어에서 인프라스트럭처를 분리하는 깔끔한 리포지토리 인터페이스입니다. Optional 반환 타입으로 null 안전성을 보장합니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)

9-24: LGTM!

JPA 리포지토리에 대한 깔끔한 위임 구현입니다. 도메인 리포지토리 인터페이스를 올바르게 구현하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java (1)

8-23: LGTM!

멱등성 처리를 위한 EventHandled 엔티티 영속화를 담당하는 리포지토리 구현입니다. 다른 리포지토리 구현체들과 일관된 패턴을 따르고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java (1)

9-19: LGTM!

읽기 전용 트랜잭션으로 인벤토리 조회를 수행하는 서비스입니다. 트랜잭션 경계를 제공하면서 리포지토리에 대한 접근을 캡슐화합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java (1)

17-29: LGTM!

재고 소진 시 캐시를 무효화하는 로직이 올바르게 구현되어 있습니다. null 체크와 재고 조건 검증이 적절합니다. @Transactional(readOnly = true)는 DB 읽기 작업에만 적용되며, Redis 작업은 JPA 트랜잭션 범위 밖이므로 문제없습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

16-19: LGTM!

Short-circuit 평가를 사용한 null 안전한 구현입니다. eventId가 null이면 DB 조회 없이 false를 반환합니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java (1)

20-33: LGTM!

개별 상품 상세 캐시 키 삭제와 리스트 캐시 패턴 무효화 로직이 올바르게 구현되어 있습니다. null 체크로 안전하게 처리됩니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (2)

13-23: LGTM!

버전 기반 업데이트 패턴이 잘 구현되어 있습니다. applyLikeDelta의 반환값에 따른 조건부 저장은 불필요한 DB 쓰기를 방지합니다. 새 엔티티의 경우 명시적 save() 호출이 필요하므로 적절한 구현입니다.


25-35: LGTM!

applyLikeDelta와 일관된 패턴을 따르고 있으며, quantity <= 0 검증이 추가되어 있습니다. 버전 기반 조건부 저장으로 중복 업데이트를 방지합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

43-51: 버전 기반 중복 방지 로직이 잘 구현되었습니다.

shouldSkip을 통한 버전 체크로 Out-of-order 이벤트와 중복 처리를 방지합니다. Line 48의 Math.max(0L, next)는 좋아요 수가 음수가 되는 것을 방지합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (2)

11-34: 엔티티 설계가 적절합니다.

이벤트 멱등성 추적을 위한 필드 구성이 잘 되어 있습니다. eventId 길이 64자는 UUID(36자) 및 기타 형식을 충분히 수용하며, nullable 필드(eventType, occurredAt)는 다양한 이벤트 형식을 유연하게 처리할 수 있습니다.


50-50: 타임스탬프 생성 방식을 확인하세요.

ZonedDateTime.now()를 사용하면 각 서버의 시스템 시계에 의존합니다. 분산 환경에서 서버 간 시계 차이가 있을 수 있지만, 감사 추적 목적으로는 허용 가능한 수준입니다. 더 정밀한 순서 보장이 필요하면 외부 타임스탬프 소스 사용을 고려하세요.

이 애플리케이션의 배포 환경과 시계 동기화 정책을 확인하여 현재 접근 방식이 적합한지 검토하세요.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java (1)

9-19: 구현이 적절합니다.

표준 리포지토리 패턴을 따르는 깔끔한 위임 구현입니다. ProductMetricsRepositoryImpl과 일관된 구조를 유지하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java (1)

43-63: 버전 기반 중복 방지가 효과적으로 작동하고 있어 추가 조치가 필요하지 않습니다.

ProductMetrics.applyLikeDelta()는 이벤트의 버전을 비교하여 이미 처리된 버전의 이벤트를 건너뜁니다(line 43-44의 shouldSkip 확인). markHandled가 실패하여 배치가 재처리되더라도, 같은 버전의 이벤트는 lastCatalogVersion >= version 조건으로 인해 두 번째 업데이트가 차단되므로 지표 중복이 발생하지 않습니다. 현재의 순서(비즈니스 로직 → markHandled → acknowledge)는 안전하고 멱등성이 보장됩니다.

Comment on lines +15 to +19
// resilience4j
implementation("io.github.resilience4j:resilience4j-spring-boot3")

// feign
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Search for Spring Cloud BOM in all gradle files
echo "=== Searching for Spring Cloud BOM ==="
rg "spring-cloud-dependencies" --type=gradle

# Search for resilience4j BOM
echo -e "\n=== Searching for resilience4j BOM ==="
rg "resilience4j-bom" --type=gradle

# Check for platform/BOM imports
echo -e "\n=== Searching for platform declarations ==="
rg "platform|dependencyManagement" --type=gradle -A 2

# Check the build.gradle.kts structure
echo -e "\n=== Checking build.gradle.kts structure ==="
find . -name "build.gradle.kts" | head -5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 886


🏁 Script executed:

#!/bin/bash

# Read the entire build.gradle.kts file to understand version management
echo "=== Reading apps/commerce-api/build.gradle.kts ==="
wc -l apps/commerce-api/build.gradle.kts

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 176


🏁 Script executed:

#!/bin/bash

# Read the apps/commerce-api/build.gradle.kts file
echo "=== apps/commerce-api/build.gradle.kts ==="
cat -n apps/commerce-api/build.gradle.kts

# Read the root build.gradle.kts to check for parent BOM declarations
echo -e "\n=== root build.gradle.kts ==="
cat -n build.gradle.kts

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5972


resilience4j-spring-boot3의 버전 관리 전략 확인 필요

Spring Cloud BOM은 root build.gradle.kts의 dependencyManagement 블록(lines 42-46)에서 이미 선언되어 있으므로 spring-cloud-starter-openfeign의 버전은 적절히 관리됩니다.

다만 resilience4j-spring-boot3은 resilience4j-bom이 프로젝트에 선언되어 있지 않아 명시적인 버전 관리가 필요합니다. 현재 버전이 명시되지 않은 상태이므로, 다음 중 하나를 검토하세요:

  1. resilience4j-bom을 dependencyManagement에 추가하거나
  2. resilience4j-spring-boot3에 명시적으로 버전을 지정하거나
  3. 프로젝트 속성(project.properties)을 통해 버전을 관리

추가로 Feign 및 resilience4j 통합 테스트를 위한 전용 테스트 의존성 추가도 검토하세요.

Comment on lines +88 to +100
public void markFailed(String message) {
this.status = OutboxStatus.FAILED;
this.lastError = message;
}

public void markPendingForRetry(String message) {
this.status = OutboxStatus.PENDING;
this.lastError = message;
}

public void increaseAttempt() {
this.attemptCount += 1;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for attemptCount, maxRetry, MAX_RETRY, and retry limit related keywords
rg -n "attemptCount|maxRetry|MAX_RETRY|retry.*limit|RETRY.*LIMIT" --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Find OutboxService and OutboxEventPublisher files
fd -e "java" | xargs rg -l "class OutboxService|class OutboxEventPublisher"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 256


🏁 Script executed:

#!/bin/bash
# Search for all mentions of attemptCount in the entire codebase
rg -n "attemptCount" --type=java -B 2 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1625


🏁 Script executed:

#!/bin/bash
# Read OutboxService to see how retry limit is handled
cat apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2038


🏁 Script executed:

#!/bin/bash
# Read OutboxEventPublisher to see how retry mechanism works
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2406


재시도 횟수 제한 로직 추가 필요

현재 코드에서 최대 재시도 횟수 제한이 없습니다. OutboxService.markFailed() 메서드가 attemptCount를 확인하지 않고 무조건 markPendingForRetry()를 호출하고 있으며, OutboxEventPublisher.publishPendingEvents()에서도 재시도 횟수를 검사하지 않습니다. 이로 인해 영구적으로 실패하는 이벤트가 무한정 재시도되어 리소스를 낭비할 수 있습니다.

OutboxService.markFailed() 메서드에 최대 재시도 횟수(예: 5회) 초과 시 상태를 FAILED로 최종 결정하는 로직을 추가하세요.

🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
around lines 88-100, add a capped retry policy: define a MAX_RETRY constant
(e.g., 5) and modify the failure handling so that when an event fails you
increment attemptCount and then if attemptCount >= MAX_RETRY set status =
OutboxStatus.FAILED and set lastError, otherwise set status =
OutboxStatus.PENDING and set lastError; also ensure any publisher logic
(publishPendingEvents) skips events whose attemptCount >= MAX_RETRY so
permanently failed events are not requeued.

Comment on lines +29 to +31
@Async
@Transactional
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

@async@transactional 조합은 트랜잭션 경계를 깨뜨립니다.

@Async는 별도 스레드에서 메서드를 실행하고, @Transactional은 그 스레드에서 새로운 트랜잭션을 시작합니다. @TransactionalEventListener(AFTER_COMMIT)은 원본 트랜잭션이 커밋된 후에 트리거되지만, 이 리스너의 트랜잭션이 실패해도 원본 트랜잭션은 이미 커밋되어 롤백할 수 없습니다.

권장 해결책:

  • @Async 제거하고 동기 처리하거나
  • @Transactional 제거하고 멱등성 키 기반 재처리 메커니즘 사용
  • 또는 별도의 Kafka consumer로 분리하여 재시도 가능하도록 구현
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java
around lines 29-31: the combination of @Async and @Transactional with
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) breaks
transactional semantics because the listener runs in a separate thread and any
failure in its transaction cannot roll back the original transaction; fix by
choosing one approach: remove @Async to run the listener synchronously so it
shares transactional ordering with the original commit, or remove @Transactional
and implement an idempotent retry/reprocessing mechanism (e.g., idempotency keys
and retry logic) for the async execution, or move this logic into a dedicated
Kafka consumer/service that supports retries and durable failure handling; apply
one of these options and update tests/config accordingly.

Comment on lines +13 to +15
app:
callback:
base-url: http://localhost:8082
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

환경별 콜백 URL 설정 누락

PG 콜백 URL이 localhost:8082로 하드코딩되어 있어 로컬 환경 외에서는 작동하지 않습니다. dev, qa, prd 프로파일에 대한 환경별 콜백 URL 설정이 필요합니다.

🔎 환경별 설정 예시
 ---
 spring:
   config:
     activate:
       on-profile: dev
+
+app:
+  callback:
+    base-url: https://dev-api.yourdomain.com

 ---
 spring:
   config:
     activate:
       on-profile: qa
+
+app:
+  callback:
+    base-url: https://qa-api.yourdomain.com

 ---
 spring:
   config:
     activate:
       on-profile: prd
+
+app:
+  callback:
+    base-url: https://api.yourdomain.com

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +17 to +23
pg:
client:
base-url: http://localhost:8081
connect-timeout-ms: 1000
read-timeout-ms: 500
sync:
fixed-delay-ms: 6000000
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

PG 클라이언트 타임아웃 및 동기화 주기 검토 필요

  1. read-timeout-ms: 500ms가 너무 짧습니다: 결제 처리는 외부 PG사와의 통신을 포함하므로 500ms는 매우 공격적인 설정입니다. 네트워크 지연이나 PG사 응답 시간을 고려하면 타임아웃이 자주 발생할 수 있습니다.

  2. fixed-delay-ms: 6000000ms (100분) 검증 필요: 동기화 작업이 100분마다 실행되는 것이 의도된 것인지 확인이 필요합니다. 일반적으로 결제 상태 동기화는 더 짧은 주기로 실행됩니다.

  3. 환경별 PG URL 설정 누락: localhost:8081은 로컬 환경에서만 작동합니다.

🔎 권장 설정
 pg:
   client:
     base-url: http://localhost:8081
     connect-timeout-ms: 1000
-    read-timeout-ms: 500
+    read-timeout-ms: 3000  # 결제 작업에 충분한 시간 제공
   sync:
-    fixed-delay-ms: 6000000
+    fixed-delay-ms: 300000  # 5분 (필요시 조정)
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/resources/application.yml around lines 17 to 23,
the PG client timeouts and sync interval are unsafe for production and the
base-url is hardcoded to localhost; increase read-timeout-ms from 500 to a
higher sensible default (e.g., 2000-5000ms) and ensure connect-timeout-ms is
also reasonable (e.g., 1000-3000ms) to avoid premature failures, change
sync.fixed-delay-ms from 6000000 (100 minutes) to a more typical interval (e.g.,
600000 = 10 minutes) or make it configurable, and replace the hardcoded base-url
with an environment-specific configuration (use spring profile or an env var
placeholder like ${PG_BASE_URL} so prod/staging can override); validate these
values with the team and add comments/documentation for expected env overrides.

Comment on lines +35 to +42
private void evictPattern(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (CollectionUtils.isEmpty(keys)) {
return;
}
redisTemplate.delete(keys);
log.debug("Evicted {} keys for pattern={}", keys.size(), pattern);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

프로덕션 환경에서 Redis KEYS 명령어 사용은 권장되지 않습니다.

redisTemplate.keys(pattern)은 내부적으로 Redis의 KEYS 명령어를 사용합니다. 이 명령어는:

  • O(N) 시간 복잡도로 전체 키를 스캔
  • 실행 중 Redis 서버를 블로킹
  • 키가 많은 프로덕션 환경에서 심각한 지연 유발 가능

SCAN 명령어를 사용하는 것을 권장합니다.

🔎 SCAN 사용 예시
 private void evictPattern(String pattern) {
-    Set<String> keys = redisTemplate.keys(pattern);
-    if (CollectionUtils.isEmpty(keys)) {
-        return;
-    }
-    redisTemplate.delete(keys);
-    log.debug("Evicted {} keys for pattern={}", keys.size(), pattern);
+    ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(100).build();
+    Set<String> keys = new HashSet<>();
+    try (Cursor<String> cursor = redisTemplate.scan(scanOptions)) {
+        while (cursor.hasNext()) {
+            keys.add(cursor.next());
+        }
+    }
+    if (!keys.isEmpty()) {
+        redisTemplate.delete(keys);
+        log.debug("Evicted {} keys for pattern={}", keys.size(), pattern);
+    }
 }

필요한 import 추가:

import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import java.util.HashSet;
📝 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.

Suggested change
private void evictPattern(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (CollectionUtils.isEmpty(keys)) {
return;
}
redisTemplate.delete(keys);
log.debug("Evicted {} keys for pattern={}", keys.size(), pattern);
}
private void evictPattern(String pattern) {
ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(100).build();
Set<String> keys = new HashSet<>();
try (Cursor<String> cursor = redisTemplate.scan(scanOptions)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
}
if (!keys.isEmpty()) {
redisTemplate.delete(keys);
log.debug("Evicted {} keys for pattern={}", keys.size(), pattern);
}
}

@@ -0,0 +1,65 @@
package com.loopers.interfaces.consumer;

import com.loopers.confg.kafka.KafkaConfig;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

컴파일 오류: 패키지명 오타를 수정하세요.

confgconfig의 오타입니다. 이 임포트는 컴파일 실패를 일으킵니다.

🔎 수정 제안
-import com.loopers.confg.kafka.KafkaConfig;
+import com.loopers.config.kafka.KafkaConfig;
📝 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.

Suggested change
import com.loopers.confg.kafka.KafkaConfig;
import com.loopers.config.kafka.KafkaConfig;
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java
around line 3, the import uses a misspelled package name
"com.loopers.confg.kafka.KafkaConfig" causing a compile error; update the import
to the correct package "com.loopers.config.kafka.KafkaConfig" (fix the
folder/package name if necessary and ensure package declarations match) so the
class resolves and compiles.

Comment on lines +5 to +13
public record CatalogEventMessage(
String eventId,
String eventType,
Long productId,
String userId,
long delta,
ZonedDateTime occurredAt
) {
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find CatalogEventMessage usage in the codebase
echo "=== Finding CatalogEventMessage usage ==="
rg -n "CatalogEventMessage" --type java -A 5 -B 2

echo -e "\n=== Looking for consumer implementations ==="
fd -e java -p "consumer" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5078


🏁 Script executed:

#!/bin/bash
# Search for validation/verification patterns in consumer layer
echo "=== Searching for null checks and validation patterns ==="
rg -n "CatalogEventMessage|@NotNull|Objects.requireNonNull|if.*null" --type java \
  -p apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ \
  -A 3 -B 1 2>/dev/null || echo "Path may not exist, searching broader..."

echo -e "\n=== Looking for consumer handler methods ==="
rg -n "subscribe|handle|process|consume" --type java -A 10 | grep -A 10 -B 3 "CatalogEventMessage"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 9658


🏁 Script executed:

#!/bin/bash
# Explore the commerce-streamer directory structure
echo "=== Directory structure of commerce-streamer ==="
find apps/commerce-streamer -type f -name "*.java" | head -20

echo -e "\n=== Looking for message handlers and processors ==="
find apps/commerce-streamer -type f -name "*Consumer*" -o -name "*Handler*" -o -name "*Processor*"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2352


🏁 Script executed:

#!/bin/bash
# Get the full CatalogMetricsConsumer code
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2939


🏁 Script executed:

#!/bin/bash
# Check if productId/userId fields are required based on Kafka schema or message definition
echo "=== Checking for any validation annotations or schema definitions ==="
rg -n "@NotNull|@Valid|@Required|nullable" --type java apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/

echo -e "\n=== Checking Kafka/Jackson configuration ==="
rg -n "JsonProperty|JsonIgnore|DeserializationFeature" --type java apps/commerce-streamer/src/main/java/com/loopers/

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 203


Kafka 메시지 DTO로 적절하게 정의되었습니다. 다만, 필수 필드 검증이 완전하지 않습니다.

Record 사용이 불변 payload를 위해 적절하나, 소비자의 null 검증에 문제가 있습니다. CatalogMetricsConsumer에서:

  • eventId는 검증됨 (line 40)
  • productId는 검증 없이 사용됨 (line 50에서 직접 호출) → 잠재적 NPE 위험
  • userId는 소비자에서 사용되지 않음에도 메시지에 포함되어 있음

최소한 productId에 대한 null 체크를 추가하거나, 메시지에서 제거하세요.

🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java
around lines 5–13, the record allows productId to be null which leads to a
potential NPE in CatalogMetricsConsumer (uses productId without check); update
the record to enforce non-null for productId (e.g., add a compact constructor
that calls Objects.requireNonNull(productId, "productId") or change the type to
a primitive if semantically appropriate) and, since userId is not used by the
consumer, either remove userId from the record to simplify the payload or
document/retain it if required by other consumers; implement the chosen change
and run affected consumers to ensure no runtime NPEs.

@@ -0,0 +1,94 @@
package com.loopers.interfaces.consumer;

import com.loopers.confg.kafka.KafkaConfig;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

컴파일 오류: 패키지명 오타를 수정하세요.

confgconfig의 오타입니다. CatalogMetricsConsumer와 동일한 오류입니다.

🔎 수정 제안
-import com.loopers.confg.kafka.KafkaConfig;
+import com.loopers.config.kafka.KafkaConfig;
📝 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.

Suggested change
import com.loopers.confg.kafka.KafkaConfig;
import com.loopers.config.kafka.KafkaConfig;
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java
around line 3, the import uses the misspelled package
"com.loopers.confg.kafka.KafkaConfig"; change it to the correct package
"com.loopers.config.kafka.KafkaConfig" and update any other occurrences in this
file (and mirror the same fix in CatalogMetricsConsumer) so imports and package
names compile correctly.

Comment on lines +36 to +49
@Test
@DisplayName("이전 버전이나 동일 버전이면 판매량이 증가하지 않는다")
void increaseSales_appliesOnlyForNewerVersion() {
ProductMetrics metrics = ProductMetrics.initialize(2L);

boolean applied = metrics.increaseSales(3, 200L);
boolean skipped = metrics.increaseSales(5, 150L);
boolean duplicateSkip = metrics.increaseSales(1, 200L);

assertThat(applied).isTrue();
assertThat(skipped).isFalse();
assertThat(duplicateSkip).isFalse();
assertThat(metrics.getSalesCount()).isEqualTo(3);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

테스트 로직 오류: 버전 5는 버전 3보다 최신입니다.

Line 41에서 버전 3을 적용한 후, Line 42에서 버전 5를 적용하려고 합니다. 버전 5 > 3이므로 최신 버전이며 적용되어야 하지만, 테스트는 skipped=false(적용되지 않음)를 기대합니다. 이는 테스트 설명 "이전 버전이나 동일 버전이면 판매량이 증가하지 않는다"와 모순됩니다.

버전 5는 이전 버전이 아니라 최신 버전이므로, 다음 중 하나를 수정해야 합니다:

  1. Line 42의 버전을 2 이하로 변경 (이전 버전 테스트)
  2. 또는 assertThat(skipped).isTrue() 로 변경 (적용됨을 검증)
🔎 테스트 로직 수정 제안

버전 5를 이전 버전(예: 버전 2)으로 변경하여 테스트 의도를 명확히 하세요:

 @Test
 @DisplayName("이전 버전이나 동일 버전이면 판매량이 증가하지 않는다")
 void increaseSales_appliesOnlyForNewerVersion() {
     ProductMetrics metrics = ProductMetrics.initialize(2L);

     boolean applied = metrics.increaseSales(3, 200L);
-    boolean skipped = metrics.increaseSales(5, 150L);
+    boolean skipped = metrics.increaseSales(2, 150L);
     boolean duplicateSkip = metrics.increaseSales(1, 200L);

     assertThat(applied).isTrue();
     assertThat(skipped).isFalse();
     assertThat(duplicateSkip).isFalse();
     assertThat(metrics.getSalesCount()).isEqualTo(3);
 }
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
around lines 36 to 49, the test uses version 5 on line 42 which is newer than
the previously applied version 3, contradicting the test intent; change the
second increaseSales call’s version argument from 5 to a prior version (e.g., 2)
so it represents an older version and should be skipped, keeping the assertions
as-is (skipped false expectation remains for duplicateSkip and salesCount should
still equal 3).

@adminhelper adminhelper merged commit 987d68a into Loopers-dev-lab:adminhelper Dec 30, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant