객체가 제공할 기능 중심으로 엔티티를 구현하도록 유도하려면, JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set 메서드를 구현하지 않도록 하자.
하이버네이트는 @Access로 접근 방식을 지정하지 않으면 @Id, @Embeddable 위치에 따라 접근 방식을 결정
필드에 위치하면 필드 접근 방식을 선택
get 메서드에 위치하면 메서드 접근 방식 선택
@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는 다음과 같이 밸류 타입과 컬럼 데이터 간의 변환을 처리하기 위한 기능을 정의
X는 밸류 타입
Y는 DB 타입
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) 지정 시 프로퍼티 값 변환 시 사용할 컨버터를 직접 지정
@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: 외부키로 사용할 컬럼)
@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에는 한 개의 컬럼에 콤마로 구분해서 저장
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 인터페이스를 상속받아야 한다.
@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: 해당 밸류 데이터가 저장된 테이블 이름 지정
@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로 매핑하므로 식별자 매핑을 위한 필드와 구현 클래스를 구분하기 위한 타입 식별 컬럼을 추가해야 한다.
식별자 생성 규칙은 도메인 규칙이므로 도메인 영역(도메인 서비스) 에 식별자 생성 기능을 위치시켜야 한다.
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 자동 증가 컬럼을 식별자로 사용
도메인 객체를 리포지터리에 저장할 때 식별자가 생성
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 특화 애너테이션 지우기
JPA 연동을 위한 클래스 추가하기
...
DIP 적용의 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향받지 않도록 하기 위함이다.
하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.
변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다.
DIP를 완벽하게 지키면 좋지만, JPA 애노테이션을 도메인 모델에 사용하면서도 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느 정도 유지할 수 있다.