공공부하자개발 · 영어 학습 노트
자바
미니 프로젝트쇼핑몰 정산 시스템 4단계0/4 완료
  • 011단계: 콘솔 주문 관리 (초급 기술)
  • 022단계: 객체지향 리팩터링 (중급 기술)
  • 033단계: 모던 자바 적용 (고급 기술)
  • 044단계: 일마감 정산 배치 (배치 기술)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 미니 프로젝트 › 02 / 4

2단계: 객체지향 리팩터링 (중급 기술)

섹션 6진행 0 / 4
1이번 단계의 목표2설계3구현 따라가기4실행 결과와 해설5확장 과제6다음 단계로‹ 이전다음 ›

3. 구현 따라가기

소스는 java-src/project/stage2/(23개 파일). 값 객체 → 예외 → 도메인 → 결제 → 저장소 → 서비스 → Main 순서로 읽는다. 이 순서는 의존 방향의 역순, 즉 "아무것도 의존하지 않는 것부터"다.

3.1: Money.java — 불변 값 객체

java
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억 한계 때문이다(총 매출 집계에서 넘칠 수 있다).

3.2: 예외 계층

java
/**
 * 쇼핑몰 도메인 예외의 공통 부모.
 * 호출자는 구체 예외를 각각 잡을 수도, 이 타입 하나로 뭉뚱그려 잡을 수도 있다.
 * unchecked(RuntimeException)로 두어 시그니처마다 throws를 강요하지 않는다.
 */
public class ShopException extends RuntimeException {
    public ShopException(String message) { super(message); }
}
java
/** 입력값이 규칙에 맞지 않을 때 (수량 0, 음수 금액, 빈 주문 등). */
public class ValidationException extends ShopException {
    public ValidationException(String message) { super(message); }
}
java
/** 저장소에 해당 ID가 없을 때. 1단계의 "null 반환 후 호출자가 검사"를 대체한다. */
public class NotFoundException extends ShopException {
    public NotFoundException(String entity, Object id) {
        super(entity + "을(를) 찾을 수 없습니다: " + id);
    }
}
java
/** 재고가 요청 수량보다 적을 때. 어떤 상품이 얼마나 부족한지 필드로 보관한다. */
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; }
}
java
/** 결제 수단의 잔액·한도가 결제 금액보다 적을 때. */
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()을 여기저기 부르던 것과 대비된다.

3.3: 도메인 — Member, Product

java
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의 집계에 나온다.

java
/** 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을 먼저 부른다. 누가 호출하든 재고가 음수가 되는 경로가 없다.

3.4: 도메인 — OrderLine, Order

java
/** 주문 한 줄: 상품 + 수량. 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()이 자기 안에서 계산된다.

java
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()이 항상 일관되게 한다.

3.5: 결제 — PaymentMethod, AbstractPaymentMethod

java
/**
 * 결제 수단 추상화. 주문 서비스는 카드/계좌/포인트가 무엇인지 몰라도
 * 이 인터페이스만 보고 결제를 요청한다.
 */
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; }
}
java
/**
 * 템플릿 메서드: 공통 흐름(금액 검증 → 한도 검증 → 실제 결제 → 영수증)은 여기서,
 * 수단별 차이(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으로 이어지는 이유다.

3.6: 결제 — 구현 3개

java
/** 카드 결제: 월 한도 안에서만 승인. */
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); }
}
java
/** 계좌 이체: 잔액 안에서만 승인. */
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; }
}
java
/** 포인트 결제: 회원 포인트를 차감. 포인트로 결제하면 포인트를 적립하지 않는다. */
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의 결제 호출은 한 글자도 바뀌지 않는다.

3.7: 저장소 인터페이스와 인메모리 구현

java
import java.util.List;

/** 회원 저장소 계약. 구현이 배열이든 HashMap이든 DB든 서비스 코드는 바뀌지 않는다. */
public interface MemberRepository {
    void save(Member member);
    Member findById(long id);          // 없으면 null (Optional은 3단계에서)
    List<Member> findAll();
}
java
import java.util.List;

public interface ProductRepository {
    void save(Product product);
    Product findById(long id);
    List<Product> findAll();
}
java
import java.util.List;

public interface OrderRepository {
    void save(Order order);
    Order findById(String id);
    List<Order> findAll();
    String nextId();                   // "O-0001" 형태의 채번
}
java
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()); }
}
java
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()); }
}
java
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로 이어진다.

3.8: OrderService.java

java
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가 카드인지 포인트인지 서비스는 모른다. 그것이 다형성이다.

3.9: Main.java — 조립과 시나리오

java
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());
        }
    }
}
text
// 출력:
=== 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
구현 따라가기
  • 3.1: Money.java — 불변 값 객체
  • 3.2: 예외 계층
  • 3.3: 도메인 — Member, Product
  • 3.4: 도메인 — OrderLine, Order
  • 3.5: 결제 — PaymentMethod, AbstractPaymentMethod
  • 3.6: 결제 — 구현 3개
  • 3.7: 저장소 인터페이스와 인메모리 구현
  • 3.8: OrderService.java
  • 3.9: Main.java — 조립과 시나리오
이전 섹션2 설계3 / 6다음 섹션4 실행 결과와 해설