도메인 주도 설계(DDD)는 현실 세계의 문제를 도메인 중심의 코드로 모델링하는 접근이다.
핵심은 기술보다 해결 규칙에 집중하는 것이다.
그런데 실제 코드를 작성하다 보면, 우리는 종종 코드로 명시적인 로직을 작성하기보다 DB나 외부 시스템에서 제공하는 메서드로 한번에 해결하고는 한다.
아래 코드를 한번 보자.
public class UserService {
private final UserRepository userRepository;
public void ValidateNewUser(String email) {
if (userRepository.existsByEmail(email)) throw new DuplicateEmailException();
}
}
회원가입을 시도했을 때, 같은 이메일이 존재하면 예외를 던지는 코드다.
이메일 중복 검사는 도메인 로직일까, 아니면 단순 데이터 검증일까?
존재 여부 확인은 도메인 로직인가?
위 코드는 이메일 중복 검사는 단순히 DB에 동일한 row가 있는지 확인하는 "기술적인 문제"로 보일 수 있다.
하지만 실제로는 "같은 이메일로는 회원가입할 수 없다"는 도메인 규칙에 해당한다.
그리고 이 규칙을 확인하기 위해서는 DB 조회라는 인프라를 의존하는 액션이 필요하다.
즉, 다음과 같은 두 가지 성격이 공존한다.
| 관점 | 내용 | 속성 |
| 비즈니스 관점 | 이메일은 중복될 수 없다. | 도메인 규칙 |
| 기술 관점 | DB를 조회하여 Null 여부를 판단한다. | 인프라 의존 |
두 관점을 코드에서 어떻게 분리해야 할까?
도메인과 기술적 관심사를 분리해보기
트랜잭션 스크립트는 로직 자체가 Service Layer에 집중되기 때문에 DB에 대한 CRUD와 도메인 로직이 하나의 메서드 안에 응집된다.
이 방식은 개발하기 편하고 전체적인 흐름도 한눈에 파악되어 좋지만, 단위 기능별로 어떤 작업을 수행하는지 경계를 긋기 힘들다.
비즈니스 규칙이 기술 코드에 섞여 단위 기능별로 의도가 흐려진다
트랜잭션 스크립트는 DB 조회, 상태 변경, 외부 호출 등이 한 메서드 안에서 뒤섞인다.
결과적으로 "무엇(Can)"을 해야 하는지와 "어떻게(How)" 해야 하는지가 한 덩어리가 되어 비즈니스 규칙이 의도대로 드러나지 않는다.
은행 도메인에서 다음 규칙이 있다고 하자.
- 도메인 규칙: 출금시 잔액(balance)보다 큰 금액을 출금할 수 없다.
- 기술/인프라: 출금과 입금은 반드시 하나의 트랜잭션으로 처리돼야 한다.
@Service
public class TransferService {
private final AccountRepository accountRepository;
private final TransferLogRepository logRepository;
@Transactional
public void transfer(long fromId, long toId, Money amount) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
if (from.getBalance() < amount)) {
throw new NotEnoughBalanceException();
}
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
accountRepository.save(from);
accountRepository.save(to);
}
}
이렇게 service 레이어에서 절차적으로 행위를 명시하는 방식을 "트랜잭션 스크립트" 방식이라고 한다.
이체를 위해서 구현된 도메인 기능은 입금, 출금 총 두가지다.
입출금 기능별로 경계를 표현하면 다음과 같다.

위 코드에서 핵심 기능들을 한 눈에 파악하기 힘들 수 있다.
왜 파악이 힘든 경우가 생길까?
- 핵심 기능 (입출금) 위 아래로 조회, 저장 로직이 있다.
- 잔액이 부족하면 예외를 발생시키는 로직은 출금에 대한 제한사항이지만 코드상에서 명시적으로 표현되지 않는다. (대충 보면 이체 기능의 예외처럼 보인다.)
기능을 메서드로 분리하기
잔액부족과 출금을 하나의 컨텍스트로 묶으려면 메서드로 분리하면 된다.
@Service
public class TransferService {
private final AccountRepository accountRepository;
private final TransferLogRepository logRepository;
@Transactional
public void transfer(long fromId, long toId, Money amount) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
withdraw(from, amount);
deposit(to, amount);
accountRepository.save(from);
accountRepository.save(to);
}
private void withdraw(Account account, long amount) {
if (account.getBalance() < amount)) {
throw new NotEnoughBalanceException();
}
account.setBalance(account.getBalance() - amount);
}
private void deposit(Account account, long amount) {
account.setBalance(account.getBalance() + amount);
}
}
그런데 생각해보면 withdraw, deposit 메서드가 TransferService에 종속되는 기능일까?
입출금은 Account에 대한 행위다. 즉, 입출금의 책임을 도메인에게 위임하는것이 좋을 수 있다.
도메인에게 책임을 위임하는 방법
1. 도메인이 스스로 행동하도록 해보자
@Entity
public class Account {
private Long id;
private long balance;
public void withdraw(long amount) {
if (balance < amount) throw new NotEnoughBalanceException();
this.balance -= amount;
}
public void deposit(long amount) {
this.balance += amount;
}
}
@Service
@Transactional
public class TransferService {
private final AccountRepository accountRepository;
private final TransferLogRepository logRepository;
public void transfer(long fromId, long toId, Money amount) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
from.withdraw(amount);
to.deposit(amount);
accountRepository.save(from);
accountRepository.save(to);
}
}
객체의 필드값을 변경해야 한다면 해당 객체가 직접 책임지고 하는것이 더 자연스럽다.
이렇게 하면 우리가 출금에 대한 정책과 결과만 따로 테스트하기도 쉬워진다.
출금 기능에 대한 테스트를 작성한다면 다음과 같이 할 수 있다.
class AccountTest {
@Test
void 출금_성공() {
Account account = new Account("1002-292-4838302", 1000L);
account.withdraw(100L);
assertThat(account.getBalance()).equals(900L);
}
@Test
void 잔액만큼_출금이_가능하다() {
Account account = new Account("1002-292-4838302", 1000L);
account.withdraw(1000L);
assertThat(account.getBalance()).isZero();
}
@Test
void 잔액보다_큰_금액을_출금하면_실패한다() {
Account account = new Account("1002-292-4838302", 1000L);
assertThatThrownBy(() -> account.withdraw(1001L))
.isInstanceOf(NotEnoughBalanceException.class);
}
}
만약 기존 코드에 대한 테스트를 짰다면 repository Mocking도 필요했을 것이다.
즉, 출금을 테스트하는데 repository까지 신경써야하는 상황이 된다.
(예제가 단순해서 그렇지 실무에서는 더 번거로울것이다)
도메인이 스스로 행동하도록 만들면 1. 서비스 로직은 더 간결해지고 2. 도메인의 의미를 명확히 드러낼 수 있다.
2. 출금, 입금 같은 행위가 도메인 객체 내부로 이동하면 어떤 장점이 있을까?
도메인이 스스로 행동하게된다!
- 도메인 규칙이 코드와 구조를 통해 분명하게 드러난다.
잔액 부족 여부는 Account가 가장 잘 알고 있으며, 따라서 withdraw 메서드 내부에서 자연스럽게 규칙을 적용할 수 있다. - 서비스는 "절차"가 아니라 "협력"만 담당한다.
TransferService는 단순히 출금, 입금, 저장의 조합만 수행할 뿐, 세부 정책을 알 필요가 없다. - 테스트 코드의 응집도가 높아진다.
도메인 객체가 규칙을 모두 가지고 있으므로 복잡한 mock, DB 의존 없이 순수한 단위 테스트를 작성할 수 있다.
만약 테스트코드를 작성하는데 Repository를 mock해야 한다면 도메인 규칙과 기술이 섞여있다는 강력한 신호다.
DDD의 핵심은?
DDD는 어렵고 무거운 아키텍처를 짜기 위해 존재하는 것이 아니다.
굳이 DDD를 사용하려는 가장 중요한 목표는 단 하나다.
의도를 드러내는 것
즉, 코드를 읽는 사람에게 비즈니스 규칙의 의도가 명확히 보이도록 만드는 것
기술을 중심으로 만드는 코드가 아니라 문제 해결을 중심으로 코드를 설계하자.
'프로그래밍 > 객체지향 설계' 카테고리의 다른 글
| [오브젝트] 10장. 상속과 코드 재사용 (0) | 2021.07.04 |
|---|---|
| [오브젝트] 10장. 상속과 코드 재사용 (2) | 2021.07.02 |
| [오브젝트] 5장. 책임 할당하기 (1) | 2021.07.01 |
| [오브젝트] 3장. 역할, 책임, 협력 (1) | 2021.06.30 |
| [오브젝트] 2장. 객체지향 프로그래밍 (1) | 2021.05.21 |