Coroutine ②
혹시 Coroutine을 써보고는 싶은데, 너무 복잡해서 망설이고 계신가요? 써보면서 감을 잡고 싶은데 어떻게 시작해야 할지도 모르겠나요? 그렇다면 제대로 찾아오셨어요! 이 글에서는 Coroutine을 사용해볼 수 있을 만큼만 간단하게 알아보려고 합니다. 🙂
이 글에서는
1. Coroutine이란? - Coroutine Builder
2. Coroutine 써보기 - Coroutine Scope, Coroutine Context
의 순서로 알아볼 예정입니다.
1. Coroutine이란?
Coroutine이란 Co + Routine으로 구성된 합성어에요. 코드를 짜다 보면 비동기적인 방법이 필요할 때가 있죠? 네트워크 통신처럼 오래 걸리는 일을 할 때요. 이럴 때 보통 Thread를 이용하는데, 이를 보다 경량화하고 효율적으로 개선한 것이 Coroutine이라고 생각하시면 돼요. Thread보다 더 작은 Coroutine을 이용해서 여러 개의 Routine들을 같이 다루겠다는 거죠. 아래의 글에서 조금 더 자세한 이야기를 확인할 수 있어요.
코틀린 코루틴(coroutine) 개념 익히기 · 쾌락코딩
코틀린 코루틴(coroutine) 개념 익히기 25 Aug 2019 | coroutine study 앞서 코루틴을 이해하기 위한 두 번의 발악이 있었지만, 이번에는 더 원론적인 코루틴에 대해서 알아보려 한다. 코루틴의 개념이 정확
wooooooak.github.io
글을 읽어보셨나요? Coroutine의 놀라운 점은 비동기 처리를 동기적인 방법처럼 순차적으로 진행해도 된다는 거에요. 예를 들어서 네트워크 통신을 통해 회원 정보를 가져오는 메서드(1️⃣)와 회원 정보를 이용해 사용자 기기에 저장된 정보를 가져오는 메서드(2️⃣)가 있다고 해봐요. 두 메서드는 시간이 오래 걸리는 작업이기 때문에 비동기 처리를 하겠죠? 아마 Retrofit을 이용해서 콜백 메서드를 호출하거나, Thread를 구현해서 비동기적인 처리를 진행할 거에요.
하지만 Coroutine을 이용하면, 그냥 메서드 1️⃣과 메서드 2️⃣를 차례로 호출하면 돼요. '그러면 시간이 오래 걸리는 작업이 진행되는 동안 다른 작업을 못하게 되는게 아닌가?'라고 생각할 수도 있지만, Coroutine에는 일시중단이라는 신기한 개념이 있어요. Thread를 이용해서 비동기 처리를 진행하면 서로 다른 Thread가 각자 자신의 일을 하잖아요? 하지만 Coroutine은 몸이 두 개인 것처럼 두 작업을 왔다 갔다 하면서 거의 동시에 진행해요. 좀 낯설죠? 코드를 실행하다 말고 뛰쳐 나가서 다른 코드를 실행하고 돌아온다니... 좀 더 구체적으로 알아볼 필요가 있겠어요.
Coroutine은 일시중단 함수( = Suspend Function)를 이용해요. Suspend Function이란 일시중단 가능한 함수를 말하고, 다른 코루틴 내부 또는 Suspend Function 내부에서만 사용할 수 있어요. 그럼 Suspend Function을 실행하는 Coroutine은 어떻게 만들까요? Coroutine Builder 메서드들을 통해서 만들 수 있는데요, launch(), async(), runBlocking(), coroutineScope() 등 다양한 메서드가 있어요. 간단하게 알아볼까요?
1. runBlocking()
runBlocking()은 실행할 코드를 람다식 형태의 매개변수로 전달받아요. 전달 받은 코드를 실행하는데, 작업이 완료될 때까지 runBlocking()을 호출한 Thread를 중지시켜요. 우리는 작업 여러 개를 동시에 진행하려고 Coroutine을 사용하는 건데 이러면 Coroutine을 쓸 이유가 없죠? 그래서 이 Coroutine Builder는 연습할 때만 쓰고, 실제로는 쓰면 안돼요.
예를 들어서 아래와 같은 코드가 있으면 출력 순서가 어떻게 될까요? 참고로 delay()는 몇 ms 동안 Thread를 정지시키는 메서드에요.
fun main() {
println("1")
runBlocking {
delay(1000)
println("2")
}
println("3")
}
정답은 1 - 2- 3 이에요. "1"을 출력하고 runBlocking에 진입해서 1초( = 1000ms) 동안 멈춰있다가 "2"를 출력하고, runBlocking을 탈출해서 "3"을 출력해요. 아직까지는 별로 어렵지 않죠?
2. launch()
launch()는 runBlocking()과 마찬가지로 실행할 코드를 람다식 형태의 매개변수로 전달받아요. 중요한 것은 전달 받은 작업이 완료될 때까지 launch()를 호출한 Thread를 중지시키지 않아요. 그래서 launch 블럭 내부에서 일시중단 함수를 만나면 얼마든지 블럭 바깥으로 나갔다 올 수 있어요. 게다가 launch() 내부의 작업을 완료하기 전에 Main Thread가 종료되면 launch() 블럭도 자동으로 종료돼요. 예시를 통해서 더 알아봐요.
fun main() {
println("1")
GlobalScope.launch {
delay(1000)
println("2")
}
println("3")
}
갑자기 GlobalScope가 등장했는데, 아까 일시중단 함수는 Coroutine 내부 또는 다른 일시중단 함수 내부에서만 쓸 수 있다고 했죠? launch()도 일시중단 함수이기 때문에 이를 사용하기 위해 GlobalScope라는 전역 Coroutine을 사용한 것 뿐이에요. 중요한 내용은 아니니 대충 넘기고, 출력 순서를 예상해볼까요?
답은 1 - 3 입니다. 왜냐면 launch 블럭 내부에 진입해서 1초를 기다리는 동안 Main Thread가 종료되기 때문이에요. 더 구체적으로 살펴보면 아래와 같은 과정을 거쳐요.
1️⃣ "1"을 출력해요.
2️⃣ launch 블럭에 진입해서 1초를 대기해요.
여기서 delay()는 일시중단 함수이기 때문에 블럭을 탈출하고, launch 블럭 바깥의 작업을 마저 수행해요.
3️⃣ "3"을 출력하고, 모든 코드가 실행되었기 때문에 프로그램이 종료돼요.
원래는 delay(1000)을 호출한지 1초 후에 "2"가 출력되었어야 했는데, 프로그램이 종료되는 바람에 출력되지 못했네요.
일시중단이라는 개념이 처음 등장했는데, 이해가 좀 가시나요? 아직은 블럭 바깥을 마음대로 오간다는 게 이해하기 어려울 거에요. 그렇지만 일시중단 함수를 만나면 블럭을 탈출하여 다른 작업을 진행할 수 있다는 사실만 알고 있으면 그렇게 어려울 것도 없을 거에요.
만약에 GlobalScope를 안 쓰고 runBlocking()을 쓰면 출력 순서가 어떻게 될까요?
fun main() = runBlocking {
println("1")
launch {
delay(1000)
println("2")
}
println("3")
}
이때는 1 - 3 - 2가 돼요. 달라진 거라곤 바깥 쪽에 runBlocking()이라는 Coroutine Builder을 썼을 뿐인데, 왜 "2"가 출력됐을까요? 그건 runlocking()이 내부의 Coroutine들이 작업을 끝낼 때까지 기다려주기 때문이에요. 모든 Coroutine Builder가 그런 건 아니니까 잘 기억해주세요 😉 그럼, 다음 Coroutine Builder도 볼까요?
3. async()
async()는 사실 launch()와 크게 다르지 않습니다. 차이점이 있다면 launch()는 Job 객체를 반환하는데, async()는 Deferred 객체를 반환한다는 거에요. 둘의 사용 목적이 다르지만, 중요한 건 Deferred 객체는 작업 결과로 생성한 값을 전달한다는 거에요. 쉽게 말하면 작업을 수행한 이후에 전달할 값이 없으면 launch()를, 값이 있으면 async()를 사용한다고 생각하면 돼요.
4. coroutineScope()
coroutineScope()는 runBlocking()과 마찬가지로, 블럭 내부의 작업을 완료하기 전까지 coroutineScope()를 호출한 Thread를 중지시킵니다. 하지만 runBlocking()은 코루틴 외부에서도 호출할 수 있는 반면, coroutineScope()는 코루틴 내부에서만 호출할 수 있어요. 그래서 coroutineScope()은 Coroutine 내부에서 순차적으로 작업을 진행하고 싶을 때 사용해요. 말이 조금 어려운가요? 아래의 예시를 보시면 언제 coroutineScope()를 써야하는지 이해가 되실 거에요.
fun main() = runBlocking {
println("1")
coroutineScope {
delay(1000)
println("2")
}
launch {
delay(100)
println("3")
}
println("4")
}
이 경우에는 출력 순서가 어떻게 될까요?
정답은 1 - 2 - 4 - 3 이에요. 혹시 1 - 4 - 3 - 2 라고 예상하셨나요? 위에서 말했던 것처럼, coroutineScope()은 자신을 호출한 Thread를 중지시켜요. 그래서 "1"을 출력한 이후에 coroutineScope 블럭으로 진입하고 나서 "2"를 출력하기 전까지는 해당 블럭을 탈출하지 않습니다. 이처럼, Coroutine 내부에서 특정 작업이 먼저 실행되도록 하고 싶을 때 coroutineScope()을 이용하면 돼요.
내용이 많아서 헷갈리죠? 간단하게 정리하면 아래와 같아요.
요약 1
여기까지 Coroutine이 무엇인지, 언제 사용하는 지를 간략하게 알아봤어요. 요약하자면 1️⃣ Coroutine이란 Thread를 경량화해서 비동기 작업을 효율적으로 처리할 수 있게 해주는 도구에요. 그리고 2️⃣ 한 작업을 하다가 다른 작업을 할 수 있게 해주는 Suspend 함수와 Coroutine을 만들어 주는 Coroutine Builder에 대해서 알아봤습니다. Coroutine Builder에는 launch(), async(), coroutineScope() 등이 있었습니다.
2. Coroutine 써보기
이제 Coroutine이 무엇이고, 언제 사용할 수 있는지는 알았어요. 다음으로는 Coroutine을 어떻게 사용할 수 있는지에 대해 이야기해봐요.
1. CoroutineContext란?
Coroutine은 시간이 오래 걸리는 작업을 편리하게 처리할 수 있도록 많은 기능을 지원해요.
그 중에서 Dispatcher이 대표적인데요, Coroutine은 아래와 같이 간단한 방식으로 작업이 진행될 Thread를 선택할 수 있어요.
좀 더 자세히 이야기하자면, Dispatcher에는 Main, IO, Default가 있어요. IO Dispatcher는 Network 작업을 위한 Dispatcher에요. Default는 따로 Dispatcher을 설정하지 않으면 사용되는데, 최대 CPU와 같은 갯수의 스레드를 이용해요. 그래서 CPU 집약적인 작업에 유리해요. 반면에 IO Dispatcher은 내부적으로 최대 64개의 공유 스레드풀을 사용합니다.
이렇게 Coroutine은 다양한 도구를 통해 비동기 처리를 지원하는데, 이 도구들을 모아 놓은 것이 CoroutineContext에요. 대표적인 도구로는 위에서 언급했던 Dispatcher과 Exception Handler, Coroutine의 이름인 Name, Coroutine builder가 반환하는 Job 등이 있어요.
2. CoroutineScope과 Structured Concurrency
그리고 CoroutineScope는 CoroutineContext에 접근하기 위한 인터페이스에요. 이 인터페이스는 CoroutineScope라는 단 하나의 속성만을 가지고 있어요.
그럼 CoroutineContext가 제공하는 도구를 어떻게 사용할 수 있는지 조금 더 구체적으로 알아봐요. Coroutine에는 Structured Concurrency라는 개념이 있어요. 간단히 말하면, 이전 글에서 Coroutine Builder를 통해서 Coroutine을 생성할 수 있다고 했었죠? 이렇게 생성된 Coroutine 사이에 부모, 자식이라는 관계를 둔다는 거에요. 생성한 쪽이 부모, 생성된 쪽이 자식이 되는 거죠.
이렇게 생성된 구조에는 세 가지 규칙이 적용돼요.
1️⃣ 모든 자식이 일을 끝내기 전까지는 부모는 끝나지 않는다.
2️⃣ 예외는 부모에게 전파된다.
3️⃣ 취소는 자식에게 전파된다.
2️⃣의 예외는 Coroutine 내부에서 처리되지 않은 예외를 말해요. Coroutine에서는 처리되지 않은 예외를 부모 Coroutine에게 넘기기 때문에 일관된 방식으로 예외를 처리할 수 있어요.
만약에 Root Coroutine에서도 예외가 처리되지 않으면 어떻게 될까요? 그러면 해당 작업이 진행되고 있던 Thread가 다운되면서 Coroutine은 자동으로 취소돼요. 그런데 부모 Coroutine은 취소됐는데, 자식 Coroutine은 작업이 완료되면 원하지 않는 결과가 발생할 수도 있겠죠? 세 번째 규칙이 이런 경우를 위한 거에요. 만약에 부모가 예외를 처리하지 못해 작업이 취소되면 해당 부모의 다른 자식 Coroutine도 작업을 취소하기 때문에 작업 상태를 편리하게 관리할 수 있어요.
실제로 CoroutineContext를 사용하려면 Coroutine Builder에 매개 변수로 전달하면 돼요. 예를 들어서 Main Dispatcher을 사용하는 경우 아래와 같이 사용할 수 있어요.
CoroutineScope(Dispatchers.Main) {
// code block that is processed on main thread
}
여러 개의 도구를 사용하려면 + 연산자를 사용하면 돼요. CoroutineContext는 연산자 오버로딩을 지원해요.
CoroutineScope(Dispatchers.Default + CoroutineName("default coroutine")) {
// code block that is processed on default thread
}
요약 2
이렇게 Coroutine을 사용하는 방법을 간단히 알아봤어요. 정리하면 1️⃣ CoroutineContext는 동시성 프로그래밍을 지원하는 도구들의 모음이었죠? 이를 통해서 Dispatcher, Exception Handler 등을 지정할 수 있었어요. 그리고 2️⃣ CoroutineScope는 CoroutineContext에 대한 인터페이스로, Structured Concurrency를 통해 Coroutine의 생명 주기를 쉽게 관리할 수 있도록 도와줬어요.
Coroutine을 처음 접해보시는 분들이 기본적인 개념을 이해하고, 간단히 사용해볼 수 있으면 좋겠다는 생각에서 써본 글이었는데, 실제로는 어떠셨는지 모르겠네요 😅 그럼 제 글을 읽어주셔서 감사하고, 질문이나 틀린 내용이 있다면 언제든지 댓글에 남겨주세요 ☺️ 감사합니다!
참고 자료
[Coroutine] 코루틴 학습 - 9 (Coroutine scope function)
Kotlin Coroutines and Flow for Android Development [2024]