Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 @@ -4,12 +4,20 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@ConfigurationPropertiesScan
@SpringBootApplication
@EnableScheduling
public class CommerceApiApplication {

/**
* Sets the JVM default time zone to Asia/Seoul after the application bean is constructed.
*
* This runs after dependency injection so the application (including scheduled tasks) uses
* Asia/Seoul as the default time zone.
*/
@PostConstruct
public void started() {
// set timezone
Expand All @@ -19,4 +27,4 @@ public void started() {
public static void main(String[] args) {
SpringApplication.run(CommerceApiApplication.class, args);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.loopers.application.catalog;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 브랜드 조회 파사드.
* <p>
* 브랜드 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다.
* </p>
*
* @author Loopers
* @version 1.0
*/
@RequiredArgsConstructor
@Component
public class CatalogBrandFacade {
private final BrandRepository brandRepository;

/**
* Retrieve brand information for the given brand ID.
*
* @param brandId the brand's identifier
* @return the BrandInfo for the given brand ID
* @throws CoreException if no brand exists with the given ID
*/
public BrandInfo getBrand(Long brandId) {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
return BrandInfo.from(brand);
}

/**
* 브랜드 정보를 담는 레코드.
*
* @param id 브랜드 ID
* @param name 브랜드 이름
*/
public record BrandInfo(Long id, String name) {
/**
* Create a BrandInfo from a Brand entity.
*
* @param brand the source Brand entity
* @return a BrandInfo containing the brand's id and name
*/
public static BrandInfo from(Brand brand) {
return new BrandInfo(brand.getId(), brand.getName());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
import com.loopers.domain.product.ProductDetailService;
import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -31,33 +28,60 @@
public class CatalogProductFacade {
private final ProductRepository productRepository;
private final BrandRepository brandRepository;
private final LikeRepository likeRepository;
private final ProductDetailService productDetailService;

/**
* 상품 목록을 조회합니다.
* Retrieve a paginated list of products, optionally filtered by brand.
*
* @param brandId 브랜드 ID (선택)
* @param sort 정렬 기준 (latest, price_asc, likes_desc)
* @param page 페이지 번호 (0부터 시작)
* @param size 페이지당 상품 수
* @return 상품 목록 조회 결과
* Performs batch-loading of brands to avoid N+1 query issues.
*
* @param brandId optional brand ID to filter products
* @param sort sorting key; expected values include "latest", "price_asc", "likes_desc"
* @param page zero-based page index
* @param size number of products per page
* @return a ProductInfoList containing the page of ProductInfo, total count, page, and size
*/
public ProductInfoList getProducts(Long brandId, String sort, int page, int size) {
long totalCount = productRepository.countAll(brandId);
List<Product> products = productRepository.findAll(brandId, sort, page, size);

if (products.isEmpty()) {
return new ProductInfoList(List.of(), totalCount, page, size);
}

// ✅ 배치 조회로 N+1 쿼리 문제 해결
// 브랜드 ID 수집
List<Long> brandIds = products.stream()
.map(Product::getBrandId)
.distinct()
.toList();

// 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해)
Map<Long, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, brand -> brand));

// 상품 정보 변환 (이미 조회한 Product 재사용)
List<ProductInfo> productsInfo = products.stream()
.map(product -> getProduct(product.getId()))
.map(product -> {
Brand brand = brandMap.get(product.getBrandId());
if (brand == null) {
throw new CoreException(ErrorType.NOT_FOUND,
String.format("브랜드를 찾을 수 없습니다. (브랜드 ID: %d)", product.getBrandId()));
}
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount());
return new ProductInfo(productDetail);
})
.toList();

return new ProductInfoList(productsInfo, totalCount, page, size);
}

/**
* 상품 정보를 조회합니다.
* Retrieve detailed product information including the brand name and likes count.
*
* @param productId 상품 ID
* @return 상품 정보와 좋아요 수
* @throws CoreException 상품을 찾을 수 없는 경우
* @param productId the ID of the product to retrieve
* @return a ProductInfo containing product details and the product's likes count
* @throws CoreException if the product or its brand cannot be found (ErrorType.NOT_FOUND)
*/
public ProductInfo getProduct(Long productId) {
Product product = productRepository.findById(productId)
Expand All @@ -67,15 +91,13 @@ public ProductInfo getProduct(Long productId) {
Brand brand = brandRepository.findById(product.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));

// 좋아요 수 조회
Map<Long, Long> likesCountMap = likeRepository.countByProductIds(List.of(productId));
Long likesCount = likesCountMap.getOrDefault(productId, 0L);
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
Long likesCount = product.getLikeCount();

// 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력)
ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount);
// ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);

return new ProductInfo(productDetail);
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
import com.loopers.domain.user.UserRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

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

/**
* 좋아요 관리 파사드.
Expand All @@ -33,27 +34,52 @@ public class LikeFacade {
private final ProductRepository productRepository;

/**
* 상품에 좋아요를 추가합니다.
* <p>
* 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다.
* </p>
* Add a like for the given product on behalf of the specified user.
*
* @param userId 사용자 ID (String)
* @param productId 상품 ID
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
* Ensures idempotency: if a like already exists for the user and product, the method returns without side effects.
* To handle concurrent requests it relies on a UNIQUE constraint at the database level and treats
* a DataIntegrityViolationException caused by a duplicate key as a successful, idempotent outcome;
* if such an exception occurs but the like is still not present after re-check, the exception is rethrown.
*
* @param userId the identifier of the user
* @param productId the identifier of the product
* @throws CoreException if the user or product cannot be found
* @throws org.springframework.dao.DataIntegrityViolationException if saving fails due to a constraint violation and the like is not present after re-check
*/
@Transactional
public void addLike(String userId, Long productId) {
User user = loadUser(userId);
loadProduct(productId);

// 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리)
// ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음
// 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능
Optional<Like> existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
if (existingLike.isPresent()) {
return;
}

// 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능)
// ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지
Like like = Like.of(user.getId(), productId);
likeRepository.save(like);
try {
likeRepository.save(like);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// UNIQUE 제약조건 위반 예외 처리
// 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때,
// 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생
// 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 (멱등성 보장)

// 저장 실패 후 다시 한 번 확인 (다른 트랜잭션이 이미 저장했을 수 있음)
Optional<Like> savedLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
if (savedLike.isEmpty()) {
// 예외가 발생했지만 실제로 저장되지 않은 경우 (드문 경우)
// UNIQUE 제약조건 위반이지만 다른 이유일 수 있으므로 예외를 다시 던짐
throw e;
}
// 이미 저장되어 있으므로 정상 처리로 간주
return;
}
}

/**
Expand All @@ -80,12 +106,18 @@ public void removeLike(String userId, Long productId) {
}

/**
* 사용자가 좋아요한 상품 목록을 조회합니다.
* Retrieve the list of products liked by the specified user.
*
* @param userId 사용자 ID (String)
* @return 좋아요한 상품 목록
* @throws CoreException 사용자를 찾을 수 없는 경우
* This returns a list of LikedProduct DTOs for the user's likes and relies on each Product's
* `likeCount` field as the source of the like count; that value is asynchronously aggregated and
* may be slightly stale (approximately up to 5 seconds). The method verifies that every liked
* product still exists and will fail if any referenced product cannot be found.
*
* @param userId the identifier of the user
* @return a list of LikedProduct representing the products the user has liked
* @throws CoreException if the user cannot be found or if any liked product is missing
*/
@Transactional(readOnly = true)
public List<LikedProduct> getLikedProducts(String userId) {
User user = loadUser(userId);

Expand All @@ -101,26 +133,26 @@ public List<LikedProduct> getLikedProducts(String userId) {
.map(Like::getProductId)
.toList();

// 상품 정보 조회
List<Product> products = productIds.stream()
.map(productId -> productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))))
.toList();
// ✅ 배치 조회로 N+1 쿼리 문제 해결
Map<Long, Product> productMap = productRepository.findAllById(productIds).stream()
.collect(Collectors.toMap(Product::getId, product -> product));

// 좋아요 수 집계
Map<Long, Long> likesCountMap = likeRepository.countByProductIds(productIds);
// 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인
if (productMap.size() != productIds.size()) {
throw new CoreException(ErrorType.NOT_FOUND, "일부 상품을 찾을 수 없습니다.");
}

// 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
return likes.stream()
.map(like -> {
Product product = products.stream()
.filter(p -> p.getId().equals(like.getProductId()))
.findFirst()
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())));
Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L);
return LikedProduct.from(product, like, likesCount);
Product product = productMap.get(like.getProductId());
if (product == null) {
throw new CoreException(ErrorType.NOT_FOUND,
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId()));
}
// Product 엔티티의 likeCount 필드를 내부에서 사용
return LikedProduct.from(product);
})
.toList();
}
Expand Down Expand Up @@ -158,23 +190,26 @@ public record LikedProduct(
Long likesCount
) {
/**
* Product와 Like로부터 LikedProduct를 생성합니다.
* Create a LikedProduct DTO from the given Product.
*
* Uses the Product.likeCount field as the source of the product's like count.
*
* @param product 상품 엔티티
* @param like 좋아요 엔티티
* @param likesCount 좋아요 수
* @return 생성된 LikedProduct
* @param product the product entity to convert; must not be null
* @return the constructed LikedProduct
* @throws IllegalArgumentException if {@code product} is null
*/
public static LikedProduct from(Product product, Like like, Long likesCount) {
public static LikedProduct from(Product product) {
if (product == null) {
throw new IllegalArgumentException("상품은 null일 수 없습니다.");
}
return new LikedProduct(
product.getId(),
product.getName(),
product.getPrice(),
product.getStock(),
product.getBrandId(),
likesCount
product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
);
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
*
* @param productId 상품 ID
* @param quantity 수량
* @param couponCode 쿠폰 코드 (선택)
*/
public record OrderItemCommand(Long productId, Integer quantity) {
public record OrderItemCommand(Long productId, Integer quantity, String couponCode) {
public OrderItemCommand {
if (productId == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다.");
Expand All @@ -18,5 +19,15 @@ public record OrderItemCommand(Long productId, Integer quantity) {
throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다.");
}
}
}

/**
* Create an OrderItemCommand with no coupon code.
*
* @param productId the product identifier
* @param quantity the quantity of the product
* @return the created OrderItemCommand with `couponCode` set to `null`
*/
public static OrderItemCommand of(Long productId, Integer quantity) {
return new OrderItemCommand(productId, quantity, null);
}
}
Loading