Language/Kotlin

[Kotlin/Coroutine] 코루틴으로 비동기처리 Cool 하게 하기

djawnstj 2023. 9. 5. 15:21

이용자가 로그인하면 응답받은 회원 정보로 계좌목록 조회해 주시고 각 계좌별 은행 서버에 잔액 조회해 주시고 대표계좌는 최근 일주일 거래내역 조회해 주시고 각 거래내역 별 .....

위 상황은 물론 과장한 가상의 케이스이지만 API 요청에 대한 응답을 받은 후 다른 API 요청을 보내는 일은 실제로 빈번하다.

꼭 API 요청이 아니더라도 비동기적으로 처리하는 로직에 대해 종료시점 또는 응답값 반환시점에 필요한 행위를 하기 위해 Callback 을 많이 사용한다.

 

Callback

위와 같은 상황을 해결하기 위해선(네트워크 요청이 비동기적이라는 가정 하에) 아래처럼 응답을 받은 후 호출할 함수를 인자로 넘겨주어 순차적으로 호출되도록 만든다.

fun main() {
    foo() {
        boo(it) {
            // ...
        }
    }
}

하지만 이렇게 하면 문제점이 존재하는데...

Callback Hell

콜백이 많아지면 Callback Hell 에 빠진다. 

이런 코드는 가독성도 해치고 유지보수성도 낮아진다.

 

코틀린의 코루틴을 사용하면 이런 문제를 어느 정도 해결할 수 있다.

 

네트워크 요청을 통해 콜백과 코루틴 비교

비동기 네트워크 요청하는 코드를 이용해 콜백과 코루틴을 사용했을 때 각각 어떻게 달라지는지 비교해보고자 한다.

 

우선 네트워크 요청에 필요한 간단한 네트워크 요청 클라이언트를 구현했다.

private val client = OkHttpClient.Builder().build()

private const val BASE_URL = "http://localhost:8080"

private val gson = Gson()
private val mapType = object : TypeToken<Map<String, String>>() {}.type

// Json 문자열을 Map 으로 변환
private fun toMap(bodyString: String): Map<String, String>  = gson.fromJson(bodyString, mapType)

fun createRequest(url: String, method: String, body: RequestBody? = null): Request {

    val builder = Request.Builder()
        .url(url)

    when (method.uppercase()) {
        "GET" -> builder.get()
        "POST" -> body?.let { builder.post(it) }
    }

    return builder.build()
}

클라이언트는 OkHttpClient 를 이용해 간단하게 구성했다.

 

아래는 네트워크 요청/응답에 사용할 DTO 객체이다.

data class UserRequest(
    val id: Int? = null,
    val name: String? = null,
    val age: Int? = null
) {
    fun toQueryStringMap(): Map<String, String> = buildMap {
        this@UserRequest.let { request ->
            request.id?.let { put("id", it.toString()) }
            request.name?.let { put("id", it) }
            request.age?.let { put("id", it.toString()) }
        }
    }

    fun toJsonBody(): String = Gson().toJson(this)
}

data class UserResponse(
    @SerializedName("code")
    val code: Int,
    @SerializedName("id")
    val id: Int? = null,
    @SerializedName("name")
    val name: String? = null,
    @SerializedName("age")
    val age: Int? = null
) {
    override fun toString(): String {
        return "UserResponse(code=$code${if (id != null) ", id=$id" else ""}${if (name != null) ", name=$name" else ""}${if (age != null) ", age=$age" else ""})"
    }
}

 

콜백을 이용한 비동기 네트워크 요청

우선 고전적인 콜백을 이용해서 비동기 네트워크 요청을 하는 코드로 구현해 봤다.

private fun baseCallV1(uri: String, method: String, body: RequestBody? = null, callback: (response: Response) -> Unit) {
    val request = createRequest("$BASE_URL$uri", method, body)

    Thread {
        client.newCall(request).execute().use(callback)
    }.start()
}

fun callSaveUserV1() {
    baseCallV1("/users", "POST", UserRequest(name = "hong", age = 25).toJsonBody().toRequestBody("application/json".toMediaType())) {
        it?.body?.string()?.let { bosyString ->
            val body: Map<String, String> = toMap(bosyString)

            callGetAgeV1(body["id"]?.toInt() ?: throw IllegalArgumentException("id is null"))
        }
    }
}

fun callGetAgeV1(id: Int) {
    baseCallV1("/users/age?id=$id", "GET") {
        it?.body?.string()?.let { bosyString ->
            val body: Map<String, String> = toMap(bosyString)

            println("result: ${body["age"]}")
        }
    }
}

callSaveUserV1() 을 호출해 이용자 정보를 저장하면, 응답으로 받은 id 값으로 age 를 조회하는 요청을 보내는 로직이다.

 

callSaveUserV1() 함수 안에서 응답을 받으면 콜백을 호출해 callGetAgeV1() 을 호출하게 된다.

이런 로직이 늘어나면 위에서 본 Callback Hell 처럼 콜백이 그득하게 있는 코드가 되는 거다.

 

이 경우 함수의 단위 테스트가 불가능하는 등 문제가 있을 수 있다.

 

그럼 코루틴으로 이런 문제를 해결해 보겠다.

 

코루틴으로 비동기처리 1

private fun baseCallV2(uri: String, method: String, body: RequestBody? = null): Response {
    val request = createRequest("$BASE_URL$uri", method, body)
    return client.newCall(request).execute()
}

fun callSaveUserV2(): Map<String, String>  {
    val response = baseCallV2("/users", "POST", UserRequest(name = "hong", age = 25).toJsonBody().toRequestBody("application/json".toMediaType()))
    return response.body?.string()?.let { toMap(it) } ?: emptyMap()
}

fun callGetAgeV2(id: Int): Map<String, String> {
    val response = baseCallV2("/users/age?id=$id", "GET")
    return response.body?.string()?.let { toMap(it) } ?: emptyMap()
}

우선 네트워크 요청을 보내는 코드이다.

눈에 띄는 차이점은 Thread 를 이용하지 않았기 때문에 명시적 반환값을 사용할 수 있다는 것이다.

 

코루틴은 분명 비동기 프로그래밍으로 알려져 있는데 Thread 생성 없이 어떻게 비동기를 처리한단 말인가?

 

이 비밀은 코루틴과 스레드의 차이에 있다. 이 둘의 차이를 간단하게 말하자면

  • 스레드: 작업공간을 분리시켜 각 스레드 간 메모리 공유가 불가
  • 코루틴: 하나의 스레드 내에서 여러 루틴을 번갈아가며 실행이 가능. 메모리 공유되고, 루틴을 잠시 중지시키면 사용하던 변수를 보관

라고 할 수 있다.

 

코루틴을 사용하려면 일단 suspend 함수 내에서 호출해야 한다. 코루틴을 생성하는 간단한 방법은 함수를 runBlocking 블록으로 감싸면 된다.

suspend fun main(): Unit = runBlocking {
    val saveUserApiResponse: Deferred<Map<String,String>> = async(Dispatchers.IO) { callSaveUserV2() }
    val getAgeApiResponse: Deferred<Map<String,String>> = async(Dispatchers.IO) { callGetAgeV2(saveUserApiResponse.await()["id"]?.toInt() ?: throw IllegalArgumentException("id is null")) }
    println("result: ${getAgeApiResponse.await()["age"]}")
}

코루틴의 async 를 사용해 위와 같이 깔끔하게 해결을 해주었다.

 

async 를 이용하면 Deferred 를 반환받게 된다. 이 Deferred 변수에 await() 를 호출하면 해당 루틴시 실행되고 반환값을 받을 수 있다.

 

이 정도도 충분히 좋지만 작은 문제가 더 존재한다. 바로 반환값인 Deferred 에 의존하게 된다는 것이다.

이렇게 되면 수평확장에 닫혀있을 수 있으므로 Map<String, String> 를 바로 반환받도록 하는 것이 좋다.

 

코루틴으로 비동기처리 2

이 문제를 해결하는 방법은 간단하다.

코루틴 안에서 호출할 루틴들을 코루틴(suspend fun)으로 바꿔주면 된다.

private suspend fun baseCallV3(uri: String, method: String, body: RequestBody? = null): Response = CoroutineScope(Dispatchers.IO).async {
    val request = createRequest("$BASE_URL$uri", method, body)
    client.newCall(request).execute()
}.await()

suspend fun callSaveUserV3(): Map<String, String> = coroutineScope {
    async {
        val response = baseCallV3("/users", "POST", UserRequest(name = "hong", age = 25).toJsonBody().toRequestBody("application/json".toMediaType()))
        response.body?.string()?.let { toMap(it) } ?: emptyMap()
    }.await()
}

suspend fun callGetAgeV3(id: Int): Map<String, String> = coroutineScope {
    async {
        val response = baseCallV3("/users/age?id=$id", "GET")
        response.body?.string()?.let { toMap(it) } ?: emptyMap()
    }.await()
}

suspend fun main(): Unit = runBlocking {
    val saveUserApiResponse: Map<String, String> = callSaveUserV3()
    val getAgeApiResponse: Map<String, String> = callGetAgeV3(saveUserApiResponse["id"]?.toInt() ?: throw IllegalArgumentException("id is null"))

    println("result: ${getAgeApiResponse["age"]}")
}

이제 완벽하게 문제를 해결한 코드가 됐다.

예시를 네트워크 요청으로 들었지만, 이런 상황 외에도 여러 비동기 처리 로직에서 이용하기 좋은 방법이라 생각된다.

반응형