구조적 동시성 (Structured Concurrency) 이란?
부모 코루틴은 자신의 스코프를 자식에게 전달하고, 자식 코루틴은 해당 스코프에서 호출을 받는다.
이러한 부모-자식 간의 관계를 생성하는 것을 structured concurrency라고 한다.
- 부모-자식간의 관계에 대한 상호작용은 다음과 같다.
- 자식 코루틴은 부모 코루틴으로부터 컨텍스트를 상속받는다. (하지만 해당 컨텍스트를 덮어쓰는 것도 가능하다.)
- 부모 코루틴은 자식 코루틴이 모두 완료될 때까지 일시중단된다. (기다린다)
- 부모 코루틴이 cancel되었을 때, 자식 코루틴들도 모두 cancel 된다.
- 자식 코루틴에서 예외가 발생하여 부모 코루틴으로 전파되었을 때, 부모 코루틴 또한 종료된다.
단, 다른 coroutine builder들과 다르게 runBlocking은 CoroutineScope의 확장함수가 아니다. (launch와 async 빌더는 CoroutineScope 의 확장함수이다.)
이는 runBlocking이 자식 코루틴은 될 수 없고, 오로지 root 코루틴만 될 수 있다는 것을 의미한다.
실행 환경(Context) 상속
두 개의 코루틴이 부모-자식 관계일 때 부모의 Coroutine Context이 자식 코루틴에게 상속됩니다.
즉, 부모 코루틴이 가진 CoroutineDispatcher, CoroutineName, ExceptionHandler 등 여러 Elment가 포함된 Context가 상속됩니다.
하지만 여기서 주의할 점은 CorotuineContext의 주요 Element 중 하나인 Job은 상속되지 않는다는 점입니다.
fun main() = runBlocking<Unit> { // 부모 코루틴 생성
val runBlockingJob = coroutineContext[Job] // 부모 코루틴의 CoroutineContext로부터 부모 코루틴의 Job 추출
launch { // 자식 코루틴 생성
val launchJob = coroutineContext[Job] // 자식 코루틴의 CoroutineContext로부터 자식 코루틴의 Job 추출
println(runBlockingJob === launchJob) // 출력 : false
}
}
따라서, 위의 코드를 실행하면 동일성 비교에서 false가 출력됩니다.
부모 코루틴의 Job이 자식 코루틴에게 상속되지 않는다는 것을 이 코드를 통해 확인이 가능합니다.
왜 Job객체는 상속되지 않나요?
각 코루틴을 제어하려면 Job 객체가 필요한데, Job객체를 부모 코루틴으로부터 상속받게 되면, 개별적으로 코루틴 제어가 어려워집니다.
그래서 launch, async 함수 같은 코루틴 빌더 함수는 호출할 때 Job 객체를 새로 생성하는 것입니다.
구조화에 사용되는 Job
부모 코루틴과 자식 코루틴은 각각 독립적인 Job 객체를 갖지만, 서로 아무런 관계가 없는 것은 아닙니다.
Job 객체는 코루틴을 구조화하는데 사용됩니다.
public interface Job : CoroutineContext.Element {
// ...
@ExperimentalCoroutinesApi
public val parent: Job?
// ...
public val children: Sequence<Job>
// ...
}
실제로 Job 내부코드에는 parent와 children 프로퍼티가 존재합니다.
이 프로퍼티를 통해서 자신의 부모와 자식을 참조합니다.
부코 코루틴의 Job 객체는 children 프로퍼티를 통해 자식 코루틴의 Job객체를 참조하고,
자식 코루틴 Job 객체는 parent 프로퍼티를 통해 부모 코루틴의 Job 객체를 참조합니다.
fun main() = runBlocking<Unit> { // 부모 코루틴
val parentJob = coroutineContext[Job]
launch { // 자식 코루틴
val childJob = coroutineContext[Job]
println("${childJob?.parent === parentJob}") // true
println("${parentJob?.children?.contains(childJob)}") // true
}
async { // 자식 코루틴
val childJob = coroutineContext[Job]
println("${childJob?.parent === parentJob}") // true
println("${parentJob?.children?.contains(childJob)}") // true
}
}
코드를 통해 확인해보면 부모 자식 간에 서로 참조하고 있다는 것을 알 수 있습니다.
부모 코루틴은 하나만 가질 수 있지만, 자식 코루틴은 여러 자식을 가질 수 있기 때문에, children 프로퍼티는 Sequence<Job>타입으로 선언되어 있습니다.
결국, Job이 독립적으로 생성되지만, 내부적으로 부모-자식관계로 참조가 이루어져있음을 알 수 있습니다.
그림으로 도식화 해보면 아래와 같습니다.
부모-자식 관계를 정의하는 내부코드
이제 내부 코드를 통해 부모-자식 관계를 정의하는 코드를 보겠습니다.
launch() 함수의 내부 동작을 통해 살펴보도록 하겠습니다. (Async를 호출 하는 경우도 내부적으로 동일한 흐름을 따릅니다.)
부모 CoroutineContext와 자식 CoroutineContext를 합친 새로운 CoroutineContext인 newContext를 인자로 전달합니다.
launch 함수에서 호출하는 StandloneCoroutine를 이어서 보겠습니다.
StandaloneCoroutine은 AbstractCoroutine 추상 클래스를 상속받는 클래스로, 생성자에서 parentContext(부모 컨텍스트)를 인자로 전달 받습니다.
그리고 initParentJob을 true로 설정하여 부모 Job과 연결을 형성합니다.
그리고 실제 AbstractCoroutine 클래스에서는 parentContext를 통해 부모 Job을 가져오고, initParentJob()을 호출합니다.
만약 부모 Job이 있다면, 자식(this)을 부모에 등록하는 과정이 실행될 것입니다.
아래에서 살펴보겠습니다.
initParentJob()이 정의된 곳을 보면 attachChild(this)를 확인할 수 있습니다.
여기서 this는 JobSupport 클래스를 상속한 AbstractCoroutine 클래스를 상속한 StandaloneCoroutine 객체입니다.
즉, 이 코드는 전달된 부모의 Job에 자식 Job을 등록(attach)하는 것입니다.
이 시점부터 부모가 취소되면 child도 함께 취소되는 구조적 동시성 관계가 성립됩니다.
정리하자면, launch()를 호출하면 standaloneCoroutine 객체를 생성함과 동시에 내부적으로 attachChild() 함수를 통해 부모-자식 관계를 정의하고, 자식 클래스인 StandaloneCoroutine 객체를 새로운 Job 객체로 리턴하게 됩니다.
Job구조화 깨기
특정한 방법을 사용하면 job의 구조화를 깨고 부모-자식의 참조 관계를 제거할 수 있습니다.
Job구조화가 어떻게하면 깨질 수 있는지 알아보겠습니다.
1. 새로운 CoroutineScope 사용해 구조화 깨기
CoroutineScope객체가 생성되면 새로운 루트 Job이 생성되며, 이를 사용해 코루틴의 구조화를 깰 수 있습니다.
fun main() = runBlocking<Unit> { // 루트 Job 생성
val newScope = CoroutineScope(Dispatchers.IO) // 새로운 루트 Job 생성
newScope.launch(CoroutineName("Coroutine1")) { // Coroutine1 실행
launch(CoroutineName("Coroutine3")) { // Coroutine3 실행
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine4")) { // Coroutine4 실행
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
newScope.launch(CoroutineName("Coroutine2")) { // Coroutine2 실행
launch(CoroutineName("Coroutine5")) { // Coroutine5 실행
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
}
/*
// 결과:
Process finished with exit code 0
*/
이 코드를 구조화하면 다음과 같습니다.
아무런 결과가 나오지 않는 이유는 구조화가 깨졌기 때문입니다.
2. 새로운 Job을 생성해 구조화 깨기
자식 코루틴에서 새로운 Job()을 생성함으로써 Job구조화를 깰 수 있습니다.
fun main() = runBlocking<Unit> {
val newRootJob = Job() // 새로운 루트 Job 생성
launch(CoroutineName("Coroutine1") + newRootJob) {
launch(CoroutineName("Coroutine3")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine4")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
launch(CoroutineName("Coroutine2") + newRootJob) {
launch(CoroutineName("Coroutine5") + Job()) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
delay(50L) // 모든 코루틴이 생성될 때까지 대기
newRootJob.cancel() // 새로운 루트 Job 취소
delay(1000L)
}
/*
// 결과:
[main @Coroutine5#6] 코루틴 실행
*/
newRootJob에 취소가 요청되더라도 새로운 루트 Job의 자식이 돼버린 Coroutine5에는 취소가 전파되지 않습니다.
자식 코루틴에서 Job을 생성하면 parent 프로퍼티가 null이 돼 부모가 없는 루트 Job이 생성됩니다.
따라서, 기존 부모와의 참조 관계가 끊어지고, 새로 생성된 Job이 독립적인 루트 Job이자 새로운 부모 역할을 하게 됩니다.
다만, 기존 부모와 Job 참조관계만 끊어질 뿐, 기존에 상속받았던 CoroutineDispatcher, CoroutineName 등의 CoroutineContext 요소들은 그대로 유지됩니다.
생성된 Job의 부모를 명시적으로 설정하기
새로운 Job을 생성할 때 부모를 명시적으로 설정하지 않으면, 기본적으로 parent 프로퍼티가 null이 되어 루트 Job이 생성됩니다.
즉, 기존 부모와의 참조관계가 끊어지면서 독립적인 Job이 만들어집니다.
그래서, 앞서 살펴봤던 예제에서도 새로운 Job이 생성된 것이죠
부모와의 참조를 유지하려면 새로운 Job을 생성하지 않고 launch를 사용하면 자동으로 부모-자식 관계가 형성됩니다.
하지만, 특정한 이유로 새로운 Job을 생성하면서도 부모 Job을 유지하고 싶다면, 명시적으로 부모의 Job객체를 넘길 수 있습니다.
parent 인자로 job 객체를 넘기면 해당 Job을 부모로 가지는 새로운 Job 객체를 생성할 수 있습니다.
아래 예제는 parent를 명시적으로 지정하여 부모-자식 관계를 형성하는 방식입니다.
fun main() = runBlocking<Unit> {
launch(CoroutineName("Coroutine1")) {
val coroutine1Job = this.coroutineContext[Job] // Coroutine1의 Job
val newJob = Job(parent = coroutine1Job)
launch(CoroutineName("Coroutine2") + newJob) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
}
/*
// 결과:
[main @Coroutine2#3] 코루틴 실행
// 프로세스 종료 로그가 출력되지 않는다.
*/
위와 같이 부모 Job을 명시적으로 설정하면 넘겨받은 parent를 기반으로 부모-자식 관계가 형성됩니다.
하지만, 위 코드를 실행해 보면, "xx코루틴 실행" 메시지가 출력된 후에도 프로세스가 종료되지 않는 현상이 발생합니다.
그 이유는 새롭게 생성된 Job 객체(newJob)는 내부적으로 실행 완료를 자동으로 처리하지 않기 때문입니다.
즉, 모든 자식 코루틴이 실행을 마쳐도 newJob은 여전히 활성 상태(active)로 남아 있기 때문에, 부모 코루틴이 종료되지 않습니다. (단, parent가 null인 경우 자동으로 종료)
그래서 명시적으로 아래와 같이 완료함수인 complete를 호출해서 완료 상태로 변경해야합니다.
fun main() = runBlocking<Unit> {
launch(CoroutineName("Coroutine1")) {
val coroutine1Job = this.coroutineContext[Job]
val newJob = Job(coroutine1Job)
launch(CoroutineName("Coroutine2") + newJob) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
newJob.complete() // 명시적으로 완료 호출
}
}
/*
// 결과:
[main @Coroutine2#3] 코루틴 실행
Process finished with exit code 0
*/
왜 구조적 동시성을 지켜야할까?
구조적 동시성(Structured Concurrency)은 Kotlin 코루틴에서 안정적인 비동기 작업 관리를 위해 필수적인 개념입니다.
이는 부모 코루틴이 자식 코루틴의 생명 주기를 관리하도록 보장하여, 코드의 안정성과 유지 보수성을 높여줍니다.
1. 리소스 누수 방지( Memory & Resource Leaks Prevention)
CoroutineScope(Dispatchers.IO) {
repeat(1000) { i ->
GlobalScope.launch { // not structure concurrency
delay(1000)
println("작업 $i 완료")
}
}
}
위 코드는 부모 코루틴이 먼저 종료되면, 자식 코루틴들은 독립적으로 남아 실행됩니다.
이는 메모리누수 또는 리소스 해제 실패로 이어질 수 있습니다.
✅ 구조적 동시성 적용시:
CoroutineScope(Dispatchers.IO) {
repeat(1000) { i ->
launch { // structure concurrency
delay(1000)
println("작업 $i 완료")
}
}
}
부모 코루틴이 종료될 때 모든 자식 코루틴이 함께 종료되므로 리소스 누수 위험이 사라집니다.
2. 예외 전파 및 가독성 향상
runBlocking {
val job1 = launch { // structure concurrency
delay(1000)
println("작업 1 완료")
}
val job2 = GlobalScope.launch { // not structure concurrency
throw RuntimeException("❌ 작업 2 실패")
}
}
만약 자식 코루틴에서 발생한 예외가 부모 코루틴에 전파되지 않으면 오류가 숨겨진 채로 남을 수 있습니다.
따라서, 디버깅이 복잡하고 유지보수가 어려워집니다.
위 예제의 경우 job1은 부모-자식 관계를 따르지만, job2는 부모-자식 관계가 아닙니다. 따라서, job에서 예외가 발생해도 부모까지 전파되지 않습니다.
✅ 구조적 동시성 적용 시
runBlocking {
val job1 = launch { // structure concurrency
delay(1000)
println("작업 1 완료")
}
val job2 = launch { // structure concurrency
throw RuntimeException("❌ 작업 2 실패")
}
}
job2의 예외가 부모 코루틴으로 즉시 전파되며, 모든 자식 코루틴이 취소됩니다.
-> 코드의 흐름이 직관적이고 코루틴의 생명주기 관리가 용이해집니다.
3. 취소 전파로 데이터 무결성 보장
suspend fun performDatabaseTransaction() = coroutineScope {
try{
val job1 = GlobalScope.async { updateAccountA() } //not structure concurrency
val job2 = GlobalScope.async { updateAccountB() } //not structure concurrency
job1.await()
job2.await()
} catch (e: Exception) {
println("오류 발생: ${e.message}")
}
}
job1이 실패하고 job2가 성공하면 데이터 불일치가 발생할 수 있습니다.
위 예제는 부모 자식관계 구조가 아니기 때문에, job1과 job2는 부모 코루틴인 runBlocking과 독립적으로 동작합니다.
그래서 job1에서 예외가 발생하더라도 다른 작업에 취소 신호가 전달되지 않습니다.
즉, updateAccountA()에서 실패하더라도, UpdateAccountB()는 성공한 상태로 남게 됩니다.
결과적으로 계좌 A는 업데이트 되고, 계좌 B는 실패한 불완전한 트랜잭션 상황이 발생합니다.
일부 작업이 실패하더라도 나머지 작업이 계속되어 데이터 무결성이 위반되는 문제이 발생합니다.
✅ 구조적 동시성 적용 시
suspend fun performDatabaseTransaction() = coroutineScope {
try{
val job1 = async { updateAccountA() } //structure concurrency
val job2 = async { updateAccountB() } //structure concurrency
job1.await()
job2.await()
} catch (e: Exception) {
println("오류 발생: ${e.message}")
}
}
부모-자식 관계가 유지되므로, 예외가 부모로 전파되고 모든 작업이 안전하게 종료됨.
하나의 업데이트가 실패하면 모든 트랜잭션이 롤백되어, 데이터 무결성을 보장할 수 있습니다.
참고자료
https://icarus8050.tistory.com/147
[Coroutine] 코루틴 학습 - 4 (Structured Concurrency)
Structured Concurrency 지난 포스트에서 살펴봤던 launch builder에서 GlobalScope는 코루틴이 완료되지 않아도 기다리지 않고 프로세스가 종료되었다. (코루틴은 쓰레드를 블로킹하지 않기 때문) 아래의 코
icarus8050.tistory.com
https://augustin26.tistory.com/89
[코루틴의 정석] 7장. 구조화된 동시성
구조화된 코루틴의 대표적인 특징은 다음과 같다. 부모 코루틴의 실행 환경이 자식 코루틴에게 상속된다.작업을 제어하는 데 사용된다.부모 코루틴이 취소되면 자식 코루틴도 취소된다.부모
augustin26.tistory.com
부모 코루틴과 자식 코루틴 : 상속되지 않는 Job
상속되지 않는 Job 두 개의 코루틴이 부모-자식 관계일 때 부모의 CoroutineContext 자식에게 상속된다. 그래서 부모의 CoroutineName, CoroutineDispatcher, CoroutineExceptionHandler는 자식에게 상속된다. 하지
velog.io
'Android > Coroutine' 카테고리의 다른 글
[Android] 코루틴(Coroutine)에서의 작업 취소 (1) | 2024.11.15 |
---|---|
[Android] 코루틴(Coroutine) 예외 처리 방법 (1) | 2024.11.09 |
[Android] 코루틴(Coroutine) Builder에 대한 고찰 - Deep Dive (0) | 2024.11.09 |
[Android] 코루틴(Coroutine) Scope에 대한 고찰 - Deep Dive (0) | 2024.11.09 |
[Android] 코루틴(Coroutine)의 Context란 ? (0) | 2024.11.09 |