Kotlin 코루틴: launch vs async, suspend 함수, 코루틴 스코프와 구조화된 동시성, Flow
이 글의 핵심
코루틴 설정부터 launch·async 선택 기준, suspend 함수, 구조화된 동시성과 취소 전파, 병렬 실행과 Flow까지 안드로이드에서 자주 겪는 상황을 중심으로 정리합니다.
들어가며
코루틴 얘기할 때 스레드 수십 개 띄우는 그림만 먼저 떠올리면 이미 절반은 틀렸다고 본다. 핵심은 “멈췄다가 이어갈 수 있는 일”을 문법으로 쓰게 해 준 suspend랑, 그것을 어디 스레드에 올릴지 고르는 디스패처거든.
Go 고루틴·채널은 “많은 경량 일꾼 + 통신” 쪽 느낌이 강하며, Kotlin은 suspend에서 실행을 양보하는 방법이 async/await 계열에 가깝다. Rust async/Tokio·Node·JS Promise랑 자주 견줘지는 이유가 그것입니다.
솔직한 취향: 나는 Dispatchers.Unconfined는 거의 쓰지 말자는 쪽입니다. “디버그할 때 누가 어디서 돌아가는지”가 먼저 흐려져서, 이득이 체감될 때까지 기다리느니 처음부터 Main/IO/Default로 쪼개는 게 낫다.
- 경량이라 수천 개 겹쳐도 괜찮은 경우가 많고
- async/await 스타일이라 읽는 사람 뇌엔 덜 상처 준다(콜백 지옥 대비)
- 구조화된 동시성 덕에 취소를 “끄덕”이 아니라 “트리”로 잡을 수 있음
- 취소·타임아웃을 언어 차원에서 이야기할 수 있음
안드로이드에서 겪는 게 제일 잘 감 온다
옛날에 RecyclerView 쪽에서 GlobalScope.launch로 API 쏘고, 스크롤하다가 화면 뺐더니 “어? 콜백은 와 있는데 뷰는 죽었네” 같은 거 한 번쯤 겪어봤다면, 그게 생명주기랑 엮인 코루틴이 제일 잘 터지는 환경이다. 메인스레드는 한 개인데, 일은 비동기로 잔뜩 풀어 놨으니, 누가 취소해 줄지가 곧 UX랑 직결이야.
내가 흔히 봤던 패턴은 이런 느낌입니다. 리스트 셀에 썸네일 로딩 붙이면서 viewModelScope는 안 쓰며, 컴포저블/프래그먼트에 직접 launch 박은 다음, 로딩 끝날 때 findViewById가 null이라 터지는. 혹은 Dispatchers.Main에서 JSON 통째로 파싱하다 ANR 한 방 먹는. 둘 다 “코루틴 썼으니 멀쩡한 줄” 알았는데, 스레드랑 생명주기만 틀어져도 끝나는 케이스다.
viewModelScope + Dispatchers.IO + 구조화된 취소(화면 나가면 Job 정리) 이 삼박자가 Android에서 코루틴을 “쓰기”에서 “쓸 만하게” 바꾼다고 생각한다. 이 길 말고 Kotlin Android 쪽이 더 촘촘하니, 여기서는 “코루틴 뼈대”만 잡고 넘기자.
launch vs async, 표 말고 결론부터
launch는 결과를 안 남기는 불—Job이 나오고, 보통 부수 효과(로그, UI 갱신, 이벤트) 쪽에 쓴다. async는 Deferred<T>를 줘서 나중에 값 꺼내 먹는 병렬 작업에 쓴다. 예외는 launch 쪽이 CoroutineExceptionHandler/부모 전파 쪽에 더 잘 터지고, async는 await() 하는 순간에 “아 맞다 여기 터지는구나” 느낌이 강하다.
의견: “그냥 async만 잔뜩” 붙이고 await를 빼 둔 코드, 나중에 조용히 썩는다. 완료·실패를 await나 joinAll 같은 걸로 반드시 건드리게 만드는 쪽이 맞다.
- 결과가 없고 기다리기만 하면
launch+ 필요하면join(). - 여러 군데 동시에 굴리다 합처리해야 하면
async+await()/awaitAll().
fun main() = runBlocking {
launch { println("fire-and-forget") }
val a = async { delay(10); 1 }
val b = async { delay(10); 2 }
println(a.await() + b.await()) // 병렬 후 합산
}
구조화된 동시성, 주방 얘기 말고 그냥
동시에 여러 API 때리는 건 “주방”보다 부모 Job이 죽으면 자식도 같이 죽는다는 쪽이 실무에 더 와닿는다. coroutineScope { }, supervisorScope { }, ViewModel의 viewModelScope가 그 규칙을 쓰는 케이스다.
coroutineScope: 자식 하나라도 실패하면 나머지 취소 + 실패로 전파. “한 묶음이면 같이 죽자”supervisorScope: 자식 실패가 형제한테 퍼지지 않음. “피드는 가져올 수 있는데 프로필만 500” 같은 UI에서 유리
suspend fun loadDashboard() = supervisorScope {
val profile = async { api.profile() } // 실패해도
val feed = async { api.feed() } // feed는 시도 가능
runCatching { profile.await() }.getOrNull() to feed.await()
}
내 입장: Android에서 supervisorScope는 “한 화면에 요청이 여러 갈래로 갈릴 때” 상당히 자주 쓴다. 반대로 “결제 한 번 = 트랜잭션 한 방”이면 coroutineScope가 맞다.
kotlinx-coroutines 의존성 추가
build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
runBlocking, launch, async 빌더
runBlocking
import kotlinx.coroutines.*
fun main() = runBlocking {
println("시작")
delay(1000)
println("끝")
}
launch
fun main() = runBlocking {
launch {
delay(1000)
println("World!")
}
println("Hello,")
delay(2000) // 코루틴 완료 대기
}
// 출력:
// Hello,
// World!
async
fun main() = runBlocking {
val deferred = async {
delay(1000)
"결과"
}
println("작업 중...")
val result = deferred.await()
println(result)
}
suspend 함수
suspend fun fetchUser(): String {
delay(1000) // 네트워크 요청 시뮬레이션
return "홍길동"
}
suspend fun fetchPosts(): List<String> {
delay(1500)
return listOf("Post 1", "Post 2")
}
fun main() = runBlocking {
val user = fetchUser()
val posts = fetchPosts()
println("사용자: $user")
println("게시글: $posts")
}
CoroutineScope와 Dispatchers
CoroutineScope
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
println("백그라운드 작업")
}
// 종료
scope.cancel()
Dispatchers
// Main: UI 스레드 (Android)
launch(Dispatchers.Main) {
updateUI()
}
// IO: 네트워크, 파일
launch(Dispatchers.IO) {
fetchData()
}
// Default: CPU 집약적
launch(Dispatchers.Default) {
heavyComputation()
}
// Unconfined: 제한 없음
launch(Dispatchers.Unconfined) {
// 작업
}
async + await로 병렬 실행
async + await
fun main() = runBlocking {
val time = measureTimeMillis {
val user = async { fetchUser() } // 1초
val posts = async { fetchPosts() } // 1.5초
println("사용자: ${user.await()}")
println("게시글: ${posts.await()}")
}
println("총 시간: ${time}ms") // 약 1500ms (병렬)
}
Flow, StateFlow, SharedFlow
기본 Flow
fun numbers(): Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
fun main() = runBlocking {
numbers().collect { value ->
println(value)
}
}
Flow 연산자
fun main() = runBlocking {
(1..10).asFlow()
.filter { it % 2 == 0 }
.map { it * it }
.collect { println(it) }
// 4, 16, 36, 64, 100
}
StateFlow와 SharedFlow
class ViewModel {
private val _state = MutableStateFlow(0)
val state: StateFlow<Int> = _state
fun increment() {
_state.value++
}
}
fun main() = runBlocking {
val vm = ViewModel()
launch {
vm.state.collect { value ->
println("State: $value")
}
}
delay(100)
vm.increment()
vm.increment()
delay(100)
}
API 호출과 타임아웃 예제
예제 1: API 호출
data class User(val id: Int, val name: String)
suspend fun fetchUserFromApi(id: Int): User {
delay(1000) // 네트워크 지연
return User(id, "사용자$id")
}
fun main() = runBlocking {
val users = (1..5).map { id ->
async { fetchUserFromApi(id) }
}.awaitAll()
users.forEach { println(it) }
}
예제 2: 타임아웃
fun main() = runBlocking {
try {
withTimeout(1500) {
repeat(3) {
println("작업 $it")
delay(1000)
}
}
} catch (e: TimeoutCancellationException) {
println("타임아웃!")
}
}
Main, IO, Default, Unconfined 디스패처
- Main: Android에선 UI 스레드. 여기는 짧게. 네트워크/파일/CPU 작업 넣지 마라가 정석이다(넣는 순간 “코루틴 쓰는데 왜 끊기지” 시작).
- IO: 블로킹 I/O 냄새 나는 풀. 파일, 소켓, DB. 스레드가 좀 더 많다.
- Default: CPU 잡는 일. 정렬, 이미지, 무식한 루프. 코어 수에 맞게 제한.
- Unconfined: “어디서 돌지”를 고정하지 않는다. 첫
suspend전까지는 호출 스레드에 붙을 수도 있고… 나는 테스트·아주 좁은 특수 케이스 아니면 그냥 피한다고 봄. 예측이 어렵으며, UI 코드랑 엮이면 잡기 빡세다.
// UI → 백그라운드 → 다시 UI
withContext(Dispatchers.IO) { readFile() }
withContext(Dispatchers.Default) { heavySort(data) }
withContext(Dispatchers.Main) { updateUi() }
일상 비유로 이해하기: 동시에 여러 타이머를 돌리는 느낌(스택 하나로 여러 요리)이 동시성에 가깝으며, 진짜로 요리사 여럿이 병렬로 굽는 게 병렬성에 가깝다. 코루틴은 둘 다 잡을 수 있지만, Android에서 자주 쓰는 그림은 “메인 1 + IO/디폴트 풀 N”.
Flow vs Channel, 표 말고 감으로
Flow는 콜드/핫 스트림 쪽, 특히 flow { }는 “구독할 때” 흐름이 잡히는 쪽이 강하다. UI 상태, 페이지 네이션, 이벤트 스트림 — ViewModel + StateFlow/SharedFlow 루트면 거의 Flow 쪽. 다수 소비자는 SharedFlow / StateFlow로 뿌리면 된다.
Channel은 송신–수신 파이프. 액터 스타일, 워커 큐, “작업 한 건 넣고 한 건 꺼내는” 느낌. 백프레셔가 꼭 필요한 설계에서 자주 이야기된다. 소비자 여럿 붙이는 건 Channel이 더 손이 간다(그래서 Flow 쪽이 UI에 잘 박힘).
실무 팁: 화면 상태·API 결과 스트림이면 Flow가 자연스럽다. “코루틴끼리 일 한 건 던지기”면 Channel. 둘 다 필요하면 channelFlow { }로 합치기도 한다. 취향 싸움 아니고, “누가 누구를 구독하느냐”에 가깝다.
try-catch와 CoroutineExceptionHandler
suspend안 동기 예외 → 그냥try/catch.launch루트에서 터지는 미처리 예외 →CoroutineExceptionHandler나 부모로 전파.async→ 예외는 대개await()에서 터진다(그 전엔CancellationException쪽이 될 수도).
val handler = CoroutineExceptionHandler { _, e ->
println("Unhandled: ${e.message}")
}
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
error("from launch")
}
delay(100)
scope.cancel()
}
async 내부를 기록만 하고 흐르게 하려면 runCatching { }랑 supervisorScope 조합이 익숙하다.
협력적 취소와 withTimeout
- 협력적 취소:
delay,yield,isActive이런 애들이 있어야 빨리 멈춘다. Java 블로킹만 있으면 취소가 늦게 먹힌다(그래서 I/O는 중단 가능한 API +Dispatchers.IO조합이 이득인 경우가 많다). withTimeout→TimeoutCancellationException(상위는CancellationException계열).withTimeoutOrNull→ 초과 시null로 실패를 단순화.
suspend fun work() = coroutineScope {
launch {
while (isActive) {
delay(100)
// 취소 시 루프 종료
}
}
}
suspend fun fetchOrNull() = withTimeoutOrNull(3000) {
api.slowCall()
}
부분 실패 수집과 흔한 실수
runCatching으로 성공과 실패를 나눠 모으기
여러 비동기 작업을 동시에 시도하되, 하나가 실패해도 나머지 결과를 모으고 싶을 때 supervisorScope와 조합할 수 있다. 아래는 표준 라이브러리만으로 성공 문자열과 예외를 분리하는 예입니다.
import kotlinx.coroutines.*
suspend fun mayFail(id: Int): String {
delay(50)
if (id % 4 == 0) error("fail on $id")
return "ok-$id"
}
suspend fun gather(ids: List<Int>): Pair<List<String>, List<Throwable>> = coroutineScope {
val results = ids.map { id ->
async {
runCatching { mayFail(id) }
}
}.awaitAll()
val oks = results.mapNotNull { it.getOrNull() }
val errs = results.mapNotNull { it.exceptionOrNull() }
oks to errs
}
fun main() = runBlocking {
val (oks, errs) = gather((1..12).toList())
println("성공: $oks")
println("실패 수: ${errs.size}")
}
GlobalScope, Main에서 긴 연산, await 누락
- GlobalScope.launch — 생명주기랑 끊으면 “언제 죽는지” 모르는 유령 Job이 남는다. Android에선 거의 금지에 가깝다고 본다.
Main에서 장시간 연산 — 코루틴이 아니라 스레드 문제다.Default로 보내라.async를await없이 — 조용한 실패/자원 낭비.
viewModelScope와 디스패처 고르기
- 뷰모델·Android 쪽에선
SupervisorJob+viewModelScope패턴으로 취소를 맞추는 경우가 많다(세부는 Android 편으로). - I/O는
IO, CPU는Default. Unconfined는 “정말 이유가 있을 때만”. - 부모 취소 = 자식 취소, 이것은 안 지키면 “화면 껐는데 API만 도는” 그림이 나온다.
RxJava랑 뭐가 다르냐 (한 줄 취향)
- 코루틴은 Kotlin/구조화된 동시성 쪽 1지망. 새 프로젝트면 이쪽으로 수렴시키는 게 유지보수에 이득인 경우가 많다.
- RxJava는 레거시 Android에서 아직 튼튼하다. “갈아엎는 비용”이 크면 점진적으로
suspend래핑 쪽이 현실적입니다. - Java 21 가상 스레드는 서버/JVM 이야기에 가깝다. ANR이 있는 Android 메인이랑은 문제 정의가 다르다.
한 걸음 더: 코루틴이 안에서 하는 일
여기까지가 쓰는 법이고, 아래는 “왜 여기서 스레드가 바뀌었지?”, “부모만 취소했는데 왜 자식도 멈췄지?” 같은 질문에 답하려면 알아야 하는 부분이다.
suspend는 상태 기계로 컴파일된다
suspend 함수는 컴파일러가 suspend 호출 지점마다 끊어서 상태 기계로 바꾼다. 재개 후에도 필요한 지역 변수는 코루틴 객체의 필드로 옮겨지고, 재개할 때는 “몇 번째 지점부터”만 보고 이어서 실행한다(연속 전달 스타일, CPS). 스레드를 붙잡고 기다리는 게 아니라 “다음에 할 일”만 남기고 빠지니까 스레드 하나로 수천 개 코루틴을 돌릴 수 있는 것이다. 반대로 말하면 suspend 지점이 없는 긴 연산은 이 혜택을 전혀 못 받는다.
withContext는 공짜가 아니다
withContext(Dispatchers.IO)는 현재 코루틴을 다른 스레드 풀 큐에 다시 넣는 일이라, 한 요청 안에서 IO와 Default를 수십 번 오가면 그만큼 큐잉과 스레드 전환이 쌓인다. 리포지토리 계층에서 IO로 한 번, CPU 집약 계산은 Default로 한 번, 이렇게 계층 단위로 경계를 두는 편이 낫다. DB 커넥션 풀이 10개인데 IO 디스패처에서 64개가 동시에 쿼리를 날리면 대부분 커넥션을 기다리며 블로킹된다. 이럴 땐 Dispatchers.IO.limitedParallelism(10)으로 전용 디스패처를 만들거나 Semaphore로 동시 호출 수를 풀 크기에 맞춘다.
coroutineScope vs supervisorScope
// 하나라도 실패하면 전체 실패 — "전부 성공해야 의미 있는" 병렬 조회
suspend fun loadAll(ids: List<Int>) = coroutineScope {
ids.map { async { repo.load(it) } }.awaitAll()
}
// 서로 독립 — 하나 실패해도 나머지는 계속
suspend fun loadReports(urls: List<String>) = supervisorScope {
urls.map { async { runCatching { download(it) } } }.awaitAll()
}
supervisorScope로 감싸도 실패한 자식의 예외가 사라지는 건 아니다. async면 await()에서, launch면 핸들러로 가니까 위처럼 runCatching으로 받거나 로깅을 해 둬야 한다.
콜백 API를 Flow로: callbackFlow
리스너 등록·해제가 있는 API는 callbackFlow로 감싸고, 반드시 awaitClose에서 해제한다. 이걸 빠뜨리면 수집이 끝나도 리스너가 남아 누수가 된다.
fun locationUpdates(): Flow<Location> = callbackFlow {
val listener = LocationListener { trySend(it) }
client.register(listener)
awaitClose { client.unregister(listener) }
}
검색창처럼 입력이 연달아 들어올 땐 debounce로 잠깐 기다렸다가 마지막 값만 쓰고, flatMapLatest로 새 입력이 오면 이전 요청 수집을 취소하면 늦게 도착한 옛 결과가 화면을 덮어쓰는 레이스가 사라진다. 진행률처럼 최신 값만 의미 있으면 conflate로 중간 값을 버린다.
테스트는 가상 시간으로
kotlinx-coroutines-test의 runTest는 delay를 실제로 기다리지 않고 가상 시간을 앞당긴다. 3초 타임아웃 테스트가 즉시 끝나는 이유다. 대신 프로덕션 코드가 Dispatchers.IO를 하드코딩하면 테스트 디스패처로 바꿀 수가 없으니, 디스패처를 생성자로 주입받게 만들어 두는 게 좋다.
로그 컨텍스트가 끊길 때
MDC(요청 ID 같은 로그 컨텍스트)는 스레드 로컬이라, 코루틴이 다른 스레드에서 재개되면 사라진다. kotlinx-coroutines-slf4j의 MDCContext()를 컨텍스트에 넣으면 재개될 때마다 복원된다. 서버에서 “로그에 요청 ID가 가끔 비어 있다”면 거의 이 문제다.
추가 리소스
코루틴 요약
- launch / async: 부수 효과 vs
Deferred로 병렬 결과;await누락 = 함정 - Dispatchers: Main(UI) 짧게, IO(I/O), Default(CPU), Unconfined는 난 쓰기 싫다는 입장
- 구조화된 동시성: 부모 취소 = 자식 취소;
coroutineScopevssupervisorScope - Flow vs Channel: 스트림/상태는 Flow, 파이프/큐느낌은 Channel. UI는 Flow가 편한 경우가 많다
- 에러 처리:
try/catch,CoroutineExceptionHandler,async는await시점 - 취소·타임아웃: 협력적 취소,
withTimeout/withTimeoutOrNull - suspend / Flow·StateFlow — 비동기의 문법
- Android에선
viewModelScope+Main/IO분리가 체감 품질을 좌우한다
다음 단계
같이 보면 좋은 글
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++20 코루틴 기초
- C++ future와 promise
- C++ Asio Composed Operation | 비동기 함수 설계 [#7]
- Kotlin 컬렉션
- Kotlin Android 개발 | Activity, ViewModel, Jetpack
- [Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명
- Rust 비동기 프로그래밍 | async/await, Tokio
- Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await
- JavaScript 비동기 프로그래밍
자주 묻는 질문 (FAQ)
Q. GlobalScope.launch를 쓰면 왜 안 되나요?
A. GlobalScope에서 시작한 코루틴은 화면이나 요청 같은 생명주기와 연결되지 않아서, 화면을 벗어나도 취소되지 않고 계속 실행됩니다. 이미 사라진 화면을 갱신하려다 크래시가 나거나, 아무도 결과를 쓰지 않는 네트워크 요청이 쌓이는 원인이 됩니다. Android에서는 viewModelScope나 lifecycleScope처럼 생명주기에 묶인 스코프를 쓰고, 일반 코드에서는 호출하는 쪽의 스코프 안에서 coroutineScope로 자식 코루틴을 만드는 것이 기본입니다.
Q. launch와 async는 어떻게 다른가요?
A. launch는 결과를 돌려주지 않고 Job을 반환하므로 결과가 필요 없는 작업에 씁니다. async는 Deferred를 반환하고, await()를 호출해 결과를 받습니다.