fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/architecture/DDD와 도메인 모델링: 시니어 백엔드 관점…
architecture

DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드

DDD(Domain-Driven Design)는 "소프트웨어의 복잡성은 도메인 그 자체의 복잡성에서 나온다"는 전제에서 출발한다. 기술 스택을 아무리 정교하게 고르고 아키텍처 계층을 얼마나 깔끔하게 나누든, 비즈니스 규칙이 엉뚱한 곳에 흩뿌려져 있으면 결국 유지보수 비용이 폭증한다. DDD는 복잡한 도메인을 코드로 직접 표현해 변경 비용을 낮추는 데 목적이...

2026.09.03·13 min read·78 views

왜 DDD인가

DDD**(Domain-Driven Design)**는 "소프트웨어의 복잡성은 도메인 그 자체의 복잡성에서 나온다"는 전제에서 출발한다. 기술 스택을 아무리 정교하게 고르고 아키텍처 계층을 얼마나 깔끔하게 나누든, 비즈니스 규칙이 엉뚱한 곳에 흩뿌려져 있으면 결국 유지보수 비용이 폭증한다. DDD는 복잡한 도메인을 코드로 직접 표현해 변경 비용을 낮추는 데 목적이 있다.

Anemic Domain vs Rich Domain

Anemic domain 모델은 엔티티가 getter/setter와 필드만 가진 데이터 구조체에 불과하고, 실제 비즈니스 로직은 OrderService, OrderValidator, OrderHelper 같은 서비스 레이어에 흩어진다. 이 패턴의 문제는 다음과 같다.

  • 비즈니스 규칙이 어디 있는지 찾을 수 없다. order.cancel()이 있어야 할 자리에 OrderService.cancelOrder(order)가 있고, 동일한 취소 로직이 반환/환불/관리자-강제취소 세 군데에 중복된다.
  • 객체 자신이 불변식(invariant)을 지키지 못한다. order.setStatus(CANCELLED)를 누구든 호출할 수 있으니, 배송 완료 상태에서 취소되는 사고가 발생한다.
  • 테스트가 서비스 레이어에 몰리고, 순수 도메인 단위 테스트가 불가능해진다.

Rich domain 모델은 엔티티가 자기 책임을 스스로 진다.

java
// Anemic
public class Order {
    private OrderStatus status;
    public void setStatus(OrderStatus s) { this.status = s; }
}
public class OrderService {
    public void cancel(Order order) {
        if (order.getStatus() == DELIVERED) throw ...;
        order.setStatus(CANCELLED);
    }
}
 
// Rich
public class Order {
    private OrderStatus status;
    public void cancel() {
        if (status == DELIVERED)
            throw new OrderAlreadyDeliveredException(id);
        if (status == CANCELLED) return; // 멱등
        this.status = CANCELLED;
        registerEvent(new OrderCancelledEvent(id));
    }
}

변경 비용 관점에서 보면 Rich 모델은 "취소 규칙이 바뀌었다"는 요구에 한 메서드만 수정하면 된다. Anemic 모델은 규칙이 흩어져 있어 회귀가 난다.

전략 패턴 (Strategic DDD)

Bounded Context

Bounded Context는 특정 도메인 모델이 유효한 경계다. 같은 "상품"이라는 단어라도 주문 컨텍스트의 Product(가격, 할인 가능 여부)와 재고 컨텍스트의 Product(SKU, 창고별 수량)는 다른 모델이다. 하나로 합치면 Product 클래스에 필드 50개와 메서드 30개가 붙어 "신 객체"가 탄생한다.

경계를 정하는 힌트:

  • 팀 경계와 일치하는가 (Conway's Law)
  • 트랜잭션 경계가 자연스럽게 분리되는가
  • 동일 용어가 다른 의미로 쓰이는가

Ubiquitous Language

Bounded Context 내부에서는 기획자, 개발자, DBA가 같은 용어를 쓴다. 기획 문서에 "주문 취소"라고 쓰여 있으면 코드의 메서드명도 cancel()이어야지 updateStatus(4)가 되어서는 안 된다. 언어가 흔들리면 모델이 흔들린다.

Context Map

컨텍스트 간 관계를 매핑한다.

  • Upstream / Downstream: 데이터와 의미를 주는 쪽이 upstream, 받는 쪽이 downstream. 주문이 결제 upstream인지, 결제가 주문 upstream인지는 도메인 흐름에 달려 있다.
  • ACL(Anticorruption Layer): downstream이 upstream의 모델을 그대로 흡수하면 외부 모델 오염으로 내부 도메인이 망가진다. 변환 레이어를 두어 "우리 용어"로 번역한다. 레거시 연동에서 필수다.
  • OHS(Open Host Service): upstream이 다수 downstream에게 표준 API를 공개한다. REST/이벤트 스키마가 여기에 해당한다.
  • Conformist: downstream이 upstream 모델을 그대로 따라 쓴다. 보통 힘의 차이가 클 때(외부 벤더 API 등) 선택한다.

전술 패턴 (Tactical DDD)

Entity

식별자(ID)로 동일성을 판별하는 객체. Order, Member가 전형이다. 동일 ID면 필드가 달라도 같은 엔티티다.

Value Object (VO)

값 자체로 동일성이 결정되고 불변이다. Money, Address, DateRange.

java
@Embeddable
public class Money {
    @Column(name = "amount", precision = 19, scale = 4)
    private BigDecimal amount;
 
    @Column(name = "currency", length = 3)
    private String currency;
 
    protected Money() {}
 
    public Money(BigDecimal amount, String currency) {
        if (amount == null || amount.signum() < 0)
            throw new IllegalArgumentException("amount must be >= 0");
        this.amount = amount;
        this.currency = currency;
    }
 
    public Money add(Money other) {
        requireSameCurrency(other);
        return new Money(this.amount.add(other.amount), currency);
    }
 
    private void requireSameCurrency(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("currency mismatch");
    }
}

VO로 감싸는 이점은 무결성을 타입으로 보장한다는 점이다. 메서드 시그니처가 cancel(BigDecimal refund, String currency)가 아니라 cancel(Money refund)가 되면 파라미터 순서 실수가 원천 차단된다.

Aggregate / Aggregate Root

Aggregate는 한 트랜잭션에서 일관성을 함께 지켜야 할 엔티티 묶음이다. Aggregate Root를 통해서만 내부 엔티티에 접근한다. Order가 루트이고 OrderLine은 내부 엔티티다. 외부에서 OrderLine을 직접 저장/수정하면 Order의 총액 같은 불변식이 깨진다.

핵심 설계 원칙:

  1. 일관성 경계 == 트랜잭션 경계. Aggregate 하나가 한 트랜잭션에서 커밋된다. 여러 Aggregate를 한 트랜잭션에 묶으면 동시성 충돌이 급증한다.
  2. 작게 유지. Order가 수천 개의 OrderLine을 가지면 로딩만으로 DB가 죽는다. 배치 처리 전용 Aggregate를 따로 설계하거나 경계를 재고한다.
  3. 다른 Aggregate는 ID로 참조. Order가 Member 객체를 필드로 가지지 않고 MemberId만 가진다. 객체 그래프가 폭주하는 것을 막는다.
java
@Entity
@Table(name = "orders")
public class Order {
    @Id
    private Long id;
 
    @Embedded
    private MemberId memberId; // 다른 Aggregate는 ID 참조
 
    @OneToMany(
        mappedBy = "order",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    private List<OrderLine> lines = new ArrayList<>();
 
    @Embedded
    private Money totalAmount;
 
    private OrderStatus status;
 
    public static Order place(MemberId memberId, List<OrderLineCommand> commands) {
        Order order = new Order();
        order.memberId = memberId;
        order.status = OrderStatus.PLACED;
        commands.forEach(order::addLine);
        order.recalculateTotal();
        return order;
    }
 
    private void addLine(OrderLineCommand cmd) {
        this.lines.add(new OrderLine(this, cmd.productId(), cmd.quantity(), cmd.unitPrice()));
    }
 
    public void cancel() {
        if (status == OrderStatus.DELIVERED)
            throw new OrderAlreadyDeliveredException(id);
        this.status = OrderStatus.CANCELLED;
    }
 
    private void recalculateTotal() {
        this.totalAmount = lines.stream()
            .map(OrderLine::subtotal)
            .reduce(Money.ZERO_KRW, Money::add);
    }
}

Repository

Aggregate Root 단위로만 존재한다. OrderRepository는 있지만 OrderLineRepository는 없다. OrderLine은 Order를 통해서만 접근한다. 이 원칙 하나만 지켜도 아래의 "Repository에서 직접 필드 수정하기" 같은 안티패턴이 줄어든다.

Domain Service

엔티티 하나에 자연스럽게 속할 수 없는 도메인 로직은 Domain Service로 뺀다. 예: 환율 변환, 여러 Aggregate를 조회해 가격을 계산하는 규칙. 주의할 점은 Domain Service는 인프라(DB, 외부 API)가 아니다. 단지 "어느 엔티티에 붙이기 어색한 도메인 규칙"이다.

Domain Event

"무언가가 일어났다"를 표현하는 불변 객체. OrderPlacedEvent, PaymentCompletedEvent. 사이드 이펙트(메일 발송, 재고 차감, 적립금 부여)를 도메인에서 분리해내는 핵심 장치다.

JPA로 Aggregate 구현하기

JPA는 DDD를 강제하지 않지만, Aggregate 패턴과 궁합이 좋다.

cascade / orphanRemoval 선택 기준

Aggregate 내부 엔티티는 Root의 생명주기에 종속된다. 따라서:

  • cascade = CascadeType.ALL로 Root 저장 시 함께 저장
  • orphanRemoval = true로 컬렉션에서 제거된 자식은 DB에서도 삭제
java
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();

반대로 다른 Aggregate와의 관계에서는 cascade를 절대 걸지 않는다. Order가 Member에 cascade를 걸면 주문 삭제가 회원 삭제로 전파된다.

@OneToMany 선택 기준

단방향/양방향, EAGER/LAZY, @JoinColumn/@JoinTable 선택은 Aggregate 경계 안에서 이루어진다.

  • Aggregate 내부: 양방향 + LAZY가 기본. Root에서 자식을 조작하고 불변식을 검증하기 편하다.
  • 컬렉션이 크거나 페이징이 필요하면 Aggregate 경계를 다시 의심. 진짜 한 트랜잭션에서 전체를 로딩할 필요가 있는가.

@Embeddable로 VO 표현

테이블에 필드가 늘어나는 것을 두려워하지 말고, 도메인의 말을 타입으로 옮긴다.

java
@Embeddable
public class DateRange {
    private LocalDateTime start;
    private LocalDateTime end;
 
    public boolean contains(LocalDateTime t) {
        return !t.isBefore(start) && !t.isAfter(end);
    }
}
 
@Entity
public class Coupon {
    @Id private Long id;
    @Embedded private DateRange validPeriod;
 
    public boolean isUsableAt(LocalDateTime t) {
        return validPeriod.contains(t);
    }
}

Repository 인터페이스의 위치 — 의존성 역전

전형적인 3-layer 아키텍처에서는 Service → Repository → DB로 흐른다. DDD/헥사고날에서는 Repository 인터페이스를 도메인 레이어에 두고 구현체를 인프라 레이어에 둔다.

plaintext
domain/
  order/
    Order.java
    OrderRepository.java         // 인터페이스 (순수 도메인)
infrastructure/
  persistence/
    JpaOrderRepository.java      // Spring Data JPA 구현
    OrderRepositoryImpl.java     // 혹은 QueryDSL 조합

이유:

  • 도메인 레이어가 JPA, MyBatis 같은 구현 세부사항에 의존하지 않는다. 단위 테스트에서 인메모리 구현으로 대체 가능.
  • 저장소 기술 교체가 쉬워진다. MySQL에서 MongoDB로 옮기거나, 읽기 전용 캐시를 추가할 때 도메인 코드는 그대로다.
  • Spring Data JPA를 쓰면서도 도메인 레이어에 OrderRepository 인터페이스만 두고, 인프라 레이어에 interface JpaOrderRepository extends JpaRepository<...>, OrderRepository를 선언해 합치는 방식이 실무적이다.

Application Service vs Domain Service

구분Application ServiceDomain Service
역할유스케이스 조립순수 도메인 규칙
트랜잭션여기서 시작·종료없음
외부 의존DB, 이메일, 외부 API원칙적으로 없음
예OrderCancelService.cancel(orderId)DiscountPolicy.apply(order, coupon)

트랜잭션 경계는 Application Service에 둔다. @Transactional을 도메인 엔티티나 Domain Service에 붙이면 "이 도메인 규칙은 DB에 붙어 있다"는 선언이 되어 응집이 깨진다.

java
@Service
@RequiredArgsConstructor
public class CancelOrderApplicationService {
    private final OrderRepository orderRepository;
    private final RefundGateway refundGateway;
    private final ApplicationEventPublisher publisher;
 
    @Transactional
    public void cancel(OrderId id, String reason) {
        Order order = orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
        order.cancel(); // 도메인 규칙
        // 외부 결제 취소는 트랜잭션 커밋 후에
        publisher.publishEvent(new OrderCancelledEvent(id, reason));
    }
}

Domain Event 발행

방법 1 — Spring Data의 @DomainEvents

java
public class Order extends AbstractAggregateRoot<Order> {
    public void cancel() {
        // ...
        registerEvent(new OrderCancelledEvent(this.id));
    }
}

AbstractAggregateRoot를 상속하면 save() 시점에 등록된 이벤트가 자동 발행된다. 편하지만 Spring Data JPA에 종속된다.

방법 2 — ApplicationEventPublisher + @TransactionalEventListener

java
@Component
public class OrderCancelledHandler {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCancelledEvent event) {
        // 커밋 이후에만 실행 — 보상 처리가 더 단순해진다
    }
}

AFTER_COMMIT 페이즈가 중요하다. 주문 취소 트랜잭션이 롤백됐는데 환불 메일이 나가는 사고를 막는다. 반대로 이벤트 처리 실패가 본 트랜잭션에 영향을 주지 않음을 이해하고, 유실 대비 아웃박스 패턴을 병행한다.

커머스 예시로 Bounded Context 나누기

대규모 커머스 도메인을 다음과 같이 자른다.

  • Order Context: Order, OrderLine, OrderStatus. 주문 수명주기 관리.
  • Payment Context: Payment, PaymentMethod. PG 연동, 승인/취소.
  • Coupon Context: Coupon, CouponPolicy, IssuedCoupon. 발급, 소진, 검증.
  • Inventory Context: Stock, Reservation. 재고 차감/복구.

이들 사이의 관계:

  • Order → Payment: Order가 upstream. OrderId가 Payment 쪽 식별자에 참조된다. Payment는 Order 취소 이벤트를 구독해 환불을 트리거.
  • Order → Coupon: 주문 생성 시 Coupon Context에 "이 쿠폰 사용 가능?"을 질의하고, appliedCouponId만 Order에 저장. Coupon 객체 전체를 들고 오지 않는다.
  • Order ↔ Inventory: 주문 확정 시 재고 예약 이벤트 발행 → Inventory가 처리. 실패 시 보상 트랜잭션.
java
// Order Aggregate는 Coupon 객체를 모른다. ID만 안다.
@Entity
public class Order {
    @Embedded private CouponId appliedCouponId; // nullable
    @Embedded private Money discountAmount;     // 적용 시점에 계산된 금액만 저장
}

이 설계의 이점: 쿠폰 정책이 바뀌어도 과거 주문의 할인 금액은 변하지 않는다. "과거의 사실"을 불변으로 보존하는 것이 Aggregate 설계의 중요한 감각이다.

가벼운 CQRS 도입

조회 요구가 복잡해지면 명령(Command) 모델과 조회(Query) 모델을 분리한다. Event Sourcing까지 가지 않아도, Read 전용 DTO와 전용 쿼리만 분리해도 효과가 크다.

java
// 명령 측 — 도메인 엔티티
@Service
public class PlaceOrderService {
    @Transactional
    public OrderId place(PlaceOrderCommand cmd) { ... }
}
 
// 조회 측 — 도메인 엔티티를 거치지 않는 플랫한 DTO
public interface OrderQueryDao {
    List<OrderListItemDto> findRecentByMember(MemberId memberId, Pageable p);
    OrderDetailDto findDetail(OrderId id);
}

OrderDetailDto는 주문, 결제, 쿠폰, 배송 상태를 한 번의 조인 쿼리나 애플리케이션 조합으로 채운다. Aggregate를 억지로 로딩해 DTO로 변환하는 방식보다 훨씬 단순하고 빠르다. 쓰기 모델은 일관성을 위해, 읽기 모델은 성능을 위해 최적화한다는 관점의 분리다.

레거시에서 DDD로 점진 도입하기

"풀 리라이트는 실패한다." 현실적 전략은 다음과 같다.

  1. Seam 식별: 가장 변경 요구가 많고 복잡한 모듈(예: 주문 취소 플로우)을 먼저 고른다.
  2. ACL 설치: 해당 모듈 앞에 Anticorruption Layer를 둔다. 레거시의 OrderEntity를 새 도메인의 Order로 변환하는 번역 계층.
  3. 새 로직은 새 모델로: 새 기능은 신 도메인에서 구현. 기존 호출은 ACL을 통해 기존 모델과 호환 유지.
  4. Strangler Fig: 기능 단위로 신 모델이 레거시를 잠식하도록 점진 이동. 한꺼번에 자르지 않는다.
  5. 이벤트 도입: 느슨한 결합이 필요해진 지점부터 Domain Event를 도입. 동기 호출 사슬을 이벤트로 끊어 Bounded Context 간 독립성을 확보.

안티패턴 목록

  1. Service 레이어 비만: OrderService 2,000줄, 엔티티는 setter만. → Rich 모델로 로직 이동.
  2. Repository에서 비즈니스 검증: orderRepository.cancelOrderIfNotDelivered(id) 같은 메서드. Repository는 저장소 추상화이지 정책 판단자가 아니다.
  3. Setter 난사: public setter는 불변식을 깨는 최단 경로다. 상태 변경은 cancel(), approve() 같은 의도가 드러나는 메서드로.
  4. Aggregate 간 객체 참조: Order가 Member 엔티티를 직접 들고 있음 → 트랜잭션 경계 혼란, 대규모 객체 그래프 로딩. ID 참조로 바꾼다.
  5. EAGER 기본: @OneToMany(fetch = EAGER)는 Aggregate 경계를 의심하게 만든다. 기본은 LAZY, 필요한 유스케이스에서만 fetch join.
  6. 도메인에 Spring/JPA 어노테이션 범람: 도메인 엔티티에 @Transactional, @CachePut 등이 붙으면 응집이 깨진다. 이런 관심사는 Application 레이어로.

실무 경험의 DDD 재해석

다음과 같은 실무 경험은 DDD 용어로 자연스럽게 번역된다.

  • 엔진 추상화 / 전략 패턴 도입: 할인 정책·선정 알고리즘을 DiscountPolicy, SelectionStrategy 같은 Domain Service로 분리한 사례. OCP를 지키면서 정책 교체 비용을 낮춘 것은 전형적인 DDD식 리팩터링이다.
  • 내부 모듈 경계 분리: 서로 다른 팀/기능이 공유하던 단일 모듈을 기능 단위로 쪼갠 경험 → Bounded Context 분리. 공유되던 DTO를 각 컨텍스트 안에서 자체 모델로 재정의한 경험이 있다면 강력한 어필 포인트.
  • 공통 유틸에서 벗어나 도메인 중심으로 이동: CommonUtil.calculate(...)를 엔티티의 calculate() 메서드로 옮긴 경험 → Anemic에서 Rich로의 이동.

면접에서는 "전략 패턴을 적용했습니다"보다 "정책 객체를 Domain Service로 분리해 할인 규칙이 엔티티 상태와 분리되도록 설계했습니다"가 훨씬 강하게 들린다.

로컬 실습 환경

plaintext
project/
├── build.gradle
├── docker-compose.yml
└── src/main/java/com/example/shop/
    ├── order/
    │   ├── domain/
    │   │   ├── Order.java
    │   │   ├── OrderLine.java
    │   │   ├── OrderRepository.java
    │   │   └── event/OrderCancelledEvent.java
    │   ├── application/
    │   │   ├── PlaceOrderService.java
    │   │   └── CancelOrderService.java
    │   └── infrastructure/
    │       └── JpaOrderRepository.java
    └── coupon/
        └── ...

docker-compose로 MySQL 8을 띄운다.

yaml
version: "3.8"
services:
  mysql:
    image: mysql:8.0
    ports: ["3306:3306"]
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: shop

application.yml에서 hibernate.jdbc.batch_size=50, order_inserts=true, order_updates=true를 켠다. Aggregate 저장 시 자식 insert가 배치로 묶여 나가는지 show_sql 로그로 확인한다.

실행 가능한 실습 시나리오

  1. Order.place(...) 정적 팩토리로 주문을 생성하고, OrderLine 3개 중 1개를 컬렉션에서 제거해 orphanRemoval 동작을 확인한다.
  2. Order.cancel() 안에서 AbstractAggregateRoot.registerEvent를 호출하고, @TransactionalEventListener(AFTER_COMMIT)가 실제로 커밋 후에만 실행되는지 로그로 검증한다. 의도적으로 예외를 던져 롤백 시 이벤트가 발행되지 않는지 확인.
  3. OrderRepository 인터페이스를 도메인 패키지에 두고, 인프라 패키지의 JpaOrderRepository가 이를 extends하도록 구성. 도메인 코드에서 jakarta.persistence.* import가 하나도 없는 상태를 만든다.
  4. 같은 Money VO를 두 번 생성해 equals 결과를 검증. JPA 로딩 후에도 값이 같으면 동일 객체로 취급되는지 확인.
  5. Order가 Member 객체 대신 MemberId만 가지도록 리팩터링하고, N+1 문제가 사라지는 것을 SQL 로그로 확인.
  6. 조회 전용 OrderQueryDao를 별도로 만들어 주문 목록 화면 전용 쿼리를 작성. 명령 모델을 거치지 않는 것을 확인.

면접 답변 프레이밍

Q. 도메인 모델링은 어떻게 하시나요?

저는 도메인을 Bounded Context 단위로 먼저 나눕니다. 같은 "상품"이라도 주문과 재고에서 다른 책임을 지니기 때문에 하나의 모델로 합치지 않습니다. 컨텍스트 안에서는 Entity와 Value Object를 구분해, 식별자로 추적해야 할 것은 Entity, 값 자체가 의미인 것은 VO로 표현합니다. 비즈니스 규칙은 서비스가 아니라 엔티티 자신의 메서드로 두어 불변식이 깨지지 않도록 합니다. 예전에 쿠폰 정책 모듈을 리팩터링할 때 CouponService.validate(coupon, order)에 흩어진 규칙을 Coupon.isUsableFor(order)로 옮긴 적이 있는데, 이후 새 정책 추가 비용이 반으로 줄었습니다.

Q. Aggregate 경계는 어떻게 정하시나요?

핵심 질문은 "어떤 것들이 한 트랜잭션에서 함께 일관성을 지켜야 하는가"입니다. 주문과 주문 라인은 금액 합계 불변식이 있으니 한 Aggregate, 주문과 결제는 트랜잭션을 분리할 수 있고 실제로 분리해야 동시성이 확보되니 별도 Aggregate로 둡니다. Aggregate는 작게 유지하고, 다른 Aggregate는 객체가 아닌 ID로 참조합니다. 과거에 Order가 Member 엔티티를 직접 참조해 발생한 N+1과 캐스케이드 사고를 겪고 나서 이 원칙을 철저하게 지킵니다.

Q. 레거시 코드에 DDD를 어떻게 도입하시겠습니까?

풀 리라이트는 거의 실패하니, Strangler Fig 방식으로 접근합니다. 변경 빈도가 높은 모듈부터 식별해 ACL로 둘러싸고, 새 기능은 신 도메인 모델에서 구현합니다. 기존 호출부는 ACL을 통해 호환을 유지합니다. 이 과정에서 Bounded Context 경계를 처음에는 느슨하게 긋고, Ubiquitous Language가 안정된 다음에 점점 단단하게 굳혀갑니다.

체크리스트

  • 엔티티에 public setter가 없는가 (상태 변경은 의도가 드러나는 메서드로)
  • Aggregate Root만 Repository를 갖는가
  • 다른 Aggregate는 ID로만 참조하는가
  • 도메인 레이어에 JPA/Spring 어노테이션이 최소한으로만 사용되는가
  • 트랜잭션 경계가 Application Service에 있는가, 엔티티나 Domain Service에 분산되지 않았는가
  • VO는 불변이고 equals/hashCode가 값 기반인가
  • cascade와 orphanRemoval이 Aggregate 경계 안에서만 사용되는가
  • Domain Event가 AFTER_COMMIT 페이즈에서 발행되는가
  • 읽기 복잡도가 큰 화면은 별도 Query 모델을 갖는가
  • Ubiquitous Language가 코드, DB 컬럼명, API 스펙에 일관되게 반영되어 있는가
  • 레거시와 새 모델 사이에 ACL이 존재하는가
  • 면접에서 "Aggregate 경계를 어떻게 정하냐"에 30초 안에 구체 사례로 답할 수 있는가
on this page
  • 01왜 DDD인가
  • Anemic Domain vs Rich Domain
  • 02전략 패턴 **(Strategic DDD)**
  • Bounded Context
  • Ubiquitous Language
  • Context Map
  • 03전술 패턴 **(Tactical DDD)**
  • Entity
  • Value Object **(VO)**
  • Aggregate / Aggregate Root
  • Repository
  • Domain Service
  • Domain Event
  • 04JPA로 Aggregate 구현하기
  • cascade / orphanRemoval 선택 기준
  • @OneToMany 선택 기준
  • @Embeddable로 VO 표현
  • 05Repository 인터페이스의 위치 — 의존성 역전
  • 06Application Service vs Domain Service
  • 07Domain Event 발행
  • 방법 1 — Spring Data의 @DomainEvents
  • 방법 2 — ApplicationEventPublisher + @TransactionalEventListener
  • 08커머스 예시로 Bounded Context 나누기
  • 09가벼운 CQRS 도입
  • 10레거시에서 DDD로 점진 도입하기
  • 11안티패턴 목록
  • 12실무 경험의 DDD 재해석
  • 13로컬 실습 환경
  • 14실행 가능한 실습 시나리오
  • 15면접 답변 프레이밍
  • 16체크리스트
tags
#study

이런 글도

  • architecture

    코드 제거 작업의 grep 통과 조건과 문서 부패

    2026.10.02
  • architecture

    캐시 설계 전략 총정리

    2026.09.03
  • architecture

    분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가

    2026.09.03
  • architecture

    분산 트랜잭션

    2026.09.03

댓글 (0)