Kotlin 코루틴 스코프 종류와 활용법: 상황별 실전 예제
코루틴 스코프의 종류와 각각의 특징, 그리고 실제로 어떤 상황에서 어떤 스코프를 사용하면 좋은지 초보자도 이해할 수 있도록 다양한 예제와 함께 정리합니다. 실수하기 쉬운 부분과 실전 패턴, 공식 문서 레퍼런스까지 한 번에 익혀보세요.
들어가며
코틀린에서 비동기 프로그래밍을 안전하게 구현하려면 코루틴
과 더불어 코루틴 스코프
의 이해가 필수입니다. 코루틴 스코프는 코루틴의 생명주기와 취소 정책을 결정하며, 잘못 사용하면 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다. 이 글에서는 대표적인 코루틴 스코프의 종류와 각각의 특징, 그리고 실전에서 어떤 상황에 어떤 스코프를 선택해야 하는지 다양한 예제와 함께 설명합니다.
또한, 실전에서 비동기 흐름을 유연하게 제어할 때 자주 언급되는 Deferred
(디퍼드) 패턴에 대해서도 함께 다룹니다. Kotlin의 Deferred
, JavaScript의 Deferred
/Promise
등 다양한 환경에서의 활용법과 차이점, 그리고 실무에서의 사용 맥락을 예시와 함께 정리합니다.
코루틴 스코프란?
- 코루틴을 실행하고 관리하는 컨텍스트(범위)
- 스코프가 종료되면 해당 스코프에서 실행된 모든 코루틴이 자동으로 취소됨
- 코루틴의 생명주기를 안전하게 관리하는 핵심 도구
대표적인 코루틴 스코프 종류
- GlobalScope
- CoroutineScope
- MainScope
- viewModelScope (Android)
- 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() // 결과: "결과"
- 하나의 async 결과(Deferred
- 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입니다. 즉, 코루틴 내에서 실행되는 비동기 연산의 완료와 그 결과값을 나중에 받을 수 있도록 해주는 역할을 합니다. Deferred
는 Job
의 하위 타입으로, 취소·완료·에러 처리뿐 아니라, 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 패턴이 남아있는 레거시 코드를 마주할 수 있으니, 구조와 동작 원리를 이해해두면 좋습니다.
참고 레퍼런스
코루틴 스코프를 올바르게 사용하면 앱의 안정성과 유지보수성이 극적으로 향상됩니다. 항상 스코프의 생명주기를 의식하고, 상황에 맞는 스코프를 선택하세요!