코루틴 공식 가이드 자세히 읽기 — Part 5— Dive 1

콜백, 리액티브 그리고 코루틴

Myungpyo Shim
13 min readApr 7, 2020

Korean [English]

(목차로 돌아가기)

개발자로서 우리는 우리를 더욱 생산성 있게 만들어주고 편안하게 해주는 새로운 기술들을 사용하는 것을 기꺼이 즐깁니다. 어떤 새로운 기술을 학습할 때 그 기술의 개념이 쉽게 와 닿지 않으면 먼저 기존에 알고 있는 유사한 기술이 없는지 떠올려보고, 있다면 새로운 기술과의 비교 / 대조를 통해 새로운 것의 개념을 구체화 하여 이해 하곤 합니다. 이러한 방식의 학습이 가능한 이유는 대부분의 새로운 기술이 “무” 에서 나오는 것이 아니고, 기존 기술의 약점을 보완하고 강점을 강화하는 방향으로 발전되어 나오기 때문입니다.

이번에 살펴 볼 코루틴도 그동안 우리가 비동기 처리를 위해 작성해 왔던 코딩 스타일을 보다 직관적이고 안전하게 작성할 수 있도록 돕기 위해 고안되었습니다.

이번 파트에서는 “사용자 설정을 동기화” 한다는 아래와 같은 가상의 시나리오를 설정하고 콜백 방식, Rx 방식, 코루틴 방식으로 구현해보며 구현을 비교해보고, 다음 파트 에서는 이번 파트의 코루틴 예제를 가지고 내부 구현을 살펴보며 코루틴의 중단함수, 잡(Job) 같은 요소들의 역할과 관계에 대해서 살펴보겠습니다.

시나리오
- 안드로이드 앱에서 사용자 설정을 동기화 하는 기능을 만들고자 함
- 사용자 설정은 로컬(DB)에 저장되며, 필요한 순간마다 원격의 설정을 가져와서 업데이트 함
- 로컬에는 원격에 없는 로컬만의 설정이 추가될 수 있지만 원격과 동기화되는 설정은 로컬에서 변경할 수 없음. (단방향 동기화)

시나리오의 3번째 항목은 예제를 단순하게 만들기 위한 내용입니다. 그리고 다음과 같은 레이어를 두어 구현해 보겠습니다.

main 함수 : 실행중인 UI 스레드를 나타냅니다.
ViewModel : Android ViewModel 을 상속하진 않지만 Framework 없이 구현하기 위해 간단 버전을 만들어 사용합니다.
UserSettingRepository : DataSource 들을 이용하여 추상화된 기능을 수행합니다.
LocalUserSettingDataSource : 디바이스 로컬 데이터 소스를 나타냅니다.
RemoteUserSEttingDataSource : 원격 데이터 소스를 나타냅니다.

Callback 을 이용한 구현

우선 Callback 을 이용한 구현을 해보겠습니다.

전체 코드는 여기에서 확인할 수 있습니다.

우선 가장 깊은 레이어 인 DataSource 레이어를 살펴보겠습니다.

Local 과 Remote DataSource 는 UserSettingDataSource 인터페이스에 정의 된 메서드들을 구현하며, 여기서는 단지 테스트를 위해 일정 시간 이후에 성공 콜백을 전달하는 동작만 수행하도록 구현되었습니다.

이 DataSource 들을 사용하는 Repository 레이어를 살펴보겠습니다.

Repository 레이어는 외부로 노출하는 기능들에 대한 실행 스레드 선택 등의 주체가 됩니다. 이번 구현에서는 I/O bound task 들이라는 점을 고려하여 cachedThreadPool 을 이용하도록 하였습니다.

syncUserSetting 함수는 userId 와 수행 결과를 받을 Callback 을 인자로 전달 받아 다음의 과정을 수행합니다.

  1. 원격 사용자 설정 로딩
  2. 로컬 사용자 설정 로딩
  3. 로컬 사용자 업데이트 후 저장

위 과정들은 모두 Callback 을 통해 수행됩니다.

또 한가지 눈여겨 보아야 할 부분은 `ExecutorService.submit()` 의 결과인 Future 오브젝트를 자체 정의한 Disposable 형태로 변환하여 반환하고 있다는 점입니다. 이를 통해 UI 가 종료되면(ViewModel.onClear()) 작업을 취소하는데 사용합니다.

이 Repository 를 사용하는 ViewModel 코드를 살펴봅시다.

ViewModel 은 syncUserSetting() 함수가 불리면 Repository 를 통해 사용자 설정 동기화를 요청하고 그 결과로 받은 disposable 을 캐시하고 있다가 UI 가 종료되면(onClear()) 취소하는 동작을 수행합니다.

아래는 실행 결과 입니다 (괄호는 스레드 이름을 나타냅니다).

[main] SampleViewModel 
: syncUserSetting() — start
[Thread-0]UserSettingRepository
: syncUserSetting() — Fetch from remote data source
[Thread-0] UserSettingRepository
: syncUserSetting() — Load from local data source
[Thread-0] UserSettingRepository
: syncUserSetting() — Update to local data source
[Thread-0] UserSettingRepository
: syncUserSetting() — Success
[Thread-0] SampleViewModel : syncUserSetting()
: success : UserSetting(userId=TestUser#1, primaryColor=FFFF0000, secondaryColor=FF0000FF)
[main] SampleViewModel
: onClear()

Rx 를 이용한 구현

이번에는 위 콜백 구현을 Rx 를 이용한 구현으로 변경해 보겠습니다.

전체 코드는 여기에서 확인할 수 있습니다.

이번에도 가장 깊은 레이어 인 DataSource 레이어 부터 살펴보겠습니다.

DataSource 에서 제공하는 API 가 이제 Callback 을 인자로 받지 않고, Rx Single 을 반환하고 있는 것을 확인할 수 있습니다.

이제 이를 이용하는 Repository 코드를 보겠습니다.

Repository 에서는 로컬과 원격 데이터 소스의 사용자 설정 데이터를 가져와 업데이트 된 로컬 데이터를 만들고(zip) 이를 로컬 데이터 소스에 업데이트 합니다.

다음은 이를 사용하는 ViewModel 코드를 살펴보겠습니다.

작업이 수행 될 스레드 선택을 ViewModel 에서 하고 있으며 UI 가 종료 되었을 경우 취소를 위해 작업 요청의 결과로 받은 Disposible 을 CompositeDisposable에 등록함으로써 ViewModel onClear() 시 취소 될 수 있도록 하였습니다.

아래는 실행 결과 입니다 (스레드 이름을 로그 앞부분에 붙였습니다).

main SampleViewModel 
: syncUserSetting() - start
RxCachedThreadScheduler-1 RemoteUserSettingDataSource
: loadUserSetting()
RxCachedThreadScheduler-1 LocalUserSettingDataSource
: loadUserSetting()
RxComputationThreadPool-1 LocalUserSettingDataSource
: updateUserSetting()
main SampleViewModel
: syncUserSetting() : success : UserSetting(userId=TestUser#1, primaryColor=FFFF0000, secondaryColor=FF0000FF)
main SampleViewModel
: onClear()

Callback 방식과 Rx 방식 비교

다소 투박하게 만들어지긴 했지만 Callback 방식으로 작성 된 코드를 Rx 방식 기준으로 비교해 보겠습니다.

  • 👍 Rx 방식에서는 비동기 작업의 수행 스레드와 결과 처리 스레드 지정을 작업 요청 코드에서 손쉽게 설정할 수 있는 기능이 제공 됩니다.
    (물론 Callback 방식에서도 요청하는 코드에서 Executor 를 사용하고, 결과는 요청 스레드로 Post 하는 코드를 작성하여 만들 수 있겠지만 추가 구현은 비교에서 제외하도록 하겠습니다.)
  • 👍 Rx 방식에서는 원하는 데이터를 만들어 내기까지의 일련의 작업 간의 관계 및 스레드 지정이 Chaining 방식으로 작성 가능하여 가독성이 더 좋아집니다.
    (😰 물론 Rx 는 다양한 연산자를 사용 가능하며 이러한 연산자들의 역할과 이들이 전달 받는 함수 파라미터들에 대한 이해가 먼저 이루어지지 않으면 Rx 가독성은 장점이 될 수 없습니다. 반면 Callback 은 아주 직관적이지요, Callback-hell 을 만들긴 하지만요.😈)
  • 👍 Rx 방식에서는 체인에 취소가 발생하면 각각의 연산자에게 취소 이벤트에 대응할지 그렇지 못할지 달려있긴 하지만 대부분의 표준 연산자들은 취소 이벤트에 적절하게 대응하도록 되어있습니다.
    (Callback 방식에서는 UI 종료에 대응하기 위해서 현재 작업이 취소 되었는지를 구현하는 사람이 수시로 Thread.interrupted() 를 통해 확인하여 취소 동작을 구현하지만, Rx 방식에서는 Chain 에서 스트림 이동 시 마다 자동으로 취소 여부를 체크하고 취소 되었으면 체인을 종료합니다. 물론 콜백방식처럼 취소가 가능한 구간마다 isDisposed() 체크를 통해 더 취소 요청에 액티브하게 응답할 수도 있습니다.)
  • 👍 예외 상황에 대해서 Callback 방식은 모든 예외를 에러 콜백으로 전달 할 수 있도록 try-catch { } 블록을 통해 처리해야 하지만 Rx 구현체들은 이를 기본 제공하여 onError 로 전달합니다.
  • 😰 Rx 체인의 디버깅은 기존엔 IDE Break point 가 동작하지 않는 등 어려움이 많았으나 이제 IDE 의 지원으로 좀 더 수월해 지긴 했습니다만… 여전이 문제가 되는 구간을 찾으려고 doOnNext(Log…) 를 체인 중간 중간 삽입하는 경우가 많습니다.

Coroutine 을 이용한 구현

자, 이제 우리 관심사인 코루틴을 이용한 구현으로 변경해 보겠습니다.

전체 코드는 여기에서 확인할 수 있습니다.

이번에도 제일 깊은 레이어인 DataSource 부터 살펴보겠습니다.

이번엔 DataSource 에서 제공하는 API 가 중단함수로 작성되어 콜백 파라미터나 Rx데이터 타입 Wrapping 없이 원하는 타입을 바로 반환하고 있으며, DataSource 구현 내부도 일반 함수 호출과 크게 다르지 않습니다.

Repository 코드를 살펴보면,

syncUserSetting 함수가 중단함으로 선언되면서 내부 코드가 정말 순차적으로 바뀐 것을 볼 수 있습니다. (async { } 를 이용하여 local, remote 를 동시 요청 할 수 있지만 이번엔 순차적으로 수행되도록 작성하였습니다.)

이를 사용하는 ViewModel 코드를 살펴보겠습니다.

코드를 보면 ViewModel 이 CoroutineScope 를 구현하고 있습니다. Android 에서 ViewModel 은 UI 의 라이프 사이클에 바운드 되어 UI 가 내려갈 시 onClear 함수를 호출하여 적절한 리소스 해제 시점을 제공하는 오브젝트 입니다.
(onClear() 에서 현재 scope 에서 수행되는 작업의 취소 같은 부분은 사실 Android ViewModelScope 를 사용하면 코드를 줄일 수 있는 부분입니다.)

ViewModel 에서 syncUserSetting 호출 부를 보면 IO 디스패처가 설정 된 스코프 안에서 Repository 의 syncUserSetting 함수를 호출하여 IO 스레드 풀을 통해 작업이 수행되고 그 결과는 다시 현재 스레드에서 받을 수 있도록 처리되어 있습니다.
(runCatching 은 코틀린에서 제공하는 try-catch 지원 확장 함수 인데, 전달 된 블록의 코드를 수행하고 결과를 Result<T> 형으로 반환합니다. 에러 발생 시 Result 는 에러 값을 갖습니다.)

❗️알아두기
저는 어떤 중단 함수가 실행되어야 할 디스패처는 해당 중단 함수가 정의 된 레이어에서 정의하곤 하는데, 이렇게 하면 호출하는 쪽에서는 호출 할 중단 함수가 어떤 스레드에서 실행되어야 하는지에 대한 고민 없이 단순히 코루틴 안에서 중단 함수의 호출만 수행할 수 있게 됩니다.

아래는 실행 결과 입니다 (스레드 이름 및 코루틴 이름이 각 로그 앞에 추가되어 있습니다).

main @coroutine#1 SampleViewModel 
: syncUserSetting()
main @coroutine#2 SampleViewModel
: syncUserSetting() start
DefaultDispatcher-worker-1 @coroutine#2 UserSettingRepository
: syncUserSetting() - Fetch from remote data source
DefaultDispatcher-worker-1 @coroutine#2 RemoteUserSettingDataSource
: loadUserSetting()
DefaultDispatcher-worker-1 @coroutine#2 UserSettingRepository
: syncUserSetting() - Load from local data source
DefaultDispatcher-worker-1 @coroutine#2 LocalUserSettingDataSource
: loadUserSetting()
DefaultDispatcher-worker-3 @coroutine#2 UserSettingRepository
: syncUserSetting() - Sync and Store to local data source
DefaultDispatcher-worker-3 @coroutine#2 LocalUserSettingDataSource
: updateUserSetting()
main @coroutine#2 SampleViewModel : syncUserSetting() : result
: Success(UserSetting(userId=TestUser#1, primaryColor=FFFF0000, secondaryColor=FF0000FF))
main @coroutine#1 SampleViewModel
: onClear()

실행 결과를 보면 사용자의 설정 정보 동기화 로직은 스레드 풀의 워커 스레드를 이용하여 수행 되었음을 확인할 수 있습니다. 또한 눈여겨 보아야 할 부분은 main 스레드에서 수행되던 coroutine#2 가 디스패처를 통해 워커 스레드에서 수행되고 나서 그 결과는 다시 main 스레드로 디스패치 되어 로그로 출력 되었다는 점 입니다.

Callback 및 Rx 방식과 코루틴 방식 비교

  • 👍 코루틴 역시 Rx 처럼 비동기 작업의 수행 스레드와 결과 처리 스레드 지정을 작업 요청 코드에서 손쉽게 설정할 수 있는 기능이 제공 됩니다.
  • 👍 코루틴에서 원하는 결과 데이터를 만들어 내기까지 일련의 작업(함수)들 간의 호출 방식 및 실행 스레드 지정은 일반적인 동기 함수에서와 동일합니다. 이는 중단 함수가 제공하는 컴파일 타임 CPS 스타일 변환을 통해 가능합니다. 결과적으로 가독성을 얻으면서도 새로운 프레임워크로 인한 추가적인 학습 비용을 줄일 수 있습니다.
  • 👍 코루틴 역시 요청 작업의 취소 기능이 제공됩니다. 최상위 스코프의 Job 이 취소되면 이 취소는 자식 Job 들로 전파되어 다음 중단 함수 호출 시 작업이 취소되었다면 CancellationException 으로 종료 됩니다(Rx Chain 에서 다음 수행할 작업에서 취소 여부를 확인하여 종료되었던 것과 동일). 물론 커스텀 중단 함수를 작성하여 코루틴의 isActive 상태 체크나 yield() 함수 호출로 취소에 적극 대응하도록 작성 할 수도 있습니다.
  • 👍 예외 역시 일반적인 try-catch 방식으로 현재 함수 및 자식 중단함수의 예외 상황에 대한 처리가 가능합니다. 한 가지 유의해야 할 점은 예외 처리가 두가지 방식으로 분류되는데 대표적으로 launch 방식과 async 방식이 있으며 이에 관련한 내용에 이해가 필요합니다 😰.
  • 👍 디버깅 역시 처음엔 조금 미흡했으나 이제 IDE 의 도움으로 코루틴의 Break point 가 정상 동작합니다.

다음은 여기서 작성한 예제를 기준으로 코루틴의 내부를 들여야 보겠습니다.

끝.

--

--