코루틴 스코프의 종류와 각각의 특징, 그리고 실제로 어떤 상황에서 어떤 스코프를 사용하면 좋은지 초보자도 이해할 수 있도록 다양한 예제와 함께 정리합니다. 실수하기 쉬운 부분과 실전 패턴, 공식 문서 레퍼런스까지 한 번에 익혀보세요.


들어가며

코틀린에서 비동기 프로그래밍을 안전하게 구현하려면 코루틴과 더불어 코루틴 스코프의 이해가 필수입니다. 코루틴 스코프는 코루틴의 생명주기와 취소 정책을 결정하며, 잘못 사용하면 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다. 이 글에서는 대표적인 코루틴 스코프의 종류와 각각의 특징, 그리고 실전에서 어떤 상황에 어떤 스코프를 선택해야 하는지 다양한 예제와 함께 설명합니다.

또한, 실전에서 비동기 흐름을 유연하게 제어할 때 자주 언급되는 Deferred(디퍼드) 패턴에 대해서도 함께 다룹니다. Kotlin의 Deferred, JavaScript의 Deferred/Promise 등 다양한 환경에서의 활용법과 차이점, 그리고 실무에서의 사용 맥락을 예시와 함께 정리합니다.

코루틴 스코프란?

  • 코루틴을 실행하고 관리하는 컨텍스트(범위)
  • 스코프가 종료되면 해당 스코프에서 실행된 모든 코루틴이 자동으로 취소됨
  • 코루틴의 생명주기를 안전하게 관리하는 핵심 도구

대표적인 코루틴 스코프 종류

  1. GlobalScope
  2. CoroutineScope
  3. MainScope
  4. viewModelScope (Android)
  5. lifecycleScope (Android)

1. GlobalScope

  • 앱 전체에서 살아있는 글로벌한 스코프
  • 앱이 종료될 때까지 살아있으므로, 메모리 누수 위험이 있음
  • 실제로는 거의 권장되지 않음
GlobalScope.launch {
    // 앱이 종료될 때까지 살아있는 작업
    println("GlobalScope에서 실행")
}

언제 사용? 정말로 앱 전체 생명주기와 함께해야 하는 백그라운드 작업(예: 로그 수집 등) 외에는 사용 자제


WebClient/ktor-client 코루틴 실전 예제

코루틴 스코프와 async를 활용하면 여러 비동기 HTTP 요청을 동시에 처리할 수 있습니다. 대표적으로 ktor-client, Spring WebClient 등에서 코루틴을 쉽게 사용할 수 있습니다.

1. ktor-client를 활용한 병렬 HTTP 호출 예제
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import kotlinx.coroutines.*

suspend fun fetchUrl(client: HttpClient, url: String): String = client.get(url).bodyAsText()

fun main() = runBlocking {
    val client = HttpClient(CIO)
    val urls = listOf("https://httpbin.org/get", "https://httpbin.org/uuid")
    val results = urls.map { url ->
        async { fetchUrl(client, url) }
    }.awaitAll()
    println(results)
    client.close()
}

여러 API를 동시에 호출하고 결과를 모으고 싶을 때, launch 대신 async+awaitAll 패턴을 사용하면 효율적입니다.

2. Spring WebClient를 활용한 코루틴 예제 (실전 병렬 호출, 에러 처리 포함)

Spring WebClient는 리액티브 기반이지만, 코루틴과 함께 사용하면 매우 직관적이고 동기식 코드처럼 작성할 수 있습니다. 아래 예제는 여러 API를 병렬로 호출하고, 결과를 모아 집계하며, 에러도 안전하게 처리하는 실전 패턴입니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.reactor.awaitSingle
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono

suspend fun fetchWithWebClient(client: WebClient, url: String): String =
    client.get().uri(url).retrieve().bodyToMono<String>().awaitSingle()

fun main() = runBlocking {
    val client = WebClient.create()
    val urls = listOf("https://httpbin.org/get", "https://httpbin.org/uuid")
    val results = urls.map { url ->
        async {
            try {
                fetchWithWebClient(client, url)
            } catch (e: Exception) {
                "Error: ${e.message}"
            }
        }
    }.awaitAll()
    println(results)
}
  • 실전 팁:
    • async로 여러 요청을 동시에 실행하면, 느린 API가 있어도 전체 응답 속도를 단축할 수 있습니다.
    • 각 요청마다 try-catch로 감싸면, 일부 요청이 실패해도 전체 결과를 안전하게 집계할 수 있습니다.
    • WebClient는 코루틴과 결합 시 awaitSingle()을 사용하면 리액티브 스트림을 자연스럽게 코루틴으로 변환할 수 있습니다.

WebClient는 Spring Boot 2.0 이상에서 기본 제공되며, build.gradle에 implementation("org.springframework.boot:spring-boot-starter-webflux") 추가가 필요합니다.


2. CoroutineScope

  • 원하는 생명주기를 직접 관리할 수 있는 스코프
  • 클래스나 특정 객체의 생명주기에 맞춰 코루틴을 관리할 때 유용합니다.
class MyRepository : CoroutineScope by CoroutineScope(Dispatchers.IO) {
    fun fetchData() = launch {
        // 네트워크 작업 등
    }
    fun clear() {
        cancel() // 스코프 종료 시 모든 코루틴 취소됨
    }
}

언제 사용? 커스텀 객체, 싱글톤 등에서 명확한 생명주기 관리가 필요할 때


3. MainScope

  • UI 스레드(MainThread)를 기본 디스패처로 사용하는 스코프
  • 안드로이드/데스크톱 UI 등에서 자주 사용
class MainViewModel : MainScope() {
    fun loadData() = launch {
        // UI 업데이트
    }
    override fun onCleared() {
        cancel() // ViewModel이 파괴되면 코루틴도 취소됨
    }
}

언제 사용? UI 컨트롤러(ViewModel, Activity 등)에서 메인스레드 작업이 필요할 때


4. viewModelScope (Android)

  • 안드로이드 아키텍처 컴포넌트에서 제공
  • ViewModel의 생명주기와 자동으로 연동됩니다.
class MyViewModel : ViewModel() {
    fun getUser() {
        viewModelScope.launch {
            // ViewModel이 살아있는 동안만 실행
        }
    }
}

언제 사용? ViewModel에서 비동기 작업을 안전하게 처리할 때 (메모리 누수 방지)


5. lifecycleScope (Android)

  • Activity, Fragment의 생명주기와 연동되는 스코프
  • 화면이 사라지면 코루틴도 자동으로 종료됩니다.
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            // Activity가 살아있는 동안만 실행됩니다.
        }
    }
}

언제 사용? 화면 단위에서 네트워크 요청, 애니메이션 등 일시적 작업에 적합합니다.


async란? launch와의 차이와 활용법

  • async는 동시에 여러 작업을 실행하고, 그 결과를 나중에 받을 수 있는 코루틴 빌더입니다.
  • 반환값이 없는 launch와 달리, async는 Deferred<T>를 반환하며, await()로 결과를 얻을 수 있습니다.
  • 여러 비동기 작업을 동시에 실행하고, 모든 결과가 모일 때까지 기다릴 때 매우 유용합니다.
import kotlinx.coroutines.*

fun main() = runBlocking {
    val one = async { delay(1000); "첫 번째 결과" }
    val two = async { delay(500); "두 번째 결과" }
    println("모든 작업 시작!")
    // awaitAll을 사용하면 두 작업이 끝날 때까지 기다렸다가 결과를 모읍니다.
    val results = awaitAll(one, two)
    println(results) // [첫 번째 결과, 두 번째 결과]
}

launch는 단순히 작업을 시작할 때, async는 결과가 필요한 동시 작업에 사용하세요.


await 종류와 차이점

코루틴에서 비동기 결과를 기다릴 때는 다양한 await 함수가 있습니다. 상황에 따라 적절한 await 함수를 사용하면 코드를 더 직관적이고 효율적으로 만들 수 있습니다.

  • Deferred.await()
    • 하나의 async 결과(Deferred)를 기다릴 때 사용합니다.
    • 예시:
      val job = async { "결과" }
      val result = job.await() // 결과: "결과"
    
  • awaitAll(vararg Deferred)
    • 여러 개의 Deferred를 동시에 기다릴 때 사용합니다.
    • 모든 결과가 모일 때까지 기다렸다가, 결과 리스트를 반환합니다.
    • 예시:
      val d1 = async { 1 }
      val d2 = async { 2 }
      val results = awaitAll(d1, d2) // 결과: [1, 2]
    
  • **List<Deferred>.awaitAll()**
    • Deferred 리스트에서 바로 awaitAll을 호출할 수 있습니다.
    • 예시:
      val jobs = listOf(async { "A" }, async { "B" })
      val results = jobs.awaitAll() // 결과: ["A", "B"]
    
  • awaitSingle()
    • 리액티브 스트림(Mono, Flux 등)을 코루틴에서 기다릴 때 사용합니다.
    • 주로 Spring WebClient, Reactor 등에서 코루틴과 연동할 때 사용합니다.
    • 예시:
      val mono = webClient.get().uri(url).retrieve().bodyToMono<String>()
      val result = mono.awaitSingle() // 결과: HTTP 응답 본문
    

정리: 단일 결과는 await, 여러 결과는 awaitAll, 리액티브 타입은 awaitSingle을 사용하세요.


상황별 추천 사용법 & 실수 방지 팁

  • GlobalScope: 정말 특별한 경우 외에는 사용하지 말 것
  • CoroutineScope: 커스텀 객체, 싱글톤, 라이브러리 등에서 명확한 스코프 관리가 필요할 때 직접 생성해서 사용
  • MainScope: UI 컨트롤러에서 메인스레드 작업을 할 때 사용
  • viewModelScope, lifecycleScope: 안드로이드 앱이라면 반드시 적극 활용 (메모리 누수 방지)
  • 항상 스코프의 생명주기와 코루틴의 취소 정책을 고려하세요.
// 잘못된 예시: Activity가 사라져도 코루틴이 살아있는 경우
GlobalScope.launch {
    // Activity가 이미 파괴됐는데도 실행됨 (메모리 누수 위험)
}

// 올바른 예시: lifecycleScope를 사용
lifecycleScope.launch {
    // Activity 종료 시 자동으로 취소됩니다.
}

Kotlin의 Deferred 클래스란?

Kotlin에서 Deferred<T> 클래스는 비동기 작업의 결과를 미래에 제공하는 일종의 Job입니다. 즉, 코루틴 내에서 실행되는 비동기 연산의 완료와 그 결과값을 나중에 받을 수 있도록 해주는 역할을 합니다. DeferredJob의 하위 타입으로, 취소·완료·에러 처리뿐 아니라, await()를 통해 결과값을 받아올 수 있다는 점이 특징입니다.

  • Job: 단순히 작업의 완료/취소만 관리
  • Deferred: 작업의 완료/취소 + 결과값 반환

이러한 특성 때문에, 여러 비동기 작업을 동시에 실행하고 각각의 결과를 모아야 할 때, 혹은 비동기 작업의 결과값이 중요한 상황에서 Deferred를 자주 사용합니다.

실전 패턴: Deferred(디퍼드) 패턴이란?

비동기 작업을 다루다 보면, 단순히 실행만 하는 것이 아니라 결과를 나중에 받아야 하거나, 외부에서 비동기 작업의 완료를 직접 통제해야 할 때가 있습니다. 이럴 때 자주 사용하는 것이 바로 Deferred 패턴입니다.

Kotlin의 Deferred

Kotlin 코루틴에서 Deferred<T>는 미래에 결과를 제공하는 비동기 작업을 표현합니다. Job과 달리 결과값을 반환할 수 있으며, await()로 값을 받을 수 있습니다.

val deferred: Deferred<Int> = CoroutineScope(Dispatchers.IO).async {
    // 오래 걸리는 작업
    42
}

runBlocking {
    val result = deferred.await() // 42
    println(result)
}
JavaScript의 Deferred

JavaScript에서는 표준 Promise가 있지만, jQuery 등에서는 외부에서 resolve/reject를 직접 제어할 수 있는 Deferred 패턴이 널리 쓰였습니다.

function asyncTask() {
  const deferred = {};
  deferred.promise = new Promise((resolve, reject) => {
    deferred.resolve = resolve;
    deferred.reject = reject;
  });

  // 비동기 작업 예시
  setTimeout(() => {
    deferred.resolve('작업 완료!');
  }, 1000);

  return deferred;
}

const d = asyncTask();
d.promise.then(result => {
  console.log(result); // '작업 완료!'
});
Deferred vs Promise
  • Promise는 생성 시점에 resolve/reject를 외부에서 직접 제어할 수 없습니다.
  • Deferred는 외부에서 resolve/reject를 직접 호출할 수 있도록 만든 패턴입니다.
  • 최근에는 표준 Promise만 사용하는 것이 권장되지만, 복잡한 비동기 흐름 제어나 레거시 코드에서는 Deferred가 여전히 활용됩니다.
실무 활용 팁
  • Kotlin에서는 Deferred를 통해 비동기 작업의 결과를 안전하게 다룰 수 있고, 여러 코루틴의 결과를 awaitAll 등으로 한 번에 취합할 수 있습니다.
  • JS에서는 Deferred 패턴이 남아있는 레거시 코드를 마주할 수 있으니, 구조와 동작 원리를 이해해두면 좋습니다.

참고 레퍼런스


코루틴 스코프를 올바르게 사용하면 앱의 안정성과 유지보수성이 극적으로 향상됩니다. 항상 스코프의 생명주기를 의식하고, 상황에 맞는 스코프를 선택하세요!