JPA를 이용한 리포지토리 구현
애그리거트를 어떤 저장소에 저장하느냐에 따라 리포지터리를 구현하는 방법이 다르다.
리포지토리의 인터페이스는 애그리거트와 함께 도메인 영역 패키지에 두고, 리포지토리의 구현 클래스는 인프라 영역에 둔다. 이를 통해 인프라 영역에 대한 의존을 낮출 수 있다.
리포지토리의 인터페이스는 애그리거트 루트를 기준으로 작성한다. 예를 들어 주문 애그리거트라면 Order 엔티티 외에 Orderer, ShippingInfo 등 다양한 객체를 포함하지만 루트 엔티티인 Order를 기준으로 작성하도록 한다.
가장 기본적인 조회와 저장 메서드는 아래와 같이 구현한다.
Spring Data JPA 사용 시 Repository 인터페이스를 정의하면 구현 객체를 자동으로 만들어주기 때문에 직접 리포지토리 구현 클래스를 만들 필요가 없다.
또한 애그리거트를 수정한 결과를 저장소에 반영하는 메서드도 구현할 필요 없이, 데이터의 변경이 일어나면 트랜잭션 범위에서 자동으로 DB에 반영해준다.
Copy public interface OrderRepository extends Repository < Order , OrderNo > {
Optional < Order > findById ( OrderNo no);
void save ( Order order);
}
엔티티와 밸류 매핑
애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
하나의 테이블에 엔티티와 밸류가 함께 있으면 밸류는 @Embeddable로 매핑하고, 밸류 타입 프로퍼티는 @Embedded로 매핑한다.
루트 엔티티와 루트 엔티티에 속한 밸류는 아래와 같이 하나의 테이블에 매핑할 때가 많다.
다음은 Order라는 루트 엔티티와 그에 속하는 Orderer, MemberId 밸류들을 @Embeddable을 통해 매핑하는 예제이다.
밸류 타입 프로퍼티는 @Embedded 어노테이션을 붙여 매핑한다.
MemberId 프로퍼티와 매핑되는 칼럼 이름은 orderer_id이기 때문에 @AttributeOverrides 어노테이션을 이용해 매핑할 칼럼을 재정의한다.
Copy @ Entity
@ Table (name = “purchase_order”)
public class Order {
// ...
@ Embedded
private Orderer orderer;
}
@ 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;
}
JPA에서는 DB에서 데이터를 읽어와 객체를 생성할 때 기본 생성자를 이용하기 때문에, @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. 만약 기본 생성자가 필요 없는 불변 타입이라면 다른 코드에서 사용할 수 없도록 protected 으로 두어야 한다.
@Access
JPA에서는 필드에 접근하기 위해 메서드를 사용하거나 필드에 직접 접근하는 두 가지 방식을 사용할 수 있다. 메서드 기반 접근 방식을 사용하려면 @Access(AccessType.PROPERTY) 를 적용하고, 필드 방식을 사용하려면 @Access(AccessType.FIELD)를 적용해야 한다.
JPA 구현체인 하이버네이트는 @Access를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @Id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 사용하고 get 메서드에 위치하면 메서드 접근 방식을 사용한다.
메서드 접근 방식을 사용할 경우 getter/setter를 제공해주어야 하는데, JPA를 위해서 추가하는 것은 불필요한 구현이 될 수 있으므로 가급적 필드 방식을 사용해야 한다.
AttributeConverter
밸류 타입의 여러 프로퍼티를 하나의 컬럼에 매핑하기 위해 AttributeConverter를 사용할 수 있다.
Money라는 밸류 타입을 Integer 타입으로 변환해 컬럼에 저장하기 위해서는 아래와 같이 컨버터를 생성해야 한다.
autoApply 속성을 true로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다. autoApply의 기본값은 false이며 이 때에는 컨버터 적용을 원하는 필드에 @Convert(converter = MoneyConverter.class)
어노테이션을 붙여주어야 한다.
Copy @ 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) ;
}
}
밸류 컬렉션 매핑
별도 테이블 매핑
밸류 컬렉션을 프로퍼티로 갖는 엔티티를 매핑할 때 별도 테이블로 매핑하려면 @ElementCollection과 @CollectionTable을 함께 사용해야 한다.
다음은 주문 엔티티 내부에 주문 상세 내용들을 컬렉션으로 가질 때 매핑하는 방법이다.
@OrderColumn 애너테이션을 이용해서 지정한 칼럼에 OrderLine List의 인덱스 값을 저장한다. 이를 통해 OrderLine이 몇번째 순서에 존재하는지 확인할 수 있다.
@CollectionTable은 밸류를 저장할 테이블을 지정한다. name 속성은 테이블 이름을 지정하고, joinColumns 속성은 외부키로 사용할 컬럼을 지정한다.
Copy @ 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;
// ...
}
한개 컬럼 매핑
밸류 컬렉션을 한개의 컬럼에 저장하고자 한다면 AttributeConverter를 사용해야 하며, 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.
아래는 Email의 Set을 래핑한 EmailSet 클래스를만들고 Converter를 구현한 것이다.
Copy 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) ;
}
}
@ Entity
public class Person {
@ Column (name = “emails”)
@ Convert (converter = EmailSetConverter . class )
private EmailSet emailSet;
// ...
}
밸류 타입 식별자
식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 경우 @EmbeddedId 어노테이션을 사용해 매핑해야 한다.
JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다. 또한 식별자 타입의 비교 목적으로 equals(), hashcode() 메서드가 사용되므로 적절하게 구현해야 한다.
식별자 자체를 밸류 타입으로 둘 경우 식별자에 기능을 추가해 사용할 수 있다는 장점이 있다.
아래는 OrderNo라는 밸류 타입을 Order 엔티티의 식별자로 두고 이를 매핑하기 위해 @EmbeddedId 어노테이션을 사용하는 예제이다.
Copy @ 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;
// ...
}
밸류로 매핑
애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 만약 구성요소가 자신만의 독자적인 라이프 사이클을 갖는다면 다른 애그리거트의 엔티티일 가능성이 크다.
애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다.
주의해야 할 점은 테이블에서 클래스로 넘어올 때 테이블의 식별자를 무조건 애그리거트 구성요소의 식별자로 매핑하면 안된다는 것이다. 분리되어 있는 두 테이블을 클래스로 변환할 때 식별자를 사용하는 이유를 확인하여 고유 식별자를 둘지 말지 결정해야 한다.
아래와 같은 DB 테이블을 클래스로 매핑할 때 그대로 ARTICLE_CONTENT의 id까지 매핑하는 엔티티 타입이 아닌, id를 제외한 밸류 타입으로 매핑해야 한다.
아래와 같이 ArticleContent 객체와 ArticleContent 테이블을 매핑하기 위해 @SecondaryTable과 @AttributeOverride을 사용한다. @SecondaryTable의 name에는 밸류를 매핑할 테이블을, pkJoinColumns에는 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다.
Copy @ 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_content 테이블까지 조인해서 데이터를 읽어온다. 이를 해결하려면 조회 전용 기능을 구현해야 하며 11장에 해결 방법이 나온다.
밸류 컬렉션을 @Entity로 매핑
개념적으로 밸류이지만 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
예를 들어 상품의 이미지를 List 컬렉션으로 관리하고 이미지는 여러 타입을 지원하기 위해 상속 구조를 갖도록 한다면, JPA의 @Embeddable이 상속 매핑을 지원하지 않으므로 밸류 타입에 @Entity를 이용해야만 한다.
아래와 같이 여러 이미지의 자식 타입을 하나의 IMAGE 테이블에 넣기 위해 IMAGE_TYPE 정보를 담는다.
Image 추상 클래스에는 아래와 같이 JPA 기능을 설정한다.
**@**Inheritance 어노테이션을 적용하고 strategy 값으로 SINGLE_TABLE을 사용한다.
@DiscriminatorColumn 어노테이션을 사용해 타입 구분용으로 사용할 컬럼을 지정하면, 자식 클래스에서 @DiscriminatorValue 어노테이션의 값이 해당 컬럼에 들어간다.
Copy @ 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() ;
}
// ...
}
@ Entity
@ DiscriminatorValue (“II”)
public class InternalImage extends Image {
// ...
}
Product 클래스에서는 List 컬렉션에 @OneToMany 어노테이션을 붙여 매핑을 처리한다.@OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 Image 객체들이 Product 객체의 생명 주기에 맞춰지도록 cascade 속성과 orphanRemoval 속성을 지정해주어야 한다.
@Entity에 대한 @OneToMany 매핑에서 컬렉션의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행하므로 성능 상 문제가 발생할 수 있다.
이러한 문제를 회피하려면 상속 구조를 포기하고 @Embeddable 타입을 사용해 컬렉션의 clear() 메서드를 호출해도 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행하게 해야 한다.
Copy @ Entity
@ Table (name = “product”)
public class Product {
@ OneToMany (
cascade = { CascadeType . PERSIST , CascadeType . REMOVE } ,
orphanRemoval = true )
@ 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);
}
}
ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
애그리거트 간 집합 연관은 성능 상의 이유로 피해야 하지만 요구사항으로 인해 집합 연관을 사용해야 하는 경우, ID 참조를 이용한 단방향 집합 연관을 적용할 수 있다.
아래는 Product에서 Category로의 단방향 M-N 연관을 ID 참조 방식으로 구현한 것으로, 밸류 컬렉션 매핑과 동일한 형태이며 컬렉션 원소로 밸류 대신 연관을 맺는 식별자가 들어간다.
@ElementCollection을 이용하기 때문에 Product를 삭제할 때 매핑에 사용한 조인 테이블(PRODUCT_CATEGORY)의 데이터도 함께 삭제된다.
Copy @ Entity
@ Table (name = “product”)
public class Product {
@ EmbeddedId
private ProductId id;
@ ElementCollection
@ CollectionTable (name = “product_category” ,
joinColumns = @ JoinColumn (name = “product_id”))
private Set < CategoryId > categoryIds;
// ...
애그리거트 로딩 전략과 영속성 전파
JPA 매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(FetchType.EAGER)으로 설정하면 된다.
컬렉션에 대해 즉시 로딩 전략을 사용하면 조회되는 데이터 개수가 많아져 성능 문제가 발생할 수 있다.
아래와 같이 구성된 Product 엔티티를 조회하고자 한다면 카타시안 조인을 사용하게 되어 쿼리 결과에 중복이 생기게 된다. 하이버네이트가 중복 데이터를 자동으로 제거해주지만, 애그리거트가 커지면 쿼리 결과가 급증하게 될 것이다.
Copy @ Entity
@ Table (name = “product”)
public class Product {
@ 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 <>();
@ ElementCollection (fetch = FetchType . EAGER )
@ CollectionTable (name = “product_option” ,
joinColumns = @ JoinColumn (name = “product_id”))
@ OrderColumn (name = “list_idx”)
private List < Option > options = new ArrayList <>();
// ...
애그리거트가 완전해야 하는 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문이다. 다만 JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하여 실제 상태 변경 시점에 필요한 구성 요소만 로딩하더라도 문제가 되지 않는다.
표현 영역에서 애그리거트의 상태 정보를 보여줄 때에도 완전한 애그리거트가 필요하지만 이는 조회 전용 기능과 모델을 구현하는 것이 훨씬 유리하다.
아래는 Product 엔티티의 옵션을 변경하는 removeOption 메서드에서 옵션들을 가져와 특정 인덱스의 옵션을 제거할 때 지연 로딩을 하도록 구현한 것이다.
Copy @ Transactional
public void removeOptions( ProductId id , int optIdxToBeDeleted) {
Product product = productRepository . findById (id);
product . removeOption (optIdxToBeDeleted);
}
@ Entity
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);
}
}
식별자 생성 기능
식별자는 크게 세 가지 방식 중 하나로 생성한다.
사용자가 직접 생성
이 경우 도메인 영역에 식별자 생성 기능을 구현할 필요가 없다.
도메인 로직으로 생성
식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
도메인 서비스를 이용해 식별자를 생성하는 메서드를 제공하면 응용 서비스는 이를 이용해 식별자를 생성한 후 도메인 객체를 생성할 수 있다.
Copy public class ProductIdService {
public ProductId nextId () {
// 정해진 규칙으로 식별자 생성
}
}
public class CreateProductService {
@ Transactional
public ProductId createProduct ( ProductCreationCommand cmd) {
ProductId id = productIdService . nextId ();
Product product = new Product(id , cmd . getDetail() , cmd . getPrice()) ;
productRepository . save (product);
return id;
}
}
혹은 리포지토리 인터페이스에 식별자를 생성하는 메서드를 추가하고 구현하도록 둘 수도 있다.
DB를 이용한 일련번호 사용
JPA에서 제공하는 @GeneratedValue를 사용하면 된다. save() 할 때 DB에 연결을 맺어 ID 생성 요청을 하고, 해당 아이디를 기반으로 insert 쿼리를 보내게 된다.
Copy @ Entity
@ Table (name = “article”)
public class Article {
@ Id
@ GeneratedValue (strategy = GenerationType . IDENTITY )
private Long id;
// ...
도메인 구현과 DIP
DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데, 앞서 다뤘던 내용에서는 도메인 모델이 영속성 구현 기술인 JPA에 그대로 의존하고 있다.
리포지토리와 도메인 모델의 구현 기술은 거의 바뀌지 않기 때문에 개발 편의성과 실용성, 낮은 복잡도를 위해 DIP를 포기하면서 개발하는 것이 나을 수도 있다.