Skip to content

Feat/product search redis size#121#122

Merged
CheatIsKey merged 29 commits into
developfrom
feat/Product-search-redis-size
Apr 27, 2026
Merged

Feat/product search redis size#121#122
CheatIsKey merged 29 commits into
developfrom
feat/Product-search-redis-size

Conversation

@DGAZA-max
Copy link
Copy Markdown
Contributor

💡 개요

DGAZA-max added 28 commits April 17, 2026 12:00
fix: 상품 수정 로직 수정(CachePut 삭제, CacheEvict으로 대체)
fix: 인기검색어와 상품 검색 충돌 해결
@github-actions
Copy link
Copy Markdown

🤖 AI 코드리뷰

리더보드 — 현재 4위 (리뷰 102건) | 전체 8개 팀 참여 중
업데이트 확인 — 리뷰가 질문을 던지다 — 생각해보기

안녕하세요! 가즈아님, 이번 PR은 정말 인상 깊습니다. 단순히 기능 구현을 넘어, 실무에서 사용되는 다양한 캐시 전략(L1/L2 Composite Cache)과 성능 테스트(K6) 도입까지 시도하셨군요. 시니어 개발자로서 이런 도전을 하는 주니어 분을 보면 정말 가슴이 뜁겁니다.

요구사항의 핵심인 v1, v2 검색 API 공존, QueryDSL 동적 쿼리, 인기 검색어 Redis Sorted Set 구현은 물론이고, K6 부하 테스트 스크립트까지 꼼꼼하게 작성해주신 점이 아주 훌륭합니다.

코드를 꼼꼼히 리뷰해보았고, 더욱 성장할 수 있는 포인트들을 정리해보았습니다.


📋 과제 요구사항 준수 여부

  • 검색 API v1, v2 공존 및 Caffeine 캐시 적용: @Cacheable을 활용하여 v1(DB 조회)과 v2(Local Cache 조회)가 공존하도록 잘 구현해주셨습니다. (준수 ✅)
  • 인기 검색어 중복 카운팅 방지 (Redis Sorted Set): KeywordQueryServiceImplunless = "#result.isEmpty()"를 추가하신 것으로 보아, 캐시 로직을 섬세하게 다듬고 계신 것이 보입니다. (준수 ✅)
  • K6 부하 테스트 (도전 기능): 1-1부터 6번까지, 캐시 히트/미스에 따른 시나리오와 Ramp-up 테스트 등 엄청나게 잘 작성해주셨습니다. (훌륭합니다 🎉)
  • 사용 금지 기술 (Redisson): Redisson 의존성은 추가되지 않았으며, Lettuce와 Spring Data Redis를 적절히 활용하셨습니다. (준수 ✅)

🟢 잘된 점

  1. L1(Local) / L2(Redis) Composite Cache 패턴 도입: CompositeCache 클래스를 통해 로컬 캐시(Caffeine)와 글로벌 캐시(Redis)를 투명하게 연결하는 다층 캐시(Multi-layer Cache) 구조를 설계하셨습니다. 실무에서 대규모 트래픽을 다룰 때 레이턴시(Latency)를 줄이기 위해 자주 쓰이는 고급 패턴입니다.
  2. 체계적인 K6 부하 테스트 코드: 단순히 API를 때리는 것을 넘어, vus(가상 유저 수)를 조절하고 tags로 v1/v2를 구분하며 check() 함수로 응답을 검증하는 등 테스트 자동화 역량이 돋보입니다.
  3. Spring Cache 추상화에 대한 깊은 이해: CacheConfig에서 @EnableCaching의 위치를 조정하고, RedisCacheManagerCaffeineCacheManager를 분리하여 Bean 충돌을 방지한 설계가 매우 훌륭합니다.

🔴 필수 수정

1. 보안상 치명적인 인가(Authorization) 누락 문제
SecurityConfig에 상품 수정(PUT) API를 인증 없이 누구나 호출할 수 있도록(permitAll) 열어두셨습니다.

// SecurityConfig.java
.requestMatchers(HttpMethod.PUT, "/api/products/**").permitAll() // <- 보안 이슈

이렇게 되면 악의적인 사용자가 상품의 가격을 0원으로 바꾸거나 재고를 마음대로 조작할 수 있습니다. 실무에서는 절대 permitAll()로 열어두면 안 되는 엔드포인트입니다.

  • 해결 방법: 해당 라인을 제거하거나, authenticated() 또는 hasRole('ADMIN') 등으로 변경하여 권한이 있는 사람만 수정할 수 있도록 잠가주세요.

🟡 권장 개선

1. 상품 수정 시 캐시 Evict(삭제) 처리 누락
ProductCommandService에서 상품을 수정(PUT)할 때, DB 데이터는 변경되지만 캐시(Caffeine, Redis)에 저장된 예전 데이터는 그대로 남아있게 됩니다. 이렇게 되면 사용자가 수정된 상품을 조회할 때 한동안 예전 정보만 보게 됩니다(캐시 정합성 깨짐).

  • 해결 방법: @CacheEvict를 사용하여 데이터가 변경될 때 캐시에서 해당 데이터를 삭제해 주어야 합니다.
// ProductCommandService.java
@CacheEvict(value = "productDetail", key = "#productId") // 추가
public ProductUpdateResponse updateProduct(Long productId, ProductUpdateRequest request) {
    // ... 비즈니스 로직 ...
}

2. 페이징 처리 시 페이지 번호 파라미터 중복 문제
ProductController에서 @PageableDefault로 Spring의 Pageable 객체를 주입받으면서, 동시에 @RequestParam int page로 직접 페이지 번호를 받고 있습니다. 이 경우 로직이 꼬일 수 있습니다.

  • 해결 방법: 클라이언트가 ?page=1을 넘겼을 때, Spring의 Pageable이 이를 자동으로 인식하게 두거나, 커스텀 객체(SearchProductRequest) 안에서 페이지 번호를 처리하는 것이 좋습니다.
// 변경 전
public ResponseEntity<ApiResponse<Page<GetAllProductResponse>>> search(
        @Valid SearchProductRequest searchRequest,
        @RequestParam(defaultValue = "1") int page, // 중복
        Pageable pageable) {

// 변경 후 (Pageable에 초기값 설정을 위임)
public ResponseEntity<ApiResponse<Page<GetAllProductResponse>>> search(
        @Valid SearchProductRequest searchRequest,
        @PageableDefault(page = 0, size = 10) Pageable pageable) {
    // Spring Data JPA는 page가 0부터 시작하므로, 클라이언트가 1을 넘기면 -1 처리가 필요할 수 있습니다.
    // 하지만 API 설계상 0부터 시작함을 명시하는 것이 RESTful합니다.

3. K6 테스트 스크립트의 하드코딩된 URL 분리
k6-test-1-1.js 등 여러 스크립트에서 http://host.docker.internal:8090이 하드코딩 되어 있습니다. 환경이 바뀔 때마다 모든 스크립트를 수정해야 합니다.

  • 해결 방법: K6의 환경 변수(__ENV)를 활용해 보세요.
const BASE_URL = __ENV.BASE_URL || 'http://host.docker.internal:8090';
const url = `${BASE_URL}/api/products/search/${version}?keyword=${keyword}`;

💡 학습 포인트

  1. Cache Eviction 전략 (@CacheEvict vs @CachePut)
    캐시는 읽기(Read) 성능을 위해 쓰지만, 쓰기(Write)가 발생할 때 캐시를 어떻게 처리할지 결정해야 합니다. 수정 즉시 캐시를 지울 것인가(Evict)? 아니면 수정된 데이터로 캐시를 덮어쓸 것인가(Put)? 두 방식의 장단점과 실무에서는 어떤 것을 선호하는지 찾아보세요.
  2. Spring PageablePage 객체의 올바른 사용법
    REST API에서 페이징을 처리할 때, 프론트엔드 개발자와 page 번호의 시작(0 vs 1)을 어떻게 맞출 것인지 고민해 보는 것이 좋습니다.

🤔 생각해보기

현재 도입하신 **L1(Caffeine

💬 리뷰에 대해 궁금한 점이 있나요? 코멘트에 @sparta 를 남겨보세요!
예: @sparta 이 코드에서 동시성 이슈가 발생할 수 있나요?


AI 리뷰는 참고용입니다. 최종 판단은 팀원이 직접 합니다.

@github-actions
Copy link
Copy Markdown

🤖 AI 코드리뷰

리더보드 — 현재 4위 (리뷰 103건) | 전체 8개 팀 참여 중
업데이트 확인 — 리뷰가 질문을 던지다 — 생각해보기

📋 과제 요구사항 준수 여부

  • 검색 API v1, v2 공존: ProductController/search/v1, /search/v2 엔드포인트가 명확히 분리되어 공존합니다. (준수 ✅)
  • Caffeine Local Cache 적용: LocalCacheConfig를 통해 @EnableCaching과 Caffeine 설정이 정상적으로 구성되었습니다. (준수 ✅)
  • 도전 기능 (K6 부하 테스트): 다양한 관점의 부하 테스트 시나리오(동일 키워드, 랜덤 키워드, Cache Miss, Ramp-up 등)가 작성되어 있습니다. (훌륭합니다)
  • 사용 금지 기술 (Redisson): 의존성에 추가되지 않았습니다. (준수 ✅)

🟢 잘된 점

  1. Composite Cache 패턴 구현: CompositeCache 클래스를 통해 L1(Caffeine)과 L2(Redis)를 투명하게 연결하는 다층 캐시(Multi-layer Cache) 구조를 설계한 점은 시니어 레벨의 접근입니다. 실무에서 대규모 트래픽을 다룰 때 레이턴시를 줄이기 위한 정석적인 패턴입니다.
  2. Spring Cache 추상화에 대한 깊은 이해: CacheConfig, LocalCacheConfig, CompositeCacheConfig로 빈(Bean) 설정을 깔끔하게 분리하고 @Primary를 활용해 CacheManager 충돌을 방지한 설계가 매우 인상적입니다.
  3. Redis 직렬화 설정: JavaTimeModule 등록 및 disableCachingNullValues() 등 운영 환경에서 발생할 수 있는 직렬화 이슈와 메모리 누수를 사전에 방지한 점이 좋습니다.

🟡 개선 제안

1. 상품 수정 시 캐시 Evict(삭제) 처리 누락
Product.update() 로직을 통해 DB 데이터를 변경하지만, 캐시(Caffeine, Redis)에 저장된 예전 데이터는 그대로 남아있습니다. 사용자가 수정된 상품을 조회할 때 한동안 예전 정보만 보게 되는 정합성 깨짐 문제가 발생합니다.

  • 해결 방법: 상품 수정 메서드에 @CacheEvict를 추가하여 데이터가 변경될 때 캐시에서 해당 데이터를 삭제하세요.
@CacheEvict(value = "productDetail", key = "#productId") // 추가
public ProductUpdateResponse updateProduct(Long productId, ProductUpdateRequest request) {
    // ... 비즈니스 로직 ...
}

2. 페이징 처리 시 파라미터 중복 및 관례 위배
ProductController에서 @PageableDefault로 Spring의 Pageable 객체를 주입받으면서 동시에 @RequestParam int page로 직접 페이지 번호를 받고 있습니다. 또한, 클라이언트가 넘긴 page 값을 내부적으로 page - 1로 변환하고 있는데, 이는 Spring Data JPA의 페이징이 0부터 시작한다는 것을 컨트롤러에서 하드코딩하여 억지로 맞추는 안티 패턴입니다.

  • 해결 방법: 프론트엔드와 API 명세를 맞출 때는 아예 page를 0부터 시작하도록 규약을 잡거나, 커스텀 ArgumentResolver를 구현하여 전역적으로 처리하는 것이 좋습니다. 컨트롤러 내에서 page - 1을 직접 계산하는 로직은 제거하세요.

🔴 보안 / 성능 주의

1. 인가(Authorization) 누락 문제 미해결
이전 리뷰에서 지적했던 내용입니다. 상품 수정(PUT) API가 SecurityConfigpermitAll() 경로(/api/products/**)에 포함되어 있어, 인증되지 않은 누구나 상품의 가격이나 재고를 마음대로 조작할 수 있는 치명적인 보안 구멍이 뚫려 있습니다.

  • 해결 방법: SecurityConfig에서 해당 엔드포인트를 permitAll()에서 제외하고, authenticated() 또는 hasRole('ADMIN')으로 잠그십시오. 이 부분은 실무 서비스라면 1순위 장애이자 해킹 사고입니다. 반드시 수정하세요.

💡 학습 포인트

  • Cache Eviction vs Cache Put: 캐시는 읽기 성능을 위해 쓰지만, 쓰기(수정/삭제)가 발생할 때 캐시를 어떻게 처리할지 결정해야 합니다. 수정 즉시 캐시를 지울 것인가(@CacheEvict)? 아니면 수정된 데이터로 캐시를 덮어쓸 것인가(@CachePut)? 두 방식의 정합성과 성능 차이를 고민해 보세요.
  • Spring Pageable 관례: Spring 생태계에서 페이징은 기본적으로 0부터 시작합니다(PageRequest.of(0, 10)). 이 관례를 따를지, 커스텀하게 1부터 시작할지 결정했다면 그 기준을 컨트롤러의 파라미터 로직이 아닌 설정이나 명세로 풀어내야 유지보수성이 좋아집니다.

🤔 생각해보기

현재 도입하신 L1(Caffeine) / L2(Redis) Composite Cache 구조에서, 상품 수정(PUT) API가 1초에 100번 호출된다면 캐시 정합성을 지키기 위해 @CacheEvict가 양쪽 캐시의 데이터를 지우는 과정에서 발생할 수 있는 "Thundering Herd(Cache Stampede)" 현상은 어떻게 방어할 수 있을까요?

💬 이 질문에 대해 궁금한 점이 있으면 코멘트에 @sparta 를 남겨보세요!
예: @sparta Cache Stampede를 방지하려면 @CacheEvict 대신 @CachePut이 나을까요?

🔄 이전 피드백 반영 여부

  • 보안상 인가(Authorization) 누락 문제: 미해결 - SecurityConfigpermitAll() 경로에 /api/products/**가 추가되어 오히려 더 넓게 권한이 열렸습니다.
  • 페이징 처리 시 페이지 번호 파라미터 중복 문제: 미해결 - 여전히 @RequestParam int pagePageable이 혼용되고 있으며, page - 1 하드코딩이 존재합니다.
  • K6 테스트 스크립트의 하드코딩된 URL 분리: 미반영 - k6-test-1-1.js 등에 여전히 http://host.docker.internal:8090이 하드코딩 되어 있습니다.

AI 리뷰는 참고용입니다. 최종 판단은 팀원이 직접 합니다.

@CheatIsKey CheatIsKey merged commit 00a4b08 into develop Apr 27, 2026
2 checks passed
@CheatIsKey CheatIsKey deleted the feat/Product-search-redis-size branch April 27, 2026 16:04
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.

[Feat] 상품 검색 및 필터링 구현

3 participants