Software Enginner 🇯🇵 and 🇰🇷

도메인 주도 설계의 Aggregate Root를 설계하고 유지하는 법

Aggregate Root가 비대해지고, 많은 책임을 지닐 때 이를 해결하는 방법에 대해 알아봅니다.

Series. Domain Driven Design in Depth

  1. 도메인 주도 설계의 Aggregate Root를 설계하고 유지하는 법 - 2022-05-06 00:35:00 +0000

Aggregate Root 는 도메인 단위이자, 우리가 현실 세계를 기술하는 명세이기도 합니다. 정적인 코드가 동적인 세계와 결합 되어 변화하는 시점을 파악하고, 그것을 통해 모델을 도출하는 것은 서비스를 개발하는 개발자에게 있어 가장 큰 일입니다. 그러나, 이러한 도메인 객체 설계가 좋은 방향으로 동작하기 위해서는 사전에 많은 작업이 필요합니다. 올바르게 각 요소를 Aggregate Root로 쪼개고, 묶는 방법은 무엇일까요.

코드의 생명 주기(Lifecycle)

코드 또한 여러 이유에 의해 생성과 소멸을 반복합니다. 우리는 코드에 녹여내는 현실이 이미 과거라는 사실을 알고 있습니다. 이것은 정적인 코드가 표현할 수 있는 한계이며, 많은 경우 이런 생명 주기의 연장을 위해 “미래를 위한 설계”따위를 실천하고는 합니다. 그러나, 이 방식은 위험한 결과를 야기할 수 있는 접근법입니다. 비즈니스는 격동적이며, 우리의 코드도 격동적으로 변화하고 진화할 것입니다. 코드는 이러한 비즈니스의 생명 주기와 깊은 연관을 가지고, 어느 때에 사용할 법한 미래를 위한 설계는 많은 경우 의도치 않은 방향으로 사용되어 버리거나, 아니면 애초에 사용할 기회를 얻지 못합니다. 설령 같은 프로젝트의 같은 기획자, 개발자가 만들어 내는 서비스라도 그러합니다! 그리고 이러한 요구사항의 변화는 전혀 이상하거나 잘못된 것이 아닙니다.

이러한 생명 주기를 파악하고, 분석해서 현재 도메인에 가장 잘 맞는 객체를 설계하는 것, 이것이 우리가 지향할 목표입니다. 미래를 위한 무언가는, 적절한 책임을 가진 객체와, 확장에 열려있는 소프트웨어, 명세의 분리와 추상화에 의존하는 기술로 달성할 수 있습니다. 레거시라 불려오는 거대한 무언가는 객체의 기능에 “미래에 쓸법한 기능”을 많이 만들어두지 않아서가 아닌, 뒤섞인 의존 관계와 수정하기에 너무 거대하기 짝이 없는 코드로부터 비롯됩니다. 그러니까, 마치 이건 Too big to fail 같은 겁니다.

어렴풋 알 수 있듯, 코드의 생명 주기라 하면 비즈니스의 발전 속도, 방향에 지대한 영향을 받습니다. 그렇다면, 우리는 코드의 생명 주기와 객체의 응집도와 결합도에 가장 큰 영향을 미치며 이러한 객체, 코드를 설계할 때 가장 중요시 여겨야 하는 것은 바로 이런 “생명 주기” 를 공유하는 시스템을 개발하는 것이라는 사실이 나름 자명해집니다.

작은 객체

객체를 설계함에 있어 거대한 객체는 필연적으로 문제를 야기시킵니다. 사실, 큰 객체 자체만으로도 이미 설계에 지대한 문제가 있다는 것을 방증합니다. 예를 들어, 여러분이 주문을 관리하는 Aggregate Root 를 설계한다고 가정합시다.

@AggregateRoot
class Order {
    // 내부에는 수 많은 로직이 있을 것이다.
}

주문 Aggregate root 는 어떤 영역부터 어떤 영역까지를 담당해야 하나요? 정답은 없습니다. 그러나, 작을 수록 좋습니다. Aggregate Root, 다른 객체를 설계하는 기본 개념은 쪼갤 수 없는 단위까지 쪼갠 무언가를 표현하는 것입니다. 많은 Microservice arch, 기타 등등의 조금은 핫하다라 불리우는 시스템이 오히려 복잡도를 가중시키는 이유는 이런 근본 원칙을 무시한 채 개발하고 있기 때문입니다. 도메인 주도 설계에서 큰 Aggregate Root는 너무 많은 문제를 수반시킵니다. 설령 그 객체가 논리적으로 보았을 때 “응집도 높은”코드라 하여도 말입니다.

도메인 로직이 존재해야 하는 곳은 Domain Layer이고, 각 도메인의 Aggregate Root는 이를 모두 담당하게 되어 있습니다. 이러한 설계에서, 객체에 많은 책임을 부여하거나, 설령 적은 책임에 많은 기능을 부여하여도 Aggregate Root는 이미 우리가 관리할 수 있는 범주를 넘어설 것입니다.

작은 객체를 설계할 수 없는 때는 분명 존재합니다. 이것은 당연한 일입니다. 그러나 Method(Aggregate Root의 관점에서, 비즈니스 로직의 행위)의 구현은 객체간의 협력으로 달성시킬 수 있습니다. 무상태 객체에 해당하는 Domain Service는, 어쩌면 여러분의 이런 문제점을 해결시켜 줄 수 있는 하나의 방법이 될 수 있습니다.

작게 만들 수 없는 설계

하나의 Aggregate Root를 가지고 여러 시스템이 협력하는 일은 매우 빈번한 작업입니다. Batch와 같은 경우는 Aggregate Root로서 관리하기에 너무나 어렵고, 실제로 그러한 작업을 권장하지는 않습니다. 그러나, Administrator API <-> User API 가 같은 Aggregate Root를 쓰는 경우는 복잡도가 높아질 수 있습니다.

정답은 아닙니다만, 저는 세 가지 대안을 보통 제시합니다.

  1. Aggregate Root에 최대한 추가
  2. Domain Service로 분리
  3. Aggregate Root분리

Aggregate Root에 최대한 추가

class User {
    public void delete() {}
  	public void addInformation() {}
  	public void removeInformation() {}
  	// ...and
}

이 방법은 매우 보편적인 방식입니다. 행위가 많아진다는 단점이 있지만, 객체가 자신의 행위를 공개함으로서 얻는 이점은 무척 크다고 생각합니다. 예를 들어 봅시다. 좋은 코드란, 코드 스스로가 행위와 결과를 서술할 수 있어야 합니다. 코드를 마치 시처럼, 글처럼 읽을 수 있어야 하고, 이를 다른 개발자가 사전에 필요한 지식 없이 이해할 수 있어야 합니다 (무척 힘들지만, 그걸 목표로 하는 것입니다)

자, 위 코드에서 public 을 통해 이 행위를 외부에 공개했습니다. 이는 당연, 외부에서 사용할 수 있고 사용해야 하는 행위임을 의미합니다. void 를 통해 우리는 이 행위를 통해 그 어떤 정보도 추가로 받아올 수 없음을 알았습니다. 그리고, 행위의 이름에 그 명확한 요소가 있습니다.

제가 제시하는 3가지 방법 모두 Aggregate Root에 Method를 추가해야 하는 것은 맞습니다만, 이 방법을 따로 소개하는 이유는 Aggregate Root에 있는 로직을 직접 호출한다는 점이 다른 두 방식과 조금 상이합니다.

Domain Service를 이용한 분리

Domain Service 는 아주 특수한 용도의 Service 객체입니다. 스스로 상태를 관리하지 않으며, Aggregate Root 를 외부에서 주입 받아 사용합니다. 마치 State machine pattern 과 유사하게 동작합니다.

@Service
class UserManagementService {
  	public void removeInformation(@Nonnull User user) {
        user.removeInformation();
    }
}
@AggregateRoot
class User {
     private Information information;
  
     void removeInformation() {
         this.information = null;
     }
}

이런 방식을 사용하는 이유는, 여러분이 만약 복잡한 객체를 설계했다고 가정해봅시다. 여러분의 API는 보편적으로 관리용 API와 그렇지 않은 API 두 개를 가지고 있을 것입니다. 이는 혼용되어 사용될 가능성이 높습니다. 이러한 상황 속에서, 어떠한 행위는 그 호출을 명시적으로 제한해야 할 필요가 있을 수 있습니다.

이러한 비즈니스 로직의 호출은 Application Layer가 담당하고, Application Layer는 호출의 정당성 (이 호출이 가능한가에 대한) 검증을 실시함이 맞지만, 이를 도메인 로직에서 분리해야 할 상황은 현실에서 겪기 쉬운 이야기입니다. 그렇다면, 여러분은 User Aggregate Root의 행위를 은닉시키고, 호출의 책임을 UserManagementService 로 넘길 수 있습니다. 이러한 구조는 이 코드(행위)의 소비자(호출)가 행위를 호출하는 시점에 이미 어느 정도의 정보를 알 수 있기에 꽤 괜찮은 방식이라 할 수 있을 것 같습니다. 물론, 이를 강하게 제한하고자 한다면 ArchUnit과 같은 아키텍처 테스트를 추가함이 좋습니다.

물론 이 방법은 단점이 있습니다. 이러한 모든 작업은, 결국 User Aggregate Root를 작업할 때 알 수 없다는 것입니다. 필연적으로, 우리는 하나의 행위를 위해 두 번의 수정이 필요한 것입니다. 이게 좋은 패턴일까요? 합리적인 선택 속에서, 이는 그저 하나의 선택지일 뿐입니다.

Aggregate Root의 분리

일단, 가장 쉬운 방식이며 가장 어려울 수 있는 방식입니다. 여러분의 Aggregate는 어쩌면 너무 비대한 객체가 되어버렸을 것입니다. 그렇다면, 우리가 취할 수 있는 어쩌면 최선의 방식은 Aggregate Root를 아예 분리시키는 것입니다. 비대한 객체는 복잡도가 높습니다. 책임지는 영역도 넓습니다. 작은 객체 여럿은 서로간의 Integration이 어렵지만, 작게 유지시키는 것은 큰 가치가 있는 일입니다.

@AggregateRoot
public class User {}

@AggregateRoot
public class Auth {}

@AggregateRoot
public class Information {}

생명 주기를 공유하지 않는 선에서, 더 작게, 더 짧게 객체를 나누어 보세요. 물론, 이 방식은 필연적으로 서로간의 Integration을 요구한다는 점에서 구현에 신경쓸 부분이 많습니다. 그러나, 더 작은 객체는 사용하기 편합니다. 이해하기 쉽습니다. 여러분이 비대한 객체를 테스트한다 생각해보세요. 대부분, 하나의 객체가 많은 협력 관계나 필드가 많을 때 우리는 테스트에 어려움을 겪습니다. 이것을 개발 당시에 신경쓰지 않는 이유는 많은 경우 Spring IoC Container 따위를 통해 객체의 생성과 관리 책임을 위임하기 때문입니다. 이는 무척 멋진 기능이나, 이로 인해 초기화가 어려운 객체가 탄생해버립니다.

그 모든 것들을 없애버리고, 맑은 시야에서 객체를 바라보자면, 복잡하고 책임이 많은 객체가 왜 좋지 않은지 알 수 있습니다. 특히, 영속화가 이루어지는 이러한 객체들은 더 복잡한 문제가 숨어 있습니다. 우리는 큰 객체를 다루면서, 데이터베이스의 최적화까지 고려해야 하는 운명에 마주합니다.

더 작게, 때로는 기교를 부려가며

@Inheritance 어노테이션이나 @MappedSuperclass 는 객체의 상속을 통해 객체지향적 관점에서 Application과 Domain을 설계할 수 있도록 도와줍니다. 서로 조금은 다른 Annotation이긴 하지만 일부 비슷한 구현이 있기에, Inheritance 를 기준으로 설명드리겠습니다.

외부에서 우리가 User 를 바라볼 때 어떠한 관점에서 바라봅니까? 예를 들면, 여러분이 만약 서비스A의 사용자라 가정해봅시다. 서비스A는 커머스의 회원 시스템이며, 여러분은 이 회원 시스템에 로그인 후 자신의 이름, 생년월일등을 변경할 수 있습니다. 그런데, 여러분에게 이메일을 변경할 권한이 없다 가정해봅시다.

그러나 대부분, 관리자는 이러한 기능을 제공하고 있습니다. 서로 같은 객체를 바라보나, 서로 상이한 비즈니스 로직이 포함되어 있다는 것입니다. 이러한 불일치는 현실 세계의 Application에서 익히 발생하는 매우 보편적인 문제입니다. 여러분의 Application이 작다면, 이러한 일은 큰 문제가 아닙니다. Aggregate Root 혼자 이 모든 로직을 담당해도 충분히 작고, 미치는 여파는 미미합니다. 그러나 대규모의, Aggregate Root가 “어쩔 도리 없이” 비대한 경우는 분명 존재합니다. 의도하였든, 의도하지 않았든 이러한 코드는 마주할 수 밖에 없습니다. 왜냐하면, 객체의 확장 가능성을 열어둔 시점에서 여러분은 미래 어느 시점에 이 객체의 “코드 레벨의 포화도”가 올라간다는 믿음이 있는 것이기 때문입니다. 아니라면, 여러분은 객체를 처음 구성한 그 순간 이미 완성되었음을 가정하고 “여기는 더 이상 수정하지 않아요!”따위의 말을 했겠죠.

다행스럽게도, 객체 지향적 관점에서 상속은 많은 것을 해결해 줄 수 있습니다. 적어도 이 문제에서는, 상속 관계가 가지는 강한 의존성이 오히려 감사할 지경입니다.

@AggregateRoot()
@Inheritance(strategy = strategy = InheritanceType.JOINED)
@DiscriminatorColumn()
@Getter(access = AccessType.PUBLIC)
class User { // 객체의 접근을 제한한다.
    @Id
    protected UserId id;
}

@Entity
public class AdminUser extends User { // 관리자가 제어하는 유저
    public void removeInformation() {
        // Do something..
    }
}

결국은 작게 쪼개야 한다

Aggregate Root의 복잡도는 필연적인 요소입니다. 복잡도를 측정하는 정량적 수치 (Cyclomatic Complexity 따위와 같은) 가 “돌아올 수 없는 선”에 해당하는 역치를 만났을 때 우리는 이러한 설계의 개선을 추진해야 하는 나름의 의무를 지니고 있습니다. 이러한 복잡도 증가는 도메인 주도 설계가 지향하는, Aggregate Root를 통한 접근, 비즈니스 로직의 은닉화와 파편화의 제한에 해당하는 것을 우리가 현실의 복잡한 문제를 풀어내고자 할 때 발생하는 것이기에 언제나, 설령 작디 작은 서비스를 만드는 상황에도 발생합니다.

복잡도를 풀어내는 많은 방법이 있습니다. Domain Event(Transaction Boundary는 다르지만 같은 Application에서 동작하도록)따위를 사용하거나, 그냥 “큰 객체”로 유지하는 방법도 좋은 방안이긴 합니다. 여러분의 복잡도를 잘 살펴보세요.

작게 쪼개는 것은 어려운 일입니다. Tx Boundary 를 분리한다는 것은, Eventual consistency를 요구하는 Application을 설계한다는 것입니다. 전통적이고, 보수적이고, 안정적인 RDBMS의 Tx를 사용하지 않을 정도의 가치가 있는 작업이라면, 진행하세요.