[Volume 5] 인덱스 설계,Redis 캐시 조회 최적화 - 임나현#193
[Volume 5] 인덱스 설계,Redis 캐시 조회 최적화 - 임나현#193iohyeon wants to merge 15 commits intoLoopers-dev-lab:iohyeonfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughRedis 기반 상품·주문 캐시 관리자와 지연 더블-딜리트 무효화 도입, 페이지 기반에서 커서 기반 페이징으로 전환, QueryDSL 커서 쿼리 추가, 대규모 데이터 시더·k6 부하 스크립트 및 엔티티 인덱스 추가가 포함된 변경이다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Controller as Controller
participant Facade as ProductFacade
participant CacheMgr as ProductCacheManager
participant Repo as ProductRepositoryImpl
participant Redis as Redis
rect rgba(200, 150, 255, 0.5)
Note over Client,Redis: 커서 기반 상품 목록 조회(캐시 어사이드 + 더블 딜리트 연계)
Client->>Controller: GET /api/v1/products?sort=...&size=...&cursor=...
Controller->>Controller: cursor 디코드 → ProductCursor
Controller->>Facade: getDisplayableProductsWithCursor(brandId, sort, cursor, size)
Facade->>CacheMgr: getProductList(sort, brandId)
CacheMgr->>Redis: GET products:list:...
alt 캐시 히트
Redis-->>CacheMgr: JSON
CacheMgr-->>Facade: Optional(ProductCursorResult)
else 캐시 미스
Redis-->>CacheMgr: null
CacheMgr-->>Facade: Optional.empty
Facade->>Repo: findAllDisplayableWithCursor(brandId, sort, cursor, size)
Repo-->>Facade: CursorResult<Product>
Facade->>CacheMgr: putProductList(sort, brandId, result)
CacheMgr->>Redis: SET products:list:... (TTL 60s)
end
Facade-->>Controller: ProductCursorResult
Controller->>Controller: nextCursor 생성 및 인코드
Controller-->>Client: ProductCursorListResponse (products + paging)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 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)
📝 Coding Plan
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 Tip CodeRabbit can use TruffleHog to scan for secrets in your code with verification capabilities.Add a TruffleHog config file (e.g. trufflehog-config.yml, trufflehog.yml) to your project to customize detectors and scanning behavior. The tool runs only when a config file is present. |
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 (22)
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java-60-72 (1)
60-72:⚠️ Potential issue | 🟠 Major중복 회피용 재추첨은 고좋아요 상품에서 시딩 시간을 급격히 늘린다.
현재 방식은
likeCount가USER_COUNT에 가까워질수록 마지막 사용자 몇 명을 뽑기 위해 난수를 계속 재시도하게 된다. 이 PR의 분포면 수천 좋아요 상품이 반복적으로 나오므로 시딩 시간이 비선형으로 늘고 로컬/CI 실행이 불안정해질 수 있다. 사용자 ID 배열을 부분 셔플하거나BitSet기반 샘플링으로 바꿔 한 번의 선형 패스로 고유 사용자를 뽑는 편이 안전하다.likeCount == USER_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/infrastructure/seeder/ProductLikeSeeder.java` around lines 60 - 72, The current retry loop in ProductLikeSeeder (usedUsers + random retry) causes quadratic/severe slowdown when likeCount approaches USER_COUNT; replace it with deterministic unique sampling: build a list/array of user IDs 1..USER_COUNT and perform a partial Fisher–Yates shuffle to draw the first likeCount unique IDs (or use a BitSet and iterate to pick the first likeCount set bits) and then create likes from those IDs, removing the while-loop and usedUsers set; also add a unit/integration test that seeds a product with likeCount == USER_COUNT to assert all USER_COUNT unique likes are created within a short timeout to catch regressions.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java-59-67 (1)
59-67:⚠️ Potential issue | 🟠 Major
USED쿠폰에 미래 시각과 비어 있는 주문 참조가 섞일 수 있다.
createdAt이 현재에 가까우면plusDays(1..30)때문에used_at이 미래가 되고,paidOrderIds가 부족하면USED인데도order_id와used_at이 null인 레코드가 나온다. 이런 데이터는 사용 이력 조회와 통계 검증을 왜곡한다.usedAt은createdAt과now사이에서만 뽑고, 매핑할 PAID 주문이 없으면USED를 만들지 말고 실패시키거나 다른 상태로 낮추는 편이 안전하다. 최근 생성 쿠폰과paidOrderIds가 비어 있는 경우를 각각 넣어used_at <= now,used_at >= created_at,USED => order_id != 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/infrastructure/seeder/IssuedCouponSeeder.java` around lines 59 - 67, The current seeder may assign a usedAt in the future and create USED records without an order when paidOrderIds is exhausted; modify the block that handles status to (1) ensure usedAt is picked between createdAt and now (e.g., random time between createdAt.plusSeconds(1) and now) rather than using plusDays blindly, (2) only mark status "USED" and assign orderId when paidOrderIds has an available id (use paidOrderIdx % paidOrderIds.size() safely and increment paidOrderIdx only after a successful assignment), and (3) if no paid order is available, downgrade the status (or skip creating a USED entry) so USED => orderId != null and usedAt <= now && usedAt >= createdAt hold; add tests that create recent coupons and empty paidOrderIds asserting those invariants for ZonedDateTime createdAt, usedAt, orderId, status, paidOrderIdx, and paidOrderIds.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java-40-45 (1)
40-45:⚠️ Potential issue | 🟠 Major선행 시더 누락을 경고만 하고 넘기면 부분 시딩을 정상으로 오인한다.
likeCountsPerProduct가 null이면 전체 좋아요 시딩이 통째로 빠지는데, 현재는 warn 후 계속 진행한다. 성능 측정과 캐시 검증에서 데이터셋이 불완전해도 실패로 드러나지 않아 원인 파악이 어려워진다.products.like_count를 DB에서 다시 읽어오거나, 최소한 예외를 던져 시딩을 즉시 중단하는 편이 안전하다.ProductLikeSeeder를 단독 실행했을 때 조용히 성공하지 않고 실패하거나 DB 기반으로 정상 보정되는 테스트를 추가하는 편이 좋다. As per coding guidelines 'null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.'최소 수정안
int[] likeCountsPerProduct = productSeeder.getLikeCountsPerProduct(); if (likeCountsPerProduct == null) { - log.warn("[ProductLikeSeeder] ProductSeeder 미실행. 건너뜁니다."); - return; + throw new IllegalStateException("ProductSeeder must run before ProductLikeSeeder"); }🤖 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/seeder/ProductLikeSeeder.java` around lines 40 - 45, The seed() method in ProductLikeSeeder uses productSeeder.getLikeCountsPerProduct() and only logs a warning if it returns null, which lets the seeder silently produce incomplete data; change the behavior so that when likeCountsPerProduct is null you either (A) reload the like counts from the database (e.g., query products.like_count via the ProductRepository/DAO) and proceed with the correct counts, or (B) fail fast by throwing an unchecked exception (e.g., IllegalStateException) from ProductLikeSeeder.seed() with a clear message; update callers/tests to expect the new behavior and add a unit/integration test that runs ProductLikeSeeder.seed() when productSeeder is absent to ensure it either throws or correctly re-reads products.like_count from the DB.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java-34-64 (1)
34-64:⚠️ Potential issue | 🟠 MajorBrandSeeder가 ID를 명시하지 않으면 ID 시퀀스가 꼬져 후속 시더가 실패한다.
BrandSeeder는id컬럼을 지정하지 않고 auto-increment에 의존하지만,ProductSeeder와BrandLikeSeeder는 브랜드 ID 범위 1~500을 직접 가정한다.DataSeeder의isAlreadySeeded()메서드는products테이블만 검사하므로, 테이블이 부분 삭제되거나 초기화 후 재시딩되면BrandSeeder가 ID 501부터 시작하게 되어 FK 참조 위반이나 ACTIVE/INACTIVE 분포 오류를 유발한다. 로컬 개발에서 테이블을 수동으로 삭제한 후 재시딩하거나, 통합 테스트 중 실패 후 재실행할 때 쉽게 재현된다.권장 사항:
BrandSeeder의INSERT문에id컬럼을 명시하거나, 시딩 전에 모든 관련 테이블을TRUNCATE하고 auto-increment를 초기화하는 로직을DataSeeder에 추가한다.isAlreadySeeded()검사를 모든 핵심 테이블(users, brands, products)을 확인하도록 강화하거나, 보다 안전한 대안으로 시딩 상태를 별도 플래그 테이블로 관리한다.- 테이블이 이미 존재할 때 재시딩이 발생해도 참조 무결성과 데이터 분포가 유지되는지 확인하는 통합 테스트를 추가한다.
🤖 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/seeder/BrandSeeder.java` around lines 34 - 64, BrandSeeder currently omits explicit id values which lets the DB auto-increment advance (causing later seeds like ProductSeeder and BrandLikeSeeder to assume brand IDs 1..500 and fail); fix by either (A) inserting explicit id in BrandSeeder's INSERT (include id column and set ps.setInt for ids 1..BRAND_COUNT in the BatchPreparedStatementSetter in BrandSeeder) or (B) ensure DataSeeder resets state before seeding by truncating related tables and resetting sequences (add logic to DataSeeder before calling BrandSeeder to TRUNCATE brands, products, brand_likes and reset the brands sequence), and also strengthen DataSeeder.isAlreadySeeded() to check users, brands, products (or use a dedicated seed flag table) so re-seeding preserves expected ID ranges referenced by ProductSeeder and BrandLikeSeeder.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java-65-72 (1)
65-72:⚠️ Potential issue | 🟠 Major삭제 시각이 생성 시각보다 과거로 내려갈 수 있다.
Line 65-72에서
createdAt과deleted_at을 독립적으로 생성해서 최근 생성된 항목은deleted_at < created_at가 쉽게 발생한다. 이런 데이터는 soft delete 조건, 정렬, 이력 분석 결과를 왜곡하므로deleted_at은 항상createdAt이후가 되도록createdAt~now구간에서 파생해야 한다. 시드 후 전체cart_items에 대해deleted_at is null or deleted_at >= created_at를 검증하는 테스트를 추가해야 한다.🤖 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/seeder/CartItemSeeder.java` around lines 65 - 72, In CartItemSeeder update the deleted_at generation so it is derived from createdAt (not independently from now): when isDeleted is true pick a random instant between createdAt and now (e.g., random offset from createdAt up to now-createdAt) and use that for deleted_at to guarantee deleted_at >= createdAt; also add a post-seed test that queries cart_items and asserts for every row that deleted_at is null or deleted_at >= created_at to prevent regressions.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java-76-115 (1)
76-115:⚠️ Potential issue | 🟠 Major부분 시딩 상태를
products한 테이블로만 판정하면 재실행이 막힌다.Line 77-115는
products건수만 보고 전체 시딩 완료로 간주하는데, 현재 구조에서는 중간 단계에서 실패해도 앞선 배치가 커밋될 수 있어 반쪽 데이터베이스가 다음 부팅부터 영구히 skip될 수 있다. 특히 다른 시더들이1..NID 연속성을 전제로 참조하고 있어 한번 어긋나면 이후 FK, unique 충돌까지 연쇄된다. 마지막 성공 시점에만 기록하는 별도 seeding marker를 두거나 각 핵심 테이블의 기대 건수를 검증해 불일치 시 재시도 또는 중단하도록 바꾸고,productSeeder.seed()직후 강제 실패시킨 뒤 재기동해도 skip되지 않는 통합 테스트를 추가해야 한다.🤖 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/seeder/DataSeeder.java` around lines 76 - 115, The current isAlreadySeeded() check relies solely on products count which lets partial runs permanently skip later retries; update the seeding strategy by (A) replacing or augmenting isAlreadySeeded() to validate multiple critical tables' expected counts (e.g., products, users, orders) or check a dedicated seeding marker/flag written only after the full run completes, and (B) ensure the codepath that writes the marker occurs at the very end of run() after all seeders (userSeeder.seed(), productSeeder.seed(), orderSeeder.seed(), etc.) finish successfully; add an integration test that simulates a failure immediately after productSeeder.seed() and verifies that on restart the seeder does not skip and either retries missing tables or proceeds until the final marker is written.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java-73-91 (1)
73-91:⚠️ Potential issue | 🟠 Major쿠폰 상태와 유효기간을 따로 뽑으면 데이터 불변식이 깨진다.
Line 73-91은
status와valid_from/valid_to를 독립 생성해서ACTIVE인데 이미 만료됐거나EXPIRED인데 아직 유효한 쿠폰을 쉽게 만든다. 상태와 기간을 함께 사용하는 조회, 캐시, 성능 테스트 결과가 왜곡되므로 최소한ACTIVE와EXPIRED는 기간과 일치하도록 기간을 상태에서 파생해야 한다. 시드 후 status별로ACTIVE => validFrom <= now < validTo,EXPIRED => validTo < now를 검증하는 테스트를 추가해야 한다.🤖 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/seeder/CouponTemplateSeeder.java` around lines 73 - 91, The current seeder generates status via generateStatus() separately from validFrom/validTo, which can create inconsistent rows (e.g., ACTIVE but already expired); modify CouponTemplateSeeder so that after calling generateStatus() you derive validFrom and validTo from the status: for "ACTIVE" set validFrom <= now and validTo > now (e.g., validFrom = now.minus(random up to X days), validTo = now.plus(random up to Y days)), for "EXPIRED" set validTo < now (both validFrom and validTo in the past), and for other statuses choose an appropriate window; update the code paths that call ps.setTimestamp(10/11) and ps.setString(12) to use these derived dates, and add a unit/integration test that seeds some rows and asserts invariants: ACTIVE => validFrom <= now < validTo, EXPIRED => validTo < now.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java-41-41 (1)
41-41:⚠️ Potential issue | 🟠 Major시드 비밀번호는 유효한 BCrypt 해시여야 한다.
라인 41의
"$2a$10$dummyHashedPasswordForSeeding"은 완전한 BCrypt 해시 형식이 아니다. BCrypt 해시는$2a$10$<22글자 salt><31글자 hash>구조(총 60글자)를 가져야 하는데, 현재 값은 salt와 hash 부분이 불완전하다. 애플리케이션이BCryptPasswordEncoder.matches()를 통해 인증할 때 이 값은 false를 반환하거나 예외를 발생시켜 시드 계정 로그인이 실패한다.미리 생성한 정상 BCrypt 해시를 상수로 저장하거나, 시딩 시
PasswordEncoder.encode("testPassword")로 생성한 값을 재사용하도록 수정한다. 시드 완료 후 시드 계정으로 실제 로그인 가능 여부를 검증하는 통합 테스트를 추가한다.🤖 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/seeder/UserSeeder.java` at line 41, The seeded password string in UserSeeder (the ps.setString(...) for the password parameter) is not a valid 60-character BCrypt hash; replace the hardcoded "$2a$10$dummyHashedPasswordForSeeding" with a real BCrypt hash or generate one at seed time using your PasswordEncoder (e.g., call passwordEncoder.encode("testPassword") inside the UserSeeder before ps.setString) and store that value, and add/invoke an integration test that verifies the seeded account can authenticate using BCryptPasswordEncoder.matches(); ensure you update any constant names if you extract the precomputed hash.http/commerce-api/order.http-35-38 (1)
35-38:⚠️ Potential issue | 🟠 Major예제 파일에 비밀번호를 하드코딩하지 않는 편이 안전하다.
.http파일은 저장소, 화면 공유, 로그 수집 과정에서 그대로 노출되기 쉬워 테스트 계정 유출로 바로 이어진다. 운영에서는 스테이징 계정 재사용이나 자동화 스크립트 전파로 피해 범위가 커질 수 있으니X-Loopers-LoginPw는{{ORDER_API_PASSWORD}}같은 변수로 치환하고 실제 값은 로컬 전용 환경 파일에서만 주입하는 방식으로 바꾸는 편이 안전하다. 추가로 시크릿 스캐너가*.http파일의 자격증명을 차단하는지와, 변수 치환 후 샘플 요청이 정상 동작하는지 확인하는 검증을 넣어야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@http/commerce-api/order.http` around lines 35 - 38, Replace the hardcoded header value for X-Loopers-LoginPw with a placeholder variable (e.g., {{ORDER_API_PASSWORD}}) so the .http example does not contain actual credentials; update any local environment or VSCode REST Client environment file to inject the real password locally, run the sample request to verify variable substitution works, and add a quick check that your secret-scanning rules (if any) allow variableized .http files to avoid false positives.apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java-39-40 (1)
39-40:⚠️ Potential issue | 🟠 Major잘못된 커서를
IllegalArgumentException으로 던지면 API 오류 포맷이 흔들릴 수 있다.이 예외가 컨트롤러 밖으로 그대로 올라가면 다른 입력 오류와 응답 스키마가 달라질 수 있다. 운영에서는 클라이언트가 커서 오류만 별도 처리해야 하고 로그 상관 분석도 어려워지므로, 기존 공통 오류 체계의
CoreException으로 감싸되 cause는 유지하는 형태로 맞추는 편이 안전하다. 추가로 잘못된 Base64, 잘못된 JSON, 필수 필드 누락 커서 각각에 대해 동일한 오류 응답 본문이 내려오는 API 테스트를 넣어야 한다. 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."apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java-20-22 (1)
20-22:⚠️ Potential issue | 🟠 Major
Map<String, Object>역직렬화 시 타입 정보 손실로 인한 런타임 오류 위험
JavaTimeModule을 등록해도Map<String, Object>로 역직렬화할 때는 ISO-8601 날짜 문자열이String으로 유지되며ZonedDateTime등 구체적 타입으로 변환되지 않는다. 이로 인해 다음 페이지 요청 시 정렬 키를 타입에 맞게 캐스팅할 때 런타임 오류가 발생하며, 특정 정렬 조건에서만 간헐적인 400/500 에러가 발생하여 원인 파악이 어려워진다.
ProductCursorPayload,OrderCursorPayload같은 명시적 DTO나 record로 분리해 타입을 고정하거나,JsonNode로 읽고 필드를 명시적으로 파싱하는 방식으로 개선한다. 추가로 날짜 기반 커서와 숫자 기반 커서 각각에 대해 encode→decode round-trip 테스트를 추가해 타입이 유지되는지 검증한다.한편
decode()메서드의catch (Exception e)블록이 모든 예외를 포괄하고IllegalArgumentException으로 변환하므로, Base64 디코딩 오류와 JSON 파싱 오류를 구분하기 어렵다. 예외 타입을 세분화하거나 로그에 원인 예외의 타입과 메시지를 명시적으로 기록하도록 개선한다.🤖 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/common/CursorEncoder.java` around lines 20 - 22, CursorEncoder currently deserializes cursor payloads into Map<String,Object> using MAPPER which loses concrete types (e.g. ISO-8601 dates become String) causing runtime cast errors; change CursorEncoder to deserialize into explicit DTOs/records (e.g. ProductCursorPayload, OrderCursorPayload) or read into JsonNode and parse each field to the correct type (ZonedDateTime, Long, etc.) rather than Map<String,Object>, update encode/decode to use those types, add unit tests that round-trip date-based and numeric cursors to verify type preservation, and in decode() replace the broad catch(Exception) with finer-grained handling (separate Base64 decoding errors vs JSON parsing/type errors) and include the original exception type/message in the thrown IllegalArgumentException or throw specific exceptions so callers can distinguish failure reasons.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-95-99 (1)
95-99:⚠️ Potential issue | 🟠 Major
size파라미터 상한값 검증이 없어 DoS 위험이 존재한다.현재
size파라미터는 검증 없이 직접 데이터베이스.limit()쿼리에 전달되므로, 클라이언트가 의도적으로 매우 큰 값(예: 1000000)을 요청할 경우 데이터베이스 부하와 메모리 오버플로우가 발생한다.ProductController의size파라미터에@Max제약을 추가하여 상한값을 제한해야 한다. AdminCouponFacade에서Math.max(1, size)로 방어하는 패턴처럼, 이 메서드도 일관된 검증 전략을 적용해야 한다. 추가로 경계값 테스트(size=최대값+1, 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/domain/product/ProductService.java` around lines 95 - 99, The getDisplayableProductsWithCursor method forwards the client-provided size directly to the repository causing a DoS risk; add an upper bound check (and lower-bound clamp) before calling productRepository.findAllDisplayableWithCursor — either enforce `@Max` on the ProductController size parameter and/or cap/validate size inside ProductService (e.g., sanitize size = Math.max(1, Math.min(size, MAX_PAGE_SIZE))) in getDisplayableProductsWithCursor and then call productRepository.findAllDisplayableWithCursor with the sanitized value; add unit tests for boundary cases (size = MAX_PAGE_SIZE + 1 and size < 1) to ensure the validation works.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java-13-19 (1)
13-19:⚠️ Potential issue | 🟠 Major팩토리 메서드에서
id필드의 null 검증이 필요하다.커서 기반 페이지네이션에서
id는 동점(tie-breaker) 역할을 하므로 필수 값이다. 첫 페이지는 커서 객체 자체를 null로 전달하는 방식으로 올바르게 처리되고 있으나, 팩토리 메서드(ofLatest(),ofPrice(),ofLikes())에서 null id를 검증하지 않아 잘못된 커서가 리포지토리 쿼리 실행 시 Querydsl에서 문제를 야기할 수 있다. 특히 클라이언트에서 보낸 커서가 손상되거나 id 필드가 누락된 경우decodeCursor()메서드에서 NPE가 발생한다.각 팩토리 메서드에서
Objects.requireNonNull(id, "id는 null이 될 수 없습니다")를 추가하여 잘못된 커서 생성을 조기에 방어하고, 이에 대한 단위 테스트를 추가해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java` around lines 13 - 19, ProductCursor 팩토리 메서드에서 id가 null일 경우 Querydsl/NPE를 유발하므로 ofLatest(), ofPrice(), ofLikes() 내부에서 생성 직전에 Objects.requireNonNull(id, "id는 null이 될 수 없습니다")로 검증을 추가해 잘못된 커서 생성을 방어하고, decodeCursor()에서 NPE가 발생하지 않도록 관련 단위 테스트를 각각 추가하여 null id 입력 시 예외가 발생하는지를 검증하세요.apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java-161-162 (1)
161-162:⚠️ Potential issue | 🟠 Major목록 캐시 키가
size를 누락한다.여기서 기대하는 키 규약이
products:list:{sort}:{brand|all}로 고정되어 있어size=20과size=50첫 페이지가 같은 엔트리를 공유하게 된다. 운영에서는 요청한 건수와 실제 응답 건수가 엇갈리고, 다음 커서도 이전 요청 크기에 오염될 수 있다. 캐시 키에size를 포함하거나 캐시를 고정 size에만 허용하고,20 → 50,50 → 20시나리오 통합 테스트를 추가해야 한다.Also applies to: 179-180, 211-213
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java` around lines 161 - 162, The test constructs a cache key missing the requested page size (key = "products:list:LATEST:all"), causing different-size requests to collide; update ProductCacheIntegrationTest to include the size in the cache key generation used with redisTemplate.opsForValue().get(...) (e.g., "products:list:{sort}:{size}:{brandOrAll}") or enforce using a fixed size across the cache helper, and then add integration assertions that exercise both size=20 -> size=50 and size=50 -> size=20 sequences to verify no cross-contamination; adjust the other occurrences referenced around the product-list tests (the blocks using redisTemplate.opsForValue().get at the other noted locations) to use the same size-aware key format.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-104-105 (1)
104-105:⚠️ Potential issue | 🟠 Major주문 목록 invalidation에 single delete만 두면 stale write race가 남는다.
첫 페이지 cache miss가 DB를 읽는 동안 주문 생성/취소가 커밋되고 캐시를 한 번만 삭제하면, 느린 읽기 쪽이 오래된 목록을 다시 써 넣을 수 있다. 그러면 방금 생성되거나 취소된 주문 상태가 TTL 300초 동안 잘못 노출될 수 있다. 상품과 동일하게 delayed double delete를 적용하거나 versioned key/namespace bump로 write-after-delete race를 끊는 편이 안전하다. 생성/취소와 기본 목록 조회를 교차 실행하는 동시성 통합 테스트도 추가해야 한다.
Also applies to: 136-137, 288-288, 322-323
🤖 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 104 - 105, The single cache deletion using orderCacheManager.evictOrderList(userId) leaves a stale-write race; update the OrderFacade methods that call evictOrderList (the places around the shown snippet and the other occurrences) to employ a safe invalidation strategy: either perform a delayed double-delete (call evictOrderList(userId), sleep a small configurable interval, then call evictOrderList(userId) again) or implement a versioned key/namespace bump for the user's order list (increment the user's order-list version/namespace on create/cancel and read keys keyed by that version) so readers cannot re-populate an older namespace; also add a concurrency integration test that runs parallel create/cancel and list-read to assert no stale results are cached for the TTL window.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java-73-87 (1)
73-87:⚠️ Potential issue | 🟠 Major잘못된 상품
cursor가 일관된 오류 응답으로 처리되지 않는다.운영 중 잘못된
cursor가 들어오면CursorEncoder.decode,ZonedDateTime.parse, 숫자 캐스팅 예외와 여기의IllegalArgumentException이 그대로 올라가서 API 오류 형식이 깨질 수 있다.decodeCursor전체를try/catch로 감싸 전용CoreException으로 변환하고 cause를 보존하는 편이 안전하다.sort불일치,sv/id누락, 타입 불일치 케이스의 컨트롤러 테스트도 추가해야 한다. 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." As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java-86-90 (1)
86-90:⚠️ Potential issue | 🟠 Major잘못된
cursor입력이 500으로 누수된다.운영 중 만료되거나 변조된
cursor가 들어오면CursorEncoder.decode,ZonedDateTime.parse, 숫자 캐스팅 예외가 그대로 전파되어 응답 포맷이 깨지고 500으로 보일 가능성이 높다. 디코딩/파싱 전체를try/catch로 감싸 전용CoreException으로 변환하고 cause를 보존하는 편이 안전하다.base64손상,createdAt/id누락, 타입 불일치 케이스의 컨트롤러 테스트도 추가해야 한다. 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." As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java` around lines 86 - 90, Wrap the whole cursor decoding/parsing block in OrderController (the code that calls CursorEncoder.decode, ZonedDateTime.parse and casts id) in a try/catch that catches all parsing/decoding exceptions, create and throw a CoreException (preserving the original exception as the cause) with a user-facing message, and ensure ApiControllerAdvice will translate that to the unified response; also add controller tests for corrupted base64, missing createdAt/id, and wrong types to validate the conversion to CoreException and consistent error responses.apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-342-360 (1)
342-360:⚠️ Potential issue | 🟠 Major기본 주문 목록 캐시가
size를 구분하지 않는다.현재 cache get/put가
userId만으로 결정되어size=50요청이 채운 첫 페이지를size=20요청이 그대로 재사용할 수 있다. 그러면 응답 건수와PagingInfo.size가 어긋나고 커서도 잘못 이어진다. 캐시 키에size를 포함하거나, 최소한 캐시 사용을 고정 size(예: 20)일 때만 허용해야 한다.20 → 50,50 → 20,size=0/1회귀 테스트를 추가하는 편이 안전하다.최소 수정안 예시
+ boolean useDefaultCache = isDefaultQuery && size == 20; - if (isDefaultQuery) { + if (useDefaultCache) { java.util.Optional<OrderCursorResult> cached = orderCacheManager.getOrderList(userId); if (cached.isPresent()) { return cached.get(); } } @@ - if (isDefaultQuery) { + if (useDefaultCache) { orderCacheManager.putOrderList(userId, cursorResult); }🤖 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 342 - 360, The cache currently keys only by userId (calls to orderCacheManager.getOrderList / putOrderList in OrderFacade) so different requests with different size values can return incorrect pages; change the caching logic to include the requested size (e.g., key = userId + ":" + size) or restrict caching to a single fixed page size (check isDefaultQuery && size==FIXED_SIZE before get/put), update the cache manager usages accordingly, and add unit tests covering size changes (20→50, 50→20, and edge sizes like 0/1) to ensure PagingInfo.size and cursor continuity remain correct.apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-196-205 (1)
196-205:⚠️ Potential issue | 🟠 Major활성 브랜드만 열거하는 목록 무효화는 방금 삭제/비활성화된 브랜드 키를 놓친다.
Line 198의
getAllActiveBrands()결과만으로 키를 만들면,afterCommit시점에 이미 active 집합에서 빠진 brandId의products:list:{sort}:{oldBrandId}는 TTL까지 남는다. 운영에서는 브랜드 삭제/비활성화 직후에도 해당 브랜드 필터 첫 페이지가 stale하게 응답된다. 현재 활성 브랜드 외에 이번 트랜잭션에서 영향받은brandId를 명시적으로 합쳐서 삭제하거나, 브랜드별 version/prefix 무효화로 전환하는 편이 안전하다. 특정 브랜드 목록 캐시를 미리 채운 뒤 삭제/비활성화 후 같은 brandId로 재조회했을 때 stale 캐시가 사용되지 않는 테스트를 추가해 달라.🤖 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/cache/ProductCacheManager.java` around lines 196 - 205, evictAllProductListCache() currently builds eviction keys only from brandService.getAllActiveBrands(), which misses brandIds removed/disabled in the same transaction and leaves stale products:list:{sort}:{oldBrandId} entries; update the method to also include the set of brandIds affected by the current transaction (e.g., pass in or fetch recently changed brandIds from the caller/brandService) when constructing keys (still using LIST_KEY_PREFIX and iterating ProductSortType.values()), or switch to a brand-specific version/prefix invalidation scheme so deactivations immediately change cache keys; also add an integration test that pre-warms a brand-specific products list cache, performs brand deactivate/delete, then re-queries that brandId and asserts the stale cache is not returned.apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-156-167 (1)
156-167:⚠️ Potential issue | 🟠 Major브랜드 비활성화에서 상세 캐시를 남기면 비노출 상품 상세가 최대 300초 동안 계속 노출된다.
Line 157의 "상세는 브랜드 상태 무관" 가정이 현재 상세 조회 규칙과 맞지 않는다.
ProductFacade.getProductDetail()의 DB 경로는brandService.getActiveBrand(product.getBrandId())를 통과해야 하므로, 브랜드가 비활성 상태가 되면 해당 브랜드 상품 상세도 즉시 막혀야 한다. active→inactive 전환 시 영향받는productId목록을 함께 받아 상세 캐시도 1·2차 삭제하도록 바꾸고, 상태 전환 직후 상세 조회가 캐시 hit로 성공하지 않는 통합 테스트를 추가해 달라.🤖 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/cache/ProductCacheManager.java` around lines 156 - 167, The current registerListOnlyDoubleDelete() assumes product detail cache is unaffected by brand state changes, but ProductFacade.getProductDetail() calls brandService.getActiveBrand(product.getBrandId()) so detail caches must be evicted for affected productIds; modify registerListOnlyDoubleDelete() (or add a new overload) to accept a Collection<Long> affectedProductIds and call evictAllProductListCache() plus evictProductDetailCache(productId) for each id immediately after commit and again after DOUBLE_DELETE_DELAY_MS, updating usages to pass the list of productIds on active→inactive transitions; also add an integration test that toggles a brand to inactive, supplies the impacted productIds, and asserts that a subsequent ProductFacade.getProductDetail() does not return a cached (hit) response immediately after the transition.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java-55-75 (1)
55-75:⚠️ Potential issue | 🟠 Major첫 페이지 목록 캐시에
size차원이 빠져 있어 서로 다른 요청이 오염된다.Line 59와 Line 74의 캐시 API가
size를 받지 않아서, 동일한brandId/sort조합에 대해size=20으로 캐시가 채워진 뒤size=50요청이 들어오면 20개 결과와hasNext가 그대로 재사용된다. 캐시 계약을size까지 포함하도록 바꾸되 무효화도 같은 차원으로 맞추거나, 이 API에서 허용하는 page size를 단일 값으로 고정하는 편이 안전하다. 같은brandId/sort로size만 다르게 두 번 호출했을 때 서로 다른 캐시 엔트리를 사용하고 반환 개수와hasNext가 각각 올바른지 테스트를 추가해 달라.수정 예시
- Optional<ProductCursorResult> cached = productCacheManager.getProductList(sort, brandId); + Optional<ProductCursorResult> cached = productCacheManager.getProductList(sort, brandId, size); ... - productCacheManager.putProductList(sort, brandId, cursorResult); + productCacheManager.putProductList(sort, brandId, size, cursorResult);🤖 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 55 - 75, The cache currently ignores page size causing cross-contamination; update getDisplayableProductsWithCursor to include size in the cache key (use productCacheManager.getProductList(sort, brandId, size) and productCacheManager.putProductList(sort, brandId, size, cursorResult) or adjust the existing cache APIs accordingly) or alternatively enforce a single fixed page size used by this method and only cache that size; ensure the cache invalidation paths use the same size dimension; add unit tests for getDisplayableProductsWithCursor that call it twice with the same brandId/sort but different size values and assert separate cache entries are used and that returned ProductCursorResult.items().size() and hasNext() match the underlying service results.apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-60-83 (1)
60-83:⚠️ Potential issue | 🟠 MajorRedis 연결 실패 시 조회 API까지 장애가 전파되고, 캐시 무효화가 중단될 수 있다.
redisTemplate의get/set/delete호출이 JSON 역직렬화 예외(JsonProcessingException)만 처리하고 있어서, Redis 연결 끊김, 타임아웃, 메모리 부족 등으로 인한DataAccessException계열 예외가 그대로 전파된다. 운영에서는 Redis 일시 장애만으로 상품 조회 API가 500이 되거나, 후속 무효화 콜백이 중간에서 끊겨 stale 캐시가 TTL까지 남을 수 있다.수정안:
get/set/delete호출을 각각try-catch로 감싸서DataAccessException을 별도로 잡는다.- 읽기(
get) 실패는Optional.empty()로 miss 처리하여 DB 조회로 폴백한다.- 쓰기(
set) 실패는 경고 로그만 남기고 요청 자체는 성공시킨다(부분 쓰기는 무시).- 삭제(
delete) 실패는 경고 로그만 남기고 나머지 키 삭제를 계속 시도한다.추가 테스트:
RedisConnectionFailureException시뮬레이션 시 상세/목록 조회가 DB 결과로 정상 응답하는지 확인한다.- 무효화 콜백에서 일부 삭제 실패 시에도 나머지 키 삭제를 계속 시도하는지 확인한다.
- 테스트 구성:
redisTemplate을 mock하여get/set/delete에서DataAccessException계열 예외를 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/application/cache/ProductCacheManager.java` around lines 60 - 83, The current getProductDetail and putProductDetail use redisTemplate.get/set/delete without catching DataAccessException, so Redis connection/timeouts will propagate; wrap each redisTemplate call in try-catch(DataAccessException) around the redis access in getProductDetail (for key = DETAIL_KEY_PREFIX + productId) so a read failure returns Optional.empty() (fall back to DB) and logs a warning, wrap the write in putProductDetail (redisTemplate.opsForValue().set with DETAIL_TTL) so a write failure only logs a warning and does not fail the request, and ensure any redisTemplate.delete(key) calls (including the one in the JsonProcessingException handler) are similarly caught so deletion failures log a warning and allow other invalidations to continue; keep existing JsonProcessingException handling for objectMapper unchanged.
🟡 Minor comments (9)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-161-172 (1)
161-172:⚠️ Potential issue | 🟡 Minor빈 ID 목록은 즉시 반환해야 한다.
ids가 비어 있으면 결과는 항상 빈 리스트인데도 현재 구현은 브랜드 조회와 상품 조회를 계속 수행한다. 운영에서는 "좋아요 없음" 같은 흔한 정상 케이스가 불필요한 DB 부하와 지연으로 바뀐다. 수정안은 메서드 초입에서 빈 입력을 즉시 반환하도록 처리하는 것이다. 추가 테스트로 빈ids입력에서 빈 결과를 반환하고, 추가 조회 없이 종료되는지 리포지토리 테스트로 고정하는 것이 좋다.예시 수정안
`@Override` public List<Product> findAllByIdIn(List<Long> ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + QProductEntity product = QProductEntity.productEntity; List<Long> activeBrandIds = getActiveBrandIds();🤖 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 161 - 172, The method ProductRepositoryImpl.findAllByIdIn currently calls getActiveBrandIds() and executes a query even when the input ids list is empty; change the method to check if ids == null or ids.isEmpty() at the very start and immediately return Collections.emptyList() (or List.of()) to avoid calling getActiveBrandIds() and the query; update or add a repository unit test for findAllByIdIn to pass an empty ids list and assert it returns an empty list and that getActiveBrandIds()/queryFactory are not invoked (mock verification) to prevent unnecessary DB work.apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java-92-101 (1)
92-101:⚠️ Potential issue | 🟡 Minor좋아요가 비활성 또는 삭제 브랜드까지 생성된다.
generateBrandId()의 5% 경로는 401500을 반환하는데, 제공된500은 soft delete다. 이렇게 만들면 사용자 좋아요가 실제 노출되지 않는 브랜드에 쌓여 조회 결과와 캐시 적중률 분석이 어긋난다. 기본 시나리오는BrandSeeder기준 이 구간은 비활성이고 491ACTIVE_BRAND_COUNT이하의 브랜드만 선택하고, 비활성 브랜드 케이스가 꼭 필요하면 별도 시더로 분리하는 편이 안전하다.brand_likes와brands를 join해서status = ACTIVE이면서deleted_at is 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/infrastructure/seeder/BrandLikeSeeder.java` around lines 92 - 101, generateBrandId() is currently returning IDs in 401–500 (inactive / soft-deleted brands) which causes likes to point at non-visible brands; update the BrandLikeSeeder.generateBrandId() logic to only return IDs within the active range (use the project constant ACTIVE_BRAND_COUNT or the same upper bound used by BrandSeeder) and remove the branch that generates 401–500 IDs (or move that behavior into a separate inactive-brand seeder). Also add a unit/integration test that inserts brand_likes and joins brands to assert that only brands with status = ACTIVE and deleted_at IS NULL are referenced by likes to catch regressions.k6/cache-protection.js-29-39 (1)
29-39:⚠️ Potential issue | 🟡 Minor워밍업 없이 바로 측정하면 캐시 보호 효과가 왜곡된다.
주석은 "캐시 워밍업 후" 시나리오인데 실제 스크립트는 첫 요청부터 임계치에 포함해 cold miss와 cache hit를 섞어 측정한다. 운영에서는 이 결과를 근거로 캐시 보호 효과를 판단하면 p95가 환경마다 흔들려 성능 결론이 불안정해진다.
setup()에서 정렬별 첫 페이지를 한 번씩 호출해 캐시를 채운 뒤 본문 시나리오에서는 hit 상태만 측정하도록 분리하는 편이 안전하다. 추가로 워밍업 유무에 따른 응답시간 차이를 비교하는 k6 시나리오를 하나 더 두어 캐시 보호 효과가 실제로 재현되는지 확인해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/cache-protection.js` around lines 29 - 39, The current default exported function in k6/cache-protection.js mixes cold misses and hits because there is no warmup; add a setup() function that iterates SORT_TYPES and calls `${BASE_URL}/api/v1/products?sort=${sort}&size=20` once per sort to populate the cache before the test, and modify the default exported function (the existing anonymous export) to only issue requests that expect cache hits (preserve the tag 'list_cached'); additionally add a second scenario (separate k6 scenario or named function) that runs the same requests without calling setup() so you can compare warmup vs no-warmup latencies. Ensure you reference SORT_TYPES, BASE_URL, the request URL construction, and the 'list_cached' tag when making these changes.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java-24-26 (1)
24-26:⚠️ Potential issue | 🟡 Minor
ofPrice팩토리 메서드가 정렬 타입을 검증하지 않는다.
ofPrice는PRICE_ASC또는PRICE_DESC에서만 사용되어야 하나, 어떤ProductSortType이든 허용한다. 잘못된 정렬 타입 전달 시 쿼리 조건 불일치로 인해 예상치 못한 결과가 발생할 수 있다.🛡️ 정렬 타입 검증 추가
public static ProductCursor ofPrice(ProductSortType sort, Integer basePrice, Long id) { + if (sort != ProductSortType.PRICE_ASC && sort != ProductSortType.PRICE_DESC) { + throw new IllegalArgumentException("Price cursor requires PRICE_ASC or PRICE_DESC sort type"); + } return new ProductCursor(sort, null, basePrice, null, 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/ProductCursor.java` around lines 24 - 26, ofPrice 팩토리 메서드(ProductCursor.ofPrice)가 ProductSortType 검증을 하지 않아 잘못된 정렬 타입 전달 시 쿼리 불일치가 발생할 수 있습니다; ofPrice의 시작에서 전달된 sort가 ProductSortType.PRICE_ASC 또는 ProductSortType.PRICE_DESC인지 확인하고 그렇지 않으면 IllegalArgumentException을 던지도록 검증 로직을 추가하세요(에러 메시지에 허용되는 값들을 포함). 이 변경으로 잘못된 사용을 조기에 차단하고 호출자에게 명확한 예외를 제공할 수 있습니다.k6/concurrent-read-write.js-21-21 (1)
21-21:⚠️ Potential issue | 🟡 Minor
stalePriceCount카운터가 정의만 되고 사용되지 않는다.캐시 무효화 후 stale 데이터 반환을 감지하기 위해 정의된 것으로 보이나, 실제로 증가시키는 로직이 없다. 쓰기 후 읽기에서 이전 가격이 반환되는지 검증하는 로직을 추가하거나, 사용하지 않는다면 제거해야 한다.
♻️ Stale 데이터 감지 로직 예시
+let lastWrittenPrice = null; + export function writer() { const newPrice = 10000 + Math.floor(Math.random() * 90000); + lastWrittenPrice = newPrice; const res = http.patch(`${BASE_URL}/api-admin/v1/products/1`, JSON.stringify({ basePrice: newPrice }), { headers: ADMIN_HEADER, tags: { name: 'write_update' } } ); check(res, { 'update 200': (r) => r.status === 200 }); sleep(10); } export function reader() { // ... existing code ... } else { const res = http.get(`${BASE_URL}/api/v1/products/1`, { tags: { name: 'read_detail' } }); check(res, { '200 OK': (r) => r.status === 200 }); + // Stale 감지: 쓰기 이후 이전 가격 반환 시 카운트 + if (lastWrittenPrice && res.status === 200) { + const body = JSON.parse(res.body); + if (body.data && body.data.basePrice !== lastWrittenPrice) { + stalePriceCount.add(1); + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/concurrent-read-write.js` at line 21, The Counter stalePriceCount is declared but never updated; either increment it when a stale price is detected in the read-after-write validation flow (i.e., inside the logic that verifies the read returns the newly written price—increment stalePriceCount when the observed price equals the previous/old value) or remove the unused stalePriceCount declaration; locate the read-after-write verification (the scenario or function performing the write then subsequent read) and add a stalePriceCount.add(1) call on detection of stale data, or delete the stalePriceCount constant if you choose to drop stale detection entirely.k6/no-cache-baseline.js-39-43 (1)
39-43:⚠️ Potential issue | 🟡 Minorsetup에서 응답 구조 접근 시 방어 로직이 부족하다.
body.data.products에 직접 접근하는데, 응답 구조가 예상과 다르거나 products가 빈 배열인 경우lastProduct가 undefined가 되어 이후 테스트에서 fallback 값 100000을 사용하게 된다. 이 fallback이 유효하지 않은 커서일 경우 모든 요청이 실패할 수 있다.🛡️ 방어 로직 강화
if (body.data && body.data.products && body.data.products.length > 0) { const lastProduct = body.data.products[body.data.products.length - 1]; - result[sort] = lastProduct.id; + if (lastProduct && lastProduct.id) { + result[sort] = lastProduct.id; + } } + } else { + console.warn(`Failed to fetch initial cursor for ${sort}: ${res.status}`); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/no-cache-baseline.js` around lines 39 - 43, In the setup block that populates result using response body, harden access to body.data.products: check that body and body.data exist, that Array.isArray(body.data.products) and it has length > 0, and that the computed lastProduct is defined and has an id before assigning result[sort] = lastProduct.id; if any check fails, do not assign the fallback 100000 as a valid cursor—either leave result[sort] undefined/null or set a safe sentinel and log a warning so downstream code can handle missing/invalid cursors; update the logic around lastProduct, result, and sort accordingly.apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java-74-83 (1)
74-83:⚠️ Potential issue | 🟡 Minor트랜잭션 컨텍스트 부재 시 registerEvictAfterCommit 호출 방어 필요
TransactionSynchronizationManager.registerSynchronization()은 활성 트랜잭션이 없으면IllegalStateException을 발생시킨다. 호출 측에서 반드시@Transactional내부에서 호출해야 하나, 방어적 코드가 없으면 운영 중 예기치 않은 장애로 이어질 수 있다.방어 코드 추가 제안
public void registerEvictAfterCommit(Long userId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + evictOrderList(userId); + return; + } TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { `@Override` public void afterCommit() { evictOrderList(userId); } } ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java` around lines 74 - 83, Wrap the call to TransactionSynchronizationManager.registerSynchronization in a defensive check inside OrderCacheManager.registerEvictAfterCommit: first call TransactionSynchronizationManager.isSynchronizationActive() (or isActualTransactionActive()) and only register the TransactionSynchronization that calls evictOrderList(userId) if true; if no active transaction, avoid calling registerSynchronization and instead call evictOrderList(userId) directly (or log and schedule a safe async eviction) so registerSynchronization is never invoked when no transaction is present.apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-53-53 (1)
53-53:⚠️ Potential issue | 🟡 MinorregisterListOnlyDoubleDelete() 메서드를 명시적으로 사용하도록 변경하라
createProduct에서registerDelayedDoubleDelete(null)을 호출하는데, 이는 목록 캐시만 무효화하려는 의도이다. 구현부를 보면 null 처리가 올바르게 되어 있으나, BrandAdminFacade의changeBrandStatus에서는 동일한 목적으로registerListOnlyDoubleDelete()를 사용하고 있다.null 전달은 의도를 명시하지 못하므로 일관성 있게 전용 메서드를 사용하라:
productCacheManager.registerListOnlyDoubleDelete();🤖 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` at line 53, Change the call in ProductAdminFacade#createProduct from productCacheManager.registerDelayedDoubleDelete(null) to the explicit productCacheManager.registerListOnlyDoubleDelete() so the intent to invalidate only list caches is clear and consistent with BrandAdminFacade; update the invocation in the createProduct method to use registerListOnlyDoubleDelete and remove the null argument usage of registerDelayedDoubleDelete.apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java-76-80 (1)
76-80:⚠️ Potential issue | 🟡 Minor
endAt경계가 배타적으로 바뀌었다.같은 리포지토리의 기존 조회 경로는
Between기반인데 여기만createdAt.lt(endAt)라서endAt과 정확히 같은 시각의 주문이 빠진다. 날짜 경계 누락은 고객 문의 시 재현이 어려워 운영 부담이 크다.loe(endAt)로 맞추거나, 배타 경계가 의도라면 컨트롤러/스펙/테스트를 함께 맞춰 의미를 고정해야 한다.createdAt == endAt인 주문 포함 여부를 검증하는 저장소 테스트도 추가하는 편이 안전하다.수정 예시
- order.createdAt.lt(endAt), + order.createdAt.loe(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/infrastructure/order/OrderRepositoryImpl.java` around lines 76 - 80, OrderRepositoryImpl에서 쿼리 조건에 order.createdAt.lt(endAt)로 되어 있어 endAt과 정확히 같은 시각의 주문이 누락됩니다; order.createdAt.lt(endAt) 를 order.createdAt.loe(endAt)로 변경하여 기존 Between 경계와 일치시키거나(권장) 만약 배타 경계가 의도라면 컨트롤러/스펙/테스트를 함께 수정해 의미를 고정하세요; 변경 대상은 OrderRepositoryImpl의 where 절과 연관된 order, createdAt, orderCursorCondition 참조 코드이며, 변경 후 createdAt == endAt인 주문이 포함되는지 검증하는 저장소 단위 테스트도 추가하십시오.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ff04a4e9-4797-41b0-83a0-29e85d0422ba
📒 Files selected for processing (51)
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.javaapps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.javaapps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.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/product/ProductCursor.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/infrastructure/order/OrderJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.javaapps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.javadocker/infra-compose.ymlhttp/commerce-api/order.httphttp/commerce-api/product.httpk6/cache-protection.jsk6/concurrent-read-write.jsk6/no-cache-baseline.jsk6/product-detail.jsk6/product-list.jsk6/product-load.js
💤 Files with no reviewable changes (2)
- apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java
- apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java
Outdated
Show resolved
Hide resolved
| export default function (data) { | ||
| const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; | ||
| // 2페이지 요청 → cursor 파라미터 포함 → 캐시 우회 (첫 페이지만 캐싱) | ||
| const lastId = data[sort] || 100000; | ||
| const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20&cursorId=${lastId}`; | ||
| const res = http.get(url, { tags: { name: 'list_no_cache' } }); | ||
|
|
||
| check(res, { | ||
| '200 OK': (r) => r.status === 200, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 실제 API에서 커서 파라미터를 어떻게 처리하는지 확인
rg -n "cursorId|cursor" --type=java -A5 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3910
🏁 Script executed:
#!/bin/bash
# k6 테스트 파일에서 커서 파라미터를 어떻게 사용하는지 확인
cat -n k6/no-cache-baseline.js | head -60Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2113
🏁 Script executed:
#!/bin/bash
# CursorEncoder 구현 확인
rg -n "class CursorEncoder|encode|decode" --type=java -A10 | grep -A10 "CursorEncoder"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 9194
🏁 Script executed:
#!/bin/bash
# API 응답 구조 확인 - ProductResponse 클래스에서 nextCursor 또는 cursor 필드 찾기
rg -n "class ProductResponse|nextCursor|PagingInfo" --type=java -A15 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7435
API 커서 파라미터 형식을 정확히 반영해야 한다.
현재 코드는 세 가지 문제가 있다:
-
파라미터 이름 오류:
cursorId=${lastId}를 사용하나, API는cursor파라미터를 기대한다. (ProductController.java line 35) -
파라미터 값 형식 오류: 순수 ID 값을 전달하나, API는 CursorEncoder로 Base64 인코딩된 JSON 형식을 요구한다. (CursorEncoder.java line 29, 37) 이 형식의 커서를 디코딩하면
{"sort": "...", "id": ..., "sv": "..."}형태의 데이터가 추출되어야 한다. -
setup()에서 추출 값 오류:
lastProduct.id만 추출하나, API 응답에서 제공하는body.data.paging.nextCursor를 사용해야 한다. (ProductResponse.java line 58)
현재 상태에서 테스트는 실패한다. CursorEncoder.decode()가 순수 ID를 디코딩하려 할 때 "유효하지 않은 커서입니다" 예외를 발생시킨다. setup()에서 응답의 nextCursor를 추출하고, 요청 URL에 cursor 파라미터명으로 정확히 전달하도록 수정해야 한다.
수정 방안
export function setup() {
const result = {};
for (const sort of SORT_TYPES) {
const res = http.get(`${BASE_URL}/api/v1/products?sort=${sort}&size=20`);
if (res.status === 200) {
const body = JSON.parse(res.body);
if (body.data && body.data.paging && body.data.paging.nextCursor) {
result[sort] = body.data.paging.nextCursor; // nextCursor 추출
}
}
}
return result;
}
export default function (data) {
const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)];
const cursor = data[sort];
// cursor가 null인 경우 첫 페이지 요청으로 처리하거나 테스트 스킵
const url = cursor
? `${BASE_URL}/api/v1/products?sort=${sort}&size=20&cursor=${cursor}` // cursor 파라미터 사용
: `${BASE_URL}/api/v1/products?sort=${sort}&size=20`;
const res = http.get(url, { tags: { name: 'list_no_cache' } });
// ...
}수정 후에는 실제 커서 기반 pagination이 정상 작동하는지, 각 정렬 타입별로 올바른 커서가 전달되는지 추가 테스트가 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@k6/no-cache-baseline.js` around lines 49 - 58, The test fails because the
script sends a raw ID as cursor and uses the wrong parameter name; update
setup() to parse the service response and store body.data.paging.nextCursor for
each SORT_TYPES entry (instead of lastProduct.id), then change the default
exported function to read that stored cursor (e.g., data[sort]) and send it
using the query parameter name cursor (not cursorId); ensure the request falls
back to the first-page URL when no cursor is available so CursorEncoder.decode
is never fed a plain numeric ID.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java`:
- Around line 57-58: The generated order times are inconsistent with status
values in OrderSeeder: adjust the time generation logic so it enforces
status-specific constraints — for PENDING (detected via generateOrderStatus())
set orderedAt to within the last 30 minutes (or otherwise ensure expiresAt =
orderedAt.plusMinutes(30) yields expiresAt > now); for CANCELED set orderedAt to
a past time and set canceledAt to a timestamp between orderedAt and now (i.e.,
canceledAt = orderedAt.plusSeconds(randomWithin(0, now - orderedAt))); only
compute expiresAt for statuses that logically require it (e.g., PENDING) and
ensure it is > now; finally add seed test assertions that PENDING records have
expires_at > now and CANCELED records satisfy ordered_at <= canceled_at <= now
to prevent regressions.
- Around line 128-168: The current flushOrdersAndItems flow is non-atomic and
uses a fragile MAX(id) window to map new orders to items; change
flushOrdersAndItems to run the whole "flushOrders -> lookup inserted orders ->
flushOrderItems" sequence inside a single transaction (use `@Transactional` on
flushOrdersAndItems or programmatic TransactionTemplate) and stop using
lastIdBefore/MAX(id); instead query the just-inserted rows by their batch-unique
key (orderNumber) produced in the batch (use orderNumber to fetch orders and
their IDs after flushOrders returns), verify the returned newOrder list size
equals itemsPerOrder.size() and throw an exception if it does not, then map
itemsPerOrder to the fetched order IDs deterministically and call
flushOrderItems; keep references to flushOrders, flushOrderItems, itemsPerOrder,
and orderNumber to locate the changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 44733181-bc3e-471b-a821-59d388e13f0a
📒 Files selected for processing (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java
| String status = generateOrderStatus(); | ||
| ZonedDateTime orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24)); |
There was a problem hiding this comment.
상태와 시간 필드의 정합성이 깨진다.
Line 58에서 orderedAt을 최근 180일 범위로 뽑고 Line 86에서 expiresAt = orderedAt.plusMinutes(30)을 넣으면, PENDING 주문 대부분이 생성 시점부터 이미 만료된 상태가 된다. 반대로 Line 85의 orderedAt.plusDays(1)은 CANCELED 주문에 미래 시각의 canceled_at을 만들 수 있다. 이런 데이터는 상태 기반 목록 조회, 캐시 검증, 통계 테스트를 왜곡한다.
상태별로 시간 제약을 분리하는 편이 안전하다. PENDING은 orderedAt을 최근 30분 이내로 제한하거나 expiresAt > now를 보장하고, CANCELED는 orderedAt <= canceledAt <= now를 만족하도록 생성해야 한다.
수정 예시
- String status = generateOrderStatus();
- ZonedDateTime orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24));
+ String status = generateOrderStatus();
+ ZonedDateTime orderedAt;
+ ZonedDateTime canceledAt = null;
+ ZonedDateTime expiresAt = null;
+
+ if ("PENDING".equals(status)) {
+ orderedAt = now.minusMinutes(random.nextInt(30));
+ expiresAt = orderedAt.plusMinutes(30);
+ } else {
+ orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24));
+ if ("CANCELED".equals(status)) {
+ canceledAt = orderedAt.plusHours(random.nextInt(24) + 1);
+ if (canceledAt.isAfter(now)) {
+ canceledAt = now;
+ }
+ }
+ }
...
- ZonedDateTime canceledAt = "CANCELED".equals(status) ? orderedAt.plusDays(1) : null;
- ZonedDateTime expiresAt = "PENDING".equals(status) ? orderedAt.plusMinutes(30) : null;추가로 시더 테스트에서 PENDING -> expires_at > now, CANCELED -> ordered_at <= canceled_at <= now를 검증하는 케이스를 넣는 편이 좋다.
Also applies to: 85-86
🤖 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/seeder/OrderSeeder.java`
around lines 57 - 58, The generated order times are inconsistent with status
values in OrderSeeder: adjust the time generation logic so it enforces
status-specific constraints — for PENDING (detected via generateOrderStatus())
set orderedAt to within the last 30 minutes (or otherwise ensure expiresAt =
orderedAt.plusMinutes(30) yields expiresAt > now); for CANCELED set orderedAt to
a past time and set canceledAt to a timestamp between orderedAt and now (i.e.,
canceledAt = orderedAt.plusSeconds(randomWithin(0, now - orderedAt))); only
compute expiresAt for statuses that logically require it (e.g., PENDING) and
ensure it is > now; finally add seed test assertions that PENDING records have
expires_at > now and CANCELED records satisfy ordered_at <= canceled_at <= now
to prevent regressions.
| private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) { | ||
| // 주문 INSERT 전 마지막 ID 확인 | ||
| Long lastIdBefore = jdbcTemplate.queryForObject( | ||
| "SELECT COALESCE(MAX(id), 0) FROM orders", Long.class | ||
| ); | ||
|
|
||
| // 주문 INSERT | ||
| flushOrders(orderBatch); | ||
|
|
||
| // 방금 삽입된 주문들의 ID 조회 | ||
| List<Long> newOrderIds = jdbcTemplate.queryForList( | ||
| "SELECT id FROM orders WHERE id > ? ORDER BY id", | ||
| Long.class, lastIdBefore | ||
| ); | ||
|
|
||
| // 주문상품에 order_id 매핑 후 INSERT | ||
| List<Object[]> itemBatch = new ArrayList<>(5_000); | ||
| for (int i = 0; i < newOrderIds.size(); i++) { | ||
| Long orderId = newOrderIds.get(i); | ||
| List<Object[]> items = itemsPerOrder.get(i); | ||
|
|
||
| for (Object[] item : items) { | ||
| itemBatch.add(new Object[]{ | ||
| orderId, | ||
| item[0], // productId | ||
| item[1], // productName | ||
| item[2], // brandName | ||
| item[3], // unitPrice | ||
| item[4] // quantity | ||
| }); | ||
|
|
||
| if (itemBatch.size() >= 5_000) { | ||
| flushOrderItems(itemBatch); | ||
| itemBatch.clear(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!itemBatch.isEmpty()) { | ||
| flushOrderItems(itemBatch); | ||
| } |
There was a problem hiding this comment.
주문/주문상품 배치가 원자적이지 않고 ID 범위 매핑도 취약하다.
Line 130의 MAX(id) 조회, Line 135의 주문 insert, Line 138의 id 재조회, Line 160/167의 주문상품 insert가 각각 별도 JDBC 호출로 끝난다. 그래서 flushOrderItems() 중간에 예외가 나면 주문만 저장되고 order_items가 비는 배치가 남는다. 여기에 Line 138의 id > lastIdBefore 방식은 다른 세션의 주문 insert가 끼어들면 newOrderIds에 외부 행이 섞일 수 있어 itemsPerOrder와 매핑이 어긋난다. 이 상태는 DataSeeder가 products 존재 여부만 보고 전체 시딩을 건너뛰는 구조와 만나면, 재기동으로도 자동 복구되지 않는다.
한 배치의 주문 insert / id 해석 / order_items insert를 하나의 트랜잭션으로 묶고, id 범위 대신 배치 내부의 고유 키인 orderNumber로 다시 조회해 매핑하는 편이 안전하다. 또한 조회된 주문 수와 itemsPerOrder.size()가 다르면 즉시 실패하도록 방어 코드를 두는 편이 좋다.
수정 예시
- private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) {
- Long lastIdBefore = jdbcTemplate.queryForObject(
- "SELECT COALESCE(MAX(id), 0) FROM orders", Long.class
- );
-
- flushOrders(orderBatch);
-
- List<Long> newOrderIds = jdbcTemplate.queryForList(
- "SELECT id FROM orders WHERE id > ? ORDER BY id",
- Long.class, lastIdBefore
- );
+ private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) {
+ transactionTemplate.executeWithoutResult(status -> {
+ flushOrders(orderBatch);
+ List<Long> newOrderIds = findOrderIdsByOrderNumbers(orderBatch);
+ if (newOrderIds.size() != itemsPerOrder.size()) {
+ throw new IllegalStateException("Inserted orders and buffered items are out of sync");
+ }
- List<Object[]> itemBatch = new ArrayList<>(5_000);
- for (int i = 0; i < newOrderIds.size(); i++) {
- Long orderId = newOrderIds.get(i);
- List<Object[]> items = itemsPerOrder.get(i);
- ...
- }
+ List<Object[]> itemBatch = new ArrayList<>(5_000);
+ for (int i = 0; i < newOrderIds.size(); i++) {
+ Long orderId = newOrderIds.get(i);
+ List<Object[]> items = itemsPerOrder.get(i);
+ ...
+ }
+ });
}추가로 flushOrderItems()에서 예외를 강제로 발생시켜 전체 배치가 rollback되는지, 외부 주문 insert가 동시에 발생해도 주문-상품 매핑이 바뀌지 않는지를 검증하는 테스트를 추가하는 편이 좋다.
🤖 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/seeder/OrderSeeder.java`
around lines 128 - 168, The current flushOrdersAndItems flow is non-atomic and
uses a fragile MAX(id) window to map new orders to items; change
flushOrdersAndItems to run the whole "flushOrders -> lookup inserted orders ->
flushOrderItems" sequence inside a single transaction (use `@Transactional` on
flushOrdersAndItems or programmatic TransactionTemplate) and stop using
lastIdBefore/MAX(id); instead query the just-inserted rows by their batch-unique
key (orderNumber) produced in the batch (use orderNumber to fetch orders and
their IDs after flushOrders returns), verify the returned newOrder list size
equals itemsPerOrder.size() and throw an exception if it does not, then map
itemsPerOrder to the fetched order IDs deterministically and call
flushOrderItems; keep references to flushOrders, flushOrderItems, itemsPerOrder,
and orderNumber to locate the changes.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java (1)
21-23: 현재 인덱스 설계는 커서 predicate 형태와 함께 실행 계획을 고정 검증하는 편이 안전하다.
ProductRepositoryImpl.cursorCondition()은(sortKey < ?) OR (sortKey = ? AND id < ?)형태인데, MySQL이 이를 단일 range scan으로 최적화하지 못하면 Line 21-23 인덱스가 있어도 cursor 이후 페이지에서 leaf scan과 filesort가 늘 수 있다. 운영에서는 첫 페이지보다 후속 페이지 p95가 흔들리는 형태로 드러난다. 가능하면 row constructor 비교로 predicate를 정규화하는 방안까지 검토하고, 최소한EXPLAIN ANALYZE기준으로 정렬 4종 × brand 필터 유무에서key,rows examined,Using filesort를 회귀 테스트에 포함하는 것이 좋다.🤖 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/ProductEntity.java` around lines 21 - 23, The current cursor predicate emitted by ProductRepositoryImpl.cursorCondition() uses "(sortKey < ?) OR (sortKey = ? AND id < ?)" which can prevent MySQL from performing a single range scan even though ProductEntity defines indexes idx_products_latest, idx_products_price and idx_products_likes; change the predicate generation to use a row-constructor comparison like "(sortKey, id) < (?, ?)" (or the equivalent normalized SQL) so it can map to the composite index range scan, ensure the composite index column order and nullability in ProductEntity matches the sort + id ordering, and add regression tests that run EXPLAIN ANALYZE for each of the four sort orders × brand-filter on the repository methods asserting the chosen key, rows_examined and absence of "Using filesort" to detect regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java`:
- Around line 17-19: The `@Index` on UserAddressEntity
(idx_user_addresses_user_created) only updates Hibernate metadata and doesn't
guarantee runtime creation; add an explicit migration that runs "CREATE INDEX
idx_user_addresses_user_created ON user_addresses (user_id, created_at);" in
your DB migration tool (e.g., Flyway/Liquibase) and include a rollback DROP
INDEX as appropriate, then add a CI/integration check that queries
information_schema.statistics and runs EXPLAIN on the target queries to ensure
the index is present and used; apply the same pattern for OrderEntity,
IssuedCouponEntity, and ProductEntity indexes referenced in the review so schema
changes are managed by migrations rather than relying solely on `@Index`.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 20-25: ProductEntity's `@Index` annotations are only metadata and
won't change the production schema (ddl-auto: none), so add a Flyway migration
that creates the new composite indexes shown in the entity (e.g., match the
columns for idx_products_latest, idx_products_price, idx_products_likes,
idx_products_brand) and drops the old indexes to realize the 6→4 reduction;
after deploying, run SHOW INDEX FROM products to confirm the physical indexes
and run EXPLAIN on the key queries (LATEST, PRICE_ASC, LIKES_DESC) that use the
cursor predicate (createdAt/id OR-style predicate) to ensure the new composite
indexes are being used—if EXPLAIN shows full scans, consider adjusting index
column order or rewriting the cursor predicate to a range-friendly form so the
composite index (created_at DESC, id DESC, status, deleted_at) can be used
efficiently.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 21-23: The current cursor predicate emitted by
ProductRepositoryImpl.cursorCondition() uses "(sortKey < ?) OR (sortKey = ? AND
id < ?)" which can prevent MySQL from performing a single range scan even though
ProductEntity defines indexes idx_products_latest, idx_products_price and
idx_products_likes; change the predicate generation to use a row-constructor
comparison like "(sortKey, id) < (?, ?)" (or the equivalent normalized SQL) so
it can map to the composite index range scan, ensure the composite index column
order and nullability in ProductEntity matches the sort + id ordering, and add
regression tests that run EXPLAIN ANALYZE for each of the four sort orders ×
brand-filter on the repository methods asserting the chosen key, rows_examined
and absence of "Using filesort" to detect regressions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 02b3e43a-4c70-4819-a2bd-de28735ac7f7
📒 Files selected for processing (4)
apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java
| @Table(name = "user_addresses", indexes = { | ||
| @Index(name = "idx_user_addresses_user_created", columnList = "user_id, created_at") | ||
| }) |
There was a problem hiding this comment.
운영 스키마에 실제 인덱스 생성이 보장되지 않는다
이 변경은 엔티티 메타데이터만 추가하므로, 운영에서 Hibernate DDL 생성을 사용하지 않으면 idx_user_addresses_user_created는 실제로 생성되지 않는다. 그러면 배포 후에도 user_addresses 조회가 풀스캔으로 남아 성능 개선이 적용되지 않고, 로컬/테스트와 운영의 실행 계획이 달라져 장애 분석도 어려워진다.
수정안으로는 @Index를 문서화 용도로 유지하더라도, 배포용 마이그레이션에 CREATE INDEX idx_user_addresses_user_created ON user_addresses (user_id, created_at);를 명시해 스키마 버전으로 관리해야 한다. 추가 테스트로는 MySQL 통합 테스트나 배포 전 검증 단계에서 information_schema.statistics로 인덱스 존재를 확인하고, 해당 조회 쿼리에 EXPLAIN을 걸어 실제로 이 인덱스를 타는지 검증하는 편이 안전하다. 같은 패턴이 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java:25-28, apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java:20-23, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java:19-25에도 보이므로 함께 정리하는 편이 좋다.
🤖 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/address/UserAddressEntity.java`
around lines 17 - 19, The `@Index` on UserAddressEntity
(idx_user_addresses_user_created) only updates Hibernate metadata and doesn't
guarantee runtime creation; add an explicit migration that runs "CREATE INDEX
idx_user_addresses_user_created ON user_addresses (user_id, created_at);" in
your DB migration tool (e.g., Flyway/Liquibase) and include a rollback DROP
INDEX as appropriate, then add a CI/integration check that queries
information_schema.statistics and runs EXPLAIN on the target queries to ensure
the index is present and used; apply the same pattern for OrderEntity,
IssuedCouponEntity, and ProductEntity indexes referenced in the review so schema
changes are managed by migrations rather than relying solely on `@Index`.
| @Table(name = "products", indexes = { | ||
| @Index(name = "idx_products_latest", columnList = "created_at DESC, id DESC, status, deleted_at"), | ||
| @Index(name = "idx_products_price", columnList = "base_price ASC, id ASC, status, deleted_at"), | ||
| @Index(name = "idx_products_likes", columnList = "like_count DESC, id DESC, status, deleted_at"), | ||
| @Index(name = "idx_products_brand", columnList = "brand_id, status, deleted_at") | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== schema management config =="
rg -n --glob '*.{yml,yaml,properties}' 'ddl-auto|hibernate\.hbm2ddl|flyway|liquibase'
echo
echo "== product index migrations =="
rg -n --glob '*.{sql,java,kt,xml,yml,yaml}' 'idx_products_|create index|drop index|products'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 13653
🏁 Script executed:
find . -type d \( -name db -o -name migration* -o -name flyway -o -name liquibase \) 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
fd -e sql | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | head -150Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6278
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | tail -n +150Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3972
@Index 메타데이터만으로는 운영 DB의 인덱스가 실제로 변경되지 않는다.
운영 환경이 ddl-auto: none 설정이므로 Line 20-25의 @Index 어노테이션은 엔티티 메타데이터일 뿐 스키마 변경을 트리거하지 않는다. Flyway/Liquibase 마이그레이션 파일이 없어서 기존 인덱스는 그대로 유지되며, PR에서 설명한 6→4 축소 효과는 배포 후 반영되지 않는다. 결과적으로 쓰기 오버헤드와 메모리 점유가 계속된다.
추가로, 커서 조건이 (createdAt < cursor) OR (createdAt = cursor AND id < cursor) 형태의 OR 기반 술어식을 사용하는데, 복합 인덱스 (created_at DESC, id DESC, status, deleted_at)는 이 OR 조건을 효율적으로 처리하지 못한다. MySQL 옵티마이저가 인덱스 레인지 스캔 대신 필터링을 통한 전체 스캔을 선택할 가능성이 높다.
조치:
- 신규 인덱스 생성과 구 인덱스 삭제를 명시한 Flyway 마이그레이션 파일 추가
- 배포 후
SHOW INDEX FROM products로 최종 인덱스 구조 검증 - 주요 쿼리(
LATEST,PRICE_ASC,LIKES_DESC)별EXPLAIN실행 계획 확인하여 커서 조건이 인덱스를 실제로 활용하는지 검증
🤖 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/ProductEntity.java`
around lines 20 - 25, ProductEntity's `@Index` annotations are only metadata and
won't change the production schema (ddl-auto: none), so add a Flyway migration
that creates the new composite indexes shown in the entity (e.g., match the
columns for idx_products_latest, idx_products_price, idx_products_likes,
idx_products_brand) and drops the old indexes to realize the 6→4 reduction;
after deploying, run SHOW INDEX FROM products to confirm the physical indexes
and run EXPLAIN on the key queries (LATEST, PRICE_ASC, LIKES_DESC) that use the
cursor predicate (createdAt/id OR-style predicate) to ensure the new composite
indexes are being used—if EXPLAIN shows full scans, consider adjusting index
column order or rewriting the cursor predicate to a range-friendly form so the
composite index (created_at DESC, id DESC, status, deleted_at) can be used
efficiently.
There was a problem hiding this comment.
🧹 Nitpick comments (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java (3)
39-60: 배치 청킹(chunking) 미적용 - 다른 seeder와 불일치
OrderSeeder는BATCH_SIZE = 2_000으로 청킹하여 메모리 사용량을 제어한다.UserSeeder는 5,000건을 단일 배치로 처리하며, 현재 규모에서는 문제없으나USER_COUNT증가 시 JDBC 드라이버의 배치 버퍼 메모리가 급증할 수 있다. 일관된 패턴 적용을 권장한다.추가 테스트:
USER_COUNT를 50,000 이상으로 늘려 메모리 사용량과 GC 영향을 프로파일링해볼 것.🤖 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/seeder/UserSeeder.java` around lines 39 - 60, UserSeeder currently calls jdbcTemplate.batchUpdate with USER_COUNT all at once, risking large JDBC batch memory usage; modify UserSeeder to chunk inserts into batches (reuse the existing BatchPreparedStatementSetter logic) by iterating over USER_COUNT in windows of a configurable BATCH_SIZE (match OrderSeeder's BATCH_SIZE = 2_000 or introduce the same constant), invoking jdbcTemplate.batchUpdate for each chunk and passing an adjusted BatchPreparedStatementSetter that maps the chunk index to the actual user index; ensure getBatchSize() returns the current chunk size and use USER_COUNT and the new BATCH_SIZE to control the loop.
37-37:BCryptPasswordEncoder를 static final 필드로 추출 권장
seed()호출 시마다BCryptPasswordEncoder인스턴스를 생성한다. 현재는seed()가 한 번만 호출되므로 성능 영향은 미미하나, encoder는 thread-safe하고 재사용 가능하므로 static 필드로 추출하면 다른 seeder 패턴과 일관성이 높아진다.♻️ 수정안
+private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + void seed() { log.info("[UserSeeder] {}명 생성 시작", USER_COUNT); long start = System.currentTimeMillis(); ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); - String encodedPassword = new BCryptPasswordEncoder().encode(SEEDING_RAW_PASSWORD); + String encodedPassword = PASSWORD_ENCODER.encode(SEEDING_RAW_PASSWORD);🤖 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/seeder/UserSeeder.java` at line 37, Extract the BCryptPasswordEncoder instantiation into a reusable static final field on UserSeeder (e.g., private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder()) and replace the inline new BCryptPasswordEncoder() call in seed() where encodedPassword is computed so the method uses ENCODER.encode(SEEDING_RAW_PASSWORD); keep field name ENCODER and update any imports if necessary.
20-21:USER_COUNT와SEEDING_RAW_PASSWORD상수의 접근 제어 검토 필요
USER_COUNT는OrderSeeder,PointAccountSeeder등에서 참조하므로 package-private이 적절하다. 그러나SEEDING_RAW_PASSWORD는 현재 이 클래스 내부에서만 사용되므로private으로 변경하여 노출 범위를 최소화하는 것이 안전하다. 운영 환경에서 seeder가 포함된 빌드가 배포될 경우, 패키지 내 다른 클래스에서 원문 비밀번호에 접근할 수 있다.🔒 수정안
static final int USER_COUNT = 5_000; -static final String SEEDING_RAW_PASSWORD = "Hx7!mK2@"; +private static final String SEEDING_RAW_PASSWORD = "Hx7!mK2@";🤖 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/seeder/UserSeeder.java` around lines 20 - 21, Keep USER_COUNT package-private (no access modifier) so other seeders like OrderSeeder and PointAccountSeeder can reference it, and change SEEDING_RAW_PASSWORD to private (private static final) to limit its visibility to UserSeeder only; locate the declarations of USER_COUNT and SEEDING_RAW_PASSWORD in UserSeeder and adjust the access modifier for SEEDING_RAW_PASSWORD while leaving USER_COUNT unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java`:
- Around line 39-60: UserSeeder currently calls jdbcTemplate.batchUpdate with
USER_COUNT all at once, risking large JDBC batch memory usage; modify UserSeeder
to chunk inserts into batches (reuse the existing BatchPreparedStatementSetter
logic) by iterating over USER_COUNT in windows of a configurable BATCH_SIZE
(match OrderSeeder's BATCH_SIZE = 2_000 or introduce the same constant),
invoking jdbcTemplate.batchUpdate for each chunk and passing an adjusted
BatchPreparedStatementSetter that maps the chunk index to the actual user index;
ensure getBatchSize() returns the current chunk size and use USER_COUNT and the
new BATCH_SIZE to control the loop.
- Line 37: Extract the BCryptPasswordEncoder instantiation into a reusable
static final field on UserSeeder (e.g., private static final
BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder()) and replace the
inline new BCryptPasswordEncoder() call in seed() where encodedPassword is
computed so the method uses ENCODER.encode(SEEDING_RAW_PASSWORD); keep field
name ENCODER and update any imports if necessary.
- Around line 20-21: Keep USER_COUNT package-private (no access modifier) so
other seeders like OrderSeeder and PointAccountSeeder can reference it, and
change SEEDING_RAW_PASSWORD to private (private static final) to limit its
visibility to UserSeeder only; locate the declarations of USER_COUNT and
SEEDING_RAW_PASSWORD in UserSeeder and adjust the access modifier for
SEEDING_RAW_PASSWORD while leaving USER_COUNT unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: cb13bf4e-08ac-4937-8bf2-270bae244813
📒 Files selected for processing (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java
📌 Summary
🧭 Context & Decision
전체 API 분석 현황 — 왜 이 2개를 골랐는가
10개 조회 API에 EXPLAIN을 찍어서 심각도를 분류했다. **기준은 type(스캔 방식), rows(스캔 행 수), filtered(통과율)**이다.
GET /products)GET /orders)GET /coupons)GET /carts)GET /likes)GET /addresses)GET /brand-likes)GET /brands)GET /products/{id})GET /points)선정 기준: type=ALL + rows > 5만 + filtered < 10%를 "심각"으로 분류했다. 이 조합은 대량의 행을 풀스캔하면서 90% 이상을 버린다는 뜻이다.
문제 정의
EXPLAIN을 찍어보니 상품/주문 모두 풀 테이블 스캔이었다.
상품 목록 (
GET /api/v1/products)19만건 풀스캔 + filesort. filtered=5%이므로 95%의 행을 읽고 버린다. Brand 테이블 JOIN은 eq_ref로 빠르지만, products 쪽이 병목.
주문 목록 (
GET /api/v1/orders)9.8만건 풀스캔. filtered=1.11%로 98.89%를 읽고 버린다. user_id에 인덱스가 없다. Hibernate는
@ManyToOneFK에만 자동 인덱스를 만들고, ID 참조(Long 필드)에는 만들지 않기 때문이다. 추가로 FETCH JOIN으로 items를 전부 로딩하는데, 목록 응답(OrderSummaryResult)은 items를 한 번도 안 쓴다.선택지와 결정
1. 쿼리 최적화: Brand JOIN을 어떻게 제거할 것인가?
brand_id IN (SELECT ...)2. 상품 인덱스: 정렬 선두 vs WHERE 선두
(created_at DESC, id DESC, status, deleted_at)(status, deleted_at, created_at DESC)status IN ('ACTIVE','SOLDOUT')통과율 85%,deleted_at IS NULL통과율 95% → 합산 ~80%. 통과율이 높으면 LIMIT 20 채우기 위해 ~25개만 읽으면 된다. WHERE 선두(B)로 하면IN(multiple equality)이 다음 컬럼의 정렬을 깨뜨려서 filesort가 발생한다.3. 주문 인덱스: 왜 1개로 충분한가
IN이 정렬을 깨뜨림 + 4종 동적 정렬 + brandId 분기created_at DESC고정.(user_id, created_at)1개4. 페이지네이션: 고객 Cursor vs 어드민 OFFSET
5. 캐시 무효화: TTL만? Delayed Double Delete?
6. 주문 목록 캐싱: 할 것인가, 말 것인가?
startAt/endAt의now()기반 키를 쓰면 매 초마다 키가 달라져서 히트율이 0이 되는 문제를 발견. 캐시 키를orders:list:{userId}로 단순화하고, 첫 페이지(cursor=null) + 커스텀 기간 없음 조건만 캐싱하여 해결.🏗️ Design Overview
변경 범위
인덱스 전략
idx_products_latest(created_at DESC, id DESC, status, deleted_at)idx_products_price(base_price ASC, id ASC, status, deleted_at)idx_products_likes(like_count DESC, id DESC, status, deleted_at)idx_products_brand(brand_id, status, deleted_at)idx_orders_user_created(user_id, created_at)인덱스 개수를 6개 → 4개로 줄인 판단: brandId 필터 시 최대 ~6,000건인데, 이 규모의 filesort는 sub-ms 수준이다. brand 전용 정렬 인덱스 3개를 유지하면 INSERT마다 6개 인덱스를 갱신해야 하므로, 쓰기 비용 대비 읽기 이득이 적다.
캐시 키 전략
products:detail:{productId}products:list:{sort}:{brandId|all}orders:list:{userId}키 설계 시 고려한 점:
all로 표기. 2페이지 이후는 cursor 값이 유저마다 달라서 히트율이 0이므로 캐싱하지 않는다.orders:list:{userId}:{startAt}:{endAt}로 설계했는데, startAt/endAt이now()기반이라 매 초마다 키가 달라져서 히트율이 0이 됐다.orders:list:{userId}로 단순화하고 기본 조회 조건만 캐싱하여 해결.KEYS/SCAN패턴 매칭 대신 키를 직접 열거.4개 정렬 x (all + 활성 브랜드 수)조합을Collection<String>으로 모아 일괄 DELETE. 현재 약 1,600개 키.주요 컴포넌트 책임
1. 상품 목록 — Brand JOIN 제거 + 리터럴 IN
ProductRepositoryImpl.java2. 상품 인덱스 (4개)
3. 주문 목록 — 인덱스 + FETCH JOIN 분리 + Cursor
주문 인덱스 (1개):
FETCH JOIN 분리 — 목록/상세 조회 경로를 나눔:
OrderRepositoryImpl.java2단계 쿼리로 분리한 이유는 FETCH JOIN + LIMIT/OFFSET을 함께 쓰면 Hibernate가 전체 데이터를 메모리에 올린 뒤 애플리케이션에서 페이지네이션하기 때문이다(
HHH000104경고).주문 EXPLAIN 기대 효과:
4. Cursor 페이지네이션 — CursorResult, cursorCondition
CursorResult.javaProductRepositoryImpl.java— 커서 조건:5. 상품 캐시 — Delayed Double Delete
ProductCacheManager.java캐시 무효화 시나리오:
registerDelayedDoubleDelete(productId)registerDelayedDoubleDelete(null)registerBrandDeleteDoubleDelete(productIds)registerListOnlyDoubleDelete()목록 캐시의 "전체 삭제"에서
KEYS/SCAN패턴 매칭 대신 키를 직접 열거하는 방식을 택했다.4개 정렬 x (all + 활성 브랜드 수)키를Collection<String>으로 모아 일괄 DELETE.6. 상품 Facade — Cache-Aside 통합
ProductFacade.java첫 페이지만 캐싱한다. 2페이지 이후는 cursor 값이 유저마다 달라서 히트율이 0이기 때문이다.
7. 주문 캐시 — afterCommit DELETE
OrderCacheManager.javaOrderFacade.java8. 캐시 정합성과 비즈니스 허용 범위
9. 성능 측정 결과
각 최적화의 독립적 기여를 확인하기 위해 시나리오를 나눠서 순차 적용했다.
단계별 레이턴시 (상품 목록, LATEST 기준):
개선 요인 분해:
정렬별 Cursor 레이턴시:
K6 부하 테스트 (20 VUs, 1분 40초):
10. 캐시 통합 테스트
ProductCacheIntegrationTest.javaOrderCacheIntegrationTest.java11. 데이터 시더
DataSeeder— 14개 테이블,@Profile("local").12. K6 부하 테스트 스크립트
k6/no-cache-baseline.js|k6/cache-protection.js|k6/concurrent-read-write.js🔁 Flow Diagram
상품 목록 조회 — Cache-Aside + Cursor
sequenceDiagram autonumber participant Client participant Controller participant Facade as ProductFacade participant Cache as ProductCacheManager<br/>(Redis) participant Service as ProductService participant DB as MySQL Client->>Controller: GET /products?sort=LATEST&size=20 Controller->>Facade: getDisplayableProductsWithCursor(cursor=null) rect rgb(235, 245, 255) Note over Facade,Cache: 첫 페이지 → 캐시 확인 Facade->>Cache: getProductList(LATEST, null) alt 캐시 HIT Cache-->>Facade: CursorResult (from Redis) Facade-->>Client: 200 OK (캐시 응답) else 캐시 MISS Cache-->>Facade: empty Facade->>Service: getDisplayableProductsWithCursor() Service->>DB: SELECT ... WHERE brand_id IN (...)<br/>AND (sortKey, id) < cursor<br/>ORDER BY ... LIMIT 21 DB-->>Service: 21 rows Service-->>Facade: CursorResult(20 items, hasNext=true) Facade->>Cache: putProductList(LATEST, null, result) Facade-->>Client: 200 OK (DB 응답 + 캐시 저장) end end상품 수정 → Delayed Double Delete
sequenceDiagram autonumber participant Admin participant Facade as ProductAdminFacade participant DB as MySQL participant CacheMgr as ProductCacheManager participant Redis Admin->>Facade: updateProduct(productId, ...) Facade->>DB: UPDATE product SET ... Facade->>CacheMgr: registerDelayedDoubleDelete(productId) Note over CacheMgr: afterCommit 콜백 등록 Facade->>DB: COMMIT rect rgb(255, 235, 235) Note over CacheMgr,Redis: afterCommit 실행 CacheMgr->>Redis: 1차 DELETE (상세 + 목록 전체) Note over CacheMgr: 500ms 대기 CacheMgr->>Redis: 2차 DELETE (상세 + 목록 전체) end✅ 과제 체크리스트
변경 목적
상품 목록(GET /products)·주문 목록(GET /orders)에서 발생하던 풀 테이블 스캔(type=ALL)과 filesort를 해소하고 인덱스·Cursor·Redis 캐시를 단계적으로 적용해 응답시간을 평균 566ms → 65ms(8.7배)로 개선하고 20 VU 부하에서 p95 목표 달성을 노립니다.
핵심 변경점
products/orders 테이블에 복합 인덱스 추가(Product: idx_products_latest/price/likes/brand, Order: idx_orders_user_created), Brand JOIN 제거(활성 brand ID 선조회→IN), 페이지네이션을 OFFSET/COUNT 대신 CursorResult/ProductCursor 기반으로 전환하고 cursorCondition(정렬키+id OR 결합) 적용, Redis Cache-Aside 도입(상품 상세 TTL 300s, 목록 첫페이지만 TTL 60s; 주문 목록 기본조회 첫페이지만 300s) 및 Delayed Double Delete/afterCommit 무효화 전략과 목록 키(정렬×브랜드 약 1,600개)를 열거 삭제하는 무효화 방식 구현.
리스크/주의사항
목록 전체 무효화 시 Redis에서 대량 키 삭제·eviction(운영 비용/성능)과 비동기 무효화 지연으로 인한 일관성 문제 발생 가능성, Delayed Double Delete와 주문의 단순 afterCommit 방식 간 정책 차이로 복잡성 증가, QueryDSL로 작성된 OR/복합 cursorCondition이 실제 MySQL에서 인덱스를 기대대로 활용하는지(특히 OR 사용시 인덱스 스캔이 발생하지 않을 우려) 확인 필요.
테스트/검증 방법
통합 테스트: ProductCacheIntegrationTest·OrderCacheIntegrationTest로 캐시 적중·무효화 시나리오 검증; 부하 테스트: k6 스크립트(k6/product-list, concurrent-read-write 등)로 20 VU 기준 응답시간(p95/p99) 측정; 대규모 검증: 제공된 시더로 200k 상품·100k 주문을 채운 환경에서 Redis(maxmemory=512MB, allkeys-lru) 동작·eviction 영향 관찰 및 인덱스/쿼리 변경 전후 EXPLAIN 비교 권장. 추가 확인이 필요한 항목(확인 질문): Redis 전체 목록 키 삭제(약 1,600개 조합)로 실제 운영에서 DELETE 부담을 감당할 수 있는지, OR 기반 cursorCondition의 실행계획(EXPLAIN) 결과를 제공해 주실 수 있나요?