안드로이드 코루틴

PEPPERMINT100·2021년 1월 9일
2
post-thumbnail

서론

저번 글까지 안드로이드의 스레드 구분과 작업 스레드에서 UI 작업을 할 수 있게 해주며 스레드의 메시지 큐에 작업을 넣고 실행시켜주는 핸들러, 그리고 메시지 큐를 계속해서 도는 루퍼까지 해서 안드로이드의 백그라운드 처리에 대해 알아보았다.

이러한 과정을 거쳐서 코드를 작성하는 이유는 비동기 처리가 필요하기 때문이다. 안드로이드 어플리케이션이 실행되면 메인 스레드가 실행이 되고 스레드는 기본적으로 요청을 동기적으로 하나씩 처리하게 된다.

그 메인 스레드 내에서 네트워크 처리, DB 처리 또는 복잡한 연산을 하게 되면 사용자에게 보여지는 UI 처리가 늦어질 수 있다.

따라서 네트워크 처리 또는 DB 처리 등 요청의 응답을 대기해야 하는 작업, 또는 처리가 굉장히 오래 걸리는 복잡한 연산 작업은 작업 스레드(백그라운드)를 하나 더 생성하여 처리를 해주어야 한다.

이러한 프로그래밍 방식을 비동기 프로그래밍이라고 한다.

먼저 동기란 synchronous, 즉 동시에 일어나는 개념이다.(요청과 응답이 동시에 일어남) 어떤 요청이 들어가면 즉시 그 결과가 나와야 하는 것을 의미한다. 즉 작업에 소요되는 시간이 얼마나 되던지 그 자리에서 결과가 필요하기 때문에 만약 메인 스레드에서 막대한 for 문의 작업을 처리하느라 UI 처리를 못했던 부분이 동기적으로 코드가 작성되어 있기 때문이라고 할 수 있겠다.

비동기란 asynchronous, 즉 동시에 일어나지 않는 다는 것, 다시 말하면 어떤 요청이 들어갔을 때 그 요청의 결과가 바로 주어지지 않아도 된다는 것이다. 그 요청은 다른 스레드에서 작업을 하고 계속 다른 작업을 할 수 있게되고 그 결과가 주어지면 그 때 비동기적으로 결과를 내면 된다는 의미이다. 다시 말해서 우리가 스레드와 핸들러를 통해 (비교적)원시적으로 비동기적인 작업을 구현했다고 볼 수 있다.

AsyncTask

Room 데이터 베이스를 사용해 보았을 때 AsyncTask를 이용해보았다. DB에 트랜잭션을 실행하는 행위는 비동기적으로 실행해야 하는 행위이므로 AsyncTask를 사용하여 DB 쿼리를 실행해보았는데,

    fun loadTodos(){
        val getTask = object: AsyncTask<Unit, Unit, Unit>(){
            override fun doInBackground(vararg params: Unit?) {
                todoList = db.todoDao().getAllTodos()
                Log.d(TAG, "MainActivity - doInBackground: $todoList ");
            }

            override fun onPostExecute(result: Unit?) {
                super.onPostExecute(result)
                setRecyclerView(todoList)
            }
        }

        getTask.execute()
    }

이런식으로 사용을 했었다. AsyckTask 안의 메소드들을 오버라이딩하고 백그라운드에서 할 작업, 그리고 실행 이후에 할 작업들을 간단히 적어주는 것으로 비동기 처리가 가능하였다. 그런데 AsyncTask를 작성할 때마다 IDE가 이 방식은 곧 deprecated 될 것이라고 계속 알려주었다.

안드로이드 공부한지 얼마 되지 않았는데 findByView, SQLite 연결방식, AsyncTask, Handler까지 전부다 deprecated 되는 것만 배우는 것 같다.

도대체 무엇을 공부해야 하나

다른 방법

가장 원시적인 방법으로 작업 스레드를 직접 생성해서 백그라운드에서 핸들러, 루퍼로 돌리는 방법이 있었고 이제 곧 사라질 AsyncTask를 제외하면 두 가지 옵션이 남는다.

1. RxJava
2. Coroutine

먼저 Rx는 리액티브의 약자라고 하는데 Observerble 이라는 개념을 이용한다고 한다.

뭔지 전혀 모릅니다.

객체의 이름만 봐서는 어떤 변수의 변화를 감지하는 느낌인 것 같다. 그리고 Rx는 안드로이드에만 존재하는 개념이 아니라 모든 언어에 추가적으로 탑재할 수 있는 개념인것 같아서 지금 배워두면 유용해보였다.

하지만 간단히 구글링 해본 결과 Coroutine이 조금 더 배우기 쉽고, Rx를 이미 이용하고 있는 프로젝트가 아닌 이상 Coroutine으로 하는것이 좋다는 글이 있었다.

그리고 조금 더 트렌디한 방법인 것 같아서 코루틴을 먼저 배워보려고 한다.

코루틴

코루틴 역시 다른 비동기 방식과 비슷하게 새로운 스레드를 만들어서 비동기 처리를 하게 된다. 하지만 코루틴은 스레드를 여러 개 생성하는 것이 아닌 하나의 스레드에서 여러 개의 코루틴을 만듬으로서 여러 비동기 처리를 가능하게 한다. 그리고 경량 스레드로서 다른 비동기 작업을 처리하는 스레드들보다 더 복잡한 연산을 쉽게 처리할 수 있다.

그리고 어떤 코루틴을 실행하다가 멈출 수도 있고 다른 컨텍스트로 넘어가서 다른 코루틴을 시작할 수도 있고 사용의 자유도가 높을 뿐만 아니라 그 방법이 굉장히 직관적이고 쉽게 만들어져 있다. 이전에 사용했던 AsyncTaskHandler, 그리고 Javascript에서 사용해왔던 Promiseasync await보다 훨씬 직관적이다.(이 부분은 개인적인 의견입니다.)

개인적으로 안드로이드에서 라이프 사이클과 스레드의 흐름, 코루틴 생성, 캔슬까지 전부 직관적으로 보여주는 부분이 굉장히 맘에 들었습니다.

먼저 코루틴을 사용하려면 그레이들 파일에

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'

위와 같은 의존성을 추가하고 싱크버튼을 눌러준다. 그리고 새로운 어플리케이션을 생성하고 메인 액티비티에 아래와 같이 작성해보자.


import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
   private val TAG: String = "로그"
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       
       GlobalScope.launch {
           delay(3000)
           // 여기서 비동기 작업을 이것저것 합니다.
           Log.d(TAG, "MainActivity - onCreate: GlobalScope lauched : ${Thread.currentThread().name} ");
       }
       Log.d(TAG, "MainActivity - onCreate: Main Thread : ${Thread.currentThread().name} "); // 메인 스레드와 다른 이름이라는 것을 알 수있다.
   }
}

먼저 코루틴이 사용되는 범위를 정해준다. GlobalScope는 말그대로 전역 스코프로 사용하는 순간 즉시 새로운 스레드가 생성되고 그안에서 바로 비동기 작업이 가능해진다.

GlobalScope는 최상위 코루틴이며 어플리케이션이 종료할때 까지 살아있는 코루틴이다.

그리고 launch를 통해 코루틴을 열어주는데, 그 아래 delay는 말 그대로 잠시 코루틴을 늦춰준다. 마치 Thread.sleep 과 비슷한데, Thread.sleep은 스레드 자체를 멈추지만 delay는 코루틴만을 멈추게 한다. 스레드 자체를 멈추는 방법은 runBlocking이라는 방법이 있는데, 잠시 후 설명하도록 할 것이다.

어플을 실행하고 로그문을 살펴보면 먼저 메인 스레드 로그가 실행되고 GlobalScope 내에서 실행되는 스레드는 3초 후 실행되는 것을 확인 할 수 있다.

suspend 함수

위 코드에서 delay 부분에 마우스를 올리면 delaysuspend 함수라는 것을 알려준다. 이 suspend 함수는 중단함수라고도 하며 말 그대로 코루틴 내에서 이 함수를 처리할 때 잠시 코루틴을 중단 시키고 자기가 할 일을 한다.

suspend 함수는 코루틴 내에서 또는 다른 suspend 함수 내에서만 실행될 수 있다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
    private val TAG: String = "로그"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        GlobalScope.launch {
            delay(1000)
            val networkTaskResult = networkTask() // 이 작업 하는데 3초
            val dbTaskResult = databaseTask() // 이 작업 하는데 3초

            Log.d(TAG, "network task result : $networkTaskResult"); // 총 6초 이후 로그가 출력된다.
            Log.d(TAG, "db task result : $dbTaskResult");
        }
    }

    suspend fun networkTask(): String{ // suspend 함수를 선언할 수 있다.
        delay(3000)
        return "Did some network Task"
    }

    suspend fun databaseTask(): String {
        delay(3000)
        return "Did some db task"
    }
}

아래부터 보면 각각 3초의 처리 시간이 걸리는 네트워크 작업과 DB 작업이 있다고 하자. 둘 다 비동기로 처리해야 하는 작업이 된다. 그리고 어플리케이션을 실행하고 로그를 살펴보면 코루틴에서 각각 3초 총 6초의 작업을 전부 처리하고 아래 로그가 찍히는 것을 볼 수 있다. suspend 함수가 처리하느라 코루틴의 흐름을 잠시 멈추게 한 것이다.

코루틴에서의 컨텍스트와 디스패쳐

코루틴은 컨텍스트에서 다른 컨텍스트로의 변환이 자유롭다. 그러한 작업을 디스패쳐를 통해서 할 수 있는데,

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {
    private val TAG: String = "로그"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val helloText = findViewById<TextView>(R.id.hello_text)

        GlobalScope.launch(Dispatchers.IO) {
            val networkTaskResult  = networkTask()
            // Main 디스패쳐를 선택했으므로 UI 작업이 가능하게 된다.
            withContext(Dispatchers.Main){
                helloText.text = networkTaskResult
            }
        }
    }

    suspend fun networkTask(): String{
        delay(3000)
        return "result of network call"
    }
}

저번 글부터 계속 UI 작업은 메인 스레드에서만 가능하다고 했었다. 따라서 IO 디스패쳐에서 네트워크 작업을 하고 그 결과를 Main 디스패쳐에서 적용하도록 하면 된다. 그리고 이 모든 것은 글로벌 스코프 코루틴 내에서 작동하게 된다. 디스패쳐는 이 둘뿐만이 아니라


Main : 메인 스레드에서 하는 UI 작업 시
IO : 일반적인 비동기작업 , 네트워킹, DB, 파일 입출력 등
Default: 오래걸리는 복잡한 연산 메인스레드를 늦출만한 것들
Unconfined: 특정 스레드에 국한되지 않음, 호출한 스레드에서부터 suspend 함수를 발생시키고  그 서스펜드 함수의 스레드내에 존재하게 된다.

여러가지가 존재하며 또는 newSingleThreadContext("MyThread") 이런식으로 직접 이름을 붙일 수도 있다.

runBlocking

지금까지 사용해왔던 delay는 해당 코루틴만을 잠시 멈추게 하지만 runBlocking을 이용하면 해당 스레드를 멈출 수 있다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {
    private val TAG: String = "로그"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val helloText = findViewById<TextView>(R.id.hello_text)

        Log.d(TAG, "MainActivity - onCreate: Before run blocking ");
        runBlocking { // 메인 스레드를 멈추게 한다.
            launch {
                delay(3000L)
                Log.d(TAG, "first launch ");
            }
            launch {
                delay(3000L)
                Log.d(TAG, "second launch ");
            }
            Log.d(TAG, "MainActivity - onCreate: started run blocking ");
        }
        Log.d(TAG, "MainActivity - onCreate: after run blocking ");
    }
}

runBlocking은 작업 중인 스레드를 잠시 멈추게 할 때 사용하면 된다. 일반적으로 테스팅이나 코루틴의 디버깅시 사용을 하며 그 안에서 새로운 코루틴들을 생성할 수 있다.

예를 들면 위 서스펜드 함수를 설명할 때 했던 예제 코드에서 두 작업을 처리하는데 각각 3초씩 총 6초가 걸렸는데, runBlocking내에서 각각 다른 코루틴을 launch 하여 사용하면 작업을 따로 따로 다른 코루틴들이 처리하도록 할 수 있다.

Job, Cancel

사실 코루틴을 launch 시키면 job을 반환하게 된다. 이제 이 job을 다른 코루틴에 넣어서 실행시킬 수도 있고, 실행 중에 멈추게(cancel)할 수도 있다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.TextView
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {
    private val TAG: String = "로그"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 코루틴은 job을 리턴한다
        val job = GlobalScope.launch {
            repeat(5){
                Log.d(TAG, " 코루틴이 작동중입니다. ");
                delay(1000L)
            }

        }

        runBlocking {
            job.join() // wait for it to finish but it's suspen fun
            delay(2000L)
            job.cancel()
            Log.d(TAG, " job이 끝나고 메인 스레드가 계속됩니다. ");
        }
    }
}

runBlocking 내에 launch 시킨 코루틴을 넣고 2초 후에 cancel 하게 되면 repeat을 통해 다섯번 실행되기로 한 코루틴이 멈추게 된다. 너무 오래 걸리는 작업을 그냥 취소시킬 때 사용하면 되는데


            withTimeout(3000L){
                // 복잡한 연산
                // 3초 이상 걸리면 자동으로 코루틴이 cancel 해줌
            }

withTimeout을 통해서 추가적인 코드없이 제한시간을 두고 작업을 취소시킬 수도 있다.

Scope

만약 cancel 메소드를 지우고 5번 반복하는 repeat이 끝나기전 다른 액티비티로 이동하면 어떻게 될까? 정답은 계속 그 코루틴 작업이 실행된다. 이유는 우리가 해당 코루틴의 스코프를 글로벌로 두었기 때문이다. 처음에 말했듯이 글로벌 스코프는 어플리케이션이 종료될 때 까지 살아있기 때문이다. 즉 액티비티 내에서 finish()를 하더라도 계속 살아있게 된다.

이렇게 되면 코루틴이 finish 된 액티비티의 리소스들을 계속해서 잡고 있으므로 JVM의 가비지 컬렉터가 제 할일을 못하게 되므로 메모리 누수의 원인이 될 수 있다. 그래서 코루틴은 다른 스코프들도 지원한다.

lifeCycleScope로 코루틴을 실행하면 현재 액티비티의 라이프 사이클에 의존하게 된다. 따라서 액티비티가 종료되면(finish메소드) 액티비티 내 lifecycleScope로 실행된 코루틴들은 전부 종료가 된다.

또는 coroutineScope 빌더를 사용하여 코루틴의 고유한 스코프를 선언할 수 있다. 이렇게 생성된 스코프는 실행된 모든 하위 코루틴이 완료되면 끝이 난다.

Async Await

자바스크립트의 그것과 비슷하다. Async로 실행한 함수의 결과를 await으로 풀어서 가져올 수 있다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.TextView
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

class MainActivity : AppCompatActivity() {
    private val TAG: String = "로그"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        GlobalScope.launch(Dispatchers.IO) {
            //mearueTimemillis는 코루틴의 시간을 재준다.
            var time = measureTimeMillis {
                val networkTask1 = async { networkTask1() }
                val networkTask2 = async { networkTask2() }
                
                Log.d(TAG, "result1 : ${networkTask1.await()} ");
                Log.d(TAG, "result1 : ${networkTask2.await()} ");
            }
            Log.d(TAG, "coroutine task takes about $time");
        }
    }
    
    suspend fun networkTask1(): String{
        delay(3000L)
        Log.d(TAG, "first task done");
        return "first network task"
    }

    suspend fun networkTask2(): String{
        delay(3000L)
        Log.d(TAG, "second task done");
        return "second network task"
    }
}

다시 처음에 6초 걸렸던 네트워크 작업을 다시 봐보자. 먼저 measureTimeMillis라는 메소드를 사용하는데, 이는 코루틴의 실행 시간을 재준다. 시간을 먼저 재주고 각 태스크를 다른 async 코루틴에 담아서 보내준다.

그렇게 작업이 완료된 값을 부르면 Deffered라는 타입을 반환하는데 마치 자바스크립트에서 Promise<Pending>된 상태와 비슷해보인다. 이를 await으로 풀어서 사용하면 된다. 각각 두 태스크를 각각 다른 async 코루틴에 담아서 실행하므로 총 3초의 시간이 걸리는 것을 확인할 수 있다.

결론

간단하게 코루틴의 사용방법을 알아보았다. 이 모든 작업을 하나의 경량스레드에서 전부 처리할 수 있다는게 놀라웠다. 배우고 간단히 Retrofit에 물려서 사용을 해보았는데, 콜백을 통해서 비동기 처리를 할 때보다 코드도 훨씬 간결하고 직관적이 되는 것을 확인할 수 있었다. 그리고 아직 깊게 공부하지 않고 복잡하게 사용안해서 그런지는 몰라도 굉장히 쉬웠다. 비동기 처리를 쉽게하는 것도 놀라운데, 컨텍스트의 이동에도 용이하고 심지어 코드의 가독성도 높은게 왜 코루틴이 대세가 되고 있는지 알 수 있었다. 이제 천천히 안드로이드로 첫 프로젝트를 하게 될 예정인데, 모든 비동기를 코루틴으로 처리할 생각을 하니 벌써부터 든든하다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

1개의 댓글

comment-user-thumbnail
2024년 6월 22일

명쾌한 정리 감사드립니다!

답글 달기