어떤 기능이 여러 출처에 의해 분기를 해야하는 경우가 실무에서는 자주 생긴다.
주문 도메인이라면 처음에는 카테고리 A에 대해서만 판매를 하다가 카테고리 B라는 것이 생긴다.
결제 도메인이라면 처음에는 카카오페이만 지원을 했더라도 나중에 다른 네이버페이를 추가할 수 있다.
이럴 경우 레이어를 두는 것이 보통 일반적이다.
Client -> KakaopayService -> KakaopayExternalAPI
이런식으로 되어 있는데 네이버 페이를 추가한다면
Client -> PaymentService -> KakaopayService -> KakaopayExternalAPI
-> NaverpayService -> NaverpayExternalAPI
이런식으로 분기를 할 수 있는 계층(위에서 PaymentService)이 등장하게 된다.
그렇다면 구현은 어떻게 할까?
보통은 if 같은 분기나 switch ~ case를 사용하는 것을 실무에서는 많이 보았다.
@Service
public class PaymentService {
@Autowire
private KakaopayService kakaopayService;
@Autowire
private NaverpayService naverpayService;
public void prepare(PaymentPreparePayload payload) {
// ..
switch (payload.getType()) {
case KAKAOPAY:
kakaopayService..prepare(payload);
break;
case NAVERPAY:
naverpayService..prepare(payload);
break;
default:
throw new InvalidPaymentTypeException(payload);
}
// ..
처음에는 괜찮아 보일 수 있으나 여러가지 문제가 있다.
만약 PaymentService가 하는 동작은 바뀌지 않는데 결제 수단이 추가된다면 계속 PaymentService를 추가해야 한다.
또한 위에서는 prepare 메서드 하나만 보이나 approve나 cancel 같은 다른 메서드가 있다면 분기하는 로직은 계속 등장하게 된다.
결국 단일책임의 원칙 위반 및 중복되는 코드의 냄새가 많이 보이는 코드가 된다.
전략 패턴
이럴 경우서비스를 추상화 하여 인터페이스로 분리하는 것도 좋은 전략이다. (바로 전략 패턴이다)
스프링에서는 ServiceLocatorFactoryBean 라는 것을 통해 이런 의존성을 끊을 수 있게 도와준다.
우선 공통의 인터페이스를 만들고 그 인터페이스를 serviceLocatorInterface 를 통해 설정을 해주면 스프링은 알아서 그 인터페이스 타입의 프록시 객체를 만들어준다.
public interface PaymentHandlerLocator {
PaymentHandler getPaymentHandler(PaymentType paymentType);
}
public enum PaymentType {
KAKAOPAY, NAVERPAY
}
ServiceLocatorFactoryBean 빈에 대한 설정은 아래와 같이 할 수 있다.
@Configuration
@ComponentScan(basePackages = {"com.tistory.namocom.payment"})
public class PaymentHandlerLocatorConfig {
@Bean
public ServiceLocatorFactoryBean serviceLocatorForPaymentHandlerLocator() {
ServiceLocatorFactoryBean factoryBean = new ServiceLocatorFactoryBean();
factoryBean.setServiceLocatorInterface(PaymentHandlerLocator.class);
factoryBean.setServiceLocatorExceptionClass(PaymentErrorException.class); // option
return factoryBean;
}
}
이를 사용하는 코드는 아래와 같다.
@Service
public class PaymentService {
@Autowire
private PaymentHandlerLocator paymentHandlerLocator;
public void prepare(PaymentPreparePayload payload) {
// ..
PaymentHandler handler = paymentHandlerLocator.getPaymentHandler(payload.getType());
handler.prepare(payload);
// ..
빈 이름을 찾을 수 있게 PaymentType에 맞게 지정을 했다. (별도의 상수를 만들어서 PaymentType와 연결하는 것도 좋은 방법이다.)
@Service("KAKAOPAY")
public class KakaopayService {
@Autowire
private KakaopayExternalAPI kakaopayExternalAPI;
public void prepare(PaymentPreparePayload payload) {
// ..
@Service("NAVERPAY")
public class NaverpayService {
@Autowire
private NaverpayExternalAPI naverpayExternalAPI;
public void prepare(PaymentPreparePayload payload) {
// ..
PaymentType.KAKAOPAY 의 경우 내부적으로 toString이 호출되어 "KAKAOPAY"라는 이름의 빈을 찾게 된다.
이제 어떤 서비스를 경정해야 하는지 PaymentHandlerLocator 가 결정을 하기 때문에 추가되는 결제수단이 있더라도 PaymentService는 바뀔 이유가 없다. (물론 새로운 인터페이스가 추가되거나 입력 파라미터가 바뀌어 어쩔 수 없이 수정될 수 있지만 전보다 훨씬 낫다.)
ServiceLocatorFactoryBean의 동작 원리
초기화
일단 ServiceLocatorFactoryBean 은 아래와 같은 계층 구조를 가지고 있다.
1) BeanFactory 를 주입
BeanFactoryAware 인터페이스를 구현하고 있기 때문에 setBeanFactory 이라는 메서드를 통해 BeanFactory 를 주입을 받을 수 있다.
public interface BeanFactoryAware extends Aware {
void setBeanFactory(BeanFactory beanFactory) throws BeansException;
}
오버라이드를 한 것은 초기화시 내부의 beanFactory에 저장을 해둔다.
public class ServiceLocatorFactoryBean implements FactoryBean<Object>, BeanFactoryAware, InitializingBean {
@Nullable
private ListableBeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.beanFactory = (ListableBeanFactory) beanFactory;
}
2) 프록시 객체 생성
InitializingBean 인터페이스를 구현하고 있는데 afterPropertiesSet() 메서드를 스프링이 호출을 한다.
이 시점에 프록시 객체를 만든다. 참고로 대리 역할을 할 객체는 ServiceLocatorInvocationHandler 인데 위의 다이어그램에 나와있다.
바로 java.lang.reflect 패키지의 InvocationHandler 라는 프록시 인스턴스를 구현할 수 있게 하고 있는 인터페이스를 구현하고 있는 것이다.
public class ServiceLocatorFactoryBean implements FactoryBean<Object>, BeanFactoryAware, InitializingBean {
//..
@Nullable
private Object proxy;
@Override
public void afterPropertiesSet() {
this.proxy = Proxy.newProxyInstance(
this.serviceLocatorInterface.getClassLoader(),
new Class<?>[] {this.serviceLocatorInterface},
new ServiceLocatorInvocationHandler());
}
3) 빈(프록시 객체) 제공
FactoryBean 인터페이스를 구현하고 있기 때문에 객체랑 객체의 타입을 반환한다.
public class ServiceLocatorFactoryBean implements FactoryBean<Object>, BeanFactoryAware, InitializingBean {
@Nullable
private Class<?> serviceLocatorInterface;
@Nullable
private Object proxy;
@Override
@Nullable
public Object getObject() {
return this.proxy;
}
@Override
public Class<?> getObjectType() {
return this.serviceLocatorInterface;
}
@Override
public boolean isSingleton() {
return true;
}
사용
그러면 사용처인 prepare에서 getPaymentHandler를 호출하면 어떤 일이 벌어지는 것일까?
PaymentService는 PaymentHandlerLocator 를 빈 초기화시 프록시 객체를 주입받게 된다.
@Service
public class PaymentService {
@Autowire
private PaymentHandlerLocator paymentHandlerLocator;
public void prepare(PaymentPreparePayload payload) {
// ..
PaymentHandler handler = paymentHandlerLocator.getPaymentHandler(payload.getType());
handler.prepare(payload);
// ..
paymentHandlerLocator.getPaymentHandler( ) 가 호출되면 프록시 객체(ServiceLocatorInvocationHandler)의 invoke 메서드가 호출된다. invoke 메서드는 내부의 private 메서드인 invokeServiceLocatorMethod 를 호출하는데 빈 초기화 때 주입받은 beanFactory에서 타입 인자의 빈이 있는지 찾아 돌려주는 것이다.
그런데, JavaDoc API 에는
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/config/ServiceLocatorFactoryBean.html 를 보면 전형적으로 prototype beans에 대해서 사용한다고 되어 있다.
토비의 스프링 같은 곳에서도 프로토타입 빈을 설명하면서
ObjectFactory, ObjectFactoryCreatingFactoryBean 사용
ServiceLocatorFactoryBean 사용
Provider
를 제공한다고 되어 있다.
따라서 프로토타입 빈을 서비스할 것이 아니라면 권장되지 않는 ServiceLocatorFactoryBean를 굳이 쓸 필요는 없다.
ServiceLocatorFactoryBean를 흉내내서 아래와 같이 구현을 할 수 있을 것이다.
@Component
public class PaymentHandlerLocator {
@Autowire
private ListableBeanFactory beanFactory;
@Override
public PaymentHandler getPaymentHandler(PaymentType paymentType) {
String beanName = paymentType.toString();
try {
return beanFactory.getBean(beanName, PaymentHandler.class);
} catch (BeansException ex) {
throw new InvalidPaymentTypeException(NOT_SUPPORTED_PAYMENT_TYPE,
paymentType + "은/는 지원하지 않는 결제타입 입니다.);
}
}
}
ServiceLocatorFactoryBean 을 쓰지 않고 이렇게 했을 때 장점은 다음과 같다.
1. ServiceLocatorFactoryBean 를 빈으로 생성해주는 설정을 테스트를 할 때 Mocking이 쉬워진다.
@Configuration 에 @ComponentScan 위치를 지정해 줄 수 있다. 문제는 해당 경로에 다른 빈 들도 있으면 의존성이 높아져 테스트 코드를 만들기 어려웠는데 직접 만들면 이런 의존성을 없앨 수 있기에 테스트 코드를 만들기 쉬워진다.
2. 예외에 대한 세심한 처리가 가능하다.
ServiceLocatorFactoryBean 에도 setServiceLocatorExceptionClass 를 지원해서 발생할 예외를 NoSuchBeanDefinitionException 이외로 설정을 할 수 있다 하지만 createServiceLocatorException 자체가 아래와 같이 구현이 되어 있다보니 예외의 시그너처를 맞춰주어야 한다. 메시지도 아래와 같은 형식으로 고정이 된다.
"No bean named 'TOSS' available"
커스터마이징 하기가 어렵다. (위의 ~는 지원하지 않는 결제타입과 같은 것은 불가능하다.)
protected Exception createServiceLocatorException(Constructor<Exception> exceptionConstructor, BeansException cause) {
Class<?>[] paramTypes = exceptionConstructor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
for (int i = 0; i < paramTypes.length; i++) {
if (String.class == paramTypes[i]) {
args[i] = cause.getMessage();
}
else if (paramTypes[i].isInstance(cause)) {
args[i] = cause;
}
}
return BeanUtils.instantiateClass(exceptionConstructor, args);
}
---
2024-03-07 Bart가 언급해주셔서 다시 보다가 맞춤법 fix
=>
'Programing > Framework' 카테고리의 다른 글
[Spring] SSE vs WebFlux (0) | 2020.02.08 |
---|---|
[Spring] mvc 예외처리 (0) | 2020.02.07 |
[Spring] Boot 빈 의존성 사례 - Spring Integration (TCP) (0) | 2020.01.30 |
[Spring] ClientHttpResponse 인터페이스 계층 구조 (0) | 2020.01.08 |
[Sonarqube] Spring 기본 테스트 (0) | 2019.12.12 |