본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.
출처: 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)
이번 포스팅에선 쇼핑몰 도메인의 일부분인 주문 도메인을 애그리거트 개념을 설명하면서 코드로 어떻게 구현될 수 있는지를 알아보겠습니다. 설명의 기준은 Spring 과 JPA를 예시로 적용할 것이므로 간단하게 프로젝트 세팅 방법부터 알아보겠습니다.
0. 준비하기 - 프로젝트 세팅 및 설정 추가하기
스프링 프로젝트를 생성할 때 저는 https://start.spring.io/ 페이지를 이용합니다. 해당 URL 로 접속하면 아래와 같은 화면이 나타납니다.
https://start.spring.io/
위 화면에서 아래 순서로 설정해주시면 됩니다.
가장 기본으로 설정되어 있는 화면을 캡처한 것 입니다. 전 이번 예제에서 Java 를 사용할 것이므로 위 설정을 그대로 유지했습니다.
생성할 프로젝트에 관한 기본 정보를 설정하는 화면 입니다. Group 이나 Artifact 는 원하시는 내용을 넣으시면 됩니다. 해당 값이 프로젝트의 패키지 경로가 됩니다.
주의할 점은 앞에서 SpringBoot 설정 시, 3.1.0 같이 맨 앞이 3 으로 시작하는 경우, Java 버전은 17 이상을 사용해야 합니다.
이 점만 주의해서 확인해주시고 dependency 를 설정합니다.
오른쪽에 Dependency 설정에선 위와 같이 3개의 Dependency 를 추가해줍니다.
추가는 오른쪽 위에 "ADD DEPENDENCIES" 버튼을 클릭해서 위 Dependency 를 검색해서 추가할 수 있습니다.
Dependency 추가가 완료됐다면 화면 하단에 "GENERATE" 버튼을 클릭합니다. 그러면 다운로드 폴더에 Zip 파일이 다운로드 됩니다.
압축을 풀고, 개발 IDE 에서 해당 프로젝트를 Open 하면 프로젝트 세팅이 완료됩니다.
아래는 프로젝트를 Open 한 후에 보여지는 build.gradle 의 모습입니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
아래는 JPA 설정에 필요한 application.yml 입니다.
프로젝트 Open 시, 기본으로 application.properties 파일이 들어있습니다.
같은 위치에 application.yml 파일을 새로 생성하시고, 기존에 있던 application.properties 파일은 삭제하셔도 됩니다.
spring:
datasource:
url: jdbc:h2:mem:testdb // h2 데이터베이스 메모리 실행
username: sa // h2 데이터베이스 기본 계정
password:
driver-class-name: org.h2.Driver // h2 데이터베이스 드라이버
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
format_sql: true
use_sql_comments: true
해당 포스팅이 JPA 가 메인이 아닌 만큼, 애그리거트와 관련없는 설명은 다소 생략됐을 수 있습니다.
참고로 yml 파일은 들여쓰기가 매우 중요하므로 indent (인덴트, 들여쓰기) 를 꼭 신경써주시기 바랍니다.
1. 스프링 데이터 JPA 로 리포지토리 구현
Spring Data JPA 를 활용해 Repository 를 구현하기 위해 Entity 객체가 먼저 만들어져 있어야 합니다.
주문 애그리거트 구현을 위해 Order 엔티티를 아래처럼 생성해 보겠습니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
}
@Embeddable
public class OrderNo {
@Column(name = "order_number")
private String number;
}
@EmbeddedId 애노테이션은 아래에서 소개할 예정이니 지금은 Order 엔티티의 PK 가 OrderNo class 안에 있는 number 필드변수라고만 생각해주시면 됩니다.
그리고 OrderRepository는 아래처럼 JpaRepository interface 를 상속받습니다.
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, OrderNo> {
Optional<Order> findById(OrderNo no);
}
Entity 와 Repository 를 잘 만들었다면, 프로젝트 실행 시 아래와 같은 로그가 나타난다면 정상적으로 Entity 가 생성된 것 입니다.
2. 주문 애그리거트 루트 엔티티 설계하기
구현을 하기 전에는 항상 설계 과정을 거쳐야 합니다. 대략적이더라도 설계를 하고 안하고의 차이는 개발을 하는 과정에서 많은 차이를 보여주는데요, 주문 애그리거트 구현을 위해 주문 애그리거트를 설계해보도록 하겠습니다.
설계를 위해 개발을 위한 요구사항을 먼저 수립해보겠습니다.
1. 주문 시, 여러 상품을 한 번에 주문 할 수 있다.
2. 주문 시, 주문자의 정보와 배송지 정보를 저장한다.
3. 주문된 상품이 준비 중인 경우, 배송지를 변경할 수 있다.
위 처럼 간단하게나마 도메인에 대한 요구사항을 정리하고, 해당 요구사항에 만족하는 설계로 아래와 같은 간단한 다이어그램을 도출해냅니다.
3. JPA로 주문 애그리거트 구현하기
위에서 정의해본 요구사항을 토대로 클래스 다이어그램을 도출해봤습니다.
지금부터는 JPA 에서 제공하는 기능을 활용해 애그리거트를 구현해보겠습니다.
본 포스팅은 JPA 관련 포스팅이 아니므로, 사용될 기능에 대한 설명이 부족할 수 있으니 참고 부탁드립니다.
3.1 @Embedded 와 @Embeddable 그리고 @EmbeddedId
JPA 는 @Entity 로 선언된 class 를 데이터베이스의 테이블로 변환하여 자동으로 DDL 을 생성해주는 기능을 제공합니다.
예를 들면, 위 요구사항을 기준으로 Orders 라는 주문정보를 관리하는 데이터베이스 테이블을 설계해본다면 아래처럼 도출 될 것입니다.
위와 같은 구조의 데이터베이스를 @Entity 로 구현하면 1차원적으로 아래 처럼 구현될 것 입니다.
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "order_number")
private String no;
@Column(name = "orderer_name")
private String ordererName;
@Column(name = "orderer_phone_number")
private String ordererPhoneNumber;
@Column(name = "order_status")
private String orderStatus;
@Column(name = "delivery_address")
private String deliveryAddress;
@Column(name = "delivery_detail_address")
private String deliveryDetailAddress;
@Column(name = "delivery_zip_code")
private String deliveryZipCode;
@Column(name = "receiver_name")
private String receiverName;
@Column(name = "receiver_phone_number")
private String receiverPhoneNumber;
}
위 코드처럼 Orders 객체를 구현하고 프로그램을 실행하면, JPA 에서 해당 클래스를 가지고 DDL 을 아래처럼 생성해 줄 것 입니다.
클래스와 필드의 순서는 조금 다르지만, 원하는 데이터베이스의 스키마는 잘 충족시킨 모습을 볼 수 있습니다.
위 코드가 주문 애그리거트의 기능을 수행하지 못하는 것은 아니지만, DDD 에서 말하는 애그리거트 구현의 중요한 점은 "객체지향 기법을 살려서" 개발해야 하는 것 입니다.
따라서, 하나의 의미를 가지는 밸류객체를 적절하게 활용하면 훨씬 더 좋은 코드로 수정할 수 있습니다.
처음에 그린 클래스 다이어그램을 적용해보면 아래 처럼 객체를 분리해볼 수 있습니다.
@Entity
@Table(name = "orders")
public class Order {
@Id
private OrderNo no;
private Orderer orderer;
@Column(name = "order_status")
private String orderStatus;
private DeliveryInfo deliveryInfo;
}
public class OrderNo {
@Column(name = "order_number")
private String number;
}
public class Orderer {
@Column(name = "orderer_name")
private String name;
@Column(name = "orderer_phone_number")
private String phoneNumber;
}
public class DeliveryInfo {
private Address address;
private Receiver receiver;
}
public class Address {
@Column(name = "delivery_address")
private String address;
@Column(name = "delivery_detail_address")
private String detailAddress;
@Column(name = "delivery_zip_code")
private String zipCode;
}
public class Receiver {
@Column(name = "receiver_name")
private String name;
@Column(name = "receiver_phone_number")
private String phoneNumber;
}
하지만, 위 코드처럼 수정하고 프로그램을 실행시키면, 오류가 발생하면서 실행되지 않을 것 입니다.
그 이유는 JPA가 Orders class 를 테이블로 변환할 수 없기 때문 인데요.
예를 들어, 기존엔 단순 String 자료형이었던 필드변수 no 가 지금은 OrderNo 라는 새로운 객체가 되었고,
같은 방식으로 ordererName, ordererPhoneNumber 도 Orderer 라는 새로운 객체로 변환되었기 때문에
JPA 는 해당 객체들을 DB 테이블 칼럼으로 변환할 때 어떤 자료형으로 변환해야 하는지 알 수 없어서 결과적으로 Orders 라는 Entity 를 DB 테이블로 변환할 수 없다고 판단합니다.
따라서, JPA에게 "이건 밸류객체로 나눠놨을 뿐이고, 사실은 Entity에 속하는 칼럼정보로 사용되어야 돼" 라고 알려줘야 하는데,
그 방법이 바로 @Embedded, @EmbeddedId 그리고 @Embeddable 을 사용하는 것 입니다.
- @Embeddable : 엔티티에 포함될 객체타입 class 에 명시합니다. "이 class 는 엔티티에 포함될 수 있다" 라는 의미입니다.
- @EmbeddId : @Embeddable 이 선언된 class 가 PK 로 사용되는 경우, 엔티티의 PK 필드변수에 선언합니다.
- @Embedded : @Embeddable 이 선언된 class 을 엔티티에서 단순 밸류타입으로 가질 때, 엔티티의 필드변수에 선언합니다.
위 세 가지의 특징을 고려하여 엔티티와 밸류객체를 수정하면 아래처럼 수정됩니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
@Embedded
private Orderer orderer;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Embedded
private DeliveryInfo deliveryInfo;
}
@Embeddable
public class OrderNo {
@Column(name = "order_number")
private String number;
}
@Embeddable
public class Orderer {
@Column(name = "orderer_name")
private String name;
@Column(name = "orderer_phone_number")
private String phoneNumber;
}
public enum OrderStatus {
PREPARE, SHIPPING, END
}
@Embeddable
public class DeliveryInfo {
@Embedded
private Address address;
@Embedded
private Receiver receiver;
}
@Embeddable
public class Address {
@Column(name = "delivery_address")
private String address;
@Column(name = "delivery_detail_address")
private String detailAddress;
@Column(name = "delivery_zip_code")
private String zipCode;
}
@Embeddable
public class Receiver {
@Column(name = "receiver_name")
private String name;
@Column(name = "receiver_phone_number")
private String phoneNumber;
}
위 코드로 프로그램을 실행하면 JPA에서 아래와 같이 정상적인 DDL 을 만들어준다는 것을 확인할 수 있습니다.
3.2 @Embedded 밸류 객체로 1:N 테이블 매핑해보기 (주의! @OneToMany 아님)
앞서 @Embedded 를 통해 밸류객체를 생성하여 코드에 의미를 부여해보았습니다.
이제는 처음 정의한 요구사항 중 "주문 시, 여러 상품을 한 번에 주문할 수 있다." 를 구현해보겠습니다.
해당 구현을 위해 먼저 주문상품 정보를 가질 OrderItem class 를 아래처럼 생성했습니다.
@Embeddable
public class OrderItem {
@Column(name = "product_id")
private String productId;
@Column(name = "quantity")
private int quantity;
@Column(name = "price")
private int price;
@Column(name = "amounts")
private int amounts;
}
이제 위 OrderItem 를 List 로 가지는 orderItems 를 Order 엔티티에 추가합니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
@Embedded
private Orderer orderer;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Embedded
private DeliveryInfo deliveryInfo;
private List<OrderItem> orderItems;
}
여기서는 앞에서 적용했던 @Embedded 를 바로 적용할 수 없습니다. 왜냐하면, OrderItem 은 1개에 Order에 대해 여러 개가 존재할 수 있기 때문에, 별도의 테이블로 저장되어야 자연스럽기 때문입니다.
JPA 에선 이러한 문제를 @OneToMany, @ManyToOne 같은 매핑관계로 해결할 수 도 있지만, 이번 포스팅에선 밸류객체로 해결해보려 합니다.
바로 @ElementCollection 과 @CollectionTable 을 활용하는 것입니다.
일단 적용된 코드를 먼저 보겠습니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
@Embedded
private Orderer orderer;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Embedded
private DeliveryInfo deliveryInfo;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "orders_item_list",
joinColumns = @JoinColumn(name = "order_number")
)
private List<OrderItem> orderItems;
}
위 코드를 적용해서 프로그램을 실행하면 JPA 에서 아래와 같이 2개의 테이블을 생성하는 DDL 을 만들어주는 것을 확인할 수 있습니다.
여기서 눈여겨 봐야할 내용은 @ElementCollection 과 @CollectionTable 입니다.
@ElementCollection 과 @CollectionTable은 밸류 컬렉션 필드를 별도의 테이블로 매핑하고 싶을 때 사용합니다.
이 때, 매핑될 테이블의 이름을 @CollectionTable 의 name 속성으로 지정해줍니다.
그리고, 별도의 테이블이 생성될 때, Order 의 외래키를 담기 위해 joinColumns 속성에
@JoinColumn(name="order_number")를 명시함으로써, 외래키 칼럼 명을 order_number 로 적용하도록 한 것 입니다.
3.3 @Embedded 생각해보기
앞에서 @Embedded 를 활용해서 밸류 객체로 엔티티를 좀 더 이해하기 쉬운 코드로 작성해봤습니다.
저는 저렇게 클래스를 분리해놓는게, 2가지의 양면성이 있다고 생각합니다.
- 장점 : 밸류타입을 만들면, 개발자가 타입명을 직접 만들 수 있으므로, 특정 값의 의미를 명확히 할 수 있습니다. 또한 특정 객체의 기능 혹은 검증에 대한 책임을 명확하게 분리할 수 있습니다.
- 단점 : 엔티티가 가지는 정보가 많으면 많을 수록, 많은 밸류 객체로 데이터가 퍼지게 되어 한 눈에 엔티티의 속성을 확인하기 어렵습니다. 엔티티 내 밸류타입이 검증/기능이 많아지면 엔티티부터 로직을 확인해야하므로, 한 눈에 기능흐름을 파악하기 어려울 수 있습니다.
즉, 위에서 제시해본 2가지의 양면성으로 인해 @Embedded 를 사용했을 때 장점이 큰지 단점이 큰지 고민이 될 수 있다고 생각합니다.
저는 개인적으로 아래의 애그리거트 구현의 대전제를 지키기 위해서라도 @Embedded 사용을 하는 편입니다.
애그리거트 외부에선 애그리거트 루트를 통해서만 애그리거트에 속하는 객체에 간접적으로 접근을 할 수 있고,
이를 위해 애그리거트 루트는 기능을 구현한 메서드를 제공해야한다.
- 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지, 3.2.1 도메인 규칙과 일관성 중
위 내용을 한 번 코드로 알아보기 위해 새로운 요구사항을 하나 추가해보록 하겠습니다.
새 요구사항 : 주문 정보 조회 시, 주문한 상품의 총 결제금액을 표시한다.
주문한 상품들의 총 금액을 구하는 요구사항이 추가되어 Order 엔티티에 해당 기능을 수행할 메서드를 추가해보겠습니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
@Embedded
private Orderer orderer;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Embedded
private DeliveryInfo deliveryInfo;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "orders_item_list",
joinColumns = @JoinColumn(name = "order_number")
)
private List<OrderItem> orderItems;
private int totalPrice;
protected Order(){}
public Order(OrderNo no, Orderer orderer, OrderStatus orderStatus, DeliveryInfo deliveryInfo,
List<OrderItem> orderItems) {
this.no = no;
this.orderer = orderer;
this.orderStatus = orderStatus;
this.deliveryInfo = deliveryInfo;
this.orderItems = orderItems;
this.totalPrice = calculateTotalPrice();
}
private int calculateTotalPrice(){
return orderItems.stream()
.mapToInt(OrderItem::getAmounts)
.sum();
}
}
@Embeddable
public class OrderItem {
@Column(name = "product_id")
private String productId;
@Column(name = "quantity")
private int quantity;
@Column(name = "price")
private int price;
@Column(name = "amounts")
private int amounts;
protected OrderItem(){}
public OrderItem (String productId, int quantity, int price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
this.amounts = this.quantity * this.price;
}
public int getAmounts() {
return this.amounts;
}
}
Order 엔티티에서 Stream 을 통해 OrderItem에서 계산된 amounts 값을 모두 더하는 방식으로 구현되어 있습니다.
현재 코드는 OrderItem 객체에 amounts 라는 필드가 존재하기 때문에 가능하지만, 사실 amounts 필드는 없어도 무방한 필드입니다.
그럼 OrderItem 객체 내에 amounts 필드가 없다면 어떻게 코드가 변경될까요? 직관적으로 생각해본다면 아래처럼 변경 될 것입니다.
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderNo no;
@Embedded
private Orderer orderer;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Embedded
private DeliveryInfo deliveryInfo;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "orders_item_list",
joinColumns = @JoinColumn(name = "order_number")
)
private List<OrderItem> orderItems;
private int totalPrice;
protected Order(){}
public Order(OrderNo no, Orderer orderer, OrderStatus orderStatus, DeliveryInfo deliveryInfo,
List<OrderItem> orderItems) {
this.no = no;
this.orderer = orderer;
this.orderStatus = orderStatus;
this.deliveryInfo = deliveryInfo;
this.orderItems = orderItems;
this.totalPrice = calculateTotalPrice();
}
private int calculateTotalPrice(){
return orderItems.stream()
.mapToInt(OrderItem::calculateAmounts)
.sum();
}
}
@Embeddable
public class OrderItem {
@Column(name = "product_id")
private String productId;
@Column(name = "quantity")
private int quantity;
@Column(name = "price")
private int price;
protected OrderItem(){}
public OrderItem (String productId, int quantity, int price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
}
public int calculateAmounts() {
return this.quantity * this.price;
}
}
위 코드도 각 OrderItem 의 계산을 Order 에서 처리하지 않고, OrderItem 이 처리하도록 위임하고 있습니다.
이와 같이 @Embedded 를 통해 객체를 분리하게 되면, 엔티티는 메서드를 통해 밸류객체에게 실제 기능 수행을 위임함으로써,
객체지향에서 지향하는 역할과 책임을 명확히 분리할 수 있습니다.
'DDD&MSA' 카테고리의 다른 글
[DDD] 도메인 서비스(Domain Service) (0) | 2023.06.14 |
---|---|
[DDD] 응용 서비스 구현과 표현 계층 (0) | 2023.06.07 |
[DDD] 애그리거트(Aggregate) 이해하기 (0) | 2023.06.02 |
[DDD] Domain 영역의 구성 요소 맛보기(with 애플리케이션 아키텍처) (0) | 2023.05.29 |
[DDD] DDD - 엔티티(Entity)와 밸류(Value) (feat. Java) (0) | 2023.05.28 |
댓글