[인터널] 안드로이드 통신 컴포넌트 동작 매커니즘 ① - AsyncTask , RxAndroid

안드로이드의 통신 컴포넌트에 대한 내부 구조를 최대한 쉽게 정리해 보았습니다. 시리즈의 첫 글인 이번 편에서는 AsyncTask , RxAndroid에 대해 다룹니다.

작성일 2023년 12월 19일

안드로이드의 유명세에 비해 내부의 동작 메커니즘이 잘 공개되어 있지 않아 크래시나 Heap Dump 분석 시 나온 Stack 정보 등을 제대로 해석을 못 하는 경우가 허다합니다.

국내 1위의 프론트엔드 모니터링 솔루션 IMQA (모바일 + Web)을 가진 저희 IMQA는 운영체제 내부에서 발생하는 여러 스택 정보를 제대로 해석하지 못해 어려움을 호소하는 고객분들을 종종 만납니다. 갈증을 100% 해결하기 어렵겠지만, 안드로이드의 통신 컴포넌트에 대한 내부구조를 최대한 쉽게 설명드리고자 합니다.

아이러니하게도 Android는 매우 범용적이지만, 내부는 잘 정리된 문서가 없습니다. 이에 동작 원리와 코드 스택에 해석에 대한 어려움을 가지는 분들을 위해 연재하고자 합니다.

안드로이드 통신의 근본적인 매커니즘을 잘 전달드리기 위해 POSA2(Pattern Oriented Software Architecture) 저자인 Douglas Schmidt 교수님이 전달한 내용과 저의 경험을 담아 안드로이드 통신 컴포넌트들의 차이점과 동작 원리 등에 대해서 설명해 드리고자 합니다.

연재에서 다루는 컴포넌트와 패턴(기법)들

앞으로 주요 컴포넌트들과 이 구조를 설명해 줄 패턴들을 다룰 예정입니다.

  • Half-Sync/ Half-Async (AsyncTask) + PubSub + EventBus(RxJava/RxAdndorid)
  • Active Object (Looper - Handler, Java NIO)
  • Broker (Binder)
  • Event Channel (Intent)

위 패턴들이 처음 언급된 서적들은 Pattern Oriented Software Architecture VOL I, II입니다. 줄여서 POSA(포사) 시리즈라고 부르며, GoF의 Design Pattern만큼 큰 영향력을 발휘하는 패턴입니다. (POSA 1권은 10년 전에 제가 감역 작업에 참여를 하였으며, 절판된 상황입니다. 조만간 세상에 나올 수 있도록 준비하고 있습니다.)

다양한 통신 메커니즘을 가진 이유가 무엇인지? 조금 더 근본적으로 설명을 하는 것이고, 이 강좌의 연재를 마칠 때면 이들 간의 차이를 명확히 이해하고 판단하실 수 있을 겁니다.

안드로이드의 통신을 담당하는 구성요소로 크게 3가지 (1가지는 Deprecated)가 있습니다.
231229_-----------_01

AsycnTask는 왜 사라졌나..

231229_-----------_02

2019년 말 AsyncTask는 사형 선고를 받았습니다. 정말 사용하기 쉬웠는데요.
아키텍처적으로 보면 쉽게 이해가 될 듯합니다.
AsyncTask는 성능보다는 쉬운 사용성에 기반을 한 HSHA 패턴입니다.

Half-Sync/Half-Async (HSHA) 패턴

절반은 동기적으로, 절반은 비동기적으로 처리하는 패턴입니다.
비유하자면 프린터 출력하는 것을 생각하시면 됩니다.

231229_-----------_03

프린터는 부서에서 누구나 쓰는 공통적인 자원입니다. 나와 동료가 거의 동시에 출력을 걸었다면, 둘 다 뒤죽박죽 섞여서 출력이 되거나, 앞에 다른 동료의 출력물이 많은 경우, 출력될 때까지 아무 일도 못 하고 기다리고 있으면 답답하겠죠.

그래서 프런터 Spool(Queue)에 출력물을 보낸 다음, 내 순서가 되면 순차적으로 FIFO 형태대로 출력됩니다.

AsyncTask

AsyncTask를 HSHA 패턴을 매칭시킨 간단한 그림을 보시죠.

231229_-----------_04

UI Thread는 비동기적으로 특정한 작업(Network..)을 맡기고 콜백이 올 때까지 다른 일을 할 수 있죠. 특정한 작업은 Queue에 쌓여 순차적으로 일을 처리합니다.

하지만 ASyncTask가 사라진 몇몇 이유가 있습니다.

  • 메모리 누수: AsyncTask는 내부적으로 Activity에 대한 참조를 가질 수 있어, Activity가 종료된 후에도 AsyncTask가 완료되지 않았다면 메모리 누수가 발생할 수 있습니다.
  • 구성 변경(Configuration Changes): 시스템 또는 사용자의 특정 동작에 의해 자동으로 발생하는 것으로, 예를 들면 화면 회전, 키보드 상태 변경, 언어 변경 등이 있습니다. 화면 회전과 같은 구성 변경이 일어나면 Activity가 재생성되는데, 이 때 실행 중인 AsyncTask가 새로운 Activity 인스턴스에 영향을 미치지 못하게 됩니다.
  • 쓰레드 관리: AsyncTask는 내부적으로 쓰레드를 관리하지만, 복잡한 병렬 처리나 쓰레드 풀 설정, 작업 스케줄링 등의 세밀한 제어가 어렵습니다.
  • 결과 반환과 UI 갱신: AsyncTask는 onPostExecute() 메서드를 통해 UI 스레드에서 결과를 반환합니다. 그러나 Activity나 Fragment의 생명주기와 맞지 않을 경우 문제가 될 수 있습니다.
  • 백그라운드 실행 제약: Android Oreo (API 레벨 26) 이상에서는 백그라운드 작업에 제약이 있어, AsyncTask가 더 이상 적합하지 않을 수 있습니다.
  • 실행한 동작을 취소하기 쉽지 않다: 실행한 동작을 취소하는 기능을 직접 제공하지는 않습니다. 우회적으로 만들 수는 있으나, 효율적인 성능을 제공하지 않습니다.
    ∙ 참고: [안드로이드/Java] AsyncTask 화면 넘김시 중단하는 법, http 요청 중단하는 법

AsycnTask의 발생한 크래시 장애

 java.lang.RuntimeException: 
  at android.os.AsyncTask$4.done (AsyncTask.java:399)
  at java.util.concurrent.FutureTask.finishCompletion (FutureTask.java:383)
  at java.util.concurrent.FutureTask.setException (FutureTask.java:252)
  at java.util.concurrent.FutureTask.run (FutureTask.java:271)
  at android.os.AsyncTask$SerialExecutor$1.run (AsyncTask.java:289)
  at java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
  at java.lang.Thread.run (Thread.java:919)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 
  at com.google.zxing.pdf417.decoder.ec.ModulusGF.multiply (ModulusGF.java:105)
  at com.google.zxing.pdf417.decoder.ec.ModulusPoly.evaluateAt (ModulusPoly.java:99)
  at com.google.zxing.pdf417.decoder.ec.ErrorCorrection.decode (ErrorCorrection.java:53)
  at com.google.zxing.pdf417.decoder.PDF417ScanningDecoder.correctErrors (PDF417ScanningDecoder.java:557)
  at com.google.zxing.pdf417.decoder.PDF417ScanningDecoder.decodeCodewords (PDF417ScanningDecoder.java:530)
  at com.google.zxing.pdf417.decoder.PDF417ScanningDecoder.createDecoderResultFromAmbiguousValues (PDF417ScanningDecoder.java:309)
  at com.google.zxing.pdf417.decoder.PDF417ScanningDecoder.createDecoderResult (PDF417ScanningDecoder.java:278)
  at com.google.zxing.pdf417.decoder.PDF417ScanningDecoder.decode (PDF417ScanningDecoder.java:124)
  at com.google.zxing.pdf417.PDF417Reader.decode (PDF417Reader.java:87)
  at com.google.zxing.pdf417.PDF417Reader.decode (PDF417Reader.java:61)
  at com.google.zxing.MultiFormatReader.decodeInternal (MultiFormatReader.java:171)
  at com.google.zxing.MultiFormatReader.decodeWithState (MultiFormatReader.java:85)
  at org.reactnative.camera.tasks.BarCodeScannerAsyncTask.doInBackground (BarCodeScannerAsyncTask.java:100)
  at org.reactnative.camera.tasks.BarCodeScannerAsyncTask.doInBackground (BarCodeScannerAsyncTask.java:10)
  at android.os.AsyncTask$3.call (AsyncTask.java:378)
  at java.util.concurrent.FutureTask.run (FutureTask.java:266)

원인

배열의 인덱스 범위를 초과해서 발생한 코드입니다.
크래시는 AsyncTask에서 발생하고 있으나, 실제 문제는 ZXing 라이브러리의 PDF417 바코드 스캐너 관련 부분이며, 인덱스 범위를 초과하는 문제로 발생했습니다.

해결 방법:

  • 입력 데이터의 유효성을 검사해야 합니다.
  • AsyncTask의 비정상 종료를 안전하게 처리하는 로직을 추가하는 것이 필요합니다.

다양한 대안들의 범람

Kotlin 코루틴 (Coroutines)

RxJava

  • 메모리 누수: Disposable을 통해 명시적으로 구독을 해제함으로써 메모리 누수를 방지할 수 있습니다.
  • 구성 변경: ViewModel과 함께 사용하면 구성 변경에 더 강건합니다.
  • 쓰레드 관리: Scheduler를 통해 복잡한 쓰레드 관리와 작업 스케줄링이 가능합니다.
  • 결과 반환과 UI 갱신: Observer 패턴을 통해 UI를 유연하게 업데이트할 수 있습니다.

WorkManager

  • 백그라운드 실행 제약: 백그라운드 작업에 대한 제약을 안드로이드 버전에 관계없이 효율적으로 관리합니다.
  • 구성 변경과 생명주기: 작업은 앱의 생명주기와 무관하게 실행됩니다.

LiveData와 ViewModel

  • 메모리 누수: LiveData는 생명주기를 자동으로 관리해 메모리 누수를 방지합니다.
  • 구성 변경: ViewModel은 구성 변경 시에도 데이터를 유지할 수 있습니다.
  • 결과 반환과 UI 갱신: LiveData를 통해 데이터 변경을 UI에 자동으로 반영할 수 있습니다.

RxAndroid(RxJava)의 전략

Kotlin에서는 coroutines이라는 대안이 나왔지만, Java Android 진영에는 한동안 이를 대신할만한 대안으로 RxAndroid(RxJava의 Android Bindig)를 활용했습니다.

RxJava의 핵심코어 초기 설계 아이디어

간단히 아키텍처적으로 보면  Publisher-Subscriber (구 명칭 Observer, 줄여서 Pub-Sub) 패턴과 Event Bus 패턴의 조합으로 구성되어 있습니다.

여러분이 쉽게 개념적으로 이해하실 수 있게 그림을 보완하면 다음과 같습니다.

Pub-Sub 패턴은 게시자와 구독자로 나뉘어져 있으며, 구독자가 게시자에게 자신의 참조 정보를 전달하여, 게시자 쪽에 이벤트가 발생하면 해당 메시지를 수신자에게 전달하는 패턴입니다.  즉 상태 정보를 클라이언트가 주기적으로 Polling 하는 현상이 줄어들기 때문에 성능적으로 많은 이점이 있습니다.

거기다 Operator(사실상 EventBus + Message 제어 역할)를 하는 다양한 구성요소를 제공했습니다.

Operatos에서 다양하게 데이터를 핸들링하는 기법들(조합하고, 합병하고, 압축 등)을 제공함으로써 좀 더 유연하게 제어할 수 있는 장점이 있습니다.  메세지를 제어하는 상세한 기법은  이 글 (왜, 어떻게, 안드로이드를 위한 RxJava)을 참고해 주시길 바랍니다.  

RxAndroid를 통해서 좀 더 이벤트 기반의 효율적인 아키텍처를 가질 수 있게 되었고, 엑티비티/ 프래그먼트등의 생명주기에 따라서 쉽게 게시/구독 관계를 조정할 수 있게 되면서 ASyncTask의 많은 단점을 개선할 수 있게 되었습니다.

RxJava(Android)를 이용한 다양한 아키텍처 사례

RxAndroid3에 큰 변화가 있긴 하지만 이 부분은 다음에 다루도록 하고, RxAndroid (RxJava)  1.0, 2.0에 적용한  다양한 아키텍처 사례가 있습니다.

RxAndroid 참조아키텍처 - https://androidrepo.com/repo/tehmou-rx-android-architecture-android-app

가장 대표적으로 사용되는 아키텍처로 ViewModel을 활용하여 액티비티/프래그먼트의 생명 주기에 유연하게 대처할 수 있을 뿐만 아니라, Network로 들어온 데이터를 Pub-Sub 형태대로 CP에 넣고 영향을 받는 다른 컴포넌트에 Notify하는 형태대로 디자인 할 수 있습니다.  

좀 더 상세한 글은 여기를 참조해 주시고, 실제 소스코드는 여기를 참고해 주시길 바랍니다.

MVP 패턴 with RxJava

RxJava2 + MVP - https://medium.com/android-news/mvp-with-rxjava2-room-koin-6f9492b94500
  • MVP 구조: 앱은 MVP 패턴을 따르며, 복잡성을 줄이기 위해 API 연결과 사용 사례는 생략됩니다.
  • Room DB와 DAO: Room 데이터베이스와 데이터 액세스 오브젝트(DAO)를 생성하며, 이 과정에서 RxJava2가 활용됩니다.
  • Repository: Room이 설정된 후, QuoteRepository가 생성되며, 이 과정에서 Koin을 사용해 의존성 주입이 일어납니다.
  • Ingteractor: 비즈니스 로직을 구현하는 부분입니다. 리포지토리와 프레젠터 사이에서 작동합니다.
  • Presenter: UI 변경을 알리는 역할을 합니다. RxJava2와 LiveData를 활용하여 데이터를 UI에 반영합니다.
  • UI(보기): 사용자 인터페이스는 데이터를 표시하고, 사용자 입력에 따라 데이터를 업데이트합니다.
  • Koin을 이용한 의존성 주입: 클래스간의 의존성을 관리하기 위해 Koin을 사용합니다. AppModule 파일에 의존성 정보가 정의되어 있습니다.

좀 더 상세한 내용은 이 원문을 참고해 주시길 바랍니다. 샘플코드도 언급하고 있습니다.

MVVM 패턴

RxJava + MVVM 아키텍처 - https://medium.com/@manuelvicnt/rxjava-android-mvvm-app-structure-with-retrofit-a5605fa32c00
  • Retrofit Layer: 실제 네트워크 요청을 담당합니다.
  • APIService Layer: API 요청을 담당하며 에러 처리와 응답 파싱을 합니다.
  • RequestManager Layer: 데이터를 준비하고 다른 요청을 연결합니다. flatMap 연산자를 사용하여 다양한 요청을 연결할 수 있습니다.
  • ViewModel Layer: View가 백그라운드로 가면 요청이 취소되므로, ViewModel에서 Observable(Publisher)에 구독합니다. 그리고 Subject를 사용하여 View에 응답을 전달합니다.
  • View Layer: OnResume에서 ViewModel의 Subject에 구독하고, OnPause에서는 구독을 해제합니다.

좀더 상세한 내용과 소스 코드는 원문을 참고해 주시길 바랍니다.

RxJava(RxAndroid)는 완벽한 대안인가?

IO 처리의 성능 이슈

RxAndroid (RxJava)에서 가장 성능적인 병목은 IO입니다. 소스 코드 내부를 살펴보아도 Java NIO의 이점인 Buffer(메모리 직접 접근), Channel(File, Socket 채널) 등을 적극 활용하지 않고 있습니다.

네트워크 IO, Disk IO 같은 연산 처리 시, Blocking이 되기 때문에, 종종 네트워크 응답이 늦은 경우 Block이 되는 현상을 목격할 수 있었습니다.

국내에 몇몇 챗봇 서비스들 중 RxJava(RxAndroid)를 기반으로 한 AOS Library에서 많은 장애가 나왔습니다.

그럼, 대안은 무엇인가?

Widows가 아닌 이상 완벽하게 Block의 발생을 막을 수 없지만, 상대적으로 적게 Blocking이 일어나는 대안들이 존재합니다. Looper-Handler, Java NIO (New IO)의 근간이 되는 Active Object 패턴과 함께 내부 구조를 다음 시리즈에 언급하겠습니다.

정리하며.

이번 글에서는 AsyncTask가 왜 Deprecated될 수 밖에 없었는지?
HSHA (Half-Sync/ Half-Async) 패턴으로 내부 구조를 설명해 드렸습니다.

또한 대안으로 거론되었던, RxAdndorid(RxJava) 대해 설명해 드렸습니다.
이 기술의 기반이 되는 Publisher/ Subscriber + EventBus 패턴도 설명해 드렸습니다.

다음 글에는 더 나은 아키텍처를 가진 안드로이드의 통신 요소 Looper-Handler,
그리고 공식적으로 Android Desugaring 된 Java NIO를 설명해 드리고, 근간이 되는 아키텍처인 Active Object를 소개해 드리겠습니다.

Share on

Tags

IMQA 뉴스레터 구독하기

국내외 다양한 기술 소식을 선별하여 매월 전달해드립니다. IMQA 뉴스레터를 통해 기술 이야기를 함께해보세요.

구독하기