코루틴이 안드로이드 비동기프로그래밍에서 표준으로 쓰이고 있다.
코루틴에 대한 정리
태초에 콜백이 있었다.
콜백코드는 어떻게 구현되어있고 왜 콜백지옥이라고 표현하는가?
코틀린에서의 단순한 예시를 보자
fun requestApi(cb: Callback) {
val th = Thread {
val res = api.request() // 블로킹 호출
cb.success(res)
}
th.start()
}
해당 함수를 호출하는곳에서 다른쓰레드를 만들어서 해당 쓰레드에서 블로킹을 진행하기 때문에
이 함수를 호출하는 외부에서는 비동기로 진행되고 블로킹이 되지 않는다.
함수를 호출한 흐름과 다른 흐름에서 콜백이 실행된다.
콜백 기반 비동기 프로그래밍의 근본적인 문제는 제어 흐름의 역전(Inversion of Control)이다.
콜백 지옥의 4대 문제점
- 피라미드 구조: 코드가 오른쪽으로 무한히 확장되는 형태
- 에러 처리 곤란: 각 단계별 예외 처리가 일관되기 어려움
- 흐름 제어 불편: 조건문/반복문 사용이 매우 복잡해짐
- 메모리 누수 위험: 콜백이 객체를 계속 참조할 가능성
비동기 IO의 사용
자 여기서 파일의 읽고쓰기처럼 IO 컨텍스트 스위칭을 줄이기 위해, 디스크 IO를 생각해보자
데이터가 오고가는동안, 멈추지않고 계속해서 일을 시키는 기술이다.
그럼, 데이터가 오고가는것을 어떻게 알 수 있을까? OS레벨의 시스템콜을 통해 비동기IO가 구현되어 있다.
- 커널 공간에 작업 큐 생성
- 완료 시 이벤트 통지(epoll 등)
그럼 프로그래밍 언어단에서 해당 비동기IO의 개념을 어떻게 사용할 수 있을까?
함수의 반환값은 어떻게 전해질까?
JS에서의 이벤트루프
자바스크립트에서는 이 문제를 명쾌하게 해결했다.
이벤트 루프에 Future를 넣고, 해당함수로 결과가 오면 이벤트루프를 통해 데이터가 왔다고 알림을 넣어준다.
그럼 함수가 다 끝날때마다(콜스택이 빌때마다) 이벤트루프를 확인해서 알림이 왔다면, 해당 콜백코드를 마저 실행시킨다.
즉 JS는 콜스택이 빌때마다 정지할수 있다.
하지만 JS에서는 싱글쓰레드를 사용하기 때문에 멀티코어환경에서는 쓰레드를 활용할 수 없는 문제가 생긴다.
코틀린 코루틴에서의 작동
그렇다면 쓰레드풀을 관리하는 관리자. 코루틴 디스패처가 있고, 데이터가 완료될때마다 놀고있는 쓰레드에게 작업을 배분하면 될거같다.
한 쓰레드가 일을 하다가 오래걸리는 IO작업을 코루틴에 맡기고 그 후 코드는 디스패처를 통해서 작업을 마저하면 된다.
여기서, 정지가능한 함수인 suspend의 개념이 사용된다. 디스패처에게 일을 맡기고 현재 진행지점에서 멈추고, 해당쓰레드는 다시 쓰레드풀로 돌아가는 것이다.
코틀린 코루틴은 컴파일 타임에 상태 머신으로 변환된다.
- 이는 코루틴이 suspend와 resume을 통해 중단되었다가 이어지는 동작을 한다는 것이다.
- 코루틴이 중단(suspend)되는 순간에만 다른 코루틴이 실행될 수 있다.
- 따라서 비선점형 환경에서는 작업이 적절히 suspend되지 않으면, 다른 작업이 실행되지 못할 수 있다.
기존 쓰레드 모델과의 성능 이점
코루틴은 쓰레드풀위에서 돌아가기 때문에
직접적으로 쓰레드를 생성하고 쓰레드를 전환하는 비용에 비해 매우 적은 컨텍스트 전환 비용을 가진다.
코루틴 스케줄링 과정
- 코루틴 시작: launch 또는 async 호출
- 디스패처 선택: 작업 유형에 따라 적절한 스레드 풀 지정
- 중단 포인트: 정지가능한 지점에서 실행 일시 정지
- 재개: 작업 완료 후 원래 컨텍스트에서 실행 계속
이 과정을 통해 개발자는 더 이상 "어떻게 비동기를 구현할까"가 아니라 "무엇을 비동기로 실행할까"에 집중할 수 있게 된다.
경량 쓰레드(Lightweight Thread)로서의 코루틴
경량 쓰레드란 기존 OS 수준의 스레드와 비교해 생성 및 관리 비용이 훨씬 낮은 실행 단위를 의미한다.
코루틴은 사용자 공간(user-space)에서 구현되는 경량 쓰레드로, 다음과 같은 특징을 가진다.
1. 메모리 효율성 비교
실행 단위스택 크기생성 비용
일반 스레드 | 1MB (기본값) | 높음 (커널 개입) |
코루틴 | 몇 KB ~ 수십 KB | 매우 낮음 |
일반 스레드는 생성 시마다 독립적인 스택 메모리를 할당하지만, 코루틴은 공유 스레드 풀 위에서 작동해 메모리 사용량이 극도로 적습니다. 64GB 서버에서 실험한 사례에서는:
- 일반 스레드: 최대 3,000개 생성 가능
- 코루틴: 동시에 수백만 개 실행 가능
컨텍스트 스위칭 비용 차이
// 10만 개의 작업 실행 비교
fun test() {
// 스레드 사용 시 (실행 불가능한 수준)
(1..100_000).forEach { i ->
Thread { /* 작업 */ }.start() // OutOfMemoryError 발생
}
// 코루틴 사용 시
runBlocking {
(1..100_000).forEach { i ->
launch { /* 작업 */ } // 정상 실행
}
}
}
- OS 스레드: 커널 모드 전환 필요 (마이크로초 단위)
- 코루틴: 사용자 공간에서 처리 (나노초 단위, 100배 이상 빠름)
3. 실제 구현 메커니즘
경량성의 비결은 Continuation Passing Style(CPS) 변환에 있다:
- 컴파일러가 suspend 함수를 상태 머신으로 분해
// 컴파일된 코드 예시 (간소화) class CoroutineImpl { int state; Object result; void resume() { switch(state) { case 0: /* 초기 실행 */; break; case 1: /* 재개 지점 1 */; break; // ... } } }
- 실행 상태를 객체 하나에 압축 저장
- 스레드 풀의 워커 스레드가 여러 코루틴을 라운드 로빈 방식으로 처리
예제 코드
viewModelScope.launch {
try {
// 동기적으로 보이는 코드, 비동기적으로 실행
val user = repository.fetchUser()
val posts = repository.fetchPosts(user.id)
// UI 업데이트 (Main 디스패처에서 자동 실행)
updateUiState(user, posts)
} catch (e: Exception) {
showError(e)
}
}
코루틴의 사용으로 인해 다음과 같은 이점이 생긴다.
- 가독성: 동기식 코드처럼 작성 가능
- 에러 처리: 전통적인 try-catch 사용 가능
- 자원 관리: viewModelScope에 의해 생명주기 자동 관리
코루틴에서의 실행 컨텍스트는 어떻게 넘겨지는가?
suspend함수를 선언할때는 해당 함수가 어느 디스패처에서 실행되는지에 대한 선언이 없다.
코루틴세계에 대한 정보가 없는데 어떻게 실행되는 것일까?
실제 디컴파일을 해보면 suspend함수는 함수를 호출할때, 컨티뉴에이션 객체를 전달한다.
해당 컨티뉴에이션 객체가 컨텍스트를 참조하기 때문에, suspend에서 부모의 컨텍스트에 접근하는 것이 가능한 것이다.
suspend fun hello() {
println("Hello, Coroutines!")
}
@Nullable
public static final Object hello(@NotNull Continuation $completion) {
String var1 = "Hello, Coroutines!";
System.out.println(var1);
return Unit.INSTANCE;
}
@kotlin.SinceKotlin public interface Continuation<in T> {
public abstract val context: kotlin.coroutines.CoroutineContext
public abstract fun resumeWith(result: kotlin.Result<T>): kotlin.Unit
}
데이터 스트림에 대한 추상화
기존의 코루틴은 단일 비동기 값(async/await)이나 간단한 동시성 작업(launch)에 최적화되어 있었다.
실시간 데이터 스트림을 처리하기에는 적합하지 않다.
이 문제를 해결하기 위해 Kotlin은 Flow API를 도입했다.
즉, UI에서 계속해서 변화하는 값이 있을때, 매번 해당값을 수동으로 변경하는 것이 아닌,
Flow API를 통해 선언적으로 사용이 가능해 지는 것이다.
Flow 기본 개념
Flow는 콜드 스트림(Cold Stream)으로, 다음과 같은 특징을 가진다.
- 컬렉션처럼 순차적으로 값을 방출하지만 비동기적으로 처리 가능
- 취소 가능하고 구조화된 동시성 지원
- 예외 처리 메커니즘 내장
fun fetchUserData(): Flow<Data> = flow {
// 데이터베이스나 네트워크에서 데이터 가져오기
for (i in 1..5) {
delay(100) // 비동기 작업 시뮬레이션
emit(Data(i)) // 값 방출
}
}
작성중 TODO