
이전 글에서는 스레드와 코루틴을 비교하며 각각의 특징과 차이점을 알아보고,
어째서 코루틴이 자연스럽게 선택되었는지 알아보았습니다.
코루틴은 스레드보다 가벼우며 비동기 코드를 동기 코드처럼 작성할 수 있게 해주며,
구조화된 동시성을 통해 코드의 안정성과 가독성을 크게 높여줍니다.
하지만 여기서 한 가지 의문이 남습니다.
코루틴은 어떻게 실행을 멈췄다가, 다시 이어서 실행할 수 있을까?
suspend 키워드가 붙은 함수는 마법처럼 동작합니다.
함수 내부에서 네트워크 요청이나 지연 작업을 만나면 실행이 일시 중단되고,
콜백 없이도 이후 코드가 자연스럽게 이어집니다.
심지어 중단되기 전의 지역 변수나 실행 흐름도 그대로 유지됩니다.
이전 글에서 Continuation이라는 객체를 통해 이러한 처리를 한다고 언급한 바 있습니다.
이번 글에서는 코루틴이 suspend와 Continuation을 통해
어떤 식으로 이 동작을 구현할 수 있었는지 알아보고자 합니다.
suspend코루틴을 처음 접할 때 가장 먼저 마주치는 키워드 중 하나는 suspend입니다.
대부분의 경우, 이 키워드는 단순히 비동기 함수에 붙이는 표시 정도로 이해되고 넘어갑니다.
실제로 suspend는 문법적인 장식이 아니라 함수의 실행 모델 자체를 바꾸는 선언입니다.
suspend 키워드가 붙은 함수는 실행 도중 중단될 수 있습니다.
여기서 중요한 점은, 이 중단이 return도 아니고 예외도 아니라는 것입니다.
이 동작을 가능하게 하기 위해 Kotlin 컴파일러는 특별한 규칙을 적용합니다.
그래서 suspend 함수는 반드시 코루틴 또는 다른 suspend 함수 내에서만 호출 가능합니다.
일반적인 함수의 콜스택으로는 이 '멈춤'을 표현할 수 없기 때문입니다.
suspend 함수는 콜스택을 쌓지 않는다일반 함수는 호출될 때마다 스택 프레임을 쌓지만,
suspend 함수는 중단 지점에서 콜스택을 유지하지 않습니다.
중단이 발생하는 순간 실행 정보는 콜스택이 아닌 Continuation으로 옮겨지고,
스택을 정리된 뒤 스레드는 자유롭게 반환됩니다.
때문에 suspend 함수는 함수가 멈춘 것처럼 보이지만,
실제로는 상태만 남기고 실행 환경은 완전히 해체됩니다.
suspend를 다루는 방식suspend는 런타임 기능이 아닌 컴파일 타임 개념입니다.
Kotlin 컴파일러는 suspend 함수를 다음과 같이 변환합니다.
Continuation 파라미터를 추가한다.이 덕분에 개발자는 콜백이나 상태 관리를 직접 하지 않아도,
중단과 재개가 가능한 코드를 작성할 수 있습니다.
Continuation위에서 본 suspend 함수가 실행될 때 어떤 일이 일어나는지 알아보기 전에,
Continuation에 대해 알아보겠습니다.
Continuation은 suspend 함수가 중단되었을 때 이후의 실행 흐름을 하나의 객체로 캡슐화한 것,
즉 지금 이 지점 이후에 실행되어야 할 코드 전체라고 생각할 수 있겠습니다.
를 모두 알고 있는 실행의 설계도라고 볼 수 있습니다.
Continuation<T>Kotlin에서 Continuation은 다음과 같은 인터페이스로 정의되어 있습니다.
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
T : 해당 코루틴이 최종적으로 반환할 값의 타입resumeWith(result) : 중단된 코루틴을 다시 실행시키는 진입점위 코드에서 알 수 있듯 Continuation은 성공과 실패를 포함한 전체 흐름의 재개 지점까지 포함합니다.
또한 중단된 코루틴은 누군가 resumeWith를 호출해 주기 전까지 다시 실행되지 않습니다.
이 호출은 보통 다음과 같은 경우에 발생합니다.
delay)중요한 점은 resume이 함수 호출처럼 즉시 이어지는 것이 아니라,
해당 Continuation을 실행 큐에 다시 올리라는 의미입니다.
따라서 재개 시점의 스레드는 이전의 스레드와 다를 수도 있습니다.
이렇게 Continuation은 코루틴의 실행 상태를 담고 있고,
중단 이후의 흐름을 기억하며 재개를 통해 실행을 이어 붙이는 핵심 추상화입니다.
suspend 함수의 중단suspend 함수는 언제나 중단되는 것은 아닙니다.
중단은 오직 중단 지점에 도달했을 때만 발생합니다.
중단 지점은 다음과 같은 상황에서만 만들어집니다.
suspend 함수를 호출할 때delay, await 등)즉 suspend 키워드가 붙어 있다고 해서 자동으로 멈추는 것은 아닙니다.
중단될 수 있는 가능성만을 가질 뿐, 실제 중단은 특정 함수 호출을 통해서만 발생합니다.
suspend fun loadUser(): User {
val token = fetchToken() // 로컬 DB 접근
val user = fetchUser() // 네트워크 접근
return user
}
위 코드의 fetchToken()와 fetchUser() 모두 중단 가능 함수라면,
loadUser() 메소드는 중단 가능 지점을 2개나 가지고 있게 되는 것입니다.
중단 지점에 도달하면 코루틴은 다음과 같은 과정을 거치게 됩니다.
Continuation 객체에 저장한다.코드를 예시로 들어서 살펴보겠습니다.
suspend fun fetchToken(): String {
delay(1000)
return "token"
}
이렇게 fetchToken() 메소드 내부에 delay()가 있다면,
해당 메소드에서의 중단 지점은 delay()가 호출되는 부분입니다.
loadUser()를 실행 중이고, fetchToken() 내부에서 delay()를 만났다고 가정하겠습니다.
val token = fetchToken()
이 코드에서는 다음과 같은 일이 일어납니다.
loadUser()의 실행 상태를 정리한다.token은 아직 값이 없음val user = fetchUser(token)Continuation 객체가 생성된다loadUser()는 아직 반환되지 않았지만, 실행은 완전히 멈춘 상태가 됩니다.
loadUser()는 컴파일러에 의해 대략 이런 구조로 바뀝니다.
public Object loadUser(Continuation completion) {
class LoadUserStateMachine extends ContinuationImpl {
Object result;
int label = 0; // 현재 상태
Object L$0; // 로컬 변수(token)
LoadUserStateMachine(Continuation completion) {
super(completion);
}
@Override
public Object invokeSuspend(Object result) {
this.result = result;
return loadUser(this); // 상태를 가지고 자기 자신을 다시 호출
}
}
LoadUserStateMachine sm = (completion instanceof LoadUserStateMachine)
? (LoadUserStateMachine) completion
: new LoadUserStateMachine(completion);
switch (sm.label) {
case 0: // 초기 상태
sm.label = 1;
Object var = fetchToken(sm);
if (var == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
case 1: // fetchToken 완료 후 재개 시점
String token = (String) sm.result;
sm.L$0 = token;
sm.label = 2;
Object var2 = fetchUser(token, sm);
if (var2 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
case 2: // fetchUser 완료 후 재개 시점
String tokenFromStore = (String) sm.L$0;
User user = (User) sm.result;
return user; // 최종 결과 반환
}
return null;
}
컴파일러는 함수 본문을 switch-case문으로 나눕니다.
메소드 내부의 중단 지점은 각 케이스가 되며, 상태를 label이라는 값으로 구분하게 됩니다.
fetchToken이 네트워크 지연 등으로 인해 COROUTINE_SUSPENDED를 반환하면
현재의 label을 저장하고 함수를 즉시 종료합니다.
또한 작업이 완료되면 invokeSuspend가 실행되는데 이 때 label은 1이므로,
switch문은 case 1으로 넘어가게 되어 다음 작업을 실행할 수 있게 됩니다.
이렇게 상태들의 집합, 그리고 그 상태들 간의 전이를 정의한 것을 상태 머신이라고 합니다.
코루틴에서는 상태 머신의 개념을 차용하여 중단과 재개를 구현하였습니다.
위 코드에서 알 수 있듯 loadUser()는 이제 User를 반환하지 않습니다.
대신 Continuation을 파라미터로 받고 Any?를 반환합니다.
그리고 컴파일러는 내부에서 ContinuationImpl을 상속받은 익명 클래스를 생성합니다.
또한 loadUser() 내부의 함수들도 파라미터로 상태 머신(Continuation)을 받게 되어,
결과를 반환하는 대신 다음 할 일을 처리하게 됩니다.
즉 부모에서 자식으로 Continuation이 전달되어 연쇄적인 재개가 일어납니다.
이러한 것을 CPS라고 부르며, 코루틴 내부는 상태 머신과 CPS가 아주 중요하게 작용하고 있습니다.
일반적인 함수의 로컬 변수는 스택 프레임에 저장되어 함수가 종료되면 사라집니다.
하지만 코루틴은 중단되는 동안 함수 자체가 스택에서 제거됩니다.
따라서 컴파일러는 아래와 같이 로컬 변수를 Continuation 구현체의 필드로 승격시킵니다.
class LoadUserStateMachine extends ContinuationImpl {
Object result;
int label = 0; // 현재 상태
Object L$0; // 로컬 변수(token)
}
이를 통해 코루틴이 나중에 다른 스레드에서 재개되더라도,
이전에 사용하던 로컬 변수 값을 힙 영역에서 안전하게 꺼내 쓸 수 있게 됩니다.
중단된 코루틴은 Continuation 객체로 존재하며 아무 일도 하지 않은 채 재개를 기다립니다.
스스로 다시 실행되지 않고, 외부에서 resumeWith를 호출해야 합니다.
이 호출은 보통 다음과 같은 코드 내부에서 발생합니다.
delay, withContext 같은 표준 라이브러리suspendCancellableCoroutine 내부 구현그리고 결과는 Continuation 내부로 전달됩니다.
continuation.resumeWith(Result.success(value)) // 성공
continuation.resumeWith(Result.failure(exception)) // 실패
이 값은 즉시 반환되지 않고 Continuation에 저장되며, 다음 실행 단계에서 소비됩니다.
suspend fun fetchToken(): String {
delay(1000)
return "token"
}
이 메소드를 예로 들자면, 1초가 지나고 나서 중단되었던 fetchToken()이 재개됩니다.
예외가 발생하지 않았으니 실질적으로는 아래와 같은 코드가 호출될 것입니다.
continuation.resumeWith(Result.success("token"))
이제 이 resumeWith는 ContinuationImpl의 invokeSuspend를 호출합니다.
@Override
public Object invokeSuspend(Object result) {
this.result = result;
return loadUser(this); // 상태를 가지고 자기 자신을 다시 호출
}
이 때 case 0 블록 내부의 코드에 의해 label은 1이 되었으므로,
다음으로 실행해야 하는 작업인 case 1의 코드를 실행하게 됩니다.
case 1: // fetchToken 완료 후 재개 시점
String token = (String) sm.result;
sm.L$0 = token;
sm.label = 2;
Object var2 = fetchUser(token, sm);
if (var2 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
이러한 과정을 거쳐 모든 중단 지점을 통과하고 함수의 끝에 도달하면,
더 이상 중단은 발생하지 않고 최종적으로 값이 반환됩니다.
이 시점에서 Continuation의 역할은 끝납니다.
상태 머신은 더 이상 사용되지 않고 해당 객체는 GC의 대상이 됩니다.
이러한 의문을 가질 수도 있습니다.
만약 워커 스레드에서
resumeWith를 호출했다면, 이후의 UI 업데이트도 워커 스레드에서 실행되나?
기본적으로 Continuation.resumeWith()가 호출되면 호출한 스레드에서 코드가 실행되는데,
위의 문제를 해결하고 싶을 때는 중간에 가로채서 적절한 스레드로 넘겨주는 역할이 필요합니다.
이러한 문제를 해결할 수 있는 것이 CoroutineDispatcher입니다.
코루틴은 Continuation을 그대로 사용하지 않고, 디스패쳐가 가로챈 버전을 사용합니다.
코루틴이 시작되거나 재개될 때 내부에서는 ContinuationInterceptor에게 Continuation을 넘겨줍니다.
(참고로 CoroutineDispatcher는 ContinuationInterceptor의 구현체)
interface ContinuationInterceptor : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<ContinuationInterceptor>
fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
fun releaseInterceptedContinuation(continuation: Continuation<*>)
}
abstract class CoroutineDispatcher : AbstractCoroutineContextElement, ContinuationInterceptor {
abstract fun dispatch(context: CoroutineContext, block: Runnable)
}
Continuation : 컴파일러가 생성한 상태 머신 그 자체CoroutineDispatcher) : 스레드 결정 주체Continuation : 원본 Continuation을 감싸서 만든 객체래핑된 Continuation은 resumeWith()가 호출되었을 때 원본을 바로 실행하지 않고,
dispatcher.dispatch()를 먼저 호출합니다.
이 때 디스패쳐가 관리하는 스레드 풀의 작업 큐에 해당 코루틴을 추가하고,
해당 스레드 내부에서 invokeSuspend()가 실행되게 됩니다.
즉 코루틴을 실행할 때 빌더 함수에 디스패쳐를 명시적으로 전달하게 되면,
위의 동작에 의해 코루틴은 지정한 디스패쳐의 스레드에서 실행됩니다.
viewModelScope.launch { // viewModelScope는 기본적으로 Dispatchers.Main
val user = loadUser() // IO 작업
_uiState.update { copy(user = user) } // UI 갱신
}
따라서 위와 같은 코드도 문제 없이 동작할 수 있게 되는 것입니다.
이렇듯 함수가 어떻게 중단되고 재개되는지 살펴보았습니다.
겉보기에는 단순한 동기 코드지만, 그 뒤에서는 컴파일러가 함수를 상태 머신으로 분해하고
실행의 나머지를 Continuation이라는 객체로 다루고 있다는 사실을 확인했습니다.
이 관점에서 보면 코루틴은 더 이상 경량 스레드도, 편한 비동기 문법도 아닌 것처럼 느껴집니다.
코루틴은 실행 흐름 자체를 값으로 취급하며, 중단과 재개는 그 값을 저장하고 재실행하는 과정에 가깝습니다.
이러한 구조를 이해하고 나면 그동안 코루틴을 사용하며 겪은 문제가 자연스레 이해되는 듯 합니다.
이제 코루틴 코드를 마주할 때 이 함수는 어디서 중단될 수 있을지,
또는 어떤 일이 일어날지에 대해 한 번씩 생각해 보면 좋을 것 같습니다.