Table of contents
- 서비스에 모든 로직을 넣는 습관
- 로직 배치의 세 가지 선택지
- Entity에 로직을 넣는 경우
- Domain Service — Entity에 넣기 어색한 로직
- Application Service — 유스케이스의 오케스트레이터
- Application Service vs Domain Service
- 실전에서 흔히 겪는 경계 문제
- 계층 간 의존 방향
- 핵심 정리
서비스에 모든 로직을 넣는 습관
Spring Boot 프로젝트에서 가장 흔히 보이는 구조가 있다. Controller → Service → Repository. 그리고 Service 클래스 안에 비즈니스 로직이 전부 들어있다. Entity는 getter와 setter만 가진 빈 껍데기다.
이런 구조를 빈약한 도메인 모델(Anemic Domain Model)이라 부른다. Martin Fowler가 안티패턴으로 지목한 구조인데, 놀라울 정도로 많은 프로젝트가 이렇게 생겼다. 왜냐하면 이게 가장 쉽기 때문이다. Entity에 로직을 넣으려면 객체 설계를 고민해야 하는데, Service에 절차적으로 나열하면 일단 돌아가니까.
문제는 Service가 비대해지면서 나타난다. OrderService가 주문 생성, 주문 취소, 주문 조회, 할인 계산, 재고 확인, 결제 요청, 알림 발송까지 전부 담당하게 되면, 수백 줄짜리 메서드가 등장한다. 로직이 한 곳에 모여 있으니 코드를 이해하기도, 테스트하기도, 수정하기도 어려워진다.
DDD는 이 문제에 대해 명확한 답을 제시한다. 로직을 적절한 곳에 배치하라. Entity가 할 수 있는 일은 Entity에게 맡기고, Entity에 넣기 어색한 도메인 로직은 Domain Service로, 유스케이스를 조율하는 역할은 Application Service로 분리한다.
로직 배치의 세 가지 선택지
DDD에서 비즈니스 로직을 둘 수 있는 곳은 크게 세 가지다.
- Entity / Value Object: 특정 객체의 상태와 밀접하게 연관된 로직
- Domain Service: 하나의 Entity에 속하기 어색한 도메인 로직
- Application Service: 도메인 로직이 아닌 유스케이스 조율, 인프라 호출, 트랜잭션 관리
어디에 둘지 결정하는 흐름을 다이어그램으로 표현하면 이렇다.
flowchart TD
Q1{"이 로직이<br/>특정 Entity의<br/>상태를 변경하는가?"}
Q2{"여러 Aggregate나<br/>외부 정보가<br/>필요한가?"}
Q3{"도메인 규칙인가,<br/>기술적 조율인가?"}
E["Entity / Value Object에 배치"]
DS["Domain Service에 배치"]
AS["Application Service에 배치"]
Q1 -->|"예"| E
Q1 -->|"아니오"| Q2
Q2 -->|"예"| Q3
Q2 -->|"아니오"| DS
Q3 -->|"도메인 규칙"| DS
Q3 -->|"기술적 조율"| AS
style E fill:#5ca45c
style DS fill:#d4943a
style AS fill:#4a90d9
이 판단 기준이 절대적인 규칙은 아니다. 경계가 모호한 경우도 많고, 팀마다 기준이 다를 수 있다. 중요한 건 왜 여기에 뒀는지를 설명할 수 있느냐는 것이다.
Entity에 로직을 넣는 경우
가장 이상적인 배치는 Entity 안이다. 객체가 자신의 상태를 스스로 관리하는 것이 객체지향의 기본 원칙이기도 하다.
5편에서 만든 Order Aggregate Root가 좋은 예시다.
class Order(
val id: Long,
private val _items: MutableList<OrderItem>,
private var _status: OrderStatus
) {
fun place() {
require(_items.isNotEmpty()) { "주문 항목이 비어있으면 주문할 수 없다" }
_status = OrderStatus.PLACED
}
fun cancel() {
require(_status == OrderStatus.PLACED) {
"접수된 주문만 취소할 수 있다"
}
_status = OrderStatus.CANCELLED
}
fun totalAmount(): Money {
return _items.fold(Money(BigDecimal.ZERO)) { acc, item ->
acc + item.totalPrice()
}
}
}
place(), cancel(), totalAmount() — 이 메서드들은 Order의 상태에만 의존한다. 외부 서비스를 호출하거나 다른 Aggregate를 참조하지 않으며, Order가 스스로 판단하고 상태를 전이한다. 이런 로직은 Entity에 넣는 것이 자연스럽다.
Entity에 로직을 넣으면 테스트도 간결해진다.
@Test
fun `빈 주문은 접수할 수 없다`() {
val order = Order.create(id = 1L, customerId = 100L)
assertThrows<IllegalArgumentException> {
order.place()
}
}
Repository도, Spring Context도 필요 없다. 순수한 단위 테스트다.
Domain Service — Entity에 넣기 어색한 로직
모든 도메인 로직이 하나의 Entity에 깔끔하게 들어가는 것은 아니다. 두 가지 이상의 Aggregate가 관여하거나, Entity가 알 필요 없는 외부 정보가 필요한 경우에는 Domain Service가 적합하다.
특징 1: 상태를 갖지 않는다
Domain Service는 상태(State)를 갖지 않는 순수한 동작(Behavior)의 모음이다. 자체적인 필드를 두지 않고, 전달받은 도메인 객체들을 조합하여 결과를 만들어낸다. Spring의 @Service로 등록하되, 인스턴스 변수가 없어야 한다는 점에서 일반적인 서비스 클래스와 구분된다.
특징 2: 도메인 언어로 이름을 짓는다
Domain Service의 이름은 도메인 전문가가 이해할 수 있는 용어여야 한다. OrderProcessingHelper가 아니라 FundTransferService, ExchangeRateConverter 같은 이름이 적절하다.
예시: 환율 변환
환율 변환은 특정 Entity에 속하기 어렵다. Money Value Object가 환율을 알아야 할 이유는 없고, Order가 환율 API를 호출하는 것도 어색하다. 이런 로직이 Domain Service의 전형적인 후보다.
class ExchangeRateService(
private val exchangeRateProvider: ExchangeRateProvider
) {
fun convert(money: Money, targetCurrency: String): Money {
if (money.currency == targetCurrency) return money
val rate = exchangeRateProvider.getRate(
from = money.currency,
to = targetCurrency
)
return Money(
amount = money.amount * rate,
currency = targetCurrency
)
}
}
ExchangeRateProvider는 인터페이스로 정의되고, 실제 외부 API 호출은 인프라 계층의 구현체가 담당한다. Domain Service 자체는 도메인 계층에 위치하며 인프라에 직접 의존하지 않는다.
예시: 중복 검사
회원 가입 시 이메일 중복 검사를 생각해보자. Member Entity가 내 이메일이 중복인지 스스로 판단할 수 있을까? 불가능하다. 중복 여부는 다른 Member들의 이메일을 확인해야 알 수 있으므로, 개별 Entity의 책임 범위를 벗어난다.
class MemberRegistrationService(
private val memberRepository: MemberRepository
) {
fun ensureEmailNotDuplicated(email: String) {
val existing = memberRepository.findByEmail(email)
if (existing != null) {
throw DuplicateEmailException("이미 사용 중인 이메일이다: $email")
}
}
}
이 서비스는 도메인 규칙(이메일은 고유해야 한다)을 구현하면서도, 자체 상태를 갖지 않는다. Repository를 통해 기존 데이터를 확인하고, 규칙 위반 시 예외를 던지는 것이 전부다.
Domain Service를 만들어야 하는 신호
다음 중 하나라도 해당되면 Domain Service를 고려한다.
- 로직이 두 개 이상의 Aggregate에 걸쳐 있다
- Entity가 알 필요 없는 외부 정보(환율, 세율 등)가 필요하다
- 이 로직을 Entity A에 넣어야 하나, B에 넣어야 하나 고민이 된다
- 로직의 이름이 특정 Entity가 아닌 도메인 개념 자체를 표현한다
Application Service — 유스케이스의 오케스트레이터
Application Service는 유스케이스를 조율하는 계층이다. 도메인 로직을 직접 수행하지 않고, 도메인 객체와 Domain Service에게 위임한다. 트랜잭션을 시작하고, 도메인 객체를 Repository에서 꺼내고, 필요한 연산을 호출하고, 결과를 다시 저장하는 흐름을 관리한다.
주문 접수라는 유스케이스를 Application Service로 구현하면 이렇다.
@Service
@Transactional
class PlaceOrderService(
private val orderRepository: OrderRepository,
private val exchangeRateService: ExchangeRateService,
private val eventPublisher: ApplicationEventPublisher
) {
fun execute(command: PlaceOrderCommand): PlaceOrderResult {
// 1. Aggregate 조회
val order = orderRepository.findById(command.orderId)
?: throw OrderNotFoundException(command.orderId)
// 2. 도메인 로직 실행 (Order에게 위임)
order.place()
// 3. Domain Service 호출 (필요한 경우)
val totalInKRW = exchangeRateService.convert(
order.totalAmount(), "KRW"
)
// 4. 저장
orderRepository.save(order)
// 5. 이벤트 발행
eventPublisher.publishEvent(
OrderPlacedEvent(orderId = order.id, amount = totalInKRW)
)
return PlaceOrderResult(orderId = order.id, totalAmount = totalInKRW)
}
}
이 코드에서 Application Service가 하는 일을 살펴보면, 도메인 로직은 하나도 없다.
order.place()— 주문 접수의 비즈니스 규칙은OrderEntity가 수행exchangeRateService.convert()— 환율 변환은 Domain Service가 수행- 나머지는 조회, 저장, 이벤트 발행 같은 기술적 조율
Application Service를 시퀀스 다이어그램으로 그리면 역할이 더 명확해진다.
sequenceDiagram
participant C as Controller
participant AS as PlaceOrderService
participant R as OrderRepository
participant O as Order
participant DS as ExchangeRateService
participant EP as EventPublisher
C->>AS: execute(command)
AS->>R: findById(orderId)
R-->>AS: Order
AS->>O: place()
O-->>AS: 상태 전이 완료
AS->>DS: convert(totalAmount, "KRW")
DS-->>AS: Money(KRW)
AS->>R: save(order)
AS->>EP: publishEvent(OrderPlacedEvent)
AS-->>C: PlaceOrderResult
Application Service는 오케스트라의 지휘자와 같다. 각 악기(도메인 객체, Domain Service)가 연주하는 방법을 모르지만, 언제 어떤 악기가 들어와야 하는지를 알고 있다.
Application Service vs Domain Service
두 서비스의 차이를 정리하면 이렇다.
| 구분 | Domain Service | Application Service |
|---|---|---|
| 포함하는 로직 | 도메인 규칙 | 유스케이스 조율 |
| 상태 | 없음 | 없음 |
| 트랜잭션 관리 | 하지 않음 | 담당 |
| 인프라 의존 | 최소화 (인터페이스 의존) | 직접 사용 가능 |
| 소속 계층 | 도메인 | 애플리케이션 |
| 테스트 방식 | 단위 테스트 | 통합 테스트 |
| 이름 예시 | ExchangeRateService | PlaceOrderService |
가장 핵심적인 차이는 “이 코드를 제거하면 도메인 규칙이 사라지는가?”라는 질문이다. Domain Service를 제거하면 환율 변환 규칙이 사라진다. Application Service를 제거하면 주문 접수라는 유스케이스 흐름이 사라지지만, 개별 도메인 규칙은 Entity와 Domain Service에 여전히 남아 있다.
실전에서 흔히 겪는 경계 문제
경계 문제 1: 알림 발송은 어디에?
주문 접수 시 고객에게 이메일을 보낸다는 요구사항이 있다. 이메일 발송은 도메인 규칙인가?
아니다. 이메일 발송은 도메인 개념이 아니라 기술적 부수 효과(Side Effect)다. 주문이 접수된다는 도메인 규칙이고, 주문이 접수되면 이메일을 보낸다는 정책(Policy)이다. 이런 부수 효과는 Application Service에서 이벤트를 발행하고, 이벤트 핸들러가 처리하는 구조가 적절하다.
경계 문제 2: 할인 계산은 어디에?
단일 주문의 할인율 계산은 Order Entity에 둘 수 있다. 하지만 VIP 등급이면 추가 10% 할인, 쿠폰 적용, 프로모션 기간 할인처럼 여러 조건이 복합적으로 작용하면 어떻게 될까.
이 경우 DiscountPolicy라는 Domain Service를 만드는 것이 적절하다. 여러 할인 규칙을 조합하는 로직은 특정 Entity의 책임이 아니기 때문이다.
class DiscountPolicy(
private val memberRepository: MemberRepository
) {
fun calculateDiscount(order: Order, memberId: Long): Money {
val member = memberRepository.findById(memberId)
?: return Money(BigDecimal.ZERO)
val gradeDiscount = when (member.grade) {
Grade.VIP -> order.totalAmount().amount * BigDecimal("0.10")
Grade.GOLD -> order.totalAmount().amount * BigDecimal("0.05")
else -> BigDecimal.ZERO
}
return Money(gradeDiscount, order.totalAmount().currency)
}
}
경계 문제 3: Application Service가 비대해질 때
Application Service도 비대해질 수 있다. 하나의 서비스에 유스케이스가 열 개씩 들어가면 결국 빈약한 도메인 모델과 같은 문제를 반복하게 된다.
해결 방법은 유스케이스 단위로 Application Service를 나누는 것이다. OrderService에 createOrder(), cancelOrder(), updateOrder(), getOrderDetail() 등을 모두 넣는 대신, PlaceOrderService, CancelOrderService, GetOrderDetailService로 분리한다.
// 유스케이스마다 별도의 Application Service
@Service class PlaceOrderService(...)
@Service class CancelOrderService(...)
@Service class GetOrderDetailService(...)
하나의 클래스에 하나의 유스케이스. 이렇게 하면 각 클래스의 의존성이 최소화되고, 변경의 영향 범위가 좁아진다. CQRS(Command Query Responsibility Segregation) 패턴과도 자연스럽게 연결되는 구조다.
계층 간 의존 방향
지금까지 다룬 Entity, Domain Service, Application Service의 계층 관계를 정리한다.
Controller → Application Service → Domain Service → Entity / Value Object
→ Repository (인터페이스)
의존 방향은 항상 안쪽(도메인)을 향한다. Application Service는 Domain Service와 Repository를 사용하고, Domain Service는 Entity와 Value Object를 사용한다. 반대 방향으로 의존하는 코드가 있다면 설계를 재검토해야 한다.
이 구조에서 가장 안쪽에 있는 Entity와 Value Object는 어떤 계층에도 의존하지 않는다. 순수한 도메인 로직만 담고 있기 때문에 테스트가 쉽고, 프레임워크가 바뀌어도 영향을 받지 않는다.
핵심 정리
이 편에서 다룬 내용을 정리하면 이렇다.
- 빈약한 도메인 모델은 Service에 모든 로직이 몰리는 안티패턴이다. 로직을 적절한 곳에 배치해야 한다
- Entity에 넣을 수 있는 로직은 Entity에 넣는 것이 가장 이상적이다
- Domain Service는 특정 Entity에 속하기 어색한 도메인 규칙을 담당하며, 상태를 갖지 않는다
- Application Service는 유스케이스를 조율하는 오케스트레이터로, 도메인 로직을 직접 수행하지 않고 위임한다
- Application Service가 비대해지면 유스케이스 단위로 분리한다
- 의존 방향은 항상 도메인을 향한다
다음 편에서는 Domain Event와 Anti-Corruption Layer를 다룬다. Aggregate 간의 결과적 일관성을 이벤트로 어떻게 구현하는지, 외부 시스템으로부터 도메인을 어떻게 보호하는지 살펴보면서 시리즈를 마무리한다.




Loading comments...