본문으로 바로가기
반응형

Photo by unsplash

앞선 포스팅에서 Ktor를 이용한 서버를 생성했습니다. Ktor는 다양한 platform에서의 동작을 지원하며, client 기능도 제공하고 있으므로 이를 이용하여 Android에 적용해 보도록 하겠습니다. 

서버 명세

앞선 포스팅에서 만든 서버는 아래 형태의 http api를 제공합니다.

1. GET 방식

  • Method: GET
  • Request url: http://192.168.0.6/picture?id=0
  • Result json(sample):
    • {
          "title":"falls",
          "location":"No address!!",
          "imageUrl":"http://192.168.0.6/nature/nature1.jpg"
      }

2. POST 방식

  • Method: POST
  • Request url: http://192.168.0.6/picture
  • Request body(sample):
    •  
    • { "pictureId":"0" }
  • Result json(sample):
    • {
          "title":"falls",
          "location":"No address!!",
          "imageUrl":"http://192.168.0.6/nature/nature1.jpg"
      }

build.gradle

android에서 ktor client를 사용하기 위해서는 depecency의 추가가 필요합니다. 따라서 app 단위 build.gradle에 아래와 같이 추가합니다.

dependencies {

...
    //Ktor
    implementation "io.ktor:ktor-client-cio:$ktor_version"
    implementation "io.ktor:ktor-client-json:$ktor_version"
    implementation "io.ktor:ktor-client-serialization-jvm:$ktor_version"
    implementation "io.ktor:ktor-client-logging-jvm:$ktor_version"
...
  • io.ktor:ktor-client-cio: http 엔진은 CIO (Coroutine I/O)를 사용.
  • io.ktor:ktor-client-json: json을 기반으로 데이터를 주고 받기위해 추가.
  • io.ktor:ktor-client-serialization-jvm: kotlinx.serialization을 이용하여 json 객체를 converting하기 위해 추가.
  • io.ktor:ktor-client-logging-jvm: network logging을 위해 추가.

제가 사용한 버전은 아래와 같습니다.

추가적으로 kotlinx.serialization을 사용하기 위해서는 plugin에 아래 항목을 추가해야 합니다.

org.jetbrains.kotlin.plugin.serialization

해당 구문을 앱단위 build.grdle 최상단에 아래와 같이 추가합니다. 다른 형태의 build 명세를 사용한다면 kotlinx.serialzation 공식 페이지에서 import 방법을 확인해야 합니다.[2]

build.gradle(app 단위)

Http client의 생성

간략하게 MVVM 패턴을 이용하여 구조를 구성합니다.

  • RestFulTestActivity: 서버에서 얻어온 결과 표시를 위한 화면 구성
  • RestFulTestViewModel: 서버 요청 및 결과 activity로 전달
  • RestFulTestRepository: http client 생성 및 serve request 구현

먼저 RestFulTestRepositoryHttpClient 생성 코드를 만듭니다.

class RestFulTestRepository {
    companion object {
        ...
    }
    
    private val client: HttpClient

    private val json = Json {
        ignoreUnknownKeys = true // 모델에 없고, json에 있는경우 해당 key 무시
        prettyPrint = true
        isLenient = true // "" 따옴표 잘못된건 무시하고 처리
        encodeDefaults = true //null 인 값도 json에 포함 시킨다.
    }

    init {
        client = HttpClient(CIO) {            
            // Json serializer
            install(JsonFeature) {
                serializer = KotlinxSerializer(json)
            }
    }

client 객체는 HttpClient를 이용하여 만들 수 있습니다. 이때 CIO(Coroutien I/O)를 client 엔진으로 사용하기 위해 param으로 넘겨줍니다. 만약 okHttp 같은 다른 엔진을 사용한다면 아래와 같이 설정 할 수 있습니다.[3]

//build.gradle dependencies에 추가
implementation "io.ktor:ktor-client-okhttp:$ktor_version"

// httpClient 생성
client = HttpClient(Okhttp) { ... }

또는 android에서 제공하는 HttpURLConnection을 쓴다면,

//build.gradle dependencies에 추가
implementation "io.ktor:ktor-client-android:$ktor_version"

// httpClient 생성
client = HttpClient(Android) { ... }

데이터 전달은 json으로 하므로 install(JsonFeature) {..} 를 추가 합니다. 블럭 내부에서는 사용할 json converter를 KotlinSerializer로 설정했습니다. 이때 넘겨주는 Json 객체는 기본값을 넘겨줘도 상관 없으나 추가적인 옵션을 넣었습니다. kotlinx.serialization의 사용법 및 추가 옵션 상세 내용은 이전 포스팅에서 확인할 수 있습니다.[4]

만약 json converter를 Gson이나 Jackson을 사용하고 싶다면 아래와 같이 변경이 가능합니다.

//build.gradle dependencies에 추가
implementation "io.ktor:ktor-client-gson:$ktor_version"

val client = HttpClient(CIO) {
    install(JsonFeature) {
        serializer = GsonSerializer()
    }
}

//build.gradle dependencies에 추가
implementation "io.ktor:ktor-client-jackson:$ktor_version"

val client = HttpClient(CIO) {
    install(JsonFeature) {
        serializer = JacksonSerializer()
    }
}

사실 이 정도만 하더라도 httpClient의 생성이 완료됩니다. 추후 GETPOST에 대한 코드가 들어가겠지만 코드 몇줄로 client를 생성할 수 있습니다.

HttpClient 기본 Header 추가

위 코드만으로도 client가 동작하긴 하지만 많이 사용하는 기능들을 추가로 설정할 수 있습니다. 헤더는 필요에 따라 GET또는 POST시점에 해당 코드 부분에서 추가할 수 있습니다. 다만 고정적으로 사용하는 기본 header가 있다면 http client를 생성하면서 바로 설정이 가능합니다.

 

client = HttpClient(CIO) {
        // Header 또는 기본값
        defaultRequest {
            header("Accept", "application/json")
            header("Content-type", "application/json")

           // or 직접 accept, contentType로 정의
//          accept(ContentType.Application.Json)
//          contentType(ContentType.Application.Json))
            // param 추가시    
//          parameter("api_key", "mykey")
            }
            ...

header에 acceptcontent-type에 대한 정의를 추가합니다. header() 함수를 이용할수도 있고, 아예 따로 정의된 accept(), contentType() 함수를 사용할 수 있습니다. token이나 api-key를 넣어야 한다면 parameter() 함수로 추가가 가능합니다.

defualutRequest 블럭을 사용하지 않고 install을 이용해서 설정할 수도 있습니다.

client = HttpClient(CIO) {
        // Header 또는 기본값
//        defaultRequest {
//            header("Accept", "application/json")
//           header("Content-type", "application/json")
//         }

           install(DefaultRequest) {
               header("Accept", "application/json")
               header("Content-type", "application/json")
           }
         ...

HttpClient Timeout 설정

client = HttpClient(CIO) {
    ...
     // Timeout
        install(HttpTimeout) {
            requestTimeoutMillis = 10_000L
            connectTimeoutMillis = 10_000L
            socketTimeoutMillis = 10_000L
        }
    ...

timeout에 대한 설정은 install(HttpTimeout) {..}을 사용합니다.

Logging 설정

client = HttpClient(CIO) {
    ...
    //Logging
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.ALL
    }
    ....

http connection에 대한 로깅을 위해 logger를 설정합니다. 다만 위와같이 설정하면 console에 표현되므로 android 설정에 맞게 logcat에 표시하려면 아래와 같이 사용해야 합니다.

client = HttpClient(CIO) {
    ...
    //Logging
    install(Logging) {
        logger = object: Logger {
            override fun log(message: String) {
                Log.d(TAG, message)
            }
        }
//      logger = Logger.DEFAULT
        level = LogLevel.ALL
    }
    ....

정리해 보면 최종적으로 생성된 httpClient는 아래와 같습니다.

init {
    client = HttpClient(CIO) {
        // Header 또는 기본값
        defaultRequest {
            header("Accept", "application/json")
            header("Content-type", "application/json")
        }


        // Json serializer
        install(JsonFeature) {
            serializer = KotlinxSerializer(json)
        }

        // Timeout
        install(HttpTimeout) {
            requestTimeoutMillis = 10_000L
            connectTimeoutMillis = 10_000L
            socketTimeoutMillis = 10_000L
        }

        //Logging
        install(Logging) {
            logger = object: Logger {
                override fun log(message: String) {
                    Log.d(TAG, message)
                }
            }
            level = LogLevel.ALL
        }
    }
}

GET / POST의 구현

client를 생성했다면 GET / POST 는 아래와 같이 한줄정도로 호출할 수 있습니다.

class RestFulTestRepository {
    companion object {
        private const val TAG = "RestFulTestRepository"
        const val BASE_URL = "http://192.168.0.6"
    }
    ...
    init {
        client = HttpClient(CIO) {
       ...
    }

    ...

    @Throws
    suspend fun getPictureByGet(id: Int) =
        client.get<Picture>(BASE_URL + "/picture") {
            parameter("id", 0)
    }

    @Throws
    suspend fun getPictureByPost(pictureRequest: PictureRequest) =
        client.post<Picture>(BASE_URL + "/picture") {
            body = pictureRequest
    }
}

여기서 getpost는 모두 suspend function입니다. 따라서 coroutine scope에서 호출되어야 하며 Main thread에서 호출해서는 안됩니다.

이 두개의 api는 http 정상 응답인 2xx가 아닌경우 exception을 발생시킵니다. 이때 발생하는 exception 처리를 위해 아래와 같은 getErrorStatus() 함수를 추가합니다.

class RestFulTestRepository {
    ...

    init {
        client = HttpClient(CIO) {
          ...
    }

    fun getErrorStatus(th: Throwable): Int = when (th) {
        is RedirectResponseException -> { //Http Code: 3xx
            (th.response.status.value)
        }
        is ClientRequestException -> { //Http Code: 4xx
            (th.response.status.value)
        }
        is ServerResponseException -> { //Http Code: 5xx
            (th.response.status.value)
        }
        is UnresolvedAddressException -> { // Network Error - Internet Error
            1000
        }
        else -> 9999 // Unknown
    }

    @Throws
    suspend fun getPictureByGet(id: Int) =
      ...
    }

    @Throws
    suspend fun getPictureByPost(pictureRequest: PictureRequest) =
        ...
    }

Json 처리를 위한 객체 추가

get / post 방식 모두 동일한 형태의 json 결과를 받습니다. 결과를 담을 Picture 클래스와, post 방식으로 요청할때 pictureId를 넘겨줄 PictureRequest 클래스를 추가합니다.

@Serializable
data class Picture(
    val title: String,
    val location: String,
    val imageUrl: String
)

@Serializable
data class PictureRequest(val pictureId:Int)

사실 이 두 class는 서버에서 만든것과 동일합니다. 서버와 클라이언트 모두 kotlin을 사용하기에 동일한 class를 복사해서 만들 수 있습니다.

Coroutine scope에서의 호출

repository의 구현이 완료 되었으므로 ViewModel에서 이를 호출하는 requestPicture() 함수를 구현합니다.

@HiltViewModel
class RestFulTestViewModel @Inject constructor(
    private val restFulRepository: RestFulTestRepository) : ViewModel() {
    
    companion object {
        private const val TAG = "RestFulTestViewModel"
    }

    private val _pictureData = MutableStateFlow(Picture("", "", ""))
    val pictureData: StateFlow<Picture> = _natureData

    fun requestPicture() {        
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val response = restFulRepository.getPictureByGet(0)
//                val response = restFulRepository.getPictureByPost(PictureRequest(1))
                Log.i(TAG, "requestRestFul() - success:$response")
                _pictureData.emit(response)
            } catch (th: Throwable) {
                Log.e(TAG, "Error:Code: ${restFulRepository.getErrorStatus(th)}")
            }
        }
    }
}

Dispathers.IO로 설정된 viewModelScope에서 launchpostget 함수를 호출합니다. 호출이 완료되었으면 stateFlowemit하여 데이터를 전달하도록 합니다.

Activity 화면에 표시

앱을 구동하면 서버를 호출하여 아래와 같이 화면에 받아온 정보를 보여주도록 구성합니다.

onRsume()에서 viewmodel의 requestPicture()함수를 호출하여 서버에 데이터를 요청합니다.

결과 데이터는 stateFlow에 들어가므로 collectAsState()를 이용하여 composable 함수인 RestFulTestScreen()에 넘겨줍니다.

@AndroidEntryPoint
class RestFulTestActivity : AppCompatActivity() {
    private val viewModel: RestFulTestViewModel by viewModels()

    @ExperimentalCoilApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                val data by viewModel.pictureData.collectAsState()
                RestFulTestScreen(data)
            }
        }
    }

    override fun onResume() {
        super.onResume()
        viewModel.requestPicture()
    }
}

@ExperimentalCoilApi
@Composable
fun RestFulTestScreen(data: Picture) {
    Box(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.align(Alignment.Center).padding(20.dp)) {
            Image(
                painter = rememberImagePainter(data.imageUrl),
                contentDescription = "",
                modifier = Modifier.size(300.dp).border(1.dp, Color.Gray).align(Alignment.CenterHorizontally),
                        contentScale = ContentScale.Crop
            )
            Divider(Modifier.padding(10.dp))
            Text("Title: ${data.title}")
            Text("Location: ${data.location}")
        }
    }
}

composable 내부에서는 넘겨받은 데이터를 표기하고 imageUrlcoil을 이용하여(rememberImagePainter)로드합니다.

물론 coil을 사용하기 위해 gradle에 아래와 같이 dependency를 추가 합니다.

 

마치며

간단하게 따라하기 형태로 ktor server 생성 및 client를 구현해 봤습니다. 동일한 kotlin 언어를 사용함으로써 복붙의 이점도 있었지만 궁극적으로는 client 개발자가 server 개발을 하기 위한 진입장벽이 매우 낮아졌음이 가장 크게 와 닿았습니다. 실제로 spring boot를 공부할때보다 학습 시간은 절반 이하였던것 같습니다.

서버 개발을 하지 않는 client 개발자라도, 간단한 서버 프로그램이나, 테스트를 위해 서버를 구축해야 할 일이 발생할 수 있습니다. 이 프로젝트들은 kotlin으로만 작성되어, kotlin 생태계를 구성하는 corouitne, kotlinx.serialization 및 compose로 UI를 구현하고 coil(coroutine기반의 비동기 이미지 로딩)까지 사용하여 통일성 있는 방식으로 프로젝트를 구성 하였습니다.

HTTP api를 구현할때 Ktor-client를 사용할지, Retrofit 사용할지에 대한 선택은 개발자의 몫입니다. 여기서는 성능과 확장성, 활용성등에 대한 논의로 어떤게 더 좋다라는 결정을 하지는 않습니다. (사실 그정도의 판단은 실제로 적용해 보고 나서 판단이 가능할듯 합니다.) 다만 여기서는 ktor라는 새로운 framework에 대한 소개를 중점적으로 나열했고, client 개발자가 쉽게 서버코드를 작성하고 읽을수 있다는 부분을 어필하고 싶었습니다.

여기에 더하여 DB를 연동하거나, Web service를 만들거나, web socket을 구현 하는것도 어렵지 않으니 spring boot나, okHttp의 web socket등 기존 구현과 비교해 보면서 본인에게 더 잘 맞는 서비스를 선택해보는걸 추천 드립니다.

References

[1] 2022.01.24 - [개발이야기/Spring & Ktor Framework] - [Ktor] HTTP API (Restful api) Server 생성 #2

[2] https://github.com/Kotlin/kotlinx.serialization#using-the-plugins-block

[3] https://betterprogramming.pub/how-to-use-ktor-in-your-android-app-a99f50cc9444

[4] 2020.12.15 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 -JSON features #6

반응형