woniper

BeanNameGenerator로 Restful API 버전 관리 본문

Spring

BeanNameGenerator로 Restful API 버전 관리

woniper1 2016. 10. 23. 14:29

Rest API Version Path 문제

  Rest API를 만드는데 version 관리가 되어야한다.

예를 들면

  • http://localhost:8080/v1/foo
  • http://localhost:8080/v2/foo
와 같은 URL이 필요하다. Spring에서는 @RequestMapping에 value값으로 URL을 설정할 수 있다. 위 URL을 @RequestMapping으로 표현해보자.

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class SampleController {
    @RequestMapping(value = "/v1/foo")
    public ResponseEntity<?> v1Foo() {
        return ResponseEntity.ok("v1 foo");
    }
    @RequestMapping(value = "/v2/foo")
    public ResponseEntity<?> v2Foo() {
        return ResponseEntity.ok("v2 foo");
    }
}
cs

  

  단순히 위와 같이 표현 할 수 있는데, 다시 생각해보자. 하나에 Controller 클래스에 @RequestMapping을 메소드에 설정 할 때마다 value에 version 정보를 넣어야한다. 좀 더 공통화 할 수 없을까?

  @RequestMapping 애노테이션은 메소드 뿐 아니라 클래스에도 설정이 가능하다. 클래스에 설정이 가능하다는 것은 정의된 메소드 URL에 공통 URL path를 클래스 설정을 통해 공통화 할 수 있다는 것이다.

  위 코드를 클래스 @RequestMapping 설정으로 바꿔보자.


1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/v1")
public class SampleController {
    @RequestMapping(value = "/foo")
    public ResponseEntity<?> v1Foo() {
        return ResponseEntity.ok("v1 foo");
    }
    @RequestMapping(value = "/v2/foo")
    public ResponseEntity<?> v2Foo() {
        return ResponseEntity.ok("v2 foo");
    }
}
cs


  이렇게 클래스를 통해 v1 path는 공통화 했다. 그런데 문제는 v2 path다. v2 path는 어떻게 해야될까? V2SampleController Class를 생성해서 v2 path를 공통화 하면 될것 같다. 하지만 더 좋은 방법이 없을까?


요구사항

  • 버전 관리가 가능한 Controller 클래스를 만들어 version path는 공통화 하자.(@RequestMapping을 클래스에 설정하자.)
  • 버전과 패키지 경로는 다르게, Controller 클래스명은 동일하게 만들고싶다. (예를 들면 아래와 같이)
    • v1 controller : net.woniper.controller.v1.SampleController
    • v2 controller : net.woniper.contorller.v2.SampleController
  • Bean 이름 설정 없이 동일한 클래스명으로 Bean을 등록하고 싶다.

v1.SampleController


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package net.woniper.bean.generator.controller.v1;
 
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/v1")
public class SampleController {
 
    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello~ v1");
    }
}
 
cs


v2.SampleController


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package net.woniper.bean.generator.controller.v2;
 
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/v2")
public class SampleController {
 
    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello~ v2");
    }
}
 
cs


  위 코드는 요구사항에 맞게 패키지 경로만 다르고 클래스명은 동일하게 정의한 Controller다. 이제 WebApplication을 실행시켜보자. 나는 SpringBoot 테스트해보았다.



Class명이 같은 Controller에 문제점

  아마 위와 같이 패키지 경로는 다르지만 SampleController (@RestController 또는 @Controller가 붙은)와 같이 동일한 클래스명이 있다면 아래와 같은 에러가 난다.

org.springframework.beans.factory.BeanDefinitionStoreException: Failed to parse configuration class [net.woniper.bean.generator.CustomBeanNameGeneratorApplication]; nested exception is org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'sampleController' for bean class [net.woniper.bean.generator.controller.v2.SampleController] conflicts with existing, non-compatible bean definition of same name and class [net.woniper.bean.generator.controller.v1.SampleController]

at org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:187) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:324) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:246) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:273) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:98) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:681) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:523) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.boot.context.embedded.EmbeddedWebApplicationContext.refresh(EmbeddedWebApplicationContext.java:122) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]

at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:761) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]

at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:371) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]

at org.springframework.boot.SpringApplication.run(SpringApplication.java:315) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]

at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:134) [spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]

at net.woniper.bean.generator.CustomBeanNameGeneratorApplication.main(CustomBeanNameGeneratorApplication.java:15) [main/:na]

Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'sampleController' for bean class [net.woniper.bean.generator.controller.v2.SampleController] conflicts with existing, non-compatible bean definition of same name and class [net.woniper.bean.generator.controller.v1.SampleController]

at org.springframework.context.annotation.ClassPathBeanDefinitionScanner.checkCandidate(ClassPathBeanDefinitionScanner.java:320) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ClassPathBeanDefinitionScanner.doScan(ClassPathBeanDefinitionScanner.java:259) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ComponentScanAnnotationParser.parse(ComponentScanAnnotationParser.java:137) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassParser.doProcessConfigurationClass(ConfigurationClassParser.java:275) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassParser.processConfigurationClass(ConfigurationClassParser.java:237) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:204) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

at org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:173) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]

... 12 common frames omitted


이 에러는 동일한 Bean 이름이 충돌된다는 뜻이다.


Spring Bean

  Spring에서는 Bean이라는 개념이 존재한다. Bean은 Spring에 컨테이너라는 공간에서 관리되는 객체를 말한다. Bean은 컨테이너가 식별 할 수 있는 고유에 이름을 갖고있다. 그런데 SampleController라는 Bean 이름이 2개가 발견되어 충돌되는 것이다. Spring은 @Component 애노테이션이 붙은 클래스는 모두 Bean으로 인지하고 컨테이너에 등록한다. SampleController에 붙은 @RestController도 내부를 보면 @Component가 붙은 애노테이션이다.

  @Controller, @RestController, @Service, @Repository 등의 애노테이션은 모두 @Component 애노테이션을 상속 받고, Bean 대상이 된다. 그렇다면 컨테이너는 어떤 기준으로 빈을 생성할 때 고유에 이름을 생성할까? 바로 클래스명이 Bean 이름이 되는것이다. 그렇기 때문에 SampleController라는 2개의 Bean 이름이 발견된 것이고 위와 같은 충돌 에러가 났다. 

  물론 Bean 이름을 애노테이션 설정을 통해 지정 할 수 있다. 예를 들면 

1
@RestController("v1SampleController")
cs

이렇게 default value속성에 Bean이름을 지정할 수 있다. Bean 이름을 다르게 설정한다면 충돌에러는 당연히 나지 않을 것이다. 그런데 마지막 요구사항 처럼 Bean 이름은 설정을 통해 수동으로 분리하고 싶지 않다. 자동화하고 싶은 것이다.


BeanNameGenerator

  Spring에서는 BeanNameGenerator 구현체를 통해 Bean 이름을 생성하고 Bean을 등록한다. 앞서 말했듯이 Spring Bean 이름은 클래스명이 Bean 이름이 되기 때문에 마지막 요구사항인 Bean 이름 설정 없이 클래스명은 동일하지만, Bean 이름은 다르게 하고 싶다. BeanNameGenerator 구현체를 만들자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package net.woniper.bean.generator;
 
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanNameGenerator;
import org.springframework.context.annotation.AnnotationBeanNameGenerator;
 
import java.util.ArrayList;
import java.util.List;
 
public class CustomBeanNameGenerator implements BeanNameGenerator {
 
    /**
     * basePackages 외에 scaning된 beanGenerator
     */
    private static final BeanNameGenerator DELEGATE = new AnnotationBeanNameGenerator();
 
    /**
     * VersioningBeanNameGenerator 대상 package 경로
     */
    private List<String> basePackages = new ArrayList<>();
 
    @Override
    public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
        if(isTargetPackageBean(definition)) {
            return getBeanName(definition);
        }
 
        return DELEGATE.generateBeanName(definition, registry);
    }
 
    private boolean isTargetPackageBean(BeanDefinition definition) {
        String beanClassName = getBeanName(definition);
        return basePackages.stream().anyMatch(beanClassName::startsWith);
    }
 
    private String getBeanName(BeanDefinition definition) {
        return definition.getBeanClassName();
    }
 
    public boolean addBasePackages(String path) {
        return this.basePackages.add(path);
    }
}
 
cs


  간단하다. Bean이름을 Class 명이 아닌 패키지 경로까지 포함된 Bean 이름으로 등록하면 된다. 요구사항을 충족시키는 코드다.


BeanNameGenerator 등록


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package net.woniper.bean.generator;
 
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
 
@SpringBootApplication
public class CustomBeanNameGeneratorApplication {
 
    public static void main(String[] args) {
        CustomBeanNameGenerator beanNameGenerator = new CustomBeanNameGenerator();
        beanNameGenerator.addBasePackages("net.woniper.bean.generator.controller");
 
        new SpringApplicationBuilder(CustomBeanNameGeneratorApplication.class)
                .beanNameGenerator(beanNameGenerator)
                .run(args);
    }
}
 
cs

  

  SpringBoot에서는 위와같이 SpringApplicationBuilder를 통해 BeanNameGenerator를 등록한다. "net.woniper.bean.generator.controller" 하위에 Bean만 CustomBeanNameGenerator 대상이다.


마무리

  보통 Spring을 사용해 WebApplication 개발하는 경우 Controller, Service, Repository와 같이 계층별로 나눠놓은 클래스를 Bean으로 등록해 @Autowired 애노테이션을 통해 주입받아 사용한다. 그런데 위와 같이 CustomBeanNameGenerator로 Service나 Repository 클래스 또한 패키지명을 포함한 Bean 이름으로 설정한다면 주입받을때 Bean을 식별하기 @Qualifier 애노테이션을 사용해야한다. @Qualifier("net.woniper.service.v1.SampleService")와 같이 말이다. 물론 이렇게 사용하면 된다. 하지만 패키지명까지 입력하는게 번거롭기도하고, CustomBeanNameGenerator로 이름이 생성되 등록되기 때문에 개발자는 Bean 이름을 추적하기 어렵다.

  Controller는 보통 @Autowired 애노테이션을 사용해 주입해 사용하는 경우는 거의 없다. (test 할때는 필요하기도 하다.) 왜나하면 Controller가 최상위 계층이고 하위 계층인 Service나 Repository를 주입받아 사용한다. 즉 Service나 Repository 처럼 @Autowired, @Qualifier 애노테이션을 사용해 주입받는 경우가 거의 없다는 뜻이다. 그래서 패키지명을 포함한 Bean 이름으로 컨테이너에 등록해도 문제가 없다고 판단된다. 그래서 내가 추천하는 방법은 Controller는 모두 CustomBeanNameGenerator를 통해 등록을하고, 그 하위 계층인(주입 받아야하는 계층) Service나 Repository는 Bean 이름을 직접 설정해서 사용하는 것을 추천한다.

전체 소스는 https://github.com/woniper/spring-example/tree/master/spring-boot-custom-bean-name-generator 여기를 참고하자. (더 좋은 방법이 있다면 댓글 부탁드립니다.)

Comments