코루틴을 쓰다 보면 "자식 코루틴이 실패하면 부모는 어떻게 되지?", "하나가 취소되면 나머지는?" 같은 의문이 생긴다. 이걸 체계적으로 관리하는 것이 구조화된 동시성(Structured Concurrency)이다.
구조화된 동시성은 코루틴의 가장 중요한 설계 원칙이다. 이 원칙이 없으면 코루틴은 그냥 "가벼운 스레드"일 뿐이고, 스레드에서 겪었던 문제(누수, 추적 불가, 예외 유실)를 그대로 겪게 된다.
구조화된 동시성이란
핵심 규칙은 간단하다. 모든 코루틴은 스코프에 속하고, 부모 코루틴은 모든 자식이 완료될 때까지 끝나지 않는다.
suspend fun fetchProfile() = coroutineScope {
val user = async { fetchUser() } // 자식 1
val posts = async { fetchPosts() } // 자식 2
// 두 자식이 모두 완료될 때까지 여기서 대기
UserProfile(user.await(), posts.await())
}
// fetchProfile이 반환될 때, 모든 자식 코루틴은 이미 완료된 상태다
비유하자면 회사의 팀 구조와 같다. 팀장(부모)은 팀원(자식)이 모든 업무를 끝내기 전에 "끝났습니다"라고 보고할 수 없다. 팀원 하나가 문제를 일으키면 팀장이 책임지고, 팀이 해체되면 팀원 전원의 작업도 중단된다.
이 규칙이 보장하는 것들은 다음과 같다.
- 누수 방지 — 부모가 끝나면 자식도 끝난다. "어딘가에서 돌고 있는 잊혀진 코루틴"이 생기지 않는다.
- 에러 전파 — 자식이 실패하면 부모가 알고, 다른 자식도 취소된다.
- 취소 전파 — 부모가 취소되면 모든 자식이 취소된다.
부모-자식 관계
launch나 async로 코루틴을 만들면, 현재 스코프의 Job이 부모 Job이 된다.
이 계층에서 규칙은 세 가지다.
- 부모는 모든 자식이 완료될 때까지 완료되지 않는다.
- 부모가 취소되면 모든 자식(과 손자)이 취소된다.
- 자식이 예외로 실패하면 부모도 취소되고, 다른 자식도 취소된다.
3번 규칙이 중요하다. fetchPosts가 실패하면 fetchUser도 취소된다. "하나가 실패하면 전부 실패"가 기본 동작이다.
coroutineScope vs supervisorScope
coroutineScope — 하나 실패하면 전부 취소
suspend fun fetchProfile() = coroutineScope {
val user = async { fetchUser() }
val posts = async { fetchPosts() } // 여기서 예외 발생!
// fetchPosts 실패 → fetchUser도 취소 → coroutineScope 전체가 실패
UserProfile(user.await(), posts.await())
}
coroutineScope은 자식 중 하나라도 실패하면 나머지 자식을 모두 취소하고, 자신도 예외를 던진다. "전부 성공하거나 전부 실패"하는 트랜잭션 같은 동작이다.
supervisorScope — 실패가 전파되지 않음
suspend fun fetchDashboard() = supervisorScope {
val user = async { fetchUser() } // 성공
val notifications = async { fetchNotifications() } // 실패!
// notifications는 실패하지만, user는 영향 없이 완료됨
val userData = user.await()
val notiData = runCatching { notifications.await() }.getOrDefault(emptyList())
Dashboard(userData, notiData)
}
supervisorScope은 자식의 실패가 다른 자식에게 전파되지 않는다. 각 자식이 독립적으로 성공/실패한다.
언제 어떤 걸 쓸지 판단하는 기준은 이렇다.
coroutineScope— 자식들이 서로 의존적일 때. 하나가 실패하면 나머지도 의미 없을 때.supervisorScope— 자식들이 독립적일 때. 하나가 실패해도 나머지는 의미가 있을 때.
대시보드 화면처럼 여러 독립적인 데이터를 동시에 불러올 때 supervisorScope가 적합하다. 알림 조회가 실패해도 사용자 정보는 보여줘야 하니까.
취소(Cancellation)
코루틴의 취소는 협조적(cooperative)이다. 코루틴이 스스로 "나 취소됐나?" 확인하는 방식이다.
val job = launch {
repeat(1000) { i ->
println("작업 $i")
delay(100) // 중단점 — 여기서 취소를 확인
}
}
delay(500)
job.cancel() // 취소 요청
cancel()을 호출하면 코루틴의 Job이 취소 상태가 된다. 하지만 코루틴이 즉시 멈추는 것은 아니다. 다음 중단점(suspension point)에서 CancellationException이 발생하면서 코루틴이 종료된다.
delay(), yield(), withContext() 등 모든 suspend 함수가 중단점이다.
취소가 안 되는 경우
중단점이 없으면 취소가 동작하지 않는다.
val job = launch {
var i = 0
while (true) {
i++ // CPU 바운드 루프 — 중단점이 없다!
}
}
job.cancel() // 이 코루틴은 취소되지 않는다
이런 경우에는 명시적으로 취소 상태를 확인해야 한다.
val job = launch {
var i = 0
while (isActive) { // isActive로 취소 상태 확인
i++
}
}
또는 yield()를 주기적으로 호출해서 중단점을 만든다.
val job = launch {
var i = 0
while (true) {
i++
if (i % 1000 == 0) yield() // 주기적으로 중단점 삽입
}
}
예외 처리
코루틴의 예외 처리는 일반 try-catch와 약간 다르게 동작한다.
launch의 예외
launch에서 발생한 예외는 부모로 전파된다.
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
throw RuntimeException("실패!") // 부모 스코프로 전파
}
launch 안의 예외는 try-catch로 잡을 수 없다(밖에서). 예외가 구조화된 동시성 계층을 따라 올라가기 때문이다.
async의 예외
async에서 발생한 예외는 await() 호출 시 던져진다.
val deferred = async {
throw RuntimeException("실패!")
}
try {
deferred.await() // 여기서 예외가 발생
} catch (e: RuntimeException) {
println("잡았다: ${e.message}")
}
하지만 await()로 잡는다고 부모에 전파가 안 되는 건 아니다. supervisorScope 안에서 써야 부모 전파 없이 await()에서만 잡을 수 있다.
CoroutineExceptionHandler
최상위 코루틴의 예외를 포괄적으로 처리할 때 쓴다.
val handler = CoroutineExceptionHandler { _, exception ->
log.error("코루틴 예외: ${exception.message}", exception)
}
val scope = CoroutineScope(Dispatchers.Default + handler)
scope.launch {
throw RuntimeException("처리되지 않은 예외")
// handler에서 잡힌다
}
CoroutineExceptionHandler는 최상위 코루틴(launch로 시작된)의 처리되지 않은 예외만 잡는다.async의 예외는 잡지 않는다 (await()에서 처리해야 하므로).- 자식 코루틴에는 효과 없다. 반드시 스코프에 직접 설정해야 한다.
심화 분석
CancellationException은 특별하다
코루틴이 취소되면 CancellationException이 발생한다. 이 예외는 "정상적인 취소"로 취급되어, 부모에 실패로 전파되지 않는다.
coroutineScope {
val job1 = launch {
delay(1000)
println("1 완료")
}
val job2 = launch {
delay(500)
job1.cancel() // job1을 취소
println("2 완료")
}
// job1은 취소되지만, coroutineScope는 실패하지 않는다
}
CancellationException을 catch할 때 주의할 점이 있다.
// 나쁜 예 — CancellationException까지 잡아버림
try {
delay(1000)
} catch (e: Exception) {
// CancellationException도 여기서 잡힌다!
// 코루틴이 취소되지 않음
}
// 좋은 예 — CancellationException은 다시 던진다
try {
delay(1000)
} catch (e: Exception) {
if (e is CancellationException) throw e
// 나머지 예외만 처리
}
예외 처리 전략 선택
또는 supervisorScope] A -->|async| C[await에서 try-catch] A -->|자식들이 독립적| D[supervisorScope] A -->|전부 성공해야 의미| E[coroutineScope] style B fill:#e1f5fe style C fill:#e8f5e9 style D fill:#fff3e0 style E fill:#fce4ec
자주 하는 실수
SupervisorJob을 직접 전달하면서 구조화 깨뜨리기
// 나쁜 예 — 부모-자식 관계가 깨진다
launch(SupervisorJob()) {
// 이 코루틴은 부모와 분리됨!
}
// 좋은 예 — supervisorScope 사용
supervisorScope {
launch { /* ... */ }
}
launch(SupervisorJob())을 하면 새 Job이 부모와의 관계를 대체해버려서 구조화된 동시성이 깨진다. supervisorScope을 쓰는 것이 올바른 방법이다.
모든 곳에 try-catch
// 과도한 try-catch
launch {
try {
val data = fetchData()
try {
processData(data)
} catch (e: Exception) { /* ... */ }
} catch (e: Exception) { /* ... */ }
}
코루틴의 예외 전파 메커니즘을 이해하면 이런 방어적 코딩이 줄어든다. coroutineScope/supervisorScope와 CoroutineExceptionHandler로 계층적으로 처리하는 것이 깔끔하다.
finally에서 suspend 함수 호출
launch {
try {
fetchData()
} finally {
// delay(100) // CancellationException! — 이미 취소된 코루틴에서 suspend 불가
withContext(NonCancellable) {
delay(100) // NonCancellable 안에서는 가능
cleanup()
}
}
}
코루틴이 취소된 후 finally에서 suspend 함수를 호출하면 CancellationException이 다시 발생한다. withContext(NonCancellable)로 감싸야 한다.