본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로소프트웨어 공학 › 설계 › 도메인 주도 설계

도메인 모델

목차

도메인 모델: 업무 규칙을 코드에 옮기는 단위

도메인 모델은 업무 담당자가 설명하는 개념과 규칙을 코드로 표현한 것이다. orders 테이블의 모든 열을 그대로 Order 필드로 복사하는 것만으로 모델이 완성되지는 않는다. 주문이 언제 배송지를 바꿀 수 있는지, 취소하면 어떤 상태가 되는지 같은 규칙이 모델에 드러나야 한다.

엔티티와 값 객체

엔티티(Entity)는 시간이 지나 속성이 바뀌어도 같은 것으로 식별되는 대상이다. 주문 번호가 O-42인 주문은 배송지가 달라져도 같은 주문이다. 값 객체(Value Object)는 식별자보다 값 자체가 의미를 결정한다. 주소의 우편번호와 상세 주소가 같으면 같은 배송지 값으로 취급할 수 있다. 값 객체를 불변으로 만들면 공유된 객체가 예상치 못하게 바뀌는 일을 줄일 수 있다.

다음 예제는 주문이 CREATED 상태일 때에만 배송지를 바꿀 수 있다는 가상의 규칙을 구현한다. Address는 Java 16부터 정식 지원된 record로 표현했다.

import java.util.Objects;

record Address(String postalCode, String detail) {
    Address {
        if (postalCode == null || postalCode.isBlank()
                || detail == null || detail.isBlank()) {
            throw new IllegalArgumentException("배송지 주소가 필요합니다");
        }
    }
}

enum OrderStatus { CREATED, PAID, SHIPPED }

final class Order {
    private final String orderNo;
    private Address shippingAddress;
    private OrderStatus status = OrderStatus.CREATED;

    Order(String orderNo, Address shippingAddress) {
        this.orderNo = Objects.requireNonNull(orderNo);
        this.shippingAddress = Objects.requireNonNull(shippingAddress);
    }

    void changeShippingAddress(Address newAddress) {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("주문 확정 후에는 배송지를 바꿀 수 없습니다");
        }
        shippingAddress = Objects.requireNonNull(newAddress);
    }

    void markPaid() {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("결제할 수 없는 주문 상태입니다");
        }
        status = OrderStatus.PAID;
    }

    Address shippingAddress() { return shippingAddress; }
}

changeShippingAddress는 상태를 확인한 뒤 새 주소를 넣는다. 예를 들어 생성 직후 주소 변경은 허용하지만 markPaid() 후 같은 호출은 예외를 낸다. 실제 서비스에서 변경 마감 시점이 결제인지 출고인지에 따라 조건은 달라진다. 그 차이를 담당자와 확인해 코드와 테스트에 같은 말로 남겨야 한다. Order의 식별자는 orderNo이며, 새 Address를 넣어도 주문의 정체성은 유지된다.

애그리거트, 저장소, 도메인 서비스

주문에 항목과 총액 규칙이 더해지면 Order를 애그리거트 루트로 두고 항목 변경이 항상 루트를 거치게 할 수 있다. 루트는 한 번의 변경에서 지켜야 하는 불변식을 보호한다. 다른 애그리거트인 고객이나 상품 전체를 주문 객체에 붙여 거대한 객체 그래프를 만들기보다 식별자로 참조할지 검토한다.

리포지터리(Repository)는 주문을 식별자로 읽고 저장하는 인터페이스다. 배송지 변경 유스케이스는 주문 조회 → changeShippingAddress 호출 → 저장 순서로 흐른다. 저장소가 변경 가능 여부를 판단하거나, 컨트롤러가 status 필드를 직접 고치면 같은 규칙이 여러 곳으로 퍼진다. 동시 요청이 가능한 환경에서는 모델의 검사와 별도로 버전 검사나 트랜잭션을 사용해 저장 시 충돌도 다뤄야 한다.

도메인 서비스(Domain Service)는 중요한 업무 판단이지만 한 엔티티의 책임으로 놓기 어려울 때 사용한다. 예를 들어 회원 등급, 쿠폰, 주문 금액을 함께 고려하는 할인 계산은 서비스 후보가 될 수 있다. 모든 로직을 서비스로 보내고 엔티티를 getter/setter 묶음으로 만들면 모델이 규칙을 보호하지 못하므로, 먼저 규칙의 주체가 있는지 확인한다.

Java record 공식 문서

같은 카테고리의 글