2장에서는 안드로이드에서 코루틴을 사용하기위한 기초를 담고있다.

안드로이드의 UI 스레드

안드로이드의 UI 스레드는 UI를 업데이트하고, 사용자와의 상호작용을 리스닝하며, 사용자에 의해 생성된 이벤트 처리와 같이 사용자와의 상호작용 처리를 전담하는 스레드이다.

이처럼 안드로이드의 UI 스레드는 사용자 경험과 관련된 처리들을 전담하기 때문에 블로킹되거나 오랜시간 사용자 상호작용이 아닌 처리를 담당해서는 안된다. 만일 그런 일이 발생한다면 사용자에게 텅 빈 화면을 노출시키거나 ANR을 발생시킬 수도 있다.

이러한 이유로 안드로이드에서는 UI 스레드와 백그라운드 스레드를 분리하는 것이 중요하고, UI 스레드와 백그라운드 스레드를 분리하는 것을 목적으로 정의된 Exception들이 존재한다.

  1. CallFromWrongThreadException
    : UI 스레드가 아닌 스레드에서 UI 업데이트를 시도하면 발생할 수 있는 예외이다. UI 스레드만이 뷰 계층을 생성하고, 업데이트 할 수 있도록, 즉 UI 업데이트 코드가 UI 스레드에서 실행되도록 보장하기 위함이다.

  2. NetworkOnMainException : Java에서 네트워크 동작은 기본적으로 블로킹이다. 그러므로 UI 스레드에서 네트워크 작업이 수행되면 UI 스레드가 블로킹되기 때문에 UI 스레드가 블로킹된다는 것은 UI가 멈추는 것을 의미하므로 이를 방지하기 위함이다.

이들의 예시는 아래에서 코드로 확인할 수 있다.

💡 백그라운드 스레드가 웹 서비스를 호출하고, 처리된 응답을 이용해서 UI 스레드에서 UI를 업데이트하는 구조로 사용자 경험을 개선하고자 하는 것 !

스레드 생성

코틀린에서는 스레드 생성 과정을 단순화해서 쉽고 간단하게 스레드를 생성할 수 있지만, 직접 액세스하거나 제어하지 않는 방법을 제공한다 !

CoroutineDispatcher ?
가용성, 부화, 설정을 기반으로 스레드 간에 코루틴을 분산 하는 오케스트레이터

코드로 살펴보자 !

class MainActivity : AppCompatActivity() {

    val netDispatcher = newSingleThreadContext(name = "ServiceCall")
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

위의 코드는 ServiceCall이라는 이름의 스레드 하나를 갖는 디스패처를 생성하는 코드이다.
만일 이 디스패처가 관리하는 코루틴은 모두 해당 스레드에서 실행되는 것이다.

디스패처에 코루틴 붙이기

이제 스레드와 코루틴을 관리하는 역할을 하는 디스패처에 코루틴을 어떻게 붙이는지 알아본다.
대표적으로 async와 launch라는 코루틴 빌더가 있고, 이들을 알아볼 것이다 !

알아보기에 앞서 비교 및 정리하고가면 좋을 것 같은 내용이 있다.

Job vs Deferred

  • Job : 코루틴 스코프를 관리하기 위해 제공되는 객체라고 볼 수 있을 것 같다. 스코프가 완료되기를 대기할 수도 있고, 스코프를 캔슬할수도 있다.
  • Deferred : 공식 문서에서는 Non-Blocking Cancellable Future라고 되어있다. 내부를 보면 Job을 확장하고 있는 것을 확인할 수 있는데, Non-Blocking Cancellable은 Job을 확장해서 가지는 성격이라고 생각한다. 그리고 Future는 리턴값을 가지기 때문에 Future 같은 역할을 한다.
    즉, 정리하면 결과값을 가지는 Job이라는 의미를 Non-Blocking Future라고 표현한 것 같
    다.

join vs await

  • join() : 코루틴이 종료될 때까지 대기
  • await() : 코루틴이 종료될 때까지 대기하며, 결과 값을 리턴

💡뇌피셜
사람들이 async는 await / launch는 join이라는 말이 async는 Deferred를 반환하기 때문인 것 같다. 사실 async도 join을 사용할 수 있지만 그러면 async를 사용하는 의미가 없기 때문이다. 또한 launch는 Job을 반환하기 때문에 await는 아예 사용할 수 없다.

그럼 이제 진짜 대표적인 코루틴 빌더인 async와 launch에 대해서 알아보자 !
( 이외에도 runBlocking 같은 것도 있다 .. 요놈은 말그대로 스레드를 블로킹한다.. )

async()

결과 처리가 필요할 때 사용하는 코루틴 빌더로, Deferred<T>를 반환한다.

  • async는 블록 내에서 예외가 발생하면 반환하는 Deferred를 이용하여 확인이 가능하다
    아래에 예외를 던지는 코드를 이용한 예제이다.
fun main(args : Array<String>) = runBlocking{
    val task = GlobalScope.async{
        doSomething()
    }
    task.join()
    if(task.isCancelled){
        val exception = task.getCancellationException()
        println("error with message : ${exception.cause}")
    } else{
        println("success")
    }
}

fun doSomething() {
    throw UnsupportedOperationException("Can't do")
}

위의 예제에서도 확인할 수 있듯이 결과값을 통해서 확인할 수 있는 값은 아래와 같다.

  • isCancelled : 예외가 발생했는지 아닌지
  • getCancellationException() : 발생한 예외 가져오기

getCancellationException은 InternalCoroutinesApi로, 코드를 작성하다보니 @InternalCoroutinesApi annotion을 필요로 했다. 이는 coroutines 내부에서만 사용해야한다는 의미이다 !

위의 예제를 실행시켜보면 예외가 발생해도 main까지 전파되어 main이 중단되거나 하지는 않는다.
그렇다면 예외를 전파시키기 위해서는 어떻게 해야할까 !

fun main(args : Array<String>) = runBlocking{
    val task = GlobalScope.async{
        doSomething()
    }
    task.join()
    if(task.isCancelled){
        val exception = task.getCancellationException()
        println("error with message : ${exception.cause}")
    } else{
        println("success")
    }
}

fun doSomething() {
    throw UnsupportedOperationException("Can't do")
}

위와 같이 join()이 아닌 await()를 사용하면 된다. await()는 결과 값을 받아오고 exception도 rethrow 해주기 때문에 예외가 main까지 전달되어 중단되는 것을 확인할 수 있다. 예외를 전파해준다는 것은 예외가 무시되지 않는 것이지만, 또 예외처리를 더 주의깊게 해야한다는 얘기도 될 수 있다 !

예외 처리 및 전파에대한 자세한 내용은 3장에서 다룬다.

launch()

결과 값이 필요하지 않을 때 사용하는 코루틴빌더로 Job을 반환하는 코루틴 빌더이다. 결과 값을 신경쓰지 않고, 코루틴을 시작한다. 단 연산이 실패하면 통보한다. ( fire-and-foget방식 )

💡 fire-and-forget ?
미사일을 발사하고 미사일에 대해서 잊고있어도 미사일은 표적을 향해서 날아가고 명중한다는 의미에서 유래되었다. 여기서는 실행 후 결과에 대해서 신경 쓸 필요가 없다는 의미로 사용되었다.

예제를 보면 확실하게 이해할 수 있다.

fun main(args : Array<String>) = runBlocking{
    val task = GlobalScope.launch{
        doSomething()
    }
    task.join()
    println("completed")
}

private fun doSomething() {
    throw UnsupportedOperationException("Can't do")
}

결과를 보면 예외를 던졌을 때, 예외를 발생시키지만 main 실행은 정상 종료되는 것을 확인할 수 있다.

코루틴을 시작할 때 디스패처 지정하기

지금까지 기본 디스패처에서 실행하는 예제들만을 살펴봤다. 하지만 코루틴을 시작할 때 실행될 쓰레드를 디스패처로 지정할 수 있다.

fun main() = runBlocking {
    val dispatcher = newSingleThreadContext(name = "ServiceCall")
    val task = launch(dispatcher) {
        printCurrentThread()
    }
    task.join()
}

fun printCurrentThread(){
    println("running in thread [${Thread.currentThread().name}]")
    // running in thread [ServiceCall]
    // 디스패처 지정하지 않으면 running in thread [main]
}

안드로이드 프로젝트에 적용하기

작성된 코드 전체는 github repository에서 확인할 수 있다.
특히 2장에 대한 커밋들은 PR #1 - Chapter2 에서 확인 가능하다.

네트워크 통신을 통해서 뉴스 목록을 fetchRssHeadlines()를 호출하여 아래와 같이 뷰를 업데이트 해주려고한다. 해당 동작을 위해서 아래와 같이 코드를 작성하면 오류가 발생한다.

이는 위에서 언급한 CalledFromWrongException의 예시이다.

GlobalScope.launch(dispatcher) {
            val headlines = fetchRssHeadlines()
            val newsCount = findViewById<TextView>(R.id.newsCount)
            newsCount.text = "Found ${headlines.size} News" // 문제 발생

        }

이를 해결하기 위해서는 아래와 같이 UI 스레드 디스패처를 사용해서 코루틴을 시작하고 그 안에서 뷰 업데이트를 진행해주면 된다.

GlobalScope.launch(dispatcher) {
            val headlines = fetchRssHeadlines()
            val newsCount = findViewById<TextView>(R.id.newsCount)
            GlobalScope.launch(Dispatchers.Main){
                newsCount.text = "Found ${headlines.size} News"
            }
        }

위의 코드들은 onCreate()안에 우선 작성되어 있었다.

그런데 만일 나중에 새로고침과 같은 버튼이 생긴다면 어떻게 될까 ? 해당 코드들을 반복해서 또 작성해주어야 할 것이다 ! 이를 방지하기 위해서 뉴스를 받아와 뷰에 반영하는 부분의 코드를 함수로 뽑아내서 작성하고, 해당 함수를 onCreate()에서 호출하게끔 아래와 같이 수정해주었다.

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        loadNews()
    }

    private fun loadNews(){
        val headlines = fetchRssHeadlines()
        val newsCount = findViewById<TextView>(R.id.newsCount)
        GlobalScope.launch(Dispatchers.Main){
            newsCount.text = "Found ${headlines.size} News"
        }
    }

하지만 이 코드에도 문제가 있다. 심지어 실행하면 오류가 발생하는 것을 확인할 수 있다.
이것이 또 위에서 언급한 NetworkOnMainThreadException의 예시이다.

호출된 함수는 호출한 함수와 같은 스레드에서 실행이 된다. 즉, 위의 코드에서 loadNews는 UI 스레드에서 실행되는 것이다. 그런데 loadNews는 네트워크 통신을 수행하는 fetchRssHeadlines를 호출하고 있기 때문에, UI 스레드에서 네트워크 통신을 수행하게 되므로 예외가 발생하는 것이다 !

이를 해결하려면 기존처럼 onCreate에서 dispatcher로 스레드를 지정해서 코루틴을 시작하고, 그 안에서 loadNews를 호출하면 된다. 하지만 loadNews를 분리한 목적을 생각해보면, 해당 함수가 여러 곳에서 호출될 수 있는데, 그러면 호출하는 곳마다 코루틴 스코프를 만들어야하고, 코루틴 빌더가 여러번 작성돼야 할 것이다. 이는 좋은 방법이 아닐 수 있다.

그래서 아래와 같이 loadNews 함수 자체를 launch 호출을 포함하고, Job을 반환하는 asyncLoadNews로 만들 수 있다. 그러면 스레드와 무관하게 해당 함수를 호출할 수 있고, 해당 함수는 지정된 스레드에서 실행된다. 또한 호출한 쪽에서 반환되는 job을 이용해서 cancel 할 수도 있게된다.

private fun asyncLoadNews() = GlobalScope.launch(dispatcher){
        val headlines = fetchRssHeadlines()
        val newsCount = findViewById<TextView>(R.id.newsCount)
        GlobalScope.launch(Dispatchers.Main){
            newsCount.text = "Found ${headlines.size} News"
        }
    }

함수에 async를 붙여서 호출하는 입장에서 비동기 함수라는 것을 인지할 수 있고, 함수가 완료되는 것을 기다리지 않는다거나 할 수 있다 !

위의 코드는 해당 함수가 특정 스레드에서 실행되게 강제하고 싶다면 유용하지만, 유연성이 조금 떨어질 수 있기 때문에 비동기 함수에 디스패처를 주입하는 방식으로 구현한다면 호출하는 입장에서 디스패처를 지정할 수 있기때문에 유연성이 조금 올라갈 수 있는 선택지이다.

코드로 작성해보면 아래와 같다.

private fun asyncLoadNews(dispatcher : CoroutineDispatcher) = GlobalScope.launch(dispatcher){
        val headlines = fetchRssHeadlines()
        val newsCount = findViewById<TextView>(R.id.newsCount)
        GlobalScope.launch(Dispatchers.Main){
            newsCount.text = "Found ${headlines.size} News"
        }
    }

위의 과정을 쭉 보면 아래의 세 가지 방법으로 네트워크 통신 코드를 호출해봤다.

  • 동기 함수를 async, lauch로 감싸기
  • 특정 디스패처로 실행되는 비동기 함수
  • 디스패처를 주입받는 비동기 함수

필자는 각자의 장단점이 있고, 상황에 따라서 판단해서 사용하면 된다고 언급하고 있다.

맞는 말이라고 생각한다. 호출되는 곳이 다양한지, 실행 스레드를 지정하는데 강제성을 띄는게 좋은지 아니면 유연한 게 좋은지 등등 위의 설명을 참고하여 다양한 요소를 고려하여 상황에 맞게 사용하면 좋을 것 같다 !

0개의 댓글