💡 DDD를 제대로 공부해본 적 없는 학부생 수준의 조잡한 포스팅일 뿐이니 참고만 해주세요!
참고로 DDD를 다루는 레퍼런스들이 대부분이 너무 어려운 개념으로 설명을 하는 게 마음에 안 들어서, 나름대로 쉬운 언어를 선택하려고 노력은 했으나, 그 과정에서 오류가 있을 수 있습니다.
📕 목차
1. Introduction
2. Responsibility of Lower-level Modules
3. Architecture Improvment
4. Reflection and Future Direction
1. Introduction
📌 As-is
내 블로그 조회수의 대부분을 차지하는 멀티 모듈 포스팅을 작성한지도, 벌써 1년이 다 되어간다.
당시에도 알고 있던 사실이었지만, 위 설계 방법에는 정말 많은 문제가 내재되어 있을 것이며, 지금 당장 뭐가 뭔지도 모르겠으니 나중에 고민하겠다고 스킵한 부분들이 많았다. (특히, 악명높은 DDD는 감히 관심조차 주지 않았다.)
이에 그치지 않고, service layer를 어떻게 분리할 것인지에 대한 고찰도 수행했기에, 제품은 상당히 견고하고 안정된 코드 품질을 유지할 수 있게 되었다.
하지만, 프로젝트가 진행될 수록 당췌 거슬림이 해결되지 않는 부분이 있었는데...대체 도메인 비즈니스 로직은 어디에 정의되어야 하는 걸까?
도메인 비즈니스 규칙이란 건 대체 뭐라고 정의해야 하는 걸까?
이런 의문들이 지금까지 내 머릿속을 헤집어놓고 있었다.
그러다 최근 채팅방 가입 기능을 개발하다가, 의도치 않게 DDD를 찍먹해봤었다.
덕분에 강제로 부분적인 개안을 당하고, 내 코드의 문제점들이 하나하나 선명히 보이기 시작했다.
이번 포스팅은 이런 주제로 글을 쓸 예정이다.
더 이상 리팩토링을 시도해볼만큼 작은 규모도 아니고, 시간이 없기 때문에 프로젝트에 반영은 아직 못 해봤다.
그래서 아래 내용들은 철저히 이론과 경험에 입각한 내용일 뿐이다.
📌 Five Warning Sign
DDD를 찍먹하기 전에도 이상 징후는 지속적으로 발견이 됐었다.
다음은 날 매번 괴롭히던 대표적인 케이스들 5가지를 소개하고자 한다.
1️⃣ Testing Defficulties
- 하나의 도메인 규칙을 테스트하기 위해, 언제나 도메인 모듈의 서비스와 하위 모듈의 서비스를 각각 테스트 해야 함. 문제는 테스트 관점이 일관성이 없음. 어떨 땐 모든 도메인 로직이 domain 모듈에 있고, 어떨 땐 하위 서비스에 위치함.
- UserFixture와 같은 테스트 일관성을 맞추기 위한 장치를 사용하는데, 이걸 domain 모듈과 external-api에 중복 정의해야 하는 현상이 발생함.
- repository에 rdb와 redis를 동적으로 처리할 수 있도록 만들었는데, 이로 인한 애플리케이션 실행 오류가 domain 뿐 아니라, external-api 테스트까지 전파되는 이슈. domain에서 어떤 repository(infrastructure)를 선택했는지가 왜 domain과 external-api 모듈로 전파되어야 하는가?
2️⃣ Degradation of Domain Services to Repository Adapters
- 도메인 서비스가 단순히 저장소 계층 wrapper로 전락
- @DomainService라는 커스텀 어노테이션까지 만들었으나, 불변성 검사와 행위는 Entity가 표현.
- 실제 핵심 도메인 비즈니스를 완성하려면, 하위 모듈의 서비스가 완성하는 이상한 상황이 발생. 도메인 서비스는 그저 repository를 직접적으로 의존하지 못 하게 막는 정도의 역할밖에 수행하질 못 함.
3️⃣ Concentration of Domain Logic in external-api module
- 본래 도메인 계층에 있어야 할 비즈니스 로직이 하위 모듈로 새어 나감
- (2)번과 일맥상통하는 이야기. 지금까지는 domain 모듈의 service는 하나의 entity에 대해서만 관심을 갖고, 이러한 여러 service를 조합하는 것이 하위 서비스의 역할이라 여겼음. (예를 들어, domain 모듈에 Order와 Payment 이력을 저장할 수 있게 메서드 제공하면, 하위 모듈 서비스에서 이를 조합헤서 결제 프로세스 구현)
- 그러나 "주문 후 결제 처리"라는 핵심 비즈니스 로직을 여러 하위 모듈에서 언제나 일관되게 처리해야 함. (다른 모듈에서 똑같은 비즈니스 로직이 필요한데, 얘도 중복 코드를 구현하는 중.)
- 이렇게 되니, 비즈니스 규칙이 변경되었을 때 중복 코드를 매번 추적해서 일관성을 맞춰주는 작업을 수반하게 됨.
- 그나마 최근에는 Datasource를 감추기 위해 Repository interface를 보다 적극적으로 활용함으로써, domain 모듈 service가 제 기능을 하기 시작했으나 중복 검사 문제는 여전함.
- 즉, 관심사 분리가 올바르게 되어있지 않음을 암시하고 있는 상황.
4️⃣ Ambiguous Module Boundaries
- 모듈 간의 책임과 경계가 불분명해져 코드의 응집도가 떨어짐
- 어떨 땐, Domain 모듈의 서비스가 비즈니스 로직을 처리하고, 어떨 땐 하위 서비스에게 떠넘기는 상황
- 문제는 여기에 어떠한 기준이 없이, 그때그때 떠오르는 방식대로 처리하다보니, 실제 비즈니스 규칙이 어디에 구현되어 있는지 모든 흐름을 추적해야 겨우 알 수 있음.
5️⃣ Scattered Business Rules
- 비즈니스 규칙이 여러 계층에 분산되어 일관성과 유지 보수성 저하. (사실상 (4)랑 같은 내용)
- 도메인의 불변성은 Entity가 수행하고 있는데, 행위(ex. 재고 감소; decreaseStock())는 누가 표현할 것인가? 이것도 어떨 땐 Entity가, 어떨 땐 Service가 처리하고 있는 상황
- 외부 인프라 통신은 당연히 하위 모듈 서비스가 수행해야 하겠지만, 여러 도메인에 걸친 작업을 처리하는 것도 하위 모듈 서비스의 역할이라 볼 수 있는가?
2. Responsibility of Lower-level Modules
📌 What's the Domain Rule?
💡 계층이 나뉘어져 있을 뿐, 모두 도메인 규칙이다. 그저 비즈니스 규칙의 성격에 따라 위치와 구현 방식이 달라질 뿐이다.
우선 도메인의 정의에 의해 다시 되짚고 넘어가자.
도메인이란 비즈니스가 해결하고자 하는 문제 영역이라고 정의할 수 있다.
그리고 도메인 규칙은 비즈니스의 핵심 문제를 해결하기 위한 제약 사항과 동작 방식을 의미한다.
- 비즈니스 문제 중심: 도메인은 기술과 인프라로부터 자유로워야 한다.
- 시간 불변성: 기술이나 인프라가 변해도, 비즈니스의 본질적 규칙은 유지해야 한다.
- 자기 완결성: 외부 의존성 없이 규칙 그 자체로 완전해야 한다.
이러한 도메인 규칙의 구성 요소는 다음과 같다.
- 불변식
- 도메인 객체가 항상 만족해야 하는 제약 조건이자, 다른 상태로 이전하기 위해 충족해야 하는 조건
- 객체 생명 주기 전반에 걸쳐, 항상 참이어야 함
- 명시적 제약 조건(ex. Entity 상태 검증 메서드), 암묵적 제약 조건(ex. 도메인 모델 구조 자체로 규칙을 표현)으로 표현 가능
- Entity Life-cycle
- Entity 생성, 수정, 삭제에 대한 규칙
- Aggregate Root를 통한 일관성을 유지해야 함
- 도메인 정책 (Policy)
- 특정 상황에서 적용되는 비즈니스 결정 규칙
- 여러 대안 중 선택하는 기준이며, 상황에 따라, 사내 규칙에 따라 다르게 적용될 수 있는 규칙
- 도메인 서비스 규칙
- 단일 Entity로 표현할 수 없는 도메인 규칙들
- 여러 Aggregate 간의 조율이 필요한 규칙
그리고 도메인 규칙은 흔히들 3개의 계층으로 나눈다고 한다.
1️⃣ Core Domain Rules (핵심 도메인 규칙)
- 우리 비즈니스가 고객에게 제공하는 핵심 가치.
- "왜 고객이 우리 서비스를 선택하는가?"
- "우리만의 특별한 것이 무엇인가?"
- 우리가 다른 경쟁자와 차별화되는 부분.
- ex1) 자사만의 특별한 동적 가격 책정 시스템은 고객의 구매 이력, 실시간 시장 수요, 재고 상황, 경쟁사 가격을 기반으로 판매가를 결정.
- ex2) 자사만의 특별한 추천 알고리즘으로 고객의 취향 분석, 연관 상품, 시즌별 트랜드, 키워드 등을 조합하여 개인화된 추천 기능을 제공. (youtube, netflix, google 등이 여기에 특화되어 있다.)
- Bounded Context의 핵심을 형성하는 영역
2️⃣ Supporting Domain Rules (지원 도메인 규칙)
- 핵심 도메인을 지원하는 필수 규칙.
- "우리 방식대로 해야 하는 것이 무엇인가?"
- "핵심 가치를 어떻게 전달할 것인가?"
- 비즈니스에 특화되긴 했으나, 차별화 요소는 아님. 보통 대체 가능하나, 커스터마이징이 필요.
- ex1) 자사만의 재고 관리 정책. 안전 재고량 설정, 자동 발주 시점, 창고별 재고 분배 등
- ex2) 자사만의 배송 관리 정책. 우선 순위 결정 방식, 지역별 배송 방식, 배송 파트너 결정
- 재사용 가능한 형태로 구현 가능
3️⃣ Generic Domain Rules (일반 도메인 규칙)
- 모든 비즈니스에서 거의 공통적으로 사용.
- "비즈니스 운영을 위한 기본적인 것들은 무엇인가?"
- 누구나 비슷하게 하며, 필요하지만 중요도는 떨어지는 것들
- 잘 변경되지 않으며, 대부분 이미 표준화된 해결책이 존재함. (그래서 그냥 외부 시스템을 사용하는 경우도 많음)
- ex1) 로그인 도메인은 ID/PW 입력을 받거나, OAuth 토큰으로 자사에서 발급한 토큰 혹은 세션 ID를 인증 정보로 활용
- ex2) 결제 처리는 결제 수단을 검증하고, 금액을 검증하고, 결제를 처리하는 통상적인 규약이 이미 정해져 있음. 특히나 이런 기능은 보통 은행과 비즈니스 협약을 통해 외부 API를 사용함.
- 기술적 구현에 가까운 규칙, 외부 솔루션으로 대부분 대체 가능하다.
Domain Driven Development가 어려운 이유는 저 도메인에 대한 이해가 부족해서라고 생각한다.
왜냐면, 워낙 도메인 비즈니스 로직이니 뭐니 하는 단어를 많이 들어봐서 잘 알고 있다고 착각하거나, 잘 모르는 이유가 개발 실력이 부족해서라고 오해하기 쉽기 때문.
하지만 이건 기획과 상당히 밀접해있으며, 회사 실무자들에겐 너무도 익숙하지만 학생들에게 있어선 대체 이게 뭔데 싶은 것이다.
실제 회사들의 Business Model을 분석해보는 것이 Domain을 이해하는데 더 도움이 될 것이다.
핵심 도메인 | 지원 도메인 | 일반 도메인 | |
쿠팡 | • 로켓배송 시스템 (핵심 경쟁력) • 와우 멤버십 혜택 계산 • 로켓 프레시 재고 관리 |
• 일반 상품 배송 관리 • 반품/교환 처리 • 판매자 관리 |
• 회원가입/로그인 • 장바구니 • 표준 결제 처리 |
네이버 쇼핑 | • 상품 검색/추천 알고리즘 • 가격 비교 시스템 • 쇼핑 인사이트 분석 |
• 리뷰 관리 • 판매자 평가 시스템 • 포인트 적립/사용 |
• 회원가입/로그인 • 기본 주문 처리 • 알림 발송 |
배달의 민족 | "어떻게 하면 고객이 더 쉽게 맛있는 음식을 주문할 수 있을까?" | "어떻게 하면 주문을 효율적으로 처리할 수 있을까?" | "어떻게 하면 안전하게 결제할 수 있을까?" |
이런 관점에서 봤을 때, Domain을 이해한다는 것은 "코드의 영역"이 아니라, "우리가 해결하고자 하는 비즈니스 문제의 영역"이라고 이해해야 한다.
이러한 구분이 필요한 이유는 다음과 같다.
- 어떤 도메인에 더 많은 리소스를 투자할 것인가? 더 깊게 모델링할 것인가?
- 어떤 부분을 외부 솔루션으로 대체할 수 있는가? (ex. 결제 시스템은 결제 대행사에게 요청하면 된다.)
즉, 중요도에 따라 내부 인프라를 어떻게 구축하고 제어해야 하는 지, 어떤 부분에 예산과 인력을 투자할 지를 구분하는 척도가 되는 것이다.
여기까지 왔으면 도메인에 대한 이해는 얼추 끝났다. 바로 실전으로 간다. (딱 대 🔨)
📌 Domain Rule Location (Technology-free Domain Model)
현재의 아키텍처를 다시 놓고 봤을 때, 그렇다면 도메인 규칙은 어디서 구현되어야 하는가?
BOOT와 DATA 양 쪽에 모두 Service Layer가 존재해야 한다.
단, 두 서비스 간의 통신이 웹 서버 의존적인 객체여선 안 되며, 공통으로 한 쪽에만 구현해선 안 된다.
- 2022 INFCON, Naver 김대성님 -
어느 한 쪽에서 모든 작업을 처리하는 것은 올바르지 않다는 힌트를 얻을 수 있긴 하나, 그렇다면 두 Service Layer가 존재해야 할 이유는 무엇인가?
각 Service는 무슨 역할을 수행해야 한다는 것인가?
이를 이해하기 위해선 두 Service의 역할이 무엇인지, 그리고 핵심 비즈니스 규칙에 대해서 다시 한 번 상기해볼 필요가 있다.
참고로 나는 우아한 모듈 규칙을 참고한 멀티 모듈 아키텍처를 구성하고 있는 상황이다.
여기서 다음과 같은 유즈케이스를 구현해야 한다고 하자. (이거 계속 써먹을 예정)
📝 자사 주문/결제 시스템 비즈니스 규칙
1. 사용자가 상품을 선택하고, 유효한 결제 정보를 입력한다.
2. 서버는 주문 이력을 데이터베이스에 저장한다.
3. 서버는 결제 정보를 데이터에 저장한다.
4. 결제 대행사에 결제 프로세스를 요청한다.
4-1. 전송 실패 시, 재시도 3회
4-2. 4.1이 실패하면 1, 2 데이터를 삭제하고, 클라이언트는 상품 결제에 실패한다.
5. 프로세스 성공을 slack으로 전송한다.
6. 클라이언트에게 정상 결제 응답을 반환한다.
지금까지 구현한 방식대로라면, 이런 식으로 개발을 했을 것이다.
// domain 모듈
@DomainService
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public create(...) { // 클라이언트에게 받은 주문 정보
Order order = Order.of(...); // Entity 내에서 불변성 검사
orderRepository.save(order);
}
}
@DomainService
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
@Transactional
public create(...) { // 클라이언트에게 받은 결제 정보
Payment payment = Payment.of(...); // Entity 내에서 불변성 검사
paymentRepository.save(payment);
}
}
// infra 모듈
@Component
@RequiredArgsConstructor
public class PaymentAdapter {
.. 결제 대행사에게 요청을 보내기 위한 파싱, API 호출 로직 등
}
@Component
@RequiredArgsConstructor
public class SlackNotificationEventHandler {
private final SlackNotificationAdapter slackNotificationAdapter;
@EventListener
public void handle(SlackNotificationEvent event) {
SlackNotificationRequest request = request.from(event);
slackNotificationAdapter.send(request);
}
}
// external-api 모듈
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderService orderService;
private final PaymentService paymentService;
private final PaymentAdapter paymentAdapter; // 외부 시스템으로 전송하기 위한 로직 포함
private final ApplicationEventListener applicationEventListener;
@Transactional
public Payment execute(...) {
// 1. 각 Entity의 도메인 서비스 호출
Order order = orderService.create(...);
Payment payment = paymentService.create(order, ...); // 주문 정보와 결제 정보
// 2. 결제 대행사 호출
try {
PaymentEventResult result = paymentAdapter.execute(payment);
} catch(PaymentException e) {
// 재시도 및 실패 처리
}
// 3. 슬랙 알림
try {
applicationEventListener.publish(SlackNotificationEvent.create(...));
} catch (SlackNotificationException e) {
// 어떻게 처리?
}
return payment;
}
}
@UseCase
@RequiredArgsConstructor
public class OrderUseCase {
private final OrderPaymentService orderPaymentService;
public OrderResponse pay(...) {
Payment payment = orderPaymentService.execute(...);
return OrderMapper.toOrderResponse(payment);
}
}
겉보기에 잘 설계된 것처럼 보이며, 동작에는 아무런 이상이 없을 것이다.
하지만 하나씩 따지고 들어가면 무언가 이상하다는 것을 알 수 있다.
- Payment Service가 영속화된 Order를 받음으로써, Order Service가 강하게 결합되어 있다.
- 다른 하위 모듈에서 OrderPayment와 동일한 비즈니스 로직이 필요하면,
- 외부 시스템 통신이 트랜잭션 경계 안에 속해있다.
- 슬랙 알림이 실패하더라도 결제 내역이 롤백될 필요는 없으나, 이 또한 하나의 트랜잭션 경계 안에 속하게 되어 slack 알림 실패가 결제 실패를 야기한다.
- UseCase와 도메인 모듈의 Service가 그저 위임자 혹은 wrapper의 역할만을 수행할 뿐, 그저 클래스 복잡도만 늘리는 중간 계층으로 전락했다. (물론, UseCase로 파사드 패턴을 적용했기에 테스트의 편의성을 얻기는 했다.)
이를 점진적으로 개선할 아이디어를 모색해보자.
📌 External System
외부 액터와의 통신은 일반적으로 트랜잭션 경계 밖에서 이루어져야 한다.
프로세스에서 외부 액터와의 상호작용은 결제 대행 시스템 통신과 슬랙 알림 두 가지.
하지만 그 둘의 비즈니스 성격이 다르다.
- 결제 요청 실패 시, 재시도 로직을 요구하며, 요청 실패 시 이전의 모든 트랜잭션의 결과를 롤백해야 한다.
- 슬랙 알림 전송 실패 시, 에러 로그만 기록하고 결과는 유효하게 반환한다.
(지금 생각해보니, 결제 실패했을 때 슬랙 알림 보내는 예시가 더 낫지 않았을까? ㅋㅋㅋㅋ)
그렇다면, 올바른 구현은 다음과 같았어야 하지 않을까?
// external-api 모듈
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderService orderService;
private final PaymentService paymentService;
private final PaymentAdapter paymentAdapter; // 외부 시스템으로 전송하기 위한 로직 포함
public Payment execute(...) {
// 1. 각 Entity의 도메인 서비스 호출
Payment payment = executeInTransaction(() -> {
Order order = orderService.create(...);
return paymentService.create(order, ...); // 주문 정보와 결제 정보
});
// 2. Tx 밖에서 외부 시스템(결제 대행사) 호출
PaymentResult externalResult = retryTemplate.execute(context -> {
try {
return paymentClient.processPayment(payment);
} catch (Exception e) {
// 실패 시 보상 트랜잭션
executeInTransaction(() ->
paymentService.handleFailure(payment)
);
throw e;
}
});
return payment;
}
@Transactional // AOP 기반 동작에서 내부 메서드 호출은 선언적 Tx가 동작하지 않는다. 이건 그저 예시일 뿐이다.
private <T> T executeInTransaction(Supplier<T> operation) {
return operation.get();
}
}
@UseCase
@RequiredArgsConstructor
public class OrderUseCase {
private final OrderPaymentService orderPaymentService;
private final ApplicationEventListener applicationEventListener;
public OrderResponse pay(...) { // Ubiquitous Languate 메서드 명
PaymentResult result = orderPaymentService.execute(...);
if (result.isSuccess()) {
// infra 수준 에러는 infra 모듈에서 출력
applicationEventListener.publish(SlackNotificationEvent.create(...));
}
return OrderMapper.toOrderResponse(payment);
}
}
개인적으로 UseCase는 언제나 소설처럼 읽을 수 있는 추상화 수준을 준수해야 한다고 생각한다.
그래서 메서드와 클래스 명은 되도록 유비쿼터스 언어(Ubiquitous Language)를 사용하는 것을 지향해야 한다.
non-critial하며, 별도의 라이프 사이클을 갖는 slack 알림 전송 로직은 독립적인 컴포넌트로 분리하여 UseCase 단으로 호출하는 것으로 바꾸었다.
이렇게 하면 하위 모듈의 Service는 순수한 트랜잭션의 경계를 기준으로 나눌 수 있게 된다. (all or not)
📌 Clear Speartion of Concerns
그렇다면 UseCase 클래스는 단순히 Router 역할 뿐 아니라, 이름 그대로의 기능을 수행하게 하려면 어떤 역할을 부여해야 하는가?
도메인 모듈의 서비스와 하위 모듈의 서비스는 본질적으로 어떻게 다르며, 각각 무엇에 책임져야 하는가?
나는 다음과 같이 정의해보았다.
🟡 UseCase Layer Responsibilities
- 어떤 순서로 처리할 것인가?
- 비개발자도 이해할 수 있는 흐름. 그저 사용자 스토리를 보여줄 뿐이다.
- 전체 비즈니스 프로세스를 조율
- 각자 다른 트랜잭션 경계의 서비스들을 호출하여, 실제 작업 처리는 하위 클래스들에게 위임한다.
- 실패 시나리오에 대한 대체 흐름(Alternative Flow) 정의
- Usecase 문서를 작성해보거나 읽어본 적이 있다면 이해하기 쉬울 것이다. Usecase 클래스는 그 문서를 그대로 코드로 "서술"해놓은 정도의 추상화 수준을 유지하는 것이 적절하다고 생각한다.
- Client가 이해할 수 있도록, 응답 결과를 적절히 매핑.
🟡 Service Layer in Lower Modules
- 하위 모듈의 서비스들은 "어떻게 구현할 것인가?"에 포커싱
- 도메인 모듈 내의 서비스의 제약은 곧, 도메인 규칙의 본질(핵심 비즈니스 로직은 기술로부터 자유로워야 한다)과 이어진다.
- 외부 시스템과의 통신, 트랜잭션 경계와 보상 트랜잭션, 결과에 따른 시스템 통합 처리와 같은 기술적 솔루션이 필요한 부분을 처리. (도메인 모듈 내 서비스가 처리할 수 없는 영역)
- 에러 처리와 로깅 정책, 인프라 관점에서의 캐싱 전략과 성능 최적화 등등
- 실제 도메인 모듈 내의 서비스가 어떻게 작업을 처리하는 지는 전혀 관심이 없다!
🟡 Service Layer in Domain Module
- 무엇을 해야 하는가?
- 기술적인 관점을 배제한, 순수하게 비즈니스 규칙에만 집중
- 도메인 모델의 불변성을 보장
- 동일한 생명 주기를 가지는 Enity들의 일관성을 보장
- Aggregate Root
- 여러 Entity들을 트랜잭션적 일관성 경계로 묶어, 비즈니스 로직의 단일 진입점을 제공하는 역할
- 예를 들어, Order와 Payment는 강하게 결합된 Aggregate로 취급하겠다고 한다면, 둘을 따로 생성할 방법을 제공하지 않고, 반드시 Order를 생성할 때는 Payment가 함께 생성되며, 둘 중 하나라도 실패하면 모두 실패하도록 만듦. ("1 aggregate, 1 repository")
💡 도메인 계층과 비즈니스 로직
예전에 어디선가 봤던 내용 중에, Application에서 구현할 것인지, Domain에서 구현할 것인지에 대한 내용을 본 적이 있다.
예를 들어, 특정 도메인에 대한 통계 기능을 추가할 때, 이를 어디서 수행해야 하는지였다.
이를 결정하는 요소는 통계 시스템의 중심 역할에 따라 달라진다.
만약, 통계 조회가 핵심 비즈니스 로직이라 볼 수 있다면 도메인 모듈에 작성하는 게 맞다.
하지만 그렇지 않다면, 사용하는 측에서 잘 작성해서 사용해야 한다.
📌 Transaction Policy
🤔 Law of Demeter and "Tell, Don't Ask"
디미터의 법칙은 다른 객체가 어떠한 자료를 갖고 있는지 속사정을 몰라야 한다는 것을 의미하며, "객체가 어떤 데이터를 가지고 있는가?"가 아니라, "객체가 어떤 메시지를 주고 받는가?"를 프로그래핑으로 표현하는 원칙이다.
TDA도 마찬가지로 정보 은닉에 대한 이야기인데, 둘 모두 멍청한 사용자와 뛰어난 도구를 추구한다.
이 원칙대로라면, Application Service에서 Usecase를 실행할 때, aggregate root에 해당하는 개체에게 실제 비즈니스 로직을 위임하고, Aggregate root 내부의 파트 객체들을 직접 조작해서는 안 된다고 한다.
즉, 비즈니스 로직을 수행하고, 그 결과만을 Application Service가 받아야 한다는 것이다.
// external-api 모듈
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderService orderService;
private final PaymentService paymentService;
private final PaymentAdapter paymentAdapter;
public Payment execute(...) {
// 1. 각 Entity의 도메인 서비스 호출
Payment payment = executeInTransaction(() -> {
...
});
// 2. Tx 밖에서 외부 시스템(결제 대행사) 호출
PaymentResult externalResult = retryTemplate.execute(context -> {
...
});
return payment;
}
}
지금의 설계는 위 원칙을 준수하고 있는가?
도메인 모듈 내의 서비스야 아직 다루지 않았으니 그렇다 쳐도, Application Server가 트랜잭션 관리 정책에 대해 너무 많은 비즈니스 규칙에 노출되어 있다고 볼 수도 있다.
그래서 어쩌면 다음과 같이 구현해야 하는 게 올바른 게 아니었을까 싶었다.
// Domain Layer
public class PaymentPolicy {
public static final int MAX_RETRY_ATTEMPTS = 3;
public static final Duration RETRY_INTERVAL = Duration.ofMinutes(1);
}
public class PaymentDomainService {
// 순수한 비즈니스 로직
public Payment createPaymentForOrder(Order order, PaymentCommand command) {
Payment payment = new Payment(order, command);
payment.validate(); // 비즈니스 규칙 검증
return payment;
}
public void handlePaymentFailure(Payment payment) {
// 비즈니스 관점의 실패 처리 로직
payment.markAsFailed();
}
}
// Infrastructure Layer
@Component
@RequiredArgsConstructor
class PaymentProcessor {
private final PaymentClient paymentClient;
private final RetryTemplate retryTemplate;
public PaymentResult processWithRetry(Payment payment) {
return retryTemplate.execute(context ->
paymentClient.processPayment(payment)
);
}
}
@Component
@RequiredArgsConstructor
class TransactionManager {
private final PlatformTransactionManager txManager;
public <T> T executeInTransaction(Supplier<T> operation) {
return new TransactionTemplate(txManager)
.execute(status -> operation.get());
}
}
// Application Layer
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderDomainService orderDomainService;
private final PaymentDomainService paymentDomainService;
private final PaymentProcessor paymentProcessor;
private final TransactionManager txManager;
public PaymentResult execute(OrderPaymentCommand command) {
// 1. Local Transaction - 순수 도메인 로직
Payment payment = txManager.executeInTransaction(() -> {
Order order = orderDomainService.createOrder(command);
return paymentDomainService.createPaymentForOrder(order, command);
});
try {
// 2. External Integration - 기술적 처리
PaymentResult result = paymentProcessor.processWithRetry(payment);
if (!result.isSuccess()) {
// 3. Compensation Transaction - 도메인 로직
txManager.executeInTransaction(() ->
paymentDomainService.handlePaymentFailure(payment)
);
}
return result;
} catch (Exception e) {
// 실패 처리도 동일한 패턴 적용
txManager.executeInTransaction(() ->
paymentDomainService.handlePaymentFailure(payment)
);
throw new PaymentProcessingException(e);
}
}
}
그러나 분명 이걸 보면, "와, 정말 우아하다"라는 느낌 보다는 "너무 복잡한데?"라는 생각이 먼저 들 것이다.
여기서 "멍청한 사용자, 똑똑한 도구" 원칙을 다시 한 번 입맛대로 해석해보자.
다른 곳이라면 몰라도, 적어도 도메인 규칙에서 이 원칙은 Aggregate 내부의 비즈니스 로직에 적용되는 내용을 말한다고 생각한다.
즉, 기술적 인프라까지 적용하려 들면, 불필요할 정도로 프로젝트를 복잡하게 만들 우려가 존재한다.
예를 들어, 다음과 같은 경우엔 반드시 감춰야 하는 경우가 된다.
// ❌ Bad - Order 내부를 너무 많이 알고 있음
class OrderService {
public void process() {
Order order = repository.findById(id);
List<OrderItem> items = order.getItems(); // 내부 상태 노출
Money total = items.stream()
.map(OrderItem::getPrice)
.reduce(Money.ZERO, Money::add);
}
}
// ✅ Good - Order에 위임
class OrderService {
public void process() {
Order order = repository.findById(id);
Money total = order.calculateTotal(); // 내부 구현은 모름
}
}
하지만 기술적인 인프라 핸들링까지 처리해야 할까?
오히려 다음과 같이 처리하는 게 보다 직관적이고, 기술적 문제를 해결하기 쉬워진다.
// 이런 건 괜찮음
class OrderProcessingService {
public void process() {
transactionManager.executeInTransaction(() -> {
// 트랜잭션 처리
});
retryTemplate.execute(() -> {
// 재시도 로직
});
}
}
즉, "멍청한 사용자, 똑똑한 도구"를 과하게 해석해서 슈퍼 똑똑이 도구를 만들려고 하다보면,
오히려 사용자들이 도구를 사용하는 것에 어려움을 느껴 개발 생산성을 떨어트릴 수도 있게 된다.
이는 팀원들과의 적절한 협의가 필요한 부분이라고 생각한다.
3. Architecture Improvement
📌 Aggregative Root
위에서 언급한 멍청한 사용자와 똑똑한 도구를 조금 더 논해보자.
그러나 이번에는 Aggregative Root를 중점적으로 이야기 해보자 한다.
DDD에서 일관성(consistency)이라는 개념을 논할 때는 다음 두 가지로 구분한다.
- 트랜잭션 일관성
- 하나의 트랜잭션 안에서 언제나 일관성이 보장된다.
- 강한 결합도가 필요한 경우
- 결과적 일관성
- 중간 상태에서는 일관성이 깨질 수 있으나, 시간 차를 두고 최종적으로 일관성을 달성한다.
- 느슨한 결합이 가능하거나, 그렇게 해야 하는 경우
현재 방식은 "Order 객체가 생성되면, Payment 객체가 함께 생성되어야 한다."라는 비즈니스 규칙을 준수하기 위해,
다음과 같이 구현되어 있다.
// external-api 모듈
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderService orderService;
private final PaymentService paymentService;
public Payment execute(...) {
// 1. 각 Entity의 도메인 서비스 호출
Payment payment = executeInTransaction(() -> {
Order order = orderService.create(...);
return paymentService.create(order, ...); // 주문 정보와 결제 정보
});
...
}
}
Order와 Payment 생성 서비스를 Application Server에서 각각 호출하여 조합하는 방식으로 구현 중인데, 여기서 규칙을 수정해보자.
- 기존의 규칙: Order를 생성하고, Payment를 생성한다.
- 새로운 규칙: 이미 저장된 Order를 조회하여, Item들의 가격 합계를 구해 Payment를 생성한다.
🙅♂️ As-is. 똑똑한 사용자와 멍청한 도구
// 잘못된 예시 - "똑똑한 사용자와 멍청한 도구"
public class Order {
private List<OrderItem> items;
public Long calculateTotal() { ... }
public List<OrderItem> getItems() { ... } // 내부 상태 노출
}
public class PaymentService {
public void processPayment(Long amount) { ... }
}
// Application Service가 너무 많은 것을 알고 있음
public class OrderPaymentService {
public void process(OrderCommand command) {
Order order = orderRepository.findById(command.getOrderId());
Long total = order.calculateTotal();
// Application Service가 비즈니스 로직 처리
paymentService.processPayment(total); // 직접 처리
// 일관성 유지를 위한 복잡한 로직이 여기에...
}
}
- domain 모듈의 서비스가 그저 데이터를 알려주는 역할밖에 수행하지 않아서, 그 어떤 의미있는 동작도 가지지 못 하는 무기력한 도메인 모델(anemic domain model)이 되어버렸다. (지금의 내 상황)
- 도메인 객체가 단순 데이터 컨테이너로 전락
- 비즈니스 로직이 하위 서비스 계층으로 새어나감
- 객체 지향의 캡슐화 원칙 위반과 도메인 규칙 중복 발생 우려
- 하위 서비스 로직을 구현하는 개발자가 언제나 비즈니스 규칙을 준수하기 위한 정책을 꼼꼼하게 살펴봐야 하는 문제
그렇다면 이걸 어떻게 개선할 수 있을 지, 두 가지 방법을 알아보자.
1️⃣ Transactional Consistency
// domain layer
public class OrderDomainService {
// 단일 진입점 제공
public Payment createPaymentByOrder(OrderCommand command) {
Order order = orderRepository.findById(command.getOrderId());
Payment payment = new Payment(order.getId(), order.calculateTotal());
return paymentRepository.save(payment);
}
}
- 첫 번째 방법은 Order, Payment 생성 서비스를 분리하지 않고, Payment를 생성하기 위한 단일 진입점만을 개방하는 방법이다.
- Payment를 생성하기 위한 비즈니스 규칙이 domain service에서 감춰버림으로써, 하위 서비스는 그저 호출하여 결과값을 받기만 하면 된다.
- 즉각적인 데이터 일관성이 필요하고, 하나의 비즈니스 개념으로 강하게 결합되어 있으며, 실패 시 즉각 롤백이 필요한 경우 적합하다고 생각한다.
2️⃣ Eventual Consistency
// Order Aggregate
public class OrderService {
public OrderResult create(...) {
Order order = orderRepository.save(...);
try {
// 2. Payment 요청
Payment payment = paymentPort.requestPayment(
PaymentRequest.from(order)
);
return OrderResult.success(order, payment);
} catch (PaymentException e) {
// 3. 실패 시 보상 트랜잭션
orderRepository.delete(order);
throw new OrderCreationFailedException(e);
}
}
}
// Payment Aggregate
public class PaymentService implements PaymentPort {
@Override
public Payment create(PaymentRequest request) {
return paymentRepository(request.toEntity(request))
}
}
// Application Service
public class OrderPaymentService {
public void process(OrderCommand command) {
// Aggregate에 작업을 위임하고 결과만 받음
OrderResult result = OrderService.create(...);
// 이후 로직 처리
}
}
- 이전과 동일하지만, 이번에는 Order와 Payment 서비스를 여전히 독립적으로 구현하는 방법
- 둘은 다른 context기 때문에 event로 통신을 하고, "결과적 일관성" 보장을 위해 보상 트랜잭션 정책을 준비해야 한다.
- 이 방법은 모놀리식 아키텍처에서 사용하기엔 과할 정도로 복잡하기 때문에, SAGA 패턴의 MSA에서 사용하는 것이 바람직하다고 생각
- 시간 차이가 허용되며, 독립적인 비즈니스 개념이며, 분산 환경을 고려해야 하는 경우 적합하다고 생각한다.
🤔 둘 중 어떤 방법을 택해야 하는 거지?
이거 때문에 정말 많은 고민을 했는데, 여전히 정답을 모르겠다.
하지만 나름대로 도메인 모델을 설계할 때, 이런 점들을 고려하면 어떨 지 가이드라인을 작성해봤다.
1. 비즈니스 규칙의 응집도
• 관련된 규칙들이 함께 변경되어야 하는가?
• 하나의 개념으로 표현 가능한 것인가?
2. 기술적 제약
• 트랜잭션 범위
• 분산 환경에서의 운용 가능성
• 확장성
3. 유지 보수성
• 비즈니스 규칙 변경 용이성
• 코드 이해도와 디버깅 용이성
• 테스트 용이성
위 사항들을 적절히 고려해보고, 본인들의 서비스에 맞는 전략을 잘 수립하는 것이 무엇보다 중요하다고 생각된다.
📌 Domain-Infrastructure Dependency Concerns
"그럼 이제 구현만 하면 되는 거 아닐까요?"라고 하기엔 고질적인 문제가 하나 더 남아있다.
😫 현재 상황과 딜레마
- 도메인의 순수성 vs 실용성
- 도메인은 기술적 구현으로부터 자유로워야 한다.
- 그러나 실제로는 DB 설정 등이 도메인에 존재한다.
- 이벤트 처리, 메시징 등의 인프라 활용이 필요. (물론 모놀리식에서 메시지 브로커로 동기화를 하겠다고 하면, 감히 오버엔지니어링이라고 말할 것 같다.)
- 현실적 제약
- 빌드 시간 최적화 필요
- 개발 생산성 고려
- 유지보수 용이성
1️⃣ Domain → Infrastructure
- 어느 강의였는지 못 찾겠으나, 실용성을 위해 원칙을 위배하고 Domain 모듈이 Infra 모듈을 의존하도록 만든 사례를 본 적이 있다. (이는 공부하는 입장에선 부적절해보이지만, 실무자 입장에선 정말 합리적인 판단이라고 생각한다.)
- 회사의 코드는 빌드 시간이 상당히 오래 걸리고, 멀티 모듈을 통해서 위 문제를 개선할 수 있다.
- 변화가 거의 없는 Infra는 캐시 데이터를 사용하고, Domain만 빌드하여 최종 애플리케이션을 부트하면 되기 때문.
- 그러나, 원칙을 위해 방향을 역으로 뒤집는 순간 Domain의 변화에 의해, Infra 모듈도 언제나 다시 빌드해야 하는 상황에 놓인다.
- 장점: 빌드 최적화, 실용적, 구현 복잡도 감소
- 단점: DDD 원칙 위배, 도메인 순수성 훼손
2️⃣ Infrastructure → Domain
- 정말 원칙을 준수해야만 하는 위결척사파 보수주의 개발자라면, 의존성 역전 원칙을 활용하여 Infra 모듈이 Domain 모듈을 의존하도록 만들 수 있다.
- 예를 들어, Domain에서 Port interface를 정의해놓으면, Infra 모듈에서 Adapter 클래스로 인프라 단에서의 처리 로직을 구현할 수 있다.
- 장점: DDD 원칙 준수, 도메인 순수성 유지, 테스트 용이성
- 단점: 빌드 시간 증가, 구현 복잡도 증가
3️⃣ Domain Service Layer
- 우아한 기술 블로그에서는 위 구조를 유지하기 위해, 도메인 모듈의 database 의존성을 허용해놓았다.
- 그러나 다중 인프라스트럭처를 모두 분리하여, 하나의 도메인은 하나의 인프라(MySQL, Redis 등)에 대한 책임만 지고, 상위에 domain service를 추가해두었다.
- 단점이라고 한다면, 초기 세팅에 시간이 조금 들 것 같다.
진짜 저긴 천재들만 있는 곳인가? ㅋㅋㅋㅋ
📌 Infrastructure Integration Patterns
그렇다면 인프라 계층과 도메인 계층을 어떻게 효과적으로 통합할 것인가?
여기엔 다양한 전략이 있는데, 이름만 다르고 하는 일은 거의 비슷하다.
그저 무엇을 추상화하는가에 따라 이름이 다를 뿐이다.
(위의 항목 중, 2 혹은 3번을 택했다고 가정.)
1️⃣ Repository Pattern
// Domain Service 모듈
public interface OrderRepository {
Order save(Order order); // Collection처럼 동작
Optional<Order> findById(OrderId id); // Collection처럼 동작
void remove(Order order); // Collection처럼 동작
List<Order> findByCustomerId(CustomerId id);
}
// Domain-rds 모듈
@Repository
public class JpaOrderRepository implements OrderRepository {
private final JpaRepository<OrderEntity, Long> jpaRepository;
@Override
public Order findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(this::toModel)
.orElseThrow(OrderNotFoundException::new);
}
}
- 도메인 객체의 저장소 추상화
- Collection과 같이 Java의 기능을 그대로 활용
- (2) 혹은 (3)의 전략을 택했을 시, 의존성을 역전시켜 하위 모듈을 상세하게 알지 못하도록 만들 수 있다.
2️⃣ Gateway Pattern
// Domain 모듈
public interface PaymentGateway {
PaymentResult processPayment(Payment payment);
PaymentStatus checkPaymentStatus(PaymentId id);
void cancelPayment(PaymentId id);
}
// Infra 모듈
@Component
public class ExternalPaymentGateway implements PaymentGateway {
private final PaymentClient paymentClient;
@Override
public PaymentResult process(Payment payment) {
// 외부 시스템에게 요청 전달 혹은 인프라 제어
}
}
- 외부 시스템이나 서비스 접근점 제공
- 외부 시스템의 복잡성을 캡슐화 및 통합
- MSA처럼 다른 context 간의 통신이 필요한 경우
3️⃣ Adapter Pattern
// Domain Module
public interface MessageSender {
void send(Message message); // 도메인에 맞는 단순 인터페이스
}
// Infrastructure Module
@Component
public class SlackMessageAdapter implements MessageSender {
private final SlackClient slackClient; // 복잡한 외부 라이브러리
private final SlackConverter converter; // 변환 로직
@Override
public void send(Message message) {
// 도메인 모델을 외부 시스템 형식으로 변환
SlackMessage slackMessage = converter.convert(message);
slackClient.send(slackMessage);
}
}
- 호환되지 않는 인터페이스 변환
- 한 인터페이스를 다른 인터페이스로 변환하여, 인터페이스 불일치 해결. (기술적 통합)
- 여기선 예시를 위해 domain 모듈에 Sender를 domain 모듈에 선언했다고 했는데, 보통 event를 infra 모듈에 정의하고 application service에서 사용하게 될 것이다.
📌 Port-Adapter vs Event-Driven
다시 처음으로 돌아와서, "결과적 일관성" 방식으로 설계를 하고자 한다면, 서로 다른 두 도메인 서비스를 어떻게 통합할 수 있을까?
아예 하나의 서비스로 처리한다면 문제가 없지만, Order와 Payment의 서비스를 별도로 만들면 반드시 제어해줄 필요가 있다.
그러나 단순히 OrderService에 PaymentService 의존성을 주입하는 것은 자칫 순환 참조의 문제로 빠지게 될 우려가 있기 때문에, 나는 Port-Adapter 패턴과 Event Driven 패턴을 도입해볼 것 같다.
1️⃣ Decision Criteria: 선택 기준
Port Adapter | Event Driven | |
시간 결합도 | 즉시 응답 필요 | 비동기 처리 가능 |
일관성 요구 사항 | 트랜잭션 일관성 보장 | 최종 일관성 보장 |
오류 처리 | 즉각적 복구/롤백 | 보상 트랜잭션 |
응답 방식 | Synchronous | Asynchronous |
2️⃣ Context Considerations: 상황별 고려사항
Port Adapter | Event Driven | |
시스템 특성 | • 단일 시스템 내 통합 • 강한 일관성 요구 • 명확한 계약 필요 |
• 분산 시스템 • 느슨한 결합 • 확장성 중시 |
Bounded Context | 서로 하나의 context에 포함되어 있음 | 서로 다른 context에 존재 |
성능 고려사항 | • 직접적 의존성으로 인한 응답 시간 • 동기 처리로 인한 자원 점유 |
• 메시지 큐 오버헤드 • 비동기 처리 지연 |
유지보수 측면 | • 명확한 인터페이스로 변경 영향도 파악 용이 • 직접적인 의존성으로 디버깅 용이 |
• 이벤트 흐름 추적이 필요 • 시스템 복잡도 증가 |
4. Reflection and Future Direction
📌 Architecutral Consideration
정말...길고 긴 여정의 끝이 보이기 시작했다...
현재의 내 프로젝트 구성의 문제점들을 파악하고, 각각에 대한 여러 개선 방식들을 살펴봤는데
정답이 없는 부분들이 참 많아서 난해했다.
그래서 비록 명쾌한 해답까지는 아니지만, 각자 자신들의 프로젝트에서 이런 점들을 고려해보면 좋지 않을까~? 싶은 점들을 정리했다.
끝으로 완벽한 설계란 없다. (더 나은 설계만 있을 뿐)
다만 우리는 현재 상황에서 최선의 선택을 하고,
그 선택을 명확히 이해하고 문서화하며,
필요할 때 개선할 수 있는 유연성을 가지고 있으면 충분하다.
그게 바로 진정한 아키텍처의 가치라고 생각한다.
1️⃣ Balance between Ideal Design and Practical Constraints
- 도메인 순수성과 실용성 균형
- 도메인 로직의 순수성은 되도록 유지하되, 실용성을 위해 원칙을 어기는 경우가 금지되진 않는다는 걸 이해.
- ex. DB 설정을 domain에 두되, 기술적 구현을 인프라에 위임하거나, 모듈을 세분화
- 개발 생산성과 설계 원칙
- 빌드 시간과 같은 현실적인 제약을 고려해야 함
- 팀의 이해도와 생산성을 감안. (무작정 멀티 모듈로 나눈다고 상책이 아니다...)
- 점진적 개선
- "좋은 아키텍트는 결정되지 않은 사항들을 최대화한다" - Rovert C. Martine -
- 현재 상황에서 완벽한 설계 보단, 최선의 선택을 추구하는 것이 언제나 올바르다.
2️⃣ Trade-offs between Principles and Pragmatism
- 원칙과 실용성
- Port Adapter보단 직접 의존이 현실적으로 개발이 수월하고 빠르다.
- 모놀리식에서 Event Driven 개발은 과하다. 따라서 트랜잭션 일관성을 유지하는 것이 나을 수 있다.
- 기술 부채
- 뭐든 개발이 진행될 수록, 기존 방식의 문제점은 꾸준히 보이기 마련이다. 이를 허용하되, 의도적인 타협의 문서화 관리가 보다 중요하다.
- 향후 개선 계획을 수립하고 점진적인 리팩토링으로 문제를 개선해나가는 것이 좋다.
- 팀의 역량
- 현재 팀이 기술적으로 이 내용들을 이해할 수 있을 정도로 성숙한가?
- 그렇지 않다면, 러닝 커브를 잘 계산해보고, 프로젝트에 적용하는 것이 옳은 지 잘 생각해보라.
📌 Future Chanllenges
이건 나를 위한 숙제.
여전히 이해하지 못 했거나, 앞으로 도전해보고 싶은 내용들.
1️⃣ Understanding Bounded Contexts and Domain Separationo
- 도메인 경계 식별
- 비즈니스 요구사항에 따른 경계 설정
- 하위 도메인 간의 관계 정의
- 컨텍스트 매핑
- 도메인 간의 통신 패턴 결정
- 통합 전략 수립
2️⃣ Scalability Considerations
- 수평 확장성
- MSA로의 전환 가능성을 고려했을 때, 어떤 차이가 발생할까?
- 분산 시스템에서는 어떤 사항들을 추가로 고려해야 할까?
- domain 모듈 분리 전략
3️⃣ Refactoring
- 현재 설계의 점진적 개선
- 도메인 모델 순수성을 강화
- 인프라 의존성 정리
- 문서화
- 설계 의사 결정 과정들을 기록해서, 도메인 지식을 공유할 수 있으면 좋을 듯..