DDD(Domain Driven Design) - Entity (엔터티)
DDD 를 알기전에는 나역시 그랬고, 반 버논의 도메인 주도 설계 구현에서도 언급한 내용이 있다.
바로 "개발자는 도메인보다 데이터에 초점을 맞추려는 경향이 있다." 소프트웨어 개발에 관한 대부분의 접근법이 데이터베이스에 중점을 두기 때문에, DDD를 처음 접하는 사람에게 일어날 수 있는 현상이라고 한다. 나도 매우 동의한다. 지금까지 그렇게 해왔다.
풍부한 행동을 바탕으로 도메인 개념을 설계하지 않고, 데이터의 속성(컬럼)과 연결(외래 키)을 먼저 생각하려 한다. 이를 바탕으로 데이터 모델을 대응하는 객체로 투영하게 되는데, 이로 인해 게터와 세터로 가득찬 무기력한 도메인이 되버린다.
지금부터 엔터티란 무엇인가에 대해서 다루어 보려고한다.
1. 엔터티는 식별자를 가진다.
2. 엔터티는 변화 가능성(mutability)을 가진다.
-> 엔터티는 고유한 대상으로 긴 시간에 걸쳐 계속해서 변화한다. 어떤 예를 들 수 있을까? 지금 재직 중인 회사에서 엔터티로 다룰 수 있다면, 제일 먼저 생각나는 엔터티는 Order(주문) 이다. 특정 식별자를 가진 주문의 상태는 계속해서 변화한다.
객체를 특성(attribute) 이 아니라 식별자에 따라 구분한다면, 모델을 정의할 때 이를 우선적으로 다루어야 한다고 한다. 클래스의 정의를 단순하게 유지하면서 수명주기의 지속성(continuity)과 식별자에 집중해야 한다고 한다. 형태나 히스토리에 상관없이, 각 객체를 구분하는 수단을 정의해야 한다고 한다.
이 모델에선 같은 대상이 된다(아마도 equals and hashcode 를 의미하는 것 같다)는 의미가 무엇인지 반드시 정의해야 함을 강조하고 있다.
앞서, 엔터티는 식별자를 가지고 변화 가능성을 가진다고 했다. 따라서 시간이 흘러도 고유성(uniqueness) 을 보장할 수 있도록, 식별자를 구현하는 방법들을 확보하는 것이 중요하다.
그렇다면, 식별자를 구현하는 방법에는 어떤것들이 있을까?
간단하지만, 치명적인 단점이 있다. 책에서도 언급하지만 대부분의 유스케이스에서 맞지 않다고 한다.
나는 매우 동의한다. 식별자는 변하지 않아야 한다. 하지만 사용자가 식별자를 제공하게 되면 변경의 여지가 있을 수 있는 경우가 보통이다. 다시 정리하자면, 고유하고 정확하며 오랫동안 지속돼야 하는 식별자의 생성을 사용자가 수행해도 될런지??
물론 장점이 없는것은 아니다, 사람이 읽을 수 있게 된다. 하지만 단점이 매우 크다. 개인적으로 비추한다.
UUID는 성능이 우수하다. 1초 동안에 특정 종류의 엔터티가 무수히 생성되어도 속도를 유지할 수 있다. 고성능 도메인에서도 얼마나 많은 인스턴스든 백그라운드에서 캐시를 채우며 성능을 커버할 수 있다고 한다. 반면에, UUID는 사람이 읽을 수 없다. 이 점을 보완하기 위해서 특정 세그먼트와 사람이 식별가능한 문자열을 조합한다.
ex) ORD-2020-12-31-f36ab21c
와 같이 ORD는 주문을 의미하며 다음의 이어지는 문자열은 생성일을 나타낼 수 있다. 이는 UUID의 유일성을 얼마나 신뢰할지에 대해서 세그먼트의 허용 여부를 정하면 된다.
물론 단점은 분명하다. 식별자를 얻기 위해 데이터베이스까지 갔다가 와야한다. UUID와 비교해서 성능적으로 나쁘다. 물론 시퀀스/증가 값을 캐싱하는 방법으로 어느정도 성능을 보완할 수는 있다. 하지만 보통 서버 노드가 재시작시 사용 하지 않은 값들이 유실되어 중간중간 이가빠지게 된다..
이 방법은 사실 나도 언뜻 정확하게 이해하기가 어렵다. 책에서도 식별자 생성 전략 중에서 가장 복잡하다고 언급한다. 이는 바운디드 컨텍스트 간에 의존성이 생길 수 있기 때문에 최대한 보수적으로 사용하길 권장하고 있다.
식별자 구현 방법에 있어서, 살펴보았다. 그렇다면 식별자 생성에 있어서 트레이드 오프 관계를 얘기하려고한다.
빠른 식별자 생성과 할당 vs 늦은 식별자 생성과 할당
-> 예를들어, 엔터티 생성 시 생성에 대한 도메인 이벤트를 발행하고, 이를 구독하는 시나리오일 경우 늦은 식별자 생성(영속성 메커니즘이 식별자를 생성한다.) 을 사용할 경우 식별자가 생성될 때 까지 이벤트를 발행하지 못한다. 반대로 빠른 식별자 생성(UUID or 다른 방법) 을 사용하게 되면, 미리 식별자를 생성하기 때문에 생성 이벤트를 바로 발행할 수 있는 장점이 있다.
만약에 어떤 커맨드 발생 시 식별자가 바로 필요하다면 빠른 식별자 생성 방법이 적합하다.
반대로 식별자가 커맨드 발생 시 바로 필요하지 않다면 늦은 식별자 생성 방법을 취해도 좋다.
그 다음에 나오는 내용에 관련해서는 실제적으로 도메인 전문가와 개발자간의 바운디트 컨텍스트내에서 유비쿼터스 언어를 정의해가며[ 요구사항(유스케이스나 사용자 스토리가 전혀 아니다) 을 도메인 전문가와 개발자가 정제해 나가며 ] 엔터티를 설계하는 예제를 담고 있다. 이와 관련되어서는 상세하게 간추려서 요약할 내용은 크게 없다. 객체지향적으로 설계하는 방법을 담고 있다. 실무에서 직접적으로 해 볼 수 있는 기회가 생기면 좋겠다.
클래스 다이어그램이을 통해서 각각의 설계 변화 과정과 요구사항을 정제해 나가면서 유비쿼터스 언어를 집대성 하는 과정을 개괄적으로 다룰 수 있겠지만 사실 너무 귀찬타.. 아래 섹션들만 언급하고 스킵하겠다.
1. 엔터티와 속성을 알아내기
2. 필수 행동 파헤치기
하지만 다음에 나오는 섹션은 중요해서 다루어본다.
책에서 다루는 단적인 예에 접근하려한다.
어떤 한 객체가 User와 Person 의 역할을 모두 수행하게 만들 수 있다. 이것이 좋은 방향이라고 가정하고 진행한다. 이 가정에 따르면, 별도의 Person 객체를 User 객체의 참조 연결로 포함해야 할 이유가 전혀 없다.
대개 둘 이상의 객체 사이에서 유사점과 차이점을 함께 발견하기 때문인데, 겹치는 특성은 하나의 객체상에서 여러 인터페이스를 섞어낼 수 있다.
나는 이와같은 구현을 보고 조금 당황스러웠다. 말이 안되진 않는데 그렇다고 이게 맞다고 하기도 애매하다. 그리고 두 인터페이스가 복잡할수록 HumanUser 는 엉망이 된다. 만약 한 객체가 User, Person, System 3가지 역할을 수행한다면 더 난감해진다. 책에서는 Person 과 System 을 아래와 같이 Principal 로 묶는다면 단순하게 할 수 있다고 한다.
해당 설계를 바탕으로 runtime 시에 실제 접근 주체를 결정하려고 한다. 사람과 시스템 주체는 다르게 구현된다. 속성정보들이 다를 것이다. 그리고 forwarding delegation 위임 전달 구현의 설계를 시도한다.
runtime 에서 어떤 타입이 존재하는지 해당 객체에게 위임하도록 한다. 아래와 같다.
처음에 책에서 해당 코드를 보고 "도대체 이건 뭐지?" 싶었다. 이 난해한 설계가 책에서는 이를 객체 정신 분열증 ( Object schizophrenia ) 라고 부른다. 딱 적합하다. 행동은 forwarding 혹은 dispatching 으로 알려진 기법을 사용해 위임된다.
psersonPrincipal 과 systemPrincipal 은 행동이 실제로 실행되는 엔터티 UserPrincipal의 식별자를 옮겨주지 않는다. 객체 정신 분열증은 위임된 객체가 위임받기 전 본래의 객체 식별자를 모르는 상황을 나타낸다. 위임받은 내부에선 실제 자신이 누구인지 혼란을 겪는다.
'위임은 복잡하게 만들지 않으며 단순하게 해줄 때에만 좋은 설계다' 위의 예제는 잘못된 설계를 나타내준다.
책에서는 해당예제를 해결하는 내용은 다루고 있지 않다. 해당 문제는 실제로 종종 마주하게 될 수 있다고 언급한다. 책에서는 Qi4j 와 같이 올바른 도구를 사용하면 문제를 개선할 수 있다고 한다.
https://svn.apache.org/repos/asf/zest/site/content/java/2.0/tutorials.html#_overview
아래의 Qi4j 를 사용한 단적인 예를 확인할 수 있다. Qi4j 를 사용해 본적이 없어서 잘 모르지만, AOP 도구로 확인이 된다.
이는 책에서 다루었던 pricipal 예제와 상당히 유사하다.
https://dzone.com/articles/aspect-oriented-programming-qi4j
또 다른 설계의 이야기로, 역할 인터페이스를 작은 단위로 만들어서 사용하길 권장하고 있다. 이는 엔터티의 역할이 유스케이스에 맞게 ( 클라이언트에서 필요한 역할에 따라서) 해당 인터페이스 타입으로 fetching 하여 역할을 수행하도록 하는 패턴이다.
단위가 작은 인터페이스는 Customer 등의 구현한 클래스 자체에 행동을 구현하기가 좀 더 쉽도록 해준다. 구현을 별도의 클래스로 위임할 필요가 없으며, 그렇기 때문에 객체 정신 분열증을 막아준다.
바로 "개발자는 도메인보다 데이터에 초점을 맞추려는 경향이 있다." 소프트웨어 개발에 관한 대부분의 접근법이 데이터베이스에 중점을 두기 때문에, DDD를 처음 접하는 사람에게 일어날 수 있는 현상이라고 한다. 나도 매우 동의한다. 지금까지 그렇게 해왔다.
풍부한 행동을 바탕으로 도메인 개념을 설계하지 않고, 데이터의 속성(컬럼)과 연결(외래 키)을 먼저 생각하려 한다. 이를 바탕으로 데이터 모델을 대응하는 객체로 투영하게 되는데, 이로 인해 게터와 세터로 가득찬 무기력한 도메인이 되버린다.
지금부터 엔터티란 무엇인가에 대해서 다루어 보려고한다.
1. 엔터티는 식별자를 가진다.
2. 엔터티는 변화 가능성(mutability)을 가진다.
-> 엔터티는 고유한 대상으로 긴 시간에 걸쳐 계속해서 변화한다. 어떤 예를 들 수 있을까? 지금 재직 중인 회사에서 엔터티로 다룰 수 있다면, 제일 먼저 생각나는 엔터티는 Order(주문) 이다. 특정 식별자를 가진 주문의 상태는 계속해서 변화한다.
객체를 특성(attribute) 이 아니라 식별자에 따라 구분한다면, 모델을 정의할 때 이를 우선적으로 다루어야 한다고 한다. 클래스의 정의를 단순하게 유지하면서 수명주기의 지속성(continuity)과 식별자에 집중해야 한다고 한다. 형태나 히스토리에 상관없이, 각 객체를 구분하는 수단을 정의해야 한다고 한다.
이 모델에선 같은 대상이 된다(아마도 equals and hashcode 를 의미하는 것 같다)는 의미가 무엇인지 반드시 정의해야 함을 강조하고 있다.
앞서, 엔터티는 식별자를 가지고 변화 가능성을 가진다고 했다. 따라서 시간이 흘러도 고유성(uniqueness) 을 보장할 수 있도록, 식별자를 구현하는 방법들을 확보하는 것이 중요하다.
그렇다면, 식별자를 구현하는 방법에는 어떤것들이 있을까?
1. 사용자가 식별자를 제공한다.
-> 말 그대로 사용자가 직접 식별 가능한 값이나 기호를 입력 필드에 입력하거나 사용가능한 문자 집합에서 선택하여 엔터티를 생성하는 방법이다.간단하지만, 치명적인 단점이 있다. 책에서도 언급하지만 대부분의 유스케이스에서 맞지 않다고 한다.
나는 매우 동의한다. 식별자는 변하지 않아야 한다. 하지만 사용자가 식별자를 제공하게 되면 변경의 여지가 있을 수 있는 경우가 보통이다. 다시 정리하자면, 고유하고 정확하며 오랫동안 지속돼야 하는 식별자의 생성을 사용자가 수행해도 될런지??
물론 장점이 없는것은 아니다, 사람이 읽을 수 있게 된다. 하지만 단점이 매우 크다. 개인적으로 비추한다.
2. 어플리케이션이 식별자를 생성한다.
-> 어플리케이션이 클러스터링되거나 다수의 컴퓨팅 노드에 걸쳐 배포된 상황이라면 주의를 해야겠지만, 자동으로 식별자를 생성할 수 있는 신뢰도 높은 방법이 있다. 바로 UUID, GUID 이다.UUID는 성능이 우수하다. 1초 동안에 특정 종류의 엔터티가 무수히 생성되어도 속도를 유지할 수 있다. 고성능 도메인에서도 얼마나 많은 인스턴스든 백그라운드에서 캐시를 채우며 성능을 커버할 수 있다고 한다. 반면에, UUID는 사람이 읽을 수 없다. 이 점을 보완하기 위해서 특정 세그먼트와 사람이 식별가능한 문자열을 조합한다.
ex) ORD-2020-12-31-f36ab21c
와 같이 ORD는 주문을 의미하며 다음의 이어지는 문자열은 생성일을 나타낼 수 있다. 이는 UUID의 유일성을 얼마나 신뢰할지에 대해서 세그먼트의 허용 여부를 정하면 된다.
3. 영속성 메커니즘이 식별자를 생성한다.
-> 데이터베이스로 시퀀스나 증가 값을 호출한 결과를 사용할 수 있다. java 에선 2byte short 32,767 개의 고유 식별자를 가지며, 4byte int 의 경우 2,147,483,647 개의 고유 식별자를, 8byte long 의 경우, 9,223,372,036,854,775,807 개의 식별자가 가능하다.물론 단점은 분명하다. 식별자를 얻기 위해 데이터베이스까지 갔다가 와야한다. UUID와 비교해서 성능적으로 나쁘다. 물론 시퀀스/증가 값을 캐싱하는 방법으로 어느정도 성능을 보완할 수는 있다. 하지만 보통 서버 노드가 재시작시 사용 하지 않은 값들이 유실되어 중간중간 이가빠지게 된다..
4. 바운디드 컨텍스트가 식별자를 할당한다.
-> 식별자를 관리하는 바운디드 컨텍스트를 두고 이 바운디드 컨텍스트가 식별자를 제공하는 경우이다.이 방법은 사실 나도 언뜻 정확하게 이해하기가 어렵다. 책에서도 식별자 생성 전략 중에서 가장 복잡하다고 언급한다. 이는 바운디드 컨텍스트 간에 의존성이 생길 수 있기 때문에 최대한 보수적으로 사용하길 권장하고 있다.
식별자 구현 방법에 있어서, 살펴보았다. 그렇다면 식별자 생성에 있어서 트레이드 오프 관계를 얘기하려고한다.
빠른 식별자 생성과 할당 vs 늦은 식별자 생성과 할당
-> 예를들어, 엔터티 생성 시 생성에 대한 도메인 이벤트를 발행하고, 이를 구독하는 시나리오일 경우 늦은 식별자 생성(영속성 메커니즘이 식별자를 생성한다.) 을 사용할 경우 식별자가 생성될 때 까지 이벤트를 발행하지 못한다. 반대로 빠른 식별자 생성(UUID or 다른 방법) 을 사용하게 되면, 미리 식별자를 생성하기 때문에 생성 이벤트를 바로 발행할 수 있는 장점이 있다.
만약에 어떤 커맨드 발생 시 식별자가 바로 필요하다면 빠른 식별자 생성 방법이 적합하다.
반대로 식별자가 커맨드 발생 시 바로 필요하지 않다면 늦은 식별자 생성 방법을 취해도 좋다.
대리 식별자 (surrogate identity)
-> 보통의 ORM 의 경우 ORM 고유의 방법으로 객체를 식별하길 원한다. 단적인 예로 JPA 의
@GeneratedValue, @SequenceGenerator 를 들 수 있다.
대리 식별자를 사용하게 되면, 고유의 어플리케이션 단에서 사용하는 엔터티 식별자와 ORM 에서 관리할 식별자로 엔터티에 총 2개의 식별자를 사용하게 된다.
책에서는 계층 슈퍼 타입을 통해 상속을 통해서 대리 식별자의 사용하는 예시를 보여준다.
하지만 나는 가능하다면 대리 식별자를 사용하지 않는것이 개발하는 입장에서 혼선을 주지 않을 것이라고 생각하며, 관리 포인트가 2가지로 늘어나게 되어 염려스러운 입장도 있다. 하지만 물론 올바른 상화에 올바르게 사용한다면 무리는 없다고 생각한다.
식별자의 안전성
-> 대부분의 경우 식별자는 수정하지 못하도록 보호하고, 할당된 수명주기에 걸쳐 안정적으로 유지되어야 한다.
책에서는 식별자 setter 를 클라이언트로부터 숨기며, 이미 노출되어 있을 경우 setter 내에 Assertion 가드를 취하는 방법을 권장한다.
나는 setter 를 코드 레벨에서 생성하지 않는것이 우선 좋다고 생각한다. 프레임워크 단에서 리플렉션을 통해서 필드에 접근하는 방법 이외에는 식별자에게 가능한 setter를 열어두지 않는 방법이 좋아보인다.
하지만 불가피할 경우 책에서 언급하는 방법데로 Assertion 가드를 사용할 수 있다.
하지만 이방법은 코드가 지저분해질 수 있다.
그 다음에 나오는 내용에 관련해서는 실제적으로 도메인 전문가와 개발자간의 바운디트 컨텍스트내에서 유비쿼터스 언어를 정의해가며[ 요구사항(유스케이스나 사용자 스토리가 전혀 아니다) 을 도메인 전문가와 개발자가 정제해 나가며 ] 엔터티를 설계하는 예제를 담고 있다. 이와 관련되어서는 상세하게 간추려서 요약할 내용은 크게 없다. 객체지향적으로 설계하는 방법을 담고 있다. 실무에서 직접적으로 해 볼 수 있는 기회가 생기면 좋겠다.
클래스 다이어그램이을 통해서 각각의 설계 변화 과정과 요구사항을 정제해 나가면서 유비쿼터스 언어를 집대성 하는 과정을 개괄적으로 다룰 수 있겠지만 사실 너무 귀찬타.. 아래 섹션들만 언급하고 스킵하겠다.
1. 엔터티와 속성을 알아내기
2. 필수 행동 파헤치기
하지만 다음에 나오는 섹션은 중요해서 다루어본다.
엔터티의 역할과 책임
-> 은 다루어야 겠다. OOP 에서 일반적으로 인터페이스는 구현 클래스의 역할을 결정한다. 올바르게 설계되었다면, 클래스는 구현하는 각 인터페이스마다 하나의 역할을 갖는다. 만약 클래스에 명시적으로 선언된 역할이 없다면 ( 명시적으로 인터페이스도 구현 하지 않은 ) 기본적으로 해당 클래스의 역할을 한다. 이는 해당 클래스의 퍼블릭 메소드라는 암시적 인터페이스를 갖는다. 따라서 어떤 User 라는 클래스가 어떠한 명시적 인터페이스도 구현하지 않은 경우, User 라는 역할을 수행한다.책에서 다루는 단적인 예에 접근하려한다.
어떤 한 객체가 User와 Person 의 역할을 모두 수행하게 만들 수 있다. 이것이 좋은 방향이라고 가정하고 진행한다. 이 가정에 따르면, 별도의 Person 객체를 User 객체의 참조 연결로 포함해야 할 이유가 전혀 없다.
대개 둘 이상의 객체 사이에서 유사점과 차이점을 함께 발견하기 때문인데, 겹치는 특성은 하나의 객체상에서 여러 인터페이스를 섞어낼 수 있다.
나는 이와같은 구현을 보고 조금 당황스러웠다. 말이 안되진 않는데 그렇다고 이게 맞다고 하기도 애매하다. 그리고 두 인터페이스가 복잡할수록 HumanUser 는 엉망이 된다. 만약 한 객체가 User, Person, System 3가지 역할을 수행한다면 더 난감해진다. 책에서는 Person 과 System 을 아래와 같이 Principal 로 묶는다면 단순하게 할 수 있다고 한다.
해당 설계를 바탕으로 runtime 시에 실제 접근 주체를 결정하려고 한다. 사람과 시스템 주체는 다르게 구현된다. 속성정보들이 다를 것이다. 그리고 forwarding delegation 위임 전달 구현의 설계를 시도한다.
runtime 에서 어떤 타입이 존재하는지 해당 객체에게 위임하도록 한다. 아래와 같다.
처음에 책에서 해당 코드를 보고 "도대체 이건 뭐지?" 싶었다. 이 난해한 설계가 책에서는 이를 객체 정신 분열증 ( Object schizophrenia ) 라고 부른다. 딱 적합하다. 행동은 forwarding 혹은 dispatching 으로 알려진 기법을 사용해 위임된다.
psersonPrincipal 과 systemPrincipal 은 행동이 실제로 실행되는 엔터티 UserPrincipal의 식별자를 옮겨주지 않는다. 객체 정신 분열증은 위임된 객체가 위임받기 전 본래의 객체 식별자를 모르는 상황을 나타낸다. 위임받은 내부에선 실제 자신이 누구인지 혼란을 겪는다.
'위임은 복잡하게 만들지 않으며 단순하게 해줄 때에만 좋은 설계다' 위의 예제는 잘못된 설계를 나타내준다.
책에서는 해당예제를 해결하는 내용은 다루고 있지 않다. 해당 문제는 실제로 종종 마주하게 될 수 있다고 언급한다. 책에서는 Qi4j 와 같이 올바른 도구를 사용하면 문제를 개선할 수 있다고 한다.
https://svn.apache.org/repos/asf/zest/site/content/java/2.0/tutorials.html#_overview
아래의 Qi4j 를 사용한 단적인 예를 확인할 수 있다. Qi4j 를 사용해 본적이 없어서 잘 모르지만, AOP 도구로 확인이 된다.
이는 책에서 다루었던 pricipal 예제와 상당히 유사하다.
https://dzone.com/articles/aspect-oriented-programming-qi4j
또 다른 설계의 이야기로, 역할 인터페이스를 작은 단위로 만들어서 사용하길 권장하고 있다. 이는 엔터티의 역할이 유스케이스에 맞게 ( 클라이언트에서 필요한 역할에 따라서) 해당 인터페이스 타입으로 fetching 하여 역할을 수행하도록 하는 패턴이다.
단위가 작은 인터페이스는 Customer 등의 구현한 클래스 자체에 행동을 구현하기가 좀 더 쉽도록 해준다. 구현을 별도의 클래스로 위임할 필요가 없으며, 그렇기 때문에 객체 정신 분열증을 막아준다.
고정자
-> 때론 엔터티는 하나 이상의 고정자 ( invariant ) 를 갖는다. 고정자란 엔터티의 전체 수명주기에 걸쳐 트랜잭션적 일관성이 유지돼야 하는 상태다. 고정자는 애그리게잇에 관한 문제이기도 한데, 애그리게잇 루트는 언제나 엔터티이다. 엔터티가 포함된 객체의 null 이 아닌 상태를 바탕으로 한 고정자나 다른 상태의 계산 결과를 통해 이뤄지는 고정자를 갖고 있다면, 하나 이상의 생성자 매개변수로 해당 상태를 제공해야 한다.
단적인 예로 모든 User 객체는 반드시 tenantId, username, password, person 4 가지 고정자를 포함해야 한다. 즉 생성이 성공적으로 이루어지면, 이렇게 선언한 인스턴스 변수의 참조는 절대 null 이 되지 않는다. 아래 예제에서는 생성자와 setter 자가 위임을 통한 Assertion Guard 를 통해 이를 보장한다.
그 밖에, 엔터티의 변화 추척에 대한 내용도 있다.
하지만 이는 추후에 다룰 이벤트 소싱에 대한 내용이 많아서 생략하도록 하겠다.
혹시 틀린부분이나 보완할 부분이 있으면 편하게 알려주시면 감사하겠습니다.
댓글
댓글 쓰기