스레드(Thread)

[Thread] 4. Kotlin의 코루틴(Coroutine)이란?

Stark97 2024. 9. 19. 11:37
반응형
 
 
 

이번 편에서는 코루틴이 무엇인지 알아보자.

📌 서론

이전 스레드 시리즈에서는 '일반 스레드와 스레드풀'을 사용하여 성능 비교를 했었다.

두가지 테스트만으로도 충분히 흥미로운 결과를 얻을 수 있었으며 내가 지금까지 개발하면서 생각하지 못했던 스레드를 활용한 성능 개선 방법에 대해 배울 수 있었다.

 

사실 내가 처음 이 시리즈를 작성하게 된 이유는 단순히 스레드의 성능이 궁금해서가 아니다. 동시성에 대한 테스트를 하다보니 스레드에 대해서 알아야겠다는 생각이 들었고 공부하다보니 공부할 것이 정말 많아서 정리를 해야겠다고 생각했기 때문이다.

 

그래서 열심히 세미나도 찾아보고 각종 문서, 블로그도 살펴봤다. 그랬더니 요즘은 '경량 스레드'를 사용하여 동시성을 제어하는 경우가 있다는 것도 알게 되었다. 그래서 이것에 대해 굉장히 큰 호기심이 생겼다.

 

사실 중심이 되는 경량 스레드 기술인 kotlin의 '코루틴'과 Java의 '가상 스레드'에 대해서는 이미 알고 있었다. 하지만 별로 다뤄볼 기회가 없었기에 이 내용들이 더욱 흥미로웠던 것 같다.

 

그래서 이전과는 달리 코루틴은 바로 테스트하지 않고 한 번 공부한 내용을 정리해서 설명하고 넘어가려 한다. 나도 공부를 하며 정리하는 것이다 보니 부족한 부분들이 있겠지만 함께 알아가도록 하자.

바로 이전의 스레드 성능 테스트 포스팅이다. (이번 내용과 이어지지는 않지만 스레드 성능을 파악하는데 도움이 된다.)

 

[Thread] 3. 일반 스레드 vs 스레드풀 (I/O, CPU 성능 비교)

스레드 vs 스레드풀 (성능 비교)📌 서론이전 1,2탄 포스팅에서 '일반 스레드'와 '스레드풀'을 사용했을 때 I/O, CPU 작업을 진행하면 어떤 성능을 보여줄지 테스트를 진행했다. 테스트를 통해 I/O 작

curiousjinan.tistory.com

 

1. 테스트 환경

언어 Kotlin (Java21)
프레임워크 SpringBoot3.x.x
라이브러리 코루틴(core, reactor)
IDE IntelliJ
AI tool ChatGPT 4o, Claude3.5 Sonnet
마음가짐 호기심, 참을성, 노력, 반복

플러그인과 버전은 다음과 같다.

plugins {
	kotlin("jvm") version "1.9.25"
	kotlin("plugin.spring") version "1.9.25"
	id("org.springframework.boot") version "3.3.3"
	id("io.spring.dependency-management") version "1.1.6"
}

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}

build.gradle에 코루틴 관련 라이브러리를 추가한다.

// coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

전체 소스코드 주소 (Github)

 

GitHub - wlsdks/Thread-study: 스레드에 대한 모든것을 알아보는 프로젝트

스레드에 대한 모든것을 알아보는 프로젝트. Contribute to wlsdks/Thread-study development by creating an account on GitHub.

github.com

 

2. 코루틴(Coroutine)이 뭔가요?

코루틴의 기본 개념

  • 코루틴은 'Co(협력적) routine(루틴: 작업의 집합)'의 줄임말로, 여러 작업이 협력하여 실행되는 방식을 의미한다. 일반 함수가 호출되면 시작부터 끝까지 실행되는 반면, 코루틴은 중간에 실행을 잠시 멈추고 다른 코루틴에게 제어권을 넘길 수 있다. 이후 다시 제어권을 받아 중단된 지점부터 실행을 재개할 수 있다.

  • 또한 코루틴은 경량 스레드라고도 불리며, 비동기 프로그래밍을 위한 간단한 방법을 제공한다. 코루틴을 사용하면 스레드를 차단하지 않고도 비동기적으로 코드를 작성할 수 있으며, 이는 성능 향상과 코드 가독성 개선에 도움이 된다.

코루틴의 특징

  • 경량성: 수천 개의 코루틴을 생성해도 성능에 큰 영향을 주지 않는다.
  • 비동기적 코드의 동기적 표현: 콜백 지옥 없이 순차적인 코드 흐름으로 비동기 작업을 표현할 수 있다.
  • 구조화된 동시성: 코드 구조를 간단하게 유지하면서 복잡한 비동기 작업을 처리할 수 있다.

코루틴 빌더 (코루틴을 시작하기 위해서는 코루틴 빌더를 사용해야 한다.)

  • 주요 코루틴 빌더에는 launch, async, runBlocking 등이 있다.
  • launch: 새로운 코루틴을 시작하며, 결과를 반환하지 않는다.
  • async: 새로운 코루틴을 시작하며, Deferred 객체를 반환하여 결과를 비동기적으로 가져올 수 있다.
  • runBlocking: 현재 스레드를 차단하고 코루틴을 실행한다. 주로 메인 함수나 테스트 코드에서 사용된다.
fun main() = runBlocking {
    launch {
        val data = fetchData()
        println("받은 데이터: $data")
    }
}

 

코루틴 컨텍스트와 디스패처 (스레드)

  • 코루틴은 코루틴 컨텍스트를 통해 어떤 스레드에서 실행될지 결정한다. 디스패처(Dispatcher)는 코루틴을 실행할 스레드를 지정하는 역할을 한다.
    • Dispatchers.Default: 기본적인 백그라운드 스레드 풀에서 코루틴을 실행합니다. CPU 집약적인 작업에 적합하다.
    • Dispatchers.IO: I/O 작업에 최적화된 스레드 풀에서 코루틴을 실행한다.
    • Dispatchers.Main: 안드로이드 등에서 메인(UI) 스레드에서 코루틴을 실행한다.
    • Dispatchers.Unconfined: 호출된 스레드에서 코루틴을 실행하며, 일시 중단 후 재개될 때 스레드가 변경될 수 있다.
import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(Dispatchers.Default) {
        // 코드 작성
    }
}

fun main() = runBlocking {
    launch(Dispatchers.IO) {
        // 코드 작성
    }
}

fun main() = runBlocking {
    // This example is intended for Android applications.
    launch(Dispatchers.Main) {
        // 코드 작성
    }
}

fun main() = runBlocking {
    launch(Dispatchers.Unconfined) {
        // 코드 작성
    }
}

스프링과 코루틴

  • 코루틴(Coroutine)은 비동기 프로그래밍에서 사용하는 개념으로, 일반적인 함수 호출과 달리 실행 중간에 일시 중지하고 다시 재개할 수 있는 함수다. 코루틴은 비동기 작업을 보다 효율적으로 처리하기 위해 고안된 구조로, 동시에 여러 작업을 실행할 수 있는 것처럼 보이지만 실제로는 단일 스레드에서 실행된다.

  • Spring 공식 문서에서는 코루틴을 활용한 비동기 처리를 지원하기 위해 Kotlin과의 통합을 언급한다. Kotlin은 코루틴을 기본적으로 지원하며, suspend 키워드를 사용하여 코루틴을 정의한다. 코루틴을 사용하면 블로킹(Blocking) 없이 비동기 작업을 수행할 수 있어, I/O 작업이 많은 애플리케이션에서 특히 유용하다.

  • Spring 6.x와 Spring Boot 3.x 이상에서는 Reactor 기반의 프로젝트 외에도 Kotlin 코루틴을 지원하여, 더욱 읽기 쉽고 관리하기 쉬운 비동기 코드를 작성할 수 있다. 예를 들어, 기존의 콜백 방식이나 리액티브 스트림을 사용하는 것보다, 코루틴을 사용하면 코드의 복잡도가 낮아지고 가독성이 향상된다.

아래의 스프링 공식 문서를 통해 내용을 살펴보는 것도 좋을 것이다.

 

Coroutines :: Spring Framework

Transactions on Coroutines are supported via the programmatic variant of the Reactive transaction management provided as of Spring Framework 5.2. For suspending functions, a TransactionalOperator.executeAndAwait extension is provided. import org.springfram

docs.spring.io

 

코루틴의 핵심 메커니즘 Continuation 객체

  • 코루틴의 핵심 메커니즘 중 하나는 'Continuation' 객체다. Continuation은 코루틴의 실행 상태를 캡슐화하는 객체로, 코루틴이 중단된 지점의 정보를 저장하고 있다.
  1. 상태 저장: Continuation 객체는 코루틴이 중단된 시점의 로컬 변수, 실행 지점 등의 상태 정보를 저장한다.
  2. 재개 메커니즘: 코루틴이 재개될 때, Continuation 객체를 통해 이전에 중단된 정확한 지점부터 실행을 계속할 수 있다.
  3. 비동기 브리지: Continuation은 동기 코드와 비동기 실행 사이의 브리지 역할을 한다. 비동기 작업이 완료되면 Continuation을 통해 코루틴을 재개한다.

 

코루틴을 사용한다면 kotlin.coroutines 패키지에 들어가서 Continuation 인터페이스를 확인할 수 있다.

package kotlin.coroutines

import kotlin.contracts.*
import kotlin.coroutines.intrinsics.*
import kotlin.internal.InlineOnly

@SinceKotlin("1.3")
public interface Continuation<in T> {
    /**
     * The context of the coroutine that corresponds to this continuation.
     */
    public val context: CoroutineContext

    /**
     * Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
     * return value of the last suspension point.
     */
    public fun resumeWith(result: Result<T>)
}

Continuation의 작동 방식

  1. 생성: 코루틴이 시작되거나 중단점(suspension point)에 도달할 때마다 새로운 Continuation 객체가 생성된다.
  2. 상태 캡처: 현재의 실행 상태(변수 값, 실행 위치 등)가 Continuation 객체에 저장된다.
  3. 전달: 이 Continuation 객체는 비동기 작업을 처리하는 시스템에 전달된다.
  4. 재개: 비동기 작업이 완료되면, 저장된 Continuation 객체를 사용하여 코루틴의 실행을 재개한다.

Continuation의 장점

  1. 코드 단순화: 복잡한 비동기 로직을 단순한 순차적 코드로 작성할 수 있게 해준다.
  2. 성능 최적화: 필요한 최소한의 상태만 저장하여 메모리 사용을 최적화한다.
  3. 디버깅 용이성: 비동기 코드의 실행 흐름을 더 쉽게 추적할 수 있다.

 

 

코루을 사용하기 위해서는 스코프를 설정해야 한다.
지금부터 스코프에 대해 알아보자.


3. 코루틴(Coroutine)의 스코프

코루틴 스코프란

  • 코루틴의 스코프(Scope)는 코루틴의 수명 주기와 컨텍스트를 관리하는 역할을 한다. 코루틴 스코프는 코루틴을 실행하고 관리하는 데 사용되며, 코루틴 빌더(launch, async 등)가 어떤 컨텍스트에서 코루틴을 실행해야 하는지 알려준다.

코루틴 스코프의 역할

  1. 코루틴의 수명 주기 관리: 코루틴 스코프는 코루틴의 시작부터 종료까지의 수명 주기를 관리한다.
  2. 컨텍스트 제공: 코루틴이 실행될 때 필요한 디스패처와 예외 처리기를 제공한다.
  3. 구조화된 동시성 지원: 코루틴의 계층 구조를 만들어, 부모-자식 관계에서 에러 전파와 취소를 체계적으로 관리한다.

코루틴 스코프의 종류

  • CoroutineScope 인터페이스
    • 코루틴 스코프를 나타내는 기본 인터페이스로, coroutineContext 프로퍼티를 갖는다.

  • 빌더 함수에 의해 생성되는 스코프
    • runBlocking:
      • 현재 스레드를 차단하여 코루틴 스코프를 생성한다.
      • 주로 메인 함수나 테스트 코드에서 사용된다.
      • 함수 형태: 일반 함수 (일시 중단 함수가 아님)

    • coroutineScope:
      • 일시 중단(suspending) 함수 내에서 새로운 코루틴 스코프를 생성한다.
      • 현재 스레드를 차단하지 않고, 내부의 모든 코루틴이 완료될 때까지 현재 코루틴을 일시 중단한다.
      • 구조화된 동시성을 구현하는 데 사용된다.
      • 함수 형태: 일시 중단 함수 (suspend 함수)

    • supervisorScope:
      • coroutineScope와 유사하지만, 자식 코루틴의 실패가 다른 자식 코루틴이나 부모 코루틴에 영향을 미치지 않는 스코프를 생성한다.
      • 한 자식 코루틴에서 예외가 발생해도 다른 자식 코루틴은 취소되지 않고 계속 실행된다.
      • 에러 격리와 안정적인 동시성 제어가 필요할 때 사용한다.

  • 사용자 정의 스코프 (커스텀)
    • 클래스나 객체에서 CoroutineScope를 구현하여 커스텀 스코프를 만들 수 있다.
    • 예시: 안드로이드의 ViewModel에서 viewModelScope 사용

코루틴 스코프를 사용하는 이유

  1. 구조화된 동시성(Structured Concurrency)
    • 구조화된 동시성은 코루틴의 계층 구조를 통해 동시 작업을 체계적으로 관리하는 개념이다.
    • 부모 코루틴 스코프 내에서 생성된 자식 코루틴들은 부모의 수명 주기에 종속된다.
    • 부모 코루틴이 취소되면, 자식 코루틴들도 자동으로 취소된다.

  2. 코루틴의 취소와 예외 처리
    • 코루틴 스코프는 취소 전파를 관리한다.
    • 예외가 발생하면 상위 스코프로 예외가 전달되고, 관련된 코루틴들이 취소된다.

  3. 컨텍스트 관리
    • 코루틴 스코프는 코루틴 컨텍스트를 제공한다.
    • 디스패처(어떤 스레드에서 코루틴을 실행할지 결정)와 예외 처리기 등을 설정할 수 있다.

코루틴 스코프의 사용 예시

1. runBlocking을 사용한 코루틴 스코프 (RunBlocking.kt)

  • runBlocking은 현재 스레드를 차단하고, 내부의 코루틴이 모두 완료될 때까지 대기한다.
  • 내부에서 launch를 사용하여 새로운 코루틴을 시작할 수 있다.
  • 이 코드에서 코루틴 스코프는 runBlocking에 의해 제공된다.
package com.study.thread.study.coroutine.scope

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 이 블록 내에서 코루틴 스코프가 생성됩니다.
    launch {
        println("코루틴 1 실행 중")
    }
    launch {
        println("코루틴 2 실행 중")
    }
    // runBlocking은 내부의 모든 코루틴이 완료될 때까지 현재 스레드를 차단합니다.
    println("메인 함수 종료")
}

2. coroutineScope를 사용한 코루틴 스코프 (CoroutineScope.kt)

  • coroutineScope는 suspend 함수 내에서 사용되며, 새로운 코루틴 스코프를 생성한다.
  • 스레드를 차단하지 않고, 현재 코루틴을 일시 중단하여 내부의 코루틴이 모두 완료될 때까지 대기한다.
  • 내부의 코루틴들이 완료되면, coroutineScope 블록 이후의 코드가 실행된다.
package com.study.thread.study.coroutine.scope

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
    println("메인 함수 시작")
    coroutineScope {
        // 이 블록 내에서 새로운 코루틴 스코프가 생성됩니다.
        launch {
            println("코루틴 1 실행 중")
            delay(1000)
            println("코루틴 1 완료")
        }
        launch {
            println("코루틴 2 실행 중")
            delay(500)
            println("코루틴 2 완료")
        }
        println("코루틴 스코프 내의 코드 실행")
    }
    println("메인 함수 종료")
}

3. 클래스에서 CoroutineScope 구현하기 (CoroutineScopeClass.kt)

  • 클래스에서 CoroutineScope를 구현하여 커스텀 코루틴 스코프를 생성할 수 있다.
  • coroutineContext를 오버라이드하여 디스패처와 Job을 설정한다.
  • 이 스코프 내에서 launch를 사용하여 코루틴을 실행할 수 있다.
package com.study.thread.study.coroutine.scope

import kotlinx.coroutines.*

class MyClass : CoroutineScope {
    private val job = Job()
    override val coroutineContext = Dispatchers.Default + job

    fun start() {
        launch {
            println("작업 시작")
            delay(1000)
            println("작업 완료")
        }
    }

    fun stop() {
        job.cancel()
    }
}

fun main() {
    val myClass = MyClass()
    myClass.start()
    Thread.sleep(1500)
    myClass.stop()
}

코루틴 스코프와 구조화된 동시성의 관계 (CoroutineStructure.kt)

  1. 부모-자식 관계: 코루틴 스코프 내에서 시작된 코루틴은 자식 코루틴이 되며, 부모 코루틴의 수명 주기에 종속된다.
  2. 에러 전파: 자식 코루틴에서 예외가 발생하면 부모 코루틴으로 전파되어 전체 작업이 취소될 수 있다.
  3. 취소 전파: 부모 코루틴이 취소되면 자식 코루틴들도 함께 취소된다.
package com.study.thread.study.coroutine.scope


import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    try {
        coroutineScope {
            launch {
                println("작업 1 시작")
                delay(1000)
                println("작업 1 완료")
            }
            launch {
                println("작업 2 시작")
                delay(500)
                throw Exception("작업 2에서 예외 발생")
            }
        }
    } catch (e: Exception) {
        println("예외 처리: ${e.message}")
    }
    println("메인 함수 종료")
}

출력 결과

  • 작업 2에서 예외가 발생하면, coroutineScope 내의 다른 코루틴(작업 1)도 취소된다.
  • 예외는 coroutineScope를 감싸는 try-catch 블록으로 전파되어 처리된다.
  • 이를 통해 에러가 체계적으로 관리되는 것이다.
작업 1 시작
작업 2 시작
예외 처리: 작업 2에서 예외 발생
메인 함수 종료

최종 정리

  • 코루틴 스코프는 코루틴의 수명 주기와 컨텍스트를 관리하는 역할을 한다.
  • 코루틴 스코프를 통해 구조화된 동시성을 구현하고, 에러와 취소를 체계적으로 관리할 수 있다.
  • runBlocking, coroutineScope 등은 코루틴 스코프를 생성하는 방법이며, 각각의 사용 목적과 동작 방식이 다르다.
  • 코루틴을 효과적으로 사용하기 위해서는 코루틴 스코프의 개념과 역할을 이해하는 것이 중요하다.

 

 

코루틴 스코프에 대해 이해했다면 다음 step으로 넘어가자.
이제부턴 우리가 테스트에서 자주 사용하게될 delay() 함수에 대해 알아볼 것이다.

 

 

 

4. 코루틴의 delay() 함수와 기본 동작 이해하기1

코루틴의 delay 함수를 알아보자

  • kotlinx.coroutines 패키지 내부의 Delay 인터페이스 하단에 delay 함수가 선언되어 있다.
package kotlinx.coroutines

@InternalCoroutinesApi
public interface Delay {

    // ...

}

// 메서드가 선언되어 있다.
public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

delay() 함수의 주요 특징

  1. 코루틴 중단
    • delay() 함수는 현재 코루틴을 지정된 시간 동안 중단시킨다. 즉, 코루틴이 지정된 시간 동안 실행을 멈추고, 다른 작업을 처리할 수 있게 된다. 이것은 스레드를 블로킹하는 것이 아니라, 코루틴 자체를 중단시키는 방식이다. (굉장히 중요하다!)

  2. 스레드 해제
    • 코루틴이 중단되면, 현재 사용 중인 스레드를 다른 작업에 사용할 수 있도록 해제한다. 예를들어 코루틴이 delay()로 중단되면, 그 코루틴이 실행되던 스레드는 중단된 코루틴을 처리할 필요가 없어지기 때문에 다른 작업을 할 수 있는 상태가 된다. 코루틴은 이렇게 스레드를 블로킹하지 않고 해제시킨다.

  3. 다른 코루틴 실행 기회
    • 코루틴이 중단된 동안, 스레드는 다른 대기 중인 작업이나 다른 코루틴을 실행할 수 있는 기회를 제공받는다. 중단된 코루틴이 delay()를 통해 다시 실행될 준비가 될 때까지 스레드는 다른 코루틴들을 실행할 수 있는 상태가 되는 것이다.

    • 즉, 코루틴이 delay()로 중단되는 동안 '스레드'는 아무것도 하지 않고 '중단시킨 코루틴'의 중단된 시간이 끝날때까지 대기하는 것이 아니라 '다른 코루틴'이 현재 '스레드'에서 실행될 수 있는 기회를 제공한다는 의미다. 하지만 여기서 중요한 점은, 항상 다른 코루틴이 실행된다는 보장은 없다는 것이다. 만약 다른 대기 중인 작업이 없다면, 스레드는 쉬게 된다.

  4. 시간 경과 후 재개
    • 지정된 시간이 지나면, 중단되었던 코루틴이 다시 실행 가능한 상태가 된다. 정확히 지정된 시간 후에 실행된다는 보장은 없으며, 약간의 지연이 있을 수 있다.

 

다음 코드를 통해 delay()의 동작을 살펴보자. (CoroutineDelay.kt)

package com.study.thread.study.coroutine

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        println("코루틴 1: 시작")
        delay(1000) // 1초 동안 중단
        println("코루틴 1: 1초 후 재개")
    }

    launch {
        println("코루틴 2: 시작")
        delay(500) // 0.5초 동안 중단
        println("코루틴 2: 0.5초 후 재개")
        delay(700) // 추가 0.7초 동안 중단
        println("코루틴 2: 추가 0.7초 후 재개")
    }

    println("메인: 모든 코루틴 시작됨")
    delay(2000) // 메인 코루틴을 2초 동안 중단하여 다른 코루틴들이 완료될 때까지 기다림
    println("메인: 종료")
}

이 코드의 실행 결과는 아래와 같다.

  • 나는 아래의 결과를 보고 뭔가 이상하고 느꼈다. 왜냐하면 "코루틴 1: 시작"이 먼저 나올것이라고 생각했기 때문이다. 근데 "메인: 모든 ..." 이 먼저 출력된다. 뭘까 한참을 고민하다 이것은 내가 아직 코루틴의 동작을 잘 이해하지 못했기 때문에 가진 생각이라는 것을 알게되었다.
메인: 모든 코루틴 시작됨
코루틴 1: 시작
코루틴 2: 시작
코루틴 2: 0.5초 후 재개
코루틴 1: 1초 후 재개
코루틴 2: 추가 0.7초 후 재개
메인: 종료

이 코드의 결과가 "메인: 모든 코루틴 시작됨"부터 실행된 이유를 알아보자.

  1. 메인 코루틴 실행
    • runBlocking은 메인 코루틴을 생성한다.
    • 메인 코루틴은 순차적으로 코드를 실행한다.

  2. 자식 코루틴 생성
    • 메인 코루틴은 두 개의 launch 코루틴을 생성한다.
    • 이 launch 코루틴들은 즉시 시작되지만, 실행은 되지 않은 상태다.

  3. "메인: 모든 코루틴 시작됨" 출력
    • 메인 코루틴이 launch 블록들을 지나 이 줄에 도달한다.
    • 이 시점에서 자식 코루틴들은 아직 실행되지 않았다.

  4. 자식 코루틴 실행
    • 메인 코루틴이 delay(2000)를 만나 중단된다.
    • 이제 자식 코루틴들이 실행될 기회를 얻는다.
    • "코루틴 1: 시작""코루틴 2: 시작"이 거의 동시에 출력된다. (코루틴1에서 delay를 만나 중단되면서 2가 바로 실행된다.)

  5. 코루틴들의 중단과 재개
    • 코루틴 1은 delay()를 만나 1초 동안 중단된다.
    • 코루틴 2는 delay()를 만나 0.5초 동안 정지된 후 재개되어 "코루틴 2: 0.5초 후 재개"를 출력한다.
    • 이후 코루틴2는 delay()를 만나 0.7초 동안 정지된다.
    • 코루틴2가 0.7초간 정지되어있는 동안 코루틴 1이 1초 후 재개(delay가 끝나서)되어 "코루틴 1: 1초 후 재개"를 출력한다.
    • 코루틴1은 완전히 종료되었고 아직 정지되어있던 코루틴 2가 0.7초가 지나면서 재개되어 마지막 메시지를 출력한다.

  6. 메인 코루틴 종료
    • 2초가 지나면 메인 코루틴이 재개되어 "메인: 종료"를 출력한다.

결론적으로 main() 실행 결과 "시작됨"이 먼저 나오는 이유는 다음과 같다.

  1. 코루틴의 지연 시작: launch로 생성된 코루틴은 즉시 실행되지 않고, 다음 중단점 또는 yield 포인트까지 메인 코루틴이 계속 실행된다.
  2. 순차적 실행: 메인 코루틴은 launch 블록들을 생성한 후 바로 다음 줄로 넘어가 "메인: 모든 코루틴 시작됨" 메시지를 출력한다.
  3. 실행 기회: 자식 코루틴들은 메인 코루틴이 delay(2000)를 만나 중단될 때까지 실행 기회를 얻지 못한다.

코드의 주요 포인트

  • 위의 예시 코드를 한번 더 가져왔다.
package com.study.thread.study.coroutine

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        println("코루틴 1: 시작")
        delay(1000) // 1초 동안 중단
        println("코루틴 1: 1초 후 재개")
    }

    launch {
        println("코루틴 2: 시작")
        delay(500) // 0.5초 동안 중단
        println("코루틴 2: 0.5초 후 재개")
        delay(700) // 추가 0.7초 동안 중단
        println("코루틴 2: 추가 0.7초 후 재개")
    }

    println("메인: 모든 코루틴 시작됨")
    delay(2000) // 메인 코루틴을 2초 동안 중단하여 다른 코루틴들이 완료될 때까지 기다림
    println("메인: 종료")
}

1. 코루틴의 독립성

  • 각 코루틴은 완전히 독립적으로 실행된다.
  • 한 코루틴이 delay되었다고 해서 다른 코루틴이 delay된 코루틴의 코드를 실행하게 하지 않는다. (delay된 순간 다른 코루틴이 계속 코드를 동작시키는게 아니라 delay가 끝나면 코루틴이 기존 코드를 이어서 실행한다.)

 

2. delay 동작

  • delay(1000)를 만나면 해당 코루틴만 1초 동안 중단된다.
  • 이 중단은 오직 해당 코루틴에만 영향을 미친다.

 

3. 코드 실행의 독점성

  • 코루틴 1이 delay(1000)으로 중단되었을 때, 실행될 다른 코루틴(예: 코루틴 2)은 코루틴 1이 처리하던 코드의 다음 line 코드들을 실행하거나 완료시킬 수 없다. 각 코루틴은 자신의 코드만을 실행한도록 설계되었다.

4. 중단과 재개

  • 코루틴 1이 delay(1000) 후에 재개될 때, 반드시 자신의 다음 라인(println("코루틴 1: 1초 후 재개"))부터 실행을 하게된다.
  • 다른 코루틴은 이 과정에 개입할 수 없다.

5. 동시성과 병렬성

  • 여러 코루틴이 동시에 실행될 수 있지만, 각자의 코드 실행 흐름은 독립적이다.
  • 만약 한 코루틴이 중단되면, 다른 코루틴이 실행될 기회를 얻지만, 중단된 코루틴의 코드를 대신 실행하는 것이 아니라는 의미다.

6. 최종 결론

  • 다른 코루틴이 "코루틴 1"의 delay를 무시하고 "코루틴 1"의 나머지 코드를 실행하거나 완료시키는 것은 불가능하다.
  • 각 코루틴은 자신의 실행 흐름을 독점적으로 유지하며, delay 후에는 반드시 자신의 다음 코드를 실행한다.
  • delay는 해당 코루틴의 실행만을 일시적으로 중단시키며, 이 동안 다른 코루틴이 실행될 수는 있지만, 중단된 코루틴의 코드에는 접근할 수 없다.
  • 이러한 방식으로 코루틴은 각자의 실행 컨텍스트를 유지하면서도 효율적인 동시성을 제공한다. 한 코루틴의 delay는 다른 코루틴이 실행될 수 있는 기회를 제공하지만, 각 코루틴의 로직과 실행 순서는 항상 보존된다.

 

지금까지의 테스트에서는 1개의 스레드만 사용했기에 스레드가 코루틴의 suspend 상태에 따라 왔다갔다 이동하며 코루틴을 동작시켰다. 근데 현실적인 개발 환경에서는 멀티스레드를 사용한다. 멀티스레드 환경에서는 코루틴이 어떻게 동작할까?



 

이전까지의 테스트 상황 (1개의 스레드)

  • runBlocking은 현재 스레드(일반적으로 메인 스레드)를 블로킹하며, 그 안에서 생성된 코루틴들은 별도의 디스패처를 지정하지 않는 한 부모의 CoroutineContext를 상속받는다. 따라서 모든 코루틴이 같은 스레드에서 실행된다. 만약 여러 개의 스레드에서 코루틴을 실행하려면, 코루틴 디스패처를 명시적으로 지정하여 각 코루틴이 다른 스레드 또는 스레드 풀에서 실행되도록 해야 한다. 

새로운 스레드를 만들어 코루틴 실행하기

  • Dispatchers.Default는 공용 스레드 풀을 사용한다. 이 스레드 풀은 여러 개의 스레드를 가지고 있으며, 코루틴은 이 스레드들 중 아무 곳에서나 실행될 수 있다. 아래의 코드처럼 작성하면 이 공용 스레드 풀을 사용할 수 있다.
package com.study.thread.study.coroutine

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    // 새로운 스레드 풀을 사용하는 디스패처 지정
    launch(Dispatchers.Default) {
        println("코루틴 1: 시작 on ${Thread.currentThread().name}")
        delay(1000) // 1초 동안 중단
        println("코루틴 1: 1초 후 재개 on ${Thread.currentThread().name}")
    }

    launch(Dispatchers.Default) {
        println("코루틴 2: 시작 on ${Thread.currentThread().name}")
        delay(500) // 0.5초 동안 중단
        println("코루틴 2: 0.5초 후 재개 on ${Thread.currentThread().name}")
        delay(700) // 추가 0.7초 동안 중단
        println("코루틴 2: 추가 0.7초 후 재개 on ${Thread.currentThread().name}")
    }

    println("메인: 모든 코루틴 시작됨 on ${Thread.currentThread().name}")
    delay(2000) // 메인 코루틴을 2초 동안 중단하여 다른 코루틴들이 완료될 때까지 기다림
    println("메인: 종료 on ${Thread.currentThread().name}")
}

실행 결과

  • 하단의 결과를 보면 자신을 처리하던 스레드가 게속해서 작업을 처리하는 형태가 아니다. 왜냐하면 코루틴이 일시 중단(suspension)되었다가 재개(resume)될 때, 반드시 이전에 실행되던 스레드에서 재개되는 것이 아니라 스레드 풀 내에서 사용 가능한 다른 스레드에서 재개되기 때문이다.
  • 코루틴 2를 보면 DefaultDispatcher-worker-2에서 시작했지만, 이후에는 DefaultDispatcher-worker-1에서 실행된다. 이는 코루틴이 스레드 풀 내의 가용한 스레드에서 유연하게 실행됨을 보여준다.
코루틴 1: 시작 on DefaultDispatcher-worker-1
메인: 모든 코루틴 시작됨 on main
코루틴 2: 시작 on DefaultDispatcher-worker-2
코루틴 2: 0.5초 후 재개 on DefaultDispatcher-worker-1
코루틴 1: 1초 후 재개 on DefaultDispatcher-worker-1
코루틴 2: 추가 0.7초 후 재개 on DefaultDispatcher-worker-1
메인: 종료 on main

스레드와 코루틴의 관계

  1. 스레드 풀 내의 스레드들은 코루틴의 실행을 담당하지만, 코루틴은 특정 스레드에 고정되지 않는다.
  2. 코루틴의 작업들은 스레드 풀에 의해 관리되며, 스레드 풀은 작업량과 스레드 가용성에 따라 코루틴을 스레드에 할당한다.
  3. 코루틴 2가 처음에는 DefaultDispatcher-worker-2에서 시작되었지만, 재개 시점에는 DefaultDispatcher-worker-1이 사용 가능했기 때문에 그 스레드에서 실행된 것이다.

의미하는바

  1. 스레드 안전성 (Thread Safe)
    • 코루틴은 스레드에 독립적으로 설계되어, 실행되는 스레드가 변경되더라도 로직이나 상태에는 영향이 없다. 일시 중단(suspend)과 재개 시에도 코루틴은 자신의 컨텍스트와 데이터를 안전하게 유지하므로, 스레드 간섭 없이 안정적인 동작이 가능하다. 이는 개발자가 스레드 동기화 문제를 걱정하지 않고도 코루틴을 활용할 수 있게 해준다.

  2. 성능 최적화
    • 스레드 풀은 시스템 자원을 효율적으로 사용하기 위해 코루틴을 가용한 스레드에 유연하게 스케줄링한다. 코루틴이 일시 중단되면 해당 스레드는 다른 작업을 처리할 수 있어 자원 활용도가 높아진다. 이러한 방식은 컨텍스트 전환 오버헤드를 줄이고, 전체적인 시스템 성능을 향상시킨다.

  3. 개발자의 부담 감소
    • 코루틴은 복잡한 스레드 관리나 동기화 메커니즘을 추상화하여, 개발자가 비동기 프로그래밍을 간단하고 직관적으로 구현할 수 있게 한다. 위의 코드만 봐도 한눈에 이해가 된다는 것을 알 수 있다. 그렇기에 코드의 가독성과 유지보수성이 높아지고, 동시성 관련 버그의 발생 위험이 줄어든다. 결국 개발자는 핵심 로직에 집중할 수 있어 생산성이 향상된다.

 

이 내용은 생각보다 복잡해서 한번만 봐서는 이해하기 쉽지 않다.
다른 예시를 통해 한 번 더 분석해보자.

 

 

5. 코루틴의 delay() 함수와 기본 동작 이해하기2

코루틴의 동작 원리와 delay() 함수의 역할

  • 코루틴은 비동기적으로 여러 작업을 처리할 수 있도록 설계된 '경량 스레드'다. 코루틴의 주요 장점 중 하나는 스레드의 효율적인 사용에 있다. 스레드가 중단된 코루틴을 처리하는 동안 다른 작업을 처리할 수 있어, 자원을 효율적으로 사용할 수 있다. 이때, 코루틴의 중단점을 활용하는 함수가 바로 delay()다.

예시 코드: delay()와 코루틴의 동작 (소스코드는 github에서 CoroutineSuspend.kt)

  • 이번 예시에서 코루틴이 어떻게 생성되는지 알아가면 좋을 것이라고 생각한다. 예를 들면 runBlocking을 사용해도 코루틴이 생성되고 내부에서 launch{} 를 사용해도 코루틴이 생성된다. 다만 부모 자식의 관계는 나눠지게 된다.
  • 코루틴을 생성하는 방법은 굉장히 다양하기 때문에 그 방법들중 하나라고 생각하면 된다.
package com.study.thread.study.coroutine

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking


fun main(): Unit = runBlocking {
    launch {  // 첫 번째 코루틴
        suspendFunction()
    }

    launch {  // 두 번째 코루틴
        repeat(5) {
            delay(300)  // 0.3초마다 출력
            println("Other coroutine running: $it")
        }
    }
}

suspend fun suspendFunction() {
    println("Suspend function started")

    delay(1000)  // 1초 동안 중단됨
    println("After delay in suspend function")

    delay(1000)  // 다시 1초 동안 중단됨
    println("Suspend function ended")
}

각 코루틴의 생성

  1. runBlocking 코루틴 (부모)
    • main 함수는 runBlocking을 통해 동기적으로 하나의 코루틴을 실행한다. 즉, runBlocking이 최상위 부모(main)코루틴이다.

  2. 첫 번째 launch 코루틴 (자식1)
    • suspendFunction()을 실행하는 비동기 코루틴이 launch로 하나 더 생성된다. 이는 첫 번째 비동기 코루틴이며 runBlocking의 자식 코루틴이다.

  3. 두 번째 launch 코루틴 (자식2)
    • repeat와 delay()를 이용해 반복적으로 메시지를 출력하는 비동기 코루틴이 또 하나 더 생성된다. 이는 두 번째 비동기 코루틴이며 이는 runBlocking의 자식 코루틴이다.
    • 참고로 이때 5개의 코루틴이 생성되는 것이 아니라 1개의 코루틴이 반복만큼(5번) 작업을 처리한다.

  4. 참고사항 : launch{}에 대해서
    • launch{}는 항상 단 하나의 새로운 코루틴을 생성한다. 블록 내부의 코드 구조나 복잡성에 관계없이, 그 블록 전체가 하나의 코루틴으로 실행된다. 여러 개의 독립적인 코루틴을 만들려면, 여러 번 launch를 호출해야 한다.

    • 또한 launch 호출 시 디스패처를 명시적으로 지정하지 않으면, 코루틴은 부모의 컨텍스트를 상속받는다. 부모 코루틴이 실행되는 스레드나 디스패처를 그대로 사용하게 된다는 말이다. 예를 들어, runBlocking 내부에서 launch를 호출하면, runBlocking을 동작시키던 현재 스레드 (예: 메인 스레드)에서 launch의 코루틴이 실행된다.

예시코드에서 생성된 총 코루틴의 개수

  • 총 3개의 코루틴이 존재한다.
  • runBlocking 코루틴 (부모)
  • 첫 번째 launch 코루틴 (자식)
  • 두 번째 launch 코루틴 (자식)

실행 흐름

  1. 첫 번째 코루틴 (suspendFunction())은 시작하자마자 delay(1000)으로 1초 동안 중단된다.

  2. 이때, 두 번째 코루틴은 비동기적으로 실행되며, 0.3초마다 "Other coroutine running" 메시지를 출력한다.

  3. 첫 번째 코루틴이 중단(suspend)된 동안, 스레드는 두 번째 코루틴을 실행한다. 즉, 스레드는 중단된 첫 번째 코루틴의 delay시간이 끝나기를 기다리지 않고, 그 사이에 두 번째 코루틴을 처리한다.

  4. 첫 번째 코루틴의 delay()가 끝나면, 해당 코루틴이 재개되고 작업을 이어간다. 이 과정이 반복되면서 두 코루틴이 비동기적으로 번갈아 가며 실행된다. (바로 아래에서 더 정확하게 알아보자.)

제일 중요한 코루틴의 delay 종료 후 동작 (코루틴간 협력 방식)

1. delay 종료 시 동작

  • 코루틴의 delay가 끝나면, 해당 코루틴은 즉시 실행 가능한 상태가 된다.
  • 하지만 이것이 즉시 실행된다는 의미는 아니다.

2. 실행 중인 코루틴의 처리

  • 만약 두 번째 코루틴이 실행 중일 때 첫 번째 코루틴의 delay가 끝나면, 두 번째 코루틴은 즉시 중단되지 않는다.
  • 두 번째 코루틴은 자신의 현재 작업 단위(예: println과 그 다음의 delay)를 완료할 때까지 계속 실행된다.

3. 중단점과 스케줄링

  • 두 번째 코루틴이 자신의 delay에 도달하면 (즉, 코드의 중단점에 도달하면), 코루틴 스케줄러가 동작한다. 이 시점에서 스케줄러는 실행 가능한 코루틴들 중 하나를 선택한다.
  • 중단점(suspension point)은 코루틴이 자발적으로 실행을 양보할 수 있는 지점이다. 대표적인 중단점으로는 delay(), yield(), 네트워크 호출, 파일 I/O 등이 있다.
  • 만약 코루틴 내에 중단점이 없다면, 해당 코루틴은 작업을 모두 완료할 때까지 계속 실행된다. 이 경우, 다른 코루틴들은 이 코루틴이 완료될 때까지 실행 기회를 얻지 못할 수 있다.

4. 스케줄러의 선택

  • 스케줄러는 일반적으로 공정성을 고려하여 코루틴을 선택한다.
  • 첫 번째 코루틴의 delay가 이미 끝났다면, 이 시점에서 첫 번째 코루틴이 선택될 가능성이 높다.
  • 그러나 이는 보장된 것은 아니며, 시스템의 상태와 다른 요인들에 따라 달라질 수 있다.

5. 실제 동작 예시

  • 코루틴 1이 delay(1000) 후 실행 가능해진다.
  • 이 시점에 코루틴 2가 "코루틴 2: 작업 3" 출력 중이라면, 이 작업과 그 다음의 delay(300)까지 완료한다.
  • delay(300) 에 도달하면 코루틴 2는 중단되고, 스케줄러가 동작한다.
  • 이때 스케줄러는 실행 가능한 '코루틴 1'을 선택하여 실행할 가능성이 높다.

6. 비선점형 특성

  • 코루틴은 비선점형(non-preemptive) 방식으로 동작한다.
  • 즉, 실행 중인 코루틴은 자발적으로 제어권을 양보할 때까지 계속 실행된다.

7. 결론

  • 첫 번째 코루틴의 delay가 끝났을 때 두 번째 코루틴이 실행 중이라면, 두 번째 코루틴은 즉시 중단되지 않는다. 대신, 두 번째 코루틴이 자연스럽게 다음 중단점(예: delay)에 도달할 때까지 실행을 계속한다. 그 후에 스케줄러가 동작하여 다음에 실행할 코루틴을 선택하며, 이때 delay가 끝난 첫 번째 코루틴이 선택될 가능성이 높다. 이러한 방식으로 코루틴들은 협력적으로 실행되며, 효율적으로 리소스를 공유한다.

예시 코드 실행 결과

Suspend function started
Other coroutine running: 0
Other coroutine running: 1
Other coroutine running: 2
After delay in suspend function
Other coroutine running: 3
Other coroutine running: 4
Suspend function ended
  1. 첫 번째 코루틴 (suspendFunction())은 delay(1000)을 만나면 중단된다. 그 동안 스레드는 중단된 코루틴을 대기하지 않고, 두 번째 코루틴을 실행한다.
  2. 두 번째 코루틴은 0.3초마다 메시지를 출력하며, 첫 번째 코루틴이 delay()에서 중단될 때마다 실행된다.
  3. 첫 번째 코루틴이 다시 재개되면 그 코루틴이 다시 실행되며, 이후 두 번째 delay()에서 다시 중단된다. 이런 식으로 두 코루틴이 비동기적으로 번갈아 가며 실행된다.

 

핵심 포인트

  1. 코루틴의 중단과 재개
    • delay()는 코루틴을 중단시키고, 그동안 스레드가 다른 코루틴을 처리할 수 있게 해준다.

  2. 스레드 블로킹이 없다는 점
    • 코루틴이 중단되면 스레드가 차단되지 않고 다른 대기 중인 코루틴을 실행할 수 있는 상태가 된다.

  3. 다른 코루틴이 실행될 수 있는 기회
    • 중단된 코루틴이 다시 실행되기 전까지, 스레드는 다른 코루틴이나 작업을 처리할 기회를 가진다. 하지만 대기 중인 다른 작업이 없으면, 스레드는 쉬게 된다.

결론

  • delay()는 코루틴의 중단을 통해 스레드 자원을 효율적으로 사용할 수 있게 해주고, 다른 코루틴이 실행될 수 있는 기회를 제공한다.
  • 스레드는 중단된 코루틴을 기다리는 것이 아니라, 다른 코루틴을 실행하면서 비동기 작업의 효율성을 극대화할 수 있다.

 

이번에도 멀티 스레드 환경을 구성해보자.

 

 

멀티 스레드 환경 구성하기 (Dispatchers.Default 사용)

  • Dispatchers.Default는 공유된 스레드 풀을 사용하며, 이 스레드 풀은 시스템의 가용한 CPU 코어 수에 따라 스레드 수를 결정한다. 이 스레드 풀 내의 스레드들은 작업 큐를 통해 코루틴을 스케줄링하며, 코루틴은 일시 중단 후 재개될 때 다른 스레드에서 실행될 수 있다.
package com.study.thread.study.coroutine

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main(): Unit = runBlocking {
    launch(Dispatchers.Default) {  // 첫 번째 코루틴
        suspendFunction2()
    }

    launch(Dispatchers.Default) {  // 두 번째 코루틴
        repeat(5) {
            delay(300)  // 0.3초마다 출력
            println("Other coroutine running: $it on ${Thread.currentThread().name}")
        }
    }
}

suspend fun suspendFunction2() {
    println("Suspend function started on ${Thread.currentThread().name}")

    delay(1000)  // 1초 동안 중단됨
    println("After delay in suspend function on ${Thread.currentThread().name}")

    delay(1000)  // 다시 1초 동안 중단됨
    println("Suspend function ended on ${Thread.currentThread().name}")
}

결과는 다음과 같다.

  • 이번에도 확인할 수 있는 것은 코루틴은 일시 중단(suspension) 후에 재개(resume)될 때, 반드시 이전에 실행되던 스레드에서 재개되지 않는다는 것이다. 대신, 스레드 풀 내에서 사용 가능한 다른 스레드에서 재개된다.
Suspend function started on DefaultDispatcher-worker-1
Other coroutine running: 0 on DefaultDispatcher-worker-2
Other coroutine running: 1 on DefaultDispatcher-worker-2
Other coroutine running: 2 on DefaultDispatcher-worker-2
After delay in suspend function on DefaultDispatcher-worker-2
Other coroutine running: 3 on DefaultDispatcher-worker-2
Other coroutine running: 4 on DefaultDispatcher-worker-2
Suspend function ended on DefaultDispatcher-worker-2

만약 코루틴 launch가 좀 더 많으면 어떻게될까?

  • 코드에 작성되어 있던 launch {} 를 복사해서 2개 더 추가했다.
package com.study.thread.study.coroutine

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main(): Unit = runBlocking {
    launch(Dispatchers.Default) {  // 첫번째 코루틴
        suspendFunction()
    }

    launch(Dispatchers.Default) {  // 두번째 코루틴
        suspendFunction()
    }

    launch(Dispatchers.Default) {  // 세번째 코루틴
        suspendFunction()
    }

    launch(Dispatchers.Default) {  // 네번째 코루틴
        repeat(5) {
            delay(300)  // 0.3초마다 출력
            println("Other coroutine running: $it on ${Thread.currentThread().name}")
        }
    }
}

suspend fun suspendFunction() {
    println("Suspend function started on ${Thread.currentThread().name}")

    delay(1000)  // 1초 동안 중단됨
    println("After delay in suspend function on ${Thread.currentThread().name}")

    delay(1000)  // 다시 1초 동안 중단됨
    println("Suspend function ended on ${Thread.currentThread().name}")
}

결과는 다음과 같다.

  • 코루틴이 많이 필요하다보니 DefaultDispatcher-worker가 4까지 생성된 것을 확인할 수 있었다.
Suspend function started on DefaultDispatcher-worker-1
Suspend function started on DefaultDispatcher-worker-2
Suspend function started on DefaultDispatcher-worker-3
Other coroutine running: 0 on DefaultDispatcher-worker-1
Other coroutine running: 1 on DefaultDispatcher-worker-1
Other coroutine running: 2 on DefaultDispatcher-worker-1
After delay in suspend function on DefaultDispatcher-worker-2
After delay in suspend function on DefaultDispatcher-worker-1
After delay in suspend function on DefaultDispatcher-worker-4
Other coroutine running: 3 on DefaultDispatcher-worker-4
Other coroutine running: 4 on DefaultDispatcher-worker-4
Suspend function ended on DefaultDispatcher-worker-1
Suspend function ended on DefaultDispatcher-worker-4
Suspend function ended on DefaultDispatcher-worker-2

 

 

delay() 함수와 코루틴의 기본 동작에 대한 설명이 끝났다.
이제부터는 코루틴을 사용하기 위한 suspend 함수를 알아보자.


6. 코루틴(Coroutine)의 suspend 함수

지금까지 delay함수를 예시로 들면서 suspend 함수를 사용하던 부분들이 있었다. (바로 위의 멀티스레드 예시)

이제는 suspend(일시정지) 함수를 어떻게 사용하면 될지 하나하나 알아보자.

 

suspend 함수란?

  • 코루틴에서 suspend 함수는 일시 중단(suspend) 및 재개(resume)가 가능한 특별한 함수다. 일반 함수와는 달리, suspend 함수는 코루틴 내에서만 호출될 수 있으며, 함수 실행 중에 코루틴을 일시 중단할 수 있다.
suspend fun fetchData(): String {
    // 네트워크 요청 시뮬레이션
    delay(1000)
    return "데이터 가져오기 완료"
}

suspend 함수의 특징

  • 코루틴 컨텍스트에서 실행: suspend 함수는 코루틴 내에서 호출되어야 한다.
  • 일시 중단 가능: 함수 실행 중에 delay(), withContext() 등과 같은 일시 중단 함수를 호출하여 코루틴을 일시 중단할 수 있다.
  • 비차단식 프로그래밍: 스레드를 차단하지 않고도 긴 작업을 처리할 수 있다.

suspend 함수에서 일반 함수 호출이 가능한가?

  • suspend 함수 내부에서는 일반 함수를 자유롭게 호출할 수 있다. 그러나 그 반대는 성립하지 않는다. 즉, 일반 함수에서는 직접 suspend 함수를 호출할 수 없다.
// suspend 함수
suspend fun processData() {
    val data = fetchData()
    val processedData = transformData(data)
    println(processedData)
}

// 일반 함수
fun transformData(input: String): String {
    // 일반 함수 로직
    return input.uppercase()
}
  • 위 예제의 processData()는 suspend 함수로, 데이터를 가져온 후 transformData()라는 일반 함수를 호출한다.
  • transformData()는 문자열을 받아서 대문자로 변환하여 반환하는 일반 함수다.

코루틴의 suspend 지점을 가장 쉽게 파악할 수 있는 방법 (IntelliJ)

  • suspend fun을 사용하는 코드의 좌측에는 IntellJ에서 일시정지 아이콘을 표시해준다.
  • delay() 함수 또한 일시정지를 나타내기에 인텔리제이에서 아이콘을 표시해 주는것을 확인할 수 있다. 이렇게 개발하면서도 인텔리제이의 도움을 받아서 일시정지 구역을 바로바로 체크할 수 있다.

인텔리제이 suspend 함수 일시정지 아이콘

suspend 함수 내부에서 일반 함수를 호출한다면?

  • 코루틴은 suspend 함수 내의 특정 지점(중단점)에서만 중단될 수 있다.
  • 중단점의 대표적인 예는 delay(), await(), 또는 다른 suspend 함수의 호출이다.
  • 즉, 일반 함수를 호출할때는 코루틴을 중단시키지 않는다.
  • 아래의 코드를 보면 일반 함수에는 일시정지 아이콘이 없는것을 확인할 수 있다.

일반 함수 일시정지 아이콘 확인

 

 

음.. suspend함수에서 일반 함수를 호출하면 코루틴의 context가 어떻게 될지
좀 더 자세히 알아보자.


suspend 함수의 코루틴 실행 컨텍스트 이해하기

  • 만약 suspend 함수에서 일반 함수를 호출하면, 일반 함수는 동일한 코루틴 컨텍스트에서 실행된다. 이는 별도로 컨텍스트를 변경하지 않는 한, 동일한 스레드와 코루틴 범위를 공유한다는 의미다.
package com.study.thread.study.coroutine.suspend

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

// 코루틴 컨텍스트와 일반 함수 컨텍스트 비교
fun main(): Unit = runBlocking {
    launch(Dispatchers.Default) {
        performOperation()
    }
}

// suspend 함수
suspend fun performOperation() {
    println("코루틴 컨텍스트: ${Thread.currentThread().name}")
    regularFunctionContext()
}

// 일반 함수
fun regularFunctionContext() {
    println("일반 함수 컨텍스트: ${Thread.currentThread().name}")
}

출력 결과는 다음과 같다.

  • 이 출력 결과는 suspend 함수와 일반 함수 모두 동일한 스레드에서 실행됨을 보여준다.
코루틴 컨텍스트: DefaultDispatcher-worker-1
일반 함수 컨텍스트: DefaultDispatcher-worker-1

이런 실행 컨텍스트를 아는것이 중요한 이유가 있다.

  • 성능: 일반 함수에서 무거운 연산을 수행하면 코루틴이 차단될 수 있으므로 주의해야 한다.
  • 스레드 관리: 실행 컨텍스트를 알면 스레드 안전성을 확보하고 스레드 관련 문제를 피할 수 있다.

 

 

멀티 스레드 환경을 구성해보면서 스레드를 여러개 사용하면 코루틴이 어떻게 동작하는지 알아봤다.
이번에는 메인 스레드의 동작 관점으로 멀티스레드 활용 코드를 다시한번 살펴보자.



7. suspend 함수의 스레드 관리

일반 함수에서 무거운 작업을 수행할 때의 주의점

  • suspend 함수 내부에서 일반 함수를 호출할 때, 일반 함수가 무거운 연산이나 스레드를 차단하는 작업을 수행한다면 코루틴의 장점을 살리지 못하고 성능 문제가 발생할 수 있다. 코루틴은 비동기 프로그래밍을 효율적으로 처리하기 위해 설계되었지만, 일반 함수에서 스레드를 차단하면 코루틴의 비동기성이 무의미해진다.

스레드 차단의 위험성: 단일 스레드를 사용할때 (main 스레드만 사용)

  • 하단의 transformData() 함수에서 Thread.sleep()으로 스레드를 차단하고 있다.
package com.study.thread.study.coroutine.suspend

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        println("추가 코루틴 시작 - 스레드: ${Thread.currentThread().name}")
        repeat(5) { i ->
            println("추가 코루틴 실행 중... $i - 스레드: ${Thread.currentThread().name}")
            delay(500)
        }
    }
    processData()
}

suspend fun processData() {
    val data = fetchData()
    val processedData = transformData(data) // 메인 스레드 차단
    println("결과 출력 - 스레드: ${Thread.currentThread().name}")
}

suspend fun fetchData(): String {
    println("fetchData 시작 - 스레드: ${Thread.currentThread().name}")
    delay(1000)
    println("fetchData 완료 - 스레드: ${Thread.currentThread().name}")
    return "Data fetched"
}

fun transformData(input: String): String {
    println("transformData 시작 - 스레드: ${Thread.currentThread().name}")
    Thread.sleep(1000) // 메인 스레드 차단
    println("transformData 완료 - 스레드: ${Thread.currentThread().name}")
    return input.uppercase()
}
  • 위 코드에서 transformData() 함수는 Thread.sleep(1000)을 사용하여 현재 스레드를 1초간 차단한다. 이는 코루틴의 비동기 실행을 방해하고, 메인 스레드에서 실행될 경우 UI 응답성 저하 또는 애플리케이션이 멈추는 현상을 유발할 수 있다.

실행 결과는 다음과 같다.

  • 코루틴 디스패처를 사용하지 않기 때문에 main 스레드 1개만 사용한다는 것을 알 수 있다.
fetchData 시작 - 스레드: main
추가 코루틴 시작 - 스레드: main
추가 코루틴 실행 중... 0 - 스레드: main
추가 코루틴 실행 중... 1 - 스레드: main
fetchData 완료 - 스레드: main
transformData 시작 - 스레드: main
transformData 완료 - 스레드: main
결과 출력 - 스레드: main
추가 코루틴 실행 중... 2 - 스레드: main
추가 코루틴 실행 중... 3 - 스레드: main
추가 코루틴 실행 중... 4 - 스레드: main

실행 순서 상세 분석

1. runBlocking 시작: 메인 스레드를 차단하고 코루틴 스코프를 생성한다.

2. launch 블록 실행

  • launch를 호출하여 새로운 코루틴을 생성한다.
  • 하지만 launch는 비동기로 동작하므로, 현재 스레드는 즉시 다음 코드(processData())로 진행한다.
fun main() = runBlocking {
    launch {
        println("추가 코루틴 시작 - 스레드: ${Thread.currentThread().name}")
        repeat(5) { i ->
            println("추가 코루틴 실행 중... $i - 스레드: ${Thread.currentThread().name}")
            delay(500)
        }
    }
    processData()
}

3. processData() 호출

  • main 함수에서는 launch {}를 호출한 뒤 바로 내부 코드를 실행하지 않고 우선 순차적으로 processData() 함수를 호출한다.
suspend fun processData() {
    val data = fetchData()
    val processedData = transformData(data) // 메인 스레드 차단
    println("결과 출력 - 스레드: ${Thread.currentThread().name}")
}

4. fetchData() 실행 및 지연

  • processData()함수 내부에서 fetchData() 함수가 실행되고 "시작" 메시지를 출력한 후 delay(1000)으로 코루틴을 1초 지연시킨다. 이 지연시간 동안 메인 스레드는 다른 코루틴을 실행할 수 있게 된다.
suspend fun fetchData(): String {
    println("fetchData 시작 - 스레드: ${Thread.currentThread().name}")
    delay(1000)
    println("fetchData 완료 - 스레드: ${Thread.currentThread().name}")
    return "Data fetched"
}

5. launch 코루틴의 실행

  • fetchData()함수의 1초 지연시간 동안 launch로 생성된 코루틴이 실행된다. 다만 repeat(5)에 따라 반복문 코드가 실행되고 "추가 코루틴 실행 중..." 메시지를 출력한뒤 delay(500)으로 0.5초 지연된다. 이 지연 또한 코루틴의 일시 중단을 의미하며, 이렇게 됨으로써 다시 다른 코루틴이 실행될 수 있게 되었다.
fun main() = runBlocking {
    // 다시 이 launch가 동작한다.
    launch {
        println("추가 코루틴 시작 - 스레드: ${Thread.currentThread().name}")
        repeat(5) { i ->
            println("추가 코루틴 실행 중... $i - 스레드: ${Thread.currentThread().name}")
            delay(500)
        }
    }
    processData()
}

6. 1초간 지연되었던 fetchData()함수가 재개되면

  • "완료" 메시지를 출력하고 processData() 함수 내부에서 transformData()를 호출한다.

7. transformData()의 스레드 차단

  • transformData()에서는 Thread.sleep(1000)으로 메인 스레드를 차단하게 된다. (delay와는 다른 완전 스레드 차단이다.)
  • 이 스레드 차단시간 동안에는 메인 스레드에서 실행되는 다른 코루틴이나 작업이 실행되지 못한다. (모든것이 완전 정지된다.)
suspend fun processData() {
    val data = fetchData()
    
    // 1초 시간이 지난 뒤 아래 함수가 호출된다.
    val processedData = transformData(data)
    
    println("결과 출력 - 스레드: ${Thread.currentThread().name}")
}

fun transformData(input: String): String {
    println("transformData 시작 - 스레드: ${Thread.currentThread().name}")
    Thread.sleep(1000) // 메인 스레드 차단
    println("transformData 완료 - 스레드: ${Thread.currentThread().name}")
    return input.uppercase()
}

8. launch 코루틴의 재개 (남은 반복문 호출)

  • Thread.sleep이 끝나고 메인 스레드가 다시 사용 가능해지면, 남은 반복 작업을 수행한다.
fun main() = runBlocking {
    // 마지막으로 다시 이 launch가 동작한다.
    launch {
        println("추가 코루틴 시작 - 스레드: ${Thread.currentThread().name}")
        repeat(5) { i ->
            println("추가 코루틴 실행 중... $i - 스레드: ${Thread.currentThread().name}")
            delay(500)
        }
    }
    processData()
}

 

결론

  • 코루틴은 협력적 멀티태스킹을 사용하여 스레드 간 작업을 스케줄링한다. delay()와 같은 일시 중단 함수를 만나면 코루틴은 일시 중단되고, 스케줄러는 다른 코루틴을 실행할 수 있다.
  • Thread.sleep()은 현재 스레드를 완전히 차단하므로, 해당 스레드에서 실행 중인 다른 코루틴도 실행되지 못한다. 따라서 메인 스레드에서 Thread.sleep()을 사용하면, 메인 스레드에서 실행되는 모든 코루틴이 일시적으로 중지된다.
  • 현재 코드에서는 모든 코루틴이 메인 스레드에서 실행되고 있다. 따라서 메인 스레드가 차단되면 코루틴의 실행에 직접적인 영향을 미친다는것을 확인할 수 있다.

 

 

만약 코루틴이 main 스레드만 사용한다면 스레드 차단이 발생하게 되면 성능상 이점이 없다는 것을 알았다. 하지만 걱정할 필요는 없다. 우리는 이미 해결방법을 알고있기 때문이다.



문제해결 1번 : 스레드 차단 피하기 (CoroutineDelay.kt)

  • transformData() 함수에서 Thread.sleep() 대신 delay()를 사용하면 코루틴은 일시 중단되지만 스레드는 차단되지 않는다. 이를 통해 메인 스레드의 차단을 피할 수 있다.
suspend fun transformData(input: String): String {
    println("transformData 시작 - 스레드: ${Thread.currentThread().name}")
    delay(1000) // 코루틴 일시 중단
    println("transformData 완료 - 스레드: ${Thread.currentThread().name}")
    return input.uppercase()
}

스레드 차단 피하기 : 실행결과

  • transformData() 함수에서 Thread.sleep(1000)을 delay(1000)으로 변경하여 메인 스레드를 차단하지 않고 코루틴을 일시 중단한다. 이로 인해 메인 스레드가 차단되지 않아 다른 코루틴들이 원활하게 실행된다. 아래 결과를 보면 모든 작업이 메인 스레드에서 실행되지만, 스레드 차단이 없기 때문에 응답성이 유지되는 것을 확인할 수 있다.
fetchData 시작 - 스레드: main
추가 코루틴 시작 - 스레드: main
추가 코루틴 실행 중... 0 - 스레드: main
추가 코루틴 실행 중... 1 - 스레드: main
fetchData 완료 - 스레드: main
transformData 시작 - 스레드: main
추가 코루틴 실행 중... 2 - 스레드: main
추가 코루틴 실행 중... 3 - 스레드: main
transformData 완료 - 스레드: main
결과 출력 - 스레드: main
추가 코루틴 실행 중... 4 - 스레드: main

문제해결 2번 : 별도의 디스패처 사용 (CoroutineDispatcher.kt)

  • 무거운 작업이나 스레드를 차단하는 작업은 withContext(Dispatchers.Default)를 사용하여 별도의 스레드에서 실행하는 것이 좋다. 그러면 메인 따로 별도 스레드 따로 코루틴 작업을 처리한다.
  • withContext(Dispatchers.Default) 이 코드는 transformData() 함수를 기본 디스패처(Dispatchers.Default)에서 실행되도록 한다. Dispatchers.Default는 일반적으로 백그라운드 스레드 풀을 사용하므로, 메인 스레드를 차단하지 않는다.
// 여기서 별도의 디스패처를 사용한다. Dispatchers.Default 적용
suspend fun processData() {
    val data = fetchData()
    
    val processedData = withContext(Dispatchers.Default) {
        transformData(data) // 별도의 스레드에서 실행
    }
    
    println("결과 출력 - 스레드: ${Thread.currentThread().name}")
}

별도의 디스패처 : 실행결과

  • 코드를 보면 transformData() 함수는 여전히 Thread.sleep(1000)을 사용하여 스레드를 차단한다. 그러나 withContext(Dispatchers.Default)를 사용하여 별도의 디스패처에서 실행하므로, 메인 스레드가 차단되지 않는다. 따라서 메인 스레드에서 실행되는 코루틴들이 원활하게 실행되는 모습을 관찰할 수 있다. (transformData 시작 다음에도 "추가 코루틴 실행 중"이 계속해서 나오는 것을 확인할 수 있다.)
fetchData 시작 - 스레드: main
추가 코루틴 시작 - 스레드: main
추가 코루틴 실행 중... 0 - 스레드: main
추가 코루틴 실행 중... 1 - 스레드: main
fetchData 완료 - 스레드: main
transformData 시작 - 스레드: DefaultDispatcher-worker-1
추가 코루틴 실행 중... 2 - 스레드: main
추가 코루틴 실행 중... 3 - 스레드: main
transformData 완료 - 스레드: DefaultDispatcher-worker-1
결과 출력 - 스레드: main
추가 코루틴 실행 중... 4 - 스레드: main

문제해결 3번 : 코루틴의 실행 순서 제어 Join 사용 (CoroutineJoin.kt)

  • 만약 코루틴의 실행 순서를 명확하게 제어하고 싶다면, join()을 사용하여 코루틴이 완료될 때까지 기다릴 수 있다.
package com.study.thread.study.coroutine.suspend

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        println("추가 코루틴 시작 - 스레드: ${Thread.currentThread().name}")
        repeat(5) { i ->
            println("추가 코루틴 실행 중... $i - 스레드: ${Thread.currentThread().name}")
            delay(500)
        }
    }
    
    job.join() // 추가 코루틴이 완료될 때까지 기다림
    joinProcessData()
}

코루틴의 실행 순서 제어: 실행결과

  • 코드에서 job.join()을 호출하여 launch로 생성된 추가 코루틴이 완료될 때까지 대기한다. 따라서 processData()는 추가 코루틴이 완료된 후에 실행된다. 이렇게 코루틴의 실행 순서를 명확하게 제어할 수 있다.
추가 코루틴 시작 - 스레드: main
추가 코루틴 실행 중... 0 - 스레드: main
추가 코루틴 실행 중... 1 - 스레드: main
추가 코루틴 실행 중... 2 - 스레드: main
추가 코루틴 실행 중... 3 - 스레드: main
추가 코루틴 실행 중... 4 - 스레드: main
fetchData 시작 - 스레드: main
fetchData 완료 - 스레드: main
transformData 시작 - 스레드: main
transformData 완료 - 스레드: main
결과 출력 - 스레드: main

 

 

 

반응형