5장. 엔티티 / IDDD (도메인 주도 설계 구현)
개요
# 참고
IMPLEMENTING DOMAIN DRIVEN DESIGN (도메인 주도 설계 구현) - 반 버논 지음
# 개요
DDD에 대한 기술서적을 읽고 DDD가 무엇인지 이해하고 어떻게 코드에 적용시킬 수 있는지 고민해 보려고 한다.
# 5장의 로드맵
- 고유한 대상을 설계할 때 엔티티가 왜 올바른 위치를 가지는지
- 엔티티를 위한 고유 식별자의 생성 방법
- 엔티티 설계에서 유비쿼터스 언어를 잡아내는 방법
- 엔티티의 역할과 책임
- 엔티티의 유효성 검사와 그 결과를 저장소에 저장하는 방법
5장. 엔티티
개발자는 도메인보다 데이터에 초점을 맞추려는 경향이 있다. 소프트웨어 개발에 관한 대부분의 접근법이 데이터베이스에 중점을 두기 때문에, DDD를 처음 접하는 사람에게 일어날 수 있는 현상이다. 풍부한 행동을 바탕으로 도메인 개념을 설계하진 않고, 주로 데이터의 속성(열)과 연결(외래키)을 먼저 생각하려 한다. 이로 인해 도메인 모델 안에 있는 거의 모든 개념이 게터와 세터 메소드를 너무 많이 갖고 있는 엔티티로 코딩된다.
5-1. 엔티티를 사용하는 이유
도메인 개념의 개별성에 신경을 쓸 때, 한 개념을 시스템 내의 나머지 모든 객체와 반드시 구분해야 하는 제약 조건이 있을 때, 이를 엔티티로 설계한다. 엔티티는 고유한 대상으로 긴 시간에 걸쳐 계속해서 변화할 수 있다. 긴 시간동안 계속해서 변화가 일어나 많이 달라 보일 수 있지만 이는 같은 식별자를 가진 같은 객체 이다.
엔티티와 값 객체 사이의 차이점은 고유 식별자와 변화 가능성이라는 특징이 있다. 엔티티가 모델링 도구로 적합하지 않을 때도 있다. 대부분 개념은 값으로 모델링해야 한다.
5-2. 고유식별자
엔티티의 설계 초기엔 고유 식별자의 주심을 이루는 우선적인 특성과 행동을 비롯해 이를 쿼리하는데 도움을 주는 요소에 의도적으로 집중하고, 우선적인 사항을 마무리할 때까진 다른 특성이나 행동을 의도적으로 무시한다. 시간이 흘러도 고유성의 보존됨을 보장해줄 수 있도록, 식별자를 구현하는 다양한 옵션의 확보는 아주 중요한 일이다.
값 객체는 고유 식별자의 홀더 역할을 할 수 있다. 값 객체는 불변하기 떄문에 식별자의 안정성이 확보되고, 식별자의 유형에 따른 모든 행동은 중앙집중화된다. 식별자 행동의 중심점이 생기면, 단순함의 정도와는 상관없이 모델의 다른 부분이나 클라이언트로 노하우가 새나가는 것을 막아준다.
# 사용자가 식별자를 제공한다.
사용자가 직접 고유 식별자의 세부사항을 입력하는 편이 간단해 보일 수 있다. 사용자가 식별 가능한 값이나 기호를 입력 필드에 입력하거나 사용 가능한 문자 집합에서 선택하면 엔티티가 생성된다. 하지만 이러한 식별자를 사용자가 직접 정의하는 것은 문제가 있다. 양질의 식별자를 생성하는 일을 사용자에게 의지한다는 점이다. 식별자는 변하지 않아야 하기 때문에 대개 사용자가 이를 바꿔서는 안된다. 사용자 입력 값은 언제든 매칭에 사용할 엔티티의 속성으로 고려해도 되지만, 이를 고유 식별자로 사용해선 안 된다.
# 애플리케이션이 식별자를 생성한다.
애플리케이션이 클러스터링되거나 다수의 서버를 운영하고 있다면 신경을 써야 하지만, 고유 식별자를 자동으로 생성할 수 있는 방법을 사용하는 것이 신뢰도도 높고 좋다. 높은 정확도로 완벽하게 고유 식별자를 생성하는 방법은 UUID와 GUID가 있다. 자바에서는 공식적으로 표준 UUID 생성기로 채택되었다. UUID는 상대적으로 생성이 빠른 식별자이고, 영속성 메커니즘과 같은 외부와의 상호 교류가 필요하지 않다. 고성능의 도메인에선 얼마나 많은 UUID 인스턴스든 백그라운드에서 캐시를 다시 채우며 캐싱할 수 있다. 이런 큰 식별자를 사용하면 드물게 메모리 부담 때문에 비실용적인 렌더링이 발생하기도 한다. 이럴 땐 영속성 메커니즘을 통해 생성한 8바이트의 긴 식별자가 문제를 개선할 수 있다.
# 영속성 메커니즘이 식별자를 생성한다.
고유 식별자의 생성을 영속성 메커니즘에 위임하는 방식만의 이점이 있다. 데이터베이스로 시퀀스나 증가 값을 호출하는 결과는 언제나 고유하다. 심지어 8바이트의 long 정소는 9,223,372,036,854,775,807개의 고유 식별자를 제공할 수 있다. 하지만 이러한 방식은 성능적 측면이 단점이 될 가능성도 있따. 값을 얻기 위해 데이터베이스까지 가야 한다는 점이 애플리케이션 안에서 식별자를 생성할 때에 비해 유의미하게 많은 시간을 소모할 수 있따. 이는 데이터베이스의 부하와 애플리케이션의 요청에 따라 달라진다. 이런 문제를 피하는 방법으론 리파지토리 내부와 같이, 애플리케이션의 안쪽에 시퀀스/증가 값을 캐싱하는 방법이 있다. 이 방법이 잘 작동할 수도 있지만, 보통 서버 노드가 재시작되면 분명 아직 사용하지 않는 값은 유실된다.
이렇게 잃어버린 캐시가 정말 적은 수라면, 미리 할당된 값을 캐싱하는 방법은 실용적이지 않거나 불필요하다. 잃어버린 식별자를 회수하거나 복구하는 일이 가능하더라도, 그럴 만한 가치가 없는 번거로운 일일 수 있다. 모델이 늦은 식별자 생성만으로도 충부하다면 미리 할당해 캐싱하는 방법은 문제가 되지 않는다. 죽, 생성의 시점이 문제가 될 수도 있다.
# 빠른 식별자 생성
오라클이 반환하는 시퀀스 값은 하이버네이트를 통해 BigDecimal 인스턴스로 매핑되기 때문에, 우리는 하이버네이트에게 결과가 Long으로 변환되길 원한다고 알려줘야 한다.
MySQL과 같이 시퀀스를 지원하지 않는 데이터베이스는 어떻게 할까? MySQL은 자동 증가 열을 지원한다. 일반적으로 행이 새로 추가되기 전엔 자동 증가 열을 지원한다. 일반적으로 행이 추가되기 전엔 자동 증가가 일어나지 않는다. 하지만 MySQL 자동 증가를 오라클 시퀀스와 동일하게 만드는 방법이 있다.
하이버네이트는 이동식 시퀀스를 제공하지만, 늦은 식별자 생성만을 지원한다. 리파지토리 내에서 빠른 시퀀스 생성을 지원하려면 사용자 지정 하이버네이트 쿼리나 JDBC 쿼리를 생성해야 한다.
Key Point
즉, 순서가 중요하다.
식별자 엔티티의 생성과 할당이 일어나는 시점이 중요하다.
빠른 식별자 생성과 할당은 엔티티가 저장되기 전에 일어난다.
늦은 식별자 생성과 할당은 엔티티가 저장될 때 일어난다.
# 또 하나의 바운디드 컨텍스트가 식별자를 할당한다.
또 다른 컨텍스트가 식별자를 할당할 땐 각 식별자의 검색과 매칭과 할당을 위한 통합이 필요하다. 로컬 바운디드 컨텍스트는 외부 시스템이 게시한 도메인 이벤트를 구독한다. 이에 관한 알림을 받으면, 로컬 시스템은 해당하는 고유 애그리게잇 엔티티의 상태를 바꿔서 외부 시스템의 엔티티 상태를 반영한다. 경우에 따라선 로컬 바운디드 컨텍스트가 반드시 외부 시스템에게 변경 내용을 푸시하면 동기화를 시작해야 할 때도 있다. 이 방법은 식별자 생성 전략 중에서 가장 복잡하다. 로컬 엔티티의 관리는 로컬 도메인 행동에 따른 변환뿐만 아니라 하나 이상의 외부 시스템에서 발생하는 일에도 의존적일 가능성이 있다. 따라서 이 접근법은 최대한 보수적으로 사용해야 한다.
# 식별자 생성의 시점이 문제가 될 때
식별자 생성은 객체 구성의 일부로서 일찍 일어나거나 영속성의 일부로서 늦게 일어날 수 있다. 빠른 식별자 생성이 중요할 때도 있고 그렇지 않은 경우도 있다. 가장 단순한 경우인 새 엔티티가 영속됐을 때, 즉 새로운 행이 데이터베이스에 삽입되었을 때는 식별자의 후기 배분이 문제가 되지 않는 부분을 고려해야 한다. 엔티티 인스턴스가 새롭게 생성되는 시점에서 클라이언트는 해당 식별자가 필요하지 않다. 이는 좋은 일이기도 하지만, 더 이상 식별자가 남아있지 않기 때문이다. 따라서 오직 인스턴스를 저장한 이후에만 식별자를 사용할 수 있다. 클라이언트가 새로운 Entity를 Repository에 추가할 기회를 잡기 전에 도메인 이벤트를 수신한다. 따라서 도메인 이벤트가 올바르게 초기화되기 위해선 식별자 생성을 빠르게 완료해야 한다.
5-3 대리식별자
하이버네이트와 같은 일부 ORM 도구는 자신만의 고유한 방법으로 객체 식별자를 처리하길 원한다. 하이버네이트는 숫자 시퀀스와 같은 데이터베이스의 원시 타입을 각 엔티티의 1차 식별자로 사용하는 편을 선호한다. 만약 도메인에서 다른 유형의 식별자가 필요하다면, 하이버네이트와 달갑지 않은 충돌을 일으킨다. 충돌을 해소하기 위해선 두 개의 식별자를 사용해야 한다. 두 식별자 중 하나는 도메인 모델에 맞춰 도메인 요구사항에 따라 설계한다. 다른 하나는 하이버네이트를 위한 식별자로, 대리 식별자 라고 불느다.
대리 식별자를 생성하는 방법은 간단하다. 대리 식별자의 타입을 담고 있는 엔티티 특성을 하나 만든다. 일반적으로 long이나 int를 사용한다. 외부 세계에는 대리 속성을 가추는 편이 가장 바람직하다. 대리는 도메인 모델의 일부가 아니기 때문에 가시성은 영속성의 누수를 의미한다. 일부의 누수가 불가피하다 해도, 몇 단계를 거치면 모델 개발자와 클라이언트로 부터 이를 숨길 수 있다8.
5-4. 식별자의 안전성
대부분의 경우 고유 식별자를 수정하지 못하도록 보호되고, 할당된 엔티티의 수명주기에 걸쳐 안정적으로 유지돼야 한다. 식별자 수정을 방지하기 위해 취할 수 있는 당연한 방법이 있다. 식별자 셑터를 클라이언트로부터 숨기는 방법이 있고, 만약 세터가 이미 존재한다면 식별자의 상태 변화로부터 엔티티 자체를 보호하기 위해 세터 내에 가드를 만들 수 있다.
P.264 코드 첨부
User 엔티티의 도메인 식별자이며, 단 한 번만 내부적으로만 변형이 가능하다. setter는 클라이언트로부터 스스로를 숨기며 캡슐화를 제공한다. 엔티티의 공개된 행동을 스스로 세터에게 위임시키면, 해당 메소드는 null인지 아닌지 확인한다.
5-5. 엔티티의 속성
엔티티와 값 객체의 차이가 뭘까? 여기서 변경이란 단어가 중요하다. User 엔티티의 속성중 일부인 전화번호가 변경이 되었다고 엔티티 객체를 바꿀 필요는 없다.
Q. User 내 Person 객체가 있다면 클라이언트에게 권한을 줘야 할까?
A. 클라이언트가 User 모습에 액세스할 수 있고 그 행동을 실행시키기 위해 Person 내부로 갈 수 있다면, 클라이언트는 시간이 지난 후 리팩토링이 필요할지도 모른다. 그 대신에 팀이 user상의 개인 행동을 모델링해서 보안 원칙에 맞게 좀 더 일반화시켰고, 이후에 닥쳐올 파문을 어느 정도 피할 수 있었다.
Q. 그렇다면 Person은 모두 노출해야 할까? 아니면 감춰야 할까?
정보를 쿼리하는 목적으로 Person을 놏출하기로 결정했다. 접근자는 Principal 인터페이스를 지원하도록 다시 설계될 수 있고, Person과 System은 각각 특수호된 principal 일 수 있다.
Q. 비밀번호 변경
엔티티는 비밀번호 면경 메소드를 가지고 있다. 엔티티가 메소드를 가지고 있다면 클라이언트에겐 안호화돼 있는 비밀번호로의 접근조차도 절대 허요되지 않는다. 또한 비밀번호를 엔티티에서 설정하면 어그리게잇 경계를 넘어선 절대 드러나지 않으며, 모든 이는 AuthenticationService를 통한 한 가지 접근법만 사용할 수 있다.
또한 수정을 유발할 수 있는 행동이 성공하면, 그에 맞는 도메인 이벤트 결과를 게시하기로 결정할 수 있다. 이벤트의 필요성을 인식했다면, 이벤트는 최소한 두 가지를 이룰수 있게 해준다.
1. 모든 객체의 수명주기에 걸쳐 변화를 추적할 수 있도록 해준다.
2. 외부 구독자가 변경에 맞춰 동기화 할 수 있도록 해주고, 이는 외부자에게 자율성의 잠재력을 부여한다.
5-6. 역할과 책임
모델링의 한 측면은 객체의 역할과 책임을 발견하는 것이다. 역할과 책임의 분석은 도메인 객체 전반에 적용할 수 있다.
# 여러 역할을 수행하는 도메인 객체
객체지향 프로그래밍에서 일반적으로 인터페이스는 구현 클래스의 역할을 결정한다. 클래스는 구현하는 각 인터페이스마다 하나의 역할을 갖는다. 만약 클래스에 명시적으로 선언된 역할이 없다면 기본적으로 해당 클래스의 역할을 갖는다.
즉, 클래스는 퍼블릭 메소드라는 아시적 인터페이스를 갖고 있다.
우리는 한 객체가 User와 Person의 역할을 모두 수행하게 만들 수도 있다. 이 가정에 따르면, 별도의 Person 객체를 User 객체의 참조연결로 포함해야 할 이유가 전혀 없다. 대신 한 객체가 두 역할을 수행할 뿐이다. 이런 방법을 채택하는 이유는 겹치는 특성은 하나의 객체상에서 여러 인터페이스를 섞어 나타낼 수 있기때문이다. ~ 이후 작성
# 생성
새로운 엔티티를 인스턴스화할 때, 이를 완전히 식별해 클라이언트가 찾을 수 있도록 충분한 상태 정보를 포착하는 생성자를 사용하도록 권장한다. 빠른 식별자 생성을 사용한다면, 올바르게 설계된 생성자는 최소한 고유 식별자를 매개변수로 갖는다. 만약 엔티티가 이름이나 설명과 같은 다른 수단으로 쿼리된다면, 그 모든 사항도 생성자의 매개변수로 포함시킨다.
엔티티는 하나 이상의 고정자를 갖는다. 고정자는 엔티티의 전체 수명주기에 걸쳐 트랜잭션 일관성이 유지돼야 하는 상태다. 애그리게잇 루트는 언제나 엔티티이므로, 엔티티가 포함된 객체의 null이 아닌 상태를 바탕으로 한 고정자나 다른 상태의 계산 결과를 통해 이뤄지는 고정자를 갖고 있다면, 하나 이상의 생성자 매개변수로 해당 상태를 제공해야 한다. 즉 모든 User객체는 피드를 포함해야 하며, 생성이 성공적으로 이뤄지면, 이렇게 선언한 인스턴스 변수의 참조는 절대 null이 되지 않는다. User의 생성자와 생성자의 인스턴스 변수 세터가 이를 보장한다.
이 설계는 자가 캡슐화가 얼마나 강력한지를 보연준다. 생성자는 인스턴스 변수 할당을 자신의 내부 특성/속성 세터로 위임하는데, 이는 변수의 자가 캡슐화를 제공한다. 자가 캡슐화는 각 세터에게 상태의 일부를 설정하는바람지한 계약 조건을 결정토록 한다. 각 세터는 엔티티를 대신해 개별적으로 null이 아니라는 제약을 검사해서 인스턴스 계약을 집행한다. 하지만 이런 세터 메소드의 자가 캡슐화 기법은 필요 이상으로 복잡해질 수 있다.
복잡한 엔티티 인스턴스화를 위해선 팩토리를 사용하면 좋다. User 생성자의 접근지정자가 Protected이기 때문에 Tenant엔티티는 User인스턴스를 위해 패토리로서 동작하며, 같은 모듈 내의 클래스만이 User생성자를 사용할 수 있다. 이를 통해 Tenant만 User인스턴스를 생성할 수 있다. P.286 코드에서 registerUser()는 팩토리이다. 패토리는 User 기본값 상태의 생성을 단순화하고 User와 Person 엔티티 모두의 TenantId가 언제나 정화하게 해준다. 이 모든 일은 유비쿼터스 언어를 다루는 팩토리 메소드의 제어를 받으며 일어난다.
# 유효성 검사
모델 내의 유효성 검사를 사용하는 주 이유는 하나의 특성/속성, 전체 객체, 객체의 컴포지션 등의 정확성을 확인하기 위해서다. 우리는 이 모델 내부의 세 단계에 걸쳐 유효성 검사를 살펴본다. 이번에는 일반적 접근법을 다루는 대신, 좀 더 정교한 접근법으로 이어지는 것을 확인 할 것이다.
유효성 검사를 통해 여러가지를 이룰 수 있따. 도메인 객체의 모든 특성/속성이 개별적으로 유효하다고 객체 전체가 하나의 대상으로서 유효하다는 의미는 아니다. 두 개의 올바른 특성을 조합해 전체 객체가 유효하지 않도록 만들 수 있다. 하나의 객체 전체가 유효하다고 해서 객체의 겈ㅁ포지션도 유효하다고는 할 수 없다. 개별적으론 유효한 생태를 가진 두 엔티티의 조합이, 실제론 컴포지션을 유효하지 않게 할 수도 있다. 따라서 우리는 하나 이상의 단계로 이뤄진 유효성 검사를 통해 가능한 모든 문제를 다뤄야 한다.
# 특성/속성의 유효성 검사
Q. 하나의 특성이나 속성을 비유효한 값의 설정으로부터 보호하는 방법은 무엇일까?
A. 자가 캡슐화의 사용을 강력히 추천한다. 자가 캡슐화는 같은 클래스 내에서부터 모든 데이터로의 액세스가 접근자 메소드를 거쳐가도록 설계하는 방법이다. 이 방법에는 몇 가지 이점이 있다. 객체의 인스턴스 변수를 추상화할 수 있도록 해준다. 이를 통해 해당 객체를 담고 있는 많은 다른 객체에서 손쉽게 특성/속성을 가져오는 방법을 제공한다. 또한 유효성 검사의 단순한 형태를 지원한다는 점이다.
자가 캡슐화를 사용해 올바른 객체 상태를 보호하는 과정을 유효성 검사라 부르길 좋아하지 않는다. 일부 개발자에게 검부감을 줄 수 있는데, 유효성 검사는 도메인 객체가 아닌 유효성 검사 클래스의 책이이어야 하는 별개의 문제이기 때문이다.
유효성 검사에는 엔티티의 전체 상태가 사용 가능해야 하므로, 일부에선 이 시점을 유효성 검사 프로세스 로직을 엔티티로 직접 집어넣기에 알맞은 순간으로 볼 수도 있다. 하지만 여기서 주의해야 한다. 많은 경우 도메인 객체의 유효성 검사는 도메인 객체 자체보다 더 자주 변경된다. 엔티티 내부에 유효성 검사를 집어넣으면 너무 많은 책임ㅇ르 부여하기도 한다. 엔티티는 이미 자신의 상태를 유지해 도메인의 행동을 다뤄야 하는 책임을 갖고 있다.
유효성 검사 컴포넌트는 엔티티 상태가 유효하진 결정하는 책임을 갖느다. 유효성 검사 클래스를 설계할 땐, 이를 엔티티와 같은 패키지 안에 두자. 특성/속성 읽기 접근자는 적어도 protected로 선어해야하고 public도 괜찮다. 만약 유효성 검사 클래스가 엔티티와 같은 모듈에 위치하지 않는다면 모든 특성/속성 접근자가 퍼블릭이 되어야 하는데, 이는 여러 측면에서 바람직하지 않다.