Java & Spring / / 2023. 2. 21. 11:18

[DDD] 도메인 주도 개발 - (3) 리포지토리 & 모델 구현

728x90
[DDD] 도메인 주도 개발 - (1) 좋은 아키텍처와 도메인 주도 설계
[DDD] 도메인 주도 개발 - (2) 애그리거트
[DDD] 도메인 주도 개발 - (3) Repositoy 모델 구현
[DDD] 도메인 주도 개발 - (4) CQRS - 조회 중심 도메인 구현 
[DDD] 도메인 주도 개발 - (5) 도메인 서비스
[DDD] 도메인 주도 개발 - (6) 응용 서비스와 표현 영역
[DDD] 도메인 주도 개발 - (7) 애그리거트 트랜잭션 관리
[DDD] 도메인 주도 개발 - (8) 도메인 모델과 BOUNDED CONTEXT
[DDD] 도메인 주도 개발 - (9) 이벤트

 


 

 

사실 jpa를 이용하고 있었다면

DDD개념을 어느정도 사용하고 있었던 것과 같다

 

1. 리포지토리 기본 기능

리포지토리는 기본 조회, 저장(수정, 등록) 기능 + 삭제, 검색 등을 추가로 구현하면 된다.

findById()
save()

 

 

상속 구현

Checkout(도서 대출)에 대한 Checkout 리포지토리를 만든다고 하면

interface checkoutRepository를 먼저 만든 후, 구현 기술에 따라 상속받아 구현하면 된다

public interface CheckoutRepository{
   findById(Long id);
   save(Checkout checkout);
}
@Repository 
public class JpaCheckoutRepository extends CheckoutRepository{
	
    @PersistenceContext
	private EntityManager entityManager;
	
	@Override
	public Checkout findById(Long id){...};
    
	@Overrrice
	public void save(Checkout checkout){...};
}

JPA를 사용한다면 해당 기술에 맞게 구현해주면 된다.

그러나 만약 JPA만 사용한다고하면, 굳이 따로 인터페이스를 만들지 않고, 해당 소스처럼 많이 쓰고 있을 것이다.

public interface CheckoutRepository extends JpaRepository<Checkout, Long> {

}

상태가 변하면 자동으로 반영되므로, 별도로 저장소에 반영하기 위한 save()를 할 피요를 없다.

 

 

 

2, 모델 맵핑 구현

도메인 모델을 만들 때, 그 구조를 이해하고 어떤 식으로 맵핑할지도 정해야한다.

 

1) 기본틀

모델을 만드는 기본 방식은 Entity와 Value를 구분하는 것이다.

 

(1) 애그리거트 루트가 되는 것에는 @Entiy를 붙여주고
(2) Value에 @Embeddable 붙여주기
   - Value에는 @Embeddable을, 루트에 Value 타입 프로퍼티를 넣었을 때는 @Enbedded를 붙여준다.
(3) (만약 컬럼명과 변수명이 다르면) 
   @AttributeOverride(name="변수명", column = @Column(name = "컬럼명"))을 달아준다
(4) 식별자는 기본 자료형보다는 특정 class화 시킨다
(5) Value는 중첩도 가능하다.
    @Embeddable을 단 class 안에 다시 Value가 @Embedded 되도 된다.

 

말로 보면 복잡해 보이는데 실제로 해보면 간단하고 제일 중요하다.

만들어볼 모델은 도서관 대출 모델이다. 필요한 정보들을 나열해보면,

 

대출번호, 대출자 이름, 대출자 전화번호, 책이름, 책 저자, 출판사, 연장횟수, 반납자, 반납일자, 반납 장소, 반납 여부

등이 있다.

 

속성들을 묶어보면 이렇다. 

- 대출정보 : 대출번호, 그 외(연장횟수, 반납예정일자)
- 대출자 : 이름, 전화번호 
- 도서정보 : 도서명, 저자, 출판사
- 반납정보 : 반납일자, 반납장소

 

대출자는 대출 모델에서는 대출자지만, 다른 애그리거트인 회원 정보 User이다.

책도 마찬가지로 다른 애그리거트인 Book이다. 

@Entity
public class User{
    @Id
    private UserId userId;  // 회원 식별자 
    private String name;    // 회원 이름 
    private Address address;    // 회원 주소
}
@Entity
public class Book{
    @Id
    private BookId bookid; 		// 도서식별자
    private String bookName;	// 책명
    private String author;	// 저자 
    private String publisher; // 출판사
    private localTime publishedDate; // 발간일
}

이를 토대로 보면, 대출정보, User, 도서정보는 각 식별자를 가진  Entity임을 알 수 있다.

 

(1) Entity 객체인 Checkout에 @Entity 어노테이션을 달아준다.

@Entity
public class Checkout{
    @Id
    private Long checkoutId; // 대출 식별자 번호
    @ManyToOne
    private User user;	// 회원정보
    @ManyToOne		
    private Book book;	// 도서정보
    private ReturnInfo returnInfo;	// 반납정보
    private int reCheckoutCount; 	// 연장 횟수
    private LocalDate originalReturnDate; // 반납예정일자
}

- User와 Book은 다른 애그리거트 루트로 다른 테이블로 분리되어있기 때문에 연관관계를 표시해주어야하는데 

우선은 @ManyToOne으로 표시해준다.

- 따로 묶일 필요가 없는 연장횟수(reCheckoutCount), 반납예정일자(OriginalReturnDate)는 일반 필드로 둔다.

 

(2) Value에는 @Embeddable을 붙여주기

- 여러 반납 정보(반잡일자, 반납장소, 반납자)들을 하나의 반납 정보(ReturnInfo)를 묶은 이유는 해당 정보들이 모두 있어야지만 반납 여부가 확정되기 때문이다.

- 또, 반납 정보는 대출 정보에서만 필요하고 다른 애그리거트와 함께 쓰이는 일은 드물어서 같은 테이블 안에 있다. 

 

이런 경우에 반납정보가 Value가 된다.  Value에 @Embeddable을 붙여준다.

@Embeddable
public class ReturnInfo {
    private LocalDate date;  // 반납일자
    private Location location; // 반납장소
}

Checkout에서도 ReturnInfor가 Value임을 알려줘야 하는데, 이때는 @Embedded를 쓴다.

@Entity
public class Checkout{
...
    @Embedded
    private ReturnInfo returnInfo;	// 반납정보
    private int reCheckoutCount; 	// 연장 횟수
    private LocalDate originalReturnDate; // 반납예정일자
}

 

(3) 만약 반납정보(ReturnInfo)의 실제 칼럼명이 다르다면,

   @AttributeOverrides나 @AttributeOverride를 써서 가리키는 컬럼명을 알려줄 수 있다.

@Entity
public class Checkout{
...
    @Embedded
    @AttributeOverride(name="date", column = @Column(name = "return_date"))
    @AttributeOverride(name="location", column = @Column(name = "return_location"))
    private ReturnInfo returnInfo;	// 반납정보
...
}

 

(4) 식별자는 기본 자료형보다는 특정 class화 시킨다.

현재 CheckoutId는 그냥 Long으로 되어있는데, 그냥 Long으로 쓰면 의미가 명확하지 않다.

따라서 CheckoutId로 별도 객체를 만들어서, 식별자를 명확하게 표기해준다.

public class CheckoutId{
    private final Long id; 
    pbulic CheckoutId(Long id){this.id = id;}
}
@Entity
public class Checkout{
    @Id
    private CheckoutId checkoutId;	// 대출 식별자 번호
...
}

이때 @EmbeddedId를 쓸 수도 있다.

주의 할 점으로

- 식별자는 Serializable 타입이어야 한다. (상속)

이렇게 하면 식별자에 기능 추가가 가능하다. (앞자리에 따라 세대 구분히난 방식 등)

- JPA는 equal, hashcode 값을 사용하므로, 두 메서드에 알맞게 구현이 필요하다.

 

(5) Value는 중첩도 가능하다.

  ReturnInfo의 반납장소를 Location 객체로 두었는데, 만약 도서관이 아닌 외부에서도 반납이 가능할 수 있다.

  이 때 도서관 반납장소와, 외부반납장소를 다른 테이블로 관리한다면 이를 위치코드화 하고

  해당 테이블의 id를 저장해서 필요할 때 해당 테이블과 조인하면 된다. 

public enum LocationCode {
    LIBRARY, EXTERNAL; 
}

 

@Embeddable
public class Location {
    private Long locationId; // 해당 id
    private LocationCode locationCode;  // 위치 코드 (도서반납장소 or 외부반납장소) 
}

이렇게 하면 아래처럼 Value들이 중첩된 것을 볼 수 있다.

@Embeddable
public class ReturnInfo {
    private LocalDate locationDate;  // 반납일자
    @Embedded
    private Location location; // 반납장소
}

 

기본 완성한 도서대출 모델은 아래와 같다.

@Entity
public class Checkout{
    @Id
    private CheckoutId checkoutId;	// 대출 식별자 번호
    private CheckoutState state; // 대출 상태
    @ManyToOne
    private User user;	// 회원정보
    @ManyToOne
    private Book book;	// 도서정보
    @Embedded
    private ReturnInfo returnInfo;	// 반납정보
    private int reCheckoutCount; 	// 연장 횟수
    private LocalDate originalReturnDate; // 반납예정일자
}

 

2) 기본 생성자

기본 생성자가 필요없더라도 JPA를 적용하려면 꼭 기본 생성자가 필요하다. (기술적 제약)

JPA 프로바이더가 객체를 생성할 때만 사용되는데,

따라서 다른 소스에서 못 쓰도록 protected로 선언한다.

@Entity
public class Checkout{
...
    protected CheckOut(){}
}

 

그러면 왜 private이 아닐까?

하이버네이트가 프록시객체를 상속받아서 지연로딩을 하기 때문이다.

프록시 클래스의 상위기본생성자를 호출해야하는데, 지연로딩 대상인 @Entity, @Embeddable이 기본생성자를 protected로 지정해놓았기 때문에 적어도 protected 이상으로 써야한다.

 

3) 접근 방식

메서드접근방식과 필드접근방식이 있는데, 필드접근방식을 사용하기를 추천한다.

 

메서드 접근방식

메서드 접근방식은 기존처럼 get/set메서드명 형식의 getter, setter를 구현해서 사용하던 방식이다.

데이터 기반 엔티티를 구현할 때 사용하며, 외부에서도 수정이 가능해 캡슐화가 어렵다.

클래스명 위에 @Access(AccessType.PROPERTY)로 표시해준다.

@Entity
@Access(AccessType.PROPERTY)
public class Checkout{
    private CheckoutState state; // 대출 상태

    @Column(name="state"
    @Enumerated(EnumType.String)
    public CheckoutState getState(){
    	return state;
    }
    
    public void setState(CheckoutState state){
    	this.state = state;
    }
}

기존해도 말한 바와 같이, setter는 그 의미가 불분명하고 실제 해당 값이 모두 채워진 것인지 알기 어렵기 때문에

-> complete~(), cancel(), change~() 등의 기능명이 들어나는 메소드 명을 쓰는 것이 좋다.

 

 

필드 접근방식

필드 접근 방식은 불필요한 getter, setter 구현을 지양한다.

방법은 간단한다.

@Access(AccessType.PROPERTY) -> @Access(AccessType.FIELD)로 표기해주면 된다.

@Entity
@Access(AccessType.FIELD)
public class Checkout{
    private CheckoutState state; // 대출 상태

    public void returnBooks(Checkout checkout){	// 반납
    	...
    }
    
    public Checkout expandCheckout(Checkout checkout){ // 연장
    	...
    }
}

구현시에 의미가 명확하도록 메서드를 구현한다.

 

만약 @Accesss를 지정하지 않으면, @Id, @EmbeddedId의 위치로 결정된다.

필드에 위치하면 필드 접근 방식이고, getter 메서드에 위치하면 메서드 접근방식 이다.

 

 

4) AttributeConverter

두 개 이상의 객체를 한 column에 저장될 때 사용 한다.

예를 들어, x, y, z로 구성된 좌표 객체가 있다.

public class coordinate{
   Double x; 
   Double y;
   Double z;
}

해당 컬럼을 각각 x, y, z 컬럼으로 DB에 저장하는 것이 아니라,

'x|y|z' 이렇게 한 값으로 구분자를 넣어서 coordinate 컬럼에 저장할 수도 있다.

 

이 경우에는 @Embeddable(각각 저장)으로 처리할 수 없고, 기존 getter, setter 방식으로 변환해서 처리해야한다. 

AttributeConverter를 상속을 받아서, 해당 변환 처리를 구현해준다. 

public class CoordinateConverter implements AttibuteConverter<Coordinate, String>{
    @Override
    public String convertToDatabaseColumn(Coordinate attribute) {
        if (attribute == null) return null;
        return attribute.getValue();
    }

    @Override
    public AltCode convertToEntityAttribute(String dbData) {
        if(dbData == null || dbData.isEmpty()) return null;
        return new Coordinate(dbData);
    }

}

위와 같은 컨버터를 만들고,  이를 사용하는 Entity에서 @Convert를 달아준다.

public class library { 
    ...
    @Convert(converter = CoordinateConverter.class)
    public String coordinate;
    ...
}

@Convert(autoApply = true)는 모델에 출현하는 모든 해당 타입의 프로퍼티에 대해 자동으로 적용한다는 뜻이다.

false인 경우나 어노테이션이 없으면, 사용할 컨버터를 위처럼 직접 적용해야한다.

 

5)  Value collection

컬렉션(List, set) 을 저장할 때는 별도 테이블의 각 row로 저장하거나, 한 테이블의 하나의 컬럼에 저장하는 방법이 있다.

public class Checkout {
    public CheckoutId checkoutId;
    public List<CheckoutDetails> details; 
}
public class CheckoutDetails {
    public CheckoutId checkoutId;
    public BookId bookId; 
}

 

(1) 별도 테이블 맵핑

@ElementCollection과 @CollectionTable을 함께 사용한다.

@Entity
@Table(name="checkout")
public class Checkout {
    public CheckoutId checkoutId;
    
    @ElementCollection 
    @CollectionTable(name="checkout_details", joinColumns=@JoinColumn(name="checkout"))
    @OrderColumn(name="checkout_details_id")
    public List<CheckoutDetails> details; 
}
@Embeddable
public class CheckoutDetails {

    @Embedded
    public CheckoutId checkoutId;
    
    @Colmun(name="book_id")
    public BookId bookId; 
    
    ...
}

@CollectionTable은 밸류를 저장할 테이블을 지정시에 사용한다.

- name : 테이블 속성

- joinColums : 외부키 지정,  배열로 외부키가 2개 이상인 경우도 가능하다.

② CheckoutDetails에는 id가 없는 이유는 List가 인덱스를 가지기 때문이다.

따라서 @OrderColumn으로 해당 테이블의 id를 지정해준다.

 

 

(2)  Value collection - 한개 컬럼에 맵핑

여러 개의 리스트를 구분자(,)로 이어서 하나의 컬럼에 저장할 수도 있다.

4)번의 AttributeConverter를 쓴 것 처럼 하면 된다. 이때는 새로운 value 타입을 추가해야한다.

public class ImageSet {
    public List<Image> images; 
}
public class ImageSetConverter implements AttibuteConverter<ImageSet, String>{
    @Override
    public String convertToDatabaseColumn(ImageSet attribute) {
        if (attribute == null) return null;
        return attribute.getImages.strea().map(Image::toString).collect(Collectors.joining(","));
    }

    @Override
    public ImageSet convertToEntityAttribute(String dbData) {
        if(dbData == null || dbData.isEmpty()) return null;
        String[] images = dbData.split(",");
        Set<Image> imageSet = Arrays.stream(images).map(value -> new Image(value)).collect(toSet();
        return new ImageSet(imageSet);
    }

}

이후에 똑같이 ImageSet에 컨버터를 지정한다.

public class Checkout { 
    ...
    @Convert(converter = ImageSetConverter.class)
    public ImageSet imageSet;
    ...
}

 

6별도 테이블에 저장 

일반적으로 루트만 Entity이고 루트 외의 요소는 Value가 된다.

만약 루트 외의 다른 Entity가 있다면 일반적으로는 다른 애그리거트이다.

 

이 때 Entity와 Value가 다른 테이블에 별도 저장될 수 있다.

public class Board {
    public Long id; 
    public String title; 
    @AttributeOverride
    public BoardContent boardContent;
}
@SecondaryTable
public class BoardContent {
    public String Content; 
    public ContentType ContentType; 
}

만약 같은 테이블 안에 있는 value라면 @Embeddable, @Embbeded 어노테이션을 썼겠지만,

Entity와 Value가 다른 테이블에 있어서 이를 매번 조인해야하는 경우는 @SecondaryTable, @AttributeOveride를 쓴다.

 

@SecondaryTable : 두 테이블을 무조건 조인해서 조회한다.

BoardContent는 엔티티에 맵핑되고, Board는 지연로딩으로 작동한다.

name : 밸류 저장할 테이블 지정

pkjoinColums : 밸류 테이블에서 엔티티 테이블로 조인 시 사용할 컬럼

 

@AttributeOveride :

name : 컬럼명

column = @Column(Table=테이블명)

 

 

cf) Entity와 Value 구분은 간단하게는 Id로 구분할 수 있지만,

테이블 식별자(PK)와는 구분해야한다. 

아래는 Board와 BoardContent 테이블이 분리되어 있는 경우이다.

public class Board {
    public Long id; 
    public String title; 
    public BoardContent boardContent;
}
public class BoardContent {
    public Long id; 	// pk
    public Long boardId; 	// Board와 조인을 위해 필요한 id 	
    public String Content; 
    public BoardType boardType; 
}

이떄 BoardContent의 boardId는 Entity의 Id가 아니라 단순히 BoardContent 테이블의 pk이므로

BoardContent를 Entity로 파악하면 안 된다.

그리고 이런 경우는 Board와 BoardContent가 1:n인 경우가 많기 때문에, Board에서도 List 컬렉션으로 구성해주어야한다.

 

7Value를 엔티티로 저장

이 경우는 위에서 살펴본 것처럼, Entity와 Value가 테이블로 분리되었는데,

Value를 id를 가진 엔티티로 만들어야 하는 경우이다.

예외적인 경우이지만 팀 표준인 경우나 기존 테이블을 써야하는 경우가 있을 수 있다.

 

예를 들어

Book에 이미지가 있는데, 이미지 타입에 따라 이미지가 저장된 테이블이 다를 수 있다.

public class book {
    private BookId bookId; 
    ...
    private ImageType imageType; 
    private List<Image> image; 
}
public class InternalImage {
    private ImageId imageId; 
    ...
}
public class ExternalImage {
    private ImageId imageId; 
    ...
}

이미지 타입에 따라 조인하는 테이블이 달라지는 경우는 @Embeddable로 처리가 어렵다.

 

이 때는 @Entity로 각각 Image테이블을 Entity로 만드는데, 이를 abstract class로 만들어서 상속을 시킨다. 

@Entity로 만들기는 했지만, 실제는 value이기 때문에 상태 변경등에 대한 기능 메서드는 추가하지 않는다.

@Inheritance(Strategy = SingleTable) 
@DiscriminatorColumn(name=imageType)
public abstract class Image {
    private ImageId imageId; 
    ...
}

 

@DiscriminatorColumn

- name : 타입 구분 용도의 컬럼이다.

@Entity
@DiscriminatorValue("II") // II : Internal Image
public class InternalIamge extends Image {
    private ImageId imageId; 
    ...
}

@DiscriminatorValue에서 구분할 ImageType을 적어준다.

이때 InternalIamge는 엔티티이기 때문에 Book에서 OnetoMany로 맵핑된다.

 

 

cf) 이 때 만약 Image 컬렉션을 비우기 위해서 clear() 해야하는 경우가 있을 수 있다.

@Embeddable의 경우는 clear()로 한번에 삭제를 할 수 있는데,

@Entity에 대한 OnetoMany 맵핑은 select로 조회후 각각 delete를 하게 되어 비효율적이다.

 

따라서 이런 경우에는 abstract class 상속을 포기하고, @Embeddable 단일클래스로 구현한 후,

타입에 따라서 if-else로 구분해서 조인하도록 해야한다.

성능과 유지보수를 고려해서 구현 방식을 결정한다.

 

 

8M:N 단방향 맵핑

M:N 맵핑(집합연관)은 가능한한 피하면 좋지만, 필요하다면, 단방향 ID 참조를 한다.

private set<CategoryIds> categoryIds;

방식은 동일하나, 대신 식별자 CategoryIds로 연관한다.

 

@ElementCollection : 상위 데이터 삭제 시, 조인된 데이터도 삭제된다.

직접 참조가 아니므로 영속성 전파나 로딩 전략을 고민할 필요가 없다.

 

 

도메인 식별자 생성

ID(identification) 도메인 모델을 만들 때, 그 구조를 이해하고 어떤 식으로 맵핑할지도 정해야한다.

방식은 3가지가 있다.

1) 사용자 직접 생성
2) 도메인 로직으로 생성
3) DB 일련번호 (Auto increment) 로 생성

 

1) 사용자 직접 생성

이 경우는 사용자가 직접 ID를 입력하는 방식이다.  주로 이메일, 아이디 같은 고유값을 사용한다. 

사용자가 직접 입력한 값을 식별자로 사용하면 된다.

 

2) 도메인 로직으로 생성

특정 값을 조합하는 방식으로 쓸 수 있다.

예를 들어, 대출번호를 회원ID+시간의 형태로 할 수 있다.

이 경우에는 도메인 영역에서도 식별자를 생성하는 기능을 구현해야한다.

 

(1) 서비스로 구현

public class IdService{
	public String createId(User user){
    }
}

IdService에서 Id를 생성하는 기능을 구현한 후에, checkoutService(대출 서비스)에서 해당 메소드를 호출하는 방법이 있다. 

 

(2) Repository로 구현

public interface checkoutRepository {
	checkoutId nextId(); 
}

인터페이스를 만들어서, 상송박은 구현 클래스에서 구현하는 방식이다.

 

3) DB 일련번호 (Auto increment)로 생성

mySql의 autoIncrement와 같이 자동생성되는 값을 식별자로 쓰기 때문에

DB에 값이 들어가기 전에는 식별자를 알 수가 없다.

DB에 행이 추가되면, 식별자가 반환되는 형태이다.

 

 

 

애그리거트 로딩 전략

즉시로딩과 지연로딩 선택은 애그리거트에 맞게 선택한다.

 

중요한 것은 로딩시 애그리거트는 루트에 속한 모든 객체가 완전한 상태여야 한다는 것이다. 

 

이 때 즉시로딩은 완전한 상태를 나타내기 좋다.

1. 상태 변경 시, 모든 상태가 완전하고

2. 표현 영역에서 상태정보를 보여줄 때, 모든 조인 값을 보여주기 때문에 좋다.

   (별도 조회 기능 실행 시 중요)

 

그러나  카타시아 조인으로 성능이 저하 될 수 있다는 점이다.

한 애그리거트를 조인 시, 테이블이 3개 조인되면, 쿼리 결과가 중복될 수 있다.

하이버네이트는 중복 데이터를 제거해서 반환하지만, 크기가 크면 성능이 떨어질 수 있다.

 

이때는 지연로딩을 해도 좋다.

1. 트랜잭션 범위 내에서 상태변경 시, 지연 로딩으로 설정한 연관 로딩이 가능하다

2. 상태 변화시에만 지연로딩을 써도 괜찮다. (추가 쿼리는 성능에 문제 없음)

3. @Entity, @Embeddable 모두 동작 방식이 같고, 구현 방식이 같으므로 크게 신경 쓸 필요가 없다.

 

 

영속성 전파

완전히 영속성이 전파되면, 조회, 저장 및 삭제(루트 + 속한 모든 객체)가 모두 하나로 처리된다.

 

@Embeddable은 함께 저장되고 삭제되며 (cascade 속성 삭제 추가하지 않아도)

@Entity는 cascade 속성으로 설정된다.

 

 

 

 

 

 

### 출처 :
'DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기(최범균)'

http://www.yes24.com/Product/Goods/27750871

 

 

300x250
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유