안드로이드 : diary – RestAPI (Retrofit)

이번 포스트에서는 웹 프로그램이 제공하는 RestAPI 를 이용해서 데이터를 주고받는 기능을 만들어보려고 해. 가장 먼저 안드로이드앱이 웹 프로그램에 로그인할 수 있는 기능을 추가해야 하는데, 이전에 만들었던 웹 프로그램 diary 에서는 RestAPI 로 로그인하는 기능은 구현되어 있지 않아. 먼저 안드로이드앱이 사용할 수 있도록 로그인하는 RestAPI 를 추가해볼께.

이미 Spring Boot : diary – jwt 로그인으로 변경, 로그아웃까지 수정 포스트에서 jwt 토큰방식의 로그인, 로그아웃 기능으로 변경해본 바 있어. 이번 포스트에서는 이 기능을 활용하는 RestAPI 를 추가해보려고 해.

@RestController 애노테이션을 붙인 APILoginController 클래스를 하나 만들어볼께.

APILoginController.java

@RestController
@RequestMapping("/api/v1/login")
@Slf4j
public class APILoginController {
    @PostMapping()
    public ResponseEntity<?> login(@RequestBody LoginBody loginBody) {

    }
}

login 함수는 POST method 로 LoginBody 클래스형 데이터의 구조로 로그인 정보를 입력받아서 로그인 처리를 한 후에 jwt 토큰을 발급해주는 기능으로 구현할 생각이야.

LoginBody 클래스는 아래 구조와 같아.

LoginBody.java
@Getter
@ToString
public class LoginBody {
    String email;
    String password;
}

email 과 password 가 json 형식으로 전달될거야. (@RequestBody)

그럼 전달된 로그인정보(email, password)를 이용하여 로그인처리한 후에 jwt 토큰을 생성해서 내려주는 코드를 작성해볼께.

APILoginController.java
...
    @PostMapping()
    public ResponseEntity<?> login(@RequestBody LoginBody loginBody) {
        log.debug(String.valueOf(loginBody));
        try {
            UserDetails userDetails = loginService.loadUserByUsername(loginBody.getEmail());

            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                    new UsernamePasswordAuthenticationToken(userDetails, loginBody.getPassword(), userDetails.getAuthorities());
            // 2. 인증 가능 여부 확인(패스워드 일치여부 확인)
            Authentication authentication = authenticationManagerBuilder.getObject().authenticate(usernamePasswordAuthenticationToken);
            System.out.println(authentication);

            // 사용자 인증에 성공하면 쿠키로 jwt 토큰을 발급한다.
            String jwtToken = jwtUtil.createToken(authentication);
            System.out.println(jwtToken);

            LoginResult result = LoginResult.builder()
                    .body(jwtToken)
                    .errorCode(0)
                    .build();

            return ResponseEntity.ok().body(result);
        } catch (UsernameNotFoundException e) {

            LoginResult result = LoginResult.builder()
                    .errorCode(400)
                    .errorMessage("Invalid login info")
                    .build();
            return ResponseEntity.status(400).body(result);
        }
    }
}

웹 프로그램에서 로그인하는 경우에는 쿠키를 생성해서 응답해주었지만, 앱에서 로그인할 때는 jwt 토큰값을 LoginResult 형식의 json 데이터로 만들어서 내려주도록 했어.

LoginResult 클래스형 데이터는 아래와 같아.

LoginResult.java
@Getter
@Setter
@ToString
@Builder
public class LoginResult {
    String body;// token
    Integer errorCode;// errorCode (200(OK), 400(BAD_REQUEST)
    String errorMessage;
}

swagger UI 를 이용해서 새로 추가한 로그인 RestAPI 를 테스트해볼께.



로그인이 성공했을 때 body 에 jwt 토큰값이 담겨져 내려오는 것을 확인할 수가 있어.

이제 이 RestAPI 를 안드로이드 앱에서 사용해볼께.

안드로이드 앱에서 RestAPI 를 사용하는 경우에 Retrofit (https://square.github.io/retrofit/)이라는 라이브러리를 사용해. 이번 포스트에서 구현하는 기능 역시 Retrofit 라이브러리를 사용해볼 예정이야.

Retrofit 라이브러리를 사용하기 위해서는 프로젝트에 의존성 라이브러리를 설정해 주어야겠지.

build.gradle.kts
...
dependencies {
    implementation(libs.androidx.navigation.fragment.ktx)
    implementation(libs.androidx.navigation.ui.ktx)
    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0") // Gson 컨버터 추가
...

build.gradle.kts 파일의 dependencies 영역에 위와 같이 의존성 라이브러리를 추가해 주고 나면 아래 그림에서처럼 밑줄이 그어지지.


밑줄위에 마우스 커서를 갖다대면 아래 그림에서처럼 새로운 버전이 있을 경우 새 버전에 대한 정보를 알려주기도 해.


그리고 아래 그림에서처럼 라이브러리 카탈로그 정의 방식으로 사용하도록 추천해주기도 해.


추천해주는 내용으로 최종 수정된 결과는 아래와 같아.


build.gradle.kts 파일의 내용이 변경되면 Sync Now 를 해주어야 하지. Sync 를 하기 전에는 위 그림과 같이 해당 라이브러리가 빨간색 글자로 표시가 되지.

Sync Now 를 해주고 나면 빨간색 표시가 사라지게 돼.


Retrofit 라이브러리는 인터넷을 사용해서 서버와 HTTP 통신을 하도록 도와주는 라이브러리이기 때문에 네트워크 접근 권한을 추가해 주어야 해. AndroidManifest.xml 파일을 열어서 아래 코드를 추가해주면 돼.

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
...

Retrofit 라이브러리를 사용하기 위해서 Retrofit 객체를 생성해 주어야 하는데, 아래와 같은 방식으로 생성해주면 돼. LoginFragment 에서 로그인하기 위해서 HTTP 통신을 할 수 있도록 할거야.

LoginFragment.kt
...
class LoginFragment : Fragment() {
    // TODO: Rename and change types of parameters
    private var param1: String? = null
    private var param2: String? = null

    private lateinit var mainActivity: MainActivity

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("http://diary.woohahaapps.com:8080")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
    }
...

baseUrl 의 파라미터로 접속할 서비스 주소를 설정해주고, json 형식의 데이터로 통신하기 위한 GsonConverterFactory 를 생성해주는거야.

생성된 변수 retrofit 을 이용해서 RestAPI 서비스에 접속해야 하는데, RestAPI 서비스는 restservice 패키지를 새로 만들고 그 아래에 LoginService 라는 이름으로 Interface 를 추가해서 아래와 같이 정의를 해주면 돼.

LoginService.kt
package com.woohahaapps.androiddiary.restservice

import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST

interface LoginService {

    @POST("/api/v1/login")
    fun login(
        @Body params: RequestLoginBody
    ): Call<ResponseLoginResult>

}

이 인터페이스는 앞서 웹 diary 프로그램에서 만든 RestAPI 를 이용하기 위한 것이야.

입력파라미터 RequestLoginBody 와 출력파라미터 ResponseLoginResult 는 data class 로 아래와 같이 작성해 주도록 할께.

RequestLoginBody.kt
package com.woohahaapps.androiddiary.domain

import com.google.gson.annotations.SerializedName

data class RequestLoginBody(
    @SerializedName("email") val email: String
    , @SerializedName("password") val password: String
)
ResponseLoginResult.kt
package com.woohahaapps.androiddiary.domain

import com.google.gson.annotations.SerializedName

data class ResponseLoginResult(
    @SerializedName("body") val body: String
    , @SerializedName("errorCode") val errorCode: Int
    , @SerializedName("errorMessage") val errorMessage: String
)

@SerializedName 애노테이션으로 붙인 이름은 json 데이터형식으로 컨버팅할 때 사용되는 key 의 값이야.

이번에는 fragment_login.xml 에 로그인정보에 해당하는 email 주소와 password 를 입력받기 위한 UI 컴포넌트를 디자인해볼께.


프로그램이 실행되었을 때 로그인화면에서 email 과 password 를 입력한 뒤에 Login 버튼을 누르면 RestAPI 를 호출해야 하는데, 기존에 작성되어 있는 프래그먼트 전환 테스트 코드를 삭제하고 아래와 같이 작성해주면 돼. (설명을 하기 위해서 단계적으로 작성해 나갈께)

LoginFragment.kt
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment

        val v: View = inflater.inflate(R.layout.fragment_login, container, false)

        val btnLogin : Button? = v.findViewById(R.id.btn_login)
        btnLogin?.setOnClickListener{
//            mainActivity.setLoginState(true)
//            //mainActivity.moveFragmentToList()
//
//            findNavController().navigate(R.id.action_loginFragment_to_listFragment)
            
            val editEmail = v.findViewById<EditText>(R.id.editEmail)
            val editPassword = v.findViewById<EditText>(R.id.editPassword)

            val loginService = retrofit.create(LoginService::class.java)
            loginService.login(RequestLoginBody(editEmail.text, editPassword.text))
                .enqueue(object: Callback<ResponseLoginResult> {
                    
                })
        }

        return v
    }
...

우선 email 과 password 입력값을 구하기 위해서 editEmail 과 editPassword 를 선언했어.

RestAPI 인터페이스를 사용하기 위해서 retrofit.create 함수를 사용해서 LoginService 인터페이스를 생성했지.

LoginService 인터페이스의 login 함수에 ReqeustLoginBody 클래스형의 변수를 전달하고 enqueue 함수로 Callback<ResponseLoginResult> 형의 object 를 넘겨주면 해당 함수가 비동기적으로 실행되고나서 ResponseLoginResult 형의 데이터가 리턴되는 콜백함수가 실행되게 돼.

이제 2개의 override 함수를 작성해주는 일이 남았어.

LoginFragment.kt
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment

        val v: View = inflater.inflate(R.layout.fragment_login, container, false)

        val btnLogin : Button? = v.findViewById(R.id.btn_login)
        btnLogin?.setOnClickListener{
//            mainActivity.setLoginState(true)
//            //mainActivity.moveFragmentToList()
//
//            findNavController().navigate(R.id.action_loginFragment_to_listFragment)
            
            val editEmail = v.findViewById<EditText>(R.id.editEmail)
            val editPassword = v.findViewById<EditText>(R.id.editPassword)

            val loginService = retrofit.create(LoginService::class.java)
            loginService.login(RequestLoginBody(editEmail.text, editPassword.text))
                .enqueue(object: Callback<ResponseLoginResult> {
                    override fun onResponse(
                        call: Call<ResponseLoginResult>,
                        response: Response<ResponseLoginResult>
                    ) {
                        TODO("Not yet implemented")
                    }

                    override fun onFailure(
                        call: Call<ResponseLoginResult>, 
                        t: Throwable) {
                        TODO("Not yet implemented")
                    }
                })
        }

        return v
    }
...

RestAPI 함수 호출 결과 성공하면 onResponse 함수가 호출되고, 실패했을 경우에는 onFailure 함수가 호출되는거야.

onResponse 함수가 호출될 때 리턴값은 ResponseLoginResult 형의 response 변수로 확인할 수 있어. 즉, response.body 에 jwt 토큰값이 내려오게 되지.

onResponse 함수는 아래와 같이 작성할 수 있어.

                    override fun onResponse(
                        call: Call<ResponseLoginResult>,
                        response: Response<ResponseLoginResult>
                    ) {
                        Log.d("DATA", response.toString())
                        if (response.isSuccessful.not()) {
                            Log.d("ERROR", "CODE=" + response.code().toString())
                        }
                        
                        response.body()?.let {
                            val result: ResponseLoginResult = response.body()!!
                            val token = result.body
                            
                            mainActivity.setLoginState(true)
                            findNavController().navigate(R.id.action_loginFragment_to_listFragment)
                        }
                    }

실패한 경우에 실행되는 onFailure 함수는 아래와 같이 작성할 수 있겠어.

                    override fun onFailure(
                        call: Call<ResponseLoginResult>,
                        t: Throwable) {
                        Log.d("FAIL", t.toString())
                    }

이렇게 코드를 작성한 후에 실행시키니까, onFailure 함수가 호출되고 아래와 같이 Exception 이 발생을 했어.

java.net.UnknownServiceException: CLEARTEXT communication to diary.woohahaapps.com not permitted by network security policy


접속하려는 주소가 https 가 아닌 http 로 설정되어 있는 경우에 발생하는 Exception 이야.

그래서 http://diary.woohahaapps.com:8080 주소를 https://diary.woohahaapps.com:8080 으로 수정해주니 이번에는 아래와 같은 다른 내용의 Exception 이 발생을 했어.

javax.net.ssl.SSLException: Unable to parse TLS packet header


실제로 https://diary.woohahaapps.com 이 올바른 인증서가 설치되어 있지 않은 상태라서 아래와 같은 클래스 파일을 만들어서 해결을 했어.

UnsafeOkHttpClient.kt
package com.woohahaapps.androiddiary.restservice

import okhttp3.OkHttpClient
import java.security.cert.CertificateException
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager

class UnsafeOkHttpClient {
    companion object {
        fun getUnsafeOkHttpClient(): OkHttpClient.Builder {
            try {
                // Create a trust manager that does not validate certificate chains
                val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
                    @Throws(CertificateException::class)
                    override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
                    }

                    @Throws(CertificateException::class)
                    override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
                    }

                    override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
                        return arrayOf()
                    }
                })

                // Install the all-trusting trust manager
                val sslContext = SSLContext.getInstance("SSL")
                sslContext.init(null, trustAllCerts, java.security.SecureRandom())
                // Create an ssl socket factory with our all-trusting manager
                val sslSocketFactory = sslContext.socketFactory

                val builder = OkHttpClient.Builder()
                builder.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
                builder.hostnameVerifier { _, _ -> true }
                //builder.hostnameVerifier ( hostnameVerifier = HostnameVerifier{ _, _ -> true })

                return builder
            } catch (e: Exception) {
                throw RuntimeException(e)
            }
        }
    }
}

위 클래스 파일은 retrofit 을 생성할 때 아래와 같이 적용하면 돼.

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("https://diary.woohahaapps.com:8080")
        .addConverterFactory(GsonConverterFactory.create())
        .client(UnsafeOkHttpClient.getUnsafeOkHttpClient().build())
        .build()

개발 과정에서 가장 편한 방법은 AndroidManifest.xml 에 아래와 같은 코드를 추가해서 http 프로토콜로 통신이 가능하게 하는 방법이야.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application
        android:usesCleartextTraffic="true"
        android:allowBackup="true"
...

로그인 가능한 email 주소와 password 를 입력하였다면 아래 그림과 같이 정상적인 jwt 토큰이 내려오는 것을 확인할 수가 있어.


이렇게 내려받은 jwt 토큰은 다른 RestAPI 를 호출할 때마다 헤더로 포함시켜서 전송해주어야 해. 그렇기 때문에 저장해두는 로직이 필요해.

이번 포스트에서는 간단하게 고전적인 SharedPreference 저장로직을 사용해볼거야.

datastore 패키지를 생성하고 PreferenceUtil 클래스를 아래와 같이 작성해주자.

PreferenceUtil.kt
package com.woohahaapps.androiddiary.datastore

import android.content.Context
import android.content.SharedPreferences

class PreferenceUtil(context: Context) {

    private val prefs: SharedPreferences =
        context.getSharedPreferences("prefs_name", Context.MODE_PRIVATE)

    fun getString(key: String, defValue: String): String {
        return prefs.getString(key, defValue).toString()
    }

    fun setString(key: String, value: String) {
        prefs.edit().putString(key, value).apply()
    }

}

MainActivity.kt 에 전역으로 사용할 수 있도록 싱글톤으로 preferences 변수를 선언해줘.

MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
        lateinit var preferences: PreferenceUtil
    }

    fun setPref(key: String, value: String) {
        preferences.setString(key, value)
    }
    
    fun getPref(key: String, defValue: String) : String {
        return preferences.getString(key, defValue)
    }
...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        preferences = PreferenceUtil(this)
        enableEdgeToEdge()
...

로그인 API 호출 결과 정상적인 jwt 토큰을 받은 경우에 아래와 같이 토큰을 저장할께.

...
            loginService.login(RequestLoginBody(editEmail.text.toString(), editPassword.text.toString()))
                .enqueue(object: Callback<ResponseLoginResult> {
                    override fun onResponse(
                        call: Call<ResponseLoginResult>,
                        response: Response<ResponseLoginResult>
                    ) {
                        Log.d("DATA", response.toString())
                        if (response.isSuccessful.not()) {
                            Log.d("ERROR", "CODE=" + response.code().toString())
                        }

                        response.body()?.let {
                            val result: ResponseLoginResult = response.body()!!
                            val token = result.body
                            mainActivity.setPref("TOKEN", token)

                            mainActivity.setLoginState(true)
                            findNavController().navigate(R.id.action_loginFragment_to_listFragment)
                        }
                    }
...

저장된 이 토큰값을 사용할 때는 mainActivity.getPref(“TOKEN”, “”) 처럼 사용하면 돼. 다음 포스트에서 추가적인 RestAPI 를 호출하면서 jwt 토큰을 헤더에 포함시켜서 전달하는 방법에 대해서 알아볼께.

Leave a Comment