
안드로이드 앱 개발에서 비동기 처리는 불가피합니다.
네트워크 통신, DB 접근, 이미지 렌더링 등 시간이 오래 걸리는 작업을 메인 스레드에서 실행하는 순간 사용자에게 돌아오는 것은 프레임 드랍이나 ANR이기 때문입니다.
이러한 문제를 해결하기 위해 Java의 Thread를 직접 생성하거나,
AsyncTask, RxJava와 같은 방식을 사용해 왔습니다.
AsyncTask는 Android 11에서 Deprecated됨)또 안드로이드에서 Kotlin을 공식 언어로 지정하면서 코루틴을 사용하게 되었습니다.
이번 글에서는 스레드 기반 작업이 가졌던 한계를 짚어보고,
코루틴이 어떤 방법을 통해 이런 문제를 해결할 수 있었는지에 대해 알아보고자 합니다.
스레드는 운영체제 수준에서 관리되는 프로세스 실행 흐름의 단위입니다.
안드로이드 시스템에서 프로세스가 앱에 할당된 공간이라면,
스레드는 그 공간 안에서 실제로 발로 뛰며 코드를 실행하는 일꾼과도 같습니다.

프로세스는 독립적인 힙 메모리 영역을 할당받고,
프로세스 내부의 각 스레드는 독립적인 스택 메모리를 할당받습니다.
또한 프로세스 내부의 스레드들은 프로세스가 가진 힙 영역과 정적 데이터를 공유합니다.
Java와 Kotlin의 Thread는 이러한 스레드를 사용하기 위해 만들어진 클래스입니다.
실제로 Thread를 통해 어떻게 비동기 작업을 처리했는지 코드 예시를 보겠습니다.
import kotlin.concurrent.thread
class BookingHistoryRepositoryImpl private constructor(private val database: MovieDatabase) :
BookingHistoryRepository {
override fun saveBooking(ticket: MovieTicket) {
thread {
database.bookingHistoryDao().insert(BookingHistoryMapper.mapToBookingHistory(ticket))
}
}
override fun getBookings(onLoaded: (List<MovieTicket>) -> Unit) {
thread {
val movieTickets =
database.bookingHistoryDao().getAll().map { bookingHistory ->
BookingHistoryMapper.mapFromBookingHistory(bookingHistory)
}
onLoaded(movieTickets)
}
}
}
BookingHistoryRepositoryImpl은 Room DB에 접근할 수 있는 클래스입니다.
DB를 조회하거나, 데이터를 삽입하는 등의 작업은 시간이 오래 걸리는 I/O 작업이기 때문에,
메인 스레드를 막지 않으려면 별도의 워커 스레드에서 처리해야 합니다.
Kotlin에서는 비동기 처리를 위해 위처럼 람다 내부에 코드를 작성합니다.
또한 비동기 작업의 결과를 외부에서 활용해야 할 경우 메소드의 매개변수로 콜백 함수를 받게 됩니다.
위 코드는 잘 동작하기 때문에, 문제가 없다고 생각할 수 있습니다.
그러나 스레드의 원리를 더 자세하게 살펴보면 분명한 한계가 존재합니다.
64비트 시스템 기준으로 스레드의 크기는 1MB입니다.
Java와 Kotlin의 Thread는 실제 물리적인 스레드를 사용하기에,
처리해야 하는 작업이 100개라면 스레드가 차지하는 메모리는 100MB 이상이 될 것입니다.
이는 모바일 기기의 제한된 리소스 환경에서 큰 문제로 다가옵니다.
물론 정해진 개수만큼 스레드를 미리 생성해두고 사용하는 방식도 존재하지만,
직접적인 해결책이 될 수는 없습니다.
위의 getBookings 메소드 호출부를 살펴보겠습니다.
override fun loadBookingHistory() {
bookingHistoryRepository.getBookings { movieTickets ->
view.showBookingHistory(movieTickets)
}
}
보시다시피 DB 호출 결과를 UI에 반영하기 위해 콜백 함수를 사용하고 있습니다.
근데 만약에, 여러 개의 비동기 작업의 결과를 한 번에 반영하려면 어떻게 해야 할까요?
위 코드에 서버 통신 과정이 추가되었다고 가정해보겠습니다.
override fun loadBookingHistory() {
bookingHistoryRepository.getBookings { movieTickets ->
bookingRepository.fetchBooking(movieTickets) {
view.showBookingHistory(movieTickets)
}
}
}
이렇게 코드의 깊이가 한 단계 더 깊어질 것이고, 이 깊이는 작업의 개수에 비례합니다.
작업의 개수가 점점 늘어난다면 아마 아래와 같은 형태가 될 것입니다.

이러한 형태를 콜백 지옥이라고 부릅니다.
JS에서는 promise나 async, await을 통해 이러한 문제를 해결하였지만
아쉽게도 Java 및 Kotlin의 Thread에는 해결책이 없습니다.
또 백그라운드 스레드에서 즉시 UI에 결과를 반영해야 할 때가 종종 있는데,
이 경우에는 runOnUiThread와 같은 복잡한 전달 과정을 거쳐야 합니다.
스레드는 OS에 의한 문맥 교환을 통해 동시성을 보장합니다.
여러 개의 작업이 동시에 진행되는 것처럼 보여주기 위해 작업을 계속 전환하는데,
이 때 문맥 교환이 일어납니다.

위 그림을 예시로 들어보겠습니다.
A 스레드에서 Task 1을 수행하던 중 Task 2의 결과가 필요한 경우가 있다고 가정할 때,
OS에서는 비동기적으로 B 스레드를 호출합니다.
이 때 A 스레드는 블로킹되고 B 스레드로 문맥 교환이 일어나면서 Task 2를 수행합니다.
마지막으로 Task 2가 완료되면 다시 A 스레드로 문맥 교환이 일어나고 결과값을 반환합니다.
동시에 수행할 Task 3, 4는 각각 C, D 스레드에 할당되며,
OS에서 선점 스케줄링을 통하여 동시성을 보장하게 됩니다.
하지만 문맥 교환에서 발생할 수 있는 문제점이 있습니다.
스레드가 실행되던 중 문맥 교환을 위해 중단되면, 현재 상태를 메모리에 저장합니다.
그리고 다음 순서가 오면 이 상태를 다시 CPU로 불러와야 합니다.
또 CPU 내부에는 고성능 메모리인 L1~L3 캐시가 존재하는데,
문맥 교환이 일어나면 캐시 데이터는 제거됩니다.
이러한 과정에서 상당한 성능 저하가 일어납니다.
이렇게 OS가 직접 관리하는 스레드의 한계를 극복하기 위해서,
하나의 스레드를 효율적으로 사용하는 방법을 고안하게 됩니다.
위에서 보았듯, 스레드는 생성 비용이 비싸고 문맥 교환에서 일어나는 오버헤드가 큽니다.
이에 대한 대체재로 AsyncTask나 RxJava가 등장했었습니다.
하지만 AsyncTask는 메모리 누수나 실행 순서를 보장할 수 없다는 점,
RxJava는 러닝 커브가 가파르며 단순한 작업이어도 Observable이라는 틀에 넣어야 한다는 명확한 단점들이 존재했습니다.
Kotlin 개발자는 순차적인 코드 구조를 유지하면서도 비동기로 동작하는 기능을 원했고,
그러다가 코루틴이 등장하게 됩니다.
사실 코루틴은 Kotlin만의 특별한 기능이 아닌 소프트웨어 개발의 개념입니다.
코루틴(Coroutine)은 비동기 프로그래밍과 동시성(concurrency) 처리를 위한 프로그래밍 구조로, 함수나 루틴의 실행을 중단(suspend)하고 필요 시 다시 재개(resume)할 수 있는 특성을 가진다. 코루틴은 일반적인 서브루틴(subroutine)과 달리, 실행 흐름을 협력적으로 제어할 수 있으며, 이를 통해 효율적인 자원 관리와 코드 간결화를 가능하게 한다.
또 Kotlin 이전에 Simula, Go 등의 언어에서는 이미 해당 개념을 적용하고 있었습니다.
코루틴이 어떻게 스레드를 대체할 수 있게 되었는지 알아보겠습니다.
코루틴은 경량 스레드라고도 불립니다.
그 이유는 실행 흐름을 갖고 있으면서도, 스레드가 지는 대부분의 무거운 비용을 지지 않기 때문입니다.
먼저 스레드는 OS가 직접 관리하는 실행 단위입니다.
JVM에서 스레드를 생성하면 OS 스레드가 생성되고,
최소 1MB 이상의 스택 메모리가 스레드에 할당됩니다.
반면 코루틴은 OS가 인지하지 못하는 존재입니다.
코루틴은 JVM 스레드 위에서 동작하는 중단 가능한 함수의 집합에 가깝고,
라이브러리가 스케줄링을 담당합니다.
또한 코루틴은 스레드처럼 독립적인 스택 메모리를 갖지 않고,
일시 중단 가능한 지점에서 필요한 로컬 변수와 다음에 실행할 위치 등과 같은 최소한의 상태만을 힙 메모리에 객체 형태로 저장합니다.
즉, 실행 흐름 전체를 위한 스택을 유지하는 것이 아니라 중단했다가 다시 이어서 실행하기 위한 정보만 보관하는 것입니다.
코루틴은 스레드 위에서 실행되지만,
여러 코루틴들이 스레드를 서로 양보하면서 실행되기 때문에 스레드와 메모리를 효율적으로 사용하면서 비동기 작업을 처리할 수 있습니다.
이러한 이유때문에 코루틴은 경량 스레드라고 할 수 있습니다.
코루틴의 문맥 교환은 스레드와 달리 실행하던 함수의 상태를 저장하고 재개하는 과정에 가깝습니다.
그래서 문맥 교환이라는 말을 사용하긴 하지만, 실제로 일어나는 일은 다릅니다.

코루틴은 실행 도중 일시 중단 지점에 도달하면 멈출 수 있습니다.
A 스레드에서 Task 1을 수행하다가 일시 중단 지점을 만나게 되면,
코루틴의 상태를 Continuation이라는 객체에 저장합니다.
코루틴이 중단되는 순간 스레드는 자유로워지며,
스케줄러는 다른 코루틴을 할당하거나 그 즉시 반환합니다.
따라서 A 스레드에서 Task 1을 수행하다가 Task 2를 수행해야 하는 경우 별도의 문맥 교환 없이 그대로 처리할 수 있습니다.
중단되었던 코루틴이 재개될 때도 마찬가지입니다.
스레드는 적절한 스레드를 선택해 저장했던 Continuation을 호출합니다.
그러면 코루틴은 함수 호출이 이어지듯 이전에 멈췄던 지점부터 실행됩니다.
이 과정은 함수 반환과 재개에 가까운 동작이며,
스레드의 문맥 교환에서 발생하는 커널 모드 전환, 레지스터 복원과 같은 작업이 불필요합니다.
다만 위 경우에서는 A 스레드와 C 스레드가 동시에 수행하는데,
동시성 보장을 위해 문맥 교환이 필요할 수도 있습니다.
따라서 코루틴의 장점을 살리기 위해서는 단일 스레드에서 코루틴 객체를 제어하는 것이 좋습니다.
일반적으로 비동기 코드는 콜백을 기반으로 작성됩니다.
스레드의 예시 코드에서 봤던 것처럼, 어떤 작업이 끝나면 다음 작업을 콜백으로 넘기고 그 안에서 또 다시 비동기 작업을 호출하는 식입니다.
이렇게 되면 코드의 실행 순서는 시간 흐름에 따라 분산되고,
실제 로직의 흐름을 파악하기가 어렵습니다.
결국 콜백 지옥에 빠지게 되고 예외 처리나 리소스 정리도 어려워집니다.
코루틴에서는 이 문제를 suspend라는 키워드로 해결했습니다.
suspend 함수는 겉으로 보기에는 일반 함수와 동일하지만,
내부적으로는 실행 도중 중단될 수 있습니다.
이러한 일시 중단이 콜스택 전체를 블로킹하지는 않습니다.
함수가 중단되면 코루틴의 상태만 저장되고,
스레드는 반환되어 다른 작업을 수행할 수 있습니다.
하지만 개발자가 코드를 읽을 때는 중단과 재개를 의식하지 않아도 됩니다.
덕분에 비동기 작업을 다음과 같이 순차적으로 표현할 수 있습니다.
override fun loadBookingHistory() {
viewModelScope.launch {
val movieTickets = bookingHistoryRepository.getBookings() // suspend
bookingRepository.fetchBooking(movieTickets) // suspend
view.showBookingHistory(movieTickets)
}
}
로컬 DB에서 데이터를 조회하고 데이터를 서버에 업로드하는 작업이
단순히 위에서 아래로 이어진 코드로 작성됩니다.
실제 실행 시점에는 각 단계가 비동기적으로 동작하지만,
코드의 형태는 동기적 코드처럼 자연스럽게 흐릅니다.
즉 언제 중단하고 재개할지는 개발자의 책임이 아니며,
개발자는 오직 무엇을 어떤 순서로 할지에만 집중할 수 있습니다.
결국 코루틴의 비동기 코드의 순차적 작성이 의미하는 바는
실행 방식은 비동기지만 사고 방식은 동기적으로 유지할 수 있다는 점입니다.
개발자는 비동기 처리로 인한 복잡한 제어의 흐름을 직접 관리하지 않아도 되고,
코드의 가독성와 유지보수성을 크게 향상시킬 수 있습니다.
이러한 특징 외에도 예외 처리나 취소 등 스레드에 비해 다양한 이점이 존재합니다.
코루틴은 스레드보다 가볍고, 문맥 교환 비용이 낮으며,
비동기 코드를 순차적으로 표현할 수 있다는 장점을 갖습니다.
때문에 대기 시간이 길고 동시성이 중요한 작업에서는 코루틴이 거의 최선의 선택이라고 할 수 있습니다.
특히 안드로이드와 같이 동시에 많은 작업을 처리하지만 실제 CPU 사용량은 크지 않은 대부분의 환경에서는 코루틴이 코드 가독성과 성능 양쪽에서 모두 이점을 줍니다.
하지만 코루틴은 스레드를 대체하는 개념이 아니며,
스레드를 더 효율적으로 사용하기 위한 추상화에 가깝습니다.
따라서 코루틴 이전에 그 기반이 되는 스레드에 대해 먼저 공부해보고,
그 둘에 어떤 차이점이 있는지 알아보면 쉽게 이해할 수 있을 것 같습니다.