코루틴 빌더 선택의 딜레마 #
코루틴을 처음 사용할때, 이와 같은 고민을 한 적이 있다
“언제 launch를 쓰고 언제 async를 써야 하지?”
“runBlocking은 왜 main에서만 쓰라고 하지?”
“똑같이 코루틴인데 왜 이렇게 다르게 동작할까?”
// 이 세 줄의 차이를 정확히 설명할 수 있나요?
launch { delay(1000) }
async { delay(1000) }
runBlocking { delay(1000) }
같은 코루틴 작업인데 완전히 다른 방식으로 동작하는 이유는 각 빌더가 만드는 Job의 특성에 있다. Job의 매커니즘을 이해하면 딜레마를 해결할수있다.
Job이란 정말 무엇인가? #
코루틴에서 Job은 코루틴의 생명주기를 제어하고 관리하는 핵심요소라고 말한다.
즉, 실행중인 코루틴을 핸들링 할 수 있다라고 생각하면 될 듯하다.
Job이 CoroutineContext.Element인 이유 #
CoroutineContext은 환경설정으로 단순히 알고 있었지만, Job은 단순히 코루틴의 생명주기를 제어하는것이 아니라 부모 Job 정보 전달 , 자식 Job 자동 생성 및 연결, 취소 신호 전파 경로 설정 코루틴 실행에 필요한 모든 정보의 집합 이라고 생각하면 CoroutineContext의 요소에 들어가있는게 맞다.
interface Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job>
// ==================== 상태 관련 프로퍼티 ====================
val isActive: Boolean
val isCompleted: Boolean
val isCancelled: Boolean
// ==================== 부모-자식 관계 관리 ====================
val parent: Job? // 부모 Job 참조
val children: Sequence<Job> // 모든 자식 Job들의 시퀀스
// ==================== 생명주기 제어 메소드 ====================
fun start(): Boolean
fun cancel(cause: CancellationException? = null): Boolean
fun cancelAndJoin(): Unit
fun cancelChildren(cause: CancellationException? = null): Unit
// ==================== 동기화 메소드 ====================
suspend fun join(): Unit
// ==================== 콜백 등록 ====================
fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
fun invokeOnCompletion(
onCancelling: Boolean = false,
invokeImmediately: Boolean = true,
handler: CompletionHandler
): DisposableHandle
// ==================== 자식 Job 연결 ====================
fun attachChild(child: ChildJob): ChildHandle
}
Job의 6가지 상태와 전환 메커니즘 #
| 상태 | isActive | isCompleted | isCancelled | 설명 |
|---|---|---|---|---|
| New | false | false | false | 선택적 초기 상태 |
| Active | true | false | false | 기본 초기 상태, 실행 중 |
| Completing | true | false | false | 완료 중인 임시 상태 |
| Cancelling | false | false | true | 취소 중인 임시 상태 |
| Cancelled | false | true | true | 취소된 최종 상태 |
| Completed | false | true | false | 정상 완료된 최종 상태 |
Job의 주요 함수들 #
-
start():Job을 시작하는데, 시작이라는 건New상태에서Active상태로 변경하는 것을 의미한다.
launch,async같은 Coroutine Builder 를 통해 Coroutine 을 만들 때CoroutineStart를 별도로 세팅하지 않는다면start()함수를 호출하지 않아도 Coroutine 이 생성될 때 이미start()함수가 호출되어Active한 상태이다. -
join():Job이 아직 시작하지 않았다면 시작해주기도 하고,Job의 동작이 완료될 때까지join()을 호출한 Coroutine 을 일시중단한다. 즉, 동기적으로 동작하도록 한다. 이러한 특성 때문에 Job 의 동기, 비동기적 흐름을 제어할 때 유용하게 사용할 수 있으며,joinAll()이라는 함수를 제공해 여러 개의 Job 이 완료될 때까지 기다릴 수도 있다. -
cancel(): 현재 Job 을 취소한다. 취소는Active상태에서Cancelling상태를 거쳐 취소 정리 작업이 완료되면Cancelled상태로 변경하는 것을 의미한다.New상태에서cancel()호출 시Cancelled상태로 변경된다. -
cancelAndJoin(): 현재 Job 을 취소하고, 취소 작업이 완료될 때까지 기다린다. cancel() 함수는 단순히 “취소해” 라는 명령이지만, cancelAndJoin() 함수는 “취소하고 확실히 취소되는 것까지 기다려” 라는 명령이다. -
cancelChildren(): 현재 Job 의 child job 들을 전부 취소한다.
New vs Active 왜 두개의 시작 상태가 필요할까? #
// New 상태로 시작하는 코루틴
val lazyJob = async(start = CoroutineStart.LAZY) {
println("실제 작업 시작")
delay(1000)
"결과"
}
println("생성 직후: isActive = ${lazyJob.isActive}") // false (New 상태)
// 명시적으로 시작해야 Active 상태가 됨
lazyJob.start()
println("시작 후: isActive = ${lazyJob.isActive}") // true (Active 상태)
New 상태의 존재 이유
- 지연 실행: 필요할 때까지 리소스 사용을 미룰 수 있음
- 조건부 실행: 특정 조건이 만족될 때만 시작
- 배치 처리: 여러 코루틴을 생성한 후 한 번에 시작
Completing vs Completed 미묘하지만 중요한 차이 #
suspend fun demonstrateCompletingState() {
val parentJob = launch {
println("부모 작업 시작")
// 두 개의 자식 코루틴 생성
launch {
delay(2000)
println("자식 1 완료")
}
launch {
delay(3000)
println("자식 2 완료")
}
println("부모 작업 완료 - 하지만 자식들을 기다림")
// 여기서 부모는 Completing 상태가 됨
}
delay(1000)
println("1초 후 부모 상태: isActive = ${parentJob.isActive}") // true (Completing)
println("1초 후 부모 상태: isCompleted = ${parentJob.isCompleted}") // false
parentJob.join()
println("모든 자식 완료 후: isCompleted = ${parentJob.isCompleted}") // true (Completed)
}
Completing 상태가 필요한 이유
- 구조적 동시성 보장: 부모는 모든 자식의 완료를 대기
- 리소스 정리: 자식들이 정리 작업을 완료할 시간 제공
- 일관된 상태 관리: 부분적 완료 상태를 명확히 구분
Cancelling 취소도 시간이 걸린다 #
suspend fun demonstrateCancellingState() {
val job = launch {
try {
repeat(100) { i ->
delay(100)
println("작업 진행: $i")
}
} finally {
println("정리 작업 시작")
// 정리 작업도 suspend 함수를 사용할 수 있음
delay(1000)
println("정리 작업 완료")
}
}
delay(500)
println("취소 요청 전: isCancelled = ${job.isCancelled}")// false
job.cancel()
println("취소 요청 후: isCancelled = ${job.isCancelled}")// true (Cancelling 상태)
println("취소 요청 후: isCompleted = ${job.isCompleted}")// false (아직 정리 중)
job.join()// 정리 완료까지 대기
println("정리 완료 후: isCompleted = ${job.isCompleted}")// true (Cancelled 상태)
}
코루틴 빌더와 Job 생성 #
코틀린 코루틴에서 코루틴 빌더는 새로운 코루틴을 시작하고, 그 코루틴의 Job 인스턴스를 반환하여 해당 코루틴의 생명주기를 관리할 수 있도록 해주는 함수다. 즉, 코루틴 빌더를 호출하는 순간 새로운 Job 객체가 내부적으로 생성되거나 기존 Job에 연결된다.
주요 코루틴 빌더 개요 #
코틀린 코루틴에서 가장 많이 사용되는 빌더들은 다음과 같다
| 빌더 | 반환 타입 | 블로킹 여부 | 주요 용도 |
|---|---|---|---|
| launch | Job | Non-blocking | Fire-and-forget 작업 |
| async | Deferred |
Non-blocking | 결과가 필요한 병렬 작업 |
| runBlocking | T | Blocking | 테스트, main 함수 |
launch: Fire-and-forget Job #
launch는 가장 기본적인 코루틴 빌더로, 결과값이 필요 없는 독립적인 작업을 실행할 때 사용한다
Fire-and-forget 방식은 작업을 시작한 후 그 결과를 기다리지 않고 바로 다음 일을 진행하는 방식을 말한다.
각각이 독립적인 실행 단위로 각 launch가 자신만의 스코프를 가진다.
기본사용법 #
fun demonstrateLaunch() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
val job: Job = scope.launch {
println("Launch 코루틴 시작: ${Thread.currentThread().name}")
delay(1000)
println("Launch 코루틴 완료")
// 반환값 없음 (Unit)
}
println("Job 상태: isActive=${job.isActive}, isCompleted=${job.isCompleted}")
// true, false
}
독립적 생명주기 관리 #
suspend fun demonstrateIndependentLifecycle() {
val scope = CoroutineScope(Job())
val job1 = scope.launch {
repeat(10) { i ->
delay(300)
println("Job 1: $i")
}
}
val job2 = scope.launch {
repeat(5) { i ->
delay(600)
println("Job 2: $i")
}
}
delay(2000)
// Job1만 개별적으로 취소 가능
job1.cancel()
println("Job1 취소됨")
// Job2는 계속 실행됨
job2.join()
println("Job2 완료됨")
}
-
독립적인 시작과 완료
launch는 호출되는 순간 즉시 비동기적으로 작업을 시작한다
그리고 launch 블록 내부의 코드가 모두 실행되거나, 중간에 예외가 발생하거나, 외부에서 cancel()이 호출되면 이 Job은 완료(또는 취소)된다.
이 과정은 다른 launch로 시작된 코루틴이나 부모 코루틴의 완료 여부와는 독립적으로 진행될 수 있다. -
독립적인 취소
launch가 반환하는 Job 객체에 cancel()을 호출하면 해당 Job과 그 자식 Job들만 취소된다
다른 Job이나 부모 Job은 영향을 받지 않는다 (물론부모 Job이 취소되면 자식 Job들도 취소되지만, 자식 Job의 취소가 부모에게 직접적인 취소를 유발하지는 않는다).
즉시 예외 전파 #
suspend fun demonstrateImmediateExceptionPropagation() {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("예외 포착: ${exception.message}")
}
val scope = CoroutineScope(Job() + exceptionHandler)
val job1 = scope.launch {
println("Job 1 시작 (delay 후 예외 발생)")
delay(300)
throw IllegalArgumentException("Launch 예외!") // 즉시 전파
}
val job2 = scope.launch {
// job1이 예외를 발생시키면 job2도 취소됩니다.
repeat(3) { i ->
delay(200)
println("Job 2 실행 중... $i")
}
println("Job 2 완료")
}
delay(500) // 잠시 기다려 예외가 발생하도록 함
println("Job 1 취소됨? ${job1.isCancelled}, 완료됨? ${job1.isCompleted}")
println("Job 2 취소됨? ${job2.isCancelled}, 완료됨? ${job2.isCompleted}") // true
scope.cancel()
}
launch로 시작된 코루틴에서 예외가 발생하면 기본적으로 즉시 해당 예외를 부모 코루틴으로 전파한다.
- 즉시 전파: 예외가 발생한 즉시 해당 코루틴은 실패 상태가 되고, 예외는 상위 계층으로 전파된다.
- 부모 취소: 부모 Job (또는 CoroutineScope)은 자식 Job으로부터 예외를 받으면, 자신을 포함한 나머지 모든 자식 Job들을 취소시킨다.
Launch Job의 특성 정리 #
- 타입: StandaloneCoroutine (Job 구현체)
- 반환값: Job (결과값 없음)
- 실행 방식: Fire-and-forget (비동기, 비블로킹)
- 예외 처리: 즉시 전파
- 용도: 결과가 필요 없는 독립적 작업에 최적화
async: 결과를 반환하는 Deferred Job #
async는 결과값이 필요한 비동기 작업을 실행할 때 사용하며, Deferred
Deferred 인터페이스 #
public interface Deferred<out T> : Job {
// ==================== 결과 대기 및 수령 ====================
public suspend fun await(): T
// ==================== 즉시 결과 확인 (논블로킹) ====================
public val isCompleted: Boolean // Job에서 상속
public fun getCompleted(): T // -> 논블로킹으로 즉시 결과를 가져옴. 완료되지 않았으면 예외 발생!
public fun getCompletionExceptionOrNull(): Throwable?
// ->
//null: 성공적으로 완료되었거나 아직 미완료
//Throwable: 예외로 인해 실패한 경우
// ==================== 선택적 대기 (select 표현식용) ====================
public val onAwait: SelectClause1<T>
}
Job을 상속받기때문에, Job의 기능을 사용할수있다, 즉 Job + Deffred 이라고 보면 된다
onAwait() vs await() 의 차이가 뭘까?
간단히 말하면 await()는 “이거 완료되면 결과 줘!“이고, onAwait()는 “여러 개 중에 제일 먼저 완료되는 거 있으면 알려줘!“라고 생각할 수 있겠다.
기본 사용법 #
suspend fun demonstrateAsync() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
val deferred: Deferred<String> = scope.async {
println("Async 코루틴 시작: ${Thread.currentThread().name}")
delay(1000)
println("Async 코루틴 완료")
"계산 결과" // 반환값
}
println("Deferred 타입: ${deferred::class.simpleName}") // DeferredCoroutine
// 결과 대기 및 수령
val result = deferred.await()
println("결과: $result")
}
결과 캐싱 #
suspend fun demonstrateResultCaching() {
val scope = CoroutineScope(Job())
val asyncJob = scope.async {
println("실제 계산 수행 중...")
delay(2000) // 무거운 계산 시뮬레이션
"계산 결과"
}
// 첫 번째 await() - 실제 계산 대기
println("첫 번째 await 시작")
val result1 = asyncJob.await()
println("첫 번째 결과: $result1")
// 두 번째 await() - 캐시된 결과 즉시 반환
println("두 번째 await 시작")
val result2 = asyncJob.await() // 즉시 반환됨!
println("두 번째 결과: $result2")
}
지연된 예외 전파 #
async로 시작된 코루틴에서 예외가 발생하면, 이 예외는 즉시 전파되지 않고 async가 반환하는 Deferred 객체 내부에 저장된다.
예외는 Deferred 객체 내부에 “숨겨져” 있다가, 해당 Deferred에 대해 await() 함수가 호출되는 시점에 다시 던져진다
따라서 async에서 발생한 예외는 await()를 호출하는 코드 블록에서 try-catch로 명시적으로 처리해야 한다.
suspend fun demonstrateDelayedException() {
val scope = CoroutineScope(Job())
// Async의 경우 - 예외가 await()까지 지연됨
val asyncJob = scope.async {
delay(500)
throw RuntimeException("Async에서 예외 발생!")
// 이 예외는 await() 호출까지 지연됨
}
delay(1000)
try {
asyncJob.await() // 여기서 예외 발생
} catch (e: Exception) {
println("Async 예외: ${e.message}")
}
}
그럼 지연된 예외전파라고 했으니 await()를 호출하기 전까지는 다른 코루틴들이 즉시 취소가 안되고, 예외가 발생 한 이후 취소가 되는거니, 사실상 부분완료가 가능한거 아닌가?
fun main() = runBlocking {
val scope = CoroutineScope(Job())
val tasks = listOf(
scope.async { delay(200); "작업1 성공" },
scope.async { delay(100); "작업2 성공" },
scope.async { delay(800); throw RuntimeException("작업3 실패") },
)
// 성공한 작업들만 수집
val successResults = tasks.mapNotNull { deferred ->
try {
deferred.await()
} catch (e: Exception) {
println("작업 실패: ${e.message}")
null // 실패한 경우 null 반환
}
}
println("성공한 작업들: $successResults")
// 출력: 성공한 작업들: [작업1 성공, 작업2 성공]
}
- Launch: 하나라도 실패하면 전체가 의미없는 경우 (트랜잭션)
- Async: 부분 성공이라도 가치있는 경우 (데이터 수집, 파일 처리)
Async Job의 특성 정리 #
- 타입:DeferredCoroutine (Job 구현체)
- 반환값: Deferred
(결과값 있음) - 실행 방식: Fire-and-forget (비동기, 비블로킹)
- 예외 처리: 지연예외 전파 (Deffred객체에 저장 뒤 await호출시 예외전파)
- 용도: 결과값이 필요한 비동기 계산
runBlocking: 블로킹 Job #
runBlocking은 현재 스레드를 블로킹하면서 코루틴을 실행하는 빌더다.
주로 테스트나 main 함수에서 사용된다
기본 사용법 #
fun demonstrateRunBlocking() {
println("runBlocking 시작 전: ${Thread.currentThread().name}")
val result: String = runBlocking {
println("runBlocking 내부: ${Thread.currentThread().name}")
// 현재 스레드를 블로킹하면서 실행
delay(1000)
"runBlocking 결과"
}
println("runBlocking 완료: $result")
println("runBlocking 종료 후: ${Thread.currentThread().name}")
}