Armeria의 WebClient를 더 쉽게 테스트하기

이 글은 Add WebTestClient #4339 이슈를 해결하는 과정을 정리한 글입니다. PR까지는 보냈지만 아쉽게도 머지되지는 않았습니다. 그래도 그 과정에서 Armeria의 WebClient가 어떻게 동작하는지 배울 수 있었기 때문에 글로 정리하였습니다.

WebClient 소개

Armeria의 WebClientApache HttpClient와 유사하게 HTTP 요청을 보낼 수 있습니다. HTTP/2를 지원하고 비동기 요청 응답을 지원한다는 장점을 갖고 있습니다. 따라서 Armeria의 테스트 코드에서 WebClient를 이용하여 테스트할 서버에 요청을 보내고 응답이 기대한 값과 같은지 확인하는 방식으로 자주 사용합니다.

위 코드를 보면 WebClient client로 get 요청을 보내고 그 결과인 AggregatedHttpResponse res의 상태 코드가 기대한 값과 같은지 확인합니다. 이 외에도 소스 코드를 조사해보면 비슷한 꼴의 테스트 코드가 많다는 것을 쉽게 알 수 있습니다.

이슈 개요

따라서 Add WebTestClient #4339 이슈는 테스트 코드에서 나타나는 보일러 플레이트 코드를 줄일 수 있는 fluent syntax를 제안합니다. 아래는 이슈에서 제안한 테스트 코드 예시입니다.

바뀐 코드를 보면 assertStatus(), assertHeaders(), assertBody() 등의 메소드를 사용한 후에 내가 isEqualTo, contain 메소드를 체이닝하여 기대한 값인지 확인하고 있습니다. 확실히 기존 코드에 비해 코드 작성이 매끄럽습니다.

WebClient를 따라서 WebTestClient 만들어보기

당시 Armeria에 대한 지식이 거의 없었기 때문에 대단한 계획은 없었고 WebClient를 따라서 비슷하게 WebTestClient를 만들면 되지 않겠냐는 생각 뿐이었습니다. 그래서 WebClient를 어떻게 생성하고 사용하는지 그 과정을 하나씩 탐구했습니다.

우선 관련된 클래스나 인터페이스를 나열하면 WebClient, WebClientBuilder, WebClientRequestPreparation, DefaultWebClient 4가지 정도로 추릴 수 있었습니다. 먼저 WebClient는 각종 HTTP 요청을 보낼 수 있는 메소드가 정의된 인터페이스입니다. WebClientBuilderWebClient 인스턴스를 생성할 수 있는 빌더 클래스입니다. WebClientRequestPreparationWebClient로 요청을 보낼 때 다양한 파라미터를 fluent syntax로 주입할 수 있는 객체입니다. 마지막으로 DefaultWebClient는 Armeria 내부에 WebClient 구현체 입니다. 이 외에도 “WebClient” 단어가 들어간 클래스나 인터페이스가 있었지만 새롭게 만들 WebTestClient에 맞춰서 만들어야 할 것들은 이정도로 보였습니다.

먼저 가장 중요한 WebTestClient를 어떻게 만들지 고민했습니다. WebTestClientWebClient의 메소드를 모두 똑같이 사용할 것으로 보였습니다. 따라서 WebClient 인터페이스를 상속해서 사용할지 먼저 고민했습니다. WebClient의 가장 중요한 메소드는 HttpResponse execute(HttpRequest req, RequestOptions options) 입니다. (사실 해당 메소드 외 추상 메소드는 별로 중요하지 않아서 거의 Functional Interface로 봐도 무방할 정도입니다.) 그런데 WebTestClientexecute는 아마 HttpResponse를 확장한 어떤 타입을 리턴해야 할 것입니다. assertStatus 등의 테스트용 메소드를 사용해야 하기 때문입니다. 저는 그 타입에 TestHttpResponse라 이름을 붙였습니다. 여기서도 비슷하게 TestHttpResponseHttpResponse를 상속할지 고민되었습니다.

그렇게 고민하면서 계속 엮인 소스 코드를 읽으면서 탐색하던 중 의외의 곳에서 힌트를 발견했습니다. 바로 BlockingWebClient입니다. 해당 인터페이스는 #4021 PR에서 추가되었습니다. 글 첫 부분에서 언급했듯이 WebClient는 비동기로 요청과 응답을 받습니다. 즉 execute 메소드로 요청을 보낸 경우 리턴하는 HttpResponseFuture와 비슷하게 응답 데이터를 실제로 담고 있지 않습니다. 만약 스트리밍 응답을 동기적으로 기다려서 전체 데이터를 받고 싶은 경우 HttpResponse#aggregate 메소드로 CompletableFuture<AggregatedHttpResponse>로 변환한 후 join 메소드를 사용해 AggregatedHttpResponse로 변환합니다. BlockingWebClient가 만들어진 배경은 테스트 코드에서는 전체 결과를 동기적으로 기다리는 경우가 잦아 HttpResponseAggregatedHttpResponse로 변환하는 보일러 플레이트 코드를 줄이기 위함입니다.

저는 BlockingWebClientWebTestClient와 매우 공통점이 많다는 사실을 쉽게 알 수 있었습니다. 만들어진 배경도 그렇거니와 WebClient의 API를 확장해서 새로운 기능을 추가했다는 점이 그랬습니다. 따라서 BlockingWebClient, BlockingWebClientBuilder, BlockingWebClientRequestPreparation, DefaultBlockingWebClient를 새로운 이정표로 삼았습니다.

BlockingWebClient를 따라서 WebTestClient 만들기

그럼 BlockingWebClient는 어떻게 WebClient의 API를 확장했을까요? 이를 이해하기 위해서 WebClient vs BlockingWebClient, DefaultWebClient vs DefaultBlockingWebClient 관계를 살펴보겠습니다. (BlockingWebClientBuilder, BlockingWebClientRequestPreparation 구현은 WebClient*와 큰 차이가 없어서 이 글에서는 굳이 다루지 않았습니다.)

먼저 BlockingWebClient를 보면 WebClient를 상속하지 않고 독립적으로 구성하는 것을 볼 수 있습니다. 즉, 분명 둘 사이에 중요한 관계성이 있지만 이를 인터페이스 레벨에서 드러내지 않습니다. BlockingWebClientWebClient의 거의 모든 메소드를 똑같이 갖습니다. 대신 차이점은 요청을 보내는 메소드에서 HttpResponse를 리턴하는 대신 AggregatedHttpResponse를 리턴합니다.

다음으로 실제 구현체인 DefaultBlockingWebClient를 보겠습니다. 여기서 진실이 드러나는데요. 내부에 WebClient delegate 필드를 갖고 있는 것을 확인할 수 있습니다. 그리고 execute 메소드 구현을 보면 WebClient delegateexecute를 호출하고 그 결과를 동기적으로 기다려서 AggregatedHttpResponse로 변환하여 리턴하는 것을 볼 수 있습니다. 정리하면 상속이 아닌 합성을 통해 내부 필드로 가진 WebClient 인스턴스에게 실제 요청을 위임하고 DefaultBlockingWebClient는 동기적으로 결과를 기다리기만 합니다.

이 방식을 취할 때 드러나는 장점은 사용자에게 보여지는 퍼블릭 인터페이스에서는 WebClientBlockingWebClient의 의존성이 없기 때문에 유연하게 유지보수할 수 있습니다. 또한 사용자가 볼 수 없는 구현 레벨에서 합성을 통해 DefaultWebClient에게 작업을 위임하여 코드를 재사용할 수 있었습니다.

여기서 얻은 아이디어를 바탕으로 WebTestClientBlockingWebClient를 벤치마킹했습니다. 요청 메소드들은 AggregatedHttpResponse대신 TestHttpResponse를 리턴하고 DefaultWebTestClientDefaultBlockingWebClient를 합성하여 사용했습니다.

TestHttpResponse에 테스트 기능 추가하기

여기까지 WebTestClient를 완성했습니다. 실제로 여기까지 조사하고 만드는데 거의 일주일이 걸렸습니다. 하지만 지금까지 준비단계였고 이제 본격적으로 편리한 테스트를 위한 assertStatus(), assertBody()등의 메소드를 가진 TestHttpResponse를 구현할 차례입니다.

우리가 무엇을 만들어야 하는지 이슈에서 제안한 코드를 다시 쪼개서 분석해보겠습니다.

위에서 봤던 테스트 코드를 더 자세히 분석하기 위해 한 줄에 하나의 메소드만 썼습니다. 저는 먼저 각 메소드의 리턴 타입이 무엇일지 상상해보았습니다. 일단 WebTestClient를 만들었으니 execute 메소드는 TestHttpResponse를 리턴합니다. 그 다음 assertStatus()A라는 데이터 타입을 리턴한다고 해봅시다. 이어지는 isEqualTo 메소드는 다시 TestHttpResponse를 리턴해야 합니다. 그래야 assertHeaders()를 연결해서 호출할 수 있기 때문입니다. 정리하면 TestHttpResponseATestHttpResponseBTestHttpResponseC → … 를 반복하게됩니다.

따라서 A, B, C 자리에 해당하는 데이터 타입을 만들어야 하고 거기에는 TestHttpResponse로 돌아올 모종의 방법도 필요합니다.

AssertThat<T, U>와 HttpStatusAssert, HttpHeadersAssert, HttpDataAssert

잠깐 정리하면, TestHttpResponseassertStatus() 메소드가 AassertHeaders, assertTrailersBassertBodyC를 리턴하는 그림까지 그렸습니다. 저는 A, B, C를 각각 HttpStatusAssert, HttpHeadersAssert, HttpDataAssert라 이름 붙였습니다. 이 때 세 클래스 모두 assertion을 진행한 후에 TestHttpResponse로 다시 돌아올 방법이 필요했습니다. 그래서 저는 세 클래스가 상속할 추상 클래스 AssertThat<T, U>를 제안했습니다.

사실 코드를 보면 우리가 쉽게 볼 수 있는 Pair 또는 Tuple이라는 것을 알 수 있습니다. 대신 first, second가 아닌 특별한 의미를 부여한 필드를 사용했습니다. actualback 필드는 무엇을 의미할까요? 먼저 actual은 테스트에서 프로그램 실행 후 실제 결과 값을 말합니다. 이 값을 기대되는 값과 비교하여 프로그램이 올바른지 확인합니다. 다음으로 back은 테스트를 끝난 후 TestHttpResponse로 돌아갈 레퍼런스를 저장할 필드입니다. 추가로 필드에 접근하기 위한 접근자 메소드도 만들었습니다.

그럼 AssertThat과 함께 어떻게 구현했는지 HttpStatusAssert를 살펴보겠습니다.

먼저 AssertThat<HttpStatus, TestHttpResponse>을 상속했기 때문에 생성자는 필수적으로 HttpStatus actualTestHttpResponse back을 인자로 받습니다. 그리고 isEqualTo 메소드를 보면 흔히 볼 수 있는 JUnit의 assertEquals 메소드를 이용해 인자로 받은 HttpStatus expectedactual()을 비교합니다. 테스트가 끝난 뒤 back()을 리턴하여 다시 TestHttpResponse를 사용할 수 있습니다.

마지막으로 TestHttpResponse 구현체인 DefaultTestHttpResponseassertSomething() 메소드들을 첨부합니다.

여기까지 TestHttpResponse의 테스트 기능까지 구현 완료했습니다. 제 PR을 참고하시면 이외에도 몇 가지 소소한 코드가 더 있지만 가장 메인이 되는 줄기인 WebTestClient - TestHttpResponse 흐름은 모두 설명하였습니다.

고칠까 말까 고민되는 부분

따로 추가 리뷰가 없어 손대지 않았지만 고민되는 부분이 있습니다. 먼저 AssertThat 추상 클래스가 필요한지 의문이 듭니다. HttpStatusAssert, HttpHeadersAssert, HttpDataAssert 의 공통된 요구 사항에서 착안하여 이를 위한 추상 클래스를 상속하게 했습니다. 그러나 AssertThat은 설명을 듣지 않고 코드를 봤을 때 그 용도를 짐작하기 없습니다. 그래서 대안으로 AssertThat을 사용하지 않고 평범하게 생성자에 actual 값과 다시 돌아올 TestHttpResponse 레퍼런스를 주입하는 방법도 있습니다. 이 경우 공통된 요구 사항을 세 클래스가 따로 처리하지만 크게 복잡한 로직이 아니기 때문에 별로 코드 양이 늘어나거나 하지 않습니다. 굳이 아쉬운 점을 꼽으면 나중에 HttpSomethingAssert가 추가되었을 때 추상 클래스가 있으면 생성자에서 actualTestHttpResponse 레퍼런스를 주입하도록 강요할 수 있다는 점이 있습니다.

두 번째로 AssertThat이 본질적으로 Pair 혹은 Tuple과 다르지 않기 때문에 그 이름이 적절한가 고민이 됩니다. AssertThat이라는 이름을 사용함으로써 해당 추상 클래스는 테스트를 위해 쓰여야 한다는 것을 강하게 암시합니다. 이는 오히려 본질적으로 Pair와 다르지 않은 클래스의 사용 범위를 제한하는 것처럼 보입니다.

마지막으로 AssertThatback 필드의 이름이 아쉽습니다. back이라는 이름을 들은 사람은 그 용도를 짐작하기 매우 어렵습니다. 클래스 이름을 보면 필드가 actualexpected가 있어야 할 것 같은데 막상 back이 있고 이는 테스트와는 전혀 관련이 없습니다. 테스트를 마치고 돌아갈 레퍼런스 저장소일 뿐입니다. 그래서 AsserThat의 이름을 바꾸거나 back의 이름을 바꾸고 싶습니다. (backdoor는 이상한가요? ㅎㅎ)

느낀 점

총 두 단계로 먼저 WebClient를 따라해서 WebTestClient를 만들었고 그 다음 편리한 테스트 메소드를 지원하는 TestHttpResponse를 만들었습니다. 정리하니 양이 많진 않지만 매일 1~2시간씩 들여서 거의 2주 정도 작업했던 것으로 기억합니다. 지금도 없지만 당시 Armeria 지식이 더 적었기 때문에 소스 코드를 분석하면서 정말 많은 공부가 되었습니다. 특히 WebClient의 구조를 꽤나 자세히 알 수 있었던게 가장 큰 수확이라고 생각합니다. 제 기억이 맞다면 해당 메소드가 처음으로 코드를 수정하는 Armeria PR이었습니다. 이전에는 코드 수정이 두렵기도 하고 뭘 고쳐야 할지도 몰라서 못했는데 이 PR도 처음에는 엄청 막막했지만 시간을 들여 결국 끝까지 갈 수 있었습니다. 그래서 지식과 함께 자신감도 챙겼던 경험이 되었네요.

사실 이 글은 PR이 머지되지 않아 그냥 노션에만 기록하고 넘기려 했는데 글 쓸 거리가 없어서 급하게 꺼내 썼습니다. 막상 글로 정리하고 나니 공부했던 것들을 복습하고 제 코드의 의도도 깔끔하게 정리하고 아쉬웠던 점도 회고하니 1석 3조였습니다. 이 글을 읽으시는 분들도 기회가 된다면 자신이 도전했다가 기억 속에만 남겨진 도전들을 공유해주시면 커뮤니티와 저에게 많은 도움이 될 것 같습니다. 😆

comments powered by Disqus

Related