Search

Application Layer와 Domain Layer의 구분 및 설계 원칙

Tags
Architcture
DDD
Last edited time
2026/02/15 12:49
2 more properties

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
복사
Facade 내에서 if/else 분기로 주요한 비즈니스 규칙(예: 할인 조건, 결제 방식 결정)을 표현하고 있다면, Domain layer로 이동을 고려해야 합니다. 단, 단순한 흐름 제어(null 체크, 에러 핸들링 등)는 Application Layer에서 허용됩니다.

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의 세 가지 특성은 다음과 같습니다.
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