Skip to content

Latest commit

 

History

History
614 lines (448 loc) · 16 KB

File metadata and controls

614 lines (448 loc) · 16 KB

서론

Suspending 함수들은 다른 함수에게 Continuation을 전달해 주어야 한다.

따라서 Suspend 함수는 일반 함수를 호출할 수 있지만, 일반 함수는 suspend 함수를 호출할 수 없다.

suspend 함수는 suspend 함수에 의해 호출되어야 하고, 그 suspend함수 또한 suspend함수에 의해 호출되어야 한다.

이러한 suspend 함수는 어디선가 시작되어야 하는데, 그런 역할을 하는게 Coroutine Builders이다.

Coroutine Builders의 종류

1. launchBuilder

launch는 개념적으로 새로운 Thread를 실행하는 것과 비슷하다.

단지 우리는 coroutine을 실행하고, 독립적으로 실행된다는 것 뿐이다.

다음은 launch를 어떻게 사용하는가에 대한 예제이다.

fun main() {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    Thread.sleep(2000L)
}

다음과 같은 결과가 나오게 된다.

// Hello,
// (1 sec)
// World!
// World!
// World!

이런게 가능한 이유는 각각의 Coroutine이 독립적으로 존재하며 독립적으로 실행되기 때문이다.


launchCoroutineScope의 확장 함수로, 부모 Coroutine과 자식 Coroutine간의 관계를 표현하는 데에 있어 매우 중요한 메커니즘인 Structured Concurrency의 일부분이다.

structured concurrency는 이 챕터 뒤쪽에서 자세히 다룰 예정이다.

하지만 지금은 GlobalScope로 직접 launch, async를 호출함으로써 structured concurrency에 대한 이야기는 넘기겠다.

이건 흔한 예제는 아니고, 실제 상황에서 GlobalScope를 직접 사용하는 경우는 매우 적다.


알아차렸을수도 있는데, 맨 뒤에 Thread.sleep(2000L)를 붙여줬다.

이유는 main Thread가 suspend된 메소드보다 먼저 끝나버리면 안되기 때문에 기다려 주어야 한다.

왜냐하면 delay()가 Thread를 Blocking시키지 않기 때문인데, delay()는 단지 일정 시간 이후에 resume()할 뿐이다.

따라서 main 메소드가 먼저 끝나버리면 Coroutine은 suspend된 함수가 재실행 되기 전에 프로그램이 꺼져버리게 된다.

뒤에서 다시 설명하겠지만 structured concurrency를 사용하게 되면 Thread.sleep()이 없어도 된다.


launch가 동작하는 방법은 어느정도 Daemon Thread와 비슷하다.

여기서 Daemon Thread란 Normal Thread의 백그라운드 작업을 주로 맡아서 하는 보조적인 Thread를 의미한다.

우선순위가 낮고, Normal Thread가 종료되면 함께 종료된다는 특징이 있다.

둘 다 각각의 프로세스로 작업을 처리하며, Normal Thread가 종료됨에 따라 함께 종료된다.

fun main() {
    thread(isDaemon = true) {
        Thread.sleep(1000L)
        println("World!")
    }
    
    thread(isDaemon = true) {
        Thread.sleep(1000L)
        println("World!")
    }

    thread(isDaemon = true) {
        Thread.sleep(1000L)
        println("World!")
    }
    
    println("Hello,")
    Thread.sleep(2000L)
}

하지만 비용적인 측면에서는 Blocking된 Thread를 유지하는건 비싸지만, Coroutine을 유지하는건 거의 공짜에 가깝다.

2. runBlocking Builder

Coroutine의 기본적인 규칙은 Thread를 Blocking하지 않고 suspend하는 것 이지만, Blocking 시켜야 하는 경우도 있다.

Main 함수와 같이 프로그램이 일찍 끝나면 안되는 경우가 있다.

우리는 대부분의 경우에서 runBlocking을 사용할 것이다.


runBlocking은 굉장히 예외적인 Builder이다.

runBlocking 내부에서 suspend될 때마다 runBlocking을 호출한 Thread를 Blocking한다.

즉, runBlocking { delay(1000L) }Thread.sleep(1000L)과 같다.

fun main() {
    runBlocking {
        delay(1000L)
        println("World!")
    }
    runBlocking {
        delay(1000L)
        println("World!")
    }
    runBlocking {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

따라서 다음과 같은 결과가 나온다.

// (1 sec)
World!
// (1 sec)
World!
// (1 sec)
World!
Hello,

다음 코드도 같은 역할을 한다.

fun main() {
    Thread.sleep(1000L)
    println("World!")
    
    Thread.sleep(1000L)
    println("World!")
    
    Thread.sleep(1000L)
    println("World!")
    println("Hello,")
}

실제로 runBlocking이 사용되는 몇 가지 경우가 있다.

먼저 main 함수는 runBlocking을 사용하지 않으면 프로그램이 종료되기 때문에 runBlocking을 사용해야 한다.

다음으로 unit테스트를 진행할 때 사용해야 하는데, unit테스트를 할 때에도 종료되면 안되기 때문에 runBlocking을 사용한다.

fun main() = runBlocking {
    
}

class MyTests {
    @Test
    fun `a test`() = runBlocking {
        
    }
}

여기서 Thread.sleep()delay()로 변경하려면 launch를 사용할 수도 있지만, 나중에 structured concurrency에 대해 배우고 나면 runBlocking이 더 유용한걸 알 수 있다.

fun main() = runBlocking {
    GlobalScope.launch{
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    delay(2000L) // 아직은 필요하다.
}
Hello,
// (1 sec)
World!
World!
World!

runBlocking은 굉장히 중요한 Builder이지만 현대 프로그래밍에서는 거의 쓸 일이 없다.

단위 테스트에서는 주로 대체자인 runTest를 사용하고, main 함수는 suspend 함수로 만든다.

runTest는 뒤에 Testing Coroutine에서 자세히 설명한다.

suspend fun main() {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    delay(2000L)
}
        

main을 suspend 시키는게 훨씬 편하다.

하지만 아직은 runBlocking을 계속 쓸 예정이다.

3. async Builder

asynclaunch와 비슷하지만 값을 반환할 수 있다.

asyncDeferred<T> 타입을 반환하고, Deferred는 값이 준비되었을 때 반환해주는 suspend 메소드인 await()이 있다.

fun main() = runBlocking {
    val resultDeferred: Deferred<Int> = GlobalScope.async {
        delay(1000L)
        42
    }
	val result: Int = resultDeferred.await() // (1 sec)
    println(result) // 42
}

launch와 비슷하게 async는 호출되었을 때 coroutine을 즉시 시작시킨다.

만약 값이 준비된 후에 await()을 호출하게 되면 즉시 값을 반환하게 되고, 값이 준비되지 않았다면 준비될때까지 suspend된다.

fun main() = runBlocking {
    val res1 = GlobalScope.async {
        delay(1000L)
        "Text 1"
    }
    
    val res2 = GlobalScope.async {
        delay(3000L)
        "Text 2"
    }
    
    val res3 = GlobalScope.async {
        delay(2000L)
        "Text 3"
    }
    
    println(res1.await())
    println(res2.await())
    println(res3.await())
}

다음과 같은 결과가 나오게 된다.

// (1 sec)
Text 1
// (2 sec)
Text 2
Text 3

asynclaunch에서 값을 반환하는 것 말고 추가적인 기능들을 지원한다.

launch는 모두 async로 바꿔도 잘 동작하지만, async는 값을 반환해야 한다는 상징적인 의미가 있기 때문에, 꼭 필요한 곳에서만 사용해야 한다.

fun main() = runBlocking {
	// async의 오남용
    GlobalScope.async {
        delay(1000L)
        println("World!")
    }
    
    println("Hello,")
    delay(2000L)
}
Hello,
// (1 sec)
World!

async builder는 동시에 두 곳에서 데이터를 수집하고 병합하는 등 두 가지의 처리를 병렬로 처리할 때 사용된다.

scope.launch {
    val news = async {
        newsRepo.getNews()
            .sortedByDescending { it.date }
	}
    
    val newsSummary = newsRepo.getNewsSummary()
    // 이것도 async로 감쌀 수 있지만, 중복되기 때문에 하지 않았다.
    
    view.showNews(
        newsSummary,
        news.await()
    )
}

Structured Concurrency

만약 Coroutine을 GlobalScope로 시작하게 되면 프로그램은 그 Coroutine을 위해 기다려주지 않는다.

전에 말했듯이, Coroutine은 아무 Thread도 Blocking하지 않기 때문에, 프로그램이 종료되는 것을 막지 않는다.

fun main() = runBlocking {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    
    GlobalScope.launch {
        delay(2000L)
        println("World!")
    }
    
    println("Hello,")
//    delay(3000L)
}
// Hello,

처음에 GlobalScope가 필요한 이유는 lunchasyncCoroutineScope의 확장 함수이기 때문이다.

하지만 runBlocking을 선언하는 부분을 보면 block의 Receiver가 CoroutineScope로 되어있다.

fun <T> runBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

GlobalScope를 제거해도 된다.

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

따라서 launchrunBlocking의 자식이 된다.

부모로서의 자식이 모두 끝날때까지 기다려야 한다는 책임이 있는데, 자식을 인지할 수 있으므로 runBlocking은 자식들이 모두 끝날 때까지 suspend된다.

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

다음과 같은 결과가 나오게 된다.

Hello,
// (1 sec)
World!
// (1 sec)
World!

각각의 launch는 개별적인 coroutine을 생성하고, runBlocking모든 자식 coroutine들이 종료될때까지 기다려준다.


부모는 자식을 위해 scope를 제공하고, 자식들은 해당 scope에서 실행된다.

이렇게 구축된 관계를 structured concurency라고 한다.

부모 - 자식 관계의 중요한 특징은 다음과 같다.

  • 자식은 부모에게서 context를 상속받는다.

    Coroutine context에서 설명하겠지만, context는 재정의 될 수 있다.

  • 부모는 모든 자식이 끝날때까지 suspend된다.

  • 부모가 취소되면 모든 자식들도 취소된다.

  • 자식에서 오류가 발생하면 부모도 파괴한다.


주의할점은 다른 coroutine builder들과는 다르게 runBlocking은 CoroutineScope의 확장 함수가 아니다.

따라서 runBlocking은 자식이 될 수 없고, 오직 root로만 존재할 수 있다.

The Bigger Picture

전에 말했듯이 suspend 함수는 다른 suspend 함수에 의해 호출되어야 하고, 모든 것은 coroutine builder에 의해 시작되어야 한다.

runBlocking을 제외하고, builder들은 CoroutineScope에서 시작되어야 한다.

우리가 사용한 간단한 예제에선 scope를 runBlocking에서 제공했지만 어느정도 큰 Application에서는 개발자 혹은 프레임워크에 의해 제공된다.

첫 번째 builder에서 scope를 제공하면, 다른 builder들에서 첫 번째 builder의 scope에서 시작할 수 있다.


아래 실제 프로젝트에서 자주 사용될만한 예제들을 가져왔다.

// for both of android / kotlin

class NetworkUserRepository(private val api: UserApi) : UserRepository {
    suspend fun getUser(): User = api.getUser().toDomainUser()
}

class NetworkNewsRepository(
    private val api: NewsApi,
    private val settings: SettingsRepository,
) : NewsRepository {
    suspend fun getNews(): List<News> =
    	api.getNews()
    		.map { it.toDomainNews() }
    
    suspend fun getNewsSummary(): List<News> {
        val type = settings.getNewsSummaryType()
        return api.getNewsSummary(type)
    }
}

// for android

class MainPresenter(
    private val view: MainView,
    private val userRepo: UserRepository,
	private val newsRepo: NewsRepository
) : BasePresenter {
    fun onCreate() {
        scope.launch {
            val user = userRepo.getUser()
            view.showUserData(user)
        }
        
        scope.launch {
            val news = async {
                newsRepo.getNews()
                	.sortedByDescending { it.date }
            }
            
            val newsSummary = async {
                newsRepo.getNewsSummary()
            }
            view.showNews(newsSummary.await(), news.await())
        }
    }
}

// for backend

@Controller
class UserController(
    private val tokenService: TokenService,
    private val userService: UserService
){
    @GetMapping("/me/{userId}") 
    suspend fun findUser(
        @PathVariable userId: String,
        @RequestHeader("Authorization") authorization: String
    ): UserJson {
        val userId = tokenService.readUserId(authorization)
        val user = userService.findUserById(userId)
        return user.toJson()
    }
}

여기서 문제가 있는데, 어디에서도 scope를 지정하지 않았다.

하지만 파라미터로 scope를 지정해주는건 좋은 방법이 아니기 때문에 builder를 위한 scope를 제공해주는 coroutineScope를 사용할 수 있다.

coroutineScope 사용하기

user 데이터와 user가 조회한 게시글 목록을 비동기적으로 조회해서 병합해야 한다고 상상해보자.

async를 호출하기 위해서는 scope가 필요하지만, scope를 지정해준적이 없다.

scope를 생성하기 위해서 coroutineScope를 사용하면 된다.

suspend fun getArticlesForUser(userToken: String?): List<ArticleJson> = coroutineScope {
    val articles = async {
        articleRepository.getArticles()
    }
    
    val user = userService.getUser(userToken)
    
    articles.await()
    	.filter { canSeeOnList(user, it) }
    	.map { toArticleJson(it) }
}

여기서 async를 하지 않아도 동시에 조회할 수 있다.

하지만, 동시에 조회한다는걸 강조하기 위해 async를 붙여줬다.

coroutineScope는 단지 람다 식을 위한 scope를 생성해 주는 suspend 함수일 뿐이다.

위 예제에서는 람다식의 반환 타입인 List<ArticleJson>을 반환해 줬다.


coroutineScope는 suspend 함수 안에서 scope가 필요할 때 사용하는 굉장히 중요한 함수이다.

사용하는 입장에서는 굉장히 편하게 사용할 수 있도록 설계되었지만, 제대로 분석하기 위해서는 context, cancelling, Exception handling에 대한 개념이 필요하다.


또한 main 함수에서 runBlocking 대신 사용할수도 있다.

suspend fun main(): Unit = coroutineScope {
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

diagram

우리는 일반적으로 scope를 시작하거나, runBlocking을 호출하여 사용한다.

여기서, 우리는 또 다른 builder들이나 suspend 함수를 호출할 수 있다.

suspend 함수는 builder들을 호출할 수 없기 때문에 coroutineScope와 같은 scope 함수를 호출하게 된다.