본문 바로가기

자바 심화/TIL

DDD(Domain-Driven Design)

개요

Java 심화과정의 특강 주제였던 DDD에 대해 간단하게 정리하고 프로젝트에 적용시킬 수 있도록 구현 방법을 배워볼 것이다.

 

DDD(Domain-Driven Design)?

복잡한 소프트웨어 개발에서 도메인 중심으로 설계를 진행하는 접근 방식.

특징

  1. 도메인 중심 설계: 비즈니스 로직이 중심이 되며, 기술적 구현보다 도메인 모델을 우선한다.
  2. 유비쿼터스 언어 사용: 개발자와 비즈니스 전문가가 동일한 언어로 소통하여 오해를 줄인다.
  3. 계층화된 아키텍처: 애플리케이션 계층(프레젠테이션, 응용, 도메인, 인프라)으로 나눠 역할을 눈리한다.
  4. 도메인 모델의 명확한 경계: 바운디드 컨텍스트로 경계를 정의하고 모델 간 상호 작용을 관리한다.

장단점

  • 장점
    1. 비즈니스 로직 명확화: 복잡한 비즈니스 로직이 코드로 명확하게 표현된다.
    2. 유지보수성 향상: 도메인 지식을 반영한 설계로 코드를 이해하고 수정하기 쉽다.
    3. 확장성과 재사용성: 도메인 모델이 잘 정의되면 새로운 요구사항에 유연하게 대응할 수 있다.
    4. 팀 협업 강화: 비즈니스 전문가와 개발자가 공통 언어로 협력할 수 있다.
  • 단점
    1. 복잡한 초기 설계:초기 설계가 복잡하고 많은 도메인 지식이 필요하다.
    2. 고비용 개발: 학습 비용과 초기 개발 비용이 크며, 작은 프로젝트에는 과도할 수 있다.
    3. 조직 협업 필수: 도메인 전문가와의 긴밀한 협업이 필요해 협업 구조가 없는 조직에서는 어려움을 겪을 수 있다.

핵심 개념

 

  1. 도메인 (Domain): 해결하려는 특정 비즈니스 문제의 영역.
  2. 유비쿼터스 언어 (Ubiquitous Language): 개발자와 도메인 전문가가 공통으로 사용하는 언어로, 코드와 대화 모두에서 동일한 용어 사용을 지향.
  3. 엔터티 (Entity): 고유 식별자를 가진 객체로, 상태가 변해도 식별자는 유지.
  4. 값 객체 (Value Object): 식별자 없이 값으로만 비교되는 객체입니다. 상태가 불변이며 재사용이 가능.
  5. 애그리게이트 (Aggregate): 관련된 엔터티와 값 객체들의 집합으로, 일관성을 유지하기 위해 루트 엔터티 (Aggregate Root) 를 통해서만 접근이 허용.
  6. 도메인 서비스 (Domain Service): 특정 도메인 로직이 엔터티나 값 객체에 포함되기 어려운 경우, 서비스로 분리.
  7. 리포지토리 (Repository): 애그리게이트의 영속성을 관리하며, 데이터 저장소와 도메인 객체 간의 연결을 담당.
  8. 팩토리 (Factory): 복잡한 객체 생성 로직을 캡슐화하여 일관된 인스턴스 생성을 제공.
  9. 바운디드 컨텍스트 (Bounded Context): 도메인을 명확하게 구분하여 특정 컨텍스트 내에서만 유효한 도메인 모델을 정의.

적용 시 고려해야 할 사항

 

  1. 도메인 지식 수집: 비즈니스 전문가와의 지속적인 협업이 필수.
  2. 바운디드 컨텍스트 정의: 시스템 경계를 명확히 설정하고 컨텍스트 간 상호 작용을 관리.
  3. 복잡성 관리: 프로젝트가 충분히 복잡할 때만 적용해야 하며, 작은 프로젝트는 DDD가 과도할 수 있음.
  4. 조직의 지원 및 문화: 도메인 전문가와 개발자 간의 원활한 협업을 위한 조직 문화가 필요.
  5. 지속적인 리팩토링: 도메인 지식은 점진적으로 발전하므로, 리팩토링을 계획에 포함해야 함.

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