JPA를 이용한 리포지터리 구현
모듈 위치
리포지터리 인터페이스는 애그리거트와 같은 도메인 영역 에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역 에 속한다.
기능하면 리토지터리 구현 클래스를 인프라스트럭처 영역에 위치 시켜서 인프라스트럭처에 대한 의존을 낮추자.
리포지터리 기본 기능 구현
리포지터리 인터페이스는 애그리거트 루트를 기준 으로 작성하자.
Copy public interface OrderRepository {
Optional < Order > findById ( OrderNo no);
void save ( Order order);
List < Order > findByOrdererId ( String ordererId , int startRow , int size);
}
애그리거트 조회는 ID 외에도 JPA Criteria
, JPQL
을 사용할 수도 있다.
사용자가 삭제 기능을 실행할 때 데이터를 바로 삭제하기보다는 삭제 플래그를 사용해서 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현하자.
관리자 기능에서 삭제 데이터 조회를 해야 하거나 데이터 원복을 위해 일정 기간 보관해야 할 경우도 있다.
Spring Data JPA를 이용한 리포지터리 구현
스프링과 JPA를 함께 적용할 때는 Spring Data JPA를 사용한다.
지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록
아래 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체를 자동 등록
org.springframework.data.repository.Repository<T, ID>
인터페이스 상속
Spring Data JPA 사용을 위해 지정한 규칙에 맞게 메서드를 작성해야 한다.
매핑 구현
엔티티와 밸류 기본 매핑 구현
애그리거트와 JPA 매핑을 위한 기본 규칙
애그리거트 루트는 엔티티이므로 @Entity
매핑 설정
Copy @ Entity
@ Table (name = "purchase_order" )
public class Order {
@ EmbeddedId
private OrderNo number;
@ Embedded
private Orderer orderer;
@ Embedded
private ShippingInfo shippingInfo;
...
한 테이블에 엔티티와 밸류 데이터가 같이 있다면
밸류 타입 프로퍼티는 @Embedded
로 매핑 설정
Copy @ Embeddable
public class Orderer {
@ Embedded
@ AttributeOverrides ( // 컬럼명과 실제 컬럼명이 다르므로 매핑 컬럼 변경
@ AttributeOverride (name = "id" , column = @ Column (name = "orderer_id" ))
)
private MemberId memberId; // Member 애그리거트를 ID로 참조
@ Column (name = "orderer_name" )
private String name;
...
}
...
@ Embeddable
public class MemberId implements Serializable {
@ Column (name = "member_id" )
private String id;
}
@ Embeddable
public class ShippingInfo {
@ Embedded
@ AttributeOverrides ({ // 매핑 설정과 다른 컬럼 이름을 사용하기 위한 설정의 다른 예
@ AttributeOverride (name = "zipCode" , column = @ Column (name = "shipping_zip_code" )) ,
@ AttributeOverride (name = "address1" , column = @ Column (name = "shipping_addr1" )) ,
@ AttributeOverride (name = "address2" , column = @ Column (name = "shipping_addr2" ))
})
private Address address;
@ Column (name = "shipping_message" )
private String message;
@ Embedded
private Receiver receiver;
...
}
기본 생성자
앤티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다.
불변 타입이면 생성 시점에 필요한 값을 모두 전달받고, 값을 변경하는 set 메서드는 미제공
하지만, JPA에서 @Entity
, @Embeddable
로 클래스를 매핑하려면 기본 생성자를 제공해야 한다.
DB에서 데이터를 읽어와 매핑 객체를 생성할 때 기본 생성자 사용
다른 코드에서 기본 생성자를 사용하지 못 하도록 protected 선언을 해주자.
기본 생성자는 JPA 프로바이더가 객체 생성 시에만 사용
Copy @ Embeddable
public class Receiver {
@ Column (name = "receiver_name" )
private String name;
@ Column (name = "receiver_phone" )
private String phone;
protected Receiver () {
}
public Receiver ( String name , String phone) {
this . name = name;
this . phone = phone;
}
...
}
필드 접근 방식 사용
객체가 제공할 기능 중심으로 엔티티를 구현하도록 유도하려면, JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 않도록 하자.
하이버네이트는 @Access
로 접근 방식을 지정하지 않으면 @Id
, @Embeddable
위치에 따라 접근 방식을 결정
get 메서드에 위치하면 메서드 접근 방식 선택
Copy @ Entity
@ Table (name = "purchase_order" )
@ Access ( AccessType . FIELD )
public class Order {
@ EmbeddedId
private OrderNo number;
@ Column (name = "state" )
@ Enumerated ( EnumType . STRING )
private OrderState state;
...
}
AttributeConverter를 이용한 밸류 매핑 처리
AttributeConverter는 다음과 같이 밸류 타입과 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의
Copy package javax . persistence ;
public interface AttributeConverter < X , Y > {
Y convertToDatabaseColumn ( X var1); // 밸류 타입을 DB 컬럼 값으로 변환
X convertToEntityAttribute ( Y var1); // DB 컬럼 값을 밸류로 변환
}
Money 밸류 타입을 위한 AttributeConverter 예시
AttributeConverter 구현 클래스는 @Converter
적용
autuApply = true
지정 시 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 AttributeConverter 자동 적용
autuApply = false
(default) 지정 시 프로퍼티 값 변환 시 사용할 컨버터를 직접 지정
Copy @ Converter (autuApply = true )
public class MoneyConverter implements AttributeConverter < Money , Integer > {
@ Override
public Integer convertToDatabaseColumn ( Money money) {
return money == null ? null : money . getValue ();
}
@ Override
public Money convertToEntityAttribute ( Integer value) {
return value == null ? null : new Money(value) ;
}
}
...
/**
* autuApply = false 일 경우
*/
@ Column (name = "total_amounts" )
@ Convert (converter = MoneyConverter . class )
private Money totalAmounts;
밸류 컬렉션: 별도 테이블 매핑
밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection
, @CollectionTable
을 함께 사용
@OrderColumn
을 이용해서 지정한 컬럼에 리스트의 인덱스 값을 저장.(List 타입 자체가 인덱스를 가지고 있다)
@CollectionTable
은 밸류를 저장할 테이블 지정(name: 테이블 이름, joinColumns: 외부키로 사용할 컬럼)
Copy @ Entity
@ Table (name = "purchase_order" )
public class Order {
@ EmbeddedId
private OrderNo number;
@ ElementCollection (fetch = FetchType . LAZY )
@ CollectionTable (name = "order_line" , joinColumns = @ JoinColumn (name = "order_number" ))
@ OrderColumn (name = "line_idx" )
private List < OrderLine > orderLines;
...
}
...
@ Embeddable
public class OrderLine {
@ Embedded
private ProductId productId;
@ Convert (converter = MoneyConverter . class )
@ Column (name = "price" )
private Money price;
@ Column (name = "quantity" )
private int quantity;
...
}
밸류 컬렉션: 한 개 컬럼 매핑
밸류 컬렉션을 별도 테이블이 아닌 한 개 컬럼에 저장해야 할 경우
도메인 모델에는 이메일 주소 목록을 Set으로 보관하고, DB에는 한 개의 컬럼에 콤마로 구분해서 저장
Copy public class EmailSet {
private Set < Email > emails = new HashSet <>();
public EmailSet ( Set < Email > emails) {
this . emails . addAll (emails);
}
public Set < Email > getEmails () {
return Collections . unmodifiableSet (emails);
}
}
...
public class EmailSetConverter implements AttributeConverter < EmailSet , String > {
@ Override
public String convertToDatabaseColumn ( EmailSet attribute) {
if (attribute == null ) return null ;
return attribute . getEmails () . stream ()
. map (email -> email . getAddress ())
. collect ( Collectors . joining ( "," ));
}
@ Override
public EmailSet convertToEntityAttribute ( String dbData) {
if (dbData == null ) return null ;
String [] emails = dbData . split ( "," );
Set < Email > emailSet = Arrays . stream (emails)
. map (value -> new Email(value) )
. collect ( toSet() );
return new EmailSet(emailSet) ;
}
}
...
@ Column (name = "emails" )
@ Convert (converter = EmailSetConverter . class )
private EmailSet emails;
밸류를 이용한 ID 매핑
밸류 타입을 식별자로 매핑하면 @Id
대신 @Embeddable
을 사용한다.
식별자 타입은 Serializable 타입이어야 하므로, 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.
Copy @ Entity
@ Table (name = "purchase_order" )
public class Order {
@ EmbeddedId
private OrderNo number;
...
}
...
@ Embeddable
public class OrderNo implements Serializable {
@ Column (name = "order_number" )
private String number;
...
}
밸류 타입으로 식별자를 구현하면 식별자에 기능을 추가 할 수 있다.
JPA는 내부적으로 엔티티 비교 목적으로 equals()
, hashcode()
값을 사용하므로 식별자로 사용할 밸류 타입은 이 두 메서드를 알맞게 구현해야 한다.
별도 테이블에 저장하는 밸류 매핑
애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다.
다른 엔티티가 있다면 진짜 엔티티인지 의심해 보자.
독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다.
별도 테이블에 데이터를 저장한다고 엔티티는 아니다.
애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 확인하는 것이다.
별도 테이블로 저장하고 테이블에 PK가 있다고 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니다.
밸류를 별도 테이블에 저장해야 한다면, 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable
, @AttributeOverrides
을 사용한다.
@SecondaryTable
: name 속성은 밸류를 저장할 테이블, pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 컬럼 지정
Article 조회 시 Article, ArticleContent 두 테이블을 조인해서 데이터를 조회
@AttributeOverrides
: 해당 밸류 데이터가 저장된 테이블 이름 지정
Copy @ Entity
@ Table (name = "article" )
// 목록 화면에 보여줄 Article 조회 시 불필요한 ArticleContent 테이블까지 조인해서 데이터를 읽어오는 문제가 있다.
@ SecondaryTable (
name = "article_content" ,
pkJoinColumns = @ PrimaryKeyJoinColumn (name = "id" )
)
public class Article {
@ Id
@ GeneratedValue (strategy = GenerationType . IDENTITY )
private Long id;
private String title;
@ AttributeOverrides ({
@ AttributeOverride (
name = "content" ,
column = @ Column (table = "article_content" , name = "content" )) ,
@ AttributeOverride (
name = "contentType" ,
column = @ Column (table = "article_content" , name = "content_type" ))
})
@ Embedded
private ArticleContent content;
}
Article 목록 화면 조회 시 불필요한 ArticleContent 테이블까지 조인해서 데이터를 읽어오는 문제가 있다.
문제 해결을 위해 ArticleContent를 엔티티로 매핑하고 지연 로딩 방식으로 설정할 수도 있지만, 밸류 모델을 엔티티로 만드는 방식이 좋은 방법은 아니다.
이럴 경우 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.
밸류 컬렉션을 @Entity로 매핑하기
개념적으로 밸류인데 구현 기술 한계나 팀 표준으로 @Entity
를 사용해야 할 경우이다.
상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable
대신 @Entity
를 이용해서 상속 매핑으로 처리해야 한다.
밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드 와 구현 클래스를 구분하기 위한 타입 식별 컬럼 을 추가해야 한다.
Copy ├── Product
│ └──Image
│ ├── InternalImage
│ └── ExternalImage
└──
한 테이블에 Image와 그 하위 클래스를 매핑하기 위해
@Inheritance
InheritanceType.SINGLE_TABLE
@DiscriminatorColumn
으로 타입 구분용 컬럼 지정
상속받은 클래스는 @Entity
, @DiscriminatorValue
를 사용해서 매핑 설정
Copy @ Entity
@ Inheritance (strategy = InheritanceType . SINGLE_TABLE )
@ DiscriminatorColumn (name = "image_type" )
@ Table (name = "image" )
public abstract class Image {
@ Id
@ GeneratedValue (strategy = GenerationType . IDENTITY )
@ Column (name = "image_id" )
private Long id;
@ Column (name = "image_path" )
private String path;
@ Column (name = "upload_time" )
private LocalDateTime uploadTime;
protected Image () {
}
public Image ( String path) {
this . path = path;
this . uploadTime = LocalDateTime . now ();
}
...
// Image를 @Entity 매핑했지만 모델에서는 밸류이므로 상태 변경 기능은 제외
}
...
@ Entity
@ DiscriminatorValue ( "II" )
public class InternalImage extends Image {
}
...
@ Entity
@ DiscriminatorValue ( "EI" )
public class ExternalImage extends Image {
}
Image가 @Entity
이므로 목록을 담고 있는 Product는 @OneToMany
로 매핑 처리
Image는 밸류이므로 독자적인 라이프 사이클이 없고, Product에 완전히 의존
cascade: Product와 함께 저장, 삭제되도록 지정
orphanRemoval: 리스트에서 Image 객체를 제거하면 DB에서 함께 제거되도록 지정
Copy @ Entity
@ Table (name = "product" )
public class Product {
@ EmbeddedId
private ProductId id;
@ OneToMany (cascade = { CascadeType . PERSIST , CascadeType . REMOVE } ,
orphanRemoval = true , fetch = FetchType . LAZY )
@ JoinColumn (name = "product_id" )
@ OrderColumn (name = "list_idx" )
private List < Image > images = new ArrayList <>();
...
public void changeImages ( List < Image > newImages) {
images . clear ();
images . addAll (newImages);
}
}
여기서, images.clear()
메서드 호출의 삭제 과정은 비효율적이다.
하이버네이트는 @Entity
를 위한 컬렉션 객체의 clear()
메서드 호출 시 selet 쿼리로 대상 엔티티 로딩 후, 각 개별 엔티티에 대해 delete 쿼리를 실행한다.
변경 빈도가 낮다면 괜찮지만 빈도가 높아지면 전체 서비스 성능에 문제가 될 수 있다.
반면, 하이버네이트는 @Embeddable
타입에 대한 컬렉션의 clear()
메서드 호출 시 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다.
애그리거트의 특성을 유지하면서 이 문제를 해소하려면, 상속을 포기하고 @Embeddable
매핑된 단일 클래스로 구현해야 한다.
타입에 따라 다른 기능을 구현해야 한다면 if-else를 사용해야 한다.
코드 유지 보수와 성능의 두 가지 측면을 고려해서 구현 방식을 선택해 보자.
ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
애그리거트 간 집합 연산은 성능 상의 이유로 피하는게 좋지만, 요구사항을 구현하는 데 집합 연산을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해볼 수 있다.
Product -> Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것이다.
차이점이라면 집합의 값에 밸류 대신 연관을 맺는 식별자가 오는 것
Copy @ Entity
@ Table (name = "product" )
public class Product {
@ EmbeddedId
private ProductId id;
@ ElementCollection (fetch = FetchType . LAZY )
@ CollectionTable (name = "product_category" ,
joinColumns = @ JoinColumn (name = "product_id" ))
private Set < CategoryId > categoryIds;
@ElementCollection
을 이용하므로 Product 삭제 시 매핑에 사용된 조인 테이블 데이터도 함께 삭제된다.
애그리거트를 직접 참조한다면 영속성 전파나 로딩 전략을 고민해야 하지만 ID 참조 방식으로 고민을 덜 수 있다.
애그리거트 로딩 전략
JPA 매핑 설정 시 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태
애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(EAGER)으로 설정하면 된다.
Copy // @Entity 컬렉션에 대한 즉시 로딩 설정
@ OneToMany (cascade = { CascadeType . PERSIST , CascadeType . REMOVE } ,
orphanRemoval = true , fetch = FetchType . EAGER )
@ JoinColumn (name = "product_id" )
@ OrderColumn (name = "list_idx" )
private List < Image > images = new ArrayList <>();
// @Embeddable 컬렉션에 대한 즉시 로딩 설정
@ ElementCollection (fetch = FetchType . LAZY )
@ CollectionTable (name = "order_line" , joinColumns = @ JoinColumn (name = "order_number" ))
@ OrderColumn (name = "line_idx" )
private List < OrderLine > orderLines;
즉시 로딩 방식은 애그리거트 루트 로딩 시점에 애그리거트에 속한 모든 객체를 함께 로딩 한다. 하지만, 컬렉션에 즉시 로딩을 설정하면 문제가 발생 할 수 있다.
Product 조회에 Image, Option 테이블도 함께 조인할 경우 카타시안 조인이 사용되면서 쿼리 결과에 중복이 발생한다.
product에 image 2개, option 2개라면 카타시안 조인 쿼리로 4개의 행이 조회된다.
product 테이블 정보는 4번 중복, imgae, option 테이블 정보는 2번 중복
하이버네이트가 중복 데이터를 제거해서 객체로 변환해 주지만 애그리거트가 커지면 문제 가 될 수 있다.
보통 조회 성능 문제 때문에 즉시 로딩 방식을 사용하지만 이렇게 조회되는 데이터 개수가 많아지면 즉시 로딩 방식을 사용 시 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등)을 검토해 보아야 한다.
애그리거트는 개념적으로 하나여야 한다. 애그리거트가 완전해야 하는 이유는
상태를 변경하는 기능을 실행 시 애그리거트 상태가 완전해야 함
표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요
두 번째 사유는 별도 조회 전용 기능과 모델을 구현하는 방식 을 사용하면 되지만, 첫 번째 사유는 조회 시점에 즉시 로딩으로 로딩할 필요는 없다 .
JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하므로 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제되지 않는다
일반적으로 상태 변경 기능 실행 빈도보다 조회 기능 실행 빈도가 훨씬 높다.
Copy @ Transactional
public void revmoeOptions( ProductId id , int optIdxToBeDeleted) {
// Product 로딩. 컬렉션은 지연 로딩으로 설정했다면 Option은 로딩되지 않음
Product product = productRepository . findByid (id);
// 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
product . removeOption (optIdxToBeDeleted);
}
...
@ Entity
public class Product {
@ ElementCollection (fetch = FetchType . LAZY )
@ CollectionTable (name = "product_option" ,
joinColumns = @ JoinColumn (name = "product_id" ))
@ OrderColumn (name = "list_idx" )
private List < Option > options = new ArrayList <>();
public void removeOption ( int optIdx) {
//실제 컬렉션에 접근할 때 로딩
this . options . remove (optIdx);
}
}
애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요는 없다.
지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없다.
즉시 로딩은 @Entity
, @Embeddable
에 대해 다르게 동작하고, JPA 프로바이더에 따라 구현 방식에 차이가 있다.
지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높다.
무조건 설정하기보다 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하자.
애그리거트의 영속성 전파
애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭할 때도 하나로 처리해야 함을 의미 한다.
저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다.
삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다.
@Embeddable
매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.
@Entity
타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정 필요
@OneToOne
, @OneToMany
는 cascade 속성의 기본값이 없으므로 cascade 속성값으로 CascadeType.PERSIST
, CascadeType.REMOVE
를 설정하자.
Copy @ OneToMany (cascade = { CascadeType . PERSIST , CascadeType . REMOVE } , orphanRemoval = true )
@ JoinColumn (name = "product_id" )
@ OrderColumn (name = "list_idx" )
private List < Image > images = new ArrayList <>();
식별자 생성 기능
식별자는 크게 세 가지 방식 중 하나로 생성
식별자 생성 규칙은 도메인 규칙이므로 도메인 영역(도메인 서비스) 에 식별자 생성 기능을 위치시켜야 한다.
Copy public class ProductIdService {
public ProductId nextId () {
...
}
}
...
public class CreateProductService {
@ Autowired private ProductIdService productIdService;
@ Autowired private ProductRepository productRepository;
@ Transactional
public ProductId createProduct ( ProductCreationCommand cmd) {
// 응용 서비스는 도메인 서비스를 이용해서 식별자를 생성
ProductId id = productIdService . nextId ();
Product product = new Product(id , cmd . getDetail() , cmd . getPrice() , ... ) ;
productRepository . save (product);
return id;
}
}
식별자 생성 규칙을 구현하기에 적합한 또 다른 장소는 리포지터리
@GeneratedValue
: DB 자동 증가 컬럼을 식별자로 사용
도메인 객체를 리포지터리에 저장할 때 식별자가 생성
Copy public interface ProductRepository {
...
ProductId nextId ();
}
...
@ Entity
@ Table (name = "article" )
public class Article {
@ Id
@ GeneratedValue (strategy = GenerationType . IDENTITY )
private Long id;
public Long getId () {
return id;
}
}
...
public class WriteArticleService {
private ArticleRepository articleRepository;
public Long write ( NewArticleRequest req) {
Article article = new Article( ... ) ;
articleRepository . save (article); // EntityManager#save() 실행 시 식별자 생성
return article . getId (); // 저장 이후 식별자 사용
}
}
도메인 구현과 DIP
도메인에서 구현 기술에 대한 의존을 없애고 순수하게 유지하려면 구현 클래스를 인프라에 위치 시켜야 한다.
Spring Data JPA Repository 인터페이스 상속 없애기
Repository 구현체 인프라에 위치시키기
도메인 클래스에서 @Entity, @Table 같은 JPA 특화 애너테이션 지우기
DIP 적용의 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향받지 않도록 하기 위함이다.
하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.
변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다.
DIP를 완벽하게 지키면 좋지만, JPA 애노테이션을 도메인 모델에 사용하면서도 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느 정도 유지 할 수 있다.
복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택일 수 있다.
도메인 모델 단위 테스트와 리포지터리 테스트도 문제 없다.