안드로이드/알아두기

[안드로이드] 코루틴에 대하여

썩은홍시 2021. 12. 9. 21:51

사실 코루틴은 이미 쓰고있다. 대신 정말 1%만 쓰고있다고 자부할 수 있다. 정말  GlobalScope.launch{ ... } 이렇게 비동기 처리를 할 요량으로만 사용중이다..

하지만 최근 이직 시장을 살펴보면서 알게된건 코루틴을 사용하는 것에 대하여 보다 면밀하고 세밀하게 알아야 할 필요성을 많이 느껴서 이렇게 코루틴에 대하여 정리를 하게 되었다.

 

Coroutine

코루틴에 대해서 알아보려고 하다가 아래 코틀린을 소개해주는 코틀린 컨퍼런스 영상을 보았다. 시간이 된다면 다들 보는걸 추천한다. 개인적으로 직관적으로 설명을 잘 해주었다.

 

코틀린 컨퍼런스 2017 - https://www.youtube.com/watch?v=_hfBv0a09Jc

 

 

내가 주로 API Call이나 Room 작업을 할 때 메인 스레드가 아니라 스레드를 따로 만들거나 비동기로 진행시켰다. 이렇게 되면 해당 작업을 완료하고 완료한 시점에서 데이터를 업데이트하는 등 동작을 하게 됐다. 이렇게 되면 아래의 사진과 같이 callback hell 이라는 귀찮은 상황이 발생하게 된다.

내가 딱 저렇게 했다 ㅎㅎㅎ 

 

초기 대응책으로는 Promise, Rx가 대응책으로 제시되었다. Promise, Rx 충분히 좋은 것들이다. 다만 코루틴의 suspend를 이용하면 훨씬 더 간편하게 해결할 수 있게 되었다.

 

코루틴 살짝 찍먹만 해보자

그럼 이런 suspend를 제공하는 코루틴은 뭐길래 나에게 굴러들어온 복대이일 것인가?

코루틴은 안드로이드에서 UI 스레드에서 실행시키기 힘든 Network, SQLite Operation과 같이 비동기적으로 실행되는 코드를 간결하게 만들어주기 위해 안드로이드에서 사용되는 실행 설계 패턴이다. 그럼 이 코루틴의 특징에 대해서 잠깐 알아보자. 참고로 내가 이해한 특징들만 적어 둔다. 내가 이해 못한 특징들은 적어봤자 다시 봐도 모르니까 안적고 나중에 다시 코루틴에 대해 찾아서 공부할 것이다.

 

  • 단일 스레드에서는 많은 코루틴을 실행할 수 있다.
  • 코루틴에서 다른 코루틴이 호출되면 그 코루틴을 차단하는게 아니라 잠시 정지시킨다.
  • JetPack에 코루틴이 포함됐다. JetPack Library에서 코루틴을 완전히 지원한다.
  • 코루틴이 시작된 스레드를 중단하지 않으면서 비동기적으로 실행되는 코드
  • 코루틴은 스코프 내에서 실행되어야 한다.
  • 코루틴은 생명주기에 따라서 onDestroy 시점에서 소멸될 때 관련 코루틴을 한번에 취소하여 메모리 누수를 방지한다.

 

 

코루틴 스코프와 디스패쳐 살짝만 알아보자

사실 나는 GlobalScope만 주구 장창 써댔다. 효율성? 그런거는 나에게 없었다. 그저 이 코드가 돌아가기만 하면 된다고 생각했다. 다만 아래 스코프 종류들에 대해서 알고는 있었지만, 그저 코드만 돌아가니까 장땡이다 싶어서 상관 없다고 생각했는데, 이렇게 알게되니까 또 색다르게 재밌다.

 

Scope종류
  • GlobalScope - 특정 액티비티나 프래그먼트의 생명주기와 함께 동작해서 실행 도중 별도 생명주기 관리가 필요없다. 시작부터 종료까지 실행 시간이 비교적 긴 코루틴의 경우에 적합하다.
  • CoroutineScope - 필요할때만 열고 완료되면 닫아주는 코루틴 스코프에 사용하기 적합하다.
  • ViewModelScope - 뷰모델 컴포넌트를 사용한다면, ViewModel 인스턴스에서 사용하기 위해 제공되는 스코프이다. GlobalScope와 비슷하게, 뷰모델 인스턴스가 소멸될 때 자동으로 소멸되고 작업도 자연스럽게 취소된다.

 

Dispatcher를 몰라서 CoroutineScope를 안쓴 것 같은 느낌도 없잖아 있다. 그래서 Dispatcher에 대해 좀 설명하자면, 코루틴을 실행 때 적당한 스레드에 할당하여 실행 도중 발생하는 pause나 resume를 담당한다.

 

Dispatchers 종류
  • Default : CPU를 많이 쓰는 작업에 최적화 되어있는 디스패쳐
  • IO : Input/Output의 약자처럼 이미지 다운로드, 파일 입출력 등과 같은 입출력 작업에 최적화 되어있는 디스패쳐
  • Main : 메인이라하면 역시 UI 아니겠는가? UI 작업과 찰떡궁합인 디스패쳐
  • Unconfined : 이건 사실 좀 특이해서 참고한 설명을 그대로 적는다.
    호출한 컨텍스트(Context)를 기본으로 사용하는 디스패쳐이다. 다만 작업이 중단된 후 다시 실행될 때 컨텍스트가 바뀌면 바뀐 컨텍스트를 따라간다.

 

이렇게 된거 코루틴 상태관리도 좀 해보자

위에서 주구장창 살짝만, 찍먹만 해보자고 해놓고 설명이 늘어지고 있다. 사실 나도 이렇게 늘어질지는 몰랐다. 근데 어쩌냐.... 재밌는데. 내 블로그에 내가 글 싸지르는 거니까 그냥 해야지. 어차피 여기다 댓글 다는 사람도 없지 않은가.

 

launch / async

GlobalScope.launch {  }
CoroutineScope(Dispatchers.IO).async {  }

위 코드를 보면 코루틴은 launch 혹은 aync로 시작이 가능하다. 상추님의 블로그에서는 launch는 상태를 관리할 수 있고, async는 상태를 관리에 묻고 결과 반환까지 받고 더블로 간다고 설명해주셨다.

이부분에 대해서 좀 더 생각 단계별로 생각을 해봤다.

  1. 코루틴은 다른 작업에 의해 pause 될 수 있다.
  2. 그리고 작업이 다시 resume 될 수 있다.
  3. 특정 스레드에서 여러개가 존재할 수 있다.
  4. 각 코루틴들은 작업을 하고 있는지 중지 되었는지 작업이 이제 막 재개 되었는지 각 코루틴들만의 상태가 있다.
  5. 그렇다면 우리는 이 상태를 제어하고 내가 원하는 입맛에 맞게 골라서 사용할 수 있어야 진정으로 이 코루틴을 사용하는 거 아닐까 생각이 든다.
  6. 더불어 아까 suspend를 설명했을 때 Network나 DB 작업을 해서 결과값을 받을 때까지 기다리는 죽이게 멋있는 작업 진행 방식을 해준다. 그것 까지 더해지면 무조건 써야 한다.

이런 단계적 사고를 거쳐보면 무조건적으로 코루틴의 상태를 내가 관리할 수 있다면 당연히 써야 하지 않은가? 하는 생각도 들게 되었다.

 

cancel

동작을 실행시켰으면, 당연히 동작을 멈추는 것 또한 가능하다. 키워드가 착 감기게 cancel을 사용하면 동작을 멈출 수 있게 된다. 아래 코드를 통해서 좀 더 가시적으로 설명한다.

class MainActivity():AppCompatActivity(){

    override fun onCreate(savedInstanceState: Bundle?){
    
    	...
        
        val cancelTest = CoroutineScope(Dispatchers.Default).launch {
            val doJob = launch{
                for(i in 0..1000)
                    Log.e(TAG, "Coroutine Test doing Job1 $i")
            }
        }


        binding.button.setOnClickListener{
            cancelTest.cancel()
        }
    }
}

위 코드와 같이 cancel을 시키게 되는데, 우리가 지금 현재 취소시킨건 cancelTest이다. 그리고 doJob이 진행하고 있던 작업도 cancelTest 코루틴이 취소되면서 함께 취소가 된 것이다.

 

join

하나의 코루틴에는 여러개의 launch 블록이 있어도 상관이 없다. 다만 이렇게 launch 된 작업들은 모두 새로운 코루틴으로 분기가 되어서 실행되기 때문에 순서를 정하지 못한다. 다만 정 순서를 정해야 겠다 싶으면 join을 사용해서 순차적으로 실행되게 할 수 있다.

 

        CoroutineScope(Dispatchers.Default).launch {
            launch {
                for (i in 0..5) {
                    delay(500)
                    Log.d("COROUTINE", "$i")
                }
            }.join()
            launch {
                for (i in 6..10) {
                    delay(500)
                    Log.d("COROUTINE", "$i")
                }
            }
        }

 

join은 사용법이 간단하다. 그저 순서대로 launch 뒤에 join을 붙여주면 된다. 그리고 사용 했을 때와 사용하지 않을을 때 작업차이는 아래와 같다. 왼쪽 결과는 join을 사용하지 않은 경우이고, 오른쪽 경우는 join을 사용한 결과이다. 이처럼 join을 사용하여 순차적으로 작업이 진행되게 작업 순서를 개발자가 직접 정할 수 있다.

 

 

async

async는 아까도 말했 듯 결과 값을 처리하게 해준다. 즉 코루틴 스코프의 결과를 받아서 쓸 수 있다는 소리이다. 이럴때는 await() 을 호출하여 기다렸다가 코드를 실행시킬 수 있게 된다. 아래와 같이 사용하면 된다.

 

        CoroutineScope(Dispatchers.Default).launch {
            val async1 = async{
                delay(500)
                200
            }
            val async2 = async{
                delay(1000)
                200
            }

            Log.d("Coroutine", "${async1.await()} + ${async2.await()}")
        }

 

suspend

suspend는 사전적 의미로는 특정 일을 중지하거나 미룬다 라는 뜻이다. 코루틴 문서에서 suspend를 정의하길 'a function that could be started, paused, and resume' 이라고 한다. 함수는 함수인데, 그 함수가 '시작되고 멈추고 다시 재개될 수 있는 함수야!' 라고 말해준다.

좀 더 안드로이드와 연관지어 생각해보자. 그럼 API Call을 해서 결과 값을 기다려야 하는데, suspend를 쓰면 결과 값을 기다려주는 함수를 만들 수 있겠네? 그럼 callback을 쓸 필요 없이 suspend를 써서 API Call이 가져오는 결과를 처리할 수 있겠네!? 그럼 이게 콜백 함수를 대체할 수 있는 대체제가 아니라 대안책이 되겠다! 라는 의미로 나는 해석을 했다. 

 

실질적인 예제는 다음 포스팅에서

 

요즘들어 나 자신을 push하면서 포스팅에다가 예제까지 작성하고 하다보니까 글이 길어졌다. 이 포스팅 하나로는 도저히 감당이 되지 않았다. 그래서 retrofit에다가 의존성 주입을 시키고 그걸 suspend로 짜자잔 하게 만든 예제를 다음 포스팅에서 따로 올리겠다. 올리고 나면 여기다가 링크를 달아야지. 그럼 사전적 의미는 여기서 그만 알아보자

 

 

참고

상추님 티스토리 - 코틀린 코루틴 한 번에 끝내기

코틀린 코루틴 공식 깃허브

코틀린월드 닷컴 - suspend fun의 이해

브랜디 랩스 장순철님 - Kotlin Coroutines

누리의 오늘의 기술 - Coroutine suspend function은 대체 뭐야?