DDD(Domain-Driven Design , 도메인 주도 설계) 에 대해 알아보자 - 1

StrayCat·2026년 3월 17일

CS지식

목록 보기
19/32

1. 회고 — 내가 그동안 만들어온 코드의 구조

나는 지금가지 Jpa Entity << 테이블 발사대.. 정도로 알고 썼다. 진심이다.

롤로 비유하면 팀이 야스오를 쓰니까 말파이트를 픽해준다..는 느낌으로..

내 머리속에선 자연스럽게 Jpa사용 > Entity구조 > 테이블 매칭 정도였다.
일종의 정형화된 조합으로 받아들였다.

이것은 그로인해 생기는 한계점을 언젠가 마주할 것이고, 이를 어떻게 극복할 수 있는지에 대한 이야기다.

Spring + JPA로 프로젝트를 마치고 나면, 대부분의 코드는 아래 두 가지 형태 중 하나로 수렴한다.

장면 1 — Entity 하나가 세상의 중심이 된다

@Entity
public class User extends BaseEntity {

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();

    @OneToMany(mappedBy = "user")
    private List<Review> reviews = new ArrayList<>();

    @OneToMany(mappedBy = "user")
    private List<Cart> carts = new ArrayList<>();

    @OneToMany(mappedBy = "user")
    private List<UserAddress> addresses = new ArrayList<>();

    @OneToMany(mappedBy = "user")
    private List<Store> stores = new ArrayList<>();  // OWNER인 경우
}

User를 조회했을 뿐인데 Order, Review, Cart, UserAddress, Store가 줄줄이 딸려온다.
User가 이 모든 것을 "소유"해야 할 이유가 있는가?
리뷰 기능과 장바구니 기능은 서로 아무 관련이 없는데, User라는 하나의 Entity가 모든 것의 중심이 되어버린 것이다.


장면 2 — 주문 Service에 Repository가 잔뜩

@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;
    private final UserRepository userRepository;        // 왜 주문 서비스가 유저 레포를 알아야 하는가?
    private final StoreRepository storeRepository;      // 가게 레포도?
    private final ProductRepository productRepository;  // 상품 레포도?
    private final UserAddressRepository userAddressRepository;
    private final PaymentService paymentService;
}

주문 기능을 만들려는 것뿐인데, OrderService가 "모든 것을 아는 전지전능한 서비스"가 되어버렸다.


장면 3 — 괴물 메서드의 탄생

@Transactional
public CreateOrderResponse createOrder(UUID userId, CreateOrderRequest request) {
    // 유저 조회 → 가게 확인 → 주문번호 생성 → 금액 계산 → 배송지 확인
    // → 주문 생성 → 상품 조회 → 옵션 매핑 → 연관관계 설정 → 결제 생성
    // ... 이 모든 것이 하나의 메서드 안에 ...
}

동작은 한다. 하지만 이 메서드를 다시 읽어야 할 상황이 오면?

  • 금액 계산 방식이 바뀌면? → createOrder 내부를 전부 뒤져야 한다
  • 리뷰 서비스에서도 주문 상태를 확인하고 싶다면? → OrderRepository를 다른 Service에 주입하고 검증 로직을 또 작성한다 (중복)
  • 테스트를 작성하려면? → Mock 객체가 5~6개 필요하다. 테스트 코드가 본 코드보다 길어진다

2. 이것 또한 패턴이었다: 트랜잭션 스크립트 (Transaction Script)

이 방식에는 이름이 있다. Martin Fowler의 저서 Patterns of Enterprise Application Architecture에서 정의한 "트랜잭션 스크립트" 패턴이다.

하나의 요청(트랜잭션)을 처리하는 절차를 하나의 메서드에 스크립트처럼 순서대로 나열하는 방식

이 패턴 자체가 나쁜 것은 아니다. 실제 개발 환경에서도 간단한 CRUD 앱이나 복잡도가 낮은 기능에는 트랜잭션 스크립트가 오히려 적합하다. 빠르고 직관적이기 때문이다.

문제는 비즈니스가 복잡해지는 순간 이 패턴이 한계에 부딪힌다는 점이다.

함께 따라오는 문제가 하나 더 있다. 바로 빈혈 도메인 모델(Anemic Domain Model) 이다.

빈혈 도메인 모델이란?
Entity 객체가 getter/setter만 가득하고, 실제 비즈니스 로직은 전혀 없는 상태.
겉으로는 객체지향처럼 보이지만, 실제로는 그냥 데이터 덩어리(DTO)에 불과하다.
Martin Fowler는 이것을 안티패턴(anti-pattern, 피해야 할 설계 방식)으로 지목한다.

트랜잭션 스크립트와 빈혈 도메인 모델은 항상 함께 다닌다.
Entity는 데이터만 담고, 모든 로직은 Service에 몰려있는 구조가 바로 그것이다.


3. 왜 문제인가 — 한계가 드러나는 순간들

① "최소 주문 금액 검증 추가해 주세요"

"가게별로 최소 주문 금액이 있어요. 배달비 포함이고, 할인 전 금액 기준이에요."

createOrder 메서드 내부를 찾아서 if-else를 추가한다.
Store의 minimumOrderAmount와 deliveryFee를 꺼내와서 비교하는 로직이 또 들어간다.
createOrder는 점점 괴물 메서드가 된다.

② "주문 거절 시 결제 환불해 주세요"

환불 로직과 상태 변경 로직이 createOrder, cancelOrder, rejectOrder에 각각 흩어져있다.
이 규칙들이 정확히 대칭인지 어떻게 보장하는가?

③ "주문 금액 미리보기 만들어 주세요"

실제 주문 생성 없이 예상 금액만 보여주는 기능인데, 금액 계산 로직이 createOrder 안에 있으니 꺼낼 수가 없다. 결국 비슷한 코드를 복사하게 된다.


이것이 트랜잭션 스크립트의 근본적 문제다.

비즈니스 규칙이 "코드의 절차" 안에 녹아 있어서, 규칙만 따로 꺼내 쓸 수가 없다.


4. DDD는 어떻게 다른가

DDD(Domain-Driven Design, 도메인 주도 설계)는 Eric Evans의 저서 Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)에서 체계화된 방법론이다. 핵심 철학은 단순하다.

"비즈니스 로직은 그 책임을 가진 도메인 객체 안에 있어야 한다."

Step 1. 도메인 객체가 자신의 규칙을 스스로 지킨다

public class Product extends BaseEntity {

    private boolean isSoldOut;
    private boolean isHidden;

    // 상태 확인 — Product 스스로 자신의 상태를 알고 있다
    public boolean isSoldOut() { return this.isSoldOut; }
    public boolean isHidden()  { return this.isHidden; }

    // 상태 변경 — Product가 품절/해제를 직접 관리한다
    public void markSoldOut()  { this.isSoldOut = true; }
    public void markAvailable(){ this.isSoldOut = false; }
}
// Order는 생성 시점에 OrderItem을 함께 받아 불변식(Invariant)을 보장한다
// "주문 항목 없는 주문"은 존재할 수 없다
public class Order extends BaseEntity {

    // 정적 팩토리 메서드 — 비즈니스 의도를 메서드 이름으로 드러낸다
    public static Order create(User user, Store store,
                               UserAddress deliveryAddress,
                               String requestMemo,
                               List<OrderItem> items) {
        return new Order(user, store, deliveryAddress, requestMemo, items);
    }

    // 생성자를 private으로 잠가 유효성 검증 우회를 방지한다
    private Order(User user, Store store, UserAddress deliveryAddress,
                  String requestMemo, List<OrderItem> items) {
        validateAtLeastOneItem(items);          // 주문 항목 최소 1개 검증
        this.orderNo = generateOrderNo();       // 주문번호 생성
        this.status  = OrderStatus.PENDING_PAYMENT;
        bindItems(items);
        this.totalAmount = calculateTotal();    // 총액은 생성 시점에 자동 계산
    }

    // 상태 전이 — Order가 허용된 상태 변경만 받아들인다
    public void cancel(String reason) {
        validateStatus(
            OrderStatus.PENDING_PAYMENT,
            OrderStatus.PAID,
            OrderStatus.ACCEPTED
        );
        this.status = OrderStatus.CANCELED;
        this.canceledReason = reason;
    }

    private void validateStatus(OrderStatus... allowed) {
        for (OrderStatus s : allowed) {
            if (this.status == s) return;
        }
        throw new IllegalStateException("허용되지 않은 주문 상태 변경. current=" + this.status);
    }
}

왜 new Order()가 아니라 Order.create()인가?

new Order()는 그냥 "객체를 만든다"는 기술적 표현이다.
Order.create()는 "주문을 생성한다"는 비즈니스 의도가 메서드 이름에 드러난다.
생성자를 private으로 잠그면 create()를 통해서만 주문을 만들 수 있으므로 유효성 검증을 우회할 수 없다.
이 방식을 정적 팩토리 메서드(Static Factory Method) 패턴이라 부른다.


Step 2. 가게 도메인이 자신의 비즈니스 규칙을 지킨다

public class Store extends BaseEntity {

    private StoreStatus status;
    private Integer minimumOrderAmount;

    // 주문 가능 여부를 Store가 직접 판단한다
    // OrderService에 이 로직이 있으면 안 된다
    public void validateOrderable(Integer orderAmount) {
        if (this.status != StoreStatus.ACTIVE) {
            throw new StoreNotOperatingException();
        }
        if (orderAmount < this.minimumOrderAmount) {
            throw new InvalidMinimumOrderAmountException();
        }
    }
}

가게 운영 조건이 바뀌면? Store 클래스만 수정하면 된다. OrderService를 열 필요가 없다.


Step 3. Service는 "흐름의 조율자"가 된다

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final StoreService storeService;     // Repository가 아닌 Service를 통해 소통
    private final ProductService productService; // "남의 집 냉장고를 직접 열지 않는다"
    private final PaymentService paymentService;

    @Transactional
    public OrderResult createOrder(CreateOrderCommand command) {
        // CreateOrderCommand: Controller 소유의 Request DTO가 아닌,
        // Service 계층이 소유하는 Command 객체를 받는다 (계층 간 의존 방향 유지)

        // 1. 가게 검증 — StoreService에 위임
        Store store = storeService.getStore(command.storeId());

        // 2. 상품 목록 조회 — N번 단건 조회 대신 IN 쿼리 1번 (성능 고려)
        List<UUID> productIds = command.items().stream()
            .map(CreateOrderCommand.Item::productId)
            .toList();
        Map<UUID, Product> productMap = productService.getProductsByIds(productIds);

        // 3. 주문 항목 준비 — 상태 검증은 Product 객체가 직접 담당
        List<OrderItem> orderItems = command.items().stream()
            .map(item -> {
                Product product = productMap.get(item.productId());
                if (product.isSoldOut()) throw new IllegalStateException("품절 상품");
                if (product.isHidden())  throw new IllegalStateException("주문 불가 상품");
                return OrderItem.create(product, item.quantity());
            })
            .toList();

        // 4. 주문 생성 — 불변식(최소 1개 항목, 총액 계산)은 Order가 보장
        Order order = Order.create(
            command.userId(), store, command.requestMemo(), orderItems
        );

        // 5. 최소 주문 금액 검증 — Store가 스스로 검증
        store.validateOrderable(order.getTotalAmount());

        orderRepository.save(order);

        // 6. 결제 생성 — PaymentService에 위임
        paymentService.createPayment(
            CreatePaymentCommand.of(order, order.getTotalAmount())
        );

        return OrderResult.from(order); // Entity → Service 계층의 Result로 변환
    }
}

비교해보자. 각 줄이 무엇을 하는지 코드만 읽어도 이해된다.

  • 상품 상태 확인 → product.isSoldOut(), product.isHidden()
  • 주문 총액 계산 → Order.create() 시점에 자동 실행
  • 가게 최소 주문 금액 검증 → store.validateOrderable()

비즈니스 규칙이 각자의 집에 살고 있다.
더 이상 OrderService라는 원룸에 모든 규칙이 몰려 살지 않는다.


💡 왜 CreateOrderRequest가 아닌 CreateOrderCommand인가?

CreateOrderRequest는 Controller(표현 계층)가 소유하는 HTTP 요청 DTO이다.
Service가 이것을 직접 받으면 Service가 Controller에 의존하게 되어 레이어 간 의존 방향이 역전된다.
Controller에서 CreateOrderCommand.of(request)로 변환한 뒤 Service에 전달하면,
Service는 HTTP 요청이든 스케줄러든 이벤트 핸들러든 어디서나 재사용할 수 있다.


5. 핵심 개념 정리

DDD를 처음 접할 때 마주치는 용어들을 간략히 정리해두자.

개념설명
도메인 (Domain)소프트웨어가 해결하려는 비즈니스 문제 영역. (예: 주문, 결제, 배송)
유비쿼터스 언어 (Ubiquitous Language)개발자와 기획자가 함께 쓰는 공통 언어. 코드의 변수명, 메서드명이 이 언어로 작성된다.
엔티티 (Entity)고유 식별자(ID)를 가지는 도메인 객체. 속성이 바뀌어도 같은 객체다.
값 객체 (Value Object)식별자 없이 속성값만으로 동등성을 판단하는 불변(Immutable) 객체. (예: Money, Address)
애그리거트 (Aggregate)함께 관리되어야 하는 엔티티와 값 객체의 묶음. 경계 내 일관성을 보장한다.
애그리거트 루트 (Aggregate Root)애그리거트의 진입점. 외부에서는 루트를 통해서만 접근한다. (예: Order는 OrderItem의 루트)
도메인 서비스 (Domain Service)특정 엔티티에 속하기 애매한 비즈니스 로직을 담는 서비스.
도메인 이벤트 (Domain Event)"주문이 생성되었다" 같이 도메인 내에서 발생한 사실을 나타내는 객체.
리포지토리 (Repository)애그리거트의 영속성(저장/조회)을 담당하는 인터페이스.
바운디드 컨텍스트 (Bounded Context)하나의 도메인 모델이 유효한 경계. MSA의 각 서비스가 하나의 바운디드 컨텍스트에 대응한다.

6. MSA 환경에서의 DDD

위의 예시는 모놀리식(Monolithic, 하나의 애플리케이션) 환경에서의 DDD이다.
MSA(Microservice Architecture)에서는 주문 서비스, 상품 서비스, 결제 서비스가 각각 별도의 애플리케이션으로 분리된다.

도메인 간 통신 방식은 크게 두 가지다.


방식 1 — 동기 통신 (API 직접 호출)

FeignClient나 WebClient를 사용해 다른 서비스의 REST API를 직접 호출한다.

// 주문 서비스에서 상품 서비스의 API를 호출하는 FeignClient
@FeignClient(name = "product-service")
public interface ProductClient {
    @GetMapping("/api/products/{productId}")
    ProductInfo getProduct(@PathVariable UUID productId);

    @PostMapping("/api/products/batch")
    Map<UUID, ProductInfo> getProductsByIds(@RequestBody List<UUID> productIds);
}

직관적이지만, 상품 서비스가 다운되면 주문도 실패한다는 강한 결합 문제가 있다.


방식 2 — 비동기 통신 (이벤트 기반) ⭐ MSA에서 권장

주문 서비스는 "주문이 생성되었다"는 이벤트만 발행하고, 다른 서비스들이 그 이벤트를 구독해 각자의 로직을 처리한다.

// 1. 도메인 이벤트 정의
public record OrderCreatedEvent(
    UUID orderId,
    UUID userId,
    UUID storeId,
    List<OrderItemDto> orderItems,
    Integer totalAmount
) {}
// 2. 주문 서비스 — 이벤트를 발행하고 끝낸다
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher; // 이벤트 발행기

    @Transactional
    public OrderResult createOrder(CreateOrderCommand command) {
        Order order = Order.create(...);
        orderRepository.save(order);

        // "주문이 생성되었다"는 사실만 세상에 알린다
        // 누가 이 이벤트를 구독하는지는 관심 없다
        eventPublisher.publishEvent(new OrderCreatedEvent(
            order.getId(), command.userId(), order.getStoreId(), ...
        ));

        return OrderResult.from(order);
    }
}
// 3. 결제 서비스 — 이벤트를 구독해 결제를 생성한다
@Service
@RequiredArgsConstructor
public class PaymentEventHandler {

    @EventListener  // Kafka를 사용한다면 @KafkaListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        Payment payment = Payment.createPending(event.orderId(), event.totalAmount());
        paymentRepository.save(payment);
    }
}

이 방식의 핵심은 다음과 같다. OrderService는 PaymentService의 존재를 전혀 모른다.
나중에 "포인트 적립" 기능이 추가되어도 OrderService는 단 한 줄도 수정할 필요가 없다.
PointEventHandler만 새로 만들어 이벤트를 구독하면 끝이다.


구분모놀리식 (DDD)MSA — API 직접 호출MSA — 이벤트 기반
통신 방식Service 직접 호출HTTP 호출 (FeignClient)이벤트 발행 → 구독
결합도같은 프로세스 (강결합)네트워크 의존 (중간)이벤트만 알면 됨 (약결합)
장애 전파하나 터지면 전체 영향상품 서비스 장애 → 주문 실패주문은 성공, 나머지는 나중에 처리
기능 추가Service 주입 추가Client 추가이벤트 구독자만 추가

모놀리식에서 DDD의 감각을 익혀두면, MSA로 전환할 때 도메인 경계가 자연스럽게 서비스 경계로 이어진다.


7. 결국 왜 배워야 하는가

사실, 위에서 봤던 트랜잭션 스크립트 스타일의 createOrder 코드는 AI에게 요청하면 30초 만에 나온다.

그렇다면 그 코드를 짜는 능력만으로 개발자로서의 가치를 만들어낼 수 있을까?

AI가 절대 대체할 수 없는 영역이 있다. 바로 비즈니스 맥락을 이해하는 능력이다.

"주문 취소 정책이 복잡해요. 결제 대기 중에는 즉시 취소, 사장님 수락 전에는 전액 환불, 조리 시작 후에는 취소 불가예요. 다음 분기에 부분 환불도 추가될 예정이에요."

이 요구사항을 듣고 다음 질문을 자연스럽게 던질 수 있는가?

  • "취소 정책을 Order 도메인 안에 둘 것인가, 별도의 CancelPolicy 도메인으로 분리할 것인가?"
  • "부분 환불이 추가되었을 때 기존 구조가 유연하게 수용할 수 있는가?"
  • "환불 처리의 책임은 Order인가, Payment인가, 아니면 별도의 Refund 도메인인가?"

이것은 코드 문제가 아니라 비즈니스 판단의 문제이다. AI는 우리 회사의 비즈니스를 모른다.


그리고 현실도 알고 있어야 한다

한 가지 덧붙이자면, 대부분의 기업 시스템은 이미 수년에 걸쳐 구축된 레거시 코드베이스 위에서 운영되고 있다. 트랜잭션 스크립트로 짜인 수십만 줄의 코드를 하루아침에 DDD로 바꾸는 것은 현실적으로 불가능하다.

그렇다고 DDD를 배울 필요가 없다는 뜻이 아니다. 오히려 그 반대다.

DDD의 진짜 가치는 "기존 코드를 점진적으로 개선하는 방향 감각"을 제공하는 데 있다. 새로운 기능을 추가할 때, 새로운 모듈을 설계할 때, 리팩터링 우선순위를 판단할 때 — 이 감각이 있는 개발자와 없는 개발자의 결과물은 시간이 지날수록 벌어진다.


Before / After

Before (지금까지)After (DDD 관점)
User Entity에 모든 연관관계를 몰아넣음도메인 경계를 나눠 각 도메인이 필요한 관계만 가짐
모든 비즈니스 로직이 Service 메서드에 나열도메인 객체가 자신의 규칙을 스스로 관리
기능 추가 시 기존 Service 메서드를 더 길게 늘림새로운 도메인 객체나 정책 클래스를 추가
테스트하려면 Mock 객체 5~6개 필요도메인 객체 단위로 독립적인 테스트 가능
AI가 짜준 코드를 그대로 가져다 씀AI에게 올바른 구조를 지시할 수 있음
기획자의 말을 받아 적고 구현만 함기획자와 같은 언어로 소통하며 함께 설계함

💬 마치며

Spring과 JPA는 도구(Tool)이다. DDD는 그 도구를 어떤 방향으로 쓸지에 대한 설계 철학이다.

처음에는 낯설고 복잡하게 느껴질 수 있다. 하지만 이 감각이 쌓이면, 기획자의 요구사항을 듣는 순간부터 코드의 구조가 머릿속에 그려지기 시작한다.

다음 포스팅에서는 DDD의 세부 개념들(Aggregate, Value Object, Bounded Context, Domain Event)을 하나씩 더 깊이 다뤄볼 예정이다.


📚 참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글