티스토리 뷰

이전 글: https://gojs.tistory.com/65

 

복잡한 도메인 관리하기

도메인이 많아지는 경우만약 100개 이상의 도메인을 표현하는 다이어그램을 보고 시스템을 이해하려한다면 한 번에 이해가 되지 않을 것이다.특정 기능을 수정하는 경우 이 기능과 인접한 몇 개

gojs.tistory.com

 

JPA를 사용하지 않고 MyBatis나 JDBC를 활용하더라도 의존성을 잘 관리한다면 충분히 DDD를 적용할 수 있다.

그러나 JPA와 같은 ORM을 사용한다면 도메인 모델과 데이터 모델을 매핑하는 다양한 기능을 제공하기 때문에 JPA를 사용해서 DDD를 적용하는 방법을 알아보고자 한다.

("Object" Relation Mapping이기 때문에 그나마 가장 쉽기도 하다)

 

레포지토리 구성

기본적으로 하나의 애그리거트에는 하나의 레포지토리를 구성한다.

@Repository
public class JpaTransferRepository implements TransferRepository {

    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public Optional<Transfer> findById(String id) {
        return Optional.ofNullable(entityManager.find(Transfer.class, id));
    }
    
    @Override
    public void save(Transfer transfer) {
        entityManager.persist(transfer);
    }
}

위와 같이 기본적으로 id 기준으로 애그리거트를 조회하는 기능과 애그리거트를 저장하는 기능을 구현하여 제공할 수 있다.

 

public class TransferService {

    @Transactional
    public void completeTransfer(String transferId) {
        Transfer transfer = transferRepository
            .findById(transferId)
            .orElseThrow(() -> TransferNotFoundException());
        
        transfer.complete();
    }
}

만약 위와 같이 이체 거래를 완료로 바꾸는 로직이 있다고 하더라도, 해당 서비스 메서드 내부에서 save 메서드를 호출할 필요는 없다.

JPA는 트랜잭션이 끝나는 시점에 영속성 컨텍스트에 존재하는 엔티티의 변경 내용을 자동으로 DB에 반영한다.

 

@Override
public List<Transfer> findByReceiverId(String receiverId, int startRow, int fetchSize) {
    TypedQuery<Transfer> query = entityManager.createQuery(
        "select t from Transfer t " +
        "where t.receiver.id = :receiverId",
        Transfer.class
    );
    
    query.setParameter("receiverId", receiverId);
    query.setFirstResult(startRow);
    query.setMaxResults(fetchSize);
    
    return query.getResultList();
}

위의 코드와 같이 송금인ID를 기준으로 이체 목록을 조회하는 메서드를 JPQL로 구성할 수도 있다.

 

 

도메인 모델을 데이터 구조와 매핑

이체 도메인 모델
이체 데이터 모델

위와 같은 이체 도메인 모델을 이체 데이터 모델로 매핑해야한다고 가정하자.

 

@Entity
@Table(name = "transfer")
public class Transfer {

    @Embedded
    private Money amount;
    
    @Embedded
    private SendInfo sendInfo;
    
    @Embedded
    private ReceiveInfo receiveInfo;
    
    @Column(name = "create_at")
    private LocalDateTime createAt;
}

애그리거트 루트인 Transfer 엔티티를 위와 같이 매핑할 수 있다.

애그리거트가 포함하는 밸류 오브젝트는 @Embedded 어노테이션으로 매핑한다.

 

@Embeddable
public SendInfo {

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(
            name = "accountNumber",
            column = @Column(name = "send_account_number")
        ),
        @AttributeOverride(
            name = "ownerId",
            column = @Column(name = "send_owner_id")
        )
    })
    private Account sendAccount;
    
    @Column(name = "sender_summary")
    private String senderSummary;
}

밸류 클래스에서는 @Embeddable 어노테이션을 적용해서 매핑한다.

그리고 위와 같이 밸류 내부에 또 다른 밸류와 매핑이 필요한 경우에는 @AttributeOverride 어노테이션을 활용해서 매핑할 수 있다.

 

@Embeddable
public class ReceiveInfo {

    ...
    
    protected ReceiveInfo() {}
    
    public ReceiveInfo(...) {
        ...
    }
}

일반적으로 밸류 오브젝트는 setter 메서드 없이 불변 객체로 구성하게 된다. 따라서 기본 생성자가 필요 없는 경우가 대다수다.

그러나 JPA는 @Entity, @Embeddable 어노테이션으로 클래스를 매핑하려면 기본 생성자를 사용해야만 하기 때문에, 위와 같이 protected 접근 제한자를 적용한 기본 생성자를 생성해주어야 한다.

 

@Entity
@Access(AccessType.FIELD)
public class Transfer {
    ...
}

JPA는 엔티티의 필드에 접근하는 타입은 PROPERTY, FIELD 타입이 있다.

프로퍼티 타입은 getter/setter로 필드에 접근하기 때문에 불필요한 setter 메서드를 강제로 구형하게 된다.

따라서 위와 같이 AccessType.FIELD로 설정해서 JPA에 필드에 직접 접근하도록 한다.

 

AttributeConverter를 활용한 밸류 매핑

Money 밸류의 경우에는 두 개 이상의 필드를 가지고 있으며 데이터로 변환하기 위해서는 특정한 로직이 필요하다.

이와 같은 로직을 구현하기 위해서 AttributeConverter를 활용할 수 있다.

 

@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Long> {

    @Override
    public Long convertToDatabaseColumn(Money money) {
        return money == null ? 0L : money.getValue();
    }
    
    @Override
    public Money convertToEntityAttribute(Long value) {
        return value == null ? Money.zero() : Money.of(value);
    }
}

위와 같이 AttributeConvert 인터페이스를 구현하는 클래스를 만들 수 있다.

@Convert 어노테이션의 autoApply 옵션을 true로 설정하였기 때문에 모델에 적용되는 모든 Money 오브젝트에 대해서 Converter가 자동으로 적용된다.

 

public class Transfer {

    ...

    @Column(name = "amount")
    @Converter(converter = MoneyConverter.class)
    private Money amount;
    
    ...
}

autoApply 옵션의 디폴트 값은 false이며 이 경우에는 위와 같이 도메인 모델에 직접 Converter를 지정해야한다.

 

별도 테이블에 관리되는 밸류 매핑

하나의 애그리거트에 포함된 도메인 모델들은 동일한 라이프사이클을 가진다. 따라서 하나의 테이블로 설계된 예제로 알아보았다.

그러나 밸류 오브젝트더라도 별도의 테이블에 관리될 수 있다.

이 경우 밸류 오브젝트도 식별자를 가지게 되지만 이 식별자는 애그리거트 루트 엔티티에 매핑하기 위한 식별자이다.

@Entity
@Table(name = "transfer")
@SecondaryTable(
    name = "send_info",
    pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Transfer {

    @Id
    private UUID transferId;
    
    @AttributeOverrides({
        @AttributeOverride(
            name = "senderSummary",
            column = @Column(table = "send_info", name = "sender_summary")
        ),
        ...
    })
}

@SecondaryTable 어노테이션의 name 속성은 밸류 오브젝트를 적재할 테이블명을 지정한다.

pkJoinColumns 속성은 엔티티 테이블과 조인할 컬럼을 지정한다.

 

Transfer transfer = entityManager.find(Transfer.class, id);

위 코드를 실행할 때 @SecondaryTable로 지정된 테이블과 엔티티 테이블을 조인하여 쿼리를 실행하게 된다.

 

별도 테이블에 관리되는 밸류 컬렉션 매핑

특정 엔티티가 밸류를 컬렉션 타입으로 포함한다고 가정하면 밸류를 저장할 테이블이 별도로 존재할 것이다.

이 테이블은 외래키를 가지며 이 외래키는 애그리거트 루트 엔티티의 식별자 컬럼을 가르킬 것이다.

 

@Entity
@Table(name = "account")
public class Account {
    ...
    
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "transfer_history", joinColumns = @JoinColumn("account_number"))
    @OrderColumn(name = "history_index")
    private List<TransferHistory> transferHistories;
    
    ...
}

위와 같이 @ElementCollection과 @CollectionTable 어노테이션을 활용하여 별도 테이블에 존재하는 밸류 컬렉션을 매핑한다.

@OrderColumn 어노테이션을 활용하여 순서 정보를 저장할 컬럼을 지정한다. (밸류 모델에 필드 선언하지 않아도 자동으로 생성)

 

별도 테이블에 관리하는 밸류 @Entity로 매핑

JPA는 @Embeddable 타입 클래스의 상속 매핑을 지원하지 않는다. 따라서 상속 구조를 갖는 밸류의 경우에는 @Entity로 매핑해야한다.

 

@OneToMany(
    cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    orphanRemoval = true)
@JoinColumn(name = "account_id")
@OrderColumn(name = "list_index")
private List<TransactionHistory> histories = new ArrayList<>();

이 경우 애그리거트 루트 엔티티에서는 위와 같은 연관관계를 가지게 된다.

TransactionHistory는 밸류이므로 Account에 의존하여 라이프사이클을 가진다. 따라서 cascade 옵션을 위와 같이 설정하여 함께 저장되고 함께 삭제하도록 설정한다. 또한 List에서 객체를 삭제하는 경우에 DB에서 삭제되도록 orphanRemoval을 true로 설정한다.

 

애그리거트 로딩 전략

애그리거트에 속한 모든 객체는 모두 로딩되어있어야 완전한 하나가 된다.

 

@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    orphanRemoval = true, fetch = FetchType.EAGER)

따라서 만약 별도의 테이블로 밸류를 관리한다면 위와 같이 즉시 로딩을 설정하여 한 번에 조회되도록 하는 것이 일반적이다.

위와 같이 연관관계를 설정하면 애그리거트 루트를 로딩할 때 연관관계 테이블과의 조인 쿼리를 발생시켜 즉시 로딩한다.

 

@ElementCollection(fetch = FetchType.EAGER)

그러나 위와 같이 컬렉션 타입의 밸류의 경우에는 두 개 이상의 테이블을 조인하게되어서 카타시안 곱만큼의 row를 반환하게 되기 때문에 성능적으로 문제를 발생할 수 있다.

 이와 같은 이슈가 발생하는 경우에는 지연 로딩으로 설정하면 된다.

(애그리거트가 완전하지 않겠지만 애그리거트의 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 괜찮다)

 

DIP를 어기는 이유

DIP는 구체화된 클래스에 의존하는 대신 인터페이스에 의존하여 컴포넌트 간 의존성을 역전할 수 있다는 원칙이다.

그러나 위 포스팅 내용 중 엔티티 클래스를 보면 구체적인 구현 기술인 JPA에 직접 의존하고 있다.

DIP에 따르면 도메인 클래스는 POJO로 구성되어야 한다.

 

DIP를 지키는 구조

그렇다면 DIP를 따르게 되면 위와 같이 Transfer는 POJO이고 이를 상속받는 JpaTransfer가 별도로 존재해야한다.

 

DIP를 적용하는 이유는 세부 기술에 대한 변경이 발생했을 때 손쉽게 변경에 대응하기 위해서이다.

그러나 실제로 JPA가 마이바티스나 JDBC로 변경되는 경우는 거의 없다.

따라서 오버 엔지니어링을 피하고 현실에 타협하여 이번 포스팅 내용과 같은 구조로 구성되게 되었다.

 

다음 글: https://gojs.tistory.com/67

 

DDD에서 표현 계층과 응용 계층 구성하기

응용 계층 구성응용 계층은 도메인 객체를 사용해서 사용자가 원하는 기능을 제공한다.응용 계층은 표현 계층에 의존성을 가지지 않으며 어떤 통신을 이용하는지 등의 정보를 알 필요는 없다. 

gojs.tistory.com

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함