Spring WebClient

오늘은 Spring의 WebClient의 사용법에 대해서 몇가지 알아보도록 하자. 사용 API만 살펴 볼 예정이므로 reactive streams(reactor..) 들의 개념과 사용법은 다른 블로그를 살펴보길 바란다. reactive streams 대한 내용을 알고 보면 좋지만 몰라도 코드를 보는데는 문제가 없을 듯 하다.

WebClient는 Spring5 에 추가된 인터페이스다. spring5 이전에는 비동기 클라이언트로 AsyncRestTemplate를 사용을 했지만 spring5 부터는 Deprecated 되어 있다. 만약 spring5 이후 버전을 사용한다면 AsyncRestTemplate 보다는 WebClient 사용하는 것을 추천한다. 아직 spring 5.2(현재기준) 에서 AsyncRestTemplate 도 존재하긴 한다.

기본 문법

기본적으로 사용방법은 아주 간단하다. WebClient 인터페이스의 static 메서드인 create()를 사용해서 WebClient 를 생성하면 된다. 한번 살펴보자.

@Test
void test1() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

create()는 두가지 메서드가 있는데 baseUrl를 지정해주는 것과 그렇지 않은 것 두가지가 존재한다. 원하는 API를 사용하면 되겠다.

@Test
void test1() {

    WebClient webClient = WebClient.create();
    Mono<String> hello = webClient.get()
            .uri("http://localhost:8080/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

API 자체도 명확하다. get(), post(), put(), patch(), 등 http method들을 모두 정의되어 있다.

webClient.get()
  .///
webClient.post()
  .///
webClient.put()
  .///
webClient.method(HttpMethod.GET)
  .///

또는 위와 같이 HttpMethod를 지정할 수 있다.

uri 또한 여러 메서드가 존재한다. 단순하게 string 으로 uri을 만들 수 도 있고 queryParam, pathVariable 등 명확하게 uri을 만들 수 도 있다. 위의 코드를 사실 RestTemplate 클래스를 자주 사용했다면 익숙한 문법일 수 있다.

@Test
void test1_3() {

    WebClient webClient = WebClient.create();
    Mono<String> hello = webClient.get()
            .uri("http://localhost:8080/sample?name=wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}


@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", Map.of("name", "wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

@Test
void test1_3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.get()
            .uri(it -> it.path("/sample")
                    .queryParam("name", "wonwoo")
                    .build()
            ).retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

위와 같이 여러 방법이 존재하니 각자가 원하는 어떤것을 사용해도 좋다. 마지막 S uri(Function<UriBuilder, URI> uriFunction) API는 좀 더 세세하게 컨트롤 할 수 있으니 세세하게 컨트롤 할 일이 있다면 이 API를 사용하는게 좋다.

다음은 retrieve() 메서드인데 이 메서드는 request를 만들고 http request를 보내는 역할을 한다. 이 메서드 말고 exchange()가 존재하는데 약간 다르다. 사실 API만 살짝 다를뿐이지 retrieve() 내부에선 exchange() 메서드를 호출한다.

retrieve() 메서드는 ResponseSpec 타입을 리턴하고 exchange() 메서드는 Mono<ClientResponse> 를 리턴하고 있다.

@Test
void test2() {

    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .exchange()
            .flatMap(it -> it.bodyToMono(String.class));

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

위의 test1_3 메서드와 test2 메서드는 동일한 동작을 한다. 위에서 말했다시피 exchange() 메서드는 ClientResponse를 사용해 좀 더 세세한 컨트롤을 하면 된다.

@Test
void test2() {

    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample1?name={name}", "wonwoo")
            .exchange()
            .flatMap(it -> {
                if(it.statusCode() == HttpStatus.NOT_FOUND) {
                    throw new NotFoundException("....");
                }
                return it.bodyToMono(String.class);
            });

    StepVerifier.create(hello)
            .verifyError(NotFoundException.class);
}

이렇게 기본문법에 대해서 알아봤다. 그리 어려운 내용도 없는 듯 하다. 좀 더 범용성있게 사용하려면 아직은 부족하다. 좀 더 살펴보자.

formData 와 message

위에서 알아보지 않은게 있는데 바로 post나 put 기타 http 메서드에 자주 사용하는 formdata 와 message에 대해서 알아보자.
만약 formData 로 server에 보낸다면 다음과 같이 작성하면 된다.

import static org.springframework.web.reactive.function.BodyInserters.fromFormData;

@Test
void test3() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.post()
            .uri("/sample")
            .body(fromFormData("name", "wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

fromFormData란 static 메서드를 사용해서 전달하면 된다. 만약 좀 더 많은 내용이 있다면 .with(key, value) 메서드를 체이닝해 사용하면 된다.

.body(fromFormData("name", "wonwoo").with("foo","bar").with("...","..."))

또는 MultiValueMap를 이용해서 fromFormData에 넣어도 된다.

이번엔 message에 대해서 알아보자. 일반적으로 json, xml 기타 message로 보낼때 유용하다. 한번 살펴보자.

@Test
void test3_1() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.put()
            .uri("/sample")
            .bodyValue(new Sample("wonwoo"))
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

위와같이 bodyValue를 이용해서 message를 전달할 수 있다. 참고로 spring 5.2 이전버전에선 syncBody를 이용해야 한다. spring 5.2에선 syncBody는 Deprecated 되었다.

만약 전달하는 message가 Publisher 타입일 수 도 있다. 그럼 다음과 같이 작성하면 된다.

@Test
void test3_2() {

    WebClient webClient = WebClient.create("http://localhost:8080");
    Mono<String> hello = webClient.put()
            .uri("/sample")
            .body(Mono.just(new Sample("wonwoo")), Sample.class)
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("wonwoo")
            .verifyComplete();
}

filter

filter이용하면 client를 호출하기전에 인터셉터해서 추가적인 작업을 할 수 있다. 예를들면 로그를 출력 할 수도 있고 헤더정보 혹은 인증정보를 넣어 호출 할 수 있다.

필터를 사용하려면 ExchangeFilterFunction 인터페이스를 구현하면 된다. 추상 메서드는 하나뿐이라 람다를 이용해도 좋다.

@Test
void test4() {

    WebClient webClient = WebClient.builder().filter((request, next) -> next.exchange(ClientRequest.from(request)
            .header("foo", "bar").build())).baseUrl("http://localhost:8080")
            .build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();

}

위의 코드는 헤더 정보 를 추가하는 코드이다. 또한 한번에 여러 필터를 적용할 수 도 있다.

WebClient.builder().filters(exchangeFilterFunctions ->
        exchangeFilterFunctions.add(0, (request, next) -> {
            return next.exchange(request);
        }));

위의 코드는 0번째에 해당 필터를 삽입하는 코드이다. 물론 filter를 계속 체이닝해서 써도 상관 없다.

ClientHttpConnector

현재 spring에서는 reactive http client가 2개밖에 존재하지 않는다. netty와 jetty이다. 사실 spring을 사용한다면 그냥 netty를 사용하는게 정신건강에 좋을 듯 싶다.

<dependency>
    <groupId>org.eclipse.jetty</groupId>
    <artifactId>jetty-reactive-httpclient</artifactId>
</dependency>

위와 같이 jetty reactive httpclient를 먼저 디펜더시 받은 후에 다음과 같이 작성하면 된다.

@Test
void test5() {
    WebClient webClient = WebClient.builder().clientConnector(new JettyClientHttpConnector())
            .baseUrl("http://localhost:8080").build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();
}

추가 적인 설정은 JettyClientHttpConnector 클래스와 org.eclipse.jetty.client.HttpClient 클래스를 살펴보면 되겠다.

default values

만약 기본으로 헤더정보 쿠키정보등 값을 지정하고 싶다면 다음과 같이 작성하면 된다.

@Test
void test6() {

    WebClient webClient = WebClient.builder().baseUrl("http://localhost:8080")
            .defaultHeader("foo", "bar")
            .defaultCookie("foo", "BAR")
            .defaultRequest(it -> it.header("test", "sample")).build();

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .expectNext("hello wonwoo!")
            .verifyComplete();

}

위와 같이 작성하면 기본으로 위와 같은 값이 같이 전송된다. defaultRequest() 메서드를 사용하면 좀더 세세하게 컨트롤이 가능하니 참고하면 되겠다.

retrieve

위에서 잠깐 언급한 retrieve 메서드를 이용하는 경우에 보다 상세한 에러 코드들을 컨트롤 할 수 있다. 원한다면 사용해도 좋다.

@Test
void test7() {
    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample1?name={name}", "wonwoo")
            .retrieve()
            .onStatus(HttpStatus::is4xxClientError, __ -> Mono.error(new IllegalArgumentException("4xx")))
            .onStatus(HttpStatus::is5xxServerError, __ -> Mono.error(new IllegalArgumentException("5xx")))
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .verifyErrorMessage("4xx");

} 

onStatus 메서드를 이용해서 해당 코드를 작성후에 Mono type의 exception을 던지면 된다. 위의 코드는 4xx 에러 코드일땐 4xx라는 메시지를 던지고 5xx 에러 코드일 땐 5xx라는 메세지를 던진다는 코드이다.

onStatus() 메서드 말고도 onRawStatus() 메서드도 존재한다. 이것은 위와 같이 HttpStatus 코드가 아닌 int로된 코드를 리턴한다.

@Test
void test8() {
    WebClient webClient = WebClient.create("http://localhost:8080");

    Mono<String> hello = webClient.get()
            .uri("/sample?name={name}", "wonwoo")
            .retrieve()
            .onRawStatus(it -> it == 400, __ -> Mono.error(new IllegalArgumentException("aaa")))
            .bodyToMono(String.class);

    StepVerifier.create(hello)
            .verifyErrorMessage("400");
}

이렇게 기본문법과 사용법에 대해서 알아봤다. 물론 좀 더 많은 메서드들이 있지만 필자가 자주 사용할만한 API 위주로 살펴봤다. 다른 궁금한 점이 있다면 해당 문서를 찾아보길 추천한다.

마지막으로 Spring boot를 사용할 때에 WebClient는 어떻게 사용해야 될까? 사실 기본적인 설정은 되어있다. 그래서 아주 쉽고 간단하게 사용할 수 있다.

spring boot

spring boot 를 사용할 때는 WebClient.Builder 인터페이스가 기본적으로 bean으로 등록 되어있다. 그래서 우리는 이걸 사용하면 된다.

@RestController
public class WebClientController {

    private final WebClient webClient;

    public WebClientController(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://localhost:9999").build();
    }

    @GetMapping("/users")
    public Mono<String> findByName() {
        return webClient.get()
                .uri("/users")
                .retrieve()
                .bodyToMono(String.class);
    }
}

딱히 설명할 것도 없다. 만약 필터나 default values 가 필요하면 위에서 했던 그 방법을 그대로 이용하면 된다.

public WebClientController(WebClient.Builder builder) {
    this.webClient = builder
            .filter((request, next) -> next.exchange(request))
            .defaultHeader("foo", "bar")
            .baseUrl("http://localhost:8080")
            .build();
}

그리고 만약 전역적으로 커스텀할 코드들이 있다면 WebClientCustomizer 인터페이스를 이용해서 커스텀할 수 있다.

@Bean
WebClientCustomizer webClientCustomizer() {
    return builder -> builder.filter((request, next) -> next.exchange(request));
}

위와 같이 WebClientCustomizer 빈으로 등록하여 커스터마이징하면 된다.

번외로 kotlin 코드도 한번 살펴보자.

bodyToFlux(), bodyToMono(), body(), awaitExchange(), bodyToFlow(), awaitBody() 등 확장함수로 된 함수들이 몇가지 존재하니 참고하면 되겠다. 몇가지는 코루틴 관련 확장함수이다.

오늘은 이렇게 Spring의 WebClient에 대해서 살펴봤다. 이정도만 알아도 사용하기엔 충분할 듯 싶다. Spring 5에서 Non blocking http client를 사용한다면 꼭 WebClient를 사용하도록 하자!

Spring WebFlux HandlerMethodArgumentResolver

오늘은 Spring WebFlux의 HandlerMethodArgumentResolver에 대해서 알아보도록 하자.

사실 WebFlux 이전에 WebMvc에도 동일한 기능이 존재한다. 인터페이스명까지 동일하니 거부감은 사실 없다. 기존의 mvc의 기능과 동일은 하나 WebFlux API에 맞춰진 형태라 생각하면 된다. 어떤 기능인지는 여기를 참고해도 되고 다른 블로그 혹은 문서를 살펴봐도 좋다.

WebMvc 클래스는 org.springframework.web.method.support.HandlerMethodArgumentResolver와 같고 WebFlux의 클래스는 org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver 이와 같다.

HandlerMethodArgumentResolverSupport

위의 내용을 알아보기 전에 WebMvc에는 존재 하지 않지만 WebFlux에 존재하는 클래스인 HandlerMethodArgumentResolverSupport 를 살펴보자. HandlerMethodArgumentResolverSupport 에는 protected 메서드가 3개 존재하는데 checkParameterType, checkParameterTypeNoReactiveWrapper, checkAnnotatedParamNoReactiveWrapper 라는 메서드이다.

어떤 일들은 하는지 한번살펴보자. 일반적으로는 HandlerMethodArgumentResolver.supportsParameter 메서드에서 많이 사용하고 있다. 물론 위의 메서드를 사용하지 않아도 된다.

checkParameterType

checkParameterType 메서드는 reactive 랩퍼 클래스를 허용한다는 메서드이다. 한마디로 파라미터에 reactive 랩퍼 클래스를 사용해도 된다는 의미를 갖고 있다.

사용법은 아래와 같다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return checkParameterType(parameter, UserInfo.class::isAssignableFrom);
}

두번째 파라미터는 Predicate<Class<?>>로 해당 조건이 만족하면 true를 리턴하고 그렇지 않으면 false를 리턴한다.
UserInfo 라는 클래스를 파라미터로 허용한다는 뜻인데 그게 만약 reactive 클래스라도 허용한다는 뜻이다.

@GetMapping("/")
public Mono<Message> message(Mono<UserInfo> userInfo) {

    //...
}

@GetMapping("/")
public Mono<Message> message(UserInfo userInfo) {

     //...
}

위와 같이 Mono<UserInfo>UserInfo 둘다 가능하다는 뜻이다.

checkParameterTypeNoReactiveWrapper

checkParameterType 메서드와는 다르게 reactive 스타일을 허용하지 않는다.
사용법은 다음과 같다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return checkParameterTypeNoReactiveWrapper(parameter, UserInfo.class::isAssignableFrom);
}

두번째 파라미터는 이 또한 Predicate<Class<?>>로 해당 조건이 만족하면 true를 리턴하고 그렇지 않으면 false를 리턴한다.

@GetMapping("/")
public Mono<Message> message(Mono<UserInfo> userInfo) {

    //...
}

만약 위와 같이 작성했지만 파라미터를 reactive 스타일로 받는다음 에러가 발생한다.

FooHandlerMethodArgumentResolver does not support reactive type wrapper: reactor.core.publisher.Mono<ml.wonwoo.example.UserInfo>

위의 메서드를 사용할 경우에는 아래와 같이 사용해야 된다.

@GetMapping("/")
public Mono<Message> message(UserInfo userInfo) {

    //...
}


checkAnnotatedParamNoReactiveWrapper

위의 두 내용은 Type과 관련있었지만 이 메서드는 애노테이션과 관련이 있다. 이 메서드는 리액티브 스타일을 허용 하지 않는다.

사용법은 다음과 같다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return checkAnnotatedParamNoReactiveWrapper(parameter, CurrentUser.class,
            (annotation , clazz) -> !UserInfo.class.isAssignableFrom(clazz));
}

두번째 파라미터는 BiPredicate<A, Class<?>>로 해당 조건이 만족하면 true를 리턴하고 그렇지 않으면 false를 리턴한다.

위와 같이 작성하면 아래와 같이 파라미터로 사용가능하다.

@GetMapping("/")
public Mono<Message> message(@CurrentUser User user) {

     //...
}

근데 왜 checkAnnotatedParamReactiveWrapper 메서드는 만들지 않았을까?

어쨌든 WebFlux에서는 HandlerMethodArgumentResolverSupport 대부분 상속받아서 구현하고 있으니 HandlerMethodArgumentResolverSupport 상속받아서 구현해도 되며 HandlerMethodArgumentResolver 를 직접 구현해도 상관없다.

하지만 필자는 HandlerMethodArgumentResolverSupport 상속받아서 구현했다.

resolveArgument

위에서는 어떤 파라미터로 받을지 체크하는 부분이였다면 resolveArgument는 해당 파라미터에 대한 구현을 직접해줘야 한다.

만약 reactive 스타일로 파라미터로 받지 않는다면 예전처럼 webmvc 구현해도 되지만 그렇지 않고 reactive 스타일도 지원한다면 추가적으로 조금 구현해야 되는게 있다.

참고로 동기적으로 구현하고 싶다면 SyncHandlerMethodArgumentResolver 를 구현하면 된다.

해당 파라미터가 reactive 스타일의 파라미터인지 알아야 한다. 하지만 이것보다 중요한건 대부분 reactor를 사용하겠지만 만약 다른 Reactive Streams API(rxjava1, rxjava2, jdkFlow) 혹은 coroutine 을 사용한다면 그에 맞게 변환을 해줘야 한다. 그 클래스가 ReactiveAdapterRegistry 이며 자세한건 문서를 찾아보자.

참고로 HandlerMethodArgumentResolverSupport 의 메서드들도 ReactiveAdapterRegistry를 사용한다.

@Override
public Mono<Object> resolveArgument(MethodParameter parameter, BindingContext bindingContext, ServerWebExchange exchange) {

    ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);

    ReactiveAdapter adapter = (resolvableType != null ? getAdapterRegistry().getAdapter(resolvableType.resolve()) : null);

    Mono<UserInfo> userMono = Mono.just(new UserInfo("wonwoo", "wonwoo@test.com"));

    return userMono
            .map(userInfo -> adapter != null ? adapter.fromPublisher(userMono) : userInfo);
}

위와 같이 해당 타입의 ReactiveAdapter를 가져와 Publisher 하면 된다. 아주 간단하다. 크게 복잡한 내용은 없는 것 같다.
그럼 다음과 같이 사용가능하다.

//reactor
@GetMapping("/")
public Mono<UserInfo> hello(Mono<UserInfo> userInfo) {

    return userInfo;
}

@GetMapping("/")
public Flux<UserInfo> hello(Flux<UserInfo> userInfo) {

    return userInfo;
}

//rxjava 1,2 
@GetMapping("/")
public Single<UserInfo> hello(Single<UserInfo> userInfo) {

    return userInfo;
}

@GetMapping("/")
public Observable<UserInfo> hello(Observable<UserInfo> userInfo) {

    return userInfo;
}

@GetMapping("/")
public Maybe<UserInfo> hello(Maybe<UserInfo> userInfo) {

    return userInfo;
}

@GetMapping("/")
public Flowable<UserInfo> hello(Flowable<UserInfo> userInfo) {

    return userInfo;
}


//jdk
@GetMapping("/")
public Flow.Publisher<UserInfo> hello(Flow.Publisher<UserInfo> userInfo) {

    return userInfo;
}

rxjava는 잘 몰라서 그냥 테스트만 해봤다.

오늘은 이렇게 Spring WebFlux의 HandlerMethodArgumentResolver 대해서 알아봤다. 만약 사용할 일이 있다면 언젠든지 사용해도 좋다. 어려운 내용이 아니므로 한번씩 해보는 것도 나쁘지 않아 보인다.

WebFlux도 공부할게 많다.

Testcontainers 로 integration 테스트하기

오늘 이야기 할 내용은 Testcontainers라이브러리로 integration 테스트를 해보도록 하자.

Testcontainers는 java 라이브러리로 (다른 언어도 존재는 함) 데이터베이스, 메시지 큐 또는 웹 서버와 같은 의존성이 있는 모듈에서 테스트 할 수 있게 도와주는 도구이다. 기본적으로는 docker 컨테이너 기반으로 동작하기에 docker가 설치는 되어 있어야 한다.

만약 docker가 설치 되어 있지 않다면 docker를 설치 해야 된다. 내부적으로는 도커의 이미지를 땡겨와 실행하기 때문이다.

Testcontainers 다양한 테스트 프레임워크를 지원한다. junit4 부터 junit5, Spock등 java 진영에서 주로 사용하는 테스트 프레임워크를 지원하니 만약 다른 프레임워크를 사용한다면 조금은 추가적인 작업이 필요로 할 수도 있다. 하지만 걱정할 필요는 없다. 테스트 프레임워크에 종속성이 없어도 충분히 사용은 가능하다.

사실은 지원한다는 것도 Testcontainers의 라이플사이클만 지원하는 정도이다. 도커의 실행과 종료 정도만 관여하기에 사용해도 되고 원하지 않는다면 사용하지 않아도 좋다.
만약 다른 테스트 프레임워크를 사용한다면 라이플사이클 정도만 추가 하면 된다.

그럼 한번 간단하게 사용해보자. 필자는 junit5를 사용했고 테스트로는 mongodb를 사용할 예정이다.

일단 적절하게 디펜더시를 받도록 하자.

// 기타 몽고 관련 및 테스트 관련 디펜더시

testImplementation("org.testcontainers:testcontainers:1.12.0")

위의 문법은 gradle kotlin dsl 이다.

기본 문법


import com.mongodb.MongoClient; import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; class JDefaultTestcontatinersTests { private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10"); @BeforeEach void setup() { mongoDbContainer.start(); } @Test void default_mongo_db_test() { int port = mongoDbContainer.getMappedPort(27017); MongoClient mongoClient = new MongoClient(mongoDbContainer.getContainerIpAddress(), port); MongoDatabase database = mongoClient.getDatabase("test"); MongoCollection<Document> collection = database.getCollection("users"); Document document = new Document("name", "wonwoo") .append("email", "test@test.com"); collection.insertOne(document); FindIterable<Document> documents = collection.find(); assertThat(documents).hasSize(1); documents.forEach((Consumer<? super Document>) it -> { assertThat(it.get("name")).isEqualTo("wonwoo"); assertThat(it.get("email")).isEqualTo("test@test.com"); }); } @BeforeEach void close() { mongoDbContainer.stop(); } }

GenericContainer(imageName) 의 생성자에는 docker image 명을 작성하면 된다. 필자는 몽고디비로 테스트하기 위해 mongo:4.0.10 라는 이미지를 사용했다.

실제로 외부 포트는 (몽고디비에 경우) 27017 로 열리지 않는다. 외부의 영향을 받지 않기 하기 위해 그런듯 싶다. 외부 포트를 가져오기 위해서는 getMappedPort(originalPort) 메서드를 사용해서 가져올 수 있다.

또한 GenericContainer 에는 docker 와 관련된 많은 메서드들이 있다.
예를들어 Environment, Command, Label, Network, dependsOn등 docker와 관련된 커멘드들을 사용할 수 있으니 필요하다면 사용해도 된다.


private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10") .withEnv("FOO", "BAR") .withCommand("command test") .withLabel("TEST","LABEL") .withNetwork(Network.newNetwork()) .dependsOn(new MongoDbContainer());

그 후에 해당 테스트를 진행 하면된다. 너무 간단하다. 사실 뭐 별거 없다.

junit5

junit5를 사용해서 작성도 해보자.


testImplementation("org.testcontainers:junit-jupiter:1.12.0")

testcontainers 에서 지원해주는 junit5를 디펜더시 받아야 한다. 이 또한 너무 간단하다.


/// 기타 import import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers class JJunit5TestContatinersTests { @Container private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10"); @Test void junit5_mongo_db_test() { int port = mongoDbContainer.getMappedPort(27017); MongoClient mongoClient = new MongoClient(mongoDbContainer.getContainerIpAddress(), port); MongoDatabase database = mongoClient.getDatabase("test"); //(대충 테스트한다는 내용) } }

@Testcontainers 어노테이션은 junit5의 Extension 클래스로 해당 @Container 어노테이션이 달린 컨테이너를 실행시키는 어노테이션이다.

@BeforeEach 같이 실행전에 start() 메서드를 실행 할 필요없이 사용하면 되는 그런 어노테이션이다.
junit4경우에는 @Rule 어노테이션을 사용하면 된다.


@Rule public GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10");

docker compose

docker compose 파일도 지원한다.

version: '2'
services:
  redis:
    image: redis:3.2
    ports:
      - 6379:6379
    volumes:
      - ~/data/redis:/data

  mongo:
    image: mongo:4.0.10

    ports:
      - 27017:27017

적절하게 docker compose 파일을 작성후에 테스트도 가능하다. volumes을 사용한다면 특정한 공간에 데이터가 저장되어 데이터가 계속 쌓이게 되므로 유의해야 한다.

@Testcontainers
class JDockerComposeTests {

    private final int REDIS_PORT = 6379;
    private final int MONGO_PORT = 27017;

    @Container
    private final DockerComposeContainer dockerCompose = new DockerComposeContainer(new File("src/test/resources/docker-compose.yaml"))
            .withExposedService("redis_1", REDIS_PORT)
            .withExposedService("mongo_1", MONGO_PORT);


    @Test
    void docker_compose_test() {

        int port = dockerCompose.getServicePort("mongo_1", MONGO_PORT);
        String host = dockerCompose.getServiceHost("mongo_1", MONGO_PORT);

        //(대충 테스트 한다는 내용)
    }

}

위와 같이 docker compose 파일을 작성후에 DockerComposeContainer 클래스를 이용해서 사용하면 된다. 사용법은 기존과 비슷하다.
참고로 꼭 파일명은 docker-compose 일 필요는 없다. 필자는 docker-compose는 docker-compose 라는 파일명이 익숙해서 그렇게 작성하였다.

spring boot data mongodb

기본적으로 사용법은 배워 봤다. 사실 그리 어렵지 않다. 적당한 디펜더시와 docker의 이미지만 설정한다면 쉽게 사용할 수 있다.

이번에는 우리가 자주 사용하는 spring boot를 사용해서 테스트를 진행 할 것이다. Spring boot 경우에는 일반적으로 자동설정에 있는 몽고 설정을 사용을 한다. 그래서 위와는 조금 다르게 설정을 해야 한다. 한번 살펴보자.

@DataMongoTest
@ContextConfiguration(initializers = MongoDbContainerInitializer.class)
class JSpringDataTestcontatinersTests {

    private final TodoRepository todoRepository;

    @Autowired
    private JSpringDataTestcontatinersTests(TodoRepository todoRepository) {

        this.todoRepository = todoRepository;
    }


    @Test
    void spring_data_mono_test() {

        todoRepository.save(new Todo(null, "wonwoo", "test@test.com"));

        List<Todo> todo = todoRepository.findAll();

        assertThat(todo).hasSize(1);

        todo.forEach(it -> {

            assertThat(it.getName()).isEqualTo("wonwoo");
            assertThat(it.getEmail()).isEqualTo("test@test.com");

        });

    }

}

필자는 @DataMongoTest 어노테이션을 이용해서 테스트를 작성했다. 물론 다른 nosql이나 rdb의 경우도 비슷하게 작성하면 된다.

MongoDbContainerInitializer 라는 클래스가 눈에 띈다. 이건 필자가 작성한 코드이다. 사실 별거 없고 위와 비슷하게 GenericContainer 실행하는 클래스이다.

public class MongoDbContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private GenericContainer mongoDbContainer = new GenericContainer("mongo:4.0.10");

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        mongoDbContainer.start();

        TestPropertyValues.of(

                "spring.data.mongodb.uri=mongodb://" + mongoDbContainer.getContainerIpAddress() + ":" + mongoDbContainer.getMappedPort(27017) + "/test"

        ).applyTo(applicationContext);
    }
}


spring boot 의 자동설정인 spring.data.mongodb.uri이라는 프로퍼티에 해당 url을 끼워 넣기 위한 작업을 하는 클래스이다. 위에서 말했다시피 실제 외부 포트는 27017이 아니기 때문이다.

조금 더 간단하게 사용하기 위해 메타 어노테이션을 이용해도 된다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@DataMongoTest
@ContextConfiguration(initializers = MongoDbContainerInitializer.class)
@DirtiesContext //optional
public @interface JDataMongoIntegrationTest {


}

위와 같이 작성 후에 테스트를 해보자.

@JDataMongoIntegrationTest
class JSpringDataCustomizedTestcontatinersTests {

    private final TodoRepository todoRepository;

    @Autowired
    private JSpringDataCustomizedTestcontatinersTests(TodoRepository todoRepository) {
        this.todoRepository = todoRepository;
    }

    @Test
    void spring_data_mono_customized_test() {

        // (대충 테스트 한다는 내용)

    }

}

위와 같이 작성해도 문제 없이 잘 테스트가 진행 될 것이다.

오늘은 이렇게 Testcontainers의 몇가지 기능을 살펴봤다.

Testcontainers 에는 많은 기능이 있지만 기본적인 테스트를 하기 위한 정도로 작성하였다. 추후에 embedded 관련해서도 작성해보려고 한다.

필자의 경우에는 Mongo image를 직접 작성했지만 Testcontainers 에서 지원해주는 모듈들이 많다. 예를들어 MySQLContainer, PostgreSQLContainer, MSSQLServerContainer, Db2Container, CouchbaseContainer, Neo4jContainer, ElasticsearchContainer, KafkaContainer, RabbitMQContainer, MockServerContainer 등등 더 많은 컨테이너들을 기본적으로 지원해주고 있다. 적절한 디펜더시만 받으면 된다. (근데 왜 몽고는 없지..?)

Testcontainers 의 또 다른 장점(?)은 spring boot의 지원을 잘해주고 있다. 뭐 많은 이유가 있겠지만 가장 큰 이유는 해당 프로젝트에 피보탈 개발자분이 한분 계신다. 또한 Spring boot 도 Testcontainers 종종 사용해서 테스트를 진행하고 있다.

사실 원래는 코틀린으로 먼저 작성을 해서 예제의 클래스들이 모두 J~~로 시작한다. 아직 대부분의 개발자분들이 java에 익숙하기 때문에 자바로 추가적으로 작성하였다. 만약 코틀린에도 관심이 있다며 여기(github)에 자바와 코틀린 소스 모두 있으니 참고하면 되겠다.

아직 필자도 많은 부분을 알고 있지는 않다. 필자도 이제 테스트할 때 도입할 예정이라 기본적으로 부분부터 살펴봤다. 사실 이정도만 알아도 큰 문제는 되지 않아 보인다. 쓰다보면 괜찮아지겠지..