소스는 java-src/project/stage3/(19개 파일). 예외 4개(ShopException, ValidationException, NotFoundException, OutOfStockException)는 2단계와 같으므로 생략한다. InsufficientBalanceException은 사라졌다 — PaymentResult.Declined가 대신한다.
/**
* 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원 서식을 위해 남겼다.
/** 회원. 포인트 잔액은 별도 원장으로 뺐고(이 단계에서는 생략), 등급이 추가됐다. */
public record Member(Long id, String name, Grade grade) {
public enum Grade { BASIC, VIP }
public boolean isVip() { return grade == Grade.VIP; }
}/**
* 상품. 2단계와 달리 재고(stock)가 없다 — 재고는 여러 스레드가 동시에 바꾸는 값이라
* 불변 record에 둘 수 없고, Inventory가 따로 관리한다.
*/
public record Product(Long id, String name, String category, Money price) {}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); }
}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>의 타입 인자로 쓰기 위해서다. 제네릭은 원시 타입을 받지 못한다.
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]은 확정돼 있다.
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(...)과 같은 설계다.
/**
* 결제 결과. 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; }
}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"가 위험해지는 첫 사례다.
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();
}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 참고).
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>>가 됐을 것이다.
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())는 매출이 같은 회원이 있을 때 출력 순서를 고정하기 위한 것이다 — 스트림 결과가 실행마다 달라지면 안 된다.
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++는 읽기·더하기·쓰기 세 동작이라 스레드 간 갱신이 유실된다.
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));
}
}// 출력:
=== 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 값은 환경에 따라 다르다. 나머지는 실행마다 같다.