Skip to content

[Volume 3] 도메인 주도 설계 구현 - 김남진#121

Open
Namjin-kimm wants to merge 21 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-3
Open

[Volume 3] 도메인 주도 설계 구현 - 김남진#121
Namjin-kimm wants to merge 21 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-3

Conversation

@Namjin-kimm
Copy link

@Namjin-kimm Namjin-kimm commented Feb 27, 2026


📌 Summary

  • 배경: 루퍼스 커머스 서비스에서 브랜드, 상품, 좋아요, 주문 4개 도메인의 핵심 기능 구현이 필요했습니다.
  • 목표: TDD(Red → Green → Refactor) 사이클로 각 도메인의 비즈니스 요구사항을 구현하고, 레이어드 아키텍처를 기반으로 하지만 DDD적인 구현을 위해 도메인 레이어와 인프라 레이어 간에 DIP 적용
  • 결과: 4개 도메인 CRUD + 인증/인가 + 재고 동시성 처리(비관적 락) + 주문 스냅샷 패턴 구현을 완료했으며, 테스트 계층별 책임 분리를 통해 중복 테스트를 정리했습니다.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 기존 프로젝트에는 User, Example 도메인만 존재했고, 커머스 핵심 기능인 Brand, Product, Like, Order가 없었습니다.
  • 문제(또는 리스크): 주문 시 동시 요청이 들어올 경우 재고 초과 차감이 발생할 수 있고, 주문 후 상품 정보가 변경되면 주문 이력의 무결성이 깨질 수 있습니다.
  • 성공 기준: 각 유스케이스별 E2E 테스트 통과, 동시 주문 시 재고 정합성 보장, 주문 이력 불변성 보장.

선택지와 결정

[도메인 엔티티와 JPA 엔티티 분리 여부]

  • A: 완벽한 DIP 구현을 위해서는 도메인 엔티티와 JPA 엔티티를 분리하는 것이 원칙적으로 맞음.
  • B: 분리하지 않고 도메인 엔티티가 곧 JPA 엔티티
  • 최종 결정: B.
    엄격한 DIP를 구현하기 위해서는 두 가지를 분리하는게 맞으나, 분리하게 되면 JPA가 제공하는 더티 체킹과 같은 이점 사용 불가. 도메인 엔티티에서 JPA 엔티티로 변환하는 코드가 도메인마다 증가하는 비용이 발생한다. 그리고 JPA 어노테이션을 도메인 엔티티에 사용한다고 해서 JPA의 기술이 도메인의 순수성을 침투하기 보다는 해당 어노테이션으로 인해 도메인의 구조나 동작이 바뀌는 것은 없다, 해당 어노테이션은 JPA를 구현할때만 동작할 뿐 JPA를 사용하지 않으면 아무 영향 없음. 이것 자체가 도메인의 순수성을 어느 정도 지켜준다고 판단.
  • 트레이드오프: 가능성은 낮지만 추후 JPA가 아닌 다른 기술을 사용할 경우 전체 도메인 코드를 다 수정해야함

[E2E 인증 테스트 중복 제거]

  • A: 매 E2E 테스트마다 사용자의 인증여부도 테스트코드에 포함
  • B: 해당 엔트포인트가 인터셉터와 와이어링이 잘 되어있는지 한 번만 확인하면 되므로, 나머지 불필요한 인증 부분은 테스트코드에서 제거
  • 최종 결정: B.
    AuthInterceptorTest(단위 테스트)에서 인터셉터 로직을 완전히 커버하고 있으므로, 각 E2E 테스트에서는 URL 패턴 와이어링 확인용으로 도메인당 1개만 유지했습니다.
  • 트레이드오프: E2E 테스트 코드를 볼 때 처음부터 끝까지의 흐름이 명확하지 않을 수 있음

🏗️Design Overview

변경 범위

  • 영향 받는 모듈/도메인: Brand, Product, Like, Order
  • 신규 추가:
    • Like — 좋아요 도메인 전 레이어 (Entity, Service, Repository, Facade, Controller)
    • Order / OrderItem — 주문 도메인 전 레이어
    • OrderAdminV1ApiSpec — Swagger 문서 인터페이스 분리
  • 제거/대체:
    • LikeRepository.findByUserIdAndProductId() — 미사용 메서드 제거
    • ProductV1Dto.inStock(boolean) → stock(int) 교체
    • ProductV1Dto.brandId 제거 (내부 식별자 불필요 노출)

주요 컴포넌트 책임

  • LikeFacade: 상품 존재 확인(ProductService) → 좋아요 저장(LikeService) → 좋아요 수 증가(ProductService) 조율. 브랜드/상품 삭제 시 cascade 삭제 흐름도 처리
  • Like Entity: BaseEntity 미상속 — hard delete, (userId, productId) UNIQUE 제약, @PrePersist로 createdAt 직접 관리
  • OrderFacade: 비관적 락 기반 재고 확인 + 차감 → 스냅샷 기반 OrderItem 구성 → 주문 저장의 단일 트랜잭션 조율
  • ProductService.verifyAndDecreaseStock(): SELECT FOR UPDATE로 행 잠금, 상품 존재/재고 검증, dirty checking으로 재고 차감
  • OrderItem: 주문 시점 상품명/브랜드명/가격 스냅샷 저장 — 이후 상품 정보 변경에도 주문 이력 불변성 보장

🔁 Flow Diagram

Main Flow — 주문 생성 (US-O01)

sequenceDiagram
autonumber
participant Client
participant Controller as OrderV1Controller
participant Facade as OrderFacade
participant ProductService
participant BrandService
participant OrderService
participant DB

Client->>Controller: POST /api/v1/orders (X-Loopers-LoginId/Pw)
Note over Controller: AuthInterceptor → userId 주입

Controller->>Facade: create(userId, OrderCreateCommand)

Facade->>ProductService: verifyAndDecreaseStock(Map<productId, Quantity>)
ProductService->>DB: SELECT * FROM product WHERE id IN (...) FOR UPDATE
DB-->>ProductService: 상품 목록 (행 잠금 획득)
Note over ProductService: 상품 존재 확인<br/>재고 충분 여부 검증<br/>decreaseStock() dirty checking
ProductService->>DB: UPDATE product SET stock = stock - N (트랜잭션 커밋 시)
ProductService-->>Facade: products

Facade->>BrandService: findNamesByIds(brandIds)
BrandService->>DB: SELECT brand WHERE id IN (...)
DB-->>BrandService: 브랜드 목록
BrandService-->>Facade: brandNameMap

Note over Facade: OrderItem 스냅샷 구성<br/>(상품명/브랜드명/가격 복사)

Facade->>OrderService: create(userId, orderItems)
OrderService->>DB: INSERT orders + INSERT order_item (CASCADE)
DB-->>OrderService: saved Order
OrderService-->>Facade: Order

Facade-->>Controller: OrderInfo
Controller-->>Client: 200 OK ApiResponse<OrderResponse>

예외 흐름

sequenceDiagram
autonumber
participant Client
participant Facade as OrderFacade
participant ProductService
participant Advice as ApiControllerAdvice

Client->>Facade: create(userId, command)
Facade->>ProductService: verifyAndDecreaseStock(...)

alt 존재하지 않는 상품 포함
  ProductService-->>Facade: CoreException(NOT_FOUND)
  Facade-->>Advice: 예외 전파
  Advice-->>Client: 404 NOT_FOUND
else 재고 부족
  ProductService-->>Facade: CoreException(BAD_REQUEST)
  Facade-->>Advice: 예외 전파
  Advice-->>Client: 400 BAD_REQUEST
end

## ✅ Checklist

🏷 Product / Brand 도메인

  • 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다.
  • 상품의 정렬 조건(latest, price_asc, likes_desc) 을 고려한 조회 기능을 설계했다
  • 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다
  • 재고의 음수 방지 처리는 도메인 레벨에서 처리된다

👍 Like 도메인

  • 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다
  • 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다
  • 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다

🛒 Order 도메인

  • 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다
  • 주문 시 상품의 재고 차감을 수행한다
  • 재고 부족 예외 흐름을 고려해 설계되었다
  • 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다

🧩 도메인 서비스

  • 도메인 내부 규칙은 Domain Service에 위치시켰다
  • 상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer 에서 처리했다
  • 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다
  • 도메인 서비스는 상태 없이, 동일한 도메인 경계 내의 도메인 객체의 협력 중심으로 설계되었다

🧱 소프트웨어 아키텍처 & 설계

  • 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다
    • Application → Domain ← Infrastructure
  • Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다
  • 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다
  • Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다
  • 패키지는 계층 + 도메인 기준으로 구성되었다 (/domain/order, /application/like 등)
  • 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다

변경 목적

TDD와 계층형 아키텍처를 적용하여 Brand, Product, Like, Order 4개 핵심 도메인을 구현하고, 의존성 역전 원칙을 준용한 도메인-인프라 계층 분리 구축.

핵심 변경점

  • 도메인 계층: 4개 도메인 완전 구현(엔티티, 서비스, 리포지토리), Money/Quantity/Stock 값 객체, PasswordEncoder 인터페이스로 Spring Security 디커플링
  • 주문/재고 동시성 처리: SELECT FOR UPDATE 비관적 락으로 TOCTOU 방지, dirty checking으로 재고 차감, OrderItem 스냅샷 패턴으로 주문 이력 보존
  • 좋아요 기능: hard delete(BaseEntity 미상속), (userId, productId) unique 제약, @PrePersist로 생성시간 관리, 상품 삭제 시 cascade hard delete
  • 인증/권한: @loginuser 아규먼트 리졸버, AdminAuthInterceptor 분리(X-Loopers-Ldap 헤더 검증), E2E 테스트에서 인증 검증 최소화
  • 응용 계층: 브랜드/상품/좋아요/주문 Facade 신규 추가로 오케스트레이션 담당, 다층 계층 간 DTO 변환

리스크/주의사항

  • Domain과 JPA 엔티티를 분리하지 않은 설계(향후 마이그레이션 비용 발생 가능): 이 선택이 실제 요구사항을 반영한 것인지 확인 필요
  • 비관적 락(SELECT FOR UPDATE) 사용 시 데이터베이스 부하 및 데드락 위험: 높은 동시성 환경에서 성능 영향 테스트 필요
  • Like hard delete의 cascade 동작이 productIds 리스트 기반인데, 대량 삭제 시 배치 처리 필요 여부는 검토되었는지 확인

테스트/검증

  • 도메인/서비스/인프라 별 단위/통합 테스트 27개, E2E 테스트로 각 도메인별 전체 흐름 검증
  • 주문 생성 시 재고 차감, 좋아요 추가/삭제 시 카운트 변경, 상품 삭제 시 좋아요 cascade 등 비즈니스 규칙 명시적 검증
  • 인증 인터셉터는 단위 테스트로 검증하고 E2E 테스트에서 최소 와이어링만 수행하는 전략 적용

madirony and others added 21 commits February 7, 2026 22:07
fix : 예제 테스트 코드 오류 해결을 위한 testcontainers 버전 업
…서 사용자 인증 후 사용자의 정보를 @loginuser 어노테이션을 통해 UserInfo에 할당
- 도메인 레이어에 PasswordEncoder 인터페이스 정의
- BcryptPasswordEncoder 구현체를 Infrastructure 레이어로 이동
- 도메인이 외부 프레임워크(Spring Security)에 의존하지 않도록 개선
- 도메인 모델링 및 아키텍처 레이어별 역할 명시
@coderabbitai
Copy link

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

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Note

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

🟠 Major comments (33)
apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major

@Table(name = "user")은 예약어 충돌 위험이 있어 비예약어로 변경 필요하다

user는 PostgreSQL, MySQL, Oracle, H2 등 대부분의 DB에서 예약어로 취급된다. JPA가 자동으로 식별자를 쿼팅할 수 있으나, 명시적 dialect 설정이 없는 현 상태에서는 환경별 동작이 불안정할 수 있다. 또한 코드베이스의 테이블명 규칙이 혼재되어 있다(orders, likes 복수형 vs product, example, brand, user 단수형). 테이블명을 users 또는 app_user로 변경하고, 네이밍 규칙을 통일하도록 한다. 기존 스키마가 배포된 환경은 명시적 마이그레이션을 함께 반영해야 한다.

🔧 제안 수정안
-@Table(name = "user")
+@Table(name = "users")

추가로 다음을 확인한다:

  • JPA ddl-auto, database-platform 설정이 모든 profile(dev, qa, prd)에 명시되어 있는지
  • 마이그레이션 전략(Flyway/Liquibase 등) 수립 여부
  • 테이블명 네이밍 규칙 문서화 및 통일
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java` at
line 17, The `@Table`(name = "user") on UserModel risks SQL reserved-word
collisions; change the table name to a non-reserved identifier (e.g.,
`@Table`(name = "users") or `@Table`(name = "app_user") in the UserModel class),
update any repository/JPQL references to that entity, and add a corresponding DB
migration (Flyway/Liquibase) to rename the existing table so deployments are
safe; also verify that JPA settings (spring.jpa.hibernate.ddl-auto and
spring.jpa.database-platform) are explicitly set for all profiles (dev/qa/prd)
and document/align the project table-naming convention (singular vs plural)
across entities like Order/Orders, Like/Likes, Product, Brand.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java-21-27 (1)

21-27: ⚠️ Potential issue | 🟠 Major

API 스펙 인터페이스에서 @LoginUser 어노테이션이 누락되었다.

UserV1ApiSpec에서 UserInfo 파라미터가 @LoginUser 어노테이션 없이 정의되어 있다. 이로 인해 SpringDoc이 생성하는 OpenAPI 문서에서 UserInfo가 일반 요청 파라미터처럼 노출될 수 있다.

같은 프로젝트의 OrderV1ApiSpecLikeV1ApiSpec는 이미 @LoginUser 어노테이션을 적용하고 있으므로, UserV1ApiSpec도 동일한 패턴으로 수정하여 일관성을 유지해야 한다. 운영 환경에서 API 문서를 보는 클라이언트 개발자에게 혼란을 주고, 잘못된 API 호출 시도로 이어질 수 있다.

♻️ 수정 예시
     `@Operation`(
         summary = "내 정보 조회",
         description = "인증된 사용자의 정보를 조회합니다."
     )
-    ApiResponse<UserV1Dto.UserResponse> getMe(UserInfo userInfo);
+    ApiResponse<UserV1Dto.UserResponse> getMe(`@LoginUser` UserInfo userInfo);

     `@Operation`(
         summary = "비밀번호 변경",
         description = "인증된 사용자의 비밀번호를 변경합니다."
     )
-    ApiResponse<Object> changePassword(UserInfo userInfo, UserV1Dto.ChangePasswordRequest request);
+    ApiResponse<Object> changePassword(`@LoginUser` UserInfo userInfo, UserV1Dto.ChangePasswordRequest 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/user/UserV1ApiSpec.java`
around lines 21 - 27, Add the missing `@LoginUser` annotation to the UserInfo
parameter in UserV1ApiSpec so SpringDoc does not treat it as a regular request
parameter; specifically annotate the UserInfo argument in the methods
getMe(UserInfo userInfo) and changePassword(UserInfo userInfo,
UserV1Dto.ChangePasswordRequest request) with `@LoginUser` (following the pattern
used in OrderV1ApiSpec and LikeV1ApiSpec) to keep the OpenAPI output consistent.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java-3-4 (1)

3-4: ⚠️ Potential issue | 🟠 Major

도메인 계층이 Spring Framework에 직접 의존하여 DIP 위반 및 아키텍처 경계 붕괴 상태다.

현재 OrderRepository를 포함한 도메인 계층 전체에서 Spring Data의 Page/Pageable과 @Component, @Transactional 등 인프라 관심사가 산재되어 있다. 이는 프레임워크 변경 시 도메인 코드 수정을 강제하고, 단위 테스트 작성 시 Spring 컨텍스트 로딩 비용을 초래하며, 운영 환경에서의 배포 리스크와 롤백 난이도를 크게 높인다.

수정 방향:

  1. 도메인 포트(Repository 인터페이스)에서 Spring Data 타입 제거
  2. OrderPageQuery, OrderPageResult 등 도메인 전용 조회 파라미터/결과 타입 정의
  3. 인프라 계층 어댑터에서만 Spring Data 타입으로 변환

필수 검증:
아키텍처 테스트를 추가하여 com.loopers.domain 패키지 내 모든 Java 파일에서 org.springframework.* import가 발생하면 빌드 실패하도록 강제해야 한다. 현재 8개 파일에서 24건의 Spring 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/domain/order/OrderRepository.java`
around lines 3 - 4, The domain layer currently depends on Spring types (e.g.,
OrderRepository uses org.springframework.data.Page/Pageable), violating DIP;
change the repository interface OrderRepository to use domain-specific types
(define OrderPageQuery and OrderPageResult as domain DTOs) and remove all
org.springframework.* imports from classes in com.loopers.domain; implement
conversion between OrderPageQuery/OrderPageResult and Spring Page/Pageable in
the infrastructure adapter (the adapter that implements OrderRepository), and
add an architecture test that fails the build if any Java file under
com.loopers.domain imports org.springframework.* to enforce the boundary.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java-47-66 (1)

47-66: ⚠️ Potential issue | 🟠 Major

quantity null 검증 누락으로 DB 예외가 늦게 터질 수 있다

  • 운영 관점: 애플리케이션 레벨에서 선제 차단하지 않으면 flush 시점 제약조건 예외로 변환되어 500 응답/롤백이 발생하고 원인 추적이 어려워진다.
  • 수정안: 생성자와 guard()validateQuantity를 추가해 도메인 예외(CoreException)로 조기 실패시켜야 한다.
  • 추가 테스트: quantity == nullOrderItem 생성 시 BAD_REQUEST가 발생하는 테스트를 추가해야 한다.
수정 예시
     public OrderItem(Long productId, Quantity quantity, String productName, String brandName, Money price) {
         validateProductId(productId);
+        validateQuantity(quantity);
         validateProductName(productName);
         validateBrandName(brandName);
         validatePrice(price);
@@
     protected void guard() {
         validateProductId(this.productId);
+        validateQuantity(this.quantity);
         validateProductName(this.productName);
         validateBrandName(this.brandName);
         validatePrice(this.price);
     }
+
+    private void validateQuantity(Quantity quantity) {
+        if (quantity == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "수량은 필수입니다.");
+        }
+    }

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/domain/order/OrderItem.java`
around lines 47 - 66, OrderItem is missing null validation for quantity; add a
validateQuantity(Quantity) call in the OrderItem constructor and in the
overridden guard() method (alongside existing
validateProductId/Name/Brand/Price) so null quantity fails fast by throwing the
domain CoreException; update or add a unit test that constructs OrderItem with
quantity == null and asserts a BAD_REQUEST/CoreException is raised.
apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-60-63 (1)

60-63: ⚠️ Potential issue | 🟠 Major

주문 항목 리스트의 null 요소를 허용하면 런타임 실패로 전이된다

  • 운영 관점: 리스트 내부 null 요소가 허용되면 저장/매핑 시점에서 NPE가 발생해 장애 복구와 원인 분석 비용이 커진다.
  • 수정안: validateOrderItems에서 null 요소 포함 여부까지 차단해야 한다.
  • 추가 테스트: orderItems에 null 요소가 포함된 생성 요청이 BAD_REQUEST로 실패하는 테스트를 추가해야 한다.
수정 예시
+import java.util.Objects;
@@
     private void validateOrderItems(List<OrderItem> orderItems) {
-        if (orderItems == null || orderItems.isEmpty()) {
+        if (orderItems == null || orderItems.isEmpty() || orderItems.stream().anyMatch(Objects::isNull)) {
             throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 최소 1개 이상이어야 합니다.");
         }
     }

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/domain/order/Order.java` around
lines 60 - 63, In Order.validateOrderItems, extend the existing null/empty check
to also reject lists that contain any null element:
iterate/orderItems.stream().anyMatch(Objects::isNull) and if true throw new
CoreException(ErrorType.BAD_REQUEST, "주문 항목은 null을 포함할 수 없습니다."); update the
error message to be clear and consistent with other BAD_REQUEST uses
(ErrorType.BAD_REQUEST, CoreException) and add a unit/integration test that
submits an orderItems list containing a null entry and asserts a BAD_REQUEST is
thrown.
apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-22-33 (1)

22-33: ⚠️ Potential issue | 🟠 Major

내부 orderItems 컬렉션이 외부에 가변 상태로 노출된다

  • 운영 관점: 외부 코드가 getOrderItems().clear() 같은 조작을 수행하면 BR-O01(최소 1개 항목) 불변식이 우회되어 데이터 무결성이 깨질 수 있다.
  • 수정안: 컬렉션 필드의 기본 getter 노출을 막고, 읽기용으로 List.copyOf(orderItems)를 반환해야 한다.
  • 추가 테스트: getter로 받은 컬렉션 수정 시 원본이 변하지 않는지(또는 수정 불가 예외 발생) 검증해야 한다.
수정 예시
 `@NoArgsConstructor`(access = AccessLevel.PROTECTED)
 `@Getter`
 public class Order extends BaseEntity {
@@
+    `@Getter`(AccessLevel.NONE)
     `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER)
     `@JoinColumn`(name = "order_id", nullable = false)
     private List<OrderItem> orderItems = new ArrayList<>();
+
+    public List<OrderItem> getOrderItems() {
+        return List.copyOf(orderItems);
+    }

As per coding guidelines **/domain/**/*.java: 엔티티/값 객체 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.

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

In `@apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java` around
lines 22 - 33, The public getter currently exposes the mutable field orderItems
(List<OrderItem>) so external code can mutate the collection and break the "min
1 item" invariant; stop Lombok from generating a default mutable accessor and
add an explicit public List<OrderItem> getOrderItems() that returns
List.copyOf(orderItems) (or Collections.unmodifiableList(orderItems)) to ensure
immutability, keep the field private with existing cascade/orphanRemoval
settings, and add a unit test that calls getOrderItems().clear() or attempts
modification and asserts the original orderItems remains unchanged or that an
UnsupportedOperationException is thrown.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java-26-29 (1)

26-29: ⚠️ Potential issue | 🟠 Major

findById에서 메모리 필터링을 사용하면 불필요한 엔티티 로딩 비용이 발생한다

현재 코드는 orderJpaRepository.findById(id) 후 메모리에서 deletedAt == null 필터를 수행한다. 이로 인해 삭제된 주문도 먼저 데이터베이스에서 조회되며, Order 엔티티가 EAGER 로딩 설정인 OrderItems까지 함께 로드되어 불필요한 메모리/I/O 비용이 증가한다. 특히 조회 빈도가 높을수록 누적 비용이 커진다.

다른 메서드들(findAllByUserIdAndCreatedAtBetween, findAll)은 이미 데이터베이스 레벨에서 soft-delete 필터를 적용하고 있으므로, findById도 동일한 패턴으로 개선해야 한다.

OrderJpaRepositoryOptional<Order> findByIdAndDeletedAtIsNull(Long id) 메서드를 추가하고, OrderRepositoryImpl.findById에서 이 메서드를 사용하도록 변경한다. 추가로 deletedAt이 설정된 주문 ID 조회 시 Optional.empty를 반환하는 테스트와 정상 주문 조회를 분리 검증해야 한다.

🤖 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/OrderRepositoryImpl.java`
around lines 26 - 29, The current OrderRepositoryImpl.findById loads entities
then filters by deletedAt in memory, causing unnecessary DB/I-O and
eager-loading; add a repository-level query Optional<Order>
findByIdAndDeletedAtIsNull(Long id) to OrderJpaRepository and change
OrderRepositoryImpl.findById to call that method directly; update or add unit
tests to verify that a deleted order ID returns Optional.empty and a non-deleted
order returns the entity (separate test cases).
apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java-5-13 (1)

5-13: ⚠️ Potential issue | 🟠 Major

주문 커맨드에서 null 검증과 방어적 복사가 없어 예외 일관성이 깨진다

  • 운영 관점: items == null 또는 요소가 null인 경우 OrderFacade 라인 40에서 NullPointerException이 발생해 500 에러로 반환되며, CoreException → ApiControllerAdvice 통일 패턴이 깨진다.
  • 수정안: compact constructor에서 null/empty/null-element를 검증하고 List.copyOf(items)로 방어적 복사를 적용해야 한다.
  • 추가 테스트: items가 null/빈 리스트/null 요소 포함일 때 각각 CoreException(BAD_REQUEST)가 반환되는 테스트를 추가해야 한다.
수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import java.util.List;

 public record OrderCreateCommand(
         List<Item> items
 ) {
+    public OrderCreateCommand {
+        if (items == null || items.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 최소 1개 이상이어야 한다.");
+        }
+        if (items.stream().anyMatch(item -> item == null)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목에 null이 포함될 수 없다.");
+        }
+        items = List.copyOf(items);
+    }

     public record Item(
             Long productId,
             int 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/OrderCreateCommand.java`
around lines 5 - 13, Add a compact constructor to OrderCreateCommand that
validates items: throw a CoreException(BAD_REQUEST) if items is null,
items.isEmpty(), or any element is null; perform a defensive copy with
List.copyOf(items) and assign it to the record component so the record holds an
immutable, validated list. Update the OrderCreateCommand record (and keep nested
Item) to use this compact constructor for validation, and add unit tests that
exercise OrderCreateCommand creation when items is null, when items is empty,
and when items contains a null element, asserting that each case results in a
CoreException with BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java-13-14 (1)

13-14: ⚠️ Potential issue | 🟠 Major

기간 조회 경계 조건이 하루를 초과해 포함될 수 있다

Spring Data JPA의 Between 키워드는 BETWEEN ?1 AND ?2로 변환되며, 양끝이 포함(>= AND <=)된다. 현재 코드는 to = endAt.plusDays(1).atStartOfDay(KST)로 다음날 00:00:00을 상한으로 설정하는데, Between의 양끝 포함 특성으로 인해 다음날 정각에 생성된 주문이 조회 범위에 포함된다. 이는 정산/통계 집계 시 오차를 발생시킬 수 있다.

수정안: Between 대신 GreaterThanEqualAndLessThan 조건으로 변경하여 상한을 배타적으로 처리한다.

-    List<Order> findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull(
+    List<Order> findAllByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanAndDeletedAtIsNull(
             Long userId, ZonedDateTime from, ZonedDateTime to);

추가 테스트: endAt + 1일 00:00:00에 생성된 주문이 결과에 포함되지 않는지 경계값 테스트를 작성한다.

🤖 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`
around lines 13 - 14, The current repository method
findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull uses Between which is
inclusive on both ends; change it to use exclusive upper-bound by renaming the
method to
findAllByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanAndDeletedAtIsNull
and keep the parameters (Long userId, ZonedDateTime from, ZonedDateTime to) so
Spring Data generates ">= from AND < to"; update any call sites accordingly and
add a unit/integration boundary test that creates an Order at
endAt.plusDays(1).atStartOfDay(KST) and asserts it is NOT returned by the query.
apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-30-32 (1)

30-32: ⚠️ Potential issue | 🟠 Major

EAGER 로딩과 페이지 조회의 N+1 쿼리 패턴이 발생한다

개발자 의견(29줄)에 따라 OrderInfo 응답에 항목을 항상 포함한다는 점은 이해하나, 관리자 주문 목록 조회(findAllByDeletedAtIsNull(pageable))에서 페이지 크기 20으로 조회 시 다음과 같이 작동한다:

  1. SELECT orders LIMIT 20 실행
  2. OrderInfo.of()에서 order.getOrderItems() 호출 (17줄)
  3. 각 Order마다 별도 쿼리로 SELECT order_items WHERE order_id = ? 실행 (총 20+1 = 21개 쿼리)

단순 derived query에는 fetch join이나 @EntityGraph가 없어 JPA는 EAGER 지정만으로는 JOIN 최적화를 보장하지 않는다. 페이지 크기 증가 시 병목이 급격히 심화한다.

수정안: 기본값을 LAZY로 두고, 상세 조회/목록 조회별로 @EntityGraph 또는 @Query with fetch join으로 로딩 범위를 명시적으로 제어해야 한다.

-    `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER)
+    `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.LAZY)

추가로 두 가지 문제가 더 있다:

  • 컬렉션 캡슐화: 22줄 @Getter로 List이 외부에 노출되어 BR-O01 불변식이 우회될 수 있다. Collections.unmodifiableList() 반환으로 수정한다.
  • 입력 검증: 60-64줄에서 null/empty만 검증하고 내부 원소 null 검증이 없다. orderItems.stream().anyMatch(Objects::isNull) 추가 검증이 필요하다.

관리자 주문 목록 API에서 페이지 크기 10, 20, 50으로 쿼리 수와 응답 시간을 비교하는 회귀 테스트를 추가해야 한다.

🤖 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/Order.java` around
lines 30 - 32, Change Order.orderItems from EAGER to LAZY and control loading
via repository-level fetch (use `@EntityGraph` or `@Query` with fetch join for
methods like findAllByDeletedAtIsNull(Pageable) and for detailed OrderInfo
loads), update the getter for orderItems to return
Collections.unmodifiableList(orderItems) to enforce encapsulation, and
strengthen input validation where order items are accepted (the
constructor/mutator referenced around lines 60-64) by rejecting lists containing
null elements via orderItems.stream().anyMatch(Objects::isNull) (throw
IllegalArgumentException), and add a regression test that calls the admin order
list API (findAllByDeletedAtIsNull) with page sizes 10/20/50 to assert query
counts and response time do not regress.
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java-136-140 (1)

136-140: ⚠️ Potential issue | 🟠 Major

동일 OrderItem 객체 재사용으로 테스트 데이터가 오염될 수 있다

운영 관점에서 동일 OrderItem 인스턴스를 서로 다른 Order에 재사용하면 영속화 시 연관관계가 덮어써지거나 예기치 않은 상태가 되어 테스트가 간헐적으로 통과/실패할 수 있다. 각 주문마다 buildOrderItem(...)으로 새 인스턴스를 생성하도록 수정하는 것이 안전하다. 추가로 “두 주문이 각각 독립적인 orderItems 1건을 가진다”는 검증을 넣어 회귀를 막아야 한다.

수정 예시
-            OrderItem item = buildOrderItem(product, brand.getName());
-
-            orderJpaRepository.save(new Order(USER_ID, List.of(item)));
-            orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item))); // 타인의 주문
+            orderJpaRepository.save(new Order(USER_ID, List.of(buildOrderItem(product, brand.getName()))));
+            orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(buildOrderItem(product, brand.getName())))); // 타인의 주문
-            OrderItem item = buildOrderItem(product, brand.getName());
-
-            orderJpaRepository.save(new Order(USER_ID, List.of(item)));
-            orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item)));
+            orderJpaRepository.save(new Order(USER_ID, List.of(buildOrderItem(product, brand.getName()))));
+            orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(buildOrderItem(product, brand.getName()))));

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

Also applies to: 184-187

🤖 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 136 - 140, Tests reuse the same OrderItem instance causing
persistence/association bleed between orders; instead call
buildOrderItem(product, brand.getName()) separately for each Order (e.g., create
item1 and item2) before saving orderJpaRepository.save(new Order(USER_ID,
List.of(item1))) and orderJpaRepository.save(new Order(OTHER_USER_ID,
List.of(item2))); also update the other occurrence (around the second block at
the same file) similarly and add assertions that each saved Order has exactly
one orderItem and that the two orderItems are distinct (e.g., non-null, size ==
1, and not the same instance) to prevent regressions.
apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java-24-37 (1)

24-37: ⚠️ Potential issue | 🟠 Major

Quantity null 입력 시 NPE로 500이 발생한다

운영 관점에서 decrease/increase/hasEnoughquantity.getValue()를 바로 호출하여 null 입력 시 CoreException이 아닌 NPE로 떨어지고 에러 응답 일관성이 깨진다. 공통 null 가드를 두고 BAD_REQUEST를 명시적으로 던지도록 수정해야 한다. 추가 테스트로 각 메서드에 null 전달 시 CoreException(ErrorType.BAD_REQUEST)가 발생하는 케이스를 넣어야 한다.

수정 예시
     public Stock decrease(Quantity quantity) {
+        requireQuantity(quantity);
         int newQuantity = this.quantity - quantity.getValue();
         if (newQuantity < 0) {
             throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
         }
         return new Stock(newQuantity);
     }

     public Stock increase(Quantity quantity) {
+        requireQuantity(quantity);
         return new Stock(this.quantity + quantity.getValue());
     }

     public boolean hasEnough(Quantity quantity) {
+        requireQuantity(quantity);
         return this.quantity >= quantity.getValue();
     }
+
+    private void requireQuantity(Quantity quantity) {
+        if (quantity == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "수량은 비어있을 수 없습니다.");
+        }
+    }

Based on learnings "오류는 CoreException을 통해 ApiControllerAdvice로 일원화해야 하며, 일관된 응답 포맷을 유지해야 한다."

🤖 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/Stock.java` around
lines 24 - 37, Add null-guards to Stock.decrease, Stock.increase and
Stock.hasEnough so passing a null Quantity throws new
CoreException(ErrorType.BAD_REQUEST, "...") instead of causing an NPE: in each
method (decrease/increase/hasEnough) check if the Quantity parameter is null and
throw CoreException(ErrorType.BAD_REQUEST, "quantity must not be null") before
calling quantity.getValue(); update Stock class accordingly and add unit tests
asserting that calling decrease(null), increase(null) and hasEnough(null) each
result in CoreException with ErrorType.BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java-22-35 (1)

22-35: ⚠️ Potential issue | 🟠 Major

브랜드명 정규화가 없어 중복 데이터가 누적될 수 있다

운영 관점에서 "나이키"" 나이키 "가 서로 다른 값으로 저장되면 중복 검사(existsByName)와 조회 결과가 어긋나 데이터 정합성이 깨질 수 있다. 생성/수정 시 trim()으로 정규화한 값을 검증·저장하도록 수정하는 것이 필요하다. 추가 테스트로 생성/수정 시 공백 정규화가 적용되는지와 공백-only 입력이 거절되는지를 포함해야 한다.

수정 예시
     public Brand(String name) {
-        validateName(name);
-        this.name = name;
+        this.name = normalizeName(name);
     }

     public void update(String name) {
-        validateName(name);
-        this.name = name;
+        this.name = normalizeName(name);
     }

-    private void validateName(String name) {
-        if (name == null || name.isBlank()) {
+    private String normalizeName(String name) {
+        String normalized = name == null ? null : name.trim();
+        if (normalized == null || normalized.isBlank()) {
             throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다.");
         }
+        return normalized;
     }

As per coding guidelines "**/domain/**/*.java: 도메인 모델의 불변성과 캡슐화를 점검하고 도메인 규칙을 명확히 유지한다."

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

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java` around
lines 22 - 35, Normalize and validate brand names by trimming whitespace before
validation and assignment: in the Brand constructor, the update(String name)
method, and inside validateName(String name) ensure you call name = name == null
? null : name.trim() (or equivalent) so storage and validation use the trimmed
value; then validate null/blank and assign the trimmed string to this.name.
Update any callers that rely on raw input if necessary and add unit tests
verifying that creating/updating with surrounding spaces stores the trimmed name
and that inputs of only-whitespace are rejected by validateName.
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java-81-85 (1)

81-85: ⚠️ Potential issue | 🟠 Major

브랜드 ID 검증이 약해 잘못된 참조값이 도메인으로 유입된다.

운영에서 brandId=0 또는 음수 값이 들어오면 DB 제약 예외로 500이 발생하거나 데이터 정합성 이슈로 이어질 수 있다. validateBrandId에서 null뿐 아니라 1 미만 값도 CoreException(ErrorType.BAD_REQUEST)로 차단해야 한다. 추가 테스트로 brandId=0, brandId=-1 생성 시 BAD_REQUEST를 검증해야 한다.

수정 예시
 private void validateBrandId(Long brandId) {
-    if (brandId == null) {
-        throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다.");
+    if (brandId == null || brandId <= 0) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 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/product/Product.java`
around lines 81 - 85, Update Product.validateBrandId to reject non-positive IDs:
currently it only checks for null, so change the validation to throw new
CoreException(ErrorType.BAD_REQUEST, "...") when brandId is null OR brandId < 1
to block 0 and negative values; update/create unit tests for Product creation to
assert that passing brandId = 0 and brandId = -1 results in CoreException with
ErrorType.BAD_REQUEST (refer to the Product class and validateBrandId method and
the constructor or factory that calls it).
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java-22-24 (1)

22-24: ⚠️ Potential issue | 🟠 Major

@Modifying 옵션으로 영속성 컨텍스트 일관성을 보장하도록 수정한다.

deleteAllByProductIdIn은 현재 @Modifying만 선언되어 있어 벌크 삭제 후 1차 캐시가 즉시 갱신되지 않을 수 있다. 서비스 레이어에서 이미 빈 컬렉션을 필터링하므로 IN절 오류는 발생하지 않으나, 동일 트랜잭션에서 삭제 직후 조회 시 영속성 컨텍스트와 데이터베이스 상태가 불일치할 수 있다. @Modifying(clearAutomatically = true, flushAutomatically = true)를 추가하여 자동으로 영속성 컨텍스트를 초기화하고 쿼리를 먼저 실행하도록 한다.

수정 예시
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`("DELETE FROM Like l WHERE l.productId IN :productIds")
     void deleteAllByProductIdIn(`@Param`("productIds") Collection<Long> productIds);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java`
around lines 22 - 24, Update the repository method deleteAllByProductIdIn in
LikeJpaRepository to ensure JPA persistence context consistency after the bulk
delete by adding the `@Modifying` options: set clearAutomatically = true and
flushAutomatically = true so the delete is flushed to the database and the
first-level cache is cleared automatically; modify the `@Modifying` annotation on
the deleteAllByProductIdIn(`@Param`("productIds") Collection<Long> productIds)
method to include these two attributes.
apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java-19-21 (1)

19-21: ⚠️ Potential issue | 🟠 Major

BrandRepositoryImpl의 soft-delete 필터링 불일치로 인한 조회 경로 보안 결함.

findById()와 findAllByIds()는 삭제된 브랜드를 포함하여 조회하므로, BrandService.update() 호출 시 삭제된 브랜드가 수정될 수 있다. 기존 existsByName* 메서드는 deletedAt 조건을 포함하고 있으나, 조회 메서드는 누락되어 있어 soft-delete 정책이 일관되지 않는다. OrderRepositoryImpl처럼 .filter(brand -> brand.getDeletedAt() == null)을 추가하거나, ProductRepositoryImpl처럼 JPA 쿼리 메서드에 AndDeletedAtIsNull 조건을 포함하여 모든 조회 경로에 soft-delete 필터를 적용해야 한다. 삭제된 브랜드 ID로 조회 후 Optional.empty()를 반환하는 통합 테스트를 추가하기를 권장한다.

🤖 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 19 - 21, findById(Long) and findAllByIds(...) in
BrandRepositoryImpl currently return soft-deleted brands; update these methods
to exclude deleted entries by applying the same soft-delete filter used in
existsByName* (either change the JPA query signatures to include
AndDeletedAtIsNull or post-filter results with .filter(b -> b.getDeletedAt() ==
null)), so BrandRepositoryImpl.findById and findAllByIds never return brands
with non-null deletedAt; also add an integration test that attempts to load a
deleted brand ID and asserts Optional.empty() (exercise via BrandService.update
to ensure the update path is protected).
apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java-19-24 (1)

19-24: ⚠️ Potential issue | 🟠 Major

동시성 환경에서 존재 여부 확인 후 작업 패턴은 경쟁 조건으로 인해 표준화되지 않은 오류를 발생시킨다.

create 메서드에서 동일한 userId/productId 요청이 동시에 들어올 때, 두 요청 모두 존재 여부 확인을 통과한 후 한 건이 데이터베이스 유니크 제약 위반으로 DataIntegrityViolationException을 던진다. 현재는 이 예외가 ApiControllerAdvice에서 처리되지 않아 일반 예외 핸들러(Throwable)에 의해 500 INTERNAL_ERROR로 응답되므로, 표준화된 CONFLICT 오류 포맷과 맞지 않는다. 제약 위반을 CoreException(CONFLICT) 로 변환해야 한다.

delete 메서드도 동시성 취약점이 있다. 존재 여부 확인과 삭제 사이에 다른 요청이 레코드를 삭제하면, 원래 요청의 삭제는 효과 없이 통과하지만 LikeFacade.delete()의 후속 decreaseLikeCount() 호출은 여전히 실행되어 상품 좋아요 수가 부정확해질 수 있다.

수정 예시
     public Like create(Long userId, Long productId) {
-        if (likeRepository.existsByUserIdAndProductId(userId, productId)) {
-            throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.");
+        try {
+            Like like = new Like(userId, productId);
+            return likeRepository.save(like);
+        } catch (DataIntegrityViolationException e) {
+            throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.");
         }
-        Like like = new Like(userId, productId);
-        return likeRepository.save(like);
     }
 
     public void delete(Long userId, Long productId) {
-        if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
-            throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
+        long deletedCount = likeRepository.deleteByUserIdAndProductIdReturnCount(userId, productId);
+        if (deletedCount == 0) {
+            throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
         }
-        likeRepository.deleteByUserIdAndProductId(userId, productId);
     }

LikeRepositorydeleteByUserIdAndProductIdReturnCount 메서드 추가 필요.

필수 테스트: (1) 동일 좋아요 동시 생성 2건 시나리오에서 1건은 성공, 1건은 CONFLICT 응답 및 표준 에러 포맷 검증, (2) 동일 좋아요 동시 취소 시나리오에서 0건 또는 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/like/LikeService.java`
around lines 19 - 24, Change optimistic existence-check flows in
LikeService.create and LikeService.delete to be atomic and translate DB
constraint failures into standardized CONFLICT errors: remove the separate
existsByUserIdAndProductId check in create and instead try to save and catch
DataIntegrityViolationException, throwing new CoreException(ErrorType.CONFLICT,
"...") when a unique constraint is violated; for delete, replace the
exists-then-delete pattern with a single repository operation (add a
LikeRepository.deleteByUserIdAndProductIdReturnCount method that returns the
number of rows deleted) and branch on the returned count to decide whether to
call LikeFacade.decreaseLikeCount() (only when count == 1) or throw
CoreException(CONFLICT) as appropriate; update tests to assert one success/one
CONFLICT on concurrent creates and verify like count consistency for concurrent
deletes.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java-42-49 (1)

42-49: ⚠️ Potential issue | 🟠 Major

좋아요 카운트 UPDATE의 영향 행 수를 반환해 정합성을 검증해야 한다

존재하지 않거나 이미 삭제된 상품에 대한 UPDATE가 0건이어도 현재는 조용히 성공 처리되어, 운영 시 like 레코드와 집계 카운트가 어긋나는 문제를 숨길 수 있다.
수정안은 반환형을 int로 바꾸고 서비스 계층에서 1건 갱신 여부를 검증해 실패 시 CoreException으로 명시적으로 처리하는 것이다.
추가 테스트로 잘못된 상품 ID/삭제 상품 ID에 대해 영향 행 수 0일 때 예외가 발생하고 카운트가 변하지 않는지 검증이 필요하다.

수정 예시
-void incrementLikeCount(`@Param`("id") Long id);
+int incrementLikeCount(`@Param`("id") Long id);

-void decrementLikeCount(`@Param`("id") Long id);
+int decrementLikeCount(`@Param`("id") Long id);

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/product/ProductJpaRepository.java`
around lines 42 - 49, Change the return type of
ProductJpaRepository.incrementLikeCount and decrementLikeCount from void to int
so the executed UPDATE returns the affected row count; in the service method
that calls these (e.g., ProductService.likeProduct / unlikeProduct) check the
returned int equals 1 and throw a CoreException when it is not (0 or >1) to
signal a failed/invalid update; add tests that call the service with a
non-existent or deleted product ID and assert a CoreException is thrown and that
the product's likeCount remains unchanged.
apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java-32-35 (1)

32-35: ⚠️ Potential issue | 🟠 Major

삭제된 브랜드가 조회 경로로 재노출될 수 있다

운영 중 soft delete된 브랜드가 조회/수정 흐름에 다시 진입하면 삭제 정책과 데이터 정합성이 깨지는 문제가 발생한다.
수정안은 활성 데이터 전용 조회 메서드(findByIdAndDeletedAtIsNull)를 리포지토리 계층에 추가하고, 서비스에서 해당 메서드만 사용하도록 변경하는 것이다.
추가 테스트로 브랜드 soft delete 이후 findById, update 호출 시 모두 CoreException(ErrorType.NOT_FOUND)가 발생하는 통합 테스트가 필요하다.

수정 예시
 public Brand findById(Long id) {
-    return brandRepository.findById(id)
+    return brandRepository.findByIdAndDeletedAtIsNull(id)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다."));
 }
// BrandRepository / BrandJpaRepository 측 추가 예시
Optional<Brand> findByIdAndDeletedAtIsNull(Long id);

As per coding guidelines **/*Repository*.java: 쿼리 조건 누락/과다 조회를 점검한다.

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

In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java`
around lines 32 - 35, The current BrandService.findById uses
brandRepository.findById which can return soft-deleted brands; update the data
access to use a new repository method Optional<Brand>
findByIdAndDeletedAtIsNull(Long id) (add this to
BrandRepository/BrandJpaRepository) and change BrandService.findById to call
brandRepository.findByIdAndDeletedAtIsNull(id).orElseThrow(() -> new
CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); and add integration
tests asserting that after soft-delete both findById (service call) and update
attempts throw CoreException(ErrorType.NOT_FOUND).
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-26-27 (1)

26-27: ⚠️ Potential issue | 🟠 Major

findById에 soft delete 필터가 없어 삭제 상품이 재조회될 수 있다

운영에서 삭제된 상품이 다시 조회되면 주문/좋아요/관리자 화면 등 후속 유스케이스에서 비정상 데이터가 전파될 수 있다.
수정안은 활성 상품 전용 조회(findByIdAndDeletedAtIsNull)를 JPA 리포지토리에 추가하고 여기서 그 메서드를 사용하도록 변경하는 것이다.
추가 테스트로 상품 soft delete 이후 findById가 빈 결과를 반환하고, 서비스 findByIdNOT_FOUND를 던지는 통합 테스트가 필요하다.

수정 예시
 `@Override`
 public Optional<Product> findById(Long id) {
-    return productJpaRepository.findById(id);
+    return productJpaRepository.findByIdAndDeletedAtIsNull(id);
 }

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/product/ProductRepositoryImpl.java`
around lines 26 - 27, The current ProductRepositoryImpl.findById(Long id)
returns productJpaRepository.findById(id) without filtering out soft-deleted
rows; change it to call a JPA repository method that only returns active
products (e.g., productJpaRepository.findByIdAndDeletedAtIsNull(id)) and update
ProductRepositoryImpl.findById to return that Optional. Add the new repository
method signature (findByIdAndDeletedAtIsNull) to the JPA interface and update
any callers if needed. Add integration tests that soft-delete a Product, assert
repository.findById returns empty, and assert the service layer findById
translates that to a NOT_FOUND exception.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-40-44 (1)

40-44: ⚠️ Potential issue | 🟠 Major

중복 상품 ID 입력 시 500으로 실패할 수 있다

현재 Collectors.toMap은 동일 productId가 들어오면 IllegalStateException을 던져 운영에서 간헐적 500 오류를 유발한다.
수정안은 중복 상품 ID를 합산 처리하거나, 정책상 금지라면 CoreException(ErrorType.BAD_REQUEST)로 명시적으로 차단하는 것이다.
추가 테스트로 동일 상품 ID가 중복된 주문 요청에 대해 400 계열 에러(또는 합산 처리 결과)가 일관되게 동작하는 E2E 테스트가 필요하다.

수정 예시(합산 처리)
-Map<Long, Quantity> quantityByProductId = command.items().stream()
-        .collect(Collectors.toMap(
-                OrderCreateCommand.Item::productId,
-                item -> new Quantity(item.quantity())
-        ));
+Map<Long, Integer> summedQuantityByProductId = command.items().stream()
+        .collect(Collectors.toMap(
+                OrderCreateCommand.Item::productId,
+                OrderCreateCommand.Item::quantity,
+                Integer::sum
+        ));
+Map<Long, Quantity> quantityByProductId = summedQuantityByProductId.entrySet().stream()
+        .collect(Collectors.toMap(
+                Map.Entry::getKey,
+                e -> new Quantity(e.getValue())
+        ));

As per coding guidelines **/*.java: Optional/Stream 남용으로 가독성이 떨어지면 단순화하고, 예외 흐름이 명확한지 점검한다.

🤖 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 40 - 44, The current construction of quantityByProductId in
OrderFacade using Collectors.toMap will throw IllegalStateException on duplicate
productId; update the logic in OrderFacade to detect duplicate
OrderCreateCommand.Item.productId values and either (a) merge quantities by
summing into a single Quantity when collecting (i.e., combine values for the
same key) or (b) explicitly validate and throw new
CoreException(ErrorType.BAD_REQUEST) when duplicates are not allowed; ensure you
reference OrderCreateCommand.Item and Quantity for locating the code, and add an
E2E test that sends an order with duplicate productId entries asserting a 400
response (or asserting the merged quantity result) so behavior is deterministic.
apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java-40-42 (1)

40-42: ⚠️ Potential issue | 🟠 Major

인증 실패 로그에서 loginId 평문 노출을 제거해야 한다.

운영 관점에서 인증 실패 로그는 수집/보관 범위가 넓어 식별자 평문 노출 시 보안 사고 범위가 커진다.
수정안: 실패 로그에서 loginId를 제거하거나 마스킹된 값만 기록하는 것이 좋다.
추가 테스트: 테스트 로거 appender를 붙여 실패 시 로그 메시지에 원본 loginId가 포함되지 않는지 검증하는 단위 테스트를 추가하는 것이 좋다.

수정 예시
         try {
             userInfo = userFacade.authenticate(loginId, loginPw);
         } catch (CoreException e) {
-            log.warn("인증 실패 - loginId: {}, URI: {}", loginId, request.getRequestURI());
+            log.warn("인증 실패 - URI: {}", request.getRequestURI());
             throw e;
         }
As per coding guidelines `**/*.java`: "로깅 시 민감정보 노출 가능성을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java`
around lines 40 - 42, The current catch block in AuthInterceptor logs the
plaintext loginId (log.warn(...) referencing loginId and
request.getRequestURI()); remove the plaintext loginId from the log or replace
it with a masked value (e.g., hash or fixed token) and update the log call to
only include non-sensitive context (such as request.getRequestURI()); also add a
unit test that attaches a test logger/appender to AuthInterceptor to assert that
when CoreException is thrown the produced log does not contain the original
loginId string (verifying masking/removal).
apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java-42-49 (1)

42-49: ⚠️ Potential issue | 🟠 Major

페이지네이션 입력값 범위 검증을 컨트롤러에서 선행해야 한다.

운영 관점에서 size 상한이 없으면 과대 조회로 DB 부하가 급증할 수 있고, 음수/0 입력은 비일관 예외 응답으로 이어질 수 있다.
수정안: page >= 0, 1 <= size <= MAX_PAGE_SIZE를 컨트롤러에서 명시 검증하고, 위반 시 CoreException 기반 4xx로 반환하는 것이 좋다.
추가 테스트: E2E에서 page=-1, size=0, size=101 요청에 대해 상태 코드와 에러 포맷이 동일하게 유지되는지 검증이 필요하다.

수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
@@
 public class BrandAdminV1Controller implements BrandAdminV1ApiSpec {
 
+    private static final int MAX_PAGE_SIZE = 100;
+
@@
     public ApiResponse<BrandAdminV1Dto.BrandListResponse> getBrandList(
             `@RequestParam`(defaultValue = "0") int page,
             `@RequestParam`(defaultValue = "10") int size) {
+        if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "page/size 값이 유효하지 않습니다.");
+        }
         Page<BrandInfo> infoPage = brandAdminFacade.findAll(PageRequest.of(page, size));
         return ApiResponse.success(BrandAdminV1Dto.BrandListResponse.from(infoPage));
     }
As per coding guidelines `**/*Controller*.java`: "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 상태 코드와 에러 응답 포맷이 일관되는지 점검한다."
🤖 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/brand/BrandAdminV1Controller.java`
around lines 42 - 49, Add request-range validation in
BrandAdminV1Controller.getBrandList: enforce page >= 0 and 1 <= size <=
MAX_PAGE_SIZE (define MAX_PAGE_SIZE constant if not present) before calling
brandAdminFacade.findAll; on violation throw the existing CoreException (or a
subclass) that maps to a 4xx response so the controller returns a consistent
error format/status; keep validation in the controller (not service) and add E2E
tests for requests page=-1, size=0 and size=101 to assert identical error
status/format.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-24-33 (1)

24-33: ⚠️ Potential issue | 🟠 Major

상품 목록 API에 페이지 파라미터 범위 제한을 추가해야 한다.

운영 관점에서 size 무제한 요청은 트래픽 급증 시 즉시 성능 저하를 유발하고, 비정상 값은 에러 응답 일관성을 해칠 수 있다.
수정안: 컨트롤러에서 page/size를 검증하고 상한을 두어 과도한 조회를 차단하는 것이 좋다.
추가 테스트: E2E에서 size 상한 초과/음수 입력 시 표준화된 에러 응답을 반환하는지 검증이 필요하다.

수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
@@
 public class ProductV1Controller implements ProductV1ApiSpec {
 
+    private static final int MAX_PAGE_SIZE = 100;
+
@@
     public ApiResponse<ProductV1Dto.ProductListResponse> getProducts(
             `@RequestParam`(required = false) Long brandId,
             `@RequestParam`(defaultValue = "latest") String sort,
             `@RequestParam`(defaultValue = "0") int page,
             `@RequestParam`(defaultValue = "20") int size)
     {
+        if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "page/size 값이 유효하지 않습니다.");
+        }
         var pageable = PageRequest.of(page, size, ProductSortType.from(sort).toSort());
         Page<ProductInfo> result = productFacade.findAll(brandId, pageable);
         return ApiResponse.success(ProductV1Dto.ProductListResponse.from(result));
     }
As per coding guidelines `**/*Controller*.java`: "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 상태 코드와 에러 응답 포맷이 일관되는지 점검한다."
🤖 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/product/ProductV1Controller.java`
around lines 24 - 33, The getProducts method in ProductV1Controller does not
validate page/size parameters, allowing negative or excessively large sizes; add
request validation and bounds checks to prevent DoS and ensure consistent error
responses. Annotate the controller with `@Validated` and add Bean Validation
annotations on the method parameters (e.g., `@RequestParam` `@Min`(0) int page,
`@RequestParam` `@Min`(1) `@Max`(100) int size) or perform an explicit check in
getProducts that throws the standardized bad-request exception used by the
project when values are out of range; ensure ProductV1Controller.getProducts
returns the same ApiResponse/error format and add E2E tests that assert
standardized error responses for negative and over-limit size inputs.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java-31-33 (1)

31-33: ⚠️ Potential issue | 🟠 Major

브랜드명 매핑 누락 시 null 전파를 차단해야 한다.

운영 관점에서 일부 브랜드 ID가 매핑에서 누락되면 응답에 null이 섞이거나 매핑 과정에서 예외가 발생해 장애 원인 추적이 어려워진다.
수정안: 매핑 시 브랜드명이 없으면 즉시 예외를 발생시켜 데이터 정합성 문제를 빠르게 드러내는 것이 좋다.
추가 테스트: findNamesByIds 결과에서 특정 브랜드 ID가 누락된 상황을 모킹해, 의도한 예외 타입으로 실패하는 테스트를 추가하는 것이 좋다.

수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
@@
     public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
         Page<Product> products = productService.findAll(brandId, pageable);
         List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList();
         Map<Long, String> brandNameMap = brandService.findNamesByIds(brandIds);
-        return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId())));
+        return products.map(product -> {
+            String brandName = brandNameMap.get(product.getBrandId());
+            if (brandName == null) {
+                throw new CoreException(ErrorType.NOT_FOUND, "브랜드 정보를 찾을 수 없습니다.");
+            }
+            return ProductInfo.from(product, brandName);
+        });
     }
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/product/ProductFacade.java`
around lines 31 - 33, ProductFacade is currently mapping products to ProductInfo
using brandService.findNamesByIds but will propagate null brand names if a brand
ID is missing; update the mapping so that after calling
brandService.findNamesByIds(brandIds) you validate that every brandId from
products exists in brandNameMap (e.g., compute missingIds = brandIds -
brandNameMap.keySet()) and if any are missing throw a clear runtime exception
(IllegalStateException or custom DataIntegrityException) that names the missing
IDs before calling ProductInfo.from; also add a unit test that mocks
findNamesByIds to omit a brand ID and asserts the method throws the intended
exception to cover the failure path.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java-29-37 (1)

29-37: ⚠️ Potential issue | 🟠 Major

동시 요청 시 중복 좋아요 예외가 충돌 에러로 정규화되지 않을 수 있다

Line 29~37 흐름은 중복 체크와 저장이 분리되어 있어 경합 상황에서 DB 유니크 제약 위반 경로로 빠질 수 있다. 이 경우 운영에서는 간헐 500 응답으로 나타나 재시도 폭증과 장애성 알람을 유발한다. 중복 예외를 도메인 CONFLICT로 일관 변환하고, 가능하면 유니크 제약 기반으로 멱등 처리 경로를 단일화하는 것이 좋다. 추가로 동일 userId/productId 동시 2요청 테스트를 넣어 1건 성공 + 1건 CONFLICT + likeCount 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/application/like/LikeFacade.java`
around lines 29 - 37, The create flow in LikeFacade (methods: create,
productService.findById, likeService.create, productService.increaseLikeCount)
can surface DB unique-constraint errors as 500s under concurrent requests;
change the implementation so unique-constraint violations raised by
likeService.create are caught and normalized to your domain CONFLICT exception
(or return idempotent result) instead of bubbling as a 500, and prefer a
single-path handling (e.g., handle DB unique-constraint in likeService.create or
use an upsert/INSERT ... ON CONFLICT semantics) so only one success + one
CONFLICT occurs; also add a concurrency test that issues two simultaneous
create(userId, productId) calls asserting 1 success + 1 CONFLICT and likeCount
increments by 1 (use the same identifiers: LikeFacade.create,
likeService.create, productService.increaseLikeCount).
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-44-50 (1)

44-50: ⚠️ Potential issue | 🟠 Major

기간 역전 입력 검증이 없어 잘못된 요청이 정상 조회처럼 처리될 수 있다

Line 44~50에서 startAt > endAt 검증이 없으면 운영에서 사용자 입력 오류가 빈 결과로 숨겨져 장애 분석이 어려워진다. 컨트롤러 또는 파사드에서 범위를 선검증하고 CoreException(BAD_REQUEST)로 실패시키는 것이 안전하다. 추가로 GET /api/v1/orders?startAt=2099-01-01&endAt=2020-01-01 케이스를 E2E에 추가해 400과 표준 에러 포맷을 검증해야 한다.

🔧 검증 로직 예시
 public ApiResponse<OrderV1Dto.OrderListResponse> getOrders(
         `@LoginUser` UserInfo loginUser,
         `@RequestParam` `@DateTimeFormat`(iso = DateTimeFormat.ISO.DATE) LocalDate startAt,
         `@RequestParam` `@DateTimeFormat`(iso = DateTimeFormat.ISO.DATE) LocalDate endAt)
 {
+    if (startAt.isAfter(endAt)) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "조회 시작일은 종료일보다 늦을 수 없습니다.");
+    }
     return ApiResponse.success(OrderV1Dto.OrderListResponse.from(
             orderFacade.findAllByUserId(loginUser.id(), startAt, endAt)));
 }
Based on learnings 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.
🤖 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/OrderV1Controller.java`
around lines 44 - 50, The controller method getOrders in OrderV1Controller
currently lacks validation for inverted date ranges; add a pre-check in
getOrders (before calling orderFacade.findAllByUserId) that compares startAt and
endAt and if startAt.isAfter(endAt) throw a CoreException with BAD_REQUEST so
the error flows through ApiControllerAdvice and returns the unified 400 error
format; also add an E2E test for GET /api/v1/orders with
startAt=2099-01-01&endAt=2020-01-01 to assert a 400 response and standard error
payload.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java-22-26 (1)

22-26: ⚠️ Potential issue | 🟠 Major

페이지 파라미터 검증 부재로 500 응답 또는 과도한 조회 부하가 발생할 수 있다

Line 22~26은 page < 0, size <= 0, 과대 size를 제한하지 않아 운영에서 예외 누수(500)나 대량 조회 부하를 유발할 수 있다. 컨트롤러에서 범위를 검증하고 CoreException(BAD_REQUEST)로 표준 실패 응답을 반환하며, size 상한(예: 100)을 강제하는 것이 좋다. 추가로 page=-1, size=0, size=10000 케이스를 API 테스트에 추가해 400과 에러 포맷 일관성을 검증해야 한다.

🔧 검증 예시
 public ApiResponse<OrderAdminV1Dto.OrderListResponse> getOrders(
         `@RequestParam`(defaultValue = "0") int page,
         `@RequestParam`(defaultValue = "20") int size)
 {
+    if (page < 0 || size <= 0 || size > 100) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "page/size 파라미터가 유효하지 않습니다.");
+    }
     return ApiResponse.success(OrderAdminV1Dto.OrderListResponse.from(
             orderAdminFacade.findAll(PageRequest.of(page, size))));
 }
Based on learnings 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.
🤖 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/OrderAdminV1Controller.java`
around lines 22 - 26, Validate the incoming pagination params in
OrderAdminV1Controller before calling orderAdminFacade: check that page >= 0,
size > 0 and cap size to a sensible max (e.g., 100); on invalid values throw a
CoreException with BAD_REQUEST so ApiControllerAdvice produces the standard
error response; update the method that currently calls PageRequest.of(page,
size) to use the validated/capped values and add API tests for page=-1, size=0
and size=10000 asserting a 400 response and consistent error format.
apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java-27-29 (1)

27-29: ⚠️ Potential issue | 🟠 Major

인증 제외 경로가 과도해 향후 비인가 노출 위험이 있다

Line 27~29는 products/**, brands/** 전체를 인증 제외하므로, 운영 중 쓰기 엔드포인트가 같은 prefix에 추가되면 인증 없이 노출될 수 있다. 공개가 필요한 조회 API만 명시적으로 제외하거나, 인터셉터에서 GET 요청만 우회하도록 제한하는 방식으로 범위를 축소해야 한다. 추가로 보안 회귀 테스트로 POST/PUT/DELETE가 무인증일 때 401을 보장하는 케이스를 넣어야 한다.

🔧 범위 축소 예시
 registry.addInterceptor(authInterceptor)
         .addPathPatterns("/api/**")
         .excludePathPatterns("/api/v1/users/signup")
-        .excludePathPatterns("/api/v1/products/**")
-        .excludePathPatterns("/api/v1/brands/**");
+        .excludePathPatterns(
+                "/api/v1/products",
+                "/api/v1/products/{id}",
+                "/api/v1/brands",
+                "/api/v1/brands/{id}"
+        );
apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java-167-172 (1)

167-172: ⚠️ Potential issue | 🟠 Major

좋아요 테스트 픽스처를 도메인 경로로 생성해야 신뢰성이 유지된다.

운영 관점에서 Like만 직접 저장하면 Product.likeCount와 데이터가 어긋나 삭제 시 음수 카운트 같은 결함을 테스트가 놓칠 수 있다. 수정안은 Arrange에서 POST /api/v1/likes/{productId}를 호출하거나 LikeFacade.create(userId, productId)를 사용해 실제 쓰기 경로로 픽스처를 만드는 것이다. 추가 테스트로 삭제 성공 후 likeCount >= 0 검증과, 목록 조회에서 기대 likeCount 값 검증을 포함하는 것이 좋다.

수정 예시
- likeJpaRepository.save(new Like(userId, product.getId()));
+ testRestTemplate.exchange(
+         ENDPOINT_CREATE_LIKE.apply(product.getId()),
+         HttpMethod.POST,
+         new HttpEntity<>(headers),
+         new ParameterizedTypeReference<ApiResponse<LikeV1Dto.LikeResponse>>() {}
+ );

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

Also applies to: 224-229, 284-289

🤖 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/like/LikeV1ApiE2ETest.java`
around lines 167 - 172, The test currently creates a Like directly via
likeJpaRepository.save which can desync Product.likeCount; instead create the
fixture through the write path by calling the public endpoint POST
/api/v1/likes/{productId} (or call LikeFacade.create(userId, productId)) after
signUpAndGetUserId and product creation to ensure product.likeCount is updated;
replace the likeJpaRepository.save(...) call in LikeV1ApiE2ETest with that
API/facade call and then add assertions after delete and list flows to verify
Product.likeCount >= 0 and that returned list items include the expected
likeCount value (reference symbols: likeJpaRepository.save, LikeFacade.create,
POST /api/v1/likes/{productId}, Product.likeCount, signUpAndGetUserId,
createUserHeaders).
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-13-15 (1)

13-15: ⚠️ Potential issue | 🟠 Major

세 record의 List 필드에 compact constructor를 추가하여 방어적 복사를 적용하여야 한다.

OrderCreateRequest, OrderResponse, OrderListResponse 모두 List 필드를 그대로 보관하고 있다. 호출자가 생성자 호출 후 원본 리스트를 수정할 때 DTO 내부의 상태가 변동되어 로그/응답 재현성이 깨질 수 있다. 또한 응답 DTO를 여러 곳에서 공유할 때 예측 불가능한 상태 변경이 발생할 수 있다.

각 record에 compact constructor를 추가하여 List.copyOf(...)로 복사하고, null 값은 List.of()로 정규화하여야 한다.

수정 예시
 public record OrderCreateRequest(
         List<OrderItemRequest> items
-) {}
+) {
+    public OrderCreateRequest {
+        items = (items == null) ? List.of() : List.copyOf(items);
+    }
+}

 public record OrderResponse(
         Long orderId,
         ZonedDateTime createdAt,
         List<OrderItemResponse> items
 ) {
+    public OrderResponse {
+        items = (items == null) ? List.of() : List.copyOf(items);
+    }
     public static OrderResponse from(OrderInfo info) {
         return new OrderResponse(
                 info.id(),
                 info.createdAt(),
                 info.items().stream().map(OrderItemResponse::from).toList()
         );
     }
 }

 public record OrderListResponse(
         List<OrderResponse> orders
 ) {
+    public OrderListResponse {
+        orders = (orders == null) ? List.of() : List.copyOf(orders);
+    }
     public static OrderListResponse from(List<OrderInfo> infos) {
         return new OrderListResponse(
                 infos.stream().map(OrderResponse::from).toList()
         );
     }
 }

추가로 DTO 생성 후 원본 리스트를 수정할 때 내부 리스트가 변하지 않는지 검증하는 테스트를 작성하는 것이 좋다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java`
around lines 13 - 15, Add compact constructors to the record types
OrderCreateRequest, OrderResponse, and OrderListResponse that defensively copy
their List fields using List.copyOf(...) and normalize null inputs to List.of();
locate the record declarations (e.g., public record
OrderCreateRequest(List<OrderItemRequest> items) {}) and implement a compact
constructor body that replaces null lists with List.of() and assigns
List.copyOf(theList) to the record component to prevent external mutation of
internal state.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-26-31 (1)

26-31: ⚠️ Potential issue | 🟠 Major

register() 메서드에 @Transactional 어노테이션이 누락되어 있다.

brandService.findById()productService.register()가 별도 트랜잭션에서 실행될 경우, 브랜드 검증 후 상품 등록 사이에 브랜드가 삭제되면 데이터 정합성 문제가 발생할 수 있다. 운영 환경에서 동시성 이슈로 인한 고아 데이터가 생성될 가능성이 있다.

🔒 트랜잭션 추가 제안
     // 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05)
+    `@Transactional`
     public ProductInfo register(ProductRegisterCommand command) {
         Brand brand = brandService.findById(command.brandId()); // 브랜드 미존재 시 NOT_FOUND 예외
         Product product = productService.register(
                 command.brandId(), command.name(), command.price(), command.stock());
         return ProductInfo.from(product, brand.getName());
     }
🤖 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 26 - 31, The register method in ProductAdminFacade lacks
transactional protection: wrap the public ProductInfo
register(ProductRegisterCommand command) method with a transactional boundary
(add `@Transactional` on the method or the class) so brandService.findById(...)
and productService.register(...) run in the same transaction; ensure the class
imports and spring transaction configuration support `@Transactional` and keep the
method signature (register) and calls to brandService.findById and
productService.register unchanged.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-53-58 (1)

53-58: ⚠️ Potential issue | 🟠 Major

ProductAdminFacade의 update() 메서드에 @Transactional 어노테이션이 필요하다.

상품 수정 후 브랜드명 조회가 별도 트랜잭션에서 실행되므로, 브랜드 조회 실패 시에도 상품 수정은 이미 커밋된다. productService.update()@Transactional이지만, 해당 메서드 반환 시 트랜잭션이 종료되고 변경사항이 flush된다. 따라서 facade 레벨에서 @Transactional으로 감싸 전체 작업을 원자적으로 처리해야 한다. 운영 관점에서 부분 업데이트 발생 가능성을 제거하기 위해 update() 메서드에 @Transactional 추가가 필수다. 더불어 동일한 패턴의 register() 메서드도 일관성 있게 @Transactional으로 보호하는 것을 검토한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java`
around lines 53 - 58, Wrap the facade-level operations in a single transaction:
add the `@Transactional` annotation to ProductAdminFacade.update so the call to
productService.update(...) and the subsequent brandService.findById(...) execute
atomically; this prevents committing the product change if brand lookup fails.
Also review the ProductAdminFacade.register method and apply `@Transactional`
there if it follows the same pattern to ensure consistent transactional
boundaries across the facade.

Comment on lines +29 to +34
public void delete(Long userId, Long productId) {
if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
}
likeRepository.deleteByUserIdAndProductId(userId, productId);
}
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:

rg -n -C3 'deleteByUserIdAndProductId' --type=java

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

Length of output: 3498


🏁 Script executed:

fd 'LikeFacade.java' --type f

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

Length of output: 161


🏁 Script executed:

fd 'LikeRepository.java' --type f

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

Length of output: 160


🏁 Script executed:

rg -n 'class LikeService' --type=java -A 30

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

Length of output: 7102


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

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

Length of output: 3765


🏁 Script executed:

rg -n 'decreaseLikeCount|delete.*LikeService|void delete' apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java -A 10

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

Length of output: 613


🏁 Script executed:

rg -n '@Transactional|isolation' apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java

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

Length of output: 890


🏁 Script executed:

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

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

Length of output: 3294


🏁 Script executed:

rg -n 'decreaseLikeCount|increaseLikeCount' apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java -A 5

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

Length of output: 581


좋아요 삭제 메서드를 int 반환으로 변경하고 선조회를 제거해야 한다.

동시 취소 요청 시 두 트랜잭션이 모두 exist() 확인을 통과할 수 있고, 두 번째 삭제가 실제 영향 행 수 0건이어도 void 시그니처로는 이를 감지할 수 없다. 결과적으로 LikeFacade.delete()에서 decreaseLikeCount()가 두 번 호출되어 likeCount가 1회 이상 불일치하는 운영 장애가 발생할 수 있다. deleteByUserIdAndProductId()의 반환값을 int로 변경하고, 0이면 NOT_FOUND 예외를 던지도록 수정해야 한다. 이는 멱등성을 보장하고 동시 요청에 대한 정확한 에러 처리를 가능하게 한다.

수정 예시
@@
-    public void delete(Long userId, Long productId) {
-        if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
-            throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
-        }
-        likeRepository.deleteByUserIdAndProductId(userId, productId);
+    public void delete(Long userId, Long productId) {
+        int deleted = likeRepository.deleteByUserIdAndProductId(userId, productId);
+        if (deleted == 0) {
+            throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
+        }
     }
// LikeRepository 도메인 인터페이스
int deleteByUserIdAndProductId(Long userId, Long productId);

동시 삭제 2건에서 1건만 성공하고 likeCount가 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/like/LikeService.java`
around lines 29 - 34, Change LikeService.delete to stop pre-checking
existsByUserIdAndProductId and instead call
likeRepository.deleteByUserIdAndProductId and interpret its int return value:
update the repository signature so deleteByUserIdAndProductId returns int, call
that from LikeService.delete, and if the returned int is 0 throw new
CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); otherwise proceed normally;
also add an integration test that issues two concurrent deletes through
LikeFacade.delete and asserts only one delete succeeded and likeCount decreased
exactly once.

Comment on lines +16 to +31
private static final String ADMIN_HEADER = "X-Loopers-Ldap";
private static final String ADMIN_LDAP_VALUE = "loopers.admin";

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
String adminString = request.getHeader(ADMIN_HEADER);

if(adminString == null || adminString.isBlank()){
log.warn("관리자 인증 헤더 누락 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr());
throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 누락되었습니다.");
}

if(!adminString.equals(ADMIN_LDAP_VALUE)){
log.warn("관리자 인증 실패 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr());
throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증에 실패했습니다.");
}
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

정적 헤더 값 기반 관리자 인증은 우회 가능성이 높아 권한 탈취 위험이 크다.

운영 관점에서 클라이언트가 동일 헤더를 임의 주입하면 /api-admin/** 보호가 사실상 무력화될 수 있다. 수정안은 헤더 문자열 자체를 권한 근거로 쓰지 말고, 선행 인증 단계에서 검증된 사용자 컨텍스트의 관리자 권한으로 판별하도록 변경하는 것이다. 최소 임시 대응으로도 값 하드코딩은 제거하고 시크릿 설정 주입 + 게이트웨이 단에서 외부 헤더 제거가 필요하다.

추가 테스트로 (1) 헤더만 있는 미인증 요청 차단, (2) 인증되었지만 비관리자 권한 차단, (3) 관리자 권한 통과, (4) 헤더 스푸핑 시도 차단 시나리오를 E2E로 보강해야 한다.

🤖 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/interceptor/AdminAuthInterceptor.java`
around lines 16 - 31, The current AdminAuthInterceptor uses a hardcoded header
value (ADMIN_HEADER / ADMIN_LDAP_VALUE) which is insecure; change preHandle in
AdminAuthInterceptor to stop trusting the header string as proof of admin and
instead validate admin privilege from the authenticated user context (e.g.,
retrieve principal/Authentication from SecurityContextHolder or the existing
auth middleware) and check for an admin role/authority; remove the hardcoded
ADMIN_LDAP_VALUE usage (or move any secret into injected configuration if a
temporary header-based secret is required), ensure requests with only the header
but no authenticated principal are rejected, and coordinate with the gateway to
strip external admin headers; add E2E tests covering: header-only
unauthenticated request blocked, authenticated non-admin blocked, authenticated
admin allowed, and header-spoofing attempts blocked.

@looper88888
Copy link
Contributor

@coderabbitai

🏷 Product / Brand 도메인
상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다.
상품의 정렬 조건(latest, price_asc, likes_desc) 을 고려한 조회 기능을 설계했다
상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다
재고의 음수 방지 처리는 도메인 레벨에서 처리된다
👍 Like 도메인
좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다
상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다
단위 테스트에서 좋아요 등록/취소 흐름을 검증했다
🛒 Order 도메인
주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다
주문 시 상품의 재고 차감을 수행한다
재고 부족 예외 흐름을 고려해 설계되었다
단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다
🧩 도메인 서비스
도메인 내부 규칙은 Domain Service에 위치시켰다
상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer 에서 처리했다
복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다
도메인 서비스는 상태 없이, 동일한 도메인 경계 내의 도메인 객체의 협력 중심으로 설계되었다
🧱 소프트웨어 아키텍처 & 설계
전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다
Application → Domain ← Infrastructure
Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다
핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다
Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다
패키지는 계층 + 도메인 기준으로 구성되었다 (/domain/order, /application/like 등)
테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다

위 체크리스트가 맞는지 확인해

@coderabbitai
Copy link

coderabbitai bot commented Feb 28, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 전체 패키지 구조 확인
echo "=== 패키지 구조 ==="
tree -L 4 -d src/main/java/org/loopers/commerce || find src/main/java/org/loopers/commerce -type d | head -50

echo -e "\n=== Product 엔티티 ==="
fd -e java Product.java src/main/java --exec echo "File: {}" \; --exec cat {}

echo -e "\n=== Brand 엔티티 ==="
fd -e java Brand.java src/main/java --exec echo "File: {}" \; --exec cat {}

echo -e "\n=== Like 엔티티 ==="
fd -e java Like.java src/main/java --exec echo "File: {}" \; --exec cat {}

echo -e "\n=== Order 관련 엔티티 ==="
fd -e java "Order.java|OrderItem.java" src/main/java --exec echo "File: {}" \; --exec cat {}

Length of output: 639


🏁 Script executed:

#!/bin/bash
# Repository 인터페이스와 구현체 위치 확인
echo "=== Repository 인터페이스 (domain 레이어) ==="
find src/main/java -path "*/domain/*" -name "*Repository.java" -type f

echo -e "\n=== Repository 구현체 (infrastructure 레이어) ==="
find src/main/java -path "*/infrastructure/*" -name "*Repository*.java" -type f

echo -e "\n=== ProductRepository 인터페이스 내용 ==="
fd -e java ProductRepository.java src/main/java/org/loopers/commerce/domain --exec cat {}

echo -e "\n=== ProductJpaRepository 구현체 내용 ==="
fd -e java ProductJpaRepository.java src/main/java --exec cat {}

Length of output: 483


🏁 Script executed:

#!/bin/bash
# Service 및 Facade 파일 확인
echo "=== Domain Service 파일들 ==="
find src/main/java -path "*/domain/*" -name "*Service.java" -type f

echo -e "\n=== Application Facade 파일들 ==="
find src/main/java -path "*/application/*" -name "*Facade.java" -type f

echo -e "\n=== ProductService 내용 ==="
fd -e java ProductService.java src/main/java/org/loopers/commerce/domain --exec cat {}

Length of output: 312


🏁 Script executed:

#!/bin/bash
# 재고 차감 및 음수 방지 로직 확인
echo "=== Product 엔티티의 재고 관련 메서드 ==="
ast-grep --pattern 'class Product {
  $$$
  decreaseStock($$$) {
    $$$
  }
  $$$
}'

echo -e "\n=== Stock 값 객체 ==="
fd -e java Stock.java src/main/java --exec cat {}

echo -e "\n=== verifyAndDecreaseStock 메서드 ==="
rg -A 20 "verifyAndDecreaseStock" src/main/java

Length of output: 241


🏁 Script executed:

#!/bin/bash
# 정렬 조건 및 조회 기능 확인
echo "=== 상품 정렬 조건 관련 코드 ==="
rg -C 5 "latest|price_asc|likes_desc|SortBy|OrderBy" src/main/java

echo -e "\n=== Product DTO 브랜드/좋아요 포함 여부 ==="
fd -e java "ProductV1Dto.java|ProductResponse.java" src/main/java --exec cat {}

Length of output: 212


🏁 Script executed:

#!/bin/bash
# Like 도메인 테스트 확인
echo "=== Like 도메인 테스트 파일들 ==="
find src/test/java -name "*Like*Test.java" -type f

echo -e "\n=== LikeServiceTest 내용 ==="
fd -e java LikeServiceTest.java src/test/java --exec cat {}

echo -e "\n=== Order 도메인 테스트 파일들 ==="
find src/test/java -name "*Order*Test.java" -type f | head -10

Length of output: 278


🏁 Script executed:

#!/bin/bash
# ProductFacade 확인 (Application Layer의 조합 로직)
echo "=== ProductFacade 내용 ==="
fd -e java ProductFacade.java src/main/java --exec cat {}

echo -e "\n=== OrderFacade 내용 ==="
fd -e java OrderFacade.java src/main/java --exec cat {}

Length of output: 250


🏁 Script executed:

#!/bin/bash
# 값 객체(VO) 확인
echo "=== Money 값 객체 ==="
fd -e java Money.java src/main/java --exec cat {}

echo -e "\n=== Quantity 값 객체 ==="
fd -e java Quantity.java src/main/java --exec cat {}

Length of output: 243


🏁 Script executed:

#!/bin/bash
# 현재 디렉토리 구조 확인
echo "=== 현재 디렉토리 ==="
pwd

echo -e "\n=== 최상위 디렉토리 구조 ==="
ls -la

echo -e "\n=== Java 소스 파일 찾기 ==="
find . -name "*.java" -type f | head -20

echo -e "\n=== 디렉토리 구조 (최대 3레벨) ==="
tree -L 3 -d . 2>/dev/null || find . -type d -maxdepth 3 | head -50

Length of output: 3488


🏁 Script executed:

#!/bin/bash
# 도메인 엔티티들 확인
echo "=== Product 엔티티 ==="
find ./apps/commerce-api -name "Product.java" -path "*/domain/*" -exec cat {} \;

echo -e "\n=== Brand 엔티티 ==="
find ./apps/commerce-api -name "Brand.java" -path "*/domain/*" -exec cat {} \;

echo -e "\n=== Like 엔티티 ==="
find ./apps/commerce-api -name "Like.java" -path "*/domain/*" -exec cat {} \;

echo -e "\n=== Order 엔티티 ==="
find ./apps/commerce-api -name "Order.java" -path "*/domain/*" -exec cat {} \;

echo -e "\n=== OrderItem 엔티티 ==="
find ./apps/commerce-api -name "OrderItem.java" -path "*/domain/*" -exec cat {} \;

Length of output: 10628


🏁 Script executed:

#!/bin/bash
# Repository 인터페이스와 구현체 확인
echo "=== Repository 인터페이스 (domain 레이어) ==="
find ./apps/commerce-api -path "*/domain/*" -name "*Repository.java" -type f

echo -e "\n=== Repository 구현체 (infrastructure 레이어) ==="
find ./apps/commerce-api -path "*/infrastructure/*" -name "*Repository*.java" -type f

echo -e "\n=== ProductRepository 인터페이스 ==="
find ./apps/commerce-api -name "ProductRepository.java" -path "*/domain/*" -exec cat {} \;

Length of output: 2546


🏁 Script executed:

#!/bin/bash
# Domain Service 파일들 확인
echo "=== Domain Service 파일들 ==="
find ./apps/commerce-api -path "*/domain/*" -name "*Service.java" -type f

echo -e "\n=== ProductService 내용 ==="
find ./apps/commerce-api -name "ProductService.java" -path "*/domain/*" -exec cat {} \;

echo -e "\n=== BrandService 내용 ==="
find ./apps/commerce-api -name "BrandService.java" -path "*/domain/*" -exec cat {} \;

Length of output: 6668


🏁 Script executed:

#!/bin/bash
# 재고 관련 값 객체 및 로직 확인
echo "=== Stock 값 객체 ==="
find ./apps/commerce-api -name "Stock.java" -exec cat {} \;

echo -e "\n=== Money 값 객체 ==="
find ./apps/commerce-api -name "Money.java" -exec cat {} \;

echo -e "\n=== Quantity 값 객체 ==="
find ./apps/commerce-api -name "Quantity.java" -exec cat {} \;

Length of output: 2336


🏁 Script executed:

#!/bin/bash
# 정렬 조건 관련 코드 확인
echo "=== 정렬 관련 코드 ==="
rg -n "latest|price_asc|likes_desc" ./apps/commerce-api/src/main/java --type java -C 3

echo -e "\n=== ProductSortBy enum/class ==="
find ./apps/commerce-api -name "*Sort*.java" -exec echo "File: {}" \; -exec cat {} \;

Length of output: 6443


🏁 Script executed:

#!/bin/bash
# ProductInfo/DTO 확인 (브랜드 정보, 좋아요 수 포함 여부)
echo "=== ProductInfo ==="
find ./apps/commerce-api -name "ProductInfo.java" -exec cat {} \;

echo -e "\n=== ProductV1Dto ==="
find ./apps/commerce-api -name "ProductV1Dto.java" -exec cat {} \;

Length of output: 2193


🏁 Script executed:

#!/bin/bash
# Application Facade 확인
echo "=== ProductFacade ==="
find ./apps/commerce-api -name "ProductFacade.java" -exec cat {} \;

echo -e "\n=== OrderFacade ==="
find ./apps/commerce-api -name "OrderFacade.java" -exec cat {} \;

Length of output: 5021


🏁 Script executed:

#!/bin/bash
# 테스트 파일 확인
echo "=== Like 테스트 파일들 ==="
find ./apps/commerce-api/src/test -name "*Like*Test.java" -type f

echo -e "\n=== Order 테스트 파일들 ==="
find ./apps/commerce-api/src/test -name "*Order*Test.java" -type f

echo -e "\n=== LikeServiceTest 일부 ==="
find ./apps/commerce-api/src/test -name "LikeServiceTest.java" -exec head -100 {} \;

echo -e "\n=== OrderServiceTest 일부 ==="
find ./apps/commerce-api/src/test -name "OrderServiceTest.java" -exec head -100 {} \;

Length of output: 701


@looper88888

체크리스트의 모든 항목을 코드베이스와 대조하여 검증한 결과, 모든 항목이 구현 요구사항을 충족하고 있다.


✅ 검증 결과

🏷 Product / Brand 도메인

  1. 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다

    • ProductInfobrandName, likeCount 포함
    • ProductV1Dto.ProductResponse에도 동일 필드 노출
  2. 정렬 조건(latest, price_asc, likes_desc)을 고려한 조회 기능 설계

    • ProductSortType enum에 3가지 정렬 기준 정의
    • toSort() 메서드로 JPA Sort 객체 변환 (Line 27-31)
  3. 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다

    • Product 엔티티에 Stock 임베디드 타입 포함
    • decreaseStock(Quantity) 메서드로 재고 차감 (Product.java Line 52-54)
    • ProductService.verifyAndDecreaseStock()에서 비관적 락 기반 원자적 재고 차감
  4. 재고의 음수 방지 처리는 도메인 레벨에서 처리된다

    • Stock VO 생성자에서 quantity < 0 검증
    • Stock.decrease()에서 newQuantity < 0 검증 후 예외 발생 (Stock.java Line 21-24)

👍 Like 도메인

  1. 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리

    • Like 엔티티가 독립적으로 존재, (userId, productId) UNIQUE 제약
    • LikeService, LikeRepository 도메인 계층에 존재
  2. 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공

    • Product 엔티티에 likeCount 필드 관리
    • ProductInfoProductV1Dto에서 likeCount 노출
  3. 단위 테스트에서 좋아요 등록/취소 흐름 검증

    • LikeTest, LikeServiceIntegrationTest, LikeV1ApiE2ETest 존재

🛒 Order 도메인

  1. 주문은 여러 상품을 포함하며, 각 상품의 수량을 명시

    • OrderList<OrderItem> 보유
    • OrderItemQuantity VO 포함 (최소 1 이상 검증)
  2. 주문 시 상품의 재고 차감 수행

    • OrderFacade.create()에서 productService.verifyAndDecreaseStock() 호출
    • SELECT FOR UPDATE 비관적 락으로 재고 검증 후 dirty checking으로 차감
  3. 재고 부족 예외 흐름 고려

    • ProductService.verifyAndDecreaseStock()에서 hasEnough() 검증
    • 재고 부족 시 CoreException(BAD_REQUEST, "상품의 재고가 부족합니다") 발생 (ProductService.java Line 91-93)
  4. 단위 테스트에서 정상/예외 주문 흐름 검증

    • OrderTest, OrderServiceIntegrationTest, OrderV1ApiE2ETest, OrderAdminV1ApiE2ETest 존재

🧩 도메인 서비스

  1. 도메인 내부 규칙은 Domain Service에 위치

    • ProductService, BrandService, LikeService, OrderService 모두 domain 패키지에 위치
    • 재고 검증, 중복 확인, 좋아요 수 증감 등 비즈니스 규칙 포함
  2. 상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer에서 처리

    • ProductFacade.findById()에서 ProductServiceBrandService를 조합하여 ProductInfo 생성
  3. 복합 유스케이스는 Application Layer에 존재, 도메인 로직은 위임

    • ProductFacade, OrderFacade, LikeFacade 등이 application 패키지에서 여러 도메인 서비스 조율
    • 예: OrderFacade.create()ProductService, BrandService, OrderService 조합
  4. 도메인 서비스는 상태 없이, 동일 도메인 경계 내 객체 협력 중심

    • 모든 Domain Service가 @Component로 stateless 설계
    • Repository를 주입받아 도메인 객체 조회/저장 위임

🧱 소프트웨어 아키텍처 & 설계

  1. Application → Domain ← Infrastructure 아키텍처 구성

    • application: Facade (ProductFacade, OrderFacade 등)
    • domain: Entity, VO, Service, Repository 인터페이스
    • infrastructure: Repository 구현체 (ProductRepositoryImpl, ProductJpaRepository)
  2. Application Layer는 도메인 객체 조합하여 흐름 orchestration

    • ProductFacade.findAll()에서 ProductServiceBrandService 조율
    • OrderFacade.create()에서 재고 차감 → 브랜드명 조회 → 주문 생성 순차 처리
  3. 핵심 비즈니스 로직은 Entity, VO, Domain Service에 위치

    • Product.decreaseStock(), Stock.decrease(), Stock.hasEnough() 등 엔티티/VO에 로직
    • ProductService.verifyAndDecreaseStock(), BrandService.register() 등 도메인 서비스에 규칙
  4. Repository 인터페이스는 Domain Layer, 구현체는 Infra에 위치

    • 인터페이스: domain/product/ProductRepository.java
    • 구현체: infrastructure/product/ProductRepositoryImpl.java
    • JPA 인터페이스: infrastructure/product/ProductJpaRepository.java
  5. 패키지는 계층 + 도메인 기준으로 구성

    • com.loopers.domain.product, com.loopers.application.product, com.loopers.infrastructure.product
    • com.loopers.domain.order, com.loopers.application.order 등 일관된 구조
  6. 테스트는 외부 의존성 분리, Fake/Stub 사용 가능한 구조

    • 도메인 단위 테스트: LikeTest, OrderTest
    • 통합 테스트: LikeServiceIntegrationTest, OrderServiceIntegrationTest
    • E2E 테스트: LikeV1ApiE2ETest, OrderV1ApiE2ETest
    • Repository 인터페이스 기반 설계로 Mock/Fake 교체 가능

🎯 종합 평가

모든 체크리스트 항목이 DDD 원칙과 클린 아키텍처를 준수하며 구현되었다. 특히 다음 사항이 우수하다:

  • 도메인 순수성: Stock, Money, Quantity 등 VO에서 불변성과 검증 로직 캡슐화
  • 계층 간 의존성 역전: Repository 인터페이스가 domain에 위치, 구현체가 infrastructure에 위치하여 DIP 준수
  • 동시성 안전성: 비관적 락과 dirty checking으로 재고 동시성 문제 해결
  • 이력 보존: OrderItem 스냅샷 패턴으로 주문 후 상품 정보 변경에도 주문 이력 무결성 보장

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.

3 participants