PROGRAMMING LANGUAGE/KOTLIN

[Kotlin Coroutine] 코루틴 빌더와 비동기 패턴의 이해

EARTH_ROOPRETELCHAM 2024. 6. 16. 00:04
728x90
반응형

목표

  • launchasync 빌더 사용법과 각각의 특징 설명
  • Deferred 객체 사용법과 비동기 결과 처리 방법
  • 실제 네트워크 호출을 예로 들어 비동기 처리 실습

코루틴 빌더 함수

코루틴을 생성하는 데 사용하는 함수를 코루틴 빌더 함수라고 한다.

코루틴 빌더 함수 종류
- runBlocking
- launch
- async

launch

launch

launch는 코루틴을 추상화한 Job 객체를 반환하며, 이 Job 객체가 cancel되면 코루틴도 cancel됩니다.

launchcoroutineContextCoroutineScope를 통해 상속받고, 추가적인 context 요소를 넣을 수도 있습니다.

  • 만약, 동일한 key를 가진 context 요소가 이미 있었다면 해당 값을 덮어씌우는 형태로 동작합니다.

launch에 context 설정 유무에 따라 다른 context로 동작함 #1
launch에 context 설정 유무에 따라 다른 context로 동작함 #2

위 이미지를 보면 확인할 수 있듯이, launchcontext를 따로 설정하지 않으면 EmptyCoroutineContext로 설정되어 부모의 context를 그대로 상속합니다. 반면에, launchcontext를 설정하면, 부모의 context를 상속받되, 중복된 key가 있다면 덮어씌우는 형식으로 동작합니다.

코루틴 상태

코루틴 상태

코루틴은 위와 같은 상태를 가지며, 상태값은 Job 객체를 통해 외부에 노출이 됩니다.

  • 생성(New): 코루틴 빌더를 통해 코루틴을 생성할 때의 상태이며, 자동으로 실행 중 상태로 넘어감(Lazy 설정 시, 생성 상태로 대기)
  • 실행 중(Active): 코루틴이 실제 실행 중일때와 실행 중 일시 중단되었을 때 모두 실행 중 상태 유지
  • 실행 완료(Completed): 코루틴이 완료된 상태
  • 취소 중(Cancelling): cancel() 함수 호출을 통해 취소가 진행 중인 상태로, 아직 취소가 완료된 상태가 아니라 코루틴은 여전히 실행중
  • 취소 완료(Cancelled): 코루틴의 취소 확인 시점에 취소가 확인되면, 취소 완료 상태가 되며 코루틴은 더 이상 실행되지 않음

Job 객체는 코루틴을 추상화한 객체이기 때문에, 필드 값을 외부에서 볼 수는 없고, 코루틴의 상태를 아래 필드를 통해 간접적으로 노출합니다.

Job 객체 통해 확인해보는 코루틴 상태

  • isActive: 코루틴 활성화 여부
    • 활성화: 코루틴이 실행된 후 취소 요청되거나 실행이 완료되지 않은 상태
  • isCancelled: 코루틴이 취소 요청되었는지 여부
    • 취소 요청이 되었다고 해서 바로 코루틴이 취소되는 것은 아니며, isCancelled가 true가 되면, 실제로는 코루틴이 아직 실행중이어도 코루틴이 활성화된 상태로 보지 않아, isActive가 false가 됨
  • isCompleted: 코루틴 실행 완료 여부

job으로 할 수 있는 일

  • join()joinAll()을 통한 작업 순차처리
특정 launch 코루틴이 완료된 후 실행되어야 하는 코루틴이 있다면, 두 코루틴 사이에 join() 함수를 활용해 순차처리하도록 설정할 수 있습니다.

join() 함수 예시
join() 호출 시, 이미 실행 중인 다른 코루틴은 일시 중단하지 않음

참고!
위 예시처럼, join()은 join()을 호출한 코루틴을 제외하고, 이미 실행중인 다른 코루틴을 일시 중단하진 않습니다.
복수의 코루틴의 실행이 모두 끝날 때까지 호출부의 코루틴을 일시 중단시켜야할 때는 joinAll()을 활용할 수 있습니다.
사용 예) 여러 API를 비동기적으로 호출하여 결과를 모두 받아온 후 병합하는 코루틴을 수행시켜야 할 때 사용

joinAll() 함수 예시

joinAll() 함수를 테스트할 때에는 Dispatcher 설정에 따라 사용할 수 있는 스레드 개수가 달라 혹 작업 속도에 영향을 주지 않을지 궁금하여 launchDispatcher를 설정한 경우와 아닌 케이스를 나누어 테스트를 진행했습니다. Dispatcher를 따로 설정할 경우, runBlocking 내부에서 돌아도 main 스레드가 아닌 다른 스레드에서 동작하여 Dispatcher를 설정하지 않을 때보다 속도가 빠를 것을 기대했으나 큰 차이는 없었습니다. 

  • 코루틴 지연 시작
기본적으로 launch 함수를 사용한 코루틴은 생성한 이후 사용할 수 있는 스레드가 있는 경우 곧바로 실행됩니다. 만약, 코루틴은 지금 생성하되, 이후에 실행해야 한다면 코루틴을 지연 시작(Lazy Start)할 수 있는 기능을 이용할 수 있습니다.

lazy로 설정한 job은 명시적으로 start() 함수를 호출해야 실행됨


위 예시처럼, lazy로 설정한 launch 코루틴은 해당 jobstart() 함수를 명시적으로 호출해야 실제로 실행이 됩니다. 참고로, 위 예시에서 immediateJobactive 값이 true로 나온 이유는, immediateJob을 수행한 스레드와 job의 상태를 출력하는 스레드가 서로 상이하여서 immediateJob이 완전히 종료되기 전에 active값을 확인했기 때문입니다. 

  • 코루틴 취소
간혹, 작업을 하다보면 사용자가 요청을 취소할 경우도 있습니다. 이미 취소된 요청인데, 관련된 코루틴 작업을 진행하면 스레드를 소비하게 되므로 애플리케이션 성능에 좋지 않습니다. 이 경우, job의 cancel()함수를 통해 코루틴을 취소할 수 있습니다.
사용 예) 특정 시간동안 끝나지 않은 코루틴은 취소하고자 할 경우 사용

아래 예시에서 확인할 수 있듯이, 1000ms을 10번 반복하는 launch 코루틴을 만들었을 때, 호출부에서 3500ms을 기다린 후 수행이 완료되지 않은 부분부터 cancel하여 로그가 10번이 아닌, 4번 찍힘을 확인할 수 있습니다.

job의 cancel() 사용 시, cancel()전까지의 코루틴 작업이 진행됨(중간), cancelAndJoin() 예시(오른쪽)

cancel() 함수를 사용할 때에는 순차 처리 작업에 유의해야 합니다. 예를 들어, A라는 코루틴 취소가 완료된 이후 B라는 코루틴이 시작되어야 한다면, cancelAndJoin() 함수를 사용해야 합니다. (cancel() 호출 후 바로 join() 호출해도 무방)

  • cancel() 호출 시, 코루틴은 즉시 취소되는 것이 아닌, Job 객체 내부의 취소 확인용 플래그를 '취소 요청됨'으로 변경함으로써 코루틴이 취소되어야 함을 알립니다. 이후 미래 어느 시점에 코루틴의 취소 요청이 있는지 확인 후 실제 취소 작업이 이루어집니다.
    • 코루틴의 취소 요청 확인 시점은, 일반적으로 일시 중단 시점이나 코루틴이 실행을 대기하는 시점으로, 이 시점이 없다면 코루틴은 취소되지 않습니다
  • cancelAndJoin() 함수는 취소 작업이 완료될 때까지 해당 코루틴의 호출부의 코루틴이 일시 중단됩니다.

[ 일시 중단 시점이 없는 코루틴 취소하는 법 ]

예를 들어, 아래에서 cancel하고자 하는 job에는 일시 중단 시점(예: 코루틴 내 delay 함수 호출)이나 코루틴이 실행을 대기하는 시점이 없어서, cancel되지 않습니다.

중단 가능 지점이 없어 취소가 되지 않고 있음

이처럼, 중단 가능 지점이 없어 cancel되지 않는 코루틴이 있다면 아래 방법을 통해 취소 여부 확인을 할 수 있습니다.

  • 코루틴 내부에 delay를 통한 취소 확인
  • 코루틴 내부에 yield를 넣어 취소 확인
  • CoroutineScope.isActive를 사용한 취소 확인

위 테스트와 달리 cancel()하고자 하는 job 내에 중단 가능 지점이 있어 cancel()이 되는 것을 아래 예시에서 확인할 수 있습니다.

하지만 아래 예시는 수행하는 코루틴 내에 강제로 중단 가능 지점(delay, yield)을 넣는 방식이라, 코루틴이 잠시 멈추거나, 스레드 사용을 양보하여 성능을 저하시키게 됩니다. 

정상적으로 cancel()하기 위해 코루틴 내부에 delay 또는 yield 추가

이럴 때 사용할 수 있는 방법이 CoroutineScope.isActive를 사용한 취소 확인 방법입니다. CoroutineScope코루틴의 활성화 여부를 나타내는 isActive 필드를 가지고 있으며, 코루틴에 취소 요청이 들어갔을 때 isActivefalse로 바꾸어줍니다. 따라서, 아래 예시처럼 isActive 필드값을 중단 지점 없는 코루틴에서 성능 저하 없이 코루틴을 취소시켜줍니다.

  • 취소 요청이 되면, Job.isCancelledtrue가 되고 Job.isActivefalse가 되기 때문에 CoroutineScopeisActive를 활용할 수 있는 것입니다.

CoroutineScope.isActive를 통한 성능 저하 없이 중단 지점 없는 코루틴 취소

참고) 만약 위 예시에서 launch의 context를 따로 설정하지 않으면, this.isActive로 취소 체크가 불가능합니다. 그 이유는, runBlocking의 context를 상속 받은 launch는 메인스레드에서 계속 동작하기 때문에, job.cancel() 구문을 메인 스레드가 접근할 수 없기 때문입니다.

async

async(왼쪽)와 Deferred(오른쪽)

앞서 살펴본 launch 코루틴 빌더는 기본적으로 수행 결과를 반환하지 않습니다. 코루틴 라이브러리는 비동기 작업으로부터 결과를 수신해야 하는 경우 사용할 수 있는 async 코루틴 빌더를 제공합니다. asynclaunchJob을 반환했던 것과 달리, Deferred를 반환하여 Deferred 객체를 통해 코루틴 결과값을 얻습니다.

 

async 코루틴 빌더는 launch 코루틴 빌더와 마찬가지로 CoroutineScope의 확장함수이며, 기본적인 호출 방법은 launch와 유사합니다. start 필드를 통해 LAZY 설정도 할 수 있고, 특정 context에서 수행될 수 있도록 Dispatcher도 설정이 가능합니다.

 

async가 반환하는 Deferred 객체는 Job을 상속하고 있어, 기본적으로 Job과 같은 코루틴 추상화 객체입니다. Deferred는 Job보다 좀 더 편리한 메소드를 제공하며 미래 어느 시점에 코루틴 결과값을 가져올 수 있는 역할도 함께 제공합니다.

Deferred는 Job에서 제공하는 함수를 사용할 수 있음

Deferred 객체를 통해 결과값 수신을 기다리기 위한 await() 함수가 존재합니다. await() 함수를 호출하면 응답을 가져올 때까지 호출부의 코루틴을 일시 중단(Job의 join()과 유사한 역할)하게 됩니다. join()에 joinAll()이 있었던 것처럼, 여러 Deferred 객체 결과값 반환을 기다리는 awaitAll() 함수도 제공합니다.

await, awaitAll 함수 예시

  • awaitAll() 함수 관련한 개인적인 아쉬움은, 결과를 List 형태로 제공준다는 점입니다. 여러 개의 async 결과물을 map 형태로 넣어줬다면 좀 더 사용하기 좋을 것 같다는 생각이 듭니다.
    • 예를 들어, 특정 클래스에 데이터를 채우기 위해 여러 API 호출이 필요하다면 async를 활용할 수 있습니다. 이때, awaitAll()로 한 번에 결과를 들고오고 싶지만 각 필드에 어떤 Deferred 결과를 넣을지 List로 전달받으면 코드에 명시적으로 표현하기가 어렵습니다.

withContext

withContext

withContext는 전달받은 context 내에서 람다식으로 전달받은 내용을 수행하고 결과를 반환합니다. withContextasyncawait() 함수를 각각 호출을 해야 코루틴 결과를 반환받았던 것보다 좀 더 간결하게 표현할 수 있습니다.

async-await(왼쪽)과 withContext(오른쪽) - withContext가 조금 더 간결하며, 새로운 코루틴 생성 여부가 다름

withContextasync-await 동작 방식은 내부적으로는 차이가 있습니다.

  • withContext: 코루틴 빌더가 아니기 때문에, 새로운 코루틴을 생성하는게 아닌 기존 코루틴을 유지합니다. 단지, withContext에 설정된 context로 코루틴 수행처를 옮겨갈 뿐(컨텍스트 스위칭)입니다. 따라서, 하나의 코루틴 내에서 실행되는 것이기 때문에 동기적으로 실행됩니다.
  • async-await: 코루틴 빌더이기 때문에, 호출처와는 다른 새로운 코루틴을 생성합니다. 따라서, 호출처의 코루틴과 별개의 코루틴이기 때문에 호출처의 코루틴 수행 도중에 async 결과를 받아 동기적으로 실행하고자 await을 호출하는 것입니다.

다만, withContext를 사용할 때에는 몇 가지 주의가 필요합니다.

async-awaitAll(왼쪽) vs withContext 여러개(오른쪽)

  • withContext는 새로운 코루틴을 생성하는게 아니기 때문에, 병렬적으로 수행할 수 있는 작업을 withContext로 각각 나타낼 경우 동기적 수행을 통해 수행 시간이 배로 걸리게 됩니다.
    • withContext는 호출처의 코루틴을 컨텍스트 스위칭을 통해 다른 스레드에서 수행하는 것이기 때문에, 코루틴이 동기적으로 수행되어 병렬 수행 가능한 withContext를 여러개 만들더라도 동기적으로 동작합니다.

runBlocking과 launch 차이

runBlocking 함수와 launch 함수는 모두 코루틴 빌더 함수이지만, 코루틴 빌더를 호출한 스레드를 사용하는 방법에 차이가 있습니다.

runBlocking 함수의 동작 방식

  • runBlocking 함수가 호출되면, runBlocking 코루틴 실행이 완료될때까지 호출부의 스레드를 차단(block)하고 사용합니다.
    • runBlocking 함수가 호출된 스레드가 아닌 다른 스레드에서 실행되더라도, 해당 코루틴이 실행되는 동안 runBlocking 함수를 호출한 스레드는 차단됩니다. runBlocking 코루틴이 완료되어야 차단이 풀립니다.
    • 이때, 호출된 스레드는 배타적으로 사용되기 때문에, runBlocking 하위 코루틴은 호출부 스레드를 사용할 수 있습니다.
runBlocking 함수의 차단은 스레드 블로킹과는 다릅니다.
스레드 블로킹은 해당 스레드가 어떤 작업에도 사용될 수 없게 차단된다면,
runBlocking 함수의 차단runBlocking 코루틴과 자식 코루틴들을 제외한 다른 작업이 스레드를 사용할 수 없는 것입니다.

runBlocking 코루틴은 호출부의 스레드를 차단함

launch 함수의 동작 방식

launch 함수는 runBlocking과 달리, 호출부의 스레드를 차단하지 않습니다. 따라서, launch 코루틴 내 delay 같은 작업으로 인해 현재 스레드를 사용하지 않는다면 다른 작업에 해당 스레드를 양보합니다.

참고 자료

728x90
반응형