소스는 java-src/project/stage2/(23개 파일). 값 객체 → 예외 → 도메인 → 결제 → 저장소 → 서비스 → Main 순서로 읽는다. 이 순서는 의존 방향의 역순, 즉 "아무것도 의존하지 않는 것부터"다.
import java.util.Objects;
/**
* 불변 금액 값 객체. int 금액을 그대로 더하고 빼던 1단계와 달리
* "음수 금액 금지", "원 단위 표시", "동등성 비교"를 한 곳에서 책임진다.
*/
public final class Money implements Comparable<Money> {
public static final Money ZERO = new Money(0);
private final long amount; // 원 단위. final → 생성 후 변경 불가
private Money(long amount) {
if (amount < 0) throw new ValidationException("금액은 음수일 수 없습니다: " + amount);
this.amount = amount;
}
public static Money won(long amount) { return new Money(amount); }
public long amount() { return amount; }
public boolean isZero() { return amount == 0; }
// 연산은 항상 "새 객체"를 돌려준다 — 원본은 절대 변하지 않는다
public Money plus(Money other) { return new Money(this.amount + other.amount); }
public Money minus(Money other) { return new Money(this.amount - other.amount); } // 음수면 생성자에서 예외
public Money times(int multiplier) { return new Money(this.amount * multiplier); }
public Money percent(int percent) { return new Money(this.amount * percent / 100); }
public boolean isLessThan(Money other) { return this.amount < other.amount; }
@Override
public int compareTo(Money other) { return Long.compare(this.amount, other.amount); }
// 값 객체의 핵심: 같은 금액이면 "같은 돈". HashMap/HashSet에 넣으려면 둘 다 필요하다
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money other)) return false;
return amount == other.amount;
}
@Override
public int hashCode() { return Objects.hash(amount); }
@Override
public String toString() { return String.format("%,d원", amount); }
}불변의 세 조건이 모두 있다. final class(상속으로 가변 하위 클래스를 못 만들게), private final 필드, setter 없음. minus가 음수를 만들면 생성자가 막으므로 "잔액 − 결제액"이 음수가 되는 버그는 컴파일 타임이 아니라도 가장 이른 런타임 시점에 터진다. long으로 바꾼 것은 int의 21억 한계 때문이다(총 매출 집계에서 넘칠 수 있다).
/**
* 쇼핑몰 도메인 예외의 공통 부모.
* 호출자는 구체 예외를 각각 잡을 수도, 이 타입 하나로 뭉뚱그려 잡을 수도 있다.
* unchecked(RuntimeException)로 두어 시그니처마다 throws를 강요하지 않는다.
*/
public class ShopException extends RuntimeException {
public ShopException(String message) { super(message); }
}/** 입력값이 규칙에 맞지 않을 때 (수량 0, 음수 금액, 빈 주문 등). */
public class ValidationException extends ShopException {
public ValidationException(String message) { super(message); }
}/** 저장소에 해당 ID가 없을 때. 1단계의 "null 반환 후 호출자가 검사"를 대체한다. */
public class NotFoundException extends ShopException {
public NotFoundException(String entity, Object id) {
super(entity + "을(를) 찾을 수 없습니다: " + id);
}
}/** 재고가 요청 수량보다 적을 때. 어떤 상품이 얼마나 부족한지 필드로 보관한다. */
public class OutOfStockException extends ShopException {
private final String productName;
private final int stock;
private final int requested;
public OutOfStockException(String productName, int stock, int requested) {
super("재고 부족: " + productName + " (재고 " + stock + ", 요청 " + requested + ")");
this.productName = productName;
this.stock = stock;
this.requested = requested;
}
public String getProductName() { return productName; }
public int getStock() { return stock; }
public int getRequested() { return requested; }
}/** 결제 수단의 잔액·한도가 결제 금액보다 적을 때. */
public class InsufficientBalanceException extends ShopException {
public InsufficientBalanceException(String method, Money available, Money required) {
super(method + " 잔액 부족: 사용 가능 " + available + ", 필요 " + required);
}
}InsufficientBalanceException의 생성자가 Money를 받아 메시지를 만든다. Money.toString()이 12,000원을 만들어 주므로 메시지 서식 코드가 예외 클래스에 없다. 1단계에서 format()을 여기저기 부르던 것과 대비된다.
import java.util.Objects;
/** 2단계 회원. 포인트가 int에서 Money로 바뀌었고, 포인트 사용 규칙을 스스로 지킨다. */
public class Member {
private final long id;
private final String name;
private Money point;
public Member(long id, String name, Money point) {
this.id = id;
this.name = Objects.requireNonNull(name);
this.point = Objects.requireNonNull(point);
}
public long getId() { return id; }
public String getName() { return name; }
public Money getPoint() { return point; }
public void addPoint(Money amount) { point = point.plus(amount); }
/** 포인트 차감. 부족하면 예외 — 호출자가 잔액을 미리 확인할 필요가 없다. */
public void usePoint(Money amount) {
if (point.isLessThan(amount)) throw new InsufficientBalanceException("포인트", point, amount);
point = point.minus(amount);
}
// 엔티티의 동등성은 ID로 판단한다 (이름이 같아도 다른 회원)
@Override
public boolean equals(Object o) {
return o instanceof Member other && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
@Override
public String toString() { return name + "(#" + id + ")"; }
}Money는 "값이 같으면 같다", Member는 "ID가 같으면 같다". 값 객체와 엔티티의 동등성 기준이 다르다는 것을 두 클래스의 equals가 보여 준다. Member를 HashMap의 키로 쓰는 장면이 Main의 집계에 나온다.
/** 2단계 상품. 재고 검증이 Main에서 상품 자신으로 옮겨 왔다. */
public class Product {
private final long id;
private final String name;
private final String category;
private final Money price;
private int stock;
public Product(long id, String name, String category, Money price, int stock) {
if (stock < 0) throw new ValidationException("재고는 음수일 수 없습니다: " + stock);
this.id = id;
this.name = name;
this.category = category;
this.price = price;
this.stock = stock;
}
public long getId() { return id; }
public String getName() { return name; }
public String getCategory() { return category; }
public Money getPrice() { return price; }
public int getStock() { return stock; }
/** 재고가 충분한지만 확인 (차감하지 않음). 주문 전체를 먼저 검증할 때 쓴다. */
public void ensureStock(int qty) {
if (stock < qty) throw new OutOfStockException(name, stock, qty);
}
public void decreaseStock(int qty) {
ensureStock(qty); // 검증과 차감이 항상 함께 — 빼먹을 수 없다
stock -= qty;
}
public void increaseStock(int qty) { stock += qty; }
@Override
public boolean equals(Object o) {
return o instanceof Product other && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
@Override
public String toString() { return name + "(" + price + ", 재고 " + stock + ")"; }
}1단계 decreaseStock은 그냥 뺐다. 2단계는 ensureStock을 먼저 부른다. 누가 호출하든 재고가 음수가 되는 경로가 없다.
/** 주문 한 줄: 상품 + 수량. 1단계의 병렬 배열 두 개가 객체 하나로 합쳐졌다. */
public class OrderLine {
private final Product product;
private final int quantity;
public OrderLine(Product product, int quantity) {
if (quantity <= 0) throw new ValidationException("수량은 1 이상이어야 합니다: " + quantity);
this.product = product;
this.quantity = quantity;
}
public Product getProduct() { return product; }
public int getQuantity() { return quantity; }
/** 줄 합계 = 단가 × 수량. 계산 책임이 데이터 옆에 있다. */
public Money lineTotal() { return product.getPrice().times(quantity); }
@Override
public String toString() {
return product.getName() + " x" + quantity + " = " + lineTotal();
}
}OrderLine이 Product 객체를 갖는다. 1단계는 int productId를 갖고 매번 findProduct를 했다. 참조를 갖는 순간 탐색이 사라지고, lineTotal()이 자기 안에서 계산된다.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 2단계 주문. 항목 수 제한이 사라졌고(ArrayList), 상태 전이를 스스로 관리한다. */
public class Order {
public enum Status { CREATED, PAID, CANCELLED }
private final String id;
private final Member member;
private final List<OrderLine> lines;
private Status status = Status.CREATED;
private PaymentMethod.Receipt receipt; // 결제 전에는 null
public Order(String id, Member member, List<OrderLine> lines) {
if (lines.isEmpty()) throw new ValidationException("주문 항목이 비어 있습니다");
this.id = id;
this.member = member;
this.lines = new ArrayList<>(lines); // 방어적 복사: 외부 리스트 변경에 영향받지 않게
}
public String getId() { return id; }
public Member getMember() { return member; }
public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); }
public Status getStatus() { return status; }
public PaymentMethod.Receipt getReceipt() { return receipt; }
public Money total() {
Money sum = Money.ZERO;
for (OrderLine line : lines) sum = sum.plus(line.lineTotal());
return sum;
}
/** CREATED → PAID. 다른 상태에서 호출하면 예외. */
public void markPaid(PaymentMethod.Receipt receipt) {
if (status != Status.CREATED) throw new ValidationException("결제할 수 없는 상태: " + status);
this.status = Status.PAID;
this.receipt = receipt;
}
public void cancel() {
if (status == Status.CANCELLED) throw new ValidationException("이미 취소된 주문: " + id);
this.status = Status.CANCELLED;
}
@Override
public boolean equals(Object o) {
return o instanceof Order other && id.equals(other.id);
}
@Override
public int hashCode() { return id.hashCode(); }
@Override
public String toString() {
return id + " " + member.getName() + " " + total() + " [" + status + "]";
}
}total()이 주문 합계의 유일한 정의다. 1단계에서 createOrder와 orderTotal에 나뉘어 있던 계산이 여기로 모였다. 생성자의 new ArrayList<>(lines)(방어적 복사)와 getLines()의 unmodifiableList가 짝을 이룬다 — 밖에서 항목을 넣거나 빼는 경로를 막아 total()이 항상 일관되게 한다.
/**
* 결제 수단 추상화. 주문 서비스는 카드/계좌/포인트가 무엇인지 몰라도
* 이 인터페이스만 보고 결제를 요청한다.
*/
public interface PaymentMethod {
/** 결제 결과 영수증. 인터페이스 안의 record는 암묵적으로 static이다. */
record Receipt(String method, Money amount, String approvalCode) {
@Override
public String toString() { return method + " " + amount + " (승인 " + approvalCode + ")"; }
}
String name();
/** 결제 수행. 실패하면 InsufficientBalanceException 등 ShopException을 던진다. */
Receipt pay(Money amount);
/** 이 수단으로 결제하면 포인트를 적립하는가. 대부분 true라 default로 둔다. */
default boolean earnsPoint() { return true; }
}/**
* 템플릿 메서드: 공통 흐름(금액 검증 → 한도 검증 → 실제 결제 → 영수증)은 여기서,
* 수단별 차이(validate / doPay)는 하위 클래스에서.
*/
public abstract class AbstractPaymentMethod implements PaymentMethod {
private static int approvalSeq = 1000; // 승인번호 채번 (모든 수단 공용)
@Override
public final Receipt pay(Money amount) { // final: 흐름 자체는 바꾸지 못하게
if (amount.isZero()) throw new ValidationException("0원은 결제할 수 없습니다");
validate(amount);
doPay(amount);
String code = prefix() + "-" + (++approvalSeq);
return new Receipt(name(), amount, code);
}
/** 잔액/한도 검사. 부족하면 InsufficientBalanceException. */
protected abstract void validate(Money amount);
/** 실제 차감. validate를 통과한 뒤에만 호출된다. */
protected abstract void doPay(Money amount);
protected abstract String prefix();
}pay가 validate → doPay 순서를 강제한다. 하위 클래스가 doPay에서 잔액을 확인하지 않아도, validate가 먼저 실행되므로 안전하다. approvalSeq가 static이라 카드·계좌·포인트가 하나의 번호대를 공유한다 — 출력에서 CARD-1001, CARD-1002, BANK-1003으로 이어지는 이유다.
/** 카드 결제: 월 한도 안에서만 승인. */
public class CardPayment extends AbstractPaymentMethod {
private final String cardNo;
private final Money monthlyLimit;
private Money used = Money.ZERO;
public CardPayment(String cardNo, Money monthlyLimit) {
this.cardNo = cardNo;
this.monthlyLimit = monthlyLimit;
}
@Override public String name() { return "카드(" + cardNo + ")"; }
@Override protected String prefix() { return "CARD"; }
@Override
protected void validate(Money amount) {
Money available = monthlyLimit.minus(used);
if (available.isLessThan(amount)) throw new InsufficientBalanceException(name(), available, amount);
}
@Override
protected void doPay(Money amount) { used = used.plus(amount); }
}/** 계좌 이체: 잔액 안에서만 승인. */
public class BankAccountPayment extends AbstractPaymentMethod {
private final String accountNo;
private Money balance;
public BankAccountPayment(String accountNo, Money balance) {
this.accountNo = accountNo;
this.balance = balance;
}
@Override public String name() { return "계좌(" + accountNo + ")"; }
@Override protected String prefix() { return "BANK"; }
@Override
protected void validate(Money amount) {
if (balance.isLessThan(amount)) throw new InsufficientBalanceException(name(), balance, amount);
}
@Override
protected void doPay(Money amount) { balance = balance.minus(amount); }
public Money getBalance() { return balance; }
}/** 포인트 결제: 회원 포인트를 차감. 포인트로 결제하면 포인트를 적립하지 않는다. */
public class PointPayment extends AbstractPaymentMethod {
private final Member member;
public PointPayment(Member member) { this.member = member; }
@Override public String name() { return "포인트(" + member.getName() + ")"; }
@Override protected String prefix() { return "POINT"; }
@Override public boolean earnsPoint() { return false; } // default 메서드 재정의
@Override
protected void validate(Money amount) {
if (member.getPoint().isLessThan(amount))
throw new InsufficientBalanceException(name(), member.getPoint(), amount);
}
@Override
protected void doPay(Money amount) { member.usePoint(amount); }
}세 클래스 모두 15~20줄이고, 차이는 "무엇을 확인하고 무엇을 빼는가"뿐이다. 네 번째 수단(예: 상품권)을 추가하려면 같은 모양의 클래스 하나를 더 쓰면 되고, OrderService나 Main의 결제 호출은 한 글자도 바뀌지 않는다.
import java.util.List;
/** 회원 저장소 계약. 구현이 배열이든 HashMap이든 DB든 서비스 코드는 바뀌지 않는다. */
public interface MemberRepository {
void save(Member member);
Member findById(long id); // 없으면 null (Optional은 3단계에서)
List<Member> findAll();
}import java.util.List;
public interface ProductRepository {
void save(Product product);
Product findById(long id);
List<Product> findAll();
}import java.util.List;
public interface OrderRepository {
void save(Order order);
Order findById(String id);
List<Order> findAll();
String nextId(); // "O-0001" 형태의 채번
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** HashMap 기반 회원 저장소. 1단계의 선형 탐색 O(n)이 O(1)이 되고, 크기 제한이 사라진다. */
public class InMemoryMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new LinkedHashMap<>(); // 등록 순서 유지
@Override public void save(Member member) { store.put(member.getId(), member); }
@Override public Member findById(long id) { return store.get(id); }
@Override public List<Member> findAll() { return new ArrayList<>(store.values()); }
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class InMemoryProductRepository implements ProductRepository {
private final Map<Long, Product> store = new LinkedHashMap<>();
@Override public void save(Product product) { store.put(product.getId(), product); }
@Override public Product findById(long id) { return store.get(id); }
@Override public List<Product> findAll() { return new ArrayList<>(store.values()); }
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new LinkedHashMap<>();
private int seq = 0;
@Override public void save(Order order) { store.put(order.getId(), order); }
@Override public Order findById(String id) { return store.get(id); }
@Override public List<Order> findAll() { return new ArrayList<>(store.values()); }
@Override public String nextId() { return String.format("O-%04d", ++seq); }
}세 구현체가 거의 같은 코드다. 타입만 다르다. findById가 아직 null을 돌려주는 것도 남은 숙제다. 이 두 가지 — 중복과 null — 가 3단계의 제네릭 Repository<T, ID>와 Optional로 이어진다.
import java.util.ArrayList;
import java.util.List;
/**
* 주문 유스케이스. 1단계 Main의 static 메서드들이 이 클래스로 옮겨 왔고,
* 저장소는 인터페이스로만 참조한다(생성자 주입).
*/
public class OrderService {
/** 주문 요청 한 줄. 1단계의 int[] 두 개 대신 이름 있는 쌍. */
public record ItemRequest(long productId, int quantity) {}
private final MemberRepository memberRepo;
private final ProductRepository productRepo;
private final OrderRepository orderRepo;
public OrderService(MemberRepository memberRepo, ProductRepository productRepo, OrderRepository orderRepo) {
this.memberRepo = memberRepo;
this.productRepo = productRepo;
this.orderRepo = orderRepo;
}
public Order createOrder(long memberId, List<ItemRequest> items) {
if (items == null || items.isEmpty()) throw new ValidationException("주문 항목이 비어 있습니다");
Member member = memberRepo.findById(memberId);
if (member == null) throw new NotFoundException("회원", memberId);
// 1) 모든 항목을 먼저 검증 — 중간에 실패해도 재고가 반쯤 깎이지 않도록
List<OrderLine> lines = new ArrayList<>();
for (ItemRequest item : items) {
Product product = productRepo.findById(item.productId());
if (product == null) throw new NotFoundException("상품", item.productId());
product.ensureStock(item.quantity()); // OutOfStockException
lines.add(new OrderLine(product, item.quantity())); // ValidationException (수량 ≤ 0)
}
// 2) 검증을 모두 통과한 뒤에만 재고 차감
for (OrderLine line : lines) line.getProduct().decreaseStock(line.getQuantity());
Order order = new Order(orderRepo.nextId(), member, lines);
orderRepo.save(order);
return order;
}
public PaymentMethod.Receipt pay(String orderId, PaymentMethod method) {
Order order = orderRepo.findById(orderId);
if (order == null) throw new NotFoundException("주문", orderId);
PaymentMethod.Receipt receipt = method.pay(order.total()); // 다형성: 수단이 무엇이든 같은 호출
order.markPaid(receipt);
if (method.earnsPoint()) {
order.getMember().addPoint(order.total().percent(1)); // 1% 적립
}
return receipt;
}
public void cancel(String orderId) {
Order order = orderRepo.findById(orderId);
if (order == null) throw new NotFoundException("주문", orderId);
if (order.getStatus() == Order.Status.PAID)
throw new ValidationException("결제 완료된 주문은 취소할 수 없습니다: " + orderId);
order.cancel();
for (OrderLine line : order.getLines()) line.getProduct().increaseStock(line.getQuantity());
}
}createOrder를 1단계와 나란히 놓으면 차이가 보인다. 1단계는 실패마다 println + return이었고 반환값이 없었다. 2단계는 실패마다 throw이고 성공하면 Order를 반환한다. 호출자가 결과를 받아 다음 일(결제)을 할 수 있다.
pay의 method.pay(order.total()) 한 줄이 이 단계의 핵심이다. method가 카드인지 포인트인지 서비스는 모른다. 그것이 다형성이다.
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 2단계: 상속/인터페이스/예외 계층/컬렉션/Repository/불변 Money.
* javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main
*/
public class Main {
public static void main(String[] args) {
System.out.println("=== 1. Money 값 객체 ===");
moneyDemo();
// ---- 저장소 조립: 서비스는 인터페이스만 안다 ----
MemberRepository memberRepo = new InMemoryMemberRepository();
ProductRepository productRepo = new InMemoryProductRepository();
OrderRepository orderRepo = new InMemoryOrderRepository();
OrderService service = new OrderService(memberRepo, productRepo, orderRepo);
System.out.println();
System.out.println("=== 2. 회원/상품 등록 (크기 제한 없음) ===");
memberRepo.save(new Member(1, "김철수", Money.won(5_000)));
memberRepo.save(new Member(2, "이영희", Money.won(12_000)));
memberRepo.save(new Member(3, "박민수", Money.won(0)));
memberRepo.save(new Member(4, "최지우", Money.won(3_000))); // 1단계에서는 실패했던 4번째 회원
productRepo.save(new Product(101, "무선 마우스", "전자", Money.won(25_000), 10));
productRepo.save(new Product(102, "USB-C 케이블", "전자", Money.won(8_000), 30));
productRepo.save(new Product(103, "자바 입문서", "도서", Money.won(32_000), 3));
productRepo.save(new Product(104, "텀블러", "생활", Money.won(15_000), 20));
productRepo.save(new Product(105, "노트", "문구", Money.won(2_500), 100));
System.out.println("회원 " + memberRepo.findAll().size() + "명, 상품 " + productRepo.findAll().size() + "개");
System.out.println();
System.out.println("=== 3. 주문 생성과 예외 ===");
tryCreate(service, 1, List.of(new OrderService.ItemRequest(101, 2), new OrderService.ItemRequest(102, 1)));
tryCreate(service, 2, List.of(new OrderService.ItemRequest(103, 5))); // OutOfStock
tryCreate(service, 9, List.of(new OrderService.ItemRequest(101, 1))); // NotFound(회원)
tryCreate(service, 3, List.of(new OrderService.ItemRequest(101, 1), new OrderService.ItemRequest(999, 1))); // NotFound(상품)
tryCreate(service, 3, List.of(new OrderService.ItemRequest(105, 0))); // Validation
tryCreate(service, 2, List.of(new OrderService.ItemRequest(103, 1), new OrderService.ItemRequest(104, 3)));
tryCreate(service, 3, List.of(new OrderService.ItemRequest(101, 1)));
tryCreate(service, 4, List.of(new OrderService.ItemRequest(104, 1), new OrderService.ItemRequest(105, 4))); // 4번째 주문도 OK
System.out.println();
System.out.println("=== 4. 결제: 수단이 달라도 호출은 같다 ===");
PaymentMethod card = new CardPayment("1234-****", Money.won(100_000));
PaymentMethod bank = new BankAccountPayment("110-222", Money.won(50_000));
Member lee = memberRepo.findById(2);
Member choi = memberRepo.findById(4);
tryPay(service, "O-0001", card); // 58,000 카드 → 성공
tryPay(service, "O-0002", bank); // 77,000 계좌(잔액 50,000) → 잔액 부족
tryPay(service, "O-0002", new PointPayment(lee)); // 77,000 포인트(12,000) → 잔액 부족
tryPay(service, "O-0002", card); // 카드 잔여 한도 42,000 → 한도 부족
tryPay(service, "O-0003", card); // 25,000 카드 → 성공
tryPay(service, "O-0003", card); // 이미 결제됨 → Validation
tryPay(service, "O-0004", new PointPayment(choi)); // 25,000 포인트(3,000) → 부족
tryPay(service, "O-0004", bank); // 25,000 계좌 → 성공
tryPay(service, "O-9999", card); // NotFound
System.out.println();
System.out.println("=== 5. 취소와 재고 복구 ===");
System.out.println("취소 전 재고: " + productRepo.findById(103).getStock() + ", " + productRepo.findById(104).getStock());
try {
service.cancel("O-0001"); // PAID → 취소 불가
} catch (ShopException e) {
System.out.println("[" + e.getClass().getSimpleName() + "] " + e.getMessage());
}
service.cancel("O-0002"); // CREATED → 취소 + 재고 복구
System.out.println("취소 후 재고: " + productRepo.findById(103).getStock() + ", " + productRepo.findById(104).getStock());
System.out.println();
System.out.println("=== 6. 주문 목록 ===");
for (Order o : orderRepo.findAll()) {
System.out.println(o);
for (OrderLine line : o.getLines()) System.out.println(" - " + line);
}
System.out.println();
System.out.println("=== 7. 집계 (HashMap.merge) ===");
Map<Member, Money> salesByMember = new HashMap<>(); // Member가 키 → equals/hashCode 필요
Money total = Money.ZERO;
for (Order o : orderRepo.findAll()) {
if (o.getStatus() != Order.Status.PAID) continue;
salesByMember.merge(o.getMember(), o.total(), Money::plus);
total = total.plus(o.total());
}
System.out.println("총 매출(결제 완료): " + total);
for (Member m : memberRepo.findAll()) {
System.out.println("회원별 매출: " + m.getName() + " "
+ salesByMember.getOrDefault(m, Money.ZERO) + " (포인트 " + m.getPoint() + ")");
}
StringBuilder sb = new StringBuilder("남은 재고:");
for (Product p : productRepo.findAll()) sb.append(" ").append(p.getName()).append("=").append(p.getStock());
System.out.println(sb);
}
static void moneyDemo() {
Money a = Money.won(10_000);
Money b = Money.won(10_000);
Money c = a.plus(Money.won(5_000));
System.out.println("a == b ? " + (a == b) + ", a.equals(b) ? " + a.equals(b));
System.out.println("a.plus(5,000) = " + c + ", a는 그대로 " + a);
Set<Money> set = new HashSet<>(List.of(a, b, c));
System.out.println("HashSet 크기: " + set.size() + " (10,000원 두 개는 하나로)");
try {
Money.won(3_000).minus(Money.won(5_000));
} catch (ValidationException e) {
System.out.println("[ValidationException] " + e.getMessage());
}
}
static void tryCreate(OrderService service, long memberId, List<OrderService.ItemRequest> items) {
try {
Order order = service.createOrder(memberId, items);
System.out.println("주문 생성: " + order);
} catch (OutOfStockException e) {
System.out.println("[재고 부족] " + e.getProductName() + " " + e.getStock() + "/" + e.getRequested());
} catch (ShopException e) { // 나머지는 부모 타입으로 한 번에
System.out.println("[" + e.getClass().getSimpleName() + "] " + e.getMessage());
}
}
static void tryPay(OrderService service, String orderId, PaymentMethod method) {
try {
PaymentMethod.Receipt receipt = service.pay(orderId, method);
System.out.println("결제 성공: " + orderId + " → " + receipt);
} catch (ShopException e) {
System.out.println("결제 실패: " + orderId + " [" + e.getClass().getSimpleName() + "] " + e.getMessage());
}
}
}// 출력:
=== 1. Money 값 객체 ===
a == b ? false, a.equals(b) ? true
a.plus(5,000) = 15,000원, a는 그대로 10,000원
HashSet 크기: 2 (10,000원 두 개는 하나로)
[ValidationException] 금액은 음수일 수 없습니다: -2000
=== 2. 회원/상품 등록 (크기 제한 없음) ===
회원 4명, 상품 5개
=== 3. 주문 생성과 예외 ===
주문 생성: O-0001 김철수 58,000원 [CREATED]
[재고 부족] 자바 입문서 3/5
[NotFoundException] 회원을(를) 찾을 수 없습니다: 9
[NotFoundException] 상품을(를) 찾을 수 없습니다: 999
[ValidationException] 수량은 1 이상이어야 합니다: 0
주문 생성: O-0002 이영희 77,000원 [CREATED]
주문 생성: O-0003 박민수 25,000원 [CREATED]
주문 생성: O-0004 최지우 25,000원 [CREATED]
=== 4. 결제: 수단이 달라도 호출은 같다 ===
결제 성공: O-0001 → 카드(1234-****) 58,000원 (승인 CARD-1001)
결제 실패: O-0002 [InsufficientBalanceException] 계좌(110-222) 잔액 부족: 사용 가능 50,000원, 필요 77,000원
결제 실패: O-0002 [InsufficientBalanceException] 포인트(이영희) 잔액 부족: 사용 가능 12,000원, 필요 77,000원
결제 실패: O-0002 [InsufficientBalanceException] 카드(1234-****) 잔액 부족: 사용 가능 42,000원, 필요 77,000원
결제 성공: O-0003 → 카드(1234-****) 25,000원 (승인 CARD-1002)
결제 실패: O-0003 [InsufficientBalanceException] 카드(1234-****) 잔액 부족: 사용 가능 17,000원, 필요 25,000원
결제 실패: O-0004 [InsufficientBalanceException] 포인트(최지우) 잔액 부족: 사용 가능 3,000원, 필요 25,000원
결제 성공: O-0004 → 계좌(110-222) 25,000원 (승인 BANK-1003)
결제 실패: O-9999 [NotFoundException] 주문을(를) 찾을 수 없습니다: O-9999
=== 5. 취소와 재고 복구 ===
취소 전 재고: 2, 16
[ValidationException] 결제 완료된 주문은 취소할 수 없습니다: O-0001
취소 후 재고: 3, 19
=== 6. 주문 목록 ===
O-0001 김철수 58,000원 [PAID]
- 무선 마우스 x2 = 50,000원
- USB-C 케이블 x1 = 8,000원
O-0002 이영희 77,000원 [CANCELLED]
- 자바 입문서 x1 = 32,000원
- 텀블러 x3 = 45,000원
O-0003 박민수 25,000원 [PAID]
- 무선 마우스 x1 = 25,000원
O-0004 최지우 25,000원 [PAID]
- 텀블러 x1 = 15,000원
- 노트 x4 = 10,000원
=== 7. 집계 (HashMap.merge) ===
총 매출(결제 완료): 108,000원
회원별 매출: 김철수 58,000원 (포인트 5,580원)
회원별 매출: 이영희 0원 (포인트 12,000원)
회원별 매출: 박민수 25,000원 (포인트 250원)
회원별 매출: 최지우 25,000원 (포인트 3,250원)
남은 재고: 무선 마우스=7 USB-C 케이블=29 자바 입문서=3 텀블러=19 노트=96