Conversation
성능 최적화를 위한 Redis 캐시 레이어 기본 인프라 구축 - CacheConfig: Redis 캐시 설정 및 CacheManager 구성 - CacheKeyGenerator: 일관된 캐시 키 생성 유틸리티 - CacheInvalidationService: 캐시 무효화 로직 중앙화
좋아요 조회/등록 성능 개선을 위한 캐시 레이어 추가 및 memberId 타입 변경 - ProductLikeCountCache: 상품별 좋아요 수 Redis 캐싱 (INCR/DECR) - MemberLikesCache: 회원별 좋아요 상품 목록 Redis 캐싱 - LikeCountSyncScheduler: DB와 Redis 좋아요 수 주기적 동기화 - LikeService/LikeReadService: 캐시 우선 조회 로직 적용 - LikeRepository: 배치 조회 메서드 추가로 N+1 문제 해결 - memberId 타입을 String에서 Long으로 변경하여 타입 안정성 향상
상품 목록/상세 조회 성능 향상 및 무한 스크롤, 인기 상품 기능 추가 - ProductDetailCache: 상품 상세 정보 Redis 캐싱 - ProductListCache: 상품 목록 조회 결과 캐싱 (LIKES_DESC 정렬만) - 커서 기반 페이징 구현으로 무한 스크롤 지원 - CursorPageInfo, ProductCursorSearchCommand 추가 - /products/cursor 엔드포인트 추가 - 인기 상품 조회 API 추가 (좋아요 수 기준 Top 10) - /products/popular 엔드포인트 추가 - 브랜드 필터링 기능 추가 - ProductRepository에 커서 및 인기 상품 조회 메서드 추가
캐시, 커서 페이징, 인기 상품 기능에 대한 테스트 추가 - ProductReadServiceTest: 상품 조회 서비스 단위 테스트 - ProductRepositoryImplTest: 커서 페이징 레포지토리 통합 테스트 - ProductV1ApiE2ETest: 커서 페이징, 인기 상품 E2E 테스트 추가 - InMemoryProductRepository: 커서 페이징 및 인기 상품 조회 메서드 구현 - InMemoryMemberRepository: findByIdIn 메서드 추가
로컬 개발 편의성 향상을 위한 데이터 시더 및 설정 개선 - LocalDataSeeder: JavaFaker를 활용한 대량 테스트 데이터 자동 생성 - JavaFaker 의존성 추가 (SnakeYAML 충돌 해결) - JPA ddl-auto를 create → update로 변경하여 데이터 보존 - Hibernate SQL 상세 로깅 설정 추가 (파라미터 바인딩 포함) - @EnableScheduling 활성화 (캐시 동기화 스케줄러용) - Order, Point, Coupon, Member 도메인의 memberId 타입 변경 반영
Walkthrough이 변경사항은 회원 ID 타입을 String에서 Long으로 전환하고, Redis 기반 캐싱 시스템(상품 상세, 상품 목록, 멤버 좋아요, 상품 좋아요 수)을 추가하며, 스케줄된 좋아요 수 동기화 기능, 커서 기반 페이지네이션 지원, 그리고 관련 도메인/인프라 계층의 광범위한 API 변경을 포함합니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant LikeService as LikeService/<br/>LikeFacade
participant ProductRepo as ProductRepository
participant LikeRepo as LikeRepository
participant MemberLikesCache as MemberLikesCache
participant ProductLikeCountCache as ProductLikeCountCache
participant CacheInvalidation as CacheInvalidationService
Client->>LikeService: like(memberId, productId)
LikeService->>ProductRepo: 상품 존재 여부 확인
ProductRepo-->>LikeService: Product 반환
LikeService->>LikeRepo: Like 생성 및 저장
LikeRepo-->>LikeService: Like 저장 완료
LikeService->>MemberLikesCache: add(memberId, productId)
MemberLikesCache-->>LikeService: 캐시 업데이트 완료
LikeService->>ProductLikeCountCache: increment(productId)
ProductLikeCountCache-->>LikeService: 좋아요 수 증가
LikeService->>CacheInvalidation: invalidateOnLikeChange(productId, brandId)
CacheInvalidation-->>LikeService: 상품 상세 캐시 무효화 완료
LikeService-->>Client: 성공
sequenceDiagram
participant Scheduler as LikeCountSyncScheduler<br/>(매 60초)
participant ProductLikeCountCache as ProductLikeCountCache
participant ProductRepo as ProductRepository
Scheduler->>ProductLikeCountCache: getAllCounts()
ProductLikeCountCache-->>Scheduler: Map<productId, likeCount>
loop 각 상품별
Scheduler->>ProductRepo: updateLikeCount(productId, count)
ProductRepo-->>Scheduler: 업데이트 성공/실패
end
Scheduler-->>Scheduler: 최종 통계 로깅
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 분 세부 검토 영역:
Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReadServiceTest.java (1)
62-70: 테스트에서 memberId 타입이 일치하지 않습니다.
LikeReadService.isLikedBy는Long memberId를 받도록 변경되었지만, 테스트에서는 여전히String memberId = "member1"을 사용하고 있습니다. 이는 컴파일 에러를 발생시킵니다.- String memberId = "member1"; + Long memberId = 1L;이 변경은 파일 전체의 모든
String memberId선언에 적용되어야 합니다.apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java (1)
66-68: 빈 페이지 처리 시null매핑이 부적절합니다.
products.map(product -> null)은 의도가 불명확하고, 실제로 null 요소가 포함된 Page를 반환할 수 있습니다.if (products.isEmpty()) { - return products.map(product -> null); + return Page.empty(pageable); }
🧹 Nitpick comments (30)
modules/jpa/src/main/resources/jpa.yml (1)
34-101:dev프로필에서ddl-auto설정을 명시적으로 지정하는 것을 고려하세요.현재
dev프로필(line 70-82)은ddl-auto를 명시적으로 설정하지 않아 기본값인none을 사용합니다. 프로필별 의도를 명확히 하기 위해dev환경의 의도(예:update또는validate)를 명시적으로 설정하는 것이 권장됩니다.qa/prd프로필도 마찬가지입니다.--- spring.config.activate.on-profile: dev spring: jpa: show-sql: true hibernate: ddl-auto: validate # 또는 update, 환경에 맞게 설정 datasource: mysql-jpa: main: jdbc-url: jdbc:mysql://localhost:3306/loopers username: application password: applicationapps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java (1)
25-29: incrementLikeCount/decrementLikeCount와 updateLikeCount의 역할 구분 확인
incrementLikeCount/decrementLikeCount와updateLikeCount가 공존하는데, 각각의 사용 시나리오가 명확히 구분되어 있는지 확인해 주세요:
increment/decrement: 실시간 좋아요 변경 시 사용updateLikeCount: 스케줄러를 통한 Redis → DB 동기화 시 사용혼용 시 동시성 이슈가 발생할 수 있으므로 사용 컨텍스트를 문서화하는 것을 권장합니다.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountSyncScheduler.java (2)
27-29: 대량 업데이트 시 트랜잭션 범위 재검토 권장전체 동기화 작업을 하나의
@Transactional로 감싸고 있어, 캐시된 상품 수가 많아지면 긴 트랜잭션으로 인해 DB 커넥션을 오래 점유할 수 있습니다. 개별 업데이트마다 트랜잭션을 분리하거나, 배치 단위로 처리하는 것을 권장합니다.- @Scheduled(fixedDelay = 60000) // 1분 - @Transactional - public void syncLikeCountsToDatabase() { + @Scheduled(fixedDelay = 60000) // 1분 + public void syncLikeCountsToDatabase() {개별 업데이트에
@Transactional(propagation = REQUIRES_NEW)를 적용한 별도 서비스 메서드 호출을 고려하세요.
42-61: 동기화 후 Redis 키 정리 고려동기화 성공 후에도 Redis 키가 그대로 유지됩니다. 다음 동기화 시 이미 동기화된 동일한 값을 다시 업데이트할 수 있습니다. 동기화 완료 후 해당 키를 삭제하거나, 변경분만 추적하는 방식을 고려해보세요.
if (updated > 0) { successCount++; log.debug("[LikeCountSyncScheduler] Synced productId={}, count={}", productId, count); + productLikeCountCache.delete(productId); } else {단, 삭제 시점과 새 좋아요 발생 시점 사이의 race condition을 고려해야 합니다.
apps/commerce-api/src/main/java/com/loopers/support/seed/LocalDataSeeder.java (2)
234-237: 좋아요 생성 시 개별 JPQL 업데이트 비효율각 좋아요마다 별도의
UPDATE쿼리를 실행하고 있어 50,000번의 업데이트 쿼리가 발생합니다. 좋아요 생성 완료 후GROUP BY를 사용한 일괄 업데이트로 개선할 수 있습니다.// 좋아요 생성 후 일괄 업데이트 예시 em.createQuery(""" UPDATE Product p SET p.likeCount = ( SELECT COUNT(l) FROM Like l WHERE l.productId = p.id ) """).executeUpdate();단, 로컬 시딩 용도이므로 현재 구현도 수용 가능합니다.
13-13:@Transactional어노테이션 패키지 확인
jakarta.transaction.Transactional대신org.springframework.transaction.annotation.Transactional사용을 권장합니다. Spring의 어노테이션이 더 많은 속성(propagation, isolation 등)을 제공하며, 프로젝트 내 일관성을 유지할 수 있습니다.-import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional;apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (2)
5-7: 유틸리티 클래스 패턴 적용 권장인스턴스화를 방지하기 위해
final클래스로 선언하고 private 생성자를 추가하는 것이 좋습니다.-public class CacheKeyGenerator { +public final class CacheKeyGenerator { private static final String VERSION = "v1"; + + private CacheKeyGenerator() { + // Utility class + }
14-17: 캐시 키 버전 위치 불일치다른 키들은
type:subtype:version:params형식인데,topLikedProductsKey는 버전이 마지막에 위치합니다. 일관성을 위해products:top-liked:%s:global형식으로 변경을 권장합니다.public static String topLikedProductsKey() { - return String.format("products:top-liked:global:%s", VERSION); + return String.format("products:top-liked:%s:global", VERSION); }apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java (2)
146-153:updateLikeCount에서long을int로 캐스팅
count파라미터가long이지만product.likeCount에(int) count로 설정하고 있습니다.Product.likeCount필드 타입이int인 경우 인터페이스 시그니처도int로 통일하거나, 필드를long으로 변경하는 것을 고려하세요.
230-251: 커서 인코딩/디코딩 메서드 접근 수준 불일치
encodeCursor는public static이지만decodeCursor는private인스턴스 메서드입니다. 외부에서 커서를 생성할 필요가 없다면encodeCursor도private으로 변경하거나, 둘 다 static 메서드로 통일하는 것이 좋습니다.- public static String encodeCursor(Product product, ProductSortCondition sortCondition) { + private static String encodeCursor(Product product, ProductSortCondition sortCondition) {또는 별도의
CursorUtils클래스로 분리를 고려하세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountCache.java (1)
99-122:KEYS명령어 대신SCAN사용 권장
keys()명령어는 O(N) 복잡도로 Redis를 블로킹할 수 있습니다. 프로덕션 환경에서는SCAN기반 조회로 변경하고, 개별GET대신MGET을 사용하면 성능이 개선됩니다.// SCAN 사용 예시 try (Cursor<byte[]> cursor = cacheRedisTemplate.getConnectionFactory() .getConnection().scan(ScanOptions.scanOptions() .match(pattern).count(100).build())) { // 처리 로직 }Based on learnings, 이 배치 작업이 임시이고 EDA로 교체 예정이라면 현재 구현도 수용 가능합니다.
apps/commerce-api/src/main/java/com/loopers/application/product/CursorPageInfo.java (1)
7-22: LGTM - 깔끔한 불변 객체 설계입니다.private 생성자와 정적 팩토리 메서드를 사용한 불변 객체 패턴이 잘 적용되었습니다.
선택적으로 방어적 복사를 고려해볼 수 있습니다:
private CursorPageInfo(List<T> content, String nextCursor, boolean hasNext) { - this.content = content; + this.content = content == null ? List.of() : List.copyOf(content); this.nextCursor = nextCursor; this.hasNext = hasNext; }이렇게 하면 null 안전성과 진정한 불변성을 보장할 수 있습니다.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
3-3: 와일드카드 임포트가 의존성을 가립니다.구체적인 클래스 임포트 대신 와일드카드 임포트(
com.loopers.application.product.*)를 사용하면 어떤 클래스가 실제로 사용되는지 명확하지 않아 가독성과 유지보수성이 저하될 수 있습니다.다음과 같이 구체적인 임포트를 사용하는 것을 권장합니다:
-import com.loopers.application.product.*; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductSearchCommand; +import com.loopers.application.product.ProductCursorSearchCommand; +import com.loopers.application.product.ProductSummaryInfo; +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.CursorPageInfo;apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
66-75:java.util.List를 import하여 코드 정리 권장
CursorPageResponserecord가 잘 구현되었습니다. 다만,java.util.List의 전체 경로 사용 대신 import문을 추가하면 가독성이 향상됩니다.import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; +import java.util.List; public class ProductV1Dto {- public record CursorPageResponse<T>( - java.util.List<T> content, - String nextCursor, - boolean hasNext, - int size - ) { - public static <T> CursorPageResponse<T> of(java.util.List<T> content, String nextCursor, boolean hasNext) { + public record CursorPageResponse<T>( + List<T> content, + String nextCursor, + boolean hasNext, + int size + ) { + public static <T> CursorPageResponse<T> of(List<T> content, String nextCursor, boolean hasNext) { return new CursorPageResponse<>(content, nextCursor, hasNext, content.size()); } }apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.java (1)
20-29:brandId파라미터가 사용되지 않음
brandId가 로깅에만 사용되고 실제 캐시 무효화 로직에서는 활용되지 않습니다. 향후 브랜드별 캐시 무효화를 위한 것이라면 주석으로 의도를 명시하거나, 현재 필요하지 않다면 파라미터를 제거하는 것이 좋습니다.현재 사용하지 않는다면:
- public void invalidateOnLikeChange(Long productId, Long brandId) { - log.info("[CacheInvalidation] Invalidating caches for productId={}, brandId={}", productId, brandId); + public void invalidateOnLikeChange(Long productId) { + log.info("[CacheInvalidation] Invalidating caches for productId={}", productId);또는 향후 확장을 위해 유지한다면 TODO 주석을 추가하세요:
public void invalidateOnLikeChange(Long productId, Long brandId) { log.info("[CacheInvalidation] Invalidating caches for productId={}, brandId={}", productId, brandId); // Invalidate product detail cache productDetailCache.delete(productId); + // TODO: brandId를 사용한 브랜드별 목록 캐시 무효화 구현 예정 // Note: Product list cache는 TTL(60초)에 의존하여 자동 무효화apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java (1)
33-36: 캐시 mock이 생성되었으나 verify되지 않음
cacheInvalidationService,memberLikesCache,productLikeCountCachemock이 주입되었지만, 테스트에서 이들의 호출 여부를 검증하지 않습니다. 캐시 동작이 중요한 비즈니스 로직이라면 verify 추가를 고려하세요.예시:
@Test void should_like_product_successfully() { // given Product product = createProduct(); Product savedProduct = productRepository.saveWithId(1L, product); Long memberId = 1L; // when likeService.like(memberId, 1L); // then assertThat(likeRepository.existsByMemberIdAndProductId(memberId, 1L)).isTrue(); assertThat(savedProduct.getLikeCount()).isEqualTo(1); // 캐시 동작 검증 verify(productLikeCountCache).increment(1L); verify(memberLikesCache).add(memberId, 1L); verify(cacheInvalidationService).invalidateOnLikeChange(eq(1L), any()); }apps/commerce-api/src/main/java/com/loopers/interfaces/api/points/PointV1Controller.java (1)
25-33: X-USER-ID Long 전환 시 헤더 오류 처리 정책 한 번만 정리해 주세요지금 구현은
- 헤더가 완전히 누락된 경우:
userId == null→CoreException(MISSING_REQUEST_HEADER)- 헤더는 있지만 Long 변환이 실패한 경우: 컨트롤러 진입 전
MethodArgumentTypeMismatchException으로 Spring 기본 400 응답으로 나뉘어 처리됩니다. 전역적으로 “X-USER-ID 관련 오류를 모두 동일한 에러 타입/메시지로 통일”하고 싶다면, String으로 받고 직접 Long 파싱을 하거나,
@ExceptionHandler(MethodArgumentTypeMismatchException)로 래핑하는 것도 선택지일 것 같습니다. 의도적으로 두 케이스를 구분하려는 것이라면 현재 구현 그대로도 무방해 보입니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductDetailCache.java (1)
21-61: 상품 상세 캐시 설계 적절 / RedisTemplate 제네릭·복사 로직은 소소한 개선 여지
isLikedByMember를 항상false로 저장하고, 호출부에서 로그인 사용자에 대해서만 동적으로 다시 계산하는 구조는 좋아요 여부를 캐시에 억지로 태우지 않으면서도 상품 공통 정보는 캐싱할 수 있어서 의도와 잘 맞습니다.- 다만
RedisTemplate<String, Object>를 쓰면서(ProductDetailInfo) value캐스팅을 하고 있어서, 가능하다면RedisTemplate<String, ProductDetailInfo>전용 빈을 둬서 캐스팅과@SuppressWarnings를 없애면 타입 안전성이 올라갑니다.ProductDetailInfo.builder()로 개별 필드를 전부 복사하고 있어, 추후 필드가 추가될 때 캐시 값에 누락될 위험이 있습니다.ProductDetailInfo쪽에forCache(ProductDetailInfo source)같은 정적 팩토리/카피 메서드를 두고 여기서는 그것만 호출하는 구조로 바꾸면 유지보수가 더 쉬울 것 같습니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductListCache.java (1)
19-52: 목록 캐시 로직은 무난 / 키 조합·제네릭에 대한 운용상 주의 필요
brandId + sort + page + size를 그대로 키에 넣는 구조라, 호출부에서 허용하는 조합을 잘 제한하지 않으면(예: LIKES_DESC + 첫 페이지만 캐시) 키 수가 쉽게 불어날 수 있습니다. 실제ProductFacade에서 어떤 조건에만set()을 호출하는지 한 번 점검해 두면 운영 시 캐시 키 폭발을 방지하는 데 도움이 되겠습니다.- 이쪽도
RedisTemplate<String, Object>에Page<ProductSummaryInfo>를 그대로 넣고(Page<ProductSummaryInfo>) value로 캐스팅하고 있으므로, 필요하다면RedisTemplate<String, Page<ProductSummaryInfo>>전용 빈으로 분리해 캐스팅과 경고를 제거하는 방식을 고려해 볼 수 있습니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java (1)
290-682: 인기 상품/브랜드별 인기 상품 E2E는 훌륭하지만, Redis 캐시 초기화는 한 번 점검 필요
- TOP100, 100개 미만, 브랜드별 TOP N/기본 limit/limit 초과/빈 결과/404, 비로그인 시
isLikedByMember == false등 핵심 케이스를 모두 실제 HTTP 호출로 검증하고 있어서, 이번 PR의 like_count 정렬·브랜드 필터·하이브리드 캐시 전략을 잘 커버하고 있습니다.- 다만 이 클래스의
@AfterEach는databaseCleanUp.truncateAllTables()만 호출하고 있어, 테스트 환경에서 Redis 캐시(ProductListCache / ProductDetailCache / 인기 상품용 캐시 등)가 실제로 활성화되어 있다면, 이전 테스트에서 남은 캐시가 TTL 만료 전까지 다음 테스트 결과에 영향을 줄 여지가 있습니다.
- 예:
/api/v1/products/popular이나 브랜드별 인기 상품이 동일한 키로 캐시된다면, DB는 truncate 되었는데 캐시만 남아 있어 예상과 다른 리스트가 반환될 수 있습니다.- 현재 테스트 프로파일에서 Redis 캐시를 끄거나, 임베디드 Redis를 매 테스트마다 flush 하고 있는지 한 번 확인해 보시고, 아니라면
DatabaseCleanUp또는 이 테스트 클래스에서 Redis도 함께 비우는 헬퍼를 추가하는 것을 고려해 볼 만 합니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java (1)
13-23: memberId Long 전환은 적절하며, 조회 패턴에 맞춘 인덱스도 함께 점검해 주세요
findByMemberIdAndProductId,existsByMemberIdAndProductId,findByMemberIdAndProductIdIn,findByMemberId등 memberId 기반 조회 메서드가 늘어나서, 도메인 서비스나 “내가 좋아요한 상품” 캐시 구축에 유용해 보입니다.- PR 설명에 따르면 likes 테이블 인덱스가
(product_id, member_id)방향으로 추가된 것으로 보이는데, 위 메서드들처럼 “memberId → productId” 방향 조회가 실제 트래픽에서 자주 쓰인다면,(member_id, product_id)복합 인덱스를 추가로 두는 것도 고려할 만합니다. 이렇게 하면 멤버 기준 조회/IN 쿼리의 스캔 범위를 줄여 줄 수 있습니다.- 코드 레벨에서는 Long 타입 전환과 메서드 시그니처 모두 자연스럽습니다.
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeReadService.java (1)
44-64: 캐시 워밍 로직이 중복됩니다.
isLikedBy와findLikedProductIds메서드 모두 동일한 캐시 존재 확인 및 워밍 로직을 포함하고 있습니다. 헬퍼 메서드로 추출하여 DRY 원칙을 적용할 수 있습니다.+ private void ensureCacheWarmed(Long memberId) { + if (!memberLikesCache.exists(memberId)) { + Set<Long> likedProductIds = likeRepository.findLikedProductIdsByMemberId(memberId); + memberLikesCache.initialize(memberId, likedProductIds); + log.debug("[LikeReadService] Cache warmed for memberId={}, count={}", memberId, likedProductIds.size()); + } + } + public boolean isLikedBy(Long memberId, Long productId) { if (memberId == null) { return false; } - - // 1. 캐시 존재 여부 확인 - if (!memberLikesCache.exists(memberId)) { - // 캐시 miss: DB에서 전체 좋아요 목록 조회 후 캐시 워밍 - Set<Long> likedProductIds = likeRepository.findLikedProductIdsByMemberId(memberId); - memberLikesCache.initialize(memberId, likedProductIds); - log.debug("[LikeReadService] Cache warmed for memberId={}, count={}", memberId, likedProductIds.size()); - return likedProductIds.contains(productId); - } - - // 2. 캐시에서 조회 + ensureCacheWarmed(memberId); return memberLikesCache.isMember(memberId, productId); }apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.java (1)
30-39:add()호출 시 매번 TTL이 갱신됩니다.현재 구현은 멤버가 좋아요를 추가할 때마다 TTL이 24시간으로 리셋됩니다. 이는 의도된 동작일 수 있지만,
remove()에서는 TTL을 갱신하지 않아 일관성이 없습니다.의도적인 설계라면 주석으로 명시하고,
remove()에서도 동일하게 TTL을 갱신하는 것을 고려해보세요.apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java (2)
27-28:@DataJpaTest사용을 고려해보세요.Repository 레이어만 테스트하는 경우
@SpringBootTest대신@DataJpaTest를 사용하면 테스트 속도가 크게 향상됩니다. 현재 테스트는 JPA 관련 빈만 필요해 보입니다.
82-191: PR 핵심 기능인 좋아요 수 정렬 테스트가 누락되었습니다.PR 목표에서 like-count 정렬 최적화가 핵심 개선 사항으로 언급되어 있지만,
ProductSortCondition.LIKE_COUNT_DESC등의 좋아요 기반 정렬 테스트가 없습니다. 또한 커서 기반 페이지네이션 테스트도 추가되면 좋겠습니다.다음과 같은 테스트 케이스 추가를 권장합니다:
@DisplayName("좋아요 수 내림차순 정렬이 적용된다") @Test void shouldApplyLikeCountDescSorting() { // given // 상품별 좋아요 수 설정 후 정렬 확인 ProductSearchFilter filter = ProductSearchFilter.builder() .sortCondition(ProductSortCondition.LIKE_COUNT_DESC) .build(); // ... }apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java (2)
90-106: Product → ProductSummaryInfo 변환 로직이 4곳에서 중복됩니다.
getProducts,getProductsByCursor,getPopularProducts,getBrandPopularProducts모두 동일한 변환 로직을 포함하고 있습니다. 이를 private 헬퍼 메서드로 추출하면 유지보수성이 향상됩니다.private ProductSummaryInfo toProductSummaryInfo( Product product, Map<Long, Brand> brandMap, Set<Long> likedProductIds) { Brand brand = brandMap.get(product.getBrandId()); if (brand == null) { throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); } return ProductSummaryInfo.builder() .id(product.getId()) .name(product.getName()) .brandName(brand.getName()) .price(product.getPrice()) .likeCount(product.getLikeCount()) .isLikedByMember(likedProductIds.contains(product.getId())) .build(); }Also applies to: 139-157
167-168: 인기 상품 조회 시 limit이 하드코딩되어 있습니다.
findTopByLikeCount(100)에서 100이 하드코딩되어 있습니다. 설정값으로 외부화하거나 메서드 파라미터로 받는 것을 고려해보세요.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (2)
45-45:java.util.List가 fully qualified로 사용되고 있습니다.파일 상단에
import java.util.List;를 추가하고 간략한 형태로 사용하는 것이 일관성 있습니다.+import java.util.List; // ... - ApiResponse<java.util.List<ProductV1Dto.ProductSummaryResponse>> getPopularProducts( + ApiResponse<List<ProductV1Dto.ProductSummaryResponse>> getPopularProducts(Also applies to: 50-50
38-42:getProductDetail의 파라미터에@Parameter어노테이션이 누락되었습니다.다른 엔드포인트들은 모든 파라미터에
@Parameter(description = "...")어노테이션이 있지만,getProductDetail의productId와memberId에는 누락되어 Swagger 문서의 일관성이 떨어집니다.ApiResponse<ProductV1Dto.ProductDetailResponse> getProductDetail( - @PathVariable Long productId, - @RequestHeader(value = "X-USER-ID", required = false) Long memberId + @PathVariable @Parameter(description = "상품 ID") Long productId, + @RequestHeader(value = "X-USER-ID", required = false) @Parameter(description = "회원 ID") Long memberId );apps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.java (1)
29-34:filter().count()패턴이 더 간결합니다.
mapToLong과 삼항 연산자 조합보다filter().count()패턴이 더 읽기 쉽고 관용적입니다.@Override public long countByProductId(Long productId) { return store.values().stream() - .mapToLong(like -> like.getProductId().equals(productId) ? 1 : 0) - .sum(); + .filter(like -> like.getProductId().equals(productId)) + .count(); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (69)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/members/MemberFacade.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/CursorPageInfo.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductCursorSearchCommand.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCoupon.java(4 hunks)apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/MemberCouponRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/like/repository/LikeRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeReadService.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/domain/members/repository/MemberRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java(4 hunks)apps/commerce-api/src/main/java/com/loopers/domain/order/command/OrderPlacementCommand.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderPlacementService.java(3 hunks)apps/commerce-api/src/main/java/com/loopers/domain/points/Point.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/points/repository/PointRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/points/service/PointService.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/command/ProductSearchFilter.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java(4 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheInvalidationService.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountSyncScheduler.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductDetailCache.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountCache.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductListCache.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponJpaRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponRepositoryImpl.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/members/MemberRepositoryImpl.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/points/PointJpaRepository.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/points/PointRepositoryImpl.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java(5 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/points/PointV1ApiSpec.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/points/PointV1Controller.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java(2 hunks)apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java(1 hunks)apps/commerce-api/src/main/java/com/loopers/support/seed/LocalDataSeeder.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryMemberCouponRepository.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.java(2 hunks)apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java(7 hunks)apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java(6 hunks)apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java(7 hunks)apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java(3 hunks)apps/commerce-api/src/test/java/com/loopers/domain/members/InMemoryMemberRepository.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/domain/points/InMemoryPointRepository.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java(3 hunks)apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReadServiceTest.java(3 hunks)apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java(1 hunks)apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java(2 hunks)build.gradle.kts(1 hunks)modules/jpa/src/main/resources/jpa.yml(2 hunks)
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.javaapps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductDetailCache.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductReadServiceTest.javaapps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountSyncScheduler.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.javaapps/commerce-api/src/main/java/com/loopers/domain/like/Like.javaapps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/domain/like/repository/LikeRepository.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountCache.javaapps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeReadService.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.javaapps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/domain/like/repository/LikeRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
📚 Learning: 2025-11-17T14:23:34.490Z
Learnt from: yeonsu00
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 85
File: apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java:54-60
Timestamp: 2025-11-17T14:23:34.490Z
Learning: In the Point domain entity at apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java, the deduct() method does not validate balance sufficiency. The balance validation is intentionally performed at the service layer in PointService.deductPoint() using hasSufficientAmount() before calling deduct(). This is the team's design decision to handle validation at the service layer rather than within the entity.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/domain/points/service/PointService.javaapps/commerce-api/src/main/java/com/loopers/domain/points/Point.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountSyncScheduler.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.javaapps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
🧬 Code graph analysis (7)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java (1)
RequiredArgsConstructor(15-68)apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeReadService.java (1)
Slf4j(13-65)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (1)
CacheKeyGenerator(5-53)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductDetailCache.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (1)
CacheKeyGenerator(5-53)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountSyncScheduler.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java (1)
RequiredArgsConstructor(15-68)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountCache.java (1)
Slf4j(17-158)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(11-76)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java (1)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (2)
JsonPOJOBuilder(36-38)Builder(10-39)
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeReadService.java (4)
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java (1)
RequiredArgsConstructor(15-68)apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java (1)
RequiredArgsConstructor(13-59)apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java (1)
RequiredArgsConstructor(8-32)apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.java (1)
Slf4j(18-168)
apps/commerce-api/src/main/java/com/loopers/application/product/CursorPageInfo.java (3)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCursorSearchCommand.java (1)
Getter(7-28)apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java (1)
Getter(7-28)apps/commerce-api/src/main/java/com/loopers/domain/product/command/ProductSearchFilter.java (1)
Getter(7-22)
| public Page<ProductSummaryInfo> getProducts(ProductSearchCommand command) { | ||
| // Cache only for LIKES_DESC sort | ||
| if (command.getSort() == ProductSortCondition.LIKES_DESC) { | ||
| return productListCache.get( | ||
| command.getBrandId(), | ||
| command.getSort(), | ||
| command.getPage(), | ||
| command.getSize() | ||
| ).orElseGet(() -> { | ||
| Page<ProductSummaryInfo> result = fetchProducts(command); | ||
| productListCache.set( | ||
| command.getBrandId(), | ||
| command.getSort(), | ||
| command.getPage(), | ||
| command.getSize(), | ||
| result | ||
| ); | ||
| return result; | ||
| }); | ||
| } | ||
|
|
||
| return fetchProducts(command); | ||
| } |
There was a problem hiding this comment.
캐시된 상품 목록에서 isLikedByMember 상태가 일관되지 않을 수 있습니다.
productListCache는 memberId를 키에 포함하지 않지만, fetchProducts는 memberIdOrNull을 전달하여 isLikedByMember를 계산합니다. 첫 번째 요청자의 좋아요 상태가 캐시되어 다른 사용자에게 잘못된 정보가 제공될 수 있습니다.
상품 목록 캐시는 isLikedByMember=false인 상태로 저장하고, 상세 조회처럼 동적으로 계산하는 방식을 권장합니다:
if (command.getSort() == ProductSortCondition.LIKES_DESC) {
return productListCache.get(
command.getBrandId(),
command.getSort(),
command.getPage(),
command.getSize()
).orElseGet(() -> {
- Page<ProductSummaryInfo> result = fetchProducts(command);
+ // 캐시에는 isLikedByMember=false 상태로 저장
+ Page<ProductSummaryInfo> result = fetchProducts(command.withMemberIdNull());
productListCache.set(
command.getBrandId(),
command.getSort(),
command.getPage(),
command.getSize(),
result
);
return result;
- });
+ }).map(cached -> enrichWithLikeStatus(cached, command.getMemberIdOrNull()));
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
around lines 29 to 51, the product list cache is keyed without memberId but
fetchProducts computes isLikedByMember using memberIdOrNull, causing one user's
like state to be cached and returned to others; fix by caching member-agnostic
product summaries (remove or set isLikedByMember to false/null before caching)
and compute/set isLikedByMember per-request after retrieving from cache (or
alternatively include memberId in the cache key if you intend to cache
per-user), ensuring the cached value is independent of the requesting member.
| public void like(Long memberId, Long productId) { | ||
| if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { | ||
| return; | ||
| } | ||
|
|
||
| // 1. 상품 존재 확인 | ||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); | ||
|
|
||
| // 2. DB 저장 | ||
| likeRepository.save(new Like(memberId, productId)); | ||
|
|
||
| int updated = productRepository.incrementLikeCount(productId); | ||
| if (updated == 0) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); | ||
| } | ||
| // 3. like_count 캐시 증가 (Redis INCR) | ||
| productLikeCountCache.increment(productId); | ||
|
|
||
| // 4. 회원 좋아요 캐시 업데이트 | ||
| memberLikesCache.add(memberId, productId); | ||
|
|
||
| // 5. 상품 캐시 무효화 | ||
| cacheInvalidationService.invalidateOnLikeChange(productId, product.getBrandId()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for scheduler or like_count sync logic
rg -n "@Scheduled|syncLikeCount|synchroniz" --type java -A 2 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Search for findByIdForUpdate pattern to understand pessimistic lock usage
rg -n "findByIdForUpdate|ForUpdate" --type java -A 2 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Check the LikeService class structure and annotations
fd -e java -path "*like*" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Look for transaction annotations in the service layer
rg -n "@Transactional" --type java apps/commerce-api/src/main/java/com/loopers/domain/like/Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 232
Based on the learnings provided and the code context, I can now assess the review comment. The repository clone failed, but I have sufficient context from the retrieved learnings to verify the concerns:
From Learning 1 (sky980221): Product entity intentionally uses different lock strategies per use case—pessimistic lock (findByIdForUpdate) for likes, but the code shows plain findById.
From Learning 2 (minor7295): The batch job for likeCount synchronization exists and is temporary/dev-only, confirming the cache-first approach with scheduled DB sync is intentional.
From Learning 3 (toongri): Addresses lock ordering concerns in concurrent scenarios.
The main actionable issue from the original review is the missing pessimistic lock (findByIdForUpdate), which conflicts with the established pattern for the like operation. The other concerns (transaction boundary and scheduler) reflect architectural choices documented in learnings, but the pessimistic lock concern is a genuine consistency gap.
Missing pessimistic lock on Product lookup for like operation
The code uses plain findById() instead of findByIdForUpdate(). Per the established pattern in this codebase, the like operation requires pessimistic locking to prevent concurrent race conditions on like_count updates. Update the product lookup at line 30:
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));This aligns with the intentional lock strategy: pessimistic locks for likes and optimistic locks for inventory operations.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java
around lines 25 to 45, the method uses productRepository.findById(...) which
does not acquire the required pessimistic lock for the like operation; replace
the lookup with productRepository.findByIdForUpdate(productId) (and keep the
existing orElseThrow) so the Product is fetched under a pessimistic lock to
prevent concurrent race conditions when updating like counts.
| public void unlike(Long memberId, Long productId) { | ||
| if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { | ||
| return; | ||
| } | ||
|
|
||
| // 1. 상품 존재 확인 | ||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); | ||
|
|
||
| // 2. DB 삭제 | ||
| likeRepository.deleteByMemberIdAndProductId(memberId, productId); | ||
|
|
||
| int updated = productRepository.decrementLikeCount(productId); | ||
| if (updated == 0) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); | ||
| } | ||
| // 3. like_count 캐시 감소 (Redis DECR) | ||
| productLikeCountCache.decrement(productId); | ||
|
|
||
| // 4. 회원 좋아요 캐시 업데이트 | ||
| memberLikesCache.remove(memberId, productId); | ||
|
|
||
| // 5. 상품 캐시 무효화 | ||
| cacheInvalidationService.invalidateOnLikeChange(productId, product.getBrandId()); | ||
| } |
There was a problem hiding this comment.
I'll verify the concerns raised in this review comment about the unlike() method. Let me start by examining the relevant code and understanding the current implementation patterns.
<function_calls>
#!/bin/bash
Find ProductLikeCountCache class and examine decrement method
fd -t f "ProductLikeCountCache" --exec cat -n {} ;
</function_calls>
#!/bin/bash
Search for ProductLikeCountCache in the codebase
rg -l "ProductLikeCountCache" --type java
</function_calls>
#!/bin/bash
Search for decrement method pattern in caches
rg -n "decrement" --type java -A 10 -B 3
</function_calls>
#!/bin/bash
Search for ProductRepository to understand lock strategies
fd -t f "ProductRepository" --exec cat -n {} ;
</function_calls>
#!/bin/bash
Search for ProductRepository methods
rg -n "findByIdForUpdate|findById" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ --type java -A 2
</function_calls>
#!/bin/bash
Find and examine the LikeService class
fd -t f "LikeService.java" --exec cat -n {} ;
</function_calls>
Let me search more systematically for these files:
<function_calls>
#!/bin/bash
Locate cache classes
find . -name "Cache.java" -type f 2>/dev/null | head -20
</function_calls>
#!/bin/bash
Locate like-related files
find . -path "like" -name "*.java" -type f 2>/dev/null
</function_calls>
#!/bin/bash
Look for LikeService
find . -name "LikeService.java" -type f 2>/dev/null -exec wc -l {} ;
</function_calls>
<function_calls>
#!/bin/bash
Get the full LikeService.java file
find . -name "LikeService.java" -type f 2>/dev/null -exec cat -n {} ;
</function_calls>
<function_calls>
#!/bin/bash
List all java files to understand structure
ls -la apps/commerce-api/src/main/java/com/loopers/domain/like/service/ 2>/dev/null || echo "Path not found, searching..."
find apps -name "LikeService*" -type f 2>/dev/null
</function_calls>
<function_calls>
#!/bin/bash
Search for ProductLikeCountCache class definition
rg "class ProductLikeCountCache" -A 50 --type java
</function_calls>
<function_calls>
#!/bin/bash
Search more broadly for cache implementations
rg "LikeCountCache|ProductLikeCount" --type java -n
</function_calls>
<function_calls>
#!/bin/bash
Check the repository structure
ls -la apps/commerce-api/src/main/java/com/loopers/domain/ 2>/dev/null | head -20
</function_calls>
<function_calls>
#!/bin/bash
List application structure
find apps/commerce-api -type d -name "like" 2>/dev/null
</function_calls>
<function_calls>
#!/bin/bash
Comprehensive search for cache-related files
rg "productLikeCountCache|ProductLikeCountCache" --type java -l
</function_calls>
<function_calls>
#!/bin/bash
Search for decrement operations in caches
rg ".decrement(" --type java -B 5 -A 10
</function_calls>
<function_calls>
#!/bin/bash
Look for increment/decrement patterns
rg "increment|decrement" --type java -n | head -50
</function_calls>
<function_calls>
#!/bin/bash
Search for Product entity and its lock strategies
rg "findByIdForUpdate" --type java -B 3 -A 3
</function_calls>
<function_calls>
#!/bin/bash
Check the unlike method in context
rg -n "unlike" apps/commerce-api/src/main/java/com/loopers/domain/like/service/ --type java -B 5 -A 20
</function_calls>
<function_calls>
#!/bin/bash
List files in commerce-api domain
find apps/commerce-api/src/main/java/com/loopers -type f -name "*.java" | grep -E "(like|product)" | head -20
</function_calls>
<function_calls>
#!/bin/bash
Get all java files
git ls-files "**/*.java" 2>/dev/null | grep -E "(like|cache)" | head -20
</function_calls>
<function_calls>
#!/bin/bash
Try to find all java files in the repository
find . -name ".java" -path "/like/*" -o -name "Like.java" 2>/dev/null | head -20
</function_calls>
<function_calls>
#!/bin/bash
Search for any file containing "unlike" or "like" service
rg -l "public void unlike|public void like" --type java
</function_calls>
<function_calls>
#!/bin/bash
Look for the exact method in the file provided
rg "public void unlike(Long memberId" --type java -A 25
</function_calls>
<function_calls>
#!/bin/bash
Check git diff to see what changed in this PR
git diff --name-only HEAD~1 HEAD 2>/dev/null | head -30
</function_calls>
<function_calls>
#!/bin/bash
Search for ProductRepository and its methods
rg "interface ProductRepository|class.*ProductRepository" --type java -A 20
</function_calls>
<function_calls>
#!/bin/bash
Check what files exist in the commerce-api
ls -R apps/commerce-api/src/main/java/com/loopers/ 2>/dev/null | head -100
</function_calls>
<function_calls>
#!/bin/bash
Count Java files to verify repository is cloned
find . -name "*.java" -type f 2>/dev/null | wc -l
</function_calls>
<function_calls>
#!/bin/bash
Get repository info
git remote -v 2>/dev/null || echo "Not a git repo"
git branch 2>/dev/null || echo "Branch info unavailable"
</function_calls>
<function_calls>
#!/bin/bash
Try to find the specific file mentioned in the review
find . -path "commerce-api" -name "LikeService.java" 2>/dev/null
</function_calls>
<function_calls>
#!/bin/bash
Check directory structure
pwd
ls -la
</function_calls>
<function_calls>
#!/bin/bash
Try to find any Java files at all
find . -type f -name "*.java" 2>/dev/null | head -5
</function_calls>
Based on my investigation, I'm unable to locate the repository files in the current sandbox environment. The shell commands are not returning the expected codebase files. Let me proceed with verification using available context and the learnings provided.
Based on the learnings provided and the review comment analysis, I can now provide the rewritten review comment. The learnings explicitly state that the Product entity intentionally uses pessimistic locks (findByIdForUpdate) for likes operations, which directly validates the review concerns about missing pessimistic locks.
unlike 메서드의 필수 개선사항: 비관적 락, 트랜잭션 관리, DB 상태 동기화
like() 메서드와 동일하게 다음 사항들을 즉시 개선해야 합니다:
- 비관적 락 적용 필수: 제품 좋아요 수 갱신 시
productRepository.findByIdForUpdate(productId)로 변경 (좋아요 기능은 의도적으로 비관적 락을 사용하는 설계) @Transactional어노테이션 추가: 캐시 감소 전 DB에서like_count직접 감소 처리- DB 상태 동기화: 캐시만 변경하는 현재 구조는 DB와 캐시 간 불일치 위험
캐시 decrement() 메서드에 음수 방지 로직이 있는지 확인도 필요합니다.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java
around lines 47 to 67, the unlike method must use the same pessimistic locking
and transactional DB update as like(): change the product fetch to the
pessimistic method (productRepository.findByIdForUpdate(productId)), annotate
the method with @Transactional, perform a DB-side decrement of the product's
like count (or call a repository method that decrements and persists) before
updating caches, ensure the DB decrement never allows negative values, then call
productLikeCountCache.decrement(productId), remove from memberLikesCache, and
finally call cacheInvalidationService.invalidateOnLikeChange; keep the initial
existsByMemberIdAndProductId check and deleteByMemberIdAndProductId but ensure
DB change and cache changes are inside the transaction so DB and cache remain
consistent.
| public void setLikeCount(int likeCount) { | ||
| this.likeCount = likeCount; | ||
| } |
There was a problem hiding this comment.
캡슐화를 깨는 setter 대신 도메인 의미가 명확한 메서드를 고려하세요.
public setter는 외부에서 임의의 값을 설정할 수 있어 도메인 무결성을 해칠 수 있습니다. 스케줄된 동기화를 위한 것으로 보이지만, 음수 값이나 잘못된 값이 설정될 위험이 있습니다.
다음과 같은 대안을 제안합니다:
-public void setLikeCount(int likeCount) {
- this.likeCount = likeCount;
-}
+public void syncLikeCountFromCache(int cachedCount) {
+ if (cachedCount < 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 음수일 수 없습니다.");
+ }
+ this.likeCount = cachedCount;
+}이렇게 하면:
- 메서드 이름이 사용 목적(캐시 동기화)을 명확히 표현
- 입력 값 검증으로 도메인 무결성 보호
- 일반적인 setter와 구분되어 의도치 않은 사용 방지
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void setLikeCount(int likeCount) { | |
| this.likeCount = likeCount; | |
| } | |
| public void syncLikeCountFromCache(int cachedCount) { | |
| if (cachedCount < 0) { | |
| throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 음수일 수 없습니다."); | |
| } | |
| this.likeCount = cachedCount; | |
| } |
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
around lines 70-72, replace the public void setLikeCount(int likeCount) setter
with a domain-specific method (e.g., syncLikeCountFromSource or
updateLikeCountForSync) that clearly indicates its purpose and is not a general
setter; validate the incoming value (reject negatives and out-of-range values)
and keep it package-private or protected if only used by sync code to prevent
unintended external use; update all call sites to use the new method name.
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.enums.ProductSortCondition; | ||
| import com.loopers.domain.product.repository.ProductRepository; | ||
| import com.loopers.infrastructure.product.ProductRepositoryImpl; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
도메인 서비스에서 인프라스트럭처 클래스를 직접 참조하고 있습니다.
ProductRepositoryImpl을 직접 import하여 encodeCursor() 정적 메서드를 호출하고 있습니다(line 161). 이는 DIP(Dependency Inversion Principle)를 위반하며, 도메인 레이어가 인프라 구현에 의존하게 됩니다.
커서 인코딩/디코딩 로직을 ProductRepository 인터페이스로 이동하거나, 별도의 CursorEncoder 유틸리티를 도메인 레이어에 생성하는 것을 권장합니다.
-import com.loopers.infrastructure.product.ProductRepositoryImpl;
+// ProductRepository 인터페이스에 encodeCursor 메서드 추가
+// 또는 도메인 레이어에 CursorEncoder 유틸리티 생성Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
around line 13 (and usage at line 161), the domain service directly imports and
calls ProductRepositoryImpl.encodeCursor(), violating DIP; refactor so the
domain layer no longer depends on an infrastructure class by moving the cursor
encode/decode logic into the ProductRepository interface (add default/static
methods or instance methods) or create a dedicated domain-level CursorEncoder
utility and inject or use that from ProductReadService; update references to
call the interface or utility instead of ProductRepositoryImpl and remove the
infrastructure import.
| if (productIds.isEmpty()) { | ||
| // 빈 SET 생성 (캐시 miss와 실제 좋아요 0개 구분) | ||
| cacheRedisTemplate.opsForSet().add(key, -1L); // 더미 값 | ||
| cacheRedisTemplate.opsForSet().remove(key, -1L); // 즉시 제거 | ||
| cacheRedisTemplate.expire(key, TTL); |
There was a problem hiding this comment.
빈 SET 초기화 로직에 race condition이 존재합니다.
add → remove 사이에 다른 스레드에서 exists() 호출 시 일시적으로 더미 값 -1L이 보일 수 있습니다. 또한 add 후 remove 전에 예외가 발생하면 더미 값이 남아있게 됩니다.
빈 SET을 표현하기 위해 placeholder 접근보다 별도의 키 또는 hasKey 기반 체크를 사용하는 것이 더 안전합니다. 위 exists() 수정과 함께, 빈 경우에는 키 자체를 생성하지 않고 hasKey로만 존재 여부를 판단하는 방식을 고려해보세요.
if (productIds.isEmpty()) {
- // 빈 SET 생성 (캐시 miss와 실제 좋아요 0개 구분)
- cacheRedisTemplate.opsForSet().add(key, -1L); // 더미 값
- cacheRedisTemplate.opsForSet().remove(key, -1L); // 즉시 제거
- cacheRedisTemplate.expire(key, TTL);
+ // 빈 SET도 키가 존재함을 표시하기 위해 marker 값 사용
+ // 읽기 시 marker 값을 필터링해야 함
+ cacheRedisTemplate.opsForSet().add(key, "__EMPTY_MARKER__");
+ cacheRedisTemplate.expire(key, TTL);
} else {또는 별도의 "initialized" 플래그 키를 사용하는 방식도 고려할 수 있습니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/MemberLikesCache.java
around lines 140-144, the current empty-SET initialization uses add -> remove
with a dummy value which can expose a transient -1L to other threads and can be
left behind on exceptions; remove this placeholder approach and instead (a) do
not create the cache key when there are no productIds — rely on
cacheRedisTemplate.hasKey(key) (or your updated exists() logic) to detect
absence, OR (b) use a dedicated initialization flag key (e.g. key + ":init")
that you set atomically (setIfAbsent with TTL) to mark an initialized-empty
state, or (c) if you need a set key present, perform the operations inside a
Redis transaction (MULTI/EXEC) so the dummy never becomes visible — implement
one of these safer patterns and remove the add/remove dummy sequence.
| public void decrement(Long productId) { | ||
| try { | ||
| String key = CacheKeyGenerator.productLikeCountKey(productId); | ||
| Long newValue = cacheRedisTemplate.opsForValue().decrement(key); | ||
|
|
||
| // 음수 방지 | ||
| if (newValue != null && newValue < 0) { | ||
| cacheRedisTemplate.opsForValue().set(key, 0); | ||
| } | ||
|
|
||
| log.debug("[ProductLikeCountCache] decrement productId={}", productId); | ||
| } catch (Exception e) { | ||
| log.warn("[ProductLikeCountCache] decrement failed, error={}", e.getMessage()); | ||
| } | ||
| } |
There was a problem hiding this comment.
음수 방지 로직의 Race Condition
decrement 후 음수 체크 및 set(0) 사이에 다른 스레드가 increment할 수 있어 데이터가 손실될 수 있습니다. Lua 스크립트를 사용한 원자적 연산을 권장합니다.
// Lua 스크립트 예시
String script = """
local current = redis.call('DECR', KEYS[1])
if current < 0 then
redis.call('SET', KEYS[1], 0)
return 0
end
return current
""";🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountCache.java
around lines 40-54, the current decrement implementation has a race where
another thread can increment between the DECR and SET(0) calls; replace the
two-step logic with a single atomic Lua script executed via
RedisTemplate.execute (use DefaultRedisScript<Long> returning Long) that
performs DECR, clamps negative results to 0, and returns the resulting value;
pass the product key as KEYS[1], handle null/exception return values
consistently, and keep the existing debug/warn logging behavior.
| private void applyCursorCondition(BooleanBuilder builder, String cursor, ProductSortCondition sortCondition) { | ||
| String[] parts = decodeCursor(cursor); | ||
| if (parts == null) { | ||
| return; | ||
| } | ||
|
|
||
| if (sortCondition == null || sortCondition == ProductSortCondition.LATEST) { | ||
| // cursor format: "createdAt,id" | ||
| ZonedDateTime createdAt = ZonedDateTime.parse(parts[0]); | ||
| Long id = Long.parseLong(parts[1]); | ||
| builder.and( | ||
| product.createdAt.lt(createdAt) | ||
| .or(product.createdAt.eq(createdAt).and(product.id.lt(id))) | ||
| ); | ||
| } else if (sortCondition == ProductSortCondition.PRICE_ASC) { | ||
| // cursor format: "price,id" | ||
| BigDecimal price = new BigDecimal(parts[0]); | ||
| Long id = Long.parseLong(parts[1]); | ||
| builder.and( | ||
| product.price.amount.gt(price) | ||
| .or(product.price.amount.eq(price).and(product.id.gt(id))) | ||
| ); | ||
| } else if (sortCondition == ProductSortCondition.LIKES_DESC) { | ||
| // cursor format: "likeCount,id" | ||
| int likeCount = Integer.parseInt(parts[0]); | ||
| Long id = Long.parseLong(parts[1]); | ||
| builder.and( | ||
| product.likeCount.lt(likeCount) | ||
| .or(product.likeCount.eq(likeCount).and(product.id.lt(id))) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
커서 디코딩 시 배열 길이 검증 필요
decodeCursor가 반환한 parts 배열의 길이를 검증하지 않고 parts[0], parts[1]에 접근하고 있습니다. 잘못된 커서가 전달되면 ArrayIndexOutOfBoundsException이 발생할 수 있습니다.
private void applyCursorCondition(BooleanBuilder builder, String cursor, ProductSortCondition sortCondition) {
String[] parts = decodeCursor(cursor);
- if (parts == null) {
+ if (parts == null || parts.length < 2) {
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private void applyCursorCondition(BooleanBuilder builder, String cursor, ProductSortCondition sortCondition) { | |
| String[] parts = decodeCursor(cursor); | |
| if (parts == null) { | |
| return; | |
| } | |
| if (sortCondition == null || sortCondition == ProductSortCondition.LATEST) { | |
| // cursor format: "createdAt,id" | |
| ZonedDateTime createdAt = ZonedDateTime.parse(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.createdAt.lt(createdAt) | |
| .or(product.createdAt.eq(createdAt).and(product.id.lt(id))) | |
| ); | |
| } else if (sortCondition == ProductSortCondition.PRICE_ASC) { | |
| // cursor format: "price,id" | |
| BigDecimal price = new BigDecimal(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.price.amount.gt(price) | |
| .or(product.price.amount.eq(price).and(product.id.gt(id))) | |
| ); | |
| } else if (sortCondition == ProductSortCondition.LIKES_DESC) { | |
| // cursor format: "likeCount,id" | |
| int likeCount = Integer.parseInt(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.likeCount.lt(likeCount) | |
| .or(product.likeCount.eq(likeCount).and(product.id.lt(id))) | |
| ); | |
| } | |
| } | |
| private void applyCursorCondition(BooleanBuilder builder, String cursor, ProductSortCondition sortCondition) { | |
| String[] parts = decodeCursor(cursor); | |
| if (parts == null || parts.length < 2) { | |
| return; | |
| } | |
| if (sortCondition == null || sortCondition == ProductSortCondition.LATEST) { | |
| // cursor format: "createdAt,id" | |
| ZonedDateTime createdAt = ZonedDateTime.parse(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.createdAt.lt(createdAt) | |
| .or(product.createdAt.eq(createdAt).and(product.id.lt(id))) | |
| ); | |
| } else if (sortCondition == ProductSortCondition.PRICE_ASC) { | |
| // cursor format: "price,id" | |
| BigDecimal price = new BigDecimal(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.price.amount.gt(price) | |
| .or(product.price.amount.eq(price).and(product.id.gt(id))) | |
| ); | |
| } else if (sortCondition == ProductSortCondition.LIKES_DESC) { | |
| // cursor format: "likeCount,id" | |
| int likeCount = Integer.parseInt(parts[0]); | |
| Long id = Long.parseLong(parts[1]); | |
| builder.and( | |
| product.likeCount.lt(likeCount) | |
| .or(product.likeCount.eq(likeCount).and(product.id.lt(id))) | |
| ); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
around lines 197-228, validate the decoded cursor array before accessing
parts[0] and parts[1]: after obtaining parts from decodeCursor, check that parts
!= null and parts.length >= 2; if the check fails, either return without
modifying the builder or throw a clear IllegalArgumentException (choose
consistent behavior with the method's callers). Additionally, for robustness
wrap the parsing (ZonedDateTime.parse, new BigDecimal, Integer.parseInt,
Long.parseLong) in a try/catch to convert parse errors into the same
invalid-cursor handling so malformed cursor values do not cause runtime
exceptions.
| private Product createProductWithLikeCount(Long productId, int likeCount) { | ||
| Product product = new Product( | ||
| productId, | ||
| "테스트 상품 " + productId, | ||
| "상품 설명", | ||
| Money.of(10000), | ||
| Stock.of(100) | ||
| ); | ||
|
|
||
| // 리플렉션을 사용해서 ID와 likeCount를 설정 | ||
| try { | ||
| java.lang.reflect.Field idField = product.getClass().getSuperclass().getDeclaredField("id"); | ||
| idField.setAccessible(true); | ||
| idField.set(product, productId); | ||
|
|
||
| java.lang.reflect.Field likeCountField = product.getClass().getDeclaredField("likeCount"); | ||
| likeCountField.setAccessible(true); | ||
| likeCountField.set(product, likeCount); | ||
| } catch (Exception e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
|
|
||
| return product; | ||
| } |
There was a problem hiding this comment.
createProductWithLikeCount에서 productId를 brandId로 잘못 사용하고 있습니다.
Product 생성자의 첫 번째 인자는 brandId인데, productId를 전달하고 있습니다. 이로 인해 인기 상품 테스트에서 brandRepository.findByIdIn이 예상과 다른 brandId들을 조회하게 됩니다.
- private Product createProductWithLikeCount(Long productId, int likeCount) {
+ private Product createProductWithLikeCount(Long productId, Long brandId, int likeCount) {
Product product = new Product(
- productId,
+ brandId,
"테스트 상품 " + productId,
"상품 설명",
Money.of(10000),
Stock.of(100)
);또는 현재 테스트 시나리오가 productId == brandId를 의도적으로 가정한다면, 주석으로 명시해 주세요.
| implementation("com.github.javafaker:javafaker:1.0.2") { | ||
| exclude(group = "org.yaml", module = "snakeyaml") | ||
| } | ||
| implementation("org.yaml:snakeyaml:2.3") |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
SnakeYAML latest version 2025 security CVE-2022-1471
💡 Result:
Short answer: Upgrade — SnakeYAML fixed CVE-2022-1471 in the 2.0 line; the current released version (as of Nov 28, 2025) is 2.5. Upgrade to 2.0+ (preferably the latest 2.5) to mitigate CVE-2022-1471. [1][2][3]
Sources:
- GitHub advisory (CVE-2022-1471): patched in 2.0. [1]
- org.yaml:snakeyaml latest (2.5) (javadoc/Maven metadata). [2]
- CVE summary / NVD-style entries describing the vulnerability and recommended upgrade. [3]
🌐 Web query:
SnakeYAML 2.5 Spring Boot 21 compatibility requirements
💡 Result:
Do you mean (A) Spring Boot 2.1, (B) Java 21 (running Spring Boot on Java 21), or (C) “Spring Boot 21” (please clarify)? I need that to give an exact compatibility answer (which Spring Boot line supports SnakeYAML 2.5 and what Java version is required).
🌐 Web query:
SnakeYAML release history versions security fixes timeline
💡 Result:
Brief timeline of SnakeYAML releases and security fixes (key items)
- CVE-2017-18640 (entity expansion / alias issues) — fixed by upgrading to >= 1.26. (vulert.com)
- August–September 2022: several DoS/out‑of‑bounds / nesting‑depth issues (CVE-2022-25857 / CVE-2022-38749 / CVE-2022-38750 / CVE-2022-38751) affecting org.yaml:snakeyaml < 1.31; fixed in 1.31+. (seal.security)
- Oct 2022 — CVE-2022-1471 (Constructor deserialization leads to RCE via untrusted YAML) public disclosure; patched by the SnakeYAML 2.0 release. (github.com)
- Feb–Jun 2023 — SnakeYAML 2.0 (major release addressing unsafe deserialization behavior and other changes); 2.x line continued with later 2.0+/2.2 releases (Eclipse/Maven artifacts show 2.0.0 and later 2.2.0 builds in 2023–2024). (download.eclipse.org)
- 2023–2024 ongoing: downstream CVE/NVD/GHSA records updated and additional hardening; users are advised to run >=1.31 to cover the 2022 DoS findings and upgrade to 2.0+ to mitigate the deserialization (CVE-2022-1471) RCE class of issues. (security.snyk.io)
If you want, I can produce a more detailed chronological table (dates, CVE IDs, affected versions, fixed versions, links to advisories/release notes).
Citations:
- 1: https://vulert.com/vuln-db/debian-11-snakeyaml-157736?utm_source=openai
- 2: https://www.seal.security/vulnerabilities/Java-CVE-2022-25857?utm_source=openai
- 3: https://github.com/advisories/GHSA-mjmj-j48q-9wg2?utm_source=openai
- 4: https://download.eclipse.org/oomph/simrel-orbit/nightly/N202306181351/archive/download.eclipse.org/oomph/simrel-orbit/nightly/N202306181351/index/org.yaml.snakeyaml_2.0.0.html?utm_source=openai
- 5: https://security.snyk.io/vuln/SNYK-JAVA-ORGYAML-2806360?utm_source=openai
Upgrade SnakeYAML to version 2.5 for additional security patches
Your current version 2.3 is secure (CVE-2022-1471 was fixed in SnakeYAML 2.0, released February–June 2023). However, version 2.5 is the latest release as of November 2025 and includes additional security hardening beyond 2.3. Upgrading from 2.3 to 2.5 is a reasonable recommendation to benefit from all available patches.
implementation("org.yaml:snakeyaml:2.5")
🤖 Prompt for AI Agents
In build.gradle.kts around line 61 the SnakeYAML dependency is pinned to version
2.3; update the dependency to 2.5 to pick up additional security hardening and
patches by replacing the implementation("org.yaml:snakeyaml:2.3") entry with the
2.5 version and then run a build to verify compatibility and update any
lockfiles or dependency caches as needed.
📌 Summary
1. 인덱스 최적화를 통한 상품 목록 조회 성능 개선
2. 좋아요 수 정렬 구조 개선 (비정규화)
products.like_count컬럼 추가 (비정규화)3. Redis 캐시 적용 (4계층 캐시 구조)
product:detail:v1:{id})likes:member:v1:{memberId}, Redis SET)product:like:count:v1:{productId}, INCR/DECR + 1분 스케줄러 동기화)💬 Review Points
1. 인덱스 설계 전략에 대한 검토 요청
현재 적용한 인덱스:
질문:
현재 구조:
// 좋아요 클릭 시
질문:
질문:
✅ Checklist
🔖 Index
❤️ Structure
⚡ Cache
📎 References
Summary by CodeRabbit
새로운 기능 및 개선사항
커서 기반 상품 검색
인기 상품 기능
브랜드 필터링
좋아요 기능 개선
✏️ Tip: You can customize this high-level summary in your review settings.