2022. 10. 11. 04:01ㆍDev/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)
'Dev > Java' 카테고리의 다른 글
[short-note] Spring Webflux: Webclient SOCKS5 Proxy setting with Auth(ID/PW) (0) | 2023.02.06 |
---|---|
조건이 복잡한 IF/ELSE-IF 문 리팩토링 (1) | 2023.01.06 |
enum 의 활용(2) - method 추가 (0) | 2022.09.22 |
enum 의 활용(1) - 상수에 value 추가 (1) | 2022.09.19 |
상수의 정의 (1) | 2022.09.09 |