이용자가 로그인하면 응답받은 회원 정보로 계좌목록 조회해 주시고 각 계좌별 은행 서버에 잔액 조회해 주시고 대표계좌는 최근 일주일 거래내역 조회해 주시고 각 거래내역 별 .....
위 상황은 물론 과장한 가상의 케이스이지만 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"]}")
}
이제 완벽하게 문제를 해결한 코드가 됐다.
예시를 네트워크 요청으로 들었지만, 이런 상황 외에도 여러 비동기 처리 로직에서 이용하기 좋은 방법이라 생각된다.