개요
Java 심화과정의 특강 주제였던 DDD에 대해 간단하게 정리하고 프로젝트에 적용시킬 수 있도록 구현 방법을 배워볼 것이다.
DDD(Domain-Driven Design)?
복잡한 소프트웨어 개발에서 도메인 중심으로 설계를 진행하는 접근 방식.
특징
- 도메인 중심 설계: 비즈니스 로직이 중심이 되며, 기술적 구현보다 도메인 모델을 우선한다.
- 유비쿼터스 언어 사용: 개발자와 비즈니스 전문가가 동일한 언어로 소통하여 오해를 줄인다.
- 계층화된 아키텍처: 애플리케이션 계층(프레젠테이션, 응용, 도메인, 인프라)으로 나눠 역할을 눈리한다.
- 도메인 모델의 명확한 경계: 바운디드 컨텍스트로 경계를 정의하고 모델 간 상호 작용을 관리한다.
장단점
- 장점
- 비즈니스 로직 명확화: 복잡한 비즈니스 로직이 코드로 명확하게 표현된다.
- 유지보수성 향상: 도메인 지식을 반영한 설계로 코드를 이해하고 수정하기 쉽다.
- 확장성과 재사용성: 도메인 모델이 잘 정의되면 새로운 요구사항에 유연하게 대응할 수 있다.
- 팀 협업 강화: 비즈니스 전문가와 개발자가 공통 언어로 협력할 수 있다.
- 단점
- 복잡한 초기 설계:초기 설계가 복잡하고 많은 도메인 지식이 필요하다.
- 고비용 개발: 학습 비용과 초기 개발 비용이 크며, 작은 프로젝트에는 과도할 수 있다.
- 조직 협업 필수: 도메인 전문가와의 긴밀한 협업이 필요해 협업 구조가 없는 조직에서는 어려움을 겪을 수 있다.
핵심 개념
- 도메인 (Domain): 해결하려는 특정 비즈니스 문제의 영역.
- 유비쿼터스 언어 (Ubiquitous Language): 개발자와 도메인 전문가가 공통으로 사용하는 언어로, 코드와 대화 모두에서 동일한 용어 사용을 지향.
- 엔터티 (Entity): 고유 식별자를 가진 객체로, 상태가 변해도 식별자는 유지.
- 값 객체 (Value Object): 식별자 없이 값으로만 비교되는 객체입니다. 상태가 불변이며 재사용이 가능.
- 애그리게이트 (Aggregate): 관련된 엔터티와 값 객체들의 집합으로, 일관성을 유지하기 위해 루트 엔터티 (Aggregate Root) 를 통해서만 접근이 허용.
- 도메인 서비스 (Domain Service): 특정 도메인 로직이 엔터티나 값 객체에 포함되기 어려운 경우, 서비스로 분리.
- 리포지토리 (Repository): 애그리게이트의 영속성을 관리하며, 데이터 저장소와 도메인 객체 간의 연결을 담당.
- 팩토리 (Factory): 복잡한 객체 생성 로직을 캡슐화하여 일관된 인스턴스 생성을 제공.
- 바운디드 컨텍스트 (Bounded Context): 도메인을 명확하게 구분하여 특정 컨텍스트 내에서만 유효한 도메인 모델을 정의.
적용 시 고려해야 할 사항
- 도메인 지식 수집: 비즈니스 전문가와의 지속적인 협업이 필수.
- 바운디드 컨텍스트 정의: 시스템 경계를 명확히 설정하고 컨텍스트 간 상호 작용을 관리.
- 복잡성 관리: 프로젝트가 충분히 복잡할 때만 적용해야 하며, 작은 프로젝트는 DDD가 과도할 수 있음.
- 조직의 지원 및 문화: 도메인 전문가와 개발자 간의 원활한 협업을 위한 조직 문화가 필요.
- 지속적인 리팩토링: 도메인 지식은 점진적으로 발전하므로, 리팩토링을 계획에 포함해야 함.
MSA에 DDD 적용 시 이점
1. 바운디드 컨텍스트와 서비스 경계 일치
- 설명: DDD의 바운디드 컨텍스트는 도메인 모델의 경계를 정의. 이는 MSA의 마이크로서비스 경계와 자연스럽게 일치.
- 장점:
- 서비스 간 명확한 경계 설정
- 서비스 간 의존성 최소화
2. 도메인 중심의 서비스 설계
- 설명: 각 마이크로서비스는 특정 도메인 기능을 중심으로 설계됨.
- 장점:
- 비즈니스 요구사항 반영 용이
- 서비스 변경 시 영향 범위 제한
3. 독립적인 개발과 배포
- 설명: 도메인 모델이 서비스별로 명확하게 정의되므로 팀이 독립적으로 개발하고 배포할 수 있다.
- 장점:
- 개발 팀 간 충돌 감소
- 서비스 단위별 배포 및 확장 용이
4. 데이터 일관성 관리
- 설명: 애그리게이트와 리포지토리 패턴을 활용해 각 서비스가 자신만의 데이터 저장소를 관리한다.
- 장점:
- 데이터 격리 및 복잡한 트랜잭션 관리 최소화
- 데이터 무결성과 일관성 보장
5. 팀 간 협업 강화
- 설명: DDD의 유비쿼터스 언어와 컨텍스트 맵핑은 팀 간 협업을 개선한다.
- 장점:
- 공통된 도메인 언어 사용으로 의사소통 강화
- 비즈니스 요구사항 반영 정확성 증가
6. 확장성과 유지보수성 향상
- 설명: 각 서비스는 도메인 중심으로 설계되어 특정 비즈니스 기능을 관리하므로 확장이 쉽다.
- 장점:
- 새로운 기능 추가 시 기존 서비스 영향 최소화
- 서비스 확장과 장애 격리 용이
DDD 구조 및 구현
Domain
Entity
//TODO: DDD를 사용할 경우 access 제한 설정을 권장드립니다.
@Entity
@Table(name = "users")
@Getter
@Builder(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
public class User {
private static final String PHONE_NUMBER_TRANSFER_TARGET = "-";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
@Column(name = "phone_number")
private String phoneNumber;
private Boolean isManufacture;
@CreatedDate
private LocalDateTime createdAt;
public static User create(String name, String email, String phoneNumber) {
User user = User.builder()
.name(name)
.email(email)
.build();
//TODO: Static Method 로 구현한 규칙의 경우 builder 생성 이후 호출로 구현 가능합니다.
user.transferPhoneNumberFormat(phoneNumber);
return user;
}
public void update(String name, String email) {
this.name = name;
this.email = email;
}
//TODO: 객체 수정 시 Domain 에 정해진 규칙을 적용하여 수정하는 방법
public void update(String name, String email, String phoneNumber) {
update(name, email);
transferPhoneNumberFormat(phoneNumber);
}
private void transferPhoneNumberFormat(String phoneNumber) {
this.phoneNumber = phoneNumber.replaceAll(PHONE_NUMBER_TRANSFER_TARGET, "");
}
public void changeToManufacture() {
this.isManufacture = true;
}
}
- access 레벨을 제한해 비즈니스 로직이 도메인(Entity) 변경에 영향을 받지 않도록 해준다.
- 대신 객체 생성 및 수정 시 Domain 내의 로직을 이용하는 방법을 사용한다.
/***
* TODO: Jpa 에서 Value Object 를 구성하는 방법
* */
@Embeddable
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class AuditingDate {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
- 어노테이션을 활용해 History 정보를 DB에 저장할 수 있도록 한다.
// TODO: Entity 에 Value Object 연결 예시
@Embedded
private AuditingDate dateInfo;
private String manufacturedBy;
public static Product create(String name) {
return Product.builder()
.name(name)
.build();
}
- 어노테이션을 활용해 History 정보를 쉽게 Entity에 연결할 수 있다.
//TODO: 하위 Entity 의 depth 가 길고, 자주 사용하는 기능일 경우 .get().get().get() 형태를 줄이기 위한 함수도 제공할 수 있습니다.
public List<ProductImage> getImages() {
return this.detail.getImages();
}
public void updateManufacture(String username) {
this.manufacturedBy = username;
}
- DDD 구조에서는 루트 애그리게이트가 하위 애그리게이트를 관리하는 방식으로 가기 때문에 위와 같은 메서드 활용이 필요하다.
/***
* TODO: DDD를 사용하면서 생기는 장점 (캡슐화)
* ProductDetail 의 규칙을 가정함 - retailPrice 는 supplyPrice 의 1.2배로 한다.
* 현재 ProductDetail 의 생성,수정 메서드에 규칙이 연결되어 있기 때문에
* 추후 비즈니스 규칙이 변경되어 1.2 배의 기준이 변경될 때 ProductDetail 의 updatePrice 메서드만 수정하면 된다.
*/
private void updatePrice(Long price) {
this.retailPrice = Math.round(price * 1.5);
this.supplyPrice = price;
}
- DDD로 캡슐화를 할 수 있어 비즈니스 규칙의 변경으로 인한 코드 변경을 최소화 할 수 있다.
Repository
// TODO: 레포지토리를 Interface 없이 바로 사용하는 예제
public interface ProductRepository extends JpaRepository<Product, Long> {
}
- 일반적으로 Repository를 사용할 때와 방식이 같다.
//TODO: Interface 계층에 존재하는 JpaRepository 를 분리하기 위한 Interface 구현 예제
@Repository
public interface UserRepository {
List<User> findAll();
Optional<User> findById(Long id);
User save(User user);
}
- 인프라 계층에 있는 User JpaRepository 인터페이스를 구현해서 사용
Service
@Service
public class UserProductDomainService {
/***
* TODO: 도메인 서비스 구현 예제 (비즈니스 로직이 아닙니다! 도메인 로직을 구현해야 합니다)
* User 엔티티에서 Product 엔티티를 불러올 수 없고,
* Product 엔티티에서 User 엔티티를 불러올 수 없으니까 아래와 같은 도메인 서비스를 구현합니다.
*/
public void connectUserProduct(User user, Product product) {
user.changeToManufacture();
product.updateManufacture(user.getName());
}
}
- 도메인 서비스이 필요한 부분에 대해서는 추가적으로 학습이 필요할 것 같다.
Application
Service
@Service
public class UserProductService {
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final UserProductDomainService userProductDomainService;
public UserProductService(ProductRepository productRepository, UserRepository userRepository, UserProductDomainService userProductDomainService) {
this.productRepository = productRepository;
this.userRepository = userRepository;
this.userProductDomainService = userProductDomainService;
}
//TODO: 도메인 서비스 사용 예제
@Transactional
public void connectProductToUser(Long userId, Long productId) {
final User user = userRepository.findById(userId).orElseThrow();
final Product product = productRepository.findById(productId).orElseThrow();
userProductDomainService.connectUserProduct(user, product);
}
}
- 2개의 Entity가 필요한 상황에서 도메인 서비스를 사용해 처리한다.
- 기본적인 비즈니스 로직에서 크게 벗어나지 않은 것을 확인할 수 있다.
public ProductDto getProduct(Long productId) {
return productRepository.findById(productId)
.map(ProductDto::of) // TODO: DTO 치환 로직 static 메서드 사용 예제
.orElseThrow();
}
- map을 사용해 Entity 객체를 DTO로 치환해 특정 데이터만 외부로 노출한다.
DTO
@AllArgsConstructor
@Getter
@Builder(access = AccessLevel.PRIVATE)
public class UserDto {
private String name;
private String email;
private String phoneNumber;
//TODO: Static Method 사용 예시
public static UserDto create(String name, String email, String phoneNumber) {
return UserDto.builder()
.name(name)
.email(email)
.phoneNumber(phoneNumber)
.build();
}
}
- DTO 변환을 static 메서드를 사용해서 비즈니스 로직이 변경에 영향을 받지 않도록 설계
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
@Builder(access = AccessLevel.PRIVATE)
public class ProductDto {
private Long productId;
private String name;
private Long retailPrice;
private Long supplyPrice;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ProductImageDto> images;
//TODO: 연관 관계 Depth 가 깊은 Entity 치환 예제
public static ProductDto of(Product product) {
return ProductDto.builder()
.productId(product.getId())
.name(product.getName())
.retailPrice(product.getDetail().getRetailPrice())
.supplyPrice(product.getDetail().getSupplyPrice())
.createdAt(product.getDateInfo().getCreatedAt())
.updatedAt(product.getDateInfo().getUpdatedAt())
// Option1. 내부 Entity 를 탐색하여 데이터 치환
.images(product.getDetail().getImages().stream().map(ProductImageDto::of).collect(Collectors.toList()))
// Option2. Root 에그리거트에 존재하는 함수를 이용한 데이터 치환
// .images(product.getImages().stream().map(ProductImageDto::of).collect(Collectors.toList()))
.build();
}
//TODO: DTO 치환 시 inner 클래스 구현 예제
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
public static class ProductImageDto {
private String name;
private String url;
// TODO: Builder 패턴이 항상 만능은 아닙니다. 클래스 내에 변수가 몇개 없을 경우는 생성자로 대체가 가능합니다.
public static ProductImageDto of(ProductImage image) {
return new ProductImageDto(image.getName(), image.getUrl());
}
}
}
- 연관 관계로 Depth가 깊은 경우 Entity를 치환하는 방법, Repository를 사용하지 않기 때문에 체이닝 메서드를 사용해야 한다.
Infrastructure
//TODO: JpaRepository 를 Interface 계층에 위시시키는 방법
public interface UserRepositoryImpl extends JpaRepository<User, Long>, UserRepository {
}
- 도메인 영역에서 구현해 사용하는 Jpa Repository인 것 같다.
Presentation
@Getter
@AllArgsConstructor
public class UserRequest {
private String username;
private String email;
private String phoneNumber;
//TODO: UserInterface 계층과 Application 계층간 Object 분리 방법
public UserDto toDTO() {
return UserDto.create(this.username, this.email, this.phoneNumber);
}
}
- Controller와 RequestDto가 해당 계층에 위치한다.
정리
- 아직 DDD에 익숙하지 않아 간단한 예제 코드를 보면서 각 계층의 역할을 온전히 이해하는 것이 어려웠다.
- 다양한 프로젝트에 적용해 보면서 익숙해지는 과정이 필요할 것 같다.
'자바 심화 > TIL' 카테고리의 다른 글
Open Route Service API 사용 (1) | 2024.12.16 |
---|---|
프로젝트 문제 해결(역직렬화, git 에러) (0) | 2024.12.12 |
SAGA 패턴 (1) | 2024.12.07 |
Kafka - 기초 (0) | 2024.12.06 |
Rabbit MQ (1) | 2024.12.05 |