[DDD] 도메인 주도 개발 - (1) 좋은 아키텍처와 도메인 주도 설계 [DDD] 도메인 주도 개발 - (2) 애그리거트 [DDD] 도메인 주도 개발 - (3) Repositoy 모델 구현 [DDD] 도메인 주도 개발 - (4) CQRS - 조회 중심 도메인 구현 [DDD] 도메인 주도 개발 - (5) 도메인 서비스 [DDD] 도메인 주도 개발 - (6) 응용 서비스와 표현 영역 [DDD] 도메인 주도 개발 - (7) 애그리거트 트랜잭션 관리 [DDD] 도메인 주도 개발 - (8) 도메인 모델과 BOUNDED CONTEXT [DDD] 도메인 주도 개발 - (9) 이벤트 |
Contents
사실 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 컬렉션으로 구성해주어야한다.
7) Value를 엔티티로 저장
이 경우는 위에서 살펴본 것처럼, 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로 구분해서 조인하도록 해야한다.
성능과 유지보수를 고려해서 구현 방식을 결정한다.
8) M: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
'Java & Spring' 카테고리의 다른 글
[Java Test] 1. JUnit5 (4) properties, 확장, 마이그레이션 (0) | 2023.05.22 |
---|---|
[Java Test] 1. JUnit5 (3) 테스트 인스턴스 & 순서 지정 (0) | 2023.05.19 |
[Java Test] 1. JUnit5 (2) 테스트 필터링, 테스트 반복 (1) | 2023.05.19 |
[Java Test] 1. JUnit5 (1) (0) | 2023.05.18 |
[DDD] 도메인 주도 개발 - (2) 애그리거트 (0) | 2023.02.21 |
[DDD] 도메인 주도 개발 - (1) 좋은 아키텍처와 도메인 주도 설계 (0) | 2023.01.11 |
[Spring Legacy] 설명 (0) | 2021.01.27 |
[Spring Regacy] 스프링 레가시 프로젝트 만들기 (0) | 2021.01.27 |