Coroutine (코루틴)

김명진·2020년 12월 29일
0

안드로이드

목록 보기
2/25

흔히 사용하는 콜백으로 구성된 코드는 깊이가 깊어지고 관리하기 어려워지는 단점이 있다. 콜백의 단계가 늘어다면 관리하기가 어려워지고 대부분 프로그래밍을 하다보면 콜백이 10단 이상으로 늘어나는 경우도 있다.

이런 깊은 단계의 콜백을 죽음의 피라미드 혹은 콜백 지옥이라고 부른다. 또 다른 단점은 에러 처리가 불편하다. 선형적으로 짜는 프로그램과 달리, 콜백 체인에서 적적히 에러 처리를 하는게 쉽지 않다. 매번 콜백마다 에러 처리를 하면 되지만 이것 또한 콜백 깊이가 깊어질수록 복잡해지고 관리도 어려워진다. 그래서 코루틴을 사용할것이다.

코루틴은 비선점형 멀티태스킹을 하는 일반화된 서브루틴이다. 멀티태스킹을 하기 위해 일반적으로 하나의 서브루틴이 특정한 스레드에서 동작하는데, 코르틴은 유예(suspend)와 재개(resume)를 통해 잠시 튻정 스레드에 수행되었다가 다른 서비루틴을 위해 양보할 수 있는 방식이다.

코루틴 예제:

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	MainScope().launch{
		val fetchedData = fetchData()
		val processedData = processData(fetchData)
		Log.d("AAA",processedData)
	}
}

suspend fun fetchData() = withContext(coroutineContext) {
	Thread.sleep(1000)
	"something"
}

suspend fun processData(data : String) = withContext(coroutineContext) {
	Thread.sleep(1000)
	data.toUpperCase()
}

1. fetchData 메서드를 호출했다.
2. 데이터를 가져오는 동안 (fetchData 메서드가 완료될 때까지) 기다린다.
3. processData를 호출한다.
4. 데이터를 변경하는 동안 (processData가 완료될 때까지) 기다린다.
5. 다시 호출자(caller)로 돌아간다. 그때까지 caller는 기다리고 있다.

블록으로 설명
1. fetchData 메서드를 호출하고 launch 블록은 보관된다.
2. fetchData가 데이터를 가져오는 동안 launch 블록은 기다리지 않는다
3. 데이터를 가져온 후 launch 블록은 깨어난다.
4. 깨어난 launch 블록은 processData를 호출하고 launch 블록은 보관된다.
5. processData가 자료를 가져오는 동안 launch 블록은 기다리지 않는다.
6. 데이터를 가져오면 launch 블록은 깨어나 해당 데이터를 사용한다.

조금더 디테일하기 보면

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	MainScope().launch{
		val fetchedData = fetchData()
		val processedData = processData(fetchData)
		Log.d("AAA",processedData)
	}
}

MainScope() 키워드로 코루틴 스코프를 만들었다. 코루틴에서 코루틴은 스코프에 의존적이다. 적절한 스코프가 없으면 전역적인 스코프인 GlobalScope라도 써야한다 (추천하지 않는 방법). GlobalScope는 전역적으로 영향을 주기 때문에관리가 힘들다.

MainScope는 메인 스레드에서 수행되는 코루틴 스코프다. 코루틴을 만드는 함수를 코루틴 빌더라고 부른다. ContextScope.launch, ContextScope.async, withContext, runBlocking 등 코루틴을 만들어 내는 코루틴 빌더다.

suspend fun fetchData() = withContext(coroutineContext) {
	Thread.sleep(1000)
	"something"
}

suspend fun processData(data : String) = withContext(coroutineContext) {
	Thread.sleep(1000)
	data.toUpperCase()
}

fetchData와 processData 메서드들은 suspend 함수이다. 코루틴 빌더인 withContext는 뒤의 블록의 내용을 코루틴 내에서 수행한 후 결과를 반환한다. 수행을 마치고 결과를 반환하기 떄문에 사용하기 편하다. withContext 뒤에 인자로 coroutineContext를 넣어두었는데, 모든 코루틴 스코프는 Coroutine Context를 가지고 있고, 일 처리는 모두 Coroutine Context에 의존해서 진행한다. Coroutine Context는 일종의 불변 집함으로 코루틴의 이름, 어떤 스레드에서 수행될지 결정하는 디스패처, 동작을 바꾸는 인터셉터, 코루틴을 컨트롤할 수 있는 잡 객체 등을 가지고 있다. launch와 async는 확장 함수이기따문에 굳이 Coroutine Context를 추가할 필요없다.

외부 스레드 사용하기

suspend fun fetchData() = withContext(Dispatchers.IO) {
	Thread.sleep(1000)
	"something"
}

suspend fun processData(data : String) = withContext(Dispatchers.IO) {
	Thread.sleep(1000)
	data.toUpperCase()
}

Dispatchers.IO를 지정하면 별도의 스레드에서 fetchData와 processData를 수행한다.

앞서 withContext는 수행이 끝날 떄까지 기다렸다 반환 값을 전달한다. 편리한 반면에 하나의 호출을 기다려야 하는 불편함이 있다. async는 여러 호출을 병렬적으로 진행할 수 있다.

fun CoroutineScope.fetchDataAsync() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"something"
}

또 특히점은 suspend 함수가 아니다. suspend 함수는 일을 수행한 후에 리턴을 해야한다. 하지만 async는 호출하고 바로 종료되며 반환값은 Deferred 타입이다.

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	MainScope().launch{
		val fetchedData = fetchData()
		val processedData = processData(fetchData.await())
		Log.d("AAA",processedData)
	}
}

fun CoroutineScope.fetchDataAsync() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"something"
}

suspend fun processData(data : String) = withContext(coroutineContext) {
	Thread.sleep(1000)
	data.toUpperCase()
}

fetchDataAsync 호출 측면에서도 달리진 부분이 있는데 반환 값을 바로 쓰지 못하고 await 메서드를 호출하는 부분이다. fetchDataAsync는 실행이 끝나기 전에 반환되었다. await에서 쉬었다가 async가 호출이 끝난 후 재개되면 withContext를 수행했을 때보다 성능적인 이점이 없고 타이핑의 양만 늘었는데 왜 필요한다?

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	MainScope().launch{
		val deferred1 = fetchDataAsync()
		val deferred2 = fetchDataAsync2()
		val processedData = processData("${deferred1.await()} ${deferred2.await()}")
		Log.d("AAA",processedData)
	}
}

fun CoroutineScope.fetchDataAsync() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"something"
}

fun CoroutineScope.fetchDataAsync2() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"good"
}

이렇게 async 코루틴 두 개를 불러오고 await를 호출할 경우 fetchDataAsync와 fetchDataAsync2는 별도의 스레드에서 거의 동시에 수행되고, launch 블록은 둘다 수행되길 기다렸다 재개된다.

메인 스레드도 낭비되지 않으며 두 작업 모두 별도의 스레드에서 수행되니 상대적으로 더 효율적이다.

잡 취소

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	
	val mainScope = MainScope() (1)
	val job = mainScope.launch{  (2)
		val deferred1 = fetchDataAsync()  (3)
		val deferred2 = fetchDataAsync2()
		// mainScope.cancel()  (4)
		// job.cancel()        (5)
		// deferred1.cancel()  (6)
		val processedData = processData("${deferred1.await()} ${deferred2.await()}")
		Log.d("AAA",processedData)
	}
}

fun CoroutineScope.fetchDataAsync() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"something"
}

fun CoroutineScope.fetchDataAsync2() = async(Dispatchers.IO) {
	Thread.sleep(1000)
	"good"
}

(2) launch 빌더가 job를 반환하고
(3) async 빌더가 반환하는 Deferred<T> 타입 역시 job이다
(5),(6) 둘다 cancel 메서드를 호출해서 종료할 수 있다.
조금 다른행텨가 하나 있는데 (1)에 있는 CoroutineScope에 대해 (4) cancel 메서드를 호출하는 것이다. 

더 좋은 방법은 MainScope를 onCreate에 호출하고 onDestroy에서 해제하는것이다.

override fun onDestroy(){
	super.onDestroy()
	mainScope.cancel()
}

하지만 스코프나 잡을 적절히 챙기고 해제하는 식으로 수동으로 관리하면 실수하기 쉽다. 조금 더 나은 방식은 생명주기를 인지하는 코루틴 스코프를 사용하면 알아서 해제된다. 생명주기 이해 코루틴 스코프는 LifecycleCoroutineScope이다. LifecycleCoroutineScope는 액티비티나 프래그먼트의 생명주기를 이해하고 거기에 맞추어서 코루틴 스코프를 제공한다.

LifecycleCoroutineScope를 사욜하기 위해서는 app/build.gradle에 의존성을 추가로 해야한다.

override fun onCreate(savedInstanceState: Bundle?){
	super.onCreate(savedInstanceState)
	setContentView(R.layout.activity_main)
	
	lifecycleScope.launch{ 
		val deferred1 = fetchDataAsync() 
		val deferred2 = fetchDataAsync2()
		val processedData = processData("${deferred1.await()} ${deferred2.await()}")
		Log.d("AAA",processedData)
	}
}

MainScope에 관련한 로직을 모두 제거하고 lifecycleScope로 코루틴 스코프를 불러오면 된다. lifecycleOwner에서는 lifecycle(LifecycleOwner.lifecycleScope)를 호출할 수 있는데 Activty와 Fragment가 LifecycleOwner이다.

delay 메서드

Thread.sleep 메서드는 스레드를 독점하는 문제가 있다. delay 메서드는 현재 실행되는 코드 블록을 일정 시간을 쉬게 하고 다른 코드 블록에 현재 스레드를 쓸 기회를 준다.

fun CoroutineScope.fetchDataAsync() = async(Dispatchers.IO) {
	delay(1000)
	"something"
}

코루틴과 Retrofit

레트로핏에 코루틴을 사용하면 콜백 없이 반환 값을 직접 사용할 수 있다.

interface UserAPI{
	@GET("user/")
	fun listUsers(): Call<Response>

	@GET("user/")
	fun listUsers(@Query("name") name: String,
								@Query("nickname") nickname: String): Call<Response>

	@GET("user/{pid}/")
	suspend fun getUser(@Path("pid") pid: Int) : UserResponse
}

getUser이 suspend function이기 때문에 getUser은 다른 suspend나 코루틴 내에서만 호출할 수 있다.

class ViewModel: ViewModel(){
	var response = MutableLiveData<UserResponse>()

	fun getUser(pid: Int){
		viewModelScope.launch{
			val user = Repository.getUser(pid)
			response.value = user
			}
		}
}

getUser 은 viewModelScopde.lauch 블록 내에서 수행하는 것 이외에 큰 차이는 없다

viewModelScope.launch{
	val user = Repository.getUser(pid)
	response.value = user
}

다른 예제:

@GET("users/{userName}/repos")
suspend fun getRepo(@Path("userName") userName: String): JsonArray

GlobalScope.launch(Dispatchers.IO) {
    val res: JsonArray = api.getRepo("papered")
    Log.d("retrofit", res.toString())
}
profile
꿈꾸는 개발자

0개의 댓글