코루틴이란?
코루틴은 안드로이드에서 간단하게 비동기적으로 코드를 실행할 수 있는 동시성 디자인 패턴입니다.
동기와 비동기는 어떤 차이가 있을까요??
동기 : 작업을 순차적으로 진행
비동기: 순차적으로 작업을 진행하지 않고, 여러 작업을 동시에 진행하는 것.
코루틴 관련 개념들을 알아봅시다.
코루틴 스코프
코루틴의 실행 범위를 정의하며, 코루틴의 생명 주기를 관리한다. 부모 자식 관계 구조에 따라서 스코프의 종료 및 취소가 전파된다. 코루틴은 스코프에 바인딩되며 부모 스코프가 종료나 취소가 된다면 자동으로 내부에 있는 모든 코루틴도 종료나 취소 처리가 된다.
코루틴 스코프 종류
- GlobalScope : 앱 생명주기와 함께한다.
- lifecycleScope : Activity나 Fragment가 활성 상태일 때만 코루틴을 유지할 수 있습니다.
- viewModelScope : ViewModel의 생명주기와 함께한다.
- custom scope : CoroutineScope 함수를 이용해서 직접 생성
CoroutineScope(ExampleCoroutineContext).launch{}
코루틴 컨텍스트
코루틴 작업 정보들의 집합으로, 코루틴 스코프에 바인딩되는 정보들이다. 예시로 코루틴 이름, 디스패처, 잡 등이 있다. 코루틴 스코프 내부에 존재하며, 링크드 리스트 형태로 연결되어 있음.
코루틴 빌더
위에서 말한 코루틴 스코프는 작업에 대한 정보와 범위의 명세였다면 스코프의 정보를 기반으로 실제 코루틴을 메모리에 올리기 위해서는 코루틴 빌더를 사용해야 한다.
주요 API
runBlocking : 특수한 경우에만 사용, 코루틴 스코프 밖에서 사용한다. caller Thread를 Block하고 주로 main 함수나 테스트 용으로 사용함.
fun main(){
runBlocking {
delay(1000)
println("runBlocking")
}
println("main")
}
// runBlocking
// main
launch : fire and forgot, 코루틴 스코프 안에서 사용가능하고, Caller Coroutine을 중단하지 않고, 생성된 작업을 의미하는 Job을 리턴, 주로 비동기작업 요청하고, 다른 작업을 하는 경우 사용.
fun main(){
runBlocking {
launch {
delay(1000)
println("launch")
}
launch {
delay(500)
println("launch2")
}
println("main")
}
}
// main
// launch2
// launch
async : fire and wait, 코루틴 스코프 안에서 사용. Caller Coroutine을 중단하지 않고, Deferred<T>를 리턴한다. 이로 인해 await()을 사용해서 코루틴을 중단시키고 값을 기다릴 수있음. 주로 비동기 작업 요청하고 작업하고 있다가 작업 결과를 받아오고 싶을 때 사용함.
fun main(){
runBlocking {
val a = async {
delay(1000)
return@async "A"
}
val b = async {
delay(500)
return@async "B"
}
println("C")
println(a.await())
println(b.await())
println("D")
}
}
// C
// A
// B
// D
suspend function
중단, 재개가 가능한 함수를 suspend function이라 한다. 비동기 코드의 결과 값을 콜백 없이 return 방식으로 순차적으로 받아올 수 있음.
coroutine dispatcher
코루틴을 어느 쓰레드에 보내서 실행할지를 정의. 코루틴 빌더에 코루틴 컨텍스트로 추가하거나 withContext를 사용한다. withContext는 중단 함수이면서 내부 Block을 원하는 Dispatcher에서 실행하고 결과를 받아올 수 있는 API. 해당 코루틴에서 다른 쓰레드로 전환해서 작업을 하고 싶을 때 주로 사용.
주요 디스패처
Dispatchers.Main : 주로 UI 작업을 위해 사용, 무거운 작업을 하는건 권장하지 않음.
Dispatchers.IO: I/O작업을 위해 사용.
DIspatchers.Default: CPU 집약적인 작업을 처리할 때 사용되는 디스패처로, 백그라운드에서 여러 CPU 코어를 활용하여 병렬 처리를 합니다.
Dispatchers.Unconfined: 호출한 쓰레드에서 이어서 작업을 하기 위해서 사용.
코루틴 취소
지금까지 코루틴 실행시키는 것을 알아보았습니다. 때때로 취소를 해야 하는 경우도 있을텐데 지금부터 코루틴 취소에 대해서 알아보겠습니다. 코루틴의 실행 취소는 CoroutineContext의 한 종류인 Job이 관리합니다.
launch : launch는 Job을 리턴하는데 이 Job을 통해서 취소가 가능하다.
fun main() {
runBlocking {
val job = launch() {
println("Hello, ")
delay(1000)
println("World!")
}
delay(500)
job.cancel()
}
}
// Hello,
async : async은 Deffered<T>를 리턴하는데 Deffered는 Job의 인터페이스이고, Deffered를 통해서 취소가 가능하다. await()중에 취소가 되면 해당 지점에서 JobCancellationException가 발생한다.
fun main() {
runBlocking {
val job: Deferred<String> = async {
delay(1000)
return@async "Hello, World!"
}
job.await()
job.cancel()
}
}
// await() 전에 취소를 하게 되면 에러가 발생.
withTimeout : 시간제한을 두고 취소하고 싶을 때 사용한다. suspend function으로 입력받은 block의 결과를 기다렸다가 리턴한다
fun main() {
runBlocking {
val job = withTimeout(1000) {
println("Start")
delay(2000)
println("End")
return@withTimeout
}
}
}
// 시간 초과되면 TimeoutCancellationException 발생.
CoroutineScope : 정의된 스코프의 모든 코루틴을 취소시킬 수 있다.
fun main() {
runBlocking {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
println("Start")
delay(1000)
println("End")
}
delay(500)
scope.cancel()
}
}
// Start
Job은 부모 자식 관계를 가질 수 있는데 코루틴 내부에서 새로운 코루틴을 만들게 되면 기존 코루틴의 Job이 새로 만들어진 코루틴의 Job의 부모로 등록이 된다. 이런 경우에는 한쪽이 취소가 되었을 때 어떤 영향을 주는지 알아보겠습니다.
부모 Job이 취소 되면 자식에게도 취소가 전파되어 자식도 취소가 되지만 반대로 자식 Job이 취소된 경우에는 부모에 영향을 주지 않습니다. 주의해야 할 점은 취소 요청을 한다고 해서 즉시 코루틴이 종료되는 것은 아니고, delay(), yield()와 같이 코루틴이 중단되는 시점이 있는 경우에 취소 여부를 확인하기 때문에 중단점이 없다면 작업이 끝난 후에 취소가 가능하다는 점입니다.
코루틴 예외 처리 방법
코루틴 내부에서 예외가 발생하면 어떻게 될까요???
- try catch : 에러가 나올 수 있는 로직을 try-catch 구문으로 감싸는 것으로 에러 처리를 하는 방식으로 주로 사용되는 방법입니다. 코루틴취소에서 주의할만한 점은 async의 경우 await()가 불리는 시점에 예외가 발생할 수 있기 때문에 await() 시점에서 try-catch 구문을 사용해야 합니다.
- CoroutineExceptionHandler
try-catch 구문 내부의 launch 블럭에서 에러가 발생한 경우에는 try-catch구문에서 예외처리를 할수가 없다. 이 경우에는 CoroutineExceptionHandler를 사용한다.
fun main() {
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(Dispatchers.IO + handler)
scope.launch {
delay(1000)
throw RuntimeException()
}
}
코루틴 취소에서는 자식의 취소 여부가 부모 Job에 영향을 주지 않았는데 에러 발생 같은 경우에는 어떨까요???
부모에서 에러가 발생하면 자식으로 전파되는 것은 취소와 동일하고, 반대로 자식에서 에러가 발생하면 취소와는 다르게 부모로 전파가 되고, 혹시 다른 자식들도 있다면 모두 취소 처리를 합니다. 결국에는 에러가 발생이 되면 계층 구조내의 모든 코루틴에 에러가 전파됩니다.
그렇다면 자식에서 에러가 발생하더라도 부모나 다른 자식으로 전파되지 않게 하는 방법에는 무엇이 있을까요??
그러한 경우에는 SupervisorJob + CoroutineExceptionHandler를 사용하면 되는데요. 예시를 들어보겠습니다.
SupervisorJob
val aScope = CoroutineScope(Dispatchers.IO + Job())
val bScope = CoroutineScope(Dispatchers.Default + Job())
bScope.launch {
aScope.launch {
delay(1000)
Log.d("TEST", "file1 완료")
}
aScope.launch {
delay(200)
Log.d("TEST", "file2 완료")
}
aScope.launch {
delay(600)
Log.d("TEST", "file3 완료")
}
aScope.launch {
delay(700)
Log.d("TEST", "file4 완료")
}
aScope.launch {
delay(500)
Log.d("TEST", "file5 완료")
}
}
// file2 완료
// file5 완료
// file3 완료
// file4 완료
// file1 완료
위 코드는 하나의 코루틴 내부에 5개의 코루틴이 있는 예제입니다. 지금은 아무 문제 없이 돌아갑니다.
5개의 자식 코루틴중에 하나의 코루틴에서 예외가 발생한다면 어떻게 될까요??
val aScope = CoroutineScope(Dispatchers.IO + Job())
val bScope = CoroutineScope(Dispatchers.Default + Job())
bScope.launch {
aScope.launch {
delay(1000)
Log.d("TEST", "file1 완료")
}
aScope.launch {
delay(200)
Log.d("TEST", "file2 완료")
}
aScope.launch {
delay(600)
Log.d("TEST", "file3 완료")
}
aScope.launch {
delay(700)
Log.d("TEST", "file4 완료")
}
aScope.launch {
delay(500)
throw Exception("error")
Log.d("TEST", "file5 완료")
}
}
// file2 완료
// FATAL EXCEPTION: DefaultDispatcher-worker-5
file2 완료라는 로그가 찍히고 예외가 발생해서 나머지 코루틴들이 작업을 마치지 못하고 취소된 것을 볼 수 있습니다.
이제 여기서 자식 코루틴의 Job을 SupervisorJob로 바꿔보겠습니다.
val handler = CoroutineExceptionHandler {
_, throwable -> Log.e("TEST", "CoroutineExceptionHandler", throwable)
}
val aScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + handler)
val bScope = CoroutineScope(Dispatchers.Default + Job())
bScope.launch {
aScope.launch {
delay(1000)
Log.d("TEST", "file1 완료")
}
aScope.launch {
delay(200)
Log.d("TEST", "file2 완료")
}
aScope.launch {
delay(600)
Log.d("TEST", "file3 완료")
}
aScope.launch {
delay(700)
Log.d("TEST", "file4 완료")
}
aScope.launch {
delay(500)
throw Exception("error")
Log.d("TEST", "file5 완료")
}
}
// file2 완료
// CoroutineExceptionHandler : error
// file3 완료
// file4 완료
// file1 완료
바꾸고 나니 위처럼 예외는 발생했지만 다른 코루틴에는 영향이 가지 않는 것을 확인할 수 있습니다. 코루틴에서의 예외발생으로 인해 다른 코루틴도 취소가 되는 상황에는 유용하게 사용할 수 있을 것 같습니다.
이렇게 부모작업과 하위 작업 간의 트리구조를 이루어 작업 간에 유지보수성을 높이고, 안정적으로 동시성 코드를 작성할 수 있도록 하는 동시성 프로그래밍 접근 방식을 구조적 동시성이라 한다.
'안드로이드' 카테고리의 다른 글
[Compose] LaunchedEffect Unit, true (0) | 2024.10.05 |
---|---|
[Compose] viewModel(), hiltViewModel() 차이는 뭘까?? (1) | 2024.10.04 |
[Compose] 중복 클릭 제어(Throttle) (0) | 2024.09.25 |
[Android] ViewModel에서의 context (0) | 2024.09.21 |
[Compose] Navigation back stack (0) | 2024.09.18 |