Skip to content

[volume 4] - 4주차 과제 김남진#152

Open
Namjin-kimm wants to merge 12 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-4
Open

[volume 4] - 4주차 과제 김남진#152
Namjin-kimm wants to merge 12 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-4

Conversation

@Namjin-kimm
Copy link

@Namjin-kimm Namjin-kimm commented Mar 5, 2026

📌 Summary

  • 배경: 쿠폰 도메인이 없어 사용자에게 할인 혜택을 제공할 수 없었으며, 주문 시 동시 요청에 대한 재고/쿠폰 중복 처리 위험이 존재했다.
  • 목표: 쿠폰 템플릿 CRUD 및 사용자 쿠폰 발급/조회 기능을 구현하고, 주문-쿠폰 연동과 동시성 제어(비관적 락, 원자적 UPDATE)를 추가한다.
  • 결과: 쿠폰 도메인 전 계층 구현 완료, 주문 흐름에 쿠폰 할인 적용, 재고/쿠폰 동시성 테스트 통과.

구현중 고민한 것들

[쿠폰 만료일 관리 방식]

  • 고려한 대안:
    • A: 매번 CouponTemplate.expiredAt 참조 → 관리자가 만료일 수정 시 발급된 쿠폰의 유효 기간이 소급 변경됨
    • B: 발급 시 UserCoupon.expiredAt에 스냅샷 저장 → 발급 이후 템플릿 변경에 독립적
  • 최종 결정: B (스냅샷) 채택. minOrderAmount는 현행 정책 기준 적용이 자연스러우므로 스냅샷 제외.
  • 트레이드오프: 목록 조회 시 템플릿 재조회 없이 UserCoupon.expiredAt만으로 상태 계산 가능 → N+1 없음. 단, 만료일 일괄 수정이 기발급 쿠폰에 반영되지 않음.

[여러 상품 주문 시 데드락 방지]

  • 고려한 대안:
    • A: 고객이 여러 상품 주문 시, 해당 상품들의 데이터에 비관적 락이 걸리므로 일어나는 데드락을 방지하기 위해, 코드 레벨에서 상품을 id순으로 정렬하여 비관적 락 적용
    • B: MySQL InnoDB는 PK 인덱스 오름차순으로 락을 잡으므로 DB의 기능 활용
  • 최종 결정: B 채택. 추가적인 id 오름차순 정렬 불필요

[재고 동시성 제어]

  • 고려한 대안:
    • A: 원자적 업데이트 → 락을 걸지 않으므로 락 점유 시간이 없음
    • B: 비관적 락 → 재고 확인 시점부터 락 보유, 주문 로직이 길면 락 점유 시간이 길어지는 단점이 있음
  • 최종 결정: B 채택. 재고 부족으로 인한 주문 실패의 경우, 사용자에게 구체적인 에러 메세지(어떤 제품의 재고가 부족한지, 현재 재고, 주문 수량)를 전달하기 위해 비관적 락 사용

[상품 좋아요 컬럼 업데이트 방식]

  • 고려한 대안:
    • A: 원자적 업데이트 사용 → 락을 걸지 않아 성능상 좋지만, 도메인 모델에서 좋아요 수 증가라는 행위 사라짐.
    • B: 낙관적 락 → 사용자의 요청이 실패할 수 있고, 요청이 많을 경우 재시도 전략으로 인한 DB 부하가 있을 수 있음. 하지만 도메인 모델이 명확한 행위를 가지므로 비즈니스적인 행위가 명확함
  • 최종 결정: A 채택. 도메인 모델의 행위가 사라지고 repository 호출을 통해서 좋아요 업데이트를 하지만 DIP를 통해 repository 인터페이스가 도메인 레이어에 존재하므로 이 또한 도메인 비즈니스 로직에 해당된다고 판단. 성능상 이점이 좋다고 판단

🏗️Design Overview

변경 범위

  • 영향 받는 모듈/도메인: coupon, order, product (동시성)
  • 신규 추가:
    • 쿠폰 도메인 전 계층: CouponTemplate, UserCoupon, CouponService, CouponFacade, CouponAdminFacade, CouponTemplateRepository,
      UserCouponRepository, CouponV1Controller, CouponAdminV1Controller
    • 테스트: CouponFacadeIntegrationTest, CouponConcurrencyTest, OrderFacadeIntegrationTest, OrderConcurrencyTest, LikeConcurrencyTest
  • 제거/대체: spring-retry 의존성 제거 (낙관적 락 재시도 미사용)

주요 컴포넌트 책임

  • CouponTemplate: 쿠폰 정책(정액/정률 할인, 최소 주문 금액) 캡슐화. calculateDiscount(), validateMinOrderAmount() 도메인 메서드 제공
  • UserCoupon: 발급 시 expiredAt 스냅샷 보존. validateNotExpired(now) 로 만료 검증 (Tell, don't ask)
  • CouponService.applyCoupon(): 소유권 확인 → 만료 검증 → 최소 금액 검증 → 원자적 사용 처리 → 할인 금액 반환
  • OrderFacade.create(): 재고 확인(비관적 락) → 쿠폰 적용 → 재고 차감(dirty checking) → 주문 저장의 단일 트랜잭션 오케스트레이션
  • ProductService.findAllAndVerifyStock(): SELECT FOR UPDATE로 재고 확인. 호출자의 트랜잭션에 참여하여 락 유지

리뷰포인트

1. 재고 차감 - atomic update VS 비관적 락
재고 차감은 UPDATE product SET stock = stock - N WHERE id = ? AND stock >= N 형태로 확인과 차감을 단일 쿼리로 결합할 수 있어 atomic update만으로도 동시성을 보장할 수 있습니다. 그러나 이 방식은 UPDATE가 0 rows를 반환했을 때 어떤 상품의 재고가 얼마나 부족한지 애플리케이션 레벨에서 파악하기 어려워 구체적인 에러 메시지를 만들기 위해 추가 SELECT가 필요합니다. 사용자에게 "나이키 에어맥스 (현재 재고: 3개, 주문 수량: 5개)"처럼 구체적인 메시지를 추가 조회 없이 반환하기 위해 SELECT FOR UPDATE로 재고를 먼저 확인하는 비관적 락 방식을 선택하였습니다. atomic update를 사용할 수 있음에도 사용자 ux를 위해 비관적 락을 사용하였는데 이런 판단이 합리적인지 멘토님의 의견이 궁금합니다.

2. 데드락 방지
MySQL InnoDB는 PK 인덱스 오름차순으로 락을 잡는 사실을 알게 되었습니다. 그래서 여러 고객들이 동일한 상품 조합을 서로 다른 순서로 동시에 주문하면 데드락이 발생할 수 있는데, 현재는 이 부분을 mysql의 기능에 의존함으로써 데드락을 방지하는 상황입니다. 다만 이 동작은 SQL 표준이 아닌 MySQL 구현에 의존하는 것이므로 별도로 코드 레벨에서 상품의 재고 차감 순서를 항시 오름차순으로 보장하는 구현이 필요한지 궁금합니다.

3. 좋아요 유니크 제약 처리 위치
한 고객의 좋아요 요청이 동시에 요청될 경우 나머지 하나의 요청은 좋아요 테이블의 유니크 키(user_id, product_id) 제약에 의해 DataIntegrityViolationException 에러를 발생하게 됩니다.

try {
return likeRepository.saveAndFlush(new Like(userId, productId));
} catch (DataIntegrityViolationException e) {
throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.");
}

현재는 위와 같이 jpa의 saveAndFlush 사용하여 바로 DataIntegrityViolationException 에러를 발생하게 하고 catch 하여서 에러 메세지를 커스터마이징 하는 방법을 선택하였습니다. 이 방법 외에 ApiControllerAdvice에서 DataIntegrityViolationException을 전역으로 처리하여 "이미 처리되었습니다" 수준의 메시지를 반환하는 방법도 있는데, 어떤 방식이 더 적합한지 궁금합니다.

4. 실무에서의 동시성 제어 처리 방법
멘토링에서 말씀해주신 atomic > 낙관적 락 > 비관적 락 , 이 우선순위 순으로 최대한 적용하는게 좋다 라는 말씀을 듣고 모든 동시성 제어가 필요한 부분에 각각의 방법을 적용했을때 발생하는 장단점에 대해서 고민을 많이 했습니다. 재고 차감 부분에서 '사용자에게 구체적인 에러 메세지 반환'의 의도만 없었다면 락 없이 atomic update만 사용했을것 같습니다.
그치만 추후 주문 단계에 외부 API 호출(예: 결제)이나 여러 도메인을 조율하는 로직이 추가된다면 락 범위나 방식을 재검토해야 할 것 같은데, 실무에서는 이런 확장 가능성을 미리 감안하여 동시성 제어 방식을 선택하는지, 아니면 현재 요구사항에 맞게 최소한으로 적용하고 필요 시 리팩터링하는지 궁금합니다.

5. 도메인 주도 설계에서 조금은 벗어나는 느낌
동시성 제어가 필요한 부분에 최대한 atomic update를 사용할 수 있는 부분은 해당 방법을 적용하려고 노력했습니다. 하지만 구현하고 나서 코드를 보니 도메인 주도 설계와 조금은 멀어졌다는 느낌을 받았습니다. 재고 차감, 좋아요 등 바로 쿼리를 통해 상태 변경을 함으로써 도메인 모델에서의 명시적인 행위가 사라졌다는 느낌을 받았습니다. 어떻게 보면 인프라 레이어에 해당 책임을 위임한것 아닌가..?라는 생각이 들기도 했는데요, 그럼에도 내린 결론은 현재 저희 프로젝트는 DIP를 통해 도메인 주도 설계를 하고 있고, atomic update로 인해 도메인 모델의 행위가 사라졌다고 해도 repository interface는 domain layer에서 정의한 것이기 때문에, 해당 interface를 호출해서 진행하는 것 또한 도메인 비즈니스 로직이다라는 결론을 내렸습니다. 이에 대한 멘토님의 의견이 궁금합니다.

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

쿠폰 시스템을 신규 구현하고 주문 시스템에 쿠폰 적용 기능을 통합하며, 트랜잭션 경계와 동시성 처리를 강화하고 도메인 엔티티의 책임을 재조정하는 변경사항이다.

Changes

Cohort / File(s) Summary
쿠폰 도메인 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java, UserCoupon.java, CouponType.java, CouponStatus.java, CouponTemplateRepository.java, UserCouponRepository.java
쿠폰 템플릿과 사용자 쿠폰 엔티티 추가. CouponTemplate은 할인율/정액 계산, 최소 주문액 및 만료 검증 기능 포함. UserCoupon은 soft-delete 지원하는 발급 기록 엔티티. 저장소는 soft-delete 필터링과 원자적 사용 처리(useIfAvailable) 지원.
쿠폰 도메인 서비스
apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
발급, 적용, 템플릿 CRUD 및 배치 조회 메서드 제공. 발급 시 중복 검증, 적용 시 원자적 업데이트로 동시 요청 안전성 보장. 템플릿 삭제 시 연관된 쿠폰 cascade soft-delete.
쿠폰 인프라 저장소
apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateJpaRepository.java, CouponTemplateRepositoryImpl.java, UserCouponJpaRepository.java, UserCouponRepositoryImpl.java
JPA 저장소와 구현체로 soft-delete 필터링(deletedAt IS NULL) 일관성 유지. useIfAvailable@Modifying(flushAutomatically = true) 통해 즉시 flush.
쿠폰 애플리케이션 파사드 및 DTO
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminFacade.java, CouponFacade.java, CouponTemplateInfo.java, CouponTemplateRegisterCommand.java, CouponTemplateUpdateCommand.java, UserCouponInfo.java
관리자용/사용자용 쿠폰 파사드. UserCouponInforesolveStatus 헬퍼로 발급/사용/만료 상태를 조회 시점에 동적 계산. CouponTemplateInfo는 template 스냅샷 제공.
쿠폰 API 컨트롤러 및 DTO
apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java, CouponAdminV1Dto.java, CouponV1Controller.java, CouponV1Dto.java
관리자 CRUD(/api-admin/v1/coupons) 및 사용자 발급/조회(/api/v1/coupons) 엔드포인트. 요청/응답 DTO는 도메인 info 객체로부터 매핑.
주문 도메인 및 서비스 수정
apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java, OrderService.java
Order 생성자에 userCouponId, originalAmount, discountAmount 필드 추가. finalAmount 계산 필드로 도메인 로직 이동(BR-O13). OrderService.create 시그니처 확장.
주문 애플리케이션 레이어
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java, OrderCreateCommand.java, OrderInfo.java
OrderFacade에 다단계 주문 생성 로직: ① 비관적 락으로 재고만 확인 ② originalAmount 계산 ③ 쿠폰 적용(선택) ④ dirty-checking으로 재고 차감 ⑤ 가격 스냅샷 포함해 주문 저장. OrderCreateCommand/OrderInfo에 쿠폰 및 가격 필드 추가.
주문 API 계층
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java, OrderV1Dto.java
OrderCreateRequest/OrderResponse에 userCouponId, 금액 필드 추가. 메서드 시그니처는 변경 없음(DTO 매핑만 확장).
브랜드 시스템 개선
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java, BrandFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
BrandAdminFacade/BrandFacade에 @Transactional 추가. BrandAdminFacade.delete는 이제 관리 엔티티 획득 후 brand.delete() 호출(aggregate root로 책임 이동). BrandService에서 delete 메서드 제거.
상품 시스템 개선
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java, ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
ProductAdminFacade/ProductFacade에 @Transactional 추가. ProductService.verifyAndDecreaseStock → findAllAndVerifyStock으로 메서드 이름 변경 및 동작 변경: 재고 차감 로직 제거, 검증만 수행(SELECT FOR UPDATE).
좋아요 시스템 개선
apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java, LikeService.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java, LikeRepositoryImpl.java
LikeService.create에서 saveAndFlush + try-catch로 동시성 처리: UNIQUE 제약 위반 시 DataIntegrityViolationException을 CONFLICT 도메인 에러로 변환. LikeRepository에 saveAndFlush 메서드 추가.
테스트 - 도메인 및 통합
apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java, BrandServiceTest.java, product/ProductServiceIntegrationTest.java, order/OrderServiceIntegrationTest.java, order/OrderTest.java
BrandService delete 테스트 제거. ProductService 메서드 이름 변경 반영. Order 생성자 호출에 새 파라미터 추가 및 금액 검증 추가.
테스트 - 쿠폰 통합
apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java, UserCouponTest.java, CouponConcurrencyTest.java, CouponFacadeIntegrationTest.java
쿠폰 엔티티 생성, 할인 계산, 만료 검증 단위 테스트. 동시 발급 시 한 건만 성공 검증. 발급/적용/CRUD 통합 테스트.
테스트 - 주문/좋아요 동시성
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java, like/LikeConcurrencyTest.java
주문 동시 처리 시 재고 정확성 검증(초과 주문 시 실패 확인). 같은 사용자 동시 좋아요 시 1건만 성공, 다른 사용자 동시 좋아요는 모두 성공 검증.
테스트 - E2E
apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiE2ETest.java, CouponV1ApiE2ETest.java, order/OrderFacadeIntegrationTest.java, order/OrderAdminV1ApiE2ETest.java, order/OrderV1ApiE2ETest.java
쿠폰 관리자/사용자 API 엔드포인트 E2E. 주문 생성 시 쿠폰 적용 통합 테스트(유효, 기간만료, 중복사용, 다른 사용자, 최소주문액 미달, 트랜잭션 롤백 시나리오). 주문 API E2E 및 admin API 수정.
기타 설정
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
의존성 추가 시 빈 줄 정렬. 주석 텍스트 수정(L1 캐시 언급 제거).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant OrderFacade
    participant ProductService
    participant CouponService
    participant OrderService
    participant DB

    User->>OrderFacade: create(userId, items, couponId)
    OrderFacade->>ProductService: findAllAndVerifyStock(비관적락)
    ProductService->>DB: SELECT FOR UPDATE
    DB-->>ProductService: products
    ProductService-->>OrderFacade: verified products

    OrderFacade->>OrderFacade: originalAmount 계산
    
    alt 쿠폰 적용
        OrderFacade->>CouponService: applyCoupon(couponId, userId, originalAmount)
        CouponService->>DB: useIfAvailable (원자적 update)
        DB-->>CouponService: discount amount
        CouponService-->>OrderFacade: discount
    else 쿠폰 미적용
        OrderFacade->>OrderFacade: discount = 0
    end
    
    OrderFacade->>OrderFacade: finalAmount = originalAmount - discount
    
    Note over OrderFacade: dirty-checking으로 재고 차감<br/>(SELECT FOR UPDATE 보유)
    
    OrderFacade->>OrderService: create(userId, items, couponId, originalAmount, discount)
    OrderService->>DB: INSERT Order with amounts
    DB-->>OrderService: Order
    OrderService-->>OrderFacade: Order
    
    OrderFacade-->>User: OrderInfo
Loading
sequenceDiagram
    participant User
    participant CouponFacade
    participant CouponService
    participant UserCouponRepo
    participant DB

    User->>CouponFacade: issueCoupon(userId, templateId)
    
    CouponFacade->>CouponService: issue(userId, templateId)
    
    CouponService->>UserCouponRepo: existsByUserIdAndCouponTemplateId(중복확인)
    UserCouponRepo->>DB: SELECT ... WHERE deletedAt IS NULL
    DB-->>UserCouponRepo: exists?
    UserCouponRepo-->>CouponService: boolean
    
    alt 중복 발급 시도
        CouponService-->>CouponFacade: CONFLICT 예외
    else 새로운 발급
        CouponService->>CouponService: template 존재 확인 & expiredAt 스냅샷
        CouponService->>UserCouponRepo: save(new UserCoupon(...))
        UserCouponRepo->>DB: INSERT user_coupon
        DB-->>UserCouponRepo: UserCoupon
        UserCouponRepo-->>CouponService: UserCoupon
        CouponService-->>CouponFacade: UserCoupon
        CouponFacade->>CouponFacade: UserCouponInfo로 변환<br/>(status = resolveStatus)
        CouponFacade-->>User: UserCouponInfo
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

사유: 쿠폰 시스템이라는 신규 도메인이 추가되면서 도메인 엔티티(CouponTemplate, UserCoupon), 서비스, 저장소, API 계층에 걸쳐 일관된 설계가 필요하다. 동시에 기존 주문 시스템에 쿠폰 적용 로직이 깊숙이 통합되어 재고 관리 방식이 변경되었고, 트랜잭션 경계와 동시성 처리(원자적 useIfAvailable, saveAndFlush + 예외 변환)가 여러 곳에 산재해 있다. 또한 브랜드/상품 도메인의 책임 재조정(aggregate root 패턴 적용)과 메서드 시그니처 변경이 추가적으로 고려되어야 한다. 테스트 커버리지는 광범위하지만, 주문-쿠폰-재고 상호작용 시나리오와 동시성 상황이 복잡하게 얽혀 있어 검토에 상당한 주의가 필요하다.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive PR 제목이 '[volume 4] - 4주차 과제 김남진'으로 과제명만 명시되어 있으며 변경 사항의 핵심을 명확히 드러내지 않는다. 제목을 '쿠폰 도메인 구현 및 주문 동시성 제어'와 같이 수정하여 PR의 주요 변경 내용을 즉시 파악할 수 있도록 한다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 템플릿의 필수 섹션을 모두 포함하고 있으며, 설계 의사결정이 명확하게 기록되어 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 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.

@Namjin-kimm
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

✅ 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: 15

Note

Due to the large number of review comments, Critical, Major 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 (5)
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java (1)

268-296: ⚠️ Potential issue | 🟠 Major

비관적 락 계약을 실제로 검증하는 테스트가 필요하다.

현재 검증 범위는 "재고 미차감"뿐이라서, 구현에서 락이 빠지거나 lock timeout 설정이 사라져도 이 테스트는 그대로 통과한다. 운영에서는 동일 상품 동시 주문 시 과다판매나 요청 스레드 장시간 점유로 이어질 수 있다. 별도 스레드와 독립 트랜잭션으로 동일 상품을 선점한 뒤, 두 번째 findAllAndVerifyStock 호출이 설정된 시간 안에 대기 후 실패하거나 첫 번째 트랜잭션이 끝나기 전까지 진행되지 않음을 단언하는 케이스를 추가하는 편이 안전하다. 추가로 timeout 이후 예외 타입과 재고 불변까지 함께 검증해 달라. Based on learnings, findOptionByIdWithLock() uses entityManager.find(ProductOption.class, optionId, LockModeType.PESSIMISTIC_WRITE) without a lock timeout hint. The call flow is: OrderService.prepareOrderItems()findProductOnly() (loads only Product, not ProductOption) → deductStock()findOptionByIdWithLock(). Since ProductOption is never loaded into the 1st-level cache before this point, there is no stale read risk. However, the lock timeout hint (jakarta.persistence.lock.timeout) is missing, risking infinite wait.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java`
around lines 268 - 296, Add an integration test that actually verifies the
pessimistic lock behavior: spawn a separate thread/transaction that acquires a
PESSIMISTIC_WRITE lock on the same ProductOption (use
entityManager.find(ProductOption.class, optionId,
LockModeType.PESSIMISTIC_WRITE, Map.of("jakarta.persistence.lock.timeout",
5000)) or equivalent) and holds it briefly, then in the main test call
productService.findAllAndVerifyStock(...) and assert that the second call either
blocks and then fails with the configured lock timeout exception (and that
product stock remains unchanged); additionally, modify findOptionByIdWithLock()
to pass a lock timeout hint (jakarta.persistence.lock.timeout) when calling
entityManager.find(..., LockModeType.PESSIMISTIC_WRITE) so the DB/ORM enforces a
bounded wait rather than infinite blocking.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java (1)

54-74: ⚠️ Potential issue | 🟡 Minor

originalAmount, discountAmount null 검증 테스트가 누락되어 있다.

생성자 파라미터가 확장되었으나 originalAmount, discountAmount가 null일 때의 검증 테스트가 없다. 도메인 엔티티의 필수 필드 검증 누락은 런타임 NPE로 이어질 수 있으며, 장애 원인 추적이 어려워진다.

추가해야 할 테스트:

`@DisplayName`("originalAmount가 null이면 BAD_REQUEST 에러가 발생한다.")
`@Test`
void throwsBadRequest_whenOriginalAmountIsNull() {
    CoreException result = assertThrows(CoreException.class,
            () -> new Order(USER_ID, List.of(validOrderItem()), null, null, ZERO_DISCOUNT));
    assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}

`@DisplayName`("discountAmount가 null이면 BAD_REQUEST 에러가 발생한다.")
`@Test`
void throwsBadRequest_whenDiscountAmountIsNull() {
    CoreException result = assertThrows(CoreException.class,
            () -> new Order(USER_ID, List.of(validOrderItem()), null, VALID_PRICE, null));
    assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java`
around lines 54 - 74, Add missing null-validation unit tests for the expanded
Order constructor: add two tests in OrderTest that assert a CoreException with
ErrorType.BAD_REQUEST is thrown when constructing new Order(...) with
originalAmount set to null and when discountAmount is null; use the same pattern
as existing tests, calling new Order(USER_ID, List.of(validOrderItem()), null,
null, ZERO_DISCOUNT) for the originalAmount case and new Order(USER_ID,
List.of(validOrderItem()), null, VALID_PRICE, null) for the discountAmount case,
and assert result.getErrorType() equals ErrorType.BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java (1)

5-13: ⚠️ Potential issue | 🟠 Major

커맨드 생성 시 방어적 복사와 기본 불변식 검증을 추가해야 한다.

recordList<Item>은 가변 참조이므로 호출자가 원본 리스트를 수정하면 주문 대상이 뒤늦게 변경될 수 있다. 주문 생성은 금액 계산, 쿠폰 검증, 재고 차감으로 이어지므로 이런 입력 변이는 운영에서 재현하기 어려운 불일치를 야기한다. compact constructor에서 items = List.copyOf(items)로 방어적 복사를 하고, 빈 목록과 userCouponId <= 0 같은 기본 불변식도 프로젝트 표준 예외로 즉시 차단해야 한다. 추가로 원본 리스트 수정이 커맨드에 반영되지 않는 테스트와 빈 목록 및 음수 쿠폰 ID가 BAD_REQUEST로 거절되는 테스트를 보강해야 한다.

🔧 제안 수정
 public record OrderCreateCommand(
         List<Item> items,
         Long userCouponId  // nullable. 쿠폰 미적용 시 null (BR-O09)
 ) {
+    public OrderCreateCommand {
+        if (items == null || items.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품은 1개 이상이어야 합니다.");
+        }
+        items = List.copyOf(items);
+
+        if (userCouponId != null && userCouponId <= 0L) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 쿠폰 식별자입니다.");
+        }
+    }
 
     public record Item(
             Long productId,
             int quantity
     ) {}
 }

CoreExceptionErrorType import를 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java`
around lines 5 - 13, OrderCreateCommand's items list must be defensively copied
and basic invariants validated in the record's compact constructor: make items =
List.copyOf(items) to prevent external mutation, validate items is not empty and
that if userCouponId is non-null it is > 0, and throw the project-standard
CoreException with the appropriate ErrorType (BAD_REQUEST) on violation; add
required imports for CoreException and ErrorType and update tests to assert
original list mutation does not affect OrderCreateCommand and that empty items
or non-positive coupon IDs produce BAD_REQUEST.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java (1)

82-99: ⚠️ Potential issue | 🟡 Minor

재조회 기준 검증이 없어 JPA 금액 매핑 회귀를 놓친다.

지금 검증은 create()가 반환한 엔티티 값에만 걸려 있어서, 새로 추가된 금액 스냅샷 컬럼 매핑이 깨져도 테스트가 통과한다. 운영에서는 주문 저장 직후 조회 응답과 정산 데이터가 어긋날 수 있다. saved에 대해서도 originalAmount, discountAmount, finalAmount를 모두 검증하라. 추가로 이 재조회 검증을 유지해 컬럼명/매핑 변경 회귀를 막아야 한다.

변경 예시
             Order saved = orderJpaRepository.findById(result.getId()).orElseThrow();
             assertThat(saved.getOrderItems()).hasSize(1);
             assertThat(saved.getOrderItems().get(0).getProductName()).isEqualTo("나이키 에어맥스");
+            assertThat(saved.getOriginalAmount().getAmount()).isEqualTo(originalAmount.getAmount());
+            assertThat(saved.getDiscountAmount().getAmount()).isZero();
+            assertThat(saved.getFinalAmount().getAmount()).isEqualTo(originalAmount.getAmount());

As per coding guidelines "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java`
around lines 82 - 99, The test currently only asserts amounts on the returned
Order from orderService.create(...) so JPA mapping regressions can slip by;
update OrderServiceIntegrationTest to also re-query and assert monetary fields
on the persisted entity by using
orderJpaRepository.findById(result.getId()).orElseThrow() (the saved variable)
and add assertions that saved.getOriginalAmount().getAmount(),
saved.getDiscountAmount().getAmount(), and saved.getFinalAmount().getAmount()
equal the expected values (matching the assertions on result) to prevent
column/mapping regressions after persist.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java (1)

26-46: ⚠️ Potential issue | 🟠 Major

금액 필드 int 타입 사용으로 인한 오버플로우 위험 개선 필요

도메인 레이어에서 금액을 관리하는 Money 클래스는 음수 검증만 수행하며, 상한값 검증이 없다. OrderFacade에서 주문 항목들의 금액을 int 타입으로 합산할 때 오버플로우 가능성이 있다. 또한 Order 생성자의 finalAmount = new Money(originalAmount.getAmount() - discountAmount.getAmount()) 계산도 int 범위를 초과할 경우 오버플로우가 발생한다.

  • long 타입으로 변경하거나, Money 클래스에 최대 금액 상한값(예: 1억 원) 검증을 추가한다.
  • 경계값 테스트를 추가하여 Integer.MAX_VALUE 근처에서의 동작을 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java`
around lines 26 - 46, The OrderResponse record and its from(OrderInfo info)
method use int for monetary fields (originalAmount, discountAmount, finalAmount)
which risks overflow; change these fields to long in OrderResponse and update
the from(OrderInfo info) mapping accordingly so it accepts long values from
OrderInfo (or casts safely), and either update the Money domain to enforce a max
cap or switch all monetary arithmetic in OrderFacade/Order constructor to use
long (or a bounded Money validation); also add boundary unit tests that
construct orders near Integer.MAX_VALUE to verify sums and finalAmount
calculations do not overflow.
🟡 Minor comments (8)
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java-49-51 (1)

49-51: ⚠️ Potential issue | 🟡 Minor

할인 적용 시 finalAmount 계산 로직 검증 테스트가 누락되어 있다.

현재 ZERO_DISCOUNT 케이스만 검증하므로 finalAmount = originalAmount - discountAmount 계산 로직이 실제로 동작하는지 확인할 수 없다. 운영 중 할인 계산 오류 발생 시 고객 클레임과 정산 불일치가 발생할 수 있다.

추가 테스트 케이스:

  • 할인 적용 시 finalAmount가 정확히 계산되는지 검증
  • discountAmount > originalAmount 시 도메인 규칙에 따른 처리 검증
`@DisplayName`("할인이 적용된 Order 생성 시 finalAmount가 정확히 계산된다.")
`@Test`
void createsOrder_whenDiscountApplied() {
    // arrange
    Money discountAmount = new Money(5000);
    Money originalAmount = new Money(20000);
    
    // act
    Order order = new Order(USER_ID, List.of(validOrderItem()), 1L, originalAmount, discountAmount);
    
    // assert
    assertThat(order.getFinalAmount().getAmount()).isEqualTo(15000);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java`
around lines 49 - 51, Add tests to verify the finalAmount calculation logic when
discounts are applied: create a test that constructs an Order (using the Order
constructor) with a non-zero Money discount (e.g., discountAmount = new
Money(5000) and originalAmount = new Money(20000)) and assert
order.getFinalAmount().getAmount() equals originalAmount - discountAmount; also
add a test for the domain rule when discountAmount > originalAmount (using Order
and Money) to assert the expected handling (e.g., finalAmount floor to zero or
exception per domain rule) so getFinalAmount and discount handling are covered
beyond the ZERO_DISCOUNT case.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java-23-23 (1)

23-23: ⚠️ Potential issue | 🟡 Minor

VALID_PRICE.getAmount() * VALID_QUANTITY.getValue() 연산에서 정수 오버플로우 위험이 있다.

현재 테스트 값(10000 * 2 = 20000)은 안전하지만, int 범위(약 21억) 내에서 향후 테스트 데이터 변경 시 오버플로우가 발생할 수 있다. Money 클래스에 multiply(int quantity) 메서드를 추가하거나, 테스트에서 오버플로우 경계 케이스를 명시적으로 검증하는 것이 바람직하다. 장시간 운영되는 e-커머스 시스템에서 대량 주문이나 고가 상품의 경우 이 문제가 발생할 수 있으므로, 안전한 연산 메서드를 도메인 계층에 구현하도록 제안한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java` at
line 23, Tests risk integer overflow when computing VALID_PRICE.getAmount() *
VALID_QUANTITY.getValue(); add a safe multiplication operation on the domain
Money class (e.g., Money.multiply(int quantity) or Money.times(int)) that
performs the multiplication using a wider type (long/BigDecimal) and returns a
Money, and update OrderTest to use Money.multiply(VALID_QUANTITY.getValue()) (or
add a specific overflow boundary test) instead of doing the raw int multiply;
reference Money, Money.multiply (new), VALID_PRICE and VALID_QUANTITY to locate
and replace the vulnerable expression.
apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java-30-35 (1)

30-35: ⚠️ Potential issue | 🟡 Minor

예외 cause 보존 및 로깅 누락

DataIntegrityViolationExceptionCoreException으로 변환할 때 원본 예외가 보존되지 않아 운영 환경에서 장애 원인 추적이 어려워진다. 또한 DataIntegrityViolationException은 UNIQUE 제약 위반 외에도 FK 제약, NOT NULL 등 다양한 경우에 발생하므로, 모든 케이스를 CONFLICT로 처리하면 실제 문제를 마스킹할 수 있다.

🛠️ 수정 제안
         try {
             return likeRepository.saveAndFlush(new Like(userId, productId));
         } catch (DataIntegrityViolationException e) {
-            // exists 체크를 동시에 통과한 경우 UNIQUE 제약 위반 → CONFLICT 에러로 변환하여 반환
-            throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.");
+            // exists 체크를 동시에 통과한 경우 UNIQUE 제약 위반 → CONFLICT 에러로 변환
+            // cause 보존으로 운영 시 원인 추적 가능
+            throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.", e);
         }

추가 테스트 권장: UNIQUE 제약 위반이 아닌 다른 DataIntegrityViolationException 발생 시 의도대로 동작하는지 확인하는 테스트 케이스 추가를 권장한다.

As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java`
around lines 30 - 35, The current catch in LikeService around
likeRepository.saveAndFlush swallows the original
DataIntegrityViolationException and unconditionally maps it to
ErrorType.CONFLICT; preserve the cause and add logging, and only convert to
CONFLICT for unique-constraint violations: catch DataIntegrityViolationException
e, log the original exception (e.g., logger.error("Failed to save Like for
userId={}, productId={}", userId, productId, e)), inspect e.getCause() (e.g.,
instanceof org.hibernate.exception.ConstraintViolationException or inspect
SQLState/errorCode on SQLException) to determine if it is a UNIQUE constraint—if
so rethrow new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.", e) else
rethrow a more appropriate CoreException (e.g., ErrorType.BAD_REQUEST or
ErrorType.SERVER_ERROR) preserving e as the cause; update unit tests to cover
UNIQUE vs non-unique DataIntegrityViolationException scenarios.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java-70-112 (1)

70-112: ⚠️ Potential issue | 🟡 Minor

할인·만료 경계값이 비어 있어 회귀를 놓친다.

현재는 RATE 할인에서 나누어떨어지지 않는 금액과 expiredAt == now 경계가 빠져 있다. 이 두 케이스는 구현이 조금만 바뀌어도 실제 결제 금액이나 쿠폰 사용 가능 여부가 달라져 운영 이슈로 번진다. 9_999 같은 비정수 할인 결과가 나오는 금액으로 소수점 처리 정책을 고정하고, expiredAt과 비교 시각이 정확히 같은 경우의 기대값도 명시하라. 두 경계값 케이스를 별도 테스트로 추가하라.

As per coding guidelines "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

Also applies to: 152-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java`
around lines 70 - 112, Add two boundary tests in CouponTemplateTest: one in the
CalculateDiscount nested class that uses validRateTemplate (e.g., value 10) on a
non-divisible order amount such as 9_999 and asserts the discount follows the
project's policy (explicitly assert truncation/floor to 999) so the
fractional-case is covered; and one that constructs a CouponTemplate whose
expiredAt equals Instant.now() (use the builder/helper you already use to set
expiry) and asserts calculateDiscount(now) returns the expected result for the
expiry-boundary (explicitly assert coupon is treated as expired and returns 0),
referencing CouponTemplate.calculateDiscount and your validRateTemplate/expiry
builder helpers so the tests pin the intended behavior.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponTest.java-23-30 (1)

23-30: ⚠️ Potential issue | 🟡 Minor

발급 만료일 스냅샷이 검증되지 않는다.

이 PR의 핵심 규칙 중 하나가 발급 시 expiredAt을 고정 저장하는 것인데, 현재 테스트는 usedAt만 확인해서 그 회귀를 잡지 못한다. 운영에서는 템플릿 만료일이 변경될 때 이미 발급된 쿠폰의 유효성 판단이 흔들릴 수 있다. getExpiredAt()도 함께 검증하라. 추가로 템플릿 만료일 변경과 무관하게 UserCoupon.expiredAt이 유지되는 흐름은 통합 테스트로 한 번 더 고정하라.

변경 예시
             assertThat(userCoupon.getCouponTemplateId()).isEqualTo(COUPON_TEMPLATE_ID);
             assertThat(userCoupon.getUserId()).isEqualTo(USER_ID);
+            assertThat(userCoupon.getExpiredAt()).isEqualTo(FUTURE_EXPIRED_AT);
             assertThat(userCoupon.getUsedAt()).isNull();

As per coding guidelines "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponTest.java`
around lines 23 - 30, The test
UserCouponTest.createsCoupon_whenValidParameters() currently only asserts
usedAt; add an assertion to verify userCoupon.getExpiredAt() equals
FUTURE_EXPIRED_AT to ensure expiredAt is captured at issuance; additionally,
add/integrate an integration test that issues a UserCoupon from a
CouponTemplate, mutates the template's expiry, reloads the issued UserCoupon,
and asserts the persisted UserCoupon.expiredAt still equals the original
FUTURE_EXPIRED_AT to guard against template-driven regressions.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java-83-89 (1)

83-89: ⚠️ Potential issue | 🟡 Minor

이 헬퍼는 무쿠폰 주문만 만들어 새 응답 필드를 검증하지 못한다.

지금 구성으로는 userCouponId = null, discountAmount = 0인 주문만 저장된다. 운영에서는 관리자 조회 응답의 userCouponId, originalAmount, discountAmount, finalAmount 직렬화가 깨져도 이 테스트는 계속 통과한다. 쿠폰 적용 주문 fixture를 하나 더 저장하고 목록/상세 응답에서 네 필드를 모두 assert하는 케이스를 추가하는 편이 안전하다. 추가 테스트로 discountAmount > 0이고 finalAmount = originalAmount - discountAmount인 주문을 검증해야 한다. As per coding guidelines, "**/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."를 따른 코멘트이다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java`
around lines 83 - 89, 현재 createSavedOrder 헬퍼는 userCouponId=null,
discountAmount=0인 무쿠폰 주문만 생성하므로 쿠폰 적용 시리얼라이제이션/계산 로직을 검증하지 못합니다; fix: 추가로 쿠폰이
적용된 주문을 만드는 헬퍼(createSavedOrderWithCoupon 또는 확장된 createSavedOrder)를 구현하여 Order
생성 시 userCouponId(예: someCouponId), discountAmount>0을 설정하고 finalAmount를
originalAmount - discountAmount로 계산해 Order 인스턴스에 넣어 orderJpaRepository.save로 저장한
뒤, 기존 E2E 목록 및 상세 테스트에서 해당 쿠폰 주문의 userCouponId, originalAmount, discountAmount,
finalAmount를 모두 assert하도록 추가하세요; 관련 식별자: createSavedOrder, Order, OrderItem,
orderJpaRepository, USER_ID.
apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java-26-28 (1)

26-28: ⚠️ Potential issue | 🟡 Minor

주석을 업데이트하여 실제 원자적 보호 조건을 명확히 해야 한다.

현재 주석(26-27줄)은 "WHERE used_at IS NULL 조건 사용"만 언급하지만, 실제 SQL(UserCouponJpaRepository 라인 31)에는 deletedAt IS NULL 조건도 포함되어 있다. 또한 applyCoupon() 서비스에서 validateNotExpired()로 만료 검증을 선행한다. 주석을 다음과 같이 개선하면 코드 의도가 명확해진다:

"원자적 쿠폰 사용 처리. 만료 및 soft delete 쿠폰은 서비스에서 선행 검증하고, UPDATE는 used_at IS NULL AND deleted_at IS NULL 조건으로 동시 요청 중 하나만 성공하도록 보장한다."

만료 직후와 soft delete 쿠폰이 모두 0건 업데이트로 끝나는지 검증하는 통합 테스트를 추가하면, 선행 검증과 원자적 UPDATE가 함께 작동함을 확인할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java`
around lines 26 - 28, Update the comment on the useIfAvailable method to
explicitly state that atomic protection uses the UPDATE condition "used_at IS
NULL AND deleted_at IS NULL" (the soft-delete check is in
UserCouponJpaRepository) and that expiration is validated earlier in applyCoupon
via validateNotExpired; also add an integration test that ensures attempting to
apply an expired coupon or a soft-deleted coupon results in 0 rows updated to
verify the pre-validation plus atomic UPDATE behavior works together.
apps/commerce-api/src/main/java/com/loopers/application/coupon/UserCouponInfo.java-33-41 (1)

33-41: ⚠️ Potential issue | 🟡 Minor

쿠폰 상태 결정 로직의 경계 조건에 대한 테스트 추가 필요

now.isAfter(expiredAt) 조건으로 만료 여부를 판단하면, 만료 시각과 정확히 같은 시점에서 AVAILABLE 상태를 반환한다. 운영 중에는 쿠폰 조회 시점과 실제 결제 처리 시점 사이에 시간 차이가 발생하여, 조회 시 AVAILABLE이었던 쿠폰이 결제 직전에 만료될 수 있다. 경계 시점의 동작이 비즈니스 요구사항과 일치하는지 명확히 검증해야 한다.

resolveStatus 메서드에 대한 단위 테스트를 추가하여 다음 케이스들을 검증한다:

  • usedAt이 존재하면 USED 반환
  • 현재 시각이 만료 시각보다 뒤면 EXPIRED 반환
  • 현재 시각이 만료 시각과 같으면 AVAILABLE 반환 (경계 조건)
  • 현재 시각이 만료 시각보다 앞으면 AVAILABLE 반환
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/UserCouponInfo.java`
around lines 33 - 41, The resolveStatus logic in UserCouponInfo currently uses
now.isAfter(expiredAt) which treats now == expiredAt as AVAILABLE; add unit
tests for the resolveStatus(UserCoupon userCoupon, LocalDateTime expiredAt,
LocalDateTime now) method to explicitly verify behavior: (1) when
userCoupon.getUsedAt() != null return CouponStatus.USED, (2) when
now.isAfter(expiredAt) return CouponStatus.EXPIRED, (3) when
now.isEqual(expiredAt) return CouponStatus.AVAILABLE (boundary), and (4) when
now.isBefore(expiredAt) return CouponStatus.AVAILABLE; locate tests around
UserCouponInfo.resolveStatus and assert against CouponStatus enum values to lock
in the intended boundary semantics.
🧹 Nitpick comments (14)
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java (1)

314-329: 실패 경로에서도 재고 불변을 함께 단언하는 편이 안전하다.

현재 throwsBadRequest_whenStockIsInsufficient()는 예외 타입만 확인하므로, 검증 도중 재고가 잘못 변경되어도 놓친다. 운영에서는 주문이 실패했는데 재고만 줄어드는 형태로 나타나 장애 분석이 어려워진다. 예외 발생 후 상품을 다시 조회해 수량이 기존 값 1 그대로인지 확인하고, 가능하면 다른 정상 상품이 함께 있을 때도 어떤 상품도 변경되지 않는 케이스를 추가해 달라. As per coding guidelines, **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java`
around lines 314 - 329, Update the test throwsBadRequest_whenStockIsInsufficient
to also assert the product's Stock is unchanged after the exception: after
calling productService.findAllAndVerifyStock and catching CoreException, reload
the Product via productJpaRepository.findById(product.getId()) and assert its
Stock quantity equals the original value (1); additionally, add a second saved
Product and include it in a combined quantityMap to assert no product's stock
changed on failure (reload both via productJpaRepository and assert their Stock
quantities remain unchanged). This uses the existing test method, Product,
Stock, productJpaRepository, and productService.findAllAndVerifyStock to locate
where to add the post-exception verification.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java (1)

31-34: 할인 시나리오 테스트를 위한 헬퍼 메서드 오버로드가 필요하다.

현재 validOrder(Long userId)는 항상 ZERO_DISCOUNT를 사용하므로 할인 적용 시나리오를 테스트할 수 없다. 운영 환경에서 쿠폰 할인 계산 오류는 매출 손실로 직결되므로, 다양한 할인 금액에 대한 테스트가 필수다.

제안: 할인 금액을 파라미터로 받는 오버로드 추가
 private Order validOrder(Long userId) {
     Money originalAmount = new Money(VALID_PRICE.getAmount() * VALID_QUANTITY.getValue());
     return new Order(userId, List.of(validOrderItem()), null, originalAmount, ZERO_DISCOUNT);
 }
+
+private Order validOrderWithDiscount(Long userId, Money discountAmount) {
+    Money originalAmount = new Money(VALID_PRICE.getAmount() * VALID_QUANTITY.getValue());
+    return new Order(userId, List.of(validOrderItem()), 1L, originalAmount, discountAmount);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java`
around lines 31 - 34, The helper validOrder(Long userId) always uses
ZERO_DISCOUNT so discount scenarios can't be tested; add an overloaded helper
validOrder(Long userId, Money discount) that constructs and returns a new
Order(userId, List.of(validOrderItem()), null, originalAmount, discount)
(compute originalAmount same way as existing validOrder), and update tests to
call the new overload when different discounts need to be exercised; keep the
existing validOrder(Long userId) as a convenience delegating to the new overload
with ZERO_DISCOUNT to preserve current tests.
apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java (3)

66-67: 테스트 데이터 생성 시 likeCount 초기값 확인 필요

DifferentUsersConcurrentLike 테스트에서 likeCount가 THREAD_COUNT와 일치하는지 검증하는데, Product 생성 시 likeCount 초기값이 0임을 명시적으로 확인하면 테스트 의도가 명확해진다.

// arrange 단계에서 초기값 검증 추가
assertThat(product.getLikeCount()).isEqualTo(0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java`
around lines 66 - 67, In the DifferentUsersConcurrentLike test, make the initial
likeCount explicit: after creating/saving the Product via
productJpaRepository.save (the local variable product in LikeConcurrencyTest),
add an assertion that product.getLikeCount() is 0 to document the starting state
before the concurrent like operations; this clarifies the test intent and
ensures the subsequent assertion comparing to THREAD_COUNT is valid.

123-127: DifferentUsersConcurrentLike 테스트 실패 감지 누락

서로 다른 사용자의 동시 좋아요는 모두 성공해야 하므로, 예외 발생 시 명시적으로 실패를 기록해야 한다. 현재 구현은 예외가 발생해도 테스트가 통과할 수 있어 실제 문제를 놓칠 수 있다.

🧪 수정 제안
+    private final AtomicInteger failureCount = new AtomicInteger(0);
+    private final List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());

     executor.submit(() -> {
         try {
             startLatch.await();
             likeFacade.create(userId, product.getId());
             successCount.incrementAndGet();
         } catch (Exception e) {
-            // 예상치 못한 실패
+            failureCount.incrementAndGet();
+            failures.add(e);
         } finally {
             doneLatch.countDown();
         }
     });

assert 부분에 추가:

assertThat(failureCount.get())
    .withFailMessage("Unexpected failures: %s", failures)
    .isEqualTo(0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java`
around lines 123 - 127, The catch block inside the DifferentUsersConcurrentLike
test currently swallows exceptions so failures are not detected; update the
catch to record errors by incrementing the AtomicInteger failureCount and
appending the exception or message to the failures collection (used in the
test), and keep the doneLatch.countDown() in finally; after waiting for threads
(after await on doneLatch) add an assertion like
assertThat(failureCount.get()).withFailMessage("Unexpected failures: %s",
failures).isEqualTo(0) to explicitly fail the test if any thread threw an
exception; refer to failureCount, failures, doneLatch and the catch/finally in
LikeConcurrencyTest.

81-85: 예외 무시로 인한 테스트 신뢰성 저하 가능성

catch 블록에서 모든 예외를 무시하면 DB 연결 실패, 트랜잭션 타임아웃 등 예상치 못한 예외도 정상 케이스로 처리되어 테스트 결과가 왜곡될 수 있다. CONFLICT 예외만 예상된 실패로 처리하고, 나머지는 실패로 기록하여 테스트 신뢰성을 높이는 것을 권장한다.

🧪 수정 제안
+    private AtomicInteger conflictCount = new AtomicInteger(0);
+    private AtomicInteger unexpectedFailureCount = new AtomicInteger(0);

     // act
     for (int i = 0; i < THREAD_COUNT; i++) {
         executor.submit(() -> {
             try {
                 startLatch.await();
                 likeService.create(USER_ID, product.getId());
                 successCount.incrementAndGet();
-            } catch (Exception e) {
-                // 예상된 실패 (TOCTOU 경쟁 또는 exists 체크에서 CONFLICT)
+            } catch (CoreException e) {
+                if (e.getErrorType() == ErrorType.CONFLICT) {
+                    conflictCount.incrementAndGet();
+                } else {
+                    unexpectedFailureCount.incrementAndGet();
+                }
+            } catch (Exception e) {
+                unexpectedFailureCount.incrementAndGet();
             } finally {
                 doneLatch.countDown();
             }
         });
     }

assert 부분에 추가:

assertThat(unexpectedFailureCount.get()).isEqualTo(0);
assertThat(successCount.get() + conflictCount.get()).isEqualTo(THREAD_COUNT);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java`
around lines 81 - 85, The current catch block in the concurrent test swallows
all exceptions; update the catch in the block surrounding doneLatch.countDown()
to only treat conflict/TOCTOU (e.g., the specific exception type or message that
indicates CONFLICT) as an expected failure by incrementing conflictCount, and
for any other exception increment an unexpectedFailureCount (or rethrow/wrap) so
unexpected DB/timeout errors are surfaced; then add the suggested assertions
after the threads complete:
assertThat(unexpectedFailureCount.get()).isEqualTo(0) and
assertThat(successCount.get() + conflictCount.get()).isEqualTo(THREAD_COUNT) to
enforce test reliability.
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java (1)

20-22: @Modifying 쿼리 후 영속성 컨텍스트 정리 설정 필요

@Modifying JPQL 쿼리는 데이터베이스에서 직접 DELETE를 실행하지만, 영속성 컨텍스트의 엔티티를 자동으로 제거하지 않는다. 테스트 코드(LikeServiceIntegrationTest 라인 187-188)에서 deleteAllByProductId() 직후 existsByUserIdAndProductId()로 조회하는 패턴이 있으며, clearAutomatically 속성이 없으면 같은 트랜잭션 내에서 stale 엔티티가 반환될 수 있다.

현재 ProductAdminFacade, BrandAdminFacade의 운영 흐름상 삭제 후 재조회가 없으므로 즉시 문제는 아니나, 향후 유지보수 시 의도하지 않은 데이터 불일치를 초래할 위험이 있다.

clearAutomatically 속성 추가 제안
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true)
     `@Query`("DELETE FROM Like l WHERE l.productId = :productId")
     void deleteAllByProductId(`@Param`("productId") Long productId);

-    `@Modifying`
+    `@Modifying`(clearAutomatically = true)
     `@Query`("DELETE FROM Like l WHERE l.productId IN :productIds")
     void deleteAllByProductIdIn(`@Param`("productIds") Collection<Long> productIds);

두 메서드 모두에 clearAutomatically = true를 설정하여 영속성 컨텍스트 정합성을 보장하기 바란다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java`
around lines 20 - 22, The `@Modifying` JPQL delete in LikeJpaRepository (method
deleteAllByProductId) must include clearAutomatically=true to avoid returning
stale entities from the persistence context after the bulk delete; update the
`@Modifying` annotation on deleteAllByProductId (and any other `@Modifying` delete
methods in LikeJpaRepository) to set clearAutomatically = true so the
persistence context is cleared automatically after the DELETE and subsequent
existsByUserIdAndProductId() or other reads see the correct state.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java (1)

44-53: brandNameMap에서 null 반환 가능성 검토 필요.

운영 관점에서 상품이 참조하는 브랜드가 soft-delete 되었거나 데이터 정합성 문제로 brandService.findNamesByIds()에서 누락될 경우, brandNameMap.get(product.getBrandId())가 null을 반환한다. ProductInfo.from()에 null brandName이 전달되면 NPE 또는 사용자에게 불완전한 데이터가 노출될 수 있다.

수정안: brandNameMap.getOrDefault(product.getBrandId(), "알 수 없음") 또는 null 체크 후 명시적 처리를 권장한다.

추가 테스트: 삭제된 브랜드를 참조하는 상품 조회 시나리오에 대한 테스트 추가를 권장한다.

♻️ null 방어 코드 제안
         // 기존의 상품 List 각각의 product에 해당하는 브랜드명 매핑
-        return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId())));
+        return products.map(product -> ProductInfo.from(product, brandNameMap.getOrDefault(product.getBrandId(), "알 수 없음")));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java`
around lines 44 - 53, In ProductAdminFacade.findAll the brandNameMap returned by
brandService.findNamesByIds(…) can miss entries (soft-deleted or inconsistent
data) causing brandNameMap.get(product.getBrandId()) to be null; update the
mapping to supply a safe default (e.g., use
brandNameMap.getOrDefault(product.getBrandId(), "알 수 없음") or explicitly map null
to a placeholder before calling ProductInfo.from) and add a unit/integration
test for the case where a product references a deleted/missing brand to assert
the placeholder is used and no NPE occurs.
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java (1)

20-22: 시간 기준을 주입해 만료 판정 기준을 고정하는 편이 안전하다.

이 클래스는 LocalDateTime.now()를 직접 읽고 있어서 배포 환경의 timezone이나 clock skew에 따라 같은 쿠폰의 상태가 달라질 수 있다. 운영에서는 만료 경계 시점에 노드별 응답이 갈리면 CS 이슈로 바로 이어진다. Clock 또는 공용 TimeProvider를 주입해 발급 응답과 목록 조회가 같은 기준 시각을 사용하게 하고, 추가 테스트로 고정 시계에서 expiredAt 경계값 상태가 일관적인지 검증하는 편이 안전하다.

Also applies to: 31-35

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java`
around lines 20 - 22, The issue: CouponFacade.issue(...) calls
LocalDateTime.now() directly which makes expiration checks non-deterministic;
change CouponFacade to accept an injected Clock or TimeProvider (e.g., via
constructor or `@Autowired`) and use that to obtain the current time in issue and
any other methods (also update the similar code in the 31-35 region). Replace
LocalDateTime.now() with LocalDateTime.now(clock) or timeProvider.now() inside
UserCouponInfo.from(...) call sites, update the CouponFacade
constructor/signature to store the clock/timeProvider, and add unit tests using
a fixed Clock to assert consistent expiredAt boundary behavior.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java (2)

37-44: import 구문 정리

java.util.List가 inline으로 사용되었다. 상단에 import 구문을 추가하여 일관성을 유지하는 것이 좋다.

♻️ 수정 제안
 import com.loopers.application.coupon.UserCouponInfo;
 import com.loopers.domain.coupon.CouponStatus;
 
 import java.time.LocalDateTime;
 import java.time.ZonedDateTime;
+import java.util.List;
     public record MyCouponListResponse(
-            java.util.List<UserCouponResponse> coupons
+            List<UserCouponResponse> coupons
     ) {
-        public static MyCouponListResponse from(java.util.List<UserCouponInfo> infos) {
+        public static MyCouponListResponse from(List<UserCouponInfo> infos) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java`
around lines 37 - 44, The inline usage of java.util.List in the
MyCouponListResponse record should be replaced with a top-level import for List
to keep imports consistent; add "import java.util.List;" to the class imports
and update the record declaration and the static factory method
signatures/usages (MyCouponListResponse, UserCouponResponse::from, and parameter
type UserCouponInfo list in from) to refer to List instead of java.util.List so
all list types are consistent with the rest of the file.

14-31: 시간 타입 불일치 확인 필요

issuedAtZonedDateTime이고, expiredAt/usedAtLocalDateTime이다. 운영 관점에서 타임존 처리가 일관되지 않으면 만료 시점 판단 시 오류가 발생할 수 있다. 특히 서버 타임존과 클라이언트 타임존이 다른 경우 쿠폰 만료 여부가 잘못 계산될 수 있다.

도메인 레이어에서 시간 타입을 통일하거나, API 응답 시 명시적으로 타임존을 포함하여 반환하는 것을 권장한다. 타임존 관련 엣지 케이스(예: DST 전환, 다국적 사용자)에 대한 테스트를 추가하는 것이 운영 안정성을 높인다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java`
around lines 14 - 31, UserCouponResponse has inconsistent time types (issuedAt:
ZonedDateTime vs expiredAt/usedAt: LocalDateTime) which can cause TZ bugs;
update the DTO and mapping in UserCouponResponse.from(UserCouponInfo) to use a
single consistent temporal type (prefer ZonedDateTime or Instant with zone
normalization) for all three fields, or convert expiredAt/usedAt from
LocalDateTime to ZonedDateTime using a specified ZoneId when constructing the
record; ensure the chosen zone is applied consistently and adjust the record
field types and mapping logic in from(UserCouponInfo) accordingly.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiE2ETest.java (1)

46-46: 테스트 시간값의 플레이키 가능성

FUTURE_EXPIRED_AT가 static 필드로 LocalDateTime.now().plusDays(30)을 사용한다. 테스트 클래스 로드 시점에 값이 고정되므로, 장시간 실행되는 테스트 스위트나 CI 환경에서 예상치 못한 시간 관련 실패가 발생할 수 있다.

테스트 메서드 내부에서 시간값을 생성하거나, 테스트용 고정 시간을 사용하는 것을 권장한다.

♻️ 수정 제안
-    private static final LocalDateTime FUTURE_EXPIRED_AT = LocalDateTime.now().plusDays(30);
+    // 테스트 메서드 내에서 LocalDateTime.now().plusDays(30) 호출 권장
+    // 또는 고정된 미래 시간 사용
+    private static final LocalDateTime FUTURE_EXPIRED_AT = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiE2ETest.java`
at line 46, FUTURE_EXPIRED_AT is a static field computed once with
LocalDateTime.now().plusDays(30) which can cause flaky time-dependent tests;
change the code so the timestamp is created inside each test (or use a test
fixed clock) instead of the static constant in CouponAdminV1ApiE2ETest—replace
references to FUTURE_EXPIRED_AT with a locally computed
LocalDateTime.now().plusDays(30) inside test methods or inject a deterministic
Clock for generating the expiry time.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderFacadeIntegrationTest.java (1)

41-42: 테스트 시간값 플레이키 가능성

FUTURE_EXPIRED_ATPAST_EXPIRED_ATLocalDateTime.now() 기반으로 static 필드에 정의되어 있다. 앞서 CouponAdminV1ApiE2ETest와 동일한 문제로, 장시간 실행되는 CI 환경에서 테스트 실패 가능성이 있다.

특히 PAST_EXPIRED_AT = LocalDateTime.now().minusDays(1)는 테스트 실행 시점에 따라 만료 여부 판단이 달라질 수 있다.

♻️ 수정 제안
-    private static final LocalDateTime FUTURE_EXPIRED_AT = LocalDateTime.now().plusDays(30);
-    private static final LocalDateTime PAST_EXPIRED_AT = LocalDateTime.now().minusDays(1);
+    private static final LocalDateTime FUTURE_EXPIRED_AT = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
+    private static final LocalDateTime PAST_EXPIRED_AT = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderFacadeIntegrationTest.java`
around lines 41 - 42, The static test timestamps FUTURE_EXPIRED_AT and
PAST_EXPIRED_AT in OrderFacadeIntegrationTest are computed from
LocalDateTime.now() making tests flaky in long-running CI; change them to
deterministic values by either (a) replacing the static fields with fixed
constants (e.g. a specific ISO LocalDateTime string parsed to LocalDateTime) or
(b) inject/override a Clock used by the code under test and compute
FUTURE_EXPIRED_AT/PAST_EXPIRED_AT from a fixed Clock in a `@BeforeEach/`@BeforeAll
setup; update references to FUTURE_EXPIRED_AT and PAST_EXPIRED_AT to use the new
deterministic sources so expiration checks are stable across runs.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java (1)

87-92: 내 쿠폰 조회 E2E가 사용자 격리를 검증하지 못한다.

현재는 자격증명 helper가 단일 사용자에 고정되어 있고, positive case도 쿠폰 1건만 만들어서 구현이 인증 사용자 필터를 빠뜨려도 테스트가 통과한다. 운영에서는 다른 회원 쿠폰이 섞여 내려오는 권한/개인정보 회귀를 놓칠 수 있으므로, 두 번째 사용자의 쿠폰도 함께 만든 뒤 응답이 현재 사용자 쿠폰만 포함하는지까지 단언하도록 바꾸는 편이 안전하다. 헤더 helper도 사용자별 자격증명을 받을 수 있게 바꾸고, 각 사용자가 서로 다른 목록을 받는 케이스를 추가해야 한다. As per coding guidelines, **/*Test*.java: 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.

Also applies to: 204-226

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java`
around lines 87 - 92, The createUserHeaders helper currently returns a fixed
single user's credentials causing the E2E "my coupons" test to miss
user-isolation bugs; modify the helper (createUserHeaders) to accept loginId and
password parameters and return headers for those credentials, then update the
E2E test(s) that create/list coupons (the test block around the previous
single-coupon positive case and the tests referenced in the 204-226 region) to
create coupons for two distinct users, invoke the list-my-coupons endpoint for
each user using createUserHeaders with their respective credentials, and add
assertions that each response contains only that user's coupons (and does not
include the other user's coupon ids) to ensure proper authentication-based
isolation.
apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponFacadeIntegrationTest.java (1)

216-233: soft delete 테스트가 실제 soft delete 여부를 보장하지 못한다.

지금은 active count가 0인지밖에 보지 않아 구현이 hard delete로 바뀌어도 테스트가 통과한다. 운영에서는 발급 이력과 감사 추적 손실을 놓칠 수 있으므로, 삭제 전 UserCoupon id를 저장해 두고 soft-deleted row를 조회할 수 있는 경로로 deletedAt이 채워졌는지까지 검증하는 편이 안전하다. 여러 발급 건이 모두 soft delete 되는 케이스도 함께 추가해야 한다. As per coding guidelines, **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponFacadeIntegrationTest.java`
around lines 216 - 233, Update the test
softDeletesTemplateAndRelatedIssues_whenDeleted to assert soft-delete semantics:
before calling couponAdminFacade.delete(template.getId()) save and capture the
id(s) of the created UserCoupon entities (use userCouponJpaRepository.save(...)
and keep their getId()), create multiple UserCoupon rows to cover multi-issue
case, then after delete verify the CouponTemplate deletedAt is set and for each
captured UserCoupon id load the row via
userCouponJpaRepository.findById(id).orElseThrow() and assert its getDeletedAt()
is not null (instead of only checking active count); keep references to
CouponTemplate, UserCoupon, couponAdminFacade.delete, userCouponJpaRepository
and couponTemplateJpaRepository so the changes are easy to locate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bfe8b3d3-d6f7-4130-8399-bc4cb77a4be3

📥 Commits

Reviewing files that changed from the base of the PR and between bec6f85 and d484b1a.

⛔ Files ignored due to path filters (5)
  • .claude/skills/anaylize-query/SKILL.md is excluded by !**/*.md and included by **
  • .docs/design/01-requirements.md is excluded by !**/*.md and included by **
  • .docs/design/02-sequence-diagrams.md is excluded by !**/*.md and included by **
  • .docs/design/03-class-diagrams.md is excluded by !**/*.md and included by **
  • .docs/design/04-erd.md is excluded by !**/*.md and included by **
📒 Files selected for processing (56)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateRegisterCommand.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponTemplateUpdateCommand.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/UserCouponInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplateRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponTemplateRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTemplateTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/UserCouponTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAdminV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java
💤 Files with no reviewable changes (2)
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java

Comment on lines 54 to 58
Map<Long, Quantity> quantityByProductId = command.items().stream()
.collect(Collectors.toMap(
OrderCreateCommand.Item::productId,
item -> new Quantity(item.quantity())
));
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:

# 파일 위치 확인 및 코드 내용 조회
find . -name "OrderFacade.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 165


🏁 Script executed:

# 파일 내용 확인 (54-70 라인)
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java | head -80 | tail -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1741


🏁 Script executed:

# CoreException 정의 확인
find . -name "CoreException.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

# ErrorType 정의 확인
find . -name "ErrorType.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 159


🏁 Script executed:

# CoreException 클래스 정의 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 741


🏁 Script executed:

# ErrorType 정의 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1057


🏁 Script executed:

# OrderCreateCommand 구조 확인 (items 메서드, Item 클래스)
find . -name "OrderCreateCommand.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 172


🏁 Script executed:

# OrderCreateCommand 내용 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 462


🏁 Script executed:

# ApiControllerAdvice 확인하여 CoreException 처리 확인
find . -name "ApiControllerAdvice.java" -o -name "*ControllerAdvice.java" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 170


🏁 Script executed:

# ApiControllerAdvice 내용 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6820


중복 상품 ID 요청이 500으로 응답된다.

Collectors.toMap은 동일 productId가 들어오면 IllegalStateException을 던진다. 현재 ApiControllerAdvice의 일반 예외 처리기(111-114행)에서 이를 INTERNAL_ERROR(500)으로 반환한다. 운영에서는 잘못된 주문 입력이 일관된 400이 아니라 간헐적 500으로 보이며, 프로젝트의 CoreException 통합 에러 처리 패턴을 활용하지 못한다. merge function으로 CoreException을 명시적으로 던지면 ApiControllerAdvice의 CoreException 전용 처리기에서 400 BAD_REQUEST로 정확하게 응답한다.

수정안
         Map<Long, Quantity> quantityByProductId = command.items().stream()
                 .collect(Collectors.toMap(
                         OrderCreateCommand.Item::productId,
-                        item -> new Quantity(item.quantity())
+                        item -> new Quantity(item.quantity()),
+                        (existing, duplicate) -> {
+                            throw new CoreException(ErrorType.BAD_REQUEST, "동일 상품은 한 번만 주문할 수 있습니다.");
+                        }
                 ));

동일 productId가 두 번 들어온 주문 요청이 400 응답을 반환하는지 테스트로 검증해야 한다.

📝 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
Map<Long, Quantity> quantityByProductId = command.items().stream()
.collect(Collectors.toMap(
OrderCreateCommand.Item::productId,
item -> new Quantity(item.quantity())
));
Map<Long, Quantity> quantityByProductId = command.items().stream()
.collect(Collectors.toMap(
OrderCreateCommand.Item::productId,
item -> new Quantity(item.quantity()),
(existing, duplicate) -> {
throw new CoreException(ErrorType.BAD_REQUEST, "동일 상품은 한 번만 주문할 수 있습니다.");
}
));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 54 - 58, The current use of Collectors.toMap when building
quantityByProductId will throw IllegalStateException on duplicate
OrderCreateCommand.Item.productId, causing a 500; change the collector to supply
a merge function that throws your domain CoreException (e.g., new
CoreException(BAD_REQUEST, "duplicate productId")) so the ApiControllerAdvice
CoreException handler returns 400, and update the construction of
quantityByProductId (the stream -> collect call) to use that merge behavior;
also add a unit/integration test that sends an OrderCreateCommand with the same
productId twice and asserts the API returns 400 BAD_REQUEST.

Copy link
Author

@Namjin-kimm Namjin-kimm Mar 6, 2026

Choose a reason for hiding this comment

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

@coderabbitai 해당 부분 수정했어, 확인해줘

Comment on lines +83 to +88
// ④ 재고 차감 (dirty checking, 비관적 락 범위 내)
// products List는 IN 절 쿼리를 사용했으므로 Mysql이 PK를 오름차순으로 정렬해줌 -> 데드락 방지
for (Product product : products) {
Quantity quantity = quantityByProductId.get(product.getId());
product.decreaseStock(quantity);
}
Copy link

@coderabbitai coderabbitai bot Mar 6, 2026

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:

find . -name "OrderFacade.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 165


🏁 Script executed:

git ls-files | grep -E "(OrderFacade|Product|Quantity)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1814


🏁 Script executed:

wc -l ./apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 169


🏁 Script executed:

sed -n '60,100p' ./apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2006


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java | head -120

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5456


🏁 Script executed:

grep -n "SELECT.*FOR UPDATE" ./apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

grep -r "SELECT.*FOR UPDATE" --include="*.java" apps/commerce-api/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 223


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5092


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3061


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/order/OrderFacadeIntegrationTest.java | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4463


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2702


🏁 Script executed:

grep -n "findAllByIdsForUpdate\|deadlock\|데드락" ./apps/commerce-api/src/test/java/com/loopers/domain/order/OrderFacadeIntegrationTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find ./apps/commerce-api -name "*.java" -path "*/test/*" -exec grep -l "deadlock\|데드락\|동시\|concurrent" {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 432


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6655


SELECT ... FOR UPDATE 쿼리에 명시적 ORDER BY id를 추가하고 역순 상품 조합의 동시 주문 회귀 테스트를 작성하여 데드락을 방지한다.

Line 84의 코멘트처럼 IN 절이 항상 PK 오름차순으로 정렬된다고 가정할 수 없다. ProductJpaRepository.java의 findAllByIdsForUpdate() 쿼리에는 명시적인 ORDER BY 절이 없으며, 쿼리 실행 계획이나 데이터 분포에 따라 락 획득 순서가 바뀔 수 있다. 예를 들어 Thread1이 상품 [1, 2] 순으로, Thread2가 [2, 1] 순으로 동시에 주문할 경우 데드락이 발생한다. OrderConcurrencyTest.java의 기존 테스트는 단일 상품 주문만 검증하므로 이 시나리오를 포착하지 못한다. 다음 두 가지를 수정한다:

  1. ProductJpaRepository의 쿼리에 ORDER BY p.id 추가로 락 획득 순서 고정
  2. 역순 상품 조합 두 건을 동시에 주문하는 회귀 테스트 추가
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 83 - 88, Add a deterministic lock order to the FOR UPDATE query and
a regression test that triggers the reverse-order deadlock scenario: update
ProductJpaRepository.findAllByIdsForUpdate() to include an explicit "ORDER BY
p.id" so rows are locked in a consistent order, and add a concurrency test in
OrderConcurrencyTest that submits two orders with identical product IDs in
opposite orders (e.g., [1,2] and [2,1]) concurrently to verify no deadlock
occurs.

Copy link
Author

Choose a reason for hiding this comment

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

리뷰포인트로 남긴 부분...멀티 로우 락의 순서 보장을 mysql InnoDB에 의존하냐 마냐의 문제

Copy link

Choose a reason for hiding this comment

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

@Namjin-kimm, 의도적으로 남긴 트레이드오프 포인트임을 확인하였다. 기술적 관점에서 다음을 보완한다.

현재 접근법의 근거 (InnoDB 의존)

IN 절로 PK를 조회할 경우, InnoDB의 B+Tree 클러스터드 인덱스 탐색 특성상 PK 오름차순으로 행을 스캔하여 락을 획득한다. 따라서 동일한 상품 집합을 포함한 두 트랜잭션이 같은 순서로 락을 획득하게 되어 데드락이 방지된다.

리스크 포인트

SELECT FOR UPDATE는 MySQL이 동일한 실행 방식으로 처리할 경우에만 데드락이 발생하지 않으며, 실행 방식이 달라질 수 있다는 점이 명시되어 있다.

구체적으로 다음 시나리오에서 가정이 깨질 수 있다:

상황 영향
쿼리 옵티마이저가 풀 테이블 스캔 선택 락 순서가 물리적 레코드 배치에 의존
인덱스 통계 부패 또는 재분석 후 실행 계획 변경 PK 순서 보장 소멸
MySQL 버전 업그레이드 후 옵티마이저 동작 변경 기존 가정 무효화

실제 위험도

InnoDB는 데드락을 자동으로 감지하여 충돌 트랜잭션 중 하나를 롤백한다. 따라서 데드락 발생 시 서버가 무한 대기에 빠지지는 않으나, 주문 트랜잭션이 예기치 않게 실패하여 사용자 경험에 영향을 준다.

결론

InnoDB 의존 방식은 현행 단일 DB 환경에서 실용적으로 동작하지만, 명시적 ORDER BY id를 쿼리에 추가하면 실행 계획과 MySQL 버전에 무관하게 락 순서를 보장할 수 있다. 설계 문서에 이 의존성을 명시적으로 기록하는 것도 운영 관점에서 권장한다.


✏️ Learnings added
Learnt from: Namjin-kimm
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 152
File: apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java:86-91
Timestamp: 2026-03-06T09:00:13.956Z
Learning: In Loopers-dev-lab/loop-pack-be-l2-vol3-java PR `#152`, OrderFacade.java's deadlock prevention for multi-product orders relies on MySQL InnoDB's implicit behavior of scanning IN-clause PK queries in ascending order (B+Tree clustered index traversal). This is a deliberate design decision acknowledged by the author as a trade-off: InnoDB generally locks rows in PK ascending order for IN queries, but this is not a formal SQL standard guarantee. If the query optimizer changes execution plans (e.g., full table scan), lock order may change and deadlocks could occur. The safe alternative is adding explicit ORDER BY id to the SELECT FOR UPDATE query in ProductJpaRepository.findAllByIdsForUpdate(). InnoDB does auto-detect and roll back deadlocked transactions, so hangs are avoided but transaction failures still occur.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jsj1215
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 142
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java:80-94
Timestamp: 2026-03-06T06:41:37.434Z
Learning: In `ProductRepositoryImpl.java` (Loopers-dev-lab/loop-pack-be-l2-vol3-java), `findOptionByIdWithLock()` uses `entityManager.find(ProductOption.class, optionId, LockModeType.PESSIMISTIC_WRITE)` without a lock timeout hint. The call flow is: `OrderService.prepareOrderItems()` → `findProductOnly()` (loads only Product, not ProductOption) → `deductStock()` → `findOptionByIdWithLock()`. Since ProductOption is never loaded into the 1st-level cache before this point, there is no stale read risk. However, the lock timeout hint (`jakarta.persistence.lock.timeout`) is missing, risking infinite wait. Fix: use the 4-parameter `entityManager.find()` overload with `Map.of("jakarta.persistence.lock.timeout", 3000L)`.

Learnt from: madirony
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 0
File: :0-0
Timestamp: 2026-03-06T04:36:52.920Z
Learning: In PR `#148` of Loopers-dev-lab/loop-pack-be-l2-vol3-java, ALL WithLock repository methods — including `OrderJpaRepository.findByIdWithLock` — have `QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))` applied consistently. No WithLock method is missing the timeout hint. This was confirmed by reading the full file content of OrderJpaRepository.java.

Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 6
File: apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java:9-10
Timestamp: 2026-02-04T05:52:09.963Z
Learning: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).

Comment on lines +24 to +33
return new OrderInfo(
order.getId(),
order.getUserId(),
order.getUserCouponId(),
order.getOriginalAmount().getAmount(),
order.getDiscountAmount().getAmount(),
order.getFinalAmount().getAmount(),
order.getCreatedAt(),
items
);
Copy link

@coderabbitai coderabbitai bot Mar 6, 2026

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:

cd apps/commerce-api && find . -type f -name "*.java" | grep -E "(OrderInfo|Order\.java)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 197


🏁 Script executed:

find . -type f -name "*.sql" -o -name "*.java" | grep -i migration | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find apps/commerce-api -type f -name "*.java" | xargs rg "class Order\b" -l

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 152


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2051


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4038


🏁 Script executed:

find apps/commerce-api -type f \( -name "*.sql" -o -name "V*.java" -o -name "*.yml" \) | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 137


🏁 Script executed:

cat apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1044


🏁 Script executed:

find apps/commerce-api -type d -name "db" -o -name "migration* " -o -name "flyway" -o -name "liquibase"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find . -type f -name "V*.sql" -o -name "U*.sql" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java 2>/dev/null || echo "Money class not found at expected path"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 799


금액 스냅샷 필드의 null 안전성을 확보해야 한다.

Order 엔티티의 originalAmount, discountAmount, finalAmount 필드는 데이터베이스 제약으로만 NOT NULL이 설정되어 있고, Java 수준의 @NonNull 또는 @NotNull 어노테이션이 없다. OrderInfo.of() 메서드에서 이들 필드를 직접 체이닝으로 역참조할 때, 스키마 변경 이전의 기존 주문 데이터나 불완전한 마이그레이션으로 인해 null 값이 남아 있으면 NullPointerException이 발생하여 조회 API가 500 오류로 실패한다.

운영 영향: 과거 주문 목록/상세 조회가 일괄 장애로 번질 수 있다.

수정안:

  1. 데이터베이스 마이그레이션 파일과 기존 데이터 backfill 스크립트를 이 PR에 포함하여 모든 주문 레코드의 세 금액 필드를 채운 후 NOT NULL 제약을 적용한다.
  2. 또는 Order 엔티티에 Java 수준의 @NonNull 어노테이션을 추가하고, null 가능성을 처리할 수 있는 방어적 로직(예: 기본값 반환)을 OrderInfo.of()에 추가한다.

추가 테스트: 금액 스냅샷이 비어 있는 레거시 주문 데이터를 읽는 시나리오를 테스트하여 기대 동작을 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java`
around lines 24 - 33, OrderInfo.of(...) currently dereferences
order.getOriginalAmount(), getDiscountAmount(), and getFinalAmount() directly
which can NPE for legacy rows; update by adding Java-level non-null annotations
to the Order entity fields (e.g., annotate originalAmount, discountAmount,
finalAmount with `@NonNull/`@NotNull) and modify OrderInfo.of (and the OrderInfo
constructor call site) to defensively handle nulls — check each of
order.getOriginalAmount(), order.getDiscountAmount(), order.getFinalAmount() and
substitute a safe default Money/amount (e.g., zero) or use Optional-like getters
before calling getAmount() so API reads never throw NullPointerException; also
add tests that construct Orders with null amount fields to verify behavior.

Copy link
Author

Choose a reason for hiding this comment

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

기존 DB의 null 값이 들어가 있던 상황이라면 에러 발생 가능, 하지만 현재 상황에는 그럴 일이 없으므로 수정하지 않음

Copy link

Choose a reason for hiding this comment

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

@Namjin-kimm, 이해했다. 신규 스키마에서 기존 레거시 데이터가 없는 상황이라면 현실적인 위험이 없다는 판단은 합리적이다. 다만 향후 스키마 변경이나 데이터 마이그레이션이 발생할 경우 해당 지점을 다시 검토하기를 권장한다.


🧠 Learnings used
Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 6
File: apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java:9-10
Timestamp: 2026-02-04T05:52:09.963Z
Learning: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).

Comment on lines +29 to +35
public UserCoupon issue(Long userId, Long couponTemplateId) {
if (userCouponRepository.existsByUserIdAndCouponTemplateId(userId, couponTemplateId)) {
throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다.");
}
// 쿠폰 템플릿 존재 여부 검증
CouponTemplate template = findTemplateById(couponTemplateId);
return userCouponRepository.save(new UserCoupon(couponTemplateId, userId, template.getExpiredAt()));
Copy link

@coderabbitai coderabbitai bot Mar 6, 2026

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:

# 파일 위치 확인
fd -t f 'CouponService|UserCoupon|Coupon.*Test' --extension java | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1114


🏁 Script executed:

# 스키마 파일 탐색
fd -t f -e sql -e yml | grep -i 'schema\|migration\|ddl' | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 디렉토리 구조 파악
find apps/commerce-api/src -type d | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1127


🏁 Script executed:

# 파일 크기 확인
wc -l apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
wc -l apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java
wc -l apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 330


🏁 Script executed:

# CouponService.java 전체 내용
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6003


🏁 Script executed:

# UserCoupon.java 내용
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2575


🏁 Script executed:

# CouponConcurrencyTest 파일 읽기
cat -n apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4737


🏁 Script executed:

# UserCouponRepository 관련 파일 읽기
wc -l apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
cat -n apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1438


🏁 Script executed:

# UserCouponRepositoryImpl 확인
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2486


🏁 Script executed:

# UserCouponJpaRepository 확인 (useIfAvailable 구현)
fd -t f 'UserCouponJpaRepository.java' --extension java
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2416


🏁 Script executed:

# issue() 메서드의 동시성 테스트 확인
rg -n "issue|발급" apps/commerce-api/src/test/java/com/loopers/domain/coupon/ -A 5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 10141


issue() 메서드의 중복 발급이 동시성 하에서 취약하다.

현재 구현은 existsByUserIdAndCouponTemplateId 조회 후 save하는 select-then-act 패턴으로, 동일 사용자/템플릿에 대한 동시 요청이 들어오면 두 트랜잭션이 모두 중복 검사를 통과해 중복 발급될 수 있다. UserCoupon.java의 주석(라인 20-21)에서 "UNIQUE 제약은 soft delete 테이블이므로 DB 미적용, 애플리케이션 레벨 검증"이라고 명시했으나, 애플리케이션 레벨의 단순 SELECT 조회는 트랜잭션 격리 수준 하에서도 경합 조건을 보장하지 못한다.

운영에서는 BR-C03(중복 발급 방지)이 깨져 할인 부채와 CS 이슈로 이어진다. 다음 중 하나로 개선해야 한다:

  1. 원자적 UPDATE 방식: applyCoupon()의 useIfAvailable()처럼 데이터베이스 UPDATE 쿼리에 WHERE 조건을 포함해 원자성 보장
  2. 비관적 락: SELECT ... FOR UPDATE로 발급 시점의 행을 잠금
  3. 낙관적 락: @Version 필드 추가로 version 기반 동시성 제어
  4. 멱등성 보장: 이미 발급된 쿠폰이면 중복 발급 대신 기존 쿠폰을 반환하는 idempotent 구현

또한 CouponConcurrencyTest는 현재 applyCoupon()만 검증하며, issue() 메서드의 동시 요청 시나리오(같은 userId/couponTemplateId로 10개 이상의 스레드가 동시 발급 요청)가 누락되어 있다. 성공 요청이 정확히 1건만 남고 DB 행 수도 1건인지 검증하는 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java`
around lines 29 - 35, issue() uses a select-then-act pattern
(existsByUserIdAndCouponTemplateId -> save) and can produce duplicate UserCoupon
rows under concurrency; change issue() to perform an atomic
insert-with-fallback: wrap creation in a `@Transactional` method, attempt to
save(new UserCoupon(...)) and catch DataIntegrityViolationException (or
DuplicateKeyException) indicating a concurrent insert, then load and return the
existing coupon via userCouponRepository.findByUserIdAndCouponTemplateId(userId,
couponTemplateId); remove the initial existsByUserIdAndCouponTemplateId
pre-check or keep it only for fast-fail but rely on the exception for
correctness; finally add a concurrent test in CouponConcurrencyTest that spawns
many threads calling CouponService.issue(userId, couponTemplateId) and asserts
exactly one persisted UserCoupon and that all threads receive the same coupon
(or only one successful issuance).

Copy link
Author

Choose a reason for hiding this comment

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

쿠폰 중복 발급 여부는 현재 단계에서 방지하지 않음

Copy link

Choose a reason for hiding this comment

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

@Namjin-kimm, 결정을 확인하였다. 현재 단계에서 issue() 메서드의 동시 중복 발급 방지는 구현 범위 밖임을 인지하였다.

향후 단계에서 해당 기능이 필요해질 경우, useIfAvailable()과 유사한 원자적 UPDATE 방식 또는 DB UNIQUE 제약 기반의 예외 처리 방식을 검토하면 될 것이다.


✏️ Learnings added
Learnt from: Namjin-kimm
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 152
File: apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java:29-35
Timestamp: 2026-03-06T09:19:43.842Z
Learning: In the loop-pack-be-l2-vol3-java project, the `issue()` method in `CouponService.java` (apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java) uses a select-then-act pattern (existsByUserIdAndCouponTemplateId -> save) and does NOT implement concurrent duplicate issuance prevention. This is an intentional design decision for the current stage; duplicate issuance prevention (BR-C03 concurrency hardening) is deferred to a later phase.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 6
File: apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java:9-10
Timestamp: 2026-02-04T05:52:09.963Z
Learning: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).

Comment on lines +41 to +45
public CouponTemplate(String name, CouponType type, int value, Integer minOrderAmount, LocalDateTime expiredAt) {
validateName(name);
validateType(type);
validateValue(value);
validateExpiredAt(expiredAt);
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

템플릿 검증에서 핵심 불변식을 빠뜨렸다.

현재는 minOrderAmount < 0 같은 설정도 저장되고, 정률 쿠폰에 value > 100도 허용된다. 운영에서는 관리자 오입력만으로 최소 주문 조건이 사실상 무력화되거나 할인액이 주문 금액을 넘어가 결제 금액이 음수가 되는 사고가 날 수 있으므로, minOrderAmount 검증을 추가하고 RATE1..100 범위만 허용하도록 타입별 검증으로 바꾸는 편이 안전하다. 등록/수정 모두 음수 최소 주문 금액과 101% 할인을 거절하는 테스트, 그리고 100% 할인 경계 테스트를 추가해야 한다. As per coding guidelines, **/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.

Also applies to: 55-59, 103-109, 123-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponTemplate.java`
around lines 41 - 45, The constructor CouponTemplate(...) currently calls
validateName, validateType, validateValue, validateExpiredAt but misses
validation for minOrderAmount and type-specific rate bounds; update the class to
add and call validateMinOrderAmount(minOrderAmount) to reject negative values
and change validateValue(int value) into type-aware checks (e.g., for
CouponType.RATE require 1..100 inclusive, for CouponType.AMOUNT require value >=
0) and ensure these validations are invoked both on construction and in any
update methods (e.g., methods that modify template fields). Also add unit tests
covering creation and update: reject negative minOrderAmount, reject RATE > 100
(e.g., 101), allow RATE == 100 (boundary), and reject negative fixed amount
where applicable to enforce these invariants.

Copy link
Author

Choose a reason for hiding this comment

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

수정 완료

Comment on lines +27 to +32
// 원자적 쿠폰 사용: WHERE used_at IS NULL 조건으로 동시 요청 중 하나만 성공
// flushAutomatically: UPDATE 전 pending 변경사항 flush 보장
// clearAutomatically 미사용: 같은 트랜잭션 내 Product 등 다른 관리 엔티티의 dirty checking을 유지하기 위함
@Modifying(flushAutomatically = true)
@Query("UPDATE UserCoupon uc SET uc.usedAt = :now WHERE uc.id = :id AND uc.userId = :userId AND uc.usedAt IS NULL AND uc.deletedAt IS NULL")
int useIfAvailable(@Param("id") Long id, @Param("userId") Long userId, @Param("now") LocalDateTime now);
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

만료 검증이 TOCTOU에 취약하다.

지금 구조에서는 만료 검증과 사용 처리가 분리되어 있어, 만료 시각 경계에서 이미 만료된 쿠폰이 DB에서는 사용 처리될 수 있다. 운영에서는 만료 쿠폰 적용으로 주문 금액이 잘못 계산되므로, useIfAvailableWHERE 절에 만료 조건까지 포함해 사용 가능성 전체를 DB 한 번에 판정해야 한다. updated == 0도 곧바로 “이미 사용됨”으로 단정하지 말고 만료/삭제를 재판별하거나 메시지를 일반화하는 편이 안전하다. expiredAt 직전/직후 시점에서 정확히 한쪽만 성공하는 경계 테스트도 추가해야 한다. As per coding guidelines, **/*Repository*.java: 쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java`
around lines 27 - 32, The current useIfAvailable update query can let TOCTOU
expiry races pass; modify the UserCoupon repository method useIfAvailable so its
WHERE clause also includes expiry-check (e.g., AND uc.expiredAt > :now or
appropriate not-expired condition) alongside uc.usedAt IS NULL and uc.deletedAt
IS NULL, pass the same now parameter and update the method signature to accept
it; change any caller logic that treats a returned 0 as strictly “already used”
to instead re-query or return a generalized failure reason
(used/expired/deleted) so callers can distinguish and handle expiry, and add
boundary tests around expiredAt (just before/after) to ensure only one
concurrent request wins.

Comment on lines +28 to +35
public ApiResponse<CouponAdminV1Dto.CouponTemplateListResponse> getTemplates(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size)
{
return ApiResponse.success(
CouponAdminV1Dto.CouponTemplateListResponse.from(
couponAdminFacade.findAllTemplates(PageRequest.of(page, size))));
}
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:

fd 'ApiControllerAdvice\.java$|ControllerAdvice.*\.java$' apps/commerce-api/src/main/java -x cat -n {}

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6820


🏁 Script executed:

rg -n "PageRequest\\.of\(|@ExceptionHandler|IllegalArgumentException|CoreException" apps/commerce-api/src/main/java apps/commerce-api/src/test/java | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 15015


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4804


페이지 및 크기 파라미터를 유효성 검증 없이 PageRequest에 전달하면 공통 에러 포맷이 깨진다.

PageRequest.of(page, size)page < 0 또는 size <= 0일 때 IllegalArgumentException을 던지는데, ApiControllerAdvice에 해당 예외 핸들러가 없어 catch-all Throwable 핸들러로 빠져 INTERNAL_ERROR(500)로 응답한다. 클라이언트 입력 오류이므로 BAD_REQUEST(400)로 응답해야 한다. 또한 과도한 size 값이 들어올 경우 데이터베이스 부하가 증가하므로 상한선 검증도 필수다.

컨트롤러에서 다음과 같이 수정하면 된다:

  • page >= 0 검증
  • size > 0size <= MAX_PAGE_SIZE 검증
  • 위반 시 CoreException(BAD_REQUEST, "page and size parameters must be valid")로 throw

테스트로는 page=-1, size=0, size=초과값 요청이 모두 ApiResponse.FAIL 응답(code: BAD_REQUEST)을 받는지 확인해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java`
around lines 28 - 35, Validate request parameters in
CouponAdminV1Controller.getTemplates before calling PageRequest.of: add checks
that page >= 0 and size > 0 && size <= MAX_PAGE_SIZE (introduce a private static
final int MAX_PAGE_SIZE constant), and if any check fails throw new
CoreException(BAD_REQUEST, "page and size parameters must be valid") so client
input errors return BAD_REQUEST instead of bubbling as IllegalArgumentException;
keep using couponAdminFacade.findAllTemplates(...) after validation and add
tests for page=-1, size=0, and size>MAX_PAGE_SIZE to assert ApiResponse.FAIL
with code BAD_REQUEST.

Comment on lines +68 to +89
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(THREAD_COUNT);

for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 모든 스레드를 대기기 시키고 동시에 applyCoupon 작업을 시작할 수 있도록
couponService.applyCoupon(coupon.getId(), user.getId(), ORDER_AMOUNT);
successCount.incrementAndGet();
} catch (Exception e) {
// 예상된 실패 (이미 사용된 쿠폰)
} finally {
doneLatch.countDown();
}
});
}
// startLatch의 카운트 다운은 메인스레드에 존재하기 때문에, 반복문이 다 실행되고 아래 코드가 실행이 됨.
// 그때 비로소 10개의 대기중인 백그라운드 코드가 동시에 실행이 됨.
startLatch.countDown(); // 모든 스레드 동시 출발
doneLatch.await();
executor.shutdown();
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:

find . -type f -path "*commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 171


🏁 Script executed:

wc -l ./apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 174


🏁 Script executed:

sed -n '60,97p' ./apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2074


🏁 Script executed:

sed -n '1,70p' ./apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2927


🏁 Script executed:

find . -type f -name "*.java" | xargs grep -l "class CoreException" | head -3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 246


🏁 Script executed:

find . -type f -name "CouponService.java" -path "*/main/*" | xargs grep -A 10 "applyCoupon" | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 643


동시성 테스트가 무한 대기(hang) 또는 중요한 버그를 은폐할 수 있다.

doneLatch.await()에 timeout이 없어서 실제 데드락 또는 리소스 고갈이 발생하면 CI 파이프라인이 실패하지 않고 무한 대기로 멈춘다. 여기에 catch (Exception)이 모든 예외를 삼키므로, 데이터베이스 연결 오류, 트랜잭션 타임아웃, 동시성 버그까지 "예상된 비즈니스 실패"로 위장된다. 결과적으로 운영 환경에서 가장 위험한 동시성 회귀를 테스트가 놓치게 된다.

다음 조치가 필요하다:

  • doneLatch.await(timeout, TimeUnit.SECONDS)로 5초 내 완료 강제
  • executor.shutdownNow()finally 블록에서 항상 실행하여 리소스 누수 방지
  • catch (CoreException e) 로 범위를 좁혀서 비즈니스 예외만 수집, failures 큐에 기록
  • 단일 성공(successCount=1) + 나머지는 모두 기대한 CoreException 타입만 허용하도록 검증

추가로 예외 타입과 메시지가 일정하고, 실패 시나리오에서도 정해진 시간 내 테스트가 종료되는지 별도 케이스로 검증해야 한다.

수정 예시
+import com.loopers.support.error.CoreException;
 import java.time.LocalDateTime;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
@@
             AtomicInteger successCount = new AtomicInteger(0);
+            ConcurrentLinkedQueue<Throwable> failures = new ConcurrentLinkedQueue<>();
             ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
             CountDownLatch startLatch = new CountDownLatch(1);
             CountDownLatch doneLatch = new CountDownLatch(THREAD_COUNT);
 
-            for (int i = 0; i < THREAD_COUNT; i++) {
-                executor.submit(() -> {
-                    try {
-                        startLatch.await(); // 모든 스레드를 대기기 시키고 동시에 applyCoupon 작업을 시작할 수 있도록
-                        couponService.applyCoupon(coupon.getId(), user.getId(), ORDER_AMOUNT);
-                        successCount.incrementAndGet();
-                    } catch (Exception e) {
-                        // 예상된 실패 (이미 사용된 쿠폰)
-                    } finally {
-                        doneLatch.countDown();
-                    }
-                });
-            }
-            // startLatch의 카운트 다운은 메인스레드에 존재하기 때문에, 반복문이 다 실행되고 아래 코드가 실행이 됨.
-            // 그때 비로소 10개의 대기중인 백그라운드 코드가 동시에 실행이 됨.
-            startLatch.countDown(); // 모든 스레드 동시 출발
-            doneLatch.await();
-            executor.shutdown();
+            try {
+                for (int i = 0; i < THREAD_COUNT; i++) {
+                    executor.submit(() -> {
+                        try {
+                            startLatch.await();
+                            couponService.applyCoupon(coupon.getId(), user.getId(), ORDER_AMOUNT);
+                            successCount.incrementAndGet();
+                        } catch (CoreException e) {
+                            failures.add(e);
+                        } finally {
+                            doneLatch.countDown();
+                        }
+                    });
+                }
+                startLatch.countDown();
+                assertThat(doneLatch.await(5, TimeUnit.SECONDS)).isTrue();
+            } finally {
+                executor.shutdownNow();
+            }
@@
             UserCoupon used = userCouponJpaRepository.findById(coupon.getId()).orElseThrow();
             assertThat(successCount.get()).isEqualTo(1);      // 정확히 1번만 성공
             assertThat(used.getUsedAt()).isNotNull();          // DB에 used_at 기록 확인
+            assertThat(failures).hasSize(THREAD_COUNT - 1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponConcurrencyTest.java`
around lines 68 - 89, The test can hang and hide real errors; update the
concurrency block so doneLatch.await(...) uses a timeout (e.g.,
doneLatch.await(5, TimeUnit.SECONDS)) and always shuts down the executor in a
finally block (executor.shutdownNow()); narrow the catch to business failures
(catch CoreException e) and record them into a failures collection, keep
incrementing successCount only on successful couponService.applyCoupon(...)
calls, and assert afterwards that successCount.get() == 1 and all other failures
are CoreException with expected messages to fail fast on DB/transaction issues.

Comment on lines +71 to +72
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(OVER_THREAD_COUNT);
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:

find . -type f -name "OrderConcurrencyTest.java" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 169


🏁 Script executed:

wc -l ./apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 173


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6655


동시성 테스트가 무기한 대기로 빌드를 멈출 수 있다.

doneLatch.await()에 제한 시간이 없어서 락 회귀나 스레드 누수가 발생하면 테스트가 실패하지 않고 빌드 전체를 붙잡는다. await(timeout, TimeUnit.SECONDS)로 상한을 두고, ExecutorServiceshutdownNow()awaitTermination()을 호출해 자원을 정확히 정리해야 한다. 두 테스트 메서드(라인 89-90, 132-133)에서 동일한 패턴을 수정하고, 의도적으로 락을 오래 점유하는 시나리오에서 테스트가 제한 시간 내 실패하는지 확인하면 된다.

제안 수정
+import java.util.concurrent.TimeUnit;
@@
-            doneLatch.await();
-            executor.shutdown();
+            assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();
+            executor.shutdownNow();
+            assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();

두 번째 테스트(라인 132-133)에도 동일한 패턴을 적용하면 된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderConcurrencyTest.java`
around lines 71 - 72, 동시성 테스트가 무기한 대기할 수 있으니 CountDownLatch의 무제한 await를 제한시간이 있는
await(timeout, TimeUnit.SECONDS)로 변경하고, 테스트에서 사용하는 ExecutorService를 종료할 때
shutdownNow()를 호출한 다음 awaitTermination(timeout, TimeUnit.SECONDS)로 자원 정리를 보장하도록
수정하세요; 구체적으로 OrderConcurrencyTest의 CountDownLatch 변수들(startLatch, doneLatch,
OVER_THREAD_COUNT)을 사용하는 두 테스트 메서드(각 테스트의 doneLatch.await() 호출)를 찾아서 타임아웃 버전으로
바꾸고, 테스트 종료 블록에서 ExecutorService.shutdownNow()와
ExecutorService.awaitTermination(...)를 호출해 강제 종료와 대기 로직을 추가해 주세요.

Comment on lines +76 to +85
executor.submit(() -> {
try {
startLatch.await(); // 모든 스레드를 대기시키고 동시에 출발
orderFacade.create(USER_ID, command);
successCount.incrementAndGet();
} catch (Exception e) {
// 예상된 실패 (재고 부족 - BAD_REQUEST)
} finally {
doneLatch.countDown();
}
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

예상 실패와 비정상 실패를 분리해서 검증해야 한다.

지금은 Exception을 모두 삼켜서 첫 번째 테스트가 재고 부족 대신 데드락, 락 타임아웃, NPE로 실패해도 성공 건수만 맞으면 통과할 수 있다. 운영 관점에서는 "정상적인 품절 차단"과 "동시성 장애"를 구분하지 못해 회귀를 놓치게 된다. 실패 예외를 별도로 수집해 기대한 비즈니스 예외만 허용하고, 그 외 예외는 즉시 테스트를 실패시키도록 바꿔 달라. 추가로 초과 주문 시 정확히 OVER_THREAD_COUNT - STOCK_COUNT건만 재고 부족으로 실패하고, 락 타임아웃/데드락 계열 예외는 0건임을 검증하는 어설션을 넣어 달라.

As per coding guidelines "**/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."

Also applies to: 119-128

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