Conversation
Phase 1: Redis 카운터 버퍼링 - LikeCountCacheService: Redis INCRBY/DECRBY 기반 좋아요 수 관리 - LikeApplicationService: afterCommit 훅으로 Redis 카운터 업데이트 Phase 2: Thundering Herd 방지 - DistributedLockService: Redis SETNX 기반 분산 락 - ProductLikeCountQueryService: Single Flight 패턴 적용 조회 Phase 3: 배치 동기화 - LikeCountSyncJobConfig: Spring Batch Job 정의 - LikeCountSyncTasklet: Redis 카운터 → DB 동기화 기타 - ProductJpaEntity: like_count 컬럼 추가 - DataInitializer: 더미 데이터 생성 (local 프로파일) - DB 마이그레이션 스크립트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- create-pr: PR 생성 명령어 개선 - create-log: 작업 로그 생성 명령어 추가 - analyze-query: 쿼리 분석 스킬 위치 이동 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
📝 WalkthroughWalkthrough좋아요 기능에 Redis 기반 캐싱과 트랜잭션 동기화를 추가하였다. DistributedLockService와 LikeCountCacheService를 신규 구현하고, 상품 목록 API에 좋아요 수를 포함시켰다. 배치 작업으로 캐시-DB 동기화를 수행하며, 관련 테스트와 데이터 마이그레이션 스크립트를 추가하였다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Controller as ProductV1Controller
participant Service as LikeApplicationService
participant Domain as LikeDomainService
participant Repository as LikeRepository
participant TxnMgr as TransactionSynchronizationManager
participant Cache as LikeCountCacheService
User->>Controller: like(userId, productId)
Controller->>Service: like(userId, productId)
Service->>Domain: like(userId, productId)
Domain->>Repository: save(like)
Repository-->>Domain: like saved
Domain-->>Service: success
Service->>TxnMgr: registerAfterCommit(incrementCallback)
TxnMgr-->>Service: callback registered
Note over TxnMgr: Transaction commits...
TxnMgr->>Service: execute incrementCallback
Service->>Cache: increment(productId)
Cache-->>Service: updated count
Service-->>TxnMgr: done
Service-->>Controller: likeCount updated
Controller-->>User: response
sequenceDiagram
actor User
participant Controller as ProductV1Controller
participant QueryService as ProductLikeCountQueryService
participant Cache as LikeCountCacheService
participant Lock as DistributedLockService
participant Repository as LikeRepository
User->>Controller: getProducts(brandId, sort)
Controller->>QueryService: getLikeCounts([productIds])
loop For each productId
QueryService->>Cache: getWithRefreshSignal(productId)
alt Cache Hit
Cache-->>QueryService: (hit=true, value, shouldRefresh=false)
QueryService-->>QueryService: return cached value
else Cache Miss
Cache-->>QueryService: (hit=false, value=null, shouldRefresh=false)
QueryService->>Lock: tryLock(key, ttl)
alt Lock Acquired
Lock-->>QueryService: Optional.of(lockValue)
QueryService->>Cache: double-check cache
QueryService->>Repository: countByProductId(productId)
Repository-->>QueryService: actualCount
QueryService->>Cache: set(productId, actualCount)
QueryService->>Lock: unlock(key, lockValue)
Lock-->>QueryService: true
QueryService-->>QueryService: return actualCount
else Lock Not Acquired
Lock-->>QueryService: Optional.empty()
QueryService->>QueryService: waitAndRetryFromCache(productId)
alt Retry Success
QueryService->>Cache: get(productId)
Cache-->>QueryService: Optional.of(value)
else Retry Exhausted
QueryService->>Repository: countByProductId(productId)
Repository-->>QueryService: actualCount
end
end
end
end
QueryService-->>Controller: Map<productId, likeCount>
Controller-->>User: products with likeCounts
sequenceDiagram
participant Scheduler as Spring Batch Scheduler
participant Job as LikeCountSyncJob
participant Tasklet as LikeCountSyncTasklet
participant Cache as LikeCountBatchCacheService
participant Repository as LikeRepository
participant DB as ProductRepository
participant Redis as Redis
Scheduler->>Job: execute job
Job->>Tasklet: execute(stepContribution, chunkContext)
Tasklet->>Cache: getDirtyProductIds()
Cache->>Redis: SMEMBERS dirty:{version}:like
Redis-->>Cache: Set<productId>
Cache-->>Tasklet: dirtyIds
loop For each productId in dirtyIds
Tasklet->>Repository: countByProductId(productId)
Repository-->>Tasklet: actualCount
Tasklet->>DB: updateLikeCount(productId, actualCount)
DB-->>Tasklet: rows updated
alt Update Success
Tasklet->>Cache: set(productId, actualCount)
Cache->>Redis: SETEX key value
Redis-->>Cache: OK
Tasklet->>Cache: removeDirty(productId)
Cache->>Redis: SREM dirty:{version}:like productId
Redis-->>Cache: OK
else Update Failed
Tasklet-->>Tasklet: log warning, continue
end
end
Tasklet-->>Job: RepeatStatus.FINISHED
Job-->>Scheduler: job completed
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 generate a title for your PR based on the changes.Add |
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/CommerceApiApplication.java-7-11 (1)
7-11:⚠️ Potential issue | 🟠 Major기본 비동기 실행기 사용으로 인한 스레드 폭증 위험을 해결해야 한다
라인 11의
@EnableAsync활성화만 있고 명시적ThreadPoolTaskExecutor설정이 없어서 Spring의 기본SimpleAsyncTaskExecutor를 사용하게 된다. 이 실행기는 스레드 풀이 없어 비동기 요청이 발생할 때마다 새로운 스레드를 무제한으로 생성하므로, 좋아요 버튼 클릭 같은 높은 트래픽 상황에서 스레드 폭증으로 인한 OOM, 지연, 서버 재기동이 발생할 수 있다.
ThreadPoolTaskExecutor를 명시적으로 설정하여 스레드 풀 크기, 큐 용량, 거절 정책을 제한해야 한다.@Async("실행기명")으로 특정 메서드가 사용할 실행기를 명시하는 것이 필수다.수정 예시
`@Configuration` `@EnableAsync` public class AsyncConfig implements AsyncConfigurer { `@Bean`(name = "likeCountAsyncExecutor") public Executor likeCountAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("like-count-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }그 후
ProductLikeCountQueryService의 메서드를 다음과 같이 수정한다.`@Async`("likeCountAsyncExecutor") public void refreshInBackground(Long productId) { // ... }추가 테스트로 다음을 검증해야 한다: (1) 동시 100개 요청 시 active 스레드가 최대치로 제한되는지, (2) 큐 포화 시
CallerRunsPolicy로 인해 호출 스레드에서 직접 실행되는지, (3) 비동기 메서드 내DistributedLockService예외 발생 시 로깅과 복구 처리가 정상인지 확인한다.🤖 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/CommerceApiApplication.java` around lines 7 - 11, The app enables `@EnableAsync` but relies on Spring's unbounded SimpleAsyncTaskExecutor; add a dedicated ThreadPoolTaskExecutor bean (e.g., bean name "likeCountAsyncExecutor") configured with sensible core/max pool sizes, queueCapacity, threadNamePrefix and a RejectedExecutionHandler like CallerRunsPolicy (initialize the executor), and register it in a new AsyncConfig class annotated with `@Configuration` and `@EnableAsync`; then update the asynchronous method (e.g., ProductLikeCountQueryService.refreshInBackground) to use `@Async`("likeCountAsyncExecutor") so async calls use the bounded pool; after changes, add tests to verify max active threads, queue saturation behavior, and exception handling in DistributedLockService inside the async method.scripts/insert-dummy-data.sql-247-264 (1)
247-264:⚠️ Potential issue | 🟠 Major쿠폰 발급 수 불변식이 깨진다.
max_issue_count를 생성한 뒤issued_count를 별도로0..499에서 다시 뽑고 있어서,max_issue_count가 100~499인 행에서는issued_count > max_issue_count가 쉽게 나온다. 운영 관점에서는 발급 한도 초과 상태가 기본 데이터처럼 섞여 들어가서 쿠폰 발급/사용 API와 배치 검증이 왜 실패했는지 구분하기 어려워진다.수정안으로는
max_issue_count를 먼저 지역 변수에 담고, 값이 있을 때는issued_count를0..max_issue_count범위에서만 생성해야 한다. 추가 테스트로는 적재 후max_issue_count IS NOT NULL AND issued_count > max_issue_count조건의 건수가 0인지 검증해야 한다.Also applies to: 272-293
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/insert-dummy-data.sql` around lines 247 - 264, The INSERT currently generates max_issue_count with IF(RAND() < 0.3, NULL, FLOOR(100 + RAND() * 9900)) and separately picks issued_count as FLOOR(RAND() * 500), which can produce issued_count > max_issue_count; change the INSERT construction so max_issue_count is computed first into a local/generated variable and then issued_count is derived conditionally: if max_issue_count IS NULL then pick a standalone random issued_count, otherwise pick issued_count = FLOOR(RAND() * (max_issue_count + 1)) (or similar) to guarantee issued_count <= max_issue_count; apply the same fix to the second INSERT block referenced (lines ~272-293) and add a post-load check that counts rows WHERE max_issue_count IS NOT NULL AND issued_count > max_issue_count equals 0.scripts/insert-dummy-data.sql-10-12 (1)
10-12:⚠️ Potential issue | 🟠 Major비어 있지 않은 DB에서는 관계가 조용히 깨진다.
brand_id를1..5000으로 고정하고,user_id와product_id도 연속 PK를 가정해 뽑고 있다. 여기에 FK 체크까지 꺼져 있어서 기존 데이터가 있거나 ID gap이 있는 DB에서는 신규 상품/좋아요/주문이 기존 행에 붙거나 고아 레코드가 그대로 적재된다. 운영 관점에서는 이 데이터셋으로 수행한 성능·동시성·기능 검증 결과를 신뢰할 수 없게 된다.수정안으로는 실제로 이번 스크립트가 삽입한 사용자/브랜드/상품 ID 집합을 임시 테이블에 저장해 그 집합에서만 참조하도록 바꾸는 편이 안전하다. 이 수준까지 바꾸기 어렵다면 최소한 시작 전에 대상 테이블이 비어 있지 않으면 즉시 중단해야 한다. 추가 테스트로는 선행 데이터가 1건 이상 있는 상태에서 스크립트를 실행한 뒤,
LEFT JOIN으로 고아 FK가 0건인지와 신규 레코드가 의도한 부모 집합만 참조하는지 확인해야 한다.Also applies to: 121-124, 167-170, 190-198, 337-339, 376-382
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/insert-dummy-data.sql` around lines 10 - 12, The script disables FK/unique checks (SET FOREIGN_KEY_CHECKS, SET autocommit, SET unique_checks) which allows silent corruption when the DB already contains rows; change the script to first verify target tables are empty and abort if any have rows, or (preferably) capture the exact IDs the script inserts into temporary tables (e.g., temp_inserted_users, temp_inserted_brands, temp_inserted_products) and use those temp tables as the only valid parent sets for subsequent INSERTs/LIKE/ORDER rows instead of hard-coding id ranges; restore and enforce FK/unique checks and commit only after verification, and add a post-run LEFT JOIN check to assert there are zero orphaned FK references before finishing.apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java-146-163 (1)
146-163:⚠️ Potential issue | 🟠 Major좋아요 seed가 like_count 동기화 경로를 완전히 우회한다.
여기서는
likesrow만 저장하고products.like_count, Redis count, dirty set을 전혀 갱신하지 않아서 배치 sync도 이 데이터를 따라잡지 못할 가능성이 크다. 시드 종료 후products.like_count를 한 번 backfill하거나, 초기화도 실제 애플리케이션 경로를 통해 캐시/dirty set을 함께 갱신하도록 바꿔 달라. 추가로 초기화 직후 상품 상세/목록 API에서 seed된 인기 상품의likeCount가 0이 아닌지 검증하는 테스트를 넣어 달라.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java` around lines 146 - 163, The createLikes method currently writes only Like rows (via Like.create and likeRepository.save) and does not update products.like_count, the Redis counters, or the dirty set, which breaks sync; modify DataInitializer.createLikes to either call the application's like creation flow (e.g., the service method that handles likes so it updates DB, increments Redis counters and marks the product in the dirty set) or, if doing a direct seed, perform a backfill after seeding that updates Product.like_count and the Redis counts and adds product IDs to the dirty set so the batch sync sees them; finally, add an integration test that seeds data and then calls the product detail/list API to assert the returned likeCount for seeded popular products is > 0.apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java-54-68 (1)
54-68:⚠️ Potential issue | 🟠 Major초기화 전체를 단일 트랜잭션과 영속성 컨텍스트에 묶으면 로컬 부팅이 막힌다.
지금 구조는 20만 상품, 대량 likes, 5만 쿠폰을 한 번에 붙잡아서 메모리 사용량·redo log·rollback 비용이 모두 커진다. 배치를 1k~5k 단위로 끊고 각 배치마다 별도 트랜잭션과
flush/clear를 적용해 달라. 추가로 축소된 데이터셋과 실제 목표 데이터셋 각각에 대해 초기화 완료 시간과 peak memory를 확인하는 통합 테스트나 수동 벤치마크를 남겨 달라.🤖 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/init/DataInitializer.java` around lines 54 - 68, The DataInitializer.run currently performs full dataset creation (createUsers, createBrands, createProducts, createLikes, createCoupons) inside a single `@Transactional` method causing huge memory/redo/rollback cost; refactor so each large insert sequence (especially createProducts and createLikes/createCoupons) is split into batches of ~1k–5k records and each batch is committed in its own transaction (create batch helper methods and call them from run), and after persisting each batch call EntityManager.flush() and EntityManager.clear() to release the persistence context; also add a small integration test or manual benchmark that runs the initializer against a reduced dataset and the target dataset recording completion time and peak memory to validate performance and stability.scripts/migration/V001__add_like_count_to_products.sql-9-14 (1)
9-14:⚠️ Potential issue | 🟠 Major좋아요 백필을 상관 서브쿼리로 두면 마이그레이션 시간이 급격히 늘 수 있다.
현재 쿼리는 상품 수만큼
likes집계를 반복할 수 있어서 대량 데이터에서 배포 시간이 길어지고 DDL 이후 lock 유지 시간이 늘어난다.likes를 한 번만GROUP BY product_id로 집계한 뒤JOIN UPDATE로 바꾸는 편이 안전하다. 추가로 운영 데이터 크기에 가까운 샘플에서EXPLAIN과 dry-run을 돌려likes.product_id인덱스를 타는지 확인해 달라.수정 예시
-UPDATE products p -SET p.like_count = ( - SELECT COUNT(*) - FROM likes l - WHERE l.product_id = p.id -); +UPDATE products p +LEFT JOIN ( + SELECT l.product_id, COUNT(*) AS like_count + FROM likes l + GROUP BY l.product_id +) lc ON lc.product_id = p.id +SET p.like_count = COALESCE(lc.like_count, 0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/migration/V001__add_like_count_to_products.sql` around lines 9 - 14, The current UPDATE uses a correlated subquery over products which will scan/aggregate likes per product and slow migration; instead compute likes per product once with SELECT product_id, COUNT(*) FROM likes GROUP BY product_id and then perform an UPDATE products p JOIN (that aggregate) a ON a.product_id = p.id SET p.like_count = a.count (and handle zeroes for products with no likes), replacing the correlated subquery in V001__add_like_count_to_products.sql; after changing, run EXPLAIN and a dry-run on a production-sized sample to confirm the query uses the likes.product_id index and that locks/time are acceptable.scripts/migration/V001__add_like_count_to_products.sql-18-18 (1)
18-18:⚠️ Potential issue | 🟠 Major브랜드별 좋아요 정렬 인덱스가 실제 정렬 조건을 끝까지 덮지 못한다.
브랜드 필터 목록이
like_count DESC, created_at DESC로 정렬되면 현재 인덱스로는 동률 구간에서 filesort가 남아 페이지 조회 때마다 DB 부하가 커진다. 인덱스를(brand_id, like_count DESC, created_at DESC)로 확장해 정렬과 페이징을 같이 태우는 편이 안전하다. 추가로 해당 조회의EXPLAIN ANALYZE를 붙여Using filesort가 사라지는지 확인해 달라.수정 예시
-CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); +CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC, created_at DESC);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/migration/V001__add_like_count_to_products.sql` at line 18, 인덱스 idx_products_brand_like가 like_count DESC까지만 포함해 동률 구간에서 filesort가 발생하므로 products 테이블의 인덱스를 (brand_id, like_count DESC, created_at DESC)로 확장해 생성하도록 변경하고, 변경 후 해당 쿼리에 대해 EXPLAIN ANALYZE를 실행해 "Using filesort"가 사라졌는지 확인해 결과를 첨부하세요; 참고로 수정 대상은 기존 CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC) 선언을 대체하는 인덱스 생성문이며, 쿼리 정렬 키인 brand_id, like_count, created_at 순서를 유지해야 합니다.http/like.http-21-22 (1)
21-22:⚠️ Potential issue | 🟠 MajorHTTP 예제의 정렬 파라미터값이 실제 enum과 불일치하여 수정이 필수다.
HTTP 문서의
sort=LIKE_COUNT_DESC는 실제 enum 상수LIKES_DESC와 다르다. 이 불일치로 인해 예제를 그대로 실행하면 400 Bad Request가 발생한다. ProductSort enum과 API 명세에 정의된LIKES_DESC로 예제를 수정해야 한다. 또한 HTTP 예제 파일의 각 요청을 실행하는 자동 스모크 테스트를 추가하여 문서와 코드의 동기화를 보장해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@http/like.http` around lines 21 - 22, The HTTP example uses sort=LIKE_COUNT_DESC which doesn't match the ProductSort enum constant LIKES_DESC; update the request example in the like.http snippet to use sort=LIKES_DESC so it matches the ProductSort enum and avoids 400s, and add an automated smoke test that executes each request in the like.http file (or its HTTP collection runner) to validate examples against the running API on CI; locate the example by the GET request line and the ProductSort enum/constant names (ProductSort, LIKES_DESC) to make the change and add the smoke-test job that fails the build if any example returns a non-2xx response.apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java-20-37 (1)
20-37:⚠️ Potential issue | 🟠 Major패턴 기반 정리로는 Redis 상태 누수를 완전히 방지할 수 없다.
withReuse(true)로 인해 컨테이너가 재사용되고 고정된 DB 0을 여러 테스트 클래스가 공유하는 구조는 문제가 맞다. 일부 테스트 클래스(LikeCountSyncJobE2ETest,ProductLikeCountQueryServiceTest,LikeCountCacheServiceTest,DistributedLockServiceTest)에서@BeforeEach/@AfterEach로 정리를 수행하지만, 모두 패턴 기반 정리(redisTemplate.keys("like:*")→delete())를 사용한다. 이는 다음 문제들을 야기한다:
- 각 테스트가 정의한 특정 패턴의 키만 정리되므로 다른 패턴의 고아 키나 TTL 만료되지 않은 키가 남음
redisTemplate.keys()+delete()는 원자적이지 않아 동시 테스트 환경에서 race 발생 가능CommerceBatchApplicationTest는 TestRedisConfiguration을 사용하지만 정리 로직이 전혀 없음- 재사용 컨테이너는 로컬 머신의 이전 실행 결과까지 유지하므로 cross-run 오염 가능
withReuse(false)로 변경하거나, 패턴 기반이 아닌FLUSHDB명령으로 DB 0 전체를 매 테스트마다 비워야 한다. 현재 방식은 격리를 보장하지 못하므로 캐시·분산락 테스트의 순서 의존성 문제를 완전히 해결할 수 없다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java` around lines 20 - 37, The reuse flag on the test Redis container enables cross-test and cross-run state leakage; change REDIS_CONTAINER.withReuse(true) to withReuse(false) so each test run gets a fresh container, and add a global DB flush step that runs before each test (e.g., a shared test base or TestExecutionListener used by tests like LikeCountSyncJobE2ETest, ProductLikeCountQueryServiceTest, LikeCountCacheServiceTest, DistributedLockServiceTest and CommerceBatchApplicationTest) that invokes FLUSHDB against the container (use RedisTemplate or direct connection to REDIS_CONTAINER host/port) to atomically clear DB 0 instead of pattern-based deletion; keep the testRedisProperties method and RedisProperties usage unchanged while ensuring the cleanup runs against REDIS_CONTAINER.getHost()/getMappedPort(REDIS_PORT).scripts/truncate-tables.sql-5-17 (1)
5-17:⚠️ Potential issue | 🟠 Major외래키 체크 복구가 실패 경로에서 보장되지 않는다.
운영 관점에서 중간
TRUNCATE실패 시FOREIGN_KEY_CHECKS복구가 누락되면 동일 세션 후속 작업에서 무결성 붕괴가 발생할 수 있다.
수정안은 기존 값을 저장 후 복구하도록 변경하고, 이 스크립트를 반드시 전용 세션에서 실행하도록 명시하는 것이다.
추가 테스트로 의도적으로 존재하지 않는 테이블TRUNCATE를 끼워 넣어 실패시킨 뒤SELECT @@FOREIGN_KEY_CHECKS가 원복되는지 검증하는 실행 시나리오 테스트가 필요하다.수정 예시
-SET FOREIGN_KEY_CHECKS = 0; +SET `@OLD_FOREIGN_KEY_CHECKS` = @@FOREIGN_KEY_CHECKS; +SET FOREIGN_KEY_CHECKS = 0; ... -SET FOREIGN_KEY_CHECKS = 1; +SET FOREIGN_KEY_CHECKS = `@OLD_FOREIGN_KEY_CHECKS`;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/truncate-tables.sql` around lines 5 - 17, Save the current FOREIGN_KEY_CHECKS value into a user variable before disabling it, perform the TRUNCATEs, and always restore the original value at the end (even on error) by using a guarded restore pattern (store @@FOREIGN_KEY_CHECKS into e.g. `@old_fk_checks`, SET FOREIGN_KEY_CHECKS=0, run TRUNCATE TABLE order_items/.../users, then SET FOREIGN_KEY_CHECKS=@old_fk_checks); document that this script must be executed in a dedicated session and add a test scenario that injects an invalid TRUNCATE (nonexistent table) to verify SELECT @@FOREIGN_KEY_CHECKS returns the original value after failure.apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java-57-64 (1)
57-64:⚠️ Potential issue | 🟠 Majordirty ID 파싱 실패가 전체 동기화를 무효화한다.
운영 관점에서 Line 59에서 숫자 파싱 하나만 실패해도 Line 64로 빠져 빈 집합을 반환하므로 해당 배치 주기의 전체 동기화가 건너뛰어져 캐시-DB 불일치가 장시간 지속될 수 있다.
수정안은 멤버 단위로 파싱 예외를 격리해 잘못된 값만 건너뛰고 유효한 ID는 계속 처리하는 것이다.
추가 테스트로 dirty set에["101", "bad", "102"]가 있을 때 결과가{101, 102}인지 검증하는 테스트가 필요하다.수정 예시
Set<Long> result = new HashSet<>(); for (String member : members) { - result.add(Long.parseLong(member)); + try { + result.add(Long.parseLong(member)); + } catch (NumberFormatException nfe) { + log.warn("dirty productId 파싱 실패: member={}", member); + } } return result;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-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java` around lines 57 - 64, In LikeCountBatchCacheService (the method iterating over members to build result), avoid letting a single NumberFormatException abort the whole conversion: change the loop to parse each String member individually, catching and logging parse errors per-member (e.g., log.warn with the bad value and exception) and skip only the invalid entries while still adding valid Longs to result; ensure the method returns the set of successfully parsed IDs instead of an empty set on any parse failure; add a unit test asserting that input ["101","bad","102"] yields {101,102}.apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java-50-66 (1)
50-66:⚠️ Potential issue | 🟠 Major부분 실패를 삼키면 배치 모니터링이 무력화된다
개별 상품 동기화 예외를 로그만 남기고 계속 진행하면, 실제로는 일부 상품이 갱신되지 않았어도 Step이 정상 종료로 기록될 가능성이 높다. 운영에서는 like_count 드리프트가 누적돼도 배치 실패로 감지되지 않아 재처리와 알람이 모두 늦어진다. 실패 건이 하나라도 있으면 최종적으로 예외를 다시 던지거나 custom exit status를 내려 모니터링과 재시도가 걸리도록 바꾸는 편이 낫다. 추가로 dirty 상품 하나에서 예외를 강제로 발생시키고 Job exit code가
COMPLETED가 아님을 검증하는 테스트를 넣어야 한다. As per coding guidelines, "예외 흐름이 명확한지 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java` around lines 50 - 66, The loop currently swallows per-product exceptions (syncProductLikeCount) and always returns RepeatStatus.FINISHED, which hides partial failures; change the logic so that if any iteration fails (failCount > 0) you either rethrow an exception after the loop or set the Step/Job exit status to a non-COMPLETED value (e.g., contribution.setExitStatus(ExitStatus.FAILED) or throw new RuntimeException with context) so monitoring/retry triggers; update references around dirtyProductIds loop, syncProductLikeCount, contribution.incrementWriteCount and the final return (RepeatStatus.FINISHED) accordingly, and add a unit/integration test that forces syncProductLikeCount to throw for one dirty product and asserts the Job/Step exit code is not COMPLETED.scripts/benchmark/run-benchmark.sh-6-6 (1)
6-6:⚠️ Potential issue | 🟠 Major파이프라인 실패가 가려져도 스크립트가 성공으로 끝난다
docker exec ... | tee는pipefail이 없으면tee의 종료 코드만 반환한다. MySQL 인증이나 프로시저 실행이 실패해도 마지막에 완료 메시지를 찍어 잘못된 리포트를 정상 결과처럼 남길 수 있다.set -euo pipefail로 바꾸고 실패 시 완료 배너를 출력하지 않도록 처리해야 한다. 추가로 잘못된 비밀번호나 존재하지 않는 프로시저로 실행했을 때 종료 코드가 0이 아니고 완료 메시지가 출력되지 않는 테스트를 해 두는 편이 안전하다.예시 수정안이다
-set -e +set -euo pipefailAlso applies to: 37-42
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/benchmark/run-benchmark.sh` at line 6, The script currently uses only "set -e" so pipeline failures (e.g., the "docker exec ... | tee" that runs MySQL/procedure) can be masked by tee returning 0; change the shell options to "set -euo pipefail" and update the run-benchmark.sh flow so the completion banner/finished message is only printed when the critical commands exit successfully (check the exit status of the docker exec / mysql invocation and bail out before printing the banner). Locate the pipeline invocation (references: the docker exec ... | tee line) and surrounding banner/echo logic and add explicit error handling/early return so failures (bad password or missing procedure) propagate and the banner is not shown; also add simple CI/test commands that run with an invalid password and with a non-existent procedure to verify non-zero exit codes.scripts/benchmark/index-performance-test.sql-238-241 (1)
238-241:⚠️ Potential issue | 🟠 Major벤치마크 인덱스가 실제 조회 조건을 반영하지 못한다
모든 측정 쿼리에
deleted_at IS NULL이 들어가는데 인덱스에는 빠져 있어서, 소프트 삭제 데이터가 쌓인 운영 상태의 실행 계획을 재현하지 못한다. 이 상태로 나온 수치는 실제 서비스와 다른 인덱스 결정을 유도할 수 있다.deleted_at을 포함한 복합 인덱스로 정의를 맞추고, 실제 마이그레이션 정의가 있다면 이 스크립트도 동일 정의를 재사용하도록 맞추는 편이 안전하다. 추가로 삭제 데이터가 일정 비율 섞인 데이터셋에서EXPLAIN과 벤치마크를 다시 돌려 의도한 인덱스가 선택되는지 확인하는 테스트가 필요하다.예시 수정안이다
- CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC); - CREATE INDEX idx_products_brand_id ON products (brand_id); - CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); - CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC); + CREATE INDEX idx_products_like_count ON products (deleted_at, like_count DESC, created_at DESC); + CREATE INDEX idx_products_brand_id ON products (brand_id, deleted_at); + CREATE INDEX idx_products_brand_like ON products (brand_id, deleted_at, like_count DESC); + CREATE INDEX idx_products_brand_price ON products (brand_id, deleted_at, price ASC);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/benchmark/index-performance-test.sql` around lines 238 - 241, The benchmark index definitions (idx_products_like_count, idx_products_brand_id, idx_products_brand_like, idx_products_brand_price) omit the deleted_at predicate while all test queries include "deleted_at IS NULL", so the benchmark doesn't reproduce real plan choices; update those CREATE INDEX statements to include deleted_at (e.g., as the leading or trailing column to match real query predicate and sort needs), align them with the actual migration/index definitions used in production (reuse the same composite definitions if available), and then re-run EXPLAIN and the performance tests on a dataset with a realistic fraction of soft-deleted rows to verify the intended index is chosen.apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java-74-80 (1)
74-80:⚠️ Potential issue | 🟠 Major없는 상품은 active dirty set에서 정리해야 한다
updateLikeCount()가 0을 반환하는 경우는 재시도해도 성공할 가능성이 낮은데, 현재는 경고 로그만 남기고 dirty 플래그를 유지한다. 운영에서는 동일한productId를 매 배치마다 다시 세고 다시 경고하게 되어 불필요한 부하와 로그 노이즈가 계속 쌓인다. 해당 ID는 active dirty set에서 제거하고, 필요하면 별도 reconciliation/dead-letter set으로 보내 원인만 추적하는 방식이 안전하다. 추가로 DB에 없는productId를 dirty set에 넣고 한 번 실행한 뒤 active dirty set에서 빠졌는지 검증하는 테스트를 추가해야 한다.예시 수정안이다
if (updated > 0) { likeCountCacheService.set(productId, actualCount); likeCountCacheService.removeDirty(productId); log.debug("상품 좋아요 수 동기화: productId={}, count={}", productId, actualCount); } else { + likeCountCacheService.removeDirty(productId); log.warn("상품이 존재하지 않음: productId={}", productId); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java` around lines 74 - 80, The else-branch in LikeCountSyncTasklet (where updateLikeCount(...) returns 0) currently only logs a warning and leaves the ID in the active dirty set; change it to remove the ID from the active dirty set by calling likeCountCacheService.removeDirty(productId) and, if your cache supports it, move the ID to a reconciliation/dead-letter set (e.g., likeCountCacheService.moveToDeadLetter(productId) or likeCountCacheService.addDeadLetter(productId)) so it won’t be retried every batch; also add a unit/integration test asserting that when updateLikeCount returns 0 the productId is removed from the active dirty set (and optionally placed into the dead-letter set) to verify the behavior.apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java-215-217 (1)
215-217:⚠️ Potential issue | 🟠 Major동시성 테스트 대기에는 타임아웃이 필요하다
Line 215의
endLatch.await()는 실패 시 무기한 대기하여 파이프라인을 멈출 수 있다. 이는 장애 분석 시간을 크게 늘린다.
수정안으로await(timeout, unit)로 제한하고 실패 시 즉시 단정 실패 처리하며, 종료 경로에서shutdownNow()까지 보강하는 것을 권장한다.
추가 테스트로 의도적으로 한 작업을 지연시켜 타임아웃이 정상적으로 실패를 보고하는지 검증해야 한다.제안 diff
- endLatch.await(); - executor.shutdown(); + boolean completed = endLatch.await(5, java.util.concurrent.TimeUnit.SECONDS); + assertThat(completed).isTrue(); + executor.shutdownNow();🤖 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/infrastructure/cache/DistributedLockServiceTest.java` around lines 215 - 217, Replace the unbounded wait at endLatch.await() in DistributedLockServiceTest with a bounded wait using endLatch.await(timeout, unit) and assert that it returns true (fail the test immediately if it returns false); also strengthen the teardown by calling executor.shutdownNow() after executor.shutdown() when the await fails or in finally cleanup to ensure threads are terminated; update the test case(s) so one task is artificially delayed (e.g., sleep) to verify the timeout path reports failure as expected.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-27-30 (1)
27-30:⚠️ Potential issue | 🟠 Major
sort파라미터 명칭을 명확히 하고 에러 메시지에 가능한 enum 값을 제시해야 한다Line 27의
@RequestParam(required = false) ProductSort sort는 ApiControllerAdvice의 MethodArgumentTypeMismatchException 핸들러에 의해 통합 에러 포맷으로 처리되므로, 포맷 일관성 문제는 없다. 다만 두 가지 개선이 필요하다.
에러 메시지 품질: 현재 MethodArgumentTypeMismatchException 핸들러(라인 33-39)는 잘못된 enum 값에 대해 "값 'INVALID'이(가) 잘못되었습니다"만 반환하며, 사용 가능한 값(LATEST, PRICE_ASC, LIKES_DESC)을 제시하지 않는다. JSON 본문의 enum 에러는 가능한 값을 안내하는데(라인 54-66), 쿼리 파라미터 에러는 그렇지 않아 일관성이 떨어진다. MethodArgumentTypeMismatchException 핸들러를 개선하여 enum 타입일 경우 가능한 값을 함께 제시하도록 수정해야 한다.
파라미터 이름 충돌: 현재
sort파라미터명은 Pageable의 기본sort파라미터와 명칭이 겹쳐 향후 혼선 가능성이 있다.productSort로 변경하여 명시적으로 분리하는 것이 운영상 안전하다.추가 테스트로
?sort=INVALID요청 시 개선된 에러 메시지가 제공되는지, 그리고?sort=LATEST&page=0&sort=id,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 27 - 30, Rename the query parameter in ProductV1Controller from sort to productSort (update the `@RequestParam` name and the call to productService.findAll(brandId, sort, pageable) accordingly to pass the new ProductSort productSort) to avoid collision with Pageable.sort, and update the MethodArgumentTypeMismatchException handler in ApiControllerAdvice so that when the exception parameter type is an enum (e.g., ProductSort.class) it appends the list of valid enum values (ProductSort.values()) to the error message (instead of just "값 'X'이(가) 잘못되었습니다") so the response for invalid query values like ?productSort=INVALID returns the possible values; add/adjust tests to assert ?productSort=INVALID returns the enriched message and that ?productSort=LATEST&page=0&sort=id,desc behaves correctly.apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java-86-93 (1)
86-93:⚠️ Potential issue | 🟠 MajorThread.sleep 기반 TTL 검증과 무제한 await는 테스트 신뢰도를 해친다
Line 92의 고정 대기(150ms)는 Redis TTL 만료 시점의 스케줄링 편차를 고려하지 않아 CI 부하 상황에서 간헐 실패를 야기한다. 락 만료 판정 신뢰도가 떨어진다.
Line 215의
endLatch.await()는 타임아웃 없이 호출되어, 스레드가 hang되거나 예외가 발생할 경우 파이프라인이 무한 정지된다.수정안:
- Line 92: 고정 sleep 제거, 일정 시간 내 짧은 간격 폴링(Awaitility 라이브러리 권장)으로 만료 시점을 안정적으로 확인한다.
- Line 215:
endLatch.await(10, TimeUnit.SECONDS)같이 적절한 타임아웃을 추가한다.- 수정 후 동시성 테스트를 30~50회 반복 실행해 실패율이 0에 수렴하는지 검증한다.
🤖 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/infrastructure/cache/DistributedLockServiceTest.java` around lines 86 - 93, In the 락_만료후_재획득 test, remove the fixed Thread.sleep(150) and replace it with a short-interval polling/await (use Awaitility or equivalent) that repeatedly checks until the first lock TTL has expired and tryLock becomes possible within a bounded timeout; target a small retry interval and an overall timeout to tolerate scheduling variance. Also update the unbounded endLatch.await() call to use a bounded wait like endLatch.await(10, TimeUnit.SECONDS) to avoid indefinite hangs. After making these changes (methods referenced: 락_만료후_재획득 and the endLatch.await call), run the concurrency test 30–50 times to ensure failure rate approaches zero.apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java-17-23 (1)
17-23:⚠️ Potential issue | 🟠 MajorRedis 테스트 컨테이너의 부동 태그와 불완전한 cleanup 전략이 로컬 테스트 재현성을 떨어뜨린다
redis:7.0부동 태그는 현재 7.0.15를 가리키지만 향후 변경될 수 있어 테스트 재현성을 약화시킨다. 패치 버전(redis:7.0.15)으로 고정하거나 digest로 pin하도록 변경한다.
withReuse(true)는 ~/.testcontainers.properties에 testcontainers.reuse.enable=true를 설정한 경우에만 로컬 개발 환경에서 작동한다. 현재 CI 환경에서는 재사용이 비활성화되어 있으나, 개발자 로컬 환경에서 재사용되는 경우 Redis 데이터가 유지되며 테스트 간 격리가 약해진다. 로컬 재사용을 피하려면withReuse(true)제거를 권장한다.현재 테스트들이 lock:* 또는 {product:}:like: 등 특정 프리픽스만 정소하는 부분적 cleanup을 수행하므로, 다른 테스트에서 생성한 키가 남아 교차 오염을 유발할 수 있다. 모든 테스트 클래스에서
@BeforeEach/@AfterEach에 RedisCleanUp.truncateAll()을 호출하여 Redis 전체를 초기화하도록 통일한다.🤖 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/config/TestRedisConfiguration.java` around lines 17 - 23, TestRedisConfiguration에서 사용중인 REDIS_IMAGE을 부동 태그 대신 패치 버전(예: "redis:7.0.15" 또는 이미지 digest)로 고정하고, REDIS_CONTAINER 인스턴스에서 withReuse(true)를 제거하여 로컬 컨테이너 재사용으로 인한 상태 유지 문제를 방지하세요; 또한 테스트 간 완전한 격리를 위해 모든 관련 테스트 클래스의 `@BeforeEach` 또는 `@AfterEach` 훅에서 RedisCleanUp.truncateAll()을 호출하도록 통일(혹은 공통 테스트 베이스 클래스에 추가)해 모든 키를 초기화하도록 하세요 (참조 심볼: REDIS_IMAGE, REDIS_CONTAINER, withReuse, RedisCleanUp.truncateAll, TestRedisConfiguration).apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java-148-181 (1)
148-181:⚠️ Potential issue | 🟠 Major테스트가
단일 DB 조회를 실제로 검증하지 않는다현재 assertion은 모든 스레드가 끝났는지와 캐시 저장 여부만 보므로, 구현이 회귀되어도 여러 스레드가 DB를 동시에 조회하는 상황을 잡지 못한다.
LikeRepository.countByProductId()를 spy/mock 해서 호출 횟수를 1회로 단언하고,endLatch.await()에는 timeout을 넣어 교착 시 CI가 무한 대기하지 않게 해 달라. 느린 DB 응답 케이스도 함께 추가해 재시도 budget 초과 시나리오를 막는 편이 안전하다. As per coding guidelines "**/*Test*.java: 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.`"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java` around lines 148 - 181, The test currently only asserts thread completion and cache presence but doesn't verify single DB lookup; update ProductLikeCountQueryServiceTest to spy/mock LikeRepository (or inject a test spy) and assert LikeRepository.countByProductId(product.getId()) is invoked exactly once when calling productLikeCountQueryService.getLikeCount concurrently, add a timeout to endLatch.await(...) to avoid CI hang, and add an additional test case that simulates a slow DB response (e.g., delay in the mocked countByProductId) to validate retry/budget behavior and ensure the single-flight logic still prevents multiple DB calls.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java-30-32 (1)
30-32:⚠️ Potential issue | 🟠 Major현재 재시도 budget으로는 느린 DB에서 herd가 다시 열린다
현재 budget은 최대 150ms뿐이라 DB count 조회가 그보다 조금만 느려도 대기 중 요청들이 동시에
countByProductId()로 떨어져, 부하 순간에 보호해야 할 DB를 다시 몰아치게 된다. 재시도 소진 후에는 캐시가 채워질 때까지 더 기다리거나 락 재시도로 단일 조회를 유지하고, 최소한 fallback 결과도 캐시에 기록해 연쇄 miss를 끊어야 한다. DB 조회를 500ms 이상 지연시킨 동시성 테스트를 추가해, 동시에 들어온 요청에서도 DB 호출 수가 1회로 유지되는지 확인해 달라. As per coding guidelines "**/*Service*.java: 외부 호출에는 타임아웃/재시도/서킷브레이커 고려 여부를 점검한다.`"Also applies to: 143-161
🤖 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/cache/ProductLikeCountQueryService.java` around lines 30 - 32, The retry budget is too small (LOCK_TTL, RETRY_DELAY, MAX_RETRIES) causing herd to reopen under slow DB; increase the retry window (e.g., raise MAX_RETRIES and RETRY_DELAY or extend LOCK_TTL) and change the lock/fallback logic in ProductLikeCountQueryService (the methods that acquire the lock and call countByProductId()) so that when retries are exhausted you either wait for the cache to be populated or re-attempt a lock-acquire loop that keeps a single in-flight DB call, and always write a safe fallback value into the cache on failure to break chain misses; add a concurrency integration test that simulates DB countByProductId() taking >=500ms and asserts only one DB invocation for many concurrent requests to verify herd protection.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java-66-69 (1)
66-69:⚠️ Potential issue | 🟠 Major
@Async가 같은 빈 내부 호출로 인해 적용되지 않는다
refreshInBackground()메서드는@Async어노테이션이 있지만, 66-69번 줄에서 같은 빈 내부로 직접 호출되므로 Spring AOP 프록시를 거치지 않아 동기 실행된다. 캐시가 TTL 만료(PER) 상태인 hit 요청은 stale 값을 즉시 반환해야 하는데, 현재는 DB count 조회를 호출 스레드에서 동기 대기하게 된다. 운영 환경에서 DB 응답이 지연되면 조회 API의 지연이 급격히 악화된다.
refreshInBackground()를 별도 빈으로 분리해 외부 호출하거나,ApplicationEventPublisher또는TaskExecutor를 사용해 명시적으로 비동기 실행하도록 수정한다.getLikeCount()가 호출 스레드에서 즉시 반환되고 별도 스레드에서 캐시가 갱신되는지 검증하는 통합 테스트를 추가한다.해당 코드 위치
// 66-69번 줄: 같은 빈 내부 직접 호출 if (result.shouldRefresh()) { refreshInBackground(productId); } // 168-188번 줄: `@Async` 메서드 정의 `@Async` public void refreshInBackground(Long productId) { // ... }🤖 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/cache/ProductLikeCountQueryService.java` around lines 66 - 69, ProductLikeCountQueryService calls its own `@Async` method refreshInBackground(...) directly, so Spring proxying is bypassed and the refresh runs synchronously; fix by moving the async work into a separate bean (e.g., ProductLikeCountRefresher.refreshInBackground(Long)) or invoke refresh via an injected TaskExecutor/ApplicationEventPublisher so the call goes through Spring and executes on a different thread; update getLikeCount(...) to submit the refresh task (executor.execute(...) or publisher.publishEvent(...)) instead of calling refreshInBackground(...) directly, keep the `@Async` or executor-based implementation on the new bean/method, and add an integration test that asserts getLikeCount(...) returns immediately for a PER (TTL-expired) cache hit while the DB refresh happens asynchronously (e.g., use a CountDownLatch or test double to verify background refresh invocation).
🟡 Minor comments (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java-110-112 (1)
110-112:⚠️ Potential issue | 🟡 Minor
likeCountsetter에 무결성 가드가 필요하다.운영 관점에서 Line 110의 setter가
null또는 음수를 허용하면 flush 시점 제약 위반이나 비정상 정렬(좋아요 음수)로 이어져 배치/조회 흐름 장애를 유발할 수 있다.
수정안은 setter에서 null/음수 입력을 즉시 차단하여 엔티티 불변식을 유지하는 것이다.
추가 테스트로setLikeCount(null)및setLikeCount(-1L)호출 시 예외가 발생하는 단위 테스트를 추가하는 것이 필요하다.수정 예시
public void setLikeCount(Long likeCount) { - this.likeCount = likeCount; + if (likeCount == null || likeCount < 0) { + throw new IllegalArgumentException("likeCount must be non-null and >= 0"); + } + this.likeCount = likeCount; }As per coding guidelines "
**/*.java: null 처리, 방어적 복사, 불변성 ... 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java` around lines 110 - 112, In ProductJpaEntity, the setter method setLikeCount should enforce invariants by rejecting null and negative values: update setLikeCount(Long likeCount) to validate the input and throw an unchecked exception (e.g., IllegalArgumentException or NullPointerException per project convention) when likeCount == null or likeCount < 0 to prevent invalid state; then add unit tests asserting that calling ProductJpaEntity.setLikeCount(null) and setLikeCount(-1L) each throw the expected exception.apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java-95-124 (1)
95-124:⚠️ Potential issue | 🟡 Minor핵심 성공 경로를 검증하지 않아 집계 로직이 망가져도 테스트가 통과한다
현재 시나리오는 dirty 상품만 만들고
LikeEntity를 넣지 않아서 기대값이 0이다.countByProductId()나updateLikeCount()가 잘못 구현돼도 이 테스트는 계속 초록색이라 실제 배치 회귀를 잡지 못한다. 최소 1건 이상의 좋아요를 저장한 뒤 DBlikeCount가 정확히 그 수로 동기화되는지 검증하는 케이스를 별도로 추가하는 편이 낫다. 추가로 다건 좋아요, 없는 상품, 부분 실패 시 exit status까지 함께 검증해야 한다. As per coding guidelines, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java` around lines 95 - 124, Add a positive-path E2E test in LikeCountSyncJobE2ETest that actually exercises the aggregation logic: create a ProductEntity, persist one or more LikeEntity records for that product (and optionally multiple likes to test aggregation), add the productId to the DIRTY_SET_KEY via redisTemplate, run the batch via jobLauncherTestUtils.launchJob()/job, then assert the jobExitStatus is COMPLETED, the productRepository.findById(productId).get().getLikeCount() equals the exact number of persisted LikeEntity rows, and that likeCountCacheService.getDirtyProductIds() no longer contains the productId; also add separate assertions/cases for a product with zero likes and a partial-failure or non-existent-product scenario to validate exit status and error handling.scripts/benchmark/run-benchmark.sh-9-13 (1)
9-13:⚠️ Potential issue | 🟡 Minor표시한 MYSQL_HOST/MYSQL_PORT가 실제 연결에 사용되지 않는다
현재는 컨테이너 내부에서
mysql클라이언트를 실행하면서-h/-P를 넘기지 않기 때문에, 출력에 어떤 host/port를 적더라도 실제 대상은 항상 컨테이너 내부 MySQL이다. 운영 관점에서는 리포트 메타데이터가 거짓이 되고, 다른 포트나 원격 DB를 벤치마크한다고 착각한 채 잘못된 환경 결과를 남길 수 있다. 이 스크립트를 컨테이너 전용으로 고정할 것이라면 host/port 설정과 출력 자체를 제거하고 컨테이너 이름만 받도록 단순화하고, 외부 대상 지원이 목적이라면-h "$MYSQL_HOST" -P "$MYSQL_PORT"를 명시해야 한다. 추가로MYSQL_HOST를 의도적으로 틀리게 넣었을 때 실제로 실패하는지, 또는 설정을 제거했다면 출력에도 host/port가 남지 않는지 확인하는 테스트가 필요하다.Also applies to: 20-21, 34-38
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/benchmark/run-benchmark.sh` around lines 9 - 13, The script declares MYSQL_HOST/MYSQL_PORT but never passes them to the mysql client, so the benchmark always targets the container-local DB; update the mysql invocation(s) in run-benchmark.sh (look for the commands that call mysql around the blocks referencing MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE) to explicitly include -h "$MYSQL_HOST" -P "$MYSQL_PORT" (and keep -u "$MYSQL_USER" -p"$MYSQL_PASSWORD"), or alternatively remove MYSQL_HOST/MYSQL_PORT variables and their mentions if you intend to lock this script to the container host only; after the change add a small smoke test that runs the script with an invalid MYSQL_HOST to ensure it fails when external host is unreachable (or that host/port are not printed when you removed them).
🧹 Nitpick comments (6)
scripts/insert-dummy-data.sql (1)
221-226: 좋아요 수 반영은 한 번 집계해서 갱신하는 편이 낫다.현재 쿼리는 상품 20만 건 각각에 대해
likes를 다시 세고 있어서 마지막 단계가 병목이 된다. 운영 관점에서는 시드 시간이 크게 흔들리고products갱신 구간이 길어져 이후 검증 작업까지 지연된다.수정안으로는
likes를 한 번GROUP BY product_id로 집계한 뒤JOIN UPDATE로 반영하는 방식이 낫다. 추가 테스트로는 대표 볼륨에서 실행 시간을 비교하고, 샘플 상품 몇 건에 대해products.like_count와likes실집계가 일치하는지 확인해야 한다.♻️ 제안하는 수정안
-UPDATE products p -SET p.like_count = ( - SELECT COUNT(*) - FROM likes l - WHERE l.product_id = p.id -); +UPDATE products p +LEFT JOIN ( + SELECT product_id, COUNT(*) AS like_count + FROM likes + GROUP BY product_id +) l ON l.product_id = p.id +SET p.like_count = COALESCE(l.like_count, 0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/insert-dummy-data.sql` around lines 221 - 226, The current UPDATE on table products (UPDATE products p ... WHERE l.product_id = p.id) recalculates COUNT(*) per product causing a hotspot; instead aggregate likes once with a GROUP BY product_id (e.g., via a CTE or a temporary aggregated table of product_id, cnt) and then perform a single JOIN UPDATE to set products.like_count = agg.cnt for matching product_id; reference the products.like_count column, likes.product_id and the UPDATE products p statement when editing; after changing, run timing comparisons on a representative data volume and spot-check a few product ids by comparing products.like_count vs a direct COUNT(*) on likes filtered by product_id to validate correctness.apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java (1)
16-33: 이 fake는 동시성 테스트에 투입되면 결과를 오염시킬 수 있다.
HashMap/HashSet기반의 read-modify-write는 병렬increment/decrement에서 lost update를 만들 수 있어서 실제 캐시 구현과 무관한 flaky를 낸다.ConcurrentHashMap과newKeySet()으로 바꾸고merge/compute로 원자적으로 갱신해 달라. 추가로ExecutorService로 같은productId를 동시에 증감시키는 테스트를 넣어 fake 자체의 선형화 가능성을 확인해 달라.수정 예시
- private final Map<Long, Long> store = new HashMap<>(); - private final Set<Long> dirtySet = new HashSet<>(); + private final Map<Long, Long> store = new java.util.concurrent.ConcurrentHashMap<>(); + private final Set<Long> dirtySet = java.util.concurrent.ConcurrentHashMap.newKeySet(); public Long increment(Long productId) { - Long current = store.getOrDefault(productId, 0L); - Long newValue = current + 1; - store.put(productId, newValue); + Long newValue = store.merge(productId, 1L, Long::sum); dirtySet.add(productId); return newValue; } public Long decrement(Long productId) { - Long current = store.getOrDefault(productId, 0L); - Long newValue = Math.max(0, current - 1); - store.put(productId, newValue); + Long newValue = store.compute( + productId, + (id, current) -> Math.max(0L, (current == null ? 0L : current) - 1L) + ); dirtySet.add(productId); return newValue; }🤖 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/fake/FakeLikeCountCacheService.java` around lines 16 - 33, FakeLikeCountCacheService의 store와 dirtySet은 HashMap/HashSet으로 read-modify-write를 사용해 동시성에서 lost update를 유발하므로 store를 ConcurrentHashMap으로, dirtySet을 ConcurrentHashMap.newKeySet()으로 교체하고 increment/decrement는 getOrDefault/put 대신 ConcurrentHashMap.merge 또는 compute를 사용해 원자적으로 갱신하도록 변경하라; 또한 같은 productId에 대해 동시 증감이 발생할 때 fake 자체가 선형화 가능한지 검증하는 테스트를 추가하되 ExecutorService로 여러 스레드가 같은 productId를 동시에 increment/decrement하게 하고 최종 합계와 중간 반환값들이 일관되는지(assertions) 확인하도록 구현하라 (참고 식별자: FakeLikeCountCacheService, store, dirtySet, increment, decrement).apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java (1)
10-10: 단건 카운트만으로는 배치에서 N+1 쿼리 병목이 발생한다.운영 관점에서 dirty 상품이 많을 때 상품별
countByProductId반복 호출은 DB 왕복이 급증해 배치 지연과 피크 시간대 부하를 유발한다.
수정안은 상품 ID 목록 기반 집계 쿼리(IN + GROUP BY)를 추가해 한 번에 카운트를 가져오도록 바꾸는 것이다.
추가 테스트로 100개 상품 ID 입력 시 집계 결과 정확성과 호출 쿼리 수 감소를 검증하는 통합 테스트가 필요하다.리팩터링 예시
public interface LikeBatchRepository extends JpaRepository<LikeEntity, Long> { long countByProductId(Long productId); + + `@Query`(""" + SELECT l.productId as productId, COUNT(l.id) as likeCount + FROM LikeEntity l + WHERE l.productId IN :productIds + GROUP BY l.productId + """) + java.util.List<ProductLikeCountView> countByProductIds(`@Param`("productIds") java.util.Collection<Long> productIds); + + interface ProductLikeCountView { + Long getProductId(); + Long getLikeCount(); + } }As per coding guidelines "
**/*Repository*.java: N+1 가능성 ... 대량 데이터에서의 병목을 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java` at line 10, The single-count method countByProductId on LikeBatchRepository causes N+1 DB roundtrips in batch jobs; add a bulk aggregation method (e.g., countByProductIdIn or findCountsByProductIds) that accepts a List<Long> productIds and returns counts grouped by productId (Map<Long,Long> or a DTO list) implemented with a single IN + GROUP BY query, replace per-id calls in the batch job with this bulk method, and add an integration test that supplies 100 product IDs to assert aggregated counts are correct and that the number of DB queries is drastically reduced.apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java (1)
37-43: 캐시 부수효과 검증 테스트가 빠져 있다Line 37-43에서 캐시 서비스를 주입했지만 호출 검증이 없어, 운영에서 좋아요 수 캐시가 누락되어 DB/캐시 불일치가 발생해도 테스트가 통과할 수 있다.
수정안으로like/unlike성공·실패 경로별increment/decrement호출 여부를Mockito.verify(...)로 명시 검증하거나,FakeLikeCountCacheService로 최종 카운트를 검증하는 테스트를 추가하는 것이 좋다.
추가 테스트로중복 좋아요(CONFLICT)에서는 증가 호출이 발생하지 않는지,멱등 unlike에서는 감소 호출이 과도하게 발생하지 않는지까지 포함해야 한다.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/application/like/LikeApplicationServiceTest.java` around lines 37 - 43, 현재 LikeApplicationServiceTest에서 likeCountCacheService를 주입만 하고 호출 검증이 없으므로 LikeApplicationService의 like/unlike 흐름에서 캐시 부수효과를 검증하도록 테스트를 추가하세요: LikeApplicationService의 like(...)와 unlike(...) 성공 경로에서는 likeCountCacheService.increment(...) / decrement(...)가 각각 한 번 호출되는지 Mockito.verify로 검증하고, 실패/예외 경로(예: 중복 좋아요로 인한 CONFLICT)에서는 increment 호출이 발생하지 않는지, 멱등 unlike 호출 시 decrement가 과도하게 호출되지 않는지 검증하거나 대체로 FakeLikeCountCacheService를 만들어 최종 카운트를 검증하도록 교체해 경계값·실패케이스·예외 흐름을 모두 포함시키세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java (1)
107-108: 동률 정렬 시 페이지 경계 안정성이 부족하다Line 107-108 정렬은
likeCount,createdAt가 동일한 데이터에서 순서가 비결정적일 수 있어, 운영 중 오프셋 페이징에서 중복/누락 조회를 유발할 수 있다. 3차 정렬키로id DESC를 추가해 순서를 고정하는 편이 안전하다.
추가 테스트로likeCount와createdAt가 같은 상품을limit보다 많이 생성한 뒤, 연속 페이지 조회 시 ID 중복/누락이 없는지 검증하는 케이스를 권장한다.제안 diff
- case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount") - .and(Sort.by(Sort.Direction.DESC, "createdAt")); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount") + .and(Sort.by(Sort.Direction.DESC, "createdAt")) + .and(Sort.by(Sort.Direction.DESC, "id"));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/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java` around lines 107 - 108, ProductRepositoryImpl의 LIKES_DESC 정렬(Sort.by(... "likeCount").and(Sort.by(... "createdAt")))은 likeCount와 createdAt이 동일한 경우 순서가 비결정적이어서 오프셋 페이징에서 중복/누락을 유발하니, LIKES_DESC 분기에서 3차 키로 id DESC를 추가해 정렬을 고정하고(예: 추가적인 .and(Sort.by(Sort.Direction.DESC, "id"))), 변경 후에는 테스트를 추가해 likeCount와 createdAt이 동일한 상품을 limit보다 많이 만들어 연속 페이지 조회 시 ID 중복/누락이 없는지(ProductRepositoryImpl의 해당 페이징 쿼리/메서드 호출을 사용하여) 검증하세요.apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java (1)
195-200: 샤드 수 하드코딩은 테스트 유지보수성을 떨어뜨린다Line 195-200의
16개 샤드가정은 내부 샤딩 정책 변경 시 기능은 정상인데 테스트만 깨지는 상황을 만든다.
수정안으로 샤드 수를 서비스 상수/설정값에서 참조하거나, 샤드 수에 독립적인 랜덤 ID 세트 검증으로 바꾸는 것을 권장한다.
추가 테스트로 임의 ID 집합에 대해setMultiple/getMultipleround-trip이 보존되는지 검증하면 구현 변경에도 안정적이다.🤖 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/infrastructure/cache/LikeCountCacheServiceTest.java` around lines 195 - 200, The test currently hardcodes a "16 shard" assumption causing brittleness; update LikeCountCacheServiceTest to avoid relying on a fixed shard count by either (a) reading the shard count from the service/config constant (e.g., a SHARD_COUNT or a getter on LikeCountCacheService) and using it to compute IDs instead of magic numbers like 16, or (b) replace the fixed IDs with a shard-agnostic randomized ID set and assert round-trip behavior using likeCountCacheService.setMultiple(...) and likeCountCacheService.getMultiple(...) (or repeated set/get) to verify values are preserved; adjust assertions to use the dynamically generated IDs so the test remains valid if internal sharding changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4107ae2d-716a-4519-be16-6086def6175e
⛔ Files ignored due to path filters (4)
.claude/commands/create-log.mdis excluded by!**/*.mdand included by**.claude/commands/create-pr.mdis excluded by!**/*.mdand included by**.claude/skills/analyze-query/SKILL.mdis excluded by!**/*.mdand included by**scripts/benchmark/PERFORMANCE-ANALYSIS.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (42)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductService.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.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/test/java/com/loopers/application/like/LikeApplicationServiceTest.javaapps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.javaapps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.javaapps/commerce-api/src/test/resources/application-test.ymlapps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.javaapps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.javaapps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.javaapps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.javaapps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.javaapps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.javaapps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.javaapps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.javahttp/like.httpmodules/redis/src/main/resources/redis.ymlscripts/benchmark/index-performance-test.sqlscripts/benchmark/run-benchmark.shscripts/insert-dummy-data.sqlscripts/migration/V001__add_like_count_to_products.sqlscripts/migration/V001__add_like_count_to_products_rollback.sqlscripts/run-dummy-data.shscripts/truncate-tables.sql
| public Long increment(Long productId) { | ||
| try { | ||
| String key = buildKey(productId); | ||
| Long result = writeTemplate.opsForValue().increment(key); | ||
| writeTemplate.opsForValue().set(key, String.valueOf(result), DEFAULT_TTL); | ||
| markDirty(productId); | ||
| return result; | ||
| } catch (Exception e) { | ||
| log.warn("Redis increment 실패: productId={}", productId, e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 좋아요 수 감소 + 캐시 덮어쓰기. | ||
| */ | ||
| public Long decrement(Long productId) { | ||
| try { | ||
| String key = buildKey(productId); | ||
| Long result = writeTemplate.opsForValue().decrement(key); | ||
| long safeResult = result != null && result >= 0 ? result : 0L; | ||
| writeTemplate.opsForValue().set(key, String.valueOf(safeResult), DEFAULT_TTL); | ||
| markDirty(productId); | ||
| return safeResult; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Redis에서 존재하지 않는 키에 INCR를 호출하면 0에서 시작하는가, 그리고 INCR후 별도의SET을 수행하는 조합은 동시 클라이언트 사이에서 원자적인가?
💡 Result:
-
존재하지 않는 키에
INCR호출 시 시작값: 키가 없으면 Redis가 그 키를 먼저0으로 간주/설정한 뒤 증가시키므로,INCR결과는 **1**이 됩니다(“0에서 시작”한다고 표현한다면 초깃값을 0으로 두고 증가). [1] -
INCR후 별도의SET(두 커맨드 조합)의 원자성:INCR단일 커맨드 자체는 원자적이지만,INCR다음에 별도의SET을 보내는 두 커맨드의 “묶음”은 그 사이에 다른 클라이언트 명령이 끼어들 수 있어 원자적이지 않습니다. 이런 조합을 원자적으로 만들려면MULTI/EXEC트랜잭션으로 묶거나(큐잉된 명령들을 원자적으로 실행) [2], Lua 스크립트(EVAL)로 한 번에 실행해야 합니다. [3]
Sources: [1] Redis INCR command docs, [2] Redis MULTI command docs, [3] Redis Lua scripting (EVAL) docs.
INCR/DECR 후 SET으로 인한 동시성 카운터 손실
현재 구현은 INCR/DECR로 값을 증감한 뒤 동일 키에 SET을 실행한다. 이 두 커맨드는 원자적이지 않으므로, 동시 요청에서 늦게 도착한 쓰기가 더 최신의 증가값을 덮어써 카운터가 손실된다. 또한 캐시 키가 없을 때 INCR은 0에서 시작해 1을 반환하므로, 기존 DB 좋아요 수를 무시하고 초기화된다. 이는 캐시 냉시작 직후 응답값과 이후 배치 동기화 값의 불일치를 야기한다.
Lua 스크립트 또는 MULTI/EXEC 트랜잭션으로 증감과 TTL 연장을 원자적으로 처리하고, 키 미존재 시에는 DB 기준 절대값으로 초기화한 뒤 증감을 수행하도록 수정하다. 기존 좋아요가 존재하는 상품에서 캐시를 비운 뒤 동시에 like/unlike 요청을 보내 최종 카운트와 Redis 값이 일치하는지 검증하는 테스트를 추가하다.
🤖 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/cache/LikeCountCacheService.java`
around lines 122 - 145, The increment/decrement methods (increment, decrement)
use non-atomic INCR/DECR followed by SET
(writeTemplate.opsForValue().increment/decrement and
writeTemplate.opsForValue().set), which can be lost under concurrency and
wrongly initialize a missing key to 1; replace these with an atomic Redis
operation (use a Lua script or MULTI/EXEC) that: 1) checks if the key exists, if
not reads the authoritative DB count and initializes the key to that base value
before applying the delta, 2) performs the increment/decrement and updates the
TTL in one atomic step, and 3) still calls markDirty(productId) as needed;
update buildKey usage accordingly and add a concurrency test that clears the
cache for an existing product, fires concurrent like/unlike requests, and
asserts final Redis value equals the DB-synced like count.
| private void syncProductLikeCount(Long productId) { | ||
| long actualCount = likeRepository.countByProductId(productId); | ||
|
|
||
| int updated = productRepository.updateLikeCount(productId, actualCount); | ||
|
|
||
| if (updated > 0) { | ||
| likeCountCacheService.set(productId, actualCount); | ||
| likeCountCacheService.removeDirty(productId); | ||
| log.debug("상품 좋아요 수 동기화: productId={}, count={}", productId, actualCount); |
There was a problem hiding this comment.
DB 커밋 전에 Redis를 갱신하면 최신 좋아요나 롤백 결과를 잃을 수 있다
countByProductId() 이후에 set()과 removeDirty()를 바로 호출하면, 같은 상품에 대한 새 좋아요가 동기화 중간에 커밋되거나 Step 트랜잭션이 마지막에 롤백될 때 Redis만 더 최신이거나 더 오래된 값으로 남고 dirty 플래그까지 사라질 수 있다. 이 경우 다음 배치가 다시 잡지 못해 최종 수렴이 깨진다. 상품 단위 락이나 버전 토큰으로 동기화 구간을 보호하고, Redis 갱신과 dirty 제거는 DB 커밋 이후에만 수행하도록 옮겨야 한다. 추가로 countByProductId()와 removeDirty() 사이에 새 좋아요를 넣는 동시성 테스트와, DB 롤백 시 dirty 플래그가 유지되는 테스트를 같이 두는 편이 안전하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java`
around lines 69 - 77, syncProductLikeCount currently reads DB and immediately
calls likeCountCacheService.set(...) and removeDirty(...), which can lose
correctness if new likes are committed during sync or if the DB transaction
rolls back; change the flow so DB-side operations
(likeRepository.countByProductId and productRepository.updateLikeCount in
syncProductLikeCount) remain inside the transactional step but defer cache
updates—call likeCountCacheService.set(productId, actualCount) and
likeCountCacheService.removeDirty(productId) only after the DB transaction
successfully commits (e.g., via a transaction commit callback, an afterCommit
hook, or by moving cache updates to the outer coordinator that runs
post-commit), and protect the sync window for a product using a product-level
lock or optimistic version token to avoid concurrent updates; add tests that
insert a like between countByProductId and cache removal to confirm eventual
consistency and a rollback test ensuring the dirty flag remains set when the DB
transaction aborts.
📌 Summary
🧭 Context & Decision
문제 정의
선택지와 결정
도메인 설계
캐싱 전략
동시성 제어
인덱스 설계
배치 동기화
트레이드오프
추후 개선 여지
🏗️ Design Overview
변경 범위
domain/coupon/*: CouponTemplate, IssuedCoupon, 할인 정책domain/point/*: UserPointdomain/order/Order: 할인 적용 필드 추가infrastructure/cache/*: LikeCountCacheService, DistributedLockService, ProductLikeCountQueryServicebatch/job/likesync/*: LikeCountSyncJobConfig, LikeCountSyncTasklet주요 컴포넌트 책임
CouponTemplate: 쿠폰 템플릿 Aggregate Root (발급 가능 여부 검증, 발급 수 관리)IssuedCoupon: 사용자에게 발급된 쿠폰 (사용 처리, 만료 검증)CouponDiscountPolicy: 할인 금액 계산 전략 인터페이스UserPoint: 사용자 포인트 Aggregate Root (적립/차감, 잔액 검증)LikeCountCacheService: Redis 좋아요 카운터 INCR/DECR/GETDistributedLockService: Redis SETNX 기반 분산 락ProductLikeCountQueryService: Single Flight 패턴 적용 좋아요 수 조회LikeCountSyncTasklet: dirty flag 기반 Redis→DB 배치 동기화🔁 Flow Diagram
쿠폰 발급 Flow
sequenceDiagram autonumber participant Client participant CouponV1Controller participant CouponUserService participant CouponTemplateRepository participant IssuedCouponRepository participant DB Client->>CouponV1Controller: POST /api/v1/coupons/{templateId}/issue CouponV1Controller->>CouponUserService: issue(userId, templateId) CouponUserService->>CouponTemplateRepository: findById(templateId) CouponTemplateRepository->>DB: SELECT DB-->>CouponTemplateRepository: CouponTemplate CouponUserService->>IssuedCouponRepository: existsByUserIdAndTemplateId() IssuedCouponRepository->>DB: SELECT alt 이미 발급됨 DB-->>IssuedCouponRepository: true CouponUserService-->>CouponV1Controller: throw COUPON_ALREADY_ISSUED else 발급 가능 DB-->>IssuedCouponRepository: false CouponUserService->>CouponUserService: template.issue() CouponUserService->>IssuedCouponRepository: save(issuedCoupon) IssuedCouponRepository->>DB: INSERT CouponUserService->>CouponTemplateRepository: save(template) CouponTemplateRepository->>DB: UPDATE issued_count CouponUserService-->>CouponV1Controller: IssuedCouponResult end CouponV1Controller-->>Client: 201 Created할인 적용 주문 Flow
sequenceDiagram autonumber participant Client participant OrderV1Controller participant OrderApplicationService participant IssuedCouponRepository participant UserPointRepository participant OrderRepository participant DB Client->>OrderV1Controller: POST /api/v1/orders/with-discount OrderV1Controller->>OrderApplicationService: placeOrderWithDiscount(request) OrderApplicationService->>OrderApplicationService: 원가 계산 alt 쿠폰 적용 OrderApplicationService->>IssuedCouponRepository: findById(couponId) IssuedCouponRepository->>DB: SELECT DB-->>IssuedCouponRepository: IssuedCoupon OrderApplicationService->>OrderApplicationService: coupon.calculateDiscount(originalAmount) OrderApplicationService->>OrderApplicationService: coupon.use() OrderApplicationService->>IssuedCouponRepository: save(coupon) IssuedCouponRepository->>DB: UPDATE status=USED end alt 포인트 적용 OrderApplicationService->>UserPointRepository: findByUserId(userId) UserPointRepository->>DB: SELECT FOR UPDATE DB-->>UserPointRepository: UserPoint OrderApplicationService->>OrderApplicationService: userPoint.deduct(pointAmount) OrderApplicationService->>UserPointRepository: save(userPoint) UserPointRepository->>DB: UPDATE balance end OrderApplicationService->>OrderApplicationService: Order.createWithDiscount() OrderApplicationService->>OrderRepository: save(order) OrderRepository->>DB: INSERT OrderApplicationService-->>OrderV1Controller: OrderResult OrderV1Controller-->>Client: 201 Created좋아요 등록 + 캐시 업데이트 Flow
sequenceDiagram autonumber participant Client participant LikeV1Controller participant LikeApplicationService participant LikeRepository participant LikeCountCacheService participant Redis participant DB Client->>LikeV1Controller: POST /api/v1/products/{productId}/likes LikeV1Controller->>LikeApplicationService: addLike(userId, productId) LikeApplicationService->>LikeRepository: save(like) LikeRepository->>DB: INSERT DB-->>LikeRepository: Like LikeApplicationService->>LikeApplicationService: afterCommit callback 등록 LikeApplicationService-->>LikeV1Controller: LikeResult LikeV1Controller-->>Client: 201 Created Note over LikeApplicationService,Redis: Transaction Commit 후 LikeApplicationService->>LikeCountCacheService: increment(productId) LikeCountCacheService->>Redis: INCRBY like:count:{productId} 1 Redis-->>LikeCountCacheService: newCount LikeCountCacheService->>Redis: SADD like:dirty {productId} Redis-->>LikeCountCacheService: OK좋아요 수 조회 (Single Flight) Flow
sequenceDiagram autonumber participant Client participant ProductV1Controller participant ProductLikeCountQueryService participant LikeCountCacheService participant DistributedLockService participant Redis participant DB Client->>ProductV1Controller: GET /api/v1/products/{id} ProductV1Controller->>ProductLikeCountQueryService: getLikeCount(productId) ProductLikeCountQueryService->>LikeCountCacheService: get(productId) LikeCountCacheService->>Redis: GET like:count:{productId} alt 캐시 히트 Redis-->>LikeCountCacheService: count LikeCountCacheService-->>ProductLikeCountQueryService: count else 캐시 미스 Redis-->>LikeCountCacheService: null ProductLikeCountQueryService->>DistributedLockService: tryLock("like:load:{productId}") DistributedLockService->>Redis: SETNX lock:like:load:{productId} alt 락 획득 성공 Redis-->>DistributedLockService: true ProductLikeCountQueryService->>DB: SELECT COUNT(*) FROM likes DB-->>ProductLikeCountQueryService: count ProductLikeCountQueryService->>LikeCountCacheService: set(productId, count) LikeCountCacheService->>Redis: SET like:count:{productId} ProductLikeCountQueryService->>DistributedLockService: unlock() else 락 획득 실패 Redis-->>DistributedLockService: false ProductLikeCountQueryService->>ProductLikeCountQueryService: wait 50ms ProductLikeCountQueryService->>LikeCountCacheService: get(productId) LikeCountCacheService->>Redis: GET Redis-->>LikeCountCacheService: count end end ProductLikeCountQueryService-->>ProductV1Controller: likeCount ProductV1Controller-->>Client: ProductResponse좋아요 수 배치 동기화 Flow
sequenceDiagram autonumber participant Scheduler participant LikeCountSyncJob participant LikeCountSyncTasklet participant LikeCountBatchCacheService participant Redis participant ProductBatchRepository participant DB Scheduler->>LikeCountSyncJob: trigger (5분 주기) LikeCountSyncJob->>LikeCountSyncTasklet: execute() LikeCountSyncTasklet->>LikeCountBatchCacheService: getDirtyProductIds() LikeCountBatchCacheService->>Redis: SMEMBERS like:dirty Redis-->>LikeCountBatchCacheService: Set<productId> loop 각 productId에 대해 LikeCountSyncTasklet->>LikeCountBatchCacheService: getLikeCount(productId) LikeCountBatchCacheService->>Redis: GET like:count:{productId} Redis-->>LikeCountBatchCacheService: count LikeCountSyncTasklet->>ProductBatchRepository: updateLikeCount(productId, count) ProductBatchRepository->>DB: UPDATE products SET like_count = ? LikeCountSyncTasklet->>LikeCountBatchCacheService: removeDirtyFlag(productId) LikeCountBatchCacheService->>Redis: SREM like:dirty {productId} end LikeCountSyncTasklet-->>LikeCountSyncJob: RepeatStatus.FINISHED🤖 Generated with Claude Code
요약
변경 목적: 좋아요 테이블의 COUNT(*) 쿼리로 인한 Hot Row 경합 문제를 Redis 캐싱으로 해결하고, 쿠폰/포인트/주문 도메인 기능을 완성하여 전자상거래 핵심 기능 구현.
핵심 변경점:
리스크/주의사항:
테스트/검증 방법: