[Android] 10(2). 코루틴

7 minute read


코루틴

안드로이드는 앞에서 살펴본 스레드를 경량화한 코루틴^Coroutine^이라는 새로운 도구를 제공합니다. 다른 언어에서 이미 사용되고 있는 동시성 프로그래밍 개념을 도입한 것이며, 코루틴의 코^Co^ 는 ‘함께’ 또는 ‘동시에’라는 의미입니다.

코루틴에서 스레드는 단지 코루틴이 실행되는 공간을 제공하는 역할을 하는데, 실행 중인 스레드를 중단시키지 않기 때문에 하나의 스레드에 여러 개의 코루틴이 존재할 수 있습니다.

image-20210823150237512

위의 구조를 스레드를 이용해서 처리한다면 1번에 해당하는 스레드가 잠시 멈추고 2번 스레드가 처리하도록 우선순위를 넘겨야만 합니다. 이러한 ‘컨텍스트 스위칭’ ^ContextSwitching^ 이 자주 일어날수록 성능의 저하가 발생합니다.

코루틴은 이런 컨텍스트 스위칭을 하나의 스레드에서 처리하므로 성능 저하가 적고, 동일한 구조에서는 스레드보다 훨씬 적은 자원을 소모합니다.


버전에 따른 코루틴 설정


버전에 따라 코루틴을 내장하는 버전도 있고 그렇지 않은 버전도 있어서, 현재 사용하는 안드로이드 스튜디오에서 코루틴을 지원하는 지 확인할 필요가 있습니다.

MainActivity.kt를 열고 onCreate( ) 메서드 안에서 CoroutineScope를 입력했을 때 자동완성이 된다면 추가 설정이 필요치 않습니다.

자동완성 되지 않는다면, build.gradle 파일에 아래 의존성을 추가합니다.

dependencies{
    ...
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
}


코루틴의 최신 버전은 안드로이드 공식 페이지에서 확인할 수 있습니다.



코루틴 스코프


코루틴은 정해진 스코프 안에서 실행되는데 이것을 ‘코루틴 스코프’^CoroutineScope^라고 합니다. 앞선 포스팅에서 살펴본 코틀린의 thread(start=true) { /* 실행 코드 */ } 와 비슷하게 정해진 스코프 안의 코드들이 코루틴에서 동작합니다.

다음은 GlobalScope.launch를 사용해서 코루틴을 실행하는 간단한 코드입니다.

GlobalScope.launch{
    // 여기 작성된 코드가 코루틴으로 실행
}


코루틴을 실행하는 스코프에는 글로벌 스코프^GlobalScope^와 코루핀 스코프^CoroutineScope^가 있는데, 다음과 같은 사용상의 차이점이 있습니다.

  • 글로벌 스코프: 앱의 생명 주기와 함게 동작하기 때문에 실행되는 동안은 별도의 생명 주기 관리가 필요하지 않습니다. 주로 앱의 시작부터 종료될 때까지 혹은 장시간 실행되어야 하는 코루틴이 있을 경우에 사용합니다.
  • 코루틴 스코프: 버튼을 클릭해서 서버의 정보를 가져오거나 파일을 여는 등의 필요할 때만 열고 완료되면 닫는 경우에 사용합니다.
binding.btnDownload.setOnClickListener{
    CoroutineScope(Dispatchers.IO).launch{
        // 여기서 이미지를 불러오는 등의 코드를 처리
    }
}

글로벌 스코프와는 다르게 코루틴 스코프는 괄호 안에 Dispatchers.IO라는 상숫값이 입력되어 있습니다. 이것은 디스패처라고 하는데, 코루틴이 실행될 스레드를 지정하는 것이라고 생각하면 됩니다.


디스패치의 종류

코루틴이 실행될 스레드를 정하는 디스패처^Dispatcher^는 IO, Main, Default, Unconfined 등이 있는데, 이 중 주로 IO와 Main을 조합해서 사용합니다.

종류 역할
Dispatchers.Default CPU를 많이 사용하는 작업을 백그라운드 스레드에서 실행하도록 최적화되어 있는 디스패처입니다. 안드로이드의 기본 스레드풀(Thread Pool)을 사용합니다.
Dispatchers.IO 이미지 다운로드, 파일 입출력 등의 입출력에 최적화되어 있는 디스패처입니다.
Dispatchers.Main 안드로이드의 기본 스레드에서 코루틴을 실행하고 UI와 상호작용에 최적화되어 있는 디스패처입니다. UI 컴포넌트를 참조하는 경우 Main 컨텍스트를 사용해야 합니다.
Dispatchers.Unconfined 조금 특수한 컨텍스트입니다. 자신을 호출한 컨텍스트를 기본으로 사용하는데, 중단 후 다시 실행하는 시점에 컨텍스트가 바뀌면 자신의 컨텍스트도 다시 실행하는 컨텍스트를 따라갑니다.



launch와 상태 관리


코루틴은 launch 또는 async로 시작합니다.

launch는 상태를 관리할 수 있고 async는 상태를 관리하고 연산 결과까지 반환받을 수 있습니다. launch는 호출하는 것만으로 코루틴을 생성할 수 있고, 반환되는 잡^Job^을 변수에 저장해두고 상태 관리용으로 사용할 수 있습니다.

생성된 코루틴에서 상태 관리 메서드를 호출하면 코루틴의 실행을 멈추거나 동작을 지연시킬 수 있습니다.


cancel

코루틴의 동작을 멈추는 상태 관리 메서드입니다. 하나의 스코프 안에 여러 개의 코루틴이 있다면 하위의 코루틴들도 모두 동작을 멈춥니다.

다음 코드의 마지막 버튼 클릭 리스터에서 job의 cancel 메서드가 호출되면 job뿐만 아니라 같은 스코프에 있는 job1의 코드도 모두 동작을 중단합니다.

val job = CoroutineScope(Dispatchers.Default).launch{
    val job1 = launch{
        for (i in 0..10){
            delay(500)
            Log.d("코루틴", "결과 = $i")
        }
    }
}

binding.btnStop.setOnClickListener{
    job.cancel()
}


join

코루틴 스코프 안에 선언된 여러 개의 launch 블록은 모두 새로운 코루틴으로 분기되면서 동시에 처리되기 때문에 순서를 정할 수 없습니다. 이럴 때 launch 블록 뒤에 join( ) 메서드를 사용하면 각각의 코루틴이 순차적으로 실행됩니다.

다음 코드는 코루틴 스코프 안에 2개의 코루틴이 launch로 사용되었는데, join( ) 메서드로 인해 앞의 코루틴 실행이 완료된 후에 두 번재 코루틴이 실행됩니다.

CoroutineScope(Dispatchers.Default).launch(){
    launch{
        for(i in 0..5){
            delay(500)
            Log.d("코루틴", "결과1 = $i")
        }
    }.join()
  
    launch{
        for(i in 0..5){
            delay(500)
            Log.d("코루틴", "결과2 = $i")
        }
    }
}



async와 반환값 처리


async는 코루틴 스코프의 연산 결과를 받아서 사용할 수 있습니다.

예를 들어 시간이 오래 걸리는 2개의 코루틴을 async로 선언하고, 결괏값을 처리하는 곳에서 await 함수를 사용하면 결과 처리가 완료된 후에 await을 호출한 줄의 코드가 실행됩니다.

CoroutineScope(Dispatchers.Default).async{
    val deferred1 = async{
        delay(500)
        350
    }
    val deferred2 = async{
        delay(1000)
        200
    }
    Log.d("코루틴", "연산 결과 = ${deferred1.await() + deferred2.await()}")
}



suspend


코루틴과 스레드의 가장 큰 차이점이자 특징이 suspend 키워드입니다.

코루틴 안에서 suspend 키워드로 선언된 함수가 호출되면 이전까지의 코드 실행이 멈추고, suspend 함수의 처리가 완료된 후에 멈춰 있던 원래 스코프의 다음 코드가 실행됩니다.

suspend fun subRoutine(){
    for (i in 1..10){
        Log.d("SubRoutine", "$i")
    }
}

CoroutineScope(Dispatchers.Main).launch{
    // (코드 1)
    subRoutine()
    // (코드 2)
}

위 코드를 보면 ‘(코드 1)’이 실행된 후 SubRoutine( ) 함수가 호출됩니다. suspend 키워드를 사용했기 때문에 subRoutine( ) 안의 코드가 모두 실행된 후에 ‘(코드 2)’가 실행됩니다.

**이는 함수 차원에서 ‘join( )’을 사용하는 것으로 볼 수 있습니다. **


위에서 subRoutine( )은 suspend 키워드를 붙였기 때문에 CoroutineScope 안에서 자동으로 백그라운드 스레드처럼 동작합니다. suspend가 코루틴을 가장 잘 나타내는 이유는 subRoutine( )이 실행되면서 호출한 측의 코드를 잠시 멈췄지만 스레드의 중단이 없기 때문입니다.

이 코드를 스레드로 작성했다면 부모에 해당하는 ‘(코드 1)’이 동작하는 스레드를 멈춰야만 가능한데, 코루틴에서는 부모 루틴의 상태 값을 저장한 후 subRoutine( )을 실행하고, 다시 subRoutine( )이 종료된 후 부모 루틴의 상태 값을 복원하는 형재로 동작하므로 스레드에는 영향을 주지 않습니다.

이런 구조가 스레드의 동시성에서 발생할 수 있는 성능 저하를 막아줍니다.



withContext로 디스패처 분리


suspend 함수를 코루틴 스코프에서 호출할 때 호출한 스코프와 다른 디스패처를 사용해야 할 때가 있습니다.

예를 들어 호출 측 코루틴은 Main 디스패처에서 UI를 제어하는데, 호출되는 suspend 함수는 디스크에서 파일을 읽어와야 하는 경우 withContext를 사용해서 호출되는 suspend 함수의 디스패처를 IO로 변경할 수 있습니다.

suspend fun readFile(): String{
    return "파일 내용"
}

CoroutineScope(Dispatchers.Main).launch{
    // 화면 처리
    val result = withContext(Dispatchers.IO){
        readFile()
    }
    Log.d("코루틴", "파일 결과 = $result")
}



이미지 다운로드 앱 만들기


웹 상의 이미지 주소를 입력한 다음 백그라운드에서 이미지를 다운로드하고 완료되면 이미지를 화면에 보여주는 Coroutine 프로젝트를 작성해봅니다.


메니페스트에 권한 설정하고 화면 만들기

1. 인터넷 권한 명세하기

인터넷에서 이미지를 다운로드하기 위해 AndroidManifest.xml 파일에 인터넷 권한을 명세합니다.

<uses-permission android:name="android.permission.INTERNET"/>


2. 레이아웃 만들기

image-20210823172940359

  • 이미지뷰: id=imageView, layout_margin=8dp
  • 플레인텍스트: id=editUrl
  • 버튼: id=btnDownload, layout_margin=16dp
  • 프로그래스바: id=progress, (visibility=gone)

위의 레이아웃을 구성했다면 프로그래스바의 visibility 속성은 gone으로 지정하여 평상시에 보이지 않도록 합니다.


코드 작성하기

0. 코루틴 사용 확인

코루틴을 사용할 수 있는 지 확인하기 위해 MainActivitykt를 열고 onCreate 메서드 안에 CoroutineScope를 입력해서 자동완성 되는지 확인합니다.

자동완성 되지 않는다면 build.gradle 파일에 코루틴 의존성을 추가합니다.

dependencies {
    ...
    // 0. 코루틴 의존성 추가
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1"
    ...
}


1. loadImage( ) 서스펜드 함수 정의

class 코드 밖 탑레벨^Top-level^에 loadImage( ) 함수를 작성하고 suspend 키워드를 사용해서 코루틴으로 만들어줍니다.

URL 객체를 만들고 URL이 가지고 있는 openStream을 Bitmap 이미지로 반환하는 간단한 함수입니다.

// 1. URL 객체를 만들고 URL이 가지고 있는 openStream을 Bitmap 이미지로 반환하는 서스펜드 함수
suspend fun loadImage(imageUrl: String): Bitmap {
    val url = URL(imageUrl) // java.net
    val stream = url.openStream()
    return BitmapFactory.decodeStream(stream)
}

탑레벨에 함수 작성하기

탑레벨은 class, import, package와 같은 위치로, 클래스 블록 바깥 부분을 지칭합니다.


2. 버튼 클릭 리스너 달기

onCreate( ) 안에 있는 setContentView… 아래에서 btnDownload 버튼의 클릭리스너를 달아줍니다.

이 클릭 리스너 안에 코루틴으로 이미지를 다운받고 이미지뷰에 출력하는 코드를 작성합니다.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // 2. 버튼 클릭 리스너 달기
        binding.btnDownload.setOnClickListener {
            ...
        }


3. 코루틴 작성하기

먼저 클릭 리스너 안에 CoroutineScope를 추가합니다. 컨텍스트는 Main으로 입력해서 UI 관련 요소들을 다룰 수 있도록 구성합니다.

binding.btnDownload.setOnClickListener {
            // 3. 코루틴 스코프 추가, 컨텍스트는 Main
            CoroutineScope(Dispatchers.Main).launch{
              
            }
        }


코루틴 스코프 안에서 다음의 순서로 코드를 작성합니다.

  • 프로그래스바의 visibility 속성을 View.VISIBLE 로 지정하여 가시화함
  • 화면의 플레인텍스트에 입력된 값을 가져와서 url 변수에 저장
  • loadImage( ) 함수를 호출해서 Bitmap 객체를 bitmap 변수에 저장
binding.btnDownload.setOnClickListener {
            CoroutineScope(Dispatchers.Main).launch{
                binding.progress.visibility = View.VISIBLE
                val url = binding.editUrl.text.toString()
                // 파일 다운로드는 IO 컨텍스트에서 진행
                val bitmap = withContext(Dispatchers.IO){
                    loadImage(url)
                }
            }
        }

파일 다운로드와 같은 동작은 백그라운드 스레드의 IO 컨텍스트에서 하므로 withContext( )문을 사용해서 컨텍스트를 IO로 전환합니다.


마지막으로 코루틴 스코프 밖에서 이미지뷰에 이미지를 출력하고 프로그래스 바의 visibility 속성을 다시 View.GONE으로 설정합니다.

        binding.btnDownload.setOnClickListener {
            CoroutineScope(Dispatchers.Main).launch{
                binding.progress.visibility = View.VISIBLE
                val url = binding.editUrl.text.toString()
                val bitmap = withContext(Dispatchers.IO){
                    loadImage(url)
                }
                binding.imageView.setImageBitmap(bitmap)
                binding.progress.visibility = View.GONE
            }
        }


✋ 다음처럼 클릭리스너부터 시작하는 binding 처리를 run 스코프로 감싸면 다음과 같이 반복되는 binding.을 제거할 수 있습니다.

        // binding.run 블록을 이용해 반복되는 'binding' 을 제거		
				binding.run{
            btnDownload.setOnClickListener {
                // 3. 코루틴 스코프 추가, 컨텍스트는 Main
                CoroutineScope(Dispatchers.Main).launch{
                    progress.visibility = View.VISIBLE
                    val url = binding.editUrl.text.toString()
                    val bitmap = withContext(Dispatchers.IO){
                        loadImage(url)
                    }
                    imageView.setImageBitmap(bitmap)
                    progress.visibility = View.GONE
                }
            }
        }



[결과 화면]

image-20210823174817730

이미지 주소가 http라면?

이미지의 주소가 http로 시작되면 AndoridManifest.xml 파일의 < application > 태그에 android:usesCleartestTraffic=”true” 속성을 추가해야 합니다.



정리


  • 코루틴은 하나의 스레드 안에서 동시성 프로그래밍을 구현하며, 스레드와 달리 스위칭에 의한 성능 저하가 적고 자원 소모도 적습니다.
  • 코루틴의 스코프에는 글로벌 스코프와 코루틴 스코프가 있습니다.
    • 글로벌 스코프: 앱의 시작부터 종료될 때까지 혹은 장시간 실행되어야 하는 코루틴이 있을 경우에 사용합니다.
    • 코루틴 스코프: 필요할 때만 실행되고 완료되면 종료되는 경우에 사용합니다.
  • 디스패처는 코루틴이 실행될 스레드를 지정해주고, IO, Main, Default, Unconfined 등이 있습니다.
  • launch 스코프는 코루틴이 실행할 코드를 작성하는 곳입니다.
    • cancel( ) 메서드는 코루틴의 동작을 멈추도록 합니다. 하위 코루틴이 있다면 모두 멈춥니다.
    • join( ) 메서드는 지정된 코루틴이 모두 완료된 후에 이후의 코루틴이 실행되도록 합니다.
  • async 스코프는 코루틴의 연산 결과를 받아서 사용할 수 있습니다.
    • await( ) 메서드와 조합하면 시간이 오래 걸리는 코루틴의 결과 처리가 완료된 후에 await을 호출한 코드가 실행되어 반환값을 받도록 할 수 있습니다.
  • suspend 키워드는 함수 앞에 붙이는 키워드로, 코루틴 안에서 suspend 키워드로 선언된 함수가 호출되면 이전까지의 코드 실행이 멈추고 suspend 함수의 처리가 완료된 후에 다시 코드를 실행하도록 합니다.
  • withContext(디스패처) 스코프는 현재 코루틴의 디스패처와 다른 디스패처를 사용하는 코드를 실행해야 할 때 사용합니다.

Categories:

Updated:

Leave a comment