suspend 함수는 정말 함수일까? #
suspend fun fetchUserData(): User {
val user = apiCall()
delay(1000)
return user
}
겉보기에는 평범한 함수처럼 보인다 하지만 정말 함수일까? 함수라면 호출하면 끝까지 실행되어야 한다. 중간에 멈출 수 없고, 지역변수는 함수가 끝나면 사라져야 한다. 그런데 이 suspend 함수는…
- delay(1000) 호출 시 1초간 멈춘다
- 그동안 다른 코루틴이 실행된다
- 1초 후 정확히 그 지점부터 재개된다
- user 변수는 그대로 살아있다
이것이 과연 우리가 알고 있는 “함수"의 정의에 맞을까?
더 놀라운 건, 같은 함수가 여러 스레드에서 실행될 수 있다는 점이다
이것은 일반적인 함수로는 불가능한 일이다. 그렇다면 suspend 함수는 정말 함수가 아닌 완전히 다른 무언가인 걸까?
이 글에서는 겉보기에는 함수 같지만 실제로는 전혀 다른 존재인 suspend 함수의 정체를 밝혀보자.
과연 코틀린 컴파일러가 우리 코드를 어떻게 변환시키는 걸까?
함수의 상식을 뒤 엎는 4가지 현상 #
발견의 시작: 함수처럼 보이지만 함수가 아닌 존재 #
함수는 가장 기본적이고 예측가능해야되는 존재인데, 코틀린의 코루틴을 관찰하던 중, 기존 함수의 법칙을 완전히 뒤엎는 함수의 중단과 재개가 가능한 현상들이 발견되었다. 기존 함수들의 법칙들을 비교하여 코루틴에서 관찰된 이상현상에 대해서 탐구해보자.
// 일반 함수 - 예측가능한동작
fun normalFunction(): String {
val result = compute()
return result
}
// suspend함수 -
suspend fun coroutineFunction(): String {
val result = compute()
delay(1000)// 여기서 무언가 이상한일이….?
return result
}
기존 함수의 법칙들 #
-
법칙 1: 연속성의 원리
관찰: 함수는 호출되면 끝까지 연속적으로 실행된다
증명: 중간에 멈출 수 없으며, 실행이 시작되면 반드시 완료되거나 예외로 종료된다 -
법칙 2: 상태 소멸의 원리
관찰: 지역변수는 스택에 저장되고, 함수 종료 시 모든 상태가 소멸된다
증명: 함수가 끝나면 해당 스택 프레임의 모든 정보가 영원히 사라진다 -
법칙 3: 단일 진입점의 원리
관찰: 함수는 하나의 진입점을 가지며, 한 번 종료되면 다시 중간부터 실행될 수 없다
증명: 함수 호출은 항상 첫 번째 줄부터 시작된다 -
법칙 4: 스레드 일관성의 원리
관찰: 함수는 호출된 스레드에서 시작해서 같은 스레드에서 종료된다
증명: 실행 중 스레드가 바뀔 수 없다
현상 1: 함수의 “일시정지” 현상 #
suspend fun impossiblePause() {
println("실행 시작...")
delay(2000) // ← 여기서 함수가 "사라짐"
// 2초 후 갑자기 "다시 나타남"
println(“재개됨!") // 함수가 나타났다
}
-> 실험결과
//실행 시작...
//(2초 동안 아무 일도 일어나지 않음)
//재개됨!
기존 법칙 위반: 연속성의 원리를 정면으로 위반
- 함수가 중간에 멈춤
- 2초 동안 존재하지 않았다가 다시 나타남
- 하지만 완전히 종료된 것도 아님
-> 결론: 함수가 “일시정지” 상태에 있었다!
현상 2: 지역변수의 불멸 현상 #
suspend fun immortalVariables() {
val secrets = mutableListOf<String>()
var counter = 0
repeat(3) { round ->
secrets.add("비밀 $round")
counter++
println("Round $round: secrets=${secrets.size}, counter=$counter")
delay(1000) // ← 함수 "소멸" 지점
// 재개 후에도 변수들이 살아있다!
println("부활! secrets는 여전히 ${secrets.size}개")
}
println("최종 결과: $secrets")
}
-> 실험결과
//Round 0: secrets=1, counter=1
//부활! secrets는 여전히 1개
//Round 1: secrets=2, counter=2
//부활! secrets는 여전히 2개
//Round 2: secrets=3, counter=3
//부활! secrets는 여전히 3개
//최종 결과: [비밀 0, 비밀 1, 비밀 2]
기존 법칙 위반: 상태 소멸의 원리를 정면으로 위반
- 함수가 소멸했는데도 지역변수가 살아남음
- 스택 프레임이 사라졌는데도 상태가 보존됨
-> 결론: 지역변수가 함수의 “죽음"을 초월하여 생존한다!
현상 3: 다중 진입점 현상 #
suspend fun multipleEntryPoints() {
println("진입점 1: 함수 시작")
delay(500)
println("진입점 2: 첫 번째 재개") // 여기서 다시 "진입"
delay(500)
println("진입점 3: 두 번째 재개") // 또 다른 "진입"
println("최종 종료")
}
-> 실험결과
//진입점 1: 함수 시작
//(500ms 후)
//진입점 2: 첫 번째 재개
//(500ms 후)
//진입점 3: 두 번째 재개
//최종 종료
기존 법칙 위반: 단일 진입점의 원리를 정면으로 위반
- 함수가 여러 번 재진입됨
- 중간 지점부터 실행이 재개됨
-> 결론 : 함수가 여러 개의 “진입문"을 가지고 있다!
현상 4: 스레드 순간이동 현상 #
suspend fun threadTeleportation() {
println("위치 1 - Thread: ${Thread.currentThread().name}")
delay(100) // ← 순간이동 발생 지점
println("위치 2 - Thread: ${Thread.currentThread().name}")
delay(100) // ← 또 다른 순간이동
println("위치 3 - Thread: ${Thread.currentThread().name}")
}
-> 실험결과
//위치 1 - Thread: DefaultDispatcher-worker-1
//위치 2 - Thread: DefaultDispatcher-worker-3 // 순간이동!
//위치 3 - Thread: DefaultDispatcher-worker-2 // 또 순간이동
기존 법칙 위반: 스레드 일관성의 원리를 정면으로 위반
- 함수가 실행 중에 다른 스레드로 “순간이동”
- 연속된 코드인데도 다른 스레드에서 실행됨
-> 결론 : 함수가 스레드 간을 자유롭게 이동한다!
원인의 분석: 코틀린 컴파일러의 마법같은 변환 #
suspend 함수가 보여주는 네가지 상식을 뒤 엎는 현상들에 왜 이런일이 발생하는걸까?
그 원인을 찾기위해 코틀린 컴파일러의 역할인 코루틴의 핵심 매커니즘인 상태머신변환과 CPS 변환에 대해 알아보려고 한다
CPS 와 상태머신을 이해한다면 코루틴의 중단과 재개 매커니즘의 비밀을 풀어보자.
상태머신으로의 변환 자세히 알아보기 #
suspend 함수가 어떻게 상태머신으로 변환되는지 구체적으로 살펴보자. 이 과정은 코루틴의 핵심 메커니즘이며, 효율적인 일시중단과 재개를 가능하게 한다.
코틀린 컴파일러는 suspend 함수를 발견하면 일련의 변환 과정을 거쳐 상태머신으로 변환한다. 이 과정은 여러 단계로 나뉘어 진행되며, 각 단계에서 서로 다른 최적화와 변환이 적용된다.
1단계: 중단 지점 식별
컴파일러는 먼저 함수 내에서 실제로 중단이 발생할 수 있는 지점들을 찾는다. 모든 suspend 함수 호출이 실제로 중단을 일으키는 것은 아니기 때문에, 컴파일러는 정적 분석을 통해 실제 중단 가능성을 판단한다.
suspend fun analyzeMe(): String {
val a = normalFunction() // 중단 없음
val b = suspendFunction() // 중단 가능
val c = inlineFunction() // 인라인 후 분석
val d = conditionalSuspend() // 조건부 중단
return "$a$b$c$d"
}
2단계: 제어 흐름 그래프 생성
중단 지점을 기준으로 함수의 실행 흐름을 상태로 나눈다
상태 0: 함수 시작 ~ 첫 번째 중단 지점
상태 1: 첫 번째 재개 ~ 두 번째 중단 지점
상태 2: 두 번째 재개 ~ 함수 종료
3단계: 지역 변수 생명주기 분석
각 중단 지점에서 어떤 지역 변수들이 재개 시에도 필요한지 분석한다. 이는 상태머신 객체에 저장해야 할 필드를 결정하는 중요한 단계다.
suspend fun variableAnalysis(): String {
val persistent = "needed later" // 상태머신 필드로 저장 필요
val temporary = compute() // 중단 지점 이후 사용되지 않으면 저장 불필요
val result = suspendCall() // 중단 지점
return "$persistent-$result" // persistent 변수가 재개 후 사용됨
}
4단계: 상태머신 클래스 생성
분석 결과를 바탕으로 실제 상태머신 클래스를 생성한다. 이 클래스는 ContinuationImpl을 상속받으며, 함수의 실행 상태와 지역 변수들을 필드로 포함한다.
상태머신 구조 분석 #
생성된 상태머신의 구조를 자세히 살펴보면, 코루틴의 효율성과 안전성이 어떻게 보장되는지 이해할 수 있다.
class SuspendFunctionStateMachine : ContinuationImpl {
// 상태 관리
var label: Int = 0
// 원본 함수의 매개변수들
var param1: String
var param2: Int
// 중단 지점에서 보존해야 할 지역 변수들
var localVar1: String? = null
var localVar2: List<String>? = null
// 중간 결과값들
var temp1: Any? = null
var temp2: Any? = null
override fun invokeSuspend(result: Result<Any?>): Any? {
// 예외 처리: 이전 suspend 호출에서 예외가 발생했다면 전파
val exception = result.exceptionOrNull()
if (exception != null) {
throw exception
}
when (label) {
0 -> { /* 첫 번째 상태 로직 */ }
1 -> { /* 두 번째 상태 로직 */ }
2 -> { /* 세 번째 상태 로직 */ }
else -> throw IllegalStateException()
}
}
}
주요 구성 요소
- label 필드: 현재 상태를 나타내는 정수값. 재개 시 어느 지점부터 실행할지 결정
- 매개변수 필드들: 원본 함수의 모든 매개변수를 보존
- 지역 변수 필드들: 중단 지점 이후에도 사용되는 지역 변수들만 선별적으로 저장
- 임시 결과 필드들: 복잡한 표현식의 중간 계산 결과 보존
메모리 최적화 전략
컴파일러는 메모리 사용량을 최소화하기 위해 여러 최적화를 적용한다
- 변수 재사용: 생명주기가 겹치지 않는 변수들은 같은 필드를 공유
- 타입 소거: 제네릭 타입 정보를 제거하여 필드 수 감소
- 널 초기화: 사용이 끝난 객체 참조를 즉시 null로 설정하여 GC 지원
label과 제어 흐름 관리
상태머신의 핵심은 label 필드와 이를 기반으로 한 분기 처리다. 이는 when 표현식으로 구현되며, 각 분기는 하나의 실행 상태를 나타낸다.
복잡한 제어 흐름 예시
suspend fun complexExample(): String {
val list = mutableListOf<String>()
for (i in 0..2) {
val item = fetchItem(i) // 중단 지점 1, 2, 3
list.add(item)
}
val processed = processAll(list) // 중단 지점 4
return processed
}
위 함수는 다음과 같은 상태머신으로 변환된다
class ComplexExampleStateMachine : ContinuationImpl {
var label: Int = 0
var list: MutableList<String>? = null
var i: Int = 0
var item: String? = null
override fun invokeSuspend(result: Result<Any?>): Any? {
when (label) {
0 -> {
// 초기 상태: 반복문 시작
list = mutableListOf()
i = 0
label = 1
return fetchItem(i, this)
}
1, 2, 3 -> {
// 반복문 내부: fetchItem 결과 처리
item = result.getOrThrow() as String
list!!.add(item!!)
i++
if (i <= 2) {
label = i + 1 // 다음 반복을 위한 라벨 증가
return fetchItem(i, this)
} else {
label = 4
return processAll(list!!, this)
}
}
4 -> {
// 최종 상태: processAll 결과 처리
val processed = result.getOrThrow() as String
return processed
}
else -> throw IllegalStateException("Invalid label: $label")
}
}
}
예외 처리와 상태 관리 #
try-catch 블록은 상태머신에서 특별한 처리가 필요하다. 컴파일러는 예외 처리 스택을 별도로 관리하여, 중단 중에 예외가 발생해도 올바른 catch 블록으로 제어가 전달되도록 보장한다.
예외 처리가 포함된 suspend 함수
suspend fun exceptionHandling(): String {
try {
val result = riskyOperation() // 중단 지점 1
return result
} catch (e: IllegalArgumentException) {
val recovery = handleError(e) // 중단 지점 2
return recovery
} finally {
cleanup() // 중단 지점 3 (suspend 함수인 경우)
}
}
변환된 상태머신
class ExceptionHandlingStateMachine : ContinuationImpl {
var label: Int = 0
var exceptionState: Int = 0 // 예외 처리 상태 추가
var result: String? = null
override fun invokeSuspend(result: Result<Any?>): Any? {
var exception = result.exceptionOrNull()
// 예외 처리 로직
if (exception != null) {
when (exceptionState) {
0 -> {
// try 블록에서 예외 발생
if (exception is IllegalArgumentException) {
label = 2 // catch 블록으로 이동
exceptionState = 1
exception = null // 예외 처리됨
} else {
// 처리되지 않은 예외는 상위로 전파
throw exception
}
}
1 -> {
// catch 블록에서 예외 발생
label = 3 // finally 블록으로 이동
// 예외는 보존하여 finally 후 다시 던짐
}
}
}
when (label) {
0 -> {
// try 블록 시작
exceptionState = 0
label = 1
return riskyOperation(this)
}
1 -> {
// try 블록 성공
val successResult = result.getOrThrow() as String
label = 3 // finally로 이동
return cleanup(this)
}
2 -> {
// catch 블록
exceptionState = 1
label = 3
return handleError(exception as IllegalArgumentException, this)
}
3 -> {
// finally 블록 또는 최종 반환
if (exceptionState == 1 && exception != null) {
// catch 블록에서 처리되지 않은 예외가 있다면 던짐
throw exception
}
// 정상 결과 반환
return this.result ?: result.getOrThrow() as String
}
else -> throw IllegalStateException("Invalid label: $label")
}
}
}
예외 전파의 핵심 원리
- Result 타입 활용: resumeWith(Result.failure(exception))로 예외 전달
- 예외 상태 추적: exceptionState 필드로 현재 예외 처리 단계 관리
- 예외 보존: catch되지 않은 예외는 finally 블록 이후에도 보존되어 상위로 전파
- 중단 중 예외: suspend 함수 호출 중 발생한 예외도 동일한 메커니즘으로 처리
예외와 취소의 구분
override fun invokeSuspend(result: Result<Any?>): Any? {
val exception = result.exceptionOrNull()
when {
exception is CancellationException -> {
// 코루틴 취소 - 특별 처리
throw exception
}
exception != null -> {
// 일반 예외 - 상태머신 로직에 따라 처리
handleException(exception)
}
else -> {
// 정상 결과 처리
handleSuccess(result.getOrThrow())
}
}
}
이런 복잡한 예외 처리 구조도 컴파일러는 정확한 label과 예외 상태 관리를 통해 상태머신으로 변환한다.
각 중단 지점마다 고유한 label이 할당되고, 재개 시 정확한 실행 지점과 예외 처리 상태가 복원된다.
Continuation 이란 무엇인가? #
Continuation은 “계속될 작업"을 의미하는 프로그래밍 개념이다. 쉽게 말해, 현재 실행 중인 코드가 중단된 후 어떻게 재개할지를 나타내는 객체라고 볼 수 있다.
코루틴 컨텍스트에서 Continuation은 다음과 같은 역할을 한다
- 재개 방법 정의:
resumeWith()함수로 결과나 예외를 받아 처리 - 실행 환경 제공:
CoroutineContext로 스레드, 디스패처 등의 실행 환경 관리 - 콜백 역할: 비동기 작업 완료 시 호출될 콜백 인터페이스 제공
코루틴 컨텍스트에서 Continuation은 다음과 같은 정보를 포함한다
- 재개될 때 실행할 코드의 위치
- 중단 시점의 실행 상태 (로컬 변수, 매개변수 등)
- 재개를 위한 콜백 함수 (resumeWith)
Continuation 인터페이스
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
- 코루틴 컨텍스트 (context): 실행 환경 정보
- 재개를 위한 콜백 함수 (resumeWith): 결과나 예외를 받아 처리
실제 재개될 때 실행할 코드의 위치와 중단 시점의 실행 상태는 Continuation을 구현한 상태머신 클래스가 관리한다
왜 Continuation을 매개변수로 받는가? #
Continuation을 매개변수로 받는 이유는 단순히 “콜백” 때문이 아니라, 코루틴의 전체 실행 환경을 관리하기 위해서다
- 언제 재개할지 (비동기 완료 시점)
- 어디서 재개할지 (어떤 스레드/Dispatcher)
- 어떻게 재개할지 (성공/실패 처리)
- 취소되었을 때 어떻게 할지
이 모든 것을 하나의 객체로 캡슐화한 것이 바로 Continuation이다.
Continuation Passing Style (CPS) 변환 #
CPS 변환은 일반적인 함수 호출 방식을 “다음에 실행할 작업을 매개변수로 전달하는 방식"으로 바꾸는 변환 기법이다. 코루틴에서는 이를 통해 함수의 일시중단과 재개를 구현한다.
실제로 다음과 같은 suspend 함수가 있다면
suspend fun fetchData(): String {
delay(1000)
return "data"
}
컴파일러는 다음과 같이 변환한다(CPS변환).
fun fetchData(continuation: Continuation<String>): Any? {
val stateMachine = if (continuation is FetchDataStateMachine) {
continuation
} else {
FetchDataStateMachine(continuation)
}
return stateMachine.invokeSuspend(Result.success(Unit))
}
결과를 continuation에 전달을 하는데 비동기 함수는 즉시 값을 반환할 수 없으므로
콜백으로 나중에 값을 받았을때 무엇을 전달해야한다. -> 그래서 Continuation
CPS 변환 과정 #
코틀린 컴파일러는 suspend 함수를 CPS로 변환할 때 다음 단계를 거친다
- suspend 함수 분석 및 중단 지점 식별 함수 내에서 다른 suspend 함수를 호출하는 지점들을 찾아 중단 지점으로 식별한다.
- 상태머신 클래스 생성
- 지역 변수와 실행 상태를 관리할 상태머신 클래스 생성
- 각 중단 지점을 하나의 상태(label)로 정의
- 실제 실행 로직을 상태머신의 invokeSuspend 메서드에 구현
- 함수 시그니처 변환(CPS)
- 모든 suspend 함수에 Continuation 매개변수 추가
- 함수의 반환 타입을 Any?로 변경
- 함수 본문 변환
- 원래 함수로직을 상태머신 호출로 대체
- 상태머신 인스턴스 생성 및 invokeSuspend 호출
- 반환값 처리 방식 변경
- 즉시 완료 시: 실제 값 반환
- 중단 시: COROUTINE_SUSPENDED 특수 값 반환
- 호출 지점 변환
- suspend 함수 호출을 continuation 전달 방식으로 변환
- 체인 형태의 continuation 구조 형성
컴파일 타임에서 실제코드 변환예시 #
구체적인 예시를 통해 CPS 변환 과정을 살펴보자. 다음과 같은 간단한 suspend 함수가 있다고 가정하자
suspend fun example(): String {
val a = fetchA()
val b = fetchB()
return "$a-$b"
}
suspend fun fetchA(): String {
delay(100)
return "A"
}
suspend fun fetchB(): String {
delay(100)
return "B"
}
1단계: 각 함수를 상태머신으로 변환 #
example 함수에서 fetchA()와 fetchB() 호출 지점이 중단 지점이다.
2단계: 상태머신 클래스 생성 #
fetchA의 상태머신
class FetchAStateMachine(
private val completion: Continuation<String>
) : ContinuationImpl {
var label: Int = 0
override fun invokeSuspend(result: Result<Any?>): Any? {
when (label) {
0 -> {
label = 1
return delay(100, this) // this를 continuation으로 전달
}
1 -> {
// delay 완료 후
completion.resumeWith(Result.success("A"))
return COROUTINE_SUSPENDED
}
else -> throw IllegalStateException()
}
}
}
example 함수의 상태머신
class ExampleStateMachine(
private val completion: Continuation<String>
) : ContinuationImpl {
var label: Int = 0
var a: String? = null
var b: String? = null
override fun invokeSuspend(result: Result<Any?>): Any? {
when (label) {
0 -> {
// 첫 번째 상태: fetchA() 호출
label = 1
val fetchAResult = fetchA(this)
if (fetchAResult == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
// fetchA가 즉시 완료된 경우 (일반적이지 않음)
return invokeSuspend(Result.success(fetchAResult))
}
1 -> {
// fetchA() 완료 후
a = result.getOrThrow() as String
label = 2
val fetchBResult = fetchB(this)
if (fetchBResult == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
// fetchB가 즉시 완료된 경우
return invokeSuspend(Result.success(fetchBResult))
}
2 -> {
// fetchB() 완료 후
b = result.getOrThrow() as String
val finalResult = "$a-$b"
completion.resumeWith(Result.success(finalResult))
return COROUTINE_SUSPENDED
}
else -> throw IllegalStateException("Invalid state: $label")
}
}
}
3단계: 함수 시그니처 변환 #
fun fetchA(continuation: Continuation<String>): Any? {
val stateMachine = if (continuation is FetchAStateMachine) {
continuation
} else {
FetchAStateMachine(continuation)
}
return stateMachine.invokeSuspend(Result.success(Unit))
}
fun example(continuation: Continuation<String>): Any? {
val stateMachine = if (continuation is ExampleStateMachine) {
continuation
} else {
ExampleStateMachine(continuation)
}
return stateMachine.invokeSuspend(Result.success(Unit))
}
런타임에서 실제동작흐름 #
이제 컴파일된 상태머신이 런타임에서 어떻게 동작하는지 메모리 상태 변화와 함께 자세히 살펴보겠다.
1단계 초기 실행시작 #
runBlocking {
val start = example() // ← 여기서 시작
}
2단계 exmaple() 함수호출 #
fun example(continuation: Continuation<String>): Any? {
val stateMachine = if (continuation is ExampleStateMachine) {
continuation // 재개 시 에는 기존 인스턴스 재사용
} else {
ExampleStateMachine(continuation) // 새 인스턴스 생성
}
return stateMachine.invokeSuspend(Result.success(Unit))
}
메모리 상태변화
Heap:
├── RunBlockingContinuation
└── ExampleStateMachine ← 새로 생성!
├── label: 0
├── a: null
├── b: null
├── completion: RunBlockingContinuation
└── 메타데이터
3단계 ExampleStateMachine 실행 (label = 0) #
0 -> {
// 첫 번째 상태: fetchA() 호출
label = 1 // 다음 재개 시점 미리설정
val fetchAResult = fetchA(this) // ExampleStateMachine 인스턴스 넘기기
if (fetchAResult == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
// fetchA가 즉시 완료된 경우 (일반적이지 않음)
return invokeSuspend(Result.success(fetchAResult))
}
label 1로 설정 -> fetchA 호출
메모리 상태 업데이트
Heap:
ExampleStateMachine:
├── label: 1 ← 변경! (fetchA 완료 후 재개할 지점)
├── a: null
├── b: null
├── completion: RunBlockingContinuation
4단계 fetchA() 함수 실행 #
fun fetchA(continuation: Continuation<String>): Any? {
// continuation = ExampleStateMachine 인스턴스
val stateMachine = if (continuation is FetchAStateMachine) {
continuation
} else {
FetchAStateMachine(continuation) // ← 새 인스턴스 생성!
}
return stateMachine.invokeSuspend(Result.success(Unit))
}
메모리 상태변화
Heap:
├── RunBlockingContinuation
├── ExampleStateMachine (label: 1, a: null, b: null)
└── FetchAStateMachine ← 새로 생성!
├── label: 0
├── completion: ExampleStateMachine ← continuation 체인!
└── 메타데이터
5단계: FetchAStateMachine 실행 (label = 0) #
when (label) {
0 -> {
label = 1 // delay 완료 후 재개할 지점 설정
return delay(100, this) // this = FetchAStateMachine
}
}
메모리 상태 업데이트
Heap:
├── RunBlockingContinuation
├── ExampleStateMachine (label: 1, a: null, b: null)
└── FetchAStateMachine
├── label: 1 ← 변경! (delay 완료 후 재개 지점)
├── completion: ExampleStateMachine
6단계: delay(100) 함수 실행 - 실제 중단 발생! #
delay 함수의 내부 구현
suspend fun delay(timeMillis: Long): Unit = suspendCancellableCoroutine { cont ->
// cont = FetchAStateMachine 인스턴스
// 타이머 작업 생성
val resumeTask = Runnable {
cont.resumeWith(Result.success(Unit)) // 100ms 후 실행될 작업
}
// 스케줄러에 등록
DefaultDelay.schedule(resumeTask, 100, TimeUnit.MILLISECONDS)
// 이 함수는 COROUTINE_SUSPENDED를 반환하여 중단 신호를 보냄
}
중단시점의 메모리 상태
[중단 발생! 스택 프레임들이 모두 해제됨]
Stack: (완전히 비워짐)
Thread: 스레드 풀에 반납됨 → 다른 작업 처리 가능
Heap: (상태 보존!)
├── RunBlockingContinuation
├── ExampleStateMachine
│ ├── label: 1 ← fetchA 결과 대기 중인 재개 지점
│ ├── a: null ← fetchA 완료 후 결과가 저장될 위치
│ ├── b: null
│ └── completion: RunBlockingContinuation
└── FetchAStateMachine
├── label: 1 ← delay 완료 후 재개 지점
├── completion: ExampleStateMachine ← 결과 전달 대상
└── 타이머에 등록됨
[Timer Scheduler]
├── 100ms 후 실행 예정:
│ └── FetchAStateMachine.resumeWith(Result.success(Unit))
핵심 포인트 #
- 스택: 완전히 해제되어 메모리 절약
- 스레드: 다른 코루틴이나 작업에 재사용 가능
- 힙: 상태머신 인스턴스들만 보존 (매우 적은 메모리)
- 타이머: 100ms 후 재개 트리거 예약
7단계: 100ms 후 재개 - 새로운 스레드 할당 #
타이머 만료 시
// 스케줄러가 실행
resumeTask.run() {
FetchAStateMachine.resumeWith(Result.success(Unit))
}
메모리 상태 변화
Heap에서 상태 복원:
FetchAStateMachine:
├── label: 1 ← 여기서 재개!
├── completion: ExampleStateMachine
└── result: Result.success(Unit) ← delay 완료 신호
8단계: FetchAStateMachine 재개 (label = 1) #
when (label) {
1 -> {
// delay 완료 후 - 결과를 상위 continuation에 전달
completion.resumeWith(Result.success("A"))
return COROUTINE_SUSPENDED
}
}
메모리 상태 변화
[FetchAStateMachine 작업 완료]
Heap:
├── FetchAStateMachine ← GC 대상이 됨
├── ExampleStateMachine ← resumeWith(Result.success("A")) 호출됨
│ ├── label: 1
│ ├── a: null → "A"가 들어올 예정
│ └── completion: RunBlockingContinuation
└── RunBlockingContinuation
9단계: ExampleStateMachine 재개 (label = 1) #
when (label) {
1 -> {
// fetchA() 완료 후
a = result.getOrThrow() as String // "A" 저장!
label = 2 // fetchB 완료 후 재개 지점 설정
val fetchBResult = fetchB(this)
if (fetchBResult == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
return invokeSuspend(Result.success(fetchBResult))
}
}
메모리 상태 업데이트
Heap:
ExampleStateMachine:
├── label: 2 ← 변경! (fetchB 완료 후 재개 지점)
├── a: "A" ← 저장됨! (중단 이전 상태가 보존됨)
├── b: null
└── completion: RunBlockingContinuation
10단계: fetchB() 실행 (fetchA와 동일한 패턴) #
fetchA와 완전히 동일한 과정
- FetchBStateMachine 인스턴스 생성
- delay(100) 호출로 중단
- 스택 해제, 스레드 반납
- 100ms 후 재개
- “B” 결과 반환
중단 중 메모리 상태
Heap:
├── ExampleStateMachine (label: 2, a: "A", b: null)
└── FetchBStateMachine (label: 1, 타이머 등록됨)
11단계: 최종 완료 (label = 2) #
ExampleStateMachine 최종 실행
when (label) {
2 -> {
// fetchB() 완료 후
b = result.getOrThrow() as String // "B" 저장
val finalResult = "$a-$b" // "A-B" 생성
completion.resumeWith(Result.success(finalResult))
return COROUTINE_SUSPENDED
}
}
최종 메모리 상태
Heap:
├── ExampleStateMachine ← GC 대상
│ ├── label: 2
│ ├── a: "A"
│ ├── b: "B"
│ └── finalResult: "A-B" → RunBlockingContinuation에 전달
└── RunBlockingContinuation ← "A-B" 결과 수신
[runBlocking 재개]
Stack:
├── main() 프레임
├── runBlocking() 프레임 ← 재개됨
│ └── result: "A-B"
└── println() 호출 예정
핵심 메커니즘 요약 #
메모리 효율성
- 스택 프레임: 중단 시 완전히 해제 → 메모리 절약
- 힙 객체: 상태머신 인스턴스만 보존 → 최소한의 메모리
- 스레드: 중단 중에는 다른 작업에 재사용 가능
상태 보존 방식
- label: 정확한 재개 지점 추적
- 지역 변수: 필요한 값들만 인스턴스 필드로 보존 (a, b)
- continuation 체인: 결과 전달 경로 유지
비동기 처리 원리
- 중단: COROUTINE_SUSPENDED 반환으로 스택 해제 신호
- 스케줄링: 타이머, I/O 완료 등의 이벤트로 재개 트리거
- 재개: 새로운 스레드에서 힙의 상태머신 복원
delay() 함수의 역할
- 실제 중단 지점: suspendCancellableCoroutine 사용
- 타이머 등록: 지정된 시간 후 재개 스케줄링
- 리소스 해제: 대기 중에는 스레드나 스택 메모리 사용하지 않음
결론: 함수처럼 보이지만 클래스였다 #
우리가 처음 던졌던 질문,
“suspend 함수는 진짜 함수일까?”의 답은 이제 명확하다.
겉보기엔 평범한 함수처럼 보이지만,
내부적으로는 상태를 기억하고, 멈췄다 다시 살아나고,
심지어 다른 스레드에서 이어서 실행될 수도 있다.
이건 함수라기보단,
자신의 상태와 흐름을 품고 있는 작은 객체,
즉 ‘클래스’에 가까운 존재다.
그 모든 마법은 컴파일러가 상태머신으로의 변환과
Continuation을 이용한 흐름 제어를 통해 이뤄낸 것이다.
우리는 오늘 다음 사실을 발견했다:
- 함수가 멈췄다가 다시 실행될 수 있다 -> delay 등에서 COROUTINE_SUSPENDED 반환으로 스택 해제
- 지역 변수는 살아남는다 -> 상태머신 객체의 필드로 힙에 안전하게 저장
- 중간 지점부터 실행이 가능하다 -> when 문을 통한 정확한 실행 지점 복원
결국 코루틴은
“함수의 탈을 쓴 상태머신”,
또는 “멈출 수 있는 클래스”인 셈이다.
나머지 마법(스레드 순간이동 등)은
다음 글에서 코루틴 디스패처와 스케줄링 전략을 통해 이어서 살펴보자.
끝!