┌──────────────────────────────────────────────────────────────────────┐
│ Main: 조립 + 시나리오 │
│ Repository<Member,Long> = new InMemoryRepository<>(Member::id) │
│ DiscountPolicy policy = rate(10).onlyIf(Member::isVip) │
│ .plus(fixed(3,000).minSubtotal(50,000)) │
└───────┬──────────────────────┬───────────────────────┬───────────────┘
▼ ▼ ▼
┌────────────────┐ ┌───────────────────┐ ┌──────────────────────────┐
│ OrderService │ │ StatisticsService │ │ ConcurrentOrderProcessor │
│ createOrder() │ │ topMembers(n) │ │ ExecutorService(threads) │
│ memberNameOf() │ │ salesByCategory() │ │ Future 수집 → Summary │
│ (Optional 체인)│ │ monthlySales() │ │ shutdown+awaitTermination│
└──┬─────┬───┬───┘ │ bestSeller() │ └──────┬───────────┬───────┘
│ │ │ │ ordersOver(money) │ │ │
│ │ │ └─────────┬─────────┘ │ │
│ │ └──────────────┐ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────┐ ┌─────────────────────┐ ┌────────────┐ ┌────────────────┐
│ Inventory │ │ Repository<T, ID> │ │DiscountPolicy│ │ PaymentGateway │
│ ConcurrentHM │ │ save/findById(Opt) │ │ @Functional │ │ charge() → │
│ tryReserve() │ │ findAll/count │ │ rate/fixed │ │ PaymentResult │
│ = compute │ │ InMemoryRepository │ │ plus/onlyIf │ │ (sealed) │
└──────────────┘ │ <T,ID>(Function) │ │ minSubtotal │ │ Approved │
└─────────────────────┘ └────────────┘ │ Declined │
└────────────────┘
도메인 (전부 record):
Member(id, name, grade) Product(id, name, category, price)
OrderLine(product, quantity) Order(id, member, lines, orderedAt, discount)
Money(amount)| 2단계 | 3단계 | 이유 |
|---|---|---|
| 저장소 인터페이스 3개 + 구현 3개 (6파일) | Repository<T, ID> + InMemoryRepository<T, ID> (2파일) |
타입만 다른 중복 제거. ID 추출은 Function<T, ID> 주입 |
T findById(id) → null |
Optional<T> findById(id) |
"없을 수 있음"을 타입에 표시 |
Money final 클래스 50줄 |
record Money(long amount) 22줄 |
equals/hashCode/toString/접근자 자동 생성 |
Product.stock 필드 + decreaseStock |
Inventory 클래스 (ConcurrentHashMap) |
재고는 동시에 바뀌는 값 → 불변 record에서 분리, 원자적 갱신 |
Order.status 가변 + markPaid |
Order는 불변 record, 결제 결과는 PaymentResult 값 |
record는 상태 전이에 부적합. 결제 결과를 주문과 분리 |
InsufficientBalanceException |
PaymentResult.Declined(reason) |
흔한 결과는 값으로. sealed로 경우의 수 고정 |
| 할인 없음 | DiscountPolicy 함수형 인터페이스 |
람다 한 줄 정책 + default 메서드 조합 |
for + Map.merge |
스트림 collect(groupingBy(...)) |
선언적 집계, 정렬/제한 체인 |
| 단일 스레드 | ExecutorService + AtomicInteger |
동시 주문 처리 |
record는 불변이다. Product를 record로 만들면 stock을 바꿀 수 없다. 두 선택지가 있었다.
| 선택 | 방법 | 문제 |
|---|---|---|
| Product를 class로 유지 | synchronized decreaseStock |
상품마다 락. 동시성 규칙이 도메인 객체에 섞임 |
| 재고를 Inventory로 분리 | ConcurrentHashMap<Long, Integer>.compute |
상품은 순수 데이터, 재고 동시성은 한 클래스가 전담 |
후자를 골랐다. "동시에 바뀌는 상태"와 "바뀌지 않는 데이터"를 분리하면 불변인 쪽은 스레드 걱정 없이 공유할 수 있고, 가변인 쪽만 집중해서 보호하면 된다. 이 분리는 4단계 배치에서 "집계 상태"와 "주문 레코드"를 나누는 것과 같은 원리다.
스레드 A: tryReserve(109, 1) 스레드 B: tryReserve(109, 1)
│ │
├─ compute(109, fn) ── 락 획득 │
│ cur=1 ≥ 1 → return 0 ├─ compute(109, fn) ── 대기 ...
├─ 락 해제 → true │ 락 획득. cur=0 < 1 → return 0 (그대로)
│ ├─ 락 해제 → falseConcurrentHashMap.compute(key, fn)은 같은 키에 대한 fn 실행을 직렬화한다. fn 안에서 "현재값 확인 → 새 값 계산"을 하므로 두 스레드가 같은 재고를 동시에 보는 일이 없다. synchronized 블록을 직접 쓰지 않고도 check-then-act를 원자화하는 표준 기법이다.
PaymentResult (sealed)
├── Approved(approvalCode, amount) record
└── Declined(reason) record
switch (result) {
case Approved a -> ...
case Declined d -> ...
} ← default 없음. 세 번째 구현이 생기면 컴파일 오류로 알려 줌2단계의 PaymentMethod 인터페이스는 "누구나 구현 가능"이 장점이었다(상품권 추가). PaymentResult는 반대로 "승인/거절 둘뿐"임을 못 박는 것이 목적이다. 열어야 할 것과 닫아야 할 것이 다르다.