본문 바로가기

Programing/Framework

[Spring] ServiceLocatorFactoryBean

어떤 기능이 여러 출처에 의해 분기를 해야하는 경우가 실무에서는 자주 생긴다.

주문 도메인이라면 처음에는 카테고리 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에 대해서 사용한다고 되어 있다.

토비의 스프링 같은 곳에서도 프로토타입 빈을 설명하면서 ApplicationContext객체를 이용해서 getBean메서드로 사용하는 것이 서비스 코드 안에 스프링의 컨테이너와 관련된 코드가 위치하는 것이 어울리지 않기에 아래와 같은 방법을 제공한다고 설명한다.

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

=>