오늘은 프로젝트에 DDD를 도입하며 고민하고 공부했던 적용기를 다뤄보려 한다.
도메인 주도 개발이란
DDD(Domain-Driven Design) 또는 도메인 주도 설계라고 부르며 도메인 모델을 중심에 놓고 설계하는 방식을 일컫는다.
도메인이란 무엇일까
도메인은 특정 문제 영역을 의미하며, 소프트웨어가 해결하려는 비즈니스 영역을 의미한다. 예를 들어, 이커머스 플랫폼을 개발한다면 이커머스 플랫폼은 개발해야하는 대상이 되고 이를 도메인이라고 한다.
도메인은 또 하위 도메인으로 나눠질수 있는데, 주문, 결제, 배송, 고객 관리 등이 해당 도메인에 포함될 수 있다.
각 하위 도메인은 독립적으로 기능을 제공할 수도 있고, 다른 하위 도메인과 연동하여 기능을 제공하기도 한다.
예를 들어, 카탈로그 도메인은 상품 목록을 제공하고, 주문 도메인은 고객의 주문을 처리하며, 결제와 배송 도메인은 이를 지원한다.
모든 하위 도메인을 구현할 필요는 없고 외부 서비스를 사용하기도 한다.
하위 도메인은 비즈니스의 성격에 따라 다르게 구성될 수 있다. 예를 들어, 대형 장비를 판매하는 B2B 기업이라면 배송 추적이나 결제 기능이 필요 없을 수도 있지만, B2C 기업인 쿠팡이라면 배송 추적과 결제 기능이 필수적이다.
이처럼 각 비즈니스별 하위 도메인은 다르며, 하위 도메인들의 유기적인 협력으로 소프트웨어를 바라보는 것이 DDD의 핵심이다.
특징
DDD는 도메인 모델을 중심으로 설계와 구현을 한다.
도메인 모델이란 비즈니스 요구사항을 반영한 비즈니스 로직, 데이터, 규칙을 포함하는 객체들의 집합으로 도메인을 표현하는 모델을 의미한다.
즉, 내가 구현하는 상위 도메인을 객제치향적으로 설계하고 구현한다는 뜻이다.
프로젝트에 적용
현재 개발 중인 Pointer 서비스의 도메인 설계를 진행하며 DDD를 적용해 보았다.
포인터(Pointer) 서비스의 설계를 같이 해보면서 따라가보자.
Pointer는 고2 학생들에게 매일 기출 3 문항에 대해 자세한 분석을 제공하는 서비스이다.
이 서비스의 하위 도메인을 크게 나누어보면 아래와 같다.
이를 더 잘게 쪼개보면
위와 같이 쪼갤 수 있었다.
이 중 문항 세트 도메인의 설계를 집중적으로 다뤄보겠다.
문항 세트를 자세히 보자.
문항 세트 도메인의 핵심 기능과 개념은 위와 같이 '세트, 문항, 새끼문항, 개념 태그' 4가지로 이루어져있다.
- 세트(ProblemSet): 문항을 묶는 단위로, 타이틀과 3개의 문항으로 구성됨
- 문항(Problem): 학생이 푸는 문제로, 새끼문항을 포함할 수 있음
- 새끼문항(ChildProblem): 문항의 하위 문제
- 개념 태그(ConceptTag): 문항 및 새끼문항에 부여되는 개념 태그
이제 요구사항을 분석하고, 도메인 모델을 도출해 보자.
도메인 모델 도출
DDD에서는 요구사항을 바탕으로 도메인 모델을 도출한다.
문항과 새끼문항의 요구사항의 일부를 가져와 예시로 들어보겠다.
(지금부터 나오는 요구사항은 각 도메인의 요구사항 중 일부임을 밝힌다.)
[문항과 새끼문항 요구사항]
- 문항은 한 개 이상의 새끼 문항을 가질 수 있다.
- 문항과 새끼 문항은 문항 이미지, 정답을 가지며 한 개 이상의 개념 태그를 가질 수 있다
- 정답은 객관식, 주관식이 있으며 객관식일 경우 1~5, 주관식일 경우 0~999의 범위를 갖는다.
위 요구사항을 만족시키는 새끼문항(ChildProblem)을 객체로 구현해보자.
@Getter
public class ChildProblem {
private String imageUrl;
private Answer answer;
private Set<ConceptTag> conceptTags;
private void ChildProblem(String imageUrl, String answer, Set<ConceptTag> conceptTags){
this.imageUrl = imageUrl;
this.answer = new Answer(answer);
this.conceptTags = conceptTags;
}
}
앞선 요구사항에는 ‘정답은 객관식, 주관식이 있으며 객관식일 경우 1~5, 주관식일 경우 0~999의 범위를 갖는다.’ 라는 요구사항이 있었다.
ChildProblem에는 Answer라는 객체로 이를 구현하고 있다.
요구사항에 따르면, 객관식, 주관식에 따라서 유효성 검증 방식이 달라지고 있다.
public class Answer {
private AnswerType answerType;
private String value;
public Answer(String value, AnswerType answerType) {
if (value == null) {
throw new InvalidValueException(ErrorCode.BLANK_INPUT_VALUE);
}
validateByType(value, answerType);
this.value = value;
}
private void validateByType(String answer, AnswerType answerType) {
if (answerType == AnswerType.MULTIPLE_CHOICE) {
if (!answer.matches("^[1-5]*$")) {
throw new InvalidValueException(ErrorCode.INVALID_MULTIPLE_CHOICE_ANSWER);
}
}
if (answerType == AnswerType.SHORT_NUMBER_ANSWER) {
try {
int numericAnswer = Integer.parseInt(answer);
if (numericAnswer < 0 || numericAnswer > 999) {
throw new InvalidValueException(ErrorCode.INVALID_SHORT_NUMBER_ANSWER);
}
} catch (NumberFormatException e) {
throw new InvalidValueException(ErrorCode.INVALID_SHORT_NUMBER_ANSWER);
}
}
}
}
주관식, 객관식의 타입을 나타내는 AnswerType Enum 객체와 value를 통해서 생성자에서 유효성 판단을 거친 뒤 초기화를 완료하게 구현하였다.
[문항 요구사항]
- 새끼 문항은 문항이 생성된 이후에 추가되며 문항이 삭제되면 같이 삭제된다.
- 문항의 개념 태그 리스트는 새끼 문항의 개념 태그 리스트들의 합집합이다.
문항(Problem)의 추가 요구사항이다. 마찬가지로 객체로 구현해보자.
@public class Problem {
private String imageUrl;
private Answer answer;
private Set<ConceptTag> conceptTags;
private List<ChildProblem> childProblems;
private void Problem(String imageUrl, String answer, Set<ConceptTag> conceptTags){
this.imageUrl = imageUrl;
this.answer = new Answer(answer);
this.conceptTags = conceptTags;
}
public void updateChildProblem(List<ChildProblem> inputChildProblems) {
List<ChildProblem> mutableChildProblems = new ArrayList<>(inputChildProblems);
this.childProblems = mutableChildProblems;
this.conceptTags.clear();
mutableChildProblems.forEach(childProblem -> conceptTags.addAll(childProblem.getConceptTags()));
}
}
새끼 문항은 문항이 생성된 이후에 추가 된다는 요구사항이 있어 생성자에는 childProblems를 초기화해주지 않고 update 메서드로 따로 구현하였다.
'문항의 개념 태그 리스트는 새끼 문항의 개념 태그 리스트들의 합집합이다.' 를 updateChildProblem()에서 같이 구현해주었다.
- 세트는 타이틀과 3개의 문항으로 이루어진다.
- 타이틀은 빈 값으로 입력 시 ‘제목 없음’으로 등록된다.
- 세트가 컨펌이 완료되면 세트에 속한 문항의 새끼문항은 수정할 수 없다.
세트의 요구사항은 위와 같다.
public class ProblemSet extends BaseEntity {
private String title;
private boolean isConfirmed;
private List<Problem> problems = new ArrayList<>();
public ProblemSet(String title, List<Problem> problems) {
this.title = verifyTitle(title);
this.isConfirmed = false;
this.problems = problems;
}
private static String verifyTitle(String title) {
return (title == null || title.trim().isEmpty()) ? "제목 없음" : title;
}
public void toggleConfirm() {
if (!this.confirmStatus) {
problems.stream()
.filter(Problem.toggleConfirm())
.toList();
}
this.confirmStatus = !this.confirmStatus;
}
}
세트가 컨펌이 완료되면 세트에 속한 문항의 새끼문항은 수정할 수 없다. 이를 위해 문항에서 isConfirm이라는 상태가 필요하다.
toggleCofirm() 메서드를 보면 problems 를 돌면서 문항의 confirm 상태를 togggle해주고 있다.
문항에도 변경사항을 적용해주자.
public class Problem {
... 다른 필드
private boolean isConfirmed;
private void Problem(String imageUrl, String answer, Set<ConceptTag> conceptTags, boolean isConfirmed){
... 다른 필드
this.confirmed = false;
}
... 다른 메서드
public void toggleConfirm() {
this.confirmStatus = !this.confirmStatus;
}
}
이로써 우리는 비즈니스 요구사항을 반영한 비즈니스 로직, 데이터, 규칙을 포함하는 객체들 만들었다.
즉, 도메인 모델을 도출했다. 우리는 이 객체들을 데이터베이스에 저장을 해야한다.
DB에 도메일 모델 객체 저장
도메인 모델 객체를 도출했으니 이를 DB에 저장해야한다.
이를 위해서는 도메인 모델 객체에서 엔티티와 밸류를 구분해야한다.
엔티티와 벨류
도출한 모델은 크게 엔티티와 벨류로 나눌 수 있다.
아까 도출한 모델에는 엔티티도 있고 벨류도 존재하는데 둘의 차이점을 알아보자.
엔티티
엔티티(Entity)는 고유한 식별자(Identity)를 가지며, 내부 데이터가 변경될 수 있는 객체이다.
즉, 같은 속성을 가지더라도 식별자가 다르면 다른 객체로 간주된다.
포인터 서비스는 위 4가지의 엔티티를 가지고 있다.
위 4가지가 엔티티인 이유는 세트, 문항, 새끼문항은 수정한다고 다른 객체가 되면 안되기 때문이다.
개념 태그도 여러 문항, 새끼문항에서 사용되는 특성상 한번의 수정이 모든 문항에 적용되야되기 때문에 엔티티로 관리해야한다.
식별자 생성 방법
JPA를 사용하고 있다면
@GeneratedValue(strategy = GenerationType.IDENTITY)
를 통해 자동 생성되는 ID 값을 대부분 사용할 것이다.
이 방법과 함께 식별자를 생성하는 방식에 여러가지가 있는데
- DB에서 생성하는 Auto-Increment or Sequence
- UUID나 twitter의 snowflake
- custom 생성기 사용
같은 방법들이 있다.
DB에서 생성하는 Auto-Increment or Sequence는 많이들 사용하는 방법이고
UUID나 twitter의 snowflake는 분산 환경이며 서비스 트래픽이 많을 때 고려해볼 수 있다.
세번째는 custom 생성기 사용인데,
서비스의 특성에 따라 커스텀 식별자 생성기를 사용할 수도 있다. 하지만, 특정한 의미를 담은 자연키(예: 주민등록번호, 주문번호 등)를 PK로 사용하는 것은 권장하지 않는다.
자연키를 PK로 사용하면 안 되는 이유
- 비즈니스 요구사항이 변경되면 PK까지 수정해야 하는 경우가 발생할 수 있다.
- 시스템 전반에서 PK가 사용되므로, 운영 중에 변경이 어렵고 서비스 안정성에 영향을 줄 수 있다.
권장하는 방식
- Auto-Increment 또는 Sequence를 활용한 대체키 사용
- 도메인에서 의미를 가지는 키(주문번호 등)는 PK가 아닌 별도의 컬럼으로 관리
식별자는 시스템 전반에서 사용되므로, 변경 가능성을 최소화하고 유연한 설계를 고려하는 것이 중요하다.
최근 PK를 직접 생성하는 고유한 자연키로 사용하였다가 키 생성 요구사항이 변경되어 서비스 변경이 유동적이지 못하는 문제를 겪었었다,,
벨류
값 객체(Value Object)라고 불리는 벨류는 고유한 식별자가 없으며, 객체의 속성이 같다면 동일한 객체로 취급된다.
public class Answer {
private AnswerType answerType;
private String value;
}
포인터 서비스에서는 아까 만들었던 Answer 객체 같은 것인데, AnswerType과 value라는 다른 타입의 2개의 데이터를 담지만 2개의 필드 모두 정답의 유효성을 검증하는데 같이 쓰이게 되며 하나의 개념을 표현하게 되므로 적합하다고 볼 수 있다.
만약 이를 벨류 객체로 만들지 않았다면 다음과 같을 것이다.
@public class Problem {
private String imageUrl;
private AnswerType answerType;
private String answerValue;
private Set<ConceptTag> conceptTags;
private List<ChildProblem> childProblems;
}
Problem의 Answer 필드도 answerType과 answerValue로 변경해주어야할 것이다.
물론, 코드가 돌아가는 것에는 무리가 없을 테지만 정답의 타입에 따른 유효성 검증도 Problem 객체로 올라오면서 Problem이 수행하는 책임이 많아질 것이다.
이에 따라, Problem 객체를 이해하는 인지 비용이 올라갈 수 있다.
벨류타입이라고 꼭 2개 이상의 데이터를 담아야하는건 아니다.
public class ProblemSet extends BaseEntity {
...다른 필드
private String title;
public ProblemSet(String title, List<Problem> problems) {
...다른 필드
this.title = verifyTitle(title);
}
private static String verifyTitle(String title) {
return (title == null || title.trim().isEmpty()) ? "제목 없음" : title;
}
...다른 메서드
}
세트의 위와 같은 '타이틀은 빈 값으로 입력 시 ‘제목 없음’으로 등록된다.' 이라는 요구사항을 만족시키기 위한 기능들을 모아 아래와 같이 밸류 객체를 만들 수도 있다,
public class Title {
private static final String DEFAULT_TITLE = "제목 없음";
private String title;
public Title(String title) {
this.title = verifyTitle(title);
}
private static String verifyTitle(String title) {
return (title == null || title.trim().isEmpty()) ? DEFAULT_TITLE : title;
}
}
이처럼 유효성 검증이 필요한 데이터를 값 객체로 만들어 책임을 분리할 수도 있다.
애그리거트
현재 문항세트의 도메인 모델이다.
조금씩 복잡해지고 있다.
서비스가 커질수록 도메인 모델은 커지게되고 많은 엔티티와 벨류가 출현하게 된다.
저런 문항세트 같은 하위 도메인도 얼마나 많이 생기겠는가.
도메인이 복잡해지면 개발자가 전체 구조가 아닌 한 개의 엔티티와 벨류에만 집중하게되는,
즉 상위 수준에서 모델을 보지 못하고 개별 요소에만 초점을 맞춰 큰 틀에서 모델 관리가 안되는 상황이 올 수 있다.
우리가 서울시를 지도로 볼때 대축적으로 확대에서 본다면 이해하기가 어렵지만
지역구 단위로 소축적으로 본다면 이해하기 쉬워진다.
우리는 위와 같이 각 하위 개념으로 표현한 모델을 묶어 상위 개념 모델로 표현할 것이다.
이를 애그리거트라고 부른다.
하지만 어떻게 애그리거트로 묶어야할까?
애그리거트는 관련된 모델을 하나로 모았기 때문에 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 갖게 된다.
위와 같은 2개의 모델은 항상 같이 생성되고 같이 삭제된다.
따라서 하나의 애그리거트로 묶을 수 있다.
또한 도메인 요구사항에 따라 함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높다.
문항과 새끼문항은 각각 엔티티이고 생명주기도 완전 같진 않다.
문항이 생성되고 나중에 새끼문항이 추가될 수도 있으며 새끼문항이 삭제된다고 문항이 삭제되지도 않는다.
하지만, 이 둘은 굉장히 밀접해 같이 변경되는 경우가 많은데,
- 새끼문항에 개념 태그가 달리면 문항의 개념태그도 달라진다.
- 문항이 컨펌되면 새끼문항은 수정할 수 없다.
- 문항이 삭제되면 새끼문항도 삭제된다.
이런 경우에는 각각이 엔티티라도 같은 애그리거트에 묶는다.
하지만 주의할 점은 ‘A가 B를 갖는다’ 라는 요구사항이 있다고 항상 같은 에그리거트는 아니다.
대표적인 예시가 상품과 리뷰의 관계인데, 상품이 리뷰를 갖긴 하지만 이둘은 함께 생성되지도, 함께 변경되지도 않는다.
게다가 생성 주체도 상품 담당자와 고객으로 다르다.
우리 서비스에서 세트와 문항 사이의 관계도 마찬가지이다.
애그리거트로 각 도메인 모델을 묶으면 위와 같다.
애그리거트로 각 상위 모델로 묶었다면 우리는 이제부터 애그리거튼 간의 관계로 도메인 모델을 이해하고 구현하게된다.
루트 엔티티
하나의 애그리거트는 자신에 속한 모든 객체를 관리하는 루트 엔티티를 가지며, 루트 엔티티를 통해서만 애그리거트 내 엔티티나 벨류 객체에 접근할 수 있다.
public class Problem {
public void update(Answer answer, List<ChildProblem> childProblem) {
// Problem의 update를 통해서만 정답과 childProblem을 수정할 수 있다.
}
}
이를 통해 애그리거튼 단위로 구현을 캡슐화할 수 있다.
이처럼 루트 엔티티를 통해 애그리거트의 일관성을 깨지 않고 한 곳에서 관리할 수 있다.
따라서 애그리거트에 속하는 객체들의 setter를 함부로 외부로 노출하면 안된다.
이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.
다음 2가지를 습관화하자.
- 단순 필드 변경을 하는 Set 메서드를 public으로 만들지 않는다.
- 벨류 타입은 불변으로 한다. (수정 시 객체 자체를 교체)
공개 set 메서드는 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현영역으로 분산시키는 원인이 된다.
이는 코드 응집도를 떨어트려 유지보수를 어렵게 만든다.
만든다면 비즈니스 로직이 잘 들어나는 메서드 이름을 붙여주자 (changePassword, cancel, confirm등)
트랜잭션 범위
트랜잭션 범위는 작을 수록 좋다.
한 트랜잭션이 한 개의 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것은 다르다.
수정하는 테이블이 많아질 수록 잠금 대상이 많아지고 동시에 처리할 수 있는 트랜잭션 개수가 떨어진다는 것을 의미한다.
이는 전체적인 처리량을 떨어트린다.
따라서 하나의 트랜잭션에서는 하나의 애그리거트만 수정하도록 하자.
(주문의 배송지 정보를 수정하면서 사용자의 주소까지 변경하지말라는 것이다)
만약 어쩔 수 없이 2개의 애그리거트를 동시에 수정해야한다면 하나의 애그리거트에서 다른 애그리거트를 수정하지말고 응용 서비스를 만들어서 구현하자.
두 개의 트랜잭션을 분리해야한다면 도메인 이벤트를 사용하면 동기, 비동기와 상관없이 분리할 수 있다.
에그리거트간 연관 관계
문항, 새끼문항, 개념태그의 연관관계를 맺어보자.
애그리거트 안에 속한 엔티티끼리는 필드 참조로, 다른 애그리거트는 ID 참조를 한다.
애그리거트 간 ID 참조
DDD에서 다른 애그리거트 간 연관관계는 ID 참조를 통해 해결한다.
첫번째 이유
한 트랜잭션에서 여러 애그리거트의 수정을 명시적으로 피하기위함이다.
problem.getConceptTags.get(0).updateName();
위와 같이 다른 애그리거트를 직점 참조하여 수정하는 일을 사전에 방지한다.
이는 에그리거트간 경계를 명확히하고 애그리거트 간 물리적인 경계를 끊어줘 모델의 복잡성을 낮춰준다.
두번째 이유
JPA를 사용하면 참조한 객체를 Eager 혹은 Lazy 로딩을 하게 되는데 이에 대한 고민을 없애준다.
JPA를 사용하면 함께 보여주어야할 때는 eager 로딩을 통해 같이 조회하고, 같이 조회할 필요가 없을 때는 lazy로딩을 하면 되는데 이를 고려해서 JPQL 등을 이용해서 로딩 전략을 구현해야한다.
id 참조를 하면 같이 조회할 때에 join을 해서 가져오기만 하면된다. (단순해짐)
하지만, 이 때문에 N+1문제가 반드시 발생하니 JPQL이나 QueryDSL으로 fetch join을 통해 하나의 쿼리로 가져오는 것이 좋다.
세번째 이유
DBMS의 확장이다.
서비스가 커지다보면 DB도 확장되며 하위 도메인별로 시스템이 분리되기 시작한다.
각 하위 도메인이 같은 DBMS에 없을 수도 있으며 한 하위 도메인은 Mysql을 사용하는데 다른 하위 도메인은 MongoDB를 쓸 수도 있다.
애그리거트 내부에서의 객체 참조
애그리거트 내부의 객체는 루트 엔티티에서 모두 관리한다.
같은 엔티티 끼리라도 루트 엔티티가 내부의 엔티티를 관리하게 되는 것이다.
따라서 애그리거트 내부에서 엔티티끼리는 객체 참조로 구현하였다.
이제 실제 연관관계를 맺어보자.
개념 태그 <-> 문항 관계 (다대다)
문항과 개념태그는 다대다 관계이므로 중간테이블이 필요하다.
이때 중간테이블을 만들기 전 두 관계가 양방향 연관관계가 필요한가 생각해볼 필요가 있다.
개념적으로 다대다 관계라고 구현에서까지 다대다 관계가 필요하지 않을 수 있기 때문이다.
개념 태그 → 문항 관계
특정 개념 태그에 속한 문항을 조회해야하는 요구사항이 있다면 아래와 같이 구현을 생각해볼 수 있다.
개념 태그에서 문항을 참조하게 된다면 ID 참조를 하게 될 것이다.
public class ConceptTag {
private Set<Long> problemIds = new HashSet<>();
}
하지만 위 방식에는 하나의 문제가 발생한다.
개념 태그에 연결된 문항이 많아질 경우 (예를 들어 수 만건 정도) 모든 문항 ID를 한 번에 조회해야 하는 성능 문제가 발생한다.
이는 메모리에 수 만건의 데이터가 적재되는 사태를 발생시킨다.
이는 페이징을 통해 성능 튜닝을 할 수 있지만, 현재 특정 개념 태그에 속한 문항을 조회해야하는 요구사항도 없기 때문에 개념 태그는 문항을 알 필요가 없다.
문항 → 개념 태그 관계
문항은 자신에게 달린 개념 태그를 반드시 알아야 하므로, 문항에서 개념 태그를 조회하는 단방향 연관관계를 설계가 바람직하다.
public class Problem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "problem_id")
private Long id;
@ElementCollection
@CollectionTable(name = "problem_concept", joinColumns = @JoinColumn(name = "concept_tag_id"))
Set<Long> conceptTagIds;
}
JPA를 활용하면 중간 테이블을 따로 객체로 구현하지않고 단방향 연관관계를 맺을 수 있다.
@ElementCollection는 해당 컬렉션이 별도의 테이블로 생성되게 해주며 @CollectionTable은 해당 테이블의 속성을 설정해준다.
문항<->새끼 문항 (일대다 관계)
보통 1:N 관계에서는 다대일로 구현을 하고 FK를 가지고 있는 '다'쪽에 연관관계 주인을 잡아 영속성을 관리한다.
하지만 문항 애그리거트의 루트 엔티티는 문항이고 문항 객체는 자신이 가진 새끼문항을 생성 및 관리객체를 위해 참조하고 있어야했다.
또한, 새끼문항은 자신이 속한 문항 객체를 알 필요가 없었다.
이에 따라 '일'쪽이 연관관계 주인이 되는 문항 → 새끼 문항의 단방향 연관관계를 구성하였다.
public class Problem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "problem_id")
private Long id;
@ElementCollection
@CollectionTable(name = "problem_concept", joinColumns = @JoinColumn(name = "concept_tag_id"))
Set<Long> conceptTagIds;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "problem_id")
@OrderColumn(name = "sequence")
private List<ChildProblem> childProblems = new ArrayList<>();
}
위와 같이 구현하고 ChildProblem 객체에는 Problem을 참조하지 않게 하였다.
JPA는 일대다 관계에서 '다'쪽에 연관관계 매핑이 되어 있지않고 '일'쪽에 mappedBy가 달려 있지 않으면 @JoinColumn이 달린 쪽을 자동으로 연관관계의 주인으로 설정한다.
결과
이로써 도메인 모델을 도출하고 엔티티와 밸류로 분류하며 엔티티간 연관관계까지 맺음으로써 DB에 도메인 모델을 저장할 준비를 끝냈다.
하지만, 이것이 끝이 아니고 한번 만들어둔 도메인 모델을 객체지향적으로 유지하려면 레이어드 아키텍처로 표현되는 각 영역의 책임을 잘 숙지하고 있어야한다.
각 영역의 책임
많은 개발자들이 스프링을 구현하며 레이어드 아키텍처를 차용한다.
이는 표현 영역, 응용영역, 인프라영역, 도메인 영역으로 나누어지게 된다.
표현영역
표현 영역은 DTO, 헤더 등의 클라이언트나 사용자한테 넘어오는 요청을 검증한다.
주요 검증 영역은 필수 값, 값의 형식, 범위 등을 검증할 수 있다.
응용영역
응용영역은 레포지터리에서 애그리거트를 조회하고 저장하는 역할을 수행한다.
주요 검증 영역은 데이터의 존재 유무 같은 논리적 오류를 검증한다.
또한, 도메인 영역의 기능을 불러와 실행시키는 역할을 한다.
도메인 영역
우리 서비스만의 비즈니스 로직이 수행되는 영역이다.
도메인 영역을 비즈니스 요구사항에 맞게 객체지향적으로 유지 관리한다면 코드 가독성과 유지보수를 챙기며 개발을 지속할 수 있다.
인프라 영역
DB 모듈이나 외부 3rd 파티 서비스의 api를 호출하는 역할을 수행할 수 있다.
초반 보여준 도메인 그림에 따르면 모든 하위 도메인을 우리 비즈니스가 구현할 필요는 없다고 하였다.
인프라 영역에서 외부 api를 구현체를 만들어 호출하고 도메인 영역에서는 이를 인터페이스를 통해 호출할 수 있다.
결론
DDD를 공부하며 프로젝트에 직접 적용하는 과정을 거치며 기존에 무의식적으로 혹은 이유는 정확히 공감하지 못했지만 어디선가 주워들은 패턴들을 사용하는 이유를 명확히 알고 공감하게 되었던 것 같다.
최범균님의 '도메인 주도 개발 시작하기' 책을 몇 번이나 펼치고 읽고 또 읽으며 고민하고 적용하면서 성장할 수 있었던 것 같다.
저에게 멋진 인사이트를 소개해준 최범균님께 감사인사를 드립니다.
참고
도메인 주도 개발하기 - 최범균
'spring' 카테고리의 다른 글
[SpringBoot] Sentry On-premise 구축기 (2) | 2024.09.29 |
---|---|
Java reflection을 통해 RestDocs 생성 테스트 코드 작성 시간 1/2로 줄이기 (2) | 2024.09.27 |
[스프링] service 인터페이스 도입과 접근제한자 (1) | 2024.09.20 |
[spring security] 왜 500번 에러도 401(Unauthorized)에러가 될까? (0) | 2024.04.07 |
이미지 저장/조회 서버 만들기(3) - AWS Presigned URL 이미지 업로드 (0) | 2024.02.26 |