1. 들어가기 앞서
현재 문서에서 서술하는 기준은 유일한 정답이 아닙니다.
완벽한 아키텍처는 존재하지 않으며, 모든 설계 선택에는 트레이드오프(Trade-off)가 있습니다. 그럼에도 널리 알려진 패턴을 기본값(default)으로 두는 것이 조직 차원에서 의사결정 비용을 줄이는 데 도움이 됩니다.
•
이 문서의 목적
◦
팀의 일관성과 유지보수성을 높이기 위한 가이드 제공
◦
Clean Architecture, DDD를 공통 언어와 방향성으로 활용
◦
온보딩, 코드 리뷰, 변경 영향 파악을 쉽게 하는 구조 지향
•
참고 자료
◦
Robert C. Martin (Clean Architecture, SOLID Principles)
◦
Eric Evans (Domain-Driven Design)
◦
Vaughn Vernon (Implementing DDD, Domain-Driven Design Distilled)
◦
Kent Beck (Test-Driven Development, Implementation Patterns)
◦
Alistair Cockburn (Hexagonal Architecture)
◦
Martin Fowler (Patterns of Enterprise Application Architecture)
2. 백엔드 팀이 추구하는 방향
우리는 단순한 레이어드 아키텍처가 아닌 도메인 모델 중심의 설계를 추구합니다.
2.1. 왜 도메인 모델 중심인가?
"The business rules are the heart of the system. They should not depend on frameworks, databases, or external agencies." — Robert C. Martin, Clean Architecture, Ch.22
•
유지보수 용이성
◦
비즈니스 규칙이 한 곳에 집중되어 있으면 변경 영향 범위를 예측할 수 있습니다
•
테스트 용이성
◦
외부 의존성 없는 순수한 도메인 로직은 빠르고 안정적인 테스트가 가능합니다
•
의사소통
◦
도메인 전문가와 개발자가 같은 언어(Ubiquitous Language)로 소통할 수 있습니다
2.2. 풍부한 도메인 모델 (Rich Domain Model)
"In a rich domain model, behavior is implemented in the entities themselves, not in service layers." — Eric Evans, Domain-Driven Design
Anemic Domain Model(빈약한 도메인 모델)은 데이터만 가진 DTO와 다를 바 없습니다.
풍부한 도메인 모델은
•
데이터와 행위가 함께 존재합니다
•
불변식(Invariant)을 스스로 보호합니다
•
캡슐화를 통해 잘못된 상태 전이를 방지합니다
3. Application Layer 상세
"Application Services orchestrate domain objects to perform use cases. They are thin and contain no domain logic." — Vaughn Vernon, Implementing DDD, Ch.14
3.1. 역할과 책임
Application Layer는 시스템이 "무엇을 하는가"(UseCase)를 구현합니다:
책임 | 설명 |
UseCase 구현 | 사용자 또는 외부 시스템의 요청을 처리 |
트랜잭션 경계 관리 | @Transactional로 작업 단위 보장 |
여러 Aggregate 조율 | 하나의 UseCase에서 여러 Aggregate를 협력시킴 |
외부 시스템 연계 | 외부 API 호출, 메시지 발행 등 |
이벤트 발행 | Domain에서 생성된 이벤트를 외부로 전파 |
Domain Event 흐름
•
이벤트 생성
◦
Domain Layer (Aggregate 내부에서 상태 변경 시)
•
이벤트 발행/전파
◦
Application Layer (트랜잭션 완료 후 외부 시스템에 전달)
Domain Layer Application Layer Infrastructure
│ │ │
│ 이벤트 생성 │ │
│ (Aggregate 상태 변경 시) │ │
│ ─────────────────────────>│ │
│ │ 이벤트 발행/전파 │
│ │ (EventPublisher 호출) │
│ │ ─────────────────────────>│
│ │ │ 메시지 브로커 전송
JavaScript
복사
3.2. Application Service의 특징
•
허용되는 것
◦
Repository, Client 등 외부 의존성
◦
@Transactional 사용
◦
Spring Framework 의존 (@Component, @Service)
•
금지되는 것
◦
비즈니스 규칙을 직접 구현 (Domain Layer의 책임)
@Service
@Transactional
class OrderCommandService(
private val orderRepository: OrderRepository, // Repository 의존 허용
private val paymentClient: PaymentClient, // 외부 Client 의존 허용
private val eventPublisher: ApplicationEventPublisher // Spring 의존 허용
) {
fun placeOrder(command: PlaceOrderCommand): OrderId {
// Application Service는 "흐름"을 조율
val order = Order.create(command.userId, command.items) // Domain 호출
orderRepository.save(order)
paymentClient.requestPayment(order.totalAmount)
eventPublisher.publishEvent(OrderPlacedEvent(order.id))
return order.id
}
}
Kotlin
복사
3.3. Handler/Listener와 Service의 관계
Handler와 Listener는 Application Layer에 위치하며, Application Service를 호출합니다.
┌──────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Controller │ │
│ └────────────────────┬─────────────────────────┘ │
│ │ │
└────────────────────────┼─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Handler │ │ Listener │ │ Scheduler │ │
│ │ (Message)│ │ (Event) │ │ (Cron) │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────────┼──────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Application Service │ │
│ │ (UseCase 구현, 트랜잭션 관리) │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
└──────────────────────────┼───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Domain Layer │
│ │
│ ┌────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ Entity │ │ Value │ │ Domain │ │
│ │ │ │ Object │ │ Service │ │
│ └────────┘ └────────────┘ └────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Kotlin
복사
동일 레이어 내 역할 분리
Handler/Listener와 Application Service는 같은 Application Layer에 있지만, 역할이 다릅니다.
구분 | Handler/Listener | Application Service |
역할 | 트리거 (진입점) | UseCase 실행 |
책임 | 메시지/이벤트 수신 및 파싱 | 트랜잭션 관리, Aggregate 조율 |
재사용성 | 특정 이벤트/메시지에 종속 | 여러 진입점에서 공유 |
// Handler는 "트리거" 역할, Service는 "UseCase 실행" 역할
@Component
class OrderEventHandler(
private val orderService: OrderCommandService
) {
@EventListener
fun handle(event: PaymentCompletedEvent) {
orderService.confirmOrder(event.orderId)
}
}
Kotlin
복사
•
이러한 구조의 이점
◦
재사용성: 동일한 UseCase를 Controller, Handler, Scheduler 등 여러 진입점에서 호출 가능
◦
단일 책임: Handler는 이벤트 수신만, Service는 비즈니스 로직 조율만 담당
◦
테스트 용이성: Service를 독립적으로 테스트 가능
3.4. Facade의 조건부 허용
Facade는 여러 Application Service를 조합하여 복합적인 UseCase를 수행할 때 사용할 수 있습니다.
Facade 사용의 우려점
•
Service를 조합하다 보면 도메인 규칙이 Application Layer(Facade)로 새어나올 수 있습니다
•
도메인 모델 중심이 아닌 Facade 중심의 로직으로 변질될 위험이 있습니다
Facade 선택의 명확한 기준
허용 | 금지 |
순수한 "조율(orchestration)"만 담당 | 비즈니스 규칙을 직접 구현 |
도메인 규칙/판단은 Domain Layer에서 수행 | if/else 등으로 핵심 도메인 규칙 표현 |
단순히 여러 Service를 순서대로 호출 | 조건 분기로 비즈니스 흐름 결정 |
// 좋은 예: 순수한 조율만 담당
@Service
class OrderFacade(
private val orderService: OrderCommandService,
private val inventoryService: InventoryService,
private val notificationService: NotificationService
) {
fun processOrder(command: ProcessOrderCommand) {
orderService.createOrder(command)
inventoryService.decreaseStock(command.items)
notificationService.sendConfirmation(command.userId)
}
}
// 나쁜 예: 도메인 규칙이 Facade로 유출
@Service
class OrderFacade(...) {
fun processOrder(command: ProcessOrderCommand) {
// 이런 판단은 Domain Layer에 있어야 함
if (command.totalAmount > 100_000 && user.isVip()) {
orderService.createPriorityOrder(command)
} else {
orderService.createOrder(command)
}
}
}
Kotlin
복사
4. Domain Layer 상세
"A Domain Service expresses domain logic that does not naturally fit within an entity." — Eric Evans, Domain-Driven Design, Ch.5
4.1. 역할과 책임
Domain Layer는 비즈니스의 핵심 개념과 규칙을 표현합니다
책임 | 설명 |
비즈니스 개념 표현 | Entity, Value Object로 도메인 모델링 |
불변식(Invariant) 보호 | 비즈니스 규칙이 항상 참임을 보장 |
상태 변화의 정합성 | Aggregate Root를 통한 일관성 보장 |
순수 계산/판단 | Domain Service로 복잡한 비즈니스 로직 표현 |
4.2. 풍부한 도메인 모델 (Rich Domain Model)
불변식(Invariant)의 개념
불변식은 항상 참이어야 하는 비즈니스 규칙입니다.
// 불변식: "주문 수량은 1 이상이어야 한다"
class OrderItem private constructor(
val productId: ProductId,
val quantity: Int
) {
init {
require(quantity >= 1) { "주문 수량은 1 이상이어야 합니다" }
}
companion object {
fun create(productId: ProductId, quantity: Int): OrderItem {
return OrderItem(productId, quantity)
}
}
}
Kotlin
복사
Aggregate의 역할
Aggregate는 일관성 경계를 정의합니다. Aggregate Root를 통해서만 내부 Entity에 접근합니다.
class Order private constructor(
val id: OrderId,
private val _items: MutableList<OrderItem>,
private var _status: OrderStatus
) {
val items: List<OrderItem> get() = _items.toList()
val status: OrderStatus get() = _status
// 상태 변경은 Aggregate Root를 통해서만
fun addItem(item: OrderItem) {
check(_status == OrderStatus.DRAFT) { "확정된 주문에는 상품을 추가할 수 없습니다" }
_items.add(item)
}
fun confirm() {
check(_items.isNotEmpty()) { "상품이 없는 주문은 확정할 수 없습니다" }
_status = OrderStatus.CONFIRMED
}
}
Kotlin
복사
Anemic vs Rich Domain Model
// Anemic Domain Model (빈약한 도메인 모델)
data class Order(
val id: OrderId,
var items: MutableList<OrderItem>, // 외부에서 직접 조작 가능
var status: OrderStatus // 외부에서 직접 변경 가능
)
// Service에서 비즈니스 규칙 처리 - 로직이 분산됨
class OrderService {
fun addItem(order: Order, item: OrderItem) {
if (order.status != OrderStatus.DRAFT) {
throw IllegalStateException("확정된 주문에는 상품을 추가할 수 없습니다")
}
order.items.add(item)
}
}
// Rich Domain Model (풍부한 도메인 모델)
class Order private constructor(...) {
// 비즈니스 규칙이 Entity 내부에 캡슐화됨
fun addItem(item: OrderItem) {
check(_status == OrderStatus.DRAFT) { "확정된 주문에는 상품을 추가할 수 없습니다" }
_items.add(item)
}
}
Kotlin
복사
4.3. Domain Service의 엄격한 정의
"When a significant process or transformation in the domain is not a natural responsibility of an ENTITY or VALUE OBJECT, add an operation to the model as a standalone interface declared as a SERVICE. Define the interface in terms of the language of the model and make sure the operation name is part of the UBIQUITOUS LANGUAGE. Make the SERVICE stateless." — Eric Evans, Domain-Driven Design, Ch.5
Domain Service는 특정 Aggregate에 넣기 어색한 도메인 규칙을 담당합니다.
Eric Evans가 정의한 Domain Service의 세 가지 특성은 다음과 같습니다.
1.
도메인 개념을 표현:
a.
Aggregate나 Entity 또는 Value Object에 자연스럽게 속하지 않는 도메인 연산
b.
특정 Aggregate에 정의하기 모호한 도메인 로직을 정의
2.
도메인 언어로 정의
a.
인터페이스가 도메인 모델의 다른 요소들로 정의됨
3.
무상태(Stateless)
a.
연산에 사용되는 상태가 없음
용어 구분: Domain Service vs Port
용어 | 정의 | 위치 |
Domain Service | 특정 Aggregate에 속하지 않는 순수 도메인 규칙 | Domain Layer |
Port (Repository Interface) | 외부 세계와의 경계를 정의하는 추상화 | Domain Layer |
두 개념 모두 Domain Layer에 위치하지만, 사용 방식이 다릅니다.
•
Domain Service: 외부 의존성 없이 순수 계산/판단 수행
•
Port: Application Service가 주입받아 사용
핵심: "Domain Layer에 위치한다"와 "Domain Service에서 의존 가능하다"는 다른 개념입니다.
Port는 Domain Layer에 있지만, 이를 사용하는 것은 Application Service의 역할입니다.
Domain Service vs Application Service vs Aggregate 비교
구분 | Domain Service | Application Service | Aggregate |
위치 | Domain Layer | Application Layer | Domain Layer |
역할 | 순수 비즈니스 규칙/정책 | UseCase 조율, 트랜잭션 | 상태 관리, 불변식 보호 |
외부 의존성 | 불가 | 허용 | 불가 |
상태 | 무상태 (Stateless) | 무상태 (Stateless) | 상태 보유 (Stateful) |
테스트 | 순수 단위 테스트 | mock 필요 | 순수 단위 테스트 |
언제 무엇을 사용하는가?
•
핵심 구분 기준
◦
Aggregate: "이 데이터의 상태를 보호해야 하는가?" → 상태와 행위를 캡슐화
◦
Domain Service: "이 로직이 특정 Entity에 속하지 않는 순수 규칙인가?" → 상태 없이 계산/판단
◦
Application Service: "외부 시스템과 연계하거나 트랜잭션이 필요한가?" → 흐름 조율
// Aggregate: 상태를 가지며, 자신의 불변식을 보호
class Order private constructor(
val id: OrderId,
private var _status: OrderStatus,
private val _items: MutableList<OrderItem>
) {
fun addItem(item: OrderItem) {
check(_status == OrderStatus.DRAFT) { "확정된 주문에는 상품을 추가할 수 없습니다" }
_items.add(item)
}
}
// Domain Service: 상태 없이 순수 계산/판단만 수행
// - 여러 Aggregate에 걸친 규칙
// - 단일 Entity에 넣기 어색한 비즈니스 정책
class PricingPolicy {
fun calculateDiscount(userGrade: UserGrade, orderAmount: Money): Money {
val rate = when (userGrade) {
UserGrade.VIP -> Percentage(15)
UserGrade.GOLD -> Percentage(10)
else -> Percentage(0)
}
return orderAmount * rate
}
}
// Application Service: 외부 의존성과 트랜잭션 관리
@Service
@Transactional
class OrderCommandService(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository,
private val pricingPolicy: PricingPolicy
) {
fun placeOrder(command: PlaceOrderCommand): OrderId {
val user = userRepository.findById(command.userId)
val order = Order.create(command.userId, command.items)
val discount = pricingPolicy.calculateDiscount(user.grade, order.totalAmount)
order.applyDiscount(discount)
orderRepository.save(order)
return order.id
}
}
Kotlin
복사
interface라도 IoC를 통한 의존은 외부 의존성
•
Domain Service에서 interface 의존이 허용되지 않는 이유
1.
런타임에 외부 구현체가 주입됨
•
interface를 선언해도 실제로는 Infrastructure의 구현체가 주입됩니다
•
Domain이 간접적으로 외부 세계에 의존하게 됩니다
2.
Domain이 "언제, 어떻게 실행되는지"를 알게 됨
•
Repository를 주입받으면 "데이터가 어딘가에 저장된다"는 것을 암시합니다
•
순수한 비즈니스 규칙 표현이 아닌 "절차"가 됩니다
3.
테스트에서 mock이 필요해짐 = 순수하지 않음
•
mock이 필요하다는 것은 외부 의존성이 있다는 증거입니다
•
Kent Beck의 순수 단위 테스트 원칙에 위배됩니다
// 잘못된 Domain Service: interface 의존 있음
class PricingDomainService(
private val discountRepository: DiscountRepository // ❌ interface라도 외부 의존성
) {
fun calculatePrice(order: Order): Money {
val discount = discountRepository.findByUserId(order.userId) // ❌ mock 필요
return order.totalAmount - discount.amount
}
}
// 올바른 Domain Service: 순수 함수/계산만
class PricingPolicy {
fun calculateDiscountedPrice(totalAmount: Money, discount: Discount): Money {
return totalAmount - discount.amount
}
fun determineDiscountRate(userGrade: UserGrade, orderCount: Int): Percentage {
return when {
userGrade == UserGrade.VIP && orderCount >= 100 -> Percentage(20)
userGrade == UserGrade.VIP -> Percentage(15)
orderCount >= 50 -> Percentage(10)
else -> Percentage(0)
}
}
}
Kotlin
복사
•
Domain Service 테스트 예시
class PricingPolicyTest {
private val policy = PricingPolicy() // ✅ Spring 없이, mock 없이 순수 인스턴스화
@Test
fun `VIP이고 100회 이상 주문시 20% 할인`() {
val rate = policy.determineDiscountRate(UserGrade.VIP, 100)
assertThat(rate).isEqualTo(Percentage(20))
}
}
Kotlin
복사
4.4. Repository Interface의 위치 (Port)
"The Ports are the boundaries of the application." — Alistair Cockburn, Hexagonal Architecture
Repository interface는 Domain Layer에 위치합니다. 이는 Hexagonal Architecture의 Port 개념입니다.
┌──────────────────────────────────────────────────────────┐
│ Domain Layer │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ OrderRepository (interface = Port) │ │
│ └────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
▲
│ implements (DIP)
│
┌──────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ JpaOrderRepository (구현체 = Adapter) │ │
│ └────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Kotlin
복사
•
DIP(Dependency Inversion Principle) 충족
◦
고수준 모듈(Domain)이 저수준 모듈(Infrastructure)에 의존하지 않습니다
◦
둘 다 추상화(interface)에 의존합니다
중요: Port가 Domain에 있다고 Port를 사용하는 Service까지 Domain에 있어야 하는 것은 아닙니다.
// Repository interface는 Domain에
// domain/repository/OrderRepository.kt
interface OrderRepository {
fun findById(id: OrderId): Order?
fun save(order: Order)
}
// 이 Repository를 사용하는 Service는 Application에
// application/service/OrderCommandService.kt
@Service
class OrderCommandService(
private val orderRepository: OrderRepository // Application Service에서 사용
) { ... }
Kotlin
복사
5. 판단 체크리스트
5.1. Domain Service vs Application Service 구분 기준표
질문 | Domain Service | Application Service |
Repository 주입이 필요한가? | 불가 | 가능 |
외부 API 호출이 필요한가? | 불가 | 가능 |
@Transactional이 필요한가? | 불가 | 가능 |
테스트에 mock이 필요한가? | 불가 (순수 테스트) | 가능 |
Spring 없이 테스트 가능한가? | 필수 | 불필요 |
5.2. 질문 기반 체크리스트
새로운 Service를 만들 때 다음 질문을 해보세요
1.
"이 로직이 데이터베이스 없이 동작하는가?"
•
Yes → Domain Service 후보
•
No → Application Service
2.
"이 로직에 외부 시스템 호출이 포함되는가?"
•
Yes → Application Service
•
No → Domain Service 후보
3.
"이 테스트를 Spring 없이 작성할 수 있는가?"
•
Yes → Domain Service
•
No → Application Service
4.
"이 로직이 순수한 비즈니스 규칙/계산/판단인가?"
•
Yes → Domain Service
•
No → Application Service
6. 경계가 모호해진 사례와 복구 비용
6.1. Domain 경계가 모호해지는 상황
다음과 같은 상황에서 Domain Layer의 경계가 흐려질 수 있습니다:
•
외부 의존성이 Domain으로 침투: Repository, Client 등이 Domain Service에 주입됨
•
UseCase 흐름이 Domain으로 이동: 조율 로직이 Domain에 포함됨
•
프레임워크 종속: @Transactional, @Autowired 등이 Domain에 등장
6.2. 복구 비용 관점
상황 | 비용 | 전제 조건 |
Application → Domain 승격 | 상대적으로 낮음 | 외부 의존성이 이미 분리되어 있는 경우 |
경계 모호 → 복구 | 높음 | 의존성 분리, 테스트 재작성, 영향 범위 파악 필요 |
주의: Application → Domain 승격도 외부 의존성이 섞여 있으면 분리 작업이 필요하므로 비용이 증가할 수 있습니다.
경계가 모호해지면
•
코드 리뷰 시 기준이 불명확해져 논의가 길어짐
•
일관된 가이드 유지가 어려워짐
•
새로운 팀원의 학습 곡선 증가
6.3. 상세 사례 (Command 중심)
Note: Query는 CQRS 패턴에 따라 별도 분리 예정이므로 본 사례에서 제외합니다.
사례 1: Domain Service가 UseCase가 된 경우 (점진적 오염 과정)
•
순수 단위 테스트 불가능
•
Domain Layer의 독립성 상실
•
"Domain Service"라는 이름이 무의미해짐
// 1단계: 순수한 Domain Service로 시작
class OrderValidationPolicy {
fun validate(order: Order): ValidationResult {
return when {
order.items.isEmpty() -> ValidationResult.failure("상품이 없습니다")
order.totalAmount <= Money.ZERO -> ValidationResult.failure("금액이 올바르지 않습니다")
else -> ValidationResult.success()
}
}
}
// 2단계: "재사용"을 위해 Repository 추가 (오염 시작)
class OrderValidationPolicy(
private val userRepository: UserRepository // ❌ 오염!
) {
fun validate(order: Order): ValidationResult {
val user = userRepository.findById(order.userId) // ❌ DB 의존
// ...
}
}
// 3단계: 더 많은 의존성 추가 (UseCase가 됨)
class OrderValidationPolicy(
private val userRepository: UserRepository,
private val inventoryClient: InventoryClient, // ❌ 외부 API
private val eventPublisher: EventPublisher // ❌ 이벤트 발행
) {
// 이제 이것은 Domain Service가 아니라 Application Service
}
Kotlin
복사
사례 2: 재사용 욕심이 만든 if/else 지옥
•
단일 책임 원칙(SRP) 위반
•
테스트가 복잡해짐
•
변경 영향 범위 예측 불가
// "재사용 가능한" Domain Service를 만들려다 생긴 문제
class UniversalOrderPolicy(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository,
private val inventoryRepository: InventoryRepository
) {
fun process(order: Order, mode: ProcessMode): ProcessResult {
return when (mode) {
ProcessMode.VALIDATION -> {
// 검증 로직
}
ProcessMode.CALCULATION -> {
// 계산 로직
}
ProcessMode.FULL_PROCESS -> {
// 전체 프로세스 (트랜잭션 필요)
}
}
}
}
Kotlin
복사
사례 3: Facade에서 도메인 규칙을 조합한 경우
•
잘못된 사례
// Facade가 도메인 규칙을 포함
@Service
class CheckoutFacade(
private val orderService: OrderCommandService,
private val paymentService: PaymentService,
private val shippingService: ShippingService
) {
fun checkout(command: CheckoutCommand): CheckoutResult {
val order = orderService.getOrder(command.orderId)
// 이런 비즈니스 규칙은 Domain에 있어야 함
val shippingFee = if (order.totalAmount >= Money.of(50_000)) {
Money.ZERO // 5만원 이상 무료배송
} else {
Money.of(3_000)
}
// 결제 방식 결정 로직도 Domain의 책임
val paymentMethod = when {
order.totalAmount >= Money.of(100_000) -> PaymentMethod.INSTALLMENT
command.user.hasDefaultCard() -> PaymentMethod.CARD
else -> PaymentMethod.BANK_TRANSFER
}
// ...
}
}
Kotlin
복사
•
올바른 수정
// Domain Service로 비즈니스 규칙 이동
class ShippingFeePolicy {
fun calculate(orderAmount: Money): Money {
return if (orderAmount >= Money.of(50_000)) Money.ZERO else Money.of(3_000)
}
}
class PaymentMethodPolicy {
fun determine(orderAmount: Money, user: User): PaymentMethod {
return when {
orderAmount >= Money.of(100_000) -> PaymentMethod.INSTALLMENT
user.hasDefaultCard() -> PaymentMethod.CARD
else -> PaymentMethod.BANK_TRANSFER
}
}
}
// Facade는 순수한 조율만
@Service
class CheckoutFacade(
private val orderService: OrderCommandService,
private val paymentService: PaymentService,
private val shippingFeePolicy: ShippingFeePolicy,
private val paymentMethodPolicy: PaymentMethodPolicy
) {
fun checkout(command: CheckoutCommand): CheckoutResult {
val order = orderService.getOrder(command.orderId)
val shippingFee = shippingFeePolicy.calculate(order.totalAmount)
val paymentMethod = paymentMethodPolicy.determine(order.totalAmount, command.user)
// 조율만 담당
shippingService.setFee(order, shippingFee)
paymentService.process(order, paymentMethod)
// ...
}
}
Kotlin
복사
7. 검증된 패턴을 기본값으로 두는 이유
"Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy." — Robert C. Martin, Clean Architecture
7.1. 트레이드오프를 전제로
모든 설계에는 트레이드오프가 있고, 상황에 따라 최적의 선택이 달라질 수 있습니다. 이런 전제 하에서도 검증된 패턴을 기본값으로 두는 것이 팀에 도움이 되는 이유가 있습니다.
7.2. 기본값을 정해두면 좋은 점
1.
리뷰와 온보딩에서의 일관성
•
명확한 기준이 있으면 코드 리뷰 시 논의가 수월해집니다
•
새로운 팀원도 익숙한 패턴으로 빠르게 적응할 수 있습니다
2.
의사결정 비용 감소
•
매번 "이 로직은 어디에 둘까?"를 고민하는 시간이 줄어듭니다
•
기본값이 있으면 예외적인 경우에만 논의하면 됩니다
3.
장기적 유지보수 고려
•
초기에 경계를 잡아두면 복잡도가 커져도 관리가 용이합니다
•
경계가 흐려진 코드베이스를 되돌리는 비용은 상대적으로 큽니다
7.3. 예외가 필요한 경우
원칙은 단순하게 유지하되, 예외가 필요한 경우에는 근거와 대안을 기록하고 팀과 합의합니다.
•
예시
◦
성능상의 이유로 Domain Service에서 캐시 조회가 필요한 경우
◦
Application Service로 승격하거나, 값을 파라미터로 받도록 설계 변경
8. Reference
•
Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design, 2017
•
Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices, 2002
•
Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003
•
Vaughn Vernon, Implementing Domain-Driven Design, 2013
•
Vaughn Vernon, Domain-Driven Design Distilled, 2016
•
Kent Beck, Test-Driven Development: By Example, 2002
•
Kent Beck, Implementation Patterns, 2007
•
Alistair Cockburn, Hexagonal Architecture, 2005
•
Martin Fowler, Patterns of Enterprise Application Architecture, 2002