O-0001 2024-01-05 김철수 36,900원 (할인 4,100원)
O-0003 2024-01-15 박민수 99,600원 (할인 14,400원)
O-0006 2024-02-11 정수민 54,000원 (할인 3,000원)| 주문 | 회원 | 소계 | VIP 10% | 5만 이상 3천 | 합계 할인 |
|---|---|---|---|---|---|
| O-0001 | 김철수 VIP | 25,000 + 16,000 = 41,000 | 4,100 | 0 (41,000 < 50,000) | 4,100 |
| O-0003 | 박민수 VIP | 89,000 + 25,000 = 114,000 | 11,400 | 3,000 | 14,400 |
| O-0006 | 정수민 BASIC | 30,000 + 27,000 = 57,000 | 0 (BASIC) | 3,000 | 3,000 |
rate(10).onlyIf(Member::isVip).plus(fixed(3_000).minSubtotal(50_000))가 세 주문에서 각기 다른 조합으로 작동했다. 서비스 코드에는 if (member.isVip())가 한 줄도 없다 — 조건은 전부 정책 안에 있다.
주문 목록의 두 예외가 주문 목록보다 먼저 출력된 것은 createSampleOrders 안에서 잡혀서 즉시 출력되고, 목록은 그 뒤에 orders.findAll().forEach(...)로 찍히기 때문이다.
| 코드 | 결과 | 설명 |
|---|---|---|
memberNameOf("O-0001") |
김철수 |
findById → map → flatMap → map → orElse 전부 값 있음 |
memberNameOf("O-9999") |
(알 수 없음) |
첫 findById 가 empty → 체인이 empty 를 전달 → orElse 기본값 |
findById(105L).map(Product::name).orElse("없음") |
노트 |
값 → 이름 |
findById(106L).ifPresent(...) |
106 있음: ... |
있을 때만 실행. 없으면 조용히 지나감 |
2단계 OrderService의 if (member == null) throw ...가 .orElseThrow(() -> ...)로 바뀌었다. 검사를 잊을 수 없다 — Optional<Member>에서 Member를 꺼내려면 반드시 orElse* 계열을 거쳐야 하기 때문이다.
총 매출: 754,000원
-- 카테고리별 --
도서: 178,000원 (5개) 문구: 35,000원 (14개) 생활: 126,000원 (6개) 전자: 504,000원 (14개)카테고리 합계는 178,000 + 35,000 + 126,000 + 504,000 = 843,000인데 총 매출은 754,000이다. 차이 89,000은 할인 합계다.
salesByCategory는 OrderLine::lineTotal(할인 전 소계)로 집계하고, totalSales는 Order::total(할인 후)로 집계한다. 할인은 주문 단위라 어느 카테고리에 배분할지 정의가 없기 때문이다.
이런 "같아 보이는 두 숫자가 다른 이유"를 설명할 수 있어야 정산 리포트를 만들 자격이 있다 — 4단계의 주제다.
베스트셀러: 노트 14개는 O-0004(10) + O-0008(4). bestSeller의 max에 thenComparing(id, reverseOrder())를 붙인 이유는 동률일 때 결과가 흔들리지 않게 하기 위해서다.
월별 합계 193,500 + 310,600 + 249,900 = 754,000 = 총 매출. 월별은 Order::total로 집계하므로 일치한다.
조합: VIP 11,000원 / BASIC 3,000원같은 80,000원 소계에 VIP는 8,000 + 3,000, BASIC은 0 + 3,000. Map.of로 정책 네 개를 이름과 함께 담아 두고 루프로 비교했다 — 정책이 값이기 때문에 가능한 일이다. 2단계의 클래스 방식이었다면 인스턴스 네 개를 만들어야 했겠지만, 그 역시 값이긴 하다. 차이는 정책 하나를 정의하는 비용(클래스 15줄 vs 람다 1줄)이다.
350,000원 → 거절: 카드 1회 한도(300,000원) 초과switch (result)에 default가 없다. PaymentResult가 sealed이고 Approved, Declined만 허용하므로 컴파일러가 "모든 경우를 다뤘다"고 판단한다. 나중에 Pending을 추가하면 이 switch는 컴파일 오류가 된다 — 처리를 빼먹을 수 없다. 2단계의 catch (InsufficientBalanceException)은 빼먹어도 컴파일이 됐다.
스레드 1: 승인 60, 재고부족 40, 거절 0, 남은 재고 0, 667ms
스레드 8: 승인 60, 재고부족 40, 거절 0, 남은 재고 0, 332ms스레드 1개든 8개든 승인 60, 재고부족 40, 남은 재고 0이다. Inventory.tryReserve의 compute가 재고 확인과 차감을 원자화했기 때문이다. 만약 Inventory를 HashMap + "get 후 put"으로 짰다면 8스레드에서 승인이 60을 넘고 재고가 음수가 되는 실행이 나온다(확장 과제 3에서 재현).
시간은 8스레드가 빠르다. 승인된 60건만 게이트웨이(5ms 지연)를 호출하므로 순차는 60 × 5ms ≈ 300ms 이상, 병렬은 그것을 8로 나눈다. 수치는 OS 타이머 정밀도에 따라 다르지만 비율은 일관된다. 재고 부족 40건은 게이트웨이를 호출하지 않아 거의 즉시 끝난다.
총 주문 수: 132 = 샘플 12 + 순차 60 + 병렬 60. InMemoryRepository.save가 synchronizedMap 위에서 동작하므로 8스레드가 동시에 저장해도 유실이 없다. AtomicInteger seq로 채번한 주문 ID도 중복이 없다.
| 항목 | 2단계 | 3단계 |
|---|---|---|
| 저장소 코드 | 6파일 ≈ 60줄 | 2파일 ≈ 45줄, 엔티티가 늘어도 0줄 추가 |
Money |
50줄 | 22줄 |
| 회원별 상위 N | 없음 (있었다면 for+merge+sort ≈ 15줄) | 스트림 8줄 |
| 카테고리별 합계+수량 | Map 2개 + 이중 for ≈ 12줄 | teeing 6줄 |
| 할인 정책 추가 | 클래스 1개 (≈ 15줄) | 람다 1줄 |
| 조회 실패 처리 | if (x == null) throw 반복 |
orElseThrow 체인 |
| 동시 주문 100건 (5ms 지연) | 불가 (재고 오염) | 8스레드 ≈ 순차의 1/2~1/8 |
| 결제 결과 누락 | 런타임까지 모름 | 컴파일 오류 |
| 파일 수 / 줄 수 | 23 / ≈ 625 | 19 / ≈ 600 |
줄 수는 비슷하다. 늘어난 것은 통계·동시성·할인이라는 기능이고, 줄어든 것은 저장소·값 객체라는 보일러플레이트다.