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에 다시 개발되었다.

Spring 의 @ControllerAdvice

오늘은 Spring의 @ControllerAdvice 어노테이션에 대해서 알아보도록 하자. 많은 내용은 아니지만 이런기능도 있으니 한번 살펴보도록 하자.
대부분이 Spring 을 사용할 때 @ControllerAdvice를 글로벌 예외처리기로 사용한다. 하지만 @ControllerAdvice 어노테이션은 예외처리기만을 위한 것은 아니다.

아마도 예외처리기로 사용할 때가 많아서 대부분이 예외처리로 사용할 뿐이다.
구글에 @ControllerAdvice을 검색을 해보면 Exception 처리만 수두룩하다. 뭐 틀린말은 아니다. 예외처리로만 사용해도 문제는 없다.
하지만 예외처리뿐만 아니라 다용도로 사용할 수 있으니 알면 좋을 것 같아서 포스팅을 해본다.

흔히 사용하는 예외처리 @ExceptionHandler

대부분이 @ControllerAdvice를 예외처리기로만 사용한다. 필자도 일반적으로 여러분과 비슷하게 @ControllerAdvice를 사용할 때 99%를 예외처리기로 사용한다.

@ControllerAdvice
public class GlobalControllerAdvice {

    @ExceptionHandler(NullPointerException.class)
    public void nullPointerException(NullPointerException e) {
        //blabla
    }

    //...
}

우리는 위와 같이 특정한 exception을 잡아서 처리한다. 아주 좋은 방법이다. exception을 비지니스 로직에 넣지 않고 분리함으로써 비지니스 로직에 좀 더 집중할 수 있게 한다.
아마도 예외처리기로 대다수가 작성하다보니까 @RestControllerAdvice 어노테이션도 추가 된 듯 싶다. @RestControllerAdvice 은 @ControllerAdvice 어노테이션과 @ResponseBody 어노테이션을 합쳐놓은 어노테이션이다. 만약 예외처리를 body로 전달하고 싶다면 @RestControllerAdvice 이용하면 된다.

ModelAttribute

@ControllerAdvice 어노테이션에 사용할 수 있는 어노테이션은 @ModelAttribute 어노테이션이다.
이것 역시 글로벌 하게 사용할 수 있다. @ModelAttribute를 모든 Controller에 사용한다면 @ControllerAdvice에 선언하면 된다.

@RestControllerAdvice
public class GlobalControllerAdvice {

    @ModelAttribute
    public User user() {
        return new User("wonwoo");
    }
}

위와 같이 사용할 경우 ModelAttribute를 글로벌하게 사용한다는 의미이다. 예를들어 Spring security를 사용해서 사용자 정보를 얻고 싶다면 아래처럼 작성하면 좀 더 편리하다.

@ControllerAdvice
public class GlobalControllerAdvice {

    @ModelAttribute
    public User user(@AuthenticationPrincipal User user) {
        return user;
    }
}

매번 Controller에 작성하지 않고 @ControllerAdvice 이용해서 한번만 작성하면 된다. 매번 작성하는 것보다 나은 방법이다.

InitBinder

@ControllerAdvice에 사용할 수 있는 어노테이션이 한가지 더 있다. 바로 @InitBinder 어노테이션이다. @InitBinder 어노테이션은 아주 다양한 설정을 지원한다.
예를들어 Validator, Formatter, Converter, PropertyEditor 등, 뿐만 아니라 여러가지를 설정을 할 수 있는 어노테이션이다. 여기서는 주 목적이 이것들을 설명하는 것이 아니라 생략하겠다.

이런것들 역시 우리는 글로벌하게 설정할 수 있다. 바로 @ControllerAdvice 를 이용하면 된다.

@ControllerAdvice
public class GlobalControllerAdvice {

    @InitBinder
    public void initBinder(DataBinder dataBinder){
       //...
    }
}

위와 같이 사용할 경우에도 역시 글로벌 하게 @InitBinder를 사용할 수 있다. 근데 사실 글로벌하게 사용할 일이 있나 싶기도 하다. Validator, Formatter, Converter 는 해당 컨트롤러만 사용할 일이 많아 해당 컨트롤러에 있는게 더 나은 방법인 것 같다. 또한 Formatter, Converter 경우에는 WebMvcConfigurer 상속받아 설정하는 것이 더 편리해서 굳이 이 방법으로 글로벌하게 설정 할일은 드물 것 같다. 그렇지만 만약 사용할 일이 있다면 이 방법도 존재하니 참고하면 되겠다.

ResponseBodyAdvice, RequestBodyAdvice

이번엔 어노테이션이 아닌 인터페이스이다. @ControllerAdvice를 사용할 수 있는 마지막 인터페이스이다. 해당 인터페이스를 구현하고 @ControllerAdvice 어노테이션을 설정하면 Spring이 자동으로 감지해 해당 인터페이스의 역할에 맞게 실행해 준다.

@ControllerAdvice
public class FooResponseBodyAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return false;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
        Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        return null;
    }
}


@ControllerAdvice
public class FooRequestBodyAdvice implements RequestBodyAdvice {

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return false;
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
        Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        return null;
    }

    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
        Class<? extends HttpMessageConverter<?>> converterType) {
        return null;
    }

    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
        Class<? extends HttpMessageConverter<?>> converterType) {
        return null;
    }
}

위 처럼 ResponseBodyAdvice, RequestBodyAdvice 인터페이스만 구현해주면 된다. 정확한 사용법은 해당 Spring 문서를 보면 될 것 같다.
간단하게 말하자면 ResponseBodyAdvice 는 body에 쓰기전에 커스텀하게 변경가능하고 RequestBodyAdvice 경우에는 바디를 읽기전, 읽은 후 등에 커스텀하게 Body를 변경가능하다.

간단한 예제로 Jackson 의 @JsonView 어노테이션들이 Spring이 사용하게끔 ResponseBodyAdvice, RequestBodyAdvice 인터페이스를 이용해서 구현되었다.


@PostMapping("/") @JsonView(View.Users.class) public User hello(@JsonView(View.Users.class) @RequestBody User user) { return user; }

@JsonView 사용법은 어렵지 않으니 다른 블로그들을 참고 하면 되겠다. 예전에 쓴 글이 있으나 영 빈약해서..

오늘은 이렇게 Spring 의 @ControllerAdvice 에 대해서 알아봤다.
@ControllerAdvice는 예외처리기 말고도 위와 같이 많은 기능을 제공해주고 있으니 필요하다면 사용해도 괜찮다.

Spring boot Properties

오늘은 Spring boot properties에 대해서 이야기 해보도록 하자.
Spring boot 에는 다양한 설정파일을 제공하고 있다. .properties, .yaml 파일뿐만 아니라 다른 여러방법도 존재한다.
이것뿐만 아니라 우선순위 등 잘 사용하는 방법을 알아보도록 하자.

Random

properties에 random 함수를 사용할 수 있다. 실제로는 자주 사용하지는 않겠지만 테스트할 경우에는 유용할 수 있는 랜덤함수이다. 하지만 필자도 한번도 사용한적이 없다는..
int, long, uuid, 원하는 범위의 int 등으로 설정할 수 있다.

name=${random.int}
name=${random.long}
name=${random.value}
name=${random.int(10)}
name=${random.int[1024,65536]}

위와 같이 random 이라고 prefix에 작성하면 된다. 실제 이것은 바꿀수는 없다. 위의 값을 꺼내보면 매번 다른 값이 출력 된다.
해당 클래스는 RandomValuePropertySource 클래스이니 참고하길 바란다.

xml?

우리가 흔히 알고 있는 .properties, .yaml 파일의 확장자뿐만 아니라 xml 파일도 설정파일로 작성할 수 있다. 그렇지만 잘 사용하지 않는 이유는 properties나 yaml이 더욱 간편해서? 아마 그런거 같은데 xml도 설정파일로도 가능하니 사용하고 싶으면 사용해도 된다.

application.xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <comment>comment</comment>
    <entry key="name">wonwoo</entry>
</properties>

별다른 설정하지 않아도 위와 같이 설정하면 name이라는 프로퍼티에 wonwoo 라는 값이 들어가 있을 것이다.
음 이것은 java의 Properties 클래스를 이용하는 거 보니까 java의 스펙인 것 같다. 더 자세한 정보들은 인터넷 또는 스펙을 찾아보면 될 것 같다.

또한 Spring boot 에서 지원해주는 것은 위와 같이 .properties, .yml, xml 이지만 만약 자신은 다른 파일, 예를들어 json 파일로 설정파일을 하고 싶다면 다음과 같이 구현만 해주면 된다.


public class JsonPropertySourceLoader extends YamlPropertySourceLoader { @Override public String[] getFileExtensions() { return new String[]{"json"}; } }
{
  "name": "blabla"
}

위와 같이 만들어서 설정해주면 된다. 어떤 설정파일이든 자신에 맞게 구현하면 쉽게 파일들을 설정정보로 넣을 수 있다.

우선순위

Spring boot properties는 우선순위가 존재한다. 예를들어 application.properties, 와 application-dev.properties 존재하는 가운데 profile을 dev로 한다면 어떻게 될까? 우선순위가 높은건 application-dev.properties 파일이 된다. 우선순위가 낮다고 해서 application.properties가 사라지는 건 아니고 application-dev.properties 파일을 먼저 읽고 그 다음에 application.properties를 읽게 된다.

applciation.properties
name=kevin
address=seoul
applcation-dev.properties
name=wonwoo

만약 파일이 위와 같다면 name=wonwoo가 우선순위가 높아 dev 파일을 읽지만 address는 dev에 존재하지 않으니 application.properties 파일에 있는 address를 읽게 된다. 우선순위가 높은게 오버라이딩되니 참고하면 되겠다.

Spring boot 에서는 classpath에 있는 properties 말고도 다른 파일들을 로드하려고 한다. 예를들어 classpath:config/ 위치에 properties가 존재해도 그 파일을 읽는다.
기본적으로는 네 군데의 위치에서 파일을 읽는다.

  1. 해당 프로젝트의 루트 /config/ file:./config/
  2. 해당 프로젝트의 루트 file:./
  3. 해당 프로젝트의 클래스패스의 /config/ classpath:/config/
  4. 해당 프로젝트의 클래스패스 classpath:/

위와 같이 네 군데에서 파일들을 읽어 온다. 여기서도 우선순위가 정해진다. 가장 우선순위가 높은 위치는 1번이고 우선순위가 낮은 위치는 4번이다. 하지만 필자는 보통 4번에 넣는다는 점..

기본적으로는 위의 네 군데에서 파일을 읽어드리지만 만약 변경하고 싶다면 다음과 같이 변경하면 된다.

spring.config.location=classpath:/test/

SPRING_CONFIG_LOCATION=classpath:/test/

위와 같이 변경하면 classpath 의 /test 폴더 아래의 프로퍼티를 읽게 된다. 이것은 기존의 classpath에 application.properties 파일이 있어도 그 파일은 읽지 않는다. 그냥 저 /test/ 폴더 밑의 파일들만 읽게 된다. 따락서 나머지 파일들은 해당 설정에 파일로 읽어드리지 않는다.

참고로 spring.config.location 프로퍼티는 시스템 환경설정이나 args로 넣어야 동작한다. 왜냐하면 그 파일을 읽기 위해 바로 로드되어야 하기 떄문이다.

또한 spring.config.additional-location 속성을 통해 확장할 수 있다. spring.config.location 은 해당 파일만 읽어들이는 거라면 spring.config.additional-location은 설정한 해당 파일을 읽고 나머지 기본설정도 읽는 기능이다.

spring.config.additional-location=classpath:/additional/ 

SPRING_CONFIG_ADDITIONAL_LOCATION=classpath:/additional/

만약 위와 같이 설정했다면 제일 우선순위가 높은 파일은 classpath:/additional/ 아래의 파일들 이다. 그리고 나머지는 기본값과 동일하다.

  1. classpath:/additional/
  2. file:./config/
  3. file:./
  4. classpath:/config/
  5. classpath:/

참고로 만약 classpath:/additional/ 와 file:./additional/ 같이 설정하였다면 file: 이 항상 우선순위가 높다. spring.config.location 도 동일하다.

properties와 yaml 의 우선순위는 properties가 높다. 아마도 파일명으로 정렬을 하는듯 싶다.

spring.application.json

Spring boot는 설정정보를 json으로 바로 작성해서 환경설정을 할 수 있다.

spring.application.json={"name":"won1"}
SPRING_APPLICATION_JSON={"name":"won1"}

이 또한 시스템환경변수나 commandLine args 로 작성해야 된다.

위에서 봤던 spring.config.additional-location 보다 spring.application.json이 우선순위가 더 높다. 만약 additional-location과 application.json을 같이 사용했을 경우에는 spring.application.json에 작성했던 json이 먼저 환경설정에 들어가게 된다.

오늘은 이렇게 Spring boot의 properties에 대해서 살펴봤다. 우선순위가 조금 헷갈려서 다시 한번 찾아보는 계기가 되었다.