[DDD] : 리포지터리와 모델 구현

이번 포스팅에서는 자바의 ORM 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해 살펴보겠습니다.

 

앞선 포스팅에서 설명한 것처럼 리포지터리 인터페이스애그리거트와 같은 도메인 영역에 속하고, 이를 구현한 클래스인프라스트럭처 영역에 속합니다.

가능하면 위와 같이 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜 인프라스트럭처에 대한 의존을 낮추는 것이 중요합니다.

  • 리포지터리 인터페이스는 애그리거트 루트를 기준으로 작성합니다.
  • 애그리거트를 수정한 결과를 저장소에 반영하는 메서드는 필요없습니다.
    • JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영합니다.
    • JPA는 변경된 객체의 데이터를 DB에 반영하기 위해 UPDATE 쿼리를 실행합니다.

매핑 구현

애그리거트와 JPA 매핑을 위한 기본 규칙은 다음과 같습니다.

  • 애그리거트 루트는 엔티티이므로 `@Entity`로 매핑 설정
  • 밸류는 `@Embeddable`로 매핑 설정
  • 밸류 타입 프로퍼티는 `@Embedded`로 매핑 설정

주문 애그리거트를 예시로 들어보겠습니다.

  • Order :  애그리거트 루트
  • Orderer : 밸류
  • ShippingInfo : 밸류
    • Address, Receiver 객체를 포함
루트 엔티티와 루트 엔티티에 속한 밸류는 한 테이블에 매핑하는 경우가 많습니다.

 

코드에 이를 적용하면 다음과 같습니다.

@Entity
@Table(name "purchase_order")
public class Order {
	
	@Embedded
	private Orderer orderer;
	
	@Embedded
	private ShippingInfo shippingInfo;
	
	...
}

@Embeddable
public class Orderer {
	
	@Embedded
	@AttributeOverrides({
		@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
	})
	private MemberId memberId;
	
	@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_zipCode")),
		@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;
}
  • 루트 엔티티인 `Order`는 JPA의 `@Entity`로 매핑합니다.
  • 또한, `@Embedded`를 이용해서 밸류 타입 프로퍼티를 설정합니다.
  • `Orderer`는 `Order`에 속하는 밸류이므로 `@Embeddable`로 매핑합니다.
  • `Orderer`의 `memberId`는 `Member` 애그리거트의 ID로 참조합니다.
    • 위 테이블 그림에서 `Orderer`의 `memberId` 프로퍼티와 매핑되는 칼럼의 이름이 `orderer_id`이기 때문에, `@AttributeOverrides` 어노테이션을 이용해 매핑해줍니다.

기본 생성자

밸류 타입이 불변인 경우, 생성 시점에 생성자를 통해 필요한 값을 모두 전달받아 `set` 메서드를 제공하지 않습니다. 하지만, JPA에서 `@Entity`와 `@Embeddable`로 클래스를 매핑하려면 기본 생성자(파라미터가 없는)를 제공해야 합니다.

따라서, 이 경우 기본 생성자를 JPA 프로바이더가 객체를 생성할 때만 사용될 수 있도록 `protected`로 선언해줘야 합니다.

AttributeConverter를 이용한 밸류 매핑 처리

int, string과 같은 단일 타입은 DB 테이블의 한 개 칼럼에 매핑이 되지만, 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때가 있습니다. 

예를 들어 다음과 같이 Length가 길이와 단위의 두 프로퍼티를 갖고 있는데 DB 테이블에는 한 개의 칼럼에 '1000mm'와 같은 형식으로 저장하는 경우를 생각해봅시다.

이러한 경우는 `@Embeddable` 어노테이션을 통해 처리할 수 없습니다. 이럴 때 사용할 수 있는 것이 `AttributeConverter`입니다.

`AttributeConverter`는

밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의하고 있습니다.

  • 타입 파라미터 `X` := 밸류 타입
  • 타입 파라미터 `Y` := DB 타입
  • `convertToDatabaseColumn()` := 밸류 타입 → DB 칼럼 값으로 변환
  • `convertToEntityAttribute()` := DB 칼럼 값 → 밸류로 변환

돈을 의미하는 Money 밸류 타입에 대해 이를 적용해보면 다음 코드와 같습니다.

@Converter(autoApply = 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);
	}
}
  • `@Converter` := `AttributeConverter` 인터페이스를 구현한 클래스에 적용
    • `autoApply = true` := 모델에 출현하는 모든 밸류 타입에 대해 해당 Converter를 자동으로 적용

밸류 컬렉션 : 별도 테이블 매핑

`Order` 엔티티는 한 개 이상의 `OrderLine`을 가질 수 있습니다. 따라서 `Order`와 `OrderLine`을 저장하기 위한 테이블은 아래와 같이 매핑이 가능합니다.

  • `ORDER_LINE` 테이블
    • 밸류 컬랙션을 저장
    • 외부키를 이용해 엔티티에 해당하는 `PURCHASE_ORDER` 테이블을 참조
    • List 타입의 컬랙션을 위해 인덱스 값을 저장하는 `line_idx`

@ElementCollection과 @CollectionTable

밸류 컬랙션을 별도 테이블로 매핑할 때는 `@ElementCollection`과 `@CollectionTable`을 함께 사용합니다.

@Entity
@Table(name = "purchase_order")
public class Order {
	
	@EmbeddedId
	private OrderNo number;
	...
	@ElementCollection(fetch = FetchType.EAGER)
	@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;
	
	@Column(name = "price")
	private Money price;
	
	@Column(name = "quantity")
	private int quantity;
	
	@Column(name = "amounts")
	private Money amounts;
	...
}
  • `OrderLine`에 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재하지 않습니다.
    • 그 이유는 List 타입 자체가 인덱스를 가지고 있기 때문입니다.
    • JPA는 `@OrderColumn` 어노테이션을 이용해서 지정한 칼럼에 리스트의 인덱스 값을 저장합니다.
  • `@CollectionTable` := 밸류를 저장할 테이블을 지정
    • `name` 속성 := 테이블 이름을 지정
    • `joinColumns` 속성 := 외부키로 사용할 칼럼을 지정. 외부키가 두 개 이상인 경우, `@JoinColumn`의 배열을 이용해 외부키 목록을 지정

밸류 컬랙션 : 한 개 칼럼 매핑

만약, 이메일 주소 목록을 모델에서는 Set으로 보관하고 DB에는 한 개 칼럼에 콤마(,)로 구분해서 저장해야 하는 경우에는 앞서 살펴본 `AttributeConverter`를 사용해 매핑할 수 있습니다. 여기서 `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.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);
	}
}

...
// EmailSet 타입 프로퍼티가 EmailSetConverter를 사용하도록 설정
@Column(name = "emails")
@Convert(converter = EmailSetConverter.class)
private EmailSet emailSet;
...

 

밸류를 이용한 ID 매핑

식별자(ID)의 의미를 부각시키기 위해 다음과 같이 식별자 자체를 밸류 타입으로 만들 수 있습니다.

@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에서 식별자 타입은 Serializable 타입이어야 합니다. 
  • 따라서, 식별자로 사용할 밸류 타입은 `Serializable` 인터페이스를 상속받아야 합니다.

별도 테이블에 저장하는 밸류 매핑

별도 테이블로 밸류를 매핑 한 모델의 경우 다음과 같을 수 있습니다.

  • `ArticleContent`는 밸류이므로 `@Embeddable`로 매핑
  • `ArticleContent`와 매핑되는 테이블은 `Article`과 매핑되는 테이블과 다릅니다.
  • 밸류를 매핑 한 테이블을 지정하기 위해 `@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({
		@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;
}
  • `@SecondaryTable`
    • `name` := 밸류를 저장할 테이블을 지정
    • `pkJoinColumns` := 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정

밸류 매핑 시, @OneToOne 대신 @SecondaryTable을 사용하는 이유

  • 개념적 일관성
    `@SecondaryTable`은 밸류 개념을 더 잘 표현합니다. 밸류는 독립적인 엔티티가 아니라 주 엔티티의 일부로 간주됩니다. `@OneToOne`은 두 개의 독립적인 엔티티 간의 관계를 나타내므로, 밸류의 개념과 맞지 않습니다.
  • 데이터 접근 방식
    `@SecondaryTable`을 사용하면 주 엔티티와 밸류 데이터를 항상 함께 조회합니다. 이는 밸류가 주 엔티티의 불가분한 부분이라는 DDD의 개념과 일치합니다.
  • 구현 단순화
    `@SecondaryTable`을 사용하면 별도의 엔티티 클래스 없이 하나의 엔티티 클래스에서 모든 데이터를 관리할 수 있습니다.
  • 제한적 사용
    `@SecondaryTable`은 두 테이블이 항상 함께 사용되어야 할 때만 사용해야 합니다. 이는 밸류가 주 엔티티와 생명주기를 같이 하는 DDD의 개념과 일치합니다.

애그리거트의 영속성 전파

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

  • `@Embeddable` 매핑 타입
    • 함께 저장되고 삭제되기 때문에 cascade 속성을 추가로 설정할 필요가 없다.
  • 애그리거트에 속한 `@Entity` 매핑 타입
    • @OneToOne, @OneToMany ... 등은 cascade 속성의 기본값이 없어, cascade 속성값으로 `CascadeType.PERSIST`, `CascadeType.REMOVE`를 설정해야 합니다.

'DDD' 카테고리의 다른 글

[DDD] : 표현 영역  (0) 2025.03.11
[DDD] : 응용 영역  (0) 2025.03.10
[DDD] : 아키텍처  (0) 2025.03.04
[DDD] : 도메인 모델  (0) 2025.02.27