기본 기능은 조회와 저장(업데이트)
인터페이스는 다음과 같은 형식을 갖는다.
public interface OrderRepository {
public Order findById(OrderNo no); // id로 Order 엔티티 조회
public void save(Order order);
}
findById()
는 아이디에 해당하는 애그리거트가 존재하면 Order를 리턴하고, 존재하지 않으면 null을 리턴한다.인터페이스를 구현한 클래스는 JPA의 EntityManager
를 이용해서 기능을 구현한다.
package com.myshop.order.infra.repository;
import com.myshop.order.command.domain.Order;
import com.myshop.order.command.domain.OrderNo;
import com.myshop.order.command.domain.OrderRepository;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityManager entityManager;
// entityManager의 find 메서드를 이용해 aggregate를 검색한다.
@Override
public Order findById(OrderNo id) {
return entityManager.find(Order.class, id);
}
// entityManager의 persist 메서드를 이용해 aggregate를 저장한다.
@Override
public void save(Order order) {
entityManager.persist(order);
}
}
aggregate를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없다. (JPA 에서 transaction 범위에서 변경한 데이터를 자동으로 DB에 반영하므로)
public class ChangeOrderService {
@Transactional
public void changeShippingInfo(OrderNo no, ShippingInfo newShippingInfo) {
Order order = orderRepository.findById(no);
if (order == null) throw new OrderNotFoundException();
order.changeShippingInfo(newShippingInfo);
}
...
}
@Transactional
이 선언되어 Spring의 transaction 관리 기능을 통해 transaction 범위에서 실행된다.ID가 아닌 다른 조건으로 aggregate를 조회해야 하는 경우 findBy 뒤에 조건 대상이 되는 property name을 붙인다.
하나 이상의 엔티티 객체를 리턴할 수 있는 경우에는 컬렉션 타입을 사용하여 return값을 정의한다. (List 등)
ID 이외에 다른 조건으로 aggregate를 조회하는 경우에는 JPA의 Criteria나 JPQL을 사용한다.
JPQL 사용의 예
@Override
public List<Order> findByOrdererId(String ordererId, int startRow, int fetchSize) {
TypedQuery<Order> query = entityManager.createQuery(
"select o from Order o " +
"where o.orderer.memberId.id = :ordererId " +
JpaQueryUtils.toJPQLOrderBy("o", "number.number desc"),
Order.class);
query.setParameter("ordererId", ordererId);
query.setFirstResult(startRow);
query.setMaxResults(fetchSize);
return query.getResultList();
}
...
public static String toJPQLOrderBy(String alias, String... orders) {
if (orders == null || orders.length == 0) return "";
String orderParts = Arrays.stream(orders)
.map(order -> alias + "." + order)
.collect(joining(", "));
return "order by " + orderParts;
}
aggregate 삭제 기능은 JPA entityManager의 remove() 메서드를 이용해 구현할 수 있다.
@Override
public void remove(Order order) {
entityManager.remove(order);
}
Aggregate와 JPA Mapping을 위한 기본 규칙
mapping 예시 : 주문 aggregate의 mapping
Entity : Order
import javax.persistence.Entity;
@Entity
@Table(name = "purchase_order")
public class Order {
@Embedded
private Orderer orderer;
@Embedded
private ShippingInfo shippingInfo;
...
}
Value :
Orderer
import javax.persistence.*;
@Embeddable
public class Orderer {
// MemberId에 정의된 column 이름을 변경하기 위해
// @AttributeOverride Annotation 사용
@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;
...
}
orderer_id
이므로 memberId 에 설정된 member_id
와 이름이 다르다.ShippingInfo (Address, Receiver)
@Embeddable
public class ShippingInfo {
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip_code")),
@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;
...
}
root entity와 root에 속한 value 는 한 테이블에 매핑될 수 있다. (PURCHASE_ORDER)
entity와 value의 생성자는 객체를 생성할 때 필요한 것을 전달받는다.
객체가 불변 타입이면 생성 시점에 필요한 값을 모두 전달받으므로 값을 변경하는 setter를 제공하지 않는다.
기본 생성자는 JPA Provider가 객체를 생성할 때만 사용될 수 있도록 protected
로 선언한다.
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Receiver {
}
// 또는
@Embeddable
public class Receiver {
protected Receiver() {}
}
private
이 아닌 protected
로 접근지시자를 지정해야 한다.JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있음.
메서드 방식
@Entity
@Table(name = "purchase_order")
@Access(AccessType.PROPERTY)
public class Order {
private OrderState state;
@Column(name = "state")
@Enumerated(EnumType.STRING)
public OrderState getState() {
return state;
}
public void setState(OrderState state) {
this.state = state;
}
}
엔티티를 객체가 제공할 기능 중심으로 구현하도록 유도하려면 JPA 매핑 처리를 property 방식이 아닌 field 방식으로 선택해서 불필요한 getter, setter 를 구현하지 말아야 한다.
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@Column(name = "state")
@Enumerated(EnumType.STRING)
private OrderState state;
... // setter가 아닌 도메인에 필요한 기능 구현 (cancel(), changeShippingInfo() 등)
... // 필요한 getter 제공
}
int, long, String, LocalDate 같은 타입은 DB 테이블의 한 개 칼럼과 매핑된다.
이와 비슷하게 value type의 property를 한 개 컬럼에 매핑해야 할 때도 있다.
JPA 2.0 버전에서는 이를 처리하기 위해 다음과 같이 컬럼과 매핑하기 위한 프로퍼티를 따로 추가하고 getter, setter에서 실제 벨류 타입과 변환 처리를 해야 했다.
public class Product {
@Column(name = "WIDTH")
private String width;
public Length getWidth() {
return new Width(width); // DB 컬럼 값을 실제 프로퍼티 타입으로 변환
}
void setWidth(Length width) {
this.width = width.toString(); // 실제 프로퍼티 타입을 DB 컬럼 값으로 변환
}
...
}
JPA 2.1에서는 DB 컬럼과 밸류 사이의 변환 코드를 모델에 구현하지 않아도 된다.대신 AttributeConverter를 사용해서 변환을 처리할 수 있다. (AttributeConverter 는 JPA 2.1에 추가된 인터페이스)
package javax.persistence;
// type parameter X는 Value Type이고, Y는 DB 타입이다.
public interface AttributeConverter<X, Y> {
public Y convertToDatabaseColumn(X attribute); // Value Type -> DB Column 으로 변환
public X convertToEntityAttribute(Y dbData); // DB Column -> Value Type 으로 변환
}
AttributeConverter 의 구현 예 : MoneyConverter
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Converter(autoApply = true) // 모델에 출현하는 모든 Money 타입의 property에 대해 MoneyConverter를 자동으로 적용한다.
public class MoneyConverter implements AttributeConverter<Money, Integer> {
@Override
public Integer convertToDatabaseColumn(Money money) {
if (money == null) {
return null;
} else {
return money.getValue();
}
}
@Override
public Money convertToEntityAttribute(Integer value) {
if (value == null) {
return null;
} else {
return new Money(value);
}
}
}
AttributeConverter 인터페이스를 구현한 클래스는 @Converter 애노테이션을 적용한다.
@Converter 의 autoApply 속성이 false 인 경우 (default 가 false), 값을 변환할 때 사용할 컨버터를 직접 지정할 수 있다.
예를 들어, Order의 totalAmounts 프로퍼티는 Money 타입 인데 이 프로퍼티를 DB total_amounts 컬럼에 매핑할 때 MoneyConverter를 사용한다.
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@Column(name = "total_amounts")
private Money totalAmounts; // MoneyConverter 를 적용해서 값 변환
/*
@Converter(autoApply = false) 인 경우
*/
@Column(name = "total_amounts")
@Convert(converter = MoneyCustomConverter.class)
private Money totalAmounts; // MoneyCustomConverter 를 적용해서 값 변환
}
ORDER_LINE 테이블 (value collection 저장)은 외부키를 이용해서 엔티티에 해당 하는 PURCHASE_ORDER 테이블을 참조한다.
밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
...
@ElementCollection
@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;
...
}
@CollectionTable은 밸류를 저장할 테이블을 지정할 때 사용한다.
// 이메일 집합
public class EmailSet {
private Set<Email> emails = new HashSet<>();
private EmailSet() {}
public EmailSet(Set<Email> emails) {
this.emails.addAll(emails);
}
public Set<Email> getEmails() {
return Collections.unmodifiableSet(emails);
}
}
import javax.persistence.AttributeConverter;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static java.util.stream.Collectors.toSet;
// AttributeConverter
public class EmailSetConverter implements AttributeConverter<EmailSet, String> {
@Override
public String convertToDatabaseColumn(EmailSet attribute) {
if (attribute == null) return null;
return attribute.getEmails().stream()
.map(Email::toString)
.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
private String number;
// 또는
@Id
private Long id;
@EmbeddedId
private OrderNo number;
//
@Embeddable
public class OrderNo implements Serializable {
@Column(name="order_number")
private String number;
...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderNo orderNo = (OrderNo) o;
return Objects.equals(number, orderNo.number);
}
@Override
public int hashCode() {
return Objects.hash(number);
}
}
> (*Address로 표기된 부분은 추측컨데 ArticleContent의 오타로 보인다.)
ArticleContent는 밸류이므로 @Embeddable 로 매핑.
ArticleContent 매핑되는 테이블은 밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverrides, @AttributeOverride를 사용한다.
import javax.persistence.*;
@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;
// 해당 annotation을 사용해서 해당 밸류 데이터가 저장된 테이블 이름을 지정한다.
@AttributeOverrides({
@AttributeOverride(name = "content",
column = @Column(table = "article_content")),
@AttributeOverride(name = "contentType",
column = @Column(table = "article_content"))
})
@Embedded
private ArticleContent content;
...
}
import javax.persistence.Embeddable;
@Embeddable
public class ArticleContent {
private String content;
private String contentType;
private ArticleContent() {
}
public ArticleContent(String content, String contentType) {
this.content = content;
this.contentType = contentType;
}
public String getContent() {
return content;
}
public String getContentType() {
return contentType;
}
}
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Repository
public class JpaArticleRepository implements ArticleRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public void save(Article article) {
entityManager.persist(article);
}
// @SecondaryTable 에서 매핑된 article_content 테이블을 조인
// 게시글 목록을 보여주는 경우 article 테이블의 데이터만 필요하므로 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.
@Override
public Article findById(Long id) {
return entityManager.find(Article.class, id);
}
}
예를 들어, 제품의 이미지 업로드 방식에 따라 이미지 경로와 썸네일 이미지 제공 여부가 달라지는 경우에 이를 위해 Image를 다음과 같이 계충 구조로 설계할 수 있다.
Image 클래스에 @Inheritance 적용하고 strategy 값으로 InheritanceType.SINGLE_TABLE 을 사용.
@DiscriminatorColumn 을 이용해서 타입을 구분하는 용도로 사용할 칼럼을 지정한다.
Image를 @Entity로 매핑했지만 모델에서 Image는 엔티티가 아니라 밸류이므로 상태를 변경하는 메서드는 추가하지 않는다.
import javax.persistence.*;
import java.util.Date;
@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;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "upload_time")
private Date uploadTime;
protected Image() {}
public Image(String path) {
this.path = path;
this.uploadTime = new Date();
}
protected String getPath() {
return path;
}
public Date getUploadTime() {
return uploadTime;
}
public abstract String getUrl();
public abstract boolean hasThumbnail();
public abstract String getThumbnailUrl();
}
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
protected InternalImage() {}
public InternalImage(String path) {
super(path);
}
@Override
public String getUrl() {
return "/images/original/" + getPath();
}
@Override
public boolean hasThumbnail() {
return true;
}
@Override
public String getThumbnailUrl() {
return "/images/thumbnail/"+getPath();
}
}
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
private ExternalImage() {}
public ExternalImage(String path) {
super(path);
}
@Override
public String getUrl() {
return getPath();
}
@Override
public boolean hasThumbnail() {
return false;
}
@Override
public String getThumbnailUrl() {
return null;
}
}
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
...
**@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
orphanRemoval = true, fetch = FetchType.EAGER)**
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
...
// 이미지 교체를 위해 clear() 메서드를 호출
public void changeImages(List<Image> newImages) {
images.clear();
images.addAll(newImages);
}
}
selet * from image where product_id=?
쿼리(이미 로딩했다면 select는 생략)와 각 Image를 삭제하기 위한 이미지 개수 만큼의 delete from image where image_id=?
쿼리를 실행한다.@Embeddable
public class Image {
@Column(name = "image_type")
private String imageType;
@Column(name = "image_path")
private String path;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "upload_time")
private Date uploadTime;
...
public boolean hasThumbnail() {
// 성능을 위해 다형을 포기하고 if-else로 구현
if (imageType.equals("II")) {
return true;
} else {
return false;
}
}
}
그럼에도 불구하고 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
// Product에서 Category로의 단방향 M:N 연관을 ID 참조 방식으로 구현.
@ElementCollection
@CollectionTable(name = "product_category",
joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
ID 참조를 이용한 애그리거트 간 단방향 M:N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정한 것을 확인할 수 있다.
차이점이 있다면, 집합의 값에 밸류 타입 대신 연관을 맺는 식별자가 온다는 점이다.
@ElementCollection 을 이용하기 때문에 Product 를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다.
애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다
는 것이다.즉, 애그리거트의 루트를 로딩(조회)하면 속해있는 모든 객체가 완전한 상태여야 하는 것.
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리게트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(FetchType.EAGER
)으로 설정한다.
컬렉션이나 @Entity 에 대한 매핑의 fetch 속성을 즉시 로딩으로 설정하면 EntityManager#find() 메서드로 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.
// @Entity 컬렉션에 대한 즉시 로딩 설정
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
// @Embeddable 컬렉션에 대한 즉시 로딩 설정
@ElementCollection
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
product의 image, option을 함께 조회하는 경우 product 테이블의 각 unique row만 조회되는 것이 아니라 join된 테이블의 row와 함께 조회된다.
(product1-image1-option1, product1-image1-option2 product1-image2-option1, ...)
물론, 하이버네이트가 중복된 데이터를 알맞게 제거해서 실제 메모리에는 1개의
Product 객체에 설정된 밸류만큼의 객체로 변환해 주지만 애그리거트가 커지면 문제가 될 수 있다.
JPA는 트랜잭션 범위 내에서 지연 로딩(FetchType.LAZY
)을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성 요소만 로딩해도 문제가 되지 않는다.
// product에 image정보, option정보가 함께 조회되어야 구성
@Transactional
public void removeOptions(ProductId id, int optIdxToBeDeleted) {
// product를 로딩. 컬렉션은 지연 로딩(Lazy)으로 설정했다면 Option은 로딩하지 않음
Product product = productRepository.findById(id);
// transaction 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
product.removeOption(optIdxToBeDeleted);
}
@Entity
@Table(name = "product")
public class Product {
...
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "product_option",
joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "list_idx")
private List<Option> options = new ArrayList<>();
...
public void removeOption(int optIdx) {
// 실제 컬렉션에 접근할 때 로딩.
this.options.remove(optIdx);
}
}
일반적인 애플리케이션은 상태를 변경하는 기능을 실행하는 빈도보다 조회 하는 기능을 실행하는 빈도가 휠씬 높다.
그러한 점에서 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 문제가 되지 않는다고 한다.
PERSIST
, REMOVE
를 설정한다.cascade = {CascadeType.PERSIST, CascadeType.REMOVE}
식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
public class ProductIdService {
public ProductId nextId() {
// 정해진 규칙으로 식별자 생성
}
public ProductOrderId createId(UserId userId) {
return new ProductOrderId(userId.toString() + "-" + getTimeStamp());
}
private String getTimeStamp() {
return Long.toString(System.currentTimeMillis();
}
}
응용 서비스는 도메인 영역의 서비스를 이용해서 식별자를 구한 뒤 엔티티를 생성
@Service
public class CreateProductService {
@Autowired
private ProductIdService idService;
@Autowired
private ProductRepository productRepository;
public ProductId createProduct(ProductCreationCommand cmd) {
// 응용 서비스는 도메인 서비스를 이용해서 식별자를 생성 처리
ProductId id = idService.nextId();
Product product = new Product(id, cmd.getDetail(), ...);
productRepository.save(product);
return id;
}
}
특정 값의 조합으로 식별자가 생성되는 것 역시 규칙이므로 도메인 서비스를 이용해서 식별자를 생성할 수 있다.
public interface ProductRepository {
...
// 식별자를 생성하는 메서드
ProductId nextId();
}
import javax.persistence.*;
@Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
public Long getId() {
return id;
}
...
}
public class WriteArticleService {
private ArticleRepository articleRepository;
Article article = new Article("제목", new ArticleContent("content", "type"));
articleRepository.save(article);
// EntityManager#save() 실행 시점에 식별자가 생성된다.
// 저장 이후에 식별자를 사용할 수 있다.
Long savedArticleId = article.getId();
}