Spring AnnotatedElementUtils (meta-annotation)

오늘은 Spring에서 제공해주는 AnnotatedElementUtils (Meta-annoation)클래스에 대해서 알아보도록 하자.
아주 예전에 메타 어노테이션에 대해서 알아본적이 있는데 그 행위들을 AnnotatedElementUtils 이라는 클래스를 이용하여 구현되었다.

어노테이션 속성의 오버라이딩기능을 사용하고 싶다면 AnnotatedElementUtils클래스를 사용하면 되고, 그렇지 않다면 AnnotationUtils 클래스만을 이용하면 된다.

AnnotationUtils

간단한 예제를 보면서 살펴보자.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Publish {

    String value();

    String address() default "";
}

위와 같이 @Publish라는 어노테이션이 있다고 가정하자. 속성으로는 valueaddress 라는 속성을 갖고 있다.

@Publish("redis")
public class SpringAnnotatedElementApplicationTests {

    @Test
    public void annotation() throws NoSuchMethodException {
        Publish publish = AnnotationUtils.findAnnotation(this.getClass(), Publish.class); 
        //  
    }
}

일반적으로는 AnnotationUtils 클래스를 사용하면 문제 없다. publish의 value 속성에는 redis라는 값이 들어가 있다. 아주 심플하다.
위와 같은 방법으로 어노테이션을 정의해도 되겠지만 좀 더 나은 방법으로는 메타 어노테이션으로 해당 속성을 미리 정해놓는 것이다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Publish(value = "redis")
public @interface RedisPublish {

}

위와 같이 미리 어노테이션을 미리 정해놓을 수 있다.

@RedisPublish
public class SpringAnnotatedElementApplicationTests {

    @Test
    public void annotation() throws NoSuchMethodException {
        Publish publish = AnnotationUtils.findAnnotation(this.getClass(), Publish.class);
        //
    }
}

위와 같이 작성해도 아까 봤던 @Publish("redis")와 동일한 값을 얻을 수 있다. 물론 address 도 추가하여 넣을 수 있다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Publish(value = "redis", address = "127.0.0.1")
public @interface RedisPublish {

}

AnnotationUtils의 기능은 여기까지이다. 만약에 어노테이션의 속성들을 오버라이딩 해야 한다면 AnnotationUtils 클래스가 아닌 AnnotatedElementUtils 클래스를 사용해서 속성들을 오버라이딩 하면 된다.

AnnotatedElementUtils

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Publish(value = "redis")
public @interface RedisPublish {

    String address() default "";
}

다시 이런 @RedisPublish 어노테이션이 있다고 가정하자. address 속성이 RedisPublish 에 추가 되었다.

@RedisPublish(address = "localhost")
public class SpringAnnotatedElementApplicationTests {

    @Test
    public void annotation() throws NoSuchMethodException {

        Publish publish = AnnotatedElementUtils.findMergedAnnotation(this.getClass(), Publish.class);
        Publish publish1 = AnnotationUtils.findAnnotation(this.getClass(), Publish.class);
    }
}

만약 위와 같이 해당 속성을 가져온다면 어떻게 될까?
AnnotationUtils.findAnnotation을 사용할 경우에는 해당 value만 redis라는 값이 들어가 있고 address에는 아무 값이 들어가 있지 않다. 하지만 AnnotatedElementUtils.findMergedAnnotation이 경우에는 value와 address 모두 값이 들어가 있는 것을 확인 할 수 있다. 아주 유용하게 사용할 수 있을 것 같다.

또한 만약에 address라는 속성이 마음에 들지 않는다면 속성 자체도 변경하여 오버라이딩 할 수 있다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Publish(value = "redis")
public @interface RedisPublish {

    @AliasFor(annotation = Publish.class, attribute = "address")
    String host() default "";
}

@AliasFor 어노테이션으로 타겟 어노테이션과 속성을 정의해주면 해당 속성으로 오버라이딩 된다. 여기서는 host 에 값을 넣었지만 실제로는 Publish.address에 값이 들어 가게 된다.

AnnotatedElementUtils 클래스가 의미하는 바는 알았으니 몇가지 메서드만 알아보도록 하자.

AnnotatedElementUtils.method()

AnnotatedElementUtils.getMetaAnnotationTypes

AnnotatedElementUtils.getMetaAnnotationTypes 은 메타 어노테이션의 클래스를 Set으로 가져온다.

AnnotatedElementUtils.getMetaAnnotationTypes(this.getClass(), RedisPublish.class); // ["xxx.xxx.xxxx.Publish"]

주의할 점은 선언된 어노테이션의 메타 어노테이션만 가져온다. RedisPublishPublish만을 갖고 있기때문에 Publish만이 리턴된다.

AnnotatedElementUtils.hasMetaAnnotationTypes

해당 어노테이션이 메타 어노테이션으로 있는지 없는지 boolean 값을 리턴한다. 여기도 주의할 점은 메타 어노테이션인지만을 체크한다.

AnnotatedElementUtils.hasMetaAnnotationTypes(this.getClass(), RedisPublish.class); //false
AnnotatedElementUtils.hasMetaAnnotationTypes(this.getClass(), Publish.class); //true

AnnotatedElementUtils.isAnnotated

isAnnotated 은 자기 자신을 포함한 어노테이션이 있는지 체크한다.

AnnotatedElementUtils.isAnnotated(this.getClass(), RedisPublish.class); //true
AnnotatedElementUtils.isAnnotated(this.getClass(), Publish.class);  //true

AnnotatedElementUtils.getMergedAnnotationAttributes, findMergedAnnotationAttributes

두 메서드는 AnnotationAttributes 클래스로 리턴하는데 해당 속성과 값을 Map으로 저장하고 있다. 실제로 findMergedAnnotation를 호출하면 findMergedAnnotationAttributes 메서드를 사용한다.

classValuesAsString, nestedAnnotationsAsMap 두개의 파라미터가 더 있는데 classValuesAsString는 어노테이션에 클래스 속성이 있다면 이것을 class로 담을지 string으로 담을지 결정하는 것이고 nestedAnnotationsAsMap 파라미터는 속성중 어노테이션이 있다면 어노테이션 그대로 사용할지 아니면 AnnotationAttributes 클래스로 변환할지 결정하는 파라미터이다. (true이면 변환 아니면 그대로)

AnnotatedElementUtils.getMergedAnnotationAttributes(this.getClass(), Publish.class.getName(), false, false)
AnnotatedElementUtils.findMergedAnnotationAttributes(this.getClass(), Publish.class, false, false)

AnnotatedElementUtils.getAllMergedAnnotations, findAllMergedAnnotations

만약 메타 어노테이션이 여러개 선언되어있다면 해당 메서드를 사용하면 된다.

@RedisPublish(host = "localhost")
@KafkaPublish
public class SpringAnnotatedElementApplicationTests {
}

AnnotatedElementUtils.getAllMergedAnnotations(this.getClass(), Publish.class);
AnnotatedElementUtils.findAllMergedAnnotations(this.getClass(), Publish.class);

각각 2개의 어노테이션들이 리턴된다.

AnnotatedElementUtils.getMergedRepeatableAnnotations, findMergedRepeatableAnnotations

@Repeatable이 선언되었다면 선언된 어노테이션의 의 정보를 가져올 수 있다.

@Publish("foo")
@Publish("bar")
@Publish("name")
public class SpringAnnotatedElementApplicationTests {
   ///....
}

AnnotatedElementUtils.getAllMergedAnnotations(this.getClass(), Publish.class)
AnnotatedElementUtils.findAllMergedAnnotations(this.getClass(), Publish.class)

이 외도 많은 메서드가 있지만 이 정도만 알아봐도 문제 없을 듯하다.
하지만 여기에서 조금 궁금한점이 있다. 같은 메서드같은데 getxxx, findxxx 메서드가 종종 보인다. 하는 역할은 같아보이는데 무슨 차이가 있을까?
차이가 있으니 저렇게 나눠놨을 것이라고 판단되어서 찾아봤다.

getxxx 와 findxxx의 차이는 다음과 같다.

  • 클래스가 인터페이스를 상속받고 인터페이스의 해당 어노테이션이 있는 경우
@RedisPublish
interface Foo {

}

class FooClazz implements Foo {

}

AnnotatedElementUtils.findMergedAnnotation(FooClazz.class, Publish.class) // not null
AnnotatedElementUtils.getMergedAnnotation(FooClazz.class, Publish.class) // null
  • 클래스가 클래스를 상속받고 상위 클래스에 해당 어노테이션이 있는 경우
@RedisPublish
class Foo {

}

class FooClazz extends Foo {

}

AnnotatedElementUtils.findMergedAnnotation(FooClazz.class, Publish.class) // not null
AnnotatedElementUtils.getMergedAnnotation(FooClazz.class, Publish.class) // null
  • 해당 메서드가 브릿지 메서드일 경우

interface Comparable<T> { @RedisPublish int compareTo(T o); } class ComparableValues implements Comparable<String> { @Override public int compareTo(String o) { return 0; } } AnnotatedElementUtils.findMergedAnnotation(ComparableValues.class.getMethod("compareTo", Object.class), Publish.class); //not null AnnotatedElementUtils.getMergedAnnotation(ComparableValues.class.getMethod("compareTo", Object.class), Publish.class); //null
  • 클래스가 인터페이스를 상속받고 상위 메서드에 해당 어노테이션이 있는 경우
interface Foo {
    @RedisPublish
    void bar();
}


class FooClazz implements Foo {

    @Override
    public void bar() {

    }
}

AnnotatedElementUtils.findMergedAnnotation(FooClazz.class.getMethod("bar"), Publish.class); //not null
AnnotatedElementUtils.getMergedAnnotation(FooClazz.class.getMethod("bar"), Publish.class); // null
  • 클래스가 클래스를 상속받고 상위 메서드에 해당 어노테이션이 있는 경우
class Foo {
    @RedisPublish
    public void bar(){

    }
}


class FooClazz extends Foo {

    @Override
    public void bar() {

    }
}

AnnotatedElementUtils.findMergedAnnotation(FooClazz.class.getMethod("bar"), Publish.class); //not null
AnnotatedElementUtils.getMergedAnnotation(FooClazz.class.getMethod("bar"), Publish.class); // null

이하 default 메서드도 포함.

findxxxx가 좀 더 기능이 추가된 메서드라고 생각하면 되겠다. 상위 클래스, 인터페이스, 메서드들까지 검색하여 적절하게 리턴해준다.

오늘은 이렇게 util성의 클래스에 대해서 알아봤다. 어노테이션을 활용하여 개발한다면 해당 클래스를 참고하면 되겠다.

Spring은 @Inherited 어노테이션을 존중하므로 해당 어노테이션에 @Inherited 선언되어 있다면 getxxx 메서드 또한 적절하게 가공해서 제공해주고 있다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Publish(value = "redis")
@Inherited
public @interface RedisPublish {

    @AliasFor(annotation = Publish.class, attribute = "address")
    String host() default "";
}

@RedisPublish
class Foo {

    public void bar() {
    }
}

class FooClazz extends Foo {

    @Override
    public void bar() {

    }
}

AnnotatedElementUtils.getMergedAnnotation(FooClazz.class, Publish.class) //not null

Spring Web immutable Parameter

오늘은 Web immutable Parameter Object에 대해서 알아보도록 하자.
요즘에는 immutable Object를 많이 사용하는 듯 하다. 아마도 가장 좋은점은 스레드 세이프하다는 장점이 있어야 일 것이다.
그래서 오늘 Spring web과 관련해서 immutable 한 Parameter에 대해서 알아보도록 하자.

요즘은 코틀린으로 Spring 개발을 많이 하고 있고 Spring 에서도 코틀린을 거의 완벽히 지원해주고 있다.
또한 java에서는 lombok도 많이 사용하고 있으니 괜찮다면 한번 살펴보는 것도 나쁘지 않다.

@ModelAttribute

Spring5 부터는 @ModelAttribute도 불변의 Object도 사용가능하다. 아마도 코틀린을 지원하면서 고려가 많이 된 것 같다.

@RestController
public class PersonController {

    @PostMapping("/")
    Person person(@ModelAttribute Person person) {
        return person;
    }
}


public class Person {
    private final String name;
    private final String email;

    public Person(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

}

만약 위의 코드처럼 작성하고 해당 컨트롤러를 호출해보자.

http POST :8080 name==wonwoo email==wonwoo@test.com

만약 버전이 Spring5 이전버전이라면 아래와 같이 에러가 발생할 것이다.

java.lang.NoSuchMethodException: xxx.xxxxx.xxxxxxx.Person.<init>()
 ...
 ...

기본 생성자가 없다는 뜻으로 Spring5 이전버전에서는 무조건 default 생성자가 존재했어야 했다. 하지만 spring5 부터는 기본생성자 없이도 에러가 발생하지 않는다.
Spring5에서 테스트를 해보자.

http POST :8080 name==wonwoo email==wonwoo@test.com
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Sun, 27 Jan 2019 10:58:49 GMT
Transfer-Encoding: chunked

{
    "email": "wonwoo@test.com",
    "name": "wonwoo"
}

그럼 에러가 발생하지 않고 우리가 원하던 데이터가 출력이 된다. 아주 괜찮다. lombok을 대부분 많이, 잘 사용하니 lombok을 사용한다면 좀 더 코드가 간결해 질 수 있다.

@Value
public class Person {
    String name;
    String email;
}

lombok 의 @Value
lombok @Value가 하는 역할은 위의 링크를 참고 하자.

만약 해당 모델의 파라미터명과 전달하는 파라미터가 다르다면 어떻게 할까? 이것 역시 Spring 에서 지원해주고 있다.
@ConstructorProperties 어노테이션을 통해 해당 파라미터명을 변경할 수 있다. @ConstructorProperties 어노테이션은 Spring이 제공해주는 것 아니지만 지원은 해주는 java bean 스펙이다.

어쨌든 @ConstructorProperties 어노테이션을 이용해서 파라미터를 변경할 수 있으니 한번 해보자.

public class Person {
    private final String name;
    private final String email;

    @ConstructorProperties({"user_name", "user_email"})
    public Person(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

}

위와 같이 생성자에 @ConstructorProperties 어노테이션을 작성하고 해당 컬럼의 명을 순서대로 작성해주면 된다.

http POST :8080 user_name==wonwoo user_email==wonwoo@test.com
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Sun, 27 Jan 2019 11:03:29 GMT
Transfer-Encoding: chunked

{
    "email": "wonwoo@test.com",
    "name": "wonwoo"
}

아주 간단하다.

만약 lombok을 사용한다면 아래와 같이 작성하면 된다.

@Value
@RequiredArgsConstructor(onConstructor_ = @ConstructorProperties({"user_name", "user_email"}))
//@RequiredArgsConstructor(onConstructor = @__(@ConstructorProperties({"user_name", "user_email"})))
class Person {
    String name;
    String email;
}

onConstructor 문법은 java 버전과 관련이 있다.

@RequestBody (Jackson)

jackson 기준이기 때문에 다른 라이브러리를 사용한다면 다를 수 있으니 참고하길 바란다.

@RequestBody 어노테이션 또 한 불변의 Object로 만들 수 있다. Spring 지원한다기 보다는 사용하는 해당 라이브러리가 지원하고 있으니 사용하는 라이브러리의 문서를 살펴보면 좋다.

일단 기본적으로는 아무 설정하지 않았다면 jackson의 경우에는 기본생성자가 있어야 한다. 하지만 여러 방법으로 기본생성자 없이 사용할 수 있으니 한번 살펴보도록 하자.

@JsonProperty

@JsonProperty 어노테이션으로 해당 필드를 지정해주면 된다.

public class Person {

    private final String name;
    private final String email;

    public Person(@JsonProperty("name") String name, @JsonProperty("email") String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

위와 같이 작성한다면 기본생성자 없이도 deserialize가 가능하다. 만약 모델의 필드와 요청파라미터가 다르다면 @JsonProperty의 value를 변경하기만 하면 된다.

lombok을 사용한다면 다음과 같다.

@Value
class Person {
    String name;
    String email;

    public Person(@JsonProperty("name") String name, @JsonProperty("email") String email) {
        this.name = name;
        this.email = email;
    }
}

lombok일 경우에는 조금 귀찮다. 생성자 필드에는 어떻게 넣지..?

@ConstructorProperties

jackson 도 @ConstructorProperties 어노테이션을 지원하다. 아마도 jackson2.7 부터 지원한다고 했는데 그 이하버전은 테스트는 해보지 않았다.

public class Person {

    private final String name;
    private final String email;

    @ConstructorProperties({"name", "email"})
    public Person(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

마찬가지로 해당 필드를 변경하고 싶다면 @ConstructorProperties의 속성을 순서대로 변경하면 된다.

lombok을 사용할 경우는 다음과 같다.

@Value
@RequiredArgsConstructor(onConstructor_ = @ConstructorProperties({"name", "email"}))
class Person {
    String name;
    String email;
}

jackson-module-parameter-names

jackson 해당 모듈을 사용해서도 가능하다. 하지만 ObjectMapper를 조금 설정해줘야 한다.

@Bean
public ObjectMapper objectMapper() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new ParameterNamesModule());
    return objectMapper;
}

위와 같이 설정한 후에 사용하면 바로 사용할 수 있다. 하지만 위의 모듈은 java8부터 가능하다. 만약 그 이하 버전을 사용한다면 위의 모듈을 사용할 수 없다.

public class Person {

    private final String name;
    private final String email;

    public Person(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

해당 모듈을 사용한다면 어노테이션을 사용하지 않아도 기본적으로 동작을 한다.

lombok을 사용할 경우는 다음과 같다.

@Value
class Person {
    String name;
    String email;
}

참고로 Spring boot 2.0 부터는 spring-boot-starter-json 에 jackson-module-parameter-names 모듈이 포함되어 있다. 그래서 spring-boot-starter-web을 디펜더시 받는 다면 기본적으로 spring-boot-starter-json가 포함 되어 있으니 jackson-module-parameter-names를 추가 하지 않아도 된다. 만약 그 이전 버전을 사용한다면 해당 모듈만 디펜더시만 받으면 자동설정이 동작한다.
또 한 jackson-module-parameter-names 과 @ConstructorProperties 어노테이션은 함께 동작하지 않는 것 같다. jackson-module-parameter-names 을 사용한다면 @JsonProperty 어노테이션을 사용해야 한다.

@Value
//@RequiredArgsConstructor(onConstructor_ = @ConstructorProperties({"user_name", "user_email"})) // not working
class Person {
    String name;
    String email;

    public Person(@JsonProperty("user_name") String name, @JsonProperty("user_email") String email) {
        this.name = name;
        this.email = email;
    }
}

위처럼 jackson-module-parameter-names 을 사용하는 경우엔 @JsonProperty 어노테이션을 사용해서 해당 필드를 변경해야 한다.

오늘은 이렇게 Spring Web immutable Parameter 에 대해서 알아봤다.
꼭 Object를 불변으로 만들 필요는 없지만 사용할일이 있다면 참고하면 되겠다. 또 한 프로퍼티들을 변경해야 하는 일이 있다면 사용해도 괜찮을 듯 하다.

Spring boot 2.1 의@WebMvcTest

오늘 이야기할 내용은 Spring boot 2.1의 @WebMvcTest 어노테이션에 대해서 살펴볼텐데 기존의 @WebMvcTest과 조금 다른 부분이 있어 그것에 대해 알아보도록 하자.

만약 Spring boot 2.0 혹은 그 이전 버전에서 Spring boot 2.1로 버전을 올린다면 함께 봐야 할 수도 있다. 물론 그 상황이 라면?
필자도 Spring boot 2.0 에서 2.1로 올렸을 때 발생한 이슈였다. 2.0에서는 문제 없이 잘 실행 되었지만 2.1로 버전을 올렸더니 갑자기 테스트 케이스들이 실패하였다.
그래서 그 이유가 무엇인지 찾아보기 시작했다.

일단 어떤 경우에 이런 현상이 나오는지 코드로 살펴보도록 하자. 이 코드는 단지 예제일 뿐이다.

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/hi").authenticated()
            .anyRequest()
            .permitAll()
            .and()
            .csrf()
            .disable();
    }

    @Bean
    public UserManager userManager(UserRepository userRepository) {
        return new UserManager(userRepository);
    }
}

위는 Security 설정이다. 딱히 문제 될 것은 없어 보인다. 기본적인 Security 설정이며 UserManager 경우에는 Security 와 관련있는 어떤 클래스라고 생각하면 되겠다. 그래서 위와 같이 설정을 했다고 하자.

@RestController
public class HelloController {

    @GetMapping("hi")
    public String hi() {
        return "hi";
    }
}


@RunWith(SpringRunner.class)
@WebMvcTest(HelloController.class)
@WithMockUser
public class HelloControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void hi() throws Exception {
        mockMvc.perform(get("/hi")).andExpect(status().isOk())
            .andExpect(content().string("hi"))
            .andDo(print());
    }
}

그리고 나서 위와 같이 테스트 코드를 작성해보자. 딱히 문제는 없어 보인다. 테스트 코드를 돌려봐도 문제가 없다. 초록색 창이 뜨면서 테스트가 성공한 것을 볼 수 있을 것이다.
하지만 여기에서 버전을 2.1로 올려서 테스트를 한다면 어떻게 될까? 위의 코드가 2.1에서 잘 동작했다면 필자는 이 글을 쓸 이유가 없었을 것이다.

Spring boot 버전을 2.1로 올리고 테스트를 해보면 다음과 같은 에러가 발생한다.

java.lang.IllegalStateException: Failed to load ApplicationContext

....

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.demosecurity.UserRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:1644)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1203)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1164)
    at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:857)
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:760)

stack trace 가 길어서 특정부분만 가져왔다.

갑자기 버전을 올리니 위와 같은 에러가 발생한다. 아까 위에서 설정한 UserManager의 디펜더시인 UserRepository타입의 빈을 찾을 수 없다는 내용이다. 그럼 왜 갑자기 이런 에러가 발생하는 걸까?

그 이유는 @WebMvcTest어노테이션으로 테스트할 때 기존에는 Spring boot의 기본 Config를 사용했다면 2.1부터는 WebSecurityConfigurer를 사용한 Config들도 같이 설정되어 실행된다. 그래서 위와 같이 UserRepository 라는 빈을 찾지 못한다고 에러가 발생된 것이다.

기존에는 설정해 놓은 프로젝트의 Security의 설정이 동작하는 것이 아니라 Spring boot 의 기본 설정이 동작해서 이 것이 정말 맞는 테스트인가? 의문도 생길수 있다.

아래의 코드를 살펴보자.

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/admin/**").access("hasRole('ADMIN')")
            .anyRequest()
            .permitAll()
            .and()
            .csrf()
            .disable();
    }
}

기존의 코드를 위의 코드로 변경하고 테스트케이스를 만들어보자.

@RunWith(SpringRunner.class)
@WebMvcTest(AdminController.class)
public class AdminControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "example", roles = {"ANONYMOUS"})
    public void adminTestWithBadAuthentication() throws Exception {
        mockMvc.perform(get("/admin"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(username = "user", roles = {"ADMIN"})
    public void adminTestWithAuthentication() throws Exception {
        mockMvc.perform(get("/admin"))
            .andExpect(status().isOk())
            .andExpect(content().string("admin"));
    }

}

위의 테스트 코드는 전혀 문제가 없어 보인다. adminTestWithBadAuthentication 메서드를 테스트 할 경우 role 이 ANONYMOUS 이니 403 에러가 떨어져야 정상이다. 왜냐하면 /admin/** 으로 들어올경우 role이 ADMIN 이어야 한다고 설정했으니 말이다.

.antMatchers("/admin/**").access("hasRole('ADMIN')")

하지만 Spring boot 2.0 혹은 그 이전버전에서는 위의 테스트 코드가 정상적으로 성공하지 않는다. adminTestWithAuthentication 메서드는 성공하나 adminTestWithBadAuthentication 메서드는 실패로 돌아간다.

java.lang.AssertionError: Status 
Expected :403
Actual   :200

우리는 http status code가 403을 기대 했는데 뜬금없이 정상적인 코드 200이 떨어졌다. 우리가 원하는 테스트가 아니다.

만약 2.1 이전 버전에서 위의 테스트 코드를 성공시키고 싶다면 아래와 같이 코드를 조금 변경해야 한다.

@RunWith(SpringRunner.class)
@WebMvcTest(AdminController.class)
@Import(WebSecurityConfig.class)
public class AdminControllerTests {
  //...
}

그러면 아무 문제 없이 테스트를 통과 할 수 있다. 하지만 Spring boot 2.1 부터는 위와 같이 설정하지 않아도 기본적으로 프로젝트의 Security 설정을 등록하므로 위와 같은 설정은 필요 없다.

오늘은 이렇게 Spring boot 2.1의 @WebMvcTest 변화에 대해서 알아봤다.
만약 Spring boot 2.1로 올리면서 테스트가 깨진다면 해당 이유일 수도 있으니 참고하면 되겠다.

해당 이슈의 토론은 여기에서 확인 할 수 있다.
원래 해당이슈는 2.0 때 개발되었다가 revert 되었다. 아마도 2.0이 나오기 바로 직전에 개발되어서 리스크가 크다고 판단되어 revert를 하고 2.1에 다시 개발되었다.