저는 블록체인 서비스 기업에서 거래소 서비스를 개발하며 외부 API 호출할 일이 많았습니다.
외부 API 요청을 위해 RestTemplate을 사용하며 찾은 기능과 서버 간 요청 반복작업에서 중복코드를 제거하고 코드 재사용성을 높일 수 있는 방법을 정리했습니다.
이해를 돕기 위해 적당한 OPEN API를 찾던 중 파파고 API가 좋아보여서 파파고 API를 사용했습니다.
소나큐브는 정적 코드 분석기입니다. 코드 스멜을 확인하기 위해서 사용했습니다.
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject("http://helloworld.com", String.class);
// ttp://helloworld.com 에서 반환하는 문자열 리턴
log.info(result);
// RestTemplate 객체 생성
RestTemplate restTemplate = new RestTemplate();
// Map으로 파라미터를 생성
Map<String, Object> parameters = new HashMap<>();
// 요청 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + "your-access-token");
// 요청 바디
parameters.put("key", "value");
var response = restTemplate
.postForEntity("https://helloworld.com",
new HttpEntity<>(parameters, headers),
Object.class);
log.info(response.toString());
curl "https://openapi.naver.com/v1/papago/n2mt" \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
-H "X-Naver-Client-Id: {{CLIENT_ID}}" \
-H "X-Naver-Client-Secret: {{CLIENT_SECRET}}" \
-d "source={{ko}}&target={{en}}&text={{번역할 문자열 (안녕)}}" -v
{
"message": {
"result": {
"srcLangType": "ko",
"tarLangType": "en",
"translatedText": "Hello", // 안녕 -> Hello
"engineType": "PRETRANS"
},
"@type": "response",
"@service": "naverservice.nmt.proxy",
"@version": "1.0.0"
}
}
@Service
public class Papago implements Translator {
// 파파고 API 엔드포인트
private static final String REQUEST_URL = "https://openapi.naver.com/v1/papago/n2mt";
// 인증 Key
private static final String HEADER_CLIENT_ID = "X-Naver-Client-Id";
private static final String HEADER_CLIENT_SECRET = "X-Naver-Client-Secret";
// 파라미터 Key
private static final String BODY_SOURCE_NAME = "source";
private static final String BODY_TARGET_NAME = "target";
private static final String BODY_TEXT_NAME = "text";
// 인증 Value
private final String clientId;
private final String secret;
// 요청 정보 초기화
private RestTemplate request = new RestTemplate();
private HttpHeaders header = new HttpHeaders();
private LinkedMultiValueMap<String, String> paramter = new LinkedMultiValueMap<>();
// API 인증 관련 정보 생성자 주입
public Papago(
@Value("${api.papago.client-id}") String clientId,
@Value("${api.papago.secret}") String secret) {
this.clientId = clientId;
this.secret = secret;
}
// 헤더 설정
public void setHeaders() {
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.set(HEADER_CLIENT_ID, clientId);
header.set(HEADER_CLIENT_SECRET, secret);
}
// 로직 구현
@Override
public String translate(String source, String target, String text) {
setHeaders();
// 파라미터 설정
paramter.add(BODY_SOURCE_NAME, source);
paramter.add(BODY_TARGET_NAME, target);
paramter.add(BODY_TEXT_NAME, text);
var response = request.postForEntity(
REQUEST_URL,
new HttpEntity<>(paramter, header),
PapagoTranslateResponse.class);
// PapagoTranslateResponse 객체로 파싱
if (null == response.getBody() || null == response.getBody().getTranslatedText()) {
throw new IllegalArgumentException(text + "에 대한 번역결과가 없습니다.");
}
return response.getBody().getTranslatedText();
}
}
코드 스멜은 하나도 없지만 translate
메서드가 호출될 때마다 setHeader()
메서드를 실행됩니다. 물론 다른 방법으로도 해결할 수 있지만 RestTemplate은 이런 반복적인 작업을 간단하게 처리하기 위한 인터셉터 인터페이스를 제공합니다.
RestTemplate의 인터셉터는 HTTP 요청을 보내기 전/후로 특정한 처리를 추가할 수 있도록 해주는 기능입니다.
인터셉터를 활용하면 Access-Key 같은 요청들의 공통 헤더를 인터셉터를 통해 코드 중복 없이 설정할 수 있습니다.
package org.springframework.http.client;
import java.io.IOException;
import org.springframework.http.HttpRequest;
// 함수형 인터페이스로 선언됨
// abstract 메서드가 한 개만 있어야 한다.
// default, static은 상관없음.
@FunctionalInterface
public interface ClientHttpRequestInterceptor {
ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException;
}
@Component
// 인터셉터를 구현
public class HeaderSettingInterceptor implements ClientHttpRequestInterceptor {
private static final String HEADER_CLIENT_ID = "X-Naver-Client-Id";
private static final String HEADER_CLIENT_SECRET = "X-Naver-Client-Secret";
private final String clientId;
private final String secret;
public HeaderSettingInterceptor(
@Value("${api.papago.client-id}") String clientId,
@Value("${api.papago.secret}") String secret) {
this.clientId = clientId;
this.secret = secret;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws
IOException {
// 헤더에 Key, Value 추가
request.getHeaders().add(HEADER_CLIENT_ID, clientId);
request.getHeaders().add(HEADER_CLIENT_SECRET, secret);
request.getHeaders().setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 실제 요청
return execution.execute(request, body);
}
}
@Configuration
public class AppConfig {
private final HeaderSettingInterceptor headerSettingInterceptor;
public AppConfig(HeaderSettingInterceptor headerSettingInterceptor) {
this.headerSettingInterceptor = headerSettingInterceptor;
}
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList(headerSettingInterceptor));
return restTemplate;
}
}
@Service
public class Papago implements Translator {
private static final String REQUEST_URL = "https://openapi.naver.com/v1/papago/n2mt";
private static final String BODY_SOURCE_NAME = "source";
private static final String BODY_TARGET_NAME = "target";
private static final String BODY_TEXT_NAME = "text";
private final RestTemplate request;
private LinkedMultiValueMap<String, String> paramter = new LinkedMultiValueMap<>();
public Papago(RestTemplate request) {
this.request = request;
}
@Override
public String translate(String source, String target, String text) {
paramter.add(BODY_SOURCE_NAME, source);
paramter.add(BODY_TARGET_NAME, target);
paramter.add(BODY_TEXT_NAME, text);
var response = request.postForEntity(
REQUEST_URL,
new HttpEntity<>(paramter, new HttpHeaders()),
PapagoTranslateResponse.class);
if (null == response.getBody() || null == response.getBody().getTranslatedText()) {
throw new IllegalArgumentException(text + "에 대한 번역결과가 없습니다.");
}
return response.getBody().getTranslatedText();
}
}
디버그 화면을 보면 RestTemplate 인스턴스에 HeaderSettingInterceptor가 등록된 것을 확인할 수 있습니다.
RestTemplate의 ErrorHandler는 RestTemplate이 HTTP 요청을 보낼 때 발생할 수 있는 예외를 처리하는 방법을 정의하는 인터페이스입니다.
예를 들어 HTTP 4xx 또는 5xx 상태 코드를 수신한 경우 RestTemplate이 예외를 던집니다.
이러한 예외를 처리하는 방법을 정의할 수 있습니다.
hasError() 메서드는 해당 응답이 오류였는지 판단하는 메서드입니다.
handleError() 메서드는 실패한 HTTP 요청에 대한 처리 방법을 결정합니다.
네이버 개발자센터에서 제공하는 오류코드를 바탕으로 예외를 처리하는 ErrorHandler 만듭니다.
// 오류 예시
{
"errorCode": "N2MT07",
"errorMessage": "text parameter is needed (text 파라미터가 필요합니다.)"
}
5xx 오류가 발생하면 RuntimeException을 던지고 4xx 오류 발생 시 오류 메세지를 반환하는 예시입니다.
@Component
public class TranslateErrorHandler implements ResponseErrorHandler {
// JSON 파싱용 ObjectMapper 주입
private final ObjectMapper om;
public TranslateErrorHandler(ObjectMapper om) {
this.om = om;
}
@Override
// 오류 판별
public boolean hasError(ClientHttpResponse response) throws IOException {
return response.getStatusCode().is4xxClientError()
|| response.getStatusCode().is5xxServerError();
}
@Override
// 오류 핸들링
public void handleError(ClientHttpResponse response) throws IOException {
var status = response.getStatusCode();
if (status.is5xxServerError()) {
throw new RuntimeException("예상치 못한 오류가 발생했습니다.");
}
if (status.is4xxClientError()) {
ErrorResponse errorResponse = om.readValue(response.getBody(), ErrorResponse.class);
throw new IllegalArgumentException(errorResponse.getErrorMessage());
}
}
@Data
@NoArgsConstructor
static class ErrorResponse {
private String errorCode;
private String errorMessage;
}
}
외부 API 호출은 많은 리소스가 필요하기 때문에 Trending이나 Top같은 지표들은 캐싱을 적용할 수 있습니다.
하지만 외부 API를 사용하다 보면 캐싱이 어려워 매 번 서버 간 요청을 해야되는 상황이라면 Blocking 때문에 처리량이 낮아지는 문제가 생깁니다.
Async RestTemplate 비동기 호출을 통해 블로킹을 방지하고 서버의 처리량과 응답성을 향상시킬 수 있습니다.
RestTemplate과 사용법은 유사하지만, 비동기 호출을 지원하고 콜백을 등록할 수 있는 메서드들이 추가되어있습니다.
사실 Spring 5.0부터는 WebClient를 지원하기 때문에 AsyncRestTemplate을 사용할 이유가 없습니다. 하지만 기존 RestTemplate을 사용하는 어플리케이션의 코드 호환성을 고려해 AsyncRestTemplate를 사용하는 것도 좋은 방법입니다.
AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate();
ListenableFuture<ResponseEntity<String>> future = asyncRestTemplate.getForEntity("https://www.example.com", String.class);
future.addCallback(new ListenableFutureCallback<ResponseEntity<String>>() {
@Override
public void onSuccess(ResponseEntity<String> result) {
// 비동기 호출이 성공했을 때 처리할 내용
}
@Override
public void onFailure(Throwable ex) {
// 비동기 호출이 실패했을 때 처리할 내용
}
});
// 비동기 호출 취소
future.cancel(true);