ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 결제 기능 - PaymentService에 구현체 주입하기
    프로젝트 2022. 4. 13. 22:23
    프로젝트 결제 구조

    프로젝트의 결제 서비스를 구현하는 도중 어려움에 직면해 현재 고민하고 있는 내용을 작성해보고자 한다. 위 그림을 보면 PaymentService를 인터페이스로 두고, 네 가지 결제 클래스가 해당 인터페이스를 구현하도록 되어 있다. 이전까지 다른 서비스를 작성할 때는 구체 클래스가 하나뿐이어서 괜찮았지만, 이번 결제 서비스는 이용자가 어떤 결제 수단을 선택하느냐에 따라 다른 타입의 결제 클래스를 주입해야 했다. 경험하지 못한 부분이어서 이것저것 책이나 다른 분들의 코드를 참고하기도 했고, 멘토님께도 약간의 도움을 받으며 PaymentService에 구체 타입을 주입하는 두 가지 방법을 적용해보기로 했다. 첫 번째는 Factory 클래스를 만들어 해당 클래스에서 구체 타입을 주입하는 방법이고, 두 번째는 Controller에서 각 결제 방식의 API를 만드는 방법이다. (참고로 작성한 코드는 피드백을 받지 않은 상태여서 정답이 절대 아니고, 고민의 흔적으로만 봐야 한다..)

    (수정) 고민 끝에 Factory 클래스를 이용하기로 했다. (이유는 결론에)

    (1) Factory 클래스 이용하기

    @Component
    @RequiredArgsConstructor
    public class PaymentFactory {
    
    	private final ContactPaymentService contactPayment;
    	private final CreditCardService creditCard;
    	private final DepositService deposit;
    	private final KakaoPayService kakaoPay;
    
    	public PaymentService getType(PaymentType paymentType) {
    		final PaymentService paymentService;
    
    		switch (paymentType) {
    			case CONTACT_PAYMENT:
    				paymentService = contactPayment;
    				break;
    			case CREDIT_CARD:
    				paymentService = creditCard;
    				break;
    			case DEPOSIT:
    				paymentService = deposit;
    				break;
    			case KAKAO_PAY:
    				paymentService = kakaoPay;
    				break;
    			default:
    				throw new IllegalArgumentException();
    		}
    
    		return paymentService;
    	}
    
    }

    팩토리는 객체를 만드는 공장이라고 이해하면서 작업을 진행했다. 팩토리 클래스 안에는 PaymentService 객체를 리턴하는 팩토리 메소드가 있고, 메소드 내에서 구체 타입을 주입한다. switch문을 보면 paymentType(이용자가 선택한 결제 수단)에 따라 PaymentService에 구체 타입을 주입하고, 리턴하는 것을 알 수 있다.

    @RestController
    @Api(tags = {"결제 컨트롤러 API"})
    @AllArgsConstructor
    @RequestMapping("/{orderId}/payment")
    public class PaymentController {
    
    	private final PaymentFactory paymentFactory;
    
    	@PostMapping
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "결제")
    	public void pay(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		final PaymentService paymentService = paymentFactory.getType(requestPaymentDTO.getPaymentType());
    
    		paymentService.pay(orderId, userId, requestPaymentDTO);
    	}
    
    }

    컨트롤러는 PaymentService에 의존하는 대신 PaymentFactory에 의존하고, 해당 클래스의 팩토리 메소드를 호출한다. 팩토리 클래스가 어떤 걸 주입할지 알아서 해주기 때문에 컨트롤러는 구체 클래스를 몰라도 된다. 새로운 결제 방식이 생기더라도 컨트롤러는 수정할 필요가 없을 것이다. 다만 팩토리 메소드의 switch문이 길어질 수는 있을 것 같다.

    (2) 결제 방식에 따라 API 만들기


    이 방식은 작성 중에 문제가 생겼는데, 일단 작성이라도 해보자는 마음으로 작성해보았다.

    @Service
    @AllArgsConstructor
    public class CreditCardService implements PaymentService {
    
    	private final PaymentDao paymentDao;
    
    	@Override
    	public void pay(Long orderId, String userId, RequestPaymentDTO requestPaymentDTO) {
    
    		PaymentDTO paymentDTO = new PaymentDTO(
    			orderId, userId, PaymentType.CREDIT_CARD,
    			requestPaymentDTO.getAmountPaid(), PaymentStatus.BEFORE_CHECK, LocalDateTime.now()
    		);
    
    		paymentDao.insertPayment(paymentDTO);
    
    	}
    
    }

    구현 클래스는 PaymentDao에 의존하고, PaymentService 인터페이스의 pay 메소드를 오버라이딩 하고 있다. 나머지 세 구현 클래스도 같은 구조로 되어 있다.

    @RestController
    @Api(tags = {"결제 컨트롤러 API"})
    @AllArgsConstructor
    @RequestMapping("/{orderId}/payment")
    public class PaymentController {
    
    	private PaymentService paymentService;
    	private CreditCardService creditCardService;
    	private DepositService depositService;
    	private KakaoPayService kakaoPayService;
    	private ContactPaymentService contactPaymentService;
    
    	@PostMapping("/credit_card")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "카드로 결제")
    	public void payCreditCard(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		creditCardService.pay(orderId, userId, requestPaymentDTO);
    	}
    
    	@PostMapping("/deposit")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "계좌이체로 결제")
    	public void payDeposit(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		depositService.pay(orderId, userId, requestPaymentDTO);
    	}
    
    	@PostMapping("/kakaopay")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "카카오페이로 결제")
    	public void payKakaoPay(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		kakaoPayService.pay(orderId, userId, requestPaymentDTO);
    	}
    
    	@PostMapping("/contact_pay")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "만나서 결제")
    	public void payContact(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		contactPaymentService.pay(orderId, userId, requestPaymentDTO);
    	}
    
    }

    문제가 발생한 부분은 컨트롤러였다. 컨트롤러가 인터페이스인 PaymentService에만 의존하게 하고, 각 API에서 구체 타입으로 초기화하고 싶었는데 불가능했다. 이유는 구현 클래스가 PaymentDao를 의존하는데, @AllArgsConstructor 어노테이션을 선언해서 PaymentDao가 생성자의 매개변수로 와야 하기 때문으로 보인다. PaymentDao는 인터페이스로 선언되어 있기 때문에 매개변수로 사용하려면 내부에 선언된 추상 메소드를 오버라이딩 하는 익명 클래스를 구현해야 한다. 하지만 PaymentDao는 매핑 파일에 있는 SQL을 호출해주는 용도로 원래 인터페이스로 선언되어야 하기 때문에, 익명 클래스로 구현한다는 건 잘못된 방법이라는 생각이 들었다. 어쩔 수 없이 컨트롤러에서 결제 서비스의 구체 클래스들을 직접 의존하게 되었다. 다른 방법이 있는지 고민했지만 너무 오래 시간을 빼앗기는 듯하여 일단 이 상태로 두었다.
    이렇게 작성하면, PaymentService 인터페이스를 만든 의미가 없게 된다. 만약 기존 결제 방식에 변경되는 부분이 있다면, 컨트롤러도 변경해야 하고, 삭제할 경우에도 컨트롤러를 건드려야 한다. 또한 새로운 결제 방식이 생길 때마다 컨트롤러에 구체 클래스에 대한 의존성을 추가해야 하고, 새 API도 만들어야 한다. 컨트롤러가 매우 복잡해지고, 중복되는 코드도 많아질 것이다. 코드의 양도 많고, 변경에도 취약해 매우 좋지 않은 코드로 보인다.

    (3) 결제 방식에 따라 API 만들기 - 전략 패턴


    책에서 전략 패턴을 공부하게 되었는데, 결제 서비스에 적용해보면 이해가 더 잘 될 것 같아 시도해보기로 했다. 이 방식 역시 아직 고민 중인 부분이기 때문에 좋은 코드는 아닐 수 있다.. 우선 전략 패턴은 제 3자인 클라이언트가 다양한 전략 중 하나를 선택하여 전략 객체를 생성하고, 생성한 전략 객체를 컨텍스트에 주입하는 패턴으로 정의되어 있다. 여기서 컨텍스트는 전략 객체를 직접 사용하는 사용자로 볼 수 있다. 전략 패턴을 이용하면 새로운 전략을 추가하더라도, 컨텍스트의 코드는 변경할 필요가 없다고 한다. 변경에는 닫혀있고, 확장에는 열려있어야 한다는 OCP 원칙을 잘 지킬 수 있을 것으로 보인다. 다만 전략 객체를 생성하여 컨텍스트에 주입해주는 클라이언트는 구체적인 전략을 알아야 하기에 클라이언트 코드는 복잡해질 수 있을 것 같다.

    public interface PaymentStrategy {
    
    	PaymentDTO getPaymentDTO(Long orderId, String userId, RequestPaymentDTO requestPaymentDTO);
    
    }

    전략 패턴을 적용하기 위해 PaymentStrategy 인터페이스를 만들고, 각 결제 전략 클래스를 만들어 해당 인터페이스를 구현하기로 했다. 결제 전략은 PaymentDTO 타입을 리턴한다.

    @Component
    public class CreditCardStrategy implements PaymentStrategy {
    
    	@Override
    	public PaymentDTO getPaymentDTO(Long orderId, String userId, RequestPaymentDTO requestPaymentDTO) {
    		return new PaymentDTO(
    			orderId, userId, PaymentType.CREDIT_CARD,
    			requestPaymentDTO.getAmountPaid(), PaymentStatus.BEFORE_CHECK, LocalDateTime.now()
    		);
    	}
    
    }

    예를 들어 CreditCardStrategy는 위와 같은 PaymentDTO 타입을 생성하여 리턴한다.

    @Service
    @AllArgsConstructor
    public class PaymentService {
    
    	private final PaymentDao paymentDao;
    
    	public void pay(Long orderId, String userId, RequestPaymentDTO requestPaymentDTO, PaymentStrategy paymentStrategy) {
    		paymentDao.insertPayment(paymentStrategy.getPaymentDTO(orderId, userId, requestPaymentDTO));
    	}
        
    }

    PaymentService는 매개변수로 PaymentStrategy 타입을 받아와 getPaymentDTO 메소드를 호출한 값을 로직에 이용한다. 전략을 주입받아 사용하는 PaymentService를 전략 패턴의 컨텍스트로 판단하였다.

    @RestController
    @Api(tags = {"결제 컨트롤러 API"})
    @AllArgsConstructor
    @RequestMapping("/{orderId}/payment")
    public class PaymentController {
    
    	private CreditCardStrategy creditCardStrategy;
    	private DepositStrategy depositStrategy;
    	private KakaoPayStrategy kakaoPayStrategy;
    	private ContactPaymentStrategy contactPaymentStrategy;
    	private PaymentService paymentService;
    
    	@PostMapping("/credit_card")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "카드로 결제")
    	public void payCreditCard(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		paymentService.pay(orderId, userId, requestPaymentDTO, creditCardStrategy);
    	}
    
    	@PostMapping("/deposit")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "계좌이체로 결제")
    	public void payDeposit(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		paymentService.pay(orderId, userId, requestPaymentDTO, depositStrategy);
    	}
    
    	@PostMapping("/kakaopay")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "카카오페이로 결제")
    	public void payKakaoPay(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		paymentService.pay(orderId, userId, requestPaymentDTO, kakaoPayStrategy);
    	}
    
    	@PostMapping("/contact_pay")
    	@ResponseStatus(HttpStatus.CREATED)
    	@ApiOperation(value = "만나서 결제")
    	public void payContact(@PathVariable("orderId") Long orderId, @CurrentUser String userId,
    		@RequestBody @Valid RequestPaymentDTO requestPaymentDTO) {
    		paymentService.pay(orderId, userId, requestPaymentDTO, contactPaymentStrategy);
    	}
    
    }

    컨트롤러는 구체적인 결제 전략을 알고 있고, 그 전략들을 PaymentService 객체에 주입시켜준다고 생각하여 전략 패턴의 클라이언트로 판단했다. 위 코드들을 보면 클라이언트인 컨트롤러는 구체적인 전략을 알고 있어야 하지만, 컨텍스트인 PaymentService 클래스를 보면 구체적인 전략을 모르고 있어도 된다. 컨트롤러에서 주입만 받으면 된다. (2)의 방식보다 조금 정리된 느낌이 있어도, 구체적인 전략에 의존하고 있는 컨트롤러는 역시 복잡해 보인다. 더 개선할 여지가 있을 것 같지만, 아직 진행 중인 고민이기 때문에 수정은 추후에 할 수 있을 것 같다.


    지금 판단하기에는 Factory 클래스를 만들어서 관리하는 방법이 가장 좋아 보인다. 팩토리 클래스가 알아서 PaymentService에 구체 타입을 주입해주기 때문에 컨트롤러는 구체 타입을 몰라도 된다는 점이 매우 큰 장점으로 보인다. 새로운 결제 방식이 생기더라도 팩토리 switch문만 추가를 하면 되고, 컨트롤러는 영향을 받지 않는다. 결제 컨트롤러는 결제 진행 기능만 있는 게 아니라 추후 결제 관련 다른 기능도 추가될 예정인데, 구체 타입이 필요 없는 기능도 있을 수 있으니 구체 타입에 의존하는 방식보다는 Factory 클래스로 관리하는 방법이 좋지 않나 하는 생각도 든다. 피드백을 받고, (2), (3) 방식을 개선할 수 있다면 다시 판단을 해봐야겠다.

    (최종 판단) 고민 끝에 Factory 클래스를 이용하는 방법을 최종적으로 선택하게 되었다. 윗 단락에 적은 것처럼 결제 방식마다 API를 나누는 방법은 구체 타입에 의존하여 확장성이 떨어진다. 컨트롤러가 매우 복잡해지고, 새로 작업을 하거나 유지 보수를 할 때 작업할 양이 많아진다.
      전략 패턴을 사용하더라도 전략을 사용하는 컨텍스트만 이득을 보고 컨트롤러는 여전히 구체적인 전략을 알고 있어야 한다. 이런 단점들이 치명적이라고 느껴졌다.
      반면에 Factory 클래스 방법은 컨트롤러가 구체 타입을 리턴해주는 팩토리 클래스에만 의존하면 되기 때문에 매우 깔끔해진다. 새로운 결제 방식이 생길 때마다 switch문을 늘리는 게, 컨트롤러의 코드를 계속 늘려 양이 방대해지는 것보다 훨씬 효율적이다. 꽤 오래 고민해서 그런지 다음에 또 비슷한 상황을 마주한다면 쉽게 고민을 끝낼 수 있을 것 같다. 끝.

    '프로젝트' 카테고리의 다른 글

    페어 프로그래밍 후기  (2) 2022.05.08
    페어 프로그래밍 알아보기  (4) 2022.05.05
    2. Scale Out의 세션 불일치 문제  (2) 2021.08.11
    1. Scale Up과 Scale Out 알아보기  (2) 2021.07.23
Designed by Tistory.