[Coroutine] 코루틴 빌더(1)

코틀린 코루틴을 공부하며 정리한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

코루틴 빌더

중단 함수를 호출하기 위해서는 첫 게시글에서 본 것과 같이 함수 앞에 suspend를 붙여야 한다. 그 이유를 한 번 확인해보자.

fun main() {  
    funA()  
    funB()  
}  

suspend fun funA() {  
    println("funA")  
}  

fun funB() {  
    println("funB")  
}

위 코드를 작성하면 main 함수의 funA()를 호출한 부분에서 다음과 같은 에러가 발생한다.

Suspend function 'funA' should be called only from a coroutine or another suspend function

-> 중단 함수 funA는 코루틴 또는 또다른 중단 함수로부터 호출될 수 있다.

첫 게시글에서도 설명했듯이, 중단 함수는 디컴파일 될 때, 함수 매개 변수로 Continuation 객체를 받을 수 있도록 변경된다. 즉, main 함수에서 funA를 실행하기 위해서는, Continuation 객체를 전달해야 하는데, 이를 전달할 수 없기 때문에 에러가 발생하는 것이다.

 

이렇게, 부모 중단 함수와 자식 중단 함수의 관계를 정립하는 것은 굉장히 중요한 일이며, 이를 구조화된 동시성(Structued concurrency)라고 부른다. 에러 메세지에 나오듯이, 중단 함수가 아닌 코루틴을 통해 어떻게 이런 구조를 만들 수 있는지 확인해보자.

launch

launch가 작동하는 방식은 thread 함수를 호출하여, 새로운 스레드를 시작하는 것과 비슷하다. 코루틴을 시작하면, 불꽃놀이를 할 때, 불꽃이 하늘 위로 각자 퍼지는 것처럼 별개로 실행된다.

위 내용은 책의 내용을 그대로 가져온 것이다. 이게 어떤 의미인지 코드로 살펴보자.

fun main() {  
    globalScopeTest()  
}  

fun globalScopeTest() {  
    GlobalScope.launch {  
        println("[1] ${Thread.currentThread().name}")  
    }  
    GlobalScope.launch {  
        println("[2] ${Thread.currentThread().name}")  
    }  
    GlobalScope.launch {  
        println("[3] ${Thread.currentThread().name}")  
    }  
}

위 코드를 실행하면, 아무것도 출력되지 않고, 프로그램이 종료된다. 각 문장을 출력하기 위해서는, Thread.sleep을 통해, 각 스코프가 실행될 수 있는 기회를 주어야 한다.

fun main() {  
    globalScopeTest()  
}  

fun globalScopeTest() {  
    GlobalScope.launch {  
        println("[1] ${Thread.currentThread().name}")  
    }  
    GlobalScope.launch {  
        println("[2] ${Thread.currentThread().name}")  
    }  
    GlobalScope.launch {  
        println("[3] ${Thread.currentThread().name}")  
    }  
    println("[4] = ${Thread.currentThread().name}")  
    Thread.sleep(1)  
}
[4] = main
[2] DefaultDispatcher-worker-2
[1] DefaultDispatcher-worker-1
[3] DefaultDispatcher-worker-3

중단 함수 게시글에서 본 것과 같이, 중단 함수는 어디선가 블로킹 혹은 중단이 일어났을 때, 다른 일을 처리하게 된다. 즉, Thread.sleep이 없으면 globalScopeTest 함수에서 중단이 일어나지 않아 각 스코프의 launch 함수가 실행되지 않는 것이다.

 

또한, 결과를 보면 4번 문장이 먼저 출력되고, 그 다음에 1, 2, 3 혹은 3, 1, 2 등 다양한 순서로 실행되는 것을 볼 수 있다. 이렇게 실행되는 이유는 GlobalScope 때문이다.

 

앞서 설명한 내용과 같이 구조화된 동시성은 부모와 자식 관계를 통해 구조화를 시킨다고 했다. 하지만, GlobalScope를 호출하는 globalScopeTest 함수는 중단 함수도 아니고, 코루틴을 사용하지 않은 일반 함수이다. 즉, 3개의 부모 코루틴을 만든 뒤에 launch를 호출한 것이다. 때문에 각 스코프는 각기 다른 worker를 가지게 되는 것을 볼 수 있다.

GlobalScope, launch

위 내용을 토대로 아래와 같은 생각을 해볼 수 있다.

GlobalScope를 전역으로 선언하면, 1개의 스코프를 만든 것이니까 괜찮지 않을까?

val scope = GlobalScope  

fun globalScopeTest() {  
    scope.launch {  
        println("[1] ${Thread.currentThread().name}")  
    }  
    scope.launch {  
        println("[2] ${Thread.currentThread().name}")  
    }  
    scope.launch {  
        println("[3] ${Thread.currentThread().name}")  
    }  
    println("Thread.currentThread().name = ${Thread.currentThread().name}")  
    Thread.sleep(1)  
}
Thread.currentThread().name = main
[3] DefaultDispatcher-worker-3
[1] DefaultDispatcher-worker-1
[2] DefaultDispatcher-worker-2

결과는 변함없다. 그 이유는, GlobalScopelaunch의 코드를 보면 알 수 있다.

public object GlobalScope : CoroutineScope {  
     override val coroutineContext: CoroutineContext  
        get() = EmptyCoroutineContext  
}
public fun CoroutineScope.launch(  
    context: CoroutineContext = EmptyCoroutineContext,  
    start: CoroutineStart = CoroutineStart.DEFAULT,  
    block: suspend CoroutineScope.() -> Unit  
): Job {  
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

launch 함수는 새로운 코루틴을 생성할 때, newCoroutineContext를 통해 호출된 스코프의 coroutineContext와 전달된 context를 결합하여 아예 새로운 코루틴 컨텍스트를 만들게 된다. GlobalScope에서 launch를 호출하면, 이 코루틴은 EmptyCoroutineContext를 기본 컨텍스트로 사용하며, 독립적으로 실행된다. 즉, 전역 스코프에서 생성된 코루틴은 부모-자식 관계 없이 독립적으로 동작하게 되는 것이다.

 

또한, GlobalScope는 프로그램이 해당 스코프가 끝날 때까지 기다리지 않는다.

fun globalScopeTest() {  
    scope.launch {  
        println("start")  
        delay(10.seconds)  
    }
    Thread.sleep(1)  
}

위 코드를 실행하면, start만 출력하고, 10초를 기다리지 않고, 바로 프로그램이 종료된다. 이 이유는, delay는 스레드를 블록시키는 것이 아닌, 코루틴을 중단만 하기 때문이다. 즉, 스레드가 블로킹되지 않으면, 할 일이 없기 때문에 그대로 종료되는 것이다.

runBlocking

main 함수는 모든 작업을 완료하고, 종료가 되어야 한다. 때문에, 프로그램이 너무 빨리 끝나지 않도록 블로킹을 해야하는데, 이럴 때 사용된다.

fun runBlockingTest() = runBlocking {  
    runBlocking {  
        delay(1.seconds)  
        println("1")  
    }  
    runBlocking {  
        delay(1.seconds)  
        println("2")  
    }  
    runBlocking {  
        delay(1.seconds)  
        println("3")  
    }  
}
(1초 뒤)
1
(1초 뒤)
2
(1초 뒤)
3

launch의 경우 별개로 실행되는데, runBlocking은 순서에 맞춰 실행되는 것을 볼 수 있다. 이는, 중단 메인 함수와 동일하게, 시작한 스레드를 중단시킨다. 때문에 Thread.sleep과 비슷하게 동작을 하게 되는 것이다.

coroutineScope

이전에 봤던 빌더는 모두 중단 함수가 아니어도 사용이 가능했지만, coroutineScope는 중단 함수 내에서 스코프가 필요할 때, 사용한다.

launch

coroutineScope 또한 코루틴을 만들어주는 빌더이므로, launch를 내부에서 사용이 가능하다.

suspend fun coroutineScopeTest() = coroutineScope {  
    launch {  
        println("[1] ${Thread.currentThread().name}")  
    }  
    launch {  
        println("[2] ${Thread.currentThread().name}")  
    }  
    launch {  
        println("[3] ${Thread.currentThread().name}")  
    }
}
[1] DefaultDispatcher-worker-1
[2] DefaultDispatcher-worker-1
[3] DefaultDispatcher-worker-1

결과를 보면, 모두 동일한 스레드를 사용하는 것을 알 수 있다. 분명 앞에서 launch는 새로운 코루틴 컨텍스트를 생성한다고 했는데, 어떻게 된 걸까?

 

coroutineScope 내부의 launch는 부모 컨텍스트인 coroutineScope의 컨텍스트를 상속받아 실행되므로, 이 launch 블록들은 모두 동일한 컨텍스트를 사용한다. 이로 인해, 동일한 디스패처가 사용되며, 동일한 스레드에서 실행되게 되는 것이다.

정리

현업에서 GlobalScope는 잘 사용되지 않는다. 스프링 환경에서 중요한 작업을 GlobalScope에 맡겼을 때, 에러가 발생해서 서버가 내려간다면, 위험한 상황이 펼쳐질 것이다. 가능한 coroutineScope를 이용해서, 구조화하는 것이 좋다.

'Book Study > [Kotlin] 코틀린 코루틴' 카테고리의 다른 글

[Coroutine] 플로우  (0) 2024.12.15
[Coroutine] 핫 데이터 소스와 콜드 데이터 소스  (4) 2024.11.13
[Coroutine] 코루틴 빌더(2)  (0) 2024.08.22
[Coroutine] delay  (0) 2024.08.08
[Coroutine] 중단 함수  (0) 2024.08.06