본문 바로가기
Mobile App

[안드로이드] - coroutine (코루틴) - 1

by Jman 2023. 1. 18.
반응형

코루틴

코루틴은 루틴의 일종이다. co + routine 으로 협동하는 루틴이라고 한다.

코루틴은 이전에 자신의 실행이 마지막으로 중단되었던 지점에 그 다음 장소에서 실행을 재개한다.

진입점과 출구점이 여러 개 특징을 가지고 있고, 입구점과 출구점이 하나는 서브루틴이라고 한다.

나무위키

 

코루틴은 비동기 실행을 간단한 코드로 처리할 수 있고, 메인 스레드가 블로킹되는 그러한 상태를 관리할 수 있게 도움을 준다.

또한, 코루틴은 비동기 프로그래밍을 하기 위해 콜백로 처리하는 걸, 순차적으로 짤 수 있게 해주는 장점이 있다.

비동기 처리를 순차적으로 처리할 수 있게라는 말이 간단하는 말이다.

 

구글 I/O 발표 내용

서버를 한 번 호출하고, 서버에서 온 데이터(user)를 텍스트에 넣어주는 코드를 드림코드라고 불린다.

val user = fetchUserData()
textView.text = user.name

하지만, 메인스레드에서 네트워크 Call을 하면서 NetworkOnMainThreadException 에러 발생이 뜨거나 ANR 이 뜬다.

이 부분을 극복하고자, Thread 처리를 하게된다.

thread {
    val user = fetchUserData()
    textView.text = user.name
}

스레드에다가 네트워크 콜을하고, UI 업데이트를 시키게되면 UI 를 UI스레드에서 업데이트해야 하는데, 별도 스레드에서 업데이트를 하기 때문에  CalledFromWrongThreadException 이 발생하게 된다. 그 외에도 이슈가 많다.

fetchUserData { user -> //callback
	textView.text = user.name
}

그래서 우리가 생각하고 원하던 코드 방식이 아니라, Callback 방식으로 코드를 작업해야 했다.

별도의 함수를 만들어, 서버를 콜하는 함수안에서 스레드를 이용해서 서버를 콜하고, 다시 메인 스레드로 스위칭해서 UI 를 업데이트를 하게끔 코드를 작성했다. 이렇게 하게되면, 콜백 지옥이 된다.

 

하지만, 이런 부분조차도 이슈가 있다. OutofMemoryError 다.

따라서 메모리도 관리를 해야 한다.

콜한 객체가 화면으로 업데이트할 때, clear 또는 dispose 를 해야하기 때문에 메모리를 관리해줘야하고, cancel 코드도 들어가야 한다.

 

이러한 문제 때문에 구글에서 가장 추천하는 방법이 코틀린에 코루틴이다.


코루틴은 비동기를 간단하게 해주면서, 콜백을 코루틴으로 교체할 수 있다.

기본 블로킹코드를 우선 보자

fun loadUser() {
    val user = api.fetchUser()
    show(user)
}

위 코드는 안된다는 것을 앞에 설명한 것을 읽었다면 이해가 됐을 것이다.

fun loadUser() {
	api.fetchUser { user ->
    	show(user)
    }
}

따라서, api 함수를 하나 만들어서, callback 방식으로 비동기 처리를 하게 됩니다.

하지만, 그래도 아쉽습니다. 콜백지옥이라는 단점이 존재하니깐요.

suspend fun loadUser() {
    val user = api.fetchUser()
    show(user)
}

그래서 구글에서 제안한 코루틴이라는 기술을 도입합니다.

suspend 라는 키워드를 이용하여 중단시킵니다.

비동기를 순차적인 코드로 만들 수 있습니다.


Coroutine Basic

Job ( Coroutine Context)

 

fun main() = runBlocking {
    GlobalScope.launch {
        delay(3000L)
        println("World!")
    }

    println("Hello,")
    delay(2000L)
}

위 코드를 실행하면, Hello, 만 찍히고 프로그램이 종료 될 것이다.

메인 스레드 안에, runBlocking 이 2초면 끝나게 되는데, 그 안에 있는 launch 코루틴이 3초 뒤에 world 라는 텍스트를 찍기 때문에 delay 중에, 상위 코루틴 종료와 함께 같이 종료가 된다.

 

이 부분을 Job 객체에 담아 join 을 시켜보자.

fun main() = runBlocking {
    val job = GlobalScope.launch {
        delay(3000L)
        println("World!")
    }

    println("Hello,")
    job.join()
}

이런식으로 코드를 작성하면, launch 빌더가 반환되는 Job 객체를 join 함수를 적용하면 해당 코루틴이 종료될 때까지 main 함수가 기다려준다.

 

하지만,

Job 객체를 사용해서 이 부분을 해소시킨다 하더라도 다른 이슈가 발생한다.

그 이슈는 Job 객체를 리턴하는 코루틴이 종료될 때까지 기다려주기 위해 join 을 필요에 의해서 코루틴마다 작성을 해야하기 때문이다.

그러면 중복 코드가 발생하는 이슈가 발생한다. 

job.join()
job2.join()
job3.join()
...

그래서 구조적 동시성이라는 것이 나왔다.

구조적 동시성

위 문제는 launch 빌더를 GlobalScope 에서 만들었기 때문에 발생한 것이다.

상위 스코프인 GlobalScope 랑 runBlocking 이랑 연관이 없기 때문에 runBlocking 이 바로 종료되면, launch 내부 동작이 다 끝나지 않았더라도 종료가 되는데, 이 부분을 GlobalScope 을 launch 하는 게 아닌, runBlocking 내에서 launch 를 하는 것이다.

 

fun main() = runBlocking {
    this.launch {
        delay(3000L)
        println("World!")
    }
    
    launch {
        delay(3000L)
        println("World!")
    }
 
    println("Hello,")
}

 

위와 같이 코드를 작성해서 코루틴을 관리하면 된다. 그러면, Job 객체 join 을 작성하지 않더라도 부모 코루틴 스코프인 runBlocking 이 자식 코루틴 스코프 내 동작을 다 기다려준다.

따라서, Global 스코프 인 상위 스코프를 작성하기 보단, 구조적 동시성을 사용하자.

 

Refactoring Extract Function

fun main() = runBlocking {
    launch {
        extractFunction()
    }
    println("Hello,")
}

suspend fun extractFuntion() {
    delay(3000L)
    println("World!")
}

함수를 추출하여, 리팩토링을 할 수 있다. 

여기서 바뀐 부분은 suspend 키워드가 붙은 점이다. delay 함수를 사용하는 건, 코루틴 스포크 내에서 사용 가능한 함수이다.

따라서, suspend 키워드만 붙혀주면 된다.

즉, delay 함수가 아니더라도, 우리가 새 코루틴을 만들 때, 그 로직을 실행하기 위해서는 suspend 함수가 존재해야 한다는 걸 알 수 있다.

 

Coroutine ARE light-weight

fun main() = runBlocking {
	repeat(100_000) {
    	launch {
        	delay(1000L)
            print(".")
        }
    }
}

위 코드는 일 초에 한 번에 10만 개의 점(.)을 찍도록하는 코드다.

이 부분을 코루틴이 아닌, Thread 를 사용하게 된다면? OOM 으로 인한 이슈가 발생할 것이다. (그런데, 해봤는데 OOM 이 안됐다.....시스템 상태에 따라서 다른 것 같다.) 

하지만, Thread 로 동작을 시켰을 땐 버벅임이 존재했다.

 

정리하자면, Thread 랑 비슷하게 동작해서 비교를 해볼 수 있었지만, Thread 는 아니고 코루틴은 Thread 보다 굉장히 가볍다.

 

코루틴 디버깅 방법

- Thread.currentThread().name 을 통해  Thread 로깅 확인

- VM options 에 -Dkotlinx.coroutines.debug 만을 추가해서 실행하여 Thread 로깅 확인


Coroutine Cancellation and Timeouts

코루틴을 중지하는 것은 매우 중요하다. 코루틴 하나하나 마다, 메모리에 직접적인 영향을 주기 때문에 메모리 관리 측면에서 중요하다.

 

코루틴을 취소하는 것은 굉장히 간단하다.

그냥 Cancel 을 하면된다.

 

Cancel 사용법 ( Job 은 Cancel 이 가능하다 )

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("job object : sleeping $i ...")
            delay(500L)
        }
    }
    
    delay(1300L)
    println("main : I'm tired of waiting!")
    job.cancel()
    println("main : Now I can quit.")
}

 

Cancellation is cooperative (코루틴이 취소되기 위해서는 협조적이어야 한다.)

fun main() = runBlocking {

    val startTime = System.currentTimeMillis()
    val job = launch(Dispatcher.Default) {
    	var nextPrintTime = startTime
        var i = 0
        while (1 < 5) {
        	if(System.currentTimeMillis() >= nextPrintTime) {
            	// delay(1L)
                // yield()
            	println("job object : I'm sleeping ${i++}...")
                nextPrintTime += 500L
            }
        }
    }
    
    delay(1300L)
    println("main : I'm tired of waiting!")
    job.cancelAndJoin()
    println("main : Now I can quit.")
}

위 코드는 cancel 이 되지 않는다. 왜? 취소 가능한 상태가 아니기 때문이다.

취소되기 위해 협조적이기 위해서는 코루틴 내에 로직에서 suspend 가 존재해야 하는데, 그러한 코드가 없어서 모든 로직이 다 수행되고 종료가 된다.

따라서, 그 부분을 해결하기 위해서는 두 가지 방법이 존재한다.

 

way 1 : suspend 함수를 이용하여, exception 처리를 한다. 

주석을 확인해보자.

그냥 delay 를 사용하게 되면, suspend 을 사용하기에서 해당 로직 수행 중에, exception 처리가 되어 해당 코루틴이 종료되게 된다.

또한 코루틴 취소를 위해서 굳이 쓸모 없는 코드인 delay(1L)를 작성할 필요가 없다. 그럴 땐 yield 를 사용하면 된다. 

way 2 : 명시적으로 상태를 체크해서, 상태가 isActive 가 아니면, 해당 코루틴을 종료하게끔 하는 방식이다. 아래 코드를 확인해보자.

fun main() = runBlocking {

    val startTime = System.currentTimeMillis()
    val job = launch(Dispatcher.Default) {
    	var nextPrintTime = startTime
        var i = 0
        while (isActive) {
        	if(System.currentTimeMillis() >= nextPrintTime) {
            	// delay(1L)
                // yield()
            	println("job object : I'm sleeping ${i++}...")
                nextPrintTime += 500L
            }
        }
    }
    
    delay(1300L)
    println("main : I'm tired of waiting!")
    job.cancelAndJoin()
    println("main : Now I can quit.")
}

위 같이 코드를 작성하여 취소에 협조적인 코드를 작성하게 되면, suspend 함수를 의도적으로 로직에 사용하여 exception 처리를 해줄 필요가 없다.

 

Closing resources with finally

코루틴을 취소했을 때, 리소스를 처리는 어떤식으로 해야할까?

fun main() = runBlocking {
    val job = launch {
    	try {
            repeat(1000) { i ->
                println("job object : sleeping $i ...")
                delay(500L)
            }        
        } finally {
        	// 이 부분에서 리소스 해지 등 메모리 관리를 해준다.
        }
    }
    
    delay(1300L)
    println("main : I'm tired of waiting!")
    job.cancelAndJoin()
    println("main : Now I can quit.")
}

 

Timeout

코루틴에서 취소처리르 하는 것이 아닌, 이 시간대가 지나면 코루틴이 실행 취소가 된다. 라는 의미이다.

fun main() = runBlocking {
	withTimeout(1300L) {
    	repeat(1000) { i ->
        	println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

위 코드로 작성을 했다면, 이와 같이 exception 발생이 된다.

 

이 위 exception 을 해결하기 위해서는

withTimeoutOrNull 을 사용하면 된다.

 

withTimeoutOrNull

fun main() = runBlocking {
	val result = withTimeoutOrNull(1300L) {
    	repeat(1000) { i ->
        	println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    
    println("Result is $result")
}

위와 같이 코드를 작성하면 Null 이 반환되는 것을 알 수 있다.

 

Composing Suspending Functions

Sequenctial by default

fun main() = runBlocking<Unit> {
	val time = measureTimeMillis {
    	val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo()
        println("The answer is ${one + two}")
    }
	println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
	println("one")
	delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
	println("two")
	delay(1000L) // pretend we are doing something useful here, too
    return 29
}

위 코드가 비동기 일지라도, 순차적으로 실행이된다.

=> 시간은 2초정도 소요된다. 보통 2초만 되더라도 안드로이드 내 UI 작업이 멈춘다는 느낌을 받을 것이다.

이런 부분이 코루틴을 이용을하면 전혀 발생하지 않는다.

그 이유는 메인스레드에서 별도의 코루틴을 생성하여 작업이 이루어지기 때문이다.

 

Concurrent using async

fun main() = runBlocking<Unit> {
	val time = measureTimeMillis {
    	val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("The answer is ${one.await() + two.await()}")
    }
	println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
	println("one")
	delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
	println("two")
	delay(1000L) // pretend we are doing something useful here, too
    return 29
}

위 코드는 그 앞에 있던 코드와 다른 점은 async 를 사용한 점이 다르다.

코루틴 안에서 순차적으로 실행이되지만, 첫 번째 함수를 콜하고 대기 한 뒤, 두 번째 함수도 콜 한 뒤 대기시켜서, println 에서 출력값을 한 번에 나오게 된다.

=> 소요된 시간은 1초이다. 

Deferred 라는 result 값을 받아서 한 번에 출력을 한다.

 

Lazily started async (async 로 만든 걸 나중에 실행시키는 방법은)

fun main() = runBlocking<Unit> {
	val time = measureTimeMillis {
    	val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
        // some computation
        one.start() // start the first one
        two.start() // start the second one
        println("The answer is ${one.await() + two.await()}")
    }
	println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
	println("one")
	delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
	println("two")
	delay(1000L) // pretend we are doing something useful here, too
    return 29
}

위에서 코드처럼, async 에 CoroutineStart.LAZY 를 걸어주면, start 또는 await 을 해야만이 실행이된다.

=> 두 개 동시 스타트를 하여서 1초 소요된다.

만약에, start() 코드를 뺀다면, 코루틴만 만들고 start 로 실행을 하지 않아서 println 에서 await() 시점에서 1초 + 1초 가 걸려서 

=> 2초가 걸린다.

 

Structured concurrency with async ( async 를 사용하기 위해서 구글이 권장하는 구조적인 코드 )

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        println("The answer is ${concurrentSum()}")
    }
    println("Completed in $time ms")
}

suspend fun concurrentSum(): Int = coroutineScope {
    val one = { doSomethingUsefulOne() }
    val two = { doSomethingUsefulTwo() }
    one.await() + two.await()
}

suspend fun doSomethingUsefulOne(): Int {
	println("one")
	delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
	println("two")
	delay(1000L) // pretend we are doing something useful here, too
    return 29
}

위와 같이 async 를 사용하는 구조적인 코드이다.

단, 코루틴 스코프 안에서 사용하되, 글로벌 스코프에서 사용하면 exception 이 발생해도 실행이되는 문제가 발생된다.

 

 

 

 

참고

https://kotlinlang.org/docs/coroutines-overview.html

 

반응형