4. 편하게 남김없이. Coroutine

don9wan·2021년 11월 21일
0

반성 식탁

목록 보기
4/14
post-thumbnail

일전에 Coroutine에 대해 글을 작성했다.
그럼에도 불구해도 이번에 글을 다시 쓰게 된 계기가 있다.
Coroutine을 두리뭉술하겐 알았으나 구체적인 사용법을 몰라서 삽을 좀 팠다. 많이.. 공부의 부족이었다.
하여간 이번 프로젝트에서 Coroutine을 많이 사용했다. ViewModel 클래스에서도 Coroutine을 간단하게 사용할 수 있도록 viewModelScope를 자체적으로 지원하기도 하고, Firestore의 데이터를 View에 뿌려주는 작업이 많았던만큼 Coroutine 사용이 잦았다.

Coroutine

Coroutine을 다시 한번 간단명료하게 정리하자면 경량 쓰레드이다.
운영체제에서 스레드는 lightweight process, 경량 프로세스이다.
같은 맥락으로 스레드는 프로세스의 작업 흐름 단위, 코루틴은 스레드의 작업 흐름 단위이다.
이러한 스레드를 통해 스레드처럼 비동기 작업을 수행할 수 있게 된다.
그리고 코루틴은 스레드보다 눈에 띄는 장점들을 가지고 있기에 근간의 비동기 처리에서 빈번하게 사용된다.
Coroutine을 왜 사용하는지에 대해선 이전에 작성한 글을 한 번 보고 오자.
오늘은 코루틴에 대한 실사용 후기 정도를 작성할 것 같다.

Dispatcher

Coroutine을 사용하기 전에 Dispatcher의 역할에 대해 알 필요가 있다. Dispatch의 의미는 '보내다', '파견하다'이다. Dispatcher는 스레드에 코루틴을 보내주는 역할을 한다.

  • 코루틴의 Dispatcher는 스레드 풀을 만들고 제어 및 관리해준다.
  • 이 Dispatcher라는 녀석에게 특정 코루틴을 보내주면
  • Dispatcher는 자신이 관리 중인 스레드풀에서 적절하게(자원이 남는 스레드를 찾는 등) 코루틴을 배분해준다.
  • 배분 받은 스레드는 해당 코루틴을 수행한다.
Dispatcher(thread pool) 만들기
val singleDispatcher = newFixedThreadPoolContext("SingleThread")
val multiDispatcher = newFixedThreadPoolContext(3, "MultiThread")

안드로이드에선 위와 같이 Dispatcher를 별도로 만들 필요가 없다. 기본으로 3가지 타입의 Dispatcher가 구현돼있기 때문이다. 따라서 안드로이드 개발 시 Dispatcher를 사용해야 할 때, 다음 세 가지 종류의 Dispatcher 중 골라서 사용하면 된다.

  • Dispatcher.Main : Android Main Thread에서 코루틴을 실행하는 Dispatcher
  • Dispatcher.IO : 디스크/네트워크 등 I/O 작업을 실행하는데 최적화된 Dispatcher
  • Dispatcher.Default : CPU를 많이 사용하는 작업(정렬 등)을 기본 스레드 외부에서 실행하는데 최적화된 Dispatcher

따라서 안드로이드에서 뷰 작업이 관여된 작업을 수행할 시, Dispatcher.Main이 아닌 다른 디스패처를 사용해서 작업을 수행한다면 앱이 작동 중지된다. 안드로이드에서 뷰 작업의 경우 MainThread를 사용해야 하기 때문이다. 이 디스패처를 통해 CoroutineScope를 만드는데, 용도에 맞는 Dispatcher를 넣어주면 된다.

Main Dispatcher를 사용해 CoroutineScope 만들기
CoroutineScope(Dispatcher.Main)

launch, async

위에서 만든 CoroutineScope에 두 가지 메서드를 사용하여, 디스패처에 코루틴을 전달할 수 있다. 두 가지 메서드는 launch(){}와 async(){}이다. 두 가지의 차이는 정말 간단하다. 두 메서드의 스코프 내에는 우리가 처리하고 싶은 작업의 내용이 구현되는데, 이 작업의 내용에서 특정 데이터 값을 리턴시키고 싶으면 async(){}, 리턴할 값 없이 작업만 수행하고 싶다면 launch(){}를 사용한다. 특정데이터 값을 반환하진 않지만, Job이라는 타입의 작업을 반환한다.

비동기 작업으로 1 + 2의 값을 return 또는 print하고 싶다. 어떻게 해야할까?

CoroutineScope(Dispatcher.Main).launch{
	//print(launch{})
    launch{ print(1 + 2) } 
    
    //return(async{})
    val deferredResult : Deferred<Int> = async{
    	1 + 2
    }
    print( deferredResult.await() )
}

이런 방식으로 구현하면된다. async의 경우 Deferred<Int> 반환 타입은 아마 처음 볼 것이다. 이 Deferred라는 타입은 미래에 할당될 수 있는 값에 대한 데이터 타입 이다. 본래 return은 즉시 값을 반환한다. 하지만 비동기 처리의 경우 즉시 값이 반환되어야 할 타이밍에 아직 작업이 진행되고 있을 가능성이 높다. 따라서 Deferred라는 값의 데이터 타입을 사용하는 것이다. 참고로 Deferred는 launch 메서드가 반환하는 타입인 Job을 확장한 인터페이스이다. async 작업을 담은 변수 deferredResult에 .await() 메서드를 사용한 것이 확인된다. await()은 해당 비동기 작업이 끝날 때까지 대기 처리하는 메서드이다. 따라서 print( deferredResult.await() )는 deferredResult의 async 작업이 끝나고 난 뒤 처리된다.

그리고 하나의 CoroutineScope 내에서 Dispatcher를 자유자재로 변경할 수 있을까? 이러한 궁금증은 충분히 합리적이다. 클라우드 서버에 저장해놓은 값을 리턴해서 (I/O Dispatcher), View에 해당 값을 설정해줄 수도 있지 않을까. 또 리스트를 받아와서 정렬을 하고 나서 뷰에 뿌려줄 수도 있는 것이고 말이다. 해당 처리는 매우 쉽다. Coroutine은 사용자가 매우 쉽게 사용할 수 있도록 만들어져있다.

//Dispatcher.Main에서
CoroutineScope(Dispatcher.Main).launch{
   //Dispatcher.IO 사용
   val deferredResult : Deferred<ArrayList<Int>> = async(Dispatcher.IO){
      arrayList<Int>
   }

   //Dispatcher.Default 사용
   launch(Dispatcher.Default){
      deferredResult.sort()
   }
  
   //Dispatcher.Main 사용
   setRecyclerView(deferredResult)
}

'잠깐 다른 디스패처로 덮어쓴다'라는 표현이 어울릴 정도로, 그냥 다른 Dispatcher을 생성해서 사용하면 된다. 여기까지가 새로운 coroutine을 생성하는 두 메서드 launch(){}, async(){}을 알아봤다.
launch{}는 특정 값을 반환하지 않고, Job이라는 타입으로 작업을 반환하며,
async{}는 특정 값을 반환한다. 비동기 작업 특성 상 Deferred<T> 타입으로 값을 반환 받아야 한다.
이정도로 넘어가보자. 아까 언급했던 코루틴 대기 메서드 await()에 관련하여, suspend fun에 대한 얘기를 시작할 것이다.

suspend(중단하다)

suspend는 '중단하다'라는 뜻이다. suspend fun은 중단 가능한 함수이다. 왜 중단 가능이 필요하다는 것일까. 전에 작성한 Thread 글에서 blocking에 대해 언급했다. 여기 서로 다른 코루틴 1과 코루틴 2가 있다. 코루틴 1의 작업은 코루틴 2 작업이 마치고 나서야 실행될 수 있다고 가정해보자. 그럼 코루틴 1은 코루틴 2의 작업이 마칠 때까지 중단되어 있어야 (실행되지 않아야) 한다. 이 때 작업흐름을 어떻게 중단시킬 수 있는가?에 대한 이슈이고, 이를 간단히 할 수 있는 것이 위에서 언급된 await() 메서드이다.

그리고 여기서 coroutine의 이점이 발휘된다. 기존의 코루틴이 아닌 스레드의 경우, 대기 처리를 하면 해당 스레드가 통째로 쉬고 있어야 했다. 이것은 자원 낭비로 이어질 수 있다. 반면 코루틴의 경우 특정 작업을 await() 처리하면, 동일한 Dispatcher를 사용하는 작업을 곧바로 진행할 수 있다. 즉, 대기하느라 자원을 낭비할 수도 있던 쓰레드를 더욱 남김없이 사용할 수 있다는 것이다. 그리고 코루틴이 이를 관리한다. 특정 코루틴 작업을 일시 중단하게 해줄 수 있도록. 이 때문에 어떤 특정작업이 await() 처리되어야 하는 경우, 해당 작업은 CoroutineScope의 스코프 내부 또는 일시중단 가능한 함수 suspend fun 함수의 스코프 내부에서만 처리할 수 있다. 실제로 CoroutineScope 외부에서 await() 메서드를 사용할 수 없다.

Job Lazy - start(), join()

이 정도면 Coroutine을 사용할 준비는 거의 끝이 났다. 이번엔 Job에 대한 내용을 얘기해보자. CoroutineScope(Dispatcher).launch{}는 Job이라는 타입으로 스코프 내 비동기 작업을 반환한다. 해당 작업에 메소드를 추가적으로 사용함으로써 더욱 다양하게 처리할 수 있다. 대표적으로, 실행시점을 조절할 수 있다는 것이다. CoroutineStart.LAZY를 사용하면 된다. 해당 값을 launch 메서드의 인자값으로 전달하면, 특정 시점이 되기 전까지 해당 코루틴을 실행하지 않는다. 이를 Lazy하게 생성된 Job이라고 하는데, 두 가지 메서드를 사용하여 해당 Job을 실행시킬 수 있다. start()와 join()이다.

CoroutineScope(Dispatcher.Main).launch{
   val job : Job = CoroutineScope(Dispatcher.IO).launch(start = CoroutineStart.LAZY){
      print(1)
   }

   //해당 시점에 Job 실행
   job.start()
}

하지만 콘솔에 1은 출력되지 않을 것이다. IO Thread는 Main Thread가 종료되면 같이 종료되기 때문이다. 가장 아래에 delay(1000) 처리를 하면되는 걸까? 물론 출력은 될 것이다. Print(1)을 처리하는 시간이 1초보단 작으니. 하지만 바람직하지 않다. print(1)이 0.05초만에 실행되고 종료된다면, Main Thread는 0.95초동안 목적 없이 낭비 되기 때문이다. 자원 낭비이다. 이런 경우를 대비해 join()이 있는 것이다. 위의 코드를

CoroutineScope(Dispatcher.Main).launch{
   val job : Job = CoroutineScope(Dispatcher.IO).launch(start = CoroutineStart.LAZY){
      print(1)
   }

   //해당 시점에 Job 실행하고, 해당 Job이 종료될 때까지 해당 코루틴을 일시 중단해준다.
   //이 때 해당 코루틴은 MainThraed의 코루틴이다.
   job.join()
}

로 바꾸게 되면, 주석처리돼있는 것과 동일하게 처리된다. delay()로 자원낭비 또는 크래쉬를 발생시킬 염려 없이, job의 작업이 끝나면 Main Thread가 종료되는 훈훈한 광경을 볼 수 있다. join()은 코루틴을 일시중단 처리해주는 메서드이다. 당연히! CoroutineScope, suspend fun 스코프 내부에서만 사용 가능하다.

취소, 취소요! cancel.

launch, 즉 Job은 실행이 취소될 수 있어야한다. 우리가 네트워크로 특정 데이터를 받아오는 작업을 했을 때 지연이 발생했다고 가정해보자. 그리고 현재 코루틴은 해당 작업에 await() 처리를 해놓은 상태이다. 어떻게 될까? 계속 기다려야지. 어쩔 수 없다. 이러한 상황을 미연에 방지하기 위해, 특정 상황 기준 충족 시 Job을 취소할 수 있어야 한다는 것이다.

Job을 취소하는 메서드의 이름은 정직하다. cancel()이다. 그리고 해당 메서드에 String 타입의 message와 Throwable을 넘길 수 있다. cancel 원인을 출력하는 메서드도 존재하며, job이 종료됐을 때 수행할 코드를 작성하는 메서드인 invokeOnCompletion{}을 통해 에러 유무에 따라 다른 코드를 수행할 수 있도록 조치해놓을 수 있다.

//Job 취소
job.cancel()
(or)
job.cancel("Job Cancelled by User", InterruptedException("Cancelled Forcibly"))

//Job 종료 시 수행할 코드
//throwable이 null이면 성공적으로 수행된 것이다.
job.invokeOnCompletion { throwable -> 
   when(throwable){ 
      is CancellationException -> println("Cancelled") 
      null -> println("Completed with no error") 
   } 
}

//cancel 원인 출력
//cancel시 넘겨지는 Exception의 종류는 CancellationException으로 고정된다. 
//위에서는 InterruptedException을 넘겼지만, 출력되는 것은 CacellationException이며
//cancel시 넘긴 Throwable은 반영되지 않는다.
println(job.getCancellationException())

여기까지면..

이정도면 Coroutine을 어느정도 사용할 준비가 된 것 같다. Coroutine 사용도가 매우 높은만큼 필자는 추가적인 공부를 진행할 예정이다. 일단 프로젝트부터 손 봐야 할 것 같다. 잘 모르고 코드를 짜놨다. 어렴풋하게 기억이 나는데, 비효율적으로 코드를 짜놓은 부분이 있던 걸로 기억난다. 아무튼 여기까지!

profile
한 눈에 보기 : https://velog.io/@dongwan999/LIST

0개의 댓글