개발을 하다 보면 입력값과 출력값, 그리고 수행하는 역할이 동일하지만 세부 동작이 조금씩 다른 기능을 만들어야 할 때가 종종 있다. 이런 상황에서는 인터페이스에 메서드를 선언하고, 실제 메서드의 동작은 각 구현체에서 다르게 구현하는 방식을 적용하게 된다.
문제는 이 구현체들을 실제로 사용하는 쪽에서 자신에게 필요한 구현체를 어떻게 선택해서 사용할 것인가에 있다. Spring에서는 이 상황을 자동화하면서도 유연하게 해결할 수 있는 방법을 알아보자.
간단한 예제로 우리는 Hello, My name is Thomas 처럼 이름과 함께 인사하는 서비스와 Hello, I'm from Korea 처럼 나라이름과 함께 인사하는 서비스를 만들어보자.
인사를 하려는 의도와 입력값, 출력값이 같기 때문에 인터페이스에 인사하는 메소드를 먼저 정의해보자.
public interface HelloService {
String sayHello();
}
그리고 각 서비스를 @Service를 붙여 다음과 같이 간단하게 구현해보자.
// 이름
@Service
public class HelloWithNameServiceImpl implements HelloService{
@Override
public String sayHello() {
return "Hello, My name is Thomas";
}
}
// 국적
@Service
public class HelloWithCountryServiceImpl implements HelloService{
@Override
public String sayHello() {
return "Hello, I'm from Korea";
}
}
이렇게 구현체를 구현했는데 어떻게 내가 원하는 구현체를 사용할 수 있을까?
첫 번째 방법은 원하는 구현체에 @Primary를 붙이는 것이다. 먼저 이름과 함께 인사라는 HelloWithNameServiceImpl에 @Primary를 붙여보자.
// 이름
@Service
@Primary
public class HelloWithNameServiceImpl implements HelloService{
@Override
public String sayHello() {
return "Hello, My name is Thomas";
}
}
그리고 인터페이스인 HelloService를 주입받아 sayHello()를 실행 시키면 @Primary가 붙은 HelloWithNameServiceImpl의 메소드를 실행시키게 된다.
@RestController
@RequestMapping("/forBlog")
@RequiredArgsConstructor
public class HomeController {
private final HelloService helloService;
@GetMapping("/hello")
public String getHelloWithStringKey() {
return helloService.sayHello();
}
}
이 방법의 문제는 사용자마다 사용하고 싶은 서비스가 다른데 직접 선택할 수 없다는 점과, 다른 서비스를 선택하기 위해선 @Primary의 위치를 수정하고 애플리케이션을 재시작해야 한다는 문제점이 있다.
위의 문제점을 해결하기 위해서 Map를 사용해보자.
먼저 controller에 HelloService 대신 Map<String, HelloService> 를 주입받아보자.
이런 경우 스프링은 컨텍스트에 등록된 모든 HelloService 타입의 빈을 자동으로 찾고, 키에는 빈의 이름(String), 값에는 해당 빈의 구현체(HelloService)가 자동으로 들어간 Map을 주입해 준다.
그리고 return helloServiceMap.get(type).sayHello();처럼 원하는 구현체를 map에서 키값으로 가져와 실행시킬 수 있다.
@RestController
@RequestMapping("/forBlog")
@RequiredArgsConstructor
public class HomeController {
private final Map<String, HelloService> helloServiceMap;
@GetMapping("/hello/{withType}")
public String getHelloWithStringKey(@PathVariable("withType") String type) {
return helloServiceMap.get(type).sayHello();
}
}
여기서 키는 구현체이름이 HelloWithNameServiceImpl 이였다면 helloWithNameServiceImpl가 키 값으로 설정된되어 /forBlog/hello/helloWithNameServiceImpl 처럼 요청해야 한다.
만약 이름이 너무 길어 다른 이름을 사용하고 싶다면 구현체의 다음과 같이 @Service("customName") 처럼 구현체의 이름을 짧게 지정해 /forBlog/hello/customName 으로 좀 더 관리하게 용의하게 할 수 있다.
@Service("name") // customName 설정
public class HelloWithNameServiceImpl implements HelloService{
...
}
@Bean 계열에선 ()안에 원하는 이름으로 설정할 수 있다.
이 예제는 구현체가 2개만 있어서 이전 방법 처럼 이름을 짧게 만들어 관리해도 충분해 보인다. 하지만 구현체가 여러개라면 문자열 키 사용으로 인해 오타나 이름 변경 시 오류, 코드 작성 시 빈 이름을 정확히 알고 있어야 한다는 점 등 관리에서 문제를 겪을 수 있다.
이 해결책으로 빈 이름이 아닌 커스텀한 type를 만들어 mapping하면 관리가 더 편해진다.
먼저 Enum으로 타입을 정의해 보자.
public enum HelloType {
NAME, COUNTRY
}
그리고 매핑을 하기 위해서는 각 구현체는 자신이 어떤 타입인지 알려주는 메소드가 필요하다. 따라서 인터페이스에 해당 메소드를 선언하고 구현체에서 각각에 맞는 타입을 리턴하는 메소드를 구현해야 한다.
public interface HelloService {
String sayHello();
HelloType getHelloType(); // 타입 리턴 메소드 선언
}
@Service("name")
public class HelloWithNameServiceImpl implements HelloService{
@Override
public String sayHello() {...}
@Override
public HelloType getHelloType() {
return HelloType.NAME; //NAME 타입 리턴
}
}
@Service
public class HelloWithCountryServiceImpl implements HelloService{
@Override
public String sayHello() {...}
@Override
public HelloType getHelloType() {
return HelloType.COUNTRY; // COUNTRY 타입 리턴
}
}
그럼 우리가 만든 타입과 구현체를 map으로 만들어 Bean에 등록해줘야 한다. @Configuration과 @Bean 이용하여 애플리케이션이 시작할 때다음과 같이 Map<HelloType, HelloService>를 빈에 등록할 수 있다.
@Configuration
public class HelloConfig {
@Bean
public Map<HelloType, HelloService> getHelloServiceImplMap(List<HelloService> helloServiceList) {
return helloServiceList.stream().collect(Collectors.toMap(HelloService::getHelloType, Function.identity()));
}
}
여기서List<HelloService> 는 context에서 @Service로 등록된 HelloService들을 찾아서 자동으로 List로 조회해 올 수 있도록 해준다.
그리고 스트림을 사용해 키로 사용할 HellpType 은 getHelloType로 조회해오고, Function.identity()은 i -> i와 같은 역할로 현재 구현체를 값으로 Map으로 저장할 수 있다.
그럼 이 역시 우리는 @RequiredArgsConstructor와 private final로 선언한 Map<HelloType, HelloService>를 통해 위에서 등록한 빈을 가져올 수 있다.
그리고 사용 방법은 String 키로 사용했을 때와 비슷하게 다음과 같이 get()를 사용해 원하는 구현체의 메소드를 이용할 수 있게된다.
@RestController
@RequestMapping("/forBlog")
@RequiredArgsConstructor
public class HomeController {
private final Map<HelloType, HelloService> helloServiceMap;
@GetMapping("/hello/{withType}")
public String getHelloWithHelloType(@PathVariable("withType") HelloType type) {
return helloServiceMap.get(type).sayHello();
}
}
위와 같이 구조를 만들게 된다면 다음과 같은 효과가 있다.
인터페이스를 사용하는 구현체의 내부 구조를 전혀 몰라도 상관이 없다.
다양한 HelloType를 계속해서 확장해서 추가할 수 있다.
원하는 HelloService 구현체를 자유롭게 선택해서 사용할 수 있다.