관리 메뉴

나만을 위한 블로그

[Android] @SerializedName이 둘 이상의 필드명을 탐색하게 설정하는 법 본문

Android

[Android] @SerializedName이 둘 이상의 필드명을 탐색하게 설정하는 법

참깨빵위에참깨빵 2024. 2. 14. 22:01
728x90
반응형

※ 이 포스팅의 내용은 Gson 2.4 이상부터 사용할 수 있다. 2.4 미만의 버전을 사용 중이라면 버전을 올려야 한다

 

안드로이드에서 서버와 비동기 통신을 수행하려면 보통 레트로핏을 사용한다. 그리고 @SerializedName을 써서 서버에서 받은 값을 역직렬화하고 앱에서 만들어 둔 data class의 변수에 대입해서 사용한다.

그러나 @SerializedName 안에는 특별한 일이 없다면 하나의 문자열만 넣어서, 그 문자열에 해당하는 값을 JSON 문자열에서 가져온다. 이 때 서버에서 내려주는 JSON의 필드가 스네이크 케이스, 카멜 케이스, 파스칼 케이스인 경우에 모두 대응하려면 어떻게 해야 할까?

이 경우가 아니라도 하나의 @SerializedName이 여러 필드명을 알고 있어야 한다면 어떻게 구현해야 할까?

 

Gson의 deserialze()를 커스텀하는 방법도 있지만 가장 간편한 방법은 @SerializedName을 사용하고, 이 안에 value, alternate 매개변수를 각각 설정하는 것이다.

 

https://stackoverflow.com/a/71788597

 

Is there a Gson @DeserializedName equivalent to the @SerializedName annotation?

I'm able to set the @SerializedName to this_field so when I use the toJson() function it'll use it properly. However when I'm trying to read it in via fromJson() function it'll try to use thisField.

stackoverflow.com

@SerializedName(value="this_field", alternate={"thisField"}) 
private Integer thisField;
(중략)...this_field를 쓸 수 있다면 사용하고, 쓸 수 없다면 thisField를 사용한다

 

value와 alternate를 정리하면 아래와 같다.

 

  • value : 내가 받을 것으로 예상한 JSON 필드명. {"name":"a"} 형태의 값을 받을 경우 "name"이 value에 해당
  • alternate : value로 설정한 필드명을 찾을 수 없는 경우 대신 사용할 JSON 필드명. {"userName":"a"} 형태의 값을 받을 경우 value로 "name"을 설정하고 alternate로 "userName"을 설정해도 값을 가져올 수 있다.

 

예제를 보면서 확인한다. 무료 API 중 NBA 선수의 정보를 확인할 수 있는 API를 사용할 것이다. 데이터 바인딩, hilt 라이브러리 설정 과정은 생략한다.

먼저 data class부터 만든다.

 

import com.google.gson.annotations.SerializedName

data class Player(
    @SerializedName("id")
    val id: Int,
    @SerializedName("firstName")
    val firstName: String,
    @SerializedName("height_feet")
    val heightFeet: Int,
    @SerializedName("height_inches")
    val heightInches: Int,
    @SerializedName("last_name")
    val lastName: String,
    @SerializedName("position")
    val position: String,
    @SerializedName("team")
    val team: PlayerTeam,
    @SerializedName("weight_pounds")
    val weightPounds: Int,
)

data class PlayerTeam(
    @SerializedName("id")
    val teamId: Int,
    @SerializedName("abbreviation")
    val abbreviation: String,
    @SerializedName("city")
    val city: String,
    @SerializedName("conference")
    val conference: String,
    @SerializedName("division")
    val division: String,
    @SerializedName("full_name")
    val fullName: String,
    @SerializedName("name")
    val teamName: String,
)

 

그리고 인터페이스에 메서드를 정의한다.

 

import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path

interface NbaPlayerService {
    @GET("api/v1/players/{id}}")
    suspend fun getPlayer(
        @Path("id") id: Int
    ): Response<Player>
}

 

이후 hilt를 사용하기 위한 object를 정의한다.

 

import com.example.myapp.data.NbaPlayerService
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideGson(): Gson = GsonBuilder().setLenient().create()

    @Provides
    @Singleton
    fun provideOkHttpInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
        setLevel(HttpLoggingInterceptor.Level.BODY)
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(1, TimeUnit.MINUTES)
        .readTimeout(1, TimeUnit.MINUTES)
        .writeTimeout(1, TimeUnit.MINUTES)
        .addInterceptor(provideOkHttpInterceptor())
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl("https://www.balldontlie.io/")
        .client(provideOkHttpClient())
        .addConverterFactory(GsonConverterFactory.create(provideGson()))
        .build()

    @Provides
    @Singleton
    fun provideNbaPlayerService(): NbaPlayerService =
        provideRetrofit().create(NbaPlayerService::class.java)
}

 

그리고 인터페이스를 사용하는 레포지토리, 뷰모델을 차례로 만든다.

 

import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NbaPlayerRepository @Inject constructor(
    private val nbaPlayerService: NbaPlayerService
) {
    suspend fun getPlayer(id: Int): Response<Player> = nbaPlayerService.getPlayer(id)
}
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.myapp.data.NbaPlayerRepository
import com.example.myapp.data.Player
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class NbaPlayerViewModel @Inject constructor(
    private val nbaPlayerRepository: NbaPlayerRepository
): ViewModel() {
    private val _playerInfo = MutableLiveData<Player?>()
    val playerInfo: LiveData<Player?> = _playerInfo

    fun getPlayer(id: Int) = viewModelScope.launch {
        val result = nbaPlayerRepository.getPlayer(id)
        if (result.isSuccessful && result.body() != null) {
            _playerInfo.value = result.body()
        } else {
            _playerInfo.value = null
        }
    }
}

 

그리고 액티비티에서 뷰모델의 함수를 호출한 다음 LiveData의 값을 관찰한다.

 

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.example.myapp.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val nbaPlayerViewModel: NbaPlayerViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        getPlayer(237)
        nbaPlayerViewModel.playerInfo.observe(this) { player ->
            player?.let {
                binding.tvPlayer.text = it.toString()
                Timber.e("player 정보 확인 : $it")
            }
        }
    }

    private fun getPlayer(id: Int) {
        nbaPlayerViewModel.getPlayer(id)
    }
}

 

이후 앱을 실행하면 서버로부터 받은 응답과 선수 정보를 로그로 확인하면 아래와 같다.

 

{
  "id":237,
  "first_name":"LeBron",
  "height_feet":6,
  "height_inches":8,
  "last_name":"James",
  "position":"F",
  "team":{
    "id":14,
    "abbreviation":"LAL",
    "city":"Los Angeles",
    "conference":"West",
    "division":"Pacific",
    "full_name":"Los Angeles Lakers",
    "name":"Lakers"
  },
  "weight_pounds":250
}
player 정보 확인 : Player(id=237, firstName=LeBron, heightFeet=6, heightInches=8, lastName=James, position=F, team=PlayerTeam(teamId=14, abbreviation=LAL, city=Los Angeles, conference=West, division=Pacific, fullName=Los Angeles Lakers, teamName=Lakers), weightPounds=250)

 

별 문제 없이 API로부터 받은 JSON의 필드들이 data class에 담긴다.

그러나 위에서 말한 대로 하나의 @SerializedName이 여러 필드명을 알고 있어야 하는 경우가 생길 수 있다.

이에 대응하려면 스택오버플로우에서 말하는 대로 @SerializedName 안의 내용을 조금 바꿔주기만 하면 된다. 아래 코드의 firstName 부분을 확인한다.

 

import com.google.gson.annotations.SerializedName

data class Player(
    @SerializedName("id")
    val id: Int,
    @SerializedName(value = "firstName", alternate = ["first_name", "FirstName"])
    val firstName: String,
    @SerializedName("height_feet")
    val heightFeet: Int,
    @SerializedName("height_inches")
    val heightInches: Int,
    @SerializedName("last_name")
    val lastName: String,
    @SerializedName("position")
    val position: String,
    @SerializedName("team")
    val team: PlayerTeam,
    @SerializedName("weight_pounds")
    val weightPounds: Int,
)

data class PlayerTeam(
    @SerializedName("id")
    val teamId: Int,
    @SerializedName("abbreviation")
    val abbreviation: String,
    @SerializedName("city")
    val city: String,
    @SerializedName("conference")
    val conference: String,
    @SerializedName("division")
    val division: String,
    @SerializedName("full_name")
    val fullName: String,
    @SerializedName("name")
    val teamName: String,
)

 

이렇게 작성하면 Gson은 처음에 JSON 문자열을 받으면 먼저 firstName이라는 필드를 JSON 문자열 안에서 찾는다. 있다면 당연히 그걸 사용할 것이다.

만약 없다면 alternate로 선언한 배열 안의 값들(first_name, FirstName)과 일치하는 필드를 JSON 문자열 안에서 찾는다. 그 결과 일치하는 필드가 있다면 그 값을 firstName 변수에 대입한다.

위와 같이 수정하고 앱을 실행해도 처음과 동일하게 정상적으로 작동하는 걸 볼 수 있다.

 

만약 @SerializedName을 사용하지 않고 커스텀 deserializer를 구현해서 사용해야 한다면 아래와 같이 할 수 있다. 값을 찾지 못한 경우 각 타입의 기본값으로 초기화한다.

이 방법은 @SerializedName을 사용하지 않았거나, @SerializedName 안에 "first_name" 대신 다른 이상한 값("1", "abc" 등)을 넣었을 경우 동작한다.

 

import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import java.lang.reflect.Type

class PlayerDeserializer: JsonDeserializer<Player> {
    override fun deserialize(
        json: JsonElement?,
        typeOfT: Type?,
        context: JsonDeserializationContext?
    ): Player {
        val jsonObject = json?.asJsonObject

        val id = jsonObject?.get("id")?.asInt ?: 0
        val firstName = jsonObject?.get("firstName")?.asString ?: jsonObject?.get("first_name")?.asString ?: jsonObject?.get("FirstName")?.asString ?: ""
        val heightFeet = jsonObject?.get("height_feet")?.asInt ?: 0
        val heightInches = jsonObject?.get("height_inches")?.asInt ?: 0
        val lastName = jsonObject?.get("last_name")?.asString ?: ""
        val position = jsonObject?.get("position")?.asString ?: ""
        val team = context?.deserialize<PlayerTeam>(jsonObject?.get("team"), PlayerTeam::class.java) ?: PlayerTeam(0, "", "", "", "", "", "")
        val weightPounds = jsonObject?.get("weight_pounds")?.asInt ?: 0

        return Player(id, firstName, heightFeet, heightInches, lastName, position, team, weightPounds)
    }
}

class PlayerTeamDeserializer : JsonDeserializer<PlayerTeam> {
    override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): PlayerTeam {
        val jsonObject = json?.asJsonObject

        val teamId = jsonObject?.get("id")?.asInt ?: 0
        val abbreviation = jsonObject?.get("abbreviation")?.asString ?: ""
        val city = jsonObject?.get("city")?.asString ?: ""
        val conference = jsonObject?.get("conference")?.asString ?: ""
        val division = jsonObject?.get("division")?.asString ?: ""
        val fullName = jsonObject?.get("full_name")?.asString ?: ""
        val teamName = jsonObject?.get("name")?.asString ?: ""

        return PlayerTeam(teamId, abbreviation, city, conference, division, fullName, teamName)
    }
}
import com.example.myapp.data.NbaPlayerService
import com.example.myapp.data.Player
import com.example.myapp.data.PlayerDeserializer
import com.example.myapp.data.PlayerTeam
import com.example.myapp.data.PlayerTeamDeserializer
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideGson(): Gson = GsonBuilder().setLenient()
        .registerTypeAdapter(Player::class.java, PlayerDeserializer())
        .registerTypeAdapter(PlayerTeam::class.java, PlayerTeamDeserializer())
        .create()

    @Provides
    @Singleton
    fun provideOkHttpInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
        setLevel(HttpLoggingInterceptor.Level.BODY)
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(1, TimeUnit.MINUTES)
        .readTimeout(1, TimeUnit.MINUTES)
        .writeTimeout(1, TimeUnit.MINUTES)
        .addInterceptor(provideOkHttpInterceptor())
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl("https://www.balldontlie.io/")
        .client(provideOkHttpClient())
        .addConverterFactory(GsonConverterFactory.create(provideGson()))
        .build()

    @Provides
    @Singleton
    fun provideNbaPlayerService(): NbaPlayerService =
        provideRetrofit().create(NbaPlayerService::class.java)
}

 

과정이 번거롭고 관리해야 하는 클래스가 많아질 수 있기 때문에, 가급적이면 @SerializedName을 사용하고 alternate 매개변수를 활용하는 게 좋을 것 같다.

반응형
Comments