Android/프로젝트

안드로이드 통신에 자주 사용하는 Retrofit 알아보기 (1) - HttpURLConnection, OkHttp, Retrofit 장점을 중심으로

sh1mj1 2023. 9. 27. 13:16

Jwt 토큰 관련해서 프로젝트의 버그 픽스, 간단한 리팩토링(리팩토링이라고 하기도 뭐하지만) 을 진행하면서 이 김에 Retrofit 클래스의 내부 구조에 대해서 조금 이해를 해야 할 필요가 있을 것 같아 정리해보려고 합니다.

 

 

안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기

 

안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기

지난 4개월 전쯤 친구를 통해서 여러 다른 분들과 플레이스토어에 바바-BABA 라는 앱을 출시했습니다. 작성했던 코드들을 천천히 보면서 리팩토링 및 버그 픽스를 하며 공부를 더 해보려고 합니

sh1mj1-log.tistory.com

 

APi 요청 시 Access Token 을 갱신해야 할 때 Refresh Token 으로 토큰 갱신 후 같은 api 재요청

 

Api 요청 시 Access Token 을 갱신해야 할 때 Refresh Token 으로 토큰 갱신 후 같은 api 재요청

이전 글 [안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기] 안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기 지난 4개월 전쯤 친구를 통해서 여

sh1mj1-log.tistory.com

 

위 글에서 간단히 코드를 수정한 부분들을 정리한 내용입니다.

배달의 민족 기술이사 김영한님의 Http 웹 기본 지식 강의를 듣고 정리한 내용들

 

'Computer Science/Http 웹 지식' 카테고리의 글 목록

 

sh1mj1-log.tistory.com

 

위 Http 웹 기본 지식에 대해서도 알면 더 도움이 될 겁니다. 당연히 강의를 결제해서 듣는 것을 추천합니다!!

 

https://www.inflearn.com/course/http-%EC%9B%B9-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC

 

모든 개발자를 위한 HTTP 웹 기본 지식 - 인프런 | 강의

실무에 꼭 필요한 HTTP 핵심 기능과 올바른 HTTP API 설계 방법을 학습합니다., [사진] 📣 확인해주세요!본 강의는 자바 스프링 완전 정복 시리즈의 세 번째 강의입니다. 우아한형제들 최연소 기술

www.inflearn.com

 

개요

일단 retrofit2 패키지의 Retrofit 클래스는 okHttp3 패키지(HttpUrl, OkHttpClient 클래스) 에 의존성을 가집니다.

정확히 말하면 Retrofit 클래스 내부에 중첩 클래스 Builder 에서 의존성을 가지고 있고 해당 Builder 타입의 메서드를 Retrofit 클래스에서 사용합니다.

 

OkHttp3 역시 Square 에서 만든 라이브러리로 일종의 Third-Party 라이브러리입니다.

사실 초창기 안드로이드에서는 네트워크 콜을 수행하기 위해서 HttpURLConnection 을 사용했습니다.

 

HttpURLConnection

HttpURLConnectionURLConnection 클래스를 상속받는 abstrct class 입니다.

 

URLConnection 은 App. 과 URL 간의 통신 연결을 사용하는 모든 클래스의 슈퍼클래스입니다.

URLConnection 클래스의 인스턴스는 URL 에서 참조한 리소스를 읽고 쓰는 데 사용됩니다. openConnection()connect() 메서드를 사용해서 원격 리소스 연결 관련 패러미터를 조작하고, 리소스와 상호작용, 리소스의 헤더와 내용을 쿼리합니다.

 

https://developer.android.com/reference/java/net/HttpURLConnection 참조

위는 Android developer 공식 문서에서 가져온 내용입니다.

 

HttpUrlConnection 은 그림에서 나온 것처럼 java.net 패키지에 존재합니다.

즉, 자바에서 기본적으로 제공하는 클래스이기 때문에 호환성 문제도 없고 가볍게 사용할 수 있다는 장점이 있습니다.

 

Android developer 공식 문서에서 HttpURLConnection 을 사용해서 통신을 하는 간단한 가이드라인이 소개되고 있습니다.

 

  1. URL.openConnection() 호출.
    → 얻은 결과를 HttpURLConnection 으로 캐스팅해서 HttpURLConnection 객체를 얻기.
  2. http 통신의 request 준비.
    (request 의 주요 프로퍼티는 URL 이며, 헤더에 credentials(자격 증명), content type, 쿠키 등이 들어감)
  3. request 바디에 원하는 데이터 담아서 전송.
    (request 바디가 있다면 인스턴스를 setDoOutput(true) 로 설정한 후, URLConnection.getOutputStream() 으로 얻은 스트림에 데이터를 직접 써서 전송함.)
  4. http 통신의 response 를 읽기.
    (response 헤더에는 보통 response 바디의 content type, 길이, 쿠키 등의 메타 데이터가 담김
    URLConnection.getInputStream() 으로 얻은 스트림에 response 바디가 있음.
    response 바디가 없으면 이 메서드는 빈 스트림을 리턴함.)
  5. 연결 끊기
    (response 의 바디를 읽고 나서 disconnect() 를 호출. HttpURLConnection 을 close 해야 함
    연결을 끊으면 리소스가 릴리즈되어서 리소스가 close 되거나 리소스를 재사용할 수 있음.)

 

HttpURLConnection 을 이용해서 GET 메서드를 호출하고 response 를 읽는 코드는 아래처럼 수행됩니다.

fun run() {
        try {
            val url = URL("https://publicobject.com/helloworld.txt")
            val connection = url.openConnection() as HttpURLConnection

            // GET 요청 설정
            connection.requestMethod = "GET"

            // 응답 코드 확인
            val responseCode = connection.responseCode
            if (responseCode != HttpURLConnection.HTTP_OK) {
                throw IOException("Unexpected code $responseCode")
            }

            // 응답 헤더 출력
            val headers = connection.headerFields
            for ((name, valueList) in headers) {
                for (value in valueList) {
                    println("$name: $value")
                }
            }

            // 응답 본문 읽기
            val reader = BufferedReader(InputStreamReader(connection.inputStream))
            var line: String?
            val response = StringBuilder()

            while (reader.readLine().also { line = it } != null) {
                response.append(line).append('\n')
            }
            reader.close()

            // 응답 내용 출력
            println(response.toString())
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

위처럼 사용법이 복잡하고 간단한 api 하나를 부르는데도 boiler code 가 너무 많아집니다.

 

게다가 위 가이드라인에는 적혀있지 않지만 비동기도 AsyncTask 객체를 만들어서 직접 구현해야 합니다...

 

OkHttp

OkHttpSquare 에서 개발한 HTTP 네트워크 통신 Third-party 라이브러리입니다.

 

OkHttp 의 장점은 아래와 같습니다.

 

  • 간결하고 직관적인 api 를 제공, Http request 를 생성하고 전송이 쉬워짐.
  • 커스텀 헤더 및 인터셉터를 제공. 네트워크 통신 조작, 로깅이 쉬움.
  • 비동기 및 동기 요청을 모두 지원.
  • 테스팅이 쉬워짐.
  • Http/2 지원. http2 의 스트림과 커넥션을 관리하기 위해 사용하는 shared memory pool 을 사용.
  • Http/2 를 사용할 수 없는 경우는 Connection pooling 을 지원. 요청 대기 시간이 줄어듬.
  • transparent GZIP 으로 다운로드 사이즈를 줄임.
  • response caching 으로 반복적인 요청에 대해 효율적 처리 가능.

 

square 깃허브 사이트에서 간단한 사용법을 확인할 수 있습니다.

https://square.github.io/okhttp/recipes/

class MyOkHttpExample {
    private val client = OkHttpClient()

    fun run() {
        val url = "https://publicobject.com/helloworld.txt"
        val request = Request.Builder()
            .url(url)
            .get() // GET 요청 메서드를 설정
            .build()

        try {
            val response: Response = client.newCall(request).execute()

            if (response.isSuccessful) {
                println("Headers: ${response.headers()}")
                println("Response: ${response.body()?.string()}")
            } else {
                throw IOException("Unexpected code ${response.code()}")
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

기존에 HttpURLConnection 에서는 직접 connection 을 열고 닫는 등의 동작이 우리가 사용하는 코드에서는 보이지 않고 훨씬 더 직관적으로 이해가 되는 것을 볼 수 있습니다.

 

만약 클라이언트 - 서버에서 통신 시 자주 사용하는 JSON 포맷으로 바디에 데이터를 담아서 GET 요청을 한다면 아래처럼만 코드를 수정하면 됩니다.

class MyOkHttpExample {
    private val client = OkHttpClient()

    fun run() {
        val url = "https://publicobject.com/helloworld.json" // JSON 엔드포인트로 변경
        val request = Request.Builder()
            .url(url)
            .get()
            .build()

        try {
            val response: Response = client.newCall(request).execute()

            if (response.isSuccessful) {
                val jsonResponse = response.body()?.string()
                if (jsonResponse != null) {
                    val jsonObject = JSONObject(jsonResponse)
                    val message = jsonObject.getString("message")
                    println("Message: $message")
                }
            } else {
                throw IOException("Unexpected code ${response.code()}")
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

 

Retrofit

우리가 정말로 많이 사용하는 Retrofit 입니다.

사실상 안드로이드에서 서버와 간단한 형태의 통신을 할 때는 대부분 Retrofit 을 사용한다고 할 수 있는데요.

 

OkHttp 처럼 Square 에서 만들어졌으며 OkHttp 를 네트워크 레이어로 활용하여 그 위에 레이어를 한 단계 더 추가해서 만든 라이브러리입니다.

 

OkHttp 가 저수준의 네트워크 명령(request, response, caching) 등을 수행하고 Retrofit 은 그 위에 추가로 abstraction layer 를 얹어서 조금 더 사용하기 편하고 간결하게 만든 것입니다.

 

사용할 때의 코드를 간단히 살펴봅시다.

interface ApiService {
    @GET("helloworld.txt")
    fun getHelloWorld(): Call<String>
}

class MyRetrofitExample {
    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("https://publicobject.com/")
        .client(OkHttpClient()) // OkHttp 클라이언트 사용
        .addConverterFactory(ScalarsConverterFactory.create())
        .build()

    private val apiService: ApiService = retrofit.create(ApiService::class.java)

    fun run() {
        val call = apiService.getHelloWorld()

        try {
            val response = call.execute()

            if (response.isSuccessful) {
                println("Headers: ${response.headers()}")
                println("Response: ${response.body()}")
            } else {
                throw IOException("Unexpected code ${response.code()}")
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

 

만약에 JSON 포맷으로 바디에 데이터를 담아서 통신한다면 아래와 같습니다.

data class MyData(val message: String) // JSON 데이터 구조와 일치해야 함

interface ApiService {
    @GET("helloworld.txt")
    fun getHelloWorld(): Call<MyData> // 반환 타입을 Kotlin 데이터 클래스로 변경
}

class MyRetrofitExample {
    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("https://publicobject.com/")
        .client(OkHttpClient()) // OkHttp 클라이언트 사용
        .addConverterFactory(GsonConverterFactory.create()) // Gson 컨버터 사용
        .build()

    private val apiService: ApiService = retrofit.create(ApiService::class.java)

    fun run() {
        val call = apiService.getHelloWorld()

        try {
            val response = call.execute()

            if (response.isSuccessful) {
                val myData = response.body()
                println("Message: ${myData?.message}")
            } else {
                throw IOException("Unexpected code ${response.code()}")
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

기본적으로 retrofit 은 api query 를 service interface 를 이용해서 작성합니다.

 

그리고 api call 을 하고자 하는 urlOkHttpClient 를 이용해서 Retrofit 의 인스턴스를 만들어줍니다.

위에서는 받은 JSON 형태의 데이터를 GSON 객체로 만들어주는 것도 간단히 추가되어 build 되고 있습니다.

 

이렇게 OKHttpClient 안에 Intercepter(예를 들어 HttpLoggingInterceptor) 를 추가하여 http 통신 중간의 과정을 모두 log로 남길 수 있고,

  ConverterFactory를 추가하여 api response를 원하는 format으로 받을 수 있습니다.(JSON → (GsonConverterFactory), Xml → ... )

 

위에서 생성한 Retrofit Client 인스턴스로 service interface 의 구현체를 생성해서 api call 을 수행하는 것입니다.

 

Retrofit 을 사용해서 통신을 할 때 의존성 구조를 간단히 표현하면 아래처럼 되겠죠.

 

Retrofit 사용의 장점

사실 안드로이드에서 서버와의 대부분의 통신은 request 나 respone 의 Body 에 JSON 포맷의 데이터를 넣어서 수행됩니다.

 

즉, 위 두번째 MyRetrofitExample 클래스에서의 코드의 대부분을 대부분을 똑같이 사용하는 것이죠.

 

달라지는 부분은 리턴되는 데이터 타입과 baseUrl 뒤에 붙은 path 나 query 부분입니다.

그런데 이 리턴되는 데이터 타입, path, query 는 모두 Api Service interface 에서 결정합니다!!!

 

즉, 공통으로 사용되는 위의 MyRetrofitExample 부분만 잘 만들어두고 클라이언트에서 사용하는 각자 다른 부분은 Api Service interface 에서만 간단히 변경해가면서 사용하면, 객체지향의 관점에서 굉장한 이득을 볼 수 있습니다.

(물론 retrofit.create(ApiService::class.java) 의 부분도 패러미터로 빼내어 클라이언트에서 결정할 수 있도록 해야 겟죠.)

 

이는 굉장한 이득이죠!! 🔥🔥🔥🔥🔥🔥

 

만약 서버 클래스(MyRetrofitExample 부분을 개발하는) 개발자가 있다고 합시다.

그리고 Client 부분을 개발하는 클라이언트 부분 개발자가 있다고 하면, 클라이언트 개발자는 서버 클래스의 구현 부분에 대해 자세히 알지 못해도 간단히 사용할 수 있게 됩니다.

 

실제 코드로 장점 체감하기(추가적으로 Hilt 를 사용했음)

위에서의 링크 글 두 개(안드로이드 레트로핏 .... @Header 중복을 제거하기, Api 요청 시 ... 토큰 갱신 후 같은 api 재요청 글) 에서는 Hilt 를 사용하여 중복 코드를 굉장히 많이 줄인 것을 알 수 있습니다.

 

만약에 프로젝트에서 새로운 도메인 '친구' 가 생기고 그에 대한 api 를 호출하는 부분이 생긴다고 하면 내부 구현에 대해 자세히 알지 못하더라도 굉장히 간단회 추가할 수 있습니다.

 

  1. Api Service Interface(ex. FriendApi) 에서 URL 의 path 나 query 에 맞춰서 api 호출 함수를 만들어 주기
  2. api 호출 함수시 서버로부터 받는 데이터에 맞게 data class 만들어주기
  3. Hilt 를 이용해 DI 를 해주기
  4. 클라이언트에서 이를 사용하기.

간단한 코드로 보시죠.

 

1. FriendApi 에서 함수 만들어 주기

interface FriendApi {

    @GET("friends")
    suspend fun getAllFriends(
    ): Response<FriendList>

    @GET("friend/{friendId}")
    suspend fun getFriend(
        @Path("friendId") friendId: String
    ): Response<Friend>

    @POST("friend")
    suspend fun addFriend(
        @Body friend: Friend
    ): Response<Unit>

    @DELETE("friend/{friendId}")
    suspend fun deleteFriend(
        @Path("friendId") friendId: String
    ): Response<Unit>

    ....

}

 

2. data class FriendList, Friend 만들어주기

data class FriendList(
    val friends: List<Friend>
)

data class Friend{
    val friendId: String,
    val isMarried: Boolean,
    val location: String
}

 

3. Hilt 에서 DI 해주기

    @Singleton
    @Provides
    fun provideFriendeApi(
        @BabaRetrofit retrofit: Retrofit
    ): FriendApi = retrofit.create(FirendApi::class.java)

 

4. 클라이언트 코드에서 FriendApi 의 함수를 호출해서 사용.

 

이렇게만 하면 끝인 겁니다.

정말 이게 다입니다.

 

이렇게 요구사항 추가에 대해 빠르고 쉽고 간단하게 대응할 수 있습니다. 변경에 대해서도 그렇죠.

 

이렇게 Retrofit 에 대해서 매우 간단히 알아보았습니다.

 

객체지향에 대해 관심을 가지고 공부하니 OkHttp 와 Retrofit 이 얼마나 잘 만들어진 라이브러리인지가 잘 보이네요...

 

사실 Retrofit 은 내부적으로 Builder 패턴, 프록시 패턴 등의 디자인 패턴들을 사용해서 효율적으로 구현되어 있습니다.

 

다음에는 더 자세히 Retrofit 이 어떻게 구현되었는지에 대해 알아보겠습니다.

 

참고한 내용

https://square.github.io/okhttp/recipes/

https://developer.android.com/reference/java/net/HttpURLConnection

https://jaeyeong951.medium.com/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-http-%ED%86%B5%EC%8B%A0%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B1%B0%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83-9c90f5625d3e