[volume-3] 도메인 주도 설계 구현 — 최숙희#128
[volume-3] 도메인 주도 설계 구현 — 최숙희#128SukheeChoi wants to merge 23 commits intoLoopers-dev-lab:SukheeChoifrom
Conversation
- MemberModel 엔티티 및 MemberRepository 인터페이스 추가 - MemberService: 로그인 ID 중복 검증, 비밀번호 규칙 검증, 암호화 저장 - MemberV1Controller: POST /api/v1/members API - PasswordEncoderConfig: BCrypt 설정 - ApiControllerAdvice: @Valid 검증 예외 핸들러 추가 - 단위 테스트 6개 추가 (Service 4개, Controller 2개) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- AuthMember 어노테이션 및 AuthMemberResolver 추가 - 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) - GET /api/v1/members/me API 추가 - 이름 마스킹 로직 (홍길동 → 홍길*) - ErrorType.UNAUTHORIZED 추가 - 단위 테스트 5개 추가 (Controller 3개, DTO 2개) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberModel에 changePassword() 메서드 추가 - MemberService에 비밀번호 변경 로직 구현 - 현재 비밀번호 검증 - 동일 비밀번호 사용 불가 - 비밀번호 규칙 검증 (8~16자, 영문/숫자/특수문자) - 생년월일 포함 불가 - MemberV1Controller에 PATCH /me/password 엔드포인트 추가 - CLAUDE.md를 .gitignore에 추가 (git 추적 제외) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberModel → Member 엔티티 리네이밍 (DDD 네이밍) - Value Object 도입: LoginId, Email, BirthDate, Password (@embeddable, 자가 검증) - Gender enum 추가 및 회원가입 시 성별 필수 처리 - PasswordPolicy 도메인 정책 분리 (순수 함수) - Service 얇은 조율 계층으로 리팩토링 (검증 로직 VO/Policy로 이동) - 포인트 조회 API 신규 구현 (GET /api/v1/points) - AuthMemberResolver 보안 에러 메시지 통일 - 단위 테스트 (LoginIdTest, EmailTest, BirthDateTest 등) - 통합 테스트 (MemberServiceIntegrationTest, @SpyBean) - E2E 테스트 (MemberV1ApiE2ETest, PointV1ApiE2ETest) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
DDD 리팩토링 + Value Object 도입 + 포인트 조회 구현
기능 요구사항에 해당하지 않는 Gender enum, 포인트 조회 API를 제거한다. Member 엔티티에서 gender/point 필드를 제거하고 관련 테스트를 정리한다. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 01-requirements.md: 액터 정의, 미결정 사항 섹션 추가 - 02-sequence-diagrams.md: 트랜잭션 경계 rect 블록, 읽는 법, 잠재 리스크 추가 - 03-class-diagram.md: 다이어그램 읽는 법, 잠재 리스크 추가 - 04-erd.md: 잠재 리스크 섹션 추가 브랜드/상품/좋아요/주문 도메인의 요구사항, 시퀀스, 클래스, ERD 설계 완료 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
판단 기준을 명확히 함: "주문 상세 화면을 독립적으로 렌더링할 수 있는가?" - 필수/권장/제외 항목 분류 및 근거 추가 - image_url 제외 이유 명시 (현재 상품 스펙에 없음, 오버엔지니어링 방지) - 트레이드오프 설명 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 주문 취소 API 명세 추가 (POST /api/v1/orders/{orderId}/cancel)
- 대고객 브랜드 목록 API 추가 (GET /api/v1/brands)
- order_item 삭제 정책 수정 (Order와 생명주기 공유)
- 시퀀스 다이어그램 URI prefix 통일 (/api-admin/v1)
- 상품 삭제 유스케이스 추가 (US-P05)
- 좋아요 목록 N+1 의도 명시
- 주문 취소 시 삭제된 상품 처리 리스크 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 주문 취소 API 제거 (요구사항에 없음) - US-O04 유스케이스 제거 - 시퀀스 다이어그램 8번 (주문 취소) 제거 - 대고객 브랜드 목록 API 제거 (요구사항에 없음) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 도메인 & 객체 설계 전략 (Entity/VO/Domain Service 구분 기준) - 아키텍처 & 패키지 전략 (DIP 실무 타협 기준, 의존 방향) - DIP 인사이트: 정석 vs 실무 타협 정리 (DDD 저자 명언 포함) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- domain/member/MemberService → application/member/MemberFacade - 레이어드 아키텍처 원칙에 맞게 유스케이스 조율을 Application Layer에서 담당 - Controller, 통합 테스트 import 경로 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Brand: Entity + CRUD (삭제 시 연관 상품 cascade soft delete) - Product: Entity + Price/Stock VO + CRUD (N+1 해결: JPQL JOIN) - Like: Entity (hard delete) + 좋아요 등록(멱등)/취소 - Order: Aggregate Root + OrderItem 스냅샷 + 재고 차감 - DIP 적용: Repository Interface(Domain) ← Impl(Infrastructure) - ProductWithBrand 조회 전용 모델로 읽기/쓰기 관심사 분리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- VO 테스트: Price, Stock (생성 검증, 비즈니스 규칙) - Entity 테스트: Brand, Product, Order, OrderItem, Like - Facade 테스트: Fake Repository 기반 순수 단위 테스트 - BrandFacadeTest, ProductFacadeTest, LikeFacadeTest, OrderFacadeTest - DIP 이점 활용: Spring 컨텍스트 없이 도메인 로직 검증 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
P0 요구사항 미구현 수정:
- 브랜드/상품 삭제 시 좋아요 hard delete 연쇄 처리
- 주문 취소 + 재고 복원 API 추가 (POST /orders/{id}/cancel)
- 좋아요 목록/주문 상세 조회 권한 검증 추가 (FORBIDDEN)
- 좋아요 취소 멱등성 보장 (예외 → 조기 리턴)
P1 설계 결함 수정:
- 상품 목록 정렬 지원 (latest/price_asc/likes_desc)
- findByIdWithBrand LEFT JOIN 조건 버그 수정
P2 테스트 품질 보강:
- Fake Repository soft delete 필터링 반영
- 주문 취소, 연쇄 삭제, 멱등성 등 누락 테스트 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Order.ItemSnapshot record 도입, Order.create()가 스냅샷을 받아 내부에서 OrderItem 생성 - OrderItem 생성자를 package-private로 변경하여 외부 패키지에서 직접 생성 차단 - OrderFacade는 더 이상 OrderItem을 직접 생성하지 않고 ItemSnapshot만 전달 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 레이어드 아키텍처 Mermaid 다이어그램 추가 (Facade별 책임 명시) - 전체 클래스 다이어그램 갱신 (ItemSnapshot, package-private 반영) - Aggregate 라이프사이클 통제 점검 결과 및 Entity vs VO 통제 기준 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
findByIdWithBrand() JOIN 쿼리를 제거하고, ProductFacade에서 Product와 Brand를 각각 조회 후 조합하도록 리팩토링. 목록 조회는 성능을 위해 기존 JOIN 방식 유지. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Order.create()에 빈 주문 방지 guard 추가 (도메인 불변식) - Price, Stock에 @EqualsAndHashCode 추가 (VO 값 동등성 보장) - OrderTest에 빈 항목/null 항목 테스트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
Context & Decision
이번 과제를 설계하고 구현하면서 지킨 기준과 판단 이유를 정리한다.
1. 레이어별 역할 정의
2. 클래스별 역할
Product,Brand,Order,Like,MemberOrderItemPrice,Stock,LoginId,Password등ProductRepository등 (Domain)ProductRepositoryImpl등 (Infra)ProductFacade,OrderFacade등3. 핵심 설계 기준 — "쓰기는 원칙, 읽기는 최적화"
쓰기(CUD) — 각 Repository는 자기 도메인만 조회/변경한다.
Facade가 여러 Repository를 각각 호출하여 조합한다. Aggregate 간 느슨한 결합을 유지하고, 변경 영향 범위를 최소화한다.
읽기(R) — 성능이 필요하면 JOIN을 허용한다.
목록 조회처럼 N+1 비용이 큰 곳에서만 JOIN을 사용한다. JOIN이 허용되더라도 Repository 인터페이스에 명시하여 투명하게 관리한다.
이 기준을 적용한 판단:
단건 조회는 쿼리 1→2개 증가뿐이고 실측 성능 차이가 무의미하다. 반면 목록 조회를 분리하면 N+1이 발생하거나 별도 IN 쿼리가 필요해져 코드가 복잡해진다. 비용이 낮은 곳부터 원칙을 적용했다.
4. 그 외 설계 결정
Aggregate 간 ID 참조
Product.brandId,Order.memberId,Like.productId— 객체 참조 없음Aggregate Root의 라이프사이클 통제
Order가OrderItem을 완전 통제 (CascadeType.ALL+orphanRemoval)OrderItem생성자를 package-private으로 제한 → 외부에서 직접 생성 불가Order.create(ItemSnapshot...)정적 팩토리로만 생성DIP 실무 타협
@Entity는 Domain에서 사용 (테스트 가능성을 해치지 않으므로)VO 자기 검증 + 값 동등성
Price: 음수 방지,@EqualsAndHashCode로 값 동등성 보장Stock.decrease(): 재고 부족 시 예외, 불변 — 새 인스턴스 반환삭제 정책
주문 스냅샷
productName,productPrice,brandName저장Design Overview
변경 범위
apps/commerce-api전 레이어레이어드 아키텍처
graph TB subgraph Interfaces ["Interfaces Layer — Controller, DTO"] BC["BrandController\nBrandAdminController"] PC["ProductController\nProductAdminController"] OC["OrderController\nOrderAdminController"] LC["LikeController"] MC["MemberV1Controller"] end subgraph Application ["Application Layer — Facade (유스케이스 조율, 트랜잭션)"] BF["BrandFacade\n· 브랜드 CRUD\n· 삭제 시 상품+좋아요 연쇄 처리"] PF["ProductFacade\n· 상품 CRUD + 정렬 조회\n· 삭제 시 좋아요 연쇄 처리"] OF["OrderFacade\n· 주문 생성 (재고 차감, 스냅샷)\n· 주문 취소 (재고 복원)\n· 권한 검증"] LF["LikeFacade\n· 좋아요 추가 (멱등)\n· 좋아요 취소 (멱등)\n· likeCount 동기화"] MF["MemberFacade\n· 회원가입\n· 비밀번호 변경"] end subgraph Domain ["Domain Layer — Entity, VO, Repository Interface"] direction LR BR["«interface»\nBrandRepository"] PR["«interface»\nProductRepository"] OR["«interface»\nOrderRepository"] LR2["«interface»\nLikeRepository"] MR["«interface»\nMemberRepository"] end subgraph Infrastructure ["Infrastructure Layer — Repository 구현체 (JPA)"] BRI["BrandRepositoryImpl\nBrandJpaRepository"] PRI["ProductRepositoryImpl\nProductJpaRepository"] ORI["OrderRepositoryImpl\nOrderJpaRepository"] LRI["LikeRepositoryImpl\nLikeJpaRepository"] MRI["MemberRepositoryImpl\nMemberJpaRepository"] end BC --> BF PC --> PF OC --> OF LC --> LF MC --> MF BF --> BR BF --> PR BF --> LR2 PF --> PR PF --> BR PF --> LR2 OF --> OR OF --> PR OF --> BR LF --> LR2 LF --> PR MF --> MR BRI -.->|implements| BR PRI -.->|implements| PR ORI -.->|implements| OR LRI -.->|implements| LR2 MRI -.->|implements| MR클래스 다이어그램
classDiagram direction TB %% ===== Brand Aggregate ===== class Brand { <<Aggregate Root>> -Long id -String name -String description +Brand(name, description) +changeName(name) +changeDescription(description) +delete() } %% ===== Product Aggregate ===== class Product { <<Aggregate Root>> -Long id -Long brandId -String name -Price price -Stock stock -int likeCount +Product(brandId, name, price, stock) +changeName(name) +changePrice(price) +changeStock(stock) +decreaseStock(quantity) +increaseStock(quantity) +incrementLikeCount() +decrementLikeCount() +delete() } class Price { <<Value Object>> -int value +Price(value) } class Stock { <<Value Object>> -int quantity +Stock(quantity) +decrease(amount) Stock +increase(amount) Stock +hasEnough(amount) boolean } Product *-- Price : contains Product *-- Stock : contains %% ===== Order Aggregate ===== class Order { <<Aggregate Root>> -Long id -Long memberId -OrderStatus status -int totalPrice -List~OrderItem~ items +create(memberId, List~ItemSnapshot~)$ Order +cancel() +getItems() List~OrderItem~ } class ItemSnapshot { <<Record>> +Long productId +String productName +int productPrice +String brandName +int quantity } class OrderItem { <<Entity · package-private constructor>> -Long id -Long productId -String productName -int productPrice -String brandName -int quantity ~OrderItem(productId, productName, productPrice, brandName, quantity) +getSubtotal() int } class OrderStatus { <<Enumeration>> CREATED PAID CANCELLED } Order *-- OrderItem : creates internally Order -- ItemSnapshot : receives as input Order --> OrderStatus : has %% ===== Like Aggregate ===== class Like { <<Aggregate Root>> -Long id -Long memberId -Long productId -ZonedDateTime createdAt +Like(memberId, productId) } %% ===== Member Aggregate ===== class Member { <<Aggregate Root>> -Long id -LoginId loginId -Password password -String name -BirthDate birthDate -Email email +Member(loginId, password, name, birthDate, email) +changePassword(newPassword) } class LoginId { <<Value Object>> -String value +LoginId(value) } class Password { <<Value Object>> -String encoded +create(plain, birthDate, encoder)$ Password +matches(plain, encoder) boolean } class Email { <<Value Object>> -String value +Email(value) } class BirthDate { <<Value Object>> -LocalDate value +from(dateString)$ BirthDate } Member *-- LoginId : contains Member *-- Password : contains Member *-- Email : contains Member *-- BirthDate : contains %% ===== Aggregate 간 ID 참조 ===== Product ..> Brand : brandId Order ..> Member : memberId OrderItem ..> Product : productId Like ..> Member : memberId Like ..> Product : productIdFlow Diagram
주문 생성
sequenceDiagram autonumber participant Client participant Controller as OrderController participant Facade as OrderFacade participant ProductRepo as ProductRepository participant BrandRepo as BrandRepository participant OrderRepo as OrderRepository Client->>Controller: POST /api/v1/orders Controller->>Facade: createOrder(memberId, items) Note over Facade: @Transactional 시작 loop 각 주문 상품 Facade->>ProductRepo: findById(productId) Note over Facade: product.decreaseStock(qty) end Facade->>BrandRepo: findAllByIds(brandIds) Note over Facade: 스냅샷 생성 (상품명, 가격, 브랜드명) Facade->>OrderRepo: save(Order.create(snapshots)) Note over Facade: @Transactional 종료 (실패 시 전체 롤백) Facade-->>Controller: Order Controller-->>Client: 201 Created상품 단건 조회 (Facade 조합)
sequenceDiagram autonumber participant Client participant Controller as ProductController participant Facade as ProductFacade participant ProductRepo as ProductRepository participant BrandRepo as BrandRepository Client->>Controller: GET /api/v1/products/{id} Controller->>Facade: getProductDetail(productId) Facade->>ProductRepo: findById(productId) Facade->>BrandRepo: findById(brandId) Note over Facade: ProductWithBrand 조합 Facade-->>Controller: ProductWithBrand Controller-->>Client: 200 OK상품 목록 조회 (JOIN)