[Volume 3] 도메인 주도 설계 구현 - 김남진#121
[Volume 3] 도메인 주도 설계 구현 - 김남진#121Namjin-kimm wants to merge 21 commits intoLoopers-dev-lab:Namjin-kimmfrom
Conversation
fix : 예제 테스트 코드 오류 해결을 위한 testcontainers 버전 업
…서 사용자 인증 후 사용자의 정보를 @loginuser 어노테이션을 통해 UserInfo에 할당
- 도메인 레이어에 PasswordEncoder 인터페이스 정의 - BcryptPasswordEncoder 구현체를 Infrastructure 레이어로 이동 - 도메인이 외부 프레임워크(Spring Security)에 의존하지 않도록 개선
- 도메인 모델링 및 아키텍처 레이어별 역할 명시
[docs] : erd 내용 수정
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
🟠 Major comments (33)
apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java-17-17 (1)
17-17:⚠️ Potential issue | 🟠 Major
@Table(name = "user")은 예약어 충돌 위험이 있어 비예약어로 변경 필요하다
user는 PostgreSQL, MySQL, Oracle, H2 등 대부분의 DB에서 예약어로 취급된다. JPA가 자동으로 식별자를 쿼팅할 수 있으나, 명시적 dialect 설정이 없는 현 상태에서는 환경별 동작이 불안정할 수 있다. 또한 코드베이스의 테이블명 규칙이 혼재되어 있다(orders,likes복수형 vsproduct,example,brand,user단수형). 테이블명을users또는app_user로 변경하고, 네이밍 규칙을 통일하도록 한다. 기존 스키마가 배포된 환경은 명시적 마이그레이션을 함께 반영해야 한다.🔧 제안 수정안
-@Table(name = "user") +@Table(name = "users")추가로 다음을 확인한다:
- JPA ddl-auto, database-platform 설정이 모든 profile(dev, qa, prd)에 명시되어 있는지
- 마이그레이션 전략(Flyway/Liquibase 등) 수립 여부
- 테이블명 네이밍 규칙 문서화 및 통일
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java` at line 17, The `@Table`(name = "user") on UserModel risks SQL reserved-word collisions; change the table name to a non-reserved identifier (e.g., `@Table`(name = "users") or `@Table`(name = "app_user") in the UserModel class), update any repository/JPQL references to that entity, and add a corresponding DB migration (Flyway/Liquibase) to rename the existing table so deployments are safe; also verify that JPA settings (spring.jpa.hibernate.ddl-auto and spring.jpa.database-platform) are explicitly set for all profiles (dev/qa/prd) and document/align the project table-naming convention (singular vs plural) across entities like Order/Orders, Like/Likes, Product, Brand.apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java-21-27 (1)
21-27:⚠️ Potential issue | 🟠 MajorAPI 스펙 인터페이스에서
@LoginUser어노테이션이 누락되었다.
UserV1ApiSpec에서UserInfo파라미터가@LoginUser어노테이션 없이 정의되어 있다. 이로 인해 SpringDoc이 생성하는 OpenAPI 문서에서UserInfo가 일반 요청 파라미터처럼 노출될 수 있다.같은 프로젝트의
OrderV1ApiSpec과LikeV1ApiSpec는 이미@LoginUser어노테이션을 적용하고 있으므로,UserV1ApiSpec도 동일한 패턴으로 수정하여 일관성을 유지해야 한다. 운영 환경에서 API 문서를 보는 클라이언트 개발자에게 혼란을 주고, 잘못된 API 호출 시도로 이어질 수 있다.♻️ 수정 예시
`@Operation`( summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다." ) - ApiResponse<UserV1Dto.UserResponse> getMe(UserInfo userInfo); + ApiResponse<UserV1Dto.UserResponse> getMe(`@LoginUser` UserInfo userInfo); `@Operation`( summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다." ) - ApiResponse<Object> changePassword(UserInfo userInfo, UserV1Dto.ChangePasswordRequest request); + ApiResponse<Object> changePassword(`@LoginUser` UserInfo userInfo, UserV1Dto.ChangePasswordRequest request);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java` around lines 21 - 27, Add the missing `@LoginUser` annotation to the UserInfo parameter in UserV1ApiSpec so SpringDoc does not treat it as a regular request parameter; specifically annotate the UserInfo argument in the methods getMe(UserInfo userInfo) and changePassword(UserInfo userInfo, UserV1Dto.ChangePasswordRequest request) with `@LoginUser` (following the pattern used in OrderV1ApiSpec and LikeV1ApiSpec) to keep the OpenAPI output consistent.apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java-3-4 (1)
3-4:⚠️ Potential issue | 🟠 Major도메인 계층이 Spring Framework에 직접 의존하여 DIP 위반 및 아키텍처 경계 붕괴 상태다.
현재 OrderRepository를 포함한 도메인 계층 전체에서 Spring Data의 Page/Pageable과
@Component,@Transactional등 인프라 관심사가 산재되어 있다. 이는 프레임워크 변경 시 도메인 코드 수정을 강제하고, 단위 테스트 작성 시 Spring 컨텍스트 로딩 비용을 초래하며, 운영 환경에서의 배포 리스크와 롤백 난이도를 크게 높인다.수정 방향:
- 도메인 포트(Repository 인터페이스)에서 Spring Data 타입 제거
- OrderPageQuery, OrderPageResult 등 도메인 전용 조회 파라미터/결과 타입 정의
- 인프라 계층 어댑터에서만 Spring Data 타입으로 변환
필수 검증:
아키텍처 테스트를 추가하여 com.loopers.domain 패키지 내 모든 Java 파일에서 org.springframework.* import가 발생하면 빌드 실패하도록 강제해야 한다. 현재 8개 파일에서 24건의 Spring import가 발생 중이므로 점진적 정리 계획이 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java` around lines 3 - 4, The domain layer currently depends on Spring types (e.g., OrderRepository uses org.springframework.data.Page/Pageable), violating DIP; change the repository interface OrderRepository to use domain-specific types (define OrderPageQuery and OrderPageResult as domain DTOs) and remove all org.springframework.* imports from classes in com.loopers.domain; implement conversion between OrderPageQuery/OrderPageResult and Spring Page/Pageable in the infrastructure adapter (the adapter that implements OrderRepository), and add an architecture test that fails the build if any Java file under com.loopers.domain imports org.springframework.* to enforce the boundary.apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java-47-66 (1)
47-66:⚠️ Potential issue | 🟠 Major
quantitynull 검증 누락으로 DB 예외가 늦게 터질 수 있다
- 운영 관점: 애플리케이션 레벨에서 선제 차단하지 않으면 flush 시점 제약조건 예외로 변환되어 500 응답/롤백이 발생하고 원인 추적이 어려워진다.
- 수정안: 생성자와
guard()에validateQuantity를 추가해 도메인 예외(CoreException)로 조기 실패시켜야 한다.- 추가 테스트:
quantity == null로OrderItem생성 시BAD_REQUEST가 발생하는 테스트를 추가해야 한다.수정 예시
public OrderItem(Long productId, Quantity quantity, String productName, String brandName, Money price) { validateProductId(productId); + validateQuantity(quantity); validateProductName(productName); validateBrandName(brandName); validatePrice(price); @@ protected void guard() { validateProductId(this.productId); + validateQuantity(this.quantity); validateProductName(this.productName); validateBrandName(this.brandName); validatePrice(this.price); } + + private void validateQuantity(Quantity quantity) { + if (quantity == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 필수입니다."); + } + }As per coding guidelines
**/*.java: null 처리와 예외 흐름이 명확한지 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java` around lines 47 - 66, OrderItem is missing null validation for quantity; add a validateQuantity(Quantity) call in the OrderItem constructor and in the overridden guard() method (alongside existing validateProductId/Name/Brand/Price) so null quantity fails fast by throwing the domain CoreException; update or add a unit test that constructs OrderItem with quantity == null and asserts a BAD_REQUEST/CoreException is raised.apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-60-63 (1)
60-63:⚠️ Potential issue | 🟠 Major주문 항목 리스트의 null 요소를 허용하면 런타임 실패로 전이된다
- 운영 관점: 리스트 내부 null 요소가 허용되면 저장/매핑 시점에서 NPE가 발생해 장애 복구와 원인 분석 비용이 커진다.
- 수정안:
validateOrderItems에서null요소 포함 여부까지 차단해야 한다.- 추가 테스트:
orderItems에 null 요소가 포함된 생성 요청이BAD_REQUEST로 실패하는 테스트를 추가해야 한다.수정 예시
+import java.util.Objects; @@ private void validateOrderItems(List<OrderItem> orderItems) { - if (orderItems == null || orderItems.isEmpty()) { + if (orderItems == null || orderItems.isEmpty() || orderItems.stream().anyMatch(Objects::isNull)) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 최소 1개 이상이어야 합니다."); } }As per coding guidelines
**/*.java: null 처리와 예외 흐름이 명확한지 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java` around lines 60 - 63, In Order.validateOrderItems, extend the existing null/empty check to also reject lists that contain any null element: iterate/orderItems.stream().anyMatch(Objects::isNull) and if true throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 null을 포함할 수 없습니다."); update the error message to be clear and consistent with other BAD_REQUEST uses (ErrorType.BAD_REQUEST, CoreException) and add a unit/integration test that submits an orderItems list containing a null entry and asserts a BAD_REQUEST is thrown.apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-22-33 (1)
22-33:⚠️ Potential issue | 🟠 Major내부
orderItems컬렉션이 외부에 가변 상태로 노출된다
- 운영 관점: 외부 코드가
getOrderItems().clear()같은 조작을 수행하면 BR-O01(최소 1개 항목) 불변식이 우회되어 데이터 무결성이 깨질 수 있다.- 수정안: 컬렉션 필드의 기본 getter 노출을 막고, 읽기용으로
List.copyOf(orderItems)를 반환해야 한다.- 추가 테스트: getter로 받은 컬렉션 수정 시 원본이 변하지 않는지(또는 수정 불가 예외 발생) 검증해야 한다.
수정 예시
`@NoArgsConstructor`(access = AccessLevel.PROTECTED) `@Getter` public class Order extends BaseEntity { @@ + `@Getter`(AccessLevel.NONE) `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER) `@JoinColumn`(name = "order_id", nullable = false) private List<OrderItem> orderItems = new ArrayList<>(); + + public List<OrderItem> getOrderItems() { + return List.copyOf(orderItems); + }As per coding guidelines
**/domain/**/*.java: 엔티티/값 객체 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java` around lines 22 - 33, The public getter currently exposes the mutable field orderItems (List<OrderItem>) so external code can mutate the collection and break the "min 1 item" invariant; stop Lombok from generating a default mutable accessor and add an explicit public List<OrderItem> getOrderItems() that returns List.copyOf(orderItems) (or Collections.unmodifiableList(orderItems)) to ensure immutability, keep the field private with existing cascade/orphanRemoval settings, and add a unit test that calls getOrderItems().clear() or attempts modification and asserts the original orderItems remains unchanged or that an UnsupportedOperationException is thrown.apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java-26-29 (1)
26-29:⚠️ Potential issue | 🟠 Major
findById에서 메모리 필터링을 사용하면 불필요한 엔티티 로딩 비용이 발생한다현재 코드는
orderJpaRepository.findById(id)후 메모리에서deletedAt == null필터를 수행한다. 이로 인해 삭제된 주문도 먼저 데이터베이스에서 조회되며, Order 엔티티가 EAGER 로딩 설정인 OrderItems까지 함께 로드되어 불필요한 메모리/I/O 비용이 증가한다. 특히 조회 빈도가 높을수록 누적 비용이 커진다.다른 메서드들(
findAllByUserIdAndCreatedAtBetween,findAll)은 이미 데이터베이스 레벨에서 soft-delete 필터를 적용하고 있으므로,findById도 동일한 패턴으로 개선해야 한다.
OrderJpaRepository에Optional<Order> findByIdAndDeletedAtIsNull(Long id)메서드를 추가하고,OrderRepositoryImpl.findById에서 이 메서드를 사용하도록 변경한다. 추가로deletedAt이 설정된 주문 ID 조회 시 Optional.empty를 반환하는 테스트와 정상 주문 조회를 분리 검증해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java` around lines 26 - 29, The current OrderRepositoryImpl.findById loads entities then filters by deletedAt in memory, causing unnecessary DB/I-O and eager-loading; add a repository-level query Optional<Order> findByIdAndDeletedAtIsNull(Long id) to OrderJpaRepository and change OrderRepositoryImpl.findById to call that method directly; update or add unit tests to verify that a deleted order ID returns Optional.empty and a non-deleted order returns the entity (separate test cases).apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java-5-13 (1)
5-13:⚠️ Potential issue | 🟠 Major주문 커맨드에서 null 검증과 방어적 복사가 없어 예외 일관성이 깨진다
- 운영 관점:
items == null또는 요소가 null인 경우 OrderFacade 라인 40에서 NullPointerException이 발생해 500 에러로 반환되며, CoreException → ApiControllerAdvice 통일 패턴이 깨진다.- 수정안: compact constructor에서 null/empty/null-element를 검증하고
List.copyOf(items)로 방어적 복사를 적용해야 한다.- 추가 테스트: items가 null/빈 리스트/null 요소 포함일 때 각각
CoreException(BAD_REQUEST)가 반환되는 테스트를 추가해야 한다.수정 예시
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; public record OrderCreateCommand( List<Item> items ) { + public OrderCreateCommand { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 최소 1개 이상이어야 한다."); + } + if (items.stream().anyMatch(item -> item == null)) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목에 null이 포함될 수 없다."); + } + items = List.copyOf(items); + } public record Item( Long productId, int quantity ) {} }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java` around lines 5 - 13, Add a compact constructor to OrderCreateCommand that validates items: throw a CoreException(BAD_REQUEST) if items is null, items.isEmpty(), or any element is null; perform a defensive copy with List.copyOf(items) and assign it to the record component so the record holds an immutable, validated list. Update the OrderCreateCommand record (and keep nested Item) to use this compact constructor for validation, and add unit tests that exercise OrderCreateCommand creation when items is null, when items is empty, and when items contains a null element, asserting that each case results in a CoreException with BAD_REQUEST.apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java-13-14 (1)
13-14:⚠️ Potential issue | 🟠 Major기간 조회 경계 조건이 하루를 초과해 포함될 수 있다
Spring Data JPA의
Between키워드는BETWEEN ?1 AND ?2로 변환되며, 양끝이 포함(>= AND <=)된다. 현재 코드는to = endAt.plusDays(1).atStartOfDay(KST)로 다음날 00:00:00을 상한으로 설정하는데, Between의 양끝 포함 특성으로 인해 다음날 정각에 생성된 주문이 조회 범위에 포함된다. 이는 정산/통계 집계 시 오차를 발생시킬 수 있다.수정안:
Between대신GreaterThanEqualAndLessThan조건으로 변경하여 상한을 배타적으로 처리한다.- List<Order> findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull( + List<Order> findAllByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanAndDeletedAtIsNull( Long userId, ZonedDateTime from, ZonedDateTime to);추가 테스트:
endAt + 1일 00:00:00에 생성된 주문이 결과에 포함되지 않는지 경계값 테스트를 작성한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java` around lines 13 - 14, The current repository method findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull uses Between which is inclusive on both ends; change it to use exclusive upper-bound by renaming the method to findAllByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanAndDeletedAtIsNull and keep the parameters (Long userId, ZonedDateTime from, ZonedDateTime to) so Spring Data generates ">= from AND < to"; update any call sites accordingly and add a unit/integration boundary test that creates an Order at endAt.plusDays(1).atStartOfDay(KST) and asserts it is NOT returned by the query.apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java-30-32 (1)
30-32:⚠️ Potential issue | 🟠 MajorEAGER 로딩과 페이지 조회의 N+1 쿼리 패턴이 발생한다
개발자 의견(29줄)에 따라 OrderInfo 응답에 항목을 항상 포함한다는 점은 이해하나, 관리자 주문 목록 조회(
findAllByDeletedAtIsNull(pageable))에서 페이지 크기 20으로 조회 시 다음과 같이 작동한다:
SELECT orders LIMIT 20실행- OrderInfo.of()에서
order.getOrderItems()호출 (17줄)- 각 Order마다 별도 쿼리로
SELECT order_items WHERE order_id = ?실행 (총 20+1 = 21개 쿼리)단순 derived query에는 fetch join이나
@EntityGraph가없어 JPA는 EAGER 지정만으로는 JOIN 최적화를 보장하지 않는다. 페이지 크기 증가 시 병목이 급격히 심화한다.수정안: 기본값을 LAZY로 두고, 상세 조회/목록 조회별로
@EntityGraph또는@Querywith fetch join으로 로딩 범위를 명시적으로 제어해야 한다.- `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER) + `@OneToMany`(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.LAZY)추가로 두 가지 문제가 더 있다:
- 컬렉션 캡슐화: 22줄
@Getter로List이 외부에 노출되어 BR-O01 불변식이 우회될 수 있다.Collections.unmodifiableList()반환으로 수정한다.- 입력 검증: 60-64줄에서 null/empty만 검증하고 내부 원소 null 검증이 없다.
orderItems.stream().anyMatch(Objects::isNull)추가 검증이 필요하다.관리자 주문 목록 API에서 페이지 크기 10, 20, 50으로 쿼리 수와 응답 시간을 비교하는 회귀 테스트를 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java` around lines 30 - 32, Change Order.orderItems from EAGER to LAZY and control loading via repository-level fetch (use `@EntityGraph` or `@Query` with fetch join for methods like findAllByDeletedAtIsNull(Pageable) and for detailed OrderInfo loads), update the getter for orderItems to return Collections.unmodifiableList(orderItems) to enforce encapsulation, and strengthen input validation where order items are accepted (the constructor/mutator referenced around lines 60-64) by rejecting lists containing null elements via orderItems.stream().anyMatch(Objects::isNull) (throw IllegalArgumentException), and add a regression test that calls the admin order list API (findAllByDeletedAtIsNull) with page sizes 10/20/50 to assert query counts and response time do not regress.apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java-136-140 (1)
136-140:⚠️ Potential issue | 🟠 Major동일
OrderItem객체 재사용으로 테스트 데이터가 오염될 수 있다운영 관점에서 동일
OrderItem인스턴스를 서로 다른Order에 재사용하면 영속화 시 연관관계가 덮어써지거나 예기치 않은 상태가 되어 테스트가 간헐적으로 통과/실패할 수 있다. 각 주문마다buildOrderItem(...)으로 새 인스턴스를 생성하도록 수정하는 것이 안전하다. 추가로 “두 주문이 각각 독립적인orderItems1건을 가진다”는 검증을 넣어 회귀를 막아야 한다.수정 예시
- OrderItem item = buildOrderItem(product, brand.getName()); - - orderJpaRepository.save(new Order(USER_ID, List.of(item))); - orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item))); // 타인의 주문 + orderJpaRepository.save(new Order(USER_ID, List.of(buildOrderItem(product, brand.getName())))); + orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(buildOrderItem(product, brand.getName())))); // 타인의 주문- OrderItem item = buildOrderItem(product, brand.getName()); - - orderJpaRepository.save(new Order(USER_ID, List.of(item))); - orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item))); + orderJpaRepository.save(new Order(USER_ID, List.of(buildOrderItem(product, brand.getName())))); + orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(buildOrderItem(product, brand.getName()))));As per coding guidelines "
**/*Test*.java: 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."Also applies to: 184-187
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java` around lines 136 - 140, Tests reuse the same OrderItem instance causing persistence/association bleed between orders; instead call buildOrderItem(product, brand.getName()) separately for each Order (e.g., create item1 and item2) before saving orderJpaRepository.save(new Order(USER_ID, List.of(item1))) and orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item2))); also update the other occurrence (around the second block at the same file) similarly and add assertions that each saved Order has exactly one orderItem and that the two orderItems are distinct (e.g., non-null, size == 1, and not the same instance) to prevent regressions.apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java-24-37 (1)
24-37:⚠️ Potential issue | 🟠 Major
Quantitynull 입력 시 NPE로 500이 발생한다운영 관점에서
decrease/increase/hasEnough가quantity.getValue()를 바로 호출하여null입력 시CoreException이 아닌 NPE로 떨어지고 에러 응답 일관성이 깨진다. 공통 null 가드를 두고BAD_REQUEST를 명시적으로 던지도록 수정해야 한다. 추가 테스트로 각 메서드에null전달 시CoreException(ErrorType.BAD_REQUEST)가 발생하는 케이스를 넣어야 한다.수정 예시
public Stock decrease(Quantity quantity) { + requireQuantity(quantity); int newQuantity = this.quantity - quantity.getValue(); if (newQuantity < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); } return new Stock(newQuantity); } public Stock increase(Quantity quantity) { + requireQuantity(quantity); return new Stock(this.quantity + quantity.getValue()); } public boolean hasEnough(Quantity quantity) { + requireQuantity(quantity); return this.quantity >= quantity.getValue(); } + + private void requireQuantity(Quantity quantity) { + if (quantity == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 비어있을 수 없습니다."); + } + }Based on learnings "오류는 CoreException을 통해 ApiControllerAdvice로 일원화해야 하며, 일관된 응답 포맷을 유지해야 한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java` around lines 24 - 37, Add null-guards to Stock.decrease, Stock.increase and Stock.hasEnough so passing a null Quantity throws new CoreException(ErrorType.BAD_REQUEST, "...") instead of causing an NPE: in each method (decrease/increase/hasEnough) check if the Quantity parameter is null and throw CoreException(ErrorType.BAD_REQUEST, "quantity must not be null") before calling quantity.getValue(); update Stock class accordingly and add unit tests asserting that calling decrease(null), increase(null) and hasEnough(null) each result in CoreException with ErrorType.BAD_REQUEST.apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java-22-35 (1)
22-35:⚠️ Potential issue | 🟠 Major브랜드명 정규화가 없어 중복 데이터가 누적될 수 있다
운영 관점에서
"나이키"와" 나이키 "가 서로 다른 값으로 저장되면 중복 검사(existsByName)와 조회 결과가 어긋나 데이터 정합성이 깨질 수 있다. 생성/수정 시trim()으로 정규화한 값을 검증·저장하도록 수정하는 것이 필요하다. 추가 테스트로 생성/수정 시 공백 정규화가 적용되는지와 공백-only 입력이 거절되는지를 포함해야 한다.수정 예시
public Brand(String name) { - validateName(name); - this.name = name; + this.name = normalizeName(name); } public void update(String name) { - validateName(name); - this.name = name; + this.name = normalizeName(name); } - private void validateName(String name) { - if (name == null || name.isBlank()) { + private String normalizeName(String name) { + String normalized = name == null ? null : name.trim(); + if (normalized == null || normalized.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); } + return normalized; }As per coding guidelines "
**/domain/**/*.java: 도메인 모델의 불변성과 캡슐화를 점검하고 도메인 규칙을 명확히 유지한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java` around lines 22 - 35, Normalize and validate brand names by trimming whitespace before validation and assignment: in the Brand constructor, the update(String name) method, and inside validateName(String name) ensure you call name = name == null ? null : name.trim() (or equivalent) so storage and validation use the trimmed value; then validate null/blank and assign the trimmed string to this.name. Update any callers that rely on raw input if necessary and add unit tests verifying that creating/updating with surrounding spaces stores the trimmed name and that inputs of only-whitespace are rejected by validateName.apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java-81-85 (1)
81-85:⚠️ Potential issue | 🟠 Major브랜드 ID 검증이 약해 잘못된 참조값이 도메인으로 유입된다.
운영에서
brandId=0또는 음수 값이 들어오면 DB 제약 예외로 500이 발생하거나 데이터 정합성 이슈로 이어질 수 있다.validateBrandId에서 null뿐 아니라 1 미만 값도CoreException(ErrorType.BAD_REQUEST)로 차단해야 한다. 추가 테스트로brandId=0,brandId=-1생성 시BAD_REQUEST를 검증해야 한다.수정 예시
private void validateBrandId(Long brandId) { - if (brandId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + if (brandId == null || brandId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 1 이상이어야 합니다."); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java` around lines 81 - 85, Update Product.validateBrandId to reject non-positive IDs: currently it only checks for null, so change the validation to throw new CoreException(ErrorType.BAD_REQUEST, "...") when brandId is null OR brandId < 1 to block 0 and negative values; update/create unit tests for Product creation to assert that passing brandId = 0 and brandId = -1 results in CoreException with ErrorType.BAD_REQUEST (refer to the Product class and validateBrandId method and the constructor or factory that calls it).apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java-22-24 (1)
22-24:⚠️ Potential issue | 🟠 Major@Modifying 옵션으로 영속성 컨텍스트 일관성을 보장하도록 수정한다.
deleteAllByProductIdIn은 현재@Modifying만 선언되어 있어 벌크 삭제 후 1차 캐시가 즉시 갱신되지 않을 수 있다. 서비스 레이어에서 이미 빈 컬렉션을 필터링하므로 IN절 오류는 발생하지 않으나, 동일 트랜잭션에서 삭제 직후 조회 시 영속성 컨텍스트와 데이터베이스 상태가 불일치할 수 있다.@Modifying(clearAutomatically = true, flushAutomatically = true)를 추가하여 자동으로 영속성 컨텍스트를 초기화하고 쿼리를 먼저 실행하도록 한다.수정 예시
- `@Modifying` + `@Modifying`(clearAutomatically = true, flushAutomatically = true) `@Query`("DELETE FROM Like l WHERE l.productId IN :productIds") void deleteAllByProductIdIn(`@Param`("productIds") Collection<Long> productIds);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java` around lines 22 - 24, Update the repository method deleteAllByProductIdIn in LikeJpaRepository to ensure JPA persistence context consistency after the bulk delete by adding the `@Modifying` options: set clearAutomatically = true and flushAutomatically = true so the delete is flushed to the database and the first-level cache is cleared automatically; modify the `@Modifying` annotation on the deleteAllByProductIdIn(`@Param`("productIds") Collection<Long> productIds) method to include these two attributes.apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java-19-21 (1)
19-21:⚠️ Potential issue | 🟠 MajorBrandRepositoryImpl의 soft-delete 필터링 불일치로 인한 조회 경로 보안 결함.
findById()와 findAllByIds()는 삭제된 브랜드를 포함하여 조회하므로, BrandService.update() 호출 시 삭제된 브랜드가 수정될 수 있다. 기존 existsByName* 메서드는 deletedAt 조건을 포함하고 있으나, 조회 메서드는 누락되어 있어 soft-delete 정책이 일관되지 않는다. OrderRepositoryImpl처럼 .filter(brand -> brand.getDeletedAt() == null)을 추가하거나, ProductRepositoryImpl처럼 JPA 쿼리 메서드에 AndDeletedAtIsNull 조건을 포함하여 모든 조회 경로에 soft-delete 필터를 적용해야 한다. 삭제된 브랜드 ID로 조회 후 Optional.empty()를 반환하는 통합 테스트를 추가하기를 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java` around lines 19 - 21, findById(Long) and findAllByIds(...) in BrandRepositoryImpl currently return soft-deleted brands; update these methods to exclude deleted entries by applying the same soft-delete filter used in existsByName* (either change the JPA query signatures to include AndDeletedAtIsNull or post-filter results with .filter(b -> b.getDeletedAt() == null)), so BrandRepositoryImpl.findById and findAllByIds never return brands with non-null deletedAt; also add an integration test that attempts to load a deleted brand ID and asserts Optional.empty() (exercise via BrandService.update to ensure the update path is protected).apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java-19-24 (1)
19-24:⚠️ Potential issue | 🟠 Major동시성 환경에서 존재 여부 확인 후 작업 패턴은 경쟁 조건으로 인해 표준화되지 않은 오류를 발생시킨다.
create메서드에서 동일한userId/productId요청이 동시에 들어올 때, 두 요청 모두 존재 여부 확인을 통과한 후 한 건이 데이터베이스 유니크 제약 위반으로DataIntegrityViolationException을 던진다. 현재는 이 예외가ApiControllerAdvice에서 처리되지 않아 일반 예외 핸들러(Throwable)에 의해 500 INTERNAL_ERROR로 응답되므로, 표준화된 CONFLICT 오류 포맷과 맞지 않는다. 제약 위반을CoreException(CONFLICT)로 변환해야 한다.
delete메서드도 동시성 취약점이 있다. 존재 여부 확인과 삭제 사이에 다른 요청이 레코드를 삭제하면, 원래 요청의 삭제는 효과 없이 통과하지만LikeFacade.delete()의 후속decreaseLikeCount()호출은 여전히 실행되어 상품 좋아요 수가 부정확해질 수 있다.수정 예시
public Like create(Long userId, Long productId) { - if (likeRepository.existsByUserIdAndProductId(userId, productId)) { - throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + try { + Like like = new Like(userId, productId); + return likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); } - Like like = new Like(userId, productId); - return likeRepository.save(like); } public void delete(Long userId, Long productId) { - if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { - throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); + long deletedCount = likeRepository.deleteByUserIdAndProductIdReturnCount(userId, productId); + if (deletedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); } - likeRepository.deleteByUserIdAndProductId(userId, productId); }
LikeRepository에deleteByUserIdAndProductIdReturnCount메서드 추가 필요.필수 테스트: (1) 동일 좋아요 동시 생성 2건 시나리오에서 1건은 성공, 1건은 CONFLICT 응답 및 표준 에러 포맷 검증, (2) 동일 좋아요 동시 취소 시나리오에서 0건 또는 1건 성공으로 끝나는지, 그리고 좋아요 수 카운트 정합성 검증.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java` around lines 19 - 24, Change optimistic existence-check flows in LikeService.create and LikeService.delete to be atomic and translate DB constraint failures into standardized CONFLICT errors: remove the separate existsByUserIdAndProductId check in create and instead try to save and catch DataIntegrityViolationException, throwing new CoreException(ErrorType.CONFLICT, "...") when a unique constraint is violated; for delete, replace the exists-then-delete pattern with a single repository operation (add a LikeRepository.deleteByUserIdAndProductIdReturnCount method that returns the number of rows deleted) and branch on the returned count to decide whether to call LikeFacade.decreaseLikeCount() (only when count == 1) or throw CoreException(CONFLICT) as appropriate; update tests to assert one success/one CONFLICT on concurrent creates and verify like count consistency for concurrent deletes.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java-42-49 (1)
42-49:⚠️ Potential issue | 🟠 Major좋아요 카운트 UPDATE의 영향 행 수를 반환해 정합성을 검증해야 한다
존재하지 않거나 이미 삭제된 상품에 대한 UPDATE가 0건이어도 현재는 조용히 성공 처리되어, 운영 시 like 레코드와 집계 카운트가 어긋나는 문제를 숨길 수 있다.
수정안은 반환형을int로 바꾸고 서비스 계층에서 1건 갱신 여부를 검증해 실패 시CoreException으로 명시적으로 처리하는 것이다.
추가 테스트로 잘못된 상품 ID/삭제 상품 ID에 대해 영향 행 수 0일 때 예외가 발생하고 카운트가 변하지 않는지 검증이 필요하다.수정 예시
-void incrementLikeCount(`@Param`("id") Long id); +int incrementLikeCount(`@Param`("id") Long id); -void decrementLikeCount(`@Param`("id") Long id); +int decrementLikeCount(`@Param`("id") Long id);As per coding guidelines
**/*Repository*.java: 쿼리 조건 누락/과다 조회, 데이터 무결성 병목을 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java` around lines 42 - 49, Change the return type of ProductJpaRepository.incrementLikeCount and decrementLikeCount from void to int so the executed UPDATE returns the affected row count; in the service method that calls these (e.g., ProductService.likeProduct / unlikeProduct) check the returned int equals 1 and throw a CoreException when it is not (0 or >1) to signal a failed/invalid update; add tests that call the service with a non-existent or deleted product ID and assert a CoreException is thrown and that the product's likeCount remains unchanged.apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java-32-35 (1)
32-35:⚠️ Potential issue | 🟠 Major삭제된 브랜드가 조회 경로로 재노출될 수 있다
운영 중 soft delete된 브랜드가 조회/수정 흐름에 다시 진입하면 삭제 정책과 데이터 정합성이 깨지는 문제가 발생한다.
수정안은 활성 데이터 전용 조회 메서드(findByIdAndDeletedAtIsNull)를 리포지토리 계층에 추가하고, 서비스에서 해당 메서드만 사용하도록 변경하는 것이다.
추가 테스트로 브랜드 soft delete 이후findById,update호출 시 모두CoreException(ErrorType.NOT_FOUND)가 발생하는 통합 테스트가 필요하다.수정 예시
public Brand findById(Long id) { - return brandRepository.findById(id) + return brandRepository.findByIdAndDeletedAtIsNull(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); }// BrandRepository / BrandJpaRepository 측 추가 예시 Optional<Brand> findByIdAndDeletedAtIsNull(Long id);As per coding guidelines
**/*Repository*.java: 쿼리 조건 누락/과다 조회를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java` around lines 32 - 35, The current BrandService.findById uses brandRepository.findById which can return soft-deleted brands; update the data access to use a new repository method Optional<Brand> findByIdAndDeletedAtIsNull(Long id) (add this to BrandRepository/BrandJpaRepository) and change BrandService.findById to call brandRepository.findByIdAndDeletedAtIsNull(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); and add integration tests asserting that after soft-delete both findById (service call) and update attempts throw CoreException(ErrorType.NOT_FOUND).apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-26-27 (1)
26-27:⚠️ Potential issue | 🟠 Major
findById에 soft delete 필터가 없어 삭제 상품이 재조회될 수 있다운영에서 삭제된 상품이 다시 조회되면 주문/좋아요/관리자 화면 등 후속 유스케이스에서 비정상 데이터가 전파될 수 있다.
수정안은 활성 상품 전용 조회(findByIdAndDeletedAtIsNull)를 JPA 리포지토리에 추가하고 여기서 그 메서드를 사용하도록 변경하는 것이다.
추가 테스트로 상품 soft delete 이후findById가 빈 결과를 반환하고, 서비스findById는NOT_FOUND를 던지는 통합 테스트가 필요하다.수정 예시
`@Override` public Optional<Product> findById(Long id) { - return productJpaRepository.findById(id); + return productJpaRepository.findByIdAndDeletedAtIsNull(id); }As per coding guidelines
**/*Repository*.java: 쿼리 조건 누락/과다 조회를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` around lines 26 - 27, The current ProductRepositoryImpl.findById(Long id) returns productJpaRepository.findById(id) without filtering out soft-deleted rows; change it to call a JPA repository method that only returns active products (e.g., productJpaRepository.findByIdAndDeletedAtIsNull(id)) and update ProductRepositoryImpl.findById to return that Optional. Add the new repository method signature (findByIdAndDeletedAtIsNull) to the JPA interface and update any callers if needed. Add integration tests that soft-delete a Product, assert repository.findById returns empty, and assert the service layer findById translates that to a NOT_FOUND exception.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-40-44 (1)
40-44:⚠️ Potential issue | 🟠 Major중복 상품 ID 입력 시 500으로 실패할 수 있다
현재
Collectors.toMap은 동일productId가 들어오면IllegalStateException을 던져 운영에서 간헐적 500 오류를 유발한다.
수정안은 중복 상품 ID를 합산 처리하거나, 정책상 금지라면CoreException(ErrorType.BAD_REQUEST)로 명시적으로 차단하는 것이다.
추가 테스트로 동일 상품 ID가 중복된 주문 요청에 대해 400 계열 에러(또는 합산 처리 결과)가 일관되게 동작하는 E2E 테스트가 필요하다.수정 예시(합산 처리)
-Map<Long, Quantity> quantityByProductId = command.items().stream() - .collect(Collectors.toMap( - OrderCreateCommand.Item::productId, - item -> new Quantity(item.quantity()) - )); +Map<Long, Integer> summedQuantityByProductId = command.items().stream() + .collect(Collectors.toMap( + OrderCreateCommand.Item::productId, + OrderCreateCommand.Item::quantity, + Integer::sum + )); +Map<Long, Quantity> quantityByProductId = summedQuantityByProductId.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> new Quantity(e.getValue()) + ));As per coding guidelines
**/*.java: Optional/Stream 남용으로 가독성이 떨어지면 단순화하고, 예외 흐름이 명확한지 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java` around lines 40 - 44, The current construction of quantityByProductId in OrderFacade using Collectors.toMap will throw IllegalStateException on duplicate productId; update the logic in OrderFacade to detect duplicate OrderCreateCommand.Item.productId values and either (a) merge quantities by summing into a single Quantity when collecting (i.e., combine values for the same key) or (b) explicitly validate and throw new CoreException(ErrorType.BAD_REQUEST) when duplicates are not allowed; ensure you reference OrderCreateCommand.Item and Quantity for locating the code, and add an E2E test that sends an order with duplicate productId entries asserting a 400 response (or asserting the merged quantity result) so behavior is deterministic.apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java-40-42 (1)
40-42:⚠️ Potential issue | 🟠 Major인증 실패 로그에서
loginId평문 노출을 제거해야 한다.운영 관점에서 인증 실패 로그는 수집/보관 범위가 넓어 식별자 평문 노출 시 보안 사고 범위가 커진다.
수정안: 실패 로그에서loginId를 제거하거나 마스킹된 값만 기록하는 것이 좋다.
추가 테스트: 테스트 로거 appender를 붙여 실패 시 로그 메시지에 원본loginId가 포함되지 않는지 검증하는 단위 테스트를 추가하는 것이 좋다.As per coding guidelines `**/*.java`: "로깅 시 민감정보 노출 가능성을 점검한다."수정 예시
try { userInfo = userFacade.authenticate(loginId, loginPw); } catch (CoreException e) { - log.warn("인증 실패 - loginId: {}, URI: {}", loginId, request.getRequestURI()); + log.warn("인증 실패 - URI: {}", request.getRequestURI()); throw e; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java` around lines 40 - 42, The current catch block in AuthInterceptor logs the plaintext loginId (log.warn(...) referencing loginId and request.getRequestURI()); remove the plaintext loginId from the log or replace it with a masked value (e.g., hash or fixed token) and update the log call to only include non-sensitive context (such as request.getRequestURI()); also add a unit test that attaches a test logger/appender to AuthInterceptor to assert that when CoreException is thrown the produced log does not contain the original loginId string (verifying masking/removal).apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java-42-49 (1)
42-49:⚠️ Potential issue | 🟠 Major페이지네이션 입력값 범위 검증을 컨트롤러에서 선행해야 한다.
운영 관점에서
size상한이 없으면 과대 조회로 DB 부하가 급증할 수 있고, 음수/0 입력은 비일관 예외 응답으로 이어질 수 있다.
수정안:page >= 0,1 <= size <= MAX_PAGE_SIZE를 컨트롤러에서 명시 검증하고, 위반 시CoreException기반 4xx로 반환하는 것이 좋다.
추가 테스트: E2E에서page=-1,size=0,size=101요청에 대해 상태 코드와 에러 포맷이 동일하게 유지되는지 검증이 필요하다.As per coding guidelines `**/*Controller*.java`: "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 상태 코드와 에러 응답 포맷이 일관되는지 점검한다."수정 예시
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; @@ public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + private static final int MAX_PAGE_SIZE = 100; + @@ public ApiResponse<BrandAdminV1Dto.BrandListResponse> getBrandList( `@RequestParam`(defaultValue = "0") int page, `@RequestParam`(defaultValue = "10") int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "page/size 값이 유효하지 않습니다."); + } Page<BrandInfo> infoPage = brandAdminFacade.findAll(PageRequest.of(page, size)); return ApiResponse.success(BrandAdminV1Dto.BrandListResponse.from(infoPage)); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java` around lines 42 - 49, Add request-range validation in BrandAdminV1Controller.getBrandList: enforce page >= 0 and 1 <= size <= MAX_PAGE_SIZE (define MAX_PAGE_SIZE constant if not present) before calling brandAdminFacade.findAll; on violation throw the existing CoreException (or a subclass) that maps to a 4xx response so the controller returns a consistent error format/status; keep validation in the controller (not service) and add E2E tests for requests page=-1, size=0 and size=101 to assert identical error status/format.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-24-33 (1)
24-33:⚠️ Potential issue | 🟠 Major상품 목록 API에 페이지 파라미터 범위 제한을 추가해야 한다.
운영 관점에서
size무제한 요청은 트래픽 급증 시 즉시 성능 저하를 유발하고, 비정상 값은 에러 응답 일관성을 해칠 수 있다.
수정안: 컨트롤러에서page/size를 검증하고 상한을 두어 과도한 조회를 차단하는 것이 좋다.
추가 테스트: E2E에서size상한 초과/음수 입력 시 표준화된 에러 응답을 반환하는지 검증이 필요하다.As per coding guidelines `**/*Controller*.java`: "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 상태 코드와 에러 응답 포맷이 일관되는지 점검한다."수정 예시
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; @@ public class ProductV1Controller implements ProductV1ApiSpec { + private static final int MAX_PAGE_SIZE = 100; + @@ public ApiResponse<ProductV1Dto.ProductListResponse> getProducts( `@RequestParam`(required = false) Long brandId, `@RequestParam`(defaultValue = "latest") String sort, `@RequestParam`(defaultValue = "0") int page, `@RequestParam`(defaultValue = "20") int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "page/size 값이 유효하지 않습니다."); + } var pageable = PageRequest.of(page, size, ProductSortType.from(sort).toSort()); Page<ProductInfo> result = productFacade.findAll(brandId, pageable); return ApiResponse.success(ProductV1Dto.ProductListResponse.from(result)); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java` around lines 24 - 33, The getProducts method in ProductV1Controller does not validate page/size parameters, allowing negative or excessively large sizes; add request validation and bounds checks to prevent DoS and ensure consistent error responses. Annotate the controller with `@Validated` and add Bean Validation annotations on the method parameters (e.g., `@RequestParam` `@Min`(0) int page, `@RequestParam` `@Min`(1) `@Max`(100) int size) or perform an explicit check in getProducts that throws the standardized bad-request exception used by the project when values are out of range; ensure ProductV1Controller.getProducts returns the same ApiResponse/error format and add E2E tests that assert standardized error responses for negative and over-limit size inputs.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java-31-33 (1)
31-33:⚠️ Potential issue | 🟠 Major브랜드명 매핑 누락 시 null 전파를 차단해야 한다.
운영 관점에서 일부 브랜드 ID가 매핑에서 누락되면 응답에 null이 섞이거나 매핑 과정에서 예외가 발생해 장애 원인 추적이 어려워진다.
수정안: 매핑 시 브랜드명이 없으면 즉시 예외를 발생시켜 데이터 정합성 문제를 빠르게 드러내는 것이 좋다.
추가 테스트:findNamesByIds결과에서 특정 브랜드 ID가 누락된 상황을 모킹해, 의도한 예외 타입으로 실패하는 테스트를 추가하는 것이 좋다.As per coding guidelines `**/*.java`: "null 처리 ... 구현 안정성을 점검한다."수정 예시
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; @@ public Page<ProductInfo> findAll(Long brandId, Pageable pageable) { Page<Product> products = productService.findAll(brandId, pageable); List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList(); Map<Long, String> brandNameMap = brandService.findNamesByIds(brandIds); - return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))); + return products.map(product -> { + String brandName = brandNameMap.get(product.getBrandId()); + if (brandName == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드 정보를 찾을 수 없습니다."); + } + return ProductInfo.from(product, brandName); + }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` around lines 31 - 33, ProductFacade is currently mapping products to ProductInfo using brandService.findNamesByIds but will propagate null brand names if a brand ID is missing; update the mapping so that after calling brandService.findNamesByIds(brandIds) you validate that every brandId from products exists in brandNameMap (e.g., compute missingIds = brandIds - brandNameMap.keySet()) and if any are missing throw a clear runtime exception (IllegalStateException or custom DataIntegrityException) that names the missing IDs before calling ProductInfo.from; also add a unit test that mocks findNamesByIds to omit a brand ID and asserts the method throws the intended exception to cover the failure path.apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java-29-37 (1)
29-37:⚠️ Potential issue | 🟠 Major동시 요청 시 중복 좋아요 예외가 충돌 에러로 정규화되지 않을 수 있다
Line 29~37 흐름은 중복 체크와 저장이 분리되어 있어 경합 상황에서 DB 유니크 제약 위반 경로로 빠질 수 있다. 이 경우 운영에서는 간헐 500 응답으로 나타나 재시도 폭증과 장애성 알람을 유발한다. 중복 예외를 도메인
CONFLICT로 일관 변환하고, 가능하면 유니크 제약 기반으로 멱등 처리 경로를 단일화하는 것이 좋다. 추가로동일 userId/productId 동시 2요청테스트를 넣어1건 성공 + 1건 CONFLICT + likeCount 1 증가를 보장해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java` around lines 29 - 37, The create flow in LikeFacade (methods: create, productService.findById, likeService.create, productService.increaseLikeCount) can surface DB unique-constraint errors as 500s under concurrent requests; change the implementation so unique-constraint violations raised by likeService.create are caught and normalized to your domain CONFLICT exception (or return idempotent result) instead of bubbling as a 500, and prefer a single-path handling (e.g., handle DB unique-constraint in likeService.create or use an upsert/INSERT ... ON CONFLICT semantics) so only one success + one CONFLICT occurs; also add a concurrency test that issues two simultaneous create(userId, productId) calls asserting 1 success + 1 CONFLICT and likeCount increments by 1 (use the same identifiers: LikeFacade.create, likeService.create, productService.increaseLikeCount).apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java-44-50 (1)
44-50:⚠️ Potential issue | 🟠 Major기간 역전 입력 검증이 없어 잘못된 요청이 정상 조회처럼 처리될 수 있다
Line 44~50에서
startAt > endAt검증이 없으면 운영에서 사용자 입력 오류가 빈 결과로 숨겨져 장애 분석이 어려워진다. 컨트롤러 또는 파사드에서 범위를 선검증하고CoreException(BAD_REQUEST)로 실패시키는 것이 안전하다. 추가로GET /api/v1/orders?startAt=2099-01-01&endAt=2020-01-01케이스를 E2E에 추가해 400과 표준 에러 포맷을 검증해야 한다.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.🔧 검증 로직 예시
public ApiResponse<OrderV1Dto.OrderListResponse> getOrders( `@LoginUser` UserInfo loginUser, `@RequestParam` `@DateTimeFormat`(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, `@RequestParam` `@DateTimeFormat`(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { + if (startAt.isAfter(endAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "조회 시작일은 종료일보다 늦을 수 없습니다."); + } return ApiResponse.success(OrderV1Dto.OrderListResponse.from( orderFacade.findAllByUserId(loginUser.id(), startAt, endAt))); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java` around lines 44 - 50, The controller method getOrders in OrderV1Controller currently lacks validation for inverted date ranges; add a pre-check in getOrders (before calling orderFacade.findAllByUserId) that compares startAt and endAt and if startAt.isAfter(endAt) throw a CoreException with BAD_REQUEST so the error flows through ApiControllerAdvice and returns the unified 400 error format; also add an E2E test for GET /api/v1/orders with startAt=2099-01-01&endAt=2020-01-01 to assert a 400 response and standard error payload.apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java-22-26 (1)
22-26:⚠️ Potential issue | 🟠 Major페이지 파라미터 검증 부재로 500 응답 또는 과도한 조회 부하가 발생할 수 있다
Line 22~26은
page < 0,size <= 0, 과대size를 제한하지 않아 운영에서 예외 누수(500)나 대량 조회 부하를 유발할 수 있다. 컨트롤러에서 범위를 검증하고CoreException(BAD_REQUEST)로 표준 실패 응답을 반환하며,size상한(예: 100)을 강제하는 것이 좋다. 추가로page=-1,size=0,size=10000케이스를 API 테스트에 추가해 400과 에러 포맷 일관성을 검증해야 한다.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.🔧 검증 예시
public ApiResponse<OrderAdminV1Dto.OrderListResponse> getOrders( `@RequestParam`(defaultValue = "0") int page, `@RequestParam`(defaultValue = "20") int size) { + if (page < 0 || size <= 0 || size > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "page/size 파라미터가 유효하지 않습니다."); + } return ApiResponse.success(OrderAdminV1Dto.OrderListResponse.from( orderAdminFacade.findAll(PageRequest.of(page, size)))); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java` around lines 22 - 26, Validate the incoming pagination params in OrderAdminV1Controller before calling orderAdminFacade: check that page >= 0, size > 0 and cap size to a sensible max (e.g., 100); on invalid values throw a CoreException with BAD_REQUEST so ApiControllerAdvice produces the standard error response; update the method that currently calls PageRequest.of(page, size) to use the validated/capped values and add API tests for page=-1, size=0 and size=10000 asserting a 400 response and consistent error format.apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java-27-29 (1)
27-29:⚠️ Potential issue | 🟠 Major인증 제외 경로가 과도해 향후 비인가 노출 위험이 있다
Line 27~29는
products/**,brands/**전체를 인증 제외하므로, 운영 중 쓰기 엔드포인트가 같은 prefix에 추가되면 인증 없이 노출될 수 있다. 공개가 필요한 조회 API만 명시적으로 제외하거나, 인터셉터에서GET요청만 우회하도록 제한하는 방식으로 범위를 축소해야 한다. 추가로 보안 회귀 테스트로POST/PUT/DELETE가 무인증일 때 401을 보장하는 케이스를 넣어야 한다.🔧 범위 축소 예시
registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/v1/users/signup") - .excludePathPatterns("/api/v1/products/**") - .excludePathPatterns("/api/v1/brands/**"); + .excludePathPatterns( + "/api/v1/products", + "/api/v1/products/{id}", + "/api/v1/brands", + "/api/v1/brands/{id}" + );apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java-167-172 (1)
167-172:⚠️ Potential issue | 🟠 Major좋아요 테스트 픽스처를 도메인 경로로 생성해야 신뢰성이 유지된다.
운영 관점에서
Like만 직접 저장하면Product.likeCount와 데이터가 어긋나 삭제 시 음수 카운트 같은 결함을 테스트가 놓칠 수 있다. 수정안은 Arrange에서POST /api/v1/likes/{productId}를 호출하거나LikeFacade.create(userId, productId)를 사용해 실제 쓰기 경로로 픽스처를 만드는 것이다. 추가 테스트로 삭제 성공 후likeCount >= 0검증과, 목록 조회에서 기대likeCount값 검증을 포함하는 것이 좋다.수정 예시
- likeJpaRepository.save(new Like(userId, product.getId())); + testRestTemplate.exchange( + ENDPOINT_CREATE_LIKE.apply(product.getId()), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<ApiResponse<LikeV1Dto.LikeResponse>>() {} + );As per coding guidelines, "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."
Also applies to: 224-229, 284-289
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java` around lines 167 - 172, The test currently creates a Like directly via likeJpaRepository.save which can desync Product.likeCount; instead create the fixture through the write path by calling the public endpoint POST /api/v1/likes/{productId} (or call LikeFacade.create(userId, productId)) after signUpAndGetUserId and product creation to ensure product.likeCount is updated; replace the likeJpaRepository.save(...) call in LikeV1ApiE2ETest with that API/facade call and then add assertions after delete and list flows to verify Product.likeCount >= 0 and that returned list items include the expected likeCount value (reference symbols: likeJpaRepository.save, LikeFacade.create, POST /api/v1/likes/{productId}, Product.likeCount, signUpAndGetUserId, createUserHeaders).apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java-13-15 (1)
13-15:⚠️ Potential issue | 🟠 Major세 record의 List 필드에 compact constructor를 추가하여 방어적 복사를 적용하여야 한다.
OrderCreateRequest, OrderResponse, OrderListResponse 모두 List 필드를 그대로 보관하고 있다. 호출자가 생성자 호출 후 원본 리스트를 수정할 때 DTO 내부의 상태가 변동되어 로그/응답 재현성이 깨질 수 있다. 또한 응답 DTO를 여러 곳에서 공유할 때 예측 불가능한 상태 변경이 발생할 수 있다.
각 record에 compact constructor를 추가하여
List.copyOf(...)로 복사하고, null 값은List.of()로 정규화하여야 한다.수정 예시
public record OrderCreateRequest( List<OrderItemRequest> items -) {} +) { + public OrderCreateRequest { + items = (items == null) ? List.of() : List.copyOf(items); + } +} public record OrderResponse( Long orderId, ZonedDateTime createdAt, List<OrderItemResponse> items ) { + public OrderResponse { + items = (items == null) ? List.of() : List.copyOf(items); + } public static OrderResponse from(OrderInfo info) { return new OrderResponse( info.id(), info.createdAt(), info.items().stream().map(OrderItemResponse::from).toList() ); } } public record OrderListResponse( List<OrderResponse> orders ) { + public OrderListResponse { + orders = (orders == null) ? List.of() : List.copyOf(orders); + } public static OrderListResponse from(List<OrderInfo> infos) { return new OrderListResponse( infos.stream().map(OrderResponse::from).toList() ); } }추가로 DTO 생성 후 원본 리스트를 수정할 때 내부 리스트가 변하지 않는지 검증하는 테스트를 작성하는 것이 좋다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java` around lines 13 - 15, Add compact constructors to the record types OrderCreateRequest, OrderResponse, and OrderListResponse that defensively copy their List fields using List.copyOf(...) and normalize null inputs to List.of(); locate the record declarations (e.g., public record OrderCreateRequest(List<OrderItemRequest> items) {}) and implement a compact constructor body that replaces null lists with List.of() and assigns List.copyOf(theList) to the record component to prevent external mutation of internal state.apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-26-31 (1)
26-31:⚠️ Potential issue | 🟠 Major
register()메서드에@Transactional어노테이션이 누락되어 있다.
brandService.findById()와productService.register()가 별도 트랜잭션에서 실행될 경우, 브랜드 검증 후 상품 등록 사이에 브랜드가 삭제되면 데이터 정합성 문제가 발생할 수 있다. 운영 환경에서 동시성 이슈로 인한 고아 데이터가 생성될 가능성이 있다.🔒 트랜잭션 추가 제안
// 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05) + `@Transactional` public ProductInfo register(ProductRegisterCommand command) { Brand brand = brandService.findById(command.brandId()); // 브랜드 미존재 시 NOT_FOUND 예외 Product product = productService.register( command.brandId(), command.name(), command.price(), command.stock()); return ProductInfo.from(product, brand.getName()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java` around lines 26 - 31, The register method in ProductAdminFacade lacks transactional protection: wrap the public ProductInfo register(ProductRegisterCommand command) method with a transactional boundary (add `@Transactional` on the method or the class) so brandService.findById(...) and productService.register(...) run in the same transaction; ensure the class imports and spring transaction configuration support `@Transactional` and keep the method signature (register) and calls to brandService.findById and productService.register unchanged.apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-53-58 (1)
53-58:⚠️ Potential issue | 🟠 MajorProductAdminFacade의
update()메서드에@Transactional어노테이션이 필요하다.상품 수정 후 브랜드명 조회가 별도 트랜잭션에서 실행되므로, 브랜드 조회 실패 시에도 상품 수정은 이미 커밋된다.
productService.update()는@Transactional이지만, 해당 메서드 반환 시 트랜잭션이 종료되고 변경사항이 flush된다. 따라서 facade 레벨에서@Transactional으로 감싸 전체 작업을 원자적으로 처리해야 한다. 운영 관점에서 부분 업데이트 발생 가능성을 제거하기 위해update()메서드에@Transactional추가가 필수다. 더불어 동일한 패턴의register()메서드도 일관성 있게@Transactional으로 보호하는 것을 검토한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java` around lines 53 - 58, Wrap the facade-level operations in a single transaction: add the `@Transactional` annotation to ProductAdminFacade.update so the call to productService.update(...) and the subsequent brandService.findById(...) execute atomically; this prevents committing the product change if brand lookup fails. Also review the ProductAdminFacade.register method and apply `@Transactional` there if it follows the same pattern to ensure consistent transactional boundaries across the facade.
| public void delete(Long userId, Long productId) { | ||
| if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); | ||
| } | ||
| likeRepository.deleteByUserIdAndProductId(userId, productId); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n -C3 'deleteByUserIdAndProductId' --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3498
🏁 Script executed:
fd 'LikeFacade.java' --type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 161
🏁 Script executed:
fd 'LikeRepository.java' --type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 160
🏁 Script executed:
rg -n 'class LikeService' --type=java -A 30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7102
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3765
🏁 Script executed:
rg -n 'decreaseLikeCount|delete.*LikeService|void delete' apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java -A 10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 613
🏁 Script executed:
rg -n '@Transactional|isolation' apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 890
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3294
🏁 Script executed:
rg -n 'decreaseLikeCount|increaseLikeCount' apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 581
좋아요 삭제 메서드를 int 반환으로 변경하고 선조회를 제거해야 한다.
동시 취소 요청 시 두 트랜잭션이 모두 exist() 확인을 통과할 수 있고, 두 번째 삭제가 실제 영향 행 수 0건이어도 void 시그니처로는 이를 감지할 수 없다. 결과적으로 LikeFacade.delete()에서 decreaseLikeCount()가 두 번 호출되어 likeCount가 1회 이상 불일치하는 운영 장애가 발생할 수 있다. deleteByUserIdAndProductId()의 반환값을 int로 변경하고, 0이면 NOT_FOUND 예외를 던지도록 수정해야 한다. 이는 멱등성을 보장하고 동시 요청에 대한 정확한 에러 처리를 가능하게 한다.
수정 예시
@@
- public void delete(Long userId, Long productId) {
- if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
- throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
- }
- likeRepository.deleteByUserIdAndProductId(userId, productId);
+ public void delete(Long userId, Long productId) {
+ int deleted = likeRepository.deleteByUserIdAndProductId(userId, productId);
+ if (deleted == 0) {
+ throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다.");
+ }
}// LikeRepository 도메인 인터페이스
int deleteByUserIdAndProductId(Long userId, Long productId);동시 삭제 2건에서 1건만 성공하고 likeCount가 1회만 감소하는지 확인하는 통합 테스트를 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java`
around lines 29 - 34, Change LikeService.delete to stop pre-checking
existsByUserIdAndProductId and instead call
likeRepository.deleteByUserIdAndProductId and interpret its int return value:
update the repository signature so deleteByUserIdAndProductId returns int, call
that from LikeService.delete, and if the returned int is 0 throw new
CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); otherwise proceed normally;
also add an integration test that issues two concurrent deletes through
LikeFacade.delete and asserts only one delete succeeded and likeCount decreased
exactly once.
| private static final String ADMIN_HEADER = "X-Loopers-Ldap"; | ||
| private static final String ADMIN_LDAP_VALUE = "loopers.admin"; | ||
|
|
||
| @Override | ||
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ | ||
| String adminString = request.getHeader(ADMIN_HEADER); | ||
|
|
||
| if(adminString == null || adminString.isBlank()){ | ||
| log.warn("관리자 인증 헤더 누락 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); | ||
| throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 누락되었습니다."); | ||
| } | ||
|
|
||
| if(!adminString.equals(ADMIN_LDAP_VALUE)){ | ||
| log.warn("관리자 인증 실패 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); | ||
| throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증에 실패했습니다."); | ||
| } |
There was a problem hiding this comment.
정적 헤더 값 기반 관리자 인증은 우회 가능성이 높아 권한 탈취 위험이 크다.
운영 관점에서 클라이언트가 동일 헤더를 임의 주입하면 /api-admin/** 보호가 사실상 무력화될 수 있다. 수정안은 헤더 문자열 자체를 권한 근거로 쓰지 말고, 선행 인증 단계에서 검증된 사용자 컨텍스트의 관리자 권한으로 판별하도록 변경하는 것이다. 최소 임시 대응으로도 값 하드코딩은 제거하고 시크릿 설정 주입 + 게이트웨이 단에서 외부 헤더 제거가 필요하다.
추가 테스트로 (1) 헤더만 있는 미인증 요청 차단, (2) 인증되었지만 비관리자 권한 차단, (3) 관리자 권한 통과, (4) 헤더 스푸핑 시도 차단 시나리오를 E2E로 보강해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AdminAuthInterceptor.java`
around lines 16 - 31, The current AdminAuthInterceptor uses a hardcoded header
value (ADMIN_HEADER / ADMIN_LDAP_VALUE) which is insecure; change preHandle in
AdminAuthInterceptor to stop trusting the header string as proof of admin and
instead validate admin privilege from the authenticated user context (e.g.,
retrieve principal/Authentication from SecurityContextHolder or the existing
auth middleware) and check for an admin role/authority; remove the hardcoded
ADMIN_LDAP_VALUE usage (or move any secret into injected configuration if a
temporary header-based secret is required), ensure requests with only the header
but no authenticated principal are rejected, and coordinate with the gateway to
strip external admin headers; add E2E tests covering: header-only
unauthenticated request blocked, authenticated non-admin blocked, authenticated
admin allowed, and header-spoofing attempts blocked.
|
🏷 Product / Brand 도메인 위 체크리스트가 맞는지 확인해 |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 639 🏁 Script executed: Length of output: 483 🏁 Script executed: Length of output: 312 🏁 Script executed: Length of output: 241 🏁 Script executed: Length of output: 212 🏁 Script executed: Length of output: 278 🏁 Script executed: Length of output: 250 🏁 Script executed: Length of output: 243 🏁 Script executed: Length of output: 3488 🏁 Script executed: Length of output: 10628 🏁 Script executed: Length of output: 2546 🏁 Script executed: Length of output: 6668 🏁 Script executed: Length of output: 2336 🏁 Script executed: Length of output: 6443 🏁 Script executed: Length of output: 2193 🏁 Script executed: Length of output: 5021 🏁 Script executed: Length of output: 701
체크리스트의 모든 항목을 코드베이스와 대조하여 검증한 결과, 모든 항목이 구현 요구사항을 충족하고 있다. ✅ 검증 결과🏷 Product / Brand 도메인
👍 Like 도메인
🛒 Order 도메인
🧩 도메인 서비스
🧱 소프트웨어 아키텍처 & 설계
🎯 종합 평가모든 체크리스트 항목이 DDD 원칙과 클린 아키텍처를 준수하며 구현되었다. 특히 다음 사항이 우수하다:
|
📌 Summary
🧭 Context & Decision
문제 정의
선택지와 결정
[도메인 엔티티와 JPA 엔티티 분리 여부]
엄격한 DIP를 구현하기 위해서는 두 가지를 분리하는게 맞으나, 분리하게 되면 JPA가 제공하는 더티 체킹과 같은 이점 사용 불가. 도메인 엔티티에서 JPA 엔티티로 변환하는 코드가 도메인마다 증가하는 비용이 발생한다. 그리고 JPA 어노테이션을 도메인 엔티티에 사용한다고 해서 JPA의 기술이 도메인의 순수성을 침투하기 보다는 해당 어노테이션으로 인해 도메인의 구조나 동작이 바뀌는 것은 없다, 해당 어노테이션은 JPA를 구현할때만 동작할 뿐 JPA를 사용하지 않으면 아무 영향 없음. 이것 자체가 도메인의 순수성을 어느 정도 지켜준다고 판단.
[E2E 인증 테스트 중복 제거]
AuthInterceptorTest(단위 테스트)에서 인터셉터 로직을 완전히 커버하고 있으므로, 각 E2E 테스트에서는 URL 패턴 와이어링 확인용으로 도메인당 1개만 유지했습니다.
🏗️Design Overview
변경 범위
주요 컴포넌트 책임
🔁 Flow Diagram
Main Flow — 주문 생성 (US-O01)
sequenceDiagram
autonumber
participant Client
participant Controller as OrderV1Controller
participant Facade as OrderFacade
participant ProductService
participant BrandService
participant OrderService
participant DB
예외 흐름
sequenceDiagram
autonumber
participant Client
participant Facade as OrderFacade
participant ProductService
participant Advice as ApiControllerAdvice
🏷 Product / Brand 도메인
latest,price_asc,likes_desc) 을 고려한 조회 기능을 설계했다👍 Like 도메인
🛒 Order 도메인
🧩 도메인 서비스
🧱 소프트웨어 아키텍처 & 설계
/domain/order,/application/like등)변경 목적
TDD와 계층형 아키텍처를 적용하여 Brand, Product, Like, Order 4개 핵심 도메인을 구현하고, 의존성 역전 원칙을 준용한 도메인-인프라 계층 분리 구축.
핵심 변경점
리스크/주의사항
테스트/검증