┌───────────────────────────────┐
│ Main │
│ 조립 + 시나리오 + try/catch │
└──────────────┬────────────────┘
│ 호출
▼
┌────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────┐
│ <<interface>> │ │ OrderService │ │ <<interface>> │
│ MemberRepository │◀───│ createOrder(memberId, items)│───▶│ PaymentMethod │
│ ProductRepository │ │ pay(orderId, method) │ │ name() │
│ OrderRepository │ │ cancel(orderId) │ │ pay(Money): Receipt │
└─────────▲──────────┘ └──────────────┬───────────────┘ │ earnsPoint() default│
│ implements │ 사용 └──────────▲───────────┘
┌─────────┴──────────┐ ▼ │ implements
│ InMemory*Repository│ ┌──────────────────────────────┐ ┌──────────┴───────────┐
│ LinkedHashMap 기반 │ │ 도메인 │ │ AbstractPaymentMethod│
└────────────────────┘ │ Member ── Money point │ │ pay() final 템플릿 │
│ Product ── Money price,stock│ │ validate() abstract │
│ Order ── List<OrderLine> │ │ doPay() abstract │
│ ── Status enum │ └──────────▲───────────┘
│ OrderLine ── Product, qty │ │ extends
│ Money (불변 값 객체) │ ┌──────────┴───────────┐
└──────────────────────────────┘ │ CardPayment │
│ BankAccountPayment │
┌──────────────────────────────────────────────┐ │ PointPayment │
│ 예외 계층 │ └──────────────────────┘
│ RuntimeException │
│ └─ ShopException │
│ ├─ ValidationException │
│ ├─ NotFoundException │
│ ├─ OutOfStockException │
│ └─ InsufficientBalanceException │
└──────────────────────────────────────────────┘| 계층 | 클래스 | 책임 | 1단계에서 어디 있었나 |
|---|---|---|---|
| 값 객체 | Money |
금액의 표현·연산·검증·동등성 | int + Main.format() |
| 도메인 | Member, Product, OrderLine, Order |
자기 데이터의 규칙(재고 검증, 상태 전이, 합계) | Main의 검증 코드 |
| 예외 | ShopException 계층 |
실패의 종류와 정보 | println 문자열 |
| 저장소 | *Repository 인터페이스, InMemory*Repository |
저장·조회. 구현 교체 가능 | Main의 static 배열 |
| 결제 | PaymentMethod, AbstractPaymentMethod, 구현 3개 |
수단별 승인·차감 | 없었음 |
| 서비스 | OrderService |
유스케이스 흐름(검증 순서, 재고 차감 시점, 적립) | Main.createOrder() |
| 진입점 | Main |
조립, 시나리오, 예외 출력 | Main |
왜 이렇게 나눴는가. 기준은 "바뀌는 이유가 같은 것끼리 모은다"이다. 결제 수단이 추가되면 PaymentMethod 구현만 늘어난다. 저장소를 DB로 바꾸면 InMemory*만 바뀐다. 적립률이 바뀌면 OrderService.pay 한 줄이다. 1단계에서는 이 세 가지 변경이 전부 Main을 건드렸다.
| 층 | 역할 | 왜 필요한가 |
|---|---|---|
PaymentMethod (인터페이스) |
OrderService가 의존하는 계약. pay, name, earnsPoint |
서비스가 구체 클래스를 몰라야 수단 추가 시 서비스가 안 바뀐다 |
AbstractPaymentMethod (추상 클래스) |
세 수단의 공통 흐름: 0원 검사 → validate → doPay → 영수증 |
흐름을 한 곳에 두어 "검증을 빼먹은 결제 수단"이 생길 수 없게 |
CardPayment 등 (구체) |
validate(한도/잔액 검사)와 doPay(차감)만 구현 |
수단별 차이만 쓴다 |
AbstractPaymentMethod.pay는 final이다. 하위 클래스가 흐름 자체를 바꾸지 못하게 잠근 것이다. 이것이 템플릿 메서드 패턴이고, 중급 "추상 클래스 vs 인터페이스" 레슨의 실전 사례다. 인터페이스만 있었다면 세 구현체가 각각 0원 검사·승인번호 채번을 반복했을 것이다.
earnsPoint()는 인터페이스의 default 메서드다. 대부분 true이고 PointPayment만 false다. 추상 클래스에 두어도 되지만, "인터페이스만 구현한 제3의 결제 수단"도 기본값을 얻게 하려고 인터페이스에 두었다.
| 예외 | 언제 | 부가 정보 |
|---|---|---|
ValidationException |
수량 ≤ 0, 빈 주문, 음수 금액, 잘못된 상태 전이 | 메시지 |
NotFoundException |
ID로 찾았는데 없음 | 엔티티 종류, ID |
OutOfStockException |
재고 < 요청 | 상품명, 재고, 요청 수량 (필드로 노출) |
InsufficientBalanceException |
한도/잔액 < 결제액 | 수단명, 가용액, 필요액 |
전부 ShopException(→ RuntimeException)의 자식이다. unchecked로 둔 이유: 이 예외들은 "호출자가 반드시 복구해야 하는 상황"이 아니라 "요청이 잘못됐음"을 알리는 것이고, checked로 만들면 createOrder를 부르는 모든 메서드 시그니처에 throws가 전파된다.
호출자는 catch (OutOfStockException e)로 특정 실패만 잡거나, catch (ShopException e)로 도메인 실패 전체를 한 번에 잡는다.
OutOfStockException만 필드(productName, stock, requested)를 노출한다. 호출자가 "부족한 수량만큼 다른 창고에서 가져오기" 같은 후속 처리를 할 수 있게 하기 위해서다. 메시지 문자열을 파싱하게 만들면 안 된다.
OrderService ──의존──▶ MemberRepository (interface)
▲
│ implements
InMemoryMemberRepository (LinkedHashMap<Long, Member>)OrderService는 생성자로 세 저장소 인터페이스를 받는다(생성자 주입). Main이 new InMemoryMemberRepository()를 만들어 넣어 주므로, 나중에 JdbcMemberRepository를 만들면 Main 한 줄만 바뀐다. 테스트에서는 가짜 저장소를 넣을 수 있다 — 1단계의 static 배열로는 불가능했던 일이다.
HashMap 대신 LinkedHashMap을 쓴 이유는 findAll()이 등록 순서를 돌려주게 하기 위해서다. 출력 순서가 실행마다 바뀌면 시나리오를 따라가기 어렵다.