저번 포스팅에서는 같은 성격의 서비스들을 일관되게 사용하는 방법에 대해 알아보았다.
같은 성격의 서비스들은 비슷한 로직이 있고 이를 효율적으로 재사용하는 방법에 대해 알아보려고한다.
@Service
public interface PaymentService {
PaymentDto payForOrder(PaymentDto paymentDto, Long orderId);
}
PaymentService 는 payForOrder 라는 주문에 대한 결제를 완료하는 메서드를 정의하고 있고,
PamentService 의 구현체인 현금, 카드, 모바일 결제서비스에서 이 메서드를 각자 구현하고 있는 형태이다.
위와 같은 구조에서 결제 정보들은 PaymentRepository 를 통해 하나의 테이블로 관리하고,
각 결제수단에 따른 부가정보들은 테이블을 분리하여 별도로 관리한다.
PaymentService 의 구현체인 CashPaymentService 를 살펴보자.
@RequiredArgsConstructor
public class CashPaymentService implements PaymentService {
private final CashPaymentRepository cashPaymentRepository;
private final OrderService orderService;
private final ItemService itemService;
@Override
@Transactional
public PaymentDto payForOrder(PaymentDto paymentDto, Long orderId) {
//공통로직
Order order = orderService.getOrder(orderId);
Payment payment = order.getPayment(); // 사용자가 주문을 완료하면 결제대기 상태로 만들어져있다
List<OrderItem> orderItems = order.getOrderItems(); // 주문에서 담은 상품목록
// 기존의 Payment 객체의 id 값을 제외하고 변경
payment = Payment.builder()
.paymentStatus(PaymentStatus.COMP) // 결제완료
.id(payment.getId())
.price(order.getTotalPrice()) // 결제금액
.createdAt(LocalDateTime.now()) // 결제시간
.paymentType(paymentDto.getPaymentType()) // 결제방법
.build();
/* 변경된 객체를 따로 저장하지 않는다. -> JPA의 dirty checking 을 통한 DB call */
/* 주문이 완료되면 아이템의 전체 재고에서 주문수량만큼 빼주어야한다. */
orderItems.forEach((orderItem) ->
itemService.decrementStock(orderItem.getItem().getId(), orderItem.getQuantity())
);
/*=====================================================================================*/
//개별로직
CashPaymentDto cashPaymentDto = (CashPaymentDto) paymentDto;
CashPayment cashPayment = cashPaymentDto.toEntity();
cashPayment.setPayment(payment);
cashPaymentRepository.save(cashPayment);
/*=====================================================================================*/
//다시 공통로직
return PaymentDto.toDto(payment);
}
}
구현체들은 공통적으로 주문객체를 불러와 해당하는 주문의 결제정보와 주문상품들에 접근한다. 디폴트 값인 결제 준비상태로 되어있는 결제정보 객체에 결제상태, 금액, 시간, 방법들을 저장하고, 결제가 완료됐으니 상품들의 재고를 차감해야한다. 이 부분은 결제수단이 달라지더라도 변하지 않는다.
그리고 각 결제수단에 따라 다른 부가정보를 저장하는데 결제수단마다 로직이 달라질 수 있다. 즉 결제수단이 달라지면 변하는 부분이다.
다시 결제정보를 DTO 로 변경하며 결제완료정보를 리턴한다. 역시 변하지 않는다.
결제완료 정보를 저장하고, 상품재고차감, 정보 리턴이라는 변하지 않는 공통로직 사이에 개별적인 로직이 있는 형태이다.
변하는 부분이 변하지않는 부분을 감싸고있기 때문에 변하지 않는 부분만을 추출하여 재사용하기가 어려워보인다..
1. 추상클래스를 이용한 공통부분 재사용 - 템플릿 메서드 패턴
변하지 않는 부분을 추출하기 어려워, 변하는 부분을 추출해보자. 변하지 않는 부분은 부모타입에 두고 변하는 부분을 추상 메소드로 정의, 각 결제수단 마다 해당하는 메서드를 오버라이딩하는 형태로 바꿔보는 것이다.
기존의 인터페이스로 만들었던 PaymentService 를 추상클래스로 바꿔보자.
@RequiredArgsConstructor
public abstract class PaymentService {
private final OrderService orderService;
private final ItemService itemService;
@Transactional
public PaymentDto payForOrder(PaymentDto paymentDto, Long orderId) {
Order order = orderService.getOrder(orderId);
Payment payment = order.getPayment();
List<OrderItem> orderItems = order.getOrderItems();
payment = Payment.builder()
.paymentStatus(PaymentStatus.COMP)
.id(payment.getId())
.price(order.getTotalPrice())
.createdAt(LocalDateTime.now())
.paymentType(paymentDto.getPaymentType())
.build();
orderItems.forEach((orderItem) ->
itemService.decrementStock(orderItem.getItem().getId(), orderItem.getQuantity())
);
//개별로직 추출
savePaymentInfo(payment);
return PaymentDto.toDto(payment,paymentDto);
}
protected abstract void savePaymentInfo(Payment payment, PaymentDto paymentDto);
}
다음과같이 공통부분을 payForOrder() 메서드로 구현하고 결제수단별 부가정보를 저장하는 로직은 savePaymentInfo() 라는 추상메서드로 정의한다.
@RequiredArgsConstructor
public class CashPaymentService extends PaymentService {
private final CashPaymentRepository cashPaymentRepository;
@Override
protected void savePaymentInfo(Payment payment, PaymentDto paymentDto) {
CashPaymentDto cashPaymentDto = (CashPaymentDto) paymentDto;
CashPayment cashPayment = cashPaymentDto.toEntity();
cashPayment.setPayment(payment);
cashPaymentRepository.save(cashPayment);
}
}
이제 결제수단에 따라 PaymentService 를 상속받아 savePaymentInfo() 메서드를 구현하기만 하면된다!
결제수단이 추가될 때에도 같은 방법으로 확장한다면 상위타입에 변화가 생기지 않으므로 객체지향적으로 설계하면서 코드를 재사용할 수 있다.
하지만,
이러한 상속을 통한 확장에는 문제가 있다. 결제수단이 새롭게 추가될 때마다 상속을 통해 새로운 클래스를 만들어야한다. 결제수단이 n개가 되면 n개의 서브클래스를 만들어서 사용해야한다.
또한, 재사용성을 고려한 설계로 바꾸면서 기존에 인터페이스에만 의존하던 부분을 추상클래스에 의존하도록 바뀌었다.
컴파일 시점에서 슈퍼-서브 타입의 관계가 결정되기 때문에, 확장구조가 고정되어 유연성이 떨어진다는 단점이 있다.
2. 다시 한 번 전략 패턴
상속을 통한 확장의 문제점을 해결하기 위해,
기존의 인터페이스를 통해서만 의존하는 전략 패턴을 유지하면서 코드를 재사용하는 방법에 대해서 알아보자.
먼저 기존의 추상메서드의 위치를 PaymentInfoStrategy 라는 인터페이스로 옮긴다. 이렇게 함으로써 공통로직과 개별로직을 아예 분리할 수 있다.
public interface PaymentInfoStrategy {
void savePaymentInfo(Payment payment, PaymentDto paymentDto);
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final OrderService orderService;
private final ItemService itemService;
@Transactional
public PaymentDto payForOrder(PaymentInfoStrategy paymentInfoStrategy,
PaymentDto paymentDto, Long orderId) {
Order order = orderService.getOrder(orderId);
Payment payment = order.getPayment();
List<OrderItem> orderItems = order.getOrderItems();
payment = Payment.builder()
.paymentStatus(PaymentStatus.COMP)
.id(payment.getId())
.price(order.getTotalPrice())
.createdAt(LocalDateTime.now())
.paymentType(paymentDto.getPaymentType())
.build();
orderItems.forEach((orderItem) ->
itemService.decrementStock(orderItem.getItem().getId(), orderItem.getQuantity())
);
// 구현체를 직접 선택하지 않는다.
paymentInfoStrategy.savePaymentInfo(payment, paymentDto);
return PaymentDto.toDto(payment,paymentDto);
}
}
여기서 주목할 점은 PaymentInfoStategy 타입의 구현체를 PaymentService 안에서 아래와 같이
paymentInfoStrategy paymentInfoStrategy = new CashPaymentService();
객체를 생성하여(혹은 스프링 IoC로부터 주입받아서) 직접 선택하는 것이 아니라 파라미터를 통해 외부로 부터 주입받는 것인데
그 이유는 인터페이스에만 의존하기 위해 추상화를 적용하였는데 하위타입인 CashPaymentService 와의 의존성이 생기기 때문이다.
이를 끊어내는 것이 전략패턴의 핵심이고 구현체 선택의 책임은 팩토리 등을 통한 외부로 떠넘겨야한다.
@Component
@RequiredArgsConstructor
public class PaymentFactory {
private final CashPaymentService cashPaymentService;
private final CreditPaymentService creditPaymentService;
private final MobilePaymentService mobilePaymentService;
public PaymentService getType(PaymentType paymentType) {
final PaymentInfoStrategy paymentInfoStrategy;
switch (paymentType) {
case CASH:
paymentInfoStrategy = cashPaymentService;
break;
case CREDIT:
paymentInfoStrategy = creditPaymentService;
break;
case MOBILE:
paymentInfoStrategy = mobilePaymentService;
break;
default:
throw new IllegalArgumentException();
}
return paymentInfoStrategy;
}
}
이를 위해 기존에 다음과 같은 팩토리를 적용했었다.
인터페이스에만 의존하고, 재사용성까지 고려하여 중복코드도 제거하였지만, 여전히 n개의 결제수단마다 n개의 서브클래스가 만들어지는 문제는 남아있다.
3. 템플릿 콜백 패턴 적용
한 단계 더 나아가 람다를 활용한 콜백메서드를 통해 n개의 서브클래스를 만들지 않는 방법에 대해서도 알아보자.
지금까지 구조는 Client 로부터 컨트롤러로 요청이 들어오면 컨트롤러는 결제타입을 팩토리에 보내 결제타입에 맞는 PaymentInfoStrategy 구현체를 결정하고 이를 리턴해준다. 컨트롤러는 다시 이 구현클래스를 통해 서비스에게 요청을 보내는 구조이다.
팩토리 입장에서는 이미 구현된 클래스들을 가지고 있다가 타입에 맞춰 건내주는 것이기 때문에 n개의 결제수단 만큼 클래스들이 미리 만들어져 있어야 했던 것이다.
팩토리의 역할을 payForOrder() 가 대신하고, payForOrderWithStrategy() 에서 비즈니스로직을 담당한다. 즉, payForOrder() 에서 타입에 따라 실행방법을 정의해서 파라미터로 넘겨주면, 공통로직을 실행하다가 개별로직을 실행해야 할 때 파라미터로 넘겨받은 방법대로 실행한다.
기존 방법과의 차이점은,
실행방법을 클래스로 미리 생성해놓고 선택하는 것이 아닌, 요청이 들어올 때마다 실행방법이 적혀있는 메소드를 전달하는 것이다.
자바에서는 메소드 자체를 파라미터로 전달할 방법이 없기 때문에 메소드가 담긴 오브젝트를 전달해야 한다.
@Controller
@RequestMapping("/api/orders/payment")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
@PostMapping(value = "/{orderId}")
public ResponseEntity payForOrder(@RequestBody PaymentDto paymentDto, @PathVariable Long orderId) {
return ResponseEntity.ok(paymentService.payForOrder(paymentDto, orderId));
}
}
이제 컨트롤러는 Service를 호출하는 역할만하고,
/**
* 결제 방법을 결정하는 역할
* 기존의 Service 를 선택하는 PaymentFactory 를 대체
*/
public PaymentDto payForOrder(PaymentDto paymentDto, Long orderId) {
switch (paymentDto.getPaymentType()) {
case CASH:
return payForOrderWithStrategy(payment -> {
CashPaymentDto cashPaymentDto = (CashPaymentDto) paymentDto;
CashPayment cashPayment = cashPaymentDto.toEntity();
cashPayment.setPayment(payment);
cashPaymentRepository.save(cashPayment);
}, paymentDto, orderId);
case CREDIT:
return payForOrderWithStrategy(payment -> {
CreditPaymentDto creditPaymentDto = (CreditPaymentDto) paymentDto;
CreditPayment creditPayment = creditPaymentDto.toEntity();
creditPayment.setPayment(payment);
creditPaymentRepository.save(creditPayment);
}, paymentDto, orderId);
case MOBILE:
return payForOrderWithStrategy(payment -> {
MobilePaymentDto mobilePaymentDto = (MobilePaymentDto) paymentDto;
MobilePayment mobilePayment = mobilePaymentDto.toEntity();
mobilePayment.setPayment(payment);
mobilePaymentRepository.save(mobilePayment);
}, paymentDto, orderId);
default:
throw new IllegalArgumentException();
}
}
payForOrder() 에서 타입에 따라 실행방법을 적어서 payForOrderWithStrategy() 메서드를 호출한다.
현재는 비슷한 동작을 하는 예제코드이다. 실행방법을 기술하는 부분에 다른 로직을 적용할 수 있다는 점을 짚고넘어가자.
@Transactional
public PaymentDto payForOrderWithStrategy(PaymentInfoStrategy paymentInfoStrategy,
PaymentDto paymentDto, Long orderId) {
Order order = orderService.getOrder(orderId);
Payment payment = order.getPayment();
List<OrderItem> orderItems = order.getOrderItems();
payment = Payment.builder()
.paymentStatus(PaymentStatus.COMP)
.id(payment.getId())
.price(order.getTotalPrice())
.createdAt(LocalDateTime.now())
.paymentType(paymentDto.getPaymentType())
.build();
orderItems.forEach((orderItem) ->
itemService.decrementStock(orderItem.getItem().getId(), orderItem.getQuantity())
);
paymentInfoStrategy.savePaymentInfo(payment);
return PaymentDto.of(payment);
}
마무리
참고
'Spring' 카테고리의 다른 글
어떻게 성격이 비슷한 여러 종류의 서비스를 추상화할까? - 전략 패턴을 통한 추상화 (0) | 2019.12.21 |
---|---|
@AuthenticationPrincipal - 현재 사용자 조회하기 (4) | 2019.09.20 |