저번 글까지 안드로이드의 스레드 구분과 작업 스레드에서 UI 작업을 할 수 있게 해주며 스레드의 메시지 큐에 작업을 넣고 실행시켜주는 핸들러, 그리고 메시지 큐를 계속해서 도는 루퍼까지 해서 안드로이드의 백그라운드 처리에 대해 알아보았다.
이러한 과정을 거쳐서 코드를 작성하는 이유는 비동기 처리가 필요하기 때문이다. 안드로이드 어플리케이션이 실행되면 메인 스레드가 실행이 되고 스레드는 기본적으로 요청을 동기적으로 하나씩 처리하게 된다.
그 메인 스레드 내에서 네트워크 처리, DB 처리 또는 복잡한 연산을 하게 되면 사용자에게 보여지는 UI 처리가 늦어질 수 있다.
따라서 네트워크 처리 또는 DB 처리 등 요청의 응답을 대기해야 하는 작업, 또는 처리가 굉장히 오래 걸리는 복잡한 연산 작업은 작업 스레드(백그라운드)를 하나 더 생성하여 처리를 해주어야 한다.
이러한 프로그래밍 방식을 비동기 프로그래밍이라고 한다.
먼저 동기란 synchronous, 즉 동시에 일어나는 개념이다.(요청과 응답이 동시에 일어남) 어떤 요청이 들어가면 즉시 그 결과가 나와야 하는 것을 의미한다. 즉 작업에 소요되는 시간이 얼마나 되던지 그 자리에서 결과가 필요하기 때문에 만약 메인 스레드에서 막대한 for 문의 작업을 처리하느라 UI 처리를 못했던 부분이 동기적으로 코드가 작성되어 있기 때문이라고 할 수 있겠다.
비동기란 asynchronous, 즉 동시에 일어나지 않는 다는 것, 다시 말하면 어떤 요청이 들어갔을 때 그 요청의 결과가 바로 주어지지 않아도 된다는 것이다. 그 요청은 다른 스레드에서 작업을 하고 계속 다른 작업을 할 수 있게되고 그 결과가 주어지면 그 때 비동기적으로 결과를 내면 된다는 의미이다. 다시 말해서 우리가 스레드와 핸들러를 통해 (비교적)원시적으로 비동기적인 작업을 구현했다고 볼 수 있다.
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
으로 하는것이 좋다는 글이 있었다.
그리고 조금 더 트렌디한 방법인 것 같아서 코루틴을 먼저 배워보려고 한다.
코루틴 역시 다른 비동기 방식과 비슷하게 새로운 스레드를 만들어서 비동기 처리를 하게 된다. 하지만 코루틴은 스레드를 여러 개 생성하는 것이 아닌 하나의 스레드에서 여러 개의 코루틴을 만듬으로서 여러 비동기 처리를 가능하게 한다. 그리고 경량 스레드로서 다른 비동기 작업을 처리하는 스레드들보다 더 복잡한 연산을 쉽게 처리할 수 있다.
그리고 어떤 코루틴을 실행하다가 멈출 수도 있고 다른 컨텍스트로 넘어가서 다른 코루틴을 시작할 수도 있고 사용의 자유도가 높을 뿐만 아니라 그 방법이 굉장히 직관적이고 쉽게 만들어져 있다. 이전에 사용했던 AsyncTask
나 Handler
, 그리고 Javascript에서 사용해왔던 Promise
나 async 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초 후 실행되는 것을 확인 할 수 있다.
위 코드에서 delay
부분에 마우스를 올리면 delay
가 suspend
함수라는 것을 알려준다. 이 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")
이런식으로 직접 이름을 붙일 수도 있다.
지금까지 사용해왔던 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
하여 사용하면 작업을 따로 따로 다른 코루틴들이 처리하도록 할 수 있다.
사실 코루틴을 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
을 통해서 추가적인 코드없이 제한시간을 두고 작업을 취소시킬 수도 있다.
만약 cancel
메소드를 지우고 5번 반복하는 repeat
이 끝나기전 다른 액티비티로 이동하면 어떻게 될까? 정답은 계속 그 코루틴 작업이 실행된다. 이유는 우리가 해당 코루틴의 스코프를 글로벌로 두었기 때문이다. 처음에 말했듯이 글로벌 스코프는 어플리케이션이 종료될 때 까지 살아있기 때문이다. 즉 액티비티 내에서 finish()
를 하더라도 계속 살아있게 된다.
이렇게 되면 코루틴이 finish
된 액티비티의 리소스들을 계속해서 잡고 있으므로 JVM의 가비지 컬렉터가 제 할일을 못하게 되므로 메모리 누수의 원인이 될 수 있다. 그래서 코루틴은 다른 스코프들도 지원한다.
lifeCycleScope
로 코루틴을 실행하면 현재 액티비티의 라이프 사이클에 의존하게 된다. 따라서 액티비티가 종료되면(finish메소드) 액티비티 내 lifecycleScope로 실행된 코루틴들은 전부 종료가 된다.
또는 coroutineScope
빌더를 사용하여 코루틴의 고유한 스코프를 선언할 수 있다. 이렇게 생성된 스코프는 실행된 모든 하위 코루틴이 완료되면 끝이 난다.
자바스크립트의 그것과 비슷하다. 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
에 물려서 사용을 해보았는데, 콜백을 통해서 비동기 처리를 할 때보다 코드도 훨씬 간결하고 직관적이 되는 것을 확인할 수 있었다. 그리고 아직 깊게 공부하지 않고 복잡하게 사용안해서 그런지는 몰라도 굉장히 쉬웠다. 비동기 처리를 쉽게하는 것도 놀라운데, 컨텍스트의 이동에도 용이하고 심지어 코드의 가독성도 높은게 왜 코루틴이 대세가 되고 있는지 알 수 있었다. 이제 천천히 안드로이드로 첫 프로젝트를 하게 될 예정인데, 모든 비동기를 코루틴으로 처리할 생각을 하니 벌써부터 든든하다.
명쾌한 정리 감사드립니다!