최근 비동기 처리를 구현하면서 코루틴의 내부 동작과 활용법을 깊이 있게 검토해 보았습니다. 단순히 API를 사용하는 수준을 넘어, 코루틴의 작동 원리와 최신 Java 21 가상 스레드(Virtual Thread)와의 차이점, 그리고 실무적인 예외 처리 전략을 중심으로 학습한 내용을 정리해 보았습니다.
1. 동시성 메커니즘의 비교 (Thread vs Coroutine vs Virtual Thread)
효율적인 동시성 제어를 위해서는 각 메커니즘이 자원을 어떻게 관리하는지 파악하는 것이 우선이라고 생각합니다. 관리 주체와 비용에 따른 차이를 다음과 같이 정리했습니다.
| 구분 | 스레드 (Platform) | 가상 스레드 (Java 21) | 코루틴 (Kotlin) |
| 관리 주체 | OS 커널 | JVM (Project Loom) | Kotlin 런타임 |
| 메모리 점유 | 약 1MB (Stack) | 수 KB (Heap) | 수 KB (객체) |
| 스위칭 비용 | 커널 레벨 (고비용) | 유저 레벨 (저비용) | 라이브러리 레벨 (최저비용) |
| 실행 모델 | 선점형 (Preemptive) | 선점형 (JVM 스케줄링) | 협력적 (Cooperative) |
- 플랫폼 스레드: OS 자원을 직접 사용하므로 생성 개수에 제약이 있고, 컨텍스트 스위칭 시 발생하는 커널 개입 오버헤드가 컸습니다.
- 코루틴: suspend 지점에서 상태를 저장(Continuation)하고 스레드 점유를 해제합니다. 스레드를 차단(Blocking)하지 않고 양보한다는 점이 핵심입니다.
- 가상 스레드: 기존의 동기식 블로킹 API를 그대로 사용하더라도, I/O 발생 시 JVM이 해당 스레드를 마운트 해제(Unmount)하여 대규모 병렬 처리를 지원해 주었습니다.
2. 코루틴 입문: 필수 개념
2.1 suspend와 CPS 원리
suspend 함수는 코루틴 내부에서만 호출 가능하며, 실행을 일시 중단했다가 재개할 수 있는 특징이 있습니다. 컴파일러는 이를 CPS(Continuation Passing Style) 구조로 변환하여, 중단 시점의 상태를 객체로 저장하고 재개 시 이를 활용하도록 구현해 주었습니다.
import kotlinx.coroutines.delay
suspend fun doSomethingUseful() {
println("작업을 시작합니다.")
// 1초 동안 코루틴을 중단합니다.
// 이때 실행 중이던 스레드는 차단되지 않고 다른 작업을 수행할 수 있습니다.
delay(1000L)
println("작업이 완료되었습니다.")
}
2.2 코루틴 빌더: launch vs async
- launch: 새로운 코루틴을 실행하고 Job 객체를 반환합니다. 결과값이 필요 없는 비동기 작업에 적합했습니다.
- async: 결과를 반환하는 코루틴을 실행하며 Deferred<T>를 반환합니다. await()를 호출하여 결과가 도출될 때까지 대기할 수 있었습니다.
- runBlocking: 내부 코루틴이 완료될 때까지 현재 스레드를 차단합니다. 주로 테스트 환경이나 메인 함수의 진입점에서만 사용을 권장했습니다.
3. 구조화된 동시성 (Structured Concurrency)
코루틴은 계층 구조를 통해 생명주기를 엄격하게 관리했습니다. 부모 스코프가 취소되면 자식 코루틴까지 자동으로 정제되므로, 자원 누수를 효과적으로 방지할 수 있었습니다.
fun main() = runBlocking {
val job = launch {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
// 부모가 취소되면 자식도 이 블록으로 진입하여 자원을 정리합니다.
println("자식 코루틴이 취소되었습니다.")
}
}
println("부모 코루틴 작업 중...")
}
delay(500L)
println("부모 코루틴 취소 요청")
job.cancelAndJoin() // 부모를 취소함으로써 전체 계층 구조를 안전하게 정리했습니다.
}
4. 실전 디스패처(Dispatchers) 전략
작업의 성격에 따라 코루틴을 실행할 스레드 풀을 적절히 선택하는 것이 중요했습니다.
- Dispatchers.Main: UI 업데이트가 필요한 작업에 사용했습니다. (Android 등)
- Dispatchers.IO: 네트워크 통신, DB 접근, 파일 입출력 등 I/O 작업에 최적화되어 있었습니다.
- Dispatchers.Default: 복잡한 연산이나 리스트 정렬 등 CPU 집중 작업에 최적화되어 있었습니다.
suspend fun fetchData() = withContext(Dispatchers.IO) {
// I/O 최적화 스레드 풀에서 작업을 수행한 뒤 원래 컨텍스트로 복귀했습니다.
"Processed Result"
}
5. 고급 제어 및 가상 스레드 통합
5.1 SupervisorJob의 활용
일반적인 Job은 자식 중 하나가 실패하면 전체 계층이 취소되었습니다. 반면 supervisorScope를 사용하면 자식의 실패가 부모나 다른 형제에게 전파되지 않도록 격리할 수 있었습니다. 특정 독립 작업의 예외가 전체 시스템에 영향을 주지 않아야 할 때 유용했습니다.
5.2 Java 가상 스레드(Virtual Threads)와의 통합
Kotlin 코루틴 디스패처를 Java 21의 가상 스레드로 설정해 보았습니다. 기존의 블로킹 라이브러리를 활용하면서도 동시성 성능을 끌어올릴 수 있는 효율적인 조합이었습니다.
import java.util.concurrent.Executors
import kotlinx.coroutines.*
val VTDispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()
fun main() = runBlocking {
withContext(VTDispatcher) {
println("현재 실행 스레드: ${Thread.currentThread().name} (Virtual Thread)")
// 가상 스레드 위에서 코루틴을 실행하여 I/O 성능을 극대화했습니다.
}
VTDispatcher.close()
}
6. 데이터 통신: 채널(Channel)
코루틴 간 데이터를 주고받을 때는 Channel을 사용했습니다. send와 receive 모두 중단 함수로 동작하여 안전한 통신이 가능했습니다.
fun main() = runBlocking {
val channel = Channel<Int>()
launch { // 생산자
for (x in 1..3) channel.send(x * x)
channel.close()
}
launch { // 소비자
for (y in channel) println("수신된 데이터: $y")
}
}
마치며
코루틴을 직접 다뤄보며 느낀 점은, 단순히 비동기를 구현하는 것을 넘어 비동기 코드를 동기 코드처럼 직관적으로 읽히게 만든다는 철학이 인상적이었습니다. Java 가상 스레드라는 새로운 대안이 나왔지만, Kotlin 환경에서 구조화된 동시성이 주는 안정성은 여전히 큰 장점이라고 생각합니다. 도구의 특성을 정확히 이해하고 상황에 맞게 조합하는 역량이 중요하다는 것을 다시 한번 체감했습니다.
'프로그래밍 언어 > Kotlin' 카테고리의 다른 글
| Build (2) | 2025.04.25 |
|---|---|
| Companion Object (4) | 2025.04.24 |
| Nested Class, Inner Class (0) | 2025.04.24 |
| Null 처리 (2) | 2025.04.23 |