안드로이드 Http 통신(Retrofit)

PEPPERMINT100·2021년 1월 5일
0
post-thumbnail

서론

클라이언트단에서 서버로 특정 리소스에 대해 요청을 보낼 때 Http 통신을 한다. Http 통신은 Socket 통신과는 다르게 클라이언트에서 요청한 경우에만 통신이 되므로 서버 비용을 절감하는데 유용하여 많은 경우에 Http 통신 방법을 사용한다. 그리고 Http 통신은 클라이언트에서 서버로만 요청이 가능한 단방향 통신이다. Javascript에서는 fetch 함수나 제이쿼리의 ajax 또는 axios 모듈을 사용하여 Http 통신을 하지만 안드로이드에서는 Retrofit이라는 자바 라이브러리를 통하여 Http 통신을 한다.

설정

build.gradle 파일에 다음의 의존성을 추가해준다.

  // retrofit
    implementation "com.squareup.retrofit2:retrofit:2.8.1"
    implementation "com.squareup.retrofit2:converter-gson:2.8.1"
    implementation("com.squareup.okhttp3:logging-interceptor:4.8.1")

하나는 retrofit이고 그 다음은 json 형태의 데이터를 다루기 위한 gson 라이브러리이고 또 하나는 http 통신의 중간에서 데이터 확인을 위한 로깅을 도와줄 인터셉터 라이브러리이다.

그리고 maifests

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

위와 같은 라인을 추가하여 인터넷 사용을 허용해준다.

안드로이드 스튜디오 상단의 Sync now 버튼을 통해 의존성을 최신화하고 메인 디렉토리 내에 retrofit 이라는 패키지를 추가하고 그 안에 IRetroift 이라는 인터페이스, RetrofitClient 라는 이름의 오브젝트, RetrofitManager 라는 클래스를 추가해준다.

IRetrofitRetrofitManager에서 사용할 Http 요청의 Method 종류와 Url의 쿼리를 정해준다. 그리고 RetrofitClientRetrofit 에 대한 설정을 포함한 객체를 싱글톤으로 생성해준다. 그리고 RetrofitManager에서 최종적으로 만들어진 Http 요청을 담은 메소드들을 담아두고 다른 액티비티나 프래그먼트, 서비스 등에서 가져와서 사용하게 된다.

IRetrofit

import com.google.gson.JsonElement
import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.POST

interface IRetrofit {
    @FormUrlEncoded
    @POST("/login")
    fun loginRequest(
        @Field("username") username: String,
        @Field("password") password: String
    ): Call<JsonElement>
}

IRetrofit 인터페이스는 이렇게 작성해준다. 서버에 로그인 요청을 보낸다고 가정하여 usernamepassword를 보내고 그 응답으로 JsonElement를 받도록 인터페이스를 작성해준다. @Post 어노테이션을 통해 Http 통신 메소드 방식을 꼭 알려주어야 한다.

RetrofitClient

import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitClient {
    private var retrofitClient: Retrofit? = null
    private val TAG: String = "로그"

    fun getClient(baseUrl: String): Retrofit?{
        val client = OkHttpClient.Builder()

        val loggingInterceptor = HttpLoggingInterceptor(object: HttpLoggingInterceptor.Logger{
            override fun log(message: String) {
                Log.d(TAG, "RetrofitClient - log: $message");
            }
        })

        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
        client.addInterceptor(loggingInterceptor)

        client.connectTimeout(10, TimeUnit.SECONDS)
        client.readTimeout(10, TimeUnit.SECONDS)
        client.writeTimeout(10, TimeUnit.SECONDS)
        client.retryOnConnectionFailure(true)

        if(retrofitClient == null){
            retrofitClient = Retrofit.Builder()
                .baseUrl(baseUrl)
                .addConverterFactory(GsonConverterFactory.create())
                .client(client.build())
                .build()
        }
        return retrofitClient
    }
}

위와 같이 RetrofitClient를 작성해준다. 싱글톤 패턴 적용을 위해 object로 생성을 해준다.

val loggingInterceptor = HttpLoggingInterceptor(object: HttpLoggingInterceptor.Logger{
            override fun log(message: String) {
                Log.d(TAG, "RetrofitClient - log: $message");
            }
        })

        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
        client.addInterceptor(loggingInterceptor)

이 부분이 로깅을 도와주는 부분인데 BODY로 인터셉터 레벨을 책정하여 client에 붙여줌으로서 response.body 부분을 로그캣에서 확인할 수 있도록 해준다.

client.connectTimeout(10, TimeUnit.SECONDS)
client.readTimeout(10, TimeUnit.SECONDS)
client.writeTimeout(10, TimeUnit.SECONDS)
client.retryOnConnectionFailure(true)

이 부분을 통해서 Http 재요청에 대한 설정을 할 수 있는데, connectTimeout은 클라이언트에서 TCP handshake 방식이 완성된 서버에 요청을 보내는데에 제한 시간을 둔다.

readTimeout은 http 통신 연결이 완료된 순간부터 제한시간을 두고 writeTimeout은 1 바이트의 요청을 보내는 시간이 10초보다 크다면 재요청을 하도록 한다. 이 설정은 인터넷 연결이 안좋은 곳에 있는 사용자에 대한 설정이다. 이외에 자세한 내용은 여기에서 확인할 수 있다.

LoginResponse

enum class LoginResponse {
    OK,
    FAIL
}

LoginResponse라는 이름으로 enum을 만들어준다. 이는 요청이 성공적인지 아니면 실패인지 구분하도록 한다.

RetrofitManager

import android.util.Log
import com.example.retrofitpractice.LoginResponse
import com.google.gson.JsonElement
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class RetrofitManager {
    private val BASE_URL = "http://10.0.2.2:5000"
    private val TAG: String = "로그"

    companion object{
        val instance = RetrofitManager()
    }

    private val iRetrofit: IRetrofit? = RetrofitClient.getClient(BASE_URL)?.create(IRetrofit::class.java)

    fun login(username: String, password: String, completion: (LoginResponse, String) -> Unit){
        var call = iRetrofit?.loginRequest(username, password) ?: return

        call.enqueue(object: Callback<JsonElement>{
            override fun onFailure(call: Call<JsonElement>, t: Throwable) {
                Log.d(TAG, "RetrofitManager - onFailure: ");
                completion(LoginResponse.FAIL, t.toString())
            }

            override fun onResponse(call: Call<JsonElement>, response: Response<JsonElement>) {
                Log.d(TAG, "RetrofitManager - onResponse: ${response.body()} ");
                Log.d(TAG, "RetrofitManager - onResponse: status code is ${response.code()}");
                if(response.code() != 200){
                    completion(LoginResponse.FAIL, response.body().toString())
                }else{
                    completion(LoginResponse.OK, response.body().toString())
                }
            }
        })
      }
}

실제로 액티비티나 프래그먼트 등 안드로이드 컴포넌트에서 지금까지 설정한 Retrofit 메소드들을 꺼내게 되는 부분이 RetrofitManager이다. 여기서는 코틀린의 람다를 통해 자바스크립트의 콜백과 비슷한 completion을 구현을 하게 된다. 참고로

object: Callback<JsonElement>

이 부분은 코틀린의 특이한 점인데 람다의 인자로 인터페이스를 받게한다. 받는 순간 object에 빨간 줄이 뜨는데 alt+enter를 통해 메소드들을 오버라이딩 해주면 된다.

그렇게 오버라이딩 된 메소드는 요청이 성공시 그리고 실패시에 대한 대처를 어떻게 하는지 나오는데, 그 부분을 우리는 액티비티에서 completion을 통해 보내줌으로서 컨트롤하면 된다.

XML, data binding

<?xml version="1.0" encoding="utf-8"?>
<layout>
        <LinearLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto"
            xmlns:tools="http://schemas.android.com/tools"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical"
            android:gravity="center"
            >
                <EditText
                    android:id="@+id/username_input"
                    android:layout_width="150dp"
                    android:layout_height="wrap_content"
                    android:hint="Username"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    />

                <EditText
                    android:id="@+id/password_input"
                    android:layout_width="150dp"
                    android:layout_height="wrap_content"
                    android:hint="Password"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@+id/username_input" />

                <Button
                    android:id="@+id/login_button"
                    android:layout_width="150dp"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="20dp"
                    android:text="로그인"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@+id/password_input" />

                <Button
                    android:id="@+id/test_button"
                    android:layout_width="150dp"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="20dp"
                    android:text="테스트"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@+id/password_input" />
        </LinearLayout>
</layout>

간단하게 로그인 폼 양식을 작성해준다. 그리고 gradle 파일에


    buildFeatures {
        dataBinding true
    }
    

위 라인을 추가하여 데이터 바인딩을 켜주고 sync now 버튼을 통해 활성화 시켜준다.

Activity

이제 실제로 지금까지 세팅한 Retrofit을 통해 MainActivity에서 서버로 요청을 보내보았다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
import androidx.databinding.DataBindingUtil
import com.example.retrofitpractice.databinding.ActivityMainBinding
import com.example.retrofitpractice.retrofit.RetrofitManager

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding
    private val TAG: String = "로그"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.loginButton.setOnClickListener{
            Log.d(TAG, "MainActivity - onCreate: ");
            val username: String = binding.usernameInput.text.toString()
            val password: String = binding.passwordInput.text.toString()
            loginRequest(username, password)
        }
    }

    fun loginRequest(username: String, password: String){
        var dialogBuilder = AlertDialog.Builder(this@MainActivity)
        if(username.isEmpty() || password.isEmpty()){
            dialogBuilder.setTitle("알림")
            dialogBuilder.setMessage("빈 칸을 전부 채워주세요.")
            dialogBuilder.setPositiveButton("확인", null)
            dialogBuilder.show()
        }else{
            RetrofitManager.instance.login(username=username, password=password, completion = {
                loginResponse, response ->
                    when(loginResponse){
                        LoginResponse.FAIL -> {
                            dialogBuilder.setTitle("알림")
                            dialogBuilder.setMessage("로그인 실패")
                            dialogBuilder.setPositiveButton("확인", null)
                            dialogBuilder.show()
                        }

                        LoginResponse.OK -> {
                            dialogBuilder.setTitle("알림")
                            dialogBuilder.setMessage("로그인 성공")
                            dialogBuilder.setPositiveButton("확인", null)
                            dialogBuilder.show()
                        }
                    }
            })
        }
    }
}

어떤 방법이 Best Practice에 해당하는지 고민을 해보았는데, 일반적으로 하나의 서버를 두고 여러 종류의 클라이언트가 통신을 하므로 에러 코드를 RetrofitManager에서 response.code()를 통해서 먼저 받는다. 그리고 그 결과에 따라서 유연하게 LoginResponse라는 enum class로 결과를 변환한 후 Activity에서 처리를 하였다.

RetrofitManager를 싱글톤으로 구현하였으므로 인스턴스를 바로 가져와서 안에 있는 메소드를 사용하면 되는데 completion(Javascript의 콜백과 비슷)을 통해 결과값을 람다로 처리해준다. 여기서는 Dialog를 생성하여 결과를 나타내도록 했다.

서버

서버는 nodejs를 통해 간단하게 구현하였다. 인터넷에 많이 존재하는 공공 API를 사용해도 되지만 로그인 폼 요청을 해보고 싶었기에 여기서는 직접 구현하였다.

const express = require('express')
const { urlencoded } = require('express')

const app = express()

app.use(express.json())
app.use(urlencoded({ extended: "false"}))

app.post("/login", (req, res) => {
    const { username, password } = req.body
    console.log(username, password)

    if(username === "pepper" && password =="123123"){
        res.status(200).json({
            message: "로그인 성공"
        })
    }else {
        res.status(403).json({
            message: "로그인 실패"
        })
    }
})

app.listen(5000, () => {
    console.log('server started..')
})

위 서버 코드는 깃허브에 올려 두었으니 nodejs를 다운받고 서버 폴더에서 npm start를 통해서 실행해주면 된다.

안드로이드와 HTTP 통신

이 부분에서 삽질을 약간하였는데, 일단 안드로이드에서 HTTP 통신을 위해 거쳐야할 몇 단계가 있었다. 하나는 위에서 처리한 use-permission부분이다. 이 부분에서 인터넷 사용을 허락해주어야 하고 또 하나는 안드로이드는 기본적으로 HTTPS 통신만이 가능하다는 것이다.

HTTPS 통신에 관련한 내용은 필자가 직접 여기에서 aws를 사용해보면서 직접 HTTP를 HTTPS로 바꾼 글이 있으므로 간단히 읽어보면 좋을 것이다.

위 글은 길고 논외의 내용이 많으므로 간단히 요약하자면 HTTP 통신에 전달되는 값들을 암호화하여 보안성을 강화한 네트워크 프로토콜이 HTTPS 에 해당한다.

따라서 위 세팅만으로 바로 안드로이드 에뮬레이터에서 통신을 하려고하면 실패할 것이다. 여기서 헤맸던 부분을 간단히 정리해보고자 한다.

인터넷 사용 허용

maifests

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

을 추가해 준다.

ClearText 허용

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

위 코드를 추가하여 HTTPS로 암호화 되지 않은 데이터도 통신이 가능하도록 한다. 단 실제 서비스를 출시한다면 당연히 서버와 HTTPS 통신을 하는 것이 좋다.

안드로이드 에뮬레이터의 Localhost

private val BASE_URL = "http://10.0.2.2:5000"

RetrofitManager 파일의 상단을 보면 위와 같은 코드가 있다. 일반적으로 로컬 환경에서 개발할 때는 localhost 라는 url을 통해서 통신을 했는데 안드로이드 에뮬레이터는 컴퓨터에 또 다른 가상 머신을 활성화하여 만들어진 환경으로 다른 로컬 ip 주소를 사용해 주어야 한다.

결론

retorift을 통해 직접 만든 서버와 간단히 통신을 해보았다. 이 글에서는 @POST 요청만을 하지만 당연히 @GET, @PUT, @DELETE 모두 가능하다. 그리고 파라미터를 통해 쿼리를 넣을 수도 있다. 안드로이드 개발 환경이 아직 익숙하지 않아서인지 굉장히 많은 보일러플레이트들이 필요한것 처럼 느껴진다. 이번 글에서 작성된 코드는 전부 여기에서 확인할 수 있다. 이 깃허브 안에 android-test-server 폴더를 확인하면 nodejs 서버가 들어있는데, 만약 nodejs에 익숙하지 않다면 다른 언어를 통해 구현할 수도 있지만 컴퓨터에 nodejs를 설치하고 서버 폴더 내에서 npm install && npm start 커맨드만 실행하면 5000번 포트에서 로컬 서버를 열 수 있다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

0개의 댓글