공공부하자개발 · 영어 학습 노트
자바
미니 프로젝트쇼핑몰 정산 시스템 4단계0/4 완료
  • 011단계: 콘솔 주문 관리 (초급 기술)
  • 022단계: 객체지향 리팩터링 (중급 기술)
  • 033단계: 모던 자바 적용 (고급 기술)
  • 044단계: 일마감 정산 배치 (배치 기술)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 미니 프로젝트 › 03 / 4

3단계: 모던 자바 적용 (고급 기술)

섹션 6진행 0 / 4
1이번 단계의 목표2설계3구현 따라가기4실행 결과와 해설5확장 과제6다음 단계로‹ 이전다음 ›

4. 실행 결과와 해설

4.1 할인이 붙은 주문 — 정책 조합의 결과

text
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(...)로 찍히기 때문이다.

4.2 Optional 체인 — 네 가지 형태

코드 결과 설명
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* 계열을 거쳐야 하기 때문이다.

4.3 스트림 통계 — 숫자 검산

text
총 매출: 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로 집계하므로 일치한다.

4.4 정책 비교표

text
  조합: VIP 11,000원 / BASIC 3,000원

같은 80,000원 소계에 VIP는 8,000 + 3,000, BASIC은 0 + 3,000. Map.of로 정책 네 개를 이름과 함께 담아 두고 루프로 비교했다 — 정책이 값이기 때문에 가능한 일이다. 2단계의 클래스 방식이었다면 인스턴스 네 개를 만들어야 했겠지만, 그 역시 값이긴 하다. 차이는 정책 하나를 정의하는 비용(클래스 15줄 vs 람다 1줄)이다.

4.5 sealed switch

text
  350,000원 → 거절: 카드 1회 한도(300,000원) 초과

switch (result)에 default가 없다. PaymentResult가 sealed이고 Approved, Declined만 허용하므로 컴파일러가 "모든 경우를 다뤘다"고 판단한다. 나중에 Pending을 추가하면 이 switch는 컴파일 오류가 된다 — 처리를 빼먹을 수 없다. 2단계의 catch (InsufficientBalanceException)은 빼먹어도 컴파일이 됐다.

4.6 동시 주문 — 정확히 60건

text
스레드 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도 중복이 없다.

4.7 성능·가독성 비교

항목 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

줄 수는 비슷하다. 늘어난 것은 통계·동시성·할인이라는 기능이고, 줄어든 것은 저장소·값 객체라는 보일러플레이트다.

실행 결과와 해설
  • 4.1 할인이 붙은 주문 — 정책 조합의 결과
  • 4.2 Optional 체인 — 네 가지 형태
  • 4.3 스트림 통계 — 숫자 검산
  • 4.4 정책 비교표
  • 4.5 sealed switch
  • 4.6 동시 주문 — 정확히 60건
  • 4.7 성능·가독성 비교
이전 섹션3 구현 따라가기4 / 6다음 섹션5 확장 과제