Kotlin/코틀린 코루틴의 정석
[Kotlin 코루틴] async와 Deferred
꾸준함.
2024. 11. 15. 16:12
서론
- launc 코루틴 빌더를 통해 생성되는 코루틴은 기본적으로 작업 실행 후 결과를 반환하지 않지만
- async 코루틴 빌더를 통해 생성된 코루틴으로부터 결괏값을 수신받을 수 있음
- async 함수를 사용하면 결괏값이 있는 코루틴 객체인 Deferred가 반환됨
- Deferred 객체를 통해 코루틴으로부터 결괏값을 수신할 수 있음
async 사용해 결괏값 수신하기
1. async 사용해 Deferred 생성하기
- async 함수는 launch 함수와 유사하지만 다음과 같은 차이점이 존재함
- launch는 코루틴이 결괏값을 직접 반환할 수 없기 때문에 Job 객체를 반환
- async는 코루틴의 결괏값을 직접 반환하고 결괏값을 담아 반환하기 위해 Deferred<T> 타입의 객체를 반환
* Deferred는 Job과 같이 코루틴을 추상화한 객체이지만 코루틴으로부터 생성된 결괏값을 감싸는 기능을 추가로 가지며 결괏값의 타입은 제네릭 타입 T로 표현
2. await를 사용한 결괏값 수신
- Deferred 객체는 미래의 어느 시점에 결괏값이 반환될 수 있음을 표현하는 코루틴 객체
- 코루틴이 실행 완료될 때 결괏값이 반환됨
- 만약 결괏값이 필요하다면 결괏값이 수신될 때까지 대기해야 함
- Deferred 객체는 결괏값 수신의 대기를 위해 await 함수 제공
- await 함수는 await의 대상이 된 Deferred 코루틴이 실행 완료될 때까지 await 함수를 호출한 코루틴을 일시 중단 시킴
- Deferred 코루틴이 실행 완료되면 결괏값을 반환하고 호출부의 코루틴을 재개함
- Job 객체의 join 함수와 매우 유사하게 동작
Deferred는 특수한 형태의 Job
- Deferred 객체는 Job 객체의 특수한 형태로 Deferred 인터페이스는 Job 인터페이스의 서브타입으로 선언된 인터페이스
- 따라서 `모든 코루틴 빌더는 Job 객체를 생성한다`라는 명제는 참
- Deferred 객체는 코루틴으로부터 결괏값 수신을 위해 Job 객체에 몇 가지 기능이 추가되었을 뿐, 여전히 Job 객체의 일종
- 이런 특성 때문에 Deferred 객체는 Job 객체의 모든 함수와 프로퍼티를 사용할 수 있음
복수의 코루틴으로부터 결괏값 수신하기
- 개발할 때 여러 비동기 작업으로부터 결괏값을 반환받아 병합해야 하는 케이스가 생김
- 위와 같은 경우에는 복수의 코루틴을 생성해 결괏값을 취합해야 함
1. await를 사용해 복수의 코루틴으로부터 결괏값 수신하기
부연 설명
- 앞서 언급했다시피 await를 호출하면 결괏값이 반환될 때까지 호출부의 코루틴이 일시 중단됨
- Dispatchers.IO를 사용해 백그라운드 쓰레드에서 코루틴을 실행하더라도 await를 호출하면 코루틴이 실행 완료될 때까지 runBlocking 코루틴이 일시 중단돼 대기함
- 따라서 participantDeferred1.await()가 participantDeferred2 코루틴이 생성되기 전에 호출되면 participantDeferred1 코루틴과 participantDeferred2 코루틴은 순차적으로 처리됨
- 서로 간에 독립적인 작업을 동시에 처리할 수 있음에도 불구하고 순차적으로 처리하게 되면 매우 비효율적
- 따라서 participantDeferred1 코루틴의 await를 호출하는 위치를 participantDeferred2 코루틴이 실행된 이후로 만들어 동시에 처리하도록 처리
2. awaitAll을 사용한 결괏값 수신하기
- await 함수를 N번 호출해야하는 경우 await 함수를 N 줄에 걸쳐 호출해야 하기 때문에 가독성이 안 좋음
- 위 문제를 해결하기 위해 코루틴 라이브러리는 복수의 Deferred 객체로부터 결괏값을 수신하기 위한 awaitAll 함수를 제공
3. 컬렉션에 대해 awaitAll 사용하기
- Collection<Deferred<T>>에 대해 awaitAll 함수를 호출하면 컬렉션에 속한 Deferred들이 모두 완료돼 결괏값을 반환할 때까지 대기
withContext
1. withContext로 async-await 대체하기
- 코루틴 라이브러리에서 제공되는 withContext 함수를 사용하면 async-await 작업 대체 가능
- withContext 함수가 호출되면 함수의 인자로 설정된 CoroutineContext 객체를 사용해 block 람다식을 실행하고 완료되면 해당 결과를 반환
- withContext 함수를 호출한 코루틴은 인자로 받은 CoroutineContext 객체를 사용해 block 람다식을 실행하며, block 람다식을 모두 실행하면 다시 기존의 CoroutineContext 객체를 사용해 코루틴이 재개됨
- async-await 쌍을 연속적으로 실행했을 때의 동작과 매우 유사함
2. withContext의 동작 방식
- withContext 함수는 겉보기에는 async와 await를 연속적으로 호출하는 것과 비슷하게 동작하지만 내부적으로 보면 다르게 동작함
- async-await 쌍은 새로운 코루틴을 생성해 작업을 처리
- withContext 함수는 실행 중이던 코루틴을 그대로 유지한 채로 코루틴의 실행 환경만 변경해 작업을 처리
부연 설명
- runBlocking 함수와 block 람다식을 실행하는 쓰레드와 withContext 함수의 block 람다식을 실행하는 쓰레드는 main과 DefaultDispatcher-worker-1으로 다르지만 코루틴은 coroutine$1으로 같은 것을 확인 가능
- withContext 함수는 새로운 코루틴을 생성하는 대신 기존의 코루틴에서 CoroutineContext 객체만 바꿔서 실행
- withContext 함수가 호출되면 실행 중인 코루틴의 실행 환경이 withContext 함수의 context 인자 값으로 변경돼 실행되며 이를 컨텍스트 스위칭이라고 칭함
- 만약 context 인자로 CoroutineDispatcher 객체가 넘어온다면 코루틴은 해당 CoroutineDispatcher 객체를 사용해 다시 실행됨
부연 설명
- withContext를 호출하면 코루틴이 유지된 채로 코루틴을 실행하는 실행 쓰레드만 변경되기 때문에 동기적으로 실행됨
- async-await 쌍을 사용하면 새로운 코루틴을 만들지만 await 함수를 통해 순차 처리가 돼 동기적으로 실행됨
3. withContext 사용 시 주의점
- 복수의 독립적인 작업이 병렬로 실행돼야 하는 상황에 withContext를 사용할 경우 성능 문제 야기 가능
부연 설명
- runBlocking을 통해 실행된 코루틴은 처음에는 메인 쓰레드에서 실행되는데 withContext(Dispatchers.IO)를 사용하면 코루틴을 유지한 채로 실행 쓰레드만 바뀜
- 따라서 1초 간 대기 후 `Hello`를 반환받고, 다시 1초간 대기 후 `World`를 반환받음
- 각 withContext 블록의 코드를 실행하는 데는 1초 밖에 안 걸리지만 순차적으로 처리돼 2초의 시간이 걸리게 됨
- withContext 함수가 새로운 코루틴을 생성하지 않기 때문에 생기는 문제
- 이 문제를 해결하기 위해서는 withContext를 제거하고, 코루틴을 생성하는 async-await 쌍으로 대체하면서 Deferred 객체에 대한 await 함수 호출을 만든 코루틴이 실행된 뒤에 해야 함
4. 정리
- withContext 함수를 사용하면 코드가 깔끔해 보이는 효과를 내지만 잘 못 사용하게 될 경우 코루틴을 동기적으로 실행하도록 만들어 코드 실행 시간이 배 이상으로 증가할 수 있음
- withContext 함수가 새로운 코루틴을 생성하지 않는다는 것을 명심해야 함
참고
코틀린 코루틴의 정석 (조세영 저)
반응형