들어가며
프로젝트를 진행하다 보면 한 가지 고민을 하게 됩니다.
사용자 조회, 포인트 검증, 재고 확인, 주문 저장, 결제 처리…
한 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: 전체 흐름 조율
→ 응집도가 높음
이점: 코드 이해가 쉽고, 변경 시 관련 코드를 한 곳에서 처리
테스트의 극적인 개선
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에서는 하나의 기능을 테스트하기 위해 6개 Repository를 Mock으로 설정해야 했습니다.
테스트 코드가 복잡할수록 테스트 유지보수 비용도 올라갑니다.
After에서는:
- OrderLineAllocator? ProductRepository, OrderItemRepository만 Mock
- OrderCashier? UserPointRepository, PaymentRepository만 Mock
- OrderService? OrderLineAggregator, OrderCashier만 Mock
각 컴포넌트는 필요한 것만 의존하고, 필요한 것만 테스트합니다. 이것이 가능해진 이유도 책임이 명확하게 분리되었기 때문입니다.
목표는 “더 작은 클래스를 만드는 것”이 아닙니다.
목표는:
- 읽기 쉬운 코드를 만드는 것
- 변경에 유연한 구조를 만드는 것
- 테스트하기 쉬운 설계를 만드는 것
그 결과가 책임의 분리였을 뿐입니다.
당신의 코드를 봤을 때 “이 Service 메서드가 너무 크다”고 느껴진다면, 그것은 여러 책임이 섞여있다는 신호입니다.
책임을 분리하는 순간, 코드는 더 명확해지고, 더 유연해지고, 더 테스트하기 쉬워집니다.
