Skip to content

도메인 주도 설계 구현 #103

Open
hyejin0810 wants to merge 13 commits intoLoopers-dev-lab:hyejin0810from
hyejin0810:main
Open

도메인 주도 설계 구현 #103
hyejin0810 wants to merge 13 commits intoLoopers-dev-lab:hyejin0810from
hyejin0810:main

Conversation

@hyejin0810
Copy link

@hyejin0810 hyejin0810 commented Feb 26, 2026


제목: 도메인 주도 설계 구현

📌 Summary
- 목표: 설계 문서를 기반으로 Brand/Product/Like/Order/User 5개 도메인의 전 레이어(Domain → Infrastructure → Application → Interfaces)를 TDD로 구현하고, 단위 테스트로 도메인 로직 정합성을 검증한다.

  • 결과: 5개 도메인 전 레이어 구현 완료. Controller → Facade → Service → Repository(interface) → RepositoryImpl 흐름
    전 도메인 일관 적용. 주문 스냅샷, 브랜드 삭제 연쇄, 좋아요 수 비정규화 구현. 단위 테스트 추가.

💬 리뷰 포인트

  1. Facade의 크로스 도메인 직접 호출: OrderFacade가 ProductService, UserService, BrandService를 직접 의존하는 구조가
    적절한지, 책임이 과도하게 집중되지 않는지
  2. N+1 방지 방식: fetch join 대신 getBrandsByIds(), getProductsByIds() IN 쿼리 + Application 단 Map 조립 방식을
    선택한 판단이 적절한지
  3. 주문 취소 시 재고/잔액 복구 위치: OrderFacade.cancelOrder()에서 재고 복구(product.increaseStock())와 잔액
    복구(user.restoreBalance())를 처리하는 구조가 도메인 책임 관점에서 적절한지

🧭 Context & Decision

  1. @transactional 위치 — Service vs Facade

문제: 초기에 Facade에만 @transactional을 두었으나, Service 단독 호출 시 트랜잭션이 보장되지 않는 문제가 있었다.

결정: Service와 Facade 양쪽에 @transactional 적용

🛠 트랜잭션 적용 전략

레이어 적용 방식
Domain Service 쓰기: @Transactional, 읽기: @Transactional(readOnly = true)
Application Facade 쓰기: @Transactional, 읽기: @Transactional(readOnly = true)
  • 이유: Service가 단독으로 호출될 수도 있으므로 Service 자체가 트랜잭션을 보장해야 함. Facade의 @transactional
    크로스 도메인 조율 시 전체 롤백을 보장하기 위한 외부 경계 역할.
  • 트레이드오프: Facade에서 Service를 호출하면 Spring의 트랜잭션 전파(REQUIRED)로 합류되므로 실질적으로 하나의
    트랜잭션으로 동작. 단, 트랜잭션 경계가 두 레이어에 걸쳐 있어 명시적 관리가 필요.

  1. Facade의 역할

🏛 Facade 레이어 의존성 현황

Facade 의존하는 Service 도입 이유
BrandFacade BrandService, ProductService Brand 삭제 시 관련 Product 연쇄 Soft Delete 처리
ProductFacade ProductService, BrandService 상품 응답에 브랜드명 보강 및 조회 시 N+1 문제 방지
LikeFacade LikeService, ProductService, UserService, BrandService 좋아요 목록 조회 시 상품/브랜드 정보 통합 보강
OrderFacade OrderService, ProductService, UserService, BrandService 재고/잔액 차감, 스냅샷 저장 및 취소 시 복구 로직 오케스트레이션
UserFacade UserService 서비스 간 직접 참조에 의한 레이어 위반 방지 및 구조 통일

  1. JPA Entity를 Domain 레이어에 두는 이유

문제: 순수 Domain 레이어라면 @entity, @table 등 JPA 기술을 모르는 것이 이상적이다.

결정: @entity 어노테이션을 Domain Entity(Brand, Product 등)에 직접 적용

  • 이유: Entity를 Infrastructure로 분리하면 JPA 더티 체킹과 1차 캐시를 사용할 수 없어, 상태 변경마다 명시적 save()
    호출이 필요해짐. 또한 Domain Service가 Infrastructure 타입을 import하는 의존 방향 역전 문제 발생.
  • 트레이드오프: Domain이 JPA 기술을 완전히 모르는 순수성은 포기하지만, 더티 체킹/1차 캐시 활용과 명확한 의존 방향을
    얻음.

  1. N+1 해결 — IN 쿼리 + Application 단 조립

문제: 상품 목록 조회 시 브랜드명을 보강하는 과정에서 N+1 쿼리 발생 위험.

결정: fetch join 대신 IN절 배치 조회 + Facade에서 Map 조립

// 1. 중복 없는 브랜드 ID 추출
List<Long> brandIds = products.stream()
    .map(Product::getBrandId)
    .distinct()
    .toList();

// 2. 브랜드 벌크 조회 및 Map 변환
Map<Long, Brand> brandMap = brandService.getBrandsByIds(brandIds).stream()
    .collect(Collectors.toMap(Brand::getId, b -> b));

// 3. 상품 정보와 브랜드 정보 결합
return products.stream()
    .map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId())))
    .toList();

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈: apps/commerce-api 전 레이어
  • 신규 추가: Brand, Product, Like, Order, User 도메인의 Entity/Repository(interface)/Service, Facade/Info,
    RepositoryImpl/JpaRepository, Controller/Dto
  • 수정: UserFacade 신규 추가로 UserV1Controller 레이어 위반 수정, OrderService.cancelOrder 시그니처 변경 (Order
    엔티티 직접 수신), BrandFacade 트랜잭션 누락 수정, OrderFacade 주문 소유자 검증 추가

주요 컴포넌트 책임

🎯 컴포넌트별 책임 정의

분류 컴포넌트 책임 (Responsibility)
Domain Entity Brand 브랜드 이름 중복 방지 검증, Soft Delete 처리
Product 재고 관리(decrease/increase), 좋아요 수 관리, Soft Delete 처리
Order 주문 상태 관리(approve/cancel), 주문 소유권 검증
OrderItem 주문 시점의 상품 정보(이름, 브랜드, 단가) 스냅샷 보관
Application Facade BrandFacade 브랜드 삭제 시 소속 상품들의 연쇄 Soft Delete 오케스트레이션
OrderFacade 주문(재고·잔액 차감 → 스냅샷 저장), 취소(재고·잔액 복구) 흐름 제어
LikeFacade 좋아요 추가/취소 및 상품 도메인의 likesCount 갱신 조율
Infrastructure *RepositoryImpl Domain Repository 인터페이스 구현(DIP), JpaRepository 위임

🔁 Flow Diagram

주문 생성 흐름

sequenceDiagram
    autonumber
    participant Client
    participant Controller as OrderV1Controller
    participant Facade as OrderFacade
    participant UserSvc as UserService
    participant ProductSvc as ProductService
    participant BrandSvc as BrandService
    participant OrderSvc as OrderService

    Client->>Controller: POST /api/v1/orders
    Controller->>Facade: createOrder(loginId, pw, items)

    Note over Facade: [Transaction Start]

    Facade->>UserSvc: authenticate (인증 & 잔액 차감)
    
    loop 각 주문 상품별 (items)
        Facade->>ProductSvc: getProduct (재고 검증)
        Facade->>ProductSvc: decreaseStock (재고 차감)
    end

    Facade->>BrandSvc: getBrandsByIds (브랜드 벌크 조회: N+1 방지)
    
    Facade->>OrderSvc: createOrder (주문 기본 정보 저장)
    Facade->>OrderSvc: createOrderItems (스냅샷: 상품명·브랜드명·단가 저장)

    Note over Facade: [Transaction Commit] <br/>(예외 발생 시 전체 Rollback)

    Facade-->>Controller: OrderInfo (Response DTO)
    Controller-->>Client: 200 OK
Loading

브랜드 삭제 연쇄 흐름

                                                                                         
    sequenceDiagram 
    autonumber
    participant Client
    participant Controller as BrandV1Controller
    participant Facade as BrandFacade
    participant ProductSvc as ProductService
    participant BrandSvc as BrandService

    Client->>Controller: DELETE /api/v1/brands/{id}
    Controller->>Facade: deleteBrand(id)

    Note over Facade: @Transactional 시작

    Facade->>ProductSvc: deleteProductsByBrandId (소속 상품 연쇄 soft delete)
    Facade->>BrandSvc: deleteBrand (Brand soft delete)

    Note over Facade: @Transactional 종료 (실패 시 전체 롤백)

    Facade-->>Controller: void
    Controller-->>Client: 200 OK
Loading

PR 요약: Domain-Driven Design 5개 도메인 전 계층 TDD 구현

변경 목적
도메인 주도 설계(DDD) 원칙에 따라 Brand, Product, Like, Order, User 5개 도메인의 Domain → Infrastructure → Application → Interfaces 계층을 TDD 방식으로 전수 구현하고, 단위 테스트로 도메인 로직을 검증합니다.

핵심 변경점

  • 계층 설계: Service와 Facade 양쪽에 @transactional 적용(읽기는 readOnly=true)하되, Facade는 크로스 도메인 조율의 외부 경계 역할. OrderFacade는 Product, User, Brand Service를 직접 의존하여 주문 생성, 취소, 승인 흐름을 오케스트레이션.
  • N+1 방지: fetch join 대신 IN 배치 조회(findAllByIds) 후 Application 레이어에서 Map으로 조립 (예: OrderFacade의 브랜드 조회 및 스냅샷 저장).
  • 주문 스냅샷: 주문 시점의 상품명·브랜드명·단가를 OrderItem에 저장하여 이후 상품 정보 변경으로부터 주문 내역 보호.
  • 연쇄 삭제 & 비정규화: 브랜드 삭제 시 BrandFacade에서 ProductService.deleteProductsByBrandId()로 소속 상품 soft delete; Product.likesCount 비정규화로 좋아요 수 관리.
  • API 설계: 5개 도메인의 Facade/Info, Controller/Dto, RepositoryImpl/JpaRepository 일관 추가 및 단위 테스트(총 800+ 라인).

리스크 및 확인 사항

  • OrderService.cancelOrder(Order) 시그니처 변경으로 호출처에서 사전 조회 필수 → OrderFacade에서 소유권 검증 후 전달하므로 의도된 설계인지 확인 필요.
  • Facade가 여러 Service에 직접 의존(OrderFacade → ProductService, UserService, BrandService 등)하는 오케스트레이션 방식이 일관성 있게 적용되었는지 리뷰.
  • 주문 취소 시 재고 복구와 잔액 환불 로직이 OrderFacade에 구현되어 있으므로, 이것이 Facade vs Domain 책임 분배 원칙과 맞는지 재확인.

테스트 및 검증
각 도메인 엔티티(Brand, Product, Like, Order, User)의 비즈니스 로직과 Service, Facade의 조율 로직을 단위 테스트로 검증(BrandTest, ProductTest, LikeTest, OrderTest, UserTest 등 추가). 트랜잭션 경계, N+1 방지, soft delete 동작, 스냅샷 정합성이 테스트로 보장되는지 확인 필요.

hyejin0810 and others added 12 commits February 23, 2026 21:40
- 장바구니 최대 100개 제한 제거
- likes_count 동시성: @Version 낙관적 락 명시 (시퀀스 다이어그램, 클래스 다이어그램, 요구사항)
- 주문번호 생성 전략: UUID 기반 (ORD-yyyyMMdd-{UUID 앞 8자리}) 명시
- 주문 취소 트랜잭션: @transactional 원자적 처리 범위 명시
- 물리적 FK: ORDERS→USERS, ORDER_ITEMS→ORDERS 구간 DDL FK 제약 명시
- OrderFacade 추가: OrderService + CartService 조율로 관심사 분리
- CartService 추가: 장바구니 관리 책임 분리
- OrderService.generateOrderNumber() 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 장바구니(Cart) 도메인 전체 제거 (요구사항, 시퀀스, 클래스, ERD)
- User에 balance 필드 추가 (USERS 테이블 포함)
- 주문 생성 시퀀스: 잔액 확인 → 잔액 차감 흐름 추가
- 주문 취소 시퀀스: 잔액 복구 단계 추가 (UserRepo 참여자 추가)
- 기능 요구사항: 잔액 조회, 잔액 확인/차감/복구 항목 추가
- 시나리오 3 → 상품 주문으로 변경 (장바구니 시나리오 삭제)
- 핵심 제약: 잔액 부족 시 주문 실패 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- User: balance 추가, deductBalance/restoreBalance, authenticate()
- Brand: 등록/조회/삭제 CRUD
- Product: @Version 낙관적 락, decreaseStock/increaseStock, likesCount
- Like: 중복 방지(CONFLICT), 취소(NOT_FOUND), Product.likesCount 연동
- Order/OrderItem: UUID 주문번호, PENDING/CONFIRMED/CANCELLED 상태 관리
- OrderFacade: 재고 차감 + 잔액 차감 단일 트랜잭션, 취소 시 원복
- 각 도메인 단위 테스트 (LikeTest, LikeServiceTest, OrderTest, OrderServiceTest 등)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BrandFacade.deleteBrand에 @transactional 추가 (연쇄 삭제 원자성 보장)
- OrderFacade.cancelOrder에 주문 소유자 검증 추가 (타인 주문 취소 시 NOT_FOUND)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cancelOrder(Long id) → cancelOrder(Order order)로 변경하여 중복 조회 제거
- Facade에서 조회한 Order를 직접 전달하도록 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UserServiceTest: isNotNull() 중복 제거 (isEqualTo로 충분)
- OrderTest: 테스트 이름과 무관한 필드 검증 제거 (status만 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UserFacade 생성: getMyInfo 유스케이스를 application 레이어로 이동
- UserService에서 getMyInfo 제거 및 UserInfo import 삭제 (domain → application 역방향 의존 해소)
- UserV1Controller가 getMyInfo를 UserFacade를 통해 호출하도록 수정
- UserFacadeTest 추가, UserServiceTest의 GetMyInfo 테스트 이동
- UserV1ControllerStandaloneTest를 UserFacade mock 기반으로 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Service 레이어(User/Brand/Product/Like/Order/Member)의 @transactional 전부 제거
- BrandFacade: register(@transactional), getBrand/getBrands(@transactional readOnly) 추가
- ProductFacade: 모든 메서드에 @transactional 추가
- 트랜잭션 경계를 유스케이스 단위인 Facade로 일원화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- User/Brand/Product/Like/Order/MemberService 메서드에 @transactional 복원
- 쓰기 메서드: @transactional, 읽기 메서드: @transactional(readOnly = true)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

브랜드, 상품, 주문, 좋아요, 사용자 도메인에 대한 엔티티, 저장소 인터페이스, 도메인 서비스, 애플리케이션 파사드, REST 컨트롤러 및 DTO를 추가하여 전자상거래 기능을 확장한다. 트랜잭션 관리, 소프트 삭제, 낙관적 잠금을 포함한 44개 파일과 11개 테스트 파일이 신규 추가된다.

Changes

Cohort / File(s) 요약
브랜드 도메인 계층
com/loopers/domain/brand/Brand.java, BrandRepository.java, BrandService.java
브랜드 엔티티와 저장소 인터페이스, 도메인 서비스를 추가한다. 브랜드 등록 시 이름 중복 검증과 소프트 삭제 기능을 구현한다.
브랜드 인프라 및 애플리케이션 계층
com/loopers/infrastructure/brand/BrandJpaRepository.java, BrandRepositoryImpl.java, com/loopers/application/brand/BrandFacade.java, BrandInfo.java
Spring Data JPA 저장소 구현, 삭제된 브랜드를 제외한 쿼리 필터링, 트랜잭션으로 관리되는 파사드와 DTO를 추가한다.
브랜드 REST API
com/loopers/interfaces/api/brand/BrandV1Controller.java, BrandV1Dto.java
브랜드 CRUD 엔드포인트와 요청/응답 DTO를 정의한다.
상품 도메인 계층
com/loopers/domain/product/Product.java, ProductRepository.java, ProductService.java
상품 엔티티, 저장소 인터페이스, 도메인 서비스를 추가한다. 재고 관리, 좋아요 증감, 낙관적 잠금을 지원한다.
상품 인프라 및 애플리케이션 계층
com/loopers/infrastructure/product/ProductJpaRepository.java, ProductRepositoryImpl.java, com/loopers/application/product/ProductFacade.java, ProductInfo.java
JPA 저장소 구현, 소프트 삭제 필터링, 트랜잭션 관리 파사드, 상품 정보 DTO를 추가한다.
상품 REST API
com/loopers/interfaces/api/product/ProductV1Controller.java, ProductV1Dto.java
상품 조회, 생성, 수정, 삭제 엔드포인트와 DTO를 정의한다.
좋아요 도메인 계층
com/loopers/domain/like/Like.java, LikeRepository.java, LikeService.java
좋아요 엔티티(사용자-상품 복합 유니크 제약), 저장소 인터페이스, 도메인 서비스를 추가한다. 중복 좋아요 방지 및 좋아요 목록 조회를 지원한다.
좋아요 인프라 및 애플리케이션 계층
com/loopers/infrastructure/like/LikeJpaRepository.java, LikeRepositoryImpl.java, com/loopers/application/like/LikeFacade.java
JPA 저장소 구현, 트랜잭션 관리 파사드를 추가한다. 좋아요 추가/제거 시 상품의 좋아요 개수를 동기화한다.
좋아요 REST API
com/loopers/interfaces/api/like/LikeV1Controller.java, LikeV1Dto.java
좋아요 목록 조회, 추가, 제거 엔드포인트와 DTO를 정의한다.
주문 도메인 계층
com/loopers/domain/order/Order.java, OrderItem.java, OrderStatus.java, OrderRepository.java, OrderItemRepository.java, OrderService.java
주문 및 주문 항목 엔티티, 상태 열거형, 저장소 인터페이스, 도메인 서비스를 추가한다. 주문 상태 전이(PENDING → CONFIRMED/CANCELLED), 상품 정보 스냅샷, 주문 번호 생성을 지원한다.
주문 인프라 계층
com/loopers/infrastructure/order/OrderJpaRepository.java, OrderRepositoryImpl.java, OrderItemJpaRepository.java, OrderItemRepositoryImpl.java
주문 및 주문 항목 JPA 저장소 구현을 추가한다.
주문 애플리케이션 계층
com/loopers/application/order/OrderFacade.java, OrderInfo.java, OrderItemInfo.java, OrderRequest.java
트랜잭션 관리 주문 파사드, 주문 및 항목 DTO를 추가한다. 주문 생성 시 사용자 인증, 잔액 차감, 상품 재고 차감, 브랜드 정보 일괄 로드(N+1 방지)를 조율한다.
주문 REST API
com/loopers/interfaces/api/order/OrderV1Controller.java, OrderV1Dto.java
주문 생성, 조회, 취소, 승인 엔드포인트와 요청/응답 DTO를 정의한다.
사용자 도메인 확장
com/loopers/domain/user/User.java, UserService.java, UserRepository.java
사용자 엔티티에 잔액 필드를 추가한다. 잔액 차감/복구 메서드와 UserService의 인증 메서드를 추가한다.
사용자 인프라 및 애플리케이션 계층
com/loopers/infrastructure/user/UserJpaRepository.java, UserRepositoryImpl.java, com/loopers/application/user/UserFacade.java, UserInfo.java
사용자 JPA 저장소 구현, 트랜잭션 관리 파사드, UserInfo DTO에 잔액 필드를 추가한다.
사용자 REST API
com/loopers/interfaces/api/user/UserV1Controller.java, UserV1Dto.java
UserFacade를 의존성으로 추가하고, UserResponse DTO에 잔액 필드를 추가한다.
도메인 테스트
src/test/java/com/loopers/domain/brand/BrandTest.java, BrandServiceTest.java, src/test/java/com/loopers/domain/like/LikeTest.java, LikeServiceTest.java, src/test/java/com/loopers/domain/order/OrderTest.java, OrderServiceTest.java, src/test/java/com/loopers/domain/product/ProductTest.java, ProductServiceTest.java, src/test/java/com/loopers/domain/user/UserServiceTest.java, UserTest.java
각 도메인 엔티티와 서비스의 단위 테스트를 추가한다. 유효성 검증, 상태 전이, 오류 처리를 검증한다.
API 테스트
src/test/java/com/loopers/application/user/UserFacadeTest.java, src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java
사용자 파사드 및 컨트롤러의 통합 테스트를 추가한다.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant OrderFacade as OrderFacade<br/>(Application)
    participant UserService as UserService<br/>(Domain)
    participant ProductService as ProductService<br/>(Domain)
    participant BrandService as BrandService<br/>(Domain)
    participant OrderService as OrderService<br/>(Domain)

    Client->>OrderFacade: createOrder(loginId, password, items)
    OrderFacade->>UserService: authenticate(loginId, password)
    UserService-->>OrderFacade: User
    OrderFacade->>ProductService: getProductsByIds(itemIds)
    ProductService-->>OrderFacade: List<Product>
    OrderFacade->>BrandService: getBrandsByIds(brandIds)
    BrandService-->>OrderFacade: List<Brand>
    OrderFacade->>UserService: deductBalance(totalAmount)
    UserService-->>OrderFacade: void
    OrderFacade->>ProductService: decreaseStock(productId, quantity)
    ProductService-->>OrderFacade: void
    OrderFacade->>OrderService: createOrder(userId, orderNumber, totalAmount)
    OrderService-->>OrderFacade: Order
    OrderFacade->>OrderService: createOrderItem(orderId, productInfo)
    OrderService-->>OrderFacade: OrderItem
    OrderFacade-->>Client: OrderInfo
Loading
sequenceDiagram
    participant Client
    participant LikeFacade as LikeFacade<br/>(Application)
    participant UserService as UserService<br/>(Domain)
    participant LikeService as LikeService<br/>(Domain)
    participant ProductService as ProductService<br/>(Domain)

    Client->>LikeFacade: addLike(loginId, password, productId)
    LikeFacade->>UserService: authenticate(loginId, password)
    UserService-->>LikeFacade: User
    LikeFacade->>ProductService: getProduct(productId)
    ProductService-->>LikeFacade: Product
    LikeFacade->>LikeService: addLike(userId, productId)
    LikeService-->>LikeFacade: Like
    LikeFacade->>ProductService: increaseLikes(productId)
    ProductService-->>LikeFacade: void
    LikeFacade-->>Client: ApiResponse<Void>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55분

주문 생성 흐름에서 사용자 인증, 상품 재고 차감, 브랜드 정보 일괄 로드 등 다양한 도메인 서비스를 조율하는 복잡한 로직이 포함되어 있다. 또한 소프트 삭제, 낙관적 잠금, 트랜잭션 관리가 여러 계층에 걸쳐 있으며, 상태 전이 검증 로직이 있다. 44개 파일에 걸친 광범위한 변경으로 인해 각 계층 간 의존성과 데이터 일관성을 면밀히 검토해야 한다.

Possibly related PRs

  • PR #18: UserFacade 및 사용자 인증 관련 코드를 공통으로 추가/수정하고 있어 직접적인 코드 연관성이 있다.
  • PR #6: UserService 인증 메서드 시그니처 변경 및 사용자 계층 개선이 겹치는 부분이 있다.
  • PR #33: UserInfo 및 UserResponse DTO의 잔액 필드 추가가 공통적으로 진행되고 있다.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 '도메인 주도 설계 구현'으로 본 PR의 핵심 목표(5개 도메인의 전 레이어 DDD 구현)를 명확히 요약하고 있다.
Description check ✅ Passed PR 설명은 요구 템플릿의 주요 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 모두 포함하고 있으며, 설계 의사결정 근거와 아키텍처 다이어그램이 상세히 기술되어 있다.

✏️ 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.

@hyejin0810
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 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: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (6)
apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java-24-29 (1)

24-29: ⚠️ Potential issue | 🟡 Minor

브랜드명 정규화와 길이 검증이 없어 DB 예외로 지연 실패할 수 있다.

현재는 공백 패턴과 100자 초과 입력이 도메인에서 걸러지지 않아 운영 중 DB 예외로 늦게 실패하거나 동일 의미 데이터가 분산 저장될 수 있다. 생성자에서 trim() 정규화 후 blank/최대 길이 검증을 추가해 도메인 계층에서 조기 실패시키는 것이 안전하다. 경계값 테스트로 null, " ", 101자 문자열, " 나이키 " 입력 케이스를 추가해야 한다.

변경 제안
 public Brand(String name, String description) {
-    if (name == null || name.isBlank()) {
+    String normalizedName = (name == null) ? null : name.trim();
+    if (normalizedName == null || normalizedName.isBlank()) {
         throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다.");
     }
-    this.name = name;
+    if (normalizedName.length() > 100) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 100자 이하여야 합니다.");
+    }
+    this.name = normalizedName;
     this.description = description;
 }

As per coding guidelines **/*.java: "예외 흐름이 명확한지 점검" 및 **/domain/**/*.java: "도메인 규칙 ... 점검" 규칙을 적용한 제안이다.

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

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java` around
lines 24 - 29, The Brand constructor currently accepts names without
normalization or max-length checks which can cause DB errors or duplicate
semantic entries; in Brand(String name, String description) call name = name ==
null ? null : name.trim(), then validate: if name is null or name.isBlank() or
name.length() > 100 throw new CoreException(ErrorType.BAD_REQUEST, "...");
update any usages relying on raw input and add unit tests for Brand constructor
covering null, "   ", a 101-character string, and a trimmed input like " 나이키 "
to assert trimming and the length/blank validations.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-20-24 (1)

20-24: ⚠️ Potential issue | 🟡 Minor

toOrderItemRequests()에서 items가 null일 경우 NullPointerException이 발생한다.

클라이언트가 items 필드 없이 요청을 보내면 items가 null이 되어 items.stream() 호출 시 NPE가 발생한다. 운영 환경에서 잘못된 요청으로 인해 500 에러가 반환되어 사용자 경험이 저하될 수 있다.

수정안: 도메인 레이어에서 검증하거나, 메서드 내에서 방어적으로 처리한다.

방어적 처리 예시
 public List<OrderRequest.OrderItemRequest> toOrderItemRequests() {
+    if (items == null || items.isEmpty()) {
+        return List.of();
+    }
     return items.stream()
         .map(i -> new OrderRequest.OrderItemRequest(i.productId(), i.quantity()))
         .toList();
 }

추가 테스트: items가 null이거나 빈 리스트인 요청에 대해 적절한 에러 응답이 반환되는지 검증하는 테스트를 추가한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java`
around lines 20 - 24, toOrderItemRequests() can NPE when items is null; update
the method (OrderV1Dto::toOrderItemRequests) to defensively handle a null items
field by treating it as an empty list (e.g., check items == null and return an
empty List or stream over Collections.emptyList()) before mapping to
OrderRequest.OrderItemRequest, and add tests verifying behavior for null and
empty items inputs so the API returns a controlled response rather than throwing
a NullPointerException.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-37-40 (1)

37-40: ⚠️ Potential issue | 🟡 Minor

findAllByIds에서 빈 리스트 입력 시 문제가 발생할 수 있다.

ids 리스트가 전달될 경우 WHERE id IN () 형태의 SQL이 생성되어 일부 DB에서 오류가 발생할 수 있다. 호출부(ProductService, LikeFacade 등)에서 방어 로직이 없다면 운영 장애로 이어질 수 있다.

수정안: 빈 리스트일 경우 빈 결과를 즉시 반환한다.

제안하는 수정
 `@Override`
 public List<Product> findAllByIds(List<Long> ids) {
+    if (ids == null || ids.isEmpty()) {
+        return List.of();
+    }
     return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java`
around lines 37 - 40, ProductRepositoryImpl.findAllByIds should guard against an
empty ids list to avoid generating a SQL "IN ()" error; if ids is null or empty,
return Collections.emptyList() immediately instead of calling
productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids). Update the method to
check (ids == null || ids.isEmpty()) and return an empty list, leaving callers
like ProductService and LikeFacade unchanged.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-53-62 (1)

53-62: ⚠️ Potential issue | 🟡 Minor

OrderResponse.from()에서 info.items()가 null일 경우 NPE가 발생할 수 있다.

info.items()가 null이면 stream() 호출 시 NPE가 발생한다. OrderInfo 생성 시점에 items가 항상 non-null임을 보장하거나 방어 코드를 추가해야 한다.

방어적 처리 예시
 public static OrderResponse from(OrderInfo info) {
+    List<OrderItemResponse> itemResponses = info.items() != null
+        ? info.items().stream().map(OrderItemResponse::from).toList()
+        : List.of();
     return new OrderResponse(
         info.id(),
         info.userId(),
         info.orderNumber(),
         info.status(),
         info.totalAmount(),
-        info.items().stream().map(OrderItemResponse::from).toList()
+        itemResponses
     );
 }
🤖 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 53 - 62, OrderResponse.from currently calls info.items().stream()
which can NPE if items is null; update OrderResponse.from to defensively handle
null items by treating a null items collection as empty before streaming (e.g.,
check info.items() == null or use Optional/Collections.emptyList()), then map
elements with OrderItemResponse.from and collect toList; keep the rest of the
constructor arguments unchanged so OrderResponse.from and OrderItemResponse.from
are used consistently.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java-28-41 (1)

28-41: ⚠️ Potential issue | 🟡 Minor

getLikedProducts에서 빈 productIds 리스트 처리가 누락되어 있다.

productIds가 빈 리스트일 경우 productService.getProductsByIds(productIds) 호출 시 빈 IN 절(WHERE id IN ())이 생성되어 DB 벤더에 따라 SQL 오류가 발생할 수 있다. 운영 환경에서 좋아요가 없는 사용자 조회 시 장애로 이어질 수 있다.

수정안: 빈 리스트일 경우 조기 반환하여 불필요한 DB 호출을 방지한다.

제안하는 수정
 `@Transactional`(readOnly = true)
 public List<ProductInfo> getLikedProducts(String loginId, String rawPassword) {
     User user = userService.authenticate(loginId, rawPassword);
     List<Long> productIds = likeService.getLikedProductIds(user.getId());
+    if (productIds.isEmpty()) {
+        return List.of();
+    }
     // 상품 목록을 IN 쿼리로 한 번에 조회 (N+1 방지)
     List<Product> products = productService.getProductsByIds(productIds);

추가 테스트: 좋아요가 없는 사용자에 대해 getLikedProducts 호출 시 빈 리스트가 반환되는지 검증하는 테스트를 추가한다.

🤖 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/like/LikeFacade.java`
around lines 28 - 41, getLikedProducts currently calls
productService.getProductsByIds(productIds) even when productIds is empty, which
can produce an invalid IN () SQL; modify getLikedProducts to check if
productIds.isEmpty() after obtaining them and return Collections.emptyList() (or
List.of()) immediately to avoid the DB call, and ensure downstream mapping
(brandIds/brandMap) is skipped; also add a unit/integration test asserting that
a user with no likes causes getLikedProducts(loginId, rawPassword) to return an
empty list.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java-27-34 (1)

27-34: ⚠️ Potential issue | 🟡 Minor

가격 검증 테스트가 음수 경계값을 실제로 검증하지 않는다

Line 27의 시나리오는 "0 이하"인데 현재 입력이 0 하나뿐이라 음수 가격 검증이 깨져도 테스트가 통과한다. @ParameterizedTest0과 음수를 함께 검증하도록 수정해야 회귀를 조기에 막을 수 있다. 추가로 동일 경계값을 Product.update 경로에도 테스트해 생성/수정 규칙 일관성을 검증해야 한다.

수정 예시
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
...
-        `@Test`
-        void throwsBadRequest_whenPriceIsZeroOrNegative() {
+        `@ParameterizedTest`
+        `@ValueSource`(ints = {0, -1})
+        void throwsBadRequest_whenPriceIsZeroOrNegative(int invalidPrice) {
             CoreException exception = assertThrows(CoreException.class,
-                () -> new Product(1L, "나이키 신발", 0, 100, "설명", "url"));
+                () -> new Product(1L, "나이키 신발", invalidPrice, 100, "설명", "url"));
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/ProductTest.java`
around lines 27 - 34, The current test
throwsBadRequest_whenPriceIsZeroOrNegative only checks 0 and misses negative
boundaries; change it to a `@ParameterizedTest` in ProductTest to run both 0 and
negative values (e.g., -1) against new Product(...) and assert CoreException
with ErrorType.BAD_REQUEST, and add a matching parameterized test for
Product.update(...) to ensure the same validation on updates; reference the
Product constructor and Product.update, and replace the single `@Test` method
throwsBadRequest_whenPriceIsZeroOrNegative with parameterized variants to cover
both creation and update paths.
🧹 Nitpick comments (24)
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java (4)

18-30: OrderItem 입력 검증 테스트 누락

OrderItem 생성 테스트가 유효한 입력에 대해서만 검증하고 있다. 다음 예외 케이스에 대한 테스트가 필요하다:

  1. productName이 null 또는 빈 문자열인 경우
  2. price가 0 이하인 경우
  3. quantity가 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/OrderTest.java`
around lines 18 - 30, Add unit tests for invalid OrderItem inputs: create new
test methods alongside savesSnapshot_whenInputIsValid that verify OrderItem
throws appropriate exceptions when productName is null or empty, when price <=
0, and when quantity <= 0; reference the OrderItem constructor in your tests and
use assertions (e.g., assertThrows) to assert the expected exception
type/message for each case so invalid snapshot data cannot be created.

74-84: 상태 전이 테스트 시나리오 확장 필요

현재 approve 테스트는 이미 CONFIRMED 상태인 주문을 다시 승인하는 경우만 검증한다. CANCELLED 상태의 주문을 승인하려는 경우도 테스트해야 한다. 운영 환경에서 취소된 주문이 실수로 승인 처리되는 버그를 사전에 방지할 수 있다.

♻️ CANCELLED 상태에서 승인 시도 테스트
`@DisplayName`("CANCELLED 상태의 주문을 승인하면, BAD_REQUEST 예외가 발생한다.")
`@Test`
void throwsBadRequest_whenOrderIsCancelled() {
    Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L);
    order.cancel();

    CoreException exception = assertThrows(CoreException.class, order::approve);
    assertThat(exception.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 74 - 84, Add a new test in OrderTest that verifies approving a
CANCELLED order throws BAD_REQUEST: create an Order instance, call
order.cancel(), then assertThrows(CoreException.class, order::approve) and
assert that exception.getErrorType() equals ErrorType.BAD_REQUEST; name the test
method throwsBadRequest_whenOrderIsCancelled and include a DisplayName
describing the scenario to mirror the existing pending-state test structure.

104-114: 상태 전이 테스트 시나리오 확장 필요

cancel 테스트도 마찬가지로 CONFIRMED 상태의 주문을 취소하려는 경우를 테스트해야 한다. 이미 확정된 주문의 취소 시도에 대한 예외 처리가 올바르게 동작하는지 검증이 필요하다.

♻️ CONFIRMED 상태에서 취소 시도 테스트
`@DisplayName`("CONFIRMED 상태의 주문을 취소하면, BAD_REQUEST 예외가 발생한다.")
`@Test`
void throwsBadRequest_whenOrderIsConfirmed() {
    Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L);
    order.approve();

    CoreException exception = assertThrows(CoreException.class, order::cancel);
    assertThat(exception.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 104 - 114, Add a new unit test in OrderTest that mirrors the
existing non-PENDING cancellation test but sets the order to CONFIRMED before
calling cancel: create an Order instance, call order.approve(), then assert that
calling order.cancel() throws a CoreException with ErrorType.BAD_REQUEST;
reference the existing test structure and use the same assertThrows/assertThat
pattern as in the throwsBadRequest_whenOrderIsNotPending test to keep
consistency.

47-54: 경계값 테스트 보강 필요: 음수 totalAmount

현재 totalAmount가 0인 경우만 테스트하고 있다. 음수 값에 대한 테스트도 추가하여 경계값 검증을 완전하게 해야 한다.

♻️ 음수 totalAmount 테스트 추가 예시
`@DisplayName`("totalAmount가 음수이면, BAD_REQUEST 예외가 발생한다.")
`@Test`
void throwsBadRequest_whenTotalAmountIsNegative() {
    CoreException exception = assertThrows(CoreException.class,
        () -> new Order(1L, "ORD-20240101-ABCD1234", -1000L));
    assertThat(exception.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 47 - 54, Add a negative-value boundary test mirroring the existing
zero-value test: create a new test method (e.g.,
throwsBadRequest_whenTotalAmountIsNegative) in OrderTest that constructs an
Order with a negative totalAmount (e.g., -1000L) and asserts it throws
CoreException with ErrorType.BAD_REQUEST; reference the same constructor used in
the current test (new Order(1L, "ORD-20240101-ABCD1234", -1000L)) and use
assertThrows/assertThat like the existing
throwsBadRequest_whenTotalAmountIsNotPositive test.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)

3-4: 도메인 레이어의 Spring Data 의존성

PagePageableorg.springframework.data.domain 패키지에 속하며, 도메인 레이어가 인프라스트럭처 프레임워크에 의존하게 된다. PR 설명에서 JPA 엔티티를 도메인 레이어에 두는 결정을 내렸으므로 현재 설계와 일관성이 있으나, 향후 프레임워크 교체 시 도메인 레이어 변경이 필요하다는 점을 인지해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java`
around lines 3 - 4, ProductRepository currently imports Spring Data types Page
and Pageable, coupling the domain layer to framework APIs; to fix this, remove
org.springframework.data.domain.Page/Pageable from the domain by introducing a
domain-agnostic pagination contract (e.g., PaginationRequest and
PaginationResult or PageDto) and update ProductRepository to use those types, or
alternatively move ProductRepository out of the domain layer into the
infrastructure layer and keep a domain-facing repository interface that uses
pure domain types; reference the ProductRepository interface and replace usages
of Page and Pageable throughout the codebase with the new pagination
abstractions or adapters.
apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java (1)

13-13: 대량 데이터 조회 시 메모리 이슈 가능성

findAllByDeletedAtIsNull() 메서드는 페이지네이션 없이 모든 브랜드를 조회한다. 브랜드 수가 증가하면 메모리 부족 및 응답 지연이 발생할 수 있다.

관리자 화면 등 전체 목록이 필요한 경우에도 페이지네이션을 적용하거나, 조회 용도에 따라 findAllByDeletedAtIsNull(Pageable pageable) 메서드를 추가하는 것을 권장한다.

🤖 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/brand/BrandJpaRepository.java`
at line 13, The repository method findAllByDeletedAtIsNull() in
BrandJpaRepository returns all rows without pagination which can cause OOM for
large datasets; replace or overload it with a paginated variant by adding
findAllByDeletedAtIsNull(Pageable pageable) and update call sites (e.g., admin
listing endpoints or service methods) to accept a Pageable and request paged
results, or limit batch sizes for bulk processing; ensure service/controller
code uses the new Pageable-based method and adjust tests accordingly.
apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java (1)

14-44: 경계값 및 추가 예외 케이스 테스트 누락

현재 테스트는 null, 공백(" "), 유효한 이름에 대한 케이스만 검증한다. 테스트 가이드라인에 따라 다음 경계값/예외 케이스를 추가하는 것이 좋다:

  1. 빈 문자열 "" - 공백(" ")과 다른 케이스로 별도 검증 필요
  2. description이 null인 경우 - 허용 여부에 따른 동작 검증
  3. 이름 길이 경계값 - 최대 길이 초과 시 동작 검증 (DB 컬럼 제약과 일치 여부)
  4. 특수문자/유니코드 - 이름에 특수문자 포함 시 정상 처리 여부

운영 관점에서 이러한 경계값 테스트가 누락되면, 프로덕션에서 예상치 못한 입력에 대해 장애가 발생할 수 있다.

🤖 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/brand/BrandTest.java`
around lines 14 - 44, Add missing boundary and edge-case tests to BrandTest: add
a test method throwsBadRequest_whenNameIsEmpty that constructs new Brand("") and
asserts BAD_REQUEST; add createsBrand_whenDescriptionIsNull (or throws depending
on domain rule) to construct new Brand("Valid", null) and assert expected
behavior/error; add name length boundary tests (createsBrand_whenNameAtMaxLength
and throwsBadRequest_whenNameExceedsMaxLength) that build names at the
DB/validation max length and one longer and assert success or BAD_REQUEST
accordingly; and add createsBrand_withSpecialCharactersAndUnicode to construct
new Brand with special chars/unicode (e.g., "ブランド-№✓") and assert the Brand is
created with correct name/description. Reference the Brand constructor and
BrandTest/Create nested class when adding these test methods.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java (1)

5-11: 불변성 보장을 위한 방어적 복사 검토 필요

items 필드가 List<OrderItemRequest>로 선언되어 외부에서 전달받은 리스트를 그대로 참조한다. 이로 인해 다음 문제가 발생할 수 있다:

  1. 외부 변경 가능성 - 호출자가 전달 후 리스트를 수정하면 DTO 내부 상태가 변경됨
  2. null 리스트/요소 - items가 null이거나 null 요소 포함 시 후속 처리에서 NPE 발생

운영 환경에서 예측 불가능한 상태 변경으로 인한 장애를 방지하려면 방어적 복사를 권장한다.

♻️ 방어적 복사 적용 예시
 public record OrderRequest(
     List<OrderItemRequest> items
 ) {
+    public OrderRequest {
+        items = items == null ? List.of() : List.copyOf(items);
+    }
+
     public record OrderItemRequest(
         Long productId,
         Integer quantity
     ) {}
 }
🤖 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/OrderRequest.java`
around lines 5 - 11, OrderRequest currently stores the incoming
List<OrderItemRequest> reference directly (items), allowing external mutation
and nulls; add a compact canonical constructor for OrderRequest that performs
defensive copying and null checks: require items non-null (or replace null with
empty list if you prefer), validate no null elements (Objects.requireNonNull on
each OrderItemRequest), and assign items = List.copyOf(items) (or
Collections.unmodifiableList(new ArrayList<>(items))) so the record holds an
immutable copy; reference the OrderRequest record and its nested
OrderItemRequest type when making these changes.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java (1)

12-12: findById 메서드 선언이 중복된다.

JpaRepository<Order, Long>는 이미 Optional<Order> findById(Long id)를 제공한다. 명시적 재선언은 불필요하며, 코드 유지보수 시 혼란을 야기할 수 있다. 제거를 권장한다.

수정안
 public interface OrderJpaRepository extends JpaRepository<Order, Long> {

-    Optional<Order> findById(Long id);
-
     Page<Order> findByUserId(Long userId, Pageable pageable);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java`
at line 12, Remove the redundant explicit declaration of Optional<Order>
findById(Long id) from the OrderJpaRepository interface; JpaRepository<Order,
Long> already provides findById, so delete the findById method declaration in
OrderJpaRepository to avoid duplication and potential maintenance confusion.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java (3)

115-132: cancelOrder의 예외 케이스 테스트가 누락되어 있다.

PENDING 상태가 아닌 주문(예: 이미 취소된 주문, 승인된 주문)을 취소할 때 예외가 발생하는지 서비스 레벨에서 검증이 필요하다. OrderTest에서 도메인 로직 테스트가 있더라도, 서비스 레이어에서 예외가 올바르게 전파되는지 확인해야 운영 시 예기치 않은 동작을 방지할 수 있다.

추가 테스트 예시
`@DisplayName`("이미 취소된 주문을 취소하면, BAD_REQUEST 예외가 발생한다.")
`@Test`
void throwsBadRequest_whenOrderIsAlreadyCancelled() {
    // Arrange
    Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L);
    order.cancel(); // 먼저 취소

    // Act & Assert
    CoreException exception = assertThrows(CoreException.class,
        () -> orderService.cancelOrder(order));
    assertThat(exception.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/OrderServiceTest.java`
around lines 115 - 132, Add a service-layer negative test in OrderServiceTest to
assert cancelOrder throws the proper exception when order status is not PENDING:
create an Order, set its status to a non-PENDING state (e.g., call
order.cancel() or set status to CANCELLED/APPROVED), then call
orderService.cancelOrder(order) and assert a CoreException is thrown and its
getErrorType() equals ErrorType.BAD_REQUEST; reference the existing cancelOrder
method and Order class to locate the test and mirror the positive test structure
for arrangement, action, and assertion.

40-58: createOrder의 유효성 검증 실패 테스트가 누락되어 있다.

OrderTest에서 totalAmount <= 0일 때 BAD_REQUEST 예외가 발생하는 것을 테스트하고 있다. 서비스 레이어에서도 이 예외가 정상적으로 전파되는지 확인하는 테스트가 필요하다.

추가 테스트 예시
`@DisplayName`("totalAmount가 0 이하이면, BAD_REQUEST 예외가 발생한다.")
`@Test`
void throwsBadRequest_whenTotalAmountIsNotPositive() {
    // Act & Assert
    CoreException exception = assertThrows(CoreException.class,
        () -> orderService.createOrder(1L, "ORD-20240101-ABCD1234", 0L));
    assertThat(exception.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/OrderServiceTest.java`
around lines 40 - 58, Add a negative test in OrderServiceTest to verify
service-level validation: in the CreateOrder nested class add a test (e.g.,
throwsBadRequest_whenTotalAmountIsNotPositive) that calls
orderService.createOrder(1L, "ORD-20240101-ABCD1234", 0L) and asserts a
CoreException is thrown and its getErrorType() equals ErrorType.BAD_REQUEST; use
assertThrows to capture the exception and assert the error type to ensure
validation in createOrder is propagated from the service layer.

134-152: approveOrder의 NOT_FOUND 및 상태 예외 테스트가 누락되어 있다.

다음 시나리오에 대한 테스트가 필요하다:

  1. 존재하지 않는 주문 ID로 승인 시 NOT_FOUND 예외 발생
  2. 이미 승인된 주문을 다시 승인 시 BAD_REQUEST 예외 발생
  3. 취소된 주문을 승인 시 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/OrderServiceTest.java`
around lines 134 - 152, Add three negative tests inside the ApproveOrder nested
test class for orderService.approveOrder: (1) when orderRepository.findById(...)
returns Optional.empty() assert that approveOrder throws the service's NOT_FOUND
exception; (2) when findById returns an Order with status OrderStatus.CONFIRMED
assert that approveOrder throws the service's BAD_REQUEST exception; (3) when
findById returns an Order with status OrderStatus.CANCELLED assert that
approveOrder throws the service's BAD_REQUEST exception. Use
given(orderRepository.findById(…)).willReturn(Optional.empty()) or
Optional.of(orderWithStatus) to set up each scenario and use assertThrows
against the specific exception type your service raises for NOT_FOUND and
BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java (1)

24-37: signUp과 getMyInfo의 위임 패턴이 일관되지 않다.

getMyInfoUserFacade를 통해 호출하지만, signUpUserService를 직접 호출하고 Controller에서 UserInfo.from(user) 변환을 수행한다. 이 불일치는 다음 문제를 야기할 수 있다:

  1. 향후 signUp에 크로스 도메인 로직(예: 가입 시 포인트 지급)이 추가될 때 Controller 수정 필요
  2. 변환 로직 위치가 레이어별로 혼재되어 유지보수 복잡도 증가

UserFacade.signUp을 추가하여 패턴을 통일하거나, 현재 구조를 유지할 경우 주석으로 의도를 명시하는 것을 권장한다.

🤖 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/user/UserV1Controller.java`
around lines 24 - 37, The signUp controller currently calls UserService.signUp
and performs UserInfo.from(user) itself, breaking the delegation pattern used by
getMyInfo via UserFacade; add a UserFacade.signUp(loginId, password, name,
birthday, email) that handles service calls and returns a UserInfo (or
UserV1Dto.UserResponse) and then update UserV1Controller.signUp to delegate to
UserFacade.signUp and return the facade result (or convert the facade's UserInfo
to UserResponse), keeping transformation logic out of the controller and
matching getMyInfo's pattern.
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java (1)

5-9: null 입력에 대한 방어 로직 고려가 필요하다.

from(Brand brand) 메서드에서 brand가 null일 경우 NPE가 발생한다. 호출부에서 null이 전달되지 않음이 보장된다면 현재 구현도 허용 가능하나, 방어적 프로그래밍 관점에서 명시적 검증을 추가하면 디버깅이 용이해진다.

제안 (선택적)
 public static BrandInfo from(Brand brand) {
+    if (brand == null) {
+        throw new IllegalArgumentException("Brand must not be null");
+    }
     return new BrandInfo(brand.getId(), brand.getName(), brand.getDescription());
 }
🤖 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/brand/BrandInfo.java`
around lines 5 - 9, The static factory BrandInfo.from(Brand brand) lacks a null
check and will NPE if called with null; add an explicit defensive validation at
the start of BrandInfo.from (e.g., use Objects.requireNonNull(brand, "brand must
not be null") or throw an IllegalArgumentException) before accessing
brand.getId()/getName()/getDescription(), so the method fails fast with a clear
message when a null Brand is passed.
apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java (1)

44-96: 잔액 연산의 경계값 테스트가 누락되어 있다.

현재 테스트는 정상 케이스와 잔액 부족 예외만 검증한다. 운영 환경에서 예기치 않은 입력으로 인한 장애를 방지하려면 다음 경계값 테스트가 필요하다:

  • deductBalance(0L): 0 차감 시 동작
  • deductBalance(-1L): 음수 차감 시 예외 발생 여부
  • restoreBalance(0L): 0 복구 시 동작
  • restoreBalance(-1L): 음수 복구 시 예외 발생 여부
  • 오버플로우: Long.MAX_VALUE 잔액에서 추가 복구 시 동작

도메인 레이어에서 입력값 검증이 누락되면 상위 레이어에서 잘못된 데이터가 유입될 수 있다.

🤖 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/user/UserTest.java` around
lines 44 - 96, Add boundary tests for User.deductBalance and
User.restoreBalance: assert that deductBalance(0L) leaves getBalance unchanged
and restoreBalance(0L) leaves getBalance unchanged; assert that negative inputs
(deductBalance(-1L) and restoreBalance(-1L)) throw CoreException with
ErrorType.BAD_REQUEST; and add a test that calling restoreBalance on a User with
balance Long.MAX_VALUE (or restoring an amount that would overflow) either
throws CoreException (ErrorType.BAD_REQUEST or overflow-specific) or is safely
handled—implement the assertion that matches the domain's intended behavior.
Locate tests in the existing nested classes (DeductBalance, RestoreBalance) and
use the User, deductBalance, restoreBalance, getBalance, CoreException, and
ErrorType symbols to add these cases.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java (1)

16-24: items를 방어적 복사 없이 보관하면 응답 스냅샷이 외부에서 변조될 수 있다.

운영 환경에서 from 호출 후 원본 리스트가 수정되면 API 응답과 로그/캐시 데이터가 비결정적으로 바뀌어 장애 추적이 어려워진다. from에서 List.copyOf(...)로 불변 복사를 적용하고, null은 빈 리스트로 정규화하는 것이 안전하다. 원본 리스트를 사후 변경해도 OrderInfo.items()가 변하지 않는지, items().add(...)가 예외를 던지는지 테스트를 추가해야 한다.

변경 제안
 public record OrderInfo(
     Long id,
     Long userId,
     String orderNumber,
     OrderStatus status,
     Long totalAmount,
     List<OrderItemInfo> items
 ) {
     public static OrderInfo from(Order order, List<OrderItemInfo> items) {
+        List<OrderItemInfo> safeItems = (items == null) ? List.of() : List.copyOf(items);
         return new OrderInfo(
             order.getId(),
             order.getUserId(),
             order.getOrderNumber(),
             order.getStatus(),
             order.getTotalAmount(),
-            items
+            safeItems
         );
     }
 }

As per coding guidelines **/*.java: "null 처리, 방어적 복사, 불변성 ... 점검" 규칙을 적용한 제안이다.

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

In `@apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java`
around lines 16 - 24, The from factory method in OrderInfo currently stores the
provided items list directly which permits external mutation; update
OrderInfo.from to normalize null to an empty list and store an immutable
defensive copy (e.g., List.copyOf(items)) so OrderInfo.items() cannot be
modified afterward, and add a unit test that calls OrderInfo.from with a mutable
list (and with null) then verifies OrderInfo.items() content remains stable and
that attempts to mutate OrderInfo.items() throw an exception.
apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java (1)

127-138: 삭제 성공 테스트에 영속화 호출 검증이 없어 회귀 탐지력이 약하다.

현재 검증은 메모리 객체 상태(deletedAt)만 확인하므로 서비스에서 저장 호출이 누락되어도 테스트가 통과할 수 있다. 단위 테스트에서는 brandRepository.save(brand) 호출 여부까지 검증해 soft delete 영속화 경로를 고정하는 것이 안전하다. 추가로 저장 호출이 1회인지까지 검증하는 테스트를 포함해야 한다.

변경 제안
 import static org.mockito.BDDMockito.given;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
@@
         void softDeletesBrand_whenIdExists() {
@@
             // Assert
             assertThat(brand.getDeletedAt()).isNotNull();
+            verify(brandRepository).save(brand);
         }

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/brand/BrandServiceTest.java`
around lines 127 - 138, Update the test softDeletesBrand_whenIdExists to also
verify that the repository persistence path is invoked: after calling
brandService.deleteBrand(1L) assert the brand.getDeletedAt() is not null and add
a Mockito verify(brandRepository).save(brand) (or verify(brandRepository,
times(1)).save(brand)) to ensure brandRepository.save(...) is called exactly
once; reference the existing mock brandRepository and the
brandService.deleteBrand(...) invocation when adding this assertion.
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java (1)

13-13: 좋아요 ID 목록 조회에서 엔티티 전체 로딩은 대량 사용자 시 불필요한 I/O를 만든다.

운영 영향: Line 13 경로는 Like 엔티티를 전부 적재하므로, 좋아요 수가 큰 사용자에서 메모리/영속성 컨텍스트 부하가 커져 응답 지연 위험이 증가한다.
수정안: productId만 필요할 때는 ID projection 쿼리를 별도 메서드로 제공하는 것이 낫다.
추가 테스트: DataJpaTest로 findProductIdsByUserId가 동일한 결과를 반환하고, 빈 결과를 정상 반환하는 케이스를 추가하는 것이 좋다.

수정 예시 diff
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
@@
     List<Like> findByUserId(Long userId);
+
+    `@Query`("select l.productId from Like l where l.userId = :userId")
+    List<Long> findProductIdsByUserId(`@Param`("userId") Long userId);
 }

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/like/LikeJpaRepository.java`
at line 13, The current repository method findByUserId(Long userId) loads full
Like entities causing heavy I/O for users with many likes; replace/add a
projection method that returns only product IDs (e.g., add List<Long>
findProductIdsByUserId(Long userId) in LikeJpaRepository) and update callers to
use the new method when only productId is needed; also add a DataJpaTest that
asserts findProductIdsByUserId returns the same productId set as the original
findByUserId mapping and correctly returns an empty list for users with no
likes.
apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java (1)

34-36: 빈 ID 컬렉션에 대한 가드가 없어 불필요한 DB 호출이 발생한다.

운영 영향: 호출량이 높을 때 빈 목록 조회도 매번 SQL을 태우면 DB 부하가 누적되고, 일부 환경에서는 IN () 처리 방식 차이로 예외 리스크가 생길 수 있다.
수정안: Line 34 진입 시 ids가 null/empty이면 즉시 List.of()를 반환해 조기 종료하는 것이 좋다.
추가 테스트: 빈 ids 입력 시 빈 리스트 반환 및 JPA repository 호출이 발생하지 않음을 검증하는 테스트를 추가하는 것이 좋다.

수정 예시 diff
     `@Override`
     public List<Brand> findAllByIds(Collection<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return List.of();
+        }
         return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids);
     }

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/brand/BrandRepositoryImpl.java`
around lines 34 - 36, The method findAllByIds currently always calls
brandJpaRepository.findAllByIdInAndDeletedAtIsNull which will execute a DB query
for empty or null id collections; add a guard at the start of findAllByIds to
return List.of() immediately when ids is null or ids.isEmpty() to avoid the
unnecessary DB call, and update/add a unit test that calls findAllByIds with
null and empty collections asserting an empty list is returned and that
brandJpaRepository.findAllByIdInAndDeletedAtIsNull is not invoked.
apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java (1)

120-129: 삭제 성공 테스트가 부수효과를 검증하지 않아 회귀를 놓칠 수 있다.

운영 영향: 서비스 코드가 변경되어 실제 delete 호출이 빠져도 현재 테스트는 통과하므로, 운영 데이터에 좋아요 잔존이 발생해도 사전에 탐지하지 못할 수 있다.
수정안: Line 128 이후 likeRepository.delete(like) 호출 여부를 verify로 명시 검증하는 편이 안전하다.
추가 테스트: NOT_FOUND 케이스에서 delete가 호출되지 않음을 never()로 함께 검증하면 예외 경로 회귀도 막을 수 있다.

수정 예시 diff
         // Act & Assert (no exception thrown)
         likeService.removeLike(1L, 2L);
+        verify(likeRepository).delete(like);

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/like/LikeServiceTest.java`
around lines 120 - 129, The test deletesLike_whenLikeExists in LikeServiceTest
only asserts no exception; update it to explicitly verify that
likeRepository.delete(like) is invoked by calling a Mockito verify after
likeService.removeLike(1L, 2L) (use the same Like instance returned by
given(likeRepository.findByUserIdAndProductId(...))). Also update the
NOT_FOUND/exception path test to verify that likeRepository.delete(...) is
never() called to prevent regression of skipping deletions; reference the
methods likeService.removeLike and likeRepository.delete/
findByUserIdAndProductId in your assertions.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java (1)

120-134: 목록 조회 테스트가 개수만 확인해 데이터 정합성 회귀를 탐지하기 어렵다.

운영 영향: 크기만 맞고 내용이 틀린 응답(잘못된 상품 매핑, 순서 뒤바뀜)이 발생해도 테스트가 통과할 수 있어 API 품질 저하를 사전에 차단하지 못한다.
수정안: 이름/주요 필드 기준으로 containsExactly 검증을 추가해 결과 정합성을 명시하는 것이 좋다.
추가 테스트: ID 일부 미존재/중복 입력 시 반환 목록이 기대 정책(중복 허용 여부, 순서 보장 여부)을 따르는지 케이스를 분리해 검증하는 것이 좋다.

수정 예시 diff
-            assertThat(result).hasSize(2);
+            assertThat(result)
+                .extracting(Product::getName)
+                .containsExactly("나이키 신발", "아디다스 티셔츠");

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/ProductServiceTest.java`
around lines 120 - 134, The test returnsProducts_whenIdsExist only asserts size;
update it to verify result content and order by asserting key fields (e.g.,
product names or IDs) using AssertJ's containsExactly on the list returned by
productService.getProductsByIds(ids), ensuring the expected Product instances
(or DTOs) match those from the given(productRepository.findAllByIds(ids)) stub;
also add separate tests for partial-missing IDs and duplicate-ID inputs to
validate behavior (duplicate allowance and ordering) per service policy.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java (1)

57-61: 브랜드 삭제 연쇄 처리에서 엔티티별 save 반복은 대량 데이터에서 병목이 된다

현재 구현은 상품 수만큼 update를 반복해 트랜잭션 시간이 길어지고 운영 중 락 경합이 커질 수 있다. 저장소에 브랜드 기준 일괄 soft delete 메서드를 추가해 단건 배치 처리로 전환하는 편이 안전하다. 추가로 서비스 단위 테스트에서 저장소 호출이 1회로 축약되는지 검증하고, 통합 테스트에서 대량 상품 삭제 시 응답 시간과 타임아웃 리스크를 확인해야 한다.

리팩터링 방향 예시
 `@Transactional`
 public void deleteProductsByBrandId(Long brandId) {
-    productRepository.findAllByBrandId(brandId).forEach(product -> {
-        product.delete();
-        productRepository.save(product);
-    });
+    productRepository.softDeleteByBrandId(brandId);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java`
around lines 57 - 61, 현재 deleteProductsByBrandId 구현은
productRepository.findAllByBrandId(...).forEach(product -> { product.delete();
productRepository.save(product); })처럼 개별 엔티티를 반복 저장해 대량 처리에서 병목을 유발하니,
ProductRepository에 브랜드 기준 일괄 soft delete 메서드(예: softDeleteByBrandId or
markDeletedByBrandId(Long brandId))를 추가하고 deleteProductsByBrandId는 해당 일괄 메서드만
호출하도록 교체하세요; 기존 개별 호출(product.delete(), productRepository.save(product))을 제거하고
트랜잭션/쿼리로 한 번에 업데이트되도록 구현한 뒤, 서비스 단위 테스트에서 productRepository의 일괄 호출이 1회만 발생하는지(모의
객체로 verify) 검증하고, 통합 테스트로 대량 상품 삭제 시 응답 시간과 타임아웃 리스크를 확인하세요.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java (1)

98-156: 인증 헤더 누락 시나리오에 대한 테스트가 없다.

X-Loopers-LoginId 또는 X-Loopers-LoginPw 헤더가 누락된 경우의 동작을 검증하는 테스트가 없다. 운영 환경에서 클라이언트 오류로 인해 헤더가 누락될 수 있으며, 이때 적절한 에러 응답(400 또는 401)이 반환되는지 확인해야 한다.

추가 테스트 제안:

`@DisplayName`("인증 헤더가 누락되면, 400을 반환한다.")
`@Test`
void returns400_whenAuthHeadersMissing() throws Exception {
    mockMvc.perform(get("/api/v1/users/me"))
        .andExpect(status().isBadRequest());
}
🤖 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/user/UserV1ControllerStandaloneTest.java`
around lines 98 - 156, Add a test for the missing auth header scenario in the
UserV1ControllerStandaloneTest::GetMyInfo nested class: create a new test method
(e.g., returns400_whenAuthHeadersMissing) annotated with `@DisplayName` and `@Test`
that calls mockMvc.perform(get("/api/v1/users/me")) without setting
X-Loopers-LoginId or X-Loopers-LoginPw and asserts
.andExpect(status().isBadRequest()); no stubbing of userFacade is required for
this case.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java (1)

60-64: generateOrderNumber의 테스트 가능성과 충돌 위험에 대한 개선이 필요하다.

  1. 테스트 가능성: LocalDate.now()를 직접 호출하여 날짜 기반 테스트가 어렵다. Clock을 주입받아 시간을 제어할 수 있도록 개선하면 테스트가 용이해진다.

  2. UUID 충돌 위험: 8자리 UUID 부분 문자열(substring(0, 8))은 약 40억 가지 조합(16^8)을 제공하지만, 동일 날짜에 대량 주문 발생 시 Birthday Paradox로 인해 예상보다 빠르게 충돌이 발생할 수 있다. 일 기준 약 65,000건 이상에서 50% 확률로 충돌이 발생한다.

수정안:

  • UUID 길이를 12자리 이상으로 확장하거나, 타임스탬프와 시퀀스를 조합하는 방식을 고려한다.
  • 또는 DB 유니크 제약과 재시도 로직을 추가한다.
Clock 주입을 통한 테스트 가능성 개선
+import java.time.Clock;
+
 `@RequiredArgsConstructor`
 `@Component`
 public class OrderService {

     private final OrderRepository orderRepository;
     private final OrderItemRepository orderItemRepository;
+    private final Clock clock;
     
-    public String generateOrderNumber() {
-        String date = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
+    public String generateOrderNumber() {
+        String date = java.time.LocalDate.now(clock).format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
         String uuid = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
         return "ORD-" + date + "-" + uuid;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java`
around lines 60 - 64, The generateOrderNumber method is hard to test and has
collision risk; change it to accept/inject a java.time.Clock (use Clock system
default in production) so you can control LocalDate.now(clock) in tests, and
strengthen the unique portion by increasing the UUID substring to at least 12
characters (or combine a high-resolution timestamp + per-request sequence) to
reduce birthday-paradox collisions; additionally ensure persistence enforces a
unique constraint on order number and add a retry-on-duplicate save loop (or
throw a clear exception) when save fails due to uniqueness to guarantee
correctness.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bfd730c and 9e81b2c.

⛔ Files ignored due to path filters (5)
  • Round3.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-diagram.md is excluded by !**/*.md and included by **
  • docs/design/04-erd.md is excluded by !**/*.md and included by **
📒 Files selected for processing (63)
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.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/order/OrderItemInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/Like.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/OrderItem.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.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/infrastructure/order/OrderItemJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.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/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java

import java.util.Optional;

public interface BrandJpaRepository extends JpaRepository<Brand, Long> {
Optional<Brand> findByNameAndDeletedAtIsNull(String name);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# BrandJpaRepository 파일 위치 확인 및 내용 조회
find . -name "BrandJpaRepository.java" -type f 2>/dev/null

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

Length of output: 175


🏁 Script executed:

# Brand 엔티티 파일 검색
find . -name "Brand.java" -type f 2>/dev/null | grep -E "entity|domain|model"

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

Length of output: 154


🏁 Script executed:

# 마이그레이션 파일 검색 (brand 관련)
find . -name "*.sql" -o -name "*.xml" | xargs grep -l -i "brand" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

# Repository 관련 파일 구조 확인
git ls-files | grep -i "brand" | head -30

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

Length of output: 960


🏁 Script executed:

# Brand 엔티티 파일 내용 조회
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java

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

Length of output: 1160


🏁 Script executed:

# BrandJpaRepository 파일 내용 조회
cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java

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

Length of output: 724


🏁 Script executed:

# 마이그레이션 디렉토리 구조 확인
find . -type d -name migration -o -type d -name migrations -o -type d -name db 2>/dev/null | head -10

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

Length of output: 67


🏁 Script executed:

# SQL 파일 전체 검색
find . -name "*.sql" -type f 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

# BaseEntity 파일 검색 및 내용 조회
find . -name "BaseEntity.java" -type f 2>/dev/null

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

Length of output: 147


🏁 Script executed:

# BrandService 내용 조회
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java

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

Length of output: 1942


🏁 Script executed:

# BrandRepositoryImpl 내용 조회
cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java

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

Length of output: 1490


🏁 Script executed:

# BaseEntity 내용 조회
cat -n ./modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java

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

Length of output: 2291


🏁 Script executed:

# BrandFacade 내용 조회하여 비즈니스 로직 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java

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

Length of output: 1571


🏁 Script executed:

# BrandRepository 인터페이스 확인
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java

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

Length of output: 519


🏁 Script executed:

# 데이터베이스 스키마/마이그레이션 파일 검색 (다양한 위치 확인)
find . -type f \( -name "*.sql" -o -name "*.xml" \) 2>/dev/null | grep -i -E "migration|flyway|liquibase|schema" | head -20

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

Length of output: 67


🏁 Script executed:

# 프로젝트 구조 확인 (리소스 디렉토리 포함)
find ./apps/commerce-api -type d -name resources 2>/dev/null | head -5

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

Length of output: 123


🏁 Script executed:

# resources 하위 db 관련 파일 검색
find ./apps/commerce-api/src -type f -name "*.sql" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# 스프링 설정 파일에서 JPA/Hibernate DDL 설정 확인
find ./apps/commerce-api/src -name "application*.properties" -o -name "application*.yml" | xargs cat -n 2>/dev/null

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

Length of output: 1450


🏁 Script executed:

# 전체 resources 디렉토리 구조 확인
find ./apps/commerce-api/src/main/resources -type f 2>/dev/null | head -30

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

Length of output: 139


🏁 Script executed:

# 혹시 modules/jpa 쪽에 마이그레이션이 있는지 확인
find ./modules -type f \( -name "*.sql" -o -name "*.xml" \) 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# jpa.yml 파일 확인 (JPA/Hibernate 설정)
find ./apps/commerce-api/src -name "jpa.yml" | xargs cat -n 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# Flyway/마이그레이션 관련 설정 확인
grep -r "flyway\|migration\|hibernate.ddl\|jpa.hibernate.ddl" ./apps/commerce-api/src --include="*.yml" --include="*.properties" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

# 테스트 코드에서 데이터베이스 생성 방식 확인
cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java

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

Length of output: 1827


현재 unique 제약이 소프트 삭제 패턴과 충돌하여 브랜드 이름 재사용이 불가능하다.

@Column(name = "name", nullable = false, unique = true)는 데이터베이스 UNIQUE 제약을 name 컬럼 단독에 생성한다. 소프트 삭제 시 deleted_at이 설정되어도 행은 DB에 남아있고, UNIQUE 제약은 삭제된 행도 카운트하므로 동일 이름으로 재등록할 수 없다. findByNameAndDeletedAtIsNull() 애플리케이션 레벨 검사는 통과하지만 데이터베이스 제약 위반으로 save() 시점에 실패한다.

조치 방안:

  • @Columnunique = true 제거
  • (name, deleted_at) 복합 UNIQUE 제약 또는 WHERE deleted_at IS NULL 부분 인덱스 추가
  • (name, deleted_at) 복합 인덱스 추가로 조회 성능 확보

대량 데이터 환경에서 인덱스 없이 name 조건만으로 스캔하면 성능 저하가 심하므로 반드시 복합 인덱스를 구성해야 한다.

🤖 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/brand/BrandJpaRepository.java`
at line 11, The Brand entity's name column is declared with unique=true which
conflicts with soft-delete and causes save() to fail despite
findByNameAndDeletedAtIsNull passing; remove unique=true from the `@Column` on the
name field in the Brand entity, and instead add a database-level composite
unique constraint or index on (name, deleted_at) (or a partial/filtered unique
index WHERE deleted_at IS NULL if your DB supports it) to enforce uniqueness for
non-deleted rows and also add a composite index on (name, deleted_at) to
preserve lookup performance used by
BrandJpaRepository.findByNameAndDeletedAtIsNull.

Comment on lines +8 to +9
public interface UserJpaRepository extends JpaRepository<User, Long> {
Optional<User> findByLoginId(String loginId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.java" | grep -i user | head -20

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

Length of output: 1145


🏁 Script executed:

find . -type f -name "*Repository*.java" | head -20

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

Length of output: 1848


🏁 Script executed:

# User 엔티티 찾기
fd -e java User.java

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

Length of output: 150


🏁 Script executed:

# BrandJpaRepository 찾기
fd -e java BrandJpaRepository.java

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

Length of output: 173


🏁 Script executed:

# Read User entity
wc -l apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/User.java

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

Length of output: 3135


🏁 Script executed:

# Read BrandJpaRepository to see soft-delete pattern
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java

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

Length of output: 724


🏁 Script executed:

# Read UserJpaRepository
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java

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

Length of output: 447


🏁 Script executed:

# Read UserRepositoryImpl
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java

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

Length of output: 1132


🏁 Script executed:

# Find and read BaseEntity
fd -e java BaseEntity.java

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

Length of output: 145


🏁 Script executed:

# Check if User entity inherits deletedAt from BaseEntity
cat -n apps/commerce-api/src/main/java/com/loopers/domain/BaseEntity.java

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

Length of output: 183


🏁 Script executed:

# Check Brand entity to confirm it has deletedAt field
cat -n apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java | head -50

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

Length of output: 1160


🏁 Script executed:

# Read BaseEntity from modules/jpa
cat -n modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java

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

Length of output: 2291


소프트 삭제 필터링 불일치로 인한 논리적 오류 발생 가능

User 엔티티는 BaseEntity를 확장하여 deletedAt 필드를 상속받으므로 소프트 삭제가 적용된다. 그러나 UserJpaRepository.findByLoginId()는 삭제된 사용자를 필터링하지 않는 반면, BrandJpaRepository는 모든 조회 메서드에서 DeletedAtIsNull 조건을 적용한다. 이 불일치로 인해 삭제된 사용자 계정이 로그인 조회 시에도 반환될 수 있어, 인증/인가 오류 또는 보안 문제가 발생할 수 있다.

findByLoginId() 메서드에 DeletedAtIsNull 필터를 추가하고, findById() 등 다른 조회 메서드도 동일한 필터링을 적용해야 한다.

🤖 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/user/UserJpaRepository.java`
around lines 8 - 9, The repository method findByLoginId in UserJpaRepository
returns users regardless of soft-delete; update UserJpaRepository to only return
non-deleted users by adding the DeletedAtIsNull filter to findByLoginId and any
other retrieval methods (e.g., findById) so they mirror BrandJpaRepository's
behavior; locate the interface UserJpaRepository and modify the method
signatures (or add `@Query` variants) to include the deletedAt IS NULL condition
for all read operations that should ignore soft-deleted records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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