https://product.kyobobook.co.kr/detail/S000001810495

 

도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지 | 최범균 - 교보문고

도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지 | 가장 쉽게 배우는 도메인 주도 설계 입문서!이 책은 도메인 주도 설계(DDD)를 처음 배우는 개발자를 위한 책이다. 실제 업무에 DDD를

product.kyobobook.co.kr

Chapter 1_도메인 모델 시작하기: https://inhyeok-blog.tistory.com/55
Chapter 2_아키텍처 개요 : https://inhyeok-blog.tistory.com/56
Chapter 3_애그리거트 : https://inhyeok-blog.tistory.com/57
Chapter 4_리포지터리와 모델 구현 : https://inhyeok-blog.tistory.com/58
Chapter 5_스프링 데이터 JPA를 이용한 조회 기능 : 
Chapter 6_응용 서비스와 표현 영역 : 
Chapter 7_도메인 서비스 : 
Chapter 8_애그리거트 트랜잭션 관리 :
Chapter 9_도메인 모델과 바운디드 컨텍스트 : 
Chapter 10_이벤트 : 
Chapter 11_CQRS : 


JPA를 이용한 리포지터리 구현

앞선 글에서 언급한 바와 같이 리포지터리 인터페이스는 domian영역에, 구현은 infra 영역에 속한다. JPA를 활용하면 기본적으로 두 가지 메서드를 제공한다.

  • Optional<Order> findById(OrderNo no)
  • void save(Order order)

이때 JPA는 변경 감지 기능을 활용해서 Entity를 영속화 시킬 수 있기 때문에, 따로 저장 쿼리를 날려줄 필요는 없다. 또한 Optional을 사용해서 조회 결과가 null인 경우를 우아하게 처리할 수도 있을 것이다.

이렇게 기본 메서드를 만들고, 이후에 필요에 따라 추가 메서드를 구현하게 된다. 예컨데 findByOrdererId, delete와 같은 것이다.

*사실 Entity를 delete하는 요구 사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않다. 데이터 삭제보다는 플래그를 이용해 사용자에게 보여줄지 여부를 결정하는 것이 일반적이다.

 

스프링 데이터 JPA를 이용한 리포지터리 구현

Spring Data Jpa는 인터페이스만 만들면 구현체를 자동으로 만들어서 Spring Bean으로 등록해준다. Spring Data Jpa는 필자가 이미 익숙하므로 생략하겠다.

매핑 구현

애그리거트와 JPA 매핑을 위한 기본 규칙은 다음과 같다.

  1. 애그리거트 루트는 @Entity로 매핑 설정한다.
  2. 밸류는 @Embeddable로 매핑 설정한다.
  3. 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.

이런 규칙을 따라서 매핑하면 아래의 그림과 같은 형태로 매핑 되게 된다. 그림을 보면 알겠지만, 밸류 타입은 엔티티와 한 테이블에 표현되는 경우가 많다.

@Embeddable, @Embedded의 사용법은 익숙하니 넘어가고, @AttributeOverride 에너테이션에 대해 간단히 살펴보자.

 

위의 그림에서 보면 Address밸류에 있는 필드가 실제 테이블과 이름이 다르다. 이럴 때 사용하는 것이 @AttributeOverride 에너테이션이다.

 

이런 식으로 사용하는 것이다.

 

기본 생성자

엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달 받는다. 이때 불변 타입이면 생성 시점에 필요한 값을 모두 전달 받으므로, set 메서드가 필요없다. 하지만 JPA에서 @Entity와 @Embeddable을 매핑하려면 기본 생성자를 제공해야 한다. 이는 바이트 코드를 조작해서 객체를 만드는 CGLIB의 특성 상 어쩔 수 없는 부분이다.

이런 기술적인 이유로 우린 반드시 기본 생성자를 만들어야 하지만, 외부에서 이를 사용하는 것은 막아야 한다. 따라서 public이 아니라 protected로 선언해야 한다.

필드 접근 방식 사용

protected 기본 생성자를 통해서 Entity/Value 객체를 생성했다면, 이제 객체의 필드와 테이플의 컬럼을 매핑 해줘야 한다. 이때 필드와 메서드 2가지의 매핑 방법이 있다.(아니 전혀 몰랐는데?)

이는 @Access 어노테이션으로 지정해 줄 수 있는데, 다음과 같이 사용하게 된다.

이 방식은 Method를 통해서 매핑 하는 방법이다. 클래스 위에 @Access(Accesstype.PROPERTY)를 달아주는 것으로 메서드 매핑 방법을 실현할 수 있다. 이 방법은 캡슐화를 깨고, 엔티티 밖에서 핵심 도메인 로직을 수행할 가능성을 만든다. 또한 밸류 타입의 경우 불변성을 유지하지 못하게 되는 문제가 발생하게 된다. 따라서 우리는 메서드 매핑 방식이 아니라 필드 매핑 방식을 사용할 것이다.

@Access(AccessType.FIELD)를 달아줘서 필드 매핑을 했다. 우리에게 친숙한 방법으로, 필드 위에 어노테이션을 달아서 매핑에 대한 자세한 정보를 기술한다.

필자가 JPA를 사용하는동안 이런 방식이 있는지 왜 알지 못했을까?

그 이유는 바로 @Access를 설정하지 않으면 @Id나 @EmbeddedId의 위치를 기준으로 접근 방식이 설정되기 때문이다. @Id가 필드에 달려 있으면 필드 접근, Getter에 달려 있으면 프로퍼티 접근 방법을 사용하게 된다.

AttributeConverter를 이용한 밸류 매핑 처리

두 개 이상의 프로퍼티를 가진 밸류 타입을 한 개 칼럼에 매핑 하려면 @Embedddable 애너테이션이 아니라 @AttributeConverter 애너테이션을 사용해야 한다.

예컨데, Length라는 밸류객체가 2개의 프로퍼티를 갖고 있는데 DB 테이블에는 한 개 칼럼으로 표현되는 경우가 있을 수 있다.

7

이럴 때 AttributeConverter 인터페이스를 상속 받아서 구현함으로서 활용할 수 있다.

이렇게 생긴 인터페이스를

이렇게 구현하는 것이다.

 

두 메서드의 이름을 보면 각각이 어떤 역할을 수행할지 명확히 보인다.

여기서 @Converter 애너테이션을 주목하자. autoApply 속성을 true로 주면 이제 자동으로 해당 밸류 객체를 변환해서 반환해 준다. 만약 autoApply 값을 false로 주게 된다면(default가 false이다.) 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정해야 한다.

밸류 컬렉션: 별도 테이블 매핑

Order Entity와 OrderLine을 생각해보자. Order은 여러 개의 OrderLine을 가질 수 있다. 그리고 OrderLine은 밸류 객체이다. 이럴 때 우리는 List 타입의 콜랙션을 @ElementCollection과 @CollectionTable을 함께 사용해서 매핑 할 수 있다.

이 방식은 평소에 전혀 사용해보지 않은 방식이다. 이는 밸류 타입과 엔티티 타입을 명확히 분리하지 않고 개발했던 나의 습관 때문일 것이다.

그래서인지 한 가지 의문이 생겼다. @OneToMany와 @ElementCollection의 차이는 뭘까?

 

이는 @ElementCollection을 사용한 밸류 타입 객체는 부모 엔티티와 독립적으로 존재할 수도, 쿼리할 수도, 생존할 수도 없다는 것이다. 따라서 캐스케이드 작업을 전혀 지원하지 않으며, 이는 해당 객체가 항상 부모 엔티티와 함께 유지, 병합, 제거됨을 의미한다. 특히 개념적으로 이 객체는 밸류 타입이라는 것을 명시하는 것과 같은 효과를 기대할 수 있을 것이다.

벨류 컬렉션: 한개 칼럼 매핑

밸류 컬렉션을 별도의 테이블이 아닌 하나의 컬럼에 저장해야 하는 경우도 분명히 있을 것이다. 예를 들어서 사용자의 주소를 별도의 테이블로 저장하는 것이 아니다 Comma(,)를 통해서 분리만 해두는 것이다. 이를 위해서도 앞서 설명한 AttributeConverter를 사용할 수 있을 것이다. 단 AttributeConverter를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.

이렇게 해야 AttributeConverter의 제네릭(generalize)에 담을 수 있다.

밸류를 이용한 ID 매핑

식별자를 지정할 때는 밸류 객체를 만들어서 @id 대신 @EmbeddedId를 사용하자. 밸류 타입을 사용하면 ID를 표현한 밸류 객체 내부에 도메인 로직을 추가할 수 있다. 예컨데, Snowflake 방식으로 만들어진 ID에서 시간을 추출하거나, 요청이 처리된 Server를 확인할 수 있는 메서드를 추가할 수 있을 것이다.

별도 테이블에 저장하는 밸류 매핑

애그리거트에서 루트를 제외한 구성요소는 대부분 밸류이다. 루트 외에 또 다른 엔티티가 있다면 밸류이거나 다른 애그리거트에 속할 확률이 높다.

애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 찾는 것이다. 이때의 식별자는 객체를 유일하게 식별하기 위해서 사용되는 것을 의미한다. 테이블에 존재하는 식별자가 고유 식별자가 아닐 수 있다. 이는 단지 매핑을 위한 장치일 수 있는 것이다.

예를 들어보자.

Article 객체에서 ArticleContent 객체를 분리했다. 그리고 ID가 존재하길래 이를 Entity로 생각해서 @OneToOne Mapping했다. 하지만 이는 잘못됐다. ArticleContent의 ID는 Article 테이블의 데이터와 연결하기 위한 것이지, ARTICLE_CONTENT를 위한 별도 식별자가 필요한 것은 아니었다. ArticleContent를 밸류로 보고 접근하면 모델은 다음과 같이 바뀐다.

ArticleContent는 밸류이므로 @Embeddable로 매핑한다. 하지만 이 방식은 앞서 살펴본 바와 같이 새로운 테이블로 매핑되지 않고, Article 테이블 컬럼들이 포함되게 된다. 이를 방지하기 위해서 @SecondaryTable과 @AttributeOverride를 사용해야한다.

@SecondaryTable과 @AttributeOverride의 자세한 문법은 글의 분량 상 생략한다. 관심 있는 사람들은 검색해보자.

이렇게 하나의 엔티티에 여러 테이블을 매핑하면 조회 성능이 떨어지는 문제가 있다. 엔티티 루트를 조회하면 언제나 SecondaryTable을 Join해서 가져오기 때문이다. 이를 해결하는 방법으로 5장에서 조회 전용 기능 구현을 살펴보고, 11장에서 Query/Command 분리 기법을 알아본다.

밸류 컬렉션을 @Entity로 매핑하기

개념적으로는 벨류인데, 구현 기술이나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.

만약 업로드 방식에 따라 썸네일 저장 여부와 URL을 구성하는 방식이 변경된다면 상속을 이용해서 도메인 모델을 만들 것이다. 그리고 각 메서드를 다형성을 이용해서 효과적으로 핸들링 할 것이다. 하지만 슬프게도 JPA에서 밸류 타입은 상속 매핑을 지원하지 않는다. 이를 해결하기 위해 @Embeddable 대신@Entity를 사용해서 상속 매핑을 해야 한다. 그리고 테이블에서는 DTYPE(discriminator type)을 통해서 타입을 식별한다.

한 테이블에 Image와 그 하위 클래스를 매핑 하기 위해 다음과 같은 설정이 필요하다.

  • @Inheritance 애너테이션 적용, strategy 값으로 SINGLE_TABLE 사용
    • 상속을 매핑 하는데, 한 테이블에 저장하는 방식으로 하겠다는 의미
  • @DiscriminatorColumn 애너테이션을 이용하여 타입 구분 용으로 사용할 칼럼 지정
    • 상속 받은 클래스에 @DiscriminatorValue 애너테이션을 달아 줘야 함

그리고 Image를 Entity로 매핑했지만 모델에서 Image는 밸류 이므로 상태를 변경할 수 없도록 해야한다.

Image는 밸류이므로 라이프 사이클을 PRODUCT에 완전히 의존한다. 따라서 cascade와 orphanRemoval을 잘 설정해줘야 한다.

또한 Hibernate의 경우 @OneToMany 로 매핑된 컬렉션의 clear() 매서드를 호출하면 삭제 과정이 비효율 적이다(Query를 여러 번 날린다).

반면 @Embeddable로 이를 매핑하면 단일 클래스에 DTYPE만 다르게 해서 구현해야 한다. 이렇게 된다면 각 메서드는 분기 처리를 통해서 수행해야 할 것이다.

이 두 가지 방법은 분명 트레이드-오프가 존재한다. 따라서 유지 보수와 성능 두 가지 측면을 고려해서 구현 방식을 선택해야 한다.

ID참조와 조인 테이블을 이용한 단 방향 M-N 매핑

앞서 한 애그리거트에서 다른 애그리거트의 집합 연관을 성능 상의 이유로 피하라고 조언했지만, 상황에 따라 어쩔 수 없는 경우도 존재한다. 이런 경우 ID 참조를 이용한 단 방향 집합 연관을 적용해볼 수 있다. 이는 3장에서 이미 보여준 바 있다.

애그리거트 로딩 전략

애그리거트에 속한 객체는 모두 모여야 완전한 하나가 된다. 그리고 이는 애그리거트에 속한 모든 객체가 즉시 로딩 전략을 취해야 한다는 것으로 받아 들여지기 쉽다. 하지만 이는 성능 문제를 가져오기도 한다. 특히 카사디안 곱으로 JOIN이 걸리는 경우가 대표적이다. 따라서 저자는 지연 로딩 전략을 고려할 것을 제안한다.

우리가 애그리거트를 완전하게 유지하려는 이유는 2가지 이다.

  • 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 한다.
  • 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하다.

이 2가지 이유 중에 2번째 문제는 별도의 조회 전용 기능과 모델을 구현하는 방식을 사용하는 것이 더 유리하기 때문에, 근본적인 이유는 애그리거트의 상태 변경과 관련된 1번째 문제가 더 크다. 하지만 상태 변경을 위해 즉시 로딩이 필수적이지는 않다. 트랜잭션 범위 내에서의 지연 로딩은 허용되기 때문이다. 또한 이로 인해 발생하는 추가 쿼리의 양도 조회 쿼리에 비해 현저히 적을 것으로, 상태 변경 쿼리의 지연 로딩이 주는 성능 저하는 치명적이지 않는다.

지연 로딩은 동작 방식이 모든 JPA의 구현체에 동일하고, 즉시 로딩처럼 다양한 경우의 수를 따질 필요도 없는 장점이 있다. 하지만 쿼리 실행 횟수가 즉시 로딩 보다 많아질 확률이 높다는 것 역시 사실이다. 결국 상황에 따라 즉시 로딩과 지연 로딩을 잘 선택 해야 한다.

애그리거트의 영속성 전파

애그리거트는 조회/수정/삭제 등 모든 상황에서 완전한 상태여야 한다.

  • 저장 메서드는 애그리거트 루트 뿐만 아니라 애그리거트에 속한 모든 객체를 저장한다.
  • 삭제 메서드는 애그리거트 루트 뿐만 아니라 애그리거트에 속한 모든 객체를 삭제한다.

@Embeddable 매핑 타입은 함께 저장 및 삭제가 가능하지만, @Entity 타입으로 매핑한 경우에는 cascade 속성을 사용해서 완전한 상태를 유지해 줘야 한다.

식별자 생성 기능

식별자는 크게 3가지 방법으로 만들어진다.

  • 사용자가 직접 생성 (ex_이메일 등)
  • 도메인 로직으로 생성 (ex_snowflake 등)
  • DB를 이용한 일련번호 사용 (ex_Identity방식 등)

사용자가 직접 생성하는 경우는 프로그램에 식별자 생성 기능을 구현할 필요가 없다.

도메인 로직으로 생성하는 경우는 별도 서비스로 식별자 생성 기능을 분리해야 한다. 그리고 식별자 생성 기능은 도메인 로직이기 때문에 도메인 영역(도메인 서비스)에 위치 시켜야 한다.

DB를 이용해서 생성하는 경우에는 JPA의 @GeneratedValue를 사용하게 된다. 이때 저장 ID를 저장 시점에 알 수 있다는 점을 유의해서 개발해야 한다.

도메인 구현과 DIP

이번 장에서 논의했던 도메인 구현 방식은 DIP 원칙을 어기고 있다. @Entity, @Talbe, @Id, Respotiroy<T, K> 등 도메인이 인프라에 의존하고 있다.

그럼 의존을 제거하고 순수한 도메인 모델을 만든다면 어떤 모양이 되어야 할까?

이런 구조가 된다. 이렇게 된다면 Infra를 구현 기술을 언제든 변경할 수 있을 것이다.

하지만 저자는 infra 구현 기술은 거의 변경되지 않고, DIP를 지키면 필요 이상으로 구현이 복잡해 지는 점을 지적한다. 또한 도메인 모델을 단위 테스트하는데 문제가 없다는 점 역시 언급했다.

DIP를 완벽하게 지키면 좋겠지만, 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느 정도 유지하는 것이 합리적 선택이다.

+ Recent posts