Conversation
- Gender enum(MALE/FEMALE) 추가, phone 필드(선택, 010-XXXX-XXXX) 추가 - PATCH /api/v1/members/me: 전화번호 수정 API - DELETE /api/v1/members/me: 비밀번호 확인 후 soft delete 회원 탈퇴 API - 탈퇴 후 로그인 차단(findByLoginIdAndDeletedAtIsNull), 재가입 방지(existsByLoginId) - DIP 리팩토링: Controller에서 Member Entity 직접 참조 제거, loginId만 전달 - AuthFilter → loginId만 request에 저장, Service에서 @transactional 내 조회로 managed entity 보장 - getPassword() 제거 → verifyPassword() 도메인 메서드로 비밀번호 캡슐화 - 불필요한 save() 호출 제거 (dirty checking 활용) - ArchUnit: Interfaces → @entity 직접 의존 차단 규칙 추가
📝 WalkthroughWalkthrough회원, 주소, 상품, 브랜드, 주문, 좋아요 등 전자상거래의 핵심 도메인을 구현한다. 각 도메인마다 JPA 엔티티, 서비스, 레포지토리/리더, 애플리케이션 Facade, REST 컨트롤러, DTO를 추가한다. 회원 인증을 위해 필터 기반의 보안 레이어를 도입하고, 광범위한 도메인 및 E2E 테스트로 기능을 검증한다. Changes
Sequence DiagramsequenceDiagram
participant Client as 클라이언트
participant Controller as OrderV1Controller
participant Facade as OrderFacade
participant MemberService as MemberService
participant AddressService as AddressService
participant ProductService as ProductService
participant OrderService as OrderService
Client->>Controller: POST /api/v1/orders (loginId, addressId, items)
Controller->>Facade: createOrder(loginId, addressId, itemRequests)
Facade->>MemberService: getMemberByLoginId(loginId)
MemberService-->>Facade: Member
Facade->>AddressService: getAddress(memberId, addressId)
AddressService-->>Facade: Address
Facade->>ProductService: getProductsByIds(productIds)
ProductService-->>Facade: List<Product>
Facade->>Facade: validateStock(items, products)
Facade->>ProductService: decreaseStock(productId, quantity)
ProductService-->>Facade: ✓
Facade->>OrderService: createOrder(memberId, shipping, totalAmount)
OrderService-->>Facade: Order
Facade->>OrderService: createOrderItems(orderId, items)
OrderService-->>Facade: ✓
Facade->>Facade: toOrderInfo(order, items)
Facade-->>Controller: OrderInfo
Controller-->>Client: ApiResponse<OrderV1Dto.OrderResponse>
Estimated code review effort🎯 4 (Complex) | ⏱️ ~70 minutes 이유:
Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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: 7
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
🟠 Major comments (26)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java-547-567 (1)
547-567:⚠️ Potential issue | 🟠 Major헬퍼 메서드가 실패 원인을 NPE로 은닉한다
운영 관점에서 사전 데이터 생성이 실패했을 때 NPE로만 보이면 CI 장애 원인 파악 시간이 길어져 대응 속도가 떨어진다.
registerBrand/registerProduct에서CREATED와body/data존재를 먼저 단언한 뒤 ID를 반환하도록 수정하는 것이 안전하다. 추가 테스트로, 의도적으로 잘못된 생성 요청을 넣었을 때 NPE가 아니라 상태코드 단언 실패로 종료되는지 검증해야 한다.수정 예시
private Long registerBrand(String name, String description) { var request = new BrandV1Dto.RegisterRequest(name, description); ResponseEntity<ApiResponse<BrandV1Dto.BrandResponse>> response = testRestTemplate.exchange( BRAND_ADMIN, HttpMethod.POST, adminEntity(request), new ParameterizedTypeReference<>() {} ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); return response.getBody().data().brand().id(); } private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); ResponseEntity<ApiResponse<ProductV1Dto.ProductResponse>> response = testRestTemplate.exchange( PRODUCT_ADMIN, HttpMethod.POST, adminEntity(request), new ParameterizedTypeReference<>() {} ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); return response.getBody().data().product().id(); }As per coding guidelines, "예외 흐름이 명확한지 점검한다. null 처리 ... 점검한다."
🤖 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 547 - 567, The helper methods registerBrand and registerProduct currently assume response body exists and can NPE; update both methods to first assert the response status is HttpStatus.CREATED (or expected status) and that response.getBody() and response.getBody().data() (and .brand()/ .product()) are non-null before extracting the id, throwing clear assertion failures if not; additionally add a negative test that sends an invalid register request and asserts the response status assertion fails (e.g., BAD_REQUEST) to ensure failures surface as status assertion errors rather than NPEs.apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java-9-30 (1)
9-30:⚠️ Potential issue | 🟠 Major비밀번호 필드를 가진 record 요청 DTO는 자동
toString()으로 민감정보를 노출한다.Line 9-30의
RegisterRequest,ChangePasswordRequest,WithdrawRequest는 record 기본toString()이 원문 비밀번호를 포함하므로, 요청 로깅/예외 로깅 시 계정 탈취 리스크가 발생한다. 수정안은 민감 필드를 마스킹하는toString()오버라이드 또는 비밀번호 필드를 가진 요청 DTO만 일반 클래스로 전환하여 로그 출력을 통제하는 방식이 적절하다. 추가 테스트로는 각 DTO의toString()결과에 평문 비밀번호가 포함되지 않는지 검증해야 한다.수정 예시 diff
public record RegisterRequest( String loginId, String password, String name, LocalDate birthDate, String gender, String email, String phone - ) {} + ) { + `@Override` + public String toString() { + return "RegisterRequest[loginId=%s, password=****, name=%s, birthDate=%s, gender=%s, email=%s, phone=%s]" + .formatted(loginId, name, birthDate, gender, email, phone); + } + } public record ChangePasswordRequest( String currentPassword, String newPassword - ) {} + ) { + `@Override` + public String toString() { + return "ChangePasswordRequest[currentPassword=****, newPassword=****]"; + } + } public record WithdrawRequest( String password - ) {} + ) { + `@Override` + public String toString() { + return "WithdrawRequest[password=****]"; + } + }As per coding guidelines
**/*.java의 "로깅 시 민감정보 노출 가능성을 점검한다" 기준을 적용했다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java` around lines 9 - 30, The record DTOs RegisterRequest, ChangePasswordRequest, and WithdrawRequest expose plaintext passwords via the default record toString(); update them to prevent sensitive leakage by either converting those three records into ordinary classes with a custom toString() that masks password fields (e.g., replace characters with asterisks or omit them) or override toString() for each record to return a safe representation that excludes or masks password values; update any constructors/getters as needed to preserve immutability/validation and add unit tests asserting that toString() for RegisterRequest, ChangePasswordRequest, and WithdrawRequest does not contain the raw password string.apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java-5-11 (1)
5-11:⚠️ Potential issue | 🟠 Major도메인 페이징 객체가 가변 컬렉션 참조를 그대로 노출하고 있어 상태 오염 위험이 있다.
Line 6의
content가 방어적 복사 없이 저장되면 생성 이후 외부 변경이 내부 상태를 오염시켜 캐시/응답 일관성이 깨질 수 있어 운영 장애 원인이 된다. compact constructor에서List.copyOf와 기본 메타데이터 검증을 추가하는 수정이 필요하다. 추가 테스트로는원본 리스트 변경 후 PageResult.content 불변성 유지,null content 입력 시 예외,음수 page/size/total 입력 시 예외를 포함해야 한다.수정 예시 diff
package com.loopers.domain; import java.util.List; +import java.util.Objects; public record PageResult<T>( List<T> content, long totalElements, int totalPages, int page, int size -) {} +) { + public PageResult { + content = List.copyOf(Objects.requireNonNull(content, "content must not be null")); + if (totalElements < 0 || totalPages < 0 || page < 0 || size < 0) { + throw new IllegalArgumentException("paging metadata must be non-negative"); + } + } +}As per coding guidelines
**/*.java의 "null 처리, 방어적 복사, 불변성 ... 점검한다" 및**/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/PageResult.java` around lines 5 - 11, PageResult currently stores the mutable List<T> content reference directly; add a compact constructor in the PageResult record that performs defensive copy via List.copyOf(content) and validates inputs (throw NullPointerException for null content and IllegalArgumentException for negative totalElements/page/size/totalPages as applicable) to enforce immutability and metadata correctness, update the record signature to use the compact constructor name PageResult and ensure content is assigned to the copied list; also add unit tests: "original list mutation does not change PageResult.content", "null content throws NPE", and "negative page/size/totalElements throws IAE".apps/commerce-api/build.gradle.kts-13-14 (1)
13-14:⚠️ Potential issue | 🟠 Major보안 의존성의 명시적 버전 선언과 취약점 스캔 CI 게이트를 추가해야 한다.
14번 라인의
spring-boot-starter-security는 버전이 명시되지 않아 부모 Spring Boot 버전에 종속된다. 2025년 Spring Security에서 발표된 CVE-2025-22228(HIGH, BCrypt 우회), CVE-2025-22234(MEDIUM, 타이밍 공격), CVE-2025-41248/41249(권한 검증 우회)가 존재하므로, 프로젝트가 사용 중인 Spring Boot 버전의 Spring Security 버전을 명확히 확인하고 취약 버전이 아님을 검증해야 한다. 아울러 23번 라인의archunit-junit5:1.4.0은 알려진 취약점이 없으나, 최신 버전 1.4.1(2025년 5월)로 업그레이드하는 편이 바람직하다.운영 관점의 수정안:
spring-boot-starter-security의 부모 Spring Boot 버전을 명시적으로 확인하고 Spring Security ≥ 6.3.9 또는 ≥ 6.4.5 이상으로 고정한다.archunit-junit5를 1.4.1로 업그레이드한다.- CI에 소프트웨어 구성 분석(SCA) 스캔을 추가하여 의존성 취약점 발견 시 빌드를 차단하도록 설정한다.
추가 테스트: PR 단계에서 알려진 Spring Security 취약점 버전으로의 의존성 침투 시 CI 스캔이 정상적으로 차단하는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/build.gradle.kts` around lines 13 - 14, Pin the Spring Security dependency version instead of relying on the parent Spring Boot BOM by replacing the loose dependency implementation("org.springframework.boot:spring-boot-starter-security") with an explicit artifact/version that ensures Spring Security >= 6.3.9 or >= 6.4.5 (confirm the exact safe version for your Spring Boot line) and update the archunit test dependency from version 1.4.0 to 1.4.1 (reference: the existing archunit-junit5 declaration). Also add/enable an SCA-based dependency vulnerability scan in the CI pipeline so builds fail on known vulnerable versions and include a PR-level test that validates the CI gate blocks a PR when a vulnerable Spring Security version is introduced.http/commerce-api/like-v1.http-9-10 (1)
9-10:⚠️ Potential issue | 🟠 Major민감 인증정보를 HTTP 샘플에 하드코딩하면 유출 위험이 커진다
Line 9-10 등에서 로그인 ID/PW가 평문으로 반복되어 운영 로그, 화면 캡처, 협업 도구 공유 시 유출 위험이 커진다. 샘플은 환경변수/로컬 시크릿 참조로 바꾸는 것이 안전하다. 추가 테스트로 CI에서
X-Loopers-LoginPw:뒤 리터럴 문자열을 탐지하는 정적 점검을 넣어 재유입을 차단하는 것이 좋다.🔧 제안 수정안
`@baseUrl` = http://localhost:8080 +@loginId = {{$dotenv LOOPERS_LOGIN_ID}} +@loginPw = {{$dotenv LOOPERS_LOGIN_PW}} @@ -X-Loopers-LoginId: testUser1 -X-Loopers-LoginPw: Test1234! +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}}Also applies to: 14-15, 19-20, 31-32, 36-37, 41-42, 53-54, 58-59, 70-71, 75-76
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@http/commerce-api/like-v1.http` around lines 9 - 10, Replace the hardcoded credentials in the HTTP sample headers (X-Loopers-LoginId and X-Loopers-LoginPw) with placeholder references to environment/local secrets (e.g., ${LOOPERS_LOGIN_ID} and ${LOOPERS_LOGIN_PW}) and update any sample usage comments to instruct users to set those env vars or a local secret file; then add a CI static-check rule that scans .http samples for literal strings following "X-Loopers-LoginPw:" (and optionally "X-Loopers-LoginId:") to fail the build if a plaintext credential is detected, ensuring future commits cannot reintroduce hardcoded credentials.apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeReaderImpl.java-26-28 (1)
26-28:⚠️ Potential issue | 🟠 Major페이지 파라미터 검증이 없어 500 계열 예외로 새어 나갈 수 있다
Line 27의
PageRequest.of(page, size)는 음수/0 입력 시IllegalArgumentException을 던져 공통 에러 포맷을 깨뜨릴 수 있다. 운영 관점에서 클라이언트 입력 오류가 500으로 관측되면 장애 탐지 노이즈가 커진다. 리더 진입부에서CoreException(BAD_REQUEST)로 선검증해 일관된 에러 응답을 보장하는 것이 필요하다. 추가 테스트로page=-1,size=0요청 시 400과 표준 에러 바디를 검증하는 API 테스트를 추가하는 것이 좋다.🛠️ 제안 수정안
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; @@ public PageResult<Like> findAllByMemberIdAndTargetType(Long memberId, LikeTargetType targetType, int page, int size) { + if (page < 0 || size <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "페이지 파라미터가 올바르지 않다."); + } Pageable pageable = PageRequest.of(page, size); Page<Like> result = likeJpaRepository.findAllByMemberIdAndTargetType(memberId, targetType, pageable); return new PageResult<>(result.getContent(), result.getTotalElements(), result.getTotalPages(), result.getNumber(), result.getSize()); }Based on learnings In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to 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/infrastructure/like/LikeReaderImpl.java` around lines 26 - 28, The method findAllByMemberIdAndTargetType in LikeReaderImpl calls PageRequest.of(page, size) without validating inputs, which can raise IllegalArgumentException and leak 500 errors; add explicit validation at the start of findAllByMemberIdAndTargetType to ensure page >= 0 and size > 0 and throw new CoreException(BAD_REQUEST) for invalid values so errors flow through ApiControllerAdvice, and add API tests that call the endpoint (or the reader) with page=-1 and size=0 asserting a 400 response and the standardized error body.apps/commerce-api/src/main/java/com/loopers/support/auth/AdminAuthFilter.java-53-54 (1)
53-54:⚠️ Potential issue | 🟠 Major
startsWith("/api-admin/")경로 판별은 우회 여지가 있다Line 54의 현재 구현은
/api-admin(슬래시 없음) 경로를 필터링하지 못한다. 실제 운영 환경에서 향후 개발자가/api-admin엔드포인트를 추가할 경우 인증 필터가 우회될 위험이 있으며, 이는 재현이 어려운 보안 사고로 이어질 수 있다. 현재 모든 엔드포인트가/api-admin/v1/...패턴을 따르고 있지만, 보안 필터는 명시적 규칙에 의존해야 한다.RequestMatcher를 사용하여/api-admin과/api-admin/**두 경로를 함께 명시하는 것이 방어적 설계다. 추가로/api-admin,/api-admin/,/api-admin/v1/brands세 경로에서 동일한 인증 동작을 검증하는 테스트를 추가하는 것이 좋다.🧭 제안 수정안
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; @@ public class AdminAuthFilter extends OncePerRequestFilter { + private static final RequestMatcher ADMIN_MATCHER = new OrRequestMatcher( + new AntPathRequestMatcher("/api-admin"), + new AntPathRequestMatcher("/api-admin/**") + ); @@ private boolean requiresAuthentication(HttpServletRequest request) { - return request.getRequestURI().startsWith("/api-admin/"); + return ADMIN_MATCHER.matches(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/support/auth/AdminAuthFilter.java` around lines 53 - 54, The requiresAuthentication(HttpServletRequest request) method currently uses request.getRequestURI().startsWith("/api-admin/") which misses the root path "/api-admin" and can be bypassed; update this method to match both "/api-admin" and any subpaths (e.g., "/api-admin/**") using a RequestMatcher (or explicitly check equals("/api-admin") || startsWith("/api-admin/")), and add unit tests asserting the filter triggers for "/api-admin", "/api-admin/", and "/api-admin/v1/brands" to ensure consistent authentication behavior.apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java-25-27 (1)
25-27:⚠️ Potential issue | 🟠 Major인가 기본 정책을
permitAll로 설정했으므로, 신규 보호 API 경로 추가 시 필터 규칙 누락만으로 무인증 노출이 발생할 수 있다.
AdminAuthFilter와MemberAuthFilter에서 특정 경로만 인증을 검증하는 구조이므로, 새로운 API 엔드포인트가 추가될 때 필터의requiresAuthentication()메서드에 경로 규칙을 명시하지 않으면 자동으로permitAll()정책에 따라 무방비로 노출된다. 이는 운영 과정에서 보호 대상 경로 누락으로 인한 보안 결함이 발생하기 쉬운 fail-open 구조다.더불어 두 필터 모두 인증 성공 시
SecurityContext에Authentication객체를 설정하지 않고,request.setAttribute()로만 정보를 저장하므로 Spring Security의 인가 규칙이 실제로 작동하지 않는 상태다.수정 방향:
SecurityConfig의 기본 정책을denyAll()로 변경하고 인증 불필요 경로만permitAll()로 명시하거나, 보호 경로를 선언적으로 구성해 새 엔드포인트 추가 시 기본 보호 상태를 유지하도록 개선한다.- 필터에서 인증 성공 시 Spring의
Authentication객체를SecurityContext에 설정해 인가 규칙이 정상 작동하도록 한다.- 보호 대상 URI(
/api/v1/members/**,/api/v1/orders/**,/api/v1/products/{id}/likes등)에 대해 무헤더 및 오류 헤더 요청은 항상 401을 반환하고, 정상 인증 헤더 요청만 통과하는 파라미터화 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/support/config/SecurityConfig.java` around lines 25 - 27, SecurityConfig currently calls authorizeHttpRequests(...).anyRequest().permitAll which creates a fail-open policy; change the default to denyAll() and explicitly permit only public paths (e.g., health, auth endpoints) so new APIs are protected by default, and declare protected URIs (/api/v1/members/**, /api/v1/orders/**, /api/v1/products/*/likes) explicitly in the security matcher rules. In AdminAuthFilter and MemberAuthFilter ensure requiresAuthentication() implements strict path matching for only the intended protected paths and, on successful authentication, populate Spring Security by creating an Authentication and calling SecurityContextHolder.getContext().setAuthentication(auth) instead of only using request.setAttribute(...). Finally add parameterized E2E tests that hit the protected URIs to assert requests with missing/invalid auth return 401 and valid auth is allowed.apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java-41-48 (1)
41-48:⚠️ Potential issue | 🟠 Major
likeCount증감 로직의 동시성 보호가 부재하여 고트래픽 환경에서 유실 갱신 발생 가능하다.현재 구현은 엔티티를 로드한 후 메모리에서 증감하고 저장하는 Read-Modify-Write 패턴을 사용한다. 이 방식은 두 개 이상의 트랜잭션이 동일 Brand를 동시에 수정할 때 일부 변경사항이 누락되는 유실 갱신 문제를 야기한다. 결과적으로
Brand.likeCount와 실제 Like 레코드 건수가 불일치하여 운영 지표 신뢰도와 정렬 결과의 안정성이 저하된다.
- 수정안: 데이터베이스 수준의 원자적 쿼리(
UPDATE brand SET like_count = like_count + 1 WHERE id = ?)를 통해 동시 요청에도 카운트 일관성을 보장해야 한다.- 추가 테스트: 동시성 통합 테스트(최소 100개 이상의 동시 toggleBrandLike 요청)를 실시하여 최종
Brand.likeCount와 Like 레코드 건수가 일치하는지 검증해야 한다.🤖 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 41 - 48, The current in-memory read-modify-write in Brand.increaseLikeCount() and Brand.decreaseLikeCount() causes lost updates under concurrency; replace these usages with a database-level atomic update (e.g., add repository methods like incrementLikeCountById(id) and decrementLikeCountById(id) that execute SQL UPDATE brand SET like_count = like_count + 1 (or -1 with safety) WHERE id = ? and call those instead of modifying Brand.likeCount in memory), ensure decrement protects against negative values in the SQL (e.g., WHERE like_count > 0), and add a concurrency integration test that issues 100+ concurrent toggleBrandLike requests and asserts final Brand.likeCount equals the Like record count.apps/commerce-api/src/test/java/com/loopers/support/auth/AdminAuthFilterE2ETest.java-72-74 (1)
72-74:⚠️ Potential issue | 🟠 Major성공 케이스에서 상태코드를
isNotEqualTo(401)로만 검증하면 404/500도 통과되어 실제 API 동작 검증이 불완전하다.인증 필터가 의도대로 통과하더라도 라우팅 오류나 내부 오류로 인한 4xx/5xx 응답이 발생하면 테스트는 녹색으로 유지되어 보안 회귀 탐지가 늦어진다. 특히 Admin 엔드포인트는 인증 필터 검증이 핵심이므로 성공 경로에서 기대하는 상태코드를 명시해야 한다.
- 라인 72-74:
passesAuthentication_whenValidLdapHeader()- 유효 LDAP 헤더로 인증 통과 시 반드시 2xx 상태코드 명시- 라인 86-87:
allowsPublicApi_withoutLdapHeader()- Public API 접근 성공 시 2xx 명시🛠️ 수정안
- assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();🤖 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/support/auth/AdminAuthFilterE2ETest.java` around lines 72 - 74, Replace the weak negative assertion in passesAuthentication_whenValidLdapHeader() (and similarly in allowsPublicApi_withoutLdapHeader()) that only checks "isNotEqualTo(HttpStatus.UNAUTHORIZED)" with a positive assertion that the response is a successful 2xx, e.g. assert that response.getStatusCode().is2xxSuccessful() (or assertTrue(response.getStatusCode().is2xxSuccessful())) to ensure the admin auth filter truly allows the request and avoids false positives from 4xx/5xx routing or server errors.apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java-49-50 (1)
49-50:⚠️ Potential issue | 🟠 Major소계 계산에서
long오버플로우를 방지해야 한다.
productPrice(Long) ×quantity(int) 곱셈 결과가Long.MAX_VALUE를 초과하면 음수 또는 왜곡된 금액이 저장되어 결제·정산 장애로 이어진다. 예를 들어 상품 가격이 약 4.6조원이고 수량이 3개인 경우 오버플로우가 발생한다.
- 수정안:
Math.multiplyExact(productPrice, (long) quantity)를 사용하고, 오버플로우 발생 시CoreException(ErrorType.BAD_REQUEST, ...)로 변환해 요청을 거부해야 한다.- 추가 테스트:
Long.MAX_VALUE / 2근처 가격과 중대형 수량의 조합에 대해 오버플로우 예외 케이스를 분리해 검증해야 한다. 예:productPrice = Long.MAX_VALUE / 3 + 1, quantity = 3일 때ArithmeticException발생 확인.🛠️ 수정 예시
public Long getSubtotal() { - return productPrice * quantity; + try { + return Math.multiplyExact(productPrice, (long) quantity); + } catch (ArithmeticException 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/domain/order/OrderItem.java` around lines 49 - 50, The getSubtotal method currently multiplies productPrice by quantity without overflow checks; replace the multiplication with Math.multiplyExact(productPrice, (long) quantity) inside getSubtotal and catch ArithmeticException to throw new CoreException(ErrorType.BAD_REQUEST, "subtotal overflow" or similar) so overflow requests are rejected; update tests to include an overflow case (e.g., productPrice = Long.MAX_VALUE / 3 + 1 and quantity = 3) to assert ArithmeticException handling/translation to CoreException.apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java-84-86 (1)
84-86: 🛠️ Refactor suggestion | 🟠 Major도메인 불변식을 엔티티 레이어에서 보호해야 한다.
현재 구현에서
changeDefault(boolean isDefault)메서드는 공개(public)되어 있으나 어떤 검증도 수행하지 않는다. 실제로는AddressService.changeDefault()가 같은 트랜잭션 내에서 기존 기본 배송지를 해제하고 새로운 기본 배송지를 설정하므로 회원이 기본 배송지 없는 상태가 되지 않지만, 도메인 불변식 "모든 회원은 최소 하나의 기본 배송지를 가져야 한다"는 엔티티 레이어에서 보호되지 않고 있다.공개 뮤테이터 메서드가 검증 없이 노출되어 있으면 서비스 레이어 규율에만 의존하게 되어 도메인 설계 원칙에 어긋난다.
changeDefault()는 패키지 프라이빗으로 제한하거나, 도메인 규칙을 메서드에 포함시켜야 한다. 예를 들어setAsDefault()/unsetAsDefault()같은 의도를 명확히 하는 분리된 메서드로 리팩토링하고, 각각 해당 불변식을 검증하도록 개선이 필요하다.🤖 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/address/Address.java` around lines 84 - 86, The public mutator changeDefault(boolean isDefault) exposes domain-invariant risk; restrict its visibility and enforce the "each member must have at least one default address" rule inside the entity. Refactor changeDefault into two intention-revealing methods on Address: setAsDefault() and unsetAsDefault(), make them package-private (or private) so only domain/service code in the same package can call them, and add validation hooks inside unsetAsDefault() to prevent leaving a member without any default (or throw a domain exception if called incorrectly). Update AddressService.changeDefault() to call the new setAsDefault()/unsetAsDefault() methods and remove reliance on a raw public boolean setter.apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java-46-51 (1)
46-51:⚠️ Potential issue | 🟠 MajorbrandId 도메인 검증 누락으로 실패 지점이 인프라로 밀린다.
운영 관점에서
brandId가 null/비정상 값이어도 엔티티 생성이 통과하면 트랜잭션 커밋 시점의 DB 예외로 터져 비즈니스 오류와 시스템 오류가 혼재될 수 있다.
수정안으로 생성 시점에brandId를 양수/필수로 검증해 도메인 오류로 조기 차단해야 한다.
추가 테스트로brandId가null,0, 음수인 경우CoreException(BAD_REQUEST)가 발생하는지 검증해야 한다.수정 예시
public static Product create(Long brandId, String name, String description, Long price, int stockQuantity, int maxOrderQuantity) { + validatePositive(brandId, "브랜드 ID는 0보다 커야 합니다."); validateNotBlank(name, "상품명은 필수입니다."); validatePositive(price, "가격은 0보다 커야 합니다."); validateNotNegative(stockQuantity, "재고 수량은 0 이상이어야 합니다."); validatePositive((long) maxOrderQuantity, "최대 주문 수량은 0보다 커야 합니다."); return new Product(brandId, name, description, price, stockQuantity, maxOrderQuantity); }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/product/Product.java` around lines 46 - 51, Product.create currently lacks validation for brandId so invalid/null/zero/negative values propagate to infra-level DB errors; add domain-level checks by validating brandId is non-null and positive at the start of Product.create (use the existing validation helpers or add validateNotNull/validatePositive calls) before constructing Product, and update/create unit tests to assert that Product.create throws CoreException with BAD_REQUEST for brandId == null, 0, and negative values; reference the Product.create factory method and the Product constructor when making the change.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-57-69 (1)
57-69:⚠️ Potential issue | 🟠 Major좋아요 수 증감 시 동시성 문제
increaseLikeCount와decreaseLikeCount는 조회 후 엔티티 필드를 변경하는 방식으로, 동시에 여러 요청이 들어오면 lost update가 발생할 수 있다. 예를 들어 두 사용자가 동시에 좋아요를 누르면 실제로는 1만 증가할 수 있다.운영 환경에서 동시성 이슈를 방지하려면:
@Version필드를 통한 낙관적 락 적용- 또는
UPDATE product SET like_count = like_count + 1 WHERE id = ?형태의 atomic update 쿼리 사용🔧 atomic update 쿼리 방식 예시
ProductJpaRepository에 추가:
`@Modifying` `@Query`("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") int incrementLikeCount(`@Param`("id") Long id); `@Modifying` `@Query`("UPDATE Product p SET p.likeCount = CASE WHEN p.likeCount > 0 THEN p.likeCount - 1 ELSE 0 END WHERE p.id = :id") int decrementLikeCount(`@Param`("id") Long id);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java` around lines 57 - 69, The current increaseLikeCount and decreaseLikeCount methods perform read-modify-write on the Product entity and can suffer lost updates under concurrency; replace their logic with atomic DB updates by adding modifying repository methods (e.g., in ProductJpaRepository add incrementLikeCount(Long id) and decrementLikeCount(Long id) that run UPDATE ... SET likeCount = likeCount + 1 / CASE ... -1) and call those from the service methods instead of loading Product via getProduct(id); ensure the service methods remain `@Transactional` and use the repository methods' returned updated-row count to handle missing records.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-49-55 (1)
49-55:⚠️ Potential issue | 🟠 Major대량 브랜드 삭제 시 트랜잭션 크기 문제
deleteAllByBrandId는 단일 트랜잭션 내에서 모든 상품을 조회하고 각각 soft delete를 수행한다. 브랜드에 속한 상품이 수천 개 이상이면:
- 영속성 컨텍스트에 대량 엔티티 로딩으로 메모리 부하
- flush 시점에 대량 UPDATE 쿼리 발생으로 DB 부하
- 트랜잭션 시간 증가로 락 경합 가능성
배치 처리(청크 단위 삭제) 또는 벌크 UPDATE 쿼리로 개선을 검토해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java` around lines 49 - 55, The deleteAllByBrandId method loads all products via productReader.findAllByBrandId and calls Product.delete() inside one transaction which will OOM and create massive UPDATEs for large brands; change this to a chunked or bulk approach: either implement a repository-level bulk soft-delete like productRepository.bulkSoftDeleteByBrandId(brandId) that issues a single UPDATE setting deleted flag/timestamp, or iterate using a paged reader (e.g., productReader.findAllByBrandIdPaged or a streaming query) and process in fixed-size chunks, calling delete on each entity then flush() and clear() between chunks to bound the persistence context and shorten transaction time; update deleteAllByBrandId to use that new bulk or chunked method and ensure `@Transactional` boundaries are applied per-chunk if using chunked processing.apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java-24-27 (1)
24-27:⚠️ Potential issue | 🟠 MajorexistsByLoginId에서 soft-delete 필터가 누락되어 있다.
findByLoginId, findById, findAll 메서드는 모두 deletedAt.isNull() 조건을 적용하는데, existsByLoginId는 삭제된 회원도 포함하여 조회한다. MemberService의 register 메서드에서 이 메서드를 사용하여 중복 가입을 방지하므로, 삭제된 회원의 loginId로 신규 가입이 차단되는 운영 이슈가 발생한다.
🐛 soft-delete 필터 추가 제안
`@Override` public boolean existsByLoginId(String loginId) { - return memberJpaRepository.existsByLoginId(loginId); + return memberJpaRepository.findByLoginIdAndDeletedAtIsNull(loginId).isPresent(); }MemberJpaRepository에는 이미 existsByLoginId와 별도로 findByLoginIdAndDeletedAtIsNull 메서드가 존재한다. existsByLoginId를 직접 사용하지 말고, 기존의 soft-delete 필터가 적용된 메서드를 활용하거나, 또는 새로운 existsByLoginIdAndDeletedAtIsNull 메서드를 추가하여 사용해야 한다. 삭제된 회원의 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/infrastructure/member/MemberReaderImpl.java` around lines 24 - 27, existsByLoginId currently ignores soft-delete and blocks re-registration; update MemberReaderImpl.existsByLoginId to consult the soft-delete-aware query (use memberJpaRepository.findByLoginIdAndDeletedAtIsNull(...) and return presence, or add and call memberJpaRepository.existsByLoginIdAndDeletedAtIsNull(...)) so deleted members' loginIds don't block new registrations invoked from MemberService.register; also add an integration test covering re-registration after soft-delete.apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Controller.java-17-25 (1)
17-25:⚠️ Potential issue | 🟠 Major페이지네이션 파라미터의 범위 제한이 필요하다.
size파라미터에 상한이 없어size=10000같은 요청이 들어올 경우 메모리 과다 사용 및 DB 부하가 발생할 수 있다. 운영 환경에서 의도치 않은 대량 조회로 인한 장애를 방지해야 한다.🛡️ size 상한 검증 추가 제안
Facade 또는 Service 레이어에서 검증하거나, Controller에서 값 보정을 수행한다:
`@GetMapping` public ApiResponse<MemberAdminV1Dto.MemberListResponse> getMembers( `@RequestParam`(required = false) String keyword, `@RequestParam`(defaultValue = "0") int page, `@RequestParam`(defaultValue = "20") int size ) { + int validatedSize = Math.min(size, 100); // 최대 100건 제한 - PagedInfo<AdminMemberInfo> result = memberFacade.getMembersForAdmin(keyword, page, size); + PagedInfo<AdminMemberInfo> result = memberFacade.getMembersForAdmin(keyword, page, validatedSize); return ApiResponse.success(MemberAdminV1Dto.MemberListResponse.from(result)); }또는 도메인 레이어에서 검증하여 CoreException을 throw한다.
🤖 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/member/MemberAdminV1Controller.java` around lines 17 - 25, Add a hard upper bound check for the pagination parameters in MemberAdminV1Controller.getMembers: introduce a MAX_PAGE_SIZE constant (e.g., 100) and clamp or validate the incoming size (and ensure page >= 0) before calling memberFacade.getMembersForAdmin; either adjust size = Math.min(size, MAX_PAGE_SIZE) or return a 4xx ApiResponse when size > MAX_PAGE_SIZE, and document/centralize the check so memberFacade.getMembersForAdmin always receives a safe size value.apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemReader.java-5-8 (1)
5-8:⚠️ Potential issue | 🟠 Major
findAllByOrderIds메서드에서 빈 리스트 전달 시 불필요한 데이터베이스 쿼리 방지 필요.
OrderItemReaderImpl.findAllByOrderIds구현체에서 파라미터로 받은orderIds가 빈 리스트일 때 즉시 공 리스트를 반환하지 않고 저장소 메서드를 호출한다. 실제로OrderFacade.toPagedSummary에서 조회 결과가 없을 때 빈orderIds리스트를 전달하므로, SQLWHERE id IN ()절을 포함한 불필요한 쿼리가 실행되어 데이터베이스 리소스를 낭비한다.구현 코드
`@Override` public List<OrderItem> findAllByOrderIds(List<Long> orderIds) { return orderItemJpaRepository.findAllByOrderIdInAndDeletedAtIsNull(orderIds); }
orderIds.isEmpty()체크를 추가하여 빈 리스트인 경우 저장소 호출 없이 공 컬렉션을 반환하도록 수정하고, 이 동작을 단위 테스트로 검증한다.🤖 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/OrderItemReader.java` around lines 5 - 8, The implementation of OrderItemReader.findAllByOrderIds (in OrderItemReaderImpl.findAllByOrderIds) should short-circuit when orderIds is empty to avoid invoking orderItemJpaRepository.findAllByOrderIdInAndDeletedAtIsNull with an empty collection; update the method to return Collections.emptyList() (or equivalent) when orderIds.isEmpty() and otherwise call the repository, and add a unit test verifying that passing an empty orderIds list does not call the repository and returns an empty list.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-23-34 (1)
23-34:⚠️ Potential issue | 🟠 MajorProductFacade의 sort 파라미터 검증 누락으로 인한 500 에러 발생
ProductFacade.getProducts()38-39줄에서 유효하지 않은 sort 값(예:"INVALID")이 전달되면ProductSortType.valueOf(sort)에서IllegalArgumentException이 발생한다. 현재는ApiControllerAdvice의 genericThrowable핸들러에 의해 500INTERNAL_ERROR로 응답되며, 사용자에게 유효한 sort 값이 무엇인지 알려주지 않는다.개선 방안:
ProductFacade의getProducts()메서드에서valueOf()호출 시 예외를 처리하고, 유효한 sort 타입(LATEST, PRICE_ASC, PRICE_DESC, LIKES_DESC)을 명시한 에러 메시지와 함께CoreException을 던져야 한다. 이를 통해 사용자는 400BAD_REQUEST와 함께 올바른 sort 값을 인식할 수 있다.public PagedInfo<ProductInfo> getProducts(String keyword, Long brandId, String sort, int page, int size) { ProductSortType sortType; try { sortType = ProductSortType.valueOf(sort); } catch (IllegalArgumentException e) { throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 sort 값입니다. 가능한 값: LATEST, PRICE_ASC, PRICE_DESC, LIKES_DESC"); } // ... 나머지 로직 }🤖 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 23 - 34, ProductFacade.getProducts currently passes the raw sort string into ProductSortType.valueOf(...) which throws IllegalArgumentException for invalid values; wrap the valueOf call in a try/catch inside getProducts, catch IllegalArgumentException and throw a CoreException with ErrorType.BAD_REQUEST and a clear message listing valid sort types (e.g., "유효하지 않은 sort 값입니다. 가능한 값: LATEST, PRICE_ASC, PRICE_DESC, LIKES_DESC") so invalid requests return 400 instead of 500.apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java-15-15 (1)
15-15:⚠️ Potential issue | 🟠 Major
findAllByIdInAndDeletedAtIsNull에 IN 절 파라미터 크기 제한에 대한 보호 로직을 추가해야 한다.BrandReaderImpl의 findAllByIds(List ids) 메서드는 호출자의 ID 리스트 크기를 검증하지 않고 데이터베이스 저장소에 직접 전달한다. 데이터베이스별 IN 절 파라미터 제한(예: Oracle 1000개, SQL Server 1000개)에 도달하면 쿼리 실패 또는 성능 저하가 발생한다.
BrandReaderImpl의 findAllByIds 메서드에 배치 크기 제한(권장 500~1000)을 정의하고, 이를 초과하는 경우 배치 분할 조회를 구현해야 한다. 이때 각 배치 결과를 병합하여 반환해야 한다. 또한 일반적인 사용 사례 이상으로 대량의 ID(5000개 이상)를 전달하는 테스트 케이스를 추가하여 배치 처리 로직의 정확성을 검증해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java` at line 15, BrandReaderImpl.findAllByIds currently forwards the full id list to BrandJpaRepository.findAllByIdInAndDeletedAtIsNull which risks DB IN-clause limits; implement batching (e.g., BATCH_SIZE = 500 or 1000) inside BrandReaderImpl.findAllByIds to split the incoming List<Long> ids into sublists, invoke repository.findAllByIdInAndDeletedAtIsNull for each batch, collect and merge results preserving uniqueness, and return the combined list; add a unit/integration test that supplies a very large id list (>=5000 ids) to verify batching and correct merged results.apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java-261-272 (1)
261-272:⚠️ Potential issue | 🟠 Major좋아요 목록 검증이 개수 중심이라 데이터 정확성·격리 회귀를 놓칠 수 있다.
Line 269
272, Line 338341은 건수만 검증해 잘못된 항목이 섞여도 통과할 수 있다. 운영 관점에서 타 사용자 데이터 노출 같은 프라이버시 장애를 늦게 발견하게 된다. 반환 ID를 정확히 단정하고, 좋아요하지 않은 항목이 제외되는지까지 검증하도록 강화해야 한다. 추가 테스트로 다른 회원의 좋아요 데이터를 함께 생성한 뒤 내 목록에 포함되지 않는지 검증해야 한다.🛠 수정 예시
assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().products()).hasSize(2) + () -> assertThat(response.getBody().data().products()).hasSize(2), + () -> assertThat(response.getBody().data().products().stream() + .map(p -> p.product().id()) + .toList()) + .containsExactlyInAnyOrder(product1, product2) );As per coding guidelines "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."
Also applies to: 330-341
🤖 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/LikeV1ApiE2ETest.java` around lines 261 - 272, The current assertions in LikeV1ApiE2ETest only check the product count, which can miss wrong items; update the test that calls the GET "/api/v1/members/me/likes/products" (in LikeV1ApiE2ETest) to assert exact product IDs and their order (or unordered set) returned by response.getBody().data().products(), and add setup that creates likes for another member and assert those other-member liked IDs are not present in the response; specifically, replace/augment the hasSize(2) checks with explicit assertions on the product ID values (and optionally other key fields) and add a separate scenario that creates a second member with likes to ensure isolation/exclusion.apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.java-41-43 (1)
41-43:⚠️ Potential issue | 🟠 Major페이지 파라미터 방어 검증이 없어 비정상 요청이 일관되지 않게 실패할 수 있다.
Line 41~43에서
page,size를 그대로 사용하면 잘못된 값이 전달될 때 런타임 예외가 상위로 전파되어 응답 포맷이 깨질 수 있다. 운영 관점에서 에러 표준화가 깨지면 모니터링/장애 분류가 어려워진다.page >= 0,size > 0을 선검증하고CoreException(BAD_REQUEST)로 변환하도록 수정해야 한다. 추가 테스트로page=-1,size=0요청이 400과 표준 에러 바디를 반환하는지 검증해야 한다.🛠 수정 예시
+import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; ... `@Override` public PageResult<Brand> findAll(String keyword, int page, int size) { + if (page < 0 || size <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상, size는 1 이상이어야 합니다."); + } Pageable pageable = PageRequest.of(page, size);Based on learnings "In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.java` around lines 41 - 43, In BrandReaderImpl.findAll, add defensive validation to ensure page >= 0 and size > 0 and if not throw a CoreException with BAD_REQUEST so the error is handled by ApiControllerAdvice; update the method to check the incoming page and size before constructing PageRequest and throw new CoreException(BAD_REQUEST) on invalid values, and add tests asserting that requests with page=-1 and size=0 return HTTP 400 with the standard error body.apps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.java-235-242 (1)
235-242:⚠️ Potential issue | 🟠 Major목록 0번 인덱스 단정은 정렬 정책 변경 시 플래키를 유발한다.
Line 241에서 첫 번째 원소를 바로 단정하면 정렬 기준 변경이나 DB 반환 순서 변화 시 테스트가 간헐적으로 실패한다. 운영 관점에서 CI 불안정은 실제 장애 신호를 희석시킨다. 수정 대상 주소를
addressId로 찾아 검증하도록 바꾸는 편이 안전하다. 추가 테스트로 주소 2개를 둔 상태에서 두 번째 주소를 수정해도 ID 기준 검증이 안정적으로 통과하는지 확인해야 한다.🛠 수정 예시
- assertThat(listResponse.getBody().data().addresses().get(0).label()).isEqualTo("새집"); + var updated = listResponse.getBody().data().addresses().stream() + .filter(a -> a.id().equals(addressId)) + .findFirst(); + assertThat(updated).isPresent(); + assertThat(updated.get().label()).isEqualTo("새집");As per coding guidelines "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.java` around lines 235 - 242, The assertion that directly checks the first list element (listResponse.getBody().data().addresses().get(0).label()) is brittle; update the test in AddressV1ApiE2ETest to locate the modified address by its addressId instead of by index (use the response list returned from ENDPOINT_ADDRESSES to find the DTO whose id matches the expected addressId and assert its label), and add a second scenario where two addresses are created, then modify the second address and verify changes by searching the list for that addressId to ensure the test is stable against ordering changes.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-82-87 (1)
82-87:⚠️ Potential issue | 🟠 Major삭제된 상품에 대한 주문 취소 시 예외가 발생하여 취소 자체가 실패할 수 있다.
Line 84에서
productService.getProduct(item.getProductId())를 호출하는데, 주문 이후 해당 상품이 삭제되었다면NOT_FOUND예외가 발생한다. 이 경우:
- 주문 취소 트랜잭션 전체가 롤백됨
- 고객은 삭제된 상품이 포함된 주문을 취소할 수 없음
운영 관점에서 상품 삭제와 주문 취소 간의 의존성으로 인해 CS 이슈가 발생할 수 있다.
🔧 권장 수정: 삭제된 상품 조회 허용 또는 예외 처리
방법 1: 삭제된 상품 포함 조회 메서드 추가
// ProductReader Optional<Product> findByIdIncludingDeleted(Long id);방법 2: 예외 처리로 스킵
for (OrderItem item : items) { try { Product product = productService.getProduct(item.getProductId()); product.increaseStock(item.getQuantity()); } catch (CoreException e) { if (e.getErrorType() == ErrorType.NOT_FOUND) { log.warn("삭제된 상품 재고 복원 스킵: productId={}", item.getProductId()); continue; } throw e; } }삭제된 상품 포함 주문 취소 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/application/order/OrderFacade.java` around lines 82 - 87, In the OrderFacade stock restore loop that calls productService.getProduct(item.getProductId()) for each OrderItem, handle the case where the product was deleted so cancellation doesn't fail: either update productService/ProductReader to expose a findByIdIncludingDeleted(Long) and use it when restoring stock, or catch the CoreException from getProduct and if e.getErrorType() == ErrorType.NOT_FOUND log a warning (including item.getProductId()) and continue; ensure you still rethrow unexpected exceptions and add an E2E test for cancelling an order containing a deleted product.apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java-39-41 (1)
39-41:⚠️ Potential issue | 🟠 MajorlikeCount 증감 시 동시성 경합(lost update)이 발생할 수 있다.
toggleLike후increaseLikeCount/decreaseLikeCount를 호출하는 과정에서, 두 사용자가 동시에 같은 상품에 좋아요를 누르면getBrand/getProduct→likeCount읽기 → 증가 → 저장 사이에 경합이 발생하여 count가 누락될 수 있다.운영 환경에서 인기 상품에 대한 동시 좋아요 시 likeCount 정확도가 저하된다. 즉각적인 장애는 아니나, 데이터 정합성 관점에서 개선이 필요하다.
🔧 권장 수정: 비관적 잠금 또는 UPDATE 쿼리 직접 실행
방법 1:
@Lock(LockModeType.PESSIMISTIC_WRITE)적용// BrandReader/ProductReader에 잠금 조회 메서드 추가 Optional<Brand> findByIdForUpdate(Long id);방법 2: UPDATE 쿼리 직접 실행 (권장)
// BrandRepository에 추가 `@Modifying` `@Query`("UPDATE Brand b SET b.likeCount = b.likeCount + 1 WHERE b.id = :id") int incrementLikeCount(`@Param`("id") Long id);동시성 테스트 추가를 권장한다:
`@Test` void concurrentLikeToggle_shouldMaintainCorrectCount() throws Exception { // 다중 스레드로 동시 좋아요 토글 후 최종 count 검증 }🤖 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 39 - 41, The likeCount update is vulnerable to lost-update race conditions when toggleLike calls productService.increaseLikeCount/decreaseLikeCount concurrently; fix by using a database-level update or pessimistic lock instead of read-modify-write. Replace the current read-then-save logic in increaseLikeCount and decreaseLikeCount with a single atomic UPDATE statement (add e.g. incrementLikeCount/decrementLikeCount `@Modifying` queries to BrandRepository/ProductRepository and call them from ProductService) or add a findByIdForUpdate method on BrandReader/ProductReader and annotate with `@Lock`(LockModeType.PESSIMISTIC_WRITE) to lock the row inside toggleLike; also add a concurrent unit test (concurrentLikeToggle_shouldMaintainCorrectCount) to verify correctness under multithreaded toggles.apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java-85-96 (1)
85-96:⚠️ Potential issue | 🟠 Major
cancelOrder메서드에@Transactional어노테이션이 누락되었다.
order.cancel()호출로 엔티티 상태가 변경되지만, 이 메서드에는@Transactional이 없다. 현재OrderFacade에서 트랜잭션 컨텍스트 내에서 호출되어 동작하지만, 이 서비스 메서드가 다른 곳에서 직접 호출될 경우 상태 변경이 영속화되지 않는 장애가 발생할 수 있다.운영 관점에서 트랜잭션 경계가 명확하지 않으면 추후 유지보수 시 예상치 못한 데이터 불일치 문제를 유발할 수 있다.
🔧 수정안
+ `@Transactional` public List<OrderItem> cancelOrder(Long orderId, Long memberId) { Order order = orderReader.findByIdAndMemberId(orderId, memberId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));추가 테스트:
cancelOrder를 Facade가 아닌 Service 단독 호출 시에도 DB 상태 변경이 정상 반영되는지 확인하는 통합 테스트를 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java` around lines 85 - 96, The cancelOrder method in OrderService mutates the Order entity via order.cancel() but lacks a transactional boundary; annotate the method (or the OrderService class) with `@Transactional` so the entity state changes are flushed and persisted when this service is called directly, ensure the proper import of org.springframework.transaction.annotation.Transactional, and keep existing behavior when called from OrderFacade; add an integration test that calls OrderService.cancelOrder directly to verify the DB state is updated.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (5)
.claude/skills/requirements-analysis/SKILL.mdis excluded by!**/*.mdand included by**docs/design/01-requirements.mdis excluded by!**/*.mdand included by**docs/design/02-sequence-diagrams.mdis excluded by!**/*.mdand included by**docs/design/03-class-diagram.mdis excluded by!**/*.mdand included by**docs/design/04-erd.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (124)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/application/PagedInfo.javaapps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.javaapps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.javaapps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.javaapps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.javaapps/commerce-api/src/main/java/com/loopers/application/like/BrandLikeInfo.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeToggleInfo.javaapps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.javaapps/commerce-api/src/main/java/com/loopers/application/member/AdminMemberInfo.javaapps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.javaapps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderSummaryInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.javaapps/commerce-api/src/main/java/com/loopers/domain/PageResult.javaapps/commerce-api/src/main/java/com/loopers/domain/address/Address.javaapps/commerce-api/src/main/java/com/loopers/domain/address/AddressReader.javaapps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.javaapps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.javaapps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.javaapps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.javaapps/commerce-api/src/main/java/com/loopers/domain/like/Like.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeReader.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.javaapps/commerce-api/src/main/java/com/loopers/domain/member/Gender.javaapps/commerce-api/src/main/java/com/loopers/domain/member/Member.javaapps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.javaapps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.javaapps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.javaapps/commerce-api/src/main/java/com/loopers/domain/order/Order.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemReader.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.javaapps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.javaapps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-api/src/main/java/com/loopers/support/auth/AdminAuthFilter.javaapps/commerce-api/src/main/java/com/loopers/support/auth/AuthUtils.javaapps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.javaapps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.javaapps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.javaapps/commerce-api/src/test/java/com/loopers/ArchitectureTest.javaapps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.javaapps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.javaapps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberAdminV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/support/auth/AdminAuthFilterE2ETest.javahttp/commerce-api/address-v1.httphttp/commerce-api/brand-v1.httphttp/commerce-api/like-v1.httphttp/commerce-api/member-v1.httphttp/commerce-api/order-v1.httphttp/commerce-api/product-v1.http
| Product product = productService.getProduct(productId); | ||
| product.validateOrderQuantity(quantity); | ||
| product.decreaseStock(quantity); | ||
|
|
||
| totalAmount += product.getPrice() * quantity; | ||
| commands.add(new OrderService.OrderItemCommand( | ||
| productId, product.getName(), product.getPrice(), quantity | ||
| )); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, find and examine OrderFacade.java to see the full context of createOrder method
fd -t f "OrderFacade.java" apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 163
🏁 Script executed:
# Check the OrderFacade.createOrder method with `@Transactional` annotation
rg -n "class OrderFacade|@Transactional|void createOrder|public.*createOrder" -A5 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2373
🏁 Script executed:
# Check ProductService.getProduct `@Transactional` setting
fd -t f "ProductService.java" apps/commerce-api/src/main/java/ && cat apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2733
🏁 Script executed:
# Search for decreaseStock and increaseStock usage patterns to verify if save() is called
rg -n "decreaseStock|increaseStock" -B2 -A5 apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3609
🏁 Script executed:
# Get the full createOrder method in OrderFacade
sed -n '32,74p' apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1805
🏁 Script executed:
# Also check the cancelOrder method that uses increaseStock
sed -n '75,87p' apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 568
🏁 Script executed:
# Check if ProductRepository has any explicit save calls
rg -n "productRepository.save" apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 214
readOnly 트랜잭션에서 엔티티 상태 변경이 DB에 반영되지 않는다.
ProductService.getProduct()의 @Transactional(readOnly = true) 설정으로 인해 이 메서드 내에서 반환된 Product 엔티티에 대한 수정사항이 데이터베이스에 반영되지 않는다. 54-55줄에서 product.validateOrderQuantity(quantity)와 product.decreaseStock(quantity)를 호출하지만, readOnly 트랜잭션 컨텍스트에서는 Hibernate 세션이 읽기 전용 모드로 설정되어 dirty checking을 통한 변경사항 반영이 차단된다.
운영 관점의 문제점:
- 주문 생성 후에도 실제 재고가 차감되지 않아 재고 불일치 발생
- 동시성 주문 시 재고 초과 판매(overselling) 위험
- 주문 취소(cancelOrder) 시 increaseStock() 호출도 동일한 문제 발생
수정안:
ProductService에 decreaseStock()과 increaseStock() 전용 메서드를 추가하여 @Transactional (readOnly=false)로 선언하거나, OrderFacade에서 productService를 통해 재고를 수정하도록 구조 변경이 필요하다.
테스트 방법:
createOrder() 호출 후 직접 데이터베이스 조회하여 Product 테이블의 stock_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/OrderFacade.java`
around lines 53 - 61, The Product entity mutations performed in OrderFacade
(calls to product.validateOrderQuantity(...) and product.decreaseStock(...)) are
happening on an entity obtained via ProductService.getProduct() which is
annotated `@Transactional`(readOnly = true), so changes are not flushed; fix by
adding write-side methods on ProductService such as decreaseStock(productId,
quantity) and increaseStock(productId, quantity) annotated `@Transactional` (no
readOnly) and move the stock-change logic into those methods (or call those new
methods from OrderFacade instead of mutating the returned Product), and update
cancelOrder to call ProductService.increaseStock(...) similarly so DB updates
are persisted via a write transaction.
| public Address register(Long memberId, String label, String recipientName, String recipientPhone, | ||
| String zipCode, String address1, String address2) { | ||
| long count = addressReader.countByMemberId(memberId); | ||
| if (count >= MAX_ADDRESS_COUNT) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "배송지는 최대 " + MAX_ADDRESS_COUNT + "개까지 등록할 수 있습니다."); | ||
| } | ||
| boolean isDefault = count == 0; | ||
| Address address = Address.create(memberId, label, recipientName, recipientPhone, | ||
| zipCode, address1, address2, isDefault); | ||
| return addressRepository.save(address); |
There was a problem hiding this comment.
동시 등록 시 주소 수 상한과 기본 배송지 단일성이 깨질 수 있다.
운영 관점에서 동일 회원의 등록 요청이 동시에 들어오면 count 기반 검증이 경합에 취약해 10개 초과 저장 또는 기본 배송지 2건 이상이 발생할 수 있어 데이터 정합성이 깨진다.
수정안으로 회원 단위 잠금(FOR UPDATE 기반 조회 또는 멤버 행 잠금)으로 등록을 직렬화하고, DB에 deleted_at IS NULL AND is_default = true 조건의 유니크 인덱스를 추가해 최종 방어선을 두는 것이 필요하다.
추가 테스트로 동일 회원에 대해 동시 등록 요청을 발생시켜 총 주소 수가 10개를 넘지 않고 기본 배송지가 정확히 1개인지 검증해야 한다.
수정 예시
`@Transactional`
public Address register(Long memberId, String label, String recipientName, String recipientPhone,
String zipCode, String address1, String address2) {
- long count = addressReader.countByMemberId(memberId);
+ List<Address> addresses = addressReader.findAllByMemberIdForUpdate(memberId);
+ long count = addresses.size();
if (count >= MAX_ADDRESS_COUNT) {
throw new CoreException(ErrorType.BAD_REQUEST, "배송지는 최대 " + MAX_ADDRESS_COUNT + "개까지 등록할 수 있습니다.");
}
- boolean isDefault = count == 0;
+ boolean isDefault = addresses.stream().noneMatch(Address::getIsDefault);
Address address = Address.create(memberId, label, recipientName, recipientPhone,
zipCode, address1, address2, isDefault);
return addressRepository.save(address);
}CREATE UNIQUE INDEX ux_address_member_default_active
ON address(member_id)
WHERE deleted_at IS NULL AND is_default = true;As per coding guidelines **/*Service*.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/address/AddressService.java`
around lines 21 - 30, The current AddressService.register flow uses
addressReader.countByMemberId then creates and saves an Address, which is racy
under concurrent requests and can allow >MAX_ADDRESS_COUNT or multiple
is_default=true rows; fix by moving register into a transactional boundary
(annotate AddressService.register or calling method) and acquire a member-scoped
lock when checking addresses (e.g., SELECT ... FOR UPDATE on the member row or
use a repository method that locks existing addresses for the member before
counting), enforce the MAX_ADDRESS_COUNT check inside that transaction, ensure
only one address is marked default by clearing is_default on other addresses for
the member within the same transaction before saving, and add a DB-level unique
partial index (e.g., on member_id WHERE deleted_at IS NULL AND is_default =
true) as a final defensive constraint to prevent dual defaults; reference
AddressService.register, addressReader.countByMemberId, addressRepository.save,
and Address.create when applying these changes and add concurrent-insertion
tests to validate count and single-default invariants.
| public void changeDefault(Long id, Long memberId) { | ||
| Address newDefault = getAddress(id, memberId); | ||
| List<Address> addresses = addressReader.findAllByMemberId(memberId); | ||
| for (Address address : addresses) { | ||
| if (address.getIsDefault()) { | ||
| address.changeDefault(false); | ||
| } | ||
| } | ||
| newDefault.changeDefault(true); | ||
| } |
There was a problem hiding this comment.
기본 배송지 변경 로직이 동시성 상황에서 복수 기본 배송지를 남길 수 있다.
운영 관점에서 두 요청이 동시에 다른 주소를 기본으로 변경하면, 각 트랜잭션이 자신이 본 스냅샷 기준으로만 isDefault를 내리기 때문에 최종적으로 두 주소가 모두 기본 상태로 남을 수 있다.
수정안으로 회원 주소 집합을 잠금 조회한 뒤 일괄적으로 기본값을 해제하고 대상 1건만 기본값으로 설정하거나, 벌크 업데이트 + 유니크 인덱스로 단일성을 강제해야 한다.
추가 테스트로 서로 다른 주소를 대상으로 동시 changeDefault를 실행해 항상 기본 배송지가 정확히 1건인지 검증해야 한다.
수정 예시
`@Transactional`
public void changeDefault(Long id, Long memberId) {
- Address newDefault = getAddress(id, memberId);
- List<Address> addresses = addressReader.findAllByMemberId(memberId);
- for (Address address : addresses) {
- if (address.getIsDefault()) {
- address.changeDefault(false);
- }
- }
- newDefault.changeDefault(true);
+ List<Address> addresses = addressReader.findAllByMemberIdForUpdate(memberId);
+ Address newDefault = addresses.stream()
+ .filter(address -> address.getId().equals(id))
+ .findFirst()
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "배송지를 찾을 수 없습니다."));
+
+ for (Address address : addresses) {
+ address.changeDefault(false);
+ }
+ newDefault.changeDefault(true);
}As per coding guidelines **/*Service*.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/address/AddressService.java`
around lines 58 - 67, The changeDefault method can leave multiple addresses
marked default under concurrent requests; wrap the operation in a transaction
and perform a locked/bulk update: fetch the member's addresses with a
pessimistic lock (or execute a single bulk UPDATE clearing isDefault for
memberId via addressReader/repository), then set the target Address (from
getAddress(id, memberId)) to isDefault=true using Address.changeDefault(true)
and save; alternatively also enforce a database unique partial index on
(member_id) where is_default=true to guarantee uniqueness and add a concurrency
test that runs concurrent changeDefault calls on different ids to assert exactly
one default remains.
| Optional<Like> existing = likeReader.findByMemberIdAndTargetTypeAndTargetId(memberId, targetType, targetId); | ||
|
|
||
| if (existing.isPresent()) { | ||
| likeRepository.delete(existing.get()); | ||
| return false; | ||
| } | ||
|
|
||
| Like like = Like.create(memberId, targetType, targetId); | ||
| likeRepository.save(like); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and read the LikeService.java file
find . -type f -name "LikeService.java" -path "*/domain/like/*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 159
🏁 Script executed:
# Also search for Like entity and related files
find . -type f -name "Like*.java" | grep -E "(domain|entity)" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 608
🏁 Script executed:
# Look for database schema or JPA configuration
find . -type f \( -name "*.sql" -o -name "schema*" \) | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Read the LikeService.java file
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1455
🏁 Script executed:
# Read the Like entity
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/like/Like.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3025
🏁 Script executed:
# Read LikeRepository to understand persistence
cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 250
🏁 Script executed:
# Check LikeServiceTest for concurrency tests
cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6382
🏁 Script executed:
# Check if there's a Spring Boot configuration file for transaction isolation level
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
# Look for any JPA/Hibernate configuration that might affect isolation levels
rg -l "hibernate|jpa|transaction" --type properties --type yaml | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 119
🏁 Script executed:
# Check if there are any integration tests that might test with actual DB
find . -path "*/test/*" -name "*IntegrationTest*.java" -o -path "*/test/*" -name "*ConcurrencyTest*.java" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 180
🏁 Script executed:
# Check the main application.yml for transaction/JPA configuration
cat -n ./apps/commerce-api/src/main/resources/application.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1450
🏁 Script executed:
# Look at the integration test pattern to understand testing approach
cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2972
🏁 Script executed:
# Check if there's any global exception handling or DataIntegrityViolationException handling
rg "DataIntegrityViolationException|ConstraintViolation|UniqueConstraint" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 315
🏁 Script executed:
# Check the jpa.yml configuration for isolation level details
cat -n ./apps/commerce-api/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 163
🏁 Script executed:
# Check for any global exception handlers
find ./apps/commerce-api -type f -name "*ExceptionHandler*.java" -o -name "*ErrorHandler*.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Look for any use of pessimistic locking or SELECT FOR UPDATE patterns in the codebase
rg "LockModeType|SELECT FOR UPDATE|@Lock" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Find jpa.yml file
find . -name "jpa.yml" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 125
🏁 Script executed:
# Look for exception handlers in infrastructure layer
find ./apps/commerce-api -type f -name "*.java" | xargs grep -l "ControllerAdvice\|ExceptionHandler" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 170
🏁 Script executed:
# Check if there's any retry mechanism or circuit breaker for repository calls
rg "@Retryable|@CircuitBreaker|Resilience4j" --type java | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Read the jpa.yml configuration
cat -n ./modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2903
🏁 Script executed:
# Check the ApiControllerAdvice to see if DataIntegrityViolationException is handled
cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6820
동시 요청 시 중복 삽입 유니크 제약 예외가 500으로 전파된다
라인 19-27의 check-then-act 패턴으로 인해 동일한 사용자의 빠른 연타나 재시도 요청 시 두 트랜잭션 모두 existing이 없다고 판단한 뒤 insert 경쟁 조건이 발생할 수 있다. 두 번째 insert는 uk_like_member_target 유니크 제약을 위반하여 DataIntegrityViolationException을 발생시키는데, 현재 ApiControllerAdvice에서 이를 처리하지 않으므로 500 오류로 전파된다. 운영 관점에서 정상적인 사용자 동작(더블 클릭, 재시도)으로 간헐 500이 발생하면 장애로 인지된다.
저장소 단에서 원자적 토글로 개선하거나, 최소한 중복 제약 예외를 도메인 규칙에 맞게 흡수하여 일관된 응답을 반환해야 한다. 또한 동일한 (memberId, targetType, targetId)에 대해 동시 토글 부하를 걸어 500 미발생과 최종 상태 일관성을 검증하는 동시성 통합 테스트를 추가하는 것이 필요하다.
⚙️ 임시 완화 예시
+import org.springframework.dao.DataIntegrityViolationException;
@@
public boolean toggleLike(Long memberId, LikeTargetType targetType, Long targetId) {
Optional<Like> existing = likeReader.findByMemberIdAndTargetTypeAndTargetId(memberId, targetType, targetId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
return false;
}
- Like like = Like.create(memberId, targetType, targetId);
- likeRepository.save(like);
- return true;
+ try {
+ Like like = Like.create(memberId, targetType, targetId);
+ likeRepository.save(like);
+ return true;
+ } catch (DataIntegrityViolationException e) {
+ Optional<Like> concurrent = likeReader.findByMemberIdAndTargetTypeAndTargetId(memberId, targetType, targetId);
+ if (concurrent.isPresent()) {
+ likeRepository.delete(concurrent.get());
+ return false;
+ }
+ 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/domain/like/LikeService.java`
around lines 19 - 27, The check-then-act race causes
DataIntegrityViolationException on concurrent inserts; update LikeService to
handle this by (1) replacing the fragile pattern with an atomic repository
toggle (preferred) or at minimum wrap the likeRepository.save call in a
try/catch for DataIntegrityViolationException (or the specific constraint
violation), and on catch treat it as "already exists" and return the same
logical result as a successful insert (i.e., return true) instead of letting the
exception propagate to ApiControllerAdvice; reference
likeReader.findByMemberIdAndTargetTypeAndTargetId, likeRepository.save,
likeRepository.delete, Like.create and the uk_like_member_target unique
constraint, and add a concurrency integration test that simulates parallel
toggles for the same (memberId, targetType, targetId) to assert no 500s and
consistent final state.
| public void decreaseStock(int quantity) { | ||
| if (this.stockQuantity < quantity) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); | ||
| } | ||
| this.stockQuantity -= quantity; | ||
| } |
There was a problem hiding this comment.
음수/0 수량 입력이 재고 무결성을 깨뜨릴 수 있다.
운영 관점에서 decreaseStock(-1)은 재고를 감소가 아니라 증가시키며, validateOrderQuantity가 0/음수를 허용해 주문 경로에서 비정상 수량이 통과될 수 있어 재고 데이터 오염과 악용 가능성이 생긴다.
수정안으로 차감/주문 수량 모두 quantity > 0을 강제하고, 예외는 CoreException으로 일관 처리해야 한다.
추가 테스트로 decreaseStock(-1), decreaseStock(0), validateOrderQuantity(-1/0)가 모두 예외를 던지고 재고가 변하지 않는지 검증해야 한다.
수정 예시
public void decreaseStock(int quantity) {
+ validatePositive((long) quantity, "차감 수량은 0보다 커야 합니다.");
if (this.stockQuantity < quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
this.stockQuantity -= quantity;
}
public void validateOrderQuantity(int quantity) {
+ if (quantity <= 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 0보다 커야 합니다.");
+ }
if (quantity > this.maxOrderQuantity) {
throw new CoreException(ErrorType.BAD_REQUEST,
"상품 '" + this.name + "'의 최대 주문 수량(" + this.maxOrderQuantity + ")을 초과했습니다.");
}
}As per coding guidelines **/domain/**/*.java: 도메인 규칙을 엔티티에서 일관되게 강제해 불변성과 캡슐화를 보장해야 한다.
Also applies to: 91-95
🤖 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 69 - 74, The decreaseStock method currently allows zero/negative
quantities which can increase stock or leave it unchanged; update
Product.decreaseStock to validate that quantity > 0 and throw a
CoreException(ErrorType.BAD_REQUEST, "...") for non-positive inputs before any
mutation, and keep the existing insufficient-stock check; likewise update/ensure
validateOrderQuantity (the method referenced around lines 91-95) enforces
quantity > 0 and throws CoreException on invalid values so domain invariants are
enforced in the entity; add unit tests asserting decreaseStock(-1),
decreaseStock(0), and validateOrderQuantity(-1/0) all throw CoreException and
that stockQuantity is unchanged after these calls.
| public static ProductLikeListResponse from(PagedInfo<ProductLikeInfo> result) { | ||
| var dtos = result.content().stream() | ||
| .map(info -> new ProductLikeDto( | ||
| new ProductDto( | ||
| info.product().id(), | ||
| info.product().name(), | ||
| info.product().description(), | ||
| info.product().price(), | ||
| info.product().stockQuantity(), | ||
| info.product().maxOrderQuantity(), | ||
| info.product().likeCount(), | ||
| new BrandDto( | ||
| info.product().brand().id(), | ||
| info.product().brand().name(), | ||
| info.product().brand().description(), | ||
| info.product().brand().likeCount() | ||
| ) | ||
| ), | ||
| info.likedAt() | ||
| )) | ||
| .toList(); | ||
| return new ProductLikeListResponse( | ||
| dtos, | ||
| new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages()) | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, locate the file and explore surrounding code
fd "LikeV1Dto.java" --type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 163
🏁 Script executed:
# Check the ProductLikeInfo and BrandLikeInfo definitions
rg -l "class ProductLikeInfo|record ProductLikeInfo" --type java -g '!*Test*'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 166
🏁 Script executed:
# Check for PagedInfo definition
rg -l "class PagedInfo|record PagedInfo" --type java -g '!*Test*'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 155
🏁 Script executed:
# Search for ProductLikeInfo usage to understand how it's created
rg "ProductLikeInfo" --type java -g '!*Test*' -B2 -A2 | head -60Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5679
🏁 Script executed:
# Look for the service/facade layer that creates PagedInfo<ProductLikeInfo>
rg "PagedInfo<ProductLikeInfo>|new PagedInfo" --type java -g '!*Test*' -B3 -A3 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7879
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 836
🏁 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: 5343
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java | sed -n '45,110p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2918
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 567
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 998
🏁 Script executed:
# Check if there are any existing null checks or defensive patterns in similar code
rg "Objects.requireNonNull|if.*== null|Objects.isNull" --type java apps/commerce-api/src/main/java/com/loopers/application/like/ -B2 -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check BrandLikeInfo structure for comparison
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/BrandLikeInfo.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 675
LikeFacade.getMyLikedProducts()에서 brandMap.get()이 null을 반환할 수 있어 NPE 발생 위험이 있다.
LikeFacade 61-85번 라인에서 getMyLikedProducts() 메서드가 ProductLikeInfo를 생성할 때, 79번 라인의 Brand brand = brandMap.get(product.getBrandId());는 null을 반환할 수 있다. 이는 brand가 데이터베이스에서 삭제되었거나 존재하지 않는 경우 발생한다. 이 null 값이 80번 라인의 ProductLikeInfo.of(like, product, brand)로 전달되고, ProductLikeInfo 내부에서 BrandInfo.from(brand)을 호출할 때 brand.getId() 등의 메서드 호출로 인해 NPE가 발생한다.
같은 파일의 getMyLikedBrands() 메서드(88-106번 라인)는 98번 라인에서 .filter(like -> brandMap.containsKey(like.getTargetId()))로 brandMap에 존재하는 항목만 필터링하는 방식으로 안전하게 처리하고 있다. getMyLikedProducts() 메서드도 동일한 패턴으로 수정하거나, BrandInfo.from()에 Objects.requireNonNull() 또는 null 체크를 추가하여 운영 시점에 문제를 조기에 감지할 수 있도록 개선하기를 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java`
around lines 46 - 71, LikeFacade.getMyLikedProducts() can pass a null Brand from
brandMap into ProductLikeInfo, causing NPE later; update the method to filter
out likes whose product brandId is not present in brandMap (use the same pattern
as getMyLikedBrands() with brandMap.containsKey(product.getBrandId())) before
constructing ProductLikeInfo, or alternatively add an explicit null check (e.g.,
Objects.requireNonNull) inside ProductLikeInfo.of or BrandInfo.from to fail fast
with a clear message; locate and change the mapping logic in
LikeFacade.getMyLikedProducts() that builds ProductLikeInfo and ensure
brandMap.get(...) is guarded.
| private static final String HEADER_LDAP = "X-Loopers-Ldap"; | ||
| private static final String ADMIN_LDAP_VALUE = "loopers.admin"; | ||
|
|
There was a problem hiding this comment.
관리자 인증값 하드코딩은 즉시 우회 가능한 인증 취약점이다
Line 22의 고정 문자열은 코드 유출/열람만으로 재현 가능한 값이라 헤더 스푸핑에 취약하고, 운영 중 로테이션도 불가능하다. 운영 관점에서 관리 API 전체가 단일 정적 값에 의존하면 침해 시 피해 반경이 너무 크다. 시크릿 매니저/환경설정으로 외부화하고 주기적 교체가 가능하도록 바꾸는 것이 필요하다. 추가 테스트로 잘못된 값/누락/정상 값/회전된 값 각각에 대해 401/200을 검증하는 통합 테스트를 추가하는 것이 좋다.
🔒 제안 수정안
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
@@
- private static final String ADMIN_LDAP_VALUE = "loopers.admin";
+ `@Value`("${security.admin.ldap-value}")
+ private String adminLdapValue;
@@
- if (!ADMIN_LDAP_VALUE.equals(ldapValue)) {
+ if (!adminLdapValue.equals(ldapValue)) {
sendUnauthorizedResponse(response);
return;
}Also applies to: 34-37
🤖 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/support/auth/AdminAuthFilter.java`
around lines 21 - 23, AdminAuthFilter currently hardcodes the admin LDAP header
value (HEADER_LDAP / ADMIN_LDAP_VALUE) which is insecure; remove the hardcoded
ADMIN_LDAP_VALUE and load the expected admin value from a configuration/secret
source (e.g., environment variable or secret manager) at startup in
AdminAuthFilter, ensure the filter fails closed when the configured secret is
missing or invalid (log and return 401), and support rotation by reading the
value from a centrally managed config client or refreshing cache as appropriate;
also add integration tests for AdminAuthFilter that assert 401 on
missing/incorrect header, 200 on the correct header, and a test for rotated
value behavior.
📌 Summary
🧭 Context & Decision
1. 좋아요 도메인 구조
2. 좋아요 삭제 전략
3. 주문 시 재고 차감 전략
4. 주문-배송지/상품 관계
5. Domain Reader의 Spring Data 의존
6. Facade → Reader 직접 참조
🏗️ Design Overview
변경 범위
apps/commerce-api(Member, Brand, Product, Address, Like, Order)주요 컴포넌트 책임
Domain Layer
Member: 회원가입 검증(loginId/password/name/email/phone/gender), 비밀번호 변경, soft delete 탈퇴Brand: 브랜드 CRUD, likeCount 관리, soft delete 시 하위 상품 cascadeProduct: 상품 CRUD, 재고 관리(decrease/increaseStock), maxOrderQuantity 검증, likeCountAddress: 배송지 CRUD, 기본 배송지 관리(첫 등록 시 자동 기본, 기본 배송지 삭제 불가)Like: 단일 다형 구조(targetType + targetId), Hard delete 토글Order/OrderItem: 주문 생성/취소, 배송지 스냅샷, 상품 스냅샷, 상태 전이(COMPLETED → CANCELLED)Application Layer
OrderFacade: 주문 생성 오케스트레이션 (배송지 검증 → 중복 상품 합산 → 재고 선차감 → 주문 저장)LikeFacade: 좋아요 토글 + Product/Brand likeCount 동기 갱신ProductFacade: 상품 조회 시 Brand 정보 조합BrandFacade: 브랜드 삭제 시 하위 상품 cascade soft deleteInfrastructure Layer
ProductReaderImpl,MemberReaderImpl,OrderReaderImpl)findAllByIds,findAllByOrderIds)Support Layer
MemberAuthFilter: JWT 인증 필터 (Member API 경로)AdminAuthFilter: X-Loopers-Ldap 헤더 기반 Admin 인증AuthUtils: Controller 공통 인증 로직 추출API 엔드포인트
테스트 현황
🔁 Flow Diagram
Main Flow
주문 생성 (핵심 흐름)
sequenceDiagram autonumber participant Client participant Controller participant OrderFacade participant MemberService participant AddressService participant ProductService participant OrderService participant DB Client->>Controller: POST /api/v1/orders Controller->>OrderFacade: createOrder(loginId, addressId, items) OrderFacade->>MemberService: getMember(loginId) MemberService->>DB: findByLoginId DB-->>MemberService: Member OrderFacade->>AddressService: getAddress(addressId, memberId) AddressService->>DB: findByIdAndMemberId DB-->>AddressService: Address (스냅샷 추출) OrderFacade->>OrderService: mergeOrderItems(items) Note over OrderService: 동일 상품 수량 합산 + 빈 목록 검증 OrderService-->>OrderFacade: Map<productId, quantity> loop 합산된 상품별 OrderFacade->>ProductService: getProduct(productId) ProductService->>DB: findById DB-->>ProductService: Product Note over OrderFacade: product.validateOrderQuantity(qty) Note over OrderFacade: product.decreaseStock(qty) end OrderFacade->>OrderService: createOrder(memberId, 배송지 스냅샷, totalAmount) OrderService->>DB: save Order OrderFacade->>OrderService: createOrderItems(orderId, 상품 스냅샷 목록) OrderService->>DB: saveAll OrderItems OrderFacade-->>Controller: OrderInfo Controller-->>Client: 200 OK + OrderResponse주문 취소 (재고 복원)
sequenceDiagram autonumber participant Client participant Controller participant OrderFacade participant OrderService participant ProductService participant DB Client->>Controller: POST /api/v1/orders/{orderId}/cancel Controller->>OrderFacade: cancelOrder(loginId, orderId) OrderFacade->>OrderService: cancelOrder(orderId, memberId) Note over OrderService: 소유권 검증 + 취소 상태 체크 Note over OrderService: order.cancel() (COMPLETED → CANCELLED) Note over OrderService: OrderItem 목록 조회 OrderService-->>OrderFacade: List<OrderItem> loop 각 아이템별 (cross-domain orchestration) OrderFacade->>ProductService: getProduct(productId) Note over OrderFacade: product.increaseStock(qty) — 재고 복원 end OrderFacade-->>Controller: void Controller-->>Client: 200 OK레이어 의존 구조
PR 요약: 도메인 주도 설계 기반 이커머스 플랫폼 구현
변경 목적
상품 조회→좋아요→주문으로 이어지는 e-commerce 핵심 흐름을 6개 도메인(Member, Brand, Product, Address, Like, Order)으로 구현하고, DDD 원칙에 따른 계층화된 아키텍처(Domain/Service/Facade/Controller/Repository/Reader/Infrastructure 분리)를 확립하여 비즈니스 로직과 기술 구현의 명확한 경계를 설정합니다.
핵심 변경점
리스크 및 주의사항
테스트 및 검증
Unit 테스트(Entity/Service에 대한 도메인 테스트 130+개, 각 Facade별 로직 검증), E2E 테스트(Testcontainers + TestRestTemplate으로 HTTP 시나리오 검증), ArchUnit 규칙(계층 간 접근 제한, 저장소 순수성, 도메인 setter 금지 등) 적용으로 아키텍처 준수 여부를 자동화 검증합니다. OrderFacade cancelOrder의 재고 복구, Like 좋아요/취소 토글, Order 생성 시 재고 전감소 케이스 모두 테스트 커버 확인됨.