Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@
import com.loopers.domain.product.ProductLikeInfo;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserDomainService;
import com.loopers.infrastructure.cache.ProductCacheService;
import com.loopers.interfaces.api.like.ProductLikeDto;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;

import java.util.List;
Expand All @@ -22,44 +20,24 @@
public class ProductLikeFacade {

private final ProductLikeDomainService productLikeDomainService;
private final ProductDomainService productDomainService;
private final UserDomainService userDomainService;
private final ProductCacheService productCacheService;


public ProductLikeDto.LikeResponse likeProduct(String userId, Long productId) {
// 사용자 조회
User user = userDomainService.findUser(userId);

// 좋아요 - 낙관적 락 예외 발생 가능
try {
ProductLikeInfo info = productLikeDomainService.likeProduct(user, productId);
return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());

} catch (ObjectOptimisticLockingFailureException e) {
throw new CoreException(
ErrorType.CONFLICT,
"일시적인 오류가 발생했습니다. 다시 시도해주세요."
);
}
ProductLikeInfo info = productLikeDomainService.likeProduct(user, productId);
productCacheService.deleteProductDetail(productId);
return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
}

public ProductLikeDto.LikeResponse unlikeProduct(String userId, Long productId) {
// 사용자 조회
User user = userDomainService.findUser(userId);

// 좋아요 취소 - 낙관적 락 예외 발생 가능
try {
ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, productId);
return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());

} catch (
ObjectOptimisticLockingFailureException e) {
throw new CoreException(
ErrorType.CONFLICT,
"일시적인 오류가 발생했습니다. 다시 시도해주세요."
);
}

ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, productId);
productCacheService.deleteProductDetail(productId);
return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
}

public ProductLikeDto.LikedProductsResponse getLikedProducts(String userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.loopers.domain.point.PointAccountDomainService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDomainService;
import com.loopers.infrastructure.cache.ProductCacheService;
import com.loopers.interfaces.api.order.OrderDto;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
Expand All @@ -16,9 +17,6 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
Expand All @@ -27,6 +25,7 @@ public class OrderFacade {
private final OrderDomainService orderDomainService;
private final ProductDomainService productDomainService;
private final PointAccountDomainService pointAccountDomainService;
private final ProductCacheService productCacheService;

@Transactional
public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> itemRequests) {
Expand All @@ -48,7 +47,10 @@ public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> item
itemRequest.quantity()
);

totalAmount += product.getPrice() * itemRequest.quantity();
// 재고 변경되었으니 해당 상품의 detail 캐시 무효화
productCacheService.deleteProductDetail(itemRequest.productId());

totalAmount += product.getPrice() * itemRequest.quantity();

orderItems.add(OrderItem.create(
product.getId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDomainService;
import com.loopers.domain.product.ProductSortType;
import com.loopers.infrastructure.cache.ProductCacheService;
import com.loopers.infrastructure.cache.ProductDetailCache;
import com.loopers.interfaces.api.product.ProductDto;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -13,6 +15,7 @@

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand All @@ -23,9 +26,14 @@ public class ProductFacade {

private final ProductDomainService productDomainService;
private final BrandDomainService brandDomainService;
private final ProductCacheService productCacheService;

/**
* 상품 목록 조회
* 상품 목록 조회 (Cache-Aside 패턴)
*
* 1. 캐시 조회
* 2. Cache Hit: 캐시된 상품 목록 직접 반환
* 3. Cache Miss: DB 조회 → 캐시 저장
*/
public ProductDto.ProductListResponse getProducts(
Long brandId,
Expand All @@ -38,33 +46,114 @@ public ProductDto.ProductListResponse getProducts(
brandDomainService.getActiveBrand(brandId);
}

// 상품 조회
// 1. 목록 캐시 조회 (ID만)
Optional<ProductCacheService.ProductListCache> cachedList =
productCacheService.getProductList(brandId, sort, page, size);

if (cachedList.isPresent()) {
// Cache Hit: ID 리스트로 각 상품 detail 캐시 조회
List<Long> productIds = cachedList.get().getProductIds();
List<ProductDto.ProductResponse> products = productIds.stream()
.map(this::getProductResponseFromCache)
.toList();

return new ProductDto.ProductListResponse(
products,
cachedList.get().getTotalCount()
);
}

// Cache Miss: DB에서 조회
ProductSortType sortType = ProductSortType.from(sort);
Page<Product> products = productDomainService.getProducts(
brandId, sortType, page, size
);
Page<Product> products = productDomainService.getProducts(brandId, sortType, page, size);

// 브랜드 정보 조회
Set<Long> brandIds = products.stream()
Set<Long> brandIds = products.getContent().stream()
.map(Product::getBrandId)
.collect(Collectors.toSet());
Map<Long, Brand> brandMap = brandDomainService.getBrandMap(brandIds);

// Response 생성
ProductDto.ProductListResponse response = ProductDto.ProductListResponse.from(products, brandMap);

// 각 상품 detail 캐시 저장
for (Product product : products.getContent()) {
Brand brand = brandMap.get(product.getBrandId());
ProductDetailCache cache = ProductDetailCache.from(product, brand);
productCacheService.setProductDetail(product.getId(), cache);
}

// 목록 캐시 저장 (ID만)
List<Long> productIds = response.products().stream()
.map(ProductDto.ProductResponse::id)
.toList();
ProductCacheService.ProductListCache listCache =
new ProductCacheService.ProductListCache(productIds, response.totalCount());
productCacheService.setProductList(brandId, sort, page, size, listCache);

return ProductDto.ProductListResponse.from(products, brandMap);
return response;
}

private ProductDto.ProductResponse getProductResponseFromCache(Long productId) {
// detail 캐시 조회
Optional<ProductDetailCache> cached = productCacheService.getProductDetail(productId);

if (cached.isPresent()) {
ProductDetailCache cache = cached.get();
return new ProductDto.ProductResponse(
productId,
cache.getName(),
cache.getPrice(),
cache.getTotalLikes(),
cache.getBrand()
);
}

// Cache Miss: DB에서 조회
Product product = productDomainService.getProduct(productId);
Brand brand = brandDomainService.getBrand(product.getBrandId());

// detail 캐시 저장
ProductDetailCache cache = ProductDetailCache.from(product, brand);
productCacheService.setProductDetail(productId, cache);

return new ProductDto.ProductResponse(
product.getId(),
product.getName(),
product.getPrice(),
product.getTotalLikes(),
ProductDto.BrandSummary.from(brand)
);
}

/**
* 상품 상세 조회
* 상품 상세 조회 (Cache-Aside 패턴)
*
* 1. 캐시 조회
* 2. Cache Hit: 캐시된 데이터 직접 반환 (id만 URL에서)
* 3. Cache Miss: DB 조회 → 캐시 저장 → 반환
*/
public ProductDto.ProductDetailResponse getProduct(Long productId) {
// 1. 상품 조회
Product product = productDomainService.getProduct(productId);
// 1. 캐시 조회 시도
Optional<ProductDetailCache> cachedDetail = productCacheService.getProductDetail(productId);

if (cachedDetail.isPresent()) {
// Cache Hit: 캐시된 데이터 직접 반환
return ProductDto.ProductDetailResponse.from(
productId,
cachedDetail.get()
);
}

// 2. 브랜드 조회
// Cache Miss: DB에서 조회
Product product = productDomainService.getProduct(productId);
Brand brand = brandDomainService.getBrand(product.getBrandId());

// 3. DTO 변환
return ProductDto.ProductDetailResponse.from(product, brand);
// 캐시 데이터 생성 및 저장
ProductDetailCache cache = ProductDetailCache.from(product, brand);
productCacheService.setProductDetail(productId, cache);

// Response 반환
return ProductDto.ProductDetailResponse.from(productId, cache);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@

import com.loopers.domain.BaseEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;

@Entity
@Table(name = "brands")
@Table(
name = "brands",
indexes = {
@Index(name = "idx_active", columnList = "active"),
@Index(name = "idx_name", columnList = "name")
}
)
public class Brand extends BaseEntity {

private String name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

Expand All @@ -14,8 +15,13 @@
name = "product_likes",
uniqueConstraints = @UniqueConstraint(
name = "uk_product_like_user_product",
columnNames = {"user_id", "product_id"} // ⚠️ 실제 컬럼명으로!
)
columnNames = {"user_id", "product_id"}
),
indexes = {
@Index(name = "idx_user_id", columnList = "user_id"),
@Index(name = "idx_product_id", columnList = "product_id"),
@Index(name = "idx_user_product", columnList = "user_id, product_id")
}
)
public class ProductLike extends BaseEntity {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,51 +20,47 @@ public class ProductLikeDomainService {

@Transactional
public ProductLikeInfo likeProduct(User user, Long productId) {
// 이미 좋아요했는지
// 이미 좋아요했는지 확인
Optional<ProductLike> existingLike = productLikeRepository
.findByUserIdAndProductId(user.getId(), productId);

if (existingLike.isPresent()) {
long current = productRepository.findByIdOrThrow(productId).getTotalLikes();

return ProductLikeInfo.from(true, current);
}

// 좋아요
// 좋아요 기록 추가
ProductLike like = ProductLike.create(user.getId(), productId);
productLikeRepository.save(like);

// 좋아요 증가 및 저장
Product product = productRepository.findByIdOrThrow(productId);
product.increaseLikes();
productRepository.save(product);
productRepository.flush();
// 좋아요 수 증가 (비정규화)
productRepository.incrementLikeCount(productId);

return ProductLikeInfo.from(true, product.getTotalLikes());
// 증가된 좋아요 수 조회
long currentLikeCount = productRepository.findByIdOrThrow(productId).getTotalLikes();
return ProductLikeInfo.from(true, currentLikeCount);
}

@Transactional
public ProductLikeInfo unlikeProduct(User user, Long productId) {
// 좋아요 조회
// 좋아요 기록 조회
Optional<ProductLike> existingLike = productLikeRepository
.findByUserIdAndProductId(user.getId(), productId);

if (existingLike.isEmpty()) {
long current = productRepository.findByIdOrThrow(productId).getTotalLikes();

return ProductLikeInfo.from(false, current);
}

// 좋아요 취소
// 좋아요 기록 삭제
productLikeRepository.delete(existingLike.get());

// 좋아요 감소 및 저장
Product product = productRepository.findByIdOrThrow(productId);
product.decreaseLikes();
productRepository.save(product);
productRepository.flush();
// 좋아요 수 감소 (비정규화)
productRepository.decrementLikeCount(productId);

return ProductLikeInfo.from(false, product.getTotalLikes());
// 감소된 좋아요 수 조회
long currentLikeCount = productRepository.findByIdOrThrow(productId).getTotalLikes();
return ProductLikeInfo.from(false, currentLikeCount);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
import java.util.List;

@Entity
@Table(name = "orders")
@Table(
name = "orders",
indexes = {
@Index(name = "idx_user_id", columnList = "user_id"),
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_user_status", columnList = "user_id, status"),
@Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC")
}
)
public class Order extends BaseEntity {

private String userId;
Expand Down
Loading