Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bd44f98
chore: H2 인메모리 DB 및 정적 리소스 설정 추가
plan11plan Mar 9, 2026
7df0527
feat: 유저 도메인 기능 확장 (회원가입, 포인트 충전, 전체 조회)
plan11plan Mar 9, 2026
3e0f000
feat: Admin 유저 관리 API 구현 (목록 조회, 포인트 지급)
plan11plan Mar 9, 2026
d84da98
feat: Admin 데이터 생성기 API 구현 (유저/좋아요/주문/쿠폰 대량 생성)
plan11plan Mar 9, 2026
ddfa615
feat: 관리자/쇼핑몰 프론트엔드 페이지 구현
plan11plan Mar 9, 2026
a070fd4
feat: ApplicationRunner 대용량 데이터 생성기 구현
plan11plan Mar 10, 2026
2f69bc3
docs: Round 5 테스트 데이터 설계 문서 추가
plan11plan Mar 10, 2026
45745f7
refactor: 대용량 데이터 생성 로직을 Service로 분리
plan11plan Mar 10, 2026
19fbf2e
feat: 대용량 데이터 초기화 API 및 대시보드 버튼 추가
plan11plan Mar 10, 2026
3c9205f
docs: 데이터 생성 API 실행 방법 추가
plan11plan Mar 10, 2026
49e5d70
feat: 패션 이커머스 특성 반영 데이터 초기화 개선
plan11plan Mar 11, 2026
f29986b
feat: 상품 이미지 모델 및 좋아요 비정규화 구현
plan11plan Mar 11, 2026
a40cfce
test: 상품 이미지/좋아요 테스트 코드 추가
plan11plan Mar 11, 2026
0ffb9a9
feat: 데이터 초기화에 상품 이미지 생성 추가
plan11plan Mar 11, 2026
04bd023
feat: 상품 목록/상세 페이지 이미지 UI 추가
plan11plan Mar 11, 2026
7725cdf
chore: static 리소스 디렉토리 gitignore 추가
plan11plan Mar 11, 2026
893a0b6
refactor: 상품 인덱스 전략을 soft delete 대응 복합 인덱스로 변경
plan11plan Mar 11, 2026
b20ca33
feat: 데이터 초기화 soft delete 비율 설정 외부화
plan11plan Mar 11, 2026
161ca4a
fix: 상품 목록 가격 정렬 버그 수정
plan11plan Mar 12, 2026
25eec25
feat: 상품 목록 브랜드 사이드바 및 정렬 칩 UI 개선
plan11plan Mar 12, 2026
3a6b812
feat: 상품 조회 유즈케이스별 복합 인덱스 추가
plan11plan Mar 12, 2026
ffd203d
feat: 브랜드 목록 Redis 캐시 적용
plan11plan Mar 12, 2026
e377bd9
refactor: 브랜드 캐시를 @Cacheable 기반으로 리팩토링
plan11plan Mar 12, 2026
5b96c96
feat: 상품 목록 Redis 캐시 적용
plan11plan Mar 12, 2026
5d17004
refactor: CacheConfig @Qualifier 불일치 수정 및 미사용 import 제거
plan11plan Mar 12, 2026
5bbfc98
refactor: 컨벤션 리뷰 반영 (인라인 변수, 반복 참조 추출)
plan11plan Mar 12, 2026
9a0dab8
test: 상품 목록 E2E 테스트 캐시 오염 방지
plan11plan Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ claude/*
.claude/settings.local.json
.interview-state/

### Static Resources ###
**/src/main/resources/static/

### Documentation ###
docs/*
!docs/design/
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
import com.loopers.domain.brand.BrandModel;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.ProductService;
import com.loopers.support.cache.CacheType;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
Expand All @@ -18,6 +22,7 @@ public class BrandFacade {
private final BrandService brandService;
private final ProductService productService;

@CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true)
@Transactional
public void registerBrand(BrandCriteria.Register criteria) {
brandService.register(criteria.name());
Expand All @@ -29,19 +34,31 @@ public BrandResult getBrand(Long id) {
return BrandResult.from(brandModel);
}

@Caching(evict = {
@CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)})
@Transactional
public void updateBrand(Long id, BrandCriteria.Update criteria) {
brandService.update(id, criteria.name());
}

@Caching(evict = {
@CacheEvict(cacheNames = CacheType.Names.BRAND_LIST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)})
@Transactional
public void deleteBrand(Long id) {
brandService.delete(id);
productService.deleteAllByBrandId(id);
}

@Cacheable(cacheNames = CacheType.Names.BRAND_LIST, key = "'all'")
@Transactional(readOnly = true)
public Page<BrandResult> getBrands(Pageable pageable) {
return brandService.getAll(pageable).map(BrandResult::from);
public BrandResult.ListPage getBrands(Pageable pageable) {
return BrandResult.ListPage.from(
brandService.getAll(pageable).map(BrandResult::from));
}
Comment on lines +58 to 63
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

캐시 키에 Pageable 정보가 포함되지 않아 잘못된 데이터가 반환된다.

key = "'all'"로 고정되어 있어, 서로 다른 page/size 요청이 동일한 캐시 데이터를 반환한다. 예를 들어 page=0, size=10 요청 후 캐시된 결과가 page=1, size=20 요청에도 반환되어 운영 환경에서 데이터 불일치가 발생한다.

🐛 수정 제안
-@Cacheable(cacheNames = CacheType.Names.BRAND_LIST, key = "'all'")
+@Cacheable(cacheNames = CacheType.Names.BRAND_LIST, key = "#pageable.pageNumber + '-' + `#pageable.pageSize`")
 `@Transactional`(readOnly = true)
 public BrandResult.ListPage getBrands(Pageable pageable) {

또는 페이지네이션이 필요 없는 전체 목록 조회라면, Pageable 파라미터를 제거하고 전체 데이터를 캐시해야 한다.

📝 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
@Cacheable(cacheNames = CacheType.Names.BRAND_LIST, key = "'all'")
@Transactional(readOnly = true)
public Page<BrandResult> getBrands(Pageable pageable) {
return brandService.getAll(pageable).map(BrandResult::from);
public BrandResult.ListPage getBrands(Pageable pageable) {
return BrandResult.ListPage.from(
brandService.getAll(pageable).map(BrandResult::from));
}
`@Cacheable`(cacheNames = CacheType.Names.BRAND_LIST, key = "#pageable.pageNumber + '-' + `#pageable.pageSize`")
`@Transactional`(readOnly = true)
public BrandResult.ListPage getBrands(Pageable pageable) {
return BrandResult.ListPage.from(
brandService.getAll(pageable).map(BrandResult::from));
}
🤖 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/brand/BrandFacade.java`
around lines 58 - 63, The cache key in BrandFacade.getBrands is fixed to "'all'"
while the method accepts a Pageable, causing different page/size requests to
return the same cached data; update the `@Cacheable` on getBrands to include
pageable information in the cache key (e.g., incorporate pageNumber, pageSize
and sort from the Pageable via SpEL like referencing
`#pageable.pageNumber/`#pageable.pageSize/#pageable.sort or `#root.methodName` with
those fields) so each page/size/sort combination gets a distinct cache entry, or
if you truly want to cache the entire list remove the Pageable parameter and
cache the full result instead.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@

import com.loopers.domain.brand.BrandModel;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.domain.Page;

public record BrandResult(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) {
public static BrandResult from(BrandModel model) {
return new BrandResult(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt());
}

public record ListPage(
int page, int size, long totalElements, int totalPages,
List<BrandResult> items
) {
public static ListPage from(Page<BrandResult> resultPage) {
return new ListPage(
resultPage.getNumber(),
resultPage.getSize(),
resultPage.getTotalElements(),
resultPage.getTotalPages(),
new ArrayList<>(resultPage.getContent()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
import com.loopers.application.product.dto.ProductCriteria;
import com.loopers.application.product.dto.ProductResult;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.ProductLikeService;
import com.loopers.domain.product.ImageType;
import com.loopers.domain.product.ProductImageService;
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import com.loopers.support.util.PaginationUtils;
import java.util.Comparator;
import java.util.List;
import com.loopers.support.cache.CacheType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -23,8 +27,12 @@ public class ProductFacade {

private final ProductService productService;
private final BrandService brandService;
private final ProductLikeService productLikeService;
private final ProductImageService productImageService;

@Caching(evict = {
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)})
@Transactional
public void registerProduct(ProductCriteria.Register criteria) {
brandService.validateExists(criteria.brandId());
Expand All @@ -36,15 +44,37 @@ public ProductResult getProduct(Long id) {
ProductModel product = productService.getById(id);
return ProductResult.of(
product,
brandService.getById(product.getBrandId()).getName(),
productLikeService.countLikes(id));
brandService.getById(product.getBrandId()).getName());
}

@Transactional(readOnly = true)
public ProductResult.DetailWithImages getProductDetail(Long id) {
ProductModel product = productService.getById(id);
return new ProductResult.DetailWithImages(
ProductResult.of(
product,
brandService.getById(product.getBrandId()).getName()),
productImageService.getImagesByProductIdAndType(id, ImageType.MAIN).stream()
.map(ProductResult.ImageResult::from)
.toList(),
productImageService.getImagesByProductIdAndType(id, ImageType.DETAIL).stream()
.map(ProductResult.ImageResult::from)
.toList());
}

@Caching(evict = {
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)})
@Transactional
public void updateProduct(Long id, ProductCriteria.Update criteria) {
productService.update(id, criteria.name(), criteria.price(), criteria.stock());
}

@Caching(evict = {
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE, allEntries = true),
@CacheEvict(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES, allEntries = true)})
@Transactional
public void deleteProduct(Long id) {
productService.delete(id);
Expand All @@ -69,7 +99,10 @@ public Page<ProductResult> getProductsByBrandId(Long brandId, Pageable pageable)
public Page<ProductResult> getProductsWithActiveBrand(Pageable pageable) {
Page<ProductModel> products = productService.getAll(pageable);
return new PageImpl<>(
toResultsWithActiveBrand(products.getContent()),
ProductResult.fromWithActiveBrand(
products.getContent(),
brandService.getActiveNameMapByIds(
ProductModel.extractDistinctBrandIds(products.getContent()))),
products.getPageable(),
products.getTotalElements());
}
Expand All @@ -78,34 +111,70 @@ public Page<ProductResult> getProductsWithActiveBrand(Pageable pageable) {
public Page<ProductResult> getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) {
Page<ProductModel> products = productService.getAllByBrandId(brandId, pageable);
return new PageImpl<>(
toResultsWithActiveBrand(products.getContent()),
ProductResult.fromWithActiveBrand(
products.getContent(),
brandService.getActiveNameMapByIds(
ProductModel.extractDistinctBrandIds(products.getContent()))),
products.getPageable(),
products.getTotalElements());
}

@Transactional(readOnly = true)
public Page<ProductResult> getProductsWithActiveBrandSortedByLikes(int page, int size) {
return toPageSortedByLikes(productService.getAll(), page, size);
Page<ProductModel> products = productService.getAllSortedByLikeCountDesc(
PageRequest.of(page, size));
return new PageImpl<>(
ProductResult.fromWithActiveBrand(
products.getContent(),
brandService.getActiveNameMapByIds(
ProductModel.extractDistinctBrandIds(products.getContent()))),
products.getPageable(),
products.getTotalElements());
}

@Transactional(readOnly = true)
public Page<ProductResult> getProductsWithActiveBrandByBrandIdSortedByLikes(Long brandId, int page, int size) {
return toPageSortedByLikes(productService.getAllByBrandId(brandId), page, size);
public Page<ProductResult> getProductsWithActiveBrandByBrandIdSortedByLikes(
Long brandId, int page, int size) {
Page<ProductModel> products = productService.getAllByBrandIdSortedByLikeCountDesc(
brandId, PageRequest.of(page, size));
return new PageImpl<>(
ProductResult.fromWithActiveBrand(
products.getContent(),
brandService.getActiveNameMapByIds(
ProductModel.extractDistinctBrandIds(products.getContent()))),
products.getPageable(),
products.getTotalElements());
}

private Page<ProductResult> toPageSortedByLikes(List<ProductModel> products, int page, int size) {
List<ProductResult> sorted = toResultsWithActiveBrand(products).stream()
.sorted(Comparator.comparingLong(ProductResult::likeCount).reversed())
.toList();
return PaginationUtils.toPage(sorted, page, size);
@Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_LATEST,
key = "(#brandId ?: 'all') + ':' + #page + ':' + #size")
@Transactional(readOnly = true)
public ProductResult.ListPage getProductListLatest(Long brandId, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return ProductResult.ListPage.from(brandId != null
? getProductsWithActiveBrandByBrandId(brandId, pageable)
: getProductsWithActiveBrand(pageable));
}

private List<ProductResult> toResultsWithActiveBrand(List<ProductModel> products) {
return ProductResult.fromWithActiveBrand(
products,
brandService.getActiveNameMapByIds(
ProductModel.extractDistinctBrandIds(products)),
productLikeService.countLikesByProductIds(
ProductModel.extractIds(products)));
@Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_PRICE,
key = "#sort + ':' + (#brandId ?: 'all') + ':' + #page + ':' + #size")
@Transactional(readOnly = true)
public ProductResult.ListPage getProductListByPrice(
Long brandId, String sort, int page, int size) {
Sort sortOrder = "price_asc".equals(sort)
? Sort.by(Sort.Direction.ASC, "price")
: Sort.by(Sort.Direction.DESC, "price");
return ProductResult.ListPage.from(brandId != null
? getProductsWithActiveBrandByBrandId(brandId, PageRequest.of(page, size, sortOrder))
: getProductsWithActiveBrand(PageRequest.of(page, size, sortOrder)));
}

@Cacheable(cacheNames = CacheType.Names.PRODUCT_LIST_LIKES,
key = "(#brandId ?: 'all') + ':' + #page + ':' + #size")
@Transactional(readOnly = true)
public ProductResult.ListPage getProductListByLikes(Long brandId, int page, int size) {
return ProductResult.ListPage.from(brandId != null
? getProductsWithActiveBrandByBrandIdSortedByLikes(brandId, page, size)
: getProductsWithActiveBrandSortedByLikes(page, size));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ public class ProductLikeFacade {
public void like(Long userId, Long productId) {
productService.validateExists(productId);
productLikeService.like(userId, productId);
productService.incrementLikeCount(productId);
}

@Transactional
public void unlike(Long userId, Long productId) {
productLikeService.unlike(userId, productId);
productService.decrementLikeCount(productId);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.loopers.application.product.dto;

import com.loopers.domain.product.ImageType;
import com.loopers.domain.product.ProductImageModel;
import com.loopers.domain.product.ProductModel;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.Page;

public record ProductResult(
Long id,
Expand All @@ -13,37 +17,60 @@ public record ProductResult(
int price,
int stock,
long likeCount,
String thumbnailUrl,
ZonedDateTime createdAt,
ZonedDateTime updatedAt,
ZonedDateTime deletedAt
) {
public static ProductResult of(ProductModel model, String brandName) {
return of(model, brandName, 0L);
}

public static ProductResult of(ProductModel model, String brandName, long likeCount) {
return new ProductResult(
model.getId(),
model.getBrandId(),
brandName,
model.getName(),
model.getPrice(),
model.getStock(),
likeCount,
model.getLikeCount(),
model.getThumbnailUrl(),
model.getCreatedAt(),
model.getUpdatedAt(),
model.getDeletedAt());
}

public static List<ProductResult> fromWithActiveBrand(
List<ProductModel> products, Map<Long, String> brandNameMap,
Map<Long, Long> likeCountMap) {
List<ProductModel> products, Map<Long, String> brandNameMap) {
return products.stream()
.filter(product -> brandNameMap.containsKey(product.getBrandId()))
.map(product -> ProductResult.of(
product,
brandNameMap.get(product.getBrandId()),
likeCountMap.getOrDefault(product.getId(), 0L)))
product, brandNameMap.get(product.getBrandId())))
.toList();
}

public record ImageResult(Long id, String imageUrl, ImageType imageType, int sortOrder) {
public static ImageResult from(ProductImageModel model) {
return new ImageResult(
model.getId(), model.getImageUrl(), model.getImageType(), model.getSortOrder());
}
}

public record ListPage(
int page, int size, long totalElements, int totalPages,
List<ProductResult> items
) {
public static ListPage from(Page<ProductResult> resultPage) {
return new ListPage(
resultPage.getNumber(),
resultPage.getSize(),
resultPage.getTotalElements(),
resultPage.getTotalPages(),
new ArrayList<>(resultPage.getContent()));
}
}

public record DetailWithImages(
ProductResult product,
List<ImageResult> mainImages,
List<ImageResult> detailImages
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ public record UserResult(
String loginId,
String name,
String birthDate,
String email
String email,
long point
) {
public static UserResult from(UserModel model) {
return new UserResult(
model.getId(),
model.getLoginId(),
model.getName(),
model.getBirthDateString(),
model.getEmail());
model.getEmail(),
model.getPoint());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.loopers.domain.product;

public enum ImageType {
MAIN,
DETAIL
}
Loading