[ 코루틴 이란? ]
코루틴(coroutine)은 co-(함께)와 routine(루틴, 일상적인 함수 실행)의 합성어입니다.
즉, "함께 실행되는 루틴"이라는 의미로, 서로 협력하며 실행 흐름을 주고받는 함수(또는 실행 단위)를 뜻합니다.
일반 함수는 호출되면 실행을 마칠 때까지 제어권을 갖고 있다가 끝나면 호출자에게 제어권을 반환합니다.
반면 코루틴은 실행 도중에도 중단(suspend)하고, 다시 재개(resume)할 수 있는 함수입니다.
[ 탄생 배경 ]
‘코루틴’이라는 개념 자체는 현대적인 async/await, Kotlin 코루틴보다 훨씬 오래된 개념입니다
코루틴은 1960년대에 처음 등장한 개념으로, 당시에는 스택프레임을 공유하며 함수 간 제어권을 명시적으로 전환할 수 있는 구조로 설계되었습니다.
현대에 와서 코루틴은 동시성 프로그래밍을 더 쉽고 가볍게 구현하기 위해 다시 조명되었습니다.
보통 동시성을 구현하는 데 주로 두 가지 방식이 사용되었습니다.
1. 하나는 스레드 기반 방식으로, 이는 운영체제 수준에서 관리되는 무거운 리소스를 활용하기 때문에 문맥 전환 비용이 크고, 동기화 문제도 자주 발생했습니다.
2. 다른 하나는 콜백 기반의 비동기 프로그래밍 방식으로, 대표적으로 Node.js나 JavaScript의 초기 비동기 모델에서 볼 수 있었으며, 콜백이 중첩되는 구조적 한계로 인해 '콜백 지옥'이라는 문제가 빈번하게 발생했습니다.
이러한 방식들의 한계를 극복하기 위해, 비동기 코드를 마치 동기 코드처럼 읽기 쉽게 유지하면서도 높은 성능을 낼 수 있는 경량 실행 단위가 필요했고, 그 해답으로 등장한 것이 바로 코루틴입니다.
언어마다 구현 방식은 다르지만, 예를 들어 JavaScript에서는 async/await, Python에서는 async def와 await, Kotlin에서는 suspend, coroutineScope, withContext, async 같은 문법으로 코루틴을 지원합니다.
이처럼 코루틴은 언어에 따라 표현 방식은 다를지라도, 공통적으로 '중단 가능한 함수'라는 개념을 기반으로 하고 있습니다.
[ Stackful Coroutine vs Stackless Coroutine ]
코루틴은 내부 구현 방식에 따라 stackful과 stackless로 나뉩니다.
stackful 코루틴은 별도의 호출 스택을 보유하기 때문에 실행 중 언제든지 중단하고 재개할 수 있는 유연함이 있지만, 메모리 사용량이 많습니다. 자바 가상스레드가 stackful 코루틴처럼 동작합니다.
반면, stackless 코루틴은 상태 머신 기반(추후 설명)으로 동작하며, suspend 키워드와 같이 명시된 지점에서만 중단할 수 있어 상대적으로 제한적이지만 훨씬 가볍고 효율적 입니다.
Kotlin 코루틴은 stackless 방식으로 구현되어 있습니다.
[ Stackless Coroutine 상태 머신 ]
스택없이 상태 머신으로 동작한다는게 무슨 말일까요?
아래 코드처럼 동작한다고 이해하면 됩니다.
suspend fun simple() {
println("A")
delay(1000)
println("B")
delay(1000)
println("C")
}
위 함수는 Kotlin 컴파일러로 인해 suspend 키워드가 붙은 함수는 내부적으로 상태 머신형태의 Continuation 기반 클래스로 변환됩니다.
이 작업은 컴파일 타임에 수행되며, 개발자가 suspend 키워드 하나만 붙여도 컴파일러가 그 함수를 상태 머신 형태로 바꿔주는 것입니다.
위 코루틴 코드는 3단계로 나뉜 상태 머신으로 변환됩니다.
class SimpleCoroutine : Continuation<Unit> {
var label = 0
override fun resumeWith(result: Result<Unit>) {
when (label) {
0 -> {
println("A")
label = 1
delay(1000, this) // this는 다음 상태를 알고 있음
return
}
1 -> {
println("B")
label = 2
delay(1000, this)
return
}
2 -> {
println("C")
}
}
}
}
suspend 함수는 실행 도중에 일시 중단될 수 있기 때문에, 어디까지 실행되었는지를 기억할 수 있는 메커니즘이 필요합니다. 이 역할을 하는 것이 바로 label입니다.
label은 일종의 실행 위치를 나타내는 상태값으로, 함수가 어느 지점까지 실행되었는지를 숫자로 표현합니다. 예를 들어 어떤 함수가 중간에 delay() 같은 중단 가능한 지점에서 멈췄다면, 그 시점의 label 값이 갱신되어 다음 재개 시 어디서부터 다시 실행해야 할지를 결정하게 됩니다.
이후 코루틴이 재개되면 resumeWith() 메서드가 호출되며, 내부에서는 현재 label 값을 확인해 어떤 블록을 다시 실행해야 하는지 분기합니다. 이런 구조 덕분에 코루틴은 호출 스택을 저장하지 않아도, 상태를 기억하고 이어서 실행할 수 있게 됩니다.
이처럼 label과 resumeWith()의 조합은 코루틴이 중단되었다가도 정확히 이어서 동작할 수 있도록 하는 핵심 구조라고 볼 수 있습니다.
[ 코틀린-코루틴이란? ]
Kotlin의 코루틴은 'stackless coroutine'을 Kotlin 언어의 문법(suspend 키워드 등)과 컴파일러 변환을 통해 구현한 것입니다.
Java는 언어 차원에서 코루틴을 지원하지 않기 때문에 Kotlin처럼 suspend 키워드나 상태 머신 기반의 코루틴을 직접 사용할 수는 없습니다.
(하지만 자바는 코루틴 대신 Java 21 이상에서 제공되는 가상 스레드를 활용하면 됩니다.)
추가로 코루틴이라는 단어를 “코루틴은 큐에 등록되었다가 해당 스레드에서 실행되는 구조입니다.” 같이 사용합니다.
위에 설명한대로라면 코루틴은 개념인데 큐에 등록된다고? 무슨말 일까요?
코틀린 코루틴 프로젝트에서 코루틴이라는 단어는 아래와 같은 의미로 쓰입니다.
- 중단(suspend)과 재개(resume)가 가능한 실행 단위
- 스레드가 아니라 스레드 위에서 스케줄되는 단위
- Dispatcher 스레드 풀에 등록되고 실행되는 대상
- 상태 머신으로 컴파일된 로직 컨테이너
[ 코틀린-코루틴 동작 방식 ]

기존 자바 Thread 방식은 위와 같습니다.
Java의 전통적인 스레드 모델은 운영체제의 커널 스레드와 1:1로 매핑되는 구조를 가지고 있습니다. 즉, 자바에서 Thread 객체를 생성하고 start()를 호출하면, JVM은 내부적으로 운영체제에게 커널 스레드 생성을 요청하고, 이 스레드가 Java 코드 실행을 담당하게 됩니다.
예를 들어, 대표적인 자바 웹서버인 Tomcat의 경우, 기본적으로 수백 개의 스레드를 가진 스레드 풀을 유지합니다. 톰캣이 200개의 스레드를 가진다면, 이는 곧 JVM 프로세스 안에서 200개의 커널 스레드가 동시에 존재하고 있다는 의미입니다.
운영체제는 이러한 커널 스레드를 기준으로 CPU 스케줄링을 수행합니다. 즉, CPU가 어떤 작업을 실행할지 결정할 때 단위가 되는 것은 커널 스레드입니다. 이때 CPU는 여러 스레드에 짧은 시간 간격으로 CPU 사용권을 분배하면서 실행을 전환하는데, 이 과정에서 컨텍스트 스위칭 이 발생합니다. 스레드를 전환할 때마다 CPU는 해당 스레드의 레지스터, 스택 포인터, 메모리 매핑 등의 상태를 저장하고 복원해야 하며, 이는 결코 가볍지 않은 작업입니다.
스레드 수가 많아질수록 운영체제는 더 많은 스레드에게 CPU를 분배해야 하므로, 개별 스레드가 CPU를 점유할 수 있는 빈도는 줄어들게 됩니다. 동시에 컨텍스트 스위칭도 더 자주 발생하게 되어, CPU가 실제 애플리케이션 로직을 처리하기보다는 스레드 전환 자체에 자원을 더 많이 소모하는 비효율적인 상황이 벌어질 수 있습니다.
결과적으로, 전통적인 스레드 모델은 스레드 수가 늘어날수록 컨텍스트 스위칭 비용 증가와 CPU 분산 문제로 인해 성능이 저하될 수 있는 구조입니다
하지만 코루틴은 실제 OS 스레드를 직접 생성하지 않고, 하나의 스레드 위에서 여러 개의 코루틴을 실행할 수 있도록 관리됩니다.

코루틴은 애플리케이션(사용자) 수준에서 관리되며, 훨씬 낮은 오버헤드로 생성되고 전환될 수 있습니다.
위 그림은 코루틴 기반 동시성 처리 구조를 시각화한 것입니다. 코루틴은 JVM 상에서 동작하는 User Thread 위에서 실행되며, 직접 OS 커널 스레드와 1:1로 매핑되지는 않습니다. 대신 여러 개의 코루틴이 하나의 유저 스레드에 할당되어 순차적 또는 비동기적으로 실행되며, 이 유저 스레드는 다시 커널 스레드와 매핑됩니다.
즉, 실제로 CPU에서 실행되기 위해서는 코루틴이 유저 스레드 위에 바인딩되어야 하며, 해당 유저 스레드가 커널 스레드와 연결되어야 실행이 가능합니다. 이러한 구조는 다수의 코루틴이 소수의 커널 스레드 위에서 효율적으로 스케줄링되고 실행될 수 있도록 해줍니다.
코루틴은 단순히 "가벼운 스레드"가 아니라, 유저 스레드 위에서 구조적으로 협력 실행되는 상태 머신 기반의 작업 단위입니다. OS 수준의 컨텍스트 스위치 없이, 객체 상태 전환만으로도 실행 흐름을 관리할 수 있기 때문에, 블로킹 없이 일시 중단과 재개가 가능하고, 대규모 비동기 애플리케이션에서 탁월한 성능을 발휘할 수 있습니다.
[ 코틀린-코루틴 문법 간략 소개 ]
@RestController
@RequestMapping("/v1")
class BlogController(
private val testClients: List<BlogClient>,
) {
@GetMapping("/blogs/recent")
suspend fun getBlogs(
@RequestParam(defaultValue = "1") days: Long
): BlogResponse = coroutineScope {
val results = testClients.mapIndexed { index, client ->
async {
try {
delay(5000) // io작업(외부 api 호출 등)
mutableListOf("test1","test2")
} catch (e: Exception) {
emptyList()
}
}
}.map { it.await() }.flatten()
BlogResponse(results)
}
}
[ suspend ]
suspend는 “이 함수는 중간에 멈췄다가 나중에 다시 이어서 실행될 수 있다”는 뜻입니다. suspend fun은 I/O 대기 같은 지점에서 스레드를 점유하지 않고 잠깐 멈출 수 있습니다. Kotlin 코루틴 런타임이 멈췄던 지점 이후부터 이어서 실행해줍니다.
[ coroutineScope ]
coroutineScope { ... } 블록 내부에서 실행된 모든 비동기 작업이 다 끝나야 블록이 종료됩니다. 블록 안에서 실패가 발생하면, 모든 코루틴이 함께 취소됩니다. 즉 메서드 내부에서 “병렬로 async 여러 개를 실행”할 때 필요합니다. "자식 코루틴을 병렬로 실행해서 그 결과를 합쳐서 리턴"하는 패턴에서 coroutineScope는 잘 쓰입니다.
[ async, await ]
async는 코루틴 내에서 비동기 작업을 시작하는 함수입니다. 즉시 실행되지만 결과는 나중에 사용할 수 있습니다. async는 반드시 CoroutineScope 안에서만 사용할 수 있는 함수이기 때문에, coroutineScope와 함께 사용하는 경우가 많습니다
await는 async로 시작한 비동기 작업의 결과를 기다리는 함수입니다
[ Suspend ]
@GetMapping("/data")
suspend fun handle(): String
이건 실질적으로 다음처럼 변환된 함수로 볼 수 있습니다
fun handle(continuation: Continuation<String>): Any
suspend 함수를 호출하기 전까지는 일반 함수와 똑같이, 스레드가 메서드 호출마다 자신의 스택 메모리에 스택 프레임을 쌓으면서 실행이 진행됩니다. 그러다 어떤 지점에서 suspend 함수를 호출하면 흐름이 바뀝니다.
suspend 함수는 내부에 일시 중단 가능한 지점(suspension point)을 가지고 있고, 이 지점에 도달하면 현재 실행 상태(어디까지 실행됐는지, 지역 변수 등)를 Continuation이라는 객체에 저장합니다. 그러고 나서 일반 함수가 바로 리턴되는 것처럼 호출자에게 제어를 넘기고, 스레드는 스택 프레임을 비운 채 종료되거나 스레드풀로 반납됩니다.
중요한 점은, 저장된 Continuation은 그냥 방치되는 게 아니라, 코루틴 디스패처(스케줄러)가 관리하는 실행 큐에 등록됩니다. 이 디스패처는 내부적으로 여러 Continuation들을 순환하며, 조건이 충족된 작업(예: I/O 완료, 타이머 만료 등)을 골라서 다시 스레드에 할당해 실행시킵니다. 이때는 처음 사용했던 스레드가 아닐 수도 있고, 완전히 다른 스레드일 수도 있습니다.
suspend 함수는 꼭 결과를 리턴하지 않아도 됩니다. 그냥 실행하고 끝낼 수도 있고,
결과를 다른 객체에게 넘겨야 한다면, Continuation.resume(result) 같은 방식으로 전달할 수 있습니다.
이렇게 전달된 결과는 콜백처럼 다음 로직을 실행하는 객체나 흐름으로 연결될 수 있습니다.
이 구조는 Java의 Future나 CompletableFuture처럼, 작업을 시작해두고 완료되면 결과를 전달하거나 처리하는 방식과 유사합니다.
(+) Kotlin에서 suspend 키워드를 붙인 함수는 코루틴 안에서 중단 가능한 작업을 수행할 수 있다는 뜻입니다.
하지만 suspend 함수를 호출한다고 반드시 호출 스레드가 반환되는 것은 아닙니다.
suspend 함수 내부에 실제 중단 지점(suspension point) 이 존재해야만,
해당 지점에서 코루틴이 중단되고 스레드가 반환됩니다.
즉 suspend 함수내에 async, delay, withcontext 같은 중단 지점을 만나야 스레드를 반환합니다.
그렇지 않으면, 일반 함수처럼 끝까지 동기적으로 실행되며 스레드를 점유한 채 종료됩니다.
[ Dispatcher ]
앞서 suspend 함수가 중단되면 그 상태가 Continuation 객체에 저장되고, 나중에 다시 실행된다고 설명했습니다.
그런데 여기서 한 가지 궁금한 점이 생깁니다.
"다시 실행은 누가 시키는가?", "어떤 스레드에서 실행되는가?"
이 역할을 담당하는 것이 바로 코루틴 디스패처(Dispatcher) 입니다. Kotlin의 코루틴 Dispatcher는 코루틴이 어떤 스레드에서 실행될지를 결정합니다.
쉽게 말해, 자바의 ExecutorService와 유사한 구조를 갖고 있으며, 내부적으로는 스레드풀과 작업 큐를 포함하고 있습니다.
suspend 함수가 일시 중단되면, 해당 코루틴의 Continuation은 디스패처 내부의 작업 큐에 등록됩니다. 그리고 이 큐를 감시하던 워커 스레드가 순차적으로 가져와 실행합니다. 이 구조 덕분에 코루틴은 일시 중단되었다가 다른 스레드에서 안전하게 재개될 수 있습니다.
async { ... } // 현재 CoroutineScope의 디스패처 사용
async(Dispatchers.IO) { ... } // IO 전용 디스패처 사용
withContext(Dispatchers.IO) { ... } // 일시적으로 IO 디스패처에서 실행
launch(Dispatchers.IO) { ... } // IO 디스패처에서 launch
이처럼 파라미터에 Dispatchers.IO를 지정하면, 코루틴은 내부적으로 구성된 IO 전용 스레드풀에서 실행됩니다. 반대로
Dispatchers.Default 는 CPU 바운드 작업에 적합한 스레드풀을 사용합니다.
어떤 스레드에서 실행할지는 개발자가 명시적으로 선택할 수도 있고 생략하면 현재 코루틴 컨텍스트의 디스패처가 사용됩니다.
[ Spring MVC + Tomcat + Coroutine의 I/O 응답 흐름 ]
Kotlin에서 suspend 함수는 컴파일 시 내부적으로 Continuation 객체를 통해 상태를 저장하고,
결과를 비동기적으로 전달할 수 있도록 설계됩니다.
Spring MVC는 이 Continuation을 직접 생성하지는 않지만, Kotlin의 ContinuationInterceptor를 활용하여 resume 시점에 결과가 도착하면 이를 감지하고, Spring의 비동기 응답 컨텍스트(DeferredResult, WebAsyncManager)에 전달하도록 연결합니다.
이 덕분에 코루틴이 완료되면, Spring이 자동으로 응답 처리를 이어받아
HttpServletResponse를 통해 클라이언트에게 응답을 전송할 수 있게 됩니다.
흐름은 아래와 같습니다.
클라이언트 요청 (톰캣 Worker 스레드에서 수신됨)
↓
DispatcherServlet
↓
HandlerAdapter → Controller → suspend 함수 실행
↓
코루틴이 일시 중단되거나, 컨트롤러가 DeferredResult 반환
↓
Spring은 AsyncContext.startAsync() 호출로 비동기 컨텍스트 시작
→ 현재 요청은 Async 상태로 전환되고, 톰캣 스레드는 반납됨
↓
코루틴이 다른 스레드(Dispatchers.IO 등)에서 실행 완료
↓
DeferredResult.setResult(컨트롤러 메서드 결과) 호출
↓
Spring이 AsyncContext.dispatch() 호출
↓
Tomcat이 Worker 스레드 중 하나를 선택해 요청 재처리
↓
DispatcherServlet이 HttpServletResponse를 통해 응답 작성
↓
응답이 클라이언트 소켓에 write되고, AsyncContext.complete()
[ launch ]
코틀린에서 코루틴을 실행할 수 있는 대표적인 방식 중 하나가 launch입니다. launch는 결과값을 반환하지 않는 코루틴 빌더로, 주로 백그라운드에서 병렬로 실행해야 할 작업에 사용됩니다. 예를 들어 로그 기록, DB 저장, 알림 전송처럼 어떤 처리를 수행하되 그 결과를 바로 사용할 필요는 없는 경우에 적합합니다.
fun main() = runBlocking {
launch {
delay(1000)
println("launch 블록 실행 완료!")
}
println("main 끝")
}
위 코드에서 runBlocking은 메인 스레드를 블로킹하면서 코루틴 환경을 열어줍니다. 그 안에서 launch를 호출하면 별도의 코루틴이 생성되어 백그라운드에서 실행됩니다. launch 블록 안의 코드는 1초 후 실행되지만, println("main 끝")은 바로 출력됩니다. 이처럼 launch는 실행 흐름을 블로킹하지 않고 비동기적으로 동작하므로, 여러 작업을 동시에 처리하고자 할 때 유용합니다.
결론적으로 launch는 반환값이 필요 없는 작업을 비동기적으로 실행할 때 사용하면 좋으며, 메모리 낭비 없이 가벼운 코루틴을 병렬로 실행할 수 있는 좋은 도구입니다.
다만 결과를 기다리거나 예외를 처리해야 하는 경우에는 async나 try-catch와의 적절한 조합이 필요합니다.
[ async-await, withcontext ]
[ WithContext ]
suspend fun main() {
val time = measureTimeMillis {
// withContext는 현재 코루틴에서 컨텍스트(IO)만 전환하고, 순차적으로 실행됨
val a = withContext(Dispatchers.IO) {
delay(1000) // 1초 걸리는 작업
"A"
}
val b = withContext(Dispatchers.IO) {
delay(1000) // 또 1초 걸리는 작업
"B"
}
println("결과: $a + $b")
}
println("총 실행 시간: $time ms")
}
[실행 결과]
결과: A + B
총 실행 시간: 2001 ms
코틀린 코루틴에서 withContext는 흔히 Dispatchers.IO나 Dispatchers.Default와 함께 사용되어 스레드를 전환할 수 있도록 도와줍니다. withContext는 새로운 코루틴을 생성하는 것이 아니라, 현재 코루틴의 문맥(Context)만 전환한 채 순차적으로 실행됩니다.
위 예제는 delay(1000)이 두 번 순차 실행되기 때문에 전체 시간이 약 2초가 걸립니다.
만약 병렬 실행을 원한다면 async를 사용해야 합니다.
[ async-await ]
suspend fun test() {
val time = measureTimeMillis {
coroutineScope {
val a = async(Dispatchers.IO) {
delay(1000)
"A"
}
val b = async(Dispatchers.IO) {
delay(1000)
"B"
}
println("결과: ${a.await()} + ${b.await()}")
}
}
println("총 실행 시간: $time ms")
}
[실행 결과]
결과: A + B
총 실행 시간: 1001 ms
코틀린 코루틴에서는 여러 작업을 동시에 처리하고 싶을 때 async와 await 조합을 자주 사용합니다.
async는 별도의 코루틴을 생성하여 즉시 실행하는 코루틴 빌더입니다.
이때 생성된 코루틴은 지정한 Dispatcher에 등록되어, 별도의 스레드에서 병렬로 실행될 수 있습니다
위 예제에서 두 async 블록은 각각 코루틴을 생성하고 즉시 실행됩니다. Dispatchers.IO를 사용했기 때문에 두 코루틴은 서로 다른 스레드에서 병렬로 실행될 가능성이 높습니다
. 그 결과, 두 개의 1초짜리 작업이 동시에 시작되어, 총 실행 시간은 약 1초만 걸립니다.
[ WithContext 예외처리 ]
suspend fun main() {
val time = measureTimeMillis {
try {
val result = withContext(Dispatchers.IO) {
delay(500)
throw RuntimeException("withContext 내부에서 오류 발생")
}
println("결과: $result") // 실행되지 않음
} catch (e: Exception) {
println("예외 잡힘: ${e.message}")
}
}
println("총 실행 시간: $time ms")
}
[실행 결과]
예외 잡힘: withContext 내부에서 오류 발생
총 실행 시간: 500 ms
코틀린 코루틴에서 withContext는 현재 코루틴의 문맥(Context)만 바꿔서 작업을 실행하는 함수입니다.
내부적으로는 새로운 코루틴을 만들지 않고, 같은 코루틴 안에서 Dispatcher만 바꿔 실행하기 때문에 예외 전파 방식도 일반적인 함수 호출과 유사합니다.
withContext는 Dispatchers.IO로 문맥을 전환하여 블록을 실행합니다. 이때 delay(500) 이후 예외가 발생하면, 그 예외는 즉시 상위 호출자로 전파됩니다. 마치 일반적인 함수에서 예외가 던져지는 것과 같은 방식입니다.
따라서 withContext 블록은 항상 try-catch로 감싸는 것이 좋습니다. 잡지 않으면 상위 코루틴이 취소(Cancelled) 상태가 되어 전체 작업이 중단될 수 있습니다.
.
[ async-await 예외처리 ]
suspend fun test() {
val time = measureTimeMillis {
supervisorScope {
val deferred = async(Dispatchers.IO) {
delay(500)
throw RuntimeException("async 내부에서 오류 발생")
}
try {
val result = deferred.await()
println("결과: $result")
} catch (e: Exception) {
println("예외 잡힘: ${e.message}")
}
}
}
println("총 실행 시간: $time ms")
}
[실행 결과]
예외 잡힘: async 내부에서 오류 발생
총 실행 시간: 500 ms
코루틴을 사용할 때 async는 비동기 작업을 실행하고, 그 결과를 나중에 await()로 받을 수 있는 형태로 만들어줍니다. 그런데 async 내부에서 예외가 발생하면, 이 예외는 즉시 터지지 않고 Deferred 객체 안에 저장된 채 대기합니다.
즉, async는 예외를 바로 던지지 않고 숨겨놓았다가, await()를 호출하는 순간 다시 던지는 방식으로 처리합니다. 이 점은 withContext와는 확연히 다른 예외 흐름입니다.
async 블록은 즉시 실행되지만, 예외는 그 내부에서 발생해도 await()를 호출하기 전까지 밖으로 드러나지 않습니다. Deferred 객체는 일종의 결과 캡슐이고, 이 안에 성공 결과 또는 예외가 담기게 됩니다.
await()를 호출하면 이 Deferred 안에 들어있던 예외가 다시 던져지고, 이 지점에서 try-catch로 감싸서 예외를 안전하게 처리할 수 있습니다. 반대로, await()를 끝내 호출하지 않는다면, 예외는 잡히지 않고 남게 되며, 해당 코루틴은 실패 상태로 유지됩니다.
suspend fun test5() {
supervisorScope {
val deferred = async(Dispatchers.IO) {
delay(500)
throw RuntimeException("async 내부에서 오류 발생")
}
val result = deferred.await() // ❗ 예외를 잡지 않음
println("결과: $result") // 실행되지 않음
}
}
[실행 결과]
Exception in thread "main" java.lang.RuntimeException: async 내부에서 오류 발생
at ...
위처럼 async 블록 내부에서 예외가 발생하더라도, 그 예외는 즉시 호출자에게 전달되지 않습니다. 대신 Deferred 객체 안에 저장되어 있다가, await()를 호출하는 순간 그 자리에서 다시 던져집니다.
문제는 여기서 await()를 try-catch로 감싸지 않는다면, 이 예외는 처리되지 않고 상위로 전파되며, main 함수처럼 루트 스코프라면 곧바로 프로세스가 크래시됩니다.
[ Scope ]
코루틴 스코프(CoroutineScope) 는 코루틴의 생명 주기와 컨텍스트를 정의합니다.
이 스코프가 언제 시작되고, 언제 끝나며, 어떤 디스패처(스레드) 에서 실행되는지 등을 결정합니다.
코루틴은 구조적 동시성(Structured Concurrency) 을 따르기 때문에, 코루틴을 어디서 시작했는지에 따라 부모-자식 관계가 생깁니다. 부모 스코프가 취소되면 모든 자식 코루틴도 함께 취소됩니다
구조적 동시성은 코루틴이 생성되고 종료되는 생명 주기를 명확한 코드 구조 안에서 관리하자는 개념입니다. 쉽게 말해, 부모가 살아 있는 동안에만 자식 코루틴이 존재할 수 있다"는 원칙입니다.
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
launch {
// 자식1
}
launch {
// 자식2
}
}
이 경우 scope.cancel() 하면 자식1, 자식2 코루틴도 자동으로 취소됩니다.
즉 코루틴이 언제 시작되고 끝나는지 정의하며 부모-자식 계층을 형성하며, 부모가 취소되면 자식도 취소됩니다.
스코프 예로는 CoroutineScope, coroutineScope, runBlocking, supervisorScope 가 있습니다.
[ coroutineScope ]
suspend fun coroutineScope() = coroutineScope {
val a = async {
delay(500)
println("A 실행 중...")
throw RuntimeException("A 실패 발생")
}
val b = async {
delay(1000)
println("B 실행 중...")
"B 결과"
}
val resultB = b.await()
println("▶ B 완료: $resultB")
}
coroutineScope { ... }는 일시적으로 새로운 코루틴 스코프를 생성하는 suspend 함수입니다. 이 블록 안에서 실행되는 코루틴들은 모두 이 스코프에 귀속되며, 블록이 끝나기 전까지 자식 코루틴이 모두 완료되어야 스코프를 빠져나올 수 있습니다.
중요한 점은, 이 스코프 안에 있는 어느 하나의 코루틴이라도 예외가 발생하면 전체 스코프가 즉시 취소된다는 점입니다. 위 코드에서는 a.await()를 호출하지 않았음에도, a에서 예외가 발생하는 순간 coroutineScope 자체가 실패하며 b도 함께 취소됩니다.
[ supervisorScope ]
suspend fun coroutineScope() = coroutineScope {
val a = async {
delay(500)
println("A 실행 중...")
throw RuntimeException("A 실패 발생")
}
val b = async {
delay(1000)
println("B 실행 중...")
"B 결과"
}
val resultB = b.await()
println("▶ B 완료: $resultB")
}
여러 개의 비동기 작업을 병렬로 수행하다 보면, 그 중 하나가 실패하더라도 나머지는 계속 실행되기를 원하는 경우가 있습니다. 이럴 때 사용하는 것이 바로 supervisorScope입니다.
supervisorScope는 내부의 자식 코루틴 중 하나가 실패하더라도, 다른 자식 코루틴에는 영향을 주지 않고 계속 실행되도록 보장합니다. 예외가 발생한 코루틴만 종료되고, 나머지는 그대로 유지됩니다.
다만 주의할 점은, 실패한 코루틴의 예외를 직접 처리하지 않으면 supervisorScope 바깥으로 예외가 전파된다는 것입니다. 따라서 각 async 내부 혹은 await() 시점에서 반드시 try-catch로 예외를 감싸야 합니다. 그렇지 않으면 전체 실패로 간주될 수 있습니다.
coroutineScope 내부에서는 자식 코루틴 중 하나가 예외를 던지면, 예외를 try-catch로 잡더라도 전체 scope가 즉시 취소됩니다.
즉, 에러가 발생하면 모든 자식 코루틴이 즉시 취소되는 구조입니다.
반면 supervisorScope는 개별 코루틴의 실패가 전체 스코프의 실패로 이어지지 않도록 설계되어 있습니다.
예외를 적절히 처리해 주기만 하면, 다른 코루틴은 정상적으로 끝날 수 있습니다.
[ runBlocking ]
Kotlin에서 코루틴을 사용하려면 suspend 함수 안에서 호출해야 합니다. 하지만 main 함수나 테스트 코드처럼 suspend 함수가 아닌 일반 함수 안에서 코루틴을 써야 하는 경우도 많습니다. 이럴 때 사용하는 것이 바로 runBlocking입니다.
runBlocking {
// 내부 CoroutineScope 생성
launch {
// 이 블록도 코루틴
}
}
// 바깥 스레드는 위 코루틴들이 끝날 때까지 대기 (Blocking)
runBlocking은 코루틴을 실행하면서도, 호출한 스레드를 블로킹(blocking)시켜 결과를 기다립니다.
위 코드에서는 runBlocking 블록 안에서 launch로 코루틴을 실행한 뒤, 해당 코루틴이 끝날 때까지 main 스레드는 대기합니다. 즉, 코루틴을 마치 일반 함수처럼 동기적으로 실행할 수 있게 해주는 도구입니다.
이런 특성 덕분에 runBlocking은 JUnit 테스트 코드에서 코루틴을 테스트할 때 자주 사용됩니다. suspend 함수나 비동기 코드를 동기적으로 실행해 테스트 흐름을 제어할 수 있기 때문입니다.
하지만 주의해야 할 점도 있습니다. runBlocking은 내부에서 코루틴을 실행하면서 호출한 스레드를 멈춰 세우기 때문에, 실제 애플리케이션 서비스 코드에서는 사용하면 안 됩니다. 예를 들어, 웹 서버의 요청 처리 흐름이나 이벤트 루프 안에서 runBlocking을 사용하면 성능 저하나 데드락 문제가 발생할 수 있습니다.
'Server' 카테고리의 다른 글
| 프로세스 종료와 시그널 (6) | 2025.08.02 |
|---|---|
| Java의 BLOCKED 상태와 I/O 대기로 인한 일시정지 상태 (0) | 2025.07.12 |
| java 다이렉트 버퍼에 대하여 (2) | 2025.07.11 |
| Multi Plexing 에 대하여 (0) | 2025.07.07 |
