본문 바로가기

JAVA/DDD

애그리게이트

반응형

애그리게이트란?

객체지향 프로그래밍에서는 여러개의 객체가 모여 한 가지 의미를 갖는 하나의 객체가 된다.
이렇게 객체가 모여 이룬 객체는 어떤 불변조건(어떤 처리를 수행하는 동안 참을 유지해야 하는)을 유지해야 한다.

이 불변 조건은 언제나 유지돼야 하지만, 객체가 가진 데이터를 변경하는 조작을 무제한 허용하면 이를 유지하기 어렵다.
따라서 객체를 다루는데도 질서가 필요하다.

  • 애그리게이트는 경계와 루트를 갖는다.
  • 애그리게이트의 경게는 애그리게이트에 포함되는 대상을 결정하는 경계다.
  • 그리고 루트는 애그리게이트에 포함되는 특정한 객체이다.

외부에서 애그리게이트를 다루는 조작은 모두 루트를 거쳐야한다.
애그리게이트에 포함되는 객체를 외부에 노출하지 않음으로서 불변 조건을 유지할수 있다.

<Aggregate Root>
        User
  |             |
  V            V    
UserId    UserName

외부에서는 애그리게이트 내부에 있는 객체를 조작할수 없다.
애그리게이트를 조작하는 직접적인 인터페이스가 되는 객체는 애그리게이트 루트뿐이다.

var userName = new UserName("NewName");

//NG
user.Name = userName;

//OK
user.changeName(userName); 

어떤 방식을 취하든 결과는 같지만 changeName() 메서드를 통해 전달받은 값을 확인(null 체크 등)할 수 있다.
즉 사용자명이 아닌 유효하지 않는 데이터의 존재를 방지할수 있다.

서클 애그리게이트에 포함되는 서클명을 허용된것은 애그리게이트의 루트에 해당하는 Circle 객체뿐이다.
서클에 새로운 사용자를 추가하는것도 애그리게이트 루트를 통해야 한다.

circle.members.add(member);

이 코드는 애그리게이트의 규칙을 위반한 코드다.

서클 애그리게이트에 포함되는 members에 대한 조작은 애그리게이트의 루트인 Circle 객체를 통해야 한다.

public class Circle {
    private final CircleId id;
    private User owner;

    private List<User> membrers;



    public void join(User member) {
        if(member == null) throw new ArgumentNullException(nameof(member));

        if(member.size() >= 29)  {

            throw new CircleFullException(id);
        }

        members.add(member);
    }
}
circle.join(user);

join 메서드는 새로운 사용자를 서클에 추가할때 최대 인원 초과여부를 먼저 확인한다.
members 속성이 외부에 공개되지 않으므로 서클에 새로운 사용자를 추가하려면 join 메서드를 호출하는 방법뿐이다.
결과적으로 서클에 사용자를 추가할때 항상 최대 인원 초과 여부를 확인이 이루어지며, 서클의 최대 인원은 서클장을 포함한 최대 30명이라는 불변 조건이 항상 유지될수 있다.

속성에 직접 접근해 사용자를 추가하는 방식과 비교해 코드의 어감이 바꿨다.
서클 멤버에 새로운 사용자를 추가한다는 구체적인 처리내용이 읽히는데 비해 서클에 새로운 사용자를 참여시킨다는 직관적인 내용으로 바꿨다.
외부에서 내부 객체를 직접 다루는 대신 내부 객체를 감싸는 객체에 요청하는 형태를 취한다.
이러한 방법으로 불변 조건을 유지하면서도 직관과 좀더 일치하는 코드를 만들수 있다.

객체를 다루는 조작의 기본 원칙
데메테르의 법칙은 어떤 컨텍스트에서 다음 객체의 메서드만 호출할수 있게 제한한다.

  • 객체 자신
  • 인자로 전달받은 객체
  • 인스턴스 변수
  • 해당 컨텍스트에서 직접 생성한 객체
        if(member.size() >= 29)  {

            throw new CircleFullException(id);
        }

서클에 소속된 사용자의 수가 규칙에 정해진 최대 인원을 초과하는지 확인하는 코드로 Circle 객체의 속성인 members에 직접 접근해 size() 메서드를 호출한다.
이는 데메테르의 법칙에 명시한 '메서드를 사용할수 있는 객체의 범위'를 벗어낫기 때문에 테메테르의 법칙을 위반한 코드다.
또한 서클의 최대 인원에 대한 로직이 여기저기 흩어지게 만든다.

    public boolean isFull() {
        return members.size() >= 29;
    }

    public void join(User member) {
        if(member == null) throw new ArgumentNullException(nameof(member));

        if(isFull()>= 29)  {

            throw new CircleFullException(id);
        }

        members.add(member);
    }

서클의 최대 인원수의 관련된 지식은 모두 isFull() 메서드에 집중된다.
최대인원이 변경돼도 isFull 메서드만 수정하면된다.

내부 데이터를 숨기기 위해

객체 내부의 데이터는 함부로 외부에 공개돼서는 안된다.
그러나 데이터를 외부에 전혀 공개하지 않으면 리포지터리가 객체를 데이터스토어에 저장할수 없다.

public class EFUserRepository implements IUserRepository {

    public void save(User user) {
        var userDataModel = new UserDataModel(user.getId(), user.getName());

        context.users.add(userDataModel);
        context.saveChanges();
    }
}

User 클래스가 id, name을 완전히 비공개하면 이 코드는 컴파일 에러가 일으킨다.

가장 단순하고 일반적인 해결책으로는 규칙을 이용한 보호를 들수 있다.
즉 리포지토리 객체외에는 애그리게이트의 내부 데이터에 접근하는 코드를 함부로 작성하지 않는다. (다시 말해 게터를 사용하지 않는)것이다.
이 방법은 팀 내 공감대를 잘 형성한다면 가장 적은 비용으로도 효과를 거둘수 있다.
하지만 합의는 강제력이 없기 때문에 개발자가 실수 혹은 고의로 규칙을 깨는 상황이 발생할수 있다.

다른 방법으로 노티피케이션 객체를 이용

public interface IUserNotification {

    void id(UserId id);
    void name(UserName name);
}
public class UserDataModelBuilder implements IUserNotification {

    private UserId id;
    private UserName name;

    public void id(UserId id) {
        this.id = id;
    }

    public void name(UserName name) {
        this.name = name;
    }

    //전달받은 데이터로 데이터 모델을 생성하는 메서드
    public UserDataModel build() {
        return new UserDataModel(id, name);
    }
}
public class User {

    private final UserId id;
    private final UserName name;

    public void notify(IUserNotification note) {
        note.id(id);
        note.name(name);
    }
}

이런 방법으로 객체의 내부 데이터는 비공개로 그대로 두면서 외부에 데이터를 전달할수 있다.

public class EFUserRepository implements IUserRepository {

    public void save(User user) {

        //노티피케이션 객체를 전달했다가 다시 회수해 내부 데이터를 입수한다.
        var userDataModelBuilder = new UserDataModelBuilder();
        user.notify(userDataModelBuilder);

        // 전달받은 내부 데이터로 데이터 모델을 생성
        var userDataModel = userDataModelBuilder.build();

        // 데이터 모델을 ORM에 전달
        context.users.add(userDataModel);
        context.saveChanges();
    }
}

코드 양이 크게 늘어난다는 단점이 있다.
그리고 노티피케이션 객체 및 관련 코드를 한꺼번에 생성해주는 도구를 만들어 사용하면 이런 문제를 피할수 있다.

애그리게이트의 경계는 어떻게 정할것인가?
경계를 정하는 원칙 중 가장 흔히 사용하는것은 '변경의 단위'이다.
변경의 단위가 애그리게이트의 경계로 이어지는 이유는 그 원칙을 어겨보면 이해하기 쉽다.

서클을 변경할때는 서클 애그리게이트 내부로 변경이 제한되어야 하고, 사용자를 변경할때도 사용자 애그리게이트 내부의 정보만 변경되어야 한다.
만약 이런 규칙을 위반하고 서클 애그리게이트에서 자신의 경계를 넘어 사용자 애그리게이트까지 변경하려고 하면 프로그램에 어떤일이 일어나는지 보자.

public class Circle {

    private List<User> members;

    public void changeMemberName(UserId id, UserName name) {

        var target = members.firstOrDefault( x -> x.getId().equals(id));

        if (target != null) {
            target.changeName(name);

        }
    }
}

서클에 소속된 사용자의 사용자명을 변경하는 코드다.
변경의 범위가 서클을 벗어난다는 점이다.
서클 애그리게이트의 경계를 넘어 사용자 애그리게이트를 조작하면 그 영향이 리포지토리에 나타난다.

사용자 애그리게이트를 변경한 내용이 저장되지 않는다.
애그리게이트 경계를 넘어선 변경을 허용하려면 리포지토리에 수정이 필요하다.
즉 서클 애그리게이트의 경계를 넘어 사용자 애그리게이트까지 변경할수 있게 리포지토리를 수정한 결과 서클 리포지토리의 로직 대부분이 사용자의 정보를 수정하는 코드로 오염됐다.

애그리게이트에 대한 변경은 해당 애그리게이트 자신에게만 맡기고 퍼시스턴시 요청도 애그리게이트 단위로 해야 한다.
리포지토리는 애그리게이트마다 하나씩 만든다.

식별자를 이용한 컴포지션
Circle 객체는 User 클래스의 인스턴스를 컬렉션 객체에 저장하고 프로퍼티를 통해 객체에 접근해 메서드를 호출할수 있는데 그것 자체를 문제로 보는 경우.
애그리게이트의 경계를 넘지 않는다는 불문율을 만드는것보다 더 나은 방법은 없을까?
바로 인스턴스를 갖지 않는것이다.
인스턴스를 실제로 갖지 않지만 그런 것처럼 보이게끔하는것, 엔티티에 그런것이 있다.
바로 식별자다.

서클 애그리게이트가 사용자 애그리게이트를 직접 포함하는 대신 사용자 애그리게이트의 식별자를 포함하게 코드를 수정한다.

public class Circle {

    //public List<User> members;
    public List<UserId> members;
}

이런 방법을 사용하면 member 프로퍼티를 공개하더라도 User 객체의 메서드를 호출하는 일은 없다.
User 객체의 메서드를 호출해야 한다면 UserRepository에서 UserId를 키로 해서 해당하는 User 객체를 복원 받은 뒤 이 객체의 메서드를 호출하는 방법뿐이다.
이런 절차가 강제된다면 적어도 부주의하게 메서드를 호출해 애그리게이트 너머의 영역을 변경하는 일은 일어나지 않는다.
또 이 방법은 메모리를 절약하는 부수적인 효과도 있다.

애그리게이트의 크기와 조작의 단위
애그리게이트의 크키가 지나치게 커지면 그만큼 애그리게이트를 대상으로 처리가 실패할 가능성이 높다.

따라서 애그리게이트의 크기는 가능한 작게 유지하는것이 좋다.
지나치게 비대해진 애그리게이트가 있다면 애그리게이트 범위를 재검토해야 한다.

또 한 트랜잭션에서 여러 애그리게이트를 다루는 것도 가능한 피해야 한다.
여러 애그리게이트에 걸친 트랜잭션은 범위가 큰 애그리게이트와 마찬가지로 광범위한 데이터에 로크를 걸 가능성이 높다.

  • 애그리게이트는 변경의 단위이다.
  • 데이터를 변경하는 단위로 다뤄진 객체의 모임을 애그리게이트이라고 한다.
  • 애그리게이트에는 루트 객체가 있고, 모든 조작은 이 루트 객체를 통해 이뤄진다.
  • 그러므로 애그리게이트 내부의 객체에 대한 조작은 제약이 따르며 이로 인해 애그리게이트 내부의 불변조건이 유지된다.
  • 애그리게이트는 데이터 변경의 단위가 되므로 트랜잭션이나 로크와도 밀접한 관계를 갖는다.

출처 - 도메인 주도 설계 철저 입문
저 - 나루세미 마사노부

반응형

'JAVA > DDD' 카테고리의 다른 글

도메인 주도 설계(소개)  (0) 2018.03.24
리포지터리와 모델구현(JPA 중심)  (0) 2017.12.23
트랜잭션 범위  (0) 2017.12.23
Aggregate 애그리거트  (0) 2017.12.19
아키텍처  (0) 2017.12.18