📌 높은 자유도에는 많은 책임이 따르는 법
코드는 내 마음대로 짜도 되지만, 미션 Merge 또한 리뷰어 마음이라는 것을 잊지 말자 🥔
레벨2 세 번째 미션을 진행하면서부터 선택의 폭이 넓어지는 것을 느꼈다. 이전 레벨에서는 도메인 설계, 책임 분리 같이 코드 내 관점에서 고민하는 일이 많았는데, 이번 레벨에서는 엔티티의 기본키 생성 전략, HttpClient 등의 기술적 측면에서 스스로 결정을 내려야 하는 상황에 자주 놓였다.
예약 대기 미션을 하면서 리뷰어한테 많은 질문을 받은 후부터, 결정을 내릴 때는 그에 대한 근거가 있어야 한다고 의식하고 있는 상태였다. 그래서 공식 문서 한 번 쓰윽 훑고, RestClient가 RestTemplate의 인프라랑 WebClient의 fluent API를 합친 좋은 거래, 아무튼 최신이니까 이거 쓰자~ 하면서 냅다 적용하고 넘어갔다. 이걸 근거라고 할 수 있나 싶은 의문이 양심을 쿡쿡 찌르긴 했다만...... 나는 빨리빨리 빼면 시체인걸 ☠️
그러다 피드백 강의를 듣고 의사 결정에 대한 기준을 재정립할 필요성을 느껴서, RestClient를 선택하고 코드에 적용한 과정을 되짚어 보려고 한다.
📌 기술의 특징 파악하기
만 했으면 된 거 아닌가?
RestTemplate
RestTemplate은 HTTP 요청을 처리하는 동기적 클라이언트이다. 클래스 이름에서 알 수 있듯 Template 메서드 패턴 사용한다. 매우 유연한 설정이 가능한 만큼, 메서드 오버로딩이 많이 필요하다는 단점 또한 있다. 예전 버전의 스프링 프레임워크에 사용하기 좋지만, 동기식으로 동작하기 때문에 논블로킹 환경에 적용하기에는 어렵다.
WebClient
WebClient는 RestTemplate과 달리 비동기식 논블로킹 동작이 가능한 클라이언트이다. fluent API를 제공해 함수형 프로그래밍이 가능해 Java의 람다 사용 시 가독성 측면에서 이점을 많이 얻을 수 있어 RestTemplate의 단점을 보완한다. 높은 부하 환경에서도 효율적으로 동작하며, 리액티브 프로그래밍 모델과 잘 맞는다.
RestClient
RestClient는 스프링 6에 도입되었으며, fluent API를 제공하는 동기적 클라이언트이다. RestTemplate과 동일하게 추상화된 인프라를 통해 유연한 설정이 가능하면서, WebClient의 fluent API를 함께 제공한다고 볼 수 있다.
아무튼 RestTemplate과 WebClient의 장점을 모두 합친 거고, 현재 미션에서는 동기-비동기, 블로킹-논블로킹을 따질 만큼 복잡한 요청이 오가는 것은 아니니까 페어 중에는 그냥 바로 RestClient로 결론을 내렸다.
📌 최신 기술은 무조건 편리하고 좋다
산이 다섯 개 모인 장소는?
경기도 오산 ⛰️ ⛰️ ⛰️ ⛰️ ⛰️
정말 딱 특징만 파악하고 아무런 공부도 하지 않았던 것은 아니었다. 학습 테스트로 GET 요청 보내는 코드도 열심히 작성해 보고, 내가 놓친 단점들은 없는지 파악하기 위해 다양한 키워드들로 구글링도 했다.
비동기적 처리를 할 수 없고, 참고 자료가 많이 없고, 이런 저런 사소한 문제들이 존재했다. 기묘하게도 '최신 기술'이라는 네 글자가 정말 판단력을 흐렸다. 아무튼 최근에 나왔다고 하잖아, 그럼 당연히 이전에 쓰던 것보다는 좋은 거 아니야?
객관적으로 봤을 때는 그럴 수도 있지만, 코딩 햇병아리 감자한테도 그럴지... 🤔
📌 아슬아슬 오류 핸들링
토스의 결제 승인 API를 호출하고, RestClient의 onStatus() 메서드 설정으로 오류 핸들링을 추가했다. ClientHttpResponse 타입의 응답에 getBody() 메서드를 호출했더니 문자열도 아니고 웬 InputStream이 튀어나와서 저걸 객체로 바꾸려고 삽질을 엄청 했다.
결국 제대로 된 핸들링 방법 못 찾고 ControllerAdvice에서 다음과 같이 HttpClientErrorException을 처리해 주도록 코드를 작성했다.
package roomescape.web.controller.handler;
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(HttpClientErrorException.class)
public ResponseEntity<String> handleHttpClientErrorException(HttpClientErrorException e) {
return ResponseEntity.status(e.getStatusCode()).body(e.getResponseBodyAsString());
}
}
첫 번째 PR 제출하고 조조한테 배웠는데 ObjectMapper 사용해서 InputStream을 객체로 변환해 주는 방법을 사용하면 되는 것이었다. 커스텀 예외랑 상태 코드 처리도 추가되면서 최종적으로는 다음과 같은 핸들링 로직을 적용했다.
private void handlePaymentError(HttpRequest request, ClientHttpResponse response) throws IOException {
PaymentErrorResponse paymentErrorResponse = objectMapper.readValue(response.getBody(), PaymentErrorResponse.class);
Optional<PaymentServerErrorCode> serverErrorCode = PaymentServerErrorCode.from(paymentErrorResponse.code());
if (serverErrorCode.isPresent()) {
throw new PaymentException(paymentErrorResponse, serverErrorCode.get().getMessage());
}
throw new PaymentException(response.getStatusCode(), paymentErrorResponse);
}
📌 입 벌려 테스트 들어간다
TDD 했으면 RestClient 안 썼을 텐데 포항항ꉂꉂ(ᵔᗜᵔ*)
RestClient에 대한 테스트 방법을 몰라서 처음에는 다음과 같이 테스트를 작성하려고 했다:
@ExtendWith(MockitoExtension.class)
class PaymentManagerTest {
@DisplayName("bdd로 결제 승인 요청을 승인한다.")
@Test
void approvedTestWithBdd() {
RestClient restClient = mock(RestClient.class);
RequestBodyUriSpec requestBodyUriSpec = mock(RequestBodyUriSpec.class);
given(restClient.post()).willReturn(requestBodyUriSpec);
given(requestBodyUriSpec.uri(anyString())).willReturn(requestBodyUriSpec);
given(requestBodyUriSpec.header(anyString(), anyString())).willReturn(requestBodyUriSpec);
given(requestBodyUriSpec.body(any(PaymentApproveRequest.class))).willReturn(requestBodyUriSpec);
given(requestBodyUriSpec.retrieve()).willReturn(mock(ResponseSpec.class));
PaymentManager paymentManager = new PaymentManager(
new PaymentAuthorizationGenerator(new PaymentConfiguration("secretKey")), restClient);
paymentManager.approve(new PaymentApproveRequest("paymentKey", "orderId", 1000L));
then(restClient).should().post();
then(requestBodyUriSpec).should().uri(eq("https://api.tosspayments.com/v1/payments/confirm"));
then(requestBodyUriSpec).should().header(eq("Authorization"), anyString());
then(requestBodyUriSpec).should().body(new PaymentApproveRequest("paymentKey", "orderId", 1000L));
then(requestBodyUriSpec).should().retrieve();
}
}
근데 이게 Service를 테스트 하는 맥락에 대입해 보면, ReservationService.save()를 호출했을 때 ReservationRepository.save()가 함께 호출되었는지를 검증하는 것과 유사하다고 느꼈다. 실제로 검증되어야 하는 것은 Repository의 save() 메서드 호출 여부가 아닌, 올바른 데이터 저장 여부라서 굳이 저런 케이스를 쓸 필요가 있나? 하는 생각이 들었다. 프로덕션 코드 수정에 민감해서 테스트 유연성도 많이 떨어진다.
그래서 RestClien를 사용할 때 Mock을 하는 방법을 찾다가 @RestClientTest라는 것을 발견했고 PaymentManager에 대한 테스트를 작성할 수 있었다.
package roomescape.infrastructure.payment;
@RestClientTest(PaymentManager.class)
class PaymentManagerTest {
@Autowired
private PaymentManager paymentManager;
@Autowired
private MockRestServiceServer server;
@Autowired
private ObjectMapper objectMapper;
@DisplayName("결제 승인을 요청하고 올바르게 응답을 반환한다.")
@Test
void approve() throws IOException {
PaymentApproveDto paymentApproveDto = new PaymentApproveDto("paymentKey", "orderId", 1000L);
String paymentApproveJson = objectMapper.writeValueAsString(paymentApproveDto);
this.server.expect(requestTo("https://api.tosspayments.com/v1/payments/confirm"))
.andExpect(content().json(paymentApproveJson))
.andRespond(withSuccess(paymentApproveJson, MediaType.APPLICATION_JSON));
PaymentApproveDto actualResponse = paymentManager.approve(paymentApproveDto);
assertThat(actualResponse).isEqualTo(paymentApproveDto);
this.server.verify();
}
@DisplayName("결제 승인 실패 시 PaymentException이 발생한다.")
@Test
void invalidPaymentApproveRequest() throws IOException {
PaymentApproveDto paymentApproveDto = new PaymentApproveDto("invalidKey", "orderId", 1000L);
String paymentApproveJson = objectMapper.writeValueAsString(paymentApproveDto);
String responseJson = objectMapper.writeValueAsString(new PaymentErrorResponse("ALREADY_PROCESSED_PAYMENT", "이미 처리된 결제 입니다."));
this.server.expect(requestTo("https://api.tosspayments.com/v1/payments/confirm"))
.andExpect(content().json(paymentApproveJson))
.andRespond(withStatus(HttpStatus.BAD_REQUEST).body(responseJson));
assertThatThrownBy(() -> paymentManager.approve(paymentApproveDto))
.isInstanceOf(PaymentException.class)
.hasMessage("이미 처리된 결제 입니다.");
this.server.verify();
}
}
분명 블로그 보면서 똑같이 설정했는데, 이상하게 자꾸 MockRestServiceServer가 아닌 토스 서버에서 응답이 왔다.
알고 보니 해당 글에서는 Client 클래스에서 RestClient가 아닌 RestTemplate을 사용하고 있었다. 어노테이션 이름이 RestClientTest라길래 RestClient 전용인 줄 알았지 😑
다행히 나와 같은 문제를 겪었던 사람이 있어서 이를 해결할 수 있었다.
그러니까 RestClinet.Builder의 requestFactory()를 직접 호출하면 MockClientHttpRequestFactory의 설정을 오버로딩 해서 Mock 설정이 제대로 이뤄지지 않는 것이 문제였다.
그래서 다음과 같이 RestClient 관련 설정을 변경하여 올바르게 테스트를 할 수 있게 되었다 휴
package roomescape.infrastructure.payment;
@Configuration
public class ClientHttpRequestConfig {
private static final int CONNECTION_TIMEOUT = 10;
private static final int READ_TIMEOUT = 30;
@Bean
public RestClientCustomizer restClientCustomizer() {
return restClientBuilder -> RestClient
.builder()
.requestFactory(createRequestFactory());
}
private ClientHttpRequestFactory createRequestFactory() {
// ...
return new HttpComponentsClientHttpRequestFactory(httpClient);
}
}
📌 그래서 저의 결론은요
이번 경험을 통해 참고 자료의 필요성을 정말 많이 느꼈다. RestTemplate에 대한 정보가 훨씬 많은 것을 인지하고 있었지만, RestClient를 선택했었다. 예제 코드도 거의 없고, 오로지 공식 문서에만 의존해서 개발을 해야 하는 상황이 언젠가 발생할 텐데, 참고 자료가 부족하다는 이유만으로 RestTemplate을 선택하는 것은 나의 성장에 도움이 되지 않을 것이라는 걱정을 했다.
하지만 반대로 생각해 보면, 자료가 넘쳐나는 JPA나 Spring MVC에 대한 코드를 작성할 때조차 전전긍긍인데, 너무 앞선 걱정이었던 듯싶다. 정보가 많을수록 동작 원리를 더 깊게 배울 수 있고, 문제 상황에 대한 해결책을 쉽게 찾을 수 있으니, 개발 효율이 높아지는 것 또한 분명한 장점이 될 수 있다.
이번 외부 API 연동을 통해, 의사 결정을 할 때는 내가 처한 상황에 따라 유연하게 선택의 기준을 정해야 한다는 것을 배웠다. 당장 기능 구현이 급한 상황이라면, 내가 조금 더 익숙하고, 참고할 수 있는 자료가 많은 기술을 선택하고 나중에 차차 개선해 나갈 것 같다. 반대로 각 기술의 장단점을 비교하고 학습할 시간이 충분하다면, 생소하더라도 내가 운영할 서비스에 가장 적합한 기술을 적용하는 시도를 하면 좋을 것 같다. 앞으로는 코치님이 공유해 주었던 에러 핸들링의 간편성, 이번에 직접 경험한 테스트 용이성과 같은 기준들도 함께 고려하며 의사 결정을 하도록 노력해 보려고 한다 👽
https://www.baeldung.com/spring-boot-restclient
'우아한테크코스 > Level2' 카테고리의 다른 글
[우아한테크코스] JPA에서 감자로 살아남기🥔 (5) | 2024.05.18 |
---|---|
[우아한테크코스] 로그인에서 감자로 살아남기 🥔 (2) | 2024.05.13 |
@ResponseBody와 ReponseEntity (0) | 2024.04.28 |