관리 메뉴

나만을 위한 블로그

[Android] Retrofit 사용 시 4xx 응답을 파싱하는 방법 본문

Android

[Android] Retrofit 사용 시 4xx 응답을 파싱하는 방법

참깨빵위에참깨빵 2022. 1. 30. 23:39
728x90
반응형

레트로핏을 사용하다 보면 서버 응답을 받아오는 부분을 아래와 같이 작성할 수 있다.

 

repository.method(sendDataMap).enqueue(new Callback<String>() {
    @Override
    public void onResponse(Call<String> call, Response<String> response) {
        if (response.isSuccessful()) {
            data.setValue(response.body());
        } else {
            data.setValue(response.body());
        }
    }

    @Override
    public void onFailure(Call<String> call, Throwable t) {
        data.setValue(null);
    }
});

 

레트로핏의 onResponse()는 서버 통신이 성공했을 경우 호출되는 콜백 메서드다. 그리고 이 안에서 if로 조건을 걸어 isSuccessful()이 호출됐고 body()가 null이 아닐 경우에만 클라이언트에서 파싱해 사용할 JSON 값을 담는다.

그러나 이 방식을 사용할 경우 받을 수 없는 4xx로 시작하는 HTTP 에러 코드와 같이 넘어오는 메시지를 파싱할 수 없다. null이 들어가서 내 의도와 다른 값을 받게 되는 경우가 발생할 수도 있다.

왜 그런지 설명하기 전에 코드부터 보고 시작하자. 그나마 쓸 수 있는 백엔드 언어가 PHP기 때문에 테스트 API 코드는 PHP로 구성했다.

아래 PHP 파일들은 DB 커넥션과 API 파일이다. 내 경우 컴퓨터에 xampp가 설치돼 있어 로컬 서버 환경이 구축돼 있기 때문에 이를 바탕으로 진행했다. Node.js를 쓰더라도 흐름만 안다면 코드로 작성할 수 있을 것이다.

각 파일명은 파일 내 최상단의 주석에 써놨다.

 

<?php
// connect.php

$username = "root";
$password = "";
$db = "DB 이름";
$hostname = "localhost";

$con = mysqli_connect($hostname, $username, $password, $db);

if ($con)
{
    // echo "register DB 접속 성공";
}
else
{
    echo "register DB 접속 실패";
}
<?php
// testapi.php

require 'connect.php';

$user_id = $_POST['user_id'];

if ($con) {
    $sql = "SELECT * FROM user WHERE user_id = '$user_id'";
    $result = mysqli_query($con, $sql) or die(mysqli_error($con));

    if (mysqli_num_rows($result) > 0) {
        $status = "성공";
        $errorCode = 200;
        echo json_encode(array(
            'status' => $status,
            'errorCode' => $errorCode
        ), JSON_UNESCAPED_UNICODE);
    } else {
        $status = "실패";
        $errorCode = 400;
        http_response_code($errorCode);
        echo json_encode(array(
            'status' => $status,
            'errorCode' => http_response_code($errorCode)
        ), JSON_UNESCAPED_UNICODE);
    }
} else {
    $status = "에러";
    $resultCode = 500;
    echo json_encode(array(
        'status' => $status,
        'errorCode' => $errorCode
    ), JSON_UNESCAPED_UNICODE);
}
mysqli_close($con);

 

API라고 부르기도 부끄러운 코드지만 난 백엔드 개발자가 아니고 안드로이드 개발자기 때문에 이게 최선이라고 변을 늘어놓겠다. 쥐구멍 하나 주세요

아무튼 이렇게 설정하고 안드로이드에서 필요한 설정들을 해주고 레트로핏을 사용하기 위한 클래스, 인터페이스를 만들어준다.

의존성 문구가 없다면 앱 수준 gradle에 아래의 의존성 문구부터 넣고 시작한다.

 

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2'

 

인터페이스는 아래와 같이 구성했다.

 

import retrofit2.Call;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.POST;

public interface TestInterface {

    @FormUrlEncoded
    @POST("testapi.php")
    Call<String> testApiCall(
            @Field("user_id") String userId
    );
}

 

레트로핏 객체를 설정하는 ApiClient 파일 내용은 아래와 같다.

 

import android.util.Log;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
import retrofit2.converter.scalars.ScalarsConverterFactory;

public class TestApiClient {
    private static Retrofit retrofit;
    private static final String BASE_URL = "http://10.0.2.2/";

    public static Retrofit getRetrofit() {
        Gson gson = new GsonBuilder()
                .setLenient()
                .create();

        OkHttpClient client = new OkHttpClient.Builder()
                .addInterceptor(setHttpLoggingInterceptor())
//                .connectTimeout(AppDefine.CONNECT_TIMEOUT, TimeUnit.SECONDS) // 60 * 1000
//                .writeTimeout(AppDefine.WRITE_TIMEOUT, TimeUnit.SECONDS)     // 60 * 1000
//                .readTimeout(AppDefine.READ_TIMEOUT, TimeUnit.SECONDS)       // 60 * 1000
                .build();

        if (retrofit == null) {
            retrofit = new Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .client(client)
                    .addConverterFactory(ScalarsConverterFactory.create())
                    .addConverterFactory(GsonConverterFactory.create(gson))
                    .build();
        }

        return retrofit;
    }

    public static HttpLoggingInterceptor setHttpLoggingInterceptor() {
        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(message -> Log.e("HttpLoggingInterceptor", "message : " + message));
        return interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
    }
}

 

참고로 xampp를 쓸 경우 localhost를 써도 작동하지 않을 수가 있다. 그럴 때는 10.0.2.2를 넣어주면 작동한다.

이제 XML과 자바 파일을 코딩해준다. 결과 확인을 위해 editText, 버튼, 텍스트뷰만 대충 갖다놨다.

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/edittext"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginStart="20dp"
        android:layout_marginEnd="20dp"/>

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="40dp"
        android:text="API 호출"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edittext"/>

    <TextView
        android:id="@+id/result_textview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:gravity="center"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/button"/>

</androidx.constraintlayout.widget.ConstraintLayout>
import android.os.Bundle;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;

import java.io.IOException;

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

public class MainActivity extends AppCompatActivity {

    private final String TAG = this.getClass().getSimpleName();

    private EditText editText;
    private Button button;
    private TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        editText = findViewById(R.id.edittext);
        button = findViewById(R.id.button);
        textView = findViewById(R.id.result_textview);

        button.setOnClickListener(v -> testMethod(editText.getText().toString()));
    }

    private void testMethod(String userId) {
        TestInterface testInterface = TestApiClient.getRetrofit().create(TestInterface.class);
        Call<String> call = testInterface.testApiCall(userId);
        call.enqueue(new Callback<String>() {
            @Override
            public void onResponse(Call<String> call, Response<String> response) {
                if (response.isSuccessful()) {
                    Log.e(TAG, "isSuccessful() : " + response.body());
                } else {
                    Log.e(TAG, "isSuccessful()이 아님 : " + response.body());
                }
                textView.setText(response.body());
            }

            @Override
            public void onFailure(Call<String> call, Throwable t) {
                Log.e(TAG, "에러 : " + t.getMessage());
            }
        });
    }
}

 

이 상태로 실행하면 아래와 같이 로그가 출력된다.

 

실패 케이스를 입력할 경우 null이 출력되고 텍스트뷰에도 결과가 set되지 않는다. 4xx 에러를 갖고 오지 못하는 것이다.

그런데 요구사항 중 401 에러를 받을 경우 어떤 문장을 토스트로 출력하라는 요구사항이 있다면 어떻게 해아 할까?

방법은 onResponse() 안의 else 내부 코드를 아래와 같이 바꾸는 것이다.

 

import android.os.Bundle;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;

import java.io.IOException;

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

public class MainActivity extends AppCompatActivity {

    private final String TAG = this.getClass().getSimpleName();

    private EditText editText;
    private Button button;
    private TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        editText = findViewById(R.id.edittext);
        button = findViewById(R.id.button);
        textView = findViewById(R.id.result_textview);

        button.setOnClickListener(v -> testMethod(editText.getText().toString()));
    }

    private void testMethod(String userId) {
        TestInterface testInterface = TestApiClient.getRetrofit().create(TestInterface.class);
        Call<String> call = testInterface.testApiCall(userId);
        call.enqueue(new Callback<String>() {
            @Override
            public void onResponse(Call<String> call, Response<String> response) {
                if (response.isSuccessful()) {
                    Log.e(TAG, "isSuccessful() : " + response.body());
                    textView.setText(response.body());
                } else {
                    try {
                        String body = response.errorBody().string();
                        Log.e(TAG, "error - body : " + body);
                    }   catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }

            @Override
            public void onFailure(Call<String> call, Throwable t) {
                Log.e(TAG, "에러 : " + t.getMessage());
            }
        });
    }
}

 

그럼 이렇게 로그가 찍힌다.

 

이제 null이 아니라 4xx 에러를 받아서 파싱할 수 있게 됐다.

gif가 아니라 스크린샷으로도 확인해 보자. 먼저 성공 케이스인 aaa를 입력 후 API를 호출하면 로그는 이렇게 나온다.

 

 

isSuccessful()이 호출되고 서버에서 받은 응답을 보여주고 있다. 여기서 실패 케이스인 abc를 입력 후 api를 호출하면 로그 내용이 조금 바뀐다.

 

isSuccessful()이 아니라 try-catch 안의 error - body 부분이 출력된다. 왜 이 부분은 if에 설정한 isSuccessful()에 해당하지 않고 else로 빠져서 출력되는 걸까? 왜 null이 나오지 않을까? 해답은 아래의 공식문서에 있다.

 

https://square.github.io/retrofit/2.x/retrofit/retrofit2/Response.html#isSuccessful--

 

Response (Retrofit 2.7.1 API)

static  Response error(int code, okhttp3.ResponseBody body) Create a synthetic error response with an HTTP status code of code and body as the error body.

square.github.io

isSuccessful() : code()가 200~300 범위에 있으면 true를 반환한다

 

그렇다. isSuccessful()은 서버 응답이 2~300에 속할 때만 호출되는 메서드다. 그래서 400대의 HTTP 코드를 갖는 서버 응답을 받지 못하고 else로 빠져나가는 것이다.

그래서 4xx 에러 코드와 같이 딸려오는 JSON 값이 로그에 찍히고, 난 이걸 받아서 파싱할 수 있게 된 것이다.

참고로 errorBody()의 설명은 아래와 같다.

 

errorBody() : 실패한 응답의 원시 응답 본문이다

 

협업 시 에러코드 별로 어떤 문구를 사용자에게 보여줄지 백엔드 개발자와 약속하고 문서로 남기는 경우가 많다. 이를 잘 정의해 놓고 클라이언트에서 4xx 에러 코드를 갖는 응답을 파싱하지 못한다면 문제가 될 수 있으니, 이 글을 참고해서 4xx 에러 코드를 파싱해 보자.

반응형
Comments