[Android] Retrofit

Minji Jeong·2022년 4월 30일
1

Android

목록 보기
7/39
post-thumbnail

Retrofit

안드로이드 개발을 하다보면 서버와 통신해야 할 때가 굉장히 많다. 예전에는 AsyncTask를 사용했지만, AsyncTask는 예전에 deprecated 되어버렸고 이제는 그 역할을 Retrofit이 대체하게 되었다. 나는 Open API를 제공하는 서버로부터 데이터를 얻기 위해, 그리고 회원가입 및 로그인을 구현하는 과정에서 Retrofit을 사용했다. 하지만 아직도 에러가 나면 정신을 못차리고 있기 때문에 🤣 다시 제대로 공부해볼 겸 블로그에 글을 쓰기로 했다.

먼저 Retrofit 이란,
서버와 클라이언트 간 http 통신을 위한 라이브러리로, OkHttp 라이브러리를 기반으로 하고 있다.
Retrofit은 HTTP 작업을 수행하기 위해 OkHttp에서 지원하는 HTTP/2와 SPDY, 인터셉터 등을 사용한다.

Retrofit을 사용하면 REST 기반의 웹 서비스를 통해 JSON 구조의 데이터를 쉽게 가져오고 업로드 할 수 있다.
그러니까 안드로이드 앱에서 필요한 데이터를 서버로부터 가져오고, 서버에 데이터를 전송하기 위한 코드를 작성할 때 사용한다. 기존에 사용했던 AsyncTask로 서버와의 통신을 구현하는 것은 어렵고 시간이 많이 소요되는 반면, retrofit은 가독성이 좋으며 간편하게 사용할 수 있다는 장점이 있다.

retrofit을 사용하기 위해서는 다음 세 가지의 클래스가 필요하다.

Data class
✔ Http 작업을 정의하는 Interface
✔ Retrofit.Builder를 선언한 Object

1. build.gradle(Module)에 retrofit 라이브러리에 대한 종속 항목 추가

implementation 'com.squareup.retrofit2:retrofit:[version]'

// 응답 결과가 JSON일 때 객체로 변환해줌
implementation 'com.squareup.retrofit2:converter-gson:[version]'

2. 인터넷 접속 권한을 얻기 위해 Manifest.xml에 android.permission.INTERNET 추가

만약 통신하고자 하는 사이트가 http로 시작한다면 application 태그 내부에 android:usesCleartextTraffic="true" 속성을 추가해서 모든 http url에 대해서 접근을 허용해야한다(난 이걸 추가 안해줘서 몇 번의 착오를 겪었다...).

<uses-permission android:name="android.permission.INTERNET"/>

<application
        ...
        android:usesCleartextTraffic="true">
</application>

💡 http vs https ?

http
서버/클라이언트 모델을 따라 데이터를 주고 받기 위한 프로토콜이다. 암호화가 되지 않은 평문 데이터를 전송하는 프로토콜이기 때문에 보안에 취약하다.

https
기존 http에 데이터 암호화가 추가된 프로토콜로, 보안에 취약한 http의 단점을 보완했다.

먼저 나는 GET과 POST 요청 방식을 사용했기 때문에, 실습 부분은 Retrofit에서의 GET과 POST방식에 대해 쓰고자 한다. GET POST 말고도 정보를 수정하거나 삭제하는 요청 방식이 있는데, http method(POST, GET, PUT, PATCH, DELETE)를 사용하여 서버에 원하는 작업을 요청할 수 있다.

GET
단순 정보 조회를 위해 사용한다.
POST
서버에 데이터를 생성하기 위해 사용한다.
PUT
서버 내의 데이터를 업데이트하기 위해 사용한다.
PATCH
PUT과 마찬가지로 데이터를 업데이트하기 위해 사용하는데, PUT은 전체 데이터를 교체하는 반면 PATCH는 데이터의 일부만 업데이트하는 데 사용한다.
DELETE
서버 내의 데이터를 삭제하기 위해 사용한다.

나는 식단일기 어플을 제작하기 위해 OPEN API를 사용했고, 음식 데이터를 단순히 조회해야 했기 때문에 이 과정에서 GET 요청방식을 사용했다. 또한 사용자 로그인을 구현하기 위해 이 과정에서 POST 요청방식을 사용했다. 먼저 OPEN API를 제공하는 서버로부터 어떻게 데이터를 가져오는지부터 알아보자.

나는 식단일기 어플을 제작하기 위해 <식품안전나라>의 식품영양성분DB API를 사용해서 음식 데이터(음식명 + 칼로리)를 가져와야했다. 보통 API 소개 페이지에서는 요청주소와 필수요청인자들에 대해 상세하게 설명해놓아서 매우 편리하다.🤣 먼저 요청주소에 대해 살펴보자.

기본적인 요청주소는 "openapi.foodsafetykorea.go.kr/api/keyId/serviceId/dataType/startIdx/endIdx" 다. keyId는 API를 신청하고 발급받은 키를 사용하면되고, serviceId를 해당 API의 서비스 ID, dataType은 XML형식으로 데이터를 발급받을 것인지 JSON 형식으로 데이터를 발급받을 것인지, startIdx와 endIdx는 발급받을 첫 데이터의 인덱스와 마지막 데이터의 인덱스(그러니까 발급받을 데이터 개수)를 입력하면 된다.

다음으로 요청인자를 확인해보자.

여기서 "필수"라고 명시되어 있는 변수들은 무조건 요청인자로 보내야한다. 나는 JSON 형태로 데이터를 제공받고 싶었고, 1000개의 데이터를 필요로 했기 때문에 요청주소를 다음과 같이 작성했다(더 많은 데이터를 가져오고 싶었으나 일일 API 호출제한이 1000개였다🤣).

openapi.foodsafetykorea.go.kr/api/발급받은 keyId/I2790/json/1/1000

위 주소로 요청을 하고, 위 주소로부터 식품명과 칼로리 데이터를 제공받아야 했다. 어떻게 식품명과 칼로리 데이터만 제공받을 수 있을까? 일단 식품명과 칼로리 변수명은 다음과 같았다.

DESC_KOR -> 식품명
NUTR_CONT1 -> 열량(kcal)(1회제공량당)

JSON 형태로 제공받을 시 식품영양DB의 기본적인 데이터 구조는 어떤지 살펴봤다(나는 XML보다 JSON이 더 보기 편해서 JSON 형태로 요청했다).

{"I2790":{
	"total_count":"90608",
	"row":[
		{   "NUTR_CONT8":"1.9",
		    "NUTR_CONT9":"0.1",
			"NUTR_CONT4":"8.5",
			"NUTR_CONT5":"16.9",
			"NUTR_CONT6":"1264.31",
			"NUM":"1",
			"NUTR_CONT7":"106.18",
			"NUTR_CONT1":"368.8",
			"NUTR_CONT2":"39.7",
			"SUB_REF_NAME":"식약처(\u002716) 제4권",
			"NUTR_CONT3":"33.5",
			"RESEARCH_YEAR":"2019",
			"MAKER_NAME":"",
			"GROUP_NAME":"",
			"SERVING_SIZE":"500",
			"SAMPLING_REGION_NAME":"충주",
			"SAMPLING_MONTH_CD":"AVG",
			"SAMPLING_MONTH_NAME":"평균",
			"DESC_KOR":"꿩불고기",
			"SAMPLING_REGION_CD":"94",
			"FOOD_CD":"D000006"},
	}],
	"RESULT":{
		"MSG":"정상처리되었습니다.",
		"CODE":"INFO-000"
	}
}}
데이터 개수가 무려 9만개다..

먼저 전체적인 데이터를 감싸고 있는 오브젝트가 I2790(식품영양DB의 서비스 ID)이다. 그 내부에 total_count와 row, RESULT 오브젝트가 있고, row 내부에 내가 필요로 하는 데이터들인 DESC_KOR, NUTR_CONT1이 있다. 데이터구조를 그림으로 표현하면 다음과 같다.

이 구조를 참고해서 row 내부에 있는 DESC_KOR과 NUTR_CONT1를 가져오기 위한 데이터 클래스를 생성해야한다.

3. 데이터 클래스 생성

//응답받을 리소스의 구조를 토대로 작성해야함
data class FoodList(
    @SerializedName("I2790") val list:FoodDto
)

data class FoodDto(
    @SerializedName("row") val food:List<FoodItem>
)

//내가 갖고와야하는 데이터는 DESC_KOR, NUTR_CONT1
data class FoodItem(
    @SerializedName("DESC_KOR") val foodName:String,
    @SerializedName("NUTR_CONT1") val kcal:String
)

...
data class LoginData(
    @SerializedName("id") val user_id: String,
    @SerializedName("pw") val user_pw: String
)

@SerializedName
Gson converter가 JSON에서 정해진 이름을 개발자가 정의한 이름으로 매핑해주기 위한 어노테이션이다.

4. 인터페이스 정의

다음으로 인터페이스를 정의해야하는데, 원하는 요청 방식에 따라서 작성해준다. 먼저 단순하게 데이터를 가져오는 것에는 GET 어노테이션을 사용하면 된다.

interface FoodService {
    @GET("api/{keyId}/{serviceId}/{dataType}/1/1000")
    fun getFoodName(
    	@Path("keyId") val keyId : String,
        @Path("serviceId") val serviceId : String,
        @Path("dataType") val dataType : String
    ):Call<FoodList> 
}

로그인 구현에는 POST를 사용했다. 요청인자들은 엔드포인트 뒤에 작성해도 되지만, 나는 POST 사용 시 @Body 어노테이션을 사용해 요청인자를 보냈다.

interface LoginService {
    @POST("api/login")
    fun login(@Body request: LoginData): Call<Void>
}
// @Field 어노테이션 사용 시
interface LoginService {
    @POST("api/login")
    @FormUrlEncoded
    fun login(
        @Field("id") user_id: String,
        @Field("pw") user_pw: String
    ): Call<Void>
}

요청인자는 필요에 따라 @Field, @Body, @Query, @Path 어노테이션을 사용해서 보낼 수 있다.

@Field, @Body, @Query, @Path

@Field : @FormUrlEncoded 어노테이션을 사용해서 파라미터를 전달하는데, key=value&key=value의 형태로 전달된다.
@Body :JSON 형태의 하나의 객체만 전달한다(key: value, key: value).
@Query : Query Parameter를 명시한다.
@Path : Path Parameter를 명시한다.

💡 Query Parameter?

URL 뒤에 전달되어야 하는 key-value 형식의 파라미터다. URL 끝에는 '?'가 추가되는데, ? 기호는 경로 및 Query parameter를 구분하는 기준이 된다. 또한 여러 Query parameter를 추가할 때, '&' 기호를 파라미터 사이에 배치하는 방식으로 추가할 수 있다.

GET /users?id=123&pw=*** 
// @Query 어노테이션 사용
@GET("user")
fun getUserOuth(
    @Query("id") user_id: String, 	
    @Query("pw") user_pw: String
):Call<UserCredentail> 

💡 Path Parameter?

Query parameter와 다르게 경로 자체를 변수로써 사용한다.

GET /users/123
// @Path 어노테이션 사용
@GET("user/{id}/{pw}")
fun getUserOuth(
	@Path("id") user_id: String, 
    @Path("pw") user_pw: String
):Call<UserCredentail>

Query parameter와 Path variable은 사용 목적에서 차이가 있다. Query parameter는 리소스를 정렬하거나 필터링 할 때 사용하지만, Path variable는 특정 리소스를 식별하는데 사용된다. 또한 query parameter의 경우 URL 끝에 추가되므로 null 값을 전달해도 문제가 없지만, path variable의 경우 값 자체가 URL의 일부이므로 null 값을 전달할 수 없다. null값이 전달된다면 404 에러가 발생할 것이다.

GET 및 POST 어노테이션의 ()안에는 요청주소의 엔드포인트를 작성해야한다.
http:// openapi.foodsafetykorea.go.kr/api/key/I2790/json/1/5...
http:// minjisite.go.kr/api/login
빨간색 - Base URL
파란색 - End Point

5. Retrofit 객체 생성
다음으로 Retrofit 객체를 생성해준다. 서버 호출이 필요할 때마다 객체를 생성하는 것은 비효율적이기 때문에 Object로 생성했다. 만약 서버 요청시 응답받는 데까지 시간이 조금 걸린다면 OkHttpClient의 connectTimeout()을 호출하여 최대 요청시간을 정의해 줄 수도 있다.

object FoodClient { //Singleton

   //서버 요청시간 정의
   val okHttpClient= OkHttpClient.Builder()
        .connectTimeout(120, TimeUnit.SECONDS)
        .readTimeout(120, TimeUnit.SECONDS)
        .writeTimeout(120, TimeUnit.SECONDS)
        .build()

    var gson= GsonBuilder().setLenient().create()

    val retrofit= Retrofit.Builder()
        .baseUrl("base_url")
        .addConverterFactory(GsonConverterFactory.create(gson))
        .client(okHttpClient)
        .build()

    val foodService : FoodService by lazy { retrofit.create(FoodService::class.java) }
    val loginService : LoginService by lazy { retrofit.create(LoginService::class.java) }
}

addConvertFactory()
JSON은 자바나 코틀린에서 바로 사용할 수 있는 데이터 형식이 아니기 때문에 이를 변환해주기 위해 데이터를 파싱할 컨버터를 사용해아한다.

6. HTTP 요청과 응답
이제 Retrofit 객체와 인터페이스를 사용해 서버와 통신해보자. Retrofit은 enqueue(비동기), execute(동기)의 2가지 통신 방식을 지원하는데, 나는 enqueue를 사용했다. 통신에 성공했을 경우엔 onResponse(), 통신에 실패했다면 onFailure()가 호출된다.

통신에 성공한 뒤 정상적으로 응답을 받으면 reponse.body에 내가 요청했던 데이터가 들어오고, 어떻게 연결은 했으나 잘못된 요청인자를 보냈거나 주소를 잘못 작성하는 등의 실수로 에러가 발생한다면 if(response.isSuccessful.not()) 구문이 실행된다. 에러 메세지를 확인하고 싶다면 응답받은 response를 문자열로 변환해서 로그에 찍어보자.

통신에 실패했을 때도 마찬가지다. 통신 실패 시 원인을 알고 싶다면 Throwable을 문자열로 변환해서 로그에 찍어보자.

private fun getFoodName(){
	 NetworkClient.FoodService.getFoodName()
     .enqueue(object: Callback<LoginData> {
     	override fun onResponse(call: Call<LoginData>, response: Response<LoginData>){
			if (response.isSuccessful.not()){
            	Log.e(TAG, response.toString())
                return
            }else{
				response.body()?.let{
       			...
                }
            }
       override fun onFailure(call: Call<LoginData>, t: Throwable){
       		Log.e(TAG, "연결 실패")
            Log.e(TAG, t.toString())
       }
   })
}

private fun login(id: String, pw: String){
	 val data = LoginData(id, pw) 
	 NetworkClient.loginService.login(data)
     .enqueue(object: Callback<Void> {
     	override fun onResponse(call: Call<Void>, response: Response<Void>){
			if (response.isSuccessful.not()){
            	Log.e(TAG, response.toString())
                return
            }else{
               	Log.e(TAG, "로그인 성공")
             }
       }
       override fun onFailure(call: Call<Void>, t: Throwable){
       		Log.e(TAG, "연결 실패")
            Log.e(TAG, t.toString())
       }
   })
}

통신에 성공했다면 응답받은 body를 사용해 원하는 작업들을 해주면 된다!

References

https://stackoverflow.com/questions/46530247/android-studio-no-network-security-config-specified
https://jhkimmm.tistory.com/m/16
https://kimch3617.tistory.com/10
https://todaycode.tistory.com/38
https://todaycode.tistory.com/41
https://yuuj.tistory.com/174

Query parameter vs Path parameter reference

https://dar0m.tistory.com/222
https://rapidapi.com/blog/api-glossary/parameters/query/
https://stackoverflow.com/questions/37698501/retrofit-2-path-vs-query
https://medium.com/@fullsour/when-should-you-use-path-variable-and-query-parameter-a346790e8a6d

profile
Mobile Software Engineer

0개의 댓글