본문 바로가기

카테고리 없음

DDD 개념과 Boundary Context? Aggregate? Domain Model?

이번에 회사 프로젝트에서 DDD 설계 방식을 도입해서 백엔드 개발을 진행했다

처음에는 평소에 하던 백엔드 개발 방식과 무엇이 다른가 의아했지만

3,4개월이 지나고 관련 서적들을 읽으며 정리해보니 

내가 여태까지 했던 백엔드 개발은 객체지향 언어인 JAVA를 사용해서 절차지향적인 프로그램을 한 것이라는 생각이 들었다.

 

3,4개월동안 DDD로 프로젝트를 진행한 지금 

한번더 서적들과 사이드 프로젝트에 DDD를 적용해보며 

DDD에 대해서 정리해 보는 시간을 가지고자 한다.

 

😀 DDD (Domain Driven Development)

  • 비즈니스 Domain 별로 나누어 설계
  • Domain과 모듈 간에 낮은 결합도 / 높은 응집성
    • 비즈니스 정책 변경 시, 파일 한개만 수정해서 배포해도 전 시스템에 반영되도록

 

😀 DDD의 구성요소

Domain Model

  • 비즈니스 도메인의 서비스를 추상화한 설계도
  • 데이터와 기능을 구성요소로 가지고 이를 하나의 모델로 보여준다
  • 모든 비즈니스 로직을 도메인 모델로 위임하는 것이 포인트

Domain Model 예시

 

위 다이어그램은 하나의 예시이다

이번 프로젝트에서 휴가 시스템을 담당했었는데, 휴가 신청이라는 비즈니스를 Domain Model로 설계해보면 위와 같은 모델로 설계할 수 있다. 

  • Submit & SubmitDetail - 하나의 신청은 여러 신청상세건을 가지고
    • 예를 들어, 우리는 휴가 신청 한번 할 때 연차휴가 1일 / 포상휴가 1일 이렇게 두건(다건의 신청 상세)을 신청할 수 있다
  • Approval - 하나의 신청은 하나의 전자결재를 가진다
    • 예를 들어, 우리는 신청을 요청하면 팀장이 결재시스템을 통해 휴가를 승인할 수 있다

각 모델은 Data(멤버변수)와 비즈니스 로직(메서드)을 가지게 된다

그리고, 다른 연관성 있는 Domain 모델을 참조하는 형태로 모델이 Java Class로 설계된다

public class Submit {

	private Approval approval;
    private List<SubmitDetail> submitDetails;
    
    private Long sumbmitId;
    private String employeeNo;
    private String submitReason;
    
    .... 중략..
    
    public approve();
    public cancel();
    
    .... 중략..
   

}

 

Aggregate

  • 동일한 생명주기를 가지는 Domain Model들의 묶음

유져가 휴가 신청을 시작하면 아까 저 위의 Submit, SubmitDetail, Approval 도메인 모델은 한번에 메모리 상에 올라오고 영속화 된다

예를 들어

  1. 유져가 신청 버튼을 누르면
  2. 결재상태부터 신청 내용까지 메모리에 new 되었다가
  3. 모두 하나의 Transaction으로 DB에 들어가는

모두 같은 생명주기 선상에 있다

그래서 Submit, SubmitDetail, Approval 도메인 모델은 하나의 애그리거트에 속한다고 할 수 있다

 

두개의 다른 Aggregate 간에는

  • Dependancy가 없어야 한다

 

Bounded Context

  • 사용자, 프로세스, 정책 등을 고려하여 도메인 모델들을 묶은 하나의 경계
  • Aggregate의 집합

Boundary Context 예시

 

Boundary Context는 철저히 비즈니스 관점에서 나누어야 한다 (추후로 MSA의 기준이 될수도)

신청에는 신청을 관장하는 신청에 대한 설정(SubmitConfig)라는 도메인 모델이 있는데 

신청에 대한 설정은 유져의 신청 행위와는 다른 생명주기를 가지기 때문에 Submit, SubmitDetail, Approval과는 다른 Aggregate이다

 

따라서 신청 Boundary Context는 두개의 Aggregate를 가지고 있고

휴가는 신청과는 비즈니스적으로 다른 시스템으로 분류하여 다른 Boundary Context를 가지고 있다

 

두개의 Boundary Context 간에는 

  • Dependency가 없어야하고
  • 패키지적으로 분리

 

😀 DDD 아키텍처 - Layerd Architecture

DDD 개발방식으로 개발시 채택할 수 있는 아키텍터는 헥사고날 아키텍처와 레이어드 아키텍처가 있는데 

우리 프로젝트에서 채택했던 방식은 레이어드 아키텍처였다

 

Layerd 아키텍처

 

 

데이터 CUD 시의 플로우

  1. Presentation Layer로 들어온 Request를 Application의 Service 파라미터로 넘겨준다
  2. Service 초반에 Domain Model로 매핑한다
  3. Domain Model 멤버 메서드를 중심으로 비즈니스 로직을 실행한다
  4. 비즈니스 로직 실행 결과가 메모리상의 Java 객체(도메인 모델)에 세팅되면 Infrastructure Layer로 넘겨서 영속화 시킨다

데이터 조회 시 플로우

아래는 CQRS 방식으로 데이터를 읽지 않고 단일모델로 Read까지 할때를 가정

  1. Infrastructure에서는 영속화 스토리지(DB, 인메모리캐시 등) 에서 데이터를 select 해온다.
  2. 데이터들을 Domain Model로 매핑하여 Memory 위에 올려준다
  3. Applicaiton Layer(Service)는 Domain Model을 가지고 요청에 대응하며 Domain Model의 멤버 메서드들을 호출하며 비즈니스 로직 수행은 Domain Model에 위임힌다
  4. Presentation Layer는 Application Layer로 부터 반환된 DTO 혹은 로직처리가 완료된 Domain Model을 FE가 원하는 포맷으로 변경해서 HTTP Response

 

💻 실습 - DDD 방식으로 Service 하나 짜보기

회사에서 겪어본 DDD를 체화하기 위해 사이드 프로젝트에 적용해보았다

이번에 구현할 기능이 회원의 뱃지 관련 API였는데

비즈니스에 대해서 간략 설명하자면

 

 

 

유져는 내정보 화면에서 자기 가지고 있는 

뱃지를 수정할 수 있다

 

 

 

 

 

 

 

 

 

 

 

 

서비스에는 여러가지 뱃지가 있고

유져는 미션들을 통해서 뱃지를 획득할 수 있다

유져는 대표 뱃지를 설정할 수 있다

 

 

 

 

 

 

 

 

 

 

Boundary Context 는 Member(회원)이 되고

Boundary Context 하위 도메인들은 아래의 캡쳐와 같다

Domain Package 캡처

 

Member Context에서 Aggregate를 나누게 되면

  • 애그리거트 - 1
    • Member 회원
    • MemberSocialAccount 회원소셜로그인계정
  • 애그리거트 - 2
    • MemberFollow - 팔로우
  • 에그리거트 - 3
    • Badge - 뱃지
    • MemberBadge - 뱃지 보유 (다대다테이블)
  • 에그리거트 - 4
    • MemberPushSetting - 알림 설정
  • 애그리거트 - 5
    • MemberStatusHistory - 회원 상태 이력 
  • 애그리거트 - 6
    • MemberTermAgree - 회원 약관 동의

위의 애그리거트들의 객체는 어플리케이션 런타임에서 생명주기를 같이 한다는 얘기이고, 서로 참조 관계로 되어있다

뱃지 기능을 구현하기 위해 Aggregate - 3의 도메인 모델들을 구현해보면

ERD

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "BADGE")
public class Badge extends BaseAuditEntity {

  @OneToMany(mappedBy = "badge", fetch = FetchType.LAZY)
  protected List<MemberBadge> memberBadges;

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", nullable = false)
  private Long id;

  @Column(name = "status", nullable = false)
  private BadgeStatus badgeStatus;

  @Column(name = "name", nullable = false)
  private String name;

  @Column(name = "description")
  private String description;

  @Column(name = "active_hint")
  private String activeHint;

  @Column(name = "image_url")
  private String image;

  @Column(name = "sort_order")
  private int sortOrder;

  // 활성화된 뱃지인지 확인
  public boolean isActive() {
    return memberBadges != null && !memberBadges.isEmpty();
  }

  // 대표 뱃지인지 확인
  public boolean isMainBadge() {
    if(memberBadges == null || memberBadges.isEmpty()) return false;
    MemberBadge memberBadge = this.memberBadges.get(0);
    return Yn.TRUE.equals(memberBadge.getIsMain());
  }

  // 대표 뱃지로 등록
  public void registerToMain() {
    if(memberBadges ==null || memberBadges.isEmpty()) throw new RestApiException(MEMBER_BADGE_NOT_EXIST);
    MemberBadge memberBadge = this.memberBadges.get(0);
    memberBadge.registerToMainBadge();
  }

  // 대표 뱃지 취소
  public void cancelMain() {
    if(memberBadges ==null || memberBadges.isEmpty()) throw new RestApiException(MEMBER_BADGE_NOT_EXIST);
    MemberBadge memberBadge = this.memberBadges.get(0);
    memberBadge.cancelMainBadge();
  }


}

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "MEMBER_BADGE")
public class MemberBadge extends BaseAuditEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", nullable = false)
  private Long id;

  @Column(name = "member_id")
  private Long memberId;

  @ManyToOne
  @JoinColumn(name = "badge_id", nullable = false)
  private Badge badge;

  @Column(name = "main_yn", nullable = false)
  private Yn isMain;

  public void registerToMainBadge() {
    this.isMain = Yn.TRUE;
  }

  public void cancelMainBadge() {
    this.isMain = Yn.FALSE;
  }

}

 

하나의 애그리거트에 속한 Badge와 MemberBadge 도메인 모델은 생명주기를 같이 하므로 

  1. 조회시 아래의 프로세스를 따른다
    Infrastructure 레이어에서 저장소로부터 Data 조회
  2. Badge가 MemberBadge를 참조하는 형태로 Domain 모델로 Mapping 하여 메모리에 올리기
  3. Application 레이어로 전달
@Repository
@RequiredArgsConstructor
public class QBadgeJpaRepositoryImpl implements QBadgeJpaRepository {

  private final JPAQueryFactory jpaQueryFactory;

  private QBadge qBadge = QBadge.badge;
  private QMemberBadge qMemberBadge = QMemberBadge.memberBadge;

  @Override
  public List<Badge> findAllByMemberId(Long memberId) {
    return jpaQueryFactory.select(qBadge)
        .from(qBadge)
        .leftJoin(qBadge, qMemberBadge.badge)
        .where(qMemberBadge.memberId.eq(memberId))
        .orderBy(qBadge.sortOrder.asc())
        .fetch();
  }
}

 

QueryDSL로 Join select문을 만들어서

Badge 도메인 모델(MemberBadge를 참조하고 있는)로 매핑하여 반환한다 

 

 

Aggregate Root

  • 애그리거트에 속한 모든 도메인 객체가 일관된 상태를 유지하기 위해 애그리거트 전체를 관리할 하나의 주체 즉 하나의 대표 도메인 모델

 

눈여겨 보아야할 부분은 Badge Entity이다

비즈니스 로직을 처리하는 도메인 멤버 메서드들을 살펴보면, Badge 를 통해서 MemberBadge의 값이 조작되는 것을 볼 수 있다

  • Badge 도메인 모델은 Aggregate Root이고
  • Aggregate Root를 통해서만 도메인 로직을 구현해야함
@Service
@RequiredArgsConstructor
public class BadgeService {

  private final BadgeRepository badgeRepository;
  private final MemberRepository memberRepository;

  @Transactional
  public void updateMainBadge(Long memberId, Long badgeId) {

    Member member = memberRepository.getMember(memberId, MemberStatus.NORMAL)
        .orElseThrow(() -> new RestApiException(MEMBER_NOT_EXIST_BADGE));

    if(member.isLazyBadgeAcquireLevel()) throw new RestApiException(LAZY_NOT_AVAIL_UPDATE_MAIN_BADGE);

    List<Badge> badges = badgeRepository.getBadges(memberId);
    
    Optional<Badge> oldMainBadge = badges.stream()
        .filter(Badge::isMainBadge)
        .findFirst();
    Badge newMainBadge = badges.stream()
        .filter(e -> e.getId().equals(badgeId))
        .findFirst()
        .orElseThrow(() -> new RestApiException(BADGE_NOT_EXIST));

    oldMainBadge.ifPresent(Badge::cancelMain);

    newMainBadge.registerToMain();

    badgeRepository.save(badges);

  }

}

 

Service 단에서는 MemberBadge에 직접 접근하지 않고, 

애그리거트 Root인 Badge를 통해서 Badge와 MemberBadge의 데이터를 관리하고

하나의 생명주기속에서 일관성을 유지할 수 있게 해준다

 

만약 MemberBage가 Service 단에서 사용된다면

  @Transactional
  public void updateMainBadge(Long memberId, Long badgeId) {

    Member member = memberRepository.getMember(memberId, MemberStatus.NORMAL)
        .orElseThrow(() -> new RestApiException(MEMBER_NOT_EXIST_BADGE));

    if(member.isLazyBadgeAcquireLevel()) throw new RestApiException(LAZY_NOT_AVAIL_UPDATE_MAIN_BADGE);

    List<Badge> badges = badgeRepository.getBadges(memberId);
    
    Optional<Badge> oldMainBadge = badges.stream()
        .filter(Badge::isMainBadge)
        .findFirst();
    Badge newMainBadge = badges.stream()
        .filter(e -> e.getId().equals(badgeId))
        .findFirst()
        .orElseThrow(() -> new RestApiException(BADGE_NOT_EXIST));

    if(oldMainBadge.isPresent()) {
    	if(oldMainBadge.getMemberBadges() ==null || oldMainBadge.getMemberBadges().isEmpty()) throw new RestApiException(MEMBER_BADGE_NOT_EXIST);
        MemberBadge memberBadge = this.memberBadges.get(0);
        memberBadge.cancelMainBadge();
    }

    if(newMainBadge.getMemberBadges() ==null || newMainBadge.getMemberBadges().isEmpty()) throw new RestApiException(MEMBER_BADGE_NOT_EXIST);
    MemberBadge memberBadge = this.memberBadges.get(0);
    memberBadge.registerToMainBadge();
    
    badgeRepository.save(badges);

  }

 

  • Use Case 중심으로 간단하게 전개되어야할 Service 로직이 복잡해진다
  • 도메인의 데이터 변경하는 로직을 도메인에 위임하지 않고, Service에서 모두 처리하는 절차형 프로그래밍이 된다
  • 도메인 로직이 어플리케이션(Service) 영역 혹은 Presentation 영역으로 흩어지게 된다
  • 비즈니스 로직이 도메인 모델에서 원포인트로 관리되지 않았기 때문에, 나중에 대표 뱃지로 지정하는 비즈니스 로직이 바뀌게 된다면, 대표 뱃지로 지정하는 비즈니스 로직이 있는 Service / Presentation 단 코드를 모두 수정해야함

위와 같이 객체지향 원리를 깨먹으면서, 단점들이 많아진다

 

객체 참조가 아닌 필드 참조를 통한 타 애그리거트 참조

  • 애그리거트 - 1
    • Member 회원
    • MemberSocialAccount 회원소셜로그인계정
  • 에그리거트 - 3
    • Badge - 뱃지
    • MemberBadge - 뱃지 보유 (다대다테이블)

아까 위에서 우리는 애그리거트1, 3을 비즈니스 적으로 분류했다

즉 애플리케이션 런타임에서 두 애그리거트는 생명주기가 다르다는 것이다

예를 들어,

  • 회원 가입을 할 때 Member와 MemberSocialAccount이 생기고 
  • 대표 뱃지를 설정할 때는 memberId만 알면 된다
  • 굳이 다른 회원정보(자기소개, 상태 등)을 알 필요가 없다

ERD

@Table(name = "MEMBER_BADGE")
public class MemberBadge extends BaseAuditEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", nullable = false)
  private Long id;

  @Column(name = "member_id")
  private Long memberId;
  
  ... 생략

 

그렇기 때문에

  • Aggregate간의 의존 결합도를 낮추고 
  • 생명주기를 확실히 하기 위해서 MemberBadge는 Member 객체 참조를 하지 않음
  • MemberBadge에서 Member의 도메인 메서드에 접근해서 임의로 데이터 수정 방지

위의 장점을 가지고 DB의 Raw한 데이터 member_id를 참조한다

badgeRepository.save(badges);

 

그렇기 때문에 위와 같이 Repository save메서드에서는 두개의 테이블만 upsert 해주면 된다

 

 

이렇게 애그리거트 간의 결합도를 끊어내면 저장소간의 다양화도 쉽다

요즘은 한 Boundary Context 안의 모든 도메인 엔티티를 RDBMS에만 적재하는 것이 아닐 수도 있다

  • Follow 정보 (애그리거트-2)는 Mongo DB를 사용해서 NoSQL에 저장
  • Member 정보 (애그리거트-1)은 MySQL
  • Badge 정보 (애그리거트-3)은 Redis에 Cache 

위와 같이 하나의 Boundary Context 내에서도 관심사나 데이터의 종류에 따라서 Aggregate간 저장소 종류가 다를 수 있다

애그리거트 간에 결합도를 끊어냈기 때문에 위에 적재하는 로직도 쉽게 분리하여 구현 가능하다

 

 

😀 DDD의 장점

DDD의 원리 + 구성요소의 개념에 대해 학습하고

이를 사이드 프로젝트에 적용해서 한번더 정리해 볼 수 있었다

 

적용해보고, 유지보수해보니 장점은

  • 정책 변화에 빠르게 대응 가능
    • 비즈니스 정책 변경시 도메인 모델만 보면된다
    • 도메인 모델에 비즈니스 로직을 철저히 위임해놨기 때문에 해당 멤버 메서드 로직만 수정하면 됨
  • 물리적 서버 분리 용이
    • 서비스가 커져서 MSA로 전환 필요시 용이
    • Boundary Context간 결합도가 낮고, 도메인 모델 설계로 응집도가 높기 때문에 서비스 찢어내기가 용이
      • Boundary Context간 협력은 아래의 설계 방식 사용
        • 복합 서비스 구성 for 의존성 낮추기 + 객체간 사이클 방지
        • Spring Data를 활용하여 도메인 이벤트 퍼블리싱

 

😀 DDD를 위한 사전 작업

기능 구현 전, 고려할게 많다보니 팀으로 일한다면 DDD를 위해 협의할 점이 많다

 

  • 유비쿼터스 용어 정의
    • 기획자, 디자이너, 개발자 간의 소통을 위해 용어 통일
    • 용어 사전 같이 유비쿼터스 용어를 정의하여 해당 용어 기반으로 통일
    • 비즈니스가 바뀌어 유져에게 보이는 서비스명이 변경되더라도 조직 내부적으로는 원활한 소통 가능
  • Boundary Context와 Domain Model에 대한 합의
    • 개발자 전원이 비즈니스 도메인에 대한 이해가 필요
    • 오랜 시간동안 도메인 설계에 대한 공수가 필요
      • 도메인의 생명주기에 대한 논의
      • 낮은 결합성/높은 응집도의 시스템 설계를 위해서는 의존성 고려 등 다양한 상호 논의가 필요