-
[방법론] 도메인 주도 개발 시작하기 DDD - 2방법론 2023. 12. 15. 10:26
1. JPA 레포지토리
JPA 맵핑 방법
엔티티는 @Entity로 매핑 설정
밸류는 @Embeddable로 매핑 설정
밸류타입 프로퍼티는 @Embedded로 매핑 설정 한다.
코드 예시엔티티, 밸류타입 설정
// 엔티티 설정 @Entity public class Order { ... // 밸류타입 설정 @Embedded private Orderer orderer; } @Embeddable public clss ShippingInfo { // MemberId에 정의된 칼럼 이름을 변경하기 위해 // @AttributeOerride 어노테이션 사용 @Embedded @AttributeOverrides( @AttribueOverride(name = "id", column = @Column(name = "orderer_id"))) private MemberId memberId; }
JPA에서 엔티티에 데이터 바인딩 방법 지정
@Entity @Access(AccessType.FIELD) // AccessType.PROPERTY, AccessType.FIELD - JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택 (프로퍼티 방식 선택시 jpa가 엔티티 맵핑시 set 메서드 사용) // 만약에 Access 타입 미지정시 @Id나 @EmbeddedId 위치에 따라서 접근 방식 선택 필드에 @Id 위치시 필드 접근 @Id가 get메서드에 위치시 메서드 접근 방식 선택 public class Order { ... @Embedded private Orderer orderer; }
@AttribueConverter 데이터 변환
@AttribueConverter 이용한 밸류 매핑 처리
DB타입과 자바 객체 타입이 다른경우
JPA에서 다음과같이 맵핑하면 DB타입을 직접 변환하지 않고 사용가능@Entity @Table(name = "product") public class Product { @EmbeddedId private ProductId id; private String name; // 변환할 타입 @Convert(converter = MoneyConverter.class) private Money price; }
변환타입 구현
public class MoneyConverter implements AttributeConverter<Money, Integer> { // DB에 삽입시 @Override public Integer convertToDatabaseColumn(Money money) { return money == null ? null : money.getValue(); } // DB에서 가져온 후 객체로 변환 @Override public Money convertToEntityAttribute(Integer value) { return value == null ? null : new Money(value); } }
@Inheritance 엔티티 상속
같은 도메인 모델 ( 같은 테이블 ) 이지만 외부인지 내부 이미지인지에 따라서 가져올 방법 수정
@Inheritance로 클래스 상속 (@Entity는 상속 가능, @Embeddable은 상속 불가능) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 컬럼 지정 public abstract class InternalImage extends Image { public abstract String getUrl(); public abstract boolean hasThumbnail(); public abstract String getThumbnailUrl(); } @Entity @DiscriminatorColumn("II") public class InternalImage extends Image { public abstract String getUrl(){구현}; public abstract boolean hasThumbnail(){구현}; public abstract String getThumbnailUrl(){구현}; } @Entity @DiscriminatorColumn("EI") public class ExternalImage extends Image { public abstract String getUrl(){구현}; public abstract boolean hasThumbnail(){구현}; public abstract String getThumbnailUrl(){구현}; }
2. CQRS
CQRS 정의
CQRS (Command Query Responsibility Segregation)
명령 모델과 조회 모델을 분리하는 패턴이다.
명령 모델은 상태를 변경하는 기능을 구현할 때 사용
조회 모델은 데이터를 조회하는 기능에 사용사용자들이 호출하는 기능은 조회 기능이다. 페이지 진입시 대부분 데이터 로딩 기능인데 JPA는 여러 테이블 정보를 가져올때 지연로딩이 적용되어진 경우 N+1 문제와 함께 성능 문제가 발생한다.
queryDsl, jpql, mybatis, jdbcTemplate 등을 사용하여 조회 모델은 다른 방법을 통해 구현하는 것이 성능상 좋다.
반대로 명령 모델은 애그리거트 형태로 도메인 모델 객체를 관리하는데 JPA가 최적화 되어 있기 때문에 그대로 사용하면된다.
장점
1. 명령 모델 작성 시 도메인 객체 입장에서 작성 가능
2. 명령 모델을 조회 모델과 분리하여 전용 서비스를 만들었기 떄문에 성능, 코드가 명확해진다.
단점
1. 모델을 분리 했기 때문에 코드량이 많아진다.
2. 조회 모델은 캐시를 적용하는 경우가 대부분인데 이를 위한 동기화 작업과 관리가 더 필요하다.
이해한 내용
JPA는 RDB 입장이 아닌 객체지향 모델에 맞게 잘 설계되었고 생성, 수정 부분에서 큰 이점으로 작용한다.
다만 JPA 엔티티 모델을 조회로 사용하기에는 구조상 부족하다고 생각된다.조회 할때 엔티티로 조회하면 N+1, 중복 행으로 쿼리 수, 데이터 양이 예상치 못하게 나오는 경우가 있다.
여러 테이블 조인시는 QueryDsl, Jpql을 사용하여 원하는 형태로 조회를 하도록 분리해야한다.
3. 모듈 구성
책에서의 구성 ( 모듈구성, 패키지 구성은 취향 차이다. )
ui - controller
application - 응용 계층 ( 도메인 모델의 규칙과 기능의 순서를 정하는 계층)
domain - (도메인 모델의 규칙, 기능 정의)
infrastructure - 레포지토리, 외부 의존 관계 설정4. 이벤트
하나의 트랜잭션으로 관리하는 경우 환불 기능을 구현하기 위해서는 결제 취소와 외부 PG사의 환불 서비스가 같이 진행되어야 한다.
하지만 외부 API가 오래걸리는 작업이라면 스레드와 트랜잭션을 들고 있는 시간이 상당하며 서버의 부하를 일으킬 수 있다.
이러한 문제를 이벤트를 통해 해결할 수 있다.
이벤트는 퍼블리셔(디스패처)와 구독자(이벤트 핸들러)가 있다.
이벤트 또한 동기적으로 처리시 외부 API 시간에 맞춰서 서버의 부하가 결정되기 때문에 비동기 방식으로 진행되는게 권장된다.
이벤트 디스패처 코드 예제
@Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { public void cancel() { verifyNotYetShipped(); // 도메인 상태 변경 this.state = OrderState.CANCELED; // 환불 이벤트 발행 Events.raise(new OrderCanceledEvent(number.getNumber())); } }
이벤트 핸들러 코드 예제
@Service public class OrderCanceledEventHandler { private RefundService refundService; public OrderCanceledEventHandler(RefundService refundService) { this.refundService = refundService; } @TransactionalEventListener(classes = OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
5. 비동기 방식으로 이벤트 4가지 방법
스프링 부트 비동기 적용
어플리케이션 컨텍스트 설정 부분에 @EnableAsync
@SpringBootApplication // 비동기 방식 설정 @EnableAsync public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }
사용할 EventListener에 @Async - 새로운 스레드 생성 작업
@Service public class OrderCanceledEventHandler { private RefundService refundService; public OrderCanceledEventHandler(RefundService refundService) { this.refundService = refundService; } // 비동기 방식 @Async @TransactionalEventListener(classes = OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
1. 로컬 핸들러 비동기 실행
로컬에서 취소작업을 진행하고 저장소에 보관을 안할 경우 예외 발생 시 이벤트가 소실되어 추적이 힘들다.
2. 메시지 큐 ( kafka, rabbitmq)
글로벌 트랜잭션을 지원여부
rabbitmq O
kafka X
글로벌 트랜잭션은 전체 성능이 떨어지는 단점이 있다.
kafka는 고가용성으로 서버가 구성되며 다운되도 다시 서버가 뜨면 이전에 큐에 쌓아둔 데이터를 유지한채 작업이 진행된다.
3. 이벤트 저장소와 이벤트 포워더 사용
이벤트를 저장소에 쌓아두고 포워더를 통해서 작업 진행 ( 스케줄러로 시간 지정, 무한하게 실패할 수 있기 때문에 실패 횟수 지정 )
4. 이벤트 저장소와 이벤트 제공 API 사용
외부 서버에서 이벤트를 실행 할 수 있도록 설정한다.
REST API를 제공하여 외부에서 이벤트를 가져와 처리할 수있도록 한다.
5. 비동기 예외 사항 처리
만일 DB에서 주문 상태 변경은 실패했는데 이벤트 핸들러는 작동한경우
비동기 방식이기 때문에 DB에 주문 취소가 커밋되기전에 이벤트가 동작하는 경우 환불처리는 됬지만 DB는 상태 변경이 안됬을 수 있다.
EventListener 트랜잭션에 TransactionPhase.AFTER_COMMIT 속성 설정
트랜잭션 커밋후 이벤트 동작 할 수 있도록 지정
@Service public class OrderCanceledEventHandler { private RefundService refundService; public OrderCanceledEventHandler(RefundService refundService) { this.refundService = refundService; } @Async @TransactionalEventListener( classes = OrderCanceledEvent.class, // 비동기이지만 트랜잭션 커밋 후 동작하도록 설정 phase = TransactionPhase.AFTER_COMMIT ) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
6. 소스 출처
https://github.com/madvirus/ddd-start2/tree/main
'방법론' 카테고리의 다른 글
[방법론] 도메인 주도 개발 시작하기 DDD - 1 (1) 2023.12.14