성장기록지
suspend 함수의 내부 구현 알아보기 (1탄) 본문
진행한 프로젝트의 콜백지옥을 해결하기 위해 여러 학습을 진행 중
suspend 함수의 내부 구조에 대해 학습하게 되었다.
많은 학습량이 있지만 우선 CPS에 대한 이론을 다뤄보겠다.
Kotlin 컴파일러가 suspend
키워드를 만났을 때
코루틴은 Continuation Passing Style(CPS) 형태로 동작한다.
CPS는 호출되는 함수에 Continuation을 전달하고, 각 함수의 작업이 완료되는 대로 전달받은 Continuation을 호출하는 방식을 말한다.
그렇다면 Continuation
이란 뭘까? Continuation
인터페이스를 한번 알아보도록 하겠다.
Continuation 인터페이스에는 크게 context
객체와 resumeWith()
함수가 있다.
context
는 각 Continuation이 특정 스레드 혹은 스레드 풀에서 실행되는 것을 허용해준다.- 현재 Continuation과 관련된 CoroutineContext이다.
CoroutineContext
는 코루틴이 실행되는 환경 및 문맥을 말하며, 현재 실행되는 코루틴의 상태 정보를 담고 있다. resumeWith
는 특정 함수 a가 suspend 되어야 할 때, 현재 함수에서 a의 결과 값을 T로 받게 해주는 함수이다 (Result).
실제로 suspend 함수를 디컴파일 해보면 아래와 같이Continuation이 생기는 것을 알 수 있다.
suspend fun findUser(userId: Long): UserDto {
val profile = userProfileRepository.findProfile(userId)
val image = userImageRepository.findImage(profile)
return UserDto(profile=profile,image=image)
}
@Nullable
public final Object findUser(long userId, @NotNull Continuation $completion) {
Object $continuation;
label27: {
if ($completion instanceof <undefinedtype>) {
$continuation = (<undefinedtype>)$completion;
if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
break label27;
}
}
아직은 잘 이해가 가지 않을 것이다.
이해를 돕기위해 어떻게 suspend되고, resume되는지 대략적으로 설명해보도록 하겠다.
다음과 같은 코드가 있다고 해보자.
class UserService {
private val userProfileRepository = UserProfileRepository()
private val userImageRepository = UserImageRepository()
suspend fun findUser(userId: Long): UserDto {
println("프로필 가져오겠음")
val profile = userProfileRepository.findProfile(userId)
println("이미지 가져오겠음")
val image = userImageRepository.findImage(profile)
return UserDto(profile = profile, image = image)
}
}
각각의 Repository는 다음과 같이 구현되어있다.
Profile과 Image도 임시로 구성한 클래스이다.
class UserProfileRepository {
suspend fun findProfile(userId: Long): Profile {
delay(100L)
return Profile()
}
}
class UserImageRepository {
suspend fun findImage(profile: Profile): Image {
delay(100L)
return Image()
}
}
위의 findUser에서는 findProfile(userId)과 findImage(userId)에서 중단되었다가 재개될 것이다.
그렇다면 함수의 실행 단계를 3단계로 나누어 볼 수 있다.
1단계 : 초기 시작 단계
2단계: findProfile()
에서 1차 중단 후 재시작
3단계: findImage()
에서 2차 중단 후 재시작
코드에 단계를 표시한다면 다음과 같을 수 있다.
class UserService {
private val userProfileRepository = UserProfileRepository()
private val userImageRepository = UserImageRepository()
suspend fun findUser(userId: Long): UserDto {
// 1단계 : 초기 시작
println("프로필 가져오겠음")
val profile = userProfileRepository.findProfile(userId)
//2단계 : 1차 중단 후 제시작
println("이미지 가져오겠음")
val image = userImageRepository.findImage(profile)
//3단계 : 2차 중단 후 재시작
return UserDto(profile = profile, image = image)
}
}
그렇다면 각 단계를 구별하기 위해 label
이라는 것으로 표시하고 싶다 생각하였다.
이를 위해 Continuation
이라는 인터페이스를 만들고, 이를 구현한 sm
(State Machine 약자)을 구현하도록 하여 label
을 갖고있도록 하고자 한다.
interface Continuation {
}
class UserService {
private val userProfileRepository = UserProfileRepository()
private val userImageRepository = UserImageRepository()
suspend fun findUser(userId: Long): UserDto {
val sm = object : Continuation { //State Machine
var label = 0
}
println("프로필 가져오겠음")
val profile = userProfileRepository.findProfile(userId)
// 이하 생략...
그렇다면 label
을 통해 어떻게 1~3단계를 구별할 수 있을까?
when
을 활용하여 단계별로 실행 코드를 구별하면 된다!
아래는 수정한 예시 코드이다.
suspend fun findUser(userId: Long): UserDto {
val sm = object : Continuation {
var label = 0
}
when (sm.label) {
0 -> { // 1단계 : 초기 시작 단계
println("프로필 가져오겠음")
val profile = userProfileRepository.findProfile(userId)
}
1 -> { //2단계 : 1차 중단 후 제시작
println("이미지 가져오겠음")
val image = userImageRepository.findImage(profile)
}
2 -> {//3단계 : 2차 중단 후 재시작
return UserDto(profile = profile, image = image)
}
}
}
하지만 이렇게 코드를 구성하면 당연히 많은 에러가 나게된다. (각자의 when문에 변수가 있으므로)
에러들을 해결하기 위해 익명 객체sm
에 profile
과 image
를 추가해주도록 한다!
Repository를 통해 받은 데이터는 sm에 할당해준다!
또한 단계를 넘어가기 위해 단계별로 label값들을 증가시켜준다!
suspend fun findUser(userId: Long): UserDto {
val sm = object : Continuation {
var label = 0
var profile: Profile? = null
var image: Image? = null
}
when (sm.label) {
0 -> { // 1단계 : 초기 시작 단계
println("프로필 가져오겠음")
sm.label = 1 // 라벨 증가
val profile = userProfileRepository.findProfile(userId)
sm.profile = profile
}
1 -> { //2단계 : 1차 중단 후 제시작
println("이미지 가져오겠음")
sm.label = 2 //라벨 증가
val image = userImageRepository.findImage(sm.profile!!)
sm.image = image
}
2 -> {//3단계 : 2차 중단 후 재시작
return UserDto(profile = sm.profile!!, image = sm.image!!)
}
}
}
하지만 이렇게 코드를 작성한다고 해도, sm.label이 1일때와 2일때는 실행되지않는다.
label값 갱신 후 findUser를 다시 호출해주지 않기 떄문이다.
이를 해결하기 위해 Continuation
을 전달하여 콜백으로 활용한다!
아래의 코드를 통해 설명하겠다.
우선 findProfile()
과 findImage()
도 continuation
을 받을 수 있게 수정해준다.
class UserProfileRepository {
suspend fun findProfile(userId: Long, cont: Continuation): Profile {
delay(100L)
return Profile()
}
}
class UserImageRepository {
suspend fun findImage(profile: Profile, cont: Continuation): Image {
delay(100L)
return Image()
}
}
findUser()
를 재실행시킬 수 있도록 Continuation
에 resumeWith()
라는 함수를 만들어준다.
interface Continuation {
suspend fun resumeWith(data: Any?)
}
그렇다면 익명객체 sm
에서 override를 해주어야 할 것이다.
이 때 재귀함수처럼 findUser()
를 다시 호출해준다. (사실은 재귀는 아니고 resume이 되는것이다) 이 때 Continuation
을 들고 있게 해준다.
그렇다면 findProfile()
과 findImage()
이후에 Continuation
을 들고 다시 findUser()
가 호출될 것이다.
전체적인 코드를 보면 다음과 같이 된다.
interface Continuation {
suspend fun resumeWith(data: Any?)
}
class UserService {
private val userProfileRepository = UserProfileRepository()
private val userImageRepository = UserImageRepository()
suspend fun findUser(userId: Long, cont: Continuation): UserDto {
val sm = object : Continuation {
var label = 0
var profile: Profile? = null
var image: Image? = null
override suspend fun resumeWith(data: Any?) {
findUser(userId, this)
}
}
when (sm.label) {
0 -> { // 1단계 : 초기 시작 단계
println("프로필 가져오겠음")
sm.label = 1 // 라벨 증가
val profile = userProfileRepository.findProfile(userId,sm)
sm.profile = profile
}
1 -> { //2단계 : 1차 중단 후 제시작
println("이미지 가져오겠음")
sm.label = 2 //라벨 증가
val image = userImageRepository.findImage(sm.profile!!,sm)
sm.image = image
}
2 -> {//3단계 : 2차 중단 후 재시작
return UserDto(profile = sm.profile!!, image = sm.image!!)
}
}
}
}
Continuation
으로 findUser()
가 호출되기에, findProfile()
과 findImage()
도 Profile
과 Image
를 반환할 필요 없이 아래와 같이 코드가 구성된다.
class UserProfileRepository {
suspend fun findProfile(userId: Long, cont: Continuation) {
delay(100L)
cont.resumeWith(Profile())
}
}
class UserImageRepository {
suspend fun findImage(profile: Profile, cont: Continuation) {
delay(100L)
cont.resumeWith(Image())
}
}
그렇다면 label
값에 따라 Profile인지, Image인지 추론이 가능하게된다.
그렇기에 resumeWith()
에서 when(label)을 사용하도록 바꿔준다.
그 후 findUser()
에서 label값을 올려주고 Profile, Image를 할당하던 일들을 없애준다.
또한 when절이 다 끝난 이후가 label이 2가 되므로, 3단계를 2→{} 내부가 아닌 외부로 뺼 수 있다.
suspend fun findUser(userId: Long, cont: Continuation): UserDto {
val sm = object : Continuation {
var label = 0
var profile: Profile? = null
var image: Image? = null
override suspend fun resumeWith(data: Any?) {
when (label) { // when(label)을 이용한 data 추론 및 label 값 증가
0 -> {
profile = data as Profile
label = 1
}
1 -> {
image = data as Image
label = 2
}
}
findUser(userId, this)
}
}
when (sm.label) {
0 -> { // 1단계 : 초기 시작 단계
println("프로필 가져오겠음")
userProfileRepository.findProfile(userId, sm)
}
1 -> { //2단계 : 1차 중단 후 제시작
println("이미지 가져오겠음")
val image = userImageRepository.findImage(sm.profile!!, sm)
}
}
//3단계 : 2차 중단 후 재시작
return UserDto(profile = sm.profile!!, image = sm.image!!)
}
기대 효과 및 보충설명
Callback 감소
해당 글을 참고하여 조금 더 보충설명을 하겠다.
두개의 코드를 비고하는 것과 같이, 콜백을 사용하였을때보다 코드가 더 간결해보인다.
StateMachine의 상세 구현
위에서 설명한 상태 머신은 아래처럼 Continuation의 자식인 CoroutineImpl을 구현하여,
원래 suspend 함수에 선언된 변수들과, 실행 결과인 result, 현재의 진행 상태인 label을 가지고 있는다.
fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) {
class GetGingerBraveStateMachine(completionL Continuation<Any?>): CoroutineImpl(completion) {
// 기존 함수 내부에 선언된 변수들
var dough: Dough? = null
var magicDough: MagicDough? = null
var cookie: Cookie? = null
var gingerBrave: GingerBrave? = null
var result: Any? = null
var label: Int = 0
override fun invokeSuspend(result: Any?) {
this.result = result
getGingerBrave(api, this)
}
}
}
요약
- Coroutine은 Continuation을 주고 받는 CPS 패러다임을 사용한다.
- Kotlin 컴파일러는 suspend fun의 시그니처를 변경한다. 매개변수에 Continuation을 추가한다.
- Kotlin 컴파일러는 suspend fun의 내부 코드들을 분석하여 중단 가능 지점을 찾아 구분한다.
- Kotlin 컴파일러는 다음 실행 지점을 나타내는 label과 내부 변수들을 관리하는 상태머신 클래스를 생성한다.
참고 자료
https://tech.devsisters.com/posts/crunchy-concurrency-kotlin/