Skip to content

Commit 04ff345

Browse files
committed
feat(product): 상품 도메인 모델 구현 및 재고/정렬 기능 추가
- Product 엔티티 구현 (재고, 좋아요 수 포함) - decreaseStock, increaseLikeCount 등 도메인 로직 추가 - ProductRepository 인터페이스 정의 - Product 단위/통합 테스트 작성
1 parent 25b423e commit 04ff345

12 files changed

Lines changed: 593 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.product.ProductDetail;
4+
5+
/**
6+
* packageName : com.loopers.application.product
7+
* fileName : ProductDetail
8+
* author : byeonsungmun
9+
* date : 2025. 11. 13.
10+
* description :
11+
* ===========================================
12+
* DATE AUTHOR NOTE
13+
* -------------------------------------------
14+
* 2025. 11. 13. byeonsungmun 최초 생성
15+
*/
16+
public record ProductDetailInfo(
17+
Long id,
18+
String name,
19+
String brandName,
20+
Long price,
21+
Long likeCount
22+
) {
23+
public static ProductDetailInfo from(ProductDetail productDetail) {
24+
return new ProductDetailInfo(
25+
productDetail.getId(),
26+
productDetail.getName(),
27+
productDetail.getBrandName(),
28+
productDetail.getPrice(),
29+
productDetail.getLikeCount()
30+
);
31+
}
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.brand.Brand;
4+
import com.loopers.domain.brand.BrandService;
5+
import com.loopers.domain.like.LikeService;
6+
import com.loopers.domain.product.ProductDetail;
7+
import com.loopers.domain.product.ProductDomainService;
8+
import com.loopers.domain.product.ProductService;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.stereotype.Component;
13+
14+
/**
15+
* packageName : com.loopers.application.product
16+
* fileName : ProdcutFacade
17+
* author : byeonsungmun
18+
* date : 2025. 11. 10.
19+
* description :
20+
* ===========================================
21+
* DATE AUTHOR NOTE
22+
* -------------------------------------------
23+
* 2025. 11. 10. byeonsungmun 최초 생성
24+
*/
25+
@Component
26+
@RequiredArgsConstructor
27+
public class ProductFacade {
28+
29+
private final ProductService productService;
30+
private final BrandService brandService;
31+
private final LikeService likeService;
32+
private final ProductDomainService productDomainService;
33+
34+
public Page<ProductInfo> getProducts(Pageable pageable) {
35+
return productService.getProducts(pageable)
36+
.map(product -> {
37+
Brand brand = brandService.getBrand(product.getId());
38+
long likeCount = likeService.countByProductId(product.getId());
39+
return ProductInfo.of(product, brand, likeCount);
40+
});
41+
}
42+
43+
public ProductDetailInfo getProduct(Long id) {
44+
ProductDetail productDetail = productDomainService.getProductDetail(id);
45+
return ProductDetailInfo.from(productDetail);
46+
}
47+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.brand.Brand;
4+
import com.loopers.domain.product.Product;
5+
6+
/**
7+
* packageName : com.loopers.application.product
8+
* fileName : ProductInfo
9+
* author : byeonsungmun
10+
* date : 2025. 11. 10.
11+
* description :
12+
* ===========================================
13+
* DATE AUTHOR NOTE
14+
* -------------------------------------------
15+
* 2025. 11. 10. byeonsungmun 최초 생성
16+
*/
17+
public record ProductInfo(
18+
Long id,
19+
String name,
20+
String brandName,
21+
Long price,
22+
Long likeCount
23+
) {
24+
public static ProductInfo of(Product product, Brand brand, Long likeCount) {
25+
return new ProductInfo(
26+
product.getId(),
27+
product.getName(),
28+
brand.getName(),
29+
product.getPrice(),
30+
likeCount
31+
);
32+
}
33+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.loopers.domain.product;
2+
3+
import com.loopers.support.error.CoreException;
4+
import com.loopers.support.error.ErrorType;
5+
import jakarta.persistence.*;
6+
import lombok.Getter;
7+
8+
/**
9+
* packageName : com.loopers.domain.product
10+
* fileName : Product
11+
* author : byeonsungmun
12+
* date : 2025. 11. 10.
13+
* description :
14+
* ===========================================
15+
* DATE AUTHOR NOTE
16+
* -------------------------------------------
17+
* 2025. 11. 10. byeonsungmun 최초 생성
18+
*/
19+
20+
@Entity
21+
@Table(name = "product")
22+
@Getter
23+
public class Product {
24+
25+
@Id
26+
@GeneratedValue(strategy = GenerationType.IDENTITY)
27+
private Long id;
28+
29+
@Column(name = "ref_brand_id", nullable = false)
30+
private Long brandId;
31+
32+
@Column(nullable = false)
33+
private String name;
34+
35+
@Column(nullable = false)
36+
private Long price;
37+
38+
@Column
39+
private Long likeCount;
40+
41+
@Column(nullable = false)
42+
private Long stock;
43+
44+
protected Product() {}
45+
46+
private Product(Long brandId, String name, Long price, Long likeCount, Long stock) {
47+
this.brandId = requireValidBrandId(brandId);
48+
this.name = requireValidName(name);
49+
this.price = requireValidPrice(price);
50+
this.likeCount = requireValidLikeCount(likeCount);
51+
this.stock = requireValidStock(stock);
52+
}
53+
54+
public static Product create(Long brandId, String name, Long price, Long stock) {
55+
return new Product(
56+
brandId,
57+
name,
58+
price,
59+
0L,
60+
stock
61+
);
62+
}
63+
64+
private Long requireValidBrandId(Long brandId) {
65+
if (brandId == null || brandId <= 0) {
66+
throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다.");
67+
}
68+
69+
return brandId;
70+
}
71+
72+
private String requireValidName(String name) {
73+
if (name == null || name.isEmpty()) {
74+
throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다.");
75+
}
76+
return name;
77+
}
78+
79+
private Long requireValidPrice(Long price) {
80+
if (price == null || price < 0) {
81+
throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0원 이상이어야 합니다.");
82+
}
83+
return price;
84+
}
85+
86+
public Long requireValidLikeCount(Long likeCount) {
87+
if (likeCount == null || likeCount < 0) {
88+
throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 개수는 0개 미만으로 설정할 수 없습니다.");
89+
}
90+
return likeCount;
91+
}
92+
93+
private Long requireValidStock(Long stock) {
94+
if (stock < 0) {
95+
throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 0 미만으로 설정할 수 없습니다.");
96+
}
97+
return stock;
98+
}
99+
100+
public void increaseLikeCount() {
101+
this.likeCount++;
102+
}
103+
104+
public void decreaseLikeCount() {
105+
if (this.likeCount > 0) this.likeCount--;
106+
}
107+
108+
public void decreaseStock(Long quantity) {
109+
if (quantity <= 0) {
110+
throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 0보다 커야 합니다.");
111+
}
112+
if (this.stock - quantity < 0) {
113+
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
114+
}
115+
this.stock -= quantity;
116+
}
117+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.loopers.domain.product;
2+
3+
import com.loopers.domain.brand.Brand;
4+
import lombok.Getter;
5+
6+
/**
7+
* packageName : com.loopers.domain.product
8+
* fileName : ProductDetail
9+
* author : byeonsungmun
10+
* date : 2025. 11. 13.
11+
* description :
12+
* ===========================================
13+
* DATE AUTHOR NOTE
14+
* -------------------------------------------
15+
* 2025. 11. 13. byeonsungmun 최초 생성
16+
*/
17+
@Getter
18+
public class ProductDetail {
19+
20+
private Long id;
21+
private String name;
22+
private String brandName;
23+
private Long price;
24+
private Long likeCount;
25+
26+
protected ProductDetail() {}
27+
28+
private ProductDetail(Long id, String name, String brandName, Long price, Long likeCount) {
29+
this.id = id;
30+
this.name = name;
31+
this.brandName = brandName;
32+
this.price = price;
33+
this.likeCount = likeCount;
34+
}
35+
36+
public static ProductDetail of(Product product, Brand brand, Long likeCount) {
37+
return new ProductDetail(
38+
product.getId(),
39+
product.getName(),
40+
brand.getName(),
41+
product.getPrice(),
42+
likeCount
43+
);
44+
}
45+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.loopers.domain.product;
2+
3+
import com.loopers.domain.brand.Brand;
4+
import com.loopers.domain.brand.BrandRepository;
5+
import com.loopers.domain.like.LikeRepository;
6+
import com.loopers.support.error.CoreException;
7+
import com.loopers.support.error.ErrorType;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Component;
10+
11+
/**
12+
* packageName : com.loopers.domain.product
13+
* fileName : ProductDetailService
14+
* author : byeonsungmun
15+
* date : 2025. 11. 13.
16+
* description :
17+
* ===========================================
18+
* DATE AUTHOR NOTE
19+
* -------------------------------------------
20+
* 2025. 11. 13. byeonsungmun 최초 생성
21+
*/
22+
@Component
23+
@RequiredArgsConstructor
24+
public class ProductDomainService {
25+
26+
private final ProductRepository productRepository;
27+
private final BrandRepository brandRepository;
28+
private final LikeRepository likeRepository;
29+
30+
public ProductDetail getProductDetail(Long id) {
31+
Product product = productRepository.findById(id)
32+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
33+
Brand brand = brandRepository.findById(product.getBrandId())
34+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다"));
35+
long likeCount = likeRepository.countByProductId(id);
36+
37+
return ProductDetail.of(product, brand, likeCount);
38+
}
39+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.loopers.domain.product;
2+
3+
import org.springframework.data.domain.Page;
4+
import org.springframework.data.domain.Pageable;
5+
6+
import java.util.Optional;
7+
8+
/**
9+
* packageName : com.loopers.domain.product
10+
* fileName : ProductRepositroy
11+
* author : byeonsungmun
12+
* date : 2025. 11. 12.
13+
* description :
14+
* ===========================================
15+
* DATE AUTHOR NOTE
16+
* -------------------------------------------
17+
* 2025. 11. 12. byeonsungmun 최초 생성
18+
*/
19+
public interface ProductRepository {
20+
Page<Product> findAll(Pageable pageable);
21+
22+
Optional<Product> findById(Long id);
23+
24+
void incrementLikeCount(Long productId);
25+
26+
void decrementLikeCount(Long productId);
27+
28+
Product save(Product product);
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.loopers.domain.product;
2+
3+
import com.loopers.support.error.CoreException;
4+
import com.loopers.support.error.ErrorType;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.data.domain.Page;
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
/**
12+
* packageName : com.loopers.domain.product
13+
* fileName : ProductService
14+
* author : byeonsungmun
15+
* date : 2025. 11. 12.
16+
* description :
17+
* ===========================================
18+
* DATE AUTHOR NOTE
19+
* -------------------------------------------
20+
* 2025. 11. 12. byeonsungmun 최초 생성
21+
*/
22+
23+
@Component
24+
@RequiredArgsConstructor
25+
public class ProductService {
26+
27+
private final ProductRepository productRepository;
28+
29+
@Transactional(readOnly = true)
30+
public Page<Product> getProducts(Pageable pageable) {
31+
return productRepository.findAll(pageable);
32+
}
33+
34+
public Product getProduct(Long productId) {
35+
return productRepository.findById(productId)
36+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다"));
37+
}
38+
}
39+

0 commit comments

Comments
 (0)