[Coroutines] 코루틴 활용하기 in 안드로이드

빙티·2025년 2월 16일

Kotlin Coroutines

목록 보기
2/2
post-thumbnail

들어가며

요즘은 Retrofit이나 Room 등 많은 안드로이드 라이브러리에서 내부적으로 코루틴을 지원합니다.
때문에 코루틴을 왜 사용해야 하는지, 어떻게 작동하는지 잘 몰라도 손쉽게 비동기를 구현할 수 있습니다.
사실 제 이야기였습니다. viewModelScope랑 launch만 무지성 호출하던 시절이 있었거든요.

이번 포스팅에서는 코루틴과 친해지기 위해, 스타카토에서 이미지 비동기 업로드 기능을 구현하며 배운 코루틴 개념들을 정리해보겠습니다.
이미지 비동기 업로드 기능은 아래 사진을 참고하면 쉽게 이해되실 겁니다.




비동기 처리의 중요성

비동기란?
작업을 시작한 후 결과를 기다리지 않고 다른 작업을 수행하다가, 기존 작업이 완료되면 그 결과를 처리하는 방식

안드로이드는 UI 작업을 메인 스레드에서만 수행하는 싱글 스레드 패러다임을 채택하고 있습니다.
이러한 패러다임을 채택한 이유는 UI를 그리는 뷰의 구조적 특성 때문입니다.
뷰들은 계층적인 구조를 가지며 이를 제대로 표시하기 위해서는 각 요소를 그리는 순서가 매우 중요합니다.

만약 메인 스레드 외에 다른 스레드를 뷰를 그리기 위해 사용한다면, 동기화 문제로 예상치 못한 동작이 발생할 수 있습니다.
그래서 안드로이드에서 메인 스레드는 UI 스레드라고도 불리며 일정 주기(약 16ms)마다 UI를 렌더링 합니다.

image

위 사진처럼 메인 스레드가 블록되어 업데이트가 적절한 타이밍에 일어나지 못한다면, 프레임 드랍이 생기며 앱이 사용자와 매끄럽게 상호작용할 수 없어집니다.

또한 blocking 상태가 5초 이상 유지되면 아래처럼 ANR(Application Not Responding) 오류가 발생하며 앱이 종료됩니다.
image

따라서 이런 상황을 막기 위해 네트워크 요청이나 파일 I/O 처럼 오랜 시간이 걸리는 작업을 비동기 처리하는게 중요합니다.


코루틴의 작동 방식


image

루틴이란 ‘특정한 일을 처리하기 위한 일련의 명령’입니다.
서브 루틴은 루틴의 하위에서 실행되는 또 다른 루틴, 즉 함수 내부에서 호출되는 함수를 뜻합니다.

서브 루틴은 하나의 진입점을 가지며 한번 호출되면 끝날 때까지 멈추지 않고 쭉 실행됩니다.
따라서 어떤 루틴에 의해 서브 루틴이 호출되면, 루틴이 실행 되던 스레드가 다른 작업을 할 수 없습니다.


image

반면 코루틴은 일시 중단 가능한 작업 단위로, 특정 지점에서 작업을 멈추고 필요할 때 재개할 수 있습니다.
자신이 작업을 하지 않을 때는 실행을 멈춘 후 다른 코루틴이 스레드를 사용하며 작업할 수 있도록 스레드 사용 권한을 양보합니다.
일반적인 루틴과 달리 코루틴은 여러개의 진입점을 가지는데, '함수가 처음 호출될 때'와 '중단 이후 재개할 때'입니다.


image

코루틴은 실행 정보를 저장하고 전달하기 위해 CPS(continuationPassingStyle) 방식을 사용합니다.

함수를 호출할 때마다 Continuation을 전달한다는 뜻으로, Continuation 객체는 중단 시점마다 현재 상태들을 기억하고 다음에 무슨 일을 해야 할지를 담고 있는 확장된 콜백 역할을 합니다.

  • context : dispatcher, Job 등 CoroutineContext를 저장하는 변수
  • resumeWith() : 실행을 재개하기 위한 메서드



안드로이드에서 코루틴 사용하기

목표 : 코루틴을 이용해 사진 첨부 과정을 비동기 처리해 보자!

이제 제 최근 프로젝트인 스타카토에 적용한 과정을 순서대로 설명 드리겠습니다.

사진 첨부 기능

요구 사항
기록에 여러 장의 사진을 비동기로 첨부할 수 있다.

기능 흐름

  1. 갤러리/카메라의 이미지 URI를 MultiBody.Part로 변환한다.
  2. 서버를 거쳐 S3에 파일을 올리고 응답으로 Url을 받는다.
  3. 응답을 기다리는 동안 로딩 애니메이션을 보여주고, 성공하면 UI를 업데이트 한다.
  4. 업로드 도중 X 버튼이 클릭되면 작업을 취소하고 리스트에서 사진을 삭제한다.
  5. 업로드에 실패한 사진은 실패/재시도 버튼을 보여준다.
업로드 중업로드 완료업로드 에러
2loading3finish1empty

위 기능에 코루틴을 적용한 내용을 관련 개념들과 함께 순서대로 정리해 보겠습니다.


1. CoroutineScope로 코루틴 제어하기

코루틴 스코프는 간단히 말하면 코루틴이 실행되고 관리되는 범위입니다.
따라서 모든 코루틴은 코루틴 스코프의 내부에서 실행되어야 합니다.
kotlinx.coroutines는 코루틴 스코프를 만들기 위한 다양한 함수/객체를 제공합니다.

코루틴 스코프 함수 비교

  • coroutineScope : 부모 코루틴과 구조적으로 연결되며, 하나의 자식이 취소되거나 예외를 던지면 전체 코루틴이 취소된다.
  • supervisorScope : 부모 코루틴과 구조적으로 연결되지만, 하나의 자식이 취소되거나 예외를 던져도 다른 자식에게 전파하지 않는다.

구조화된 동시성

코루틴 스코프는 어떻게 내부 코루틴들의 실행 범위와 생명주기를 관리할까요?

코루틴은 부모-자식 관계의 계층 구조를 형성할 수 있습니다.
부모 코루틴은 자식 코루틴의 실행을 관리하고 제어할 수 있으며, 이를 코루틴의 구조화된 동시성이라고 합니다.

구조화된 동시성 덕분에 우리는 손쉽게 코루틴 간 실행 흐름을 관리하고 일관된 예외·취소 처리를 할 수 있습니다.

구조화된 동시성의 핵심 1 - 실행 흐름 관리

  • 동시성 제어: 특정 스코프(혹은 코루틴)에 속한 여러 코루틴들은 서로 중단과 재개를 반복하며 실행된다.
  • 실행 흐름 제어 : 기본적으로 부모 코루틴은 자식 코루틴이 완료될 때까지 기다린다.
// 두 코루틴이 모두 끝나기 전까지 coroutineScope는 리턴되지 않음
coroutineScope {
    launch { /* 첫 번째 코루틴 */ }
    launch { /* 두 번째 코루틴 */ }
}

구조화된 동시성의 핵심 2 - 예외 및 취소 전파

  • 예외 전파: 자식 코루틴에서 발생한 예외는 부모에게 전파될 수 있으며, 부모의 종류(스코프)에 따라 전체 코루틴이 취소될 수도 있고 일부만 취소될 수도 있다.
  • 부모-자식 관계: 부모 코루틴이 취소되면, 그 하위의 모든 자식 코루틴도 자동으로 취소된다.
// 예외 발생 시 전체 취소되는 경우
coroutineScope {
    launch {
        throw Exception("Error in coroutine") // 1. 예외 발생 → 부모와 모든 자식 취소됨
    }
    launch {
        delay(1000)
        println("이 코루틴은 실행되지 않음.") // 실행되지 않음
    }
}

// 예외 발생해도 다른 자식이 유지되는 경우
supervisorScope {
    launch {
        throw Exception("Error in coroutine") // 1. 예외 발생 → 부모는 유지됨
    }
    launch {
        delay(1000)
        println("이 코루틴은 정상 실행됨.") // 실행됨
    }
}

위 코드에서 coroutineScope의 경우 예외가 부모에게 전파되어 모든 코루틴 취소되지만 supervisorScope의 경우 부모와 다른 자식이 유지됩니다.




👉 적용해보기 : 뷰모델의 생명주기를 따르는 viewModelScope

사진 업로드 로직과 데이터는 AAC ViewModel에서 관리하고 있습니다.

메모리 누수를 방지하기 위해 ViewModel이 활성화된 경우에만 코루틴이 작동하게 해야 하므로, ViewModelScope를 사용해 뷰모델 생명주기를 따르도록 해주었습니다.

ViewModelScope는 ViewModel의 생명주기에 따라 onClear() 시점에 모든 코루틴을 자동으로 취소해주는 코루틴 스코프로 androidx.lifecycle에서 제공합니다.

viewModelScope는 위 코드처럼 커스텀 게터를 가진 확장 프로퍼티로 되어있습니다.

ViewModel에서는 여러 개의 비동기 작업을 수행하는 경우가 많으므로, 개별 코루틴이 독립적으로 실행될 수 있도록 보장해야 합니다.

따라서 내부에서 SupervisorJob을 사용해 하나의 자식 코루틴이 실패해도 다른 자식 코루틴이 영향 받지 않도록 합니다.

이 외에도 androidx.lifecycle은 액티비티나 프래그먼트 등 안드로이드의 lifeCycleAware 컴포넌트에서 사용할 수 있는 lifecycleScope를 제공합니다.




2. 사진 업로드 네트워크 요청을 위한 코루틴 만들기

Coroutine Builder

코루틴을 만들고 시작하는 역할을 하는 함수들로, 코루틴이 수행할 동작을 람다로 정의할 수 있습니다.
코틀린에서 제공하는 주요 코루틴 빌더로는 launch, async 등이 있습니다.

1. launch

  • 값을 반환하지 않는 코루틴을 생성/실행할 수 있습니다.
  • Job을 반환하며, cancel()로 실행 중인 작업을 중단할 수 있습니다.
  • 예외 발생 시 즉시 전파됩니다.
fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

2. async

  • 값을 반환하는 코루틴을 생성/실행할 수 있습니다.
  • Job을 상속하는 Deferred를 반환하며, 이 때 T는 수행 결과 반환되는 값의 타입입니다.
  • await()로 값이 반환될 때까지 기다린 후 결과값을 얻어 낼 수 있습니다.
  • 예외 발생 시 await()를 호출한 곳에서 예외가 던져집니다.
fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

위의 두 빌더로 만들어진 코루틴은 기본적으로 즉시 실행됩니다. (CoroutineStart.DEFAULT)

👉 적용해보기 : 사진마다 launch로 Job을 생성해 관리한다.

갤러리에서 사진을 선택해 가져오면, 해당 사진을 S3 서버에 업로드하고 url을 받아오는 작업을 비동기로 수행합니다.

    private fun createPhotoUploadJob(photo: PhotoUiModel) =
    viewModelScope.launch() {
        imageRepository.convertImageFileToUrl(photo.file)
            .onSuccess { data ->
                updatePhotoWithUrl(photo, data.imageUrl)
            }.onException { state ->
                if (this.isActive) handleException(state)
            }
            .onServerError(::handleServerError)
    }
  • viewModelScope에서 launch로 코루틴을 시작합니다.
  • imageRepository.convertImageFileToUrl에서 서버 요청을 보내고 URL 응답을 기다립니다.
  • 요청 성공 시 updatePhotoWithUrl에서 URL을 저장합니다.
  • 요청 실패 시 관련 메서드로 적절한 조치를 취해줍니다.

요구사항을 살펴보면, 사진은 리스트에서 삭제될 수 있습니다. (자세한 UI는 이전 포스트를 참고해주세요!)
이 때 업로드 요청 도중 사진이 삭제되면 직접 Job을 취소해야 합니다.
따라서 위에서 만든 코루틴 Job들을 Map에서 관리해주었습니다.

// 사진 삭제 시 Job 취소
override fun onDeleteClicked(deletedPhoto: PhotoUiModel) {
    if (photoJobs[deletedPhoto.uri.toString()]?.isActive == true) {
        photoJobs[deletedPhoto.uri.toString()]?.cancel()
    }
}
  • 사진 URI 정보를 Key로, 코루틴 Job을 Value로 photoJobs Map에 저장해 직접 취소할 수 있도록 관리합니다.
// Job 취소·완료 시 Map에서 제거
fun fetchPhotosUrlsByUris(photo: PhotoUiModel) {
    val job = createPhotoUploadJob(photo)
    job.invokeOnCompletion { _ ->
        photoJobs.remove(photo.uri.toString())
    }
    photoJobs[photo.uri.toString()] = job
}
  • invokeOnCompletionjob이 완료되면 성공/실패 여부와 관계없이 URI를 기준으로 해당하는 Job을 photoJobs에서 제거합니다.



3. 스레드 설정하기

Dispatcher

Dispatcher는 코루틴이 실행될 스레드나 스레드 풀을 결정하는 역할을 합니다.
각각은 특정 작업에 최적화되어 있으므로, 이를 고려한 적절한 선택을 통해 성능을 최적화하는 것이 중요합니다.
주요 Dispatcher는 다음과 같습니다.

  1. Dispatchers.Main:
    • UI 스레드에서 코루틴을 실행합니다.
      안드로이드의 경우 UI 업데이트는 항상 Main 스레드에서만 가능하므로, UI 관련 작업은 이 Dispatcher에서 실행해야 합니다.
  2. Dispatchers.IO:
    • 블로킹 작업에 최적화된 스레드 풀에서 코루틴을 실행합니다.
      ex) 파일 입출력, 네트워크 요청, 데이터베이스 작업 등
    • 많은 스레드를 이용해서 비동기 작업을 병렬로 수행할 수 있습니다.
  3. Dispatchers.Default:
    • CPU 집약적인 작업(계산량이 많은 작업)을 처리할 때 사용합니다. ex) 복잡한 알고리즘, 데이터 처리 작업
    • 적절한 스레드 수를 유지하면서 CPU 성능을 최적화합니다.
  4. Dispatchers.Unconfined:
    • 특정 스레드에 구애받지 않아 어떤 스레드로 옮겨갈지 보장되지 않습니다
    • 일반적으로는 잘 사용하지 않으며 테스트 용도나 특수한 상황에서 사용됩니다.

👉 적용해보기 : suspend 키워드로 ApiService 메서드를 IO 스레드에서 처리하자

메서드에 suspend 키워드를 추가하면 Retrofit2가 내부적으로 코루틴을 통해 API 호출을 비동기로 수행합니다.

이미지 전송을 위한 ImageApiService 코드는 아래와 같습니다.

interface ImageApiService {
    @Multipart
    @POST(IMAGE_PATH)
    suspend fun postImage(
        @Part imageFile: MultipartBody.Part,
    ): Response<ImageResponse>
}

루트인 viewModelScopeDispatchers.Main를 이용하지만, 위 suspend fun postImage()의 경우 Retrofit2가 내부적으로 Dispatchers.IO를 사용하도록 처리하고 있기 때문에 별도로 Dispacher를 설정해 줄 필요는 없습니다.




+ 트러블 슈팅 (전파되지 않는 CancellationException)

코루틴 Job이 cancel을 통해 취소되면 상태가 Cancelling으로 바뀌고 중단 가능 지점에서 JobCancellationException 예외를 던집니다.

이 말인 즉슨, 코루틴 내부에 중단 가능 지점이 없다면 cancel을 호출해도 취소되지 않는다는 것입니다.

참고로 CancellationException은 코루틴의 취소에 사용되는 특별한 예외로, 부모 코루틴에게 전파되지 않는다는 특징이 있습니다. (JobCancellationException은 CancellationException의 서브 클래스 입니다.)

원래는 JobCancellationException 예외가 발생해도 따로 처리해주지 않고 무시했기 때문에 문제가 되지 않았습니다.
그러나 네트워크 관련 에러 핸들링을 추가하며, 사진을 S3 서버에 업로드 하기 위해 멀티파트 네트워크 요청을 보내는 과정에서 문제가 발생했습니다.

응답을 기다리는 도중에 리스트에서 사진을 삭제하면 코루틴이 취소되며 JobCancellationException예외가 발생했습니다.
이 때 해당 예외가 네트워크 불안정 Exception으로 잘못 처리되어 부적절한 스낵바가 띄워지는 버그가 있었습니다.

따라서 if (this.isActive) handleException(e, message)처럼 isActive 여부를 검사함으로서 Job 취소 예외를 무시할 수 있었습니다.




마무리

지금까지 코루틴의 구성 요소와 특징, 사용 이유, 동작 방식에 대해 간단히 알아보고
스타카토 프로젝트에서 코루틴을 어떻게 적용했는지 살펴보았습니다.
만약 이 글을 읽고 스타카토 서비스에 관심이 생겼다면 구글 플레이스토어를 찾아 주세요.

코루틴 이녀석 참 헷갈리고 어렵지요...
그치만 계속 공부한다면 언젠간 친구가 될 거라고 믿습니다.

profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글