성장기록지

suspend 함수의 내부 구현 알아보기 (1탄) 본문

카테고리 없음

suspend 함수의 내부 구현 알아보기 (1탄)

pengcon 2024. 12. 29. 20:04

진행한 프로젝트의 콜백지옥을 해결하기 위해 여러 학습을 진행 중
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문에 변수가 있으므로)

에러들을 해결하기 위해 익명 객체smprofileimage를 추가해주도록 한다!

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()를 재실행시킬 수 있도록 ContinuationresumeWith()라는 함수를 만들어준다.

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()ProfileImage를 반환할 필요 없이 아래와 같이 코드가 구성된다.

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/

https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/wasm/src/kotlin/coroutines/CoroutineImpl.kt