실무에서 만나는 코루틴 스코프 함수 #
코루틴을 사용하다보면 이런 상황들을 자주 직면하게 된다
- 여러 API를 호출해서 모든 데이터가 준비 되어야 다음 단계로 넘어가고싶은데..
 - 일부 작업이 실패해도 다른 중요한 작업들은 계속 실행되어야 될텐데..
 - 네트워크 호출이 너무 오래걸리면 타임아웃 처리를 하고싶은데..
 - 무거운 계산작업을 해당 스레드가 아닌 다른 스레드에서 실행 하고싶은데..
 
해당 실무에서 코루틴스코프 안에서 코루틴을 계층적으로 관리해야될지, 독립적으로 관리해야될지 대해 코루틴 스코프 함수에 대해서 알아보려고한다.
코루틴 스코프를 이해하기 위해서는 구조화된 동시성에 대해서 먼저 알아보기로 하자
기존 동시성 프로그래밍의 문제 #
구조화된 동시성이 등장한 배경을 이해하기 위해, 기존 동시성 프로그래밍의 문제점들을 살펴보자.
취소의 어려움 및 리소스 누수 #
ExecutorService executor = Executors.newFixedThreadPool(3);
val executor = Executors.newFixedThreadPool(3)
// 10개의 작업 시작
val futures = (0 until 10).map {
    executor.submit {
        while (!Thread.currentThread().isInterrupted) {
            doHeavyWork()  // 무거운 작업
        }
    }
}
// 문제: 모든 작업을 취소하려면 하나씩 추적해야 함
futures.forEach { it.cancel(true) }  // 일일이 취소
특정 작업이 실패하거나 더 이상 필요 없어져 취소해야 할 경우, 관련된 모든 하위 작업을 안전하게 취소하는 것이 어렵다
예를 들어, 웹 요청을 처리하다가 사용자 연결이 끊겼을 때, 해당 요청을 위해 시작된 모든 백그라운드 작업들을 수동으로 찾아 취소해야 된다 
그렇지 않으면 작업은 계속 실행되어 불필요하게 리소스를 점유(메모리 누수, 스레드 누수)하게 된다.
이유 : 스레드나 퓨처는 기본적으로 독립적인 생명주기를 가지므로, 부모-자식 관계나 계층적인 취소 메커니즘이 없다
예외처리의 복잡성 #
val executor = Executors.newFixedThreadPool(3)
// 작업 실행
val executor = Executors.newFixedThreadPool(3)
// Future를 받아서 처리
val future = executor.submit {
    throw RuntimeException("작업 실패!")
}
try {
    future.get()  // 여기서 예외 발생!
} catch (e: ExecutionException) {
    println("예외 포착: ${e.cause?.message}")
}
비동기 작업 중 예외가 발생했을 때, 부모에게 전파하고 통합적으로 처리하는 것이 어렵다
예외가 발생한 스레드나 퓨처의 결과는 단순히 실패로 기록될 뿐, 상위 작업으로 자동으로 전파되거나 관련된 다른 작업들을 취소하지 않을 수 있다.
이유: 각 비동기 작업이 독립적으로 실행되므로, 예외 전파 메커니즘이 명확하게 정의되어 있지 않다. 개발자가 모든 가능한 예외 경로를 수동으로 처리해야 함.
스코프 및 생명주기 관리의 어려움 #
// 문제: UI가 사라져도 작업은 계속됨
class MyActivity {
    private val executor = Executors.newFixedThreadPool(3)
    
    fun onCreate() {
        // 화면이 생성될 때 작업 시작
        executor.submit {
            Thread.sleep(10000)  // 10초 작업
            updateUI()  // 이미 화면이 없을 수도...
        }
    }
    
    fun onDestroy() {
        // 화면 파괴
        // executor의 작업들은 여전히 실행 중!
    }
}
비동기 작업이 시작되었고, 언제까지 유효해야 하는지 관리하기 어렵다. 예를 들어, UI 컴포넌트가 파괴되었는데 해당 컴포넌트가 시작한 네트워크 요청이 계속 실행되는 경우가 발생할 수 있다.
이유 : 비동기 작업의 생명주기가 해당 작업이 시작된 부모의 생명주기와 명확히 연결되어 있지 않기 때문
코루틴의 구조화된 동시성이란? #
코루틴의 구조화된 동시성은 이러한 기존 동시성 프로그래밍의 문제점을 해결하기 위해 도입되었다. 비동기 작업(코루틴)들을 논리적인 부모-자식 계층 구조로 묶어 관리하는데 한번 알아보자.
코루틴 구조화된 동시성 핵심 원칙 #
구조화된 동시성은 다음 세 가지 핵심 원칙을 기반으로 한다
- 계층적 구조: 모든 코루틴은 명확한 부모-자식 관계를 가진다
 - 자동 생명주기 관리: 부모는 모든 자식의 완료를 기다린다
 - 일관된 오류 처리: 예외와 취소가 예측 가능하게 전파된다
 
Job 계층 구조와 부모-자식 관계 #
구조화된 동시성의 핵심은 Job의 계층 구조다.
모든 코루틴은 Job을 가지며, 이 Job들이 트리 구조를 형성한다.
suspend fun demonstrateJobHierarchy() {
    val rootJob = Job()
    val parentScope = CoroutineScope(rootJob + Dispatchers.Default)
    parentScope.launch {// Level 1 Job
        val level1Job = coroutineContext[Job]!!
        println("Level 1 Job: $level1Job")
        println("Level 1 부모: ${level1Job.parent}")// rootJob
        launch {// Level 2 Job
            val level2Job = coroutineContext[Job]!!
            println("Level 2 Job: $level2Job")
            println("Level 2 부모: ${level2Job.parent}")// level1Job
            launch {// Level 3 Job
                val level3Job = coroutineContext[Job]!!
                println("Level 3 Job: $level3Job")
                println("Level 3 부모: ${level3Job.parent}")// level2Job
                delay(1000)
                println("Level 3 완료")
            }
            println("Level 2 완료")
        }
        println("Level 1 완료")
    }
}
자동 생명주기 관리 #
구조화된 동시성의 핵심은 개발자가 명시적으로 작업을 정리하지 않아도, 부모-자식 관계에 따라 자동으로 관리된다.
부모완료대기 #
suspend fun demonstrateAutomaticWaiting() {
    val startTime = System.currentTimeMillis()
    coroutineScope {
        println("${elapsed(startTime)}ms: 부모 스코프 시작")
        launch {
            delay(2000)
            println("${elapsed(startTime)}ms: 자식 1 완료")
        }
        launch {
            delay(3000)
            println("${elapsed(startTime)}ms: 자식 2 완료")
        }
        launch {
            delay(1000)
            println("${elapsed(startTime)}ms: 자식 3 완료")
        }
        println("${elapsed(startTime)}ms: 부모 로직 완료")
// 여기서 부모는 Completing 상태가 되어 모든 자식을 대기
    }
    println("${elapsed(startTime)}ms: 모든 작업 완료")
// 가장 오래 걸린 자식(3초) 완료 후에 실행됨
}
private fun elapsed(startTime: Long) = System.currentTimeMillis() - startTime
그럼 자식 코루틴이 부모 완료까지 메모리가 유지 될까? 정확한 메모리 정리는 시점은 언제일까?
fun main() = runBlocking {
    val childReferences = mutableListOf<WeakReference<Job>>()
    val parentJob = launch {
        println("부모 코루틴 시작")
        // 자식 코루틴 1 - 빠르게 완료
        val child1 = launch {
            println("자식 1 시작")
            delay(1000)
            println("자식 1 완료")
        }
        childReferences.add(WeakReference(child1))
        // 자식 코루틴 2 - 빠르게 완료
        val child2 = launch {
            println("자식 2 시작")
            delay(1500)
            println("자식 2 완료")
        }
        childReferences.add(WeakReference(child2))
        // 자식들이 완료된 후 부모는 계속 실행
        delay(3000)  // 자식들 완료 후 1.5초 더 실행
        println("부모 코루틴 완료")
    }
    // 2초 후 확인 (자식들은 완료됨, 부모는 아직 실행 중)
    delay(2000)
    System.gc()  // 강제 GC
    delay(100)   // GC 완료 대기
    println("\n=== 2초 후 메모리 상태 ===")
    childReferences.forEachIndexed { index, ref ->
        if (ref.get() == null) {
            println("자식 ${index + 1}: 메모리에서 정리됨")
        } else {
            println("자식 ${index + 1}: 아직 메모리에 남아있음")
        }
    }
    parentJob.join()
    System.gc()
    delay(100)
    println("\n=== 부모 완료 후 메모리 상태 ===")
    childReferences.forEachIndexed { index, ref ->
        if (ref.get() == null) {
            println("자식 ${index + 1}: 메모리에서 정리됨")
        } else {
            println("자식 ${index + 1}: 아직 메모리에 남아있음")
        }
    }
}
약한 참조를 통해 구조화된 동시성에서는 언제 메모리가 정리되는지 추적해보았더니,
자식코루틴이 완료되면 바로 GC대상이 되는걸 확인했다
메모리 누수가 발생하는 경우 #
- 외부 참조 유지
 
class MemoryLeakExample {
    private val completedJobs = mutableListOf<Job>()// 위험!
    suspend fun createLeak() {
        repeat(1000) { index ->
            val job = launch {
                delay(100)
                println("작업 $index")
            }
            completedJobs.add(job)//Job 참조를 계속 보관
            job.join()
        }
// completedJobs에 1000개 Job이 저장되어 메모리 누수
// 클래스 멤버 스코프는 자동으로 정리되지 않기에 꼭 메모리 정리를 ㅎ ㅐ줘야됨
    completedJobs.forEach {
        it.cancel()
    }
    }
}
- GlobalScope 사용
 
fun globalScopeLeak() {
    repeat(1000) { index ->
        GlobalScope.launch {//  부모가 없어서 정리되지 않음
            val data = ByteArray(1024 * 1024)
            delay(60000)// 1분간 실행
            println("GlobalScope 작업 $index")
        }
    }
// 이 함수가 끝나도 1000개 코루틴이 계속 실행됨// 1000MB 메모리가 1분간 유지됨
}
메모리 자동 정리의 정확한 시점
- 자식 코루틴은 완료되는 즉시 메모리 정리 대상
 - 부모 코루틴 완료까지 기다리지 않음
 - 외부 강한 참조가 있으면 GC 되지 않음
 - 적절한 스코프 관리가 메모리 누수 방지의 핵심
 
취소 전파 메커니즘 #
구조화된 동시성에서 취소는 계층적으로 전파된다. 부모가 취소되면 모든 자식이 취소되고, 자식의 실패는 부모로 전파되어 형제들도 취소시킨다.
하향식 취소전파(부모 -> 자식) #
suspend fun demonstrateDownwardCancellation() {
    val parentJob = launch {
        println("부모 작업 시작")
        
        val child1 = launch {
            try {
                repeat(100) { i ->
                    delay(100)
                    println("자식 1 작업: $i")
                }
            } catch (e: CancellationException) {
                println("자식 1 취소됨: ${e.message}")
                throw e  // 중요: CancellationException은 다시 던져야 함
            } finally {
                println("자식 1 정리 작업")
            }
        }
        
        val child2 = launch {
            try {
                repeat(100) { i ->
                    delay(150)
                    println("자식 2 작업: $i")
                }
            } catch (e: CancellationException) {
                println("자식 2 취소됨: ${e.message}")
                throw e
            } finally {
                println("자식 2 정리 작업")
            }
        }
        
        delay(5000)  // 5초 후 정상 완료 예정
        println("부모 작업 완료")
    }
    
    delay(1000)  // 1초 후 부모 취소
    println("=== 부모 취소 요청 ===")
    parentJob.cancel(CancellationException("사용자 요청에 의한 취소"))
    
    parentJob.join()
    println("모든 작업 정리 완료")
}
상향식 취소 전파(자식 -> 부모) #
suspend fun demonstrateUpwardExceptionPropagation() {
    try {
        coroutineScope {
            launch {
                delay(1000)
                println("자식 1 정상 완료")
            }
            
            launch {
                delay(1500)
                throw RuntimeException("자식 2에서 예외 발생!")
                // 이 예외가 부모로 전파되어 모든 형제를 취소시킴
            }
            
            launch {
                try {
                    repeat(10) { i ->
                        delay(200)
                        println("자식 3 작업: $i")
                    }
                    println("자식 3 정상 완료")
                } catch (e: CancellationException) {
                    println("자식 3: 형제의 예외로 인해 취소됨")
                }
            }
            
            delay(3000)
            println("부모 작업 완료")  // 이 라인은 실행되지 않음
        }
    } catch (e: Exception) {
        println("예외 포착: ${e.message}")
        println("모든 자식 작업이 취소되었습니다")
    }
}
협력적 취소 #
협력적 취소란?
협력적 취소는 코루틴이 외부에서 취소 요청을 받았을 때, 스스로 취소 상태를 확인하고 적절히 반응한다.
강제적으로 중단되는 것이 아니라, 코루틴이 “협력적으로” 취소에 응답한다
왜 협력적 취소가 필요한가? #
suspend fun problematicLongRunningTask() {
    val job = launch {
        //  문제: 이 루프는 취소되지 않음!
        repeat(1000000) { i ->
            performHeavyComputation(i)  // CPU 집약적 작업
            // delay()나 취소 확인 없음
        }
        println("작업 완료")
    }
    
    delay(1000)
    job.cancel()  // 취소 요청하지만...
    println("취소 요청함")
    
    job.join()  // 취소되지 않아서 계속 기다림!
    println("작업 종료")  // 이 라인까지 도달하는데 매우 오래 걸림
}
fun performHeavyComputation(i: Int) {
    // 무거운 계산 작업 시뮬레이션
    var result = 0
    repeat(10000) { result += it }
}
문제점
- cancel() 호출해도 코루틴이 계속 실행됨
 - CPU 집약적 작업이 취소 신호를 확인하지 않음
 - 앱이 종료되어도 백그라운드에서 계속 실행될 수 있음
 
협력적 취소 구현 방법들 #
- ensureActive() 사용
 
suspend fun cooperativeWithEnsureActive() {
    val job = launch {
        try {
            repeat(1000000) { i ->
                // 주기적으로 취소 상태 확인
                ensureActive()  // 취소되었으면 CancellationException 발생
                
                performHeavyComputation(i)
                
                if (i % 10000 == 0) {
                    println("진행률: ${i / 10000}%")
                }
            }
            println("작업 완료")
        } catch (e: CancellationException) {
            println("작업 취소됨: ${e.message}")
            throw e  // 중요: CancellationException은 다시 던져야 함
        }
    }
    
    delay(1000)
    job.cancel("사용자가 취소함")
    println("취소 요청함")
    
    job.join()
    println("작업 종료")  // 빠르게 도달함
}
- isActive () 사용
 
suspend fun cooperativeWithIsActive() {
    val job = launch {
        var i = 0
        while (isActive && i < 1000000) {  // isActive로 취소 상태 확인
            performHeavyComputation(i)
            i++
            
            if (i % 10000 == 0) {
                println("진행률: ${i / 10000}%")
            }
        }
        
        if (isActive) {
            println("작업 완료")
        } else {
            println("작업 취소됨")
        }
    }
    
    delay(1000)
    job.cancel()
    job.join()
}
- yield() 사용
 
suspend fun cooperativeWithYield() {
    val job = launch {
        try {
            repeat(1000000) { i ->
                performHeavyComputation(i)
                
                // yield()는 취소 확인 + 다른 코루틴에게 실행 기회 제공
                if (i % 1000 == 0) {
                    yield()  // 취소되었으면 CancellationException 발생
                }
                
                if (i % 10000 == 0) {
                    println("진행률: ${i / 10000}%")
                }
            }
            println("작업 완료")
        } catch (e: CancellationException) {
            println("작업 취소됨")
            throw e
        }
    }
    
    delay(1000)
    job.cancel()
    job.join()
}
왜 협력적 취소인가? - 강제 취소의 문제점과 협력적 취소의 필요성 #
왜 굳이 협력적 취소를 해야될까?
강제취소 시 발생할 문제점들을 살펴보자
강제 취소로 인한 커넥션 누수 #
class ConnectionPoolExhaustion {
    
    suspend fun processLargeDataBatch(dataList: List<Data>) {
        val connections = mutableListOf<Connection>()
        
        try {
            dataList.forEach { data ->
                val connection = DatabasePool.getConnection()  // 커넥션 획득
                connections.add(connection)
                
                val preparedStatement = connection.prepareStatement(
                    "INSERT INTO large_data (id, content, processed_at) VALUES (?, ?, ?)"
                )
                
                // 만약 여기서 강제 취소된다면?
                Thread.sleep(100)  // 네트워크 지연
                
                preparedStatement.setInt(1, data.id)
                preparedStatement.setString(2, data.content)
                preparedStatement.setTimestamp(3, Timestamp.now())
                preparedStatement.executeUpdate()
                
                // PreparedStatement 정리도 안됨
                preparedStatement.close()
            }
            
        } finally {
            // 강제 취소 시 실행되지 않음!
            connections.forEach { it.close() }
        }
    }
}
만약 이 커넥션을 얻은 직후, 혹은 Thread.sleep(500) 같은 대기 중에 외부에서 processingJob.cancel()이 호출되었다고 가정해보자
Thread.sleep()은 InterruptedException을 던져 catch 블록으로 진입하게 하고 finally에서 커넥션을 닫을 수 있다.
진짜 문제: 하지만 만약 connection.prepareStatement()나 executeUpdate() 같은 블로킹 JDBC 호출이 아주 오랫동안 응답이 없고 InterruptedException도 던지지 않는 상황이 발생한다면?
코루틴은 해당 블로킹 호출이 끝날 때까지 스레드를 계속 점유하고 있게 된다.
cancel()이 호출되어도, 해당 코루틴은 다음 취소 가능 지점(delay() 같은)에 도달하거나 블로킹 I/O가 InterruptedException을 던지기 전까지는 실제로 중단되지 않는다.
이 상태에서 커넥션은 계속 열려있는 상태로 유지되고 finally 블록은 호출되지 않는다.
동시에 여러 코루틴이 이 비협력적인 함수를 호출하면, 커넥션 풀의 모든 커넥션이 이처럼 블로킹된 코루틴에 의해 점유되어 고갈될 수 있다. 새로운 요청은 커넥션을 얻지 못하고 대기하거나 실패하게 된다.
트랜잭션 일관성 파괴 #
class DatabaseForceCancellationProblems {
    
    suspend fun dangerousUserRegistration(user: User) {
        val connection = DatabaseConnection.getConnection()
        var transaction: Transaction? = null
        
        try {
            transaction = connection.beginTransaction()
            
            // 1. 사용자 기본 정보 저장
            val userId = insertUser(connection, user.basicInfo)
            println("사용자 기본 정보 저장 완료: $userId")
            
            // 만약 여기서 강제 취소된다면?
            Thread.sleep(1000)  // 네트워크 지연 시뮬레이션
            
            // 2. 사용자 프로필 저장 (실행되지 않음)
            insertUserProfile(connection, userId, user.profile)
            
            // 3. 사용자 권한 설정 (실행되지 않음)  
            insertUserPermissions(connection, userId, user.permissions)
            
            // 4. 환영 이메일 큐에 추가 (실행되지 않음)
            addToEmailQueue(connection, userId, "welcome")
            
            transaction.commit()
            println("사용자 등록 완료")
            
        } finally {
            // 강제 취소 시 finally 블록이 실행되지 않을 수 있음!
            transaction?.rollback()  // 실행 안됨
            connection?.close()      // 실행 안됨
        }
    }
}
데이터를 INSERT하고 TimeUnit.SECONDS.sleep(1) 동안 락을 잡고 있는다.
만약 이 sleep 도중 cancel()이 호출되고, InterruptedException이 발생하여 catch 블록으로 진입한다면 connection?.rollback()이 호출되어 트랜잭션은 롤백될 것이다.
진짜 문제: 만약 connection.commit() 직전에 cancel()이 호출되었는데, sleep과 같은 강제적인 블로킹이 아닌, 실제 JDBC 드라이버의 블로킹 I/O가 InterruptedException을 던지지 않는 경우라면?
트랜잭션이 커밋되지도, 롤백되지도 않은 채로 불완전한 상태로 남아있을 수 있다 (일부 데이터는 테이블에 임시 저장되지만 커밋되지 않음).
이후 해당 스레드나 연결이 재사용되더라도 이전 트랜잭션의 상태가 명확하지 않아 데이터 불일치로 이어진다.
트랜잭션 내에서 부분적으로만 데이터가 저장된 채 예외가 발생하고 트랜잭션이 적절히 롤백되지 않는다면, 데이터베이스에 불완전한 레코드가 남을 수 있다. 이 불완전한 상태의 데이터가 다른 트랜잭션에게 보이거나(낮은 격리 수준에서), 다음 재시도 시 문제가 될 수 있다
락 해제로 인한 데드락 #
class DatabaseLockDeadlock {
    
    suspend fun updateAccountBalance(accountId: Int, amount: BigDecimal) {
        val connection = DatabaseConnection.getConnection()
        
        try {
            connection.autoCommit = false
            
            // 1. 계좌 레코드에 배타적 락 획득
            val lockStatement = connection.prepareStatement(
                "SELECT balance FROM accounts WHERE id = ? FOR UPDATE"
            )
            lockStatement.setInt(1, accountId)
            val resultSet = lockStatement.executeQuery()
            
            if (resultSet.next()) {
                val currentBalance = resultSet.getBigDecimal("balance")
                println("계좌 $accountId 락 획득, 현재 잔고: $currentBalance")
                
                // 만약 여기서 강제 취소된다면?
                Thread.sleep(2000)  // 복잡한 계산 시뮬레이션
                
                // 잔고 업데이트 (실행되지 않음)
                val newBalance = currentBalance.add(amount)
                val updateStatement = connection.prepareStatement(
                    "UPDATE accounts SET balance = ? WHERE id = ?"
                )
                updateStatement.setBigDecimal(1, newBalance)
                updateStatement.setInt(2, accountId)
                updateStatement.executeUpdate()
                
                connection.commit()
                println("계좌 업데이트 완료")
            }
            
        } finally {
            // 강제 취소 시 실행되지 않음!
            connection.rollback()  // 롤백 안됨 → 락 해제 안됨
            connection.close()     // 커넥션 반납 안됨
        }
    }
}
SELECT … FOR UPDATE 쿼리가 성공적으로 실행되면, accountId에 해당하는 계좌 레코드에 배타적 락이 걸립니다. 이 락은 commit()이나 rollback()이 호출될 때까지 유지된다.
문제는 락 획득 직후에 Thread.sleep(2000) (2초간 스레드 블로킹)이 발생한다는 점이다.
이 2초 동안, 해당 계좌 레코드는 다른 트랜잭션이 접근하지 못하도록 잠긴 상태로 유지됩니다. 이는 “장기적인 락 점유"를 시뮬레이션한다.
Thread.sleep()전에 취소한다면? 같은 블로킹 작업으로 인해 트랜잭션이 오랫동안 COMMIT 또는 ROLLBACK되지 않고 활성 상태로 남아있게 된다.
이 상태에서 다른 트랜잭션이 동일한 accountId에 접근하여 락을 획득하려 하면, 이전 트랜잭션이 락을 해제할 때까지 무한정 대기(블로킹) 상태에 빠진다.
만약 두 트랜잭션이 서로가 획득한 락을 교차로 요구하는 상황(예: 계좌 A에서 B로 이체, 계좌 B에서 A로 이체)에서 이런 블로킹이 발생하면 데이터베이스 데드락으로 이어져 시스템 전체가 멈출 수 있다.
coroutineScope - 모 아니면 도 #
coroutineScope는 모든 자식 작업이 성공해야만 전체가 성공하는 “all-or-nothing” 방식의 구조화된 동시성을 제공하는 suspend 함수이다.
한 자식이 실패하면 즉시 모든 형제 작업을 취소하고 전체가 실패한다.
핵심 동작 원리 #
1. 컨텍스트 상속과 새로운 Job 생성
suspend fun parentCoroutine() {
    println("부모 코루틴 시작")
    
    // coroutineScope는 현재 코루틴의 컨텍스트를 상속받음
    coroutineScope {
        // 상속받은 것: Dispatcher, CoroutineExceptionHandler 등
        // 새로 생성된 것: 이 스코프만의 Job (부모 Job의 자식이 됨)
        
        launch { /* 이 Job은 coroutineScope Job의 자식 */ }
        async { /* 이 Job도 coroutineScope Job의 자식 */ }
        
        println("coroutineScope 내부 작업들 시작")
    }  // 모든 자식이 완료될 때까지 여기서 대기
    
    println("부모 코루틴 계속 진행")  // 모든 자식 완료 후 실행
}
계층 구조
부모 코루틴
    └── coroutineScope (새로운 Job)
            ├── launch 작업 1
            ├── async 작업 2  
            └── launch 작업 3
2. 양방향 취소 전파
suspend fun demonstrateCancellationPropagation() {
    println("=== 취소 전파 예제 ===")
    
    try {
        coroutineScope {
            // 작업 1: 정상 실행
            launch(CoroutineName("Task1")) {
                try {
                    repeat(10) { i ->
                        delay(300)
                        println("작업 1 진행: $i")
                    }
                    println("작업 1 완료")
                } catch (e: CancellationException) {
                    println("작업 1 취소됨")
                    throw e
                }
            }
            
            // 작업 2: 1초 후 실패
            launch(CoroutineName("Task2")) {
                delay(1000)
                println("작업 2에서 예외 발생!")
                throw RuntimeException("작업 2 실패")
            }
            
            // 작업 3: 정상 실행 중이지만 작업 2 실패로 취소됨
            launch(CoroutineName("Task3")) {
                try {
                    repeat(15) { i ->
                        delay(200)
                        println("🔧 작업 3 진행: $i")
                    }
                    println("작업 3 완료")
                } catch (e: CancellationException) {
                    println("작업 3 취소됨 (형제 실패로 인해)")
                    throw e
                }
            }
            
            println("모든 작업 시작됨")
        }
        
        println("모든 작업 성공적으로 완료")  // 실행되지 않음
        
    } catch (e: Exception) {
        println("전체 스코프 실패: ${e.message}")
    }
}
실행 결과:
=== 취소 전파 예제 ===
모든 작업 시작됨
작업 1 진행: 0
작업 3 진행: 0
작업 1 진행: 1
작업 3 진행: 1
작업 1 진행: 2
작업 3 진행: 2
작업 1 진행: 3
작업 3 진행: 3
작업 2에서 예외 발생!
작업 1 취소됨
작업 3 취소됨 (형제 실패로 인해)  
전체 스코프 실패: 작업 2 실패
3. 구조화된 완료 대기
suspend fun demonstrateStructuredCompletion() {
    println("구조화된 완료 대기 시작")
    val startTime = System.currentTimeMillis()
    
    coroutineScope {
        // 빠른 작업
        launch {
            delay(500)
            println("빠른 작업 완료 (500ms)")
        }
        
        // 중간 작업  
        launch {
            delay(1500)
            println("중간 작업 완료 (1500ms)")
        }
        
        // 느린 작업
        launch {
            delay(2000)
            println("느린 작업 완료 (2000ms)")
        }
        
        println("모든 작업 시작됨")
    }  // 가장 느린 작업(2000ms)까지 모두 완료되면 여기서 진행
    
    val endTime = System.currentTimeMillis()
    println("모든 작업 완료 - 총 소요시간: ${endTime - startTime}ms")
}
예외 처리 패턴 #
1. 기본 try-catch 패턴
suspend fun basicExceptionHandling() {
    try {
        coroutineScope {
            launch { riskyOperation1() }
            launch { riskyOperation2() }
            launch { riskyOperation3() }
        }
        println("모든 작업 성공")
        
    } catch (e: Exception) {
        println("작업 실패: ${e.message}")
        
        // 실패 타입별 처리
        when (e) {
            is IllegalArgumentException -> handleValidationError(e)
            is IOException -> handleNetworkError(e)
            is SecurityException -> handleSecurityError(e)
            else -> handleUnknownError(e)
        }
    }
}
2. CoroutineExceptionHandler와 함께 사용
suspend fun exceptionHandlerWithCoroutineScope() {
    val exceptionHandler = CoroutineExceptionHandler { context, exception ->
        val jobName = context[CoroutineName]?.name ?: "Unknown"
        println("예외 핸들러: [$jobName]에서 ${exception::class.simpleName} 발생")
        println("예외 메시지: ${exception.message}")
        
        // 메트릭 기록, 알림 전송 등
        recordException(jobName, exception)
    }
    
    val scope = CoroutineScope(
        SupervisorJob() + exceptionHandler + Dispatchers.Default
    )
    
    scope.launch(CoroutineName("MainTask")) {
        try {
            coroutineScope {  // 내부에서는 여전히 fail-fast 동작
                launch(CoroutineName("SubTask-1")) {
                    delay(1000)
                    println("SubTask-1 완료")
                }
                
                launch(CoroutineName("SubTask-2")) {
                    delay(500)
                    throw IllegalStateException("SubTask-2 실패!")
                }
                
                launch(CoroutineName("SubTask-3")) {
                    delay(1500)
                    println("SubTask-3 완료")  // 실행되지 않음
                }
            }
            
        } catch (e: Exception) {
            println("coroutineScope에서 잡힌 예외: ${e.message}")
            // 여기서 복구 로직 수행
        }
    }
    
    delay(3000)
    scope.cancel()
}
private fun recordException(jobName: String, exception: Throwable) {
    println("메트릭 기록: $jobName - ${exception::class.simpleName}")
}
3. 부분 재시도 패턴
suspend fun retryPattern() {
    val maxRetries = 3
    var attempt = 0
    
    while (attempt < maxRetries) {
        try {
            coroutineScope {
                launch { criticalTask1() }
                launch { criticalTask2() }
                launch { criticalTask3() }
            }
            
            println("모든 작업 성공")
            return  // 성공 시 종료
            
        } catch (e: Exception) {
            attempt++
            println("시도 $attempt 실패: ${e.message}")
            
            if (attempt >= maxRetries) {
                println("최대 재시도 횟수 초과")
                throw e
            }
            
            val delayTime = attempt * 1000L  // 지수 백오프
            println(" ${delayTime}ms 후 재시도...")
            delay(delayTime)
        }
    }
}
coroutineScope를 사용해야 하는 경우
- 모든 작업이 성공해야 하는 경우
 - 원자적 연산이 필요한 경우 (트랜잭션과 유사)
 - 하나라도 실패하면 전체가 의미 없는 경우
 - 빠른 실패(fail-fast)가 원하는 동작인 경우
 
예시: 결제 처리, 데이터 마이그레이션, 파일 다운로드+압축+저장
supervisorScope - 최선을 다하자 #
supervisorScope는 자식 작업들이 독립적으로 실행되어 일부 실패를 허용하는 “best-effort” 방식의 구조화된 동시성을 제공하는 suspend 함수이다. 한 자식이 실패해도 다른 형제 작업들은 계속 실행되며, 가능한 많은 결과를 얻으려고 노력한다.
핵심 동작 원리 #
1. 컨텍스트 상속과 SupervisorJob 생성
suspend fun parentCoroutine() {
    println("부모 코루틴 시작")
    
    // supervisorScope는 현재 코루틴의 컨텍스트를 상속받음
    supervisorScope {
        // 상속받은 것: Dispatcher, CoroutineExceptionHandler 등
        // 새로 생성된 것: SupervisorJob (자식 실패가 형제에게 전파되지 않음)
        
        launch { /* 이 Job은 supervisorScope SupervisorJob의 자식 */ }
        async { /* 이 Job도 독립적으로 실행됨 */ }
        
        println("supervisorScope 내부 작업들 시작")
    }  // 모든 자식이 완료될 때까지 여기서 대기 (실패한 것도 완료로 간주)
    
    println("부모 코루틴 계속 진행")  // 성공/실패 관계없이 모든 자식 완료 후 실행
}
계층 구조
부모 코루틴
    └── supervisorScope (SupervisorJob)
            ├── launch 작업 1 (독립)
            ├── async 작업 2 (독립)
            └── launch 작업 3 (독립)
            
// 작업 2가 실패해도 작업 1, 3은 계속 실행
2. 독립적 실행과 부분 실패 허용
suspend fun demonstrateIndependentExecution() {
    println("=== 독립적 실행 예제 ===")
    
    supervisorScope {
        // 작업 1: 정상 실행
        launch(CoroutineName("Task1")) {
            try {
                repeat(8) { i ->
                    delay(400)
                    println("작업 1 진행: $i")
                }
                println("작업 1 완료")
            } catch (e: CancellationException) {
                println("작업 1 취소됨")
                throw e
            }
        }
        
        // 작업 2: 1초 후 실패 (하지만 다른 작업에 영향 없음)
        launch(CoroutineName("Task2")) {
            try {
                delay(1000)
                println("작업 2에서 예외 발생!")
                throw RuntimeException("작업 2 실패")
            } catch (e: RuntimeException) {
                println("작업 2 실패 처리: ${e.message}")
                // 예외를 여기서 처리하여 다른 작업에 영향 없음
            }
        }
        
        // 작업 3: 정상 실행 (작업 2 실패와 무관하게 계속)
        launch(CoroutineName("Task3")) {
            try {
                repeat(12) { i ->
                    delay(300)
                    println("작업 3 진행: $i")
                }
                println("작업 3 완료")
            } catch (e: CancellationException) {
                println("작업 3 취소됨")
                throw e
            }
        }
        
        // 작업 4: 중간에 실패하지만 독립적
        launch(CoroutineName("Task4")) {
            try {
                repeat(6) { i ->
                    delay(500)
                    println("작업 4 진행: $i")
                    if (i == 3) {
                        throw IllegalStateException("작업 4 중간 실패")
                    }
                }
                println("작업 4 완료")
            } catch (e: Exception) {
                println("작업 4 실패: ${e.message}")
                // 다른 작업들은 계속 실행됨
            }
        }
        
        println("모든 작업 시작됨")
    }  // 성공/실패 관계없이 모든 작업이 끝나면 진행
    
    println("supervisorScope 완료 - 성공한 작업들의 결과 활용 가능")
}
실행 결과:
=== 독립적 실행 예제 ===
모든 작업 시작됨
작업 1 진행: 0
작업 3 진행: 0
작업 4 진행: 0
작업 1 진행: 1
작업 3 진행: 1
작업 2에서 예외 발생!
작업 2 실패 처리: 작업 2 실패
작업 4 진행: 1
작업 1 진행: 2
작업 3 진행: 2
... (작업 1, 3은 계속 실행)
작업 4 진행: 3
작업 4 실패: 작업 4 중간 실패
... (작업 1, 3은 여전히 계속)
작업 1 완료
작업 3 완료
supervisorScope 완료 - 성공한 작업들의 결과 활용 가능
3. 구조화된 완료 대기 (성공/실패 무관)
suspend fun demonstrateFlexibleCompletion() {
    println("유연한 완료 대기 시작")
    val startTime = System.currentTimeMillis()
    val results = mutableMapOf<String, String>()
    
    supervisorScope {
        // 빠른 성공 작업
        launch {
            delay(500)
            results["fast"] = "빠른 작업 성공"
            println("⚡ 빠른 작업 완료 (500ms)")
        }
        
        // 실패하는 작업
        launch {
            try {
                delay(800)
                throw RuntimeException("실패 작업")
            } catch (e: Exception) {
                results["failed"] = "실패: ${e.message}"
                println("실패 작업 처리됨 (800ms)")
            }
        }
        
        // 느린 성공 작업
        launch {
            delay(1500)
            results["slow"] = "느린 작업 성공"
            println("느린 작업 완료 (1500ms)")
        }
        
        println("모든 작업 시작됨")
    }  // 성공/실패 관계없이 모든 작업이 끝나면 진행
    
    val endTime = System.currentTimeMillis()
    println("모든 작업 완료 - 총 소요시간: ${endTime - startTime}ms")
    println("결과: $results")
}
예외 처리 패턴 #
1. 개별 예외 처리로 독립성 보장
suspend fun individualExceptionHandling() {
    supervisorScope {
        launch {
            try {
                riskyOperation1()
                handleOperation1Success()
            } catch (e: Exception) {
                println("작업 1 실패: ${e.message}")
                handleOperation1Failure(e)
            }
        }
        
        launch {
            try {
                riskyOperation2()
                handleOperation2Success()
            } catch (e: Exception) {
                println("작업 2 실패: ${e.message}")
                handleOperation2Failure(e)
            }
        }
        
        launch {
            try {
                riskyOperation3()
                handleOperation3Success()
            } catch (e: Exception) {
                println("작업 3 실패: ${e.message}")
                handleOperation3Failure(e)
            }
        }
    }
    
    println("모든 작업 시도 완료 - 성공한 것들의 결과 활용")
}
2. CoroutineExceptionHandler를 통한 통합 모니터링
suspend fun unifiedExceptionMonitoring() {
    val exceptionHandler = CoroutineExceptionHandler { context, exception ->
        val jobName = context[CoroutineName]?.name ?: "Unknown"
        println("예외 모니터링: [$jobName] ${exception::class.simpleName}")
        
        // 실패 메트릭 기록
        recordFailureMetric(jobName, exception)
        
        // 필요시 알림 (심각한 예외만)
        if (exception is CriticalException) {
            sendAlert(jobName, exception)
        }
    }
    
    val scope = CoroutineScope(SupervisorJob() + exceptionHandler + Dispatchers.Default)
    
    scope.launch {
        supervisorScope {
            launch(CoroutineName("CriticalTask")) {
                // 예외가 발생해도 핸들러에서 로깅만 하고 다른 작업 계속
                performCriticalTask()
            }
            
            launch(CoroutineName("OptionalTask")) {
                performOptionalTask()
            }
            
            launch(CoroutineName("BackgroundTask")) {
                performBackgroundTask()
            }
        }
    }
    
    delay(5000)
    scope.cancel()
}
3. 결과 집계와 부분 성공 처리
suspend fun resultAggregationPattern() {
    data class TaskResult(
        val taskName: String,
        val success: Boolean,
        val result: Any? = null,
        val error: String? = null
    )
    
    val results = mutableListOf<TaskResult>()
    
    supervisorScope {
        launch {
            try {
                val result = performDataProcessing()
                results.add(TaskResult("DataProcessing", true, result))
            } catch (e: Exception) {
                results.add(TaskResult("DataProcessing", false, error = e.message))
            }
        }
        
        launch {
            try {
                val result = performImageProcessing()
                results.add(TaskResult("ImageProcessing", true, result))
            } catch (e: Exception) {
                results.add(TaskResult("ImageProcessing", false, error = e.message))
            }
        }
        
        launch {
            try {
                val result = performVideoProcessing()
                results.add(TaskResult("VideoProcessing", true, result))
            } catch (e: Exception) {
                results.add(TaskResult("VideoProcessing", false, error = e.message))
            }
        }
    }
    
    // 결과 분석
    val successfulTasks = results.filter { it.success }
    val failedTasks = results.filter { !it.success }
    
    println("작업 완료 보고서:")
    println("  성공: ${successfulTasks.size}개")
    println("  실패: ${failedTasks.size}개")
    
    if (successfulTasks.isNotEmpty()) {
        println("성공한 작업들로 서비스 제공 가능")
        processSuccessfulResults(successfulTasks)
    }
    
    if (failedTasks.isNotEmpty()) {
        println("실패한 작업들 재시도 스케줄링")
        scheduleRetryForFailedTasks(failedTasks)
    }
}
supervisorScope를 사용해야 하는 경우
- 독립적인 작업들의 병렬 처리
 - 일부 실패를 허용하는 경우
 - 최선의 노력(best effort) 방식
 - 사용자 경험을 위해 부분 결과라도 보여줘야 하는 경우
 
예시: 대시보드 로딩, 소셜 피드, 백그라운드 작업, 데이터 수집
withContext- 컨텍스트 전환을 통한 효율적 작업 분배 #
withContext는 현재 코루틴의 컨텍스트를 일시적으로 변경하여 특정 작업을 다른 환경에서 실행하는 suspend 함수이다. 주로 다른 Dispatcher로 전환하여 CPU 집약적 작업, I/O 작업, UI 업데이트 등을 적절한 스레드에서 수행할 때 사용한다.
핵심 동작 원리 #
1. 컨텍스트 임시 변경과 자동 복구
suspend fun demonstrateContextSwitching() {
    println("시작 스레드: ${Thread.currentThread().name}")
    
    // UI 스레드에서 시작했다고 가정
    val userData = withContext(Dispatchers.IO) {
        // 이 블록은 IO 스레드에서 실행
        println("IO 작업 스레드: ${Thread.currentThread().name}")
        
        // 네트워크 호출이나 파일I/O 등
        delay(1000)
        loadUserDataFromNetwork()
    }
    
    // withContext 블록이 끝나면 자동으로 원래 컨텍스트로 복귀
    println("복귀 후 스레드: ${Thread.currentThread().name}")
    
    val processedData = withContext(Dispatchers.Default) {
        // CPU 집약적 작업은 Default 스레드에서
        println("CPU 작업 스레드: ${Thread.currentThread().name}")
        
        processUserData(userData)
    }
    
    // 다시 원래 스레드로 복귀
    println("최종 스레드: ${Thread.currentThread().name}")
    updateUI(processedData)
}
private suspend fun loadUserDataFromNetwork(): String {
    // 네트워크 호출 시뮬레이션
    return "사용자 데이터"
}
private fun processUserData(data: String): String {
    // CPU 집약적 처리 시뮬레이션
    return data.uppercase()
}
private fun updateUI(data: String) {
    println("UI 업데이트: $data")
}
2. 블로킹과 논블로킹의 조화
suspend fun demonstrateBlockingIntegration() {
    println("=== 블로킹 작업 통합 예제 ===")
    
    try {
        // 메인 작업
        println("메인 작업 시작")
        
        // 블로킹 I/O 작업을 적절한 스레드에서 실행
        val fileContent = withContext(Dispatchers.IO) {
            println("파일 읽기 시작 - 스레드: ${Thread.currentThread().name}")
            
            // 블로킹 I/O 작업
            Thread.sleep(1000)  // File.readText() 시뮬레이션
            "파일 내용"
        }
        
        println("파일 읽기 완료: $fileContent")
        
        // CPU 집약적 처리
        val processedContent = withContext(Dispatchers.Default) {
            println("데이터 처리 시작 - 스레드: ${Thread.currentThread().name}")
            
            // CPU 집약적 작업
            var result = fileContent
            repeat(1000000) { // 무거운 계산 시뮬레이션
                result = result.hashCode().toString()
            }
            
            "처리된 데이터: ${result.take(10)}"
        }
        
        println("처리 완료: $processedContent")
        
        // UI 업데이트 (원래 컨텍스트에서)
        println("UI 업데이트 완료")
        
    } catch (e: Exception) {
        println("작업 실패: ${e.message}")
    }
}
3. 컨텍스트 요소 개별 변경
suspend fun demonstrateContextElementChanges() {
    val originalName = coroutineContext[CoroutineName]?.name
    println("원래 코루틴 이름: $originalName")
    
    // Dispatcher만 변경
    withContext(Dispatchers.IO) {
        val currentName = coroutineContext[CoroutineName]?.name
        println("IO 컨텍스트 - 이름: $currentName, 스레드: ${Thread.currentThread().name}")
    }
    
    // 이름만 변경
    withContext(CoroutineName("DataProcessor")) {
        val currentName = coroutineContext[CoroutineName]?.name
        println("이름 변경 - 이름: $currentName, 스레드: ${Thread.currentThread().name}")
    }
    
    // 여러 요소 동시 변경
    withContext(Dispatchers.Default + CoroutineName("HeavyComputation")) {
        val currentName = coroutineContext[CoroutineName]?.name
        println("복합 변경 - 이름: $currentName, 스레드: ${Thread.currentThread().name}")
        
        // 무거운 계산 작업
        delay(500)
    }
    
    println("원래 컨텍스트로 복귀: $originalName")
}
- 
컨텍스트 전환: newContext로 지정된 새로운 CoroutineContext (주로 Dispatcher) 위에서 { … } 블록 안의 코드를 실행한다.
 - 
현재 코루틴 중단: withContext를 호출한 현재 코루틴은 { … } 블록 안의 코드가 완료될 때까지 중단(suspend)된다. 블록의 실행이 끝나면 원래의 컨텍스트로 돌아와 이어서 실행된다.
 - 
반환 값: { … } 블록의 마지막 표현식의 결과를 반환한다.
 - 
취소 및 예외 전파: withContext는 구조화된 동시성(Structured Concurrency)의 규칙을 따른다.
만약 withContext 블록 내부에서 예외가 발생하면, 이 예외는 withContext를 호출한 부모 코루틴으로 전파된다. withContext를 호출한 부모 코루틴이 취소되면, withContext 블록 내부의 작업도 함께 취소된다. - 
새로운 코루틴을 생성하지 않음: launch나 async와 달리, withContext는 새로운 코루틴 인스턴스를 생성하지 않는다. 단지 기존 코루틴의 실행 컨텍스트를 일시적으로 전환할 뿐이다.