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

3단계: 모던 자바 적용 (고급 기술)

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

3. 구현 따라가기

소스는 java-src/project/stage3/(19개 파일). 예외 4개(ShopException, ValidationException, NotFoundException, OutOfStockException)는 2단계와 같으므로 생략한다. InsufficientBalanceException은 사라졌다 — PaymentResult.Declined가 대신한다.

3.1: Money.java — record로

java
/**
 * 3단계 Money: 2단계의 final 클래스 50줄이 record 20줄로.
 * equals/hashCode/toString(재정의)/접근자를 컴파일러가 만든다.
 */
public record Money(long amount) implements Comparable<Money> {
    public static final Money ZERO = new Money(0);

    public Money {                                       // compact constructor: 검증만
        if (amount < 0) throw new ValidationException("금액은 음수일 수 없습니다: " + amount);
    }

    public static Money won(long amount) { return new Money(amount); }

    public Money plus(Money o) { return new Money(amount + o.amount); }
    public Money minus(Money o) { return new Money(amount - o.amount); }
    public Money times(int n) { return new Money(amount * n); }
    public Money percent(int p) { return new Money(amount * p / 100); }
    public Money min(Money o) { return amount <= o.amount ? this : o; }
    public boolean isLessThan(Money o) { return amount < o.amount; }
    public boolean isZero() { return amount == 0; }

    @Override public int compareTo(Money o) { return Long.compare(amount, o.amount); }
    @Override public String toString() { return String.format("%,d원", amount); }
}

compact constructor(public Money { ... })는 매개변수 대입 전에 실행되는 검증 블록이다. 2단계의 private 생성자 + equals + hashCode + amount() 접근자가 전부 사라졌다. toString만 12,000원 서식을 위해 남겼다.

3.2: 도메인 record — Member, Product, OrderLine, Order

java
/** 회원. 포인트 잔액은 별도 원장으로 뺐고(이 단계에서는 생략), 등급이 추가됐다. */
public record Member(Long id, String name, Grade grade) {
    public enum Grade { BASIC, VIP }

    public boolean isVip() { return grade == Grade.VIP; }
}
java
/**
 * 상품. 2단계와 달리 재고(stock)가 없다 — 재고는 여러 스레드가 동시에 바꾸는 값이라
 * 불변 record에 둘 수 없고, Inventory가 따로 관리한다.
 */
public record Product(Long id, String name, String category, Money price) {}
java
public record OrderLine(Product product, int quantity) {
    public OrderLine {
        if (quantity <= 0) throw new ValidationException("수량은 1 이상이어야 합니다: " + quantity);
    }

    public Money lineTotal() { return product.price().times(quantity); }
}
java
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;

/**
 * 주문. 상태 변경(markPaid 등)은 record에 어울리지 않아 "결제 결과"를 별도 값으로 뺐다.
 * 컬렉션 필드는 compact constructor에서 List.copyOf로 불변화한다.
 */
public record Order(String id, Member member, List<OrderLine> lines, LocalDate orderedAt, Money discount) {
    public Order {
        if (lines.isEmpty()) throw new ValidationException("주문 항목이 비어 있습니다");
        lines = List.copyOf(lines);                     // 외부에서 원본 리스트를 바꿔도 영향 없음
    }

    public Money subtotal() {
        return lines.stream().map(OrderLine::lineTotal).reduce(Money.ZERO, Money::plus);
    }

    public Money total() { return subtotal().minus(discount); }

    public YearMonth month() { return YearMonth.from(orderedAt); }

    @Override
    public String toString() {
        return id + " " + orderedAt + " " + member.name() + " " + total()
                + (discount.isZero() ? "" : " (할인 " + discount + ")");
    }
}

Order의 compact constructor에서 lines = List.copyOf(lines)는 2단계의 방어적 복사 + unmodifiableList를 한 줄로 대체한다. List.copyOf는 진짜 불변 리스트를 만든다. subtotal()의 reduce(Money.ZERO, Money::plus)는 2단계의 for (OrderLine line : lines) sum = sum.plus(...)다.

Member.id가 long이 아니라 Long인 이유는 Repository<Member, Long>의 타입 인자로 쓰기 위해서다. 제네릭은 원시 타입을 받지 못한다.

3.3: Inventory.java — 원자적 재고 예약

java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 재고 원장. 여러 스레드가 동시에 같은 상품을 예약해도 재고가 음수가 되지 않는다.
 * ConcurrentHashMap.compute는 "같은 키"에 대한 갱신을 원자적으로 직렬화한다.
 */
public class Inventory {
    private final Map<Long, Integer> stock = new ConcurrentHashMap<>();

    public void put(Long productId, int qty) { stock.put(productId, qty); }

    public int get(Long productId) { return stock.getOrDefault(productId, 0); }

    /**
     * 재고를 확인하고 차감하는 두 동작을 하나의 원자적 갱신으로 묶는다.
     * "확인 후 차감"을 두 문장으로 쓰면 그 사이에 다른 스레드가 끼어든다(check-then-act 경쟁).
     */
    public boolean tryReserve(Long productId, int qty) {
        int[] reserved = {0};
        stock.compute(productId, (id, current) -> {
            int cur = current == null ? 0 : current;
            if (cur < qty) return current;              // 부족: 값 그대로
            reserved[0] = 1;
            return cur - qty;                           // 충분: 차감
        });
        return reserved[0] == 1;
    }

    public void release(Long productId, int qty) { stock.merge(productId, qty, Integer::sum); }
}

int[] reserved = {0}은 람다 안에서 바깥 변수를 바꾸기 위한 관용구다(람다는 effectively final 변수만 캡처하므로 배열로 감싼다). compute가 반환하면 reserved[0]은 확정돼 있다.

3.4: DiscountPolicy.java — 람다로 만드는 정책

java
import java.util.function.Predicate;

/**
 * 할인 정책 = (회원, 소계) → 할인액. 함수형 인터페이스이므로 람다로 만들고
 * default 메서드로 조합한다. 2단계라면 정책마다 클래스를 만들었을 것이다.
 */
@FunctionalInterface
public interface DiscountPolicy {
    Money discountFor(Member member, Money subtotal);

    // ---- 기본 정책 (정적 팩토리) ----
    static DiscountPolicy none() { return (m, s) -> Money.ZERO; }

    static DiscountPolicy rate(int percent) { return (m, s) -> s.percent(percent); }

    static DiscountPolicy fixed(Money amount) { return (m, s) -> amount.min(s); }   // 소계보다 클 수 없음

    // ---- 조합자 (default 메서드) ----
    /** 두 정책의 할인액을 합산. 소계를 넘지 않도록 상한. */
    default DiscountPolicy plus(DiscountPolicy other) {
        return (m, s) -> this.discountFor(m, s).plus(other.discountFor(m, s)).min(s);
    }

    /** 회원 조건을 만족할 때만 적용. */
    default DiscountPolicy onlyIf(Predicate<Member> condition) {
        return (m, s) -> condition.test(m) ? this.discountFor(m, s) : Money.ZERO;
    }

    /** 소계가 기준 금액 이상일 때만 적용. */
    default DiscountPolicy minSubtotal(Money threshold) {
        return (m, s) -> s.isLessThan(threshold) ? Money.ZERO : this.discountFor(m, s);
    }
}

rate(10).onlyIf(Member::isVip).plus(fixed(3_000).minSubtotal(50_000))처럼 읽히는 것이 목표다. 각 조합자는 "정책을 받아 정책을 돌려주는" 고차 함수이고, 결과도 DiscountPolicy라 계속 이어 붙일 수 있다. Comparator.comparing(...).thenComparing(...)과 같은 설계다.

3.5: PaymentResult.java, PaymentGateway.java

java
/**
 * 결제 결과. sealed + record 조합: 가능한 결과가 승인/거절 둘뿐임을 컴파일러가 안다.
 * switch에서 두 경우를 모두 다루지 않으면 컴파일 오류 — 2단계의 예외 던지기와 다른 접근.
 */
public sealed interface PaymentResult permits PaymentResult.Approved, PaymentResult.Declined {

    record Approved(String approvalCode, Money amount) implements PaymentResult {}

    record Declined(String reason) implements PaymentResult {}

    default boolean isApproved() { return this instanceof Approved; }
}
java
import java.util.concurrent.atomic.AtomicInteger;

/** 결제 게이트웨이 시뮬레이션. 지연(latencyMs)을 흉내 내 동시 처리 효과를 보여 준다. */
public class PaymentGateway {
    public enum Type { CARD, BANK, POINT }

    private static final Money CARD_LIMIT = Money.won(300_000);
    private final AtomicInteger seq = new AtomicInteger(1000);   // 여러 스레드가 동시에 채번
    private final long latencyMs;

    public PaymentGateway(long latencyMs) { this.latencyMs = latencyMs; }

    public PaymentResult charge(Type type, Money amount) {
        simulateLatency();
        if (amount.isZero()) return new PaymentResult.Declined("0원 결제 불가");
        if (type == Type.CARD && CARD_LIMIT.isLessThan(amount))
            return new PaymentResult.Declined("카드 1회 한도(" + CARD_LIMIT + ") 초과");
        return new PaymentResult.Approved(type + "-" + seq.incrementAndGet(), amount);
    }

    private void simulateLatency() {
        if (latencyMs <= 0) return;
        try {
            Thread.sleep(latencyMs);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

2단계 AbstractPaymentMethod.approvalSeq는 static int였다. 여러 스레드가 ++approvalSeq를 하면 같은 번호가 두 번 나올 수 있다. AtomicInteger.incrementAndGet()은 원자적이다. 동시성이 들어오는 순간 "그냥 int"가 위험해지는 첫 사례다.

3.6: Repository.java, InMemoryRepository.java — 제네릭 저장소

java
import java.util.List;
import java.util.Optional;

/**
 * 제네릭 저장소. 2단계의 MemberRepository/ProductRepository/OrderRepository 세 인터페이스와
 * 세 구현체가 이 인터페이스 하나와 구현체 하나로 줄어든다.
 * findById는 null 대신 Optional — "없을 수 있음"이 타입에 드러난다.
 */
public interface Repository<T, ID> {
    T save(T entity);
    Optional<T> findById(ID id);
    List<T> findAll();
    long count();
}
java
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

/**
 * 엔티티 종류에 무관한 인메모리 저장소. "ID를 어떻게 꺼내는가"만 함수로 받는다.
 * 동시 저장에 대비해 synchronizedMap으로 감싸고, findAll은 락 안에서 복사본을 만든다.
 */
public class InMemoryRepository<T, ID> implements Repository<T, ID> {
    private final Map<ID, T> store = Collections.synchronizedMap(new LinkedHashMap<>());
    private final Function<T, ID> idExtractor;

    public InMemoryRepository(Function<T, ID> idExtractor) { this.idExtractor = idExtractor; }

    @Override
    public T save(T entity) {
        store.put(idExtractor.apply(entity), entity);
        return entity;
    }

    @Override
    public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }

    @Override
    public List<T> findAll() {
        synchronized (store) { return new ArrayList<>(store.values()); }
    }

    @Override
    public long count() { return store.size(); }
}

new InMemoryRepository<>(Member::id) — 생성자에 메서드 참조를 넘기면 이 저장소는 Member를 Long 키로 저장하는 법을 안다. 2단계에서 member.getId()를 하드코딩했던 자리가 함수로 바뀐 것이다.

Collections.synchronizedMap은 개별 연산을 동기화하지만 순회는 동기화하지 않으므로 findAll에서 synchronized (store)로 감싸 복사한다(컬렉션 레슨의 ConcurrentModificationException 참고).

3.7: OrderService.java — Optional과 보상 처리

java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/** 주문 유스케이스. 저장소는 제네릭 타입으로, 할인은 함수로 주입받는다. */
public class OrderService {
    public record ItemRequest(Long productId, int quantity) {}

    private final Repository<Member, Long> members;
    private final Repository<Product, Long> products;
    private final Repository<Order, String> orders;
    private final Inventory inventory;
    private final DiscountPolicy discountPolicy;
    private final AtomicInteger seq = new AtomicInteger();       // 다중 스레드 채번

    public OrderService(Repository<Member, Long> members, Repository<Product, Long> products,
                        Repository<Order, String> orders, Inventory inventory, DiscountPolicy discountPolicy) {
        this.members = members;
        this.products = products;
        this.orders = orders;
        this.inventory = inventory;
        this.discountPolicy = discountPolicy;
    }

    public Order createOrder(Long memberId, List<ItemRequest> items, LocalDate date) {
        if (items.isEmpty()) throw new ValidationException("주문 항목이 비어 있습니다");
        Member member = members.findById(memberId)
                .orElseThrow(() -> new NotFoundException("회원", memberId));   // null 검사 대신 한 줄

        // 재고 예약: 실패하면 이미 예약한 것을 되돌린다 (보상 처리)
        List<OrderLine> lines = new ArrayList<>();
        for (ItemRequest item : items) {
            Product product = products.findById(item.productId())
                    .orElseThrow(() -> new NotFoundException("상품", item.productId()));
            if (!inventory.tryReserve(product.id(), item.quantity())) {
                for (OrderLine done : lines) inventory.release(done.product().id(), done.quantity());
                throw new OutOfStockException(product.name(), inventory.get(product.id()), item.quantity());
            }
            lines.add(new OrderLine(product, item.quantity()));
        }

        Money subtotal = lines.stream().map(OrderLine::lineTotal).reduce(Money.ZERO, Money::plus);
        Money discount = discountPolicy.discountFor(member, subtotal);
        String id = String.format("O-%04d", seq.incrementAndGet());
        return orders.save(new Order(id, member, lines, date, discount));
    }

    /** Optional 체인: 주문 → 회원 → 이름. 어느 단계가 비어도 NPE 없이 기본값으로. */
    public String memberNameOf(String orderId) {
        return orders.findById(orderId)
                .map(Order::member)
                .flatMap(m -> members.findById(m.id()))     // 저장소에서 다시 조회 (Optional 반환 → flatMap)
                .map(Member::name)
                .orElse("(알 수 없음)");
    }
}

2단계는 "전부 검증 → 전부 차감" 두 루프였다. 동시성 환경에서는 검증과 차감 사이에 다른 스레드가 끼어들 수 있으므로 그 전략이 통하지 않는다. 3단계는 항목마다 원자적으로 예약하고, 중간에 실패하면 앞서 예약한 것을 되돌린다(release). 이것이 보상 트랜잭션(compensating transaction)이고, 4단계의 롤백 개념으로 이어진다.

memberNameOf의 .map(Order::member)는 Optional<Order> → Optional<Member>, .flatMap(m -> members.findById(...))는 함수가 Optional을 돌려주므로 중첩을 피하기 위해 flatMap. map을 썼다면 Optional<Optional<Member>>가 됐을 것이다.

3.8: StatisticsService.java — 스트림 집계

java
import java.time.YearMonth;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;

/** 통계. 2단계의 for + HashMap.merge 조합을 스트림 파이프라인으로. */
public class StatisticsService {
    private final Repository<Order, String> orders;

    public StatisticsService(Repository<Order, String> orders) { this.orders = orders; }

    /** 회원별 매출 상위 N. groupingBy → 정렬 → limit. */
    public Map<Member, Money> topMembers(int n) {
        return orders.findAll().stream()
                .collect(Collectors.groupingBy(Order::member,
                        Collectors.reducing(Money.ZERO, Order::total, Money::plus)))
                .entrySet().stream()
                .sorted(Map.Entry.<Member, Money>comparingByValue(Comparator.reverseOrder())
                        .thenComparing(e -> e.getKey().id()))                 // 동률이면 ID 순 (결정적 출력)
                .limit(n)
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                        (a, b) -> a, LinkedHashMap::new));                    // 순서 유지
    }

    /** 카테고리별 매출·수량. 주문 → 주문줄로 펼친 뒤(flatMap) 집계. */
    public Map<String, CategoryStat> salesByCategory() {
        return orders.findAll().stream()
                .flatMap(o -> o.lines().stream())
                .collect(Collectors.groupingBy(l -> l.product().category(), TreeMap::new,
                        Collectors.teeing(
                                Collectors.reducing(Money.ZERO, OrderLine::lineTotal, Money::plus),
                                Collectors.summingInt(OrderLine::quantity),
                                CategoryStat::new)));
    }

    public record CategoryStat(Money sales, int quantity) {}

    /** 월별 매출 (TreeMap → 월 순서). */
    public Map<YearMonth, Money> monthlySales() {
        return orders.findAll().stream()
                .collect(Collectors.groupingBy(Order::month, TreeMap::new,
                        Collectors.reducing(Money.ZERO, Order::total, Money::plus)));
    }

    /** 가장 많이 팔린 상품 (수량 기준). 없으면 Optional.empty. */
    public Optional<Map.Entry<Product, Integer>> bestSeller() {
        return orders.findAll().stream()
                .flatMap(o -> o.lines().stream())
                .collect(Collectors.groupingBy(OrderLine::product, Collectors.summingInt(OrderLine::quantity)))
                .entrySet().stream()
                .max(Map.Entry.<Product, Integer>comparingByValue()
                        .thenComparing(e -> e.getKey().id(), Comparator.reverseOrder()));
    }

    public Money totalSales() {
        return orders.findAll().stream().map(Order::total).reduce(Money.ZERO, Money::plus);
    }

    public List<Order> ordersOver(Money threshold) {
        return orders.findAll().stream()
                .filter(o -> !o.total().isLessThan(threshold))
                .sorted(Comparator.comparing(Order::total).reversed())
                .toList();
    }
}
메서드 파이프라인 2단계였다면
topMembers groupingBy(회원, 합계) → 값 내림차순 정렬 → limit(n) → LinkedHashMap for+merge, 그다음 entry 리스트를 만들어 sort, 그다음 앞에서 n개 자르기
salesByCategory flatMap으로 주문줄 펼치기 → groupingBy(카테고리, teeing(합계, 수량)) 이중 for, Map 두 개(합계용, 수량용)
monthlySales groupingBy(YearMonth, TreeMap, 합계) for+merge with TreeMap
bestSeller groupingBy(상품, 수량 합) → max for+merge, 그다음 최대 찾기 루프

teeing(JDK 12)은 한 번의 순회로 두 가지 집계(합계와 수량)를 하고 결과를 합치는 컬렉터다. groupingBy의 결과 맵을 TreeMap::new로 지정하면 키 정렬이 공짜다. topMembers의 thenComparing(e -> e.getKey().id())는 매출이 같은 회원이 있을 때 출력 순서를 고정하기 위한 것이다 — 스트림 결과가 실행마다 달라지면 안 된다.

3.9: ConcurrentOrderProcessor.java — 스레드 풀

java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 다중 주문 동시 처리. 스레드 풀에 주문 작업을 제출하고 Future로 결과를 모은다.
 * 종료는 shutdown + awaitTermination으로 확정적으로 끝낸다.
 */
public class ConcurrentOrderProcessor {
    public record Summary(int approved, int outOfStock, int declined, long elapsedMs) {}

    private final OrderService orderService;
    private final PaymentGateway gateway;
    private final int threads;

    public ConcurrentOrderProcessor(OrderService orderService, PaymentGateway gateway, int threads) {
        this.orderService = orderService;
        this.gateway = gateway;
        this.threads = threads;
    }

    public Summary process(List<OrderRequest> requests) throws InterruptedException {
        AtomicInteger approved = new AtomicInteger();
        AtomicInteger outOfStock = new AtomicInteger();
        AtomicInteger declined = new AtomicInteger();

        ExecutorService pool = Executors.newFixedThreadPool(threads);
        long t0 = System.nanoTime();
        try {
            List<Future<?>> futures = new ArrayList<>();
            for (OrderRequest req : requests) {
                futures.add(pool.submit(() -> handle(req, approved, outOfStock, declined)));
            }
            for (Future<?> f : futures) {                   // 모든 작업이 끝날 때까지 기다린다
                try {
                    f.get();
                } catch (java.util.concurrent.ExecutionException e) {
                    throw new IllegalStateException("주문 작업 실패", e.getCause());
                }
            }
        } finally {
            pool.shutdown();
            if (!pool.awaitTermination(10, TimeUnit.SECONDS)) pool.shutdownNow();
        }
        long elapsed = (System.nanoTime() - t0) / 1_000_000;
        return new Summary(approved.get(), outOfStock.get(), declined.get(), elapsed);
    }

    private void handle(OrderRequest req, AtomicInteger approved, AtomicInteger outOfStock, AtomicInteger declined) {
        Order order;
        try {
            order = orderService.createOrder(req.memberId(), req.items(), req.date());
        } catch (OutOfStockException e) {
            outOfStock.incrementAndGet();
            return;
        }
        PaymentResult result = gateway.charge(req.type(), order.total());
        switch (result) {                                   // sealed → 두 경우만 있으면 default 불필요
            case PaymentResult.Approved a -> approved.incrementAndGet();
            case PaymentResult.Declined d -> declined.incrementAndGet();
        }
    }

    public record OrderRequest(Long memberId, List<OrderService.ItemRequest> items, LocalDate date, PaymentGateway.Type type) {}
}

세 가지가 확정적 종료를 보장한다. (1) 모든 Future.get()으로 작업 완료를 기다림, (2) finally에서 shutdown() — 새 작업 거부, (3) awaitTermination — 남은 작업 종료 대기. 이 셋이 없으면 풀의 워커 스레드가 살아 있어 main이 끝나도 JVM이 종료되지 않는다.

카운터는 int가 아니라 AtomicInteger다 — count++는 읽기·더하기·쓰기 세 동작이라 스레드 간 갱신이 유실된다.

3.10: Main.java — 시나리오

java
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * 3단계: 제네릭 Repository / 스트림 통계 / Optional / 람다 할인 정책 / ExecutorService / record·sealed.
 *   javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main
 */
public class Main {

    public static void main(String[] args) throws Exception {
        // ---- 조립: 저장소 3개가 같은 클래스의 인스턴스다 ----
        Repository<Member, Long> members = new InMemoryRepository<>(Member::id);
        Repository<Product, Long> products = new InMemoryRepository<>(Product::id);
        Repository<Order, String> orders = new InMemoryRepository<>(Order::id);
        Inventory inventory = new Inventory();

        // 할인 정책을 람다 조합으로: VIP 10% + (5만 원 이상이면 3,000원)
        DiscountPolicy policy = DiscountPolicy.rate(10).onlyIf(Member::isVip)
                .plus(DiscountPolicy.fixed(Money.won(3_000)).minSubtotal(Money.won(50_000)));

        OrderService service = new OrderService(members, products, orders, inventory, policy);
        StatisticsService stats = new StatisticsService(orders);

        seed(members, products, inventory);
        System.out.println("=== 1. 기준 데이터 ===");
        System.out.println("회원 " + members.count() + "명, 상품 " + products.count() + "개");

        System.out.println();
        System.out.println("=== 2. 주문 생성 (할인 정책 적용) ===");
        createSampleOrders(service);
        orders.findAll().forEach(System.out::println);

        System.out.println();
        System.out.println("=== 3. Optional 조회 체인 ===");
        System.out.println("O-0001의 회원: " + service.memberNameOf("O-0001"));
        System.out.println("O-9999의 회원: " + service.memberNameOf("O-9999"));
        String cheapest = products.findById(105L).map(Product::name).orElse("없음");
        String missing = products.findById(999L).map(Product::name).orElse("없음");
        System.out.println("105 → " + cheapest + ", 999 → " + missing);
        products.findById(106L).ifPresent(p -> System.out.println("106 있음: " + p.name() + " " + p.price()));

        System.out.println();
        System.out.println("=== 4. 스트림 통계 ===");
        System.out.println("총 매출: " + stats.totalSales());
        System.out.println("-- 회원별 매출 상위 3 --");
        stats.topMembers(3).forEach((m, sales) ->
                System.out.println("  " + m.name() + " [" + m.grade() + "] " + sales));
        System.out.println("-- 카테고리별 --");
        stats.salesByCategory().forEach((cat, s) ->
                System.out.println("  " + cat + ": " + s.sales() + " (" + s.quantity() + "개)"));
        System.out.println("-- 월별 --");
        stats.monthlySales().forEach((ym, sales) -> System.out.println("  " + ym + ": " + sales));
        stats.bestSeller().ifPresent(e ->
                System.out.println("베스트셀러: " + e.getKey().name() + " " + e.getValue() + "개"));
        System.out.println("-- 10만 원 이상 주문 --");
        stats.ordersOver(Money.won(100_000)).forEach(o -> System.out.println("  " + o));

        System.out.println();
        System.out.println("=== 5. 할인 정책 조합 비교 ===");
        Member vip = members.findById(1L).orElseThrow();
        Member basic = members.findById(2L).orElseThrow();
        Money subtotal = Money.won(80_000);
        Map<String, DiscountPolicy> policies = Map.of(
                "없음", DiscountPolicy.none(),
                "VIP 10%", DiscountPolicy.rate(10).onlyIf(Member::isVip),
                "5만 이상 3천", DiscountPolicy.fixed(Money.won(3_000)).minSubtotal(Money.won(50_000)),
                "조합", policy);
        for (String name : List.of("없음", "VIP 10%", "5만 이상 3천", "조합")) {
            DiscountPolicy p = policies.get(name);
            System.out.println("  " + name + ": VIP " + p.discountFor(vip, subtotal)
                    + " / BASIC " + p.discountFor(basic, subtotal));
        }

        System.out.println();
        System.out.println("=== 6. sealed 결제 결과 + 패턴 매칭 switch ===");
        PaymentGateway gateway = new PaymentGateway(0);
        for (Money amount : List.of(Money.won(120_000), Money.won(350_000), Money.ZERO)) {
            PaymentResult result = gateway.charge(PaymentGateway.Type.CARD, amount);
            String line = switch (result) {
                case PaymentResult.Approved a -> "승인 " + a.approvalCode() + " " + a.amount();
                case PaymentResult.Declined d -> "거절: " + d.reason();
            };
            System.out.println("  " + amount + " → " + line);
        }

        System.out.println();
        System.out.println("=== 7. 동시 주문 처리 (한정판 재고 60, 요청 100) ===");
        List<ConcurrentOrderProcessor.OrderRequest> requests = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            long memberId = (i % 6) + 1;
            requests.add(new ConcurrentOrderProcessor.OrderRequest(memberId,
                    List.of(new OrderService.ItemRequest(109L, 1)), LocalDate.of(2024, 3, 31), PaymentGateway.Type.CARD));
        }
        PaymentGateway slowGateway = new PaymentGateway(5);          // 건당 5ms 지연

        inventory.put(109L, 60);
        var seq = new ConcurrentOrderProcessor(service, slowGateway, 1).process(requests);
        System.out.println("스레드 1: 승인 " + seq.approved() + ", 재고부족 " + seq.outOfStock()
                + ", 거절 " + seq.declined() + ", 남은 재고 " + inventory.get(109L) + ", " + seq.elapsedMs() + "ms");

        inventory.put(109L, 60);
        var par = new ConcurrentOrderProcessor(service, slowGateway, 8).process(requests);
        System.out.println("스레드 8: 승인 " + par.approved() + ", 재고부족 " + par.outOfStock()
                + ", 거절 " + par.declined() + ", 남은 재고 " + inventory.get(109L) + ", " + par.elapsedMs() + "ms");
        System.out.println("총 주문 수: " + orders.count());
    }

    static void seed(Repository<Member, Long> members, Repository<Product, Long> products, Inventory inventory) {
        members.save(new Member(1L, "김철수", Member.Grade.VIP));
        members.save(new Member(2L, "이영희", Member.Grade.BASIC));
        members.save(new Member(3L, "박민수", Member.Grade.VIP));
        members.save(new Member(4L, "최지우", Member.Grade.BASIC));
        members.save(new Member(5L, "정수민", Member.Grade.BASIC));
        members.save(new Member(6L, "한도윤", Member.Grade.VIP));

        products.save(new Product(101L, "무선 마우스", "전자", Money.won(25_000)));
        products.save(new Product(102L, "USB-C 케이블", "전자", Money.won(8_000)));
        products.save(new Product(103L, "자바 입문서", "도서", Money.won(32_000)));
        products.save(new Product(104L, "텀블러", "생활", Money.won(15_000)));
        products.save(new Product(105L, "노트", "문구", Money.won(2_500)));
        products.save(new Product(106L, "기계식 키보드", "전자", Money.won(89_000)));
        products.save(new Product(107L, "알고리즘 도서", "도서", Money.won(38_000)));
        products.save(new Product(108L, "모니터 받침대", "생활", Money.won(27_000)));
        products.save(new Product(109L, "한정판 굿즈", "생활", Money.won(12_000)));
        for (long id = 101; id <= 108; id++) inventory.put(id, 1_000);
        inventory.put(109L, 60);
    }

    static void createSampleOrders(OrderService service) {
        // memberId, 날짜, (상품ID, 수량)...  — 12건
        order(service, 1L, "2024-01-05", 101, 1, 102, 2);
        order(service, 2L, "2024-01-09", 103, 1);
        order(service, 3L, "2024-01-15", 106, 1, 101, 1);
        order(service, 4L, "2024-01-28", 105, 10);
        order(service, 1L, "2024-02-02", 107, 2);
        order(service, 5L, "2024-02-11", 104, 2, 108, 1);
        order(service, 6L, "2024-02-14", 106, 2);
        order(service, 2L, "2024-02-20", 102, 3, 105, 4);
        order(service, 3L, "2024-03-03", 103, 1, 107, 1);
        order(service, 6L, "2024-03-08", 101, 2, 108, 2);
        order(service, 4L, "2024-03-19", 104, 1);
        order(service, 1L, "2024-03-25", 106, 1, 102, 1);

        try {
            order(service, 2L, "2024-03-30", 109, 61);          // 한정판 재고(60) 초과
        } catch (OutOfStockException e) {
            System.out.println("[OutOfStockException] " + e.getMessage());
        }
        try {
            order(service, 7L, "2024-03-30", 101, 1);           // 없는 회원
        } catch (NotFoundException e) {
            System.out.println("[NotFoundException] " + e.getMessage());
        }
    }

    static void order(OrderService service, Long memberId, String date, int... idQtyPairs) {
        List<OrderService.ItemRequest> items = new ArrayList<>();
        for (int i = 0; i < idQtyPairs.length; i += 2) {
            items.add(new OrderService.ItemRequest((long) idQtyPairs[i], idQtyPairs[i + 1]));
        }
        service.createOrder(memberId, items, LocalDate.parse(date));
    }
}
text
// 출력:
=== 1. 기준 데이터 ===
회원 6명, 상품 9개

=== 2. 주문 생성 (할인 정책 적용) ===
[OutOfStockException] 재고 부족: 한정판 굿즈 (재고 60, 요청 61)
[NotFoundException] 회원을(를) 찾을 수 없습니다: 7
O-0001 2024-01-05 김철수 36,900원 (할인 4,100원)
O-0002 2024-01-09 이영희 32,000원
O-0003 2024-01-15 박민수 99,600원 (할인 14,400원)
O-0004 2024-01-28 최지우 25,000원
O-0005 2024-02-02 김철수 65,400원 (할인 10,600원)
O-0006 2024-02-11 정수민 54,000원 (할인 3,000원)
O-0007 2024-02-14 한도윤 157,200원 (할인 20,800원)
O-0008 2024-02-20 이영희 34,000원
O-0009 2024-03-03 박민수 60,000원 (할인 10,000원)
O-0010 2024-03-08 한도윤 90,600원 (할인 13,400원)
O-0011 2024-03-19 최지우 15,000원
O-0012 2024-03-25 김철수 84,300원 (할인 12,700원)

=== 3. Optional 조회 체인 ===
O-0001의 회원: 김철수
O-9999의 회원: (알 수 없음)
105 → 노트, 999 → 없음
106 있음: 기계식 키보드 89,000원

=== 4. 스트림 통계 ===
총 매출: 754,000원
-- 회원별 매출 상위 3 --
  한도윤 [VIP] 247,800원
  김철수 [VIP] 186,600원
  박민수 [VIP] 159,600원
-- 카테고리별 --
  도서: 178,000원 (5개)
  문구: 35,000원 (14개)
  생활: 126,000원 (6개)
  전자: 504,000원 (14개)
-- 월별 --
  2024-01: 193,500원
  2024-02: 310,600원
  2024-03: 249,900원
베스트셀러: 노트 14개
-- 10만 원 이상 주문 --
  O-0007 2024-02-14 한도윤 157,200원 (할인 20,800원)

=== 5. 할인 정책 조합 비교 ===
  없음: VIP 0원 / BASIC 0원
  VIP 10%: VIP 8,000원 / BASIC 0원
  5만 이상 3천: VIP 3,000원 / BASIC 3,000원
  조합: VIP 11,000원 / BASIC 3,000원

=== 6. sealed 결제 결과 + 패턴 매칭 switch ===
  120,000원 → 승인 CARD-1001 120,000원
  350,000원 → 거절: 카드 1회 한도(300,000원) 초과
  0원 → 거절: 0원 결제 불가

=== 7. 동시 주문 처리 (한정판 재고 60, 요청 100) ===
스레드 1: 승인 60, 재고부족 40, 거절 0, 남은 재고 0, 667ms
스레드 8: 승인 60, 재고부족 40, 거절 0, 남은 재고 0, 332ms
총 주문 수: 132

마지막 두 줄의 ms 값은 환경에 따라 다르다. 나머지는 실행마다 같다.

구현 따라가기
  • 3.1: Money.java — record로
  • 3.2: 도메인 record — Member, Product, OrderLine, Order
  • 3.3: Inventory.java — 원자적 재고 예약
  • 3.4: DiscountPolicy.java — 람다로 만드는 정책
  • 3.5: PaymentResult.java, PaymentGateway.java
  • 3.6: Repository.java, InMemoryRepository.java — 제네릭 저장소
  • 3.7: OrderService.java — Optional과 보상 처리
  • 3.8: StatisticsService.java — 스트림 집계
  • 3.9: ConcurrentOrderProcessor.java — 스레드 풀
  • 3.10: Main.java — 시나리오
이전 섹션2 설계3 / 6다음 섹션4 실행 결과와 해설