도메인 주도 개발( DDD): 당신이 궁금해했지만 물어보기 두려웠던 모든 것들에 대해

이라운·2023년 5월 14일
2

📰 이번에 다룬 뉴스:

Pablo Martinez 의
Domain-Driven Design: Everything You Always Wanted to Know About it, But Were Afraid to Ask
✅ 작성자 편한대로, 이해한 대로, 기억하고 싶은 부분만 번역했습니다. 믿지 마시고, 되도록이면 위의 원문을 봐주세요.
⚡️ 해당 이모지가 있는 부분은 원문에 없는 작성자의 사족입니다.

✒️ 느낀 점

개발 신입으로 일한지도 어느새 3개월 정도가 되어간다. 현업에서 일하면서 가장 먼저 체감한 차이점은, 프로젝트는 마치 릴레이 소설과 같은 느낌이라는 것이다. 앞에 사람이 설정을 잘 짜놓고 어떤 의도와 목적을 위해 만든 이벤트, 떡밥인지를 잘 보여줄 수 있도록 코드를 짜야 이를 기반으로 팀의 프로젝트에 대한 이해도를 높일 수 있다는 것이다.

🔤 번역

코드베이스가 커지면 그 복잡도도 증가할 수밖에 없습니다. 이러한 상황에서 코드를 처음에 계획한 대로 체계적이고 구조화된 상태로 유지하는 것은 점점 어려워집니다. 이를 소프트웨어 엔트로피라고 합니다. 여러 차례의 개발 반복을 거치면서, 클래스와 모듈을 적절하게 분리하고 적절하게 결합을 제한하는 것이 점점 더 어려워지며, 엄격한 아키텍처 지침이 없다면 유지 보수성이 나빠질 수 있습니다.

전통적인 모델-뷰-컨트롤러(MVC) 아키텍처에서는 "M" 레이어가 모든 비즈니스 로직을 담당하지만, 적절한 책임 경계를 유지하기 위한 명확한 규칙을 제공하지 않습니다. 이 문제를 완화하기 위해 여러 패턴이 등장했지만, 모델이 발전함에 따라 여전히 구성 요소 간의 로직과 책임이 누출되는 위험이 있어 유지 보수성과 안정성이 어려워졌습니다.

반면, 비즈니스 전문가와의 소통, 요구 사항 수집, 기술 및 비기술 팀 간의 합의를 통해 비즈니스 문제를 해결하는 시스템을 적절하게 설계하고 구현하는 것은 계속적인 반복적인 과정입니다. 이 과정에서 잘못 이해되어 프로젝트가 원래의 목표에서 벗어나는 경우가 많습니다.

예를 들어, 이름 짓기는 항상 소프트웨어 개발자가 직면하는 가장 어려운 도전 중 하나입니다. 코드에서 우리의 의도를 다른 개발자들이 이해할 수 있도록 충분히 명확해야 하며, 비즈니스 이해관계자들과 대화를 나눌 수 있도록 적절한 이름 선택을 사용해야 합니다.

도메인 주도 설계(DDD)는 이러한 도전을 해결하기 위해 소프트웨어 프로젝트에서 충돌하는 기술적 및 비기술적 요소를 조화시키고, 성공적인 시스템 구축을 촉진하는 일련의 관행과 패턴을 제안합니다.

그래서 DDD(도메인 주도 개발)이 뭔데?

'도메인'이란 단어가 의미하는 것을 정의하면서 시작해 보겠습니다. 저는 다음과 같이 정의합니다.

"어플리케이션 로직이 문제를 해결하기 위해 작동하는 공통 요구 사항, 용어 및 기능을 정의하는 특정한 활동 또는 지식 영역."

도메인 주도 설계(Domain-Driven Design, DDD)는 시스템 구현을 지속적으로 발전하는 모델에 결합시키는 소프트웨어 디자인 접근 방식으로, 프로그래밍 언어, 인프라 기술 등과 같은 관련 없는 세부 사항은 제외합니다.

이 방식은 주로 비즈니스 문제와 해당 문제를 해결하는 논리를 엄격하게 조직화하는 데 초점을 맞추고 있습니다. 이 접근 방식은 Eric Evans의 Domain-Driven Design Tackling Complexity in the Heart of Software이라는 책에서 처음 소개되었습니다.

이제 DDD의 정의와 목표를 알았으므로, 이 방법론의 세 가지 주요 기둥에 대해 자세히 살펴보겠습니다.

전략적 디자인: 정신을 잃지 않도록 디자인을 분할하기

구현이 다수의 반복을 거치며 시스템의 복잡성이 점차 증가함에 따라 그것을 통제하는 것은 어려울 수 있습니다. 따라서 대규모 시스템을 이해하고 제어하기 위한 철저한 전략이 필수적입니다. 서로 상호작용하는 경계가 명확한 컨텍스트(Bounded Context)로 모델을 분해하면 - 이러한 컨텍스트는 개념적으로와 코드상으로 각각 자체 통합된 모델을 갖습니다 - 복잡성 함정을 피하는 효과적인 방법입니다.

Bounded Context

Bounded Context는 비즈니스 도메인, 팀 및 코드 관점에서 애플리케이션 및/또는 프로젝트 일부를 둘러싼 개념적 경계입니다. 관련 컴포넌트 및 개념을 그룹화하고, 명확한 컨텍스트 없이 동일한 의미를 갖는 경우 모호함을 방지합니다.

예를 들어, Bounded Context 밖에서 "letter"는 문자 또는 종이에 쓰인 메시지와 같은 매우 다른 의미를 갖을 수 있습니다. 경계와 컨텍스트를 정의함으로써, 그것의 의미를 결정할 수 있습니다.

많은 프로젝트에서, 팀은 Bounded Contexts별로 분할되며, 각각의 팀은 자신의 도메인 전문성과 논리에 특화되어 있습니다.

Context Mapping

Context Mapping은 프로젝트에서 각 Bounded Context를 식별하고 그래픽으로 문서화하는 작업을 말합니다. Context Map은 Bounded Context와 팀이 서로 어떻게 관련되고 소통하는지를 더 잘 이해하는 데 도움이 됩니다. 그들은 실제 경계에 대한 명확한 개념을 제공하며, 팀이 시스템 설계의 개념적 구분을 시각적으로 설명하는 데 도움이 됩니다.

Bounded Context 간의 관계는 설계 요구사항과 다른 프로젝트 특정 제약 사항에 따라 다양할 수 있으며, 이 문서에서는 다음 네 가지 관계를 제외하고는 생략될 수 있습니다:

Anti-corruption Layer:

하위 Bounded Context는 상위 컨텍스트에서 가져오는 데이터나 객체를 내부 모델을 지원하도록 번역하는 레이어를 구현합니다.

Conformist:

하위 Bounded Context는 상위 컨텍스트에 적응하고 적합하도록 변경해야 합니다. 이 경우 상위 컨텍스트는 하위 요구 사항을 충족시키는 데 관심이 없습니다.

Customer/Supplier:

상위 컨텍스트는 하위 컨텍스트에 서비스를 제공하며, 하위 컨텍스트는 고객 역할을 하여 요구 사항을 결정하고 상위 컨텍스트에 변경 사항을 요청하여 자신의 요구 사항을 충족시킵니다.

Shared Kernel:

Collaborative Modeling: 풍부한 의사 소통과 효과적인 협력

때로는 두 개 이상의 컨텍스트가 겹쳐서 리소스나 구성 요소를 공유하는 것이 불가피할 수 있습니다. 이 관계에서는 변경이 필요할 때 양쪽 컨텍스트가 지속적으로 동기화되어야 하므로 가능하면 피해야 합니다.

Collaborative Modeling은 DDD에서 제안하는 접근 방식으로, 기술적인 지식 뿐만 아니라 비즈니스 지식을 가진 모든 당사자들이 참여하여 도메인을 효과적으로 모델링하는 것입니다. Evans는 도메인 모델을 "도메인 전문가의 지식뿐만 아니라, 엄격하게 조직화되고 선택적으로 추상화된 지식"이라고 설명합니다.

개발자는 도메인 전문가와 협력하여 도메인 모델을 지속적으로 개선함으로써, 단순히 코드를 기계적으로 생산하는 것이 아니라 비즈니스 문제를 해결하려는 중요한 세부 사항과 원칙을 학습합니다.

비즈니스 및 기술 팀 간의 협력을 가능하게하기 위해, 도메인 모델은 비즈니스와 기술 어휘를 결합하고, 모든 팀원이 이해하고 합의할 수 있는 중간 언어를 사용해야합니다. 이를 유비쿼터스 언어라고하며, 잘 정의된 유비쿼터스 언어를 사용하면 기술 및 비즈니스 팀 간 모든 상호 작용이 모호하지 않고 효과적으로 개선됩니다.

최종적으로, 이 유비쿼터스 언어는 코드에 내장됩니다.

Tactical Design: The Nuts and Bolts of DDD

도메인 객체들 간의 연관성을 구현하고 그 기능을 설명하는 것은 보통 눈에 보기 쉽지만, 그 의미와 존재 이유를 명확하고 직관적인 방법으로 구분해야 합니다. DDD는 이를 달성하기 위한 일련의 구조와 패턴을 제안합니다.

Entities
고유 식별자를 가지며, 일관성 있는 스레드를 가진 객체를 Entity라고 합니다. 이들은 속성만으로 정의되는 것이 아니라 더욱 중요한 것은 그들이 누구인가에 대한 것입니다. 이들의 속성은 변할 수 있고, 수명 주기도 크게 변할 수 있지만, 그들의 식별자는 유지됩니다. 식별자는 고유한 키나 유일하다는 것이 보장된 속성 조합을 통해 유지됩니다.

예를 들어, 전자상거래 도메인에서 주문은 고유 식별자를 가지고 다양한 단계를 거칩니다. 이러한 이유로 주문은 도메인 Entity로 간주됩니다.

export class Customer {

    private id: number;
    private name: string;

    protected constructor(name: string) {
        // A uuid guarantees a unique identity for the Customer Entity
        this.id = uuidv4();
        this.name = this.setName(name);
    }

    private setName(name: string): string {
        // Business invariant: Customer name should not be empty
        if (name === undefined || name === '') {
            throw new Error('Name cannot be empty');
        }
        return name;
    }

    public static create(name: string): Customer {
        return new Customer(name);
    }
}

Value Objects
Value Object는 고유 식별자를 가지지 않으며 특성을 설명하는 객체로, 무엇인지에만 관심이 있습니다.

Value Object는 엔티티의 속성이 될 수 있으며, 여러 엔티티에서 공유될 수 있습니다. 예를 들어, 두 고객이 같은 배송 주소를 가질 수 있습니다. 그러나 속성 중 하나가 변경되면 모든 엔티티가 영향을 받을 수 있는 위험이 있습니다. 이를 방지하기 위해 Value Object는 불변성을 가져야 하며, 업데이트가 필요할 때 시스템이 새로운 인스턴스로 대체되도록합니다.

또한, Value Object의 생성은 항상 생성에 사용되는 데이터의 유효성과 비즈니스 불변식을 준수하는 방식에 따라 결정되어야 합니다. 따라서 데이터가 잘못되면 객체 인스턴스가 생성되지 않습니다. 예를 들어, 북미에서 알파벳이 아닌 문자가 포함된 우편 번호는 비즈니스 불변식을 위반하며, 주소 생성 시 예외를 발생시킵니다.

export class Address {

    private readonly streetAddress: string;
    private readonly postalCode: string

    protected constructor(streetAddress: string, postalCode: string) {
        this.streetAddress = this.getValidStreetAddress(streetAddress);
        this.postalCode = this.getValidPostalCode(postalCode);
    }

    private getValidStreetAddress(streetAddress: string): string {
        // Business invariant: street address should not be longer than 128 characters
        if (streetAddress.length > 128) {
            throw new Error('Address should not be longer than 128 characters');
        }
        return streetAddress;
    }

    private getValidPostalCode(postalCode: string): string {
        // Business invariant: Should be a valid canadian postal code
        const pattern = /[a-z]\d[a-z][ \-]?\d[a-z]\d/g;
        if (!postalCode.match(pattern)) {
            throw new Error('Postal code should only contain alphanumeric caracters and spaces');
        }
        return postalCode;
    }

    public getStreetAddress(): string {
        return this.streetAddress;
    }

    public getPostalCode(): string {
        return this.postalCode;
    }

    public static create(streetAddress: string, postalCode: string): Address {
        return new Address(streetAddress, postalCode);
    }

    public equals(otherAddress: Address): boolean {
        // Value Objects equality is based on their propertie's values
        return objectHelper.isEqual(this, otherAddress);
    }
}

Services
많은 경우, 도메인 모델에서 특정 액션 또는 작업이 필요하지만 Entity 또는 Value Object와 직접적인 관련이 없는 경우가 있습니다. 이러한 액션을 구현에 강제하는 것은 그들의 정의를 왜곡시킵니다. 서비스는 상태가 없는 작업을 제공하는 클래스입니다. 동사로 일반적으로 이름이 지어지며, Entity와 Value Object의 명사와 대조됩니다. 또한 이러한 작업은 Ubiquitous Language에 기반하여 명명됩니다.

서비스는 Entities 및 Value objects의 책임과 동작을 직접적으로 박탈하지 않도록 주의해서 작성해야 합니다. 또한 클라이언트가 서비스의 히스토리를 무시하고 어떤 지정된 인스턴스를 사용할 수 있도록 상태가 없어야 합니다. 도메인 로직이 없는 Entity와 Value Object를 가지고 있는 것은 Anemic Domain Model이라는 안티 패턴으로 간주됩니다.

도메인 객체와 그들의 라이프 사이클
도메인 객체는 일반적으로 복잡한 라이프 사이클을 가지고 있습니다. 인스턴스화되고, 여러 가지 변경을 거쳐 다른 객체와 상호 작용하며, 작업을 실행하고, 영속화되고, 재구성되고, 삭제되고 등등의 작업을 수행합니다. 이러한 복잡한 라이프 사이클을 관리하면서 시스템이 이를 제대로 처리하지 못하는 것을 방지하는 것은 적절한 도메인 모델 구현의 주요 과제 중 하나입니다.

도메인 객체 간의 관계와 상호 작용을 최소화하여 도메인 모델 내에서 복잡성의 관리 가능한 수준을 유지하는 것은 어렵습니다. 특히 Eric Evans가 설명한 대로 복잡한 비즈니스 도메인에서는 다음과 같습니다.

"복잡한 연관 관계를 가진 모델의 객체에 대한 변경의 일관성을 보장하는 것은 어렵습니다. 개별 객체뿐만 아니라 밀접하게 관련된 객체 그룹에 적용되는 불변식을 유지해야 합니다. 그러나 조심스런 잠금 체계는 여러 사용자가 서로 무의미하게 간섭하고 시스템을 사용할 수 없게 만듭니다."

Aggregates
Aggregates는 앞서 언급한 도전에 대한 대처책으로, 관련된 Entities와 Value Objects의 집합으로, 비즈니스 불변성을 위반하는 것을 제한합니다.

Aggregates는 관련된 Entities와 Value Objects의 컬렉션으로, 트랜잭션 경계를 나타내며, Aggregate Root라는 외부로 향하는 Entity를 가지고 있으며, 내부 객체에 대한 모든 액세스를 제어합니다. 다른 객체는 Aggregate Root를 통해서만 상호 작용할 수 있습니다. 즉, Aggregate 내부 객체는 외부에서 직접 호출할 수 없으며, 그렇게 유지됩니다.

비즈니스 불변성은 Aggregate와 그 내용물의 무결성을 보장하는 비즈니스 규칙입니다. 다른 말로, 비즈니스 룰과 일관성을 유지하기 위한 메커니즘입니다. 예를 들어, 어떤 제품의 재고 수량이 0 일 때 주문을 받을 수 없습니다.

export class Order {
    private id: number;
    private isConfirmed: boolean;
    private total: number;
    private shippingAddress: Address;
    private customer: Customer;
    private items: Product[];
    private payments: Payment[];

    constructor(
        customer: Customer,
        shippingAddress: Address,
        items: Item[],
        payments: Payment[]
    ) {
        // Generate a unique identifier (UUID) for the Order Entity
        this.id = uuidv4();
        this.isConfirmed = false;
        this.total = 0;
        this.customer = customer;
        this.shippingAddress = shippingAddress;
        this.items = items.length ? items : [];
        this.payments = payments.length ? payments : [];
    }

    private getPaymentsTotal(): number {
        return this.payments.reduce((accumulator, payment) => accumulator + payment.total);
    }

    public addPayments(payment: Payment): void {
        this.payments.push(payment);
        this.total += payment.total;
    }

    public addItems(product: Product): void {
        // Business invariant: an order should not have items which are not in stock
        if (!product.getStockQuanity()) {
            throw new Error(`No stock for product id: ${product.id}`);
        }
        this.items.push(product);
    }

    public confirm(): void {
        // Business invariant: only fully paid orders can be confirmed
        if (this.total === this.getPaymentsTotal()) {
            throw new Error('Total amount paid does not equal order total');
        }
        this.isConfirmed = true;
    }
}

Factories
복잡한 객체 및 집합체 인스턴스를 생성하는 것은 어려운 일이며 객체의 내부 세부 정보를 너무 많이 노출할 수도 있습니다. 팩토리를 사용하여이 문제를 해결하고 필요한 캡슐화를 제공할 수 있습니다.

팩토리는 도메인 객체 또는 집합체를 원자적인 하나의 작업으로 생성할 수 있어야하며 호출 시 클라이언트가 제공해야하는 모든 데이터를 요구하고 생성된 객체에 대한 모든 불변 조건을 강제해야합니다. 이 활동은 도메인 모델의 일부는 아니지만 시스템에 적용되는 비즈니스 규칙의 일부이므로 도메인 레이어에 속합니다.

export class OrderFactory implements Factory {
    private customerEntity: Customer;
    private addressValue: Address;
    private productsRepository: Repository;
    private paymentsRepository: Repository;

    constructor(customerEntity: Customer, addressValue: Address, productsRepository: Repository, paymentsRepository: Repository) {
        this.customerEntity = customerEntity;
        this.addressValue = addressValue;
        this.productsRepository = productsRepository;
        this.paymentsRepository = paymentsRepository;
    }

    public async createOrder(customerName: string, addressDto: AddressDto, itemDtos: ItemDto[], paymentDtos: PaymentDto[]): Order {
        try {
            const customer = this.customerEntity.create(customerName);
            const shippingAddress = this.addressValue.create(addressDto.streetAddress, addressDto.postalCode);
            const items = await this.productsRepository.getProductCollection(itemDtos);
            const payments = await this.paymentsRepository.getPaymentCollection(paymentDtos);

            return new Order(customer, shippingAddress, items, payments);
        } catch(err) {
            // Error handling logic should go here
            throw new Error(`Order creation failed: ${err.message}`);
        }
    }
}

Repositories
우리는 영속성(인메모리, 파일시스템 또는 데이터베이스)에서 객체를 검색할 수 있어야 하므로, 구현 세부 정보를 클라이언트에게 숨기는 인터페이스를 제공해야 합니다. 이렇게 하면 클라이언트가 인프라 구체적인 세부 정보에 의존하지 않고 추상화만을 사용할 수 있습니다.

리포지토리는 저장된 객체를 검색하기 위해 도메인 레이어에서 사용할 수 있는 인터페이스를 제공하여 저장소 로직과의 강한 결합을 피하고 클라이언트에게 객체가 메모리에서 직접 검색되는 것처럼 보이도록 하는 역할을 합니다.

모든 리포지토리 인터페이스 정의는 도메인 레이어에 있어야 하며, 구체적인 구현은 인프라스트럭처 레이어에 있어야 함을 언급하는 것이 중요합니다.

export class OrderRepository implements Repository {
    private model: OrderModel;
    private mapper: OrderMapper;
    private productsRepository: Repository;
    private paymentsRepository: Repository;

    constructor(orderModel: OrderModel, orderMapper: Mapper, productsRepository: Repository, paymentsRepository: Repository) {
        this.model = orderModel;
        this.mapper = orderMapper;
        this.productsRepository = productsRepository;
        this.paymentsRepository = paymentsRepository;
    }

    public async getById(orderId: number): Promise<Order> {
        const order = await this.model.findOne(orderId);

        if (!order) {
            throw new Error(`No order found with order id: ${orderId}`);
        }
        return this.mapper.toDomain(order);
    }

    public async save(order: Order): Promise<Boolean> {
        const orderRecord: OrderRecord = this.mapper.toPersistence(order);

        try {
            await this.productsRepository.insert(order.items);
            await this.paymentsRepository.insert(order.payments);

            if (!!await this.getById(order.id)) {
                await this.model.update(orderRecord);
            } else {
                await this.model.insert(orderRecord);
            }
        } catch (err) {
            // call to rollback mechanism should go here
            return false;
        }
        return true;
    }
}

도메인을 다른 문제와 분리시키시

도메인 문제를 해결하기 위해 특별히 작성된 코드는 전체 코드베이스 중 일부분에 불과합니다. 이 부분이 다른 문제를 해결하는 코드와 뒤섞이면 이해하고 개선하기 어려울 수 있습니다. 도메인 로직을 다른 기능과 명확하게 분리함으로써 이러한 문제를 줄이고 대규모 복잡한 시스템에서 혼란을 방지할 수 있습니다.

DDD는 사용자 인터페이스, 어플리케이션, 도메인, 인프라스트럭처라는 4개의 주요 레이어로 코드베이스를 분할하여 책임을 분리하고 혼동을 방지하기 위한 계획을 제안합니다.

이곳의 주요 규칙은 각 레이어의 구성 요소가 해당 레이어나 그 아래 레이어의 구성 요소에만 의존해야 한다는 것입니다. 상위 레이어는 하위 레이어의 구성 요소를 그들의 공개 인터페이스를 호출함으로써 사용할 수 있으며, 하위 레이어는 Inversion of Control (IoC)을 통해 상위 레이어와만 통신할 수 있습니다.

사용자 인터페이스 레이어:
데이터를 표시하고 사용자 명령을 캡처하는 책임을 지닙니다.
어플리케이션 레이어:
도메인 규칙을 알지 못하지만 도메인 객체를 조직화하고 위임하여 도메인 작업을 조정합니다. 또한 다른 경계 컨텍스트에서만 접근 가능한 유일한 레이어입니다.
도메인 레이어:
비즈니스 로직과 규칙, 비즈니스 상태를 유지합니다. 도메인 모델이 살아 숨쉬는 곳입니다.
인프라스트럭처 레이어:
높은 레이어에서 지원하기 위해 필요한 모든 기술적 기능을 구현합니다. 지속성, 메시징, 레이어 간 통신 등이 있습니다.

모든 시스템에서 모든 레이어가 필요한 것은 아니지만, 도메인 레이어의 존재는 DDD에서 선행 조건입니다.

결론

총체적으로, DDD는 도메인 전문가들과의 활발한 협력과 엄격한 디자인 패턴을 통해 비즈니스 문제를 해결하기 위한 종합적인 접근 방법입니다. 이것은 모든 소프트웨어 프로젝트에 대한 보편적인 해결책은 아니지만, 올바르게 적용될 때 상당한 이점을 제공할 수 있습니다.

이 주제에 대한 여러 책들이 있으며, 이 글에서 몇 가지 개념은 명시적으로 생략되었지만, 도메인 주도 설계에 대한 관심을 불러일으킨다면, 이 방대한 주제의 시작점인 파란색 책과 빨간색 책을 순서대로 읽어보는 것을 추천합니다.

단어

propose: 제안하다
tactical: 전략적인, 실용적인
anemic: 빈혈의, 힘이나 기운이 없는, 빈약한

profile
Programmer + Poet = Proet

0개의 댓글