Skip to content

[5주차] - Read Optimization - 박주닮 - #46

Open
dami325 wants to merge 4 commits into
Loopers-dev-lab:dami325from
dami325:main
Open

[5주차] - Read Optimization - 박주닮 #46
dami325 wants to merge 4 commits into
Loopers-dev-lab:dami325from
dami325:main

Conversation

@dami325

@dami325 dami325 commented Nov 28, 2025

Copy link
Copy Markdown
Collaborator

📌 Summary

브랜드 기반 페이징·정렬과 좋아요 기능 추가, 인덱스 및 캐시 적용으로 조회 성능이 개선

💬 Review Points

안녕하세요. 캐시 적용 및 Materialized View 설계 관련해서 궁금한 점이 있어 질문드립니다.

수정이 잦은 도메인에도 페이징 리스트 캐시를 적용하시는지 궁금합니다.
예를 들어 상품이나 게시글처럼 생성/수정/삭제가 빈번하고, 변경 사항이 리스트에 즉시 반영돼야 하는 경우에도 캐시를 활용하시는지요?
또한, 실시간성이 필요한 리스트 캐시에 대해 보편적으로 어떤 전략을 사용하시는지 궁금합니다.

Materialized View 개념에 대해 확인하고 싶습니다.
제가 이해하기로 Materialized View는 일반 MySQL View처럼 읽을 때마다 쿼리를 실행하는 게 아니라, 조회 결과를 별도 테이블 형태로 물리적으로 저장해두고, 데이터 변경 시마다 이를 갱신하는 구조라고 알고 있습니다.
그럼 Materialized View는 라이브러리나 Mysql에 있는 개념이 아니라 직접 Materialized View 테이블을 일반 테이블처럼 설계하고, 변경 이벤트에 맞춰 갱신 로직을 구현하는 방식인 것인가요??

✅ Checklist

🗞️ Coupon 도메인

🔖 Index

  • 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다
  • 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다

❤️ Structure

  • 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다
  • 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다

⚡ Cache

  • Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다
  • 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다.

Summary by CodeRabbit

  • 신규 기능
    • 상품 목록에 선택적 브랜드 필터가 추가되어 브랜드별 페이징·정렬이 가능합니다.
    • 상품의 좋아요 수가 영구 저장되고 즉시 증가/감소로 반영됩니다.
  • 성능 개선
    • 브랜드 및 좋아요 수 기반 인덱스와 캐시 적용으로 조회 응답과 필터 성능이 향상됩니다.
  • 기타
    • 좋아요 처리 흐름이 상품의 좋아요 수와 동기화되어 일관성이 개선됩니다.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Nov 28, 2025

Copy link
Copy Markdown

Walkthrough

제품 엔터티에 likeCount 필드를 추가하고 관련 DB 인덱스들을 선언했으며, 페이지 요청에 brandId 필터를 추가해 컨트롤러 → PageCommand → Repository로 전달되도록 변경했습니다. LikeService가 좋아요 추가/삭제 시 ProductService를 호출해 product.likeCount를 갱신합니다.

Changes

코호트 / 파일(s) 변경 요약
엔티티: Product 변경
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt
likeCount: Long = 0 필드 추가 (@Column(nullable = false)), @Table에 인덱스 (idx_product_brand_id, idx_product_like_count) 선언, incrementLikeCount()/decrementLikeCount() 메서드 추가, Index import 추가
엔티티: Like 인덱스 추가
apps/commerce-api/src/main/kotlin/com/loopers/domain/like/Like.kt
@Table(indexes = [...])idx_like_product_id 인덱스 추가, Index import 사용
페이지 요청/DTO/컨트롤러
apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt, apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/common/PageRequestDto.kt, apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/product/ProductV1Controller.kt
brandId: Long? = null 필드/생성자 매개변수 및 DTO 스키마 주석 추가; 컨트롤러에서 요청의 brandId를 PageCommand에 전달하도록 변경, PageCommand에 cacheKey() 추가
쿼리/리포지토리 변경
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/product/ProductQuerydslRepository.kt
조회 및 총합 쿼리에 brandId 필터 추가; 기존 Like 조인 및 like.count() 사용 제거, 대신 product.likeCount 사용으로 프로젝션/정렬 및 그룹화 간소화
서비스 흐름: Like ↔ Product 동기화
apps/commerce-api/src/main/kotlin/com/loopers/domain/like/LikeService.kt, apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductService.kt
LikeService 생성자에 ProductService 주입 추가; 좋아요 추가/삭제 시 LikeRepository 처리 후 productService.getProduct(productId) 호출해 incrementLikeCount()/decrementLikeCount() 수행; ProductService에 캐싱 애노테이션 추가(@Cacheable, @CacheEvict)
어플리케이션 레이어: Facade 조정
apps/commerce-api/src/main/kotlin/com/loopers/application/product/ProductFacade.kt
LikeService로부터 별도 집계 조회 제거하고 product.likeCount를 직접 사용하도록 반환/구성 로직 조정 (공개 시그니처 변경 없음)
테스트 업데이트
apps/commerce-api/src/test/kotlin/com/loopers/domain/like/LikeServiceTest.kt
LikeService 테스트에 ProductService 목 추가, getProduct 스텁/검증 및 Product 인스턴스 사용으로 테스트 보강

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as ProductV1Controller
    participant DTO as PageRequestDto
    participant Command as PageCommand
    participant Repo as ProductQuerydslRepository
    participant DB

    Client->>Controller: GET /products?brandId=123&...
    Controller->>DTO: 바인딩
    Controller->>Command: new PageCommand(..., brandId=123)
    Command->>Repo: fetchProducts(command)
    Repo->>DB: SELECT ... WHERE deleted_at IS NULL AND brand_id = 123
    DB-->>Repo: rows + totalCount
    Repo-->>Controller: 페이지 결과 반환
    Controller-->>Client: 200 OK
Loading
sequenceDiagram
    participant Client
    participant Controller as LikeController
    participant LikeSvc as LikeService
    participant LikeRepo as LikeRepository
    participant ProductSvc as ProductService
    participant ProductRepo as ProductRepository

    Client->>Controller: POST /products/456/likes
    Controller->>LikeSvc: addLike(productId=456, userId=...)
    LikeSvc->>LikeRepo: save(Like...)
    LikeRepo-->>LikeSvc: savedLike
    LikeSvc->>ProductSvc: getProduct(456)
    ProductSvc->>ProductRepo: findById(456)
    ProductRepo-->>ProductSvc: product
    LikeSvc->>ProductSvc: request incrementLikeCount()
    ProductSvc->>ProductRepo: save(updated product)
    ProductRepo-->>ProductSvc: savedProduct
    LikeSvc-->>Controller: success
    Controller-->>Client: 201 Created
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • 주의할 파일/영역:
    • ProductQuerydslRepository.kt: 필터/정렬/프로젝션 변경과 성능 영향
    • Product.kt: JPA 매핑(새 컬럼, 인덱스) 및 마이그레이션 영향
    • LikeService.kt: 트랜잭션 경계에서 product.likeCount 동기화와 예외 처리
    • 캐시 애노테이션 적용 위치와 캐시 무효화 범위 확인 (ProductService)

Possibly related PRs

🐰 톡톡, 좋아요가 하나씩 쌓여요,
당근길 따라 필터 달린 제품 숲,
쿼리와 캐시가 폴짝 맞물려,
카운트와 인덱스 반짝반짝,
폴짝폴짝 검토하러 갈게요. 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 변경 사항의 주요 내용인 읽기 최적화(Read Optimization)와 관련되어 있으나, 구체적인 기술 세부사항이 부족합니다.
Description check ✅ Passed PR 설명이 대부분 완성되었습니다. 필수 섹션(Summary, Review Points, Checklist)이 모두 작성되어 있으며, 구체적인 변경사항과 검토 포인트가 포함되어 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (1)

8-18: 브랜드 ID 인덱스 추가 방향은 적절해 보입니다

brandId로 필터링하는 조회/조인이 많다면, idx_product_brand_id 인덱스 추가는 읽기 성능 개선에 도움이 될 것 같습니다. 다만 다음 두 가지는 한 번만 확인 부탁드립니다.

  1. 실제 컬럼명 일치 여부 확인 필요

    • 현재 필드 정의는 @Column(nullable = false)만 있고 name을 지정하지 않아, JPA 물리 네이밍 전략에 따라 컬럼명이 정해집니다.
    • columnList = "brand_id"가 실제 DDL 상 컬럼명과 반드시 일치하는지(예: snake_case 전략 사용 여부, 커스텀 NamingStrategy 적용 여부)를 확인해 주세요. 불일치 시 스키마 생성/검증 단계에서 문제가 생길 수 있습니다.
  2. DB 마이그레이션 도구 사용 시 인덱스 스크립트 반영

    • 운영 환경에서 spring.jpa.hibernate.ddl-auto를 끄고 Flyway/Liquibase 등으로 스키마를 관리한다면, 이 애노테이션 변경만으로는 실제 DB 인덱스가 생성되지 않습니다.
    • 동일한 인덱스를 마이그레이션 스크립트에도 추가해 두어야 운영 DB와 엔티티 정의가 일치합니다.
  3. 추가적인 인덱스 설계 관점(선택 사항)

    • 향후에 정렬/페이지네이션 패턴이 명확해지면, 예를 들어 WHERE brand_id = ? ORDER BY id DESC와 같은 쿼리가 많다면 (brand_id, id) 복합 인덱스도 후보가 될 수 있습니다. 지금은 단일 컬럼 인덱스로 시작하고, 실제 쿼리 패턴과 실행 계획을 보면서 추가/수정하는 방식이 좋아 보입니다.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e7fc047 and c34ec65.

📒 Files selected for processing (1)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (1 hunks)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/common/PageRequestDto.kt (1)

17-19: 선택적 개선: brandId 유효성 검증 추가 고려

브랜드 ID 필터가 추가되었습니다. 현재는 null 허용이지만, 값이 제공될 경우 양수인지 검증하는 것을 고려해보세요.

다음과 같이 init 블록에 검증을 추가할 수 있습니다:

     init {
         require(pageNumber >= 0) { "페이지 번호는 0 이상이어야 합니다." }
         require(pageSize > 0) { "페이지 크기는 1 이상이어야 합니다." }
         require(pageSize <= 100) { "페이지 크기는 100 이하여야 합니다." }
+        brandId?.let { require(it > 0) { "브랜드 ID는 양수여야 합니다." } }
     }
apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt (1)

7-7: 선택적 개선: brandId 유효성 검증 추가 고려

도메인 계층에서도 brandId가 제공될 경우 양수인지 검증하는 것을 고려해보세요.

다음과 같이 init 블록에 검증을 추가할 수 있습니다:

     init {
         require(pageNumber >= 0) { "페이지 번호는 0 이상이어야 합니다." }
         require(pageSize > 0) { "페이지 크기는 1 이상이어야 합니다." }
         require(pageSize <= 100) { "페이지 크기는 100 이하여야 합니다." }
+        brandId?.let { require(it > 0) { "브랜드 ID는 양수여야 합니다." } }
     }
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/product/ProductQuerydslRepository.kt (1)

20-23: 선택적 개선: where 조건 순서 일관성

count 쿼리의 where 조건 순서가 메인 쿼리(lines 44-47)와 다릅니다. 기능적으로는 동일하지만, 일관성을 위해 순서를 맞추는 것을 고려해보세요.

다음과 같이 순서를 통일할 수 있습니다:

         .where(
-            pageCommand.brandId?.let { product.brandId.eq(it) },
             product.deletedAt.isNull,
+            pageCommand.brandId?.let { product.brandId.eq(it) },
         )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c34ec65 and 3d28f38.

📒 Files selected for processing (4)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt (1 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/product/ProductQuerydslRepository.kt (4 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/common/PageRequestDto.kt (1 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/product/ProductV1Controller.kt (1 hunks)
🔇 Additional comments (4)
apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/product/ProductV1Controller.kt (1)

71-71: LGTM!

브랜드 ID 필터가 요청에서 커맨드로 올바르게 전달되고 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/product/ProductQuerydslRepository.kt (3)

68-68: 정렬 필드 매핑 업데이트됨

정렬 필드가 product.likeCount를 사용하도록 업데이트되었습니다. 이는 프로젝션 변경과 일관성이 있지만, lines 26-51에서 언급한 likeCount 필드의 존재 및 동기화 검증이 선행되어야 합니다.


26-51: I'll help you verify the concerns raised in this review comment about the denormalized likeCount field. Let me search the codebase for the Product entity definition and related like operations.
<function_calls>

#!/bin/bash

Find Product entity definition

fd -e kt -type f | xargs rg -l "class Product" | head -5


</function_calls>
<function_calls>

#!/bin/bash

Search for Product class and likeCount field

rg -A 30 "class Product\b" --type kt | head -100


</function_calls>
<function_calls>

#!/bin/bash

Check for likeCount field in codebase

rg "likeCount" --type kt -B 3 -A 3


</function_calls>
<function_calls>

#!/bin/bash

Search for like operations (create, delete, etc.)

rg -n "(Like|like)" --type kt | grep -i "fun|class|data class" | head -20


</function_calls>
<function_calls>

#!/bin/bash

Find files in commerce-api infrastructure area

fd -type f -name "*.kt" | grep -E "(product|like)" | head -20


</function_calls>


20-23: 브랜드 ID 인덱스가 올바르게 추가되었습니다

검증 완료: Product.kt 파일에서 @Index(name = "idx_product_brand_id", columnList = "brand_id") 인덱스가 @Table 어노테이션에 정의되어 있습니다(lines 13-18). 이 인덱스는 ProductQuerydslRepository의 findProducts() 메서드에서 brandId로 필터링하는 쿼리(lines 21, 46)의 성능을 보장합니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

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/main/kotlin/com/loopers/domain/like/LikeService.kt (1)

19-28: 동시성 제어가 필요합니다 - 좋아요 카운트 업데이트에서 Lost Update 문제 발생 가능.

여러 사용자가 동시에 같은 상품에 좋아요를 추가할 경우, likeCount가 정확하게 증가하지 않을 수 있습니다.

시나리오:

  1. 트랜잭션 A: 좋아요 저장 → 상품 조회(count=10) → count를 11로 증가
  2. 트랜잭션 B: 좋아요 저장 → 상품 조회(count=10) → count를 11로 증가
  3. 결과: count=11 (기대값: 12)

해결 방안:
Product 엔티티에 @Version 필드를 추가하여 JPA 낙관적 잠금을 사용하거나, 비관적 잠금(@Lock(LockModeType.PESSIMISTIC_WRITE))을 사용해야 합니다.

 @Transactional
 fun addLike(userId: Long, productId: Long) {
     if (likeRepository.existsByUserIdAndProductId(userId, productId)) {
         return
     }
 
     likeRepository.save(Like.of(userId, productId))
-
-    val product = productService.getProduct(productId)
+    val product = productService.getProductWithLock(productId)
     product.incrementLikeCount()
 }

ProductService에 비관적 잠금을 사용하는 메서드 추가:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
fun findByIdWithLock(@Param("id") id: Long): Product?
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (1)

1-32: Database migration scripts are missing and must be created before this change can be deployed to production.

The verification confirms a critical issue: The project uses spring.jpa.hibernate.ddl-auto: none in production (see modules/jpa/src/main/resources jpa.yml), which means Hibernate will not automatically create or update the database schema. Without migration scripts for the new like_count column and indexes, deploying this Product entity change to production will fail with column not found errors.

Additionally, the project has no Flyway or Liquibase dependency configured in apps/commerce-api/build.gradle.kts, and no SQL migration files exist in the repository.

Required actions:

  • Create a database migration script (V*.sql using Flyway or equivalent) to add the like_count column with default value 0 and the two indexes (idx_product_brand_id, idx_product_like_count)
  • Set up Flyway or Liquibase in the project if not already present
  • Ensure migrations are applied before deploying this schema change to any environment using ddl-auto: none
🧹 Nitpick comments (1)
apps/commerce-api/src/test/kotlin/com/loopers/domain/like/LikeServiceTest.kt (1)

20-40: 테스트 검증이 적절하지만, 동시성 테스트가 누락되었습니다.

현재 테스트는 정상 흐름을 검증하지만, LikeService.kt에서 식별된 동시성 문제를 확인하는 테스트가 없습니다. 여러 스레드에서 동시에 같은 상품에 좋아요를 추가하는 시나리오를 테스트하는 것을 권장합니다.

동시성 테스트 예시를 생성하시겠습니까?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d28f38 and 89cedec.

📒 Files selected for processing (5)
  • apps/commerce-api/src/main/kotlin/com/loopers/application/product/ProductFacade.kt (2 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/like/Like.kt (2 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/like/LikeService.kt (2 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (2 hunks)
  • apps/commerce-api/src/test/kotlin/com/loopers/domain/like/LikeServiceTest.kt (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (2)
apps/commerce-api/src/main/kotlin/com/loopers/domain/brand/Brand.kt (1)
  • validateName (30-34)
apps/commerce-api/src/main/kotlin/com/loopers/domain/order/OrderItem.kt (1)
  • validatePrice (73-77)
🔇 Additional comments (6)
apps/commerce-api/src/main/kotlin/com/loopers/domain/like/Like.kt (1)

19-21: 좋아요 조회 성능 개선을 위한 인덱스 추가가 적절합니다.

product_id 컬럼에 인덱스를 추가하여 특정 상품의 좋아요 수를 조회하는 쿼리의 성능이 개선됩니다. LikeRepository.countByProductId() 같은 조회 쿼리에서 이 인덱스를 활용할 수 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/application/product/ProductFacade.kt (2)

24-25: 읽기 성능 최적화를 위해 비정규화된 필드를 사용합니다.

product.likeCount를 직접 사용하여 LikeService를 통한 집계 쿼리를 제거했습니다. 이는 읽기 성능을 크게 개선하지만, LikeService에서 좋아요 추가/삭제 시 Product.likeCount를 올바르게 동기화하는 것이 중요합니다.


51-52: 일관성 있게 비정규화된 필드를 사용합니다.

updateProduct에서도 동일하게 product.likeCount를 사용하여 읽기 최적화 패턴을 일관되게 적용했습니다.

apps/commerce-api/src/test/kotlin/com/loopers/domain/like/LikeServiceTest.kt (1)

17-18: 테스트가 새로운 의존성을 올바르게 반영했습니다.

ProductService 목(mock)을 추가하고 LikeService에 주입하여 새로운 동작을 테스트할 수 있도록 구성했습니다.

apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt (2)

30-31: 좋아요 수를 저장하는 필드가 적절하게 추가되었습니다.

likeCount 필드를 추가하여 읽기 성능을 최적화했습니다. 기본값 0과 nullable = false 제약 조건이 적절합니다.


13-19: idx_product_like_count 인덱스 재검토 결과: 인덱스는 필요합니다.

검증 결과, likeCount 컬럼은 실제로 정렬 쿼리에 사용 중입니다:

  • ProductV1Controller의 /api/v1/products/search 엔드포인트에서 클라이언트가 sort 파라미터로 likeCount를 지정하여 인기 상품순으로 조회할 수 있습니다 (lines 57-85).
  • ProductQuerydslRepository에서 likeCount가 유효한 정렬 필드로 구현되어 있습니다 (line 68).

따라서 현재 인덱싱 전략은 적절합니다:

  • idx_product_brand_id: 브랜드별 필터링에 유용 ✓
  • idx_product_like_count: 대중적 쿼리 패턴(정렬)을 지원하므로 유지 권장 ✓

쓰기 성능 영향이 우려되면 향후 데이터 규모와 실제 정렬 쿼리 사용 빈도를 모니터링하여 필요 시 재평가하시기 바랍니다.

Comment on lines 1 to 11
package com.loopers.domain.like

import com.loopers.domain.product.ProductService
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class LikeService(
private val likeRepository: LikeRepository,
private val productService: ProductService,
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the full LikeService.kt file to understand the changes
find . -name "LikeService.kt" -type f

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Find Product entity and like_count field references
find . -name "Product.kt" -o -name "Product.java" -type f

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Search for migration files and SQL schemas
find . -name "*.sql" -o -name "*migration*" -o -name "*schema*" | head -20

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Look for data initialization or test data files
find . -name "data.sql" -o -name "test-data.sql" -o -name "*init*.sql" -type f

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# List repository structure to understand layout
ls -la

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1563


🏁 Script executed:

#!/bin/bash
# Check git status and recent changes to understand PR scope
git log --oneline -10

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Search for all .kt files in the repository
find . -name "*.kt" -type f | head -30

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 2542


🏁 Script executed:

#!/bin/bash
# Search for SQL and database related files
find . -name "*.sql" -o -name "*.xml" | grep -E "(sql|database|schema|migration)" | head -20

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Check all git branches
git branch -a

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 203


🏁 Script executed:

#!/bin/bash
# Check current branch and status
git status

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 297


🏁 Script executed:

#!/bin/bash
# Look for any like-related files in the commerce-api
find ./apps/commerce-api -name "*ike*" -o -name "*product*" -o -name "*like*" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Check git diff to see what's being changed in this PR
git diff --name-only HEAD

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Check for untracked files
git ls-files --others --exclude-standard

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Check out coderabbit_46 branch and look for LikeService.kt
git checkout coderabbit_46 2>&1
find . -name "LikeService.kt" -o -name "Product.kt" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 321


🏁 Script executed:

#!/bin/bash
# Check git log to see commits in the PR
git log --oneline -20

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Show git diff with stats
git diff main... --stat | head -30

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Look for all changes related to like or product
git diff main... --name-only | grep -E "(like|product|Like|Product)" -i

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/antml/bash
# Show files that differ between main and coderabbit_46
git log --oneline main..coderabbit_46

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1362


🏁 Script executed:

#!/bin/bash
# Read the LikeService.kt file
cat -n ./apps/commerce-api/src/main/kotlin/com/loopers/domain/like/LikeService.kt

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1471


🏁 Script executed:

#!/bin/bash
# Read the Product.kt file
cat -n ./apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 2763


🏁 Script executed:

#!/bin/bash
# Search for migration files related to like or product
find . -path "*/db/migration/*" -o -path "*/resources/db/*" -o -name "*Like*" -o -name "*Product*" | grep -E "\.(sql|xml)" | head -20

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Look for JPA entity files
find ./apps/commerce-api -name "*Entity.kt" -o -name "*Jpa*.kt" | grep -E "(like|product)" -i

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 273


🏁 Script executed:

#!/bin/bash
# Look for Flyway or Liquibase migration files
find . -path "*/db/migration*" -type d

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Search for all SQL files in the project
find . -name "*.sql" -type f 2>/dev/null | grep -v node_modules

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Check the resources directory structure
find ./apps/commerce-api/src/main/resources -type f 2>/dev/null | head -30

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 144


🏁 Script executed:

#!/antml/bash
# Look for schema or initialization files
ls -la ./apps/commerce-api/src/main/resources/ 2>/dev/null || echo "resources dir not found"

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 265


🏁 Script executed:

#!/bin/bash
# Read application.yml to understand JPA configuration
cat ./apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1049


🏁 Script executed:

#!/bin/bash
# Search for CommandLineRunner or ApplicationRunner beans that might handle initialization
rg -n "CommandLineRunner|ApplicationRunner|@PostConstruct" --type=kt

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Look for any test data or initialization classes
find ./apps/commerce-api -name "*Init*" -o -name "*Fixture*" -o -name "*Seed*" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 328


🏁 Script executed:

#!/bin/bash
# Check the commit that added the likeCount denormalization to see if migration was included
git show 89cedec --stat

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 683


🏁 Script executed:

#!/bin/bash
# Check the commit for like feature implementation
git show bbe4e98 --stat

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1095


🏁 Script executed:

#!/bin/bash
# Find and read jpa.yml
find . -name "jpa.yml" -type f

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Search for any file that contains jpa configuration
rg -l "hibernate.ddl-auto|spring.jpa" --type=yaml

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Look at the modules structure for shared JPA config
ls -la ./modules/jpa/src/main/resources/ 2>/dev/null || echo "no resources"

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 257


🏁 Script executed:

#!/bin/bash
# Check if there are any schema initialization files
find ./modules -name "*.sql" -o -name "*schema*" -o -name "*init*" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Look at the full Product entity to understand initialization
git show 89cedec:apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt | head -40

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 1062


🏁 Script executed:

#!/bin/bash
# Read jpa.yml configuration
cat ./modules/jpa/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 2236


🏁 Script executed:

#!/bin/bash
# Check the full git history of Product.kt to see when likeCount was added
git log --oneline --all -- "**/Product.kt" | head -10

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Look at the LikeService test to understand the expected behavior
find ./apps/commerce-api -name "*LikeServiceTest*" -o -name "*LikeService*Test*" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Check when the Like entity was first introduced
git log --oneline --all -- "**/Like.kt" | head -10

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Read the LikeServiceTest to understand test coverage
cat -n ./apps/commerce-api/src/test/kotlin/com/loopers/domain/like/LikeServiceTest.kt

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 3313


🏁 Script executed:

#!/bin/bash
# Check the detailed changes in commit 89cedec to see if migration was mentioned
git show 89cedec

Repository: Loopers-dev-lab/loopers-spring-kotlin-template

Length of output: 8050


기존 데이터의 일관성 확보가 필수입니다. 이 변경사항을 배포하기 전에 데이터 마이그레이션이 반드시 필요합니다.

이 커밋 (89cedec)은 상품 좋아요 수를 Product.likeCount 필드로 비정규화하는 변경입니다. 그러나 배포 환경 (dev/qa/prd)에서는 hibernate.ddl-auto: none으로 설정되어 있어 수동 마이그레이션이 필수입니다.

문제점:

  • 변경 후 신규 좋아요는 Product.likeCount가 올바르게 증가/감소됩니다
  • 하지만 기존 데이터의 경우, 새로운 like_count 컬럼이 기본값 0으로 초기화되어 데이터 불일치 발생
  • 이미 존재하는 Like 레코드들이 Product.likeCount에 반영되지 않음
  • 좋아요 수로 정렬하는 기능 (idx_product_like_count 인덱스 사용)이 부정확한 결과 반환

필요한 마이그레이션:

UPDATE loopers_product p
SET like_count = (
    SELECT COUNT(*) 
    FROM loopers_like l 
    WHERE l.product_id = p.id
);
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/kotlin/com/loopers/domain/like/LikeService.kt
around lines 1–11: the commit denormalizes like counts into Product.likeCount
but does not populate existing rows; before deploying you must add and run an
idempotent DB migration that sets product.like_count = count of existing
loopers_like rows per product, ensure the migration is managed by the project's
migration tool (Flyway/Liquibase) and added to the release, run it in dev/qa/prd
(back up DB first), verify counts and the idx_product_like_count-driven sorting
are correct after migration, and only then deploy the code that relies on
Product.likeCount.

Comment on lines 30 to 37
@Transactional
fun removeLike(userId: Long, productId: Long) {
val like = likeRepository.findByUserIdAndProductId(userId, productId) ?: return
likeRepository.delete(like)

val product = productService.getProduct(productId)
product.decrementLikeCount()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

동일한 동시성 문제가 좋아요 취소에도 존재합니다.

removeLike에서도 addLike와 동일한 Lost Update 문제가 발생할 수 있습니다. 동일한 해결 방안을 적용해야 합니다.

 @Transactional
 fun removeLike(userId: Long, productId: Long) {
     val like = likeRepository.findByUserIdAndProductId(userId, productId) ?: return
     likeRepository.delete(like)
-
-    val product = productService.getProduct(productId)
+    val product = productService.getProductWithLock(productId)
     product.decrementLikeCount()
 }

Comment on lines +39 to +47
fun incrementLikeCount() {
this.likeCount++
}

fun decrementLikeCount() {
if (this.likeCount > 0) {
this.likeCount--
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

증가/감소 메서드 구현이 적절합니다.

incrementLikeCountdecrementLikeCount 메서드가 명확하게 구현되었습니다. 특히 decrementLikeCount의 음수 방지 로직은 데이터 무결성을 보호합니다.

다만, 동시성 제어를 위해 Product 엔티티에 낙관적 잠금을 추가하는 것을 권장합니다.

+@Version
+var version: Long = 0
+
 @Column(nullable = false)
 var likeCount: Long = 0,

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/commerce-api/src/main/kotlin/com/loopers/domain/product/Product.kt
around lines 39 to 47, the increment/decrement methods are fine but the entity
lacks optimistic locking; add a version field annotated with JPA's @Version
(e.g., a Long) to the Product entity, import the annotation, ensure the field is
persisted and initialized appropriately, update the database schema/migration to
include the version column, and keep the existing methods as-is so concurrent
updates will throw OptimisticLockException for callers to handle.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductService.kt (1)

71-75: deleteProduct method missing cache eviction configuration causes data inconsistency

The deleteProduct method (lines 71-75) lacks cache eviction annotations, creating a stale cache issue:

  • Deleted products remain accessible via the "Product" cache if previously retrieved
  • Deleted products may persist in the "Products" paginated cache
  • This inconsistency contradicts the pattern established in createProduct (line 43-56) and updateProduct (line 58-69)

The method should evict both caches, following the same pattern as updateProduct:

+    @Caching(
+        evict = [
+            CacheEvict(cacheNames = ["Products"], allEntries = true),
+            CacheEvict(cacheNames = ["Product"], key = "#id"),
+        ],
+    )
     @Transactional
     fun deleteProduct(id: Long) {
         val product = getProduct(id)
         product.delete()
     }

This ensures cache consistency across create, update, and delete operations.

🧹 Nitpick comments (3)
apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt (1)

7-7: brandId 필드 추가를 승인하되, 선택적 유효성 검사를 고려하세요.

brandId 필드가 잘 추가되었습니다. 다만, null이 아닌 경우 양수 검증을 추가하면 더 방어적인 코드가 됩니다.

필요시 다음 diff를 적용하여 유효성 검사를 추가할 수 있습니다:

 init {
     require(pageNumber >= 0) { "페이지 번호는 0 이상이어야 합니다." }
     require(pageSize > 0) { "페이지 크기는 1 이상이어야 합니다." }
     require(pageSize <= 100) { "페이지 크기는 100 이하여야 합니다." }
+    brandId?.let { require(it > 0) { "브랜드 ID는 양수여야 합니다." } }
 }
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductService.kt (2)

34-41: PageCommand 기반 캐시 키(cacheKey) 구성 요소 누락 여부 확인 필요

getProductById(pageCommand)"Products" 캐시에 pageCommand.cacheKey()로 키잉하는 것은 좋습니다만, cacheKey() 안에 page, size, sort, brandId 등 페이징/필터링/정렬에 영향을 주는 필드가 모두 포함돼 있는지 꼭 확인해야 합니다. 누락 시 서로 다른 요청이 같은 캐시 엔트리를 공유하는 문제가 생길 수 있습니다.


43-63: 생성/수정 시 목록·단건 캐시 무효화 전략은 일관적이나, 비용·확장성은 고려 여지 있음

  • createProduct에서 "Products" 전체를 allEntries = true로 비우는 것은 구현이 단순해 장점이 있지만, 캐시 키가 많아질 경우 비용이 커질 수 있습니다.
  • updateProduct에서 "Products" 전체 + 해당 "Product"(id 기반)를 함께 evict 하는 전략은 일관성이 좋습니다.

현재 트래픽 규모에서는 문제 없을 수 있으나, 나중에 캐시 키 조합이 늘어날 경우 더 세분화된 invalidation 전략(예: 특정 필터 조합만 비우는 방식)을 고려해 볼 만합니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 89cedec and 0c44493.

📒 Files selected for processing (2)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt (2 hunks)
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductService.kt (4 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 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/kotlin/com/loopers/domain/product/ProductService.kt
🔇 Additional comments (2)
apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductService.kt (2)

7-9: Spring Cache 관련 import 구성은 사용처와 잘 맞습니다.

Cacheable, CacheEvict, Caching 사용이 서비스 레벨 캐싱 도입 의도와 일관되어 보입니다. 프로젝트 전반에서 동일한 캐시 설정/매니저를 사용하는지만 한 번만 확인해 주세요.


19-27: 단건 조회 캐시 도입 시 삭제/갱신 경로와의 일관성 재점검 권장

getProduct(id)"Product" 캐시를 붙인 것은 읽기 비중이 높다면 괜찮은 선택입니다. 다만 이후 모든 쓰기 경로(업데이트, 삭제 등)에서 해당 id에 대한 "Product" 캐시가 반드시 무효화되도록 설계되었는지 전체 흐름을 한 번 더 점검해 주세요. (아래 deleteProduct 코멘트 참고)

Comment on lines +17 to +19
fun cacheKey(): String {
return "${brandId}_${pageNumber}_${pageSize}_$sort"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

캐시 키 생성 로직에 안정성 문제가 있습니다.

cacheKey() 메서드에 다음 문제가 있습니다:

  1. sort 리스트의 toString() 사용: 리스트의 기본 toString()은 예측 가능하고 안정적인 캐시 키 형식을 보장하지 않습니다.
  2. 정렬 조건 순서 민감성: 동일한 정렬 조건이 다른 순서로 제공되면 의미상 동일한 쿼리임에도 다른 캐시 키가 생성되어 캐시 미스가 발생합니다.

이는 캐시 효율성을 저하시키고 불필요한 캐시 미스를 유발합니다.

다음 diff를 적용하여 안정적인 캐시 키를 생성하세요:

 fun cacheKey(): String {
-    return "${brandId}_${pageNumber}_${pageSize}_$sort"
+    val sortKey = sort
+        .sortedWith(compareBy({ it.field }, { it.direction }))
+        .joinToString(",") { "${it.field}:${it.direction}" }
+    return "${brandId}_${pageNumber}_${pageSize}_${sortKey}"
 }
📝 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.

Suggested change
fun cacheKey(): String {
return "${brandId}_${pageNumber}_${pageSize}_$sort"
}
fun cacheKey(): String {
val sortKey = sort
.sortedWith(compareBy({ it.field }, { it.direction }))
.joinToString(",") { "${it.field}:${it.direction}" }
return "${brandId}_${pageNumber}_${pageSize}_${sortKey}"
}
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/kotlin/com/loopers/domain/common/PageCommand.kt
around lines 17 to 19, the cacheKey() uses the sort list's default toString()
which is order-sensitive and non-deterministic; change it to build a
deterministic, order-insensitive string by normalizing and serializing the sort
criteria: null-safe handle brandId/pageNumber/pageSize, sort each sort-entry by
a stable key (e.g., property name + direction), map each entry to a consistent
"field:direction" token, sort those tokens alphabetically, join with a delimiter
(e.g., comma) and use that joined string in the cache key so identical logical
sorts produce the same key regardless of input order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant