📌 코루틴 사용하기
라이브러리 추가
build.gradle에 아래 코드를 추가해준다.
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
runblocking
코루틴을 만드는 가장 간단한 함수는 runBlocking이다. 이렇게 코루틴을 만드는 함수를 코루틴 빌더라고 부른다. runBlocking은 코루틴을 만들고 코드 블록이 수행이 끝날 때까지 runBlocking 다음의 코드를 수행하지 못하게 막게 된다. 말 그대로 블로킹을 하는 것이다.
언제까지 블로킹시킬까? runBlocking 블록 안에 있는 코드가 모두 실행을 끝마칠 때까지 블록 된다. runBlocking { ... } 안에 2초의 delay를 주었으므로 2초 동안 메인 스레드가 블록된다. 2초의 딜레이가 끝나면 main()함수는 종료된다. 메인쓰레드가 블록 되어있는 2초 동안에, 이전에 launch 했던 코루틴은 계속해서 동작하고 있다.
import kotlinx.coroutines.*
fun main() {
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main thread continues here immediately
runBlocking { // but this expression blocks the main thread
delay(2000L) // ... while we delay for 2 seconds to keep JVM alive
}
}
//결과
Hello,
World
runBlocking은 runBlocking {...} 블록 안에 있는 모든 코루틴들이 완료될때까지 자신이 속한 스레드를 종료시키지 않고 블락시킨다. 따라서 runBlocking에서 가장 오래 걸리는 작업인 delay(2초)가 끝날 때 까지 메인 스레드는 죽지 않고 살아있다.
만약 runBlocking이 없다면 어떻게 될까?
import kotlinx.coroutines.*
fun main() {
GlobalScope.launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
//결과
Hello,
메인쓰레드가 블록 되지 않으므로 Hello가 출력된 후 main함수가 바로 종료되어 버린다. 따라서 World는 출력되지 못한 결과를 볼 수 있다.
코루틴 빌더의 수신 객체
runBlocking안에서 this를 수행하면 코루틴이 수신 객체(Receiver)이다.
실제 IDE에서 code supporter(?)는
다음과 같이 this: CoroutineScope라고 명시해주고 있는 것을 알 수 있다.
import kotlinx.coroutines.*
fun main() = runBlocking {
println(this)
println(Thread.currentThread().name)
println("Hello")
}
//결과
BlockingCoroutine{Active}@23d2a7e8 // -> CoroutineScope의 자식
main
Hello
BlockingCoroutine은 CoroutineScope의 자식이다. 코틀린 코루틴을 쓰는 모든 곳에는 코루틴 스코프(CoroutineScope)가 존재한다.
코루틴 콘텍스트
코루틴 스코프는 코루틴을 제대로 처리하기 위한 정보, 코루틴 콘텍스트(CoroutineContext)를 가지고 있다. 수신 객체의 coroutineContext를 호출해 내용을 확인해보면 다음과 같다.
import kotlinx.coroutines.*
fun main() = runBlocking {
println(coroutineContext)
println(Thread.currentThread().name)
println("Hello")
}
//결과
[BlockingCoroutine{Active}@4abdb505, BlockingEventLoop@7ce6a65d]
main
Hello
Kotlin Playground에서 실행했을 때는
[CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@25618e91, BlockingEventLoop@7a92922]
와 같이 CoroutineId와 현재 어느 코루틴이 실행되고 있는지 까지 정보가 나왔는데 반면 IntelliJ에서 실행시켰을 때는 정보가 조금 한정돼서 나오는 것 같았다. 따라서 지금부터는 Kotlin Playground를 활용한 결과를 보여주려고 한다.
어쨌든 둘 다 context는 코루틴에 대한 정보를 담고있다는 사실을 알 수 있다.
delay와 sleep
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("launch: ${Thread.currentThread().name}")
println("World!")
}
println("runBlocking: ${Thread.currentThread().name}")
delay(500L)
println("Hello")
}
//결과
runBlocking: main @coroutine#1
launch: main @coroutine#2
World!
Hello
프린트 문이 호출된 이후 delay가 호출되는데 이때 runBlocking의 코루틴이 잠이 들게 되고 launch의 코드 블록이 먼저 수행된다. (delay의 인자는 밀리세컨드 단위의 시간을 지정할 수 있다.)
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("launch: ${Thread.currentThread().name}")
println("World!")
}
println("runBlocking: ${Thread.currentThread().name}")
Thread.sleep(500)
println("Hello")
}
//결과
runBlocking: main @coroutine#1
Hello
launch: main @coroutine#2
World!
sleep의 경우 delay와 결과가 다른데 Thread.sleep을 하면 코루틴이 아무 일을 하지 않는 동안에도 스레드를 양보하지 않고 독점하므로 launch블록으로 순서를 넘기지 않고 끝까지 작업을 모두 수행한 후 넘겨주게 된다.
launch 코루틴 빌더
빌더란 말 그대로 만들어 내는 것이다. 코루틴 빌더, 즉 코루틴을 만들어 내는 역할을 하는 것이 launch이다. launch를 활용해 코루틴 내에서 다른 코루틴을 수행할 수 있다. 새로운 코루틴을 만들기 때문에 새로운 코루틴 스코프를 만들게 된다. launch는 현재 스레드를 차단하지 않고 새로운 코루틴을 실행할수 있으며 특정 결괏값 없이 Job객체를 반환한다.
한마디로 launch는 “할 수 있다면 다른 코루틴 코드를 같이 수행”시키는 코루틴 빌더이다.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("launch: ${Thread.currentThread().name}")
println("World!")
}
println("runBlocking: ${Thread.currentThread().name}")
println("Hello")
}
//결과
runBlocking: main @coroutine#1
Hello
launch: main @coroutine#2
World!
launch 코루틴 빌더에 있는 내용이 runBlocking이 있는 메인 흐름보다 늦게 수행된 것을 볼 수 있다. 둘 다 메인 스레드(main)를 사용하기 때문에 runBlocking의 코드들이 메인 스레드를 다 사용할 때까지 launch의 코드 블록이 기다리는 것이다.
runBlocking은 Hello를 출력하고 나서 종료하지는 않고 launch 코드 블록의 내용이 다 끝날 때까지 기다리게 된다.
suspend 함수
suspend 함수는 말 그대로 일시 중단 가능한 함수를 의미한다. 해당 함수는 무조건 코루틴 스코프 내부에서 혹은 다른 suspend 함수 내부에서 수행되어야 한다.
import kotlinx.coroutines.*
suspend fun doThree() {
println("launch1: ${Thread.currentThread().name}")
delay(1000L)
println("3!")
}
fun doOne() {
println("launch1: ${Thread.currentThread().name}")
println("1!")
}
suspend fun doTwo() {
println("runBlocking: ${Thread.currentThread().name}")
delay(500L)
println("2!")
}
fun main() = runBlocking {
launch {
doThree()
}
launch {
doOne()
}
doTwo()
}
//결과
runBlocking: main @coroutine#1
launch1: main @coroutine#2
launch1: main @coroutine#3
1!
2!
3!
doOne()은 delay와 같은 함수(suspend인 함수)를 호출하지 않았기 때문에 suspend를 붙이지 않은 일반 함수로 해도 상관없다.
하지만 suspend 함수는 그 자체로 코루틴 빌더는 아니기 때문에 suspend 함수 내에서 코루틴 빌더를 호출한다면 에러가 발생한다.
import kotlinx.coroutines.*
suspend fun doOneTwoThree() {
launch {
println("launch1: ${Thread.currentThread().name}")
delay(1000L)
println("Hello")
}
}
fun main() = runBlocking {
doOneTwoThree()
}
//결과물
Unresolved reference: launch
Suspension functions can be called only within coroutine body
-> 에러발생
함수 앞에 suspend를 적어준다고 해서 그것이 함수를 백그라운드 스레드에서 실행시킨다는 뜻은 아니다. 코루틴은 메인 쓰레드 위에서 돈다. 메인스레드에서 하기에는 너무 오래 걸리는 작업을 하기 위해서는 코루틴을 Default Dispatcher 혹은 IO Dispatcher에서 관리되도록 해야한다.
여기서 Dispatcher는 무엇일까?
Dispatcher
Dispatcher란 한국어로 '보내다' 라는 뜻이다. 그렇다면 Dispatcher는 무엇을 보내는 것일까?
Dispatcher는 스레드(Thread)에 코루틴(Cortoutine)을 보낸다. 코루틴에서는 스레드 풀을 만들고 Dispatcher를 통해 코루틴을 배분한다. 즉 코루틴을 만든 다음 해당 코루틴을 Dispatcher에 전송하면 Dispatcher는 자신이 관리하는 스레드풀 내의 상황에 맞춰 코루틴을 배분한다.
코루틴이 스레드풀에 분배되는 과정을 그림으로 만들어 보면 다음과 같다.
1. User(개발자)가 코루틴을 생성한 후 Dispatcher에 전송
2. Dispatcher가 자신이 관리하는 Thread Pool에 자원이 남는 스레드를 확인한 후 해당 스레에 코루틴을 Dispatch(전송)
그림에서 볼 수 있듯이 코루틴은 스레드 풀을 만들지만 직접 제어하지는 않는다. Dispatcher에 스레드를 보내기만 하면 Dispatcher가 스레드에 코루틴을 분배시킨다.
안드로이드의 Dispatcher
안드로이드에는 기본으로 생성되어 있는 Dispatcher들이 존재한다.
- Dispatcher.Main - Android 메인 스레드에서 코루틴을 실행하는 Dispatcher. UI와 상호작용하는 작업이라는 꼭 이 Dispatcher에서 작업을 수행해야 한다.
- Dispatcher.IO - 디스크 또는 네트워크 I/O작업에 최적화 되어있는 Dispatcher이다. 일반적으로 IO 작업은 CPU에 영향을 미치지 않으므로, CPU 코어 수보다 훨씬 많은 스레드를 가지는 스레드 풀에서 수행한다(최대 64개).
- Dispatcher.Default - CPU를 많이 사용하는 작업을 기본 스레드 외부에서 실행하도록 최적화 되어있는 Dispatcher이다. CPU 코어 수에 비례하는 스레드 풀에서 수행한다. CPU 집약적인 작업, 즉 CPU 바운드 작업에 적합하다.
- newSingleThreadContext(name: String) - 코루틴을 실행할 수 있는 새로운 스레드를 생성하여 작업한다.
async 코루틴 빌더
async역시 코루틴을 만들어 내는 코루틴 빌더이다. 위에서 설명한 launch와 비슷하게 보이지만 수행 결과를 await키워드를 통해 받을 수 있다는 차이가 존재한다.
결과를 받아야 한다면 async, 결과를 받지 않아도 된다면 launch를 선택할 수 있다. async의 결과값은 Deferred 타입으로 반환된다. await 키워드를 만나면 async 블록이 수행이 끝났는지 확인하고 아직 끝나지 않았다면 suspend되었다 나중에 다시 깨어나고 반환값을 받아온다.
import kotlin.random.Random
import kotlin.system.*
import kotlinx.coroutines.*
suspend fun getRandom1(): Int {
delay(1000L)
return Random.nextInt(0, 500)
}
suspend fun getRandom2(): Int {
delay(1000L)
return Random.nextInt(0, 500)
}
fun main() = runBlocking {
val elapsedTime = measureTimeMillis {
val value1 = async { getRandom1() }
val value2 = async { getRandom2() }
println("${value1.await()} + ${value2.await()} = ${value1.await() + value2.await()}")
}
println(elapsedTime)
}
//결과
195 + 470 = 665
1082
위 코드를 보면 launch와 다른점이 존재한다. 바로 각 suspend 함수들이 특정 Int값을 반환한다는 것이다. 그 값들이 Deferred<Int> 타입으로 저장되어 value.await() 키워드를 통해 불러올 수 있게 된다. 쉽게 말하면 job.join()의 역할도 하면서 결과까지 가져오는 역할도 하는 것이다.
생각해보면 async 키워드는 우리가 코드를 짤 때 필수적인 키워드는 아니다. 단지 어떤 작업들을 동시에 수행하고 싶을 때, 그리고 그 작업들의 결과를 반환하고 싶을 때 async 키워드를 사용할 수 있다. (결과를 반환할 필요는 없고 동시성만 원할 때는 lauch 키워드를 사용하면 된다.)
join() & await()을 이용한 코루틴 제어
프로젝트를 하다 보면 코루틴의 순서를 제어해야 하는 경우가 있다. 예를 들어 어떤 A 코루틴을 통해API 통신에서 가져온 값을 이용하여 B코루틴에서 API통신을 해야 하는 경우라면 무조건 A 코루틴이 B 코루틴보다 먼저 수행이 되어야 할 것이다.
suspend fun doOneTwoThree() {
val time = measureTimeMillis {
coroutineScope {
val job = launch {
delay(1000L)
println("A 코루틴 수행")
}
job.join() // join을 통해 A 코루틴이 수행될 때 까지 B 코루틴이 수행되지 못하게 한다.
launch {
println("B 코루틴 수행")
delay(500L)
}
}
}
println(time)
}
fun main() = runBlocking {
doOneTwoThree()
}
//결과
A 코루틴 수행
B 코루틴 수행
1521
이전 3개의 launch로 코루틴이 병렬적으로 수행된 코드와 비교하면 위 코드는 A 코루틴이 다 수행되고 난 후 B 코루틴이 수행되었으므로 A, B 순차적으로 코루틴이 수행되어 총 걸린 시간이 1000이 아닌 1521이 나온 것을 확인할 수 있다.
await()을 이용해서도 코루틴을 제어할 수 있다. 위의 async 코루틴 빌더에서 활용했던 코드를 그대로 가져와 테스트 해보자.
import kotlin.random.Random
import kotlin.system.*
import kotlinx.coroutines.*
suspend fun getRandom1(): Int {
delay(1000L)
return Random.nextInt(0, 500)
}
suspend fun getRandom2(): Int {
delay(1000L)
return Random.nextInt(0, 500)
}
suspend fun main() = coroutineScope {
val elapsedTime = measureTimeMillis {
val value1 = async { getRandom1() }.await()
val value2 = async { getRandom2() }.await()
println("$value1 + $value2 = ${value1 + value2}")
}
println(elapsedTime)
}
//결과
372 + 264 = 636
2092
getRandom1(), getRandom2() 에 await()를 각각 걸어주고 실행시키게 된다면 await()에서 getRandom1()이 모두 수행되기를 기다린 다음에 getRandom2() 가 수행, 즉 동기적으로 함수들이 수행되어 총 시간이 2000내외가 걸리게 된다.
CoroutineScope
코루틴 스코프를 만드는 다른 방법은 CoroutineScope 스코프 빌더를 이용하는 것이다.
suspend fun doOneTwoThree() {
val time = measureTimeMillis {
coroutineScope {
launch {
delay(1500L)
}
launch {
delay(1000L)
}
launch {
delay(500L)
}
}
}
println(time)
}
//결과
1511
위 코드를 보면 우선 coroutineScope가 코루틴 스코프를 만들어 낸다. 그 이후 3개의 launch를 통해 3개의 자식 코루틴이 만들어지게 되는데 이 3개의 코루틴은 각각 병렬적으로 작업을 수행하게 된다.
measureTimeMillis를 통해 부모 코루틴 즉 coroutineScope이 수행되는 시간을 측정해 보면 1511 즉 3개의 코루틴이 병렬적으로 수행된 것을 확인할 수 있다.
또한 부모코루틴은 자식코루틴이 끝날 때 까지 별도의 작업을 해주지 않는 이상 종료되지 않는다.
📌 코루틴 취소
코루틴의 취소는 매우 간단해 보인다. 공식문서의 코드를 보면 다음과 같다.
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
//결과
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
하지만 원하는 대로 취소가 되지 않는 경우가 있다. 다음 두 코드를 보자
코드1
fun main() = runBlocking {
val job = launch {
while(true) {
println("while")
}
}
delay(1000L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
// result
while
while
while
...
...
...
코드2
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
while(true) {}
}
delay(1000L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
//결과
while
while
...
...
main: I'm tired of waiting!
while
while
...
...
...
우선 두 코드의 결과의 차이는 두 번째 코드는 main: I'm tried to wating! 이 출력되었다는 것이다.
두 코드의 유일한 차이점은 두 번째 코드는 luanch의 context를 Dispatcher.Deafult 바꿔주었다는 부분이다.
launch의 아무런 Dispatcher를 설정해주지 않으면 코루틴은 메인 스레드에서 돌아간다. 즉 첫 번째 코드는 코드가 실행되면 launch로 코루틴을 실행시킨 후 메인 스레드의 delay를 만나게 되고 delay는 suspend 함수이므로 다른 코루틴으로 제어권을 넘겨주게 된다.
이때 제어권을 넘겨받은 또다른 코루틴, 즉, 아까 메인 쓰레드에서 launch했던 while문 코드가 실행이 되는데, 이 while문은 조건이 항상 true라서 메인 스레드를 계속 잡고 있는다. delay에서 걸어준 1000L이 끝이 났지만, while이 thread를 계속해서 사용하고 있다는 것이다. 따라서 프로그램이 종료되지 못하고 "main: I'm tired of waiting!" 문장이 찍히지 않는 것이다.
두 번째 코드는 runBlocking 내의 코드는 메인 스레드에서, launch 코루틴은 메인 스레드가 아닌 다른 스레드에서 돌아가기 때문에 백그라운드의 while문이 메인 스레드의 흐름을 방해하지 못한다. 따라서 "main: I'm tired of waiting!" 문장이 찍히게 된다.
하지만 두 번째 코드 역시 우리가 원하는 작업, 즉 launch 코루틴(while문)이 cancel 되지는 않는다.
공식문서에서는 다음과 같이 설명한다.
Coroutine cancellation is cooperative. A coroutine code has to cooperate to be cancellable. All the suspending functions in kotlinx.coroutines are cancellable. They check for cancellation of coroutine and throw CancellationException when cancelled. However, if a coroutine is working in a computation and does not check for cancellation, then it cannot be cancelled.
코루틴은 기본적으로 CancellationException을 throw하며 cancel 하며 취소하게 되지만, cancellation을 체크하지 않는다면 cancel될 수 없다.
중요한 포인트는 "cancellaion을 체크하여 취소한다" 이며 여기서 cancellation을 체크한다는 것은 내부적으로 isActive를 확인하여 그 값이 false일 때 취소된다는 것이다.
우리에겐 두 가지 방법이 있다.
- isActive를 내부적으로 체크하고 있는 suspending 함수를 사용
- 직접 명시적으로 isAcitve를 체크
대표적인 suspending 함수인 delay의 내부코드를 살펴보면
public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}
다음과 같이 suspendCancellableCoroutine을 return하고 있는데 다시 suspendCancellableCoroutine 의 내부 구현을 따라가보면
if (resumeMode == MODE_CANCELLABLE) {
val job = context[Job]
if (job != null && !job.isActive) {
val cause = job.getCancellationException()
cancelResult(state, cause)
throw recoverStackTrace(cause, this)
}
}
job.isActive를 체크하는 부분이 있는 것을 확인할 수 있다.
정상적인 cancel 동작
위의 코드2에 while문 안에 대표적인 suspend 함수 즉 delay를 넣어주게 된다면
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
while(true) {
println("while")
delay(500L)
}
}
delay(1000L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
//결과
while
while
main: I'm tired of waiting!
main: Now I can quit.
다음과 같이 정상적으로 cancel이 된 것을 확인할 수 있다.
또한 delay를 넣지 않고도 명시적으로 isAcitve로 cancellation을 체크하게 된다면
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
while(isActive) {
println("while")
}
}
delay(1000L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
//결과
while
while
...
...
main: I'm tired of waiting!
while
while
...
main: Now I can quit
while문에 delay가 없어 중간중간 많은 while문이 출력되기는 하지만 결과적으로 정상적으로 cancel이 된 것을 확인할 수 있다.
finally
try finally를 이용하면 우리가 안드로이드 앱 개발을 할 때 흔히 해야하는 닫기 작업을 코루틴과 함께 편하게 사용할 수 있다.
즉 launch에서 자원을 할당한 경우 그 자원을 정리하기 위해서 try catch finally를 사용한다. 이렇게 대응할 수 있는 이유는
suspend 함수들은 위에서 말한 대로 CancellationException를 발생시키기 때문이다.
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
try {
while(true) {
println("while")
delay(500L)
}
} finally {
println("close resource!!")
}
}
delay(1000L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
}
//결과
while
while
main: I'm tired of waiting!
close resource!!
main: Now I can quit.
이렇게 코루틴 내에서 작업이 취소되더라도 꼭 마무리 되어야 하는 작업을 finally에 넣음으로써 프로그램의 안전성을 높일 수 있다.
'Android' 카테고리의 다른 글
안드로이드 [Kotlin] - SharedPreferences 를 이용해 Retrofit2 Header에 JWT 담기 (0) | 2022.07.09 |
---|---|
안드로이드 [Kotlin] - 아키텍처 패턴 with MVC, MVP, MVVM (feat 코드 예제) (0) | 2022.06.26 |
안드로이드 [Kotlin] - 코루틴(Coroutine) 1 - 코루틴의 기본 이론 (0) | 2022.05.25 |
안드로이드 - Fragment Lifecycle (프래그먼트 생명주기) (0) | 2022.05.18 |
안드로이드 - Activity Lifecycle (액티비티 생명주기) (0) | 2022.05.05 |