오늘은 Kotlin의 Coroutine에 대해서 정리해 보도록 하겠습니다.
1. Coroutine
Kotlin에서 네트워크 호출이나 디스크아이오 같은 비동기 코드를 다룰 때 언급되는 것이 Coroutine인데요.
공식문서에서의 Coroutine에 대한 정의를 보면,
suspendable한 Computation이라고 나와있습니다.
suspend는 중단하다 혹은 유예하다의 뜻 인데요.
필요할 때 마다 suspendable한 단위의 코드들이,
suspend 되었다가 다시 깨어나서 resume 하여 과업을 완료한다는 것 입니다.
그리고 Coroutine과 쓰레드는 컨셉적으로는 비슷하지만, 쓰레드는 아닙니다.
한 쓰레드에서 suspend 되었다가, 다른 쓰레드에서 깨어나서 실행될 수 도 있는 좀 더 가볍고 작은 단위입니다.
그래서 라이트한 쓰레드라고 표현되기도 하지요.
좀 더 구체적으로 보면,
suspend와 resume이 될 수 있는 코드블록들이,
비동기로 실행되는 네트워크요청같은 코드를 실행하다가,
응답을 기다려야 할 경우 suspend하고 Continuation이라고 하는 미래에 해야할 일들을 알고 있는 객체를 전달하구요.
해당 블록의 코드 실행이 완료되면, 다시 resume을 하면서 받았었던 Continuation을 이용해 다음 해야할 일을 resume 해 줍니다.
이러한 메커니즘을 컴파일되면서 구현해 주는 것이 Coroutine입니다.
이러한 메커니즘이 가볍고 퍼포먼스가 좋을 뿐만이 아니라,
개발자가 순차적으로 코드를 짤수 있게 해주어서 이해하고 보수하기 쉽게 도와 줍니다.
Coroutine이 컴파일단계에서 콜백같은 복잡한 비동기처리를 해 주는 것 이지요.
이제 그럼, CoroutineScope를 시작으로 Corotuine에 대해서 알아보도록 하겠습니다.
2. CoroutineScope와 CoroutineContext 그리고 Job
2-1. CoroutineScope
모든 Coroutine의 출발점이 CoroutineScope입니다.
이 Scope안에서 코루틴 코드를 빌드해서 사용하게 되는데요.
아래의 예제를 보겠습니다.
coroutineScope블록안에,
코루틴을 생성하는 코루틴 빌더인 launch 블록이 있고,
코루틴 빌더인 launch 블록안에 delay등의 코루틴 코드가 있는 것을 볼 수 있습니다.
아래 표와 같이 코루틴스코프안에 코루틴빌더가 있고, 그 블록안에 코루틴 코드들이 들어가는 것 입니다.
CoroutineScope > CoroutineBuilder(launch) > 코루틴 코드들
CoroutineScope를 생성하기 위한 빌더는 다음과 같은 것들이 있는데요.
- runBlocking
- coroutineScope
이들은 CoroutineScope를 빌드해주고, 블록안의 코드나 자식 코루틴들의 실행이 완료되는 것을 기다려 줍니다.
이 중 runBlocking은 아래 예제에서 많이 사용하게 될 것이라 몇가지 특징을 알아놓는것이 좋은데요.
이것은 실행되는 동안 쓰레드를 블로킹해줍니다.
그래서 다른 Scope빌더가 suspend함수인 반면, runBlocking은 일반 함수이기도 합니다.
유저들이 사용하는 프로덕트에서의 사용보다는 예제나 테스트 목적으로 편리하게 사용할 수 있습니다.
인터페이스인 CoroutineScope 코드의 주석을 보면,
CoroutineScope는 해당 scope의 context를 의미한다고 나와있는데요.
Context라는 것은 scope에 의해 capsule화 되어있고, 코루틴 빌더의 실행을 위해 사용되어진다고 합니다.
CoroutineContext에 대해서는 글의 아래부분에서 좀 더 다루도록 하겠습니다.
2-2. Coroutine 빌더
Scope내에서 Coroutine을 생성해주는 Coroutine Builder로는 다음과 같은 것들이 있는데요.
- launch
- async
먼저 launch 의코드를 살펴보면, CoroutineScope의 확장함수인 것을 볼 수 있습니다.
모든 Coroutine빌더들은 다 CoroutineScope의 확장함수입니다.
thread를 blocking하지 않고, 새로운 코루틴을 론치시켜주는 역할을 합니다.
이 함수는 Job객체를 return해 줍니다.
return값을 필요로 하지 않은 coroutine코드를 실행할 때 사용됩니다.
async는 "5. async와 Concurrency"에서 좀 더 자세히 보도록 할 예정인데요.
launch와 마찬가지로 쓰레드를 블로킹하지 않고 코루틴을 생성해 줍니다.
큰 차이점 중 하나는 미래의 return값을 return 해 준다는 점 입니다.
2-3. Job 객체
위에서 CoroutineScope안의 launch나 async같은 Coroutine 빌더블록안에서 코루틴 코드를 작성해 실행한다고 하였는데요.
이 때 Corotuine 빌더블록이 리턴하는 값이 바로 Job객체입니다.
이 Job객체는 론칭된 코루틴을 컨트롤 하거나, Complete(완료)를 기다릴 때 사용할 수 있습니다.
위에서 쓰인 join()함수는 Job인터페이스의 함수인데요.
job이 complete될 때까지 코루틴을 suspend해 주는 역할을 합니다.
과업이 완료되면 다시 resume해서 다음 과업이 실행되도록 해 주구요.
이렇게 job객체를 이용해서 론칭된 코루틴을 컨트롤 한 것이지요.
이외에도 cancel()같이 코루틴을 취소하는 컨트롤도 할 수 있습니다.
cancel에 대해서는 아래에서 더욱 자세하게 다루도록 하겠습니다.
위의 코드를 한번 실행부터 해 보겠습니다.
delay함수가 약 1초의 딜레이후에 World가 찍힌 것을 볼 수 있는데요.
이 코드가 실행된 다음에야 Completed가 실행되어 로그가 찍힙니다.
join()함수가 job의 완료를 기다려주었기 때문입니다.
그럼 한번 job.join()함수라인 없이 실행해 보도록 하겠습니다.
job을 기다려주지 않기 때문에, 다음 라인인 Completed가 바로 실행되어 아래와 같은 결과가 나옵니다.
3. Structured Concurrency
3-1. Structured Concurrentcy
코루틴 관련 문서를 보다보면 어딘가에서는 만나게 될 단어가 바로 Structured Concurrency인데요.
우리말로는 구조화된 동시성 정도가 될텐데요.
Kotin의 Coroutine은 Structured Concurrency를 따른다고 합니다.
모든 코루틴들은 특정한 CoroutineScope안에서 실행되며,
그러한 코루틴들은 부모 자식들의 관계를 형성하며 구조화 되어있고,
부모는 자식의 코루틴이 모두 Complete될때까지 Complete되지 않도록 설계되어 있으며,
부모에 문제가 생기면 모든 에러가 해당 부모와 자식 및 형제에 전파되어지도록 되어있다는 것 입니다.
위 코드의 실행결과를 보면 다음과 같습니다.
launch빌더안의 코루틴 코드가 delay이후에도 끝까지 실행이 되는 것을 볼 수 있는데요.
이것은 job객체가 runBlocking내부의 CoroutineScope안에서 구조화 되어있었기 때문 입니다.
자식을 기다려 주도록 되어있기 때문입니다.
이말이 참인지 알고 싶으면 코루틴빌더로 GlobalScope.launch를 실행해 보면 알 수 있는데요.
GlobalScope 는 이름 그대로 전역적인 GlobalScope인데요.
이 Scope는 전역적인 Scope여서, 다른 어느 CoroutineScope에도 포함되지 않는,
독립적인 Scope를 제공하므로 관리하기는 어렵지만, 이런 테스트를 하기에는 매우 적합하다고 할 수 있습니다.
(코드의 주석에서도 많이 사용하기를 권장하지 않는 API이기도 합니다.)
runBlocking에서 실행되는 CortouineScope와 GlobalScope.launch를 통해 실행되는 Scope는,
서로 독립적인 별개의 Scope가 되는데요.
따라서, 다른 Scope나 Job을 기다려주는 일은 발생하지 않습니다.
World에 대한 print는 delay후에 실행되는데,
그 전에 runBlockingScope의 코드는 실행되어 전체실행이 종료되어 버려서,
GlobalScope안의 println은 실행될 기회를 얻지 못하고 종료되어버립니다.
이런 GlobalScope는 어느 부모에도 속하지 않는 Scope로,
Structured Concurrency의 적용에서 벗어나는 케이스라고 할 수 있겠는데요.
이렇게 독립적인 GlobalScope는 어느 부모에도 속하지 않기에,
cancel에 대해서 전파가 되지 않아 생기는 문제도 존재합니다.
그래서 실제 프로덕션에서의 사용을 권장하지 않는 것이기도 하구요.
Structured Concurrency라는 것이, 코루틴 코드가 길고 사용하는 job이나 scope가 많아져도 관계가 구조화되어있으므로,
관리하기가 좋다는 것 인데요.
다만, 실제로 코딩해보면 이런저런 예외사항들이 발생하면서, 이것또한 다루기 쉽다고 표현하기는 어렵다는 것을 느낍니다.
4. suspend함수 사용하기
4-1. suspend 함수로 꺼내서 사용하기
공식문서에서는 아래와 같이 코틀린 빌더블록안의 코드들을,
function으로 꺼내서 리팩토링을 할 수 있다고 나와있는데요.
이렇게 꺼낸 함수들에서는 suspend라는 키워드를 붙여주어야 합니다.
그래야 코루틴 빌더 블록에서 불러서 실행할 수 있기 때문입니다.
단순히 suspend 키워드를 붙여야 실행할 수 있다는 의미이외에도,
Coroutine의 핵심메커니즘인 suspend와 resume의 포인트가 될 수있다는 의미도 있습니다.
아래의 코드를 자세히 보면, doHello()라는 suspend 키워드가 붙은 함수로 꺼내어주었는데요.
이것은 이제 하나의 suspend가 가능한 suspension포인트가 됩니다.
이것은 IDE의 좌측 gutter라는 곳에 끊어진 화살표 아이콘 표시로도 확인해 볼 수 있는데요.
intelliJ 나 AndroidStudio에서는 이것을 확인해 볼 수 있습니다.
아래 이미지에서 볼 수 있듯이, suspension포인트인 doHello()함수나 delay() 함수 좌측에는,
끊어진 화살표를 볼 수 있습니다.
4-2. 순차적으로 실행되는 코루틴
아래 이미지와 같이 2개의 suspend함수를 실행하는 코루틴코드가 있다고 하겠습니다.
suspend함수는 suspend와 resume의 메커니즘으로 동작하는 비동기함수인데요.
아래의 doSomethinsUsefulOne이 네트워크실행,
다음의 doSomethinsUsefulTwo가 파일IO실행이라고 가정해 보겠습니다.
원래 비동기적인 2개의 함수가 있다면,
하나의 함수가 실행되고 그것이 완료되어 값을 받고 나서야,
그것에 따라서, 두번째 코드를 실행시킬 수 있습니다.
그러한 과정을 코루틴코드는 내부에서는 순차적으로 실행이 되는것처럼 실행시켜 준다는 것 인데요.
아래 코드의 경우 첫번째 suspend함수의 리턴값과 두번째 suspend함수의 리턴값을 더해주었습니다.
원래 둘다 비동기적인 suspend함수로서 값을 언제주는지 보장받지 못하는데,
코루틴이 이것들의 값이 나오기를 기다려서 값을 다 받은다음, 코드에 나온데로 더해서 값을 보여준다는 것입니다.
비동기적인 연산을 순차적인것처럼 코딩할수 있게 해준다는 것 인데요.
콜백을 생각하면 코루틴이 해주는 일을 이해하기 편한데요.
콜백에 따라서 값이 넘어오면 그 값에 따라서 다음값이 동작해야 하므로,
이러한 과정이 반복될수록 블록이 깊어져서 콜백헬이 발생하게 됩니다.
코루틴을 사용하면 비동기 코드를 순차적이고 간단하게 작성하고 실행할 수 있도록 도와줍니다.
실행해보면 다음과 같은 결과를 볼 수 있습니다.
약 2초(2000ms)동안 순차적으로 연산되어 답을 합쳐 출력하였습니다.
5. async와 Concurrency
위에서 코루틴이 비동기 함수들을 순차적인 코드처럼 코딩해서 순차적으로 실행하는 것을 보여주었는데요.
동시에 실행시켜서 결과가 나오는 것 우선순으로 빠르게 비동기적으로 실행시켜 보려고 하면 어떻게 할까요?
이럴 때는 async 라는 코루틴 빌더를 사용해 주면 되는데요.
async는 동시적으로 함수를 실행해주는데, 리턴되는 값은, 미래에 값을 받을 수 있는 Deferred타입으로 들어오구요.
이것으로부터 값을 얻기 위해서는 await()함수를 아래와 같이 사용해 주면 됩니다.
완료까지 걸린시간이 1초로 절반가까이 줄어들었습니다.
동시성의 힘이라고 할 수 있겠지요.
2개의 코루틴이 각자 동시에 출발하였기 때문에 얻을 수 있는 결과입니다.
위에서는 순차적으로 실행되어 2초정도가 걸렸는데, 동시에 실행되고 나온결과를 합치니 1초정도로 시간이 대폭 줄어들었습니다.
아래와 같이, await를 각각 실행시키면 순차적실행과 같은 효과가 날 텐데요.
이번에는 각각의 결과를 기다린다음에 순차적으로 실행되므로, 다시 2초정도가 걸렸습니다.
위에서 비동기코드를 동시성을 부여해서 실행시키고 싶을 때 async api를 쓴다고 하였는데요.
물론 async는 단순히 동시성부여만을 목적으로 하지않고, 값을 return받아야 하는 비동기API에도 사용합니다.
async함수의 코드를 보면 다음과 같습니다.
위의 코드에서 리턴되는 타입으로 Deferred에 대해서도 보고 가겠습니다.
cancellable한 future라고 나와있는데요.
주석에 보면 result값을 가지는 Job이라고 나와있습니다.
코드상에서도 Job을 상속하고 있구요.
한가지 더 알아두어야 할 특징이 있는데요.
async는 fail이 날경우, 부모의 job을 cancel시킵니다.
공식문서에서는 Cancellation이 coroutine Hierarchy를 통해서 전파되어진다고 표현하고 있습니다.
아래코드와 같이 두번째 async에서 Exception이 발생했을 경우,
hierarchy를 타고 전파가 되서, 전체 코루틴이 종료가 되어버립니다.
async의 Exception 핸들링은 그래서 쉽지만은 않습니다.
실행해보면 다음과 같은데요.
익셉션이 발생되었는데, 형제간에도 전파가 되고, 부모에게까지 전파된 것 입니다.
가장 먼저는 형제인 첫번째 async에 전파되었고, 이후 부모에게 전파되어 모든 코루틴이 종료되었습니다.
이러한 특징을 바꾸기 위해서는, 한방향으로만 cancel이 전파되는 SupervisorJob이나 supervisorScope를 활용해야 합니다.
이 SupervisorJob과 SupervisorScope는 아래에서 다루도록 하겠습니다.
4-4. Lazy 하게 시작하는 async
async는 lazy하게 실행하도록 설정할수도 있는데요.
async()의 인자에 "start=CoroutineStart.LAZY"를 넣어주면 됩니다.
Lazy하게 실행된다는 것은 후에 무언가의 호출이 있어야 한다는 의미인데요.
그 때 사용하는 것이 start()함수입니다.
물론, await()함수를 사용할 때도 호출이되서 시작이 됩니다.
결과값을 기다리지 않고, start()함수에 의해 바로바로 2개가 호출되었기에,
1초만에 결과가 나왔습니다.
만약, start()함수가 없었다면, await()함수가 호출되고 값이 나오는 것을 기다리기 때문에,
2초가 걸리게 될 것 입니다.
await()는 join()처럼 결과가 나오는 것을 기다려주는 함수이기 때문입니다.
6. Coroutine디버깅하기
현재 실행중인 코루틴에 대한 정보를 쉽게 얻을 수 있는 방법이 있는데요.
JVM옵션설정에 아래 값을 넣어주는 것 입니다.
-Dkotlinx.coroutines.debug
IDE툴에 넣어주는 방법은 다음과 같은데요.
먼저, IntelliJ나 AndroidStudio에 보면 Run메뉴에 'Edit Configurations...'가 있는데요.
여기에 먼저 들어가 줍니다.
우측의 VM options에 "-Dkotlinx.coroutines.debug"를 아래와 같이 추가해 주기만 하면 됩니다.
아래의 코드를 실행해 보겠습니다.
아래와 같이 어떤 쓰레드의 몇번 코루틴에서 실행되는지 구분되어 나오는 것을 볼 수 있습니다.
7. CoroutineContext 와 Dispatchers
7-1. CoroutineContext
Context라는 것은 문맥이라는 뜻인데요.
코루틴은 항상 CoroutineContext 타입으로 정의된 어떤 Context에 의해서 실행이 됩니다.
이 context는 여러가요소의 한 집합인데요.
위에서 보았던 Job객체나 아래에서 볼 Dispatcher가 대표적인 요소들입니다.
이렇게 어떻게 컨트롤 되고, 어떤 쓰레드에서 실행될지등 어떤 조건이나 환경에서 실행될지를 의미하는 것이 Context라고 할 수 있겠네요.
코루틴 빌더인 launch, async 그리고
suspending블록을 특정한 Context에서 실행시켜주는 함수인 withContext 는 이러한 옵션들을 인자로 받아들입니다.
Context의 요소로 job이나 dispatcher가 있다고 하였는데요.
코루틴빌더에서 CoroutineContext에 대해서 아래와 같이 접근해 보도록 하겠습니다.
실행해 보면 다음과 같은 결과를 볼 수 있구요.
이 중 job에 대해서 보고자 한다면 아래와 같이 접근해 주면 됩니다.
실행해보니 다음과 같은 결과를 볼 수 있습니다.
아래에서 dispatcher들을 보면서 context가 어떻게 바뀌는지 보도록 하겠습니다.
7-2. Dispatchers
Dispatcher는 코루틴이 실행될 대 어떤 쓰레드를 사용할 것인지를 결정합니다.
대부분, Default나 IO 또는 Main정도를 많이 사용하게 될 것입니다.
이름 | ThreadName | 특징 |
Unconfined | - | Unconfined는 최초에 call된 쓰레드에서 실행이 되는데, suspend이후에는, resume이 되었을 때 suspending 함수에 의해 결정이 되는데, 어느 쓰레드에서 실행이 재개될지 알 수 없습니다. |
Default | DefaultDispatcher-worker |
최대로 CPU코어의 개수와 동일한 정도까지 실행됨 복잡한 계산등에 적합 |
IO | DefaultDispatcher-worker | 64개 혹은 CPU코어 중 더 많은 것을 한도로, 많은 IO처리에 블록킹없이 수행되도록 하기 위한 것임 |
Main | Main | Main쓰레드에서 실행시키고자 할 때 사용. |
newSingleThreadContext | 새로운 이름의 쓰레드를 정해서 실행 | ex> newSingleThreadContext("새로운쓰레드이름") 실행후에, Thread.currentThread().name 으로 확인가능 공식문서상에 매우 비싼 리소스라고 설명되어 있습니다. 실제 개발에서, release되거나, close함수를 사용해 닫아주어야 하구요. 그렇지 않으면 탑레벨 변수로 정의해서 앱전체에서 사용하도록 해야 합니다. 즉, 관리를 해 주어야 하게 된다는 것 이지요. |
위에서 보았던 것을 코루틴 빌더인 launch로 실행해 보면 다음과 같습니다.
실행하면 다음과 같이 나오는 것을 볼 수 있습니다.
7-3. Thread간 이동하기
아래 코드를 실행해 보겠습니다.
withContext()라는 함수를 이용하면 특정한 CoroutineContext에서 suspending함수를 실행할 수 있는데요.
이러한 특징을 이용해서 쓰레드간 이동을 할 수 있습니다.
1번쓰레드에서 start해서, withContext로 2번 쓰레드로 이동한다음,
다시 돌아와서 2번쓰레드에서 실행시켰습니다.
실행해보면 다음과 같은 모습을 볼 수 있습니다.
쓰레드를 여기저기 옮기다니는 모습을 볼 수 있었습니다.
참고로 위에서 newSingleThreadContext라는 것을 이용하였는데요.
익셉션이 발생해도 finally에서 close를 실행해주는 use함수를 사용하였습니다.
위에서 언급한 것처럼 newSingleThread는 리소스를 닫아주는 것을 잊어서는 않됩니다.
7-4. Children 과 다른 job
Structured Concurrency에서도 정리를 하였는데요.
Coroutine들은 구조적으로 관계를 가지고 있습니다.
CoroutineScope에서 launch된 자식 코루틴들은, 부모로부터 coroutineContext를 물려받습니다.
그래서 부모 코루틴에 cancell이 발생하면, 그의 자식들에게도 퍼져서 cancel이 되게 됩니다.
그런데, 아래 2가지 경우 중 하나만으로도, 이러한 부모자식 관계가 깨지고 독립적으로 변하게 됩니다.
- 자식이 독립적인 Scope를 가지는 GloblaScope에서 실행
- 다른 job을 가지게 될 경우
- 부모가 다른 근본이 사라진 Job이 Context에 들어오게되어 관계가 깨지게 된다.
아래 코드를 보도록 하겠습니다.
실행해보면 다음과 같은 결과를 볼 수 있습니다.
부모가 cancle()이 되자, job2는 cancel이 되었는데요.
부모가 취소되고도, job1은 살아남았습니다.
새로운 Job()객체를 받고 독립하였기 때문입니다.
이렇게 구조가 깨지게 되면,
자식의 수행이 완료될때까지 기다려 주지 않게되구요.
익셉션이 발생해도 전파가 되지 않게 됩니다.
부모를 취소해도(cancel())해도 자식은 영향을 받지않고 실행되어 버리구요.
Structured Concurrency가 깨지는 상황이므로 사용에 주의가 필요합니다.
7-5. 부모 코루틴의 책임
부모 코루틴은 다음과 같은 책임이 있습니다.
- 모든 자식들의 코루틴이 완료되기를 기다려 줄 책임
- 따로 join()함수를 사용하지 않아도 부모코루틴은 무조건 자식 코루틴의 완료를 기다려준다는 의미임
아래코드는, join()함수 없이도 자식까지 모두 실행되는 것을 볼 수 있도록 해 줍니다.
실행하면 아래와 같은 모습을 보여줍니다.
7-6. CoroutineContext elements
위에서 CoroutineContext의 요소들을 여러개 가질수 있다고 하였는데요.
코드상으로 코루틴 빌더에 인자로 이 요소들을 넣을 수 있습니다.
아래에서는 Dispatchers.Default 와 CoroutineName("test"),
그리고 part2에서 정리할 ExceptionHandler 를 "+"연산자를 이용해 넣어주었습니다.
실행해 보면 다음과 같은 결과를 볼 수 있습니다.
8. 정리
코루틴 총정리 part1은 여기서 마치구요.
다음 글에서는, Exception Handling과 Cancellation 그리고 여러개의 Job을 다룰때의 주의점 에 대해서 정리해 보도록 하겠습니다.
'Android 개발 > Coroutine , Flow, Channel' 카테고리의 다른 글
Coroutine suspend 동작에 관한 좋은 예와 잘못된 예 # 비동기 (0) | 2023.04.18 |
---|---|
flatMapLatest 이용해서 값이 들어오는 것을 기다리기 # Coroutine (0) | 2023.04.18 |
함수 실행 시간 측정 후 Delay 사용하기 (0) | 2023.04.14 |
MutableStateFlow 이용한 로딩 후 로딩 완료 기다리기 구현 방법 (0) | 2023.04.08 |
StateFlow 정리 # Android Kotlin Coroutine getStateFlow StateIn (0) | 2022.10.12 |
Kotlin Coroutine Flow 총정리 part3 # launchIn (0) | 2022.10.10 |
Kotlin Coroutine 총정리 part2 # Cancellation Exception Handling (0) | 2022.10.09 |
Coroutine을 이용해 Parallel한 네트워크 호출 #Kotlin (0) | 2020.03.28 |
코틀린 Coroutine으로 네트워크 Retry 구현하는 방법 (0) | 2020.03.24 |
Coroutine의 Async와 await 사용시 Exception Handling에 관하여 #Kotlin (0) | 2020.02.16 |
댓글