//
Search
Duplicate
📖

[도메인 주도 개발 시작하기] - 리포지터리와 모델 구현

상태
완료
담당자
시작일
2023/08/23 10:53
최종편집일
2023/12/10 11:50
나는 기존에 JPA를 사용하기 있었기 때문에 기본적인 동작인 4.2리포지터리 구현에 대해서는 생략 하도록 하겠다.
4.3 맵핑 구현
엔티티와 벨류가 한테이블에 매핑
@Entity @Table(name = "purchase_order") public class Order { ... }
Java
복사
Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑한다.
package triple.review.service; import javax.persistence.*; @Entity @Embeddable public class Orderer { @Embedded @AttributeOverrides( @AttributeOverride(name = "id", column = @Column(name = "order_id")) ) private MemberId memberId; @Column(name = "orderer_name") private String name; }
Java
복사
Orderer의 memberId는 Member의 에그리거트를 ID로 참조한다.
@Embeddable public class MemberId implements Serializable { @Column(name = "member_id") private String id; }
Java
복사
OrderermemberId 프로퍼티와 매핑되는 칼럼 이름은 ‘orderer_id’이므로 MemberId에 설정된 ‘meber_id’와 이름이 다르다. @Embeddable 타입에 설정한 컬럼이름과 실제 칼럼 이름이 다르므로 @AttributeOverrides 에너테이션을 이용해 Orderer의 memberId프로퍼티와 매핑할 칼럼 이름을 변경했다.
Orderer와 마찬가지로 ShippingInfo 밸류도 Address와 Receiver를 포함한다. 이것도 마찬가지로 @AttributeOverrides를 사용 해서 변경한다
@Entity @Embeddable public class ShippingInfo { @Embedded @AttributeOverrides({ @AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zipcode")), @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; }
Java
복사
루트 엔티티인 Order 클래스는 @Embedded를 이용해서 밸류 타입 프로퍼티를 설정한다.
@Entity public class Order { ... @Embedded private Orderer orderer; @Embedded private ShippingInfo shippingInfo; }
Java
복사
기본생성자
JPA에서 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야한다.
DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문이다. 하지만 기술적인 제약으로 Receiver와 같은 불변 타입은 기본생성자가 필요 없음에도 불구하고 다음과 같이 추가해야된다. → 기본 생성자를 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들기 때문에 Protected로 선언하여 사용한다.
@Embeddable public class Recevier { @Column(name = "receiver_name") private String name; @Column(name = "receiver_phone") private String phone; protected Recevier() {} public Receiver(String name, String phone) { this.name = name; this.phone = phone; } }
Java
복사
AttributeConvert를 이용한 밸류 매핑 처리
int, long, String, LocalDateTime와 같은 타입은 DB테이블의 한 개 컬럼에 맾이된다. 이와 비슷하게 밸류 타입의 프로퍼티를 한 개 칼럼에 매핑해야 할 때도 있다.
public class Length { private int value; private String unit; }
Java
복사
두 개 이상의 프로퍼티를 가지 밸류 타입을 한 개 칼럼에 매핑하려면 @Embeddable 애너테이션으로는 처리 할 수 없다. 이럴 때 사용할 수 있는 것이 AttributeConvert이다.
public interface AttributeConvert<X, Y> { public Y ConvertToDatabasesColumn(X attribute); public X ConvertToEntityColumn(Y dbDate); }
Java
복사
ConvertToDatabasesColumn() 메서드는 밸류 타입을 DB 컬럼 값으로 변환하는 기능
ConvertToEntityColumn() 메서드는 DB컬럼 값을 밸류 타입으로 변환하는 기능 아래와 같이 구현 할 수 있다.
@Converter(autoApply = true) public class MoneyConverter implements AttributeConvert<Money, Intenger> { @Override public Integer ConvertToDatabasesColumn(Money money) { return money == null ? null : money.getValue(); } @Override public Money ConvertToDatabasesColumn(Integer value) { return value == null ? null : new Money(value); } }
Java
복사
AttributeConvert 인터페이스를 구현한 클래스는 @Converter 애너테이션을 적용하단. 08행에서 @Converter 애너테이션의 autoApply 속성값을 보자. 이속성을 ture로 지정하면 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다.
@Entity @Table(name = "purchase_order") public class Order { ... @Column(name = "total_amounts") private Money totalAmounts; // MoneyConvert를 적용해서 값 변환 }
Java
복사
@Converter의 autoApply 속성을 false로 지정하면 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다.
public class Order { @Column(name = "total_amounts") @Convert(converter = MoneyConverter.class) private Money totalAmounts; // MoneyConvert를 적용해서 값 변환 }
Java
복사
밸류 컬렉션 : 별도 테이블 매핑
벨류 컬렉션을 별도 테이블로 매핑
밸류 컬렉션을 별도 테이블로 매핑 할때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
@Entity @Table(name = "purchase_order") public class Order{ @EmbeddedId private OrderNo number; ... @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "order_line", joinColumn = @JoinColumn(name = "order_number")) @OrderColumn(name = "line_idx") private List<OrderLine> orderLines; }
Java
복사
@Embeddable public class OrderLine { @Embedded private ProductId productId; @Column(name = "price") pricate Money price; @Column(name = "quantity") pricate Quantity quantity; @Column(name = "amonuts") pricate Money money; }
Java
복사
OrderLine의 매핑을 함께 표시했는데 OrderLine에는 List의 인덱스 값을 저장하기 위한 프로퍼티가 존재 하지 않는다. 그 이유는 List 타입 자체가 인덱스를 갖고 있기 때문이다.
예제 코드에서는 외부키가 한개 인데, 두 개 이상인 경우 @JoinColumn의 배열을 이용해서 외부키 목록을 저장한다.
밸류 컬렉션 : 한 개 칼럼 매핑
밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다. 예를 들어 도메인 모델에는 이메일 주소 목록을 set으로 보관하고 DB에는 한 개 컬럼에 콤마로 구분해서 자장 할 때가 있다. 이때 @AttributeConvert를 사용한다.
단, AttributeConvert를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.
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); } }
Java
복사
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); } }
Java
복사
이제 남은 것은 EmailSet 타입 프로퍼티가 Convert로 EmailSetConverter를 사용하도록 저장하는 것.
@Column(name = "email") @Convert(convert = EmailSetConverter.class) private EmailSet emailSet;
Java
복사
밸류를 이용한 ID 매핑
지금 까지 살펴본 예제에서 OderNo, MemberId등이 식별자를 표현하기 위해 사용한 밸류이다.
밸류 타입을 식별자로 맵핑하려면 @Id 대신 @EmbeddedId 애너테이션을 사용한다.
@Entitty @Table(name = "purchase_order") public class Order { @EmbeddedId private OrderNo orderNo; ... } @Embeddable public class OrderNo Implements Serializable { @Column(name = "order_number") private String number; ... }
Java
복사
JPA 에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다.
밸류 타입으로 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다는 점이다.
예를 들어 1세대 시스템의 주문번화와 2세대 시스템의 주문번호를 구분할 때 주문번호의 첫 글자를 이용할 경우, 다음 과 같이 OrderNo클래스에 시스템 세대를 구분할 수 있는 기능을 구현할 수 있다.
@Embeddable public class OrderNo Implements Serializable { @Column(name = "order_number") private String number; public boolean is2ndGeneration() { return number.startsWith("N"); } }
Java
복사
if (order.getNumber().is2ndGeneration()) { ... }
Java
복사
별도 테이블에 저장하는 밸류 매핑 (️)
애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해 봐야 한다. 단지 별도 테이블에 데이터를 저장한다고 해서 엔티티인 것은 아니다.
주문 애그리거트도 OrderLine을 별도 테이블에 저장히자만 OrderLine 자체는 엔티티가 아니라 밸류이다.
밸류가 아니라 엔티티가 확실하다면 다른 애그리터는 아닌지 학인해야 한다. 특히 자신만의 라이프 사이클을 갖는다면 구분되는 애그리거트일 가능성이 높다.
밸류를 엔티티로 잘못 매핑한 예
ArticleContent를 엔티티로 생각할 수 있지만 ArticleClontent는 Article의 내용을 담고 있는 밸류로 생각하는 것이 맞다. ARTICLE_CONTENT의 ID는 식별자이긴 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블과 데이터를 연결하기 위함이지 ARTICLE_CONTENT를 위한 별도 식별자가 필요하기 때문은 아니다.
ArticleContent를 밸류로 보고 접근하면 그림은 아래와 같다.
ArticleContent는 밸류이므로 @Embeddable로 매핑한다. ArticleContent와 캐핑되는 테이블은 Article과 매핑되는 테이블과 다르다. 이때 밸류를 매핑 한테이블을 지정하기 위해 @SecndaryTable과 @AttrbuteOverride을 사용한다.
@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;
Java
복사
@SecondaryTable의 name 속성은 밸류를 저장할 테이블을 지정한다. pkJoinColumns속성 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다. content 필드에 @AttributeOverride를 적용했는데 이 애너테이션을 사용해서 해당 밸류 데이터가 저장된테이블 이름을 지정한다.
// @SecondaryTable로 매팽된 article_content 테이블을 조인 Article article = entityManager.find(Article.class, 1L);
Java
복사
게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지 article_content 테이블의 데이터는 필요하지 않다. 그런데 @SecondaryTable을 사용하면 목록 화면에 보여줄 Article을 조회할 때 article_content테이블 까지 조인해서 데이터를 읽어 온다.
이 문제를 해소하고자 article_content를 엔티티로 매핑하고 Article에서 ArticleContent로의 로딩을 지연 로딩 방식으로 설정할 수도 있다. 하지만 이방식은 밸류인 모델을 엔티티로 만드는 것이므로 좋은 방법이 아니다.
밸류 컬렉션 @Entity로 매핑하기
개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
제품의 이미지 업로드 바식에 따라 이미지 경로와 섬네일 이미지 제공 여부가 달라진다고 하자.
JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다. 상속구조를 갖기 위해서는 @Entitiy를 이용해야한다.
식별자 매핑을 위한 필드도 추가해야된다. 또한 구현 클래스를 구분하기 위한 타입 칼럼을 추가해야된다.
한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용
@Inheritance 애너테이션 사용
strategy 값으로 SINGLE_TABLE 사용
@DiscriminatorColumn
사용방법
@Inheritance 사용방법
@Inheritance 예제
@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(); }
Java
복사
Image를 상속받는 클래스는 @Entity와 @Discriminator를 사용해서 맵핑한다.
@Entity @DiscriminatorValue("II") public class InternalImage extends Image { ... } @Entity @DiscriminatorValue("EI") public class ExternalImage extends Image { ... }
Java
복사
️Image가 @Entity이므로 목록을 담고 있는 Product는 다음과 같이 @OneToMany를 이용해서 매핑을 처리한다. Image는 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product에 완전히 의존한다. 따라서 Product를 저장할 때 함께 저장되고 Product를 삭제할 때 함께 삭제되도록 casecade 속성을 지정한다. 리스트에서 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); } }
Java
복사
이미지 교체를 위해 chageImages의 images.clear();를 사용중이다. @Entity에 대한 @OneToMany매핑에서 컬렉션의 clear()메서드를 호출하면 삭제 과정이 효율적이지 않다. → select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행한다. 하지만 @Embeddable 타입에 대한 컬렉션 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다. 따라서 애그리거트의 특성을 유지하면서 이문제를 해결하려면 결국 상속을 포기하고 @Emabeddable로 매핑된 단일 클래스로 구현해야된다.
@Embeddable public class Image { @Column(name = "image_type") private String imageTypes; @Column(name = "image_path") private String path; @Temporal(TemporalType.TIMESTAMP) @Column(name = "upload_time") private Date uploadTime; ... public boolean hasThumbnail() { // 성능을 위해 다형을 포기하고 if - else 로 구현 if (imageType.equal("II")) { return true; } else { return false; } } }
Java
복사
애그리거트 로딩 전략
JPA매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
// product는 완전한 하나여야 한다. Product product = productRepository.findById(id);
Java
복사
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시로딩으로 설정하면 된다.
@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<>();
Java
복사
즉시 로딩 방식으로 설정하면 애그리거트 루트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있는 장점이 있지만, 항상 좋은 것은 아니다. 특히 컬렉션에 대해 로딩 전략을 EAGER로 설정하면 오히려 문제가된다.
예를 들어 Product 애그리거트 루트가 @Entity로 구현한 Image와 @Embeddable로 구현한 Option 목록을 갖고 있다고 해보자.
@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<>(); @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true, fetch = FetchType.EAGER) @JoinColumn(name = "product_id") @OrderColumn(name = "list_idx") private List<Option> Options = new ArrayList<>(); ... }
Java
복사
Product를 조회하게 되었을때, EntityManager#find() 메서드로 Product를 조회하면 하이버네이트는 다음과 같이 Product를 위한 테이블 Image, Option을 위한 테이블 조인 쿼리를 실행
select p.product_id, ... ,... from product p left outer join image img on p.product = img.product_id left outer join product_option opt on p.product_id = opt.product_id where p.product_id = ?
SQL
복사
이 쿼리는 카타시안Cartesian 조인을 사용하고 이는 쿼리 결과에 중복을 발생시킨다.
카타시안
물론 하이버네이트가 중복된 데이터를 알맞게 제거해서 실제 메모리에는 1개의 Product 객체, 2개의 Image객체, 2개의 Option 객체로 변환해 주지만 애그리거트가 커지면 문제가 될 수 있다.
@Transaction public void removeOptions(ProductId id, int optIdxToBeDelete) { //Product를 로딩 - 컬렉션은 지연 로딩으로 설정했다면, Option은 로딩하지 않음 Product product = productRepository.findById(id); // 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능 product.removeOption(optIdxToBeDelete); }
Java
복사
일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다. 그러므로 상태 변경을 위해 지연로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.
애그리거트의 영속성 전파
애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미한다.
저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다.
삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다.
@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다. 반면에 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제 시에 함께 처리되도록 설정해야 한다.
@OneToOne, @OneToMany는 cascade 속성의 기본값이 없으므로 다음 코드처럼 cascade 속성값으로 CascadeType.PERSIST, CasecadeType.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<>();
Java
복사
식별자 생성 기능
식별자는 크게 세 가지 방식 중 하나로 생성한다.
사용자가 직접 생성
도메인 로직으로 생성
DB를 이용한 일련번호 사용
사용자가 직접 생성하는 방법
public class ProductIdService { public ProductId nextId() { .. // 정해진 규칙으로 식별자 생성 } }
Java
복사
응용 서비스
public class CreateProductService { @Autowired private ProductIdService idSevice; @Autowired private ProudctRepository productRepository; @Transaction public ProuductId createProduct(ProductCreationCommand cmd) { // 응용 서비스 도메인 서비스를 이용해서 식별자를 생성 ProductId id = productIdService.nextId(); Product product = new Product(id, cmd.getDetail(), cmd.getPrice(), ...); productRepository.save(product); return id; } }
Java
복사
도메인 서비스를 이용해 식별자 생성 방법
public class OrderIdService { public OrderId createId(UserId userId) { if (userId == null) { throw new IllegalArgumentException("invalid userid:" + userId); return new OrderId(userId.toString() + "-" + timestamp()); } } private String timeStamp() { return Long.toString(System.currentTimeMillis()); } }
Java
복사
DB를 이용한 일련번호 사용
@Entity @Table(name = "article") public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
Java
복사