Skip to content

[volume-3] 도메인 주도 설계 구현 — 최숙희#128

Open
SukheeChoi wants to merge 23 commits intoLoopers-dev-lab:SukheeChoifrom
SukheeChoi:volume-3
Open

[volume-3] 도메인 주도 설계 구현 — 최숙희#128
SukheeChoi wants to merge 23 commits intoLoopers-dev-lab:SukheeChoifrom
SukheeChoi:volume-3

Conversation

@SukheeChoi
Copy link

Summary

  • Volume 1~2에서 구현한 Member 도메인 위에 Brand, Product, Like, Order 4개 도메인을 추가 구현
  • 레이어드 아키텍처 + DIP를 전 도메인에 일관 적용하고, "쓰기는 원칙, 읽기는 최적화" 기준으로 읽기/쓰기 전략을 분리
  • 도메인 단위 테스트 + Facade 테스트로 비즈니스 로직 정합성 검증, 전체 테스트 통과

Context & Decision

이번 과제를 설계하고 구현하면서 지킨 기준과 판단 이유를 정리한다.

1. 레이어별 역할 정의

레이어 역할 핵심 원칙
Domain 비즈니스 규칙 캡슐화, 자기 검증 다른 레이어에 의존하지 않는다
Application (Facade) 유스케이스 조율, 트랜잭션 경계 도메인을 조합할 뿐 비즈니스 판단을 하지 않는다
Infrastructure Domain Repository 인터페이스 구현 DIP — Domain이 정의하고 Infrastructure가 구현한다
Interfaces HTTP 요청/응답, DTO 변환 Facade에 위임만 한다

2. 클래스별 역할

분류 클래스 역할
Entity (Aggregate Root) Product, Brand, Order, Like, Member 식별자 + 상태 변화 + 연속성
Entity (내부) OrderItem Order Aggregate 내부. package-private 생성자로 외부 생성 차단
Value Object Price, Stock, LoginId, Password 불변 + 자기 검증 + 값 동등성. 비즈니스 규칙을 값 수준에서 강제
Repository Interface ProductRepository 등 (Domain) 도메인이 필요로 하는 저장/조회 계약
Repository Impl ProductRepositoryImpl 등 (Infra) JPA로 구현. soft delete 필터링, JOIN 쿼리 등
Facade ProductFacade, OrderFacade 여러 Repository 조합, 트랜잭션 관리

3. 핵심 설계 기준 — "쓰기는 원칙, 읽기는 최적화"

쓰기(CUD) — 각 Repository는 자기 도메인만 조회/변경한다.

Facade가 여러 Repository를 각각 호출하여 조합한다. Aggregate 간 느슨한 결합을 유지하고, 변경 영향 범위를 최소화한다.

// OrderFacade.createOrder() — 각 Repository를 독립적으로 호출
Product product = productRepository.findById(productId).orElseThrow(...);
product.decreaseStock(quantity);  // 도메인 로직은 Entity에 위임

Brand brand = brandRepository.findById(product.getBrandId()).orElse(null);
// Facade에서 조합하여 스냅샷 생성

읽기(R) — 성능이 필요하면 JOIN을 허용한다.

목록 조회처럼 N+1 비용이 큰 곳에서만 JOIN을 사용한다. JOIN이 허용되더라도 Repository 인터페이스에 명시하여 투명하게 관리한다.

// ProductRepository — 조회 전용 메서드
List<ProductWithBrand> findAllWithBrand();        // JOIN으로 N+1 방지
List<ProductWithBrand> findAllWithBrand(String sort);

이 기준을 적용한 판단:

조회 유형 전략 이유
상품 목록 조회 JOIN 유지 N개 상품 × 브랜드 조회 → N+1 발생. JOIN이 합리적
상품 단건 조회 Facade 조합 쿼리 2개면 충분. 원칙 적용 비용이 낮음

단건 조회는 쿼리 1→2개 증가뿐이고 실측 성능 차이가 무의미하다. 반면 목록 조회를 분리하면 N+1이 발생하거나 별도 IN 쿼리가 필요해져 코드가 복잡해진다. 비용이 낮은 곳부터 원칙을 적용했다.

4. 그 외 설계 결정

Aggregate 간 ID 참조

  • Product.brandId, Order.memberId, Like.productId — 객체 참조 없음
  • 이유: Brand가 변경/삭제되어도 Product 엔티티 구조에 영향 없음

Aggregate Root의 라이프사이클 통제

  • OrderOrderItem을 완전 통제 (CascadeType.ALL + orphanRemoval)
  • OrderItem 생성자를 package-private으로 제한 → 외부에서 직접 생성 불가
  • Order.create(ItemSnapshot...) 정적 팩토리로만 생성
  • 팩토리 메서드에서 "주문 항목 1개 이상" 불변식을 검증

DIP 실무 타협

  • @Entity는 Domain에서 사용 (테스트 가능성을 해치지 않으므로)
  • Repository 인터페이스는 Domain에, 구현체는 Infrastructure에 분리

VO 자기 검증 + 값 동등성

  • Price: 음수 방지, @EqualsAndHashCode로 값 동등성 보장
  • Stock.decrease(): 재고 부족 시 예외, 불변 — 새 인스턴스 반환
  • 비즈니스 규칙이 Facade가 아닌 Domain에 위치

삭제 정책

엔티티 방식 이유
Brand, Product, Order Soft Delete 주문 이력 보존
Like Hard Delete 삭제된 상품의 좋아요는 의미 없음
OrderItem 삭제 없음 Order와 생명주기 공유

주문 스냅샷

  • OrderItem에 productName, productPrice, brandName 저장
  • 원본 상품/브랜드가 변경되어도 주문 이력이 당시 기준으로 보존

Design Overview

변경 범위

  • 영향 받는 모듈: apps/commerce-api 전 레이어
  • 신규: Brand, Product, Like, Order 도메인 (Entity, VO, Repository, Facade, Controller, DTO)
  • 수정: MemberService → application 레이어로 이동, Example 패키지 제거
  • 테스트: 도메인 단위 테스트 + Facade 테스트 추가

레이어드 아키텍처

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
Loading
  • Domain은 다른 레이어에 의존하지 않는다
  • Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP)

클래스 다이어그램

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 : productId
Loading

Flow 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
Loading

상품 단건 조회 (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
Loading

상품 목록 조회 (JOIN)

sequenceDiagram
  autonumber
  participant Client
  participant Controller as ProductController
  participant Facade as ProductFacade
  participant ProductRepo as ProductRepository

  Client->>Controller: GET /api/v1/products
  Controller->>Facade: getAllProducts(sort)
  Facade->>ProductRepo: findAllWithBrand(sort)
  Note over ProductRepo: Product + Brand JOIN 쿼리 1개
  ProductRepo-->>Facade: List<ProductWithBrand>
  Facade-->>Controller: List<ProductWithBrand>
  Controller-->>Client: 200 OK
Loading

SukheeChoi and others added 23 commits February 2, 2026 23:40
- 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>
@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant