기본 기능은 조회와 저장(업데이트)
인터페이스는 다음과 같은 형식을 갖는다.
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();
}