==와 equals, 그리고 HashSeta == b ? false, a.equals(b) ? true
HashSet 크기: 2 (10,000원 두 개는 하나로)a와 b는 Money.won(10_000)을 두 번 호출한 서로 다른 객체라 ==는 false다. equals를 재정의했으므로 true. HashSet에 a, b, c를 넣으면 2개만 남는다 — hashCode가 같은 값을 돌려주고(Objects.hash(amount)) equals가 true이기 때문이다.
equals만 재정의하고 hashCode를 빼먹었다면 크기가 3이었을 것이다. 컬렉션 레슨의 규약이 실제로 작동하는 장면이다.
[ValidationException] 금액은 음수일 수 없습니다: -2000은 Money.minus가 아니라 생성자에서 던진 것이다. minus는 new Money(3000 - 5000)을 시도했을 뿐이다. 검증을 생성자 한 곳에 두면 모든 연산이 자동으로 보호된다.
| 출력 | 던진 곳 | 잡은 곳 |
|---|---|---|
[재고 부족] 자바 입문서 3/5 |
Product.ensureStock |
catch (OutOfStockException e) — 필드 3개를 직접 꺼냄 |
[NotFoundException] 회원을(를) 찾을 수 없습니다: 9 |
OrderService.createOrder |
catch (ShopException e) |
[NotFoundException] 상품을(를) 찾을 수 없습니다: 999 |
OrderService.createOrder 검증 루프 |
catch (ShopException e) |
[ValidationException] 수량은 1 이상이어야 합니다: 0 |
OrderLine 생성자 |
catch (ShopException e) |
tryCreate의 catch 순서에 주목하라. OutOfStockException을 먼저, ShopException을 나중에 잡는다. 순서를 바꾸면 컴파일 오류다(부모를 먼저 잡으면 자식 catch에 도달할 수 없다). 그리고 1단계에서는 실패했던 4번째 회원(최지우)이 O-0004를 만든다. LinkedHashMap에는 크기 제한이 없다.
999 실패 후 남은 재고: 무선 마우스=7은 1단계와 같은 값이다. ensureStock(검증)과 decreaseStock(차감)을 두 루프로 나눈 것이 여전히 "전부 아니면 전무"를 지키고 있다.
결제 성공: O-0001 → 카드(1234-****) 58,000원 (승인 CARD-1001)
결제 실패: O-0002 [InsufficientBalanceException] 계좌(110-222) 잔액 부족: 사용 가능 50,000원, 필요 77,000원
결제 실패: O-0002 [InsufficientBalanceException] 포인트(이영희) 잔액 부족: 사용 가능 12,000원, 필요 77,000원
결제 실패: O-0002 [InsufficientBalanceException] 카드(1234-****) 잔액 부족: 사용 가능 42,000원, 필요 77,000원tryPay(service, "O-0002", X)를 세 번 부르는데 X만 다르다. OrderService.pay는 한 줄도 안 바뀌었는데 메시지의 "계좌/포인트/카드"와 가용액이 달라진다. 각 구현체의 validate가 자기 기준으로 검사했기 때문이다. 세 번째 줄의 사용 가능 42,000원은 카드 한도 100,000 − 첫 결제 58,000이다. CardPayment.used가 상태를 기억하고 있다.
O-0002는 결국 결제되지 않아 CREATED로 남고, 5절에서 취소된다.
결제 실패: O-0003 [InsufficientBalanceException] 카드(1234-****) 잔액 부족: 사용 가능 17,000원, 필요 25,000원같은 O-0003을 카드로 두 번 결제하는 시나리오다. 기대한 실패는 Order.markPaid의 "결제할 수 없는 상태"였지만, 실제로는 카드 한도(17,000)가 먼저 걸렸다. pay의 순서가 method.pay(...) → order.markPaid(...)이기 때문이다.
이것은 버그다. 상태 검사를 결제보다 먼저 해야 한다 — 그렇지 않으면 이미 결제된 주문에 대해 카드가 한 번 더 긁힐 수 있다. 확장 과제 1에서 고친다. 실행 결과를 코드와 대조하며 읽을 때 이런 순서 문제가 보인다.
승인번호가 CARD-1001 → CARD-1002 → BANK-1003으로 이어진다. AbstractPaymentMethod.approvalSeq가 static이라 수단을 넘나들며 하나의 번호대를 쓴다.
회원별 매출: 김철수 58,000원 (포인트 5,580원)
회원별 매출: 최지우 25,000원 (포인트 3,250원)김철수 5,000 + 580(카드 1%), 최지우 3,000 + 250(계좌 1%). 포인트 결제가 성공한 사례는 없지만, 있었다면 earnsPoint()가 false라 적립이 없었을 것이다. OrderService.pay의 if (method.earnsPoint()) 분기가 그 자리다.
취소 전 재고: 2, 16
[ValidationException] 결제 완료된 주문은 취소할 수 없습니다: O-0001
취소 후 재고: 3, 19O-0002(자바 입문서 1, 텀블러 3)를 취소하니 103 재고 2→3, 104 재고 16→19. OrderService.cancel의 increaseStock 루프다.
O-0001은 PAID라 ValidationException. 상태 전이 규칙(R8)이 Order와 OrderService에 나뉘어 있는데, Order.cancel()은 "이미 취소됨"만, 서비스는 "결제됨"을 막는다. 규칙이 두 곳에 있는 것은 아쉽다 — 3단계에서 상태 전이를 정리한다.
1단계 printSalesByMember는 회원 × 주문 이중 루프였다. 2단계는 주문을 한 번 돌면서 salesByMember.merge(o.getMember(), o.total(), Money::plus)로 회원별 합계를 쌓고, 출력할 때 getOrDefault(m, Money.ZERO)로 꺼낸다.
키가 Member 객체인데도 동작하는 것은 Member.equals/hashCode를 ID 기준으로 재정의했기 때문이다. Money::plus는 메서드 참조 — 고급 레슨에서 배우지만 merge의 세 번째 인자로 자연스럽게 등장한다.
취소된 O-0002는 if (o.getStatus() != Order.Status.PAID) continue;로 빠지므로 총 매출은 58,000 + 25,000 + 25,000 = 108,000이다.
| 관점 | 1단계 | 2단계 | 효과 |
|---|---|---|---|
| 저장 | Member[3] + 카운터 |
LinkedHashMap<Long, Member> |
크기 제한 없음, 조회 O(1), 확장 코드 0줄 |
| 조회 실패 | return null → 호출자가 검사 |
throw new NotFoundException |
검사를 잊어도 조용히 지나가지 않음 |
| 실패 종류 | 문자열 메시지만 | 예외 타입 4종 + 부모 | 호출자가 종류별 처리 가능, 부가 정보 접근 |
| 주문 항목 | int[] × 2 (병렬 배열) |
List<OrderLine> |
항목 수 제한 없음, 동기화 문제 없음 |
| 합계 계산 | createOrder와 orderTotal에 중복 |
Order.total() 하나 |
공식 변경 시 한 곳 |
| 금액 | int + format() |
Money (불변, 검증, toString) |
음수 불가, 오버플로 완화, 서식 일관 |
| 결제 | 없음 | 인터페이스 + 구현 3개 | 수단 추가 시 서비스 무변경 |
| 재고 검증 | Main이 getStock() 비교 |
Product.ensureStock() |
검증 없는 차감 경로 제거 |
| 테스트 가능성 | static 배열 — 불가 | 생성자 주입 — 가짜 저장소 가능 | 단위 테스트 가능 |
| 코드량 | 4파일 ≈ 270줄 | 23파일 ≈ 625줄 | 2.3배. 파일 수 6배 |
마지막 행이 솔직한 대가다. 코드는 늘었다. 그러나 늘어난 코드의 대부분은 "규칙을 한 곳에 적은 것"이고, 1단계는 그 규칙이 Main의 if문 속에 흩어져 있거나 아예 없었다.