ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [도메인 주도 개발 시작하기] 4. 리포지터리와 모델 구현
    STUDY/DDD 2022. 12. 1. 00:32

    1. JPA를 이용한 리포지터리 구현

     

    1-1. 모듈 위치

    리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다. 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮춰야 한다.

     

    1-2. 리포지터리 기본 기능 구현

    • ID로 애그리거트 조회하기
    • 애그리거트 저장하기 

     

    public interface OrderReporsitory {
    //  Optional<Order> findById(OrderNo no); null을 사용하기 않기 위해 Optional 사용
        Order findById(OrderNo no); // ID에 해당하는 애그리거트 반환
        void save(Order order); // 애그리거트 저장
    }

     

    리포지터리는 두 가지 기본 기능을 제공하고, 두 메서드를 위한 인터페이스는 위와 같은 형태를 갖는다. 인퍼테이스는 애그리거트 루트를 기준으로 작성한다(조회 기능은 보통 findBy프로퍼티이름(프로퍼티 값) 형식을 사용). JPA의 EntityManager를 이용해서 인터페이스 기능을 구현한 클래스를 작성할 수 있다.

     

    @Repository
    public class JpaOrderRepository implements OrderRepository {
        @PersistenceContext
        private EntityManager entityManager;
        
        @Override
        public Order findById(OrderNo id) {
        	// EntityManager의 find 메서드를 이용해서 ID로 애그리거트를 검색한다.
        	return entityManager.find(Order.class, id);
        }
        
        @Override
        public void save(Order order) {
        	// EntityManager의 persist 메서드를 이용해서 애그리거트를 저장한다.
        	entityManager.persist(order);
        }
    }

     

    애그리거트를 수정할 경우 결과를 저장소에 반영하는 메서드를 추가하지 않아도 된다. @Transactional 메서드 실행이 끝나면 트랜잭션을 커밋하는데 이때 트랜잭션 범위에서 변경된 객체의 데이터를 DB에 반영하기 위해 UPDATE 쿼리를 실행한다. 

     

    ID 외에 다른 조건으로 애그리거트를 조회할 때에는 JPA의 Criteria나 JPQL을 사용할 수 있다.

     

    또한 애그리거트를 삭제하는 기능이 필요할 수 있다. 삭제할 애그리거트 객체를 파라미터로 전달받고, 실제 구현 클래스에서는 EntityManager의 remove() 메서드를 이용해서 삭제 기능을 구현할 수 있다. 하지만 삭제 요구사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않다. 관리자 기능에서 삭제한 데이터까지 조회해야 하는 경우도 있고 데이터 원복을 위해 일정 기간 동안 보관해야 할 떄도 있기 때문이다. 따라서 보통 삭제 플래그를 사용해 화면에 보여줄지 여부를 결정하는 방식으로 구현한다.

     

    2. 스프링 데이터 JPA를 이용한 리포지터리 구현

     

    스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈(Spring Bean)으로 자동 등록해 준다.

    • org.springframework.data.repository.Repository<T, ID> 인터페이스 상속
    • T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
    //Order 엔티티 타입의 식별자가 OrderNo 타입인 경우
    @Entity
    @Table(name = "purchase_order")
    @Access(AccessType.FIELD)
    public class Order {
        @EmbeddedId
        private OrderNo number; // OrderNo가 식별자 타입
    }
    
    // Order를 위한 OrderRepository
    import org.springframework.data.repository.Repository;
    
    public interface OrderRepository extends Repository<Order, OrderNo> {
        Optional<Order> findById(OrderNo id);
        void save(Order order);
    }

     

    스프링 데이터 JPA가 OrderRepository를 리포지터리로 인식해서 알맞게 구현한 객체를 스프링 빈으로 등록한다. OrderRepository를 주입받아 사용할 수 있다.

     

    3. 매핑 구현

     

    3-1. 엔티티와 밸류 기본 매핑 구현

    • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.

     

    한 테이블에 엔티티와 밸류 데이터가 같이 있을 경우

    • 밸류는 @Embeddable로 매핑 설정한다.
    • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.

     

    엔티티와 엔티티에 속한 밸류가 한 테이블로 매핑

     

    // 루트 엔티티 Order
    @Entity
    @Table(name = "purchase_order")
    public class Order {
        ...
        @Embedded
        private Orderer orderer;
        ...
    }
    
    // Order의 밸류 Orderer
    @Embeddable
    public class Orderer {
        // MemberId에 정의된 칼럼 이름을 벼경하기 위해 @AttributeOverride 사용
        @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
        private MemberId memberId;
        
        @Column(name = "orderer_name")
        private String name;
    }

     

    • @Entity : 엔티티 설정
    • @Embeddable : 밸류 설정
    • @Embedded : 엔티티 클래스의 밸류 타입 프로퍼티 설정
    • @AttributeOverride : 프로퍼티명과 매핑할 컬럼 이름 변경(name=프로퍼티명, column=매핑할컬럼)

     

    3-2. 기본 생성자

     

    JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문이다. 이런 기술적인 제약으로 불변 타입이지만 기본 생성자를 추가해야 하는 경우가 발생한다. 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다. 기본 생성자를 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들게 되기 때문에 다른 코드에서 사용하지 못하도록 protected로 선언한다.

     

    3-3. 필드 접근 방식 사용

    JPA는 필드와 메서드 방식으로 매핑을 처리할 수 있는데, 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다.

     

    @Entity
    @Access(AccessType.FIELD)
    public class Order {
    	@EmbeddedId
        private OrderNo number;
        
        @Column(name = "state")
        @Enumerated(EnumType.STRING)
        private OrderState state;
        
        // cancel(), changeShippingInfo() 등 set 대신 의도가 잘 드러나는 메서드명을 사용해 도메인 기능 구현
        // 필요한 get 메서드 제공
    }

     

    3-4. AttributeConverter를 이용한 밸류 매핑 처리

    밸류 타입의 프로퍼티를 한 개 컬럼에 매핑해야 할 때 AttributeConverter를 사용해 밸류 타입과 컬럼 데이터 간의 변환을 처리할 수 있다. 

     

    // 밸류 타입의 프로퍼티
    public class Length {
    	private int value;
        private tring unit;
        ...
    }
    
    // 테이블 컬럼
    WIDTH VARCHAR(20)


    예를 들어 Length가 길이 값과 단위의 두 프로퍼티를 갖고 있는데 DB 테이블에는 한 개 컬럼에 저장하는 경우

    value = 1000, unit = mm 일 때, DB 테이블 컬럼 WIDTH VARCHAR(20)에 value+unit 형태인 1000mm로 저장할 수 있다.

     

    // AttributeConverter 인터페이스, X는 밸류타입, Y는 DB타입
    public interface AttributeConverter<X, Y> {
        public Y convertToDababaseColumn(X attribute);
        public X convertToEntityAttribute(Y dbData);
    }
    
    // AttributeConverter 구현체
    @Conberter(autoApply = true) // 모델에 출현하는 모든 Money 타입의 프로퍼티에 해당 구현체를 자동 적용
    public class MoneyConverter implements AttributeConverter<Money, Integer> {
        @Override
        public Integer convertToDatabaseColumn(Money money) {
        	return money == null ? null : money.getValue();
        }
        
        @Override
        public Money convertTyEntityAttribute(Integer value) {
        	return value == null ? null : new Money(value);
        }
    }
    
    // autoApply 속성이 false인 경우 직접 Converter 지정
    public class Order {
        @Column(name = "total_amounts")
        @Convert(converter = MoneyConverter.class)
        private Money totalAmounts;
    }

     

    3-5. 밸류 컬렉션:별도 테이블 매핑

    밸류 컬렉션을 별도 테이블로 매핑할 수 있다. 테이블은 외부키를 이용해서 컬렉션이 속할 엔티티 테이블을 참조한다. 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.

    @Entity
    @Table(name = "purchase_order")
    public class Order {
        @EnbeddedId
        private OrderNo number;
        
        ...
        
        @ElementCollection(fetch = FetchType.EAGER)
        @CollectionTable(name = "order_line",
        		joinColumns = @JoinColumn(name = "order_name")
        @OrderColumn(name = "line_idx")
        private List<OrderLine> orderLines;
        ...
    }

     

    • @OrderColumn : 지정한 컬럼에 리스트의 인덱스 값을 저장
    • @CollectionTable : 밸류를 저장할 테이블 지정
    • joinColumns : 외부키로 사용할 컬럼을 지정, 외부키가 두 개 이상인 경우 @JoinColumn의 배열을 이용해서 외부키 목록 지정

     

    3-6. 밸류 컬렉션:한 개 컬럼 매핑

    밸류 컬렉션이 별도 테이블이 아닌 한 개 컬럼에 매핑되는 경우 AttributeConverter를 사용할 수 있다. 단, 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.

     

    // 밸류 컬렉션을 위한 새로운 밸류 타입
    public class EmailSet {
        private Set<Email> emails = new HashSet<>();
        
        public EmailSet(Set<Email> emails) {
        	this.emails.addAll(emails);
        }
        
        public Set<Email> getEmails() {
        	return Collections.umnodifiableSet(emails);
        }
    }
    
    // Converter
    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);
        }
    }
    
    // EmailSet 타입 프로퍼티 COnverter 지정
    @Column(name = "emails")
    @Convert(converter = EmailSetConverter.class)
    private EmailSet emailSet;

     

    3-7. 밸류를 이용한 ID 매핑

    식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다. 밸류 타입을 식별자로 매핑하려면 @Id 대신 @EmbeddedId를 사용한다. JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다. 또한 JPA는 내부적으로 엔티티를 비교할 목적으로 equals()와 hashcode() 값을 사용하므로 두 메서드를 알맞게 구현해야 한다. 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다.

     

    @Entity
    @Table(name = "purchase_order")
    public class Order {
        @EmbeddedId
        private OrderNo number;
        ...
    }
    
    @Embeddable
    public class OrderNo implements Serializable {
       ...
    }

     

    3-8. 별도 테이블에 저장하는 밸류 매핑

    애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 루트 엔티티 외에 또다른 엔티티가 있다면 진짜 엔티티인지 확인해봐야 한다. 밸류가 아닌 엔티티가 확실하다면 독자적인 라이프 사이클을 갖는지 등 다른 애그리거트는 아닌지 확인해야 한다.

    애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다. 하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안된다. 별도 테이블에 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니기 때문이다.

     

    밸류 매핑 설정을 위해 @SecondaryTable과 @AttributeOverride를 사용한다.

    @Entity
    @Table(name = "article")
    @SecondaryTable(
        name = "article_content",
        pkJoinColumns = @PrimaryKeyJoinColumn(name = "id") // 밸류 테이블에서 엔티티 테이블로 조인할 떄 사용할 컬럼 지정
    )
    public class Article {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        
        private String title;
        
        @AttributeOverrides({
        	@AttrubuteOverride(
            	name = "content",
                column = @Column(table = "article_content", name = "content")
            ),
        	@AttrubuteOverride(
            	name = "contentType",
                column = @Column(table = "article_content", name = "content_type")
            ),
        })
        @Embedded
        private ArticleContent content;
    }

     

    3-9. 밸류 컬렉션을 @Entity로 매핑하기

    개념적으로 밸류지만 구현 기술의 한계나 팀 표준때문에 @Entity를 사용해야 할 때가 있다. 계층 구조를 갖는 밸류 타입인 경우, JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않기 때문에 @Entity를 이용해서 상속 매핑으로 처리해야 한다.

     

    @Entity로 매핑하므로 식별자 매핑을 위한 필드 추가와 구현 클래스를 구분하기 위한 타입 식별(discriminator) 컬럼을 추가해야 한다. @Entity로 매핑하지만 밸류이기 때문에 상태를 변경하는 기능은 추가하지 않는다.

    • @Inheritance 적용
    • strategy 값으로 SINGLE_TABLE 사용
    • @DiscriminatorColumn을 이용하여 타입 구분용으로 사용할 컬럼 지정
    • 상속 받는 클래스는 @Entity와 @DiscriminatorValue 사용해서 매핑 설정

     

    3-10. ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑

    애그리거트 간 집합 연관은 성능 상의 이유로 피해야 하지만, 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집한 연관을 적용할 수 있다.

     

    @Entity
    @Table(name = "product")
    public class Product {
        @EnbeddedId
        private ProductId id;
        
        @ElementCollection
        @CollectionTable(name = "product_category",
        		joinColumns = @JoinColumn(name = "product_id"))
        private Set<CategoryId> categoryIds;
        ...
    }

     

    ID 참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬랙션 매핑과 동일한 방식으로 설정한다. 차이점은 집합의 값에 밸류 대신 연관을 맺는 식별자가 온다는 것이다. @ElementCollection을 이용하기 때문에 Product를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다. 애그리거트를 직접 참조하는 방식을 사용했다면 영속성 전파나 로딩 전략을 고민해야 하는 데 ID 참조 방식을 사용함으로써 이런 고민을 없앨 수 있다.

     

    4. 애그리거트 로딩 전략

    JPA 매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 환전한 하나가 된다는 것이다. 즉, 이그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 함을 의미한다.

     

    조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 '즉시 로딩(FetchType.EAGER)'으로 설정하면 된다.

    @ElementCollection(fetch = FetchType.EAGER)

     

    즉시 로딩 방식은 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만, 컬렉션에 대한 로딩 전략을 즉시 로딩으로 설정한 경우 쿼리 결과에 중복이 발생할 수 있다. 애그리거트가 커지면 조회되는 데이터 개수가 더 많아지기 때문에 즉시 로딩 방식으로 사용할 때 성능(실행 빈도, 트래픽, 지연 로딩 시 실행 속도 등)을 검토해야 한다.

     

    애그리거트는 개념적으로 하나여야 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다. 애그리거트가 완전해야 하는 이유는 상태 변경 기능을 실행할 때 애그리거트가 완전해야 하고, 상태 정보를 보여줄 때 필요하기 때문인데, 상태를 변경할 때 JPA가 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제 상태를 변경하는 시점에서 필요한 구성요소만 로딩해도 문제가 되지 않는다. 따라서 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택해서 사용하면 된다.

     

    5. 애그리거트의 영속성 전파

    애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저자앟고 삭제할 때도 하나로 처리해야 함을 의미한다.

    • 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다.
    • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다.

     

    @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 되지만, 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장(CascadeType.PERSIST)과 삭제(CascadeType.REMOVE)시에 함께 처리되도록 설정해야 한다.

     

    6. 식별자 생성 기능

    식별자는 크게 세 가지 방식 중 하나로 생성한다.

    • 사용자가 직접 생성
    • 도메인 로직으로 생성
    • DB를 이용한 일련변호 사용

     

    이메일 주소처럼 사용자가 직접 식별자를 입력하는 경우 식별자 생성 주체가 사용자이기 떄문에 도메인 영역에 식별자 생성 디능을 구현할 필요가 없다.

     

    식별자 생성 규칙이 있다면 별도 서비스로 식별자 생성 기능을 분리해야한다. 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 위치시켜야 한다. 식별자 생성 규칙을 구현하기에 적합한 또 다른 장소는 리포지터리다. 인터페이스에 식별자를 생성하는 메서드를 추가하고 구현 클래스에서 알맞게 구현하면 된다.

     

    DB 자동증가 컬럼을 식별자로 사용할 경우 @GeneratedValue 를 사용하며, DB에 insert 쿼리가 실행돼야 식별자가 생성되므로 도메인 객체를 리포지터리에 저장할 때 식별자가 생성된다. JPA는 저장 시점에 생성한 식별자를 @Id로 매핑한 프로퍼티/필드에 할당하므로 저장 이후에 엔티티의 식별자를 사용할 수 있다.

     

    7. 도메인 구현과 DIP

    엔티티는 구현 기술인 JPA에 특화된 @Entity, @Table, @Id, @Column 등의 애너테이션을 사용하는데, DIP에 따르면 도메인 모델이 구현 기술인 JPA 에 의존하면 안되는데, @Entity, @Table은 구현 기술에 속하므로 도메인 모델이 JPA에 의존하게 된다. 리포지터리 인터페이스의 경우에도 스프링 데이터 JPA 의 Repository 인터페이스를 상속하고 있으므로 도메인이 인프라에 의존하는 것이다. 도메인에서 구현 기술에 대한 의존을 없애려면 구현 클래스를 인프라에 위치시켜야한다.

     

    DIP 를 적용하는 이유는 저수준 구현이 변경되어도 고수준이 영향 받지 않도록 하기 위함인데 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다. 따라서 이런 거의 변경이 없는 경우에도 변경을 미리 대비하는것은 과할 수 있다. 따라서 애그리거트, 리포지터리 등 도메인 모델을 구현할 때 타협을 하는 것도 좋다.

     

    출처 : 최범균, 『도메인 주도 개발 시작하기 DDD 핵심 개념 정리부터 구현까지』, 한빛미디어(2022), p130-p172

Designed by Tistory.