안녕하세요. 최근 Android 앱 개발에 Kotlin 코루틴을 도입하여 학습하고 있습니다.
여러 예제를 찾아보다 보니, GlobalScope.launch를 사용하여 백그라운드 작업을 시작하는 코드를 종종 보게 됩니다. 사용법이 매우 간단해서 처음에는 편리하다고 생각했습니다.
// 예시: ViewModel에서 GlobalScope 사용
class MyViewModel : ViewModel() {
fun fetchDataFromServer() {
// 이렇게 사용하면 일단 동작은 하는 것 같습니다.
GlobalScope.launch {
try {
val result = someApi.getData()
// UI 업데이트가 필요하다면?
withContext(Dispatchers.Main) {
// ...
}
} catch (e: Exception) {
// 예외 처리
}
}
}
}
하지만 코루틴 공식 문서나 많은 전문가들의 블로그에서는 **GlobalScope 사용을 적극적으로 피해야 한다(Avoid GlobalScope)**고 경고하고 있습니다.
제가 궁금한 점은, 왜 사용하면 안 되는지에 대한 구체적이고 실질적인 이유입니다.
GlobalScope를 사용하면 왜 메모리 누수가 발생할 수 있나요? ViewModel이 파괴되어도 GlobalScope에서 실행된 코루틴은 계속 살아있기 때문이라고 막연하게는 알고 있는데, 이 과정이 내부적으로 어떻게 동작하는지 궁금합니다.GlobalScope로 시작된 작업을 어떻게 안정적으로 취소할 수 있나요? Job을 따로 관리해야 하는 것 같은데, 매우 번거로워 보입니다.GlobalScope를 사용하지 말아야 하는 이유의 핵심이라고 들었습니다. 구조화된 동시성이란 정확히 무엇이며, 이것이 개발자에게 주는 이점은 무엇인가요?이러한 문제들을 해결하기 위한 모범 사례(Best Practice), 예를 들어 Android의 viewModelScope나 lifecycleScope를 사용하는 방법에 대한 명확한 코드 예시와 함께 설명해주시면 감사하겠습니다. 서버 사이드(예: Ktor) 환경에서는 어떤 식으로 CoroutineScope를 관리해야 하는지도 함께 알려주시면 더욱 좋겠습니다.
하고 답변을 작성해 보세요!
훌륭한 질문입니다. 많은 Kotlin 개발자들이 코루틴을 처음 배울 때 GlobalScope와 관련하여 비슷한 궁금증을 가집니다. 결론부터 말씀드리면, 질문자님께서 언급하신 구조화된 동시성(Structured Concurrency) 원칙을 위반하기 때문에 GlobalScope 사용을 지양해야 합니다.
GlobalScope는 애플리케이션의 전체 생명주기에 연결된 최상위 스코프입니다. 여기서 실행된 코루틴은 애플리케이션 프로세스가 살아있는 한 계속 실행됩니다. 이것이 다음과 같은 심각한 문제를 야기합니다.
제어되지 않는 생명주기 및 메모리 누수: 안드로이드의 Activity나 ViewModel과 같은 컴포넌트는 수명 주기가 있습니다. ViewModel이 onCleared()를 통해 파괴되었음에도 불구하고, 그 안에서 GlobalScope.launch로 시작된 코루틴은 여전히 살아남아 네트워크 요청을 계속하거나 결과를 처리하려고 시도합니다. 이때 이미 파괴된 View나 다른 리소스를 참조하고 있다면 전형적인 메모리 누수가 발생합니다.
취소의 어려움: GlobalScope로 실행된 작업들은 서로 아무런 관계가 없는 개별적인 작업 단위입니다. 따라서 특정 화면을 벗어날 때 관련 작업들만 골라서 한 번에 취소하는 것이 매우 어렵습니다. 모든 Job 객체를 수동으로 추적하고 관리해야만 가능하며, 이는 코드 복잡도를 높이고 실수를 유발하기 쉽습니다.
예외 처리의 분산: GlobalScope 내에서 발생한 예외는 해당 코루틴을 종료시킬 뿐, 부모 코루틴이나 다른 형제 코루틴에게 전파되지 않습니다. 사실상 GlobalScope는 부모가 없으므로, 처리되지 않은 예외는 기본 핸들러(보통 스레드의 uncaughtExceptionHandler)로 전달되어 추적하기 어려워집니다.
구조화된 동시성은 코루틴이 특정 생명주기를 가진 스코프 내에서 실행되도록 강제하는 프로그래밍 모델입니다. 마치 if-else나 try-catch 블록처럼, 코드 블록을 벗어나면 그 안에서 실행된 모든 작업이 완료되거나 취소되는 것을 보장합니다.
launch, async)을 실행하면 부모-자식 관계가 형성됩니다. 부모 스코프/코루틴이 취소되면 모든 자식 코루틴도 재귀적으로 취소됩니다. 또한 부모는 모든 자식이 완료될 때까지 기다립니다 (예외적인 경우 제외).viewModelScopeAndroid Jetpack 라이브러리는 이러한 구조화된 동시성을 쉽게 구현할 수 있도록 ViewModel을 위한 viewModelScope를 제공합니다.
class MyViewModel : ViewModel() {
fun fetchDataFromServer() {
// viewModelScope는 ViewModel의 생명주기와 연결됩니다.
viewModelScope.launch {
// 이 코루틴은 ViewModel이 clear될 때 자동으로 취소됩니다.
try {
val result = withContext(Dispatchers.IO) { // 네트워크 작업은 I/O 스레드에서
someApi.getData()
}
// UI 업데이트는 Main 스레드에서
updateUi(result)
} catch (e: CancellationException) {
// 취소되었을 때의 로깅 (선택 사항)
Log.i("MyViewModel", "Fetch data cancelled")
} catch (e: Exception) {
// 그 외 에러 처리
handleError(e)
}
}
}
override fun onCleared() {
super.onCleared()
// 이 시점에 viewModelScope가 취소되고, 위의 launch 블록도 중단됩니다.
}
}
이처럼 viewModelScope를 사용하면 메모리 누수나 리소스 낭비 걱정 없이 안전하게 비동기 작업을 처리할 수 있습니다. 이것이 코루틴이 제공하는 핵심적인 강력함입니다.
앞선 답변에서 구조화된 동시성에 대해 잘 설명해주셨네요. 저는 GlobalScope의 의도된 사용 사례와 서버 환경에서의 접근법에 대해 조금 더 보충해 드리고자 합니다.
GlobalScope는 언제 사용해야 할까요?공식 문서에서는 GlobalScope를 "delicate" API로 규정하며 최후의 수단으로만 사용할 것을 권장합니다. 그렇다면 그 최후의 수단이란 언제일까요?
바로 애플리케이션 전체의 생명주기와 동일하게 동작해야 하는 최상위 백그라운드 프로세스를 실행할 때입니다. 예를 들어, 애플리케이션이 실행되는 동안 계속해서 특정 로그를 원격 서버로 배치 전송하거나, 캐시를 정리하는 등의 데몬(daemon)과 같은 작업을 생각할 수 있습니다. 이러한 작업은 특정 화면이나 세션에 종속되지 않아야 합니다.
하지만 이 경우에도 직접 GlobalScope를 사용하기보다는, 의존성 주입(Dependency Injection)을 통해 애플리케이션 레벨의 커스텀 CoroutineScope를 만들어 사용하는 것이 테스트와 관리 측면에서 더 좋습니다.
// Application 클래스나 DI 컨테이너에서 생성
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// 이 스코프는 앱이 종료될 때만 수동으로 취소하거나, 그냥 프로세스와 함께 종료되도록 둡니다.
SupervisorJob을 사용하면 이 스코프의 자식 코루틴 중 하나가 실패해도 다른 자식들이나 스코프 자체가 취소되지 않아 독립적인 작업들을 관리하기에 용이합니다.
Ktor와 같은 최신 코루틴 기반 서버 프레임워크는 이미 구조화된 동시성을 프레임워크 레벨에서 완벽하게 지원합니다.
Application 객체 자체를 CoroutineScope로 제공합니다. GlobalScope 대신 이것을 사용하여 애플리케이션 전역적인 작업을 시작할 수 있습니다.CoroutineScope 내에서 처리됩니다. 따라서 라우팅 핸들러 내에서 launch를 호출하면, 그 코루틴은 해당 요청의 생명주기에 묶이게 됩니다. 응답이 전송되거나 커넥션이 끊어지면 해당 스코프가 취소되고, 그 안에서 실행되던 모든 작업(예: 데이터베이스 쿼리, 외부 API 호출)도 함께 취소됩니다.// Ktor 라우팅 예시
fun Route.customerRoutes() {
get("/customer/{id}") { // `this`는 Call-scoped CoroutineScope
val customerId = call.parameters["id"]
launch { // 이 launch는 요청이 끝나면 자동으로 취소됨
logAnalytics(customerId)
}
val customer = db.findCustomerById(customerId)
call.respond(customer)
}
}
이처럼, 잘 설계된 프레임워크와 라이브러리는 개발자가 GlobalScope에 의존할 필요 없이, 적절한 생명주기를 가진 스코프를 자연스럽게 사용할 수 있도록 환경을 제공합니다.
결론: GlobalScope는 구조화된 동시성의 이점을 모두 포기하는 것과 같습니다. 항상 가장 좁은 범위의 생명주기를 가진 스코프를 선택하여 코루틴을 실행하는 습관을 들이는 것이 매우 중요합니다.