Skip to content

Round3 ecommerce implementation#127

Merged
hey-sion merged 45 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round3-ecommerce-implementation
Mar 4, 2026
Merged

Round3 ecommerce implementation#127
hey-sion merged 45 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round3-ecommerce-implementation

Conversation

@hey-sion
Copy link

@hey-sion hey-sion commented Feb 27, 2026

📌 Summary

  • 배경: Round 2에서 설계한 4개 도메인(브랜드, 상품, 좋아요, 주문)을 TDD로 구현
  • 목표: Clean Architecture / DDD 기반으로 Brand → Product → Like → Order 순서로 의존 관계에 따라 점진적 구현
  • 결과: 전체 도메인 API(어드민/사용자) 구현 완료, E2E 테스트 커버리지 확보, 아키텍처 일관성 정비

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: Round 1에서 User 도메인만 존재, 이커머스 핵심 기능 전무
  • 문제(또는 리스크): 도메인 간 의존(Brand→Product→Like→Order) 처리 방식, 레이어 간 의존 방향, 테스트 전략 등 구조적 결정이 필요
  • 성공 기준(완료 정의): 전 도메인 API 정상 동작, 단위/E2E 테스트 통과, 레이어 의존 방향 위반 없음

선택지와 결정

1. Service 레이어 위치 — domain/ vs application/

  • 고려한 대안:
    • A: Repository 호출 Service는 domain/에 배치 (DDD 멘토 권장 방향)
    • B: Repository 호출 Service는 application/에 배치
  • 최종 결정: B — BrandService, ProductService 등 모두 application/ 하위 유지
  • 근거: Repository를 통한 CRUD 조율은 도메인의 순수하고 본질적인 불변식이 아님. 도메인 레이어에는 외부 상태(DB)에 의존하지 않는 순수 규칙만 남기고, "존재 확인 → 상태 변경 → 저장"의 유스케이스 흐름은 application에 배치하는 것이 레이어 책임에 더 정직함

2. BrandFacade / ProductFacade 도입 및 Controller 진입점 전략

  • 고려한 대안:
    • A: Service 간 직접 참조 (BrandService → ProductService)
    • B: Facade를 상위 조율자로 신설 후 Controller 단일 진입점으로 사용
    • C: Facade는 오케스트레이션이 필요한 경우에만 사용, 단순 CRUD는 Service 직접 호출
  • 최종 결정: C — BrandFacade(브랜드 연쇄 삭제 조율) + ProductFacade(브랜드 검증/좋아요 연쇄 삭제) 도입. 단, Controller에서 Facade를 단일 진입점으로 강제하지 않고 오케스트레이션 필요 여부로 구분
  • 근거: ProductService → BrandService 의존이 이미 존재하므로 BrandService → ProductService를 추가하면 순환 의존 발생. Facade가 상위에서 이를 끊음. A(직접 참조)는 순환 위험, B(단일 진입점)는 오케스트레이션 없는 단순 위임 메서드가 Facade에 쌓여 비대해짐
  • 비용: Controller가 Facade와 Service 중 어느 것을 호출할지 직접 판단해야 해서 B안보다 진입 경로의 일관성이 낮음

3. 헤더 기반 인증 — 인터셉터/ArgumentResolver 도입

  • 고려한 대안:
    • A: Controller마다 X-Loopers-LoginId/LoginPw 헤더를 받아 UserService 직접 호출
    • B: LoginUserInterceptor + LoginUserArgumentResolver로 인증 분리
  • 최종 결정: B — @LoginUser Long userId 어노테이션으로 Controller에서 인증 책임 제거
  • 근거: Controller마다 UserService를 직접 호출하면 인증 로직이 산재되고 IDOR 취약점(loginId만으로 타인 정보 조회 가능) 발생. 인터셉터로 분리하면 secure-by-default — 새 API 추가 시 자동으로 인증 적용, 공개 엔드포인트만 명시적 제외
  • 비용: opt-out 패턴이므로 공개 엔드포인트 추가 시 excludePathPatterns 누락 시 의도치 않게 인증이 걸릴 수 있음

4. LikeCount — 카운터 컬럼 방식

  • 고려한 대안:
    • A: 실시간 COUNT 쿼리
    • B: Product.likeCount 카운터 컬럼
    • C: Redis 카운터
  • 최종 결정: B — 같은 트랜잭션 내에서 카운터 관리로 정합성 유지
  • 근거: 실시간 COUNT(A)보다 읽기 성능 우위. Redis(C)보다 인프라 복잡도가 낮음. 좋아요 저장/삭제와 카운터 변경을 같은 트랜잭션으로 묶어 정합성 보장
  • 비용: likeCountlikes 테이블의 파생 데이터 — 동기화 누락 시 카운터 오차 발생 가능. 브랜드 삭제 연쇄 경로 등 예외 경로에서 누락되지 않도록 주의 필요

5. 좋아요 취소 멱등화

  • 고려한 대안:
    • A: 미존재 취소 시 404 반환
    • B: 200 OK (멱등 처리)
  • 최종 결정: B — requirements 4.2 준수. 취소 목적은 "좋아요 없음 보장"이므로 이미 달성된 상태는 오류가 아니라고 판단함

6. 좋아요 목록 조회 URL — /me 패턴

  • 고려한 대안:
    • A: GET /api/v1/users/{userId}/likes
    • B: GET /api/v1/users/me/likes
  • 최종 결정: B — 디비 고유값 노출하지 않도록 하고, 기존 /api/v1/users/me 패턴과 일관성 유지

7. 어드민 상품 목록 조회 — 삭제 상품 필터 위치

  • 고려한 대안:
    • A: 서비스 계층에서 후처리 필터링
    • B: 저장소 조회 조건(Query)에서 deletedAt IS NULL 필터링
  • 최종 결정: B — 불필요 데이터를 DB에서 올리지 않아 성능/메모리 효율. 삭제 정책을 Repository가 책임져 Service 레이어의 중복 필터링 불필요
  • 비용: 조회 정책(deletedAt IS NULL)이 Repository 계층에 포함됨. ProductRepositoryImplInMemoryProductRepository 양쪽에 동일 조건을 유지해야 함

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api / Brand, Product, Like, Order, User(인증 개선)

  • 주요 신규 파일 (도메인별):

    Brand

    • domain/brand/Brand.java, BrandRepository.java
    • application/brand/BrandService.java, BrandFacade.java, BrandInfo.java
    • infrastructure/brand/BrandRepositoryImpl.java, BrandJpaRepository.java
    • interfaces/api/brand/BrandV1Controller.java, AdminBrandV1Controller.java, BrandV1Dto.java

    Product

    • domain/product/Product.java (visibility enum, likeCount, active 네이밍 포함), ProductRepository.java, ProductOrder.java
    • application/product/ProductService.java, ProductFacade.java, ProductInfo.java, ProductSort.java, ProductCreateCommand.java, ProductUpdateCommand.java
    • infrastructure/product/ProductRepositoryImpl.java (QueryDSL, deletedAt IS NULL 필터)
    • interfaces/api/product/ProductV1Controller.java, AdminProductV1Controller.java, ProductV1Dto.java

    Like

    • domain/like/Like.java (BaseEntity 미상속, hard delete), LikeRepository.java
    • application/like/LikeService.java, LikeFacade.java, LikeInfo.java, LikedProductInfo.java
    • infrastructure/like/LikeRepositoryImpl.java, LikeJpaRepository.java
    • interfaces/api/like/LikeV1Controller.java, LikeV1Dto.java

    Order

    • domain/order/Order.java, OrderItem.java, OrderRepository.java, OrderItemRepository.java
    • application/order/OrderService.java, OrderFacade.java, OrderInfo.java, OrderItemInfo.java, OrderCreateCommand.java, OrderItemCommand.java
    • infrastructure/order/OrderRepositoryImpl.java, OrderItemRepositoryImpl.java
    • interfaces/api/order/OrderV1Controller.java, AdminOrderV1Controller.java, OrderV1Dto.java

    인증

    • interfaces/api/auth/LoginUser.java, LoginUserInterceptor.java, LoginUserArgumentResolver.java
    • interfaces/config/WebConfig.java

    공통

    • interfaces/api/PageResponse.javaPage<T> 역직렬화 문제 해결용 record DTO
  • 주요 수정 파일:

    • application/user/SignUpService.java — SignUpValidator 제거, 검증 로직 레이어 분리
    • application/user/UserService.javaauthenticate() 추가, getMyInfo(Long userId) 변경
    • application/brand/BrandFacade.java — 좋아요 연쇄 삭제 추가
    • application/product/ProductService.java — BrandService 의존 제거, likeCount 증감, 재고 차감
    • infrastructure/product/ProductRepositoryImpl.java — 어드민 목록 조회 deletedAt IS NULL 필터 추가
    • test/../InMemoryProductRepository.java — 테스트 더블 동일 정책 적용
    • test/../AdminProductV1ApiE2ETest.java — 삭제 상품 미노출 E2E 케이스 추가

주요 컴포넌트 책임

컴포넌트 책임
BrandFacade 브랜드 삭제 시 상품 soft delete → 좋아요 hard delete 연쇄 조율 (@Transactional)
ProductFacade 오케스트레이션이 필요한 작업만 담당: 등록(브랜드 유효성 검증), 삭제(좋아요 연쇄 삭제), 사용자 상품 조회(브랜드명 enrichment)
LikeFacade 상품 유효성 검증 + likeCount 증감 + 좋아요한 상품 정보 조합
OrderFacade 주문 항목 검증 → 상품 조회 → 재고 확인/차감 → 주문 저장 원자적 처리
LoginUserInterceptor /api/v1/** 경로 전처리 — 헤더 검증 + userId 주입
ProductRepositoryImpl QueryDSL 기반 동적 쿼리 (brandId 필터, 정렬, 페이징, deletedAt IS NULL)

🔁 Flow Diagram

브랜드 삭제 연쇄 흐름

sequenceDiagram
  autonumber
  actor Admin as 어드민
  participant AdminBrandV1Controller
  participant BrandFacade
  participant ProductService
  participant LikeService
  participant BrandService

  Admin->>AdminBrandV1Controller: DELETE /api-admin/v1/brands/{brandId}
  AdminBrandV1Controller->>BrandFacade: delete(brandId)
  BrandFacade->>ProductService: getProductIdsByBrandId(brandId)
  ProductService-->>BrandFacade: productIds
  BrandFacade->>LikeService: deleteAllByProductIds(productIds) [hard delete]
  BrandFacade->>ProductService: deleteAllByBrandId(brandId) [soft delete]
  BrandFacade->>BrandService: delete(brandId) [soft delete]
  BrandFacade-->>AdminBrandV1Controller: 완료
  AdminBrandV1Controller-->>Admin: 204 No Content
Loading

주문 생성 흐름

sequenceDiagram
  autonumber
  actor User as 사용자
  participant OrderV1Controller
  participant OrderFacade
  participant OrderService
  participant ProductService

  User->>OrderV1Controller: POST /api/v1/orders
  Note right of OrderV1Controller: LoginUserInterceptor 인증
  OrderV1Controller->>OrderFacade: createOrder(command)
  OrderFacade->>OrderService: validateItems(items) [빈 항목, 수량≤0, 중복 검증]
  OrderFacade->>ProductService: getActiveProductsByIdsOrThrow(productIds)
  Note right of ProductService: 미존재/삭제/HIDDEN → NOT_FOUND
  OrderFacade->>ProductService: decreaseStock(items)
  Note right of ProductService: DB 레벨 원자적 차감 (JPQL @Modifying)<br/>재고 부족 시 → CoreException(INSUFFICIENT_STOCK)
  OrderFacade->>OrderService: placeOrder(userId, snapshots)
  OrderService-->>OrderFacade: OrderInfo
  OrderFacade-->>OrderV1Controller: OrderInfo
  OrderV1Controller-->>User: 201 Created
Loading

hey-sion and others added 30 commits February 19, 2026 18:56
테스트 파일이 대상 클래스와 다른 패키지에 위치해 발생하는 구조적 불일치를 해소.
단순 생성/삭제 대신 git mv를 사용해 git 이력(rename)을 보존하며 이동.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProductOrder(domain) 신설: Repository 계약용 정렬 타입
- ProductSort(application) 유지: API 파라미터용 타입
- ProductSort.toOrder()로 application → domain 변환 위임
- ProductRepository/Impl: ProductSort 의존 제거, ProductOrder 사용
- ProductV1Controller: ProductSort 유지 (API 파라미터 역할 명확화)
- ProductService: sort.toOrder() 호출로 Repository에 전달

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

  • 🔍 Trigger review
✨ 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: 4

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java (1)

172-199: ⚠️ Potential issue | 🟡 Minor

테스트 이름과 기대 상태코드를 일치시켜야 한다.

현재 비밀번호 불일치 케이스에서 기대값은 404인데 테스트 메서드명이 returnsBadRequest_whenCurrentPasswordNotMatches로 남아 있어 장애 분석 시 CI 리포트 해석을 혼동시킨다. 메서드명을 returnsNotFound_whenCurrentPasswordNotMatches로 변경하는 것이 좋다. 추가로 존재하지 않는 사용자비밀번호 불일치를 파라미터화해 모두 404를 보장하는 테스트를 넣으면 인증 예외 흐름 회귀를 안정적으로 막을 수 있다. As per coding guidelines **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java`
around lines 172 - 199, Rename the test method in UserV1ApiE2ETest from
returnsBadRequest_whenCurrentPasswordNotMatches to
returnsNotFound_whenCurrentPasswordNotMatches so the method name matches the
asserted HttpStatus.NOT_FOUND; additionally consider adding a parameterized test
variant (e.g., using `@ParameterizedTest`) or a second test in the same class to
cover both "non-existent user" and "current password mismatch" scenarios to
assert 404 behavior for both error flows.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java (1)

21-26: ⚠️ Potential issue | 🟠 Major

Bean Validation 어노테이션 제거하여 도메인 검증 일원화

프로젝트는 Hexagonal Architecture 원칙에 따라 도메인 계층에서만 검증을 수행하고, DTO에서는 Bean Validation 어노테이션을 사용하지 않는다. 현재 UserV1Dto.CreateRequest@NotBlank, @NotNull이 있고 이 컨트롤러에서 @Valid를 사용 중인데, 이는 다음 두 가지 문제를 야기한다:

  1. 검증 이중화: 동일 필드에 대해 @Valid(DTO) → MethodArgumentNotValidException 경로와 User.create()(도메인) → CoreException 경로가 동시 존재한다. 도메인 엔티티의 검증 계약이 불명확하고, 다른 진입점(배치, 메시지 큐 등)에서는 DTO 검증이 적용되지 않아 일관성이 깨진다.

  2. 아키텍처 원칙 위반: 도메인 모듈의 독립성이 손상되고, 검증 로직이 계층 간 산재된다.

UserV1Dto@NotBlank, @NotNull 어노테이션과 해당 import를 제거하고, 이 컨트롤러 및 UserV1Controller@Validjakarta.validation.Valid import를 제거한다. 현재 도메인 계층(User 엔티티, PasswordPolicyValidator)의 검증 로직이 이미 모든 필드를 포괄하므로 충분하다.

추가 테스트로 빈 loginId, 잘못된 이메일, 미래 생년월일 payload 요청 시 BAD_REQUEST와 표준 ApiResponse 에러 포맷이 반환되는 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/api/user/SignUpV1Controller.java`
around lines 21 - 26, Remove DTO- and controller-level bean validation to
centralize validation in the domain: delete the `@NotBlank/`@NotNull annotations
and their imports from UserV1Dto.CreateRequest, remove the `@Valid` annotation and
its jakarta.validation.Valid import from the SignUpV1Controller.signUp method
(and from UserV1Controller if present), and ensure SignUpCommand.from(request)
relies on domain validators (User.create()/PasswordPolicyValidator). After
making these removals, add E2E tests that POST payloads with empty loginId,
invalid email, and future birthDate to verify the domain validation path returns
BAD_REQUEST with the standard ApiResponse error format.
🟠 Major comments (29)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java-218-221 (1)

218-221: ⚠️ Potential issue | 🟠 Major

주문 목록 테스트가 사용자 격리를 충분히 검증하지 못한다

보안/개인정보 관점에서 목록 API가 타 사용자 주문을 함께 반환해도 현재 isNotEmpty() 검증만으로는 테스트가 통과할 수 있다. 응답 목록의 모든 userIdsavedUser.getId()인지 검증하고, 타 사용자 주문을 미리 생성한 뒤 목록에서 제외되는지까지 검증해야 한다. 추가 테스트로 otherUser 주문 생성 후 savedUser 목록 조회 시 해당 주문 ID가 포함되지 않음을 명시적으로 확인하는 케이스를 추가하는 편이 좋다.

수정안 제안
             assertAll(
                     () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
-                    () -> assertThat(response.getBody().data()).isNotEmpty()
+                    () -> assertThat(response.getBody()).isNotNull(),
+                    () -> assertThat(response.getBody().data()).isNotEmpty(),
+                    () -> assertThat(response.getBody().data())
+                            .allSatisfy(order -> assertThat(order.userId()).isEqualTo(savedUser.getId()))
             );

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

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

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java`
around lines 218 - 221, 현재 assertAll 블록은 응답이 비어있지 않은지만 확인하므로 사용자 격리를 검증하지 못합니다;
OrderV1ApiE2ETest의 assertion을 강화해 response.getBody().data()의 각 항목에 대해 userId가
savedUser.getId()와 일치하는지 검증하고, 테스트 준비 단계에서 otherUser로 주문을 생성한 뒤 savedUser로 목록 조회
시 그 otherUser 주문의 ID가 반환 목록에 포함되지 않는지를 명시적으로 assert 하도록 수정하세요 (참조 심볼: assertAll,
response.getBody().data(), savedUser.getId(), otherUser, savedOrder 또는 생성된 다른 주문
ID).
apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java-3-14 (1)

3-14: ⚠️ Potential issue | 🟠 Major

도메인 저장소 인터페이스가 Spring Page/Pageable에 직접 결합되어 있다

도메인 계층이 프레임워크 타입에 묶이면 저장소 구현체 교체, 모듈 재사용, 또는 애플리케이션 분해 시 변경 영향이 확산된다. 현재 설계는 도메인 인터페이스 정의 단계에서 이미 Spring Data 타입을 노출하므로, 테스트 환경의 메모리 구현체도 강제로 Page/PageImpl에 의존하게 된다.

수정안은 도메인 계층에 프레임워크 비의존 페이징 질의/결과 모델(예: BrandPageQuery, BrandPageResult)을 두고, Pageable 변환은 인프라 구현체에서만 처리하는 것이다. 테스트 환경의 인메모리 구현도 순수 컬렉션 기반 페이징으로 단순화할 수 있다.

인프라 어댑터 단위 테스트에서 정렬/페이지번호/총 개수 매핑이 정확히 유지되는지 검증하는 케이스를 추가하는 것이 좋다.

리팩터 방향 예시
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;

-    Page<Brand> findAllByDeletedAtIsNull(Pageable pageable);
+    BrandPageResult findAllActive(BrandPageQuery query);
🤖 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/BrandRepository.java`
around lines 3 - 14, The BrandRepository interface currently exposes Spring
types (Page, Pageable) — change the domain API to be framework-agnostic by
replacing findAllByDeletedAtIsNull(Pageable pageable) with a method using domain
paging DTOs, e.g., Page/Query types you create such as BrandPageQuery and
BrandPageResult; keep signatures in BrandRepository like BrandPageResult
findAllByDeletedAtIsNull(BrandPageQuery query) and leave save, findById,
findAllByIdIn as-is; then move all Pageable-to-BrandPageQuery and
BrandPageResult-to-Page conversions into the infrastructure adapter that
implements BrandRepository so only the adapter deals with Spring Pageable/Page,
and add adapter unit tests to verify page number, size, sort and total count
mapping.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java-33-40 (1)

33-40: ⚠️ Potential issue | 🟠 Major

주문 항목의 핵심 필드 검증이 누락되어 데이터 무결성 리스크가 있다.

Line 33-40, 46-49에서 quantity만 검증하고 orderId, productId, productName, price를 검증하지 않는다. 운영에서는 잘못된 주문 항목이 저장되어 주문 합계 불일치, 정산 오류, 장애 대응 난이도 증가로 이어질 수 있다. 생성자에서 필수 필드를 함께 검증하도록 수정이 필요하다.

수정안 예시
     private OrderItem(Long orderId, Long productId, String productName, Integer price, Integer quantity) {
+        validateOrderId(orderId);
+        validateProductId(productId);
+        validateProductName(productName);
+        validatePrice(price);
         validateQuantity(quantity);
         this.orderId = orderId;
         this.productId = productId;
         this.productName = productName;
         this.price = price;
         this.quantity = quantity;
     }
@@
+    private void validateOrderId(Long orderId) {
+        if (orderId == null || orderId < 1) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 1 이상이어야 합니다.");
+        }
+    }
+
+    private void validateProductId(Long productId) {
+        if (productId == null || productId < 1) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 1 이상이어야 합니다.");
+        }
+    }
+
+    private void validateProductName(String productName) {
+        if (productName == null || productName.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어 있을 수 없습니다.");
+        }
+    }
+
+    private void validatePrice(Integer price) {
+        if (price == null || price < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다.");
+        }
+    }
+
     private void validateQuantity(Integer quantity) {
         if (quantity == null || quantity < 1) {
             throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다.");
         }
     }

추가 테스트로 orderId/productId null, productName blank, price null/음수에 대한 예외 케이스를 각각 분리해 검증해야 한다.
As per coding guidelines, "**/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다."

Also applies to: 46-49

🤖 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 33 - 40, The private OrderItem(Long orderId, Long productId, String
productName, Integer price, Integer quantity) constructor currently only calls
validateQuantity(quantity); add validation for orderId and productId being
non-null, productName being non-null/non-blank, and price being non-null and > 0
(throw IllegalArgumentException or use Objects.requireNonNull with clear
messages) before assigning fields; apply the same validations to the other
OrderItem constructor/creation path referenced in this class (the one at lines
46-49) so all creation code paths enforce orderId/productId non-null,
productName non-blank, price positive, and quantity validated via
validateQuantity.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemSnapshot.java-3-11 (1)

3-11: ⚠️ Potential issue | 🟠 Major

스냅샷 생성 시 입력 검증이 없어 음수 금액/수량이 주문 계산으로 유입될 수 있다.

Line 3-11에서 productId, productName, unitPrice, quantity 검증이 없어 비정상 입력이 그대로 누적 금액 계산으로 전달된다. 운영에서는 주문 총액 음수, 재고 차감 불일치, 장애 원인 추적 난이도 증가가 발생할 수 있다. record compact constructor에서 즉시 검증하도록 수정이 필요하다.

수정안 예시
 package com.loopers.application.order;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
 public record OrderItemSnapshot(
         Long productId,
         String productName,
         int unitPrice,
         int quantity
 ) {
+    public OrderItemSnapshot {
+        if (productId == null || productId < 1) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 1 이상이어야 합니다.");
+        }
+        if (productName == null || productName.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어 있을 수 없습니다.");
+        }
+        if (unitPrice < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "단가는 0 이상이어야 합니다.");
+        }
+        if (quantity < 1) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다.");
+        }
+    }
+
     public long lineAmount() {
         return (long) unitPrice * quantity;
     }
 }

추가 테스트로 unitPrice < 0, quantity < 1, productId null, productName blank 예외 케이스와 정상 입력에서 lineAmount 계산 정확성 케이스를 함께 추가해야 한다.
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/application/order/OrderItemSnapshot.java`
around lines 3 - 11, Add input validation to the OrderItemSnapshot record by
implementing a compact constructor in OrderItemSnapshot that checks productId !=
null, productName is not blank, unitPrice >= 0, and quantity >= 1; when a check
fails, throw the project's unified CoreException (so ApiControllerAdvice handles
it) with a clear message; keep the existing lineAmount() as-is but rely on
validated fields; also add unit tests covering unitPrice < 0, quantity < 1,
productId null, productName blank, and a normal case verifying lineAmount()
correctness.
apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-28-36 (1)

28-36: ⚠️ Potential issue | 🟠 Major

도메인 생성 시 필수 불변식 검증이 누락되어 있다.

Line 28-35에서 userIdtotalAmount를 검증하지 않아 잘못된 주문이 생성될 수 있다. 운영 시점에는 DB 제약 위반이 커밋 구간에서 500으로 터지거나, 음수 주문 금액이 저장되어 정산/집계 데이터가 오염될 수 있다. 생성 시점에 즉시 CoreException으로 차단하는 방식으로 수정이 필요하다.

수정안 예시
 import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
@@
     private Order(Long userId, Long totalAmount) {
+        validateUserId(userId);
+        validateTotalAmount(totalAmount);
         this.userId = userId;
         this.status = Status.ORDERED;
         this.totalAmount = totalAmount;
     }
@@
     public static Order create(Long userId, Long totalAmount) {
         return new Order(userId, totalAmount);
     }
+
+    private void validateUserId(Long userId) {
+        if (userId == null || userId < 1) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 1 이상이어야 합니다.");
+        }
+    }
+
+    private void validateTotalAmount(Long totalAmount) {
+        if (totalAmount == null || totalAmount < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "총 주문 금액은 0 이상이어야 합니다.");
+        }
+    }

추가 테스트로 Order.create(null, 1000L), Order.create(1L, null), Order.create(1L, -1L)가 모두 BAD_REQUEST를 던지는 케이스를 포함해야 한다.
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/domain/order/Order.java` around
lines 28 - 36, The Order factory is not validating required invariants: update
the private constructor Order(Long userId, Long totalAmount) (or the public
static create(Long userId, Long totalAmount)) to validate that userId and
totalAmount are non-null and that totalAmount is >= 0, and throw CoreException
(with appropriate BAD_REQUEST error code) when any check fails so errors flow
through ApiControllerAdvice; add unit tests calling Order.create(null, 1000L),
Order.create(1L, null), and Order.create(1L, -1L) expecting
CoreException/BAD_REQUEST to ensure the guards work.
apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java-36-43 (1)

36-43: ⚠️ Potential issue | 🟠 Major

도메인 생성 시 입력 검증이 없어 DB 예외로 누수될 수 있다.

Line 41-43에서 userId, productId 검증이 없으면 잘못된 입력이 DB 레이어까지 내려가 DataIntegrity 계열 예외로 표면화되어, 운영에서 에러 포맷이 불일치하거나 500으로 보일 위험이 있다.
수정안으로 Like.create 내부에서 null/0 이하를 검증하고 CoreException(ErrorType.BAD_REQUEST, ...)를 던지도록 도메인 경계에서 차단하는 것이 필요하다.
추가 테스트로 Like.create(null, 1L), Like.create(0L, 1L), Like.create(1L, null), Like.create(1L, -1L)에 대한 BAD_REQUEST 케이스를 도메인 테스트로 보강하는 것이 좋다.

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/domain/like/Like.java` around
lines 36 - 43, Add input validation inside the Like.create factory: check that
userId and productId are non-null and >0, and if any check fails throw new
CoreException(ErrorType.BAD_REQUEST, "invalid like input") (or a clear message)
so invalid values are rejected at the domain boundary; update/create domain
tests to assert that Like.create(null,1L), Like.create(0L,1L),
Like.create(1L,null) and Like.create(1L,-1L) all result in CoreException with
ErrorType.BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java-19-26 (1)

19-26: ⚠️ Potential issue | 🟠 Major

동시 등록 시 경쟁 상태로 의도치 않은 500이 발생할 수 있다.

Line 19-26은 find -> save 순서라 동시에 같은 (userId, productId) 요청이 들어오면 둘 다 조회를 통과하고 저장에서 유니크 제약 예외(DataIntegrityViolationException)가 발생한다. 현재는 이를 처리하지 않아 글로벌 예외 핸들러의 일반 Throwable 캐치로 빠져 ErrorType.INTERNAL_ERROR(500)로 응답되므로, 운영에서 ALREADY_LIKED(400) 대신 내부 오류로 노출된다.

해결책: register() 메서드에서 저장 시 DataIntegrityViolationExceptiontry-catch로 잡아 CoreException(ErrorType.ALREADY_LIKED, "이미 좋아요한 상품입니다.")로 변환한다. 선조회는 제거하고 DB 예외 기반으로 단순화한다.

추가 테스트: 동시성 시나리오(동일 사용자/상품에 대해 병렬 register)를 추가해 결과가 "성공 1건 + ALREADY_LIKED 1건"으로 고정되고 500이 발생하지 않음을 검증한다.

🤖 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/LikeApplicationService.java`
around lines 19 - 26, Remove the pre-check in LikeApplicationService.register
and instead wrap the save call in a try-catch that catches
org.springframework.dao.DataIntegrityViolationException; on catch, throw new
CoreException(ErrorType.ALREADY_LIKED, "이미 좋아요한 상품입니다."); keep creating the Like
via Like.create(userId, productId) and persist with likeRepository.save(like)
inside the try block, and add a concurrency test that runs parallel
register(userId, productId) calls asserting one success and the other throws
CoreException with ErrorType.ALREADY_LIKED.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java-292-295 (1)

292-295: ⚠️ Potential issue | 🟠 Major

정렬 검증이 누락을 놓칠 수 있어 회귀 탐지 신뢰도가 낮다.

운영 관점에서 indexOf 기반 비교는 응답에서 특정 상품이 누락되어도 -1 비교로 통과할 수 있어, 정렬/필터 회귀가 배포까지 누락될 위험이 있다.
수정안으로 정렬 테스트는 인덱스 비교 대신 기대 ID 순서를 직접 검증하도록 containsExactly(...) 형태로 바꾸는 것이 안전하다.
추가 테스트로 “정렬 대상 중 1개가 응답에서 누락된 경우 반드시 실패” 시나리오를 넣어 오탐을 차단해야 한다.

🔧 제안 수정안
-                    () -> assertThat(ids.indexOf(cheapProduct.getId()))
-                            .isLessThan(ids.indexOf(middleProduct.getId()))
-                            .isLessThan(ids.indexOf(expensiveProduct.getId()))
+                    () -> assertThat(ids)
+                            .containsExactly(cheapProduct.getId(), middleProduct.getId(), expensiveProduct.getId())
...
-                    () -> assertThat(ids.indexOf(highLikes.getId()))
-                            .isLessThan(ids.indexOf(middleLikes.getId()))
-                            .isLessThan(ids.indexOf(lowLikes.getId()))
+                    () -> assertThat(ids)
+                            .containsExactly(highLikes.getId(), middleLikes.getId(), lowLikes.getId())

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

Also applies to: 331-334

🤖 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/ProductV1ApiE2ETest.java`
around lines 292 - 295, Replace the fragile indexOf-based ordering assertion
with a direct equality check of the expected ID sequence: assert that the
response IDs list (ids) containsExactly the expected order built from
cheapProduct.getId(), middleProduct.getId(), expensiveProduct.getId() so missing
items cannot silently pass; update the assertions in ProductV1ApiE2ETest where
ids.indexOf(...) is used (the block referencing cheapProduct, middleProduct,
expensiveProduct) and add an additional negative test case that simulates one of
the expected products missing from the response and asserts the test fails (or
that containsExactly does not match) to prevent false positives from omitted
items.
apps/commerce-api/src/test/java/com/loopers/domain/brand/InMemoryBrandRepository.java-15-16 (1)

15-16: ⚠️ Potential issue | 🟠 Major

인메모리 Repository가 Pageable 계약을 지키지 않아 페이징 회귀를 숨길 수 있다.

운영 관점에서 테스트 더블이 pageable을 무시하면 실제 환경에서 발생할 페이징 버그를 테스트가 잡지 못하고, HashMap 순서 비결정성으로 플래키 테스트가 생길 수 있다.
수정안으로 필터 결과를 안정적인 순서(예: id 오름차순)로 정렬한 뒤 offset/pageSize를 적용해 PageImpl을 구성해야 한다.
추가 테스트로 page size=2, page=0/1/2 케이스에서 content.size, totalElements, 순서가 모두 기대값과 일치하는지 검증해야 한다.

🔧 제안 수정안
+import java.util.Comparator;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
...
-    private final Map<Long, Brand> store = new HashMap<>();
+    private final Map<Long, Brand> store = new LinkedHashMap<>();
...
     public Page<Brand> findAllByDeletedAtIsNull(Pageable pageable) {
-        var list = store.values().stream()
-                        .filter(b -> b.getDeletedAt() == null)
-                        .toList();
-        return new PageImpl<>(list, pageable, list.size());
+        var filtered = store.values().stream()
+                            .filter(b -> b.getDeletedAt() == null)
+                            .sorted(Comparator.comparingLong(Brand::getId))
+                            .toList();
+
+        int start = (int) pageable.getOffset();
+        int end = Math.min(start + pageable.getPageSize(), filtered.size());
+        var pageContent = start >= filtered.size() ? List.<Brand>of() : filtered.subList(start, end);
+        return new PageImpl<>(pageContent, pageable, filtered.size());
     }

As per coding guidelines: **/*Repository*.java: 쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다.

Also applies to: 39-43

🤖 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/InMemoryBrandRepository.java`
around lines 15 - 16, The in-memory repository (InMemoryBrandRepository)
currently ignores Pageable and uses an unordered HashMap (store) which can hide
paging bugs and cause flaky tests; update the repository's pageable-aware
retrieval (e.g., the method that returns Page<Brand>) to collect matching Brands
from store, sort them by id ascending (use the id field and idGenerator
semantics), then apply offset = page.getOffset() and limit = page.getSize() to
slice the sorted list and build a PageImpl with correct totalElements and
content; also add unit tests for pageSize=2 with page 0,1,2 asserting
content.size, totalElements, and that returned Brands are in ascending id order
to catch regressions.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java-24-26 (1)

24-26: ⚠️ Potential issue | 🟠 Major

인증 누락을 NOT_FOUND로 처리하면 장애·보안 신호가 왜곡된다.

운영 관점에서 인증 실패를 404로 응답하면 모니터링에서 인증 오류와 리소스 부재를 구분하지 못해 탐지/대응 품질이 떨어진다.
수정안으로 인증 헤더 누락은 인증 실패 전용 에러 타입(프로젝트의 401 매핑 타입)으로 변경하고, null뿐 아니라 blank 값도 차단해야 한다.
추가 테스트로 보호 엔드포인트에 대해 “헤더 누락/공백 → 401 계열 + 표준 에러 포맷” 회귀 테스트를 추가해야 한다.

🔧 제안 수정안
+import org.springframework.util.StringUtils;
...
-        if (loginId == null || loginPw == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, "인증 정보가 없습니다.");
+        if (!StringUtils.hasText(loginId) || !StringUtils.hasText(loginPw)) {
+            throw new CoreException(ErrorType.UNAUTHORIZED, "인증 정보가 없습니다.");
         }
🤖 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/auth/LoginUserInterceptor.java`
around lines 24 - 26, In LoginUserInterceptor, change the error thrown when
loginId or loginPw are missing from ErrorType.NOT_FOUND to the project's
authentication-failure error type that maps to 401, and expand the condition to
treat null or blank (empty or whitespace-only) values as missing; update the
thrown CoreException message to the standard auth-missing message/format. Also
add a regression test for the protected endpoint(s) that asserts when
Authorization/login headers are omitted or blank the response status is 401 and
the body follows the project’s standard error format.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandV1Dto.java-4-4 (1)

4-4: ⚠️ Potential issue | 🟠 Major

DTO의 Bean Validation 제거 및 도메인 레이어 검증으로 이동 필요다.

이 프로젝트는 헥사고날 아키텍처 원칙에 따라 모든 검증을 도메인 레이어에서 수행하도록 설계되었다. DTO 레이어의 Bean Validation은 이 원칙과 어긋난다. 현재 @NotBlankjakarta.validation 의존을 제거하고, Brand.create(...)/Brand.update(...) 도메인 메서드에서 검증하도록 이동해야 한다.

추가로 프로젝트 전체 DTO(UserV1Dto, ProductV1Dto, OrderV1Dto)에서도 Bean Validation 어노테이션을 제거하여 정합성을 맞춰야 한다. 도메인 검증 후 발생한 예외가 모두 CoreException → ApiControllerAdvice를 통해 통일된 에러 포맷으로 반환되는지 테스트로 확인하다.

🔧 제안 수정안
-import jakarta.validation.constraints.NotBlank;
...
     public record CreateRequest(
-            `@NotBlank`(message = "브랜드 이름은 필수값입니다.")
             String name,
             String description
     ) {}

     public record UpdateRequest(
-            `@NotBlank`(message = "브랜드 이름은 필수값입니다.")
             String name,
             String description
     ) {}
🤖 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/dto/BrandV1Dto.java`
at line 4, DTO들에서 Bean Validation 어노테이션과 jakarta.validation 의존을 제거하세요:
apps/commerce-api/src/main/java/.../BrandV1Dto.java의 import
jakarta.validation.constraints.NotBlank 및 모든 `@NotBlank/`@Valid 같은 어노테이션을 삭제하고
동일하게 UserV1Dto, ProductV1Dto, OrderV1Dto의 DTO들에서도 제거합니다; 대신 도메인 쪽의
Brand.create(...) 및 Brand.update(...) 메서드에 입력 검증 로직을 구현하여 빈값/유효성 체크를 수행하게 하고,
도메인 검증 실패 시 던지는 예외가 CoreException 계층으로 래핑되어 ApiControllerAdvice에서 일관된 에러 포맷으로
반환되는지 확인하는 단위/통합 테스트를 추가하세요.
apps/commerce-api/src/main/java/com/loopers/application/user/SignUpApplicationService.java-18-20 (1)

18-20: ⚠️ Potential issue | 🟠 Major

동시 가입 요청에서 중복 로그인 ID 방지가 불완전하다.

findByLoginId 사전조회만으로는 TOCTOU 경쟁 조건이 발생하여 동일 loginId를 가진 두 개의 User 레코드가 데이터베이스에 삽입되거나, DataIntegrityViolationException이 전역 핸들러에서 INTERNAL_ERROR(500)로 노출될 수 있다. User 엔티티의 @Column(unique = true) 제약을 전제로 save 호출 시 DB 무결성 예외를 CoreException(CONFLICT)로 변환하는 처리가 필수다.

🔧 제안 수정안
+import org.springframework.dao.DataIntegrityViolationException;
+
 `@Service`
 `@Transactional`
 `@RequiredArgsConstructor`
 public class SignUpApplicationService {
     private final PasswordEncoder passwordEncoder;
     private final UserRepository userRepository;
 
     public void signUp(SignUpCommand command) {
         if (userRepository.findByLoginId(command.loginId()).isPresent()) {
             throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
         }
 
         PasswordPolicyValidator.validate(command.password(), command.birthDate());
         String encodedPassword = passwordEncoder.encode(command.password());
         User user = User.create(command, encodedPassword);
 
-        userRepository.save(user);
+        try {
+            userRepository.save(user);
+        } catch (DataIntegrityViolationException e) {
+            throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
+        }
     }
 }

추가로 동일 loginId로 2개의 동시 가입 요청을 실행하여 1건은 성공하고 1건은 409 CONFLICT를 반환하는지 동시성 테스트로 검증해야 한다.

🤖 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/user/SignUpApplicationService.java`
around lines 18 - 20, The pre-check in SignUpApplicationService using
userRepository.findByLoginId(...) is vulnerable to TOCTOU race conditions;
update the sign-up flow to catch database integrity errors on
userRepository.save(...) and translate DataIntegrityViolationException (or the
specific JPA/DB exception) into throw new CoreException(ErrorType.CONFLICT, "이미
존재하는 로그인 ID입니다."); keep the existing pre-check (findByLoginId) for fast-fail but
ensure save is wrapped in a try/catch that maps integrity violations to
CoreException(CONFLICT) so duplicate unique constraint violations are returned
as 409; add a concurrency test that issues two simultaneous sign-ups with the
same loginId and asserts one succeeds and the other receives a 409 CONFLICT.
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java-72-74 (1)

72-74: ⚠️ Potential issue | 🟠 Major

가시성 변경 메서드가 null을 허용해 런타임 실패를 유발한다

운영 관점에서 visibility=null 상태는 DB 저장 시 제약조건 위반 또는 이후 로직의 NPE를 유발해 트랜잭션 롤백과 장애를 만든다. 수정안으로 null 검증 후 CoreException을 던지도록 방어해야 한다.

수정 예시
 public void changeVisibility(Visibility visibility) {
+    if (visibility == null) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "노출 상태는 필수값입니다.");
+    }
     this.visibility = visibility;
 }

추가 테스트로 changeVisibility(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/product/Product.java`
around lines 72 - 74, The changeVisibility method in Product currently allows a
null value which can cause DB constraint failures or NPEs; update
Product.changeVisibility(Visibility visibility) to validate visibility != null
and throw a CoreException with a clear message (include identifying context such
as product id or name if available) when null is passed, and add a unit test
that calls changeVisibility(null) and asserts the CoreException is thrown;
ensure you update any callers or documentation if they relied on null semantics.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java-8-11 (1)

8-11: ⚠️ Potential issue | 🟠 Major

요청 값 null/empty에서 NPE가 발생해 500으로 전이될 수 있다

운영 관점에서 잘못된 사용자 입력이 4xx가 아닌 500으로 기록되면 장애 알람 노이즈와 원인 추적 비용이 증가한다. 수정안으로 request, request.items() null/empty와 item 필드 유효성을 명시 검증하고 CoreException(BAD_REQUEST)로 변환해야 한다.

수정 예시
 public record OrderCreateCommand(Long userId, List<OrderItemCommand> items) {
     public static OrderCreateCommand from(Long userId, OrderV1Dto.CreateRequest request) {
+        if (request == null || request.items() == null || request.items().isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품은 1개 이상이어야 합니다.");
+        }
         List<OrderItemCommand> items = request.items().stream()
+                                              .peek(item -> {
+                                                  if (item.productId() == null || item.quantity() == null || item.quantity() <= 0) {
+                                                      throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품 정보가 올바르지 않습니다.");
+                                                  }
+                                              })
                                               .map(item -> new OrderItemCommand(item.productId(), item.quantity()))
                                               .toList();
         return new OrderCreateCommand(userId, items);
     }
 }

추가 테스트로 null/빈 목록/수량 0 입력에 대해 CoreException이 발생하고 API 응답이 일관된 포맷으로 내려오는지 검증해야 한다. Based on learnings: validation errors should be unified through CoreException and 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/application/order/OrderCreateCommand.java`
around lines 8 - 11, In OrderCreateCommand.from(Long, OrderV1Dto.CreateRequest)
add explicit input validation: check request != null, request.items() != null
and not empty, and validate each item's productId and quantity (>0); on any
validation failure throw new CoreException(BAD_REQUEST) (so ApiControllerAdvice
will format the response) instead of letting an NPE occur; ensure the mapping to
List<OrderItemCommand> only runs after validation and add unit tests asserting
CoreException is thrown for null request, null/empty items list, and item with
quantity 0.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

DTO의 Bean Validation 어노테이션을 제거하고 도메인 계층 검증으로 통일한다

현재 OrderV1Dto의 @NotNull, @NotEmpty, @Min 어노테이션은 프로젝트 아키텍처 원칙과 맞지 않다. OrderItem에서 이미 validateQuantity()로 domain layer 검증을 수행하고 있으므로, DTO 어노테이션은 불필요 중복이다. DTO 어노테이션을 제거하고, 주문 항목의 null/빈 목록/0 이하 수량에 대한 모든 검증을 domain layer(Order, OrderItem 생성자)로 이행해야 한다. 추가로 null items, items=[], quantity=0 등의 요청에 대해 일관된 CoreException → ApiControllerAdvice 경로로 에러가 반환되는지 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/api/order/OrderV1Dto.java`
around lines 5 - 7, Remove the Bean Validation annotations (`@NotNull`, `@NotEmpty`,
`@Min`) from OrderV1Dto and rely on domain-layer validation instead: ensure Order
and OrderItem constructors (and OrderItem.validateQuantity()) perform null/empty
items and quantity<=0 checks and throw the project's CoreException on violation;
update any factory/mapper that builds Order/OrderItem from OrderV1Dto to let
those domain exceptions bubble up; and add E2E tests that submit payloads with
null items, empty items list, and quantity=0 to verify the error flows through
ApiControllerAdvice into the API response.
apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java-49-57 (1)

49-57: ⚠️ Potential issue | 🟠 Major

테스트 저장소의 정렬·페이징 동작이 운영 구현과 불일치한다.

Line 55-57은 Pageable 슬라이싱을 적용하지 않아 전체 목록을 반환하고, Line 51-53의 LIKES_DESCid 기준으로 대체되어 운영 쿼리와 다른 결과를 만든다. 이 상태는 테스트 신뢰도를 떨어뜨려 운영 장애를 사전에 놓칠 위험이 크다. 운영 저장소와 동일하게 likeCount 정렬과 페이지 슬라이싱을 적용해야 한다.

수정안 예시
         Comparator<Product> comparator = switch (sort) {
             case PRICE_ASC -> Comparator.comparing(Product::getPrice);
-            // likes_desc는 Like 도메인 구현 전이므로 인메모리에서 id 기준으로 대체
-            case LIKES_DESC, LATEST -> Comparator.comparing(Product::getId).reversed();
+            case LIKES_DESC -> Comparator.comparing(Product::getLikeCount).reversed()
+                                         .thenComparing(Comparator.comparing(Product::getId).reversed());
+            case LATEST -> Comparator.comparing(Product::getId).reversed();
         };

         List<Product> list = stream.sorted(comparator).toList();
-        return new PageImpl<>(list, pageable, list.size());
+        int start = (int) pageable.getOffset();
+        int end = Math.min(start + pageable.getPageSize(), list.size());
+        List<Product> content = start >= list.size() ? List.of() : list.subList(start, end);
+        return new PageImpl<>(content, pageable, list.size());

추가 테스트로는 findActiveProducts에 대해 (1) LIKES_DESC 정렬 정확성, (2) page/size 반영 여부를 각각 검증하는 단위 테스트를 추가해야 한다.

🤖 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/InMemoryProductRepository.java`
around lines 49 - 57, In InMemoryProductRepository, the current sort/comparator
and paging are incorrect: change the LIKES_DESC branch in the comparator switch
to sort by Product::getLikeCount (descending) instead of getId, and after
obtaining the sorted List via stream.sorted(comparator).toList(), apply Pageable
slicing using pageable.getOffset() and pageable.getPageSize() (compute start/end
bounds safely against list.size()) to create the PageImpl from the sublist while
passing the full list.size() as total; update references to comparator, sort,
stream.sorted, and the PageImpl(...) call accordingly and add unit tests that
assert LIKES_DESC ordering by likeCount and that page/size are honored.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

DTO에서 Bean Validation 어노테이션을 제거하고 도메인 검증으로 단일 예외 흐름을 유지한다.

현재 ProductV1Dto의 CreateRequest, UpdateRequest에서 @NotNull, @NotBlank를 사용하면 @Valid에 의해 MethodArgumentNotValidException이 발생하여 ApiControllerAdvice의 라인 51 핸들러로 처리된다. 운영 중 요청 검증 실패와 도메인 검증 실패가 다른 예외 경로로 분기되므로 에러 처리 복잡도가 높아진다.

Product 도메인의 validateBrandId(), validateName(), validatePrice(), validateStockQuantity() 메서드가 이미 모든 검증을 CoreException으로 처리하고 있으므로, 요청 DTO에서는 어노테이션을 제거하고 모든 검증을 도메인 레이어에서만 수행하도록 수정한다.

수정안
 import com.loopers.application.product.ProductInfo;
 import com.loopers.domain.product.Product;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;

 import java.time.ZonedDateTime;
@@
     public record CreateRequest(
-            `@NotNull` Long brandId,
-            `@NotBlank`(message = "상품 이름은 필수값입니다.") String name,
+            Long brandId,
+            String name,
             String description,
-            `@NotNull` Integer price,
-            `@NotNull` Integer stockQuantity
+            Integer price,
+            Integer stockQuantity
     ) {}

     public record UpdateRequest(
-            `@NotBlank`(message = "상품 이름은 필수값입니다.") String name,
+            String name,
             String description,
-            `@NotNull` Integer price,
-            `@NotNull` Integer stockQuantity,
+            Integer price,
+            Integer stockQuantity,
             Product.Visibility visibility
     ) {}

API E2E 테스트에서 brandId=null, name=null/blank, price=null 요청 시 ApiControllerAdvice의 CoreException 핸들러(라인 28)로만 처리되고 BAD_REQUEST 응답이 반환되는지 검증한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java`
around lines 5 - 7, DTO에 Bean Validation 어노테이션(`@NotNull`, `@NotBlank`) 사용으로 인해 요청
검증이 MethodArgumentNotValidException 경로로 분기되므로 ProductV1Dto의 CreateRequest 및
UpdateRequest에서 해당 어노테이션들을 제거하고 `@Valid가` 더 이상 트리거되지 않도록 하여 모든 유효성 검증을 Product
도메인으로 일원화하세요; 구체적으로 ProductV1Dto 내 CreateRequest/UpdateRequest 클래스에서
jakarta.validation.constraints.* 어노테이션을 삭제하고 컨트롤러에서 `@Valid` 검사에 의존하지 않으며, 요청을
도메인으로 전달한 뒤 Product의 validateBrandId(), validateName(), validatePrice(),
validateStockQuantity()에서 CoreException을 발생시키도록 유지해 ApiControllerAdvice의
CoreException 핸들러(라인 28)로만 처리되게 만드세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-47-57 (1)

47-57: ⚠️ Potential issue | 🟠 Major

PRICE_ASC와 LIKES_DESC 정렬 시 동일값 존재 시 페이징이 비결정적이 된다.

현재 코드는 단일 정렬 키만 사용하므로 pricelikeCount가 동일한 데이터가 많을 때 페이지 간 중복이나 누락이 발생할 수 있다. 2차 정렬 키로 id를 추가해 결과를 결정적으로 고정해야 한다. LATEST의 경우 이미 id.desc()로 정렬되므로 추가 수정 불필요하다.

수정안 예시
-        OrderSpecifier<?> orderSpecifier = switch (order) {
-            case PRICE_ASC -> product.price.asc();
-            case LIKES_DESC -> product.likeCount.desc();
+        OrderSpecifier<?>[] orderSpecifiers = switch (order) {
+            case PRICE_ASC -> new OrderSpecifier<?>[]{product.price.asc(), product.id.desc()};
+            case LIKES_DESC -> new OrderSpecifier<?>[]{product.likeCount.desc(), product.id.desc()};
             case LATEST -> product.id.desc();
         };
@@
-                .orderBy(orderSpecifier)
+                .orderBy(orderSpecifiers)

PRICE_ASC/LIKES_DESC에서 동일 값 다건 데이터로 2페이지 이상 조회 시 페이지 간 중복 또는 누락이 없는지 확인하는 통합 테스트를 추가해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java`
around lines 47 - 57, The ordering is currently non-deterministic for PRICE_ASC
and LIKES_DESC because only one key is used; update the OrderSpecifier
construction in ProductRepositoryImpl so PRICE_ASC becomes
product.price.asc().and(product.id.asc()) and LIKES_DESC becomes
product.likeCount.desc().and(product.id.desc()) (leave LATEST as
product.id.desc()), then run/extend the
queryFactory.selectFrom(...).orderBy(orderSpecifier) logic as-is; also add an
integration test that pages through >1 page of identical-price or
identical-likeCount products using the repository/pagination (pageable) to
assert no duplicates or missing items across pages.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-35-37 (1)

35-37: ⚠️ Potential issue | 🟠 Major

정렬 파라미터의 표준화된 에러 처리 패턴 적용 필요.

Line 35의 enum 직접 바인딩은 현재 프레임워크 예외로 처리되어 표준 에러 응답이 반환되므로 기능적으로는 문제 없다. 그러나 프로젝트의 통일된 에러 처리 패턴(모든 에러는 CoreException → ApiControllerAdvice로 흐르기)을 따르기 위해, 문자열로 받은 뒤 명시적으로 파싱하고 실패 시 CoreException(ErrorType.BAD_REQUEST)를 던지도록 수정하는 것이 바람직하다.

수정안
     public ApiResponse<PageResponse<ProductV1Dto.ProductResponse>> getProducts(
             `@RequestParam`(required = false) Long brandId,
-            `@RequestParam`(defaultValue = "LATEST") ProductSort sort,
+            `@RequestParam`(defaultValue = "LATEST") String sort,
             `@PageableDefault`(size = 20) Pageable pageable
     ) {
-        Page<ProductV1Dto.ProductResponse> page = productFacade.getActiveProducts(brandId, sort, pageable)
+        ProductSort productSort;
+        try {
+            productSort = ProductSort.valueOf(sort.toUpperCase());
+        } catch (IllegalArgumentException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 정렬 조건입니다.");
+        }
+
+        Page<ProductV1Dto.ProductResponse> page = productFacade.getActiveProducts(brandId, productSort, pageable)
                                                                .map(ProductV1Dto.ProductResponse::from);

E2E 테스트에 GET /api/v1/products?sort=INVALID 케이스를 추가하여 400 상태코드와 표준 에러 바디 포맷을 검증한다.

🤖 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 35 - 37, The ProductV1Controller currently binds ProductSort
directly from the request; change the controller method signature to accept the
sort as a String (e.g., sortParam) and then explicitly parse it to ProductSort
inside the method (use ProductSort.valueOf or a safe parse helper); on parse
failure catch IllegalArgumentException (or null/blank) and throw new
CoreException(ErrorType.BAD_REQUEST) so the error flows through
ApiControllerAdvice and returns the standardized error body; keep the
`@PageableDefault` Pageable pageable param unchanged and ensure all code that used
the original ProductSort variable now uses the parsed enum.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java-10-10 (1)

10-10: ⚠️ Potential issue | 🟠 Major

@Valid 및 DTO 검증 어노테이션을 제거하고 도메인 계층 검증으로 통일하다

현재 코드는 @Valid 어노테이션과 DTO의 @NotBlank, @NotNull 검증을 함께 사용하는데, 이는 프로젝트 아키텍처 원칙(헥사고날 아키텍처)에 맞지 않는다. 프로젝트의 설계상 모든 검증은 도메인 계층(Product 엔티티 생성자)에서만 수행되어야 하며, 이를 통해 어떤 진입점에서든 검증 규칙이 강제되고 복잡한 도메인 규칙을 안전하게 표현할 수 있다. 현재 DTO 검증 어노테이션을 제거하고 @Valid를 삭제하면 검증 책임이 도메인 계층으로 명확히 통합되며, Product.create()가 CoreException으로 모든 오류를 일원화한다. 추가 테스트로 잘못된 상품 요청(name 필드 누락, price 음수 등)에 대해 통합 테스트를 작성하여 항상 도메인 검증에서 CoreException이 발생하는지 검증이 필요하다.

🤖 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/AdminProductV1Controller.java`
at line 10, 현재 컨트롤러와 DTO에서 사용 중인 Jakarta Bean Validation 어노테이션을 제거하여 검증 책임을
도메인으로 일원화하세요: AdminProductV1Controller에서 import jakarta.validation.Valid와 메서드
파라미터의 `@Valid` 제거하고, 이 컨트롤러에서 사용하는 요청 DTO들(상품 생성/수정 요청 DTO)에 붙어 있는 `@NotBlank`,
`@NotNull`, `@Positive` 등 모든 javax/jakarta validation 어노테이션을 삭제하여 DTO는 단순 데이터 전달 객체로
만드십시오; 대신 도메인(Product.create(...) 등)에서 모든 검증을 수행해 CoreException을 던지도록 보장하고, 관련
통합 테스트를 추가/수정하여 name 누락, 음수 price 등 잘못된 요청이 항상 도메인에서 CoreException을 발생시키는지
확인하세요.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserArgumentResolver.java-18-23 (1)

18-23: ⚠️ Potential issue | 🟠 Major

Null 처리 누락으로 인한 NPE 가능성

resolveArgument 메서드에서 webRequest.getNativeRequest(HttpServletRequest.class)null을 반환할 수 있고, request.getAttribute("userId")도 인터셉터가 값을 설정하지 않았거나 제외 경로에서 호출된 경우 null이 될 수 있다.

null이 컨트롤러로 전달되면 후속 로직에서 NPE가 발생할 수 있으며, 이는 운영 환경에서 500 에러로 나타난다. 방어적 코딩으로 명확한 예외를 발생시켜 문제 원인을 빠르게 파악할 수 있도록 해야 한다.

🛡️ 방어적 null 처리 제안
 `@Override`
 public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                               NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
     HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
+    if (request == null) {
+        throw new CoreException(ErrorType.UNAUTHORIZED, "HttpServletRequest를 가져올 수 없습니다.");
+    }
-    return request.getAttribute("userId");
+    Object userId = request.getAttribute("userId");
+    if (userId == null) {
+        throw new CoreException(ErrorType.UNAUTHORIZED, "인증 정보가 존재하지 않습니다.");
+    }
+    return userId;
 }
🤖 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/auth/LoginUserArgumentResolver.java`
around lines 18 - 23, resolveArgument currently dereferences
webRequest.getNativeRequest(HttpServletRequest.class) and returns
request.getAttribute("userId") without null checks, which can cause NPEs or
opaque 500 errors; update resolveArgument to validate both the
HttpServletRequest (result of getNativeRequest) and the "userId" attribute and,
if either is null, throw a clear runtime exception (e.g., IllegalStateException)
with a message like "HttpServletRequest missing" or "userId attribute not set -
authentication interceptor did not run" so callers get an explicit failure
instead of an NPE; apply these checks inside the resolveArgument method
(referencing resolveArgument,
webRequest.getNativeRequest(HttpServletRequest.class), and
request.getAttribute("userId")).
apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java-34-67 (1)

34-67: ⚠️ Potential issue | 🟠 Major

validateItems에서 수량 검증이 누락되어 있다

현재 구현에서 validateItems 메서드는 빈 목록과 중복 상품만 검증하고 있으며, 각 주문 항목의 수량이 0 이하인 경우에 대한 검증이 없다. HTTP 요청 DTO 레벨의 @Min(1) 검증만으로는 부족하다. 서비스 계층에서 수량 검증이 누락되면 비정상적인 데이터가 주문 생성까지 진행되어 데이터 무결성이 깨질 수 있다.

validateItems 메서드에 다음 검증을 추가해야 한다:

  • 각 항목의 수량이 1 이상인지 확인

그리고 테스트에 다음 케이스를 추가해야 한다:

  • 수량이 0인 경우 BAD_REQUEST 예외 발생 테스트
  • 수량이 음수인 경우 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/application/order/OrderApplicationServiceTest.java`
around lines 34 - 67, Add quantity validation to orderService.validateItems so
it throws a CoreException with ErrorType.BAD_REQUEST when any OrderItemCommand
has quantity < 1; update the implementation of validateItems to iterate items
and check item.getQuantity() (or equivalent) and raise the same BAD_REQUEST used
for empty/duplicate checks. Also add two unit tests in
OrderApplicationServiceTest.ValidateItems: one that constructs items with a
quantity of 0 and asserts CoreException with ErrorType.BAD_REQUEST, and another
with a negative quantity (e.g., -1) asserting the same error, mirroring the
style of the existing duplicate/empty tests.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java-21-23 (1)

21-23: ⚠️ Potential issue | 🟠 Major

findByOrderId 결과에 정렬이 명시되지 않아 주문 항목 조회 순서가 비결정적이다

현재 OrderItemJpaRepository.findByOrderId는 ORDER BY 절을 포함하지 않는다. 테스트 코드(OrderV1ApiE2ETest:269, AdminOrderV1ApiE2ETest:152)에서 .get(0)으로 첫 번째 아이템에 접근하는 패턴이 있어 실제로 순서에 의존하고 있다. 데이터베이스 플랜, 인덱스 상태, 통계 갱신 등에 따라 동일 주문의 아이템 반환 순서가 달라질 수 있으며, 이는 API 응답 일관성과 테스트 신뢰도를 훼손한다.

OrderItemJpaRepository에 정렬을 명시한 메서드(예: findByOrderIdOrderByIdAsc)를 추가하고 OrderItemRepositoryImpl에서 해당 메서드를 호출하도록 수정해야 한다.

수정 예시
     `@Override`
     public List<OrderItem> findByOrderId(Long orderId) {
-        return orderItemJpaRepository.findByOrderId(orderId);
+        return orderItemJpaRepository.findByOrderIdOrderByIdAsc(orderId);
     }
// apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java
List<OrderItem> findByOrderIdOrderByIdAsc(Long orderId);
🤖 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/OrderItemRepositoryImpl.java`
around lines 21 - 23, The current OrderItemRepositoryImpl.findByOrderId uses
orderItemJpaRepository.findByOrderId which does not guarantee deterministic
ordering; update the repository to call a sorted query by adding a method on
OrderItemJpaRepository like findByOrderIdOrderByIdAsc(Long orderId) and change
OrderItemRepositoryImpl.findByOrderId to invoke
orderItemJpaRepository.findByOrderIdOrderByIdAsc(orderId) so returned items are
consistently ordered (e.g., by id ascending) to stabilize tests that access
.get(0).
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java-38-40 (1)

38-40: ⚠️ Potential issue | 🟠 Major

브랜드 삭제 시 soft-delete된 상품의 좋아요가 누락될 수 있다.

운영 관점에서 현재 구현은 deletedAtIsNull 상품 ID만 대상으로 좋아요를 삭제하므로, 이미 soft-delete된 상품에 남은 좋아요 레코드가 잔존해 통계 왜곡 및 데이터 정합성 이슈가 발생할 수 있다.
수정안으로 브랜드 기준 “전체 상품 ID(삭제 여부 무관)”를 조회하는 경로를 별도로 두고, 그 전체 ID로 likeService.deleteAllByProductIds(...)를 수행한 뒤 상품/브랜드 삭제를 진행해야 한다.
추가 테스트로 “좋아요가 있는 상품을 먼저 soft-delete한 후 브랜드 삭제” 시 해당 좋아요가 반드시 hard-delete되는 케이스를 추가해야 한다.

🤖 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/BrandFacade.java`
around lines 38 - 40, The current flow uses
productService.getProductIdsByBrandId(brandId) which returns only non-deleted
products and so misses likes belonging to soft-deleted products; change the call
to retrieve all product IDs regardless of deletedAt (e.g. add/use
productService.getAllProductIdsByBrandId(brandId) or similar) and pass that full
list into likeService.deleteAllByProductIds(productIds) before calling
productService.deleteAllByBrandId(brandId); also add a test that soft-deletes a
product with likes first, then deletes the brand, asserting that likes for that
product are hard-deleted.
apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java-43-56 (1)

43-56: ⚠️ Potential issue | 🟠 Major

인증 실패 메시지가 계정 존재 여부를 노출한다.

운영 관점에서 로그인 ID 미존재와 비밀번호 불일치가 다른 메시지로 관측되면 계정 열거 공격에 악용될 수 있다.
수정안으로 authenticate 내부에서 사용자 조회 실패와 비밀번호 불일치를 동일한 CoreException(동일 코드/동일 메시지)으로 통합하고, loginId를 포함한 상세 식별자는 외부 응답에 노출하지 않아야 한다.
추가 테스트로 “없는 loginId”와 “틀린 비밀번호” 두 케이스가 완전히 동일한 에러 타입/메시지를 반환하는지 검증해야 한다.

🤖 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/user/UserApplicationService.java`
around lines 43 - 56, The current flow exposes whether a loginId exists via
getUserByLoginId's detailed CoreException; change logic so authenticate is the
single place that throws a generic, identical CoreException for both missing
account and bad password: modify getUserByLoginId to return an Optional<User>
(or let it propagate but don't include loginId in its message) and in
authenticate, first attempt to retrieve the user via
userRepository.findByLoginId(loginId).orElse(null) (or Optional#orElse), then if
user is null or passwordEncoder.matches(loginPw, user.getPassword()) is false,
throw new CoreException(ErrorType.NOT_FOUND, "사용자 정보가 올바르지 않습니다."); remove any
loginId-containing messages from thrown exceptions, and add tests asserting that
both "missing loginId" and "wrong password" scenarios produce the exact same
ErrorType and message.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java-34-34 (1)

34-34: ⚠️ Potential issue | 🟠 Major

ldap 헤더가 수신되지만 사용되지 않는다

X-Loopers-Ldap 헤더가 모든 엔드포인트에서 수신되지만 비즈니스 로직에서 활용되지 않는다. 보안 관점에서 관리자 API는 LDAP 인증/인가 검증이 필요할 수 있으며, 감사(audit) 로그에도 기록되어야 한다.

  • 인가가 목적이라면: 인터셉터 또는 서비스 레이어에서 LDAP 검증 로직 추가 필요
  • 감사가 목적이라면: 누가 어떤 작업을 했는지 로그 기록 필요

Also applies to: 44-44, 54-54, 63-63, 73-73

🤖 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/AdminBrandV1Controller.java`
at line 34, The X-Loopers-Ldap header is accepted in AdminBrandV1Controller
methods as the parameter ldap but never used; update the controller to either
(a) delegate ldap validation to a centralized component by registering an
interceptor that invokes a new LdapAuthService.validate(String ldap) before
controller handlers run, or (b) within each handler call an injected
LdapAuthService.validate(ldap) and an AuditService.record(actor=ldap,
action="brand.*", details=...) to record who performed the action; ensure you
reference the existing parameter name ldap and the controller class
AdminBrandV1Controller and add unit tests for both auth failure and audit
logging scenarios.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java-29-32 (1)

29-32: ⚠️ Potential issue | 🟠 Major

findByUserIdAndCreatedAtBetween 메서드 정렬 순서를 JPA Repository에 명시적으로 지정해야 한다.

InMemoryOrderRepository에서는 결과를 ID 역순으로 정렬하지만(sorted(Comparator.comparing(Order::getId).reversed())), OrderJpaRepository의 같은 메서드는 정렬 순서를 지정하지 않는다. 이는 테스트 환경과 실제 운영 환경에서 반환 순서가 달라지므로, 사용자 UI에 표시되는 주문 목록의 순서가 환경마다 다르게 나타난다. 또한 테스트는 통과하나 운영 환경에서 요구사항 불일치가 발생할 수 있다.

OrderJpaRepository.findByUserIdAndCreatedAtBetween 메서드를 @Query 어노테이션으로 명시적 정렬을 추가하거나, OrderRepositoryImpl에서 반환 전에 정렬을 수행하도록 수정한다. 수정 후 정렬 순서가 일관되는지 확인하는 통합 테스트를 작성한다.

🤖 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 29 - 32, The OrderRepositoryImpl currently delegates to
OrderJpaRepository.findByUserIdAndCreatedAtBetween without enforcing a sort,
which causes inconsistent ordering vs InMemoryOrderRepository (which sorts by id
descending); update the implementation so results are consistently ordered by id
descending by either updating OrderJpaRepository.findByUserIdAndCreatedAtBetween
(add an `@Query` with ORDER BY o.id DESC or change method signature to include
Sort) or, if you prefer to keep the repository unchanged, sort the returned List
in OrderRepositoryImpl before returning (use
Comparator.comparing(Order::getId).reversed()); then add an integration test
verifying the returned order is descending by id for
findByUserIdAndCreatedAtBetween.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-38-38 (1)

38-38: ⚠️ Potential issue | 🟠 Major

Bean Validation 어노테이션을 DTO에서 제거하고 도메인 레이어로 이동해야 한다

OrderV1Dto.CreateRequestOrderItemRequest@NotNull, @NotEmpty, @Min 등의 Bean Validation 어노테이션을 포함하고 있다(lines 15, 19-20). 프로젝트는 DTO에 검증 어노테이션을 사용하지 않고 모든 검증을 도메인 레이어에서 수행하는 정책이다.

현재 상태에서 @Valid를 통해 검증이 실행되면 MethodArgumentNotValidException이 발생하여 CoreException → ApiControllerAdvice 표준 에러 처리 패턴을 우회하게 되어 일관성 없는 에러 응답이 발생한다. DTO의 모든 Bean Validation 어노테이션을 제거하고, Order 엔티티 생성자 또는 서비스 레이어에서 검증 로직을 구현해야 한다. 이후 @Valid도 제거한다.

🤖 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`
at line 38, Remove all Bean Validation annotations (`@NotNull`, `@NotEmpty`, `@Min`,
etc.) from OrderV1Dto.CreateRequest and nested OrderItemRequest and remove the
`@Valid` on the controller parameter in OrderV1Controller (the method accepting
OrderV1Dto.CreateRequest). Implement the equivalent validation inside the domain
layer instead — e.g., add validation checks in the Order entity
constructor/factory or in the OrderService create method and throw the project's
CoreException on invalid input so the ApiControllerAdvice handles errors
consistently.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-70-73 (1)

70-73: ⚠️ Potential issue | 🟠 Major

날짜 파싱 실패 시 500 에러 발생 가능

ZonedDateTime.parse가 실패하면 DateTimeParseException이 발생하여 500 에러가 반환된다. 운영 환경에서 클라이언트의 잘못된 입력으로 인한 500 에러는 모니터링 노이즈를 유발하고, 장애 알람 임계치에 영향을 줄 수 있다.

🐛 CoreException으로 감싸는 수정안
 private ZonedDateTime parseZonedDateTime(String value) {
+    try {
         String normalized = value.replace(" ", "+");
         return ZonedDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+    } catch (DateTimeParseException e) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다: " + value, e);
+    }
 }

추가로 import java.time.format.DateTimeParseException;import com.loopers.support.error.CoreException;, import com.loopers.support.error.ErrorType;이 필요하다.

🤖 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 70 - 73, The parseZonedDateTime method currently lets
ZonedDateTime.parse throw DateTimeParseException which results in 500s; catch
java.time.format.DateTimeParseException inside parseZonedDateTime and rethrow a
com.loopers.support.error.CoreException with an appropriate ErrorType (e.g.,
ErrorType.INVALID_ARGUMENT or a BAD_REQUEST-equivalent) including the original
exception message as cause; add the necessary imports for
DateTimeParseException, CoreException, and ErrorType and ensure the thrown
CoreException results in a client-facing 4xx instead of a 500.

@@ -0,0 +1,14 @@
package com.loopers.application.order;

import com.loopers.interfaces.api.order.OrderV1Dto;
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

application 계층이 interfaces DTO를 참조해 의존 방향이 역전된다

운영 관점에서 이 결합은 어댑터 교체(REST 외 배치/메시지 진입점) 시 코어 레이어 재컴파일과 광범위한 파급을 만든다. 수정안으로 OrderCreateCommand는 API DTO를 모르도록 유지하고, OrderV1Dto.CreateRequest -> OrderCreateCommand 매핑은 컨트롤러/파사드(interfaces 계층)로 이동해야 한다. 추가 테스트로 ArchUnit 규칙(com.loopers.application..com.loopers.interfaces..에 의존 금지)을 넣어 재발을 차단해야 한다.

Also applies to: 8-13

🤖 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`
at line 3, OrderCreateCommand currently depends on the API DTO (remove import
com.loopers.interfaces.api.order.OrderV1Dto) causing an inverted dependency;
change it so OrderCreateCommand is a plain application-layer command (no
references to OrderV1Dto or its nested CreateRequest) and move the mapping logic
from OrderV1Dto.CreateRequest -> OrderCreateCommand into the controller/facade
in the interfaces layer where HTTP/API DTOs are handled; update
controller/facade to construct OrderCreateCommand from the incoming
OrderV1Dto.CreateRequest, remove DTO references from OrderCreateCommand, and add
an ArchUnit test enforcing that com.loopers.application.. must not depend on
com.loopers.interfaces...

Comment on lines +86 to +91
public void decreaseStock(int quantity) {
if (this.stockQuantity < quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
this.stockQuantity -= quantity;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

재고 차감에서 0/음수 수량이 허용되어 재고가 증가할 수 있다

운영 관점에서 잘못된 수량이 들어오면 재고 정합성이 깨져 주문 가능 수량과 실제 재고가 불일치하게 된다. 수정안으로 quantity <= 0을 선제 차단하고 부족 재고 검증을 수행해야 한다.

수정 예시
 public void decreaseStock(int quantity) {
+    if (quantity <= 0) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다.");
+    }
     if (this.stockQuantity < quantity) {
         throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
     }
     this.stockQuantity -= quantity;
 }

추가 테스트로 decreaseStock(0), decreaseStock(-1) 예외와 정상 차감 케이스를 함께 검증해야 한다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void decreaseStock(int quantity) {
if (this.stockQuantity < quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
this.stockQuantity -= quantity;
}
public void decreaseStock(int quantity) {
if (quantity <= 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다.");
}
if (this.stockQuantity < quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
this.stockQuantity -= 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/domain/product/Product.java`
around lines 86 - 91, The decreaseStock method allows zero/negative quantities
which can corrupt stock; update Product.decreaseStock to first validate the
input (if quantity <= 0) and throw a CoreException(ErrorType.BAD_REQUEST, "유효하지
않은 수량입니다." or similar), then perform the existing insufficient-stock check (if
this.stockQuantity < quantity) and subtract only after both validations; add
unit tests covering decreaseStock(0), decreaseStock(-1) raising the BAD_REQUEST
exception and a normal successful decrease case to verify correct subtraction of
stockQuantity.

Comment on lines +3 to +4
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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 apps/commerce-api/src/main/java/com/loopers/domain -name "*Repository*.java" -type f

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

Length of output: 638


🏁 Script executed:

if [ -f "apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java" ]; then
  echo "=== ProductRepository.java 파일 존재 ===" && wc -l "apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java"
fi

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

Length of output: 206


🏁 Script executed:

cat -n "apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java" | head -20

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

Length of output: 864


🏁 Script executed:

rg -n "^import org\.springframework" apps/commerce-api/src/main/java/com/loopers/domain/

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

Length of output: 1130


도메인 계층의 Repository 인터페이스가 Spring 프레임워크 타입에 직접 의존해 기술 중립성을 위반한다

domain 패키지에서 Spring의 Page<T>Pageable 타입을 노출하면 도메인 계층이 특정 프레임워크에 묶인다. 이로 인해 추후 JPA 외 다른 저장소 기술로 전환하거나, 도메인 로직을 다른 모듈에서 재사용할 때 Spring 의존성을 함께 끌어안게 되어 변경 파급 범위가 커진다. 또한 도메인 계층을 단독으로 테스트하거나 다른 애플리케이션과 공유하기 어려워진다.

도메인/애플리케이션 계층 전용 페이지네이션 모델(PageRequest, PageResult 등)을 정의하고, Page<T>Pageable에 대한 변환 로직은 infrastructure 구현체에서만 처리해야 한다. infrastructure 계층의 JPA Repository 구현체에서 도메인 인터페이스의 반환값으로 변환할 때 Spring 타입을 사용하도록 격리한다.

추가로 ArchUnit을 도입해 com.loopers.domain.. 패키지에서 org.springframework.. 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/product/ProductRepository.java`
around lines 3 - 4, 도메인 레포지토리(ProductRepository)가 Spring
타입(org.springframework.data.domain.Page, Pageable)에 직접 의존하고 있으니 도메인 전용 페이지네이션
모델(e.g. domain.pagination.PageRequest, domain.pagination.PageResult)을 만든 뒤
ProductRepository의 메서드 시그니처를 해당 도메인 타입으로 교체하고, 기존 Spring의 Page/Pageable 변환 로직은
infrastructure 계층의 JPA 구현체(예: ProductRepositoryJpaImpl)로 이동하여 변환해 반환하도록 구현하세요;
추가로 com.loopers.domain.. 패키지에서 org.springframework.. import를 방지하는 ArchUnit 규칙을
도입해 동일한 침해가 재발하지 않도록 설정하세요.

Comment on lines +27 to +40
@GetMapping
public ApiResponse<PageResponse<OrderV1Dto.OrderResponse>> getOrders(
@RequestHeader("X-Loopers-Ldap") String ldap,
@PageableDefault(size = 20) Pageable pageable
) {
Page<OrderV1Dto.OrderResponse> page = orderService.getAllOrders(pageable)
.map(OrderV1Dto.OrderResponse::from);
return ApiResponse.success(PageResponse.from(page));
}

@GetMapping("/{orderId}")
public ApiResponse<OrderV1Dto.OrderDetailResponse> getOrder(
@RequestHeader("X-Loopers-Ldap") String ldap,
@PathVariable Long orderId
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

echo "[1] WebConfig 인터셉터 설정 확인"
find apps/commerce-api/src -name "WebConfig.java" -o -name "*Config.java" | head -10
echo ""

echo "[2] 관리자 경로 인터셉터 매핑"
rg -n "addInterceptors|api-admin|LoginUserInterceptor" apps/commerce-api/src/main/java --type java -A 5 -B 2 | head -50
echo ""

echo "[3] 컨트롤러 인증 파라미터 방식 - 다른 Admin 컨트롤러 확인"
find apps/commerce-api/src -name "*AdminController.java" -o -name "*Controller.java" | grep -i admin | head -5
echo ""

echo "[4] RequestHeader 사용 현황"
rg -n "@RequestHeader.*X-Loopers-Ldap" apps/commerce-api/src/main/java --type java | head -20
echo ""

echo "[5] LoginUserArgumentResolver 또는 `@LoginUser` 패턴 확인"
rg -n "LoginUserArgumentResolver|@LoginUser" apps/commerce-api/src/main/java --type java | head -20

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

Length of output: 9541


🏁 Script executed:

#!/bin/bash
set -e

echo "[1] WebConfig 전체 내용 확인"
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java

echo ""
echo "[2] AdminOrderV1Controller 전체 내용 확인"
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java

echo ""
echo "[3] AdminBrandV1Controller 전체 내용 확인"
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java

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

Length of output: 7895


🏁 Script executed:

#!/bin/bash
set -e

echo "[1] Admin 관련 인증 설정 확인 (필터/인터셉터)"
find apps/commerce-api/src/main/java -name "*Admin*" -type f | xargs grep -l "Interceptor\|Filter\|Security" 2>/dev/null || echo "No admin-specific auth found"

echo ""
echo "[2] SecurityConfig 또는 인증 관련 설정 확인"
find apps/commerce-api/src -name "*Security*" -o -name "*Auth*" | grep -E "\.java$" | head -10

echo ""
echo "[3] LoginUserInterceptor 구현 확인 - 어떤 경로를 처리하는지"
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java

echo ""
echo "[4] `@RequestHeader` ldap 파라미터가 실제로 사용되는지 다시 확인"
rg -A 10 "@RequestHeader.*X-Loopers-Ldap" apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java

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

Length of output: 2257


관리자 API가 인증 계층 없이 노출되어 있다

/api-admin/v1/** 경로는 WebConfig의 LoginUserInterceptor 등록 범위(/api/v1/**)에서 완전히 제외되어 있다. @RequestHeader("X-Loopers-Ldap") String ldap 파라미터는 메서드 내에서 사용되지 않으며, 어떤 검증도 수행되지 않는다. 현재 상태로는 외부 L4/Nginx 검증이 실패해도 관리자 API는 보호받지 못한다.

LoginUserInterceptor를 /api-admin/**로 확장하거나, 관리자용 별도 인증 메커니즘을 추가해야 한다. 권장 방식은 /api/v1/**@LoginUser Long userId 패턴을 관리자 엔드포인트에도 적용하되, 권한 검증을 @PreAuthorize 또는 인터셉터 단계에서 강제하는 것이다. 사용하지 않는 ldap 파라미터는 제거하거나, 실제 검증과 감사(audit) 로깅에 사용하도록 개선한다.

🤖 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/AdminOrderV1Controller.java`
around lines 27 - 40, AdminOrderV1Controller exposes admin endpoints without
authentication: remove the unused `@RequestHeader`("X-Loopers-Ldap") String ldap
from getOrders/getOrder and enforce authentication/authorization either by
expanding LoginUserInterceptor registration to include /api-admin/** in
WebConfig or by adding the existing `@LoginUser` Long userId parameter to these
controller methods and protecting them with `@PreAuthorize` checks (or an
admin-specific interceptor); update audit/logging to use ldap only if you
implement real validation.

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: 3

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java (1)

4-25: ⚠️ Potential issue | 🟠 Major

컨트롤러의 @Valid는 프로젝트의 오류 처리 단일 경로와 충돌할 수 있다.

Line 25의 @ValidMethodArgumentNotValidException 분기를 열어 CoreException -> ApiControllerAdvice 표준 응답 포맷 일관성을 깨뜨릴 수 있다.
수정안으로 @Validjakarta.validation.Valid import를 제거하고, DTO→Command 변환 후 도메인/서비스 검증만 사용해 오류 경로를 단일화하는 것이 좋다.
추가 테스트로 잘못된 회원가입 입력(예: 잘못된 이메일/형식) 시에도 에러 응답이 항상 동일한 ApiResponse 포맷으로 내려오는 E2E 테스트를 권장한다.

수정 예시
-import jakarta.validation.Valid;
...
-    public ApiResponse<Void> signUp(`@Valid` `@RequestBody` UserV1Dto.CreateRequest request) {
+    public ApiResponse<Void> signUp(`@RequestBody` UserV1Dto.CreateRequest request) {

Based on learnings: "Do not use Bean Validation annotations (e.g., Valid, NotBlank, Email) on DTOs in this project. Move validation logic into the domain layer..." 및 "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice... Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling."

🤖 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/SignUpV1Controller.java`
around lines 4 - 25, The controller currently uses jakarta.validation.@Valid on
the signUp endpoint which opens MethodArgumentNotValidException and bypasses the
project's unified CoreException -> ApiControllerAdvice flow; remove the `@Valid`
annotation and the jakarta.validation.Valid import from SignUpV1Controller,
update the signUp method signature to accept UserV1Dto.CreateRequest without
`@Valid`, and ensure any validation is moved into the domain/service layer (e.g.,
convert UserV1Dto.CreateRequest to a SignUpCommand within SignUpV1Controller and
have SignUpService or the domain throw CoreException on validation failures) so
all errors flow through SignUpV1Controller.signUp -> SignUpService and adhere to
ApiResponse formatting.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java (1)

172-199: ⚠️ Potential issue | 🟡 Minor

테스트 메서드명과 기대 상태코드가 불일치한다.

Line 174의 메서드명은 BadRequest를 가리키지만 실제 검증은 Line 199에서 NOT_FOUND를 기대하여, 장애 분석 시 테스트 실패 원인 해석을 혼동시킬 수 있다.
수정안으로 메서드명을 기대 결과에 맞게 returnsNotFound_whenCurrentPasswordNotMatches로 정렬하는 것이 좋다.
추가 테스트로 상태코드-메서드명 규칙을 강제하는 테스트 네이밍 규칙 점검(예: ArchUnit/커스텀 규칙) 도입을 권장한다.

수정 예시
-        void returnsBadRequest_whenCurrentPasswordNotMatches() {
+        void returnsNotFound_whenCurrentPasswordNotMatches() {
🤖 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/UserV1ApiE2ETest.java`
around lines 172 - 199, Rename the test method
returnsBadRequest_whenCurrentPasswordNotMatches to
returnsNotFound_whenCurrentPasswordNotMatches so the method name matches the
asserted expectation (HttpStatus.NOT_FOUND) in the test; update any references
or annotations that call this method (e.g., the `@Test` method in
UserV1ApiE2ETest) to use the new name to avoid confusion during test failure
analysis.
♻️ Duplicate comments (1)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)

3-4: ⚠️ Potential issue | 🟠 Major

도메인 Repository가 Spring Page/Pageable에 직접 의존해 계층 경계를 침범한다

Line 3-4와 Line 12-13에서 프레임워크 타입이 도메인 계약에 노출되어 운영 중 저장소 기술 변경 시 파급 범위가 커지고, 도메인 단독 테스트/재사용성이 떨어진다. 도메인 전용 페이지 모델(PageQuery, PageResult)로 인터페이스를 치환하고, Spring 타입 변환은 infrastructure 구현체로 격리해야 한다. 추가로 com.loopers.domain..에서 org.springframework.. import를 금지하는 ArchUnit 테스트를 추가해 재발을 차단해야 한다.

As per coding guidelines "/domain//*.java: 도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다."

Also applies to: 12-13

🤖 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 exposes framework types
org.springframework.data.domain.Page and Pageable in its domain interface
(imports on Page/Pageable and methods using them); replace those references with
domain-level pagination contracts such as PageQuery (request) and PageResult<T>
(response), move any conversion between PageQuery/PageResult and Spring
Page/Pageable into the infrastructure implementation of ProductRepository, and
add an ArchUnit rule that forbids imports of org.springframework.. within
com.loopers.domain.. to prevent regressions.
🟠 Major comments (26)
apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java-5-6 (1)

5-6: ⚠️ Potential issue | 🟠 Major

items 방어적 복사와 입력 가드가 없어 주문 커맨드가 생성 후 변조될 수 있다.

운영에서 검증 직후 원본 리스트가 바뀌면 주문 품목이 의도와 다르게 저장되어 데이터 정합성 장애로 이어질 수 있다. 레코드 compact constructor에서 userId/items를 즉시 검증하고 List.copyOf(items)로 불변화하는 수정이 필요하다. 추가 테스트로는 (1) 생성 후 원본 리스트 변경 시 command.items()가 변하지 않는지, (2) null/empty items, null userId 입력이 일관된 BAD_REQUEST로 실패하는지를 넣어야 한다.

제안 수정안
 package com.loopers.application.order;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import java.util.List;
 
 public record OrderCreateCommand(Long userId, List<OrderItemCommand> items) {
+    public OrderCreateCommand {
+        if (userId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수값이다.");
+        }
+        if (items == null || items.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품은 최소 1개 이상이어야 한다.");
+        }
+        items = List.copyOf(items);
+    }
 }
As per coding guidelines, `**/*.java`에서는 "null 처리, 방어적 복사, 불변성"을 점검해야 한다. Based on learnings, 오류 처리는 CoreException으로 일원화해야 일관된 응답 포맷을 유지할 수 있다.
🤖 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 - 6, OrderCreateCommand currently accepts a mutable items list
and lacks input guards; update the record's compact constructor in
OrderCreateCommand to validate userId != null and items != null &&
!items.isEmpty(), wrap the items field with List.copyOf(items) to make it
immutable, and throw the unified CoreException on invalid inputs; then add tests
that (1) mutate the original source list after construction and assert
command.items() is unchanged, and (2) assert constructions with null userId,
null items, and empty items each fail with a BAD_REQUEST CoreException.
apps/commerce-api/src/main/java/com/loopers/application/user/SignUpService.java-18-27 (1)

18-27: ⚠️ Potential issue | 🟠 Major

회원가입 중복 검사에 TOCTOU 경쟁 조건이 있다.

Line 18-20의 사전 조회 후 Line 26 저장 방식은 동시 요청에서 둘 다 통과할 수 있어, 운영에서는 간헐적 중복 삽입 시도 또는 DB 예외 노출로 이어질 수 있다.
수정안으로 DB 유니크 제약 위반을 CoreException(CONFLICT)로 매핑하고 cause를 보존해 일관된 오류 응답과 추적 가능성을 확보하는 것이 좋다.
추가 테스트로 동일 loginId 동시 가입(예: CountDownLatch)에서 1건만 성공하고 나머지는 CONFLICT로 귀결되는 통합 테스트를 권장한다.

수정 예시
+import org.springframework.dao.DataIntegrityViolationException;
...
     `@Transactional`
     public void signUp(SignUpCommand command) {
         if (userRepository.findByLoginId(command.loginId()).isPresent()) {
             throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
         }

         PasswordPolicyValidator.validate(command.password(), command.birthDate());
         String encodedPassword = passwordEncoder.encode(command.password());
         User user = User.create(command, encodedPassword);

-        userRepository.save(user);
+        try {
+            userRepository.save(user);
+        } catch (DataIntegrityViolationException e) {
+            throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.", e);
+        }
     }

As per coding guidelines **/*Service*.java: "멱등성과 중복 처리 방지 전략을 점검한다." 및 **/*.java: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/user/SignUpService.java`
around lines 18 - 27, The pre-check using userRepository.findByLoginId(...)
followed by userRepository.save(user) has a TOCTOU race; instead wrap the save
to catch database unique-constraint/duplicate-key exceptions (thrown by
userRepository.save or the JPA/DB layer) and rethrow a
CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.", cause) preserving the
original exception as the cause and logging a separate detailed message; keep
PasswordPolicyValidator.validate, passwordEncoder.encode and User.create as-is,
but ensure the save exception handling maps DB uniqueness violations to CONFLICT
and rethrows with the original cause, and add an integration test that performs
concurrent sign-ups (e.g., CountDownLatch) asserting one success and others
receive CONFLICT.
apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java-36-43 (1)

36-43: ⚠️ Potential issue | 🟠 Major

도메인 생성 시 식별자 불변식 검증이 누락되었다.

Line 41에서 userId/productId가 null 또는 0 이하로 들어오면 DB 저장 시점 예외로 늦게 실패하여, 운영에서는 500 응답과 원인 추적 비용 증가로 이어질 수 있다.
수정안으로 create 또는 생성자에서 식별자 유효성(null/양수)을 즉시 검증해 도메인 경계에서 실패시키는 것이 안전하다.
추가 테스트로 Like.create(null, 1L), Like.create(0L, 1L), Like.create(1L, null) 케이스의 예외 흐름을 단위 테스트로 고정하는 것이 좋다.

수정 예시
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
...
     private Like(Long userId, Long productId) {
+        if (userId == null || userId <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 사용자 ID이다.");
+        }
+        if (productId == null || productId <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 상품 ID이다.");
+        }
         this.userId = userId;
         this.productId = productId;
     }

As per coding guidelines **/domain/**/*.java: "엔티티/값 객체/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/domain/like/Like.java` around
lines 36 - 43, The Like domain factory currently allows invalid identifiers; add
null and positive-value validation in the constructor or static factory (e.g.,
in Like.create and/or private Like(Long userId, Long productId)) to immediately
throw IllegalArgumentException with clear messages if userId or productId is
null or <= 0, update callers accordingly, and add unit tests asserting
exceptions for Like.create(null, 1L), Like.create(0L, 1L), and Like.create(1L,
null) to lock the behavior.
apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java-14-16 (1)

14-16: ⚠️ Potential issue | 🟠 Major

@Modifying 삭제 쿼리의 트랜잭션 경계를 리포지토리 메서드 수준에서 명시하면 안전성이 높아진다.

Line 14-16의 deleteByUserIdAndProductId 메서드는 @Modifying 쿼리로 명시적인 트랜잭션 관리가 필수다. 현재는 호출부인 LikeService.cancel()LikeFacade.cancel()에서 @Transactional이 선언되어 있어 즉각적 위험은 없지만, 리포지토리 메서드는 public 인터페이스이므로 향후 다른 곳에서 직접 호출될 가능성이 있다. 메서드 수준에서 트랜잭션을 명시하면 호출부의 트랜잭션 설정과 무관하게 안정적으로 동작하며, 리팩터링 시 실수로 @Transactional 제거나 readOnly=true 전환으로 인한 TransactionRequiredException을 방지할 수 있다.

메서드에 @Transactional@Modifying(clearAutomatically = true, flushAutomatically = true) 옵션을 추가하여 영속성 컨텍스트 일관성을 보장하도록 개선한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java`
around lines 14 - 16, Add method-level transaction semantics to the repository:
on LikeJpaRepository's deleteByUserIdAndProductId method, annotate it with
`@Transactional` and change `@Modifying` to `@Modifying`(clearAutomatically = true,
flushAutomatically = true) so the delete runs in its own transactional boundary
and the persistence context is flushed/cleared automatically; ensure the method
signature and `@Param` names remain unchanged.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java-33-40 (1)

33-40: ⚠️ Potential issue | 🟠 Major

도메인 불변식 검증이 수량에만 제한되어 잘못된 주문 항목이 저장될 수 있다.

Line 35-39에서 orderId, productId, productName, unitPrice 검증이 없어 null/공백/음수 데이터가 엔티티에 유입될 수 있다. 이 경우 운영에서는 저장 시점에만 실패가 발생해 장애 원인 추적이 지연된다. 생성 시점에 CoreException(BAD_REQUEST)로 즉시 차단해야 한다.
수정안은 생성자 진입 시 필수값과 범위를 함께 검증하는 메서드로 확장하는 방식이다.
추가 테스트는 orderId null/0, productId null/0, productName null/blank, unitPrice < 0 각각에 대해 BAD_REQUEST를 검증하면 된다.

수정 예시 diff
 private OrderItem(Long orderId, Long productId, String productName, long unitPrice, Integer quantity) {
-    validateQuantity(quantity);
+    validate(orderId, productId, productName, unitPrice, quantity);
     this.orderId = orderId;
     this.productId = productId;
     this.productName = productName;
     this.unitPrice = unitPrice;
     this.quantity = quantity;
 }
 
+private void validate(Long orderId, Long productId, String productName, long unitPrice, Integer quantity) {
+    if (orderId == null || orderId < 1) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID가 유효하지 않습니다.");
+    }
+    if (productId == null || productId < 1) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID가 유효하지 않습니다.");
+    }
+    if (productName == null || productName.isBlank()) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다.");
+    }
+    if (unitPrice < 0) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "상품 단가는 0 이상이어야 합니다.");
+    }
+    validateQuantity(quantity);
+}
+
 private void validateQuantity(Integer quantity) {
     if (quantity == null || quantity < 1) {
         throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다.");
     }
 }

As per coding guidelines **/*.java: "null 처리 ... 점검한다." 및 **/domain/**/*.java: "도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다."

Also applies to: 46-49

🤖 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 33 - 40, The constructor OrderItem(Long, Long, String, long,
Integer) only validates quantity; add a new private validator (e.g.,
validateOrderItemFields or validateRequiredFields) and call it from the
constructor (and any other constructor/factory overloads such as the one at
lines ~46-49) to assert orderId != null && orderId > 0, productId != null &&
productId > 0, productName != null && not blank, and unitPrice >= 0; on any
violation throw CoreException(BAD_REQUEST) so invalid order items are rejected
at creation time instead of persisting bad data.
apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java-3-4 (1)

3-4: 🛠️ Refactor suggestion | 🟠 Major

도메인 저장소 인터페이스가 Spring 타입에 직접 의존해 계층 경계를 위반한다.

domain 계층의 BrandRepository가 Page, Pageable 같은 Spring 프레임워크 타입을 직접 import하고 메서드 시그니처에 노출하면, 프레임워크 교체나 테스트 더블 구성 시 도메인 계층까지 영향을 받는다. 현재 OrderRepository, ProductRepository도 동일한 문제를 가진 상태다.

도메인 전용 페이지 요청/응답 모델(DomainPageable, DomainPage 등)을 정의하고, Spring Pageable 변환은 infrastructure 계층의 BrandRepositoryImpl에서만 처리하도록 분리해야 한다. 이렇게 하면 InMemoryBrandRepository 같은 테스트 더블도 도메인 계약에만 의존하면 된다.

또한 ArchUnit 규칙으로 com.loopers.domain.. 패키지가 org.springframework.. 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/brand/BrandRepository.java`
around lines 3 - 4, BrandRepository (and similarly OrderRepository,
ProductRepository) currently expose Spring types Page and Pageable in the domain
layer which couples the domain to Spring; change the domain repository
interfaces (e.g., BrandRepository) to use domain-only paging abstractions (e.g.,
DomainPageable for requests and DomainPage<T> for responses) and remove imports
of org.springframework.* from the domain package, then implement conversion
between DomainPageable <-> Pageable inside the infrastructure implementation
class (BrandRepositoryImpl) where Spring types are allowed; update
InMemoryBrandRepository to implement the domain paging contract for tests;
finally add an ArchUnit rule to forbid imports of org.springframework.. from
com.loopers.domain.. to prevent regressions.
apps/commerce-api/src/test/java/com/loopers/domain/brand/InMemoryBrandRepository.java-39-44 (1)

39-44: ⚠️ Potential issue | 🟠 Major

페이지네이션 계약을 무시해 테스트가 실제 저장소 동작과 어긋난다.

Line 39-44는 Pageable을 받지만 offset/size를 적용하지 않아 항상 전체 데이터를 반환한다. HashMap 기반 저장이므로 정렬 순서도 보장되지 않는다. 이 상태에서는 페이징을 사용한 테스트가 통과해도 운영 DB 조회 결과와 달라 장애를 사전에 탐지하지 못한다.

수정안은 deletedAt이 null인 목록을 정렬한 뒤 offset/limit으로 content를 슬라이싱해 PageImpl을 생성하는 것이다.

수정 예시 diff
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageImpl;
 import org.springframework.data.domain.Pageable;

 import java.util.Collection;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;

     `@Override`
     public Page<Brand> findAllByDeletedAtIsNull(Pageable pageable) {
-        var list = store.values().stream()
-                        .filter(b -> b.getDeletedAt() == null)
-                        .toList();
-        return new PageImpl<>(list, pageable, list.size());
+        var filtered = store.values().stream()
+                            .filter(b -> b.getDeletedAt() == null)
+                            .sorted(Comparator.comparing(Brand::getId))
+                            .toList();
+
+        int start = (int) pageable.getOffset();
+        int end = Math.min(start + pageable.getPageSize(), filtered.size());
+        var content = start >= filtered.size() ? List.<Brand>of() : filtered.subList(start, end);
+
+        return new PageImpl<>(content, pageable, filtered.size());
     }

추가 테스트는 PageRequest.of(0, 1)PageRequest.of(1, 1) 각각에서 content 크기/요소/totalElements를 검증하면 된다.

🤖 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/InMemoryBrandRepository.java`
around lines 39 - 44, The method findAllByDeletedAtIsNull currently ignores the
Pageable and returns the entire unsorted store; update it to (1) collect
store.values() filtered by b.getDeletedAt() == null, (2) sort deterministically
(e.g., by b.getId() or b.getCreatedAt()) to avoid HashMap order variance, (3)
apply pageable.getOffset() and pageable.getPageSize() to slice the sorted list
into the page content, and (4) construct new PageImpl<>(pagedContent, pageable,
totalFilteredCount). Modify the code in InMemoryBrandRepository
(findAllByDeletedAtIsNull) and add tests using PageRequest.of(0,1) and
PageRequest.of(1,1) to assert content size/elements and totalElements.
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java-72-74 (1)

72-74: ⚠️ Potential issue | 🟠 Major

changeVisibility에 null 방어가 없어 도메인 불변식이 깨질 수 있다

운영에서 null이 유입되면 비정상 상태가 영속화 직전까지 유지되고, 최종적으로 서버 오류(500)로 전이되어 사용자 입력 오류를 장애로 처리하게 된다.
수정안은 visibility == null을 즉시 차단하고 CoreException(BAD_REQUEST)를 반환하는 것이다.
추가 테스트로 changeVisibility(null) 예외, VISIBLE -> HIDDEN 정상 전이, HIDDEN -> VISIBLE 복귀 전이를 함께 검증해야 한다.

🛡️ 제안 수정안
     public void changeVisibility(Visibility visibility) {
+        if (visibility == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "노출 상태는 필수값입니다.");
+        }
         this.visibility = visibility;
     }

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/product/Product.java`
around lines 72 - 74, The changeVisibility method in Product must guard against
null to preserve domain invariants: update Product.changeVisibility to throw a
CoreException with BAD_REQUEST when visibility == null instead of assigning
null, and keep the current assignment for non-null values; add unit tests that
assert changeVisibility(null) throws the CoreException, that a VISIBLE -> HIDDEN
transition succeeds, and that HIDDEN -> VISIBLE transition succeeds to cover
normal and edge flows.
apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java-41-57 (1)

41-57: ⚠️ Potential issue | 🟠 Major

findActiveProducts가 페이징/좋아요 정렬 계약을 충족하지 못해 테스트 신뢰도를 떨어뜨린다

운영에서 테스트 더블이 실제 저장소 동작과 다르면 정렬/페이징 회귀가 테스트를 통과하고 운영에서만 장애로 드러난다.
수정안은 sort == null 기본값 처리, LIKES_DESClikeCount 기준 정렬, pageable offset/size 기반 슬라이싱을 적용하는 것이다.
추가 테스트로 (1) page=1,size=2 슬라이싱 정확성, (2) likeCount 내림차순 정렬, (3) sort=null 기본 정렬 동작을 검증해야 한다.

🔧 제안 수정안
     `@Override`
     public Page<Product> findActiveProducts(Long brandId, ProductOrder sort, Pageable pageable) {
+        ProductOrder effectiveSort = (sort == null) ? ProductOrder.LATEST : sort;
+
         Stream<Product> stream = store.values().stream()
                 .filter(Product::isActive);
@@
-        Comparator<Product> comparator = switch (sort) {
-            case PRICE_ASC -> Comparator.comparing(Product::getPrice);
-            // likes_desc는 Like 도메인 구현 전이므로 인메모리에서 id 기준으로 대체
-            case LIKES_DESC, LATEST -> Comparator.comparing(Product::getId).reversed();
+        Comparator<Product> comparator = switch (effectiveSort) {
+            case PRICE_ASC -> Comparator.comparing(Product::getPrice)
+                                        .thenComparing(Product::getId);
+            case LIKES_DESC -> Comparator.comparing(Product::getLikeCount, Comparator.reverseOrder())
+                                         .thenComparing(Product::getId, Comparator.reverseOrder());
+            case LATEST -> Comparator.comparing(Product::getId, Comparator.reverseOrder());
         };
 
-        List<Product> list = stream.sorted(comparator).toList();
-        return new PageImpl<>(list, pageable, list.size());
+        List<Product> list = stream.sorted(comparator).toList();
+        int start = (int) pageable.getOffset();
+        int end = Math.min(start + pageable.getPageSize(), list.size());
+        List<Product> content = start >= list.size() ? List.of() : list.subList(start, end);
+        return new PageImpl<>(content, pageable, list.size());
     }
🤖 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/InMemoryProductRepository.java`
around lines 41 - 57, The in-memory findActiveProducts method doesn't honor null
sort, doesn't sort LIKES_DESC by actual likeCount, and returns an unsliced Page;
fix findActiveProducts to default sort when sort==null (choose LATEST or
PRICE_ASC per contract), build the Comparator to handle PRICE_ASC
(Product::getPrice), LIKES_DESC
(Comparator.comparing(Product::getLikeCount).reversed()), and LATEST
(Comparator.comparing(Product::getId).reversed()), collect the filtered/sorted
list, compute total = list.size(), then apply pageable slicing using int offset
= (int) pageable.getOffset() and int size = pageable.getPageSize(), create
subList bounded by offset and Math.min(offset+size,total), and return new
PageImpl<>(subList, pageable, total).
apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandV1Dto.java-10-20 (1)

10-20: ⚠️ Potential issue | 🟠 Major

DTO 계층 Bean Validation 사용을 제거하고 도메인 검증으로 통일해야 한다. 단, BrandV1Dto만이 아닌 프로젝트 전체 DTO의 일관된 수정이 필요하다.

현재 BrandV1Dto의 @NotBlank는 실제로 MethodArgumentNotValidException 경로로 분기되어 CoreException 기반의 공통 응답 포맷과 분리된다. 그런데 UserV1Dto, ProductV1Dto, OrderV1Dto도 동일하게 @NotBlank, @NotNull, @NotEmpty 등의 Bean Validation을 사용 중이고, 모든 컨트롤러에서 @Valid를 적용하고 있다. 이는 프로젝트 전체가 현재 일관된 Bean Validation 패턴을 따르고 있다는 의미이므로, BrandV1Dto만 수정하면 오히려 일관성이 깨진다.

수정 방향은 두 가지 중 선택해야 한다:

  1. 프로젝트 전체 DTO에서 Bean Validation을 제거하고 도메인 검증으로 통일 (현재 Brand.validateName 패턴처럼)
  2. 현재 Bean Validation + MethodArgumentNotValidException 핸들링 패턴을 유지

첫 번째 방향을 선택한다면, UserV1Dto, ProductV1Dto, OrderV1Dto의 모든 @NotBlank, @NotNull, @NotEmpty를 제거하고 해당 도메인 엔티티(User, Product, Order)의 검증을 강화해야 한다. 또한 컨트롤러의 @Valid도 제거되어야 한다.

🔧 BrandV1Dto 수정안 (첫 번째 방향 선택 시)
 import com.loopers.application.brand.BrandInfo;
-import jakarta.validation.constraints.NotBlank;
 
 import java.time.ZonedDateTime;
@@
     public record CreateRequest(
-            `@NotBlank`(message = "브랜드 이름은 필수값입니다.")
             String name,
             String description
     ) {}
 
     public record UpdateRequest(
-            `@NotBlank`(message = "브랜드 이름은 필수값입니다.")
             String name,
             String description
     ) {}

추가 테스트로 AdminBrandV1ApiE2ETest에서 name=null, name="", name=" " 케이스에 대해 상태코드뿐 아니라 공통 에러 바디(code/message) 일관성까지 검증해야 한다. 단, 이는 프로젝트 전체 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/brand/dto/BrandV1Dto.java`
around lines 10 - 20, The DTOs (BrandV1Dto CreateRequest/UpdateRequest)
currently use Bean Validation annotations (`@NotBlank`) which diverges from the
project-wide error handling; choose one approach and apply it consistently — I
recommend removing DTO-level Bean Validation and moving validation to the domain
(follow Brand.validateName pattern): remove `@NotBlank/`@NotNull/@NotEmpty from
BrandV1Dto CreateRequest and UpdateRequest and from all other DTOs (UserV1Dto,
ProductV1Dto, OrderV1Dto), remove `@Valid` usage in controllers, and reinforce
validation logic inside domain entities (e.g., Brand.validateName,
User.validate..., Product.validate..., Order.validate...) so exceptions bubble
through the CoreException-based handler; update AdminBrandV1ApiE2ETest to assert
unified error body (code/message) for name=null, name="", name="  " cases after
changes.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java-286-295 (1)

286-295: ⚠️ Potential issue | 🟠 Major

정렬 검증이 누락 데이터를 허용할 수 있어 회귀를 놓칠 수 있다.

운영 관점에서 정렬 API가 일부 상품을 누락해도 indexOf 기반 비교는 조건부로 통과할 수 있어 배포 전 탐지가 약해진다. 정렬 검증은 반환 ID를 명시 순서로 containsExactly(...)로 검증해 누락과 순서를 동시에 확인해야 한다. 추가 테스트로는 PRICE_ASC/LIKES_DESC 각각에서 예상 3건이 정확히 동일 순서로 반환되는지 검증이 필요하다.

수정 예시 diff
-            assertAll(
-                    () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
-                    () -> assertThat(ids.indexOf(cheapProduct.getId()))
-                            .isLessThan(ids.indexOf(middleProduct.getId()))
-                            .isLessThan(ids.indexOf(expensiveProduct.getId()))
-            );
+            assertAll(
+                    () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+                    () -> assertThat(ids).containsExactly(
+                            cheapProduct.getId(),
+                            middleProduct.getId(),
+                            expensiveProduct.getId()
+                    )
+            );
@@
-            assertAll(
-                    () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
-                    () -> assertThat(ids.indexOf(highLikes.getId()))
-                            .isLessThan(ids.indexOf(middleLikes.getId()))
-                            .isLessThan(ids.indexOf(lowLikes.getId()))
-            );
+            assertAll(
+                    () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+                    () -> assertThat(ids).containsExactly(
+                            highLikes.getId(),
+                            middleLikes.getId(),
+                            lowLikes.getId()
+                    )
+            );

As per coding guidelines: **/*Test*.java에서는 플래키 가능성과 검증 강도를 점검해야 한다.

Also applies to: 325-334

🤖 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/ProductV1ApiE2ETest.java`
around lines 286 - 295, The current test in ProductV1ApiE2ETest uses
indexOf-based checks on ids (derived from
response.getBody().data().content().stream().map(ProductV1Dto.ProductResponse::id))
which can pass when items are missing; change the assertion to validate both
presence and exact order by asserting the returned ID list exactly equals the
expected sequence of cheapProduct.getId(), middleProduct.getId(),
expensiveProduct.getId() (and add separate exact-order assertions for PRICE_ASC
and LIKES_DESC cases referenced later at lines ~325-334). Replace the indexOf
chain with a deterministic containsExactly/equals-style assertion against the
ids list to ensure no items are omitted and order is correct.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

DTO의 Bean Validation 어노테이션 사용을 제거해야 한다.

이 프로젝트는 검증을 도메인 레이어에서 수행하고 CoreException을 통해 ApiControllerAdvice로 통일하는 아키텍처를 따르고 있다. 현재 ProductV1Dto@NotNull, @NotBlank 어노테이션은 프로젝트 정책을 위반하며, Bean Validation 예외(MethodArgumentNotValidException)가 발생하면 도메인 레이어의 CoreException 흐름을 벗어나 응답 포맷과 에러 코드가 분산될 수 있다. Product.create()Product.update()에서 이미 검증이 구현되어 있으므로, DTO의 어노테이션을 제거하면 검증 로직은 유지되고 오류 처리가 통일된다. E2E 테스트에서 잘못된 요청(null 값, 빈 문자열)이 항상 동일한 에러 포맷과 코드를 반환하는지 검증해야 한다.

수정 예시 diff
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
@@
     public record CreateRequest(
-            `@NotNull` Long brandId,
-            `@NotBlank`(message = "상품 이름은 필수값입니다.") String name,
+            Long brandId,
+            String name,
             String description,
-            `@NotNull` Integer price,
-            `@NotNull` Integer stockQuantity
+            Integer price,
+            Integer stockQuantity
     ) {}
@@
     public record UpdateRequest(
-            `@NotBlank`(message = "상품 이름은 필수값입니다.") String name,
+            String name,
             String description,
-            `@NotNull` Integer price,
-            `@NotNull` Integer stockQuantity,
+            Integer price,
+            Integer stockQuantity,
             Product.Visibility visibility
     ) {}
🤖 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/dto/ProductV1Dto.java`
around lines 5 - 7, ProductV1Dto에 선언된 Bean Validation 어노테이션(`@NotNull`,
`@NotBlank`)을 제거하여 DTO에서 검증을 하지 않도록 변경하세요; 검증은 이미 도메인에서 처리되고 있으므로 ProductV1Dto의
`@NotNull/`@NotBlank 애노테이션을 삭제하고 관련 import(jakarta.validation.*)도 정리하여
MethodArgumentNotValidException가 발생하지 않게 하며, 기존 도메인의 검증 흐름(Product.create(),
Product.update())과 ApiControllerAdvice/ CoreException 기반의 일관된 에러 처리가 유지되도록 변경 및
E2E 테스트에서 null/빈값 요청이 동일한 에러 포맷과 코드로 응답하는지 검증하세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java-10-10 (1)

10-10: ⚠️ Potential issue | 🟠 Major

주문 조회가 무제한 리스트 반환이라 대량 기간 조회에서 장애 위험이 크다.

Line 10은 기간이 넓어질수록 한 번에 전량 적재되어 응답 지연과 메모리 급증을 유발할 수 있다. 운영 관점에서 타임아웃/GC 스파이크로 이어질 수 있으니, Pageable 기반 조회와 고정 정렬(createdAt, id)로 바꾸는 것이 안전하다. 추가로 (user_id, created_at) 인덱스 유무를 확인해야 한다.
추가 테스트로 사용자 주문 1,000건 이상 데이터에서 페이지 단위 조회가 크기 제한을 지키고 정렬이 안정적으로 유지되는지 통합 테스트를 보강해야 한다.

수정 예시
 public interface OrderJpaRepository extends JpaRepository<Order, Long> {
-    List<Order> findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt);
+    Page<Order> findByUserIdAndCreatedAtBetween(
+            Long userId,
+            ZonedDateTime startAt,
+            ZonedDateTime endAt,
+            Pageable pageable
+    );
 }

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/order/OrderJpaRepository.java`
at line 10, Replace the unbounded List-returning query in OrderJpaRepository
(method findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt,
ZonedDateTime endAt)) with a Pageable-returning signature (e.g., Page<Order>
findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt,
ZonedDateTime endAt, Pageable pageable)) and require callers to pass a Pageable
that enforces a fixed sort (createdAt asc/desc, then id) to guarantee stable
ordering; also verify or add a composite index on (user_id, created_at) in the
schema/migration and add an integration test that inserts >1,000 orders for a
user and asserts paged retrieval respects page size limits and stable sort
across pages.
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java-20-21 (1)

20-21: ⚠️ Potential issue | 🟠 Major

대량 브랜드 삭제에서 productIds 선적재 방식은 성능·안정성 리스크가 크다.

Line 20~Line 21은 브랜드 상품 수가 많을 때 애플리케이션 메모리 사용량과 DB IN 파라미터 한계 문제를 유발해 배치성 장애로 이어질 수 있다. 운영 관점에서는 삭제 조건을 DB로 내려 brandId 기준 벌크 삭제로 바꾸는 것이 안전하다.
수정안으로 LikeService.deleteAllByBrandId(brandId) 경로를 추가해 저장소에서 조인/서브쿼리로 삭제하고, 파사드에서는 ID 리스트 적재를 제거하는 구조를 권장한다.
추가 테스트로 상품/좋아요가 많은 브랜드(예: 10,000건+) 삭제 시 시간/메모리 급증 없이 likes→products→brand 정합성이 유지되는 통합 테스트를 보강해야 한다.

수정 방향 예시
     `@Transactional`
     public void delete(Long brandId) {
-        List<Long> productIds = productService.getProductIdsByBrandId(brandId);
-        likeService.deleteAllByProductIds(productIds);
+        likeService.deleteAllByBrandId(brandId);
         productService.deleteAllByBrandId(brandId);
         brandService.delete(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/application/brand/BrandFacade.java`
around lines 20 - 21, 현재 BrandFacade에서
productService.getProductIdsByBrandId(brandId)로 모든 상품 ID를 메모리에 올린 뒤
likeService.deleteAllByProductIds(productIds)를 호출하는 방식은 대량 데이터에서 메모리·DB IN 한계
문제를 유발하므로, productId 리스트 적재를 제거하고 LikeService에 deleteAllByBrandId(brandId) 메서드를
추가하여 저장소 레벨에서 brandId 기준으로 bulk delete(조인 또는 서브쿼리 사용)하도록 변경하세요; BrandFacade는
productService.getProductIdsByBrandId와 likeService.deleteAllByProductIds 호출을
제거하고 likeService.deleteAllByBrandId(brandId)만 호출하도록 수정하며, LikeRepository/DAO 쪽에
brandId 기반 삭제 쿼리를 구현하고 10,000건 이상 브랜드 삭제 시 메모리·시간 영향을 검증하는 통합 테스트(좋아요→상품→브랜드
정합성)를 추가하세요.
apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java-23-35 (1)

23-35: ⚠️ Potential issue | 🟠 Major

인터셉터 제외 경로와 @LoginUser 해석이 분리되어 인증 누락이 500으로 변질될 위험이 있다.

현재 구조에서는 인터셉터가 실행되지 않은 요청에서도 LoginUserArgumentResolver가 null userId를 반환할 수 있어, 인증 실패가 일관된 인증 에러가 아니라 NPE성 500으로 번질 수 있다. 운영 관점에서 보안 이벤트 분류와 장애 분석이 어려워지는 패턴이다. LoginUserArgumentResolver에서 userId 부재 시 CoreException으로 즉시 실패시키고, 인증 필요 경로를 include 기준으로 더 명확히 제한하는 구성이 안전하다. 추가로 “인터셉터 제외 경로 + @LoginUser” 요청이 일관된 에러 포맷으로 반환되는 MVC/E2E 테스트를 추가해야 한다.

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/config/WebConfig.java`
around lines 23 - 35, Change authentication flow to fail fast and unify errors:
update LoginUserArgumentResolver so when it cannot resolve a userId it throws
CoreException (so errors route through ApiControllerAdvice) instead of returning
null; tighten WebConfig interceptor configuration to use explicit
includePathPatterns for authenticated endpoints (rather than relying only on
excludePathPatterns) so only intended routes invoke the interceptor and
resolver; add MVC/E2E tests asserting that requests to endpoints annotated with
`@LoginUser` on excluded paths return the standardized CoreException/API error
response (not a 500 NPE) to verify unified handling.
apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java-3-11 (1)

3-11: ⚠️ Potential issue | 🟠 Major

도메인 스냅샷 불변식 부재로 음수 금액/수량 및 오버플로우가 그대로 통과된다.

주문 금액 계산의 입력 경계가 이 값 객체에서 강제되지 않으면, 상위 계층 검증 누락 시 잘못된 총액이 생성되어 정산 오류로 이어질 수 있다. productId/productName/unitPrice/quantity를 생성 시점에 검증하고, 금액 계산은 Math.multiplyExact로 오버플로우를 차단해야 한다. 또한 예외 래핑 시 cause를 보존해 운영 로그에서 근본 원인을 추적 가능하게 해야 한다. 추가로 quantity <= 0, unitPrice < 0, 곱셈 오버플로우 케이스 단위 테스트를 반드시 추가해야 한다.

수정 예시
 package com.loopers.domain.order;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
 public record OrderItemSnapshot(
         Long productId,
         String productName,
         long unitPrice,
         int quantity
 ) {
+    public OrderItemSnapshot {
+        if (productId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수다.");
+        }
+        if (productName == null || productName.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수다.");
+        }
+        if (unitPrice < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 금액은 0 이상이어야 한다.");
+        }
+        if (quantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 한다.");
+        }
+    }
+
     public long lineAmount() {
-        return (long) unitPrice * quantity;
+        try {
+            return Math.multiplyExact(unitPrice, quantity);
+        } catch (ArithmeticException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액 계산 중 오버플로우가 발생했다.", e);
+        }
     }
 }

As per coding guidelines: **/domain/**/*.java 엔티티/값 객체 경계를 명확히 하고 불변성과 캡슐화를 점검하며, **/*.java 예외 처리 시 cause를 보존한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java`
around lines 3 - 11, The record OrderItemSnapshot currently allows negative/zero
and overflow-prone values; add a compact constructor for OrderItemSnapshot to
validate productId != null, productName != null/blank, quantity > 0 and
unitPrice >= 0 and throw IllegalArgumentException on violations; update
lineAmount() to use Math.multiplyExact(unitPrice, (long)quantity) inside a
try/catch that wraps ArithmeticException in a new ArithmeticException (or a
domain-specific exception) while preserving the caught exception as the cause;
add unit tests covering quantity <= 0, unitPrice < 0, and multiplication
overflow to assert exceptions are thrown.
apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java-42-56 (1)

42-56: ⚠️ Potential issue | 🟠 Major

인증/조회 실패 메시지에 식별자 노출이 있다.

운영 관점에서 로그인 ID와 사용자 ID가 예외 메시지로 노출되면 계정 열거와 개인정보 노출 리스크가 커지고, 외부 클라이언트에 내부 식별 규칙이 드러난다. 수정안으로 사용자 응답 메시지는 일반화하고, 상세 식별자는 내부 로그(마스킹)로만 분리해야 한다. 또한 인증 실패(없는 ID/잘못된 비밀번호)는 동일한 메시지로 통일해야 한다. 추가 테스트로 로그인 ID 미존재/비밀번호 불일치/사용자 ID 미존재 케이스에서 응답 메시지에 원본 식별자가 포함되지 않음을 검증해야 한다.

수정 예시
@@
     `@Transactional`(readOnly = true)
     public User authenticate(String loginId, String loginPw) {
-        User user = getUserByLoginId(loginId);
+        User user = userRepository.findByLoginId(loginId)
+                                  .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자 정보가 올바르지 않습니다."));
         if (!passwordEncoder.matches(loginPw, user.getPassword())) {
             throw new CoreException(ErrorType.NOT_FOUND, "사용자 정보가 올바르지 않습니다.");
         }
@@
     private User getUserById(Long userId) {
         return userRepository.findById(userId)
                              .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
-                                                                  "[userId = " + userId + "] 를 찾을 수 없습니다."));
+                                                                  "사용자를 찾을 수 없습니다."));
     }
As per coding guidelines `**/*.java`: 예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다. 로깅 시 민감정보 노출 가능성을 점검한다.

Also applies to: 58-61

🤖 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/user/UserService.java`
around lines 42 - 56, Both authenticate(...) and getUserByLoginId(...) expose
raw login identifiers in thrown CoreException messages; change them to return a
single generic authentication/lookup failure message to clients (e.g. "사용자 정보가
올바르지 않습니다.") while preserving the original cause when constructing
CoreException, and move detailed info (including masked loginId) into an
internal log call (not the exception message). Specifically, update
getUserByLoginId to throw CoreException with a generic message and include the
underlying NoSuchElementException as the cause, update authenticate to throw the
same generic message on password mismatch (again preserving cause), add logging
statements that record the masked loginId for debugging, and add tests covering
missing-loginId, wrong-password, and missing-user to assert no raw identifiers
appear in exception messages.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-54-77 (1)

54-77: ⚠️ Potential issue | 🟠 Major

날짜 파싱 실패와 역전된 기간 입력이 500 에러로 전파되므로 400 BAD_REQUEST로 즉시 차단해야 한다

Line 74-77에서 파싱 예외를 처리하지 않으면 잘못된 startAt/endAt 입력이 500 INTERNAL_ERROR로 전파되어 운영 알람 노이즈와 불필요한 재시도를 유발한다. 또한 startAt > endAt도 사전에 검증되지 않는다. parseZonedDateTime 메서드를 try-catch로 감싸서 DateTimeParseException을 CoreException(ErrorType.BAD_REQUEST)로 변환하고, getOrders 메서드에서 parsedStartAt.isAfter(parsedEndAt) 검증을 추가해 즉시 400으로 반환해야 한다. 추가로 "잘못된 날짜 형식"과 "역전된 기간" 각각에 대해 400 상태 코드와 표준 에러 바디를 검증하는 API 테스트를 OrderV1ApiE2ETest에 추가한다.

🔧 제안 수정안
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import java.time.format.DateTimeParseException;
...
     public ApiResponse<List<OrderV1Dto.OrderResponse>> getOrders(
             `@LoginUser` Long userId,
             `@RequestParam` String startAt,
             `@RequestParam` String endAt
     ) {
         ZonedDateTime parsedStartAt = parseZonedDateTime(startAt);
         ZonedDateTime parsedEndAt = parseZonedDateTime(endAt);
+        if (parsedStartAt.isAfter(parsedEndAt)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "조회 시작일은 종료일보다 늦을 수 없습니다.");
+        }
 
         List<OrderV1Dto.OrderResponse> orders = orderService.getOrders(userId, parsedStartAt, parsedEndAt)
                                                             .stream()
                                                             .map(OrderV1Dto.OrderResponse::from)
                                                             .toList();
         return ApiResponse.success(orders);
     }
 
     private ZonedDateTime parseZonedDateTime(String value) {
-        String normalized = value.replace(" ", "+");
-        return ZonedDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+        try {
+            String normalized = value.replace(" ", "+");
+            return ZonedDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+        } catch (DateTimeParseException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 날짜 형식입니다.");
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java`
around lines 54 - 77, The parseZonedDateTime method currently throws
DateTimeParseException which bubbles up as 500 and there is no check for
inverted ranges; wrap the parsing in a try-catch inside parseZonedDateTime to
catch DateTimeParseException and throw new CoreException(ErrorType.BAD_REQUEST,
"잘못된 날짜 형식") (or similar standardized message), and in the controller method
that calls orderService.getOrders (the getOrders endpoint) validate
parsedStartAt.isAfter(parsedEndAt) and throw
CoreException(ErrorType.BAD_REQUEST, "역전된 기간") when true so the request returns
400; also add API tests in OrderV1ApiE2ETest covering invalid date format and
startAt > endAt to assert 400 error bodies.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java-20-25 (1)

20-25: ⚠️ Potential issue | 🟠 Major

TOCTOU 경쟁 조건으로 중복 좋아요가 발생할 수 있으며, 현재 제안된 예외 처리 방식은 CoreException API와 맞지 않다.

라인 20-25의 선조회 후 저장 패턴은 동시 요청에서 중복 방지 불가능하다. DB의 (user_id, product_id) 유니크 제약이 최종 안전장치로 작동하지만, 제약 위반 시 발생하는 예외를 명확히 처리해야 한다.

다만 현재 CoreException은 cause 파라미터를 지원하지 않으므로, 제안된 수정안은 컴파일되지 않는다. 다음 중 하나를 선택해야 한다:

  1. CoreException을 확장하여 cause 체이닝 지원 추가
  2. DB 제약 위반 시 DataIntegrityViolationException을 직접 처리하지 않고, register() 호출자가 예외를 처리하도록 설계

추가로 동시성 환경에서 "성공 1건, 실패 1건, 최종 row=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/LikeService.java`
around lines 20 - 25, The current pre-check using
likeRepository.findByUserIdAndProductId(...) causes TOCTOU races; remove the
optimistic exists-check and instead attempt to persist the new Like (use
Like.create(...) then likeRepository.save(...)) and handle DB uniqueness
violations: wrap the save in a try/catch that catches
DataIntegrityViolationException and translate it into the appropriate domain
error. Since CoreException currently lacks a cause parameter, either (A) add a
CoreException constructor accepting (ErrorType, String, Throwable) and throw new
CoreException(ErrorType.ALREADY_LIKED, "이미 좋아요한 상품입니다.", ex) in the catch, or
(B) do not convert the exception and let the caller of register() handle
DataIntegrityViolationException per your chosen design; also add an integration
test exercising concurrent requests to LikeService.register(...) to assert one
success and one handled failure with final row count = 1.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-39-39 (1)

39-39: ⚠️ Potential issue | 🟠 Major

parseZonedDateTime() 메서드에서 날짜 형식 오류가 발생하면 500 에러를 반환한다

74~76줄의 parseZonedDateTime() 메서드에서 ZonedDateTime.parse()를 호출할 때 잘못된 날짜 형식이 입력되면 DateTimeParseException이 발생한다. 이 예외는 ApiControllerAdvice에서 처리되지 않아 500 에러(INTERNAL_ERROR)로 응답되므로, 클라이언트는 잘못된 요청임을 알 수 없다. 날짜 파싱 오류를 CoreException으로 변환하여 400 에러를 반환하도록 수정해야 한다. 또한 startAtendAt 파라미터의 대소 관계를 검증하는 로직도 추가하여 명확한 에러 메시지를 제공해야 한다.

🤖 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`
at line 39, The parseZonedDateTime() call currently lets
ZonedDateTime.parse(...) throw DateTimeParseException which bubbles up and
yields a 500; update parseZonedDateTime() in OrderV1Controller to catch
DateTimeParseException and throw a CoreException mapped to BAD_REQUEST with a
clear message (e.g., "invalid date format for startAt/endAt: <input>"); after
parsing both startAt and endAt in the create flow validate their ordering and,
if startAt is after endAt, throw a CoreException BAD_REQUEST with a descriptive
message (e.g., "startAt must be before or equal to endAt"); ensure these
CoreException throws are used so ApiControllerAdvice returns 400 to the client.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java-33-37 (1)

33-37: ⚠️ Potential issue | 🟠 Major

@Valid을 제거하고 검증 흐름을 도메인 계층으로 일원화해야 한다

35번 줄의 @Valid 어노테이션은 프로젝트의 헥사고날 아키텍처 원칙을 위반한다. 현재 빈 검증(Bean Validation)과 도메인 검증(PasswordPolicyValidator)이 분산되어 있어, 새 비밀번호 검증이 일관성 없이 처리된다. 비밀번호 필수값 확인은 @NotBlank로 처리하고, 비밀번호 정책(8~16자, 생년월일 미포함)은 PasswordPolicyValidator에서 처리하도록 분리되는 것이 운영 중 예측 불가능한 에러를 야기한다. @Valid를 제거하면 모든 검증이 도메인 계층에서 CoreException으로 일원화되어 ApiControllerAdvice를 통해 표준 에러 응답으로 반환된다.

또한 현재 E2E 테스트는 유효한 요청, 사용자 미존재, 현재 비밀번호 오류, 중복 변경만 검증하고 있다. 새 비밀번호가 비어있거나 정책을 위반하는 경우(예: 8자 미만, 특수문자 미포함, 생년월일 포함)에 대한 API 응답 포맷 테스트가 부재하다. 검증 오류 시나리오별로 표준 ApiResponse 형식이 유지되는지 확인하는 테스트를 추가해야 한다.

🔧 제안 수정안
     public ApiResponse<Void> updatePassword(
             `@LoginUser` Long userId,
             `@RequestHeader`("X-Loopers-LoginPw") String currentPassword,
-            `@Valid` `@RequestBody` UserV1Dto.UpdatePasswordRequest request
+            `@RequestBody` UserV1Dto.UpdatePasswordRequest request
     ) {
         userService.updatePassword(UpdatePasswordCommand.from(userId, currentPassword, request));
         return ApiResponse.success(null);
     }

그리고 UserV1Dto의 UpdatePasswordRequest에서 @NotBlank 어노테이션도 제거하여, 모든 입력값 검증을 도메인 계층에 위임해야 한다.

🤖 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 33 - 37, Remove the controller-level bean validation and centralize
password validation in the domain: in UserV1Controller.remove the `@Valid` from
the UpdatePasswordRequest parameter (method handling updatePassword) and in
UserV1Dto.UpdatePasswordRequest remove any `@NotBlank`; ensure
UpdatePasswordCommand.from still maps raw values and let PasswordPolicyValidator
(domain) throw CoreException for empty/invalid new passwords; ensure
ApiControllerAdvice maps those CoreException errors to the standard ApiResponse
format. Also add E2E tests covering new-password-empty and policy-violation
cases to assert the API returns the standardized ApiResponse for each validation
failure.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-14-21 (1)

14-21: ⚠️ Potential issue | 🟠 Major

프로젝트 가이드라인에 따라 Bean Validation 어노테이션 사용을 지양해야 한다.

이 프로젝트에서는 DTO에 @NotNull, @NotEmpty, @Min 등의 Bean Validation 어노테이션 대신 도메인 레이어(엔티티 생성자, 서비스)에서 검증하고 CoreException을 통해 일관된 에러 응답을 반환하는 패턴을 사용한다. OrderService.validateItems에서 이미 검증 로직이 존재하므로 중복된다.

🛠️ 수정안
     public record CreateRequest(
-            `@NotNull` `@NotEmpty` List<OrderItemRequest> items
+            List<OrderItemRequest> items
     ) {}

     public record OrderItemRequest(
-            `@NotNull` Long productId,
-            `@NotNull` `@Min`(1) Integer quantity
+            Long productId,
+            Integer quantity
     ) {}

검증은 OrderService.validateItems 또는 Order.create에서 CoreException을 통해 수행되어야 한다. Based on learnings: "Do not use Bean Validation annotations on DTOs in this project. Move validation logic into the domain layer."

🤖 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 14 - 21, Remove Bean Validation annotations from the DTO records
CreateRequest and OrderItemRequest (drop `@NotNull`, `@NotEmpty`, `@Min`) and rely on
the existing domain validation; ensure any validation of items is performed via
OrderService.validateItems or Order.create and that failures throw CoreException
to produce the project-standard error response; update usages/tests that assumed
bean validation accordingly so DTOs are simple data carriers and all checks move
to OrderService.validateItems / Order.create with CoreException on invalid
input.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java-69-75 (1)

69-75: ⚠️ Potential issue | 🟠 Major

getOrderItems 메서드에 소유권 검증이 없다. 서비스 계층에 authorization 책임을 추가하다.

getOrder(userId, orderId) 메서드는 order.getUserId().equals(userId) 검증을 수행하지만, getOrderItems(orderId) 메서드는 userId 파라미터가 없고 소유권 검증이 없다. 현재는 컨트롤러에서 getOrder 호출 이후에 getOrderItems를 호출하므로 보호되지만, 서비스 계층의 자기 방어 원칙(defense in depth)에 위반된다. 향후 새로운 엔드포인트 추가나 리팩토링 시 접근 제어 우회 가능성이 있다.

getOrderItems(Long userId, Long orderId) 형태로 변경하여 getOrder와 일관성 있게 소유권 검증을 수행하고, 테스트에서 userId 불일치 시 NOT_FOUND 예외 발생을 확인한다.

🤖 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/OrderService.java`
around lines 69 - 75, Change getOrderItems to accept Long userId as the first
parameter (signature getOrderItems(Long userId, Long orderId)), perform the same
ownership check used by getOrder by loading the Order (reuse getOrder(userId,
orderId) or fetch Order and verify order.getUserId().equals(userId)) and throw
the same NOT_FOUND behavior when the user doesn't own the order, then return
orderItemRepository.findByOrderId(orderId).stream().map(OrderItemInfo::from).toList();
keep `@Transactional`(readOnly = true) and update callers/tests to pass userId and
assert NOT_FOUND on mismatch.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java-49-55 (1)

49-55: ⚠️ Potential issue | 🟠 Major

getOrders 메서드의 날짜 범위 쿼리 경계 조건과 타임존 처리를 검증해야 한다.

findByUserIdAndCreatedAtBetween은 JPA의 BETWEEN으로 구현되며, 이는 SQL 표준에 따라 양 끝값을 포함한다(inclusive). 다만 다음 문제가 있다:

  1. 경계값 테스트 부재: 현재 E2E 테스트는 day-range 범위(±1일)만 검증하고, 주문이 정확히 startAt 또는 endAt 타임스탬프에 생성되는 경계 경우를 테스트하지 않는다. nanosecond 단위의 정확성이나 데이터베이스의 precision에 따라 누락/중복이 발생할 수 있다.

  2. 타임존 변환 미검증: createdAt은 ZonedDateTime으로 저장되지만, 데이터베이스 TIMESTAMP 컬럼의 timezone 처리와 JPA 변환 설정이 명확하지 않다. 운영 시 서로 다른 타임존의 사용자가 조회할 때 의도치 않은 데이터 누락이 발생할 수 있다.

  3. 매개변수 검증 부재: startAt/endAt의 null 체크 또는 순서 검증(startAt <= endAt)이 없어, 잘못된 범위 요청 시 동작이 불명확하다.

수정안: (1) 경계값 테스트 추가 - 주문 생성 시각과 정확히 일치하는 startAt/endAt으로 조회하는 test case, (2) application.properties에서 timezone 설정 명시 및 DB 설정 검증, (3) getOrders 메서드에 startAt/endAt 검증 로직 추가.

🤖 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/OrderService.java`
around lines 49 - 55, getOrders currently calls
orderRepository.findByUserIdAndCreatedAtBetween which is inclusive and lacks
parameter validation and timezone/precision handling; update getOrders to
validate startAt and endAt (non-null and startAt <= endAt), normalize/convert
both ZonedDateTime parameters to the DB canonical timezone (or UTC) before
querying (use the same conversion used for persistence), and avoid precision
loss by adjusting endAt to the DB precision if necessary (or use an explicit
>=/<= repository query instead of BETWEEN); also add E2E unit tests for boundary
cases where createdAt == startAt and createdAt == endAt and verify
application.properties DB timezone (and JPA mapping) is explicitly set to the
chosen timezone so timezone conversions are deterministic.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java-11-11 (1)

11-11: ⚠️ Potential issue | 🟠 Major

Bean Validation 어노테이션을 DTO에서 제거하고 검증을 도메인 레이어로 이동하다

Line 41, 74의 @Valid 및 ProductV1Dto의 @NotNull, @NotBlank 어노테이션은 프로젝트 아키텍처 원칙에 위배된다. 이 프로젝트는 모든 검증을 도메인 레이어에서 수행하고 CoreException으로만 실패를 반환하도록 설계되었다. 현재 Bean Validation 사용으로 인해 MethodArgumentNotValidException 경로가 별도로 생기면서 에러 처리 일관성이 유지되지 않는다.

수정안:

  • ProductV1Dto에서 @NotNull, @NotBlank 어노테이션 제거
  • 컨트롤러의 @Valid 제거
  • ProductCreateCommand, ProductUpdateCommand 생성 단계에서 도메인 검증 수행 후 검증 실패 시 CoreException 발생하도록 통일
  • 마찬가지로 ProductFacade와 ProductService의 명령 실행 시점에서도 도메인 검증 강화

테스트 추가:

  • 잘못된 name (공백), price (음수), stockQuantity (음수) 등으로 POST/PUT 호출 시 항상 동일한 에러 스키마(BAD_REQUEST)와 코드로 응답되는 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/api/product/AdminProductV1Controller.java`
at line 11, Remove bean validation annotations from ProductV1Dto and strip the
`@Valid` annotations from AdminProductV1Controller (lines where requests are
accepted), then move all field validations into domain-level
constructors/factories used by ProductCreateCommand and ProductUpdateCommand so
that invalid inputs (blank name, negative price, negative stockQuantity) throw
CoreException; also add defensive validation in ProductFacade and ProductService
entry points to ensure any command execution re-validates and re-throws
CoreException on failure. Update command creation paths to translate former DTO
constraint checks into explicit domain checks and error messages wrapped in
CoreException, and remove any handling for MethodArgumentNotValidException so
error handling is consistent. Finally add E2E tests that POST/PUT with invalid
name/price/stock produce the same BAD_REQUEST error schema and code for all
validation failures.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java-95-110 (1)

95-110: ⚠️ Potential issue | 🟠 Major

좋아요 카운트 증감의 동시성 안전성을 원자적 UPDATE로 보장해야 한다

증감 메서드는 엔티티 조회 후 메모리에서 값을 수정하고 dirty checking으로 UPDATE를 실행하는 구조이므로, 서로 다른 트랜잭션에서 동시에 요청될 때 lost update가 발생한다. 예를 들어 두 요청이 동시에 같은 상품의 좋아요 카운트를 읽으면 각각의 증감 결과 중 하나만 반영되어 카운트가 정확하지 않게 된다. 운영 환경에서는 정렬/랭킹의 품질 저하로 바로 노출되는 문제다.

현재 codebase에서 stockQuantityProductJpaRepository@Query 원자 UPDATE로 동시성을 보호하고 있으므로, 동일한 패턴을 likeCount에도 적용해야 한다. 아래 수정안 중 하나를 선택하여 적용한다.

  1. ProductRepository에 원자적 UPDATE 메서드 추가:
// ProductRepository 인터페이스
void increaseLikeCount(Long productId);
void decreaseLikeCount(Long productId);

// ProductJpaRepository
`@Modifying`(clearAutomatically = true)
`@Query`("""
    update Product p
    set p.likeCount = p.likeCount + 1
    where p.id = :productId
      and p.deletedAt is null
      and p.visibility = :visibility
""")
void increaseLikeCount(`@Param`("productId") Long productId, `@Param`("visibility") Product.Visibility visibility);

`@Modifying`(clearAutomatically = true)
`@Query`("""
    update Product p
    set p.likeCount = case when p.likeCount > 0 then p.likeCount - 1 else 0 end
    where p.id = :productId
      and p.deletedAt is null
      and p.visibility = :visibility
""")
void decreaseLikeCount(`@Param`("productId") Long productId, `@Param`("visibility") Product.Visibility visibility);
  1. 또는 @Version을 Product 엔티티에 추가하고 재시도 로직 구현.

동시성 통합 테스트를 추가하여 500~1000건 동시 요청 시 기대 카운트와 최종 카운트가 일치함을 검증한다.

🤖 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/ProductService.java`
around lines 95 - 110, The likeCount increment/decrement is vulnerable to lost
updates because ProductService.increaseLikeCount and decreaseLikeCount load the
entity and mutate it in-memory; replace this with an atomic DB UPDATE: add
methods increaseLikeCount(Long productId, Product.Visibility visibility) and
decreaseLikeCount(Long productId, Product.Visibility visibility) to the
ProductRepository and implement them in ProductJpaRepository as `@Modifying`
`@Query` updates (increase by 1; decrease by 1 with a floor-at-zero case)
mirroring the stockQuantity pattern, then change
ProductService.increaseLikeCount and decreaseLikeCount to call these repository
methods inside a `@Transactional` context (removing the entity in-memory mutation)
and add a concurrency integration test that fires hundreds of parallel requests
and asserts final likeCount equals expected.

Comment on lines +114 to +124
public void decreaseStock(List<OrderItemCommand> items) {
List<OrderItemCommand> sorted = items.stream()
.sorted(Comparator.comparing(OrderItemCommand::productId))
.toList();

for (OrderItemCommand item : sorted) {
boolean decreased = productRepository.decreaseStockIfEnough(item.productId(), item.quantity());
if (!decreased) {
throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "재고가 부족한 상품이 있습니다.");
}
}
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

재고 차감 입력 경계 검증 누락으로 재고 데이터가 오염될 수 있다

  • 문제: Line 114-124에서 quantity가 0/음수/null이거나 productId가 null인 항목을 선제 차단하지 않아, 잘못된 요청이 들어오면 재고 증가/500 오류/데이터 불일치로 이어질 수 있다. 운영에서는 재고 정합성 붕괴가 직접 장애로 연결된다.
  • 수정안: 루프 진입 전에 items와 각 항목의 productId, quantity(>=1)를 검증하고 실패 시 CoreException으로 즉시 중단해야 한다.
  • 추가 테스트: quantity=0, quantity<0, productId=null, items에 null 포함 케이스에서 예외가 발생하고 재고가 전혀 변경되지 않는 테스트를 추가해야 한다.
    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/ProductService.java`
around lines 114 - 124, Validate the input list and each OrderItemCommand before
mutating stock: in ProductService.decreaseStock first check that items and no
element are null, then iterate (or use stream) to ensure every
OrderItemCommand.productId() != null and OrderItemCommand.quantity() >= 1; if
any check fails throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "...") (or
appropriate ErrorType/message) before calling
productRepository.decreaseStockIfEnough so no partial updates occur; update or
add tests for ProductService.decreaseStock covering items == null, items
containing null, productId == null, quantity == 0 and quantity < 0 to assert a
CoreException is thrown and that productRepository.decreaseStockIfEnough is
never invoked.

Comment on lines +16 to +28
@Query("""
update Product p
set p.stockQuantity = p.stockQuantity - :quantity
where p.id = :productId
and p.deletedAt is null
and p.visibility = :visibility
and p.stockQuantity >= :quantity
""")
int decreaseStockIfEnough(
@Param("productId") Long productId,
@Param("quantity") Integer quantity,
@Param("visibility") Product.Visibility visibility
);
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 . -path "*commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java" -type f

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

Length of output: 179


🏁 Script executed:

fd "ProductJpaRepository" --type f

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

Length of output: 177


🏁 Script executed:

rg "decreaseStockIfEnough" --type java -A 10 -B 5

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

Length of output: 7730


🏁 Script executed:

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

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

Length of output: 1374


🏁 Script executed:

rg "OrderItemCommand" --type java -A 5 -B 2

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

Length of output: 13772


🏁 Script executed:

rg "decreaseStockIfEnough" --type java --context 15 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java

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

Length of output: 1366


🏁 Script executed:

fd "ProductServiceTest" --type f

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

Length of output: 172


🏁 Script executed:

rg "OrderItemCommand|quantity" apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/ --type java -B 3 -A 3

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

Length of output: 4559


🏁 Script executed:

fd "OrderV1Dto" --type f

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

Length of output: 165


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java | head -60

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

Length of output: 2513


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java | tail -80

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

Length of output: 3278


🏁 Script executed:

rg "decreaseStock" --type java -B 5 -A 10 apps/commerce-api/src/test/java/

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

Length of output: 10981


재고 차감 쿼리에 수량 양수 조건이 없어 데이터 무결성 위험이 있다.

현재 컨트롤러 레이어의 @Min(1) 검증과 도메인 모델의 유효성 검사가 0 이상의 수량을 걸러내고 있으나, 저장소 쿼리 레이어에는 수량 양수 조건이 없다. 캐싱 우회, 테스트 픽스처 직접 호출, 또는 상위 계층 검증 누락 상황에서 음수 수량이 쿼리에 도달하면 stockQuantity - (-n)으로 재고가 증가하여 정합성이 손상될 수 있다. 방어적 프로그래밍 원칙에 따라 쿼리 WHERE 절에 :quantity > 0 조건을 추가해야 한다.

수정 예시 diff
     `@Query`("""
             update Product p
             set p.stockQuantity = p.stockQuantity - :quantity
             where p.id = :productId
+              and :quantity > 0
               and p.deletedAt is null
               and p.visibility = :visibility
               and p.stockQuantity >= :quantity
             """)

또한 저장소 통합 테스트에서 quantity=0, quantity=-1일 때 업데이트 건수가 0이고 재고 변화가 없음을 검증하는 테스트 케이스를 추가해야 한다.

🤖 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 16 - 28, The update query in decreaseStockIfEnough allows negative
quantities which can increase stock; modify the JPQL WHERE clause in the
decreaseStockIfEnough method to include a defensive check that :quantity > 0 so
updates only proceed for positive amounts, and ensure the `@Param` signature
remains unchanged (productId, quantity, visibility). Also add repository
integration tests that call decreaseStockIfEnough with quantity=0 and
quantity=-1 and assert the returned update count is 0 and the
Product.stockQuantity remains unchanged.

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<ProductV1Dto.AdminProductResponse> createProduct(
@RequestHeader("X-Loopers-Ldap") String ldap,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 먼저 AdminProductV1Controller 파일의 실제 내용 확인
echo "=== AdminProductV1Controller.java 파일 확인 ==="
find apps/commerce-api -name "AdminProductV1Controller.java" -type f 2>/dev/null | head -5

# 파일이 있으면 내용 확인
if [ -f "apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java" ]; then
  wc -l "apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java"
  echo "=== 처음 100줄 ==="
  head -100 "apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java"
fi

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

Length of output: 4343


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 인증 인터셉터/리졸버 등록 확인
echo "=== 인증 인터셉터/리졸버/필터 설정 파일 검색 ==="
rg -n --type=java -l 'addInterceptors|addArgumentResolvers|InterceptorRegistry|ArgumentResolverRegistry|FilterRegistrationBean|SecurityConfig|WebConfig|LoginUserInterceptor' \
  apps/commerce-api/src/main/java --max-count=20

# admin 컨트롤러의 헤더 의존 패턴 확인
echo "=== admin 엔드포인트 헤더 검증 패턴 ==="
rg -n --type=java '@RequestHeader.*X-Loopers-Ldap' \
  apps/commerce-api/src/main/java/com/loopers/interfaces/api -C 3

# /api-admin 경로 검색
echo "=== /api-admin 경로 설정 ==="
rg -n --type=java '/api-admin' \
  apps/commerce-api/src/main/java --max-count=30

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

Length of output: 12328


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Spring Security 설정 또는 인터셉터 설정 파일 찾기
echo "=== Spring Security/WebConfig 파일 검색 ==="
find apps/commerce-api/src/main/java -type f -name "*Config.java" -o -name "*Security*.java" | head -20

# WebMvcConfigurer 구현 클래스 찾기
echo "=== WebMvcConfigurer 구현 확인 ==="
rg -n --type=java 'WebMvcConfigurer|addInterceptors|addArgumentResolvers' \
  apps/commerce-api/src/main/java --max-count=50

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

Length of output: 829


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# WebConfig.java 전체 내용 확인
echo "=== WebConfig.java 전체 내용 ==="
cat -n "apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java"

echo ""
echo "=== LoginUserInterceptor.java 전체 내용 ==="
cat -n "apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java"

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

Length of output: 3202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Spring Security 설정이 있는지 확인
echo "=== Spring Security 관련 파일 검색 ==="
find apps/commerce-api/src/main/java -type f \( -name "*Security*.java" -o -name "*Filter*.java" \) | head -20

# admin 경로에 대한 보안 설정 확인
echo "=== WebConfig에서 admin 경로 관련 설정 확인 ==="
rg -n --type=java 'api-admin' apps/commerce-api/src/main/java/com/loopers/interfaces/config/

# admin 엔드포인트 테스트 코드 확인
echo "=== Admin Controller 테스트 파일 검색 ==="
find apps/commerce-api/src -name "*AdminProductV1ControllerTest*" -o -name "*AdminProductControllerTest*" 2>/dev/null

# 테스트에서 헤더 검증 여부 확인
echo "=== 테스트 파일 목록 ==="
find apps/commerce-api/src -name "*Test.java" -type f | grep -i admin | head -10

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

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# application.properties 또는 yml에서 보안 설정 확인
echo "=== 설정 파일에서 admin 경로 보호 확인 ==="
find apps/commerce-api -name "application*.yml" -o -name "application*.properties" | xargs cat 2>/dev/null | head -100

# 헤더가 실제로 비즈니스 로직에서 사용되는지 확인
echo "=== ldap 파라미터 사용 여부 확인 ==="
rg -n 'ldap' apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java

# 다른 admin 컨트롤러도 동일한 문제가 있는지 확인
echo "=== 모든 admin 컨트롤러 목록 ==="
find apps/commerce-api/src/main/java -name "Admin*Controller.java" -type f

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

Length of output: 1714


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# ldap 파라미터가 실제로 메서드 본문에서 사용되는지 더 자세히 확인
echo "=== AdminProductV1Controller 전체 내용에서 ldap 사용 확인 ==="
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java | grep -A 20 "@RequestHeader"

# 다른 admin 컨트롤러도 동일한 패턴인지 확인
echo "=== AdminOrderV1Controller에서 ldap 사용 확인 ==="
rg -n -A 15 '@RequestHeader.*X-Loopers-Ldap' apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java | head -40

echo "=== AdminBrandV1Controller에서 ldap 사용 확인 ==="
rg -n -A 15 '@RequestHeader.*X-Loopers-Ldap' apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java | head -40

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

Length of output: 5528


관리자 인증이 중앙에서 강제되지 않아 신규 API 추가 시 보호 누락 위험이 있다

현재 /api-admin/** 경로는 WebConfig의 인터셉터에 등록되지 않았다. 각 Admin 컨트롤러에서 @RequestHeader("X-Loopers-Ldap") 헤더를 반복 선언하지만, LoginUserInterceptor는 이를 검증하지 않으며, 컨트롤러 메서드 본문에서도 ldap 파라미터를 사용하지 않는다. 이는 신규 관리자 API 추가 시 헤더 누락으로 무방비 노출될 수 있는 운영/보안 리스크를 초래한다.

/api-admin/** 경로를 WebConfig의 인터셉터에 명시적으로 등록하고 ldap 헤더 검증을 LoginUserInterceptor에 추가하거나, 별도의 관리자 전용 인터셉터를 작성하여 모든 admin 엔드포인트를 중앙에서 강제하도록 통합해야 한다. 이후 컨트롤러는 중복 헤더 선언을 제거하고 필요시 SecurityContext 또는 커스텀 ArgumentResolver를 통해 인증된 관리자 정보만 주입받도록 리팩터링하는 것이 안전하다.

모든 admin 라우트에 대해 무헤더 요청, 잘못된 헤더, 정상 헤더 케이스를 MVC 보안 테스트에서 매트릭스 형태로 검증하여 누락된 엔드포인트가 없는지 확인해야 한다.

🤖 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/AdminProductV1Controller.java`
at line 40, The admin endpoints rely on a duplicated
`@RequestHeader`("X-Loopers-Ldap") in controllers (e.g. AdminProductV1Controller)
instead of central enforcement, so register the /api-admin/** path in
WebConfig's interceptor registry and add LDAP header validation into
LoginUserInterceptor (or create a dedicated AdminInterceptor) to reject requests
with missing/invalid X-Loopers-Ldap; after centralizing validation, remove the
redundant `@RequestHeader` usage from admin controllers and switch controllers to
obtain authenticated admin info via SecurityContext or a custom
ArgumentResolver; add MVC tests covering no-header, invalid-header, and
valid-header cases across all /api-admin/** routes to ensure no admin endpoint
is unprotected.

@hey-sion hey-sion merged commit 09fdab4 into Loopers-dev-lab:hey-sion Mar 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant