목차
본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
4.1 JPA를 이용한 리포지터리 구현
1) 모듈 위치

- 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다
- 각 타입의 패키지 구성은 [그림 4.1]과 같다
- 가능하면 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮춰야 한다
- 리포지터리 구현 클래스를 domian.impl과 같은 패키지에 위치시킬 수도 있는데 이것은 리포지터리 인터페이스와 구현체를 분리하기 위한 타협안 같은 것일 뿐 좋은 설계는 아니다
2) 리포지터리 기본 기능 구현
- 리포지터리가 제공하는 기본 기능은 다음 두 가지다
- ID로 애그리거트 조회하기
- 애그리거트 저정하기
- 인터페이스는 애그리거트 루트를 기준으로 작성한다
- JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하므로 수정을 위한 메서드를 레포지토리에 별도로 추가하지 않아도 된다
4.2 스프링 데이터 JPA를 이용한 리포지터리 구현
- 스프링과 JPA를 함께 적용할 때는 대개 스프링 데이터 JPA를 사용한다
- 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해준다
- org.springframework.data.repository.Repository<T, ID> 인터페이스 상속
- T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
- 기본적인 사용방법은 공식문서를 참고하자
- 저자는 책에서 JpaRepository가 아닌 Repository를 상속하고 있는데, 이러한 이유에 대해 설명하는 영상이 있다
4.3 매핑 구현
1) 엔티티와 밸류 기본 매핑 구현
- 애그리거트와 JPA 매핑을 위한 기본 규칙은 다음과 같다
- 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다
- 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
- 밸류는 @Embeddable로 매핑 설정한다
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다
- 루트 엔티티와 루트 엔티티에 속한 밸류는 [그림 4.2]처럼 한 테이블에 매핑할 때가 많다

- 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑한다
@Entity
@Table(name = "purchase_order")
public class Order {
...
}
- Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑한다
@Embeddable
public class Orderer {
// Member에 정의된 칼럼 이름을 변경하게 위해 @AttrbuteOverride 사용
@Embedded
@AttributeOverrides(
@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
)
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
- Order의 memberId는 Member 애그리거트를 ID로 참조한다
@Embeddable
public class MemberId implements Serializable {
@Column(name = "member_id")
private String id;
2) 기본 생성자
@Embeddable
public class Receiver {
@Column(name = "receiver_name")
private String name;
@Column(name = "receiver_phone")
private String phone;
public Receiver() {
}
public Receiver(String name, String phone) {
this.name = name;
this.phone = phone;
}
}
- JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다
- DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 떄문이다
- 이런 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 위와 같이 기본 생성자를 추가해야 한다
- 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다
- 기본 생성자를 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들게 된다
- 이러한 이유로 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언한다
3) 필드 접근 방식
- JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있다
- 필드: @Access(AccessType.PROPERTY)
- 메서드: @Access(AccessType.FIELD)
- 메서드 방식을 사용하려면 프로퍼티를 위한 getter/setter를 구현해야 한다
- 엔티티에 프로퍼티를 위한 공개 getter/setter를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터를 구현할 가능성이 높아진다
- 특히 setter는 내부 데이터를 외부에서 변경할 수 있는 수단이므로 캡슐화를 깨는 원인이 될 수 있다
- setter보다는 의도가 잘 드러나는 메서드를 작성하는 것이 좋다
- 따라서 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다
- 이는 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하는 효과가 있다
4) AttributeConverter를 이용한 밸류 매핑 처리

- 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 컬럼에 매핑하려면 @Embeddable 애너테이션으로는 처리할 수가 없다
- 이때 AttributeConverter를 사용할 수 있다
- AttributeConverter는 밸류 타입과 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의하고 있다
@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);
}
}
- AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애너테이션을 적용한다
- autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다
- autoApply 속성을 false로 지정하면 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다
public class Order {
@Convert(converter = MoneyConverter.class)
@Column(name = "total_amounts")
private Money totalAmounts;
}
5) 밸류 컬렉션: 별도 테이블 매핑

- Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다
- OrderLine에 순서가 있다면 다음과 같이 List 타입을 이용해서 컬렉션을 프로퍼티로 지정할 수 있다
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
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;
...
}
- 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다
- @CollectionTable은 밸류를 저장한 테이블을 지정한다
- name 속성은 테이블 이름을 지정하고 joinColumns 속성은 외래키로 사용할 컬럼을 지정한다
- 외래키가 두 개 이상인 경우 @JoinColumn의 배열을 이용해서 외래키 목록을 지정한다
- JPA는 @OrderColumn을 이용해서 지정한 컬럼에 리스트의 인덱스 값을 지정한다
6) 밸류 컬렉션: 한 개 컬럼 매핑
- 밸류 컬렉션을 별도 테이블이 아닌 한 개 컬럼에 저장해야 할 때가 있다
- 예를 들어 도메인 모델에는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 저장해야 할 때가 있다(개인적으로 이 예시는 잘 와닿지 않았다.. 정규화에 위배되는 케이스 같아서..)
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 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정한다
@Column(name = "emails")
@Convert(converter = EmailSetConveter.class)
private EmailSet emailSet;
7) 밸류를 이용한 ID 매핑
- 식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다
- 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용한다
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
...
}
@Embeddable
public class OrderNo implements Serializable {
@Column(name = "order_number")
private String number;
...
}
- JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다
- 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다
8) 별도 테이블에 저장하는 밸류 매핑
- 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다
- 루트 엔티티 외에 또다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다
- 단지 별도 테이블에 저장된다고 해서 엔티티인 것은 아니다
- 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다
- 특히 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
- 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다
- 하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안 된다
- 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니기 때문이다
- 예를 들어 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 할 때, [그림 4.5]와 같이 Article과 ArticleContent 클래스를 두 테이블에 매핑할 수 있다
- ArticleContent는 Article의 내용을 담고 있는 밸류이다
- ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함일 뿐 ARTICLE_CONTENT를 위한 별도 식별자가 필요하기 때문은 아니다

- ArticleContent를 밸류로 보고 접근하면 모델은 [그림 4.6]과 같이 바뀐다

- ArticleContent는 밸류이므로 @Embeddable로 매핑한다
- ArticleContent와 매핑되는 테이블은 Article과 매핑되는 테이블과 다르다
- 이때 밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverride를 사용한다
- SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정한다
- pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 컬럼을 지정한다
- @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을 이용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회한다
Article article = entityManager.find(Article.class, 1L);
9) 밸류 컬렉션을 @Entity로 매핑하기
- 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다
- 예를 들어 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라진다고 할 때, 이를 위해 Image를 [그림 4.7]과 같이 계층 구조로 설계할 수 있다

- JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다
- 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 한다
- 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다
- 또한 구현 클래스를 구분하기 위한 타입 식별(discriminator) 칼럼을 추가해야 한다

- 한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용한다
- @Inheritance 애너테이션 적용
- strategy 값으로 SINGLE_TABLE 사용
- @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 컬럼 지정
- 책에서는 단일 테이블 전략(SINGLE_TABLE)만을 소개하고 있는데, 단일 테이블 전략 이외에도 조인 전략(JOINED)과 구현 클래스마다 테이블 전략(TABLE_PER_CLASS)이 존재한다
- 여기에 관한 자세한 내용은 [자바 ORM 표준 JPA 프로그래밍]을 참고하자
@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();
}
protected String getPath() {
return path;
}
public LocalDateTime getUploadTime() {
return uploadTime;
}
public abstract String getUrl();
public abstract boolean hasThumbnail();
public abstract String getThumbnailUrl();
}
- Image를 상속받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정한다
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
...
}
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
...
}
- Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해서 매핑을 처리한다
- Image는 밸류이므로 독자적인 라이프 사이클을 가지는 대신 Product에 완전히 의존한다
- 따라서 Product를 저장할 때 함께 저장되고 Product를 삭제할 때 함께 삭제되도록 cascade 속성을 지정한다
- 리스트에서 Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정한다
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
private String name;
@Convert(converter = MoneyConverter.class)
private Money price;
private String detail;
@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);
}
}
- changeImages() 메서드를 보면 이미지 교체를 위해 clear() 메서드를 사용하고 있다
- @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제 과정이 다소 비효율적이다
- 하이버네이트의 경우 @Entity를 위한 컬렉션 객체의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행한다
- 이는 변경 빈도가 높은 경우 전체 서비스 성능에 문제를 일으킬 수 있다
- 하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다
- 따라서 애그리거트의 특성을 유지하면서 이 문제를 해소하기 위해선 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 한다
- 타입에 따라 다른 기능을 구현하기 위해선 다형성을 포기하고 메서드 내부에서 조건절(if-else)을 통해 분기되는 로직을 수행해야 한다
10) ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
- 3장에서 애그리거트 간 집합 연관은 성능 상의 이유로 피해야 된다고 했지만, 요구사항을 구현하는 데 집합 연권을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다
@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;
...
- 이는 Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것이다
- ID 참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정한 것을 알 수 있다
- 집합의 값에 밸류 대신 연관을 맺는 식별자가 온다
- @ElementCollection을 이용하므로 Product를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다
- 애그리거트를 직접 참조하는 방식을 사용하면 영속성 전파나 로딩 전략을 고민해야 하는데, ID 참조 방식을 사용함으로써 이런 고민을 없앨 수 있다
4.4 애그리거트 로딩 전략
- JPA 매핑 설정 시에 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
- 즉 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 함을 의미한다
- 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑이나 조회 방식을 즉시 로딩(FetchType.EAGER)으로 설정하면 된다
- 즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만 이것이 항상 좋은 것이 아니다
- 특히 컬렉션에 대해 즉시 로딩 방식을 설정하면 여러 문제를 일으킬 수 있다
- 예컨대 즉시 로딩 방식으로 설정된 엔티티 필드나 밸류 필드에 대해서 쿼리 실행 시 lefter outer join을 수행하기 때문에 성능적인 문제를 일으킬 수 있다
- 애그리거트는 개념적으로 하나여야 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다
- 애그리거트가 완전해야 하는 이유는 두 가지 정도이다
- 첫 번째 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문이다
- 두 번째 이유는 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문이다
- 이 중 두 번째는 별도의 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리하기 때문에 애그리거트의 완전한 로딩과 관련된 문제는 상태 변경과 더 관련이 있다
- JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다
- 또한 일반적인 애플리케이션은 상태 변경을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높으므로 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다
- 위의 이유로 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요는 없다
- 지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다
- 즉시 로딩 설정은 @Entity나 @Embeddable에 대해 다르게 동작하고, JPA 프로바이더에 따라 구현 방식이 다를 수 있다
- 책에서는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하라고는 하지만, 실제론 모든 연관을 지연 로딩으로 설정하는 것이 일반적이다
- 지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다
4.5 애그리거트의 영속성 전파
- 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미한다
- 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다
- 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다
- @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다
- 반면에 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다
- @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 다음 코드처럼 cascade 속성값으로 CascadeType.PERSIST, CascadeType.REMOVE를 설정핟나
@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<>();
4.6 식별자 생성 기능
- 식별자는 크게 세 가지 방식 중 하나로 생성한다
- 사용자가 직접 생성
- 도메인 로직으로 생성
- DB를 이용한 일련번호 생성
- 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리해야 한다
- 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다
- 도메인 서비스에서 식별자를 생성하고, 응용 서비스가 이 도메인 서비스를 이용해서 식별자를 구하고 엔티티를 생성하게 구현할 수 있다
- 리포지토리 또한 식별자 생성 규칙을 구현하기에 적합한데, 레포지토리 인터페이스에 식별자를 생성하는 메서드를 추가하고 리포지토리 구현 클래스에서 알맞게 구현하면 된다
- DB 자동 증가 컬럼을 식별자로 생성하면 식별자 매핑에서 @GeneratedValue를 사용한다
- 자동 증가 컬럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지토리에 저장할 때 생성된다
- 즉, 도메인 객체를 생성하는 시점에는 식별자를 알 수 없고 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음을 의미한다
- JPA는 저장 시점에 생성한 식별자를 @Id로 매핑한 프로퍼티/필드에 할당하기 때문에 저장 이후엔 엔티티의 식별자를 이용할 수 있다
- 자동 증가 컬럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 마찬가지로 저장 시점에 식별자를 생성한다
4.7 도메인 구현과 DIP
- 이 장에서 구현한 리포지터리는 DIP 원칙을 어기고 있다
- 먼저 엔티티는 아래 코드처럼 구현 기술인 JPA에 특화된 @Entity, @Table, @Id, @Column 등의 애노테이션을 사용하고 있다
- DIP에 따르면 @Entity, @Table은 구현 기술인 JPA에 의존하지 말아야 하는데 이 코드는 도메인 모델인 Article이 영속성 구현 기술인 JPA에 의존하고 있다
@Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- 리포지토리 인터페이스도 마찬가지로 DIP 원칙을 어기고 있다
- 아래 코드에서 ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다
- 즉, 도메인이 인프라에 의존하는 것이다
public interface ArticleRepository extends Repository<Article, Long> {
void save(Article article);
Optional<Article> findById(Long id);
}
- 구현 기술에 대한 의존 없이 도메인을 순수하게 유지하려면 스프링 데이터 JPA의 Repository 인터페이스를 상속받지 않도록 수정하고 [그림4.9]와 같이 ArticleRepository 인터페이스를 구현한 클래스를 인프라에 위치시켜야 한다
- 또한 Article 클래스에서 @Entity나 @Table과 같이 JPA에 특화된 애노테이션을 모두 지우고 인프라에 JPA를 연동하기 위한 클래스를 추가해야 한다
- 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 [그림 4.9]와 같은 구조로 구현하는데, 이 구조를 가지면 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화할 수 있다

- DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다
- 하지만 리포지터리와 도메인 모델의 구현 기술은 실제론 거의 바뀌지 않는다
- JPA 전용 애너테이션을 사용하더라도 도메인 모델을 단위 테스트하는 데 문제는 없다
- 리포지터리도 마찬가지로 스프링 데이터 JPA가 제공하는 Repository 인터페이스를 상속하고 있지만 리포지토리 자체는 인터페이스이고 테스트 가능성을 해치지 않는다
- 저자는 개발 편의성과 실용성을 가져가면서 구조적인 유연함을 어느 정도 유지하는 정책을 선택했다
'책 > 도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 6장: 응용 서비스와 표현 영역 (0) | 2022.10.14 |
---|---|
[도메인 주도 개발 시작하기] 5장: 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2022.10.07 |
[도메인 주도 개발 시작하기] 3장: 애그리거트 (1) | 2022.09.20 |
[도메인 주도 개발 시작하기] 2장: 아키텍처 개요 (0) | 2022.09.16 |
[도메인 주도 개발 시작하기] 1장: 도메인 주도 개발 시작하기 (0) | 2022.09.07 |
본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
4.1 JPA를 이용한 리포지터리 구현
1) 모듈 위치

- 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다
- 각 타입의 패키지 구성은 [그림 4.1]과 같다
- 가능하면 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮춰야 한다
- 리포지터리 구현 클래스를 domian.impl과 같은 패키지에 위치시킬 수도 있는데 이것은 리포지터리 인터페이스와 구현체를 분리하기 위한 타협안 같은 것일 뿐 좋은 설계는 아니다
2) 리포지터리 기본 기능 구현
- 리포지터리가 제공하는 기본 기능은 다음 두 가지다
- ID로 애그리거트 조회하기
- 애그리거트 저정하기
- 인터페이스는 애그리거트 루트를 기준으로 작성한다
- JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하므로 수정을 위한 메서드를 레포지토리에 별도로 추가하지 않아도 된다
4.2 스프링 데이터 JPA를 이용한 리포지터리 구현
- 스프링과 JPA를 함께 적용할 때는 대개 스프링 데이터 JPA를 사용한다
- 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해준다
- org.springframework.data.repository.Repository<T, ID> 인터페이스 상속
- T는 엔티티 타입을 지정하고 ID는 식별자 타입을 지정
- 기본적인 사용방법은 공식문서를 참고하자
- 저자는 책에서 JpaRepository가 아닌 Repository를 상속하고 있는데, 이러한 이유에 대해 설명하는 영상이 있다
4.3 매핑 구현
1) 엔티티와 밸류 기본 매핑 구현
- 애그리거트와 JPA 매핑을 위한 기본 규칙은 다음과 같다
- 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다
- 한 테이블에 엔티티와 밸류 데이터가 같이 있다면
- 밸류는 @Embeddable로 매핑 설정한다
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다
- 루트 엔티티와 루트 엔티티에 속한 밸류는 [그림 4.2]처럼 한 테이블에 매핑할 때가 많다

- 주문 애그리거트에서 루트 엔티티인 Order는 JPA의 @Entity로 매핑한다
@Entity
@Table(name = "purchase_order")
public class Order {
...
}
- Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑한다
@Embeddable
public class Orderer {
// Member에 정의된 칼럼 이름을 변경하게 위해 @AttrbuteOverride 사용
@Embedded
@AttributeOverrides(
@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
)
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
- Order의 memberId는 Member 애그리거트를 ID로 참조한다
@Embeddable
public class MemberId implements Serializable {
@Column(name = "member_id")
private String id;
2) 기본 생성자
@Embeddable
public class Receiver {
@Column(name = "receiver_name")
private String name;
@Column(name = "receiver_phone")
private String phone;
public Receiver() {
}
public Receiver(String name, String phone) {
this.name = name;
this.phone = phone;
}
}
- JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다
- DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 떄문이다
- 이런 기술적인 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 위와 같이 기본 생성자를 추가해야 한다
- 기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용한다
- 기본 생성자를 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들게 된다
- 이러한 이유로 다른 코드에서 기본 생성자를 사용하지 못하도록 protected로 선언한다
3) 필드 접근 방식
- JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있다
- 필드: @Access(AccessType.PROPERTY)
- 메서드: @Access(AccessType.FIELD)
- 메서드 방식을 사용하려면 프로퍼티를 위한 getter/setter를 구현해야 한다
- 엔티티에 프로퍼티를 위한 공개 getter/setter를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터를 구현할 가능성이 높아진다
- 특히 setter는 내부 데이터를 외부에서 변경할 수 있는 수단이므로 캡슐화를 깨는 원인이 될 수 있다
- setter보다는 의도가 잘 드러나는 메서드를 작성하는 것이 좋다
- 따라서 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 말아야 한다
- 이는 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하는 효과가 있다
4) AttributeConverter를 이용한 밸류 매핑 처리

- 두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 컬럼에 매핑하려면 @Embeddable 애너테이션으로는 처리할 수가 없다
- 이때 AttributeConverter를 사용할 수 있다
- AttributeConverter는 밸류 타입과 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의하고 있다
@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);
}
}
- AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애너테이션을 적용한다
- autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다
- autoApply 속성을 false로 지정하면 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다
public class Order {
@Convert(converter = MoneyConverter.class)
@Column(name = "total_amounts")
private Money totalAmounts;
}
5) 밸류 컬렉션: 별도 테이블 매핑

- Order 엔티티는 한 개 이상의 OrderLine을 가질 수 있다
- OrderLine에 순서가 있다면 다음과 같이 List 타입을 이용해서 컬렉션을 프로퍼티로 지정할 수 있다
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
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;
...
}
- 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다
- @CollectionTable은 밸류를 저장한 테이블을 지정한다
- name 속성은 테이블 이름을 지정하고 joinColumns 속성은 외래키로 사용할 컬럼을 지정한다
- 외래키가 두 개 이상인 경우 @JoinColumn의 배열을 이용해서 외래키 목록을 지정한다
- JPA는 @OrderColumn을 이용해서 지정한 컬럼에 리스트의 인덱스 값을 지정한다
6) 밸류 컬렉션: 한 개 컬럼 매핑
- 밸류 컬렉션을 별도 테이블이 아닌 한 개 컬럼에 저장해야 할 때가 있다
- 예를 들어 도메인 모델에는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 저장해야 할 때가 있다(개인적으로 이 예시는 잘 와닿지 않았다.. 정규화에 위배되는 케이스 같아서..)
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 타입 프로퍼티가 Converter로 EmailSetConverter를 사용하도록 지정한다
@Column(name = "emails")
@Convert(converter = EmailSetConveter.class)
private EmailSet emailSet;
7) 밸류를 이용한 ID 매핑
- 식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다
- 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션을 사용한다
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
...
}
@Embeddable
public class OrderNo implements Serializable {
@Column(name = "order_number")
private String number;
...
}
- JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다
- 밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다
8) 별도 테이블에 저장하는 밸류 매핑
- 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다
- 루트 엔티티 외에 또다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 한다
- 단지 별도 테이블에 저장된다고 해서 엔티티인 것은 아니다
- 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다
- 특히 자신만의 독자적인 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다
- 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다
- 하지만 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안 된다
- 별도 테이블로 저장하고 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자를 갖는 것은 아니기 때문이다
- 예를 들어 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블로 나눠서 저장한다고 할 때, [그림 4.5]와 같이 Article과 ArticleContent 클래스를 두 테이블에 매핑할 수 있다
- ArticleContent는 Article의 내용을 담고 있는 밸류이다
- ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함일 뿐 ARTICLE_CONTENT를 위한 별도 식별자가 필요하기 때문은 아니다

- ArticleContent를 밸류로 보고 접근하면 모델은 [그림 4.6]과 같이 바뀐다

- ArticleContent는 밸류이므로 @Embeddable로 매핑한다
- ArticleContent와 매핑되는 테이블은 Article과 매핑되는 테이블과 다르다
- 이때 밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverride를 사용한다
- SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정한다
- pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 컬럼을 지정한다
- @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을 이용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회한다
Article article = entityManager.find(Article.class, 1L);
9) 밸류 컬렉션을 @Entity로 매핑하기
- 개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다
- 예를 들어 제품의 이미지 업로드 방식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라진다고 할 때, 이를 위해 Image를 [그림 4.7]과 같이 계층 구조로 설계할 수 있다

- JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다
- 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 한다
- 밸류 타입을 @Entity로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다
- 또한 구현 클래스를 구분하기 위한 타입 식별(discriminator) 칼럼을 추가해야 한다

- 한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용한다
- @Inheritance 애너테이션 적용
- strategy 값으로 SINGLE_TABLE 사용
- @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 컬럼 지정
- 책에서는 단일 테이블 전략(SINGLE_TABLE)만을 소개하고 있는데, 단일 테이블 전략 이외에도 조인 전략(JOINED)과 구현 클래스마다 테이블 전략(TABLE_PER_CLASS)이 존재한다
- 여기에 관한 자세한 내용은 [자바 ORM 표준 JPA 프로그래밍]을 참고하자
@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();
}
protected String getPath() {
return path;
}
public LocalDateTime getUploadTime() {
return uploadTime;
}
public abstract String getUrl();
public abstract boolean hasThumbnail();
public abstract String getThumbnailUrl();
}
- Image를 상속받은 클래스는 @Entity와 @Discriminator를 사용해서 매핑을 설정한다
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
...
}
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
...
}
- Image가 @Entity이므로 목록을 담고 있는 Product는 @OneToMany를 이용해서 매핑을 처리한다
- Image는 밸류이므로 독자적인 라이프 사이클을 가지는 대신 Product에 완전히 의존한다
- 따라서 Product를 저장할 때 함께 저장되고 Product를 삭제할 때 함께 삭제되도록 cascade 속성을 지정한다
- 리스트에서 Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval도 true로 설정한다
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
private String name;
@Convert(converter = MoneyConverter.class)
private Money price;
private String detail;
@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);
}
}
- changeImages() 메서드를 보면 이미지 교체를 위해 clear() 메서드를 사용하고 있다
- @Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 삭제 과정이 다소 비효율적이다
- 하이버네이트의 경우 @Entity를 위한 컬렉션 객체의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행한다
- 이는 변경 빈도가 높은 경우 전체 서비스 성능에 문제를 일으킬 수 있다
- 하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다
- 따라서 애그리거트의 특성을 유지하면서 이 문제를 해소하기 위해선 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 한다
- 타입에 따라 다른 기능을 구현하기 위해선 다형성을 포기하고 메서드 내부에서 조건절(if-else)을 통해 분기되는 로직을 수행해야 한다
10) ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
- 3장에서 애그리거트 간 집합 연관은 성능 상의 이유로 피해야 된다고 했지만, 요구사항을 구현하는 데 집합 연권을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다
@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;
...
- 이는 Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것이다
- ID 참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정한 것을 알 수 있다
- 집합의 값에 밸류 대신 연관을 맺는 식별자가 온다
- @ElementCollection을 이용하므로 Product를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다
- 애그리거트를 직접 참조하는 방식을 사용하면 영속성 전파나 로딩 전략을 고민해야 하는데, ID 참조 방식을 사용함으로써 이런 고민을 없앨 수 있다
4.4 애그리거트 로딩 전략
- JPA 매핑 설정 시에 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
- 즉 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 함을 의미한다
- 조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑이나 조회 방식을 즉시 로딩(FetchType.EAGER)으로 설정하면 된다
- 즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만 이것이 항상 좋은 것이 아니다
- 특히 컬렉션에 대해 즉시 로딩 방식을 설정하면 여러 문제를 일으킬 수 있다
- 예컨대 즉시 로딩 방식으로 설정된 엔티티 필드나 밸류 필드에 대해서 쿼리 실행 시 lefter outer join을 수행하기 때문에 성능적인 문제를 일으킬 수 있다
- 애그리거트는 개념적으로 하나여야 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다
- 애그리거트가 완전해야 하는 이유는 두 가지 정도이다
- 첫 번째 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문이다
- 두 번째 이유는 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문이다
- 이 중 두 번째는 별도의 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리하기 때문에 애그리거트의 완전한 로딩과 관련된 문제는 상태 변경과 더 관련이 있다
- JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다
- 또한 일반적인 애플리케이션은 상태 변경을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높으므로 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다
- 위의 이유로 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요는 없다
- 지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다
- 즉시 로딩 설정은 @Entity나 @Embeddable에 대해 다르게 동작하고, JPA 프로바이더에 따라 구현 방식이 다를 수 있다
- 책에서는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택하라고는 하지만, 실제론 모든 연관을 지연 로딩으로 설정하는 것이 일반적이다
- 지연 로딩은 동작 방식이 항상 동일하므로 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다
4.5 애그리거트의 영속성 전파
- 애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미한다
- 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다
- 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다
- @Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다
- 반면에 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다
- @OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 다음 코드처럼 cascade 속성값으로 CascadeType.PERSIST, CascadeType.REMOVE를 설정핟나
@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<>();
4.6 식별자 생성 기능
- 식별자는 크게 세 가지 방식 중 하나로 생성한다
- 사용자가 직접 생성
- 도메인 로직으로 생성
- DB를 이용한 일련번호 생성
- 식별자 생성 규칙이 있다면 엔티티를 생성할 때 식별자를 엔티티가 별도 서비스로 식별자 생성 기능을 분리해야 한다
- 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다
- 도메인 서비스에서 식별자를 생성하고, 응용 서비스가 이 도메인 서비스를 이용해서 식별자를 구하고 엔티티를 생성하게 구현할 수 있다
- 리포지토리 또한 식별자 생성 규칙을 구현하기에 적합한데, 레포지토리 인터페이스에 식별자를 생성하는 메서드를 추가하고 리포지토리 구현 클래스에서 알맞게 구현하면 된다
- DB 자동 증가 컬럼을 식별자로 생성하면 식별자 매핑에서 @GeneratedValue를 사용한다
- 자동 증가 컬럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지토리에 저장할 때 생성된다
- 즉, 도메인 객체를 생성하는 시점에는 식별자를 알 수 없고 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음을 의미한다
- JPA는 저장 시점에 생성한 식별자를 @Id로 매핑한 프로퍼티/필드에 할당하기 때문에 저장 이후엔 엔티티의 식별자를 이용할 수 있다
- 자동 증가 컬럼 외에 JPA의 식별자 생성 기능을 사용하는 경우에도 마찬가지로 저장 시점에 식별자를 생성한다
4.7 도메인 구현과 DIP
- 이 장에서 구현한 리포지터리는 DIP 원칙을 어기고 있다
- 먼저 엔티티는 아래 코드처럼 구현 기술인 JPA에 특화된 @Entity, @Table, @Id, @Column 등의 애노테이션을 사용하고 있다
- DIP에 따르면 @Entity, @Table은 구현 기술인 JPA에 의존하지 말아야 하는데 이 코드는 도메인 모델인 Article이 영속성 구현 기술인 JPA에 의존하고 있다
@Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- 리포지토리 인터페이스도 마찬가지로 DIP 원칙을 어기고 있다
- 아래 코드에서 ArticleRepository 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속하고 있다
- 즉, 도메인이 인프라에 의존하는 것이다
public interface ArticleRepository extends Repository<Article, Long> {
void save(Article article);
Optional<Article> findById(Long id);
}
- 구현 기술에 대한 의존 없이 도메인을 순수하게 유지하려면 스프링 데이터 JPA의 Repository 인터페이스를 상속받지 않도록 수정하고 [그림4.9]와 같이 ArticleRepository 인터페이스를 구현한 클래스를 인프라에 위치시켜야 한다
- 또한 Article 클래스에서 @Entity나 @Table과 같이 JPA에 특화된 애노테이션을 모두 지우고 인프라에 JPA를 연동하기 위한 클래스를 추가해야 한다
- 특정 기술에 의존하지 않는 순수한 도메인 모델을 추구하는 개발자는 [그림 4.9]와 같은 구조로 구현하는데, 이 구조를 가지면 구현 기술을 변경하더라도 도메인이 받는 영향을 최소화할 수 있다

- DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다
- 하지만 리포지터리와 도메인 모델의 구현 기술은 실제론 거의 바뀌지 않는다
- JPA 전용 애너테이션을 사용하더라도 도메인 모델을 단위 테스트하는 데 문제는 없다
- 리포지터리도 마찬가지로 스프링 데이터 JPA가 제공하는 Repository 인터페이스를 상속하고 있지만 리포지토리 자체는 인터페이스이고 테스트 가능성을 해치지 않는다
- 저자는 개발 편의성과 실용성을 가져가면서 구조적인 유연함을 어느 정도 유지하는 정책을 선택했다
'책 > 도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 6장: 응용 서비스와 표현 영역 (0) | 2022.10.14 |
---|---|
[도메인 주도 개발 시작하기] 5장: 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2022.10.07 |
[도메인 주도 개발 시작하기] 3장: 애그리거트 (1) | 2022.09.20 |
[도메인 주도 개발 시작하기] 2장: 아키텍처 개요 (0) | 2022.09.16 |
[도메인 주도 개발 시작하기] 1장: 도메인 주도 개발 시작하기 (0) | 2022.09.07 |