StatisticsService에 topProducts(int n)을 추가하라. 상품별 매출액(lineTotal 합계) 내림차순 상위 n개를 Map<Product, Money>(순서 유지)로 돌려준다. 동률이면 상품 ID 오름차순.
// StatisticsService에 추가
public Map<Product, Money> topProducts(int n) {
return orders.findAll().stream()
.flatMap(o -> o.lines().stream())
.collect(Collectors.groupingBy(OrderLine::product,
Collectors.reducing(Money.ZERO, OrderLine::lineTotal, Money::plus)))
.entrySet().stream()
.sorted(Map.Entry.<Product, Money>comparingByValue(Comparator.reverseOrder())
.thenComparing(e -> e.getKey().id()))
.limit(n)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
(a, b) -> a, LinkedHashMap::new));
}
// Main에서: stats.topProducts(3).forEach((p, s) -> System.out.println(" " + p.name() + " " + s));
// 출력 (샘플 12건 기준):
// 기계식 키보드 356,000원
// 알고리즘 도서 114,000원
// 무선 마우스 100,000원topMembers와 구조가 같다. flatMap 한 단계가 추가됐을 뿐이다. 두 메서드의 공통 부분("Map을 값 내림차순으로 정렬해 상위 n개")을 제네릭 메서드 <K> Map<K, Money> topN(Map<K, Money>, int, Comparator<K>)으로 뽑아낼 수도 있다.
Pending 추가PaymentResult에 Pending(String transactionId)를 추가하라(계좌 이체가 승인 대기인 경우). PaymentGateway.charge는 BANK 타입이고 금액이 100,000원 이상이면 Pending을 돌려준다. 그 후 컴파일해 보고, 어디에서 컴파일 오류가 나는지 확인한 뒤 고쳐라.
// PaymentResult.java
public sealed interface PaymentResult
permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Pending {
record Approved(String approvalCode, Money amount) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(String transactionId) implements PaymentResult {}
default boolean isApproved() { return this instanceof Approved; }
}
// PaymentGateway.charge에 추가 (0원/한도 검사 뒤):
if (type == Type.BANK && !amount.isLessThan(Money.won(100_000)))
return new PaymentResult.Pending("TX-" + seq.incrementAndGet());
// 컴파일 오류가 나는 곳 2군데 — "the switch expression does not cover all possible input values":
// 1) ConcurrentOrderProcessor.handle의 switch
// 2) Main 6절의 switch 표현식
// 각각 case 추가:
case PaymentResult.Pending p -> pending.incrementAndGet(); // Summary에 pending 필드 추가
case PaymentResult.Pending p -> "대기 " + p.transactionId();이것이 sealed의 가치다. 결과 종류가 늘었을 때 처리해야 할 모든 곳을 컴파일러가 찾아 준다. 예외 기반이었다면 catch를 빠뜨린 곳은 운영 중에 발견됐을 것이다.
Inventory와 같은 인터페이스를 갖되 내부가 HashMap + "get으로 확인 후 put으로 차감"인 UnsafeInventory를 만들어라. OrderService가 Inventory 대신 이것을 쓰게 하고(인터페이스로 추출하거나 상속), 8스레드 시나리오를 여러 번 돌려 승인 수가 60을 넘거나 재고가 음수가 되는 것을 관찰하라. 그 후 synchronized만으로 고쳐 보라.
// 1) 재현용 — 의도적으로 잘못된 구현
public class UnsafeInventory extends Inventory {
private final java.util.Map<Long, Integer> stock = new java.util.HashMap<>();
@Override public void put(Long id, int qty) { stock.put(id, qty); }
@Override public int get(Long id) { return stock.getOrDefault(id, 0); }
@Override
public boolean tryReserve(Long id, int qty) {
int cur = stock.getOrDefault(id, 0); // (1) 읽기
if (cur < qty) return false; // (2) 확인
Thread.yield(); // 경쟁을 잘 보이게 다른 스레드에 양보
stock.put(id, cur - qty); // (3) 쓰기 — (1)에서 읽은 값 기준. 그사이 남이 바꿨어도 모름
return true;
}
@Override public void release(Long id, int qty) { stock.merge(id, qty, Integer::sum); }
}
// 8스레드 실행 예 (실행마다 다름):
// 스레드 8: 승인 71, 재고부족 29, 거절 0, 남은 재고 -3, ...
// 2) synchronized로 고치기 — 메서드 전체를 하나의 임계 구역으로
@Override
public synchronized boolean tryReserve(Long id, int qty) {
int cur = stock.getOrDefault(id, 0);
if (cur < qty) return false;
stock.put(id, cur - qty);
return true;
}synchronized는 모든 상품의 예약을 하나의 락으로 직렬화하므로 정확하지만, 상품 A와 B의 주문이 서로를 기다린다. ConcurrentHashMap.compute는 키별로 직렬화하므로 다른 상품끼리는 병렬이다. 정확성은 같고 처리량이 다르다.