외부 API 클라이언트 테스트하기
마이크로서비스 아키텍처 (MSA) 가 일반화된 요즘의 개발 환경에서는 어플리케이션 외부의 다양한 서비스와 여러 방식으로 통신하게 됩니다. 기능 구현을 위해서 한 어플리케이션 내에서 아무리 적어도 서너개 이상의 외부 서비스와 연동하게 되곤 하는데, 내 서비스의 API 를 노출할 때와 다른 서비스의 API 를 사용할 때 모두 HTTP 프로토콜을 사용하는 것이 가장 일반적이라 사용할 API 제공자가 정의한 스펙에 맞춰 해당 HTTP API 를 호출하는 코드를 작성하는 상황이 상당히 자주 일어납니다.
이와 관련해 언어나 프레임워크 별로 어떤 HTTP 클라이언트를 사용할지, 어떤 방식으로 예외를 처리할지, 다수의 API 를 호출해야 하는 상황에서 성능은 어떻게 개선할지 등 여러 중요한 엔지니어링적 고민사항들이 많이 생겨나곤 합니다. 이번 포스트에서는 그 중에서도 외부 API 클라이언트의 테스트에 대해 이야기해보고, 스프링 프레임워크 기반의 개발 환경에서의 테스트 사례도 공유해드리도록 하겠습니다.
외부 API 클라이언트 테스트가 필요한가요?
외부 API 클라이언트에 대한 테스트를 작성한다고 했을 때, 그 필요성 자체에 의문을 품는 분들이 종종 있습니다. 외부 API 는 우리 어플리케이션에서 제어 불가능한 외부 프로세스 자원이기 때문에 테스팅을 하는 것이 의미가 없다고요. 우리 어플리케이션의 정상 동작이 결국 실 환경에서 외부 API 의 상태와 기능 변경에 영향을 받기 때문에 개발이나 스테이징 환경에서의 통합 테스트 정도만 의미가 있다고 말하기도 합니다.
하지만 이 말은 절반은 맞고 절반은 틀렸습니다. 외부 API 는 분명히 우리 어플리케이션에서 제어 불가능한 외부 자원이기 때문에 우리 어플리케이션에서 아무리 잘 테스트 하더라도 항상 정상임을 보증할 수 없습니다. 그러나 테스트에서 확인하고자 하는 것은 언제까지나 우리가 작성한 코드이지 외부의 서비스가 아닙니다.
외부 HTTP API 연동 클라이언트를 작성해 본 경험이 있는 분들이라면, 꽤나 많이 직렬화 (Serialization) 와 역직렬화 (Deserialization) 관련된 오류를 경험해 본 적이 있으실 겁니다. DTO 클래스의 필드명을 잘못 입력해서 요청/응답의 JSON 매핑에서 예외가 발생할 수도 있고, 더 심한 경우 매퍼의 설정에 따라 누락된 DTO 필드가 기본값으로 초기화 되어서 버그를 눈치채지 못하는 경우도 있습니다.
따라서 우리는 외부 서비스 제공자가 정의한 API 요청 및 인증 스펙에 맞춰 우리가 ‘잘’ 요청하고 있는지, 그들이 제공하기로 한 응답을 우리가 ‘잘’ 가져오고 있는지 즉, 계약 (Contract) 를 잘 준수하고 있는지 테스트 해야합니다. 이는 외부 서비스의 정상 작동여부와 완전히 별개의 문제이지요.
테스트는 또 하나의 문서
테스트를 작성하는 것의 또 다른 장점은 그 자체가 문서로 기능할 수 있다는 것입니다. 많은 경우 처음 API 를 연동할 때 연동 클라이언트를 개발한 개발자 이외에는 해당 외부 서비스에 대한 정보가 없는 상황이 대부분일 것입니다. 나중에 해당 클라이언트에 대한 유지 보수가 필요한 상황이 발생했을 때 클라이언트 코드가 예외 처리등 풍부한 상황에 대응하는 코드를 개발해두지 않았다면, 새로 유지보수를 맡은 개발자는 직접 따로 API 를 호출해보거나 사내 포털을 뒤적이면서 API 제공 부서의 문서를 찾아다닌 후에야 코드에서 제대로 드러나지 않는 API 스펙을 유추해 낼 수 있을 것입니다.
// API 에서 예외가 발생한다면 예외는 어떤 형식이지..?
public ProductDto getProduct(Long id) {
return client.exchange("/product/{id}", HttpEntity.EMPTY, ProductDto.class, id).getBody();
}
그러나 만약 해당 클라이언트에 대한 테스트 코드가 작성되어 있다면, 해당 테스트 코드를 통해 어떤 API 를 호출하는지, 어떤 예외가 발생할 수 있는지, 어떤 응답을 받을 수 있는지 등을 쉽게 파악할 수 있을 것입니다. 최종적으로 API 제공 부서의 문서를 참고해야 할 지라도 기본적인 기능을 파악하는데 드는 시간을 훨씬 단축시킬 수 있을 것입니다.
MockServer 를 활용한 단위 테스트
MockServer 를 활용하면 외부 API 를 호출하는 클라이언트 코드를 테스트할 때, 외부 API 서버를 실제로 호출하지 않고도 테스트를 할 수 있습니다. Spring 에서 제공하는 @RestClientTest
가 RestTemplate 및 RestTemplateBuilder 가 사용된 코드만을 테스트 가능한 것에 비해 클라이언트 라이브러리 중립적으로 Mock 테스트가 가능하다는 장점이 있습니다.
Dependency
dependencies {
testImplementation 'org.mock-server:mockserver-netty:5.15.0'
}
Client Code
HTTP Client 는 Spring Boot 3.2 부터 사용 가능한 RestClient
로 구현하였습니다.
public record ProductDto (
Long id,
String name,
String description,
Integer price
) {}
@Component
public class RestProductClient {
private final RestClient client;
public RestProductClient(String host) {
this.client = RestClient.create(host);
}
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
Test Code
class RestProductClientTest {
private ClientAndServer mockServer;
private RestProductClient client;
@BeforeEach
void setUp() {
mockServer = ClientAndServer.startClientAndServer(9876);
}
@AfterEach
void tearDown() {
mockServer.stop();
}
@Test
void getProductById() {
// Given
var client = new RestProductClient("http://localhost:9876"); // (1)
var productId = 1L;
var responseBody = """
{
"id": 1,
"name": "iPhone 12",
"description": "Apple iPhone 12 64GB",
"price": 1000000
}
""";
// When
mockServer
.when(
request() // (2)
.withMethod("GET")
.withPath("/products/1")
).respond(
response() // (3)
.withStatusCode(200)
.withBody(json(responseBody))
);
var result = client.getProduct(1L); // (4)
var expected = new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
);
// Then
assertEquals(expected, result); // (5)
}
}
- (1) : mockserver 와 동일한 포트로 접속하도록 클라이언트 인스턴스를 생성합니다.
- (2) : 요청을 받길 기대하는 요청 메서드와 경로를 지정합니다.
- (3) : 요청에 대한 응답을 지정합니다.
- (4) : 클라이언트 코드를 실행하고, 응답을 바인딩합니다.
- (5) : 응답이 기대하는 결과와 일치하는지 검증합니다.
API 클라이언트가 협력 객체인 경우
만약 API 클라이언트 구현 자체에 대한 테스트가 아니라 위에서 구현한 RestProductClient
와 같은 클라이언트가 다른 비즈니스 객체의 협력 객체로 사용되는 테스트라면 어떻게 해야 할까요? 마찬가지로 MockServer 를 이용해서 HTTP 통신은 Mocking 할 수 있지만, 테스트의 핵심 관심사는 HTTP 통신이 아니라 외부 서비스로부터 받은 응답을 바탕으로 기대하는 추가적인 비즈니스 로직이 제대로 수행되는지이기 때문에 MockServer 를 사용하는건 다소 불필요하고 번거로운 일일 수 있습니다. 우리가 Repository 와 협력관계에 있는 클래스를 테스트할 때 DB 를 Mocking 하지 않는 것을 상상해보면 이해하기 쉽습니다.
@Service
public class ProductService {
private final RestProductClient client;
public ProductService(RestProductClient client) {
this.client = client;
}
public Integer getProductPrice(Long id) {
return client.getProduct(id).price();
}
}
Mock 을 활용한 단위 테스트
ProductService 에 대한 단위 테스트를 수행할 경우, 협력 객체인 RestResourceClient 를 Mocking 해서 테스트를 수행할 수 있습니다.
class ProductServiceTest {
private ProductService productService;
private RestProductClient productClient;
@Test
void getProductPrice() {
// Given
productClient = mock(RestProductClient.class);
given(productClient.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
productService = new ProductService(productClient);
// When
var result = productService.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
MockBean 을 활용한 통합 테스트
스프링 어플리케이션에 대한 통합 테스트를 수행할 경우, @MockBean
을 활용해서 협력 객체를 Mocking 할 수 있습니다.
@SpringBootTest
class ProductServiceIntegrationTest {
@Autowired
private ProductService service;
@MockBean
private RestProductClient client;
@Test
void getProductPrice() {
// Given
given(client.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
// When
var result = service.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
인터페이스를 추가하고 직접 테스트 더블 구현하기
위의 두 방법은 구체 클래스인 RestProductClient
를 Mocking 하는 방법이었습니다. 하지만 매 테스트 코드마다 외부 의존성을 가지는 클래스를 mocking 하는 것은 쉽지 않은 일이고, @MockBean
을 사용할 경우 매 테스트마다 Application Context 가 재생성되기 때문에 테스트 수행 시간이 길어질 수 있습니다. 이런 경우에는 인터페이스를 추가하고 이를 구현하는 테스트 더블 구현체를 만들어서 테스트를 수행하는 것도 하나의 방법이 될 수 있습니다.
public interface ProductClient {
ProductDto getProduct(Long id);
}
@Profile({"dev", "production"})
@Component
public class RestProductClient implements ProductClient {
private final RestClient client;
public RestProductClient(@Value("${product.host}") String host) {
this.client = RestClient.create(host);
}
@Override
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
// 테스트 환경에서만 사용할 Stub 구현체
@Profile("test")
@Component
public class StubProductClient implements ProductClient {
@Override
public ProductDto getProduct(Long id) {
return new ProductDto(id, "Product " + id, "Description " + id, 100 * id.intValue());
}
}