들어가며
프로젝트를 진행하다 보면 한 가지 고민을 하게 됩니다.
사용자 조회, 포인트 검증, 재고 확인, 주문 저장, 결제 처리…
한 Service 메서드에 모든 책임이 몰려 있으면 코드는 점점 복잡해지고, 테스트도 어려워지고, 변경에도 취약해집니다.
이 글에서는 객체지향 설계 원칙을 활용하여 주문 기능을 단계적으로 리팩토링하는 과정을 보입니다.
각 단계마다 왜 분리했는지, 그리고 무엇이 개선되었는지를 다룹니다.
요구사항
구현해야 할 요구사항을 아래와 같습니다.
비즈니스 요구사항
- 사용자가 상품 주문을 요청합니다
- 주문할 상품과 갯수를 지정
- 재고가 부족한 경우 거절
- 주문 시 포인트가 차감됩니다
- 포인트가 부족한 경우 거절
기능 흐름
사용자 주문 요청
↓
사용자 조회
↓
사용자 포인트 조회
↓
[검증] 포인트 >= 주문금액?
├─ No → 거절 (포인트 부족)
└─ Yes ↓
[검증] 상품 존재?
├─ No → 거절 (상품 없음)
└─ Yes ↓
[검증] 재고 >= 주문수량?
├─ No → 거절 (재고 부족)
└─ Yes ↓
주문 정보 저장
포인트 차감
결제 정보 저장
↓
주문 완료
상세 흐름
- 사용자가 상품의 주문을 요청합니다.
- 요청 파라미터: 주문할 상품, 주문 상품의 갯수
- 주문할 사용자의 포인트를 조회합니다.
- 포인트가 주문할 상품의 총 금액보다 같거나 많은지 확인합니다.
- 포인트가 부족하면 거절합니다.
- 포인트가 충분하면 주문할 상품이 존재하는지 확인합니다.
- 상품이 없으면 거절합니다.
- 상품이 있으면 재고를 확인합니다.
- 재고가 주문한 상품의 갯수보다 많거나 같은지 확인합니다.
- 재고가 부족하면 거절합니다.
- 재고가 충분하면 주문정보를 저장합니다.
- 사용자의 포인트를 차감하고 결제 정보를 저장합니다.
문제: 모든 책임이 한곳에 집중되다
이 모든 요구사항을 한 메서드에 구현하면 어떻게 될까요? 처음 작성한 주문 기능의 코드를 보겠습니다.
@Transactional
public Order order(OrderProductsCommand command) {
// 사용자 조회
User user = userRepository.getByIdentifier(new UserIdentifier(command.getUserIdentifier()));
// 사용자 포인트 조회
UserPoint userPoint = userPointRepository.getByUserId(user.getUserId());
List<OrderProductsCommand.OrderItem> products = command.getOrderItems();
// 주문 항목 생성
List<OrderedItem> OrderedItems = products.stream()
.map(productCommand -> new OrderedItem(
new ProductId(productCommand.getProductId()),
new Quantity(productCommand.getQuantity())
)
)
.toList();
// 총 주문액 계산, 재고를 감소(재고 확인 포함!)
PayAmount payAmount = new PayAmount(
OrderedItems.stream().map(OrderedItem -> {
Product product = productRepository.getById(OrderedItem.getProductId());
if (!product.hasEnoughStock(OrderedItem.getQuantity())) {
throw new IllegalArgumentException(PRODUCT_OUT_OF_STOCK.getMessage());
}
productRepository.save(product.decreaseStock(OrderedItem.getQuantity()))
return product.getTotalPrice(OrderedItem.getQuantity());
})
.reduce(BigDecimal.ZERO, BigDecimal::add)
);
// 주문 생성 및 검증
Order order = Order.create(user.getUserId(), OrderedItems);
if (userPoint.payable(payAmount)) {
throw new IllegalArgumentException(DomainErrorCode.NOT_ENOUGH_USER_POINT_BALANCE.getMessage());
}
// 사용자 포인트차감 및 결제
Order savedOrder = orderRepository.save(order);
userPointRepository.save(userPoint.pay(payAmount));
Payment payment = Payment.create(savedOrder.getOrderId(), user.getUserId(), payAmount);
return savedOrder;
}
문제점 분석
이 코드에는 명백한 설계 문제들이 있습니다:
| 문제 | 설명 |
|---|---|
| Single Responsibility 위반 | OrderService가 사용자 조회, 재고 관리, 결제 처리까지 모두 담당합니다 |
| 높은 결합도 | ProductRepository, UserPointRepository 등 많은 의존성이 뒤섞여 있습니다 |
| 낮은 응집도 | 관련 없어 보이는 로직들이 한 메서드에 뭉쳐있습니다 |
| 테스트 어려움 | 하나의 시나리오를 테스트하려면 모든 Repository를 Mock으로 설정해야 합니다 |
코드에 매핑된 기능 흐름
위의 Before 코드를 기능 흐름에 따라 분석해보면 다음과 같습니다:
사용자 주문 요청 (OrderProductsCommand)
↓
사용자 조회 ← User user = userRepository.getByIdentifier(...)
↓
사용자 포인트 조회 ← UserPoint userPoint = userPointRepository.getByUserId(...)
↓
[검증] 포인트 >= 주문금액? ← if (userPoint.payable(payAmount)) { throw ... }
├─ No → 거절
└─ Yes ↓
[검증] 상품 존재? ← Product product = productRepository.getById(...)
├─ No → 거절
└─ Yes ↓
[검증] 재고 >= 주문수량? ← if (!product.hasEnoughStock(...)) { throw ... }
├─ No → 거절
└─ Yes ↓
주문 정보 저장 ← Order savedOrder = orderRepository.save(order)
포인트 차감 ← userPointRepository...
결제 정보 저장 ← Payment payment = Payment.create(...)
↓
주문 완료 (savedOrder)
문제는: 11단계의 기능 흐름이 모두 한 메서드에 뭉쳐있다는 것입니다.
Step 1: 가격 계산 로직 분리하기 - 복잡한 흐름을 드러내기
왜 분리하는가?
Before 코드를 보면 스트림 안에서 여러 일이 동시에 일어나고 있습니다:
PayAmount payAmount = new PayAmount(
OrderedItems.stream().map(OrderedItem -> { // 가격 계산?
Product product = productRepository.getById(...); // 상품 조회
if (!product.hasEnoughStock(...)) { // 재고 검증
throw ...;
}
productRepository.save(...); // 재고 저장
return product.getTotalPrice(...); // 가격 계산
})
.reduce(BigDecimal.ZERO, BigDecimal::add) // 합산
);
이것을 읽는 사람은 “이게 정확히 뭘 하는 건데?”라는 질문을 하게 됩니다.
해결책: OrderLineAggregator - 주문 항목들을 조율합니다
@Component
@RequiredArgsConstructor
public class OrderLineAggregator {
private final ProductRepository productRepository;
private final OrderItemRepository orderItemRepository;
public PayAmount aggregate(List<OrderItem> orderItems) {
return new PayAmount(
orderItems.stream()
.map(orderItem -> {
Product product = productRepository.getById(orderItem.getProductId());
// 재고 검증은 Product가 담당합니다
Product decreasedProduct = product.decreaseStock(orderItem.getQuantity());
orderItemRepository.save(orderItem);
productRepository.save(decreasedProduct);
return product.getTotalPrice(orderItem.getQuantity());
})
.reduce(BigDecimal.ZERO, BigDecimal::add)
);
}
}
이 분리가 가져온 변화:
- 책임 명확화:
aggregate()는 “여러 주문 항목의 총액을 계산하고 저장한다”는 의도를 명확히 드러냅니다 - 테스트 가능성: OrderLineAggregator를 따로 테스트할 수 있습니다 (OrderItemRepository, ProductRepository만 Mock으로 설정)
- 재사용성: 다른 Service에서도 이 컴포넌트를 사용할 수 있습니다
개선된 OrderService
@Transactional
public Order order(OrderProductsCommand command) {
User user = userRepository.getByIdentifier(new UserIdentifier(command.getUserIdentifier()));
UserPoint userPoint = userPointRepository.getByUserId(user.getUserId());
List<OrderedItem> OrderedItems = command.getOrderItems().stream()
.map(productCommand -> new OrderedItem(
new ProductId(productCommand.getProductId()),
new Quantity(productCommand.getQuantity())
)
)
.toList();
// 이제 복잡한 가격 계산과 재고 처리는 OrderLineAggregator에게 위임합니다
PayAmount payAmount = orderLineAggregator.aggregate(OrderedItems);
Order order = Order.create(user.getUserId(), OrderedItems);
if (userPoint.payable(payAmount)) {
throw new IllegalArgumentException(DomainErrorCode.NOT_ENOUGH_USER_POINT_BALANCE.getMessage());
}
Order savedOrder = orderRepository.save(order);
Payment payment = Payment.create(savedOrder.getOrderId(), user.getUserId());
return savedOrder;
}
Step 2: 한 주문 항목 처리를 더 잘게 나누기 - 단일 책임 원칙
왜 더 분리하는가?
OrderLineAggregator를 봐도 여전히 불편합니다:
- 한 항목 처리의 복잡도가 map 내부에 숨어있습니다
- 한 항목 처리가 실패하면 전체 주문이 실패하는데, 이를 명확하게 표현할 수 없습니다
- “한 항목을 처리한다”는 개념이 메서드명에 나타나지 않습니다
해결책: OrderLineAllocator - 한 항목씩 담당합니다
@Component
@RequiredArgsConstructor
public class OrderLineAllocator {
private final ProductRepository productRepository;
private final OrderItemRepository orderItemRepository;
@Transactional
public BigDecimal allocate(OrderItem orderItem) {
// 한 항목이 하는 일:
// 1. 상품을 조회하고
Product product = productRepository.getById(orderItem.getProductId());
// 2. 재고를 감소합니다 (Product가 검증 + 감소를 함께 처리)
Product decreasedProduct = product.decreaseStock(orderItem.getQuantity());
productRepository.save(decreasedProduct);
// 3. 주문 항목을 저장합니다
orderItemRepository.save(orderItem);
// 4. 해당 항목의 총 가격을 계산해 반환합니다
return product.getTotalPrice(orderItem.getQuantity());
}
}
여기서 중요한 부분:
product.decreaseStock(orderItem.getQuantity())를 보세요. 이 메서드는:
- 재고가 충분한지 검증합니다 (Service가 할 필요 없음)
- 재고를 감소시킵니다
- 새로운 Product 객체를 반환합니다 (불변성 유지)
Domain 객체가 자신을 보호합니다. Service는 이 결과를 저장하기만 하면 됩니다.
단순해진 OrderLineAggregator
@Component
@RequiredArgsConstructor
public class OrderLineAggregator {
private final OrderLineAllocator allocator;
public PayAmount aggregate(List<OrderItem> orderItems) {
return new PayAmount(
orderItems.stream()
.map(allocator::allocate) // 각 항목을 allocate에게 위임
.reduce(BigDecimal.ZERO, BigDecimal::add) // 합산
);
}
}
이전 Step 1과의 차이:
| 측면 | Step 1 | Step 2 |
|---|---|---|
| 한 항목 처리 | map 내부에 숨어있음 | OrderLineAllocator 메서드로 드러남 |
| 메서드명 | aggregate (무엇을?) | allocate (명확함) |
| 테스트 | OrderLineAggregator만 가능 | OrderLineAllocator도 따로 테스트 가능 |
Step 3: 결제 처리를 격리하기
왜 결제를 따로 분리하는가?
OrderService를 봐도 여전히 깔끔하지 않습니다:
// 포인트 검증
if (userPoint.payable(payAmount)) {
throw new IllegalArgumentException(...);
}
// 주문 저장
Order savedOrder = orderRepository.save(order);
// 포인트 차감 및 결제
userPointRepository.save(userPoint.pay(payAmount));
Payment payment = Payment.create(savedOrder.getOrderId(), user.getUserId(), payAmount);
여전히 서비스에서 결제가 가능한지 포인트를 검증하고 결제합니다.
마치 우리 실제 세계의 누군가가 같은 책임을 가지고 있는 사람이 있을 것 같습니다.
해결책: OrderCashier - 결제를 전문으로 담당합니다
결제 담당자(Cashier)처럼, 포인트 차감과 결제 기록을 함께 처리합니다:
@Component
@RequiredArgsConstructor
public class OrderCashier {
private final UserPointRepository userPointRepository;
private final PaymentRepository paymentRepository;
@Transactional
public Payment checkout(User user, Order order, PayAmount payAmount) {
// 1. 포인트 조회
UserPoint userPoint = userPointRepository.getByUserId(user.getUserId());
// 2. 포인트 차감
// UserPoint.pay()는 다음을 처리합니다:
// - 포인트가 충분한지 검증
// - 포인트를 차감한 새 객체 반환
UserPoint decreasedPoint = userPoint.pay(payAmount);
userPointRepository.save(decreasedPoint);
// 3. 결제 기록 생성 및 저장
Payment payment = Payment.create(order.getOrderId(), user.getUserId(), payAmount);
paymentRepository.save(payment);
return payment;
}
}
여기서 중요한 부분:
userPoint.pay(payAmount)도 마찬가지로:
- 포인트가 충분한지 검증합니다 (Service가 미리 할 필요 없음)
- 포인트를 차감합니다
- 새로운 UserPoint 객체를 반환합니다
Domain 객체가 자신을 보호합니다. OrderCashier는 이 결과를 저장하고 결제 기록을 남기기만 하면 됩니다.
최종 OrderService
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserRepository userRepository;
private final OrderRepository orderRepository;
private final OrderLineAggregator orderLineAggregator;
private final OrderCashier orderCashier;
@Transactional
public Order order(OrderProductsCommand command) {
// 1. 사용자 조회
User user = userRepository.getByIdentifier(new UserIdentifier(command.getUserIdentifier()));
// 2. 주문 생성 및 저장
Order savedOrder = orderRepository.save(Order.create(user.getUserId()));
// 3. 주문 항목 생성
List<OrderItem> orderItems = command.getOrderItems().stream()
.map(productCommand -> OrderItem.create(
savedOrder.getOrderId(),
new ProductId(productCommand.getProductId()),
new Quantity(productCommand.getQuantity())
))
.toList();
// 4. 총 주문액 계산 (재고 확인 및 감소)
PayAmount payAmount = orderLineAggregator.aggregate(orderItems);
// 5. 결제 처리 (포인트 검증, 차감, 결제 기록)
orderCashier.checkout(user, savedOrder, payAmount);
return savedOrder;
}
}
비교하면:
| 단계 | Before | After |
|---|---|---|
| 1단계: 복잡한 가격 계산 | Service에서 직접 스트림 처리 | OrderLineAggregator 위임 |
| 2단계: 한 항목 처리 | Aggregator 내부에 숨음 | OrderLineAllocator로 명확화 |
| 3단계: 결제 처리 | Service에서 직접 처리 | OrderCashier 위임 |
이제 OrderService가 하는 일:
- 사용자를 조회한다
- 주문을 생성한다
- 항목들의 재고를 확인하고 가격을 계산한다
- 결제한다
- 주문을 반환한다
각 단계가 명확하고, 각 단계를 담당하는 컴포넌트도 명확합니다.
객체지향 설계 원칙 적용
| 원칙 | Before | After |
|---|---|---|
| SRP | Service가 너무 많은 책임 | 각 컴포넌트가 한 가지만 |
GRASP 패턴의 적용:
| 패턴 | Before | After | 설명 |
|---|---|---|---|
| Information Expert | Service가 Product/UserPoint의 내부를 알고 처리 | 각 객체가 자신의 책임을 처리 | 정보를 가진 객체가 그 정보로 판단 |
| Low Coupling | Service가 모든 Repository 의존 | 각 컴포넌트가 필요한 것만 의존 | 의존성을 최소화하여 변경 영향 감소 |
| High Cohesion | 응집도가 낮음 (재고, 가격, 결제가 한곳) | 높은 응집도 (관련 것들을 함께) | 관련된 책임들을 한 곳에 모음 |
| Polymorphism | 할인 정책 같은 변화 처리 어려움 | Strategy 패턴으로 확장 가능 | 다양한 구현을 인터페이스로 대응 |
GRASP 원칙 설명: 각각의 의미
Information Expert - 정보를 가진 자가 판단한다
Before:
OrderService가 판단: "재고가 충분한가?"
→ Product의 stock 필드를 직접 확인
→ Product의 내부 구조를 알아야 함
After:
Product가 판단: "내 재고가 충분한가?"
→ product.decreaseStock()이 검증 + 처리
→ Service는 "결과를 저장"만 함
이점: Service는 Product의 내부를 몰라도 되고, Product 규칙 변경 시 Product만 수정
Low Coupling - 의존성을 최소화한다
Before:
OrderService 의존성:
userRepository, userPointRepository,
productRepository, orderRepository,
orderItemRepository, paymentRepository
→ 6개 Repository 직접 의존
After:
OrderService 의존성:
userRepository, orderRepository,
orderLineAggregator, orderCashier
→ 4개 의존 (나머지는 Aggregator, Cashier가 처리)
OrderLineAllocator 의존성:
productRepository, orderItemRepository
OrderCashier 의존성:
userPointRepository, paymentRepository
이점: 변경 영향도가 감소, 테스트 시 필요한 Mock이 줄어듦
High Cohesion - 관련된 것들을 함께 모은다
Before:
재고 확인 & 가격 계산 & 포인트 검증이 모두 Service에
→ 무관한 것들이 한곳에 섞여있음
After:
OrderLineAllocator: 재고 & 가격 (관련 있음)
OrderCashier: 포인트 & 결제 (관련 있음)
OrderService: 전체 흐름 조율
→ 응집도가 높음
이점: 코드 이해가 쉽고, 변경 시 관련 코드를 한 곳에서 처리
Polymorphism - 다양한 구현을 인터페이스로 다룬다
현재: OrderLineAllocator가 모든 항목 처리
향후 확장 가능:
interface OrderLineAllocator {
BigDecimal process(OrderItem item);
}
class StandardOrderLineAllocator implements OrderLineProcessor { }
class DiscountOrderLineAllocator implements OrderLineProcessor { }
class SubscriptionOrderLineAllocator implements OrderLineProcessor { }
→ 할인, 구독 등 다양한 주문 방식을 추가 가능
→ OrderLineAggregator는 수정 불필요 (Open/Closed)
이점: 새로운 기능 추가 시 기존 코드 수정 없음
테스트의 극적인 개선
Before: 하나의 거대한 통합 테스트
@Test
public void testOrder() {
// 모든 Mock 설정: userRepository, userPointRepository, productRepository, ...
when(userRepository.getByIdentifier(...)).thenReturn(user);
when(userPointRepository.getByUserId(...)).thenReturn(userPoint);
when(productRepository.getById(...)).thenReturn(product);
// ... 더 많은 설정들
Order result = orderService.order(command);
// 뭘 검증해야 하는가?
// - 사용자가 조회됐는가?
// - 포인트가 차감됐는가?
// - 재고가 감소했는가?
// - 주문이 저장됐는가?
// 하나의 테스트에서 모든 것을 검증해야 함
}
After: 각 컴포넌트별 단위 테스트
@Nested
class OrderLineAllocatorTest {
@Test
void testAllocate_재고가_충분하면_가격을_반환한다() {
BigDecimal result = allocator.allocate(orderItem);
assertThat(result).isEqualTo(expectedPrice);
assertThat(productRepository.findById(...).getStock()).isEqualTo(decreased);
}
@Test
void testAllocate_재고가_부족하면_예외를_던진다() {
assertThatThrownBy(() -> allocator.allocate(orderItem))
.isInstanceOf(ProductOutOfStockException.class);
}
}
@Nested
class OrderCashierTest {
@Test
void testCheckout_포인트가_충분하면_결제한다() {
Payment result = cashier.checkout(user, order, payAmount);
assertThat(result).isNotNull();
assertThat(userPointRepository.findById(...).getBalance())
.isEqualTo(expectedBalance);
}
@Test
void testCheckout_포인트가_부족하면_예외를_던진다() {
assertThatThrownBy(() -> cashier.checkout(user, order, payAmount))
.isInstanceOf(NotEnoughUserPointBalanceException.class);
}
}
@Nested
class OrderServiceTest {
@Test
void testOrder_정상적으로_주문을_완료한다() {
Order result = orderService.order(command);
assertThat(result).isNotNull();
assertThat(result.getId()).isNotNull();
}
}
테스트의 개선:
- 각 컴포넌트를 독립적으로 테스트
- 각 테스트가 하나의 것만 검증
- Mock 설정이 간단해짐
도메인 객체의 책임이 명확해짐
Before: 도메인이 무관심한 상태
// Service가 판단: 포인트가 충분한가?
if (userPoint.payable(payAmount)) {
throw new NotEnoughUserPointBalanceException(...);
}
// Service가 판단: 재고가 충분한가?
if (!product.hasEnoughStock(orderItem.getQuantity())) {
throw new ProductOutOfStockException(...);
}
Service가 도메인의 내부 로직을 알고 판단합니다. 도메인이 스스로를 보호하지 않습니다.
After: 도메인이 자신을 보호함
// UserPoint가 자신을 보호합니다
UserPoint decreasedPoint = userPoint.pay(payAmount);
// pay() 메서드 내부에서:
// - 포인트 검증: if (this.balance < payAmount) throw ...
// - 포인트 차감: return new UserPoint(this.balance - payAmount)
// Product가 자신을 보호합니다
Product decreasedProduct = product.decreaseStock(orderItem.getQuantity());
// decreaseStock() 메서드 내부에서:
// - 재고 검증: if (this.stock < quantity) throw ...
// - 재고 감소: return new Product(this.stock - quantity)
이점:
- 비즈니스 규칙이 도메인에 응집됨
- Service는 도메인 내부를 몰라도 됨
- 도메인을 독립적으로 테스트 가능
- 도메인 규칙 변경 시 한 곳만 수정
왜 이렇게 분리해야 할까?
이 글을 통해 몰려있던 책임을 차근차근 분리했습니다.
- OrderLineAggregator로 “총액 계산”을 명확히 했고
- OrderLineAllocator로 “한 항목 처리”를 드러냈으며
- OrderCashier로 “결제 담당”을 명확히 했습니다
하지만 더 중요한 것은 이 분리가 무엇을 가져왔는가입니다.
핵심 가치
의도를 읽을 수 있게 된다
// Before: "이게 뭐하는 거지?"라는 질문이 든다
PayAmount payAmount = new PayAmount(
OrderedItems.stream().map(OrderedItem -> { ... }).reduce(...)
);
// After: 메서드명과 클래스명이 의도를 말해준다
PayAmount payAmount = orderLineAggregator.aggregate(OrderedItems);
orderCashier.checkout(user, savedOrder, payAmount);
코드는 기계가 읽는 것이 아니라 사람이 읽는 것입니다. 6개월 뒤 당신이 이 코드를 다시 봤을 때, 즉시 “아, 여기서는 주문 항목들을 정리하고 결제를 처리하는구나”라고 이해할 수 있어야 합니다.
변경에 강하다
만약 “포인트 대신 신용카드로도 결제할 수 있게 해달라”는 요구사항이 들어온다면?
Before:
- OrderService를 열고
- if문을 추가해서 결제 방식을 분기
- 결제 로직을 더 추가
- 전체 메서드가 더 복잡해짐
After:
- PaymentGateway 인터페이스를 새로 만들고
- CreditCardPaymentGateway, PointPaymentGateway 구현체 추가
- OrderCashier에 주입만 하면 됨
- OrderService는 전혀 건드리지 않음
새로운 기능을 추가할 때 기존 코드를 수정하지 않는 것. 이것이 Open/Closed 원칙이고, 이것이 가능해진 이유는 책임이 명확하게 분리되었기 때문입니다.
테스트 비용이 극적으로 낮아진다
Before에서는 하나의 기능을 테스트하기 위해 6개 Repository를 Mock으로 설정해야 했습니다.
테스트 코드가 복잡할수록 테스트 유지보수 비용도 올라갑니다.
After에서는:
- OrderLineAllocator? ProductRepository, OrderItemRepository만 Mock
- OrderCashier? UserPointRepository, PaymentRepository만 Mock
- OrderService? OrderLineAggregator, OrderCashier만 Mock
각 컴포넌트는 필요한 것만 의존하고, 필요한 것만 테스트합니다. 이것이 가능해진 이유도 책임이 명확하게 분리되었기 때문입니다.
목표는 “더 작은 클래스를 만드는 것”이 아닙니다.
목표는:
- 읽기 쉬운 코드를 만드는 것
- 변경에 유연한 구조를 만드는 것
- 테스트하기 쉬운 설계를 만드는 것
그 결과가 책임의 분리였을 뿐입니다.
당신의 코드를 봤을 때 “이 Service 메서드가 너무 크다”고 느껴진다면, 그것은 여러 책임이 섞여있다는 신호입니다.
책임을 분리하는 순간, 코드는 더 명확해지고, 더 유연해지고, 더 테스트하기 쉬워집니다.
