백엔드 특성상 사실 요청을 받아서 응답을 해주는 서버이지 요청을 만드는 역할은 잘 하지 않는다. 뿐만 아니라 서버가 요청을 만들어서 하는 것 보다는 클라이언트 단에서 하는 게 더 맞다고 생각한다. 서버 부하가 심해질 수 밖에 없으니깐. 하지만, 서버에서도 할 경우가 있다. 대표적인 예로 디비 통신. 다른 서버 통신도 있을 수 있고 그럴 경우는 대게 RestTemplate을 사용한다.
이번 글은 최종 프로젝트에서 외부 서버와 통신할 일이 많았었다. 근데 RestTemplate을 사용하다가 불편한 점이 많았어서 <직접 RestTemplate을 활용한 라이브러리를 만들어보자> 라고 호기롭게 도전했다가 생각보다 RestTemplate이 정말 편리한 거구나 ㅋㅋㅋ 그러면 어떻게 하면 효과적으로 중복 코드를 많이 제거하는 방식으로 활용할 수 있을까? 싶어 만들어본 하나의 클래스에 대해 정리하려고 한다. 별거 없고 단지 제네릭과 DTO를 활용했다.
게다가 Rest Template은 서버가 TCP/IP 소켓을 열고 상대 서버에게 요청하는 것이므로 자연스레 connection pool이 고려되었다. 물론 스프링은 이를 지원해 주었다! 역시..
1. RestTemplate에 대하여.
RestTemplate은 post man 을 코드로 나타낸 수준이라고 생각 된다. method / url / header / body 이렇게 4가지만 잘 지정하면 된다. RestTemplate의 전형적인 사용 예시는 아래와 같다.
public void api(String url){
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("name", "testUser");
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", "taste");
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class
);
}
resTemplate.exchange()의 경우는 해당 파라미터들로 요청을 만들고 응답을 동시에 주고 받는 메서드이다. 그 외의 코드는 직관적이라서 설명할 건 딱히 없으리라 생각한다. 물론 exchange말고 다른 여러 메서드가 존재하지만 나머지는 딱히 사용해 본적이 없기도 하고 잘 안쓰일 것 같아서 설명을 넘어가겠다.
주목해야 할 부분은 반복되는 코드를 줄일 수 있는 키 포인트는 params의 리턴 타입이 HttpEntity<T>에서 T의 타입을 결정한다는 것과 restTemplate.exchange()의 마지막 파라미터인 String.class를 보면 String이 RespnseEntity<S>에서 S의 타입을 결정짓는 다는 것이다. 이 S의 값은 응답의 body 타입을 의미한다(body가 문자열로.. 물론 ObjectMapper를 이용하면 된다. 하지만 엄청난 예외처리).
params의 경우 Map 말고 객체로 하면 어떨까? 왜냐하면 3개의 요청 주소 전부 name:"~~~"가 필요할때 params.add("name",~~~)에서 ~~~ 부분을 제외하고는 중복되는 하드 코딩, 심지어 오타날 경우 컴파일 에러도 검출하지 못한다.
뿐만 아니라 exchange의 리턴 값도 body 내용을 객체로 받아오면 얼마나 좋을까? 위 처럼 String 으로 받아오면 모든 응답값이 String으로 넘어오기에 별도의 파싱이 필요하고 심지어 파싱 함수에는 체크 예외도 존재해서 예외 처리도 신경 써야 한다...
1.1 초난감 요청 보내기
만약 위 방식으로 요청을 하려면? 이전에 카카오 로그인에 소개했던 코드를 다시 가져오겠다.
public Long kakoLogin(String code){
ResponseEntity<String> response = getAccessToken(code);
OAuthToken oAuthToken = JsonToObject(response);
...
return kakaoId.getId();
}
private ResponseEntity<String> getAccessToken(String code){
//Post방식으로 key=value 데이터를 요청(카카오 쪽으로)
RestTemplate rt = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
//header
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
//body
MultiValueMap<String,String> params = new LinkedMultiValueMap<>();
params.add("grant_type", LoginArgs.grant_type);
params.add("client_id",LoginArgs.api_key);
params.add("redirect_uri",LoginArgs.redirect_uri);
params.add("code",code);
//header body 연결
HttpEntity<MultiValueMap<String,String>> kakaoTokenRequest =
new HttpEntity<>(params,headers);
//요청과 응답을 동시에
ResponseEntity<String> response = rt.exchange(
LoginArgs.get_token_uri,
HttpMethod.POST,
kakaoTokenRequest,
String.class
);
return response;
}
private KakaoId JsonToObject(ResponseEntity<String> response){
ObjectMapper mapper = new ObjectMapper();
KakaoId kakaoId = null;
try {
kakaoId = mapper.readValue(response.getBody(), KakaoId.class);
}catch(JsonMappingException e){
e.printStackTrace();
}catch(JsonProcessingException e){
e.printStackTrace();
}
return kakaoId;
}
코드를 보면 일일이 하드 코딩 후에 JsonToObject를 사용해서 파싱을 하는데 예외 처리까지 해주어야 하는.. 정말 어지럽다. 물론 params의 키 값을 따로 빼 놓을 수 있지만, 그정도가 한계이다. 다른 요청 메서드를 만들면 이 짓을 또 해야한다.
2. 편리한 RestTemplate
즉, 우리는 동적으로 ResponseEntity와 params의 제네릭 타입을 동적으로 결정 짓고 싶다. 그럼 우리도 제네릭을 이용해서 하나의 클래스를 만들어보자!
@Slf4j
@RequiredArgsConstructor
@Service
public class ExternalApiService <T,K>{//ret, req type
private ThreadLocal<Class<T>> type = new ThreadLocal<>();
public void setType(Class<T> type) {
this.type.set(type);
}
public Class<T> getType() {
return type.get();
}
public ResponseEntity<T> makeResponse(String url, HttpMethod method, HttpHeaders headers, K body){
RestTemplate restTemplate = new RestTemplate();
HttpEntity<K> request = new HttpEntity<>(body, headers);
ResponseEntity<T> response = restTemplate.exchange(
url,
method,
request,
getType()
);
return response;
}
}
포인트는 makeResponse에서 T 타입으로 리턴 한다. T타입은 set을 통해 지정해주며 멀티 스레드를 고려하여 ThreadLocal로 감쌌다. 이 이유는 T 타입을 메서드를 통해 동적으로 줄 방식이 없었다. 물론 비어있는 리턴 객체를 만들 수 있지만,, GC에게 부담이 되니 T값을 저장할 필드를 만드는 쪽으로 결정했다. 아무튼 이 덕에 기존 코드의 params를 일일이 하드코딩하지 말고 DTO를 만들어서 처리할 수 있다!
뿐만 아니라 응답의 body 부분 또한 K타입으로 만들어 HttpEntity의 타입을 결정 지었다. 이 덕에 String을 파싱하는 작업 + 예외처리 과정을 생략할 수 있다.
아래는 카카오 로그인이 아닌 다른 예시이니 혼동하지 마세요엉.
private KISAccessToken makeTokenResponse() {
HttpHeaders headers = createHttpHeaders(null);
//url
String url = BASEURL + KISArguments.OAUTH_TOKEN_URL;
//header
headers.setContentType(MediaType.APPLICATION_JSON);
//body
GetKISToken body = new GetKISToken();
//header body 연결
externalApiService.setType(KISAccessToken.class);
ResponseEntity<KISAccessToken> responseEntity =
externalApiService.makeResponse(url, HttpMethod.POST, headers, body);
return responseEntity.getBody();
}
private HttpHeaders createHttpHeaders(String token){
HttpHeaders headers = new HttpHeaders();
headers.add(KISArguments.APP_KEY_H,KISArguments.APP_KEY);
headers.add(KISArguments.APP_SECRET_H, KISArguments.APP_SECRET);
if(token != null){
headers.add(KISArguments.AUTHORIZATION, token);
}
return headers;
}
makeToResponse 메서드를 보면 공통 headers를 만들어주는 함수를 이용. body를 GetKISToken이라는 DTO와 리턴 타입인 KISAccessToken.class를 이용하여 responseEntity를 만드는 것을 볼 수 있다. 요청의 getBody를 하면 우리는 요청 응답의 body 값을 KISAccessToken 타입으로 매핑 되어 있어서 간편하게 사용할 수 있다.
@Data
@NoArgsConstructor
@AllArgsConstructor//생성자를 통해 동적으로 필드 값을 주입 가능
@JsonIgnoreProperties(ignoreUnknown = true)
public class KISAccessToken {
private String access_token;
private String token_type;
private String access_token_token_expired;
private Long expires_in;
}
@Data
@NoArgsConstructor
public class GetKISToken {
private final String grant_type = KISArguments.GRANT_TYPE;
private final String appkey = KISArguments.APP_KEY;
private final String appsecret = KISArguments.APP_SECRET;
}
뿐만 아니라 @JsonIgnoreProperties를 통해 요청할 서버의 리턴 body 값 중 필요한 값들 만 매핑 시켜서 객체로 저장할 수 있다.
정말 편리하다!
3. RestTemplate 스레드 풀 이용하자
RestTemplate은 우리 서버가 요청할 서버와 <3번 악수 - 통신 - 4번 악수>를 해야 한다. 이 과정은 분명히 비효율 적이다. DB나 WS의 경우 이러한 점을 극복하기 위해 스레드 풀이나 커넥션 풀을 이용한다. 우리도 그렇게 하자! RestTemplate을 위한 connection pool을 제공하자는 것이다. 물론 상대 서버에서 Keep-alive를 header에 설정해 주지 않으면 사용할 수 없다.
자세한 설정은 아래 oracle 공식 링크를 참고하면 된다.
RestTemplate (Spring Framework 6.0.3 API)
postForLocation Create a new resource by POSTing the given object to the URI template, and returns the value of the Location header. This header typically indicates where the new resource is stored. URI Template variables are expanded using the given URI v
docs.spring.io
내가 활용한 정도는 지연 시간, 커넥션 수, 스레드가 사용할 수 있는 최대 커넥션 수를 지정하는 정도만 하였다. 아래 코드 처럼.
@Configuration
public class RestTemplateConfig {
@Bean
HttpClient httpClient() {
return HttpClientBuilder.create()
.setMaxConnTotal(100) //최대 오픈되는 커넥션 수
.setMaxConnPerRoute(5) //IP, 포트 1쌍에 대해 수행할 커넥션 수
.build();
}
@Bean
HttpComponentsClientHttpRequestFactory factory(HttpClient httpClient) {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setReadTimeout(5000); //읽기시간초과, ms
factory.setConnectTimeout(3000); //연결시간초과, ms
factory.setHttpClient(httpClient);
return factory;
}
@Bean
RestTemplate restTemplate(HttpComponentsClientHttpRequestFactory factory) {
return new RestTemplate(factory);
}
}
위처럼 config 클래스를 만들어주고 RestTemplate을 만들어 주자. 그러면 ExternalApiService의 new RestTempalate을 사용하지 않고 아래와 같이 진행할 수 있다.
@Slf4j
@RequiredArgsConstructor
@Service
public class ExternalApiService <T,K>{//ret, req type
private final RestTemplate restTemplate;//DI
...
public ResponseEntity<T> makeResponse(String url, HttpMethod method, HttpHeaders headers, K body){
HttpEntity<K> request = new HttpEntity<>(body, headers);
ResponseEntity<T> response = restTemplate.exchange(
url,
method,
request,
getType()
);
return response;
}
}
이 와같이 하면 handshake를 일일이 하지 않아도 되니 응답 시간이 줄어들 수 밖에 없다. 확인해야지! Jmeter로 성능 테스트도 진행했다.
- 스레드 수 100개
- Ramp-up 주기: 3초
- Loop: 100번
- aws의 EC2(t2.micro)
위 와 같이 하였을 때 아래와 같이 성능이 나왔다.
위 파란색 라인이 connection pool을 사용하지 않았을 경우, 아래 라인이 connection pool을 사용한 경우이다. 한눈에 봐도 차이가 꽤 나는 것을 볼 수 있다!
'백앤드(스프링)' 카테고리의 다른 글
스프링을 이용한 aws 파일 업로드 (2) | 2022.12.27 |
---|---|
스프링 포인트 거래 시스템 (1) | 2022.12.21 |
스프링(TDD) 테스트 코드 작성 (0) | 2022.11.07 |
AOP 2편[내부 호출과 프록시 기술의 한계, 마무리] (0) | 2022.10.30 |
AOP 2편 [포인트 컷 분리, 어드바이스 활용, 포인트컷 지시자] (0) | 2022.10.30 |