Kotlin 코루틴 시리즈 (2/5)

이전 편: 1 suspend 함수의 동작 원리

다음 편: 3 구조화된 동시성과 예외 처리

suspend 함수가 "중단될 수 있는 함수"라면, 이 함수를 어디서, 어떤 환경에서 실행할지를 결정하는 것이 CoroutineScope, CoroutineContext, Dispatcher다.

비유하자면 suspend 함수는 레시피(어떤 작업을 할지)이고, Scope은 주방(작업 환경), Context는 주방의 설비(필요한 도구와 규칙), Dispatcher는 셰프(실제로 일하는 스레드)다. 레시피만 있으면 요리를 할 수 없다. 주방과 셰프가 있어야 한다.

코루틴 빌더

suspend 함수를 실행하려면 코루틴을 시작해야 한다. 이 역할을 하는 것이 코루틴 빌더다.

launch — 결과가 필요 없을 때

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    val data = fetchData()   // suspend 함수 호출 가능
    println(data)
}
  • launchJob을 반환한다. 결과값이 아니라 작업의 핸들이다.
  • "이 작업을 시작하고, 완료 여부만 추적한다"는 의미.
  • 반환값이 필요 없는 작업 (로깅, 알림 전송, 이벤트 발행 등)에 적합하다.

async — 결과가 필요할 때

val deferred: Deferred<String> = scope.async {
    fetchData()
}

val result: String = deferred.await()  // 결과를 기다림
  • asyncDeferred<T>를 반환한다. DeferredJob의 확장으로, 결과값을 가진 Job이다.
  • await()로 결과를 받을 수 있다. await()suspend 함수이므로 스레드를 블로킹하지 않는다.

async의 진짜 강점은 병렬 실행이다.

suspend fun fetchUserProfile(userId: Long): UserProfile {
    val user = async { userService.findById(userId) }
    val posts = async { postService.findByUserId(userId) }

    // 두 작업이 동시에 실행되고, 둘 다 완료될 때까지 기다림
    return UserProfile(user.await(), posts.await())
}

순차 실행이면 findByIdfindByUserId로 총 2초 걸릴 작업이, 병렬 실행이면 1초로 줄어든다.


runBlocking — 코루틴과 일반 세계의 다리

fun main() = runBlocking {
    val result = fetchData()
    println(result)
}
  • runBlocking현재 스레드를 블로킹하면서 코루틴을 실행한다.
  • 코루틴 세계와 일반(non-suspend) 세계를 연결하는 다리 역할이다.
  • main 함수나 테스트에서 쓴다. 프로덕션 코드에서는 가급적 쓰지 않는다.
runBlocking을 프로덕션에서 쓰지 말 것

runBlocking은 스레드를 블로킹한다. 코루틴의 장점(스레드를 효율적으로 쓰는 것)을 무력화시킨다. Spring에서 코루틴을 쓸 때는 Spring이 제공하는 코루틴 지원(suspend 컨트롤러 등)을 활용해야 한다.


CoroutineScope

CoroutineScope은 코루틴의 생명주기를 관리한다. 스코프가 취소되면 그 안에서 실행 중인 모든 코루틴도 취소된다.

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

    fun fetchPosts() {
        launch {
            // 이 코루틴은 PostService의 스코프에 속한다
        }
    }

    fun cleanup() {
        job.cancel()  // 스코프 내 모든 코루틴 취소
    }
}

실무에서 직접 CoroutineScope을 구현하는 경우는 드물다. 프레임워크가 제공하는 스코프를 쓰는 것이 일반적이다.

  • viewModelScope — Android ViewModel의 생명주기에 연결.
  • lifecycleScope — Android Activity/Fragment의 생명주기에 연결.
  • Springsuspend 함수를 컨트롤러에 사용하면 Spring이 자체적으로 스코프를 관리한다.

스코프의 핵심 역할은 코루틴이 "어디에 속하는지"를 정의하는 것이다. 부모 스코프가 취소되면 자식 코루틴도 모두 취소된다. 이것이 구조화된 동시성의 기반이다. 자세한 내용은 다음 문서에서 다룬다.


CoroutineContext

CoroutineContext는 코루틴이 실행되는 환경 정보를 담은 맵이다. 여러 요소(Element)로 구성된다.

graph TD Context["CoroutineContext"] Context --> Job["Job — 코루틴의 생명주기"] Context --> Dispatcher["Dispatcher — 실행 스레드"] Context --> Name["CoroutineName — 디버깅용 이름"] Context --> Handler["CoroutineExceptionHandler"] style Context fill:#e1f5fe

Context 요소들은 + 연산자로 합칠 수 있다.

val context = Dispatchers.IO + CoroutineName("data-fetch") + SupervisorJob()
  • +로 요소를 합치면 새 Context가 만들어진다.
  • 같은 키의 요소가 있으면 오른쪽이 왼쪽을 덮어쓴다.

CoroutineScope은 본질적으로 CoroutineContext의 래퍼다. 스코프가 가진 context가 그 안에서 생성되는 모든 코루틴에 상속된다.


Dispatcher — 어떤 스레드에서 실행할 것인가

Dispatcher는 코루틴이 어떤 스레드(풀)에서 실행될지를 결정한다. 가장 중요한 Context 요소 중 하나다.

Dispatchers.Default

CPU 바운드 작업에 적합하다. JSON 파싱, 정렬, 복잡한 계산 등.

  • 공유 스레드풀을 사용하며, 코어 수만큼 스레드가 있다.
  • launch { }, async { }의 기본 Dispatcher.

Dispatchers.IO

I/O 바운드 작업에 적합하다. 네트워크 요청, 파일 읽기/쓰기, DB 쿼리 등.

  • Default와 스레드풀을 공유하지만, 최대 64개(또는 코어 수)까지 스레드를 추가로 만들 수 있다.
  • I/O 작업은 대기 시간이 길기 때문에 스레드를 더 많이 써도 괜찮다.
suspend fun readFile(path: String): String = withContext(Dispatchers.IO) {
    File(path).readText()  // 블로킹 I/O를 IO Dispatcher에서 실행
}

withContext코루틴의 실행 스레드를 전환하는 함수다. 블로킹 코드를 withContext(Dispatchers.IO)로 감싸면, I/O 전용 스레드에서 실행되어 다른 코루틴을 블로킹하지 않는다.


Dispatchers.Main

UI 스레드에서 실행한다. Android에서 UI를 업데이트할 때 사용한다. 서버 개발에서는 거의 쓰지 않는다.


Dispatchers.Unconfined

코루틴이 처음 중단될 때까지는 호출한 스레드에서 실행되고, 재개 시에는 중단된 suspend 함수가 사용한 스레드에서 실행된다. 일반적으로 사용을 권장하지 않는다. 테스트나 특수한 경우에만 쓴다.


선택 기준 정리

Dispatcher용도예시
DefaultCPU 바운드JSON 파싱, 정렬, 계산
IOI/O 바운드네트워크, 파일, DB
MainUI 업데이트Android UI
Unconfined특수 용도테스트

withContext — Dispatcher 전환

withContext는 코루틴의 Context를 전환하고, 블록이 끝나면 원래 Context로 돌아온다.

suspend fun processData(): Result {
    val raw = withContext(Dispatchers.IO) {
        fetchFromNetwork()           // IO 스레드에서
    }
    val parsed = withContext(Dispatchers.Default) {
        parseJson(raw)               // CPU 스레드에서
    }
    return parsed
}

withContext는 새 코루틴을 만드는 것이 아니다. 같은 코루틴의 실행 스레드만 전환한다. launchasync와 달리 병렬 실행이 아니라 순차 실행이다.


심화 분석

Context 상속

자식 코루틴은 부모의 Context를 상속받되, 자신이 지정한 요소로 오버라이드한다.

val scope = CoroutineScope(Dispatchers.Default + CoroutineName("parent"))

scope.launch(Dispatchers.IO) {
    // Dispatcher: IO (오버라이드)
    // CoroutineName: "parent" (상속)
    // Job: 새 Job이 생성되되, parent의 Job이 부모가 됨
}

Job은 특별하다. 자식 코루틴에 새 Job이 자동으로 생성되지만, 부모의 Job과 부모-자식 관계를 맺는다. 이 관계가 구조화된 동시성의 기반이 된다.

GlobalScope은 왜 안 좋은가

// 쓰지 말 것
GlobalScope.launch {
    // 이 코루틴은 아무 스코프에도 속하지 않는다
}

GlobalScope은 애플리케이션 전체 생명주기를 가진다. 이 안에서 시작된 코루틴은 누구도 취소하지 않는다. 메모리 누수의 원인이 되고, 구조화된 동시성의 이점을 포기하는 것이다.

항상 적절한 생명주기를 가진 스코프를 사용하자.


자주 하는 실수

launch와 async 혼동

// 결과를 쓰는데 launch를 쓴 경우 — 결과를 받을 수 없다
val job = launch { fetchData() }  // job.결과? → 없다

// 결과가 필요하면 async
val deferred = async { fetchData() }
val result = deferred.await()

결과값이 필요하면 async, 필요 없으면 launch. 단순한 규칙이다.

withContext 대신 async + await

// 나쁜 예 — 순차 실행인데 async를 불필요하게 사용
val result = async(Dispatchers.IO) { fetchData() }.await()

// 좋은 예 — 순차 실행이면 withContext
val result = withContext(Dispatchers.IO) { fetchData() }

async { }.await()를 바로 하면 병렬 실행의 이점이 없다. 순차적 Context 전환이면 withContext가 더 적합하고 효율적이다.

Dispatcher.IO를 모든 곳에 사용

// 불필요한 IO 전환 — CPU 작업인데 IO에서 실행
withContext(Dispatchers.IO) {
    list.sortedBy { it.name }  // CPU 작업인데?
}

정렬, 계산, 파싱 등 CPU 바운드 작업은 Dispatchers.Default에서 실행해야 한다. IO는 I/O 대기 시간이 있는 작업에만 사용한다.