[Spring] 같은 계층의 N개의 의존성 리팩토링

2022. 10. 11. 04:01Dev/Java

배송 추적을 하기 위한 서비스 계층을 개발한다고 가정해보자. 우리는 먼저 간단하게 interface 를 작성하고 각 배송사의 이름으로된 구현체를 작성할 것이다. 그리고 이를 호출하게 되는 서비스까지.

// 배송조회 결과를 담을 클래스
// 여기서는 간단하게 문자열 status 만 받도록 한다
public class TrackingInfo {
    private final String status;
    
    public TrackingInfo(String status) {
        this.status = status;
    }
    
    public String getStatus() {
        return status;
    }
}


public interface DeliveryCompany {
    TrackingInfo getTrackingInfo();
}

// CJ 대한통운
@Service
public class CjDelivery implements DeliveryCompany {
    @Override
    public TrackingInfo getTrackingInfo() {
        return new TrackingInfo("배송완료");
    }
}

// 우체국 택배
@Service
public class EpostDelivery implements DeliveryCompany {
    @Override
    public TrackingInfo getTrackingInfo() {
        return new TrackingInfo("배송중");
    }
}

그리고 이를 호출 하려 하는 서비스

@Service
public class TrackingService {
    private final DeliveryCompany ePost;
    private final DeliveryCompany cj;
    // 점점 늘어날 배송사 구현체들
    ...
    
    // 생성자주입
    // 점점 늘어날 생성자의 인자들...
    public TrackingService(EpostDelivery ePost, CjDelivery cj) {
        this.ePost = ePost;
        this.cj = cj;
    }
    
    public TrackingInfo getTracking(DeliveryCompanyName deliveryCompanyName) {
        
        // 오늘 리팩토링 할 지점
        TrackingInfo trackingInfo;
        switch (deliveryCompanyName) {
            case CJ:
                trackingInfo = this.cj.getTrackingInfo();
                break;
            case EPOST:
                trackingInfo = this.ePost.getTrackingInfo();
                break;
            ... 점점 늘어날 case ...
            default:
                throw new IllegalArgumentException();
        }
        
        // 원래 이 method 가 처리해야 하는 로직
        ...
        ...
        
        
        return trackingInfo;
    }
}


public enum DeliveryCompanyName {
	// 점점 늘어날 배송사들
    EPOST, CJ
}

지금은 구현할 배송사가 단 2개라서 간단하게 생성자주입을 통해서 작성했고 Contoller 나 또 다른 Service 에서  getTracking() 을 호출하게 되는 시나리오다.

 

가장 간단한 방법이 위의 예제 코드처럼 분기문(case / if~else)을 이용해서 주입받은 빈에 구현된 method를 호출 하는 것일 테지만 DeliveryCompany 가 늘어나는 상황에 대한 적절한 대응이라고 보기 어렵다. (늘어나는 배송사마다 모두 switch 문에서 처리하려는건 유지보수에 좋지 않다. 중간에 break 하나라도 빠트리면.... 🤯). 게다가 이 getTracking() 이라는 method는 원래 처리해야 하는 product code 보다 점점 분기문의 코드가 늘어나게 되어서 결국에는 원래 목적성을 상실하게 될지도 모른다.

 

자, 그럼 이렇게 refactoring 을 해보자.

 

Refactoring

가장 먼저 해야할 것은 들어온 인자를 적절한 구현체를 반환하는 책임(위 예제코드의 switch문)을 다른 객체에게 위임하는 것이다. 이렇게 TrackingService의 getTracking() 의 원래 목적성에 맞는 책임만 가지도록 할 수 있다. 또한 이 TrackingService 가 많이 의존하고 있는 여러 DeliveryCompany 의 구현체들에 대해서도 가벼워질 것이다.

@Service
public class TrackingService {
    
    private final DeliveryCompanyFactory factory;
    
    public TrackingService(DeliveryCompanyFactory factory) {
        this.factory = factory;
    }
    
    public TrackingInfo getTracking(DeliveryCompanyName deliveryCompanyName) {
    	
        // 이제 우리는 어떤 DeliveryCompany의 구현체가 선택되었는지 알 필요가 없어졌다!!
        DeliveryCompany deliveryCompany = this.factory.getDeliveryCompany(deliveryCompanyName);
        
        // 원래 이 method 가 처리해야 하는 로직
        ...
        ...
    
        return deliveryCompany.getTrackingInfo();
    }
}
@Component
public class DeliveryCompanyFactory {
    
    private final DeliveryCompany ePost;
    private final DeliveryCompany cj;
    // 계속 늘어날 배송사에 대한 의존성 문제는 해결되지 않았다.
    
    public DeliveryCompanyFactory(EpostDelivery ePost, CjDelivery cj) {
        this.ePost = ePost;
        this.cj = cj;
    }
    
    public DeliveryCompany getDeliveryCompany(DeliveryCompanyName deliveryCompanyName) {
        DeliveryCompany company;
        
        // switch 문은 여전히 존재한다.
        switch (deliveryCompanyName) {
            case CJ:
                company = this.cj;
                break;
            case EPOST:
                company = this.ePost;
                break;
            default:
                throw new IllegalArgumentException();
        }
        
        return company;
    }
}

TrackingService 는 심플해지고 의존성과 책임이 가벼워졌지만, 그 문제점들까지 factory 클래스로 그대로 옮겨졌을뿐 아직 해결되었다고 볼 수 없다. 구현클래스를 선택하는 책임과 그에따른 문제점이 동시에 옮겨졌을 뿐이다.

 

코드를 유심히 보면 결국 1개의 인자로 인해서 결과가 도출 되는 것을 알 수 있을 것이다. DeliveryCompanyName 과 DeliveryCompany 의 구현체는 강하게 엮여있다.

앞서 enum 활용글(https://jnotebook.tistory.com/9)에서도 언급했듯이 상당히 많은 수의 분기문은 enum 으로 처리가 가능한데 이번에도 가능할 것 같다.

 

public enum DeliveryCompanyName {
    EPOST(EpostDelivery.class),
    CJ(CjDelivery.class);
    
    private final Class<? extends DeliveryCompany> implementedService;
    
    DeliveryCompanyName(Class<? extends DeliveryCompany> implementedService) {
        this.implementedService = implementedService;
    }
    
    public Class<? extends DeliveryCompany> getImplementedService() {
        return implementedService;
    }
}

배송사가 추가 될때 인자로 사용되는 enum 의 요소에 구현할 클래스정보를 인자로 넘겨서 "enum value : 구현된 클래스" 를 매핑해줬다.

이제 배송사가 추가 될때마다 개발자는 반드시 구현체를 매핑해줘야 하는 강제성을 가지게 되어서 switch/if 분기문에 비해서 휴먼에러가 현저하게 줄어들 것이다.

 

@Component
public class DeliveryCompanyFactory {
    
    private final ApplicationContext applicationContext;
    
    public DeliveryCompanyFactory(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }
    
    public DeliveryCompany getDeliveryCompany(DeliveryCompanyName deliveryCompanyName) {
        return this.applicationContext.getBean(deliveryCompanyName.getImplementedService());
    }
}

기존에 N개의 의존성을 가질 위험이 있던 멤버 인스턴스들을 제거하고 대신에 ApplicationContext 만 가지고 왔다.

ApplicationContext.getBean() 을 사용해서 직접 deliveryCompany 구현체 bean 의 instance 를 꺼내서 응답해주도록 했다. 이제 switch 분기문이 사라졌고 수 많은 deliveryCompany 의존성이 약해졌다.

 

 

enum 을 사용하지 못하는(않는) 경우?

위의 예시는 enum을 사용하도록 시나리오가 짜여져 있어서 자연스럽게 enum 에 구현클래스를 매핑하도록 유도되었다. 그렇기 때문에 enum value 로 구현클래스의 bean instance를 가져올 수 있었는데 enum을 리팩토링해서 사용하지 못하는 경우는 어떻게 해야할까?

 

분기문을 대신하기 위해서는 key : value 쌍이 있어야 할 것이다.

@Configuration
public class CommonConfig {
    
    @Bean
    public Map<String, Class<? extends DeliveryCompany>> deliveryCompanyMap() {
        return Map.ofEntries(
                Map.entry("EPOST", EpostDelivery.class),
                Map.entry("CJ", CjDelivery.class)
        );
    }
}

스프링 설정에서 우리가 사용할 key : value 를 Map 으로 정의하고

 

@Component
public class DeliveryCompanyFactory {
    
    private final ApplicationContext applicationContext;
    
    // CommonConfig 에서 정의한 Map을 주입받는다
    private final Map<String, Class<? extends DeliveryCompany>> deliveryCompanyMap;
    
    public DeliveryCompanyFactory(ApplicationContext applicationContext,
                                  Map<String, Class<? extends DeliveryCompany>> deliveryCompanyMap) {
        this.applicationContext = applicationContext;
        this.deliveryCompanyMap = deliveryCompanyMap;
    }
    
    public DeliveryCompany getDeliveryCompany(DeliveryCompanyName deliveryCompanyName) {
        
        // 예시를 위해서 enum을 사용하지 않고 String 으로 변환해서 map에서 get
        String companyName = deliveryCompanyName.name();
        Class<? extends DeliveryCompany> deliveryCompanyClass = this.deliveryCompanyMap.get(companyName);
    
        return this.applicationContext.getBean(deliveryCompanyClass);
    }
}

이렇게도 사용할 수가 있다.

 

ServiceLocatorFactoryBean

그런데 스프링에서 뭔가 만들어 두지 않았을까? 물론 있었다. ServiceLocatorFactoryBean(https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/config/ServiceLocatorFactoryBean.html)

 

위의 예제를 ServiceLocatorFactoryBean 으로 바꾸면 이렇게 된다.

 

public enum DeliveryCompanyName {
    EPOST,
    CJ;
}

enum 은 아무 커스텀 없이 순수하게 사용.

 

@Configuration
public class CommonConfig {
    
    @Bean
    public ServiceLocatorFactoryBean serviceLocatorFactoryBean() {
        ServiceLocatorFactoryBean serviceLocatorFactoryBean = new ServiceLocatorFactoryBean();
        
        // 여기에 DeliveryCompanyFacotry interface 를 setter 로 넘겨준다
        serviceLocatorFactoryBean.setServiceLocatorInterface(DeliveryCompanyFactory.class);
        return serviceLocatorFactoryBean;
    }
}

config 에서 ServiceLocatorFactoryBean 을 생성해줘야 하는데, 중요한건 이전에 우리가 class 로 구현했던 DeliveryCompanyFactory.class 가 interface 라는것이다. 이 인터페이스는 이렇게 변했다

 

public interface DeliveryCompanyFactory {
    
    DeliveryCompany getDeliveryCompany(String beanName);
}

 

우리가 ApplicationContext.getBean()으로 직접 빈을 가져왔다면 이번에는 이를 수행할 interface 를 만들어 둔 것이다.

여기서 인자로 받게되는 beanName 이 바로 'Key' 가 될 것이다.

 

우리는 enum 의 인자를 그대로 대문자로 사용할 것이니 구현 클래스의 bean name 을 대문자로 맞춰주자

@Service("CJ")
public class CjDelivery implements DeliveryCompany {
    @Override
    public TrackingInfo getTrackingInfo() {
        return new TrackingInfo("배송완료");
    }
}

@Service("EPOST")
public class EpostDelivery implements DeliveryCompany {
    @Override
    public TrackingInfo getTrackingInfo() {
        return new TrackingInfo("배송중");
    }
}

사용하는 서비스 로직은 변함이 없다.

@Service
public class TrackingService {
    
    private final DeliveryCompanyFactory factory;
    
    public TrackingService(DeliveryCompanyFactory factory) {
        this.factory = factory;
    }
    
    public TrackingInfo getTracking(DeliveryCompanyName deliveryCompanyName) {
    	
        DeliveryCompany deliveryCompany = this.factory.getDeliveryCompany(deliveryCompanyName);
        
        // 원래 이 method 가 처리해야 하는 로직
        ...
        ...
    
        return deliveryCompany.getTrackingInfo();
    }
}

 

 

이렇게 ServiceLocatorFactoryBean 를 사용하면 config 하나만으로 서비스 코드와 스프링 관련 코드가 깔끔하게 분리되어서 꽤 편리해진다.

 

하지만... 이 ServiceLocatorFactoiryBean 의 사용은 bean 의 기본 scope 인 singleton 에서는 권장되지 않는다. 이는 공식 API DOC 에도 적혀 있는데 prototype bean 의 경우에 사용된다고 한다.

They will typically be used for prototype beans, i.e. for factory methods that are supposed to return a new instance for each call.

 

이 ServiceLocatorFactoryBean에 대해서 더 자세한건 이분의 글을 참고하는 것이 좋을 것 같다.

(나모의 노트 : https://namocom.tistory.com/819)