본문 바로가기

Spring

어떻게 성격이 비슷한 여러 종류의 서비스를 추상화할까? - 전략 패턴을 통한 추상화

 

스프링과 JPA를 통해 오픈마켓의 결제 서비스를 구현해보자. 결제방식에는 (무통장입금, 카드, 모바일) 3가지 결제방식이 있다고 가정하자.
총 결제금액, 결제상태, 결제시간에 대한 정보는 공통으로 가지고 있고, 각 결제방식에 따라 다른 정보들을 포함하고있다.

 

첫 번째 포스팅에서는 일관된 방법으로 여러 종류의 서비스를 사용하는 방법을 고민해보려고한다.

 

 

 

 

 

 

가장 쉽게 생각할 수 있는 방법은 결제방식 마다 각각의 컨트롤러, 서비스, 레포지토리 를 두는 구조이다.

 

이 방법의 문제점을 생각해보면,

 

 공통로직이 변경되었을 때 각 결제방법마다 해당하는 로직을 모두 변경해주어야 한다는점이다. 단순 반복 되는 코드는 실수가 생기기 마련이고 반복작업과 디버깅 작업에는 시간적비용이 많이든다.

 또한 새로운 결제방식이 추가되었을 때, 컨트롤러, 서비스, 레포지토리 클래스를 모두 생성해야하고 비슷한 코드에 대한 단순 복붙작업이 반복된다는 것.

 

따라서, 변경이나 확장될 가능성이 있다면, 공통된 부분을 추상화하여 일관된 방법으로 서비스를 사용해보자.

 

 

1. JPA 상속 관계 매핑을 통한 추상화

객체지향의 상속과 데이터베이스의 슈퍼타입 서브타입 관계를 매핑하는 JPA 전략인 상속 관계 매핑을 통해 이를 해결하자.

 

                                 객체 상속 모델                                                              상속 관계 매핑 - 조인 전략 (Joined Strategy) 

 

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Inheritance(strategy = InheritanceType.JOINED) // 조인 전략
@DiscriminatorColumn // 타입 개념이 없는 테이블에서 타입을 구분하기위한 컬럼
public abstract class Payment {

    @Id
    @GeneratedValue
    @Column(name = "payment_id")
    private Long id;
    private int price;
    private PaymentStatus paymentstatus;
}
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class CashPayment extends Payment {

/* 각각의 다른 정보를 포함하고있다. */
    private String bank;
    private String bankAccount;
    private String name;
}

public class CreditPayment extends Payment {

    private String bank;
    private String cardNumber;
    private String name;

}

public class MobilePayment extends Payment {

    private String phone;
    private String telco;
    private String name;
}

 

 엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본 키를 받아 기본 키 + 외래 키로 사용하는 전략으로, 

Client 에서 보내주는 메시지에서 @RequestBody 를 통해 부모타입인 Payment로 받아온다.

 이 때, 하위타입인 각 결제방식의 Service와 Repository 로 받지 않고 부모타입의 PaymentService, PaymentRepository 를 통해 저장하면, 매칭되는 필드정보를 통해 자식클래스의 테이블에 DB call 되는 방식이다. 

 

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/payment")
public class PaymentController {
    private final PaymentService paymentService;

    @PostMapping("/payments")
    public ResponseEntity payForOrder(@RequestBody Payment payment) {
    	return ResponseEntity.ok(paymentService.payForOrder(payment));
    }
}

@Service
@RequiredArgsConstructor
public class PaymentService {
    private final PaymentRepository paymentRepository;

    public Payment payForOrder(Payment payment) {
       return paymentRepository.save(payment);
    }
}

public interface PaymentRepository extends JpaRepository<Payment, Long> {
}

 

다음과 같은 구조를 가능하게한다!

 

얼핏 봤을 때 굉장히 단순한 구조로 로직의 변경에도, 결제방식이 추가되더라도 책임이 있는 해당 클래스만 변경하면 되므로 해결한듯(?) 하다. 하지만 결제수단이 n개로 확장됨에따라 n개의 테이블을 만든다면, JPA외의 툴을 사용할 때 상당히 복잡할 것이다.

 

조인 전략은,
조회할 때 조인이 많이 사용되고, 조회 쿼리가 복잡하다. 따라서 MyBatis 같은 쿼리 매퍼를 통해 구현할 때 효율적이지 못함.
조회쿼리가 단순한 단일 테이블 전략을 사용하더라도 테이블의 증가에따라 매핑한 컬럼은 모두 null을 허용해야 한다는 단점이 있다.

결정적으로 위의 방식은 공통으로 적용되는 로직외에 각 결제방식마다 다른 서비스 로직을 적용하지 못한다. 즉, 단순히 분류되는 데이터에 대한 저장 이라는 "동작" 의 추상화 일 뿐이다.

 

 

 

 

2. 전략 패턴을 통한 Service Layer 추상화

다시 문제상황에 접근해보자! 저장하는 결제테이블 정보는 하나만 저장하도록 하고, 각 결제방식에 따른 부가정보는 별도의 테이블을 통해 관리 하도록 하자. 또한, 각 결제 방식에 따른 비즈니스 로직을 달리하기위에 Service Layer에서의 추상화를 적용하자.

 

 

 다음과 같은 형태로 Controller 는 오직 인터페이스만 의존한다. 인터페이스에서는 payForOrder() 라는 결제기능만 정의하고, 구현방법은 구현클래스들이 알아서 결정할 일이다. 코드에 변경이 필요할 때에는 해당 결제방법의 payForOrder() 메서드만 수정하면되고, 다른 결제 방법이 추가될 때에도 PaymentService 인터페이스를 implements 하고 payForOrder() 만 구현하면 다른 클래스는 수정하지 않아도된다. 

 

어떤 서비스 전략을 사용할 것인지 앞단에서 선택하기만 하면된다! 이를 전략(Strategy) 패턴이라고 한다.

 

이제야 뭔가 만족스럽게 추상화를 한 것 같다. 하지만 여기에도 문제가 있다. Controller 코드를 살펴보자.

 

public enum PaymentType {
    CREDIT, CASH, MOBILE
}

@Controller
@RequestMapping("/api/orders/payment")
@RequiredArgsConstructor
public class PaymentController {

    private final CashPaymentService cashPaymentService;
    private final CreditPaymentService creditPaymentService;
    private final MobilePaymentService mobilePaymentService;

    @PostMapping
    public ResponseEntity payForOrder(@RequestBody PaymentDto paymentDto) {
    	final PaymentService paymentService;

	if(paymentDto.getPaymentType() == PaymentType.CASH) {
		paymentService = cashPaymentService;	
        } else if(paymentDto.getPaymentType() == PaymentType.CREDIT) {
        	paymentService = creditPaymentService;
        } else if(paymentDto.getPaymentType() == PaymentType.MOBILE) {
        	paymentService = mobilePaymentService;
        } else {
   		   ...
        }
        
        return paymentService.payForOrder(paymentDto);
    }
}

 

 클라이언트로부터 받아오는 메시지안의 결제 타입에 따라 해당하는 Service 로 분기하는 방법이다.

 

틀린방법은 아니다..

새로운 결제 방식이 추가될 때마다 생성자를 통해 해당하는 Service 를 주입받고 , else if 문을 하나 더 추가해주면 된다.  

 

여기서 생각해 볼 점은,
PaymentController 는 URI를 통해 Client 측으로 받아오는 메시지를 서비스 측으로 전달하는 책임이 있는 클래스이다. 타입에따라 Service를 주입하는 책임까지 있다면?

이는 결제방식이 추가될 때마다 PaymentController 의 코드에 변경이 불가피하다는 것을 의미한다.
왜? 모든 결제방식을 알고있어야 적절한 Service 를 선택해서 넘겨줄테니까.

 

이 문제를 해결하기 위해 두 책임을 분리해보자.

 

@Component
@RequiredArgsConstructor
public class PaymentFactory {

    private final CashPaymentService cashPaymentService;
    private final CreditPaymentService creditPaymentService;
    private final MobilePaymentService mobilePaymentService;

    public PaymentService getType(PaymentType paymentType) {
        final PaymentService paymentService;

        switch (paymentType) {
            case CASH:
                paymentService = cashPaymentService;
                break;
            case CREDIT:
                paymentService = creditPaymentService;
                break;
            case MOBILE:
                paymentService = mobilePaymentService;
                break;
            default:
                throw new IllegalArgumentException();
        }
        return paymentService;
    }
}

 

PaymentFactory 클래스는 PaymentController 에서 결제타입에 따라 분기하는 부분만을 추출한 클래스이다.

기존의 컨트롤러 코드는 다음과 같이 바뀐다.

 

@Controller
@RequestMapping("/api/orders/payment")
@RequiredArgsConstructor
public class PaymentController {
    private final PaymentFactory paymentFactory;

    @PostMapping(value = "/{orderId}")
    public ResponseEntity payForOrder(@RequestBody PaymentDto paymentDt) {

        final PaymentService paymentService = paymentFactory.getType(paymentDto.getPaymentType());

        return ResponseEntity.ok(paymentService.payForOrder(paymentDto, orderId));
    }
}

 

Payment 인터페이스의 구현체를 선택하는 책임은 PaymentFactory 클래스에만 있기때문에 결제방식의 확장에도 PaymentController 코드는 변하지 않는다. 팩토리를 통해 책임을 분리함으로써, 로직의 확장에는 열려있고 코드의 변경에는 닫혀있는 개방-폐쇄 원칙(OCP : Open-Closed Principal)을 잘 지키는 구조이다.    

 

마무리

성격이 비슷한 여러종류의 서비스에 일관된 방법으로 서비스를 사용하도록 추상화하는방법을 고민해 보았다.

 

무조건 두 번째로 소개한 방법처럼 해야한다고 생각하지 않는다. 확장될 가능성이 아예 없는 서비스라면 각각의 서비스마다 컨트롤러, 서비스, 레포지토리를 만드는 것은 큰 비용이 들지 않는다. 오히려 전략패턴을 적용하는 것이 더 많은 비용이 들어갈 것이다. 또 단순히 테이블이 분류되는 구조에서는 JPA를 통해 효율적으로 추상화하는 방법도 좋은 방법이다.

 

그럼에도 불구하고, 유연한 구조로 설계하는 초기의 작은노력이 나중에 발생하는 거대한 노력을 줄일 수 있다.

          

참고