Android에서 Retrofit은 왜 사용하는 걸까
DoDoBest
·2024. 2. 8. 14:04
REST란 무엇인가
요즘의 웹 서버들은 REST라고 불리는 stateless web architecture를 이용해서 서비스를 제공한다. REST는 REpresentational State Transfer의 약자로, REST 아키텍처는 다음과 같은 원칙을 준수해야 한다.
- 일관된 인터페이스(uniform Interface)
- 서버가 전송하는 정보는 표준적인 형태여야 하고 일관된 형태여야 된다.
- 클라이언트는 정보를 요청할 때, URI 형태를 이용한다. - 무상태(stateless)
- 각 요청은 독립적이어야 한다. 서버는 클라이언트로부터의 요청을 처리하기 위해 과거에 그 클라이언트가 어떤 요청을 했는지를 알 필요가 없다.
- 서비스 수준에서 state를 쓸 수 있으나, 서버가 이 state를 알 필요가 없다. 클라이언트는 어떤 서버와 통신할 지 알 수 없다.
Q. 그러면 서버는 어떻게 State를 알 수 있을까?
A. Client는 State를 복원할 수 있는 Token을 요청과 함께 계속 전송해야 한다. - 계층 시스템(Layered System)
- 클라이언트가 반드시 정보를 제공하는 서버와 직접 통신을 할 필요는 없다. 적절한 권한을 가진 구성 요소가 클라이언트와 대상 서버 사이에 위치해서 대상 서버를 대리할 수 있다.
ex) 클라이언트가 로그인 서버에 이미지를 요청하면, 로그인 서버는 이미지 서버에 요청을 전달한다. - 캐싱 가능성(Cacheability)
- 캐싱 가능한 정보에 대해서는 서버가 응답을 보내올 때 응답을 캐싱할 수 있어야 된다.
- 이는 같은 요청이 매번 서버에 가는 것을 방지함으로써 응답 시간을 개선한다.
Cache : 은닉처, 있는지 없는지 상관없이 동작해야 한다. - 필요에 따른 클라이언트의 기능 확장(Code on Demand)
- 서버는 클라이언트의 기능을 확장할 수 있는 코드를 클라이언트에 전달할 수 있다.
ex) JS 코드 전송
RESTful API 요청에는 다음과 같은 내용이 담겨 있다.
- 리소스를 식별하기 위한 고유 식별자
- 대개 URL(Uniform Resource Locator) 표기를 이용. URL은 리소스의 위치를 의미한다. - HTTP 메소드 (CRUD; Create Read Update Delete)
- POST : 서버에 새로운 데이터 추가
- GET : 서버에 존재하는 리소스 가져오기
- PUT : 서버에 존재하는 리소스 업데이트 하기
- DELETE : 서버에 존재하는 리소스 삭제하기 - 기타 인증에 필요한 HTTP 헤더 정보
ex) 인증 쿠키
RESTful API 응답으로 다음과 같은 내용이 담겨 있다.
- HTTP 응답라인
- 2XX : 성공
ex) 200(OK)
- 3XX : client를 다른 곳으로 redirection
- 4XX : 실패
ex) 400(Bad Request, 요청 파라미터 누락), 403(Forbidden, 허용되지 않은 사용자의 접근), 404(Not Found, URL에 해당하는 리소스 없음)
- 5XX : Internal server error와 같은 내부 문제 - 응답 몸통(body)
- 보통 JSON(JavScript Object Notation) 문자열이나 XML(eXtensible Markup Language)
- Protocl Buffer도 있다(서버 간에 IPC 통신을 할 때 주로 사용 된다) - 기타 필요한 HTTP 헤더 정보
- 응답 몸통 데이터 타입, 응답의 문자열 인코딩 정보 등
ex) UTF-8로 읽어야 하는가?
Retrofit은 무엇인가
Retrofit은 Android와 Java를 위한 type-safe HTTP client다.
REST API 요청을 직접하면 어떻게 되는가
네이버 파파고 번역 API를 예시로 들어보자. cmd에서 실행할 수 있는 API 명세를 만족하는 요청 코드는 아래와 같다.
curl "https://openapi.naver.com/v1/papago/n2mt" \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
-H "X-Naver-Client-Id: {클라이언트 아이디 값}" \
-H "X-Naver-Client-Secret: {시크릿 값}" \
-d "source=en&target=zh-CN&text=Hello World." -v
이에 대한 응답 결과는 아래와 같다.
Kotlin으로 동작하는 코드는 아래와 같다. API 명세 구현 예제의 Java 코드를 Kotlin으로 변환했다.
import java.io.*
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
import java.net.URLEncoder
fun main() {
val clientId = "YOUR_CLIENT_ID" //애플리케이션 클라이언트 아이디값";
val clientSecret = "YOUR_CLIENT_SECRET" //애플리케이션 클라이언트 시크릿값";
val apiURL = "https://openapi.naver.com/v1/papago/n2mt"
val text = try {
URLEncoder.encode("Hello World.", "UTF-8")
} catch (e: UnsupportedEncodingException) {
throw RuntimeException("인코딩 실패", e)
}
val requestHeaders = hashMapOf<String, String>()
requestHeaders["X-Naver-Client-Id"] = clientId
requestHeaders["X-Naver-Client-Secret"] = clientSecret
val responseBody = post(apiURL, requestHeaders, text)
println(responseBody)
}
private fun post(apiUrl: String, requestHeaders: Map<String, String>, text: String): String {
val con = connect(apiUrl)
val postParams = "source=en&target=zh-CN&text=$text" //원본언어: 영어 (en) -> 목적언어: 중국어 (zh-CN)
return try {
con.setRequestMethod("POST")
for ((key, value) in requestHeaders.entries) {
con.setRequestProperty(key, value)
}
con.setDoOutput(true)
DataOutputStream(con.outputStream).use { wr ->
wr.write(postParams.toByteArray())
wr.flush()
}
val responseCode = con.getResponseCode()
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 응답
readBody(con.inputStream)
} else { // 에러 응답
readBody(con.errorStream)
}
} catch (e: IOException) {
throw RuntimeException("API 요청과 응답 실패", e)
} finally {
con.disconnect()
}
}
private fun connect(apiUrl: String): HttpURLConnection {
return try {
val url = URL(apiUrl)
url.openConnection() as HttpURLConnection
} catch (e: MalformedURLException) {
throw RuntimeException("API URL이 잘못되었습니다. : $apiUrl", e)
} catch (e: IOException) {
throw RuntimeException("연결이 실패했습니다. : $apiUrl", e)
}
}
private fun readBody(body: InputStream): String {
val streamReader = InputStreamReader(body)
try {
BufferedReader(streamReader).use { lineReader ->
val responseBody = StringBuilder()
var line = lineReader.readLine()
while (line != null) {
responseBody.append(line)
line = lineReader.readLine()
}
return responseBody.toString()
}
} catch (e: IOException) {
throw RuntimeException("API 응답을 읽는데 실패했습니다.", e)
}
}
https://developers.naver.com/docs/papago/papago-nmt-api-reference.md
다시 돌아와서 Retrofit을 왜 사용할까?
응답 결과를 단순히 String으로 변환하고, Exception을 대응하지 않고, 한 개의 API에 대한 요청만 작성하는 것으로도 약 80줄이 사용되었다. 파파고 뿐만 아니라 네이버 이미지 캡챠 API도 사용하도록 하려면 어떻게 될까? 응답 결과를 내가 사용하려는 data class로 변환하려면 어떻게 될까?
이러한 작업을 직접 구현하려면 시간이 많이 걸린다. 그래서 이러한 작업을 대신 해주는 Retrofit 라이브러리를 사용하는 것이다.
어떻게 사용하는 걸까?
모듈 단위의 build.gralde에 의존성을 추가한다.
implementation("com.squareup.retrofit2:retrofit:2.9.0")
1. XxxApiService.kt interface 파일을 생성한다.
2. 파일 내부에 API 명세의 기본 URL을 BASE_URL 변수로 설정한다.
3. Retrofit 객체를 생성하기 위한 Retrofit Builder를 만든다.
Retrofit Builder는 Retrofit 객체를 생성하는데 필요한 설정을 하는 객체입니다.
Retrofit Builder의 build 함수를 호출하기 전까지 Retrofit은 생성되지 않습니다.
package com.dothebestmayb.practiceretrofit.data.network
import retrofit2.Retrofit
interface NaverApiService {
companion object {
private const val BASE_URL = "https://openapi.naver.com/v1/papago/n2mt"
fun create(): NaverApiService {
val retrofitBuilder = Retrofit.Builder()
}
}
}
앞선 Kotlin 예제에서, API를 통해 받아온 데이터를 readBody 함수 내부에서 String으로 변환했습니다. Retrofit Builder는 API를 통해 받아온 데이터를 어떻게 변환할지 addConverterFactory 함수의 파라미터를 통해 입력 받습니다.
ConverterFactory로 무엇을 전달할까?
Gson과 Moshi를 주로 사용합니다. 둘 중 무엇을 사용해야 할까요?
Gson은 Java Object를 JSON, JSON string을 그에 상응하는 Java Object로 변환해주는 Java 라이브러리라고 소개하고 있습니다.
Gson is a Java library that can be used to convert Java Objects into their JSON representation. It can also be used to convert a JSON string to an equivalent Java object. Gson can work with arbitrary Java objects including pre-existing objects that you do not have source-code of.
https://github.com/google/gson
Gson은 자바기반 Reflection을 사용하기 때문에 Kotlin에서 지원하는 default를 사용할 수 없습니다. 또한 Reflection을 사용하면 런타임에 로드된 다른 클래스 정보들을 가져올 수 있는 장점이 있지만, 이것은 비용이 큰 작업이고 App의 퍼포먼스 저하로 이어집니다.
Moshi는 JSON을 Java나 Kotlin 클래스로 변환하는 작업을 쉽게 해주는 Android를 위한 라이브러리라고 소개하고 있습니다. 또한 Kotlin의 non-null type과 default parameter를 지원합니다.(단, Moshi는 reflection을 사용하지 않기 때문에 moshi-kotlin 의존성을 추가해 Kotlin용 adapter KotlinJsonAdapterFactory를 사용해야 한다.)
Moshi is a modern JSON library for Android, Java and Kotlin. It makes it easy to parse JSON into Java and Kotlin classes:
...
Kotlin’s non-nullable types and default parameter values
https://github.com/square/moshi
그렇다면 둘 중 무엇을 사용해야 할까요?
Gson은 더 이상의 새로운 기능은 추가하지 않고, 기존의 버그만 수정하겠다고 되어 있습니다.
Gson과 Moshi의 contributor를 살펴보면 두 프로젝트에서 겹치는 개발자 분들이 있습니다.
두 라이브러리 개발에 모두 참여하신 JakeWharton 님은 Gson 대신 Moshi 나 Kotlinx.serialization 사용을 권장했었습니다.
Gson과 Moshi 둘 다 높은 기여를 하신 Jake 형님의 의견을 차용하면 Moshi는 사실상 Gson의 v3과 동일하다고 합니다. (단지 네이밍 치아일 뿐이라고 하네요!) Jake 본인은 멀티플랫폼 지원 여부 때문에 kotlinx.serialization을 사용한다고 밝혔습니다. Moshi의 장점은 Gson 보다 훨씬 pluggable한 확장성을 지원하기 때문에 (KSP 및 Kotin IR https://github.com/ZacSweers/MoshiX/tree/main/moshi-ir), 전반적으로 런타임 퍼포먼스도 reflection을 사용하는 Gson 보다 우월합니다.
- GDG korea 재웅 님 답변
Moshi vs Kotlinx.Serialization
둘 중 무엇을 사용해야 할까요? 레딧에서 kotlinx.serialization을 왜 사용해야 하는지에 대한 질문을 찾아봤습니다. 내용을 요약하면 Jackson(Moshi와 같은 JSON converter, https://github.com/FasterXML/jackson-core)을 사용할 때는 문제가 없었으나, kotlinx.serialization을 사용하니 UUID, ZonedDateTime(DateTime 뿐만 아니라 Time zone을 표시하는 타입)와 같이 지원하지 않는 타입이 많아 직접 serialize 코드를 작성해야 해서 불편했다고 합니다.
이에 대해 JakeWharton 님은 아래와 같이 답변하셨습니다. Jackson은 모든 상황에 최선의 성능을 제공하기 위해 과도하게(everything but the kitchen sink: 필요 이상으로, 하나에서 열까지 모두) 용량이 커진 라이브러리 같다고 합니다. Gson과 Moshi는 성능과 용량 사이의 밸런스를 추구하는 라이브러리라고 합니다.
kotlinx.serialization이 다른 라이브러리와 같이 다양한 타입의 변환을 지원하지 않는 이유는 Reflection을 사용하지 않아서 라고 합니다. reflection에 기반한 serialize, deserialize는 성능에 영향을 끼치는데, 성능이 그렇게 중요하지 않다면 kotlinx.serialization가 아닌 다른 라이브러리를 사용해도 괜찮다고 하네요.
제가 개발하는 수준에서는 reflection에 의한 성능 차이가 중요하지 않습니다. 그래서 custom serialization 코드를 작성하지 않아도 되는 Moshi를 선택했습니다.
Reflection이 무엇인지는 학습 후에 별도의 글로 작성하겠습니다.
ConverterFactory로 Moshi를 사용합시다
Retrofit에서 사용하기 위한 converter-moshi 의존성을 추가합니다.
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
최신 버전은 Maven Central에서 확인할 수 있습니다.
https://central.sonatype.com/artifact/com.squareup.retrofit2/converter-moshi
baseUrl을 설정하고, converterFactory를 설정합니다. build 함수를 통해 retrofit을 생성하고, create 함수를 통해 API service 인터페이스의 객체를 생성합니다.
package com.dothebestmayb.practiceretrofit.data.network
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
interface NaverApiService {
companion object {
private const val BASE_URL = "https://openapi.naver.com/v1/"
fun create(): NaverApiService {
val retrofitBuilder = Retrofit.Builder()
return retrofitBuilder
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(NaverApiService::class.java)
}
}
}
왜 API service를 interface로 선언하는 걸까?
retrofit의 create 내부 구현을 살펴보겠습니다. 맨 처음 호출되는 validateServiceInterface 함수 내부로 가보겠습니다.
전달 받은 service가 Interface로 선언되어 있는지 확인하고 있습니다. Interface가 아니라면 Exception을 던집니다.
그 다음으로 Interface가 Type Parameter를 사용하고 있는지 검사합니다. Inteface에서 제네릭과 같은 타입이 사용된다면 Exception을 던집니다.
interface SomeInterface<E> {
fun add(element: E)
}
마지막으로 Interface에 선언된 함수의 Request Type(POST, GET, UPDATE, DELETE), 함수 이름, annotation, 파라미터가 올바른지 확인하고 ServiceMethod 객체에 담아 결과로 반환합니다. 지금은 검증하는 과정이기 때문에 반환된 결괏값을 호출한 곳에서 사용하지 않습니다. 어디서 사용하는지는 아래에서 설명하겠습니다.
중복된 탐색을 막기 위해 serviceMethodCache map 변수에 파싱한 결과를 저장해둡니다.
validateServiceInterface 호출로부터 시작된 지금까지의 작업은 interface가 객체를 생성하는 규칙을 준수했는지 확인하는 과정이였습니다.
이제 create 함수로 다시 돌아가보겠습니다. Proxy를 이용해서 interface를 기반으로한 구현 객체를 런타임에 생성합니다. 아래 invoke 함수의 return 부분을 보면 loadServiceMethod가 호출되는 것을 볼 수 있습니다. 아까 검증 과정에서 사용되지 않았던 결괏값이 여기서 사용됩니다.
지금까지 Retrofit에서 Interface로부터 객체가 어떻게 생성되는지 알아봤습니다.
Proxy는 Java Reflect와 관련된 개념으로, 학습 후에 별도의 게시물로 정리하겠습니다.
다음 게시물에서는 Reflection과 Generic에 대해 알아보고, Proxy가 어떻게 객체를 생성하는지 살펴보겠습니다.
또한 Retrofit으로 받아온 응답을 데이터 클래스로 변환하기 위한 과정을 알아보겠습니다.
참고자료
https://medium.com/mindorks/understand-how-does-retrofit-work-c9e264131f4a
https://developer.android.com/codelabs/basic-android-kotlin-compose-getting-data-internet
'학습' 카테고리의 다른 글
for .. in 은 무엇일까 (0) | 2024.03.08 |
---|---|
Android에서 ConstraintLayout은 왜 사용하는 걸까 (0) | 2024.03.03 |
Android에서 View는 어떻게 그려질까? - 1 (0) | 2024.02.28 |
RecyclerView 공백 ViewHolder 문제 (0) | 2024.02.14 |
Android popUpTo가 동작하지 않는 경우 (0) | 2023.11.13 |