Kotlin에서 RestTemplateClient를 만들어보자
시작하기 앞서
난 코틀린도 RestTemplate도 제대로 이해하지 못한다. 물론 사용은 하고 있었으나 제대로 알고 사용하는 것과는 거리가 있었다. 그래서 기본적인 내용부터 포스팅에 담을 것이다. 누군가에게는 매우 기본적인 내용이 될 수 있으니 필요에 따라 내용을 필터링하여 읽으시길 권한다.
✅ 필요한 사전 지식
- 코틀린 기초 지식
- Spring framework
다음의 3단계로 개발을 진행한다.
1. RestTemplateClient : RestTemplate을 가지고 있는 추상 클래스
2. RestTemplateConfig : RestTemplate을 주입하는 설정파일
3. 실제 API를 호출하는 Service 혹은 Client 개발
RestTemplateClient
API를 호출하는 곳에서 쉽게 RestTemplate을 사용할 수 있도록 RestTemplateClient를 추상클래스로 만든다. Service 등에서는 이것을 상속받아서 RestTemplate을 당겨쓰면 된다. 일단 아래와 같이 먼저 만들자.
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.client.RestTemplate
// RestTemplateClient 개발 30%
abstract class RestTemplateClient {
@Autowired
lateinit var objectMapper: ObjectMapper
@Autowired
lateinit var restTemplate: RestTemplate
}
RestTemplate은 Spring 3.0부터 지원하며 스프링에서 HTTP 통신, REST API에 유용하게 쓸 수 있도록 제공해주며 템플릿이다. 현 시점에서 Spring 5.0이 이미 안정화되어 있기 때문에 사용하는데 무리가 없을 것이다. ObjectMapper를 주입받고 있는데 들어가 있는 이유는 조만간 설명하겠다.
자, 다음은 header를 세팅해보자. HttpHeaders는 header를 쉽게 만들기 위해 스프링 프레임워크에서 제공하고 있는 클래스이다. JSON 데이터로 요청할 것이기 때문에 Content-Type도 지정해준다.
// RestTemplateClient 개발 60%
abstract class RestTemplateClient {
...
fun getHeader(): HttpHeaders {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
return headers
}
}
이제부터 알아야 하는 것이 다수 등장한다. RestTemplate으로 API를 호출하는 부분인데, 본 클래스는 추상 클래스로 구현하고 있다. 그렇다면 호출부도 추상적으로 구현해야 한다. 이번에는 코드를 먼저 공개하고 설명해본다. 코드를 통해 다음과 같은 3가지를 알아 볼 것이다.
- inline 함수
- reified 키워드
- postForEntity
// RestTemplateClient 개발 100% - 끝
abstract class RestTemplateClient {
...
inline fun <reified R> post(url: String, body: Any): R? {
val httpEntity = HttpEntity(body, getHeader())
val res = restTemplate.postForEntity(url, httpEntity, CommonResponse::class.java)
return objectMapper.convertValue(res.body?.payload, R::class.java)
}
inline fun <reified R> get(url:String) : R {
val httpEntity = HttpEntity<Any>(apiHeader.getHeader())
val res = restTemplate.exchange(url, HttpMethod.GET, httpEntity, CommonModel::class.java)
return objectMapper.convertValue(res.body?.response, R::class.java)
}
}
추상화된 함수가 있다. 이 함수를 통해 원하는 API를 호출한다. 그런데 이 함수는 inline 함수이다. Kotlin 문법으로 람다식을 사용했을 때 무의미한 객체 생성을 예방할 수 있는 함수이다. 그런데 람다식을 사용하고 있지 않기 때문에 inline 함수를 쓰지 않아도 되는 것 아닌가?
이유는 reified에 있다. reified를 사용하기 위해서는 반드시 inline 함수를 사용해야 한다. reified 키워드는 Runtime에 타입 정보를 알고 싶을 때 사용한다. Generics 코드 정보는 컴파일할 때는 어떤 타입인지 알 수 있다. 즉 컴파일러는 알고 있다는 것. 하지만 컴파일하고 난 뒤에는 타입 정보가 제거되어 Runtime에는 객체만 남고 타입 정보는 알 수 없게 된다. reified 키워드는 Runtime을 하고 나서도 Generics 객체의 타입 정보를 획득하는 용도로 사용된다.
post 호출하는 함수를 추상화하려면 타입 정보도 추상화할 수 밖에 없다. 그렇다면 Generics을 사용하게 될 것이고 타입 정보를 Runtime에서도 사용하게 된다면 reified를 사용하게 된다. 만약에 이렇게 쓰고 싶지 않다면 Class 파라미터도 같이 전달해야한다. 아래는 그 예시다.
// reified를 쓰지 않을 경우
fun <T : Any> String.toKotlinObject(c: KClass<T>): T {
val mapper = jacksonObjectMapper()
return mapper.readValue(this, c.java)
}
알아봐야 할 3가지 중 마지막인 postForEntity에 대해 알아보자. postForEntity는 RestTemplate에서 제공하는 함수이다. 이 함수는 POST를 수행하고 ResponseEntity<T> 객체로 데이터를 받을 수 있다. RestTemplate에서는 주어진 URI 템플릿으로 HTTP 메서드를 실행하는 함수(postForEntity를 포함해서)를 많이 제공한다. 그 중 exchange 함수가 더 익숙한 분들도 있을 것이다. HTTP 메서드를 지정해서 요청을 보낼 수 있고 HTTP 헤더를 새로 만들 수도 있다. 이렇게 좋은 exchange를 두고 postForEntity를 사용할 이유가 따로 있을까? 결론부터 이야기하자면 성능적인 차이는 없다. exchange를 사용하면 별도로 header를 세팅하고 하나씩 HTTP 메서드를 지정해야 한다. 하나씩 세팅할 필요가 없는 경우라면 postForEntity 형태의 메소드를 쓰면 되는 것이다. 어디까지나 편의성 차이 정도로 이해하면 된다.
이제 작성한 post 메소드를 살펴보자. 먼저 HttpEntity를 생성한다. 요청과 응답을 위한 HttpHeader와 HttpBody를 포함하는 클래스로 post 함수의 두번째 파라미터에서 body를, getHeader에서 headers를 담는다. postForEntity로 API를 호출 할 차례. postForEntity는 이렇게 생겼다.
@Override
public <T> ResponseEntity<T> postForEntity(String url, @Nullable Object request,
Class<T> responseType, Object... uriVariables) throws RestClientException {
RequestCallback requestCallback = httpEntityCallback(request, responseType);
ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(responseType);
return nonNull(execute(url, HttpMethod.POST, requestCallback, responseExtractor, uriVariables));
}
위 코드를 참고하여 postForEntity에 필요한 파라미터를 담아보자. 첫번째 파라미터에는 post 함수의 url을 넣고, 두번째 파라미터에는 앞에서 만든 HttpEntity를 넣는다. 세번째는 응답받을 타입인데 별도의 Response DTO를 사용하면 된다. (본 예시의 CommonModel은 어디까지나 예시일 뿐이다.) 네번째는 URI 변수를 가변인자로 받은 것으로 이 변수로 URI 템플릿을 확장하고 싶을 때 사용한다. 가변인자는 생략이 가능하므로 불필요시 해당 파라미터를 넣지 않으면 된다.
이렇게 요청을 보내고 ResponseEntity 형태로 응답을 들어온다. (본 코드에서는 val res) ResponseEntity는 HttpEntity를 상속받아 구현한 클래스로써 HttpStatus를 포함한다. 그리고 이 응답을 Jackson으로 파싱하여 원하는 Model로 치환한다. Jackson은 JSON 데이터 구조를 처리해주는 라이브러리이다. 객체를 JSON으로 직렬화하거나 JSON 데이터를 객체로 역직렬화 할 수 있다. convertValue 함수가 Object로 들어온 데이터를 원하는 model로 처리한다. 이번 포스팅에서 자세히 다룰 내용은 아니라서 이 정도로 설명하고 넘어가겠다.
그런데 아직 post 함수를 사용할 수 없다. RestTemplate을 위한 의존성 주입이 안되어 있기 때문이다.
RestTemplateConfig
여기서 핵심은 @Configuration 어노테이션이다. 설정 클래스를 만드는 어노테이션으로, 특정 타입을 반환(return)하는 함수를 만들고 @Bean 어노테이션을 붙여주면 자동으로 해당 타입의 Bean이 생성되도록 만든다. 원래라면 이 설정 클래스를 RestTemplateClient보다 먼저 만드는게 작업하기 수월하다. 하지만 반대로 개발한다고 동작하지 않는게 아니다. 그리고 원활한 설명을 위해 설정 클래스를 나중에 만들었다.
import org.apache.http.impl.client.HttpClientBuilder
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
import org.springframework.web.client.RestTemplate
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.time.Duration
@Configuration
class RestTemplateConfig (
@Value("\${sample.book}")
val host: String
) {
@Bean
@Throws(
NoSuchAlgorithmException::class,
KeyManagementException::class
)
fun restTemplate(restTemplateBuilder: RestTemplateBuilder): RestTemplate {
val httpClient = HttpClientBuilder.create()
.setMaxConnTotal(50)
.setMaxConnPerRoute(20)
.build()
val factory = HttpComponentsClientHttpRequestFactory(httpClient)
factory.setConnectTimeout(Duration.ofSeconds(3).toMillis().toInt())
factory.setReadTimeout(Duration.ofSeconds(10).toMillis().toInt())
return restTemplateBuilder
.rootUri(host)
.requestFactory { factory }
.build()
}
}
- 환경 변수(여기서는 sample.book)에서 URI를 가지고 오도록 했다.
- 빈 등록을 위한 @Bean 어노테이션이 사용되었다.
- 코틀린에서 Java의 throws를 사용하고 싶을 때 @Throws 어노테이션을 사용하면 된다.
- 서비스단에서 사용되는 RestTemplate에 의존성 주입을 하고 싶기 때문에 반환값에 RestTemplate이 들어가는 함수를 만들었다.
빈으로 등록된 RestTemplate은 RestTemplateClient에서 @Autowired를 사용하여 의존성 주입을 하게 된다. (사용 방법은 1. RestTemplateClient 부분을 참고) Autowired 어노테이션은 3가지 방법으로 의존성 주입을 할 수 있다. 그 중 생성자를 통한 의존성 주입이 가장 권장되지만 간단하게 보여주기 위해 여기서는 필드 주입으로 구현했다. 참고로 Autowired가 빠지면 아래와 같은 에러가 나타난다.
kotlin.UninitializedPropertyAccessException: lateinit property restTemplate has not been initialized
간단하게 설명이 되는 것을 먼저 소개해보았다. 보충이 필요한 부분을 더 설명해보겠다. HttpClient는 HTTP를 사용하여 통신하는 범용 라이브러리이고, RestTemplate은 HttpClient 를 추상화(HttpEntity의 json, xml 등)해서 제공해준다. HttpClient는 이름에서 알 수 있듯이 Client Side 동작을 구현한 라이브러리로 위 코드에서 볼 수 있듯이 연결 제한 등을 제어할 수 있다. 위에서 체이닝된 옵션에 대해 간단히 설명을 하자면 아래와 같다.
- setMaxConnTotal : 최대 오픈되는 커넥션 수를 제한한다.
- setMaxConnPerRoute : IP,포트 1쌍에 대해 수행 할 연결 수를 제한한다.
여기에 추가로 더 많은 속성을 지원하고 싶다면 HttpComponentsClientHttpRequestFactory를 사용하면 된다. RestTemplate은 기본 생성자 외에 ClientHttpRequestFactory 인터페이스를 받는 생성자가 있다. 별도 지정이 없다면 SimpleClientHttpRequestFactory 구현체를 사용한다. 여기서는 HttpComponentsClientHttpRequestFactory 구현체를 사용하였다. SimpleClientHttpRequestFactory에는 없는 Connection pool 설정이 가능하다. 위 코드에 나와 있는 setConnectTimeout 함수를 비교해보자.
// HttpComponentsClientHttpRequestFactory
public void setConnectTimeout(int timeout) {
Assert.isTrue(timeout >= 0, "Timeout must be a non-negative value");
this.requestConfig = requestConfigBuilder().setConnectTimeout(timeout).build();
}
// SimpleClientHttpRequestFactory
public void setConnectTimeout(int connectTimeout) {
this.connectTimeout = connectTimeout;
}
SimpleClientHttpRequestFactory은 별도로 제공되는 prepareConnection 함수에서 전역으로 받은 connectTimeout 변수를 체크하지만 HttpComponentsClientHttpRequestFactory에서는 setConnectTimeout 함수 자체에서 체크를 한다. 단순한 예이지만 이런 점에서 HttpComponentsClientHttpRequestFactory를 사용하는게 아닐까 싶다. (확실하지 않음)
마지막으로 restTemplateBuilder에 factory를 담고 build를 하면 우리가 원하는 RestTemplate를 주입할 준비가 된 것이다.
구현부
자... 이제 마지막이다. 서비스단에서 RestTemplateClient를 상속받고 원하는 서비스/함수를 만든다. 아래 예시에서는 Book 정보를 조회하기 위한 Service이다.
@Service
class BookService: RestTemplateClient() {
fun getBook(bookId : Long) : BookModel? {
val uri = "${url.book}/$bookId"
return get(uri)
}
}
그리고 아래는 위 서비스를 테스트하기 위한 테스트 코드이다.
import com.sample.BookService
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class BookServiceTest {
@Autowired
lateinit var bookService: BookService
@Test
fun testBook() {
bookService.getBook(1)
}
}
환경변수로 넣었던 값을 아무것이나 넣었더니 아래와 같이 응답이 왔다.
org.springframework.web.client.HttpClientErrorException$NotFound: 404 Not Found
잘못된 접근이라는 응답이 왔다. 응답이 있다는 것. 즉, API에 접근하기 위한 프로세스는 정상적으로 진행되었다는 뜻이다. 이제 정확한 URL과 파라미터를 입력하면 정상적인 API 요청/응답을 확인할 수 있을 것이다.
👾 Error
테스트를 하는 동안 이상한 점도 발견하였다. 서비스단에서 uri를 입력하는 부분에서 빈(Empty) String을 입력하면 아래와 같이 나타난다. 해당 메세지는 get("sample")과 같이 일반 String 값을 입력해도 동일하다.
fun getBook(bookId : Long) : BookModel? {
val uri = "${url.book}/$bookId"
return get("")
}
// 응답
org.springframework.web.client.ResourceAccessException:
I/O error on GET request for "":
null; nested exception is org.apache.http.client.ClientProtocolException
또한 아래처럼 "/"만 입력하면 Content-Type 이슈가 등장한다.
fun getBook(bookId : Long) : BookModel? {
val uri = "${url.book}/$bookId"
return get("/")
}
// 응답
org.springframework.web.client.UnknownContentTypeException:
Could not extract response: no suitable HttpMessageConverter found for response type [class com.sample.api.CommonModel] and content type [text/html;charset=UTF-8]
둘 모두 정확한 path 양식이 맞지 않아서 생기는 이슈다. "/sample"처럼 슬래쉬를 포함하여 String을 입력해야 원하는 응답을 확인할 수 있다.
안드로이드 개발자로 시작해서인지 안드로이드에서 주로 사용하는 HTTP 통신 라이브러리인 Retrofit과 같은 역할을 스프링에서는 무엇으로 하는지 궁금했다. RestTemplate가 그런 역할을 하고 있음을 알 수 있었다. 넷플릭스에서 개발한 Http client binder인 Feign이라는 것도 있다. 언젠가 이것에 대해서도 다루어 보겠다.
📚 참고자료
- Kotlin inline, reified 알아보기
- Kotlin syntax
- 코틀린에서 reified는 왜 쓸까?
- RestTemplate (RestTemplate 기초, RestTemplate으로 카카오 API 호출하기)
- RestTemplate: exchange() vs postForEntity() vs execute()
- RestTemplate (정의, 특징, URLConnection, HttpClient, 동작원리, 사용법, connection pool 적용)