Skip to content
Open
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
47 changes: 47 additions & 0 deletions Round3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## 도메인 & 객체 설계 전략

비즈니스 규칙 캡슐화: 도메인 객체(Entity, VO)는 데이터만 가진 구조체가 아니라, 자신의 비즈니스 규칙을 스스로 검증하고 수행해야 합니다.


애플리케이션 서비스의 역할: 서로 다른 도메인 객체들을 조합하고 로직을 조정(Orchestration)하여 기능을 완성하는 데 집중하며, 핵심 비즈니스 로직은 도메인으로 위임합니다.



규칙의 위치: 특정 규칙이 여러 서비스에서 중복되어 나타난다면, 해당 규칙은 도메인 객체의 책임일 가능성이 높으므로 도메인 내부로 옮깁니다.


의도적인 설계: 각 기능의 책임 소재와 객체 간 결합도에 대해 개발자의 의도를 명확히 반영하여 개발을 진행합니다.

## 아키텍처 및 패키지 구성 전략
본 프로젝트는 **레이어드 아키텍처(Layered Architecture)**를 기반으로 하며, **DIP(의존성 역전 원칙)**를 jpa 관점에서 적당히 편리한 만큼만 적용한다.

패키지 구조 (Layer + Domain)
패키징은 4개의 계층을 최상위에 두고, 그 하위에 도메인별로 구성합니다.



/interfaces/api: Presentation 레이어로 API 컨트롤러와 요청/응답 객체가 위치합니다.



/application/..: Application 레이어로 도메인 레이어를 조합하여 유스케이스 기능을 제공합니다.



/domain/..: Domain 레이어로 도메인 객체(Entity, VO, Domain Service)와 Repository 인터페이스가 위치합니다.



/infrastructure/..: Infrastructure 레이어로 JPA, Redis 등 기술적인 Repository 구현체를 제공합니다.


데이터 전달 객체(DTO) 정책

DTO 분리: API 계층에서 사용하는 Request/Response DTO와 Application 계층에서 사용하는 DTO를 엄격히 분리하여 작성합니다.

의존성 및 테스트 전략
DIP 적용: 의존성 방향은 항상 Domain을 향해야 합니다. Infrastructure 구현체는 Domain에 정의된 인터페이스를 상속합니다.


단위 테스트: 핵심 도메인 로직은 외부 의존성이 분리된 상태에서 Fake 또는 Stub을 사용하여 테스트 가능한 구조로 설계하고 검증합니다.
+2
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Component
public class BrandFacade {

private final BrandService brandService;
private final ProductService productService;

@Transactional
public BrandInfo register(String name, String description) {
Brand brand = brandService.register(name, description);
return BrandInfo.from(brand);
}

@Transactional(readOnly = true)
public BrandInfo getBrand(Long id) {
return BrandInfo.from(brandService.getBrand(id));
}

@Transactional(readOnly = true)
public List<BrandInfo> getBrands() {
return brandService.getBrands().stream()
.map(BrandInfo::from)
.toList();
}

@Transactional
public void deleteBrand(Long id) {
productService.deleteProductsByBrandId(id);
brandService.deleteBrand(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;

public record BrandInfo(Long id, String name, String description) {

public static BrandInfo from(Brand brand) {
return new BrandInfo(brand.getId(), brand.getName(), brand.getDescription());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.loopers.application.like;

import com.loopers.application.product.ProductInfo;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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

@RequiredArgsConstructor
@Component
public class LikeFacade {

private final LikeService likeService;
private final ProductService productService;
private final UserService userService;
private final BrandService brandService;

@Transactional(readOnly = true)
public List<ProductInfo> getLikedProducts(String loginId, String rawPassword) {
User user = userService.authenticate(loginId, rawPassword);
List<Long> productIds = likeService.getLikedProductIds(user.getId());
// 상품 목록을 IN 쿼리로 한 번에 조회 (N+1 방지)
List<Product> products = productService.getProductsByIds(productIds);
// 브랜드도 IN 쿼리로 한 번에 조회 후 Map으로 변환
List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList();
Map<Long, Brand> brandMap = brandService.getBrandsByIds(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, b -> b));
return products.stream()
.map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId())))
.toList();
}

@Transactional
public void addLike(String loginId, String rawPassword, Long productId) {
User user = userService.authenticate(loginId, rawPassword);
Product product = productService.getProduct(productId);
likeService.addLike(user.getId(), productId);
product.increaseLikes();
}

@Transactional
public void removeLike(String loginId, String rawPassword, Long productId) {
User user = userService.authenticate(loginId, rawPassword);
Product product = productService.getProduct(productId);
likeService.removeLike(user.getId(), productId);
product.decreaseLikes();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.loopers.application.order;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
import com.loopers.domain.order.OrderService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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

@RequiredArgsConstructor
@Component
public class OrderFacade {

private final OrderService orderService;
private final ProductService productService;
private final UserService userService;
private final BrandService brandService;

@Transactional
public OrderInfo createOrder(String loginId, String rawPassword,
List<OrderRequest.OrderItemRequest> items) {
User user = userService.authenticate(loginId, rawPassword);

List<Product> products = new ArrayList<>();
long totalAmount = 0L;
for (OrderRequest.OrderItemRequest item : items) {
Product product = productService.getProduct(item.productId());
product.decreaseStock(item.quantity());
totalAmount += (long) product.getPrice() * item.quantity();
products.add(product);
}

user.deductBalance(totalAmount);

// 주문 시점의 브랜드명을 스냅샷으로 저장하기 위해 브랜드 정보를 한 번에 조회 (N+1 방지)
List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList();
Map<Long, Brand> brandMap = brandService.getBrandsByIds(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, b -> b));

String orderNumber = orderService.generateOrderNumber();
Order order = orderService.createOrder(user.getId(), orderNumber, totalAmount);

// 상품명, 브랜드명, 이미지 URL, 단가를 스냅샷으로 저장 (이후 상품 정보 변경에도 주문 내역 보존)
for (int i = 0; i < items.size(); i++) {
Product product = products.get(i);
OrderRequest.OrderItemRequest item = items.get(i);
Brand brand = brandMap.get(product.getBrandId());
orderService.createOrderItem(order.getId(), product.getId(),
product.getName(), brand.getName(), product.getImageUrl(), product.getPrice(), item.quantity());
}

List<OrderItem> orderItems = orderService.getOrderItems(order.getId());
return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList());
}

@Transactional(readOnly = true)
public Page<OrderInfo> getOrders(String loginId, String rawPassword, Pageable pageable) {
User user = userService.authenticate(loginId, rawPassword);
Page<Order> orders = orderService.getOrders(user.getId(), pageable);

List<Long> orderIds = orders.stream().map(Order::getId).toList();
Map<Long, List<OrderItemInfo>> itemsByOrderId = orderService.getOrderItemsByOrderIds(orderIds).stream()
.collect(Collectors.groupingBy(
OrderItem::getOrderId,
Collectors.mapping(OrderItemInfo::from, Collectors.toList())
));

return orders.map(order ->
OrderInfo.from(order, itemsByOrderId.getOrDefault(order.getId(), List.of()))
);
}

@Transactional(readOnly = true)
public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId) {
User user = userService.authenticate(loginId, rawPassword);
Order order = orderService.getOrder(orderId);
if (!order.getUserId().equals(user.getId())) {
throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.");
}
List<OrderItem> items = orderService.getOrderItems(orderId);
return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList());
}

@Transactional
public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) {
User user = userService.authenticate(loginId, rawPassword);
Order found = orderService.getOrder(orderId);
if (!found.getUserId().equals(user.getId())) {
throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.");
}
Order order = orderService.cancelOrder(found);

List<OrderItem> orderItems = orderService.getOrderItems(orderId);
for (OrderItem item : orderItems) {
Product product = productService.getProduct(item.getProductId());
product.increaseStock(item.getQuantity());
}

user.restoreBalance(order.getTotalAmount());

return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList());
}

@Transactional
public OrderInfo approveOrder(String loginId, String rawPassword, Long orderId) {
userService.authenticate(loginId, rawPassword);
Order order = orderService.approveOrder(orderId);
List<OrderItem> items = orderService.getOrderItems(orderId);
return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.loopers.application.order;

import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderStatus;

import java.util.List;

public record OrderInfo(
Long id,
Long userId,
String orderNumber,
OrderStatus status,
Long totalAmount,
List<OrderItemInfo> items
) {
public static OrderInfo from(Order order, List<OrderItemInfo> items) {
return new OrderInfo(
order.getId(),
order.getUserId(),
order.getOrderNumber(),
order.getStatus(),
order.getTotalAmount(),
items
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.application.order;

import com.loopers.domain.order.OrderItem;

public record OrderItemInfo(
Long id,
Long productId,
String productName,
String brandName,
String imageUrl,
Integer price,
Integer quantity
) {
public static OrderItemInfo from(OrderItem item) {
return new OrderItemInfo(
item.getId(),
item.getProductId(),
item.getProductName(),
item.getBrandName(),
item.getImageUrl(),
item.getPrice(),
item.getQuantity()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.loopers.application.order;

import java.util.List;

public record OrderRequest(
List<OrderItemRequest> items
) {
public record OrderItemRequest(
Long productId,
Integer quantity
) {}
}
Loading