π λ°±μλ κ°λ°μ ν΅μ¬ μ°Έκ³ λ¬Έμ κ°μ΄λ
βAPI λͺ μΈμλ β무μμβ μ£Όκ³ λ°μμ§μ λν μ½μμ΄λΌλ©΄, λ°±μλ κ°λ°μλ κ·Έ μ½μμ μ§ν€κΈ° μν΄ βμ΄λ»κ²β λμν΄μΌ νλμ§μ λν ꡬ체μ μΈ μ€κ³λμ μ§μΉ¨μλ₯Ό μ°Έκ³ νμ¬ λ΄λΆ λ‘μ§μ ꡬνν©λλ€.β
λ°±μλ κ°λ°μκ° ν΅μ¬μ μΌλ‘ μ°Έκ³ νλ κ²μ ν¬κ² μΈ κ°μ§μ λλ€.
1. μꡬμ¬ν λͺ
μΈμ (κΈ°νμ, User Story)
2. λ°μ΄ν°λ² μ΄μ€ μ€κ³μ (ERD, μ€ν€λ§ μ μ)
3. μμ€ν
μν€ν
μ² μ€κ³μ
π― κ°μ: λ°±μλ κ°λ°μ μΌκ°ν
π λ°±μλ κ°λ° μ°Έκ³ λ¬Έμμ κ΄κ³λ
// API λͺ
μΈμ (μΈλΆ κ³μ½)
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateRequest request) {
// π μꡬμ¬ν λͺ
μΈμ β λΉμ¦λμ€ λ‘μ§ κ΅¬ν
// πΎ λ°μ΄ν°λ² μ΄μ€ μ€κ³μ β λ°μ΄ν° μ μ₯/μ‘°ν ꡬ쑰
// ποΈ μμ€ν
μν€ν
μ² β μΈλΆ μμ€ν
μ°λ λ°©μ
UserResponse response = userService.createUser(request);
return ResponseEntity.ok(response);
}
}
ν΅μ¬ μ΄ν΄: API λͺ μΈμλ κ²λͺ¨μ΅(Interface) μ΄κ³ , λλ¨Έμ§ μΈ λ¬Έμλ λ΄λΆ ꡬν(Implementation) μ μν μ€κ³λμ λλ€.
π 1. μꡬμ¬ν λͺ μΈμ (κΈ°νμ, User Story)
βκ°μ₯ μ€μνκ³ κ·Όλ³Έμ μΈ μ°Έκ³ μλ£ - λΉμ¦λμ€ λ‘μ§μ λͺ¨λ κ²μ΄ λ΄κΈ΄ 보물μ§λβ
β μꡬμ¬ν 무μν μλͺ»λ ꡬν
// μꡬμ¬νμ μ λλ‘ νμ
νμ§ μκ³ λ¨μνκ² κ΅¬νν μ
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// λ무 λ¨μν ꡬν - λΉμ¦λμ€ λ‘μ§ λλ½
@Transactional
public Order createOrder(OrderCreateRequest request) {
Order order = Order.builder()
.userId(request.getUserId())
.productId(request.getProductId())
.quantity(request.getQuantity())
.status(OrderStatus.CONFIRMED) // 무쑰건 νμ ? λ¬Έμ !
.build();
return orderRepository.save(order);
}
}
π₯ λ¬Έμ μ λ€:
- λΉμ¦λμ€ κ·μΉ λλ½: μ¬κ³ νμΈ, κ²°μ μ²λ¦¬, λ°°μ‘λΉ κ³μ° λ±
- μμΈ μν© λ―Έμ²λ¦¬: νμ , λ―Έμ±λ μ μ£Όλ¬Έ, μκ° μ ν λ±
- λ°μ΄ν° μ ν©μ± λΆμ‘±: μ£Όλ¬Έ μν κ΄λ¦¬, μ΄λ ₯ μΆμ λ±
β μꡬμ¬ν κΈ°λ° μ¬λ°λ₯Έ ꡬν
// μ€μ μꡬμ¬ν λͺ
μΈμ μμ
/**
* [μꡬμ¬ν λͺ
μΈμ λ°μ·]
*
* UC-001: μν μ£Όλ¬Έ μ²λ¦¬
*
* 1. κΈ°λ³Έ κ·μΉ:
* - μ€ν 10μ μ΄ν μ£Όλ¬Έμ λ€μ λ μμΉ¨ λ°°μ‘μΌλ‘ μ²λ¦¬
* - VIP λ±κΈ νμμ μν κ°κ²©μ 5% μΆκ° ν μΈ
* - μ¬κ³ κ° λΆμ‘±ν κ²½μ° μ£Όλ¬Έμ λκΈ° μνλ‘ λ³κ²½
*
* 2. μμΈ μ²λ¦¬:
* - λ―Έμ±λ
μλ μ£Όλ₯ μ£Όλ¬Έ λΆκ° (μ£Όλ¬Έ λ°λ € + μλ΄ λ©μμ§)
* - 1ν μ£Όλ¬Έ μ΅λ μλ: 10κ°
* - νμ μνμ μ£Όλ¬Έ λΆκ°
*
* 3. λ°μ΄ν° μ μ±
:
* - μ£Όλ¬Έ μ·¨μ μμλ μ΄λ ₯μ 7λ
κ° λ³΄κ΄
* - κ°μΈμ 보λ νμ νν΄ μ μ¦μ νκΈ°, μ£Όλ¬Έ λ΄μμ 5λ
보κ΄
*/
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductService productService;
private final UserService userService;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final DiscountCalculator discountCalculator;
private final DeliveryScheduler deliveryScheduler;
private final NotificationService notificationService;
@Transactional
public OrderResult createOrder(OrderCreateRequest request) {
// 1. μ¬μ©μ κ²μ¦
User user = userService.findById(request.getUserId());
validateUserOrderPermission(user, request);
// 2. μν κ²μ¦
Product product = productService.findById(request.getProductId());
validateProductOrderable(product, request);
// 3. μ¬κ³ νμΈ λ° μμ½
InventoryReservation reservation = inventoryService.reserveStock(
request.getProductId(),
request.getQuantity()
);
if (!reservation.isSuccess()) {
// μ¬κ³ λΆμ‘± μ λκΈ° μνλ‘ μ²λ¦¬
return handleOutOfStock(request, user);
}
try {
// 4. ν μΈ κ³μ° (VIP 5% μΆκ° ν μΈ λ±)
DiscountResult discount = discountCalculator.calculate(product, user, request.getQuantity());
// 5. λ°°μ‘ μΌμ κ²°μ (μ€ν 10μ μ΄νλ λ€μλ λ°°μ‘)
DeliverySchedule schedule = deliveryScheduler.scheduleDelivery(LocalDateTime.now());
// 6. μ£Όλ¬Έ μμ±
Order order = Order.builder()
.userId(user.getId())
.productId(product.getId())
.quantity(request.getQuantity())
.originalPrice(product.getPrice() * request.getQuantity())
.discountAmount(discount.getAmount())
.finalPrice(discount.getFinalPrice())
.status(OrderStatus.PENDING_PAYMENT)
.expectedDeliveryDate(schedule.getDeliveryDate())
.reservationId(reservation.getId())
.build();
Order savedOrder = orderRepository.save(order);
// 7. κ²°μ μ²λ¦¬
PaymentResult payment = paymentService.processPayment(
PaymentRequest.builder()
.orderId(savedOrder.getId())
.amount(discount.getFinalPrice())
.paymentMethod(request.getPaymentMethod())
.build()
);
if (payment.isSuccess()) {
savedOrder.confirmPayment(payment.getTransactionId());
orderRepository.save(savedOrder);
// 8. νμ μ²λ¦¬
inventoryService.confirmReservation(reservation.getId());
notificationService.sendOrderConfirmation(user, savedOrder);
return OrderResult.success(savedOrder);
} else {
// κ²°μ μ€ν¨ μ μ¬κ³ μμ½ ν΄μ
inventoryService.releaseReservation(reservation.getId());
return OrderResult.paymentFailed(payment.getErrorMessage());
}
} catch (Exception e) {
// μμΈ λ°μ μ μ¬κ³ μμ½ ν΄μ
inventoryService.releaseReservation(reservation.getId());
throw new OrderProcessingException("μ£Όλ¬Έ μ²λ¦¬ μ€ μ€λ₯κ° λ°μνμ΅λλ€", e);
}
}
// μꡬμ¬ν: λ―Έμ±λ
μ μ£Όλ₯ μ£Όλ¬Έ κ²μ¦
private void validateUserOrderPermission(User user, OrderCreateRequest request) {
Product product = productService.findById(request.getProductId());
if (product.isAlcohol() && user.isMinor()) {
throw new OrderNotAllowedException(
"λ―Έμ±λ
μλ μ£Όλ₯λ₯Ό μ£Όλ¬Έν μ μμ΅λλ€",
OrderErrorCode.MINOR_ALCOHOL_ORDER
);
}
if (request.getQuantity() > 10) {
throw new OrderNotAllowedException(
"1ν μ£Όλ¬Έ μ΅λ μλμ 10κ°μ
λλ€",
OrderErrorCode.EXCEED_MAX_QUANTITY
);
}
}
// μꡬμ¬ν: νμ μν μ£Όλ¬Έ λΆκ°
private void validateProductOrderable(Product product, OrderCreateRequest request) {
if (product.isOutOfStock()) {
throw new ProductNotOrderableException(
"νμ λ μνμ
λλ€",
ProductErrorCode.OUT_OF_STOCK
);
}
if (!product.isActive()) {
throw new ProductNotOrderableException(
"νλ§€κ° μ€λ¨λ μνμ
λλ€",
ProductErrorCode.INACTIVE_PRODUCT
);
}
}
// μꡬμ¬ν: μ¬κ³ λΆμ‘± μ λκΈ° μν μ²λ¦¬
private OrderResult handleOutOfStock(OrderCreateRequest request, User user) {
WaitingOrder waitingOrder = WaitingOrder.builder()
.userId(request.getUserId())
.productId(request.getProductId())
.quantity(request.getQuantity())
.status(WaitingStatus.WAITING_FOR_STOCK)
.createdAt(LocalDateTime.now())
.build();
waitingOrderRepository.save(waitingOrder);
// μ¬κ³ μλ¦Ό μ μ²
notificationService.subscribeStockNotification(user.getEmail(), request.getProductId());
return OrderResult.waitingForStock(waitingOrder);
}
}
// VIP 5% μΆκ° ν μΈ μ μ±
ꡬν
@Component
public class DiscountCalculator {
public DiscountResult calculate(Product product, User user, int quantity) {
int originalPrice = product.getPrice() * quantity;
int discountAmount = 0;
// κΈ°λ³Έ ν μΈ
if (originalPrice >= 50000) {
discountAmount += (int) (originalPrice * 0.1); // 10% κΈ°λ³Έ ν μΈ
}
// VIP μΆκ° ν μΈ (μꡬμ¬ν λ°μ)
if (user.isVip()) {
discountAmount += (int) (originalPrice * 0.05); // 5% μΆκ° ν μΈ
}
// μλ ν μΈ
if (quantity >= 5) {
discountAmount += (int) (originalPrice * 0.03); // 3% μλ ν μΈ
}
int finalPrice = originalPrice - discountAmount;
return DiscountResult.builder()
.originalPrice(originalPrice)
.discountAmount(discountAmount)
.finalPrice(finalPrice)
.appliedRules(buildAppliedRules(user, quantity, originalPrice))
.build();
}
}
// μ€ν 10μ μ΄ν λ€μλ λ°°μ‘ κ·μΉ ꡬν
@Component
public class DeliveryScheduler {
private static final int CUTOFF_HOUR = 22; // μ€ν 10μ
public DeliverySchedule scheduleDelivery(LocalDateTime orderTime) {
LocalDate deliveryDate;
// μꡬμ¬ν: μ€ν 10μ μ΄νλ λ€μλ λ°°μ‘
if (orderTime.getHour() >= CUTOFF_HOUR) {
deliveryDate = orderTime.toLocalDate().plusDays(2); // λ€μλ λ°°μ‘
} else {
deliveryDate = orderTime.toLocalDate().plusDays(1); // λΉμΌ λ°°μ‘
}
// μ£Όλ§/곡ν΄μΌ μ²λ¦¬
while (isWeekendOrHoliday(deliveryDate)) {
deliveryDate = deliveryDate.plusDays(1);
}
return DeliverySchedule.builder()
.deliveryDate(deliveryDate)
.cutoffTime(orderTime.toLocalDate().atTime(CUTOFF_HOUR, 0))
.isNextDayDelivery(orderTime.getHour() < CUTOFF_HOUR)
.build();
}
}
π μꡬμ¬ν λͺ μΈμ νμ© ν¨κ³Ό:
- μ νν λΉμ¦λμ€ λ‘μ§: λͺ¨λ μ 무 κ·μΉμ΄ μ½λμ λ°μ
- μμΈ μν© μλ²½ λμ: λ―Έμ±λ μ μ£Όλ¬Έ, μ¬κ³ λΆμ‘± λ± λͺ¨λ μΌμ΄μ€ μ²λ¦¬
- λ°μ΄ν° μ ν©μ± 보μ₯: μ£Όλ¬Έ μν κ΄λ¦¬, μ΄λ ₯ μΆμ μλ²½ ꡬν
- μ¬μ©μ κ²½ν ν₯μ: λͺ νν μλ¬ λ©μμ§μ λμ μ μ
πΎ 2. λ°μ΄ν°λ² μ΄μ€ μ€κ³μ (ERD, μ€ν€λ§ μ μ)
βλ°μ΄ν°μ μ²μ¬μ§ - μν°ν° κ΄κ³μ μ μ½ μ‘°κ±΄μ΄ μ½λ ꡬ쑰λ₯Ό κ²°μ νλ€β
β ERDλ₯Ό 무μν μλͺ»λ ꡬν
// ERDλ₯Ό μ λλ‘ λΆμνμ§ μκ³ κ΅¬νν λ¬Έμ μ½λ
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// μλͺ»λ μ°κ΄κ΄κ³ - ERD 무μ
private Long userId; // μΈλν€λ₯Ό λ¨μ μ«μλ‘ μ²λ¦¬
private Long productId; // μ°κ΄κ΄κ³ λ§€ν λλ½
private String productName; // λΉμ κ·ν - λ°μ΄ν° μ€λ³΅
private int price; // Product ν
μ΄λΈμ μ΄λ―Έ μλ λ°μ΄ν° μ€λ³΅
// μ μ½ μ‘°κ±΄ 무μ
private int quantity; // μ΅μκ° μ μ½ μμ
private String status; // Enum μ¬μ©νμ§ μμ
}
@Service
public class OrderService {
public Order createOrder(OrderCreateRequest request) {
// λΉν¨μ¨μ μΈ λ°μ΄ν° μ‘°ν - N+1 λ¬Έμ λ°μ
Order order = new Order();
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
// λ³λ μ‘°νλ‘ λ°μ΄ν° μ€λ³΅ μ μ₯
Product product = productService.findById(request.getProductId());
order.setProductName(product.getName()); // λ°μ΄ν° μ€λ³΅!
order.setPrice(product.getPrice()); // λ°μ΄ν° μ€λ³΅!
return orderRepository.save(order);
}
// λΉν¨μ¨μ μΈ μ£Όλ¬Έ μ‘°ν
public OrderDetailResponse getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId);
// N+1 λ¬Έμ - λ§€λ² λ³λ 쿼리
User user = userService.findById(order.getUserId());
Product product = productService.findById(order.getProductId());
return OrderDetailResponse.builder()
.order(order)
.user(user)
.product(product)
.build();
}
}
π₯ λ¬Έμ μ λ€:
- λ°μ΄ν° μ€λ³΅: Product μ 보λ₯Ό Orderμ μ€λ³΅ μ μ₯
- μ°κ΄κ΄κ³ λλ½: JPA μ°κ΄κ΄κ³ λ§€ν λ―Έμ¬μ©μΌλ‘ N+1 λ¬Έμ
- μ μ½ μ‘°κ±΄ 무μ: λ°μ΄ν° λ¬΄κ²°μ± λ³΄μ₯ λΆκ°
- μ±λ₯ μ ν: λΉν¨μ¨μ μΈ μΏΌλ¦¬λ‘ μ±λ₯ λ¬Έμ
β ERD κΈ°λ° μ¬λ°λ₯Έ ꡬν
// ERD μ€κ³μ μμ:
// User (1) ββ (N) Order (N) ββ (1) Product
// Order (1) ββ (N) OrderHistory
// User (1) ββ (N) Review ββ (1) Product
// 1. ERD κΈ°λ° μν°ν° μ€κ³
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true) // ERD μ μ½ μ‘°κ±΄ λ°μ
private String email;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
private UserGrade grade; // VIP, REGULAR, BRONZE
@Column(nullable = false)
private LocalDate birthDate;
// ERD κ΄κ³ μ μμ λ°λ₯Έ μ°κ΄κ΄κ³ λ§€ν
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
// λΉμ¦λμ€ λ©μλ - μꡬμ¬ν λ°μ
public boolean isMinor() {
return Period.between(birthDate, LocalDate.now()).getYears() < 19;
}
public boolean isVip() {
return UserGrade.VIP.equals(grade);
}
}
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int price;
@Column(nullable = false)
private int stockQuantity;
@Enumerated(EnumType.STRING)
private ProductCategory category;
@Column(nullable = false)
private boolean isActive;
// ERD κ΄κ³ μ μμ λ°λ₯Έ μ°κ΄κ΄κ³ λ§€ν
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
// λΉμ¦λμ€ λ©μλ
public boolean isAlcohol() {
return ProductCategory.ALCOHOL.equals(category);
}
public boolean isOutOfStock() {
return stockQuantity <= 0;
}
public boolean canOrder(int requestQuantity) {
return isActive && stockQuantity >= requestQuantity;
}
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ERD μΈλν€ κ΄κ³λ₯Ό JPA μ°κ΄κ΄κ³λ‘ μ νν λ§€ν
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
// ERD μ μ½ μ‘°κ±΄ λ°μ
@Column(nullable = false)
@Min(value = 1, message = "μ£Όλ¬Έ μλμ 1κ° μ΄μμ΄μ΄μΌ ν©λλ€")
@Max(value = 10, message = "1ν μ£Όλ¬Έ μ΅λ μλμ 10κ°μ
λλ€")
private int quantity;
@Column(nullable = false)
private int originalPrice;
@Column(nullable = false)
private int discountAmount;
@Column(nullable = false)
private int finalPrice;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@Column(nullable = false)
private LocalDateTime orderDate;
private LocalDate expectedDeliveryDate;
private String paymentTransactionId;
// ERD κ΄κ³ μ μμ λ°λ₯Έ μ΄λ ₯ κ΄λ¦¬
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderHistory> histories = new ArrayList<>();
// λΉμ¦λμ€ λ©μλ
public void confirmPayment(String transactionId) {
this.paymentTransactionId = transactionId;
this.status = OrderStatus.CONFIRMED;
addHistory(OrderStatus.CONFIRMED, "κ²°μ μλ£");
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED) {
throw new OrderCancelNotAllowedException("λ°°μ‘ μ€μΈ μ£Όλ¬Έμ μ·¨μν μ μμ΅λλ€");
}
this.status = OrderStatus.CANCELLED;
addHistory(OrderStatus.CANCELLED, reason);
}
private void addHistory(OrderStatus status, String memo) {
OrderHistory history = OrderHistory.builder()
.order(this)
.status(status)
.memo(memo)
.createdAt(LocalDateTime.now())
.build();
histories.add(history);
}
}
// ERD μ΄λ ₯ κ΄λ¦¬ μꡬμ¬ν λ°μ
@Entity
@Table(name = "order_histories")
public class OrderHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
private String memo;
@Column(nullable = false)
private LocalDateTime createdAt;
}
// 2. ERD κ΄κ³λ₯Ό νμ©ν ν¨μ¨μ μΈ Repository
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// ERD κ΄κ³λ₯Ό νμ©ν Fetch Join - N+1 λ¬Έμ ν΄κ²°
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.user " +
"JOIN FETCH o.product " +
"WHERE o.id = :orderId")
Optional<Order> findByIdWithUserAndProduct(@Param("orderId") Long orderId);
// ERD μΈλ±μ€ νμ©ν ν¨μ¨μ μ‘°ν
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.user " +
"WHERE o.user.id = :userId " +
"AND o.orderDate BETWEEN :startDate AND :endDate " +
"ORDER BY o.orderDate DESC")
List<Order> findUserOrdersBetween(
@Param("userId") Long userId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
// ERD μ§κ³ ν¨μ νμ©
@Query("SELECT COUNT(o) FROM Order o " +
"WHERE o.product.id = :productId " +
"AND o.status = :status")
int countByProductAndStatus(
@Param("productId") Long productId,
@Param("status") OrderStatus status
);
}
// 3. ERD κΈ°λ° μλΉμ€ ꡬν
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// ERD κ΄κ³λ₯Ό νμ©ν ν¨μ¨μ μ‘°ν
@Transactional(readOnly = true)
public OrderDetailResponse getOrderDetail(Long orderId) {
// ν λ²μ μΏΌλ¦¬λ‘ λͺ¨λ κ΄λ ¨ λ°μ΄ν° μ‘°ν
Order order = orderRepository.findByIdWithUserAndProduct(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
return OrderDetailResponse.builder()
.orderId(order.getId())
.userName(order.getUser().getName())
.userEmail(order.getUser().getEmail())
.productName(order.getProduct().getName())
.productPrice(order.getProduct().getPrice())
.quantity(order.getQuantity())
.finalPrice(order.getFinalPrice())
.status(order.getStatus())
.orderDate(order.getOrderDate())
.histories(order.getHistories())
.build();
}
// ERD μ μ½ μ‘°κ±΄μ νμ©ν λ°μ΄ν° κ²μ¦
@Transactional
public Order createOrder(OrderCreateRequest request) {
// ERD μΈλν€ μ μ½ μ‘°κ±΄ νμ©
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException());
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new ProductNotFoundException());
// ERD μ²΄ν¬ μ μ½ μ‘°κ±΄ λ°μ
validateOrderConstraints(request, user, product);
Order order = Order.builder()
.user(user) // μν°ν° μ°κ΄κ΄κ³ νμ©
.product(product) // μν°ν° μ°κ΄κ΄κ³ νμ©
.quantity(request.getQuantity())
.originalPrice(product.getPrice() * request.getQuantity())
.status(OrderStatus.PENDING_PAYMENT)
.orderDate(LocalDateTime.now())
.build();
return orderRepository.save(order);
}
}
π ERD νμ© ν¨κ³Ό:
- λ°μ΄ν° μ ν©μ±: μΈλν€μ μ μ½ μ‘°κ±΄μΌλ‘ μλͺ»λ λ°μ΄ν° λ°©μ§
- μ±λ₯ μ΅μ ν: Fetch JoinμΌλ‘ N+1 λ¬Έμ ν΄κ²°
- μ μ§λ³΄μμ±: λͺ νν μν°ν° κ΄κ³λ‘ μ΄ν΄νκΈ° μ¬μ΄ μ½λ
- νμ₯μ±: μλ‘μ΄ μν°ν° μΆκ° μ κΈ°μ‘΄ κ΄κ³ μ μ§
ποΈ 3. μμ€ν μν€ν μ² μ€κ³μ
βμΈλΆ μΈκ³μμ μν΅ λ°©μ - μμ€ν κ° μνΈμμ©μ μ€κ³λβ
β μν€ν μ² λ¬΄μν μλͺ»λ ꡬν
// μν€ν
μ² μ€κ³ μμ΄ λ¬΄μμ ꡬνν λ¬Έμ μ½λ
@Service
public class PaymentService {
public PaymentResult processPayment(PaymentRequest request) {
// νλμ½λ©λ μΈλΆ API νΈμΆ - μ€κ³μ 무μ
try {
// μΉ΄μΉ΄μ€νμ΄ APIλ₯Ό μ§μ νΈμΆ
String url = "https://kapi.kakao.com/v1/payment/ready";
String response = restTemplate.postForObject(url, request, String.class);
// μλ΅ νμ±λ νλμ½λ©
if (response.contains("SUCCESS")) {
return new PaymentResult(true, "κ²°μ μ±κ³΅");
} else {
return new PaymentResult(false, "κ²°μ μ€ν¨");
}
} catch (Exception e) {
// μλ¬ μ²λ¦¬λ λμΆ©
return new PaymentResult(false, "μμ€ν
μ€λ₯");
}
}
// μ΄λ―Έμ§ μ
λ‘λλ μν€ν
μ² κ³ λ € μμ΄ λ‘컬μ μ μ₯
public String uploadProductImage(MultipartFile file) {
String uploadDir = "/var/uploads/"; // νλμ½λ©
String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename();
try {
file.transferTo(new File(uploadDir + fileName));
return "/images/" + fileName;
} catch (IOException e) {
throw new FileUploadException("νμΌ μ
λ‘λ μ€ν¨");
}
}
}
π₯ λ¬Έμ μ λ€:
- νλμ½λ©λ μ°λ: URL, μ€μ κ°μ΄ μ½λμ λ°νμμ
- μλ¬ μ²λ¦¬ λΆμ‘±: μΈλΆ μμ€ν μ₯μ μν© λ―Έκ³ λ €
- νμ₯μ± λΆμ‘±: λ€λ₯Έ PGμ¬ μΆκ° μ μ 체 μ½λ μμ νμ
- 보μ μ·¨μ½: API ν€, μΈμ¦ μ 보 νλμ½λ©
β μν€ν μ² μ€κ³μ κΈ°λ° μ¬λ°λ₯Έ ꡬν
// μν€ν
μ² μ€κ³μ μμ:
/**
* [μμ€ν
μν€ν
μ² μ€κ³μ λ°μ·]
*
* 1. μΈλΆ μμ€ν
μ°λ:
* - κ²°μ : μΉ΄μΉ΄μ€νμ΄ API β ν₯ν ν μ€νμ΄λ¨ΌμΈ μΆκ° μμ
* - νμΌ μ μ₯: AWS S3 β CDN μ°λ
* - λ©μμ§ ν: Kafka β λΉλκΈ° μ²λ¦¬
*
* 2. 보μ μ μ±
:
* - API ν€λ νκ²½λ³μ λλ AWS Secrets Manager μ¬μ©
* - λͺ¨λ μΈλΆ API νΈμΆμ Circuit Breaker ν¨ν΄ μ μ©
* - κ°μΈμ 보λ AES-256 μνΈν
*
* 3. μ±λ₯ μꡬμ¬ν:
* - κ²°μ API μλ΅μκ°: 3μ΄ μ΄λ΄
* - νμΌ μ
λ‘λ: 10MB μ΄ν, 5μ΄ μ΄λ΄
* - λμ©λ μ²λ¦¬: λ©μμ§ ν νμ©ν λΉλκΈ° μ²λ¦¬
*/
// 1. μΈλΆ κ²°μ μμ€ν
μ°λ - μν€ν
μ² μ€κ³ λ°μ
@Configuration
@ConfigurationProperties(prefix = "payment.kakao")
@Data
public class KakaoPayConfig {
private String apiUrl;
private String secretKey;
private int timeoutSeconds;
private int retryCount;
}
// Circuit Breaker ν¨ν΄ μ μ© (μν€ν
μ² μ€κ³μ 보μ μ μ±
)
@Component
@RequiredArgsConstructor
public class KakaoPayGateway {
private final KakaoPayConfig config;
private final RestTemplate restTemplate;
private final CircuitBreaker circuitBreaker;
public PaymentResult processPayment(PaymentRequest request) {
return circuitBreaker.executeSupplier(() -> {
try {
// μ€μ κΈ°λ° API νΈμΆ
HttpHeaders headers = createHeaders();
HttpEntity<KakaoPayRequest> entity = new HttpEntity<>(
convertToKakaoPayRequest(request),
headers
);
ResponseEntity<KakaoPayResponse> response = restTemplate.exchange(
config.getApiUrl() + "/payment/ready",
HttpMethod.POST,
entity,
KakaoPayResponse.class
);
return convertToPaymentResult(response.getBody());
} catch (HttpClientErrorException e) {
// μν€ν
μ² μ€κ³μμ μλ¬ μ²λ¦¬ μ μ±
handleClientError(e);
throw new PaymentClientException("κ²°μ μμ² μ€λ₯: " + e.getMessage());
} catch (HttpServerErrorException e) {
handleServerError(e);
throw new PaymentServerException("κ²°μ μλ² μ€λ₯: " + e.getMessage());
} catch (ResourceAccessException e) {
throw new PaymentTimeoutException("κ²°μ μμ€ν
μλ΅ μ§μ°");
}
});
}
private HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getSecretKey()); // νκ²½λ³μμμ λ‘λ
return headers;
}
}
// 2. νμΌ μ μ₯ - AWS S3 μ°λ (μν€ν
μ² μ€κ³)
@Configuration
@ConfigurationProperties(prefix = "aws.s3")
@Data
public class S3Config {
private String bucketName;
private String region;
private String accessKey;
private String secretKey;
private String cdnUrl;
}
@Component
@RequiredArgsConstructor
public class S3FileUploader {
private final S3Config s3Config;
private final AmazonS3 s3Client;
public FileUploadResult uploadFile(MultipartFile file, String directory) {
// μν€ν
μ² μ€κ³μμ νμΌ μ μ±
λ°μ
validateFile(file);
try {
String fileName = generateFileName(file.getOriginalFilename());
String s3Key = directory + "/" + fileName;
// S3 μ
λ‘λ
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
s3Client.putObject(
s3Config.getBucketName(),
s3Key,
file.getInputStream(),
metadata
);
// CDN URL λ°ν (μν€ν
μ² μ€κ³μ λ°μ)
String fileUrl = s3Config.getCdnUrl() + "/" + s3Key;
return FileUploadResult.builder()
.originalFileName(file.getOriginalFilename())
.storedFileName(fileName)
.fileUrl(fileUrl)
.fileSize(file.getSize())
.uploadedAt(LocalDateTime.now())
.build();
} catch (IOException e) {
throw new FileUploadException("νμΌ μ
λ‘λ μ€ μ€λ₯κ° λ°μνμ΅λλ€", e);
}
}
// μν€ν
μ² μ€κ³μμ νμΌ μ μ±
λ°μ
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new InvalidFileException("λΉ νμΌμ μ
λ‘λν μ μμ΅λλ€");
}
if (file.getSize() > 10 * 1024 * 1024) { // 10MB μ ν
throw new FileSizeExceededException("νμΌ ν¬κΈ°λ 10MBλ₯Ό μ΄κ³Όν μ μμ΅λλ€");
}
String contentType = file.getContentType();
if (!isAllowedContentType(contentType)) {
throw new UnsupportedFileTypeException("μ§μνμ§ μλ νμΌ νμμ
λλ€");
}
}
}
// 3. λμ©λ μ²λ¦¬ - Kafka λ©μμ§ ν (μν€ν
μ² μ€κ³)
@Component
@RequiredArgsConstructor
public class OrderEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Async
public void publishOrderCreated(Order order) {
OrderCreatedEvent event = OrderCreatedEvent.builder()
.orderId(order.getId())
.userId(order.getUser().getId())
.productId(order.getProduct().getId())
.amount(order.getFinalPrice())
.orderDate(order.getOrderDate())
.build();
// μν€ν
μ² μ€κ³μμ λ©μμ§ ν ν ν½ λ°μ
kafkaTemplate.send("order-events", event);
}
}
@KafkaListener(topics = "order-events", groupId = "email-service")
@Component
@RequiredArgsConstructor
public class OrderEmailHandler {
private final EmailService emailService;
// λΉλκΈ° μ΄λ©μΌ λ°μ‘ - μν€ν
μ² μ€κ³μ λ°μ
public void handleOrderCreated(OrderCreatedEvent event) {
try {
User user = userService.findById(event.getUserId());
Product product = productService.findById(event.getProductId());
EmailMessage message = EmailMessage.builder()
.to(user.getEmail())
.subject("μ£Όλ¬Έμ΄ μλ£λμμ΅λλ€")
.template("order-confirmation")
.templateData(Map.of(
"userName", user.getName(),
"productName", product.getName(),
"amount", event.getAmount()
))
.build();
emailService.sendEmail(message);
} catch (Exception e) {
// λ©μμ§ ν μ¬μ²λ¦¬λ₯Ό μν μμΈ μ²λ¦¬
log.error("μ£Όλ¬Έ μ΄λ©μΌ λ°μ‘ μ€ν¨: orderId={}", event.getOrderId(), e);
throw new EmailSendException("μ΄λ©μΌ λ°μ‘μ μ€ν¨νμ΅λλ€", e);
}
}
}
// 4. 보μ μ μ±
ꡬν - Spring Security (μν€ν
μ² μ€κ³μ)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/admin/**").hasRole("ADMIN") // κ΄λ¦¬μ API μ ν
.requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
// 5. μ€μ μ€μ¬ κ΅¬μ± - μν€ν
μ² μ μ°μ± ν보
@Configuration
public class ExternalSystemConfig {
@Bean
@ConditionalOnProperty(name = "payment.provider", havingValue = "kakao")
public PaymentGateway kakaoPayGateway() {
return new KakaoPayGateway();
}
@Bean
@ConditionalOnProperty(name = "payment.provider", havingValue = "toss")
public PaymentGateway tossPayGateway() {
return new TossPayGateway();
}
@Bean
@ConditionalOnProperty(name = "file.storage", havingValue = "s3")
public FileUploader s3FileUploader() {
return new S3FileUploader();
}
@Bean
@ConditionalOnProperty(name = "file.storage", havingValue = "local")
public FileUploader localFileUploader() {
return new LocalFileUploader();
}
}
π μν€ν μ² μ€κ³μ νμ© ν¨κ³Ό:
- μ μ°ν μμ€ν : μ€μ λ³κ²½λ§μΌλ‘ λ€λ₯Έ μΈλΆ μμ€ν μ°λ κ°λ₯
- μμ μ±: Circuit Breaker, μ¬μλ μ μ± μΌλ‘ μ₯μ μν© λμ
- 보μ κ°ν: μΈμ¦/μΈκ°, λ°μ΄ν° μνΈν 체κ³μ μ μ©
- μ±λ₯ μ΅μ ν: λΉλκΈ° μ²λ¦¬, μΊμ± μ λ΅ λ°μ
βοΈ μΈμ μ΄λ€ λ¬Έμλ₯Ό μ€μ μ μΌλ‘ λ΄μΌ ν κΉ?
π’ νλ‘μ νΈ μ΄κΈ° λ¨κ³ (1-2μ£Όμ°¨)
πΎ λ°μ΄ν°λ² μ΄μ€ μ€κ³μ μ°μ λΆμ
// λ¨Όμ ERDλ₯Ό λΆμνμ¬ λλ©μΈ λͺ¨λΈ νμ
@Entity
public class User {
// ERD λΆμ β μν°ν° μ€κ³ β JPA λ§€ν
}
@Entity
public class Order {
// κ΄κ³ μ μ β μ°κ΄κ΄κ³ λ§€ν β Repository μ€κ³
}
// ERD κΈ°λ°μΌλ‘ κΈ°λ³Έ CRUD λ¨Όμ ꡬν
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ERD μΈλ±μ€ κΈ°λ° μΏΌλ¦¬ λ©μλ
}
π‘ κ°λ° μ§ν λ¨κ³ (3-6μ£Όμ°¨)
π μꡬμ¬ν λͺ μΈμ μ€μ¬ ꡬν
// μꡬμ¬ν νλμ© Service κ³μΈ΅μ ꡬν
@Service
public class OrderService {
// μꡬμ¬ν: "VIP νμ 5% μΆκ° ν μΈ"
public DiscountResult calculateVipDiscount(User user, int amount) {
if (user.isVip()) {
return DiscountResult.of(amount * 0.05);
}
return DiscountResult.empty();
}
// μꡬμ¬ν: "μ€ν 10μ μ΄νλ λ€μλ λ°°μ‘"
public DeliveryDate calculateDeliveryDate(LocalDateTime orderTime) {
if (orderTime.getHour() >= 22) {
return DeliveryDate.nextDay(orderTime.toLocalDate().plusDays(1));
}
return DeliveryDate.sameDay(orderTime.toLocalDate());
}
}
π μμ€ν ν΅ν© λ¨κ³ (7-8μ£Όμ°¨)
ποΈ μν€ν μ² μ€κ³μ μ€μ¬ ꡬν
// μΈλΆ μμ€ν
μ°λ λ° μΈνλΌ κ΅¬μ±
@Configuration
public class ExternalIntegrationConfig {
// κ²°μ μμ€ν
μ°λ
@Bean
public PaymentGateway paymentGateway() {
return PaymentGatewayFactory.create(paymentConfig);
}
// νμΌ μ€ν λ¦¬μ§ μ°λ
@Bean
public FileStorage fileStorage() {
return FileStorageFactory.create(storageConfig);
}
// λ©μμ§ ν μ€μ
@Bean
public MessageProducer messageProducer() {
return new KafkaMessageProducer(kafkaConfig);
}
}
π¨ μμ£Ό νλ μ€μλ€κ³Ό ν΄κ²°μ±
β μ€μ 1: λ¬Έμ κ° λΆμΌμΉ 무μ
// API λͺ
μΈμμλ κ°λ¨ν΄ 보μ΄μ§λ§...
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderCreateRequest request) {
// λ¨μν΄ λ³΄μ΄λ API
}
// μ€μ μꡬμ¬νμ 볡μ‘ν¨μ κ°κ³Ό
@Service
public class OrderService {
public Order createOrder(OrderCreateRequest request) {
// μ¬κ³ νμΈμ?
// ν μΈ κ³μ°μ?
// λ°°μ‘λΉλ?
// κ²°μ μ²λ¦¬λ?
// β μꡬμ¬ν λͺ
μΈμλ₯Ό μ λλ‘ μ λ΄€λ€!
return orderRepository.save(new Order());
}
}
β ν΄κ²°μ± : λ¬Έμ κ° μΌκ΄μ± νμΈ
// API λͺ
μΈμ + μꡬμ¬ν + ERD + μν€ν
μ²λ₯Ό λͺ¨λ κ³ λ €ν ꡬν
@Service
@RequiredArgsConstructor
public class OrderService {
// ERD β Repository μ°κ΄κ΄κ³
private final OrderRepository orderRepository;
private final UserRepository userRepository;
// μν€ν
μ² β μΈλΆ μμ€ν
μ°λ
private final PaymentGateway paymentGateway;
private final InventoryService inventoryService;
// μꡬμ¬ν β λΉμ¦λμ€ λ‘μ§ κ΅¬ν
private final DiscountCalculator discountCalculator;
private final DeliveryScheduler deliveryScheduler;
@Transactional
public OrderResult createOrder(OrderCreateRequest request) {
// λ¬Έμ κΈ°λ° μ²΄κ³μ ꡬν
// 1. ERD κΈ°λ° λ°μ΄ν° μ‘°ν
// 2. μꡬμ¬ν κΈ°λ° λΉμ¦λμ€ λ‘μ§
// 3. μν€ν
μ² κΈ°λ° μΈλΆ μ°λ
}
}
β μ€μ 2: ERDμ JPA λ§€ν λΆμΌμΉ
// ERDμμλ Userμ Orderκ° 1:N κ΄κ³μΈλ°...
@Entity
public class Order {
private Long userId; // λ¨μ μΈλν€ - μ°κ΄κ΄κ³ λ§€ν λλ½!
}
// λΉν¨μ¨μ μΈ μ‘°ν λ°μ
public OrderDetailResponse getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId);
User user = userService.findById(order.getUserId()); // N+1 λ¬Έμ !
}
β ν΄κ²°μ± : ERD μΆ©μ€ν λ§€ν
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // ERD κ΄κ³ μ νν λ§€ν
}
// ERD κΈ°λ° ν¨μ¨μ μ‘°ν
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :orderId")
Optional<Order> findByIdWithUser(@Param("orderId") Long orderId);
β μ€μ 3: μν€ν μ² μ€κ³ 무μν νλμ½λ©
// νλμ½λ©λ μΈλΆ μμ€ν
μ°λ
@Service
public class PaymentService {
public void processPayment() {
String url = "https://api.kakaopay.com"; // νλμ½λ©!
String apiKey = "12345"; // 보μ μ·¨μ½!
// ...
}
}
β ν΄κ²°μ± : μ€μ κΈ°λ° μ μ°ν ꡬ쑰
@Configuration
@ConfigurationProperties(prefix = "external")
public class ExternalSystemConfig {
private PaymentConfig payment;
private StorageConfig storage;
private NotificationConfig notification;
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentConfig config; // μ€μ μ£Όμ
private final PaymentGateway gateway; // μΆμνλ κ²μ΄νΈμ¨μ΄
}
π μ€λ¬΄ μ μ© λ‘λλ§΅
πββοΈ 1λ¨κ³: λ¬Έμ μ½κΈ° νλ ¨ (1μ£Ό)
π 체ν¬λ¦¬μ€νΈ λ§λ€κΈ°
/**
* νλ‘μ νΈ μμ μ νμ 체ν¬λ¦¬μ€νΈ
*
* β‘ μꡬμ¬ν λͺ
μΈμ
* - λΉμ¦λμ€ κ·μΉ λͺ¨λ νμ
νλκ°?
* - μμΈ μν© μ²λ¦¬ λ°©μ μ΄ν΄νλκ°?
* - λ°μ΄ν° μλͺ
μ£ΌκΈ° μ μ±
νμΈνλκ°?
*
* β‘ ERD μ€κ³μ
* - μν°ν° κ° κ΄κ³ μ νν μ΄ν΄νλκ°?
* - μ μ½ μ‘°κ±΄κ³Ό μΈλ±μ€ νμ
νλκ°?
* - μ±λ₯ κ³ λ €μ¬ν νμΈνλκ°?
*
* β‘ μν€ν
μ² μ€κ³μ
* - μΈλΆ μμ€ν
μ°λ λ°©μ μ΄ν΄νλκ°?
* - 보μ μ μ±
κ³Ό μΈμ¦ λ°©μ νμ
νλκ°?
* - μ±λ₯ μꡬμ¬νκ³Ό μ μ½μ¬ν νμΈνλκ°?
*/
πΆββοΈ 2λ¨κ³: λ¨κ³λ³ ꡬν (2-3μ£Ό)
Phase 1: ERD κΈ°λ° λλ©μΈ λͺ¨λΈλ§
// 1μ£Όμ°¨: ERD β JPA Entity λ§€ν
@Entity
public class User { }
@Entity
public class Order { }
@Repository
public interface OrderRepository { }
Phase 2: μꡬμ¬ν κΈ°λ° λΉμ¦λμ€ λ‘μ§
// 2μ£Όμ°¨: μꡬμ¬ν β Service κ³μΈ΅ ꡬν
@Service
public class OrderService {
// λͺ¨λ λΉμ¦λμ€ κ·μΉ ꡬν
}
Phase 3: μν€ν μ² κΈ°λ° μμ€ν μ°λ
// 3μ£Όμ°¨: μν€ν
μ² β μΈλΆ μμ€ν
μ°λ
@Component
public class PaymentGateway { }
@Component
public class FileUploader { }
πββοΈ 3λ¨κ³: ν΅ν© λ° μ΅μ ν (1-2μ£Ό)
μ 체 νλ‘μ° ν΅ν© ν μ€νΈ
@SpringBootTest
@TestPropertySource(properties = {
"payment.provider=kakao",
"file.storage=s3",
"message.queue=kafka"
})
class OrderIntegrationTest {
@Test
void μ£Όλ¬Έ_μ 체_νλ‘μ°_ν
μ€νΈ() {
// Given: μ¬μ©μ, μν, μ¬κ³ μ€λΉ
// When: μ£Όλ¬Έ μμ± API νΈμΆ
// Then: μꡬμ¬νμ λ°λ₯Έ λͺ¨λ λ‘μ§ κ²μ¦
// - μ¬κ³ μ°¨κ°
// - κ²°μ μ²λ¦¬
// - λ°°μ‘ μΌμ μμ±
// - μ΄λ©μΌ λ°μ‘
// - μ΄λ ₯ κΈ°λ‘
}
}
π‘ ν λ¨μ μ μ© μ λ΅
π₯ μμ ν (2-3λͺ )
// ν΅μ¬ λ¬Έμμλ§ μ§μ€
@Service
@RequiredArgsConstructor
public class OrderService {
// μꡬμ¬ν λͺ
μΈμ β ν΅μ¬ λΉμ¦λμ€ λ‘μ§λ§ μ§μ€ ꡬν
// ERD β κΈ°λ³Έ μ°κ΄κ΄κ³λ§ μ νν λ§€ν
// μν€ν
μ² β μ£Όμ μΈλΆ μ°λλ§ μ€μ κΈ°λ° κ΅¬ν
}
π₯ μ€κ° ν (4-6λͺ )
// λͺ¨λλ³ λ΄λΉμ μ§μ , λ¬Έμλ³ μ λ¬Έκ° μμ±
// μꡬμ¬ν μ λ¬Έκ°: λΉμ¦λμ€ λ‘μ§ λ΄λΉ
// ERD μ λ¬Έκ°: λ°μ΄ν° λͺ¨λΈλ§ λ΄λΉ
// μν€ν
μ² μ λ¬Έκ°: μΈνλΌ/μ°λ λ΄λΉ
@Service
public class OrderDomainService {
// κ° λλ©μΈλ³λ‘ μ λ¬Έμ± ν보
}
π₯ ν° ν (7λͺ μ΄μ)
// μμ ν λ¬Έμ κΈ°λ° κ°λ° νλ‘μΈμ€
// 1. λ¬Έμ 리뷰 β 2. μ€κ³ κ²ν β 3. ꡬν β 4. λ¬Έμ κΈ°λ° ν
μ€νΈ
@DomainService
public class OrderDomainService {
// λͺ¨λ λ¬Έμμ λ΄μ©μ΄ μλ²½νκ² λ°μλ ꡬν
}
π μ±κ³΅ μ§νμ μΈ‘μ
π μ λμ μ§ν
// 1. μꡬμ¬ν λ°μλ₯
// Before: κΈ°λ₯ ꡬνλ₯ 60% (ν΅μ¬ λΉμ¦λμ€ λ‘μ§ λλ½)
// After: κΈ°λ₯ ꡬνλ₯ 95% (λͺ¨λ μꡬμ¬ν 체κ³μ λ°μ)
// 2. λ°μ΄ν° 무결μ±
// Before: λ°μ΄ν° μ€λ₯ μ 10건 μ΄μ
// After: ERD κΈ°λ° μ μ½ μ‘°κ±΄μΌλ‘ λ°μ΄ν° μ€λ₯ 0건
// 3. μΈλΆ μ°λ μμ μ±
// Before: μΈλΆ API μ₯μ μ μ 체 μμ€ν
λ€μ΄
// After: Circuit Breakerλ‘ 99.9% κ°μ©μ± ν보
π μ μ±μ μ§ν
- κ°λ° μλ: λ¬Έμ κΈ°λ° μ²΄κ³μ κ°λ°λ‘ μ¬μμ μκ° λ¨μΆ
- λ²κ·Έ κ°μ: μꡬμ¬ν λλ½μΌλ‘ μΈν λ²κ·Έ 90% κ°μ
- ν νμ : κ³΅ν΅ λ¬Έμ κΈ°λ°μΌλ‘ μμ¬μν΅ λͺ νν
πͺ ν΅μ¬ μμΉ μ 리
π μ±κ³΅νλ λ°±μλ κ°λ°μμ λ¬Έμ νμ© λ§μΈλμ
βμ½λ©νκΈ° μ μ λ¬ΈμλΆν° - κΈν μλ‘ μ€κ³λΆν°β
3κ°μ§ μ€μ² μμΉ
- π μꡬμ¬ν μ°μ : βμ΄ κΈ°λ₯μ΄ μ νμνμ§, μ΄λ€ 쑰건μμ μ΄λ»κ² λμν΄μΌ νλμ§ λ¨Όμ νμ νλ€β
- πΎ λ°μ΄ν° μ€μ¬: βERDλ₯Ό λ³΄κ³ μν°ν° κ΄κ³λ₯Ό μ νν μ΄ν΄ν ν JPA λ§€νμ ꡬννλ€β
- ποΈ μν€ν μ² κ³ λ €: βμΈλΆ μμ€ν μ°λμ νμ μ€μ κΈ°λ°μΌλ‘, μ₯μ μν©μ κ³ λ €νμ¬ κ΅¬ννλ€β
π λ§λ¬΄λ¦¬: μ€λ¬΄μμ μ΄μλ¨λ λ°±μλ κ°λ°
β‘ μ€λ¬΄ μ μ©μ ν©κΈλ₯
βλ¬Έμλ μ μ½μ΄ μλλΌ μμ λ₯Ό μ£Όλ μ€κ³λλ€β
λ°±μλ κ°λ°μκ° μ΄ μΈ κ°μ§ λ¬Έμλ₯Ό μ λλ‘ νμ©νλ μ΄μ λ λ¬Έμ μμ²΄κ° λͺ©μ μ΄ μλλΌ, λ€μμ μν΄μμ λλ€:
- π§ μ νν ꡬν: 6κ°μ νμλ μ μ΄λ κ² κ΅¬ννλμ§ μ μ μλ μ½λ
- π λΉ λ₯Έ κ°λ°: λ¬Έμ κΈ°λ° μ²΄κ³μ μ κ·ΌμΌλ‘ μνμ°©μ€ μ΅μν
- π§ͺ μμ μ±: λͺ¨λ μꡬμ¬νκ³Ό μ μ½μ‘°κ±΄μ΄ λ°μλ κ²¬κ³ ν μμ€ν
- π₯ νμ : κΈ°νμ, λμμ΄λ, QAμ μνν μν΅
π― μ€λ¬΄ μ μ© 3λ¨κ³ μμ½
1οΈβ£ μ€λΉ λ¨κ³: λ¬Έμ μ΄ν΄
// μΈ λ¬Έμλ₯Ό λ¨Όμ μ λ
νκ³ ν΅μ¬ ν¬μΈνΈ μ 리
// μꡬμ¬ν β λΉμ¦λμ€ λ‘μ§ νλ‘μ°μ°¨νΈ μμ±
// ERD β μν°ν° κ΄κ³λ κ·Έλ €λ³΄κΈ°
// μν€ν
μ² β μμ€ν
μ°λ μνμ€ λ€μ΄μ΄κ·Έλ¨ μμ±
2οΈβ£ ꡬν λ¨κ³: λ¬Έμ κΈ°λ° μ½λ©
// ERD β Entity β Repository β Service (μꡬμ¬ν) β Controller (API λͺ
μΈμ)
// μν€ν
μ² β μΈλΆ μ°λ ꡬν
public class OrderService {
// λͺ¨λ λ¬Έμμ λ΄μ©μ΄ μ½λμ μ νν λ°μ
}
3οΈβ£ κ²μ¦ λ¨κ³: λ¬Έμ κΈ°λ° ν μ€νΈ
@Test
void μꡬμ¬ν_μλ리μ€_ν
μ€νΈ() {
// μꡬμ¬ν λͺ
μΈμμ λͺ¨λ μλ리μ€λ₯Ό ν
μ€νΈ μΌμ΄μ€λ‘ μμ±
// Given: λ¬Έμμ μ μλ μ μ 쑰건
// When: λ¬Έμμ μ μλ μ‘μ
// Then: λ¬Έμμ μ μλ κΈ°λ κ²°κ³Ό
}
π λ§μ§λ§ μ‘°μΈ
μΈ λ¬Έμλ₯Ό λ§Ήλͺ©μ μΌλ‘ λ°λ₯΄μ§ λ§μΈμ. νλ‘μ νΈμ κ·λͺ¨, νμ μλ, μΌμ μ νμ€μ±μ κ³ λ €ν΄μ μ μ ν μμ€μμ νμ©νλ κ²μ΄ μ€μν©λλ€.
- μμ νλ‘μ νΈ: μꡬμ¬ν μ€μ¬ + κΈ°λ³Έ ERD λ§€ν
- μ€κ° νλ‘μ νΈ: μΈ λ¬Έμ κ· ν μκ² νμ©
- ν° νλ‘μ νΈ: λͺ¨λ λ¬Έμ λ΄μ© μλ²½ λ°μ + λ¬Έμ κΈ°λ° μ½λ 리뷰
βμ€λμ λ¬Έμ μ΄ν΄κ° 6κ°μ νμ λλ₯Ό λ§λ λ€β
μ§κΈ λΉμ₯μ λ²κ±°λ‘μ λ³΄μΌ μ μμ§λ§, λ¬Έμ κΈ°λ° κ°λ°μ 체λν λ°±μλ κ°λ°μλ λ μ ννκ³ , λ λΉ λ₯΄κ², λ μμ νκ² κ°λ°ν μ μμ΅λλ€.
π μΆκ° νμ΅ μλ£
π μ¬ν νμ΅ μ£Όμ
- Domain Driven Design (DDD): μꡬμ¬νμ λλ©μΈ λͺ¨λΈλ‘ λ³ννλ λ°©λ²λ‘
- Event Sourcing: λ°μ΄ν° λ³κ²½ μ΄λ ₯μ μ΄λ²€νΈλ‘ κ΄λ¦¬νλ ν¨ν΄
- CQRS (Command Query Responsibility Segregation): μ½κΈ°μ μ°κΈ° λͺ¨λΈ λΆλ¦¬
- Hexagonal Architecture: μΈλΆ μμ‘΄μ±μΌλ‘λΆν° λΉμ¦λμ€ λ‘μ§ λ³΄νΈ
π κΆμ₯ λμ
- βλλ©μΈ μ£Όλ μ€κ³β - μλ¦ μλ°μ€
- βν΄λ¦° μν€ν μ²β - λ‘λ²νΈ C. λ§ν΄
- βλ§μ΄ν¬λ‘μλΉμ€ ν¨ν΄β - ν¬λ¦¬μ€ 리μ²λμ¨
- βμλ° ORM νμ€ JPA νλ‘κ·Έλλ°β - κΉμν