Armeria의 WebClient를 더 쉽게 테스트하기
이 글은 Add WebTestClient
#4339 이슈를 해결하는 과정을 정리한 글입니다. PR까지는 보냈지만 아쉽게도 머지되지는 않았습니다. 그래도 그 과정에서 Armeria의 WebClient
가 어떻게 동작하는지 배울 수 있었기 때문에 글로 정리하였습니다.
WebClient 소개
Armeria의 WebClient
는 Apache 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 요청을 보낼 수 있는 메소드가 정의된 인터페이스입니다. WebClientBuilder
는 WebClient
인스턴스를 생성할 수 있는 빌더 클래스입니다. WebClientRequestPreparation
은 WebClient
로 요청을 보낼 때 다양한 파라미터를 fluent syntax로 주입할 수 있는 객체입니다. 마지막으로 DefaultWebClient
는 Armeria 내부에 WebClient
구현체 입니다. 이 외에도 “WebClient” 단어가 들어간 클래스나 인터페이스가 있었지만 새롭게 만들 WebTestClient
에 맞춰서 만들어야 할 것들은 이정도로 보였습니다.
먼저 가장 중요한 WebTestClient
를 어떻게 만들지 고민했습니다. WebTestClient
는 WebClient
의 메소드를 모두 똑같이 사용할 것으로 보였습니다. 따라서 WebClient
인터페이스를 상속해서 사용할지 먼저 고민했습니다. WebClient
의 가장 중요한 메소드는 HttpResponse execute(HttpRequest req, RequestOptions options)
입니다. (사실 해당 메소드 외 추상 메소드는 별로 중요하지 않아서 거의 Functional Interface로 봐도 무방할 정도입니다.) 그런데 WebTestClient
의 execute
는 아마 HttpResponse
를 확장한 어떤 타입을 리턴해야 할 것입니다. assertStatus
등의 테스트용 메소드를 사용해야 하기 때문입니다. 저는 그 타입에 TestHttpResponse
라 이름을 붙였습니다. 여기서도 비슷하게 TestHttpResponse
가 HttpResponse
를 상속할지 고민되었습니다.
그렇게 고민하면서 계속 엮인 소스 코드를 읽으면서 탐색하던 중 의외의 곳에서 힌트를 발견했습니다. 바로 BlockingWebClient
입니다. 해당 인터페이스는 #4021 PR에서 추가되었습니다. 글 첫 부분에서 언급했듯이 WebClient
는 비동기로 요청과 응답을 받습니다. 즉 execute
메소드로 요청을 보낸 경우 리턴하는 HttpResponse
는 Future
와 비슷하게 응답 데이터를 실제로 담고 있지 않습니다. 만약 스트리밍 응답을 동기적으로 기다려서 전체 데이터를 받고 싶은 경우 HttpResponse#aggregate
메소드로 CompletableFuture<AggregatedHttpResponse>
로 변환한 후 join
메소드를 사용해 AggregatedHttpResponse
로 변환합니다. BlockingWebClient
가 만들어진 배경은 테스트 코드에서는 전체 결과를 동기적으로 기다리는 경우가 잦아 HttpResponse
→ AggregatedHttpResponse
로 변환하는 보일러 플레이트 코드를 줄이기 위함입니다.
저는 BlockingWebClient
가 WebTestClient
와 매우 공통점이 많다는 사실을 쉽게 알 수 있었습니다. 만들어진 배경도 그렇거니와 WebClient
의 API를 확장해서 새로운 기능을 추가했다는 점이 그랬습니다. 따라서 BlockingWebClient
, BlockingWebClientBuilder
, BlockingWebClientRequestPreparation
, DefaultBlockingWebClient
를 새로운 이정표로 삼았습니다.
BlockingWebClient를 따라서 WebTestClient 만들기
그럼 BlockingWebClient
는 어떻게 WebClient
의 API를 확장했을까요? 이를 이해하기 위해서 WebClient
vs BlockingWebClient
, DefaultWebClient
vs DefaultBlockingWebClient
관계를 살펴보겠습니다. (BlockingWebClientBuilder
, BlockingWebClientRequestPreparation
구현은 WebClient*
와 큰 차이가 없어서 이 글에서는 굳이 다루지 않았습니다.)
먼저 BlockingWebClient
를 보면 WebClient
를 상속하지 않고 독립적으로 구성하는 것을 볼 수 있습니다. 즉, 분명 둘 사이에 중요한 관계성이 있지만 이를 인터페이스 레벨에서 드러내지 않습니다. BlockingWebClient
는 WebClient
의 거의 모든 메소드를 똑같이 갖습니다. 대신 차이점은 요청을 보내는 메소드에서 HttpResponse
를 리턴하는 대신 AggregatedHttpResponse
를 리턴합니다.
다음으로 실제 구현체인 DefaultBlockingWebClient
를 보겠습니다. 여기서 진실이 드러나는데요. 내부에 WebClient delegate
필드를 갖고 있는 것을 확인할 수 있습니다. 그리고 execute
메소드 구현을 보면 WebClient delegate
의 execute
를 호출하고 그 결과를 동기적으로 기다려서 AggregatedHttpResponse
로 변환하여 리턴하는 것을 볼 수 있습니다. 정리하면 상속이 아닌 합성을 통해 내부 필드로 가진 WebClient
인스턴스에게 실제 요청을 위임하고 DefaultBlockingWebClient
는 동기적으로 결과를 기다리기만 합니다.
이 방식을 취할 때 드러나는 장점은 사용자에게 보여지는 퍼블릭 인터페이스에서는 WebClient
와 BlockingWebClient
의 의존성이 없기 때문에 유연하게 유지보수할 수 있습니다. 또한 사용자가 볼 수 없는 구현 레벨에서 합성을 통해 DefaultWebClient
에게 작업을 위임하여 코드를 재사용할 수 있었습니다.
여기서 얻은 아이디어를 바탕으로 WebTestClient
는 BlockingWebClient
를 벤치마킹했습니다. 요청 메소드들은 AggregatedHttpResponse
대신 TestHttpResponse
를 리턴하고 DefaultWebTestClient
는 DefaultBlockingWebClient
를 합성하여 사용했습니다.
TestHttpResponse에 테스트 기능 추가하기
여기까지 WebTestClient
를 완성했습니다. 실제로 여기까지 조사하고 만드는데 거의 일주일이 걸렸습니다. 하지만 지금까지 준비단계였고 이제 본격적으로 편리한 테스트를 위한 assertStatus()
, assertBody()
등의 메소드를 가진 TestHttpResponse
를 구현할 차례입니다.
우리가 무엇을 만들어야 하는지 이슈에서 제안한 코드를 다시 쪼개서 분석해보겠습니다.
위에서 봤던 테스트 코드를 더 자세히 분석하기 위해 한 줄에 하나의 메소드만 썼습니다. 저는 먼저 각 메소드의 리턴 타입이 무엇일지 상상해보았습니다. 일단 WebTestClient
를 만들었으니 execute
메소드는 TestHttpResponse
를 리턴합니다. 그 다음 assertStatus()
는 A
라는 데이터 타입을 리턴한다고 해봅시다. 이어지는 isEqualTo
메소드는 다시 TestHttpResponse
를 리턴해야 합니다. 그래야 assertHeaders()
를 연결해서 호출할 수 있기 때문입니다. 정리하면 TestHttpResponse
→ A
→ TestHttpResponse
→ B
→ TestHttpResponse
→ C
→ … 를 반복하게됩니다.
따라서 A
, B
, C
자리에 해당하는 데이터 타입을 만들어야 하고 거기에는 TestHttpResponse
로 돌아올 모종의 방법도 필요합니다.
AssertThat<T, U>와 HttpStatusAssert, HttpHeadersAssert, HttpDataAssert
잠깐 정리하면, TestHttpResponse
의 assertStatus()
메소드가 A
를 assertHeaders
, assertTrailers
가 B
를 assertBody
가 C
를 리턴하는 그림까지 그렸습니다. 저는 A
, B
, C
를 각각 HttpStatusAssert
, HttpHeadersAssert
, HttpDataAssert
라 이름 붙였습니다. 이 때 세 클래스 모두 assertion을 진행한 후에 TestHttpResponse
로 다시 돌아올 방법이 필요했습니다. 그래서 저는 세 클래스가 상속할 추상 클래스 AssertThat<T, U>
를 제안했습니다.
사실 코드를 보면 우리가 쉽게 볼 수 있는 Pair
또는 Tuple
이라는 것을 알 수 있습니다. 대신 first, second가 아닌 특별한 의미를 부여한 필드를 사용했습니다. actual
과 back
필드는 무엇을 의미할까요? 먼저 actual
은 테스트에서 프로그램 실행 후 실제 결과 값을 말합니다. 이 값을 기대되는 값과 비교하여 프로그램이 올바른지 확인합니다. 다음으로 back
은 테스트를 끝난 후 TestHttpResponse
로 돌아갈 레퍼런스를 저장할 필드입니다. 추가로 필드에 접근하기 위한 접근자 메소드도 만들었습니다.
그럼 AssertThat
과 함께 어떻게 구현했는지 HttpStatusAssert
를 살펴보겠습니다.
먼저 AssertThat<HttpStatus, TestHttpResponse>
을 상속했기 때문에 생성자는 필수적으로 HttpStatus actual
과 TestHttpResponse back
을 인자로 받습니다. 그리고 isEqualTo
메소드를 보면 흔히 볼 수 있는 JUnit의 assertEquals
메소드를 이용해 인자로 받은 HttpStatus expected
와 actual()
을 비교합니다. 테스트가 끝난 뒤 back()
을 리턴하여 다시 TestHttpResponse
를 사용할 수 있습니다.
마지막으로 TestHttpResponse
구현체인 DefaultTestHttpResponse
의 assertSomething()
메소드들을 첨부합니다.
여기까지 TestHttpResponse
의 테스트 기능까지 구현 완료했습니다. 제 PR을 참고하시면 이외에도 몇 가지 소소한 코드가 더 있지만 가장 메인이 되는 줄기인 WebTestClient
- TestHttpResponse
흐름은 모두 설명하였습니다.
고칠까 말까 고민되는 부분
따로 추가 리뷰가 없어 손대지 않았지만 고민되는 부분이 있습니다. 먼저 AssertThat
추상 클래스가 필요한지 의문이 듭니다. HttpStatusAssert
, HttpHeadersAssert
, HttpDataAssert
의 공통된 요구 사항에서 착안하여 이를 위한 추상 클래스를 상속하게 했습니다. 그러나 AssertThat
은 설명을 듣지 않고 코드를 봤을 때 그 용도를 짐작하기 없습니다. 그래서 대안으로 AssertThat
을 사용하지 않고 평범하게 생성자에 actual
값과 다시 돌아올 TestHttpResponse
레퍼런스를 주입하는 방법도 있습니다. 이 경우 공통된 요구 사항을 세 클래스가 따로 처리하지만 크게 복잡한 로직이 아니기 때문에 별로 코드 양이 늘어나거나 하지 않습니다. 굳이 아쉬운 점을 꼽으면 나중에 HttpSomethingAssert
가 추가되었을 때 추상 클래스가 있으면 생성자에서 actual
과 TestHttpResponse
레퍼런스를 주입하도록 강요할 수 있다는 점이 있습니다.
두 번째로 AssertThat
이 본질적으로 Pair
혹은 Tuple
과 다르지 않기 때문에 그 이름이 적절한가 고민이 됩니다. AssertThat
이라는 이름을 사용함으로써 해당 추상 클래스는 테스트를 위해 쓰여야 한다는 것을 강하게 암시합니다. 이는 오히려 본질적으로 Pair
와 다르지 않은 클래스의 사용 범위를 제한하는 것처럼 보입니다.
마지막으로 AssertThat
에 back
필드의 이름이 아쉽습니다. back
이라는 이름을 들은 사람은 그 용도를 짐작하기 매우 어렵습니다. 클래스 이름을 보면 필드가 actual
과 expected
가 있어야 할 것 같은데 막상 back
이 있고 이는 테스트와는 전혀 관련이 없습니다. 테스트를 마치고 돌아갈 레퍼런스 저장소일 뿐입니다. 그래서 AsserThat
의 이름을 바꾸거나 back
의 이름을 바꾸고 싶습니다. (backdoor는 이상한가요? ㅎㅎ)
느낀 점
총 두 단계로 먼저 WebClient
를 따라해서 WebTestClient
를 만들었고 그 다음 편리한 테스트 메소드를 지원하는 TestHttpResponse
를 만들었습니다. 정리하니 양이 많진 않지만 매일 1~2시간씩 들여서 거의 2주 정도 작업했던 것으로 기억합니다. 지금도 없지만 당시 Armeria 지식이 더 적었기 때문에 소스 코드를 분석하면서 정말 많은 공부가 되었습니다. 특히 WebClient
의 구조를 꽤나 자세히 알 수 있었던게 가장 큰 수확이라고 생각합니다. 제 기억이 맞다면 해당 메소드가 처음으로 코드를 수정하는 Armeria PR이었습니다. 이전에는 코드 수정이 두렵기도 하고 뭘 고쳐야 할지도 몰라서 못했는데 이 PR도 처음에는 엄청 막막했지만 시간을 들여 결국 끝까지 갈 수 있었습니다. 그래서 지식과 함께 자신감도 챙겼던 경험이 되었네요.
사실 이 글은 PR이 머지되지 않아 그냥 노션에만 기록하고 넘기려 했는데 글 쓸 거리가 없어서 급하게 꺼내 썼습니다. 막상 글로 정리하고 나니 공부했던 것들을 복습하고 제 코드의 의도도 깔끔하게 정리하고 아쉬웠던 점도 회고하니 1석 3조였습니다. 이 글을 읽으시는 분들도 기회가 된다면 자신이 도전했다가 기억 속에만 남겨진 도전들을 공유해주시면 커뮤니티와 저에게 많은 도움이 될 것 같습니다. 😆