애플리케이션에 토스 결제 기능을 추가했다. (참고: 토스페이 결제 연동하기)
현재 작성한 애플리케이션은 토스 결제 결과에 따라 로직이 다르게 수행된다.
ex. 결제에 성공 -> 예약 성공 / 결제 실패 -> 예약 실패, 사용자 정의 예외 발생
코드를 작성했으니 (TDD 방식을 사용한다면 코드를 작성하기 전) 테스트 코드를 작성해보려고 했다. 그런데 이 과정에서 많은 에러가 발생했고, 코드도 여러번 갈아 엎었다. 😭 관련 자료들을 찾아봤을 때 MockRestServiceServer와 RestTemplate를 사용한 테스트는 많이 있었는데, RestClient를 사용한 테스트 정보는 찾기가 어려웠다.그나마 찾아봤던 내용이 깃헙에 있는 내용인데, 이 방식은 여러개의 RestClient를 생성하지 못한다는 문제가 있었다. 결제 연동된 회사가 많다면 각 회사별로 RestClient가 필요한데, RestClientCustomizer를 Bean으로 등록해두는 방식은 하나의 RestClient 밖에 등록하지 못한다.
이번에 RestClient와 MockRestServiceServer를 사용해 단위테스트 했던 방식을 기록해보고자 한다.
(코드 내용이 많아 포스팅이 길어졌습니다. 양해 부탁드립니다. 😅)
어디까지 테스트 할 것인가
코드 내부에 외부 API 호출이 있다. 테스트 할 때 마다 외부 API 를 호출하는 것도 낭비이고, 실제로 내가 원하는 결과를 얻어내는 것도 쉽지 않다. 또한 내가 테스트하고싶은것은 외부 API가 원하는 결과를 주는지 확인하는 것이 아니라, 외부의 응답에 따라 내 애플리케이션이 잘 동작하는지를 확인하고 싶은것이다. 그렇다면 테스트에서 외부 API가 해야하는 일은 "내가 원하는 결과를 응답"하는것이다.
단편적으로 생각해봤을 때, 외부 서버를 Mocking 해두고 테스트에서 해당 서버로 요청을 보내면 미리 설정해둔 결과를 응답하면 될 것 같았다. 알아보니 MockRestServiceServer라는 클래스가 있었다. 공식문서에서도 아래와 같이 설명하고 있는데, 실제 서버를 사용하지 않고 요청과 응답을 설정할 수 있다.
Main entry point for client-side REST testing. Used for tests that involve direct or indirect use of the RestTemplate. Provides a way to set up expected requests that will be performed through the RestTemplate as well as mock responses to send back thus removing the need for an actual server.
그리고 @RestClientTest 가 있다는 것도 알게 되었다. (이 부분이 이번 테스트를 어렵게 만든 부분 중 하나였다.🥲)
@RestClientTest는 스프링에서 Spring Rest Client 테스트를 위한 애너테이션이다. MockRestServiceServer를 자동으로 설정하고 RestTemplateBuilder, RestClient.Builder를 사용한 Bean들을 구성한다.
MockRestServiceServer와 RestClient(Bean으로 등록하기 위해 Config 파일 사용) 를 통해 아래 방식으로 테스트 코드를 작성했다.
@RestClientTest(TossPayRestClient.class)
@ContextConfiguration(classes = PaymentConfig.class)
class TossPayRestClientTest {
@Autowired
private MockRestServiceServer server;
@Autowired
private TossPayRestClient tossPayRestClient;
@BeforeEach
void setUp(){
server.reset();
}
@DisplayName("결제에 실패하면 예외가 발생한다.")
@Test
void throwException() {
String expectedBody = """
{
"code": "NOT_FOUND_PAYMENT",
"message": "존재하지 않는 결제 입니다."
}
""";
// Mock Server의 응답을 설정
server.expect(requestTo("https://api.tosspayments.com/v1/payments/confirm"))
.andExpect(method(HttpMethod.POST))
.andRespond(
withStatus(HttpStatus.BAD_REQUEST).body(expectedBody).contentType(MediaType.APPLICATION_JSON));
PaymentRequest request = new PaymentRequest("orderId", 1000, "paymentKey");
// Mock Server로 요청 전송
assertThatCode(() -> tossPayRestClient.pay(request))
.isInstanceOf(PaymentFailException.class)
.hasMessage("존재하지 않는 결제 입니다.");
}
}
RestClient를 Bean으로 등록하기 위해 PaymentConfig 파일을 두었다. 다음은 테스트를 위해 작성했던 Config 파일들인데, 각 버전별로 동작 여부와 특징에 대해 적어두었다.
< Config Ver.1 - mocking 안됨, 테스트 실패 😭 >
TossPayRestClient가 이미 완성된 RestClient으로 Bean 등록되어 있어 해당 RestClient가 Mocking이 되지 않았다.
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentConfig {
private static final String TOSS_KEY_PREFIX = "Basic ";
private static final long CONNECTION_TIMEOUT = 5L;
private static final long READ_TIMEOUT = 30L;
private final PaymentProperties paymentProperties;
public PaymentConfig(PaymentProperties paymentProperties) {
this.paymentProperties = paymentProperties;
}
// TossPayRestClient를 Bean으로 등록함. 이 때 완성된 requestFactory가 설정됨
@Bean
public TossPayRestClient tossPayRestClient() {
return new TossPayRestClient(
RestClient.builder()
.requestFactory(createHttpRequestFactory())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Authorization 설정값")
.baseUrl("https://api.tosspayments.com")
.defaultStatusHandler(new TossPayErrorHandler())
.build()
);
}
private ClientHttpRequestFactory createHttpRequestFactory() {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(CONNECTION_TIMEOUT))
.withReadTimeout(Duration.ofSeconds(READ_TIMEOUT));
return ClientHttpRequestFactories.get(JdkClientHttpRequestFactory.class, settings);
}
}
< Config Ver.2 - mocking은 되는데 하나의 RestClient밖에 사용 안됨 🤔 >
첫번째 버전의 문제는 공식문서 설명을 통해 해결할 수 있었다. @RestClientTest는 "RestClient.Builder를 사용한 Bean들이 구성"되므로, RestClientCustomizer를 Bean으로 등록해서 테스트가 가능해졌다.
RestClientCustomizer는 RestClient.Builder를 커스터마이징하는 역할을 하는 인터페이스이다. RestClientCustomizer를 통해 RestClient.Builder에 설정을 추가하고 Bean으로 등록하고 있다. 그래서 Mocking은 가능하지만 하나의 RestClient.Builder 밖에 설정할 수 없었다. 그런데 지금 생각해보니 RestClientCustomizer에서 requestFactory, header, url, handler를 설정하지 않고 tossPayRestClient()에서 설정했으면 여러 Client가 가능했을 것 같기도 하다..!🤔 하지만 어찌됐든, 현재 상태에서는 다른 결제 회사가 추가된다면 테스트가 어려워진다.
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentConfig {
private static final String TOSS_KEY_PREFIX = "Basic ";
private static final long CONNECTION_TIMEOUT = 5L;
private static final long READ_TIMEOUT = 30L;
private final PaymentProperties paymentProperties;
public PaymentConfig(PaymentProperties paymentProperties) {
this.paymentProperties = paymentProperties;
}
// RestClient.Builder를 주입받아 TossPayRestClient를 Bean으로 등록하도록함
@Bean
public TossPayRestClient tossPayRestClient(RestClient.Builder builder) {
return new TossPayRestClient(builder.build());
}
// RestClientCustomizer로 Builder에 필요한 설정을 추가한 후 Bean으로 등록함
@Bean
public RestClientCustomizer tossPayRestClientCustomizer() {
return builder -> builder
.requestFactory(createHttpRequestFactory())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Authorization 설정값")
.baseUrl("https://api.tosspayments.com")
.defaultStatusHandler(new TossPayErrorHandler());
}
private ClientHttpRequestFactory createHttpRequestFactory() {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(CONNECTION_TIMEOUT))
.withReadTimeout(Duration.ofSeconds(READ_TIMEOUT));
return ClientHttpRequestFactories.get(JdkClientHttpRequestFactory.class, settings);
}
}
< Config Ver.3 - RestClient.Builder를 Bean으로 등록 , 정상 작동 😃>
여기서부터는 테스트 코드와 Bean 등록 방식을 수정했다.
먼저, 여러개의 RestClient.Builder를 Bean으로 등록해서 해당되는 RestClient 생성에 사용되도록 했다.
builders()를 통해 properties에 맞는 RestClient.Builder를 미리 Bean으로 등록해두고, tossPayRestClient()와 같은 Bean에 적절한 Builder를 넣어주었다. PaymentProperties를 별도로 분리했는데, Map<String, Property>를 필드로 갖는 일급 컬렉션으로 결제 vendor 이름을 키로, 관련 설정을 값으로 갖는 객체이다.
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class PaymentConfig {
private static final String BASIC = "Basic ";
private final PaymentProperties paymentProperties;
public PaymentConfig(PaymentProperties paymentProperties) {
this.paymentProperties = paymentProperties;
}
// Map<String, Builder>를 Bean으로 등록함
@Bean
public PaymentClientBuilders builders() {
return new PaymentClientBuilders(createBuilders());
}
private Map<String, Builder> createBuilders() {
Map<String, Builder> builders = new HashMap<>();
paymentProperties.getProperties().keySet()
.forEach(vendor -> builders.put("vendor이름", "vender 설정에 맞는 Builder");
return builders;
}
... 중간 로직 생략
// 생성되어있는 Builder 중, vender에 맞는 Builder 꺼내와서 사용
@Bean
public TossPayRestClient tossPayRestClient() {
Builder tossBuilder = builders().get("toss")
.defaultStatusHandler(new TossPayErrorHandler());
return new TossPayRestClient(tossBuilder.build());
}
}
< @RestClientTest 대신 단위 테스트 적용 >
그리고 테스트를 할 때 @RestClientTest를 제거했다. @RestClientTest는 Spring context를 생성하게 되는데, 만약 테스트 코드가 많아진다면 이 또한 성능을 저하시키는 원인이 될 수 있다. 그래서 Spring context를 사용하지 않고 MockRestServiceServer를 직접 생성하는 방식으로 수정했다. 테스트용 RestClient.Builder를 정의하고, TossPayRestClient, MockRestServiceServer가 이를 주입받아서 사용하도록 했다.
class TossPayRestClientTest {
// 테스트용 Builder 생성
private final RestClient.Builder testBuilder = RestClient.builder()
.baseUrl("https://api.tosspayments.com")
.defaultStatusHandler(new TossPayErrorHandler());
// 테스트용 Builder 주입
private MockRestServiceServer server = MockRestServiceServer.bindTo(testBuilder).build();
private TossPayRestClient tossPayRestClient = new TossPayRestClient(testBuilder.build());
@BeforeEach
void setUp() {
server.reset();
}
@DisplayName("결제가 정상적으로 처리되면 예외가 발생하지 않는다.")
@Test
void pay() {
server.expect(requestTo("https://api.tosspayments.com/v1/payments/confirm"))
.andExpect(method(HttpMethod.POST))
.andRespond(withSuccess());
PaymentRequest request = new PaymentRequest("orderId", 1000, "paymentKey");
assertDoesNotThrow(() -> tossPayRestClient.pay(request));
}
@DisplayName("결제에 실패하면 예외가 발생한다.")
@Test
void throwException() {
String expectedBody = """
{
"code": "NOT_FOUND_PAYMENT",
"message": "존재하지 않는 결제 입니다."
}
""";
server.expect(requestTo("https://api.tosspayments.com/v1/payments/confirm"))
.andExpect(method(HttpMethod.POST))
.andRespond(
withStatus(HttpStatus.BAD_REQUEST).body(expectedBody).contentType(MediaType.APPLICATION_JSON));
PaymentRequest request = new PaymentRequest("orderId", 1000, "paymentKey");
assertThatCode(() -> tossPayRestClient.pay(request))
.isInstanceOf(PaymentFailException.class)
.hasMessage("존재하지 않는 결제 입니다.");
}
}
정리
외부 API가 연동된 테스트에서 외부 서버를 Mocking할 때, @RestClientTest를 사용하기 보다는 MockRestServiceServer를 직접 생성해서 사용하는 편이 설정하기에도, 성능 면에서도 좋은 것 같다.
여러개의 RestClient를 사용해야하는 환경이라면 RestClient.Builder의 Collection을 Bean으로 등록한 뒤 필요한 Builder를 꺼내서 쓰는 방법을 고려해보자.
이번 포스팅은 너무 많은 에러를 겪고 고민해보고 시도해봤던 내용을 정리해보았습니다. 아직 학습중인 단계라 정확하지 않은 내용이 있을 수 있으니 참고해주시고, 혹시 잘못된 부분이 있다면 언제든지 편하게 말씀해주세요!! 🙇🏻♀️
'우아한테크코스 > 레벨 2 - Spring' 카테고리의 다른 글
[Spring] RestAssured, RestDocs로 API 문서 자동화 하기 with 공식문서 (0) | 2024.06.09 |
---|---|
[Spring, JUnit5] 테스트 격리: DB 초기화 (InitializingBean, BeforeEachCallback) (0) | 2024.05.25 |
[JPA] @Embedded, @Embeddable 개념과 사용법 (0) | 2024.05.16 |
[Spring] HandlerMethodArgumentResolver 사용하기 (0) | 2024.05.09 |
[Spring] DTO 검증하고 결과 알려주기 with @Valid, @ExceptionHandler (0) | 2024.05.03 |