[volume-5] 인덱스와 캐시를 사용한 성능 최적화#221
[volume-5] 인덱스와 캐시를 사용한 성능 최적화#221shAn-kor wants to merge 45 commits intoLoopers-dev-lab:shAn-korfrom
Conversation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
📝 WalkthroughWalkthroughSpring AOP 의존성을 추가하고, Brand/Category 엔티티에 UUID 기반 참조 ID를 도입하며, DB 기반 및 Redis 기반 캐시 레이어를 구현한다. 공개 상품 상세 조회 캐싱 및 자동화된 캐시 동기화 인프라를 추가하고, 상품 목록 페이징을 offset 기반에서 커서 기반으로 변경한다. 광범위한 통합 테스트와 Docker 구성을 추가한다. Changes
Sequence Diagram(s)sequenceDiagram
actor Client
participant ProductController
participant PublicProductListQueryApplicationService
participant ProductApplicationService
participant BrandApplicationService
participant CacheRepository as Brand/Category<br/>CacheRepository
participant Database
Client->>ProductController: getProducts(query)
ProductController->>PublicProductListQueryApplicationService: list(criteria)
PublicProductListQueryApplicationService->>ProductApplicationService: listByCursor(criteria)
ProductApplicationService->>Database: searchByCursor (with Specifications)
Database-->>ProductApplicationService: ProductCursorPage
PublicProductListQueryApplicationService->>BrandApplicationService: findNamesByIds(brandIds)
BrandApplicationService->>CacheRepository: findNamesByIds
CacheRepository-->>BrandApplicationService: Map<UUID, String>
BrandApplicationService-->>PublicProductListQueryApplicationService: brandNames
PublicProductListQueryApplicationService->>PublicProductListQueryApplicationService: map to PublicProductListView
PublicProductListQueryApplicationService-->>ProductController: PublicProductListView
ProductController-->>Client: ApiResponse<PublicProductListResponse>
sequenceDiagram
actor Mutation as Write Operation<br/>(create/update/delete)
participant ApplicationService
participant Repository
participant TransactionSync as Transaction<br/>Synchronization
participant CacheSyncer
participant CacheRepository as Redis<br/>CacheRepository
participant FailurePersistence
Mutation->>ApplicationService: create/update/delete
ApplicationService->>Repository: save/delete
Repository->>Repository: database operation
ApplicationService->>CacheSyncer: registerUpsert/registerDelete
CacheSyncer->>TransactionSync: schedule afterCommit
TransactionSync-->>TransactionSync: commit
TransactionSync->>CacheSyncer: invoke syncUpsert/syncDelete
CacheSyncer->>CacheRepository: save/delete
alt Redis Success
CacheRepository-->>CacheSyncer: success
else Redis Failure
CacheRepository-->>CacheSyncer: RuntimeException
CacheSyncer->>FailurePersistence: recordFailure
FailurePersistence->>FailurePersistence: persist retry task
end
sequenceDiagram
actor Client
participant ProductQueryFacade
participant PublicProductDetailCacheAspect as `@CachedPublicProductDetail`<br/>Aspect
participant PublicProductDetailCacheApplicationService
participant PublicProductDetailCacheRepository as Redis<br/>Cache
participant ProductRepository
Client->>ProductQueryFacade: get(productId)
ProductQueryFacade->>PublicProductDetailCacheAspect: get(productId)
PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: enabled()
alt Cache Enabled
PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: true
PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: find(productId)
alt Cache Hit
PublicProductDetailCacheApplicationService->>PublicProductDetailCacheRepository: findByKey
PublicProductDetailCacheRepository-->>PublicProductDetailCacheApplicationService: ProductView
PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: Optional<ProductView>
PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView (cached)
else Cache Miss
PublicProductDetailCacheAspect->>PublicProductDetailCacheAspect: proceed (original method)
PublicProductDetailCacheAspect->>ProductRepository: query
ProductRepository-->>PublicProductDetailCacheAspect: ProductView
PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: save(productId, view)
PublicProductDetailCacheApplicationService->>PublicProductDetailCacheRepository: save(key, view, ttl)
PublicProductDetailCacheRepository-->>PublicProductDetailCacheApplicationService: success
PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView (cached)
end
else Cache Disabled
PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: false
PublicProductDetailCacheAspect->>PublicProductDetailCacheAspect: proceed (original method)
PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
추가 검토 사항운영 관점 주요 점검 항목
보안 및 성능 관점
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 1
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
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/java/com/loopers/application/product/ProductLikeAplicationService.java (1)
44-63:⚠️ Potential issue | 🟠 Major캐시 무효화를 AFTER_COMMIT으로 보장하도록 수정해야 한다.
@AfterReturning사용으로 인해 메서드 반환 후 트랜잭션 커밋 전에 캐시가 무효화된다. 이 시점에서 예외 발생 시 DB 업데이트는 롤백되지만 캐시는 이미 삭제되어 일시적 일관성 저하와 불필요한 캐시 미스가 발생한다.동일한 목적으로
BrandCacheSyncer,CategoryCacheSyncer에서는TransactionSynchronizationManager.registerSynchronization()패턴으로afterCommit()콜백을 사용하고 있다. 같은 방식으로PublicProductDetailCacheEvictionAspect도 개선해야 한다.다음을 추가할 것:
PublicProductDetailCacheApplicationService의evict()메서드를 호출하는 대신, 트랜잭션 커밋 후에만 실행되도록TransactionSynchronization콜백 등록- 롤백 시 eviction이 실행되지 않음을 검증하는 통합 테스트 추가 (
@Transactional(rollbackFor=Exception.class)사용)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java` around lines 44 - 63, The current cache eviction (in PublicProductDetailCacheEvictionAspect) runs via `@AfterReturning` and may execute before transaction commit; change it to register a TransactionSynchronization via TransactionSynchronizationManager.registerSynchronization() and invoke PublicProductDetailCacheApplicationService.evict(productId) only from the afterCommit() callback so eviction happens after successful commit (mirror BrandCacheSyncer/CategoryCacheSyncer pattern); update the aspect to stop using `@AfterReturning` and use TransactionSynchronizationManager inside the advice that currently calls evict, and add an integration test that performs an operation annotated with `@Transactional`(rollbackFor=Exception.class) which throws to verify PublicProductDetailCacheApplicationService.evict is NOT called on rollback.apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java (1)
14-107:⚠️ Potential issue | 🟡 MinorProductListCriteria.fromAdmin() 경로에 대한 테스트가 없어 테스트 커버리지가 불완전하다.
fromAdmin() 메서드는 공개 API이나 현재 테스트 파일에서 테스트되지 않는다. fromPublic()과는 다른 검증 규칙(예: 삭제 상품 필터링 허용)을 가지므로, 다음 시나리오에 대한 테스트를 추가해야 한다:
- 삭제 상품 조건(deleted=true)을 수락하는지
- 지원되는 정렬 옵션 및 범위 검증
- 페이지/크기 검증
- 가격 범위 검증
이는 운영 관점에서 관리자 기능의 안정성을 보장하고, 공개 API와 관리자 API의 검증 규칙 차이를 명확히 하기 위해 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java` around lines 14 - 107, Add tests covering the admin path by creating a new test suite or adding test methods that call ProductListCriteria.fromAdmin(ProductListQuery ...) and assert admin-specific behavior: verify deleted=true is accepted (no CoreException and criteria.deleted() == true), ensure supported sort mappings and legacy sort values map to ProductSortOption similarly to fromPublic, assert invalid sort throws BAD_REQUEST, validate page/size bounds produce BAD_REQUEST (using negative page or size<1), and validate minPrice>maxPrice produces BAD_REQUEST; use the same helper types ProductListQuery, ProductListCriteria, ProductSortOption, and CoreException/ErrorType to mirror existing fromPublic tests.
🟠 Major comments (20)
docker/infra-compose.yml-22-26 (1)
22-26:⚠️ Potential issue | 🟠 Major헬스체크에
start_period가 없어 초기 기동에서 오탐 가능성이 있다운영 관점에서 초기 DB 부트스트랩이 느린 환경에서는 Line 24-26 기준으로 조기
unhealthy가 발생하고, 그 결과mysql-seed가 시작되지 않아 재현성이 깨질 수 있다. 수정안은start_period를 추가하고timeout/retries를 완화해 초기화 구간을 흡수하는 것이다. 추가 테스트로 빈 볼륨 상태에서docker compose up시mysql이healthy에 도달하고 이후mysql-seed가 정상 실행되는지 검증해야 한다.수정 예시
healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uapplication", "-papplication"] interval: 5s - timeout: 3s - retries: 20 + timeout: 5s + retries: 30 + start_period: 60s🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/infra-compose.yml` around lines 22 - 26, 헬스체크 블록 (healthcheck with test)에 start_period가 없어 초기 부팅 시 오탐(unhealthy) 가능성이 있으니 healthcheck에 start_period를 추가하고 timeout/retries를 완화해 초기 DB 부트스트랩 여유를 주십시오; 예를 들어 start_period를 60s 이상으로 설정하고 timeout을 10s, retries를 30 수준으로 늘려 mysql의 느린 초기화 구간을 흡수하도록 변경하고, 변경 후 빈 볼륨 상태에서 docker compose up 실행해 mysql이 healthy에 도달하고 mysql-seed가 정상 시작되는지 검증하세요.docker/infra-compose.yml-5-7 (1)
5-7:⚠️ Potential issue | 🟠 Major
mysql_native_password전역 강제는 향후 버전 업그레이드 시 인증 장애를 초래한다Line 6의
--default-authentication-plugin=mysql_native_password설정은 운영상 큰 위험을 안고 있다. MySQL 8.0.4부터는caching_sha2_password가 기본 인증 플러그인이며,mysql_native_password는 8.0.34부터 deprecated 상태이고 MySQL 8.4에서는 기본 비활성화되어 있으며 9.0.0에서 완전히 제거될 예정이다. 현재 설정을 유지하면 컨테이너 업그레이드 시 인증 실패로 이어질 가능성이 높으며, 개발/운영 환경의 MySQL 버전이 달라질 경우 버전 간 동작 불일치가 발생한다.기본 인증 플러그인을 제거하고 불가피한 레거시 클라이언트가 필요한 경우에만 해당 계정의 인증 방식을
mysql_native_password로 명시적으로 지정하는 방식으로 수정해야 한다. 수정 후에는SELECT user, plugin FROM mysql.user;쿼리를 통해 실제로 레거시 계정에만 해당 플러그인이 적용되었는지 검증해야 한다.수정 예시
command: - - --default-authentication-plugin=mysql_native_password - --local-infile=1🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/infra-compose.yml` around lines 5 - 7, The docker compose sets a global flag --default-authentication-plugin=mysql_native_password which will break future MySQL upgrades; remove that flag from the command list and rely on the server default (caching_sha2_password), and if you must support legacy clients convert only specific accounts using ALTER USER ... IDENTIFIED WITH 'mysql_native_password' (apply during initialization or migration scripts) and then verify with SELECT user, plugin FROM mysql.user; to ensure only intended accounts use mysql_native_password; keep --local-infile=1 as needed.docker-compose.yml-9-12 (1)
9-12:⚠️ Potential issue | 🟠 Major정적 DB 자격 증명 하드코딩은 로컬 환경에서도 사고 반경을 키운다.
- 왜 문제인지(운영 관점): Line 9-12, Line 111-113의 고정 계정/비밀번호는 재사용·유출 시 다른 환경까지 동일 자격 증명이 전파될 수 있다.
- 수정안:
.env기반 주입으로 전환하고, 저장소에는 안전한 기본값 또는 필수값 검증만 남긴다.- 추가 테스트:
.env누락 시 기동이 실패하는지, 올바른 값 주입 시 정상 기동하는지를 CI에서 검증한다.Also applies to: 111-113
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yml` around lines 9 - 12, Replace the hardcoded MySQL credentials (MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD) in docker-compose.yml with environment-variable references sourced from a .env file; update docker-compose service env entries to use ${MYSQL_DATABASE}, ${MYSQL_USER}, ${MYSQL_PASSWORD}, ${MYSQL_ROOT_PASSWORD} and add documentation/default .env.example with no secrets, and add startup validation (or docker-compose override) to fail fast when any of those variables are missing so CI can assert that launching without a .env fails and that providing valid values starts correctly.docker-compose.yml-49-50 (1)
49-50:⚠️ Potential issue | 🟠 MajorRedis 무인증 + 외부 포트 노출 조합은 보안 리스크가 크다.
- 왜 문제인지(운영 관점): 49-50, 73-74줄에서 호스트 포트를 모든 네트워크 인터페이스(0.0.0.0)로 공개하면서 53-60, 77-89줄에 인증 설정이 없으면 로컬 네트워크 내 비인가 접근으로 데이터 변조/삭제가 가능하다. 특히 109-117줄 애플리케이션 설정에도 Redis 비밀번호가 없어 누구든 Redis에 접근할 수 있다.
- 수정안: Redis master에
requirepass비밀번호를 설정하고, replica에masterauth를 동일하게 적용한다. 포트 바인딩을127.0.0.1:6379와127.0.0.1:6380으로 제한하고(외부 접근 차단), 애플리케이션 환경변수에 동일 비밀번호를 추가한다.- 추가 테스트: 비밀번호 없이
redis-cli PING이 거부되는지, 인증 후 읽기/쓰기 및 master-replica 복제 동작이 정상인지 확인한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yml` around lines 49 - 50, The docker-compose Redis service currently exposes ports with "6379:6379" (and similarly for the replica) and has no authentication; update the Redis master service to set a strong requirepass and the replica service to set masterauth to the same password, restrict host port bindings to 127.0.0.1:6379 (and 127.0.0.1:6380 for the replica) instead of 0.0.0.0, and add the same password to the application environment (the app's REDIS_PASSWORD/related env var) so the app authenticates when connecting; then verify redis-cli PING requires AUTH and that authenticated read/write and master-replica replication work.docker-compose.yml-5-7 (1)
5-7:⚠️ Potential issue | 🟠 Majormysql_native_password 강제 설정은 MySQL 버전 업그레이드 시 호환성 문제를 야기한다.
Line 6의
--default-authentication-plugin=mysql_native_password설정은 다음과 같은 운영상 문제를 초래한다:
- MySQL 8.4 이상에서는 mysql_native_password가 기본적으로 비활성화되어 인증 실패가 발생한다.
- MySQL 9.0 이상에서는 플러그인 자체가 제거되어 업그레이드가 불가능해진다.
- 보안 측면에서도 mysql_native_password는 레거시 방식이며, caching_sha2_password(MySQL 8.0의 기본값)가 더 강력한 인증 메커니즘을 제공한다.
해결 방안: 해당 플러그인 강제 설정을 제거하여 MySQL 8.0의 기본 인증 플러그인(caching_sha2_password)을 사용하도록 변경한다. 이는 향후 버전 업그레이드 시 마이그레이션 경로를 차단하지 않으면서 보안 수준을 향상시킨다.
추가 테스트: MySQL 컨테이너 기동 후 Spring JDBC 드라이버를 통한 DB 연결 성공 여부와 마이그레이션 스크립트(schema, reset, data load) 실행 완료를 검증한다.
수정 예시 diff
command: - - --default-authentication-plugin=mysql_native_password - --local-infile=1🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yml` around lines 5 - 7, Remove the forced authentication flag --default-authentication-plugin=mysql_native_password from the docker-compose command block (leave other flags like --local-infile=1 intact) so the container uses MySQL's default caching_sha2_password; then restart the MySQL container and verify connectivity from the Spring JDBC client and that all migration scripts (schema, reset, data load) run successfully to ensure no auth regressions.docker-compose.yml-90-94 (1)
90-94:⚠️ Potential issue | 🟠 MajorReplica 헬스체크는
PING만으로 충분하지 않다.
- 왜 문제인지(운영 관점): Line 91의
PING기반 체크는 프로세스 생존만 확인하므로 복제 단절 상태(master_link_status:down)에서도 healthy로 판정되어 읽기 지연 및 데이터 정합성 문제를 숨긴다. 특히 마스터와의 연결이 끊겼더라도 이전 데이터는 여전히 조회 가능하므로 stale data를 반환할 수 있다.- 수정안:
INFO replication에서role:slave및master_link_status:up을 모두 확인하도록 헬스체크를 강화한다. 추가로master_last_io_seconds_ago값도 모니터링하여 복제 지연을 감지한다.timeout을 최소 3초 이상으로 설정하여 두 번의 redis-cli 호출이 완료될 충분한 시간을 확보한다.test: [ "CMD-SHELL", "redis-cli -p 6379 INFO replication | grep -q 'role:slave' && redis-cli -p 6379 INFO replication | grep -q 'master_link_status:up'" ]- 추가 테스트: (1) 마스터 일시 중단/재개 시 replica 헬스 상태가 fail/recover 되는지 검증한다. (2) 네트워크 단절 시뮬레이션으로 master_link_status가 down으로 변경되고 헬스체크가 실패하는지 확인한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yml` around lines 90 - 94, Update the docker-compose service healthcheck block (the healthcheck and test fields) to stop using a simple PING and instead run a CMD-SHELL that calls redis-cli INFO replication and verifies role:slave and master_link_status:up (and optionally parses master_last_io_seconds_ago to detect large replication lag); increase the timeout to at least 3s so both redis-cli calls can complete, and keep retries/interval sensible; ensure the healthcheck uses redis-cli on port 6379 and returns non-zero on failure so Docker marks the replica unhealthy.docker/mysql/seed/02-reset.sql-1-5 (1)
1-5:⚠️ Potential issue | 🟠 Major
read_model_sync_task초기화 누락으로 재시드 시 캐시 동기화 오염 위험이 있다운영 관점에서 재시드를 여러 번 수행하면 이전 실행의 동기화 태스크가 남아 비정상 재처리, 캐시 덮어쓰기, 디버깅 난이도 증가로 이어진다.
read_model_sync_task도 함께 비워 재현성을 보장해야 한다. 추가 테스트로reset -> load를 2회 연속 수행한 뒤read_model_sync_task가 항상 0건인지와 불필요한 재시도 로그가 없는지 검증해야 한다.수정안 예시
TRUNCATE TABLE likes; TRUNCATE TABLE members; TRUNCATE TABLE products; TRUNCATE TABLE brands; TRUNCATE TABLE categories; +TRUNCATE TABLE read_model_sync_task;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/mysql/seed/02-reset.sql` around lines 1 - 5, 현재 재시드 스크립트(02-reset.sql)는 likes, members, products, brands, categories만 비우고 있어 이전 실행의 동기화 태스크가 남아 재처리/캐시 오염을 초래할 수 있으니, 같은 스크립트에 read_model_sync_task 테이블을 함께 비우도록 TRUNCATE TABLE read_model_sync_task; 문을 추가하고, 변경 후에는 reset -> load를 2회 연속 실행해 read_model_sync_task의 건수가 항상 0인지와 불필요한 재시도 로그가 없는지(재시도/동기화 관련 로그가 발생하지 않는지) 확인하세요.apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskStatus.java-3-7 (1)
3-7:⚠️ Potential issue | 🟠 Major캐시 동기화 실패 작업의 재시도 및 DEAD 상태 처리 로직 구현 필요.
PENDING,COMPLETED,DEAD상태는 정의되어 있으나, 실제 상태 전이와 재시도 메커니즘이 없다. 현재 구조는 실패를 기록하기만 하고 처리하지 않는다:
재시도 스케줄러 구현 필수: PENDING 상태 작업을 주기적으로 처리할
@Scheduled메서드 또는 배치 서비스 부재.nextAttemptAt필드가 있으나 활용되지 않음.최대 재시도 횟수 및 DEAD 상태 전이 규칙 부재:
attemptCount필드가 있으나 제한이 없고, DEAD 상태로 전이하는 로직이 전혀 없음. 무한 PENDING 상태에 빠질 수 있음.DEAD 상태 모니터링 및 알람 전략 부재: DEAD 작업 발생 시 운영팀 알림, 대시보드 노출, 수동 복구 절차가 없음. 데이터 유실 감지 불가.
중간 상태(FAILED, RETRYING) 부재로 인한 상태 추적 불가: 세 가지 상태만으로는 재시도 중인 작업과 최종 실패 작업을 구분할 수 없음.
상태 전이 로직과 함께 재시도 정책(지수 백오프, 최대 시도 횟수), 모니터링/알람 연동, 실패 작업 수동 재처리 메커니즘을 포함한 완성된 구현이 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskStatus.java` around lines 3 - 7, Extend CacheSyncTaskStatus to include intermediate states (e.g., RETRYING, FAILED) and update any consumers to use them; implement a retry scheduler (a `@Scheduled` method in your CacheSyncTaskService or a batch component that queries tasks with status PENDING or RETRYING and nextAttemptAt <= now) that invokes the sync logic, increments attemptCount, computes exponential backoff to set nextAttemptAt, transitions to RETRYING while retrying and to COMPLETED on success, and transitions to DEAD when attemptCount >= MAX_ATTEMPTS; add a helper method (e.g., computeNextAttemptAt(task) or backoffMillis(attemptCount)) to centralize backoff logic and a constant MAX_ATTEMPTS; on transition to DEAD call a NotificationService/AlertService and set a dashboard flag / expose an endpoint to list DEAD tasks for manual reprocessing, and add a manualRetry(taskId) method that resets status to PENDING/RETRYING and clears/updates nextAttemptAt to trigger reprocessing.apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheProperties.java-9-14 (1)
9-14:⚠️ Potential issue | 🟠 Major캐시 설정값 경계 검증이 없어 잘못된 배포값을 런타임까지 끌고 간다.
ttl <= 0또는jitterRatio가 0~1 범위를 벗어나면 운영에서 캐시가 즉시 만료되거나 TTL 계산이 비정상화될 수 있다. 설정 바인딩 시점에 즉시 실패하도록 검증을 넣어야 한다. 또한 잘못된 프로퍼티로 애플리케이션 컨텍스트가 실패하는 테스트를 추가해야 한다.수정 예시
`@ConfigurationProperties`("cache.public-product-detail") public record PublicProductDetailCacheProperties( `@DefaultValue`("true") boolean enabled, `@DefaultValue`("30s") Duration ttl, `@DefaultValue`("false") boolean jitterEnabled, `@DefaultValue`("0.0") double jitterRatio ) { + public PublicProductDetailCacheProperties { + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException("cache.public-product-detail.ttl must be > 0"); + } + if (jitterRatio < 0.0 || jitterRatio > 1.0) { + throw new IllegalArgumentException("cache.public-product-detail.jitter-ratio must be between 0.0 and 1.0"); + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheProperties.java` around lines 9 - 14, PublicProductDetailCacheProperties currently lacks validation so invalid config can reach runtime; add checks in the record's canonical constructor (or a static factory) to throw IllegalArgumentException when ttl == null or ttl.toMillis() <= 0 and when jitterRatio is outside [0.0, 1.0], keep fields enabled and jitterEnabled as-is, and include descriptive messages referencing ttl and jitterRatio; also add a unit/integration test that binds invalid properties (e.g., ttl=0s or jitterRatio=-0.1 or >1.0) and asserts the application context fails to start or the binding throws the expected exception.apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java-20-24 (1)
20-24:⚠️ Potential issue | 🟠 Major
save()메서드에서 기존 엔티티의 필드 업데이트 로직이 누락되어 있다.
category.id()가 non-null일 때, 기존 엔티티를 조회한 후 새로운 데이터를 반영하지 않고 그대로 재저장한다. 즉,category파라미터의 변경된 필드(예: 카테고리명 변경)가 완전히 무시되고, 기존 엔티티가 그대로 저장되어 업데이트가 발생하지 않는다.운영 관점에서 카테고리 수정 요청 시 변경사항이 반영되지 않아 데이터 정합성 문제가 발생한다. 또한 CategoryEntity에는
update()메서드가 존재하지 않으므로 이를 추가해야 한다.🐛 수정안
CategoryEntity saved = category.id() == null ? categoryJpaRepository.save(CategoryEntity.from(category)) : categoryJpaRepository.findByReferenceIdAndDeletedAtIsNull(category.id()) - .map(existing -> categoryJpaRepository.save(existing)) + .map(existing -> { + existing.update(category); + return categoryJpaRepository.save(existing); + }) .orElseGet(() -> categoryJpaRepository.save(CategoryEntity.from(category)));CategoryEntity에 아래의
update()메서드를 추가하고, 업데이트 동작을 테스트하는 단위 테스트를 작성해야 한다.public void update(Category category) { this.name = category.name(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java` around lines 20 - 24, The save() path currently re-saves the existing entity without applying changes from the incoming Category; update the logic so when category.id() is non-null and findByReferenceIdAndDeletedAtIsNull(...) returns an existing CategoryEntity you call an update method on it (implement CategoryEntity.update(Category) to set mutable fields like name) before saving, instead of saving the entity as-is or replacing it via CategoryEntity.from(category); also add unit tests for CategoryRepositoryImpl.save() that verify changed fields (e.g., name) are persisted when updating an existing entity.apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java-11-18 (1)
11-18:⚠️ Potential issue | 🟠 Major
deleted_at컬럼에 대한 인덱스 추가가 필수다.
reference_id컬럼에는 이미 UNIQUE KEY가 정의되어 있으나,deleted_at컬럼에는 인덱스가 없다.findAllByDeletedAtIsNull(Pageable)과findAllByDeletedAtIsNullOrderByIdAsc()메서드는 대량 데이터에서 전체 테이블 스캔을 수행하며, 특히 캐시 재구성 및 페이지네이션 조회 시 성능 병목이 발생한다. 다음과 같이 복합 인덱스를 추가해야 한다:CREATE INDEX idx_categories_deleted_at_id ON categories(deleted_at, id);이를 통해 소프트 삭제 기반의 조회가 효율적으로 동작한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java` around lines 11 - 18, Repository currently queries by deletedAt (methods findAllByDeletedAtIsNull, findAllByDeletedAtIsNullOrderByIdAsc, findByReferenceIdAndDeletedAtIsNull, existsByReferenceIdAndDeletedAtIsNull) but there is no DB index on deleted_at, causing full table scans; add a composite index on (deleted_at, id) to the categories table (e.g., CREATE INDEX idx_categories_deleted_at_id ON categories(deleted_at, id)) and deploy the migration so these JPA queries use the index for efficient pagination and soft-delete lookups.apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspect.java-18-21 (1)
18-21:⚠️ Potential issue | 🟠 Majorevict() 실패 시 예외가 전파되어 원본 메서드 결과가 무효화될 수 있다.
@AfterReturning으로 실행되는evict()에서 Redis 장애 등으로 예외가 발생하면, 원본 메서드(상품 변경)는 이미 성공했음에도 호출자에게 실패로 전파된다. 캐시 eviction은 TTL로 결국 만료되므로 best-effort로 처리하고, 실패 시 로깅만 수행하여 원본 트랜잭션 결과를 보존해야 한다.🐛 예외 처리 추가 제안
+ private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(PublicProductDetailCacheEvictionAspect.class); + `@AfterReturning`("@annotation(com.loopers.application.product.cache.EvictPublicProductDetailCache) && args(productId,..)") public void evict(UUID productId) { - publicProductDetailCacheApplicationService.evict(productId); + try { + publicProductDetailCacheApplicationService.evict(productId); + } catch (Exception e) { + log.warn("Failed to evict product detail cache for productId={}, will expire by TTL", productId, e); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspect.java` around lines 18 - 21, The evict(UUID productId) method in PublicProductDetailCacheEvictionAspect currently calls publicProductDetailCacheApplicationService.evict(productId) directly so failures (e.g., Redis errors) will propagate and can invalidate the original successful operation; wrap that call in a try-catch (catch Exception or RuntimeException), log the error via the existing logger with context (productId and that cache eviction failed), and do not rethrow so eviction becomes best-effort and the original method result remains unaffected.apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java-34-36 (1)
34-36:⚠️ Potential issue | 🟠 MajorjoinPoint.proceed() 결과가 null일 경우 예외 또는 불필요한 캐시 저장이 발생할 수 있다.
상품이 존재하지 않아
null이 반환되거나 예외가 발생하는 경우, null 값이 캐시에 저장된다. Redis 직렬화 설정에 따라 NPE 또는 직렬화 오류가 발생할 수 있으며, 정상 저장되더라도 이후 cache hit 시 null이 반환되어 의도치 않은 동작을 유발한다.🐛 null 체크 추가 제안
ProductView resolved = (ProductView) joinPoint.proceed(); - publicProductDetailCacheApplicationService.save(productId, resolved); + if (resolved != null) { + publicProductDetailCacheApplicationService.save(productId, resolved); + } return resolved;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java` around lines 34 - 36, joinPoint.proceed() can return null (or throw), so avoid saving null into the cache: call joinPoint.proceed() and assign to ProductView resolved, then if (resolved != null) invoke publicProductDetailCacheApplicationService.save(productId, resolved); otherwise skip caching and simply return the null/absent result; do not catch and swallow exceptions from joinPoint.proceed()—let them propagate (or rethrow) so failures are not hidden.apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java-23-37 (1)
23-37:⚠️ Potential issue | 🟠 Major캐시 조회/저장 실패 시 예외 처리가 없어 원본 로직이 실행되지 않을 수 있다.
publicProductDetailCacheApplicationService.find()나save()에서 Redis 장애로 예외가 발생하면, aspect가 예외를 전파하여 원본 로직(joinPoint.proceed())이 실행되지 않거나, 결과가 반환되지 않는다. 캐시는 보조 시스템이므로 실패 시 fallback으로 원본 로직을 실행해야 한다.🐛 캐시 실패 시 fallback 처리 제안
`@Around`("@annotation(com.loopers.application.product.cache.CachedPublicProductDetail) && args(productId)") public Object cache(ProceedingJoinPoint joinPoint, UUID productId) throws Throwable { if (!publicProductDetailCacheApplicationService.enabled()) { return joinPoint.proceed(); } - Optional<ProductView> cached = publicProductDetailCacheApplicationService.find(productId); - if (cached.isPresent()) { - return cached.get(); + try { + Optional<ProductView> cached = publicProductDetailCacheApplicationService.find(productId); + if (cached.isPresent()) { + return cached.get(); + } + } catch (Exception e) { + log.warn("Cache lookup failed for productId={}, falling back to origin", productId, e); + return joinPoint.proceed(); } ProductView resolved = (ProductView) joinPoint.proceed(); - publicProductDetailCacheApplicationService.save(productId, resolved); + if (resolved != null) { + try { + publicProductDetailCacheApplicationService.save(productId, resolved); + } catch (Exception e) { + log.warn("Cache save failed for productId={}", productId, e); + } + } return resolved; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java` around lines 23 - 37, PublicProductDetailCacheAspect.cache currently calls publicProductDetailCacheApplicationService.find(...) and save(...) without handling failures; wrap the cache interactions in try/catch blocks so that any exception from publicProductDetailCacheApplicationService.find or save is caught, logged, and the method falls back to calling joinPoint.proceed() (or returns the resolved ProductView) instead of propagating the exception; ensure enabled() is still checked and that save failures do not prevent returning the resolved result from joinPoint.proceed().apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheRepositoryImpl.java-75-80 (1)
75-80:⚠️ Potential issue | 🟠 Major개별 키 작업 실패가
category:all무효화까지 건너뛰게 만든다.이 전략에서는
category:all에 TTL이 없으므로 목록 키 삭제가 사실상 유일한 일관성 장치다. 그런데 Line 77 또는 Line 86이 실패하면 Line 78/87은 실행되지 않고 예외도 삼켜져서, 오래된 목록 캐시가 무기한 남을 수 있다. 운영에서는 Redis 일시 장애 한 번으로 삭제/수정된 카테고리가 계속 노출될 수 있다. 개별 키 처리와 목록 키 무효화를 분리해서category:all삭제는 항상 별도로 시도하고, 상세 키 갱신 실패 시에는 해당 상세 키도 함께 제거한 뒤 warn/metric을 남기는 편이 안전하다. 추가 테스트로opsForValue().set(...)또는delete(categoryKey(...))가 예외를 던져도category:all은 제거되고 다음findAll()이 DB 기준으로 재구성되는지 검증해 달라.예시 수정안
public void save(Category category) { - try { - redisTemplate.opsForValue().set(categoryKey(category.id()), writeCategory(CategoryCacheDocument.from(category))); - redisTemplate.delete(CATEGORY_ALL_KEY); - } catch (RuntimeException ignored) { - } + try { + redisTemplate.opsForValue().set(categoryKey(category.id()), writeCategory(CategoryCacheDocument.from(category))); + } catch (RuntimeException e) { + safeDelete(categoryKey(category.id())); + log.warn("Failed to upsert category cache. categoryId={}", category.id(), e); + } finally { + safeDelete(CATEGORY_ALL_KEY); + } } `@Override` public void delete(UUID id) { - try { - redisTemplate.delete(categoryKey(id)); - redisTemplate.delete(CATEGORY_ALL_KEY); - } catch (RuntimeException ignored) { - } + safeDelete(categoryKey(id)); + safeDelete(CATEGORY_ALL_KEY); }Also applies to: 84-89
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheRepositoryImpl.java` around lines 75 - 80, The save(Category) method currently swallows RuntimeExceptions so a failure setting the individual key (redisTemplate.opsForValue().set in save) can prevent CATEGORY_ALL_KEY deletion; change save and the similar delete/update flow so that CATEGORY_ALL_KEY invalidation is attempted unconditionally (move redisTemplate.delete(CATEGORY_ALL_KEY) into a finally or its own try/catch), and if the individual detail update (redisTemplate.opsForValue().set or redisTemplate.delete(categoryKey(...))) fails, ensure you remove the possibly-stale detail key and emit a warn/metric (use the same logger/metrics mechanism) before rethrowing or continuing; add tests that simulate opsForValue().set and delete(categoryKey(...)) throwing to assert CATEGORY_ALL_KEY is still deleted and next findAll() falls back to DB rebuilding.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSpecifications.java-24-24 (1)
24-24:⚠️ Potential issue | 🟠 Major문자열 리터럴로 지정한 필드명은 리팩터링 시 런타임 오류 위험이 있다.
ProductSpecifications의
"deletedAt","brandReferenceId","categoryReferenceId","price"와 ProductRepositoryImpl의"likeCount","id","createdAt","name"등 문자열 리터럴은 컴파일 타임에 검증되지 않는다. ProductEntity나 AutoIncrementBaseEntity의 필드명을 변경할 때 이 파일들이 누락되면 런타임에IllegalArgumentException이 발생한다.현재 프로젝트는 QueryDSL을 사용 중이므로, JPA Metamodel 도입 시 빌드 복잡도를 고려해야 한다. 최소한 ProductApplicationServiceIntegrationTest 패턴을 활용하여 ProductListCriteria의 다양한 조건(brandId, categoryId, minPrice, maxPrice, deleted 필터)에 대한 통합 테스트를 추가하여 필드명 변경 시 회귀를 방지하도록 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSpecifications.java` at line 24, 여러 곳에서 문자열 리터럴로 사용한 엔티티 필드명("deletedAt", "brandReferenceId", "categoryReferenceId", "price", "likeCount", "id", "createdAt", "name")은 리팩터링 시 런타임 예외를 유발하므로 ProductEntity와 AutoIncrementBaseEntity에 각각 FIELD_* 상수(예: FIELD_DELETED_AT, FIELD_ID 등)를 추가하고 ProductSpecifications의 predicates 생성부와 ProductRepositoryImpl의 쿼리 생성부에서 문자열 리터럴 대신 해당 상수를 사용하도록 교체하라; 추가로 QueryDSL/메타모델 도입이 부담스러우면 최소한 ProductApplicationServiceIntegrationTest에 ProductListCriteria의 brandId, categoryId, minPrice, maxPrice, deleted 필터 조합에 대한 통합 테스트를 추가하여 필드명 변경 시 회귀를 방지하라.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-76-105 (1)
76-105:⚠️ Potential issue | 🟠 Major커서 파싱 시 예외 처리가 누락되었다.
Integer.parseInt(cursor.primaryValue())(Lines 78, 80, 92, 94) 및ZonedDateTime.parse(cursor.primaryValue())(Lines 85, 87)에서 잘못된 커서 값이 전달되면NumberFormatException또는DateTimeParseException이 발생한다.클라이언트가 조작된 커서를 전송할 경우 500 Internal Server Error가 반환되며, 이는 보안 취약점(error-based information disclosure)이 될 수 있다. 파싱 실패 시 적절한 예외(
CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."))로 변환해야 한다.수정 예시
if (criteria.cursor() != null) { ProductCursor cursor = criteria.cursor(); + try { Predicate cursorPredicate = switch (criteria.sortOption()) { case LIKES -> cb.or( cb.lessThan(root.get("likeCount"), Integer.parseInt(cursor.primaryValue())), // ... ); // ... }; predicate = cb.and(predicate, cursorPredicate); + } catch (NumberFormatException | DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` around lines 76 - 105, ProductRepositoryImpl currently calls Integer.parseInt(cursor.primaryValue()) and ZonedDateTime.parse(cursor.primaryValue()) directly in the cursorPredicate switch (cases LIKES, LATEST, PRICE, NAME), which will throw NumberFormatException or DateTimeParseException for malformed cursors; wrap the parsing logic for those cases in try/catch blocks (or centralize parsing into a small helper method used by the switch) and on parse failure throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."); ensure you replace the raw parse calls in the switch (the places using Integer.parseInt and java.time.ZonedDateTime.parse) with the safe-parsed values or the helper method so malformed cursors return the BAD_REQUEST CoreException instead of a 500.apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java-38-40 (1)
38-40:⚠️ Potential issue | 🟠 Major시드 데이터에
UUID.randomUUID()를 쓰면 재현성이 깨진다운영 관점에서 로컬 성능 실험이나 장애 재현 시 카테고리 ID가 매번 달라지면, 같은 시나리오를 다시 실행해도 캐시 키와 연관 데이터가 달라져 결과 비교가 어려워진다. PR 목표가 재현 가능한 seed 인프라인 만큼, 고정 UUID 목록을 두거나
name기반 결정적 UUID로 바꿔 시드 결과를 항상 동일하게 만드는 편이 안전하다.변경 예시
+import java.nio.charset.StandardCharsets; ... - .map(name -> new CategoryEntity(UUID.randomUUID(), name)) + .map(name -> new CategoryEntity( + UUID.nameUUIDFromBytes(("category:" + name).getBytes(StandardCharsets.UTF_8)), + name + ))추가 테스트로는 빈 DB에서 두 번 초기화했을 때 동일한 카테고리 이름 집합이 동일한 UUID 집합으로 생성되는지와, 기대한 referenceId가 고정되는지를 검증하면 된다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java` around lines 38 - 40, The current CategorySeedInitializer builds seedEntities using UUID.randomUUID(), which breaks reproducibility; replace that with a deterministic UUID strategy (e.g., use UUID.nameUUIDFromBytes(name.getBytes(UTF_8)) or a fixed map of UUIDs keyed by DEFAULT_CATEGORIES) so CategoryEntity instances for the same name always get the same id; update the stream in the code that creates seedEntities to compute the deterministic UUID from the category name instead of UUID.randomUUID(), and add tests that run the initializer twice on an empty DB and assert the set of generated UUIDs for DEFAULT_CATEGORIES is identical and matches expected referenceIds.apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.java-6-35 (1)
6-35:⚠️ Potential issue | 🟠 Major
CacheSyncTask는 필수 필드를 생성 시점에 막아야 한다운영 관점에서 재시도 task는 장애 복구의 마지막 안전망인데,
aggregateType,aggregateId,operationType,status,nextAttemptAt같은 필수 값이null이어도 객체가 만들어지면 실제 실패는 JPA 저장이나 재처리 워커에서 늦게 터져 복구 경로가 더 불안정해진다. record compact constructor에서 필수 필드를Objects.requireNonNull로 검증해 잘못된 task를 생성 시점에 차단하는 편이 안전하다.변경 예시
import java.time.ZonedDateTime; +import java.util.Objects; import java.util.UUID; ... ) { + public CacheSyncTask { + Objects.requireNonNull(aggregateType, "aggregateType must not be null"); + Objects.requireNonNull(aggregateId, "aggregateId must not be null"); + Objects.requireNonNull(operationType, "operationType must not be null"); + Objects.requireNonNull(status, "status must not be null"); + Objects.requireNonNull(nextAttemptAt, "nextAttemptAt must not be null"); + } + public static CacheSyncTask pending(추가 테스트로는
pending(...)에 필수 인자를null로 넣었을 때 즉시 예외가 나는지와, 정상 입력 시PENDING,attemptCount=0,nextAttemptAt이 채워지는지를 검증하면 된다.As per coding guidelines, "
**/*.java: ... null 처리 ... 점검한다.and/domain//*.java`: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.java` around lines 6 - 35, Add a compact constructor to CacheSyncTask that validates required fields at creation: use Objects.requireNonNull for aggregateType, aggregateId, operationType, status, and nextAttemptAt (and lastError if you want non-null semantics) so any attempt to create an invalid record fails fast; update the class to include this compact constructor (preserving the existing static pending(...) factory) and add the necessary import for java.util.Objects.apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java-31-33 (1)
31-33:⚠️ Potential issue | 🟠 Major인프라 계층의
CategorySeedInitializer에서@Transactional제거 필요
CategorySeedInitializer는 인프라 계층 컴포넌트이므로@Transactional을 선언하면 안 된다. 운영 관점에서 초기화 로직과 일반 유스케이스의 트랜잭션 경계가 일치하지 않아 롤백 동작이 예측 불가능해진다. 아키텍처 원칙에 따라 트랜잭션 관리는 애플리케이션 계층(CategoryApplicationService)이 담당해야 한다.
run()메서드는@Transactional을 제거하고, 대신 애플리케이션 서비스에seedIfEmpty()같은 전용 메서드를 추가하여 트랜잭션 경계를 여기서 관리하도록 리팩토링한다. 그 후 통합 테스트로 롤백 시 카테고리 저장과 캐시 등록이 모두 반영되지 않는지, 정상 커밋 후에만 동기화가 실행되는지 확인한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java` around lines 31 - 33, Remove the `@Transactional` annotation from CategorySeedInitializer.run(...) and delegate transactional seeding to the application layer by adding a new method on CategoryApplicationService (e.g. seedIfEmpty()) that wraps the seeding logic in a transaction; update CategorySeedInitializer to inject and call CategoryApplicationService.seedIfEmpty() instead of performing DB work directly, and adjust/instrument integration tests to assert that both category persistence and cache registration are rolled back on transaction failure and only synchronized after a successful commit.
🟡 Minor comments (5)
apps/commerce-api/src/main/resources/application.yml-35-37 (1)
35-37:⚠️ Potential issue | 🟡 Minor운영 환경에서
jitter-enabled: false기본값은 thundering herd 위험이 있다.
jitter-enabled: false가 모든 프로파일에 적용되므로, 운영 환경에서 다수의 캐시 키가 동시에 만료될 경우 DB에 순간적인 부하가 집중될 수 있다.jitter-ratio: 0.1을 설정해 두었으나jitter-enabled가 꺼져 있어 의미가 없다.수정안:
- 운영 프로파일(prd)에서
jitter-enabled: true로 오버라이드하거나, 기본값 자체를true로 변경한다.- 상품 상세 캐시 히트율과 만료 시점 분포를 모니터링하여 thundering herd 발생 여부를 확인한다.
추가 테스트: 캐시 TTL 만료 시점에 동시 요청 부하 테스트를 수행하여 DB 스파이크 발생 여부를 검증한다.
As per coding guidelines: "타임아웃, 커넥션 풀, 로깅 레벨 등 운영에 영향을 주는 설정 변경은 근거와 영향 범위를 요구한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/resources/application.yml` around lines 35 - 37, The current application.yml sets jitter-enabled: false which risks thundering-herd spikes when many cache keys expire; change configuration so jitter is enabled in production—either set jitter-enabled: true as the default or override it in the production profile (prd) to true, keep jitter-ratio: 0.1 (or tune it) and ttl as appropriate; after making the change, deploy to a staging/prd-like environment and run concurrent cache-expiry load tests while monitoring product-detail cache hit rate and DB/connection-pool metrics to verify the mitigation.docker/mysql/seed/03-load-csv.sql-11-16 (1)
11-16:⚠️ Potential issue | 🟡 Minor
LOAD DATA LOCAL INFILE기능 검증 강화가 필요하다현재 인프라는 MySQL 서버와 시드 실행 클라이언트에
--local-infile=1옵션을 모두 적용하고 있으나, 실제로 이 기능이 정상 작동하는지 검증하는 단계가 부족하다. 현재 healthcheck는 MySQL 서버의 가용성만 확인하고 local_infile 활성화 여부는 검증하지 않는다. 시드 실행 시 CSV 적재 실패 여부를 명확히 판단하기 어렵고, 오류 발생 시 원인 파악이 제한적이다.시드 시작 전에 명시적인 preflight 검증을 추가하여 local_infile 기능 활성화 상태를 확인하고, 시드 실행 후 적재된 행 수를 검증하는 단계를 도입해야 한다. 또한 LOAD DATA 실행 시 상세 로깅과 오류 처리를 개선하여 장애 재현성을 높여야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker/mysql/seed/03-load-csv.sql` around lines 11 - 16, Add explicit preflight and post-load checks around the LOAD DATA LOCAL INFILE step: before running the LOAD DATA LOCAL INFILE '/seed-data/categories-seed.csv' INTO TABLE staging_categories ... statement, run a query to assert @@local_infile = 1 (or fail with a clear error message) to ensure the client/server local_infile feature is enabled; after LOAD DATA completes, query ROW_COUNT() or SELECT COUNT(*) FROM staging_categories WHERE ... to verify the expected number of rows were imported and log both expected vs actual counts; also wrap the LOAD DATA execution with enhanced logging of the full statement, any SQL warnings/errors (SHOW WARNINGS), and a non-zero exit/error path when import or verification fails so CI/ops can detect and troubleshoot failures.apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheDocument.java-11-13 (1)
11-13:⚠️ Potential issue | 🟡 Minornull 방어 코드 부재 -
category파라미터가 null이면 NPE 발생한다.
from(Category category)메서드에서category가 null일 경우category.id()호출 시 NPE가 발생한다. 인프라 계층에서 예상치 못한 데이터로 인한 장애를 방지하려면 null 체크 또는 명시적 예외 처리가 필요하다.🛡️ null 방어 코드 추가 제안
public static CategoryCacheDocument from(Category category) { + if (category == null) { + throw new IllegalArgumentException("category must not be null"); + } return new CategoryCacheDocument(category.id(), category.name()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheDocument.java` around lines 11 - 13, The static factory CategoryCacheDocument.from(Category category) currently dereferences category without null checks and can throw an NPE; update from(...) in CategoryCacheDocument to validate the category parameter (either return null/Optional, or throw a clear IllegalArgumentException) before calling category.id() / category.name(), and include a descriptive message when throwing so callers can understand the contract.apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java-241-251 (1)
241-251:⚠️ Potential issue | 🟡 Minor첫 페이지 응답의 성공/커서 유효성 선검증이 빠져 플래키 위험이 있다.
Line 241-251에서 첫 요청의 상태코드와
nextCursor존재를 먼저 단정하지 않으면, 실패 응답에서도 빈 문자열 커서를 추출해 후속 검증이 왜곡될 수 있다. 첫 요청에status().isOk()와$.data.nextCursor유효성 단정을 추가하고,hasNext=false일 때 커서 미전달 시나리오 테스트를 별도로 추가하는 것이 안전하다.수정 예시
- String cursor = objectMapper.readTree( - mockMvc.perform(get("/api/v1/products") - .param("brandId", brandId.toString()) - .param("sort", "likes") - .param("size", "2")) - .andReturn() - .getResponse() - .getContentAsString()) + String firstPageBody = mockMvc.perform(get("/api/v1/products") + .param("brandId", brandId.toString()) + .param("sort", "likes") + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.nextCursor").isString()) + .andReturn() + .getResponse() + .getContentAsString(); + + String cursor = objectMapper.readTree(firstPageBody) .path("data") .path("nextCursor") .asText();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java` around lines 241 - 251, In ProductControllerTest update the first mockMvc call that retrieves the initial page to assert the HTTP status and cursor presence before extracting it: add .andExpect(status().isOk()) and a jsonPath assertion like jsonPath("$.data.nextCursor").isNotEmpty() (locate the mockMvc.perform(...) block that currently reads response to objectMapper.readTree and extracts "data.nextCursor"), then extract the cursor; also add a separate test case for the scenario when hasNext=false that performs a request and asserts status().isOk() and that $.data.nextCursor is absent or empty (use jsonPath(...).doesNotExist() or isEmptyString()) to validate the no-cursor behavior.apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java-67-79 (1)
67-79:⚠️ Potential issue | 🟡 Minor설명 필드 동기화 검증이 현재는 거짓 양성을 허용한다.
Line 77의
assertThat(cached).contains("new")는imageUrl값인new-img만 들어 있어도 통과한다. 운영에서는 Redis 읽기 모델의 설명 필드가 깨져도 CI가 놓치게 된다. 캐시 문자열을 역직렬화해서 필드별로 비교하거나, 최소한 서로 겹치지 않는 값으로 업데이트한 뒤description과imageUrl을 각각 검증하는 편이 안전하다. 추가 테스트로 설명만 변경하고 이미지 URL은 유지하는 케이스를 넣어 필드별 동기화를 확인해 달라.예시 수정안
- Brand updated = brandApplicationService.update(created.id(), new UpdateBrandCommand("new", "new-img")); + Brand updated = brandApplicationService.update(created.id(), new UpdateBrandCommand("desc-v2", "img-v2")); String cached = redisTemplate.opsForValue().get("brand:" + created.id()); assertThat(updated.description()).isEqualTo("new"); - assertThat(cached).contains("new"); - assertThat(cached).contains("new-img"); + assertThat(cached).contains("\"description\":\"desc-v2\""); + assertThat(cached).contains("\"imageUrl\":\"img-v2\"");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java` around lines 67 - 79, The test updateAlsoSyncsRedisReadModel currently asserts cached contains("new") which can pass if only imageUrl contains that substring; instead retrieve and parse the cached read-model (from redisTemplate.opsForValue().get("brand:" + created.id())) into its fields and assert description.equals("new") and imageUrl.equals("new-img") explicitly, or update the test to use non-overlapping values (e.g., description="desc-new" and imageUrl="img-new") and assert each field separately; also add a second test that calls brandApplicationService.update(created.id(), new UpdateBrandCommand("desc-only", existingImageUrl)) to verify description-only updates sync the Redis read model (use BrandApplicationService.create/ update, CreateBrandCommand/UpdateBrandCommand and redisTemplate.opsForValue().get to locate the code points).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a7794da0-bb42-48a8-a067-11901d9519c6
⛔ Files ignored due to path filters (10)
docker/mysql/seed-data/brands-seed.csvis excluded by!**/*.csvand included by**docker/mysql/seed-data/categories-seed.csvis excluded by!**/*.csvand included by**docker/mysql/seed-data/products-seed-300000.csvis excluded by!**/*.csvand included by**docs/adr/2026-03-13-brand-category-cache-adr.mdis excluded by!**/*.mdand included by**docs/adr/2026-03-13-like-count-denormalization-adr.mdis excluded by!**/*.mdand included by**docs/adr/2026-03-13-product-search-index-benchmark-adr.mdis excluded by!**/*.mdand included by**docs/adr/2026-03-13-product-search-paging-adr.mdis excluded by!**/*.mdand included by**docs/adr/2026-03-13-public-product-cache-adr.mdis excluded by!**/*.mdand included by**docs/design/03-class-diagram.mdis excluded by!**/*.mdand included by**docs/design/04-erd.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (84)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/brand/BrandCacheRepository.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryCacheRepository.javaapps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/PublicProductDetailCacheApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/PublicProductListQueryApplicationService.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/CachedPublicProductDetail.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/EvictPublicProductDetailCache.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspect.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheProperties.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheRepository.javaapps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheTtlPolicy.javaapps/commerce-api/src/main/java/com/loopers/application/product/view/PublicProductListItemView.javaapps/commerce-api/src/main/java/com/loopers/application/product/view/PublicProductListView.javaapps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncAggregateType.javaapps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncOperationType.javaapps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.javaapps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskStatus.javaapps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductCursor.javaapps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductCursorPage.javaapps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.javaapps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.javaapps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductSortOption.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/db/BrandDbPassThroughCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheDocument.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncFailurePersistence.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/db/CategoryDbPassThroughCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheDocument.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheSyncer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSpecifications.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/redis/PublicProductDetailCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/coupon/category/CategoryApplicationServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/PublicProductDetailCacheApplicationServiceTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/PublicProductDetailCacheEvictionWiringTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheAspectTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspectTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheTtlPolicyTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/cache/BrandCategoryCacheToggleTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.javadocker-compose.ymldocker/infra-compose.ymldocker/mysql/init/01-schema.sqldocker/mysql/seed/02-reset.sqldocker/mysql/seed/03-load-csv.sqlmodules/jpa/src/main/java/com/loopers/domain/AutoIncrementBaseEntity.javamodules/jpa/src/main/resources/jpa.ymlmodules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
💤 Files with no reviewable changes (4)
- apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
- modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
- apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
- apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java
| public void syncUpsert(Brand brand) { | ||
| try { | ||
| brandCacheRepository.save(brand); | ||
| } catch (RuntimeException e) { | ||
| log.error("브랜드 Redis 읽기 모델 동기화에 실패했습니다. brandId={}", brand.id(), e); | ||
| cacheSyncFailurePersistence.recordBrandUpsertFailure(brand, e); | ||
| } | ||
| } | ||
|
|
||
| public void syncDelete(UUID brandId) { | ||
| try { | ||
| brandCacheRepository.delete(brandId); | ||
| } catch (RuntimeException e) { | ||
| log.error("브랜드 Redis 읽기 모델 삭제 동기화에 실패했습니다. brandId={}", brandId, e); | ||
| cacheSyncFailurePersistence.recordBrandDeleteFailure(brandId, e); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: 캐시 동기화 실패 재처리 스케줄러 존재 여부 확인
# CacheSyncTask 재처리 로직이 있는지 확인
rg -n "@Scheduled|CacheSyncTask" --type=java -C 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 21094
🏁 Script executed:
#!/bin/bash
# Search for scheduled methods that process cache sync tasks
# Look for `@Scheduled` annotations and methods that query PENDING status
rg -n "PENDING|@Scheduled" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -A 5
# Also search for batch or task scheduler in other directories
fd -t f -name "*Scheduler*" -o -name "*Batch*" --type f apps/commerce-api/src/main/java/ | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 315
🏁 Script executed:
#!/bin/bash
# Search for scheduled methods that process cache sync tasks
# Look for `@Scheduled` in cachesync infrastructure
rg "@Scheduled" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -A 10
# Search for any scheduler or batch processor files
fd -t f "Scheduler\|Batch" apps/commerce-api/src/main/java/
# Search for methods querying PENDING status
rg "CacheSyncTaskStatus\.PENDING|findByStatus|select.*status" --type=java -C 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 860
🏁 Script executed:
#!/bin/bash
# Search for any `@Scheduled` methods across entire project
rg "@Scheduled" --type=java apps/commerce-api/src/main/java/
# Also search for task/scheduler related components
find apps/commerce-api/src/main/java -type f -name "*.java" | xargs grep -l "TaskScheduler\|ScheduledExecutor\|@EnableScheduling" 2>/dev/null
# Check if there's a repository method to find PENDING tasks
rg "findBy|query" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.java -A 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Let's check the full CacheSyncTaskJpaRepository interface
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.java
# Search for all files in cachesync directory
echo "=== Files in cachesync directory ==="
ls -la apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/
# Look for any service that might handle retries
rg "cachesync\|CacheSyncTask" --type=java apps/commerce-api/src/main/java -l | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 765
🏁 Script executed:
#!/bin/bash
# Check if there's any scheduled task or retry logic elsewhere
# Search across the entire project for `@Scheduled`
rg "@Scheduled" --type=java apps/commerce-api/
# Also search for cron expressions or scheduler configs
rg "cron\|@EnableScheduling\|SchedulingConfigurer" --type=java apps/commerce-api/ | head -20
# Check application.yml or properties for scheduler configuration
find apps/commerce-api -name "application*.yml" -o -name "application*.properties" | xargs cat 2>/dev/null | grep -i "scheduler\|task\|cron"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Let's check if there are any tests or documentation that reference scheduler
rg "CacheSyncTask|read_model_sync_task" --type=java apps/commerce-api/ -l
# Check if there's any mention of retry or reprocessing
rg "retry|reprocess|batch" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -i
# Look at the complete CacheSyncFailurePersistence to understand the flow
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncFailurePersistence.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4035
🏁 Script executed:
#!/bin/bash
# Comprehensive search for any scheduler in the entire repository
find apps/commerce-api -type f -name "*.java" -exec grep -l "@Scheduled\|TaskScheduler\|@EnableScheduling\|Executor.*Schedule" {} \;
# Check if there's a separate module or service for handling retries
find apps -type d -name "*scheduler*" -o -type d -name "*batch*" -o -type d -name "*retry*"
# Let's also check if CacheSyncTask has any methods for retry logic
cat apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1133
🏁 Script executed:
#!/bin/bash
# Search for scheduled tasks in the commerce-batch module
find apps/commerce-batch -type f -name "*.java" | head -20
# Look for `@Scheduled` annotations in batch module
rg "@Scheduled" --type=java apps/commerce-batch/ -B 2 -A 10
# Look for any CacheSyncTask processing in batch
rg "CacheSyncTask\|read_model_sync" --type=java apps/commerce-batch/ -B 2 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 722
🏁 Script executed:
#!/bin/bash
# List all files in commerce-batch to see structure
find apps/commerce-batch/src/main/java -type f -name "*.java"
# Search for any scheduled job or cron in the batch module
rg "Scheduled\|@EnableScheduling\|cron" --type=java apps/commerce-batch/ -i
# Check the batch application main class
cat apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1287
🏁 Script executed:
#!/bin/bash
# Search more thoroughly in commerce-api for any scheduler or job that processes PENDING tasks
find apps/commerce-api/src/main/java -type f -name "*.java" | xargs grep -l "@Scheduled\|@EnableScheduling" 2>/dev/null
# Check entire repository for `@Scheduled`
rg "@Scheduled" --type=java . 2>/dev/null
# Look for any event listener or transaction listener that might handle retries
rg "TransactionSynchronization|EventListener" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -B 2 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check if there are any Scheduled or scheduling related classes at all
rg "Scheduled" --type=java . 2>/dev/null | head -20
# Check application configuration files
find apps -name "application*.yml" -o -name "application*.properties" -o -name "application*.yaml" | xargs cat 2>/dev/null
# Look at the BrandCacheSyncer to understand complete flow
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5091
🏁 Script executed:
#!/bin/bash
# Final verification - check if there's any query method to retrieve PENDING tasks
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskRepositoryImpl.java
# Check if there are any other cache sync syncer implementations
find apps/commerce-api/src/main/java -name "*Syncer.java" -o -name "*CacheSync*.java" | xargs wc -lRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1791
캐시 동기화 실패 재처리 메커니즘이 구현되지 않았다.
CacheSyncFailurePersistence에서 예외를 catch하고 read_model_sync_task 테이블에 PENDING 상태로 기록하는 코드는 있으나, 해당 PENDING 레코드를 처리하는 스케줄러가 존재하지 않는다. CacheSyncTaskRepository에는 저장 기능만 있고 PENDING 상태 조회 메서드가 없으며, 전체 코드베이스에 @Scheduled 애너테이션도 없다. 이는 Redis 동기화 실패 시 해당 항목이 영구적으로 DB에 남아 재시도되지 않음을 의미한다.
수정 방안:
CacheSyncTaskJpaRepository에findByStatusAndNextAttemptAtBefore(status, now)메서드 추가@EnableScheduling을 포함한 스케줄러 구현 또는commerce-batch에 배치 작업 추가- 일정 간격(예: 1분)으로 PENDING 레코드를 조회하여 재처리하고, 재시도 횟수 초과 시 DEAD 상태로 변경
- 재처리 중 예외 발생 시
nextAttemptAt을 지수 백오프로 계산하여 무한 재시도 방지
테스트:
- 캐시 동기화 실패 후 스케줄러 실행 시 PENDING 레코드가 COMPLETED 또는 DEAD 상태로 전환되는지 확인
- 최대 재시도 횟수 초과 후 DEAD 상태가 되는지 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncer.java`
around lines 50 - 66, Add a retry scheduler that picks up PENDING
read_model_sync_task rows and replays cache sync attempts: add a repository
query findByStatusAndNextAttemptAtBefore(Status status, Instant now) to
CacheSyncTaskJpaRepository, implement a scheduled component (enable scheduling
via `@EnableScheduling`) in either the service or commerce-batch module that runs
e.g. every minute, queries pending tasks using the new
findByStatusAndNextAttemptAtBefore(Status.PENDING, Instant.now()), invokes the
same retry logic used by CacheSyncFailurePersistence to re-run
syncUpsert/syncDelete, increment attempt counts and set nextAttemptAt using
exponential backoff, and flip task status to COMPLETED or DEAD when success or
max attempts exceeded; ensure failures during retry update nextAttemptAt and
attempts rather than leaving tasks stuck.
[volume-5] 상품 조회 캐시 전략 정리 및 로컬 실험 환경 보강
📌 Summary
docker/infra-compose.yml만으로 seed 포함 실험이 가능하도록 맞추는 것이 목표였습니다.categories 5 / brands 30 / products 300000)까지 재현 가능하게 보강했습니다.🧭 Context & Decision
문제 정의
infra-compose + mysql-seed만으로 실험 데이터가 재현되어야 합니다.선택지와 결정
🏗️ Design Overview
변경 범위
commerce-api상품 조회 캐시, 브랜드/카테고리 캐시 문서화, Docker infra seed 구성주요 컴포넌트 책임
ProductQueryFacade: 공개 상품 상세 조회 진입점, 상세 캐시 AOP 적용 경계PublicProductDetailCacheApplicationService: 상세 캐시 key 생성, 조회/저장/삭제 위임, TTL policy 연결PublicProductDetailCacheAspect: 상세 조회 cache hit/miss fallback 처리PublicProductDetailCacheEvictionAspect: 상품 수정/삭제/좋아요 변경 후 상세 캐시 무효화BrandCacheRepositoryImpl/CategoryCacheRepositoryImpl: TTL 없는 read-through + 목록 키 삭제 전략 유지docker/infra-compose.yml: seed 포함 로컬 실험 환경 재현 경로 제공주요 코드 링크
ProductQueryFacadePublicProductDetailCacheApplicationServicePublicProductDetailCacheAspectPublicProductDetailCacheEvictionAspectPublicProductDetailCacheRepositoryImplBrandCacheRepositoryImplCategoryCacheRepositoryImpldocker/infra-compose.yml🔁 Flow Diagram
Main Flow
sequenceDiagram autonumber participant Client participant API as ProductController participant Facade as ProductQueryFacade participant Cache as Redis Detail Cache participant Service as ProductApplicationService participant DB Client->>API: GET /api/v1/products/{productId} API->>Facade: get(productId) Facade->>Cache: find(productId) alt Cache Hit Cache-->>Facade: ProductView Facade-->>API: cached response else Cache Miss Cache-->>Facade: empty Facade->>Service: get(productId) Service->>DB: select product DB-->>Service: product Service-->>Facade: Product Facade->>Cache: save(productId, ProductView, ttl+jitter) Facade-->>API: fresh response end API-->>Client: ApiResponse<ProductResponse>Invalidation Flow
검증
:apps:commerce-api:build -x test통과테스트 코드 링크
PublicProductDetailCacheApplicationServiceTestPublicProductDetailCacheAspectTestPublicProductDetailCacheEvictionAspectTestBrandCategoryCacheToggleTest참고 문서
docs/adr/2026-03-13-public-product-cache-adr.mddocs/adr/2026-03-13-brand-category-cache-adr.mddocs/adr/2026-03-13-like-count-denormalization-adr.md리뷰포인트
1. 브랜드/카테고리 캐시 전략
카테고리, 브랜드는 변경 추가가 많지 않을거 같아서 ttl을 따로 두지 않고 조회시 캐시를 먼저 바라보게 했습니다. 처음에는 브랜드/카테고리 두 테이블을 인메모리 디비로 올려서 변경을 레디스에서만 반영하고 특정 시간 혹은 수동으로 배치로 디비에 백업을 하는 형식으로 하려고 했습니다. 그러나 배치 관련 학습 부족등으로 수정은 일딘 디비에 먼저 반영하고 커밋 후 레디스에 동기화하는 방식으로 진행했습니다. 브랜드/카테고리 등 변경이 많지 않다고 생각되는 데이터들의 경우 어떤 전략을 사용하시는지 여쭤보고 싶습니다.
2. 페이지네이션 방식
일반 사용자의 클라이언트를 웹앱이라 가정하여 커서 기반 페이지네이션 방식을 선택했습니다. 그러나 커서 방식은 목록 특정 페이지 캐시가 오프셋 방식에 비해 불리하여 적용을 못했습니다. 실무에선 어떻게 처리하시는지 여쭤보고 싶습니다.
3. 캐쉬 인터페이스의 위치
처음에는 일반 레포지토리 인터페이스처럼 도메인 계층에 두었으나, 캐쉬는 디비 부하 완화 / 조회 속도 개선 이 목적이어서 도메인과는 거리가 멀다고 생각하여 어플리케이션 계층에 두었습니다. 이 방식이 적절한지 궁금합니다.
개인질문
집 근처 추천 검색 관련 스타트업의 채용 공고를 봤는데 저랑 맞을거 같아서 대표님께 연락드렸는데 이력서 및 커피챗 제안을 주셨습니다.
주 사업 관련 키워드로 Search Engine Optimization, Generative Engine Optimization 가 있는 회사인데 커피챗 준비시 참고할만한 것들이 있을까요? 면접은 경험이 있는데 커피챗은 처음이라 준비를 어떻게 해야할지 모르겠습니다.
PR 요약
변경 목적
상품 상세 조회에 Redis 캐시를 런타임 경로에 적용하고, 브랜드/카테고리 캐시를 application 계층으로 이동하며, 커서 기반 페이징을 도입하고, UUID 기반 식별자 체계로 마이그레이션하며, 로컬 개발 환경의 재현 가능성을 보강합니다.
핵심 변경점
리스크/주의사항
테스트/검증